diff --git a/crates/ide-workspace/src/recent.rs b/crates/ide-workspace/src/recent.rs index ebb49a69f..ceff3b800 100644 --- a/crates/ide-workspace/src/recent.rs +++ b/crates/ide-workspace/src/recent.rs @@ -10,7 +10,8 @@ pub struct RecentProject { pub path: ide_core::IpcPath, pub name: String, pub opened_at_ms: f64, - /// Current git branch when last opened, shown on the welcome screen. + /// Git branch shown on the welcome screen. Persisted as of last open; [`RecentProjects::list_live`] + /// refreshes it from `.git/HEAD` when the list is shown. pub branch: Option, } @@ -94,6 +95,55 @@ fn now_ms() -> f64 { std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs_f64() * 1000.0).unwrap_or(0.0) } +/// The git directory for `project`: `.git/` or the path in a worktree's `.git` file (`gitdir: …`). +fn git_dir(project: &Path) -> Option { + let dot = project.join(".git"); + let meta = std::fs::symlink_metadata(&dot).ok()?; + if meta.file_type().is_dir() { + return Some(dot); + } + if !meta.file_type().is_file() { + return None; + } + let text = std::fs::read_to_string(&dot).ok()?; + let raw = text.lines().find_map(|line| line.strip_prefix("gitdir:"))?.trim(); + if raw.is_empty() { + return None; + } + let path = PathBuf::from(raw); + Some(if path.is_absolute() { path } else { project.join(path) }) +} + +/// Branch name, or a short commit when detached. `Err` when HEAD cannot be read (keep the stored value). +fn read_head_branch(project: &Path) -> Result, ()> { + let head = std::fs::read_to_string(git_dir(project).ok_or(())?.join("HEAD")).map_err(|_| ())?; + let head = head.trim(); + if head.is_empty() { + return Ok(None); + } + if let Some(reference) = head.strip_prefix("ref:") { + let reference = reference.trim(); + if let Some(branch) = reference.strip_prefix("refs/heads/") { + return Ok(if branch.is_empty() { None } else { Some(branch.to_owned()) }); + } + // Other symbolic refs (rare for HEAD) are not a branch name. + return Ok(None); + } + // Detached HEAD: object id — show a short commit, never the previous branch. + let short = head.get(..7).unwrap_or(head); + Ok(if short.is_empty() { None } else { Some(short.to_owned()) }) +} + +/// Overlay each entry's `branch` from `.git/HEAD` when readable; otherwise keep the stored value. +pub fn with_live_branches(mut items: Vec) -> Vec { + for item in &mut items { + if let Ok(branch) = read_head_branch(item.path.as_ref()) { + item.branch = branch; + } + } + items +} + impl RecentProjects { pub fn load(config_dir: &Path) -> Self { let file = config_dir.join("recent.json"); @@ -115,6 +165,11 @@ impl RecentProjects { self.items.lock().clone() } + /// Like [`list`], but each `branch` is refreshed from that project's `.git/HEAD` when readable. + pub fn list_live(&self) -> Vec { + with_live_branches(self.list()) + } + pub fn groups(&self) -> Vec { self.groups.lock().clone() } @@ -261,4 +316,45 @@ mod tests { assert_eq!(recent.list()[0].name, "app"); assert_eq!(recent.list().len(), 1); } + + fn write_head(git_dir: &Path, contents: &str) { + std::fs::create_dir_all(git_dir).unwrap(); + std::fs::write(git_dir.join("HEAD"), contents).unwrap(); + } + + #[test] + fn list_live_reads_branch_from_head_and_keeps_stored_when_unreadable() { + let tmp = tempfile::tempdir().unwrap(); + let repo = tmp.path().join("repo"); + write_head(&repo.join(".git"), "ref: refs/heads/feature/list-live\n"); + let plain = tmp.path().join("plain"); + std::fs::create_dir_all(&plain).unwrap(); + + let recent = RecentProjects::load(tmp.path().join("config").as_path()); + recent.touch(&repo, Some("stale".into())).unwrap(); + recent.touch(&plain, Some("kept".into())).unwrap(); + + let live = recent.list_live(); + let by_path = |p: &Path| live.iter().find(|i| i.path.as_ref() == p).unwrap(); + assert_eq!(by_path(&repo).branch.as_deref(), Some("feature/list-live")); + assert_eq!(by_path(&plain).branch.as_deref(), Some("kept")); + assert_eq!(recent.list().iter().find(|i| i.path.as_ref() == repo).unwrap().branch.as_deref(), Some("stale")); + } + + #[test] + fn list_live_follows_worktree_gitdir_and_shows_short_detached_head() { + let tmp = tempfile::tempdir().unwrap(); + let main = tmp.path().join("main"); + let wt = tmp.path().join("wt"); + std::fs::create_dir_all(&wt).unwrap(); + let wt_git = main.join(".git/worktrees/wt"); + write_head(&wt_git, "abcdefghijklmnopqrstuvwxyz0123456789abcd\n"); + std::fs::write(wt.join(".git"), "gitdir: ../main/.git/worktrees/wt\n").unwrap(); + + assert_eq!(read_head_branch(&wt).unwrap().as_deref(), Some("abcdefg")); + + let recent = RecentProjects::load(tmp.path().join("config").as_path()); + recent.touch(&wt, Some("old-branch".into())).unwrap(); + assert_eq!(recent.list_live()[0].branch.as_deref(), Some("abcdefg")); + } } diff --git a/src-tauri/src/commands/app.rs b/src-tauri/src/commands/app.rs index 8da7c5513..4fd3a506f 100644 --- a/src-tauri/src/commands/app.rs +++ b/src-tauri/src/commands/app.rs @@ -40,9 +40,15 @@ pub fn app_info(state: State<'_, AppState>) -> AppInfo { #[tauri::command] #[specta::specta] -pub fn recent_projects(state: State<'_, AppState>) -> Vec { +pub async fn recent_projects(app: tauri::AppHandle) -> Vec { + let state = app.state::(); state.note_ipc(); - state.recent.list() + let items = state.recent.list(); + // Reading every project's `.git/HEAD` may touch slow disks: off the async runtime. + match tauri::async_runtime::spawn_blocking(move || ide_workspace::recent::with_live_branches(items)).await { + Ok(items) => items, + Err(_) => state.recent.list(), + } } #[tauri::command]