feat(web-shell): upload files by dropping into the file tree (#14145)

* feat(web-shell): upload files by dropping into the file tree

* test(serve): allow hosted replay setup to settle under CI load

* fix(web-shell): address file tree upload review feedback

---------

Co-authored-by: 钉萁 <[email protected]>
This commit is contained in:
ytahdnand钉萁 authored and GitHub committed 2026-10-15 07:51:49 +00:00
1 parent a0e6678d66
commit cebf5719bc
12 files changed
+1169 -27

No files matched your search

+79
View File
@@ -0,0 +1,79 @@
# Web Shell file tree uploads
[English](web-shell-file-tree-upload.md) | [简体中文](web-shell-file-tree-upload.zh-CN.md)
## Problem and scope
The Files panel browses workspace directories but cannot receive local files.
Add file drag and drop to its tree sidebar, with a thin progress bar pinned to
its bottom. Reuse the existing workspace upload endpoint and queue; no backend,
SDK, dependency, recursive folder upload, or composer behavior changes.
## Proposed behavior
- Drop on a directory row to upload into that directory, including collapsed
directories. Drop on a file row to upload into its parent. Sidebar whitespace
targets the workspace root, regardless of selection or filter.
- Only file drags are handled. Ignore dragged local directories. Show the
destination while dragging. Cancel default file-drop behavior for events
reaching the Files panel, including its preview container and toolbar, even
when upload is unavailable. The
root destination hint uses the workspace name rather than the protocol sentinel.
- Reuse `useFileUpload` for sequential uploads, progress, limits, cancellation,
collision-safe server naming, and failures. Respect `fileUploadEnabled` and
the daemon's advertised `workspace_file_upload` capability. Never use the
composer's default upload directory for explicit tree destinations.
- Uploads belong to the Files tab's trusted workspace, including when that tab
differs from the current session. Require qualified REST support for an
advertised workspace ID; never fall back to the primary runtime.
- Keep the progress bar at the sidebar bottom while pending or uploading;
hide it after the queue settles. When the tree is collapsed, show the bar at
the Files panel bottom. Keep dismissible failures above the split panels so
collapsing the tree cannot hide them. Announce terminal results through one
polite status region instead of an assertive alert per failed file.
- Insert each server-confirmed path into the tree without losing expansion,
selection, search, or preview. Clear an empty-directory notice for its parent.
Changing the workspace or revoking upload support aborts and clears the queue.
## Implementation and decisions
`useArtifactWorkspaceTarget.ts` exposes an optional guarded upload action using
its existing owner authority checks before and after the request. Uploads require
an explicitly trusted, uniquely advertised owner. Legacy reads remain unchanged.
`WorkspaceFileBrowser.tsx` consumes that action and the existing upload hook.
The native drag event's composed path identifies Pierre shadow DOM tree rows;
the tree’s directory metadata determines directory targets, including ancestors
restored from an upload during a pending refresh. Its CSS module
provides the destination hint and a three-pixel bottom progress bar. Existing
localized upload messages are reused; new destination text is bilingual.
The aggregate progress covers the current queue, including successful entries
even after their notices expire. Clear byte accounting when the queue becomes
idle; recent success notices do not contribute to the next queue. It represents
transferred bytes, while the bar
stays visible until the server confirms completion. Existing files are never
overwritten by this UI: the existing endpoint automatically numbers collisions.
## Validation and acceptance criteria
Focused unit tests cover capability gating, trusted workspace ownership, stale
owners, host disable overrides, advertised size limits, overflow notices,
polite failure announcements, and existing queue behavior. Browser E2E uses the actual Pierre tree and
a simulated daemon to cover root, nested/collapsed directory and file-row drops,
binary bytes, progress visibility and bottom placement (including collapsed trees), stable
progress after success-notice expiry and fresh progress for subsequent queues,
readable destination hints and drag-leave cleanup, successful insertion,
errors, non-file drags, directory rejection, and unavailable upload support.
A real daemon check verifies actual filesystem bytes and collision naming if
available. Screenshots document UI behavior only, with simulated-daemon evidence
clearly labeled. Run build, typecheck, focused tests, lint, and two clean diff
review passes before opening a ready-for-review PR.
## Constraints, risks, and open questions
Native folder uploads and a new manual cancel control are outside scope.
Existing cancellation on target changes and unmount remains in effect. Upload cancellation cannot recall a
file the server already published. Drag targeting depends on Pierre's existing
row path attributes and composed events, so browser coverage is required.
Events inside an embedded preview iframe belong to that iframe document and
are outside this container-level guard. There are no unresolved product questions.
@@ -0,0 +1,58 @@
# Web Shell 文件树上传
[English](web-shell-file-tree-upload.md) | [简体中文](web-shell-file-tree-upload.zh-CN.md)
## 问题与范围
文件面板已能浏览工作区目录,但无法接收本地文件。为文件树侧栏添加文件拖放上传,
在底部固定显示细进度条。复用已有工作区上传接口和队列;不修改后端、SDK、依赖或
输入框行为,不支持递归上传本地文件夹。
## 预期行为
- 拖到目录行时上传到该目录,包括折叠的目录;拖到文件行时上传到其父目录。
侧栏空白处始终对应工作区根目录,不受选中项或筛选条件影响。
- 只处理文件拖放,忽略拖入的本地目录;拖动时显示目标位置。即使无法上传,也取消
到达文件面板的文件拖放默认行为,包括预览容器和工具栏。根目录目标提示使用工作区名称,
不显示协议中的根目录标记。
- 复用 `useFileUpload` 的顺序上传、进度、限制、取消、服务端安全编号和错误处理。
遵守 `fileUploadEnabled` 和 daemon 公布的 `workspace_file_upload` 能力;
文件树的明确目标不使用输入框的默认上传目录。
- 上传属于文件标签页对应的可信工作区,即使该标签页不同于当前会话。工作区有公布
的 ID 时必须支持限定工作区的 REST,禁止回退到主运行时。
- 等待或上传时在侧栏底部固定显示进度条,队列结束后隐藏。收起树时进度条显示在
文件面板底部。失败提示保留在分栏上方并可关闭,收起树不会隐藏。通过一个礼貌的
状态区域播报最终结果,避免为每个失败文件创建强制播报。
- 将服务端确认的路径插入文件树,保留展开、选中、筛选和预览状态,清除父目录的
空目录提示。切换工作区或撤销上传能力时中止并清空队列。
## 实现与决策
`useArtifactWorkspaceTarget.ts` 通过已有的所有者权限检查,在上传请求前后校验
所有者,提供可选的上传动作。上传要求明确可信且唯一公布的所有者,旧读取行为保持
不变。`WorkspaceFileBrowser.tsx` 使用该动作和已有上传 hook。通过原生拖放事件
的 composed path 识别 Pierre shadow DOM 中的树行,通过树自身的目录元数据判断
目录目标,包括刷新尚未完成时由上传路径恢复的祖先目录。CSS Module 提供目标提示和三像素底部进度条。复用已有上传翻译,新增
目标位置文案同时提供中英文。
汇总进度覆盖当前队列,成功通知消失后仍保留其字节统计。队列空闲时清空统计,
上一队列尚未消失的成功通知不会计入新队列。进度表示已传输字节。
进度条持续显示直到服务端确认完成。界面不会覆盖已有文件:已有接口会自动为同名
文件编号。
## 验证与验收标准
定向单测覆盖能力门控、可信工作区归属、过期所有者、宿主禁用覆盖、公布的大小限制、批次数量提示、礼貌错误播报及已有队列行为。浏览器 E2E
使用实际 Pierre 树和模拟 daemon,覆盖根目录、嵌套或折叠目录、文件行拖放、
二进制字节、进度可见性及吸底位置(包括收起文件树)、成功通知消失后进度稳定、
后续队列重新统计、可读目标提示及拖离清理、成功插入、错误、非文件拖放、目录拒绝和上传
不可用状态。如可用,真实 daemon 检查验证实际文件字节和同名编号。截图只说明
界面行为,并明确标注模拟 daemon。提交正式可评审 PR 前完成构建、类型检查、
定向测试、lint 和连续两次无问题的完整差异审查。
## 约束、风险与待定问题
本地文件夹上传及新的手动取消按钮不在范围内,保留切换目标和卸载时已有的中止行为;
取消无法撤回服务端已经发布的文件。拖放目标依赖
Pierre 既有的行路径属性和 composed 事件,因此必须有浏览器覆盖。嵌入预览 iframe
内部的事件属于其自身文档,不在容器级保护范围内。没有未决产品问题。
@@ -14484,12 +14484,16 @@ describe('Hosted Harness no-tool session', () => {
.set('X-Qwen-Client-Id', created.body.clientId as string)
.send({ prompt, promptId: PROMPT_ID, payloadDigest });
expect(admitted.status).toBe(202);
await vi.waitFor(async () => {
const status = await headers(
supertest(server).get(`/session/${SESSION_ID}/status`),
).set('X-Qwen-Client-Id', created.body.clientId as string);
expect(status.body.hasActivePrompt).toBe(false);
});
await vi.waitFor(
async () => {
const status = await headers(
supertest(server).get(`/session/${SESSION_ID}/status`),
).set('X-Qwen-Client-Id', created.body.clientId as string);
expect(status.body.hasActivePrompt).toBe(false);
},
// Settlement includes fsync-backed writes and can exceed 1s on busy CI.
{ timeout: 10_000 },
);
expect(state.model).toHaveBeenCalledTimes(1);
await headers(supertest(server).delete(`/session/${SESSION_ID}`));
const loaded = await headers(
@@ -14547,12 +14551,16 @@ describe('Hosted Harness no-tool session', () => {
.set('X-Qwen-Client-Id', created.body.clientId as string)
.send({ prompt, promptId: PROMPT_ID, payloadDigest })
.expect(202);
await vi.waitFor(async () => {
const status = await headers(
supertest(server).get(`/session/${SESSION_ID}/status`),
).set('X-Qwen-Client-Id', created.body.clientId as string);
expect(status.body.hasActivePrompt).toBe(false);
});
await vi.waitFor(
async () => {
const status = await headers(
supertest(server).get(`/session/${SESSION_ID}/status`),
).set('X-Qwen-Client-Id', created.body.clientId as string);
expect(status.body.hasActivePrompt).toBe(false);
},
// Settlement includes fsync-backed writes and can exceed 1s on busy CI.
{ timeout: 10_000 },
);
await headers(supertest(server).delete(`/session/${SESSION_ID}`));
const loaded = await headers(
supertest(server).post(`/session/${SESSION_ID}/load`),
+5 -4
View File
@@ -1539,8 +1539,8 @@ export interface WebShellProps {
/** Built-in @ mention providers to enable. Defaults to all built-ins. */
builtinAtProviders?: WebShellBuiltinAtProvidersConfig;
/**
* Controls whether the composer's file-upload entry points (drag-and-drop
* and the @ panel upload item) are enabled. Does not disable attachments.
* Controls composer uploads (drag-and-drop and the @ panel upload item)
* and Files tree drag-and-drop uploads. Does not disable attachments.
* Works alongside the daemon's
* `workspace_file_upload` capability, not instead of it: `false` force-
* disables upload even when the daemon advertises the capability, while
@@ -1552,13 +1552,14 @@ export interface WebShellProps {
*/
fileDropAction?: 'upload' | 'attach';
/**
* Directory that drag-and-dropped files upload into, **relative to the
* Directory that composer-dropped files upload into, **relative to the
* workspace root**. Use a relative path WITHOUT a leading `/` — e.g.
* `'uploads'`, `'uploads/images'`, or omit it to upload into the
* workspace root (the default). A leading-slash path like `'/uploads'`
* is rejected by the daemon as outside the workspace. The directory
* (including intermediate components) is created automatically on upload
* when it does not exist.
* when it does not exist. Files tree drops use the explicit tree destination
* instead of this setting.
*/
fileUploadDirectory?: string;
/** Additional @ mention categories shown alongside built-in files/extensions. */
@@ -39,6 +39,7 @@ const {
readWorkspaceFileBytes: vi.fn(),
fileStat: vi.fn(),
dirList: vi.fn(),
uploadWorkspaceFile: vi.fn(),
};
return {
mockActions: {
@@ -459,6 +460,7 @@ afterEach(() => {
mockSecondaryWorkspaceActions.readWorkspaceFileBytes.mockReset();
mockSecondaryWorkspaceActions.fileStat.mockReset();
mockSecondaryWorkspaceActions.dirList.mockReset();
mockSecondaryWorkspaceActions.uploadWorkspaceFile.mockReset();
mockWorkspace.client.workspaceByCwd.mockClear();
mockWorkspace.capabilities.features = [];
latestArtifactWorkspaceTarget = undefined;
@@ -648,6 +650,122 @@ describe('ArtifactPanel web previews', () => {
});
describe('artifact workspace authority', () => {
it.each(['/primary', '/secondary'])(
'uploads only through the qualified advertised owner for %s',
async (cwd) => {
mockWorkspace.capabilities.features = [
'workspace_file_upload',
'workspace_qualified_rest_core',
];
const result = {
kind: 'file_upload',
path: 'src/file.bin',
sizeBytes: 1,
hash: 'sha256:test',
};
mockSecondaryWorkspaceActions.uploadWorkspaceFile.mockResolvedValue(
result,
);
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
mounted.push({ root, container });
act(() =>
root.render(
<ArtifactWorkspaceTargetProbe revision={0} workspaceCwd={cwd} />,
),
);
const request = { path: 'src/file.bin', data: new Blob(['x']) };
await expect(
latestArtifactWorkspaceTarget!.actions.uploadWorkspaceFile!(request),
).resolves.toEqual(result);
expect(mockWorkspace.client.workspaceByCwd).toHaveBeenCalledWith(cwd);
expect(
mockSecondaryWorkspaceActions.uploadWorkspaceFile,
).toHaveBeenCalledWith(request, undefined);
},
);
it.each([[], ['workspace_file_upload'], ['workspace_qualified_rest_core']])(
'does not expose uploads without both capabilities: %j',
(features) => {
mockWorkspace.capabilities.features = features;
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
mounted.push({ root, container });
act(() => root.render(<ArtifactWorkspaceTargetProbe revision={0} />));
expect(
latestArtifactWorkspaceTarget!.actions.uploadWorkspaceFile,
).toBeUndefined();
},
);
it('does not expose uploads for an id-less legacy workspace even with both capabilities', () => {
mockWorkspace.capabilities = {
workspaceCwd: '/primary',
features: ['workspace_file_upload', 'workspace_qualified_rest_core'],
} as unknown as typeof mockWorkspace.capabilities;
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
mounted.push({ root, container });
act(() =>
root.render(
<ArtifactWorkspaceTargetProbe revision={0} workspaceCwd="/primary" />,
),
);
expect(
latestArtifactWorkspaceTarget!.actions.uploadWorkspaceFile,
).toBeUndefined();
expect(mockWorkspace.client.workspaceByCwd).not.toHaveBeenCalled();
});
it.each(['capability', 'owner'])(
'rejects captured and pending uploads after %s revocation',
async (revocation) => {
mockWorkspace.capabilities.features = [
'workspace_file_upload',
'workspace_qualified_rest_core',
];
let finish!: (value: unknown) => void;
mockSecondaryWorkspaceActions.uploadWorkspaceFile.mockReturnValue(
new Promise((resolve) => {
finish = resolve;
}),
);
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
mounted.push({ root, container });
act(() => root.render(<ArtifactWorkspaceTargetProbe revision={0} />));
const upload =
latestArtifactWorkspaceTarget!.actions.uploadWorkspaceFile!;
const request = { path: 'file.bin', data: new Blob(['x']) };
const pending = upload(request);
if (revocation === 'capability') mockWorkspace.capabilities.features = [];
else
mockWorkspace.capabilities = {
...mockWorkspace.capabilities,
workspaces: mockWorkspace.capabilities.workspaces.filter(
(entry) => entry.cwd !== '/secondary',
),
};
act(() => root.render(<ArtifactWorkspaceTargetProbe revision={1} />));
finish({
kind: 'file_upload',
path: 'file.bin',
sizeBytes: 1,
hash: 'sha256:test',
});
await expect(pending).rejects.toThrow(/no longer available/);
await expect(upload(request)).rejects.toThrow(/no longer available/);
expect(
mockSecondaryWorkspaceActions.uploadWorkspaceFile,
).toHaveBeenCalledTimes(1);
},
);
it.each(['/primary', '/secondary'])(
'lists directories through the advertised owner for %s',
async (cwd) => {
@@ -1,4 +1,5 @@
.browser {
position: relative;
height: 100%;
min-width: 0;
min-height: 0;
@@ -43,6 +44,7 @@
}
.sidebar {
position: relative;
height: 100%;
min-width: 0;
box-sizing: border-box;
@@ -112,3 +114,45 @@
color: var(--muted-foreground);
font-size: 13px;
}
.dropHint {
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
border: 1px solid var(--primary);
background: color-mix(in srgb, var(--primary) 8%, transparent);
color: var(--primary);
padding: 48px 12px 12px;
font-size: 12px;
text-align: right;
}
.uploadProgress {
position: absolute;
inset: auto 0 0;
height: 3px;
background: var(--muted);
overflow: hidden;
}
.uploadProgress > div {
height: 100%;
background: var(--primary);
transition: width 150ms;
}
.uploadError {
display: flex;
align-items: center;
flex-shrink: 0;
gap: 4px;
padding: 4px 8px;
font-size: 12px;
color: var(--destructive);
}
.uploadError > span {
flex: 1;
overflow-wrap: anywhere;
}
@@ -0,0 +1,142 @@
// @vitest-environment jsdom
import { act, type ReactNode } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, expect, it, vi } from 'vitest';
import { I18nProvider } from '../../i18n';
import { WorkspaceFileBrowser } from './WorkspaceFileBrowser';
import type { ArtifactWorkspaceActions } from './useArtifactWorkspaceTarget';
const settings = vi.hoisted(() => ({
enabled: true,
maxBytes: 2 * 1024 * 1024,
}));
vi.mock('../../customization', () => ({
useWebShellCustomization: () => ({ fileUploadEnabled: settings.enabled }),
}));
vi.mock('../../themeContext', () => ({ useTheme: () => 'light' }));
vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({
useWorkspace: () => ({
capabilities: {
features: ['workspace_file_upload', 'workspace_qualified_rest_core'],
limits: { maxWorkspaceFileUploadBytes: settings.maxBytes },
},
}),
}));
vi.mock('react-resizable-panels', () => ({
usePanelRef: () => ({ current: null }),
}));
vi.mock('../ui/resizable', () => ({
ResizablePanelGroup: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
),
ResizablePanel: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
),
ResizableHandle: () => null,
}));
vi.mock('@pierre/trees', () => ({
FileTree: class {
render() {}
getFileTreeContainer() {
return undefined;
}
subscribe() {
return () => {};
}
setSearch() {}
cleanUp() {}
},
}));
let root: Root | undefined;
let container: HTMLDivElement;
const upload = vi.fn();
async function mount() {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
const actions = {
listDirectory: vi.fn().mockResolvedValue({ entries: [], truncated: false }),
uploadWorkspaceFile: upload,
} as unknown as ArtifactWorkspaceActions;
await act(async () =>
root!.render(
<I18nProvider language="en">
<WorkspaceFileBrowser
workspaceCwd="/workspace"
actions={actions}
renderPreview={() => null}
onOpenPreview={() => {}}
/>
</I18nProvider>,
),
);
}
function dispatch(type: string, files: File[]) {
const event = new Event(type, { bubbles: true, cancelable: true });
Object.defineProperty(event, 'dataTransfer', {
value: { types: ['Files'], files },
});
act(() =>
container
.querySelector('[data-testid="workspace-file-sidebar"]')!
.dispatchEvent(event),
);
return event.defaultPrevented;
}
afterEach(() => {
act(() => root?.unmount());
container?.remove();
root = undefined;
settings.enabled = true;
settings.maxBytes = 2 * 1024 * 1024;
upload.mockReset();
});
it('host disable overrides both daemon capabilities and the available upload action', async () => {
settings.enabled = false;
await mount();
expect(dispatch('dragover', [new File(['x'], 'disabled.bin')])).toBe(true);
expect(container.textContent).not.toContain('Drop files');
expect(dispatch('drop', [new File(['x'], 'disabled.bin')])).toBe(true);
expect(upload).not.toHaveBeenCalled();
expect(container.querySelector('[role="progressbar"]')).toBeNull();
});
it('reports the advertised size limit through one polite status and allows dismissal', async () => {
await mount();
dispatch('drop', [
new File([new Uint8Array(3 * 1024 * 1024)], 'large.bin'),
new File([new Uint8Array(3 * 1024 * 1024)], 'other.bin'),
]);
expect(upload).not.toHaveBeenCalled();
expect(container.textContent).toContain('2 MiB');
expect(container.textContent).toContain('large.bin');
expect(container.textContent).toContain('other.bin');
expect(container.querySelectorAll('[role="status"]')).toHaveLength(1);
expect(container.querySelector('[role="status"]')?.textContent).toContain(
'large.bin',
);
expect(container.querySelector('[role="alert"]')).toBeNull();
expect(container.querySelector('[role="progressbar"]')).toBeNull();
act(() =>
(
container.querySelector('[aria-label="Dismiss"]') as HTMLButtonElement
).click(),
);
expect(container.textContent).not.toContain('large.bin');
});
it('reports overflow once with the skipped filename and count', async () => {
settings.maxBytes = 0;
await mount();
dispatch(
'drop',
Array.from(
{ length: 102 },
(_, index) => new File(['x'], `file-${index}.bin`),
),
);
expect(upload).not.toHaveBeenCalled();
expect(container.textContent).toContain('file-100.bin');
expect(container.textContent).toContain('2 more files');
expect(container.textContent).not.toContain('file-101.bin');
expect(container.querySelectorAll('[role="status"]')).toHaveLength(1);
expect(container.querySelector('[role="alert"]')).toBeNull();
});
@@ -7,10 +7,21 @@ import {
FolderIcon,
RefreshCwIcon,
SearchIcon,
XIcon,
} from 'lucide-react';
import { useEffect, useRef, useState, type ReactNode } from 'react';
import {
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
type DragEvent,
} from 'react';
import { useI18n } from '../../i18n';
import { useTheme } from '../../themeContext';
import { useWorkspace } from '@qwen-code/web-shell/daemon-react-sdk';
import { useWebShellCustomization } from '../../customization';
import { useFileUpload } from '../../hooks/useFileUpload';
import type { DaemonDirectoryEntry } from '@qwen-code/web-shell/daemon-react-sdk';
import type { ArtifactWorkspaceActions } from './useArtifactWorkspaceTarget';
import { Button } from '../ui/button';
@@ -49,6 +60,11 @@ interface WorkspaceFileBrowserProps {
renderPreview: (path: string, source: boolean, version: number) => ReactNode;
}
function parentDirectory(path: string): string {
const normalized = path.replace(/\/$/, '');
return normalized.slice(0, Math.max(0, normalized.lastIndexOf('/')));
}
export function WorkspaceFileBrowser({
workspaceCwd,
actions,
@@ -58,6 +74,29 @@ export function WorkspaceFileBrowser({
}: WorkspaceFileBrowserProps) {
const { t } = useI18n();
const theme = useTheme();
const workspace = useWorkspace();
const { fileUploadEnabled } = useWebShellCustomization();
const uploadClient = useMemo(
() =>
fileUploadEnabled !== false && actions.uploadWorkspaceFile
? { uploadWorkspaceFile: actions.uploadWorkspaceFile }
: undefined,
[actions, fileUploadEnabled],
);
const maxUploadBytes =
workspace.capabilities?.limits?.maxWorkspaceFileUploadBytes ??
50 * 1024 * 1024;
const { uploads, isBusy, uploadFiles, removeUpload } = useFileUpload({
client: uploadClient,
maxBytes: maxUploadBytes,
targetKey: `${workspaceCwd}:${uploadClient ? 'enabled' : 'disabled'}`,
});
const progressRef = useRef(
new Map<string, { size: number; progress: number }>(),
);
const previousDoneRef = useRef(new Set<string>());
const [dropDirectory, setDropDirectory] = useState<string>();
const uploadedRef = useRef<((path: string) => void) | undefined>(undefined);
const treePanelRef = usePanelRef();
const contentPanelRef = usePanelRef();
const hostRef = useRef<HTMLDivElement>(null);
@@ -179,13 +218,18 @@ export function WorkspaceFileBrowser({
if (disposed) return;
cache.set(path, {
path,
empty: listing.entries.length === 0,
empty:
listing.entries.length === 0 &&
![...entries.keys()].some(
(child) => parentDirectory(child) === path,
),
truncated: listing.truncated,
});
const added = listing.entries.map((entry) => {
const added = listing.entries.flatMap((entry) => {
const child = `${path ? `${path}/` : ''}${entry.name}${entry.kind === 'directory' ? '/' : ''}`;
const exists = entries.has(child);
entries.set(child, entry);
return { type: 'add' as const, path: child };
return exists ? [] : [{ type: 'add' as const, path: child }];
});
if (added.length) tree.batch(added);
} catch (error) {
@@ -214,6 +258,20 @@ export function WorkspaceFileBrowser({
cache.delete(path);
void load(path);
};
uploadedRef.current = (path) => {
const parent = parentDirectory(path);
if (!entries.has(path)) {
entries.set(path, {
name: path.split('/').at(-1)!,
kind: 'file',
ignored: false,
});
tree.add(path);
}
const directory = cache.get(parent);
if (directory) cache.set(parent, { ...directory, empty: false });
publish();
};
void load('');
return () => {
disposed = true;
@@ -224,6 +282,7 @@ export function WorkspaceFileBrowser({
host.replaceChildren();
treeRef.current = undefined;
retryRef.current = undefined;
uploadedRef.current = undefined;
};
}, [actions, version]);
@@ -238,6 +297,107 @@ export function WorkspaceFileBrowser({
?.setAttribute('aria-label', t('files.title'));
}, [t]);
const dropTarget = (event: DragEvent<HTMLDivElement>) => {
const row = event.nativeEvent
.composedPath()
.find(
(node) =>
node instanceof HTMLElement &&
node.getAttribute('role') === 'treeitem',
);
const path =
row instanceof HTMLElement
? row.getAttribute('data-item-path')
: undefined;
if (!path) return '.';
if (treeRef.current?.getItem(path)?.isDirectory())
return path.replace(/\/$/, '');
return parentDirectory(path) || '.';
};
const handleDragOver = (event: DragEvent<HTMLDivElement>) => {
if (!event.dataTransfer.types.includes('Files')) return;
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = uploadClient ? 'copy' : 'none';
if (uploadClient) setDropDirectory(dropTarget(event));
};
const handleDrop = (event: DragEvent<HTMLDivElement>) => {
if (!event.dataTransfer.types.includes('Files')) return;
event.preventDefault();
event.stopPropagation();
setDropDirectory(undefined);
if (!uploadClient) return;
const items = Array.from(event.dataTransfer.items ?? []);
const files = items.length
? items.flatMap((item) => {
if (item.kind !== 'file' || item.webkitGetAsEntry?.()?.isDirectory)
return [];
const file = item.getAsFile();
return file ? [file] : [];
})
: Array.from(event.dataTransfer.files);
uploadFiles(files, dropTarget(event), (path) =>
uploadedRef.current?.(path),
);
};
const failedUploads = uploads.filter((upload) => upload.status === 'error');
// Success notices expire after three seconds; byte accounting lasts until idle.
if (!isBusy) {
progressRef.current.clear();
previousDoneRef.current = new Set(
uploads
.filter((upload) => upload.status === 'done')
.map((upload) => upload.id),
);
} else {
for (const upload of uploads) {
if (upload.status === 'error') progressRef.current.delete(upload.id);
else if (!previousDoneRef.current.has(upload.id))
progressRef.current.set(upload.id, {
size: Math.max(1, upload.file.size),
progress: upload.progress,
});
}
}
const progressItems = [...progressRef.current.values()];
const totalBytes = progressItems.reduce((sum, item) => sum + item.size, 0);
const progress = totalBytes
? progressItems.reduce((sum, item) => sum + item.size * item.progress, 0) /
totalBytes
: 0;
const uploadErrorText = (upload: (typeof uploads)[number]) =>
`${safeDisplayText(upload.file.name)}: ${
upload.error ??
(upload.errorCode === 'tooLarge'
? t('composer.upload.error.tooLarge', {
limit: `${Math.round(maxUploadBytes / (1024 * 1024))} MiB`,
})
: upload.errorCode === 'tooManyFiles'
? t('composer.upload.error.tooManyFiles', {
count: upload.skippedCount ?? 0,
})
: t('composer.upload.error.noDaemon'))
}`;
const uploadProgress = isBusy && (
<div
className={styles.uploadProgress}
role="progressbar"
aria-label={t('composer.upload.uploading')}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(progress * 100)}
>
<div style={{ width: `${Math.max(2, progress * 100)}%` }} />
</div>
);
const preventFileDrop = (event: DragEvent<HTMLElement>) => {
if (event.dataTransfer.types.includes('Files')) {
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = 'none';
}
};
const runMenuAction = async (
action: () => void | Promise<void>,
transferFocus = false,
@@ -260,6 +420,9 @@ export function WorkspaceFileBrowser({
path
? `${workspaceCwd.replace(separator === '\\' ? /[\\/]+$/ : /\/+$/, '')}${separator}${path.replaceAll('/', separator)}`
: workspaceCwd;
const workspaceName = safeDisplayText(
workspaceCwd.split(/[\\/]/).filter(Boolean).at(-1) ?? workspaceCwd,
);
const absoluteMenuPath = absolutePath(menu?.path ?? '');
const canPreview =
selectedPath && /\.(?:md|markdown|html?)$/i.test(selectedPath);
@@ -271,6 +434,8 @@ export function WorkspaceFileBrowser({
className={styles.browser}
data-testid="workspace-file-browser"
style={{ colorScheme: theme }}
onDragOver={preventFileDrop}
onDrop={preventFileDrop}
>
{menu && (
<DropdownMenu
@@ -364,7 +529,7 @@ export function WorkspaceFileBrowser({
className={styles.path}
title={safeDisplayText(absolutePath(selectedPath ?? ''))}
>
{safeDisplayText(workspaceCwd.split(/[\\/]/).filter(Boolean).at(-1))}
{workspaceName}
{selectedPath && <> / {safeDisplayText(selectedPath)}</>}
</span>
{canPreview && (
@@ -408,6 +573,39 @@ export function WorkspaceFileBrowser({
{actionError}
</div>
)}
<div
className="sr-only"
role="status"
data-testid="workspace-file-upload-status"
>
{uploads
.filter(
(upload) => upload.status === 'done' || upload.status === 'error',
)
.map((upload) =>
upload.status === 'done'
? `${t('composer.upload.done')}: ${safeDisplayText(upload.resultPath)}`
: uploadErrorText(upload),
)
.join('; ')}
</div>
{failedUploads.length > 0 && (
<div className={styles.notices}>
{failedUploads.map((upload) => (
<div className={styles.uploadError} key={upload.id}>
<span>{uploadErrorText(upload)}</span>
<Button
variant="ghost"
size="icon-sm"
aria-label={t('composer.upload.dismiss')}
onClick={() => removeUpload(upload.id)}
>
<XIcon />
</Button>
</div>
))}
</div>
)}
<ResizablePanelGroup
className={styles.columns}
orientation="horizontal"
@@ -445,7 +643,25 @@ export function WorkspaceFileBrowser({
className={styles.sidebar}
hidden={!treeVisible}
data-testid="workspace-file-sidebar"
onDragOverCapture={handleDragOver}
onDropCapture={handleDrop}
onDragLeaveCapture={(event) => {
if (
!(event.relatedTarget instanceof Node) ||
!event.currentTarget.contains(event.relatedTarget)
) {
setDropDirectory(undefined);
}
}}
>
{dropDirectory !== undefined && uploadClient && (
<div className={styles.dropHint}>
{t('files.uploadTo')}:{' '}
{dropDirectory === '.'
? workspaceName
: safeDisplayText(dropDirectory)}
</div>
)}
<div className={styles.search}>
<SearchIcon aria-hidden />
<Input
@@ -549,9 +765,11 @@ export function WorkspaceFileBrowser({
))}
</div>
)}
{treeVisible && uploadProgress}
</div>
</ResizablePanel>
</ResizablePanelGroup>
{!treeVisible && uploadProgress}
</section>
);
}
@@ -3,6 +3,7 @@ export const FILES_MESSAGES_EN = {
'files.copyAbsolutePath': 'Copy absolute path',
'files.reference': 'Reference',
'files.title': 'Files',
'files.uploadTo': 'Drop files to upload into',
'files.description': 'Browse workspace files',
'files.filter': 'Filter files…',
'files.noMatches': 'No matches in loaded paths',
@@ -23,6 +24,7 @@ export const FILES_MESSAGES_ZH = {
'files.copyAbsolutePath': '复制绝对路径',
'files.reference': '引用',
'files.title': '文件',
'files.uploadTo': '拖放文件上传至',
'files.description': '浏览工作区文件',
'files.filter': '筛选文件…',
'files.noMatches': '已加载路径内无匹配',
@@ -10,6 +10,7 @@ import {
type DaemonWorkspaceActions,
} from '@qwen-code/web-shell/daemon-react-sdk';
import { useEffect, useMemo, useRef } from 'react';
import type { FileUploadClient } from '../../hooks/useFileUpload';
export type ArtifactWorkspaceActions = Pick<
DaemonWorkspaceActions,
@@ -20,7 +21,8 @@ export type ArtifactWorkspaceActions = Pick<
| 'listScheduledTasks'
| 'updateScheduledTask'
| 'deleteScheduledTask'
>;
> &
Partial<FileUploadClient>;
interface ArtifactWorkspaceOwner {
cwd: string;
@@ -95,6 +97,15 @@ export function useArtifactWorkspaceTarget(
() => resolveArtifactWorkspaceOwner(workspace.capabilities, workspaceCwd),
[workspace.capabilities, workspaceCwd],
);
const canUpload = Boolean(
owner?.id &&
workspace.capabilities?.features?.includes('workspace_file_upload') &&
workspace.capabilities?.features?.includes(
'workspace_qualified_rest_core',
),
);
const uploadAllowedRef = useRef(canUpload);
uploadAllowedRef.current = canUpload;
const authorityRef = useRef<ArtifactWorkspaceAuthority | undefined>(
undefined,
);
@@ -145,6 +156,24 @@ export function useArtifactWorkspaceTarget(
return current;
};
return {
...(canUpload
? ({
async uploadWorkspaceFile(req, clientId) {
const current = requireOwner();
if (!uploadAllowedRef.current) {
throw new Error('Workspace file upload is no longer available');
}
const result = await workspace.client
.workspaceByCwd(current.cwd)
.uploadWorkspaceFile(req, clientId);
requireOwner();
if (!uploadAllowedRef.current) {
throw new Error('Workspace file upload is no longer available');
}
return result;
},
} satisfies FileUploadClient)
: {}),
async listDirectory(path) {
const current = requireOwner();
const result = await (current.id
@@ -207,7 +236,7 @@ export function useArtifactWorkspaceTarget(
requireScheduledTaskOwner(workspaceId);
},
};
}, [authority, primaryActions, workspace.client]);
}, [authority, primaryActions, workspace.client, canUpload]);
if (!authority || !actions) return undefined;
return {
+5 -4
View File
@@ -751,8 +751,8 @@ export interface WebShellCustomization {
markdown?: WebShellMarkdownCustomization;
loadingPhrases?: LoadingPhrasesResolver;
/**
* Controls whether the composer's file-upload entry points (drag-and-drop
* and the @ panel upload item) are enabled. Does not disable attachments.
* Controls composer uploads (drag-and-drop and the @ panel upload item)
* and Files tree drag-and-drop uploads. Does not disable attachments.
* Works alongside the daemon's
* `workspace_file_upload` capability, not instead of it: setting `false`
* force-disables upload even when the daemon advertises the capability,
@@ -765,13 +765,14 @@ export interface WebShellCustomization {
*/
fileDropAction?: 'upload' | 'attach';
/**
* Directory that drag-and-dropped files upload into, **relative to the
* Directory that composer-dropped files upload into, **relative to the
* workspace root**. Use a relative path WITHOUT a leading `/` — e.g.
* `'uploads'`, `'uploads/images'`, or omit it to upload into the
* workspace root (the default). A leading-slash path like `'/uploads'`
* is rejected by the daemon as outside the workspace. The directory
* (including intermediate components) is created automatically on upload
* when it does not exist.
* when it does not exist. Files tree drops use the explicit tree destination
* instead of this setting.
*/
fileUploadDirectory?: string;
}
@@ -16,6 +16,7 @@ async function openFiles(
workspaceCwd = '/tmp/qwen-web-shell-e2e',
afterOpen?: () => Promise<void>,
primary = true,
uploadEnabled = false,
) {
const scenario = createWebShellDaemonScenario({
workspaceCwd,
@@ -36,6 +37,11 @@ async function openFiles(
turnCompleteEvent('files-ready', { id: 3 }),
],
});
if (uploadEnabled)
scenario.capabilities.features.push(
'workspace_file_upload',
'workspace_qualified_rest_core',
);
await page.setViewportSize({ width: 1600, height: 1000 });
await installMockDaemon(page, scenario, { baseURL });
const contents: Record<string, string> = {
@@ -1059,3 +1065,439 @@ test('scrolls the entire deep tree horizontally without squeezing names', async
await page.keyboard.press('ArrowDown');
await expect(leaf).toBeFocused();
});
async function dropLocalFiles(
page: Page,
target: import('@playwright/test').Locator,
names: string[],
directory = false,
) {
const dataTransfer = await page.evaluateHandle(
({ names, directory }) => {
const transfer = new DataTransfer();
for (const name of names) {
transfer.items.add(new File([new Uint8Array([0, 255, 128, 10])], name));
}
if (directory)
Object.defineProperty(transfer, 'items', {
value: Array.from(transfer.items).map((item) => {
item.webkitGetAsEntry = () =>
({ isDirectory: true }) as FileSystemEntry;
return item;
}),
});
return transfer;
},
{ names, directory },
);
const canceled = await target.evaluate((element, transfer) => {
const dispatch = (type: string) => {
const event = new DragEvent(type, {
dataTransfer: transfer,
bubbles: true,
cancelable: true,
composed: true,
});
element.dispatchEvent(event);
return event.defaultPrevented;
};
return { dragover: dispatch('dragover'), drop: dispatch('drop') };
}, dataTransfer);
await dataTransfer.dispose();
return canceled;
}
test('@smoke uploads dropped files into root and directory rows with bottom progress', async ({
page,
baseURL,
}) => {
const { browser } = await openFiles(
page,
baseURL!,
undefined,
undefined,
false,
true,
);
const sidebar = browser.getByTestId('workspace-file-sidebar');
const uploads: { url: string; path: string; bytes: number[] }[] = [];
let finish: (() => void) | undefined;
await page.route('**/file/upload?*', async (route) => {
const request = route.request();
uploads.push({
url: new URL(request.url()).pathname,
path: new URL(request.url()).searchParams.get('path')!,
bytes: [...request.postDataBuffer()!],
});
await new Promise<void>((resolve) => {
finish = resolve;
});
await route.fulfill({
json: {
kind: 'file_upload',
path: uploads.at(-1)!.path,
sizeBytes: 4,
hash: 'sha256:test',
},
});
});
await browser.screenshot({ path: test.info().outputPath('before.png') });
await dropLocalFiles(page, sidebar, ['root.bin', 'second.bin']);
await expect.poll(() => uploads.length).toBe(1);
expect(uploads[0]).toEqual({
url: '/workspaces/%2Ftmp%2Fqwen-web-shell-e2e/file/upload',
path: 'root.bin',
bytes: [0, 255, 128, 10],
});
const progress = sidebar.getByRole('progressbar', { name: 'Uploading' });
await expect(progress).toBeVisible();
const barBox = (await progress.boundingBox())!;
const sidebarBox = (await sidebar.boundingBox())!;
expect(barBox.height).toBe(3);
expect(barBox.y + barBox.height).toBeCloseTo(
sidebarBox.y + sidebarBox.height,
0,
);
finish!();
await expect.poll(() => uploads.length).toBe(2);
expect(uploads[1].path).toBe('second.bin');
await expect(progress).toHaveAttribute('aria-valuenow', '50');
await browser.screenshot({ path: test.info().outputPath('progress.png') });
// Wait for the hook's success row dismissal while the second upload stays pending.
await expect(
browser.getByTestId('workspace-file-upload-status'),
).not.toContainText('root.bin');
await expect(progress).toHaveAttribute('aria-valuenow', '50');
finish!();
await expect(progress).toBeHidden();
await expect(
sidebar.getByRole('treeitem', { name: 'root.bin', exact: true }),
).toBeVisible();
const folder = sidebar.getByRole('treeitem', { name: 'empty', exact: true });
await expect(
browser.getByTestId('workspace-file-upload-status'),
).toContainText('second.bin');
await dropLocalFiles(page, folder, ['nested.bin']);
await expect.poll(() => uploads.length).toBe(3);
expect(uploads[2].path).toBe('empty/nested.bin');
await expect(progress).toHaveAttribute('aria-valuenow', '0');
finish!();
await expect(progress).toBeHidden();
await page.route('**/list?path=empty', (route) =>
route.fulfill({
json: {
kind: 'list',
path: 'empty',
entries: [{ name: 'nested.bin', kind: 'file', ignored: false }],
truncated: false,
},
}),
);
await folder.click();
await expect(
sidebar.getByRole('treeitem', { name: 'nested.bin', exact: true }),
).toBeVisible();
await expect(sidebar).not.toContainText('empty: Empty folder');
await expect(sidebar.getByRole('alert')).toBeHidden();
await browser.screenshot({ path: test.info().outputPath('completed.png') });
});
test('@smoke file-row drops preserve preview and use server-confirmed collision names', async ({
page,
baseURL,
}) => {
const { browser } = await openFiles(
page,
baseURL!,
undefined,
undefined,
true,
true,
);
const sidebar = browser.getByTestId('workspace-file-sidebar');
await sidebar.getByRole('treeitem', { name: 'src', exact: true }).click();
const row = sidebar.getByRole('treeitem', { name: 'guide.md', exact: true });
await row.click();
await expect(browser.getByTestId('workspace-file-content')).toContainText(
'Nested guide',
);
let uploadPath: string | null = null;
await page.route('**/file/upload?*', async (route) => {
uploadPath = new URL(route.request().url()).searchParams.get('path');
await route.fulfill({
json: {
kind: 'file_upload',
path: 'src/guide (1).md',
sizeBytes: 4,
hash: 'sha256:test',
},
});
});
await dropLocalFiles(page, row, ['guide.md']);
await expect.poll(() => uploadPath).toBe('src/guide.md');
await expect(
sidebar.getByRole('treeitem', { name: 'guide (1).md', exact: true }),
).toBeVisible();
await expect(browser.getByTestId('workspace-file-content')).toContainText(
'Nested guide',
);
await expect(sidebar.getByRole('progressbar')).toBeHidden();
});
test('@smoke retains upload errors and ignores folders and text drags', async ({
page,
baseURL,
}) => {
const { browser } = await openFiles(
page,
baseURL!,
undefined,
undefined,
true,
true,
);
const sidebar = browser.getByTestId('workspace-file-sidebar');
let requests = 0;
await page.route('**/file/upload?*', async (route) => {
requests++;
await route.fulfill({ status: 403, json: { error: 'Upload denied' } });
});
await dropLocalFiles(page, sidebar, ['local-folder'], true);
const textTransfer = await page.evaluateHandle(() => {
const transfer = new DataTransfer();
transfer.setData('text/plain', 'hello');
return transfer;
});
const dragoverCanceled = await sidebar.evaluate((element, transfer) => {
const event = new DragEvent('dragover', {
dataTransfer: transfer,
bubbles: true,
cancelable: true,
composed: true,
});
element.dispatchEvent(event);
return event.defaultPrevented;
}, textTransfer);
expect(dragoverCanceled).toBe(false);
await expect(sidebar).not.toContainText('Drop files');
const dropCanceled = await sidebar.evaluate((element, transfer) => {
const event = new DragEvent('drop', {
dataTransfer: transfer,
bubbles: true,
cancelable: true,
composed: true,
});
element.dispatchEvent(event);
return event.defaultPrevented;
}, textTransfer);
expect(dropCanceled).toBe(false);
await textTransfer.dispose();
await expect(sidebar.getByRole('progressbar')).toBeHidden();
expect(requests).toBe(0);
await dropLocalFiles(page, sidebar, ['denied.bin']);
await expect(
browser.getByTestId('workspace-file-upload-status'),
).toContainText('Upload denied');
await expect(sidebar.getByRole('progressbar')).toBeHidden();
await browser.getByRole('button', { name: 'Dismiss', exact: true }).click();
await expect(
browser.getByTestId('workspace-file-upload-status'),
).not.toContainText('Upload denied');
});
test('@smoke prevents navigation and upload when tree upload capability is unavailable', async ({
page,
baseURL,
}) => {
const { browser } = await openFiles(page, baseURL!);
const url = page.url();
let requests = 0;
await page.route('**/file/upload?*', async (route) => {
requests++;
await route.abort();
});
expect(
await dropLocalFiles(page, browser.getByTestId('workspace-file-sidebar'), [
'disabled.bin',
]),
).toEqual({ dragover: true, drop: true });
await expect(browser).toBeVisible();
expect(page.url()).toBe(url);
expect(requests).toBe(0);
await expect(browser.getByRole('progressbar')).toBeHidden();
});
test('@smoke targets restored directories while refresh and upload overlap', async ({
page,
baseURL,
}) => {
const { browser } = await openFiles(
page,
baseURL!,
undefined,
undefined,
true,
true,
);
const sidebar = browser.getByTestId('workspace-file-sidebar');
const paths: string[] = [];
let finishUpload!: () => void;
await page.route('**/file/upload?*', async (route) => {
const path = new URL(route.request().url()).searchParams.get('path')!;
paths.push(path);
await new Promise<void>((resolve) => {
finishUpload = resolve;
});
await route.fulfill({
json: { kind: 'file_upload', path, sizeBytes: 4, hash: 'sha256:test' },
});
});
await dropLocalFiles(
page,
sidebar.getByRole('treeitem', { name: 'empty', exact: true }),
['first.bin'],
);
await expect.poll(() => paths.length).toBe(1);
let finishListing!: () => void;
let listingPending = false;
await page.route('**/list?path=.', async (route) => {
listingPending = true;
await new Promise<void>((resolve) => {
finishListing = resolve;
});
await route.fulfill({
json: {
kind: 'list',
path: '.',
entries: [{ name: 'empty', kind: 'directory', ignored: false }],
truncated: false,
},
});
});
await browser.getByRole('button', { name: 'Refresh', exact: true }).click();
await expect.poll(() => listingPending).toBe(true);
finishUpload();
const restored = sidebar.getByRole('treeitem', {
name: 'empty',
exact: true,
});
await expect(restored).toBeVisible();
await dropLocalFiles(page, restored, ['second.bin']);
await expect.poll(() => paths.length).toBe(2);
expect(paths).toEqual(['empty/first.bin', 'empty/second.bin']);
finishUpload();
finishListing();
await expect(sidebar.getByRole('progressbar')).toBeHidden();
await restored.click();
await expect(
sidebar.getByRole('treeitem', { name: 'first.bin', exact: true }),
).toBeVisible();
await expect(
sidebar.getByRole('treeitem', { name: 'second.bin', exact: true }),
).toBeVisible();
await expect(sidebar.getByRole('alert')).toBeHidden();
await expect(sidebar).not.toContainText('empty: Empty folder');
});
test('@smoke guards preview drops and keeps progress and errors visible with the tree collapsed', async ({
page,
baseURL,
}) => {
const { browser } = await openFiles(
page,
baseURL!,
undefined,
undefined,
true,
true,
);
const sidebar = browser.getByTestId('workspace-file-sidebar');
await sidebar
.getByRole('treeitem', { name: 'README.md', exact: true })
.click();
const content = browser.getByTestId('workspace-file-content');
await expect(content).toContainText('Workspace guide');
let requests = 0;
let finish!: () => void;
await page.route('**/file/upload?*', async (route) => {
requests++;
await new Promise<void>((resolve) => {
finish = resolve;
});
await route.fulfill({ status: 403, json: { error: 'Upload denied' } });
});
for (const target of [content, browser.locator('header')]) {
expect(await dropLocalFiles(page, target, ['ignored.bin'])).toEqual({
dragover: true,
drop: true,
});
}
expect(requests).toBe(0);
await dropLocalFiles(page, sidebar, ['denied.bin']);
await expect.poll(() => requests).toBe(1);
await browser
.getByRole('button', { name: 'Toggle file tree', exact: true })
.click();
await expect(sidebar).toBeHidden();
await expect(browser.getByRole('progressbar')).toBeVisible();
await browser.screenshot({
path: test.info().outputPath('collapsed-progress.png'),
});
finish();
await expect(browser.getByRole('progressbar')).toBeHidden();
await expect(
browser.getByTestId('workspace-file-upload-status'),
).toContainText('denied.bin');
await expect(
browser.getByRole('button', { name: 'Dismiss', exact: true }).locator('..'),
).toBeVisible();
await browser.screenshot({
path: test.info().outputPath('collapsed-error.png'),
});
await browser.getByRole('button', { name: 'Dismiss', exact: true }).click();
await expect(
browser.getByTestId('workspace-file-upload-status'),
).not.toContainText('denied.bin');
});
test('@smoke shows readable root, directory and file-parent drop hints and clears on leave', async ({
page,
baseURL,
}) => {
const { browser } = await openFiles(
page,
baseURL!,
undefined,
undefined,
true,
true,
);
const sidebar = browser.getByTestId('workspace-file-sidebar');
await sidebar.getByRole('treeitem', { name: 'src', exact: true }).click();
const transfer = await page.evaluateHandle(() => {
const data = new DataTransfer();
data.items.add(new File(['x'], 'hint.bin'));
return data;
});
for (const [target, destination] of [
[sidebar, 'qwen-web-shell-e2e'],
[sidebar.getByRole('treeitem', { name: 'empty', exact: true }), 'empty'],
[sidebar.getByRole('treeitem', { name: 'guide.md', exact: true }), 'src'],
] as const) {
await target.dispatchEvent('dragover', {
dataTransfer: transfer,
composed: true,
});
await expect(sidebar).toContainText(
`Drop files to upload into: ${destination}`,
);
await sidebar.dispatchEvent('dragleave', {
dataTransfer: transfer,
relatedTarget: null,
composed: true,
});
await expect(sidebar).not.toContainText('Drop files to upload into:');
}
await transfer.dispose();
});