diff --git a/Cargo.lock b/Cargo.lock index ead5fe3..5a01173 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2643,6 +2643,7 @@ dependencies = [ "goat-commands", "goat-config", "goat-protocol", + "goat-worktree", "image", "percent-encoding", "pulldown-cmark", diff --git a/crates/goat-tui/Cargo.toml b/crates/goat-tui/Cargo.toml index 4c4209a..0d837f8 100644 --- a/crates/goat-tui/Cargo.toml +++ b/crates/goat-tui/Cargo.toml @@ -11,6 +11,7 @@ publish = false goat-protocol = { workspace = true } goat-commands = { workspace = true } goat-config = { workspace = true } +goat-worktree = { workspace = true } tokio = { workspace = true } futures = { workspace = true } ratatui = { workspace = true } diff --git a/crates/goat-tui/src/app/mod.rs b/crates/goat-tui/src/app/mod.rs index ba45554..1d02b9f 100644 --- a/crates/goat-tui/src/app/mod.rs +++ b/crates/goat-tui/src/app/mod.rs @@ -88,6 +88,7 @@ pub struct App { pub(crate) composer: Composer, pub(crate) highlighter: SyntectHighlighter, pub(crate) cwd: String, + git_workspace: Option, pub(crate) next_task: u64, pub(crate) window_count: usize, pub(crate) spinner: usize, @@ -163,6 +164,9 @@ impl App { .ok() .map(|p| shorten_home(&p)) .unwrap_or_default(); + let git_workspace = std::env::current_dir() + .ok() + .and_then(|p| goat_worktree::workspace(&p).ok()); let cfg = goat_config::Config::load(); Self { theme, @@ -170,6 +174,7 @@ impl App { composer: Composer::default(), highlighter: SyntectHighlighter::new(), cwd, + git_workspace, next_task: 1, window_count: 1, spinner: 0, @@ -822,6 +827,9 @@ impl App { pub(crate) fn cwd(&self) -> &str { &self.cwd } + pub(crate) fn workspace_snapshot(&self) -> Option<&goat_worktree::Workspace> { + self.git_workspace.as_ref() + } pub(crate) fn quit_armed(&self) -> bool { self.quit_arm.is_some() } @@ -1045,7 +1053,7 @@ fn slash_command_name(raw: &str) -> Option<&str> { (!name.is_empty()).then_some(name) } -fn shorten_home(path: &Path) -> String { +pub(crate) fn shorten_home(path: &Path) -> String { let display = path.display().to_string(); if let Some(home) = std::env::var_os("HOME") { let home = home.to_string_lossy(); diff --git a/crates/goat-tui/src/view.rs b/crates/goat-tui/src/view.rs index 9eb22c4..94427db 100644 --- a/crates/goat-tui/src/view.rs +++ b/crates/goat-tui/src/view.rs @@ -1,3 +1,4 @@ +use goat_worktree::WorkspaceKind; use ratatui::{ Frame, layout::{Constraint, Layout, Margin, Rect}, @@ -7,7 +8,7 @@ use ratatui::{ use unicode_width::UnicodeWidthStr; use crate::{ - app::{App, Overlay}, + app::{App, Overlay, shorten_home}, layout::{LIST_MAX, PAD_X, SCROLL_GUTTER, format_tokens}, overlay, symbols, theme::Theme, @@ -294,6 +295,93 @@ fn fit_cwd(cwd: &str, max: usize) -> String { ) } +fn repo_basename(path: &std::path::Path) -> String { + path.file_name() + .map_or_else(|| shorten_home(path), |n| n.to_string_lossy().into_owned()) +} + +pub(crate) fn location_line_full(ws: &goat_worktree::Workspace) -> String { + let repo = repo_basename(&ws.owner_root); + match &ws.kind { + WorkspaceKind::Managed { label } => format!("{repo}@{label}"), + WorkspaceKind::Main | WorkspaceKind::OtherWorktree => { + if ws.git_branch.is_empty() { + repo + } else { + format!("{repo}:{}", ws.git_branch) + } + } + } +} + +fn fit_location_line(ws: &goat_worktree::Workspace, max: usize) -> String { + let full = location_line_full(ws); + if full.width() <= max { + return full; + } + let repo = repo_basename(&ws.owner_root); + match &ws.kind { + WorkspaceKind::Managed { label } => { + let tail = format!("@{label}"); + let repo_max = max.saturating_sub(tail.width()); + format!("{}{tail}", fit_cwd(&repo, repo_max)) + } + WorkspaceKind::Main | WorkspaceKind::OtherWorktree => { + if ws.git_branch.is_empty() { + return fit_cwd(&repo, max); + } + let branch_w = ws.git_branch.width() + 1; + let short_repo = fit_cwd(&repo, max.saturating_sub(branch_w)); + format!("{short_repo}:{}", ws.git_branch) + } + } +} + +fn workspace_location_spans( + ws: &goat_worktree::Workspace, + theme: Theme, +) -> (Vec>, usize) { + let repo = repo_basename(&ws.owner_root); + let mut spans = Vec::new(); + let mut width = 0; + match &ws.kind { + WorkspaceKind::Managed { label } => { + width += repo.width(); + spans.push(Span::styled(repo.clone(), theme.muted())); + width += 1; + spans.push(Span::styled("@", theme.muted())); + width += label.width(); + spans.push(Span::styled(label.clone(), theme.text())); + } + WorkspaceKind::Main | WorkspaceKind::OtherWorktree => { + width += repo.width(); + spans.push(Span::styled(repo, theme.muted())); + if !ws.git_branch.is_empty() { + width += 1; + spans.push(Span::styled(":", theme.muted())); + let branch = ws.git_branch.clone(); + width += branch.width(); + spans.push(Span::styled(branch, theme.text())); + } + } + } + (spans, width) +} + +fn fit_workspace_location_spans( + ws: &goat_worktree::Workspace, + max: usize, + theme: Theme, +) -> (Vec>, usize) { + let (spans, width) = workspace_location_spans(ws, theme); + if width <= max { + return (spans, width); + } + let fitted = fit_location_line(ws, max); + let w = fitted.width(); + (vec![Span::styled(fitted, theme.muted())], w) +} + pub(crate) fn model_status_label( model: &goat_protocol::ModelTarget, multiple_accounts: bool, @@ -378,10 +466,14 @@ fn render_header(frame: &mut Frame, area: Rect, app: &App, theme: Theme) { + usize::from(windows.is_some())) * 2; let status_w = model_w + ctx_w + rates_w + windows_w + status_gap; - let cwd = fit_cwd(app.cwd(), inner_w.saturating_sub(status_w)); - - let mut spans: Vec = vec![Span::styled(cwd.clone(), theme.muted())]; - let left_w = cwd.width(); + let left_max = inner_w.saturating_sub(status_w); + let (mut spans, left_w) = if let Some(ws) = app.workspace_snapshot() { + fit_workspace_location_spans(ws, left_max, theme) + } else { + let cwd = fit_cwd(app.cwd(), left_max); + let w = cwd.width(); + (vec![Span::styled(cwd, theme.muted())], w) + }; let pad = inner_w.saturating_sub(left_w + status_w); if pad > 0 { spans.push(Span::raw(" ".repeat(pad))); @@ -451,6 +543,7 @@ mod tests { use goat_protocol::{Effort, ModelTarget}; use super::{format_ctx_status, format_rate_status, model_status_label}; + use goat_worktree::WorkspaceKind; fn target(effort: Option) -> ModelTarget { ModelTarget { @@ -497,6 +590,30 @@ mod tests { assert_eq!(super::window_label(5), Some("\u{29c9} 5".to_owned())); } + #[test] + fn location_line_main() { + let ws = goat_worktree::Workspace { + owner_root: std::path::PathBuf::from("/x/goat-code"), + repo_root: std::path::PathBuf::from("/x/goat-code"), + git_branch: "main".to_owned(), + kind: WorkspaceKind::Main, + }; + assert_eq!(super::location_line_full(&ws), "goat-code:main"); + } + + #[test] + fn location_line_managed() { + let ws = goat_worktree::Workspace { + owner_root: std::path::PathBuf::from("/x/goat-code"), + repo_root: std::path::PathBuf::from("/x/goat-code/.goat/worktrees/plan"), + git_branch: "worktree-plan".to_owned(), + kind: WorkspaceKind::Managed { + label: "plan".to_owned(), + }, + }; + assert_eq!(super::location_line_full(&ws), "goat-code@plan"); + } + #[test] fn format_ctx_status_uses_token_fraction() { let (label, pct) = format_ctx_status(45_000, 128_000); diff --git a/crates/goat-worktree/src/lib.rs b/crates/goat-worktree/src/lib.rs index 1f45217..563292a 100644 --- a/crates/goat-worktree/src/lib.rs +++ b/crates/goat-worktree/src/lib.rs @@ -1,6 +1,9 @@ mod error; mod git; mod metadata; +mod workspace; + +pub use workspace::{Workspace, WorkspaceKind, workspace}; use std::{ fs, @@ -451,7 +454,10 @@ fn copy_worktree_include(invocation_cwd: &Path, target: &Path) -> Result<(), Wor mod tests { use std::{fs, path::Path, process::Command, process::Stdio}; - use super::{EXCLUDE_ENTRY, WorktreeError, prepare_from_cwd, remove_from_cwd, validate_label}; + use super::{ + EXCLUDE_ENTRY, WorkspaceKind, WorktreeError, prepare_from_cwd, remove_from_cwd, + validate_label, workspace, + }; use crate::git::{branch_exists, parse_worktrees}; fn git_available() -> bool { @@ -586,6 +592,34 @@ mod tests { assert!(status.trim().is_empty()); } + #[test] + fn workspace_main_from_repo_root() { + let Some(dir) = git_repo_with_origin() else { + return; + }; + let repo = dir.path().join("repo"); + let ws = workspace(&repo).unwrap(); + assert_eq!(ws.kind, WorkspaceKind::Main); + assert_eq!(ws.owner_root, repo.canonicalize().unwrap()); + assert_eq!(ws.git_branch, "main"); + } + + #[test] + fn workspace_managed_from_worktree_path() { + let Some(dir) = git_repo_with_origin() else { + return; + }; + let repo = dir.path().join("repo"); + let launch = prepare_from_cwd("plan", &repo).unwrap(); + let ws = workspace(&launch.path).unwrap(); + assert!(matches!( + ws.kind, + WorkspaceKind::Managed { ref label } if label == "plan" + )); + assert_eq!(ws.owner_root, repo.canonicalize().unwrap()); + assert_eq!(ws.git_branch, "worktree-plan"); + } + #[test] fn reopens_existing_dirty_worktree() { let Some(dir) = git_repo_with_origin() else { diff --git a/crates/goat-worktree/src/workspace.rs b/crates/goat-worktree/src/workspace.rs new file mode 100644 index 0000000..abdd933 --- /dev/null +++ b/crates/goat-worktree/src/workspace.rs @@ -0,0 +1,97 @@ +use std::path::{Component, Path, PathBuf}; + +use crate::error::WorktreeError; +use crate::git::{git_output, git_worktrees, os, repo_root}; + +const GOAT_DIR: &str = ".goat"; +const WORKTREES_DIR: &str = "worktrees"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Workspace { + pub owner_root: PathBuf, + pub repo_root: PathBuf, + pub git_branch: String, + pub kind: WorkspaceKind, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WorkspaceKind { + Main, + Managed { label: String }, + OtherWorktree, +} + +pub fn workspace(cwd: &Path) -> Result { + let repo_root = repo_root(cwd)?; + let worktrees = git_worktrees(&repo_root)?; + let owner_root = owner_root(&repo_root, &worktrees); + let bucket = owner_root.join(GOAT_DIR).join(WORKTREES_DIR); + let kind = if let Some(label) = managed_label(cwd, &bucket) { + WorkspaceKind::Managed { label } + } else if repo_root != owner_root { + WorkspaceKind::OtherWorktree + } else { + WorkspaceKind::Main + }; + let mut git_branch = current_branch(cwd)?; + if git_branch.is_empty() { + git_branch = short_head(cwd)?; + } + Ok(Workspace { + owner_root, + repo_root, + git_branch, + kind, + }) +} + +fn owner_root(current_root: &Path, worktrees: &[crate::git::GitWorktree]) -> PathBuf { + for worktree in worktrees { + let bucket = worktree.path.join(GOAT_DIR).join(WORKTREES_DIR); + if current_root.starts_with(&bucket) { + return worktree.path.clone(); + } + } + current_root.to_path_buf() +} + +fn managed_label(cwd: &Path, bucket: &Path) -> Option { + let cwd = cwd.canonicalize().ok()?; + let bucket = bucket.canonicalize().ok()?; + if !cwd.starts_with(&bucket) { + return None; + } + let rel = cwd.strip_prefix(&bucket).ok()?; + let label = match rel.components().next() { + Some(Component::Normal(label)) => label.to_string_lossy().into_owned(), + _ => return None, + }; + if label.starts_with('.') { + return None; + } + Some(label) +} + +fn current_branch(cwd: &Path) -> Result { + let output = git_output(cwd, &[os("branch"), os("--show-current")])?; + Ok(output.stdout.trim().to_owned()) +} + +fn short_head(cwd: &Path) -> Result { + let output = git_output(cwd, &[os("rev-parse"), os("--short"), os("HEAD")])?; + Ok(output.stdout.trim().to_owned()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::git::parse_worktrees; + + #[test] + fn owner_root_from_managed_path() { + let input = "worktree /repo\nHEAD abc\nbranch refs/heads/main\n\nworktree /repo/.goat/worktrees/plan\nHEAD def\nbranch refs/heads/worktree-plan\n\n"; + let worktrees = parse_worktrees(input); + let plan = PathBuf::from("/repo/.goat/worktrees/plan"); + assert_eq!(owner_root(&plan, &worktrees), PathBuf::from("/repo")); + } +}