1use std::fmt::Write as _;
4use std::path::Path;
5
6use crate::git::status::StatusEntry;
7use crate::model::{Column, Worktree};
8use crate::time::{parse_iso8601, relative};
9
10pub struct RenderCtx<'a> {
12 pub show_untracked: bool,
14 pub now: i64,
16 pub repo_root: &'a Path,
18}
19
20pub fn status_marker(worktree: &Worktree) -> char {
22 if worktree.is_current {
23 '*'
24 } else if worktree.is_missing {
25 '!'
26 } else if worktree.is_detached {
27 '~'
28 } else {
29 ' '
30 }
31}
32
33pub fn dirty_marker(worktree: &Worktree, show_untracked: bool) -> char {
35 if worktree.dirty == Some(true) {
36 'M'
37 } else if show_untracked && worktree.has_untracked == Some(true) {
38 '?'
39 } else {
40 ' '
41 }
42}
43
44pub fn branch_display(worktree: &Worktree) -> String {
46 match &worktree.branch {
47 Some(branch) => branch.clone(),
48 None => {
49 let hash = worktree
50 .commit
51 .as_ref()
52 .map_or("unknown", |c| c.hash.as_str());
53 format!("(HEAD detached @ {hash})")
54 }
55 }
56}
57
58pub fn ahead_behind_cell(worktree: &Worktree) -> String {
60 match (worktree.ahead, worktree.behind) {
61 (Some(ahead), Some(behind)) => format!("↑{ahead} ↓{behind}"),
62 _ => "–".to_string(),
63 }
64}
65
66pub fn pr_cell(worktree: &Worktree) -> String {
68 match &worktree.pr {
69 Some(pr) => format!("#{} ({})", pr.number, pr.state.as_str()),
70 None => String::new(),
71 }
72}
73
74pub fn path_cell(worktree: &Worktree, repo_root: &Path) -> String {
76 match worktree.path.strip_prefix(repo_root) {
77 Ok(rel) if rel.as_os_str().is_empty() => ".".to_string(),
78 Ok(rel) => rel.to_string_lossy().into_owned(),
79 Err(_) => worktree.path.to_string_lossy().into_owned(),
80 }
81}
82
83pub fn commit_cell(worktree: &Worktree, now: i64) -> String {
85 match &worktree.commit {
86 Some(commit) => {
87 let rel = parse_iso8601(&commit.timestamp)
88 .map(|unix| relative(now, unix))
89 .unwrap_or_default();
90 format!("{} {} ({rel})", commit.hash, commit.subject)
91 }
92 None => String::new(),
93 }
94}
95
96pub fn cell(worktree: &Worktree, column: Column, ctx: &RenderCtx) -> String {
98 match column {
99 Column::Status => status_marker(worktree).to_string(),
100 Column::Dirty => dirty_marker(worktree, ctx.show_untracked).to_string(),
101 Column::Branch => branch_display(worktree),
102 Column::Path => path_cell(worktree, ctx.repo_root),
103 Column::AheadBehind => ahead_behind_cell(worktree),
104 Column::Commit => commit_cell(worktree, ctx.now),
105 Column::Pr => pr_cell(worktree),
106 }
107}
108
109pub(crate) fn status_block(worktree: &Worktree, entries: &[StatusEntry]) -> String {
111 let mut out = String::new();
112 let _ = writeln!(out, "worktree: {}", worktree.path.display());
113
114 let branch = branch_display(worktree);
115 match &worktree.upstream {
116 Some(upstream) => {
117 let _ = writeln!(out, "branch: {branch} → {upstream}");
118 }
119 None => {
120 let _ = writeln!(out, "branch: {branch} (no upstream)");
121 }
122 }
123 if let Some(base) = &worktree.base_ref {
124 let _ = writeln!(out, "base: {base}");
125 }
126
127 if worktree.is_missing {
128 let _ = writeln!(out, "(directory already deleted)");
129 return out;
130 }
131
132 if let (Some(ahead), Some(behind)) = (worktree.ahead, worktree.behind) {
133 let _ = writeln!(out, "ahead: {ahead} behind: {behind}");
134 }
135 if let Some(pr) = &worktree.pr {
136 let _ = writeln!(
137 out,
138 "pr: #{} ({}) \"{}\"",
139 pr.number,
140 pr.state.as_str(),
141 pr.title
142 );
143 }
144 if !entries.is_empty() {
145 let _ = writeln!(out, "dirty:");
146 for entry in entries {
147 let _ = writeln!(out, " {} {}", entry.marker, entry.path);
148 }
149 }
150 out
151}
152
153#[cfg(test)]
154mod tests {
155 use super::*;
156 use crate::model::{Commit, Pr, PrState};
157 use std::path::PathBuf;
158
159 fn base() -> Worktree {
160 let mut w = Worktree::new(PathBuf::from("/repo/main"));
161 w.branch = Some("main".into());
162 w.slug = Some("main".into());
163 w
164 }
165
166 #[test]
167 fn status_markers() {
168 let mut w = base();
169 assert_eq!(status_marker(&w), ' ');
170 w.is_detached = true;
171 assert_eq!(status_marker(&w), '~');
172 w.is_missing = true;
173 assert_eq!(status_marker(&w), '!');
174 w.is_current = true;
175 assert_eq!(status_marker(&w), '*'); }
177
178 #[test]
179 fn dirty_markers_respect_show_untracked() {
180 let mut w = base();
181 assert_eq!(dirty_marker(&w, true), ' ');
182 w.has_untracked = Some(true);
183 assert_eq!(dirty_marker(&w, true), '?');
184 assert_eq!(dirty_marker(&w, false), ' '); w.dirty = Some(true);
186 assert_eq!(dirty_marker(&w, true), 'M'); }
188
189 #[test]
190 fn ahead_behind_and_no_upstream() {
191 let mut w = base();
192 assert_eq!(ahead_behind_cell(&w), "–");
193 w.ahead = Some(2);
194 w.behind = Some(1);
195 assert_eq!(ahead_behind_cell(&w), "↑2 ↓1");
196 }
197
198 #[test]
199 fn branch_display_detached() {
200 let mut w = base();
201 w.branch = None;
202 w.is_detached = true;
203 w.commit = Some(Commit {
204 hash: "abc1234".into(),
205 subject: "x".into(),
206 author: "a".into(),
207 timestamp: "2024-01-15T10:30:00Z".into(),
208 });
209 assert_eq!(branch_display(&w), "(HEAD detached @ abc1234)");
210 }
211
212 #[test]
213 fn path_cell_relative_and_absolute() {
214 let root = Path::new("/repo");
215 let mut w = base();
216 w.path = PathBuf::from("/repo");
217 assert_eq!(path_cell(&w, root), ".");
218 w.path = PathBuf::from("/repo/.worktrees/x");
219 assert_eq!(path_cell(&w, root), ".worktrees/x");
220 w.path = PathBuf::from("/elsewhere/y");
221 assert_eq!(path_cell(&w, root), "/elsewhere/y");
222 }
223
224 #[test]
225 fn pr_cell_renders_number_and_state() {
226 let mut w = base();
227 assert_eq!(pr_cell(&w), "");
228 w.pr = Some(Pr {
229 number: 42,
230 state: PrState::Open,
231 title: "t".into(),
232 });
233 assert_eq!(pr_cell(&w), "#42 (open)");
234 }
235
236 #[test]
237 fn commit_cell_includes_hash_subject_time() {
238 let mut w = base();
239 assert_eq!(commit_cell(&w, 0), "");
240 let ts = "2024-01-15T10:30:00Z";
241 w.commit = Some(Commit {
242 hash: "abc1234".into(),
243 subject: "Add login".into(),
244 author: "Alice".into(),
245 timestamp: ts.into(),
246 });
247 let now = parse_iso8601(ts).unwrap() + 3 * 3600;
248 assert_eq!(commit_cell(&w, now), "abc1234 Add login (3h ago)");
249 }
250
251 #[test]
252 fn status_block_full() {
253 let mut w = base();
254 w.upstream = Some("origin/main".into());
255 w.base_ref = Some("develop".into());
256 w.ahead = Some(3);
257 w.behind = Some(0);
258 w.pr = Some(Pr {
259 number: 42,
260 state: PrState::Open,
261 title: "Add login page".into(),
262 });
263 let entries = vec![
264 StatusEntry {
265 marker: 'M',
266 path: "src/main.rs".into(),
267 },
268 StatusEntry {
269 marker: '?',
270 path: "scratch.txt".into(),
271 },
272 ];
273 let block = status_block(&w, &entries);
274 assert!(block.contains("worktree: /repo/main"));
275 assert!(block.contains("branch: main → origin/main"));
276 assert!(block.contains("base: develop"));
277 assert!(block.contains("ahead: 3 behind: 0"));
278 assert!(block.contains("pr: #42 (open) \"Add login page\""));
279 assert!(block.contains("dirty:\n M src/main.rs\n ? scratch.txt"));
280 }
281
282 #[test]
283 fn status_block_no_upstream_omits_ahead_behind() {
284 let w = base();
285 let block = status_block(&w, &[]);
286 assert!(block.contains("main (no upstream)"));
287 assert!(!block.contains("ahead:"));
288 assert!(!block.contains("dirty:"));
289 }
290
291 #[test]
292 fn status_block_missing_worktree() {
293 let mut w = base();
294 w.is_missing = true;
295 w.base_ref = Some("main".into());
296 let block = status_block(&w, &[]);
297 assert!(block.contains("(directory already deleted)"));
298 assert!(block.contains("base: main"));
299 assert!(!block.contains("ahead:"));
300 }
301}