Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions docs/develop/issue-3455-navigation.zh.md
Original file line number Diff line number Diff line change
@@ -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 验证覆盖跨文档多引用、递归、零引用、重命名及参数变化后的引用查询。
14 changes: 13 additions & 1 deletion docs/product/code-editor.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -66,6 +66,18 @@ The specific correspondence between element types and floating layer content is
| **string-literal as a resource-reference** | `{resourceType} {name}`<br>Sound ""explosion"" | Preview for resource | Yes, select the resource | Yes | No | Yes |
| **identifier as a resource-reference** | `{resourceType} {name}`<br>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.).
Expand Down
14 changes: 13 additions & 1 deletion docs/product/code-editor.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ Hover 是这样一种交互模式,当用户将鼠标悬浮在某个元素上

* 元素相关的操作组 Actions,包括

- 跳转到定义 Go to Definition
- 使用处显示「查看定义」,声明处显示「查看引用」,同一位置只显示一个导航操作。
- 重命名 Rename
- 解释 Explain,调起 Copilot 对当前元素的定位、作用等进行解释
- 修改引用 Modify Reference,对于 Resource Reference,提供修改引用的入口
Expand All @@ -66,6 +66,18 @@ Hover 是这样一种交互模式,当用户将鼠标悬浮在某个元素上
| **string-literal as a resource-reference** | `{resourceType} {name}`<br>Sound ""explosion"" | Preview for resource | Yes, select the resource | Yes | No | Yes |
| **identifier as a resource-reference** | `{resourceType} {name}`<br>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 中的一种特殊的提示,用于将部分代码标记为特殊状态(如错误、警告等)。
Expand Down
116 changes: 116 additions & 0 deletions spx-gui/scripts/check-definition-references.mjs
Original file line number Diff line number Diff line change
@@ -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)
4 changes: 4 additions & 0 deletions spx-gui/src/apps/xbuilder/.env
Original file line number Diff line number Diff line change
Expand Up @@ -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=""
1 change: 1 addition & 0 deletions spx-gui/src/apps/xbuilder/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
18 changes: 18 additions & 0 deletions spx-gui/src/apps/xbuilder/router.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
23 changes: 18 additions & 5 deletions spx-gui/src/apps/xbuilder/router.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down Expand Up @@ -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 */
Expand All @@ -52,11 +69,7 @@ const routes: Array<RouteRecordRaw> = [
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')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,10 @@ export class SpxLSPClient extends Disposable implements ILSPClient {
return this.request<lsp.Definition | null>(ctx, lsp.DefinitionRequest.method, params)
}

async textDocumentReferences(ctx: RequestContext, params: lsp.ReferenceParams): Promise<lsp.Location[] | null> {
return this.request<lsp.Location[] | null>(ctx, lsp.ReferencesRequest.method, params)
}

async textDocumentTypeDefinition(
ctx: RequestContext,
params: lsp.TypeDefinitionParams
Expand Down
Loading