1use super::app::{App, GitHubFetchState, LinkPromptStage, LinkTarget, View};
2use super::keymap::{Action, KeyStroke, Keymap};
3use super::modal_keymap::{KeyContext, ModalAction, ModalKeymap};
4use super::state::async_task::TaskKind;
5use super::state::config_panel::{FieldKind, SettingField, SettingsTab};
6use super::state::confirm::ConfirmButton;
7use super::state::create_form::{Field, Mode};
8
9const CANONICAL_TRIPLE: [Field; 3] = [Field::Type, Field::Issue, Field::Desc];
14use super::state::pty_overlay::PtyKind;
15use super::state::sidebar::SidebarMode;
16use super::state::spinner::DOT_FRAMES;
17use super::theme::Theme;
18use super::wt_tree::{self, working_tree_category, WtCategory, WtNode, WT_DIR_OPEN_ICON};
19use crate::bootstrap::{BootstrapReport, StepStatus};
20use crate::command_log::CommandStatus;
21use crate::config::ConfigSource;
22use crate::github::{CiState, IssueState, LinkSource, PrState};
23use crate::worktree::{self, BranchStatus, WorktreeInfo};
24use ratatui::{
25 buffer::Buffer,
26 layout::{Alignment, Constraint, Direction, Layout, Rect},
27 style::{Color, Modifier, Style},
28 text::{Line, Span},
29 widgets::{
30 Block, BorderType, Borders, Cell, Clear, Padding, Paragraph, Row, Scrollbar, ScrollbarOrientation, ScrollbarState,
31 Table, Widget, Wrap,
32 },
33 Frame,
34};
35use std::time::{Duration, Instant};
36
37#[derive(Debug, Clone, Default)]
45pub struct SidebarSections {
46 pub worktree: Vec<Line<'static>>,
50 pub working_tree: Vec<Line<'static>>,
52 pub working_tree_counts: WorkingTreeCounts,
56 pub recent_commits: Vec<Line<'static>>,
58}
59
60#[derive(Debug, Clone, Copy)]
62pub enum LoaderWidgetState<'a> {
63 Running {
64 glyph: &'a str,
65 label: &'a str,
66 detail: Option<&'a str>,
67 },
68 Failed {
69 message: &'a str,
70 detail: Option<&'a str>,
71 },
72}
73
74#[derive(Debug, Clone, Copy)]
75pub struct LoaderWidget<'a> {
76 state: LoaderWidgetState<'a>,
77 accent: Color,
78 text: Color,
79 muted: Color,
80 failed: Color,
81 alignment: Alignment,
82}
83
84impl<'a> LoaderWidget<'a> {
85 pub fn running(glyph: &'a str, label: &'a str, detail: Option<&'a str>, theme: &Theme) -> Self {
86 Self {
87 state: LoaderWidgetState::Running { glyph, label, detail },
88 accent: theme.accent,
89 text: theme.name,
90 muted: theme.muted,
91 failed: theme.prunable,
92 alignment: Alignment::Left,
93 }
94 }
95
96 pub fn failed(message: &'a str, detail: Option<&'a str>, theme: &Theme) -> Self {
97 Self {
98 state: LoaderWidgetState::Failed { message, detail },
99 accent: theme.accent,
100 text: theme.name,
101 muted: theme.muted,
102 failed: theme.prunable,
103 alignment: Alignment::Left,
104 }
105 }
106
107 pub fn alignment(mut self, alignment: Alignment) -> Self {
108 self.alignment = alignment;
109 self
110 }
111
112 fn line(self) -> Line<'static> {
113 let mut spans = match self.state {
114 LoaderWidgetState::Running { glyph, label, .. } => vec![
115 Span::styled(
116 format!("{glyph} "),
117 Style::default().fg(self.accent).add_modifier(Modifier::BOLD),
118 ),
119 Span::styled(
120 label.to_string(),
121 Style::default().fg(self.text).add_modifier(Modifier::BOLD),
122 ),
123 ],
124 LoaderWidgetState::Failed { message, .. } => vec![
125 Span::styled("! ", Style::default().fg(self.failed).add_modifier(Modifier::BOLD)),
126 Span::styled(
127 message.to_string(),
128 Style::default().fg(self.failed).add_modifier(Modifier::BOLD),
129 ),
130 ],
131 };
132
133 let detail = match self.state {
134 LoaderWidgetState::Running { detail, .. } | LoaderWidgetState::Failed { detail, .. } => detail,
135 };
136 if let Some(detail) = detail {
137 spans.push(Span::styled(" — ", Style::default().fg(self.muted)));
138 spans.push(Span::styled(detail.to_string(), Style::default().fg(self.muted)));
139 }
140 Line::from(spans)
141 }
142}
143
144impl Widget for LoaderWidget<'_> {
145 fn render(self, area: Rect, buf: &mut Buffer) {
146 Paragraph::new(self.line()).alignment(self.alignment).render(area, buf);
147 }
148}
149
150pub fn draw(f: &mut Frame, app: &mut App) {
151 let chunks = Layout::default()
156 .direction(Direction::Vertical)
157 .constraints([Constraint::Length(1), Constraint::Min(0), Constraint::Length(1)])
158 .split(f.area());
159
160 draw_header(f, chunks[0], app);
161 draw_body(f, chunks[1], app);
162 draw_footer(f, chunks[2], app);
163
164 match app.view {
165 View::Help => draw_help(f, app),
166 View::Create => draw_create(f, app),
167 View::Confirm => draw_confirm(f, app),
168 View::Report => draw_report(f, app),
169 View::OpenMenu => draw_open_menu(f, app),
170 View::LinkPrompt => draw_link_prompt(f, app),
171 View::CommandPalette => draw_command_palette(f, app),
172 View::CommandLogs => draw_command_logs(f, app),
173 View::Config => draw_config_panel(f, app),
174 View::Pty => draw_pty_overlay(f, app),
175 View::ExecPicker => draw_exec_picker(f, app),
177 View::CleanReport => draw_clean_overlay(f, app),
179 View::Edit => draw_edit_worktree(f, app),
181 View::DetailOverlay => draw_detail_overlay(f, app),
183 View::List => {}
184 }
185}
186
187pub fn header_line(
207 repo_name: &str,
208 workdir_display: &str,
209 picker_mode: bool,
210 width: usize,
211 theme: &Theme,
212) -> Line<'static> {
213 if width == 0 {
216 return Line::default();
217 }
218
219 let sanitize = |s: &str| -> String { s.chars().map(|c| if c.is_control() { ' ' } else { c }).collect() };
220 let repo = sanitize(repo_name);
221 let path = sanitize(workdir_display);
222
223 let version_style = chip_style(theme.accent);
224 let dir_badge_style = chip_style(theme.name);
225 let picker_style = chip_style(theme.dirty);
229 let path_style = Style::default().fg(theme.muted);
230
231 let version_text = format!(" gwm {} ", env!("CARGO_PKG_VERSION"));
232 let version_w = version_text.chars().count();
233 let dir_text = format!(" {} ", repo);
234 let dir_w = dir_text.chars().count();
235
236 if width < version_w {
239 return Line::from(Span::styled(trunc(&version_text, width), version_style));
240 }
241
242 let mut spans: Vec<Span<'static>> = Vec::new();
243 let mut used = 0usize;
244
245 let dir_budget = width.saturating_sub(version_w + 1);
249 if dir_w <= dir_budget {
250 spans.push(Span::styled(dir_text, dir_badge_style));
251 used += dir_w;
252 } else if dir_budget > 0 {
253 let clipped = trunc(&dir_text, dir_budget);
254 used += clipped.chars().count();
255 spans.push(Span::styled(clipped, dir_badge_style));
256 }
257
258 if picker_mode {
261 let picker_text = " picker ".to_string();
262 let need = 1 + picker_text.chars().count(); if used + need + version_w < width {
264 spans.push(Span::raw(" "));
265 spans.push(Span::styled(picker_text, picker_style));
266 used += need;
267 }
268 }
269
270 let path_gap = 2usize;
274 if used + path_gap + version_w < width {
275 let avail = width - used - path_gap - version_w;
276 let path_disp = trunc(&path, avail);
277 if !path_disp.is_empty() {
278 let w = path_disp.chars().count();
279 spans.push(Span::raw(" "));
280 spans.push(Span::styled(path_disp, path_style));
281 used += path_gap + w;
282 }
283 }
284
285 let pad = width.saturating_sub(used + version_w);
286 if pad > 0 {
287 spans.push(Span::raw(" ".repeat(pad)));
288 }
289 spans.push(Span::styled(version_text, version_style));
290
291 Line::from(spans)
292}
293
294fn draw_body(f: &mut Frame, area: Rect, app: &mut App) {
303 use super::state::sidebar::ResolvedSidebarLayout as Resolved;
304
305 let layout = app.sidebar.resolve_layout(area.width);
306 let (table_pct, sidebar_pct) = match layout.split_percentages() {
307 Some((t, s)) => (Constraint::Percentage(t), Constraint::Percentage(s)),
308 None => {
309 app.sidebar.max_scroll = 0;
311 app.sidebar.wt_max_scroll = 0;
312 draw_list(f, area, app);
313 return;
314 }
315 };
316
317 match layout {
318 Resolved::Hidden => unreachable!("Hidden returns None from split_percentages, handled above"),
319 Resolved::SideBySide { sidebar_left } => {
320 let split = Layout::default()
321 .direction(Direction::Horizontal)
322 .constraints(if sidebar_left {
323 [sidebar_pct, table_pct]
324 } else {
325 [table_pct, sidebar_pct]
326 })
327 .split(area);
328 let (list_area, sidebar_area) = if sidebar_left {
329 (split[1], split[0])
330 } else {
331 (split[0], split[1])
332 };
333 draw_list(f, list_area, app);
334 draw_sidebar(f, sidebar_area, app);
335 }
336 Resolved::Stacked => {
337 let split = Layout::default()
341 .direction(Direction::Vertical)
342 .constraints([table_pct, sidebar_pct])
343 .split(area);
344 draw_list(f, split[0], app);
345 draw_sidebar(f, split[1], app);
346 }
347 }
348}
349
350fn draw_header(f: &mut Frame, area: Rect, app: &App) {
351 let workdir = tilde_compress(&app.workdir.to_string_lossy());
356 let line = header_line(
360 &app.display_repo_name,
363 &workdir,
364 app.picker_mode,
365 area.width as usize,
366 &app.theme,
367 );
368 f.render_widget(Paragraph::new(line), area);
369}
370
371pub fn panel_border_color(focused: bool, theme: &super::theme::Theme) -> Color {
377 if focused {
378 theme.focus
379 } else {
380 theme.muted
381 }
382}
383
384pub fn worktrees_pane_title(
396 query: &str,
397 active: bool,
398 visible: usize,
399 total: usize,
400 filter_color: Color,
401) -> Line<'static> {
402 let mut spans = vec![Span::raw(" [1] Worktrees ")];
403 if active || !query.is_empty() {
406 spans.push(Span::styled(
407 "/",
408 Style::default().fg(filter_color).add_modifier(Modifier::BOLD),
409 ));
410 spans.push(Span::raw(query.to_string()));
411 if active {
412 spans.push(Span::styled(
413 "\u{2588}",
414 Style::default().fg(filter_color).add_modifier(Modifier::SLOW_BLINK),
415 ));
416 }
417 spans.push(Span::raw(" "));
418 }
419 let counter = if query.is_empty() {
423 format!("({}) ", total)
424 } else {
425 format!("({}/{}) ", visible, total)
426 };
427 spans.push(Span::raw(counter));
428 Line::from(spans)
429}
430
431pub fn status_pane_title() -> &'static str {
437 " [2] Status "
438}
439
440pub fn pane_counter(selected: usize, visible: usize) -> Option<String> {
447 if visible == 0 {
448 None
449 } else {
450 Some(format!(" {} of {} ", selected, visible))
451 }
452}
453
454fn draw_list(f: &mut Frame, area: Rect, app: &mut App) {
455 let filtered: Vec<usize> = app.filtered_indices().to_vec();
464 let visible: Vec<&WorktreeInfo> = filtered.iter().filter_map(|&i| app.worktrees.get(i)).collect();
465 let theme = app.theme;
469
470 let is_workspace = app.is_workspace();
474 let repo_names: Vec<String> = if is_workspace {
475 filtered
476 .iter()
477 .map(|&raw| app.row_repo_name(raw).unwrap_or("?").to_string())
478 .collect()
479 } else {
480 Vec::new()
481 };
482 let repo_w = if is_workspace {
483 column_width(repo_names.iter().map(|s| s.as_str()), 6, 24)
484 } else {
485 0
486 };
487
488 let name_w = column_width(visible.iter().map(|w| w.name.as_str()), 18, 38);
492 let branch_w = column_width(visible.iter().map(|w| w.branch.as_deref().unwrap_or("-")), 18, 38);
493 let status_w: u16 = 16;
494
495 let mut header_cells = vec![Cell::from("")];
498 if is_workspace {
499 header_cells.push(Cell::from("REPO"));
500 }
501 let show_agent = app.any_agent_sessions();
505 header_cells.push(Cell::from("I/P"));
506 header_cells.push(Cell::from("NAME"));
507 header_cells.push(Cell::from("BRANCH"));
508 header_cells.push(Cell::from("STATUS"));
509 if show_agent {
510 header_cells.push(Cell::from("AGENT"));
511 }
512 header_cells.push(Cell::from("PATH"));
513 let header = Row::new(header_cells).style(Style::default().fg(theme.muted).add_modifier(Modifier::BOLD));
514
515 let now = std::time::SystemTime::now();
519 let agent_cells: Vec<Option<(&'static str, crate::agent_sessions::Freshness)>> = visible
520 .iter()
521 .map(|w| agent_cell_label(app.agents_for(w), now))
522 .collect();
523
524 let rows: Vec<Row> = visible
525 .iter()
526 .enumerate()
527 .map(|(vi, w)| {
528 let repo = is_workspace.then(|| (repo_names[vi].as_str(), repo_w));
529 let agent = show_agent.then_some(agent_cells[vi]);
530 build_row(w, repo, name_w, branch_w, status_w, agent, &theme)
531 })
532 .collect();
533
534 let mut widths = vec![Constraint::Length(4)];
548 if is_workspace {
549 widths.push(Constraint::Length(repo_w));
552 }
553 widths.extend([
554 Constraint::Length(3),
555 Constraint::Min(name_w),
556 Constraint::Min(branch_w),
557 Constraint::Length(status_w),
558 ]);
559 if show_agent {
560 widths.push(Constraint::Length(8));
563 }
564 widths.push(Constraint::Fill(1));
565
566 let list_has_focus = !(app.sidebar.open && app.sidebar.focused);
567 let border_color = panel_border_color(list_has_focus, &app.theme);
568
569 let title = worktrees_pane_title(
570 app.filter.query(),
571 app.filter.active,
572 visible.len(),
573 app.worktrees.len(),
574 app.theme.dirty,
575 );
576
577 let selected_1based = app.list_state.selected().map(|i| i + 1).unwrap_or(0);
581 let counter = pane_counter(selected_1based, visible.len());
582
583 let mut block = Block::default()
584 .borders(Borders::ALL)
585 .title(title)
586 .border_style(Style::default().fg(border_color));
587 if let Some(counter) = counter {
588 block = block.title_bottom(Line::from(counter).right_aligned());
589 }
590
591 let table = Table::new(rows, widths)
592 .header(header)
593 .column_spacing(1)
594 .block(block)
595 .row_highlight_style(Style::default().bg(theme.selection_bg).add_modifier(Modifier::BOLD))
596 .highlight_symbol("▶ ");
597
598 f.render_stateful_widget(table, area, &mut app.list_state);
599}
600
601fn draw_sidebar(f: &mut Frame, area: Rect, app: &mut App) {
608 let border_color = panel_border_color(app.sidebar.focused, &app.theme);
609 let theme = app.theme;
612
613 let active_mode = app.sidebar.mode;
623
624 let issue_pr_inner_width = area.width.saturating_sub(3) as usize;
631
632 let Some(w) = app.selected().cloned() else {
633 let issue_pr_lines = github_status_lines(app, issue_pr_inner_width);
636 let placeholder = [Line::from("(nothing selected)")];
637 let h = |lines: usize| (lines as u16).saturating_add(2);
638 let constraints = [
639 Constraint::Length(h(placeholder.len())),
640 Constraint::Length(h(issue_pr_lines.len())),
641 Constraint::Length(0),
642 Constraint::Length(0),
643 Constraint::Min(3),
644 ];
645 let chunks = Layout::default()
646 .direction(Direction::Vertical)
647 .constraints(constraints)
648 .split(area);
649 app.sidebar.max_scroll = 0;
650 app.sidebar.scroll = 0;
651 app.sidebar.wt_max_scroll = 0;
652 app.sidebar.wt_scroll = 0;
653 render_section(
654 f,
655 chunks[0],
656 status_pane_title(),
657 SectionBody::new(&placeholder),
658 border_color,
659 0,
660 None,
661 );
662 render_section(
663 f,
664 chunks[1],
665 issue_pr_pane_title(&app.keymap),
666 SectionBody::new(&issue_pr_lines),
667 border_color,
668 0,
669 None,
670 );
671 render_section(
672 f,
673 chunks[4],
674 recent_items_pane_title(active_mode, &app.keymap),
675 SectionBody::new(&[]),
676 border_color,
677 0,
678 None,
679 );
680 return;
681 };
682
683 let cache_is_current = matches!(
700 &app.sidebar.cache,
701 Some(((p, m), _)) if *p == w.path && *m == active_mode
702 );
703
704 let placeholder = if cache_is_current {
711 SidebarSections::default()
712 } else {
713 SidebarSections {
714 worktree: worktree_identity_lines(&w, None, &theme),
715 working_tree: match active_mode {
716 super::state::sidebar::SidebarMode::Commits => {
717 vec![Line::from(Span::styled("loading…", Style::default().fg(theme.muted)))]
718 }
719 super::state::sidebar::SidebarMode::Stashes => Vec::new(),
720 },
721 working_tree_counts: WorkingTreeCounts::default(),
722 recent_commits: vec![Line::from(Span::styled("loading…", Style::default().fg(theme.muted)))],
723 }
724 };
725
726 let prefix_lines = vec![sidebar_header_line(&w, app)];
732 let issue_pr_lines = github_status_lines(app, issue_pr_inner_width);
733 let agent_pins: &[String] = app
737 .agent_pins
738 .get(&crate::agent_sessions::path_display_key(&w.path))
739 .map(|v| v.as_slice())
740 .unwrap_or(&[]);
741 let agent_lines = agent_pane_lines(app.agents_for(&w), agent_pins, std::time::SystemTime::now(), &theme);
742
743 let (worktree_len, working_tree_len, working_tree_counts, commits_len) = {
748 let s = if cache_is_current {
749 app.sidebar.cache.as_ref().map(|(_, s)| s).unwrap_or(&placeholder)
750 } else {
751 &placeholder
752 };
753 (
754 s.worktree.len() + prefix_lines.len(),
755 s.working_tree.len(),
756 s.working_tree_counts,
757 s.recent_commits.len() as u16,
758 )
759 };
760
761 let h = |lines: usize| (lines as u16).saturating_add(2);
771 let fixed = h(worktree_len).saturating_add(h(issue_pr_lines.len()));
772 let (agents_height, working_tree_height, commits_height) = super::state::sidebar::split_section_heights(
773 area.height.saturating_sub(fixed),
774 agent_lines.len() as u16,
775 working_tree_len as u16,
776 commits_len,
777 );
778 let constraints = [
779 Constraint::Length(h(worktree_len)),
780 Constraint::Length(h(issue_pr_lines.len())),
781 Constraint::Length(agents_height),
782 Constraint::Length(working_tree_height),
783 Constraint::Length(commits_height),
784 ];
785 let chunks = Layout::default()
786 .direction(Direction::Vertical)
787 .constraints(constraints)
788 .split(area);
789
790 let commits_area = chunks[4];
794 let commits_visible = commits_area.height.saturating_sub(2);
795 app.sidebar.max_scroll = commits_len.saturating_sub(commits_visible);
796 if app.sidebar.scroll > app.sidebar.max_scroll {
797 app.sidebar.scroll = app.sidebar.max_scroll;
798 }
799 let scroll = app.sidebar.scroll;
800
801 let wt_visible = chunks[3].height.saturating_sub(2);
807 app.sidebar.wt_max_scroll = (working_tree_len as u16).saturating_sub(wt_visible);
808 if app.sidebar.wt_scroll > app.sidebar.wt_max_scroll {
809 app.sidebar.wt_scroll = app.sidebar.wt_max_scroll;
810 }
811 let wt_scroll = app.sidebar.wt_scroll;
812
813 let (panel_title, panel_footer) = match active_mode {
817 super::state::sidebar::SidebarMode::Commits => {
818 let title = recent_items_pane_title(active_mode, &app.keymap);
819 let footer = if commits_len == 0 {
820 None
821 } else {
822 let bottom = scroll.saturating_add(commits_visible).min(commits_len);
823 Some(format!(" {} of {} ", bottom, commits_len))
824 };
825 (title, footer)
826 }
827 super::state::sidebar::SidebarMode::Stashes => {
828 let title = recent_items_pane_title(active_mode, &app.keymap);
829 let footer = if commits_len == 0 {
834 None
835 } else {
836 Some(" Enter: copy stash@{N} to status ".to_string())
837 };
838 (title, footer)
839 }
840 };
841 let issue_pr_title = issue_pr_pane_title(&app.keymap);
842 let working_tree_title = working_tree_pane_title(&app.keymap);
843 let working_tree_footer = if working_tree_len == 0 {
848 None
849 } else {
850 working_tree_counts_footer(&working_tree_counts, &theme)
851 };
852
853 let sections = if cache_is_current {
864 app.sidebar.cache.as_ref().map(|(_, s)| s).unwrap_or(&placeholder)
865 } else {
866 &placeholder
867 };
868 render_section(
869 f,
870 chunks[0],
871 status_pane_title(),
872 SectionBody::with_prefix(&prefix_lines, §ions.worktree),
873 border_color,
874 0,
875 None,
876 );
877 render_section(
878 f,
879 chunks[1],
880 issue_pr_title,
881 SectionBody::new(&issue_pr_lines),
882 border_color,
883 0,
884 None,
885 );
886 if !agent_lines.is_empty() {
887 render_section(
888 f,
889 chunks[2],
890 agents_pane_title(&app.keymap),
891 SectionBody::new(&agent_lines),
892 border_color,
893 0,
894 None,
895 );
896 }
897 if !sections.working_tree.is_empty() {
898 render_section(
899 f,
900 chunks[3],
901 working_tree_title,
902 SectionBody::new(§ions.working_tree),
903 border_color,
904 wt_scroll,
905 working_tree_footer,
906 );
907 let inner = Rect {
913 x: chunks[3].x.saturating_add(1),
914 y: chunks[3].y.saturating_add(1),
915 width: chunks[3].width.saturating_sub(2),
916 height: chunks[3].height.saturating_sub(2),
917 };
918 if inner.height > 0 {
919 let _ = scrollable_body_area(f, inner, wt_scroll, working_tree_len, &theme);
920 }
921 }
922 render_section(
923 f,
924 commits_area,
925 panel_title,
926 SectionBody::new(§ions.recent_commits),
927 border_color,
928 scroll,
929 panel_footer.map(ratatui::text::Line::from),
930 );
931}
932
933struct SectionBody<'a> {
943 prefix: &'a [Line<'a>],
944 lines: &'a [Line<'a>],
945}
946
947impl<'a> SectionBody<'a> {
948 fn new(lines: &'a [Line<'a>]) -> Self {
951 Self { prefix: &[], lines }
952 }
953
954 fn with_prefix(prefix: &'a [Line<'a>], lines: &'a [Line<'a>]) -> Self {
958 Self { prefix, lines }
959 }
960}
961
962fn render_section(
963 f: &mut Frame,
964 area: Rect,
965 title: impl Into<ratatui::text::Line<'static>>,
974 body: SectionBody<'_>,
975 border_color: Color,
976 scroll: u16,
977 footer: Option<ratatui::text::Line<'static>>,
978) {
979 let SectionBody { prefix, lines } = body;
980 let mut block = Block::default()
981 .borders(Borders::ALL)
982 .border_type(BorderType::Rounded)
983 .title(title.into())
984 .border_style(Style::default().fg(border_color));
985 if let Some(f) = footer {
986 block = block.title_bottom(f.right_aligned());
987 }
988 fn pad<'a>(l: &'a Line<'_>) -> Line<'a> {
994 let mut spans = Vec::with_capacity(l.spans.len() + 1);
995 spans.push(Span::raw(" "));
996 spans.extend(l.spans.iter().map(|s| Span::styled(s.content.as_ref(), s.style)));
997 Line::from(spans)
998 }
999 let padded: Vec<Line<'_>> = prefix.iter().chain(lines.iter()).map(pad).collect();
1000 let paragraph = Paragraph::new(padded).block(block).scroll((scroll, 0));
1004 f.render_widget(paragraph, area);
1005}
1006
1007pub fn agents_pane_title(keymap: &Keymap) -> String {
1014 format!(" Agents [{}] ", action_chord(keymap, Action::AgentSessions, "a"))
1015}
1016
1017pub fn agent_pane_lines(
1025 agents: Option<&crate::agent_sessions::WorktreeAgents>,
1026 pinned: &[String],
1027 now: std::time::SystemTime,
1028 theme: &Theme,
1029) -> Vec<Line<'static>> {
1030 const MAX_ROWS: usize = 3;
1031 let Some(agents) = agents else {
1032 return Vec::new();
1033 };
1034 let shown: Vec<&crate::agent_sessions::AgentSession> = agents
1035 .sessions
1036 .iter()
1037 .filter(|s| pinned.iter().any(|p| p == &s.id))
1038 .collect();
1039 let mut lines: Vec<Line<'static>> = shown
1040 .iter()
1041 .take(MAX_ROWS)
1042 .map(|s| {
1043 let freshness = crate::agent_sessions::Freshness::classify(s.last_activity, s.ended, now);
1044 let (word, color) = match freshness {
1045 crate::agent_sessions::Freshness::Active => ("active", theme.clean),
1046 crate::agent_sessions::Freshness::Idle => ("idle", theme.muted),
1047 };
1048 let ago = now
1049 .duration_since(s.last_activity)
1050 .map(worktree::format_relative_duration)
1051 .unwrap_or_else(|_| "now".into());
1052 let identity = s.name.as_deref().unwrap_or(&s.id);
1053 Line::from(vec![
1054 Span::styled(
1055 s.kind.display().to_string(),
1056 Style::default().fg(color).add_modifier(Modifier::BOLD),
1057 ),
1058 Span::styled(format!(" · {word} · {ago} ago · "), Style::default().fg(theme.muted)),
1059 Span::styled(identity.to_string(), Style::default().fg(theme.name)),
1060 ])
1061 })
1062 .collect();
1063 let extra = shown.len().saturating_sub(MAX_ROWS);
1064 if extra > 0 {
1065 lines.push(Line::from(Span::styled(
1066 format!("+{extra} more"),
1067 Style::default().fg(theme.muted),
1068 )));
1069 }
1070 lines
1071}
1072
1073fn sidebar_header_line(w: &WorktreeInfo, app: &App) -> Line<'static> {
1074 let (dot, dot_color) = sidebar_status_dot(app);
1075 Line::from(vec![
1076 Span::styled(dot, Style::default().fg(dot_color).add_modifier(Modifier::BOLD)),
1077 Span::styled(w.name.clone(), worktree_name_style(&app.theme)),
1078 ])
1079}
1080
1081fn sidebar_status_dot(app: &App) -> (&'static str, Color) {
1086 if let GitHubFetchState::Loaded(pr) = app.pr_fetch_state() {
1087 return ("● ", pr_badge_color(pr.state, &app.theme));
1088 }
1089 if let GitHubFetchState::Loaded(issue) = app.issue_fetch_state() {
1090 return ("● ", issue_badge_color(issue.state, &app.theme));
1091 }
1092 let link = app.current_link();
1093 if link.pr.is_some() || link.issue.is_some() {
1094 return ("● ", Color::White);
1098 }
1099 ("● ", app.theme.muted)
1100}
1101
1102pub fn build_sidebar_sections(
1112 w: &WorktreeInfo,
1113 mode: super::state::sidebar::SidebarMode,
1114 diff: Option<worktree::DiffLineStat>,
1115 theme: &Theme,
1116) -> SidebarSections {
1117 use super::state::sidebar::SidebarMode;
1118 let body = match mode {
1119 SidebarMode::Commits => recent_commits_lines(w, RECENT_COMMITS_LIMIT, theme),
1123 SidebarMode::Stashes => stash_lines(w, STASHES_DISPLAY_LIMIT, theme),
1130 };
1131 let (working_tree, working_tree_counts) = match mode {
1132 SidebarMode::Commits => working_tree_lines(w, theme),
1133 SidebarMode::Stashes => (Vec::new(), WorkingTreeCounts::default()),
1134 };
1135 SidebarSections {
1136 worktree: worktree_identity_lines(w, diff.as_ref(), theme),
1137 working_tree,
1138 working_tree_counts,
1139 recent_commits: body,
1140 }
1141}
1142
1143pub fn build_sidebar_payload(
1152 w: &WorktreeInfo,
1153 mode: super::state::sidebar::SidebarMode,
1154 trunks: &[String],
1155 theme: &Theme,
1156) -> SidebarSections {
1157 let diff = worktree::git_diff_stat_vs_base(&w.path, trunks).ok().flatten();
1158 build_sidebar_sections(w, mode, diff, theme)
1159}
1160
1161pub const STASHES_DISPLAY_LIMIT: usize = 10;
1166
1167fn stash_lines(w: &WorktreeInfo, limit: usize, theme: &Theme) -> Vec<Line<'static>> {
1174 match crate::worktree::git_stash_list(&w.path, limit) {
1175 Ok(stashes) if stashes.is_empty() => {
1176 vec![Line::from(Span::styled(
1177 "(no stashes)",
1178 Style::default().fg(theme.muted),
1179 ))]
1180 }
1181 Ok(stashes) => stashes
1182 .into_iter()
1183 .map(|s| {
1184 Line::from(vec![
1185 Span::styled(s.ref_name, Style::default().fg(theme.dirty)),
1186 Span::raw(" "),
1187 Span::raw(s.subject),
1188 ])
1189 })
1190 .collect(),
1191 Err(e) => vec![Line::from(Span::styled(
1192 format!("git stash list failed: {}", e),
1193 Style::default().fg(theme.prunable),
1194 ))],
1195 }
1196}
1197
1198fn worktree_identity_lines(
1205 w: &WorktreeInfo,
1206 diff: Option<&worktree::DiffLineStat>,
1207 theme: &Theme,
1208) -> Vec<Line<'static>> {
1209 let mut out: Vec<Line<'static>> = Vec::with_capacity(5);
1210 let label_w = "Created".chars().count();
1211 let label_style = Style::default().fg(theme.muted);
1212
1213 let branch_color = branch_name_color(&w.status, theme);
1219 let branch = w.branch.clone().unwrap_or_else(|| "-".into());
1220 let mut spans = vec![
1221 Span::styled(format!("{:<label_w$} ", "Branch", label_w = label_w), label_style),
1222 Span::styled(branch, Style::default().fg(branch_color)),
1223 ];
1224 if let Some(head) = w.head.as_deref() {
1225 spans.push(Span::styled(" · ".to_string(), Style::default().fg(theme.muted)));
1226 spans.push(Span::styled(short_oid(head), Style::default().fg(theme.dirty)));
1227 }
1228 out.push(Line::from(spans));
1229
1230 out.push(Line::from(vec![
1234 Span::styled(format!("{:<label_w$} ", "Created", label_w = label_w), label_style),
1235 Span::styled(branch_age_label(w), Style::default().fg(branch_age_color(w, theme))),
1236 ]));
1237
1238 if let Some(d) = diff {
1245 if !d.is_empty() {
1246 out.push(Line::from(vec![
1247 Span::styled(format!("{:<label_w$} ", "Diff", label_w = label_w), label_style),
1248 Span::styled(format!("+{}", d.insertions), Style::default().fg(theme.untracked)),
1249 Span::raw(" "),
1250 Span::styled(format!("-{}", d.deletions), Style::default().fg(theme.prunable)),
1251 ]));
1252 }
1253 }
1254
1255 let mut state_spans = vec![Span::styled(
1258 format!("{:<label_w$} ", "State", label_w = label_w),
1259 label_style,
1260 )];
1261 state_spans.extend(badges_line(w, theme).spans);
1262 out.push(Line::from(state_spans));
1263
1264 out.push(Line::from(vec![
1266 Span::styled(format!("{:<label_w$} ", "Path", label_w = label_w), label_style),
1267 Span::styled(
1268 tilde_compress(&w.path.display().to_string()),
1269 Style::default().fg(theme.muted),
1270 ),
1271 ]));
1272
1273 out
1274}
1275
1276fn branch_age_label(w: &WorktreeInfo) -> String {
1283 w.age
1284 .map(worktree::format_relative_duration)
1285 .unwrap_or_else(|| "-".into())
1286}
1287
1288fn branch_age_color(w: &WorktreeInfo, theme: &Theme) -> Color {
1289 w.age.map(|age| freshness_color(age, theme)).unwrap_or(theme.muted)
1290}
1291
1292fn badges_line(w: &WorktreeInfo, theme: &Theme) -> Line<'static> {
1293 let mut spans: Vec<Span<'static>> = Vec::new();
1294 let status_label = branch_status_label(&w.status);
1302 let status_color = branch_status_color(&w.status, theme);
1303 let is_diverged = w.status.has_upstream && (w.status.ahead > 0 || w.status.behind > 0);
1304 let badge_text = if w.status.unknown {
1305 format!("? {}", status_label)
1306 } else if w.status.is_dirty {
1307 format!("● {}", status_label)
1308 } else if is_diverged {
1309 status_label
1310 } else {
1311 format!("✓ {}", status_label)
1312 };
1313 spans.push(Span::styled(badge_text, Style::default().fg(status_color)));
1314
1315 let sep = || Span::styled(" ".to_string(), Style::default().fg(theme.muted));
1316 if w.is_main {
1317 spans.push(sep());
1318 spans.push(Span::styled("★ main".to_string(), Style::default().fg(theme.main)));
1319 }
1320 if w.is_locked {
1321 spans.push(sep());
1322 spans.push(Span::styled("🔒 locked".to_string(), Style::default().fg(theme.locked)));
1323 }
1324 if w.is_prunable {
1325 spans.push(sep());
1326 spans.push(Span::styled(
1327 "⚠ prunable".to_string(),
1328 Style::default().fg(theme.prunable),
1329 ));
1330 }
1331 Line::from(spans)
1332}
1333
1334fn working_tree_lines(w: &WorktreeInfo, theme: &Theme) -> (Vec<Line<'static>>, WorkingTreeCounts) {
1335 match worktree::git_status_short(&w.path) {
1336 Ok((s, _)) if s.trim().is_empty() => (
1337 vec![Line::from(Span::styled(
1338 "✓ clean".to_string(),
1339 Style::default().fg(theme.clean),
1340 ))],
1341 WorkingTreeCounts::default(),
1342 ),
1343 Ok((s, scan_truncated)) => {
1344 let counts = working_tree_status_counts(&s);
1345 let records = wt_tree::parse_status_z(&s);
1346 let (tree, overflow) = wt_tree::build_capped_tree(&records, wt_tree::WT_TREE_MAX_FILES);
1351 let mut lines = working_tree_tree_lines(&tree, theme);
1352 if overflow > 0 {
1353 let label = if scan_truncated {
1357 format!("… {}+ more", overflow)
1358 } else {
1359 format!("… {} more", overflow)
1360 };
1361 lines.push(Line::from(Span::styled(label, Style::default().fg(theme.muted))));
1362 }
1363 (lines, counts)
1364 }
1365 Err(e) => (
1366 vec![Line::from(Span::styled(
1367 format!("! {}", e),
1368 Style::default().fg(theme.prunable),
1369 ))],
1370 WorkingTreeCounts::default(),
1371 ),
1372 }
1373}
1374
1375fn working_tree_tree_lines(nodes: &[WtNode], theme: &Theme) -> Vec<Line<'static>> {
1393 let mut out = Vec::new();
1394 push_wt_nodes(&mut out, nodes, String::new(), theme);
1395 out
1396}
1397
1398fn push_wt_nodes(out: &mut Vec<Line<'static>>, nodes: &[WtNode], prefix: String, theme: &Theme) {
1402 let last = nodes.len().saturating_sub(1);
1403 for (i, node) in nodes.iter().enumerate() {
1404 let is_last = i == last;
1405 let connector = format!("{}{}", prefix, if is_last { "└─ " } else { "├─ " });
1406 match node {
1407 WtNode::Dir {
1408 name,
1409 children,
1410 category,
1411 } => {
1412 let color = match category {
1413 Some(c) => working_tree_category_color(*c, theme),
1414 None => theme.accent,
1415 };
1416 out.push(Line::from(vec![
1417 Span::styled(connector, Style::default().fg(theme.muted)),
1418 Span::styled(
1419 format!("{} {}", WT_DIR_OPEN_ICON, wt_tree::sanitize_name(name)),
1420 Style::default().fg(color),
1421 ),
1422 ]));
1423 let child_prefix = format!("{}{}", prefix, if is_last { " " } else { "│ " });
1424 push_wt_nodes(out, children, child_prefix, theme);
1425 }
1426 WtNode::File {
1427 name,
1428 icon,
1429 badge,
1430 category,
1431 } => {
1432 let color = working_tree_category_color(*category, theme);
1433 out.push(Line::from(vec![
1434 Span::styled(connector, Style::default().fg(theme.muted)),
1435 Span::styled(format!("{} ", badge), Style::default().fg(color)),
1436 Span::styled(
1437 format!("{} {}", icon, wt_tree::sanitize_name(name)),
1438 Style::default().fg(color),
1439 ),
1440 ]));
1441 }
1442 }
1443 }
1444}
1445
1446#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1451pub struct WorkingTreeCounts {
1452 pub created: usize,
1454 pub modified: usize,
1456 pub deleted: usize,
1458}
1459
1460impl WorkingTreeCounts {
1461 pub fn is_empty(&self) -> bool {
1464 self.created == 0 && self.modified == 0 && self.deleted == 0
1465 }
1466}
1467
1468pub const WT_CREATED_ICON: &str = "\u{eadc}";
1472pub const WT_MODIFIED_ICON: &str = "\u{eadd}";
1473pub const WT_DELETED_ICON: &str = "\u{eade}";
1474
1475fn working_tree_category_color(cat: WtCategory, theme: &Theme) -> Color {
1478 match cat {
1479 WtCategory::Created => theme.untracked,
1480 WtCategory::Modified => theme.modified,
1481 WtCategory::Deleted => theme.prunable,
1482 }
1483}
1484
1485pub fn working_tree_status_counts(status_z: &str) -> WorkingTreeCounts {
1491 let mut c = WorkingTreeCounts::default();
1492 for rec in wt_tree::parse_status_z(status_z) {
1493 match working_tree_category(rec.x, rec.y) {
1494 WtCategory::Created => c.created += 1,
1495 WtCategory::Modified => c.modified += 1,
1496 WtCategory::Deleted => c.deleted += 1,
1497 }
1498 }
1499 c
1500}
1501
1502pub fn working_tree_counts_footer(counts: &WorkingTreeCounts, theme: &Theme) -> Option<Line<'static>> {
1509 if counts.is_empty() {
1510 return None;
1511 }
1512 let mut spans: Vec<Span<'static>> = vec![Span::raw(" ")];
1513 if counts.created > 0 {
1514 spans.push(Span::styled(
1515 format!("{} {} ", WT_CREATED_ICON, counts.created),
1516 Style::default().fg(theme.untracked),
1517 ));
1518 }
1519 if counts.modified > 0 {
1520 spans.push(Span::styled(
1521 format!("{} {} ", WT_MODIFIED_ICON, counts.modified),
1522 Style::default().fg(theme.modified),
1523 ));
1524 }
1525 if counts.deleted > 0 {
1526 spans.push(Span::styled(
1527 format!("{} {} ", WT_DELETED_ICON, counts.deleted),
1528 Style::default().fg(theme.prunable),
1529 ));
1530 }
1531 Some(Line::from(spans))
1532}
1533
1534pub fn working_tree_status_line(raw: &str, theme: &Theme) -> Line<'static> {
1552 let mut indices = raw.char_indices();
1558 let (x_at, x) = match indices.next() {
1559 Some(c) => c,
1560 None => return Line::from(raw.to_string()),
1561 };
1562 let (_y_at, y) = match indices.next() {
1563 Some(c) => c,
1564 None => return Line::from(raw.to_string()),
1565 };
1566 let (sep_at, sep) = match indices.next() {
1567 Some(c) => c,
1568 None => return Line::from(raw.to_string()),
1569 };
1570 let path_at = sep_at + sep.len_utf8();
1572
1573 let style = Style::default().fg(working_tree_category_color(working_tree_category(x, y), theme));
1576
1577 Line::from(vec![
1578 Span::styled(raw[x_at..sep_at].to_string(), style),
1579 Span::raw(raw[sep_at..path_at].to_string()),
1580 Span::styled(raw[path_at..].to_string(), style),
1581 ])
1582}
1583
1584pub const RECENT_COMMITS_LIMIT: usize = 300;
1588
1589pub const COMMIT_HASH_DISPLAY_LEN: usize = 8;
1592
1593pub fn recent_commits_lines(w: &WorktreeInfo, limit: usize, theme: &Theme) -> Vec<Line<'static>> {
1613 match worktree::recent_commits_cached(w, limit) {
1614 Ok(rows) if !rows.is_empty() => {
1615 let graphs = super::commit_graph::render_commits(&rows, theme);
1616 rows
1617 .into_iter()
1618 .zip(graphs)
1619 .map(|(row, graph_spans)| commit_row_line(row, graph_spans, theme))
1620 .collect()
1621 }
1622 Ok(_) => vec![Line::from(Span::styled(
1623 "(no commits)".to_string(),
1624 Style::default().fg(theme.muted),
1625 ))],
1626 Err(e) => vec![Line::from(Span::styled(
1627 format!("! {}", e),
1628 Style::default().fg(theme.prunable),
1629 ))],
1630 }
1631}
1632
1633fn commit_row_line(row: worktree::CommitRow, graph: Vec<Span<'static>>, theme: &Theme) -> Line<'static> {
1634 let mut short_hash = row.hash.to_string();
1635 short_hash.truncate(COMMIT_HASH_DISPLAY_LEN);
1636 let initials = author_initials(&row.author);
1637 let mut spans: Vec<Span<'static>> = Vec::with_capacity(5 + graph.len());
1638 spans.push(Span::styled(short_hash, Style::default().fg(theme.dirty)));
1639 spans.push(Span::raw(" "));
1640 spans.push(Span::styled(
1641 format!("{:<2}", initials),
1642 Style::default().fg(theme.accent).add_modifier(Modifier::BOLD),
1643 ));
1644 spans.push(Span::raw(" "));
1645 spans.extend(graph);
1646 spans.push(Span::raw(" "));
1647 spans.push(Span::raw(row.subject));
1648 Line::from(spans)
1649}
1650
1651pub fn author_initials(author: &str) -> String {
1673 let trimmed = author.trim();
1674 if trimmed.is_empty() {
1675 return String::new();
1676 }
1677 let mut parts = trimmed.split_whitespace();
1678 let first = parts.next().unwrap_or("");
1679 match parts.next() {
1680 Some(second) => {
1681 let a: String = first.chars().take(1).collect();
1682 let b: String = second.chars().take(1).collect();
1683 format!("{}{}", a, b)
1684 }
1685 None => first.chars().take(2).collect(),
1686 }
1687}
1688
1689fn tilde_compress(path: &str) -> String {
1693 if let Some(home) = dirs::home_dir() {
1694 tilde_compress_with_home(path, &home)
1695 } else {
1696 path.to_string()
1697 }
1698}
1699
1700pub fn tilde_compress_with_home(path: &str, home: &std::path::Path) -> String {
1708 let home_s = home.display().to_string();
1709 if let Some(rest) = path.strip_prefix(&home_s) {
1710 if rest.is_empty() || rest.starts_with('/') || rest.starts_with(std::path::MAIN_SEPARATOR) {
1713 return format!("~{}", rest);
1714 }
1715 }
1716 path.to_string()
1717}
1718
1719fn short_oid(oid: &str) -> String {
1720 oid.chars().take(7).collect()
1721}
1722
1723fn branch_status_label(s: &BranchStatus) -> String {
1724 if s.unknown {
1725 return "unknown".into();
1726 }
1727 let mut parts: Vec<String> = Vec::new();
1728 if s.is_dirty {
1729 parts.push("dirty".into());
1730 }
1731 if s.has_upstream {
1732 if s.ahead > 0 {
1733 parts.push(format!("↑{}", s.ahead));
1734 }
1735 if s.behind > 0 {
1736 parts.push(format!("↓{}", s.behind));
1737 }
1738 if !s.is_dirty && s.synced() {
1739 parts.push("synced".into());
1740 }
1741 } else if !s.is_dirty {
1742 parts.push("clean".into());
1743 }
1744 if parts.is_empty() {
1745 "clean".into()
1746 } else {
1747 parts.join(" ")
1748 }
1749}
1750
1751pub fn branch_status_color(s: &BranchStatus, theme: &Theme) -> Color {
1759 if s.unknown {
1760 theme.muted
1761 } else if s.is_dirty || s.behind > 0 {
1762 theme.dirty
1763 } else if s.ahead > 0 {
1764 theme.accent
1765 } else {
1766 theme.clean
1767 }
1768}
1769
1770fn column_width<'a>(items: impl Iterator<Item = &'a str>, min: u16, max: u16) -> u16 {
1772 let observed = items.map(|s| s.chars().count() as u16).max().unwrap_or(min);
1773 observed.clamp(min, max)
1774}
1775
1776pub fn worktree_name_style(theme: &Theme) -> Style {
1782 Style::default().fg(theme.name).add_modifier(Modifier::BOLD)
1783}
1784
1785pub fn worktree_path_style(theme: &Theme) -> Style {
1790 Style::default().fg(theme.path)
1791}
1792
1793pub fn chip_style(color: Color) -> Style {
1802 Style::default()
1803 .fg(color)
1804 .add_modifier(Modifier::REVERSED | Modifier::BOLD)
1805}
1806
1807pub fn hint_key_style(theme: &Theme) -> Style {
1814 Style::default().fg(theme.accent).add_modifier(Modifier::BOLD)
1815}
1816
1817pub fn hint_label_style(theme: &Theme) -> Style {
1821 Style::default().fg(theme.muted)
1822}
1823
1824pub fn palette_name_style(theme: &Theme) -> Style {
1831 Style::default().fg(theme.name)
1832}
1833
1834pub fn help_label_style(theme: &Theme) -> Style {
1840 Style::default().fg(theme.name)
1841}
1842
1843pub fn agent_cell_label(
1848 agents: Option<&crate::agent_sessions::WorktreeAgents>,
1849 now: std::time::SystemTime,
1850) -> Option<(&'static str, crate::agent_sessions::Freshness)> {
1851 let top = agents?.top()?;
1852 let freshness = crate::agent_sessions::Freshness::classify(top.last_activity, top.ended, now);
1853 Some((top.kind.display(), freshness))
1854}
1855
1856fn build_row(
1861 w: &WorktreeInfo,
1862 repo: Option<(&str, u16)>,
1863 name_w: u16,
1864 branch_w: u16,
1865 status_w: u16,
1866 agent: Option<Option<(&'static str, crate::agent_sessions::Freshness)>>,
1869 theme: &Theme,
1870) -> Row<'static> {
1871 let marker = table_marker(w, theme);
1872 let branch_text = w.branch.clone().unwrap_or_else(|| "-".into());
1873
1874 let name_cell = Cell::from(trunc(&w.name, name_w as usize)).style(worktree_name_style(theme));
1878
1879 let branch_cell =
1882 Cell::from(trunc(&branch_text, branch_w as usize)).style(Style::default().fg(branch_name_color(&w.status, theme)));
1883
1884 let status_cell = build_status_cell(w, status_w as usize, theme);
1885
1886 let age_label = w.age.map(format_relative_duration_str).unwrap_or_else(|| "-".into());
1895 let age_cell = Cell::from(age_label).style(Style::default().fg(theme.muted));
1896
1897 let path_cell =
1903 Cell::from(crate::naming::sanitise_for_terminal(&w.path.to_string_lossy())).style(worktree_path_style(theme));
1904
1905 let mut cells = vec![age_cell];
1906 if let Some((repo_name, repo_w)) = repo {
1907 cells.push(
1908 Cell::from(trunc(repo_name, repo_w as usize))
1909 .style(Style::default().fg(theme.accent).add_modifier(Modifier::BOLD)),
1910 );
1911 }
1912 cells.push(Cell::from(marker));
1913 cells.push(name_cell);
1914 cells.push(branch_cell);
1915 cells.push(status_cell);
1916 if let Some(agent) = agent {
1920 cells.push(match agent {
1921 Some((label, crate::agent_sessions::Freshness::Active)) => {
1922 Cell::from(label).style(Style::default().fg(theme.clean).add_modifier(Modifier::BOLD))
1923 }
1924 Some((label, crate::agent_sessions::Freshness::Idle)) => {
1925 Cell::from(label).style(Style::default().fg(theme.muted))
1926 }
1927 None => Cell::from(""),
1928 });
1929 }
1930 cells.push(path_cell);
1931 Row::new(cells)
1932}
1933
1934fn format_relative_duration_str(d: std::time::Duration) -> String {
1939 worktree::format_relative_duration(d)
1940}
1941
1942fn build_status_cell(w: &WorktreeInfo, width: usize, theme: &Theme) -> Cell<'static> {
1943 if w.is_prunable {
1945 return Cell::from("prunable").style(Style::default().fg(theme.prunable).add_modifier(Modifier::BOLD));
1946 }
1947 if w.is_locked {
1948 return Cell::from("locked").style(Style::default().fg(theme.locked));
1949 }
1950
1951 let s = &w.status;
1952 let (label, color) = format_status(s, width, theme);
1953 Cell::from(label).style(Style::default().fg(color))
1954}
1955
1956pub fn format_status(s: &BranchStatus, width: usize, theme: &Theme) -> (String, Color) {
1962 if s.unknown {
1963 return ("unknown".into(), theme.muted);
1964 }
1965
1966 let mut parts: Vec<String> = Vec::new();
1967 if s.is_dirty {
1968 parts.push("● dirty".into());
1969 }
1970 if s.has_upstream {
1971 if s.ahead > 0 {
1972 parts.push(format!("↑{}", s.ahead));
1973 }
1974 if s.behind > 0 {
1975 parts.push(format!("↓{}", s.behind));
1976 }
1977 if !s.is_dirty && s.synced() {
1978 parts.push("✓ synced".into());
1979 }
1980 } else if !s.is_dirty {
1981 parts.push("clean".into());
1982 }
1983
1984 let joined = parts.join(" ");
1985 let label = trunc(&joined, width.max(4));
1986
1987 (label, branch_status_color(s, theme))
1992}
1993
1994#[derive(Debug, Clone, Copy)]
2000enum Hint {
2001 Key(super::keymap::Action, &'static str),
2003 Modal(ModalAction, &'static str),
2007 Lit(&'static str, &'static str),
2010}
2011
2012#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2019pub enum HintContext {
2020 Worktrees,
2022 Status,
2024 Picker,
2026 Create,
2028 CreateFreeform,
2033 Confirm,
2035 OpenMenu,
2037 LinkPrompt,
2039 LinkInputNumber,
2043 CommandPalette,
2045 Report,
2047 Help,
2049 Pty,
2052 ExecPicker,
2055 Clean,
2058 Rename,
2060 RenameFreeform,
2065 Detail,
2068 CiChecks,
2072}
2073
2074impl HintContext {
2075 pub fn label(self) -> &'static str {
2078 match self {
2079 HintContext::Worktrees => "worktrees",
2080 HintContext::Status => "status",
2081 HintContext::Picker => "switch",
2082 HintContext::Create | HintContext::CreateFreeform => "create",
2083 HintContext::Confirm => "confirm",
2084 HintContext::OpenMenu => "open",
2085 HintContext::LinkPrompt => "link",
2086 HintContext::LinkInputNumber => "link",
2087 HintContext::CommandPalette => "command",
2088 HintContext::Report => "report",
2089 HintContext::Help => "help",
2090 HintContext::Pty => "terminal",
2091 HintContext::ExecPicker => "exec",
2092 HintContext::Clean => "clean",
2093 HintContext::Rename | HintContext::RenameFreeform => "rename",
2094 HintContext::Detail => "agents",
2095 HintContext::CiChecks => "checks",
2096 }
2097 }
2098
2099 fn hint_specs(self) -> &'static [Hint] {
2107 use super::keymap::Action::*;
2108 match self {
2109 HintContext::Worktrees => &[
2112 Hint::Key(Create, "new"),
2114 Hint::Key(DeleteConfirm, "del"),
2115 Hint::Key(Bootstrap, "boot"),
2116 Hint::Key(TerminalFullscreen, "open"),
2120 Hint::Key(LazyGitFullscreen, "git"),
2121 Hint::Key(ExecOverlay, "exec"),
2122 Hint::Key(AgentSessions, "agents"),
2123 Hint::Key(ReviewFullscreen, "review"),
2124 Hint::Key(YankPath, "yank"),
2125 Hint::Key(Filter, "filter"),
2127 Hint::Key(FocusStatus, "status"),
2128 Hint::Key(CommandLogs, "logs"),
2129 Hint::Key(ConfigPanel, "settings"),
2130 Hint::Key(Help, "help"),
2132 Hint::Key(Quit, "quit"),
2133 ],
2134 HintContext::Status => &[
2135 Hint::Key(Down, "scroll"),
2137 Hint::Key(WtScrollDown, "wt scroll"),
2138 Hint::Key(FetchGithub, "fetch"),
2139 Hint::Key(EditWorktree, "ci checks"),
2141 Hint::Key(ToggleSidebarMode, "mode"),
2143 Hint::Key(CycleSidebarLayout, "layout"),
2144 Hint::Key(FocusWorktrees, "worktrees"),
2146 Hint::Key(Filter, "filter"),
2147 Hint::Key(CommandLogs, "logs"),
2148 Hint::Key(ConfigPanel, "settings"),
2149 Hint::Key(Help, "help"),
2151 Hint::Key(Quit, "quit"),
2152 ],
2153 HintContext::Picker => &[
2154 Hint::Lit("Enter", "select"),
2156 Hint::Lit("Esc", "cancel"),
2157 Hint::Key(TerminalFullscreen, "open"),
2159 Hint::Key(LazyGitFullscreen, "git"),
2160 Hint::Key(YankPath, "yank"),
2161 Hint::Key(Filter, "filter"),
2163 Hint::Key(Help, "help"),
2164 Hint::Key(Quit, "quit"),
2165 ],
2166 HintContext::Create => &[
2170 Hint::Modal(ModalAction::CreateNextField, "field"),
2171 Hint::Lit("↑/↓", "type"),
2172 Hint::Modal(ModalAction::CreateToggleMode, "free-form"),
2173 Hint::Modal(ModalAction::CreateSubmit, "submit"),
2174 Hint::Modal(ModalAction::CreateCancel, "cancel"),
2175 ],
2176 HintContext::CreateFreeform => &[
2182 Hint::Modal(ModalAction::CreateToggleMode, "structured"),
2183 Hint::Modal(ModalAction::CreateSubmit, "submit"),
2184 Hint::Modal(ModalAction::CreateCancel, "cancel"),
2185 ],
2186 HintContext::Confirm => &[
2187 Hint::Modal(ModalAction::ConfirmConfirm, "confirm"),
2188 Hint::Key(ToggleDeleteBranch, "branch"),
2189 Hint::Lit("←/→", "move"),
2190 Hint::Modal(ModalAction::ConfirmActivate, "activate"),
2191 Hint::Modal(ModalAction::ConfirmCancel, "cancel"),
2192 ],
2193 HintContext::OpenMenu => &[
2194 Hint::Modal(ModalAction::OpenMenuIssue, "issue"),
2195 Hint::Modal(ModalAction::OpenMenuPr, "pr"),
2196 Hint::Key(FetchGithub, "fetch"),
2197 Hint::Modal(ModalAction::OpenMenuClose, "close"),
2198 ],
2199 HintContext::LinkPrompt => &[
2200 Hint::Modal(ModalAction::LinkChoosePrev, "prev"),
2201 Hint::Modal(ModalAction::LinkChooseNext, "next"),
2202 Hint::Modal(ModalAction::LinkChooseIssue, "issue"),
2203 Hint::Modal(ModalAction::LinkChoosePr, "pr"),
2204 Hint::Modal(ModalAction::LinkChooseAccept, "link"),
2205 Hint::Key(FetchGithub, "fetch"),
2206 Hint::Modal(ModalAction::LinkChooseCancel, "cancel"),
2207 ],
2208 HintContext::LinkInputNumber => &[
2211 Hint::Lit("0-9", "number"),
2212 Hint::Modal(ModalAction::LinkInputSubmit, "submit"),
2213 Hint::Key(FetchGithub, "fetch"),
2214 Hint::Modal(ModalAction::LinkInputCancel, "cancel"),
2215 ],
2216 HintContext::CommandPalette => &[
2217 Hint::Lit("↑/↓", "move"),
2218 Hint::Modal(ModalAction::CommandPaletteAccept, "run"),
2219 Hint::Modal(ModalAction::CommandPaletteClose, "cancel"),
2220 ],
2221 HintContext::Report => &[Hint::Modal(ModalAction::ReportClose, "close")],
2225 HintContext::Detail => &[
2229 Hint::Lit("j/k", "select"),
2230 Hint::Modal(ModalAction::DetailAttach, "attach"),
2231 Hint::Modal(ModalAction::DetailDetach, "detach"),
2232 Hint::Modal(ModalAction::DetailInput, "by id"),
2233 Hint::Modal(ModalAction::DetailClose, "close"),
2234 ],
2235 HintContext::CiChecks => &[
2236 Hint::Lit("j/k", "select"),
2237 Hint::Modal(ModalAction::CiChecksOpen, "open"),
2238 Hint::Modal(ModalAction::CiChecksFilter, "filter"),
2239 Hint::Modal(ModalAction::CiChecksRefresh, "refresh"),
2240 Hint::Modal(ModalAction::CiChecksClose, "close"),
2241 ],
2242 HintContext::Help => &[
2243 Hint::Lit("j/k", "scroll"),
2244 Hint::Lit("h/l", "pan"),
2245 Hint::Modal(ModalAction::HelpClose, "close"),
2246 ],
2247 HintContext::Pty => &[Hint::Lit("Esc", "close")],
2248 HintContext::ExecPicker => &[
2252 Hint::Lit("↑/↓", "pick"),
2253 Hint::Modal(ModalAction::ExecPickerAccept, "run"),
2254 Hint::Modal(ModalAction::ExecPickerCancel, "cancel"),
2255 ],
2256 HintContext::Clean => &[
2260 Hint::Lit("↑/↓", "profile"),
2261 Hint::Modal(ModalAction::CleanConfirm, "reclaim"),
2262 Hint::Modal(ModalAction::CleanCancel, "cancel"),
2263 ],
2264 HintContext::Rename => &[
2267 Hint::Modal(ModalAction::CreateToggleMode, "free-form"),
2268 Hint::Modal(ModalAction::CreateNextField, "field"),
2269 Hint::Lit("↑/↓", "type"),
2270 Hint::Modal(ModalAction::CreateSubmit, "submit"),
2271 Hint::Modal(ModalAction::CreateCancel, "cancel"),
2272 ],
2273 HintContext::RenameFreeform => &[
2276 Hint::Modal(ModalAction::CreateToggleMode, "structured"),
2277 Hint::Modal(ModalAction::CreateSubmit, "submit"),
2278 Hint::Modal(ModalAction::CreateCancel, "cancel"),
2279 ],
2280 }
2281 }
2282
2283 pub fn resolve(self, keymap: &super::keymap::Keymap, modal: &ModalKeymap) -> Vec<(String, String)> {
2289 self.resolve_with_fields(keymap, modal, &CANONICAL_TRIPLE)
2290 }
2291
2292 pub fn resolve_with_fields(
2309 self,
2310 keymap: &super::keymap::Keymap,
2311 modal: &ModalKeymap,
2312 fields: &[Field],
2313 ) -> Vec<(String, String)> {
2314 let structured_form = matches!(self, HintContext::Create | HintContext::Rename);
2315 self
2316 .hint_specs()
2317 .iter()
2318 .filter(|h| {
2319 if !structured_form {
2320 return true;
2321 }
2322 match h {
2323 Hint::Lit("↑/↓", "type") => fields.contains(&Field::Type),
2324 Hint::Modal(ModalAction::CreateNextField, _) => fields.len() > 1,
2325 _ => true,
2326 }
2327 })
2328 .filter_map(|h| match h {
2329 Hint::Key(action, label) => keymap
2337 .primary_chord(*action)
2338 .filter(|k| !self.key_shadowed_by_modal(k, modal) && !self.key_swallowed_by_typing(k))
2339 .map(|k| (k, label.to_string())),
2340 Hint::Modal(action, label) => modal.primary_key(*action).map(|k| (k, label.to_string())),
2341 Hint::Lit(key, label) => Some((key.to_string(), label.to_string())),
2342 })
2343 .collect()
2344 }
2345
2346 fn modal_context(self) -> Option<KeyContext> {
2350 Some(match self {
2351 HintContext::Create | HintContext::CreateFreeform | HintContext::Rename | HintContext::RenameFreeform => {
2352 KeyContext::Create
2353 }
2354 HintContext::Confirm => KeyContext::Confirm,
2355 HintContext::OpenMenu => KeyContext::OpenMenu,
2356 HintContext::LinkPrompt => KeyContext::LinkChooseTarget,
2357 HintContext::LinkInputNumber => KeyContext::LinkInputNumber,
2358 HintContext::CommandPalette => KeyContext::CommandPalette,
2359 HintContext::Report => KeyContext::Report,
2360 HintContext::Help => KeyContext::Help,
2361 HintContext::Detail => KeyContext::Detail,
2362 HintContext::CiChecks => KeyContext::CiChecks,
2363 HintContext::ExecPicker => KeyContext::ExecPicker,
2364 HintContext::Clean => KeyContext::Clean,
2365 HintContext::Worktrees | HintContext::Status | HintContext::Picker | HintContext::Pty => return None,
2366 })
2367 }
2368
2369 fn key_shadowed_by_modal(self, key: &str, modal: &ModalKeymap) -> bool {
2372 match self.modal_context() {
2373 Some(ctx) => modal
2374 .bindings_for(ctx)
2375 .iter()
2376 .any(|b| b.keys.iter().any(|ks| ks.to_string() == key)),
2377 None => false,
2378 }
2379 }
2380
2381 fn key_swallowed_by_typing(self, key: &str) -> bool {
2387 match self.modal_context() {
2388 Some(ctx) => KeyStroke::parse_chord(key)
2389 .ok()
2390 .and_then(|strokes| strokes.first().cloned())
2391 .is_some_and(|ks| ctx.reserved_typing_stroke(&ks)),
2392 None => false,
2393 }
2394 }
2395}
2396
2397fn action_chord(keymap: &Keymap, action: Action, fallback: &str) -> String {
2398 keymap.primary_chord(action).unwrap_or_else(|| fallback.to_string())
2399}
2400
2401pub fn issue_pr_pane_title(keymap: &Keymap) -> String {
2402 format!(" Issue / PR [{}] ", action_chord(keymap, Action::FetchGithub, "F"))
2403}
2404
2405pub fn working_tree_pane_title(keymap: &Keymap) -> String {
2406 format!(
2407 " Working Tree [{}] ",
2408 action_chord(keymap, Action::ReviewFullscreen, "R")
2409 )
2410}
2411
2412pub fn recent_items_pane_title(mode: SidebarMode, keymap: &Keymap) -> String {
2413 match mode {
2414 SidebarMode::Commits => format!(
2415 " Recent Commits [{}] ",
2416 action_chord(keymap, Action::LazyGitFullscreen, "l")
2417 ),
2418 SidebarMode::Stashes => format!(" Stashes [{}] ", action_chord(keymap, Action::LazyGitFullscreen, "l")),
2419 }
2420}
2421
2422pub fn modal_hint_line(hints: &[(&str, &str)], theme: &Theme) -> Line<'static> {
2423 let key_style = hint_key_style(theme);
2424 let label_style = hint_label_style(theme);
2425 let mut spans: Vec<Span<'static>> = Vec::new();
2426 for (i, (key, label)) in hints.iter().enumerate() {
2427 if i > 0 {
2428 spans.push(Span::raw(" "));
2431 }
2432 spans.push(Span::styled((*key).to_string(), key_style));
2433 spans.push(Span::styled(format!(" {}", label), label_style));
2434 }
2435 Line::from(spans).centered()
2436}
2437
2438pub fn config_edit_footer_hints(modal: &ModalKeymap) -> Vec<(String, String)> {
2444 [
2445 (ModalAction::ConfigEditSubmit, "save"),
2446 (ModalAction::ConfigEditCancel, "cancel"),
2447 ]
2448 .into_iter()
2449 .filter_map(|(action, label)| modal.primary_key(action).map(|k| (k, label.to_string())))
2450 .collect()
2451}
2452
2453pub fn config_nav_footer_hints(
2461 modal: &ModalKeymap,
2462 tab: SettingsTab,
2463 selected_kind: Option<FieldKind>,
2464) -> Vec<(String, String)> {
2465 let mut hints: Vec<(String, String)> = Vec::new();
2466 if tab == SettingsTab::All {
2467 hints.push(("j/k".to_string(), "scroll".to_string()));
2468 } else {
2469 let label = if tab == SettingsTab::Keys {
2470 "rebind"
2471 } else if selected_kind == Some(FieldKind::Choice) {
2472 "cycle"
2473 } else {
2474 "edit"
2475 };
2476 if let Some(k) = modal.primary_key(ModalAction::ConfigActivate) {
2477 hints.push((k, label.to_string()));
2478 }
2479 }
2480 for (action, label) in [
2481 (ModalAction::ConfigNextTab, "section"),
2482 (ModalAction::ConfigToggleLayer, "layer"),
2483 (ModalAction::ConfigClose, "close"),
2484 ] {
2485 if let Some(k) = modal.primary_key(action) {
2486 hints.push((k, label.to_string()));
2487 }
2488 }
2489 hints
2490}
2491
2492pub fn config_capture_footer_hints(modal: &ModalKeymap, single_only: bool) -> Vec<(String, String)> {
2500 let mut hints: Vec<(String, String)> = Vec::new();
2501 if single_only {
2502 hints.push(("any key".to_string(), "bind".to_string()));
2503 } else {
2504 if let Some(k) = modal.primary_key(ModalAction::ConfigEditSubmit) {
2505 hints.push((k, "save".to_string()));
2506 }
2507 hints.push(("Backspace".to_string(), "delete".to_string()));
2508 }
2509 if let Some(k) = modal.primary_key(ModalAction::ConfigEditCancel) {
2510 hints.push((k, "cancel".to_string()));
2511 }
2512 hints
2513}
2514
2515pub fn command_logs_footer_hints(modal: &ModalKeymap) -> Vec<(String, String)> {
2520 let mut hints: Vec<(String, String)> = vec![
2521 ("j/k".to_string(), "scroll".to_string()),
2522 ("g/G".to_string(), "top/bottom".to_string()),
2523 ];
2524 for (action, label) in [
2525 (ModalAction::CommandLogsCopy, "copy"),
2526 (ModalAction::CommandLogsClose, "close"),
2527 ] {
2528 if let Some(k) = modal.primary_key(action) {
2529 hints.push((k, label.to_string()));
2530 }
2531 }
2532 hints
2533}
2534
2535pub fn modal_hint_for_context(ctx: HintContext, keymap: &Keymap, modal: &ModalKeymap, theme: &Theme) -> Line<'static> {
2536 modal_hint_for_context_with_fields(ctx, keymap, modal, theme, &CANONICAL_TRIPLE)
2537}
2538
2539pub fn modal_hint_for_context_with_fields(
2542 ctx: HintContext,
2543 keymap: &Keymap,
2544 modal: &ModalKeymap,
2545 theme: &Theme,
2546 fields: &[Field],
2547) -> Line<'static> {
2548 let resolved = ctx.resolve_with_fields(keymap, modal, fields);
2549 let hints: Vec<(&str, &str)> = resolved.iter().map(|(k, l)| (k.as_str(), l.as_str())).collect();
2550 modal_hint_line(&hints, theme)
2551}
2552
2553fn push_modal_hint(
2554 lines: &mut Vec<Line<'static>>,
2555 ctx: HintContext,
2556 keymap: &Keymap,
2557 modal: &ModalKeymap,
2558 theme: &Theme,
2559) {
2560 lines.push(Line::from(String::new()));
2561 lines.push(modal_hint_for_context(ctx, keymap, modal, theme));
2562}
2563
2564pub fn footer_line(hints: &[(&str, &str)], status: &str, width: usize, theme: &Theme) -> Line<'static> {
2585 let key_style = hint_key_style(theme);
2586 let label_style = hint_label_style(theme);
2587 let status_style = Style::default().fg(theme.dirty);
2588
2589 if width == 0 {
2592 return Line::default();
2593 }
2594
2595 let status: String = status.chars().map(|c| if c.is_control() { ' ' } else { c }).collect();
2600 let status_text = format!("[{}]", status);
2601 let status_w = status_text.chars().count();
2602
2603 if width <= status_w {
2606 return Line::from(Span::styled(trunc(&status_text, width), status_style));
2607 }
2608
2609 let hint_budget = (width - status_w - 1).saturating_sub(1);
2615
2616 let mut spans: Vec<Span<'static>> = Vec::new();
2617 let mut used = 0usize; let mut truncated = false;
2619 for (i, (key, label)) in hints.iter().enumerate() {
2620 let sep = if i > 0 { 2 } else { 0 }; let badge_w = key.chars().count() + 1 + label.chars().count();
2623 if used + sep + badge_w > hint_budget {
2624 truncated = true;
2625 break;
2626 }
2627 if sep > 0 {
2628 spans.push(Span::raw(" ".repeat(sep)));
2629 used += sep;
2630 }
2631 spans.push(Span::styled((*key).to_string(), key_style));
2632 spans.push(Span::styled(format!(" {}", label), label_style));
2633 used += badge_w;
2634 }
2635
2636 if truncated {
2637 if used > 0 {
2638 spans.push(Span::raw(" "));
2639 used += 1;
2640 }
2641 spans.push(Span::styled("…", label_style));
2642 used += 1;
2643 }
2644
2645 let pad = width.saturating_sub(used + status_w);
2647 if pad > 0 {
2648 spans.push(Span::raw(" ".repeat(pad)));
2649 }
2650 spans.push(Span::styled(status_text, status_style));
2651 Line::from(spans)
2652}
2653
2654pub fn status_line(
2669 context: &str,
2670 hints: &[(&str, &str)],
2671 status: &str,
2672 spinner: Option<&str>,
2673 width: usize,
2674 theme: &Theme,
2675) -> Line<'static> {
2676 let context_style = chip_style(theme.focus);
2677 let key_style = hint_key_style(theme);
2678 let label_style = hint_label_style(theme);
2679 let status_style = Style::default().fg(theme.dirty);
2680 let spinner_style = Style::default().fg(theme.accent).add_modifier(Modifier::BOLD);
2681
2682 if width == 0 {
2683 return Line::default();
2684 }
2685
2686 let status: String = status.chars().map(|c| if c.is_control() { ' ' } else { c }).collect();
2687 let status_text = format!("[{}]", status);
2688 let status_w = status_text.chars().count();
2689
2690 if width <= status_w {
2693 return Line::from(Span::styled(trunc(&status_text, width), status_style));
2694 }
2695
2696 let avail = width - status_w; let mut spans: Vec<Span<'static>> = Vec::new();
2698 let mut used = 0usize;
2699
2700 let ctx_chip = format!(" {} ", context);
2702 let ctx_w = ctx_chip.chars().count();
2703 if ctx_w <= avail {
2704 spans.push(Span::styled(ctx_chip, context_style));
2705 used += ctx_w;
2706 }
2707
2708 if let Some(glyph) = spinner {
2711 let padded = format!(" {} ", glyph);
2712 let gw = padded.chars().count();
2713 if used + gw <= avail {
2714 spans.push(Span::styled(padded, spinner_style));
2715 used += gw;
2716 }
2717 }
2718
2719 let hint_budget = avail.saturating_sub(used).saturating_sub(1);
2721 let mut truncated = false;
2722 let mut hint_used = 0usize;
2723 for (i, (key, label)) in hints.iter().enumerate() {
2724 let sep = if i > 0 { 2 } else { usize::from(used > 0) };
2727 let badge_w = key.chars().count() + 1 + label.chars().count();
2728 if hint_used + sep + badge_w > hint_budget {
2729 truncated = true;
2730 break;
2731 }
2732 if sep > 0 {
2733 spans.push(Span::raw(" ".repeat(sep)));
2734 hint_used += sep;
2735 }
2736 spans.push(Span::styled((*key).to_string(), key_style));
2737 spans.push(Span::styled(format!(" {}", label), label_style));
2738 hint_used += badge_w;
2739 }
2740 used += hint_used;
2741 if truncated {
2742 if used > 0 {
2743 spans.push(Span::raw(" "));
2744 used += 1;
2745 }
2746 spans.push(Span::styled("…", label_style));
2747 used += 1;
2748 }
2749
2750 let pad = width.saturating_sub(used + status_w);
2751 if pad > 0 {
2752 spans.push(Span::raw(" ".repeat(pad)));
2753 }
2754 spans.push(Span::styled(status_text, status_style));
2755 Line::from(spans)
2756}
2757
2758fn draw_footer(f: &mut Frame, area: Rect, app: &App) {
2759 let ctx = app.hint_context();
2760 let spinner = if app.is_github_loading() || app.is_task_loading() {
2766 Some(app.spinner.glyph(DOT_FRAMES))
2767 } else {
2768 None
2769 };
2770 let resolved = ctx.resolve_with_fields(&app.keymap, &app.modal_keymap, app.create_form.fields());
2776 let hints: Vec<(&str, &str)> = resolved.iter().map(|(k, l)| (k.as_str(), l.as_str())).collect();
2777 let line = status_line(
2778 ctx.label(),
2779 &hints,
2780 &app.status,
2781 spinner,
2782 area.width as usize,
2783 &app.theme,
2784 );
2785 f.render_widget(Paragraph::new(line), area);
2787}
2788
2789#[derive(Debug, Clone, PartialEq, Eq)]
2798pub enum HelpRow {
2799 Title(String),
2801 Subtitle(String),
2804 Section(String),
2806 Blank,
2808 Entry { keys: String, label: String },
2812}
2813
2814pub fn help_rows(km: &super::keymap::Keymap, modal: &ModalKeymap, ctx: HintContext) -> Vec<HelpRow> {
2832 use super::keymap::Action;
2833
2834 let picker_mode = matches!(ctx, HintContext::Picker);
2835
2836 let bindings = km.list();
2841
2842 let keys_for = |action: Action| -> String {
2847 bindings
2848 .iter()
2849 .find(|b| b.action == action)
2850 .map(|b| {
2851 b.chords
2852 .iter()
2853 .map(|c| c.iter().map(|k| k.to_string()).collect::<Vec<_>>().join(" "))
2854 .collect::<Vec<_>>()
2855 .join(", ")
2856 })
2857 .unwrap_or_default()
2858 };
2859 let entry = |action: Action, label: &str| -> HelpRow {
2861 HelpRow::Entry {
2862 keys: keys_for(action),
2863 label: label.to_string(),
2864 }
2865 };
2866 let fixed = |keys: &str, label: &str| -> HelpRow {
2869 HelpRow::Entry {
2870 keys: keys.to_string(),
2871 label: label.to_string(),
2872 }
2873 };
2874 let modal_entry = |action: ModalAction, label: &str| -> HelpRow {
2882 HelpRow::Entry {
2883 keys: modal.keys_display(action),
2884 label: label.to_string(),
2885 }
2886 };
2887
2888 let mut rows: Vec<HelpRow> = vec![
2889 HelpRow::Title("Keybindings".to_string()),
2890 HelpRow::Subtitle(ctx.label().to_string()),
2891 HelpRow::Blank,
2892 HelpRow::Section("Global".to_string()),
2893 HelpRow::Blank,
2894 entry(Action::Quit, "quit (Esc also quits when filter is clear)"),
2895 fixed("Ctrl-C", "quit (hard-coded escape hatch)"),
2896 HelpRow::Blank,
2897 HelpRow::Section("List View".to_string()),
2898 HelpRow::Blank,
2899 entry(Action::Down, "next (scrolls sidebar when focused)"),
2900 entry(Action::Up, "prev (scrolls sidebar when focused)"),
2901 entry(Action::WtScrollDown, "scroll the Working Tree pane down (status focus)"),
2902 entry(Action::WtScrollUp, "scroll the Working Tree pane up (status focus)"),
2903 entry(Action::Top, "jump to first worktree"),
2904 entry(Action::Bottom, "jump to last worktree"),
2905 ];
2906 if picker_mode {
2907 rows.push(fixed("enter", "select highlighted worktree (prints path on exit)"));
2908 } else {
2909 rows.push(entry(Action::Create, "new worktree"));
2910 rows.push(entry(Action::DeleteConfirm, "delete selected"));
2911 rows.push(entry(Action::Bootstrap, "bootstrap selected"));
2912 }
2913 rows.push(entry(
2914 Action::TerminalFullscreen,
2915 "open per [tui.open] — shell / editor / finder",
2916 ));
2917 rows.push(entry(Action::TerminalPty, "open native $SHELL in embedded PTY overlay"));
2918 rows.push(entry(Action::OpenDocs, "open the gwm documentation in the browser"));
2919 rows.push(entry(Action::YankPath, "yank selected worktree path to clipboard"));
2920 rows.push(entry(Action::YankBranchName, "yank selected branch name to clipboard"));
2921 rows.push(entry(
2922 Action::YankWorktreeName,
2923 "yank selected worktree name to clipboard",
2924 ));
2925 rows.push(entry(Action::LazyGitFullscreen, "launch lazygit fullscreen"));
2926 rows.push(entry(Action::LazyGitPty, "open lazygit in embedded PTY overlay"));
2927 rows.push(entry(Action::ToggleSidebar, "toggle git preview sidebar"));
2928 rows.push(entry(
2929 Action::ToggleSidebarMode,
2930 "cycle sidebar mode (commits / stashes)",
2931 ));
2932 rows.push(entry(
2933 Action::CycleSidebarLayout,
2934 "cycle sidebar layout (auto / side-by-side / stacked)",
2935 ));
2936 rows.push(entry(
2937 Action::ToggleSidebarPosition,
2938 "toggle sidebar position (left / right)",
2939 ));
2940 rows.push(entry(Action::FocusSwap, "swap focus between worktree list and sidebar"));
2941 rows.push(entry(Action::FocusWorktrees, "focus the worktrees pane"));
2942 rows.push(entry(Action::FocusStatus, "focus the status pane (opens it if hidden)"));
2943 rows.push(entry(Action::CommandLogs, "show the command logs overlay"));
2944 rows.push(entry(Action::ConfigPanel, "show the resolved configuration panel"));
2945 if !picker_mode {
2948 rows.push(entry(
2949 Action::ExecOverlay,
2950 "pick an [exec.profiles] profile and run it in a PTY",
2951 ));
2952 rows.push(entry(
2953 Action::CleanOverlay,
2954 "preview and reclaim build artifacts (with confirm)",
2955 ));
2956 rows.push(entry(
2957 Action::AgentSessions,
2958 "show the agent sessions attached to this worktree",
2959 ));
2960 rows.push(entry(
2961 Action::CiChecks,
2962 "list the linked PR's CI checks (also `c` with status focus)",
2963 ));
2964 }
2965 rows.push(entry(
2966 Action::Filter,
2967 "open fuzzy filter bar (enter: sticky, esc: clear)",
2968 ));
2969 rows.push(entry(Action::Refresh, "refresh worktree list"));
2970 if !picker_mode {
2971 rows.push(entry(Action::Sync, "sync selected worktree onto its upstream (rebase)"));
2972 rows.push(entry(Action::Pull, "pull selected worktree's branch from upstream"));
2973 rows.push(entry(Action::Push, "push selected worktree's branch to remote"));
2974 rows.push(entry(Action::EditWorktree, "rename the selected worktree's branch"));
2975 rows.push(entry(
2976 Action::ExitToWorktree,
2977 "quit TUI and print selected path to stdout",
2978 ));
2979 rows.push(entry(Action::MuxPane, "open selected worktree in new mux pane/tab"));
2980 rows.push(entry(Action::Macro1, "run [tui.macro1] command"));
2981 rows.push(entry(Action::Macro2, "run [tui.macro2] command"));
2982 rows.push(entry(Action::FetchGithub, "refresh GitHub issue/PR status via `gh`"));
2983 rows.push(entry(Action::ReviewFullscreen, "run [review] launcher fullscreen"));
2984 rows.push(entry(
2985 Action::ReviewPty,
2986 "run [review] launcher in embedded PTY overlay",
2987 ));
2988 rows.push(entry(Action::ToggleDeleteBranch, "toggle 'delete branch on remove'"));
2989 rows.push(fixed("enter", "show path in status bar"));
2990 rows.push(HelpRow::Blank);
2991 rows.push(HelpRow::Section("Issue / PR".to_string()));
2992 rows.push(HelpRow::Blank);
2993 let open_picks: Vec<String> = [
2998 (ModalAction::OpenMenuIssue, "issue"),
2999 (ModalAction::OpenMenuPr, "pull request"),
3000 ]
3001 .into_iter()
3002 .filter_map(|(a, l)| modal.primary_key(a).map(|k| format!("{k}={l}")))
3003 .collect();
3004 let open_desc = if open_picks.is_empty() {
3005 "open menu".to_string()
3006 } else {
3007 format!("open menu — {}", open_picks.join(" · "))
3008 };
3009 rows.push(entry(Action::BrowseLinks, &open_desc));
3010
3011 let key = |a: ModalAction| modal.primary_key(a);
3012 let nav: Vec<String> = [ModalAction::LinkChooseNext, ModalAction::LinkChoosePrev]
3013 .into_iter()
3014 .filter_map(key)
3015 .collect();
3016 let picks: Vec<String> = [ModalAction::LinkChooseIssue, ModalAction::LinkChoosePr]
3017 .into_iter()
3018 .filter_map(key)
3019 .collect();
3020 let mut parts: Vec<String> = Vec::new();
3021 match (nav.is_empty(), key(ModalAction::LinkChooseAccept)) {
3022 (false, Some(a)) => parts.push(format!("{} + {a}", nav.join("/"))),
3023 (false, None) => parts.push(nav.join("/")),
3024 (true, Some(a)) => parts.push(a),
3025 (true, None) => {}
3026 }
3027 if !picks.is_empty() {
3028 parts.push(format!("or {}", picks.join("/")));
3029 }
3030 parts.push("then digits".to_string());
3031 rows.push(entry(
3032 Action::LinkPrompt,
3033 &format!("link prompt — {}", parts.join(", ")),
3034 ));
3035 }
3036 rows.push(entry(Action::Help, "this help"));
3037 if !picker_mode {
3038 rows.push(entry(Action::CommandPalette, "open the command palette"));
3039 }
3040 if !picker_mode {
3041 rows.extend([
3042 HelpRow::Blank,
3043 HelpRow::Section("Create Form".to_string()),
3044 HelpRow::Blank,
3045 modal_entry(ModalAction::CreatePrevType, "previous branch type"),
3046 modal_entry(ModalAction::CreateNextType, "next branch type"),
3047 modal_entry(ModalAction::CreateNextField, "next field"),
3048 modal_entry(ModalAction::CreatePrevField, "previous field"),
3049 modal_entry(ModalAction::CreateSubmit, "submit (on the last field) / next field"),
3050 modal_entry(
3051 ModalAction::CreateToggleMode,
3052 "toggle structured fields ↔ free-form name",
3053 ),
3054 modal_entry(ModalAction::CreateCancel, "cancel"),
3055 fixed(
3056 "0-9",
3057 "type into the issue field, where the patterns ask for one (digits only)",
3058 ),
3059 fixed("any char", "type into the focused text field"),
3060 fixed("Backspace", "delete the last character"),
3061 HelpRow::Blank,
3062 HelpRow::Section("Delete Worktree".to_string()),
3063 HelpRow::Blank,
3064 modal_entry(ModalAction::ConfirmFocusConfirm, "focus the Confirm button"),
3065 modal_entry(ModalAction::ConfirmFocusCancel, "focus the Cancel button"),
3066 modal_entry(ModalAction::ConfirmToggleFocus, "toggle the focused button"),
3067 modal_entry(
3068 ModalAction::ConfirmActivate,
3069 "activate the focused button (defaults to Cancel)",
3070 ),
3071 modal_entry(ModalAction::ConfirmConfirm, "confirm"),
3072 modal_entry(ModalAction::ConfirmCancel, "cancel"),
3073 ]);
3074 rows.extend([
3084 HelpRow::Blank,
3085 HelpRow::Section("Browse Links".to_string()),
3086 HelpRow::Blank,
3087 modal_entry(ModalAction::OpenMenuToggle, "toggle issue / pull request"),
3088 modal_entry(
3089 ModalAction::OpenMenuAccept,
3090 "open the highlighted target in the browser",
3091 ),
3092 modal_entry(ModalAction::OpenMenuIssue, "open the linked issue directly"),
3093 modal_entry(ModalAction::OpenMenuPr, "open the linked pull request directly"),
3094 modal_entry(ModalAction::OpenMenuClose, "close"),
3095 HelpRow::Blank,
3096 HelpRow::Section("Link Prompt".to_string()),
3097 HelpRow::Blank,
3098 modal_entry(ModalAction::LinkChooseNext, "next target (issue / PR)"),
3099 modal_entry(ModalAction::LinkChoosePrev, "previous target"),
3100 modal_entry(ModalAction::LinkChooseIssue, "pick issue directly"),
3101 modal_entry(ModalAction::LinkChoosePr, "pick pull request directly"),
3102 modal_entry(ModalAction::LinkChooseAccept, "accept the highlighted target"),
3103 modal_entry(ModalAction::LinkChooseCancel, "cancel"),
3104 fixed("0-9", "type the issue / PR number"),
3105 fixed("Backspace", "erase the last digit"),
3106 modal_entry(ModalAction::LinkInputSubmit, "submit the typed number"),
3107 modal_entry(ModalAction::LinkInputCancel, "cancel the number input"),
3108 HelpRow::Blank,
3109 HelpRow::Section("Exec Profiles".to_string()),
3110 HelpRow::Blank,
3111 modal_entry(ModalAction::ExecPickerNext, "next profile"),
3112 modal_entry(ModalAction::ExecPickerPrev, "previous profile"),
3113 modal_entry(ModalAction::ExecPickerAccept, "run the profile in a PTY overlay"),
3114 modal_entry(ModalAction::ExecPickerCancel, "cancel"),
3115 HelpRow::Blank,
3116 HelpRow::Section("Clean Reclaim".to_string()),
3117 HelpRow::Blank,
3118 modal_entry(ModalAction::CleanNext, "next profile"),
3119 modal_entry(ModalAction::CleanPrev, "previous profile"),
3120 modal_entry(ModalAction::CleanConfirm, "reclaim (starts the safety countdown)"),
3121 modal_entry(ModalAction::CleanCancel, "cancel"),
3122 HelpRow::Blank,
3123 HelpRow::Section("Agent Sessions".to_string()),
3124 HelpRow::Blank,
3125 modal_entry(ModalAction::DetailSelectNext, "next session"),
3126 modal_entry(ModalAction::DetailSelectPrev, "previous session"),
3127 modal_entry(ModalAction::DetailAttach, "attach to the selected session"),
3128 modal_entry(ModalAction::DetailDetach, "detach the selected session"),
3129 modal_entry(ModalAction::DetailInput, "attach by id (palette-style prompt)"),
3130 fixed("any char", "attach prompt: type to filter the session ids"),
3131 fixed("Backspace", "attach prompt: delete the last character"),
3132 fixed("Up/Down", "attach prompt: move the highlight"),
3133 fixed("enter", "attach prompt: attach the highlighted session"),
3134 fixed("Esc", "attach prompt: back to the list"),
3135 modal_entry(ModalAction::DetailClose, "close"),
3136 HelpRow::Blank,
3137 HelpRow::Section("CI Checks".to_string()),
3138 HelpRow::Blank,
3139 modal_entry(ModalAction::CiChecksNext, "next check"),
3140 modal_entry(ModalAction::CiChecksPrev, "previous check"),
3141 modal_entry(ModalAction::CiChecksOpen, "open the check's details URL in the browser"),
3142 modal_entry(ModalAction::CiChecksFilter, "filter the checks by name"),
3143 modal_entry(ModalAction::CiChecksRefresh, "re-fetch the PR and refresh the rows"),
3144 fixed("any char", "filter: type to narrow the checks"),
3145 fixed("Backspace", "filter: delete the last character"),
3146 fixed("Up/Down", "filter: move the highlight"),
3147 fixed("enter", "filter: open the highlighted check's URL"),
3148 fixed("Esc", "filter: back to the list"),
3149 modal_entry(ModalAction::CiChecksClose, "close"),
3150 HelpRow::Blank,
3151 HelpRow::Section("Bootstrap Report".to_string()),
3152 HelpRow::Blank,
3153 modal_entry(ModalAction::ReportClose, "close"),
3154 ]);
3155 }
3156 if !picker_mode {
3162 rows.extend([
3163 HelpRow::Blank,
3164 HelpRow::Section("Command Palette".to_string()),
3165 HelpRow::Blank,
3166 modal_entry(ModalAction::CommandPaletteNext, "next command"),
3167 modal_entry(ModalAction::CommandPalettePrev, "previous command"),
3168 modal_entry(ModalAction::CommandPaletteAccept, "run the highlighted command"),
3169 fixed("a-z 0-9 _ -", "fuzzy-filter the commands (lowercase input only)"),
3170 fixed("Backspace", "delete the last filter character"),
3171 modal_entry(ModalAction::CommandPaletteClose, "close"),
3172 HelpRow::Blank,
3173 HelpRow::Section("Command Logs".to_string()),
3174 HelpRow::Blank,
3175 modal_entry(ModalAction::CommandLogsScrollDown, "scroll down"),
3176 modal_entry(ModalAction::CommandLogsScrollUp, "scroll up"),
3177 modal_entry(ModalAction::CommandLogsScrollLeft, "pan left"),
3178 modal_entry(ModalAction::CommandLogsScrollRight, "pan right"),
3179 modal_entry(ModalAction::CommandLogsScrollTop, "jump to the top"),
3180 modal_entry(ModalAction::CommandLogsScrollBottom, "jump to the bottom"),
3181 modal_entry(
3182 ModalAction::CommandLogsCopy,
3183 "copy the full transcript to the clipboard",
3184 ),
3185 modal_entry(ModalAction::CommandLogsClose, "close"),
3186 HelpRow::Blank,
3187 HelpRow::Section("Settings".to_string()),
3188 HelpRow::Blank,
3189 modal_entry(ModalAction::ConfigNextTab, "next tab"),
3190 modal_entry(ModalAction::ConfigPrevTab, "previous tab"),
3191 modal_entry(ModalAction::ConfigToggleLayer, "toggle the Project / Global layer"),
3192 modal_entry(ModalAction::ConfigSelectNext, "next setting (All tab: scroll down)"),
3193 modal_entry(ModalAction::ConfigSelectPrev, "previous setting (All tab: scroll up)"),
3194 modal_entry(
3195 ModalAction::ConfigActivate,
3196 "toggle / edit the selected setting (Keys tab: start a key capture — a modal verb commits on its first stroke)",
3197 ),
3198 modal_entry(ModalAction::ConfigScrollLeft, "pan left (All tab)"),
3199 modal_entry(ModalAction::ConfigScrollRight, "pan right (All tab)"),
3200 modal_entry(ModalAction::ConfigScrollTop, "jump to the top (All tab)"),
3201 modal_entry(ModalAction::ConfigScrollBottom, "jump to the bottom (All tab)"),
3202 modal_entry(
3203 ModalAction::ConfigEditSubmit,
3204 "commit the edited value / the captured global chord",
3205 ),
3206 modal_entry(ModalAction::ConfigEditCancel, "cancel the edit / the key capture"),
3207 fixed(
3208 "any char",
3209 "type the value — free text for text fields, digits for numeric ones",
3210 ),
3211 fixed(
3212 "Backspace",
3213 "erase the last character / drop the last stroke of a global capture",
3214 ),
3215 fixed("enter", "capture: commit the global chord (reserved, despite rebinds)"),
3216 fixed("Esc", "capture: cancel (reserved, despite rebinds)"),
3217 modal_entry(ModalAction::ConfigClose, "close"),
3218 HelpRow::Blank,
3219 HelpRow::Section("PTY Overlay".to_string()),
3220 HelpRow::Blank,
3221 fixed(
3222 "Esc",
3223 "close the overlay — other keys pass through (any key but Ctrl-C closes a finished exec run)",
3224 ),
3225 ]);
3226 rows.extend([
3227 HelpRow::Blank,
3228 HelpRow::Section("Help Overlay".to_string()),
3229 HelpRow::Blank,
3230 modal_entry(ModalAction::HelpScrollDown, "scroll down"),
3231 modal_entry(ModalAction::HelpScrollUp, "scroll up"),
3232 modal_entry(ModalAction::HelpScrollLeft, "pan left"),
3233 modal_entry(ModalAction::HelpScrollRight, "pan right"),
3234 modal_entry(ModalAction::HelpScrollTop, "jump to the top"),
3235 modal_entry(ModalAction::HelpScrollBottom, "jump to the bottom"),
3236 modal_entry(ModalAction::HelpClose, "close"),
3237 ]);
3238 }
3239 rows
3240}
3241
3242pub fn help_lines(km: &super::keymap::Keymap, modal: &ModalKeymap, picker_mode: bool) -> Vec<String> {
3248 let ctx = if picker_mode {
3252 HintContext::Picker
3253 } else {
3254 HintContext::Worktrees
3255 };
3256 help_rows(km, modal, ctx)
3257 .into_iter()
3258 .map(|row| match row {
3259 HelpRow::Title(s) | HelpRow::Subtitle(s) | HelpRow::Section(s) => s,
3260 HelpRow::Blank => String::new(),
3261 HelpRow::Entry { keys, label } => {
3262 let keys = if keys.is_empty() { "(unbound)".to_string() } else { keys };
3263 format!(" {:<13} {}", keys, label)
3264 }
3265 })
3266 .collect()
3267}
3268
3269pub fn badge_group_width(keys: &str) -> usize {
3275 if keys.is_empty() || keys == "(unbound)" {
3276 return "(unbound)".chars().count();
3277 }
3278 let chords: Vec<&str> = keys.split(", ").collect();
3279 let glyphs: usize = chords.iter().map(|c| c.chars().count()).sum();
3283 glyphs + chords.len().saturating_sub(1)
3284}
3285
3286pub fn help_entry_line(keys: &str, label: &str, max_group_w: usize, theme: &Theme) -> Line<'static> {
3293 let key_style = hint_key_style(theme);
3294 let muted_style = Style::default().fg(theme.muted);
3295 let mut spans: Vec<Span<'static>> = vec![Span::raw(" ")];
3296 if keys.is_empty() || keys == "(unbound)" {
3297 spans.push(Span::styled("(unbound)", muted_style));
3298 } else {
3299 for (i, chord) in keys.split(", ").enumerate() {
3300 if i > 0 {
3301 spans.push(Span::raw(" "));
3302 }
3303 spans.push(Span::styled(chord.to_string(), key_style));
3304 }
3305 }
3306 let pad = max_group_w.saturating_sub(badge_group_width(keys)) + 1;
3307 spans.push(Span::raw(" ".repeat(pad)));
3308 spans.push(Span::styled(label.to_string(), help_label_style(theme)));
3309 Line::from(spans)
3310}
3311
3312fn draw_help(f: &mut Frame, app: &mut App) {
3313 let area = centered(60, 60, f.area());
3314 let rows = help_rows(&app.keymap, &app.modal_keymap, app.pane_hint_context());
3318
3319 let accent = app.theme.accent;
3322
3323 let heading_style = Style::default().fg(accent).add_modifier(Modifier::BOLD);
3324 let subtitle_style = Style::default().fg(app.theme.branch).add_modifier(Modifier::ITALIC);
3328
3329 let max_group_w = rows
3332 .iter()
3333 .filter_map(|r| match r {
3334 HelpRow::Entry { keys, .. } => Some(badge_group_width(keys)),
3335 _ => None,
3336 })
3337 .max()
3338 .unwrap_or(0);
3339
3340 let mut header_lines: Vec<Line<'static>> = Vec::new();
3346 let mut body_lines: Vec<Line<'static>> = Vec::new();
3347 for row in rows {
3348 match row {
3349 HelpRow::Title(t) => header_lines.push(Line::from(Span::styled(t, heading_style)).centered()),
3351 HelpRow::Subtitle(t) => header_lines.push(Line::from(Span::styled(t, subtitle_style)).centered()),
3352 HelpRow::Section(t) => body_lines.push(Line::from(Span::styled(
3355 t,
3356 help_section_style(help_body_section_color(&app.theme)),
3357 ))),
3358 HelpRow::Blank => body_lines.push(Line::from(String::new())),
3359 HelpRow::Entry { keys, label } => {
3360 body_lines.push(help_entry_line(&keys, &label, max_group_w, &app.theme));
3361 }
3362 }
3363 }
3364
3365 let block = overlay_block(accent);
3366 let inner_area = block.inner(area);
3367 f.render_widget(Clear, area);
3368 f.render_widget(block, area);
3369
3370 let header_h = header_lines.len() as u16;
3374 let [header_area, body_area, footer_area] =
3375 Layout::vertical([Constraint::Length(header_h), Constraint::Min(1), Constraint::Length(1)]).areas(inner_area);
3376
3377 f.render_widget(Paragraph::new(header_lines), header_area);
3378
3379 let body_viewport = body_area.height as usize;
3383 app.help_max_scroll = (body_lines.len().saturating_sub(body_viewport)) as u16;
3384 app.help_scroll = app.help_scroll.min(app.help_max_scroll);
3385 let scroll = app.help_scroll;
3386 let text_area = scrollable_body_area(f, body_area, scroll, body_lines.len(), &app.theme);
3389 let content_width = body_lines.iter().map(Line::width).max().unwrap_or(0);
3390 app.help_max_x_scroll = content_width.saturating_sub(text_area.width as usize) as u16;
3391 app.help_x_scroll = app.help_x_scroll.min(app.help_max_x_scroll);
3392 let x_scroll = app.help_x_scroll;
3393 f.render_widget(Paragraph::new(body_lines).scroll((scroll, x_scroll)), text_area);
3394 f.render_widget(
3395 modal_hint_for_context(HintContext::Help, &app.keymap, &app.modal_keymap, &app.theme),
3396 footer_area,
3397 );
3398}
3399
3400fn draw_command_logs(f: &mut Frame, app: &mut App) {
3408 let area = centered(90, 85, f.area());
3409 let accent = app.theme.accent;
3410 let muted = app.theme.muted;
3411 let ok_color = app.theme.clean;
3412 let err_color = app.theme.prunable;
3413 let label_style = help_label_style(&app.theme);
3414 let muted_style = Style::default().fg(muted);
3415 let heading_style = Style::default().fg(accent).add_modifier(Modifier::BOLD);
3416
3417 let block = overlay_block(accent);
3420 let inner = block.inner(area);
3421 f.render_widget(Clear, area);
3422 f.render_widget(block, area);
3423
3424 let [header_area, body_area, footer_area] =
3425 Layout::vertical([Constraint::Length(1), Constraint::Min(1), Constraint::Length(1)]).areas(inner);
3426
3427 f.render_widget(
3428 Paragraph::new(Line::from(Span::styled("Command Logs", heading_style)).centered()),
3429 header_area,
3430 );
3431
3432 let rule = "-".repeat(body_area.width as usize);
3435 let mut lines: Vec<Line<'static>> = Vec::new();
3436
3437 if app.command_logs.entries.is_empty() {
3438 lines.push(Line::from(Span::styled("No commands run yet.", muted_style)));
3439 } else {
3440 for (i, entry) in app.command_logs.entries.iter().rev().enumerate() {
3443 if i > 0 {
3444 lines.push(Line::from(String::new()));
3445 lines.push(Line::from(Span::styled(rule.clone(), muted_style)));
3446 lines.push(Line::from(String::new()));
3447 }
3448 lines.push(Line::from(vec![
3450 Span::styled("$ ", Style::default().fg(accent).add_modifier(Modifier::BOLD)),
3451 Span::styled(entry.command.clone(), label_style),
3452 ]));
3453 let (color, detail) = match &entry.status {
3455 CommandStatus::Exited(Some(0)) => (ok_color, format!("→ exit 0 ({} ms)", entry.duration.as_millis())),
3456 CommandStatus::Exited(Some(code)) => (
3457 err_color,
3458 format!("→ exit {} ({} ms)", code, entry.duration.as_millis()),
3459 ),
3460 CommandStatus::Exited(None) => (err_color, format!("→ terminated ({} ms)", entry.duration.as_millis())),
3461 CommandStatus::Spawn => (err_color, "✗ failed to spawn".to_string()),
3462 };
3463 lines.push(Line::from(vec![
3464 Span::raw(" "),
3465 Span::styled(detail, Style::default().fg(color)),
3466 ]));
3467 if !entry.output.is_empty() {
3470 const MAX_OUTPUT_LINES: usize = 6;
3471 let out: Vec<&str> = entry.output.lines().collect();
3472 let start = out.len().saturating_sub(MAX_OUTPUT_LINES);
3473 if start > 0 {
3474 lines.push(Line::from(Span::styled(
3475 format!(" … {} earlier line(s)", start),
3476 muted_style,
3477 )));
3478 }
3479 for l in &out[start..] {
3480 lines.push(Line::from(Span::styled(format!(" {}", l), muted_style)));
3481 }
3482 }
3483 }
3484 }
3485
3486 let body_viewport = body_area.height as usize;
3488 app.command_logs.max_scroll = (lines.len().saturating_sub(body_viewport)) as u16;
3489 app.command_logs.scroll = app.command_logs.scroll.min(app.command_logs.max_scroll);
3490 let scroll = app.command_logs.scroll;
3491 let text_area = scrollable_body_area(f, body_area, scroll, lines.len(), &app.theme);
3493 let content_w = lines.iter().map(Line::width).max().unwrap_or(0);
3494 app.command_logs.max_x_scroll = content_w.saturating_sub(text_area.width as usize) as u16;
3495 app.command_logs.x_scroll = app.command_logs.x_scroll.min(app.command_logs.max_x_scroll);
3496 let x_scroll = app.command_logs.x_scroll;
3497 f.render_widget(Paragraph::new(lines).scroll((scroll, x_scroll)), text_area);
3498 let footer_owned = command_logs_footer_hints(&app.modal_keymap);
3501 let footer_hints: Vec<(&str, &str)> = footer_owned.iter().map(|(k, l)| (k.as_str(), l.as_str())).collect();
3502 f.render_widget(modal_hint_line(&footer_hints, &app.theme), footer_area);
3503}
3504
3505fn scrollable_body_area(f: &mut Frame, area: Rect, offset: u16, content_len: usize, theme: &Theme) -> Rect {
3511 let viewport = area.height as usize;
3512 if content_len <= viewport || area.width < 2 {
3513 return area;
3514 }
3515 let max_scroll = content_len - viewport;
3521 let mut state = ScrollbarState::new(max_scroll + 1)
3522 .position(offset as usize)
3523 .viewport_content_length(viewport);
3524 let bar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
3525 .begin_symbol(None)
3526 .end_symbol(None)
3527 .thumb_style(Style::default().fg(theme.accent))
3528 .track_style(Style::default().fg(theme.muted));
3529 f.render_stateful_widget(bar, area, &mut state);
3530 Rect {
3531 width: area.width.saturating_sub(1),
3532 ..area
3533 }
3534}
3535
3536fn settings_all_lines(app: &App) -> Vec<Line<'static>> {
3541 let accent = app.theme.accent;
3542 let muted = app.theme.muted;
3543 let label_style = help_label_style(&app.theme);
3544 let muted_style = Style::default().fg(muted);
3545 let mut lines: Vec<Line<'static>> = Vec::new();
3546
3547 if app.config_panel.rows.is_empty() {
3548 lines.push(Line::from(Span::styled("No configuration resolved.", muted_style)));
3549 return lines;
3550 }
3551 let mut current_section: Option<String> = None;
3552 for row in &app.config_panel.rows {
3553 let section = row.key.split(['.', '[']).next().unwrap_or("").to_string();
3554 if current_section.as_deref() != Some(section.as_str()) {
3555 if current_section.is_some() {
3556 lines.push(Line::from(String::new()));
3557 }
3558 lines.push(Line::from(Span::styled(
3559 format!("[{section}]"),
3560 help_section_style(accent),
3561 )));
3562 current_section = Some(section);
3563 }
3564 let src_color = match row.source {
3565 ConfigSource::Repo => app.theme.clean,
3566 ConfigSource::User => app.theme.branch,
3567 ConfigSource::Default => muted,
3568 };
3569 lines.push(Line::from(vec![
3570 Span::raw(" "),
3571 Span::styled(format!("{:<7}", row.source.label()), Style::default().fg(src_color)),
3572 Span::raw(" "),
3573 Span::styled(row.key.clone(), label_style),
3574 Span::styled(" = ", muted_style),
3575 Span::styled(row.value.clone(), Style::default().fg(Color::White)),
3576 ]));
3577 }
3578 lines
3579}
3580
3581fn settings_fields_lines(app: &App, fields: &[SettingField]) -> Vec<Line<'static>> {
3587 let accent = app.theme.accent;
3588 let muted = app.theme.muted;
3589 let label_style = help_label_style(&app.theme);
3590 let muted_style = Style::default().fg(muted);
3591 let panel = &app.config_panel;
3592 let mut lines: Vec<Line<'static>> = Vec::new();
3593
3594 for (i, field) in fields.iter().enumerate() {
3595 let selected = i == panel.selected;
3596 let editing = selected && panel.editing.is_some();
3597 let value = if editing {
3598 format!("{}_", panel.editing.as_deref().unwrap_or(""))
3599 } else {
3600 field.current(&app.config)
3601 };
3602 let marker = if selected { "›" } else { " " };
3603 let marker_style = Style::default().fg(accent).add_modifier(Modifier::BOLD);
3604 let value_style = if selected {
3605 Style::default().fg(accent).add_modifier(Modifier::BOLD)
3606 } else {
3607 Style::default().fg(Color::White)
3608 };
3609 let mut spans = vec![
3610 Span::styled(format!(" {marker} "), marker_style),
3611 Span::styled(format!("{:<24}", field.label()), label_style),
3612 Span::styled(value, value_style),
3613 ];
3614 if selected && panel.layer.source() == ConfigSource::User && panel.field_source(*field) == Some(ConfigSource::Repo)
3618 {
3619 spans.push(Span::styled(" — set in .gwm.toml; switch to Project", muted_style));
3620 }
3621 lines.push(Line::from(spans));
3622 }
3623 lines
3624}
3625
3626fn settings_keys_lines(app: &App) -> (Vec<Line<'static>>, Option<usize>) {
3634 let accent = app.theme.accent;
3635 let muted = app.theme.muted;
3636 let label_style = help_label_style(&app.theme);
3637 let muted_style = Style::default().fg(muted);
3638 let panel = &app.config_panel;
3639 let mut lines: Vec<Line<'static>> = Vec::new();
3640 let mut selected_line: Option<usize> = None;
3641
3642 if panel.key_rows.is_empty() {
3643 lines.push(Line::from(Span::styled("No bindings resolved.", muted_style)));
3644 return (lines, None);
3645 }
3646
3647 let mut current_scope: Option<String> = None;
3648 for (i, row) in panel.key_rows.iter().enumerate() {
3649 if current_scope.as_deref() != Some(row.scope.as_str()) {
3650 if current_scope.is_some() {
3651 lines.push(Line::from(String::new()));
3652 }
3653 lines.push(Line::from(Span::styled(
3654 format!("[{}]", row.scope),
3655 help_section_style(accent),
3656 )));
3657 current_scope = Some(row.scope.clone());
3658 }
3659
3660 let selected = i == panel.selected;
3661 if selected {
3662 selected_line = Some(lines.len());
3663 }
3664 let capturing = selected && panel.capture.is_some();
3665 let marker = if selected { "›" } else { " " };
3666 let marker_style = Style::default().fg(accent).add_modifier(Modifier::BOLD);
3667 let src_color = match row.source {
3668 ConfigSource::Repo => app.theme.clean,
3669 ConfigSource::User => app.theme.branch,
3670 ConfigSource::Default => muted,
3671 };
3672
3673 let key_span = if capturing {
3674 let pending = panel
3675 .capture
3676 .as_ref()
3677 .map(|c| c.pending.iter().map(|s| s.to_string()).collect::<Vec<_>>().join(" "))
3678 .unwrap_or_default();
3679 Span::styled(
3680 format!("[ {pending}_ ]"),
3681 Style::default().fg(accent).add_modifier(Modifier::BOLD),
3682 )
3683 } else {
3684 let shown = if row.keys.is_empty() {
3685 "(unbound)".to_string()
3686 } else {
3687 row.keys.clone()
3688 };
3689 let style = if row.keys.is_empty() {
3690 muted_style
3691 } else if selected {
3692 Style::default().fg(accent).add_modifier(Modifier::BOLD)
3693 } else {
3694 Style::default().fg(Color::White)
3695 };
3696 Span::styled(shown, style)
3697 };
3698
3699 lines.push(Line::from(vec![
3700 Span::styled(format!(" {marker} "), marker_style),
3701 Span::styled(format!("{:<7}", row.source.label()), Style::default().fg(src_color)),
3702 Span::raw(" "),
3703 Span::styled(format!("{:<24}", row.label), label_style),
3704 key_span,
3705 ]));
3706 }
3707 (lines, selected_line)
3708}
3709
3710fn draw_config_panel(f: &mut Frame, app: &mut App) {
3717 let area = centered(60, 60, f.area());
3718 let accent = app.theme.accent;
3719 let muted = app.theme.muted;
3720 let muted_style = Style::default().fg(muted);
3721 let heading_style = Style::default().fg(accent).add_modifier(Modifier::BOLD);
3722 let subtitle_style = Style::default().fg(app.theme.branch).add_modifier(Modifier::ITALIC);
3725
3726 let tab = app.config_panel.tab;
3727 let editing = app.config_panel.editing.is_some();
3728 let selected_kind = app.config_panel.selected_field().map(SettingField::kind);
3729
3730 let title = Line::from(Span::styled("Settings", heading_style)).centered();
3734 let subtitle = Line::from(Span::styled(app.config_panel.layer.label(), subtitle_style)).centered();
3735 let mut tab_spans: Vec<Span<'static>> = vec![Span::raw(" ")];
3736 for (i, t) in SettingsTab::ALL.iter().enumerate() {
3737 if i > 0 {
3738 tab_spans.push(Span::raw(" "));
3739 }
3740 let style = if *t == tab { chip_style(accent) } else { muted_style };
3741 tab_spans.push(Span::styled(format!(" {} ", t.label()), style));
3742 }
3743 let header_lines = vec![title, subtitle, Line::from(String::new()), Line::from(tab_spans)];
3744
3745 let mut selected_line: Option<usize> = None;
3758 let body_lines = match tab {
3759 SettingsTab::All => settings_all_lines(app),
3760 SettingsTab::Keys => {
3761 let (lines, sel) = settings_keys_lines(app);
3762 selected_line = sel;
3763 lines
3764 }
3765 other => {
3766 let fields = other.fields();
3767 if !fields.is_empty() {
3768 selected_line = Some(app.config_panel.selected.min(fields.len().saturating_sub(1)));
3769 }
3770 settings_fields_lines(app, fields)
3771 }
3772 };
3773
3774 let capture_single = app.config_panel.capture.as_ref().map(|c| c.single_only);
3780 let footer_owned = if let Some(single) = capture_single {
3781 config_capture_footer_hints(&app.modal_keymap, single)
3782 } else if editing {
3783 config_edit_footer_hints(&app.modal_keymap)
3784 } else {
3785 config_nav_footer_hints(&app.modal_keymap, tab, selected_kind)
3786 };
3787 let footer_hints: Vec<(&str, &str)> = footer_owned.iter().map(|(k, l)| (k.as_str(), l.as_str())).collect();
3788
3789 let block = overlay_block(accent);
3790 let inner = block.inner(area);
3791 f.render_widget(Clear, area);
3792 f.render_widget(block, area);
3793
3794 let header_h = header_lines.len() as u16;
3795 let [header_area, body_area, footer_area] =
3796 Layout::vertical([Constraint::Length(header_h), Constraint::Min(1), Constraint::Length(1)]).areas(inner);
3797
3798 f.render_widget(Paragraph::new(header_lines), header_area);
3799
3800 let body_viewport = body_area.height as usize;
3802 app.config_panel.max_scroll = (body_lines.len().saturating_sub(body_viewport)) as u16;
3803 if let Some(sel) = selected_line {
3806 let scroll = app.config_panel.scroll as usize;
3807 if sel < scroll {
3808 app.config_panel.scroll = sel as u16;
3809 } else if body_viewport > 0 && sel >= scroll + body_viewport {
3810 app.config_panel.scroll = (sel + 1 - body_viewport) as u16;
3811 }
3812 }
3813 app.config_panel.scroll = app.config_panel.scroll.min(app.config_panel.max_scroll);
3814 let scroll = app.config_panel.scroll;
3815 let text_area = scrollable_body_area(f, body_area, scroll, body_lines.len(), &app.theme);
3817 let content_w = body_lines.iter().map(Line::width).max().unwrap_or(0);
3818 app.config_panel.max_x_scroll = content_w.saturating_sub(text_area.width as usize) as u16;
3819 app.config_panel.x_scroll = app.config_panel.x_scroll.min(app.config_panel.max_x_scroll);
3820 let x_scroll = app.config_panel.x_scroll;
3821 f.render_widget(Paragraph::new(body_lines).scroll((scroll, x_scroll)), text_area);
3822 f.render_widget(modal_hint_line(&footer_hints, &app.theme), footer_area);
3823}
3824
3825fn rename_pr_warning(app: &App, new_branch: &str, old_branch: &str) -> Option<String> {
3862 if new_branch == old_branch {
3863 return None;
3864 }
3865 let w = app.selected()?;
3866 if !matches!(w.pr_state.or(w.link.pr_state)?, PrState::Open | PrState::Draft) {
3867 return None;
3868 }
3869 Some(match w.link.pr {
3870 Some(number) => format!("⚠ renaming the branch closes PR #{}", number),
3871 None => "⚠ renaming the branch closes its open pull request".into(),
3872 })
3873}
3874
3875fn pattern_preview(app: &App, type_str: &str) -> (String, String) {
3891 if app.create_form.mode == Mode::Freeform {
3892 let name = crate::naming::WorktreeName::Freeform(app.create_form.name.clone());
3893 return (
3894 name
3895 .branch_name(&app.config.worktree, &app.repo_name)
3896 .unwrap_or_default(),
3897 name
3898 .worktree_dirname(&app.config.worktree, &app.repo_name)
3899 .unwrap_or_default(),
3900 );
3901 }
3902 let expand = |pattern: &str| {
3903 crate::config::expand_placeholders(
3904 pattern,
3905 &app.repo_name,
3906 Some(type_str),
3907 Some(&app.create_form.issue),
3908 Some(&app.create_form.desc),
3909 None,
3910 )
3911 .unwrap_or_default()
3912 };
3913 (
3914 expand(&app.config.worktree.branch_pattern),
3915 expand(&app.config.worktree.path_pattern),
3916 )
3917}
3918
3919fn form_field_lines(app: &App, type_str: &str, type_desc: &str, value_w: usize, label_w: usize) -> Vec<Line<'static>> {
3932 let accent = app.theme.accent;
3933 let muted = app.theme.muted;
3934 let surface = app.theme.selection_bg;
3935 let label = |s: &str| format!("{:<label_w$}", s);
3936
3937 let mut lines: Vec<Line<'static>> = Vec::new();
3938 for field in app.create_form.fields() {
3939 if !lines.is_empty() {
3940 lines.push(Line::from(String::new()));
3941 }
3942 lines.push(match field {
3943 Field::Type => type_selector_line(
3944 &label("Type"),
3945 type_str,
3946 type_desc,
3947 app.create_form.field == Field::Type,
3948 accent,
3949 muted,
3950 ),
3951 Field::Issue => field_input_line(
3952 &label("Issue"),
3953 &app.create_form.issue,
3954 app.create_form.field == Field::Issue,
3955 value_w,
3956 accent,
3957 muted,
3958 surface,
3959 ),
3960 Field::Desc => field_input_line(
3961 &label("Desc"),
3962 &app.create_form.desc,
3963 app.create_form.field == Field::Desc,
3964 value_w,
3965 accent,
3966 muted,
3967 surface,
3968 ),
3969 Field::Name => continue,
3971 });
3972 }
3973 lines
3974}
3975
3976fn draw_create(f: &mut Frame, app: &App) {
3977 let accent = app.theme.accent;
3978 let muted = app.theme.muted;
3979 let clean = app.theme.clean;
3980 let surface = app.theme.selection_bg;
3981
3982 let (type_str, type_desc) = app
3983 .branch_types
3984 .get(app.create_form.type_index)
3985 .map(|t| (t.name.as_str(), t.description.as_str()))
3986 .unwrap_or(("", "(no branch types configured)"));
3987
3988 let block = overlay_block(clean);
3989 let term = f.area();
3990 let outer = centered_box(70, 72, 1, term);
3991 let inner_w = block.inner(outer).width as usize;
3992
3993 let label_w = 5usize;
3996 let gutter = 2 + label_w + 2;
3997 let value_w = inner_w.saturating_sub(gutter);
3998
3999 let label = |s: &str| format!("{:<label_w$}", s);
4000 let freeform = app.create_form.mode == Mode::Freeform;
4001 let (branch_raw, dir_raw) = if freeform {
4004 (app.create_form.name.clone(), app.create_form.name.replace('/', "-"))
4005 } else {
4006 pattern_preview(app, type_str)
4007 };
4008 let branch = ellipsize_middle(&branch_raw, inner_w.saturating_sub(" Branch : ".len()));
4009 let dirname = ellipsize_middle(&dir_raw, inner_w.saturating_sub(" Dir : ".len()));
4010
4011 let mut lines = overlay_title_lines(
4012 if freeform {
4013 "New Worktree — free-form"
4014 } else {
4015 "New Worktree"
4016 },
4017 clean,
4018 );
4019 lines.push(Line::from(vec![
4024 Span::raw(" Branch : "),
4025 Span::styled(branch, Style::default().fg(app.theme.branch)),
4026 ]));
4027 lines.push(Line::from(vec![
4028 Span::raw(" Dir : "),
4029 Span::styled(dirname, Style::default().fg(app.theme.dirty)),
4030 ]));
4031 lines.push(Line::from(String::new()));
4032 if freeform {
4033 lines.push(field_input_line(
4034 &label("Name"),
4035 &app.create_form.name,
4036 app.create_form.field == Field::Name,
4037 value_w,
4038 accent,
4039 muted,
4040 surface,
4041 ));
4042 } else {
4043 lines.extend(form_field_lines(app, type_str, type_desc, value_w, label_w));
4044 }
4045
4046 let height = lines.len() as u16 + 4 + 2 + 2 ;
4047 let area = centered_box(70, 72, height, term);
4048 let inner = Layout::default()
4049 .direction(Direction::Vertical)
4050 .constraints([
4051 Constraint::Min(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), ])
4057 .split(block.inner(area));
4058
4059 f.render_widget(Clear, area);
4060 f.render_widget(block, area);
4061 f.render_widget(Paragraph::new(lines), inner[0]);
4062
4063 if app.is_create_worktree_loading() {
4064 f.render_widget(
4065 LoaderWidget::running(
4066 app.spinner.glyph(DOT_FRAMES),
4067 TaskKind::CreateWorktree.loading_label(),
4068 None,
4069 &app.theme,
4070 )
4071 .alignment(Alignment::Center),
4072 inner[1],
4073 );
4074 } else if let Some(error) = app.create_failure.as_deref() {
4075 f.render_widget(
4076 LoaderWidget::failed("create failed", Some(error), &app.theme).alignment(Alignment::Center),
4077 inner[1],
4078 );
4079 }
4080
4081 if !app.is_create_worktree_loading() {
4082 f.render_widget(
4083 Paragraph::new(create_buttons_line(accent, muted)).alignment(Alignment::Center),
4084 inner[2],
4085 );
4086 f.render_widget(
4087 Paragraph::new(modal_hint_for_context_with_fields(
4088 app.create_hint_context(),
4089 &app.keymap,
4090 &app.modal_keymap,
4091 &app.theme,
4092 app.create_form.fields(),
4093 )),
4094 inner[4],
4095 );
4096 }
4097}
4098
4099pub fn create_buttons_line(accent: Color, muted: Color) -> Line<'static> {
4105 primary_cancel_buttons_line(" Create ", accent, muted)
4106}
4107
4108pub fn rename_buttons_line(accent: Color, muted: Color) -> Line<'static> {
4113 primary_cancel_buttons_line(" Rename ", accent, muted)
4114}
4115
4116fn primary_cancel_buttons_line(primary_label: &'static str, accent: Color, muted: Color) -> Line<'static> {
4117 let primary = chip_style(accent);
4118 let idle = Style::default().fg(muted).add_modifier(Modifier::BOLD);
4119 Line::from(vec![
4120 Span::styled(primary_label, primary),
4121 Span::raw(" "),
4122 Span::styled(" Cancel ", idle),
4123 ])
4124}
4125
4126pub fn type_selector_line(
4132 label: &str,
4133 name: &str,
4134 desc: &str,
4135 focused: bool,
4136 accent: Color,
4137 muted: Color,
4138) -> Line<'static> {
4139 let arrow_style = if focused {
4140 Style::default().fg(accent).add_modifier(Modifier::BOLD)
4141 } else {
4142 Style::default().fg(muted)
4143 };
4144 let name_style = if focused {
4148 chip_style(accent)
4149 } else {
4150 Style::default().fg(Color::White)
4151 };
4152 Line::from(vec![
4153 Span::raw(" "),
4154 Span::styled(label.to_string(), Style::default().fg(muted)),
4155 Span::raw(" "),
4156 Span::styled("‹ ", arrow_style),
4157 Span::styled(format!(" {name} "), name_style),
4158 Span::styled(" ›", arrow_style),
4159 Span::raw(" "),
4160 Span::styled(desc.to_string(), Style::default().fg(muted)),
4161 ])
4162}
4163
4164pub fn field_input_line(
4171 label: &str,
4172 value: &str,
4173 focused: bool,
4174 value_width: usize,
4175 accent: Color,
4176 muted: Color,
4177 surface: Color,
4178) -> Line<'static> {
4179 let cursor = if focused { "_" } else { "" };
4180 let mut field = format!(" {value}{cursor}");
4181 let len = field.chars().count();
4182 if len < value_width {
4183 field.push_str(&" ".repeat(value_width - len));
4184 }
4185 let field_style = if focused {
4186 Style::default().fg(Color::Black).bg(accent)
4187 } else {
4188 Style::default().fg(Color::White).bg(surface)
4189 };
4190 Line::from(vec![
4191 Span::raw(" "),
4192 Span::styled(label.to_string(), Style::default().fg(muted)),
4193 Span::raw(" "),
4194 Span::styled(field, field_style),
4195 ])
4196}
4197
4198pub fn link_target_line(key: &str, label: &str, selected: bool, accent: Color, muted: Color) -> Line<'static> {
4204 const BUTTON_WIDTH: usize = 17; let button = format!(" {key} {label} ");
4206 let button = format!("{button:<BUTTON_WIDTH$}");
4207 if selected {
4208 let chip = chip_style(accent);
4209 return Line::from(vec![Span::raw(" "), Span::styled(button, chip)]);
4210 }
4211
4212 let idle = Style::default().fg(muted);
4213 Line::from(vec![Span::raw(" "), Span::styled(button, idle)])
4214}
4215
4216pub fn link_prompt_modal_width(term_width: u16) -> u16 {
4219 let width = if term_width <= 80 {
4220 term_width.saturating_mul(80) / 100
4221 } else {
4222 term_width.saturating_mul(60) / 100
4223 };
4224 width.min(72).min(term_width)
4225}
4226
4227pub fn overlay_modal_width(term_width: u16) -> u16 {
4233 let pct = if term_width <= 80 { 90 } else { 62 };
4234 (term_width.saturating_mul(pct) / 100).clamp(48, 88).min(term_width)
4235}
4236
4237pub fn help_section_style(section: Color) -> Style {
4240 Style::default().fg(section).add_modifier(Modifier::BOLD)
4241}
4242
4243pub fn confirm_detail_line(
4245 label: &str,
4246 value: impl Into<String>,
4247 label_width: usize,
4248 label_color: Color,
4249 value_style: Style,
4250) -> Line<'static> {
4251 Line::from(vec![
4252 Span::styled(
4253 format!("{label:<label_width$} ", label_width = label_width),
4254 Style::default().fg(label_color),
4255 ),
4256 Span::styled(value.into(), value_style),
4257 ])
4258}
4259
4260pub fn delete_worktree_title() -> &'static str {
4261 "Delete Worktree"
4262}
4263
4264pub fn confirm_delete_branch_line(
4265 enabled: bool,
4266 key: &str,
4267 label_width: usize,
4268 accent: Color,
4269 muted: Color,
4270) -> Line<'static> {
4271 let key_style = chip_style(accent);
4272 let value_style = chip_style(if enabled { accent } else { muted });
4273 Line::from(vec![
4274 Span::styled(
4275 format!("{:<label_width$} ", "Delete Branch", label_width = label_width),
4276 Style::default().fg(muted),
4277 ),
4278 Span::styled(format!(" {key} "), key_style),
4279 Span::raw(" "),
4280 Span::styled(format!(" {enabled} "), value_style),
4281 ])
4282}
4283
4284pub fn help_body_section_color(theme: &Theme) -> Color {
4285 theme.locked
4286}
4287
4288pub fn link_target_keys(ctx: HintContext, modal: &ModalKeymap) -> (String, String) {
4294 let (issue, pr) = match ctx {
4295 HintContext::OpenMenu => (ModalAction::OpenMenuIssue, ModalAction::OpenMenuPr),
4296 _ => (ModalAction::LinkChooseIssue, ModalAction::LinkChoosePr),
4297 };
4298 (
4299 modal.primary_key(issue).unwrap_or_default(),
4300 modal.primary_key(pr).unwrap_or_default(),
4301 )
4302}
4303
4304pub fn link_open_modal_lines(app: &App, title: &str, selected: Option<LinkTarget>) -> Vec<Line<'static>> {
4305 let accent = app.theme.accent;
4306 let muted = app.theme.muted;
4307 let ctx = if title == "Link" {
4308 HintContext::LinkPrompt
4309 } else {
4310 HintContext::OpenMenu
4311 };
4312 let (issue_key, pr_key) = link_target_keys(ctx, &app.modal_keymap);
4315 let mut lines = overlay_title_lines(title, accent);
4316 lines.extend(github_status_lines(app, 56));
4317 lines.push(Line::from(""));
4318 lines.push(link_target_line(&issue_key, "Issue", selected == Some(LinkTarget::Issue), accent, muted).centered());
4319 lines.push(link_target_line(&pr_key, "Pull Request", selected == Some(LinkTarget::Pr), accent, muted).centered());
4320 push_modal_hint(&mut lines, ctx, &app.keymap, &app.modal_keymap, &app.theme);
4321 lines
4322}
4323
4324fn draw_confirm(f: &mut Frame, app: &App) {
4325 let muted = app.theme.muted;
4326 let danger = app.theme.prunable;
4330
4331 let block = overlay_block(danger);
4332
4333 let Some(w) = app.selected() else {
4334 let mut lines = overlay_title_lines(delete_worktree_title(), danger);
4335 lines.push(Line::from("nothing selected").centered());
4336 let height = lines.len() as u16 + 2 + 2 ;
4337 let area = centered_h(40, height, f.area());
4338 f.render_widget(Clear, area);
4339 f.render_widget(Paragraph::new(lines).block(block), area);
4340 return;
4341 };
4342
4343 let term = f.area();
4347 let outer_w = term.width.saturating_mul(62) / 100;
4348 let text_w = outer_w.saturating_sub(6) as usize;
4349 let label_w = "Delete Branch".chars().count();
4350 let value_w = text_w.saturating_sub(label_w + 2).max(1);
4351
4352 let name = ellipsize_middle(&w.name, value_w);
4353 let path = ellipsize_middle(&tilde_compress(&w.path.display().to_string()), value_w);
4354
4355 let mut content: Vec<Line> = overlay_title_lines(delete_worktree_title(), danger);
4358 content.push(confirm_detail_line(
4359 "Worktree",
4360 name,
4361 label_w,
4362 muted,
4363 Style::default().fg(app.theme.dirty).add_modifier(Modifier::BOLD),
4364 ));
4365 content.push(confirm_detail_line(
4366 "Path",
4367 path,
4368 label_w,
4369 muted,
4370 Style::default().fg(muted),
4371 ));
4372 if let Some(b) = &w.branch {
4373 let branch = ellipsize_middle(b, value_w);
4374 content.push(confirm_detail_line(
4375 "Branch",
4376 branch,
4377 label_w,
4378 muted,
4379 Style::default().fg(app.theme.branch),
4380 ));
4381 }
4382 content.push(Line::from(""));
4383 content.push(confirm_delete_branch_line(
4384 app.delete_branch_on_remove,
4385 &action_chord(&app.keymap, Action::ToggleDeleteBranch, "D"),
4388 label_w,
4389 app.theme.accent,
4390 muted,
4391 ));
4392
4393 let height = content.len() as u16 + 4 + 2 + 2 ;
4398 let area = centered_h(62, height, term);
4399 f.render_widget(Clear, area);
4400
4401 let inner = Layout::default()
4407 .direction(Direction::Vertical)
4408 .constraints([
4409 Constraint::Min(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), ])
4415 .split(block.inner(area));
4416 f.render_widget(block, area);
4417
4418 f.render_widget(Paragraph::new(content).wrap(Wrap { trim: false }), inner[0]);
4419
4420 if app.is_delete_worktree_loading() {
4422 f.render_widget(
4423 LoaderWidget::running(
4424 app.spinner.glyph(DOT_FRAMES),
4425 TaskKind::DeleteWorktree.loading_label(),
4426 None,
4427 &app.theme,
4428 )
4429 .alignment(Alignment::Center),
4430 inner[1],
4431 );
4432 } else if let Some(error) = app.delete_failure.as_deref() {
4433 f.render_widget(
4434 LoaderWidget::failed("delete failed", Some(error), &app.theme).alignment(Alignment::Center),
4435 inner[1],
4436 );
4437 } else if app.confirm_is_countdown_mode() && app.confirm.is_armed() {
4438 let now = Instant::now();
4439 let mut spans = vec![Span::styled(
4440 format!("{} ", app.spinner.glyph(DOT_FRAMES)),
4441 Style::default().fg(danger).add_modifier(Modifier::BOLD),
4442 )];
4443 spans.extend(countdown_bar(
4444 app.confirm_countdown_progress(now),
4445 app.confirm_countdown_remaining_secs(now),
4446 danger,
4447 app.theme.dirty,
4448 muted,
4449 ));
4450 f.render_widget(Paragraph::new(Line::from(spans)).alignment(Alignment::Center), inner[1]);
4451 }
4452
4453 if !app.is_delete_worktree_loading() {
4455 f.render_widget(
4456 Paragraph::new(confirm_buttons_line(
4457 app.confirm.focused_button(),
4458 app.theme.accent,
4459 muted,
4460 ))
4461 .alignment(Alignment::Center),
4462 inner[2],
4463 );
4464
4465 f.render_widget(
4466 Paragraph::new(modal_hint_for_context(
4467 HintContext::Confirm,
4468 &app.keymap,
4469 &app.modal_keymap,
4470 &app.theme,
4471 )),
4472 inner[4],
4473 );
4474 }
4475}
4476
4477pub fn confirm_buttons_line(focus: ConfirmButton, accent: Color, muted: Color) -> Line<'static> {
4485 let focused = chip_style(accent);
4486 let idle = Style::default().fg(muted).add_modifier(Modifier::BOLD);
4487 let (confirm_style, cancel_style) = match focus {
4488 ConfirmButton::Confirm => (focused, idle),
4489 ConfirmButton::Cancel => (idle, focused),
4490 };
4491 Line::from(vec![
4492 Span::styled(" Confirm ", confirm_style),
4493 Span::raw(" "),
4494 Span::styled(" Cancel ", cancel_style),
4495 ])
4496}
4497
4498fn countdown_bar<'a>(
4505 progress: f64,
4506 remaining_secs: u64,
4507 filled_color: Color,
4508 secs_color: Color,
4509 frame_color: Color,
4510) -> Vec<Span<'a>> {
4511 const CELLS: usize = 10;
4512 let filled = filled_cells_for_progress(progress, CELLS);
4513 let bar: String = std::iter::repeat_n('█', filled)
4514 .chain(std::iter::repeat_n('░', CELLS - filled))
4515 .collect();
4516 vec![
4517 Span::styled(" [", Style::default().fg(frame_color)),
4518 Span::styled(bar, Style::default().fg(filled_color).add_modifier(Modifier::BOLD)),
4519 Span::styled("] ", Style::default().fg(frame_color)),
4520 Span::styled(
4521 format!("{remaining_secs}s"),
4522 Style::default().fg(secs_color).add_modifier(Modifier::BOLD),
4523 ),
4524 ]
4525}
4526
4527pub fn filled_cells_for_progress(progress: f64, cells: usize) -> usize {
4542 if progress >= 1.0 {
4543 return cells;
4544 }
4545 if progress <= 0.0 || cells == 0 {
4546 return 0;
4547 }
4548 let raw = (progress * cells as f64).floor() as usize;
4549 raw.min(cells.saturating_sub(1))
4551}
4552
4553pub fn bootstrap_report_lines(report: Option<&BootstrapReport>, theme: &Theme) -> Vec<Line<'static>> {
4554 let mut lines: Vec<Line<'static>> = Vec::new();
4555 if let Some(report) = report {
4556 for step in &report.steps {
4557 let sigil = step.status.sigil();
4558 let color = match step.status {
4559 StepStatus::Ok => theme.clean,
4560 StepStatus::Skipped => theme.muted,
4561 StepStatus::Warning => theme.dirty,
4562 StepStatus::Failed => theme.prunable,
4563 };
4564 lines.push(Line::from(vec![
4565 Span::styled(
4566 format!(" {} ", sigil),
4567 Style::default().fg(color).add_modifier(Modifier::BOLD),
4568 ),
4569 Span::styled(step.label.clone(), Style::default().fg(theme.name)),
4570 ]));
4571 for detail_line in step.detail.lines() {
4572 lines.push(Line::from(Span::styled(
4573 format!(" {}", detail_line),
4574 Style::default().fg(theme.muted),
4575 )));
4576 }
4577 }
4578 } else {
4579 lines.push(Line::from("(no report)"));
4580 }
4581 lines
4582}
4583
4584fn draw_report(f: &mut Frame, app: &App) {
4585 let accent = app.theme.accent;
4586 let logs = bootstrap_report_lines(app.report.as_ref(), &app.theme);
4587
4588 let term = f.area();
4592 let logs_height = (logs.len() as u16 + 2).max(3);
4593 let height = (2 + logs_height + 2 + 2 + 2)
4594 .min(term.height.saturating_mul(80) / 100);
4595 let area = centered_h(80, height, term);
4596 let block = overlay_block(accent);
4597 let inner = block.inner(area);
4598 let layout = Layout::default()
4599 .direction(Direction::Vertical)
4600 .constraints([
4601 Constraint::Length(1), Constraint::Length(1), Constraint::Min(3), Constraint::Length(1), Constraint::Length(1), ])
4607 .split(inner);
4608 f.render_widget(Clear, area);
4609 f.render_widget(block, area);
4610 f.render_widget(
4611 Paragraph::new(
4612 Line::from(Span::styled(
4613 "Bootstrap Report",
4614 Style::default().fg(accent).add_modifier(Modifier::BOLD),
4615 ))
4616 .centered(),
4617 ),
4618 layout[0],
4619 );
4620 render_section(f, layout[2], " Logs ", SectionBody::new(&logs), accent, 0, None);
4621 f.render_widget(
4622 Paragraph::new(modal_hint_for_context(
4623 HintContext::Report,
4624 &app.keymap,
4625 &app.modal_keymap,
4626 &app.theme,
4627 )),
4628 layout[4],
4629 );
4630}
4631
4632fn draw_pty_overlay(f: &mut Frame, app: &mut App) {
4639 let term = f.area();
4640 let area = centered(90, 90, term);
4641
4642 f.render_widget(Clear, area);
4643
4644 let title = match app.pty_overlay.as_ref().map(|p| (p.kind, p.finished)) {
4645 Some((PtyKind::LazyGit, _)) => " LazyGit ",
4646 Some((PtyKind::Terminal, _)) => " Terminal ",
4647 Some((PtyKind::Review, _)) => " Review ",
4648 Some((PtyKind::Exec, false)) => " Exec ",
4649 Some((PtyKind::Exec, true)) => " Exec · done — press any key ",
4651 None => " Overlay ",
4652 };
4653 let block = overlay_block(app.theme.accent)
4654 .title(title)
4655 .title_alignment(ratatui::layout::Alignment::Center);
4656 let inner = block.inner(area);
4657 f.render_widget(block, area);
4658
4659 if let Some(pty) = app.pty_overlay.as_ref() {
4660 let pseudo_terminal = tui_term::widget::PseudoTerminal::new(pty.parser.screen());
4661 f.render_widget(pseudo_terminal, inner);
4662 }
4663}
4664
4665fn centered(pct_x: u16, pct_y: u16, area: Rect) -> Rect {
4666 let v = Layout::default()
4667 .direction(Direction::Vertical)
4668 .constraints([
4669 Constraint::Percentage((100 - pct_y) / 2),
4670 Constraint::Percentage(pct_y),
4671 Constraint::Percentage((100 - pct_y) / 2),
4672 ])
4673 .split(area);
4674 Layout::default()
4675 .direction(Direction::Horizontal)
4676 .constraints([
4677 Constraint::Percentage((100 - pct_x) / 2),
4678 Constraint::Percentage(pct_x),
4679 Constraint::Percentage((100 - pct_x) / 2),
4680 ])
4681 .split(v[1])[1]
4682}
4683
4684pub fn centered_abs(width: u16, height: u16, area: Rect) -> Rect {
4689 let width = width.min(area.width);
4690 let height = height.min(area.height);
4691 let x = area.x + area.width.saturating_sub(width) / 2;
4692 let y = area.y + area.height.saturating_sub(height) / 2;
4693 Rect { x, y, width, height }
4694}
4695
4696fn centered_h(width_pct: u16, height: u16, area: Rect) -> Rect {
4702 let width = area.width.saturating_mul(width_pct) / 100;
4703 centered_abs(width, height, area)
4704}
4705
4706fn centered_box(width_pct: u16, max_width: u16, height: u16, area: Rect) -> Rect {
4710 let height = height.min(area.height);
4711 let width = (area.width.saturating_mul(width_pct) / 100)
4712 .min(max_width)
4713 .min(area.width);
4714 let x = area.x + area.width.saturating_sub(width) / 2;
4715 let y = area.y + area.height.saturating_sub(height) / 2;
4716 Rect { x, y, width, height }
4717}
4718
4719fn overlay_block(color: Color) -> Block<'static> {
4729 Block::default()
4730 .borders(Borders::ALL)
4731 .border_type(BorderType::Rounded)
4732 .padding(Padding::symmetric(2, 1))
4733 .border_style(Style::default().fg(color))
4734}
4735
4736fn overlay_title_lines(title: &str, color: Color) -> Vec<Line<'static>> {
4741 vec![
4742 Line::from(Span::styled(
4743 title.to_string(),
4744 Style::default().fg(color).add_modifier(Modifier::BOLD),
4745 ))
4746 .centered(),
4747 Line::from(String::new()),
4748 ]
4749}
4750
4751pub fn ellipsize_middle(s: &str, max: usize) -> String {
4758 let count = s.chars().count();
4759 if count <= max {
4760 return s.to_string();
4761 }
4762 if max <= 1 {
4763 return "…".to_string();
4764 }
4765 let keep = max - 1; let head = keep.div_ceil(2);
4767 let tail = keep - head;
4768 let head_str: String = s.chars().take(head).collect();
4769 let tail_str: String = s.chars().skip(count - tail).collect();
4770 format!("{head_str}…{tail_str}")
4771}
4772
4773fn trunc(s: &str, max: usize) -> String {
4791 let s = crate::naming::sanitise_for_terminal(s);
4792 if s.chars().count() <= max {
4793 s
4794 } else {
4795 let mut out: String = s.chars().take(max.saturating_sub(1)).collect();
4796 out.push('…');
4797 out
4798 }
4799}
4800
4801fn draw_open_menu(f: &mut Frame, app: &App) {
4804 let accent = app.theme.accent;
4805 let lines = link_open_modal_lines(app, "Open in Browser", Some(app.open_menu_selected));
4806 let height = lines.len() as u16 + 2 + 2 ;
4807 let term = f.area();
4808 let width = link_prompt_modal_width(term.width);
4809 let area = centered_abs(width, height, term);
4810 f.render_widget(Clear, area);
4811 f.render_widget(Paragraph::new(lines).block(overlay_block(accent)), area);
4812}
4813
4814fn draw_link_prompt(f: &mut Frame, app: &App) {
4815 let accent = app.theme.accent;
4816 let lines = match app.link_prompt_stage() {
4817 LinkPromptStage::ChooseTarget => {
4818 let selected = app.link_prompt_selected();
4822 link_open_modal_lines(app, "Link", Some(selected))
4823 }
4824 LinkPromptStage::InputNumber => {
4825 let label = match app.link_prompt_target() {
4826 Some(super::app::LinkTarget::Issue) => "issue #",
4827 Some(super::app::LinkTarget::Pr) => "PR #",
4828 None => "#",
4829 };
4830 let mut lines = overlay_title_lines(
4831 &format!("type the {} number", label.trim_end_matches('#').trim()),
4832 accent,
4833 );
4834 lines.push(Line::from(format!(" {}{}_", label, app.link_prompt_number_input())));
4835 push_modal_hint(
4836 &mut lines,
4837 HintContext::LinkInputNumber,
4838 &app.keymap,
4839 &app.modal_keymap,
4840 &app.theme,
4841 );
4842 lines
4843 }
4844 };
4845 let height = lines.len() as u16 + 2 + 2 ;
4846 let term = f.area();
4847 let width = link_prompt_modal_width(term.width);
4848 let area = centered_abs(width, height, term);
4849 f.render_widget(Clear, area);
4850 f.render_widget(Paragraph::new(lines).block(overlay_block(accent)), area);
4851}
4852
4853pub fn reclaim_size_color(bytes: u64, theme: &Theme) -> Color {
4857 const MIB: u64 = 1024 * 1024;
4858 if bytes >= 500 * MIB {
4859 theme.prunable
4860 } else if bytes >= 50 * MIB {
4861 theme.dirty
4862 } else {
4863 theme.clean
4864 }
4865}
4866
4867pub fn clean_dir_icon(rel: &str) -> &'static str {
4873 match rel.trim_start_matches('.').to_ascii_lowercase().as_str() {
4874 "node_modules" => "\u{e718}", "target" => wt_tree::WT_RUST_ICON, "vendor" => "\u{e73d}", "venv" | "__pycache__" | "pytest_cache" | "mypy_cache" | "tox" => "\u{e73c}", "dist" | "build" | "out" | "output" | "bin" => "\u{f487}", "cache" | "turbo" | "parcel-cache" => "\u{f187}", "nuxt" | "next" | "svelte-kit" | "astro" | "vite" => "\u{e74e}", "coverage" => "\u{f201}", _ => wt_tree::WT_DIR_ICON, }
4884}
4885
4886pub fn picker_window(len: usize, selected: usize, max_visible: usize) -> (usize, usize) {
4890 if max_visible == 0 || len <= max_visible {
4891 return (0, len);
4892 }
4893 let half = max_visible / 2;
4894 let start = selected.saturating_sub(half).min(len - max_visible);
4895 (start, start + max_visible)
4896}
4897
4898fn picker_lines(
4904 labels: &[&str],
4905 selected: usize,
4906 max_visible: usize,
4907 inner: usize,
4908 theme: &Theme,
4909) -> Vec<Line<'static>> {
4910 let mut out = Vec::new();
4911 if labels.is_empty() {
4912 return out;
4913 }
4914 let textw = inner.saturating_sub(3);
4916 let (start, end) = picker_window(labels.len(), selected, max_visible);
4917 if start > 0 {
4918 out.push(
4919 Line::from(Span::styled(
4920 format!("↑ {start} more"),
4921 Style::default().fg(theme.muted),
4922 ))
4923 .centered(),
4924 );
4925 }
4926 for (i, label) in labels.iter().enumerate().take(end).skip(start) {
4927 let marker = if i == selected { "▸" } else { " " };
4928 let txt = format!(" {marker} {:<textw$}", ellipsize_middle(label, textw));
4930 let style = if i == selected {
4931 Style::default()
4932 .fg(theme.accent)
4933 .bg(theme.selection_bg)
4934 .add_modifier(Modifier::BOLD)
4935 } else {
4936 Style::default().fg(theme.muted)
4937 };
4938 out.push(Line::from(Span::styled(txt, style)));
4939 }
4940 if end < labels.len() {
4941 out.push(
4942 Line::from(Span::styled(
4943 format!("↓ {} more", labels.len() - end),
4944 Style::default().fg(theme.muted),
4945 ))
4946 .centered(),
4947 );
4948 }
4949 out
4950}
4951
4952fn draw_exec_picker(f: &mut Frame, app: &App) {
4958 let accent = app.theme.accent;
4959 let term = f.area();
4960 let width = overlay_modal_width(term.width);
4961 let inner = width.saturating_sub(6) as usize; let mut lines = overlay_title_lines("Run an exec profile", accent);
4963 let max_visible = (term.height as usize).saturating_sub(8).max(3);
4965 let labels: Vec<&str> = app.exec_picker.profiles().iter().map(String::as_str).collect();
4966 lines.extend(picker_lines(
4967 &labels,
4968 app.exec_picker.selected_index(),
4969 max_visible,
4970 inner,
4971 &app.theme,
4972 ));
4973 push_modal_hint(
4974 &mut lines,
4975 HintContext::ExecPicker,
4976 &app.keymap,
4977 &app.modal_keymap,
4978 &app.theme,
4979 );
4980 let height = lines.len() as u16 + 2 + 2 ;
4981 let area = centered_abs(width, height, term);
4982 f.render_widget(Clear, area);
4983 f.render_widget(Paragraph::new(lines).block(overlay_block(accent)), area);
4984}
4985
4986fn draw_detail_overlay(f: &mut Frame, app: &App) {
4992 use crate::tui::state::detail_overlay::{DetailMode, DetailRole};
4993 let accent = app.theme.accent;
4994 let term = f.area();
4995 let width = overlay_modal_width(term.width);
4996 let inner = width.saturating_sub(6) as usize; let ov = &app.detail_overlay;
4998
4999 if ov.mode == DetailMode::Input && ov.kind == crate::tui::state::detail_overlay::DetailKind::CiChecks {
5004 let matches = app.ci_input_matches();
5005 let list_h = (term.height as usize).saturating_sub(12).clamp(3, 10);
5006 let (start, end) = picker_window(matches.len(), ov.input_selected, list_h);
5007
5008 let mut lines = overlay_title_lines("Filter CI checks", accent);
5009 lines.push(Line::from(vec![
5010 Span::styled("filter: ", Style::default().fg(app.theme.muted)),
5011 Span::styled(
5012 ov.input.clone(),
5013 Style::default().fg(app.theme.name).add_modifier(Modifier::BOLD),
5014 ),
5015 Span::styled("▏", Style::default().fg(accent)),
5016 ]));
5017 lines.push(Line::from(String::new()));
5018 if matches.is_empty() {
5019 lines.push(Line::from(Span::styled(
5020 "no matching check",
5021 Style::default().fg(app.theme.muted),
5022 )));
5023 }
5024 for (i, row_idx) in matches.iter().enumerate().take(end).skip(start) {
5025 let Some(row) = ov.rows.get(*row_idx) else { continue };
5026 let label_color = match row.role {
5027 DetailRole::Success => app.theme.clean,
5028 DetailRole::Failure => app.theme.prunable,
5029 DetailRole::Running => app.theme.dirty,
5030 _ => app.theme.name,
5031 };
5032 let text = format!("{} {}", row.label, row.value);
5033 let pad = inner.saturating_sub(text.chars().count());
5034 let mut style = Style::default().fg(label_color);
5035 if i == ov.input_selected {
5036 style = style.bg(app.theme.selection_bg).add_modifier(Modifier::BOLD);
5037 }
5038 lines.push(Line::from(Span::styled(format!("{}{}", text, " ".repeat(pad)), style)));
5039 }
5040 for _ in matches.len().min(end).saturating_sub(start)..list_h {
5041 lines.push(Line::from(String::new()));
5042 }
5043 let height = (2 + list_h + 2) as u16 + 2 + 2 ;
5044 let area = centered_abs(width, height, term);
5045 f.render_widget(Clear, area);
5046 f.render_widget(Paragraph::new(lines).block(overlay_block(accent)), area);
5047 let list_rect = Rect {
5052 x: area.x + 1,
5053 y: area.y + 6,
5054 width: area.width.saturating_sub(2),
5055 height: list_h as u16,
5056 }
5057 .intersection(area);
5058 if list_rect.height > 0 {
5059 let _ = scrollable_body_area(f, list_rect, start as u16, matches.len(), &app.theme);
5060 }
5061 return;
5062 }
5063
5064 if ov.mode == DetailMode::Input {
5067 let candidates = app.agent_input_candidates();
5068 let list_h = (term.height as usize).saturating_sub(12).clamp(3, 10);
5074 let (start, end) = picker_window(candidates.len(), ov.input_selected, list_h);
5075 let now = std::time::SystemTime::now();
5076
5077 let mut lines = overlay_title_lines("Attach a session", accent);
5078 lines.push(Line::from(vec![
5079 Span::styled("id: ", Style::default().fg(app.theme.muted)),
5080 Span::styled(
5081 ov.input.clone(),
5082 Style::default().fg(app.theme.name).add_modifier(Modifier::BOLD),
5083 ),
5084 Span::styled("▏", Style::default().fg(accent)),
5085 ]));
5086 lines.push(Line::from(String::new()));
5087 if candidates.is_empty() {
5088 lines.push(Line::from(Span::styled(
5089 "no matching session",
5090 Style::default().fg(app.theme.muted),
5091 )));
5092 }
5093 for (i, sess) in candidates.iter().enumerate().take(end).skip(start) {
5094 let freshness = crate::agent_sessions::Freshness::classify(sess.last_activity, sess.ended, now);
5095 let color = match freshness {
5096 crate::agent_sessions::Freshness::Active => app.theme.clean,
5097 crate::agent_sessions::Freshness::Idle => app.theme.muted,
5098 };
5099 let identity = sess.name.as_deref().unwrap_or(&sess.id);
5100 let text = format!("{:<9} {}", sess.kind.display(), identity);
5101 let pad = inner.saturating_sub(text.chars().count());
5102 let mut style = Style::default().fg(color);
5103 let mut pad_style = Style::default();
5104 if i == ov.input_selected {
5105 style = style.bg(app.theme.selection_bg).add_modifier(Modifier::BOLD);
5106 pad_style = pad_style.bg(app.theme.selection_bg);
5107 }
5108 lines.push(Line::from(vec![
5109 Span::styled(text, style),
5110 Span::styled(" ".repeat(pad), pad_style),
5111 ]));
5112 }
5113 let shown = if candidates.is_empty() { 1 } else { end - start };
5116 for _ in shown..list_h {
5117 lines.push(Line::from(String::new()));
5118 }
5119 lines.push(Line::from(String::new()));
5120 lines.push(modal_hint_line(
5121 &[
5122 ("type", "filter"),
5123 ("↑/↓", "pick"),
5124 ("Enter", "attach"),
5125 ("Esc", "back"),
5126 ],
5127 &app.theme,
5128 ));
5129 let height = lines.len() as u16 + 2 + 2 ;
5130 let area = centered_abs(width, height, term);
5131 f.render_widget(Clear, area);
5132 f.render_widget(Paragraph::new(lines).block(overlay_block(accent)), area);
5133 let list_rect = Rect {
5139 x: area.x + 1,
5140 y: area.y + 2 + 2 + 2, width: area.width.saturating_sub(2),
5142 height: list_h as u16,
5143 }
5144 .intersection(area);
5145 if list_rect.height > 0 {
5146 let _ = scrollable_body_area(f, list_rect, start as u16, candidates.len(), &app.theme);
5147 }
5148 return;
5149 }
5150 let total = ov.rows.len();
5151 let max_visible = (term.height as usize).saturating_sub(10).max(3);
5155 let visible = total.min(max_visible);
5156 let (start, end) = picker_window(total, ov.selected, visible);
5157
5158 let label_w = ov.rows.iter().map(|r| r.label.chars().count()).max().unwrap_or(0);
5159 let mut lines = overlay_title_lines(&ov.title, accent);
5160 for (i, row) in ov.rows.iter().enumerate().take(end).skip(start) {
5161 let (label_color, value_color, value_bold) = match row.role {
5162 DetailRole::Active => (app.theme.clean, app.theme.clean, true),
5163 DetailRole::Muted => (app.theme.muted, app.theme.muted, false),
5164 DetailRole::Normal => (app.theme.name, app.theme.name, false),
5165 DetailRole::Success => (app.theme.clean, app.theme.name, false),
5167 DetailRole::Failure => (app.theme.prunable, app.theme.name, false),
5168 DetailRole::Running => (app.theme.dirty, app.theme.name, false),
5169 };
5170 let mut extra: String = row.extra.as_deref().unwrap_or("").to_string();
5177 let mut extra_cols = extra.chars().count();
5178 if extra_cols > 0 {
5185 let reserve = row.value.chars().count().min(12);
5186 let extra_budget = inner.saturating_sub(label_w + 2 + reserve + 2);
5187 if extra_cols > extra_budget {
5188 if extra_budget == 0 {
5189 extra.clear();
5190 } else {
5191 let tail: String = extra.chars().skip(extra_cols - (extra_budget - 1)).collect();
5192 extra = format!("…{tail}");
5193 }
5194 extra_cols = extra.chars().count();
5195 }
5196 }
5197 let value_budget = inner.saturating_sub(label_w + 2 + if extra_cols > 0 { extra_cols + 2 } else { 0 });
5198 let value: String = if row.value.chars().count() > value_budget {
5199 let mut v: String = row.value.chars().take(value_budget.saturating_sub(1)).collect();
5200 v.push('…');
5201 v
5202 } else {
5203 row.value.clone()
5204 };
5205 let text_cols = label_w + 2 + value.chars().count();
5206 let pad = inner.saturating_sub(text_cols + extra_cols);
5207 let mut label_style = Style::default().fg(label_color);
5208 let mut value_style = Style::default().fg(value_color);
5209 if value_bold {
5210 value_style = value_style.add_modifier(Modifier::BOLD);
5211 }
5212 let mut pad_style = Style::default();
5213 let mut extra_style = Style::default().fg(app.theme.muted);
5214 if i == ov.selected {
5215 label_style = label_style.bg(app.theme.selection_bg).add_modifier(Modifier::BOLD);
5216 value_style = value_style.bg(app.theme.selection_bg);
5217 pad_style = pad_style.bg(app.theme.selection_bg);
5218 extra_style = extra_style.bg(app.theme.selection_bg);
5219 }
5220 lines.push(Line::from(vec![
5221 Span::styled(format!("{:label_w$} ", row.label), label_style),
5222 Span::styled(value, value_style),
5223 Span::styled(" ".repeat(pad), pad_style),
5224 Span::styled(extra, extra_style),
5225 ]));
5226 }
5227 let hint_ctx = if ov.kind == crate::tui::state::detail_overlay::DetailKind::CiChecks {
5230 HintContext::CiChecks
5231 } else {
5232 HintContext::Detail
5233 };
5234 push_modal_hint(&mut lines, hint_ctx, &app.keymap, &app.modal_keymap, &app.theme);
5235 let height = (2 + visible + 2) as u16 + 2 + 2 ;
5236 let area = centered_abs(width, height, term);
5237 f.render_widget(Clear, area);
5238 f.render_widget(Paragraph::new(lines).block(overlay_block(accent)), area);
5239 let rows_rect = Rect {
5244 x: area.x + 1,
5245 y: area.y + 2 + 2, width: area.width.saturating_sub(2),
5247 height: visible as u16,
5248 }
5249 .intersection(area);
5250 if rows_rect.height > 0 {
5251 let _ = scrollable_body_area(f, rows_rect, start as u16, total, &app.theme);
5252 }
5253}
5254
5255fn draw_clean_overlay(f: &mut Frame, app: &App) {
5262 let accent = app.theme.accent;
5263 let muted = app.theme.muted;
5264 let danger = app.theme.prunable;
5265 let armed = app.clean_overlay.confirm.is_armed();
5266 let border = if armed { danger } else { accent };
5267 let term = f.area();
5268 let width = overlay_modal_width(term.width);
5269 let inner = width.saturating_sub(6) as usize; let mut lines = overlay_title_lines("Reclaim build artifacts", border);
5272
5273 if app.clean_overlay.has_profiles() {
5276 let labels = app.clean_overlay.choice_labels();
5277 let max_visible = (term.height as usize).saturating_sub(14).max(3);
5278 lines.extend(picker_lines(
5279 &labels,
5280 app.clean_overlay.selected_index(),
5281 max_visible,
5282 inner,
5283 &app.theme,
5284 ));
5285 lines.push(Line::from(""));
5286 }
5287
5288 match app.clean_overlay.reclaim() {
5294 Some(reclaim) if !reclaim.artifacts.is_empty() => {
5295 let namew = inner.saturating_sub(15).max(5);
5298 let row = |icon: &str, left: &str, left_style: Style, bytes: u64, size_style: Style| -> Line<'static> {
5299 Line::from(vec![
5300 Span::styled(format!(" {icon} "), Style::default().fg(accent)),
5301 Span::styled(format!("{:<namew$}", ellipsize_middle(left, namew)), left_style),
5302 Span::styled(format!("{:>10} ", crate::clean::human_size(bytes)), size_style),
5303 ])
5304 };
5305 let max_rows = (term.height as usize).saturating_sub(14).max(3);
5306 let shown = reclaim.artifacts.len().min(max_rows);
5307 for a in reclaim.artifacts.iter().take(shown) {
5308 lines.push(row(
5309 clean_dir_icon(&a.rel),
5310 &a.rel,
5311 Style::default().fg(muted),
5312 a.bytes,
5313 Style::default().fg(reclaim_size_color(a.bytes, &app.theme)),
5314 ));
5315 }
5316 if reclaim.artifacts.len() > shown {
5317 lines.push(
5318 Line::from(Span::styled(
5319 format!("… {} more", reclaim.artifacts.len() - shown),
5320 Style::default().fg(muted),
5321 ))
5322 .centered(),
5323 );
5324 }
5325 lines.push(row(
5327 "\u{f03a}",
5328 "total",
5329 Style::default().fg(accent).add_modifier(Modifier::BOLD),
5330 reclaim.total_bytes,
5331 Style::default()
5332 .fg(reclaim_size_color(reclaim.total_bytes, &app.theme))
5333 .add_modifier(Modifier::BOLD),
5334 ));
5335 }
5336 _ => {
5337 lines.push(
5338 Line::from("nothing to reclaim")
5339 .style(Style::default().fg(muted))
5340 .centered(),
5341 );
5342 }
5343 }
5344
5345 for rel in app.clean_overlay.skipped() {
5347 lines.push(
5348 Line::from(format!("skipped {rel} — not git-ignored / holds tracked files"))
5349 .style(Style::default().fg(muted))
5350 .centered(),
5351 );
5352 }
5353
5354 if armed {
5357 lines.push(Line::from(""));
5358 lines.push(
5359 Line::from("⚠ armed — confirm again or cancel to abort")
5360 .style(Style::default().fg(danger).add_modifier(Modifier::BOLD))
5361 .centered(),
5362 );
5363 }
5364
5365 push_modal_hint(
5366 &mut lines,
5367 HintContext::Clean,
5368 &app.keymap,
5369 &app.modal_keymap,
5370 &app.theme,
5371 );
5372 let height = lines.len() as u16 + 2 + 2 ;
5373 let area = centered_abs(width, height, term);
5374 f.render_widget(Clear, area);
5375 f.render_widget(Paragraph::new(lines).block(overlay_block(border)), area);
5376}
5377
5378fn draw_edit_worktree(f: &mut Frame, app: &App) {
5392 let accent = app.theme.accent;
5393 let muted = app.theme.muted;
5394 let clean = app.theme.clean;
5395 let surface = app.theme.selection_bg;
5396
5397 let (type_str, type_desc) = app
5398 .branch_types
5399 .get(app.create_form.type_index)
5400 .map(|t| (t.name.as_str(), t.description.as_str()))
5401 .unwrap_or(("", "(no branch types configured)"));
5402
5403 let block = overlay_block(clean);
5404 let term = f.area();
5405 let outer = centered_box(70, 72, 1, term);
5406 let inner_w = block.inner(outer).width as usize;
5407 let label_w = 5usize;
5408 let gutter = 2 + label_w + 2;
5409 let value_w = inner_w.saturating_sub(gutter);
5410
5411 let label = |s: &str| format!("{:<label_w$}", s);
5412 let old_branch = app
5413 .edit_original_branch
5414 .as_deref()
5415 .or_else(|| app.selected().and_then(|w| w.branch.as_deref()))
5416 .unwrap_or("(none)");
5417 let old_display = ellipsize_middle(old_branch, inner_w.saturating_sub(" From : ".len()));
5418 let (branch_raw, dir_raw) = pattern_preview(app, type_str);
5419 let branch = ellipsize_middle(&branch_raw, inner_w.saturating_sub(" Branch : ".len()));
5420 let dirname = ellipsize_middle(&dir_raw, inner_w.saturating_sub(" Dir : ".len()));
5421
5422 let freeform = app.create_form.mode == Mode::Freeform;
5423 let mut lines = overlay_title_lines("Rename Worktree", clean);
5424 lines.push(Line::from(vec![
5425 Span::raw(" From : "),
5426 Span::styled(old_display, Style::default().fg(muted)),
5427 ]));
5428 lines.push(Line::from(String::new()));
5429 lines.push(Line::from(vec![
5430 Span::raw(" Branch : "),
5431 Span::styled(branch, Style::default().fg(app.theme.branch)),
5432 ]));
5433 lines.push(Line::from(vec![
5434 Span::raw(" Dir : "),
5435 Span::styled(dirname, Style::default().fg(app.theme.dirty)),
5436 ]));
5437 if let Some(warning) = rename_pr_warning(app, &branch_raw, old_branch) {
5438 lines.push(Line::from(vec![
5439 Span::raw(" "),
5440 Span::styled(
5441 ellipsize_middle(&warning, inner_w.saturating_sub(2)),
5442 Style::default().fg(app.theme.prunable),
5443 ),
5444 ]));
5445 }
5446 lines.push(Line::from(String::new()));
5447 if freeform {
5453 lines.push(field_input_line(
5454 &label("Name"),
5455 &app.create_form.name,
5456 app.create_form.field == Field::Name,
5457 value_w,
5458 accent,
5459 muted,
5460 surface,
5461 ));
5462 } else {
5463 lines.extend(form_field_lines(app, type_str, type_desc, value_w, label_w));
5464 }
5465
5466 let height = lines.len() as u16 + 4 + 2 + 2 ;
5467 let area = centered_box(70, 72, height, term);
5468 let inner = Layout::default()
5469 .direction(Direction::Vertical)
5470 .constraints([
5471 Constraint::Min(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), ])
5477 .split(block.inner(area));
5478
5479 f.render_widget(Clear, area);
5480 f.render_widget(block, area);
5481 f.render_widget(Paragraph::new(lines), inner[0]);
5482
5483 if app.is_edit_worktree_loading() {
5484 f.render_widget(
5485 LoaderWidget::running(
5486 app.spinner.glyph(DOT_FRAMES),
5487 TaskKind::EditWorktree.loading_label(),
5488 None,
5489 &app.theme,
5490 )
5491 .alignment(Alignment::Center),
5492 inner[1],
5493 );
5494 } else if let Some(error) = app.edit_failure.as_deref() {
5495 f.render_widget(
5496 LoaderWidget::failed("rename failed", Some(error), &app.theme).alignment(Alignment::Center),
5497 inner[1],
5498 );
5499 }
5500
5501 if !app.is_edit_worktree_loading() {
5502 f.render_widget(
5503 Paragraph::new(rename_buttons_line(accent, muted)).alignment(Alignment::Center),
5504 inner[2],
5505 );
5506 f.render_widget(
5507 Paragraph::new(modal_hint_for_context_with_fields(
5508 app.rename_hint_context(),
5511 &app.keymap,
5512 &app.modal_keymap,
5513 &app.theme,
5514 app.create_form.fields(),
5515 )),
5516 inner[4],
5517 );
5518 }
5519}
5520
5521fn draw_command_palette(f: &mut Frame, app: &App) {
5522 let area = centered(60, 50, f.area());
5523 f.render_widget(Clear, area);
5524
5525 let accent = app.theme.accent;
5526 let outer = overlay_block(accent);
5527 let inner = outer.inner(area);
5528 f.render_widget(outer, area);
5529
5530 let layout = Layout::default()
5536 .direction(Direction::Vertical)
5537 .constraints([
5538 Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Min(3), Constraint::Length(1), Constraint::Length(1), ])
5546 .split(inner);
5547
5548 f.render_widget(
5549 Paragraph::new(
5550 Line::from(Span::styled(
5551 "Command Palette",
5552 Style::default().fg(accent).add_modifier(Modifier::BOLD),
5553 ))
5554 .centered(),
5555 ),
5556 layout[0],
5557 );
5558
5559 let label = ":";
5563 let gutter = 2 + label.chars().count() + 2; let value_w = (inner.width as usize).saturating_sub(gutter);
5565 f.render_widget(
5566 Paragraph::new(field_input_line(
5567 label,
5568 app.palette.buffer(),
5569 true,
5570 value_w,
5571 accent,
5572 app.theme.muted,
5573 app.theme.selection_bg,
5574 )),
5575 layout[2],
5576 );
5577
5578 let entries = app.palette.matches();
5579 let highlight = app.palette.highlight();
5580 let mut lines: Vec<Line<'_>> = entries
5581 .iter()
5582 .enumerate()
5583 .map(|(i, entry)| {
5584 let prefix = if i == highlight { "▶ " } else { " " };
5585 let name_style = if i == highlight {
5586 Style::default().fg(accent).add_modifier(Modifier::BOLD)
5587 } else {
5588 palette_name_style(&app.theme)
5589 };
5590 Line::from(vec![
5591 Span::raw(prefix),
5592 Span::styled(format!("{:<22}", entry.name), name_style),
5593 Span::raw(" "),
5594 Span::styled(entry.description, Style::default().fg(app.theme.muted)),
5595 ])
5596 })
5597 .collect();
5598 if lines.is_empty() {
5599 lines.push(Line::from(Span::styled(
5600 " (no matching command — backspace to broaden)",
5601 Style::default().fg(app.theme.prunable),
5602 )));
5603 }
5604 f.render_widget(Paragraph::new(lines), layout[4]);
5605 f.render_widget(
5606 Paragraph::new(modal_hint_for_context(
5607 HintContext::CommandPalette,
5608 &app.keymap,
5609 &app.modal_keymap,
5610 &app.theme,
5611 )),
5612 layout[6],
5613 );
5614}
5615
5616pub fn github_status_lines(app: &App, max_width: usize) -> Vec<Line<'static>> {
5623 let link = app.current_link();
5624 let mut lines: Vec<Line<'static>> = Vec::new();
5625
5626 if link.issue.is_none() && link.pr.is_none() {
5627 let chord = action_chord(&app.keymap, Action::LinkPrompt, "i");
5631 lines.push(Line::from(Span::styled(
5632 trunc(&format!("no link · press {chord} to link"), max_width),
5633 Style::default().fg(app.theme.muted),
5634 )));
5635 return lines;
5636 }
5637
5638 if let Some(n) = link.issue {
5639 let spinner = app.spinner.glyph(DOT_FRAMES);
5640 lines.push(issue_summary_line_with_spinner(
5641 n,
5642 link.issue_source,
5643 app.issue_fetch_state(),
5644 PersistedSummary {
5645 title: link.issue_title.as_deref(),
5646 state: link.issue_state,
5647 },
5648 max_width,
5649 &app.theme,
5650 Some(spinner),
5651 ));
5652 }
5653 if let Some(n) = link.pr {
5654 let spinner = app.spinner.glyph(DOT_FRAMES);
5655 let ci_key = if app.picker_mode {
5666 None
5667 } else if app.sidebar.open && app.sidebar.focused {
5668 app
5669 .keymap
5670 .primary_chord(Action::EditWorktree)
5671 .or_else(|| app.keymap.primary_chord(Action::CiChecks))
5672 } else {
5673 app.keymap.primary_chord(Action::CiChecks)
5674 };
5675 lines.push(pr_summary_line_with_spinner(
5676 n,
5677 link.pr_source,
5678 app.pr_fetch_state(),
5679 PersistedSummary {
5680 title: link.pr_title.as_deref(),
5681 state: link.pr_state,
5682 },
5683 max_width,
5684 &app.theme,
5685 Some(spinner),
5686 ci_key.as_deref(),
5687 ));
5688 }
5689 lines
5690}
5691
5692pub const ISSUE_ICON: &str = "\u{f41b}";
5695pub const PR_ICON: &str = "\u{f407}";
5698
5699pub const CI_PASSING_ICON: &str = "\u{f42e}";
5702pub const CI_FAILING_ICON: &str = "\u{f467}";
5703pub const CI_RUNNING_ICON: &str = "\u{f46a}";
5704pub const CI_UNKNOWN_ICON: &str = "\u{f059}";
5708
5709fn source_chip(s: LinkSource, theme: &Theme) -> Option<(&'static str, Color)> {
5715 match s {
5716 LinkSource::BranchName => Some(("auto", theme.muted)),
5717 LinkSource::Detected => Some(("detected", theme.accent)),
5718 LinkSource::Explicit | LinkSource::None => None,
5719 }
5720}
5721
5722fn flatten_if_overflow(spans: &mut Vec<Span<'static>>, max_width: usize) {
5728 let w: usize = spans.iter().map(|s| s.content.chars().count()).sum();
5729 if w > max_width {
5730 let raw: String = spans.iter().map(|s| s.content.as_ref()).collect();
5731 *spans = vec![Span::raw(trunc(&raw, max_width))];
5732 }
5733}
5734
5735pub fn issue_summary_line(
5741 n: u64,
5742 src: LinkSource,
5743 state: &GitHubFetchState<crate::github::IssueStatus>,
5744 max_width: usize,
5745 theme: &Theme,
5746) -> Line<'static> {
5747 issue_summary_line_with_spinner(n, src, state, PersistedSummary::none(), max_width, theme, None)
5748}
5749
5750#[derive(Clone, Copy)]
5751struct PersistedSummary<'a, S> {
5752 title: Option<&'a str>,
5753 state: Option<S>,
5754}
5755
5756impl<S> PersistedSummary<'_, S> {
5757 fn none() -> Self {
5758 Self {
5759 title: None,
5760 state: None,
5761 }
5762 }
5763}
5764
5765enum SummaryState<'a> {
5771 Idle,
5772 CachedTitle {
5773 title: &'a str,
5774 },
5775 CachedStatus {
5776 badge: &'a str,
5777 badge_color: Color,
5778 trailing: String,
5779 trailing_color: Option<Color>,
5782 title: &'a str,
5783 },
5784 Loading,
5785 Loaded {
5786 badge: &'a str,
5787 badge_color: Color,
5788 trailing: String,
5789 trailing_color: Option<Color>,
5791 title: &'a str,
5792 },
5793 Error(&'a str),
5794}
5795
5796fn trailing_span(trailing: String, color: Option<Color>) -> Span<'static> {
5811 match color {
5812 Some(c) => Span::styled(trailing, Style::default().fg(c)),
5813 None => Span::raw(trailing),
5814 }
5815}
5816
5817fn summary_line(
5818 icon: &str,
5819 head: String,
5820 source: LinkSource,
5821 state: SummaryState,
5822 max_width: usize,
5823 theme: &Theme,
5824 spinner: Option<&str>,
5825) -> Line<'static> {
5826 let icon_seg = format!("{} ", icon); let chip = source_chip(source, theme);
5831 let source_seg_w = chip.map(|(l, _)| 1 + l.chars().count() + 2).unwrap_or(0);
5833 let prefix_w = icon_seg.chars().count() + head.chars().count() + source_seg_w;
5834
5835 let icon_color = match &state {
5839 SummaryState::CachedStatus { badge_color, .. } | SummaryState::Loaded { badge_color, .. } => *badge_color,
5840 SummaryState::Idle | SummaryState::CachedTitle { .. } | SummaryState::Loading | SummaryState::Error(_) => {
5841 theme.muted
5842 }
5843 };
5844 let build_prefix = |head_bold: bool| -> Vec<Span<'static>> {
5845 let head_style = if head_bold {
5846 Style::default().fg(theme.name).add_modifier(Modifier::BOLD)
5847 } else {
5848 Style::default().fg(theme.name)
5849 };
5850 let mut spans = vec![
5851 Span::styled(icon_seg.clone(), Style::default().fg(icon_color)),
5852 Span::styled(head.clone(), head_style),
5853 ];
5854 if let Some((label, color)) = chip {
5855 spans.push(Span::raw(" "));
5856 spans.push(Span::styled(format!(" {} ", label), chip_style(color)));
5857 }
5858 spans
5859 };
5860
5861 match state {
5862 SummaryState::Idle => {
5863 let mut spans = build_prefix(false);
5864 flatten_if_overflow(&mut spans, max_width);
5865 Line::from(spans)
5866 }
5867 SummaryState::CachedTitle { title } => {
5868 let fixed = prefix_w + 1;
5869 let budget = max_width.saturating_sub(fixed);
5870 let mut spans = build_prefix(false);
5871 spans.push(Span::raw(" "));
5872 spans.push(Span::raw(trunc(title, budget)));
5873 flatten_if_overflow(&mut spans, max_width);
5874 Line::from(spans)
5875 }
5876 SummaryState::CachedStatus {
5877 badge,
5878 badge_color,
5879 trailing,
5880 trailing_color,
5881 title,
5882 } => {
5883 let badge_seg_w = 1 + badge.chars().count() + 2;
5884 let fixed = prefix_w + badge_seg_w + trailing.chars().count() + 1;
5885 if fixed >= max_width {
5886 let mut spans = build_prefix(true);
5887 spans.push(Span::raw(" "));
5888 spans.push(Span::raw(format!(" {} ", badge)));
5889 spans.push(trailing_span(trailing, trailing_color));
5890 flatten_if_overflow(&mut spans, max_width);
5891 return Line::from(spans);
5892 }
5893 let budget = max_width - fixed;
5894 let mut spans = build_prefix(true);
5895 spans.push(Span::raw(" "));
5896 spans.push(Span::styled(format!(" {} ", badge), chip_style(badge_color)));
5897 spans.push(trailing_span(trailing, trailing_color));
5898 spans.push(Span::raw(" "));
5899 spans.push(Span::raw(trunc(title, budget)));
5900 Line::from(spans)
5901 }
5902 SummaryState::Loading => {
5903 let glyph = spinner.unwrap_or("…");
5904 let mut spans = build_prefix(false);
5905 spans.push(Span::raw(format!(" {} loading", glyph)));
5906 flatten_if_overflow(&mut spans, max_width);
5907 Line::from(spans)
5908 }
5909 SummaryState::Loaded {
5910 badge,
5911 badge_color,
5912 trailing,
5913 trailing_color,
5914 title,
5915 } => {
5916 let badge_seg_w = 1 + badge.chars().count() + 2;
5918 let fixed = prefix_w + badge_seg_w + trailing.chars().count() + 1;
5919 if fixed >= max_width {
5920 let mut spans = build_prefix(true);
5923 spans.push(Span::raw(" "));
5924 spans.push(Span::raw(format!(" {} ", badge)));
5925 spans.push(trailing_span(trailing, trailing_color));
5926 flatten_if_overflow(&mut spans, max_width);
5927 return Line::from(spans);
5928 }
5929 let budget = max_width - fixed;
5930 let mut spans = build_prefix(true);
5931 spans.push(Span::raw(" "));
5932 spans.push(Span::styled(format!(" {} ", badge), chip_style(badge_color)));
5933 spans.push(trailing_span(trailing, trailing_color));
5934 spans.push(Span::raw(" "));
5935 spans.push(Span::raw(trunc(title, budget)));
5936 Line::from(spans)
5937 }
5938 SummaryState::Error(e) => {
5939 let fixed = prefix_w + 2; let budget = max_width.saturating_sub(fixed);
5941 let mut spans = build_prefix(false);
5942 spans.push(Span::raw(" "));
5943 spans.push(Span::styled(
5944 format!("!{}", trunc(e, budget)),
5945 Style::default().fg(theme.prunable),
5946 ));
5947 flatten_if_overflow(&mut spans, max_width);
5948 Line::from(spans)
5949 }
5950 }
5951}
5952
5953fn issue_summary_line_with_spinner(
5954 n: u64,
5955 src: LinkSource,
5956 state: &GitHubFetchState<crate::github::IssueStatus>,
5957 persisted: PersistedSummary<'_, IssueState>,
5958 max_width: usize,
5959 theme: &Theme,
5960 spinner: Option<&str>,
5961) -> Line<'static> {
5962 let head = format!("Issue #{}", n);
5963 let resolved = match state {
5964 GitHubFetchState::Idle => match persisted.state {
5965 Some(state) => {
5966 let badge = match state {
5967 IssueState::Open => "open",
5968 IssueState::Closed => "closed",
5969 };
5970 SummaryState::CachedStatus {
5971 badge,
5972 badge_color: issue_badge_color(state, theme),
5973 trailing: String::new(),
5974 trailing_color: None,
5975 title: persisted.title.unwrap_or(""),
5976 }
5977 }
5978 None => persisted
5979 .title
5980 .map(|title| SummaryState::CachedTitle { title })
5981 .unwrap_or(SummaryState::Idle),
5982 },
5983 GitHubFetchState::Loading => match persisted.state {
5984 Some(state) => {
5985 let badge = match state {
5986 IssueState::Open => "open",
5987 IssueState::Closed => "closed",
5988 };
5989 SummaryState::CachedStatus {
5990 badge,
5991 badge_color: issue_badge_color(state, theme),
5992 trailing: format!(" · {} loading", spinner.unwrap_or("…")),
5993 trailing_color: None,
5994 title: persisted.title.unwrap_or(""),
5995 }
5996 }
5997 None => SummaryState::Loading,
5998 },
5999 GitHubFetchState::Loaded(s) => {
6000 let badge = match s.state {
6006 IssueState::Open => "open",
6007 IssueState::Closed => "closed",
6008 };
6009 SummaryState::Loaded {
6010 badge,
6011 badge_color: issue_badge_color(s.state, theme),
6012 trailing: String::new(),
6013 trailing_color: None,
6014 title: &s.title,
6015 }
6016 }
6017 GitHubFetchState::Error(e) => SummaryState::Error(e),
6018 };
6019 summary_line(ISSUE_ICON, head, src, resolved, max_width, theme, spinner)
6020}
6021
6022pub fn pr_summary_line(
6028 n: u64,
6029 src: LinkSource,
6030 state: &GitHubFetchState<crate::github::PrStatus>,
6031 max_width: usize,
6032 theme: &Theme,
6033 ci_hint: Option<&str>,
6034) -> Line<'static> {
6035 pr_summary_line_with_spinner(n, src, state, PersistedSummary::none(), max_width, theme, None, ci_hint)
6036}
6037
6038#[allow(clippy::too_many_arguments)] fn pr_summary_line_with_spinner(
6040 n: u64,
6041 src: LinkSource,
6042 state: &GitHubFetchState<crate::github::PrStatus>,
6043 persisted: PersistedSummary<'_, PrState>,
6044 max_width: usize,
6045 theme: &Theme,
6046 spinner: Option<&str>,
6047 ci_hint: Option<&str>,
6048) -> Line<'static> {
6049 let head = format!("PR #{}", n);
6050 let resolved = match state {
6051 GitHubFetchState::Idle => match persisted.state {
6052 Some(state) => {
6053 let badge = match state {
6054 PrState::Open => "open",
6055 PrState::Draft => "draft",
6056 PrState::Closed => "closed",
6057 PrState::Merged => "merged",
6058 };
6059 SummaryState::CachedStatus {
6060 badge,
6061 badge_color: pr_badge_color(state, theme),
6062 trailing: String::new(),
6063 trailing_color: None,
6064 title: persisted.title.unwrap_or(""),
6065 }
6066 }
6067 None => persisted
6068 .title
6069 .map(|title| SummaryState::CachedTitle { title })
6070 .unwrap_or(SummaryState::Idle),
6071 },
6072 GitHubFetchState::Loading => match persisted.state {
6073 Some(state) => {
6074 let badge = match state {
6075 PrState::Open => "open",
6076 PrState::Draft => "draft",
6077 PrState::Closed => "closed",
6078 PrState::Merged => "merged",
6079 };
6080 SummaryState::CachedStatus {
6081 badge,
6082 badge_color: pr_badge_color(state, theme),
6083 trailing: format!(" · {} loading", spinner.unwrap_or("…")),
6084 trailing_color: None,
6085 title: persisted.title.unwrap_or(""),
6086 }
6087 }
6088 None => SummaryState::Loading,
6089 },
6090 GitHubFetchState::Loaded(s) => {
6091 let badge = match s.state {
6097 PrState::Open => "open",
6098 PrState::Draft => "draft",
6099 PrState::Closed => "closed",
6100 PrState::Merged => "merged",
6101 };
6102 let (trailing, trailing_color) = match ci_indicator(s.ci, s.checks_passed, s.checks_total, theme) {
6108 Some((text, color)) => (
6109 match ci_hint {
6110 Some(key) => format!("{text} [{key}]"),
6111 None => text,
6112 },
6113 Some(color),
6114 ),
6115 None => (String::new(), None),
6116 };
6117 SummaryState::Loaded {
6118 badge,
6119 badge_color: pr_badge_color(s.state, theme),
6120 trailing,
6121 trailing_color,
6122 title: &s.title,
6123 }
6124 }
6125 GitHubFetchState::Error(e) => SummaryState::Error(e),
6126 };
6127 summary_line(PR_ICON, head, src, resolved, max_width, theme, spinner)
6128}
6129
6130pub fn branch_name_color(s: &BranchStatus, theme: &Theme) -> Color {
6145 if s.unknown {
6146 return theme.muted;
6147 }
6148 if s.is_dirty {
6149 return theme.prunable;
6150 }
6151 if s.ahead > 0 || s.behind > 0 {
6152 return theme.dirty;
6153 }
6154 if !s.has_upstream {
6155 return theme.locked;
6158 }
6159 theme.branch
6160}
6161
6162pub fn freshness_color(age: Duration, theme: &Theme) -> Color {
6167 const WEEK: u64 = 7 * 86_400;
6168 const MONTH: u64 = 30 * 86_400;
6169 let s = age.as_secs();
6170 if s < WEEK {
6171 theme.clean
6172 } else if s < MONTH {
6173 theme.dirty
6174 } else {
6175 theme.muted
6176 }
6177}
6178
6179pub fn pr_badge_color(state: PrState, theme: &Theme) -> Color {
6184 match state {
6185 PrState::Open => theme.clean,
6186 PrState::Draft => theme.muted,
6187 PrState::Merged => theme.locked,
6188 PrState::Closed => theme.prunable,
6189 }
6190}
6191
6192pub fn ci_indicator(ci: CiState, passed: u32, total: u32, theme: &Theme) -> Option<(String, Color)> {
6200 let (icon, label, color) = match ci {
6201 CiState::None => return None,
6202 CiState::Passing => (CI_PASSING_ICON, "passing", theme.clean),
6203 CiState::Failing => (CI_FAILING_ICON, "failing", theme.prunable),
6204 CiState::Running => (CI_RUNNING_ICON, "running", theme.dirty),
6205 };
6206 Some((format!(" {} CI {} {}/{}", icon, label, passed, total), color))
6207}
6208
6209pub fn issue_badge_color(state: IssueState, theme: &Theme) -> Color {
6213 match state {
6214 IssueState::Open => theme.clean,
6215 IssueState::Closed => theme.locked,
6216 }
6217}
6218
6219pub fn table_marker(w: &WorktreeInfo, theme: &Theme) -> Line<'static> {
6236 if w.is_main {
6237 return Line::from(Span::styled("★", Style::default().fg(theme.main)));
6238 }
6239 let issue_color = match (w.link.issue, w.issue_state) {
6243 (Some(_), Some(state)) => issue_badge_color(state, theme),
6244 (Some(_), None) => theme.clean,
6245 (None, _) => theme.name,
6246 };
6247 let pr_color = match (w.link.pr, w.pr_state) {
6248 (Some(_), Some(state)) => pr_badge_color(state, theme),
6249 (Some(_), None) => theme.locked,
6250 (None, _) => theme.name,
6251 };
6252 Line::from(vec![
6253 Span::styled(
6254 if w.link.issue.is_some() { "●" } else { "-" },
6255 Style::default().fg(issue_color),
6256 ),
6257 Span::styled("/", Style::default().fg(theme.muted)),
6258 Span::styled(
6259 if w.link.pr.is_some() { "●" } else { "-" },
6260 Style::default().fg(pr_color),
6261 ),
6262 ])
6263}