1use super::app::{App, GitHubFetchState, LinkPromptStage, LinkTarget, View};
2use super::keymap::{Action, 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;
8use super::state::pty_overlay::PtyKind;
9use super::state::sidebar::SidebarMode;
10use super::state::spinner::DOT_FRAMES;
11use super::theme::Theme;
12use super::wt_tree::{self, working_tree_category, WtCategory, WtNode, WT_DIR_OPEN_ICON};
13use crate::bootstrap::{BootstrapReport, StepStatus};
14use crate::command_log::CommandStatus;
15use crate::config::ConfigSource;
16use crate::github::{CiState, IssueState, LinkSource, PrState};
17use crate::worktree::{self, BranchStatus, WorktreeInfo};
18use ratatui::{
19 buffer::Buffer,
20 layout::{Alignment, Constraint, Direction, Layout, Rect},
21 style::{Color, Modifier, Style},
22 text::{Line, Span},
23 widgets::{
24 Block, BorderType, Borders, Cell, Clear, Padding, Paragraph, Row, Scrollbar, ScrollbarOrientation, ScrollbarState,
25 Table, Widget, Wrap,
26 },
27 Frame,
28};
29use std::time::{Duration, Instant};
30
31#[derive(Debug, Clone, Default)]
39pub struct SidebarSections {
40 pub worktree: Vec<Line<'static>>,
44 pub working_tree: Vec<Line<'static>>,
46 pub working_tree_counts: WorkingTreeCounts,
50 pub recent_commits: Vec<Line<'static>>,
52}
53
54#[derive(Debug, Clone, Copy)]
56pub enum LoaderWidgetState<'a> {
57 Running {
58 glyph: &'a str,
59 label: &'a str,
60 detail: Option<&'a str>,
61 },
62 Failed {
63 message: &'a str,
64 detail: Option<&'a str>,
65 },
66}
67
68#[derive(Debug, Clone, Copy)]
69pub struct LoaderWidget<'a> {
70 state: LoaderWidgetState<'a>,
71 accent: Color,
72 text: Color,
73 muted: Color,
74 failed: Color,
75 alignment: Alignment,
76}
77
78impl<'a> LoaderWidget<'a> {
79 pub fn running(glyph: &'a str, label: &'a str, detail: Option<&'a str>, theme: &Theme) -> Self {
80 Self {
81 state: LoaderWidgetState::Running { glyph, label, detail },
82 accent: theme.accent,
83 text: theme.name,
84 muted: theme.muted,
85 failed: theme.prunable,
86 alignment: Alignment::Left,
87 }
88 }
89
90 pub fn failed(message: &'a str, detail: Option<&'a str>, theme: &Theme) -> Self {
91 Self {
92 state: LoaderWidgetState::Failed { message, detail },
93 accent: theme.accent,
94 text: theme.name,
95 muted: theme.muted,
96 failed: theme.prunable,
97 alignment: Alignment::Left,
98 }
99 }
100
101 pub fn alignment(mut self, alignment: Alignment) -> Self {
102 self.alignment = alignment;
103 self
104 }
105
106 fn line(self) -> Line<'static> {
107 let mut spans = match self.state {
108 LoaderWidgetState::Running { glyph, label, .. } => vec![
109 Span::styled(
110 format!("{glyph} "),
111 Style::default().fg(self.accent).add_modifier(Modifier::BOLD),
112 ),
113 Span::styled(
114 label.to_string(),
115 Style::default().fg(self.text).add_modifier(Modifier::BOLD),
116 ),
117 ],
118 LoaderWidgetState::Failed { message, .. } => vec![
119 Span::styled("! ", Style::default().fg(self.failed).add_modifier(Modifier::BOLD)),
120 Span::styled(
121 message.to_string(),
122 Style::default().fg(self.failed).add_modifier(Modifier::BOLD),
123 ),
124 ],
125 };
126
127 let detail = match self.state {
128 LoaderWidgetState::Running { detail, .. } | LoaderWidgetState::Failed { detail, .. } => detail,
129 };
130 if let Some(detail) = detail {
131 spans.push(Span::styled(" — ", Style::default().fg(self.muted)));
132 spans.push(Span::styled(detail.to_string(), Style::default().fg(self.muted)));
133 }
134 Line::from(spans)
135 }
136}
137
138impl Widget for LoaderWidget<'_> {
139 fn render(self, area: Rect, buf: &mut Buffer) {
140 Paragraph::new(self.line()).alignment(self.alignment).render(area, buf);
141 }
142}
143
144pub fn draw(f: &mut Frame, app: &mut App) {
145 let chunks = Layout::default()
150 .direction(Direction::Vertical)
151 .constraints([Constraint::Length(1), Constraint::Min(0), Constraint::Length(1)])
152 .split(f.area());
153
154 draw_header(f, chunks[0], app);
155 draw_body(f, chunks[1], app);
156 draw_footer(f, chunks[2], app);
157
158 match app.view {
159 View::Help => draw_help(f, app),
160 View::Create => draw_create(f, app),
161 View::Confirm => draw_confirm(f, app),
162 View::Report => draw_report(f, app),
163 View::OpenMenu => draw_open_menu(f, app),
164 View::LinkPrompt => draw_link_prompt(f, app),
165 View::CommandPalette => draw_command_palette(f, app),
166 View::CommandLogs => draw_command_logs(f, app),
167 View::Config => draw_config_panel(f, app),
168 View::Pty => draw_pty_overlay(f, app),
169 View::ExecPicker => draw_exec_picker(f, app),
171 View::CleanReport => draw_clean_overlay(f, app),
173 View::Edit => draw_edit_worktree(f, app),
175 View::List => {}
176 }
177}
178
179pub fn header_line(
199 repo_name: &str,
200 workdir_display: &str,
201 picker_mode: bool,
202 width: usize,
203 theme: &Theme,
204) -> Line<'static> {
205 if width == 0 {
208 return Line::default();
209 }
210
211 let sanitize = |s: &str| -> String { s.chars().map(|c| if c.is_control() { ' ' } else { c }).collect() };
212 let repo = sanitize(repo_name);
213 let path = sanitize(workdir_display);
214
215 let version_style = chip_style(theme.accent);
216 let dir_badge_style = chip_style(theme.name);
217 let picker_style = chip_style(theme.dirty);
221 let path_style = Style::default().fg(theme.muted);
222
223 let version_text = format!(" gwm {} ", env!("CARGO_PKG_VERSION"));
224 let version_w = version_text.chars().count();
225 let dir_text = format!(" {} ", repo);
226 let dir_w = dir_text.chars().count();
227
228 if width < version_w {
231 return Line::from(Span::styled(trunc(&version_text, width), version_style));
232 }
233
234 let mut spans: Vec<Span<'static>> = Vec::new();
235 let mut used = 0usize;
236
237 let dir_budget = width.saturating_sub(version_w + 1);
241 if dir_w <= dir_budget {
242 spans.push(Span::styled(dir_text, dir_badge_style));
243 used += dir_w;
244 } else if dir_budget > 0 {
245 let clipped = trunc(&dir_text, dir_budget);
246 used += clipped.chars().count();
247 spans.push(Span::styled(clipped, dir_badge_style));
248 }
249
250 if picker_mode {
253 let picker_text = " picker ".to_string();
254 let need = 1 + picker_text.chars().count(); if used + need + version_w < width {
256 spans.push(Span::raw(" "));
257 spans.push(Span::styled(picker_text, picker_style));
258 used += need;
259 }
260 }
261
262 let path_gap = 2usize;
266 if used + path_gap + version_w < width {
267 let avail = width - used - path_gap - version_w;
268 let path_disp = trunc(&path, avail);
269 if !path_disp.is_empty() {
270 let w = path_disp.chars().count();
271 spans.push(Span::raw(" "));
272 spans.push(Span::styled(path_disp, path_style));
273 used += path_gap + w;
274 }
275 }
276
277 let pad = width.saturating_sub(used + version_w);
278 if pad > 0 {
279 spans.push(Span::raw(" ".repeat(pad)));
280 }
281 spans.push(Span::styled(version_text, version_style));
282
283 Line::from(spans)
284}
285
286fn draw_body(f: &mut Frame, area: Rect, app: &mut App) {
295 use super::state::sidebar::ResolvedSidebarLayout as Resolved;
296
297 let layout = app.sidebar.resolve_layout(area.width);
298 let (table_pct, sidebar_pct) = match layout.split_percentages() {
299 Some((t, s)) => (Constraint::Percentage(t), Constraint::Percentage(s)),
300 None => {
301 app.sidebar.max_scroll = 0;
303 draw_list(f, area, app);
304 return;
305 }
306 };
307
308 match layout {
309 Resolved::Hidden => unreachable!("Hidden returns None from split_percentages, handled above"),
310 Resolved::SideBySide { sidebar_left } => {
311 let split = Layout::default()
312 .direction(Direction::Horizontal)
313 .constraints(if sidebar_left {
314 [sidebar_pct, table_pct]
315 } else {
316 [table_pct, sidebar_pct]
317 })
318 .split(area);
319 let (list_area, sidebar_area) = if sidebar_left {
320 (split[1], split[0])
321 } else {
322 (split[0], split[1])
323 };
324 draw_list(f, list_area, app);
325 draw_sidebar(f, sidebar_area, app);
326 }
327 Resolved::Stacked => {
328 let split = Layout::default()
332 .direction(Direction::Vertical)
333 .constraints([table_pct, sidebar_pct])
334 .split(area);
335 draw_list(f, split[0], app);
336 draw_sidebar(f, split[1], app);
337 }
338 }
339}
340
341fn draw_header(f: &mut Frame, area: Rect, app: &App) {
342 let workdir = tilde_compress(&app.workdir.to_string_lossy());
347 let line = header_line(
351 &app.repo_name,
352 &workdir,
353 app.picker_mode,
354 area.width as usize,
355 &app.theme,
356 );
357 f.render_widget(Paragraph::new(line), area);
358}
359
360pub fn panel_border_color(focused: bool, theme: &super::theme::Theme) -> Color {
366 if focused {
367 theme.focus
368 } else {
369 theme.muted
370 }
371}
372
373pub fn worktrees_pane_title(
385 query: &str,
386 active: bool,
387 visible: usize,
388 total: usize,
389 filter_color: Color,
390) -> Line<'static> {
391 let mut spans = vec![Span::raw(" [1] Worktrees ")];
392 if active || !query.is_empty() {
395 spans.push(Span::styled(
396 "/",
397 Style::default().fg(filter_color).add_modifier(Modifier::BOLD),
398 ));
399 spans.push(Span::raw(query.to_string()));
400 if active {
401 spans.push(Span::styled(
402 "\u{2588}",
403 Style::default().fg(filter_color).add_modifier(Modifier::SLOW_BLINK),
404 ));
405 }
406 spans.push(Span::raw(" "));
407 }
408 let counter = if query.is_empty() {
412 format!("({}) ", total)
413 } else {
414 format!("({}/{}) ", visible, total)
415 };
416 spans.push(Span::raw(counter));
417 Line::from(spans)
418}
419
420pub fn status_pane_title() -> &'static str {
426 " [2] Status "
427}
428
429pub fn pane_counter(selected: usize, visible: usize) -> Option<String> {
436 if visible == 0 {
437 None
438 } else {
439 Some(format!(" {} of {} ", selected, visible))
440 }
441}
442
443fn draw_list(f: &mut Frame, area: Rect, app: &mut App) {
444 let filtered: Vec<usize> = app.filtered_indices().to_vec();
453 let visible: Vec<&WorktreeInfo> = filtered.iter().filter_map(|&i| app.worktrees.get(i)).collect();
454 let theme = app.theme;
458
459 let is_workspace = app.is_workspace();
463 let repo_names: Vec<String> = if is_workspace {
464 filtered
465 .iter()
466 .map(|&raw| app.row_repo_name(raw).unwrap_or("?").to_string())
467 .collect()
468 } else {
469 Vec::new()
470 };
471 let repo_w = if is_workspace {
472 column_width(repo_names.iter().map(|s| s.as_str()), 6, 24)
473 } else {
474 0
475 };
476
477 let name_w = column_width(visible.iter().map(|w| w.name.as_str()), 18, 38);
481 let branch_w = column_width(visible.iter().map(|w| w.branch.as_deref().unwrap_or("-")), 18, 38);
482 let status_w: u16 = 16;
483
484 let mut header_cells = vec![Cell::from("")];
487 if is_workspace {
488 header_cells.push(Cell::from("REPO"));
489 }
490 header_cells.push(Cell::from("I/P"));
491 header_cells.push(Cell::from("NAME"));
492 header_cells.push(Cell::from("BRANCH"));
493 header_cells.push(Cell::from("STATUS"));
494 header_cells.push(Cell::from("PATH"));
495 let header = Row::new(header_cells).style(Style::default().fg(theme.muted).add_modifier(Modifier::BOLD));
496
497 let rows: Vec<Row> = visible
498 .iter()
499 .enumerate()
500 .map(|(vi, w)| {
501 let repo = is_workspace.then(|| (repo_names[vi].as_str(), repo_w));
502 build_row(w, repo, name_w, branch_w, status_w, &theme)
503 })
504 .collect();
505
506 let mut widths = vec![Constraint::Length(4)];
520 if is_workspace {
521 widths.push(Constraint::Length(repo_w));
524 }
525 widths.extend([
526 Constraint::Length(3),
527 Constraint::Min(name_w),
528 Constraint::Min(branch_w),
529 Constraint::Length(status_w),
530 Constraint::Fill(1),
531 ]);
532
533 let list_has_focus = !(app.sidebar.open && app.sidebar.focused);
534 let border_color = panel_border_color(list_has_focus, &app.theme);
535
536 let title = worktrees_pane_title(
537 app.filter.query(),
538 app.filter.active,
539 visible.len(),
540 app.worktrees.len(),
541 app.theme.dirty,
542 );
543
544 let selected_1based = app.list_state.selected().map(|i| i + 1).unwrap_or(0);
548 let counter = pane_counter(selected_1based, visible.len());
549
550 let mut block = Block::default()
551 .borders(Borders::ALL)
552 .title(title)
553 .border_style(Style::default().fg(border_color));
554 if let Some(counter) = counter {
555 block = block.title_bottom(Line::from(counter).right_aligned());
556 }
557
558 let table = Table::new(rows, widths)
559 .header(header)
560 .column_spacing(1)
561 .block(block)
562 .row_highlight_style(Style::default().bg(theme.selection_bg).add_modifier(Modifier::BOLD))
563 .highlight_symbol("▶ ");
564
565 f.render_stateful_widget(table, area, &mut app.list_state);
566}
567
568fn draw_sidebar(f: &mut Frame, area: Rect, app: &mut App) {
575 let border_color = panel_border_color(app.sidebar.focused, &app.theme);
576 let theme = app.theme;
579
580 let active_mode = app.sidebar.mode;
590
591 let issue_pr_inner_width = area.width.saturating_sub(3) as usize;
598
599 let Some(w) = app.selected().cloned() else {
600 let issue_pr_lines = github_status_lines(app, issue_pr_inner_width);
603 let placeholder = [Line::from("(nothing selected)")];
604 let h = |lines: usize| (lines as u16).saturating_add(2);
605 let constraints = [
606 Constraint::Length(h(placeholder.len())),
607 Constraint::Length(h(issue_pr_lines.len())),
608 Constraint::Length(0),
609 Constraint::Min(3),
610 ];
611 let chunks = Layout::default()
612 .direction(Direction::Vertical)
613 .constraints(constraints)
614 .split(area);
615 app.sidebar.max_scroll = 0;
616 app.sidebar.scroll = 0;
617 render_section(
618 f,
619 chunks[0],
620 status_pane_title(),
621 SectionBody::new(&placeholder),
622 border_color,
623 0,
624 None,
625 );
626 render_section(
627 f,
628 chunks[1],
629 issue_pr_pane_title(&app.keymap),
630 SectionBody::new(&issue_pr_lines),
631 border_color,
632 0,
633 None,
634 );
635 render_section(
636 f,
637 chunks[3],
638 recent_items_pane_title(active_mode, &app.keymap),
639 SectionBody::new(&[]),
640 border_color,
641 0,
642 None,
643 );
644 return;
645 };
646
647 let needs_refresh = match &app.sidebar.cache {
650 Some(((p, m), _)) => *p != w.path || *m != active_mode,
651 None => true,
652 };
653 if needs_refresh {
654 let diff = worktree::git_diff_stat_vs_base(&w.path, &app.config.doctor.trunks)
659 .ok()
660 .flatten();
661 app.sidebar.cache = Some((
662 (w.path.clone(), active_mode),
663 build_sidebar_sections(&w, active_mode, diff, &theme),
664 ));
665 }
666
667 let header_line = sidebar_header_line(&w, app);
673 let issue_pr_lines = github_status_lines(app, issue_pr_inner_width);
674
675 let (worktree_len, working_tree_len, working_tree_counts, commits_len) = {
679 let cache = app.sidebar.cache.as_ref();
680 let s = cache.map(|(_, s)| s);
681 (
682 s.map(|s| s.worktree.len()).unwrap_or(0) + 1,
683 s.map(|s| s.working_tree.len()).unwrap_or(0),
684 s.map(|s| s.working_tree_counts).unwrap_or_default(),
685 s.map(|s| s.recent_commits.len()).unwrap_or(0) as u16,
686 )
687 };
688
689 let h = |lines: usize| (lines as u16).saturating_add(2);
697 let working_tree_height = if working_tree_len == 0 { 0 } else { h(working_tree_len) };
698 let constraints = [
699 Constraint::Length(h(worktree_len)),
700 Constraint::Length(h(issue_pr_lines.len())),
701 Constraint::Length(working_tree_height),
702 Constraint::Min(3),
703 ];
704 let chunks = Layout::default()
705 .direction(Direction::Vertical)
706 .constraints(constraints)
707 .split(area);
708
709 let commits_area = chunks[3];
713 let commits_visible = commits_area.height.saturating_sub(2);
714 app.sidebar.max_scroll = commits_len.saturating_sub(commits_visible);
715 if app.sidebar.scroll > app.sidebar.max_scroll {
716 app.sidebar.scroll = app.sidebar.max_scroll;
717 }
718 let scroll = app.sidebar.scroll;
719
720 let (panel_title, panel_footer) = match active_mode {
724 super::state::sidebar::SidebarMode::Commits => {
725 let title = recent_items_pane_title(active_mode, &app.keymap);
726 let footer = if commits_len == 0 {
727 None
728 } else {
729 let bottom = scroll.saturating_add(commits_visible).min(commits_len);
730 Some(format!(" {} of {} ", bottom, commits_len))
731 };
732 (title, footer)
733 }
734 super::state::sidebar::SidebarMode::Stashes => {
735 let title = recent_items_pane_title(active_mode, &app.keymap);
736 let footer = if commits_len == 0 {
741 None
742 } else {
743 Some(" Enter: copy stash@{N} to status ".to_string())
744 };
745 (title, footer)
746 }
747 };
748 let issue_pr_title = issue_pr_pane_title(&app.keymap);
749 let working_tree_title = working_tree_pane_title(&app.keymap);
750 let working_tree_footer = if working_tree_len == 0 {
755 None
756 } else {
757 working_tree_counts_footer(&working_tree_counts, &theme)
758 };
759
760 if let Some((_, cache)) = app.sidebar.cache.as_ref() {
770 render_section(
771 f,
772 chunks[0],
773 status_pane_title(),
774 SectionBody::with_prefix(&header_line, &cache.worktree),
775 border_color,
776 0,
777 None,
778 );
779 render_section(
780 f,
781 chunks[1],
782 issue_pr_title,
783 SectionBody::new(&issue_pr_lines),
784 border_color,
785 0,
786 None,
787 );
788 if !cache.working_tree.is_empty() {
789 render_section(
790 f,
791 chunks[2],
792 working_tree_title,
793 SectionBody::new(&cache.working_tree),
794 border_color,
795 0,
796 working_tree_footer,
797 );
798 }
799 render_section(
800 f,
801 commits_area,
802 panel_title,
803 SectionBody::new(&cache.recent_commits),
804 border_color,
805 scroll,
806 panel_footer.map(ratatui::text::Line::from),
807 );
808 }
809}
810
811struct SectionBody<'a> {
821 prefix: Option<&'a Line<'a>>,
822 lines: &'a [Line<'a>],
823}
824
825impl<'a> SectionBody<'a> {
826 fn new(lines: &'a [Line<'a>]) -> Self {
829 Self { prefix: None, lines }
830 }
831
832 fn with_prefix(prefix: &'a Line<'a>, lines: &'a [Line<'a>]) -> Self {
835 Self {
836 prefix: Some(prefix),
837 lines,
838 }
839 }
840}
841
842fn render_section(
843 f: &mut Frame,
844 area: Rect,
845 title: impl Into<ratatui::text::Line<'static>>,
854 body: SectionBody<'_>,
855 border_color: Color,
856 scroll: u16,
857 footer: Option<ratatui::text::Line<'static>>,
858) {
859 let SectionBody { prefix, lines } = body;
860 let mut block = Block::default()
861 .borders(Borders::ALL)
862 .border_type(BorderType::Rounded)
863 .title(title.into())
864 .border_style(Style::default().fg(border_color));
865 if let Some(f) = footer {
866 block = block.title_bottom(f.right_aligned());
867 }
868 fn pad<'a>(l: &'a Line<'_>) -> Line<'a> {
874 let mut spans = Vec::with_capacity(l.spans.len() + 1);
875 spans.push(Span::raw(" "));
876 spans.extend(l.spans.iter().map(|s| Span::styled(s.content.as_ref(), s.style)));
877 Line::from(spans)
878 }
879 let padded: Vec<Line<'_>> = prefix.into_iter().chain(lines.iter()).map(pad).collect();
880 let paragraph = Paragraph::new(padded).block(block).scroll((scroll, 0));
884 f.render_widget(paragraph, area);
885}
886
887fn sidebar_header_line(w: &WorktreeInfo, app: &App) -> Line<'static> {
892 let (dot, dot_color) = sidebar_status_dot(app);
893 Line::from(vec![
894 Span::styled(dot, Style::default().fg(dot_color).add_modifier(Modifier::BOLD)),
895 Span::styled(w.name.clone(), worktree_name_style(&app.theme)),
896 ])
897}
898
899fn sidebar_status_dot(app: &App) -> (&'static str, Color) {
904 if let GitHubFetchState::Loaded(pr) = app.pr_fetch_state() {
905 return ("● ", pr_badge_color(pr.state, &app.theme));
906 }
907 if let GitHubFetchState::Loaded(issue) = app.issue_fetch_state() {
908 return ("● ", issue_badge_color(issue.state, &app.theme));
909 }
910 let link = app.current_link();
911 if link.pr.is_some() || link.issue.is_some() {
912 return ("● ", Color::White);
916 }
917 ("● ", app.theme.muted)
918}
919
920pub fn build_sidebar_sections(
930 w: &WorktreeInfo,
931 mode: super::state::sidebar::SidebarMode,
932 diff: Option<worktree::DiffLineStat>,
933 theme: &Theme,
934) -> SidebarSections {
935 use super::state::sidebar::SidebarMode;
936 let body = match mode {
937 SidebarMode::Commits => recent_commits_lines(w, RECENT_COMMITS_LIMIT, theme),
941 SidebarMode::Stashes => stash_lines(w, STASHES_DISPLAY_LIMIT, theme),
948 };
949 let (working_tree, working_tree_counts) = match mode {
950 SidebarMode::Commits => working_tree_lines(w, theme),
951 SidebarMode::Stashes => (Vec::new(), WorkingTreeCounts::default()),
952 };
953 SidebarSections {
954 worktree: worktree_identity_lines(w, diff.as_ref(), theme),
955 working_tree,
956 working_tree_counts,
957 recent_commits: body,
958 }
959}
960
961pub const STASHES_DISPLAY_LIMIT: usize = 10;
966
967fn stash_lines(w: &WorktreeInfo, limit: usize, theme: &Theme) -> Vec<Line<'static>> {
974 match crate::worktree::git_stash_list(&w.path, limit) {
975 Ok(stashes) if stashes.is_empty() => {
976 vec![Line::from(Span::styled(
977 "(no stashes)",
978 Style::default().fg(theme.muted),
979 ))]
980 }
981 Ok(stashes) => stashes
982 .into_iter()
983 .map(|s| {
984 Line::from(vec![
985 Span::styled(s.ref_name, Style::default().fg(theme.dirty)),
986 Span::raw(" "),
987 Span::raw(s.subject),
988 ])
989 })
990 .collect(),
991 Err(e) => vec![Line::from(Span::styled(
992 format!("git stash list failed: {}", e),
993 Style::default().fg(theme.prunable),
994 ))],
995 }
996}
997
998fn worktree_identity_lines(
1005 w: &WorktreeInfo,
1006 diff: Option<&worktree::DiffLineStat>,
1007 theme: &Theme,
1008) -> Vec<Line<'static>> {
1009 let mut out: Vec<Line<'static>> = Vec::with_capacity(5);
1010 let label_w = "Created".chars().count();
1011 let label_style = Style::default().fg(theme.muted);
1012
1013 let branch_color = branch_name_color(&w.status, theme);
1019 let branch = w.branch.clone().unwrap_or_else(|| "-".into());
1020 let mut spans = vec![
1021 Span::styled(format!("{:<label_w$} ", "Branch", label_w = label_w), label_style),
1022 Span::styled(branch, Style::default().fg(branch_color)),
1023 ];
1024 if let Some(head) = w.head.as_deref() {
1025 spans.push(Span::styled(" · ".to_string(), Style::default().fg(theme.muted)));
1026 spans.push(Span::styled(short_oid(head), Style::default().fg(theme.dirty)));
1027 }
1028 out.push(Line::from(spans));
1029
1030 out.push(Line::from(vec![
1034 Span::styled(format!("{:<label_w$} ", "Created", label_w = label_w), label_style),
1035 Span::styled(branch_age_label(w), Style::default().fg(branch_age_color(w, theme))),
1036 ]));
1037
1038 if let Some(d) = diff {
1045 if !d.is_empty() {
1046 out.push(Line::from(vec![
1047 Span::styled(format!("{:<label_w$} ", "Diff", label_w = label_w), label_style),
1048 Span::styled(format!("+{}", d.insertions), Style::default().fg(theme.untracked)),
1049 Span::raw(" "),
1050 Span::styled(format!("-{}", d.deletions), Style::default().fg(theme.prunable)),
1051 ]));
1052 }
1053 }
1054
1055 let mut state_spans = vec![Span::styled(
1058 format!("{:<label_w$} ", "State", label_w = label_w),
1059 label_style,
1060 )];
1061 state_spans.extend(badges_line(w, theme).spans);
1062 out.push(Line::from(state_spans));
1063
1064 out.push(Line::from(vec![
1066 Span::styled(format!("{:<label_w$} ", "Path", label_w = label_w), label_style),
1067 Span::styled(
1068 tilde_compress(&w.path.display().to_string()),
1069 Style::default().fg(theme.muted),
1070 ),
1071 ]));
1072
1073 out
1074}
1075
1076fn branch_age_label(w: &WorktreeInfo) -> String {
1083 w.age
1084 .map(worktree::format_relative_duration)
1085 .unwrap_or_else(|| "-".into())
1086}
1087
1088fn branch_age_color(w: &WorktreeInfo, theme: &Theme) -> Color {
1089 w.age.map(|age| freshness_color(age, theme)).unwrap_or(theme.muted)
1090}
1091
1092fn badges_line(w: &WorktreeInfo, theme: &Theme) -> Line<'static> {
1093 let mut spans: Vec<Span<'static>> = Vec::new();
1094 let status_label = branch_status_label(&w.status);
1102 let status_color = branch_status_color(&w.status, theme);
1103 let is_diverged = w.status.has_upstream && (w.status.ahead > 0 || w.status.behind > 0);
1104 let badge_text = if w.status.unknown {
1105 format!("? {}", status_label)
1106 } else if w.status.is_dirty {
1107 format!("● {}", status_label)
1108 } else if is_diverged {
1109 status_label
1110 } else {
1111 format!("✓ {}", status_label)
1112 };
1113 spans.push(Span::styled(badge_text, Style::default().fg(status_color)));
1114
1115 let sep = || Span::styled(" ".to_string(), Style::default().fg(theme.muted));
1116 if w.is_main {
1117 spans.push(sep());
1118 spans.push(Span::styled("★ main".to_string(), Style::default().fg(theme.main)));
1119 }
1120 if w.is_locked {
1121 spans.push(sep());
1122 spans.push(Span::styled("🔒 locked".to_string(), Style::default().fg(theme.locked)));
1123 }
1124 if w.is_prunable {
1125 spans.push(sep());
1126 spans.push(Span::styled(
1127 "⚠ prunable".to_string(),
1128 Style::default().fg(theme.prunable),
1129 ));
1130 }
1131 Line::from(spans)
1132}
1133
1134fn working_tree_lines(w: &WorktreeInfo, theme: &Theme) -> (Vec<Line<'static>>, WorkingTreeCounts) {
1135 match worktree::git_status_short(&w.path) {
1136 Ok((s, _)) if s.trim().is_empty() => (
1137 vec![Line::from(Span::styled(
1138 "✓ clean".to_string(),
1139 Style::default().fg(theme.clean),
1140 ))],
1141 WorkingTreeCounts::default(),
1142 ),
1143 Ok((s, scan_truncated)) => {
1144 let counts = working_tree_status_counts(&s);
1145 let records = wt_tree::parse_status_z(&s);
1146 let (tree, overflow) = wt_tree::build_capped_tree(&records, wt_tree::WT_TREE_MAX_FILES);
1151 let mut lines = working_tree_tree_lines(&tree, theme);
1152 if overflow > 0 {
1153 let label = if scan_truncated {
1157 format!("… {}+ more", overflow)
1158 } else {
1159 format!("… {} more", overflow)
1160 };
1161 lines.push(Line::from(Span::styled(label, Style::default().fg(theme.muted))));
1162 }
1163 (lines, counts)
1164 }
1165 Err(e) => (
1166 vec![Line::from(Span::styled(
1167 format!("! {}", e),
1168 Style::default().fg(theme.prunable),
1169 ))],
1170 WorkingTreeCounts::default(),
1171 ),
1172 }
1173}
1174
1175fn working_tree_tree_lines(nodes: &[WtNode], theme: &Theme) -> Vec<Line<'static>> {
1193 let mut out = Vec::new();
1194 push_wt_nodes(&mut out, nodes, String::new(), theme);
1195 out
1196}
1197
1198fn push_wt_nodes(out: &mut Vec<Line<'static>>, nodes: &[WtNode], prefix: String, theme: &Theme) {
1202 let last = nodes.len().saturating_sub(1);
1203 for (i, node) in nodes.iter().enumerate() {
1204 let is_last = i == last;
1205 let connector = format!("{}{}", prefix, if is_last { "└─ " } else { "├─ " });
1206 match node {
1207 WtNode::Dir {
1208 name,
1209 children,
1210 category,
1211 } => {
1212 let color = match category {
1213 Some(c) => working_tree_category_color(*c, theme),
1214 None => theme.accent,
1215 };
1216 out.push(Line::from(vec![
1217 Span::styled(connector, Style::default().fg(theme.muted)),
1218 Span::styled(
1219 format!("{} {}", WT_DIR_OPEN_ICON, wt_tree::sanitize_name(name)),
1220 Style::default().fg(color),
1221 ),
1222 ]));
1223 let child_prefix = format!("{}{}", prefix, if is_last { " " } else { "│ " });
1224 push_wt_nodes(out, children, child_prefix, theme);
1225 }
1226 WtNode::File {
1227 name,
1228 icon,
1229 badge,
1230 category,
1231 } => {
1232 let color = working_tree_category_color(*category, theme);
1233 out.push(Line::from(vec![
1234 Span::styled(connector, Style::default().fg(theme.muted)),
1235 Span::styled(format!("{} ", badge), Style::default().fg(color)),
1236 Span::styled(
1237 format!("{} {}", icon, wt_tree::sanitize_name(name)),
1238 Style::default().fg(color),
1239 ),
1240 ]));
1241 }
1242 }
1243 }
1244}
1245
1246#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1251pub struct WorkingTreeCounts {
1252 pub created: usize,
1254 pub modified: usize,
1256 pub deleted: usize,
1258}
1259
1260impl WorkingTreeCounts {
1261 pub fn is_empty(&self) -> bool {
1264 self.created == 0 && self.modified == 0 && self.deleted == 0
1265 }
1266}
1267
1268pub const WT_CREATED_ICON: &str = "\u{eadc}";
1272pub const WT_MODIFIED_ICON: &str = "\u{eadd}";
1273pub const WT_DELETED_ICON: &str = "\u{eade}";
1274
1275fn working_tree_category_color(cat: WtCategory, theme: &Theme) -> Color {
1278 match cat {
1279 WtCategory::Created => theme.untracked,
1280 WtCategory::Modified => theme.modified,
1281 WtCategory::Deleted => theme.prunable,
1282 }
1283}
1284
1285pub fn working_tree_status_counts(status_z: &str) -> WorkingTreeCounts {
1291 let mut c = WorkingTreeCounts::default();
1292 for rec in wt_tree::parse_status_z(status_z) {
1293 match working_tree_category(rec.x, rec.y) {
1294 WtCategory::Created => c.created += 1,
1295 WtCategory::Modified => c.modified += 1,
1296 WtCategory::Deleted => c.deleted += 1,
1297 }
1298 }
1299 c
1300}
1301
1302pub fn working_tree_counts_footer(counts: &WorkingTreeCounts, theme: &Theme) -> Option<Line<'static>> {
1309 if counts.is_empty() {
1310 return None;
1311 }
1312 let mut spans: Vec<Span<'static>> = vec![Span::raw(" ")];
1313 if counts.created > 0 {
1314 spans.push(Span::styled(
1315 format!("{} {} ", WT_CREATED_ICON, counts.created),
1316 Style::default().fg(theme.untracked),
1317 ));
1318 }
1319 if counts.modified > 0 {
1320 spans.push(Span::styled(
1321 format!("{} {} ", WT_MODIFIED_ICON, counts.modified),
1322 Style::default().fg(theme.modified),
1323 ));
1324 }
1325 if counts.deleted > 0 {
1326 spans.push(Span::styled(
1327 format!("{} {} ", WT_DELETED_ICON, counts.deleted),
1328 Style::default().fg(theme.prunable),
1329 ));
1330 }
1331 Some(Line::from(spans))
1332}
1333
1334pub fn working_tree_status_line(raw: &str, theme: &Theme) -> Line<'static> {
1352 let mut indices = raw.char_indices();
1358 let (x_at, x) = match indices.next() {
1359 Some(c) => c,
1360 None => return Line::from(raw.to_string()),
1361 };
1362 let (_y_at, y) = match indices.next() {
1363 Some(c) => c,
1364 None => return Line::from(raw.to_string()),
1365 };
1366 let (sep_at, sep) = match indices.next() {
1367 Some(c) => c,
1368 None => return Line::from(raw.to_string()),
1369 };
1370 let path_at = sep_at + sep.len_utf8();
1372
1373 let style = Style::default().fg(working_tree_category_color(working_tree_category(x, y), theme));
1376
1377 Line::from(vec![
1378 Span::styled(raw[x_at..sep_at].to_string(), style),
1379 Span::raw(raw[sep_at..path_at].to_string()),
1380 Span::styled(raw[path_at..].to_string(), style),
1381 ])
1382}
1383
1384pub const RECENT_COMMITS_LIMIT: usize = 300;
1388
1389pub const COMMIT_HASH_DISPLAY_LEN: usize = 8;
1392
1393pub fn recent_commits_lines(w: &WorktreeInfo, limit: usize, theme: &Theme) -> Vec<Line<'static>> {
1413 match worktree::recent_commits_cached(w, limit) {
1414 Ok(rows) if !rows.is_empty() => {
1415 let graphs = super::commit_graph::render_commits(&rows, theme);
1416 rows
1417 .into_iter()
1418 .zip(graphs)
1419 .map(|(row, graph_spans)| commit_row_line(row, graph_spans, theme))
1420 .collect()
1421 }
1422 Ok(_) => vec![Line::from(Span::styled(
1423 "(no commits)".to_string(),
1424 Style::default().fg(theme.muted),
1425 ))],
1426 Err(e) => vec![Line::from(Span::styled(
1427 format!("! {}", e),
1428 Style::default().fg(theme.prunable),
1429 ))],
1430 }
1431}
1432
1433fn commit_row_line(row: worktree::CommitRow, graph: Vec<Span<'static>>, theme: &Theme) -> Line<'static> {
1434 let mut short_hash = row.hash.to_string();
1435 short_hash.truncate(COMMIT_HASH_DISPLAY_LEN);
1436 let initials = author_initials(&row.author);
1437 let mut spans: Vec<Span<'static>> = Vec::with_capacity(5 + graph.len());
1438 spans.push(Span::styled(short_hash, Style::default().fg(theme.dirty)));
1439 spans.push(Span::raw(" "));
1440 spans.push(Span::styled(
1441 format!("{:<2}", initials),
1442 Style::default().fg(theme.accent).add_modifier(Modifier::BOLD),
1443 ));
1444 spans.push(Span::raw(" "));
1445 spans.extend(graph);
1446 spans.push(Span::raw(" "));
1447 spans.push(Span::raw(row.subject));
1448 Line::from(spans)
1449}
1450
1451pub fn author_initials(author: &str) -> String {
1473 let trimmed = author.trim();
1474 if trimmed.is_empty() {
1475 return String::new();
1476 }
1477 let mut parts = trimmed.split_whitespace();
1478 let first = parts.next().unwrap_or("");
1479 match parts.next() {
1480 Some(second) => {
1481 let a: String = first.chars().take(1).collect();
1482 let b: String = second.chars().take(1).collect();
1483 format!("{}{}", a, b)
1484 }
1485 None => first.chars().take(2).collect(),
1486 }
1487}
1488
1489fn tilde_compress(path: &str) -> String {
1493 if let Some(home) = dirs::home_dir() {
1494 tilde_compress_with_home(path, &home)
1495 } else {
1496 path.to_string()
1497 }
1498}
1499
1500pub fn tilde_compress_with_home(path: &str, home: &std::path::Path) -> String {
1508 let home_s = home.display().to_string();
1509 if let Some(rest) = path.strip_prefix(&home_s) {
1510 if rest.is_empty() || rest.starts_with('/') || rest.starts_with(std::path::MAIN_SEPARATOR) {
1513 return format!("~{}", rest);
1514 }
1515 }
1516 path.to_string()
1517}
1518
1519fn short_oid(oid: &str) -> String {
1520 oid.chars().take(7).collect()
1521}
1522
1523fn branch_status_label(s: &BranchStatus) -> String {
1524 if s.unknown {
1525 return "unknown".into();
1526 }
1527 let mut parts: Vec<String> = Vec::new();
1528 if s.is_dirty {
1529 parts.push("dirty".into());
1530 }
1531 if s.has_upstream {
1532 if s.ahead > 0 {
1533 parts.push(format!("↑{}", s.ahead));
1534 }
1535 if s.behind > 0 {
1536 parts.push(format!("↓{}", s.behind));
1537 }
1538 if !s.is_dirty && s.synced() {
1539 parts.push("synced".into());
1540 }
1541 } else if !s.is_dirty {
1542 parts.push("clean".into());
1543 }
1544 if parts.is_empty() {
1545 "clean".into()
1546 } else {
1547 parts.join(" ")
1548 }
1549}
1550
1551pub fn branch_status_color(s: &BranchStatus, theme: &Theme) -> Color {
1559 if s.unknown {
1560 theme.muted
1561 } else if s.is_dirty || s.behind > 0 {
1562 theme.dirty
1563 } else if s.ahead > 0 {
1564 theme.accent
1565 } else {
1566 theme.clean
1567 }
1568}
1569
1570fn column_width<'a>(items: impl Iterator<Item = &'a str>, min: u16, max: u16) -> u16 {
1572 let observed = items.map(|s| s.chars().count() as u16).max().unwrap_or(min);
1573 observed.clamp(min, max)
1574}
1575
1576pub fn worktree_name_style(theme: &Theme) -> Style {
1582 Style::default().fg(theme.name).add_modifier(Modifier::BOLD)
1583}
1584
1585pub fn worktree_path_style(theme: &Theme) -> Style {
1590 Style::default().fg(theme.path)
1591}
1592
1593pub fn chip_style(color: Color) -> Style {
1602 Style::default()
1603 .fg(color)
1604 .add_modifier(Modifier::REVERSED | Modifier::BOLD)
1605}
1606
1607pub fn hint_key_style(theme: &Theme) -> Style {
1614 Style::default().fg(theme.accent).add_modifier(Modifier::BOLD)
1615}
1616
1617pub fn hint_label_style(theme: &Theme) -> Style {
1621 Style::default().fg(theme.muted)
1622}
1623
1624pub fn palette_name_style(theme: &Theme) -> Style {
1631 Style::default().fg(theme.name)
1632}
1633
1634pub fn help_label_style(theme: &Theme) -> Style {
1640 Style::default().fg(theme.name)
1641}
1642
1643fn build_row(
1648 w: &WorktreeInfo,
1649 repo: Option<(&str, u16)>,
1650 name_w: u16,
1651 branch_w: u16,
1652 status_w: u16,
1653 theme: &Theme,
1654) -> Row<'static> {
1655 let marker = table_marker(w, theme);
1656 let branch_text = w.branch.clone().unwrap_or_else(|| "-".into());
1657
1658 let name_cell = Cell::from(trunc(&w.name, name_w as usize)).style(worktree_name_style(theme));
1662
1663 let branch_cell =
1666 Cell::from(trunc(&branch_text, branch_w as usize)).style(Style::default().fg(branch_name_color(&w.status, theme)));
1667
1668 let status_cell = build_status_cell(w, status_w as usize, theme);
1669
1670 let age_label = w.age.map(format_relative_duration_str).unwrap_or_else(|| "-".into());
1679 let age_cell = Cell::from(age_label).style(Style::default().fg(theme.muted));
1680
1681 let path_cell = Cell::from(w.path.to_string_lossy().to_string()).style(worktree_path_style(theme));
1684
1685 let mut cells = vec![age_cell];
1686 if let Some((repo_name, repo_w)) = repo {
1687 cells.push(
1688 Cell::from(trunc(repo_name, repo_w as usize))
1689 .style(Style::default().fg(theme.accent).add_modifier(Modifier::BOLD)),
1690 );
1691 }
1692 cells.push(Cell::from(marker));
1693 cells.push(name_cell);
1694 cells.push(branch_cell);
1695 cells.push(status_cell);
1696 cells.push(path_cell);
1697 Row::new(cells)
1698}
1699
1700fn format_relative_duration_str(d: std::time::Duration) -> String {
1705 worktree::format_relative_duration(d)
1706}
1707
1708fn build_status_cell(w: &WorktreeInfo, width: usize, theme: &Theme) -> Cell<'static> {
1709 if w.is_prunable {
1711 return Cell::from("prunable").style(Style::default().fg(theme.prunable).add_modifier(Modifier::BOLD));
1712 }
1713 if w.is_locked {
1714 return Cell::from("locked").style(Style::default().fg(theme.locked));
1715 }
1716
1717 let s = &w.status;
1718 let (label, color) = format_status(s, width, theme);
1719 Cell::from(label).style(Style::default().fg(color))
1720}
1721
1722pub fn format_status(s: &BranchStatus, width: usize, theme: &Theme) -> (String, Color) {
1728 if s.unknown {
1729 return ("unknown".into(), theme.muted);
1730 }
1731
1732 let mut parts: Vec<String> = Vec::new();
1733 if s.is_dirty {
1734 parts.push("● dirty".into());
1735 }
1736 if s.has_upstream {
1737 if s.ahead > 0 {
1738 parts.push(format!("↑{}", s.ahead));
1739 }
1740 if s.behind > 0 {
1741 parts.push(format!("↓{}", s.behind));
1742 }
1743 if !s.is_dirty && s.synced() {
1744 parts.push("✓ synced".into());
1745 }
1746 } else if !s.is_dirty {
1747 parts.push("clean".into());
1748 }
1749
1750 let joined = parts.join(" ");
1751 let label = trunc(&joined, width.max(4));
1752
1753 (label, branch_status_color(s, theme))
1758}
1759
1760#[derive(Debug, Clone, Copy)]
1766enum Hint {
1767 Key(super::keymap::Action, &'static str),
1769 Modal(ModalAction, &'static str),
1773 Lit(&'static str, &'static str),
1776}
1777
1778#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1785pub enum HintContext {
1786 Worktrees,
1788 Status,
1790 Picker,
1792 Create,
1794 Confirm,
1796 OpenMenu,
1798 LinkPrompt,
1800 LinkInputNumber,
1804 CommandPalette,
1806 Report,
1808 Help,
1810 Pty,
1813 ExecPicker,
1816 Clean,
1819 Rename,
1821}
1822
1823impl HintContext {
1824 pub fn label(self) -> &'static str {
1827 match self {
1828 HintContext::Worktrees => "worktrees",
1829 HintContext::Status => "status",
1830 HintContext::Picker => "switch",
1831 HintContext::Create => "create",
1832 HintContext::Confirm => "confirm",
1833 HintContext::OpenMenu => "open",
1834 HintContext::LinkPrompt => "link",
1835 HintContext::LinkInputNumber => "link",
1836 HintContext::CommandPalette => "command",
1837 HintContext::Report => "report",
1838 HintContext::Help => "help",
1839 HintContext::Pty => "terminal",
1840 HintContext::ExecPicker => "exec",
1841 HintContext::Clean => "clean",
1842 HintContext::Rename => "rename",
1843 }
1844 }
1845
1846 fn hint_specs(self) -> &'static [Hint] {
1854 use super::keymap::Action::*;
1855 match self {
1856 HintContext::Worktrees => &[
1859 Hint::Key(Create, "new"),
1861 Hint::Key(DeleteConfirm, "del"),
1862 Hint::Key(Bootstrap, "boot"),
1863 Hint::Key(TerminalFullscreen, "open"),
1865 Hint::Key(LazyGitFullscreen, "git"),
1866 Hint::Key(ReviewFullscreen, "review"),
1867 Hint::Key(YankPath, "yank"),
1868 Hint::Key(Filter, "filter"),
1870 Hint::Key(FocusStatus, "status"),
1871 Hint::Key(CommandLogs, "logs"),
1872 Hint::Key(ConfigPanel, "settings"),
1873 Hint::Key(Help, "help"),
1875 Hint::Key(Quit, "quit"),
1876 ],
1877 HintContext::Status => &[
1878 Hint::Key(Down, "scroll"),
1880 Hint::Key(FetchGithub, "fetch"),
1881 Hint::Key(ToggleSidebarMode, "mode"),
1883 Hint::Key(CycleSidebarLayout, "layout"),
1884 Hint::Key(FocusWorktrees, "worktrees"),
1886 Hint::Key(Filter, "filter"),
1887 Hint::Key(CommandLogs, "logs"),
1888 Hint::Key(ConfigPanel, "settings"),
1889 Hint::Key(Help, "help"),
1891 Hint::Key(Quit, "quit"),
1892 ],
1893 HintContext::Picker => &[
1894 Hint::Lit("Enter", "select"),
1896 Hint::Lit("Esc", "cancel"),
1897 Hint::Key(TerminalFullscreen, "open"),
1899 Hint::Key(LazyGitFullscreen, "git"),
1900 Hint::Key(YankPath, "yank"),
1901 Hint::Key(Filter, "filter"),
1903 Hint::Key(Help, "help"),
1904 Hint::Key(Quit, "quit"),
1905 ],
1906 HintContext::Create => &[
1910 Hint::Modal(ModalAction::CreateNextField, "field"),
1911 Hint::Lit("↑/↓", "type"),
1912 Hint::Modal(ModalAction::CreateSubmit, "submit"),
1913 Hint::Modal(ModalAction::CreateCancel, "cancel"),
1914 ],
1915 HintContext::Confirm => &[
1916 Hint::Modal(ModalAction::ConfirmConfirm, "confirm"),
1917 Hint::Key(ToggleDeleteBranch, "branch"),
1918 Hint::Lit("←/→", "move"),
1919 Hint::Modal(ModalAction::ConfirmActivate, "activate"),
1920 Hint::Modal(ModalAction::ConfirmCancel, "cancel"),
1921 ],
1922 HintContext::OpenMenu => &[
1923 Hint::Modal(ModalAction::OpenMenuIssue, "issue"),
1924 Hint::Modal(ModalAction::OpenMenuPr, "pr"),
1925 Hint::Key(FetchGithub, "fetch"),
1926 Hint::Modal(ModalAction::OpenMenuClose, "close"),
1927 ],
1928 HintContext::LinkPrompt => &[
1929 Hint::Modal(ModalAction::LinkChoosePrev, "prev"),
1930 Hint::Modal(ModalAction::LinkChooseNext, "next"),
1931 Hint::Modal(ModalAction::LinkChooseIssue, "issue"),
1932 Hint::Modal(ModalAction::LinkChoosePr, "pr"),
1933 Hint::Modal(ModalAction::LinkChooseAccept, "link"),
1934 Hint::Key(FetchGithub, "fetch"),
1935 Hint::Modal(ModalAction::LinkChooseCancel, "cancel"),
1936 ],
1937 HintContext::LinkInputNumber => &[
1940 Hint::Lit("0-9", "number"),
1941 Hint::Modal(ModalAction::LinkInputSubmit, "submit"),
1942 Hint::Key(FetchGithub, "fetch"),
1943 Hint::Modal(ModalAction::LinkInputCancel, "cancel"),
1944 ],
1945 HintContext::CommandPalette => &[
1946 Hint::Lit("↑/↓", "move"),
1947 Hint::Modal(ModalAction::CommandPaletteAccept, "run"),
1948 Hint::Modal(ModalAction::CommandPaletteClose, "cancel"),
1949 ],
1950 HintContext::Report => &[Hint::Modal(ModalAction::ReportClose, "close")],
1954 HintContext::Help => &[
1955 Hint::Lit("j/k", "scroll"),
1956 Hint::Lit("h/l", "pan"),
1957 Hint::Modal(ModalAction::HelpClose, "close"),
1958 ],
1959 HintContext::Pty => &[Hint::Lit("Esc", "close")],
1960 HintContext::ExecPicker => &[
1964 Hint::Lit("↑/↓", "pick"),
1965 Hint::Modal(ModalAction::ExecPickerAccept, "run"),
1966 Hint::Modal(ModalAction::ExecPickerCancel, "cancel"),
1967 ],
1968 HintContext::Clean => &[
1972 Hint::Lit("↑/↓", "profile"),
1973 Hint::Modal(ModalAction::CleanConfirm, "reclaim"),
1974 Hint::Modal(ModalAction::CleanCancel, "cancel"),
1975 ],
1976 HintContext::Rename => &[
1979 Hint::Modal(ModalAction::CreateNextField, "field"),
1980 Hint::Lit("↑/↓", "type"),
1981 Hint::Modal(ModalAction::CreateSubmit, "submit"),
1982 Hint::Modal(ModalAction::CreateCancel, "cancel"),
1983 ],
1984 }
1985 }
1986
1987 pub fn resolve(self, keymap: &super::keymap::Keymap, modal: &ModalKeymap) -> Vec<(String, String)> {
1993 self
1994 .hint_specs()
1995 .iter()
1996 .filter_map(|h| match h {
1997 Hint::Key(action, label) => keymap
2002 .primary_chord(*action)
2003 .filter(|k| !self.key_shadowed_by_modal(k, modal))
2004 .map(|k| (k, label.to_string())),
2005 Hint::Modal(action, label) => modal.primary_key(*action).map(|k| (k, label.to_string())),
2006 Hint::Lit(key, label) => Some((key.to_string(), label.to_string())),
2007 })
2008 .collect()
2009 }
2010
2011 fn modal_context(self) -> Option<KeyContext> {
2015 Some(match self {
2016 HintContext::Create | HintContext::Rename => KeyContext::Create,
2017 HintContext::Confirm => KeyContext::Confirm,
2018 HintContext::OpenMenu => KeyContext::OpenMenu,
2019 HintContext::LinkPrompt => KeyContext::LinkChooseTarget,
2020 HintContext::LinkInputNumber => KeyContext::LinkInputNumber,
2021 HintContext::CommandPalette => KeyContext::CommandPalette,
2022 HintContext::Report => KeyContext::Report,
2023 HintContext::Help => KeyContext::Help,
2024 HintContext::ExecPicker => KeyContext::ExecPicker,
2025 HintContext::Clean => KeyContext::Clean,
2026 HintContext::Worktrees | HintContext::Status | HintContext::Picker | HintContext::Pty => return None,
2027 })
2028 }
2029
2030 fn key_shadowed_by_modal(self, key: &str, modal: &ModalKeymap) -> bool {
2033 match self.modal_context() {
2034 Some(ctx) => modal
2035 .bindings_for(ctx)
2036 .iter()
2037 .any(|b| b.keys.iter().any(|ks| ks.to_string() == key)),
2038 None => false,
2039 }
2040 }
2041}
2042
2043fn action_chord(keymap: &Keymap, action: Action, fallback: &str) -> String {
2044 keymap.primary_chord(action).unwrap_or_else(|| fallback.to_string())
2045}
2046
2047pub fn issue_pr_pane_title(keymap: &Keymap) -> String {
2048 format!(" Issue / PR [{}] ", action_chord(keymap, Action::FetchGithub, "F"))
2049}
2050
2051pub fn working_tree_pane_title(keymap: &Keymap) -> String {
2052 format!(
2053 " Working Tree [{}] ",
2054 action_chord(keymap, Action::ReviewFullscreen, "R")
2055 )
2056}
2057
2058pub fn recent_items_pane_title(mode: SidebarMode, keymap: &Keymap) -> String {
2059 match mode {
2060 SidebarMode::Commits => format!(
2061 " Recent Commits [{}] ",
2062 action_chord(keymap, Action::LazyGitFullscreen, "l")
2063 ),
2064 SidebarMode::Stashes => format!(" Stashes [{}] ", action_chord(keymap, Action::LazyGitFullscreen, "l")),
2065 }
2066}
2067
2068pub fn modal_hint_line(hints: &[(&str, &str)], theme: &Theme) -> Line<'static> {
2069 let key_style = hint_key_style(theme);
2070 let label_style = hint_label_style(theme);
2071 let mut spans: Vec<Span<'static>> = Vec::new();
2072 for (i, (key, label)) in hints.iter().enumerate() {
2073 if i > 0 {
2074 spans.push(Span::raw(" "));
2077 }
2078 spans.push(Span::styled((*key).to_string(), key_style));
2079 spans.push(Span::styled(format!(" {}", label), label_style));
2080 }
2081 Line::from(spans).centered()
2082}
2083
2084pub fn config_edit_footer_hints(modal: &ModalKeymap) -> Vec<(String, String)> {
2090 [
2091 (ModalAction::ConfigEditSubmit, "save"),
2092 (ModalAction::ConfigEditCancel, "cancel"),
2093 ]
2094 .into_iter()
2095 .filter_map(|(action, label)| modal.primary_key(action).map(|k| (k, label.to_string())))
2096 .collect()
2097}
2098
2099pub fn config_nav_footer_hints(
2107 modal: &ModalKeymap,
2108 tab: SettingsTab,
2109 selected_kind: Option<FieldKind>,
2110) -> Vec<(String, String)> {
2111 let mut hints: Vec<(String, String)> = Vec::new();
2112 if tab == SettingsTab::All {
2113 hints.push(("j/k".to_string(), "scroll".to_string()));
2114 } else {
2115 let label = if tab == SettingsTab::Keys {
2116 "rebind"
2117 } else if selected_kind == Some(FieldKind::Choice) {
2118 "cycle"
2119 } else {
2120 "edit"
2121 };
2122 if let Some(k) = modal.primary_key(ModalAction::ConfigActivate) {
2123 hints.push((k, label.to_string()));
2124 }
2125 }
2126 for (action, label) in [
2127 (ModalAction::ConfigNextTab, "section"),
2128 (ModalAction::ConfigToggleLayer, "layer"),
2129 (ModalAction::ConfigClose, "close"),
2130 ] {
2131 if let Some(k) = modal.primary_key(action) {
2132 hints.push((k, label.to_string()));
2133 }
2134 }
2135 hints
2136}
2137
2138pub fn config_capture_footer_hints(modal: &ModalKeymap, single_only: bool) -> Vec<(String, String)> {
2146 let mut hints: Vec<(String, String)> = Vec::new();
2147 if single_only {
2148 hints.push(("any key".to_string(), "bind".to_string()));
2149 } else {
2150 if let Some(k) = modal.primary_key(ModalAction::ConfigEditSubmit) {
2151 hints.push((k, "save".to_string()));
2152 }
2153 hints.push(("Backspace".to_string(), "delete".to_string()));
2154 }
2155 if let Some(k) = modal.primary_key(ModalAction::ConfigEditCancel) {
2156 hints.push((k, "cancel".to_string()));
2157 }
2158 hints
2159}
2160
2161pub fn command_logs_footer_hints(modal: &ModalKeymap) -> Vec<(String, String)> {
2166 let mut hints: Vec<(String, String)> = vec![
2167 ("j/k".to_string(), "scroll".to_string()),
2168 ("g/G".to_string(), "top/bottom".to_string()),
2169 ];
2170 for (action, label) in [
2171 (ModalAction::CommandLogsCopy, "copy"),
2172 (ModalAction::CommandLogsClose, "close"),
2173 ] {
2174 if let Some(k) = modal.primary_key(action) {
2175 hints.push((k, label.to_string()));
2176 }
2177 }
2178 hints
2179}
2180
2181fn modal_hint_for_context(ctx: HintContext, keymap: &Keymap, modal: &ModalKeymap, theme: &Theme) -> Line<'static> {
2182 let resolved = ctx.resolve(keymap, modal);
2183 let hints: Vec<(&str, &str)> = resolved.iter().map(|(k, l)| (k.as_str(), l.as_str())).collect();
2184 modal_hint_line(&hints, theme)
2185}
2186
2187fn push_modal_hint(
2188 lines: &mut Vec<Line<'static>>,
2189 ctx: HintContext,
2190 keymap: &Keymap,
2191 modal: &ModalKeymap,
2192 theme: &Theme,
2193) {
2194 lines.push(Line::from(String::new()));
2195 lines.push(modal_hint_for_context(ctx, keymap, modal, theme));
2196}
2197
2198pub fn footer_line(hints: &[(&str, &str)], status: &str, width: usize, theme: &Theme) -> Line<'static> {
2219 let key_style = hint_key_style(theme);
2220 let label_style = hint_label_style(theme);
2221 let status_style = Style::default().fg(theme.dirty);
2222
2223 if width == 0 {
2226 return Line::default();
2227 }
2228
2229 let status: String = status.chars().map(|c| if c.is_control() { ' ' } else { c }).collect();
2234 let status_text = format!("[{}]", status);
2235 let status_w = status_text.chars().count();
2236
2237 if width <= status_w {
2240 return Line::from(Span::styled(trunc(&status_text, width), status_style));
2241 }
2242
2243 let hint_budget = (width - status_w - 1).saturating_sub(1);
2249
2250 let mut spans: Vec<Span<'static>> = Vec::new();
2251 let mut used = 0usize; let mut truncated = false;
2253 for (i, (key, label)) in hints.iter().enumerate() {
2254 let sep = if i > 0 { 2 } else { 0 }; let badge_w = key.chars().count() + 1 + label.chars().count();
2257 if used + sep + badge_w > hint_budget {
2258 truncated = true;
2259 break;
2260 }
2261 if sep > 0 {
2262 spans.push(Span::raw(" ".repeat(sep)));
2263 used += sep;
2264 }
2265 spans.push(Span::styled((*key).to_string(), key_style));
2266 spans.push(Span::styled(format!(" {}", label), label_style));
2267 used += badge_w;
2268 }
2269
2270 if truncated {
2271 if used > 0 {
2272 spans.push(Span::raw(" "));
2273 used += 1;
2274 }
2275 spans.push(Span::styled("…", label_style));
2276 used += 1;
2277 }
2278
2279 let pad = width.saturating_sub(used + status_w);
2281 if pad > 0 {
2282 spans.push(Span::raw(" ".repeat(pad)));
2283 }
2284 spans.push(Span::styled(status_text, status_style));
2285 Line::from(spans)
2286}
2287
2288pub fn status_line(
2303 context: &str,
2304 hints: &[(&str, &str)],
2305 status: &str,
2306 spinner: Option<&str>,
2307 width: usize,
2308 theme: &Theme,
2309) -> Line<'static> {
2310 let context_style = chip_style(theme.focus);
2311 let key_style = hint_key_style(theme);
2312 let label_style = hint_label_style(theme);
2313 let status_style = Style::default().fg(theme.dirty);
2314 let spinner_style = Style::default().fg(theme.accent).add_modifier(Modifier::BOLD);
2315
2316 if width == 0 {
2317 return Line::default();
2318 }
2319
2320 let status: String = status.chars().map(|c| if c.is_control() { ' ' } else { c }).collect();
2321 let status_text = format!("[{}]", status);
2322 let status_w = status_text.chars().count();
2323
2324 if width <= status_w {
2327 return Line::from(Span::styled(trunc(&status_text, width), status_style));
2328 }
2329
2330 let avail = width - status_w; let mut spans: Vec<Span<'static>> = Vec::new();
2332 let mut used = 0usize;
2333
2334 let ctx_chip = format!(" {} ", context);
2336 let ctx_w = ctx_chip.chars().count();
2337 if ctx_w <= avail {
2338 spans.push(Span::styled(ctx_chip, context_style));
2339 used += ctx_w;
2340 }
2341
2342 if let Some(glyph) = spinner {
2345 let padded = format!(" {} ", glyph);
2346 let gw = padded.chars().count();
2347 if used + gw <= avail {
2348 spans.push(Span::styled(padded, spinner_style));
2349 used += gw;
2350 }
2351 }
2352
2353 let hint_budget = avail.saturating_sub(used).saturating_sub(1);
2355 let mut truncated = false;
2356 let mut hint_used = 0usize;
2357 for (i, (key, label)) in hints.iter().enumerate() {
2358 let sep = if i > 0 { 2 } else { usize::from(used > 0) };
2361 let badge_w = key.chars().count() + 1 + label.chars().count();
2362 if hint_used + sep + badge_w > hint_budget {
2363 truncated = true;
2364 break;
2365 }
2366 if sep > 0 {
2367 spans.push(Span::raw(" ".repeat(sep)));
2368 hint_used += sep;
2369 }
2370 spans.push(Span::styled((*key).to_string(), key_style));
2371 spans.push(Span::styled(format!(" {}", label), label_style));
2372 hint_used += badge_w;
2373 }
2374 used += hint_used;
2375 if truncated {
2376 if used > 0 {
2377 spans.push(Span::raw(" "));
2378 used += 1;
2379 }
2380 spans.push(Span::styled("…", label_style));
2381 used += 1;
2382 }
2383
2384 let pad = width.saturating_sub(used + status_w);
2385 if pad > 0 {
2386 spans.push(Span::raw(" ".repeat(pad)));
2387 }
2388 spans.push(Span::styled(status_text, status_style));
2389 Line::from(spans)
2390}
2391
2392fn draw_footer(f: &mut Frame, area: Rect, app: &App) {
2393 let ctx = app.hint_context();
2394 let spinner = if app.is_github_loading() || app.is_task_loading() {
2400 Some(app.spinner.glyph(DOT_FRAMES))
2401 } else {
2402 None
2403 };
2404 let resolved = ctx.resolve(&app.keymap, &app.modal_keymap);
2408 let hints: Vec<(&str, &str)> = resolved.iter().map(|(k, l)| (k.as_str(), l.as_str())).collect();
2409 let line = status_line(
2410 ctx.label(),
2411 &hints,
2412 &app.status,
2413 spinner,
2414 area.width as usize,
2415 &app.theme,
2416 );
2417 f.render_widget(Paragraph::new(line), area);
2419}
2420
2421#[derive(Debug, Clone, PartialEq, Eq)]
2430pub enum HelpRow {
2431 Title(String),
2433 Subtitle(String),
2436 Section(String),
2438 Blank,
2440 Entry { keys: String, label: String },
2444}
2445
2446pub fn help_rows(km: &super::keymap::Keymap, modal: &ModalKeymap, ctx: HintContext) -> Vec<HelpRow> {
2464 use super::keymap::Action;
2465
2466 let picker_mode = matches!(ctx, HintContext::Picker);
2467
2468 let bindings = km.list();
2473
2474 let keys_for = |action: Action| -> String {
2479 bindings
2480 .iter()
2481 .find(|b| b.action == action)
2482 .map(|b| {
2483 b.chords
2484 .iter()
2485 .map(|c| c.iter().map(|k| k.to_string()).collect::<Vec<_>>().join(" "))
2486 .collect::<Vec<_>>()
2487 .join(", ")
2488 })
2489 .unwrap_or_default()
2490 };
2491 let entry = |action: Action, label: &str| -> HelpRow {
2493 HelpRow::Entry {
2494 keys: keys_for(action),
2495 label: label.to_string(),
2496 }
2497 };
2498 let fixed = |keys: &str, label: &str| -> HelpRow {
2501 HelpRow::Entry {
2502 keys: keys.to_string(),
2503 label: label.to_string(),
2504 }
2505 };
2506 let modal_entry = |action: ModalAction, label: &str| -> HelpRow {
2510 HelpRow::Entry {
2511 keys: modal.keys_display(action),
2512 label: label.to_string(),
2513 }
2514 };
2515
2516 let mut rows: Vec<HelpRow> = vec![
2517 HelpRow::Title("Keybindings".to_string()),
2518 HelpRow::Subtitle(ctx.label().to_string()),
2519 HelpRow::Blank,
2520 HelpRow::Section("Global".to_string()),
2521 HelpRow::Blank,
2522 entry(Action::Quit, "quit (Esc also quits when filter is clear)"),
2523 fixed("Ctrl-C", "quit (hard-coded escape hatch)"),
2524 HelpRow::Blank,
2525 HelpRow::Section("List View".to_string()),
2526 HelpRow::Blank,
2527 entry(Action::Down, "next (scrolls sidebar when focused)"),
2528 entry(Action::Up, "prev (scrolls sidebar when focused)"),
2529 entry(Action::Top, "jump to first worktree"),
2530 entry(Action::Bottom, "jump to last worktree"),
2531 ];
2532 if picker_mode {
2533 rows.push(fixed("enter", "select highlighted worktree (prints path on exit)"));
2534 } else {
2535 rows.push(entry(Action::Create, "new worktree"));
2536 rows.push(entry(Action::DeleteConfirm, "delete selected"));
2537 rows.push(entry(Action::Bootstrap, "bootstrap selected"));
2538 }
2539 rows.push(entry(
2540 Action::TerminalFullscreen,
2541 "open per [tui.open] — shell / editor / finder",
2542 ));
2543 rows.push(entry(Action::TerminalPty, "open native $SHELL in embedded PTY overlay"));
2544 rows.push(entry(Action::OpenDocs, "open the gwm documentation in the browser"));
2545 rows.push(entry(Action::YankPath, "yank selected worktree path to clipboard"));
2546 rows.push(entry(Action::YankBranchName, "yank selected branch name to clipboard"));
2547 rows.push(entry(
2548 Action::YankWorktreeName,
2549 "yank selected worktree name to clipboard",
2550 ));
2551 rows.push(entry(Action::LazyGitFullscreen, "launch lazygit fullscreen"));
2552 rows.push(entry(Action::LazyGitPty, "open lazygit in embedded PTY overlay"));
2553 rows.push(entry(Action::ToggleSidebar, "toggle git preview sidebar"));
2554 rows.push(entry(
2555 Action::ToggleSidebarMode,
2556 "cycle sidebar mode (commits / stashes)",
2557 ));
2558 rows.push(entry(
2559 Action::CycleSidebarLayout,
2560 "cycle sidebar layout (auto / side-by-side / stacked)",
2561 ));
2562 rows.push(entry(
2563 Action::ToggleSidebarPosition,
2564 "toggle sidebar position (left / right)",
2565 ));
2566 rows.push(entry(Action::FocusSwap, "swap focus between worktree list and sidebar"));
2567 rows.push(entry(Action::FocusWorktrees, "focus the worktrees pane"));
2568 rows.push(entry(Action::FocusStatus, "focus the status pane (opens it if hidden)"));
2569 rows.push(entry(Action::CommandLogs, "show the command logs overlay"));
2570 rows.push(entry(Action::ConfigPanel, "show the resolved configuration panel"));
2571 if !picker_mode {
2574 rows.push(entry(
2575 Action::ExecOverlay,
2576 "pick an [exec.profiles] profile and run it in a PTY",
2577 ));
2578 rows.push(entry(
2579 Action::CleanOverlay,
2580 "preview and reclaim build artifacts (with confirm)",
2581 ));
2582 }
2583 rows.push(entry(
2584 Action::Filter,
2585 "open fuzzy filter bar (enter: sticky, esc: clear)",
2586 ));
2587 rows.push(entry(Action::Refresh, "refresh worktree list"));
2588 if !picker_mode {
2589 rows.push(entry(Action::Sync, "sync selected worktree onto its upstream (rebase)"));
2590 rows.push(entry(Action::Pull, "pull selected worktree's branch from upstream"));
2591 rows.push(entry(Action::Push, "push selected worktree's branch to remote"));
2592 rows.push(entry(Action::EditWorktree, "rename the selected worktree's branch"));
2593 rows.push(entry(
2594 Action::ExitToWorktree,
2595 "quit TUI and print selected path to stdout",
2596 ));
2597 rows.push(entry(Action::MuxPane, "open selected worktree in new mux pane/tab"));
2598 rows.push(entry(Action::Macro1, "run [tui.macro1] command"));
2599 rows.push(entry(Action::Macro2, "run [tui.macro2] command"));
2600 rows.push(entry(Action::FetchGithub, "refresh GitHub issue/PR status via `gh`"));
2601 rows.push(entry(Action::ReviewFullscreen, "run [review] launcher fullscreen"));
2602 rows.push(entry(
2603 Action::ReviewPty,
2604 "run [review] launcher in embedded PTY overlay",
2605 ));
2606 rows.push(entry(Action::ToggleDeleteBranch, "toggle 'delete branch on remove'"));
2607 rows.push(fixed("enter", "show path in status bar"));
2608 rows.push(HelpRow::Blank);
2609 rows.push(HelpRow::Section("Issue / PR".to_string()));
2610 rows.push(HelpRow::Blank);
2611 let open_picks: Vec<String> = [
2616 (ModalAction::OpenMenuIssue, "issue"),
2617 (ModalAction::OpenMenuPr, "pull request"),
2618 ]
2619 .into_iter()
2620 .filter_map(|(a, l)| modal.primary_key(a).map(|k| format!("{k}={l}")))
2621 .collect();
2622 let open_desc = if open_picks.is_empty() {
2623 "open menu".to_string()
2624 } else {
2625 format!("open menu — {}", open_picks.join(" · "))
2626 };
2627 rows.push(entry(Action::BrowseLinks, &open_desc));
2628
2629 let key = |a: ModalAction| modal.primary_key(a);
2630 let nav: Vec<String> = [ModalAction::LinkChooseNext, ModalAction::LinkChoosePrev]
2631 .into_iter()
2632 .filter_map(key)
2633 .collect();
2634 let picks: Vec<String> = [ModalAction::LinkChooseIssue, ModalAction::LinkChoosePr]
2635 .into_iter()
2636 .filter_map(key)
2637 .collect();
2638 let mut parts: Vec<String> = Vec::new();
2639 match (nav.is_empty(), key(ModalAction::LinkChooseAccept)) {
2640 (false, Some(a)) => parts.push(format!("{} + {a}", nav.join("/"))),
2641 (false, None) => parts.push(nav.join("/")),
2642 (true, Some(a)) => parts.push(a),
2643 (true, None) => {}
2644 }
2645 if !picks.is_empty() {
2646 parts.push(format!("or {}", picks.join("/")));
2647 }
2648 parts.push("then digits".to_string());
2649 rows.push(entry(
2650 Action::LinkPrompt,
2651 &format!("link prompt — {}", parts.join(", ")),
2652 ));
2653 }
2654 rows.push(entry(Action::Help, "this help"));
2655 if !picker_mode {
2656 rows.push(entry(Action::CommandPalette, "open the command palette"));
2657 }
2658 if !picker_mode {
2659 rows.extend([
2660 HelpRow::Blank,
2661 HelpRow::Section("Create Form".to_string()),
2662 HelpRow::Blank,
2663 modal_entry(ModalAction::CreatePrevType, "previous branch type"),
2664 modal_entry(ModalAction::CreateNextType, "next branch type"),
2665 modal_entry(ModalAction::CreateNextField, "next field"),
2666 modal_entry(ModalAction::CreatePrevField, "previous field"),
2667 modal_entry(ModalAction::CreateSubmit, "submit (on description) / next field"),
2668 modal_entry(ModalAction::CreateCancel, "cancel"),
2669 HelpRow::Blank,
2670 HelpRow::Section("Delete Worktree".to_string()),
2671 HelpRow::Blank,
2672 modal_entry(ModalAction::ConfirmFocusConfirm, "focus the Confirm button"),
2673 modal_entry(ModalAction::ConfirmFocusCancel, "focus the Cancel button"),
2674 modal_entry(ModalAction::ConfirmToggleFocus, "toggle the focused button"),
2675 modal_entry(
2676 ModalAction::ConfirmActivate,
2677 "activate the focused button (defaults to Cancel)",
2678 ),
2679 modal_entry(ModalAction::ConfirmConfirm, "confirm"),
2680 modal_entry(ModalAction::ConfirmCancel, "cancel"),
2681 ]);
2682 }
2683 rows
2684}
2685
2686pub fn help_lines(km: &super::keymap::Keymap, modal: &ModalKeymap, picker_mode: bool) -> Vec<String> {
2692 let ctx = if picker_mode {
2696 HintContext::Picker
2697 } else {
2698 HintContext::Worktrees
2699 };
2700 help_rows(km, modal, ctx)
2701 .into_iter()
2702 .map(|row| match row {
2703 HelpRow::Title(s) | HelpRow::Subtitle(s) | HelpRow::Section(s) => s,
2704 HelpRow::Blank => String::new(),
2705 HelpRow::Entry { keys, label } => {
2706 let keys = if keys.is_empty() { "(unbound)".to_string() } else { keys };
2707 format!(" {:<13} {}", keys, label)
2708 }
2709 })
2710 .collect()
2711}
2712
2713pub fn badge_group_width(keys: &str) -> usize {
2719 if keys.is_empty() || keys == "(unbound)" {
2720 return "(unbound)".chars().count();
2721 }
2722 let chords: Vec<&str> = keys.split(", ").collect();
2723 let glyphs: usize = chords.iter().map(|c| c.chars().count()).sum();
2727 glyphs + chords.len().saturating_sub(1)
2728}
2729
2730pub fn help_entry_line(keys: &str, label: &str, max_group_w: usize, theme: &Theme) -> Line<'static> {
2737 let key_style = hint_key_style(theme);
2738 let muted_style = Style::default().fg(theme.muted);
2739 let mut spans: Vec<Span<'static>> = vec![Span::raw(" ")];
2740 if keys.is_empty() || keys == "(unbound)" {
2741 spans.push(Span::styled("(unbound)", muted_style));
2742 } else {
2743 for (i, chord) in keys.split(", ").enumerate() {
2744 if i > 0 {
2745 spans.push(Span::raw(" "));
2746 }
2747 spans.push(Span::styled(chord.to_string(), key_style));
2748 }
2749 }
2750 let pad = max_group_w.saturating_sub(badge_group_width(keys)) + 1;
2751 spans.push(Span::raw(" ".repeat(pad)));
2752 spans.push(Span::styled(label.to_string(), help_label_style(theme)));
2753 Line::from(spans)
2754}
2755
2756fn draw_help(f: &mut Frame, app: &mut App) {
2757 let area = centered(60, 60, f.area());
2758 let rows = help_rows(&app.keymap, &app.modal_keymap, app.pane_hint_context());
2762
2763 let accent = app.theme.accent;
2766
2767 let heading_style = Style::default().fg(accent).add_modifier(Modifier::BOLD);
2768 let subtitle_style = Style::default().fg(app.theme.branch).add_modifier(Modifier::ITALIC);
2772
2773 let max_group_w = rows
2776 .iter()
2777 .filter_map(|r| match r {
2778 HelpRow::Entry { keys, .. } => Some(badge_group_width(keys)),
2779 _ => None,
2780 })
2781 .max()
2782 .unwrap_or(0);
2783
2784 let mut header_lines: Vec<Line<'static>> = Vec::new();
2790 let mut body_lines: Vec<Line<'static>> = Vec::new();
2791 for row in rows {
2792 match row {
2793 HelpRow::Title(t) => header_lines.push(Line::from(Span::styled(t, heading_style)).centered()),
2795 HelpRow::Subtitle(t) => header_lines.push(Line::from(Span::styled(t, subtitle_style)).centered()),
2796 HelpRow::Section(t) => body_lines.push(Line::from(Span::styled(
2799 t,
2800 help_section_style(help_body_section_color(&app.theme)),
2801 ))),
2802 HelpRow::Blank => body_lines.push(Line::from(String::new())),
2803 HelpRow::Entry { keys, label } => {
2804 body_lines.push(help_entry_line(&keys, &label, max_group_w, &app.theme));
2805 }
2806 }
2807 }
2808
2809 let block = overlay_block(accent);
2810 let inner_area = block.inner(area);
2811 f.render_widget(Clear, area);
2812 f.render_widget(block, area);
2813
2814 let header_h = header_lines.len() as u16;
2818 let [header_area, body_area, footer_area] =
2819 Layout::vertical([Constraint::Length(header_h), Constraint::Min(1), Constraint::Length(1)]).areas(inner_area);
2820
2821 f.render_widget(Paragraph::new(header_lines), header_area);
2822
2823 let body_viewport = body_area.height as usize;
2827 app.help_max_scroll = (body_lines.len().saturating_sub(body_viewport)) as u16;
2828 app.help_scroll = app.help_scroll.min(app.help_max_scroll);
2829 let scroll = app.help_scroll;
2830 let text_area = scrollable_body_area(f, body_area, scroll, body_lines.len(), &app.theme);
2833 let content_width = body_lines.iter().map(Line::width).max().unwrap_or(0);
2834 app.help_max_x_scroll = content_width.saturating_sub(text_area.width as usize) as u16;
2835 app.help_x_scroll = app.help_x_scroll.min(app.help_max_x_scroll);
2836 let x_scroll = app.help_x_scroll;
2837 f.render_widget(Paragraph::new(body_lines).scroll((scroll, x_scroll)), text_area);
2838 f.render_widget(
2839 modal_hint_for_context(HintContext::Help, &app.keymap, &app.modal_keymap, &app.theme),
2840 footer_area,
2841 );
2842}
2843
2844fn draw_command_logs(f: &mut Frame, app: &mut App) {
2852 let area = centered(90, 85, f.area());
2853 let accent = app.theme.accent;
2854 let muted = app.theme.muted;
2855 let ok_color = app.theme.clean;
2856 let err_color = app.theme.prunable;
2857 let label_style = help_label_style(&app.theme);
2858 let muted_style = Style::default().fg(muted);
2859 let heading_style = Style::default().fg(accent).add_modifier(Modifier::BOLD);
2860
2861 let block = overlay_block(accent);
2864 let inner = block.inner(area);
2865 f.render_widget(Clear, area);
2866 f.render_widget(block, area);
2867
2868 let [header_area, body_area, footer_area] =
2869 Layout::vertical([Constraint::Length(1), Constraint::Min(1), Constraint::Length(1)]).areas(inner);
2870
2871 f.render_widget(
2872 Paragraph::new(Line::from(Span::styled("Command Logs", heading_style)).centered()),
2873 header_area,
2874 );
2875
2876 let rule = "-".repeat(body_area.width as usize);
2879 let mut lines: Vec<Line<'static>> = Vec::new();
2880
2881 if app.command_logs.entries.is_empty() {
2882 lines.push(Line::from(Span::styled("No commands run yet.", muted_style)));
2883 } else {
2884 for (i, entry) in app.command_logs.entries.iter().rev().enumerate() {
2887 if i > 0 {
2888 lines.push(Line::from(String::new()));
2889 lines.push(Line::from(Span::styled(rule.clone(), muted_style)));
2890 lines.push(Line::from(String::new()));
2891 }
2892 lines.push(Line::from(vec![
2894 Span::styled("$ ", Style::default().fg(accent).add_modifier(Modifier::BOLD)),
2895 Span::styled(entry.command.clone(), label_style),
2896 ]));
2897 let (color, detail) = match &entry.status {
2899 CommandStatus::Exited(Some(0)) => (ok_color, format!("→ exit 0 ({} ms)", entry.duration.as_millis())),
2900 CommandStatus::Exited(Some(code)) => (
2901 err_color,
2902 format!("→ exit {} ({} ms)", code, entry.duration.as_millis()),
2903 ),
2904 CommandStatus::Exited(None) => (err_color, format!("→ terminated ({} ms)", entry.duration.as_millis())),
2905 CommandStatus::Spawn => (err_color, "✗ failed to spawn".to_string()),
2906 };
2907 lines.push(Line::from(vec![
2908 Span::raw(" "),
2909 Span::styled(detail, Style::default().fg(color)),
2910 ]));
2911 if !entry.output.is_empty() {
2914 const MAX_OUTPUT_LINES: usize = 6;
2915 let out: Vec<&str> = entry.output.lines().collect();
2916 let start = out.len().saturating_sub(MAX_OUTPUT_LINES);
2917 if start > 0 {
2918 lines.push(Line::from(Span::styled(
2919 format!(" … {} earlier line(s)", start),
2920 muted_style,
2921 )));
2922 }
2923 for l in &out[start..] {
2924 lines.push(Line::from(Span::styled(format!(" {}", l), muted_style)));
2925 }
2926 }
2927 }
2928 }
2929
2930 let body_viewport = body_area.height as usize;
2932 app.command_logs.max_scroll = (lines.len().saturating_sub(body_viewport)) as u16;
2933 app.command_logs.scroll = app.command_logs.scroll.min(app.command_logs.max_scroll);
2934 let scroll = app.command_logs.scroll;
2935 let text_area = scrollable_body_area(f, body_area, scroll, lines.len(), &app.theme);
2937 let content_w = lines.iter().map(Line::width).max().unwrap_or(0);
2938 app.command_logs.max_x_scroll = content_w.saturating_sub(text_area.width as usize) as u16;
2939 app.command_logs.x_scroll = app.command_logs.x_scroll.min(app.command_logs.max_x_scroll);
2940 let x_scroll = app.command_logs.x_scroll;
2941 f.render_widget(Paragraph::new(lines).scroll((scroll, x_scroll)), text_area);
2942 let footer_owned = command_logs_footer_hints(&app.modal_keymap);
2945 let footer_hints: Vec<(&str, &str)> = footer_owned.iter().map(|(k, l)| (k.as_str(), l.as_str())).collect();
2946 f.render_widget(modal_hint_line(&footer_hints, &app.theme), footer_area);
2947}
2948
2949fn scrollable_body_area(f: &mut Frame, area: Rect, offset: u16, content_len: usize, theme: &Theme) -> Rect {
2955 let viewport = area.height as usize;
2956 if content_len <= viewport || area.width < 2 {
2957 return area;
2958 }
2959 let max_scroll = content_len - viewport;
2965 let mut state = ScrollbarState::new(max_scroll + 1)
2966 .position(offset as usize)
2967 .viewport_content_length(viewport);
2968 let bar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
2969 .begin_symbol(None)
2970 .end_symbol(None)
2971 .thumb_style(Style::default().fg(theme.accent))
2972 .track_style(Style::default().fg(theme.muted));
2973 f.render_stateful_widget(bar, area, &mut state);
2974 Rect {
2975 width: area.width.saturating_sub(1),
2976 ..area
2977 }
2978}
2979
2980fn settings_all_lines(app: &App) -> Vec<Line<'static>> {
2985 let accent = app.theme.accent;
2986 let muted = app.theme.muted;
2987 let label_style = help_label_style(&app.theme);
2988 let muted_style = Style::default().fg(muted);
2989 let mut lines: Vec<Line<'static>> = Vec::new();
2990
2991 if app.config_panel.rows.is_empty() {
2992 lines.push(Line::from(Span::styled("No configuration resolved.", muted_style)));
2993 return lines;
2994 }
2995 let mut current_section: Option<String> = None;
2996 for row in &app.config_panel.rows {
2997 let section = row.key.split(['.', '[']).next().unwrap_or("").to_string();
2998 if current_section.as_deref() != Some(section.as_str()) {
2999 if current_section.is_some() {
3000 lines.push(Line::from(String::new()));
3001 }
3002 lines.push(Line::from(Span::styled(
3003 format!("[{section}]"),
3004 help_section_style(accent),
3005 )));
3006 current_section = Some(section);
3007 }
3008 let src_color = match row.source {
3009 ConfigSource::Repo => app.theme.clean,
3010 ConfigSource::User => app.theme.branch,
3011 ConfigSource::Default => muted,
3012 };
3013 lines.push(Line::from(vec![
3014 Span::raw(" "),
3015 Span::styled(format!("{:<7}", row.source.label()), Style::default().fg(src_color)),
3016 Span::raw(" "),
3017 Span::styled(row.key.clone(), label_style),
3018 Span::styled(" = ", muted_style),
3019 Span::styled(row.value.clone(), Style::default().fg(Color::White)),
3020 ]));
3021 }
3022 lines
3023}
3024
3025fn settings_fields_lines(app: &App, fields: &[SettingField]) -> Vec<Line<'static>> {
3031 let accent = app.theme.accent;
3032 let muted = app.theme.muted;
3033 let label_style = help_label_style(&app.theme);
3034 let muted_style = Style::default().fg(muted);
3035 let panel = &app.config_panel;
3036 let mut lines: Vec<Line<'static>> = Vec::new();
3037
3038 for (i, field) in fields.iter().enumerate() {
3039 let selected = i == panel.selected;
3040 let editing = selected && panel.editing.is_some();
3041 let value = if editing {
3042 format!("{}_", panel.editing.as_deref().unwrap_or(""))
3043 } else {
3044 field.current(&app.config)
3045 };
3046 let marker = if selected { "›" } else { " " };
3047 let marker_style = Style::default().fg(accent).add_modifier(Modifier::BOLD);
3048 let value_style = if selected {
3049 Style::default().fg(accent).add_modifier(Modifier::BOLD)
3050 } else {
3051 Style::default().fg(Color::White)
3052 };
3053 let mut spans = vec![
3054 Span::styled(format!(" {marker} "), marker_style),
3055 Span::styled(format!("{:<24}", field.label()), label_style),
3056 Span::styled(value, value_style),
3057 ];
3058 if selected && panel.layer.source() == ConfigSource::User && panel.field_source(*field) == Some(ConfigSource::Repo)
3062 {
3063 spans.push(Span::styled(" — set in .gwm.toml; switch to Project", muted_style));
3064 }
3065 lines.push(Line::from(spans));
3066 }
3067 lines
3068}
3069
3070fn settings_keys_lines(app: &App) -> (Vec<Line<'static>>, Option<usize>) {
3078 let accent = app.theme.accent;
3079 let muted = app.theme.muted;
3080 let label_style = help_label_style(&app.theme);
3081 let muted_style = Style::default().fg(muted);
3082 let panel = &app.config_panel;
3083 let mut lines: Vec<Line<'static>> = Vec::new();
3084 let mut selected_line: Option<usize> = None;
3085
3086 if panel.key_rows.is_empty() {
3087 lines.push(Line::from(Span::styled("No bindings resolved.", muted_style)));
3088 return (lines, None);
3089 }
3090
3091 let mut current_scope: Option<String> = None;
3092 for (i, row) in panel.key_rows.iter().enumerate() {
3093 if current_scope.as_deref() != Some(row.scope.as_str()) {
3094 if current_scope.is_some() {
3095 lines.push(Line::from(String::new()));
3096 }
3097 lines.push(Line::from(Span::styled(
3098 format!("[{}]", row.scope),
3099 help_section_style(accent),
3100 )));
3101 current_scope = Some(row.scope.clone());
3102 }
3103
3104 let selected = i == panel.selected;
3105 if selected {
3106 selected_line = Some(lines.len());
3107 }
3108 let capturing = selected && panel.capture.is_some();
3109 let marker = if selected { "›" } else { " " };
3110 let marker_style = Style::default().fg(accent).add_modifier(Modifier::BOLD);
3111 let src_color = match row.source {
3112 ConfigSource::Repo => app.theme.clean,
3113 ConfigSource::User => app.theme.branch,
3114 ConfigSource::Default => muted,
3115 };
3116
3117 let key_span = if capturing {
3118 let pending = panel
3119 .capture
3120 .as_ref()
3121 .map(|c| c.pending.iter().map(|s| s.to_string()).collect::<Vec<_>>().join(" "))
3122 .unwrap_or_default();
3123 Span::styled(
3124 format!("[ {pending}_ ]"),
3125 Style::default().fg(accent).add_modifier(Modifier::BOLD),
3126 )
3127 } else {
3128 let shown = if row.keys.is_empty() {
3129 "(unbound)".to_string()
3130 } else {
3131 row.keys.clone()
3132 };
3133 let style = if row.keys.is_empty() {
3134 muted_style
3135 } else if selected {
3136 Style::default().fg(accent).add_modifier(Modifier::BOLD)
3137 } else {
3138 Style::default().fg(Color::White)
3139 };
3140 Span::styled(shown, style)
3141 };
3142
3143 lines.push(Line::from(vec![
3144 Span::styled(format!(" {marker} "), marker_style),
3145 Span::styled(format!("{:<7}", row.source.label()), Style::default().fg(src_color)),
3146 Span::raw(" "),
3147 Span::styled(format!("{:<24}", row.label), label_style),
3148 key_span,
3149 ]));
3150 }
3151 (lines, selected_line)
3152}
3153
3154fn draw_config_panel(f: &mut Frame, app: &mut App) {
3161 let area = centered(60, 60, f.area());
3162 let accent = app.theme.accent;
3163 let muted = app.theme.muted;
3164 let muted_style = Style::default().fg(muted);
3165 let heading_style = Style::default().fg(accent).add_modifier(Modifier::BOLD);
3166 let subtitle_style = Style::default().fg(app.theme.branch).add_modifier(Modifier::ITALIC);
3169
3170 let tab = app.config_panel.tab;
3171 let editing = app.config_panel.editing.is_some();
3172 let selected_kind = app.config_panel.selected_field().map(SettingField::kind);
3173
3174 let title = Line::from(Span::styled("Settings", heading_style)).centered();
3178 let subtitle = Line::from(Span::styled(app.config_panel.layer.label(), subtitle_style)).centered();
3179 let mut tab_spans: Vec<Span<'static>> = vec![Span::raw(" ")];
3180 for (i, t) in SettingsTab::ALL.iter().enumerate() {
3181 if i > 0 {
3182 tab_spans.push(Span::raw(" "));
3183 }
3184 let style = if *t == tab { chip_style(accent) } else { muted_style };
3185 tab_spans.push(Span::styled(format!(" {} ", t.label()), style));
3186 }
3187 let header_lines = vec![title, subtitle, Line::from(String::new()), Line::from(tab_spans)];
3188
3189 let mut keys_selected_line: Option<usize> = None;
3193 let body_lines = match tab {
3194 SettingsTab::All => settings_all_lines(app),
3195 SettingsTab::Keys => {
3196 let (lines, sel) = settings_keys_lines(app);
3197 keys_selected_line = sel;
3198 lines
3199 }
3200 other => settings_fields_lines(app, other.fields()),
3201 };
3202
3203 let capture_single = app.config_panel.capture.as_ref().map(|c| c.single_only);
3209 let footer_owned = if let Some(single) = capture_single {
3210 config_capture_footer_hints(&app.modal_keymap, single)
3211 } else if editing {
3212 config_edit_footer_hints(&app.modal_keymap)
3213 } else {
3214 config_nav_footer_hints(&app.modal_keymap, tab, selected_kind)
3215 };
3216 let footer_hints: Vec<(&str, &str)> = footer_owned.iter().map(|(k, l)| (k.as_str(), l.as_str())).collect();
3217
3218 let block = overlay_block(accent);
3219 let inner = block.inner(area);
3220 f.render_widget(Clear, area);
3221 f.render_widget(block, area);
3222
3223 let header_h = header_lines.len() as u16;
3224 let [header_area, body_area, footer_area] =
3225 Layout::vertical([Constraint::Length(header_h), Constraint::Min(1), Constraint::Length(1)]).areas(inner);
3226
3227 f.render_widget(Paragraph::new(header_lines), header_area);
3228
3229 let body_viewport = body_area.height as usize;
3231 app.config_panel.max_scroll = (body_lines.len().saturating_sub(body_viewport)) as u16;
3232 if let Some(sel) = keys_selected_line {
3235 let scroll = app.config_panel.scroll as usize;
3236 if sel < scroll {
3237 app.config_panel.scroll = sel as u16;
3238 } else if body_viewport > 0 && sel >= scroll + body_viewport {
3239 app.config_panel.scroll = (sel + 1 - body_viewport) as u16;
3240 }
3241 }
3242 app.config_panel.scroll = app.config_panel.scroll.min(app.config_panel.max_scroll);
3243 let scroll = app.config_panel.scroll;
3244 let text_area = scrollable_body_area(f, body_area, scroll, body_lines.len(), &app.theme);
3246 let content_w = body_lines.iter().map(Line::width).max().unwrap_or(0);
3247 app.config_panel.max_x_scroll = content_w.saturating_sub(text_area.width as usize) as u16;
3248 app.config_panel.x_scroll = app.config_panel.x_scroll.min(app.config_panel.max_x_scroll);
3249 let x_scroll = app.config_panel.x_scroll;
3250 f.render_widget(Paragraph::new(body_lines).scroll((scroll, x_scroll)), text_area);
3251 f.render_widget(modal_hint_line(&footer_hints, &app.theme), footer_area);
3252}
3253
3254fn draw_create(f: &mut Frame, app: &App) {
3255 let accent = app.theme.accent;
3256 let muted = app.theme.muted;
3257 let clean = app.theme.clean;
3258 let surface = app.theme.selection_bg;
3259
3260 let (type_str, type_desc) = app
3261 .branch_types
3262 .get(app.create_form.type_index)
3263 .map(|t| (t.name.as_str(), t.description.as_str()))
3264 .unwrap_or(("", "(no branch types configured)"));
3265
3266 let block = overlay_block(clean);
3267 let term = f.area();
3268 let outer = centered_box(70, 72, 1, term);
3269 let inner_w = block.inner(outer).width as usize;
3270
3271 let label_w = 5usize;
3274 let gutter = 2 + label_w + 2;
3275 let value_w = inner_w.saturating_sub(gutter);
3276
3277 let label = |s: &str| format!("{:<label_w$}", s);
3278 let branch = ellipsize_middle(
3279 &format!("{}/#{}-{}", type_str, app.create_form.issue, app.create_form.desc),
3280 inner_w.saturating_sub(" Branch : ".len()),
3281 );
3282 let dirname = ellipsize_middle(
3283 &format!("{}-{}-{}", type_str, app.create_form.issue, app.create_form.desc),
3284 inner_w.saturating_sub(" Dir : ".len()),
3285 );
3286
3287 let mut lines = overlay_title_lines("New Worktree", clean);
3288 lines.push(type_selector_line(
3292 &label("Type"),
3293 type_str,
3294 type_desc,
3295 app.create_form.field == Field::Type,
3296 accent,
3297 muted,
3298 ));
3299 lines.push(Line::from(String::new()));
3300 lines.push(Line::from(vec![
3301 Span::raw(" Branch : "),
3302 Span::styled(branch, Style::default().fg(app.theme.branch)),
3303 ]));
3304 lines.push(Line::from(vec![
3305 Span::raw(" Dir : "),
3306 Span::styled(dirname, Style::default().fg(app.theme.dirty)),
3307 ]));
3308 lines.push(Line::from(String::new()));
3309 lines.push(field_input_line(
3310 &label("Issue"),
3311 &app.create_form.issue,
3312 app.create_form.field == Field::Issue,
3313 value_w,
3314 accent,
3315 muted,
3316 surface,
3317 ));
3318 lines.push(Line::from(String::new()));
3319 lines.push(field_input_line(
3320 &label("Desc"),
3321 &app.create_form.desc,
3322 app.create_form.field == Field::Desc,
3323 value_w,
3324 accent,
3325 muted,
3326 surface,
3327 ));
3328
3329 let height = lines.len() as u16 + 4 + 2 + 2 ;
3330 let area = centered_box(70, 72, height, term);
3331 let inner = Layout::default()
3332 .direction(Direction::Vertical)
3333 .constraints([
3334 Constraint::Min(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), ])
3340 .split(block.inner(area));
3341
3342 f.render_widget(Clear, area);
3343 f.render_widget(block, area);
3344 f.render_widget(Paragraph::new(lines), inner[0]);
3345
3346 if app.is_create_worktree_loading() {
3347 f.render_widget(
3348 LoaderWidget::running(
3349 app.spinner.glyph(DOT_FRAMES),
3350 TaskKind::CreateWorktree.loading_label(),
3351 None,
3352 &app.theme,
3353 )
3354 .alignment(Alignment::Center),
3355 inner[1],
3356 );
3357 } else if let Some(error) = app.create_failure.as_deref() {
3358 f.render_widget(
3359 LoaderWidget::failed("create failed", Some(error), &app.theme).alignment(Alignment::Center),
3360 inner[1],
3361 );
3362 }
3363
3364 if !app.is_create_worktree_loading() {
3365 f.render_widget(
3366 Paragraph::new(create_buttons_line(accent, muted)).alignment(Alignment::Center),
3367 inner[2],
3368 );
3369 f.render_widget(
3370 Paragraph::new(modal_hint_for_context(
3371 HintContext::Create,
3372 &app.keymap,
3373 &app.modal_keymap,
3374 &app.theme,
3375 )),
3376 inner[4],
3377 );
3378 }
3379}
3380
3381pub fn create_buttons_line(accent: Color, muted: Color) -> Line<'static> {
3387 primary_cancel_buttons_line(" Create ", accent, muted)
3388}
3389
3390pub fn rename_buttons_line(accent: Color, muted: Color) -> Line<'static> {
3395 primary_cancel_buttons_line(" Rename ", accent, muted)
3396}
3397
3398fn primary_cancel_buttons_line(primary_label: &'static str, accent: Color, muted: Color) -> Line<'static> {
3399 let primary = chip_style(accent);
3400 let idle = Style::default().fg(muted).add_modifier(Modifier::BOLD);
3401 Line::from(vec![
3402 Span::styled(primary_label, primary),
3403 Span::raw(" "),
3404 Span::styled(" Cancel ", idle),
3405 ])
3406}
3407
3408pub fn type_selector_line(
3414 label: &str,
3415 name: &str,
3416 desc: &str,
3417 focused: bool,
3418 accent: Color,
3419 muted: Color,
3420) -> Line<'static> {
3421 let arrow_style = if focused {
3422 Style::default().fg(accent).add_modifier(Modifier::BOLD)
3423 } else {
3424 Style::default().fg(muted)
3425 };
3426 let name_style = if focused {
3430 chip_style(accent)
3431 } else {
3432 Style::default().fg(Color::White)
3433 };
3434 Line::from(vec![
3435 Span::raw(" "),
3436 Span::styled(label.to_string(), Style::default().fg(muted)),
3437 Span::raw(" "),
3438 Span::styled("‹ ", arrow_style),
3439 Span::styled(format!(" {name} "), name_style),
3440 Span::styled(" ›", arrow_style),
3441 Span::raw(" "),
3442 Span::styled(desc.to_string(), Style::default().fg(muted)),
3443 ])
3444}
3445
3446pub fn field_input_line(
3453 label: &str,
3454 value: &str,
3455 focused: bool,
3456 value_width: usize,
3457 accent: Color,
3458 muted: Color,
3459 surface: Color,
3460) -> Line<'static> {
3461 let cursor = if focused { "_" } else { "" };
3462 let mut field = format!(" {value}{cursor}");
3463 let len = field.chars().count();
3464 if len < value_width {
3465 field.push_str(&" ".repeat(value_width - len));
3466 }
3467 let field_style = if focused {
3468 Style::default().fg(Color::Black).bg(accent)
3469 } else {
3470 Style::default().fg(Color::White).bg(surface)
3471 };
3472 Line::from(vec![
3473 Span::raw(" "),
3474 Span::styled(label.to_string(), Style::default().fg(muted)),
3475 Span::raw(" "),
3476 Span::styled(field, field_style),
3477 ])
3478}
3479
3480pub fn link_target_line(key: &str, label: &str, selected: bool, accent: Color, muted: Color) -> Line<'static> {
3486 const BUTTON_WIDTH: usize = 17; let button = format!(" {key} {label} ");
3488 let button = format!("{button:<BUTTON_WIDTH$}");
3489 if selected {
3490 let chip = chip_style(accent);
3491 return Line::from(vec![Span::raw(" "), Span::styled(button, chip)]);
3492 }
3493
3494 let idle = Style::default().fg(muted);
3495 Line::from(vec![Span::raw(" "), Span::styled(button, idle)])
3496}
3497
3498pub fn link_prompt_modal_width(term_width: u16) -> u16 {
3501 let width = if term_width <= 80 {
3502 term_width.saturating_mul(80) / 100
3503 } else {
3504 term_width.saturating_mul(60) / 100
3505 };
3506 width.min(72).min(term_width)
3507}
3508
3509pub fn overlay_modal_width(term_width: u16) -> u16 {
3515 let pct = if term_width <= 80 { 90 } else { 62 };
3516 (term_width.saturating_mul(pct) / 100).clamp(48, 88).min(term_width)
3517}
3518
3519pub fn help_section_style(section: Color) -> Style {
3522 Style::default().fg(section).add_modifier(Modifier::BOLD)
3523}
3524
3525pub fn confirm_detail_line(
3527 label: &str,
3528 value: impl Into<String>,
3529 label_width: usize,
3530 label_color: Color,
3531 value_style: Style,
3532) -> Line<'static> {
3533 Line::from(vec![
3534 Span::styled(
3535 format!("{label:<label_width$} ", label_width = label_width),
3536 Style::default().fg(label_color),
3537 ),
3538 Span::styled(value.into(), value_style),
3539 ])
3540}
3541
3542pub fn delete_worktree_title() -> &'static str {
3543 "Delete Worktree"
3544}
3545
3546pub fn confirm_delete_branch_line(
3547 enabled: bool,
3548 key: &str,
3549 label_width: usize,
3550 accent: Color,
3551 muted: Color,
3552) -> Line<'static> {
3553 let key_style = chip_style(accent);
3554 let value_style = chip_style(if enabled { accent } else { muted });
3555 Line::from(vec![
3556 Span::styled(
3557 format!("{:<label_width$} ", "Delete Branch", label_width = label_width),
3558 Style::default().fg(muted),
3559 ),
3560 Span::styled(format!(" {key} "), key_style),
3561 Span::raw(" "),
3562 Span::styled(format!(" {enabled} "), value_style),
3563 ])
3564}
3565
3566pub fn help_body_section_color(theme: &Theme) -> Color {
3567 theme.locked
3568}
3569
3570pub fn link_target_keys(ctx: HintContext, modal: &ModalKeymap) -> (String, String) {
3576 let (issue, pr) = match ctx {
3577 HintContext::OpenMenu => (ModalAction::OpenMenuIssue, ModalAction::OpenMenuPr),
3578 _ => (ModalAction::LinkChooseIssue, ModalAction::LinkChoosePr),
3579 };
3580 (
3581 modal.primary_key(issue).unwrap_or_default(),
3582 modal.primary_key(pr).unwrap_or_default(),
3583 )
3584}
3585
3586pub fn link_open_modal_lines(app: &App, title: &str, selected: Option<LinkTarget>) -> Vec<Line<'static>> {
3587 let accent = app.theme.accent;
3588 let muted = app.theme.muted;
3589 let ctx = if title == "Link" {
3590 HintContext::LinkPrompt
3591 } else {
3592 HintContext::OpenMenu
3593 };
3594 let (issue_key, pr_key) = link_target_keys(ctx, &app.modal_keymap);
3597 let mut lines = overlay_title_lines(title, accent);
3598 lines.extend(github_status_lines(app, 56));
3599 lines.push(Line::from(""));
3600 lines.push(link_target_line(&issue_key, "Issue", selected == Some(LinkTarget::Issue), accent, muted).centered());
3601 lines.push(link_target_line(&pr_key, "Pull Request", selected == Some(LinkTarget::Pr), accent, muted).centered());
3602 push_modal_hint(&mut lines, ctx, &app.keymap, &app.modal_keymap, &app.theme);
3603 lines
3604}
3605
3606fn draw_confirm(f: &mut Frame, app: &App) {
3607 let muted = app.theme.muted;
3608 let danger = app.theme.prunable;
3612
3613 let block = overlay_block(danger);
3614
3615 let Some(w) = app.selected() else {
3616 let mut lines = overlay_title_lines(delete_worktree_title(), danger);
3617 lines.push(Line::from("nothing selected").centered());
3618 let height = lines.len() as u16 + 2 + 2 ;
3619 let area = centered_h(40, height, f.area());
3620 f.render_widget(Clear, area);
3621 f.render_widget(Paragraph::new(lines).block(block), area);
3622 return;
3623 };
3624
3625 let term = f.area();
3629 let outer_w = term.width.saturating_mul(62) / 100;
3630 let text_w = outer_w.saturating_sub(6) as usize;
3631 let label_w = "Delete Branch".chars().count();
3632 let value_w = text_w.saturating_sub(label_w + 2).max(1);
3633
3634 let name = ellipsize_middle(&w.name, value_w);
3635 let path = ellipsize_middle(&tilde_compress(&w.path.display().to_string()), value_w);
3636
3637 let mut content: Vec<Line> = overlay_title_lines(delete_worktree_title(), danger);
3640 content.push(confirm_detail_line(
3641 "Worktree",
3642 name,
3643 label_w,
3644 muted,
3645 Style::default().fg(app.theme.dirty).add_modifier(Modifier::BOLD),
3646 ));
3647 content.push(confirm_detail_line(
3648 "Path",
3649 path,
3650 label_w,
3651 muted,
3652 Style::default().fg(muted),
3653 ));
3654 if let Some(b) = &w.branch {
3655 let branch = ellipsize_middle(b, value_w);
3656 content.push(confirm_detail_line(
3657 "Branch",
3658 branch,
3659 label_w,
3660 muted,
3661 Style::default().fg(app.theme.branch),
3662 ));
3663 }
3664 content.push(Line::from(""));
3665 content.push(confirm_delete_branch_line(
3666 app.delete_branch_on_remove,
3667 &action_chord(&app.keymap, Action::ToggleDeleteBranch, "D"),
3670 label_w,
3671 app.theme.accent,
3672 muted,
3673 ));
3674
3675 let height = content.len() as u16 + 4 + 2 + 2 ;
3680 let area = centered_h(62, height, term);
3681 f.render_widget(Clear, area);
3682
3683 let inner = Layout::default()
3689 .direction(Direction::Vertical)
3690 .constraints([
3691 Constraint::Min(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), ])
3697 .split(block.inner(area));
3698 f.render_widget(block, area);
3699
3700 f.render_widget(Paragraph::new(content).wrap(Wrap { trim: false }), inner[0]);
3701
3702 if app.is_delete_worktree_loading() {
3704 f.render_widget(
3705 LoaderWidget::running(
3706 app.spinner.glyph(DOT_FRAMES),
3707 TaskKind::DeleteWorktree.loading_label(),
3708 None,
3709 &app.theme,
3710 )
3711 .alignment(Alignment::Center),
3712 inner[1],
3713 );
3714 } else if let Some(error) = app.delete_failure.as_deref() {
3715 f.render_widget(
3716 LoaderWidget::failed("delete failed", Some(error), &app.theme).alignment(Alignment::Center),
3717 inner[1],
3718 );
3719 } else if app.confirm_is_countdown_mode() && app.confirm.is_armed() {
3720 let now = Instant::now();
3721 let mut spans = vec![Span::styled(
3722 format!("{} ", app.spinner.glyph(DOT_FRAMES)),
3723 Style::default().fg(danger).add_modifier(Modifier::BOLD),
3724 )];
3725 spans.extend(countdown_bar(
3726 app.confirm_countdown_progress(now),
3727 app.confirm_countdown_remaining_secs(now),
3728 danger,
3729 app.theme.dirty,
3730 muted,
3731 ));
3732 f.render_widget(Paragraph::new(Line::from(spans)).alignment(Alignment::Center), inner[1]);
3733 }
3734
3735 if !app.is_delete_worktree_loading() {
3737 f.render_widget(
3738 Paragraph::new(confirm_buttons_line(
3739 app.confirm.focused_button(),
3740 app.theme.accent,
3741 muted,
3742 ))
3743 .alignment(Alignment::Center),
3744 inner[2],
3745 );
3746
3747 f.render_widget(
3748 Paragraph::new(modal_hint_for_context(
3749 HintContext::Confirm,
3750 &app.keymap,
3751 &app.modal_keymap,
3752 &app.theme,
3753 )),
3754 inner[4],
3755 );
3756 }
3757}
3758
3759pub fn confirm_buttons_line(focus: ConfirmButton, accent: Color, muted: Color) -> Line<'static> {
3767 let focused = chip_style(accent);
3768 let idle = Style::default().fg(muted).add_modifier(Modifier::BOLD);
3769 let (confirm_style, cancel_style) = match focus {
3770 ConfirmButton::Confirm => (focused, idle),
3771 ConfirmButton::Cancel => (idle, focused),
3772 };
3773 Line::from(vec![
3774 Span::styled(" Confirm ", confirm_style),
3775 Span::raw(" "),
3776 Span::styled(" Cancel ", cancel_style),
3777 ])
3778}
3779
3780fn countdown_bar<'a>(
3787 progress: f64,
3788 remaining_secs: u64,
3789 filled_color: Color,
3790 secs_color: Color,
3791 frame_color: Color,
3792) -> Vec<Span<'a>> {
3793 const CELLS: usize = 10;
3794 let filled = filled_cells_for_progress(progress, CELLS);
3795 let bar: String = std::iter::repeat_n('█', filled)
3796 .chain(std::iter::repeat_n('░', CELLS - filled))
3797 .collect();
3798 vec![
3799 Span::styled(" [", Style::default().fg(frame_color)),
3800 Span::styled(bar, Style::default().fg(filled_color).add_modifier(Modifier::BOLD)),
3801 Span::styled("] ", Style::default().fg(frame_color)),
3802 Span::styled(
3803 format!("{remaining_secs}s"),
3804 Style::default().fg(secs_color).add_modifier(Modifier::BOLD),
3805 ),
3806 ]
3807}
3808
3809pub fn filled_cells_for_progress(progress: f64, cells: usize) -> usize {
3824 if progress >= 1.0 {
3825 return cells;
3826 }
3827 if progress <= 0.0 || cells == 0 {
3828 return 0;
3829 }
3830 let raw = (progress * cells as f64).floor() as usize;
3831 raw.min(cells.saturating_sub(1))
3833}
3834
3835pub fn bootstrap_report_lines(report: Option<&BootstrapReport>, theme: &Theme) -> Vec<Line<'static>> {
3836 let mut lines: Vec<Line<'static>> = Vec::new();
3837 if let Some(report) = report {
3838 for step in &report.steps {
3839 let sigil = step.status.sigil();
3840 let color = match step.status {
3841 StepStatus::Ok => theme.clean,
3842 StepStatus::Skipped => theme.muted,
3843 StepStatus::Warning => theme.dirty,
3844 StepStatus::Failed => theme.prunable,
3845 };
3846 lines.push(Line::from(vec![
3847 Span::styled(
3848 format!(" {} ", sigil),
3849 Style::default().fg(color).add_modifier(Modifier::BOLD),
3850 ),
3851 Span::styled(step.label.clone(), Style::default().fg(theme.name)),
3852 ]));
3853 for detail_line in step.detail.lines() {
3854 lines.push(Line::from(Span::styled(
3855 format!(" {}", detail_line),
3856 Style::default().fg(theme.muted),
3857 )));
3858 }
3859 }
3860 } else {
3861 lines.push(Line::from("(no report)"));
3862 }
3863 lines
3864}
3865
3866fn draw_report(f: &mut Frame, app: &App) {
3867 let accent = app.theme.accent;
3868 let logs = bootstrap_report_lines(app.report.as_ref(), &app.theme);
3869
3870 let term = f.area();
3874 let logs_height = (logs.len() as u16 + 2).max(3);
3875 let height = (2 + logs_height + 2 + 2 + 2)
3876 .min(term.height.saturating_mul(80) / 100);
3877 let area = centered_h(80, height, term);
3878 let block = overlay_block(accent);
3879 let inner = block.inner(area);
3880 let layout = Layout::default()
3881 .direction(Direction::Vertical)
3882 .constraints([
3883 Constraint::Length(1), Constraint::Length(1), Constraint::Min(3), Constraint::Length(1), Constraint::Length(1), ])
3889 .split(inner);
3890 f.render_widget(Clear, area);
3891 f.render_widget(block, area);
3892 f.render_widget(
3893 Paragraph::new(
3894 Line::from(Span::styled(
3895 "Bootstrap Report",
3896 Style::default().fg(accent).add_modifier(Modifier::BOLD),
3897 ))
3898 .centered(),
3899 ),
3900 layout[0],
3901 );
3902 render_section(f, layout[2], " Logs ", SectionBody::new(&logs), accent, 0, None);
3903 f.render_widget(
3904 Paragraph::new(modal_hint_for_context(
3905 HintContext::Report,
3906 &app.keymap,
3907 &app.modal_keymap,
3908 &app.theme,
3909 )),
3910 layout[4],
3911 );
3912}
3913
3914fn draw_pty_overlay(f: &mut Frame, app: &mut App) {
3921 let term = f.area();
3922 let area = centered(90, 90, term);
3923
3924 f.render_widget(Clear, area);
3925
3926 let title = match app.pty_overlay.as_ref().map(|p| (p.kind, p.finished)) {
3927 Some((PtyKind::LazyGit, _)) => " LazyGit ",
3928 Some((PtyKind::Terminal, _)) => " Terminal ",
3929 Some((PtyKind::Review, _)) => " Review ",
3930 Some((PtyKind::Exec, false)) => " Exec ",
3931 Some((PtyKind::Exec, true)) => " Exec · done — press any key ",
3933 None => " Overlay ",
3934 };
3935 let block = overlay_block(app.theme.accent)
3936 .title(title)
3937 .title_alignment(ratatui::layout::Alignment::Center);
3938 let inner = block.inner(area);
3939 f.render_widget(block, area);
3940
3941 if let Some(pty) = app.pty_overlay.as_ref() {
3942 let pseudo_terminal = tui_term::widget::PseudoTerminal::new(pty.parser.screen());
3943 f.render_widget(pseudo_terminal, inner);
3944 }
3945}
3946
3947fn centered(pct_x: u16, pct_y: u16, area: Rect) -> Rect {
3948 let v = Layout::default()
3949 .direction(Direction::Vertical)
3950 .constraints([
3951 Constraint::Percentage((100 - pct_y) / 2),
3952 Constraint::Percentage(pct_y),
3953 Constraint::Percentage((100 - pct_y) / 2),
3954 ])
3955 .split(area);
3956 Layout::default()
3957 .direction(Direction::Horizontal)
3958 .constraints([
3959 Constraint::Percentage((100 - pct_x) / 2),
3960 Constraint::Percentage(pct_x),
3961 Constraint::Percentage((100 - pct_x) / 2),
3962 ])
3963 .split(v[1])[1]
3964}
3965
3966pub fn centered_abs(width: u16, height: u16, area: Rect) -> Rect {
3971 let width = width.min(area.width);
3972 let height = height.min(area.height);
3973 let x = area.x + area.width.saturating_sub(width) / 2;
3974 let y = area.y + area.height.saturating_sub(height) / 2;
3975 Rect { x, y, width, height }
3976}
3977
3978fn centered_h(width_pct: u16, height: u16, area: Rect) -> Rect {
3984 let width = area.width.saturating_mul(width_pct) / 100;
3985 centered_abs(width, height, area)
3986}
3987
3988fn centered_box(width_pct: u16, max_width: u16, height: u16, area: Rect) -> Rect {
3992 let height = height.min(area.height);
3993 let width = (area.width.saturating_mul(width_pct) / 100)
3994 .min(max_width)
3995 .min(area.width);
3996 let x = area.x + area.width.saturating_sub(width) / 2;
3997 let y = area.y + area.height.saturating_sub(height) / 2;
3998 Rect { x, y, width, height }
3999}
4000
4001fn overlay_block(color: Color) -> Block<'static> {
4011 Block::default()
4012 .borders(Borders::ALL)
4013 .border_type(BorderType::Rounded)
4014 .padding(Padding::symmetric(2, 1))
4015 .border_style(Style::default().fg(color))
4016}
4017
4018fn overlay_title_lines(title: &str, color: Color) -> Vec<Line<'static>> {
4023 vec![
4024 Line::from(Span::styled(
4025 title.to_string(),
4026 Style::default().fg(color).add_modifier(Modifier::BOLD),
4027 ))
4028 .centered(),
4029 Line::from(String::new()),
4030 ]
4031}
4032
4033pub fn ellipsize_middle(s: &str, max: usize) -> String {
4040 let count = s.chars().count();
4041 if count <= max {
4042 return s.to_string();
4043 }
4044 if max <= 1 {
4045 return "…".to_string();
4046 }
4047 let keep = max - 1; let head = keep.div_ceil(2);
4049 let tail = keep - head;
4050 let head_str: String = s.chars().take(head).collect();
4051 let tail_str: String = s.chars().skip(count - tail).collect();
4052 format!("{head_str}…{tail_str}")
4053}
4054
4055fn trunc(s: &str, max: usize) -> String {
4056 if s.chars().count() <= max {
4057 s.to_string()
4058 } else {
4059 let mut out: String = s.chars().take(max.saturating_sub(1)).collect();
4060 out.push('…');
4061 out
4062 }
4063}
4064
4065fn draw_open_menu(f: &mut Frame, app: &App) {
4068 let accent = app.theme.accent;
4069 let lines = link_open_modal_lines(app, "Open in Browser", Some(app.open_menu_selected));
4070 let height = lines.len() as u16 + 2 + 2 ;
4071 let term = f.area();
4072 let width = link_prompt_modal_width(term.width);
4073 let area = centered_abs(width, height, term);
4074 f.render_widget(Clear, area);
4075 f.render_widget(Paragraph::new(lines).block(overlay_block(accent)), area);
4076}
4077
4078fn draw_link_prompt(f: &mut Frame, app: &App) {
4079 let accent = app.theme.accent;
4080 let lines = match app.link_prompt_stage() {
4081 LinkPromptStage::ChooseTarget => {
4082 let selected = app.link_prompt_selected();
4086 link_open_modal_lines(app, "Link", Some(selected))
4087 }
4088 LinkPromptStage::InputNumber => {
4089 let label = match app.link_prompt_target() {
4090 Some(super::app::LinkTarget::Issue) => "issue #",
4091 Some(super::app::LinkTarget::Pr) => "PR #",
4092 None => "#",
4093 };
4094 let mut lines = overlay_title_lines(
4095 &format!("type the {} number", label.trim_end_matches('#').trim()),
4096 accent,
4097 );
4098 lines.push(Line::from(format!(" {}{}_", label, app.link_prompt_number_input())));
4099 push_modal_hint(
4100 &mut lines,
4101 HintContext::LinkInputNumber,
4102 &app.keymap,
4103 &app.modal_keymap,
4104 &app.theme,
4105 );
4106 lines
4107 }
4108 };
4109 let height = lines.len() as u16 + 2 + 2 ;
4110 let term = f.area();
4111 let width = link_prompt_modal_width(term.width);
4112 let area = centered_abs(width, height, term);
4113 f.render_widget(Clear, area);
4114 f.render_widget(Paragraph::new(lines).block(overlay_block(accent)), area);
4115}
4116
4117pub fn reclaim_size_color(bytes: u64, theme: &Theme) -> Color {
4121 const MIB: u64 = 1024 * 1024;
4122 if bytes >= 500 * MIB {
4123 theme.prunable
4124 } else if bytes >= 50 * MIB {
4125 theme.dirty
4126 } else {
4127 theme.clean
4128 }
4129}
4130
4131pub fn clean_dir_icon(rel: &str) -> &'static str {
4137 match rel.trim_start_matches('.').to_ascii_lowercase().as_str() {
4138 "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, }
4148}
4149
4150pub fn picker_window(len: usize, selected: usize, max_visible: usize) -> (usize, usize) {
4154 if max_visible == 0 || len <= max_visible {
4155 return (0, len);
4156 }
4157 let half = max_visible / 2;
4158 let start = selected.saturating_sub(half).min(len - max_visible);
4159 (start, start + max_visible)
4160}
4161
4162fn picker_lines(
4168 labels: &[&str],
4169 selected: usize,
4170 max_visible: usize,
4171 inner: usize,
4172 theme: &Theme,
4173) -> Vec<Line<'static>> {
4174 let mut out = Vec::new();
4175 if labels.is_empty() {
4176 return out;
4177 }
4178 let textw = inner.saturating_sub(3);
4180 let (start, end) = picker_window(labels.len(), selected, max_visible);
4181 if start > 0 {
4182 out.push(
4183 Line::from(Span::styled(
4184 format!("↑ {start} more"),
4185 Style::default().fg(theme.muted),
4186 ))
4187 .centered(),
4188 );
4189 }
4190 for (i, label) in labels.iter().enumerate().take(end).skip(start) {
4191 let marker = if i == selected { "▸" } else { " " };
4192 let txt = format!(" {marker} {:<textw$}", ellipsize_middle(label, textw));
4194 let style = if i == selected {
4195 Style::default()
4196 .fg(theme.accent)
4197 .bg(theme.selection_bg)
4198 .add_modifier(Modifier::BOLD)
4199 } else {
4200 Style::default().fg(theme.muted)
4201 };
4202 out.push(Line::from(Span::styled(txt, style)));
4203 }
4204 if end < labels.len() {
4205 out.push(
4206 Line::from(Span::styled(
4207 format!("↓ {} more", labels.len() - end),
4208 Style::default().fg(theme.muted),
4209 ))
4210 .centered(),
4211 );
4212 }
4213 out
4214}
4215
4216fn draw_exec_picker(f: &mut Frame, app: &App) {
4222 let accent = app.theme.accent;
4223 let term = f.area();
4224 let width = overlay_modal_width(term.width);
4225 let inner = width.saturating_sub(6) as usize; let mut lines = overlay_title_lines("Run an exec profile", accent);
4227 let max_visible = (term.height as usize).saturating_sub(8).max(3);
4229 let labels: Vec<&str> = app.exec_picker.profiles().iter().map(String::as_str).collect();
4230 lines.extend(picker_lines(
4231 &labels,
4232 app.exec_picker.selected_index(),
4233 max_visible,
4234 inner,
4235 &app.theme,
4236 ));
4237 push_modal_hint(
4238 &mut lines,
4239 HintContext::ExecPicker,
4240 &app.keymap,
4241 &app.modal_keymap,
4242 &app.theme,
4243 );
4244 let height = lines.len() as u16 + 2 + 2 ;
4245 let area = centered_abs(width, height, term);
4246 f.render_widget(Clear, area);
4247 f.render_widget(Paragraph::new(lines).block(overlay_block(accent)), area);
4248}
4249
4250fn draw_clean_overlay(f: &mut Frame, app: &App) {
4257 let accent = app.theme.accent;
4258 let muted = app.theme.muted;
4259 let danger = app.theme.prunable;
4260 let armed = app.clean_overlay.confirm.is_armed();
4261 let border = if armed { danger } else { accent };
4262 let term = f.area();
4263 let width = overlay_modal_width(term.width);
4264 let inner = width.saturating_sub(6) as usize; let mut lines = overlay_title_lines("Reclaim build artifacts", border);
4267
4268 if app.clean_overlay.has_profiles() {
4271 let labels = app.clean_overlay.choice_labels();
4272 let max_visible = (term.height as usize).saturating_sub(14).max(3);
4273 lines.extend(picker_lines(
4274 &labels,
4275 app.clean_overlay.selected_index(),
4276 max_visible,
4277 inner,
4278 &app.theme,
4279 ));
4280 lines.push(Line::from(""));
4281 }
4282
4283 match app.clean_overlay.reclaim() {
4289 Some(reclaim) if !reclaim.artifacts.is_empty() => {
4290 let namew = inner.saturating_sub(15).max(5);
4293 let row = |icon: &str, left: &str, left_style: Style, bytes: u64, size_style: Style| -> Line<'static> {
4294 Line::from(vec![
4295 Span::styled(format!(" {icon} "), Style::default().fg(accent)),
4296 Span::styled(format!("{:<namew$}", ellipsize_middle(left, namew)), left_style),
4297 Span::styled(format!("{:>10} ", crate::clean::human_size(bytes)), size_style),
4298 ])
4299 };
4300 let max_rows = (term.height as usize).saturating_sub(14).max(3);
4301 let shown = reclaim.artifacts.len().min(max_rows);
4302 for a in reclaim.artifacts.iter().take(shown) {
4303 lines.push(row(
4304 clean_dir_icon(&a.rel),
4305 &a.rel,
4306 Style::default().fg(muted),
4307 a.bytes,
4308 Style::default().fg(reclaim_size_color(a.bytes, &app.theme)),
4309 ));
4310 }
4311 if reclaim.artifacts.len() > shown {
4312 lines.push(
4313 Line::from(Span::styled(
4314 format!("… {} more", reclaim.artifacts.len() - shown),
4315 Style::default().fg(muted),
4316 ))
4317 .centered(),
4318 );
4319 }
4320 lines.push(row(
4322 "\u{f03a}",
4323 "total",
4324 Style::default().fg(accent).add_modifier(Modifier::BOLD),
4325 reclaim.total_bytes,
4326 Style::default()
4327 .fg(reclaim_size_color(reclaim.total_bytes, &app.theme))
4328 .add_modifier(Modifier::BOLD),
4329 ));
4330 }
4331 _ => {
4332 lines.push(
4333 Line::from("nothing to reclaim")
4334 .style(Style::default().fg(muted))
4335 .centered(),
4336 );
4337 }
4338 }
4339
4340 for rel in app.clean_overlay.skipped() {
4342 lines.push(
4343 Line::from(format!("skipped {rel} — not git-ignored / holds tracked files"))
4344 .style(Style::default().fg(muted))
4345 .centered(),
4346 );
4347 }
4348
4349 if armed {
4352 lines.push(Line::from(""));
4353 lines.push(
4354 Line::from("⚠ armed — confirm again or cancel to abort")
4355 .style(Style::default().fg(danger).add_modifier(Modifier::BOLD))
4356 .centered(),
4357 );
4358 }
4359
4360 push_modal_hint(
4361 &mut lines,
4362 HintContext::Clean,
4363 &app.keymap,
4364 &app.modal_keymap,
4365 &app.theme,
4366 );
4367 let height = lines.len() as u16 + 2 + 2 ;
4368 let area = centered_abs(width, height, term);
4369 f.render_widget(Clear, area);
4370 f.render_widget(Paragraph::new(lines).block(overlay_block(border)), area);
4371}
4372
4373fn draw_edit_worktree(f: &mut Frame, app: &App) {
4387 let accent = app.theme.accent;
4388 let muted = app.theme.muted;
4389 let clean = app.theme.clean;
4390 let surface = app.theme.selection_bg;
4391
4392 let (type_str, type_desc) = app
4393 .branch_types
4394 .get(app.create_form.type_index)
4395 .map(|t| (t.name.as_str(), t.description.as_str()))
4396 .unwrap_or(("", "(no branch types configured)"));
4397
4398 let block = overlay_block(clean);
4399 let term = f.area();
4400 let outer = centered_box(70, 72, 1, term);
4401 let inner_w = block.inner(outer).width as usize;
4402 let label_w = 5usize;
4403 let gutter = 2 + label_w + 2;
4404 let value_w = inner_w.saturating_sub(gutter);
4405
4406 let label = |s: &str| format!("{:<label_w$}", s);
4407 let old_branch = app
4408 .edit_original_branch
4409 .as_deref()
4410 .or_else(|| app.selected().and_then(|w| w.branch.as_deref()))
4411 .unwrap_or("(none)");
4412 let old_display = ellipsize_middle(old_branch, inner_w.saturating_sub(" From : ".len()));
4413 let branch = ellipsize_middle(
4414 &format!("{}/#{}-{}", type_str, app.create_form.issue, app.create_form.desc),
4415 inner_w.saturating_sub(" Branch : ".len()),
4416 );
4417 let dirname = ellipsize_middle(
4418 &format!("{}-{}-{}", type_str, app.create_form.issue, app.create_form.desc),
4419 inner_w.saturating_sub(" Dir : ".len()),
4420 );
4421
4422 let mut lines = overlay_title_lines("Rename Worktree", clean);
4423 lines.push(Line::from(vec![
4424 Span::raw(" From : "),
4425 Span::styled(old_display, Style::default().fg(muted)),
4426 ]));
4427 lines.push(Line::from(String::new()));
4428 lines.push(type_selector_line(
4429 &label("Type"),
4430 type_str,
4431 type_desc,
4432 app.create_form.field == Field::Type,
4433 accent,
4434 muted,
4435 ));
4436 lines.push(Line::from(String::new()));
4437 lines.push(Line::from(vec![
4438 Span::raw(" Branch : "),
4439 Span::styled(branch, Style::default().fg(app.theme.branch)),
4440 ]));
4441 lines.push(Line::from(vec![
4442 Span::raw(" Dir : "),
4443 Span::styled(dirname, Style::default().fg(app.theme.dirty)),
4444 ]));
4445 lines.push(Line::from(String::new()));
4446 lines.push(field_input_line(
4447 &label("Issue"),
4448 &app.create_form.issue,
4449 app.create_form.field == Field::Issue,
4450 value_w,
4451 accent,
4452 muted,
4453 surface,
4454 ));
4455 lines.push(Line::from(String::new()));
4456 lines.push(field_input_line(
4457 &label("Desc"),
4458 &app.create_form.desc,
4459 app.create_form.field == Field::Desc,
4460 value_w,
4461 accent,
4462 muted,
4463 surface,
4464 ));
4465
4466 let height = lines.len() as u16 + 4 + 2 + 2 ;
4467 let area = centered_box(70, 72, height, term);
4468 let inner = Layout::default()
4469 .direction(Direction::Vertical)
4470 .constraints([
4471 Constraint::Min(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), ])
4477 .split(block.inner(area));
4478
4479 f.render_widget(Clear, area);
4480 f.render_widget(block, area);
4481 f.render_widget(Paragraph::new(lines), inner[0]);
4482
4483 if app.is_edit_worktree_loading() {
4484 f.render_widget(
4485 LoaderWidget::running(
4486 app.spinner.glyph(DOT_FRAMES),
4487 TaskKind::EditWorktree.loading_label(),
4488 None,
4489 &app.theme,
4490 )
4491 .alignment(Alignment::Center),
4492 inner[1],
4493 );
4494 } else if let Some(error) = app.edit_failure.as_deref() {
4495 f.render_widget(
4496 LoaderWidget::failed("rename failed", Some(error), &app.theme).alignment(Alignment::Center),
4497 inner[1],
4498 );
4499 }
4500
4501 if !app.is_edit_worktree_loading() {
4502 f.render_widget(
4503 Paragraph::new(rename_buttons_line(accent, muted)).alignment(Alignment::Center),
4504 inner[2],
4505 );
4506 f.render_widget(
4507 Paragraph::new(modal_hint_for_context(
4508 HintContext::Rename,
4509 &app.keymap,
4510 &app.modal_keymap,
4511 &app.theme,
4512 )),
4513 inner[4],
4514 );
4515 }
4516}
4517
4518fn draw_command_palette(f: &mut Frame, app: &App) {
4519 let area = centered(60, 50, f.area());
4520 f.render_widget(Clear, area);
4521
4522 let accent = app.theme.accent;
4523 let outer = overlay_block(accent);
4524 let inner = outer.inner(area);
4525 f.render_widget(outer, area);
4526
4527 let layout = Layout::default()
4533 .direction(Direction::Vertical)
4534 .constraints([
4535 Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Min(3), Constraint::Length(1), Constraint::Length(1), ])
4543 .split(inner);
4544
4545 f.render_widget(
4546 Paragraph::new(
4547 Line::from(Span::styled(
4548 "Command Palette",
4549 Style::default().fg(accent).add_modifier(Modifier::BOLD),
4550 ))
4551 .centered(),
4552 ),
4553 layout[0],
4554 );
4555
4556 let label = ":";
4560 let gutter = 2 + label.chars().count() + 2; let value_w = (inner.width as usize).saturating_sub(gutter);
4562 f.render_widget(
4563 Paragraph::new(field_input_line(
4564 label,
4565 app.palette.buffer(),
4566 true,
4567 value_w,
4568 accent,
4569 app.theme.muted,
4570 app.theme.selection_bg,
4571 )),
4572 layout[2],
4573 );
4574
4575 let entries = app.palette.matches();
4576 let highlight = app.palette.highlight();
4577 let mut lines: Vec<Line<'_>> = entries
4578 .iter()
4579 .enumerate()
4580 .map(|(i, entry)| {
4581 let prefix = if i == highlight { "▶ " } else { " " };
4582 let name_style = if i == highlight {
4583 Style::default().fg(accent).add_modifier(Modifier::BOLD)
4584 } else {
4585 palette_name_style(&app.theme)
4586 };
4587 Line::from(vec![
4588 Span::raw(prefix),
4589 Span::styled(format!("{:<22}", entry.name), name_style),
4590 Span::raw(" "),
4591 Span::styled(entry.description, Style::default().fg(app.theme.muted)),
4592 ])
4593 })
4594 .collect();
4595 if lines.is_empty() {
4596 lines.push(Line::from(Span::styled(
4597 " (no matching command — backspace to broaden)",
4598 Style::default().fg(app.theme.prunable),
4599 )));
4600 }
4601 f.render_widget(Paragraph::new(lines), layout[4]);
4602 f.render_widget(
4603 Paragraph::new(modal_hint_for_context(
4604 HintContext::CommandPalette,
4605 &app.keymap,
4606 &app.modal_keymap,
4607 &app.theme,
4608 )),
4609 layout[6],
4610 );
4611}
4612
4613pub fn github_status_lines(app: &App, max_width: usize) -> Vec<Line<'static>> {
4620 let link = app.current_link();
4621 let mut lines: Vec<Line<'static>> = Vec::new();
4622
4623 if link.issue.is_none() && link.pr.is_none() {
4624 let chord = action_chord(&app.keymap, Action::LinkPrompt, "i");
4628 lines.push(Line::from(Span::styled(
4629 trunc(&format!("no link · press {chord} to link"), max_width),
4630 Style::default().fg(app.theme.muted),
4631 )));
4632 return lines;
4633 }
4634
4635 if let Some(n) = link.issue {
4636 let spinner = app.spinner.glyph(DOT_FRAMES);
4637 lines.push(issue_summary_line_with_spinner(
4638 n,
4639 link.issue_source,
4640 app.issue_fetch_state(),
4641 PersistedSummary {
4642 title: link.issue_title.as_deref(),
4643 state: link.issue_state,
4644 },
4645 max_width,
4646 &app.theme,
4647 Some(spinner),
4648 ));
4649 }
4650 if let Some(n) = link.pr {
4651 let spinner = app.spinner.glyph(DOT_FRAMES);
4652 lines.push(pr_summary_line_with_spinner(
4653 n,
4654 link.pr_source,
4655 app.pr_fetch_state(),
4656 PersistedSummary {
4657 title: link.pr_title.as_deref(),
4658 state: link.pr_state,
4659 },
4660 max_width,
4661 &app.theme,
4662 Some(spinner),
4663 ));
4664 }
4665 lines
4666}
4667
4668pub const ISSUE_ICON: &str = "\u{f41b}";
4671pub const PR_ICON: &str = "\u{f407}";
4674
4675pub const CI_PASSING_ICON: &str = "\u{f42e}";
4678pub const CI_FAILING_ICON: &str = "\u{f467}";
4679pub const CI_RUNNING_ICON: &str = "\u{f46a}";
4680
4681fn source_chip(s: LinkSource, theme: &Theme) -> Option<(&'static str, Color)> {
4687 match s {
4688 LinkSource::BranchName => Some(("auto", theme.muted)),
4689 LinkSource::Detected => Some(("detected", theme.accent)),
4690 LinkSource::Explicit | LinkSource::None => None,
4691 }
4692}
4693
4694fn flatten_if_overflow(spans: &mut Vec<Span<'static>>, max_width: usize) {
4700 let w: usize = spans.iter().map(|s| s.content.chars().count()).sum();
4701 if w > max_width {
4702 let raw: String = spans.iter().map(|s| s.content.as_ref()).collect();
4703 *spans = vec![Span::raw(trunc(&raw, max_width))];
4704 }
4705}
4706
4707pub fn issue_summary_line(
4713 n: u64,
4714 src: LinkSource,
4715 state: &GitHubFetchState<crate::github::IssueStatus>,
4716 max_width: usize,
4717 theme: &Theme,
4718) -> Line<'static> {
4719 issue_summary_line_with_spinner(n, src, state, PersistedSummary::none(), max_width, theme, None)
4720}
4721
4722#[derive(Clone, Copy)]
4723struct PersistedSummary<'a, S> {
4724 title: Option<&'a str>,
4725 state: Option<S>,
4726}
4727
4728impl<S> PersistedSummary<'_, S> {
4729 fn none() -> Self {
4730 Self {
4731 title: None,
4732 state: None,
4733 }
4734 }
4735}
4736
4737enum SummaryState<'a> {
4743 Idle,
4744 CachedTitle {
4745 title: &'a str,
4746 },
4747 CachedStatus {
4748 badge: &'a str,
4749 badge_color: Color,
4750 trailing: String,
4751 trailing_color: Option<Color>,
4754 title: &'a str,
4755 },
4756 Loading,
4757 Loaded {
4758 badge: &'a str,
4759 badge_color: Color,
4760 trailing: String,
4761 trailing_color: Option<Color>,
4763 title: &'a str,
4764 },
4765 Error(&'a str),
4766}
4767
4768fn trailing_span(trailing: String, color: Option<Color>) -> Span<'static> {
4783 match color {
4784 Some(c) => Span::styled(trailing, Style::default().fg(c)),
4785 None => Span::raw(trailing),
4786 }
4787}
4788
4789fn summary_line(
4790 icon: &str,
4791 head: String,
4792 source: LinkSource,
4793 state: SummaryState,
4794 max_width: usize,
4795 theme: &Theme,
4796 spinner: Option<&str>,
4797) -> Line<'static> {
4798 let icon_seg = format!("{} ", icon); let chip = source_chip(source, theme);
4803 let source_seg_w = chip.map(|(l, _)| 1 + l.chars().count() + 2).unwrap_or(0);
4805 let prefix_w = icon_seg.chars().count() + head.chars().count() + source_seg_w;
4806
4807 let icon_color = match &state {
4811 SummaryState::CachedStatus { badge_color, .. } | SummaryState::Loaded { badge_color, .. } => *badge_color,
4812 SummaryState::Idle | SummaryState::CachedTitle { .. } | SummaryState::Loading | SummaryState::Error(_) => {
4813 theme.muted
4814 }
4815 };
4816 let build_prefix = |head_bold: bool| -> Vec<Span<'static>> {
4817 let head_style = if head_bold {
4818 Style::default().fg(theme.name).add_modifier(Modifier::BOLD)
4819 } else {
4820 Style::default().fg(theme.name)
4821 };
4822 let mut spans = vec![
4823 Span::styled(icon_seg.clone(), Style::default().fg(icon_color)),
4824 Span::styled(head.clone(), head_style),
4825 ];
4826 if let Some((label, color)) = chip {
4827 spans.push(Span::raw(" "));
4828 spans.push(Span::styled(format!(" {} ", label), chip_style(color)));
4829 }
4830 spans
4831 };
4832
4833 match state {
4834 SummaryState::Idle => {
4835 let mut spans = build_prefix(false);
4836 flatten_if_overflow(&mut spans, max_width);
4837 Line::from(spans)
4838 }
4839 SummaryState::CachedTitle { title } => {
4840 let fixed = prefix_w + 1;
4841 let budget = max_width.saturating_sub(fixed);
4842 let mut spans = build_prefix(false);
4843 spans.push(Span::raw(" "));
4844 spans.push(Span::raw(trunc(title, budget)));
4845 flatten_if_overflow(&mut spans, max_width);
4846 Line::from(spans)
4847 }
4848 SummaryState::CachedStatus {
4849 badge,
4850 badge_color,
4851 trailing,
4852 trailing_color,
4853 title,
4854 } => {
4855 let badge_seg_w = 1 + badge.chars().count() + 2;
4856 let fixed = prefix_w + badge_seg_w + trailing.chars().count() + 1;
4857 if fixed >= max_width {
4858 let mut spans = build_prefix(true);
4859 spans.push(Span::raw(" "));
4860 spans.push(Span::raw(format!(" {} ", badge)));
4861 spans.push(trailing_span(trailing, trailing_color));
4862 flatten_if_overflow(&mut spans, max_width);
4863 return Line::from(spans);
4864 }
4865 let budget = max_width - fixed;
4866 let mut spans = build_prefix(true);
4867 spans.push(Span::raw(" "));
4868 spans.push(Span::styled(format!(" {} ", badge), chip_style(badge_color)));
4869 spans.push(trailing_span(trailing, trailing_color));
4870 spans.push(Span::raw(" "));
4871 spans.push(Span::raw(trunc(title, budget)));
4872 Line::from(spans)
4873 }
4874 SummaryState::Loading => {
4875 let glyph = spinner.unwrap_or("…");
4876 let mut spans = build_prefix(false);
4877 spans.push(Span::raw(format!(" {} loading", glyph)));
4878 flatten_if_overflow(&mut spans, max_width);
4879 Line::from(spans)
4880 }
4881 SummaryState::Loaded {
4882 badge,
4883 badge_color,
4884 trailing,
4885 trailing_color,
4886 title,
4887 } => {
4888 let badge_seg_w = 1 + badge.chars().count() + 2;
4890 let fixed = prefix_w + badge_seg_w + trailing.chars().count() + 1;
4891 if fixed >= max_width {
4892 let mut spans = build_prefix(true);
4895 spans.push(Span::raw(" "));
4896 spans.push(Span::raw(format!(" {} ", badge)));
4897 spans.push(trailing_span(trailing, trailing_color));
4898 flatten_if_overflow(&mut spans, max_width);
4899 return Line::from(spans);
4900 }
4901 let budget = max_width - fixed;
4902 let mut spans = build_prefix(true);
4903 spans.push(Span::raw(" "));
4904 spans.push(Span::styled(format!(" {} ", badge), chip_style(badge_color)));
4905 spans.push(trailing_span(trailing, trailing_color));
4906 spans.push(Span::raw(" "));
4907 spans.push(Span::raw(trunc(title, budget)));
4908 Line::from(spans)
4909 }
4910 SummaryState::Error(e) => {
4911 let fixed = prefix_w + 2; let budget = max_width.saturating_sub(fixed);
4913 let mut spans = build_prefix(false);
4914 spans.push(Span::raw(" "));
4915 spans.push(Span::styled(
4916 format!("!{}", trunc(e, budget)),
4917 Style::default().fg(theme.prunable),
4918 ));
4919 flatten_if_overflow(&mut spans, max_width);
4920 Line::from(spans)
4921 }
4922 }
4923}
4924
4925fn issue_summary_line_with_spinner(
4926 n: u64,
4927 src: LinkSource,
4928 state: &GitHubFetchState<crate::github::IssueStatus>,
4929 persisted: PersistedSummary<'_, IssueState>,
4930 max_width: usize,
4931 theme: &Theme,
4932 spinner: Option<&str>,
4933) -> Line<'static> {
4934 let head = format!("Issue #{}", n);
4935 let resolved = match state {
4936 GitHubFetchState::Idle => match persisted.state {
4937 Some(state) => {
4938 let badge = match state {
4939 IssueState::Open => "open",
4940 IssueState::Closed => "closed",
4941 };
4942 SummaryState::CachedStatus {
4943 badge,
4944 badge_color: issue_badge_color(state, theme),
4945 trailing: String::new(),
4946 trailing_color: None,
4947 title: persisted.title.unwrap_or(""),
4948 }
4949 }
4950 None => persisted
4951 .title
4952 .map(|title| SummaryState::CachedTitle { title })
4953 .unwrap_or(SummaryState::Idle),
4954 },
4955 GitHubFetchState::Loading => match persisted.state {
4956 Some(state) => {
4957 let badge = match state {
4958 IssueState::Open => "open",
4959 IssueState::Closed => "closed",
4960 };
4961 SummaryState::CachedStatus {
4962 badge,
4963 badge_color: issue_badge_color(state, theme),
4964 trailing: format!(" · {} loading", spinner.unwrap_or("…")),
4965 trailing_color: None,
4966 title: persisted.title.unwrap_or(""),
4967 }
4968 }
4969 None => SummaryState::Loading,
4970 },
4971 GitHubFetchState::Loaded(s) => {
4972 let badge = match s.state {
4978 IssueState::Open => "open",
4979 IssueState::Closed => "closed",
4980 };
4981 SummaryState::Loaded {
4982 badge,
4983 badge_color: issue_badge_color(s.state, theme),
4984 trailing: String::new(),
4985 trailing_color: None,
4986 title: &s.title,
4987 }
4988 }
4989 GitHubFetchState::Error(e) => SummaryState::Error(e),
4990 };
4991 summary_line(ISSUE_ICON, head, src, resolved, max_width, theme, spinner)
4992}
4993
4994pub fn pr_summary_line(
5000 n: u64,
5001 src: LinkSource,
5002 state: &GitHubFetchState<crate::github::PrStatus>,
5003 max_width: usize,
5004 theme: &Theme,
5005) -> Line<'static> {
5006 pr_summary_line_with_spinner(n, src, state, PersistedSummary::none(), max_width, theme, None)
5007}
5008
5009fn pr_summary_line_with_spinner(
5010 n: u64,
5011 src: LinkSource,
5012 state: &GitHubFetchState<crate::github::PrStatus>,
5013 persisted: PersistedSummary<'_, PrState>,
5014 max_width: usize,
5015 theme: &Theme,
5016 spinner: Option<&str>,
5017) -> Line<'static> {
5018 let head = format!("PR #{}", n);
5019 let resolved = match state {
5020 GitHubFetchState::Idle => match persisted.state {
5021 Some(state) => {
5022 let badge = match state {
5023 PrState::Open => "open",
5024 PrState::Draft => "draft",
5025 PrState::Closed => "closed",
5026 PrState::Merged => "merged",
5027 };
5028 SummaryState::CachedStatus {
5029 badge,
5030 badge_color: pr_badge_color(state, theme),
5031 trailing: String::new(),
5032 trailing_color: None,
5033 title: persisted.title.unwrap_or(""),
5034 }
5035 }
5036 None => persisted
5037 .title
5038 .map(|title| SummaryState::CachedTitle { title })
5039 .unwrap_or(SummaryState::Idle),
5040 },
5041 GitHubFetchState::Loading => match persisted.state {
5042 Some(state) => {
5043 let badge = match state {
5044 PrState::Open => "open",
5045 PrState::Draft => "draft",
5046 PrState::Closed => "closed",
5047 PrState::Merged => "merged",
5048 };
5049 SummaryState::CachedStatus {
5050 badge,
5051 badge_color: pr_badge_color(state, theme),
5052 trailing: format!(" · {} loading", spinner.unwrap_or("…")),
5053 trailing_color: None,
5054 title: persisted.title.unwrap_or(""),
5055 }
5056 }
5057 None => SummaryState::Loading,
5058 },
5059 GitHubFetchState::Loaded(s) => {
5060 let badge = match s.state {
5066 PrState::Open => "open",
5067 PrState::Draft => "draft",
5068 PrState::Closed => "closed",
5069 PrState::Merged => "merged",
5070 };
5071 let (trailing, trailing_color) = match ci_indicator(s.ci, s.checks_passed, s.checks_total, theme) {
5075 Some((text, color)) => (text, Some(color)),
5076 None => (String::new(), None),
5077 };
5078 SummaryState::Loaded {
5079 badge,
5080 badge_color: pr_badge_color(s.state, theme),
5081 trailing,
5082 trailing_color,
5083 title: &s.title,
5084 }
5085 }
5086 GitHubFetchState::Error(e) => SummaryState::Error(e),
5087 };
5088 summary_line(PR_ICON, head, src, resolved, max_width, theme, spinner)
5089}
5090
5091pub fn branch_name_color(s: &BranchStatus, theme: &Theme) -> Color {
5106 if s.unknown {
5107 return theme.muted;
5108 }
5109 if s.is_dirty {
5110 return theme.prunable;
5111 }
5112 if s.ahead > 0 || s.behind > 0 {
5113 return theme.dirty;
5114 }
5115 if !s.has_upstream {
5116 return theme.locked;
5119 }
5120 theme.branch
5121}
5122
5123pub fn freshness_color(age: Duration, theme: &Theme) -> Color {
5128 const WEEK: u64 = 7 * 86_400;
5129 const MONTH: u64 = 30 * 86_400;
5130 let s = age.as_secs();
5131 if s < WEEK {
5132 theme.clean
5133 } else if s < MONTH {
5134 theme.dirty
5135 } else {
5136 theme.muted
5137 }
5138}
5139
5140pub fn pr_badge_color(state: PrState, theme: &Theme) -> Color {
5145 match state {
5146 PrState::Open => theme.clean,
5147 PrState::Draft => theme.muted,
5148 PrState::Merged => theme.locked,
5149 PrState::Closed => theme.prunable,
5150 }
5151}
5152
5153pub fn ci_indicator(ci: CiState, passed: u32, total: u32, theme: &Theme) -> Option<(String, Color)> {
5161 let (icon, label, color) = match ci {
5162 CiState::None => return None,
5163 CiState::Passing => (CI_PASSING_ICON, "passing", theme.clean),
5164 CiState::Failing => (CI_FAILING_ICON, "failing", theme.prunable),
5165 CiState::Running => (CI_RUNNING_ICON, "running", theme.dirty),
5166 };
5167 Some((format!(" {} CI {} {}/{}", icon, label, passed, total), color))
5168}
5169
5170pub fn issue_badge_color(state: IssueState, theme: &Theme) -> Color {
5174 match state {
5175 IssueState::Open => theme.clean,
5176 IssueState::Closed => theme.locked,
5177 }
5178}
5179
5180pub fn table_marker(w: &WorktreeInfo, theme: &Theme) -> Line<'static> {
5197 if w.is_main {
5198 return Line::from(Span::styled("★", Style::default().fg(theme.main)));
5199 }
5200 let issue_color = match (w.link.issue, w.issue_state) {
5204 (Some(_), Some(state)) => issue_badge_color(state, theme),
5205 (Some(_), None) => theme.clean,
5206 (None, _) => theme.name,
5207 };
5208 let pr_color = match (w.link.pr, w.pr_state) {
5209 (Some(_), Some(state)) => pr_badge_color(state, theme),
5210 (Some(_), None) => theme.locked,
5211 (None, _) => theme.name,
5212 };
5213 Line::from(vec![
5214 Span::styled(
5215 if w.link.issue.is_some() { "●" } else { "-" },
5216 Style::default().fg(issue_color),
5217 ),
5218 Span::styled("/", Style::default().fg(theme.muted)),
5219 Span::styled(
5220 if w.link.pr.is_some() { "●" } else { "-" },
5221 Style::default().fg(pr_color),
5222 ),
5223 ])
5224}