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
78 changes: 78 additions & 0 deletions src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
19 changes: 15 additions & 4 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -48,12 +49,21 @@ fn bootstrap(state: State<'_, SharedState>) -> BootstrapResponse {

#[tauri::command]
async fn open_repo(app: AppHandle, repo_root: String) -> Result<RepoSnapshot, String> {
tauri::async_runtime::spawn_blocking(move || {
let state = app.state::<SharedState>();
load_repo_snapshot(&app, &state, repo_root)
let snapshot = tauri::async_runtime::spawn_blocking({
let app = app.clone();
move || {
let state = app.state::<SharedState>();
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::WatcherState>();
watcher_state.start(&app, &snapshot.repo_root);

Ok(snapshot)
}

pub fn load_repo_snapshot(
Expand Down Expand Up @@ -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());
}
Expand Down
88 changes: 88 additions & 0 deletions src-tauri/src/watcher.rs
Original file line number Diff line number Diff line change
@@ -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<Option<WatcherInner>>,
}

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<Event, notify::Error>| {
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(),
});
}
}
28 changes: 28 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>("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) {
Expand Down Expand Up @@ -756,6 +771,19 @@ export default function App({ repoPath }: { repoPath: string }) {
>
{t.prune}
</button>
<button
className="ghost-button btn-sm btn-icon refresh-button"
onClick={() => repo && void loadRepoInner(repo.repoRoot)}
disabled={isBusy}
title={t.refresh}
>
<svg className={isBusy ? "spin" : ""} width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21.5 2v6h-6" />
<path d="M2.5 22v-6h6" />
<path d="M2.5 11.5a10 10 0 0 1 18.17-4.5" />
<path d="M21.5 12.5a10 10 0 0 1-18.17 4.5" />
</svg>
</button>
</div>
<div className="worktree-list">
{repo.worktrees.map((wt) => (
Expand Down
1 change: 1 addition & 0 deletions src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions src/locales/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ export interface Translations {
worktreeCount: (n: number) => string;
baseBranch: string;
prune: string;
refresh: string;
pruneConfirmTitle: string;
pruneDescription: string;
pruneNoCandidates: string;
Expand Down
1 change: 1 addition & 0 deletions src/locales/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 元数据,一切正常。",
Expand Down
16 changes: 16 additions & 0 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,22 @@ pre {
display: flex;
gap: 8px;
padding-bottom: 10px;
align-items: center;
}

.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 {
Expand Down
Loading