diff --git a/src/Env.elm b/src/Env.elm index 15bc6dc..0901b46 100644 --- a/src/Env.elm +++ b/src/Env.elm @@ -1,6 +1,7 @@ module Env exposing (..) import Api exposing (ApiBasePath(..)) +import Browser.Navigation as Nav import Env.AppContext as AppContext exposing (AppContext) import Perspective exposing (Perspective) @@ -19,6 +20,7 @@ type alias Env = , basePath : String , apiBasePath : ApiBasePath , appContext : AppContext + , navKey : Nav.Key , perspective : Perspective } @@ -31,12 +33,13 @@ type alias Flags = } -init : Flags -> Perspective -> Env -init flags perspective = +init : Flags -> Nav.Key -> Perspective -> Env +init flags navKey perspective = { operatingSystem = operatingSystemFromString flags.operatingSystem , basePath = flags.basePath , apiBasePath = ApiBasePath flags.apiBasePath , appContext = AppContext.fromString flags.appContext + , navKey = navKey , perspective = perspective } diff --git a/src/SearchResults.elm b/src/SearchResults.elm index 7c00580..1eeb7e5 100644 --- a/src/SearchResults.elm +++ b/src/SearchResults.elm @@ -1,6 +1,7 @@ module SearchResults exposing ( Matches , SearchResults(..) + , empty , focus , focusOn , from @@ -32,6 +33,11 @@ type SearchResults a | SearchResults (Matches a) +empty : SearchResults a +empty = + Empty + + isEmpty : SearchResults a -> Bool isEmpty results = case results of diff --git a/src/UnisonLocal/App.elm b/src/UnisonLocal/App.elm index 503fa9d..da828c3 100644 --- a/src/UnisonLocal/App.elm +++ b/src/UnisonLocal/App.elm @@ -48,8 +48,7 @@ type Modal type alias Model = - { navKey : Nav.Key - , route : Route + { route : Route , codebaseTree : CodebaseTree.Model , workspace : Workspace.Model , perspectiveLanding : PerspectiveLanding.Model @@ -63,8 +62,8 @@ type alias Model = } -init : Env -> Route -> Nav.Key -> ( Model, Cmd Msg ) -init env route navKey = +init : Env -> Route -> ( Model, Cmd Msg ) +init env route = let ( workspace, workspaceCmd ) = case route of @@ -84,8 +83,7 @@ init env route navKey = |> Maybe.withDefault Cmd.none model = - { navKey = navKey - , route = route + { route = route , workspace = workspace , perspectiveLanding = PerspectiveLanding.init , codebaseTree = codebaseTree @@ -132,7 +130,7 @@ update msg ({ env } as model) = LinkClicked urlRequest -> case urlRequest of Browser.Internal url -> - ( model, Nav.pushUrl model.navKey (Url.toString url) ) + ( model, Nav.pushUrl env.navKey (Url.toString url) ) -- External links are handled via target blank and never end up -- here @@ -298,7 +296,7 @@ update msg ({ env } as model) = navigateToDefinition : Model -> Reference -> ( Model, Cmd Msg ) navigateToDefinition model ref = - ( model, Route.navigateToDefinition model.navKey model.route ref ) + ( model, Route.navigateToDefinition model.env.navKey model.route ref ) navigateToPerspective : Model -> Perspective -> ( Model, Cmd Msg ) @@ -318,7 +316,7 @@ navigateToPerspective model perspective = |> Maybe.withDefault model.route changeRouteCmd = - Route.replacePerspective model.navKey (Perspective.toParams perspective) focusedReferenceRoute + Route.replacePerspective model.env.navKey (Perspective.toParams perspective) focusedReferenceRoute in ( { model | workspace = workspace }, changeRouteCmd ) @@ -351,7 +349,7 @@ fetchPerspectiveAndCodebaseTree oldPerspective ({ env } as model) = handleWorkspaceOutMsg : Model -> Workspace.OutMsg -> ( Model, Cmd Msg ) -handleWorkspaceOutMsg model out = +handleWorkspaceOutMsg ({ env } as model) out = case out of Workspace.None -> ( model, Cmd.none ) @@ -360,10 +358,10 @@ handleWorkspaceOutMsg model out = showFinder model withinNamespace Workspace.Focused ref -> - ( model, Route.navigateToDefinition model.navKey model.route ref ) + ( model, Route.navigateToDefinition env.navKey model.route ref ) Workspace.Emptied -> - ( model, Route.navigateToCurrentPerspective model.navKey model.route ) + ( model, Route.navigateToCurrentPerspective env.navKey model.route ) Workspace.ChangePerspectiveToNamespace fqn -> fqn diff --git a/src/UnisonLocal/PreApp.elm b/src/UnisonLocal/PreApp.elm index 9c2d798..d7c415e 100644 --- a/src/UnisonLocal/PreApp.elm +++ b/src/UnisonLocal/PreApp.elm @@ -42,10 +42,10 @@ init flags url navKey = perspectiveToAppInit perspective = let env = - Env.init preEnv.flags perspective + Env.init preEnv.flags preEnv.navKey perspective ( app, cmd ) = - App.init env preEnv.route preEnv.navKey + App.init env preEnv.route in ( Initialized app, Cmd.map AppMsg cmd ) @@ -79,7 +79,7 @@ update msg model = Ok perspective -> let env = - Env.init preEnv.flags perspective + Env.init preEnv.flags preEnv.navKey perspective newRoute = perspective @@ -87,7 +87,7 @@ update msg model = |> Route.updatePerspectiveParams preEnv.route ( app, cmd ) = - App.init env newRoute preEnv.navKey + App.init env newRoute in ( Initialized app, Cmd.map AppMsg cmd ) diff --git a/src/UnisonShare/App.elm b/src/UnisonShare/App.elm index fc0a45b..11e8328 100644 --- a/src/UnisonShare/App.elm +++ b/src/UnisonShare/App.elm @@ -40,8 +40,7 @@ import Workspace.WorkspaceItems as WorkspaceItems type alias Model = - { navKey : Nav.Key - , route : Route + { route : Route , codebaseTree : CodebaseTree.Model , workspace : Workspace.Model , perspectiveLanding : PerspectiveLanding.Model @@ -56,8 +55,8 @@ type alias Model = } -init : Env -> Route -> Nav.Key -> ( Model, Cmd Msg ) -init env route navKey = +init : Env -> Route -> ( Model, Cmd Msg ) +init env route = let -- TODO: This whole thing should be route driven ( workspace, workspaceCmd ) = @@ -81,8 +80,7 @@ init env route navKey = Catalog.init env model = - { navKey = navKey - , route = route + { route = route , workspace = workspace , perspectiveLanding = PerspectiveLanding.init , codebaseTree = codebaseTree @@ -131,7 +129,7 @@ update msg ({ env } as model) = ( _, LinkClicked urlRequest ) -> case urlRequest of Browser.Internal url -> - ( model, Nav.pushUrl model.navKey (Url.toString url) ) + ( model, Nav.pushUrl env.navKey (Url.toString url) ) -- External links are handled via target blank and never end up -- here @@ -230,7 +228,7 @@ update msg ({ env } as model) = ( Route.Catalog, CatalogMsg cMsg ) -> let ( catalog, cmd ) = - Catalog.update cMsg model.catalog + Catalog.update env cMsg model.catalog in ( { model | catalog = catalog }, Cmd.map CatalogMsg cmd ) @@ -310,7 +308,7 @@ update msg ({ env } as model) = navigateToDefinition : Model -> Reference -> ( Model, Cmd Msg ) navigateToDefinition model ref = - ( model, Route.navigateToDefinition model.navKey model.route ref ) + ( model, Route.navigateToDefinition model.env.navKey model.route ref ) navigateToPerspective : Model -> Perspective -> ( Model, Cmd Msg ) @@ -330,7 +328,7 @@ navigateToPerspective model perspective = |> Maybe.withDefault model.route changeRouteCmd = - Route.replacePerspective model.navKey (Perspective.toParams perspective) focusedReferenceRoute + Route.replacePerspective model.env.navKey (Perspective.toParams perspective) focusedReferenceRoute in ( { model | workspace = workspace }, changeRouteCmd ) @@ -363,7 +361,7 @@ fetchPerspectiveAndCodebaseTree oldPerspective ({ env } as model) = handleWorkspaceOutMsg : Model -> Workspace.OutMsg -> ( Model, Cmd Msg ) -handleWorkspaceOutMsg model out = +handleWorkspaceOutMsg ({ env } as model) out = case out of Workspace.None -> ( model, Cmd.none ) @@ -372,14 +370,14 @@ handleWorkspaceOutMsg model out = showFinder model withinNamespace Workspace.Focused ref -> - ( model, Route.navigateToDefinition model.navKey model.route ref ) + ( model, Route.navigateToDefinition env.navKey model.route ref ) Workspace.Emptied -> - ( model, Route.navigateToCurrentPerspective model.navKey model.route ) + ( model, Route.navigateToCurrentPerspective env.navKey model.route ) Workspace.ChangePerspectiveToNamespace fqn -> fqn - |> Perspective.toNamespacePerspective model.env.perspective + |> Perspective.toNamespacePerspective env.perspective |> navigateToPerspective model diff --git a/src/UnisonShare/Page/Catalog.elm b/src/UnisonShare/Page/Catalog.elm index f1bdf2e..33cec9f 100644 --- a/src/UnisonShare/Page/Catalog.elm +++ b/src/UnisonShare/Page/Catalog.elm @@ -2,13 +2,17 @@ module UnisonShare.Page.Catalog exposing (..) import Api import Env exposing (Env) -import Html exposing (Html, a, div, h1, input, span, strong, text) -import Html.Attributes exposing (autofocus, class, href, placeholder) -import Html.Events exposing (onBlur, onFocus, onInput) +import Html exposing (Html, div, h1, input, strong, table, tbody, td, text, tr) +import Html.Attributes exposing (autofocus, class, classList, placeholder) +import Html.Events exposing (onBlur, onClick, onFocus, onInput) import Http +import KeyboardShortcut exposing (KeyboardShortcut(..)) +import KeyboardShortcut.Key as Key exposing (Key(..)) +import KeyboardShortcut.KeyboardEvent as KeyboardEvent exposing (KeyboardEvent) import Perspective import Project exposing (ProjectListing) import RemoteData exposing (RemoteData(..), WebData) +import SearchResults exposing (SearchResults(..)) import Task import UI import UI.Card as Card @@ -23,10 +27,23 @@ import UnisonShare.Route as Route -- MODEL +type alias SearchResult = + ( ProjectListing, String ) + + +type alias CatalogSearchResults = + SearchResults SearchResult + + +type alias CatalogSearch = + { query : String, results : CatalogSearchResults } + + type alias LoadedModel = - { query : String + { search : CatalogSearch , hasFocus : Bool , catalog : Catalog + , keyboardShortcut : KeyboardShortcut.Model } @@ -68,12 +85,14 @@ type Msg = UpdateQuery String | UpdateFocus Bool | ClearQuery - | NoOp + | SelectProject ProjectListing | FetchCatalogFinished (Result Http.Error Catalog) + | Keydown KeyboardEvent + | KeyboardShortcutMsg KeyboardShortcut.Msg -update : Msg -> Model -> ( Model, Cmd Msg ) -update msg model = +update : Env -> Msg -> Model -> ( Model, Cmd Msg ) +update env msg model = case ( msg, model ) of ( FetchCatalogFinished catalogResult, _ ) -> case catalogResult of @@ -81,21 +100,119 @@ update msg model = ( Failure e, Cmd.none ) Ok catalog -> - ( Success { query = "", hasFocus = True, catalog = catalog }, Cmd.none ) + let + initModel = + { search = { query = "", results = SearchResults.empty } + , hasFocus = True + , catalog = catalog + , keyboardShortcut = KeyboardShortcut.init env.operatingSystem + } + in + ( Success initModel, Cmd.none ) ( UpdateFocus hasFocus, Success m ) -> ( Success { m | hasFocus = hasFocus }, Cmd.none ) ( UpdateQuery query, Success m ) -> - ( Success { m | query = query }, Cmd.none ) + let + searchResults = + if String.length query < 3 then + SearchResults.empty + + else + query + |> Catalog.search m.catalog + |> SearchResults.fromList + in + ( Success { m | search = { query = query, results = searchResults } }, Cmd.none ) ( ClearQuery, Success m ) -> - ( Success { m | query = "" }, Cmd.none ) + ( Success { m | search = { query = "", results = SearchResults.empty } }, Cmd.none ) + + ( SelectProject project, Success m ) -> + ( Success m, Route.navigateToProject env.navKey project ) + + ( Keydown event, Success m ) -> + let + ( keyboardShortcut, kCmd ) = + KeyboardShortcut.collect m.keyboardShortcut event.key + + cmd = + Cmd.map KeyboardShortcutMsg kCmd + + newModel = + { m | keyboardShortcut = keyboardShortcut } + + shortcut = + KeyboardShortcut.fromKeyboardEvent m.keyboardShortcut event + in + case shortcut of + Sequence _ Escape -> + ( Success { newModel | search = { query = "", results = SearchResults.empty } }, cmd ) + + Sequence _ ArrowUp -> + let + newSearch = + mapSearch SearchResults.prev m.search + in + ( Success { newModel | search = newSearch }, cmd ) + + Sequence _ ArrowDown -> + let + newSearch = + mapSearch SearchResults.next m.search + in + ( Success { newModel | search = newSearch }, cmd ) + + Sequence _ Enter -> + case m.search.results of + Empty -> + ( Success newModel, cmd ) + + SearchResults matches -> + let + navigate = + matches + |> SearchResults.focus + |> Tuple.first + |> Route.navigateToProject env.navKey + in + ( Success newModel, Cmd.batch [ cmd, navigate ] ) + + Sequence (Just Semicolon) k -> + case Key.toNumber k of + Just n -> + let + navigate = + SearchResults.getAt (n - 1) m.search.results + |> Maybe.map Tuple.first + |> Maybe.map (Route.navigateToProject env.navKey) + |> Maybe.withDefault Cmd.none + in + ( Success newModel, Cmd.batch [ cmd, navigate ] ) + + Nothing -> + ( Success newModel, cmd ) + + _ -> + ( Success newModel, cmd ) + + ( KeyboardShortcutMsg kMsg, Success m ) -> + let + ( keyboardShortcut, cmd ) = + KeyboardShortcut.update kMsg m.keyboardShortcut + in + ( Success { m | keyboardShortcut = keyboardShortcut }, Cmd.map KeyboardShortcutMsg cmd ) _ -> ( model, Cmd.none ) +mapSearch : (CatalogSearchResults -> CatalogSearchResults) -> CatalogSearch -> CatalogSearch +mapSearch f search = + { search | results = f search.results } + + -- VIEW @@ -121,32 +238,69 @@ viewCategory ( category, projects ) = |> Card.view -viewSearchResult : ( ProjectListing, String ) -> Html msg -viewSearchResult ( project, category ) = - a - [ class "search-result", href (projectUrl project) ] - [ Project.viewProjectListing Click.Disabled project - , span [ class "category" ] [ text category ] +viewMatch : KeyboardShortcut.Model -> SearchResult -> Bool -> Maybe Key -> Html Msg +viewMatch keyboardShortcut ( project, category ) isFocused shortcut = + let + shortcutIndicator = + if isFocused then + KeyboardShortcut.view keyboardShortcut (Sequence Nothing Key.Enter) + + else + case shortcut of + Nothing -> + UI.nothing + + Just key -> + KeyboardShortcut.view keyboardShortcut (Sequence (Just Key.Semicolon) key) + in + tr + [ classList [ ( "search-result", True ), ( "focused", isFocused ) ] + , onClick (SelectProject project) + ] + [ td [ class "project-name" ] [ Project.viewProjectListing Click.Disabled project ] + , td [ class "category" ] [ text category ] + , td [] [ div [ class "shortcut" ] [ shortcutIndicator ] ] ] -viewSearchResults : LoadedModel -> Html msg -viewSearchResults model = - if String.length model.query > 3 then - let - results = - model.query - |> Catalog.search model.catalog - |> List.map viewSearchResult +indexToShortcut : Int -> Maybe Key +indexToShortcut index = + let + n = + index + 1 + in + if n > 9 then + Nothing + + else + n |> String.fromInt |> Key.fromString |> Just + +viewMatches : KeyboardShortcut.Model -> SearchResults.Matches SearchResult -> Html Msg +viewMatches keyboardShortcut matches = + let + matchItems = + matches + |> SearchResults.mapMatchesToList (\d f -> ( d, f )) + |> List.indexedMap (\i ( d, f ) -> ( d, f, indexToShortcut i )) + |> List.map (\( d, f, s ) -> viewMatch keyboardShortcut d f s) + in + table [] [ tbody [] matchItems ] + + +viewSearchResults : KeyboardShortcut.Model -> CatalogSearch -> Html Msg +viewSearchResults keyboardShortcut { query, results } = + if String.length query > 2 then + let resultsPane = - if List.isEmpty results then - [ div [ class "empty-state" ] [ text ("No matching projects found for \"" ++ model.query ++ "\"") ] ] + case results of + Empty -> + div [ class "empty-state" ] [ text ("No matching projects found for \"" ++ query ++ "\"") ] - else - results + SearchResults matches -> + viewMatches keyboardShortcut matches in - div [ class "search-results" ] resultsPane + div [ class "search-results" ] [ resultsPane ] else UI.nothing @@ -162,10 +316,17 @@ viewLoaded model = searchResults = if model.hasFocus then - viewSearchResults model + viewSearchResults model.keyboardShortcut model.search else UI.nothing + + keyboardEvent = + KeyboardEvent.on KeyboardEvent.Keydown Keydown + |> KeyboardEvent.stopPropagation + |> KeyboardEvent.preventDefaultWhen + (\evt -> List.member evt.key [ ArrowUp, ArrowDown, Semicolon ]) + |> KeyboardEvent.attach in PageLayout.HeroLayout { hero = @@ -182,7 +343,7 @@ viewLoaded model = ] , div [] [ text "Projects, libraries, documention, terms, and types" ] ] - , div [ class "catalog-search" ] + , div [ class "catalog-search", keyboardEvent ] [ div [ class "search-field" ] [ Icon.view Icon.search , input diff --git a/src/UnisonShare/PreApp.elm b/src/UnisonShare/PreApp.elm index c58d988..789354c 100644 --- a/src/UnisonShare/PreApp.elm +++ b/src/UnisonShare/PreApp.elm @@ -47,10 +47,10 @@ init flags url navKey = perspectiveToAppInit perspective = let env = - Env.init preEnv.flags perspective + Env.init preEnv.flags preEnv.navKey perspective ( app, cmd ) = - App.init env preEnv.route preEnv.navKey + App.init env preEnv.route in ( Initialized app, Cmd.map AppMsg cmd ) @@ -84,7 +84,7 @@ update msg model = Ok perspective -> let env = - Env.init preEnv.flags perspective + Env.init preEnv.flags preEnv.navKey perspective newRoute = perspective @@ -92,7 +92,7 @@ update msg model = |> Route.updatePerspectiveParams preEnv.route ( app, cmd ) = - App.init env newRoute preEnv.navKey + App.init env newRoute in ( Initialized app, Cmd.map AppMsg cmd ) diff --git a/src/UnisonShare/Route.elm b/src/UnisonShare/Route.elm index d927750..2ea2955 100644 --- a/src/UnisonShare/Route.elm +++ b/src/UnisonShare/Route.elm @@ -8,6 +8,7 @@ module UnisonShare.Route exposing , navigateToDefinition , navigateToLatestCodebase , navigateToPerspective + , navigateToProject , perspectiveParams , replacePerspective , toDefinition @@ -286,6 +287,11 @@ navigate navKey route = |> Nav.pushUrl navKey +navigateToProject : Nav.Key -> Project a -> Cmd msg +navigateToProject navKey project = + navigate navKey (forProject project) + + -- TODO: this should go away in UnisonShare diff --git a/src/css/elements/card.css b/src/css/elements/card.css index 35c6998..b9ce301 100644 --- a/src/css/elements/card.css +++ b/src/css/elements/card.css @@ -11,6 +11,6 @@ .card .card-title { color: var(--color-card-title); text-transform: uppercase; - font-size: var(--font-size-medium); + font-size: var(--font-size-small); font-weight: normal; } diff --git a/src/css/unison-share/page/catalog.css b/src/css/unison-share/page/catalog.css index 1fa9468..5e0fe35 100644 --- a/src/css/unison-share/page/catalog.css +++ b/src/css/unison-share/page/catalog.css @@ -116,35 +116,69 @@ background: var(--color-main-bg); border-top: 1px solid var(--color-gray-lighten-50); border-radius: 0 0 var(--border-radius-base) var(--border-radius-base); - display: flex; - flex-direction: column; - gap: 0.75rem; padding: 0.75rem; } -.catalog-hero .catalog-search .search-results .search-result { - display: flex; - flex-direction: row; - padding: 0.5rem 1rem; - align-items: center; +.catalog-hero .catalog-search .search-results table { + width: 100%; +} + +.catalog-hero .catalog-search .search-results .search-result td { + padding: 0.5rem 0.75rem; height: 3rem; - border-radius: var(--border-radius-base); font-size: 1rem; } -.catalog-hero .catalog-search .search-results .search-result:hover { - background: var(--color-gray-lighten-55); - text-decoration: none; +.catalog-hero .catalog-search .search-results td:first-child { + border-radius: var(--border-radius-base) 0 0 var(--border-radius-base); +} + +.catalog-hero .catalog-search .search-results td:last { + border-radius: 0 var(--border-radius-base) var(--border-radius-base) 0; +} + +.catalog-hero .catalog-search .search-results .search-result td.project-name { + width: 20em; + text-overflow: ellipsis; + overflow: hidden; } -.catalog-hero .catalog-search .search-results .search-result .category { +.catalog-hero .catalog-search .search-results .search-result td.category { color: var(--color-main-subtle-fg); - font-size: var(--font-size-medium); - margin-left: auto; - justify-self: flex-end; + font-size: var(--font-size-small); text-transform: uppercase; } +.catalog-hero .catalog-search .search-results .search-result .shortcut { + display: flex; + align-items: center; + justify-content: flex-end; +} + +.catalog-hero .catalog-search .search-results .search-result .key { + color: var(--color-modal-subtle-fg-em); + background: var(--color-modal-subtle-bg); +} + +.catalog-hero .catalog-search .search-results .search-result .key.active { + color: var(--color-modal-focus-subtle-fg); + background: var(--color-modal-focus-subtle-bg); +} + +.catalog-hero .catalog-search .search-results .search-result.focused { + background: var(--color-gray-lighten-55); +} + +.catalog-hero .catalog-search .search-results .search-result.focused .key { + color: var(--color-modal-focus-subtle-fg); + background: var(--color-modal-focus-subtle-bg); +} + +.catalog-hero .catalog-search .search-results .search-result:hover { + background: var(--color-gray-lighten-60); + text-decoration: none; +} + .catalog-hero .catalog-search .search-results .empty-state { font-size: var(--font-size-base); color: var(--color-main-subtle-fg);