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)