diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 4827f5208e..dbc74c8a56 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -1067,6 +1067,7 @@ "join_x_managed_enterprise_group": "", "joined_team": "", "joining": "", + "jump_to_command_definition": "", "jump_to_line_x": "", "jump_to_line_x_column_y": "", "jump_to_location_in_pdf": "", diff --git a/services/web/frontend/js/features/source-editor/hooks/use-command-definitions.ts b/services/web/frontend/js/features/source-editor/hooks/use-command-definitions.ts new file mode 100644 index 0000000000..f43715af60 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/hooks/use-command-definitions.ts @@ -0,0 +1,94 @@ +import { useEffect, useState } from 'react' +import { EditorState } from '@codemirror/state' +import { SyntaxNode } from '@lezer/common' +import { syntaxTree } from '@codemirror/language' +import { useProjectContext } from '@/shared/context/project-context' +import { debugConsole } from '@/utils/debugging' +import { CommandDefinition } from '../utils/command-definitions/command-definition' +import { getCommandDefinitionIndexer } from '../utils/command-definitions/command-definition-indexer' +import { useCodeMirrorStateContext } from '../components/codemirror-context' +import { contextMenuStateField } from '../extensions/context-menu' + +/** + * Look up where `name` is defined in the project, by parsing the snapshot in a + * worker. Intended to be used while the editor context menu is open: the lookup + * runs off the main thread, so opening the menu is never blocked, and the item + * is disabled until the worker responds. + * + * Pass `name` null when the menu was not opened on a command, to avoid + * refreshing the snapshot (a project-history flush) and parsing on every + * right-click. `lookupKey` should change on each open (e.g. the tooltip object) + * so the lookup reruns even if the menu component stays mounted across reopens. + */ +function useCommandDefinition(name: string | null, lookupKey: unknown) { + const { projectSnapshot } = useProjectContext() + + const [definition, setDefinition] = useState(null) + + useEffect(() => { + // Reset while the new lookup runs so a stale definition isn't shown. + setDefinition(null) + if (!name) { + return + } + let cancelled = false + ;(async () => { + try { + await projectSnapshot.refresh() + if (cancelled) { + return + } + const found = await getCommandDefinitionIndexer().findDefinition( + projectSnapshot, + name + ) + if (!cancelled) { + setDefinition(found) + } + } catch (error) { + debugConsole.error('Failed to find command definition', error) + } + })() + return () => { + cancelled = true + } + }, [projectSnapshot, name, lookupKey]) + + return definition +} + +/** + * Resolve the command the editor context menu was opened on and its project-wide + * definition (if any). Returns nulls when the menu was not opened on a command; + * the lookup only runs when the menu was opened on a command. + */ +export function useContextMenuCommandDefinition() { + const state = useCodeMirrorStateContext() + // The tooltip is a new object on each open, so use it as the lookup key. + const tooltip = state.field(contextMenuStateField, false)?.tooltip ?? null + const pos = tooltip?.pos ?? null + const commandName = pos != null ? commandNameAtPos(state, pos) : null + const commandDefinition = useCommandDefinition(commandName, tooltip) + return { commandName, commandDefinition } +} + +/** + * Resolve the control sequence (e.g. `\mycmd`) at a document position, or null + * if the position is not on a command. Walks up from the innermost node to the + * nearest node carrying a control-sequence token, mirroring the command-usage + * logic in tree-operations/commands.ts. + */ +function commandNameAtPos(state: EditorState, pos: number): string | null { + const tree = syntaxTree(state) + // Bias right (as resolveCommandNode does) so a click on the command's first + // character resolves into it rather than the preceding token. + let node: SyntaxNode | null = tree.resolveInner(pos, 1) + for (; node; node = node.parent) { + const ctrlSeq = node.getChild('$CtrlSeq') + if (ctrlSeq && !ctrlSeq.type.is('$CtrlSym')) { + const name = state.sliceDoc(ctrlSeq.from, ctrlSeq.to) + return name.length > 0 ? name : null + } + } + return null +} diff --git a/services/web/frontend/js/features/source-editor/hooks/use-context-menu-items.tsx b/services/web/frontend/js/features/source-editor/hooks/use-context-menu-items.tsx index c1a3bf56d5..612f283965 100644 --- a/services/web/frontend/js/features/source-editor/hooks/use-context-menu-items.tsx +++ b/services/web/frontend/js/features/source-editor/hooks/use-context-menu-items.tsx @@ -16,6 +16,9 @@ import { useCommandRegistry, } from '@/features/ide-react/context/command-registry-context' import { closeContextMenuEffect } from '../extensions/context-menu' +import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context' +import { useFileTreePathContext } from '@/features/file-tree/contexts/file-tree-path' +import { useContextMenuCommandDefinition } from './use-command-definitions' import * as commands from '../extensions/toolbar/commands' import { cutSelection, @@ -58,6 +61,9 @@ export const useContextMenuItems = () => { const isReview = trackingChangesMode === 'review' const { changesInSelection, acceptChangesHandler, rejectChangesHandler } = useTrackedChangesActions() + const { openDocWithId } = useEditorManagerContext() + const { findEntityByPath } = useFileTreePathContext() + const { commandName, commandDefinition } = useContextMenuCommandDefinition() const closeMenu = useCallback(() => { view.dispatch({ effects: closeContextMenuEffect.of(null) }) @@ -186,6 +192,20 @@ export const useContextMenuItems = () => { return true }) + const handleJumpToDefinition = wrapForContextMenu( + 'jump-to-definition', + () => { + if (!commandDefinition) { + return true + } + const result = findEntityByPath(commandDefinition.path) + if (result?.type === 'doc') { + openDocWithId(result.entity._id, { gotoOffset: commandDefinition.pos }) + } + return true + } + ) + // Sync-to-PDF is special: it needs to wait for async completion before closing const handleSyncToPdf = useCallback(() => { // Switch to split view only when in editor-only mode with non-detached PDF @@ -262,6 +282,14 @@ export const useContextMenuItems = () => { show: jumpToLocationInPdfEnabled, shortcut: undefined, }, + { + label: t('jump_to_command_definition'), + handler: handleJumpToDefinition, + disabled: !commandDefinition, + separatorAbove: !jumpToLocationInPdfEnabled, + show: Boolean(commandName), + shortcut: undefined, + }, { label: wantTrackChanges ? t('back_to_editing') : t('suggest_edits'), handler: handleToggleTrackChanges, diff --git a/services/web/frontend/js/features/source-editor/utils/command-definitions/command-definition-indexer.ts b/services/web/frontend/js/features/source-editor/utils/command-definitions/command-definition-indexer.ts new file mode 100644 index 0000000000..fe8c655f50 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/utils/command-definitions/command-definition-indexer.ts @@ -0,0 +1,78 @@ +import type { ProjectSnapshot } from '@/infrastructure/project-snapshot' +import { CommandDefinition, isIndexablePath } from './command-definition' +import type { + CommandDefinitionWorkerRequest, + CommandDefinitionWorkerResponse, +} from './command-definition.worker' + +type SnapshotLike = Pick + +/** + * Locates the definition of a single command in the project by parsing the + * snapshot in a worker, off the main thread, so opening the context menu is + * never blocked. A single worker is reused across lookups so opening the menu + * never spawns one. + */ +class CommandDefinitionIndexer { + private worker: Worker + private nextId = 0 + private resolvers = new Map< + number, + (definition: CommandDefinition | null) => void + >() + + constructor() { + this.worker = new Worker( + /* webpackChunkName: "command-definition-worker" */ + new URL('./command-definition.worker.ts', import.meta.url), + { type: 'module' } + ) + this.worker.addEventListener('message', event => { + const data = event.data as CommandDefinitionWorkerResponse + if (data.type === 'definition') { + this.resolvers.get(data.id)?.(data.definition) + this.resolvers.delete(data.id) + } + }) + } + + findDefinition( + snapshot: SnapshotLike, + name: string + ): Promise { + // Only send documents that mention the command (its defined name always + // appears literally in its definition), to avoid cloning the whole project + // to the worker. + const docs: { path: string; content: string }[] = [] + for (const path of snapshot.getDocPaths()) { + if (!isIndexablePath(path)) { + continue + } + const content = snapshot.getDocContents(path) + if (content != null && content.includes(name)) { + docs.push({ path, content }) + } + } + + const id = this.nextId++ + const request: CommandDefinitionWorkerRequest = { + id, + type: 'find', + name, + docs, + } + this.worker.postMessage(request) + return new Promise(resolve => { + this.resolvers.set(id, resolve) + }) + } +} + +let indexer: CommandDefinitionIndexer | undefined + +export function getCommandDefinitionIndexer(): CommandDefinitionIndexer { + if (!indexer) { + indexer = new CommandDefinitionIndexer() + } + return indexer +} diff --git a/services/web/frontend/js/features/source-editor/utils/command-definitions/command-definition.ts b/services/web/frontend/js/features/source-editor/utils/command-definitions/command-definition.ts new file mode 100644 index 0000000000..10818f88a1 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/utils/command-definitions/command-definition.ts @@ -0,0 +1,108 @@ +import { SyntaxNode } from '@lezer/common' +import { parser } from '../../lezer-latex/latex.mjs' +import { childOfNodeWithType } from '../tree-operations/common' + +/** + * Parsing helpers for locating where a custom command is defined across the + * project. These are pure and worker-safe: the parsing runs in + * command-definition.worker.ts, off the main thread, so opening the context menu + * is never blocked. + * + * Definitions are parsed with the same lezer-latex grammar the editor uses, so + * commented-out or verbatim definitions are ignored, and are named by their + * control sequence, e.g. `\mycmd`. + */ + +export type CommandDefinition = { + name: string + path: string + // Document offset of the definition, resolved to a line/cursor position when + // the defining file is opened. + pos: number +} + +// File extensions worth scanning for command definitions. +const INDEXABLE_EXTENSIONS = ['.tex', '.sty', '.cls', '.def', '.ltx'] + +export function isIndexablePath(path: string): boolean { + const lower = path.toLowerCase() + return INDEXABLE_EXTENSIONS.some(ext => lower.endsWith(ext)) +} + +function sliceChild( + content: string, + node: SyntaxNode, + ...childTypes: string[] +): string | null { + const child = childOfNodeWithType(node, ...childTypes) + if (!child) { + return null + } + const text = content.slice(child.from, child.to) + return text.length > 0 ? text : null +} + +/** + * Parse a document and return the command definitions it contains. + */ +export function parseDefinitionsFromDoc( + path: string, + content: string +): CommandDefinition[] { + const definitions: CommandDefinition[] = [] + const tree = parser.parse(content) + + const push = (name: string | null, pos: number) => { + if (!name) { + return + } + definitions.push({ name, path, pos }) + } + + tree.iterate({ + enter(nodeRef) { + const node = nodeRef.node + switch (nodeRef.type.name) { + case 'NewCommand': + case 'RenewCommand': + push( + sliceChild(content, node, 'LiteralArgContent', 'Csname'), + node.from + ) + break + case 'Def': + push(sliceChild(content, node, 'Csname', 'CtrlSym'), node.from) + break + case 'Let': + push(sliceChild(content, node, 'Csname'), node.from) + break + } + }, + }) + + return definitions +} + +/** + * Find the first definition of `name` across the given documents. Documents that + * don't mention the control sequence at all are skipped without parsing (the + * defined name always appears literally in its definition), and the search stops + * at the first match. + */ +export function findDefinition( + docs: { path: string; content: string }[], + name: string +): CommandDefinition | null { + for (const { path, content } of docs) { + if (!content.includes(name)) { + continue + } + const match = parseDefinitionsFromDoc(path, content).find( + definition => definition.name === name + ) + if (match) { + return match + } + } + return null +} diff --git a/services/web/frontend/js/features/source-editor/utils/command-definitions/command-definition.worker.ts b/services/web/frontend/js/features/source-editor/utils/command-definitions/command-definition.worker.ts new file mode 100644 index 0000000000..8f13aaa1af --- /dev/null +++ b/services/web/frontend/js/features/source-editor/utils/command-definitions/command-definition.worker.ts @@ -0,0 +1,28 @@ +import { CommandDefinition, findDefinition } from './command-definition' + +export type CommandDefinitionWorkerRequest = { + id: number + type: 'find' + name: string + docs: { path: string; content: string }[] +} + +export type CommandDefinitionWorkerResponse = { + id: number + type: 'definition' + definition: CommandDefinition | null +} + +self.addEventListener('message', (event: MessageEvent) => { + const message = event.data as CommandDefinitionWorkerRequest + if (message.type !== 'find') { + return + } + + const response: CommandDefinitionWorkerResponse = { + id: message.id, + type: 'definition', + definition: findDefinition(message.docs, message.name), + } + self.postMessage(response) +}) diff --git a/services/web/frontend/js/features/source-editor/utils/context-menu-analytics.ts b/services/web/frontend/js/features/source-editor/utils/context-menu-analytics.ts index 22504601b5..4d31a88951 100644 --- a/services/web/frontend/js/features/source-editor/utils/context-menu-analytics.ts +++ b/services/web/frontend/js/features/source-editor/utils/context-menu-analytics.ts @@ -10,6 +10,7 @@ export type ContextMenuItemSegmentation = | 'give-feedback' | 'delete' | 'jump-to-location-in-pdf' + | 'jump-to-definition' | 'suggest-edits' | 'back-to-editing' | 'comment' diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 897271f4c7..5770f27cc2 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -1370,6 +1370,7 @@ "joined_team": "You have joined the group subscription managed by __inviterName__", "joining": "Joining", "july": "July", + "jump_to_command_definition": "Jump to command definition", "jump_to_line_x": "Jump to line __line__", "jump_to_line_x_column_y": "Jump to line __line__, column __column__", "jump_to_location_in_pdf": "Jump to location in PDF", diff --git a/services/web/test/frontend/features/source-editor/utils/command-definitions/command-definition.test.ts b/services/web/test/frontend/features/source-editor/utils/command-definitions/command-definition.test.ts new file mode 100644 index 0000000000..feebc935a9 --- /dev/null +++ b/services/web/test/frontend/features/source-editor/utils/command-definitions/command-definition.test.ts @@ -0,0 +1,63 @@ +import { expect } from 'chai' +import { + findDefinition, + parseDefinitionsFromDoc, +} from '../../../../../../frontend/js/features/source-editor/utils/command-definitions/command-definition' + +describe('command-definition', function () { + describe('parseDefinitionsFromDoc', function () { + it('indexes \\newcommand definitions at their offset', function () { + const content = 'line one\n\\newcommand{\\foo}{bar}\n' + const defs = parseDefinitionsFromDoc('macros.tex', content) + expect(defs).to.have.length(1) + expect(defs[0]).to.include({ + name: '\\foo', + path: 'macros.tex', + pos: content.indexOf('\\newcommand'), + }) + }) + + it('indexes \\def and \\let definitions', function () { + const content = '\\def\\bar#1{#1}\n\\let\\baz\\bar\n' + const names = parseDefinitionsFromDoc('defs.tex', content).map( + d => d.name + ) + expect(names).to.include('\\bar') + expect(names).to.include('\\baz') + }) + + it('ignores commented-out definitions', function () { + const content = + '% \\newcommand{\\ignored}{nope}\n\\newcommand{\\real}{x}\n' + const names = parseDefinitionsFromDoc('main.tex', content).map( + d => d.name + ) + expect(names).to.include('\\real') + expect(names).not.to.include('\\ignored') + }) + }) + + describe('findDefinition', function () { + it('finds the defining document for a command', function () { + const docs = [ + { path: 'macros.tex', content: '\\newcommand{\\foo}{x}\n' }, + { path: 'chapters/intro.tex', content: '\\def\\bar{y}\n' }, + ] + expect(findDefinition(docs, '\\foo')?.path).to.equal('macros.tex') + expect(findDefinition(docs, '\\bar')?.path).to.equal('chapters/intro.tex') + }) + + it('returns null when the command is not defined', function () { + const docs = [{ path: 'macros.tex', content: '\\newcommand{\\foo}{x}\n' }] + expect(findDefinition(docs, '\\missing')).to.equal(null) + }) + + it('returns the first definition across documents', function () { + const docs = [ + { path: 'a.tex', content: '\\newcommand{\\dup}{1}\n' }, + { path: 'b.tex', content: '\\renewcommand{\\dup}{2}\n' }, + ] + expect(findDefinition(docs, '\\dup')?.path).to.equal('a.tex') + }) + }) +})