From bd9b44937cc0efb1cd949403200f32dcfeaae75a Mon Sep 17 00:00:00 2001 From: probe Date: Wed, 21 Oct 2026 00:11:58 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(buddy):=20=E5=8C=BA=E5=88=86=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E8=A2=AB=E7=A7=BB=E5=8A=A8=E6=88=96=E5=88=A0=E9=99=A4?= =?UTF-8?q?=E4=B8=8E=E5=85=B6=E4=BB=96=E9=A2=84=E8=A7=88=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 文件在列出或展示之后被移动或删除时,预览与产出页统一显示“加载失败”,用户分不清是文件不见了 还是预览本身出了问题。 现在运行时对这类情况返回 FILE_NOT_FOUND:空间文件服务与产出服务在文件缺失时抛出该错误码, 文件预览和产出页据此显示“文件已被移动或删除”,其他失败仍显示原来的加载失败提示。 补充了中英文文案与对应的单元测试。 Co-Authored-By: Claude Sonnet 5.5 --- .../service/src/artifacts/ArtifactService.ts | 5 ++++- .../artifacts/__tests__/ArtifactService.spec.ts | 9 +++++++++ apps/buddy/service/src/rpc/runtimeRequest.ts | 1 + .../buddy/service/src/spaces/SpaceFileService.ts | 14 ++++++++++++-- .../spaces/__tests__/SpaceFileService.spec.ts | 8 ++++++++ apps/buddy/shared/runtime/localChatError.ts | 2 ++ apps/buddy/src/i18n/locales/en-US/diagnostics.ts | 1 + apps/buddy/src/i18n/locales/en-US/tasks.ts | 1 + apps/buddy/src/i18n/locales/zh-CN/diagnostics.ts | 1 + apps/buddy/src/i18n/locales/zh-CN/tasks.ts | 1 + .../modules/files/widgets/DesktopFilePreview.vue | 11 ++++++++--- .../DesktopArtifactContextSurface.vue | 4 ++-- .../__tests__/useResourcePreviews.spec.ts | 16 ++++++++++++++++ .../widgets/context-panel/useArtifactPreview.ts | 10 ++++++++-- apps/buddy/src/shared/lib/localChatError.ts | 1 + .../src/workbench/common/workbenchLabels.ts | 2 ++ 16 files changed, 77 insertions(+), 10 deletions(-) diff --git a/apps/buddy/service/src/artifacts/ArtifactService.ts b/apps/buddy/service/src/artifacts/ArtifactService.ts index f6c3a4e12..03c42f598 100644 --- a/apps/buddy/service/src/artifacts/ArtifactService.ts +++ b/apps/buddy/service/src/artifacts/ArtifactService.ts @@ -373,7 +373,10 @@ export class ArtifactService { ) { throw new ArtifactError('VALIDATION_FAILED') } - const bytes = await readFile(artifact.currentPath) + // The record keeps the path the file had when it was presented; it may have been moved or deleted since. + const bytes = await readFile(artifact.currentPath).catch((error: NodeJS.ErrnoException) => { + throw error.code === 'ENOENT' ? new ArtifactError('FILE_NOT_FOUND') : error + }) if (bytes.byteLength > BUDDY_ARTIFACT_TEXT_BYTES_LIMIT) throw new ArtifactError('ARTIFACT_SIZE_LIMIT') let text: string diff --git a/apps/buddy/service/src/artifacts/__tests__/ArtifactService.spec.ts b/apps/buddy/service/src/artifacts/__tests__/ArtifactService.spec.ts index 104c9e72e..5d0076977 100644 --- a/apps/buddy/service/src/artifacts/__tests__/ArtifactService.spec.ts +++ b/apps/buddy/service/src/artifacts/__tests__/ArtifactService.spec.ts @@ -111,6 +111,15 @@ describe('artifactService', () => { expect(fixture.service.listConversationArtifacts('conversation-1')).toHaveLength(2) }) + it('reports an output whose file was moved or deleted as not found', async () => { + const fixture = await createFixture() + const path = join(fixture.workspace, 'report.md') + await writeFile(path, '# report\n') + const [artifact] = await fixture.service.presentOutputs({ conversationId: 'conversation-1', cwd: fixture.workspace, grants: fixture.grants, paths: [path] }) + await rm(path) + await expect(fixture.service.readText(artifact!.id)).rejects.toMatchObject({ code: 'FILE_NOT_FOUND' }) + }) + it.each(['report.md', 'REPORT.MD', 'report.markdown'])('reads %s as Markdown without changing the file', async (name) => { const fixture = await createFixture() const path = join(fixture.workspace, name) diff --git a/apps/buddy/service/src/rpc/runtimeRequest.ts b/apps/buddy/service/src/rpc/runtimeRequest.ts index e359a94b4..ccbdaa02b 100644 --- a/apps/buddy/service/src/rpc/runtimeRequest.ts +++ b/apps/buddy/service/src/rpc/runtimeRequest.ts @@ -36,6 +36,7 @@ export type BuddyServiceErrorCode | 'DIRECTORY_NOT_AUTHORIZED' | 'MODEL_INPUT_UNSUPPORTED' | 'MODEL_INPUT_TOO_LARGE' + | 'FILE_NOT_FOUND' | 'SPACE_UNAVAILABLE' | 'VALIDATION_FAILED' diff --git a/apps/buddy/service/src/spaces/SpaceFileService.ts b/apps/buddy/service/src/spaces/SpaceFileService.ts index a2440f5b6..1b5f162a9 100644 --- a/apps/buddy/service/src/spaces/SpaceFileService.ts +++ b/apps/buddy/service/src/spaces/SpaceFileService.ts @@ -51,9 +51,9 @@ export class SpaceFileService { } async read(input: SpaceFileTarget): Promise { - const target = await this.resolve(input) + const target = await this.resolve(input).catch(rethrowMissingFile) this.requireDirectory(input) - const preview = await readFilePreview(target.root, target.path) + const preview = await readFilePreview(target.root, target.path).catch(rethrowMissingFile) this.requireDirectory(input) return preview } @@ -235,3 +235,13 @@ export class SpaceFileService { return { path: target.canonicalPath, root: directory.canonicalRoot } } } + +// A file moved or deleted after it was listed is reported on its own, so the preview can say so. +function rethrowMissingFile(error: unknown): never { + for (let current = error, depth = 0; current && typeof current === 'object' && depth < 5; current = (current as { cause?: unknown }).cause, depth++) { + const code = (current as { code?: unknown }).code + if (code === 'ENOENT' || code === 'PATH_NOT_FOUND') + throw new BuddyServiceError('FILE_NOT_FOUND') + } + throw error +} diff --git a/apps/buddy/service/src/spaces/__tests__/SpaceFileService.spec.ts b/apps/buddy/service/src/spaces/__tests__/SpaceFileService.spec.ts index 28747a832..7b23de7a9 100644 --- a/apps/buddy/service/src/spaces/__tests__/SpaceFileService.spec.ts +++ b/apps/buddy/service/src/spaces/__tests__/SpaceFileService.spec.ts @@ -74,6 +74,14 @@ describe('space file browsing', () => { expect(await f.files.read({ ...f.target, path: 'large.txt' })).toMatchObject({ kind: 'oversized', text: null }) }) + it('reports a file that was moved or deleted as not found', async () => { + const f = await fixture() + await writeFile(join(f.workspace, 'note.md'), '# note\n') + await expect(f.files.read({ ...f.target, path: 'note.md' })).resolves.toMatchObject({ kind: 'text' }) + await rm(join(f.workspace, 'note.md')) + await expect(f.files.read({ ...f.target, path: 'note.md' })).rejects.toMatchObject({ code: 'FILE_NOT_FOUND' }) + }) + it('saves UTF-8 content without changing its BOM, rejects stale etags and rechecks grants', async () => { const f = await fixture() const path = join(f.workspace, 'note.md') diff --git a/apps/buddy/shared/runtime/localChatError.ts b/apps/buddy/shared/runtime/localChatError.ts index 1e7e741d0..ee2f32eb8 100644 --- a/apps/buddy/shared/runtime/localChatError.ts +++ b/apps/buddy/shared/runtime/localChatError.ts @@ -34,6 +34,7 @@ export type LocalChatErrorCode | 'FILE_RECOVERY_REQUIRED' | 'FILE_OPERATION_CANCELLED' | 'FILE_PERMISSION_REVOKED' + | 'FILE_NOT_FOUND' | 'FILE_UNSUPPORTED' | 'FILE_ENGINE_INTERRUPTED' | 'LOCAL_CHAT_OPERATION_FAILED' @@ -107,6 +108,7 @@ const LOCAL_CHAT_ERROR_CODES = new Set([ 'FILE_RECOVERY_REQUIRED', 'FILE_OPERATION_CANCELLED', 'FILE_PERMISSION_REVOKED', + 'FILE_NOT_FOUND', 'FILE_UNSUPPORTED', 'FILE_ENGINE_INTERRUPTED', 'LOCAL_CHAT_OPERATION_FAILED', diff --git a/apps/buddy/src/i18n/locales/en-US/diagnostics.ts b/apps/buddy/src/i18n/locales/en-US/diagnostics.ts index 49ef8996f..3a06bf3d9 100644 --- a/apps/buddy/src/i18n/locales/en-US/diagnostics.ts +++ b/apps/buddy/src/i18n/locales/en-US/diagnostics.ts @@ -79,6 +79,7 @@ export default { 'desktop.error.credentialStore': 'Encrypted credential storage is unavailable', 'desktop.error.credentialStoreFailure': 'Could not read or save encrypted credentials', 'desktop.error.directoryNotAuthorized': 'Authorize this directory first', + 'desktop.error.fileNotFound': 'This file has been moved or deleted', 'desktop.error.taskRecoveryRequired': 'Some records for this execution cannot be read. The original records are preserved, and you can start a new turn. Export diagnostics from Logs if you need help.', 'desktop.error.fileRecoveryRequired': 'The outcome of an earlier file operation is unconfirmed, so related writes are paused. Export diagnostics from Logs if you need help.', 'desktop.error.resourceRecoveryRequired': 'An earlier operation has an unverified outcome. Check the files and external state before starting a new operation; the old operation will not be replayed automatically.', diff --git a/apps/buddy/src/i18n/locales/en-US/tasks.ts b/apps/buddy/src/i18n/locales/en-US/tasks.ts index 88fc5e584..03caf7564 100644 --- a/apps/buddy/src/i18n/locales/en-US/tasks.ts +++ b/apps/buddy/src/i18n/locales/en-US/tasks.ts @@ -171,6 +171,7 @@ export default { 'desktop.context.sourceLoadFailed': 'Could not load the source viewer', 'desktop.context.previewUnavailable': 'This file cannot be previewed here yet', 'desktop.context.previewLoadFailed': 'Could not load this file preview', + 'desktop.context.previewFileMissing': 'This file has been moved or deleted', 'desktop.context.tabCount': 'Enhanced capability tabs, {count} total', 'desktop.search.taskCount': 'Tasks ({count})', 'desktop.search.description': 'Search tasks and spaces', diff --git a/apps/buddy/src/i18n/locales/zh-CN/diagnostics.ts b/apps/buddy/src/i18n/locales/zh-CN/diagnostics.ts index 04b51e58d..dce8c7163 100644 --- a/apps/buddy/src/i18n/locales/zh-CN/diagnostics.ts +++ b/apps/buddy/src/i18n/locales/zh-CN/diagnostics.ts @@ -77,6 +77,7 @@ export default { 'desktop.error.credentialStore': '系统凭据加密服务不可用', 'desktop.error.credentialStoreFailure': '系统凭据读取或保存失败', 'desktop.error.directoryNotAuthorized': '请先授权这个目录', + 'desktop.error.fileNotFound': '文件已被移动或删除', 'desktop.error.taskRecoveryRequired': '此执行的部分记录无法读取,原始记录已保留。可以继续新一轮任务;如需协助,可在日志中导出诊断信息。', 'desktop.error.fileRecoveryRequired': '上次文件操作的结果尚未确认,相关写入已暂停。如需协助,可在日志中导出诊断信息。', 'desktop.error.resourceRecoveryRequired': '上次操作的结果尚未确认。请核对文件和外部状态后发起新操作,旧操作不会自动重跑。', diff --git a/apps/buddy/src/i18n/locales/zh-CN/tasks.ts b/apps/buddy/src/i18n/locales/zh-CN/tasks.ts index 3d0a2d978..0d216b2d0 100644 --- a/apps/buddy/src/i18n/locales/zh-CN/tasks.ts +++ b/apps/buddy/src/i18n/locales/zh-CN/tasks.ts @@ -169,6 +169,7 @@ export default { 'desktop.context.sourceLoadFailed': '无法加载源码查看器', 'desktop.context.previewUnavailable': '暂不支持在此预览该文件', 'desktop.context.previewLoadFailed': '无法读取该文件预览', + 'desktop.context.previewFileMissing': '文件已被移动或删除', 'desktop.context.tabCount': '增强能力页签,共 {count} 个', 'desktop.search.taskCount': '任务 ({count})', 'desktop.search.description': '当前可以搜索任务和空间', diff --git a/apps/buddy/src/modules/files/widgets/DesktopFilePreview.vue b/apps/buddy/src/modules/files/widgets/DesktopFilePreview.vue index 948d0c36c..dde7867e6 100644 --- a/apps/buddy/src/modules/files/widgets/DesktopFilePreview.vue +++ b/apps/buddy/src/modules/files/widgets/DesktopFilePreview.vue @@ -3,6 +3,7 @@ import type { LocalChatApi } from '@buddy-electron/shared/localChatApi' import type { BuddyFileQuote } from '@buddy-shared/conversation/buddyUserContent' import type { LocalSpaceFilePreview } from '@buddy-shared/spaces/spaceFileApi' import type { WorkbenchView } from '@/workbench/common/workbench' +import { parseLocalChatPublicError } from '@buddy-shared/runtime/localChatError' import { spaceFileTargetSchema } from '@buddy-shared/spaces/spaceFileApi' import { computed, shallowRef, useTemplateRef, watch } from 'vue' import WorkbenchMenu from '@/shared/ui/contributions/WorkbenchMenu.vue' @@ -19,6 +20,7 @@ const props = withDefaults(defineProps<{ view: WorkbenchView, files: LocalChatAp const { labels, controller } = useWorkbench() const preview = shallowRef(null) const failed = shallowRef(false) +const missing = shallowRef(false) const modes = computed(() => fileDocumentModes({ preview: preview.value?.kind === 'image' || (preview.value?.kind === 'text' && isMarkdownFile(props.view.title)), source: preview.value?.kind === 'text', edit: false })) const mode = computed({ get: () => resolveFileDocumentMode(props.view.state.mode, modes.value), set: value => controller.updateView(props.view.id, { state: { ...props.view.state, mode: value } }) }) const quoteSource = computed(() => ({ @@ -43,14 +45,17 @@ watch([identity, attempt], async (_, __, onCleanup) => { onCleanup(() => active = false) preview.value = null failed.value = false + missing.value = false try { const value = await props.files.readFile(spaceFileTargetSchema.strip().parse(props.view.resource.data)) if (active) preview.value = value } - catch { - if (active) + catch (error) { + if (active) { failed.value = true + missing.value = error instanceof Error && parseLocalChatPublicError(error.message)?.code === 'FILE_NOT_FOUND' + } } }, { immediate: true }) const document = useTemplateRef>('document') @@ -80,7 +85,7 @@ function captureResource() { diff --git a/apps/buddy/src/modules/tasks/widgets/context-panel/__tests__/useResourcePreviews.spec.ts b/apps/buddy/src/modules/tasks/widgets/context-panel/__tests__/useResourcePreviews.spec.ts index 8b5ef2a49..cb63f10bd 100644 --- a/apps/buddy/src/modules/tasks/widgets/context-panel/__tests__/useResourcePreviews.spec.ts +++ b/apps/buddy/src/modules/tasks/widgets/context-panel/__tests__/useResourcePreviews.spec.ts @@ -225,6 +225,16 @@ describe('resource previews', () => { expect(state.textPreviewLoading.value).toBe(false) }) + it('tells a moved or deleted file apart from other read failures', async () => { + const artifact = { ...createArtifact('moved'), name: 'moved.md', mimeType: 'text/markdown' } + const missing = own(() => useArtifactPreview({ artifact: () => artifact, readText: () => failWith('Error invoking remote method: Error: LEXORA_LOCAL_CHAT_ERROR:FILE_NOT_FOUND:0') })) + const broken = own(() => useArtifactPreview({ artifact: () => artifact, readText: () => failWith('LEXORA_LOCAL_CHAT_ERROR:LOCAL_CHAT_OPERATION_FAILED:0') })) + await vi.waitFor(() => expect(missing.state.textPreviewFailed.value).toBe(true)) + await vi.waitFor(() => expect(broken.state.textPreviewFailed.value).toBe(true)) + expect(missing.state.textPreviewMissing.value).toBe(true) + expect(broken.state.textPreviewMissing.value).toBe(false) + }) + it('ignores late text success and failure after switching artifacts or closing the preview', async () => { const first = deferred() const second = deferred() @@ -302,6 +312,12 @@ describe('resource previews', () => { }) }) +function failWith(message: string) { + return async (): Promise => { + throw new Error(message) + } +} + function filesTab(): TaskFilesContextTab { return { id: 'files:first', scope: 'task:first', kind: 'files', rootName: 'fixture', target: { spaceId: 'space', directoryId: 'directory', revision: 1, path: '' } } } diff --git a/apps/buddy/src/modules/tasks/widgets/context-panel/useArtifactPreview.ts b/apps/buddy/src/modules/tasks/widgets/context-panel/useArtifactPreview.ts index 1daf09518..96fe99d9f 100644 --- a/apps/buddy/src/modules/tasks/widgets/context-panel/useArtifactPreview.ts +++ b/apps/buddy/src/modules/tasks/widgets/context-panel/useArtifactPreview.ts @@ -1,4 +1,5 @@ import type { LocalArtifact, LocalArtifactText } from '@buddy-shared/artifacts/artifactApi' +import { parseLocalChatPublicError } from '@buddy-shared/runtime/localChatError' import { computed, readonly, shallowRef, watch } from 'vue' import { isMarkdownArtifact, isTextMimeType } from './artifactContextPresentation' @@ -14,6 +15,7 @@ export function useArtifactPreview(options: ArtifactPreviewOptions) { const previewOpen = shallowRef(false) const textPreview = shallowRef(null) const textPreviewFailed = shallowRef(false) + const textPreviewMissing = shallowRef(false) const textPreviewLoading = shallowRef(false) const imageUrl = computed(() => { const artifact = options.artifact() @@ -41,6 +43,7 @@ export function useArtifactPreview(options: ArtifactPreviewOptions) { previewOpen.value = false textPreview.value = null textPreviewFailed.value = false + textPreviewMissing.value = false textPreviewLoading.value = kind === 'file' && (isTextMimeType(mimeType) || isMarkdownArtifact(options.artifact())) if (!textPreviewLoading.value) return @@ -49,9 +52,11 @@ export function useArtifactPreview(options: ArtifactPreviewOptions) { if (active) textPreview.value = result } - catch { - if (active) + catch (error) { + if (active) { textPreviewFailed.value = true + textPreviewMissing.value = error instanceof Error && parseLocalChatPublicError(error.message)?.code === 'FILE_NOT_FOUND' + } } finally { if (active) @@ -79,6 +84,7 @@ export function useArtifactPreview(options: ArtifactPreviewOptions) { previewUrl, textPreview: readonly(textPreview), textPreviewFailed: readonly(textPreviewFailed), + textPreviewMissing: readonly(textPreviewMissing), textPreviewLoading: readonly(textPreviewLoading), } } diff --git a/apps/buddy/src/shared/lib/localChatError.ts b/apps/buddy/src/shared/lib/localChatError.ts index 7c590b844..92315780a 100644 --- a/apps/buddy/src/shared/lib/localChatError.ts +++ b/apps/buddy/src/shared/lib/localChatError.ts @@ -49,6 +49,7 @@ export function resolveLocalChatErrorMessage(error: unknown, language: BuddyLoca FILE_RECOVERY_REQUIRED: 'desktop.error.fileRecoveryRequired', FILE_OPERATION_CANCELLED: 'desktop.chat.unknownError', FILE_PERMISSION_REVOKED: 'desktop.error.directoryNotAuthorized', + FILE_NOT_FOUND: 'desktop.error.fileNotFound', FILE_UNSUPPORTED: 'desktop.chat.unknownError', FILE_ENGINE_INTERRUPTED: 'desktop.chat.unknownError', LOCAL_CHAT_OPERATION_FAILED: 'desktop.chat.unknownError', diff --git a/apps/buddy/src/workbench/common/workbenchLabels.ts b/apps/buddy/src/workbench/common/workbenchLabels.ts index 4801467b5..b3f75f190 100644 --- a/apps/buddy/src/workbench/common/workbenchLabels.ts +++ b/apps/buddy/src/workbench/common/workbenchLabels.ts @@ -33,6 +33,7 @@ const zh = { discardDraft: '放弃内容', missing: '此视图暂不可用,资源和布局已保留。', failed: '视图加载失败。', + fileMissing: '文件已被移动或删除。', restoreFailed: '工作台恢复失败,请重试。', backupFailed: '未保存内容的备份失败。请保存文件或重试备份。', conflict: '文件已在外部修改。请比较内容后选择保留的版本。', @@ -96,6 +97,7 @@ const en: Labels = { discardDraft: 'Discard draft', missing: 'This view is unavailable. Its resource and layout have been retained.', failed: 'The view could not be loaded.', + fileMissing: 'This file has been moved or deleted.', restoreFailed: 'Could not restore the workbench. Please retry.', backupFailed: 'Could not back up unsaved changes. Save the file or retry the backup.', conflict: 'The file has changed on disk. Compare both versions before choosing which to keep.', From 755ad4a2649a346e86cd83d322c3db372b6e2ba6 Mon Sep 17 00:00:00 2001 From: shanyuhai123 <864299347@qq.com> Date: Thu, 22 Oct 2026 10:14:05 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(buddy):=20=E8=A1=A5=E9=BD=90=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E7=BC=BA=E5=A4=B1=E6=8F=90=E7=A4=BA=E4=B8=8E=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scripts/__tests__/file-explorer.e2e.mjs | 39 +++++ .../native/host/__tests__/file_engine.rs | 165 +++++++++++++++++- apps/buddy/native/host/src/file_engine/io.rs | 30 +++- .../native/host/src/file_engine/protocol.rs | 2 + .../service/src/artifacts/ArtifactService.ts | 3 +- .../__tests__/ArtifactService.spec.ts | 12 ++ .../service/src/spaces/SpaceFileService.ts | 24 +-- .../spaces/__tests__/SpaceFileService.spec.ts | 59 ++++++- .../files/widgets/DesktopFileEditor.vue | 2 +- .../__tests__/fileViewIdentity.spec.ts | 60 ++++++- .../workbench/services/WorkingCopyService.ts | 11 +- .../__tests__/WorkingCopyService.spec.ts | 45 +++++ 12 files changed, 415 insertions(+), 37 deletions(-) diff --git a/.playwright/scripts/__tests__/file-explorer.e2e.mjs b/.playwright/scripts/__tests__/file-explorer.e2e.mjs index 60379d656..973957a88 100644 --- a/.playwright/scripts/__tests__/file-explorer.e2e.mjs +++ b/.playwright/scripts/__tests__/file-explorer.e2e.mjs @@ -3,6 +3,8 @@ import path from 'node:path' import { expect, test } from '../fixtures/electron.mjs' async function createSpace(app, page, directory) { + await page.evaluate(() => window.lexoraDesktop.settings.update({ desktop: { featureAnnouncementsEnabled: false } })) + await expect(page.getByTestId('feature-discovery-reminder')).toHaveCount(0) await app.evaluate(({ dialog }, directory) => { dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [directory] }) }, directory) @@ -137,6 +139,43 @@ test('file explorer preserves edits and undo through rename, rejects copy confli expect(diagnostics.console.filter(item => item.type === 'pageerror')).toEqual([]) }) +test('file editor distinguishes external deletion and recovers without losing unsaved edits', async ({ buddy }, testInfo) => { + const instance = await buddy.createInstance('file-missing-recovery') + const directory = path.join(instance.home, 'documents') + const source = path.join(directory, 'README.md') + await fs.mkdir(directory, { recursive: true }) + await fs.writeFile(source, 'Original document.\n') + const { app, page, diagnostics } = await instance.launch() + await createSpace(app, page, directory) + await page.evaluate(() => window.lexoraDesktop.settings.update({ desktop: { contextPanelMode: 'independent' } })) + const toggle = page.getByTestId('context-panel-toggle') + if (await toggle.getAttribute('aria-expanded') === 'false') + await toggle.click() + await page.getByTestId('task-context-open-files').click() + await page.getByTestId('file-space-picker').getByRole('button', { name: /文件树验收/ }).click() + await page.getByTestId('context-file-tree').locator('[data-path="README.md"]').click() + await page.getByTestId('document-mode-edit').click() + const editor = page.getByTestId('workbench-text-editor') + await expect(editor.locator('.view-lines')).toContainText('Original document.') + await editor.locator('.view-lines').click() + await page.keyboard.press('Control+End') + await page.keyboard.insertText('Keep this draft.') + await expect(page.locator('.file-editor')).toHaveAttribute('data-dirty', 'true') + await fs.rm(source) + await expect(page.locator('.file-editor__error')).toContainText('文件已被移动或删除') + await expect(editor.locator('.view-lines')).toContainText('Keep this draft.') + await expect(page.locator('.file-editor')).toHaveAttribute('data-dirty', 'true') + await page.screenshot({ path: testInfo.outputPath('file-missing.png') }) + await fs.writeFile(source, 'Restored outside.\n') + await expect(page.locator('.file-editor__error')).toHaveCount(0) + await expect(page.locator('.file-editor__conflict')).toContainText('Restored outside.') + await expect(editor.locator('.view-lines')).toContainText('Keep this draft.') + await expect(page.locator('.file-editor')).toHaveAttribute('data-dirty', 'true') + expect(await fs.readFile(source, 'utf8')).toBe('Restored outside.\n') + await page.screenshot({ path: testInfo.outputPath('file-restored.png') }) + expect(diagnostics.console.filter(item => item.type === 'pageerror')).toEqual([]) +}) + test('copy path uses native clipboard text independently from file copy and cut', async ({ buddy }, testInfo) => { const instance = await buddy.createInstance('file-copy-path') const directory = path.join(instance.home, process.platform === 'win32' ? 'documents' : 'documents\\draft') diff --git a/apps/buddy/native/host/__tests__/file_engine.rs b/apps/buddy/native/host/__tests__/file_engine.rs index f54f7165d..9a623f8de 100644 --- a/apps/buddy/native/host/__tests__/file_engine.rs +++ b/apps/buddy/native/host/__tests__/file_engine.rs @@ -108,6 +108,152 @@ fn id() -> String { uuid::Uuid::new_v4().to_string() } +#[test] +fn read_reports_missing_targets_and_reads_restored_files() { + let directory = tempfile::tempdir().unwrap(); + let root = directory.path().join("root"); + std::fs::create_dir(&root).unwrap(); + let mut engine = Engine::new(&directory.path().join("journal")); + engine.register("root", &root); + + for path in ["note.md", "missing/note.md"] { + assert_eq!( + engine.call(json!({"type":"read","root":"root","path":path}))["error"], + "FILE_NOT_FOUND" + ); + } + assert_eq!( + engine.call(json!({"type":"list","root":"root","path":"missing"}))["error"], + "FILE_IO_FAILED" + ); + + std::fs::write(root.join("note.md"), "original").unwrap(); + let original = engine.call(json!({"type":"read","root":"root","path":"note.md"})); + assert_eq!(original["result"]["text"], "original"); + std::fs::remove_file(root.join("note.md")).unwrap(); + assert_eq!( + engine.call(json!({"type":"read","root":"root","path":"note.md"}))["error"], + "FILE_NOT_FOUND" + ); + std::fs::write(root.join("note.md"), "restored").unwrap(); + let restored = engine.call(json!({"type":"read","root":"root","path":"note.md"})); + assert_eq!(restored["result"]["text"], "restored"); + assert_ne!(restored["result"]["etag"], original["result"]["etag"]); +} + +#[test] +fn read_reports_missing_target_when_parent_becomes_a_file() { + let directory = tempfile::tempdir().unwrap(); + let root = directory.path().join("root"); + let parent = root.join("nested"); + std::fs::create_dir_all(&parent).unwrap(); + std::fs::write(parent.join("note.md"), "original").unwrap(); + let mut engine = Engine::new(&directory.path().join("journal")); + engine.register("root", &root); + assert_eq!( + engine.call(json!({"type":"read","root":"root","path":"nested/note.md"}))["result"]["text"], + "original" + ); + + std::fs::remove_file(parent.join("note.md")).unwrap(); + std::fs::remove_dir(&parent).unwrap(); + std::fs::write(&parent, "not a directory").unwrap(); + assert_eq!( + engine.call(json!({"type":"read","root":"root","path":"nested/note.md"}))["error"], + "FILE_NOT_FOUND" + ); + assert_eq!( + engine.call(json!({"type":"acquire","leaseId":id(),"external":false,"claims":[{"root":"root","path":"nested/note.md","subtree":false}]}))["error"], + "FILE_IO_FAILED" + ); + + std::fs::remove_file(&parent).unwrap(); + std::fs::create_dir(&parent).unwrap(); + std::fs::write(parent.join("note.md"), "restored").unwrap(); + assert_eq!( + engine.call(json!({"type":"read","root":"root","path":"nested/note.md"}))["result"]["text"], + "restored" + ); +} + +#[test] +fn read_rejects_unknown_and_revoked_roots() { + let directory = tempfile::tempdir().unwrap(); + let root = directory.path().join("root"); + std::fs::create_dir(&root).unwrap(); + let mut engine = Engine::new(&directory.path().join("journal")); + engine.register("root", &root); + engine.call(json!({"type":"revoke","key":"root"})); + for key in ["root", "unknown"] { + assert_eq!( + engine.call(json!({"type":"read","root":key,"path":"missing.md"}))["error"], + "FILE_PERMISSION_REVOKED" + ); + } +} + +#[cfg(unix)] +#[test] +fn read_does_not_classify_unavailable_or_replaced_roots_as_missing_targets() { + let directory = tempfile::tempdir().unwrap(); + let root = directory.path().join("root"); + let offline = directory.path().join("offline"); + std::fs::create_dir(&root).unwrap(); + std::fs::write(root.join("note.md"), "retained").unwrap(); + let mut engine = Engine::new(&directory.path().join("journal")); + engine.register("root", &root); + + std::fs::rename(&root, &offline).unwrap(); + assert_eq!( + engine.call(json!({"type":"read","root":"root","path":"note.md"}))["error"], + "FILE_IO_FAILED" + ); + std::fs::write(&root, "not a root directory").unwrap(); + assert_eq!( + engine.call(json!({"type":"read","root":"root","path":"note.md"}))["error"], + "FILE_IO_FAILED" + ); + std::fs::remove_file(&root).unwrap(); + std::fs::rename(&offline, &root).unwrap(); + assert_eq!( + engine.call(json!({"type":"read","root":"root","path":"note.md"}))["result"]["text"], + "retained" + ); + + std::fs::rename(&root, &offline).unwrap(); + std::fs::create_dir(&root).unwrap(); + assert_eq!( + engine.call(json!({"type":"read","root":"root","path":"missing.md"}))["error"], + "FILE_PERMISSION_REVOKED" + ); +} + +#[cfg(unix)] +#[test] +fn read_preserves_permission_errors_for_files_and_inaccessible_parents() { + use std::os::unix::fs::PermissionsExt; + + let directory = tempfile::tempdir().unwrap(); + let root = directory.path().join("root"); + std::fs::create_dir_all(root.join("private")).unwrap(); + std::fs::write(root.join("note.md"), "private").unwrap(); + let mut engine = Engine::new(&directory.path().join("journal")); + engine.register("root", &root); + + for (restricted, target) in [("note.md", "note.md"), ("private", "private/missing.md")] { + let path = root.join(restricted); + let permissions = std::fs::metadata(&path).unwrap().permissions(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o0)).unwrap(); + let response = engine.call(json!({"type":"read","root":"root","path":target})); + std::fs::set_permissions(&path, permissions).unwrap(); + assert_eq!(response["error"], "FILE_IO_FAILED"); + } + assert_eq!( + engine.call(json!({"type":"read","root":"root","path":"note.md"}))["result"]["text"], + "private" + ); +} + #[test] fn journal_deduplicates_mutations_and_never_overwrites_a_destination() { let directory = tempfile::tempdir().unwrap(); @@ -304,11 +450,20 @@ fn capabilities_reject_symlink_escape_and_revoked_roots() { std::os::unix::fs::symlink(root.join("inside"), root.join("alias")).unwrap(); let mut engine = Engine::new(&directory.path().join("journal")); engine.register("root", &root); - assert!( - engine - .call(json!({"type":"read","root":"root","path":"alias/local"})) - .get("error") - .is_some() + for path in [ + "alias/local", + "alias/missing", + "escape/outside", + "escape/missing", + ] { + assert_eq!( + engine.call(json!({"type":"read","root":"root","path":path}))["error"], + "FILE_UNSUPPORTED" + ); + } + assert_eq!( + engine.call(json!({"type":"read","root":"root","path":"../missing"}))["error"], + "FILE_INVALID_REQUEST" ); assert!( engine diff --git a/apps/buddy/native/host/src/file_engine/io.rs b/apps/buddy/native/host/src/file_engine/io.rs index fe2598674..65b17c499 100644 --- a/apps/buddy/native/host/src/file_engine/io.rs +++ b/apps/buddy/native/host/src/file_engine/io.rs @@ -19,10 +19,11 @@ pub fn digest(bytes: &[u8]) -> String { } pub fn read(root: &Root, path: &str) -> Result { - root.resolve(path)?; + root.resolve(path) + .map_err(|error| read_target_error(root, error))?; let path = relative(path)?; root.check()?; - let file = open_regular(&root.dir, &path)?; + let file = open_read(&root.dir, &path).map_err(|error| read_target_error(root, error))?; let metadata = file.metadata()?; if !metadata.is_file() || metadata.len() > MAX_TEXT as u64 { return Err(Error::Unsupported); @@ -38,6 +39,23 @@ pub fn read(root: &Root, path: &str) -> Result { Ok(json!({ "text": text, "etag": etag, "identity": identity(&metadata)? })) } +fn read_target_error(root: &Root, error: Error) -> Error { + match error { + Error::Io(error) + if matches!( + error.kind(), + std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory + ) => + { + match root.check() { + Ok(()) => Error::NotFound, + Err(error) => error, + } + } + error => error, + } +} + pub fn list(root: &Root, path: &str, cursor: Option<&str>) -> Result { root.resolve(path)?; root.check()?; @@ -284,7 +302,7 @@ pub fn copy_entry( Ok(()) } -fn open_regular(dir: &Dir, path: &Path) -> Result { +fn open_read(dir: &Dir, path: &Path) -> Result { let mut options = OpenOptions::new(); options.read(true); #[cfg(unix)] @@ -294,7 +312,11 @@ fn open_regular(dir: &Dir, path: &Path) -> Result { (rustix::fs::OFlags::NONBLOCK | rustix::fs::OFlags::NOFOLLOW).bits() as i32, ); } - let file = dir.open_with(path, &options)?; + Ok(dir.open_with(path, &options)?) +} + +fn open_regular(dir: &Dir, path: &Path) -> Result { + let file = open_read(dir, path)?; if !file.metadata()?.is_file() { return Err(Error::Unsupported); } diff --git a/apps/buddy/native/host/src/file_engine/protocol.rs b/apps/buddy/native/host/src/file_engine/protocol.rs index b36a16497..f3d6dee00 100644 --- a/apps/buddy/native/host/src/file_engine/protocol.rs +++ b/apps/buddy/native/host/src/file_engine/protocol.rs @@ -169,6 +169,8 @@ pub enum Error { IdConflict, #[error("FILE_UNSUPPORTED")] Unsupported, + #[error("FILE_NOT_FOUND")] + NotFound, #[error(transparent)] NotApplied(Box), #[error("FILE_IO_FAILED")] diff --git a/apps/buddy/service/src/artifacts/ArtifactService.ts b/apps/buddy/service/src/artifacts/ArtifactService.ts index 03c42f598..0ff986192 100644 --- a/apps/buddy/service/src/artifacts/ArtifactService.ts +++ b/apps/buddy/service/src/artifacts/ArtifactService.ts @@ -373,9 +373,8 @@ export class ArtifactService { ) { throw new ArtifactError('VALIDATION_FAILED') } - // The record keeps the path the file had when it was presented; it may have been moved or deleted since. const bytes = await readFile(artifact.currentPath).catch((error: NodeJS.ErrnoException) => { - throw error.code === 'ENOENT' ? new ArtifactError('FILE_NOT_FOUND') : error + throw error.code === 'ENOENT' || error.code === 'ENOTDIR' ? new ArtifactError('FILE_NOT_FOUND') : error }) if (bytes.byteLength > BUDDY_ARTIFACT_TEXT_BYTES_LIMIT) throw new ArtifactError('ARTIFACT_SIZE_LIMIT') diff --git a/apps/buddy/service/src/artifacts/__tests__/ArtifactService.spec.ts b/apps/buddy/service/src/artifacts/__tests__/ArtifactService.spec.ts index 5d0076977..2d7f92e51 100644 --- a/apps/buddy/service/src/artifacts/__tests__/ArtifactService.spec.ts +++ b/apps/buddy/service/src/artifacts/__tests__/ArtifactService.spec.ts @@ -120,6 +120,18 @@ describe('artifactService', () => { await expect(fixture.service.readText(artifact!.id)).rejects.toMatchObject({ code: 'FILE_NOT_FOUND' }) }) + it('reports an output as missing when its parent has become a regular file', async () => { + const fixture = await createFixture() + const directory = join(fixture.workspace, 'reports') + const path = join(directory, 'report.md') + await mkdir(directory) + await writeFile(path, '# report\n') + const [artifact] = await fixture.service.presentOutputs({ conversationId: 'conversation-1', cwd: fixture.workspace, grants: fixture.grants, paths: [path] }) + await rm(directory, { recursive: true }) + await writeFile(directory, 'not a directory') + await expect(fixture.service.readText(artifact!.id)).rejects.toMatchObject({ code: 'FILE_NOT_FOUND' }) + }) + it.each(['report.md', 'REPORT.MD', 'report.markdown'])('reads %s as Markdown without changing the file', async (name) => { const fixture = await createFixture() const path = join(fixture.workspace, name) diff --git a/apps/buddy/service/src/spaces/SpaceFileService.ts b/apps/buddy/service/src/spaces/SpaceFileService.ts index 892684efb..d072f02c5 100644 --- a/apps/buddy/service/src/spaces/SpaceFileService.ts +++ b/apps/buddy/service/src/spaces/SpaceFileService.ts @@ -4,8 +4,9 @@ import type { SpaceRepository } from '../storage/spaceRepository' import { Buffer } from 'node:buffer' import { realpath, stat } from 'node:fs/promises' import { isAbsolute, relative, resolve, sep } from 'node:path' +import { BoundedFileReadError } from '../../../platform/filesystem/boundedFile' import { Emitter } from '../../../shared/events/Emitter' -import { resolveGrantedPath } from '../directories/resolveGrantedPath' +import { GrantedPathError, resolveGrantedPath } from '../directories/resolveGrantedPath' import { readFilePreview } from '../files/readFilePreview' import { createSensitivePathMatcher } from '../permissions/sensitivePaths' import { BuddyServiceError } from '../rpc/runtimeRequest' @@ -56,7 +57,7 @@ export class SpaceFileService { async read(input: SpaceFileTarget): Promise { const target = await this.resolve(input).catch(rethrowMissingFile) this.requireDirectory(input) - const preview = await readFilePreview(target.root, target.path).catch(rethrowMissingFile) + const preview = await readFilePreview(target.root, target.path).catch(error => rethrowMissingFile(error, target.path)) this.requireDirectory(input) return preview } @@ -71,7 +72,7 @@ export class SpaceFileService { } async readDocument(input: SpaceFileTarget): Promise { - const root = await this.root(input) + const root = await this.root(input, 'existing').catch(rethrowMissingFile) const result = await this.#engine.read(root, input.path) this.requireDirectory(input) if (Buffer.byteLength(result.text) > 1024 * 1024) @@ -190,9 +191,9 @@ export class SpaceFileService { return pending } - private async root(input: SpaceFileTarget): Promise { + private async root(input: SpaceFileTarget, mode: 'existing' | 'create' = 'create'): Promise { const directory = this.requireDirectory(input) - await this.resolve(input, 'create') + await this.resolve(input, mode) const key = JSON.stringify([directory.id, directory.revision]) await this.#engine.register(key, directory.canonicalRoot, true) this.requireDirectory(input) @@ -248,12 +249,11 @@ export class SpaceFileService { } } -// A file moved or deleted after it was listed is reported on its own, so the preview can say so. -function rethrowMissingFile(error: unknown): never { - for (let current = error, depth = 0; current && typeof current === 'object' && depth < 5; current = (current as { cause?: unknown }).cause, depth++) { - const code = (current as { code?: unknown }).code - if (code === 'ENOENT' || code === 'PATH_NOT_FOUND') - throw new BuddyServiceError('FILE_NOT_FOUND') - } +function rethrowMissingFile(error: unknown, targetPath?: string): never { + if (error instanceof GrantedPathError && error.code === 'PATH_NOT_FOUND') + throw new BuddyServiceError('FILE_NOT_FOUND') + const failure = (error instanceof BoundedFileReadError && error.code === 'BOUNDED_FILE_READ_FAILED' ? error.cause : error) as NodeJS.ErrnoException | undefined + if (targetPath && failure?.path === targetPath && (failure.code === 'ENOENT' || failure.code === 'ENOTDIR')) + throw new BuddyServiceError('FILE_NOT_FOUND') throw error } diff --git a/apps/buddy/service/src/spaces/__tests__/SpaceFileService.spec.ts b/apps/buddy/service/src/spaces/__tests__/SpaceFileService.spec.ts index eae16fc5a..4f35e563d 100644 --- a/apps/buddy/service/src/spaces/__tests__/SpaceFileService.spec.ts +++ b/apps/buddy/service/src/spaces/__tests__/SpaceFileService.spec.ts @@ -125,12 +125,58 @@ describe('space file browsing', () => { expect(await f.files.read({ ...f.target, path: 'large.txt' })).toMatchObject({ kind: 'oversized', text: null }) }) - it('reports a file that was moved or deleted as not found', async () => { + it.each(['note.md', 'image.png'])('reports a deleted %s and reads it again after restoration', async (name) => { const f = await fixture() - await writeFile(join(f.workspace, 'note.md'), '# note\n') - await expect(f.files.read({ ...f.target, path: 'note.md' })).resolves.toMatchObject({ kind: 'text' }) - await rm(join(f.workspace, 'note.md')) - await expect(f.files.read({ ...f.target, path: 'note.md' })).rejects.toMatchObject({ code: 'FILE_NOT_FOUND' }) + const path = join(f.workspace, name) + const content = name.endsWith('.png') ? Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aT1kAAAAASUVORK5CYII=', 'base64') : Buffer.from('# note\n') + const target = { ...f.target, path: name } + await writeFile(path, content) + const original = await f.files.read(target) + await rm(path) + await expect(f.files.read(target)).rejects.toMatchObject({ code: 'FILE_NOT_FOUND' }) + await writeFile(path, content) + expect(await f.files.read(target)).toEqual(original) + }) + + it('reports a deleted document without preventing a later read after restoration', async () => { + const f = await fixture() + const path = join(f.workspace, 'note.md') + const target = { ...f.target, path: 'note.md' } + await writeFile(path, '# original\n') + expect(await f.files.readDocument(target)).toMatchObject({ text: '# original\n' }) + await rm(path) + await expect(f.files.readDocument(target)).rejects.toMatchObject({ code: 'FILE_NOT_FOUND' }) + await writeFile(path, '# restored\n') + expect(await f.files.readDocument(target)).toMatchObject({ text: '# restored\n' }) + }) + + it('reports a missing target when its former parent is now a regular file', async () => { + const f = await fixture() + const directory = join(f.workspace, 'reports') + await mkdir(directory) + await writeFile(join(directory, 'note.md'), '# note\n') + await rm(directory, { recursive: true }) + await writeFile(directory, 'not a directory') + const target = { ...f.target, path: 'reports/note.md' } + await expect(f.files.read(target)).rejects.toMatchObject({ code: 'FILE_NOT_FOUND' }) + await expect(f.files.readDocument(target)).rejects.toMatchObject({ code: 'FILE_NOT_FOUND' }) + }) + + it('keeps an unavailable directory distinct from a missing file', async () => { + const f = await fixture() + await rm(f.workspace, { recursive: true }) + const target = { ...f.target, path: 'note.md' } + await expect(f.files.read(target)).rejects.toMatchObject({ code: 'ENOENT' }) + await expect(f.files.readDocument(target)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('preserves reader failures instead of reporting an existing file as missing', async () => { + const f = await fixture() + const path = join(f.workspace, 'note.md') + await writeFile(path, '# retained\n') + vi.stubEnv('LEXORA_BUDDY_FILE_READER', join(f.root, 'missing-reader')) + await expect(f.files.read({ ...f.target, path: 'note.md' })).rejects.toMatchObject({ code: 'BOUNDED_FILE_READER_UNAVAILABLE' }) + expect(await readFile(path, 'utf8')).toBe('# retained\n') }) it('saves UTF-8 content without changing its BOM, rejects stale etags and rechecks grants', async () => { @@ -159,7 +205,8 @@ describe('space file browsing', () => { expect((await f.files.list(f.target)).entries.find(entry => entry.name === 'escape')).toMatchObject({ unavailable: true }) for (const path of ['../outside.txt', join(f.root, 'outside.txt'), 'escape']) await expect(f.files.read({ ...f.target, path })).rejects.toThrow() - await expect(f.files.read({ ...f.target, path: 'inside.txt', revision: 99 })).rejects.toThrow() + await expect(f.files.read({ ...f.target, path: 'inside.txt', revision: 99 })).rejects.toMatchObject({ code: 'VALIDATION_FAILED' }) + await expect(f.files.readDocument({ ...f.target, path: 'inside.txt', revision: 99 })).rejects.toMatchObject({ code: 'VALIDATION_FAILED' }) await f.spaces.delete(f.target.spaceId) await expect(f.files.list(f.target)).rejects.toThrow() await expect(f.files.read({ ...f.target, path: 'inside.txt' })).rejects.toThrow() diff --git a/apps/buddy/src/modules/files/widgets/DesktopFileEditor.vue b/apps/buddy/src/modules/files/widgets/DesktopFileEditor.vue index d5d82e1a3..8115106cd 100644 --- a/apps/buddy/src/modules/files/widgets/DesktopFileEditor.vue +++ b/apps/buddy/src/modules/files/widgets/DesktopFileEditor.vue @@ -200,7 +200,7 @@ onScopeDispose(controller.configuration.subscribe(() => editor?.updateOptions({ 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..76343e5cb 100644 --- a/apps/buddy/src/modules/files/widgets/__tests__/fileViewIdentity.spec.ts +++ b/apps/buddy/src/modules/files/widgets/__tests__/fileViewIdentity.spec.ts @@ -4,6 +4,7 @@ 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 DesktopFileLoading from '@/shared/ui/loading/DesktopFileLoading.vue' @@ -66,7 +67,7 @@ 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'], readDocument?: WorkingCopyProvider['read'] } = {}) { const registry = new ContributionRegistry() registry.register('files', scope => scope.view({ id: 'files.editor', renderer: 'files.editor', label: 'File', locations: ['main'], multiple: true, supports: () => true })) const controller = new WorkbenchController(registry) @@ -74,7 +75,7 @@ async function mount(options: { deferredFirst?: boolean, preview?: boolean, read 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: options.readDocument ?? (async value => ({ text: String(value.data.path), etag: 'version' })), save: async (_, value) => ({ status: 'saved', document: value }) }) const editors: Editor[] = [] const leases: Array<{ resource: ResourceRef, releases: number }> = [] const resume = Promise.withResolvers() @@ -117,6 +118,59 @@ async function mount(options: { deferredFirst?: boolean, preview?: boolean, read } describe('file view semantic identity', () => { + it.each(['FILE_NOT_FOUND', 'LOCAL_CHAT_OPERATION_FAILED'])('renders %s preview failures and recovers through retry', async (code) => { + let restored = false + const f = await mount({ preview: true, readFile: async () => { + if (!restored) + throw new Error(`Error invoking remote method: Error: LEXORA_LOCAL_CHAT_ERROR:${code}:0`) + return preview('restored preview') + } }) + const labels = workbenchLabels('en-US') + await vi.waitFor(() => expect(f.element.querySelector('[role="alert"]')?.textContent).toContain(code === 'FILE_NOT_FOUND' ? labels.fileMissing : labels.failed)) + restored = true + f.element.querySelector('.file-preview__retry')!.click() + await vi.waitFor(() => expect(f.element.textContent).toContain('restored preview')) + expect(f.element.querySelector('[role="alert"]')).toBeNull() + }) + + it('shows a missing document without discarding edits and clears the error after external recovery', async () => { + let missing = false + let document = { text: 'original', etag: 'a' } + const f = await mount({ readDocument: async () => { + if (missing) + throw new Error('LEXORA_LOCAL_CHAT_ERROR:FILE_NOT_FOUND:0') + return document + } }) + const target = f.controller.layout.views[f.id]!.resource + await vi.waitFor(() => expect(f.copies.get(target)?.etag).toBe('a')) + f.copies.edit(target, 'local edits') + missing = true + await f.copies.refresh(target) + await nextTick() + expect(f.element.querySelector('.file-editor__error')?.textContent).toContain(workbenchLabels('en-US').fileMissing) + expect(f.copies.get(target)).toMatchObject({ text: 'local edits', dirty: true }) + missing = false + document = { text: 'restored disk', etag: 'b' } + await f.copies.refresh(target) + await nextTick() + expect(f.element.querySelector('.file-editor__error')).toBeNull() + expect(f.copies.get(target)).toMatchObject({ text: 'local edits', dirty: true, conflict: document, error: null }) + }) + + it('retries an initially missing document and removes the error when the file returns', async () => { + let missing = true + const f = await mount({ readDocument: async () => { + if (missing) + throw new Error('LEXORA_LOCAL_CHAT_ERROR:FILE_NOT_FOUND:0') + return { text: 'restored document', etag: 'a' } + } }) + await vi.waitFor(() => expect(f.element.querySelector('.file-editor__error')?.textContent).toContain(workbenchLabels('en-US').fileMissing)) + missing = false + f.element.querySelector('.file-editor__error button')!.click() + await vi.waitFor(() => expect(f.element.querySelector('.file-editor__error')).toBeNull()) + expect(f.copies.get(f.controller.layout.views[f.id]!.resource)).toMatchObject({ text: 'restored document', error: null }) + }) + it('keeps the editor and its input through cursor persistence and layout state snapshots', async () => { const f = await mount() await vi.waitFor(() => expect(f.editors).toHaveLength(1)) @@ -170,7 +224,7 @@ describe('file view semantic identity', () => { f.controller.updateView(f.id, { resource: { ...current.resource, data: { ...current.resource.data, revision: 2 } } }) second.resolve(preview('current authorized content')) await vi.waitFor(() => expect(f.element.textContent).toContain('current authorized content')) - first.reject(new Error('retired request failure')) + first.reject(new Error('LEXORA_LOCAL_CHAT_ERROR:FILE_NOT_FOUND:0')) await new Promise(resolve => setTimeout(resolve, 0)) await nextTick() expect(f.element.textContent).toContain('current authorized content') diff --git a/apps/buddy/src/workbench/services/WorkingCopyService.ts b/apps/buddy/src/workbench/services/WorkingCopyService.ts index ff0e03b48..1e6c1f0e9 100644 --- a/apps/buddy/src/workbench/services/WorkingCopyService.ts +++ b/apps/buddy/src/workbench/services/WorkingCopyService.ts @@ -5,6 +5,7 @@ import type { ResourceRef } from '../common/workbench' import { Emitter, filterEvent } from '@buddy-shared/events/Emitter' import { copyEventSnapshot } from '@buddy-shared/events/eventSnapshot' import { ReadonlyMapView } from '@buddy-shared/events/ReadonlyMapView' +import { parseLocalChatPublicError, readLocalChatErrorCode } from '@buddy-shared/runtime/localChatError' import { resourceKey } from '../common/workbench' export interface TextDocument { text: string, etag: string } @@ -336,18 +337,20 @@ export class WorkingCopyService { if (this.#stopped || current?.incarnation !== started.incarnation) return copyEventSnapshot({ ...started, loading: false, error: 'WORKING_COPY_RELEASED' }) this.#loading.delete(started.key) + const error = current.error === 'FILE_NOT_FOUND' || current.error === 'FILE_READ_FAILED' ? null : current.error if (current.dirty && current.etag) - return this.#commit('loaded', { ...current, loading: false, conflict: document.etag !== current.etag ? document : null }, { operationId }) + return this.#commit('loaded', { ...current, loading: false, error, conflict: document.etag !== current.etag ? document : null }, { operationId }) const text = current.dirty ? current.text : document.text const contentVersion = current.contentVersion + Number(text !== current.text) - return this.#commit('loaded', { ...current, text, contentVersion, savedVersion: text === document.text ? contentVersion : current.savedVersion, baseText: document.text, etag: document.etag, loading: false, conflict: null }, { operationId }) + return this.#commit('loaded', { ...current, text, contentVersion, savedVersion: text === document.text ? contentVersion : current.savedVersion, baseText: document.text, etag: document.etag, loading: false, error, conflict: null }, { operationId }) } - catch { + catch (error) { const current = this.#copies.get(started.key) if (this.#stopped || current?.incarnation !== started.incarnation) return copyEventSnapshot({ ...started, loading: false, error: 'WORKING_COPY_RELEASED' }) this.#loading.delete(started.key) - return this.#commit('load-failed', { ...current, loading: false, error: 'FILE_READ_FAILED' }, { operationId }) + const code = readLocalChatErrorCode(error) ?? (error instanceof Error ? parseLocalChatPublicError(error.message)?.code : null) + return this.#commit('load-failed', { ...current, loading: false, error: code === 'FILE_NOT_FOUND' ? code : 'FILE_READ_FAILED' }, { operationId }) } finally { if (this.#loading.get(started.key) === promise) diff --git a/apps/buddy/src/workbench/services/__tests__/WorkingCopyService.spec.ts b/apps/buddy/src/workbench/services/__tests__/WorkingCopyService.spec.ts index a4d034cad..40ec8cc3d 100644 --- a/apps/buddy/src/workbench/services/__tests__/WorkingCopyService.spec.ts +++ b/apps/buddy/src/workbench/services/__tests__/WorkingCopyService.spec.ts @@ -105,6 +105,49 @@ describe('working copy ownership and save facts', () => { await copies.dispose() }) + it.each([ + ['structured', Object.assign(new Error('Read failed'), { code: 'FILE_NOT_FOUND' }), 'FILE_NOT_FOUND'], + ['IPC', new Error('Error invoking remote method: Error: LEXORA_LOCAL_CHAT_ERROR:FILE_NOT_FOUND:0'), 'FILE_NOT_FOUND'], + ['unclassified', new Error('ENOENT: private diagnostic'), 'FILE_READ_FAILED'], + ])('classifies %s read failures without exposing diagnostics', async (_kind, failure, expected) => { + const copies = new WorkingCopyService({ ...provider(), read: async () => { + throw failure + } }) + try { + expect(await copies.open(resource)).toMatchObject({ error: expected, loading: false, text: '' }) + } + finally { + await copies.dispose() + } + }) + + it.each([false, true])('clears missing errors after an external refresh while preserving edits (dirty=%s)', async (dirty) => { + let missing = false + let document = { text: 'original', etag: 'a' } + const copies = new WorkingCopyService({ ...provider(), read: async () => { + if (missing) + throw new Error('LEXORA_LOCAL_CHAT_ERROR:FILE_NOT_FOUND:0') + return document + } }) + try { + await copies.open(resource) + if (dirty) + copies.edit(resource, 'local edits') + missing = true + await copies.refresh(resource) + expect(copies.get(resource)).toMatchObject({ error: 'FILE_NOT_FOUND', text: dirty ? 'local edits' : 'original', dirty }) + missing = false + document = { text: 'restored', etag: 'b' } + await copies.refresh(resource) + expect(copies.get(resource)).toMatchObject({ error: null, text: dirty ? 'local edits' : 'restored', dirty, conflict: dirty ? document : null }) + if (dirty) + expect(copies.backups()).toMatchObject([{ text: 'local edits', baseText: 'original', etag: 'a' }]) + } + finally { + await copies.dispose() + } + }) + it('does not replay older queued changes over a backup resync snapshot', async () => { const copies = new WorkingCopyService(provider()) await copies.open(resource) @@ -175,6 +218,8 @@ describe('working copy ownership and save facts', () => { expect(saved).toEqual([]) expect(output.mock.calls).toEqual([['WORKING_COPY_OBSERVER_FAILED']]) expect(copies.get(resource)?.error).toBe('FILE_SAVE_FAILED') + await copies.refresh(resource) + expect(copies.get(resource)?.error).toBe('FILE_SAVE_FAILED') } finally { output.mockRestore()