From 0483370f3b40a3835fbf4b10e3c96288bf607613 Mon Sep 17 00:00:00 2001 From: shawn Date: Sun, 29 Mar 2026 23:13:25 +0800 Subject: [PATCH 1/2] feat: auto-refresh worktree list via filesystem watcher Watch .git/worktrees/ for changes using the `notify` crate (FSEvents on macOS) so the worktree list updates automatically when worktrees are added or removed externally (e.g. via terminal). Also adds a manual refresh button in the worktree toolbar as a fallback. Co-Authored-By: Claude Opus 4.6 (1M context) --- src-tauri/Cargo.lock | 78 +++++++++++++++++++++++++++++++++++ src-tauri/Cargo.toml | 1 + src-tauri/src/lib.rs | 19 +++++++-- src-tauri/src/watcher.rs | 88 ++++++++++++++++++++++++++++++++++++++++ src/App.tsx | 28 +++++++++++++ src/locales/en.ts | 1 + src/locales/types.ts | 1 + src/locales/zh-CN.ts | 1 + src/styles.css | 5 +++ 9 files changed, 218 insertions(+), 4 deletions(-) create mode 100644 src-tauri/src/watcher.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index f3fbb1d..b35aae7 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1155,6 +1155,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 = "futf" version = "0.1.5" @@ -1516,6 +1525,7 @@ version = "0.10.1" dependencies = [ "chrono", "clap", + "notify", "serde", "serde_json", "sha2", @@ -1943,6 +1953,26 @@ dependencies = [ "cfb", ] +[[package]] +name = "inotify" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199" +dependencies = [ + "bitflags 2.11.0", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -2100,6 +2130,26 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + [[package]] name = "kuchikiki" version = "0.8.8-speedreader" @@ -2289,6 +2339,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", + "log", "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.61.2", ] @@ -2366,6 +2417,33 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.11.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.11.0", +] + [[package]] name = "num-conv" version = "0.2.1" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 359c31e..638d825 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -24,6 +24,7 @@ tauri-plugin-opener = "2" tauri-plugin-process = "2" tauri-plugin-single-instance = "2" tauri-plugin-updater = "2" +notify = "8" tempfile = "3" thiserror = "2" toml = "0.8" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6b1078b..b90fae1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,6 +5,7 @@ mod git; mod models; mod platform; mod store; +mod watcher; use actions::{ create_worktree, dispose_execution_session, get_execution_session, launch_worktree, @@ -48,12 +49,21 @@ fn bootstrap(state: State<'_, SharedState>) -> BootstrapResponse { #[tauri::command] async fn open_repo(app: AppHandle, repo_root: String) -> Result { - tauri::async_runtime::spawn_blocking(move || { - let state = app.state::(); - load_repo_snapshot(&app, &state, repo_root) + let snapshot = tauri::async_runtime::spawn_blocking({ + let app = app.clone(); + move || { + let state = app.state::(); + load_repo_snapshot(&app, &state, repo_root) + } }) .await - .map_err(|e| e.to_string())? + .map_err(|e| e.to_string())??; + + // Start watching this repo's .git/worktrees/ for external changes + let watcher_state = app.state::(); + watcher_state.start(&app, &snapshot.repo_root); + + Ok(snapshot) } pub fn load_repo_snapshot( @@ -549,6 +559,7 @@ pub fn run() { .show_tray_icon .unwrap_or(true); app.manage(state); + app.manage(watcher::WatcherState::new()); if tray_enabled { setup_tray(app.handle()); } diff --git a/src-tauri/src/watcher.rs b/src-tauri/src/watcher.rs new file mode 100644 index 0000000..e5a64d2 --- /dev/null +++ b/src-tauri/src/watcher.rs @@ -0,0 +1,88 @@ +use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; +use std::path::PathBuf; +use std::sync::Mutex; +use std::time::Instant; +use tauri::{AppHandle, Emitter}; + +pub struct WatcherState { + inner: Mutex>, +} + +struct WatcherInner { + _watcher: RecommendedWatcher, + _repo_root: String, +} + +impl WatcherState { + pub fn new() -> Self { + Self { + inner: Mutex::new(None), + } + } + + /// Stop the current watcher and start watching the given repo's `.git/worktrees/` directory. + /// If `.git/worktrees/` doesn't exist yet, watches `.git/` to detect its creation. + pub fn start(&self, app: &AppHandle, repo_root: &str) { + let mut guard = self.inner.lock().unwrap(); + + // Stop existing watcher (dropped automatically) + *guard = None; + + let git_dir = PathBuf::from(repo_root).join(".git"); + if !git_dir.is_dir() { + // Bare repo or not a git repo — skip watching + return; + } + + let worktrees_dir = git_dir.join("worktrees"); + let app_handle = app.clone(); + let repo_root_owned = repo_root.to_string(); + + // Debounce: ignore events within 500ms of the last emitted event + let last_emit = std::sync::Arc::new(Mutex::new(Instant::now() - std::time::Duration::from_secs(10))); + let last_emit_clone = last_emit.clone(); + + let mut watcher = match notify::recommended_watcher(move |res: Result| { + let Ok(event) = res else { return }; + + match event.kind { + EventKind::Create(_) | EventKind::Remove(_) => {} + _ => return, + } + + // If .git/worktrees/ was just created, we'll pick it up on next open_repo. + // For now, just emit the change signal for any create/remove in watched dirs. + let mut last = last_emit_clone.lock().unwrap(); + if last.elapsed() < std::time::Duration::from_millis(500) { + return; + } + *last = Instant::now(); + drop(last); + + let _ = app_handle.emit("worktrees-changed", &repo_root_owned); + }) { + Ok(w) => w, + Err(e) => { + eprintln!("[grove] failed to create fs watcher: {e}"); + return; + } + }; + + // Watch .git/worktrees/ if it exists, otherwise watch .git/ to detect its creation + let watch_path = if worktrees_dir.is_dir() { + &worktrees_dir + } else { + &git_dir + }; + + if let Err(e) = watcher.watch(watch_path, RecursiveMode::NonRecursive) { + eprintln!("[grove] failed to watch {}: {e}", watch_path.display()); + return; + } + + *guard = Some(WatcherInner { + _watcher: watcher, + _repo_root: repo_root.to_string(), + }); + } +} diff --git a/src/App.tsx b/src/App.tsx index 6484c30..1d5df20 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -217,6 +217,21 @@ export default function App({ repoPath }: { repoPath: string }) { }; }, []); + // Listen for worktrees-changed event from filesystem watcher + useEffect(() => { + let unlisten: UnlistenFn | null = null; + void listen("worktrees-changed", () => { + if (repo?.repoRoot) { + void loadRepoInner(repo.repoRoot); + } + }).then((fn) => { + unlisten = fn; + }); + return () => { + if (unlisten) unlisten(); + }; + }); // re-subscribe when repo changes so closure captures latest repo + // Show toast when update is detected useEffect(() => { if (updateInfo) { @@ -756,6 +771,19 @@ export default function App({ repoPath }: { repoPath: string }) { > {t.prune} +
{repo.worktrees.map((wt) => ( diff --git a/src/locales/en.ts b/src/locales/en.ts index 7143207..0733ffa 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -87,6 +87,7 @@ export const en: Translations = { worktreeCount: (n) => `${n} worktrees`, baseBranch: "base", prune: "Prune", + refresh: "Refresh", pruneConfirmTitle: "Prune Worktrees", pruneDescription: "Prune removes stale worktree metadata — entries left behind when a worktree directory was deleted manually without using git worktree remove.", pruneNoCandidates: "No stale worktree metadata found. Everything is clean.", diff --git a/src/locales/types.ts b/src/locales/types.ts index 3da667d..5d4ba9b 100644 --- a/src/locales/types.ts +++ b/src/locales/types.ts @@ -85,6 +85,7 @@ export interface Translations { worktreeCount: (n: number) => string; baseBranch: string; prune: string; + refresh: string; pruneConfirmTitle: string; pruneDescription: string; pruneNoCandidates: string; diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts index 8b8231a..0e048c7 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -87,6 +87,7 @@ export const zhCN: Translations = { worktreeCount: (n) => `${n} 个 Worktree`, baseBranch: "基础分支", prune: "Prune", + refresh: "刷新", pruneConfirmTitle: "Prune Worktree", pruneDescription: "Prune 会移除过期的 Worktree 元数据记录——当 Worktree 目录被手动删除(未使用 git worktree remove)时残留的条目。", pruneNoCandidates: "没有发现过期的 Worktree 元数据,一切正常。", diff --git a/src/styles.css b/src/styles.css index 4e0bfab..ffbc630 100644 --- a/src/styles.css +++ b/src/styles.css @@ -494,6 +494,11 @@ pre { display: flex; gap: 8px; padding-bottom: 10px; + align-items: center; +} + +.refresh-button { + margin-left: auto; } .repo-name-label { From 2b9a7b4d887ab0ac87bc85f1f2a0dc4e7f3e8610 Mon Sep 17 00:00:00 2001 From: shawn Date: Sun, 29 Mar 2026 23:47:57 +0800 Subject: [PATCH 2/2] fix: remove refresh button background and add spin animation on click Co-Authored-By: Claude Opus 4.6 (1M context) --- src/App.tsx | 2 +- src/styles.css | 13 ++++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 1d5df20..a453e74 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -777,7 +777,7 @@ export default function App({ repoPath }: { repoPath: string }) { disabled={isBusy} title={t.refresh} > - + diff --git a/src/styles.css b/src/styles.css index ffbc630..5a34c18 100644 --- a/src/styles.css +++ b/src/styles.css @@ -497,8 +497,19 @@ pre { align-items: center; } -.refresh-button { +.ghost-button.refresh-button { margin-left: auto; + background: none; + padding: 6px; +} + +.refresh-button .spin { + animation: spin 0.6s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } } .repo-name-label {