* 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>
529 lines
21 KiB
TypeScript
529 lines
21 KiB
TypeScript
/**
|
|
* The controlled file tree behind the files window's tree panel (TreePanel
|
|
* wraps it with the search box): a lazy VSCode-style tree rooted at the
|
|
* session's working directory. Levels load on expansion (one API call per
|
|
* directory), directories sort first, hidden entries render dimmed. The
|
|
* expansion set lives in the per-session state (owned by the caller); the
|
|
* caller also owns the refresh affordance — a `refreshTick` bump wipes the
|
|
* level cache so the visible set reloads.
|
|
*
|
|
* Row actions: hovering a row reveals an @-reference button on the far
|
|
* 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); 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 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 {
|
|
entries?: FsEntry[]
|
|
error?: string
|
|
}
|
|
|
|
/** Root label: the last path segment (mirror of the host rootLabel). */
|
|
export function baseName(path: string): string {
|
|
const trimmed = path.replace(/[\\/]+$/, '')
|
|
const at = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'))
|
|
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
|
|
expanded: string[]
|
|
onToggle: (path: string) => void
|
|
onOpenFile: (path: string) => void
|
|
/** Context-menu "open in a new tab" (file rows; absent → no entry). */
|
|
onOpenFileNewTab?: (path: string) => void
|
|
/** Context-menu "open to the side" (file rows; absent → no entry). */
|
|
onOpenFileSide?: (path: string) => void
|
|
/** Insert `@<relative path>` into the composer draft. */
|
|
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, 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 }
|
|
setData(dataRef.current)
|
|
}, [])
|
|
|
|
const loadDir = useCallback((dir: string) => {
|
|
if (dataRef.current[dir] !== undefined) return
|
|
storeLevel(dir, {})
|
|
api.fsTree({ sessionId, cwd }, dir).then((listing) => {
|
|
storeLevel(dir, { entries: listing.entries })
|
|
}).catch((error: unknown) => {
|
|
storeLevel(dir, { error: error instanceof Error ? error.message : String(error) })
|
|
})
|
|
}, [sessionId, cwd, storeLevel])
|
|
|
|
// The caller's refresh tick wipes the cache (declared BEFORE the load
|
|
// effect so the reload below sees the empty cache).
|
|
const lastTick = useRef(refreshTick)
|
|
useEffect(() => {
|
|
if (lastTick.current === refreshTick) return
|
|
lastTick.current = refreshTick
|
|
dataRef.current = {}
|
|
setData({})
|
|
}, [refreshTick])
|
|
|
|
useEffect(() => {
|
|
// Load the visible set; already-loaded levels (kept in the cache) are
|
|
// not refetched. Only the refresh tick wipes the cache.
|
|
const root = cwd
|
|
if (root === undefined) return
|
|
loadDir(root)
|
|
for (const dir of expanded) loadDir(dir)
|
|
}, [cwd, expanded, refreshTick, loadDir])
|
|
|
|
/** Copy `text`; on success flip the row's copied label for a moment. */
|
|
const copyPath = useCallback((text: string, path: string): void => {
|
|
void writeClipboard(text).then((ok) => {
|
|
if (!ok) return
|
|
setCopiedPath(path)
|
|
window.setTimeout(() => {
|
|
setCopiedPath(current => current === path ? null : current)
|
|
}, COPIED_MS)
|
|
})
|
|
}, [])
|
|
|
|
/** The row's trailing actions: the @-reference button, or the copied label. */
|
|
const rowActions = (entry: FsEntry): ReactNode => {
|
|
if (copiedPath === entry.path) {
|
|
return <span className={css.explorerCopied}>{t('copied')}</span>
|
|
}
|
|
return (
|
|
<button
|
|
type="button"
|
|
className={css.explorerRef}
|
|
aria-label={t('referenceFile')}
|
|
title={t('referenceFile')}
|
|
onClick={(event) => {
|
|
event.stopPropagation()
|
|
onReferenceFile(entry.path)
|
|
}}
|
|
>
|
|
{t('referenceFile')}
|
|
</button>
|
|
)
|
|
}
|
|
|
|
const openRowMenu = (event: MouseEvent, path: string, isDir: boolean): void => {
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
setRowMenu({ path, isDir, x: event.clientX, y: event.clientY })
|
|
}
|
|
|
|
/** Download a file through the host route (raw bytes, binary-safe). */
|
|
const downloadFile = (path: string): void => {
|
|
const url = downloadUrl({ sessionId, cwd }, path)
|
|
const anchor = document.createElement('a')
|
|
anchor.href = url
|
|
anchor.style.display = 'none'
|
|
document.body.appendChild(anchor)
|
|
anchor.click()
|
|
anchor.remove()
|
|
}
|
|
|
|
const root = cwd
|
|
|
|
const renderLevel = (dir: string, depth: number): ReactNode => {
|
|
const level = data[dir]
|
|
if (level === undefined) {
|
|
return <div className={css.explorerRow} style={{ paddingLeft: depth * 22 + 6 }}>{t('loading')}</div>
|
|
}
|
|
if (level.error !== undefined) {
|
|
return (
|
|
<div className={clsx(css.explorerRow, css.explorerError)} style={{ paddingLeft: depth * 22 + 6 }}>
|
|
{level.error}
|
|
</div>
|
|
)
|
|
}
|
|
const entries = level.entries ?? []
|
|
return entries.map(entry => {
|
|
if (entry.isDir) {
|
|
const isOpen = expanded.includes(entry.path)
|
|
return (
|
|
<div key={entry.path}>
|
|
<div
|
|
role="button"
|
|
tabIndex={0}
|
|
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) => {
|
|
if (event.key === 'Enter' || event.key === ' ') {
|
|
event.preventDefault()
|
|
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} />}
|
|
<span className={css.explorerName}>{entry.name}</span>
|
|
{entry.isSymlink && <IconLinkOutline16 size={12} className={css.explorerSymlink} />}
|
|
{rowActions(entry)}
|
|
</div>
|
|
{isOpen && renderLevel(entry.path, depth + 1)}
|
|
</div>
|
|
)
|
|
}
|
|
return (
|
|
<div
|
|
key={entry.path}
|
|
role="button"
|
|
tabIndex={0}
|
|
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) }}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter' || event.key === ' ') {
|
|
event.preventDefault()
|
|
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} />
|
|
<span className={css.explorerName}>{entry.name}</span>
|
|
{entry.isSymlink && <IconLinkOutline16 size={12} className={css.explorerSymlink} />}
|
|
{rowActions(entry)}
|
|
</div>
|
|
)
|
|
})
|
|
}
|
|
|
|
return (
|
|
<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={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} />
|
|
<span className={css.explorerName}>{baseName(root)}</span>
|
|
{copiedPath === root
|
|
? <span className={css.explorerCopied}>{t('copied')}</span>
|
|
: (
|
|
<button
|
|
type="button"
|
|
className={css.explorerRef}
|
|
aria-label={t('referenceFile')}
|
|
title={t('referenceFile')}
|
|
onClick={(event) => {
|
|
event.stopPropagation()
|
|
onReferenceFile(root)
|
|
}}
|
|
>
|
|
{t('referenceFile')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
{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) }}
|
|
items={[
|
|
// The open escapes head the FILE menu (dirs only get copy).
|
|
...(rowMenu?.isDir === false && onOpenFileNewTab !== undefined
|
|
? [{ id: 'open-new-tab', label: t('openFileNewTab'), icon: <IconCodeOutline16 size={14} /> }]
|
|
: []),
|
|
...(rowMenu?.isDir === false && onOpenFileSide !== undefined
|
|
? [{ id: 'open-side', label: t('openFileSide'), icon: <IconFolderOpen16 size={14} /> }]
|
|
: []),
|
|
// Download applies to files only (the host route refuses directories).
|
|
...(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} /> },
|
|
]}
|
|
onSelect={(id) => {
|
|
const target = rowMenu
|
|
if (target === null) return
|
|
setRowMenu(null)
|
|
if (id === 'open-new-tab') {
|
|
onOpenFileNewTab?.(target.path)
|
|
return
|
|
}
|
|
if (id === 'open-side') {
|
|
onOpenFileSide?.(target.path)
|
|
return
|
|
}
|
|
if (id === 'download') {
|
|
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,
|
|
)
|
|
}}
|
|
portal
|
|
align="start"
|
|
getAnchorRect={() => (rowMenu === null ? null : new DOMRect(rowMenu.x, rowMenu.y, 0, 0))}
|
|
anchor={<span />}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|