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}