Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
getWritingDisplayVars,
getContextBundleStats,
getWhatsNewToastMessage,
isFilesystemRoot,
isSupportedTextPath,
markdownInsertion,
normalizeProseFontFamily,
Expand Down Expand Up @@ -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();
Expand Down
9 changes: 8 additions & 1 deletion src/hooks/use-folder-watcher.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -25,7 +32,7 @@ export function useFolderWatcher(paths: readonly string[], onChange: () => void)
useEffect(() => {
let disposed = false;
const unwatchers = new Set<UnwatchFn>();
const uniquePaths = Array.from(new Set(paths.filter(Boolean)));
const uniquePaths = watchableFolderPaths(paths);

const start = async () => {
for (const path of uniquePaths) {
Expand Down
7 changes: 6 additions & 1 deletion src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
43 changes: 43 additions & 0 deletions src/lib/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,46 @@ export const STORAGE_KEYS = {
} as const;

export type StorageKey = (typeof STORAGE_KEYS)[keyof typeof STORAGE_KEYS];

type FolderSessionStorage = Pick<Storage, "getItem" | "removeItem">;

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;
}
1 change: 1 addition & 0 deletions src/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
1 change: 1 addition & 0 deletions src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
1 change: 1 addition & 0 deletions src/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
1 change: 1 addition & 0 deletions src/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
1 change: 1 addition & 0 deletions src/locales/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
1 change: 1 addition & 0 deletions src/locales/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"app.openFile": "ファイルを開く",
"app.openFileShortcut": "ファイルを開く (⌘O)",
"app.openFolder": "フォルダを開く",
"app.folderRootUnsupported": "サブフォルダを選択してください。ファイルシステムのルートは開けません",
"app.openFolderShortcut": "フォルダを開く (⌘⇧O)",
"app.close": "閉じる",
"app.closeEsc": "閉じる (esc)",
Expand Down
1 change: 1 addition & 0 deletions src/locales/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"app.openFile": "파일 열기",
"app.openFileShortcut": "파일 열기 (⌘O)",
"app.openFolder": "폴더 열기",
"app.folderRootUnsupported": "하위 폴더를 선택하세요. 파일 시스템 루트는 열 수 없습니다",
"app.openFolderShortcut": "폴더 열기 (⌘⇧O)",
"app.close": "닫기",
"app.closeEsc": "닫기 (esc)",
Expand Down
1 change: 1 addition & 0 deletions src/locales/pt-BR.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
1 change: 1 addition & 0 deletions src/locales/zh-TW.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"app.openFile": "開啟檔案",
"app.openFileShortcut": "開啟檔案(⌘O)",
"app.openFolder": "開啟資料夾",
"app.folderRootUnsupported": "請選擇一個子資料夾,無法直接開啟檔案系統根目錄",
"app.openFolderShortcut": "開啟資料夾(⌘⇧O)",
"app.close": "關閉",
"app.closeEsc": "關閉(Esc)",
Expand Down
1 change: 1 addition & 0 deletions src/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"app.openFile": "打开文件",
"app.openFileShortcut": "打开文件 (⌘O)",
"app.openFolder": "打开文件夹",
"app.folderRootUnsupported": "请选择一个子文件夹,不能直接打开文件系统根目录",
"app.openFolderShortcut": "打开文件夹 (⌘⇧O)",
"app.close": "关闭",
"app.closeEsc": "关闭 (esc)",
Expand Down
6 changes: 5 additions & 1 deletion src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <html> — lets CSS gate macOS-only chrome (traffic-light
Expand All @@ -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;

Expand Down
11 changes: 10 additions & 1 deletion tests/folder-watcher.test.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand Down
60 changes: 60 additions & 0 deletions tests/storage.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>();

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);
});