fix(welcome): show the live git branch on recent projects (#2851)

The welcome screen showed the branch stored when a project was last opened, so a checkout made later (in or outside the IDE) stayed stale. recent_projects now reads each project's HEAD when the list is built (linked worktrees via the .git file's gitdir; a detached HEAD shows the short commit), off the async runtime, and keeps the stored value when HEAD can't be read.

Co-authored-by: GGVidal <52431033+GGVidal@users.noreply.github.com>
This commit is contained in:
Gabriel Gondim
2026-11-12 10:19:57 +08:00
committed by GitHub
parent ac54a064ef
commit c54253fba2
2 changed files with 105 additions and 3 deletions
+97 -1
View File
@@ -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<String>,
}
@@ -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<PathBuf> {
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<Option<String>, ()> {
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<RecentProject>) -> Vec<RecentProject> {
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<RecentProject> {
with_live_branches(self.list())
}
pub fn groups(&self) -> Vec<RecentProjectGroup> {
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"));
}
}
+8 -2
View File
@@ -40,9 +40,15 @@ pub fn app_info(state: State<'_, AppState>) -> AppInfo {
#[tauri::command]
#[specta::specta]
pub fn recent_projects(state: State<'_, AppState>) -> Vec<RecentProject> {
pub async fn recent_projects(app: tauri::AppHandle) -> Vec<RecentProject> {
let state = app.state::<AppState>();
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]