crates/flotilla-tui/src/ui_helpers.rs
Line | Count | Source |
1 | | use std::path::Path; |
2 | | |
3 | | use flotilla_protocol::{ChangeRequestStatus, Checkout, SessionStatus, WorkItemKind}; |
4 | | use ratatui::{ |
5 | | layout::{Constraint, Flex, Layout, Rect}, |
6 | | style::Color, |
7 | | widgets::Block, |
8 | | }; |
9 | | |
10 | | use crate::theme::Theme; |
11 | | |
12 | | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
13 | | pub struct BottomAnchoredOverlayLayout { |
14 | | pub status_row: Rect, |
15 | | pub body: Rect, |
16 | | pub visible_body_rows: u16, |
17 | | } |
18 | | |
19 | | /// Truncate a string to `max` characters, appending '…' if truncated. |
20 | 556 | pub fn truncate(s: &str, max: usize) -> String { |
21 | 556 | if max == 0 { |
22 | 1 | return String::new(); |
23 | 555 | } |
24 | 555 | let char_count: usize = s.chars().count(); |
25 | 555 | if char_count <= max { |
26 | 503 | s.to_string() |
27 | | } else { |
28 | 52 | let truncated: String = s.chars().take(max - 1).collect(); |
29 | 52 | format!("{truncated}…") |
30 | | } |
31 | 556 | } |
32 | | |
33 | | /// Calculate a centered popup rectangle within `area`. |
34 | 18 | pub fn popup_area(area: Rect, percent_x: u16, percent_y: u16) -> Rect { |
35 | 18 | let [area] = Layout::vertical([Constraint::Percentage(percent_y)]).flex(Flex::Center).areas(area); |
36 | 18 | let [area] = Layout::horizontal([Constraint::Percentage(percent_x)]).flex(Flex::Center).areas(area); |
37 | 18 | area |
38 | 18 | } |
39 | | |
40 | | /// Calculate a centered popup area and its bordered inner area. |
41 | | /// |
42 | | /// Returns `(outer_area, inner_area)` where `inner_area` is the content area |
43 | | /// inside a `Block::bordered()` with the given title. |
44 | 1 | pub fn popup_frame(container: Rect, percent_x: u16, percent_y: u16, title: &str, style: ratatui::style::Style) -> (Rect, Rect) { |
45 | 1 | let area = popup_area(container, percent_x, percent_y); |
46 | 1 | let block = Block::bordered().style(style).title(title); |
47 | 1 | let inner = block.inner(area); |
48 | 1 | (area, inner) |
49 | 1 | } |
50 | | |
51 | | /// Render a popup frame: clear the area and draw a bordered block with title. |
52 | | /// Returns `(outer_area, inner_area)` for the caller to render content into. |
53 | 4 | pub fn render_popup_frame( |
54 | 4 | frame: &mut ratatui::Frame, |
55 | 4 | container: Rect, |
56 | 4 | percent_x: u16, |
57 | 4 | percent_y: u16, |
58 | 4 | title: &str, |
59 | 4 | style: ratatui::style::Style, |
60 | 4 | ) -> (Rect, Rect) { |
61 | 4 | let area = popup_area(container, percent_x, percent_y); |
62 | 4 | frame.render_widget(ratatui::widgets::Clear, area); |
63 | 4 | let block = Block::bordered().style(style).title(title); |
64 | 4 | let inner = block.inner(area); |
65 | 4 | frame.render_widget(block, area); |
66 | 4 | (area, inner) |
67 | 4 | } |
68 | | |
69 | 12 | pub fn bottom_anchored_overlay(frame_area: Rect, reserved_top_rows: u16, requested_body_rows: u16) -> BottomAnchoredOverlayLayout { |
70 | 12 | let visible_body_rows = requested_body_rows.min(frame_area.height.saturating_sub(reserved_top_rows.saturating_add(1))); |
71 | 12 | let status_row_y = frame_area.y + frame_area.height.saturating_sub(visible_body_rows.saturating_add(1)); |
72 | 12 | let status_row = Rect::new(frame_area.x, status_row_y, frame_area.width, 1); |
73 | 12 | let body = Rect::new(frame_area.x, status_row_y.saturating_add(1), frame_area.width, visible_body_rows); |
74 | 12 | BottomAnchoredOverlayLayout { status_row, body, visible_body_rows } |
75 | 12 | } |
76 | | |
77 | | /// Return (icon, color) for a work item based on its kind, workspace status, |
78 | | /// and optional session status. |
79 | 42 | pub fn work_item_icon( |
80 | 42 | kind: &WorkItemKind, |
81 | 42 | has_workspace: bool, |
82 | 42 | session_status: Option<&SessionStatus>, |
83 | 42 | theme: &Theme, |
84 | 42 | ) -> (&'static str, Color) { |
85 | 42 | match kind { |
86 | | WorkItemKind::Checkout => { |
87 | 28 | if has_workspace { |
88 | 1 | ("●", theme.checkout) |
89 | | } else { |
90 | 27 | ("○", theme.checkout) |
91 | | } |
92 | | } |
93 | 0 | WorkItemKind::AttachableSet => ("◎", theme.checkout), |
94 | 4 | WorkItemKind::Session => match session_status { |
95 | 2 | Some(SessionStatus::Running) => ("▶", theme.session), |
96 | 2 | Some(SessionStatus::Idle) => ("◆", theme.session), |
97 | 0 | Some(SessionStatus::Archived) => ("○", theme.session), |
98 | 0 | Some(SessionStatus::Expired) => ("⊘", theme.session), |
99 | 1 | None => ("○", theme.session), |
100 | | }, |
101 | 5 | WorkItemKind::ChangeRequest => ("⊙", theme.change_request), |
102 | 1 | WorkItemKind::RemoteBranch => ("⊶", theme.remote_branch), |
103 | 3 | WorkItemKind::Issue => ("◇", theme.issue), |
104 | 0 | WorkItemKind::Agent => ("⚙", theme.session), |
105 | | } |
106 | 42 | } |
107 | | |
108 | | /// Return the display icon for a session status. |
109 | 8 | pub fn session_status_display(status: &SessionStatus) -> &'static str { |
110 | 8 | match status { |
111 | 2 | SessionStatus::Running => "▶", |
112 | 2 | SessionStatus::Idle => "◆", |
113 | 2 | SessionStatus::Archived => "○", |
114 | 2 | SessionStatus::Expired => "⊘", |
115 | | } |
116 | 8 | } |
117 | | |
118 | | /// Return the display string for an agent status. |
119 | 0 | pub fn agent_status_display(status: &flotilla_protocol::AgentStatus) -> String { |
120 | 0 | match status { |
121 | 0 | flotilla_protocol::AgentStatus::Active => "▶".to_string(), |
122 | 0 | flotilla_protocol::AgentStatus::Idle => "◆".to_string(), |
123 | 0 | flotilla_protocol::AgentStatus::WaitingForPermission => "⏳".to_string(), |
124 | 0 | flotilla_protocol::AgentStatus::WaitingForInput => "⏳".to_string(), |
125 | 0 | flotilla_protocol::AgentStatus::Errored => "⚠".to_string(), |
126 | | } |
127 | 0 | } |
128 | | |
129 | | /// Return the display icon for a change request status. |
130 | 8 | pub fn change_request_status_icon(status: &ChangeRequestStatus) -> &'static str { |
131 | 8 | match status { |
132 | 1 | ChangeRequestStatus::Merged => "✓", |
133 | 1 | ChangeRequestStatus::Closed => "✗", |
134 | 6 | ChangeRequestStatus::Open | ChangeRequestStatus::Draft => "", |
135 | | } |
136 | 8 | } |
137 | | |
138 | | /// Build the git status indicator string (e.g. "MS?↑") from a checkout. |
139 | 20 | pub fn git_status_display(checkout: &Checkout) -> String { |
140 | 20 | let mut s = String::new(); |
141 | 20 | if checkout.working_tree.as_ref().is_some_and(|w| w.modified2 > 0) { |
142 | 2 | s.push('M'); |
143 | 18 | } |
144 | 20 | if checkout.working_tree.as_ref().is_some_and(|w| w.staged2 > 0) { |
145 | 1 | s.push('S'); |
146 | 19 | } |
147 | 20 | if checkout.working_tree.as_ref().is_some_and(|w| w.untracked2 > 0) { |
148 | 1 | s.push('?'); |
149 | 19 | } |
150 | 20 | if checkout.trunk_ahead_behind.as_ref().is_some_and(|m| m.ahead1 > 0) { |
151 | 1 | s.push('↑'); |
152 | 19 | } |
153 | 20 | s |
154 | 20 | } |
155 | | |
156 | | /// Return the checkout indicator: "◆" for main, "✓" for checked out, "" otherwise. |
157 | 30 | pub fn checkout_indicator(is_main: bool, has_checkout: bool) -> &'static str { |
158 | 30 | if is_main { |
159 | 11 | "◆" |
160 | 19 | } else if has_checkout { |
161 | 18 | "✓" |
162 | | } else { |
163 | 1 | "" |
164 | | } |
165 | 30 | } |
166 | | |
167 | | /// Shorten a checkout path for display in the table. |
168 | | /// |
169 | | /// Main checkout shows home-relative (e.g. `~/dev/flotilla`). |
170 | | /// Other checkouts are indented to show hierarchy: sibling worktrees show the |
171 | | /// suffix (e.g. `.low-hang-12`), nested worktrees show the relative path |
172 | | /// (e.g. `.worktrees/feat-auth`). Padding matches the parent-directory portion |
173 | | /// of the main display, but shrinks when `col_width` is tight so the actual |
174 | | /// name is preserved. |
175 | 41 | pub fn shorten_path(path: &Path, repo_root: &Path, col_width: usize, home_dir: Option<&Path>) -> String { |
176 | 41 | let main_display = shorten_against_home(repo_root, home_dir); |
177 | | |
178 | | // Main checkout — show the full shortened path. |
179 | 41 | if path == repo_root { |
180 | 9 | return main_display; |
181 | 32 | } |
182 | | |
183 | | // Ideal padding = width of the parent-directory portion of main_display. |
184 | 32 | let repo_name_len = repo_root.file_name().map(|n| n.to_string_lossy().len()).unwrap_or(0); |
185 | 32 | let ideal_padding = main_display.len().saturating_sub(repo_name_len); |
186 | | |
187 | | // Cap padding so it never exceeds half the column — leaves room for the name |
188 | | // even after the caller truncates. Crucially this is independent of the name |
189 | | // length, so every worktree at the same depth gets identical indentation. |
190 | 32 | let padding = ideal_padding.min(col_width / 2); |
191 | | |
192 | | // Under repo root (e.g. .worktrees/feat-auth, sub/dir) |
193 | 32 | if let Ok(rel23 ) = path.strip_prefix(repo_root) { |
194 | 23 | let s = rel.to_string_lossy(); |
195 | 23 | if !s.is_empty() { |
196 | 23 | let name = s.into_owned(); |
197 | 23 | return format!("{:padding$}{name}", ""); |
198 | 0 | } |
199 | 9 | } |
200 | | |
201 | | // Sibling or descendant of sibling (shares repo name prefix in first component) |
202 | 9 | if let Some(root_parent) = repo_root.parent() { |
203 | 9 | if let Ok(rel8 ) = path.strip_prefix(root_parent) { |
204 | 8 | let rel_str = rel.to_string_lossy(); |
205 | 8 | let root_name = repo_root.file_name().map(|n| n.to_string_lossy()).unwrap_or_default(); |
206 | | // Only handle paths whose first component starts with the repo name |
207 | | // (e.g. "flotilla.quick-wins/..." but not "unrelated/...") |
208 | 8 | if rel_str.starts_with(root_name.as_ref()) { |
209 | | // Strip repo name prefix to get the suffix |
210 | | // e.g. "flotilla.quick-wins" -> ".quick-wins" |
211 | | // e.g. "flotilla.quick-wins/.claude/worktrees/agent-x" -> ".quick-wins/.claude/worktrees/agent-x" |
212 | 3 | let suffix = rel_str.strip_prefix(root_name.as_ref()).unwrap_or(&rel_str); |
213 | | // If nested under a sibling (contains '/'), strip the sibling dir |
214 | | // and show only the sub-path with extra indentation. |
215 | | // e.g. ".quick-wins/.claude/worktrees/agent-x" -> ".claude/worktrees/agent-x" |
216 | 3 | let (name, extra_indent) = match suffix.find('/') { |
217 | 1 | Some(pos) => (&suffix[pos + 1..], padding + 2), |
218 | 2 | None => (suffix, padding), |
219 | | }; |
220 | 3 | let p = extra_indent.min(col_width / 2); |
221 | 3 | return format!("{:p$}{name}", ""); |
222 | 5 | } |
223 | 1 | } |
224 | 0 | } |
225 | | |
226 | | // Elsewhere — shorten against home directory. |
227 | 6 | shorten_against_home(path, home_dir) |
228 | 41 | } |
229 | | |
230 | 47 | fn shorten_against_home(path: &Path, home_dir: Option<&Path>) -> String { |
231 | 47 | if let Some(home22 ) = home_dir { |
232 | 22 | if let Ok(rel5 ) = path.strip_prefix(home) { |
233 | 5 | let s = rel.to_string_lossy(); |
234 | 5 | if s.is_empty() { |
235 | 0 | return "~".to_string(); |
236 | 5 | } |
237 | 5 | return format!("~/{s}"); |
238 | 17 | } |
239 | 25 | } |
240 | 42 | path.display().to_string() |
241 | 47 | } |
242 | | |
243 | | const BRAILLE_SPINNER: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧']; |
244 | | |
245 | 4 | pub fn spinner_char() -> char { |
246 | | use std::time::{SystemTime, UNIX_EPOCH}; |
247 | 4 | let ms = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis(); |
248 | 4 | BRAILLE_SPINNER[(ms / 100) as usize % BRAILLE_SPINNER.len()] |
249 | 4 | } |
250 | | |
251 | | /// Return the workspace indicator: "" for 0, "●" for 1, count as string for >1. |
252 | 30 | pub fn workspace_indicator(count: usize) -> String { |
253 | 30 | match count { |
254 | 27 | 0 => String::new(), |
255 | 1 | 1 => "●".to_string(), |
256 | 2 | n => format!("{n}"), |
257 | | } |
258 | 30 | } |
259 | | |
260 | | #[cfg(test)] |
261 | | mod tests { |
262 | | use flotilla_protocol::{AheadBehind, WorkingTreeStatus}; |
263 | | |
264 | | use super::*; |
265 | | use crate::theme::Theme; |
266 | | |
267 | | #[test] |
268 | 1 | fn truncate_empty_max() { |
269 | 1 | assert_eq!(truncate("hello", 0), ""); |
270 | 1 | } |
271 | | |
272 | | #[test] |
273 | 1 | fn truncate_short_string() { |
274 | 1 | assert_eq!(truncate("hi", 5), "hi"); |
275 | 1 | } |
276 | | |
277 | | #[test] |
278 | 1 | fn truncate_exact_length() { |
279 | 1 | assert_eq!(truncate("hello", 5), "hello"); |
280 | 1 | } |
281 | | |
282 | | #[test] |
283 | 1 | fn truncate_long_string() { |
284 | 1 | assert_eq!(truncate("hello world", 5), "hell…"); |
285 | 1 | } |
286 | | |
287 | | #[test] |
288 | 1 | fn truncate_max_one() { |
289 | | // max=1 means 0 chars + ellipsis |
290 | 1 | assert_eq!(truncate("hello", 1), "…"); |
291 | 1 | } |
292 | | |
293 | | #[test] |
294 | 1 | fn popup_area_centered() { |
295 | 1 | let area = Rect::new(0, 0, 100, 50); |
296 | 1 | let popup = popup_area(area, 50, 50); |
297 | | // Should be centered and smaller |
298 | 1 | assert!(popup.width <= 50); |
299 | 1 | assert!(popup.height <= 25); |
300 | 1 | assert!(popup.x > 0); |
301 | 1 | assert!(popup.y > 0); |
302 | 1 | } |
303 | | |
304 | | #[test] |
305 | 1 | fn popup_frame_returns_inner_area() { |
306 | 1 | let area = Rect::new(0, 0, 100, 50); |
307 | 1 | let (popup, inner) = popup_frame(area, 50, 50, " Test ", ratatui::style::Style::default()); |
308 | | // Popup should be centered |
309 | 1 | assert!(popup.x > 0); |
310 | 1 | assert!(popup.y > 0); |
311 | | // Inner should be inset by border (1px each side) |
312 | 1 | assert_eq!(inner.x, popup.x + 1); |
313 | 1 | assert_eq!(inner.y, popup.y + 1); |
314 | 1 | assert_eq!(inner.width, popup.width - 2); |
315 | 1 | assert_eq!(inner.height, popup.height - 2); |
316 | 1 | } |
317 | | |
318 | | #[test] |
319 | 1 | fn bottom_anchored_overlay_clamps_body_rows_to_available_space() { |
320 | 1 | let layout = bottom_anchored_overlay(Rect::new(0, 0, 40, 6), 1, 8); |
321 | | |
322 | 1 | assert_eq!(layout.visible_body_rows, 4); |
323 | 1 | assert_eq!(layout.status_row, Rect::new(0, 1, 40, 1)); |
324 | 1 | assert_eq!(layout.body, Rect::new(0, 2, 40, 4)); |
325 | 1 | } |
326 | | |
327 | | #[test] |
328 | 1 | fn bottom_anchored_overlay_preserves_requested_rows_when_space_allows() { |
329 | 1 | let layout = bottom_anchored_overlay(Rect::new(0, 0, 40, 12), 1, 3); |
330 | | |
331 | 1 | assert_eq!(layout.visible_body_rows, 3); |
332 | 1 | assert_eq!(layout.status_row, Rect::new(0, 8, 40, 1)); |
333 | 1 | assert_eq!(layout.body, Rect::new(0, 9, 40, 3)); |
334 | 1 | } |
335 | | |
336 | | #[test] |
337 | 1 | fn work_item_icon_checkout_with_workspace() { |
338 | 1 | let (icon, color) = work_item_icon(&WorkItemKind::Checkout, true, None, &Theme::classic()); |
339 | 1 | assert_eq!(icon, "●"); |
340 | 1 | assert_eq!(color, Color::Green); |
341 | 1 | } |
342 | | |
343 | | #[test] |
344 | 1 | fn work_item_icon_checkout_without_workspace() { |
345 | 1 | let (icon, color) = work_item_icon(&WorkItemKind::Checkout, false, None, &Theme::classic()); |
346 | 1 | assert_eq!(icon, "○"); |
347 | 1 | assert_eq!(color, Color::Green); |
348 | 1 | } |
349 | | |
350 | | #[test] |
351 | 1 | fn work_item_icon_session_running() { |
352 | 1 | let (icon, color) = work_item_icon(&WorkItemKind::Session, false, Some(&SessionStatus::Running), &Theme::classic()); |
353 | 1 | assert_eq!(icon, "▶"); |
354 | 1 | assert_eq!(color, Color::Magenta); |
355 | 1 | } |
356 | | |
357 | | #[test] |
358 | 1 | fn work_item_icon_session_idle() { |
359 | 1 | let (icon, color) = work_item_icon(&WorkItemKind::Session, false, Some(&SessionStatus::Idle), &Theme::classic()); |
360 | 1 | assert_eq!(icon, "◆"); |
361 | 1 | assert_eq!(color, Color::Magenta); |
362 | 1 | } |
363 | | |
364 | | #[test] |
365 | 1 | fn work_item_icon_session_none() { |
366 | 1 | let (icon, color) = work_item_icon(&WorkItemKind::Session, false, None, &Theme::classic()); |
367 | 1 | assert_eq!(icon, "○"); |
368 | 1 | assert_eq!(color, Color::Magenta); |
369 | 1 | } |
370 | | |
371 | | #[test] |
372 | 1 | fn work_item_icon_change_request() { |
373 | 1 | let (icon, color) = work_item_icon(&WorkItemKind::ChangeRequest, false, None, &Theme::classic()); |
374 | 1 | assert_eq!(icon, "⊙"); |
375 | 1 | assert_eq!(color, Color::Blue); |
376 | 1 | } |
377 | | |
378 | | #[test] |
379 | 1 | fn work_item_icon_remote_branch() { |
380 | 1 | let (icon, color) = work_item_icon(&WorkItemKind::RemoteBranch, false, None, &Theme::classic()); |
381 | 1 | assert_eq!(icon, "⊶"); |
382 | 1 | assert_eq!(color, Color::DarkGray); |
383 | 1 | } |
384 | | |
385 | | #[test] |
386 | 1 | fn work_item_icon_issue() { |
387 | 1 | let (icon, color) = work_item_icon(&WorkItemKind::Issue, false, None, &Theme::classic()); |
388 | 1 | assert_eq!(icon, "◇"); |
389 | 1 | assert_eq!(color, Color::Yellow); |
390 | 1 | } |
391 | | |
392 | | #[test] |
393 | 1 | fn session_status_display_all() { |
394 | 1 | assert_eq!(session_status_display(&SessionStatus::Running), "▶"); |
395 | 1 | assert_eq!(session_status_display(&SessionStatus::Idle), "◆"); |
396 | 1 | assert_eq!(session_status_display(&SessionStatus::Archived), "○"); |
397 | 1 | assert_eq!(session_status_display(&SessionStatus::Expired), "⊘"); |
398 | 1 | } |
399 | | |
400 | | #[test] |
401 | 1 | fn expired_icon_differs_from_archived() { |
402 | 1 | assert_ne!( |
403 | 1 | session_status_display(&SessionStatus::Expired), |
404 | 1 | session_status_display(&SessionStatus::Archived), |
405 | | "expired and archived should have distinct icons" |
406 | | ); |
407 | 1 | } |
408 | | |
409 | | #[test] |
410 | 1 | fn change_request_status_icon_all() { |
411 | 1 | assert_eq!(change_request_status_icon(&ChangeRequestStatus::Merged), "✓"); |
412 | 1 | assert_eq!(change_request_status_icon(&ChangeRequestStatus::Closed), "✗"); |
413 | 1 | assert_eq!(change_request_status_icon(&ChangeRequestStatus::Open), ""); |
414 | 1 | assert_eq!(change_request_status_icon(&ChangeRequestStatus::Draft), ""); |
415 | 1 | } |
416 | | |
417 | | #[test] |
418 | 1 | fn git_status_display_empty() { |
419 | 1 | let co = Checkout { |
420 | 1 | branch: "main".into(), |
421 | 1 | is_main: true, |
422 | 1 | trunk_ahead_behind: None, |
423 | 1 | remote_ahead_behind: None, |
424 | 1 | working_tree: None, |
425 | 1 | last_commit: None, |
426 | 1 | correlation_keys: vec![], |
427 | 1 | association_keys: vec![], |
428 | 1 | environment_id: None, |
429 | 1 | }; |
430 | 1 | assert_eq!(git_status_display(&co), ""); |
431 | 1 | } |
432 | | |
433 | | #[test] |
434 | 1 | fn git_status_display_all_flags() { |
435 | 1 | let co = Checkout { |
436 | 1 | branch: "feat".into(), |
437 | 1 | is_main: false, |
438 | 1 | trunk_ahead_behind: Some(AheadBehind { ahead: 3, behind: 0 }), |
439 | 1 | remote_ahead_behind: None, |
440 | 1 | working_tree: Some(WorkingTreeStatus { modified: 2, staged: 1, untracked: 4 }), |
441 | 1 | last_commit: None, |
442 | 1 | correlation_keys: vec![], |
443 | 1 | association_keys: vec![], |
444 | 1 | environment_id: None, |
445 | 1 | }; |
446 | 1 | assert_eq!(git_status_display(&co), "MS?↑"); |
447 | 1 | } |
448 | | |
449 | | #[test] |
450 | 1 | fn git_status_display_partial() { |
451 | 1 | let co = Checkout { |
452 | 1 | branch: "fix".into(), |
453 | 1 | is_main: false, |
454 | 1 | trunk_ahead_behind: None, |
455 | 1 | remote_ahead_behind: None, |
456 | 1 | working_tree: Some(WorkingTreeStatus { modified: 1, staged: 0, untracked: 0 }), |
457 | 1 | last_commit: None, |
458 | 1 | correlation_keys: vec![], |
459 | 1 | association_keys: vec![], |
460 | 1 | environment_id: None, |
461 | 1 | }; |
462 | 1 | assert_eq!(git_status_display(&co), "M"); |
463 | 1 | } |
464 | | |
465 | | #[test] |
466 | 1 | fn checkout_indicator_main() { |
467 | 1 | assert_eq!(checkout_indicator(true, true), "◆"); |
468 | 1 | assert_eq!(checkout_indicator(true, false), "◆"); |
469 | 1 | } |
470 | | |
471 | | #[test] |
472 | 1 | fn checkout_indicator_checked_out() { |
473 | 1 | assert_eq!(checkout_indicator(false, true), "✓"); |
474 | 1 | } |
475 | | |
476 | | #[test] |
477 | 1 | fn checkout_indicator_none() { |
478 | 1 | assert_eq!(checkout_indicator(false, false), ""); |
479 | 1 | } |
480 | | |
481 | | #[test] |
482 | 1 | fn workspace_indicator_values() { |
483 | 1 | assert_eq!(workspace_indicator(0), ""); |
484 | 1 | assert_eq!(workspace_indicator(1), "●"); |
485 | 1 | assert_eq!(workspace_indicator(2), "2"); |
486 | 1 | assert_eq!(workspace_indicator(10), "10"); |
487 | 1 | } |
488 | | |
489 | | #[test] |
490 | 1 | fn spinner_char_returns_valid_braille() { |
491 | 1 | let ch = spinner_char(); |
492 | 1 | assert!(BRAILLE_SPINNER.contains(&ch)); |
493 | 1 | } |
494 | | |
495 | | #[test] |
496 | 1 | fn shorten_path_main_checkout() { |
497 | 1 | let root = Path::new("/dev/project"); |
498 | 1 | assert_eq!(shorten_path(root, root, 40, None), "/dev/project"); |
499 | 1 | } |
500 | | |
501 | | #[test] |
502 | 1 | fn shorten_path_main_checkout_under_home() { |
503 | 1 | let home = dirs::home_dir().expect("home dir"); |
504 | 1 | let root = home.join("dev/flotilla"); |
505 | 1 | assert_eq!(shorten_path(&root, &root, 40, Some(&home)), "~/dev/flotilla"); |
506 | 1 | } |
507 | | |
508 | | #[test] |
509 | 1 | fn shorten_path_worktree_wide_column() { |
510 | | // Wide column — full padding (5 = len("/dev/")). |
511 | 1 | let root = Path::new("/dev/project"); |
512 | 1 | let wt = Path::new("/dev/project/.worktrees/feat-auth"); |
513 | 1 | assert_eq!(shorten_path(wt, root, 40, None), " .worktrees/feat-auth"); |
514 | 1 | } |
515 | | |
516 | | #[test] |
517 | 1 | fn shorten_path_worktree_narrow_column() { |
518 | | // Narrow column — padding is consistent (capped at col/2), caller truncates. |
519 | 1 | let root = Path::new("/dev/project"); |
520 | 1 | let wt = Path::new("/dev/project/.worktrees/feat-auth"); |
521 | | // ideal_padding = 5, col/2 = 11 → padding = 5 (same as wide) |
522 | 1 | assert_eq!(shorten_path(wt, root, 22, None), " .worktrees/feat-auth"); |
523 | 1 | } |
524 | | |
525 | | #[test] |
526 | 1 | fn shorten_path_worktree_very_narrow() { |
527 | | // Very narrow — padding capped at col/2 = 5, still consistent indent. |
528 | 1 | let root = Path::new("/dev/project"); |
529 | 1 | let wt = Path::new("/dev/project/.worktrees/feat-auth"); |
530 | 1 | assert_eq!(shorten_path(wt, root, 10, None), " .worktrees/feat-auth"); |
531 | 1 | } |
532 | | |
533 | | #[test] |
534 | 1 | fn shorten_path_relative() { |
535 | 1 | let root = Path::new("/dev/project"); |
536 | 1 | let sub = Path::new("/dev/project/sub/dir"); |
537 | 1 | assert_eq!(shorten_path(sub, root, 40, None), " sub/dir"); |
538 | 1 | } |
539 | | |
540 | | #[test] |
541 | 1 | fn shorten_path_nested_worktree() { |
542 | 1 | let root = Path::new("/dev/project"); |
543 | 1 | let wt = Path::new("/dev/project/.worktrees/group/feat-auth"); |
544 | 1 | assert_eq!(shorten_path(wt, root, 40, None), " .worktrees/group/feat-auth"); |
545 | 1 | } |
546 | | |
547 | | #[test] |
548 | 1 | fn shorten_path_sibling_worktree() { |
549 | | // padding = len("/dev/") = 5 |
550 | 1 | let root = Path::new("/dev/flotilla"); |
551 | 1 | let wt = Path::new("/dev/flotilla.feat-xyz"); |
552 | 1 | assert_eq!(shorten_path(wt, root, 40, None), " .feat-xyz"); |
553 | 1 | } |
554 | | |
555 | | #[test] |
556 | 1 | fn shorten_path_sibling_different_name() { |
557 | | // Sibling with a different name prefix is not a related worktree |
558 | 1 | let root = Path::new("/dev/flotilla"); |
559 | 1 | let wt = Path::new("/dev/other-project"); |
560 | 1 | assert_eq!(shorten_path(wt, root, 40, None), "/dev/other-project"); |
561 | 1 | } |
562 | | |
563 | | #[test] |
564 | 1 | fn shorten_path_sibling_under_home() { |
565 | | // padding = len("~/dev/") = 6 |
566 | 1 | let home = dirs::home_dir().expect("home dir"); |
567 | 1 | let root = home.join("dev/flotilla"); |
568 | 1 | let wt = home.join("dev/flotilla.low-hang-12"); |
569 | 1 | assert_eq!(shorten_path(&wt, &root, 40, Some(&home)), " .low-hang-12"); |
570 | 1 | } |
571 | | |
572 | | #[test] |
573 | 1 | fn shorten_path_nested_under_sibling() { |
574 | | // Worktree created by Claude agent under a sibling worktree. |
575 | | // Strips the sibling dir (.quick-wins) and adds extra indent. |
576 | | // padding = len("/dev/") = 5, +2 extra = 7 |
577 | 1 | let root = Path::new("/dev/flotilla"); |
578 | 1 | let wt = Path::new("/dev/flotilla.quick-wins/.claude/worktrees/agent-abc"); |
579 | 1 | assert_eq!(shorten_path(wt, root, 60, None), " .claude/worktrees/agent-abc"); |
580 | 1 | } |
581 | | |
582 | | #[test] |
583 | 1 | fn shorten_path_unrelated_under_same_parent() { |
584 | | // Unrelated directory under the same parent should NOT be treated as sibling |
585 | 1 | let root = Path::new("/dev/flotilla"); |
586 | 1 | let other = Path::new("/dev/unrelated/sub"); |
587 | 1 | assert_eq!(shorten_path(other, root, 40, None), "/dev/unrelated/sub"); |
588 | 1 | } |
589 | | |
590 | | #[test] |
591 | 1 | fn shorten_path_outside_root() { |
592 | 1 | let root = Path::new("/tmp/project"); |
593 | 1 | let other = Path::new("/elsewhere/wt"); |
594 | 1 | assert_eq!(shorten_path(other, root, 40, None), "/elsewhere/wt"); |
595 | 1 | } |
596 | | |
597 | | #[test] |
598 | 1 | fn shorten_path_remote_host_home() { |
599 | 1 | let remote_home = Path::new("/home/remoteuser"); |
600 | 1 | let root = Path::new("/home/remoteuser/dev/project"); |
601 | 1 | assert_eq!(shorten_path(root, root, 40, Some(remote_home)), "~/dev/project"); |
602 | 1 | } |
603 | | |
604 | | #[test] |
605 | 1 | fn shorten_path_no_home_dir() { |
606 | 1 | let root = Path::new("/srv/repos/project"); |
607 | 1 | assert_eq!(shorten_path(root, root, 40, None), "/srv/repos/project"); |
608 | 1 | } |
609 | | } |