{ 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: {
>
+ { fileInputRef.current?.click() }}
+ >
+
+
+ { folderInputRef.current?.click() }}
+ >
+
+
+ {
+ 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)}
+
+ {t('cancel')}
+
+
+
+ )
+}
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)
+ })
+})