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.