Merge PR #8399: fix(lsp): synchronize applied workspace edits (@roboomp)
This commit is contained in:
@@ -78,6 +78,9 @@
|
||||
### Fixed
|
||||
|
||||
- Fixed `lsp` `rename_file` leaving reference edits applied when the file move fails; a failed move now rolls back every rewritten reference file so the source, destination, and references are left unchanged ([#8379](https://github.com/can1357/oh-my-pi/issues/8379)).
|
||||
### Fixed
|
||||
|
||||
- Fixed applied LSP `WorkspaceEdit`s leaving already-open language-server document overlays stale for later semantic requests ([#8372](https://github.com/can1357/oh-my-pi/issues/8372)).
|
||||
|
||||
## [17.2.15] - 2026-08-12
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import type {
|
||||
ServerConfig,
|
||||
WorkspaceEdit,
|
||||
} from "./types";
|
||||
import { detectLanguageId, EquivalentUriMap, fileToUri } from "./utils";
|
||||
import { detectLanguageId, EquivalentUriMap, fileToUri, uriToFile } from "./utils";
|
||||
|
||||
// =============================================================================
|
||||
// Client State
|
||||
@@ -481,13 +481,97 @@ async function handleApplyEditRequest(client: LspClient, message: LspJsonRpcRequ
|
||||
}
|
||||
|
||||
try {
|
||||
await applyWorkspaceEdit(params.edit, client.cwd);
|
||||
await applyWorkspaceEditWithLsp(params.edit, client.cwd);
|
||||
await sendResponse(client, message.id, { applied: true }, "workspace/applyEdit");
|
||||
} catch (err) {
|
||||
await sendResponse(client, message.id, { applied: false, failureReason: String(err) }, "workspace/applyEdit");
|
||||
}
|
||||
}
|
||||
|
||||
function workspaceEditChanges(edit: WorkspaceEdit): {
|
||||
finalUris: Set<string>;
|
||||
deletedRoots: Set<string>;
|
||||
watchedFiles: WatchedFileChange[];
|
||||
} {
|
||||
const finalUris = new Set<string>();
|
||||
const deletedRoots = new Set<string>();
|
||||
const watchedFiles: WatchedFileChange[] = [];
|
||||
const watch = (uri: string, type: FileChangeType) => {
|
||||
watchedFiles.push({ filePath: uriToFile(uri), type });
|
||||
};
|
||||
|
||||
if (edit.changes) {
|
||||
for (const uri in edit.changes) {
|
||||
if (edit.changes[uri].length === 0) continue;
|
||||
finalUris.add(uri);
|
||||
watch(uri, FileChangeType.Changed);
|
||||
}
|
||||
}
|
||||
for (const change of edit.documentChanges ?? []) {
|
||||
if ("textDocument" in change) {
|
||||
if (change.edits.length === 0) continue;
|
||||
finalUris.add(change.textDocument.uri);
|
||||
watch(change.textDocument.uri, FileChangeType.Changed);
|
||||
} else if (change.kind === "create") {
|
||||
finalUris.add(change.uri);
|
||||
watch(change.uri, FileChangeType.Created);
|
||||
} else if (change.kind === "rename") {
|
||||
deletedRoots.add(change.oldUri);
|
||||
finalUris.add(change.newUri);
|
||||
watch(change.oldUri, FileChangeType.Deleted);
|
||||
watch(change.newUri, FileChangeType.Created);
|
||||
} else if (change.kind === "delete") {
|
||||
deletedRoots.add(change.uri);
|
||||
watch(change.uri, FileChangeType.Deleted);
|
||||
}
|
||||
}
|
||||
|
||||
return { finalUris, deletedRoots, watchedFiles };
|
||||
}
|
||||
|
||||
function uriIsWithin(uri: string, root: string): boolean {
|
||||
return uri === root || uri.startsWith(root.endsWith("/") ? root : `${root}/`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a server-provided workspace edit and reconcile every affected open LSP document.
|
||||
* Runtime callers use this wrapper so later semantic requests observe the committed files.
|
||||
*/
|
||||
export async function applyWorkspaceEditWithLsp(
|
||||
edit: WorkspaceEdit,
|
||||
cwd: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string[]> {
|
||||
const applied = await applyWorkspaceEdit(edit, cwd);
|
||||
const { finalUris, deletedRoots, watchedFiles } = workspaceEditChanges(edit);
|
||||
const workspace = path.resolve(cwd);
|
||||
const activeClients = Array.from(clients.values()).filter(
|
||||
client => client.status === "ready" && path.resolve(client.cwd) === workspace,
|
||||
);
|
||||
|
||||
for (const activeClient of activeClients) {
|
||||
for (const uri of [...activeClient.openFiles.keys()]) {
|
||||
let deleted = false;
|
||||
for (const root of deletedRoots) {
|
||||
if (uriIsWithin(uri, root)) {
|
||||
deleted = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!deleted) continue;
|
||||
await sendNotification(activeClient, "textDocument/didClose", { textDocument: { uri } }, signal);
|
||||
activeClient.openFiles.delete(uri);
|
||||
activeClient.diagnostics.delete(uri);
|
||||
}
|
||||
for (const uri of finalUris) {
|
||||
if (!activeClient.openFiles.has(uri)) continue;
|
||||
await refreshFile(activeClient, uriToFile(uri), signal);
|
||||
}
|
||||
}
|
||||
await notifyWorkspaceWatchedFiles(cwd, watchedFiles, signal);
|
||||
return applied;
|
||||
}
|
||||
|
||||
interface DynamicCapabilityRegistration {
|
||||
id?: unknown;
|
||||
method?: unknown;
|
||||
|
||||
@@ -16,6 +16,7 @@ import { formatPathRelativeToCwd, resolveToCwd } from "../tools/path-utils";
|
||||
import { ToolAbortError, ToolError, throwIfAborted } from "../tools/tool-errors";
|
||||
import { clampTimeout } from "../tools/tool-timeouts";
|
||||
import {
|
||||
applyWorkspaceEditWithLsp,
|
||||
clearInitializationFailure,
|
||||
ensureFileOpen,
|
||||
getActiveClients,
|
||||
@@ -45,7 +46,6 @@ import {
|
||||
} from "./diagnostics";
|
||||
import {
|
||||
applyEditsThenRename,
|
||||
applyWorkspaceEdit,
|
||||
flattenWorkspaceTextEdits,
|
||||
type RenameReferenceEdit,
|
||||
rangesOverlap,
|
||||
@@ -1317,7 +1317,7 @@ export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Them
|
||||
const appliedAction = await applyCodeAction(selectedAction, {
|
||||
resolveCodeAction: async actionItem =>
|
||||
(await sendRequest(client, "codeAction/resolve", actionItem, signal)) as CodeAction,
|
||||
applyWorkspaceEdit: async edit => applyWorkspaceEdit(edit, this.session.cwd),
|
||||
applyWorkspaceEdit: async edit => applyWorkspaceEditWithLsp(edit, this.session.cwd, signal),
|
||||
executeCommand: async commandItem => {
|
||||
await sendRequest(
|
||||
client,
|
||||
@@ -1413,7 +1413,7 @@ export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Them
|
||||
} else {
|
||||
const shouldApply = apply !== false;
|
||||
if (shouldApply) {
|
||||
const applied = await applyWorkspaceEdit(result, this.session.cwd);
|
||||
const applied = await applyWorkspaceEditWithLsp(result, this.session.cwd, signal);
|
||||
output = `Applied rename:\n${applied.map(a => ` ${a}`).join("\n")}`;
|
||||
} else {
|
||||
const preview = formatWorkspaceEdit(result, this.session.cwd);
|
||||
|
||||
@@ -2630,6 +2630,113 @@ describe("lsp regressions", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("synchronizes open document overlays after applying a rename workspace edit", async () => {
|
||||
const tempDir = TempDir.createSync("@omp-lsp-workspace-edit-sync-");
|
||||
const filePath = path.join(tempDir.path(), "main.go");
|
||||
const uri = fileToUri(filePath);
|
||||
let overlay = "";
|
||||
try {
|
||||
await Bun.write(filePath, "package main\nfunc OldName() {}\n");
|
||||
const serverConfig: ServerConfig = {
|
||||
command: "fake-gopls",
|
||||
fileTypes: ["go"],
|
||||
rootMarkers: [],
|
||||
isLinter: true,
|
||||
};
|
||||
const server = installFakeLsp((message, srv) => {
|
||||
if (message.method === "initialize") {
|
||||
srv.send({
|
||||
jsonrpc: "2.0",
|
||||
id: message.id,
|
||||
result: { capabilities: { documentSymbolProvider: true, renameProvider: true } },
|
||||
});
|
||||
} else if (message.method === "textDocument/didOpen") {
|
||||
if (
|
||||
typeof message.params === "object" &&
|
||||
message.params !== null &&
|
||||
"textDocument" in message.params &&
|
||||
typeof message.params.textDocument === "object" &&
|
||||
message.params.textDocument !== null &&
|
||||
"text" in message.params.textDocument &&
|
||||
typeof message.params.textDocument.text === "string"
|
||||
) {
|
||||
overlay = message.params.textDocument.text;
|
||||
}
|
||||
} else if (message.method === "textDocument/didChange") {
|
||||
if (
|
||||
typeof message.params === "object" &&
|
||||
message.params !== null &&
|
||||
"contentChanges" in message.params &&
|
||||
Array.isArray(message.params.contentChanges) &&
|
||||
typeof message.params.contentChanges[0] === "object" &&
|
||||
message.params.contentChanges[0] !== null &&
|
||||
"text" in message.params.contentChanges[0] &&
|
||||
typeof message.params.contentChanges[0].text === "string"
|
||||
) {
|
||||
overlay = message.params.contentChanges[0].text;
|
||||
}
|
||||
} else if (message.method === "textDocument/documentSymbol") {
|
||||
const name = overlay.includes("NewName") ? "NewName" : "OldName";
|
||||
srv.send({
|
||||
jsonrpc: "2.0",
|
||||
id: message.id,
|
||||
result: [
|
||||
{
|
||||
name,
|
||||
kind: 12,
|
||||
range: { start: { line: 1, character: 0 }, end: { line: 1, character: 17 } },
|
||||
selectionRange: { start: { line: 1, character: 5 }, end: { line: 1, character: 12 } },
|
||||
},
|
||||
],
|
||||
});
|
||||
} else if (message.method === "textDocument/rename") {
|
||||
srv.send({
|
||||
jsonrpc: "2.0",
|
||||
id: message.id,
|
||||
result: {
|
||||
changes: {
|
||||
[uri]: [
|
||||
{
|
||||
range: { start: { line: 1, character: 5 }, end: { line: 1, character: 12 } },
|
||||
newText: "NewName",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
} else if (message.method === "shutdown") {
|
||||
srv.send({ jsonrpc: "2.0", id: message.id, result: null });
|
||||
} else if (message.method === "exit") {
|
||||
srv.exit(0);
|
||||
}
|
||||
});
|
||||
vi.spyOn(lspConfig, "loadConfig").mockReturnValue({
|
||||
servers: { "fake-gopls": serverConfig },
|
||||
idleTimeoutMs: undefined,
|
||||
});
|
||||
|
||||
const tool = new LspTool(makeLspSession(tempDir.path()));
|
||||
expect(
|
||||
textResult(await tool.execute("symbols-before-rename", { action: "symbols", file: filePath })),
|
||||
).toContain("OldName");
|
||||
await tool.execute("rename-open-document", {
|
||||
action: "rename",
|
||||
file: filePath,
|
||||
line: 2,
|
||||
symbol: "OldName",
|
||||
new_name: "NewName",
|
||||
});
|
||||
expect(await Bun.file(filePath).text()).toContain("NewName");
|
||||
|
||||
const symbols = await tool.execute("symbols-after-rename", { action: "symbols", file: filePath });
|
||||
expect(textResult(symbols)).toContain("NewName");
|
||||
} finally {
|
||||
configCache.delete(tempDir.path());
|
||||
await lspClient.shutdownAll();
|
||||
tempDir.removeSync();
|
||||
}
|
||||
});
|
||||
|
||||
it("flushes pending descendant text edits before a folder rename", async () => {
|
||||
const tempDir = TempDir.createSync("@omp-lsp-folder-rename-");
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user