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
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. | ||
|
|
@@ -1151,8 +1159,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) | ||
|
|
@@ -1175,23 +1186,88 @@ | |
| 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.
|
||
|
|
||
| // Store the results for formatFileWriteResponse | ||
| this.newProblemsMessage = undefined | ||
|
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. With |
||
| 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 1232 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.
|
||
| // 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 1251 in src/integrations/editor/DiffViewProvider.ts
|
||
|
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.
|
||
| 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 1258 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 | ||
| .getDiagnostics() | ||
| .filter(([uri]) => arePathsEqual(uri.fsPath, savedFilePath)) | ||
|
|
||
| // Get diagnostic settings from state | ||
| const task = this.taskRef.deref() | ||
|
|
@@ -1200,27 +1276,58 @@ | |
| const maxDiagnosticMessages = state?.maxDiagnosticMessages ?? 50 | ||
|
|
||
| const newProblems = await diagnosticsToProblemsString( | ||
| getNewDiagnostics(this.preDiagnostics, postDiagnostics), | ||
| getNewDiagnostics(preDiagnostics, postDiagnostics), | ||
|
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. Every |
||
| [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) { | ||
| 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 1301 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 */, | ||
| ) | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
easonLiangWorldedtech marked this conversation as 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.
|
||
| } finally { | ||
| this.postSaveTails.delete(controller) | ||
|
Check warning on line 1316 in src/integrations/editor/DiffViewProvider.ts
|
||
| } | ||
| } | ||
|
|
||
| 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() | ||
|
Check warning on line 1331 in src/integrations/editor/DiffViewProvider.ts
|
||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.