From eddec90cb170b806e65fdba39ab4a24b5e10387e Mon Sep 17 00:00:00 2001 From: Mathias Jakobsen Date: Wed, 20 May 2026 15:35:59 +0100 Subject: [PATCH] Merge pull request #33649 from overleaf/mj-command-palette [web] Add command palette GitOrigin-RevId: 5bf1903836810ca5f0e2bc7f6c00a4b1da797ea2 --- .../Features/Project/ProjectController.mjs | 1 + .../components/command-palette-body.tsx | 133 ++++++++++++++++++ .../components/command-palette-root.tsx | 39 +++++ .../components/command-palette.tsx | 11 ++ .../hooks/use-command-palette-results.ts | 25 ++++ .../hooks/use-command-palette-sources.ts | 16 +++ .../hooks/use-command-palette-triggers.ts | 18 +++ .../hooks/use-command-registry-source.tsx | 77 ++++++++++ .../hooks/use-file-tree-command-source.ts | 127 +++++++++++++++++ .../js/features/command-palette/types.ts | 12 ++ .../ide-react/components/layout/ide-page.tsx | 3 + .../components/pdf-compile-button.tsx | 31 +++- .../web/frontend/stylesheets/pages/all.scss | 1 + .../pages/editor/command-palette.scss | 82 +++++++++++ 14 files changed, 573 insertions(+), 3 deletions(-) create mode 100644 services/web/frontend/js/features/command-palette/components/command-palette-body.tsx create mode 100644 services/web/frontend/js/features/command-palette/components/command-palette-root.tsx create mode 100644 services/web/frontend/js/features/command-palette/components/command-palette.tsx create mode 100644 services/web/frontend/js/features/command-palette/hooks/use-command-palette-results.ts create mode 100644 services/web/frontend/js/features/command-palette/hooks/use-command-palette-sources.ts create mode 100644 services/web/frontend/js/features/command-palette/hooks/use-command-palette-triggers.ts create mode 100644 services/web/frontend/js/features/command-palette/hooks/use-command-registry-source.tsx create mode 100644 services/web/frontend/js/features/command-palette/hooks/use-file-tree-command-source.ts create mode 100644 services/web/frontend/js/features/command-palette/types.ts create mode 100644 services/web/frontend/stylesheets/pages/editor/command-palette.scss diff --git a/services/web/app/src/Features/Project/ProjectController.mjs b/services/web/app/src/Features/Project/ProjectController.mjs index 82a135dcca..360972fd5f 100644 --- a/services/web/app/src/Features/Project/ProjectController.mjs +++ b/services/web/app/src/Features/Project/ProjectController.mjs @@ -484,6 +484,7 @@ const _ProjectController = { 'export-docx', 'sharing-updates', 'export-markdown', + 'command-palette', ].filter(Boolean) const getUserValues = async userId => diff --git a/services/web/frontend/js/features/command-palette/components/command-palette-body.tsx b/services/web/frontend/js/features/command-palette/components/command-palette-body.tsx new file mode 100644 index 0000000000..5b6a898476 --- /dev/null +++ b/services/web/frontend/js/features/command-palette/components/command-palette-body.tsx @@ -0,0 +1,133 @@ +import { + FC, + KeyboardEvent, + memo, + useCallback, + useEffect, + useRef, + useState, +} from 'react' +import { CommandPaletteSearchResult } from '../types' +import { OLModal, OLModalBody } from '@/shared/components/ol/ol-modal' +import classNames from 'classnames' +import useEventListener from '@/shared/hooks/use-event-listener' +import useCommandPaletteResults from '../hooks/use-command-palette-results' +import { debugConsole } from '@/utils/debugging' + +type CommandPaletteBodyProps = { + show: boolean + onHide(): void +} + +const CommandPaletteBody: FC = ({ show, onHide }) => { + const [query, setQuery] = useState('') + const [selectedIndex, setSelectedIndex] = useState(0) + const resultsRef = useRef(null) + const results = useCommandPaletteResults(query) + + useEffect(() => { + setSelectedIndex(0) + }, [results]) + + useEffect(() => { + const el = resultsRef.current?.children[selectedIndex] as + | HTMLElement + | undefined + el?.scrollIntoView({ block: 'nearest' }) + }, [selectedIndex]) + + const runResult = (result: CommandPaletteSearchResult) => { + try { + const res = result.onSelect(result) + if (res instanceof Promise) { + res.catch(err => { + debugConsole.error('Command palette command error', err) + }) + } + } catch (err) { + debugConsole.error('Command palette command error', err) + } finally { + onHide() + } + } + + const onKeyDown = (event: KeyboardEvent) => { + if (results.length === 0) return + if (event.key === 'ArrowDown') { + event.preventDefault() + setSelectedIndex(i => (i + 1) % results.length) + } else if (event.key === 'ArrowUp') { + event.preventDefault() + setSelectedIndex(i => (i - 1 + results.length) % results.length) + } else if (event.key === 'Enter') { + event.preventDefault() + const selected = results[selectedIndex] + if (selected) runResult(selected) + } + } + + useEventListener( + 'mousedown', + useCallback( + (event: MouseEvent) => { + if (!show) return + const target = event.target as Element | null + if (target?.closest('.command-palette .modal-dialog')) return + onHide() + }, + [show, onHide] + ) + ) + + return ( + + + setQuery(e.target.value)} + onKeyDown={onKeyDown} + placeholder="Type a command..." + aria-label="Command palette search" + className="command-palette-input" + /> +
    + {results.map((result, index) => ( +
  • + +
  • + ))} +
+
+
+ ) +} + +export default memo(CommandPaletteBody) diff --git a/services/web/frontend/js/features/command-palette/components/command-palette-root.tsx b/services/web/frontend/js/features/command-palette/components/command-palette-root.tsx new file mode 100644 index 0000000000..e6a8e5c040 --- /dev/null +++ b/services/web/frontend/js/features/command-palette/components/command-palette-root.tsx @@ -0,0 +1,39 @@ +import { + Dispatch, + SetStateAction, + useCallback, + useEffect, + useState, +} from 'react' +import useCommandPaletteTriggers from '../hooks/use-command-palette-triggers' +import CommandPaletteBody from './command-palette-body' +import { useLayoutContext } from '@/shared/context/layout-context' + +const CommandPaletteRoot = () => { + const [show, _setShow] = useState(false) + const { view } = useLayoutContext() + + const setShow: Dispatch> = useCallback( + value => { + _setShow(view === 'history' ? false : value) + }, + [view] + ) + + useEffect(() => { + if (view === 'history') { + _setShow(false) + } + }, [view]) + + useCommandPaletteTriggers(setShow) + const onHide = useCallback(() => setShow(false), [setShow]) + + if (!show) { + return null + } + + return +} + +export default CommandPaletteRoot diff --git a/services/web/frontend/js/features/command-palette/components/command-palette.tsx b/services/web/frontend/js/features/command-palette/components/command-palette.tsx new file mode 100644 index 0000000000..60bcae6174 --- /dev/null +++ b/services/web/frontend/js/features/command-palette/components/command-palette.tsx @@ -0,0 +1,11 @@ +import { lazy, Suspense } from 'react' + +const CommandPaletteRoot = lazy(() => import('./command-palette-root')) + +export default function CommandPalette() { + return ( + + + + ) +} diff --git a/services/web/frontend/js/features/command-palette/hooks/use-command-palette-results.ts b/services/web/frontend/js/features/command-palette/hooks/use-command-palette-results.ts new file mode 100644 index 0000000000..a976f870a7 --- /dev/null +++ b/services/web/frontend/js/features/command-palette/hooks/use-command-palette-results.ts @@ -0,0 +1,25 @@ +import useDebounce from '@/shared/hooks/use-debounce' +import useCommandPaletteSources from './use-command-palette-sources' +import { useEffect, useState } from 'react' +import { CommandPaletteSearchResult } from '../types' + +const useCommandPaletteResults = (query: string) => { + const debouncedQuery = useDebounce(query, 25) + const sources = useCommandPaletteSources() + const [results, setResults] = useState([]) + + useEffect(() => { + if (debouncedQuery) { + const res = sources.map(source => source.search(debouncedQuery)) + setResults(res.flat().sort((a, b) => b.score - a.score)) + } else { + const res = sources + .filter(s => 'defaults' in s) + .map(source => source.defaults!()) + setResults(res.flat().sort((a, b) => b.score - a.score)) + } + }, [debouncedQuery, sources]) + return results +} + +export default useCommandPaletteResults diff --git a/services/web/frontend/js/features/command-palette/hooks/use-command-palette-sources.ts b/services/web/frontend/js/features/command-palette/hooks/use-command-palette-sources.ts new file mode 100644 index 0000000000..803009209b --- /dev/null +++ b/services/web/frontend/js/features/command-palette/hooks/use-command-palette-sources.ts @@ -0,0 +1,16 @@ +import { useMemo } from 'react' +import { CommandPaletteSource } from '../types' +import useCommandRegistrySource from './use-command-registry-source' +import useFileTreeCommandSource from './use-file-tree-command-source' + +const useCommandPaletteSources = (): CommandPaletteSource[] => { + const fileTreeSource = useFileTreeCommandSource() + const commandRegistrySource = useCommandRegistrySource() + const sources = useMemo( + () => [fileTreeSource, commandRegistrySource], + [fileTreeSource, commandRegistrySource] + ) + return sources +} + +export default useCommandPaletteSources diff --git a/services/web/frontend/js/features/command-palette/hooks/use-command-palette-triggers.ts b/services/web/frontend/js/features/command-palette/hooks/use-command-palette-triggers.ts new file mode 100644 index 0000000000..51198f9132 --- /dev/null +++ b/services/web/frontend/js/features/command-palette/hooks/use-command-palette-triggers.ts @@ -0,0 +1,18 @@ +import useEventListener from '@/shared/hooks/use-event-listener' +import { Dispatch, SetStateAction, useCallback } from 'react' + +const useCommandPaletteTriggers = (show: Dispatch>) => { + const onKeyDown = useCallback( + (event: KeyboardEvent) => { + if ((event.ctrlKey || event.metaKey) && event.code === 'KeyP') { + event.preventDefault() + show(prev => !prev) + } + }, + [show] + ) + + useEventListener('keydown', onKeyDown) +} + +export default useCommandPaletteTriggers diff --git a/services/web/frontend/js/features/command-palette/hooks/use-command-registry-source.tsx b/services/web/frontend/js/features/command-palette/hooks/use-command-registry-source.tsx new file mode 100644 index 0000000000..4744681442 --- /dev/null +++ b/services/web/frontend/js/features/command-palette/hooks/use-command-registry-source.tsx @@ -0,0 +1,77 @@ +import { useCallback, useMemo } from 'react' +import MiniSearch from 'minisearch' +import { CommandPaletteSearchResult, CommandPaletteSource } from '../types' +import { + Command, + useCommandRegistry, +} from '@/features/ide-react/context/command-registry-context' + +const ENABLED_COMMANDS: string[] = [ + 'new_file', + 'new_folder', + 'upload_file', + 'open-settings', + 'show_version_history', + 'word_count', + 'view-pdf-presentation-mode', + 'comment', + 'compile', + 'stop-compile', + 'recompile-from-scratch', +] + +const useCommandRegistrySource = (): CommandPaletteSource => { + const { registry } = useCommandRegistry() + + const commands = useMemo(() => { + const enabled = new Set(ENABLED_COMMANDS) + return [...registry.values()].filter( + c => enabled.has(c.id) && !c.disabled && c.handler + ) + }, [registry]) + + const defaults = useCallback((): CommandPaletteSearchResult[] => { + return commands.map(command => ({ + title: command.label, + onSelect: () => command.handler!({ location: 'command-palette' }), + score: 1, + })) + }, [commands]) + + const index = useMemo(() => { + const miniSearch = new MiniSearch({ + fields: ['label'], + storeFields: ['id'], + idField: 'id', + }) + miniSearch.addAll(commands) + return miniSearch + }, [commands]) + + return useMemo( + () => ({ + id: 'command-registry', + search(query) { + const results = index.search(query, { + prefix: true, + fuzzy: term => (term.length > 3 ? 0.2 : false), + }) + return results.flatMap(({ id, score }) => { + const command = registry.get(id) + if (!command?.handler) return [] + return [ + { + title: command.label, + onSelect: () => command.handler!({ location: 'command-palette' }), + score, + }, + ] + }) + }, + defaults, + }), + [index, registry, defaults] + ) +} + +export default useCommandRegistrySource diff --git a/services/web/frontend/js/features/command-palette/hooks/use-file-tree-command-source.ts b/services/web/frontend/js/features/command-palette/hooks/use-file-tree-command-source.ts new file mode 100644 index 0000000000..eb848ad009 --- /dev/null +++ b/services/web/frontend/js/features/command-palette/hooks/use-file-tree-command-source.ts @@ -0,0 +1,127 @@ +import { useCallback, useMemo } from 'react' +import { CommandPaletteSearchResult, CommandPaletteSource } from '../types' +import { useFileTreeData } from '@/shared/context/file-tree-data-context' +import { Folder } from '@ol-types/folder' +import MiniSearch from 'minisearch' +import { findInTree } from '@/features/file-tree/util/find-in-tree' +import { debugConsole } from '@/utils/debugging' +import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context' + +type FlatFileTree = { path: string; name: string; id: string }[] + +const useFileTreeCommandSource = (): CommandPaletteSource => { + const { fileTreeData } = useFileTreeData() + const { openDocWithId, openFileWithId } = useEditorManagerContext() + const flatFileTree = useMemo( + () => flattenFileTree(fileTreeData), + [fileTreeData] + ) + const index = useMemo( + () => flatFileTree && indexFromFlatFileTree(flatFileTree), + [flatFileTree] + ) + + const onSelect = useCallback( + async (id: string) => { + if (!fileTreeData) { + return + } + const file = findInTree(fileTreeData, id) + if (!file) { + return + } + if (file.type === 'doc') { + await openDocWithId(file.entity._id) + } else if (file.type === 'fileRef') { + openFileWithId(file.entity._id) + } else { + debugConsole.error('Attempting to open invalid entity type') + } + }, + [fileTreeData, openDocWithId, openFileWithId] + ) + + const defaults = useCallback((): CommandPaletteSearchResult[] => { + if (!flatFileTree) { + return [] + } + const files: CommandPaletteSearchResult[] = flatFileTree + .slice(0, 10) + .map(({ path, name, id }) => ({ + title: name, + description: path, + onSelect: () => onSelect(id), + score: 1, + })) + + return files + }, [flatFileTree, onSelect]) + + const source: CommandPaletteSource = useMemo( + () => ({ + id: 'file-tree', + search(query) { + if (!index) { + return [] + } + const result = index.search(query, { + prefix: true, + fuzzy: term => (term.length > 3 ? 0.2 : false), + }) + return result.map(({ path, name, id, score }) => ({ + title: name, + description: path, + onSelect: () => onSelect(id), + score, + })) + }, + defaults, + }), + [index, onSelect, defaults] + ) + return source +} + +export default useFileTreeCommandSource + +const flattenFileTree = ( + folder: Folder, + parentPath = '' +): FlatFileTree | null => { + if (!folder) { + return null + } + const currentPath = `${parentPath}/${folder.name}` + const files = folder.fileRefs.map(file => ({ + path: `${currentPath}/${file.name}`.replace(/^\/rootFolder\//, ''), + name: file.name, + id: file._id, + })) + + const docs = folder.docs.map(doc => ({ + path: `${currentPath}/${doc.name}`.replace(/^\/rootFolder\//, ''), + name: doc.name, + id: doc._id, + })) + + const subFolders = folder.folders + .flatMap(subFolder => flattenFileTree(subFolder, currentPath)) + .filter(x => x !== null) + + return [...docs, ...files, ...subFolders] +} + +function indexFromFlatFileTree(flatFileTree: FlatFileTree): MiniSearch { + const miniSearch = new MiniSearch({ + fields: ['path', 'name'], + storeFields: ['path', 'name'], + }) + + if (!flatFileTree) { + return miniSearch + } + + miniSearch.addAll(flatFileTree) + + return miniSearch +} diff --git a/services/web/frontend/js/features/command-palette/types.ts b/services/web/frontend/js/features/command-palette/types.ts new file mode 100644 index 0000000000..4aeefc4cf8 --- /dev/null +++ b/services/web/frontend/js/features/command-palette/types.ts @@ -0,0 +1,12 @@ +export type CommandPaletteSearchResult = { + title: string + description?: string + onSelect(self: CommandPaletteSearchResult): void | Promise + score: number +} + +export type CommandPaletteSource = { + id: string + search(query: string): CommandPaletteSearchResult[] + defaults?(): CommandPaletteSearchResult[] +} diff --git a/services/web/frontend/js/features/ide-react/components/layout/ide-page.tsx b/services/web/frontend/js/features/ide-react/components/layout/ide-page.tsx index 8293f293a4..c152841f6f 100644 --- a/services/web/frontend/js/features/ide-react/components/layout/ide-page.tsx +++ b/services/web/frontend/js/features/ide-react/components/layout/ide-page.tsx @@ -14,6 +14,7 @@ import useThemedPage from '@/shared/hooks/use-themed-page' import MainLayout from '@/features/ide-react/components/layout/main-layout' import SettingsModalNew from '@/features/settings/components/settings-modal' +import CommandPalette from '@/features/command-palette/components/command-palette' export default function IdePage() { useLayoutEventTracking() // sent event when the layout changes @@ -25,6 +26,7 @@ export default function IdePage() { useThemedPage() // set the page theme based on user settings const showEditorSurvey = useFeatureFlag('editor-popup-ux-survey-03-2026') + const showCommandPalette = useFeatureFlag('command-palette') return ( @@ -33,6 +35,7 @@ export default function IdePage() { + {showCommandPalette && } {showEditorSurvey && } ) diff --git a/services/web/frontend/js/features/pdf-preview/components/pdf-compile-button.tsx b/services/web/frontend/js/features/pdf-preview/components/pdf-compile-button.tsx index 188a4e1530..564715cd46 100644 --- a/services/web/frontend/js/features/pdf-preview/components/pdf-compile-button.tsx +++ b/services/web/frontend/js/features/pdf-preview/components/pdf-compile-button.tsx @@ -1,5 +1,5 @@ import { useTranslation } from 'react-i18next' -import { memo } from 'react' +import { memo, useCallback } from 'react' import classNames from 'classnames' import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context' import { useStopOnFirstError } from '../../../shared/hooks/use-stop-on-first-error' @@ -17,6 +17,7 @@ import { import OLButton from '@/shared/components/ol/ol-button' import OLButtonGroup from '@/shared/components/ol/ol-button-group' import { useLayoutContext } from '@/shared/context/layout-context' +import { useCommandProvider } from '@/features/ide-react/hooks/use-command-provider' const modifierKey = /Mac/i.test(navigator.platform) ? 'Cmd' : 'Ctrl' @@ -55,12 +56,12 @@ function PdfCompileButton() { const { detachRole } = useLayoutContext() - const fromScratchWithEvent = () => { + const fromScratchWithEvent = useCallback(() => { eventTracking.sendMB('recompile-setting-changed', { setting: 'from-scratch', }) recompileFromScratch() - } + }, [recompileFromScratch]) const tooltipElement = ( <> @@ -87,6 +88,30 @@ function PdfCompileButton() { } ) + useCommandProvider( + () => [ + { + id: 'compile', + handler: () => startCompile(), + label: t('recompile'), + disabled: compiling, + }, + { + id: 'stop-compile', + handler: () => stopCompile(), + label: t('stop_compile'), + disabled: !compiling, + }, + { + id: 'recompile-from-scratch', + handler: fromScratchWithEvent, + label: t('recompile_from_scratch'), + disabled: compiling, + }, + ], + [startCompile, t, compiling, stopCompile, fromScratchWithEvent] + ) + return (