Skip to content

[Feature] 文件树基于 fs.watch 的自动刷新(含已验证的参考实现:按展开目录精确监听 + 无闪烁原地更新) #344

Description

@overact

背景 / Pain point

当前文件树只在手动点刷新按钮、展开/折叠目录、或插件自身写操作(如上传成功)时刷新。在终端里 git pull、构建工具产出文件、或用外部编辑器改文件后,侧边栏文件树不会感知,必须手动刷新。

方案总览

我在 v0.15.1 上以 pnpm patch 形式实现并验证了一版参考实现(Linux + Node 22,端到端探针测试通过),核心思路是两件事解耦

  1. 推送通道:宿主新增 /sidebar/ws/fs-events WebSocket。客户端把"当前可见目录清单"(树根 + expanded)发给宿主,宿主对每个目录挂非递归 fs.watch;任何被监听目录有变更 → 300ms 防抖 → 下发 {type:"fs"}
  2. 无闪烁消费:客户端收到事件后走与手动刷新相同的 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 做更细粒度的层级定向重拉)。

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2中:一般功能/优化area/explorerFile tree / explorer: navigation, context menu, searchenhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions