diff --git a/apps/buddy/src/app/shell/DesktopShell.vue b/apps/buddy/src/app/shell/DesktopShell.vue index 05f3a9038..c1569b39d 100644 --- a/apps/buddy/src/app/shell/DesktopShell.vue +++ b/apps/buddy/src/app/shell/DesktopShell.vue @@ -82,7 +82,7 @@ function showTasks() { @toggle-sidebar="togglePrimarySidebar" />
- +
Promise>> +interface Entry { + resource: ResourceRef + incarnation: string + task: SaveTask + editedAt: number + savingVersion: number + recovered: boolean + failed: boolean + pauses: Set +} + +/** Adapts existing debounce and working-copy saves; never owns file contents or writes. */ +export class WorkingCopyAutoSave implements FileAutoSaveController { + readonly #entries = new Map() + readonly #changes = new Emitter(() => console.error('AUTO_SAVE_OBSERVER_FAILED')) + readonly #subscriptions: (() => void)[] + #started = false + #disposed = false + + constructor(readonly copies: WorkingCopyService, readonly configuration: ConfigurationService) { + for (const copy of copies.copies.values()) this.#entry(copy).recovered = copy.dirty + this.#subscriptions = [ + copies.onDidChange(event => this.#changed(event)).dispose, + configuration.onDidChangeEffective((event) => { + if (!event.keys.includes('files.autoSave')) + return + for (const entry of this.#entries.values()) { + entry.task.cancel() + entry.editedAt = Date.now() + this.#reconcile(entry) + } + this.#changes.fire() + }).dispose, + ] + } + + get enabled(): boolean { return this.configuration.get('files.autoSave') === 'afterDelay' } + + setEnabled(enabled: boolean): void { + if (!this.#disposed) + this.configuration.set('files.autoSave', enabled ? 'afterDelay' : 'off') + } + + subscribe(listener: () => void): () => void { return this.#changes.event(listener).dispose } + + start(): void { + if (this.#disposed || this.#started) + return + this.#started = true + for (const entry of this.#entries.values()) this.#reconcile(entry) + this.#changes.fire() + } + + status(resource: ResourceRef): FileAutoSaveStatus { + const copy = this.copies.get(resource) + if (!copy) + return 'loading' + const entry = this.#entries.get(copy.incarnation) + if (copy.saving) + return copy.contentVersion > (entry?.savingVersion ?? copy.contentVersion) ? 'saving-dirty' : 'saving' + if (copy.conflict) + return 'conflict' + if (entry?.failed || copy.error === 'FILE_SAVE_FAILED') + return 'failed' + if (entry?.recovered && copy.dirty) + return 'recovered' + if (copy.blocked) + return 'blocked' + if (entry?.pauses.size) + return 'paused' + if (copy.loading || !copy.etag) + return 'loading' + if (!this.enabled) + return 'off' + return entry?.task.isPending.value ? 'waiting' : 'idle' + } + + /** A close plan holds this pause until it is cancelled or its cleanup completes. */ + pause(resource: ResourceRef): () => void { + const copy = this.copies.get(resource) + if (!copy || this.#disposed) + return () => {} + const entry = this.#entry(copy) + const token = Symbol('close') + entry.pauses.add(token) + entry.task.cancel() + this.#changes.fire() + return () => { + if (!entry.pauses.delete(token) || this.#disposed) + return + entry.editedAt = Date.now() + this.#reconcile(entry) + this.#changes.fire() + } + } + + async prepareClose(resource: ResourceRef, prepare: () => Promise): Promise { + const resume = this.pause(resource) + try { + await this.copies.settle(value => resourceKey(value) === resourceKey(resource)) + const decision = await prepare() + if (!decision) { + resume() + return false + } + const plan = decision === true ? {} : decision + return { + ...plan, + cancel: () => { + try { + plan.cancel?.() + } + finally { + resume() + } + }, + complete: async (commit) => { + try { + await plan.complete?.(commit) + } + finally { + resume() + } + }, + } + } + catch (error) { + resume() + throw error + } + } + + /** Explicit user action can resume recovered/failed content, never bypass guards. */ + async retry(resource: ResourceRef): Promise { + const copy = this.copies.get(resource) + if (!copy || this.#disposed || !this.#started) + return + const entry = this.#entry(copy) + if (copy.loading || copy.saving || copy.blocked || copy.conflict || !copy.etag || entry.pauses.size || (copy.error && copy.error !== 'FILE_SAVE_FAILED')) + return + entry.task.cancel() + entry.recovered = false + entry.failed = false + await this.#save(entry, true) + } + + dispose(): void { + if (this.#disposed) + return + this.#disposed = true + this.#subscriptions.forEach(stop => stop()) + for (const entry of this.#entries.values()) entry.task.cancel() + this.#entries.clear() + this.#changes.dispose() + } + + #entry(copy: WorkingCopy): Entry { + let entry = this.#entries.get(copy.incarnation) + if (!entry) { + const held: Entry = { + resource: copy.resource, + incarnation: copy.incarnation, + task: undefined!, + editedAt: Date.now(), + savingVersion: copy.contentVersion, + recovered: false, + failed: false, + pauses: new Set(), + } + held.task = useDebounceFn(() => this.#save(held), () => Math.max(0, 1000 - (Date.now() - held.editedAt))) + this.#entries.set(copy.incarnation, held) + entry = held + } + return entry + } + + #current(entry: Entry): WorkingCopy | undefined { + if (this.#disposed || this.#entries.get(entry.incarnation) !== entry) + return + const copy = this.copies.get(entry.resource) + return copy?.incarnation === entry.incarnation ? copy : undefined + } + + #changed(event: WorkingCopyChange): void { + if (this.#disposed || event.copy.resource.scheme !== 'file') + return + const entry = this.#entry(event.copy) + if (event.kind === 'released') { + entry.task.cancel() + this.#entries.delete(entry.incarnation) + } + else { + entry.resource = event.copy.resource + if (event.kind === 'restored') + entry.recovered = event.copy.dirty + if (event.kind === 'save-started') + entry.savingVersion = event.copy.contentVersion + if (event.kind === 'save-failed') + entry.failed = true + if (event.kind === 'saved' || event.kind === 'conflict-resolved') + entry.failed = false + if (event.kind === 'edited' || event.kind === 'moved' || (event.kind === 'guarded' && !event.copy.blocked)) { + entry.task.cancel() + entry.editedAt = Date.now() + } + if (!event.copy.dirty || event.kind === 'discarded') { + entry.recovered = false + entry.failed = false + } + this.#reconcile(entry) + } + this.#changes.fire() + } + + #reconcile(entry: Entry): void { + const copy = this.#current(entry) + if (!copy || copy.resource.scheme !== 'file' || !this.#started || !this.enabled || !copy.dirty || copy.loading || !copy.etag || copy.blocked || copy.error || copy.conflict || entry.failed || entry.recovered || entry.pauses.size) { + entry.task.cancel() + return + } + // Edits during an in-flight save retain their own deadline. The saved event + // rechecks dirty state even when their debounce already expired meanwhile. + if (!copy.saving && !entry.task.isPending.value) + void entry.task().catch(() => this.#fail(entry)) + else if (copy.saving && !entry.task.isPending.value && Date.now() - entry.editedAt < 1000) + void entry.task().catch(() => this.#fail(entry)) + } + + async #save(entry: Entry, explicit = false): Promise { + const copy = this.#current(entry) + if (!copy || !this.#started || (!explicit && !this.enabled) || !copy.dirty || copy.loading || copy.saving || !copy.etag || copy.blocked || copy.conflict || entry.pauses.size || (!explicit && (copy.error || entry.failed || entry.recovered))) { + if (!this.#disposed) + this.#changes.fire() + return + } + try { + const result = await this.copies.save(copy.resource) + if (!this.#current(entry)) + return + switch (result.status) { + case 'failed': + if (this.#current(entry)?.error === 'FILE_SAVE_FAILED') + this.#fail(entry) + break + case 'saved': + case 'unchanged': + this.#reconcile(entry) + break + case 'conflict': + case 'unavailable': + // State events cancel invalid tasks. Do not cancel a newer task that + // may have been scheduled since this request completed. + break + } + } + catch { + this.#fail(entry) + } + finally { + if (!this.#disposed) + this.#changes.fire() + } + } + + #fail(entry: Entry): void { + const copy = this.#current(entry) + if (!copy?.dirty || copy.saving) + return + entry.failed = true + entry.task.cancel() + this.#changes.fire() + } +} diff --git a/apps/buddy/src/app/workbench/__tests__/WorkingCopyAutoSave.spec.ts b/apps/buddy/src/app/workbench/__tests__/WorkingCopyAutoSave.spec.ts new file mode 100644 index 000000000..068d9d559 --- /dev/null +++ b/apps/buddy/src/app/workbench/__tests__/WorkingCopyAutoSave.spec.ts @@ -0,0 +1,494 @@ +import type { ResourceRef } from '@/workbench/common/workbench' +import type { TextDocument, WorkingCopyProvider } from '@/workbench/services/WorkingCopyService' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { resourceKey } from '@/workbench/common/workbench' +import { ContributionRegistry } from '@/workbench/services/ContributionRegistry' +import { WorkbenchController } from '@/workbench/services/WorkbenchController' +import { WorkbenchPersistence } from '@/workbench/services/WorkbenchPersistence' +import { WorkingCopyService } from '@/workbench/services/WorkingCopyService' +import { WorkingCopyAutoSave } from '../WorkingCopyAutoSave' + +const cleanups: Array<() => Promise> = [] +beforeEach(() => vi.useFakeTimers()) +afterEach(async () => { + for (const cleanup of cleanups.splice(0).reverse()) await cleanup() + vi.useRealTimers() +}) +function resource(path = 'notes.md', revision = 1): ResourceRef { + return { scheme: 'file', id: `${revision}:${path}`, data: { directoryId: 'directory', spaceId: 'space', path, revision } } +} +function fixture(options: { enabled?: boolean, start?: boolean } = {}) { + const registry = new ContributionRegistry() + registry.register('files', scope => scope.configuration({ id: 'files.autoSave', defaultValue: 'afterDelay', validate: value => value === 'off' || value === 'afterDelay' })) + const controller = new WorkbenchController(registry) + const disk = new Map() + const pending: Array<{ finish: () => void }> = [] + const state = { fail: false, conflict: false, defer: false } + const save = vi.fn(async (target, document): ReturnType => { + if (state.fail) + throw new Error('DISK_UNAVAILABLE') + if (state.conflict) + return { status: 'conflict', document: { text: 'external', etag: 'external-version' } } + if (state.defer) { + const gate = Promise.withResolvers() + pending.push({ finish: () => gate.resolve() }) + await gate.promise + } + const saved = { text: document.text, etag: `saved-${save.mock.calls.length}` } + disk.set(resourceKey(target), saved) + return { status: 'saved', document: saved } + }) + const copies = new WorkingCopyService({ + read: async target => disk.get(resourceKey(target)) ?? { text: 'base', etag: 'initial' }, + save, + }) + const autoSave = new WorkingCopyAutoSave(copies, controller.configuration) + if (options.enabled === false) + autoSave.setEnabled(false) + if (options.start !== false) + autoSave.start() + cleanups.push(async () => { + autoSave.dispose() + pending.forEach(value => value.finish()) + await copies.dispose() + await controller.dispose() + registry.dispose() + }) + return { registry, controller, copies, autoSave, save, disk, state, pending } +} +async function tick(ms = 1000) { + await vi.advanceTimersByTimeAsync(ms) +} + +describe('working-copy auto save', () => { + it('defaults to enabled, debounces the last edit, and writes through the existing save provider', async () => { + const f = fixture() + const r = resource() + await f.copies.open(r) + expect(f.autoSave.enabled).toBe(true) + f.copies.edit(r, 'first') + expect(f.autoSave.status(r)).toBe('waiting') + await tick(700) + f.copies.edit(r, 'latest') + await tick(999) + expect(f.save).not.toHaveBeenCalled() + await tick(1) + expect(f.save).toHaveBeenCalledTimes(1) + expect(f.disk.get(resourceKey(r))?.text).toBe('latest') + expect(f.copies.dirty(r)).toBe(false) + expect(f.autoSave.status(r)).toBe('idle') + }) + + it('does not auto save while disabled and keeps manual save working', async () => { + const f = fixture({ enabled: false }) + const r = resource() + await f.copies.open(r) + f.copies.edit(r, 'manual') + await tick(5000) + expect(f.save).not.toHaveBeenCalled() + expect(f.autoSave.status(r)).toBe('off') + await f.copies.save(r) + expect(f.disk.get(resourceKey(r))?.text).toBe('manual') + f.copies.edit(r, 'pending') + f.autoSave.setEnabled(true) + await tick() + expect(f.disk.get(resourceKey(r))?.text).toBe('pending') + }) + + it('never forces a max-wait save during continuous typing', async () => { + const f = fixture() + const r = resource() + await f.copies.open(r) + for (let i = 0; i < 12; i++) { + f.copies.edit(r, `input ${i}`) + await tick(600) + } + expect(f.save).not.toHaveBeenCalled() + await tick(400) + expect(f.save).toHaveBeenCalledTimes(1) + }) + + it('debounces separate resource identities independently and shares one working-copy task', async () => { + const f = fixture() + const a = resource('same.md', 1) + const b = resource('same.md', 2) + await Promise.all([f.copies.open(a), f.copies.open(a), f.copies.open(b)]) + f.copies.edit(a, 'A') + await tick(500) + f.copies.edit(b, 'B') + await tick(500) + expect(f.save).toHaveBeenCalledTimes(1) + expect(f.disk.get(resourceKey(a))?.text).toBe('A') + expect(f.copies.dirty(b)).toBe(true) + await tick(500) + expect(f.save).toHaveBeenCalledTimes(2) + }) + + it.each([200, 1500])('serializes newer edits when an in-flight write finishes after %i ms', async (elapsed) => { + const f = fixture() + const r = resource() + await f.copies.open(r) + f.state.defer = true + f.copies.edit(r, 'first') + await tick() + expect(f.autoSave.status(r)).toBe('saving') + f.copies.edit(r, 'newer') + expect(f.autoSave.status(r)).toBe('saving-dirty') + await tick(elapsed) + expect(f.save).toHaveBeenCalledTimes(1) + f.state.defer = false + f.pending[0]!.finish() + await tick(0) + if (elapsed < 1000) { + expect(f.save).toHaveBeenCalledTimes(1) + expect(f.copies.dirty(r)).toBe(true) + await tick(1000 - elapsed) + } + expect(f.save).toHaveBeenCalledTimes(2) + expect(f.disk.get(resourceKey(r))?.text).toBe('newer') + expect(f.copies.dirty(r)).toBe(false) + }) + + it('cancels stale deadlines after a manual save', async () => { + const f = fixture() + const r = resource() + await f.copies.open(r) + f.copies.edit(r, 'manual') + await f.copies.save(r) + await tick(3000) + expect(f.save).toHaveBeenCalledTimes(1) + }) + + it('cancels pending work and does not supplement an in-flight save after disabling', async () => { + const f = fixture() + const r = resource() + await f.copies.open(r) + f.copies.edit(r, 'cancel') + f.autoSave.setEnabled(false) + await tick(2000) + expect(f.save).not.toHaveBeenCalled() + f.autoSave.setEnabled(true) + f.state.defer = true + await tick() + f.copies.edit(r, 'keep unsaved') + f.autoSave.setEnabled(false) + f.pending[0]!.finish() + await tick(3000) + expect(f.save).toHaveBeenCalledTimes(1) + expect(f.copies.dirty(r)).toBe(true) + }) + + it('pauses failures across edits and switch toggles, allows other files, and resumes explicitly', async () => { + const f = fixture() + const r = resource() + await f.copies.open(r) + f.state.fail = true + f.copies.edit(r, 'failed') + await tick() + expect(f.autoSave.status(r)).toBe('failed') + f.state.fail = false + f.copies.edit(r, 'new after failure') + f.autoSave.setEnabled(false) + f.autoSave.setEnabled(true) + const other = resource('other.txt') + await f.copies.open(other) + f.copies.edit(other, 'other content') + await tick(4000) + expect(f.save).toHaveBeenCalledTimes(2) + expect(f.copies.dirty(r)).toBe(true) + await f.autoSave.retry(r) + expect(f.autoSave.status(r)).toBe('idle') + expect(f.disk.get(resourceKey(r))?.text).toBe('new after failure') + f.copies.edit(r, 'resumed') + await tick() + expect(f.disk.get(resourceKey(r))?.text).toBe('resumed') + }) + + it('resumes newer edits after a successful manual retry, and after resolving a manual retry conflict', async () => { + const f = fixture() + const r = resource() + await f.copies.open(r) + f.state.fail = true + f.copies.edit(r, 'failed') + await tick() + f.state.fail = false + f.state.conflict = true + await f.copies.save(r) + f.state.conflict = false + f.copies.resolveConflict(r, 'local') + await tick() + expect(f.copies.dirty(r)).toBe(false) + f.state.fail = true + f.copies.edit(r, 'failed again') + await tick() + f.state.fail = false + f.state.defer = true + const manual = f.copies.save(r) + f.copies.edit(r, 'new during manual retry') + await tick(1500) + f.state.defer = false + f.pending[0]!.finish() + await manual + await tick(0) + expect(f.disk.get(resourceKey(r))?.text).toBe('new during manual retry') + expect(f.autoSave.status(r)).toBe('idle') + }) + + it.each(['disk', 'local'] as const)('never overwrites a conflict and respects the %s choice', async (choice) => { + const f = fixture() + const r = resource() + await f.copies.open(r) + f.state.conflict = true + f.copies.edit(r, 'local') + await tick() + expect(f.autoSave.status(r)).toBe('conflict') + await tick(5000) + expect(f.save).toHaveBeenCalledTimes(1) + f.state.conflict = false + f.copies.resolveConflict(r, choice) + await tick() + expect(f.save).toHaveBeenCalledTimes(choice === 'local' ? 2 : 1) + expect(f.copies.get(r)?.text).toBe(choice === 'local' ? 'local' : 'external') + }) + + it('does not write before startup or silently save a restored draft after loading and further edits', async () => { + const f = fixture({ start: false }) + const r = resource() + f.copies.restore([{ key: resourceKey(r), resource: JSON.parse(JSON.stringify(r)), text: 'recovered', baseText: 'base', etag: 'initial', savedAt: '2026-10-21T00:00:00Z' }]) + await f.copies.open(r) + await tick(3000) + f.autoSave.start() + f.copies.edit(r, 'continued draft') + f.autoSave.setEnabled(false) + f.autoSave.setEnabled(true) + await tick(3000) + expect(f.autoSave.status(r)).toBe('recovered') + expect(f.save).not.toHaveBeenCalled() + await f.autoSave.retry(r) + expect(f.disk.get(resourceKey(r))?.text).toBe('continued draft') + expect(f.autoSave.status(r)).toBe('idle') + }) + + it('accepts a manual save of recovered content without changing global settings', async () => { + const f = fixture() + const r = resource() + f.copies.restore([{ key: resourceKey(r), resource: JSON.parse(JSON.stringify(r)), text: 'recovered', baseText: 'base', etag: 'initial', savedAt: '2026-10-21T00:00:00Z' }]) + await f.copies.open(r) + await f.copies.save(r) + expect(f.autoSave.status(r)).toBe('idle') + f.copies.edit(r, 'next edit') + await tick() + expect(f.save).toHaveBeenCalledTimes(2) + }) + + it('obeys file-operation guards and waits again after the guard is released', async () => { + const f = fixture() + const r = resource() + await f.copies.open(r) + const spy = vi.spyOn(f.copies, 'save') + f.copies.edit(r, 'guarded') + const release = f.copies.guard(() => true) + await tick(3000) + expect(f.autoSave.status(r)).toBe('blocked') + expect(f.save).not.toHaveBeenCalled() + release() + await tick(999) + expect(f.save).not.toHaveBeenCalled() + await tick(1) + expect(spy).toHaveBeenCalledWith(r) + expect(spy.mock.calls.every(call => call.length === 1)).toBe(true) + }) + + it('migrates the task to the latest resource without writing the old path', async () => { + const f = fixture() + const old = resource('old.md') + const moved = resource('new.md') + await f.copies.open(old) + f.copies.edit(old, 'moved content') + await tick(500) + f.copies.move(old, moved) + await tick(999) + expect(f.save).not.toHaveBeenCalled() + await tick(1) + expect(f.save.mock.calls[0]?.[0]).toEqual(moved) + expect(f.disk.has(resourceKey(old))).toBe(false) + }) + + it('keeps recovery and failure pauses when a resource is moved', async () => { + const f = fixture() + const old = resource('old.md') + f.copies.restore([{ key: resourceKey(old), resource: JSON.parse(JSON.stringify(old)), text: 'recovered', baseText: 'base', etag: 'initial', savedAt: '2026-10-21T00:00:00Z' }]) + await f.copies.open(old) + const moved = resource('new.md') + f.copies.move(old, moved) + await tick(3000) + expect(f.autoSave.status(moved)).toBe('recovered') + f.state.fail = true + await f.autoSave.retry(moved) + const again = resource('again.md') + f.copies.move(moved, again) + f.state.fail = false + await tick(3000) + expect(f.autoSave.status(again)).toBe('failed') + expect(f.save).toHaveBeenCalledTimes(1) + }) + + it('does not let a discarded/released instance save a replacement at the same key', async () => { + const f = fixture() + const r = resource() + await f.copies.open(r) + const incarnation = f.copies.get(r)!.incarnation + f.copies.edit(r, 'old') + f.copies.discard(r) + expect(f.copies.release(r)).toBe(true) + await f.copies.open(r) + expect(f.copies.get(r)?.incarnation).not.toBe(incarnation) + await tick(3000) + expect(f.save).not.toHaveBeenCalled() + f.copies.edit(r, 'replacement') + await tick() + expect(f.disk.get(resourceKey(r))?.text).toBe('replacement') + }) + + it('holds the close pause through discard cleanup, and cancellation resumes normal scheduling', async () => { + const f = fixture() + const r = resource() + await f.copies.open(r) + f.copies.edit(r, 'discard me') + const gate = Promise.withResolvers() + const preparing = f.autoSave.prepareClose(r, async () => { + await gate.promise + return { complete: () => f.copies.discard(r) } + }) + await tick(3000) + expect(f.autoSave.status(r)).toBe('paused') + expect(f.save).not.toHaveBeenCalled() + gate.resolve() + const plan = await preparing + if (typeof plan === 'boolean') + throw new Error('Expected a close plan') + await tick(2000) + expect(f.save).not.toHaveBeenCalled() + await plan.complete?.({ revision: 1 }) + await tick(2000) + expect(f.save).not.toHaveBeenCalled() + f.copies.edit(r, 'cancel close') + expect(await f.autoSave.prepareClose(r, async () => false)).toBe(false) + await tick() + expect(f.disk.get(resourceKey(r))?.text).toBe('cancel close') + }) + + it('releases a stale close plan pause and a failed preparation without losing pending edits', async () => { + const f = fixture() + const r = resource() + await f.copies.open(r) + f.copies.edit(r, 'keep') + const plan = await f.autoSave.prepareClose(r, async () => ({ validate: () => false })) + if (typeof plan === 'boolean') + throw new Error('Expected a close plan') + plan.cancel?.() + await tick() + expect(f.save).toHaveBeenCalledTimes(1) + f.copies.edit(r, 'after failed close') + await expect(f.autoSave.prepareClose(r, async () => { + throw new Error('CLOSE_FAILED') + })).rejects.toThrow('CLOSE_FAILED') + await tick() + expect(f.disk.get(resourceKey(r))?.text).toBe('after failed close') + }) + + it('waits for an in-flight write before preparing a close decision', async () => { + const f = fixture() + const r = resource() + await f.copies.open(r) + f.state.defer = true + f.copies.edit(r, 'saving') + await tick() + const confirm = vi.fn(async () => true) + const preparing = f.autoSave.prepareClose(r, confirm) + await tick(1000) + expect(confirm).not.toHaveBeenCalled() + f.pending[0]!.finish() + const plan = await preparing + expect(confirm).toHaveBeenCalledTimes(1) + if (typeof plan !== 'boolean') + await plan.complete?.({ revision: 1 }) + }) + + it.each(['cancel', 'discard', 'save', 'stale'] as const)('coordinates a real controller close with the %s decision', async (choice) => { + const f = fixture() + const r = resource() + await f.copies.open(r) + f.copies.edit(r, 'close content') + f.registry.register('view', scope => scope.view({ id: 'files.editor', renderer: 'file', label: 'File', locations: ['main'], multiple: true, supports: () => true })) + const gate = Promise.withResolvers() + const controller = new WorkbenchController(f.registry, view => f.autoSave.prepareClose(view.resource, async () => { + const accepted = f.copies.get(r)! + await gate.promise + if (choice === 'cancel') + return false + if (choice === 'save') { + await f.copies.save(r) + return { validate: () => !f.copies.dirty(r) } + } + return { validate: () => f.copies.isCurrent(accepted), complete: () => f.copies.discard(r) } + })) + try { + const id = (await controller.open(r, 'Notes'))! + const closing = controller.close(id) + await tick(3000) + expect(f.save).not.toHaveBeenCalled() + if (choice === 'stale') + f.copies.edit(r, 'edited while confirming') + gate.resolve() + const result = await closing + expect(result.committed).toBe(choice === 'discard' || choice === 'save') + await tick() + expect(f.save).toHaveBeenCalledTimes(choice === 'discard' ? 0 : 1) + if (choice === 'stale') + expect(f.disk.get(resourceKey(r))?.text).toBe('edited while confirming') + } + finally { + gate.resolve() + await controller.dispose() + } + }) + + it('persists an explicit off preference through the existing workbench snapshot', async () => { + const f = fixture() + const snapshots: unknown[] = [] + const persistence = new WorkbenchPersistence({ read: async () => null, write: async (value) => { + snapshots.push(value) + } }, f.controller, f.copies, () => {}) + await persistence.restore() + f.autoSave.setEnabled(false) + await persistence.flush() + expect(snapshots.at(-1)).toMatchObject({ configuration: { 'files.autoSave': 'off' } }) + const restored = fixture({ start: false }) + restored.controller.configuration.restore(f.controller.configuration.snapshot()) + restored.autoSave.start() + expect(restored.autoSave.enabled).toBe(false) + await persistence.dispose() + }) + + it('never auto saves preview resources and stops pending and post-save work on disposal', async () => { + const f = fixture() + const preview = { ...resource(), scheme: 'file-preview' } + await f.copies.open(preview) + f.copies.edit(preview, 'readonly') + await tick(2000) + expect(f.save).not.toHaveBeenCalled() + const r = resource() + await f.copies.open(r) + f.copies.edit(r, 'first') + f.state.defer = true + await tick() + f.copies.edit(r, 'not flushed on shutdown') + f.autoSave.dispose() + f.pending[0]!.finish() + await tick(5000) + expect(f.save).toHaveBeenCalledTimes(1) + expect(f.copies.dirty(r)).toBe(true) + expect(f.copies.backups().some(backup => backup.text === 'not flushed on shutdown')).toBe(true) + }) +}) diff --git a/apps/buddy/src/app/workbench/__tests__/instantTaskSwitch.spec.ts b/apps/buddy/src/app/workbench/__tests__/instantTaskSwitch.spec.ts index 6bf616b7c..02413126c 100644 --- a/apps/buddy/src/app/workbench/__tests__/instantTaskSwitch.spec.ts +++ b/apps/buddy/src/app/workbench/__tests__/instantTaskSwitch.spec.ts @@ -74,6 +74,7 @@ async function fixture() { } }) registerDesktopContributions(controller, new ViewRendererRegistry(), {} as WorkingCopyService, () => 'en-US') + expect(controller.configuration.get('files.autoSave')).toBe('afterDelay') inputs = useTaskInputLifecycle({ api, controller, pool, persistence: {} as WorkbenchPersistence, resources: () => ({}) as TaskResourcePanel, onError: error => errors.push(error) }) const projection = new ActiveTaskProjection(controller, pool) const loads = new Map>() diff --git a/apps/buddy/src/app/workbench/registerDesktopContributions.ts b/apps/buddy/src/app/workbench/registerDesktopContributions.ts index 53aadee8d..685f7705d 100644 --- a/apps/buddy/src/app/workbench/registerDesktopContributions.ts +++ b/apps/buddy/src/app/workbench/registerDesktopContributions.ts @@ -19,6 +19,7 @@ export function registerDesktopContributions(controller: WorkbenchController, re scope.view({ id: 'tasks.editor', renderer: 'tasks.editor', locations: ['main'], label: 'Task', supports: resource => ['task', 'draft'].includes(resource.scheme), multiple: false, prepareBeforeOpen: false }) }) controller.registry.register('lexora.files', (scope) => { + scope.configuration({ id: 'files.autoSave', defaultValue: 'afterDelay', validate: value => value === 'off' || value === 'afterDelay' }) scope.cleanup(renderers.register('files.view', DesktopFileContribution)) scope.view({ id: 'files.preview', renderer: 'files.view', label: 'Preview', locations: ['context'], supports: resource => resource.scheme === 'file-preview', multiple: true }) scope.view({ id: 'files.editor', renderer: 'files.view', label: 'Text editor', locations: ['context'], supports: resource => resource.scheme === 'file', multiple: true, priority: 10 }) diff --git a/apps/buddy/src/app/workbench/useDesktopWorkbench.ts b/apps/buddy/src/app/workbench/useDesktopWorkbench.ts index 852cfb07f..7b684a32a 100644 --- a/apps/buddy/src/app/workbench/useDesktopWorkbench.ts +++ b/apps/buddy/src/app/workbench/useDesktopWorkbench.ts @@ -35,6 +35,7 @@ import { TaskWorkspacePool } from './TaskWorkspacePool' import { useTaskInputLifecycle } from './useTaskInputLifecycle' import { WorkbenchDiagnostics } from './WorkbenchDiagnostics' import { WorkbenchResourceLifetime } from './WorkbenchResourceLifetime' +import { WorkingCopyAutoSave } from './WorkingCopyAutoSave' export function useDesktopWorkbench(options: { buddyCatalog?: Readonly>, api: LexoraDesktopApi, events: ApplicationEvents, stores: DesktopStores, taskIndex: TaskIndexController, router: Router, resources: () => TaskResourcePanel, onError: (error: unknown) => void }) { const { api, stores, router } = options @@ -56,6 +57,7 @@ export function useDesktopWorkbench(options: { buddyCatalog?: Readonly language.value) const persistence = new WorkbenchPersistence(api.workbench, controller, copies, error => backupError.value = error !== null) + const autoSave = new WorkingCopyAutoSave(copies, controller.configuration) const pool = new TaskWorkspacePool({ buddyCatalog: options.buddyCatalog, api, @@ -477,6 +479,7 @@ export function useDesktopWorkbench(options: { buddyCatalog?: Readonly { @@ -504,7 +507,7 @@ export function useDesktopWorkbench(options: { buddyCatalog?: Readonly beforeFileClose(view, closing)) } catch (error) { options.onError(error) @@ -597,6 +600,7 @@ export function useDesktopWorkbench(options: { buddyCatalog?: Readonly | undefined function dispose(): Promise { + autoSave.dispose() controller.stop() stopFileChanges() for (const timer of fileRefreshTimers.values()) clearTimeout(timer) @@ -639,7 +643,7 @@ export function useDesktopWorkbench(options: { buddyCatalog?: Readonly createFile(target, undefined, current), browser: api.browser, resources: options.resources, ready: () => initialized.value, independent: () => stores.applicationSettings.config.value?.desktop.contextPanelMode === 'independent', locateFile: target => api.localChat.spaces.readFile(target), navigationVersion: () => navigationVersion }) - return { selectionReferences, api, renderers, fileOperations, fileToolbarTargets, fileView, closeContextFiles, readingPositions, discardTask, prepareTaskDeletion, activeTask, activeTaskId, backupError, controller, copies, models, pool, persistence, initialize, flush, dispose, openTask, newTask, startTaskWithSkill, openFile, dropResource, language, get initialized() { + return { selectionReferences, api, renderers, autoSave, fileOperations, fileToolbarTargets, fileView, closeContextFiles, readingPositions, discardTask, prepareTaskDeletion, activeTask, activeTaskId, backupError, controller, copies, models, pool, persistence, initialize, flush, dispose, openTask, newTask, startTaskWithSkill, openFile, dropResource, language, get initialized() { return initialized.value }, get navigationVersion() { return navigationVersion diff --git a/apps/buddy/src/modules/files/widgets/DesktopFileEditor.vue b/apps/buddy/src/modules/files/widgets/DesktopFileEditor.vue index d5d82e1a3..6bf024677 100644 --- a/apps/buddy/src/modules/files/widgets/DesktopFileEditor.vue +++ b/apps/buddy/src/modules/files/widgets/DesktopFileEditor.vue @@ -5,8 +5,8 @@ import type * as Monaco from 'monaco-editor/editor/editor.api.js' import type { TextModelPool } from '@/workbench/browser/TextModelPool' import type { ResourceRef, WorkbenchView } from '@/workbench/common/workbench' import { spaceFileTargetSchema } from '@buddy-shared/spaces/spaceFileApi' -import { NButton } from 'naive-ui' -import { computed, onScopeDispose, shallowRef, useTemplateRef, watch } from 'vue' +import { NButton, NSwitch } from 'naive-ui' +import { computed, onScopeDispose, shallowRef, useId, useTemplateRef, watch } from 'vue' import { useBuddyI18n } from '@/i18n/buddyI18n' import WorkbenchMenu from '@/shared/ui/contributions/WorkbenchMenu.vue' import DesktopDocumentContent from '@/shared/ui/files/DesktopDocumentContent.vue' @@ -21,7 +21,25 @@ import { useResourceSelectionQuote } from '@/shared/ui/selection/useResourceSele import { useWorkbench } from '@/workbench/browser/workbenchContext' const props = withDefaults(defineProps<{ view: WorkbenchView, models: TextModelPool, language: 'zh-CN' | 'en-US', writeClipboardText: (text: string) => Promise, toolbarTarget?: HTMLElement | null, visible?: boolean }>(), { visible: true }) -const { copies, controller, labels } = useWorkbench() +const { copies, controller, labels, autoSave } = useWorkbench() +const autoSaveLabelId = useId() +const autoSaveSwitchTheme = { + railWidthSmall: '26px', + railHeightSmall: '14px', + buttonWidthSmall: '10px', + buttonHeightSmall: '10px', + buttonWidthPressedSmall: '14px', +} +const autoSaveRevision = shallowRef(0) +if (autoSave) + onScopeDispose(autoSave.subscribe(() => autoSaveRevision.value++)) +const autoSaveEnabled = computed({ + get: () => { + void autoSaveRevision.value + return autoSave?.enabled ?? false + }, + set: value => autoSave?.setEnabled(value), +}) const container = useTemplateRef('container') const { t } = useBuddyI18n(() => props.language) function quoteSource(format: 'source' | 'markdown' = 'source'): BuddyFileQuote['source'] { @@ -31,6 +49,25 @@ const failed = shallowRef(false) const editorReady = shallowRef(false) const document = useTemplateRef>('document') const copy = shallowRef(copies.get(props.view.resource)) +const autoSaveStatus = computed(() => { + void autoSaveRevision.value + return autoSave?.status(props.view.resource) ?? 'off' +}) +const saveStatusText = computed(() => { + if (copy.value?.loading) + return labels.value.loading + switch (autoSaveStatus.value) { + case 'waiting': return labels.value.autoSaveWaiting + case 'saving': return labels.value.saving + case 'saving-dirty': return labels.value.savingDirty + case 'failed': return labels.value.autoSaveFailed + case 'conflict': return labels.value.autoSaveConflict + case 'recovered': return labels.value.autoSaveRecovered + case 'blocked': return labels.value.autoSaveBlocked + case 'paused': return labels.value.autoSavePaused + default: return copy.value?.dirty ? labels.value.dirty : labels.value.saved + } +}) const modes = computed(() => fileDocumentModes({ preview: isMarkdownFile(props.view.title), source: !!copy.value?.etag, edit: !!copy.value?.etag })) const mode = computed({ get: () => resolveFileDocumentMode(props.view.state.mode ?? (props.view.state.preview === false ? 'edit' : undefined), modes.value), @@ -183,6 +220,10 @@ onScopeDispose(controller.configuration.subscribe(() => editor?.updateOptions({ -
- {{ copy?.loading ? labels.loading : copy?.dirty ? labels.dirty : labels.saved }} · UTF-8 +
+ {{ saveStatusText }} · UTF-8 + + {{ autoSaveStatus === 'recovered' ? autoSaveEnabled ? labels.autoSaveResume : labels.save : labels.retry }} +
@@ -225,4 +269,8 @@ onScopeDispose(controller.configuration.subscribe(() => editor?.updateOptions({ .file-editor__conflict, .file-editor__error { padding: 10px 14px; font-size: 12px; background: var(--buddy-state-hover); } .file-editor__conflict pre { max-height: 150px; overflow: auto; white-space: pre-wrap; } +.file-editor__auto-save { display: inline-flex; flex: none; align-items: center; gap: 5px; color: var(--buddy-text-secondary); font-size: 11px; white-space: nowrap; } +.file-editor__auto-save-switch { height: 22px; } +.file-editor__status { display: flex; flex: none; flex-wrap: wrap; align-items: center; gap: 6px 10px; padding: 3px 12px; color: var(--buddy-text-secondary); border-top: 1px solid var(--buddy-border-subtle); font-size: 10px; } +.file-editor__status span { overflow-wrap: anywhere; } diff --git a/apps/buddy/src/modules/files/widgets/__tests__/fileViewIdentity.spec.ts b/apps/buddy/src/modules/files/widgets/__tests__/fileViewIdentity.spec.ts index 0768d300c..552773726 100644 --- a/apps/buddy/src/modules/files/widgets/__tests__/fileViewIdentity.spec.ts +++ b/apps/buddy/src/modules/files/widgets/__tests__/fileViewIdentity.spec.ts @@ -4,10 +4,13 @@ import type { LocalSpaceFilePreview } from '@buddy-shared/spaces/spaceFileApi' import type { TextModelPool } from '@/workbench/browser/TextModelPool' import type { WorkbenchContext } from '@/workbench/browser/workbenchContext' import type { ResourceRef } from '@/workbench/common/workbench' +import type { WorkingCopyProvider } from '@/workbench/services/WorkingCopyService' import { afterEach, describe, expect, it, vi } from 'vitest' import { createApp, h, nextTick, provide, shallowRef } from 'vue' +import { WorkingCopyAutoSave } from '@/app/workbench/WorkingCopyAutoSave' import DesktopFileLoading from '@/shared/ui/loading/DesktopFileLoading.vue' import { workbenchKey } from '@/workbench/browser/workbenchContext' +import { resourceKey } from '@/workbench/common/workbench' import { workbenchLabels } from '@/workbench/common/workbenchLabels' import { ContributionRegistry } from '@/workbench/services/ContributionRegistry' import { WorkbenchController } from '@/workbench/services/WorkbenchController' @@ -15,9 +18,23 @@ import { WorkingCopyService } from '@/workbench/services/WorkingCopyService' import DesktopFileEditor from '../DesktopFileEditor.vue' import DesktopFilePreview from '../DesktopFilePreview.vue' -vi.mock('naive-ui', () => ({ NButton: { render: () => null }, useMessage: () => ({ success: vi.fn(), info: vi.fn(), warning: vi.fn() }) })) +vi.mock('naive-ui', async () => { + const { defineComponent, h } = await import('vue') + return { + useMessage: () => ({ success: vi.fn(), info: vi.fn(), warning: vi.fn() }), + NButton: defineComponent({ props: ['disabled', 'loading'], setup: (props, { slots }) => () => h('button', { disabled: props.disabled || props.loading }, slots.default?.()) }), + NSwitch: defineComponent({ + props: ['value', 'disabled', 'themeOverrides'], + emits: ['update:value'], + setup: (props, { emit }) => () => h('button', { 'role': 'switch', 'aria-checked': String(props.value), 'data-rail-width': props.themeOverrides?.railWidthSmall, 'data-rail-height': props.themeOverrides?.railHeightSmall, 'disabled': props.disabled, 'onClick': () => emit('update:value', !props.value) }), + }), + } +}) vi.mock('@/shared/ui/contributions/WorkbenchMenu.vue', () => ({ default: { render: () => null } })) -vi.mock('@/shared/ui/files/DesktopDocumentToolbar.vue', () => ({ default: { render: () => null } })) +vi.mock('@/shared/ui/files/DesktopDocumentToolbar.vue', async () => { + const { defineComponent, h } = await import('vue') + return { default: defineComponent({ setup: (_, { slots }) => () => h('header', slots.actions?.()) }) } +}) vi.mock('@/shared/ui/monaco/desktopMonaco', () => ({ observeDesktopMonacoTheme: () => () => {} })) vi.mock('@/shared/ui/markdown/DesktopMarkdownContent.vue', async () => { const { defineComponent, h } = await import('vue') @@ -66,15 +83,24 @@ function resource(path: string, revision = 1): ResourceRef { function preview(text: string): LocalSpaceFilePreview { return { kind: 'text', text, sizeBytes: text.length, imageUrl: null } } -async function mount(options: { deferredFirst?: boolean, preview?: boolean, readFile?: LocalChatApi['spaces']['readFile'] } = {}) { +async function mount(options: { deferredFirst?: boolean, preview?: boolean, readFile?: LocalChatApi['spaces']['readFile'], autoSave?: boolean, visible?: boolean, recovered?: boolean, save?: WorkingCopyProvider['save'], duplicate?: boolean } = {}) { const registry = new ContributionRegistry() - registry.register('files', scope => scope.view({ id: 'files.editor', renderer: 'files.editor', label: 'File', locations: ['main'], multiple: true, supports: () => true })) + registry.register('files', (scope) => { + scope.view({ id: 'files.editor', renderer: 'files.editor', label: 'File', locations: ['main'], multiple: true, supports: () => true }) + scope.configuration({ id: 'files.autoSave', defaultValue: 'afterDelay', validate: value => value === 'off' || value === 'afterDelay' }) + }) const controller = new WorkbenchController(registry) const id = (await controller.open(resource('first.md'), 'first.md'))! controller.updateView(id, { state: { mode: options.preview ? 'preview' : 'edit' } }) const view = shallowRef(controller.layout.views[id]!) const subscription = controller.onDidChangeLayout(() => view.value = controller.layout.views[id]!) - const copies = new WorkingCopyService({ read: async value => ({ text: String(value.data.path), etag: 'version' }), save: async (_, value) => ({ status: 'saved', document: value }) }) + const copies = new WorkingCopyService({ read: async value => ({ text: String(value.data.path), etag: 'version' }), save: options.save ?? (async (_, value) => ({ status: 'saved', document: value })) }) + const autoSave = options.autoSave ? new WorkingCopyAutoSave(copies, controller.configuration) : undefined + if (options.recovered) { + const target = resource('first.md') + copies.restore([{ key: resourceKey(target), resource: JSON.parse(JSON.stringify(target)), text: 'recovered draft', baseText: 'first.md', etag: 'version', savedAt: '2026-10-21T00:00:00Z' }]) + } + autoSave?.start() const editors: Editor[] = [] const leases: Array<{ resource: ResourceRef, releases: number }> = [] const resume = Promise.withResolvers() @@ -98,22 +124,23 @@ async function mount(options: { deferredFirst?: boolean, preview?: boolean, read document.body.append(element) const errors: unknown[] = [] const app = createApp({ setup() { - provide(workbenchKey, { controller, copies, labels: shallowRef(workbenchLabels('en-US')) } as WorkbenchContext) + provide(workbenchKey, { controller, copies, autoSave, labels: shallowRef(workbenchLabels('en-US')) } as WorkbenchContext) return () => options.preview - ? h(DesktopFilePreview, { view: view.value, files, language: 'en-US', writeClipboardText: async () => {}, visible: false }) - : h(DesktopFileEditor, { view: view.value, models, language: 'en-US', writeClipboardText: async () => {}, visible: false }) + ? h(DesktopFilePreview, { view: view.value, files, language: 'en-US', writeClipboardText: async () => {}, visible: options.visible ?? false }) + : h('div', Array.from({ length: options.duplicate ? 2 : 1 }, (_, key) => h(DesktopFileEditor, { key, view: view.value, models, language: 'en-US', writeClipboardText: async () => {}, visible: options.visible ?? false }))) } }) app.config.errorHandler = error => errors.push(error) app.mount(element) cleanups.push(async () => { app.unmount() + autoSave?.dispose() subscription.dispose() await controller.dispose() await copies.dispose() element.remove() expect(errors).toEqual([]) }) - return { controller, copies, editors, leases, resume, element, id } + return { controller, copies, autoSave, editors, leases, resume, element, id } } describe('file view semantic identity', () => { @@ -178,6 +205,104 @@ describe('file view semantic identity', () => { }) }) +describe('file editor auto-save controls', () => { + it('shows the compact control only in edit mode and preserves its shared setting across modes', async () => { + const f = await mount({ autoSave: true, visible: true, duplicate: true }) + await vi.waitFor(() => expect(document.querySelectorAll('[role="switch"]')).toHaveLength(2)) + const switches = () => [...document.querySelectorAll('[role="switch"]')] + await vi.waitFor(() => expect(switches()[0]!.disabled).toBe(false)) + expect(switches().map(value => value.getAttribute('aria-checked'))).toEqual(['true', 'true']) + const first = switches()[0]! + expect(document.getElementById(first.getAttribute('aria-labelledby')!)?.textContent).toBe('Auto save') + expect(first.previousElementSibling?.textContent).toBe('Auto save') + expect(first.dataset.railWidth).toBe('26px') + expect(first.dataset.railHeight).toBe('14px') + expect(first.parentElement?.title).toContain('including other Spaces') + first.click() + await nextTick() + expect(switches().map(value => value.getAttribute('aria-checked'))).toEqual(['false', 'false']) + for (const mode of ['preview', 'source']) { + f.controller.updateView(f.id, { state: { mode } }) + await nextTick() + expect(switches()).toHaveLength(0) + expect(f.element.querySelector('.file-editor__auto-save')).toBeNull() + } + f.controller.updateView(f.id, { state: { mode: 'edit' } }) + await nextTick() + expect(switches()).toHaveLength(2) + expect(switches().map(value => value.getAttribute('aria-checked'))).toEqual(['false', 'false']) + }) + + it('cancels pending auto save when switched off and enables manual save only for unsaved changes', async () => { + const save = vi.fn(async (_, value) => ({ status: 'saved', document: value })) + const f = await mount({ autoSave: true, visible: true, save }) + await vi.waitFor(() => expect(f.editors).toHaveLength(1)) + const manualSave = () => [...f.element.querySelectorAll('header button')].find(value => value.textContent === 'Save')! + expect(manualSave().disabled).toBe(true) + vi.useFakeTimers() + try { + f.copies.edit(resource('first.md'), 'manual after switching off') + await nextTick() + f.element.querySelector('[role="switch"]')!.click() + await nextTick() + expect(f.autoSave!.enabled).toBe(false) + expect(manualSave().disabled).toBe(false) + await vi.advanceTimersByTimeAsync(1500) + expect(save).not.toHaveBeenCalled() + expect(f.copies.dirty(resource('first.md'))).toBe(true) + manualSave().click() + await vi.advanceTimersByTimeAsync(0) + await nextTick() + expect(save).toHaveBeenCalledTimes(1) + expect(f.copies.dirty(resource('first.md'))).toBe(false) + expect(manualSave().disabled).toBe(true) + expect(f.autoSave!.enabled).toBe(false) + } + finally { + vi.useRealTimers() + } + }) + + it('shows recovery feedback and explicitly saves before resuming auto save', async () => { + const save = vi.fn(async (_, value) => ({ status: 'saved', document: value })) + const f = await mount({ autoSave: true, visible: true, recovered: true, save }) + await vi.waitFor(() => expect(f.element.textContent).toContain('Unsaved changes recovered')) + expect(save).not.toHaveBeenCalled() + const resume = [...f.element.querySelectorAll('button')].find(value => value.textContent === 'Save and resume auto save')! + resume.click() + await vi.waitFor(() => expect(f.copies.dirty(resource('first.md'))).toBe(false)) + expect(save).toHaveBeenCalledTimes(1) + expect(f.element.textContent).not.toContain('Unsaved changes recovered') + }) + + it('reports save errors as save errors with a working retry action, not a view loading failure', async () => { + let fail = true + const save = vi.fn(async (_, value) => { + if (fail) + throw new Error('DISK_UNAVAILABLE') + return { status: 'saved', document: value } + }) + const f = await mount({ autoSave: true, visible: true, save }) + await vi.waitFor(() => expect(f.editors).toHaveLength(1)) + f.copies.edit(resource('first.md'), 'changed') + await f.autoSave!.retry(resource('first.md')) + await nextTick() + expect(f.element.textContent).toContain('Auto save failed') + expect(f.element.textContent).not.toContain('View failed to load') + fail = false + const retry = [...f.element.querySelectorAll('button')].find(value => value.textContent === 'Retry')! + retry.click() + await vi.waitFor(() => expect(f.copies.dirty(resource('first.md'))).toBe(false)) + expect(f.element.textContent).not.toContain('Auto save failed') + }) + + it('does not show an auto-save switch for a read-only preview', async () => { + const f = await mount({ autoSave: true, preview: true, visible: true }) + await vi.waitFor(() => expect(f.element.textContent).toContain('first')) + expect(document.querySelector('[role="switch"]')).toBeNull() + }) +}) + it('keeps quick file reads quiet and shows local feedback only while a slower read is pending', async () => { vi.useFakeTimers() const loading = shallowRef(true) diff --git a/apps/buddy/src/shared/ui/files/DesktopDocumentToolbar.vue b/apps/buddy/src/shared/ui/files/DesktopDocumentToolbar.vue index 2e636af51..4fa72c43a 100644 --- a/apps/buddy/src/shared/ui/files/DesktopDocumentToolbar.vue +++ b/apps/buddy/src/shared/ui/files/DesktopDocumentToolbar.vue @@ -9,10 +9,10 @@ const mode = defineModel({ required: true })