diff --git a/spx-gui/src/apps/xbuilder/widgets/xgo-code-editor/XGoCodeEditor.ce.vue b/spx-gui/src/apps/xbuilder/widgets/xgo-code-editor/XGoCodeEditor.ce.vue
index 81faaea98..57b861022 100644
--- a/spx-gui/src/apps/xbuilder/widgets/xgo-code-editor/XGoCodeEditor.ce.vue
+++ b/spx-gui/src/apps/xbuilder/widgets/xgo-code-editor/XGoCodeEditor.ce.vue
@@ -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])
diff --git a/spx-gui/src/components/editor/spx-code-editor/CodeEditorProvider.vue b/spx-gui/src/components/editor/spx-code-editor/CodeEditorProvider.vue
index c276fcddc..10ff3e4f5 100644
--- a/spx-gui/src/components/editor/spx-code-editor/CodeEditorProvider.vue
+++ b/spx-gui/src/components/editor/spx-code-editor/CodeEditorProvider.vue
@@ -1,12 +1,8 @@
-
-
-
-
-
+
+
+
+
+
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 333e82abf..8d3d343e0 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
@@ -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'
@@ -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'
@@ -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
@@ -115,7 +128,10 @@ type TelemetryEventParams = TelemetryEventParamsForCall | TelemetryEventParamsFo
export class SpxLSPClient extends Disposable implements ILSPClient {
private emitter = new Emitter()
- constructor(private project: SpxProject) {
+ constructor(
+ private project: SpxProject,
+ private options: SpxLSPClientOptions = {}
+ ) {
super()
this.addDisposable(this.emitter)
}
@@ -155,18 +171,20 @@ export class SpxLSPClient extends Disposable implements ILSPClient {
}
private lcRef = shallowRef(null)
- private activeSpans = new Map()
+ private initializePromise: Promise | null = null
+ private activeSpans = new Map()
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 }
}
init() {
@@ -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,
@@ -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(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) {
+ 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({ signal, traceOptions }: RequestContext, method: string, params: any): Promise {
- const lc = await this.prepareRequest()
+ private async sendRequest(
+ lc: XGoLanguageClient,
+ { signal, traceOptions }: RequestContext,
+ method: string,
+ params: any,
+ suppressErrorLog?: (error: unknown) => boolean
+ ): Promise {
return tracedRequest(
method,
async (span: Sentry.Span) => {
@@ -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)
@@ -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(ctx: RequestContext, method: string, params: any): Promise {
+ const { lc, initializePromise } = await this.prepareRequest()
+ try {
+ return await this.sendRequest(lc, ctx, method, params, isServerNotInitializedError)
+ } catch (e) {
+ if (!isServerNotInitializedError(e)) throw e
+ await this.reinitialize(lc, initializePromise)
+ return this.sendRequest(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`.
diff --git a/spx-gui/src/components/editor/spx-code-editor/lsp/spxls/client.ts b/spx-gui/src/components/editor/spx-code-editor/lsp/spxls/client.ts
index 153965654..676f2156e 100644
--- a/spx-gui/src/components/editor/spx-code-editor/lsp/spxls/client.ts
+++ b/spx-gui/src/components/editor/spx-code-editor/lsp/spxls/client.ts
@@ -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 {
@@ -8,7 +14,7 @@ export interface IConnection {
export interface OngoingRequest {
/** Unique ID of the request. */
- id: number
+ id: MessageID
/** Returns a promise that resolves with the response of the request. */
response(): Promise
}
@@ -19,7 +25,7 @@ export interface OngoingRequest {
export class XGoLanguageClient {
private nextRequestId: number = 1
private pendingRequests = new Map<
- number,
+ MessageID,
{
resolve: (response: any) => void
reject: (error: any) => void
diff --git a/tools/spxls/go.mod b/tools/spxls/go.mod
index 31f44b7bd..6dc783c13 100644
--- a/tools/spxls/go.mod
+++ b/tools/spxls/go.mod
@@ -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
diff --git a/tools/spxls/go.sum b/tools/spxls/go.sum
index edaab8934..cfed397e9 100644
--- a/tools/spxls/go.sum
+++ b/tools/spxls/go.sum
@@ -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=
@@ -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=
diff --git a/tools/spxls/index.d.ts b/tools/spxls/index.d.ts
index d9d5a920f..b53efcd0b 100644
--- a/tools/spxls/index.d.ts
+++ b/tools/spxls/index.d.ts
@@ -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.
@@ -58,7 +63,7 @@ export interface RequestMessage extends Message {
/**
* The request id.
*/
- id: number
+ id: MessageID
/**
* The method to be invoked.
@@ -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.