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:
+35
-11
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user