diff --git a/apps/web/src/components/Panels/FilesPanel.test.tsx b/apps/web/src/components/Panels/FilesPanel.test.tsx index 2001f724e..b7445a356 100644 --- a/apps/web/src/components/Panels/FilesPanel.test.tsx +++ b/apps/web/src/components/Panels/FilesPanel.test.tsx @@ -17,9 +17,11 @@ import type * as ReactI18next from 'react-i18next'; const mocks = vi.hoisted(() => ({ list: vi.fn(), add: vi.fn(), + copy: vi.fn(), t: (key: string) => key, })); vi.mock('@/api/device-files', () => ({ listDeviceFiles: mocks.list })); +vi.mock('@/utils/io/clipboard', () => ({ copyToClipboard: mocks.copy })); vi.mock('react-i18next', async (importOriginal) => ({ ...(await importOriginal()), useTranslation: () => ({ t: mocks.t }), @@ -57,6 +59,7 @@ const file = { }; beforeEach(() => { vi.clearAllMocks(); + mocks.copy.mockResolvedValue(undefined); vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); useCanvasStore.setState({ canvasId: 'canvas-files', addNode: mocks.add }); usePanelStore.getState().openFiles({ @@ -158,3 +161,269 @@ it('discards late directory replies after the browsing target changes', async () await act(async () => resolve(page([file]))); expect(host.textContent).not.toContain('file.txt'); }); + +function rowFor(name: string) { + const row = [...host.querySelectorAll('[role="group"]')].find( + (element) => element.getAttribute('aria-label') === name, + ); + if (!row) throw new Error(`Missing row ${name}`); + return row; +} + +async function openMenu(name: string) { + const event = new MouseEvent('contextmenu', { + bubbles: true, + cancelable: true, + clientX: 80, + clientY: 120, + }); + const row = rowFor(name); + const target = row.querySelector('span[title], button') ?? row; + await act(async () => target.dispatchEvent(event)); + expect(event.defaultPrevented).toBe(true); + expect(document.querySelector('[role="menu"]')).not.toBeNull(); +} + +async function selectCopy(label: string, value: string) { + const item = [ + ...document.querySelectorAll('[role="menuitem"]'), + ].find((element) => element.textContent === label); + if (!item) throw new Error(`Missing menu item ${label}`); + await act(async () => item.click()); + expect(mocks.copy).toHaveBeenLastCalledWith(value); + expect(document.querySelector('[role="menu"]')).toBeNull(); +} + +it('replaces native row menus with three metadata copy actions but retains native input menus', async () => { + const folder = { + name: 'folder', + path: '/root/folder', + kind: 'directory' as const, + }; + mocks.list.mockResolvedValueOnce(page([folder, file])); + await act(async () => root.render()); + for (const entry of [folder, file]) { + await openMenu(entry.name); + const menuItem = document.querySelector('[role="menuitem"]'); + if (!menuItem) throw new Error('Missing copy action'); + const menuContext = new MouseEvent('contextmenu', { + bubbles: true, + cancelable: true, + }); + await act(async () => menuItem.dispatchEvent(menuContext)); + expect(menuContext.defaultPrevented).toBe(true); + expect( + [...document.querySelectorAll('[role="menuitem"]')].map( + (item) => item.textContent, + ), + ).toEqual([ + 'files.copyName', + 'files.copyRelativePath', + 'files.copyFullPath', + ]); + await selectCopy('files.copyName', entry.name); + await openMenu(entry.name); + await selectCopy('files.copyRelativePath', entry.name); + await openMenu(entry.name); + await selectCopy('files.copyFullPath', entry.path); + } + const input = host.querySelector('input[aria-label="files.path"]'); + if (!input) throw new Error('Missing path input'); + const nativeMenu = new MouseEvent('contextmenu', { + bubbles: true, + cancelable: true, + }); + await act(async () => input.dispatchEvent(nativeMenu)); + expect(nativeMenu.defaultPrevented).toBe(false); + expect(mocks.list).toHaveBeenCalledOnce(); + expect(mocks.add).not.toHaveBeenCalled(); +}); + +it.each([ + { + source: '/alias', + canonicalRoot: '/actual-root', + folderPath: '/actual-root/link dir', + canonicalChild: '/outside', + filePath: '/outside/报告 $x.txt', + relative: 'link dir/报告 $x.txt', + }, + { + source: '/root', + canonicalRoot: '/root', + folderPath: '/root/link dir', + canonicalChild: '/root/link dir', + filePath: '/root/link dir/back\\slash.txt', + relative: 'link dir/back\\slash.txt', + }, + { + source: 'C:/root', + canonicalRoot: 'C:\\root', + folderPath: 'C:\\root\\link dir', + canonicalChild: 'D:\\outside', + filePath: 'D:\\outside\\报告 $x.txt', + relative: 'link dir\\报告 $x.txt', + }, + { + source: '\\\\host\\share', + canonicalRoot: '\\\\host\\share\\', + folderPath: '\\\\host\\share\\link dir', + canonicalChild: '\\\\other\\share', + filePath: '\\\\other\\share\\报告 $x.txt', + relative: 'link dir\\报告 $x.txt', + }, +])( + 'copies nested displayed paths using the device separator at $source', + async (scenario) => { + const name = scenario.filePath.endsWith('back\\slash.txt') + ? 'back\\slash.txt' + : '报告 $x.txt'; + usePanelStore.getState().openFiles({ + canvasId: 'canvas-files', + agentletId: 'device', + path: scenario.source, + }); + mocks.list + .mockResolvedValueOnce({ + path: scenario.canonicalRoot, + parentPath: scenario.canonicalRoot, + entries: [ + { name: 'link dir', path: scenario.folderPath, kind: 'directory' }, + ], + }) + .mockResolvedValueOnce({ + path: scenario.canonicalChild, + parentPath: scenario.canonicalChild, + entries: [{ name, path: scenario.filePath, kind: 'file' }], + }); + await act(async () => root.render()); + await click('link dir'); + expect( + rowFor('link dir').querySelector('button')?.getAttribute('aria-expanded'), + ).toBe('true'); + expect(document.querySelector('[role="menu"]')).toBeNull(); + await openMenu(name); + await selectCopy('files.copyRelativePath', scenario.relative); + await openMenu(name); + await selectCopy('files.copyFullPath', scenario.filePath); + await openMenu(name); + await selectCopy('files.copyName', name); + expect(mocks.list).toHaveBeenCalledTimes(2); + }, +); + +it('supports keyboard opening, Escape and outside dismissal, and reports clipboard failures', async () => { + mocks.list.mockResolvedValueOnce(page([file])); + await act(async () => root.render()); + const trigger = rowFor(file.name).querySelector( + '[aria-haspopup="menu"]', + ); + if (!trigger) throw new Error('Missing actions trigger'); + await act(async () => { + trigger.focus(); + trigger.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'F10', + shiftKey: true, + bubbles: true, + cancelable: true, + }), + ); + await new Promise((resolve) => + requestAnimationFrame(() => resolve()), + ); + }); + expect(document.querySelector('[role="menu"]')).not.toBeNull(); + await act(async () => { + const first = document.querySelector('[role="menuitem"]'); + first?.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'ArrowDown', + bubbles: true, + cancelable: true, + }), + ); + }); + expect(document.activeElement?.textContent).toBe('files.copyRelativePath'); + await act(async () => + document.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Escape', + bubbles: true, + }), + ), + ); + expect(document.querySelector('[role="menu"]')).toBeNull(); + await act( + async () => + new Promise((resolve) => requestAnimationFrame(() => resolve())), + ); + expect(document.activeElement).toBe(trigger); + await openMenu(file.name); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + await act(async () => + document.body.dispatchEvent( + new PointerEvent('pointerdown', { bubbles: true }), + ), + ); + expect(document.querySelector('[role="menu"]')).toBeNull(); + mocks.copy.mockRejectedValueOnce(new Error('Clipboard denied')); + await act(async () => + trigger.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'ContextMenu', + bubbles: true, + cancelable: true, + }), + ), + ); + await selectCopy('files.copyFullPath', file.path); + expect(host.querySelector('[role="alert"]')?.textContent).toBe( + 'files.copyFailed', + ); + await openMenu(file.name); + await selectCopy('files.copyFullPath', file.path); + expect(host.querySelector('[role="alert"]')).toBeNull(); + await act(async () => trigger.click()); + expect(document.querySelector('[role="menu"]')).not.toBeNull(); + await selectCopy('files.copyName', file.name); +}); + +it('preserves file dragging and retires the menu when the browsing root changes', async () => { + mocks.list + .mockResolvedValueOnce(page([file])) + .mockResolvedValueOnce(page([])); + await act(async () => root.render()); + const draggable = rowFor(file.name).querySelector('[draggable="true"]'); + if (!draggable) throw new Error('Missing draggable file'); + const dataTransfer = { effectAllowed: '', setData: vi.fn() }; + const event = new Event('dragstart', { bubbles: true, cancelable: true }); + Object.defineProperty(event, 'dataTransfer', { value: dataTransfer }); + await act(async () => draggable.dispatchEvent(event)); + expect(dataTransfer.effectAllowed).toBe('copy'); + const payload = dataTransfer.setData.mock.calls.find( + ([mime]) => mime === 'application/x-huabu-dnd', + )?.[1]; + expect(JSON.parse(payload)).toMatchObject({ + kind: 'fileReference', + data: { + label: file.name, + fileReference: { + agentletId: 'device', + path: file.path, + deviceName: 'Remote', + }, + }, + }); + await openMenu(file.name); + await act(async () => + usePanelStore.getState().openFiles({ + canvasId: 'canvas-files', + agentletId: 'device', + path: '/new', + }), + ); + expect(document.querySelector('[role="menu"]')).toBeNull(); +}); diff --git a/apps/web/src/components/Panels/FilesPanel.tsx b/apps/web/src/components/Panels/FilesPanel.tsx index 17e75dd90..0598b1681 100644 --- a/apps/web/src/components/Panels/FilesPanel.tsx +++ b/apps/web/src/components/Panels/FilesPanel.tsx @@ -23,10 +23,14 @@ import useCanvasStore from '@/store/canvasStore'; import { usePanelStore } from '@/store/panelStore'; import { setDragPayload } from '@/utils/io/dragDrop'; +import { FilesPanelEntryMenu } from './FilesPanelEntryMenu'; + import type { FileListResult } from '@huabu/shared'; type Entry = FileListResult['entries'][number]; -type Row = { entry: Entry; depth: number } | { more: string; depth: number }; +type Row = + | { entry: Entry; depth: number; relativePath: string } + | { more: string; depth: number }; const MAX_TREE_ENTRIES = 2000; export function FilesPanel() { @@ -158,18 +162,23 @@ export function FilesPanel() { const rows = useMemo(() => { const result: Row[] = []; const visited = new Set(); - const visit = (directory: string, depth: number) => { + const rootPath = sourcePath ? pages[sourcePath]?.path : undefined; + const separator = rootPath?.startsWith('/') ? '/' : '\\'; + const visit = (directory: string, depth: number, parent: string) => { if (visited.has(directory) || depth > 32) return; visited.add(directory); const page = pages[directory]; for (const entry of page?.entries ?? []) { - result.push({ entry, depth }); + const relativePath = parent + ? `${parent}${separator}${entry.name}` + : entry.name; + result.push({ entry, depth, relativePath }); if (entry.kind === 'directory' && expanded.has(entry.path)) - visit(entry.path, depth + 1); + visit(entry.path, depth + 1, relativePath); } if (page?.nextCursor) result.push({ more: directory, depth }); }; - if (sourcePath) visit(sourcePath, 0); + if (sourcePath) visit(sourcePath, 0, ''); return result; }, [pages, expanded, sourcePath]); @@ -314,83 +323,92 @@ export function FilesPanel() { {t('files.more')} ); - const { entry, depth } = row; + const { entry, depth, relativePath } = row; const directory = entry.kind === 'directory'; const Icon = directory ? Folder : FileText; return ( -
+ setError(copied ? null : t('files.copyFailed')) + } > - {directory ? ( - - ) : ( - <> - { - if (!source || entry.kind !== 'file') return; - setDragPayload(event, { - kind: 'fileReference', - origin: { type: 'user-created' }, - data: { - label: entry.name, - fileReference: { - agentletId: source.agentletId, - path: entry.path, - deviceName: selectedDevice?.displayName, - }, - }, - }); +
+ {directory ? ( + + ) : ( + <> + { + if (!source || entry.kind !== 'file') return; + setDragPayload(event, { + kind: 'fileReference', + origin: { type: 'user-created' }, + data: { + label: entry.name, + fileReference: { + agentletId: source.agentletId, + path: entry.path, + deviceName: selectedDevice?.displayName, + }, + }, + }); + }} > - - - ) : null} - - )} -
+ + + {entry.name} + +
+ {entry.kind === 'file' ? ( + + ) : null} + + )} +
+ ); }} /> diff --git a/apps/web/src/components/Panels/FilesPanelEntryMenu.tsx b/apps/web/src/components/Panels/FilesPanelEntryMenu.tsx new file mode 100644 index 000000000..7e8f05082 --- /dev/null +++ b/apps/web/src/components/Panels/FilesPanelEntryMenu.tsx @@ -0,0 +1,105 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { MoreHorizontal } from 'lucide-react'; +import { useState, type ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Button } from '@/components/Common/Button'; +import { + DropdownMenu, + DropdownMenuItem, +} from '@/components/Common/DropdownMenu'; +import { copyToClipboard } from '@/utils/io/clipboard'; + +type FilesPanelEntryMenuProps = { + name: string; + path: string; + relativePath: string; + onCopyResult: (copied: boolean) => void; + children: ReactNode; +}; + +export function FilesPanelEntryMenu({ + name, + path, + relativePath, + onCopyResult, + children, +}: FilesPanelEntryMenuProps) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const copy = async (value: string) => { + try { + await copyToClipboard(value); + onCopyResult(true); + } catch { + onCopyResult(false); + } finally { + setOpen(false); + } + }; + + return ( +
{ + event.preventDefault(); + event.stopPropagation(); + setOpen(true); + }} + > +
{children}
+ { + if ( + event.key === 'ContextMenu' || + (event.shiftKey && event.key === 'F10') + ) { + event.preventDefault(); + event.stopPropagation(); + setOpen(true); + } + }} + > + + + } + > +
{ + event.preventDefault(); + event.stopPropagation(); + }} + > + void copy(name)}> + {t('files.copyName')} + + void copy(relativePath)}> + {t('files.copyRelativePath')} + + void copy(path)}> + {t('files.copyFullPath')} + +
+
+
+ ); +} diff --git a/apps/web/src/i18n/resources/en/common.json b/apps/web/src/i18n/resources/en/common.json index 8cdaf6cac..3ab707f63 100644 --- a/apps/web/src/i18n/resources/en/common.json +++ b/apps/web/src/i18n/resources/en/common.json @@ -15,6 +15,11 @@ "empty": "This directory is empty.", "more": "Load more entries", "add": "Add {{name}} to Canvas", + "copyName": "Copy name", + "entryActions": "Actions for {{name}}", + "copyRelativePath": "Copy relative path", + "copyFullPath": "Copy full path", + "copyFailed": "Could not copy to the clipboard. Please retry.", "offline": "This device is offline. Reconnect it and refresh.", "treeLimit": "Tree limit reached. Open a narrower directory or refresh.", "invalidReference": "Invalid file reference", diff --git a/apps/web/src/i18n/resources/zh-CN/common.json b/apps/web/src/i18n/resources/zh-CN/common.json index 85833e8fb..3ec7eae43 100644 --- a/apps/web/src/i18n/resources/zh-CN/common.json +++ b/apps/web/src/i18n/resources/zh-CN/common.json @@ -15,6 +15,11 @@ "empty": "此目录为空。", "more": "加载更多条目", "add": "将 {{name}} 添加到画布", + "copyName": "复制名称", + "entryActions": "{{name}} 的操作", + "copyRelativePath": "复制相对路径", + "copyFullPath": "复制完整路径", + "copyFailed": "无法复制到剪贴板,请重试。", "offline": "设备已离线,请重新连接后刷新。", "treeLimit": "已达到文件树上限,请打开更小的目录或刷新。", "invalidReference": "文件引用无效", diff --git a/docs/architecture/device-files.md b/docs/architecture/device-files.md index 0ffdc7e26..95a1955c0 100644 --- a/docs/architecture/device-files.md +++ b/docs/architecture/device-files.md @@ -35,6 +35,8 @@ List cursors encode the canonical directory, hidden-file option and last name. P Files uses existing Common controls and `react-virtuoso` for a lazy expandable directory disclosure list. It loads directories on demand, supports hidden-file filtering and pagination, and caps a mounted tree at 2,000 entries and 32 nested levels. Opening a path or refreshing rereads the tree; there is no watcher, polling or rename tracking. Layers remains the other sidebar mode, and Canvas search switches back to Layers. +File and directory rows replace the browser context menu with Copy name, Copy relative path and Copy full path. Relative paths follow the displayed hierarchy from the currently opened root, preserving browsed symlink names and the source device's path separator; full paths copy the entry's absolute device path verbatim. These actions copy metadata only, reuse Common DropdownMenu and the existing clipboard fallback, and report clipboard failures visibly. An actions button appears on row hover or keyboard focus and opens the same menu, including with Shift+F10 or the Context Menu key; existing disclosure and Add controls remain independent buttons. The directory path input retains its native editing menu. + Adding or dragging a file uses existing Canvas commands to create a `fileReference` node. Structural data stores only `{ agentletId, path, deviceName? }` under `data.fileReference`, plus ordinary presentation fields. The label uses a frontmatter-only node sidecar. Content/source mutation endpoints reject source bytes on these nodes. Duplication and bundle export preserve inert references, not external file contents. Loading a Canvas or processing/searching its nodes does not read remote files or import them into artifacts. Opening a file-reference Preview reads supported UTF-8 text into component-local state. The view reuses read-only CodeMirror in plain-text mode; `.tex` remains source and is never compiled. Manual refresh restarts from the path, including a replacement made by atomic save. Read more continues up to a 256 KiB display cap; stale content and offline devices remain explicit errors until retry. No edit, download-as-import, automatic AI ingestion, shell execution or automatic reference repair is provided. @@ -55,5 +57,6 @@ CodeMirror's model owns capture even when selected lines are virtualized. New ca | [private session service](../../apps/server/src/modules/agent/acp/device-files.ts) | Exact-device routing, lifecycle, queueing and response validation | | [HTTP route](../../apps/server/src/modules/agent/acp/device-files.route.ts) | Owner authorization and explicit HTTP errors | | [Files panel](../../apps/web/src/components/Panels/FilesPanel.tsx) | Lazy directory disclosure, pagination, Add and copy-only drag | +| [File entry menu](../../apps/web/src/components/Panels/FilesPanelEntryMenu.tsx) | Accessible copy actions and row-specific native-menu replacement | | [file-reference renderers](../../apps/web/src/components/Nodes/fileReference/) | Metadata card and bounded read-only source Preview | | [FileCommentActions](../../apps/web/src/components/Nodes/fileReference/FileCommentActions.tsx) | Model-based CodeMirror selection and frozen file-source capture |