import { observer } from 'mobx-react-lite' import { startTransition, useCallback, useEffect, useState } from 'react' import { useKeyboardService } from '../KeyboardServiceProvider' import { PlatformedKeyboardShortcut, TOGGLE_COMMAND_PALETTE } from '@standardnotes/ui-services' import { Combobox, ComboboxGroup, ComboboxGroupLabel, ComboboxItem, ComboboxList, ComboboxProvider, Dialog, Tab, TabList, TabPanel, TabProvider, useDialogStore, useTabContext, } from '@ariakit/react' import { classNames, DecryptedItemInterface, FileItem, SmartView, SNNote, SNTag, UuidGenerator, } from '@standardnotes/snjs' import { KeyboardShortcutIndicator } from '../KeyboardShortcutIndicator/KeyboardShortcutIndicator' import { useApplication } from '../ApplicationProvider' import Icon from '../Icon/Icon' import { getIconForItem } from '../../Utils/Items/Icons/getIconForItem' import { FileItemActionType } from '../AttachedFilesPopover/PopoverFileItemAction' import type { CommandService } from './CommandService' import { requestCloseAllOpenModalsAndPopovers } from '../../Utils/CloseOpenModalsAndPopovers' type CommandPaletteItem = { id: string description: string icon: JSX.Element shortcut?: PlatformedKeyboardShortcut resultRange?: [number, number] } & ({ section: 'notes' | 'files' | 'tags'; itemUuid: string } | { section: 'commands' }) function ListItemDescription({ item }: { item: CommandPaletteItem }) { const range = item.resultRange if (!range) { return item.description } return ( <> {item.description.slice(0, range[0])} {item.description.slice(range[0], range[1])} {item.description.slice(range[1])} ) } const Tabs = ['all', 'commands', 'notes', 'files', 'tags'] as const type TabId = (typeof Tabs)[number] function CommandPaletteListItem({ id, item, index, handleClick, selectedTab, }: { id: string item: CommandPaletteItem index: number handleClick: (item: CommandPaletteItem) => void selectedTab: TabId }) { if (selectedTab !== 'all' && selectedTab !== item.section) { return null } return ( svg]:flex-shrink-0', index === 0 && 'scroll-m-8', )} onClick={() => handleClick(item)} > {item.icon}
{item.shortcut && }
) } function ComboboxInput() { const tab = useTabContext() return ( { if (event.key !== 'Tab') { return } const activeId = tab?.getState().selectedId const options = { activeId } const nextId = event.shiftKey ? tab?.previous(options) : tab?.next(options) if (nextId) { event.preventDefault() tab?.select(nextId) } }} /> ) } // Future TODO, nice to have: A way to expose items like the current note's options // directly in the command palette rather than only having a way to open the menu function CommandPalette() { const application = useApplication() const keyboardService = useKeyboardService() const [isOpen, setIsOpen] = useState(false) const [query, setQuery] = useState('') const [recents, setRecents] = useState([]) const [items, setItems] = useState([]) // Storing counts as separate state to avoid iterating items multiple times const [itemCountsPerTab, setItemCounts] = useState({ commands: 0, notes: 0, files: 0, tags: 0, }) const [selectedTab, setSelectedTab] = useState('all') const dialog = useDialogStore({ open: isOpen, setOpen: setIsOpen, }) useEffect(() => { if (isOpen) { keyboardService.disableEventHandling() requestCloseAllOpenModalsAndPopovers() } else { keyboardService.enableEventHandling() } }, [keyboardService, isOpen]) useEffect(() => { return keyboardService.addCommandHandler({ command: TOGGLE_COMMAND_PALETTE, category: 'General', description: 'Toggle command palette', onKeyDown: (e) => { e.preventDefault() setIsOpen((open) => !open) setQuery('') }, }) }, [keyboardService]) const handleItemClick = useCallback( (item: CommandPaletteItem) => { if (item.section === 'commands') { application.commands.triggerCommand(item.id) application.recents.add(item.id, 'command') } else { const decryptedItem = application.items.findItem(item.itemUuid) if (!decryptedItem) { return } if (decryptedItem instanceof SNNote) { void application.navigationController.selectHomeNavigationView() void application.itemListController.selectItemUsingInstance(decryptedItem, true) } else if (decryptedItem instanceof FileItem) { void application.filesController.handleFileAction({ type: FileItemActionType.PreviewFile, payload: { file: decryptedItem }, }) } else if (decryptedItem instanceof SNTag || decryptedItem instanceof SmartView) { void application.navigationController.setSelectedTag(decryptedItem, 'all', { userTriggered: true, }) } } }, [ application.commands, application.filesController, application.itemListController, application.items, application.navigationController, application.recents, ], ) const createItemForInteractableItem = useCallback( (item: DecryptedItemInterface): CommandPaletteItem => { const icon = getIconForItem(item, application) let section: 'notes' | 'files' | 'tags' if (item instanceof SNNote) { section = 'notes' } else if (item instanceof FileItem) { section = 'files' } else if (item instanceof SNTag || item instanceof SmartView) { section = 'tags' } else { throw new Error('Item is not a note, file or tag') } return { section, id: UuidGenerator.GenerateUuid(), itemUuid: item.uuid, description: item.title || '', icon: , } }, [application], ) const createItemForCommand = useCallback( (command: ReturnType[0]): CommandPaletteItem => { const shortcut = command.shortcut_id ? application.keyboardService.keyboardShortcutForCommand(command.shortcut_id) : undefined return { id: command.id, description: command.description, section: 'commands', icon: , shortcut, } }, [application.keyboardService], ) useEffect( function updateCommandPaletteItems() { if (!isOpen) { setSelectedTab('all') setItems([]) return } const recents: CommandPaletteItem[] = [] const items: CommandPaletteItem[] = [] const itemCounts: typeof itemCountsPerTab = { commands: 0, notes: 0, files: 0, tags: 0, } const searchQuery = query.toLowerCase() const hasQuery = searchQuery.length > 0 if (hasQuery) { const commands = application.commands.getCommandDescriptions() for (let i = 0; i < commands.length; i++) { const command = commands[i] if (!command) { continue } if (items.length >= 50) { break } const index = command.description.toLowerCase().indexOf(searchQuery) if (index === -1) { continue } const item = createItemForCommand(command) item.resultRange = [index, index + searchQuery.length] items.push(item) itemCounts[item.section]++ } const interactableItems = application.items.getInteractableItems() for (let i = 0; i < interactableItems.length; i++) { if (items.length >= 50) { break } const decryptedItem = interactableItems[i] if (!decryptedItem || !decryptedItem.title) { continue } const index = decryptedItem.title.toLowerCase().indexOf(searchQuery) if (index === -1) { continue } const item = createItemForInteractableItem(decryptedItem) item.resultRange = [index, index + searchQuery.length] items.push(item) itemCounts[item.section]++ } } else { const recentCommands = application.recents.commandUuids for (let i = 0; i < recentCommands.length; i++) { const command = application.commands.getCommandDescription(recentCommands[i]) if (!command) { continue } const item = createItemForCommand(command) recents.push(item) itemCounts[item.section]++ } const recentItems = application.recents.itemUuids for (let i = 0; i < recentItems.length; i++) { const decryptedItem = application.items.findItem(recentItems[i]) if (!decryptedItem) { continue } const item = createItemForInteractableItem(decryptedItem) recents.push(item) itemCounts[item.section]++ } const commands = application.commands.getCommandDescriptions() for (let i = 0; i < commands.length; i++) { const command = commands[i] if (!command) { continue } const item = createItemForCommand(command) items.push(item) itemCounts[item.section]++ } items.sort((a, b) => (a.description.toLowerCase() < b.description.toLowerCase() ? -1 : 1)) } setItems(items) setRecents(recents) setItemCounts(itemCounts) }, [application, createItemForCommand, createItemForInteractableItem, isOpen, query], ) const hasNoItemsAtAll = items.length === 0 const hasNoItemsInSelectedTab = selectedTab !== 'all' && itemCountsPerTab[selectedTab] === 0 return ( } > { startTransition(() => setQuery(value)) }} > setSelectedTab((id as TabId) || 'all')}>
{Tabs.map((id) => ( {id} ))} {query.length > 0 && (hasNoItemsAtAll || hasNoItemsInSelectedTab) && (
No items found
)} {recents.length > 0 && ( Recent {recents.map((item, index) => ( ))} )} {!hasNoItemsAtAll && ( {recents.length > 0 && ( All commands )} {items.map((item, index) => ( ))} )}
) } export default observer(CommandPalette)