From 579c1df30b9884c0853fe84df23f97705271d42c Mon Sep 17 00:00:00 2001 From: icatme Date: Mon, 20 Jul 2026 18:36:01 +0800 Subject: [PATCH] fix drive-root startup hang --- src/app.tsx | 7 +++- src/hooks/use-folder-watcher.ts | 9 ++++- src/lib/index.ts | 7 +++- src/lib/storage.ts | 43 +++++++++++++++++++++++ src/locales/de.json | 1 + src/locales/en.json | 1 + src/locales/es.json | 1 + src/locales/fr.json | 1 + src/locales/it.json | 1 + src/locales/ja.json | 1 + src/locales/ko.json | 1 + src/locales/pt-BR.json | 1 + src/locales/zh-TW.json | 1 + src/locales/zh.json | 1 + src/main.tsx | 6 +++- tests/folder-watcher.test.ts | 11 +++++- tests/storage.test.ts | 60 +++++++++++++++++++++++++++++++++ 17 files changed, 148 insertions(+), 5 deletions(-) create mode 100644 tests/storage.test.ts diff --git a/src/app.tsx b/src/app.tsx index 51a5336..52eb6fa 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -38,6 +38,7 @@ import { getWritingDisplayVars, getContextBundleStats, getWhatsNewToastMessage, + isFilesystemRoot, isSupportedTextPath, markdownInsertion, normalizeProseFontFamily, @@ -628,9 +629,13 @@ export function App() { const handleOpenFolder = useCallback(async () => { const folder = await pickFolder(); if (!folder) return; + if (isFilesystemRoot(folder)) { + setLoadError({ message: t("app.folderRootUnsupported") }); + return; + } setFolders((prev) => (prev.includes(folder) ? prev : [...prev, folder])); setSidebarOpen(true); - }, [setFolders, setSidebarOpen]); + }, [setFolders, setLoadError, setSidebarOpen, t]); const handleOpenFile = useCallback(async () => { const file = await pickMarkdownFile(); diff --git a/src/hooks/use-folder-watcher.ts b/src/hooks/use-folder-watcher.ts index 073da5e..45efe4c 100644 --- a/src/hooks/use-folder-watcher.ts +++ b/src/hooks/use-folder-watcher.ts @@ -1,8 +1,15 @@ import { useEffect, useRef } from "react"; import { watch, type UnwatchFn, type WatchEvent } from "@tauri-apps/plugin-fs"; +import { isFilesystemRoot } from "@/lib/storage"; const WATCH_DEBOUNCE_MS = 350; +export function watchableFolderPaths(paths: readonly string[]): string[] { + return Array.from(new Set( + paths.filter((path) => path.length > 0 && !isFilesystemRoot(path)), + )); +} + export function isDirectoryChangeEvent(event: WatchEvent): boolean { if (event.type === "any") return true; if (typeof event.type === "string") return false; @@ -25,7 +32,7 @@ export function useFolderWatcher(paths: readonly string[], onChange: () => void) useEffect(() => { let disposed = false; const unwatchers = new Set(); - const uniquePaths = Array.from(new Set(paths.filter(Boolean))); + const uniquePaths = watchableFolderPaths(paths); const start = async () => { for (const path of uniquePaths) { diff --git a/src/lib/index.ts b/src/lib/index.ts index 0db527d..93863b7 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -21,7 +21,12 @@ export { type ThemeGroup, type ThemeMode, } from "./theme"; -export { STORAGE_KEYS, type StorageKey } from "./storage"; +export { + clearUnsafeFolderRestoreState, + isFilesystemRoot, + STORAGE_KEYS, + type StorageKey, +} from "./storage"; export { I18nProvider, LANGUAGE_CHOICES, diff --git a/src/lib/storage.ts b/src/lib/storage.ts index 23e8b5c..17b9e60 100644 --- a/src/lib/storage.ts +++ b/src/lib/storage.ts @@ -24,3 +24,46 @@ export const STORAGE_KEYS = { } as const; export type StorageKey = (typeof STORAGE_KEYS)[keyof typeof STORAGE_KEYS]; + +type FolderSessionStorage = Pick; + +export function isFilesystemRoot(path: string): boolean { + const normalized = path.trim(); + return /^[/\\]+$/.test(normalized) || /^[A-Za-z]:[/\\]*$/.test(normalized); +} + +/** + * Removes a poisoned folder session before React restores either the current + * multi-folder state or its legacy single-folder fallback. + */ +export function clearUnsafeFolderRestoreState(storage: FolderSessionStorage): boolean { + let folders: unknown; + let lastFolder: unknown; + try { + const foldersRaw = storage.getItem(STORAGE_KEYS.folders); + const lastFolderRaw = storage.getItem(STORAGE_KEYS.lastFolder); + folders = foldersRaw == null ? null : JSON.parse(foldersRaw); + lastFolder = lastFolderRaw == null ? null : JSON.parse(lastFolderRaw); + } catch { + return false; + } + + const hasUnsafeFolder = Array.isArray(folders) + && folders.some((path) => typeof path === "string" && isFilesystemRoot(path)); + const hasUnsafeFallback = typeof lastFolder === "string" && isFilesystemRoot(lastFolder); + if (!hasUnsafeFolder && !hasUnsafeFallback) return false; + + // These keys fall back to each other during hydration, so they must be + // removed together or the drive root will be restored again. + try { + storage.removeItem(STORAGE_KEYS.folders); + } catch { + // Continue so the fallback key is still cleared. + } + try { + storage.removeItem(STORAGE_KEYS.lastFolder); + } catch { + // Storage failures are non-fatal; the watcher guard remains authoritative. + } + return true; +} diff --git a/src/locales/de.json b/src/locales/de.json index a07d91f..a2f48d4 100644 --- a/src/locales/de.json +++ b/src/locales/de.json @@ -9,6 +9,7 @@ "app.openFile": "Datei öffnen", "app.openFileShortcut": "Datei öffnen (⌘O)", "app.openFolder": "Ordner öffnen", + "app.folderRootUnsupported": "Wähle einen Unterordner; Dateisystem-Stammverzeichnisse können nicht geöffnet werden", "app.openFolderShortcut": "Ordner öffnen (⌘⇧O)", "app.close": "schließen", "app.closeEsc": "schließen (esc)", diff --git a/src/locales/en.json b/src/locales/en.json index 739dd47..496f85b 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -9,6 +9,7 @@ "app.openFile": "open file", "app.openFileShortcut": "open file (⌘O)", "app.openFolder": "open folder", + "app.folderRootUnsupported": "choose a subfolder; filesystem roots cannot be opened", "app.openFolderShortcut": "open folder (⌘⇧O)", "app.close": "close", "app.closeEsc": "close (esc)", diff --git a/src/locales/es.json b/src/locales/es.json index 5481d11..66b00f6 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -9,6 +9,7 @@ "app.openFile": "abrir archivo", "app.openFileShortcut": "abrir archivo (⌘O)", "app.openFolder": "abrir carpeta", + "app.folderRootUnsupported": "elige una subcarpeta; no se puede abrir la raíz del sistema de archivos", "app.openFolderShortcut": "abrir carpeta (⌘⇧O)", "app.close": "cerrar", "app.closeEsc": "cerrar (esc)", diff --git a/src/locales/fr.json b/src/locales/fr.json index c7510a7..82a952e 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -9,6 +9,7 @@ "app.openFile": "ouvrir un fichier", "app.openFileShortcut": "ouvrir un fichier (⌘O)", "app.openFolder": "ouvrir un dossier", + "app.folderRootUnsupported": "choisissez un sous-dossier ; la racine du système de fichiers ne peut pas être ouverte", "app.openFolderShortcut": "ouvrir un dossier (⌘⇧O)", "app.close": "fermer", "app.closeEsc": "fermer (esc)", diff --git a/src/locales/it.json b/src/locales/it.json index 0796eb4..91bc5f4 100644 --- a/src/locales/it.json +++ b/src/locales/it.json @@ -9,6 +9,7 @@ "app.openFile": "apri file", "app.openFileShortcut": "apri file (⌘O)", "app.openFolder": "apri cartella", + "app.folderRootUnsupported": "scegli una sottocartella; non è possibile aprire la radice del file system", "app.openFolderShortcut": "apri cartella (⌘⇧O)", "app.close": "chiudi", "app.closeEsc": "chiudi (esc)", diff --git a/src/locales/ja.json b/src/locales/ja.json index cd933ff..7b2633e 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -9,6 +9,7 @@ "app.openFile": "ファイルを開く", "app.openFileShortcut": "ファイルを開く (⌘O)", "app.openFolder": "フォルダを開く", + "app.folderRootUnsupported": "サブフォルダを選択してください。ファイルシステムのルートは開けません", "app.openFolderShortcut": "フォルダを開く (⌘⇧O)", "app.close": "閉じる", "app.closeEsc": "閉じる (esc)", diff --git a/src/locales/ko.json b/src/locales/ko.json index 32fc562..0676eb9 100644 --- a/src/locales/ko.json +++ b/src/locales/ko.json @@ -9,6 +9,7 @@ "app.openFile": "파일 열기", "app.openFileShortcut": "파일 열기 (⌘O)", "app.openFolder": "폴더 열기", + "app.folderRootUnsupported": "하위 폴더를 선택하세요. 파일 시스템 루트는 열 수 없습니다", "app.openFolderShortcut": "폴더 열기 (⌘⇧O)", "app.close": "닫기", "app.closeEsc": "닫기 (esc)", diff --git a/src/locales/pt-BR.json b/src/locales/pt-BR.json index c2f8689..d6211fd 100644 --- a/src/locales/pt-BR.json +++ b/src/locales/pt-BR.json @@ -9,6 +9,7 @@ "app.openFile": "abrir arquivo", "app.openFileShortcut": "abrir arquivo (⌘O)", "app.openFolder": "abrir pasta", + "app.folderRootUnsupported": "escolha uma subpasta; não é possível abrir a raiz do sistema de arquivos", "app.openFolderShortcut": "abrir pasta (⌘⇧O)", "app.close": "fechar", "app.closeEsc": "fechar (esc)", diff --git a/src/locales/zh-TW.json b/src/locales/zh-TW.json index 60f333f..abe0819 100644 --- a/src/locales/zh-TW.json +++ b/src/locales/zh-TW.json @@ -9,6 +9,7 @@ "app.openFile": "開啟檔案", "app.openFileShortcut": "開啟檔案(⌘O)", "app.openFolder": "開啟資料夾", + "app.folderRootUnsupported": "請選擇一個子資料夾,無法直接開啟檔案系統根目錄", "app.openFolderShortcut": "開啟資料夾(⌘⇧O)", "app.close": "關閉", "app.closeEsc": "關閉(Esc)", diff --git a/src/locales/zh.json b/src/locales/zh.json index bbd6aaa..9b74935 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -9,6 +9,7 @@ "app.openFile": "打开文件", "app.openFileShortcut": "打开文件 (⌘O)", "app.openFolder": "打开文件夹", + "app.folderRootUnsupported": "请选择一个子文件夹,不能直接打开文件系统根目录", "app.openFolderShortcut": "打开文件夹 (⌘⇧O)", "app.close": "关闭", "app.closeEsc": "关闭 (esc)", diff --git a/src/main.tsx b/src/main.tsx index fb372c4..c43b7a7 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,7 +2,7 @@ import React from "react"; import ReactDOM from "react-dom/client"; import { App } from "./app"; import { PreviewWindow } from "./components/editor"; -import { I18nProvider } from "./lib"; +import { clearUnsafeFolderRestoreState, I18nProvider } from "./lib"; import "./styles/globals.css"; // platform class on — lets CSS gate macOS-only chrome (traffic-light @@ -18,6 +18,10 @@ const platformClass = /Mac|iPhone|iPad|iPod/i.test(ua) : "is-unknown"; // no platform-specific chrome applied — safe default document.documentElement.classList.add(platformClass); +// Run before any persisted-state hooks mount. A drive root in either folder +// key otherwise falls back through the other key and starts a recursive watch. +clearUnsafeFolderRestoreState(window.localStorage); + const params = new URLSearchParams(window.location.search); const Root = params.get("window") === "preview" ? PreviewWindow : App; diff --git a/tests/folder-watcher.test.ts b/tests/folder-watcher.test.ts index e7486f4..2c8c468 100644 --- a/tests/folder-watcher.test.ts +++ b/tests/folder-watcher.test.ts @@ -1,5 +1,14 @@ import { expect, test } from "bun:test"; -import { isDirectoryChangeEvent } from "../src/hooks/use-folder-watcher"; +import { + isDirectoryChangeEvent, + watchableFolderPaths, +} from "../src/hooks/use-folder-watcher"; + +test("does not recursively watch filesystem roots", () => { + expect(watchableFolderPaths(["V:\\", "V:\\notes", "V:\\notes", "/"])).toEqual([ + "V:\\notes", + ]); +}); test("refreshes the tree for folder creation, removal, and rename events", () => { expect(isDirectoryChangeEvent({ type: { create: { kind: "folder" } }, paths: [], attrs: null })).toBe(true); diff --git a/tests/storage.test.ts b/tests/storage.test.ts new file mode 100644 index 0000000..00962a5 --- /dev/null +++ b/tests/storage.test.ts @@ -0,0 +1,60 @@ +import { expect, test } from "bun:test"; +import { + clearUnsafeFolderRestoreState, + isFilesystemRoot, + STORAGE_KEYS, +} from "../src/lib/storage"; + +class MemoryStorage { + readonly values = new Map(); + + getItem(key: string): string | null { + return this.values.get(key) ?? null; + } + + removeItem(key: string): void { + this.values.delete(key); + } +} + +test("recognizes filesystem roots without rejecting normal folders", () => { + expect(isFilesystemRoot("V:\\")).toBe(true); + expect(isFilesystemRoot("v:/")).toBe(true); + expect(isFilesystemRoot("/")).toBe(true); + expect(isFilesystemRoot("V:\\notes")).toBe(false); + expect(isFilesystemRoot("/Users/notes")).toBe(false); +}); + +test("clears both folder keys when the folders list contains a drive root", () => { + const storage = new MemoryStorage(); + storage.values.set(STORAGE_KEYS.folders, JSON.stringify(["V:\\"])); + storage.values.set(STORAGE_KEYS.lastFolder, JSON.stringify("V:\\notes")); + storage.values.set(STORAGE_KEYS.lastFile, JSON.stringify("V:\\diagram.md")); + + expect(clearUnsafeFolderRestoreState(storage)).toBe(true); + expect(storage.getItem(STORAGE_KEYS.folders)).toBeNull(); + expect(storage.getItem(STORAGE_KEYS.lastFolder)).toBeNull(); + expect(storage.getItem(STORAGE_KEYS.lastFile)).toBe(JSON.stringify("V:\\diagram.md")); +}); + +test("clears both folder keys when only the legacy fallback contains a root", () => { + const storage = new MemoryStorage(); + storage.values.set(STORAGE_KEYS.folders, JSON.stringify(["V:\\notes"])); + storage.values.set(STORAGE_KEYS.lastFolder, JSON.stringify("V:\\")); + + expect(clearUnsafeFolderRestoreState(storage)).toBe(true); + expect(storage.getItem(STORAGE_KEYS.folders)).toBeNull(); + expect(storage.getItem(STORAGE_KEYS.lastFolder)).toBeNull(); +}); + +test("keeps a safe folder session unchanged", () => { + const storage = new MemoryStorage(); + const folders = JSON.stringify(["V:\\notes"]); + const lastFolder = JSON.stringify("V:\\notes"); + storage.values.set(STORAGE_KEYS.folders, folders); + storage.values.set(STORAGE_KEYS.lastFolder, lastFolder); + + expect(clearUnsafeFolderRestoreState(storage)).toBe(false); + expect(storage.getItem(STORAGE_KEYS.folders)).toBe(folders); + expect(storage.getItem(STORAGE_KEYS.lastFolder)).toBe(lastFolder); +}); \ No newline at end of file