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
2 changes: 1 addition & 1 deletion spx-gui/src/components/xgo-code-editor/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
88 changes: 88 additions & 0 deletions spx-gui/src/components/xgo-code-editor/hover.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { describe, expect, it, vi } from 'vitest'
import type { IDocumentBase } from './document-base'
import type { ILSPClient } from './lsp/types'
import type { ITextDocument } from './common'
import { HoverProvider } from './hover'
import { builtInCommandGoToDefinition, builtInCommandViewDefinition } from './ui/code-editor-ui'

function makeLSPClient(targetUri: string) {
return {
textDocumentHover: vi.fn().mockResolvedValue({
contents: { kind: 'markdown', value: 'Variable documentation' },
range: {
start: { line: 4, character: 0 },
end: { line: 4, character: 8 }
}
}),
getDefinition: vi.fn().mockResolvedValue(null),
textDocumentDefinition: vi.fn().mockResolvedValue({
uri: targetUri,
range: {
start: { line: 4, character: 0 },
end: { line: 4, character: 8 }
}
}),
textDocumentTypeDefinition: vi.fn().mockResolvedValue(null),
textDocumentPrepareRename: vi.fn().mockResolvedValue(null)
} as unknown as ILSPClient
}

const documentBase = {
getDocumentation: vi.fn().mockResolvedValue(null)
} as unknown as IDocumentBase

function makeTextDocument(uri: string) {
return { id: { uri } } as unknown as ITextDocument
}

describe('HoverProvider definition actions', () => {
it('offers view and go-to actions for a definition in another document', async () => {
const sourceUri = 'file:///source.spx'
const targetUri = 'file:///target.spx'
const provider = new HoverProvider(makeLSPClient(targetUri), documentBase)

const hover = await provider.provideHover(
{ textDocument: makeTextDocument(sourceUri), signal: new AbortController().signal },
{ line: 5, column: 2 }
)

expect(hover?.actions.map((action) => action.command)).toEqual([
builtInCommandViewDefinition,
builtInCommandGoToDefinition
])
expect(hover?.actions[0].arguments).toEqual([
{
textDocument: { uri: targetUri },
range: {
start: { line: 5, column: 1 },
end: { line: 5, column: 9 }
}
}
])
expect(hover?.actions[1].arguments).toEqual([
{
textDocument: { uri: targetUri },
range: {
start: { line: 5, column: 1 },
end: { line: 5, column: 9 }
}
},
{
textDocument: { uri: sourceUri },
position: { line: 5, column: 2 }
}
])
})

it('does not offer definition actions for the current location', async () => {
const sourceUri = 'file:///source.spx'
const provider = new HoverProvider(makeLSPClient(sourceUri), documentBase)

const hover = await provider.provideHover(
{ textDocument: makeTextDocument(sourceUri), signal: new AbortController().signal },
{ line: 5, column: 2 }
)

expect(hover?.actions).toEqual([])
})
})
38 changes: 25 additions & 13 deletions spx-gui/src/components/xgo-code-editor/hover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
builtInCommandCopilotExplain,
CopilotExplainKind,
builtInCommandGoToDefinition,
builtInCommandViewDefinition,
builtInCommandRename
} from './ui/code-editor-ui'

Expand Down Expand Up @@ -50,7 +51,7 @@ export class HoverProvider implements IHoverProvider {
}
}

private async getGoToDefinitionAction(position: Position, lspParams: lsp.TextDocumentPositionParams) {
private async getDefinitionActions(position: Position, lspParams: lsp.TextDocumentPositionParams) {
const [definition, typeDefinition] = (
await Promise.all([
this.lspClient.textDocumentDefinition({}, lspParams),
Expand All @@ -64,16 +65,27 @@ export class HoverProvider implements IHoverProvider {
const location = definition ?? typeDefinition
if (location == null) return null
const range = fromLSPRange(location.range)
if (containsPosition(range, position)) return null
return {
command: builtInCommandGoToDefinition,
arguments: [
{
textDocument: { uri: location.uri },
range
}
] satisfies CommandArgs<typeof builtInCommandGoToDefinition>
if (location.uri === lspParams.textDocument.uri && containsPosition(range, position)) return null
const target = {
textDocument: { uri: location.uri },
range
}
return [
{
command: builtInCommandViewDefinition,
arguments: [target] satisfies CommandArgs<typeof builtInCommandViewDefinition>
},
{
command: builtInCommandGoToDefinition,
arguments: [
target,
{
textDocument: lspParams.textDocument,
position
}
] satisfies CommandArgs<typeof builtInCommandGoToDefinition>
}
]
}

private async getRenameAction(ctx: HoverContext, position: Position, lspParams: lsp.TextDocumentPositionParams) {
Expand Down Expand Up @@ -105,12 +117,12 @@ export class HoverProvider implements IHoverProvider {
}
let range: Range | undefined = undefined
if (lspHover.range != null) range = fromLSPRange(lspHover.range)
const maybeActions = await Promise.all([
const [explainAction, definitionActions, renameAction] = await Promise.all([
this.getExplainAction(ctx, position),
this.getGoToDefinitionAction(position, lspParams),
this.getDefinitionActions(position, lspParams),
this.getRenameAction(ctx, position, lspParams)
])
const actions = maybeActions.filter((a) => a != null) as Action[]
const actions = [explainAction, ...(definitionActions ?? []), renameAction].filter((a) => a != null) as Action[]
return { contents, range, actions }
}
}
90 changes: 74 additions & 16 deletions spx-gui/src/components/xgo-code-editor/ui/CodeEditorUI.vue
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { getCleanupSignal } from '@/utils/disposable'
import { useI18n } from '@/utils/i18n'
import { getXGoIdentifierNameTip, validateXGoIdentifierName } from '@/utils/xgo'
import { theme, tabSize, insertSpaces } from '@/utils/xgo/highlighter'
import { providePopupContainer, useModal } from '@/components/ui'
import { providePopupContainer, UIIcon, useModal } from '@/components/ui'
import RenameModal from '@/components/common/RenameModal.vue'
import { useCodeEditor } from '../context'
import { getDdiDragData, getTextDocumentId, type Position, type Range, type TextDocumentIdentifier } from '../common'
Expand All @@ -34,7 +34,7 @@ import ContextMenuUI from './context-menu/ContextMenuUI.vue'
import InputHelperUI from './input-helper/InputHelperUI.vue'
import InlayHintUI from './inlay-hint/InlayHintUI.vue'
import DropIndicatorUI from './drop-indicator/DropIndicatorUI.vue'
import DocumentTabs from './document-tab/DocumentTabs.vue'
import DefinitionPeek from './DefinitionPeek.vue'
import ZoomControl from './ZoomControl.vue'
import { userLocalStorageRef } from '@/utils/user-storage'

Expand Down Expand Up @@ -81,6 +81,16 @@ const monacoEditorOptions = computed<monaco.editor.IStandaloneEditorConstruction
contextmenu: false
}))

const definitionPeekEditorOptions = computed<monaco.editor.IStandaloneEditorConstructionOptions>(() => ({
...monacoEditorOptions.value,
readOnly: true,
glyphMargin: false,
folding: false,
lineDecorationsWidth: 12,
lineNumbersMinChars: 2,
selectOnLineNumbers: false
}))

const monacoEditorRef = shallowRef<MonacoEditor | null>(null)

async function handleMonacoEditorInit(editor: MonacoEditor) {
Expand Down Expand Up @@ -256,26 +266,74 @@ providePopupContainer(codeEditorEl)
:class="{ 'bg-black/10': isResizing }"
:style="{ left: `${sidebarWidth}px` }"
></div>
<MonacoEditorComp
v-radar="{ name: 'Code text editor', desc: 'Text editor for code' }"
class="my-3 min-w-0 flex-[1_1_0]"
:monaco="codeEditor.monaco"
:options="monacoEditorOptions"
@init="handleMonacoEditorInit"
@dragover="handleMonacoEditorDragOver"
@dragleave="handleMonacoEditorDragLeave"
@drop="handleMonacoEditorDrop"
/>
<section class="my-3 min-w-0 flex flex-[1_1_0] flex-col">
<div
v-if="uiRef.previousNavigationLocation != null"
class="mx-3 mb-2 h-9 flex flex-none items-center justify-between rounded-sm bg-primary-100 px-3 text-body-medium"
>
<button
v-radar="{ name: 'Back from definition', desc: 'Return to the previous code location' }"
class="min-w-0 flex cursor-pointer items-center gap-1.5 border-0 bg-transparent text-primary-main hover:text-primary-700"
@click="uiRef.goBack()"
>
<UIIcon class="h-4 w-4 flex-none" type="back" />
<span class="truncate">
{{ $t({ en: 'Back to', zh: '返回' }) }}
{{ $t(uiRef.previousNavigationLocation.textDocument.displayName) }}
<template v-if="uiRef.previousNavigationLocation.position != null">
·
{{
$t({
en: `Line ${uiRef.previousNavigationLocation.position.line}`,
zh: `第 ${uiRef.previousNavigationLocation.position.line} 行`
})
}}
</template>
</span>
</button>
<span v-if="uiRef.activeTextDocument != null" class="ml-3 truncate text-grey-800">
{{
$t({
en: `Current: ${$t(uiRef.activeTextDocument.displayName)}`,
zh: `当前位置:${$t(uiRef.activeTextDocument.displayName)}`
})
}}
</span>
</div>
<div class="min-h-0 min-w-0 flex flex-[1_1_0]">
<div class="min-h-0 min-w-0 flex flex-[1_1_0] flex-col">
<MonacoEditorComp
v-radar="{ name: 'Code text editor', desc: 'Text editor for code' }"
class="min-h-0 min-w-0 flex-[1_1_0]"
:monaco="codeEditor.monaco"
:options="monacoEditorOptions"
@init="handleMonacoEditorInit"
@dragover="handleMonacoEditorDragOver"
@dragleave="handleMonacoEditorDragLeave"
@drop="handleMonacoEditorDrop"
/>
<DefinitionPeek
v-if="uiRef.definitionPeek != null"
:key="`${uiRef.definitionPeek.textDocument.id.uri}:${uiRef.definitionPeek.range.start.line}:${uiRef.definitionPeek.range.start.column}`"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3] DefinitionPeek :key omits range.end

DefinitionPeek applies model/selection/reveal only once in handleEditorInit; it does not watch textDocument/range, so it only updates when the :key forces a full remount. The key is uri:range.start.line:range.start.column and omits range.end. Two "view definition" actions with the same start but a different end range would reuse the same editor instance and show a stale selection. Low likelihood, but either include the full range in the key or add a watch on the props so the component is self-consistent regardless of keying. (Note: the full remount also tears down and recreates the Monaco editor instance on every peek-target change, which is one of the heavier editor operations — a stable instance updated via setModel/setSelection/revealRange would avoid that cost.)

class="mx-3 mt-2 min-h-0 flex-[0_1_45%]"
:monaco="codeEditor.monaco"
:options="definitionPeekEditorOptions"
:text-document="uiRef.definitionPeek.textDocument"
:range="uiRef.definitionPeek.range"
@close="uiRef.closeDefinitionPeek()"
/>
</div>
<aside class="flex min-h-0 min-w-0 flex-none flex-col justify-end px-2">
<ZoomControl class="flex-none" @in="zoomIn" @out="zoomOut" @reset="zoomReset" />
</aside>
</div>
</section>
<HoverUI :controller="uiRef.hoverController" />
<CompletionUI :controller="uiRef.completionController" />
<DiagnosticsUI :controller="uiRef.diagnosticsController" />
<ContextMenuUI :controller="uiRef.contextMenuController" />
<InputHelperUI :controller="uiRef.inputHelperController" />
<InlayHintUI :controller="uiRef.inlayHintController" />
<DropIndicatorUI :controller="uiRef.dropIndicatorController" />
<aside class="flex min-h-0 min-w-0 flex-none flex-col justify-between gap-10 px-2 py-3">
<DocumentTabs class="min-h-0 flex-[0_1_auto]" />
<ZoomControl class="flex-none" @in="zoomIn" @out="zoomOut" @reset="zoomReset" />
</aside>
</div>
</template>
55 changes: 55 additions & 0 deletions spx-gui/src/components/xgo-code-editor/ui/DefinitionPeek.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<script setup lang="ts">
import { UIIcon } from '@/components/ui'
import type { TextDocument } from '../text-document'
import type { Range } from '../common'
import type { Monaco, MonacoEditor, monaco as tmonaco } from '../monaco'
import MonacoEditorComp from './MonacoEditor.vue'
import { toMonacoRange } from './common'

const props = defineProps<{
monaco: Monaco
options: tmonaco.editor.IStandaloneEditorConstructionOptions
textDocument: TextDocument
range: Range
}>()

const emit = defineEmits<{
close: []
}>()

function handleEditorInit(editor: MonacoEditor) {
editor.setModel(props.textDocument.monacoTextModel)
const range = toMonacoRange(props.range)
editor.setSelection(range)
editor.revealRangeNearTopIfOutsideViewport(range)
editor.addCommand(props.monaco.KeyCode.Escape, () => emit('close'))
editor.focus()
}
</script>

<template>
<section
v-radar="{ name: 'Definition preview', desc: `Read-only definition in ${textDocument.id.uri}` }"
class="min-h-0 flex flex-col overflow-hidden rounded-md border border-dividing-line-2 bg-white shadow-sm"
>
<header class="h-10 flex items-center justify-between border-b border-dividing-line-2 px-3">
<div class="min-w-0 flex items-center gap-2 text-body-medium">
<span class="truncate font-medium text-title">
{{ $t({ en: 'Definition in', zh: '定义位于' }) }} {{ $t(textDocument.displayName) }}
</span>
<span class="flex-none text-grey-700">
· {{ $t({ en: `Line ${range.start.line}`, zh: `第 ${range.start.line} 行` }) }}
</span>
</div>
<button
v-radar="{ name: 'Close definition preview', desc: 'Close the definition preview' }"
class="h-8 w-8 flex flex-none cursor-pointer items-center justify-center rounded-sm border-0 bg-transparent hover:bg-grey-300"
:aria-label="$t({ en: 'Close definition preview', zh: '关闭定义预览' })"
@click="emit('close')"
>
<UIIcon class="h-4 w-4" type="close" />
</button>
</header>
<MonacoEditorComp class="min-h-0 flex-[1_1_0]" :monaco="monaco" :options="options" @init="handleEditorInit" />
</section>
</template>
Loading