diff --git a/README.md b/README.md index a12ae4f..c74e887 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,16 @@ 添加插件截图 -### v0.14.0 +### v0.14.1 + +**✨ 新功能** + +- 📤 **文件窗口上传**([#239](https://github.com/omdsh-dev/DSH-better-sidebar/pull/239)):头部「上传文件 / 上传文件夹」按钮 + 拖放上传(拖到树区 = 工作区根,目录行 = 进该目录,文件行 = 进其所在目录,对齐 VSCode);上传时全屏模糊进度弹层(文件级进度 + 取消 / Esc);上传中按钮禁用、成功后文件树自动刷新 + +**🐛 修复** + +- 🔒 **上传链路的代码规范整改**([#239](https://github.com/omdsh-dev/DSH-better-sidebar/pull/239)):`relativePath` 空段 / 绝对路径显式拒绝;临时文件唯一命名(并发上传互不干扰、崩溃不阻塞);写流错误监听(磁盘失败不崩溃进程);客户端错误码与服务端统一、413 本地化 + > ⚠️ **本版适配 DSH 0.1.0-rc.8**:全部 `@deepseek-ai/*` peer / devDependencies 升至 `^0.1.0-rc.8`(含传递依赖,lockfile 零 rc.7 残留),`cordis` 同步 `^4.0.0-rc.8`,CI 挂载冒烟钉版 `@deepseek-ai/dsh@0.1.0-rc.8`。**rc.7 及更早的 DSH 环境将无法解析本版依赖,请先升级 DSH**。自 v0.13.1 以来的全部更改: diff --git a/README_EN.md b/README_EN.md index dbfd797..b39024d 100644 --- a/README_EN.md +++ b/README_EN.md @@ -41,7 +41,16 @@ Add Plugins screenshot -### v0.14.0 +### v0.14.1 + +**✨ New features** + +- 📤 **Upload into the files window** ([#239](https://github.com/omdsh-dev/DSH-better-sidebar/pull/239)): header "upload file / upload folder" buttons plus drag-drop (drop on the tree body = workspace root, on a directory row = that directory, on a file row = its parent directory, VSCode semantics); full-window blurred progress overlay while uploading (per-file progress + cancel / Esc); buttons disabled while busy, tree refreshes after the upload settles + +**🐛 Fixes** + +- 🔒 **Upload-chain hardening** ([#239](https://github.com/omdsh-dev/DSH-better-sidebar/pull/239)): empty and absolute `relativePath` segments are refused outright; uniquely named temp files (concurrent uploads stay independent, crashed processes never block later uploads); write-stream error listeners (a failing disk can no longer crash the host); client error codes unified with the wire (`too-large`), 413s localized + > ⚠️ **This release adapts to DSH 0.1.0-rc.8**: every `@deepseek-ai/*` peer/devDependency is raised to `^0.1.0-rc.8` (transitives included — zero rc.7 leftovers in the lockfile), `cordis` to `^4.0.0-rc.8`, and the CI mount lane pins `@deepseek-ai/dsh@0.1.0-rc.8`. **DSH environments on rc.7 or earlier can no longer resolve this release's dependencies — upgrade DSH first.** All changes since v0.13.1: diff --git a/dsh.plugin.json b/dsh.plugin.json index fe47d4d..64f366b 100644 --- a/dsh.plugin.json +++ b/dsh.plugin.json @@ -1,6 +1,6 @@ { "id": "dsh-external/dsh-better-sidebar", - "version": "0.14.0", + "version": "0.14.1", "main": "./lib/index.js", "description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git / 浏览器,按会话隔离。暴露服务供其他插件注册侧边栏页面和文件预览器", "engines": { diff --git a/package.json b/package.json index c372819..a461367 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dsh-better-sidebar", - "version": "0.14.0", + "version": "0.14.1", "description": "DSH web plugin: a VSCode-like right sidebar (explorer / editor / terminal / git / browser), isolated per conversation session. Exposes a service for other plugins to register sidebar tabs and file viewers.", "type": "module", "repository": { diff --git a/src/client/FileTree.tsx b/src/client/FileTree.tsx index 7e85a94..20e4c58 100644 --- a/src/client/FileTree.tsx +++ b/src/client/FileTree.tsx @@ -11,19 +11,27 @@ * right (appends `@` to the composer draft), and right-click * opens a context menu: file rows offer the caller's open escapes * (new tab / to the side, only when the callbacks exist) and a download - * action (the host serves raw bytes, binary-safe); every row can copy the - * relative or absolute path (with a brief "copied" label replacing the - * button after a successful write). + * action (the host serves raw bytes, binary-safe); directory rows offer + * "upload here"; every row can copy the relative or absolute path (with a + * brief "copied" label replacing the button after a successful write). + * + * Uploads start here (drag-drop or the context menu picker) but run in the + * caller: every request is reported through `onUploadRequest(dir, items)` + * (VSCode semantics — a drop on a file row targets its parent directory), + * and `busy` gates new drags while one upload is in flight. */ -import { useCallback, useEffect, useRef, useState, type MouseEvent, type ReactNode } from 'react' +import { useCallback, useEffect, useRef, useState, type DragEvent, type MouseEvent, type ReactNode } from 'react' +import { createPortal } from 'react-dom' import clsx from 'clsx' import { IconCodeOutline16, IconCopyOutline16, IconDownloadOutline16, IconFolderClose16, IconFolderOpen16, IconLinkOutline16, Menu, writeClipboard, } from '@deepseek-ai/dsh-client-ui-primitives' import { api, downloadUrl, type FsEntry } from './api.ts' +import { IconUploadOutline16 } from './icons.tsx' import { relativeTo } from './paths.ts' import { t } from './locales.ts' +import { uploadItemsFromDrop, uploadItemsFromFiles, type UploadItem } from './upload.ts' import css from './sidebar.module.css' interface LevelData { @@ -38,9 +46,42 @@ export function baseName(path: string): string { return at === -1 ? trimmed : trimmed.slice(at + 1) } +/** The containing directory of an absolute row path (never the root edge here). */ +function parentOf(path: string): string { + const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + return at <= 0 ? path : path.slice(0, at) +} + /** How long the row's "copied" label stays after a successful write. */ const COPIED_MS = 1200 +/** + * The drop overlay's hero art: an arrow descending into a tray (upload + * zone) and tilted photo cards (chat zone). Hand-drawn, colored in the + * palette of DSH's own native drop illustration (#3964FE / #679EFE / + * #9CE5ED) so the two zones read as one family; the drop overlay is this + * flow's one brand moment, so it gets color the rest of the UI never does. + */ +const UploadDropIllustration = () => ( + +) + +/** The chat zone's art: a tilted pair of photo cards (sun over mountains). */ +const ChatDropIllustration = () => ( + +) + export function FileTree(props: { sessionId: string cwd: string | undefined @@ -55,14 +96,111 @@ export function FileTree(props: { onReferenceFile: (path: string) => void /** Bump to wipe the level cache and reload the visible set. */ refreshTick: number + /** Upload into `dir` (absolute, inside the workspace); runs in the caller. */ + onUploadRequest: (dir: string, items: UploadItem[]) => void + /** True while an upload is in flight (drops are ignored). */ + busy: boolean }) { - const { sessionId, cwd, expanded, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, onReferenceFile, refreshTick } = props + const { sessionId, cwd, expanded, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, onReferenceFile, refreshTick, onUploadRequest, busy } = props const [data, setData] = useState>({}) const dataRef = useRef(data) /** The row whose path was just copied ("copied" label replaces its button). */ const [copiedPath, setCopiedPath] = useState(null) /** Open context menu: the row path (and whether it is a directory) plus the cursor position. */ const [rowMenu, setRowMenu] = useState<{ path: string; isDir: boolean; x: number; y: number } | null>(null) + /** Whether a file drag hovers the tree (drives the portaled drop zone). */ + const [dropOver, setDropOver] = useState(false) + /** The directory a drag is hovering right now (null = body, drop to root). */ + const [dropTarget, setDropTarget] = useState(null) + /** + * Enter/leave depth under the tree body. dragenter/dragleave fire per + * element along the drag path (and bubble), so a counter — DSH InputBar's + * own pattern — is the flicker-free signal; relatedTarget is unreliable + * across engines for drag events. + */ + const dropDepth = useRef(0) + /** Explorer body element; its viewport rect anchors the portaled drop zone. */ + const bodyRef = useRef(null) + /** The body's viewport rect captured at drag entry (null = not measured). */ + const [dropRect, setDropRect] = useState<{ top: number; left: number; width: number; height: number } | null>(null) + /** Context-menu "upload here" target directory. */ + const pendingUploadDir = useRef(undefined) + const fileInputRef = useRef(null) + + /** Reset all drag state (drop landed, the drag left, or a new drag begins). */ + const resetDrop = (): void => { + dropDepth.current = 0 + setDropOver(false) + setDropTarget(null) + setDropRect(null) + } + + /** + * Drop handlers: always swallow the event (a dropped file must never open + * in the browser), then report the target directory to the caller. A drop + * ends the drag without further leave events, so the depth resets here. + * The payload collection is async (dropped folders are traversed through + * their entry handles — captured synchronously inside uploadItemsFromDrop + * while the dataTransfer is still live), so the request rides a then. + */ + const reportDrop = (dir: string, data: DataTransfer | undefined): void => { + if (busy) return + void uploadItemsFromDrop(data).then((items) => { + if (items.length > 0) onUploadRequest(dir, items) + }) + } + const handleBodyDrop = (event: DragEvent): void => { + event.preventDefault() + event.stopPropagation() + resetDrop() + if (cwd !== undefined) reportDrop(cwd, event.dataTransfer) + } + const handleDirDrop = (event: DragEvent, dir: string): void => { + event.preventDefault() + event.stopPropagation() + resetDrop() + reportDrop(dir, event.dataTransfer) + } + const handleFileDrop = (event: DragEvent, path: string): void => { + // VSCode semantics: dropping onto a file uploads into its directory. + handleDirDrop(event, parentOf(path)) + } + const handleBodyDragEnter = (event: DragEvent): void => { + event.preventDefault() + event.stopPropagation() + dropDepth.current += 1 + if (busy) return + // First entry: anchor the portaled drop zone to the body's rect. + if (dropDepth.current === 1) { + const rect = bodyRef.current?.getBoundingClientRect() + setDropRect(rect === undefined ? null : { top: rect.top, left: rect.left, width: rect.width, height: rect.height }) + } + setDropOver(true) + } + const handleBodyDragLeave = (): void => { + dropDepth.current = Math.max(0, dropDepth.current - 1) + if (dropDepth.current > 0) return + setDropOver(false) + setDropTarget(null) + setDropRect(null) + } + const handleBodyDragOver = (event: DragEvent): void => { + event.preventDefault() + event.stopPropagation() + event.dataTransfer.dropEffect = busy ? 'none' : 'copy' + if (busy) return + // Rows stop propagation, so this only fires over non-row regions: the + // drag targets the workspace root. dragover fires continuously, making + // it the authoritative (flicker-free) place to clear the row target. + setDropTarget(null) + } + const handleRowDragOver = (event: DragEvent, dir: string): void => { + event.preventDefault() + event.stopPropagation() + event.dataTransfer.dropEffect = busy ? 'none' : 'copy' + if (busy) return + setDropTarget(dir) + } const storeLevel = useCallback((path: string, level: LevelData) => { dataRef.current = { ...dataRef.current, [path]: level } @@ -170,7 +308,10 @@ export function FileTree(props: {
{ onToggle(entry.path) }} onKeyDown={(event) => { @@ -179,6 +320,8 @@ export function FileTree(props: { onToggle(entry.path) } }} + onDragOver={(event) => { handleRowDragOver(event, entry.path) }} + onDrop={(event) => { handleDirDrop(event, entry.path) }} onContextMenu={(event) => { openRowMenu(event, entry.path, true) }} > {isOpen ? : } @@ -195,7 +338,10 @@ export function FileTree(props: { key={entry.path} role="button" tabIndex={0} - className={clsx(css.explorerRow, entry.hidden && css.explorerHidden, entry.broken && css.explorerBroken)} + className={clsx( + css.explorerRow, entry.hidden && css.explorerHidden, entry.broken && css.explorerBroken, + dropTarget === parentOf(entry.path) && css.explorerRowDropTarget, + )} style={{ paddingLeft: depth * 22 + 6 }} title={entry.broken ? `${entry.path} — ${t('brokenSymlink')}` : entry.path} onClick={() => { onOpenFile(entry.path) }} @@ -205,6 +351,8 @@ export function FileTree(props: { onOpenFile(entry.path) } }} + onDragOver={(event) => { handleRowDragOver(event, parentOf(entry.path)) }} + onDrop={(event) => { handleFileDrop(event, entry.path) }} onContextMenu={(event) => { openRowMenu(event, entry.path, false) }} > @@ -217,14 +365,23 @@ export function FileTree(props: { } return ( -
+
{root === undefined ? (
{t('noSession')}
) : ( <>
{ handleRowDragOver(event, root) }} + onDrop={(event) => { handleDirDrop(event, root) }} onContextMenu={(event) => { openRowMenu(event, root, true) }} > @@ -249,10 +406,70 @@ export function FileTree(props: { {data[root] !== undefined && renderLevel(root, 1)} )} + {dropOver && dropRect !== null && createPortal( + /* + * The sidebar's drop surface, portaled to document.body at z-1001 — + * above DSH's own whole-page drop mask (z-1000, see InputBar's + * document-level intake) so the two never compete. It dims the WHOLE + * viewport (the giant box-shadow spread on the zone frame is the + * mask; the zone rect itself stays clear), teaching the zone split: + * the dimmed conversation column still takes drops into the chat + * natively (this layer is pointer-inert), while the clear frame + * marks the tree as the workspace-upload zone. Deliberate exception + * to the "panel stays below the DSH float stack" rule: transient, + * and the drop always lands on the element beneath. The hint pill + * docks at the TOP edge of the zone — right under the search row, + * the first thing the eye meets — keeping the rows aimable. + */ + <> +
+
+ +
+ + + {dropTarget !== null ? t('uploadTo', { dir: dropTarget }) : t('uploadDropHint')} + +
+
+
+ {/* The left zone's invitation, centered in the space beside the + tree; skipped when that space is too narrow to hold it. */} + {dropRect.left >= 200 && ( +
+
+ + {t('uploadDropChat')} +
+
+ )} + , + document.body, + )} {/* The one shared context menu, positioned at the right-click cursor (portal so the tree's overflow clip cannot crop it). */} + { + const dir = pendingUploadDir.current ?? root + pendingUploadDir.current = undefined + if (dir !== undefined && !busy) onUploadRequest(dir, uploadItemsFromFiles(event.target.files ?? [])) + event.target.value = '' + }} + /> { setRowMenu(null) }} @@ -268,6 +485,10 @@ export function FileTree(props: { ...(rowMenu?.isDir === false ? [{ id: 'download', label: t('download'), icon: }] : []), + // Upload into a directory (incl. the workspace root row). + ...(rowMenu?.isDir === true + ? [{ id: 'upload-here', label: t('uploadHere'), icon: }] + : []), { id: 'relative', label: t('copyRelative'), icon: }, { id: 'absolute', label: t('copyAbsolute'), icon: }, ]} @@ -287,6 +508,11 @@ export function FileTree(props: { downloadFile(target.path) return } + if (id === 'upload-here') { + pendingUploadDir.current = target.path + fileInputRef.current?.click() + return + } copyPath( id === 'relative' ? relativeTo(cwd ?? '', target.path) : target.path, target.path, diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index a28d677..6a09d2a 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -28,7 +28,7 @@ * drawer floats). Widening does not migrate back: the tabs keep living in * the right tree. */ -import { createElement, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' +import { createElement, useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type ReactNode } from 'react' import { useSyncExternalStore } from 'react' import clsx from 'clsx' import { IconCloseFill14, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' @@ -59,6 +59,33 @@ import css from './sidebar.module.css' * (mirror of the terminal view's own cap; the loop restarts on session switch). */ const FAILURE_LIMIT = 3 +/** + * OS file drags over the sidebar belong to the sidebar, not to the chat: + * DSH's composer (InputBar) listens for file drags on the DOCUMENT and + * answers with a full-screen "drop image here" mask plus image intake on + * drop. Both panel-host render sites swallow the whole event quartet — + * enter/over/leave/drop — so the region is a black hole to that document + * listener. All four must be stopped: InputBar keeps an enter/leave depth + * counter, and a leave that escapes without its matching enter unbalances + * the count (this was the full-screen mask flickering over the sidebar). + * The conversation column keeps DSH's native overlay and intake untouched; + * gated on the 'Files' type so in-app drags (tab reorder, split zones) + * propagate exactly as before. + */ +const swallowOsFileDrag = (event: DragEvent): void => { + if (!(event.dataTransfer?.types.includes('Files') ?? false)) return + event.preventDefault() + event.stopPropagation() +} + +/** The four drag events a file drag must never carry past the panel host. */ +const osFileDragShield = { + onDragEnter: swallowOsFileDrag, + onDragOver: swallowOsFileDrag, + onDragLeave: swallowOsFileDrag, + onDrop: swallowOsFileDrag, +} + /** Render the content of one tab (dispatched by type). */ function TabContent(props: { tab: SidebarTab @@ -863,7 +890,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { if (state === undefined || sessionId === undefined) { return ( -
+
{!narrow && ( @@ -949,7 +976,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { ) return ( -
+
{/* The persistent toggle cluster at the top-right corner: the bottom panel's button (bottom glyph) LEFT of the right panel's (side glyph). diff --git a/src/client/TreePanel.tsx b/src/client/TreePanel.tsx index 0f7830b..1379d77 100644 --- a/src/client/TreePanel.tsx +++ b/src/client/TreePanel.tsx @@ -6,16 +6,40 @@ * open). Owns its refresh tick: the icon next to the search input clears * the tree cache. EditorHost docks it as the tab's right panel (wrapped in * a drag-resize handle) and provides the file context-menu open escapes. + * + * Uploads (header pickers, the tree's drag-drop and "upload here" menu) + * all funnel through here: one session at a time, shown in a full-window + * progress overlay with cancel, followed by a tree refresh and a one-line + * hint under the search row (success fades, failures and cancels stay). + * OS file drags are shielded at the panel host (see Sidebar.tsx), so a + * drop over the file window uploads here and never reaches DSH's chat + * intake. */ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState, type InputHTMLAttributes } from 'react' import clsx from 'clsx' -import { IconRefreshOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconFolderOpen16, IconRefreshOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' import { api } from './api.ts' import { FileTree } from './FileTree.tsx' +import { IconUploadOutline16 } from './icons.tsx' import { t } from './locales.ts' import { resolveSidebarPath } from './produced-files.ts' +import { UploadOverlay } from './UploadOverlay.tsx' +import { + summarizeResults, uploadHintText, uploadItemsFromFiles, uploadToDir, + UPLOAD_HINT_MS, type UploadItem, +} from './upload.ts' import css from './sidebar.module.css' +/** One in-flight upload session (the overlay's progress source). */ +interface UploadSession { + dir: string + done: number + total: number + /** Relative path of the file being uploaded ('' when none is in flight). */ + current: string + controller: AbortController +} + export function TreePanel(props: { sessionId: string cwd: string | undefined @@ -36,6 +60,63 @@ export function TreePanel(props: { const [results, setResults] = useState<{ matches: string[]; truncated: boolean } | null>(null) const [error, setError] = useState(null) const [refreshTick, setRefreshTick] = useState(0) + /** One-line upload status under the search row ('' hides the hint). */ + const [uploadStatus, setUploadStatus] = useState('') + /** Whether the status line is a failure/cancel (error color, stays visible). */ + const [uploadFailed, setUploadFailed] = useState(false) + /** The in-flight upload session (null → no overlay, buttons enabled). */ + const [upload, setUpload] = useState(null) + /** True between the cancel click and the session settling (button disabled). */ + const [cancelling, setCancelling] = useState(false) + /** Set by cancelUpload; the settle path shows 'upload cancelled' instead of + * summarizing the partial results. */ + const cancelledRef = useRef(false) + const fileInputRef = useRef(null) + const folderInputRef = useRef(null) + + /** Start one upload session into `dir` (absolute, inside the workspace). */ + const startUpload = (dir: string, items: UploadItem[]): void => { + if (items.length === 0 || cwd === undefined || upload !== null) return + cancelledRef.current = false + const controller = new AbortController() + setUploadFailed(false) + setUploadStatus(uploadHintText(0, items.length, '', dir, t)) + setUpload({ dir, done: 0, total: items.length, current: '', controller }) + void uploadToDir({ sessionId, cwd }, dir, items, (done, total, current) => { + if (current !== '') setUploadStatus(uploadHintText(done, total, current, dir, t)) + setUpload(session => session === null ? session : { ...session, done, total, current }) + }, controller.signal).then((results) => { + setUpload(null) + setCancelling(false) + // Reload the tree whatever the outcome: files may have landed before a + // cancel, and failures leave whatever did succeed visible. + setRefreshTick(tick => tick + 1) + if (cancelledRef.current) { + setUploadStatus(t('uploadCancelled')) + setUploadFailed(true) + return + } + const status = summarizeResults(results, t) + setUploadStatus(status) + setUploadFailed(results.some(result => !result.ok)) + // Success messages are transient; failures stay until the next action. + if (results.every(result => result.ok)) { + window.setTimeout(() => { + setUploadStatus(current => current === status ? '' : current) + }, UPLOAD_HINT_MS) + } + }) + } + + /** Cancel the in-flight upload (aborts the request; the host drops its temp). */ + const cancelUpload = (): void => { + if (upload === null || cancelling) return + cancelledRef.current = true + setCancelling(true) + upload.controller.abort() + } + + const folderInputProps = { webkitdirectory: '' } as InputHTMLAttributes const needle = query.trim() useEffect(() => { @@ -61,6 +142,8 @@ export function TreePanel(props: { } }, [sessionId, cwd, needle]) + const busy = upload !== null + return (
@@ -80,7 +163,51 @@ export function TreePanel(props: { > + + + { + if (cwd !== undefined) startUpload(cwd, uploadItemsFromFiles(event.target.files ?? [])) + event.target.value = '' + }} + /> + { + if (cwd !== undefined) startUpload(cwd, uploadItemsFromFiles(event.target.files ?? [])) + event.target.value = '' + }} + />
+ {uploadStatus !== '' && ( +
{uploadStatus}
+ )} {needle === '' ? ( ) : (
@@ -116,6 +245,16 @@ export function TreePanel(props: { )}
)} + {upload !== null && ( + + )}
) } diff --git a/src/client/UploadOverlay.tsx b/src/client/UploadOverlay.tsx new file mode 100644 index 0000000..d6c0122 --- /dev/null +++ b/src/client/UploadOverlay.tsx @@ -0,0 +1,62 @@ +/** + * Full-window upload progress over the files tree: a blurred scrim (same mask + * token as the repo's Modal primitive) with a card showing the target + * directory, file-level progress, and a cancel button. Esc cancels too — + * clicking the scrim does not, so a stray click can never abort an upload. + * Rendered inside TreePanel (absolute inset-0), so it covers only the file + * window and never the conversation column. + */ +import { useEffect, type ReactNode } from 'react' +import { IconUploadOutline16 } from './icons.tsx' +import { t } from './locales.ts' +import { uploadHintText } from './upload.ts' +import css from './sidebar.module.css' + +export function UploadOverlay(props: { + /** Absolute upload directory (the session workspace or a tree directory). */ + dir: string + done: number + total: number + /** Relative path of the file being uploaded ('' when none is in flight). */ + current: string + onCancel: () => void + /** True while cancellation is in flight (disables the cancel button). */ + cancelling?: boolean +}): ReactNode { + const { dir, done, total, current, onCancel, cancelling } = props + // Esc cancels (MermaidModal-style native listener; the overlay is not a + // focus-trapped dialog, so a window listener is the honest scope). + useEffect(() => { + const onKey = (event: KeyboardEvent): void => { + if (event.key === 'Escape') onCancel() + } + window.addEventListener('keydown', onKey) + return () => { window.removeEventListener('keydown', onKey) } + }, [onCancel]) + + const percent = total === 0 ? 0 : Math.min(100, Math.round((done / total) * 100)) + return ( +
+
+
+ + {t('uploadingTo', { dir })} +
+
+
+
+
{uploadHintText(done, total, current, dir, t)}
+ +
+
+ ) +} diff --git a/src/client/api.ts b/src/client/api.ts index 216916a..531ff83 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -117,6 +117,45 @@ async function call(method: string, payload: Record, signal? return parsed.value as T } +/** + * Upload one file to the sidebar's raw upload route: the File goes straight + * into the POST body (no JSON/base64 re-encoding — the host streams it into + * the workspace). Failure surfaces as {@link SidebarApiError} with the wire + * code, exactly like every `/sidebar/api` call. An aborted `signal` rejects + * with the DOMException as-is (the caller decides whether that is an error). + */ +async function fetchUpload( + scope: SessionScope, + dir: string, + relativePath: string, + body: Blob, + signal?: AbortSignal, +): Promise { + const params = new URLSearchParams({ sessionId: scope.sessionId, dir, relativePath }) + if (scope.cwd !== undefined && scope.cwd !== '') params.set('cwd', scope.cwd) + let response: Response + try { + response = await fetch(`/sidebar/upload?${params.toString()}`, { + method: 'POST', + headers: { 'content-type': 'application/octet-stream' }, + body, + signal, + }) + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') throw error + throw new SidebarApiError('network', error instanceof Error ? error.message : String(error)) + } + const parsed: { ok?: boolean; value?: unknown; error?: { code?: string; message?: string } } | null + = await response.json().catch(() => null) + if (!response.ok || parsed === null || parsed.ok !== true || parsed.value === undefined) { + throw new SidebarApiError( + parsed?.error?.code ?? 'http', + parsed?.error?.message ?? `HTTP ${response.status}`, + ) + } + return parsed.value as T +} + /** One request's session scope: the conversation id plus its cwd when known. */ export interface SessionScope { sessionId: string @@ -143,6 +182,10 @@ export const api = { call('fs.read', scopePayload(scope, { path }), signal), fsWrite: (scope: SessionScope, path: string, content: string) => call<{ ok: true }>('fs.write', scopePayload(scope, { path, content })), + /** Upload one file's raw bytes into `dir` (keeps the folder tree via + * `relativePath`); the host streams it under the session workspace. */ + uploadFile: (scope: SessionScope, dir: string, relativePath: string, body: Blob, signal?: AbortSignal) => + fetchUpload<{ path: string; size: number }>(scope, dir, relativePath, body, signal), gitStatus: (scope: SessionScope, signal?: AbortSignal) => call('git.status', scopePayload(scope, {}), signal), gitDiff: (scope: SessionScope, path: string | undefined, staged: boolean, signal?: AbortSignal) => diff --git a/src/client/icons.tsx b/src/client/icons.tsx index 0fa8e55..aef4bcf 100644 --- a/src/client/icons.tsx +++ b/src/client/icons.tsx @@ -61,6 +61,15 @@ export const IconStopOutline16 = ({ size = 16, className }: IconProps) => ( ) +/** Upload glyph in the app's outline style: an arrow rising into a tray + * (the file-manager "upload into the workspace" action). */ +export const IconUploadOutline16 = ({ size = 16, className }: IconProps) => ( + + + + +) + // ── File-viewer inventory glyphs (Side card settings page) ──────────────── /** Image viewer glyph: a picture frame with a sun and a mountain. */ diff --git a/src/client/locales.ts b/src/client/locales.ts index d89900d..490ce15 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -129,6 +129,19 @@ export const zh = { copyRelative: '复制相对地址', copyAbsolute: '复制绝对地址', download: '下载', + uploadFiles: '上传文件', + uploadFolder: '上传文件夹', + uploadHere: '上传到此处', + uploadDropHint: '拖拽文件/文件夹到此处上传', + uploadDropChat: '拖放到聊天区:添加图片到对话', + uploadTo: '上传到 {dir}', + uploadingTo: '正在上传到 {dir}…', + uploadProgress: '正在上传 {done}/{total}: {name}', + uploadDone: '已上传 {count} 个文件', + uploadFailed: '上传失败:{error}', + uploadFailedUnknown: '未知错误', + uploadTooLarge: '文件过大,超出上传上限', + uploadCancelled: '上传已取消', settingsNav: '侧边卡片', settingsIntro: '管理侧边卡片的显示内容与默认行为', settingsPopupDesc: '为「{feature}」配置相关选项', @@ -384,6 +397,19 @@ export const en: Record = { copyRelative: 'Copy relative path', copyAbsolute: 'Copy absolute path', download: 'Download', + uploadFiles: 'Upload files', + uploadFolder: 'Upload folder', + uploadHere: 'Upload here', + uploadDropHint: 'Drop files/folders here to upload', + uploadDropChat: 'Drop onto the chat to add images', + uploadTo: 'Upload into {dir}', + uploadingTo: 'Uploading into {dir}…', + uploadProgress: 'Uploading {done}/{total}: {name}', + uploadDone: 'Uploaded {count} file(s)', + uploadFailed: 'Upload failed: {error}', + uploadFailedUnknown: 'Unknown error', + uploadTooLarge: 'File too large (over the upload limit)', + uploadCancelled: 'Upload cancelled', settingsNav: 'Side card', settingsIntro: 'Manage what the side card shows and how it behaves', settingsPopupDesc: 'Configure related options for {feature}', diff --git a/src/client/service.ts b/src/client/service.ts index 13f1934..d932977 100644 --- a/src/client/service.ts +++ b/src/client/service.ts @@ -470,7 +470,7 @@ export function matchUrlTarget(tabs: readonly TabDescriptor[], url: URL): TabDes * The plugin version this service instance reports. Keep in lockstep with * `package.json`'s version — `tests/service.spec.ts` asserts the pair. */ -export const SIDEBAR_SERVICE_VERSION = '0.14.0' +export const SIDEBAR_SERVICE_VERSION = '0.14.1' /** * Monotonic capability list consumers use to gate new API usage (features diff --git a/src/client/sidebar.module.css b/src/client/sidebar.module.css index e53c109..3dcb15c 100644 --- a/src/client/sidebar.module.css +++ b/src/client/sidebar.module.css @@ -832,6 +832,198 @@ body[data-dsh-sidebar-dragging] .bottomPanel { text-align: center; } +/* Drag a file/folder over a tree row: the target directory row gets a filled + highlight (drops land inside it; file rows target their parent directory). + The zone frame around the whole tree is the portaled .uploadDropZone. */ +.explorerRowDropTarget { + background: var(--dsw-alias-interactive-bg-hover); + outline: 1px dashed var(--dsw-alias-interactive-bg-hover-accent); + outline-offset: -1px; +} + +/* The sidebar's drop surface: a pointer-inert frame portaled to + document.body, fixed over the explorer body's viewport rect at z-1001 — + above DSH's own whole-page drop mask (z-1000, see InputBar's + document-level intake). The giant box-shadow spread IS the full-viewport + mask (same token DSH uses for its drop mask): everything dims except the + zone rect, teaching the zone split — the dimmed conversation column still + takes drops into the chat natively, the clear frame marks the tree as the + workspace-upload zone. Deliberate exception to the "panel stays below the + DSH float stack" rule: transient and pointer-events none, so the drop + always lands on the row beneath. The hint pill docks at the TOP edge of + the zone (right under the search row, the first thing the eye meets), + leaving the rows — the actual drop targets — aimable. */ +.uploadDropZone { + position: fixed; + z-index: 1001; + pointer-events: none; + display: flex; + align-items: flex-start; + justify-content: center; + padding: 12px; + border: 2px dashed var(--dsw-alias-interactive-bg-hover-accent); + border-radius: 10px; + box-shadow: 0 0 0 200vmax var(--dsw-alias-bg-mask-drop); + animation: dsh-row-in 150ms var(--ds-ease-in-out); +} + +/* The zone's drop hero, docked at the TOP edge (right under the search + row, the first thing the eye meets): the colorful tray illustration over + the hint pill — stacked so the rows below stay aimable. */ +.uploadDropHero { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + max-width: 100%; + padding-top: 8px; +} + +.uploadDropZonePill { + display: flex; + align-items: center; + gap: 6px; + max-width: 100%; + padding: 6px 12px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 999px; + background: var(--dsw-alias-bg-layer-2); + box-shadow: var(--dsw-shadow-lv2); + color: var(--dsw-alias-label-primary); + font: var(--dsw-font-xxs-strong-12); +} + +.uploadDropZoneText { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* The left zone's invitation: centered in the viewport space beside the + tree (inline width = the zone's left edge), telling the user the dimmed + conversation column still accepts drops — into the chat, natively. */ +.uploadDropChatHint { + position: fixed; + top: 0; + left: 0; + bottom: 0; + z-index: 1002; + pointer-events: none; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + animation: dsh-row-in 150ms var(--ds-ease-in-out); +} + +/* The invitation's content: the colorful photo-cards illustration over the + label — the drop overlay is this flow's one brand moment, so it gets the + color the rest of the UI never does (palette borrowed from DSH's native + drop illustration so the two zones read as one family). */ +.uploadDropChatCard { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + max-width: 100%; + text-align: center; + color: var(--dsw-alias-label-primary); + font: var(--dsw-font-s-strong-14); +} + +/* Full-window upload progress: blurred scrim + card (same mask/backdrop + tokens as the repo's Modal primitive; absolute within the tree panel so it + never covers the conversation column). */ +.uploadOverlay { + position: absolute; + inset: 0; + z-index: 30; + display: flex; + align-items: center; + justify-content: center; + background: var(--dsw-alias-bg-mask-1); + backdrop-filter: var(--dsw-mask-blur); + animation: dsh-row-in 150ms var(--ds-ease-in-out); +} + +.uploadOverlayCard { + display: flex; + flex-direction: column; + gap: 12px; + min-width: 280px; + max-width: min(420px, calc(100% - 48px)); + padding: 20px 24px; + border: 1px solid var(--dsw-alias-border-inverted); + border-radius: 24px; + background: var(--dsw-alias-bg-layer-2); + box-shadow: var(--dsw-shadow-lv3); +} + +.uploadOverlayTitle { + display: flex; + align-items: center; + gap: 8px; + font: var(--dsw-font-s-strong-14); + color: var(--dsw-alias-label-primary); +} + +.uploadOverlayTitle > svg { + flex: none; +} + +.uploadOverlayTitle > span { + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.uploadOverlayProgress { + height: 6px; + border-radius: 3px; + background: var(--dsw-alias-border-l2); + overflow: hidden; +} + +.uploadOverlayProgressFill { + height: 100%; + border-radius: 3px; + background: var(--dsw-alias-interactive-bg-hover-accent); + transition: width 150ms var(--ds-ease-in-out); +} + +.uploadOverlayStatus { + min-height: 1em; + font: var(--dsw-font-xxs-12); + font-variant-numeric: tabular-nums; + color: var(--dsw-alias-label-tertiary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.uploadOverlayCancel { + align-self: flex-end; + height: 28px; + padding: 0 14px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 8px; + background: transparent; + color: var(--dsw-alias-label-primary); + font: var(--dsw-font-xxs-strong-12); + cursor: pointer; +} + +.uploadOverlayCancel:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover); + border-color: var(--dsw-alias-border-l2); +} + +.uploadOverlayCancel:disabled { + opacity: 0.4; + cursor: default; +} + /* ── Editor ────────────────────────────────────────────────────────────── */ .editor { @@ -929,6 +1121,8 @@ body[data-dsh-sidebar-dragging] .bottomPanel { } .editorTreePanel { + /* Relative so the upload overlay and drop pill can cover the tree exactly. */ + position: relative; flex: 1; min-width: 0; min-height: 0; diff --git a/src/client/upload.ts b/src/client/upload.ts new file mode 100644 index 0000000..f8cf553 --- /dev/null +++ b/src/client/upload.ts @@ -0,0 +1,187 @@ +/** + * File-upload plumbing for the files window: turn a file picker or a drag-drop + * into per-file raw-byte uploads through the sidebar's `/sidebar/upload` route. + * + * Folders keep their tree in both flows: the picker's `webkitdirectory` + * selection arrives as Files with `webkitRelativePath` filled, and dropped + * folders — which never surface in `dataTransfer.files` — are traversed via + * `webkitGetAsEntry`, so the relative path is preserved for every nested file + * and the host recreates the tree under the chosen directory. The File is + * streamed straight into the POST body (no base64 inflation); uploads run + * sequentially so one slow file cannot starve the others, and each result + * reports its own outcome (the tree keeps going after a failure). An + * optional `AbortSignal` stops the queue at the next item boundary and + * aborts the in-flight request; the host cleans up its temp file when the + * request stream dies. + */ +import { api, SidebarApiError, type SessionScope } from './api.ts' +import { isAbsolutePath } from './paths.ts' +import type { CopyKey } from './locales.ts' + +/** One pending file: the browser File plus the workspace-relative target path. */ +export interface UploadItem { + file: File + relativePath: string +} + +/** One settled upload. */ +export interface UploadResult { + relativePath: string + ok: boolean + path?: string + /** Wire error code when the host refused the upload ('too-large', ...). */ + code?: string + /** The host's error message (English wire text; localize via `code`). */ + error?: string +} + +/** Sanitize a relative target: absolute paths, traversal, and empty segments + * are rejected (the host enforces the same rules with a 400). */ +function sanitizeRelativePath(rel: string): string | undefined { + if (rel === '' || isAbsolutePath(rel)) return undefined + if (rel.split(/[\\/]/).some((s) => s === '' || s === '.' || s === '..')) return undefined + return rel +} + +/** The picker's relative path: webkitRelativePath when present, else the name. */ +function relativePathOf(file: File): string | undefined { + return sanitizeRelativePath(file.webkitRelativePath || file.name || '') +} + +/** Collect a picker selection (webkitdirectory folders carry relative paths). */ +export function uploadItemsFromFiles(files: FileList | readonly File[]): UploadItem[] { + const items: UploadItem[] = [] + for (const file of files) { + const rel = relativePathOf(file) + if (rel !== undefined) items.push({ file, relativePath: rel }) + } + return items +} + +/** Read one dropped file-system entry into upload items; directories + * recurse, prefixing their name onto every descendant's relative path. */ +async function itemsFromEntry(entry: FileSystemEntry, prefix: string): Promise { + if (entry.isFile) { + const file = await new Promise((resolve, reject) => { + ;(entry as FileSystemFileEntry).file(resolve, reject) + }) + const rel = sanitizeRelativePath(prefix + file.name) + return rel === undefined ? [] : [{ file, relativePath: rel }] + } + if (entry.isDirectory) { + const reader = (entry as FileSystemDirectoryEntry).createReader() + const entries: FileSystemEntry[] = [] + // readEntries returns one BATCH per call; drain until an empty batch. + for (;;) { + const batch = await new Promise((resolve, reject) => { + reader.readEntries(resolve, reject) + }) + if (batch.length === 0) break + entries.push(...batch) + } + const nested = await Promise.all(entries.map(child => itemsFromEntry(child, `${prefix}${entry.name}/`))) + return nested.flat() + } + return [] +} + +/** + * Collect a drag-drop payload. Dropped folders do NOT surface in + * `dataTransfer.files` — they arrive as directory items, so entries are + * captured via `webkitGetAsEntry` and traversed (draining readEntries + * batches), keeping each nested file's relative path. MUST be invoked + * synchronously from the drop handler: the dataTransfer enters protected + * mode once the event dispatch ends, while the captured entry handles stay + * readable asynchronously. Falls back to the flat file list when the entry + * API is unavailable; an entry that fails to read is skipped, not fatal. + */ +export async function uploadItemsFromDrop(data: DataTransfer | undefined): Promise { + if (data === undefined) return [] + // Synchronous section: capture every entry handle before returning. + const entries = [...data.items] + .map(item => (item.kind === 'file' ? item.webkitGetAsEntry() : null)) + .filter((entry): entry is FileSystemEntry => entry !== null) + if (entries.length === 0) return uploadItemsFromFiles(data.files) + const nested = await Promise.all(entries.map(entry => itemsFromEntry(entry, '').catch(() => []))) + return nested.flat() +} + +/** + * Client-side pre-check cap. Mirrors the host's default (config `uploadLimit`) + * only — the host enforces the real, configurable limit, so a lowered host + * limit surfaces as a `too-large` wire error, never as a silent pass. + */ +export const MAX_UPLOAD_BYTES = 128 * 1024 * 1024 + +/** How long a success hint stays before fading (failures stay until the next action). */ +export const UPLOAD_HINT_MS = 3500 + +/** + * One-line upload progress text: 'Uploading into {dir}…' while no file is in + * flight, then 'Uploading {done}/{total}: {name}' per file. Shared by the tree + * hint and the full-window upload overlay. + */ +export function uploadHintText( + done: number, + total: number, + current: string, + dir: string, + t: (key: CopyKey, params?: Record) => string, +): string { + return current === '' ? t('uploadingTo', { dir }) : t('uploadProgress', { done, total, name: current }) +} + +/** + * Upload every item into `dir` (absolute, inside the session workspace), + * sequentially, reporting progress as `(done, total, currentRelativePath)`. + * Resolves with one result per item — never rejects; `signal.aborted` stops + * the queue at the next item boundary (completed items stay uploaded). + */ +export async function uploadToDir( + scope: SessionScope, + dir: string, + items: UploadItem[], + onProgress?: (done: number, total: number, current: string) => void, + signal?: AbortSignal, +): Promise { + const results: UploadResult[] = [] + let done = 0 + for (const item of items) { + if (signal?.aborted) break + onProgress?.(done, items.length, item.relativePath) + try { + if (item.file.size > MAX_UPLOAD_BYTES) { + results.push({ relativePath: item.relativePath, ok: false, code: 'too-large' }) + } else { + const res = await api.uploadFile(scope, dir, item.relativePath, item.file, signal) + results.push({ relativePath: item.relativePath, ok: true, path: res.path }) + } + } catch (error) { + // An aborted in-flight request is a cancel, not a failure: stop quietly. + if (error instanceof DOMException && error.name === 'AbortError') break + results.push({ + relativePath: item.relativePath, + ok: false, + code: error instanceof SidebarApiError ? error.code : undefined, + error: error instanceof Error ? error.message : String(error), + }) + } + done++ + } + onProgress?.(done, items.length, '') + return results +} + +/** Fold a result list into a one-line status for the tree hint. */ +export function summarizeResults( + results: UploadResult[], + t: (key: CopyKey, params?: Record) => string, +): string { + const okCount = results.filter((r) => r.ok).length + const failed = results.find((r) => !r.ok) + if (failed !== undefined) { + const detail = failed.code === 'too-large' ? t('uploadTooLarge') : (failed.error ?? t('uploadFailedUnknown')) + return t('uploadFailed', { error: detail }) + } + return t('uploadDone', { count: okCount }) +} diff --git a/src/config.ts b/src/config.ts index 5baa590..4af0d03 100644 --- a/src/config.ts +++ b/src/config.ts @@ -42,6 +42,8 @@ export interface SidebarConfig { readLimit?: number /** Media route cap (bytes); larger binaries are refused. */ mediaLimit?: number + /** Upload route cap (bytes); larger files are refused without touching disk. */ + uploadLimit?: number /** Explorer row bound of one level. */ listLimit?: number /** Terminals per session. */ @@ -70,6 +72,7 @@ export interface SidebarConfig { export const Config: z = z.object({ readLimit: z.number().step(1).min(1).default(512 * 1024), mediaLimit: z.number().step(1).min(1).default(20 * 1024 * 1024), + uploadLimit: z.number().step(1).min(1).default(128 * 1024 * 1024), listLimit: z.number().step(1).min(1).default(1000), terminalsPerSession: z.number().step(1).min(1).default(3), reconnectGraceMs: z.number().step(1).min(0).default(30_000), @@ -81,6 +84,7 @@ export const Config: z = z.object({ export interface ResolvedSidebarConfig { readLimit: number mediaLimit: number + uploadLimit: number listLimit: number terminalsPerSession: number reconnectGraceMs: number @@ -100,6 +104,7 @@ export function resolveSidebarConfig(config: SidebarConfig | undefined): Resolve return { readLimit: config?.readLimit ?? 512 * 1024, mediaLimit: config?.mediaLimit ?? 20 * 1024 * 1024, + uploadLimit: config?.uploadLimit ?? 128 * 1024 * 1024, listLimit: config?.listLimit ?? 1000, terminalsPerSession: config?.terminalsPerSession ?? 3, reconnectGraceMs: config?.reconnectGraceMs ?? 30_000, diff --git a/src/fs-operations.ts b/src/fs-operations.ts new file mode 100644 index 0000000..a48447d --- /dev/null +++ b/src/fs-operations.ts @@ -0,0 +1,99 @@ +/** + * Workspace-safe file mutations for the sidebar (the upload route today). + * + * Every write is lexically confined to the session workspace: the upload + * directory is resolved absolute and must sit inside the session cwd, the + * relative path is sanitized (absolute paths, '.', '..' and empty segments + * are refused), and the final target must stay inside both. Containment is + * lexical (no symlink resolution) — a symlinked directory inside the cwd can + * redirect writes outside, matching the trust model of the other /sidebar/* + * routes. Bytes stream from the request body to a uniquely named temp sibling + * and are renamed into place, so a failed, aborted, or oversized upload never + * leaves a partial file at the target path. + */ +import { randomUUID } from 'node:crypto' +import { once } from 'node:events' +import { createWriteStream } from 'node:fs' +import { mkdir, rename, rm, stat } from 'node:fs/promises' +import { basename, dirname, join } from 'node:path' +import type { SidebarHttpRequest } from './context-types.ts' +import { isWithin, requireAbsolute } from './fs-tree.ts' +import { SidebarError } from './wire.ts' + +/** Inputs of one upload: the session scope plus the request body stream. */ +export interface WorkspaceUploadInput { + /** The session workspace root; target and directory must stay inside it. */ + cwd: string + /** Absolute upload directory chosen by the client (inside `cwd`). */ + dir: string + /** Relative path below `dir` (absolute paths, '.', '..' and empty segments refused). */ + relativePath: string + /** The request body stream (raw bytes). */ + chunks: AsyncIterable + /** Byte cap; an oversized upload is refused without touching the target. */ + limit: number +} + +/** + * Stream `chunks` into `dir/relativePath` atomically: a uniquely named temp + * sibling receives the bytes, then is renamed over the target. The parent + * directory is created on demand (recursive), so folder uploads work before + * any level exists. The unique temp name keeps concurrent uploads to the same + * target independent (each writes and renames its own file; the last rename + * wins) and never blocks later uploads after a crashed process. + * + * @throws SidebarError with a wire code for containment, shape, and size + * failures; the temp file is always removed on failure. + */ +export async function writeWorkspaceUpload(input: WorkspaceUploadInput): Promise<{ path: string; size: number }> { + const { cwd, dir, relativePath, chunks, limit } = input + const base = requireAbsolute(dir) + if (!isWithin(cwd, base)) { + throw new SidebarError('forbidden', 'upload directory escapes the session workspace', 403) + } + if (relativePath === '' || relativePath.startsWith('/') || relativePath.startsWith('\\')) { + throw new SidebarError('bad-request', 'relativePath must stay below the upload directory', 400) + } + const segments = relativePath.split(/[\\/]/) + if (segments.some(part => part === '' || part === '.' || part === '..')) { + throw new SidebarError('bad-request', 'relativePath must stay below the upload directory', 400) + } + const target = join(base, ...segments) + if (!isWithin(cwd, target) || !isWithin(base, target)) { + throw new SidebarError('forbidden', 'target escapes the session workspace', 403) + } + const tmp = join(dirname(target), `.${basename(target)}.dsh-upload-${randomUUID()}.tmp`) + await mkdir(dirname(target), { recursive: true }) + const stream = createWriteStream(tmp, { flags: 'wx' }) + // Resolves once the stream fully closes; created up front so a stream that + // already closed (successful end, later failure) cannot leave the wait hanging. + const closed = new Promise((resolve) => { stream.once('close', () => resolve()) }) + let size = 0 + let streamError: unknown + // A permanent 'error' listener keeps a failing disk from crashing the host: + // every await below surfaces the failure through the promise chain instead. + stream.on('error', (error) => { streamError = error }) + try { + for await (const chunk of chunks) { + const buffer = Buffer.from(chunk) + size += buffer.length + if (size > limit) throw new SidebarError('too-large', `upload exceeds the ${limit} byte limit`, 413) + if (!stream.write(buffer)) await once(stream, 'drain') + if (streamError !== undefined) throw streamError + } + await new Promise((resolve, reject) => { + stream.end((error?: Error | null) => (error === undefined || error === null ? resolve() : reject(error))) + }) + if (streamError !== undefined) throw streamError + await rename(tmp, target) + const info = await stat(target) + return { path: target, size: info.size } + } catch (error) { + // Wait for the stream to fully close before unlinking (Windows locks open + // files), then remove our own uniquely named temp file. + stream.destroy() + await closed.catch(() => {}) + await rm(tmp, { force: true }).catch(() => {}) + throw error + } +} diff --git a/src/index.ts b/src/index.ts index baeb387..c31c32d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,6 +29,7 @@ import { type SidebarPrefs, } from './config.ts' import { isWithin, parentOf, requireAbsolute, listDirectory, rootLabel } from './fs-tree.ts' +import { writeWorkspaceUpload } from './fs-operations.ts' import { searchFiles } from './fs-search.ts' import { decodeHtmlUrl } from './html-route.ts' import { extractFrameAncestors } from './browser-probe.ts' @@ -622,6 +623,47 @@ export function apply(ctx: Context, config?: SidebarConfig): void { }, }), 'dsh-better-sidebar: /sidebar/api routes') + // ── Raw upload route ─────────────────────────────────────────────────── + // One request writes one file without JSON/base64 inflation. Folder uploads + // send each file with a relativePath, preserving the selected directory + // tree. Bytes stream to a temp sibling and are renamed into place, so a + // failed or oversized upload never leaves a partial file (see + // fs-operations.ts for the containment and shape rules). + ctx.effect(() => ctx.webServer.register({ + kind: 'exact', + path: '/sidebar/upload', + handler: async (req, res) => { + if (!fence(req)) { + writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden' } }) + return + } + if (req.method !== 'POST') { + writeJson(res, 405, { ok: false, error: { code: 'method-error', message: 'method not allowed' } }) + return + } + try { + const url = new URL(req.url ?? '/', 'http://dsh.internal') + const sessionId = url.searchParams.get('sessionId') + const dir = url.searchParams.get('dir') + const relativePath = url.searchParams.get('relativePath') + if (sessionId === null || dir === null || relativePath === null || relativePath.trim() === '') { + throw new SidebarError('bad-request', 'sessionId, dir, and relativePath are required') + } + const cwd = sessionCwdOf(ctx, sessionId, url.searchParams.get('cwd') ?? undefined) + const { path, size } = await writeWorkspaceUpload({ + cwd, + dir, + relativePath, + chunks: req, + limit: resolved.uploadLimit, + }) + writeOk(res, { path, size }) + } catch (error) { + writeError(res, error) + } + }, + }), 'dsh-better-sidebar: /sidebar/upload route') + // ── Lazy chunk route (client bundle splits) ───────────────────────────── // Serves the client half's split bundles (lib/client-.js) so the // heavy preview/terminal libraries load on first use, not at page start diff --git a/src/wire.ts b/src/wire.ts index 0ab939c..b49d1e6 100644 --- a/src/wire.ts +++ b/src/wire.ts @@ -12,6 +12,7 @@ export type SidebarErrorCode = | 'not-found' | 'forbidden' | 'method-error' + | 'too-large' | 'fs-error' | 'git-error' | 'pty-error' diff --git a/tests/file-tree-drop.spec.tsx b/tests/file-tree-drop.spec.tsx new file mode 100644 index 0000000..f45dffb --- /dev/null +++ b/tests/file-tree-drop.spec.tsx @@ -0,0 +1,182 @@ +/** + * FileTree drag-drop surface: an OS file drag over the tree shows the + * portaled drop zone (document.body, above DSH's z-1000 page mask) with the + * hint pill docked at the bottom; row hovers retarget the drop directory. + * The enter/leave DEPTH COUNTER is the regression guard for the mask + * flicker: child-element transitions must never hide the zone mid-drag — + * only the drag actually leaving the tree does. Drops report through + * onUploadRequest (file rows target their parent directory) and reset all + * drag state. + */ +// @vitest-environment jsdom +import { beforeAll, beforeEach, afterEach, describe, expect, it, vi } from 'vitest' +import { createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { act } from 'react-dom/test-utils' +import { FileTree } from '../src/client/FileTree.tsx' +import type { UploadItem } from '../src/client/upload.ts' + +// The act() environment flag (React 18.2 reads it before flushing effects). +;(globalThis as Record).IS_REACT_ACT_ENVIRONMENT = true + +// vitest 4.1.11+ follows the OS locale; pin en-US so hint assertions are +// deterministic regardless of the developer machine. +beforeAll(() => { + Object.defineProperty(window.navigator, 'language', { value: 'en-US', configurable: true }) +}) + +vi.mock('../src/client/api.ts', () => ({ + api: { + fsTree: async () => ({ + entries: [ + { name: 'src', path: '/tmp/src', isDir: true }, + { name: 'a.ts', path: '/tmp/a.ts', isDir: false }, + ], + }), + }, + downloadUrl: () => '/sidebar/file', +})) + +interface Harness { + container: HTMLDivElement + body: HTMLElement + uploads: { dir: string; items: UploadItem[] }[] + unmount: () => void +} + +/** Mount the tree rooted at /tmp (one level loaded: dir 'src', file 'a.ts'). */ +async function mountTree(busy = false): Promise { + const container = document.createElement('div') + document.body.append(container) + const root: Root = createRoot(container) + const uploads: { dir: string; items: UploadItem[] }[] = [] + await act(async () => { + root.render(createElement(FileTree, { + sessionId: 's1', + cwd: '/tmp', + expanded: [], + onToggle: () => {}, + onOpenFile: () => {}, + onReferenceFile: () => {}, + refreshTick: 0, + onUploadRequest: (dir, items) => { uploads.push({ dir, items }) }, + busy, + })) + }) + return { + container, + body: container.firstElementChild as HTMLElement, + uploads, + unmount: () => { act(() => { root.unmount() }) }, + } +} + +/** A bubbling drag event carrying a stub dataTransfer (jsdom has no DragEvent). */ +function dragEvent(type: string): Event { + const event = new Event(type, { bubbles: true, cancelable: true }) + Object.defineProperty(event, 'dataTransfer', { + // No entry API on the items, so drops fall back to the flat file list. + value: { + types: ['Files'], + items: [], + files: [new File(['x'], 'dropped.txt')], + dropEffect: '', + }, + }) + return event +} + +/** Dispatch inside act() so React flushes the state update. */ +function fire(target: Element, type: string): Event { + const event = dragEvent(type) + act(() => { target.dispatchEvent(event) }) + return event +} + +const dropZone = (): HTMLElement | null => document.body.querySelector('[class*="uploadDropZone"]') + +/** The row whose label text matches (rows are role="button" divs). */ +function rowByName(container: HTMLElement, name: string): HTMLElement { + const row = [...container.querySelectorAll('[role="button"]')] + .find(el => el.querySelector('[class*="explorerName"]')?.textContent === name) + if (row === undefined) throw new Error(`row not found: ${name}`) + return row +} + +describe('FileTree drag-drop surface', () => { + let harness: Harness + beforeEach(async () => { + harness = await mountTree() + }) + afterEach(() => { + harness.unmount() + document.body.innerHTML = '' + }) + + it('shows the portaled drop zone with the generic hint while a file drag hovers the tree', () => { + expect(dropZone()).toBeNull() + fire(harness.body, 'dragenter') + const zone = dropZone() + expect(zone).not.toBeNull() + expect(zone!.parentElement).toBe(document.body) + expect(zone!.textContent).toContain('Drop files/folders here to upload') + // jsdom's rects are all-zero, so the left space is 0px wide and the + // chat-area hint is skipped (its floor is 200px). + expect(document.body.querySelector('[class*="uploadDropChatHint"]')).toBeNull() + }) + + it('keeps the zone through child-element transitions and hides it only when the drag leaves the tree', () => { + const file = rowByName(harness.container, 'a.ts') + fire(harness.body, 'dragenter') // depth 1 + fire(file, 'dragenter') // bubbles: depth 2 (entered the row) + fire(file, 'dragleave') // bubbles: depth 1 — a child transition must not hide the zone + expect(dropZone()).not.toBeNull() + fire(harness.body, 'dragleave') // depth 0: the drag left the tree + expect(dropZone()).toBeNull() + }) + + it('retargets the pill to the hovered directory row and back to the root over gaps', () => { + const dir = rowByName(harness.container, 'src') + fire(harness.body, 'dragenter') + fire(dir, 'dragover') + expect(dropZone()!.textContent).toContain('Upload into /tmp/src') + expect(dir.className).toContain('explorerRowDropTarget') + fire(harness.body, 'dragover') // a non-row region: back to the workspace root + expect(dropZone()!.textContent).toContain('Drop files/folders here to upload') + expect(dir.className).not.toContain('explorerRowDropTarget') + }) + + it('drops onto a file row upload into its parent directory and reset the drag state', async () => { + const file = rowByName(harness.container, 'a.ts') + fire(harness.body, 'dragenter') + const drop = fire(file, 'drop') + // The payload collection is async (entry traversal); flush the then. + await act(async () => {}) + expect(drop.defaultPrevented).toBe(true) + expect(harness.uploads).toHaveLength(1) + expect(harness.uploads[0]!.dir).toBe('/tmp') + expect(harness.uploads[0]!.items.map(item => item.relativePath)).toEqual(['dropped.txt']) + expect(dropZone()).toBeNull() + }) + + it('drops onto the tree body upload into the workspace root', async () => { + fire(harness.body, 'dragenter') + fire(harness.body, 'drop') + await act(async () => {}) + expect(harness.uploads).toHaveLength(1) + expect(harness.uploads[0]!.dir).toBe('/tmp') + }) + + it('suppresses the affordance while an upload is in flight', async () => { + harness.unmount() + document.body.innerHTML = '' + const busyHarness = await mountTree(true) + fire(busyHarness.body, 'dragenter') + fire(busyHarness.body, 'dragover') + expect(dropZone()).toBeNull() + fire(busyHarness.body, 'drop') + await act(async () => {}) + expect(busyHarness.uploads).toHaveLength(0) + busyHarness.unmount() + }) +}) diff --git a/tests/fs-operations.spec.ts b/tests/fs-operations.spec.ts new file mode 100644 index 0000000..63c3896 --- /dev/null +++ b/tests/fs-operations.spec.ts @@ -0,0 +1,135 @@ +import { afterAll, describe, expect, it } from 'vitest' +import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { writeWorkspaceUpload } from '../src/fs-operations.ts' + +/** The test workspace root (each suite gets its own temp tree). */ +const root = mkdtempSync(join(tmpdir(), 'dsh-sidebar-upload-')) + +afterAll(() => { + rmSync(root, { recursive: true, force: true }) +}) + +/** Names of leftover temp files under `dir` (must be empty after every run). */ +function tmpLeftovers(dir: string): string[] { + return readdirSync(dir).filter(name => name.includes('.dsh-upload-')) +} + +/** Turn a string into the async-iterable chunk shape the route receives. */ +function chunksOf(text: string): AsyncIterable { + return { + async *[Symbol.asyncIterator]() { + // Split on two-byte boundaries so UTF-8 multi-byte sequences cross + // chunk edges (streaming must reassemble them as raw bytes, not text). + for (let i = 0; i < text.length; i += 2) yield text.slice(i, i + 2) + }, + } +} + +describe('writeWorkspaceUpload', () => { + it('writes a file under the upload directory and returns its size', async () => { + const { path, size } = await writeWorkspaceUpload({ + cwd: root, + dir: root, + relativePath: 'a.txt', + chunks: chunksOf('hello 世界'), + limit: 1024, + }) + expect(path).toBe(join(root, 'a.txt')) + expect(size).toBe(Buffer.byteLength('hello 世界')) + expect(readFileSync(path, 'utf8')).toBe('hello 世界') + }) + + it('creates nested directories on demand (folder uploads)', async () => { + const { path } = await writeWorkspaceUpload({ + cwd: root, + dir: root, + relativePath: 'docs/nested/deep.txt', + chunks: chunksOf('x'), + limit: 1024, + }) + expect(existsSync(path)).toBe(true) + }) + + it('resolves relativePaths against the chosen directory', async () => { + const { path } = await writeWorkspaceUpload({ + cwd: root, + dir: join(root, 'docs'), + relativePath: 'b.txt', + chunks: chunksOf('y'), + limit: 1024, + }) + expect(path).toBe(join(root, 'docs', 'b.txt')) + }) + + it('refuses traversal, empty segments, and absolute relativePaths', async () => { + await expect(writeWorkspaceUpload({ + cwd: root, dir: root, relativePath: '../evil.txt', chunks: chunksOf('x'), limit: 1024, + })).rejects.toMatchObject({ code: 'bad-request' }) + await expect(writeWorkspaceUpload({ + cwd: root, dir: root, relativePath: './a.txt', chunks: chunksOf('x'), limit: 1024, + })).rejects.toMatchObject({ code: 'bad-request' }) + await expect(writeWorkspaceUpload({ + cwd: root, dir: root, relativePath: 'a/../b.txt', chunks: chunksOf('x'), limit: 1024, + })).rejects.toMatchObject({ code: 'bad-request' }) + await expect(writeWorkspaceUpload({ + cwd: root, dir: root, relativePath: '//', chunks: chunksOf('x'), limit: 1024, + })).rejects.toMatchObject({ code: 'bad-request' }) + // Empty segments are refused, not silently collapsed. + await expect(writeWorkspaceUpload({ + cwd: root, dir: root, relativePath: 'a//b.txt', chunks: chunksOf('x'), limit: 1024, + })).rejects.toMatchObject({ code: 'bad-request' }) + await expect(writeWorkspaceUpload({ + cwd: root, dir: root, relativePath: 'a/b/', chunks: chunksOf('x'), limit: 1024, + })).rejects.toMatchObject({ code: 'bad-request' }) + // Absolute paths (POSIX and Windows separators) are refused, not re-anchored. + await expect(writeWorkspaceUpload({ + cwd: root, dir: root, relativePath: '/x.txt', chunks: chunksOf('x'), limit: 1024, + })).rejects.toMatchObject({ code: 'bad-request' }) + await expect(writeWorkspaceUpload({ + cwd: root, dir: root, relativePath: '\\x.txt', chunks: chunksOf('x'), limit: 1024, + })).rejects.toMatchObject({ code: 'bad-request' }) + }) + + it('refuses an upload directory outside the workspace', async () => { + const outside = mkdtempSync(join(tmpdir(), 'dsh-sidebar-upload-outside-')) + try { + await expect(writeWorkspaceUpload({ + cwd: root, dir: outside, relativePath: 'x.txt', chunks: chunksOf('x'), limit: 1024, + })).rejects.toMatchObject({ code: 'forbidden' }) + } finally { + rmSync(outside, { recursive: true, force: true }) + } + }) + + it('refuses oversized uploads without leaving a target or temp file', async () => { + const target = join(root, 'big.bin') + await expect(writeWorkspaceUpload({ + cwd: root, dir: root, relativePath: 'big.bin', chunks: chunksOf('1234567890'), limit: 4, + })).rejects.toMatchObject({ code: 'too-large' }) + expect(existsSync(target)).toBe(false) + expect(tmpLeftovers(root)).toEqual([]) + }) + + it('keeps concurrent uploads to the same target independent', async () => { + const target = join(root, 'race.txt') + await Promise.all([ + writeWorkspaceUpload({ cwd: root, dir: root, relativePath: 'race.txt', chunks: chunksOf('first'), limit: 1024 }), + writeWorkspaceUpload({ cwd: root, dir: root, relativePath: 'race.txt', chunks: chunksOf('second'), limit: 1024 }), + ]) + // Both renames succeed (unique temp names, no EEXIST cross-talk); the last + // rename wins and the losers leave nothing behind. + expect(['first', 'second']).toContain(readFileSync(target, 'utf8')) + expect(tmpLeftovers(root)).toEqual([]) + }) + + it('does not overwrite an existing file on a failed (oversized) retry', async () => { + const target = join(root, 'keep.txt') + await writeWorkspaceUpload({ cwd: root, dir: root, relativePath: 'keep.txt', chunks: chunksOf('original'), limit: 1024 }) + await expect(writeWorkspaceUpload({ + cwd: root, dir: root, relativePath: 'keep.txt', chunks: chunksOf('0123456789'), limit: 2, + })).rejects.toMatchObject({ code: 'too-large' }) + expect(readFileSync(target, 'utf8')).toBe('original') + }) +}) diff --git a/tests/smoke.spec.ts b/tests/smoke.spec.ts index 93eb438..17ec67e 100644 --- a/tests/smoke.spec.ts +++ b/tests/smoke.spec.ts @@ -88,7 +88,13 @@ describe('host plugin smoke', () => { get: () => undefined, } apply(ctx as never) - expect(routes.map(route => route.path)).toEqual(['/sidebar/api', '/sidebar/bundle', '/sidebar/file', '/sidebar/html']) + expect(routes.map(route => route.path)).toEqual([ + '/sidebar/api', + '/sidebar/upload', + '/sidebar/bundle', + '/sidebar/file', + '/sidebar/html', + ]) expect(upgrades.map(route => route.path)).toEqual(['/sidebar/ws/terminal', '/sidebar/ws/agent-terminals']) // Teardown runs without throwing (pty manager has nothing open). for (const cleanup of effects) cleanup() diff --git a/tests/upload.spec.ts b/tests/upload.spec.ts new file mode 100644 index 0000000..e03e00d --- /dev/null +++ b/tests/upload.spec.ts @@ -0,0 +1,208 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { api, SidebarApiError, type SessionScope } from '../src/client/api.ts' +import { + MAX_UPLOAD_BYTES, summarizeResults, uploadHintText, uploadItemsFromDrop, uploadItemsFromFiles, + uploadToDir, type UploadItem, +} from '../src/client/upload.ts' + +/** Fake t(): returns the key (and params) so assertions see which copy fired. */ +const t: Parameters[1] = (key, params) => + params === undefined ? key : `${key}:${JSON.stringify(params)}` + +const scope: SessionScope = { sessionId: 's', cwd: '/ws' } + +/** A File with an optional webkitRelativePath (folder entries). */ +function fileOf(name: string, rel?: string): File { + const file = new File(['x'], name) + if (rel !== undefined) Object.defineProperty(file, 'webkitRelativePath', { value: rel }) + return file +} + +const items = (names: string[]): UploadItem[] => + names.map(name => ({ file: fileOf(name), relativePath: name })) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('uploadItemsFromFiles', () => { + it('keeps plain file names as their relative path', () => { + expect(uploadItemsFromFiles([fileOf('a.txt')])).toEqual([{ file: expect.any(File), relativePath: 'a.txt' }]) + }) + + it('keeps the directory structure of folder entries', () => { + expect(uploadItemsFromFiles([fileOf('a.txt', 'docs/nested/a.txt')])) + .toEqual([{ file: expect.any(File), relativePath: 'docs/nested/a.txt' }]) + }) + + it('rejects absolute paths (POSIX, Windows separators, drive letters)', () => { + expect(uploadItemsFromFiles([ + fileOf('a', '/abs.txt'), + fileOf('a', '\\abs.txt'), + fileOf('a', 'C:/abs.txt'), + ])).toEqual([]) + }) + + it('rejects traversal and empty segments', () => { + expect(uploadItemsFromFiles([ + fileOf('a', 'a/../b.txt'), + fileOf('a', './a.txt'), + fileOf('a', 'a//b.txt'), + ])).toEqual([]) + }) +}) + +describe('uploadItemsFromDrop', () => { + /** A DataTransfer stub: flat file list, no entry API. */ + const dataOf = (files: File[], items: unknown[] = []): DataTransfer => + ({ files, items }) as unknown as DataTransfer + /** A dropped file entry stub (webkitGetAsEntry world). */ + const fileEntryOf = (name: string): FileSystemEntry => + ({ + isFile: true, isDirectory: false, name, + file: (ok: (file: File) => void) => { ok(new File(['x'], name)) }, + }) as unknown as FileSystemEntry + /** A dropped directory entry stub: readEntries serves one batch, then empty. */ + const dirEntryOf = (name: string, children: FileSystemEntry[]): FileSystemEntry => + ({ + isFile: false, isDirectory: true, name, + createReader: () => { + let drained = false + return { + readEntries: (ok: (entries: FileSystemEntry[]) => void) => { + ok(drained ? [] : children) + drained = true + }, + } + }, + }) as unknown as FileSystemEntry + const itemOf = (entry: FileSystemEntry): unknown => ({ kind: 'file', webkitGetAsEntry: () => entry }) + + it('falls back to the flat file list when the entry API is unavailable', async () => { + const data = dataOf([fileOf('a.txt'), fileOf('b.txt', 'dir/b.txt')]) + const items = await uploadItemsFromDrop(data) + expect(items.map(item => item.relativePath)).toEqual(['a.txt', 'dir/b.txt']) + }) + + it('returns nothing for an empty drop', async () => { + expect(await uploadItemsFromDrop(undefined)).toEqual([]) + }) + + it('traverses dropped directories, keeping every nested relative path', async () => { + const tree = dirEntryOf('docs', [ + fileEntryOf('a.txt'), + dirEntryOf('nested', [fileEntryOf('b.txt')]), + ]) + const data = dataOf([], [itemOf(fileEntryOf('top.txt')), itemOf(tree)]) + const items = await uploadItemsFromDrop(data) + expect(items.map(item => item.relativePath)).toEqual(['top.txt', 'docs/a.txt', 'docs/nested/b.txt']) + }) + + it('skips an unreadable entry instead of failing the whole drop', async () => { + const broken = { + isFile: true, isDirectory: false, name: 'bad.txt', + file: (_ok: unknown, fail: (error: Error) => void) => { fail(new Error('denied')) }, + } as unknown as FileSystemEntry + const data = dataOf([], [itemOf(broken), itemOf(fileEntryOf('good.txt'))]) + const items = await uploadItemsFromDrop(data) + expect(items.map(item => item.relativePath)).toEqual(['good.txt']) + }) + + it('sanitizes entry-derived paths (absolute and traversal segments rejected)', async () => { + const data = dataOf([], [itemOf(dirEntryOf('..', [fileEntryOf('evil.txt')]))]) + expect(await uploadItemsFromDrop(data)).toEqual([]) + }) +}) + +describe('uploadHintText', () => { + it('shows the target directory while no file is in flight', () => { + expect(uploadHintText(0, 3, '', '/ws', t)).toBe('uploadingTo:{"dir":"/ws"}') + }) + + it('shows per-file progress once a file is being uploaded', () => { + expect(uploadHintText(1, 3, 'docs/a.txt', '/ws', t)).toBe('uploadProgress:{"done":1,"total":3,"name":"docs/a.txt"}') + }) +}) + +describe('summarizeResults', () => { + it('reports the uploaded count when everything succeeded', () => { + expect(summarizeResults([ + { relativePath: 'a.txt', ok: true, path: '/ws/a.txt' }, + { relativePath: 'b.txt', ok: true, path: '/ws/b.txt' }, + ], t)).toBe('uploadDone:{"count":2}') + }) + + it('localizes the too-large wire code instead of showing the raw message', () => { + expect(summarizeResults([ + { relativePath: 'big.bin', ok: false, code: 'too-large', error: 'upload exceeds the 134217728 byte limit' }, + ], t)).toBe('uploadFailed:{"error":"uploadTooLarge"}') + }) + + it('passes through other failures raw, falling back to the unknown copy', () => { + expect(summarizeResults([ + { relativePath: 'a.txt', ok: false, error: 'boom' }, + ], t)).toBe('uploadFailed:{"error":"boom"}') + expect(summarizeResults([ + { relativePath: 'a.txt', ok: false, code: 'internal' }, + ], t)).toBe('uploadFailed:{"error":"uploadFailedUnknown"}') + }) +}) + +describe('uploadToDir', () => { + it('uploads every item sequentially and reports progress', async () => { + const spy = vi.spyOn(api, 'uploadFile') + .mockResolvedValueOnce({ path: '/ws/a.txt', size: 1 }) + .mockResolvedValueOnce({ path: '/ws/b.txt', size: 1 }) + const progress: Array<[number, number, string]> = [] + const results = await uploadToDir(scope, '/ws', items(['a.txt', 'b.txt']), (done, total, current) => { + progress.push([done, total, current]) + }) + expect(results).toEqual([ + { relativePath: 'a.txt', ok: true, path: '/ws/a.txt' }, + { relativePath: 'b.txt', ok: true, path: '/ws/b.txt' }, + ]) + expect(spy).toHaveBeenCalledTimes(2) + expect(progress).toEqual([ + [0, 2, 'a.txt'], + [1, 2, 'b.txt'], + [2, 2, ''], + ]) + }) + + it('pre-checks the client cap without calling the route', async () => { + const spy = vi.spyOn(api, 'uploadFile') + const big = fileOf('big.bin') + Object.defineProperty(big, 'size', { value: MAX_UPLOAD_BYTES + 1 }) + const results = await uploadToDir(scope, '/ws', [{ file: big, relativePath: 'big.bin' }]) + expect(results).toEqual([{ relativePath: 'big.bin', ok: false, code: 'too-large' }]) + expect(spy).not.toHaveBeenCalled() + }) + + it('keeps the wire error code from the route', async () => { + vi.spyOn(api, 'uploadFile').mockRejectedValueOnce(new SidebarApiError('forbidden', 'target escapes the session workspace')) + const results = await uploadToDir(scope, '/ws', items(['a.txt'])) + expect(results).toEqual([{ + relativePath: 'a.txt', ok: false, code: 'forbidden', error: 'target escapes the session workspace', + }]) + }) + + it('an already-aborted signal uploads nothing', async () => { + const spy = vi.spyOn(api, 'uploadFile') + const controller = new AbortController() + controller.abort() + const results = await uploadToDir(scope, '/ws', items(['a.txt', 'b.txt']), undefined, controller.signal) + expect(results).toEqual([]) + expect(spy).not.toHaveBeenCalled() + }) + + it('an abort mid-flight keeps completed files and stops the queue', async () => { + const spy = vi.spyOn(api, 'uploadFile') + .mockResolvedValueOnce({ path: '/ws/a.txt', size: 1 }) + .mockRejectedValueOnce(new DOMException('aborted', 'AbortError')) + const controller = new AbortController() + const results = await uploadToDir(scope, '/ws', items(['a.txt', 'b.txt', 'c.txt']), undefined, controller.signal) + expect(results).toEqual([{ relativePath: 'a.txt', ok: true, path: '/ws/a.txt' }]) + // a resolved, b was aborted mid-flight, c never started. + expect(spy).toHaveBeenCalledTimes(2) + }) +})