feat(explorer): upload files into the workspace from the file tree (#239)
* feat(explorer): upload files into the workspace from the file tree
- TreePanel header: upload-files / upload-folder buttons (folder picker keeps
the tree via webkitdirectory), with a one-line progress/result hint
- FileTree: drop files/folders onto the tree body (workspace root) or onto a
directory row (that directory); directory context menu gains 上传到此处
- api.ts: uploadFile posts base64 to /sidebar/upload (host writes it through
fs + shell decode; route provided for now by the local demo plugin)
- upload.ts: bytes->base64 (global-btoa-independent), FileList/DataTransfer
collection, sequential upload with per-file results; ~48MB per-file cap
- locales: zh/en strings; icons: IconUploadOutline16
* fix(explorer): reload tree after upload and auto-dismiss the status hint
The drop/context-menu upload path cleared the level cache but never
refetched (the refreshTick bump belongs to the button path), so the
visible tree emptied and only the persistent '已上传 N 个文件' hint
remained — the tree only came back after reopening the tab.
- FileTree.uploadInto now reloads root + expanded levels after clearing,
matching the refresh-tick path
- success status auto-dismisses after 3.5s (failures persist until the
next action) in both TreePanel and FileTree
* feat(host): add the /sidebar/upload route (raw bytes, workspace-safe)
The file tree's upload UI needs a host route; until now it pointed at a
locally-injected demo route. This is the real implementation:
- src/index.ts: register POST /sidebar/upload (exact route, same fence and
session-cwd resolution as every /sidebar route). Query params sessionId,
dir, relativePath; the raw request body is streamed into the workspace.
- src/fs-operations.ts: writeWorkspaceUpload streams chunks to a temp
sibling and renames into place, so a failed or oversized upload never
leaves a partial file. Upload dir must sit inside the session cwd, the
relativePath is sanitized (no '.', '..', empty or absolute segments) and
the target must stay inside both.
- src/config.ts: uploadLimit (default 128MiB; oversized -> 413 too-large,
a new wire code in src/wire.ts).
- client: send the File straight into the POST body (no base64 inflation)
and cap at the same 128MiB (upload.ts, api.ts).
Tests: tests/fs-operations.spec.ts covers write, nested dirs, traversal /
containment refusal, the size cap, and no-partial-file on failure.
(pnpm test: only pre-existing node-pty/git sandbox failures, identical on
clean main; typecheck and build pass.)
* fix(host): harden upload containment, temp-file atomicity and stream failure cleanup
- relativePath: absolute paths and empty segments are refused (400), not
silently collapsed or re-anchored (the spec's 'reject' semantics)
- temp file: uniquely named per request (randomUUID) so concurrent uploads
to the same target stay independent; crashed-process leftovers never
block later uploads (no pid reuse)
- write stream: permanent 'error' listener + error checks after every await
so a failing disk rejects the operation instead of crashing the host or
hanging the drain wait; the temp file is removed after the stream fully
closes (Windows file locks), never leaving a partial file
- docstring now states the containment is lexical (symlink trust model)
* fix(explorer): thread upload error codes and cancellation through the client
- api.uploadFile/fetchUpload accept an AbortSignal (long-op convention);
an aborted request rejects with the DOMException as-is instead of being
wrapped into a 'network' error
- UploadResult carries the wire error code; the client pre-check now emits
the same 'too-large' code as the host, and summarizeResults localizes
'too-large' instead of showing the raw English 413 message
- uploadToDir stops the queue on abort: completed files stay, in-flight
request is cancelled, nothing after is started
- uploadTooLarge copy no longer hardcodes a byte number (the host limit is
configurable); MAX_UPLOAD_BYTES is documented as a mirror, not authority
- relativePathOf rejects absolute paths (isAbsolutePath) instead of
stripping leading slashes, matching the host's 400 semantics
- progress text shared via uploadHintText for the hint and the overlay;
success-hint duration is the exported UPLOAD_HINT_MS
* feat(explorer): full-window upload progress with cancel and VSCode-style drop targets
- UploadOverlay: blurred scrim + card over the file window (Modal token
recipe: bg-mask-1 + mask blur, bg-layer-2, border-inverted, shadow-lv3),
file-level progress bar, cancel button and Esc; scrim clicks do not abort
- TreePanel owns the upload session: header pickers, tree drag-drop and the
context-menu picker funnel through startUpload; one session at a time
(buttons disabled while busy, drops guarded); on settle the tree refreshes
and the status hint shows success (3.5s, transient), failure or cancel
(error color, stays)
- FileTree: drops on directory rows target that directory, drops on FILE
rows target their parent (VSCode semantics, no more accidental root
uploads), root row and body drop into the workspace root; drag-over
highlights the target row (explorerRowDropTarget) plus the body outline
and a hint pill (uploadDropHint / uploadTo) — raw-color inline outline
replaced with the repo's dashed-accent classes
- upload status hint uses editorSearchHint + editorError like the search
status; duplicated upload flow removed from FileTree (single caller)
* test: register the upload route in the smoke inventory
The new /sidebar/upload route joined the fenced route list, so the mount
assertion must expect it (the PR's CI gate was red).
* chore: bump to 0.14.1
Rebased onto main and cut a release version for the upload feature:
package.json / dsh.plugin.json / SIDEBAR_SERVICE_VERSION stay in sync
(test-guarded), README changelogs get the v0.14.1 entry.
* feat(explorer): keep the drop hint above DSH's page overlay and suppress its intake over the tree
DSH's whole-page image-drop intake (ui-conversation InputBar) listens on
document in the bubble phase: any 'Files' drag entering the page shows its
full-screen overlay (z-1000) and a drop anywhere adds images to the chat.
Our tree handled dragover/drop but not dragenter, so the overlay flashed
over the file window and drops outside the tree rows could leak to the
chat.
- TreePanel root now swallows dragenter/dragover/drop (preventDefault +
stopPropagation) before the events reach document: the file window is an
upload target, not an image target. Row/body handlers still fire first
(bubble order) for their own targets; the root is the backstop for the
search row, gaps and the search-results view.
- The drop hint pill is portaled to document.body at z-index 1001 — above
DSH's overlay (z-1000) — so when a drag enters from outside the tree and
DSH's mask lingers until dragend, our 'Drop files/folders here' /
'Upload into {dir}' hint stays readable on top (transient decoration,
pointer-events none; the drop still lands on the tree).
* feat(explorer): zone-split the drop overlays and fix the mask flicker over the sidebar
Sidebar.tsx's panel host now swallows the whole file-drag quartet
(enter/over/leave/drop, gated on the 'Files' type): DSH's document-level
intake keeps an enter/leave depth counter, and the leaves that escaped
the tree unbalanced it — that was the native full-screen mask flickering
over the sidebar. The region is now a black hole to that listener, so the
conversation column keeps DSH's native overlay and image intake while the
sidebar presents its own upload surface (drops over it never add images
to the chat).
The tree's drop affordance is now a pointer-inert frame portaled to
document.body at z-1001, anchored to the explorer body's viewport rect:
it replaces DSH's mask over the sidebar instead of competing with it, and
the hint pill docks at the bottom edge so the rows — the actual drop
targets — stay visible and aimable instead of sitting behind a centered
pill. Inside the tree, enter/leave depth counting (InputBar's own
pattern) replaces the relatedTarget check, which is unreliable for drag
events and flickered the row highlight on icon/label transitions; the row
target is now set authoritatively by the continuously-firing dragover.
dragleave handlers on rows are gone.
Polish: capsule hint pill with the upload icon and a float shadow, the
progress overlay fades in (the repo's dsh-row-in motion), its title gains
the icon, and progress numbers render tabular. tests/file-tree-drop.spec
pins the depth-counter behavior (child transitions keep the zone) and the
parent-directory drop semantics.
* feat(explorer): dim the whole viewport behind the tree's drop zone
The bottom-docked pill sat below the fold of attention; the zone frame
alone also failed to teach the left/right split — a drag over the sidebar
gave no hint that the conversation column still accepts drops (into the
chat, natively). The zone frame now carries the full-viewport dim itself
(a 200vmax box-shadow spread in DSH's own mask-drop token): everything
outside the tree's rect fades, the tree stays clear and aimable, and a
muted invitation centered in the space left of the zone (skipped under
200px) names the chat as the image target. The pill moves to the TOP edge
of the zone, right under the search row — the first thing the eye meets.
* feat(explorer): colorful drop heroes for both zones
The overlay is this flow's one brand moment, so it gets the color the
rest of the UI never does: hand-drawn SVG illustrations in the palette of
DSH's own native drop art (#3964FE/#679EFE/#9CE5ED) so the two zones read
as one family. The upload zone docks a hero at its top edge — a tray
illustration over the hint pill, stacked so the rows below stay aimable;
the chat zone's invitation becomes a tilted photo-cards card over its
label.
* feat(explorer): traverse dropped folders instead of reading dataTransfer.files
A dropped OS folder never surfaces in dataTransfer.files (that flat list
carries files only), so folder drops were silent no-ops while the UI
advertised 'drop files/folders'. uploadItemsFromDrop now captures entry
handles via webkitGetAsEntry — synchronously, since the dataTransfer enters
protected mode once the drop dispatch ends, while the captured handles stay
readable — then drains each directory's readEntries batches recursively,
prefixing every descendant's relative path. Entry-derived paths go through
the same sanitizer as picker paths; an entry that fails to read is skipped
rather than failing the whole drop, and the flat file list remains the
fallback where the entry API is unavailable. The FileTree drop handlers
ride the resulting promise.
---------
Co-authored-by: dsh local <dsh@local>
Co-authored-by: Menghuan1918 <menghuan2003@outlook.com>
This commit is contained in:
co-authored by
dsh local
Menghuan1918
parent
5cc1a4b52e
commit
bb2456bea9
@@ -42,7 +42,16 @@
|
||||
<a href="https://github.com/user-attachments/assets/d4385b7e-aab4-425d-a5c4-2da5da81a34e"><img width="33%" alt="添加插件截图" src="https://github.com/user-attachments/assets/d4385b7e-aab4-425d-a5c4-2da5da81a34e" /></a>
|
||||
</div>
|
||||
|
||||
### 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 以来的全部更改:
|
||||
|
||||
|
||||
+10
-1
@@ -41,7 +41,16 @@
|
||||
<a href="https://github.com/user-attachments/assets/d4385b7e-aab4-425d-a5c4-2da5da81a34e"><img width="45%" alt="Add Plugins screenshot" src="https://github.com/user-attachments/assets/d4385b7e-aab4-425d-a5c4-2da5da81a34e" /></a>
|
||||
</div>
|
||||
|
||||
### 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:
|
||||
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
+235
-9
@@ -11,19 +11,27 @@
|
||||
* right (appends `@<relative path>` 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 = () => (
|
||||
<svg width="64" height="56" viewBox="0 0 64 56" fill="none" aria-hidden="true">
|
||||
<path d="M8 36h48v6a10 10 0 0 1-10 10H18A10 10 0 0 1 8 42v-6z" fill="#9CE5ED" />
|
||||
<path d="M32 4v26" stroke="#3964FE" strokeWidth="5" strokeLinecap="round" />
|
||||
<path d="M19 21l13 13 13-13" stroke="#3964FE" strokeWidth="5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** The chat zone's art: a tilted pair of photo cards (sun over mountains). */
|
||||
const ChatDropIllustration = () => (
|
||||
<svg width="92" height="72" viewBox="0 0 92 72" fill="none" aria-hidden="true">
|
||||
<rect x="8" y="14" width="34" height="34" rx="9" transform="rotate(-14 8 14)" fill="#9CE5ED" />
|
||||
<g transform="rotate(10 42 10)">
|
||||
<rect x="42" y="10" width="38" height="42" rx="9" fill="#3964FE" />
|
||||
<circle cx="56" cy="24" r="4.5" fill="white" />
|
||||
<path d="M47 44l9-11 7 8 5-5 8 10" stroke="white" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
|
||||
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<Record<string, LevelData>>({})
|
||||
const dataRef = useRef(data)
|
||||
/** The row whose path was just copied ("copied" label replaces its button). */
|
||||
const [copiedPath, setCopiedPath] = useState<string | null>(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<string | null>(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<HTMLDivElement>(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<string | undefined>(undefined)
|
||||
const fileInputRef = useRef<HTMLInputElement>(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: {
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={clsx(css.explorerRow, css.explorerDir, entry.hidden && css.explorerHidden)}
|
||||
className={clsx(
|
||||
css.explorerRow, css.explorerDir, entry.hidden && css.explorerHidden,
|
||||
dropTarget === entry.path && css.explorerRowDropTarget,
|
||||
)}
|
||||
style={{ paddingLeft: depth * 22 + 6 }}
|
||||
onClick={() => { 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 ? <IconFolderOpen16 size={14} /> : <IconFolderClose16 size={14} />}
|
||||
@@ -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) }}
|
||||
>
|
||||
<IconCodeOutline16 size={14} />
|
||||
@@ -217,14 +365,23 @@ export function FileTree(props: {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css.explorerBody}>
|
||||
<div
|
||||
ref={bodyRef}
|
||||
className={css.explorerBody}
|
||||
onDragEnter={handleBodyDragEnter}
|
||||
onDragOver={handleBodyDragOver}
|
||||
onDragLeave={handleBodyDragLeave}
|
||||
onDrop={handleBodyDrop}
|
||||
>
|
||||
{root === undefined ? (
|
||||
<div className={css.explorerEmpty}>{t('noSession')}</div>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className={css.explorerRow}
|
||||
className={clsx(css.explorerRow, dropTarget === root && css.explorerRowDropTarget)}
|
||||
style={{ paddingLeft: 6 }}
|
||||
onDragOver={(event) => { handleRowDragOver(event, root) }}
|
||||
onDrop={(event) => { handleDirDrop(event, root) }}
|
||||
onContextMenu={(event) => { openRowMenu(event, root, true) }}
|
||||
>
|
||||
<IconFolderOpen16 size={14} />
|
||||
@@ -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.
|
||||
*/
|
||||
<>
|
||||
<div
|
||||
className={css.uploadDropZone}
|
||||
style={{
|
||||
top: dropRect.top + 2,
|
||||
left: dropRect.left + 2,
|
||||
width: dropRect.width - 4,
|
||||
height: dropRect.height - 4,
|
||||
}}
|
||||
>
|
||||
<div className={css.uploadDropHero}>
|
||||
<UploadDropIllustration />
|
||||
<div className={css.uploadDropZonePill}>
|
||||
<IconUploadOutline16 size={14} />
|
||||
<span className={css.uploadDropZoneText}>
|
||||
{dropTarget !== null ? t('uploadTo', { dir: dropTarget }) : t('uploadDropHint')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* 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 && (
|
||||
<div className={css.uploadDropChatHint} style={{ width: dropRect.left }}>
|
||||
<div className={css.uploadDropChatCard}>
|
||||
<ChatDropIllustration />
|
||||
<span>{t('uploadDropChat')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>,
|
||||
document.body,
|
||||
)}
|
||||
{/*
|
||||
The one shared context menu, positioned at the right-click cursor
|
||||
(portal so the tree's overflow clip cannot crop it).
|
||||
*/}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
style={{ display: 'none' }}
|
||||
onChange={(event) => {
|
||||
const dir = pendingUploadDir.current ?? root
|
||||
pendingUploadDir.current = undefined
|
||||
if (dir !== undefined && !busy) onUploadRequest(dir, uploadItemsFromFiles(event.target.files ?? []))
|
||||
event.target.value = ''
|
||||
}}
|
||||
/>
|
||||
<Menu
|
||||
open={rowMenu !== null}
|
||||
onClose={() => { setRowMenu(null) }}
|
||||
@@ -268,6 +485,10 @@ export function FileTree(props: {
|
||||
...(rowMenu?.isDir === false
|
||||
? [{ id: 'download', label: t('download'), icon: <IconDownloadOutline16 size={14} /> }]
|
||||
: []),
|
||||
// Upload into a directory (incl. the workspace root row).
|
||||
...(rowMenu?.isDir === true
|
||||
? [{ id: 'upload-here', label: t('uploadHere'), icon: <IconUploadOutline16 size={14} /> }]
|
||||
: []),
|
||||
{ id: 'relative', label: t('copyRelative'), icon: <IconCopyOutline16 size={14} /> },
|
||||
{ id: 'absolute', label: t('copyAbsolute'), icon: <IconCopyOutline16 size={14} /> },
|
||||
]}
|
||||
@@ -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,
|
||||
|
||||
+30
-3
@@ -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 (
|
||||
<div data-dsh-panel-host>
|
||||
<div data-dsh-panel-host {...osFileDragShield}>
|
||||
<div className={css.toggleCluster}>
|
||||
{!narrow && (
|
||||
<Tooltip label={t('noSession')} side="bottom" delayMs={500}>
|
||||
@@ -949,7 +976,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
)
|
||||
|
||||
return (
|
||||
<div data-dsh-panel-host>
|
||||
<div data-dsh-panel-host {...osFileDragShield}>
|
||||
{/*
|
||||
The persistent toggle cluster at the top-right corner: the bottom
|
||||
panel's button (bottom glyph) LEFT of the right panel's (side glyph).
|
||||
|
||||
+141
-2
@@ -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<string | null>(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<UploadSession | null>(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<HTMLInputElement>(null)
|
||||
const folderInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>
|
||||
|
||||
const needle = query.trim()
|
||||
useEffect(() => {
|
||||
@@ -61,6 +142,8 @@ export function TreePanel(props: {
|
||||
}
|
||||
}, [sessionId, cwd, needle])
|
||||
|
||||
const busy = upload !== null
|
||||
|
||||
return (
|
||||
<div className={clsx(css.editorTreePanel, full === true && css.editorTreePanelFull)}>
|
||||
<div className={css.editorTreeSearch}>
|
||||
@@ -80,7 +163,51 @@ export function TreePanel(props: {
|
||||
>
|
||||
<IconRefreshOutline16 size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={t('uploadFiles')}
|
||||
title={t('uploadFiles')}
|
||||
disabled={busy}
|
||||
onClick={() => { fileInputRef.current?.click() }}
|
||||
>
|
||||
<IconUploadOutline16 size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={t('uploadFolder')}
|
||||
title={t('uploadFolder')}
|
||||
disabled={busy}
|
||||
onClick={() => { folderInputRef.current?.click() }}
|
||||
>
|
||||
<IconFolderOpen16 size={14} />
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
style={{ display: 'none' }}
|
||||
onChange={(event) => {
|
||||
if (cwd !== undefined) startUpload(cwd, uploadItemsFromFiles(event.target.files ?? []))
|
||||
event.target.value = ''
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
ref={folderInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
{...folderInputProps}
|
||||
style={{ display: 'none' }}
|
||||
onChange={(event) => {
|
||||
if (cwd !== undefined) startUpload(cwd, uploadItemsFromFiles(event.target.files ?? []))
|
||||
event.target.value = ''
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{uploadStatus !== '' && (
|
||||
<div className={clsx(css.editorSearchHint, uploadFailed && css.editorError)} title={uploadStatus}>{uploadStatus}</div>
|
||||
)}
|
||||
{needle === '' ? (
|
||||
<FileTree
|
||||
sessionId={sessionId}
|
||||
@@ -92,6 +219,8 @@ export function TreePanel(props: {
|
||||
onOpenFileSide={onOpenFileSide}
|
||||
onReferenceFile={onReferenceFile}
|
||||
refreshTick={refreshTick}
|
||||
onUploadRequest={startUpload}
|
||||
busy={busy}
|
||||
/>
|
||||
) : (
|
||||
<div className={css.explorerBody}>
|
||||
@@ -116,6 +245,16 @@ export function TreePanel(props: {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{upload !== null && (
|
||||
<UploadOverlay
|
||||
dir={upload.dir}
|
||||
done={upload.done}
|
||||
total={upload.total}
|
||||
current={upload.current}
|
||||
onCancel={cancelUpload}
|
||||
cancelling={cancelling}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className={css.uploadOverlay} role="dialog" aria-modal="true" aria-label={t('uploadingTo', { dir })}>
|
||||
<div className={css.uploadOverlayCard}>
|
||||
<div className={css.uploadOverlayTitle} title={dir}>
|
||||
<IconUploadOutline16 size={16} />
|
||||
<span>{t('uploadingTo', { dir })}</span>
|
||||
</div>
|
||||
<div
|
||||
className={css.uploadOverlayProgress}
|
||||
role="progressbar"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={total}
|
||||
aria-valuenow={done}
|
||||
aria-valuetext={t('uploadProgress', { done, total, name: current })}
|
||||
>
|
||||
<div className={css.uploadOverlayProgressFill} style={{ width: `${percent}%` }} />
|
||||
</div>
|
||||
<div className={css.uploadOverlayStatus}>{uploadHintText(done, total, current, dir, t)}</div>
|
||||
<button type="button" className={css.uploadOverlayCancel} disabled={cancelling} onClick={onCancel}>
|
||||
{t('cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -117,6 +117,45 @@ async function call<T>(method: string, payload: Record<string, unknown>, 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<T>(
|
||||
scope: SessionScope,
|
||||
dir: string,
|
||||
relativePath: string,
|
||||
body: Blob,
|
||||
signal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
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<FsTextResult | FsBinaryResult>('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<GitStatusResult>('git.status', scopePayload(scope, {}), signal),
|
||||
gitDiff: (scope: SessionScope, path: string | undefined, staged: boolean, signal?: AbortSignal) =>
|
||||
|
||||
@@ -61,6 +61,15 @@ export const IconStopOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** 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) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 10V2.75M4.75 5.5 8 2.25 11.25 5.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M2.75 10.5v2.25A1.25 1.25 0 0 0 4 14h8a1.25 1.25 0 0 0 1.25-1.25V10.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
// ── File-viewer inventory glyphs (Side card settings page) ────────────────
|
||||
|
||||
/** Image viewer glyph: a picture frame with a sun and a mountain. */
|
||||
|
||||
@@ -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<keyof typeof zh, string> = {
|
||||
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}',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<UploadItem[]> {
|
||||
if (entry.isFile) {
|
||||
const file = await new Promise<File>((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<FileSystemEntry[]>((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<UploadItem[]> {
|
||||
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 | number>) => 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<UploadResult[]> {
|
||||
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 | number>) => 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 })
|
||||
}
|
||||
@@ -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<SidebarConfig> = 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<SidebarConfig> = 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,
|
||||
|
||||
@@ -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<string | Uint8Array>
|
||||
/** 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<void>((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<void>((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
|
||||
}
|
||||
}
|
||||
@@ -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-<name>.js) so the
|
||||
// heavy preview/terminal libraries load on first use, not at page start
|
||||
|
||||
@@ -12,6 +12,7 @@ export type SidebarErrorCode =
|
||||
| 'not-found'
|
||||
| 'forbidden'
|
||||
| 'method-error'
|
||||
| 'too-large'
|
||||
| 'fs-error'
|
||||
| 'git-error'
|
||||
| 'pty-error'
|
||||
|
||||
@@ -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<string, unknown>).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<Harness> {
|
||||
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<HTMLElement>('[class*="uploadDropZone"]')
|
||||
|
||||
/** The row whose label text matches (rows are role="button" divs). */
|
||||
function rowByName(container: HTMLElement, name: string): HTMLElement {
|
||||
const row = [...container.querySelectorAll<HTMLElement>('[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()
|
||||
})
|
||||
})
|
||||
@@ -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<string | Uint8Array> {
|
||||
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')
|
||||
})
|
||||
})
|
||||
+7
-1
@@ -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()
|
||||
|
||||
@@ -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<typeof summarizeResults>[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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user