Merge PR #8399: fix(lsp): synchronize applied workspace edits (@roboomp)

This commit is contained in:
can1357
2026-08-13 05:48:27 +02:00
4 changed files with 199 additions and 5 deletions
+3
View File
@@ -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
+86 -2
View File
@@ -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;
+3 -3
View File
@@ -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 {