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
82 .iter()
83 .map(|(wt, _)| wt.name().len())
84 .max()
85 .unwrap_or(10)
86 .max(6); 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
188fn 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}