diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 1a33d8c..294194c 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -856,6 +856,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" @@ -1122,6 +1131,7 @@ name = "gitchef" version = "0.28.2" dependencies = [ "git2", + "notify-debouncer-mini", "parking_lot", "serde", "serde_json", @@ -1577,6 +1587,26 @@ dependencies = [ "cfb", ] +[[package]] +name = "inotify" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff" +dependencies = [ + "bitflags 1.3.2", + "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" @@ -1741,6 +1771,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.12.1", + "libc", +] + [[package]] name = "leb128fmt" version = "0.1.0" @@ -1904,6 +1954,18 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.48.0", +] + [[package]] name = "mio" version = "1.2.1" @@ -1966,6 +2028,36 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "notify" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" +dependencies = [ + "bitflags 2.12.1", + "crossbeam-channel", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio 0.8.11", + "walkdir", + "windows-sys 0.48.0", +] + +[[package]] +name = "notify-debouncer-mini" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d40b221972a1fc5ef4d858a2f671fb34c75983eb385463dff3780eeff6a9d43" +dependencies = [ + "crossbeam-channel", + "log", + "notify", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -3800,7 +3892,7 @@ checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", - "mio", + "mio 1.2.1", "pin-project-lite", "socket2", "windows-sys 0.61.2", @@ -4691,6 +4783,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -4742,6 +4843,21 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -4799,6 +4915,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -4817,6 +4939,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -4835,6 +4963,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -4865,6 +4999,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -4883,6 +5023,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -4901,6 +5047,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -4919,6 +5071,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 79eb371..348ddbf 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -32,6 +32,12 @@ ureq = { version = "2", features = ["json"] } # project rule (guards returned directly, no poison-unwrap boilerplate). parking_lot = "0.12" thiserror = "2" +# Filesystem watcher for the .git dir, so external git ops (CLI, other tools) and +# checkouts/commits reflect instantly without a manual refresh. -mini wraps notify +# with built-in debounce, collapsing the write burst a single git op produces into +# one event. macOS backend is FSEvents (no openssl), so it cross-compiles like the +# rest. +notify-debouncer-mini = "0.4" [profile.dev] incremental = true diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0cedf04..d237b0a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,5 +1,6 @@ mod error; mod git; +mod watch; use error::AppResult; use git::{ @@ -205,6 +206,19 @@ fn fetch(repo: String) -> AppResult { ops::fetch(&open(&repo)?) } +/// Start watching a repo's git dir so external changes push a `repo-changed` +/// event to the UI. Idempotent per path. +#[tauri::command] +fn watch_repo(app: tauri::AppHandle, watchers: tauri::State, repo: String) -> AppResult<()> { + watch::watch(&app, &watchers, &repo) +} + +/// Stop watching a repo (its tab was closed). +#[tauri::command] +fn unwatch_repo(watchers: tauri::State, repo: String) { + watch::unwatch(&watchers, &repo) +} + #[tauri::command] fn merge(repo: String, branch: String) -> AppResult { ops::merge(&open(&repo)?, &branch) @@ -479,7 +493,8 @@ pub fn run() { let builder = tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_process::init()) - .plugin(tauri_plugin_updater::Builder::new().build()); + .plugin(tauri_plugin_updater::Builder::new().build()) + .manage(watch::Watchers::default()); // macOS binds Cmd+W to the default Window menu's "Close Window". Replace the // menu with just App + Edit (no Window menu) so Cmd+W is free for the JS @@ -530,6 +545,8 @@ pub fn run() { push_force, pull, fetch, + watch_repo, + unwatch_repo, merge, fast_forward_to, rebase_onto, diff --git a/src-tauri/src/watch.rs b/src-tauri/src/watch.rs new file mode 100644 index 0000000..4650f15 --- /dev/null +++ b/src-tauri/src/watch.rs @@ -0,0 +1,148 @@ +//! Filesystem watching for open repos. Watching the `.git` dir lets the UI react +//! the instant git state changes underneath it - a commit/checkout/stash from the +//! terminal, a rebase by another tool, a branch deleted by a script - without the +//! user hitting refresh. It is purely local: no network, so it never touches a +//! remote or a rate limit. Remote changes still need an explicit/auto fetch. +//! +//! We watch the git dir, NOT the working tree: refs/HEAD/index there are the +//! high-signal, low-noise surface (a whole `node_modules` under the workdir would +//! be the opposite). Working-tree edits still refresh on window focus. +//! +//! ponytail: workdir file-watch skipped - focus-refresh already covers external +//! editor saves; add a (gitignore-filtered) workdir watch only if instant WIP +//! updates become worth the event noise. + +use std::collections::HashMap; +use std::path::PathBuf; +use std::time::Duration; + +use notify_debouncer_mini::{ + new_debouncer, + notify::{RecommendedWatcher, RecursiveMode}, + DebounceEventResult, Debouncer, +}; +use parking_lot::Mutex; +use tauri::{AppHandle, Emitter}; + +use crate::error::{AppError, AppResult}; + +/// Event name the frontend listens on; payload is the repo path that changed. +pub const REPO_CHANGED: &str = "repo-changed"; + +/// One live debouncer per watched repo path. Dropping the debouncer stops its +/// watch, so `unwatch` is just a map removal. Not "active-repo" state - a set of +/// independent per-tab watchers, keyed by path, that never race. +#[derive(Default)] +pub struct Watchers(Mutex>>); + +/// The git op that fires a burst of writes (lock files, ref updates, new objects) +/// should collapse to one refresh: wait this long after the last write. +const DEBOUNCE: Duration = Duration::from_millis(400); + +/// The git dir(s) whose changes matter for `repo`. A linked worktree keeps +/// HEAD/index in its own git dir (`path()`) but shares refs/packed-refs in the +/// common dir, so both are watched; they're equal for a normal repo (one entry). +fn git_dirs(repo: &str) -> AppResult> { + let git = git2::Repository::open(repo)?; + let mut dirs = vec![git.path().to_path_buf()]; + let common = git.commondir().to_path_buf(); + if common != git.path() { + dirs.push(common); + } + Ok(dirs) +} + +/// Start watching `repo`'s git dir(s), calling `on_change` after each debounced +/// change batch. Idempotent: a second call for an already-watched path is a no-op +/// (so re-activating a tab doesn't stack watchers). The tauri command wraps this +/// with an event emit; the split keeps the file-watch mechanism testable without +/// an AppHandle. +fn watch_with( + state: &Watchers, + repo: &str, + on_change: impl Fn() + Send + 'static, +) -> AppResult<()> { + let mut map = state.0.lock(); + if map.contains_key(repo) { + return Ok(()); + } + let dirs = git_dirs(repo)?; + let mut debouncer = new_debouncer(DEBOUNCE, move |res: DebounceEventResult| { + // A watch error (e.g. the dir vanished) just means no event this round; + // the next real change re-fires. Only a successful batch pings the UI. + if res.is_ok() { + on_change(); + } + }) + .map_err(|e| AppError::Msg(format!("watch init: {e}")))?; + + for dir in &dirs { + debouncer + .watcher() + .watch(dir, RecursiveMode::Recursive) + .map_err(|e| AppError::Msg(format!("watch {}: {e}", dir.display())))?; + } + map.insert(repo.to_string(), debouncer); + Ok(()) +} + +/// Start watching `repo`, emitting `REPO_CHANGED { repo }` on each change batch. +pub fn watch(app: &AppHandle, state: &Watchers, repo: &str) -> AppResult<()> { + let app = app.clone(); + let repo_owned = repo.to_string(); + watch_with(state, repo, move || { + let _ = app.emit(REPO_CHANGED, repo_owned.clone()); + }) +} + +/// Stop watching `repo` (dropping the debouncer). Safe to call for a path that +/// was never watched. +pub fn unwatch(state: &Watchers, repo: &str) { + state.0.lock().remove(repo); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + use std::thread::sleep; + + /// An external write inside `.git` fires the change callback, and the watcher + /// is idempotent (a second watch of the same path doesn't stack). Exercises the + /// real notify+debouncer chain against a real repo on disk. + #[test] + fn git_write_fires_change_and_watch_is_idempotent() { + let dir = std::env::temp_dir().join(format!("gitchef-watch-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let repo = git2::Repository::init(&dir).unwrap(); + drop(repo); + let path = dir.to_str().unwrap().to_string(); + + let hits = Arc::new(AtomicUsize::new(0)); + let h = hits.clone(); + let state = Watchers::default(); + watch_with(&state, &path, move || { + h.fetch_add(1, Ordering::SeqCst); + }) + .unwrap(); + // Idempotent: the second call must not start a second watcher (which would + // double every event). + watch_with(&state, &path, || panic!("second watch should be a no-op")).unwrap(); + + // Simulate an external ref update (what a branch create/commit writes). + sleep(Duration::from_millis(100)); // let the watcher arm + std::fs::write(dir.join(".git/refs/heads/feature"), "0\n").unwrap(); + + // Debounce is 400ms; poll up to ~2s for the batch. + for _ in 0..20 { + sleep(Duration::from_millis(100)); + if hits.load(Ordering::SeqCst) > 0 { + break; + } + } + unwatch(&state, &path); + let _ = std::fs::remove_dir_all(&dir); + assert!(hits.load(Ordering::SeqCst) > 0, "a .git write should fire on_change"); + } +} diff --git a/src/api.ts b/src/api.ts index 0bffc40..96e0165 100644 --- a/src/api.ts +++ b/src/api.ts @@ -98,6 +98,9 @@ export const pushForce = (repo: string) => invoke("push_force", { repo } export type PullMode = "ff" | "ff-only" | "rebase"; export const pull = (repo: string, mode: PullMode) => invoke("pull", { repo, mode }); export const fetchRemotes = (repo: string) => invoke("fetch", { repo }); +/// Start/stop watching a repo's git dir; changes emit a `repo-changed` event. +export const watchRepo = (repo: string) => invoke("watch_repo", { repo }); +export const unwatchRepo = (repo: string) => invoke("unwatch_repo", { repo }); export const merge = (repo: string, branch: string) => invoke("merge", { repo, branch }); export const fastForwardTo = (repo: string, branch: string) => invoke("fast_forward_to", { repo, branch }); diff --git a/src/components/RepoView.tsx b/src/components/RepoView.tsx index 4e14db9..273a186 100644 --- a/src/components/RepoView.tsx +++ b/src/components/RepoView.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { confirm, save } from "@tauri-apps/plugin-dialog"; +import { listen } from "@tauri-apps/api/event"; import { Menu, MenuItem, PredefinedMenuItem, Submenu } from "@tauri-apps/api/menu"; import * as api from "../api"; import { RepoContext, type RefreshOpts } from "../repoContext"; @@ -159,6 +160,12 @@ export default function RepoView({ path, isActive, onLoaded, onOpenPath }: Props const rightRef = useRef(null); // Live mirror of `busy` for the auto-fetch interval's stale closure. const busyRef = useRef(false); + // Epoch ms of the last background fetch, shared by the interval + fetch-on-focus + // so the two paths throttle against each other (no double-fetch on refocus). + const lastFetchRef = useRef(0); + // Epoch ms our last own git op settled - the watcher listener ignores its own + // debounced echo within a short grace window after this (see run()). + const lastOpAt = useRef(0); // Play the exit animation, then unmount after it (kept in sync with the // .toast.closing CSS below). @@ -294,6 +301,11 @@ export default function RepoView({ path, isActive, onLoaded, onOpenPath }: Props notify(String(e), true); } finally { busyRef.current = false; + // Stamp when our own op settled. Its .git writes fire a debounced + // `repo-changed` ~400ms later, AFTER busyRef has already flipped back for + // a fast op - so the watcher listener also skips within this grace window, + // else every local op would echo a redundant refresh of its own writes. + lastOpAt.current = Date.now(); setBusy(false); setActiveAction(null); } @@ -358,6 +370,23 @@ export default function RepoView({ path, isActive, onLoaded, onOpenPath }: Props [path, graphLimit, refreshStats] ); + // One background (non-blocking) fetch: pull remote state + prune, repaint the + // graph, and self-heal the PR list. Stamps lastFetchRef so the interval and + // fetch-on-focus throttle against a shared clock. Silent on failure - a + // background fetch offline shouldn't nag. + const backgroundFetch = useCallback(() => { + // Self-gating so every caller (interval + focus) honours ONE throttle: skip + // if auto-fetch is off, offline, or a fetch ran within the last interval. + // Without this the fixed interval tick could fire seconds after a focus fetch. + const minutes = getFetchIntervalMinutes(); + if (minutes <= 0 || !navigator.onLine || Date.now() - lastFetchRef.current < minutes * 60_000) { + return; + } + lastFetchRef.current = Date.now(); + api.fetchRemotes(path).then(() => refresh({ stats: false })).catch(() => {}); + refreshPrs(); + }, [path, refresh, refreshPrs]); + // Per-worktree dirty ("WIP") indicators are an opt-in scan: each worktree is // opened and status-walked, so this runs on demand (first load + the sidebar // "refresh WIPs" button + after adding a worktree), never on the hot path. @@ -447,7 +476,12 @@ export default function RepoView({ path, isActive, onLoaded, onOpenPath }: Props onLoaded(path, info); if (!loadedRef.current) { await refresh(); + // The tab may have closed during that await; don't register a watcher + // the unmount cleanup has already run past (it would leak, never freed). + if (!alive) return; refreshWips().catch(() => {}); + // Watch this repo's .git so external changes refresh the tab live. + api.watchRepo(path).catch(() => {}); loadedRef.current = true; } } catch (e) { @@ -473,6 +507,9 @@ export default function RepoView({ path, isActive, onLoaded, onOpenPath }: Props window.clearTimeout(timer); timer = window.setTimeout(() => { refresh({ stats: false }).catch((e) => notify(String(e), true)); + // When auto-fetch is on, regaining focus also pulls remote state. + // backgroundFetch self-throttles, so alt-tabbing can't hammer the remote. + backgroundFetch(); }, 200); }; schedule(); @@ -483,7 +520,37 @@ export default function RepoView({ path, isActive, onLoaded, onOpenPath }: Props window.removeEventListener("focus", schedule); document.removeEventListener("visibilitychange", schedule); }; - }, [isActive, notify, refresh]); + }, [isActive, notify, refresh, backgroundFetch]); + + // Live refresh when this repo's .git changes on disk (external commit/checkout/ + // stash/rebase from a terminal or other tool). The backend debounces the write + // burst into one event; we skip our own writes' echo (in flight via busyRef, or + // just-settled via lastOpAt - the 400ms debounce lands after a fast op clears + // busy) and while the window is hidden (the focus handler covers the return). + // Local only - no network, so nothing to rate-limit. + const OWN_OP_GRACE_MS = 700; // > the backend's 400ms debounce + useEffect(() => { + if (!isActive) return; + let unlisten: (() => void) | undefined; + let disposed = false; + listen("repo-changed", (e) => { + if ( + e.payload !== path || + busyRef.current || + Date.now() - lastOpAt.current < OWN_OP_GRACE_MS || + document.visibilityState === "hidden" + ) + return; + refresh({ stats: false }).catch(() => {}); + }).then((fn) => (disposed ? fn() : (unlisten = fn))); + return () => { + disposed = true; + unlisten?.(); + }; + }, [isActive, path, refresh]); + + // Stop watching when the tab closes (this instance unmounts). + useEffect(() => () => void api.unwatchRepo(path).catch(() => {}), [path]); const closeDiff = () => { setDiff(null); @@ -799,11 +866,10 @@ export default function RepoView({ path, isActive, onLoaded, onOpenPath }: Props if (!isActive || minutes <= 0) return; const id = window.setInterval(() => { if (busyRef.current || document.visibilityState === "hidden") return; - api.fetchRemotes(path).then(() => refresh({ stats: false })).catch(() => {}); - refreshPrs(); // self-heal the PR list so merged/closed ones drop off + backgroundFetch(); }, minutes * 60_000); return () => window.clearInterval(id); - }, [isActive, path, refresh, refreshPrs, autoFetchTick]); + }, [isActive, backgroundFetch, autoFetchTick]); // Re-list PRs when the app regains focus - a PR merged/closed on the provider's // web UI while you were away must not linger as "open" in the sidebar.