From 003a7c199fc4e0e82aa0e8ab89a750198f63d80b Mon Sep 17 00:00:00 2001 From: qingqing-ux Date: Tue, 25 Aug 2026 17:46:43 +0800 Subject: [PATCH] feat(code-editor): improve definition navigation --- .../src/components/xgo-code-editor/common.ts | 2 +- .../components/xgo-code-editor/hover.test.ts | 88 ++++++++++++++++++ .../src/components/xgo-code-editor/hover.ts | 38 +++++--- .../xgo-code-editor/ui/CodeEditorUI.vue | 90 ++++++++++++++---- .../xgo-code-editor/ui/DefinitionPeek.vue | 55 +++++++++++ .../xgo-code-editor/ui/code-editor-ui.test.ts | 81 +++++++++++++++++ .../xgo-code-editor/ui/code-editor-ui.ts | 91 +++++++++++++------ .../ui/command/CommandIcon.vue | 4 +- .../ui/document-tab/DocumentTab.vue | 36 -------- .../ui/document-tab/DocumentTabs.vue | 58 ------------ .../xgo-code-editor/ui/hover/HoverCard.vue | 5 +- 11 files changed, 392 insertions(+), 156 deletions(-) create mode 100644 spx-gui/src/components/xgo-code-editor/hover.test.ts create mode 100644 spx-gui/src/components/xgo-code-editor/ui/DefinitionPeek.vue create mode 100644 spx-gui/src/components/xgo-code-editor/ui/code-editor-ui.test.ts delete mode 100644 spx-gui/src/components/xgo-code-editor/ui/document-tab/DocumentTab.vue delete mode 100644 spx-gui/src/components/xgo-code-editor/ui/document-tab/DocumentTabs.vue diff --git a/spx-gui/src/components/xgo-code-editor/common.ts b/spx-gui/src/components/xgo-code-editor/common.ts index 481bf46ae3..781df05c6d 100644 --- a/spx-gui/src/components/xgo-code-editor/common.ts +++ b/spx-gui/src/components/xgo-code-editor/common.ts @@ -271,7 +271,7 @@ export function makeMCPMarkdownString(value: string | LocaleMessage): MCPMarkdow return { value, flag: 'mcp' } } -export type CommandIconType = 'explain' | 'fix' | 'goto' | 'modify' | 'rename' | 'copy' | 'copilot' +export type CommandIconType = 'explain' | 'fix' | 'goto' | 'modify' | 'rename' | 'copy' | 'copilot' | 'view' /** * Documentation string for a definition. Typically: diff --git a/spx-gui/src/components/xgo-code-editor/hover.test.ts b/spx-gui/src/components/xgo-code-editor/hover.test.ts new file mode 100644 index 0000000000..bd2e29ebf4 --- /dev/null +++ b/spx-gui/src/components/xgo-code-editor/hover.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it, vi } from 'vitest' +import type { IDocumentBase } from './document-base' +import type { ILSPClient } from './lsp/types' +import type { ITextDocument } from './common' +import { HoverProvider } from './hover' +import { builtInCommandGoToDefinition, builtInCommandViewDefinition } from './ui/code-editor-ui' + +function makeLSPClient(targetUri: string) { + return { + textDocumentHover: vi.fn().mockResolvedValue({ + contents: { kind: 'markdown', value: 'Variable documentation' }, + range: { + start: { line: 4, character: 0 }, + end: { line: 4, character: 8 } + } + }), + getDefinition: vi.fn().mockResolvedValue(null), + textDocumentDefinition: vi.fn().mockResolvedValue({ + uri: targetUri, + range: { + start: { line: 4, character: 0 }, + end: { line: 4, character: 8 } + } + }), + textDocumentTypeDefinition: vi.fn().mockResolvedValue(null), + textDocumentPrepareRename: vi.fn().mockResolvedValue(null) + } as unknown as ILSPClient +} + +const documentBase = { + getDocumentation: vi.fn().mockResolvedValue(null) +} as unknown as IDocumentBase + +function makeTextDocument(uri: string) { + return { id: { uri } } as unknown as ITextDocument +} + +describe('HoverProvider definition actions', () => { + it('offers view and go-to actions for a definition in another document', async () => { + const sourceUri = 'file:///source.spx' + const targetUri = 'file:///target.spx' + const provider = new HoverProvider(makeLSPClient(targetUri), documentBase) + + const hover = await provider.provideHover( + { textDocument: makeTextDocument(sourceUri), signal: new AbortController().signal }, + { line: 5, column: 2 } + ) + + expect(hover?.actions.map((action) => action.command)).toEqual([ + builtInCommandViewDefinition, + builtInCommandGoToDefinition + ]) + expect(hover?.actions[0].arguments).toEqual([ + { + textDocument: { uri: targetUri }, + range: { + start: { line: 5, column: 1 }, + end: { line: 5, column: 9 } + } + } + ]) + expect(hover?.actions[1].arguments).toEqual([ + { + textDocument: { uri: targetUri }, + range: { + start: { line: 5, column: 1 }, + end: { line: 5, column: 9 } + } + }, + { + textDocument: { uri: sourceUri }, + position: { line: 5, column: 2 } + } + ]) + }) + + it('does not offer definition actions for the current location', async () => { + const sourceUri = 'file:///source.spx' + const provider = new HoverProvider(makeLSPClient(sourceUri), documentBase) + + const hover = await provider.provideHover( + { textDocument: makeTextDocument(sourceUri), signal: new AbortController().signal }, + { line: 5, column: 2 } + ) + + expect(hover?.actions).toEqual([]) + }) +}) diff --git a/spx-gui/src/components/xgo-code-editor/hover.ts b/spx-gui/src/components/xgo-code-editor/hover.ts index 1da876a8f7..e973131645 100644 --- a/spx-gui/src/components/xgo-code-editor/hover.ts +++ b/spx-gui/src/components/xgo-code-editor/hover.ts @@ -12,6 +12,7 @@ import { builtInCommandCopilotExplain, CopilotExplainKind, builtInCommandGoToDefinition, + builtInCommandViewDefinition, builtInCommandRename } from './ui/code-editor-ui' @@ -50,7 +51,7 @@ export class HoverProvider implements IHoverProvider { } } - private async getGoToDefinitionAction(position: Position, lspParams: lsp.TextDocumentPositionParams) { + private async getDefinitionActions(position: Position, lspParams: lsp.TextDocumentPositionParams) { const [definition, typeDefinition] = ( await Promise.all([ this.lspClient.textDocumentDefinition({}, lspParams), @@ -64,16 +65,27 @@ export class HoverProvider implements IHoverProvider { const location = definition ?? typeDefinition if (location == null) return null const range = fromLSPRange(location.range) - if (containsPosition(range, position)) return null - return { - command: builtInCommandGoToDefinition, - arguments: [ - { - textDocument: { uri: location.uri }, - range - } - ] satisfies CommandArgs + if (location.uri === lspParams.textDocument.uri && containsPosition(range, position)) return null + const target = { + textDocument: { uri: location.uri }, + range } + return [ + { + command: builtInCommandViewDefinition, + arguments: [target] satisfies CommandArgs + }, + { + command: builtInCommandGoToDefinition, + arguments: [ + target, + { + textDocument: lspParams.textDocument, + position + } + ] satisfies CommandArgs + } + ] } private async getRenameAction(ctx: HoverContext, position: Position, lspParams: lsp.TextDocumentPositionParams) { @@ -105,12 +117,12 @@ export class HoverProvider implements IHoverProvider { } let range: Range | undefined = undefined if (lspHover.range != null) range = fromLSPRange(lspHover.range) - const maybeActions = await Promise.all([ + const [explainAction, definitionActions, renameAction] = await Promise.all([ this.getExplainAction(ctx, position), - this.getGoToDefinitionAction(position, lspParams), + this.getDefinitionActions(position, lspParams), this.getRenameAction(ctx, position, lspParams) ]) - const actions = maybeActions.filter((a) => a != null) as Action[] + const actions = [explainAction, ...(definitionActions ?? []), renameAction].filter((a) => a != null) as Action[] return { contents, range, actions } } } diff --git a/spx-gui/src/components/xgo-code-editor/ui/CodeEditorUI.vue b/spx-gui/src/components/xgo-code-editor/ui/CodeEditorUI.vue index e0426072fd..7f46ef39c0 100644 --- a/spx-gui/src/components/xgo-code-editor/ui/CodeEditorUI.vue +++ b/spx-gui/src/components/xgo-code-editor/ui/CodeEditorUI.vue @@ -18,7 +18,7 @@ import { getCleanupSignal } from '@/utils/disposable' import { useI18n } from '@/utils/i18n' import { getXGoIdentifierNameTip, validateXGoIdentifierName } from '@/utils/xgo' import { theme, tabSize, insertSpaces } from '@/utils/xgo/highlighter' -import { providePopupContainer, useModal } from '@/components/ui' +import { providePopupContainer, UIIcon, useModal } from '@/components/ui' import RenameModal from '@/components/common/RenameModal.vue' import { useCodeEditor } from '../context' import { getDdiDragData, getTextDocumentId, type Position, type Range, type TextDocumentIdentifier } from '../common' @@ -34,7 +34,7 @@ import ContextMenuUI from './context-menu/ContextMenuUI.vue' import InputHelperUI from './input-helper/InputHelperUI.vue' import InlayHintUI from './inlay-hint/InlayHintUI.vue' import DropIndicatorUI from './drop-indicator/DropIndicatorUI.vue' -import DocumentTabs from './document-tab/DocumentTabs.vue' +import DefinitionPeek from './DefinitionPeek.vue' import ZoomControl from './ZoomControl.vue' import { userLocalStorageRef } from '@/utils/user-storage' @@ -81,6 +81,16 @@ const monacoEditorOptions = computed(() => ({ + ...monacoEditorOptions.value, + readOnly: true, + glyphMargin: false, + folding: false, + lineDecorationsWidth: 12, + lineNumbersMinChars: 2, + selectOnLineNumbers: false +})) + const monacoEditorRef = shallowRef(null) async function handleMonacoEditorInit(editor: MonacoEditor) { @@ -256,16 +266,68 @@ providePopupContainer(codeEditorEl) :class="{ 'bg-black/10': isResizing }" :style="{ left: `${sidebarWidth}px` }" > - +
+
+ + + {{ + $t({ + en: `Current: ${$t(uiRef.activeTextDocument.displayName)}`, + zh: `当前位置:${$t(uiRef.activeTextDocument.displayName)}` + }) + }} + +
+
+
+ + +
+ +
+
@@ -273,9 +335,5 @@ providePopupContainer(codeEditorEl) - diff --git a/spx-gui/src/components/xgo-code-editor/ui/DefinitionPeek.vue b/spx-gui/src/components/xgo-code-editor/ui/DefinitionPeek.vue new file mode 100644 index 0000000000..2568a91479 --- /dev/null +++ b/spx-gui/src/components/xgo-code-editor/ui/DefinitionPeek.vue @@ -0,0 +1,55 @@ + + + diff --git a/spx-gui/src/components/xgo-code-editor/ui/code-editor-ui.test.ts b/spx-gui/src/components/xgo-code-editor/ui/code-editor-ui.test.ts new file mode 100644 index 0000000000..0fbb74e6b8 --- /dev/null +++ b/spx-gui/src/components/xgo-code-editor/ui/code-editor-ui.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it, vi } from 'vitest' +import type { I18n } from '@/utils/i18n' +import type { CodeEditor } from '../code-editor' +import type { Range, TextDocumentIdentifier } from '../common' +import type { TextDocument } from '../text-document' +import type { MonacoEditor } from '../monaco' +import { CodeEditorUIController } from './code-editor-ui' + +function makeTextDocument(uri: string, name: string) { + return { + id: { uri }, + displayName: { en: name, zh: name }, + monacoTextModel: { uri } + } as unknown as TextDocument +} + +function makeController() { + const source = makeTextDocument('file:///source.spx', 'Source') + const target = makeTextDocument('file:///target.spx', 'Target') + const documents = new Map([ + [source.id.uri, source], + [target.id.uri, target] + ]) + const codeEditor = { + getTextDocument: (id: TextDocumentIdentifier) => documents.get(id.uri) ?? null + } as unknown as CodeEditor + const i18n = { t: (message: { en: string }) => message.en } as unknown as I18n + const controller = new CodeEditorUIController(source.id, codeEditor, i18n, vi.fn()) + const sourceViewState = { cursorState: [] } + const editor = { + saveViewState: vi.fn().mockReturnValueOnce(sourceViewState).mockReturnValue({ cursorState: [] }), + setModel: vi.fn(), + setSelection: vi.fn(), + revealRangeNearTopIfOutsideViewport: vi.fn(), + restoreViewState: vi.fn(), + focus: vi.fn() + } as unknown as MonacoEditor + ;(controller as any)._editor = editor + ;(controller as any).activeTextDocumentIdRef.value = source.id + ;(controller as any).cursorPositionRef.value = { line: 12, column: 4 } + return { controller, editor, source, sourceViewState, target } +} + +const targetRange: Range = { + start: { line: 5, column: 1 }, + end: { line: 5, column: 7 } +} + +describe('CodeEditorUIController definition navigation', () => { + it('opens a definition preview without changing the active document', () => { + const { controller, source, target } = makeController() + + ;(controller as any).openDefinitionPeek({ textDocument: target.id, range: targetRange }) + + expect(controller.activeTextDocument).toBe(source) + expect(controller.definitionPeek).toEqual({ textDocument: target, range: targetRange }) + }) + + it('returns to the document view state captured before navigation', () => { + const { controller, editor, source, sourceViewState, target } = makeController() + + ;(controller as any).goToDefinition( + { textDocument: target.id, range: targetRange }, + { textDocument: source.id, position: { line: 4, column: 18 } } + ) + + expect(controller.activeTextDocument).toBe(target) + expect(controller.previousNavigationLocation).toMatchObject({ + textDocument: source, + position: { line: 4, column: 18 }, + viewState: sourceViewState + }) + + controller.goBack() + + expect(controller.activeTextDocument).toBe(source) + expect(controller.previousNavigationLocation).toBeNull() + expect(editor.restoreViewState).toHaveBeenLastCalledWith(sourceViewState) + expect(editor.focus).toHaveBeenCalled() + }) +}) diff --git a/spx-gui/src/components/xgo-code-editor/ui/code-editor-ui.ts b/spx-gui/src/components/xgo-code-editor/ui/code-editor-ui.ts index c223833340..9f775a5bf6 100644 --- a/spx-gui/src/components/xgo-code-editor/ui/code-editor-ui.ts +++ b/spx-gui/src/components/xgo-code-editor/ui/code-editor-ui.ts @@ -16,7 +16,6 @@ import { type ResourceIdentifier, type TextDocumentRange, isRangeEmpty, - textDocumentIdEq, selection2Range, type DefinitionDocumentationItem, isBlockDefinitionKind @@ -89,8 +88,11 @@ export const builtInCommandCopilotFixProblem: Command<[target: CopilotFixProblem export const builtInCommandCopy: Command<[], void> = 'editor.action.copy' export const builtInCommandCut: Command<[], void> = 'editor.action.cut' export const builtInCommandPaste: Command<[], void> = 'editor.action.paste' -export const builtInCommandGoToDefinition: Command<[TextDocumentPosition | TextDocumentRange], void> = - 'xgo.goToDefinition' +export const builtInCommandGoToDefinition: Command< + [target: TextDocumentPosition | TextDocumentRange, source: TextDocumentPosition | undefined], + void +> = 'xgo.goToDefinition' +export const builtInCommandViewDefinition: Command<[TextDocumentRange], void> = 'xgo.viewDefinition' export const builtInCommandGoToResource: Command<[ResourceIdentifier], void> = 'xgo.goToResource' export const builtInCommandRename: Command<[TextDocumentPosition & TextDocumentRange], void> = 'xgo.rename' export const builtInCommandRenameResource: Command<[ResourceIdentifier], void> = 'xgo.renameResource' @@ -103,6 +105,17 @@ export type InternalAction = { arguments: A } +export type DefinitionPeek = { + textDocument: TextDocument + range: Range +} + +export type CodeNavigationEntry = { + textDocument: TextDocument + position: Position | null + viewState: monaco.editor.ICodeEditorViewState | null +} + export class CodeEditorUIController extends Disposable implements ICodeEditorUIController { private commands = new Map, CommandInfo>() getCommandInfo(command: Command): CommandInfo | null { @@ -177,21 +190,47 @@ export class CodeEditorUIController extends Disposable implements ICodeEditorUIC dropIndicatorController = new DropIndicatorController(this) snippetParser = new SnippetParser(this) - /** Temporary text document IDs */ - private tempTextDocumentIds = shallowReactive([]) + private definitionPeekRef = shallowRef(null) + get definitionPeek() { + return this.definitionPeekRef.value + } - /** Temporary text documents */ - get tempTextDocuments() { - return this.tempTextDocumentIds.map((id) => { - const doc = this.codeEditor.getTextDocument(id) - if (doc == null) throw new Error(`Text document not found: ${id.uri}`) - return doc - }) + private openDefinitionPeek(target: TextDocumentRange) { + const textDocument = this.codeEditor.getTextDocument(target.textDocument) + if (textDocument == null) return + this.definitionPeekRef.value = { textDocument, range: target.range } + } + + closeDefinitionPeek() { + this.definitionPeekRef.value = null + this.editor.focus() + } + + private navigationStack = shallowReactive([]) + get previousNavigationLocation() { + return this.navigationStack[this.navigationStack.length - 1] ?? null } - closeTempTextDocuments() { - this.tempTextDocumentIds.splice(0) - if (!this.isDisposed) this.setActiveTextDocument(this.mainTextDocumentId) + private goToDefinition(target: TextDocumentPosition | TextDocumentRange, source?: TextDocumentPosition) { + const activeTextDocument = this.activeTextDocument + if (activeTextDocument != null) { + this.navigationStack.push({ + textDocument: activeTextDocument, + position: source?.position ?? this.cursorPosition, + viewState: this.editor.saveViewState() + }) + } + this.definitionPeekRef.value = null + if ('position' in target) this.open(target.textDocument, target.position) + else this.open(target.textDocument, target.range) + } + + goBack() { + const previous = this.navigationStack.pop() + if (previous == null) return + this.setActiveTextDocument(previous.textDocument.id) + if (previous.viewState != null) this.editor.restoreViewState(previous.viewState) + this.editor.focus() } /** Current active text document ID */ @@ -212,12 +251,6 @@ export class CodeEditorUIController extends Disposable implements ICodeEditorUIC this.editor.setModel(null) return } - if ( - !textDocumentIdEq(textDocument.id, this.mainTextDocumentId) && - !this.tempTextDocumentIds.some((id) => textDocumentIdEq(id, textDocument.id)) - ) { - this.tempTextDocumentIds.push(textDocument.id) - } if (this.activeTextDocument != null) this.viewStateMap.set(this.activeTextDocument, this.editor.saveViewState()) this.activeTextDocumentIdRef.value = textDocument.id this.editor.setModel(textDocument.monacoTextModel) @@ -225,11 +258,6 @@ export class CodeEditorUIController extends Disposable implements ICodeEditorUIC if (viewState != null) this.editor.restoreViewState(viewState) } - /** The "main" (initially opened) text document */ - get mainTextDocument() { - return this.codeEditor.getTextDocument(this.mainTextDocumentId) - } - private getSelectionRange() { const pos = { line: 1, column: 1 } let range = { start: pos, end: pos } @@ -618,10 +646,13 @@ export class CodeEditorUIController extends Disposable implements ICodeEditorUIC this.registerCommand(builtInCommandGoToDefinition, { icon: 'goto', title: { en: 'Go to definition', zh: '跳转到定义' }, - handler: async (params) => { - if ('position' in params) this.open(params.textDocument, params.position) - else this.open(params.textDocument, params.range) - } + handler: (target, source) => this.goToDefinition(target, source) + }) + + this.registerCommand(builtInCommandViewDefinition, { + icon: 'view', + title: { en: 'View definition', zh: '查看定义' }, + handler: (params) => this.openDefinitionPeek(params) }) this.registerCommand(builtInCommandInvokeInputHelper, { diff --git a/spx-gui/src/components/xgo-code-editor/ui/command/CommandIcon.vue b/spx-gui/src/components/xgo-code-editor/ui/command/CommandIcon.vue index 903ff03829..a67eabd264 100644 --- a/spx-gui/src/components/xgo-code-editor/ui/command/CommandIcon.vue +++ b/spx-gui/src/components/xgo-code-editor/ui/command/CommandIcon.vue @@ -1,4 +1,5 @@ diff --git a/spx-gui/src/components/xgo-code-editor/ui/document-tab/DocumentTab.vue b/spx-gui/src/components/xgo-code-editor/ui/document-tab/DocumentTab.vue deleted file mode 100644 index e4121e0742..0000000000 --- a/spx-gui/src/components/xgo-code-editor/ui/document-tab/DocumentTab.vue +++ /dev/null @@ -1,36 +0,0 @@ - - - diff --git a/spx-gui/src/components/xgo-code-editor/ui/document-tab/DocumentTabs.vue b/spx-gui/src/components/xgo-code-editor/ui/document-tab/DocumentTabs.vue deleted file mode 100644 index c8b23d5f95..0000000000 --- a/spx-gui/src/components/xgo-code-editor/ui/document-tab/DocumentTabs.vue +++ /dev/null @@ -1,58 +0,0 @@ - - - diff --git a/spx-gui/src/components/xgo-code-editor/ui/hover/HoverCard.vue b/spx-gui/src/components/xgo-code-editor/ui/hover/HoverCard.vue index 7b5b4885ba..8de27d522c 100644 --- a/spx-gui/src/components/xgo-code-editor/ui/hover/HoverCard.vue +++ b/spx-gui/src/components/xgo-code-editor/ui/hover/HoverCard.vue @@ -35,7 +35,10 @@ const handleAction = useMessageHandle(
-