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}