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}
33
34impl Icons {
35    pub fn unicode() -> Self {
36        Self {
37            current: "▶",
38            dirty: "●",
39            clean: "✓",
40            arrow_up: "↑",
41            arrow_down: "↓",
42        }
43    }
44
45    pub fn ascii() -> Self {
46        Self {
47            current: ">",
48            dirty: "*",
49            clean: "-",
50            arrow_up: "^",
51            arrow_down: "v",
52        }
53    }
54
55    pub fn from_options(opts: &UiOptions) -> Self {
56        if opts.ascii {
57            Self::ascii()
58        } else {
59            Self::unicode()
60        }
61    }
62}
63
64pub fn print_worktree_list(
65    repo: &GitRepo,
66    worktrees: &[(Worktree, WorktreeStatus)],
67    current_path: &Path,
68    opts: &UiOptions,
69) {
70    if opts.json {
71        print_worktree_list_json(repo, worktrees, current_path);
72        return;
73    }
74
75    let icons = Icons::from_options(opts);
76
77    let max_name_len = worktrees
78        .iter()
79        .map(|(wt, _)| wt.name().len())
80        .max()
81        .unwrap_or(10);
82
83    for (wt, status) in worktrees {
84        let is_current = wt.path == current_path;
85
86        let marker = if is_current { icons.current } else { " " };
87
88        let name = wt.name();
89        let name_padded = format!("{:width$}", name, width = max_name_len);
90
91        let dirty_str = format_dirty(status, &icons, opts);
92        let sync_str = format_sync(status, &icons);
93        let path_str = shorten_path(&wt.path);
94
95        if opts.color {
96            let name_colored = if is_current {
97                name_padded.green().bold().to_string()
98            } else if status.is_dirty() {
99                name_padded.yellow().to_string()
100            } else {
101                name_padded.to_string()
102            };
103
104            let marker_colored = if is_current {
105                marker.green().bold().to_string()
106            } else {
107                marker.to_string()
108            };
109
110            println!(
111                "{} {}  {}  {:>6}  {}",
112                marker_colored, name_colored, dirty_str, sync_str, path_str.dimmed()
113            );
114        } else {
115            println!(
116                "{} {}  {}  {:>6}  {}",
117                marker, name_padded, dirty_str, sync_str, path_str
118            );
119        }
120    }
121}
122
123fn format_dirty(status: &WorktreeStatus, icons: &Icons, opts: &UiOptions) -> String {
124    if status.dirty_count > 0 {
125        let s = format!("{}{:>3}", icons.dirty, status.dirty_count);
126        if opts.color {
127            s.yellow().to_string()
128        } else {
129            s
130        }
131    } else {
132        let s = format!("{:>4}", icons.clean);
133        if opts.color {
134            s.green().to_string()
135        } else {
136            s
137        }
138    }
139}
140
141fn format_sync(status: &WorktreeStatus, icons: &Icons) -> String {
142    match (status.ahead, status.behind) {
143        (Some(a), Some(b)) if a > 0 || b > 0 => {
144            format!("{}{}{}{}", icons.arrow_up, a, icons.arrow_down, b)
145        }
146        (Some(0), Some(0)) => format!("{}0{}0", icons.arrow_up, icons.arrow_down),
147        _ => "-".to_string(),
148    }
149}
150
151pub fn shorten_path(path: &Path) -> String {
152    if let Some(home) = dirs::home_dir() {
153        if let Ok(stripped) = path.strip_prefix(&home) {
154            return format!("~/{}", stripped.display());
155        }
156    }
157    path.display().to_string()
158}
159
160#[derive(Serialize)]
161struct JsonOutput<'a> {
162    repo: RepoInfo<'a>,
163    current: &'a str,
164    worktrees: Vec<JsonWorktree<'a>>,
165}
166
167#[derive(Serialize)]
168struct RepoInfo<'a> {
169    root: &'a str,
170    common_dir: &'a str,
171}
172
173#[derive(Serialize)]
174struct JsonWorktree<'a> {
175    path: &'a str,
176    branch: Option<&'a str>,
177    branch_short: Option<&'a str>,
178    head: &'a str,
179    detached: bool,
180    locked: bool,
181    dirty: DirtyInfo,
182    upstream: Option<&'a str>,
183    ahead: Option<usize>,
184    behind: Option<usize>,
185}
186
187#[derive(Serialize)]
188struct DirtyInfo {
189    count: usize,
190}
191
192fn print_worktree_list_json(
193    repo: &GitRepo,
194    worktrees: &[(Worktree, WorktreeStatus)],
195    current_path: &Path,
196) {
197    let root_str = repo.root.to_string_lossy();
198    let common_str = repo.common_dir.to_string_lossy();
199    let current_str = current_path.to_string_lossy();
200
201    let json_worktrees: Vec<JsonWorktree> = worktrees
202        .iter()
203        .map(|(wt, status)| JsonWorktree {
204            path: Box::leak(wt.path.to_string_lossy().into_owned().into_boxed_str()),
205            branch: wt.branch.as_deref(),
206            branch_short: wt.branch_short.as_deref(),
207            head: &wt.head,
208            detached: wt.detached,
209            locked: wt.locked,
210            dirty: DirtyInfo {
211                count: status.dirty_count,
212            },
213            upstream: status.upstream.as_deref(),
214            ahead: status.ahead,
215            behind: status.behind,
216        })
217        .collect();
218
219    let output = JsonOutput {
220        repo: RepoInfo {
221            root: Box::leak(root_str.into_owned().into_boxed_str()),
222            common_dir: Box::leak(common_str.into_owned().into_boxed_str()),
223        },
224        current: Box::leak(current_str.into_owned().into_boxed_str()),
225        worktrees: json_worktrees,
226    };
227
228    let json = serde_json::to_string_pretty(&output).unwrap_or_else(|_| "{}".to_string());
229    println!("{}", json);
230}
231
232pub fn print_error(msg: &str, hint: Option<&str>) {
233    let stderr = io::stderr();
234    let mut handle = stderr.lock();
235
236    let _ = writeln!(handle, "{}: {}", "error".red().bold(), msg);
237    if let Some(h) = hint {
238        let _ = writeln!(handle, "{}: {}", "hint".cyan(), h);
239    }
240}
241
242pub fn print_success(msg: &str) {
243    eprintln!("{}: {}", "success".green().bold(), msg);
244}
245
246pub fn print_warning(msg: &str) {
247    eprintln!("{}: {}", "warning".yellow().bold(), msg);
248}
249
250pub fn print_info(msg: &str) {
251    eprintln!("{}", msg);
252}