Skip to content

Commit b889dc9

Browse files
authored
feat: WSL/远程文件树与外部变更监听(第二层,基于 #236) (#239)
* fix: 修复打开 WSL 文件时主进程因 EISDIR 崩溃 (#172) 打开 \\wsl.localhost\ 下的文件时,应用会自动把文件所在目录作为工作区并用 chokidar 递归监听。chokidar 遍历到目录中的 Linux 软链接(如 *.so.0)时会对其 lstat,而 Windows/Node 对经 9P 重定向器暴露的 WSL 软链接做 lstat 会返回 EISDIR (底层是 9P 不支持读取该软链接的 reparse 数据,返回 Incorrect function,被 libuv 映射成了 EISDIR)。该错误没有任何地方处理,冒泡成主进程 uncaughtException,于是弹出 "A JavaScript error occurred in the main process" 并使文件区卡死。 修复采用双保险: - workspacePath.ts:isRemoteWorkspacePath 不再依赖 isWindows 做前置短路。该模块运行在 渲染进程(nodeIntegration:false),那里 process.platform 不可用,isWindows 恒为 false,会让原有的 WSL/远程路径防护永远失效。去掉该短路后,防护真正生效,从源头避免对 WSL 目录自动加载工作区与递归监听。UNC 路径(\\wsl.localhost\、\\wsl$\、\\server\share) 本身是 Windows 专有写法,不会出现在其他平台的合法路径里,无需平台判断即可安全识别。 - ipcBridge.ts:给 file watcher 与 directoryWatcher 各补一个 "error" 监听。chokidar 出错时会 emit "error",没有监听器时会冒泡成主进程 uncaughtException。补上后任何 watcher 错误都只记录、不再使应用崩溃,对所有文件系统(不止 WSL)都更健壮。 注:本次修复保证不再崩溃,但 WSL 文件暂时不会自动加载左侧文件树(防护跳过了)。后续会做 第二层改进,让 WSL 文件也能正常显示文件树。 * docs: 新增 WSL/远程文件监听第二层实施规约 (草案 v2) * docs(wsl-l2): 记录 §0 前置实测结果(A/B PASS) + 按实测修订 symlink 分支 §0 实测 2026-06-06 (Debian11 / Node v22 / Windows host): A 完整建树无 EISDIR -- PASS: - 生产 scanDirectory 在含 symlink/.so链/大目录/深目录 的 WSL 树上建 115 节点, 0 吞错 / 0 EISDIR / 0 uncaught - 新发现: WSL symlink 从 Windows 侧完全读不透 lstat/readlink=EISDIR, stat(跟随)/readFile=ENOENT (含指向真实存在的目标) -> symlink 仅能由 readdir d_type 识别, 拿不到 mtime, 无法解引用/打开 B fs.watchFile 在 9P 可靠性 -- PASS (全绿): 追加 / 原子替换(mv,新inode) / 同mtime仅改size(size触发) / 删除(mtime=0) / 删后重建(跟踪存活) / 同秒多写 全部 DETECTED 据 A 缩范围 (spec §4.3/§6.2/§8): - WSL 分支默认丢弃 symlink (避免死节点/点开 ENOENT), 重指向不可知文档化为局限 - isSymbolicLink() 分支只对 local/SMB 有效 - WSL symlink inert 显示移入 §8 defer * feat(wsl-l2): WSL/远程文件树轮询监听(撤销第一层跳过) 第二层:撤销第一层对 WSL/远程路径的"跳过自动加载工作区",让 WSL 文件也能 显示文件树并在外部变更时提示重载。事件式 fs.watch 在 9P 上启动即 EISDIR, 故 WSL 改走轮询;本地/SMB 保持 chokidar 实时。依据 §0 前置实测(已 PASS)。 新增 src/main/wslWatch.ts(不依赖 electron,回调注入,便于单测): - 路径分流 classifyWatchTarget / normalizeWatchPath(还原 \\?\UNC\ 防误判重开 EISDIR) - scanDirectory(WSL 丢弃读不透的 symlink;本地/SMB 加 isSymbolicLink 分支) - WslDirectoryWatcher:per-dir interval + in-flight guard + epoch 丢弃迟到结果 + 快照(path+mtime+size)diff + 不可达不清树 + 300ms debounce - WslFileWatcher:per-window union 引用计数 + fs.watchFile + mtime=0 删除语义 + size 兜底 + focus checkWindow 兜底 + before-quit dispose 接入 ipcBridge.ts:watchDirectory/file:watch 按 classify 分流;getDirectoryFiles 改用模块 scanDirectory;app browser-window-focus / before-quit / 窗口关闭清理。 renderer:workspacePath.shouldAutoLoadWorkspace 撤销远程跳过;useWorkSpace 远程 空树也置锁(避免 tabs 变化反复重扫慢 9P) + refreshWorkSpace 加 in-flight 互斥。 测试:node:test 22 个(纯函数 + 两个管理器,假依赖) 全过;另用真实 fs.watchFile + snapshotDirectory 在真 \\wsl.localhost\ 集成验证 9/9(目录增删/文件追加替换/focus/ 引用计数)。新增 pnpm test 脚本。 注:GUI 视觉确认(文件树渲染 / 重载弹框)需本机 pnpm run dev 验证。
1 parent 75c86a6 commit b889dc9

8 files changed

Lines changed: 990 additions & 122 deletions

File tree

docs/spec-wsl-file-watching-layer2.md

Lines changed: 188 additions & 0 deletions
Large diffs are not rendered by default.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"dist:win-x64": "pnpm run generate-icons && pnpm run build && electron-builder --win --x64",
3131
"lint": "oxlint . && oxfmt --check .",
3232
"format": "oxlint --fix . && oxfmt .",
33+
"test": "node --experimental-strip-types --test \"test/**/*.test.mjs\"",
3334
"prepare": "simple-git-hooks",
3435
"preinstall": "npx only-allow pnpm",
3536
"release": "genereleaselog",

src/main/ipcBridge.ts

Lines changed: 60 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import type { Block, ExportPDFOptions } from "./types";
55
import type { FileTraits } from "./fileFormat";
66
import { execSync } from "node:child_process";
77
import * as fs from "node:fs";
8-
import * as fsp from "node:fs/promises";
98
import path from "node:path";
109
import { fileURLToPath } from "node:url";
1110
import chokidar from "chokidar";
@@ -35,6 +34,13 @@ import {
3534
updateWindowOpenFiles,
3635
} from "./windowManager";
3736
import type { TearOffTabData } from "./windowManager";
37+
import {
38+
classifyWatchTarget,
39+
partitionPaths,
40+
scanDirectory,
41+
WslDirectoryWatcher,
42+
WslFileWatcher,
43+
} from "./wslWatch";
3844

3945
/** 每个窗口独立追踪保存状态(windowId → isSaved) */
4046
const windowSaveState = new Map<number, boolean>();
@@ -52,6 +58,8 @@ export function setIsQuitting(value: boolean): void {
5258
function cleanupWindowState(windowId: number): void {
5359
windowSaveState.delete(windowId);
5460
windowClosingSet.delete(windowId);
61+
// 窗口关闭:从 WSL 文件监听引用计数中移除(归零才真正 unwatchFile)
62+
wslFileWatcher.removeWindow(windowId);
5563
}
5664

5765
// 存储已监听的文件路径和对应的 watcher
@@ -63,6 +71,22 @@ let directoryWatcher: FSWatcher | null = null;
6371
let directoryChangedDebounceTimer: ReturnType<typeof setTimeout> | null = null;
6472
let imagePreviewWindow: BrowserWindow | null = null;
6573

74+
// ── WSL/远程文件监听第二层(轮询路线,见 docs/spec-wsl-file-watching-layer2.md)──
75+
// 广播到所有编辑器窗口
76+
function broadcastToEditors(channel: string, ...args: unknown[]): void {
77+
for (const editorWin of getEditorWindows()) {
78+
if (!editorWin.isDestroyed()) editorWin.webContents.send(channel, ...args);
79+
}
80+
}
81+
// WSL 远程目录:setInterval 轮询快照(fs.watch 在 9P 上启动即 EISDIR,不可用)
82+
const wslDirWatcher = new WslDirectoryWatcher({
83+
onChanged: () => broadcastToEditors("workspace:directory-changed"),
84+
});
85+
// WSL 远程已打开文件:fs.watchFile 轮询 + 引用计数
86+
const wslFileWatcher = new WslFileWatcher({
87+
onChanged: (filePath) => broadcastToEditors("file:changed", filePath),
88+
});
89+
6690
interface ImagePreviewItem {
6791
src: string;
6892
alt?: string;
@@ -1187,6 +1211,16 @@ export function registerIpcHandleHandlers() {
11871211
}
11881212
// 无需 win 的 ipc 处理
11891213
export function registerGlobalIpcHandlers() {
1214+
// WSL 第二层:窗口获得焦点时主动 stat 兜底(覆盖后台轮询被节流/切回窗口的时机,§4.4)
1215+
app.on("browser-window-focus", (_event, win) => {
1216+
void wslFileWatcher.checkWindow(win.id);
1217+
});
1218+
// 退出清理:fs.watchFile 的 StatWatcher 是 ref 句柄,残留会阻止进程正常退出(§4.2)
1219+
app.on("before-quit", () => {
1220+
wslFileWatcher.dispose();
1221+
wslDirWatcher.unwatchAll();
1222+
});
1223+
11901224
ipcMain.handle("clipboard:writeText", async (_event, text: string): Promise<boolean> => {
11911225
clipboard.writeText(text ?? "");
11921226
return true;
@@ -1465,115 +1499,14 @@ export function registerGlobalIpcHandlers() {
14651499
}
14661500
});
14671501

1468-
// 获取目录下的文件列表(树形结构)
1502+
// 获取目录下的文件列表(树形结构)。WSL 远程目录会丢弃 symlink(读不透,见 wslWatch/§0-A)。
14691503
ipcMain.handle("workspace:getDirectoryFiles", async (_event, dirPath: string) => {
14701504
try {
14711505
if (!dirPath || !fs.existsSync(dirPath)) {
14721506
return [];
14731507
}
1474-
1475-
interface WorkSpace {
1476-
name: string;
1477-
path: string;
1478-
isDirectory: boolean;
1479-
mtime: number;
1480-
children?: WorkSpace[];
1481-
}
1482-
1483-
// 性能优化配置
1484-
const MAX_DEPTH = 10; // 最大扫描深度
1485-
const MAX_FILES_PER_DIR = 100; // 每个目录最大文件数
1486-
const IGNORE_PATTERNS = [
1487-
/^\.git$/,
1488-
/^\.vscode$/,
1489-
/^\.idea$/,
1490-
/^node_modules$/,
1491-
/^\.next$/,
1492-
/^\.nuxt$/,
1493-
/^dist$/,
1494-
/^build$/,
1495-
/^coverage$/,
1496-
/^\.DS_Store$/,
1497-
/^Thumbs\.db$/,
1498-
];
1499-
1500-
function shouldIgnoreDirectory(name: string): boolean {
1501-
return IGNORE_PATTERNS.some((pattern) => pattern.test(name));
1502-
}
1503-
1504-
function isSupportedWorkspaceFile(name: string): boolean {
1505-
return /\.(?:md|markdown|png|jpe?g|gif|webp|svg|bmp)$/i.test(name);
1506-
}
1507-
1508-
async function getMtimeMs(targetPath: string): Promise<number> {
1509-
try {
1510-
const stat = await fsp.stat(targetPath);
1511-
return stat.mtimeMs;
1512-
} catch {
1513-
return 0;
1514-
}
1515-
}
1516-
1517-
async function scanDirectory(currentPath: string, depth: number = 0): Promise<WorkSpace[]> {
1518-
// 限制扫描深度
1519-
if (depth > MAX_DEPTH) {
1520-
return [];
1521-
}
1522-
1523-
try {
1524-
const items = await fsp.readdir(currentPath, { withFileTypes: true });
1525-
1526-
// 限制每个目录的文件数量
1527-
if (items.length > MAX_FILES_PER_DIR) {
1528-
console.warn(`目录 ${currentPath} 包含过多文件 (${items.length}),已限制扫描`);
1529-
items.splice(MAX_FILES_PER_DIR);
1530-
}
1531-
1532-
// 先添加文件夹,再添加文件
1533-
const directories: WorkSpace[] = [];
1534-
const files: WorkSpace[] = [];
1535-
1536-
for (const item of items) {
1537-
const itemPath = path.join(currentPath, item.name);
1538-
1539-
if (item.isDirectory()) {
1540-
// 跳过忽略的目录
1541-
if (shouldIgnoreDirectory(item.name)) {
1542-
continue;
1543-
}
1544-
1545-
const children = await scanDirectory(itemPath, depth + 1);
1546-
const dirMtime = await getMtimeMs(itemPath);
1547-
directories.push({
1548-
name: item.name,
1549-
path: itemPath,
1550-
isDirectory: true,
1551-
mtime: dirMtime,
1552-
children,
1553-
});
1554-
} else if (item.isFile() && isSupportedWorkspaceFile(item.name)) {
1555-
const fileMtime = await getMtimeMs(itemPath);
1556-
files.push({
1557-
name: item.name,
1558-
path: itemPath,
1559-
isDirectory: false,
1560-
mtime: fileMtime,
1561-
});
1562-
}
1563-
}
1564-
1565-
// 按名称排序
1566-
directories.sort((a, b) => a.name.localeCompare(b.name));
1567-
files.sort((a, b) => a.name.localeCompare(b.name));
1568-
1569-
return [...directories, ...files];
1570-
} catch (error) {
1571-
console.warn(`扫描目录失败: ${currentPath}`, error);
1572-
return [];
1573-
}
1574-
}
1575-
1576-
return await scanDirectory(dirPath);
1508+
const isWslRemote = classifyWatchTarget(dirPath) === "wsl";
1509+
return await scanDirectory(dirPath, { isWslRemote });
15771510
} catch (error) {
15781511
console.error("获取目录文件失败:", error);
15791512
return [];
@@ -1595,10 +1528,14 @@ export function registerGlobalIpcHandlers() {
15951528
const win = BrowserWindow.fromWebContents(event.sender);
15961529
if (win) updateWindowOpenFiles(win.id, filePaths);
15971530

1598-
// 先差异对比
1599-
const newFiles = filePaths.filter((filePath) => !watchedFiles.has(filePath));
1531+
// 按来源分流:WSL 远程走轮询(per-window 引用计数),本地/SMB 沿用 chokidar
1532+
const { wsl: wslPaths, chokidar: localPaths } = partitionPaths(filePaths);
1533+
if (win) wslFileWatcher.setWindowFiles(win.id, wslPaths);
1534+
1535+
// 以下仅处理本地/SMB 路径(chokidar)
1536+
const newFiles = localPaths.filter((filePath) => !watchedFiles.has(filePath));
16001537
const removedFiles = Array.from(watchedFiles).filter(
1601-
(filePath) => !filePaths.includes(filePath)
1538+
(filePath) => !localPaths.includes(filePath)
16021539
);
16031540

16041541
// 如果 watcher 不存在,创建它并设置事件监听
@@ -1644,13 +1581,25 @@ export function registerGlobalIpcHandlers() {
16441581

16451582
// 监听目录变化(用于文件列表自动刷新)
16461583
ipcMain.on("workspace:watchDirectory", (_event, dirPath: string) => {
1647-
// 先关闭旧的 watcher
1584+
// 先关闭旧的 chokidar watcher
16481585
if (directoryWatcher) {
16491586
directoryWatcher.close();
16501587
directoryWatcher = null;
16511588
}
16521589

1653-
if (!dirPath || !fs.existsSync(dirPath)) return;
1590+
if (!dirPath || !fs.existsSync(dirPath)) {
1591+
wslDirWatcher.unwatchAll();
1592+
return;
1593+
}
1594+
1595+
// WSL 远程目录:setInterval 轮询,不创建 chokidar(fs.watch 在 9P 上启动即 EISDIR)
1596+
if (classifyWatchTarget(dirPath) === "wsl") {
1597+
wslDirWatcher.watch(dirPath);
1598+
return;
1599+
}
1600+
1601+
// 本地 / SMB:沿用 chokidar 实时监听
1602+
wslDirWatcher.unwatchAll();
16541603

16551604
const IGNORE_DIRS =
16561605
/(?:^|[/\\])(?:\.git|\.vscode|\.idea|node_modules|\.next|\.nuxt|dist|build|coverage)(?:[/\\]|$)/;
@@ -1688,6 +1637,7 @@ export function registerGlobalIpcHandlers() {
16881637

16891638
// 停止监听目录
16901639
ipcMain.on("workspace:unwatchDirectory", () => {
1640+
wslDirWatcher.unwatchAll();
16911641
if (directoryWatcher) {
16921642
directoryWatcher.close();
16931643
directoryWatcher = null;

0 commit comments

Comments
 (0)