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
1 change: 1 addition & 0 deletions 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 crates/goat-tui/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
10 changes: 9 additions & 1 deletion crates/goat-tui/src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ pub struct App {
pub(crate) composer: Composer,
pub(crate) highlighter: SyntectHighlighter,
pub(crate) cwd: String,
git_workspace: Option<goat_worktree::Workspace>,
pub(crate) next_task: u64,
pub(crate) window_count: usize,
pub(crate) spinner: usize,
Expand Down Expand Up @@ -163,13 +164,17 @@ 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,
transcript: Transcript::default(),
composer: Composer::default(),
highlighter: SyntectHighlighter::new(),
cwd,
git_workspace,
next_task: 1,
window_count: 1,
spinner: 0,
Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -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();
Expand Down
127 changes: 122 additions & 5 deletions crates/goat-tui/src/view.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use goat_worktree::WorkspaceKind;
use ratatui::{
Frame,
layout::{Constraint, Layout, Margin, Rect},
Expand All @@ -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,
Expand Down Expand Up @@ -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<Span<'static>>, 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<Span<'static>>, 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,
Expand Down Expand Up @@ -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<Span> = 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)));
Expand Down Expand Up @@ -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<Effort>) -> ModelTarget {
ModelTarget {
Expand Down Expand Up @@ -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);
Expand Down
36 changes: 35 additions & 1 deletion crates/goat-worktree/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
mod error;
mod git;
mod metadata;
mod workspace;

pub use workspace::{Workspace, WorkspaceKind, workspace};

use std::{
fs,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
97 changes: 97 additions & 0 deletions crates/goat-worktree/src/workspace.rs
Original file line number Diff line number Diff line change
@@ -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<Workspace, WorktreeError> {
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<String> {
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<String, WorktreeError> {
let output = git_output(cwd, &[os("branch"), os("--show-current")])?;
Ok(output.stdout.trim().to_owned())
}

fn short_head(cwd: &Path) -> Result<String, WorktreeError> {
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"));
}
}
Loading