背景 / Pain point
当前文件树只在手动点刷新按钮、展开/折叠目录、或插件自身写操作(如上传成功)时刷新。在终端里 git pull、构建工具产出文件、或用外部编辑器改文件后,侧边栏文件树不会感知,必须手动刷新。
方案总览
我在 v0.15.1 上以 pnpm patch 形式实现并验证了一版参考实现(Linux + Node 22,端到端探针测试通过),核心思路是两件事解耦:
- 推送通道:宿主新增
/sidebar/ws/fs-events WebSocket。客户端把"当前可见目录清单"(树根 + expanded)发给宿主,宿主对每个目录挂非递归 fs.watch;任何被监听目录有变更 → 300ms 防抖 → 下发 {type:"fs"}。
- 无闪烁消费:客户端收到事件后走与手动刷新相同的
setRefreshTick 路径,但把 tick 的语义从「清空 level 缓存重拉」改为「stale-while-revalidate:逐层后台重拉,listing 有变化才原地替换」。
为什么非递归 + 按展开目录
- Linux 下
fs.watch(recursive) 是异步逐子目录铺 watch:对 $HOME 这类大目录,铺完之前事件大量丢失,且 inotify 占用巨大
- 文件树每层只展示直接子项 → 监听"已展开目录集合"恰好 100% 覆盖可见内容,watcher 数量 = 展开数,即时建立、收起即撤销
- 与 VSCode 文件资源管理器语义一致:看到的内容保持新鲜
安全围栏
完全复用现有设施:升级握手走 fence(req);客户端上报的路径经 isAbsolute ? : resolve(cwd, path) 后强制 isWithin(cwd, abs) 校验,越界路径直接丢弃。
关键实现(裁剪版)
宿主:注册 WS + 按连接维护 watch 集合
const fsWatchWss = new WebSocketServer({ noServer: true });
ctx.effect(() => ctx.webServer.registerUpgrade({
path: "/sidebar/ws/fs-events",
handler: (req, socket, head) => {
if (!fence(req)) { socket.destroy(); return; }
fsWatchWss.handleUpgrade(req, socket, head, (ws, req2) => {
const sessionId = new URL(req2.url ?? "/", "http://dsh.internal").searchParams.get("sessionId");
let cwd;
try { cwd = sessionCwdOf(ctx, sessionId); } catch { ws.close(1008, "bad sessionId"); return; }
const conn = { cwd, watchers: new Map(), timer: null };
const noteChange = () => {
if (conn.timer) return;
conn.timer = setTimeout(() => {
conn.timer = null;
if (ws.readyState === 1) ws.send(JSON.stringify({ type: "fs" }));
}, 300);
};
const watchDir = (abs) => {
if (conn.watchers.has(abs)) return;
let w; try { w = fsWatch(abs); } catch { return; } // node:fs 的 watch,零新依赖
conn.watchers.set(abs, w);
w.on("change", () => noteChange());
w.on("error", () => { conn.watchers.delete(abs); try { w.close(); } catch {} });
};
const applyWatchList = (paths) => {
const wanted = new Set([cwd]); // 树根始终监听
for (const item of paths) {
if (typeof item !== "string" || !item) continue;
const abs = isAbsolute(item) ? item : resolve(cwd, item);
if (abs === cwd || !isWithin(cwd, abs)) continue; // 越界防护
wanted.add(abs);
}
for (const [abs, w] of [...conn.watchers]) if (!wanted.has(abs)) {
conn.watchers.delete(abs); try { w.close(); } catch {}
}
for (const abs of wanted) watchDir(abs);
};
ws.on("message", (data) => {
try {
const msg = JSON.parse(String(data));
if (msg?.type === "watch" && Array.isArray(msg.paths)) applyWatchList(msg.paths);
} catch {}
});
ws.on("close", () => closeAll(conn));
ws.on("error", () => {});
if (ws.readyState === 1) ws.send(JSON.stringify({ type: "ready", cwd }));
});
}
}), "dsh-better-sidebar: fs-events push WebSocket");
客户端 TreePanel:连接 + expanded 变化即下发
const fsWatchSocketRef = useRef(null);
const fsWatchSendRef = useRef(null);
// expanded 变化 → 更新宿主 watch 集合(根 "." 始终包含)
useEffect(() => {
fsWatchSendRef.current = () => {
const s = fsWatchSocketRef.current;
if (!s || s.readyState !== 1) return;
try { s.send(JSON.stringify({ type: "watch", paths: [...expanded, "."] })); } catch {}
};
fsWatchSendRef.current();
}, [cwd, expanded]);
// 连接生命周期(断线 5s 重连)
useEffect(() => {
if (!sessionId || !cwd) return;
let closed = false, retry = null, debounce = null;
const connect = () => {
/* new WebSocket("/sidebar/ws/fs-events?sessionId=..."),
onopen → fsWatchSendRef.current(),
onmessage 收到 {type:"fs"} → 350ms 防抖 → setRefreshTick(t => t + 1),
onclose → 重连 */
};
connect();
return () => { /* 清理 */ };
}, [sessionId, cwd]);
无闪烁:refreshTick 从「清缓存」改「stale-while-revalidate」
// 原:dataRef.current = {}; setData({}); ← 整树空白一帧 = 闪烁
// 改:
useEffect(() => {
if (lastTick.current === refreshTick) return;
lastTick.current = refreshTick;
for (const dir of Object.keys(dataRef.current)) {
api.fsTree({ sessionId, cwd }, dir).then((listing) => {
const next = { entries: listing.entries };
if (JSON.stringify(dataRef.current[dir]) === JSON.stringify(next)) return; // 没变连 render 都不触发
storeLevel(dir, next); // 变了只换那一层
}).catch(() => {});
}
}, [refreshTick]);
附带收益:手动刷新按钮也不再闪。
已验证
- pnpm patchedDependencies 方式在 0.15.1 实装;WS 握手 / ready 回传 cwd / watch 列表增量增删 / 根目录与子目录变更推送 / 未监听目录不误报,全部通过脚本化探针验证
- Node 22 + Linux;
fs.watch 非递归各平台可用,零新增依赖
已知边界
- 只覆盖"可见目录":未展开的深层目录变化不触发(父链上有任一层展开即可见变化);编辑器已打开文件的"外部修改自动 reload"不在本方案内,可作后续
expanded 目前是 TreePanel 内部状态,若未来树虚拟化/多窗格,watch 列表来源需要跟随重构
如果方向认可,我可以整理成 PR(补丁 diff 现成),或按你们偏好的方式调整协议细节(比如事件携带具体 path 做更细粒度的层级定向重拉)。
背景 / Pain point
当前文件树只在手动点刷新按钮、展开/折叠目录、或插件自身写操作(如上传成功)时刷新。在终端里
git pull、构建工具产出文件、或用外部编辑器改文件后,侧边栏文件树不会感知,必须手动刷新。方案总览
我在 v0.15.1 上以 pnpm patch 形式实现并验证了一版参考实现(Linux + Node 22,端到端探针测试通过),核心思路是两件事解耦:
/sidebar/ws/fs-eventsWebSocket。客户端把"当前可见目录清单"(树根 +expanded)发给宿主,宿主对每个目录挂非递归fs.watch;任何被监听目录有变更 → 300ms 防抖 → 下发{type:"fs"}。setRefreshTick路径,但把 tick 的语义从「清空 level 缓存重拉」改为「stale-while-revalidate:逐层后台重拉,listing 有变化才原地替换」。为什么非递归 + 按展开目录
fs.watch(recursive)是异步逐子目录铺 watch:对$HOME这类大目录,铺完之前事件大量丢失,且 inotify 占用巨大安全围栏
完全复用现有设施:升级握手走
fence(req);客户端上报的路径经isAbsolute ? : resolve(cwd, path)后强制isWithin(cwd, abs)校验,越界路径直接丢弃。关键实现(裁剪版)
宿主:注册 WS + 按连接维护 watch 集合
客户端 TreePanel:连接 + expanded 变化即下发
无闪烁:refreshTick 从「清缓存」改「stale-while-revalidate」
附带收益:手动刷新按钮也不再闪。
已验证
fs.watch非递归各平台可用,零新增依赖已知边界
expanded目前是 TreePanel 内部状态,若未来树虚拟化/多窗格,watch 列表来源需要跟随重构如果方向认可,我可以整理成 PR(补丁 diff 现成),或按你们偏好的方式调整协议细节(比如事件携带具体 path 做更细粒度的层级定向重拉)。