diff --git a/.gitignore b/.gitignore index d3cec2edb..5bc849efa 100644 --- a/.gitignore +++ b/.gitignore @@ -31,4 +31,11 @@ dist # IDE .idea -src/.vscode/ \ No newline at end of file +src/.vscode/ + +# AI MCPs and settings +.serena/ +.cursor/ +.taskmaster/ +.claude/ +.kiro/ \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 678b7c2c8..aa8d1ba1c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "cross-spawn": "^7.0.3", "i18next": "^23.11.4", "lodash.isequal": "^4.5.0", + "minimatch": "^9.0.3", "module-alias": "^2.2.3", "moment": "^2.30.1", "react-lottie-loader": "^1.1.0", diff --git a/package.json b/package.json index 8a8c7579d..f2da1827b 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,9 @@ "@views": "dist/src/views", "@vscommands": "dist/src/vscommands" }, - "activationEvents": [], + "activationEvents": [ + "onStartupFinished" + ], "contributes": { "commands": [ { @@ -76,6 +78,26 @@ "light": "resources/sidebar/icons/light/rocket.svg" }, "title": "autokitteh: Run Project" + }, + { + "command": "autokitteh.autoSave.enable", + "title": "AutoKitteh: Enable Auto-Save" + }, + { + "command": "autokitteh.autoSave.disable", + "title": "AutoKitteh: Disable Auto-Save" + }, + { + "command": "autokitteh.autoSave.cancelPending", + "title": "AutoKitteh: Cancel Pending Saves" + }, + { + "command": "autokitteh.autoSave.flushAll", + "title": "AutoKitteh: Flush All Saves" + }, + { + "command": "autokitteh.autoSave.showLogs", + "title": "AutoKitteh: Show Auto-Save Logs" } ], "configuration": { @@ -135,6 +157,58 @@ "default": false, "description": "Is extension ON", "type": "hidden" + }, + "autokitteh.autoSave.enabled": { + "type": "boolean", + "default": true, + "description": "Enable AutoKitteh auto-save for workspace files." + }, + "autokitteh.autoSave.mode": { + "type": "string", + "enum": [ + "inherit", + "afterDelay", + "onFocusChange", + "onWindowChange" + ], + "default": "afterDelay", + "description": "Auto-save mode." + }, + "autokitteh.autoSave.delayMs": { + "type": "number", + "minimum": 100, + "maximum": 10000, + "default": 750, + "description": "Debounce delay in milliseconds for afterDelay mode." + }, + "autokitteh.autoSave.includeGlobs": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "Glob patterns to include for auto-save scope." + }, + "autokitteh.autoSave.excludeGlobs": { + "type": "array", + "items": { + "type": "string" + }, + "default": [ + "**/.git/**", + "**/node_modules/**", + "**/.next/**", + "**/dist/**", + "**/.cache/**" + ], + "description": "Glob patterns to exclude from auto-save." + }, + "autokitteh.autoSave.maxFileSizeKb": { + "type": "number", + "minimum": 1, + "maximum": 8192, + "default": 49, + "description": "Skip or adjust debounce for files larger than this size." } }, "title": "autokitteh" @@ -293,6 +367,7 @@ "cross-spawn": "^7.0.3", "i18next": "^23.11.4", "lodash.isequal": "^4.5.0", + "minimatch": "^9.0.3", "module-alias": "^2.2.3", "moment": "^2.30.1", "react-lottie-loader": "^1.1.0", diff --git a/src/autokitteh b/src/autokitteh index 37395422a..e7e1da86f 160000 --- a/src/autokitteh +++ b/src/autokitteh @@ -1 +1 @@ -Subproject commit 37395422abf50551952702cab81c2e4b856c2ea7 +Subproject commit e7e1da86fa63c015fd83db7bf1419fb18909dad9 diff --git a/src/extension.ts b/src/extension.ts index 32b724bb3..d5815baf3 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -9,6 +9,7 @@ import { AppStateHandler } from "@controllers/utilities/appStateHandler"; import eventEmitter from "@eventEmitter"; import { translate } from "@i18n"; import { AuthService, LoggerService, OrganizationsService } from "@services"; +import { AutoSaveService, DebounceManager, ProjectMapper } from "@services/autoSave"; import { Organization } from "@type/models"; import { SidebarTreeItem } from "@type/views"; import { ValidateURL, WorkspaceConfig } from "@utilities"; @@ -19,6 +20,7 @@ import { openBaseURLInputDialog, openWalkthrough } from "@vscommands/walkthrough let sidebarController: SidebarController | null = null; let tabsManager: TabsManagerController | null = null; let organizations: Organization[] | undefined = undefined; +let autoSaveService: AutoSaveService | null = null; export async function activate(context: ExtensionContext) { context.subscriptions.push( @@ -327,6 +329,43 @@ export async function activate(context: ExtensionContext) { return; } commands.executeCommand(vsCommands.enable); + + const debounceManager = new DebounceManager(10); + const projectMapper = new ProjectMapper(); + const statusBar = window.createStatusBarItem(); + const outputChannel = window.createOutputChannel("AutoKitteh Auto-Save"); + + autoSaveService = new AutoSaveService(debounceManager, projectMapper, statusBar, outputChannel); + + context.subscriptions.push( + commands.registerCommand("autokitteh.autoSave.enable", async () => { + await workspace.getConfiguration("autokitteh.autoSave").update("enabled", true, ConfigurationTarget.Workspace); + }) + ); + + context.subscriptions.push( + commands.registerCommand("autokitteh.autoSave.disable", async () => { + await workspace.getConfiguration("autokitteh.autoSave").update("enabled", false, ConfigurationTarget.Workspace); + }) + ); + + context.subscriptions.push( + commands.registerCommand("autokitteh.autoSave.cancelPending", async () => { + await autoSaveService?.cancelPending(); + }) + ); + + context.subscriptions.push( + commands.registerCommand("autokitteh.autoSave.flushAll", async () => { + await autoSaveService?.flushAll(); + }) + ); + + context.subscriptions.push( + commands.registerCommand("autokitteh.autoSave.showLogs", () => { + autoSaveService?.showLogs(); + }) + ); } export function deactivate() { if (sidebarController) { @@ -335,4 +374,7 @@ export function deactivate() { if (tabsManager) { tabsManager.dispose(); } + if (autoSaveService) { + autoSaveService.dispose(); + } } diff --git a/src/services/autoSave/autoSaveService.ts b/src/services/autoSave/autoSaveService.ts new file mode 100644 index 000000000..55fd36999 --- /dev/null +++ b/src/services/autoSave/autoSaveService.ts @@ -0,0 +1,305 @@ +import { minimatch } from "minimatch"; +import * as vscode from "vscode"; + +import { DebounceManager } from "./debounceManager"; +import { ProjectMapper } from "./projectMapper"; + +type AutoSaveMode = "inherit" | "afterDelay" | "onFocusChange" | "onWindowChange"; + +export class AutoSaveService { + private debounce: DebounceManager; + private mapper: ProjectMapper; + private statusBar: vscode.StatusBarItem; + private outputChannel: vscode.OutputChannel; + private disposables: vscode.Disposable[] = []; + private notifiedReadonly = new Set(); + private notifiedUntitled = new Set(); + private notifiedLargeFile = new Set(); + private externalChangeShown = new Set(); + private willSaveTriggerCount = new Map(); + private lastVersions = new Map(); + + constructor( + debounce: DebounceManager, + mapper: ProjectMapper, + statusBar: vscode.StatusBarItem, + outputChannel: vscode.OutputChannel + ) { + this.debounce = debounce; + this.mapper = mapper; + this.statusBar = statusBar; + this.outputChannel = outputChannel; + this.statusBar.text = "$(check) Saved"; + this.statusBar.show(); + + this.mapper.refreshFromConfig(); + this.registerEventHandlers(); + } + + private registerEventHandlers(): void { + this.disposables.push( + vscode.workspace.onDidChangeTextDocument((e) => this.onDidChangeTextDocument(e)), + vscode.workspace.onWillSaveTextDocument((e) => this.onWillSaveTextDocument(e)), + vscode.workspace.onDidSaveTextDocument((doc) => this.onDidSaveTextDocument(doc)), + vscode.workspace.onDidRenameFiles((e) => this.onDidRenameFiles(e)), + vscode.workspace.onDidChangeConfiguration((e) => this.onDidChangeConfiguration(e)), + vscode.window.onDidChangeActiveTextEditor((editor) => this.onDidChangeActiveTextEditor(editor)), + vscode.window.onDidChangeWindowState((state) => this.onDidChangeWindowState(state)), + vscode.workspace.createFileSystemWatcher("**/*").onDidChange((uri) => this.onExternalChange(uri)) + ); + } + + private onDidChangeTextDocument(e: vscode.TextDocumentChangeEvent): void { + if (!this.isEnabled() || e.contentChanges.length === 0) { + return; + } + + const doc = e.document; + if (!this.shouldAutoSave(doc)) { + return; + } + + const mode = this.getMode(); + if (mode !== "afterDelay" && mode !== "inherit") { + return; + } + + const delayMs = this.getDelayMs(); + this.scheduleAutoSave(doc, delayMs); + } + + private onWillSaveTextDocument(e: vscode.TextDocumentWillSaveEvent): void { + const key = e.document.uri.toString(); + const currentVersion = e.document.version; + const lastVersion = this.lastVersions.get(key); + + if (lastVersion !== undefined && currentVersion !== lastVersion) { + const count = this.willSaveTriggerCount.get(key) || 0; + if (count === 0) { + this.willSaveTriggerCount.set(key, 1); + this.lastVersions.set(key, currentVersion); + } else { + this.willSaveTriggerCount.delete(key); + } + } else { + this.lastVersions.set(key, currentVersion); + } + } + + private onDidSaveTextDocument(doc: vscode.TextDocument): void { + const key = doc.uri.toString(); + this.willSaveTriggerCount.delete(key); + this.lastVersions.delete(key); + this.updateStatusBar("saved"); + } + + private onDidRenameFiles(e: vscode.FileRenameEvent): void { + for (const file of e.files) { + this.debounce.cancel(file.oldUri); + this.debounce.cancel(file.newUri); + } + } + + private onDidChangeConfiguration(e: vscode.ConfigurationChangeEvent): void { + if (e.affectsConfiguration("autokitteh.autoSave") || e.affectsConfiguration("autokitteh.projectsPaths")) { + this.mapper.refreshFromConfig(); + } + + if (e.affectsConfiguration("autokitteh.autoSave.enabled")) { + if (!this.isEnabled()) { + this.debounce.dispose(); + } + } + } + + private onDidChangeActiveTextEditor(editor: vscode.TextEditor | undefined): void { + if (!this.isEnabled() || !editor) { + return; + } + + const mode = this.getMode(); + if (mode !== "onFocusChange" && mode !== "inherit") { + return; + } + + const doc = editor.document; + if (this.shouldAutoSave(doc) && doc.isDirty) { + this.performAutoSave(doc); + } + } + + private onDidChangeWindowState(state: vscode.WindowState): void { + if (!this.isEnabled() || state.focused) { + return; + } + + const mode = this.getMode(); + if (mode !== "onWindowChange" && mode !== "inherit") { + return; + } + + const editor = vscode.window.activeTextEditor; + if (editor && this.shouldAutoSave(editor.document) && editor.document.isDirty) { + this.performAutoSave(editor.document); + } + } + + private async onExternalChange(uri: vscode.Uri): Promise { + const key = uri.toString(); + if (this.externalChangeShown.has(key)) { + return; + } + + const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === key); + if (!doc || !doc.isDirty) { + return; + } + + this.externalChangeShown.add(key); + const choice = await vscode.window.showWarningMessage( + `File ${uri.fsPath} was changed externally.`, + "Reload", + "Keep Mine", + "Diff" + ); + + if (choice === "Reload") { + await vscode.commands.executeCommand("workbench.action.files.revert"); + } else if (choice === "Diff") { + await vscode.commands.executeCommand("workbench.files.action.compareWithSaved"); + } + } + + private scheduleAutoSave(doc: vscode.TextDocument, delayMs: number): void { + this.debounce.schedule(doc.uri, delayMs, async () => { + await this.performAutoSave(doc); + }); + } + + private async performAutoSave(doc: vscode.TextDocument): Promise { + if (!doc.isDirty) { + return; + } + + this.updateStatusBar("saving"); + + try { + await doc.save(); + this.updateStatusBar("saved"); + } catch (error) { + this.updateStatusBar("failed"); + this.log(`Failed to save ${doc.uri.fsPath}: ${error}`); + } + } + + private shouldAutoSave(doc: vscode.TextDocument): boolean { + if (doc.uri.scheme === "untitled") { + this.notifyOnce(this.notifiedUntitled, doc.uri, "Untitled files are not auto-saved."); + return false; + } + + if (doc.uri.scheme !== "file") { + return false; + } + + if (!this.mapper.isInScope(doc.uri)) { + return false; + } + + const config = vscode.workspace.getConfiguration("autokitteh.autoSave", doc.uri); + const includeGlobs = config.get("includeGlobs", []); + const excludeGlobs = config.get("excludeGlobs", []); + + if (includeGlobs.length > 0) { + const matched = includeGlobs.some((glob) => minimatch(doc.uri.fsPath, glob)); + if (!matched) { + return false; + } + } + + if (excludeGlobs.length > 0) { + const excluded = excludeGlobs.some((glob) => minimatch(doc.uri.fsPath, glob)); + if (excluded) { + return false; + } + } + + const maxSizeKb = config.get("maxFileSizeKb", 49); + const sizeKb = Buffer.byteLength(doc.getText(), "utf8") / 1024; + if (sizeKb > maxSizeKb) { + this.notifyOnce(this.notifiedLargeFile, doc.uri, `File ${doc.uri.fsPath} exceeds ${maxSizeKb}KB and is skipped.`); + return false; + } + + return true; + } + + private notifyOnce(cache: Set, uri: vscode.Uri, message: string): void { + const key = uri.toString(); + if (!cache.has(key)) { + cache.add(key); + vscode.window.showInformationMessage(message); + } + } + + private isEnabled(): boolean { + if (!vscode.workspace.isTrusted) { + return false; + } + + const config = vscode.workspace.getConfiguration("autokitteh.autoSave"); + return config.get("enabled", true); + } + + private getMode(): AutoSaveMode { + const config = vscode.workspace.getConfiguration("autokitteh.autoSave"); + return config.get("mode", "afterDelay"); + } + + private getDelayMs(): number { + const config = vscode.workspace.getConfiguration("autokitteh.autoSave"); + return config.get("delayMs", 750); + } + + private updateStatusBar(state: "saving" | "saved" | "failed"): void { + switch (state) { + case "saving": + this.statusBar.text = "$(sync~spin) Saving…"; + this.statusBar.tooltip = "Auto-saving file"; + break; + case "saved": + this.statusBar.text = "$(check) Saved"; + this.statusBar.tooltip = "File auto-saved successfully"; + break; + case "failed": + this.statusBar.text = "$(warning) Save failed"; + this.statusBar.tooltip = "Auto-save failed"; + break; + } + } + + private log(message: string): void { + this.outputChannel.appendLine(`[AutoSave] ${message}`); + } + + dispose(): void { + this.debounce.dispose(); + this.statusBar.dispose(); + this.outputChannel.dispose(); + this.disposables.forEach((d) => d.dispose()); + } + + async cancelPending(): Promise { + this.debounce.dispose(); + this.log("All pending saves cancelled."); + } + + async flushAll(): Promise { + await this.debounce.flushAll(); + this.log("All pending saves flushed."); + } + + showLogs(): void { + this.outputChannel.show(); + } +} diff --git a/src/services/autoSave/debounceManager.ts b/src/services/autoSave/debounceManager.ts new file mode 100644 index 000000000..2b3be0f40 --- /dev/null +++ b/src/services/autoSave/debounceManager.ts @@ -0,0 +1,71 @@ +import * as vscode from "vscode"; + +export class DebounceManager { + private timers = new Map(); + private pendingSaves = new Set(); + private readonly maxConcurrency: number; + + constructor(maxConcurrency: number = 10) { + this.maxConcurrency = maxConcurrency; + } + + schedule(uri: vscode.Uri, delayMs: number, fn: () => Promise): void { + const key = uri.toString(); + this.cancel(uri); + + const timer = setTimeout(async () => { + if (this.pendingSaves.size >= this.maxConcurrency) { + this.schedule(uri, delayMs, fn); + return; + } + + this.pendingSaves.add(key); + this.timers.delete(key); + + try { + await fn(); + } finally { + this.pendingSaves.delete(key); + } + }, delayMs); + + this.timers.set(key, timer); + } + + cancel(uri: vscode.Uri): void { + const key = uri.toString(); + const timer = this.timers.get(key); + if (timer) { + clearTimeout(timer); + this.timers.delete(key); + } + } + + async flushAll(): Promise { + const pending = Array.from(this.timers.keys()); + for (const key of pending) { + const timer = this.timers.get(key); + if (timer) { + clearTimeout(timer); + } + } + this.timers.clear(); + + while (this.pendingSaves.size > 0) { + await new Promise((resolve) => setTimeout(resolve, 50)); + } + } + + hasPending(uri: vscode.Uri): boolean { + const key = uri.toString(); + return this.timers.has(key) || this.pendingSaves.has(key); + } + + dispose(): void { + for (const timer of this.timers.values()) { + clearTimeout(timer); + } + this.timers.clear(); + this.pendingSaves.clear(); + } +} diff --git a/src/services/autoSave/index.ts b/src/services/autoSave/index.ts new file mode 100644 index 000000000..2717c998e --- /dev/null +++ b/src/services/autoSave/index.ts @@ -0,0 +1,3 @@ +export { AutoSaveService } from "@services/autoSave/autoSaveService"; +export { DebounceManager } from "@services/autoSave/debounceManager"; +export { ProjectMapper } from "@services/autoSave/projectMapper"; diff --git a/src/services/autoSave/projectMapper.ts b/src/services/autoSave/projectMapper.ts new file mode 100644 index 000000000..744310521 --- /dev/null +++ b/src/services/autoSave/projectMapper.ts @@ -0,0 +1,42 @@ +import * as path from "path"; +import * as vscode from "vscode"; + +export class ProjectMapper { + private scopePaths: string[] = []; + + refreshFromConfig(): void { + const config = vscode.workspace.getConfiguration("autokitteh"); + const projectsPathsRaw = config.get("projectsPaths", "{}"); + + try { + const projectsPathsObj = JSON.parse(projectsPathsRaw); + this.scopePaths = Object.values(projectsPathsObj).filter((p): p is string => typeof p === "string"); + } catch { + this.scopePaths = []; + } + } + + isInScope(uri: vscode.Uri): boolean { + if (this.scopePaths.length === 0) { + return true; + } + + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders) { + return false; + } + + const filePath = uri.fsPath; + + for (const workspaceFolder of workspaceFolders) { + for (const projectPath of this.scopePaths) { + const fullPath = path.join(workspaceFolder.uri.fsPath, projectPath); + if (filePath.startsWith(fullPath)) { + return true; + } + } + } + + return false; + } +} diff --git a/src/services/index.ts b/src/services/index.ts index dfecc48b2..75fe45091 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -8,3 +8,4 @@ export { ConnectionsService } from "@services/connections.service"; export { IntegrationsService } from "@services/integrations.service"; export { OrganizationsService } from "@services/organizations.service"; export { AuthService } from "@services/auth.service"; +export { AutoSaveService, DebounceManager, ProjectMapper } from "@services/autoSave"; diff --git a/tests/debounceManager.test.ts b/tests/debounceManager.test.ts new file mode 100644 index 000000000..0006cf3a5 --- /dev/null +++ b/tests/debounceManager.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import * as vscode from "vscode"; + +import { DebounceManager } from "../src/services/autoSave/debounceManager"; + +vi.mock("vscode"); + +describe("DebounceManager", () => { + let manager: DebounceManager; + let mockUri: vscode.Uri; + + beforeEach(() => { + manager = new DebounceManager(2); + mockUri = { toString: () => "file:///test.ts" } as vscode.Uri; + vi.useFakeTimers(); + }); + + it("should schedule a task", async () => { + const fn = vi.fn(); + manager.schedule(mockUri, 100, fn); + + expect(fn).not.toHaveBeenCalled(); + vi.advanceTimersByTime(100); + await vi.runAllTimersAsync(); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it("should cancel a scheduled task", async () => { + const fn = vi.fn(); + manager.schedule(mockUri, 100, fn); + manager.cancel(mockUri); + + vi.advanceTimersByTime(100); + await vi.runAllTimersAsync(); + expect(fn).not.toHaveBeenCalled(); + }); + + it("should enforce at-most-one pending per document", async () => { + const fn1 = vi.fn(); + const fn2 = vi.fn(); + + manager.schedule(mockUri, 50, fn1); + manager.schedule(mockUri, 100, fn2); + + vi.advanceTimersByTime(150); + await vi.runAllTimersAsync(); + + expect(fn1).not.toHaveBeenCalled(); + expect(fn2).toHaveBeenCalledTimes(1); + }); + + it("should respect max concurrency", async () => { + const uri1 = { toString: () => "file:///test1.ts" } as vscode.Uri; + const uri2 = { toString: () => "file:///test2.ts" } as vscode.Uri; + const uri3 = { toString: () => "file:///test3.ts" } as vscode.Uri; + + const fn1 = vi.fn(async (): Promise => { + await new Promise((resolve) => setTimeout(resolve, 200)); + }); + const fn2 = vi.fn(async (): Promise => { + await new Promise((resolve) => setTimeout(resolve, 200)); + }); + const fn3 = vi.fn(); + + manager.schedule(uri1, 10, fn1); + manager.schedule(uri2, 10, fn2); + manager.schedule(uri3, 10, fn3); + + vi.advanceTimersByTime(10); + await vi.runAllTimersAsync(); + + expect(fn1).toHaveBeenCalledTimes(1); + expect(fn2).toHaveBeenCalledTimes(1); + }); + + it("should flush all pending tasks", async () => { + const fn = vi.fn(); + manager.schedule(mockUri, 100, fn); + + await manager.flushAll(); + expect(fn).not.toHaveBeenCalled(); + }); + + it("should dispose all timers", () => { + const fn = vi.fn(); + manager.schedule(mockUri, 100, fn); + + manager.dispose(); + vi.advanceTimersByTime(100); + + expect(fn).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/saveLoopGuard.test.ts b/tests/saveLoopGuard.test.ts new file mode 100644 index 000000000..bfe252718 --- /dev/null +++ b/tests/saveLoopGuard.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, beforeEach } from "vitest"; + +describe("Save Loop Guard", () => { + let willSaveTriggerCount: Map; + let lastVersions: Map; + + beforeEach(() => { + willSaveTriggerCount = new Map(); + lastVersions = new Map(); + }); + + const simulateWillSave = (key: string, currentVersion: number) => { + const lastVersion = lastVersions.get(key); + + if (lastVersion !== undefined && currentVersion !== lastVersion) { + const count = willSaveTriggerCount.get(key) || 0; + if (count === 0) { + willSaveTriggerCount.set(key, 1); + lastVersions.set(key, currentVersion); + } else { + willSaveTriggerCount.delete(key); + } + } else { + lastVersions.set(key, currentVersion); + } + }; + + const simulateDidSave = (key: string) => { + willSaveTriggerCount.delete(key); + lastVersions.delete(key); + }; + + it("should allow at most one re-trigger after formatter edits", () => { + const key = "file:///test.ts"; + + simulateWillSave(key, 1); + expect(willSaveTriggerCount.get(key)).toBeUndefined(); + expect(lastVersions.get(key)).toBe(1); + + simulateWillSave(key, 2); + expect(willSaveTriggerCount.get(key)).toBe(1); + expect(lastVersions.get(key)).toBe(2); + + simulateWillSave(key, 3); + expect(willSaveTriggerCount.get(key)).toBeUndefined(); + expect(lastVersions.get(key)).toBe(2); + + simulateDidSave(key); + expect(willSaveTriggerCount.get(key)).toBeUndefined(); + expect(lastVersions.get(key)).toBeUndefined(); + }); + + it("should not re-trigger if version does not change", () => { + const key = "file:///test.ts"; + + simulateWillSave(key, 1); + expect(willSaveTriggerCount.get(key)).toBeUndefined(); + + simulateWillSave(key, 1); + expect(willSaveTriggerCount.get(key)).toBeUndefined(); + expect(lastVersions.get(key)).toBe(1); + }); + + it("should reset state after save completes", () => { + const key = "file:///test.ts"; + + simulateWillSave(key, 1); + simulateWillSave(key, 2); + expect(willSaveTriggerCount.get(key)).toBe(1); + + simulateDidSave(key); + expect(willSaveTriggerCount.get(key)).toBeUndefined(); + expect(lastVersions.get(key)).toBeUndefined(); + }); +});