Merge pull request #35056 from overleaf/mj-command-palette-improvements

[web] Command palette improvements

GitOrigin-RevId: 3d6e11697ed4726b7ecada1744d24a15291ad646
This commit is contained in:
Mathias Jakobsen
2026-09-17 11:25:26 +00:00
committed by Copybot
parent 4ef7324263
commit 912fb2dcd8
14 changed files with 339 additions and 102 deletions
@@ -111,17 +111,17 @@ const CommandPaletteBody: FC<CommandPaletteBodyProps> = ({ show, onHide }) => {
>
<button
role="menuitem"
onMouseEnter={() => setSelectedIndex(index)}
onMouseMove={() => setSelectedIndex(index)}
onClick={() => runResult(result)}
>
<div className="command-palette-result-title">
{result.title}
{result.description && (
<span className="command-palette-result-description">
{result.description}
</span>
)}
</div>
{result.description && (
<div className="command-palette-result-description">
{result.description}
</div>
)}
</button>
</li>
))}
@@ -1,7 +1,7 @@
import useDebounce from '@/shared/hooks/use-debounce'
import useCommandPaletteSources from './use-command-palette-sources'
import { useEffect, useState } from 'react'
import { CommandPaletteSearchResult } from '../types'
import { CommandPaletteSearchResult, CommandPaletteSource } from '../types'
const useCommandPaletteResults = (query: string) => {
const debouncedQuery = useDebounce(query, 25)
@@ -9,17 +9,37 @@ const useCommandPaletteResults = (query: string) => {
const [results, setResults] = useState<CommandPaletteSearchResult[]>([])
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))
}
const res = getSourcesMatchingQuery(debouncedQuery, sources).map(
({ source, query }) =>
query ? source.search(query) : (source.defaults?.() ?? [])
)
setResults(res.flat().sort((a, b) => b.score - a.score))
}, [debouncedQuery, sources])
return results
}
const getSourcesMatchingQuery = (
query: string,
sources: CommandPaletteSource[]
): { query: string; source: CommandPaletteSource }[] => {
// No query matches all sources
if (!query) return sources.map(source => ({ source, query }))
// If the query matches a prefix, return all the sources with that prefix
const matchingSources = sources.filter(
source => source.prefix && query.startsWith(source.prefix)
)
if (matchingSources.length > 0) {
return matchingSources.map(source => ({
source,
query: query.slice(source.prefix!.length).trim(),
}))
}
// otherwise return all sources that don't require a prefix match
return sources
.filter(source => !source.prefixRequired)
.map(source => ({ source, query: query.trim() }))
}
export default useCommandPaletteResults
@@ -2,13 +2,16 @@ import { useMemo } from 'react'
import { CommandPaletteSource } from '../types'
import useCommandRegistrySource from './use-command-registry-source'
import useFileTreeCommandSource from './use-file-tree-command-source'
import { useJumpToLineCommandSource } from './use-jump-to-line-command-source'
const useCommandPaletteSources = (): CommandPaletteSource[] => {
const fileTreeSource = useFileTreeCommandSource()
const commandRegistrySource = useCommandRegistrySource()
const jumpToLineSource = useJumpToLineCommandSource()
const sources = useMemo(
() => [fileTreeSource, commandRegistrySource],
[fileTreeSource, commandRegistrySource]
() => [fileTreeSource, commandRegistrySource, jumpToLineSource],
[fileTreeSource, commandRegistrySource, jumpToLineSource]
)
return sources
}
@@ -5,42 +5,85 @@ import {
Command,
useCommandRegistry,
} from '@/features/ide-react/context/command-registry-context'
import { useTranslation } from 'react-i18next'
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',
'synctex-sync-to-pdf',
'synctex-sync-to-code',
'insert-inline-math',
'insert-display-math',
'insert-figure-from-computer',
'insert-figure-from-project-files',
'insert-figure-from-another-project',
'insert-figure-from-url',
'insert-table',
'insert-citation',
'insert-link',
'insert-cross-reference',
]
type CommandGroup = {
label: string
actions: string[]
}
const useCommandRegistrySource = (): CommandPaletteSource => {
const { t } = useTranslation()
const elements: (CommandGroup | string)[] = useMemo(
() => [
{
label: t('file'),
actions: ['new_file', 'new_folder', 'upload_file'],
},
{
label: t('view'),
actions: [
'change-layout-side-by-side',
'change-layout-editor-only',
'change-layout-pdf-only',
'change-layout-detached-pdf',
'change-layout-focus-mode',
],
},
{
label: t('insert'),
actions: [
'insert-inline-math',
'insert-display-math',
'insert-figure-from-computer',
'insert-figure-from-project-files',
'insert-figure-from-another-project',
'insert-figure-from-url',
'insert-table',
'insert-citation',
'insert-link',
'insert-cross-reference',
],
},
'open-settings',
'show_version_history',
'word_count',
'view-pdf-presentation-mode',
'comment',
'compile',
'stop-compile',
'recompile-from-scratch',
'synctex-sync-to-pdf',
'synctex-sync-to-code',
'close-tab',
'close-other-tabs',
],
[t]
)
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 ret: Command[] = []
for (const element of elements) {
if (typeof element === 'string') {
const command = registry.get(element)
if (command && !command.disabled && command.handler) {
ret.push(command)
}
} else {
for (const action of element.actions) {
const command = registry.get(action)
if (command && !command.disabled && command.handler) {
ret.push({
...command,
label: `${element.label}: ${command.label}`,
})
}
}
}
}
return ret
}, [registry, elements])
const defaults = useCallback((): CommandPaletteSearchResult[] => {
return commands.map(command => ({
@@ -53,7 +96,7 @@ const useCommandRegistrySource = (): CommandPaletteSource => {
const index = useMemo(() => {
const miniSearch = new MiniSearch<Command>({
fields: ['label'],
storeFields: ['id'],
storeFields: ['id', 'label'],
idField: 'id',
})
miniSearch.addAll(commands)
@@ -63,17 +106,18 @@ const useCommandRegistrySource = (): CommandPaletteSource => {
return useMemo<CommandPaletteSource>(
() => ({
id: 'command-registry',
prefix: '>',
search(query) {
const results = index.search(query, {
prefix: true,
fuzzy: term => (term.length > 3 ? 0.2 : false),
})
return results.flatMap(({ id, score }) => {
return results.flatMap(({ id, label, score }) => {
const command = registry.get(id)
if (!command?.handler) return []
return [
{
title: command.label,
title: label,
onSelect: () => command.handler!({ location: 'command-palette' }),
score,
},
@@ -9,9 +9,12 @@ import { useEditorManagerContext } from '@/features/ide-react/context/editor-man
type FlatFileTree = { path: string; name: string; id: string }[]
const FILE_POSITION_REGEX = /:(\d+)(?:,(\d+))?$/
const useFileTreeCommandSource = (): CommandPaletteSource => {
const { fileTreeData } = useFileTreeData()
const { openDocWithId, openFileWithId } = useEditorManagerContext()
const flatFileTree = useMemo(
() => flattenFileTree(fileTreeData),
[fileTreeData]
@@ -22,7 +25,11 @@ const useFileTreeCommandSource = (): CommandPaletteSource => {
)
const onSelect = useCallback(
async (id: string) => {
async (
id: string,
line: number | undefined,
column: number | undefined
) => {
if (!fileTreeData) {
return
}
@@ -31,7 +38,10 @@ const useFileTreeCommandSource = (): CommandPaletteSource => {
return
}
if (file.type === 'doc') {
await openDocWithId(file.entity._id)
await openDocWithId(file.entity._id, {
gotoLine: line,
gotoColumn: column,
})
} else if (file.type === 'fileRef') {
openFileWithId(file.entity._id)
} else {
@@ -49,8 +59,9 @@ const useFileTreeCommandSource = (): CommandPaletteSource => {
.slice(0, 10)
.map(({ path, name, id }) => ({
title: name,
description: path === name ? undefined : path,
onSelect: () => onSelect(id),
description:
path === name ? undefined : path.split('/').slice(0, -1).join('/'),
onSelect: () => onSelect(id, undefined, undefined),
score: 1,
}))
@@ -64,14 +75,27 @@ const useFileTreeCommandSource = (): CommandPaletteSource => {
if (!index) {
return []
}
const fileLocationMatch = query.match(FILE_POSITION_REGEX)
let line: number | undefined = undefined
let column: number | undefined = undefined
if (fileLocationMatch) {
line = parseInt(fileLocationMatch[1], 10)
column = fileLocationMatch[2]
? parseInt(fileLocationMatch[2], 10)
: undefined
query = query.slice(0, fileLocationMatch.index)
}
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 === name ? undefined : path,
onSelect: () => onSelect(id),
description:
path === name ? undefined : path.split('/').slice(0, -1).join('/'),
onSelect: () => onSelect(id, line, column),
score,
}))
},
@@ -0,0 +1,43 @@
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
import { CommandPaletteSource } from '../types'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
const JUMP_REGEX = /^(\d+)(?:,(\d+))?$/
export const useJumpToLineCommandSource = (): CommandPaletteSource => {
const { jumpToLine } = useEditorManagerContext()
const { t } = useTranslation()
return useMemo(
() => ({
id: 'jump-to-line-source',
prefix: ':',
prefixRequired: true,
search: (query: string) => {
const match = query.match(JUMP_REGEX)
if (!match) {
return []
}
const line = parseInt(match[1], 10)
const column = match[2] ? parseInt(match[2], 10) : undefined
return [
{
onSelect: () => jumpToLine({ gotoLine: line, gotoColumn: column }),
title:
column != null
? t('jump_to_line_x_column_y', {
line,
column,
})
: t('jump_to_line_x', {
line,
}),
score: 1,
},
]
},
}),
[jumpToLine, t]
)
}
@@ -9,4 +9,6 @@ export type CommandPaletteSource = {
id: string
search(query: string): CommandPaletteSearchResult[]
defaults?(): CommandPaletteSearchResult[]
prefix?: string
prefixRequired?: boolean
}