mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
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:
1 parent
a0e6678d66
commit
cebf5719bc
12 files changed
+1169
-27
No files matched your search
@@ -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`),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
Reference in new issue
Block a user