+
{{ labels.failed }}
@@ -214,8 +255,11 @@ onScopeDispose(controller.configuration.subscribe(() => editor?.updateOptions({
-
@@ -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 })
-