fix(sidebar): 二轮独立验证的 9 项缺陷 + 目录级暂存

verifier 二轮报告(F1–F9)全部修复,每条都有「临时回退即变红」的判别性验证:

- F1【高】中文等非 latin1 下载名 → 真实宿主 500:content-disposition 改用
  RFC 5987(ASCII 回退 + filename*=UTF-8'');补真 node:http 用例与根因对照断言
- F2【中】多选同名文件归档重名(解压互相覆盖):冲突项逐级上溯父目录消歧
- F3【中低】maxBytes 在读完之后才判定;budget<=0 的 0 字节 FIFO 会永久阻塞:
  改为先 stat、读取前拒绝(FIFO 探针验证)
- F4【中低】SSH 模式 + 宿主不可用时 reveal 整条消失:保留 kind==='reveal' 目标
- F5【低】图钉嵌套交互语义非法且键盘不可达:改为诚实的鼠标热区,键盘入口归设置面板
- F6【低】变更树空闲 tick 全量重渲染:行 memo + 恒定 props + 内容键派生 +
  共享 store 的 sameStatus 保住快照身份
- F7【低】排序随运行时 locale 漂移:显式 'en' collation + 码点兜底
- F8【低】POSIX 文件名的反斜杠被当成层级:不再替换
- F9【低】打包中重复点击静默、无反馈:busy 行(role=status)+ in-flight 守卫
- 新增:目录行「暂存/取消暂存该目录」(git pathspec 语义,不触发折叠)

验证:typecheck / lint / test(134 文件 1441 通过)/ build 全绿;
DSH_CMD='npx -y --package @deepseek-ai/dsh@0.1.7-rc.1 dsh' pnpm test:mount 7/7 通过
This commit is contained in:
Menghuan1918
2026-09-28 18:20:08 +08:00
parent b011be1e98
commit 13d4bae321
17 changed files with 846 additions and 127 deletions
+86 -1
View File
@@ -11,7 +11,7 @@
* so it can neither escape the workspace nor cycle.
*/
import { lstat, readdir } from 'node:fs/promises'
import { join } from 'node:path'
import { basename, dirname, join } from 'node:path'
import { SidebarError } from './wire.ts'
import { ZIP_MAX_ENTRIES, type ZipEntry } from './zip.ts'
@@ -44,6 +44,91 @@ export function archiveNameOf(raw: string | null): string {
return cleaned.toLowerCase().endsWith('.zip') ? cleaned : `${cleaned}.zip`
}
/**
* The `content-disposition` value for one archive download.
*
* A non-latin1 name (a Chinese folder → `报告.zip`) CANNOT go into the header
* verbatim: Node's `writeHead` validates header values and rejects anything
* above U+00FF with `Invalid character in header content` — the route turned
* that into a 500. So the name is sent twice, exactly like `/sidebar/file`
* does for a download: an ASCII-only `filename="…"` fallback for old clients
* and the RFC 5987 `filename*=UTF-8''…` form (percent-encoded, therefore pure
* ASCII) for everyone else, which wins in every current browser.
*/
export function contentDispositionOf(name: string): string {
return `attachment; filename="${asciiFallbackOf(name)}"; filename*=UTF-8''${encodeURIComponent(name)}`
}
/**
* The ASCII-only fallback of a download name: characters a quoted header
* string cannot carry (or that no latin1 client could render) drop out, and a
* stray quote or backslash does too. A result with no stem left (`''` or a
* bare extension, as a fully non-latin1 name reduces to) becomes
* `download.zip`, so the header always names a usable file.
*/
function asciiFallbackOf(name: string): string {
const ascii = [...name]
.filter(char => {
const code = char.codePointAt(0)!
return code >= 0x20 && code <= 0x7E && char !== '"' && char !== '\\'
})
.join('')
const stem = ascii.replace(/\.zip$/i, '')
if (stem === '' || /^\.+$/.test(stem)) return 'download.zip'
return ascii
}
/**
* The in-archive name of every selection, disambiguated.
*
* The common case keeps the intuition "one selection is named by its own
* basename" — archive `/ws/src` and the entries live under `src/`. Two
* selections that would collide (multi-select `/ws/a/index.ts` and
* `/ws/b/index.ts` both wanting `index.ts`) are extended with parent
* segments until every name is unique, so an extractor cannot silently
* overwrite one member with another. A path that runs out of ancestors
* (selecting the filesystem root, or `/a` twice) falls back to the full
* '/'-joined path, which is unique by construction.
*
* @param selections - absolute (already fenced) paths, in selection order.
* @returns one '/'-separated archive name per selection, same order.
*/
export function disambiguateArchiveNames(selections: readonly string[]): string[] {
const segmentsOf = (absolute: string, depth: number): string => {
const parts: string[] = []
let current = absolute
for (let level = 0; level < depth; level += 1) {
const part = basename(current)
const parent = dirname(current)
if (part === '' || parent === current) break
parts.unshift(part)
current = parent
}
return parts.join('/')
}
const deepEnough = (absolute: string, name: string): string => {
const full = segmentsOf(absolute, Number.MAX_SAFE_INTEGER)
// Still colliding after every segment is in play (two identical paths, or
// a root-level duplicate): the full path is the best available identity.
return full === name ? absolute.replace(/^\/+/, '') : name
}
const depth = new Map<string, number>(selections.map(path => [path, 1]))
for (let round = 0; round < 64; round += 1) {
const names = selections.map(path => segmentsOf(path, depth.get(path)!))
const counts = new Map<string, number>()
for (const name of names) counts.set(name, (counts.get(name) ?? 0) + 1)
if ([...counts.values()].every(count => count === 1)) return names
const grown = new Set<string>()
for (const [index, path] of selections.entries()) {
if ((counts.get(names[index]!) ?? 0) <= 1) continue
depth.set(path, depth.get(path)! + 1)
grown.add(path)
}
if (grown.size === 0) break
}
return selections.map(path => deepEnough(path, segmentsOf(path, depth.get(path)!)))
}
/**
* Collect one selected path into the archive: a file becomes one entry, a
* directory is walked depth-first and each level contributes its own entry
+35 -11
View File
@@ -1032,16 +1032,20 @@ export function FileTree(props: {
label: (
<span className={css.openWithLabel}>
<span className={css.openWithName}>{openWithLabelOf(target)}</span>
{/* The pushpin: a span (never a button — the Menu row itself is
a button, so a nested interactive element would be invalid).
Clicking it pins/unpins the target at the menu's top level
WITHOUT selecting the row: the pin stops propagation, so the
menu stays open and the icon flips on the next render. */}
{/* The pushpin: a PLAIN span, deliberately not a control. The
Menu renders the row itself as `<button role="menuitem">`, so a
nested button/imitation-button would be invalid markup — and
anything with `tabIndex` inside it is unreachable by keyboard
anyway (the row button swallows focus). It is therefore an
honest MOUSE hot zone: hovering the row reveals it, clicking it
pins/unpins without selecting the row (stopPropagation), and it
keeps a `title` so the affordance is still nameable on hover.
Keyboard/AT users have a real, reachable path to the same list:
the plugin settings panel's pinned-targets editor
(OpenWithSettings). Do not re-add role/tabIndex here without
also making the row itself non-interactive. */}
<span
role="button"
tabIndex={-1}
className={clsx(css.openWithPin, pinnedNow && css.openWithPinActive)}
aria-label={pinnedNow ? t('unpinOpenWith') : t('pinOpenWith')}
title={pinnedNow ? t('unpinOpenWith') : t('pinOpenWith')}
onClick={(event) => {
event.preventDefault()
@@ -1172,9 +1176,15 @@ export function FileTree(props: {
* `.zip`. So the bytes are fetched explicitly: a non-2xx answer is parsed for
* its `{error: {message}}` envelope (the shape `api.ts` reads) and reported
* through `zipFailed`; a 2xx body becomes an object URL handed to the same
* hidden-anchor mechanics. `archiveBusyRef` keeps a double click from
* packaging the same selection twice.
* hidden-anchor mechanics.
*
* The host builds the archive in one pass, which can take a while for a big
* selection: `archiveBusy` drives the progress line under the error strip
* while the request is in flight, and `archiveBusyRef` keeps a repeated pick
* from packaging the same selection twice (the guard is silent by design —
* the line it is already showing IS the feedback).
*/
const [archiveBusy, setArchiveBusy] = useState(false)
const archiveBusyRef = useRef(false)
const downloadArchive = (paths: readonly string[]): void => {
if (archiveBusyRef.current) return
@@ -1187,6 +1197,7 @@ export function FileTree(props: {
return
}
archiveBusyRef.current = true
setArchiveBusy(true)
void fetch(url)
.then(async (response) => {
if (!response.ok) {
@@ -1214,7 +1225,10 @@ export function FileTree(props: {
.catch((error: unknown) => {
setActionError(t('zipFailed', { message: error instanceof Error ? error.message : String(error) }))
})
.finally(() => { archiveBusyRef.current = false })
.finally(() => {
archiveBusyRef.current = false
setArchiveBusy(false)
})
}
const root = cwd
@@ -1450,6 +1464,16 @@ export function FileTree(props: {
/>
</div>
)}
{/* The archive is built host-side in one pass: while it runs, the
row sits right under the error strip (the two CAN coexist — a
stale error stays readable while a new attempt is in flight).
`role="status"` makes it a polite live region, so the busy state
is announced without stealing focus. */}
{archiveBusy && (
<Notice kind="loading" tone="inline" role="status">
{t('loading')}
</Notice>
)}
{selected.size > 0 && (
<SectionHeader
className={css.explorerSelectionBar}
+151 -64
View File
@@ -26,7 +26,7 @@
* failures used to land under the commit box, which read as "your commit
* failed" for an action the user never ran.
*/
import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent, type ReactNode } from 'react'
import { memo, useCallback, useEffect, useMemo, useRef, useState, type MouseEvent, type ReactNode } from 'react'
import {
Button, IconChevronRightOutlineRegular, IconCodeOutlineRegular, IconCopyOutlineRegular, IconPlusOutlineRegular,
IconTrashOutlineRegular, Input, Menu, writeClipboard,
@@ -125,31 +125,62 @@ const WORKTREE_POLL_MS = 30_000
* just because `snapshot?.entries ?? []` minted a new array. */
const NO_ENTRIES: readonly GitStatusEntry[] = []
/**
* A CONTENT key for one status answer. The shared store mints a brand-new
* entries array for every poll — idle polls with byte-identical content
* included — so array identity cannot decide whether the tree must be rebuilt.
* This string can: the same paths and porcelain codes always produce the same
* key, and a key change is exactly when the tree, its counts and the row
* objects need to be recomputed.
*/
function entriesKey(entries: readonly GitStatusEntry[]): string {
let key = ''
for (const entry of entries) key += `${entry.xy}\u0000${entry.path}\n`
return key
}
/** The preview ref for one changed file (one ref per path+side). */
function worktreeRefOf(
path: string,
untracked: boolean,
staged: boolean,
worktree: string | undefined,
repoRoot: string | undefined,
): SidebarDiffRef {
return { kind: 'worktree', path, staged, untracked, worktree, repoRoot }
}
/** One changed file's row: the host's file artwork + porcelain letter + name
* + the trailing stage action (revealed on hover/focus). */
function FileRow(props: {
node: ChangeFile
icon: ReactNode
* + the trailing stage action (revealed on hover/focus).
*
* Memoized, and every prop keeps its identity across a status poll that
* changed nothing: the tree is content-keyed (so `node` is the same object),
* the callbacks are state-free, and `glyph` is a stable resolver. The icon
* resolution inside the row therefore doubles as the re-render probe the
* specs use — an idle poll must not resolve a single icon. */
const FileRow = memo(function FileRow(props: {
row: ChangeFile
glyph: (path: string) => ReactNode
staged: boolean
busy: boolean
selected: boolean
onPreview(): void
onContextMenu(event: MouseEvent): void
onToggleStage(): void
onPreview: (node: ChangeFile, staged: boolean) => void
onContextMenu: (event: MouseEvent, node: ChangeFile, staged: boolean) => void
onToggleStage: (path: string, staged: boolean) => void
}): ReactNode {
const { node, staged, busy, selected } = props
const { row: node, staged, busy, selected } = props
const action = staged ? t('unstage') : t('stage')
return (
<div className={css.row} data-selected={selected ? 'true' : undefined}>
<div className={css.row} data-row={node.path} data-selected={selected ? 'true' : undefined}>
<button
type="button"
className={css.rowMain}
data-path={node.path}
title={node.path}
onClick={props.onPreview}
onContextMenu={props.onContextMenu}
onClick={() => { props.onPreview(node, staged) }}
onContextMenu={(event) => { props.onContextMenu(event, node, staged) }}
>
<span className={css.rowIcon} aria-hidden="true">{props.icon}</span>
<span className={css.rowIcon} aria-hidden="true">{props.glyph(node.path)}</span>
<StatusBadge tone={BADGE_TONE[node.status.tone]} title={node.path}>{node.status.letter}</StatusBadge>
<span className={css.rowName}>{node.name}</span>
</button>
@@ -159,34 +190,62 @@ function FileRow(props: {
disabled={busy}
label={action}
icon={staged ? <IconTrashOutlineRegular size={14} /> : <IconPlusOutlineRegular size={14} />}
onClick={props.onToggleStage}
onClick={() => { props.onToggleStage(node.path, staged) }}
/>
</div>
)
}
})
/** One directory row: the disclosure chevron + folder artwork + the label
* (a compressed chain reads as one 'a/b/c' line) + how much changed below. */
function DirRow(props: { node: ChangeDir; icon: ReactNode; open: boolean; onToggle(): void }): ReactNode {
const { node, open } = props
* (a compressed chain reads as one 'a/b/c' line) + how much changed below,
* plus the SAME trailing action a file row carries — applied to the whole
* subtree (`git add -A -- <dir>` / `git reset -q -- <dir>`, both of which
* take a directory pathspec). The action is a sibling of the disclosure
* button, so a click can never fold the row; it stops propagation anyway so
* the intent survives a future nesting change. Memoized like {@link FileRow}. */
const DirRow = memo(function DirRow(props: {
row: ChangeDir
folderGlyph: (path: string, open: boolean) => ReactNode
staged: boolean
open: boolean
busy: boolean
onToggle: (key: string) => void
onToggleStage: (path: string, staged: boolean) => void
}): ReactNode {
const { row: node, staged, open, busy } = props
const key = `${staged ? 's' : 'u'}:${node.path}`
const action = staged ? t('unstageAll') : t('stageAll')
return (
<button
type="button"
className={css.dirRow}
data-dir={node.path}
aria-expanded={open}
title={node.path}
onClick={props.onToggle}
>
<span className={css.chevron} data-open={open ? 'true' : undefined} aria-hidden="true">
<IconChevronRightOutlineRegular size={12} />
</span>
<span className={css.rowIcon} aria-hidden="true">{props.icon}</span>
<span className={css.dirName}>{node.name}</span>
<span className={css.countPill}>{node.changes}</span>
</button>
<div className={css.row} data-row={node.path}>
<button
type="button"
className={css.dirRow}
data-dir={node.path}
aria-expanded={open}
title={node.path}
onClick={() => { props.onToggle(key) }}
>
<span className={css.chevron} data-open={open ? 'true' : undefined} aria-hidden="true">
<IconChevronRightOutlineRegular size={12} />
</span>
<span className={css.rowIcon} aria-hidden="true">{props.folderGlyph(node.path, open)}</span>
<span className={css.dirName}>{node.name}</span>
<span className={css.countPill} data-count={node.changes}>{node.changes}</span>
</button>
<IconButton
size="sm"
className={css.rowAction}
disabled={busy}
label={action}
icon={staged ? <IconTrashOutlineRegular size={14} /> : <IconPlusOutlineRegular size={14} />}
onClick={(event) => {
event.stopPropagation()
props.onToggleStage(node.path, staged)
}}
/>
</div>
)
}
})
export interface GitLensProps {
scope: SessionScope
@@ -575,28 +634,12 @@ export function GitLens(props: GitLensProps) {
void writeClipboard(text)
}
const openFileMenu = (event: MouseEvent, path: string, staged: boolean, untracked: boolean): void => {
event.preventDefault()
event.stopPropagation()
setFileMenu({ path, staged, untracked, x: event.clientX, y: event.clientY })
}
const openHistoryMenu = (event: MouseEvent, entry: GitLogEntry): void => {
event.preventDefault()
event.stopPropagation()
setHistoryMenu({ entry, x: event.clientX, y: event.clientY })
}
/** The preview ref for one changed file (one ref per path+side). */
const worktreeRefOf = (path: string, untracked: boolean, staged: boolean): SidebarDiffRef => ({
kind: 'worktree',
path,
staged,
untracked,
worktree: selectedWorktree,
repoRoot,
})
/** The preview ref for one commit. */
const commitRefOf = (entry: GitLogEntry): SidebarDiffRef => ({
kind: 'commit',
@@ -615,8 +658,11 @@ export function GitLens(props: GitLensProps) {
}
const entries = snapshot?.entries ?? NO_ENTRIES
// One fold per group, memoized on the snapshot's own entry array: the tree
// (compression + sorting + counts) is rebuilt only when git answers anew.
// The CONTENT of the answer decides whether the tree must be rebuilt; the
// array identity only decides whether the (cheap) key is recomputed.
const contentKey = useMemo(() => entriesKey(entries), [entries])
// One fold per group, rebuilt only when the change list's CONTENT changes —
// never on a poll that answered the same files, however fresh its arrays are.
const { stagedEntries, unstagedEntries, stagedTree, unstagedTree } = useMemo(() => {
const staged = entries.filter(isStagedEntry)
const unstaged = entries.filter(isUnstagedEntry)
@@ -626,34 +672,67 @@ export function GitLens(props: GitLensProps) {
stagedTree: buildChangeTree(staged),
unstagedTree: buildChangeTree(unstaged),
}
}, [entries])
// The key stands in for `entries`: same content ⇒ same tree objects ⇒ the
// memoized rows below keep their props and never re-render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [contentKey])
const isRepo = snapshot?.isRepo === true
const branch = snapshot?.branch ?? ''
const branchOptions = branch === '' ? branchNames : [branch, ...branchNames.filter(name => name !== branch)]
const clean = isRepo && stagedEntries.length === 0 && unstagedEntries.length === 0
/** Fold or unfold one directory row (keyed per group, see `collapsed`). */
const toggleDir = (key: string): void => {
/** Fold or unfold one directory row (keyed per group, see `collapsed`).
* Stable: it is a memoized row's prop. */
const toggleDir = useCallback((key: string): void => {
setCollapsed((current) => {
const next = new Set(current)
if (next.has(key)) next.delete(key)
else next.add(key)
return next
})
}
}, [])
/** The latest-value holder for the actions a memoized row calls: a row's
* props must keep their identity across polls, so these callbacks read the
* current closures through the ref instead of closing over them. */
const rowActions = useRef({
preview: (_node: ChangeFile, _staged: boolean): void => { /* replaced below */ },
stage: (_path: string, _staged: boolean): void => { /* replaced below */ },
})
useEffect(() => {
rowActions.current = {
preview: (node, staged) => {
onPreview(worktreeRefOf(node.path, isUntrackedStatus(node.status), staged, chosenPathRef.current, repoRootRef.current))
},
stage: (path, staged) => { stageEntry(path, staged) },
}
})
/** Stable row callbacks (see rowActions). */
const previewFile = useCallback((node: ChangeFile, staged: boolean): void => {
rowActions.current.preview(node, staged)
}, [])
const stagePath = useCallback((path: string, staged: boolean): void => {
rowActions.current.stage(path, staged)
}, [])
const openRowMenu = useCallback((event: MouseEvent, node: ChangeFile, staged: boolean): void => {
event.preventDefault()
event.stopPropagation()
setFileMenu({ path: node.path, staged, untracked: isUntrackedStatus(node.status), x: event.clientX, y: event.clientY })
}, [])
/** One file row of a group's tree (the single file-row renderer). */
const renderFile = (node: ChangeFile, staged: boolean): ReactNode => (
<FileRow
key={`${staged ? 's' : 'u'}:${node.path}`}
node={node}
icon={fileGlyph(node.path)}
key={`file:${staged ? 's' : 'u'}:${node.path}`}
row={node}
glyph={fileGlyph}
staged={staged}
busy={busy}
selected={isPreviewedWorktree(node.path, staged)}
onPreview={() => { onPreview(worktreeRefOf(node.path, isUntrackedStatus(node.status), staged)) }}
onContextMenu={(event) => { openFileMenu(event, node.path, staged, isUntrackedStatus(node.status)) }}
onToggleStage={() => { stageEntry(node.path, staged) }}
onPreview={previewFile}
onContextMenu={openRowMenu}
onToggleStage={stagePath}
/>
)
@@ -662,8 +741,16 @@ export function GitLens(props: GitLensProps) {
const key = `${staged ? 's' : 'u'}:${node.path}`
const open = !collapsed.has(key)
return (
<div key={key}>
<DirRow node={node} icon={folderGlyph(node.path, open)} open={open} onToggle={() => { toggleDir(key) }} />
<div key={`dir:${key}`}>
<DirRow
row={node}
folderGlyph={folderGlyph}
staged={staged}
open={open}
busy={busy}
onToggle={toggleDir}
onToggleStage={stagePath}
/>
{open && (
<div className={css.treeChildren}>
{node.children.map(child => (child.kind === 'dir' ? renderDir(child, staged) : renderFile(child, staged)))}
+18 -4
View File
@@ -47,12 +47,26 @@ interface DirDraft {
files: ChangeFile[]
}
/** Directories before files, then by name — case-insensitively, with the raw
* name breaking ties so the order never depends on the locale's collation. */
/**
* Directories before files, then by name — case-insensitively.
*
* The collation is PINNED (`'en'`, `sensitivity: 'base'`) rather than left to
* the runtime's default locale: `localeCompare(other)` alone follows whatever
* locale the browser/Node happens to run under, so the same change list could
* order differently on two machines. Base sensitivity makes case and accent
* variants compare equal, and the code-point tiebreak below then decides —
* a total order that does not move when the input order changes.
*/
function compareNames(left: string, right: string): number {
const collated = left.localeCompare(right, 'en', { sensitivity: 'base' })
if (collated !== 0) return collated
if (left === right) return 0
return left < right ? -1 : 1
}
function compareNodes(left: ChangeNode, right: ChangeNode): number {
if (left.kind !== right.kind) return left.kind === 'dir' ? -1 : 1
const folded = left.name.toLowerCase().localeCompare(right.name.toLowerCase())
return folded !== 0 ? folded : left.name.localeCompare(right.name)
return compareNames(left.name, right.name)
}
/** The number of changed files under a finished child list. */
+6 -6
View File
@@ -103,23 +103,23 @@
border-left: 1px solid var(--dsw-alias-border-l1);
}
/* One directory row: disclosure chevron + folder artwork + label + count. */
/* A directory row's disclosure button. It shares the `.row` band with a file
row (the band owns the hover/selected fill and the trailing action), so this
button is only the row's main hit area. */
.dirRow {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 6px;
width: 100%;
min-height: 28px;
padding: 0 6px 0 2px;
border: none;
border-radius: 4px;
background: transparent;
padding: 0;
color: var(--dsw-alias-label-secondary);
font: inherit;
cursor: pointer;
text-align: left;
}
.dirRow:hover { background: var(--dsw-alias-interactive-bg-hover); }
.dirRow:focus-visible { outline: 2px solid var(--dsw-alias-interactive-bg-hover-accent); outline-offset: -1px; }
.chevron { flex: none; display: inline-flex; color: var(--dsw-alias-label-tertiary); transition: transform 140ms var(--ds-ease-in-out); }
+10 -4
View File
@@ -137,9 +137,15 @@ function customIdOf(id: string): string {
/**
* The menu-visible open targets, in order (built-ins then custom editors).
* In SSH mode the local-only targets (the OS file manager, Zed, custom
* editors without the VSCode dialect) are dropped — they cannot reach a
* remote path. Unknown pinned ids are pruned here too.
* In SSH mode the local-only URL openers (Zed, custom editors without the
* VSCode dialect) are dropped — they cannot reach a remote path. The
* `explorer` target is NOT dropped: it reveals a path in the file manager of
* the machine that owns the workspace, which is exactly where the host half
* runs, and it is the only reveal left when the DSH host's own open-in-app
* capability is unavailable (older host, no desktop session). A headless
* remote cannot raise a file manager anyway — that is the same silent
* no-op the other external launches already have. Unknown pinned ids are
* pruned here too.
*/
export function resolveOpenWithTargets(config: OpenWithConfig): OpenWithTarget[] {
const ssh = config.sshHost.trim() !== ''
@@ -156,7 +162,7 @@ export function resolveOpenWithTargets(config: OpenWithConfig): OpenWithTarget[]
localOnly: !editor.isVscodeFamily,
})),
]
return targets.filter(target => !(ssh && target.localOnly))
return targets.filter(target => !(ssh && target.localOnly && target.kind !== 'reveal'))
}
/** The SSH hint appended to a target's label in remote mode. */
+27
View File
@@ -90,6 +90,24 @@ function joinRoot(root: string, rel: string): string {
return `${base}/${rel.replace(/^[\\/]+/, '')}`
}
/**
* Whether two status snapshots carry the same information. Compared field by
* field (never by serializing the whole object): the entries are the payload
* and their order is stable, so a length + pairwise compare is enough.
*/
function sameStatus(a: GitStatusResult, b: GitStatusResult): boolean {
if (a === b) return true
if (a.isRepo !== b.isRepo || a.branch !== b.branch || a.root !== b.root
|| a.truncated !== b.truncated || a.entries.length !== b.entries.length) return false
if (a.repositories?.length !== b.repositories?.length) return false
if (a.repositories !== undefined && b.repositories !== undefined
&& a.repositories.some((root, index) => root !== b.repositories![index])) return false
return a.entries.every((entry, index) => {
const other = b.entries[index]!
return entry.path === other.path && entry.xy === other.xy
})
}
interface Index {
/** Normalized absolute path → status (files only). */
files: Map<string, GitFileStatus>
@@ -177,6 +195,15 @@ function fetchSlot(slot: Slot): void {
api.gitStatus(scope, slot.worktree)
.then((snapshot) => {
if (generation !== slot.generation) return
// Idle polls return the same answer as a NEW object every 2.5s. Keeping
// the previous identity when nothing changed is what lets every
// downstream memo (the changes tree, the file tree's row model, the
// icon index) hold across ticks — without it the whole list re-rendered
// and re-resolved icons on every poll.
if (slot.snapshot !== null && sameStatus(slot.snapshot, snapshot)) {
slot.error = false
return
}
slot.snapshot = snapshot
slot.error = false
})
+14 -7
View File
@@ -38,7 +38,7 @@ import { isTrustedApiRequest } from './trust-fence.ts'
import { registerBundleRoute } from './bundle-route.ts'
import { createDirectoryWatchers, type DirectoryWatchers } from './fs-watch.ts'
import { launchExternal } from './open-external.ts'
import { archiveNameOf, collectZipEntries } from './archive-route.ts'
import { archiveNameOf, collectZipEntries, contentDispositionOf, disambiguateArchiveNames } from './archive-route.ts'
import { buildZip, type ZipEntry } from './zip.ts'
import * as git from './git.ts'
import { SettingsConflictError } from '@deepseek-ai/dsh-settings'
@@ -68,10 +68,10 @@ export type {
FileViewerProps,
FileFetchStrategy,
} from './client/service.ts'
// The archive walk + name sanitizer are exercised directly by tests/zip.spec.ts:
// The archive walk + name helpers are exercised directly by tests/zip.spec.ts:
// their bounds live inside the collector, so the suite needs them observable
// without creating 10 000 rows first.
export { archiveNameOf, collectZipEntries } from './archive-route.ts'
export { archiveNameOf, collectZipEntries, contentDispositionOf, disambiguateArchiveNames } from './archive-route.ts'
/** Plugin identity for cordis.yml rows. */
export const name = 'dsh-better-sidebar'
@@ -945,15 +945,22 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
const name = archiveNameOf(url.searchParams.get('name'))
const cwd = await sessionCwdOf(ctx, sessionId, url.searchParams.get('cwd') ?? undefined)
const fenceOn = fenceEnabledOf(() => settingsFace)
const selected: string[] = []
for (const raw of paths) selected.push(await ensureWorkspacePath(cwd, raw, fenceOn))
// Same-basename selections (a/index.ts + b/index.ts) get parent
// segments prepended, so no two archive members collide and an
// extractor cannot overwrite one with the other.
const entries: ZipEntry[] = []
for (const raw of paths) {
const absolute = await ensureWorkspacePath(cwd, raw, fenceOn)
await collectZipEntries(absolute, basename(absolute), entries)
const names = disambiguateArchiveNames(selected)
for (const [index, absolute] of selected.entries()) {
await collectZipEntries(absolute, names[index]!, entries)
}
const body = await buildZip(entries)
res.writeHead(200, {
'content-type': 'application/zip',
'content-disposition': `attachment; filename="${name}"`,
// Never the raw name: a non-latin1 value (中文目录 → 报告.zip) makes
// Node's writeHead throw and turns the download into a 500.
'content-disposition': contentDispositionOf(name),
'content-length': String(body.byteLength),
'cache-control': 'no-cache',
})
+42 -12
View File
@@ -8,11 +8,12 @@
* for the whole archive. No third-party dependency, no filesystem walking —
* the caller supplies the entries, this module only reads their bytes.
*
* The entries are bounded by `maxEntries` / `maxBytes` before anything is
* read, so a runaway selection fails with a clear error instead of filling
* memory with an archive nobody asked for.
* The entries are bounded by `maxEntries` / `maxBytes`: the count is checked
* up front, and every source is stat'ed against the remaining byte budget
* BEFORE it is read, so a runaway selection fails with a clear error instead
* of filling memory with an archive nobody asked for.
*/
import { readFile } from 'node:fs/promises'
import { readFile, stat } from 'node:fs/promises'
import { deflateRaw } from 'node:zlib'
import { promisify } from 'node:util'
import { SidebarError } from './wire.ts'
@@ -95,11 +96,20 @@ function dosDateTime(date: Date): { time: number; date: number } {
}
}
/** Normalize an entry name to a '/'-separated relative archive path. */
/**
* Normalize an entry name to a '/'-separated relative archive path.
*
* A backslash is NOT a separator here: on POSIX (and on the host half, which
* builds the names) `a\b.txt` is one legal file name, so converting it would
* silently split one file into a directory. Windows separators never reach
* this point — the walk composes names from `basename`/`join` results.
* Leading slashes are dropped (names are archive-relative by definition) and
* `..` segments are refused, so a name can never traverse.
*/
function archiveName(raw: string, isDir: boolean): string {
const normalized = raw.replace(/\\/g, '/').replace(/^\/+/, '')
const normalized = raw.replace(/^\/+/, '')
const segments = normalized.split('/').filter(segment => segment !== '' && segment !== '.')
if (segments.length === 0 || segments.includes('..')) {
if (raw === '..' || segments.length === 0 || segments.includes('..')) {
throw new SidebarError('bad-request', `invalid archive entry name "${raw}"`)
}
const name = segments.join('/')
@@ -114,8 +124,26 @@ function requireSourcePath(raw: string): string {
return raw
}
/** Read + compress one member (deflate only when it actually shrinks). */
async function prepareFile(name: string, path: string): Promise<ZipMember> {
/**
* Read + compress one member (deflate only when it actually shrinks).
* @param limit - the archive's total uncompressed ceiling (for the message).
* @param budget - uncompressed bytes still available; the source is stat'ed
* FIRST and refused when it cannot fit, so an oversized archive never pulls
* the payload into memory (let alone deflates it) before failing.
*/
async function prepareFile(name: string, path: string, limit: number, budget: number): Promise<ZipMember> {
let size: number
try {
size = (await stat(path)).size
} catch (error) {
throw new SidebarError('fs-error', `cannot read "${path}": ${error instanceof Error ? error.message : String(error)}`, 400)
}
// `budget <= 0` refuses even a zero-size source. That is not pedantry: a
// FIFO stats as 0 bytes and reading one BLOCKS until a writer shows up, so
// an exhausted budget must never reach open().
if (budget <= 0 || size > budget) {
throw new SidebarError('fs-error', `archive exceeds the ${limit} byte limit`, 400)
}
let data: Buffer
try {
data = await readFile(path)
@@ -183,14 +211,16 @@ export async function buildZip(entries: readonly ZipEntry[], opts: ZipOptions =
continue
}
const path = requireSourcePath(entry.path)
const member = await prepareFile(archiveName(entry.name, false), path)
// The bound is the archive's total UNCOMPRESSED payload: that is what the
// reader materializes, and it makes the limit independent of how well the
// input happens to compress.
totalBytes += member.size
if (totalBytes > maxBytes) {
const member = await prepareFile(archiveName(entry.name, false), path, maxBytes, maxBytes - totalBytes)
// A source can grow AFTER its stat (sparse files, concurrent writers), so
// the ceiling is re-checked against what was actually read.
if (totalBytes + member.size > maxBytes) {
throw new SidebarError('fs-error', `archive exceeds the ${maxBytes} byte limit`, 400)
}
totalBytes += member.size
members.push(member)
}