Add "Jump to command definition" to the editor context menu (#35116)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
GitOrigin-RevId: 5fcaef2004495a5e71bc083cf8fdc2bffce579d5
This commit is contained in:
Alf Eaton
2026-09-17 11:47:09 +00:00
committed by Copybot
co-authored by Claude Opus 4.8
parent d20052bf3e
commit f0b8e4af11
9 changed files with 402 additions and 0 deletions
@@ -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": "",
@@ -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<CommandDefinition | null>(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
}
@@ -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,
@@ -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<ProjectSnapshot, 'getDocPaths' | 'getDocContents'>
/**
* 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<CommandDefinition | null> {
// 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
}
@@ -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
}
@@ -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)
})
@@ -10,6 +10,7 @@ export type ContextMenuItemSegmentation =
| 'give-feedback'
| 'delete'
| 'jump-to-location-in-pdf'
| 'jump-to-definition'
| 'suggest-edits'
| 'back-to-editing'
| 'comment'
+1
View File
@@ -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",
@@ -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')
})
})
})