Repository navigation
feat(editor): async post-save diagnostics on chat-diff save path (L1, #1375) #1403
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
920815e
088e643
caf128c
1e10494
dffa685
a0e8e40
b192d86
bc2075c
321ec2f
ebe1663
b65c24f
5cc9e1a
23739e8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -46,6 +46,14 @@ | |
| private streamedLines: string[] = [] | ||
| private preDiagnostics: [vscode.Uri, vscode.Diagnostic[]][] = [] | ||
| private preEditScrollLine: number | undefined | ||
| // One controller per post-save diagnostics tail that is still waiting, so task | ||
| // disposal can cancel the wait instead of leaving a timer (and this provider and | ||
| // the pre-save diagnostics snapshot it closes over) running past the teardown. | ||
| private readonly postSaveTails = new Set<AbortController>() | ||
| // Latched once Task disposal has cancelled the tails. A save that was already awaiting | ||
| // its file operations can still reach the tail start after that point, and a tail that | ||
| // begins after disposal would emit into a dead task and keep its providers alive. | ||
| private tailsDisposed = false | ||
| // Tracks whether the user activated the target file's editor tab during the | ||
| // diff session. When the file was not already open before the edit, we only | ||
| // keep it open afterward if the user explicitly interacted with it. | ||
|
|
@@ -1152,8 +1160,11 @@ | |
| }> { | ||
| const absolutePath = path.resolve(this.cwd, relPath) | ||
|
|
||
| // Get diagnostics before editing the file | ||
| this.preDiagnostics = vscode.languages.getDiagnostics() | ||
| // Get diagnostics before editing the file. Capture the snapshot locally: | ||
| // overlapping saveDirectly calls (multi-file edits) must not let a later | ||
| // call overwrite this one's baseline before its diagnostics tail runs. | ||
| const preDiagnostics = vscode.languages.getDiagnostics() | ||
| this.preDiagnostics = preDiagnostics | ||
|
|
||
| // Write the content directly to the file | ||
| await createDirectoriesForFile(absolutePath) | ||
|
|
@@ -1176,52 +1187,155 @@ | |
| await doc.save() | ||
| } | ||
|
|
||
| // Force a small delay to ensure diagnostics are triggered | ||
| await new Promise((resolve) => setTimeout(resolve, 100)) | ||
| // The 100 ms diagnostics-settle wait is carried by the | ||
| // emitPostSaveDiagnostics tail (inMemoryDocument) instead of here: | ||
| // blocking the save path delayed every openFile=false save even when | ||
| // diagnostics were disabled or the write delay was 0. | ||
| } | ||
|
|
||
| let newProblemsMessage = "" | ||
|
|
||
| // L1 (A2): resolve without awaiting the LSP diagnostics settle. The | ||
| // diagnostics check becomes a fire-and-forget tail that emits any new | ||
| // problems via the existing "error" ClineSay type; the returned | ||
| // newProblemsMessage is therefore always undefined. | ||
| if (diagnosticsEnabled) { | ||
| // Add configurable delay to allow linters time to process | ||
| const safeDelayMs = Math.max(0, writeDelayMs) | ||
| // The method's outer try/catch guarantees it never rejects, so the | ||
| // fire-and-forget call needs no .catch wrapper. | ||
| void this.emitPostSaveDiagnostics(relPath, writeDelayMs, preDiagnostics, !openFile) | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
|
||
|
|
||
| // Store the results for formatFileWriteResponse | ||
| this.newProblemsMessage = undefined | ||
|
easonLiangWorldedtech marked this conversation as resolved.
Resolved
|
||
| this.userEdits = undefined | ||
| this.relPath = relPath | ||
| this.newContent = content | ||
|
|
||
| return { | ||
| newProblemsMessage: undefined, | ||
| userEdits: undefined, | ||
| finalContent: content, | ||
| } | ||
| } | ||
|
|
||
| // L1 (A2): fire-and-forget post-save diagnostics. After the write delay, | ||
| // collects new Error-severity problems and emits them via the existing | ||
| // "error" ClineSay type (only Error-severity diagnostics reach this branch; | ||
| // "error" carries no task-failure semantics in core). Abort-safe: say() | ||
| // rejects when the task is aborted, so the whole body sits inside a | ||
| // try/catch that degrades to a console.warn — the tail can never reject. | ||
| // The wait itself is registered in postSaveTails so Task disposal can cancel | ||
| // it: an unregistered delay keeps the timer, this provider and the pre-save | ||
| // diagnostics snapshot alive past disposal, and the tail then does stale | ||
| // diagnostics work against a task that is already gone. | ||
| private async emitPostSaveDiagnostics( | ||
| relPath: string, | ||
| writeDelayMs: number, | ||
| preDiagnostics: [vscode.Uri, vscode.Diagnostic[]][], | ||
| inMemoryDocument = false, | ||
|
Check warning on line 1233 in src/integrations/editor/DiffViewProvider.ts
|
||
| ): Promise<void> { | ||
| if (this.tailsDisposed) { | ||
| // Disposal already ran: emitting now would call say() on a disposed task. | ||
| return | ||
| } | ||
|
|
||
| const controller = new AbortController() | ||
| this.postSaveTails.add(controller) | ||
| try { | ||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
|
||
| // Add configurable delay to allow linters time to process. When the | ||
| // document was opened in memory (openFile=false), the tail also | ||
| // carries the 100 ms diagnostics-settle wait that used to block | ||
| // saveDirectly. The signal is the disposal hook: delay() rejects with | ||
| // AbortError once the task is gone, which is the tail's expected end. | ||
| const safeDelayMs = Math.max(0, writeDelayMs) + (inMemoryDocument ? 100 : 0) | ||
| try { | ||
| await delay(safeDelayMs) | ||
| await delay(safeDelayMs, { signal: controller.signal }) | ||
| } catch (error) { | ||
| console.warn(`Failed to apply write delay: ${error}`) | ||
| if (controller.signal.aborted) { | ||
|
Check warning on line 1252 in src/integrations/editor/DiffViewProvider.ts
|
||
|
easonLiangWorldedtech marked this conversation as resolved.
Resolved
|
||
| return | ||
| } | ||
| throw error | ||
| } | ||
| // A cancellation can also land between the wait resolving and the work | ||
| // starting; either way nothing is queried or emitted after it. | ||
| if (controller.signal.aborted) { | ||
|
Check warning on line 1259 in src/integrations/editor/DiffViewProvider.ts
|
||
| return | ||
| } | ||
|
|
||
| const postDiagnostics = vscode.languages.getDiagnostics() | ||
| // Filter to the saved file: saveDirectly resolves before this tail | ||
| // completes, so in a multi-file write sequence (e.g. apply_patch) | ||
| // a later file's problems must not be attributed to this relPath. | ||
| const savedFilePath = path.resolve(this.cwd, relPath) | ||
| // arePathsEqual: case-insensitive on Windows, where a relPath whose | ||
| // casing differs from the diagnostic URI is still the same file. | ||
| const postDiagnostics = vscode.languages | ||
|
Check warning on line 1269 in src/integrations/editor/DiffViewProvider.ts
|
||
| .getDiagnostics() | ||
| .filter(([uri]) => arePathsEqual(uri.fsPath, savedFilePath)) | ||
|
Comment on lines
+1269
to
+1271
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| // Get diagnostic settings from state | ||
| const task = this.taskRef.deref() | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| const state = await task?.providerRef.deref()?.getState() | ||
| const includeDiagnosticMessages = state?.includeDiagnosticMessages ?? true | ||
| const maxDiagnosticMessages = state?.maxDiagnosticMessages ?? 50 | ||
|
|
||
| // The settings read is an await too, so disposal can land inside it and the | ||
| // await still returns normally. Stopping here keeps a gone task from paying for | ||
| // the formatting pass before the emit guard below. | ||
| if (controller.signal.aborted) { | ||
|
Check warning on line 1282 in src/integrations/editor/DiffViewProvider.ts
|
||
| return | ||
| } | ||
|
Comment on lines
+1282
to
+1284
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| const newProblems = await diagnosticsToProblemsString( | ||
| getNewDiagnostics(this.preDiagnostics, postDiagnostics), | ||
| getNewDiagnostics(preDiagnostics, postDiagnostics), | ||
|
easonLiangWorldedtech marked this conversation as resolved.
Resolved
|
||
| [vscode.DiagnosticSeverity.Error], | ||
| this.cwd, | ||
| includeDiagnosticMessages, | ||
| maxDiagnosticMessages, | ||
| ) | ||
|
|
||
| newProblemsMessage = | ||
| newProblems.length > 0 ? `\n\nNew problems detected after saving the file:\n${newProblems}` : "" | ||
| } | ||
| // Formatting is awaited too, so a cancellation can land inside it. The emit below | ||
| // persists an error row into the task, so it must not start once the caller is gone: | ||
| // say() would otherwise be dropped or land in a task the user already left. | ||
| if (controller.signal.aborted) { | ||
|
Check warning on line 1297 in src/integrations/editor/DiffViewProvider.ts
|
||
| return | ||
| } | ||
|
|
||
| // Store the results for formatFileWriteResponse | ||
| this.newProblemsMessage = newProblemsMessage | ||
| this.userEdits = undefined | ||
| this.relPath = relPath | ||
| this.newContent = content | ||
| if (newProblems.length > 0) { | ||
| // This tail runs writeDelayMs after saveDirectly resolved, so the task has usually | ||
| // moved on and may be sitting in ask() waiting for the user - the next tool's | ||
| // approval, completion_result. say() bumps lastMessageTs unless the message is | ||
| // non-interactive, and ask()'s pWaitFor reads a moved lastMessageTs as | ||
| // "superseded": the pending ask would throw AskIgnoredError even though the user | ||
| // never answered. These diagnostics are informational, so they must not be able to | ||
| // cancel an ask; the "error" channel and the text stay exactly as they were. | ||
| await task?.say( | ||
|
Check warning on line 1309 in src/integrations/editor/DiffViewProvider.ts
|
||
| "error", | ||
| `New problems detected after saving file: ${relPath}\n\n${newProblems}`, | ||
| undefined /* images */, | ||
| undefined /* partial */, | ||
| undefined /* checkpoint */, | ||
| undefined /* progressStatus */, | ||
| { isNonInteractive: true } /* options */, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| ) | ||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
Comment on lines
+1309
to
+1317
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
easonLiangWorldedtech marked this conversation as resolved.
Resolved
|
||
| } catch (error) { | ||
| // Abort-safe: never let a post-save diagnostic emit become an | ||
| // unhandled rejection (say() rejects when the task is aborted). | ||
| console.warn(`Post-save diagnostics emit failed: ${error}`) | ||
|
easonLiangWorldedtech marked this conversation as resolved.
Resolved
|
||
| } finally { | ||
| this.postSaveTails.delete(controller) | ||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
|
||
| } | ||
| } | ||
|
|
||
| return { | ||
| newProblemsMessage, | ||
| userEdits: undefined, | ||
| finalContent: content, | ||
| /** | ||
| * Cancel post-save diagnostics tails that are still waiting. Called when the | ||
| * owning task is disposed. Deliberately NOT called from reset(): reset follows a | ||
| * successful write, and the tail that write started still has to report the | ||
| * problems it is waiting for. | ||
| */ | ||
| public cancelPostSaveDiagnosticsTails(): void { | ||
| this.tailsDisposed = true | ||
| for (const controller of this.postSaveTails) { | ||
| controller.abort() | ||
| } | ||
| this.postSaveTails.clear() | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.