Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ const {
await project.load(serialized)

const history = new History(project)
const lspClient = new SpxLSPClient(project)
const lspClient = new SpxLSPClient(project, { locale: i18n.lang.value })
lspClient.init()
const documentBase = new DocumentBase([...Object.values(spxDefinitionsByName), ...spxKeyDefinitions])

Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,8 @@
<template>
<CodeEditorInitializer v-if="codeEditorRef != null" :code-editor="codeEditorRef" />
<slot></slot>
</template>

<script setup lang="ts">
import { shallowRef, watch } from 'vue'
import { useCopilot } from '@/components/copilot/context'
import { type Monaco, CodeEditor, DocumentBase, useProvideCodeEditor } from '@/components/xgo-code-editor'
import { useI18n } from '@/utils/i18n'
import { useEditorCtx } from '../EditorContextProvider.vue'
import * as spxDefinitionsByName from './document-base'
import './document-base/helpers'
Expand All @@ -21,15 +17,16 @@ const props = defineProps<{

const copilot = useCopilot()
const editorCtx = useEditorCtx()
const i18n = useI18n()
const codeEditorRef = shallowRef<CodeEditor | null>(null)

watch(
() => [props.monaco, editorCtx.state] as const,
([monaco, editorState], _, onCleanup) => {
() => [props.monaco, editorCtx.state, i18n.lang.value] as const,
([monaco, editorState, lang], _, onCleanup) => {
const { project: spxProject, history } = editorState

const project = new SpxCodeEditorProject(spxProject, history)
const lspClient = new SpxLSPClient(spxProject)
const lspClient = new SpxLSPClient(spxProject, { locale: lang })
lspClient.onPropertyRenamed(({ target, oldName, newName }) => {
for (const widget of spxProject.stage.widgets) {
if (widget.type === 'monitor' && widget.target === target && widget.variableName === oldName) {
Expand Down Expand Up @@ -60,3 +57,8 @@ watch(

useProvideCodeEditor(codeEditorRef)
</script>

<template>
<CodeEditorInitializer v-if="codeEditorRef != null" :code-editor="codeEditorRef" />
<slot></slot>
</template>
136 changes: 119 additions & 17 deletions spx-gui/src/components/editor/spx-code-editor/lsp/spx-lsp-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as Sentry from '@sentry/vue'
import { Cancelled } from '@/utils/exception'
import { Disposable, getCleanupSignal, type Disposer } from '@/utils/disposable'
import Emitter from '@/utils/emitter'
import type { Lang } from '@/utils/i18n'
import { timeout, until, untilNotNull } from '@/utils/utils'
import { extname } from '@/utils/path'
import { createLSPOperationName, createLSPServerOperationName, type LSPTraceOptions } from '@/utils/tracing'
Expand All @@ -23,7 +24,7 @@ import {
type PropertyRenamedEvent
} from '@/components/xgo-code-editor'
import { XGoLanguageClient, type IConnection, ResponseError } from './spxls/client'
import type { Files as SpxlsFiles, RequestMessage, ResponseMessage, NotificationMessage } from './spxls'
import type { Files as SpxlsFiles, MessageID, RequestMessage, ResponseMessage, NotificationMessage } from './spxls'
import { xgoGetInputSlots, xgoGetProperties, xgoRenameResources } from './spxls/commands'
import { xgoPropertyRenamedNotification } from './spxls/notifications'
import { isDocumentLinkForResourceReference, parseDocumentLinkForDefinition } from './spxls/methods'
Expand All @@ -32,11 +33,23 @@ import type { WorkerHandler } from './worker'
/** The LSP target name for stage (the "Game" type in spx). */
const lspStageTarget = 'Game'

const lspClientName = 'XBuilder'
const lspWorkspaceRootURI = 'file:///'
const lspWorkspaceRootName = 'workspace'

interface IConnectionWithFiles extends IConnection {
sendFiles(files: SpxlsFiles): void
dispose(): void
}

type SpxLSPClientOptions = {
locale?: Lang
}

function isServerNotInitializedError(error: unknown): error is ResponseError {
return error instanceof ResponseError && error.code === lsp.ErrorCodes.ServerNotInitialized
}

/** Connection between LS client and server when the server runs in a Web Worker. */
class WorkerConnection implements IConnectionWithFiles {
private worker: WorkerHandler
Expand Down Expand Up @@ -115,7 +128,10 @@ type TelemetryEventParams = TelemetryEventParamsForCall | TelemetryEventParamsFo
export class SpxLSPClient extends Disposable implements ILSPClient {
private emitter = new Emitter<LSPClientEvents>()

constructor(private project: SpxProject) {
constructor(
private project: SpxProject,
private options: SpxLSPClientOptions = {}
) {
super()
this.addDisposable(this.emitter)
}
Expand Down Expand Up @@ -155,18 +171,20 @@ export class SpxLSPClient extends Disposable implements ILSPClient {
}

private lcRef = shallowRef<XGoLanguageClient | null>(null)
private activeSpans = new Map<number, Sentry.Span>()
private initializePromise: Promise<void> | null = null
private activeSpans = new Map<MessageID, Sentry.Span>()

private async prepareRequest() {
const [lc] = await Promise.all([
untilNotNull(this.lcRef),
// Typically requests are triggered earlier than file-loading:
// * file-loading: editor-event -> model-update -> file-loading
// * request: editor-event -> request
// Here we add `timeout(0)` to ensure that file-loading triggered before request really starts.
timeout(0).then(() => until(() => !this.isFilesStale.value))
])
return lc
const lc = await untilNotNull(this.lcRef)
const initializePromise = this.initializePromise ?? this.startInitialize(lc)
// Typically requests are triggered earlier than file-loading:
// * file-loading: editor-event -> model-update -> file-loading
// * request: editor-event -> request
// Here we add `timeout(0)` to ensure that file-loading triggered before request really starts.
await timeout(0)
await until(() => !this.isFilesStale.value)
await initializePromise
return { lc, initializePromise }
}
Comment thread
aofei marked this conversation as resolved.

init() {
Expand All @@ -179,6 +197,8 @@ export class SpxLSPClient extends Disposable implements ILSPClient {
this.handleTelemetryEventNotification(params)
})

this.startInitialize(this.lcRef.value)

// Register handler for property renamed notifications
this.lcRef.value.onNotification(
xgoPropertyRenamedNotification.method,
Expand All @@ -192,15 +212,85 @@ export class SpxLSPClient extends Disposable implements ILSPClient {
)
}

private createInitializeParams(): lsp.InitializeParams {
return {
processId: null,
clientInfo: {
name: lspClientName
},
locale: this.options.locale,
rootUri: lspWorkspaceRootURI,
workspaceFolders: [
{
uri: lspWorkspaceRootURI,
name: lspWorkspaceRootName
}
],
capabilities: {
workspace: {
workspaceFolders: true
},
general: {
positionEncodings: [lsp.PositionEncodingKind.UTF16]
},
textDocument: {
completion: {
completionItem: {
snippetSupport: true,
documentationFormat: [lsp.MarkupKind.Markdown]
}
},
hover: {
contentFormat: [lsp.MarkupKind.Markdown]
},
rename: {
prepareSupport: true
},
signatureHelp: {
contextSupport: true
}
}
}
}
}

private async initialize(lc: XGoLanguageClient) {
await this.sendRequest<lsp.InitializeResult>(lc, {}, lsp.InitializeRequest.method, this.createInitializeParams())
lc.notify(lsp.InitializedNotification.method, {})
}

private startInitialize(lc: XGoLanguageClient) {
const initializePromise = this.initialize(lc)
this.initializePromise = initializePromise
initializePromise.catch(() => {
if (this.initializePromise === initializePromise) {
this.initializePromise = null
}
})
return initializePromise
}

private reinitialize(lc: XGoLanguageClient, staleInitializePromise: Promise<void>) {
const currentInitializePromise = this.initializePromise
if (currentInitializePromise != null && currentInitializePromise !== staleInitializePromise) {
return currentInitializePromise
}
return this.startInitialize(lc)
}

dispose() {
this.lcRef.value?.dispose()
this.connection?.dispose()
super.dispose()
}

/** Do LSP request, with cancellation and tracing. */
private async request<T>({ signal, traceOptions }: RequestContext, method: string, params: any): Promise<T> {
const lc = await this.prepareRequest()
private async sendRequest<T>(
lc: XGoLanguageClient,
{ signal, traceOptions }: RequestContext,
method: string,
params: any,
suppressErrorLog?: (error: unknown) => boolean
): Promise<T> {
return tracedRequest(
method,
async (span: Sentry.Span) => {
Expand All @@ -226,7 +316,7 @@ export class SpxLSPClient extends Disposable implements ILSPClient {
if (e instanceof ResponseError && e.code === lsp.LSPErrorCodes.RequestCancelled) {
throw new Cancelled(e.message)
}
console.warn(`[LSP] ${method} error:`, e, ', params:', params)
if (!suppressErrorLog?.(e)) console.warn(`[LSP] ${method} error:`, e, ', params:', params)
throw e
})
.finally(unlisten)
Expand All @@ -235,7 +325,19 @@ export class SpxLSPClient extends Disposable implements ILSPClient {
)
}

private async cancelRequest(id: number) {
/** Do LSP request, with cancellation and tracing. */
private async request<T>(ctx: RequestContext, method: string, params: any): Promise<T> {
const { lc, initializePromise } = await this.prepareRequest()
try {
return await this.sendRequest<T>(lc, ctx, method, params, isServerNotInitializedError)
} catch (e) {
if (!isServerNotInitializedError(e)) throw e
await this.reinitialize(lc, initializePromise)
return this.sendRequest<T>(lc, ctx, method, params)
}
}

private async cancelRequest(id: MessageID) {
const lc = await untilNotNull(this.lcRef)
// The method `$/cancelRequest` is defined in https://github.com/microsoft/vscode-languageserver-node/blob/4c20197acf4c499345a18e79945e706345cbc50f/protocol/src/common/protocol.%24.ts#L46-L58 ,
// while not exported by package `vscode-languageserver-protocol`.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import type { NotificationMessage, RequestMessage, ResponseMessage, ResponseError as ResponseErrorObj } from './index'
import type {
MessageID,
NotificationMessage,
RequestMessage,
ResponseMessage,
ResponseError as ResponseErrorObj
} from './index'

/** Connection between the client and the language server. */
export interface IConnection {
Expand All @@ -8,7 +14,7 @@ export interface IConnection {

export interface OngoingRequest<T> {
/** Unique ID of the request. */
id: number
id: MessageID
/** Returns a promise that resolves with the response of the request. */
response(): Promise<T>
}
Expand All @@ -19,7 +25,7 @@ export interface OngoingRequest<T> {
export class XGoLanguageClient {
private nextRequestId: number = 1
private pendingRequests = new Map<
number,
MessageID,
{
resolve: (response: any) => void
reject: (error: any) => void
Expand Down
2 changes: 2 additions & 0 deletions tools/spxls/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,5 @@ require (
)

replace github.com/goplus/builder/tools/ai => ../ai

replace github.com/goplus/xgolsw => github.com/aofei/fork.goplus.xgolsw v0.0.0-20260701122425-18fd0bb225b8
4 changes: 2 additions & 2 deletions tools/spxls/go.sum
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/aofei/fork.goplus.xgolsw v0.0.0-20260701122425-18fd0bb225b8 h1:xvXKSHybkyjRga8bFKuRD4McZooDvskHSSH4rAZC+Oo=
github.com/aofei/fork.goplus.xgolsw v0.0.0-20260701122425-18fd0bb225b8/go.mod h1:ANgB42yf5ul2VDNMxp6v2yxbcwyNu1wV9IWekAgGSG4=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
Expand All @@ -15,8 +17,6 @@ github.com/goplus/spx/v2 v2.0.4 h1:4hyCM5XH3DMRjFeoTxCndrqcFZTeCWHkEicyp7wYTSs=
github.com/goplus/spx/v2 v2.0.4/go.mod h1:mF2kCvUNw97ku2PYHMEwbCENv24b5EwnlXOLiVMRbHM=
github.com/goplus/xgo v1.7.2 h1:leAinbUiz+oUukKfdYIXyYZlxm/gh6TkEg5/dzvevQk=
github.com/goplus/xgo v1.7.2/go.mod h1:QR2TaWixDRCYQfv0F7xpZbUPvuclcgg0oUFmcq5nNn0=
github.com/goplus/xgolsw v0.21.1-0.20260630012157-8eb11678c926 h1:LDYoxTq7UxoZppnHfCPQswH30MevNyoLTX8l0hrKFzo=
github.com/goplus/xgolsw v0.21.1-0.20260630012157-8eb11678c926/go.mod h1:ANgB42yf5ul2VDNMxp6v2yxbcwyNu1wV9IWekAgGSG4=
github.com/petermattis/goid v0.0.0-20250721140440-ea1c0173183e h1:D0bJD+4O3G4izvrQUmzCL80zazlN7EwJ0PPDhpJWC/I=
github.com/petermattis/goid v0.0.0-20250721140440-ea1c0173183e/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
Expand Down
9 changes: 7 additions & 2 deletions tools/spxls/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ export interface Message {
jsonrpc: string
}

/**
* A JSON-RPC message identifier.
*/
export type MessageID = number | string

/**
* A request message to describe a request between the client and the server. Every processed request must send a
* response back to the sender of the request.
Expand All @@ -58,7 +63,7 @@ export interface RequestMessage extends Message {
/**
* The request id.
*/
id: number
id: MessageID

/**
* The method to be invoked.
Expand All @@ -82,7 +87,7 @@ export interface ResponseMessage extends Message {
/**
* The request id.
*/
id: number
id: MessageID

/**
* The result of a request. This member is REQUIRED on success.
Expand Down