From cd0ac6e0c7b1bc241ebecaf4ab42a4324ec81a9e Mon Sep 17 00:00:00 2001 From: gat0sy Date: Fri, 7 Aug 2026 16:07:14 +0000 Subject: [PATCH 1/5] feat(lsp): fixing LspToPosition adding a helper textEditUtils LspToPosition threw range error on format error. We attempt to fix it here with by clamping so we get the correct line count between the client and server. applyTextEdit as also been extracted so both transport and client manager can import it from the helper. --- src/cm/lsp/clientManager.ts | 183 ++++++++++++++------------- src/cm/lsp/textEditUtils.ts | 72 +++++++++++ src/cm/lsp/transport.ts | 241 ++++++++++++++++++++++++++---------- 3 files changed, 346 insertions(+), 150 deletions(-) create mode 100644 src/cm/lsp/textEditUtils.ts diff --git a/src/cm/lsp/clientManager.ts b/src/cm/lsp/clientManager.ts index 59eae1966..1f5990b26 100644 --- a/src/cm/lsp/clientManager.ts +++ b/src/cm/lsp/clientManager.ts @@ -9,7 +9,7 @@ import { serverCompletion, serverDiagnostics, } from "@codemirror/lsp-client"; -import { EditorState, Extension, Facet, MapMode } from "@codemirror/state"; +import { EditorState, Extension, Facet } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; import lspStatusBar from "components/lspStatusBar"; import notificationManager from "lib/notificationManager"; @@ -52,6 +52,9 @@ import type { Transport, } from "./types"; import AcodeWorkspace from "./workspace"; +import { applyTextEdits } from "./textEditUtils"; + +const LSP_IDLE_GRACE_MS = 45_000; // grace period before a client with no open files is actually disposed export const lspCompletionEnabled = Facet.define({ // File-level marker used by the autocomplete override path. If any attached @@ -183,9 +186,14 @@ function connectClient( initializationOptions?: Record, rootUri?: string | null, ): void { - const hasInitializationOptions = - !!initializationOptions && Object.keys(initializationOptions).length > 0; - if (!hasInitializationOptions && !rootUri) { + const workspaceFolders = rootUri + ? [{ uri: rootUri, name: deriveFolderName(rootUri) }] + : undefined; + + if ( + (!initializationOptions || !Object.keys(initializationOptions).length) && + !workspaceFolders + ) { client.connect(transport); return; } @@ -205,14 +213,8 @@ function connectClient( if (method === "initialize" && isPlainObject(params)) { params = { ...params, - ...(hasInitializationOptions ? { initializationOptions } : {}), - ...(rootUri - ? { - workspaceFolders: [ - { uri: rootUri, name: workspaceName(rootUri) }, - ], - } - : {}), + ...(initializationOptions ? { initializationOptions } : {}), + ...(workspaceFolders ? { workspaceFolders } : {}), } as Params; } return originalRequestInner(method, params, mapped); @@ -225,13 +227,14 @@ function connectClient( } } -function workspaceName(rootUri: string): string { - const trimmed = rootUri.replace(/\/+$/, ""); - const encodedName = trimmed.slice(trimmed.lastIndexOf("/") + 1); +function deriveFolderName(uri: string): string { try { - return decodeURIComponent(encodedName) || "workspace"; + const decoded = decodeURIComponent(uri); + const trimmed = decoded.replace(/\/+$/, ""); + const segments = trimmed.split("/").filter(Boolean); + return segments[segments.length - 1] || decoded; } catch { - return encodedName || "workspace"; + return uri; } } @@ -797,8 +800,17 @@ export class LspClientManager { }, workspace: { configuration: true, + applyEdit: true, workspaceFolders: true, }, + textDocument: { + codeAction: { + dataSupport: true, + resolveSupport: { + properties: ["edit"], + }, + }, + }, }, }; @@ -1045,14 +1057,20 @@ export class LspClientManager { client = new LSPClient(clientConfig) as ExtendedLSPClient; client.__acodeServerId = server.id; connectClient( - client, - transportHandle.transport, - initializationOptions, - scope === "workspace" && server.useWorkspaceFolders - ? null - : normalizedRootUri, - ); + client, + transportHandle.transport, + initializationOptions, + normalizedRootUri, +); await waitForInitialization(client.initializing, signal, server.id); + // Fire after "initialized" + // it reuses initializationOptions as the config payload + // For LSPs sometimes requiring config to be sent twice like pylsp) + transportHandle.transport.send(JSON.stringify({ + jsonrpc: "2.0", + method: "workspace/didChangeConfiguration", + params: { settings: server.initializationOptions ?? {} }, + })); if (!client.__acodeLoggedInfo) { // Log root URI info to console if (normalizedRootUri) { @@ -1135,23 +1153,27 @@ export class LspClientManager { const uriAliases = new Map(); const effectiveRoot = normalizedRootUri ?? originalRootUri ?? null; let disposed = false; - + let idleTimer: ReturnType | undefined; const attach = ( - uri: string, - view: EditorView, - aliases: string[] = [], - ): void => { - const existing = fileRefs.get(uri) ?? new Set(); - existing.add(view); - fileRefs.set(uri, existing); - uriAliases.set(uri, uri); - for (const alias of aliases) { - if (!alias || alias === uri) continue; - uriAliases.set(alias, uri); - } - const suffix = effectiveRoot ? ` (root ${effectiveRoot})` : ""; - logLspInfo(`[LSP:${server.id}] attached to ${uri}${suffix}`); - }; + uri: string, + view: EditorView, + aliases: string[] = [], +): void => { + if (idleTimer) { + clearTimeout(idleTimer); + idleTimer = undefined; + } + const existing = fileRefs.get(uri) ?? new Set(); + existing.add(view); + fileRefs.set(uri, existing); + uriAliases.set(uri, uri); + for (const alias of aliases) { + if (!alias || alias === uri) continue; + uriAliases.set(alias, uri); + } + const suffix = effectiveRoot ? ` (root ${effectiveRoot})` : ""; + logLspInfo(`[LSP:${server.id}] attached to ${uri}${suffix}`); +}; const clearClientDiagnostics = (view: EditorView): void => { try { @@ -1164,6 +1186,10 @@ export class LspClientManager { const dispose = async (): Promise => { if (disposed) return; disposed = true; + if (idleTimer) { + clearTimeout(idleTimer); + idleTimer = undefined; + } disposePullDiagnostics(client); this.#clients.delete(key); for (const views of fileRefs.values()) { @@ -1206,13 +1232,18 @@ export class LspClientManager { } if (!fileRefs.size) { - this.options.onClientIdle?.({ - server, - client, - rootUri: effectiveRoot, - dispose, - }); - } + if (idleTimer) clearTimeout(idleTimer); + idleTimer = setTimeout(() => { + idleTimer = undefined; + if (fileRefs.size) return; // a file reattached during the grace window + this.options.onClientIdle?.({ + server, + client, + rootUri: effectiveRoot, + dispose, + }); + }, LSP_IDLE_GRACE_MS); +} }; return { @@ -1404,45 +1435,6 @@ interface Change { insert: string; } -function applyTextEdits( - plugin: LSPPlugin, - view: EditorView, - edits: TextEdit[], -): boolean { - const changes: Change[] = []; - for (const edit of edits) { - if (!edit?.range) continue; - let fromBase: number; - let toBase: number; - try { - fromBase = plugin.fromPosition(edit.range.start, plugin.syncedDoc); - toBase = plugin.fromPosition(edit.range.end, plugin.syncedDoc); - } catch (_) { - continue; - } - const fromResult = plugin.unsyncedChanges.mapPos( - fromBase, - 1, - MapMode.TrackDel, - ); - const toResult = plugin.unsyncedChanges.mapPos( - toBase, - -1, - MapMode.TrackDel, - ); - if (fromResult == null || toResult == null) continue; - const insert = - typeof edit.newText === "string" - ? edit.newText.replace(/\r\n/g, "\n") - : ""; - changes.push({ from: fromResult, to: toResult, insert }); - } - if (!changes.length) return false; - changes.sort((a, b) => a.from - b.from || a.to - b.to); - view.dispatch({ changes }); - return true; -} - function buildFormattingOptions( view: EditorView, overrides: FormattingOptions = {}, @@ -1482,6 +1474,7 @@ function resolveIndentWidth(unit: string): number { return width || 4; } + const defaultManager = new LspClientManager(); export default defaultManager; @@ -1500,13 +1493,13 @@ function normalizeRootUriForServer( if (scheme === "file") { return { normalizedRootUri: rootUri, originalRootUri: rootUri }; } - // Try to convert content:// URIs to file:// URIs if (scheme === "content") { const fileUri = contentUriToFileUri(rootUri); if (fileUri) { return { normalizedRootUri: fileUri, originalRootUri: rootUri }; } + // Can't convert to file:// - server won't work properly return { normalizedRootUri: null, originalRootUri: rootUri }; } @@ -1525,6 +1518,12 @@ function normalizeDocumentUri(uri: string | null | undefined): string | null { if (scheme === "file" || scheme === "untitled") { return uri; } + + // sftp documents: strip to the bare remote path + if (scheme === "sftp") { + return sftpUriToFileUri(uri); + } + // Convert content:// URIs to file:// URIs if (scheme === "content") { @@ -1611,6 +1610,16 @@ function contentUriToFileUri(uri: string): string | null { } } +function sftpUriToFileUri(uri: string): string | null { + // reached via this SFTP connection, so the server needs only the bare + // remote path — no scheme, host, port, or credentials. + const match = /^sftp:\/\/[^/]*(\/.*)$/.exec(uri); + if (!match) return null; + const path = match[1].split("?")[0]; + if (!path) return null; + return buildFileUri(path); +} + function buildFileUri(pathname: string): string | null { if (!pathname) return null; const normalized = pathname.startsWith("/") ? pathname : `/${pathname}`; diff --git a/src/cm/lsp/textEditUtils.ts b/src/cm/lsp/textEditUtils.ts new file mode 100644 index 000000000..76576fba9 --- /dev/null +++ b/src/cm/lsp/textEditUtils.ts @@ -0,0 +1,72 @@ +import type { LSPPlugin } from "@codemirror/lsp-client"; +import type { EditorView } from "@codemirror/view"; +import { MapMode } from "@codemirror/state"; +import type { Text } from "@codemirror/state"; +import type { TextEdit } from "vscode-languageserver-types"; + +interface Change { + from: number; + to: number; + insert: string; +} + +/** + * Convert an LSP Position to a CodeMirror document offset, clamping to + * document bounds. Handles the LSP convention where line == doc.lines + * means "end of document" (e.g. full-document formatting replacements). + */ +export function lspPositionToOffset( + doc: Text, + pos: { line: number; character: number }, +): number { + if (pos.line < 0) return 0; + if (pos.line >= doc.lines) return doc.length; + const line = doc.line(pos.line + 1); + return line.from + Math.min(pos.character, line.length); +} + +/** + * Apply a list of LSP TextEdits to an EditorView backed by the given + * LSPPlugin. Shared by clientManager.ts (edits the client pulls, e.g. + * textDocument/formatting) and transport.ts (edits the server pushes, + * e.g. workspace/applyEdit). + */ +export function applyTextEdits( + plugin: LSPPlugin, + view: EditorView, + edits: TextEdit[], +): boolean { + const changes: Change[] = []; + for (const edit of edits) { + if (!edit?.range) continue; + let fromBase: number; + let toBase: number; + try { + fromBase = lspPositionToOffset(plugin.syncedDoc, edit.range.start); + toBase = lspPositionToOffset(plugin.syncedDoc, edit.range.end); + } catch (err) { + console.error("[applyTextEdits] position conversion failed:", err, edit); + continue; + } + const fromResult = plugin.unsyncedChanges.mapPos( + fromBase, + 1, + MapMode.TrackDel, + ); + const toResult = plugin.unsyncedChanges.mapPos( + toBase, + -1, + MapMode.TrackDel, + ); + if (fromResult == null || toResult == null) continue; + const insert = + typeof edit.newText === "string" + ? edit.newText.replace(/\r\n/g, "\n") + : ""; + changes.push({ from: fromResult, to: toResult, insert }); + } + if (!changes.length) return false; + changes.sort((a, b) => a.from - b.from || a.to - b.to); + view.dispatch({ changes }); + return true; +} \ No newline at end of file diff --git a/src/cm/lsp/transport.ts b/src/cm/lsp/transport.ts index 1095687e6..6a59e01eb 100644 --- a/src/cm/lsp/transport.ts +++ b/src/cm/lsp/transport.ts @@ -11,6 +11,10 @@ import type { TransportHandle, WebSocketTransportOptions, } from "./types"; +import { LSPPlugin } from "@codemirror/lsp-client"; +import type { TextEdit } from "vscode-languageserver-types"; +import { applyTextEdits } from "./textEditUtils"; +import type AcodeWorkspace from "./workspace"; const DEFAULT_TIMEOUT = 5000; const RECONNECT_BASE_DELAY = 500; @@ -157,76 +161,187 @@ function createWebSocketTransport( dispatchToListeners(data); } - function dispatchToListeners(data: string): void { - // Debugging aid while stabilising websocket transport - if (context?.debugWebSocket) { - console.debug(`[LSP:${server.id}] <=`, data); +interface WorkspaceEditParam { + changes?: Record; + documentChanges?: Array<{ textDocument: { uri: string }; edits: TextEdit[] }>; +} + +async function applyWorkspaceEditToContext( + edit: WorkspaceEditParam | undefined, + ctx: TransportContext, +): Promise<{ applied: boolean; failureReason?: string }> { + if (!edit) return { applied: false, failureReason: "No edit provided" }; + + const changesByUri: Record = + edit.changes ?? + Object.fromEntries( + (edit.documentChanges ?? []) + .filter((c): c is { textDocument: { uri: string }; edits: TextEdit[] } => "edits" in c) + .map((c) => [c.textDocument.uri, c.edits]), + ); + + const uris = Object.keys(changesByUri); + if (!uris.length) { + return { applied: false, failureReason: "Edit contains no changes" }; + } + + const workspace = ctx.view + ? (LSPPlugin.get(ctx.view)?.client.workspace as AcodeWorkspace | undefined) + : undefined; + + if (!workspace) { + return { applied: false, failureReason: "No workspace available to apply edit" }; + } + + let appliedCount = 0; + const failures: string[] = []; + + for (const uri of uris) { + const edits = changesByUri[uri]; + if (!edits.length) continue; + + let view = workspace.getFile(uri)?.getView(); + if (!view) { + try { + view = await workspace.displayFile(uri); + } catch (error) { + failures.push(uri); + continue; + } + } + if (!view) { + failures.push(uri); + continue; } - try { - const msg = JSON.parse(data); - if (msg && typeof msg.id !== "undefined") { - let handled = true; - let result: unknown = null; - switch (msg.method) { - case "window/workDoneProgress/create": - case "workspace/diagnostic/refresh": - case "client/registerCapability": - case "client/unregisterCapability": - break; - case "workspace/configuration": - result = Array.isArray(msg.params?.items) - ? msg.params.items.map( - (item: { section?: unknown }) => - resolveWorkspaceConfiguration(item?.section), - ) - : []; - break; - case "workspace/workspaceFolders": { - const rootUri = context.rootUri; - result = rootUri - ? [ - { - uri: rootUri, - name: - rootUri.replace(/\/$/, "").split("/").pop() || - rootUri, - }, - ] - : null; - break; - } - default: - handled = false; - } - if (!handled) { - notifyListeners(data); - return; - } - const response = JSON.stringify({ - jsonrpc: "2.0", - id: msg.id, - result, - }); - if (context?.debugWebSocket) { - console.debug(`[LSP:${server.id}] => (auto-response)`, response); - } - sendMessage(response); - if (msg.method === "workspace/diagnostic/refresh") { - notifyListeners( - JSON.stringify({ + const plugin = LSPPlugin.get(view); + if (!plugin) { + failures.push(uri); + continue; + } + + const applied = applyTextEdits(plugin, view, edits); + if (applied) appliedCount++; + else failures.push(uri); + } + + if (appliedCount === 0) { + return { + applied: false, + failureReason: `Could not apply edit to: ${failures.join(", ")}`, + }; + } + if (failures.length) { + return { + applied: false, + failureReason: `Applied to ${appliedCount} file(s); failed: ${failures.join(", ")}`, + }; + } + return { applied: true }; +} + + function dispatchToListeners(data: string): void { + // Debugging aid while stabilising websocket transport + if (context?.debugWebSocket) { + console.debug(`[LSP:${server.id}] <=`, data); + } + + try { + const msg = JSON.parse(data); + if (msg && typeof msg.id !== "undefined") { + // workspace/applyEdit needs to await file-opening/edit-application, + // so it can't go through the synchronous switch below. Handle it + // separately and return immediately. + if (msg.method === "workspace/applyEdit") { + applyWorkspaceEditToContext(msg.params?.edit, context) + .then((result) => { + const response = JSON.stringify({ jsonrpc: "2.0", - method: msg.method, - params: msg.params ?? {}, - }), - ); + id: msg.id, + result, + }); + if (context?.debugWebSocket) { + console.debug(`[LSP:${server.id}] => (auto-response)`, response); + } + sendMessage(response); + }) + .catch((error) => { + console.error(`[LSP:${server.id}] workspace/applyEdit failed:`, error); + sendMessage( + JSON.stringify({ + jsonrpc: "2.0", + id: msg.id, + result: { + applied: false, + failureReason: "Internal error applying edit", + }, + }), + ); + }); + return; + } + + let handled = true; + let result: unknown = null; + switch (msg.method) { + case "window/workDoneProgress/create": + case "workspace/diagnostic/refresh": + case "client/registerCapability": + case "client/unregisterCapability": + break; + case "workspace/configuration": + result = Array.isArray(msg.params?.items) + ? msg.params.items.map( + (item: { section?: unknown }) => + resolveWorkspaceConfiguration(item?.section), + ) + : []; + break; + case "workspace/workspaceFolders": { + const rootUri = context.rootUri; + result = rootUri + ? [ + { + uri: rootUri, + name: + rootUri.replace(/\/$/, "").split("/").pop() || + rootUri, + }, + ] + : null; + break; } + default: + handled = false; + } + if (!handled) { + notifyListeners(data); return; } - } catch (_) {} + const response = JSON.stringify({ + jsonrpc: "2.0", + id: msg.id, + result, + }); + if (context?.debugWebSocket) { + console.debug(`[LSP:${server.id}] => (auto-response)`, response); + } + sendMessage(response); + if (msg.method === "workspace/diagnostic/refresh") { + notifyListeners( + JSON.stringify({ + jsonrpc: "2.0", + method: msg.method, + params: msg.params ?? {}, + }), + ); + } + return; + } + } catch (_) {} - notifyListeners(data); - } + notifyListeners(data); +} function handleClose(event: CloseEvent): void { connected = false; From 2e7c60b711a38ead31a6137b0f86d4edb1f01c77 Mon Sep 17 00:00:00 2001 From: gat0sy Date: Fri, 7 Aug 2026 22:57:17 +0000 Subject: [PATCH 2/5] feat(lsp): add go-to-definition, code action fixes, and hover file links go to def and similar fonction have been added, a new interceptFileLink method has been created to solve an FileUriExposedException you may get if taping the signature link on the hover. if the link is a website, it skips and let the normal behavior occur ( open a web browser page ) if the link is a file, it modifies the uri so the tap behave like a go to instead of crashing the whole app. There are notably also some fixes for code actions, rename...ect, now they use the new lspPostionToOffset that uses clamping --- src/cm/lsp/codeActions.ts | 8 +- src/cm/lsp/definition.ts | 145 ++++++++++++++++++++++++++++++++ src/cm/lsp/index.ts | 6 ++ src/cm/lsp/references.ts | 4 +- src/cm/lsp/rename.ts | 9 +- src/cm/lsp/tooltipExtensions.ts | 58 ++++++++++++- 6 files changed, 212 insertions(+), 18 deletions(-) create mode 100644 src/cm/lsp/definition.ts diff --git a/src/cm/lsp/codeActions.ts b/src/cm/lsp/codeActions.ts index 7022e5f1e..1f8ea915b 100644 --- a/src/cm/lsp/codeActions.ts +++ b/src/cm/lsp/codeActions.ts @@ -14,6 +14,7 @@ import type { import type { Position, Range } from "./types"; import { addLspLogFor } from "./logs"; import type AcodeWorkspace from "./workspace"; +import { lspPositionToOffset } from "./textEditUtils"; type CodeActionResponse = (CodeAction | Command)[] | null; @@ -61,13 +62,6 @@ function isCommand(item: CodeAction | Command): item is Command { ); } -function lspPositionToOffset( - doc: { line: (n: number) => { from: number } }, - pos: Position, -): number { - return doc.line(pos.line + 1).from + pos.character; -} - async function requestCodeActions( plugin: LSPPlugin, range: LspRange, diff --git a/src/cm/lsp/definition.ts b/src/cm/lsp/definition.ts new file mode 100644 index 000000000..926eb3195 --- /dev/null +++ b/src/cm/lsp/definition.ts @@ -0,0 +1,145 @@ +import { LSPPlugin } from "@codemirror/lsp-client"; +import type { EditorView } from "@codemirror/view"; +import { showReferencesPanel } from "components/referencesPanel"; +import { fetchLineText, getWordAtCursor } from "./references"; +import toast from "components/toast"; + +interface Position { + line: number; + character: number; +} + +interface Range { + start: Position; + end: Position; +} + +interface Location { + uri: string; + range: Range; +} + +interface LocationLink { + targetUri: string; + targetRange: Range; + targetSelectionRange?: Range; +} + +type DefinitionResult = Location | Location[] | LocationLink[] | null; + +interface ReferenceWithContext extends Location { + lineText?: string; +} + +type DefinitionKind = + | "definition" + | "declaration" + | "implementation" + | "typeDefinition"; + +const CAPABILITY_KEY: Record = { + definition: "definitionProvider", + declaration: "declarationProvider", + implementation: "implementationProvider", + typeDefinition: "typeDefinitionProvider", +}; + +const LABEL: Record = { + definition: "definition", + declaration: "declaration", + implementation: "implementation", + typeDefinition: "type definition", +}; + +function normalizeLocations(result: DefinitionResult): Location[] { + if (!result) return []; + const list = Array.isArray(result) ? result : [result]; + return list.map((item) => { + if ("targetUri" in item) { + return { + uri: item.targetUri, + range: item.targetSelectionRange ?? item.targetRange, + }; + } + return item; + }); +} + +async function fetchLocations( + view: EditorView, + kind: DefinitionKind, +): Promise { + const plugin = LSPPlugin.get(view); + if (!plugin) return null; + + const client = plugin.client; + const capabilities = client.serverCapabilities as + | Record + | undefined; + + if (!capabilities?.[CAPABILITY_KEY[kind]]) { + toast(`Language server does not support go to ${LABEL[kind]}`); + return null; + } + + const { state } = view; + const pos = state.selection.main.head; + const line = state.doc.lineAt(pos); + const uri = plugin.uri; + + client.sync(); + + const method = `textDocument/${kind}`; + const params = { + textDocument: { uri }, + position: { line: line.number - 1, character: pos - line.from }, + }; + + const result = await client.request( + method, + params, + ); + + return normalizeLocations(result); +} + +async function goTo(view: EditorView, kind: DefinitionKind): Promise { + try { + const locations = await fetchLocations(view, kind); + if (locations === null) return false; + + if (locations.length === 0) { + toast(`No ${LABEL[kind]} found`); + return false; + } + + if (locations.length === 1) { + const { navigateToReference } = await import( + "components/referencesPanel/utils" + ); + await navigateToReference(locations[0]); + return true; + } + + const symbolName = getWordAtCursor(view); + const panel = showReferencesPanel({ symbolName }); + const withContext: ReferenceWithContext[] = await Promise.all( + locations.map(async (loc) => ({ + ...loc, + lineText: await fetchLineText(loc.uri, loc.range.start.line), + })), + ); + panel.setReferences(withContext); + return true; + } catch (error) { + console.error(`Go to ${LABEL[kind]} failed:`, error); + return false; + } +} + +export const goToDefinition = (view: EditorView) => goTo(view, "definition"); +export const goToDeclaration = (view: EditorView) => goTo(view, "declaration"); +export const goToImplementation = (view: EditorView) => + goTo(view, "implementation"); +export const goToTypeDefinition = (view: EditorView) => + goTo(view, "typeDefinition"); \ No newline at end of file diff --git a/src/cm/lsp/index.ts b/src/cm/lsp/index.ts index f3a8b4d36..3676518b8 100644 --- a/src/cm/lsp/index.ts +++ b/src/cm/lsp/index.ts @@ -89,6 +89,12 @@ export { findAllReferences, findAllReferencesInTab, } from "./references"; +export { + goToDefinition, + goToDeclaration, + goToImplementation, + goToTypeDefinition, +} from "./definition"; export { acodeRenameExtension, acodeRenameKeymap, diff --git a/src/cm/lsp/references.ts b/src/cm/lsp/references.ts index def69288c..cb02fe207 100644 --- a/src/cm/lsp/references.ts +++ b/src/cm/lsp/references.ts @@ -33,7 +33,7 @@ interface ReferenceParams { context: { includeDeclaration: boolean }; } -async function fetchLineText(uri: string, line: number): Promise { +export async function fetchLineText(uri: string, line: number): Promise { try { interface EditorManagerLike { getFile?: (uri: string, type: string) => EditorFileLike | null; @@ -89,7 +89,7 @@ async function fetchLineText(uri: string, line: number): Promise { return ""; } -function getWordAtCursor(view: EditorView): string { +export function getWordAtCursor(view: EditorView): string { const { state } = view; const pos = state.selection.main.head; const word = state.wordAt(pos); diff --git a/src/cm/lsp/rename.ts b/src/cm/lsp/rename.ts index 75b89693c..fd5855a83 100644 --- a/src/cm/lsp/rename.ts +++ b/src/cm/lsp/rename.ts @@ -9,6 +9,7 @@ import prompt from "dialogs/prompt"; import type * as lsp from "vscode-languageserver-protocol"; import { addLspLogFor } from "./logs"; import type AcodeWorkspace from "./workspace"; +import { lspPositionToOffset } from "./textEditUtils"; interface RenameParams { newName: string; @@ -148,14 +149,6 @@ async function performRename(view: EditorView): Promise { return true; } -function lspPositionToOffset( - doc: { line: (n: number) => { from: number } }, - pos: lsp.Position, -): number { - const line = doc.line(pos.line + 1); - return line.from + pos.character; -} - async function applyChangesToFile( workspace: AcodeWorkspace, uri: string, diff --git a/src/cm/lsp/tooltipExtensions.ts b/src/cm/lsp/tooltipExtensions.ts index 2a9a157ea..34638f10d 100644 --- a/src/cm/lsp/tooltipExtensions.ts +++ b/src/cm/lsp/tooltipExtensions.ts @@ -40,6 +40,7 @@ import type { MarkupContent, } from "vscode-languageserver-types"; import { getMode, getModeForPath, type Mode } from "../modelist"; +import type AcodeWorkspace from "./workspace"; interface LspClientInternals { config?: { @@ -159,6 +160,60 @@ function startPluginLanguageLoad(mode: Mode): Promise | null { return load; } +function interceptFileLinks(container: HTMLElement, view: EditorView): void { + container.addEventListener("click", + (event) => { + const target = event.target as HTMLElement | null; + const anchor = target?.closest?.("a[href]") as HTMLAnchorElement | null; + if (!anchor) return; + + const href = anchor.getAttribute("href"); + if (!href || !href.startsWith("file://")) return; + + event.preventDefault(); + event.stopPropagation(); + + const plugin = LSPPlugin.get(view); + if (!plugin) return; + const workspace = plugin.client.workspace as AcodeWorkspace; + if (!workspace) return; + + void (async () => { + try { + const match = /^(file:\/\/[^#]*)(?:#L?(\d+))?/.exec(href); + if (!match) return; + const [, + rawUri, + lineStr] = match; + + const targetView = await workspace.displayFile(rawUri); + if (!targetView) return; + + if (lineStr) { + const line = Number.parseInt(lineStr, 10); + const doc = targetView.state.doc; + if (Number.isFinite(line) && line >= 1 && line <= doc.lines) { + const { + from + } = doc.line(line); + targetView.dispatch({ + selection: { + anchor: from + }, + effects: EditorView.scrollIntoView(from, { + y: "center" + }), + }); + targetView.focus(); + } + } + } catch (error) { + console.error("[LSP:Tooltip] Failed to open file link:", href, error); + } + })(); + }); +} + export function resolveLspHoverHighlightLanguage( language: string, ): Language | null { @@ -419,6 +474,7 @@ function lspTooltipSource( results[index].result.contents, ); } + interceptFileLinks(dom, view); return { dom }; }, above: true, @@ -620,9 +676,9 @@ function drawSignatureTooltip( const docs = dom.appendChild(document.createElement("div")); docs.className = "cm-lsp-signature-documentation cm-lsp-documentation"; docs.innerHTML = plugin.docToHTML(signature.documentation); + interceptFileLinks(docs, view); } } - return { dom }; } From 2b6f880652f008e4ff5601705f34d7cb0d617e3b Mon Sep 17 00:00:00 2001 From: gat0sy Date: Sat, 8 Aug 2026 00:16:33 +0000 Subject: [PATCH 3/5] feat(editor): resolve LSP file:// URIs across workspaces and add LSP actions menu Added resolveContentUriForFileUri() to map LSP file:// responses back to content:// and sftp:// URIs via addedFolder matching Refactor editorManager displayFile/openFile to resolve URIs before opening, enabling cross-workspace go-to-definition and references Added SFTP path-aware root URI resolution for remote workspace context Replace selection menu code-actions button with full LSP actions menu (definition, declaration, implementation, type-definition, references, rename, code-actions) with single-item auto-execution --- src/components/referencesPanel/utils.js | 165 +++++++++++++++++++++++- src/lib/editorManager.js | 68 ++++++++-- src/lib/selectionMenu.js | 93 +++++++++++-- 3 files changed, 303 insertions(+), 23 deletions(-) diff --git a/src/components/referencesPanel/utils.js b/src/components/referencesPanel/utils.js index 828cd0d37..1b28e2892 100644 --- a/src/components/referencesPanel/utils.js +++ b/src/components/referencesPanel/utils.js @@ -1,13 +1,17 @@ import { EditorView } from "@codemirror/view"; import Sidebar from "components/sidebar"; +import toast from "components/toast"; import DOMPurify from "dompurify"; import openFile from "lib/openFile"; +import { addedFolder } from "lib/openFolder"; import { clearHighlightCache, highlightLine, sanitize, } from "utils/codeHighlight"; import helpers from "utils/helpers"; +import Uri from "utils/Uri"; +import Url from "utils/Url"; export { clearHighlightCache, sanitize }; @@ -111,7 +115,24 @@ export async function navigateToReference(ref) { Sidebar.hide(); try { - await openFile(ref.uri, { render: true }); + let targetUri = ref.uri; + + if ( + targetUri.startsWith("file:///") && + !editorManager.getFile(targetUri, "uri") + ) { + const contentUri = resolveContentUriForFileUri(targetUri); + if (contentUri) { + targetUri = contentUri; + } else { + toast("Definition unreachable"); + return; + } + } + + await openFile(targetUri, { + render: true, + }); const { editor } = editorManager; if (!editor) return; @@ -143,3 +164,145 @@ export function getReferencesStats(references) { text: `${refCount} reference${refCount !== 1 ? "s" : ""} in ${fileCount} file${fileCount !== 1 ? "s" : ""}`, }; } + +const CONTENT_AUTHORITY_HANDLERS = { + "android.externalstorage": { + docIdToPath(docId) { + const trimmed = docId.replace(/:+$/, ""); + const separator = trimmed.indexOf(":"); + if (separator === -1) return null; + const volume = trimmed.slice(0, separator); + const remainder = trimmed.slice(separator + 1); + if (!remainder) return null; + const base = + volume === "primary" ? "/storage/emulated/0" : `/storage/${volume}`; + return `${base}/${remainder}`; + }, + }, + "foxdebug.acode": { + docIdToPath(docId) { + let normalized = docId.replace(/:+$/, ""); + if (!normalized) return null; + if (normalized.startsWith("raw:/")) { + normalized = normalized.slice(4); + } else if (normalized.startsWith("raw:")) { + normalized = normalized.slice(4); + } + return normalized.startsWith("/") ? normalized : null; + }, + }, +}; +CONTENT_AUTHORITY_HANDLERS["foxdebug.acodefree"] = + CONTENT_AUTHORITY_HANDLERS["foxdebug.acode"]; + +function getContentAuthorityId(contentUri) { + const match = /^content:\/\/com\.((?![:<>"/\\|?*]).*?)\.documents\//.exec( + contentUri, + ); + return match?.[1] ?? null; +} + +/** + * LSP servers hand back plain file:// uris. Acode tracks externally-added + * files by their original content:// SAF uri, so an lsp uri never matches + * an open tab directly. Resolve it against the currently added folders + * (the only external files Acode can reach without a fresh SAF prompt) + * and rebuild the real content:// uri, same docId scheme openFolder.js + * already relies on elsewhere in this codebase. + */ +export function resolveContentUriForFileUri(fileUri) { + if (!fileUri?.startsWith("file:///")) return null; + const targetPath = decodeURIComponent(fileUri.slice("file://".length)); + + for (const folder of addedFolder) { + const rootUrl = folder?.url; + if (!rootUrl) continue; + + if (rootUrl.startsWith("content:")) { + let parsed; + try { + parsed = Uri.parse(rootUrl); + } catch { + continue; + } + const authorityId = getContentAuthorityId(parsed.rootUri ?? rootUrl); + const handler = authorityId && CONTENT_AUTHORITY_HANDLERS[authorityId]; + if (!handler) continue; + + const rootPath = handler.docIdToPath(parsed.docId); + if (!rootPath) continue; + + if (targetPath === rootPath) { + return Uri.format(parsed.rootUri, parsed.docId); + } + if (targetPath.startsWith(`${rootPath}/`)) { + const suffix = targetPath.slice(rootPath.length); // leading "/" + const childDocId = parsed.docId.endsWith("/") + ? parsed.docId.slice(0, -1) + suffix + : parsed.docId + suffix; + return Uri.format(parsed.rootUri, childDocId); + } + continue; + } + + if (rootUrl.startsWith("sftp:")) { + let parts; + try { + parts = Url.decodeUrl(rootUrl); + } catch { + continue; + } + const rootPath = (parts.pathname || "").replace(/\/+$/, ""); + if (!rootPath) continue; + + let childPath = null; + if (targetPath === rootPath) { + childPath = rootPath; + } else if (targetPath.startsWith(`${rootPath}/`)) { + childPath = targetPath; + } else { + continue; + } + + return Url.formate({ + protocol: "sftp:", + hostname: parts.hostname, + username: parts.username, + password: parts.password, + port: parts.port, + path: childPath, + query: parts.query, + }); + } + if (rootUrl.startsWith("file:")) { + let rootPath; + try { + rootPath = decodeURIComponent(rootUrl.slice("file://".length)).replace( + /\/+$/, + "", + ); + } catch { + continue; + } + if (!rootPath) continue; + + // Android: /data/user/0/ is a symlink to /data/data/ + // Normalize both sides so the comparison works regardless of which + // representation Acode and the LSP each use. + const normalizeAndroidPath = (p) => + p.replace(/^\/data\/user\/0\//, "/data/data/"); + + const nTarget = normalizeAndroidPath(targetPath); + const nRoot = normalizeAndroidPath(rootPath); + + if (nTarget === nRoot) return rootUrl; + if (nTarget.startsWith(`${nRoot}/`)) { + const suffix = nTarget.slice(nRoot.length); // leading "/" + return rootUrl.replace(/\/+$/, "") + suffix; + } + continue; + } + } + + return null; +} diff --git a/src/lib/editorManager.js b/src/lib/editorManager.js index a3be985c6..c15f6211f 100644 --- a/src/lib/editorManager.js +++ b/src/lib/editorManager.js @@ -89,6 +89,7 @@ import ScrollBar from "components/scrollbar"; import SideButton, { sideButtonContainer } from "components/sideButton"; import keyboardHandler, { keydownState } from "handlers/keyboard"; import { animate } from "motion"; +import Url from "utils/Url"; import config from "./config"; import EditorFile from "./editorFile"; import openFile from "./openFile"; @@ -142,6 +143,25 @@ async function EditorManager($header, $body) { let historyIndex = -1; let isNavigatingHistory = false; + async function resolveDisplayUri(targetUri) { + if (!targetUri) return null; + const decodedUri = decodeURIComponent(targetUri); + if (!decodedUri.startsWith("file:///")) return decodedUri; + try { + const { resolveContentUriForFileUri } = await import( + "components/referencesPanel/utils" + ); + return resolveContentUriForFileUri(decodedUri) ?? decodedUri; + } catch (error) { + console.warn( + "[LSP] Failed to resolve uri for display", + decodedUri, + error, + ); + return decodedUri; + } + } + function warnRecoverable(message, error, key) { if (key) { if (recoverableWarningKeys.has(key)) return; @@ -1514,11 +1534,33 @@ async function EditorManager($header, $body) { function resolveRootUriForContext(context = {}) { const uri = context.uri || context.file?.uri; if (!uri) return null; + for (const folder of addedFolder) { const base = typeof folder?.url === "string" ? folder.url : ""; if (!base) continue; + + // Plain schemes (content://, file://) are stable strings with no + // variable auth/port formatting — a literal prefix check is fine. if (uri.startsWith(base)) return base; + + // sftp:// can carry credential/port formatting that legitimately + // differs between when a folder was added and when an individual + // file's own uri gets built later, even though both point at the + // same remote path. Compare the actual remote paths instead of + // the raw connection strings. + if (uri.startsWith("sftp:") && base.startsWith("sftp:")) { + try { + const uriPath = Url.pathname(uri); + const basePath = Url.pathname(base).replace(/\/+$/, ""); + if (uriPath === basePath || uriPath.startsWith(`${basePath}/`)) { + return base; + } + } catch (error) { + // malformed url, try the next folder + } + } } + return uri; } @@ -3311,44 +3353,42 @@ async function EditorManager($header, $body) { })(); }, displayFile: async (targetUri) => { - if (!targetUri) return null; - // Decode URI components (e.g., %40 -> @) since LSP returns encoded URIs - const decodedUri = decodeURIComponent(targetUri); - const existing = manager.getFile(decodedUri, "uri"); + const resolvedUri = await resolveDisplayUri(targetUri); + if (!resolvedUri) return null; + const existing = manager.getFile(resolvedUri, "uri"); if (existing?.type === "editor") { existing.makeActive(); return editor; } try { - await openFile(decodedUri, { render: true }); - const opened = manager.getFile(decodedUri, "uri"); + await openFile(resolvedUri, { render: true }); + const opened = manager.getFile(resolvedUri, "uri"); if (opened?.type === "editor") { opened.makeActive(); return editor; } } catch (error) { - console.error("[LSP] Failed to open file", decodedUri, error); + console.error("[LSP] Failed to open file", resolvedUri, error); } return null; }, openFile: async (targetUri) => { - if (!targetUri) return null; - // Decode URI components (e.g., %40 -> @) - const decodedUri = decodeURIComponent(targetUri); - const existing = manager.getFile(decodedUri, "uri"); + const resolvedUri = await resolveDisplayUri(targetUri); + if (!resolvedUri) return null; + const existing = manager.getFile(resolvedUri, "uri"); if (existing?.type === "editor") { existing.makeActive(); return editor; } try { - await openFile(decodedUri, { render: true }); - const opened = manager.getFile(decodedUri, "uri"); + await openFile(resolvedUri, { render: true }); + const opened = manager.getFile(resolvedUri, "uri"); if (opened?.type === "editor") { opened.makeActive(); return editor; } } catch (error) { - console.error("[LSP] Failed to open file", decodedUri, error); + console.error("[LSP] Failed to open file", resolvedUri, error); } return null; }, diff --git a/src/lib/selectionMenu.js b/src/lib/selectionMenu.js index b54dc193d..9e57e56ce 100644 --- a/src/lib/selectionMenu.js +++ b/src/lib/selectionMenu.js @@ -1,5 +1,18 @@ import appSettings from "lib/settings"; +function suppressResidualTouch(duration = 350) { + const swallow = (event) => { + event.stopPropagation(); + event.preventDefault(); + }; + document.addEventListener("click", swallow, true); + document.addEventListener("pointerup", swallow, true); + setTimeout(() => { + document.removeEventListener("click", swallow, true); + document.removeEventListener("pointerup", swallow, true); + }, duration); +} + const exec = (command) => { const { editor } = editorManager; editor.execCommand(command); @@ -12,18 +25,82 @@ const exec = (command) => { editor.focus(); }; -const showCodeActions = async () => { +const showLspMenu = async () => { + suppressResidualTouch(); + const { editor } = editorManager; if (!editor) return; + let lsp; try { - const { showCodeActionsMenu, supportsCodeActions } = await import("cm/lsp"); - if (supportsCodeActions(editor)) { - await showCodeActionsMenu(editor); - } + lsp = await import("cm/lsp"); } catch (error) { - console.warn("[SelectionMenu] Code actions not available:", error); + console.warn("[SelectionMenu] LSP module not available:", error); + return; + } + + const { LSPPlugin } = await import("@codemirror/lsp-client"); + const plugin = LSPPlugin.get(editor); + if (!plugin) return; + + const capabilities = plugin.client.serverCapabilities || {}; + + const actions = [ + capabilities.definitionProvider && { + value: "definition", + text: "Go to Definition", + icon: "keyboard_arrow_right", + run: lsp.goToDefinition, + }, + capabilities.declarationProvider && { + value: "declaration", + text: "Go to Declaration", + icon: "keyboard_arrow_right", + run: lsp.goToDeclaration, + }, + capabilities.implementationProvider && { + value: "implementation", + text: "Go to Implementation", + icon: "keyboard_arrow_right", + run: lsp.goToImplementation, + }, + capabilities.typeDefinitionProvider && { + value: "typeDefinition", + text: "Go to Type Definition", + icon: "keyboard_arrow_right", + run: lsp.goToTypeDefinition, + }, + capabilities.referencesProvider && { + value: "references", + text: "Find References", + icon: "linkinsert_link", + run: lsp.findAllReferences, + }, + capabilities.renameProvider && { + value: "rename", + text: "Rename Symbol", + icon: "edit", + run: lsp.renameSymbol, + }, + lsp.supportsCodeActions(editor) && { + value: "codeActions", + text: "Code Actions", + icon: "lightbulb", + run: lsp.showCodeActionsMenu, + }, + ].filter(Boolean); + + if (actions.length === 0) return; + + // Skip the picker entirely if there's only one thing to offer + if (actions.length === 1) { + await actions[0].run(editor); + return; } + const { default: select } = await import("dialogs/select"); + const chosen = await select("LSP Actions", actions).catch(() => null); + const action = actions.find((a) => a.value === chosen); + if (action) await action.run(editor); }; const items = []; @@ -57,8 +134,8 @@ export default function selectionMenu() { "all", ), item( - () => showCodeActions(), - , + () => showLspMenu(), + , "all", true, ), From 43e2cf7fa51ef94e7c65f3a21d6ba86820ade517 Mon Sep 17 00:00:00 2001 From: gat0sy Date: Sat, 8 Aug 2026 01:17:29 +0000 Subject: [PATCH 4/5] feat(ui): partial redesign of the custom lsp creation wizard into a form --- src/settings/lspSettings.js | 177 ++++++++++++++++++++++-------------- 1 file changed, 107 insertions(+), 70 deletions(-) diff --git a/src/settings/lspSettings.js b/src/settings/lspSettings.js index 6a9b41803..31e7f5dd9 100644 --- a/src/settings/lspSettings.js +++ b/src/settings/lspSettings.js @@ -3,6 +3,7 @@ import serverRegistry from "cm/lsp/serverRegistry"; import { builtinServers } from "cm/lsp/servers"; import settingsPage from "components/settingsPage"; import toast from "components/toast"; +import multiPrompt from "dialogs/multiPrompt"; import prompt from "dialogs/prompt"; import select from "dialogs/select"; import appSettings from "lib/settings"; @@ -297,102 +298,138 @@ export default function lspSettings() { if (key === "add_custom_server") { try { - const idInput = await prompt(strings["lsp-server-id"], "", "text"); - if (idInput === null) return; - - const serverId = normalizeServerId(idInput); + const USE_WS = true; // default transport; false = STDIO + + const result = await multiPrompt(strings["lsp-add-custom-server"], [ + { + id: "serverId", + placeholder: strings["lsp-server-id"], + type: "text", + required: true, + value: "", + }, + { + id: "label", + placeholder: strings["lsp-server-label"], + type: "text", + value: "", + }, + { + id: "languages", + placeholder: strings["lsp-language-ids"], + type: "text", + required: true, + value: "", + }, + [ + "Transport: ", + { + id: "useWebSocket", + placeholder: "WebSocket", + name: "transportType", + type: "radio", + value: USE_WS, + onchange() { + if (!!this.value) { + this.prompt.$body.get("#websocketUrl").hidden = false; + this.prompt.$body.get("#binaryCommand").hidden = true; + this.prompt.$body.get("#binaryArgs").hidden = true; + this.prompt.$body.get("#binaryCommand").value = ""; + } + }, + }, + { + id: "useStdio", + placeholder: "STDIO", + name: "transportType", + type: "radio", + value: !USE_WS, + onchange() { + if (!!this.value) { + this.prompt.$body.get("#websocketUrl").hidden = true; + this.prompt.$body.get("#websocketUrl").value = ""; + this.prompt.$body.get("#binaryCommand").hidden = false; + this.prompt.$body.get("#binaryArgs").hidden = false; + } + }, + }, + ], + { + id: "websocketUrl", + placeholder: "ws://127.0.0.1:3000/", + type: "text", + value: "ws://127.0.0.1:3000/", + hidden: !USE_WS, + }, + { + id: "binaryCommand", + placeholder: strings["lsp-binary-command"], + type: "text", + hidden: USE_WS, + value: "", + }, + { + id: "binaryArgs", + placeholder: strings["lsp-binary-args"], + type: "textarea", + hidden: USE_WS, + value: "[]", + }, + ]); + + if (!result) return; // user cancelled + + const serverId = normalizeServerId(result.serverId); if (!serverId) { toast(strings["lsp-error-server-id-required"]); return; } - const label = await prompt( - strings["lsp-server-label"], - serverId, - "text", - ); - if (label === null) return; - - const languageInput = await prompt( - strings["lsp-language-ids"], - "", - "text", - ); - if (languageInput === null) return; - const languages = normalizeLanguages(languageInput); + const label = result.label || serverId; + const languages = normalizeLanguages(result.languages); if (!languages.length) { toast(strings["lsp-error-language-id-required"]); return; } - const transportKind = await select( - strings.type || "Type", - getTransportMethods(), - ); - if (!transportKind) return; - let transport; let launcher; - if (transportKind === "websocket") { - const websocketUrlInput = await prompt( - strings["lsp-websocket-url"] || "WebSocket URL", - "ws://127.0.0.1:3000/", - "text", - { - test: (value) => { - try { - parseWebSocketUrl(value); - return true; - } catch { - return false; - } - }, - }, - ); - if (websocketUrlInput === null) return; - + if (result.useWebSocket) { + const url = String(result.websocketUrl || "").trim(); + if (!url) { + toast( + strings["lsp-error-websocket-url-required"] || + "WebSocket URL is required", + ); + return; + } transport = { kind: "websocket", - url: parseWebSocketUrl(websocketUrlInput), + url: parseWebSocketUrl(url), }; } else { - const binaryCommand = await prompt( - strings["lsp-binary-command"], - "", - "text", - ); - if (binaryCommand === null) return; - if (!String(binaryCommand).trim()) { + const binaryCommand = String(result.binaryCommand || "").trim(); + if (!binaryCommand) { toast(strings["lsp-error-binary-command-required"]); return; } - const argsInput = await prompt( - strings["lsp-binary-args"], - "[]", - "textarea", - { - test: (value) => { - try { - parseArgsInput(value); - return true; - } catch { - return false; - } - }, - }, - ); - if (argsInput === null) return; + let parsedArgs; + try { + parsedArgs = parseArgsInput(result.binaryArgs); + } catch (err) { + toast(err.message); + return; + } - const parsedArgs = parseArgsInput(argsInput); const installer = await promptInstaller(binaryCommand); if (installer === null) return; + const defaultCheckCommand = buildDefaultCheckCommand( binaryCommand, installer, ); - const checkCommand = await prompt( strings["lsp-check-command-optional"], defaultCheckCommand, @@ -405,13 +442,13 @@ export default function lspSettings() { transport = { kind: "stdio", - command: String(binaryCommand).trim(), + command: binaryCommand, args: parsedArgs, }; launcher = { bridge: { kind: "axs", - command: String(binaryCommand).trim(), + command: binaryCommand, args: parsedArgs, }, checkCommand: String(checkCommand || "").trim() || undefined, From 5fa3ac0742bea4faa4ad1bef8ec46d790eb80bae Mon Sep 17 00:00:00 2001 From: gat0sy Date: Sat, 8 Aug 2026 01:34:12 +0000 Subject: [PATCH 5/5] feat(lsp): add SFTP remote runtime provider for LSP workspaces Added sftpRemote.ts runtime provider that translates sftp:// URIs to file:// before sending to LSP servers, stripping host/credentials Register sftpRemoteRuntimeProvider in registerBuiltins.ts Delegate transport to external-websocket provider since SFTP LSP connections are handled via WebSocket tunnel --- src/cm/lsp/runtimes/registerBuiltins.ts | 3 ++ src/cm/lsp/runtimes/sftpRemote.ts | 43 +++++++++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 src/cm/lsp/runtimes/sftpRemote.ts diff --git a/src/cm/lsp/runtimes/registerBuiltins.ts b/src/cm/lsp/runtimes/registerBuiltins.ts index 5e49c5c9f..8f9fd51b7 100644 --- a/src/cm/lsp/runtimes/registerBuiltins.ts +++ b/src/cm/lsp/runtimes/registerBuiltins.ts @@ -2,7 +2,10 @@ import { registerRuntimeProvider } from "../runtimeProviders"; import builtinAlpineRuntimeProvider from "./builtinAlpine"; import externalWebSocketRuntimeProvider from "./externalWebSocket"; import webWorkerRuntimeProvider from "./webWorker"; +import { sftpRemoteRuntimeProvider } from "./sftpRemote"; registerRuntimeProvider(builtinAlpineRuntimeProvider, { replace: true }); registerRuntimeProvider(externalWebSocketRuntimeProvider, { replace: true }); registerRuntimeProvider(webWorkerRuntimeProvider, { replace: true }); +registerRuntimeProvider(sftpRemoteRuntimeProvider, { replace: true }); + diff --git a/src/cm/lsp/runtimes/sftpRemote.ts b/src/cm/lsp/runtimes/sftpRemote.ts new file mode 100644 index 000000000..3b2884348 --- /dev/null +++ b/src/cm/lsp/runtimes/sftpRemote.ts @@ -0,0 +1,43 @@ +import type { + LspRuntimeProvider, + LspRuntimeContext, + LspRuntimeUriResolutionContext, + TransportDescriptor, +} from "../types"; +import { getRuntimeProvider } from "../runtimeProviders"; + +function sftpUriToFileUri(uri: string): string | null { + const match = /^sftp:\/\/[^/]*(\/.*)$/.exec(uri); + if (!match) return null; + const path = match[1].split("?")[0]; + if (!path) return null; + return "file://" + path; +} + +export const sftpRemoteRuntimeProvider: LspRuntimeProvider = { + id: "sftp-remote", + label: "SFTP Remote", + priority: -30, + + canHandle(server, context) { + const uri = String(context.rootUri || context.uri || ""); + return /^sftp:/i.test(uri); + }, + + resolveUris(server, context: LspRuntimeUriResolutionContext) { + const documentUri = sftpUriToFileUri(context.originalDocumentUri); + const rootUri = context.originalRootUri + ? sftpUriToFileUri(context.originalRootUri) + : null; + return { documentUri, rootUri, scope: "workspace" }; + }, + + async start(server, context: LspRuntimeContext): Promise { + // Delegate transport to external-websocket provider + const external = getRuntimeProvider("external-websocket"); + if (!external || !external.start) { + throw new Error("SFTP workspace requires external-websocket transport provider"); + } + return external.start(server, context); + }, +};