diff --git a/frontend/console/e2e/command-palette.ts b/frontend/console/e2e/command-palette.ts new file mode 100644 index 0000000000..c592b2b991 --- /dev/null +++ b/frontend/console/e2e/command-palette.ts @@ -0,0 +1,13 @@ +import { expect, ftlTest } from './ftl-test' + +ftlTest('shows command palette results', async ({ page }) => { + await page.goto('http://localhost:8892') + + await page.click('#command-palette-search') + await page.fill('#search-input', 'echo') + + // Command palette should be showing the echo parts + await expect(page.getByText('echo.EchoRequest')).toBeVisible() + await expect(page.getByText('echo.EchoReponse')).toBeVisible() + await expect(page.getByText('echo.echo')).toBeVisible() +}) diff --git a/frontend/console/src/features/command-pallete/CommandPalette.tsx b/frontend/console/src/features/command-pallete/CommandPalette.tsx new file mode 100644 index 0000000000..413882a4c5 --- /dev/null +++ b/frontend/console/src/features/command-pallete/CommandPalette.tsx @@ -0,0 +1,98 @@ +import { Combobox, ComboboxInput, ComboboxOption, ComboboxOptions, Dialog, DialogBackdrop, DialogPanel } from '@headlessui/react' +import { ArrowRight01Icon, CellsIcon } from 'hugeicons-react' +import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { useSchema } from '../../api/schema/use-schema' +import { type PaletteItem, paletteItems } from './command-palette.utils' + +type CommandPaletteProps = { + isOpen: boolean + onClose: () => void +} + +export const CommandPalette: React.FC = ({ isOpen, onClose }) => { + const navigate = useNavigate() + const { data: schemaData } = useSchema() + const [query, setQuery] = useState('') + const [items, setItems] = useState([]) + + useEffect(() => { + if (schemaData) { + const newItems = paletteItems(schemaData) + setItems(newItems) + } + }, [schemaData]) + + const filteredItems = query === '' ? [] : items.filter((item) => item.title.toLowerCase().includes(query.toLowerCase())) + + const handleClose = () => { + onClose() + setQuery('') + } + + if (!isOpen) return + + return ( + + + +
+ + { + if (item) { + navigate(item.url) + handleClose() + } + }} + > + setQuery(event.target.value)} + onBlur={handleClose} + /> + + {filteredItems.length > 0 && ( + + {filteredItems.map((item) => ( + +
+
+
+

{item.title}

+

{item.subtitle}

+
+
+
+
+ ))} +
+ )} + + {query !== '' && filteredItems.length === 0 && ( +
+
+ )} +
+
+
+
+ ) +} diff --git a/frontend/console/src/features/command-pallete/command-palette.utils.ts b/frontend/console/src/features/command-pallete/command-palette.utils.ts new file mode 100644 index 0000000000..138070b761 --- /dev/null +++ b/frontend/console/src/features/command-pallete/command-palette.utils.ts @@ -0,0 +1,41 @@ +import { CellsIcon, type HugeiconsProps } from 'hugeicons-react' +import type { PullSchemaResponse } from '../../protos/xyz/block/ftl/v1/ftl_pb' +import { declIcons, declUrl } from '../modules/module.utils' + +export interface PaletteItem { + id: string + icon: React.FC & React.RefAttributes> + title: string + subtitle?: string + url: string +} + +export const paletteItems = (schema: PullSchemaResponse[]): PaletteItem[] => { + const items: PaletteItem[] = [] + + for (const module of schema) { + items.push({ + id: `${module.moduleName}-module`, + icon: CellsIcon, + title: module.moduleName, + subtitle: module.moduleName, + url: `/modules/${module.moduleName}`, + }) + + for (const decl of module.schema?.decls ?? []) { + if (!decl.value || !decl.value.case || !decl.value.value) { + return [] + } + + items.push({ + id: `${module.moduleName}-${decl.value.value.name}`, + icon: declIcons[decl.value.case], + title: decl.value.value.name, + subtitle: `${module.moduleName}.${decl.value.value.name}`, + url: declUrl(module.moduleName, decl), + }) + } + } + + return items +} diff --git a/frontend/console/src/features/modules/ModulesTree.tsx b/frontend/console/src/features/modules/ModulesTree.tsx index 0778cd6690..2992d77e9e 100644 --- a/frontend/console/src/features/modules/ModulesTree.tsx +++ b/frontend/console/src/features/modules/ModulesTree.tsx @@ -1,27 +1,11 @@ import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/react' -import { - AnonymousIcon, - ArrowRight01Icon, - BubbleChatIcon, - CircleArrowRight02Icon, - CodeIcon, - DatabaseIcon, - FileExportIcon, - FlowIcon, - FunctionIcon, - type HugeiconsProps, - LeftToRightListNumberIcon, - MessageIncoming02Icon, - PackageIcon, - Settings02Icon, - SquareLock02Icon, -} from 'hugeicons-react' +import { ArrowRight01Icon, CircleArrowRight02Icon, CodeIcon, FileExportIcon, PackageIcon } from 'hugeicons-react' import { useEffect, useMemo, useRef } from 'react' import { useNavigate, useParams } from 'react-router-dom' import type { Decl } from '../../protos/xyz/block/ftl/v1/schema/schema_pb' import { classNames } from '../../utils' import type { ModuleTreeItem } from './module.utils' -import { addModuleToLocalStorageIfMissing, listExpandedModulesFromLocalStorage, toggleModuleExpansionInLocalStorage } from './module.utils' +import { addModuleToLocalStorageIfMissing, declIcons, declUrl, listExpandedModulesFromLocalStorage, toggleModuleExpansionInLocalStorage } from './module.utils' const ExportedIcon = () => ( @@ -29,20 +13,6 @@ const ExportedIcon = () => ( ) -type IconMap = Record & React.RefAttributes>> -const icons: IconMap = { - config: Settings02Icon, - data: CodeIcon, - database: DatabaseIcon, - enum: LeftToRightListNumberIcon, - fsm: FlowIcon, - topic: BubbleChatIcon, - typeAlias: AnonymousIcon, - secret: SquareLock02Icon, - subscription: MessageIncoming02Icon, - verb: FunctionIcon, -} - type WithExport = { export?: boolean } const DeclNode = ({ decl, href, isSelected }: { decl: Decl; href: string; isSelected: boolean }) => { @@ -63,7 +33,7 @@ const DeclNode = ({ decl, href, isSelected }: { decl: Decl; href: string; isSele } }, [isSelected]) - const Icon = useMemo(() => icons[decl.value.case || ''] || CodeIcon, [decl.value.case]) + const Icon = useMemo(() => declIcons[decl.value.case || ''] || CodeIcon, [decl.value.case]) return (
  • {module.decls.map((d, i) => ( - + ))} diff --git a/frontend/console/src/features/modules/module.utils.ts b/frontend/console/src/features/modules/module.utils.ts index 778b28c3ce..57088cf833 100644 --- a/frontend/console/src/features/modules/module.utils.ts +++ b/frontend/console/src/features/modules/module.utils.ts @@ -1,3 +1,16 @@ +import { + AnonymousIcon, + BubbleChatIcon, + CodeIcon, + DatabaseIcon, + FlowIcon, + FunctionIcon, + type HugeiconsProps, + LeftToRightListNumberIcon, + MessageIncoming02Icon, + Settings02Icon, + SquareLock02Icon, +} from 'hugeicons-react' import type { Module } from '../../protos/xyz/block/ftl/v1/console/console_pb' import type { PullSchemaResponse } from '../../protos/xyz/block/ftl/v1/ftl_pb' import type { Decl } from '../../protos/xyz/block/ftl/v1/schema/schema_pb' @@ -102,3 +115,19 @@ export const addModuleToLocalStorageIfMissing = (moduleName?: string) => { localStorage.setItem('tree_m', [...expanded, moduleName].join(',')) } } + +type IconMap = Record & React.RefAttributes>> +export const declIcons: IconMap = { + config: Settings02Icon, + data: CodeIcon, + database: DatabaseIcon, + enum: LeftToRightListNumberIcon, + fsm: FlowIcon, + topic: BubbleChatIcon, + typeAlias: AnonymousIcon, + secret: SquareLock02Icon, + subscription: MessageIncoming02Icon, + verb: FunctionIcon, +} + +export const declUrl = (moduleName: string, decl: Decl) => `/modules/${moduleName}/${decl.value.case}/${decl.value.value?.name}` diff --git a/frontend/console/src/layout/Layout.tsx b/frontend/console/src/layout/Layout.tsx index 6f6d135ca3..a153299389 100644 --- a/frontend/console/src/layout/Layout.tsx +++ b/frontend/console/src/layout/Layout.tsx @@ -7,7 +7,7 @@ export const Layout = () => { const version = status.data?.controllers?.[0]?.version return ( -
    +
    diff --git a/frontend/console/src/layout/navigation/Navigation.tsx b/frontend/console/src/layout/navigation/Navigation.tsx index 75791da21a..4072b5778a 100644 --- a/frontend/console/src/layout/navigation/Navigation.tsx +++ b/frontend/console/src/layout/navigation/Navigation.tsx @@ -1,7 +1,10 @@ import { CellsIcon, Database01Icon, ListViewIcon, WorkflowSquare06Icon } from 'hugeicons-react' +import { useState } from 'react' import { NavLink } from 'react-router-dom' import { DarkModeSwitch } from '../../components' +import { CommandPalette } from '../../features/command-pallete/CommandPalette' import { classNames } from '../../utils' +import { SearchInput } from './SearchInput' import { Version } from './Version' const navigation = [ @@ -12,6 +15,8 @@ const navigation = [ ] export const Navigation = ({ version }: { version?: string }) => { + const [isCommandPalleteOpen, setIsCommandPalleteOpen] = useState(false) + return (
    + setIsCommandPalleteOpen(true)} /> + setIsCommandPalleteOpen(false)} />
    -
    +
    diff --git a/frontend/console/src/layout/navigation/SearchInput.tsx b/frontend/console/src/layout/navigation/SearchInput.tsx new file mode 100644 index 0000000000..a9233188cc --- /dev/null +++ b/frontend/console/src/layout/navigation/SearchInput.tsx @@ -0,0 +1,49 @@ +import { Search01Icon } from 'hugeicons-react' +import type React from 'react' +import { useEffect } from 'react' + +type SearchInputProps = { + onFocus: () => void +} + +export const SearchInput: React.FC = ({ onFocus }) => { + const shortcutText = window.navigator.userAgent.includes('Mac') ? '⌘ + K' : 'Ctrl + K' + + useEffect(() => { + const handleKeydown = (event: KeyboardEvent) => { + if ((event.metaKey || event.ctrlKey) && event.key === 'k') { + event.preventDefault() + onFocus() + } + } + + window.addEventListener('keydown', handleKeydown) + + return () => { + window.removeEventListener('keydown', handleKeydown) + } + }, [onFocus]) + + return ( +
    +
    + + +
    +
    + ) +}