diff --git a/.reasonix/desktop-topic-title-sources.json b/.reasonix/desktop-topic-title-sources.json index 421631c..3562618 100644 --- a/.reasonix/desktop-topic-title-sources.json +++ b/.reasonix/desktop-topic-title-sources.json @@ -36,5 +36,6 @@ "topic_20260707-092315_b8c3939bfc696802": "auto", "topic_20260707-092711_af706c3fcc7e435e": "auto", "topic_20260707-095503_bf879b65e4666516": "auto", - "topic_20260708-020616_9ab54d98daf65053": "auto" + "topic_20260708-020616_9ab54d98daf65053": "auto", + "topic_20260708-093419_71d1fea2e4b5a40f": "auto" } \ No newline at end of file diff --git a/.reasonix/desktop-topic-titles.json b/.reasonix/desktop-topic-titles.json index d6054a9..cc4abdf 100644 --- a/.reasonix/desktop-topic-titles.json +++ b/.reasonix/desktop-topic-titles.json @@ -36,5 +36,6 @@ "topic_20260707-092315_b8c3939bfc696802": "当我点击目录树切换显示文件的时候,右…", "topic_20260707-092711_af706c3fcc7e435e": "帮我检查一下app.vue里的too…", "topic_20260707-095503_bf879b65e4666516": "帮我移除markdownEditor…", - "topic_20260708-020616_9ab54d98daf65053": "帮我分析一下现在的markdownE…" + "topic_20260708-020616_9ab54d98daf65053": "帮我分析一下现在的markdownE…", + "topic_20260708-093419_71d1fea2e4b5a40f": "新的会话" } \ No newline at end of file diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index c298629..230cc84 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1011,6 +1011,15 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -1695,6 +1704,26 @@ dependencies = [ "cfb", ] +[[package]] +name = "inotify" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd854a95a4ac672fed8c054136039fd32c22cf039ff09ead7280afe920486483" +dependencies = [ + "bitflags 2.13.0", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" +dependencies = [ + "libc", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -1837,6 +1866,26 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "kqueue" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.13.0", + "libc", +] + [[package]] name = "libappindicator" version = "0.9.0" @@ -1971,6 +2020,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -2026,6 +2076,33 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.13.0", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.13.0", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -4853,6 +4930,7 @@ dependencies = [ name = "yurou" version = "0.1.0" dependencies = [ + "notify", "serde", "serde_json", "tauri", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 896f77a..38181f5 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -24,4 +24,5 @@ tauri-plugin-opener = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" time = ">=0.3.0, <0.3.47" +notify = "8.2.0" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 288dd21..5a32317 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,5 +1,8 @@ // Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ +use notify::{RecursiveMode, Watcher}; use std::fs; +use std::path::PathBuf; +use std::sync::Mutex; use tauri::menu::{MenuBuilder, MenuItemBuilder, SubmenuBuilder}; use tauri::{Emitter, Manager}; use tauri_plugin_dialog::DialogExt; @@ -11,6 +14,24 @@ struct DirEntry { is_dir: bool, } +#[derive(Default)] +struct WorkspaceWatcherState { + watcher: Mutex>, +} + +#[derive(Debug, Clone, serde::Serialize)] +struct WorkspaceChangedPayload { + root: String, + paths: Vec, + kind: String, +} + +#[derive(Debug, Clone, serde::Serialize)] +struct WorkspaceWatchErrorPayload { + root: String, + message: String, +} + fn is_hidden_entry(entry: &fs::DirEntry) -> bool { let name = entry.file_name(); let is_dot_hidden = name.to_string_lossy().starts_with('.'); @@ -94,6 +115,73 @@ fn write_file(path: String, content: String) -> Result<(), String> { fs::write(&path, &content).map_err(|e| format!("无法保存文件: {}", e)) } +#[tauri::command] +fn watch_workspace( + app: tauri::AppHandle, + state: tauri::State<'_, WorkspaceWatcherState>, + path: String, +) -> Result<(), String> { + let watch_path = PathBuf::from(&path); + if !watch_path.is_dir() { + return Err("工作目录不存在或不是文件夹".to_string()); + } + + let root = watch_path.to_string_lossy().to_string(); + let event_root = root.clone(); + let error_root = root.clone(); + let app_handle = app.clone(); + let mut watcher = + notify::recommended_watcher(move |result: notify::Result| match result { + Ok(event) => { + if event.kind.is_access() { + return; + } + + let payload = WorkspaceChangedPayload { + root: event_root.clone(), + paths: event + .paths + .iter() + .map(|path| path.to_string_lossy().to_string()) + .collect(), + kind: format!("{:?}", event.kind), + }; + let _ = app_handle.emit("workspace-changed", payload); + } + Err(error) => { + let _ = app_handle.emit( + "workspace-watch-error", + WorkspaceWatchErrorPayload { + root: error_root.clone(), + message: error.to_string(), + }, + ); + } + }) + .map_err(|e| format!("无法监听工作目录: {}", e))?; + + watcher + .watch(&watch_path, RecursiveMode::Recursive) + .map_err(|e| format!("无法监听工作目录: {}", e))?; + + let mut current = state + .watcher + .lock() + .map_err(|_| "无法更新工作目录监听状态".to_string())?; + *current = Some(watcher); + Ok(()) +} + +#[tauri::command] +fn unwatch_workspace(state: tauri::State<'_, WorkspaceWatcherState>) -> Result<(), String> { + let mut current = state + .watcher + .lock() + .map_err(|_| "无法更新工作目录监听状态".to_string())?; + *current = None; + Ok(()) +} + #[tauri::command] fn pick_save_file(app: tauri::AppHandle) -> Option { app.dialog() @@ -112,12 +200,15 @@ pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) + .manage(WorkspaceWatcherState::default()) .invoke_handler(tauri::generate_handler![ greet, pick_folder, read_dir, read_file, write_file, + watch_workspace, + unwatch_workspace, pick_save_file ]) .setup(|app| { diff --git a/src/App.vue b/src/App.vue index e4008cc..d358fea 100644 --- a/src/App.vue +++ b/src/App.vue @@ -93,6 +93,17 @@ interface WorkspaceEntry { path: string; } +interface WorkspaceChangedPayload { + root: string; + paths: string[]; + kind: string; +} + +interface WorkspaceWatchErrorPayload { + root: string; + message: string; +} + const folderPath = ref(""); const dirEntries = ref([]); const LAST_WORKSPACE_PATH_KEY = "yurou:last-workspace-path"; @@ -100,6 +111,10 @@ const RECENT_WORKSPACES_KEY = "yurou:recent-workspaces"; const MAX_RECENT_WORKSPACES = 12; const recentWorkspaces = ref(loadRecentWorkspaces()); const editorResetKey = ref(0); +const WORKSPACE_REFRESH_DELAY_MS = 140; +let workspaceRefreshTimer: ReturnType | null = null; +let workspaceRefreshInFlight = false; +let workspaceRefreshQueued = false; // ---- editor zoom (Ctrl+scroll) ---- const ZOOM_MIN = 0.7; @@ -552,24 +567,49 @@ async function saveCurrentWorkspaceDocumentBeforeSwitch() { return doSave(contentToSave); } -function resetWorkspaceAndEditorState() { +function resetWorkspaceAndEditorState(options: { stopWatcher?: boolean } = {}) { + if (options.stopWatcher !== false) { + void stopWorkspaceWatcher(); + } else { + clearPendingWorkspaceRefresh(); + } folderPath.value = ""; dirEntries.value = []; resetMarkdownEditorState(); } +async function startWorkspaceWatcher(path: string) { + if (!isTauri()) return; + try { + await invoke("watch_workspace", {path}); + } catch (e) { + console.error("Failed to watch workspace:", e); + } +} + +async function stopWorkspaceWatcher() { + clearPendingWorkspaceRefresh(); + if (!isTauri()) return; + try { + await invoke("unwatch_workspace"); + } catch (e) { + console.error("Failed to stop watching workspace:", e); + } +} + async function switchWorkspace(path: string, options: { persist?: boolean; resetEditor?: boolean } = {}) { const normalized = normalizeWorkspacePath(path); if (options.resetEditor !== false) { const saved = await saveCurrentWorkspaceDocumentBeforeSwitch(); if (!saved) return false; - resetWorkspaceAndEditorState(); + resetWorkspaceAndEditorState({stopWatcher: false}); } const loaded = await loadDir(normalized); if (!loaded) { + await stopWorkspaceWatcher(); forgetWorkspace(normalized); const lastWorkspacePath = getLastWorkspacePath(); @@ -580,6 +620,7 @@ async function switchWorkspace(path: string, options: { persist?: boolean; reset return false; } + void startWorkspaceWatcher(normalized); rememberWorkspace(normalized); if (options.persist !== false) { @@ -680,6 +721,79 @@ function findEntry(entries: DirEntry[], path: string): DirEntry | null { return null; } +async function mergeLoadedDirectoryEntries(freshEntries: DirEntry[], currentEntries: DirEntry[]): Promise { + const currentByPath = new Map( + currentEntries.map((entry) => [getWorkspaceIdentity(entry.path), entry]), + ); + + return Promise.all( + freshEntries.map(async (freshEntry) => { + const currentEntry = currentByPath.get(getWorkspaceIdentity(freshEntry.path)); + if (!freshEntry.is_dir || currentEntry?.children === undefined) { + return freshEntry; + } + + try { + const children = await invoke("read_dir", {path: freshEntry.path}); + freshEntry.children = await mergeLoadedDirectoryEntries(children, currentEntry.children); + } catch (e) { + console.error("Failed to refresh subdirectory:", e); + freshEntry.children = currentEntry.children; + } + + return freshEntry; + }), + ); +} + +function clearPendingWorkspaceRefresh() { + if (workspaceRefreshTimer) { + clearTimeout(workspaceRefreshTimer); + workspaceRefreshTimer = null; + } + workspaceRefreshQueued = false; +} + +function scheduleWorkspaceRefresh(root = folderPath.value) { + const expectedRoot = normalizeWorkspacePath(root); + if (!expectedRoot) return; + + if (workspaceRefreshTimer) { + clearTimeout(workspaceRefreshTimer); + } + + workspaceRefreshTimer = setTimeout(() => { + workspaceRefreshTimer = null; + void refreshWorkspaceTree(expectedRoot); + }, WORKSPACE_REFRESH_DELAY_MS); +} + +async function refreshWorkspaceTree(expectedRoot = folderPath.value) { + const normalizedRoot = normalizeWorkspacePath(expectedRoot); + if (!normalizedRoot || !folderPath.value) return; + if (getWorkspaceIdentity(folderPath.value) !== getWorkspaceIdentity(normalizedRoot)) return; + + if (workspaceRefreshInFlight) { + workspaceRefreshQueued = true; + return; + } + + workspaceRefreshInFlight = true; + try { + const entries = await invoke("read_dir", {path: normalizedRoot}); + if (!folderPath.value || getWorkspaceIdentity(folderPath.value) !== getWorkspaceIdentity(normalizedRoot)) return; + dirEntries.value = await mergeLoadedDirectoryEntries(entries, dirEntries.value); + } catch (e) { + console.error("Failed to refresh workspace:", e); + } finally { + workspaceRefreshInFlight = false; + if (workspaceRefreshQueued) { + workspaceRefreshQueued = false; + scheduleWorkspaceRefresh(normalizedRoot); + } + } +} + async function onToggleFolder(path: string) { const entry = findEntry(dirEntries.value, path); if (!entry || entry.children !== undefined) return; @@ -793,6 +907,19 @@ registerTauriListener("folder-opened", (event) => { void switchWorkspace(event.payload); }); +registerTauriListener("workspace-changed", (event) => { + if (!folderPath.value) return; + if (getWorkspaceIdentity(event.payload.root) !== getWorkspaceIdentity(folderPath.value)) return; + scheduleWorkspaceRefresh(event.payload.root); +}); + +registerTauriListener("workspace-watch-error", (event) => { + if (!folderPath.value) return; + if (getWorkspaceIdentity(event.payload.root) !== getWorkspaceIdentity(folderPath.value)) return; + console.error("Workspace watcher error:", event.payload.message); + scheduleWorkspaceRefresh(event.payload.root); +}); + registerTauriListener("menu-save", () => { doSave(); });