Skip to content

Commit bebf044

Browse files
committed
feat(editor): async post-save diagnostics on chat-diff save path (L1, #1375)
1 parent 113bcd3 commit bebf044

3 files changed

Lines changed: 190 additions & 36 deletions

File tree

‎src/eslint-suppressions.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1171,7 +1171,7 @@
11711171
},
11721172
"integrations/editor/__tests__/DiffViewProvider.spec.ts": {
11731173
"@typescript-eslint/no-explicit-any": {
1174-
"count": 310
1174+
"count": 306
11751175
}
11761176
},
11771177
"integrations/editor/__tests__/EditorUtils.spec.ts": {

‎src/integrations/editor/DiffViewProvider.ts‎

Lines changed: 55 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1152,8 +1152,11 @@ export class DiffViewProvider {
11521152
}> {
11531153
const absolutePath = path.resolve(this.cwd, relPath)
11541154

1155-
// Get diagnostics before editing the file
1156-
this.preDiagnostics = vscode.languages.getDiagnostics()
1155+
// Get diagnostics before editing the file. Capture the snapshot locally:
1156+
// overlapping saveDirectly calls (multi-file edits) must not let a later
1157+
// call overwrite this one's baseline before its diagnostics tail runs.
1158+
const preDiagnostics = vscode.languages.getDiagnostics()
1159+
this.preDiagnostics = preDiagnostics
11571160

11581161
// Write the content directly to the file
11591162
await createDirectoriesForFile(absolutePath)
@@ -1180,19 +1183,51 @@ export class DiffViewProvider {
11801183
await new Promise((resolve) => setTimeout(resolve, 100))
11811184
}
11821185

1183-
let newProblemsMessage = ""
1184-
1186+
// L1 (A2): resolve without awaiting the LSP diagnostics settle. The
1187+
// diagnostics check becomes a fire-and-forget tail that emits any new
1188+
// problems via the existing "error" ClineSay type; the returned
1189+
// newProblemsMessage is therefore always undefined.
11851190
if (diagnosticsEnabled) {
1186-
// Add configurable delay to allow linters time to process
1187-
const safeDelayMs = Math.max(0, writeDelayMs)
1191+
// The method's outer try/catch guarantees it never rejects, so the
1192+
// fire-and-forget call needs no .catch wrapper.
1193+
void this.emitPostSaveDiagnostics(relPath, writeDelayMs, preDiagnostics)
1194+
}
11881195

1189-
try {
1190-
await delay(safeDelayMs)
1191-
} catch (error) {
1192-
console.warn(`Failed to apply write delay: ${error}`)
1193-
}
1196+
// Store the results for formatFileWriteResponse
1197+
this.newProblemsMessage = undefined
1198+
this.userEdits = undefined
1199+
this.relPath = relPath
1200+
this.newContent = content
11941201

1195-
const postDiagnostics = vscode.languages.getDiagnostics()
1202+
return {
1203+
newProblemsMessage: undefined,
1204+
userEdits: undefined,
1205+
finalContent: content,
1206+
}
1207+
}
1208+
1209+
// L1 (A2): fire-and-forget post-save diagnostics. After the write delay,
1210+
// collects new Error-severity problems and emits them via the existing
1211+
// "error" ClineSay type (only Error-severity diagnostics reach this branch;
1212+
// "error" carries no task-failure semantics in core). Abort-safe: say()
1213+
// rejects when the task is aborted, so the whole body sits inside a
1214+
// try/catch that degrades to a console.warn — the tail can never reject.
1215+
private async emitPostSaveDiagnostics(
1216+
relPath: string,
1217+
writeDelayMs: number,
1218+
preDiagnostics: [vscode.Uri, vscode.Diagnostic[]][],
1219+
): Promise<void> {
1220+
try {
1221+
// Add configurable delay to allow linters time to process.
1222+
// delay() never rejects, so no catch is required here.
1223+
const safeDelayMs = Math.max(0, writeDelayMs)
1224+
await delay(safeDelayMs)
1225+
1226+
// Filter to the saved file: saveDirectly resolves before this tail
1227+
// completes, so in a multi-file write sequence (e.g. apply_patch)
1228+
// a later file's problems must not be attributed to this relPath.
1229+
const savedFilePath = path.resolve(this.cwd, relPath)
1230+
const postDiagnostics = vscode.languages.getDiagnostics().filter(([uri]) => uri.fsPath === savedFilePath)
11961231

11971232
// Get diagnostic settings from state
11981233
const task = this.taskRef.deref()
@@ -1201,27 +1236,20 @@ export class DiffViewProvider {
12011236
const maxDiagnosticMessages = state?.maxDiagnosticMessages ?? 50
12021237

12031238
const newProblems = await diagnosticsToProblemsString(
1204-
getNewDiagnostics(this.preDiagnostics, postDiagnostics),
1239+
getNewDiagnostics(preDiagnostics, postDiagnostics),
12051240
[vscode.DiagnosticSeverity.Error],
12061241
this.cwd,
12071242
includeDiagnosticMessages,
12081243
maxDiagnosticMessages,
12091244
)
12101245

1211-
newProblemsMessage =
1212-
newProblems.length > 0 ? `\n\nNew problems detected after saving the file:\n${newProblems}` : ""
1213-
}
1214-
1215-
// Store the results for formatFileWriteResponse
1216-
this.newProblemsMessage = newProblemsMessage
1217-
this.userEdits = undefined
1218-
this.relPath = relPath
1219-
this.newContent = content
1220-
1221-
return {
1222-
newProblemsMessage,
1223-
userEdits: undefined,
1224-
finalContent: content,
1246+
if (newProblems.length > 0) {
1247+
await task?.say("error", `New problems detected after saving file: ${relPath}\n\n${newProblems}`)
1248+
}
1249+
} catch (error) {
1250+
// Abort-safe: never let a post-save diagnostic emit become an
1251+
// unhandled rejection (say() rejects when the task is aborted).
1252+
console.warn(`Post-save diagnostics emit failed: ${error}`)
12251253
}
12261254
}
12271255
}

‎src/integrations/editor/__tests__/DiffViewProvider.spec.ts‎

Lines changed: 134 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ vi.mock("path", () => ({
3636
basename: vi.fn((path) => path.split("/").pop()),
3737
dirname: vi.fn((path) => path.split("/").slice(0, -1).join("/") || "/"),
3838
join: (...args: string[]) => args.join("/"),
39+
// diagnosticsToProblemsString formats its output header via
40+
// path.relative(cwd, uri.fsPath).toPosix(); the object-with-toPosix shape
41+
// mirrors the repo's own diagnostics.spec.ts mock.
42+
relative: vi.fn((cwd: string, p: string) => ({ toPosix: () => p.replace(`${cwd}/`, "") })),
3943
}))
4044

4145
// Mock vscode
@@ -174,6 +178,8 @@ describe("DiffViewProvider", () => {
174178
}),
175179
}),
176180
},
181+
// L1: saveDirectly's fire-and-forget diagnostics tail emits via say().
182+
say: vi.fn().mockResolvedValue(true),
177183
}
178184

179185
diffViewProvider = new DiffViewProvider(mockCwd, mockTask)
@@ -811,12 +817,17 @@ describe("DiffViewProvider", () => {
811817
{ preview: false, preserveFocus: true },
812818
)
813819

814-
// Verify diagnostics were checked after delay
820+
// L1: saveDirectly resolves before the fire-and-forget diagnostics
821+
// tail runs; flush one macrotask tick so the mocked delay (and the
822+
// post-write getDiagnostics) have been reached before asserting.
823+
await new Promise((resolve) => setTimeout(resolve, 0))
824+
825+
// Verify the tail applied the configured write delay
815826
expect(mockDelay).toHaveBeenCalledWith(2000)
816827
expect(vscode.languages.getDiagnostics).toHaveBeenCalled()
817828

818-
// Verify result
819-
expect(result.newProblemsMessage).toBe("")
829+
// Verify result: L1 no longer returns a problems message
830+
expect(result.newProblemsMessage).toBeUndefined()
820831
expect(result.userEdits).toBeUndefined()
821832
expect(result.finalContent).toBe("new content")
822833
})
@@ -847,6 +858,10 @@ describe("DiffViewProvider", () => {
847858
expect(mockDelay).not.toHaveBeenCalled()
848859
// getDiagnostics is called once for pre-diagnostics, but not for post-diagnostics
849860
expect(vscode.languages.getDiagnostics).toHaveBeenCalledTimes(1)
861+
862+
// L1: no diagnostics tail is launched, so nothing is ever emitted
863+
await new Promise((resolve) => setTimeout(resolve, 0))
864+
expect(mockTask.say).not.toHaveBeenCalled()
850865
})
851866

852867
it("should handle negative delay values", async () => {
@@ -855,18 +870,129 @@ describe("DiffViewProvider", () => {
855870

856871
await diffViewProvider.saveDirectly("test.ts", "new content", true, true, -500)
857872

873+
// L1: the tail runs after resolve; flush one macrotask tick first.
874+
await new Promise((resolve) => setTimeout(resolve, 0))
875+
858876
// Verify delay was called with 0 (safe minimum)
859877
expect(mockDelay).toHaveBeenCalledWith(0)
860878
})
861879

862880
it("should store results for formatFileWriteResponse", async () => {
863881
await diffViewProvider.saveDirectly("test.ts", "new content", true, true, 1000)
864882

865-
// Verify internal state was updated
866-
expect((diffViewProvider as any).newProblemsMessage).toBe("")
867-
expect((diffViewProvider as any).userEdits).toBeUndefined()
868-
expect((diffViewProvider as any).relPath).toBe("test.ts")
869-
expect((diffViewProvider as any).newContent).toBe("new content")
883+
// Verify internal state was updated (L1: the problems message is no
884+
// longer stored; it is emitted asynchronously via say("error"))
885+
expect(diffViewProvider["newProblemsMessage"]).toBeUndefined()
886+
expect(diffViewProvider["userEdits"]).toBeUndefined()
887+
expect(diffViewProvider["relPath"]).toBe("test.ts")
888+
expect(diffViewProvider["newContent"]).toBe("new content")
889+
})
890+
891+
it("resolves immediately and emits new problems via say('error') after the write delay", async () => {
892+
const mockDelay = vi.mocked(delay)
893+
mockDelay.mockClear()
894+
vi.mocked(vscode.languages.getDiagnostics).mockClear()
895+
896+
// Pre-write diagnostics are empty; the post-write snapshot (read by
897+
// the fire-and-forget tail) reports one new Error-severity problem.
898+
// vscode.workspace.fs.stat is an unimplemented vi.fn() mock, so
899+
// diagnosticsToProblemsString takes its "(unavailable)" fallback
900+
// branch and still formats the line.
901+
const newDiag: vscode.Diagnostic = {
902+
severity: vscode.DiagnosticSeverity.Error,
903+
range: new vscode.Range(0, 0, 0, 1),
904+
message: "boom",
905+
}
906+
const postDiagnostics: [vscode.Uri, vscode.Diagnostic[]][] = [[makeUri(`${mockCwd}/test.ts`), [newDiag]]]
907+
vi.mocked(vscode.languages.getDiagnostics).mockReturnValueOnce([]).mockReturnValue(postDiagnostics)
908+
909+
const result = await diffViewProvider.saveDirectly("test.ts", "new content", true, true, 100)
910+
911+
// L1: saveDirectly resolves before the tail emits anything.
912+
expect(result.newProblemsMessage).toBeUndefined()
913+
expect(mockTask.say).not.toHaveBeenCalled()
914+
915+
// Flush the fire-and-forget tail (the mocked delay resolves immediately).
916+
await new Promise((resolve) => setTimeout(resolve, 0))
917+
918+
expect(mockDelay).toHaveBeenCalledWith(100)
919+
expect(mockTask.say).toHaveBeenCalledTimes(1)
920+
// The existing "error" ClineSay type is used, with the new-problems text.
921+
expect(mockTask.say).toHaveBeenCalledWith(
922+
"error",
923+
expect.stringContaining("New problems detected after saving file: test.ts"),
924+
)
925+
expect(mockTask.say.mock.calls[0]?.[1]).toContain("boom")
926+
})
927+
928+
it("attributes only the saved file's new problems to the saved file", async () => {
929+
vi.mocked(vscode.languages.getDiagnostics).mockClear()
930+
931+
// Multi-file write sequence: a later file's new error must not be
932+
// attributed to this tail's relPath by the workspace-wide snapshot.
933+
const ownDiag: vscode.Diagnostic = {
934+
severity: vscode.DiagnosticSeverity.Error,
935+
range: new vscode.Range(0, 0, 0, 1),
936+
message: "own-problem",
937+
}
938+
const otherDiag: vscode.Diagnostic = {
939+
severity: vscode.DiagnosticSeverity.Error,
940+
range: new vscode.Range(0, 0, 0, 1),
941+
message: "other-file-problem",
942+
}
943+
const postDiagnostics: [vscode.Uri, vscode.Diagnostic[]][] = [
944+
[makeUri(`${mockCwd}/test.ts`), [ownDiag]],
945+
[makeUri(`${mockCwd}/other.ts`), [otherDiag]],
946+
]
947+
vi.mocked(vscode.languages.getDiagnostics).mockReturnValueOnce([]).mockReturnValue(postDiagnostics)
948+
949+
await diffViewProvider.saveDirectly("test.ts", "new content", true, true, 100)
950+
951+
// Flush the fire-and-forget tail.
952+
await new Promise((resolve) => setTimeout(resolve, 0))
953+
954+
expect(mockTask.say).toHaveBeenCalledTimes(1)
955+
expect(mockTask.say.mock.calls[0]?.[1]).toContain("own-problem")
956+
expect(mockTask.say.mock.calls[0]?.[1]).not.toContain("other-file-problem")
957+
})
958+
959+
it("never calls say when there are no new problems", async () => {
960+
vi.mocked(vscode.languages.getDiagnostics).mockClear()
961+
vi.mocked(vscode.languages.getDiagnostics).mockReturnValue([])
962+
963+
await diffViewProvider.saveDirectly("test.ts", "new content", true, true, 50)
964+
965+
// Flush the fire-and-forget tail (pre/post snapshots are both empty,
966+
// so diagnosticsToProblemsString returns "" and nothing is emitted).
967+
await new Promise((resolve) => setTimeout(resolve, 0))
968+
969+
expect(mockTask.say).not.toHaveBeenCalled()
970+
})
971+
972+
it("logs a warning instead of an unhandled rejection when the post-save say() is aborted", async () => {
973+
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
974+
vi.mocked(vscode.languages.getDiagnostics).mockClear()
975+
976+
// One new Error-severity problem so the tail reaches say().
977+
const newDiag: vscode.Diagnostic = {
978+
severity: vscode.DiagnosticSeverity.Error,
979+
range: new vscode.Range(0, 0, 0, 1),
980+
message: "boom",
981+
}
982+
const postDiagnostics: [vscode.Uri, vscode.Diagnostic[]][] = [[makeUri(`${mockCwd}/test.ts`), [newDiag]]]
983+
vi.mocked(vscode.languages.getDiagnostics).mockReturnValueOnce([]).mockReturnValue(postDiagnostics)
984+
985+
// The task is aborted while the diagnostics tail is emitting: say() rejects.
986+
mockTask.say.mockRejectedValueOnce(new Error("aborted"))
987+
988+
await diffViewProvider.saveDirectly("test.ts", "new content", true, true, 0)
989+
// Flush the fire-and-forget tail.
990+
await new Promise((resolve) => setTimeout(resolve, 0))
991+
992+
// The method's outer catch swallows the rejection with a warning.
993+
expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining("Post-save diagnostics emit failed:"))
994+
995+
consoleWarnSpy.mockRestore()
870996
})
871997
})
872998

0 commit comments

Comments
 (0)