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
+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
})