diff --git a/docs/develop/issue-3455-navigation.zh.md b/docs/develop/issue-3455-navigation.zh.md new file mode 100644 index 0000000000..75f529e2d7 --- /dev/null +++ b/docs/develop/issue-3455-navigation.zh.md @@ -0,0 +1,70 @@ +# Issue #3455:定义与引用导航 + +## 方案关系 + +- 关联 [Issue #3455](https://github.com/goplus/builder/issues/3455)。 +- 基于 [PR #3468](https://github.com/goplus/builder/pull/3468) 继续迭代;保留 #3468 作为前一版 proposal。 + +## 核心设计 + +1. 调用处只显示「查看定义」,声明处只显示「查看引用」,统一进入可编辑 Peek。 +2. Peek 与主编辑器共享代码模型、编辑历史和保存规则,不增加保存确认。 +3. 展开按钮用于在主编辑器中打开 Peek 当前显示的定义或引用。 +4. 引用列表按文档分组,显示行号和代码摘要,支持多处引用及递归调用。 +5. Peek 内可返回定义;展开后可恢复来源文档、光标、选区和滚动位置,多次展开可逐级返回。 +6. 引用列表为临时入口,不恢复右侧常驻文档列表;新增文案支持中英文。 + +## 参数修改 + +- 修改函数参数后,保留修改前找到的调用位置,引导用户逐处修正并确认。 +- 系统不猜测参数值,不自动转换类型或重排实参。 +- 关闭或展开 Peek 后,本次会话中仍可继续检查。 +- 检查期间可整组撤销参数及调用修改;若混入其他代码修改,则禁用整组撤销,避免覆盖用户改动。 +- 「完成检查」只结束人工检查,不代表保存或编译通过。 + +## 实现边界 + +- 引用来自现有 WASM 语言服务的 `textDocument/references`,不使用文本搜索,也不新增后端。 +- 声明不计入引用数量;结果按文档和位置去重排序。 +- 自递归和相互递归按普通引用展示,不展开调用图。 +- 引用加载、空结果和失败分别显示;代码变化后刷新结果并忽略过期响应。 +- 统一改名使用现有语义「重命名」,可在主编辑器和 Peek 中发起。 +- 参数检查仅覆盖参数列表,不包含接收者、类型参数、返回值或自动参数迁移;进度不跨页面刷新保存。 + +## 验收清单 + +### 定义、引用与返回 + +- 从调用处打开定义 Peek,编辑后关闭再打开,修改仍保留。 +- 从声明处打开引用列表,可选择不同文档中的调用并返回定义。 +- 展开定义或引用后,返回入口可恢复原文档和编辑器视图。 +- 多处引用、自递归、相互递归及零引用数量正确;失败时显示重试,不伪装为零引用。 + +### 重命名与参数检查 + +- 在 Peek 中重命名符号,声明、跨文档引用和递归引用同步更新;项目撤销可还原。 +- 修改参数后,原调用位置仍保留;逐处修正并标记后可完成检查。 +- 参数列表暂时不完整时仍保留调用入口,但不能完成检查。 +- 整组撤销只恢复本次参数及调用修改;混入其他代码时入口禁用。 + +### 界面 + +- 中英文标题、引用数量、状态、返回和展开提示正确。 +- 窄编辑区内标题与按钮不重叠,行号与代码间距合理。 +- 引用列表支持键盘选择,Escape 关闭列表或 Peek。 + +本地曾用 Match3 的 `isSame` 验证 22 处真实引用和参数检查流程;该演示修改不作为项目源码提交。 + +## 验证 + +在 `spx-gui` 目录执行: + +```sh +pnpm exec vitest --run src/components/xgo-code-editor src/components/editor/spx-code-editor src/components/editor/history.test.ts +pnpm run type-check +pnpm run lint +pnpm run build +node scripts/check-definition-references.mjs +``` + +提交前结果:9 个测试文件、49 项测试通过;类型检查、Lint、构建和真实 WASM 验证通过。WASM 验证覆盖跨文档多引用、递归、零引用、重命名及参数变化后的引用查询。 diff --git a/docs/product/code-editor.md b/docs/product/code-editor.md index 4b8d22ad86..3653f2e2b3 100644 --- a/docs/product/code-editor.md +++ b/docs/product/code-editor.md @@ -45,7 +45,7 @@ Hover is an interaction mode where, when the user hovers the mouse over an eleme * A set of related actions, including: - - Go to Definition + - View Definition at a symbol use, or View References at its declaration (one navigation action at a time). - Rename - Explain, which invokes Copilot to explain the location, function, etc., of the current element. - Modify Reference, which provides an entry to modify the reference for Resource References. @@ -66,6 +66,18 @@ The specific correspondence between element types and floating layer content is | **string-literal as a resource-reference** | `{resourceType} {name}`
Sound ""explosion"" | Preview for resource | Yes, select the resource | Yes | No | Yes | | **identifier as a resource-reference** | `{resourceType} {name}`
Sprite ""NiuXiaoQi"" | Preview for resource | Yes, select the resource | Yes | No | No | +### Definition and Reference Navigation + +View Definition opens an editable Peek below the source editor. Peek uses the same project text model and editing history as the main editor; closing it does not discard edits or introduce a separate save confirmation. Cloud saving still follows the existing project permissions and save status. + +The Peek header shows the current document and line, a reference-count button, Open in Editor, and Close. View References at a declaration opens the same Peek with its reference picker visible. The picker groups all project references by document and shows the line and a code excerpt for each result. Declaration locations are not counted as references; self-recursive and mutually recursive calls are ordinary references. Selecting a result previews that location in the same Peek, and Back to Definition restores its definition. + +Open in Editor promotes the currently previewed definition or reference to the main editor, preserving its cursor and scroll state. The return bar restores the source view captured before Peek opened. Repeated openings form a return stack; choosing references inside Peek does not add entries to that stack. No persistent document list is introduced. + +Reference results come from the language service, not text matching. Editing refreshes them; loading, no results, and failure with retry are distinct states. Automatic saving is not automatic refactoring: use Rename to update a symbol name and its references together. Hovering a renameable symbol inside Peek also offers Rename, using the existing dialog, project-error warning, and editing history without navigating away from Peek. + +Editing a parameter list directly in Peek starts a call review that retains previously known references. Users fix and mark each location; argument values, conversions, and argument reordering are not inferred. Closing or expanding Peek preserves the review. Finish Review ends manual inspection, not saving or compilation validation. While reviewing, Undo Adjustment restores parameter and reference-line edits together; other mixed code edits disable this bulk operation to preserve them, leaving normal project undo available. Review progress does not persist across page reloads. + ### Marker A Marker is a special hint in the Code Editor used to mark certain code as having a special status (such as errors, warnings, etc.). diff --git a/docs/product/code-editor.zh.md b/docs/product/code-editor.zh.md index f2b748d800..78dd9819c7 100644 --- a/docs/product/code-editor.zh.md +++ b/docs/product/code-editor.zh.md @@ -45,7 +45,7 @@ Hover 是这样一种交互模式,当用户将鼠标悬浮在某个元素上 * 元素相关的操作组 Actions,包括 - - 跳转到定义 Go to Definition + - 使用处显示「查看定义」,声明处显示「查看引用」,同一位置只显示一个导航操作。 - 重命名 Rename - 解释 Explain,调起 Copilot 对当前元素的定位、作用等进行解释 - 修改引用 Modify Reference,对于 Resource Reference,提供修改引用的入口 @@ -66,6 +66,18 @@ Hover 是这样一种交互模式,当用户将鼠标悬浮在某个元素上 | **string-literal as a resource-reference** | `{resourceType} {name}`
Sound ""explosion"" | Preview for resource | Yes, select the resource | Yes | No | Yes | | **identifier as a resource-reference** | `{resourceType} {name}`
Sprite ""NiuXiaoQi"" | Preview for resource | Yes, select the resource | Yes | No | No | +### 定义与引用导航 + +「查看定义」在源编辑器下方打开可编辑 Peek。Peek 与主编辑器使用同一项目代码模型和编辑历史,关闭面板不会丢弃修改,也不增加独立的保存确认。云端保存仍遵循项目原有权限和保存状态。 + +Peek 标题栏显示当前文档与行号,并提供引用数量、「在编辑器中打开」和关闭操作。在声明处点击「查看引用」会打开同一 Peek,同时展开引用选择列表。列表按项目代码文档分组,每项显示行号和代码摘要;不把声明位置计入引用数量,自递归和相互递归中的调用与普通引用一样展示。选择结果后在同一 Peek 中预览该引用,并可通过「返回定义」回到定义位置。 + +「在编辑器中打开」把当前预览的定义或引用展开到主编辑器,保留 Peek 内的光标与滚动状态。返回条恢复打开 Peek 前的源代码视图。连续展开形成逐级返回记录;在 Peek 内选择引用不增加返回记录。不恢复常驻文档列表。 + +引用结果来自语言服务,而不是文本匹配;编辑后会刷新结果,并区分加载中、无引用及失败重试状态。自动保存不等于自动重构,统一修改符号名称及其引用仍需使用「重命名」。在 Peek 中悬浮可重命名的符号也会提供该操作,复用原有弹窗、项目代码错误警告和编辑历史,无需离开 Peek。 + +在 Peek 直接修改参数列表后,编辑器保留修改前已知的引用并进入调用检查流程。用户逐处修正和标记,不自动猜测新增参数值、转换类型或重排实参。关闭或展开 Peek 不会清除检查进度;「完成检查」是结束人工检查,不是额外保存,也不代表编译成功。检查期间可整体撤销参数及引用行内的修改;若混入其他代码编辑,则禁用整组撤销,保留项目原有的逐步撤销。检查状态不跨页面刷新保存。 + ### 标记 Marker Marker 是 Code Editor 中的一种特殊的提示,用于将部分代码标记为特殊状态(如错误、警告等)。 diff --git a/spx-gui/scripts/check-definition-references.mjs b/spx-gui/scripts/check-definition-references.mjs new file mode 100644 index 0000000000..91ec57a43a --- /dev/null +++ b/spx-gui/scripts/check-definition-references.mjs @@ -0,0 +1,116 @@ +// Run after build-wasm.sh: node scripts/check-definition-references.mjs +// Uses the same WASM language server as the editor, not mocked reference results. +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import '../src/assets/wasm/wasm_exec.js' + +const go = new Go() +const bytes = await readFile(new URL('../src/assets/wasm/spxls.wasm', import.meta.url)) +const { instance } = await WebAssembly.instantiate(bytes, go.importObject) +void go.run(instance) +SetCustomPkgdataZip(await readFile(new URL('../src/assets/wasm/spxls-pkgdata.zip', import.meta.url))) + +const sources = { + 'main.spx': [ + 'func factorial(n int) int {', + ' if n <= 1 { return 1 }', + ' return n * factorial(n - 1)', + '}', + 'func even(n int) bool {', + ' if n == 0 { return true }; return odd(n - 1)', + '}', + 'func odd(n int) bool {', + ' if n == 0 { return false }; return even(n - 1)', + '}', + 'func unused() {}' + ].join('\n'), + 'Board.spx': 'onStart => {\n println factorial(5)\n println factorial(3)\n println even(4)\n}', + 'assets/index.json': '{}', + 'assets/sprites/Board/index.json': '{}' +} +const files = Object.fromEntries( + Object.entries(sources).map(([path, code]) => [ + path, + { + content: new TextEncoder().encode(code), + modTime: 1 + } + ]) +) +let nextId = 0 +const pending = new Map() +const server = NewXGoLanguageServer( + () => files, + (message) => { + if (message.id == null) return + const callbacks = pending.get(message.id) + if (callbacks == null) return + pending.delete(message.id) + if (message.error != null) callbacks.reject(new Error(message.error.message)) + else callbacks.resolve(message.result) + } +) +if (server instanceof Error) throw server + +function request(method, params) { + return new Promise((resolve, reject) => { + const id = ++nextId + pending.set(id, { resolve, reject }) + const error = server.handleMessage({ jsonrpc: '2.0', id, method, params }) + if (error != null) { + pending.delete(id) + reject(error) + } + }) +} + +const timeout = setTimeout(() => { + console.error('Language server verification timed out') + process.exit(1) +}, 20000) +try { + const initialized = await request('initialize', { processId: null, rootUri: 'file:///', capabilities: {} }) + assert.ok(initialized.capabilities.referencesProvider) + server.handleMessage({ jsonrpc: '2.0', method: 'initialized', params: {} }) + const references = async (line, character) => + (await request('textDocument/references', { + textDocument: { uri: 'file:///main.spx' }, + position: { line, character }, + context: { includeDeclaration: false } + })) ?? [] + const factorial = await references(0, 6) + assert.equal(factorial.length, 3) + assert.equal(factorial.filter((item) => item.uri === 'file:///Board.spx').length, 2) + assert.ok(factorial.some((item) => item.uri === 'file:///main.spx' && item.range.start.line === 2)) + const definition = await request('textDocument/definition', { + textDocument: { uri: 'file:///main.spx' }, + position: { line: 2, character: 14 } + }) + assert.equal((Array.isArray(definition) ? definition[0] : definition).range.start.line, 0) + const even = await references(4, 6) + assert.equal(even.length, 2) + assert.ok(even.some((item) => item.uri === 'file:///main.spx' && item.range.start.line === 8)) + assert.equal((await references(7, 6)).length, 1) + assert.equal((await references(10, 6)).length, 0) + const renameParams = { textDocument: { uri: 'file:///main.spx' }, position: { line: 0, character: 6 } } + assert.ok(await request('textDocument/prepareRename', renameParams)) + const rename = await request('textDocument/rename', { ...renameParams, newName: 'renamedFactorial' }) + assert.equal(rename.changes['file:///main.spx'].length, 2) + assert.equal(rename.changes['file:///Board.spx'].length, 2) + for (const edits of Object.values(rename.changes)) { + assert.ok(edits.every((edit) => edit.newText === 'renamedFactorial')) + } + files['main.spx'] = { + content: new TextEncoder().encode(sources['main.spx'].replace('factorial(n int)', 'factorial(n int, extra int)')), + modTime: 2 + } + const afterParameterChange = await references(0, 6) + assert.equal(afterParameterChange.length, 3) + assert.equal(afterParameterChange.filter((item) => item.uri === 'file:///Board.spx').length, 2) + console.warn( + 'PASS: multiple cross-document references, self-recursion, mutual recursion, recursive definition lookup, zero references, rename from declaration, and references after parameter changes' + ) +} finally { + clearTimeout(timeout) +} +process.exit(0) diff --git a/spx-gui/src/apps/xbuilder/.env b/spx-gui/src/apps/xbuilder/.env index a1bd316221..5a371377a0 100644 --- a/spx-gui/src/apps/xbuilder/.env +++ b/spx-gui/src/apps/xbuilder/.env @@ -72,3 +72,7 @@ VITE_DEFAULT_LANG="en" # Default project font family names, separated by commas. VITE_DEFAULT_FONT_PREFERENCES="default" + +# Route opened from the home page in Vercel Preview deployments. +# Leave empty for normal deployments. +VITE_PREVIEW_DEFAULT_ROUTE="" diff --git a/spx-gui/src/apps/xbuilder/env.ts b/spx-gui/src/apps/xbuilder/env.ts index adc1e3a52a..64c2af0ad8 100644 --- a/spx-gui/src/apps/xbuilder/env.ts +++ b/spx-gui/src/apps/xbuilder/env.ts @@ -9,6 +9,7 @@ export const defaultFontPreferences = (import.meta.env.VITE_DEFAULT_FONT_PREFERE .split(',') .map((name) => name.trim()) .filter((name) => name !== '') +export const previewDefaultRoute = (import.meta.env.VITE_PREVIEW_DEFAULT_ROUTE as string) || null export const accountOAuthClientId = import.meta.env.VITE_ACCOUNT_OAUTH_CLIENT_ID as string const sentryTracesSampleRate = parseFloat(import.meta.env.VITE_SENTRY_TRACES_SAMPLE_RATE as string) const sentryLSPSampleRate = parseFloat(import.meta.env.VITE_SENTRY_LSP_SAMPLE_RATE as string) diff --git a/spx-gui/src/apps/xbuilder/router.test.ts b/spx-gui/src/apps/xbuilder/router.test.ts new file mode 100644 index 0000000000..08d65beb75 --- /dev/null +++ b/spx-gui/src/apps/xbuilder/router.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { getHomePageRoute } from './router' + +describe('getHomePageRoute', () => { + it('opens the configured project from a preview home page', () => { + expect(getHomePageRoute('/editor/nighca/match3/sprites/Board/code')).toMatchObject({ + path: '/', + redirect: '/editor/nighca/match3/sprites/Board/code' + }) + }) + + it('keeps the normal home page without preview configuration', () => { + const route = getHomePageRoute(null) + expect(route).toMatchObject({ path: '/' }) + expect('component' in route).toBe(true) + expect('redirect' in route).toBe(false) + }) +}) diff --git a/spx-gui/src/apps/xbuilder/router.ts b/spx-gui/src/apps/xbuilder/router.ts index fbe4b86872..50e772fd57 100644 --- a/spx-gui/src/apps/xbuilder/router.ts +++ b/spx-gui/src/apps/xbuilder/router.ts @@ -1,6 +1,7 @@ import type { App } from 'vue' import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router' import type { ExploreOrder } from '@/apis/project' +import { previewDefaultRoute } from './env' import { searchKeywordQueryParamName } from './pages/community/search.vue' export function getProjectEditorRoute(ownerName: string, projectName: string, publish = false) { @@ -40,6 +41,22 @@ export function getExploreRoute(order?: ExploreOrder) { export const homePageName = 'home' +export function getHomePageRoute(defaultRoute: string | null): RouteRecordRaw { + return defaultRoute == null + ? { + path: '/', + name: homePageName, + component: () => import('./pages/community/home.vue') + } + : { + path: '/', + name: homePageName, + redirect: defaultRoute + } +} + +const homePageRoute = getHomePageRoute(previewDefaultRoute) + declare module 'vue-router' { interface RouteMeta { /** Whether the route is a search page */ @@ -52,11 +69,7 @@ const routes: Array = [ path: '/', component: () => import('./pages/community/index.vue'), children: [ - { - path: '/', - name: homePageName, - component: () => import('./pages/community/home.vue') - }, + homePageRoute, { path: '/explore', component: () => import('./pages/community/explore.vue') diff --git a/spx-gui/src/components/editor/spx-code-editor/lsp/spx-lsp-client.ts b/spx-gui/src/components/editor/spx-code-editor/lsp/spx-lsp-client.ts index f92c49f088..b1b9ed44f5 100644 --- a/spx-gui/src/components/editor/spx-code-editor/lsp/spx-lsp-client.ts +++ b/spx-gui/src/components/editor/spx-code-editor/lsp/spx-lsp-client.ts @@ -420,6 +420,10 @@ export class SpxLSPClient extends Disposable implements ILSPClient { return this.request(ctx, lsp.DefinitionRequest.method, params) } + async textDocumentReferences(ctx: RequestContext, params: lsp.ReferenceParams): Promise { + return this.request(ctx, lsp.ReferencesRequest.method, params) + } + async textDocumentTypeDefinition( ctx: RequestContext, params: lsp.TypeDefinitionParams diff --git a/spx-gui/src/components/xgo-code-editor/code-editor.ts b/spx-gui/src/components/xgo-code-editor/code-editor.ts index 0b3962f447..a95d123f42 100644 --- a/spx-gui/src/components/xgo-code-editor/code-editor.ts +++ b/spx-gui/src/components/xgo-code-editor/code-editor.ts @@ -44,6 +44,7 @@ import { CompletionProvider } from './completion' import { DiagnosticsProvider } from './diagnostics' import { SnippetVariablesProvider } from './snippet-variables' import type { ICopilot } from './copilot' +import type { FunctionChangeReview } from './ui/function-change-review' const formatTabSize = 4 const formatInsertSpaces = false @@ -58,6 +59,14 @@ export type CodeEditorParams = { } export class CodeEditor extends Disposable { + private functionChangeReviewRef = shallowRef(null) + get functionChangeReview() { + return this.functionChangeReviewRef.value + } + setFunctionChangeReview(review: FunctionChangeReview | null) { + this.functionChangeReviewRef.value?.dispose() + this.functionChangeReviewRef.value = review + } readonly monaco: Monaco readonly project: IXGoProject readonly history: History @@ -85,6 +94,7 @@ export class CodeEditor extends Disposable { ) this.diagnosticsProviderRef = shallowRef(new DiagnosticsProvider(params.lspClient, params.project)) this.snippetVariablesProviderRef = shallowRef(new SnippetVariablesProvider()) + this.addDisposer(() => this.setFunctionChangeReview(null)) } private hoverProviderRef: ShallowRef @@ -206,15 +216,25 @@ export class CodeEditor extends Disposable { await Promise.all(textDocuments.map((td) => this.formatTextDocument(td.id))) } + private applyingWorkspaceEdit = false + get isApplyingWorkspaceEdit() { + return this.applyingWorkspaceEdit + } + private applyWorkspaceEdit(workspaceEdit: lsp.WorkspaceEdit) { if (workspaceEdit.changes == null) return // For now, we support `changes` only - for (const [uri, edits] of Object.entries(workspaceEdit.changes)) { - const textDocument = this.getTextDocument({ uri }) - if (textDocument == null) { - console.warn(`Text document not found for uri: ${uri}`) - continue + this.applyingWorkspaceEdit = true + try { + for (const [uri, edits] of Object.entries(workspaceEdit.changes)) { + const textDocument = this.getTextDocument({ uri }) + if (textDocument == null) { + console.warn(`Text document not found for uri: ${uri}`) + continue + } + textDocument.pushEdits(edits.map(fromLSPTextEdit)) } - textDocument.pushEdits(edits.map(fromLSPTextEdit)) + } finally { + this.applyingWorkspaceEdit = false } } 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..fb950bd565 --- /dev/null +++ b/spx-gui/src/components/xgo-code-editor/hover.test.ts @@ -0,0 +1,123 @@ +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 { builtInCommandViewReferences, 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), + textDocumentReferences: vi.fn().mockResolvedValue([ + { + uri: 'file:///caller.spx', + range: { + start: { line: 8, character: 2 }, + end: { line: 8, character: 10 } + } + } + ]), + 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 only view definition for a call 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]) + expect(hover?.actions[0].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('offers view references at the declaration itself', 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.map((action) => action.command)).toEqual([builtInCommandViewReferences]) + expect(hover?.actions[0].arguments[2]).toEqual([ + { + textDocument: { uri: 'file:///caller.spx' }, + range: { + start: { line: 9, column: 3 }, + end: { line: 9, column: 11 } + } + } + ]) + }) + + it('omits view references when the declaration has no references', async () => { + const sourceUri = 'file:///source.spx' + const client = makeLSPClient(sourceUri) + vi.mocked(client.textDocumentReferences).mockResolvedValue([]) + const provider = new HoverProvider(client, documentBase) + + const hover = await provider.provideHover( + { textDocument: makeTextDocument(sourceUri), signal: new AbortController().signal }, + { line: 5, column: 2 } + ) + + expect(hover?.actions).toEqual([]) + }) + + it('offers view definition for a recursive call in the same document', async () => { + const uri = 'file:///main.spx' + const client = makeLSPClient(uri) + vi.mocked(client.textDocumentDefinition).mockResolvedValue({ + uri, + range: { start: { line: 1, character: 5 }, end: { line: 1, character: 13 } } + }) + const provider = new HoverProvider(client, documentBase) + const hover = await provider.provideHover( + { textDocument: makeTextDocument(uri), signal: new AbortController().signal }, + { line: 5, column: 2 } + ) + expect(hover?.actions.map((action) => action.command)).toEqual([builtInCommandViewDefinition]) + }) +}) diff --git a/spx-gui/src/components/xgo-code-editor/hover.ts b/spx-gui/src/components/xgo-code-editor/hover.ts index 1da876a8f7..1cb3c36631 100644 --- a/spx-gui/src/components/xgo-code-editor/hover.ts +++ b/spx-gui/src/components/xgo-code-editor/hover.ts @@ -11,7 +11,8 @@ import type { ILSPClient } from './lsp/types' import { builtInCommandCopilotExplain, CopilotExplainKind, - builtInCommandGoToDefinition, + builtInCommandViewReferences, + builtInCommandViewDefinition, builtInCommandRename } from './ui/code-editor-ui' @@ -50,11 +51,11 @@ export class HoverProvider implements IHoverProvider { } } - private async getGoToDefinitionAction(position: Position, lspParams: lsp.TextDocumentPositionParams) { + private async getDefinitionActions(ctx: HoverContext, position: Position, lspParams: lsp.TextDocumentPositionParams) { const [definition, typeDefinition] = ( await Promise.all([ - this.lspClient.textDocumentDefinition({}, lspParams), - this.lspClient.textDocumentTypeDefinition({}, lspParams) + this.lspClient.textDocumentDefinition({ signal: ctx.signal }, lspParams), + this.lspClient.textDocumentTypeDefinition({ signal: ctx.signal }, lspParams) ]) ).map((def) => { if (def == null) return null @@ -64,16 +65,58 @@ 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: [ + const isDeclaration = location.uri === lspParams.textDocument.uri && containsPosition(range, position) + const target = { + textDocument: { uri: location.uri }, + range + } + if (isDeclaration) { + const locations = await this.lspClient.textDocumentReferences( + { signal: ctx.signal }, + { + ...lspParams, + context: { includeDeclaration: false } + } + ) + const references = new Map() + for (const reference of locations ?? []) { + const referenceRange = fromLSPRange(reference.range) + const key = `${reference.uri}:${referenceRange.start.line}:${referenceRange.start.column}:${referenceRange.end.line}:${referenceRange.end.column}` + references.set(key, { textDocument: { uri: reference.uri }, range: referenceRange }) + } + if (references.size === 0) return [] + const referenceLocations = [...references.values()].sort( + (a, b) => + a.textDocument.uri.localeCompare(b.textDocument.uri) || + a.range.start.line - b.range.start.line || + a.range.start.column - b.range.start.column + ) + return [ { - textDocument: { uri: location.uri }, - range + command: builtInCommandViewReferences, + arguments: [ + target, + { + textDocument: lspParams.textDocument, + position + }, + referenceLocations + ] satisfies CommandArgs } - ] satisfies CommandArgs + ] } + return [ + { + command: builtInCommandViewDefinition, + arguments: [ + target, + { + textDocument: lspParams.textDocument, + position + } + ] satisfies CommandArgs + } + ] } private async getRenameAction(ctx: HoverContext, position: Position, lspParams: lsp.TextDocumentPositionParams) { @@ -105,12 +148,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(ctx, 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/lsp/types.ts b/spx-gui/src/components/xgo-code-editor/lsp/types.ts index a0205ce220..ae722ca2dc 100644 --- a/spx-gui/src/components/xgo-code-editor/lsp/types.ts +++ b/spx-gui/src/components/xgo-code-editor/lsp/types.ts @@ -49,6 +49,7 @@ export interface ILSPClient { // -- Standard LSP methods -- textDocumentHover(ctx: RequestContext, params: lsp.HoverParams): Promise textDocumentDefinition(ctx: RequestContext, params: lsp.DefinitionParams): Promise + textDocumentReferences(ctx: RequestContext, params: lsp.ReferenceParams): Promise textDocumentTypeDefinition(ctx: RequestContext, params: lsp.TypeDefinitionParams): Promise textDocumentPrepareRename( ctx: RequestContext, 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..9ee30b1369 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, UITooltip, 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,8 @@ 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 ReferenceSelectionModal from './ReferenceSelectionModal.vue' +import FunctionChangeReview from './FunctionChangeReview.vue' import ZoomControl from './ZoomControl.vue' import { userLocalStorageRef } from '@/utils/user-storage' @@ -81,6 +82,17 @@ const monacoEditorOptions = computed(() => ({ + ...monacoEditorOptions.value, + readOnly: true, + domReadOnly: true, + glyphMargin: false, + folding: false, + lineDecorationsWidth: 16, + lineNumbersMinChars: 4, + selectOnLineNumbers: false +})) + const monacoEditorRef = shallowRef(null) async function handleMonacoEditorInit(editor: MonacoEditor) { @@ -256,16 +268,79 @@ providePopupContainer(codeEditorEl) :class="{ 'bg-black/10': isResizing }" :style="{ left: `${sidebarWidth}px` }" > - +
+ +
+ +
+ + {{ + $t({ + en: `Current: ${$t(uiRef.activeTextDocument.displayName)}`, + zh: `当前位置:${$t(uiRef.activeTextDocument.displayName)}` + }) + }} + + + + {{ $t({ en: 'Exit navigation', zh: '退出导航' }) }} + +
+
+
+
+ +
+ +
+
@@ -273,9 +348,12 @@ providePopupContainer(codeEditorEl) - + diff --git a/spx-gui/src/components/xgo-code-editor/ui/FunctionChangeReview.vue b/spx-gui/src/components/xgo-code-editor/ui/FunctionChangeReview.vue new file mode 100644 index 0000000000..de8eb3b205 --- /dev/null +++ b/spx-gui/src/components/xgo-code-editor/ui/FunctionChangeReview.vue @@ -0,0 +1,107 @@ + + + diff --git a/spx-gui/src/components/xgo-code-editor/ui/ReferenceSelectionModal.test.ts b/spx-gui/src/components/xgo-code-editor/ui/ReferenceSelectionModal.test.ts new file mode 100644 index 0000000000..e75150a94e --- /dev/null +++ b/spx-gui/src/components/xgo-code-editor/ui/ReferenceSelectionModal.test.ts @@ -0,0 +1,114 @@ +import { defineComponent, h, nextTick, onBeforeUnmount, onMounted } from 'vue' +import { mount } from '@vue/test-utils' +import { describe, expect, it, vi } from 'vitest' +import type { TextDocument } from '../text-document' +import type { MonacoEditor } from '../monaco' +import ReferenceSelectionModal from './ReferenceSelectionModal.vue' +import type { ReferenceSelection } from './code-editor-ui' + +function makeSelection(): ReferenceSelection { + const textDocument = { + id: { uri: 'file:///Cube.spx' }, + displayName: { en: 'Cube', zh: 'Cube' }, + monacoTextModel: { uri: 'file:///Cube.spx' } + } as unknown as TextDocument + return { + source: { + textDocument, + position: { line: 6, column: 6 }, + viewState: null + }, + references: [ + { + textDocument, + range: { + start: { line: 27, column: 3 }, + end: { line: 27, column: 12 } + }, + code: 'setRowCol(inits.row, inits.col, false)' + }, + { + textDocument, + range: { + start: { line: 31, column: 3 }, + end: { line: 31, column: 12 } + }, + code: 'setRowCol(row, col, false)' + } + ] + } +} + +describe('ReferenceSelectionModal', () => { + it('creates a fresh preview editor when reopened', async () => { + const previewEditors: Array< + MonacoEditor & { + disposed: boolean + } + > = [] + const MonacoEditorStub = defineComponent({ + emits: ['init'], + setup(_, { emit }) { + const editor = { + disposed: false, + setModel: vi.fn(function (this: { disposed: boolean }) { + if (this.disposed) throw new Error('Preview editor has been disposed') + }), + setSelection: vi.fn(), + revealRangeNearTopIfOutsideViewport: vi.fn() + } as unknown as MonacoEditor & { disposed: boolean } + previewEditors.push(editor) + onMounted(() => emit('init', editor)) + onBeforeUnmount(() => { + editor.disposed = true + }) + return () => h('div') + } + }) + const UIModalStub = defineComponent({ + props: { visible: Boolean }, + setup(props, { slots }) { + return () => (props.visible ? h('div', slots.default?.()) : null) + } + }) + const selection = makeSelection() + const wrapper = mount(ReferenceSelectionModal, { + props: { + monaco: {} as any, + options: {}, + selection + }, + global: { + mocks: { + $t: (message: { en: string } | string) => (typeof message === 'string' ? message : message.en) + }, + stubs: { + UIModal: UIModalStub, + UIModalClose: true, + MonacoEditorComp: MonacoEditorStub + } + } + }) + await nextTick() + + expect(previewEditors).toHaveLength(1) + expect(previewEditors[0].setModel).toHaveBeenCalledTimes(1) + + await wrapper.setProps({ selection: null }) + await nextTick() + expect(previewEditors[0].disposed).toBe(true) + + const reopenedSelection = makeSelection() + await wrapper.setProps({ selection: reopenedSelection }) + await nextTick() + + expect(previewEditors).toHaveLength(2) + expect(previewEditors[0].setModel).toHaveBeenCalledTimes(1) + expect(previewEditors[1].setModel).toHaveBeenCalledTimes(1) + + const openButton = wrapper.findAll('button').find((button) => button.text() === 'Open') + expect(openButton).toBeDefined() + await openButton!.trigger('click') + expect(wrapper.emitted('open')?.at(-1)).toEqual([reopenedSelection.references[0]]) + }) +}) diff --git a/spx-gui/src/components/xgo-code-editor/ui/ReferenceSelectionModal.vue b/spx-gui/src/components/xgo-code-editor/ui/ReferenceSelectionModal.vue new file mode 100644 index 0000000000..cf9a5c76ad --- /dev/null +++ b/spx-gui/src/components/xgo-code-editor/ui/ReferenceSelectionModal.vue @@ -0,0 +1,197 @@ + + + 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..3f3378b512 --- /dev/null +++ b/spx-gui/src/components/xgo-code-editor/ui/code-editor-ui.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it, vi } from 'vitest' +import type { I18n } from '@/utils/i18n' +import type { CodeEditor } from '../code-editor' +import type { Range, TextDocumentIdentifier, TextDocumentPosition, TextDocumentRange } 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 }, + getLineContent: (line: number) => `${name} line ${line}` + } as unknown as TextDocument +} + +function makeController() { + const source = makeTextDocument('file:///source.spx', 'Source') + const target = makeTextDocument('file:///target.spx', 'Target') + const other = makeTextDocument('file:///other.spx', 'Other') + const documents = new Map([source, target, other].map((document) => [document.id.uri, document])) + 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: [{ position: { lineNumber: 4, column: 18 } }] } + const targetViewState = { cursorState: [{ position: { lineNumber: 5, column: 1 } }] } + const editor = { + saveViewState: vi.fn().mockReturnValueOnce(sourceViewState).mockReturnValue(targetViewState), + 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: 4, column: 18 } + ;(controller as any).observeDefinition = vi.fn() + return { controller, editor, source, sourceViewState, target, other } +} + +const targetRange: Range = { + start: { line: 5, column: 1 }, + end: { line: 5, column: 7 } +} + +function sourceAt(textDocument: TextDocument, line = 4): TextDocumentPosition { + return { textDocument: textDocument.id, position: { line, column: 18 } } +} + +function targetAt(textDocument: TextDocument, line = 5): TextDocumentRange { + return { + textDocument: textDocument.id, + range: { ...targetRange, start: { line, column: 1 }, end: { line, column: 7 } } + } +} + +describe('CodeEditorUIController definition navigation', () => { + it('opens a definition directly and records the origin for back navigation', () => { + const { controller, source, sourceViewState, target } = makeController() + + ;(controller as any).openDefinition(targetAt(target), sourceAt(source)) + + expect(controller.activeTextDocument).toBe(target) + expect(controller.previousNavigationLocation).toMatchObject({ + textDocument: source, + position: { line: 4, column: 18 }, + viewState: sourceViewState + }) + expect((controller as any).observeDefinition).toHaveBeenCalledWith(targetAt(target)) + }) + + it('opens one reference directly without showing the selection modal', () => { + const { controller, source, target, other } = makeController() + + ;(controller as any).openReferences(targetAt(target), sourceAt(source), [targetAt(other, 9)]) + + expect(controller.activeTextDocument).toBe(other) + expect(controller.referenceSelection).toBeNull() + expect(controller.previousNavigationLocation?.textDocument).toBe(source) + }) + + it('shows the modal for multiple references and opens the selected reference on request', () => { + const { controller, source, target, other } = makeController() + const references = [targetAt(source, 7), targetAt(other, 9)] + + ;(controller as any).openReferences(targetAt(target), sourceAt(source), references) + + expect(controller.activeTextDocument).toBe(source) + expect(controller.previousNavigationLocation).toBeNull() + expect(controller.referenceSelection?.references).toHaveLength(2) + + controller.openSelectedReference(controller.referenceSelection!.references[1]) + expect(controller.activeTextDocument).toBe(other) + expect(controller.referenceSelection).toBeNull() + expect(controller.previousNavigationLocation?.textDocument).toBe(source) + }) + + it('does nothing when no references are available', () => { + const { controller, source, target } = makeController() + + ;(controller as any).openReferences(targetAt(target), sourceAt(source), []) + + expect(controller.activeTextDocument).toBe(source) + expect(controller.referenceSelection).toBeNull() + expect(controller.previousNavigationLocation).toBeNull() + }) + + it('returns one level at a time and can exit to the first origin', () => { + const { controller, editor, source, sourceViewState, target, other } = makeController() + + ;(controller as any).openDefinition(targetAt(target), sourceAt(source)) + ;(controller as any).openDefinition(targetAt(other, 9), sourceAt(target, 5)) + + controller.goBack() + expect(controller.activeTextDocument).toBe(target) + expect(controller.previousNavigationLocation?.textDocument).toBe(source) + ;(controller as any).openDefinition(targetAt(other, 9), sourceAt(target, 5)) + controller.exitNavigation() + expect(controller.activeTextDocument).toBe(source) + expect(controller.previousNavigationLocation).toBeNull() + expect(editor.restoreViewState).toHaveBeenLastCalledWith(sourceViewState) + }) +}) 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..5dad96260d 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 @@ -44,6 +43,7 @@ import { type ICopilot } from '../copilot' import type { CodeEditor } from '../code-editor' +import { DefinitionPeekController, type PeekReference } from './definition-peek' export * from './hover' export * from './completion' @@ -89,8 +89,12 @@ 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 builtInCommandViewDefinition: Command<[target: TextDocumentRange, source: TextDocumentPosition], void> = + 'xgo.viewDefinition' +export const builtInCommandViewReferences: Command< + [target: TextDocumentRange, source: TextDocumentPosition, references: TextDocumentRange[]], + void +> = 'xgo.viewReferences' 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 +107,17 @@ export type InternalAction = { arguments: A } +export type ReferenceSelection = { + source: CodeNavigationEntry + references: PeekReference[] +} + +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 +192,151 @@ export class CodeEditorUIController extends Disposable implements ICodeEditorUIC dropIndicatorController = new DropIndicatorController(this) snippetParser = new SnippetParser(this) - /** Temporary text document IDs */ - private tempTextDocumentIds = shallowReactive([]) + private referenceSelectionRef = shallowRef(null) + get referenceSelection() { + return this.referenceSelectionRef.value + } + + private definitionObservers = new Map() - /** 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 observeDefinition(target: TextDocumentRange, references?: PeekReference[]) { + const key = `${target.textDocument.uri}:${target.range.start.line}:${target.range.start.column}` + let observer = this.definitionObservers.get(key) + if (observer == null) { + const textDocument = this.codeEditor.getTextDocument(target.textDocument) + if (textDocument == null) return + observer = new DefinitionPeekController(this.codeEditor, textDocument, target.range) + this.definitionObservers.set(key, observer) + if (references == null) void observer.loadReferences() + } + if (references != null) observer.setReferences(references) + } + + private disposeDefinitionObservers() { + this.definitionObservers.forEach((observer) => observer.dispose()) + this.definitionObservers.clear() + } + + private captureNavigationEntry(source: TextDocumentPosition): CodeNavigationEntry | null { + const sourceDocument = this.activeTextDocument + if (sourceDocument == null) return null + return { + textDocument: sourceDocument, + position: source.position, + viewState: this.editor.saveViewState() + } + } + + private navigateTo(target: TextDocumentRange, source: CodeNavigationEntry) { + if (this.codeEditor.getTextDocument(target.textDocument) == null) return + this.navigationStack.push(source) + this.referenceSelectionRef.value = null + this.open(target.textDocument, target.range) + } + + private openDefinition(target: TextDocumentRange, source: TextDocumentPosition) { + const entry = this.captureNavigationEntry(source) + if (entry == null) return + this.observeDefinition(target) + this.navigateTo(target, entry) + } + + private resolveReferences(references: TextDocumentRange[]) { + return references.flatMap((reference) => { + const textDocument = this.codeEditor.getTextDocument(reference.textDocument) + if (textDocument == null) return [] + return [ + { + textDocument, + range: reference.range, + code: textDocument.getLineContent(reference.range.start.line).trim() + } + ] }) } - closeTempTextDocuments() { - this.tempTextDocumentIds.splice(0) - if (!this.isDisposed) this.setActiveTextDocument(this.mainTextDocumentId) + private openReferences( + definition: TextDocumentRange, + source: TextDocumentPosition, + referenceLocations: TextDocumentRange[] + ) { + const entry = this.captureNavigationEntry(source) + if (entry == null) return + const references = this.resolveReferences(referenceLocations) + if (references.length === 0) return + this.observeDefinition(definition, references) + if (references.length === 1) { + this.navigateTo({ textDocument: references[0].textDocument.id, range: references[0].range }, entry) + return + } + this.referenceSelectionRef.value = { source: entry, references } + } + + closeReferenceSelection() { + this.referenceSelectionRef.value = null + this.editor.focus() + } + + openSelectedReference(reference: PeekReference) { + const selection = this.referenceSelection + if (selection == null) return + this.navigateTo({ textDocument: reference.textDocument.id, range: reference.range }, selection.source) + } + + private reviewingCallId: number | null = null + get isReviewingFunctionCall() { + return this.reviewingCallId != null + } + + continueFunctionReview() { + const review = this.codeEditor.functionChangeReview + const source = this.activeTextDocument + if (review == null || source == null) return + if (this.reviewingCallId != null) { + const current = review.calls.find((call) => call.id === this.reviewingCallId) + if (current != null) review.markChecked(current) + this.reviewingCallId = null + } + const entry = this.captureNavigationEntry({ + textDocument: source.id, + position: this.cursorPosition ?? { line: 1, column: 1 } + }) + if (entry == null) return + const call = review.remaining[0] + if (call == null) { + this.navigateTo(review.definition.target, entry) + return + } + this.reviewingCallId = call.id + this.navigateTo(call.location.target, entry) + } + + private navigationStack = shallowReactive([]) + get previousNavigationLocation() { + return this.navigationStack[this.navigationStack.length - 1] ?? null + } + + goBack() { + const previous = this.navigationStack.pop() + if (previous == null) return + this.referenceSelectionRef.value = null + this.reviewingCallId = null + this.setActiveTextDocument(previous.textDocument.id) + if (previous.viewState != null) this.editor.restoreViewState(previous.viewState) + this.editor.focus() + if (this.navigationStack.length === 0) this.disposeDefinitionObservers() + } + + exitNavigation() { + const origin = this.navigationStack[0] + if (origin == null) return + this.navigationStack.splice(0) + this.referenceSelectionRef.value = null + this.reviewingCallId = null + this.setActiveTextDocument(origin.textDocument.id) + if (origin.viewState != null) this.editor.restoreViewState(origin.viewState) + this.disposeDefinitionObservers() + this.editor.focus() } /** Current active text document ID */ @@ -212,12 +357,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 +364,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 } @@ -615,13 +749,16 @@ 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) - } + this.registerCommand(builtInCommandViewDefinition, { + icon: 'view', + title: { en: 'View definition', zh: '查看定义' }, + handler: (target, source) => this.openDefinition(target, source) + }) + + this.registerCommand(builtInCommandViewReferences, { + icon: 'view', + title: { en: 'View references', zh: '查看引用' }, + handler: (target, source, references) => this.openReferences(target, source, references) }) this.registerCommand(builtInCommandInvokeInputHelper, { @@ -717,6 +854,7 @@ export class CodeEditorUIController extends Disposable implements ICodeEditorUIC } dispose() { + this.disposeDefinitionObservers() this.snippetParser.dispose() this.dropIndicatorController.dispose() this.inlayHintController.dispose() 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/common.ts b/spx-gui/src/components/xgo-code-editor/ui/common.ts index 0b8cd6e0ca..63f7ba7680 100644 --- a/spx-gui/src/components/xgo-code-editor/ui/common.ts +++ b/spx-gui/src/components/xgo-code-editor/ui/common.ts @@ -95,7 +95,8 @@ export function toAbsolutePosition(position: Position, editor: MonacoEditor): Ab export function useDecorations( /** Returns decorations to be rendered in the editor. */ - getDecorations: () => monaco.editor.IModelDeltaDecoration[] + getDecorations: () => monaco.editor.IModelDeltaDecoration[], + getEditor?: () => MonacoEditor ) { const codeEditorUICtx = useCodeEditorUICtx() let collection: monaco.editor.IEditorDecorationsCollection | null = null @@ -107,7 +108,7 @@ export function useDecorations( return } - collection = collection ?? codeEditorUICtx.ui.editor.createDecorationsCollection([]) + collection = collection ?? (getEditor?.() ?? codeEditorUICtx.ui.editor).createDecorationsCollection([]) collection.set(decorations) }) onUnmounted(() => collection?.clear()) diff --git a/spx-gui/src/components/xgo-code-editor/ui/definition-peek.test.ts b/spx-gui/src/components/xgo-code-editor/ui/definition-peek.test.ts new file mode 100644 index 0000000000..fe58cbc429 --- /dev/null +++ b/spx-gui/src/components/xgo-code-editor/ui/definition-peek.test.ts @@ -0,0 +1,285 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { nextTick, shallowRef } from 'vue' +import type { CodeEditor } from '../code-editor' +import type { Position, Range, TextDocumentIdentifier } from '../common' +import type { TextDocument } from '../text-document' +import type { Monaco, monaco } from '../monaco' +import { DefinitionPeekController } from './definition-peek' +import { builtInCommandRename, builtInCommandViewReferences } from './code-editor-ui' + +const definitionRange: Range = { start: { line: 1, column: 6 }, end: { line: 1, column: 15 } } + +function makeDocument(uri: string, code: string) { + let counter = 0 + let version = 1 + const decorations = new Map() + const listeners = new Set<(event: monaco.editor.IModelContentChangedEvent) => void>() + const getOffsetAt = (position: Position) => + code + .split('\n') + .slice(0, position.line - 1) + .reduce((sum, line) => sum + line.length + 1, 0) + + position.column - + 1 + const getPositionAt = (offset: number) => { + const lines = code.slice(0, offset).split('\n') + return { line: lines.length, column: lines[lines.length - 1].length + 1 } + } + const model = { + getVersionId: () => version, + deltaDecorations: vi.fn((old: string[], added: { range: monaco.IRange }[]) => { + old.forEach((id) => decorations.delete(id)) + return added.map((item) => { + const id = String(++counter) + decorations.set(id, item.range) + return id + }) + }), + getDecorationRange: (id: string) => decorations.get(id), + onDidChangeContent: (callback: (event: monaco.editor.IModelContentChangedEvent) => void) => { + listeners.add(callback) + return { dispose: () => listeners.delete(callback) } + }, + getValue: () => code + } + const textDocument = { + id: { uri }, + displayName: { en: uri, zh: uri }, + monacoTextModel: model, + getValue: () => code, + getOffsetAt, + getPositionAt, + getValueInRange: (range: Range) => code.slice(getOffsetAt(range.start), getOffsetAt(range.end)), + getLineContent: (line: number) => code.split('\n')[line - 1] + } as unknown as TextDocument + return { + textDocument, + model, + decorations, + replaceSameLength(before: string, after: string, isFlush = false) { + expect(after.length).toBe(before.length) + const rangeOffset = code.indexOf(before) + code = code.replace(before, after) + const event = { + versionId: ++version, + isFlush, + isUndoing: false, + isRedoing: false, + changes: [{ rangeOffset, rangeLength: before.length, text: after }] + } as monaco.editor.IModelContentChangedEvent + ;[...listeners].forEach((callback) => callback(event)) + }, + insertLine() { + code = '\n' + code + for (const [id, range] of decorations) + decorations.set(id, { + ...range, + startLineNumber: range.startLineNumber + 1, + endLineNumber: range.endLineNumber + 1 + }) + listeners.forEach((callback) => + callback({ + changes: [], + isFlush: false, + isUndoing: false, + isRedoing: false + } as unknown as monaco.editor.IModelContentChangedEvent) + ) + } + } +} + +function location(uri: string, line: number, character = 0) { + return { uri, range: { start: { line, character }, end: { line, character: character + 9 } } } +} + +function setup() { + const stage = makeDocument('file:///main.spx', 'func factorial(n int) int {\n return factorial(n - 1)\n}') + const board = makeDocument('file:///Board.spx', 'factorial(5)\nfactorial(3)') + const files = shallowRef({}) + const provideHover = vi.fn().mockResolvedValue(null) + const references = vi + .fn() + .mockResolvedValue([ + location(stage.textDocument.id.uri, 1, 8), + location(board.textDocument.id.uri, 1), + location(board.textDocument.id.uri, 0), + location(board.textDocument.id.uri, 0) + ]) + const codeEditor = { + monaco: { editor: { TrackedRangeStickiness: { NeverGrowsWhenTypingAtEdges: 1 } } } as unknown as Monaco, + project: { exportFiles: () => files.value, getCodeFiles: () => ['main.spx', 'Board.spx'] }, + functionChangeReview: null, + isApplyingWorkspaceEdit: false, + setFunctionChangeReview: vi.fn((review) => Object.assign(codeEditor, { functionChangeReview: review })), + lspClient: { textDocumentReferences: references }, + hoverProvider: { provideHover }, + getTextDocument: (id: TextDocumentIdentifier) => + [stage, board].find((doc) => doc.textDocument.id.uri === id.uri)?.textDocument ?? null + } as unknown as CodeEditor + const controller = new DefinitionPeekController(codeEditor, stage.textDocument, definitionRange) + return { controller, references, stage, board, files, provideHover, codeEditor } +} + +afterEach(() => { + vi.restoreAllMocks() + vi.useRealTimers() +}) + +describe('DefinitionPeekController', () => { + it('starts review from a parameter type edit and preserves original references when lookup becomes empty', async () => { + const { controller, stage, codeEditor, references } = setup() + await controller.loadReferences() + stage.replaceSameLength('n int', 'n any') + expect(codeEditor.functionChangeReview?.calls).toHaveLength(3) + expect(controller.review).toBe(codeEditor.functionChangeReview) + references.mockResolvedValue([]) + await controller.loadReferences() + expect(controller.references).toHaveLength(3) + controller.dispose() + expect(codeEditor.functionChangeReview?.calls).toHaveLength(3) + codeEditor.functionChangeReview?.dispose() + }) + + it('does not start a parameter review for body edits or semantic rename operations', async () => { + const { controller, stage, codeEditor } = setup() + await controller.loadReferences() + stage.replaceSameLength('n - 1', 'n - 2') + expect(codeEditor.functionChangeReview).toBeNull() + Object.assign(codeEditor, { isApplyingWorkspaceEdit: true }) + stage.replaceSameLength('n int', 'x int') + expect(codeEditor.functionChangeReview).toBeNull() + Object.assign(codeEditor, { isApplyingWorkspaceEdit: false }) + stage.replaceSameLength('x int', 'n int', true) + expect(codeEditor.functionChangeReview).toBeNull() + controller.dispose() + }) + + it('offers the existing semantic rename action for the hovered Peek document, without nested navigation', async () => { + const { controller, stage, provideHover } = setup() + const position = definitionRange.start + const rename = { + command: builtInCommandRename, + arguments: [{ textDocument: stage.textDocument.id, position, range: definitionRange }] + } + const hover = { + contents: [], + range: definitionRange, + actions: [{ command: builtInCommandViewReferences, arguments: [] }, rename] + } + provideHover.mockResolvedValue(hover) + const ctx = { textDocument: stage.textDocument, signal: new AbortController().signal } + expect(await controller.provideHover(ctx, position)).toEqual({ ...hover, actions: [rename] }) + expect(provideHover).toHaveBeenCalledWith(ctx, position) + expect(hover.actions).toHaveLength(2) + controller.dispose() + }) + + it('does not offer rename for symbols the language service cannot rename', async () => { + const { controller, stage, provideHover } = setup() + const ctx = { textDocument: stage.textDocument, signal: new AbortController().signal } + expect(await controller.provideHover(ctx, definitionRange.start)).toBeNull() + provideHover.mockResolvedValue({ contents: [], actions: [] }) + expect(await controller.provideHover(ctx, definitionRange.start)).toBeNull() + controller.dispose() + }) + + it('queries all project references excluding the declaration, sorts and deduplicates without removing recursion', async () => { + const { controller, references } = setup() + await controller.loadReferences() + expect(references).toHaveBeenCalledWith( + { signal: expect.any(AbortSignal) }, + { + textDocument: { uri: 'file:///main.spx' }, + position: { line: 0, character: 5 }, + context: { includeDeclaration: false } + } + ) + expect(controller.references?.map((r) => [r.textDocument.id.uri, r.range.start.line])).toEqual([ + ['file:///Board.spx', 1], + ['file:///Board.spx', 2], + ['file:///main.spx', 2] + ]) + controller.dispose() + }) + + it('switches references and returns to the same definition model without replacing code', async () => { + const { controller, board, stage } = setup() + await controller.loadReferences() + controller.showReference(controller.references![0]) + expect(controller.current.textDocument.monacoTextModel).toBe(board.model) + controller.showDefinition() + expect(controller.current.textDocument.monacoTextModel).toBe(stage.model) + controller.dispose() + expect(stage.decorations.size).toBe(0) + expect(board.decorations.size).toBe(0) + }) + + it('tracks the definition after inserting lines, and queries its updated position', async () => { + const { controller, stage, references } = setup() + stage.insertLine() + expect(controller.current.range.start.line).toBe(2) + await controller.loadReferences() + expect(references.mock.calls[0][1].position).toEqual({ line: 1, character: 5 }) + controller.dispose() + }) + + it('distinguishes no references from failure and supports retry without losing the definition', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { controller, references } = setup() + references.mockRejectedValueOnce(new Error('offline')) + await controller.loadReferences() + expect(controller.failed).toBe(true) + expect(controller.references).toBeNull() + expect(controller.current).toBe(controller.definition) + references.mockResolvedValueOnce(null) + await controller.loadReferences() + expect(controller.failed).toBe(false) + expect(controller.references).toEqual([]) + controller.dispose() + }) + + it('ignores outdated results and results delivered after closing peek', async () => { + const { controller, references } = setup() + let finish: (value: unknown) => void = () => {} + references.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve + }) + ) + const old = controller.loadReferences() + references.mockResolvedValueOnce([]) + await controller.loadReferences() + finish([location('file:///Board.spx', 0)]) + await old + expect(controller.references).toEqual([]) + references.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve + }) + ) + const closing = controller.loadReferences() + controller.dispose() + finish([location('file:///Board.spx', 0)]) + await closing + expect(controller.references).toEqual([]) + }) + + it('refreshes after project edits and prevents selecting stale positions while updating', async () => { + vi.useFakeTimers() + const { controller, files, references } = setup() + await controller.loadReferences() + const beforeEdit = controller.references![0] + files.value = { changed: true } + await nextTick() + expect(controller.loading).toBe(true) + controller.showReference(beforeEdit) + expect(controller.current).toBe(controller.definition) + await vi.advanceTimersByTimeAsync(300) + expect(references).toHaveBeenCalledTimes(2) + expect(controller.loading).toBe(false) + controller.dispose() + }) +}) diff --git a/spx-gui/src/components/xgo-code-editor/ui/definition-peek.ts b/spx-gui/src/components/xgo-code-editor/ui/definition-peek.ts new file mode 100644 index 0000000000..fbe780fa81 --- /dev/null +++ b/spx-gui/src/components/xgo-code-editor/ui/definition-peek.ts @@ -0,0 +1,204 @@ +import { debounce } from 'lodash' +import { shallowRef, watch } from 'vue' +import { Disposable } from '@/utils/disposable' +import type { CodeEditor } from '../code-editor' +import { fromLSPRange, isRangeEmpty, toLSPPosition, type Position, type Range } from '../common' +import type { TextDocument } from '../text-document' +import type { HoverContext, IHoverProvider } from '../hover' +import { builtInCommandRename } from './code-editor-ui' +import { PeekLocation } from './peek-location' +import { readFunctionParameters } from './function-parameters' +import { FunctionChangeReview } from './function-change-review' + +export type PeekReference = { + textDocument: TextDocument + range: Range + code: string +} + +export class DefinitionPeekController extends Disposable implements IHoverProvider { + readonly definition: PeekLocation + private currentRef = shallowRef() + get current() { + return this.currentRef.value! + } + private referencesRef = shallowRef(null) + private referenceAnchors: PeekLocation[] = [] + get review() { + const review = this.codeEditor.functionChangeReview + if (review == null || review.definition.textDocument !== this.definition.textDocument) return null + const a = review.definition.range.start + const b = this.definition.range.start + return a.line === b.line && a.column === b.column ? review : null + } + get references() { + return this.review?.references ?? this.referencesRef.value + } + private loadingRef = shallowRef(false) + get loading() { + return this.review == null && this.loadingRef.value + } + private failedRef = shallowRef(false) + get failed() { + return this.review == null && this.failedRef.value + } + private request: AbortController | null = null + + constructor( + private codeEditor: CodeEditor, + textDocument: TextDocument, + range: Range + ) { + super() + this.definition = new PeekLocation(textDocument, range, codeEditor.monaco) + this.currentRef.value = this.definition + this.addDisposable(this.definition) + this.watchParameters() + this.addDisposer(() => { + this.request?.abort() + this.referenceAnchors.forEach((anchor) => anchor.dispose()) + if (this.current !== this.definition) this.current.dispose() + }) + const refresh = debounce(() => this.loadReferences(), 300) + this.addDisposer(() => refresh.cancel()) + this.addDisposer( + watch( + () => codeEditor.project.exportFiles(), + () => { + this.request?.abort() + this.loadingRef.value = true + refresh() + } + ) + ) + } + + private watchParameters() { + const doc = this.definition.textDocument + let previousCode = doc.getValue() + let previousNameEnd = doc.getOffsetAt(this.definition.range.end) + const initial = readFunctionParameters(previousCode, previousNameEnd) + if (initial == null) return + let previousKey = initial.key + const parameters = new PeekLocation( + doc, + { + start: doc.getPositionAt(initial.start), + end: doc.getPositionAt(initial.end) + }, + this.codeEditor.monaco, + true + ) + this.addDisposable(parameters) + this.addDisposable( + doc.monacoTextModel.onDidChangeContent((event) => { + const code = doc.getValue() + const current = readFunctionParameters(code, doc.getOffsetAt(this.definition.range.end)) + const key = current?.key ?? doc.getValueInRange(parameters.range) + const userEdit = + !event.isFlush && !event.isUndoing && !event.isRedoing && !this.codeEditor.isApplyingWorkspaceEdit + if (userEdit && key !== previousKey && this.codeEditor.functionChangeReview == null) { + const references = + this.referencesRef.value == null + ? null + : this.referenceAnchors.map((anchor) => ({ + textDocument: anchor.textDocument, + range: anchor.range, + code: anchor.textDocument.getLineContent(anchor.range.start.line).trim() + })) + const previousParameters = readFunctionParameters(previousCode, previousNameEnd) + this.codeEditor.setFunctionChangeReview( + new FunctionChangeReview( + this.codeEditor, + this.definition, + parameters, + references, + previousCode, + previousParameters == null ? null : { ...previousParameters, changes: event.changes } + ) + ) + } + previousCode = code + previousNameEnd = doc.getOffsetAt(this.definition.range.end) + previousKey = key + }) + ) + } + + showReference(reference: PeekReference) { + if (this.loading || this.failed) return + if (this.current !== this.definition) this.current.dispose() + this.currentRef.value = new PeekLocation(reference.textDocument, reference.range, this.codeEditor.monaco) + } + + showDefinition() { + if (this.current !== this.definition) this.current.dispose() + this.currentRef.value = this.definition + } + + async provideHover(ctx: HoverContext, position: Position) { + const hover = await this.codeEditor.hoverProvider.provideHover(ctx, position) + if (hover == null) return null + // Reuse the semantic rename action without introducing nested navigation in Peek. + const actions = hover.actions.filter((action) => action.command === builtInCommandRename) + return actions.length === 0 ? null : { ...hover, actions } + } + + async loadReferences() { + if (this.isDisposed) return + this.request?.abort() + const request = new AbortController() + this.request = request + this.loadingRef.value = true + this.failedRef.value = false + try { + if (isRangeEmpty(this.definition.range)) { + this.referencesRef.value = [] + return + } + const locations = await this.codeEditor.lspClient.textDocumentReferences( + { signal: request.signal }, + { + textDocument: this.definition.textDocument.id, + position: toLSPPosition(this.definition.range.start), + context: { includeDeclaration: false } + } + ) + if (request.signal.aborted) return + const references = new Map() + for (const location of locations ?? []) { + const textDocument = this.codeEditor.getTextDocument({ uri: location.uri }) + if (textDocument == null) continue + const range = fromLSPRange(location.range) + const key = `${location.uri}:${range.start.line}:${range.start.column}:${range.end.line}:${range.end.column}` + references.set(key, { textDocument, range, code: textDocument.getLineContent(range.start.line).trim() }) + } + this.setReferences( + [...references.values()].sort( + (a, b) => + a.textDocument.id.uri.localeCompare(b.textDocument.id.uri) || + a.range.start.line - b.range.start.line || + a.range.start.column - b.range.start.column + ) + ) + } catch (error) { + if (request.signal.aborted) return + console.warn('Failed to load symbol references', error) + this.referencesRef.value = null + this.failedRef.value = true + } finally { + if (!request.signal.aborted) this.loadingRef.value = false + } + } + + setReferences(references: PeekReference[]) { + this.referencesRef.value = references + this.referenceAnchors.forEach((anchor) => anchor.dispose()) + this.referenceAnchors = references.map( + (reference) => new PeekLocation(reference.textDocument, reference.range, this.codeEditor.monaco) + ) + this.review?.mergeReferences(references) + this.loadingRef.value = false + this.failedRef.value = false + } +} 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/function-change-review.test.ts b/spx-gui/src/components/xgo-code-editor/ui/function-change-review.test.ts new file mode 100644 index 0000000000..33fb29f723 --- /dev/null +++ b/spx-gui/src/components/xgo-code-editor/ui/function-change-review.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, it, vi } from 'vitest' +import type { CodeEditor } from '../code-editor' +import type { Position, Range, TextDocumentIdentifier, TextEdit } from '../common' +import type { TextDocument } from '../text-document' +import type { Monaco, monaco } from '../monaco' +import { FunctionChangeReview } from './function-change-review' +import { PeekLocation } from './peek-location' + +function makeDocument(uri: string, initial: string) { + let code = initial + let version = 1 + let id = 0 + const anchors = new Map() + const listeners = new Set<(event: monaco.editor.IModelContentChangedEvent) => void>() + const offset = (position: Position) => + code + .split('\n') + .slice(0, position.line - 1) + .reduce((n, line) => n + line.length + 1, 0) + + position.column - + 1 + const position = (value: number) => { + const lines = code.slice(0, value).split('\n') + return { line: lines.length, column: lines[lines.length - 1].length + 1 } + } + function edit(start: number, length: number, text: string) { + const end = start + length + const delta = text.length - length + for (const anchor of anchors.values()) { + anchor.start = anchor.start <= start ? anchor.start : anchor.start >= end ? anchor.start + delta : start + anchor.end = anchor.end < start ? anchor.end : anchor.end > end ? anchor.end + delta : start + text.length + } + code = code.slice(0, start) + text + code.slice(end) + const event = { + versionId: ++version, + changes: [{ rangeOffset: start, rangeLength: length, text }] + } as monaco.editor.IModelContentChangedEvent + ;[...listeners].forEach((listener) => listener(event)) + } + const model = { + getVersionId: () => version, + getValue: () => code, + deltaDecorations(old: string[], additions: { range: monaco.IRange }[]) { + old.forEach((key) => anchors.delete(key)) + return additions.map(({ range }) => { + const key = String(++id) + anchors.set(key, { + start: offset({ line: range.startLineNumber, column: range.startColumn }), + end: offset({ line: range.endLineNumber, column: range.endColumn }) + }) + return key + }) + }, + getDecorationRange(key: string) { + const anchor = anchors.get(key) + if (anchor == null) return null + const start = position(anchor.start), + end = position(anchor.end) + return { startLineNumber: start.line, startColumn: start.column, endLineNumber: end.line, endColumn: end.column } + }, + onDidChangeContent(listener: (event: monaco.editor.IModelContentChangedEvent) => void) { + listeners.add(listener) + return { dispose: () => listeners.delete(listener) } + } + } + const document = { + id: { uri }, + monacoTextModel: model, + getValue: () => code, + setValue: (value: string) => edit(0, code.length, value), + pushEdits(edits: TextEdit[]) { + edits + .map((item) => ({ start: offset(item.range.start), end: offset(item.range.end), text: item.newText })) + .sort((a, b) => b.start - a.start) + .forEach((item) => edit(item.start, item.end - item.start, item.text)) + }, + getOffsetAt: offset, + getPositionAt: position, + getValueInRange: (range: Range) => code.slice(offset(range.start), offset(range.end)), + getLineContent: (line: number) => code.split('\n')[line - 1] + } as unknown as TextDocument + return { + document, + edit, + anchors, + replace(before: string, after: string) { + edit(code.indexOf(before), before.length, after) + } + } +} + +function setup(incomplete = false) { + const original = 'func sum(x int) int {\n return sum(x - 1)\n}\n// unrelated' + const stage = makeDocument('file:///main.spx', original.replace('(x int)', '(x int, y int)')) + const board = makeDocument('file:///Board.spx', 'sum(5)\nsum(3)\n// other code') + const other = makeDocument('file:///Other.spx', 'println 1') + const documents = [stage, board, other] + const monaco = { + editor: { TrackedRangeStickiness: { NeverGrowsWhenTypingAtEdges: 1, AlwaysGrowsWhenTypingAtEdges: 0 } } + } as unknown as Monaco + const setReview = vi.fn() + const history = { doAction: vi.fn(async (_action, fn) => fn()) } + const editor = { + monaco, + history, + project: { getCodeFiles: () => ['main.spx', 'Board.spx', 'Other.spx'] }, + getTextDocument: (id: TextDocumentIdentifier) => + documents.find((doc) => doc.document.id.uri === id.uri)?.document ?? null, + setFunctionChangeReview: setReview + } as unknown as CodeEditor + const definition = new PeekLocation( + stage.document, + { start: { line: 1, column: 6 }, end: { line: 1, column: 9 } }, + monaco + ) + const parameters = new PeekLocation( + stage.document, + { start: { line: 1, column: 9 }, end: { line: 1, column: 23 } }, + monaco, + true + ) + const refs = [ + { + textDocument: board.document, + range: { start: { line: 1, column: 1 }, end: { line: 1, column: 4 } }, + code: 'sum(5)' + }, + { + textDocument: board.document, + range: { start: { line: 2, column: 1 }, end: { line: 2, column: 4 } }, + code: 'sum(3)' + }, + { + textDocument: stage.document, + range: { start: { line: 2, column: 9 }, end: { line: 2, column: 12 } }, + code: 'return sum(x - 1)' + } + ] + const review = new FunctionChangeReview(editor, definition, parameters, incomplete ? null : refs, original) + setReview.mockImplementation(() => review.dispose()) + const dispose = () => { + review.dispose() + definition.dispose() + parameters.dispose() + } + return { review, stage, board, other, history, original, setReview, refs, dispose } +} + +describe('FunctionChangeReview', () => { + it('retains all original calls including recursion even when new lookup returns no results', () => { + const { review, refs, dispose } = setup() + review.mergeReferences([]) + review.mergeReferences(refs) + expect(review.calls).toHaveLength(3) + expect(review.remaining).toHaveLength(3) + expect(review.canComplete).toBe(false) + review.calls.forEach((call) => review.markChecked(call)) + expect(review.canComplete).toBe(true) + dispose() + }) + + it('requires rechecking only the edited call, and all calls after another parameter change', () => { + const { review, stage, board, dispose } = setup() + review.calls.forEach((call) => review.markChecked(call)) + board.replace('sum(5)', 'sum(5, 1)') + expect(review.remaining.map((call) => call.id)).toEqual([1]) + expect(review.canUndo).toBe(true) + stage.replace('y int', 'y float64') + expect(review.remaining).toHaveLength(3) + expect(review.canUndo).toBe(true) + dispose() + }) + + it('undoes parameter and multi-document call edits as one reversible project action, preserving other files', async () => { + const { review, stage, board, other, history, original, setReview, dispose } = setup() + board.replace('sum(5)', 'sum(5, 1)') + stage.replace('sum(x - 1)', 'sum(x - 1, y)') + other.replace('println 1', 'println 2') + await review.undo() + expect(stage.document.getValue()).toBe(original) + expect(board.document.getValue()).toBe('sum(5)\nsum(3)\n// other code') + expect(other.document.getValue()).toBe('println 2') + expect(history.doAction).toHaveBeenCalledTimes(1) + expect(setReview).toHaveBeenCalledWith(null) + dispose() + }) + + it('refuses bulk undo when other code in an involved document was edited', async () => { + const { review, board, history, dispose } = setup() + board.replace('// other code', '// keep this change') + expect(review.hasConflictingEdits).toBe(true) + expect(review.canUndo).toBe(false) + await review.undo() + expect(history.doAction).not.toHaveBeenCalled() + expect(board.document.getValue()).toContain('keep this change') + dispose() + }) + + it('does not mark an incomplete parameter list or missing original references as fully reviewed', () => { + const { review, stage, dispose } = setup() + review.calls.forEach((call) => review.markChecked(call)) + stage.replace('(x int, y int)', '(x int, y int') + expect(review.parametersComplete).toBe(false) + expect(review.canComplete).toBe(false) + dispose() + const missing = setup(true) + expect(missing.review.referencesIncomplete).toBe(true) + expect(missing.review.canComplete).toBe(false) + missing.dispose() + }) + + it('finishes review without changing code or adding a save action', () => { + const { review, stage, history, setReview, dispose } = setup() + review.calls.forEach((call) => review.markChecked(call)) + const code = stage.document.getValue() + review.complete() + expect(setReview).toHaveBeenCalledWith(null) + expect(stage.document.getValue()).toBe(code) + expect(history.doAction).not.toHaveBeenCalled() + dispose() + }) +}) diff --git a/spx-gui/src/components/xgo-code-editor/ui/function-change-review.ts b/spx-gui/src/components/xgo-code-editor/ui/function-change-review.ts new file mode 100644 index 0000000000..013a5b646b --- /dev/null +++ b/spx-gui/src/components/xgo-code-editor/ui/function-change-review.ts @@ -0,0 +1,228 @@ +import { shallowRef } from 'vue' +import { diffChars } from 'diff' +import { Disposable } from '@/utils/disposable' +import type { CodeEditor } from '../code-editor' +import { getTextDocumentId, type Range, type TextEdit } from '../common' +import type { TextDocument } from '../text-document' +import type { monaco } from '../monaco' +import type { PeekReference } from './definition-peek' +import { PeekLocation } from './peek-location' +import { readFunctionParameters } from './function-parameters' + +export type ReviewCall = { + id: number + location: PeekLocation + line: PeekLocation +} + +type Guard = { location: PeekLocation; start: number; end: number; callId: number | null } +type DocumentReview = { original: string; guards: Guard[] } + +function restoreDocument(doc: TextDocument, original: string) { + const edits: TextEdit[] = [] + let offset = 0 + let start: number | null = null + let replacement = '' + const flush = () => { + if (start == null) return + edits.push({ range: { start: doc.getPositionAt(start), end: doc.getPositionAt(offset) }, newText: replacement }) + start = null + replacement = '' + } + for (const change of diffChars(doc.getValue(), original)) { + if (!change.added && !change.removed) { + flush() + offset += change.value.length + } else { + start ??= offset + if (change.added) replacement += change.value + else offset += change.value.length + } + } + flush() + // Minimal edits preserve the Peek selection and tracked definition/reference anchors. + if (edits.length > 0) doc.pushEdits(edits) +} + +export class FunctionChangeReview extends Disposable { + readonly definition: PeekLocation + readonly parameters: PeekLocation + readonly name: string + readonly referencesIncomplete: boolean + private callsRef = shallowRef([]) + get calls() { + return this.callsRef.value + } + private checkedRef = shallowRef(new Set()) + private conflictRef = shallowRef(false) + get hasConflictingEdits() { + return this.conflictRef.value + } + private revision = shallowRef(0) + private restoring = false + private originals = new Map() + private documents = new Map() + + constructor( + private codeEditor: CodeEditor, + definition: PeekLocation, + parameters: PeekLocation, + references: PeekReference[] | null, + originalDefinitionCode: string, + initialChange: { start: number; end: number; changes: monaco.editor.IModelContentChange[] } | null = null + ) { + super() + this.definition = new PeekLocation(definition.textDocument, definition.range, codeEditor.monaco) + this.parameters = new PeekLocation(parameters.textDocument, parameters.range, codeEditor.monaco, true) + this.addDisposable(this.definition) + this.addDisposable(this.parameters) + this.name = definition.textDocument.getValueInRange(definition.range) + this.referencesIncomplete = references == null + if ( + initialChange != null && + initialChange.changes.some( + (change) => + change.rangeOffset < initialChange.start || change.rangeOffset + change.rangeLength > initialChange.end + ) + ) { + this.conflictRef.value = true + } + for (const path of codeEditor.project.getCodeFiles()) { + const doc = codeEditor.getTextDocument(getTextDocumentId(path)) + if (doc != null) this.originals.set(doc, doc.getValue()) + } + this.originals.set(definition.textDocument, originalDefinitionCode) + this.addGuard(this.parameters, null) + this.mergeReferences(references ?? []) + } + + private getOffsets(location: PeekLocation) { + return { + start: location.textDocument.getOffsetAt(location.range.start), + end: location.textDocument.getOffsetAt(location.range.end) + } + } + + private addGuard(location: PeekLocation, callId: number | null) { + const doc = location.textDocument + let state = this.documents.get(doc) + if (state == null) { + const original = this.originals.get(doc) + if (original == null || (doc !== this.definition.textDocument && original !== doc.getValue())) { + this.conflictRef.value = true + } + state = { original: original ?? doc.getValue(), guards: [] } + this.documents.set(doc, state) + const initialVersion = doc.monacoTextModel.getVersionId() + this.addDisposable( + doc.monacoTextModel.onDidChangeContent((event) => { + if (this.restoring || event.versionId === initialVersion) return + this.handleChanges(doc, event) + }) + ) + } + state.guards.push({ location, callId, ...this.getOffsets(location) }) + } + + private handleChanges(doc: TextDocument, event: monaco.editor.IModelContentChangedEvent) { + const state = this.documents.get(doc)! + const checked = new Set(this.checkedRef.value) + for (const change of event.changes) { + const start = change.rangeOffset + const end = start + change.rangeLength + const touched = state.guards.filter((guard) => start <= guard.end && end >= guard.start) + if (!touched.some((guard) => start >= guard.start && end <= guard.end)) this.conflictRef.value = true + for (const guard of touched) { + if (guard.callId == null) checked.clear() + else checked.delete(guard.callId) + } + } + state.guards.forEach((guard) => Object.assign(guard, this.getOffsets(guard.location))) + this.checkedRef.value = checked + this.revision.value++ + if ([...this.documents].every(([document, state]) => document.getValue() === state.original)) { + this.codeEditor.setFunctionChangeReview(null) + } + } + + mergeReferences(references: PeekReference[]) { + for (const reference of references) { + if ( + this.calls.some( + (call) => + call.location.textDocument === reference.textDocument && + call.location.range.start.line === reference.range.start.line && + call.location.range.start.column === reference.range.start.column + ) + ) + continue + const location = new PeekLocation(reference.textDocument, reference.range, this.codeEditor.monaco) + const lineRange: Range = { + start: { line: reference.range.start.line, column: 1 }, + end: { + line: reference.range.end.line, + column: reference.textDocument.getLineContent(reference.range.end.line).length + 1 + } + } + const line = new PeekLocation(reference.textDocument, lineRange, this.codeEditor.monaco, true) + this.addDisposable(location) + this.addDisposable(line) + const call = { id: this.calls.length + 1, location, line } + this.callsRef.value = [...this.calls, call] + this.addGuard(line, call.id) + } + } + + get references(): PeekReference[] { + return this.calls.map(({ location }) => ({ + textDocument: location.textDocument, + range: location.range, + code: location.textDocument.getLineContent(location.range.start.line).trim() + })) + } + + isChecked(call: ReviewCall) { + return this.checkedRef.value.has(call.id) + } + markChecked(call: ReviewCall) { + this.checkedRef.value = new Set([...this.checkedRef.value, call.id]) + } + get remaining() { + return this.calls.filter((call) => !this.isChecked(call)) + } + get parametersComplete() { + void this.revision.value + const doc = this.definition.textDocument + return readFunctionParameters(doc.getValue(), doc.getOffsetAt(this.definition.range.end)) != null + } + get canComplete() { + return this.parametersComplete && !this.referencesIncomplete && this.remaining.length === 0 + } + get canUndo() { + void this.revision.value + return ( + !this.hasConflictingEdits && [...this.documents].every(([doc]) => this.codeEditor.getTextDocument(doc.id) === doc) + ) + } + + async undo() { + if (!this.canUndo) return + await this.codeEditor.history.doAction({ name: { en: 'Undo function adjustment', zh: '撤销函数调整' } }, () => { + // Recheck inside the history mutex; never overwrite an intervening edit. + if (!this.canUndo) return + this.restoring = true + try { + for (const [doc, state] of this.documents) { + restoreDocument(doc, state.original) + } + this.codeEditor.setFunctionChangeReview(null) + } finally { + this.restoring = false + } + }) + } + + complete() { + if (this.canComplete) this.codeEditor.setFunctionChangeReview(null) + } +} diff --git a/spx-gui/src/components/xgo-code-editor/ui/function-parameters.test.ts b/spx-gui/src/components/xgo-code-editor/ui/function-parameters.test.ts new file mode 100644 index 0000000000..affd2103eb --- /dev/null +++ b/spx-gui/src/components/xgo-code-editor/ui/function-parameters.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { readFunctionParameters } from './function-parameters' + +describe('readFunctionParameters', () => { + const prefix = 'func move' + it.each([ + ['(x, y float64)', '(x,yfloat64)'], + ['(\n x int,\n callback func(int, string) bool,\n)', '(xint,callbackfunc(int,string)bool,)'], + [' /* note */ (x int /* ) */ , y ...int)', '(xint,y...int)'], + ['(s struct { Value string `json:"(value)"` })', '(sstruct{Valuestring`json:"(value)"`})'], + ['()', '()'] + ])('extracts %s without confusing nested syntax, comments or strings', (params, key) => { + const code = prefix + params + ' int { return 0 }' + const result = readFunctionParameters(code, prefix.length)! + expect(result.key).toBe(key) + expect(code.slice(result.start, result.end)).toBe(params.slice(params.indexOf('('))) + }) + it.each(['(x int', ' /* unfinished', '(s struct { X string `unfinished })', ' = 3', ':= func(x int) {}'])( + 'does not invent a parameter list for %s', + (suffix) => { + expect(readFunctionParameters(prefix + suffix, prefix.length)).toBeNull() + } + ) +}) diff --git a/spx-gui/src/components/xgo-code-editor/ui/function-parameters.ts b/spx-gui/src/components/xgo-code-editor/ui/function-parameters.ts new file mode 100644 index 0000000000..3c0b4d39f9 --- /dev/null +++ b/spx-gui/src/components/xgo-code-editor/ui/function-parameters.ts @@ -0,0 +1,46 @@ +/** Read the parameter list following a language-service-resolved declaration name. */ +export function readFunctionParameters(code: string, nameEnd: number) { + let depth = 0 + let start: number | null = null + let key = '' + for (let i = nameEnd; i < code.length; i++) { + const char = code[i] + if (/\s/.test(char)) continue + if (code.startsWith('//', i)) { + const end = code.indexOf('\n', i + 2) + if (end === -1) return null + i = end + continue + } + if (code.startsWith('/*', i)) { + const end = code.indexOf('*/', i + 2) + if (end === -1) return null + i = end + 1 + continue + } + if (start == null) { + if (char !== '(') return null + start = i + } + if (char === '"' || char === "'" || char === '`') { + const quote = char + key += char + let closed = false + while (++i < code.length) { + key += code[i] + if (code[i] === '\\' && quote !== '`') { + if (++i < code.length) key += code[i] + } else if (code[i] === quote) { + closed = true + break + } + } + if (!closed) return null + continue + } + key += char + if (char === '(') depth++ + if (char === ')' && --depth === 0) return { start, end: i + 1, key } + } + return null +} 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(
-