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    // Calculate column widths
81    let max_name_len = worktrees
82        .iter()
83        .map(|(wt, _)| wt.name().len())
84        .max()
85        .unwrap_or(10)
86        .max(6); // minimum width for "BRANCH" header
87
88    // Print header
89    if opts.color {
90        println!(
91            "  {:width$}  {:>6}  {:>6}  {:>5}  {:>6}  {}",
92            "BRANCH".dimmed(),
93            "DIRTY".dimmed(),
94            "SYNC".dimmed(),
95            "AGE".dimmed(),
96            "REBASE".dimmed(),
97            "PATH".dimmed(),
98            width = max_name_len
99        );
100    } else {
101        println!(
102            "  {:width$}  {:>6}  {:>6}  {:>5}  {:>6}  {}",
103            "BRANCH",
104            "DIRTY",
105            "SYNC",
106            "AGE",
107            "REBASE",
108            "PATH",
109            width = max_name_len
110        );
111    }
112
113    for (wt, status) in worktrees {
114        let is_current = wt.path == current_path;
115
116        let marker = if is_current { icons.current } else { " " };
117
118        let name = wt.name();
119        let name_padded = format!("{:width$}", name, width = max_name_len);
120
121        let dirty_str = format_dirty(status, &icons, opts);
122        let sync_str = format_sync(status, &icons);
123        let time_str = format_time(status.last_commit_time);
124        let rebase_str = format_rebase(status, &icons, opts);
125        let path_str = shorten_path(&wt.path);
126
127        if opts.color {
128            let name_colored = if is_current {
129                name_padded.green().bold().to_string()
130            } else if status.is_dirty() {
131                name_padded.yellow().to_string()
132            } else {
133                name_padded.to_string()
134            };
135
136            let marker_colored = if is_current {
137                marker.green().bold().to_string()
138            } else {
139                marker.to_string()
140            };
141
142            println!(
143                "{} {}  {:>6}  {:>6}  {:>5}  {:>6}  {}",
144                marker_colored,
145                name_colored,
146                dirty_str,
147                sync_str,
148                time_str.dimmed(),
149                rebase_str,
150                path_str.dimmed()
151            );
152        } else {
153            println!(
154                "{} {}  {:>6}  {:>6}  {:>5}  {:>6}  {}",
155                marker, name_padded, dirty_str, sync_str, time_str, rebase_str, path_str
156            );
157        }
158    }
159}
160
161fn format_dirty(status: &WorktreeStatus, icons: &Icons, opts: &UiOptions) -> String {
162    if status.dirty_count > 0 {
163        let s = format!("{} {:>3}", icons.dirty, status.dirty_count);
164        if opts.color {
165            s.yellow().to_string()
166        } else {
167            s
168        }
169    } else {
170        let s = format!("{} {:>3}", icons.clean, "-");
171        if opts.color {
172            s.green().to_string()
173        } else {
174            s
175        }
176    }
177}
178
179fn format_sync(status: &WorktreeStatus, icons: &Icons) -> String {
180    match (status.ahead, status.behind) {
181        (Some(a), Some(b)) => {
182            format!("{}{} {}{}", icons.arrow_up, a, icons.arrow_down, b)
183        }
184        _ => "  -  ".to_string(),
185    }
186}
187
188pub fn format_time(seconds: Option<i64>) -> String {
189    match seconds {
190        Some(s) if s < 60 => "now".to_string(),
191        Some(s) if s < 3600 => format!("{}m", s / 60),
192        Some(s) if s < 86400 => format!("{}h", s / 3600),
193        Some(s) if s < 604800 => format!("{}d", s / 86400),
194        Some(s) if s < 2592000 => format!("{}w", s / 604800),
195        Some(s) => format!("{}mo", s / 2592000),
196        None => "-".to_string(),
197    }
198}
199
200fn format_rebase(status: &WorktreeStatus, icons: &Icons, opts: &UiOptions) -> String {
201    if let Some(n) = status.behind_main {
202        if n > 0 {
203            let s = format!("{} {:>3}", icons.rebase, n);
204            if opts.color {
205                s.red().to_string()
206            } else {
207                s
208            }
209        } else {
210            "    -".to_string()
211        }
212    } else {
213        "    -".to_string()
214    }
215}
216
217pub fn shorten_path(path: &Path) -> String {
218    if let Some(home) = dirs::home_dir() {
219        if let Ok(stripped) = path.strip_prefix(&home) {
220            return format!("~/{}", stripped.display());
221        }
222    }
223    path.display().to_string()
224}
225
226#[derive(Serialize)]
227struct JsonOutput {
228    repo: RepoInfo,
229    current: String,
230    worktrees: Vec<JsonWorktree>,
231}
232
233#[derive(Serialize)]
234struct RepoInfo {
235    root: String,
236    common_dir: String,
237}
238
239#[derive(Serialize)]
240struct JsonWorktree {
241    path: String,
242    branch: Option<String>,
243    branch_short: Option<String>,
244    head: String,
245    detached: bool,
246    locked: bool,
247    dirty_count: usize,
248    upstream: Option<String>,
249    ahead: Option<usize>,
250    behind: Option<usize>,
251    last_commit_seconds: Option<i64>,
252    behind_main: Option<usize>,
253}
254
255fn print_worktree_list_json(
256    repo: &GitRepo,
257    worktrees: &[(Worktree, WorktreeStatus)],
258    current_path: &Path,
259) {
260    let json_worktrees: Vec<JsonWorktree> = worktrees
261        .iter()
262        .map(|(wt, status)| JsonWorktree {
263            path: wt.path.to_string_lossy().into_owned(),
264            branch: wt.branch.clone(),
265            branch_short: wt.branch_short.clone(),
266            head: wt.head.clone(),
267            detached: wt.detached,
268            locked: wt.locked,
269            dirty_count: status.dirty_count,
270            upstream: status.upstream.clone(),
271            ahead: status.ahead,
272            behind: status.behind,
273            last_commit_seconds: status.last_commit_time,
274            behind_main: status.behind_main,
275        })
276        .collect();
277
278    let output = JsonOutput {
279        repo: RepoInfo {
280            root: repo.root.to_string_lossy().into_owned(),
281            common_dir: repo.common_dir.to_string_lossy().into_owned(),
282        },
283        current: current_path.to_string_lossy().into_owned(),
284        worktrees: json_worktrees,
285    };
286
287    let json = serde_json::to_string_pretty(&output).unwrap_or_else(|_| "{}".to_string());
288    println!("{}", json);
289}
290
291pub fn print_error(msg: &str, hint: Option<&str>) {
292    let stderr = io::stderr();
293    let mut handle = stderr.lock();
294
295    let _ = writeln!(handle, "{}: {}", "error".red().bold(), msg);
296    if let Some(h) = hint {
297        let _ = writeln!(handle, "{}: {}", "hint".cyan(), h);
298    }
299}
300
301pub fn print_success(msg: &str) {
302    eprintln!("{}: {}", "success".green().bold(), msg);
303}
304
305pub fn print_warning(msg: &str) {
306    eprintln!("{}: {}", "warning".yellow().bold(), msg);
307}
308
309pub fn print_info(msg: &str) {
310    eprintln!("{}", msg);
311}