mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-12 13:13:45 +01:00
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:
committed by
Copybot
co-authored by
Claude Opus 4.8
parent
d20052bf3e
commit
f0b8e4af11
@@ -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,
|
||||
|
||||
+78
@@ -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
|
||||
}
|
||||
+108
@@ -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
|
||||
}
|
||||
+28
@@ -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'
|
||||
|
||||
@@ -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",
|
||||
|
||||
+63
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user