From 169fb2b46c9f590e8400115caf0f1a0f6d77481a Mon Sep 17 00:00:00 2001 From: probe Date: Wed, 21 Oct 2026 00:13:12 +0800 Subject: [PATCH] =?UTF-8?q?feat(buddy):=20=E5=9C=A8=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=99=A8=E4=B8=AD=E6=89=93=E5=BC=80=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E4=BA=A7=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 任务产出现在优先在文件管理器中打开,而不是只能在产出页里看: - 位于该任务所属空间内的产出,按空间文件在文件预览中打开,因此可以刷新、编辑, 也能使用文档插件。 - 位于空间内的文件夹产出,在文件管理器中打开并展开、聚焦该文件夹,复用目录树自身的点击路径。 - 文件产出打开时收起目录树,用户仍可手动展开。 - 以下情况保持原来的产出页:无空间的任务、空间目录之外的产出、其他任务的产出, 以及超出文件预览大小限制的文件。 新增 useTaskResourcePanelArtifact、useResourcePreviews、ExplorerTree 的单元测试。 Co-Authored-By: Claude Sonnet 5.5 --- .../files/explorer/WorkspaceExplorer.vue | 3 +- .../model/context-panel/readContextTab.ts | 2 +- .../model/context-panel/taskContextPanel.ts | 4 + .../model/transcript/chatToolFileTarget.ts | 26 ++++ .../useTaskResourcePanelArtifact.spec.ts | 119 ++++++++++++++++++ .../context-panel/useTaskContextPanel.ts | 71 ++++++++--- .../context-panel/useTaskResourcePanel.ts | 12 +- .../DesktopFilesContextSurface.vue | 2 +- .../__tests__/useResourcePreviews.spec.ts | 14 +++ .../context-panel/useWorkspaceFilePreview.ts | 7 ++ .../src/shared/ui/explorer/ExplorerTree.vue | 26 +++- .../explorer/__tests__/ExplorerTree.spec.ts | 68 ++++++++++ 12 files changed, 334 insertions(+), 20 deletions(-) create mode 100644 apps/buddy/src/modules/tasks/state/context-panel/__tests__/useTaskResourcePanelArtifact.spec.ts create mode 100644 apps/buddy/src/shared/ui/explorer/__tests__/ExplorerTree.spec.ts diff --git a/apps/buddy/src/modules/files/explorer/WorkspaceExplorer.vue b/apps/buddy/src/modules/files/explorer/WorkspaceExplorer.vue index 4d5dccf10..e417b7640 100644 --- a/apps/buddy/src/modules/files/explorer/WorkspaceExplorer.vue +++ b/apps/buddy/src/modules/files/explorer/WorkspaceExplorer.vue @@ -20,6 +20,7 @@ const props = defineProps<{ nodes: readonly ExplorerNode[] loadingKeys: ReadonlySet target: SpaceFileTarget + reveal?: { path: string, token: string } language: BuddyLocale api: Pick load: (node: ExplorerNode) => Promise @@ -124,7 +125,7 @@ async function acknowledge(operation: SpaceFileOperation) {
- +
diff --git a/apps/buddy/src/modules/tasks/model/context-panel/readContextTab.ts b/apps/buddy/src/modules/tasks/model/context-panel/readContextTab.ts index 0efa80d3f..73eba0426 100644 --- a/apps/buddy/src/modules/tasks/model/context-panel/readContextTab.ts +++ b/apps/buddy/src/modules/tasks/model/context-panel/readContextTab.ts @@ -11,7 +11,7 @@ const selectionSchema = z.tuple([z.union([scopeSchema, z.templateLiteral(['space const base = z.object({ id: identity, scope: scopeSchema, source: contextPanelSourceSchema.optional() }) const tabSchema = z.discriminatedUnion('kind', [ base.extend({ kind: z.literal('browser'), conversationId: identity.nullable(), browserKey: identity.optional() }), - base.extend({ kind: z.literal('files'), target: spaceFileTargetSchema, rootName: z.string() }), + base.extend({ kind: z.literal('files'), target: spaceFileTargetSchema, rootName: z.string(), expand: z.object({ path: z.string().min(1).max(4096), token: identity }).optional(), tree: z.object({ visible: z.boolean(), token: identity }).optional() }), base.extend({ kind: z.literal('artifact'), artifact: artifactSchema, label: z.string(), viewMode: z.enum(['preview', 'source']) }), base.extend({ kind: z.literal('changes'), branchId: identity.nullable(), revision: z.string(), changeSet: changeSetSummarySchema.nullable(), conversationId: identity }), base.extend({ kind: z.literal('view'), viewId: identity, label: z.string() }), diff --git a/apps/buddy/src/modules/tasks/model/context-panel/taskContextPanel.ts b/apps/buddy/src/modules/tasks/model/context-panel/taskContextPanel.ts index f2560a87b..755a8c2f0 100644 --- a/apps/buddy/src/modules/tasks/model/context-panel/taskContextPanel.ts +++ b/apps/buddy/src/modules/tasks/model/context-panel/taskContextPanel.ts @@ -50,6 +50,10 @@ export interface TaskFilesContextTab extends ContextTabSource { kind: 'files' target: SpaceFileTarget rootName: string + // A folder to open in the tree; a new token asks to expand it again after the user collapsed it. + expand?: { path: string, token: string } + // Shows or folds the tree pane; a new token applies it again after the user toggled the pane. + tree?: { visible: boolean, token: string } } export interface TaskViewContextTab extends ContextTabSource { diff --git a/apps/buddy/src/modules/tasks/model/transcript/chatToolFileTarget.ts b/apps/buddy/src/modules/tasks/model/transcript/chatToolFileTarget.ts index 8eeb0c17d..5b29d852d 100644 --- a/apps/buddy/src/modules/tasks/model/transcript/chatToolFileTarget.ts +++ b/apps/buddy/src/modules/tasks/model/transcript/chatToolFileTarget.ts @@ -1,6 +1,7 @@ import type { LocalArtifact } from '@buddy-shared/artifacts/artifactApi' import type { LocalSpace } from '@buddy-shared/spaces/spaceApi' import type { SpaceFileTarget } from '@buddy-shared/spaces/spaceFileApi' +import { isTextArtifactMimeType } from '@buddy-shared/artifacts/artifactApi' export function cleanChatToolFilePath(path: string): string { let cleaned = path.trim() @@ -17,6 +18,18 @@ export function cleanChatToolFilePath(path: string): string { return cleaned.split('#')[0]!.split('?')[0]! } +// Space file previews read at most 1 MiB of text and 8 MiB of image; larger outputs stay on the artifact tab, which reads more. +const SPACE_FILE_TEXT_PREVIEW_BYTES = 1024 * 1024 +const SPACE_FILE_IMAGE_PREVIEW_BYTES = 8 * 1024 * 1024 + +export function fitsSpaceFilePreview(artifact: Pick): boolean { + if (artifact.kind === 'directory') + return true + if (artifact.mimeType.startsWith('image/')) + return artifact.sizeBytes <= SPACE_FILE_IMAGE_PREVIEW_BYTES + return !isTextArtifactMimeType(artifact.mimeType) || artifact.sizeBytes <= SPACE_FILE_TEXT_PREVIEW_BYTES +} + export function resolveChatArtifactTarget(artifacts: readonly LocalArtifact[], path: string): LocalArtifact | null { const normalized = cleanChatToolFilePath(path).replaceAll('\\', '/').replace(/^\/+([a-z]:\/)/i, '$1').replace(/^(\.\/)+/, '') if (!normalized || normalized.includes('\0') || normalized.split('/').includes('..') @@ -55,3 +68,16 @@ export function resolveChatToolFileTarget(space: LocalSpace | null, path: string return null return { spaceId: space.id, directoryId: directory.id, revision: directory.revision, path: segments.join('/') } } + +// Like resolveChatToolFileTarget, but the space root itself is a valid folder and resolves to the empty path. +export function resolveChatToolDirectoryTarget(space: LocalSpace | null, path: string): SpaceFileTarget | null { + const target = resolveChatToolFileTarget(space, path) + const directory = space?.primaryDirectory + if (target || !space || space.revokedAt || !directory || directory.revokedAt || !path || path.includes('\0')) + return target + const candidate = cleanChatToolFilePath(path).replaceAll('\\', '/').replace(/\/+$/, '') + const roots = [directory.canonicalRoot, directory.root].map(root => root.replaceAll('\\', '/').replace(/\/+$/, '')) + const windows = /^[a-z]:/i.test(roots[0]!) || roots[0]!.startsWith('//') + const isRoot = roots.some(root => (windows ? root.toLowerCase() === candidate.toLowerCase() : root === candidate)) + return isRoot ? { spaceId: space.id, directoryId: directory.id, revision: directory.revision, path: '' } : null +} diff --git a/apps/buddy/src/modules/tasks/state/context-panel/__tests__/useTaskResourcePanelArtifact.spec.ts b/apps/buddy/src/modules/tasks/state/context-panel/__tests__/useTaskResourcePanelArtifact.spec.ts new file mode 100644 index 000000000..587881de8 --- /dev/null +++ b/apps/buddy/src/modules/tasks/state/context-panel/__tests__/useTaskResourcePanelArtifact.spec.ts @@ -0,0 +1,119 @@ +import type { DesktopBrowserApi } from '@buddy-electron/shared/desktopApi' +import type { LocalArtifact } from '@buddy-shared/artifacts/artifactApi' +import type { LocalSpace } from '@buddy-shared/spaces/spaceApi' +// @vitest-environment jsdom +import { describe, expect, it } from 'vitest' +import { shallowRef } from 'vue' +import { useTaskResourcePanel } from '@/modules/tasks/state/context-panel/useTaskResourcePanel' +import { contextPanelFixture } from './contextPanelFixture' + +const now = '2026-09-10T00:00:00.000Z' + +function space(): LocalSpace { + return { + id: 'space', + name: 'Files', + icon: 'folder', + iconColor: 'default', + memoryScope: 'space_only', + activeRunCount: 0, + additionalDirectories: [], + revokedAt: null, + createdAt: now, + updatedAt: now, + primaryDirectory: { id: 'directory', spaceId: 'space', root: '/workspace', canonicalRoot: '/workspace', revision: 1, accessGrantedAt: now, resourcesTrustedAt: now, revokedAt: null, createdAt: now, updatedAt: now }, + } +} + +function artifact(overrides: Partial = {}): LocalArtifact { + return { artifactId: 'artifact', conversationId: 'conversation', createdAt: now, kind: 'file', mimeType: 'text/markdown', name: 'report.md', path: '/workspace/docs/report.md', previewUrl: null, runId: 'run', sizeBytes: 100, sourceArtifactId: null, sourceToolCallId: 'tool', updatedAt: now, ...overrides } +} + +function folder(overrides: Partial = {}): LocalArtifact { + return artifact({ artifactId: 'folder', kind: 'directory', mimeType: 'inode/directory', name: 'reports', path: '/workspace/docs/reports', sizeBytes: 0, ...overrides }) +} + +function fixture(activeSpace: LocalSpace | null = space()) { + const browser: Pick = { + navigate: async () => { throw new Error('unexpected navigation') }, + onStateChanged: () => () => {}, + ensureSession: async () => { throw new Error('unexpected session') }, + close: async () => {}, + openArtifact: async () => { throw new Error('unexpected browser artifact') }, + } + const { scope, options } = contextPanelFixture({ activeConversationId: shallowRef('conversation'), activeSpace: shallowRef(activeSpace) }) + const panel = scope.run(() => useTaskResourcePanel({ ...options, autoCollapse: shallowRef(false), linkOpening: shallowRef('in-app'), browser }))! + return { panel } +} + +describe('opening task outputs', () => { + it('opens an output inside the task space as a space file', async () => { + const { panel } = fixture() + await panel.openArtifact(artifact()) + expect(panel.activeTab.value).toMatchObject({ kind: 'files', target: { spaceId: 'space', directoryId: 'directory', path: 'docs/report.md' }, source: { conversationId: 'conversation', runId: 'run' }, tree: { visible: false } }) + expect((panel.activeTab.value as { expand?: unknown }).expand).toBeUndefined() + }) + + it('opens binary outputs as space files so document plugins can take them', async () => { + const { panel } = fixture() + await panel.openArtifact(artifact({ mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', name: 'brief.docx', path: '/workspace/brief.docx', sizeBytes: 40 * 1024 * 1024 })) + expect(panel.activeTab.value).toMatchObject({ kind: 'files', target: { path: 'brief.docx' } }) + }) + + it('keeps the output tab when the task has no space', async () => { + const { panel } = fixture(null) + await panel.openArtifact(artifact()) + expect(panel.activeTab.value).toMatchObject({ kind: 'artifact', artifact: { artifactId: 'artifact' } }) + }) + + it('keeps the output tab for files outside the space directory', async () => { + const { panel } = fixture() + await panel.openArtifact(artifact({ path: '/elsewhere/report.md' })) + expect(panel.activeTab.value).toMatchObject({ kind: 'artifact' }) + }) + + it('opens a folder output in the file manager with that folder expanded', async () => { + const { panel } = fixture() + await panel.openArtifact(folder()) + expect(panel.activeTab.value).toMatchObject({ kind: 'files', target: { path: '' }, expand: { path: 'docs/reports' }, tree: { visible: true }, source: { runId: 'run' } }) + }) + + it('keeps the file open in the tab when a folder output is opened again', async () => { + const { panel } = fixture() + await panel.openArtifact(artifact()) + const tabId = panel.activeTab.value!.id + await panel.openArtifact(folder()) + expect(panel.allTabs.value).toHaveLength(1) + expect(panel.activeTab.value).toMatchObject({ id: tabId, target: { path: 'docs/report.md' }, expand: { path: 'docs/reports' }, tree: { visible: true } }) + const first = (panel.activeTab.value as { expand: { token: string } }).expand.token + await panel.openArtifact(folder()) + expect((panel.activeTab.value as { expand: { token: string } }).expand.token).not.toBe(first) + }) + + it('opens the space root folder without asking to expand anything', async () => { + const { panel } = fixture() + await panel.openArtifact(folder({ path: '/workspace' })) + expect(panel.activeTab.value).toMatchObject({ kind: 'files', target: { path: '' } }) + expect((panel.activeTab.value as { expand?: unknown }).expand).toBeUndefined() + }) + + it('keeps the output tab for a folder outside the space', async () => { + const { panel } = fixture() + await panel.openArtifact(folder({ path: '/elsewhere/reports' })) + expect(panel.activeTab.value).toMatchObject({ kind: 'artifact', artifact: { name: 'reports' } }) + }) + + it('keeps the output tab for outputs the file preview cannot hold', async () => { + const { panel } = fixture() + await panel.openArtifact(artifact({ artifactId: 'large-text', sizeBytes: 1024 * 1024 + 1 })) + expect(panel.activeTab.value).toMatchObject({ kind: 'artifact', artifact: { artifactId: 'large-text' } }) + await panel.openArtifact(artifact({ artifactId: 'large-image', mimeType: 'image/png', name: 'chart.png', path: '/workspace/chart.png', sizeBytes: 8 * 1024 * 1024 + 1 })) + expect(panel.activeTab.value).toMatchObject({ kind: 'artifact', artifact: { artifactId: 'large-image' } }) + }) + + it('keeps the output tab for another task\'s output', async () => { + const { panel } = fixture() + await panel.openArtifact(artifact({ conversationId: 'other' })) + expect(panel.allTabs.value).toMatchObject([{ kind: 'artifact', artifact: { conversationId: 'other' } }]) + }) +}) diff --git a/apps/buddy/src/modules/tasks/state/context-panel/useTaskContextPanel.ts b/apps/buddy/src/modules/tasks/state/context-panel/useTaskContextPanel.ts index ed9222009..a96614e32 100644 --- a/apps/buddy/src/modules/tasks/state/context-panel/useTaskContextPanel.ts +++ b/apps/buddy/src/modules/tasks/state/context-panel/useTaskContextPanel.ts @@ -1,13 +1,15 @@ import type { LocalArtifact } from '@buddy-shared/artifacts/artifactApi' import type { LocalChangeSetSummary } from '@buddy-shared/changes/changeApi' import type { ContextPanelSource } from '@buddy-shared/context-panel/contextPanel' -import type { ArtifactViewMode, TaskContextTab } from '../../model/context-panel/taskContextPanel' +import type { LocalSpace } from '@buddy-shared/spaces/spaceApi' +import type { SpaceFileTarget } from '@buddy-shared/spaces/spaceFileApi' +import type { ArtifactViewMode, TaskContextTab, TaskFilesContextTab } from '../../model/context-panel/taskContextPanel' import type { UseTaskContextPanelOptions } from './typing' import { computed, readonly, watch } from 'vue' import { useContextPanelControl } from '@/platform/desktop/useContextPanelControl' import { readContextSelection, readContextTab } from '../../model/context-panel/readContextTab' import { artifactTabId, browserTabId, contextTabSource, spaceTaskBrowserTab, taskContextPanelScope } from '../../model/context-panel/taskContextPanel' -import { resolveChatToolFileTarget } from '../../model/transcript/chatToolFileTarget' +import { resolveChatToolDirectoryTarget, resolveChatToolFileTarget } from '../../model/transcript/chatToolFileTarget' import { useContextPanelTabs } from './useContextPanelTabs' export function useTaskContextPanel(options: UseTaskContextPanelOptions) { @@ -134,7 +136,8 @@ export function useTaskContextPanel(options: UseTaskContextPanelOptions) { return Boolean(resolveChatToolFileTarget(options.activeSpace?.value ?? null, path)) } - function previewFile(path: string, source?: ContextPanelSource) { + // collapseTree: the file is the point, so the tree starts folded away (the user can still open it). + function previewFile(path: string, source?: ContextPanelSource, collapseTree = false) { const space = options.activeSpace?.value ?? null const target = resolveChatToolFileTarget(space, path) if (!target || !space?.primaryDirectory) @@ -147,14 +150,35 @@ export function useTaskContextPanel(options: UseTaskContextPanelOptions) { && tab.target.path === target.path && (!source || tab.source?.runId === source.runId), ) + const tree = collapseTree ? { visible: false, token: crypto.randomUUID() } : undefined if (exactTab) { + if (tree) + updateFilesTab(exactTab.id, { tree }) store.select(exactTab.id) void control.open() return } + const spaceTab = findSpaceFilesTab(target, source) + if (spaceTab) { + selectFile(spaceTab.id, target.path) + if (tree) + updateFilesTab(spaceTab.id, { tree }) + store.select(spaceTab.id) + openPanel() + return + } + + openTab({ ...newFilesTab(space, target, source), tree }) + } + + function updateFilesTab(tabId: string, change: Partial>) { + store.update(tab => tab.id === tabId && tab.kind === 'files' ? { ...tab, ...change } : tab) + } + + function findSpaceFilesTab(target: SpaceFileTarget, source?: ContextPanelSource) { const active = store.activeTab.value - const spaceTab = (active?.kind === 'files' && active.target.spaceId === target.spaceId && active.target.directoryId === target.directoryId && (!source || active.source?.runId === source.runId)) + return (active?.kind === 'files' && active.target.spaceId === target.spaceId && active.target.directoryId === target.directoryId && (!source || active.source?.runId === source.runId)) ? active : store.tabs.value.find(tab => tab.kind === 'files' @@ -162,22 +186,39 @@ export function useTaskContextPanel(options: UseTaskContextPanelOptions) { && tab.target.directoryId === target.directoryId && (!source || tab.source?.runId === source.runId), ) + } - if (spaceTab) { - selectFile(spaceTab.id, target.path) - store.select(spaceTab.id) - void control.open() - return - } - - openTab({ + function newFilesTab(space: LocalSpace, target: SpaceFileTarget, source?: ContextPanelSource): TaskFilesContextTab { + return { id: `files:${source ? taskContextPanelScope(source.conversationId) : store.scope.value}:${target.spaceId}:${target.directoryId}:${source?.runId ?? 'preview'}`, scope: source ? taskContextPanelScope(source.conversationId) : store.scope.value, kind: 'files', - rootName: space.primaryDirectory.root.split(/[\\/]/).filter(Boolean).at(-1) ?? space.primaryDirectory.root, + rootName: space.primaryDirectory!.root.split(/[\\/]/).filter(Boolean).at(-1) ?? space.primaryDirectory!.root, target, source: source ?? currentSource(), - }) + } + } + + function canOpenDirectory(path: string) { + return Boolean(resolveChatToolDirectoryTarget(options.activeSpace?.value ?? null, path)) + } + + // Shows a folder in the file manager's tree without changing the file already open in the tab. + function openDirectory(path: string, source?: ContextPanelSource) { + const space = options.activeSpace?.value ?? null + const target = resolveChatToolDirectoryTarget(space, path) + if (!target || !space?.primaryDirectory) + return + const expand = target.path ? { path: target.path, token: crypto.randomUUID() } : undefined + const tree = { visible: true, token: crypto.randomUUID() } + const spaceTab = findSpaceFilesTab(target, source) + if (spaceTab) { + updateFilesTab(spaceTab.id, { expand, tree }) + store.select(spaceTab.id) + void control.open() + return + } + openTab({ ...newFilesTab(space, { ...target, path: '' }, source), expand, tree }) } function isAvailable(tab: TaskContextTab): boolean { @@ -229,6 +270,7 @@ export function useTaskContextPanel(options: UseTaskContextPanelOptions) { adoptDraft: store.adoptDraft, discardDraft: store.discardDraft, addBrowser, + canOpenDirectory, canPreviewFile, closeTab: store.close, discardConversation: (id: string) => store.discard(taskContextPanelScope(id)), @@ -236,6 +278,7 @@ export function useTaskContextPanel(options: UseTaskContextPanelOptions) { openArtifact, openBrowser, openChanges, + openDirectory, openFiles, previewFile, selectFile, diff --git a/apps/buddy/src/modules/tasks/state/context-panel/useTaskResourcePanel.ts b/apps/buddy/src/modules/tasks/state/context-panel/useTaskResourcePanel.ts index bfa4dc086..f3a65c939 100644 --- a/apps/buddy/src/modules/tasks/state/context-panel/useTaskResourcePanel.ts +++ b/apps/buddy/src/modules/tasks/state/context-panel/useTaskResourcePanel.ts @@ -6,7 +6,7 @@ import type { Ref } from 'vue' import type { UseTaskContextPanelOptions } from './typing' import { computed, onScopeDispose, readonly, shallowRef, watch } from 'vue' import { browserTabId, contextTabSource, isBrowserArtifact } from '../../model/context-panel/taskContextPanel' -import { resolveChatArtifactTarget } from '../../model/transcript/chatToolFileTarget' +import { fitsSpaceFilePreview, resolveChatArtifactTarget } from '../../model/transcript/chatToolFileTarget' import { useTaskContextPanel } from './useTaskContextPanel' interface TaskResourcePanelOptions extends UseTaskContextPanelOptions { @@ -172,7 +172,15 @@ export function useTaskResourcePanel(options: TaskResourcePanelOptions) { return const conversationId = artifact.conversationId if (!isBrowserArtifact(artifact)) { - act(() => taskContext.openArtifact(artifact)) + // An output inside the task's space is just a space file: open it there so it gets the file preview, editing and document plugins. + const source = { conversationId, runId: artifact.runId } + const inSpace = conversationId === options.activeConversationId.value && fitsSpaceFilePreview(artifact) + if (inSpace && artifact.kind === 'directory' && taskContext.canOpenDirectory(artifact.path)) + act(() => taskContext.openDirectory(artifact.path, source)) + else if (inSpace && artifact.kind === 'file' && taskContext.canPreviewFile(artifact.path)) + act(() => taskContext.previewFile(artifact.path, source, true)) + else + act(() => taskContext.openArtifact(artifact)) return } act(() => taskContext.openBrowser({ conversationId, runId: artifact.runId })) diff --git a/apps/buddy/src/modules/tasks/widgets/context-panel/DesktopFilesContextSurface.vue b/apps/buddy/src/modules/tasks/widgets/context-panel/DesktopFilesContextSurface.vue index a5673d9d7..6e151d2eb 100644 --- a/apps/buddy/src/modules/tasks/widgets/context-panel/DesktopFilesContextSurface.vue +++ b/apps/buddy/src/modules/tasks/widgets/context-panel/DesktopFilesContextSurface.vue @@ -44,7 +44,7 @@ const fileView = filePreview.current {{ t('desktop.context.retry') }} - + 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..f057bd1d0 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 @@ -141,6 +141,20 @@ describe('resource previews', () => { await vi.waitFor(() => expect(state.current.value?.nodes[0]?.children?.[0]?.children?.[0]?.key).toBe('src/utils/math.ts')) }) + it('folds or shows the tree when a card asks, and again on a new request after the user toggled it', async () => { + const value = filesTab() + const tab = shallowRef({ ...value, tree: { visible: false, token: 'one' } }) + const { state } = openFiles(tab, vi.fn().mockResolvedValue({ entries: [], nextCursor: null })) + expect(state.current.value?.treeVisible).toBe(false) + state.current.value!.treeVisible = true + tab.value = { ...value, tree: { visible: false, token: 'one' }, target: { ...value.target, path: 'a.md' } } + await nextTick() + expect(state.current.value?.treeVisible).toBe(true) + tab.value = { ...value, tree: { visible: false, token: 'two' } } + await nextTick() + expect(state.current.value?.treeVisible).toBe(false) + }) + it('publishes directory pages before completion and preserves folders expanded during pagination', async () => { const value = filesTab() const tab = shallowRef(value) diff --git a/apps/buddy/src/modules/tasks/widgets/context-panel/useWorkspaceFilePreview.ts b/apps/buddy/src/modules/tasks/widgets/context-panel/useWorkspaceFilePreview.ts index 5d584fdf4..89206855d 100644 --- a/apps/buddy/src/modules/tasks/widgets/context-panel/useWorkspaceFilePreview.ts +++ b/apps/buddy/src/modules/tasks/widgets/context-panel/useWorkspaceFilePreview.ts @@ -45,6 +45,13 @@ export function useWorkspaceFilePreview(tab: Readonly `${tab.value?.id}:${tab.value?.tree?.token}`, () => { + const request = tab.value?.tree + if (request && current.value) + current.value.treeVisible = request.visible + }, { immediate: true, flush: 'sync' }) + watch(() => [tab.value?.id, tab.value?.target.path], async (_, __, onCleanup) => { const value = tab.value const view = current.value diff --git a/apps/buddy/src/shared/ui/explorer/ExplorerTree.vue b/apps/buddy/src/shared/ui/explorer/ExplorerTree.vue index 9b9509de0..d9c553d32 100644 --- a/apps/buddy/src/shared/ui/explorer/ExplorerTree.vue +++ b/apps/buddy/src/shared/ui/explorer/ExplorerTree.vue @@ -3,7 +3,7 @@ import type { VirtualListInst } from 'naive-ui' import type { ExplorerNode, ExplorerRow as TreeRow } from './treeCollection' import type { BuddyLocale } from '@/i18n/buddyI18n' import { NVirtualList } from 'naive-ui' -import { nextTick, useId, useTemplateRef } from 'vue' +import { nextTick, useId, useTemplateRef, watch } from 'vue' import { useBuddyI18n } from '@/i18n/buddyI18n' import ExplorerRow from './ExplorerRow.vue' import { useTreeNavigation } from './useTreeNavigation' @@ -14,6 +14,8 @@ const props = defineProps<{ selectedKey: string | null loadingKeys?: ReadonlySet load?: (node: ExplorerNode) => Promise + // A folder to open as if the user clicked down to it; a new token asks again. + reveal?: { path: string, token: string } }>() const emit = defineEmits<{ select: [key: string], contextmenu: [node: ExplorerNode | null, event: MouseEvent], command: [command: string, node: ExplorerNode | null] }>() const expandedKeys = defineModel('expandedKeys', { required: true }) @@ -30,6 +32,28 @@ const navigation = useTreeNavigation({ scroll: (key) => { void nextTick(() => list.value?.scrollTo({ key })) }, }) const { rows, expanded, focused, pending } = navigation +let revealed: string | undefined +// Clicks each closed folder on the way down; this runs again whenever the rows change, until the last folder is focused. +watch([() => props.reveal, rows], () => { + const request = props.reveal + if (!request || revealed === request.token) + return + let key = '' + for (const segment of request.path.split('/').filter(Boolean)) { + key = key ? `${key}/${segment}` : segment + const row = rows.value.find(item => item.key === key) + if (!row) + return + if (!expanded.value.has(key)) { + activate(row) + return + } + } + revealed = request.token + navigation.focus(key) + // The focus outline only shows while the tree itself has focus, which a real click gives it. + tree.value?.focus({ preventScroll: true }) +}, { immediate: true }) function statusLabel(node: ExplorerNode) { return node.changeType ? t(`desktop.context.changeType.${node.changeType}`) : undefined } diff --git a/apps/buddy/src/shared/ui/explorer/__tests__/ExplorerTree.spec.ts b/apps/buddy/src/shared/ui/explorer/__tests__/ExplorerTree.spec.ts new file mode 100644 index 000000000..737e71d9b --- /dev/null +++ b/apps/buddy/src/shared/ui/explorer/__tests__/ExplorerTree.spec.ts @@ -0,0 +1,68 @@ +// @vitest-environment jsdom +import type { ExplorerNode } from '../treeCollection' +import { describe, expect, it, vi } from 'vitest' +import { createApp, h, nextTick, shallowRef } from 'vue' +import ExplorerTree from '../ExplorerTree.vue' + +// The virtual list probes touch support and watches its size; jsdom provides neither. +window.matchMedia ??= (() => ({ matches: false, addEventListener() {}, removeEventListener() {} })) as unknown as typeof window.matchMedia +globalThis.ResizeObserver ??= class { observe() {} unobserve() {} disconnect() {} } +Element.prototype.scrollTo ??= () => {} + +const unloaded = (key: string): ExplorerNode => ({ key, label: key.split('/').at(-1)!, kind: 'directory' }) + +function mount() { + const nodes = shallowRef([unloaded('src')]) + const expandedKeys = shallowRef([]) + const reveal = shallowRef<{ path: string, token: string } | undefined>() + const loaded: string[] = [] + // Folders load like the file manager does: children arrive after the folder is clicked. + const load = async (node: ExplorerNode) => { + loaded.push(node.key) + const children = node.key === 'src' ? [unloaded('src/utils')] : node.key === 'src/utils' ? [{ key: 'src/utils/math.ts', label: 'math.ts', kind: 'file' as const }] : [] + const fill = (items: readonly ExplorerNode[]): ExplorerNode[] => items.map(item => item.key === node.key ? { ...item, children } : { ...item, children: item.children && fill(item.children) }) + nodes.value = fill(nodes.value) + } + const root = document.createElement('div') + document.body.append(root) + const app = createApp({ + render: () => h(ExplorerTree, { + 'language': 'zh-CN', + 'nodes': nodes.value, + 'selectedKey': null, + 'expandedKeys': expandedKeys.value, + 'onUpdate:expandedKeys': (value: string[]) => { expandedKeys.value = value }, + 'load': load, + 'reveal': reveal.value, + }), + }) + app.mount(root) + const focused = () => root.querySelector('[role="tree"]')?.getAttribute('aria-activedescendant') ?? '' + const treeHasFocus = () => root.querySelector('[role="tree"]') === document.activeElement + return { app, expandedKeys, focused, loaded, reveal, treeHasFocus } +} + +describe('explorer tree reveal', () => { + it('opens each folder on the way down as a click would, then focuses the last one', async () => { + const tree = mount() + tree.reveal.value = { path: 'src/utils', token: 'one' } + await vi.waitFor(() => expect(tree.expandedKeys.value).toEqual(['src', 'src/utils'])) + expect(tree.loaded).toEqual(['src', 'src/utils']) + await vi.waitFor(() => expect(decodeURIComponent(tree.focused())).toMatch(/src\/utils$/)) + expect(tree.treeHasFocus()).toBe(true) + tree.app.unmount() + }) + + it('opens the folder again for a new request after the user closed it, but not for the same one', async () => { + const tree = mount() + tree.reveal.value = { path: 'src', token: 'one' } + await vi.waitFor(() => expect(tree.expandedKeys.value).toEqual(['src'])) + tree.expandedKeys.value = [] + await nextTick() + await nextTick() + expect(tree.expandedKeys.value).toEqual([]) + tree.reveal.value = { path: 'src', token: 'two' } + await vi.waitFor(() => expect(tree.expandedKeys.value).toEqual(['src'])) + tree.app.unmount() + }) +})