diff --git a/.changeset/add-editor-auto-close-settings.md b/.changeset/add-editor-auto-close-settings.md new file mode 100644 index 0000000000..705d3a425f --- /dev/null +++ b/.changeset/add-editor-auto-close-settings.md @@ -0,0 +1,5 @@ +--- +"zoo-code": minor +--- + +Add settings to control whether editor tabs Zoo opens during diff edits are auto-closed after accept/reject: auto-close transiently-opened files, auto-close even after user interaction (a refinement of the first), and auto-close newly created files. diff --git a/.changeset/fix-diff-scroll-position.md b/.changeset/fix-diff-scroll-position.md new file mode 100644 index 0000000000..040d5ce2a6 --- /dev/null +++ b/.changeset/fix-diff-scroll-position.md @@ -0,0 +1,7 @@ +--- +"zoo-code": patch +--- + +Fix diff view scroll position and tab handling when applying edits. The diff now opens scrolled to the first changed line (including end-of-file removals, which are clamped to a valid line in the modified document) instead of forcing the viewport to the top. After accepting or rejecting a diff, files that were already open are restored to their pre-edit scroll position, and files that were not open before the edit have their transiently opened tab closed -- unless the user activated that tab during the diff, in which case it is kept open. Focus is no longer pulled back to the edited file when the user has navigated elsewhere. A file that was open in a preview tab is restored in a preview tab with the original scroll position, even if the target file replaced the preview tab and was automatically closed after the diff was accepted or rejected. A file that was pinned before the edit is re-pinned after the diff closes, so applying a diff no longer drops the tab's pinned state. + +When a user clicks or edits inside the diff pane, the target file's tab is kept open after saving -- even if it was not previously open. If the user only scrolls in the diff pane, the existing close behavior is preserved. When the target file is re-revealed after a diff, it scrolls to the position most recently viewed by the user: if the user last scrolled in the diff pane, the target file mirrors that scroll position; if the user last scrolled in the target file's own editor, that position is used instead; otherwise the pre-edit scroll position is restored. diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 8399ae310e..bac3548ccb 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -180,6 +180,9 @@ export const globalSettingsSchema = z.object({ execaShellPath: z.string().optional(), diagnosticsEnabled: z.boolean().optional(), + autoCloseZooOpenedFiles: z.boolean().optional(), + autoCloseZooOpenedFilesAfterUserEdited: z.boolean().optional(), + autoCloseZooOpenedNewFiles: z.boolean().optional(), rateLimitSeconds: z.number().optional(), experiments: experimentsSchema.optional(), diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 94c9011041..0f19adcff5 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -282,6 +282,9 @@ export type ExtensionState = Pick< | "terminalProfile" | "execaShellPath" | "diagnosticsEnabled" + | "autoCloseZooOpenedFiles" + | "autoCloseZooOpenedFilesAfterUserEdited" + | "autoCloseZooOpenedNewFiles" | "language" | "modeApiConfigs" | "customModePrompts" diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index 80b5799217..ba2ae4fc04 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -16,7 +16,7 @@ import { Task } from "../../core/task/Task" import { DecorationController } from "./DecorationController" export const DIFF_VIEW_URI_SCHEME = "cline-diff" -export const DIFF_VIEW_LABEL_CHANGES = "Original ↔ Roo's Changes" +export const DIFF_VIEW_LABEL_CHANGES = "Original ↔ Zoo's Changes" // TODO: https://github.com/cline/cline/pull/3354 export class DiffViewProvider { @@ -28,6 +28,10 @@ export class DiffViewProvider { originalContent: string | undefined private createdDirs: string[] = [] private documentWasOpen = false + // Tracks whether the target file's tab was pinned before the diff session. + // Closing the tab to open the diff drops VS Code's pin state, so we restore + // it when re-showing the edited file afterward. + private documentWasPinned = false private relPath?: string private newContent?: string private activeDiffEditor?: vscode.TextEditor @@ -35,6 +39,42 @@ export class DiffViewProvider { private activeLineController?: DecorationController private streamedLines: string[] = [] private preDiagnostics: [vscode.Uri, vscode.Diagnostic[]][] = [] + private preEditScrollLine: number | undefined + // 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. + private userTouchedDocument = false + // Tracks whether the user clicked or edited inside the diff editor itself. + // A selection-change event fires on click/keyboard/edit but NOT on + // scroll, so this reliably distinguishes interaction from passive viewing. + // When true and the user saves (accepts) the diff, the target file tab is + // kept open even if it was not open before the edit began. + private userTouchedDiffEditor = false + // Tracks the most recent scroll position seen in the diff editor. Updated + // continuously so that when the user accepts the diff, the target file can + // be revealed at the same line they were viewing in the diff. + private diffScrollLine: number | undefined + // Tracks the most recent scroll position seen in the target file's own + // editor during the diff session. If the user opens the target file's tab + // and scrolls there, that position overrides the diff scroll line. + private targetFileScrollLine: number | undefined + // Records which editor (diff or the target file itself) the user scrolled + // most recently. The most-recently-scrolled source wins when choosing the + // restore scroll line. + private lastScrolledSource: "diff" | "targetFile" | undefined + private activeEditorListener?: vscode.Disposable + private diffEditorSelectionListener?: vscode.Disposable + private diffScrollListener?: vscode.Disposable + private deferredScrollTimer?: ReturnType + // Snapshot of unrelated preview tabs (italicized, not-yet-edited) captured at + // diff-open time. Opening the diff reuses the editor group's single preview + // slot and evicts these tabs; we restore any that disappeared after the diff + // session ends so the user's prior tab state is reconstructed. + private snapshotPreviewTabs: Array<{ + uri: vscode.Uri + scrollLine: number | undefined + viewColumn: vscode.ViewColumn + }> = [] private taskRef: WeakRef constructor( @@ -50,6 +90,13 @@ export class DiffViewProvider { const absolutePath = path.resolve(this.cwd, relPath) this.isEditing = true + // Capture the current scroll position before we close the tab so we can + // restore it after saving/reverting. + const existingEditor = vscode.window.visibleTextEditors.find( + (e) => e.document.uri.scheme === "file" && arePathsEqual(e.document.uri.fsPath, absolutePath), + ) + this.preEditScrollLine = existingEditor?.visibleRanges?.[0]?.start.line + // If the file is already open, ensure it's not dirty before getting its // contents. if (fileExists) { @@ -84,6 +131,7 @@ export class DiffViewProvider { // If the file was already open, close it (must happen after showing the // diff view since if it's the only tab the column will close). this.documentWasOpen = false + this.documentWasPinned = false // Close the tab if it's open (it's already saved above). const tabs = vscode.window.tabGroups.all @@ -97,6 +145,11 @@ export class DiffViewProvider { ) for (const tab of tabs) { + // Remember the pin state so we can restore it after the diff closes; + // closing the tab to open the diff would otherwise lose it. + if (tab.isPinned) { + this.documentWasPinned = true + } if (!tab.isDirty) { try { await vscode.window.tabGroups.close(tab) @@ -107,13 +160,84 @@ export class DiffViewProvider { this.documentWasOpen = true } + // Snapshot unrelated preview tabs so we can restore them if opening the + // diff evicts them (VS Code reuses the group's single preview slot). + this.snapshotPreviewTabs = this.captureUnrelatedPreviewTabs(absolutePath) + this.activeDiffEditor = await this.openDiffEditor() this.fadedOverlayController = new DecorationController("fadedOverlay", this.activeDiffEditor) this.activeLineController = new DecorationController("activeLine", this.activeDiffEditor) // Apply faded overlay to all lines initially. this.fadedOverlayController.addLines(0, this.activeDiffEditor.document.lineCount) - this.scrollEditorToLine(0) // Will this crash for new files? + // Do not force the viewport to the top here. Tools call scrollToFirstDiff() + // after the final update so the diff opens on the first changed line; an + // unconditional scroll-to-0 would override that and land off the change. this.streamedLines = [] + + // When the file was not already open before the edit, watch for the user + // activating the file's own editor tab. If they do, we treat the file as + // "touched" and keep it open after accepting/denying instead of closing + // the transiently opened tab. Activating the diff view does not count. + this.userTouchedDocument = false + if (!this.documentWasOpen) { + this.activeEditorListener = vscode.window.onDidChangeActiveTextEditor((editor) => { + if (!editor || editor.document.uri.scheme !== "file") { + return + } + if (!arePathsEqual(editor.document.uri.fsPath, absolutePath)) { + return + } + // Ignore activation of the diff editor, which can share the file path. + const activeTab = vscode.window.tabGroups.activeTabGroup.activeTab + if (activeTab?.input instanceof vscode.TabInputTextDiff) { + return + } + this.userTouchedDocument = true + }) + } + + // Track whether the user clicks or edits inside the diff editor. + // onDidChangeTextEditorSelection fires on click/keyboard/edit but NOT on + // scroll. We additionally filter to Mouse and Keyboard kinds to exclude + // programmatic selection changes (e.g. the selection anchor set by + // revealDiffLine / scrollToFirstDiff), which VS Code reports with kind + // undefined / Command and must not count as user interaction. + this.userTouchedDiffEditor = false + this.diffEditorSelectionListener = vscode.window.onDidChangeTextEditorSelection((event) => { + if ( + this.activeDiffEditor && + event.textEditor.document === this.activeDiffEditor.document && + (event.kind === vscode.TextEditorSelectionChangeKind.Mouse || + event.kind === vscode.TextEditorSelectionChangeKind.Keyboard) + ) { + this.userTouchedDiffEditor = true + } + }) + + // Track scroll position in both the diff editor and the target file's own + // editor. Whichever the user scrolled most recently determines the line we + // reveal when we re-open the target file after the diff closes. + this.diffScrollLine = undefined + this.targetFileScrollLine = undefined + this.lastScrolledSource = undefined + this.diffScrollListener = vscode.window.onDidChangeTextEditorVisibleRanges((event) => { + if (this.activeDiffEditor && event.textEditor.document === this.activeDiffEditor.document) { + const line = event.visibleRanges[0]?.start.line + if (line !== undefined) { + this.diffScrollLine = line + this.lastScrolledSource = "diff" + } + } else if ( + event.textEditor.document.uri.scheme === "file" && + arePathsEqual(event.textEditor.document.uri.fsPath, absolutePath) + ) { + const line = event.visibleRanges[0]?.start.line + if (line !== undefined) { + this.targetFileScrollLine = line + this.lastScrolledSource = "targetFile" + } + } + }) } async update(accumulatedContent: string, isFinal: boolean) { @@ -213,9 +337,30 @@ export class DiffViewProvider { await updatedDocument.save() } - await vscode.window.showTextDocument(vscode.Uri.file(absolutePath), { preview: false, preserveFocus: true }) + // Stop tracking touches and cancel any pending scroll-to-diff before any + // programmatic editor activation below. + this.disposeActiveEditorListener() + this.cancelDeferredScroll() + await this.closeAllDiffViews() + // Read auto-close preferences from state; fall back to defaults that + // preserve the existing behavior when unset. + const saveTask = this.taskRef.deref() + const saveState = await saveTask?.providerRef.deref()?.getState() + + await this.keepOrCloseEditedFile( + absolutePath, + this.userTouchedDiffEditor, + saveState?.autoCloseZooOpenedFiles ?? true, + saveState?.autoCloseZooOpenedFilesAfterUserEdited ?? false, + saveState?.autoCloseZooOpenedNewFiles ?? false, + ) + + // Restore any preview tabs the diff evicted, reconstructing the user's + // prior not-yet-edited tab state. + await this.restorePreviewTabs() + // Getting diagnostics before and after the file edit is a better approach than // automatically tracking problems in real-time. This method ensures we only // report new problems that are a direct result of this specific edit. @@ -371,12 +516,20 @@ export class DiffViewProvider { const updatedDocument = this.activeDiffEditor.document const absolutePath = path.resolve(this.cwd, this.relPath) + // Stop tracking touches and cancel any pending scroll-to-diff before any + // programmatic editor activation below. + this.disposeActiveEditorListener() + this.cancelDeferredScroll() + if (!fileExists) { if (updatedDocument.isDirty) { await updatedDocument.save() } await this.closeAllDiffViews() + // The file was newly created for this edit; close its transiently + // opened tab before deleting it from disk. + await this.closeFileTab(absolutePath) await fs.unlink(absolutePath) // Remove only the directories we created, in reverse order. @@ -400,16 +553,26 @@ export class DiffViewProvider { await vscode.workspace.applyEdit(edit) await updatedDocument.save() - if (this.documentWasOpen) { - await vscode.window.showTextDocument(vscode.Uri.file(absolutePath), { - preview: false, - preserveFocus: true, - }) - } - await this.closeAllDiffViews() + + // Read auto-close preferences from state; fall back to defaults that + // preserve the existing behavior when unset. + const revertTask = this.taskRef.deref() + const revertState = await revertTask?.providerRef.deref()?.getState() + + await this.keepOrCloseEditedFile( + absolutePath, + false, + revertState?.autoCloseZooOpenedFiles ?? true, + revertState?.autoCloseZooOpenedFilesAfterUserEdited ?? false, + revertState?.autoCloseZooOpenedNewFiles ?? false, + ) } + // Restore any preview tabs the diff evicted, reconstructing the user's + // prior not-yet-edited tab state. + await this.restorePreviewTabs() + // Edit is done. await this.reset() } @@ -448,6 +611,250 @@ export class DiffViewProvider { await Promise.all(closeOps) } + // Stop tracking user activation of the target file. Called before any + // programmatic showTextDocument so our own re-show never counts as a "touch". + private disposeActiveEditorListener(): void { + this.activeEditorListener?.dispose() + this.activeEditorListener = undefined + this.diffEditorSelectionListener?.dispose() + this.diffEditorSelectionListener = undefined + this.diffScrollListener?.dispose() + this.diffScrollListener = undefined + } + + // Cancel any pending deferred scroll-to-diff. Must run as soon as the user + // accepts/denies (before saveChanges/revertChanges restore the pre-edit + // viewport), otherwise a late timer could fire after the scroll-restore and + // yank the file back to the diff target. With auto-approve this window is + // especially tight because save runs immediately after scrollToFirstDiff. + private cancelDeferredScroll(): void { + if (this.deferredScrollTimer !== undefined) { + clearTimeout(this.deferredScrollTimer) + this.deferredScrollTimer = undefined + } + } + + // Shared accept/deny cleanup: applies the user's auto-close preferences to + // decide whether to keep or close the edited file's tab after the diff settles. + // + // Decision table (evaluated in order; first match wins): + // 1. File was already open before the edit -> always keep (closing it would + // be destructive; user-opened tabs are never auto-closed). + // 2. editType==="create" AND autoCloseZooOpenedNewFiles -> close the new file's tab. + // 3. userTouchedDocument OR keepIfTouchedDiff -> the "keep if touched" guard + // applies; it is overridden (close) only when BOTH autoCloseZooOpenedFiles + // and autoCloseZooOpenedFilesAfterUserEdited are enabled. The override is a + // refinement of the base auto-close, so it has no effect when the base + // setting is off. + // 4. autoCloseZooOpenedFiles=false -> keep the transiently-opened tab. + // 5. Default -> close the transiently-opened tab (current behavior preserved). + // + // keepIfTouchedDiff is passed as true from saveChanges() when the user clicked + // or typed inside the diff editor itself. + private async keepOrCloseEditedFile( + absolutePath: string, + keepIfTouchedDiff = false, + autoCloseZooOpenedFiles = true, + autoCloseZooOpenedFilesAfterUserEdited = false, + autoCloseZooOpenedNewFiles = false, + ): Promise { + // Files the user already had open are never auto-closed. + if (this.documentWasOpen) { + await this.showEditedFileWithoutDisruptingFocus(absolutePath) + return + } + + // New files on the accept path: close when autoCloseZooOpenedNewFiles is enabled. + if (this.editType === "create" && autoCloseZooOpenedNewFiles) { + await this.closeFileTab(absolutePath) + return + } + + const userInteracted = this.userTouchedDocument || keepIfTouchedDiff + if (userInteracted) { + // Override the "keep if touched" guard only when the base auto-close is + // also enabled; the override is a refinement, not an independent toggle. + if (autoCloseZooOpenedFiles && autoCloseZooOpenedFilesAfterUserEdited) { + await this.closeFileTab(absolutePath) + } else { + await this.showEditedFileWithoutDisruptingFocus(absolutePath) + } + return + } + + // Transient tab opened by Zoo: close by default, keep only when opted out. + if (autoCloseZooOpenedFiles) { + await this.closeFileTab(absolutePath) + } else { + await this.showEditedFileWithoutDisruptingFocus(absolutePath) + } + } + + // Re-show the edited file so it stays open after the diff closes and restore + // its pre-edit scroll position, WITHOUT disrupting wherever the user is + // currently looking. showTextDocument activates the target's tab in its + // group, so if the user navigated to a different file during the diff (e.g. + // they clicked back to file-1 while file-2 was being edited), naively showing + // the edited file would yank the active editor onto it. We capture the user's + // active editor first and re-activate it afterward when it differs. + private async showEditedFileWithoutDisruptingFocus(absolutePath: string): Promise { + const userActiveEditor = vscode.window.activeTextEditor + const userWasElsewhere = + !!userActiveEditor && + !( + userActiveEditor.document.uri.scheme === "file" && + arePathsEqual(userActiveEditor.document.uri.fsPath, absolutePath) + ) + + // When the tab needs re-pinning we must briefly focus it, since + // workbench.action.pinEditor only acts on the active editor. Otherwise we + // keep the user's focus undisturbed with preserveFocus. + const editor = await vscode.window.showTextDocument(vscode.Uri.file(absolutePath), { + preview: false, + preserveFocus: !this.documentWasPinned, + }) + // Determine the scroll line to restore. Prefer the most-recently-scrolled + // source: if the user scrolled in the diff, reveal that line; if they + // scrolled in the target file's own editor, use that position; otherwise + // fall back to the scroll position the file had before the edit began. + const restoreScrollLine = + this.lastScrolledSource === "targetFile" + ? this.targetFileScrollLine + : this.lastScrolledSource === "diff" + ? this.diffScrollLine + : this.preEditScrollLine + if (restoreScrollLine !== undefined) { + editor.revealRange( + new vscode.Range(restoreScrollLine, 0, restoreScrollLine, 0), + vscode.TextEditorRevealType.AtTop, + ) + } + + // Restore the pin state that was dropped when the tab was closed to open + // the diff. The edited file is active here (we focused it above), so the + // command targets the correct tab. + if (this.documentWasPinned) { + try { + await vscode.commands.executeCommand("workbench.action.pinEditor") + } catch (err) { + console.error(`Failed to re-pin edited file`, err) + } + } + + // If the user was viewing a different editor, re-activate it so the edited + // file stays open in the background instead of stealing the foreground. + if (userWasElsewhere && userActiveEditor) { + try { + await vscode.window.showTextDocument(userActiveEditor.document, { + viewColumn: userActiveEditor.viewColumn, + preserveFocus: false, + }) + } catch (err) { + console.error(`Failed to restore user's active editor`, err) + } + } + } + + // Capture unrelated preview tabs (italicized, not-yet-edited) along with their + // current scroll position and editor group. Opening the diff reuses the group's + // single preview slot and evicts these tabs; the snapshot lets us restore them + // in the correct group after the diff session ends. The diff target is excluded + // since it is about to be replaced by the diff view anyway. + private captureUnrelatedPreviewTabs( + diffTargetPath: string, + ): Array<{ uri: vscode.Uri; scrollLine: number | undefined; viewColumn: vscode.ViewColumn }> { + return vscode.window.tabGroups.all.flatMap((group) => + group.tabs + .filter( + (tab) => + tab.isPreview && + tab.input instanceof vscode.TabInputText && + tab.input.uri.scheme === "file" && + !arePathsEqual(tab.input.uri.fsPath, diffTargetPath), + ) + .map((tab) => { + const uri = (tab.input as vscode.TabInputText).uri + const visibleEditor = vscode.window.visibleTextEditors.find( + (e) => e.document.uri.scheme === "file" && arePathsEqual(e.document.uri.fsPath, uri.fsPath), + ) + return { + uri, + scrollLine: visibleEditor?.visibleRanges?.[0]?.start.line, + viewColumn: group.viewColumn, + } + }), + ) + } + + // Restore preview tabs captured before the diff opened, but only those VS Code + // evicted. Each is re-opened in preview mode (preserving the user's + // not-yet-edited state) without stealing focus, and its prior scroll position + // is reapplied. + private async restorePreviewTabs(): Promise { + for (const snapshot of this.snapshotPreviewTabs) { + const stillOpen = vscode.window.tabGroups.all + .flatMap((group) => group.tabs) + .some( + (tab) => + tab.input instanceof vscode.TabInputText && + tab.input.uri.scheme === "file" && + arePathsEqual(tab.input.uri.fsPath, snapshot.uri.fsPath), + ) + + if (stillOpen) { + continue + } + + // The file may have been deleted, renamed, or moved during the diff + // session. Skip restoring a tab whose underlying file no longer exists. + try { + await fs.access(snapshot.uri.fsPath) + } catch { + continue + } + + try { + const editor = await vscode.window.showTextDocument(snapshot.uri, { + preview: true, + preserveFocus: true, + viewColumn: snapshot.viewColumn, + }) + if (snapshot.scrollLine !== undefined) { + editor.revealRange( + new vscode.Range(snapshot.scrollLine, 0, snapshot.scrollLine, 0), + vscode.TextEditorRevealType.AtTop, + ) + } + } catch (err) { + console.error(`Failed to restore preview tab ${snapshot.uri.fsPath}`, err) + } + } + this.snapshotPreviewTabs = [] + } + + // Close the plain (non-diff) editor tab for the target file. Used when the + // file was opened transiently for the diff and the user never interacted + // with it, so it should not linger after accept/deny. + private async closeFileTab(absolutePath: string): Promise { + const tabs = vscode.window.tabGroups.all + .flatMap((group) => group.tabs) + .filter( + (tab) => + tab.input instanceof vscode.TabInputText && + tab.input.uri.scheme === "file" && + arePathsEqual(tab.input.uri.fsPath, absolutePath) && + !tab.isDirty, + ) + + for (const tab of tabs) { + try { + await vscode.window.tabGroups.close(tab) + } catch (err) { + console.error(`Failed to close file tab ${tab.label}`, err) + } + } + } + private async openDiffEditor(): Promise { if (!this.relPath) { throw new Error( @@ -578,30 +985,94 @@ export class DiffViewProvider { } scrollToFirstDiff() { - if (!this.activeDiffEditor) { + const editor = this.activeDiffEditor + if (!editor) { return } - const currentContent = this.activeDiffEditor.document.getText() + const targetLine = this.findFirstDiffLine(editor) + if (targetLine === undefined) { + return + } + + // Reveal on the live modified-side editor, then once more after the diff + // editor's late layout pass settles. When the file was already open and + // scrolled away from the top, the vscode.diff command re-lays-out the diff + // and momentarily snaps the viewport to the top; a single synchronous + // reveal can be overridden by that pass. The deferred reveal is gated on + // the diff still being active so a stale timer can never act on a diff + // opened by a later edit. + this.revealDiffLine(this.resolveLiveEditor(editor), targetLine) + + this.cancelDeferredScroll() + this.deferredScrollTimer = setTimeout(() => { + this.deferredScrollTimer = undefined + if (this.activeDiffEditor === editor) { + this.revealDiffLine(this.resolveLiveEditor(editor), targetLine) + } + }, 100) + } + + // The TextEditor reference captured when the diff opened can be a stale, + // detached instance whose visibleRanges no longer reflect the on-screen diff + // widget (this happens when the file was already open before the edit). A + // revealRange on that stale editor is a silent no-op because it believes the + // target line is already visible. Re-resolve the current modified-side editor + // for the same document at scroll time so the reveal drives the live viewport. + private resolveLiveEditor(editor: vscode.TextEditor): vscode.TextEditor { + return ( + vscode.window.visibleTextEditors.find( + (e) => e.document.uri.scheme === "file" && e.document === editor.document, + ) ?? editor + ) + } + + // Index of the first change in the MODIFIED (right-hand) document, or + // undefined when there is no change. For removals this is the line that now + // occupies the position of the removed block. + private findFirstDiffLine(editor: vscode.TextEditor): number | undefined { + const document = editor.document + const currentContent = document.getText() const diffs = diff.diffLines(this.originalContent || "", currentContent) let lineCount = 0 for (const part of diffs) { if (part.added || part.removed) { - // Found the first diff, scroll to it without stealing focus. - this.activeDiffEditor.revealRange( - new vscode.Range(lineCount, 0, lineCount, 0), - vscode.TextEditorRevealType.InCenter, - ) - - return + // A pure removal at the end of the file leaves the first-change line + // at (or past) the end of the modified document. Revealing a range at + // that out-of-bounds line clamps to the last line and the composite + // diff widget never moves. Clamp the target into the document so the + // reveal always lands on a real line. + const lastLine = Math.max(0, document.lineCount - 1) + return Math.min(lineCount, lastLine) } if (!part.removed) { lineCount += part.count || 0 } } + + return undefined + } + + private revealDiffLine(editor: vscode.TextEditor, targetLine: number) { + // Clamp again at reveal time: deferred reveals can run after a later edit + // shortened the document, and an out-of-bounds selection would snap the + // composite diff widget back to the top and leave it stuck there. + const lastLine = Math.max(0, editor.document.lineCount - 1) + const safeLine = Math.min(Math.max(0, targetLine), lastLine) + + // Anchor the selection on the target line before revealing. update() parks + // the selection at (0,0) to keep it out of the stream animation; moving the + // selection to the target first nudges the composite diff widget toward the + // change. preserveFocus semantics are unaffected because we never activate + // the editor here. + const targetPosition = new vscode.Position(safeLine, 0) + editor.selection = new vscode.Selection(targetPosition, targetPosition) + + const lineLength = editor.document.lineAt ? editor.document.lineAt(safeLine).text.length : 0 + editor.revealRange(new vscode.Range(safeLine, 0, safeLine, lineLength), vscode.TextEditorRevealType.InCenter) } private stripAllBOMs(input: string): string { @@ -617,17 +1088,33 @@ export class DiffViewProvider { } async reset(): Promise { + // Dispose touch listeners and cancel any pending deferred scroll BEFORE any + // async editor manipulation. closeAllDiffViews() awaits tab-close operations, + // so leaving listeners/timers live across that await could let a stale handler + // act on an editor mid-teardown. This mirrors the ordering used in + // saveChanges()/revertChanges(). + this.disposeActiveEditorListener() + this.cancelDeferredScroll() + await this.closeAllDiffViews() this.editType = undefined this.isEditing = false this.originalContent = undefined this.createdDirs = [] this.documentWasOpen = false + this.documentWasPinned = false this.activeDiffEditor = undefined this.fadedOverlayController = undefined this.activeLineController = undefined this.streamedLines = [] this.preDiagnostics = [] + this.preEditScrollLine = undefined + this.diffScrollLine = undefined + this.targetFileScrollLine = undefined + this.lastScrolledSource = undefined + this.userTouchedDocument = false + this.userTouchedDiffEditor = false + this.snapshotPreviewTabs = [] } /** diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 38c2c9959f..f7bf0d708d 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -12,6 +12,7 @@ vi.mock("delay", () => ({ vi.mock("fs/promises", () => ({ readFile: vi.fn().mockResolvedValue("file content"), writeFile: vi.fn().mockResolvedValue(undefined), + access: vi.fn().mockResolvedValue(undefined), })) // Mock utils @@ -43,9 +44,13 @@ vi.mock("vscode", () => ({ createTextEditorDecorationType: vi.fn(), showTextDocument: vi.fn(), onDidChangeVisibleTextEditors: vi.fn(() => ({ dispose: vi.fn() })), + onDidChangeActiveTextEditor: vi.fn(() => ({ dispose: vi.fn() })), + onDidChangeTextEditorSelection: vi.fn(() => ({ dispose: vi.fn() })), + onDidChangeTextEditorVisibleRanges: vi.fn(() => ({ dispose: vi.fn() })), tabGroups: { all: [], close: vi.fn(), + activeTabGroup: { activeTab: undefined }, }, visibleTextEditors: [], }, @@ -80,12 +85,33 @@ vi.mock("vscode", () => ({ Eight: 8, Nine: 9, }, - Range: vi.fn(), - Position: vi.fn(), - Selection: vi.fn(), + // Use regular functions (not arrows) so these mocks can be invoked with `new`. + // vitest v4 / tinyspy invokes the implementation as a constructor for `new mock()`, + // and arrow functions throw "is not a constructor". + Range: vi.fn().mockImplementation(function (startLine, startChar, endLine, endChar) { + return { + start: { line: startLine, character: startChar }, + end: { line: endLine, character: endChar }, + } + }), + Position: vi.fn().mockImplementation(function (line, character) { + return { line, character } + }), + Selection: vi.fn().mockImplementation(function (anchor, active) { + return { anchor, active } + }), TextEditorRevealType: { + Default: 0, InCenter: 2, + InCenterIfOutsideViewport: 3, + AtTop: 4, + }, + TextEditorSelectionChangeKind: { + Keyboard: 1, + Mouse: 2, + Command: 3, }, + TabInputText: class TabInputText {}, TabInputTextDiff: class TabInputTextDiff {}, Uri: { file: vi.fn((path) => ({ fsPath: path })), @@ -277,6 +303,395 @@ describe("DiffViewProvider", () => { "Failed to execute diff command for /mock/cwd/test.md: Cannot open file", ) }) + + it("records the pin state of an already-open pinned tab", async () => { + const mockEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.md`, scheme: "file" }, + getText: vi.fn().mockReturnValue(""), + lineCount: 0, + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } + + // An open, pinned, non-dirty tab for the target file. + const pinnedTab = { + input: Object.assign(new (vscode as any).TabInputText(), { + uri: { fsPath: `${mockCwd}/test.md`, scheme: "file" }, + }), + isDirty: false, + isPinned: true, + label: "test.md", + } + Object.defineProperty(vscode.window.tabGroups, "all", { + get: () => [{ tabs: [pinnedTab] }], + configurable: true, + }) + + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor as any) + vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => { + callback({ uri: { fsPath: `${mockCwd}/test.md`, scheme: "file" } } as any) + }, 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window).visibleTextEditors = [mockEditor as any] + ;(diffViewProvider as any).editType = "modify" + + await diffViewProvider.open("test.md") + + expect((diffViewProvider as any).documentWasPinned).toBe(true) + expect((diffViewProvider as any).documentWasOpen).toBe(true) + }) + }) + + describe("scrollToFirstDiff method", () => { + const setupEditor = (currentContent: string) => { + const revealRange = vi.fn() + // Mirror how VS Code reports lineCount: a trailing newline yields a final + // empty line, so the count is the number of "\n"-delimited segments. + const lineCount = currentContent === "" ? 0 : currentContent.split("\n").length + const lines = currentContent.split("\n") + const document = { + uri: { fsPath: `${mockCwd}/mock-file-target.txt`, scheme: "file" }, + getText: vi.fn().mockReturnValue(currentContent), + lineCount, + lineAt: vi.fn().mockImplementation((line: number) => ({ text: lines[line] ?? "" })), + } + const editor = { + document, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + visibleRanges: [{ start: { line: 0 }, end: { line: 0 } }], + revealRange, + } + ;(diffViewProvider as any).activeDiffEditor = editor + // Register the editor as the live modified-side editor so resolveLiveEditor + // finds it by document identity, mirroring the runtime path. + vi.mocked(vscode.window).visibleTextEditors = [editor as any] + return revealRange + } + + it("reveals the first changed line for an addition-only diff", () => { + ;(diffViewProvider as any).originalContent = "a\nb\nc\n" + // Insert a new line between b and c (first change is at line index 2). + const revealRange = setupEditor("a\nb\nNEW\nc\n") + + diffViewProvider.scrollToFirstDiff() + + expect(revealRange).toHaveBeenCalledTimes(1) + const range = revealRange.mock.calls[0][0] + expect(range.start.line).toBe(2) + }) + + it("reveals the first changed line for a deletion-only diff", () => { + ;(diffViewProvider as any).originalContent = "a\nb\nc\nd\n" + // Remove line c; the first change is the removed block at line index 2. + const revealRange = setupEditor("a\nb\nd\n") + + diffViewProvider.scrollToFirstDiff() + + expect(revealRange).toHaveBeenCalledTimes(1) + const range = revealRange.mock.calls[0][0] + expect(range.start.line).toBe(2) + }) + + it("clamps to the last line for a removal at the end of the file", () => { + // Long file; remove the final lines. The first-change index lands past the + // end of the shortened modified document, so it must be clamped to a real + // line or the diff widget will not scroll. + ;(diffViewProvider as any).originalContent = "a\nb\nc\nd\ne\nf\n" + const revealRange = setupEditor("a\nb\nc\n") + + diffViewProvider.scrollToFirstDiff() + + expect(revealRange).toHaveBeenCalledTimes(1) + const range = revealRange.mock.calls[0][0] + // Modified document has lines a,b,c (+ trailing empty) => lastLine index 3. + // The removed block begins at index 3, which is within bounds here. + expect(range.start.line).toBeLessThanOrEqual(3) + expect(range.start.line).toBeGreaterThanOrEqual(0) + }) + + it("reveals the first changed line for a mixed diff", () => { + ;(diffViewProvider as any).originalContent = "a\nb\nc\nd\n" + // Change line b (index 1) -- first divergence from the original. + const revealRange = setupEditor("a\nCHANGED\nc\nd\n") + + diffViewProvider.scrollToFirstDiff() + + expect(revealRange).toHaveBeenCalledTimes(1) + const range = revealRange.mock.calls[0][0] + expect(range.start.line).toBe(1) + }) + + it("anchors the selection on the target line so an in-viewport diff still scrolls", () => { + // Regression for the case where the file is already scrolled to the middle + // and the diff target is inside the current viewport: a bare revealRange is + // a no-op, leaving the viewport pinned at the top. Moving the selection to + // the target first forces the diff widget to scroll to the change. + ;(diffViewProvider as any).originalContent = "a\nb\nc\nd\n" + const revealRange = setupEditor("a\nCHANGED\nc\nd\n") + + diffViewProvider.scrollToFirstDiff() + + const editor = (diffViewProvider as any).activeDiffEditor + expect(editor.selection.active.line).toBe(1) + expect(editor.selection.anchor.line).toBe(1) + expect(revealRange).toHaveBeenCalledTimes(1) + expect(revealRange.mock.calls[0][0].start.line).toBe(1) + }) + + it("re-reveals the target line after layout settles", () => { + // The diff editor can snap the viewport back to the top during its late + // layout pass when the file was already scrolled. A deferred re-reveal + // makes the scroll stick. Verify the second reveal fires on a timer. + vi.useFakeTimers() + try { + ;(diffViewProvider as any).originalContent = "a\nb\nc\nd\n" + const revealRange = setupEditor("a\nCHANGED\nc\nd\n") + + diffViewProvider.scrollToFirstDiff() + + expect(revealRange).toHaveBeenCalledTimes(1) + // Mock timer - no wall clock time elapses here + vi.advanceTimersByTime(100) + expect(revealRange).toHaveBeenCalledTimes(2) + for (const call of revealRange.mock.calls) { + expect(call[0].start.line).toBe(1) + } + } finally { + vi.useRealTimers() + } + }) + + it("reveals on the live modified-side editor, not a stale captured reference", () => { + // Regression for "scrolls to top": the captured activeDiffEditor can be a + // detached editor whose visibleRanges no longer match the on-screen diff, + // making revealRange a no-op. The reveal must target the live editor found + // in visibleTextEditors for the same document. + ;(diffViewProvider as any).originalContent = "a\nb\nc\nd\n" + const staleReveal = setupEditor("a\nCHANGED\nc\nd\n") + const staleEditor = (diffViewProvider as any).activeDiffEditor + + // A live editor for the SAME document, distinct from the stale capture. + const liveReveal = vi.fn() + const liveEditor = { + document: staleEditor.document, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + visibleRanges: [{ start: { line: 0 }, end: { line: 0 } }], + revealRange: liveReveal, + } + vi.mocked(vscode.window).visibleTextEditors = [liveEditor as any] + + diffViewProvider.scrollToFirstDiff() + + expect(liveReveal).toHaveBeenCalledTimes(1) + expect(staleReveal).not.toHaveBeenCalled() + expect(liveReveal.mock.calls[0][0].start.line).toBe(1) + }) + + it("does not re-reveal a stale line on a diff editor opened by a later edit", () => { + // Regression for the "stuck at line 0" bug: a deferred reveal must not act + // after a subsequent edit swapped in a different active diff editor. + vi.useFakeTimers() + try { + ;(diffViewProvider as any).originalContent = "a\nb\nc\nd\n" + const firstReveal = setupEditor("a\nCHANGED\nc\nd\n") + const firstEditor = (diffViewProvider as any).activeDiffEditor + + diffViewProvider.scrollToFirstDiff() + expect(firstReveal).toHaveBeenCalledTimes(1) + + // A later edit swaps in a brand new diff editor before the timer fires. + const secondReveal = setupEditor("a\nb\nc\nd\n") + expect((diffViewProvider as any).activeDiffEditor).not.toBe(firstEditor) + + vi.advanceTimersByTime(100) + + // The stale timer saw a different active editor and did nothing more. + expect(firstReveal).toHaveBeenCalledTimes(1) + expect(secondReveal).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it("does nothing when there is no diff editor", () => { + ;(diffViewProvider as any).activeDiffEditor = undefined + expect(() => diffViewProvider.scrollToFirstDiff()).not.toThrow() + }) + }) + + describe("preview tab snapshot and restore", () => { + const makePreviewTab = (fsPath: string, isPreview = true) => { + const input = Object.assign(new (vscode as any).TabInputText(), { + uri: { fsPath, scheme: "file" }, + }) + return { isPreview, input, label: fsPath } + } + + const setTabs = (tabs: any[], viewColumn = vscode.ViewColumn.One) => { + Object.defineProperty(vscode.window.tabGroups, "all", { + get: () => [{ tabs, viewColumn }], + configurable: true, + }) + } + + it("captures unrelated preview tabs with their scroll position and group, excluding the diff target", () => { + setTabs( + [ + makePreviewTab("/mock/cwd/file-1.txt"), + makePreviewTab("/mock/cwd/file-2.txt"), // diff target -- excluded + makePreviewTab("/mock/cwd/file-3.txt", false), // not a preview -- excluded + ], + vscode.ViewColumn.Two, + ) + vi.mocked(vscode.window).visibleTextEditors = [ + { + document: { uri: { fsPath: "/mock/cwd/file-1.txt", scheme: "file" } }, + visibleRanges: [{ start: { line: 12 } }], + } as any, + ] + + const snapshot = (diffViewProvider as any).captureUnrelatedPreviewTabs("/mock/cwd/file-2.txt") + + expect(snapshot).toHaveLength(1) + expect(snapshot[0].uri.fsPath).toBe("/mock/cwd/file-1.txt") + expect(snapshot[0].scrollLine).toBe(12) + expect(snapshot[0].viewColumn).toBe(vscode.ViewColumn.Two) + }) + + it("restores an evicted preview tab in its original group and reapplies its scroll position", async () => { + const revealRange = vi.fn() + vi.mocked(vscode.window.showTextDocument).mockResolvedValue({ revealRange } as any) + // The captured file is no longer open (evicted by the diff). + setTabs([]) + ;(diffViewProvider as any).snapshotPreviewTabs = [ + { + uri: { fsPath: "/mock/cwd/file-1.txt", scheme: "file" }, + scrollLine: 12, + viewColumn: vscode.ViewColumn.Two, + }, + ] + + await (diffViewProvider as any).restorePreviewTabs() + + expect(vscode.window.showTextDocument).toHaveBeenCalledWith( + { fsPath: "/mock/cwd/file-1.txt", scheme: "file" }, + { preview: true, preserveFocus: true, viewColumn: vscode.ViewColumn.Two }, + ) + expect(revealRange).toHaveBeenCalledWith( + expect.objectContaining({ start: { line: 12, character: 0 } }), + vscode.TextEditorRevealType.AtTop, + ) + expect((diffViewProvider as any).snapshotPreviewTabs).toEqual([]) + }) + + it("does not restore a preview tab that is still open", async () => { + setTabs([makePreviewTab("/mock/cwd/file-1.txt")]) + ;(diffViewProvider as any).snapshotPreviewTabs = [ + { + uri: { fsPath: "/mock/cwd/file-1.txt", scheme: "file" }, + scrollLine: 0, + viewColumn: vscode.ViewColumn.One, + }, + ] + + await (diffViewProvider as any).restorePreviewTabs() + + expect(vscode.window.showTextDocument).not.toHaveBeenCalled() + }) + + it("skips restoring a preview tab whose file no longer exists", async () => { + setTabs([]) + const fs = await import("fs/promises") + vi.mocked(fs.access).mockRejectedValueOnce(new Error("ENOENT")) + ;(diffViewProvider as any).snapshotPreviewTabs = [ + { + uri: { fsPath: "/mock/cwd/deleted.txt", scheme: "file" }, + scrollLine: 0, + viewColumn: vscode.ViewColumn.One, + }, + ] + + await (diffViewProvider as any).restorePreviewTabs() + + expect(vscode.window.showTextDocument).not.toHaveBeenCalled() + expect((diffViewProvider as any).snapshotPreviewTabs).toEqual([]) + }) + }) + + describe("showEditedFileWithoutDisruptingFocus", () => { + it("re-activates the user's editor when they navigated to a different file", async () => { + // User is viewing file-1 while the edited file is file-2. Keeping file-2 + // open must not yank focus/foreground onto it. + const userActiveEditor = { + document: { uri: { fsPath: "/mock/cwd/file-1.txt", scheme: "file" } }, + viewColumn: 1, + } + ;(vscode.window as any).activeTextEditor = userActiveEditor + vi.mocked(vscode.window.showTextDocument).mockResolvedValue({ revealRange: vi.fn() } as any) + ;(diffViewProvider as any).preEditScrollLine = 5 + + await (diffViewProvider as any).showEditedFileWithoutDisruptingFocus("/mock/cwd/file-2.txt") + + // First call re-shows the edited file (preserveFocus); last call restores + // the user's editor with focus. + const calls = vi.mocked(vscode.window.showTextDocument).mock.calls + expect(calls[0][1]).toMatchObject({ preview: false, preserveFocus: true }) + const restoreCall = calls[calls.length - 1] + expect(restoreCall[0]).toBe(userActiveEditor.document) + expect(restoreCall[1]).toMatchObject({ viewColumn: 1, preserveFocus: false }) + }) + + it("does not re-activate when the user is already on the edited file", async () => { + const userActiveEditor = { + document: { uri: { fsPath: "/mock/cwd/file-2.txt", scheme: "file" } }, + viewColumn: 1, + } + ;(vscode.window as any).activeTextEditor = userActiveEditor + vi.mocked(vscode.window.showTextDocument).mockResolvedValue({ revealRange: vi.fn() } as any) + + await (diffViewProvider as any).showEditedFileWithoutDisruptingFocus("/mock/cwd/file-2.txt") + + // Only the single re-show of the edited file; no focus-restore round trip. + expect(vscode.window.showTextDocument).toHaveBeenCalledTimes(1) + }) + + it("re-pins the edited file when it was pinned before the diff", async () => { + const userActiveEditor = { + document: { uri: { fsPath: "/mock/cwd/file-2.txt", scheme: "file" } }, + viewColumn: 1, + } + ;(vscode.window as any).activeTextEditor = userActiveEditor + vi.mocked(vscode.window.showTextDocument).mockResolvedValue({ revealRange: vi.fn() } as any) + ;(diffViewProvider as any).documentWasPinned = true + + await (diffViewProvider as any).showEditedFileWithoutDisruptingFocus("/mock/cwd/file-2.txt") + + // The edited file must be focused (preserveFocus false) so pinEditor + // targets the correct tab, then the pin command is issued. + const calls = vi.mocked(vscode.window.showTextDocument).mock.calls + expect(calls[0][1]).toMatchObject({ preview: false, preserveFocus: false }) + expect(vscode.commands.executeCommand).toHaveBeenCalledWith("workbench.action.pinEditor") + }) + + it("does not pin the edited file when it was not pinned before the diff", async () => { + const userActiveEditor = { + document: { uri: { fsPath: "/mock/cwd/file-2.txt", scheme: "file" } }, + viewColumn: 1, + } + ;(vscode.window as any).activeTextEditor = userActiveEditor + vi.mocked(vscode.window.showTextDocument).mockResolvedValue({ revealRange: vi.fn() } as any) + ;(diffViewProvider as any).documentWasPinned = false + + await (diffViewProvider as any).showEditedFileWithoutDisruptingFocus("/mock/cwd/file-2.txt") + + expect(vscode.commands.executeCommand).not.toHaveBeenCalledWith("workbench.action.pinEditor") + }) }) describe("closeAllDiffViews method", () => { @@ -523,4 +938,887 @@ describe("DiffViewProvider", () => { expect(vscode.languages.getDiagnostics).toHaveBeenCalled() }) }) + + describe("preEditScrollLine capture and restore", () => { + it("should capture scroll line from visible editor at open() time", async () => { + const mockEditor = { + document: { + uri: { fsPath: `${mockCwd}/scroll.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue(""), + lineCount: 0, + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + visibleRanges: [{ start: { line: 42 } }], + } + + vi.mocked(vscode.window).visibleTextEditors = [mockEditor as any] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor as any) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback({ uri: { fsPath: `${mockCwd}/scroll.ts`, scheme: "file" } } as any), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window.onDidChangeVisibleTextEditors).mockReturnValue({ dispose: vi.fn() }) + ;(diffViewProvider as any).editType = "modify" + + await diffViewProvider.open("scroll.ts") + + expect((diffViewProvider as any).preEditScrollLine).toBe(42) + }) + + it("should set preEditScrollLine to undefined when the visible editor has no visibleRanges", async () => { + const mockEditorNoRanges = { + document: { + uri: { fsPath: `${mockCwd}/new.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue(""), + lineCount: 0, + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + // No visibleRanges, so the capture in open() yields undefined. + } + + vi.mocked(vscode.window).visibleTextEditors = [mockEditorNoRanges as any] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditorNoRanges as any) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback({ uri: { fsPath: `${mockCwd}/new.ts`, scheme: "file" } } as any), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window.onDidChangeVisibleTextEditors).mockReturnValue({ dispose: vi.fn() }) + ;(diffViewProvider as any).editType = "modify" + + await diffViewProvider.open("new.ts") + + expect((diffViewProvider as any).preEditScrollLine).toBeUndefined() + }) + + it("saveChanges() calls revealRange(AtTop) when documentWasOpen and preEditScrollLine is set", async () => { + const mockRevealRange = vi.fn() + const mockSavedEditor = { revealRange: mockRevealRange } + + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockSavedEditor as any) + ;(diffViewProvider as any).closeAllDiffViews = vi.fn().mockResolvedValue(undefined) + ;(diffViewProvider as any).documentWasOpen = true + ;(diffViewProvider as any).preEditScrollLine = 30 + // saveChanges early-exits without relPath, newContent, activeDiffEditor + ;(diffViewProvider as any).newContent = "content" + ;(diffViewProvider as any).activeDiffEditor = { + document: { + getText: vi.fn().mockReturnValue("content"), + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }, + } + + await diffViewProvider.saveChanges(false) + + expect(mockRevealRange).toHaveBeenCalledWith( + expect.objectContaining({ start: { line: 30, character: 0 } }), + vscode.TextEditorRevealType.AtTop, + ) + }) + + it("saveChanges() does NOT call revealRange when preEditScrollLine is undefined", async () => { + const mockRevealRange = vi.fn() + const mockSavedEditor = { revealRange: mockRevealRange } + + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockSavedEditor as any) + ;(diffViewProvider as any).closeAllDiffViews = vi.fn().mockResolvedValue(undefined) + ;(diffViewProvider as any).documentWasOpen = true + ;(diffViewProvider as any).preEditScrollLine = undefined + ;(diffViewProvider as any).newContent = "content" + ;(diffViewProvider as any).activeDiffEditor = { + document: { + getText: vi.fn().mockReturnValue("content"), + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }, + } + + await diffViewProvider.saveChanges(false) + + expect(mockRevealRange).not.toHaveBeenCalled() + }) + + it("saveChanges() cancels a pending deferred scroll so it cannot fight scroll-restore", async () => { + // With auto-approve, saveChanges runs immediately after scrollToFirstDiff. + // A late deferred reveal must not fire after the viewport is restored. + vi.useFakeTimers() + try { + const deferredReveal = vi.fn() + const liveEditor = { + document: { + uri: { fsPath: `${mockCwd}/race.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("a\nCHANGED\nc\nd\n"), + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + lineCount: 5, + lineAt: vi.fn().mockReturnValue({ text: "" }), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + visibleRanges: [{ start: { line: 0 }, end: { line: 0 } }], + revealRange: deferredReveal, + } + ;(diffViewProvider as any).originalContent = "a\nb\nc\nd\n" + ;(diffViewProvider as any).activeDiffEditor = liveEditor + vi.mocked(vscode.window).visibleTextEditors = [liveEditor as any] + + // Schedule the deferred reveal, then accept the edit before it fires. + diffViewProvider.scrollToFirstDiff() + expect(deferredReveal).toHaveBeenCalledTimes(1) + + vi.mocked(vscode.window.showTextDocument).mockResolvedValue({ revealRange: vi.fn() } as any) + ;(diffViewProvider as any).closeAllDiffViews = vi.fn().mockResolvedValue(undefined) + ;(diffViewProvider as any).documentWasOpen = true + ;(diffViewProvider as any).preEditScrollLine = 0 + ;(diffViewProvider as any).newContent = "a\nCHANGED\nc\nd\n" + + await diffViewProvider.saveChanges(false) + + // The timer was cancelled; advancing past it produces no extra reveal. + vi.advanceTimersByTime(100) + expect(deferredReveal).toHaveBeenCalledTimes(1) + expect((diffViewProvider as any).deferredScrollTimer).toBeUndefined() + } finally { + vi.useRealTimers() + } + }) + + it("revertChanges() calls revealRange(AtTop) when documentWasOpen and preEditScrollLine is set", async () => { + const mockRevealRange = vi.fn() + const mockSavedEditor = { revealRange: mockRevealRange } + + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockSavedEditor as any) + ;(diffViewProvider as any).closeAllDiffViews = vi.fn().mockResolvedValue(undefined) + ;(diffViewProvider as any).documentWasOpen = true + ;(diffViewProvider as any).preEditScrollLine = 15 + ;(diffViewProvider as any).editType = "modify" + ;(diffViewProvider as any).originalContent = "original" + ;(diffViewProvider as any).activeDiffEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.txt` }, + getText: vi.fn().mockReturnValue("modified"), + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + positionAt: vi.fn().mockReturnValue({ line: 0, character: 0 }), + }, + } + + vi.mocked(vscode.workspace.applyEdit).mockResolvedValue(true) + + await diffViewProvider.revertChanges() + + expect(mockRevealRange).toHaveBeenCalledWith( + expect.objectContaining({ start: { line: 15, character: 0 } }), + vscode.TextEditorRevealType.AtTop, + ) + }) + }) + + describe("userTouchedDocument close/keep behavior", () => { + const mockTargetPath = `${mockCwd}/mock-target-file.ts` + + const buildActiveDiffEditor = () => ({ + document: { + uri: { fsPath: mockTargetPath }, + getText: vi.fn().mockReturnValue("content"), + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + positionAt: vi.fn().mockReturnValue({ line: 0, character: 0 }), + }, + }) + + it("saveChanges() closes the file tab when the file was not open and untouched", async () => { + const closeFileTab = vi.fn().mockResolvedValue(undefined) + ;(diffViewProvider as any).closeAllDiffViews = vi.fn().mockResolvedValue(undefined) + ;(diffViewProvider as any).closeFileTab = closeFileTab + ;(diffViewProvider as any).relPath = "mock-target-file.ts" + ;(diffViewProvider as any).documentWasOpen = false + ;(diffViewProvider as any).userTouchedDocument = false + ;(diffViewProvider as any).preEditScrollLine = undefined + ;(diffViewProvider as any).newContent = "content" + ;(diffViewProvider as any).activeDiffEditor = buildActiveDiffEditor() + + await diffViewProvider.saveChanges(false) + + expect(closeFileTab).toHaveBeenCalledWith(mockTargetPath) + expect(vscode.window.showTextDocument).not.toHaveBeenCalled() + }) + + it("saveChanges() keeps the file open when the user touched it", async () => { + const closeFileTab = vi.fn().mockResolvedValue(undefined) + vi.mocked(vscode.window.showTextDocument).mockResolvedValue({ revealRange: vi.fn() } as any) + ;(diffViewProvider as any).closeAllDiffViews = vi.fn().mockResolvedValue(undefined) + ;(diffViewProvider as any).closeFileTab = closeFileTab + ;(diffViewProvider as any).relPath = "mock-target-file.ts" + ;(diffViewProvider as any).documentWasOpen = false + ;(diffViewProvider as any).userTouchedDocument = true + ;(diffViewProvider as any).preEditScrollLine = undefined + ;(diffViewProvider as any).newContent = "content" + ;(diffViewProvider as any).activeDiffEditor = buildActiveDiffEditor() + + await diffViewProvider.saveChanges(false) + + expect(closeFileTab).not.toHaveBeenCalled() + expect(vscode.window.showTextDocument).toHaveBeenCalled() + }) + + it("revertChanges() closes the file tab when the file was not open and untouched", async () => { + const closeFileTab = vi.fn().mockResolvedValue(undefined) + vi.mocked(vscode.workspace.applyEdit).mockResolvedValue(true) + ;(diffViewProvider as any).closeAllDiffViews = vi.fn().mockResolvedValue(undefined) + ;(diffViewProvider as any).closeFileTab = closeFileTab + ;(diffViewProvider as any).relPath = "mock-target-file.ts" + ;(diffViewProvider as any).documentWasOpen = false + ;(diffViewProvider as any).userTouchedDocument = false + ;(diffViewProvider as any).preEditScrollLine = undefined + ;(diffViewProvider as any).editType = "modify" + ;(diffViewProvider as any).originalContent = "original" + ;(diffViewProvider as any).activeDiffEditor = buildActiveDiffEditor() + + await diffViewProvider.revertChanges() + + expect(closeFileTab).toHaveBeenCalledWith(mockTargetPath) + expect(vscode.window.showTextDocument).not.toHaveBeenCalled() + }) + + it("revertChanges() keeps the file open when the user touched it", async () => { + const closeFileTab = vi.fn().mockResolvedValue(undefined) + vi.mocked(vscode.workspace.applyEdit).mockResolvedValue(true) + vi.mocked(vscode.window.showTextDocument).mockResolvedValue({ revealRange: vi.fn() } as any) + ;(diffViewProvider as any).closeAllDiffViews = vi.fn().mockResolvedValue(undefined) + ;(diffViewProvider as any).closeFileTab = closeFileTab + ;(diffViewProvider as any).relPath = "mock-target-file.ts" + ;(diffViewProvider as any).documentWasOpen = false + ;(diffViewProvider as any).userTouchedDocument = true + ;(diffViewProvider as any).preEditScrollLine = undefined + ;(diffViewProvider as any).editType = "modify" + ;(diffViewProvider as any).originalContent = "original" + ;(diffViewProvider as any).activeDiffEditor = buildActiveDiffEditor() + + await diffViewProvider.revertChanges() + + expect(closeFileTab).not.toHaveBeenCalled() + expect(vscode.window.showTextDocument).toHaveBeenCalled() + }) + + it("marks userTouchedDocument when the file editor is activated during the diff", async () => { + let activeCallback: ((editor: any) => void) | undefined + vi.mocked(vscode.window.onDidChangeActiveTextEditor).mockImplementation((cb: any) => { + activeCallback = cb + return { dispose: vi.fn() } + }) + + const mockEditor = { + document: { + uri: { fsPath: mockTargetPath, scheme: "file" }, + getText: vi.fn().mockReturnValue(""), + lineCount: 0, + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } + vi.mocked(vscode.window).visibleTextEditors = [mockEditor as any] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor as any) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback({ uri: { fsPath: mockTargetPath, scheme: "file" } } as any), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window.onDidChangeVisibleTextEditors).mockReturnValue({ dispose: vi.fn() }) + ;(diffViewProvider as any).editType = "modify" + ;(diffViewProvider as any).documentWasOpen = false + + await diffViewProvider.open("mock-target-file.ts") + + // Simulate the user activating the plain file editor (no diff tab active). + ;(vscode.window.tabGroups as any).activeTabGroup = { activeTab: { input: {} } } + activeCallback?.({ + document: { uri: { fsPath: mockTargetPath, scheme: "file" } }, + }) + + expect((diffViewProvider as any).userTouchedDocument).toBe(true) + }) + }) + + describe("userTouchedDiffEditor keep/close behavior", () => { + const mockTargetPath = `${mockCwd}/mock-target-file.ts` + + const buildActiveDiffEditor = () => ({ + document: { + uri: { fsPath: mockTargetPath }, + getText: vi.fn().mockReturnValue("content"), + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + positionAt: vi.fn().mockReturnValue({ line: 0, character: 0 }), + }, + }) + + it("saveChanges() keeps the file open when the user clicked inside the diff editor", async () => { + const closeFileTab = vi.fn().mockResolvedValue(undefined) + vi.mocked(vscode.window.showTextDocument).mockResolvedValue({ revealRange: vi.fn() } as any) + ;(diffViewProvider as any).closeAllDiffViews = vi.fn().mockResolvedValue(undefined) + ;(diffViewProvider as any).closeFileTab = closeFileTab + ;(diffViewProvider as any).relPath = "mock-target-file.ts" + ;(diffViewProvider as any).documentWasOpen = false + ;(diffViewProvider as any).userTouchedDocument = false + // The user clicked in the diff editor -- the flag is true. + ;(diffViewProvider as any).userTouchedDiffEditor = true + ;(diffViewProvider as any).preEditScrollLine = undefined + ;(diffViewProvider as any).newContent = "content" + ;(diffViewProvider as any).activeDiffEditor = buildActiveDiffEditor() + + await diffViewProvider.saveChanges(false) + + expect(closeFileTab).not.toHaveBeenCalled() + expect(vscode.window.showTextDocument).toHaveBeenCalled() + }) + + it("saveChanges() closes the file tab when the user only scrolled (not clicked) in the diff", async () => { + const closeFileTab = vi.fn().mockResolvedValue(undefined) + ;(diffViewProvider as any).closeAllDiffViews = vi.fn().mockResolvedValue(undefined) + ;(diffViewProvider as any).closeFileTab = closeFileTab + ;(diffViewProvider as any).relPath = "mock-target-file.ts" + ;(diffViewProvider as any).documentWasOpen = false + ;(diffViewProvider as any).userTouchedDocument = false + // The user only scrolled -- the flag stays false. + ;(diffViewProvider as any).userTouchedDiffEditor = false + ;(diffViewProvider as any).preEditScrollLine = undefined + ;(diffViewProvider as any).newContent = "content" + ;(diffViewProvider as any).activeDiffEditor = buildActiveDiffEditor() + + await diffViewProvider.saveChanges(false) + + expect(closeFileTab).toHaveBeenCalledWith(mockTargetPath) + expect(vscode.window.showTextDocument).not.toHaveBeenCalled() + }) + + it("open() registers onDidChangeTextEditorSelection and sets userTouchedDiffEditor on event", async () => { + let selectionCallback: ((event: any) => void) | undefined + vi.mocked(vscode.window.onDidChangeTextEditorSelection).mockImplementation((cb: any) => { + selectionCallback = cb + return { dispose: vi.fn() } + }) + + const mockEditor = { + document: { + uri: { fsPath: `${mockCwd}/sel.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue(""), + lineCount: 0, + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } + vi.mocked(vscode.window).visibleTextEditors = [mockEditor as any] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor as any) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback({ uri: { fsPath: `${mockCwd}/sel.ts`, scheme: "file" } } as any), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window.onDidChangeVisibleTextEditors).mockReturnValue({ dispose: vi.fn() }) + ;(diffViewProvider as any).editType = "modify" + + await diffViewProvider.open("sel.ts") + + expect((diffViewProvider as any).userTouchedDiffEditor).toBe(false) + + // Simulate a Mouse selection-change event on the captured diff editor. + const activeDiffEditor = (diffViewProvider as any).activeDiffEditor + selectionCallback?.({ + textEditor: activeDiffEditor, + kind: vscode.TextEditorSelectionChangeKind.Mouse, + }) + + expect((diffViewProvider as any).userTouchedDiffEditor).toBe(true) + }) + + it("open() does NOT set userTouchedDiffEditor for programmatic selection changes (kind=Command or undefined)", async () => { + let selectionCallback: ((event: any) => void) | undefined + vi.mocked(vscode.window.onDidChangeTextEditorSelection).mockImplementation((cb: any) => { + selectionCallback = cb + return { dispose: vi.fn() } + }) + + const mockEditor = { + document: { + uri: { fsPath: `${mockCwd}/prog.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue(""), + lineCount: 0, + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } + vi.mocked(vscode.window).visibleTextEditors = [mockEditor as any] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor as any) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback({ uri: { fsPath: `${mockCwd}/prog.ts`, scheme: "file" } } as any), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window.onDidChangeVisibleTextEditors).mockReturnValue({ dispose: vi.fn() }) + ;(diffViewProvider as any).editType = "modify" + + await diffViewProvider.open("prog.ts") + + const activeDiffEditor = (diffViewProvider as any).activeDiffEditor + + // Programmatic change via editor.selection= (kind undefined) -- e.g. revealDiffLine + selectionCallback?.({ textEditor: activeDiffEditor, kind: undefined }) + expect((diffViewProvider as any).userTouchedDiffEditor).toBe(false) + + // Programmatic change via command (kind=Command) + selectionCallback?.({ + textEditor: activeDiffEditor, + kind: vscode.TextEditorSelectionChangeKind.Command, + }) + expect((diffViewProvider as any).userTouchedDiffEditor).toBe(false) + }) + + it("open() sets userTouchedDiffEditor when event comes from a fresh editor instance wrapping the same document (stale ref fix)", async () => { + let selectionCallback: ((event: any) => void) | undefined + vi.mocked(vscode.window.onDidChangeTextEditorSelection).mockImplementation((cb: any) => { + selectionCallback = cb + return { dispose: vi.fn() } + }) + + const sharedDocument = { + uri: { fsPath: `${mockCwd}/stale.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue(""), + lineCount: 0, + } + const originalEditor = { + document: sharedDocument, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } + vi.mocked(vscode.window).visibleTextEditors = [originalEditor as any] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(originalEditor as any) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback({ uri: { fsPath: `${mockCwd}/stale.ts`, scheme: "file" } } as any), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window.onDidChangeVisibleTextEditors).mockReturnValue({ dispose: vi.fn() }) + ;(diffViewProvider as any).editType = "modify" + + await diffViewProvider.open("stale.ts") + + // Simulate VS Code giving us a NEW editor object that wraps the same + // document -- this is the real-world stale reference scenario. + const freshEditorSameDoc = { document: sharedDocument } + selectionCallback?.({ + textEditor: freshEditorSameDoc, + kind: vscode.TextEditorSelectionChangeKind.Mouse, + }) + + expect((diffViewProvider as any).userTouchedDiffEditor).toBe(true) + }) + + it("open() ignores selection events on editors other than the diff editor", async () => { + let selectionCallback: ((event: any) => void) | undefined + vi.mocked(vscode.window.onDidChangeTextEditorSelection).mockImplementation((cb: any) => { + selectionCallback = cb + return { dispose: vi.fn() } + }) + + const mockEditor = { + document: { + uri: { fsPath: `${mockCwd}/other.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue(""), + lineCount: 0, + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } + vi.mocked(vscode.window).visibleTextEditors = [mockEditor as any] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor as any) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback({ uri: { fsPath: `${mockCwd}/other.ts`, scheme: "file" } } as any), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window.onDidChangeVisibleTextEditors).mockReturnValue({ dispose: vi.fn() }) + ;(diffViewProvider as any).editType = "modify" + + await diffViewProvider.open("other.ts") + + // Fire the event with a completely different editor object. + const unrelatedEditor = { document: { uri: { fsPath: "/some/other/file.ts", scheme: "file" } } } + selectionCallback?.({ textEditor: unrelatedEditor }) + + expect((diffViewProvider as any).userTouchedDiffEditor).toBe(false) + }) + + it("revertChanges() closes the file tab even when userTouchedDiffEditor is true (deny ignores diff-touch)", async () => { + // Asymmetry guard: only saveChanges() passes userTouchedDiffEditor through to + // keepOrCloseEditedFile(). revertChanges() (deny) must NOT honor a diff-pane + // touch -- a denied edit on a not-previously-open, document-untouched file + // should still close the transient tab. This protects the documented intent + // from accidental regression. + const closeFileTab = vi.fn().mockResolvedValue(undefined) + vi.mocked(vscode.workspace.applyEdit).mockResolvedValue(true) + ;(diffViewProvider as any).closeAllDiffViews = vi.fn().mockResolvedValue(undefined) + ;(diffViewProvider as any).closeFileTab = closeFileTab + ;(diffViewProvider as any).relPath = "mock-target-file.ts" + ;(diffViewProvider as any).documentWasOpen = false + ;(diffViewProvider as any).userTouchedDocument = false + // The user clicked inside the diff pane -- but this is a deny, so it must be ignored. + ;(diffViewProvider as any).userTouchedDiffEditor = true + ;(diffViewProvider as any).preEditScrollLine = undefined + ;(diffViewProvider as any).editType = "modify" + ;(diffViewProvider as any).originalContent = "original" + ;(diffViewProvider as any).activeDiffEditor = buildActiveDiffEditor() + + await diffViewProvider.revertChanges() + + expect(closeFileTab).toHaveBeenCalledWith(mockTargetPath) + expect(vscode.window.showTextDocument).not.toHaveBeenCalled() + }) + }) + + describe("scroll position precedence in showEditedFileWithoutDisruptingFocus", () => { + const setupForScroll = ( + lastScrolledSource: "diff" | "targetFile" | undefined, + diffScrollLine: number | undefined, + targetFileScrollLine: number | undefined, + preEditScrollLine: number | undefined, + ) => { + const mockRevealRange = vi.fn() + vi.mocked(vscode.window.showTextDocument).mockResolvedValue({ revealRange: mockRevealRange } as any) + const userActiveEditor = { + document: { uri: { fsPath: `${mockCwd}/target.ts`, scheme: "file" } }, + viewColumn: 1, + } + ;(vscode.window as any).activeTextEditor = userActiveEditor + ;(diffViewProvider as any).lastScrolledSource = lastScrolledSource + ;(diffViewProvider as any).diffScrollLine = diffScrollLine + ;(diffViewProvider as any).targetFileScrollLine = targetFileScrollLine + ;(diffViewProvider as any).preEditScrollLine = preEditScrollLine + ;(diffViewProvider as any).documentWasPinned = false + return mockRevealRange + } + + it("uses diffScrollLine when lastScrolledSource is 'diff'", async () => { + const mockRevealRange = setupForScroll("diff", 20, 5, 0) + + await (diffViewProvider as any).showEditedFileWithoutDisruptingFocus(`${mockCwd}/target.ts`) + + expect(mockRevealRange).toHaveBeenCalledWith( + expect.objectContaining({ start: { line: 20, character: 0 } }), + vscode.TextEditorRevealType.AtTop, + ) + }) + + it("uses targetFileScrollLine when lastScrolledSource is 'targetFile'", async () => { + const mockRevealRange = setupForScroll("targetFile", 20, 8, 0) + + await (diffViewProvider as any).showEditedFileWithoutDisruptingFocus(`${mockCwd}/target.ts`) + + expect(mockRevealRange).toHaveBeenCalledWith( + expect.objectContaining({ start: { line: 8, character: 0 } }), + vscode.TextEditorRevealType.AtTop, + ) + }) + + it("falls back to preEditScrollLine when lastScrolledSource is undefined", async () => { + const mockRevealRange = setupForScroll(undefined, 20, 8, 3) + + await (diffViewProvider as any).showEditedFileWithoutDisruptingFocus(`${mockCwd}/target.ts`) + + expect(mockRevealRange).toHaveBeenCalledWith( + expect.objectContaining({ start: { line: 3, character: 0 } }), + vscode.TextEditorRevealType.AtTop, + ) + }) + + it("does not call revealRange when all scroll sources are undefined", async () => { + const mockRevealRange = setupForScroll(undefined, undefined, undefined, undefined) + + await (diffViewProvider as any).showEditedFileWithoutDisruptingFocus(`${mockCwd}/target.ts`) + + expect(mockRevealRange).not.toHaveBeenCalled() + }) + + it("targetFile scroll overrides diff scroll regardless of their values", async () => { + // lastScrolledSource=targetFile means the user scrolled there AFTER the diff, + // so targetFileScrollLine must win even when diffScrollLine is higher. + const mockRevealRange = setupForScroll("targetFile", 100, 2, 0) + + await (diffViewProvider as any).showEditedFileWithoutDisruptingFocus(`${mockCwd}/target.ts`) + + expect(mockRevealRange).toHaveBeenCalledWith( + expect.objectContaining({ start: { line: 2, character: 0 } }), + vscode.TextEditorRevealType.AtTop, + ) + }) + }) + + describe("diffScrollListener wiring in open()", () => { + const openWithScrollListener = async (relPath: string) => { + let scrollCallback: ((event: any) => void) | undefined + vi.mocked(vscode.window.onDidChangeTextEditorVisibleRanges).mockImplementation((cb: any) => { + scrollCallback = cb + return { dispose: vi.fn() } + }) + + const fsPath = `${mockCwd}/${relPath}` + const mockEditor = { + document: { + uri: { fsPath, scheme: "file" }, + getText: vi.fn().mockReturnValue(""), + lineCount: 0, + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } + vi.mocked(vscode.window).visibleTextEditors = [mockEditor as any] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor as any) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback({ uri: { fsPath, scheme: "file" } } as any), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window.onDidChangeVisibleTextEditors).mockReturnValue({ dispose: vi.fn() }) + ;(diffViewProvider as any).editType = "modify" + + await diffViewProvider.open(relPath) + if (!scrollCallback) { + throw new Error( + "onDidChangeTextEditorVisibleRanges mock was not invoked during open() - scroll listener setup changed", + ) + } + return { + scrollCallback, + activeDiffEditor: (diffViewProvider as any).activeDiffEditor, + fsPath, + } + } + + it("records diffScrollLine and sets lastScrolledSource to 'diff' on diff editor scroll", async () => { + const { scrollCallback, activeDiffEditor } = await openWithScrollListener("scroll-diff.ts") + + scrollCallback({ textEditor: activeDiffEditor, visibleRanges: [{ start: { line: 42 } }] }) + + expect((diffViewProvider as any).diffScrollLine).toBe(42) + expect((diffViewProvider as any).lastScrolledSource).toBe("diff") + }) + + it("records targetFileScrollLine and sets lastScrolledSource to 'targetFile' on target file scroll", async () => { + const { scrollCallback, fsPath } = await openWithScrollListener("scroll-target.ts") + + const targetFileEditor = { + document: { uri: { fsPath, scheme: "file" } }, + } + scrollCallback({ textEditor: targetFileEditor, visibleRanges: [{ start: { line: 17 } }] }) + + expect((diffViewProvider as any).targetFileScrollLine).toBe(17) + expect((diffViewProvider as any).lastScrolledSource).toBe("targetFile") + }) + + it("ignores scroll events from unrelated editors", async () => { + const { scrollCallback } = await openWithScrollListener("scroll-unrelated.ts") + + const unrelatedEditor = { + document: { uri: { fsPath: "/some/other/file.ts", scheme: "file" } }, + } + scrollCallback({ textEditor: unrelatedEditor, visibleRanges: [{ start: { line: 99 } }] }) + + expect((diffViewProvider as any).diffScrollLine).toBeUndefined() + expect((diffViewProvider as any).targetFileScrollLine).toBeUndefined() + expect((diffViewProvider as any).lastScrolledSource).toBeUndefined() + }) + + it("lastScrolledSource reflects the most recent scroll between diff and target file", async () => { + const { scrollCallback, activeDiffEditor, fsPath } = await openWithScrollListener("scroll-recency.ts") + + // First scroll in diff. + scrollCallback({ textEditor: activeDiffEditor, visibleRanges: [{ start: { line: 10 } }] }) + expect((diffViewProvider as any).lastScrolledSource).toBe("diff") + + // Then scroll in the target file -- this must win. + const targetFileEditor = { document: { uri: { fsPath, scheme: "file" } } } + scrollCallback({ textEditor: targetFileEditor, visibleRanges: [{ start: { line: 5 } }] }) + expect((diffViewProvider as any).lastScrolledSource).toBe("targetFile") + + // Back to diff again. + scrollCallback({ textEditor: activeDiffEditor, visibleRanges: [{ start: { line: 20 } }] }) + expect((diffViewProvider as any).lastScrolledSource).toBe("diff") + }) + }) + + describe("auto-close settings decision table", () => { + const mockTargetPath = `${mockCwd}/auto-close-test.ts` + + const buildActiveDiffEditor = () => ({ + document: { + uri: { fsPath: mockTargetPath }, + getText: vi.fn().mockReturnValue("content"), + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + positionAt: vi.fn().mockReturnValue({ line: 0, character: 0 }), + }, + }) + + const setupProvider = (stateOverrides: Record = {}) => { + const task = { + providerRef: { + deref: vi.fn().mockReturnValue({ + getState: vi.fn().mockResolvedValue({ + includeDiagnosticMessages: true, + maxDiagnosticMessages: 50, + ...stateOverrides, + }), + }), + }, + } + const provider = new DiffViewProvider(mockCwd, task as any) + ;(provider as any).relPath = "auto-close-test.ts" + ;(provider as any).newContent = "content" + ;(provider as any).activeDiffEditor = buildActiveDiffEditor() + ;(provider as any).closeAllDiffViews = vi.fn().mockResolvedValue(undefined) + ;(provider as any).preEditScrollLine = undefined + return provider + } + + it("already-open file is never auto-closed regardless of settings", async () => { + const provider = setupProvider({ autoCloseZooOpenedFiles: true }) + const closeFileTab = vi.fn().mockResolvedValue(undefined) + ;(provider as any).closeFileTab = closeFileTab + ;(provider as any).documentWasOpen = true + ;(provider as any).userTouchedDocument = false + vi.mocked(vscode.window.showTextDocument).mockResolvedValue({ revealRange: vi.fn() } as any) + + await provider.saveChanges(false) + + expect(closeFileTab).not.toHaveBeenCalled() + }) + + it("transient tab is kept when autoCloseZooOpenedFiles is false", async () => { + const provider = setupProvider({ autoCloseZooOpenedFiles: false }) + const closeFileTab = vi.fn().mockResolvedValue(undefined) + ;(provider as any).closeFileTab = closeFileTab + ;(provider as any).documentWasOpen = false + ;(provider as any).userTouchedDocument = false + ;(provider as any).userTouchedDiffEditor = false + vi.mocked(vscode.window.showTextDocument).mockResolvedValue({ revealRange: vi.fn() } as any) + + await provider.saveChanges(false) + + expect(closeFileTab).not.toHaveBeenCalled() + expect(vscode.window.showTextDocument).toHaveBeenCalled() + }) + + it("transient tab is closed when autoCloseZooOpenedFiles is true (default)", async () => { + const provider = setupProvider({ autoCloseZooOpenedFiles: true }) + const closeFileTab = vi.fn().mockResolvedValue(undefined) + ;(provider as any).closeFileTab = closeFileTab + ;(provider as any).documentWasOpen = false + ;(provider as any).userTouchedDocument = false + ;(provider as any).userTouchedDiffEditor = false + + await provider.saveChanges(false) + + expect(closeFileTab).toHaveBeenCalledWith(mockTargetPath) + }) + + it("touched tab is kept by default (autoCloseZooOpenedFilesAfterUserEdited unset)", async () => { + const provider = setupProvider({}) + const closeFileTab = vi.fn().mockResolvedValue(undefined) + ;(provider as any).closeFileTab = closeFileTab + ;(provider as any).documentWasOpen = false + ;(provider as any).userTouchedDocument = true + ;(provider as any).userTouchedDiffEditor = false + vi.mocked(vscode.window.showTextDocument).mockResolvedValue({ revealRange: vi.fn() } as any) + + await provider.saveChanges(false) + + expect(closeFileTab).not.toHaveBeenCalled() + }) + + it("touched tab is closed when autoCloseZooOpenedFilesAfterUserEdited is true", async () => { + const provider = setupProvider({ autoCloseZooOpenedFilesAfterUserEdited: true }) + const closeFileTab = vi.fn().mockResolvedValue(undefined) + ;(provider as any).closeFileTab = closeFileTab + ;(provider as any).documentWasOpen = false + ;(provider as any).userTouchedDocument = true + ;(provider as any).userTouchedDiffEditor = false + + await provider.saveChanges(false) + + expect(closeFileTab).toHaveBeenCalledWith(mockTargetPath) + }) + + it("touched tab is kept when autoCloseZooOpenedFilesAfterUserEdited is true but autoCloseZooOpenedFiles is false", async () => { + // The after-edit override is a refinement of the base auto-close, so it + // has no effect when autoCloseZooOpenedFiles is disabled. + const provider = setupProvider({ + autoCloseZooOpenedFiles: false, + autoCloseZooOpenedFilesAfterUserEdited: true, + }) + const closeFileTab = vi.fn().mockResolvedValue(undefined) + ;(provider as any).closeFileTab = closeFileTab + ;(provider as any).documentWasOpen = false + ;(provider as any).userTouchedDocument = true + ;(provider as any).userTouchedDiffEditor = false + vi.mocked(vscode.window.showTextDocument).mockResolvedValue({ revealRange: vi.fn() } as any) + + await provider.saveChanges(false) + + expect(closeFileTab).not.toHaveBeenCalled() + expect(vscode.window.showTextDocument).toHaveBeenCalled() + }) + + it("new file tab is closed when autoCloseZooOpenedNewFiles is true (accept path)", async () => { + const provider = setupProvider({ autoCloseZooOpenedNewFiles: true }) + const closeFileTab = vi.fn().mockResolvedValue(undefined) + ;(provider as any).closeFileTab = closeFileTab + ;(provider as any).documentWasOpen = false + ;(provider as any).userTouchedDocument = false + ;(provider as any).userTouchedDiffEditor = false + ;(provider as any).editType = "create" + + await provider.saveChanges(false) + + expect(closeFileTab).toHaveBeenCalledWith(mockTargetPath) + }) + + it("new file tab follows transient-tab rule when autoCloseZooOpenedNewFiles is false and autoCloseZooOpenedFiles is also false", async () => { + // autoCloseZooOpenedNewFiles=false means the new-file fast-path is skipped; + // the file then falls through to the normal transient-tab rule. + // With autoCloseZooOpenedFiles=false the tab should be kept. + const provider = setupProvider({ + autoCloseZooOpenedNewFiles: false, + autoCloseZooOpenedFiles: false, + }) + const closeFileTab = vi.fn().mockResolvedValue(undefined) + ;(provider as any).closeFileTab = closeFileTab + ;(provider as any).documentWasOpen = false + ;(provider as any).userTouchedDocument = false + ;(provider as any).userTouchedDiffEditor = false + ;(provider as any).editType = "create" + vi.mocked(vscode.window.showTextDocument).mockResolvedValue({ revealRange: vi.fn() } as any) + + await provider.saveChanges(false) + + expect(closeFileTab).not.toHaveBeenCalled() + expect(vscode.window.showTextDocument).toHaveBeenCalled() + }) + + it("defaults preserve existing behavior when all settings are unset", async () => { + // No auto-close settings in state: transient tab should be closed (existing default). + const provider = setupProvider({}) + const closeFileTab = vi.fn().mockResolvedValue(undefined) + ;(provider as any).closeFileTab = closeFileTab + ;(provider as any).documentWasOpen = false + ;(provider as any).userTouchedDocument = false + ;(provider as any).userTouchedDiffEditor = false + + await provider.saveChanges(false) + + expect(closeFileTab).toHaveBeenCalledWith(mockTargetPath) + }) + }) }) diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 5b311f8902..a34a8634ff 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -205,6 +205,9 @@ const SettingsView = forwardRef(({ onDone, t includeCurrentTime, includeCurrentCost, maxGitStatusFiles, + autoCloseZooOpenedFiles, + autoCloseZooOpenedFilesAfterUserEdited, + autoCloseZooOpenedNewFiles, } = cachedState const apiConfiguration = useMemo(() => cachedState.apiConfiguration ?? {}, [cachedState.apiConfiguration]) @@ -420,6 +423,9 @@ const SettingsView = forwardRef(({ onDone, t includeCurrentTime: includeCurrentTime ?? true, includeCurrentCost: includeCurrentCost ?? true, maxGitStatusFiles: maxGitStatusFiles ?? 0, + autoCloseZooOpenedFiles: autoCloseZooOpenedFiles ?? true, + autoCloseZooOpenedFilesAfterUserEdited: autoCloseZooOpenedFilesAfterUserEdited ?? false, + autoCloseZooOpenedNewFiles: autoCloseZooOpenedNewFiles ?? false, profileThresholds, imageGenerationProvider, openRouterImageApiKey, @@ -899,6 +905,9 @@ const SettingsView = forwardRef(({ onDone, t reasoningBlockCollapsed={reasoningBlockCollapsed ?? true} enterBehavior={enterBehavior ?? "send"} chatFontSize={chatFontSize ?? undefined} + autoCloseZooOpenedFiles={autoCloseZooOpenedFiles} + autoCloseZooOpenedFilesAfterUserEdited={autoCloseZooOpenedFilesAfterUserEdited} + autoCloseZooOpenedNewFiles={autoCloseZooOpenedNewFiles} setCachedStateField={setCachedStateField} /> )} diff --git a/webview-ui/src/components/settings/UISettings.tsx b/webview-ui/src/components/settings/UISettings.tsx index 3d67072a75..2b3ab08a84 100644 --- a/webview-ui/src/components/settings/UISettings.tsx +++ b/webview-ui/src/components/settings/UISettings.tsx @@ -18,6 +18,9 @@ interface UISettingsProps extends HTMLAttributes { reasoningBlockCollapsed: boolean enterBehavior: "send" | "newline" chatFontSize?: number + autoCloseZooOpenedFiles?: boolean + autoCloseZooOpenedFilesAfterUserEdited?: boolean + autoCloseZooOpenedNewFiles?: boolean setCachedStateField: SetCachedStateField } @@ -25,6 +28,9 @@ export const UISettings = ({ reasoningBlockCollapsed, enterBehavior, chatFontSize, + autoCloseZooOpenedFiles, + autoCloseZooOpenedFilesAfterUserEdited, + autoCloseZooOpenedNewFiles, setCachedStateField, ...props }: UISettingsProps) => { @@ -146,6 +152,66 @@ export const UISettings = ({ + + {/* Auto-close Zoo opened files */} + +
+ setCachedStateField("autoCloseZooOpenedFiles", e.target.checked)} + data-testid="auto-close-zoo-opened-files-checkbox"> + {t("settings:ui.autoCloseZooOpenedFiles.label")} + +
+ {t("settings:ui.autoCloseZooOpenedFiles.description")} +
+
+
+ + {/* Auto-close Zoo opened files after user interaction */} + +
+ + setCachedStateField("autoCloseZooOpenedFilesAfterUserEdited", e.target.checked) + } + data-testid="auto-close-zoo-opened-files-after-user-edited-checkbox"> + + {t("settings:ui.autoCloseZooOpenedFilesAfterUserEdited.label")} + + +
+ {t("settings:ui.autoCloseZooOpenedFilesAfterUserEdited.description")} +
+
+
+ + {/* Auto-close Zoo opened new files */} + +
+ + setCachedStateField("autoCloseZooOpenedNewFiles", e.target.checked) + } + data-testid="auto-close-zoo-opened-new-files-checkbox"> + {t("settings:ui.autoCloseZooOpenedNewFiles.label")} + +
+ {t("settings:ui.autoCloseZooOpenedNewFiles.description")} +
+
+
diff --git a/webview-ui/src/components/settings/__tests__/SettingsView.change-detection.spec.tsx b/webview-ui/src/components/settings/__tests__/SettingsView.change-detection.spec.tsx index cb5dc8ec28..2bbcc47b95 100644 --- a/webview-ui/src/components/settings/__tests__/SettingsView.change-detection.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/SettingsView.change-detection.spec.tsx @@ -303,6 +303,9 @@ describe("SettingsView - Change Detection Fix", () => { openRouterImageApiKey: undefined, openRouterImageGenerationSelectedModel: undefined, reasoningBlockCollapsed: true, + autoCloseZooOpenedFiles: true, + autoCloseZooOpenedFilesAfterUserEdited: false, + autoCloseZooOpenedNewFiles: false, ...overrides, }) diff --git a/webview-ui/src/components/settings/__tests__/UISettings.spec.tsx b/webview-ui/src/components/settings/__tests__/UISettings.spec.tsx index 50b7575625..24593a223d 100644 --- a/webview-ui/src/components/settings/__tests__/UISettings.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/UISettings.spec.tsx @@ -92,4 +92,80 @@ describe("UISettings", () => { expect(telemetryClient.capture).toHaveBeenCalledWith("ui_settings_chat_font_size_reset") }) }) + + describe("auto-close Zoo-opened files checkboxes", () => { + it("renders all three auto-close checkboxes", () => { + const { getByTestId } = render( + , + ) + expect(getByTestId("auto-close-zoo-opened-files-checkbox")).toBeTruthy() + expect(getByTestId("auto-close-zoo-opened-files-after-user-edited-checkbox")).toBeTruthy() + expect(getByTestId("auto-close-zoo-opened-new-files-checkbox")).toBeTruthy() + }) + + it("autoCloseZooOpenedFiles checkbox reflects true prop", () => { + const { getByTestId } = render() + const checkbox = getByTestId("auto-close-zoo-opened-files-checkbox") as HTMLInputElement + expect(checkbox.checked).toBe(true) + }) + + it("autoCloseZooOpenedFiles checkbox reflects false prop", () => { + const { getByTestId } = render() + const checkbox = getByTestId("auto-close-zoo-opened-files-checkbox") as HTMLInputElement + expect(checkbox.checked).toBe(false) + }) + + it("calls setCachedStateField with autoCloseZooOpenedFiles when toggled", async () => { + const setCachedStateField = vi.fn() + const { getByTestId } = render( + , + ) + const checkbox = getByTestId("auto-close-zoo-opened-files-checkbox") + fireEvent.click(checkbox) + await waitFor(() => { + expect(setCachedStateField).toHaveBeenCalledWith("autoCloseZooOpenedFiles", false) + }) + }) + + it("calls setCachedStateField with autoCloseZooOpenedFilesAfterUserEdited when toggled", async () => { + const setCachedStateField = vi.fn() + const { getByTestId } = render( + , + ) + const checkbox = getByTestId("auto-close-zoo-opened-files-after-user-edited-checkbox") + fireEvent.click(checkbox) + await waitFor(() => { + expect(setCachedStateField).toHaveBeenCalledWith("autoCloseZooOpenedFilesAfterUserEdited", true) + }) + }) + + it("calls setCachedStateField with autoCloseZooOpenedNewFiles when toggled", async () => { + const setCachedStateField = vi.fn() + const { getByTestId } = render( + , + ) + const checkbox = getByTestId("auto-close-zoo-opened-new-files-checkbox") + fireEvent.click(checkbox) + await waitFor(() => { + expect(setCachedStateField).toHaveBeenCalledWith("autoCloseZooOpenedNewFiles", true) + }) + }) + }) }) diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index 5de02a4451..aca69ed309 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -985,6 +985,18 @@ "label": "Mida de lletra del xat", "description": "Defineix la mida de la lletra (en píxels) del xat de Zoo Code. Deixa-ho al valor per defecte per coincidir amb la mida de lletra del VS Code.", "reset": "Utilitza el valor per defecte del VS Code" + }, + "autoCloseZooOpenedFiles": { + "label": "Tanca automaticament els fitxers oberts per Zoo", + "description": "Quan esta activat, les pestanyes de l'editor que Zoo ha obert temporalment per a un diff es tanquen automaticament despres d'acceptar o rebutjar el canvi. Les pestanyes que l'usuari ja tenia obertes abans de l'edicio mai no es tanquen automaticament." + }, + "autoCloseZooOpenedFilesAfterUserEdited": { + "label": "Tanca automaticament fins i tot despres de la interaccio de l'usuari", + "description": "Quan esta activat, les pestanyes obertes per Zoo es tanquen fins i tot si l'usuari ha fet clic o ha escrit al fitxer o al diff durant la sessio d'edicio. No te cap efecte si 'Tanca automaticament els fitxers oberts per Zoo' esta desactivat." + }, + "autoCloseZooOpenedNewFiles": { + "label": "Tanca automaticament els fitxers nous creats", + "description": "Quan esta activat, les pestanyes dels fitxers que Zoo ha creat (en lloc de modificar) tambe es tanquen automaticament despres d'acceptar el canvi." } }, "skills": { diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index 5d40186752..162234ca92 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -985,6 +985,18 @@ "label": "Chat-Schriftgröße", "description": "Lege die Schriftgröße (in Pixeln) für den Zoo Code-Chat fest. Belasse den Standardwert, um die Schriftgröße von VS Code zu übernehmen.", "reset": "VS Code-Standard verwenden" + }, + "autoCloseZooOpenedFiles": { + "label": "Von Zoo geöffnete Dateien automatisch schließen", + "description": "Wenn aktiviert, werden Editor-Tabs, die Zoo vorübergehend für einen Diff geöffnet hat, nach dem Akzeptieren oder Ablehnen der Änderung automatisch geschlossen. Tabs, die der Nutzer bereits vor der Bearbeitung geöffnet hatte, werden nie automatisch geschlossen." + }, + "autoCloseZooOpenedFilesAfterUserEdited": { + "label": "Auch nach Nutzer-Interaktion automatisch schließen", + "description": "Wenn aktiviert, werden von Zoo geöffnete Tabs auch dann geschlossen, wenn der Nutzer während der Bearbeitung in die Datei oder den Diff geklickt oder getippt hat. Hat keine Auswirkung, wenn 'Von Zoo geöffnete Dateien automatisch schließen' deaktiviert ist." + }, + "autoCloseZooOpenedNewFiles": { + "label": "Neu erstellte Dateien automatisch schließen", + "description": "Wenn aktiviert, werden Tabs für Dateien, die Zoo neu erstellt hat (anstatt sie zu bearbeiten), nach dem Akzeptieren der Änderung ebenfalls automatisch geschlossen." } }, "skills": { diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index edc416afcf..a28621b69a 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -165,6 +165,18 @@ "label": "Chat font size", "description": "Set the font size (in pixels) for the Zoo Code chat. Leave at the default to match VS Code's font size.", "reset": "Use VS Code default" + }, + "autoCloseZooOpenedFiles": { + "label": "Auto-close files Zoo opened", + "description": "When enabled, editor tabs that Zoo opened transiently for a diff will be closed automatically after the edit is accepted or rejected. Tabs the user already had open before the edit are never auto-closed." + }, + "autoCloseZooOpenedFilesAfterUserEdited": { + "label": "Auto-close even after user interaction", + "description": "When enabled, Zoo-opened tabs are closed even if the user clicked or typed inside the file or diff during the edit session. Has no effect when 'Auto-close files Zoo opened' is disabled." + }, + "autoCloseZooOpenedNewFiles": { + "label": "Auto-close newly created files", + "description": "When enabled, tabs for files that Zoo created (rather than modified) will also be closed automatically after the edit is accepted." } }, "prompts": { diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index f58cd7e952..bd531ac954 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -985,6 +985,18 @@ "label": "Tamaño de fuente del chat", "description": "Establece el tamaño de fuente (en píxeles) del chat de Zoo Code. Déjalo en el valor predeterminado para que coincida con el tamaño de fuente de VS Code.", "reset": "Usar el valor predeterminado de VS Code" + }, + "autoCloseZooOpenedFiles": { + "label": "Cerrar automaticamente los archivos abiertos por Zoo", + "description": "Cuando esta opcion esta activada, las pestanas del editor que Zoo abrio temporalmente para un diff se cierran automaticamente despues de aceptar o rechazar la modificacion. Las pestanas que el usuario ya tenia abiertas antes de la edicion nunca se cierran automaticamente." + }, + "autoCloseZooOpenedFilesAfterUserEdited": { + "label": "Cerrar automaticamente incluso tras interaccion del usuario", + "description": "Cuando esta opcion esta activada, las pestanas abiertas por Zoo se cierran incluso si el usuario hizo clic o escribio en el archivo o el diff durante la sesion de edicion. No tiene efecto si 'Cerrar automaticamente los archivos abiertos por Zoo' esta desactivado." + }, + "autoCloseZooOpenedNewFiles": { + "label": "Cerrar automaticamente los archivos nuevos creados", + "description": "Cuando esta opcion esta activada, las pestanas de archivos que Zoo ha creado (en lugar de modificar) tambien se cierran automaticamente despues de aceptar la modificacion." } }, "skills": { diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index 9323727520..90b479bbcd 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -985,6 +985,18 @@ "label": "Taille de police du chat", "description": "Définissez la taille de police (en pixels) du chat Zoo Code. Laissez la valeur par défaut pour correspondre à la taille de police de VS Code.", "reset": "Utiliser la valeur par défaut de VS Code" + }, + "autoCloseZooOpenedFiles": { + "label": "Fermer automatiquement les fichiers ouverts par Zoo", + "description": "Quand cette option est activée, les onglets d'éditeur que Zoo a ouverts temporairement pour un diff sont fermés automatiquement après l'acceptation ou le rejet de la modification. Les onglets que l'utilisateur avait déjà ouverts avant la modification ne sont jamais fermés automatiquement." + }, + "autoCloseZooOpenedFilesAfterUserEdited": { + "label": "Fermer automatiquement même après interaction utilisateur", + "description": "Quand cette option est activée, les onglets ouverts par Zoo sont fermés même si l'utilisateur a cliqué ou tapé dans le fichier ou le diff pendant la session de modification. N'a aucun effet si 'Fermer automatiquement les fichiers ouverts par Zoo' est désactivé." + }, + "autoCloseZooOpenedNewFiles": { + "label": "Fermer automatiquement les nouveaux fichiers créés", + "description": "Quand cette option est activée, les onglets des fichiers que Zoo a créés (plutôt que modifiés) sont également fermés automatiquement après l'acceptation de la modification." } }, "skills": { diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index 79309334dc..80eada0e66 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -985,6 +985,18 @@ "label": "चैट फ़ॉन्ट आकार", "description": "Zoo Code चैट के लिए फ़ॉन्ट आकार (पिक्सेल में) सेट करें। VS Code के फ़ॉन्ट आकार से मेल खाने के लिए डिफ़ॉल्ट पर छोड़ दें।", "reset": "VS Code डिफ़ॉल्ट का उपयोग करें" + }, + "autoCloseZooOpenedFiles": { + "label": "Zoo dvara kholi gayi files ko svachalit band karo", + "description": "Saksham hone par, Zoo dvara diff ke liye aasthayi roop se kholi gayi editor tabs ko parivartan svikaar ya asviikaar hone ke baad svachalit roop se band kar diya jaata hai. Sampadak ke pehle se hi khule tabs ko kabhi svachalit roop se band nahin kiya jaata." + }, + "autoCloseZooOpenedFilesAfterUserEdited": { + "label": "Upayogkarta ki interaction ke baad bhi svachalit band karo", + "description": "Saksham hone par, Zoo dvara kholi gayi tabs tab bhi band ho jaati hain jab upayogkarta ne sampadak satra mein file ya diff mein click ya type kiya ho. Iska koi prabhav nahin hota agar 'Zoo dvara kholi gayi files ko svachalit band karo' aksham hai." + }, + "autoCloseZooOpenedNewFiles": { + "label": "Nai banayi gayi files ko svachalit band karo", + "description": "Saksham hone par, Zoo dvara banaayi gayi (sanshodhit nahin) files ki tabs ko bhi parivartan svikaar hone ke baad svachalit roop se band kar diya jaata hai." } }, "skills": { diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index 12fec8249e..80a0c9779a 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -985,6 +985,18 @@ "label": "Ukuran font obrolan", "description": "Atur ukuran font (dalam piksel) untuk obrolan Zoo Code. Biarkan pada nilai default agar sesuai dengan ukuran font VS Code.", "reset": "Gunakan default VS Code" + }, + "autoCloseZooOpenedFiles": { + "label": "Tutup otomatis file yang dibuka Zoo", + "description": "Saat diaktifkan, tab editor yang dibuka Zoo untuk sementara waktu untuk diff akan ditutup secara otomatis setelah perubahan diterima atau ditolak. Tab yang sudah dibuka pengguna sebelum pengeditan tidak pernah ditutup secara otomatis." + }, + "autoCloseZooOpenedFilesAfterUserEdited": { + "label": "Tutup otomatis meski setelah interaksi pengguna", + "description": "Saat diaktifkan, tab yang dibuka Zoo tetap ditutup meski pengguna mengklik atau mengetik di file atau diff selama sesi pengeditan. Tidak berpengaruh jika 'Tutup otomatis file yang dibuka Zoo' dinonaktifkan." + }, + "autoCloseZooOpenedNewFiles": { + "label": "Tutup otomatis file baru yang dibuat", + "description": "Saat diaktifkan, tab untuk file yang dibuat Zoo (bukan dimodifikasi) juga ditutup secara otomatis setelah perubahan diterima." } }, "skills": { diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index b8bda12cd3..3e319c32a4 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -985,6 +985,18 @@ "label": "Dimensione carattere della chat", "description": "Imposta la dimensione del carattere (in pixel) per la chat di Zoo Code. Lascia il valore predefinito per adattarsi alla dimensione del carattere di VS Code.", "reset": "Usa il valore predefinito di VS Code" + }, + "autoCloseZooOpenedFiles": { + "label": "Chiudi automaticamente i file aperti da Zoo", + "description": "Quando abilitata, le schede dell'editor che Zoo ha aperto temporaneamente per un diff vengono chiuse automaticamente dopo l'accettazione o il rifiuto della modifica. Le schede che l'utente aveva già aperto prima della modifica non vengono mai chiuse automaticamente." + }, + "autoCloseZooOpenedFilesAfterUserEdited": { + "label": "Chiudi automaticamente anche dopo l'interazione dell'utente", + "description": "Quando abilitata, le schede aperte da Zoo vengono chiuse anche se l'utente ha fatto clic o digitato nel file o nel diff durante la sessione di modifica. Non ha effetto se 'Chiudi automaticamente i file aperti da Zoo' è disabilitata." + }, + "autoCloseZooOpenedNewFiles": { + "label": "Chiudi automaticamente i nuovi file creati", + "description": "Quando abilitata, le schede dei file che Zoo ha creato (invece di modificare) vengono chiuse automaticamente dopo l'accettazione della modifica." } }, "skills": { diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index 42f24324b6..a4ff3933b1 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -985,6 +985,18 @@ "label": "チャットのフォントサイズ", "description": "Zoo Code チャットのフォントサイズ(ピクセル単位)を設定します。VS Code のフォントサイズに合わせるには既定値のままにしてください。", "reset": "VS Code の既定値を使用" + }, + "autoCloseZooOpenedFiles": { + "label": "Zooが開いたファイルを自動的に閉じる", + "description": "有効にすると、Zooがdiffのために一時的に開いたエディタタブが、変更を承認または拒否した後に自動的に閉じられます。編集前にユーザーがすでに開いていたタブは、自動的に閉じられることはありません。" + }, + "autoCloseZooOpenedFilesAfterUserEdited": { + "label": "ユーザー操作後も自動的に閉じる", + "description": "有効にすると、ユーザーが編集セッション中にファイルまたはdiffをクリックまたは入力した場合でも、Zooが開いたタブが閉じられます。「Zooが開いたファイルを自動的に閉じる」が無効の場合は効果がありません。" + }, + "autoCloseZooOpenedNewFiles": { + "label": "新しく作成されたファイルを自動的に閉じる", + "description": "有効にすると、Zooが作成した(変更ではなく)ファイルのタブも、変更を承認した後に自動的に閉じられます。" } }, "skills": { diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index 9f19e30461..03bb45930d 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -985,6 +985,18 @@ "label": "채팅 글꼴 크기", "description": "Zoo Code 채팅의 글꼴 크기(픽셀)를 설정합니다. VS Code 글꼴 크기에 맞추려면 기본값으로 두세요.", "reset": "VS Code 기본값 사용" + }, + "autoCloseZooOpenedFiles": { + "label": "Zoo가 열었던 파일 자동 닫기", + "description": "활성화 시, Zoo가 diff를 위해 일시적으로 열었던 편집기 탭이 변경 사항을 승인하거나 거부한 후 자동으로 닫힙니다. 편집 전에 사용자가 이미 열어 둔 탭은 자동으로 닫히지 않습니다." + }, + "autoCloseZooOpenedFilesAfterUserEdited": { + "label": "사용자 상호작용 후에도 자동 닫기", + "description": "활성화 시, 사용자가 편집 세션 중에 파일이나 diff를 클릭하거나 입력했더라도 Zoo가 열었던 탭이 닫힙니다. 'Zoo가 열었던 파일 자동 닫기'가 비활성화된 경우에는 효과가 없습니다." + }, + "autoCloseZooOpenedNewFiles": { + "label": "새로 만든 파일 자동 닫기", + "description": "활성화 시, Zoo가 수정한 것이 아니라 새로 만든 파일의 탭도 변경 사항을 승인한 후 자동으로 닫힙니다." } }, "skills": { diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index bdca878318..f8d0be96a8 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -985,6 +985,18 @@ "label": "Lettergrootte van chat", "description": "Stel de lettergrootte (in pixels) in voor de Zoo Code-chat. Laat op de standaardwaarde staan om overeen te komen met de lettergrootte van VS Code.", "reset": "VS Code-standaard gebruiken" + }, + "autoCloseZooOpenedFiles": { + "label": "Automatisch sluiten van door Zoo geopende bestanden", + "description": "Wanneer ingeschakeld, worden editor-tabbladen die Zoo tijdelijk heeft geopend voor een diff automatisch gesloten nadat de wijziging is geaccepteerd of geweigerd. Tabbladen die de gebruiker al had geopend voor de bewerking worden nooit automatisch gesloten." + }, + "autoCloseZooOpenedFilesAfterUserEdited": { + "label": "Automatisch sluiten ook na gebruikersinteractie", + "description": "Wanneer ingeschakeld, worden door Zoo geopende tabbladen gesloten, zelfs als de gebruiker tijdens de bewerkingssessie in het bestand of de diff heeft geklikt of getypt. Heeft geen effect als 'Automatisch sluiten van door Zoo geopende bestanden' is uitgeschakeld." + }, + "autoCloseZooOpenedNewFiles": { + "label": "Automatisch sluiten van nieuw gemaakte bestanden", + "description": "Wanneer ingeschakeld, worden tabbladen van bestanden die Zoo heeft aangemaakt (in plaats van gewijzigd) ook automatisch gesloten nadat de wijziging is geaccepteerd." } }, "skills": { diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index 43d76ad8ce..4dad5183a5 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -985,6 +985,18 @@ "label": "Rozmiar czcionki czatu", "description": "Ustaw rozmiar czcionki (w pikselach) dla czatu Zoo Code. Pozostaw wartość domyślną, aby dopasować ją do rozmiaru czcionki VS Code.", "reset": "Użyj wartości domyślnej VS Code" + }, + "autoCloseZooOpenedFiles": { + "label": "Automatycznie zamykaj pliki otwarte przez Zoo", + "description": "Gdy wlaczone, karty edytora, ktore Zoo otworzylo tymczasowo dla diffa, sa automatycznie zamykane po zaakceptowaniu lub odrzuceniu zmiany. Karty, ktore uzytkownik mial juz otwarte przed edycja, nigdy nie sa automatycznie zamykane." + }, + "autoCloseZooOpenedFilesAfterUserEdited": { + "label": "Automatycznie zamykaj rowniez po interakcji uzytkownika", + "description": "Gdy wlaczone, karty otwarte przez Zoo sa zamykane nawet jesli uzytkownik kliknal lub pisal w pliku lub diffie podczas sesji edycji. Nie ma efektu, jesli 'Automatycznie zamykaj pliki otwarte przez Zoo' jest wylaczone." + }, + "autoCloseZooOpenedNewFiles": { + "label": "Automatycznie zamykaj nowo utworzone pliki", + "description": "Gdy wlaczone, karty plikow, ktore Zoo utworzylo (zamiast zmodyfikowalo), sa rowniez automatycznie zamykane po zaakceptowaniu zmiany." } }, "skills": { diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index 4ae7fd3a20..33800d023f 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -985,6 +985,18 @@ "label": "Tamanho da fonte do chat", "description": "Defina o tamanho da fonte (em pixels) do chat do Zoo Code. Deixe no padrão para corresponder ao tamanho da fonte do VS Code.", "reset": "Usar o padrão do VS Code" + }, + "autoCloseZooOpenedFiles": { + "label": "Fechar automaticamente arquivos abertos pelo Zoo", + "description": "Quando ativada, as abas do editor que o Zoo abriu temporariamente para um diff sao fechadas automaticamente apos aceitar ou rejeitar a modificacao. As abas que o usuario ja tinha abertas antes da edicao nunca sao fechadas automaticamente." + }, + "autoCloseZooOpenedFilesAfterUserEdited": { + "label": "Fechar automaticamente mesmo apos interacao do usuario", + "description": "Quando ativada, as abas abertas pelo Zoo sao fechadas mesmo que o usuario tenha clicado ou digitado no arquivo ou diff durante a sessao de edicao. Nao tem efeito se 'Fechar automaticamente arquivos abertos pelo Zoo' estiver desativada." + }, + "autoCloseZooOpenedNewFiles": { + "label": "Fechar automaticamente novos arquivos criados", + "description": "Quando ativada, as abas de arquivos que o Zoo criou (em vez de modificar) tambem sao fechadas automaticamente apos aceitar a modificacao." } }, "skills": { diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index c636cb3523..3e96d15848 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -985,6 +985,18 @@ "label": "Размер шрифта чата", "description": "Задайте размер шрифта (в пикселях) для чата Zoo Code. Оставьте значение по умолчанию, чтобы соответствовать размеру шрифта VS Code.", "reset": "Использовать значение по умолчанию VS Code" + }, + "autoCloseZooOpenedFiles": { + "label": "Автоматически закрывать файлы, открытые Zoo", + "description": "Если включено, вкладки редактора, которые Zoo временно открыл для diff, автоматически закрываются после принятия или отклонения изменения. Вкладки, которые пользователь открыл до редактирования, никогда не закрываются автоматически." + }, + "autoCloseZooOpenedFilesAfterUserEdited": { + "label": "Автоматически закрывать даже после взаимодействия пользователя", + "description": "Если включено, вкладки, открытые Zoo, закрываются, даже если пользователь щёлкнул или печатал в файле или diff во время сеанса редактирования. Не действует, если параметр «Автоматически закрывать файлы, открытые Zoo» отключён." + }, + "autoCloseZooOpenedNewFiles": { + "label": "Автоматически закрывать новые созданные файлы", + "description": "Если включено, вкладки файлов, которые Zoo создал (а не изменил), также автоматически закрываются после принятия изменения." } }, "skills": { diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index 7e749e751c..22fc09e2d8 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -985,6 +985,18 @@ "label": "Sohbet yazı tipi boyutu", "description": "Zoo Code sohbeti için yazı tipi boyutunu (piksel cinsinden) ayarlayın. VS Code yazı tipi boyutuyla eşleşmesi için varsayılan değerde bırakın.", "reset": "VS Code varsayılanını kullan" + }, + "autoCloseZooOpenedFiles": { + "label": "Zoo tarafindan acilan dosyalari otomatik kapat", + "description": "Etkinlestirildiginde, Zoo'nun bir diff icin gecici olarak actigi duzenleyici sekmeleri, degisiklik kabul edildikten veya reddedildikten sonra otomatik olarak kapatilir. Kullanicinin duzenleme oncesinde zaten acik oldugu sekmeler hicbir zaman otomatik olarak kapatilmaz." + }, + "autoCloseZooOpenedFilesAfterUserEdited": { + "label": "Kullanici etkilesiminden sonra da otomatik kapat", + "description": "Etkinlestirildiginde, kullanici duzenleme oturumu sirasinda dosyaya veya diff'e tiklasa ya da yazsa bile Zoo tarafindan acilan sekmeler kapatilir. 'Zoo tarafindan acilan dosyalari otomatik kapat' devre disi ise etkisi yoktur." + }, + "autoCloseZooOpenedNewFiles": { + "label": "Yeni olusturulan dosyalari otomatik kapat", + "description": "Etkinlestirildiginde, Zoo'nun olusturdugu (degistirmedigi) dosyalarin sekmeleri de degisiklik kabul edildikten sonra otomatik olarak kapatilir." } }, "skills": { diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index 7d35057fba..793cee1145 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -985,6 +985,18 @@ "label": "Cỡ chữ trò chuyện", "description": "Đặt cỡ chữ (tính bằng pixel) cho cuộc trò chuyện Zoo Code. Để ở giá trị mặc định để khớp với cỡ chữ của VS Code.", "reset": "Dùng mặc định của VS Code" + }, + "autoCloseZooOpenedFiles": { + "label": "Tự động đóng các tệp do Zoo mở", + "description": "Khi bật, các tab trình soạn thảo mà Zoo mở tạm thời để hiển thị diff sẽ tự động đóng sau khi chấp nhận hoặc từ chối thay đổi. Các tab mà người dùng đã mở trước khi chỉnh sửa sẽ không bao giờ bị tự động đóng." + }, + "autoCloseZooOpenedFilesAfterUserEdited": { + "label": "Tự động đóng ngay cả sau khi người dùng tương tác", + "description": "Khi bật, các tab do Zoo mở sẽ đóng ngay cả khi người dùng đã nhấn chuột hoặc gõ phím vào tệp hoặc diff trong phiên chỉnh sửa. Không có tác dụng nếu 'Tự động đóng các tệp do Zoo mở' bị tắt." + }, + "autoCloseZooOpenedNewFiles": { + "label": "Tự động đóng các tệp mới tạo", + "description": "Khi bật, các tab của các tệp mà Zoo đã tạo (thay vì chỉnh sửa) cũng sẽ tự động đóng sau khi chấp nhận thay đổi." } }, "skills": { diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index 7f82234c21..1e4deb8f25 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -985,6 +985,18 @@ "label": "聊天字体大小", "description": "设置 Zoo Code 聊天的字体大小(以像素为单位)。保留默认值以匹配 VS Code 的字体大小。", "reset": "使用 VS Code 默认值" + }, + "autoCloseZooOpenedFiles": { + "label": "自动关闭 Zoo 打开的文件", + "description": "启用后,Zoo 为 diff 临时打开的编辑器标签页将在接受或拒绝更改后自动关闭。用户在编辑前已打开的标签页不会被自动关闭。" + }, + "autoCloseZooOpenedFilesAfterUserEdited": { + "label": "用户操作后也自动关闭", + "description": "启用后,即使用户在编辑会话中点击或输入了文件或 diff,Zoo 打开的标签页也会被关闭。若「自动关闭 Zoo 打开的文件」已禁用,则此选项无效。" + }, + "autoCloseZooOpenedNewFiles": { + "label": "自动关闭新建的文件", + "description": "启用后,Zoo 新建(而非修改)的文件标签页在接受更改后也会自动关闭。" } }, "skills": { diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index 47e66645e2..ea0e71225d 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -112,6 +112,18 @@ "label": "聊天字型大小", "description": "設定 Zoo Code 聊天的字型大小(以像素為單位)。保留預設值以符合 VS Code 的字型大小。", "reset": "使用 VS Code 預設值" + }, + "autoCloseZooOpenedFiles": { + "label": "自動關閉 Zoo 開啟的檔案", + "description": "啟用後,Zoo 為 diff 暫時開啟的編輯器分頁將在接受或拒絕變更後自動關閉。使用者在編輯前已開啟的分頁不會被自動關閉。" + }, + "autoCloseZooOpenedFilesAfterUserEdited": { + "label": "使用者操作後也自動關閉", + "description": "啟用後,即使使用者在編輯工作階段中點擊或輸入了檔案或 diff,Zoo 開啟的分頁也會被關閉。若「自動關閉 Zoo 開啟的檔案」已停用,則此選項無效。" + }, + "autoCloseZooOpenedNewFiles": { + "label": "自動關閉新建立的檔案", + "description": "啟用後,Zoo 新建(而非修改)的檔案分頁在接受變更後也會自動關閉。" } }, "prompts": {