Files
apes-sidebar/src/client/use-dir-watch.ts
T
Menghuan1918 36fbc5ac7e feat(sidebar): 文件页与文件变动页 UIUX 重构 + 全局组件抽取
文件页(FileTree/TreePanel)
- Ctrl/Cmd 多选 + Shift 按可见行序连选(Esc/点空白/右键收敛),批量条支持复制路径、批量删除(共享 ConfirmDialog + 顺序 fsRemove)
- Git 变更着色:行名按状态上色 + M/A/D/U 徽标,目录按 dirHasChanges 着色,非仓库静默降级(数据来自共享 git 状态存储)
- 新建文件夹:fs.mkdir 路由 + mkdirWorkspaceEntry + 内联输入行(Enter/Esc/blur/IME 守卫)
- 性能:FileRow/DirRow memo(含反向验证)、搜索 hidden 停车不再卸载树、level 拉取世代守卫 + AbortController、fs.tree truncated 提示、use-dir-watch 入参 memo
- 拖拽上传视觉 token 化(硬编码色清零);可见性下传后隐藏页签停止轮询
- 右键「打开方式」改为复用 DSH 自带 open-in-app(宿主探测的关联应用 + 文件管理器显示)

文件变动页(changes/diff)
- 36px 头(宿主 SegmentedControl)+ 单一 ChangeRow + SectionHeader 分组 + 吸底提交条单状态行 + 历史独立空态
- 修掉:双份空态、暂存失败 unhandled rejection、手动刷新被丢、仓库切换刷新两次、worktree 清单缺 repoRoot、工作树被自动切走、预览持旧快照、恢复高度未 clamp、session ops 无谓轮询
- DiffPane/DiffTab 共用 use-git-diff;修掉 DiffPane 的 untracked 预览从不渲染

全局组件与宿主接入
- 新增 src/client/ui(IconButton/Chip/SectionHeader/Notice/ConfirmDialog/StatusBadge + 共享 git 状态存储)
- 新增 src/client/open-in-app.ts(宿主 open-in-app 客户端适配),删除自研 open-with 全链路与 open.external 路由
- 客户端 inject 补 remote / remote.session:缺声明时 ctx.get('remote').session 会抛出并把整个面板打成错误条(真实挂载 lane 抓到)
- 词典:+25 键 / -20 openWith* / -noChanges,20 份同步

验证:typecheck / lint / test(126 文件 1322 通过)/ build 全绿;
DSH_CMD='npx -y --package @deepseek-ai/dsh@0.1.7-rc.1 dsh' pnpm test:mount 7/7 通过(含 tab sweep 与 perf lane)
2026-09-28 00:27:31 +08:00

149 lines
5.6 KiB
TypeScript

/**
* Live refresh for the file tree.
*
* The tree lists a folder when it is expanded and caches the level, so
* anything written outside the plugin — a build, a formatter, the model's own
* `bash` — stayed invisible until the reader refreshed. DSH 0.1.7 gave its own
* tree per-directory watching; this plugin owns its tree (it took the `files`
* kind over), so it carries its own.
*
* One socket per session, open for as long as the tree is mounted. The client
* announces the folders it has expanded and the host watches exactly those;
* every notice names a directory whose listing is stale, and the tree drops
* that one cached level and re-lists it. Folders the reader collapsed are
* un-watched, so a long session does not accumulate handles.
*/
import { useEffect, useMemo, useRef } from 'react'
/** One server frame: a stale directory, a watch verdict, or a refusal. */
interface FsWatchFrame {
dir?: unknown
ok?: unknown
}
/** What the tree hands the hook. */
export interface DirectoryWatchOptions {
/** Session whose workspace is being browsed; no socket while undefined. */
sessionId: string | undefined
/** The workspace root, always watched (the tree always lists it). */
root: string | undefined
/** Expanded directories below the root, in the host's absolute path form. */
dirs: readonly string[]
/**
* A directory whose listing is stale.
* @param dir - absolute directory path, exactly the form the tree keys levels by.
*/
onStale: (dir: string) => void
}
/** How long to wait before re-opening a dropped socket; failures back off to the cap. */
const RETRY_BASE_MS = 1000
const RETRY_MAX_MS = 15_000
/**
* Keep the file tree's expanded folders watched for the component's lifetime.
* @param options - session, root, expanded directories, and the staleness callback.
*/
export function useDirectoryWatch(options: DirectoryWatchOptions): void {
const { sessionId, root, dirs, onStale } = options
// The expanded set is a fresh array on every render, so neither the socket
// effect nor the staleness callback may depend on their identity. The
// CONTENT key (NUL-joined paths — no path can contain one) is what the
// arrays are derived from, so a re-render with the same expanded set
// rebuilds neither the wanted list nor the effect dependency.
const dirsKey = dirs.join('\u0000')
const wanted = useMemo(() => {
const listed = dirsKey === '' ? [] : dirsKey.split('\u0000')
return root === undefined ? listed : [root, ...listed]
}, [root, dirsKey])
const wantedRef = useRef(wanted)
wantedRef.current = wanted
const staleRef = useRef(onStale)
staleRef.current = onStale
/** Set by the socket effect; reconciles the host's watch set with the tree's. */
const reconcileRef = useRef<() => void>(() => {})
// One string per expanded-set change: a stable dependency for the effect below.
const wantedKey = useMemo(() => wanted.join('\u0000'), [wanted])
useEffect(() => {
if (sessionId === undefined) return
let socket: WebSocket | null = null
let retry: number | undefined
let closed = false
let failures = 0
/** Directories the host confirmed it is watching (its own resolved paths). */
const watching = new Set<string>()
const send = (op: 'watch' | 'unwatch', path: string): void => {
if (socket !== null && socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ op, path }))
}
/**
* Bring the host's watch set in line with the tree's expanded set. The
* host answers every `watch` with the directory it resolved, so `watching`
* is the host's own answer rather than a client-side guess.
*/
const reconcile = (): void => {
const wanted = new Set(wantedRef.current)
for (const path of wanted) send('watch', path)
for (const dir of [...watching]) {
if (wanted.has(dir)) continue
send('unwatch', dir)
watching.delete(dir)
}
}
reconcileRef.current = reconcile
const connect = (): void => {
if (closed) return
const url = new URL('/sidebar/ws/fs-watch', location.origin)
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
url.search = new URLSearchParams({ sessionId }).toString()
socket = new WebSocket(url.toString())
socket.onopen = () => {
failures = 0
watching.clear()
reconcile()
}
socket.onmessage = (event) => {
if (typeof event.data !== 'string') return
let frame: FsWatchFrame
try {
frame = JSON.parse(event.data) as FsWatchFrame
} catch {
return
}
if (frame === null || typeof frame !== 'object' || typeof frame.dir !== 'string') return
// `ok: true` confirms a watch; `ok: false` refuses one (fenced path,
// watcher cap, unreadable directory) and must NOT re-list. A frame
// carrying only `dir` is the stale notice.
if (frame.ok === true) {
watching.add(frame.dir)
return
}
if (frame.ok === undefined) staleRef.current(frame.dir)
}
socket.onclose = () => {
if (closed) return
failures += 1
retry = window.setTimeout(connect, Math.min(RETRY_BASE_MS * 2 ** (failures - 1), RETRY_MAX_MS))
}
}
connect()
return () => {
closed = true
reconcileRef.current = () => {}
if (retry !== undefined) window.clearTimeout(retry)
socket?.close()
}
}, [sessionId])
// Reconciling on its own effect keeps an expand/collapse from tearing the
// socket down and re-opening it.
useEffect(() => {
void wantedKey
reconcileRef.current()
}, [wantedKey])
}