Skip to main content

git_workty/
ui.rs

1use crate::git::GitRepo;
2use crate::status::WorktreeStatus;
3use crate::worktree::Worktree;
4use owo_colors::OwoColorize;
5use serde::Serialize;
6use std::io::{self, Write};
7use std::path::Path;
8
9#[derive(Debug, Clone, Copy)]
10pub struct UiOptions {
11    pub color: bool,
12    pub ascii: bool,
13    pub json: bool,
14}
15
16impl Default for UiOptions {
17    fn default() -> Self {
18        Self {
19            color: true,
20            ascii: false,
21            json: false,
22        }
23    }
24}
25
26pub struct Icons {
27    pub current: &'static str,
28    pub dirty: &'static str,
29    pub clean: &'static str,
30    pub arrow_up: &'static str,
31    pub arrow_down: &'static str,
32    pub rebase: &'static str,
33}
34
35impl Icons {
36    pub fn unicode() -> Self {
37        Self {
38            current: "▶",
39            dirty: "●",
40            clean: "✓",
41            arrow_up: "↑",
42            arrow_down: "↓",
43            rebase: "⟳",
44        }
45    }
46
47    pub fn ascii() -> Self {
48        Self {
49            current: ">",
50            dirty: "*",
51            clean: "-",
52            arrow_up: "^",
53            arrow_down: "v",
54            rebase: "R",
55        }
56    }
57
58    pub fn from_options(opts: &UiOptions) -> Self {
59        if opts.ascii {
60            Self::ascii()
61        } else {
62            Self::unicode()
63        }
64    }
65}
66
67pub fn print_worktree_list(
68    repo: &GitRepo,
69    worktrees: &[(Worktree, WorktreeStatus)],
70    current_path: &Path,
71    opts: &UiOptions,
72) {
73    if opts.json {
74        print_worktree_list_json(repo, worktrees, current_path);
75        return;
76    }
77
78    let icons = Icons::from_options(opts);
79
80    let max_name_len = worktrees
81        .iter()
82        .map(|(wt, _)| wt.name().len())
83        .max()
84        .unwrap_or(10);
85
86    for (wt, status) in worktrees {
87        let is_current = wt.path == current_path;
88
89        let marker = if is_current { icons.current } else { " " };
90
91        let name = wt.name();
92        let name_padded = format!("{:width$}", name, width = max_name_len);
93
94        let dirty_str = format_dirty(status, &icons, opts);
95        let sync_str = format_sync(status, &icons);
96        let time_str = format_time(status.last_commit_time);
97        let rebase_str = format_rebase(status, &icons, opts);
98        let path_str = shorten_path(&wt.path);
99
100        if opts.color {
101            let name_colored = if is_current {
102                name_padded.green().bold().to_string()
103            } else if status.is_dirty() {
104                name_padded.yellow().to_string()
105            } else {
106                name_padded.to_string()
107            };
108
109            let marker_colored = if is_current {
110                marker.green().bold().to_string()
111            } else {
112                marker.to_string()
113            };
114
115            println!(
116                "{} {}  {}  {:>6}  {:>7}  {}  {}",
117                marker_colored,
118                name_colored,
119                dirty_str,
120                sync_str,
121                time_str.dimmed(),
122                rebase_str,
123                path_str.dimmed()
124            );
125        } else {
126            println!(
127                "{} {}  {}  {:>6}  {:>7}  {}  {}",
128                marker, name_padded, dirty_str, sync_str, time_str, rebase_str, path_str
129            );
130        }
131    }
132}
133
134fn format_dirty(status: &WorktreeStatus, icons: &Icons, opts: &UiOptions) -> String {
135    if status.dirty_count > 0 {
136        let s = format!("{}{:>3}", icons.dirty, status.dirty_count);
137        if opts.color {
138            s.yellow().to_string()
139        } else {
140            s
141        }
142    } else {
143        let s = format!("{:>4}", icons.clean);
144        if opts.color {
145            s.green().to_string()
146        } else {
147            s
148        }
149    }
150}
151
152fn format_sync(status: &WorktreeStatus, icons: &Icons) -> String {
153    match (status.ahead, status.behind) {
154        (Some(a), Some(b)) if a > 0 || b > 0 => {
155            format!("{}{}{}{}", icons.arrow_up, a, icons.arrow_down, b)
156        }
157        (Some(0), Some(0)) => format!("{}0{}0", icons.arrow_up, icons.arrow_down),
158        _ => "-".to_string(),
159    }
160}
161
162fn format_time(seconds: Option<i64>) -> String {
163    match seconds {
164        Some(s) if s < 60 => "now".to_string(),
165        Some(s) if s < 3600 => format!("{}m", s / 60),
166        Some(s) if s < 86400 => format!("{}h", s / 3600),
167        Some(s) if s < 604800 => format!("{}d", s / 86400),
168        Some(s) if s < 2592000 => format!("{}w", s / 604800),
169        Some(s) => format!("{}mo", s / 2592000),
170        None => "-".to_string(),
171    }
172}
173
174fn format_rebase(status: &WorktreeStatus, icons: &Icons, opts: &UiOptions) -> String {
175    if let Some(n) = status.behind_main {
176        if n > 0 {
177            let s = format!("{}{}", icons.rebase, n);
178            if opts.color {
179                s.red().to_string()
180            } else {
181                s
182            }
183        } else {
184            " ".to_string()
185        }
186    } else {
187        " ".to_string()
188    }
189}
190
191pub fn shorten_path(path: &Path) -> String {
192    if let Some(home) = dirs::home_dir() {
193        if let Ok(stripped) = path.strip_prefix(&home) {
194            return format!("~/{}", stripped.display());
195        }
196    }
197    path.display().to_string()
198}
199
200#[derive(Serialize)]
201struct JsonOutput {
202    repo: RepoInfo,
203    current: String,
204    worktrees: Vec<JsonWorktree>,
205}
206
207#[derive(Serialize)]
208struct RepoInfo {
209    root: String,
210    common_dir: String,
211}
212
213#[derive(Serialize)]
214struct JsonWorktree {
215    path: String,
216    branch: Option<String>,
217    branch_short: Option<String>,
218    head: String,
219    detached: bool,
220    locked: bool,
221    dirty_count: usize,
222    upstream: Option<String>,
223    ahead: Option<usize>,
224    behind: Option<usize>,
225    last_commit_seconds: Option<i64>,
226    behind_main: Option<usize>,
227}
228
229fn print_worktree_list_json(
230    repo: &GitRepo,
231    worktrees: &[(Worktree, WorktreeStatus)],
232    current_path: &Path,
233) {
234    let json_worktrees: Vec<JsonWorktree> = worktrees
235        .iter()
236        .map(|(wt, status)| JsonWorktree {
237            path: wt.path.to_string_lossy().into_owned(),
238            branch: wt.branch.clone(),
239            branch_short: wt.branch_short.clone(),
240            head: wt.head.clone(),
241            detached: wt.detached,
242            locked: wt.locked,
243            dirty_count: status.dirty_count,
244            upstream: status.upstream.clone(),
245            ahead: status.ahead,
246            behind: status.behind,
247            last_commit_seconds: status.last_commit_time,
248            behind_main: status.behind_main,
249        })
250        .collect();
251
252    let output = JsonOutput {
253        repo: RepoInfo {
254            root: repo.root.to_string_lossy().into_owned(),
255            common_dir: repo.common_dir.to_string_lossy().into_owned(),
256        },
257        current: current_path.to_string_lossy().into_owned(),
258        worktrees: json_worktrees,
259    };
260
261    let json = serde_json::to_string_pretty(&output).unwrap_or_else(|_| "{}".to_string());
262    println!("{}", json);
263}
264
265pub fn print_error(msg: &str, hint: Option<&str>) {
266    let stderr = io::stderr();
267    let mut handle = stderr.lock();
268
269    let _ = writeln!(handle, "{}: {}", "error".red().bold(), msg);
270    if let Some(h) = hint {
271        let _ = writeln!(handle, "{}: {}", "hint".cyan(), h);
272    }
273}
274
275pub fn print_success(msg: &str) {
276    eprintln!("{}: {}", "success".green().bold(), msg);
277}
278
279pub fn print_warning(msg: &str) {
280    eprintln!("{}: {}", "warning".yellow().bold(), msg);
281}
282
283pub fn print_info(msg: &str) {
284    eprintln!("{}", msg);
285}