use ratatui::Frame;
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Position, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Borders, Padding, Paragraph};
use crate::app::{App, Mode};
use crate::components::cmd_bar::StatusEntry;
use crate::repo::{ItemStatus, RepoState, RepoSummary, format_relative_time};
pub use super::style::*;
const STATUS_ZONE_WIDTH: u16 = 22;
const UNSELECTED_INDENT: &str = " ";
fn is_detail_base_mode(mode: &Mode) -> bool {
matches!(
mode,
Mode::Detail
| Mode::DetailHelp
| Mode::CommitInput
| Mode::BranchCreateInput
| Mode::TagCreateInput
| Mode::StashingUI
| Mode::WorktreeAddBranchInput
| Mode::WorktreeAddPathInput
| Mode::WorktreeLockReasonInput
| Mode::WorktreeRemoveConfirm
| Mode::BranchDeleteConfirm
| Mode::BranchCheckoutConfirm
| Mode::SubmoduleAddUrlInput
| Mode::SubmoduleAddPathInput
| Mode::SubmoduleDeleteConfirm
| Mode::SubmoduleUpdateConfirm
| Mode::TagCheckoutConfirm
| Mode::CommitCheckoutConfirm
| Mode::BranchPushConfirm
| Mode::BranchMergeConfirm
| Mode::BranchMergeIntoConfirm
| Mode::BranchRebaseConfirm
| Mode::BranchInteractiveRebaseConfirm
| Mode::TagDeleteConfirm
| Mode::TagOverwriteConfirm
| Mode::TagPushConfirm
| Mode::TagPushAllConfirm
| Mode::StashDeleteConfirm
| Mode::StashApplyConfirm
| Mode::CherryPickConfirm
| Mode::RevertConfirm
| Mode::MergeAbortConfirm
| Mode::MergeContinueConfirm
| Mode::StashCreateInput
| Mode::RemotePicker
| Mode::CommitHistoryPicker
| Mode::CommitSearchInput
| Mode::DiscardChangesConfirm
| Mode::Inspect
| Mode::SearchColumnPicker
| Mode::Logs
| Mode::LogsSearchInput
| Mode::RemoteAddNameInput
| Mode::RemoteAddUrlInput
| Mode::RemoteDeleteConfirm
| Mode::RepoSettings
| Mode::Overview
| Mode::BranchSearchInput
| Mode::FileSearchInput
| Mode::CommitFuzzySearch
| Mode::TagSearchInput
| Mode::ForgeCommentPathInput
| Mode::ForgeCommentLineInput
| Mode::ForgeCommentBodyInput
| Mode::ForgeCheckoutConfirm
)
}
pub fn draw(
f: &mut Frame,
app: &App,
area: Rect,
inner_area: Rect,
visible_count: usize,
detail_areas: &mut crate::ui_detail::DetailAreas,
main_areas: &mut Vec<Rect>,
global_summary_area: &mut Option<Rect>,
quick_label_area: &mut Option<Rect>,
) {
*global_summary_area = None;
*quick_label_area = None;
let mut swapped_theme = None;
if let Some(repo_path) = app.active_repo_item() {
if matches!(
app.mode,
Mode::Detail
| Mode::DetailHelp
| Mode::CommitInput
| Mode::BranchCreateInput
| Mode::TagCreateInput
| Mode::StashingUI
| Mode::WorktreeAddBranchInput
| Mode::WorktreeAddPathInput
| Mode::WorktreeLockReasonInput
| Mode::WorktreeRemoveConfirm
| Mode::BranchDeleteConfirm
| Mode::BranchCheckoutConfirm
| Mode::SubmoduleAddUrlInput
| Mode::SubmoduleAddPathInput
| Mode::SubmoduleDeleteConfirm
| Mode::SubmoduleUpdateConfirm
| Mode::TagCheckoutConfirm
| Mode::CommitCheckoutConfirm
| Mode::BranchPushConfirm
| Mode::BranchMergeConfirm
| Mode::BranchMergeIntoConfirm
| Mode::BranchRebaseConfirm
| Mode::BranchInteractiveRebaseConfirm
| Mode::TagDeleteConfirm
| Mode::TagOverwriteConfirm
| Mode::TagPushConfirm
| Mode::TagPushAllConfirm
| Mode::StashDeleteConfirm
| Mode::StashApplyConfirm
| Mode::CherryPickConfirm
| Mode::RevertConfirm
| Mode::MergeAbortConfirm
| Mode::MergeContinueConfirm
| Mode::StashCreateInput
| Mode::RemotePicker
| Mode::CommitSearchInput
| Mode::DiscardChangesConfirm
| Mode::Inspect
| Mode::SearchColumnPicker
| Mode::Logs
| Mode::LogsSearchInput
| Mode::RemoteAddNameInput
| Mode::RemoteAddUrlInput
| Mode::RemoteDeleteConfirm
| Mode::RepoSettings
| Mode::Overview
| Mode::ForgeCommentPathInput
| Mode::ForgeCommentLineInput
| Mode::ForgeCommentBodyInput
| Mode::ForgeCheckoutConfirm
) {
if let Some(repo_theme) = app.repo_theme_cache.get(repo_path) {
let current_theme_config = {
let lock =
crate::ui::style::THEME.read().expect("theme lock should be acquired");
crate::config::ThemeConfig {
accent: crate::ui::style::format_color(lock.accent),
warning: crate::ui::style::format_color(lock.warning),
danger: crate::ui::style::format_color(lock.danger),
success: crate::ui::style::format_color(lock.success),
border_type: crate::ui::style::format_border_type(lock.border_type),
}
};
crate::ui::update_theme(repo_theme);
swapped_theme = Some(current_theme_config);
}
}
}
if swapped_theme.is_none()
&& app.loading_repo_path.is_none()
&& !is_detail_base_mode(&app.mode)
&& !matches!(app.mode, Mode::FileHistory | Mode::Settings | Mode::DebugLogs)
&& !(app.mode == Mode::UpdateConfirm && app.current_detail.is_some())
{
if let Some(label) = &app.state.active_label_filter {
if let Some(label_theme) = app.label_theme_cache.get(label) {
let current_theme_config = {
let lock =
crate::ui::style::THEME.read().expect("theme lock should be acquired");
crate::config::ThemeConfig {
accent: crate::ui::style::format_color(lock.accent),
warning: crate::ui::style::format_color(lock.warning),
danger: crate::ui::style::format_color(lock.danger),
success: crate::ui::style::format_color(lock.success),
border_type: crate::ui::style::format_border_type(lock.border_type),
}
};
crate::ui::update_theme(label_theme);
swapped_theme = Some(current_theme_config);
}
}
}
draw_outer_frame(f, area, app);
let (content_area, status_chunk) = content_and_status_chunks(inner_area, app.status_height());
let terminal_height = app.terminal_panel_outer_height(inner_area.height);
let (content_area, terminal_area) = if terminal_height > 0 {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(0), Constraint::Length(terminal_height)])
.split(content_area);
(chunks[0], Some(chunks[1]))
} else {
(content_area, None)
};
app.terminal_panel_area.set(terminal_area);
if app.loading_repo_path.is_some() {
crate::popups::loading::draw_loading_screen(f, content_area, app);
} else if is_detail_base_mode(&app.mode)
|| (app.mode == Mode::UpdateConfirm && app.current_detail.is_some())
{
if let Some(detail) = &app.current_detail {
let item_name = app.active_repo_item().map(String::as_str).unwrap_or("");
crate::ui_detail::draw(
f,
item_name,
detail,
&app.mode,
&app.detail_focus,
app.last_staging_focus,
app.commit_list.selection,
&app.commit_list.search_query,
app.status_list.file_selection,
&app.diff.file_diff,
app.diff.diff_scroll,
app.status_list.staging_file_selection,
app.commit_list.details_scroll,
app.branch_list.local_branch_selection,
app.branch_list.remote_branch_selection,
app.tag_list.local_tag_selection,
app.branch_list.remote_selection,
app.remote_picker_selection,
app.stash_list.stash_selection,
app.stash_list.stash_file_selection,
app.file_tree.file_list_selection,
app.file_tree.file_content_scroll,
&app.file_tree.visible_files,
app.detail_tab,
app.graph_scroll,
app.graph_selection,
app.help_scroll,
detail_areas,
&app.input_buffer,
app.commit_popup.editing,
&app.branch_action_target,
&app.tag_action_target_oid,
&app.tag_delete_target,
&app.tag_push_target,
&app.discard_target,
app.stash_apply_delete_after,
app.commit_popup.amend,
app.commit_input_scroll,
app.inspect_horizontal_split_pct,
app.inspect_vertical_split_pct,
app.workspace_main_split_pct,
app.files_horizontal_split_pct,
app.branches_horizontal_split_pct,
app.stashes_horizontal_split_pct,
app.stashes_vertical_split_pct,
app.overview_horizontal_split_pct,
app,
content_area,
);
}
} else if app.mode == Mode::FileHistory {
crate::tabs::FileHistoryTab::draw_file_history(f, app, content_area);
} else if app.mode == Mode::Settings {
crate::popups::settings::draw_settings_page(f, app, content_area);
} else if app.mode == Mode::DebugLogs {
crate::popups::debug::draw_debug_logs(f, app, content_area);
} else if app.config.items.is_empty() {
draw_empty_state(f, content_area, app);
} else if app.get_items_len() == 0 {
let header = home_header_layout(content_area, app);
draw_home_header(f, &header, app, global_summary_area, quick_label_area);
if let Some(ref query) = app.repo_search_query {
draw_search_empty_state(f, header.body, query);
} else {
draw_filter_empty_state(f, header.body, app);
}
} else {
let header = home_header_layout(content_area, app);
draw_home_header(f, &header, app, global_summary_area, quick_label_area);
let list_area_parent = header.body;
let (header_area, list_area) =
if app.effective_view_mode() == crate::config::HomeViewMode::Compact {
let parts = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(0)])
.split(list_area_parent);
(Some(parts[0]), parts[1])
} else {
(None, list_area_parent)
};
if let Some(hdr) = header_area {
draw_compact_headers(f, hdr, app);
}
let (list_chunks, pin_rules) = item_chunks(list_area, visible_count, app);
*main_areas = list_chunks.clone();
draw_items(f, app, &list_chunks);
draw_pin_rules(f, app, &pin_rules);
}
if let Some(term_area) = terminal_area {
crate::components::terminal_panel::draw_terminal_panel(f, app, term_area);
}
crate::components::cmd_bar::draw_status_bar(f, app, status_chunk);
if matches!(app.mode, Mode::Help) {
crate::popups::help::draw_help_overlay(f, app, area, app.help_scroll);
}
if matches!(app.mode, Mode::About) {
crate::popups::about::draw_about_popup(f, area, app);
}
if matches!(app.mode, Mode::StatsDashboard) {
crate::popups::stats::StatsPopup::draw(f, app, area);
}
if matches!(app.mode, Mode::NotGitRepo) {
draw_not_git_repo_popup(f, area, app);
}
if matches!(app.mode, Mode::AddCwdRepoConfirm) {
crate::popups::confirm::draw_add_cwd_repo_popup(f, area, app);
}
if matches!(app.mode, Mode::Legend) {
crate::popups::legend::draw_legend_popup(f, area, app);
}
if matches!(app.mode, Mode::RepoSettings) {
crate::popups::repo_settings::RepoSettingsPopup::draw(f, app, area);
}
if matches!(app.mode, Mode::GlobalSearch) {
draw_global_search_popup(f, app, area);
}
if matches!(app.mode, Mode::RepoJump) {
draw_repo_jump_popup(f, app, area);
}
if matches!(app.mode, Mode::LabelPicker) {
draw_label_picker_popup(f, app, area);
}
if matches!(app.mode, Mode::LabelSettings) {
crate::popups::label_settings::LabelSettingsPopup::draw(f, app, area);
}
if matches!(app.mode, Mode::RepoScanPicker) {
draw_repo_scan_popup(f, app, area);
}
if matches!(app.mode, Mode::BulkAddScanPicker) {
draw_bulk_add_scan_popup(f, app, area);
}
if matches!(app.mode, Mode::BranchSearchInput) {
draw_branch_search_popup(f, app, area);
}
if matches!(app.mode, Mode::FileSearchInput) {
draw_file_search_popup(f, app, area);
}
if matches!(app.mode, Mode::CommitFuzzySearch) {
draw_commit_fuzzy_popup(f, app, area);
}
if matches!(app.mode, Mode::TagSearchInput) {
draw_tag_search_popup(f, app, area);
}
if matches!(app.mode, Mode::ImportUrlInput | Mode::ImportDestInput | Mode::ImportNameInput) {
crate::popups::import::draw_import_popup(f, area, app);
}
crate::popups::command_palette::CommandPalettePopup::draw(f, app, area);
if let Some(ref err) = app.error_message {
crate::popups::error::draw_error_popup(f, app, area, err);
} else if app.fetching {
crate::popups::loading::draw_progress_popup(f, area, app);
}
if global_summary_area.is_some() {
draw_label_badge(f, area, app);
} else {
app.label_badge_area.set(None);
}
if let Some(original_theme) = swapped_theme {
crate::ui::update_theme(&original_theme);
}
}
fn draw_outer_frame(f: &mut Frame, area: Rect, app: &App) {
let mut block = Block::default()
.borders(Borders::ALL)
.border_type(CARD_BORDER())
.border_style(muted_style())
.title(
Line::from(vec![
Span::raw(" "),
Span::styled("Gitwig", accent_style()),
Span::raw(" "),
])
.alignment(Alignment::Left),
);
let mut right_spans =
vec![Span::styled(format!(" v{} ", env!("CARGO_PKG_VERSION")), muted_style())];
if let Some(ref latest) = app.update_available {
right_spans.insert(0, Span::raw(" "));
let badge_text = if app.can_self_update() {
format!("[Update to v{}]", latest)
} else {
format!("[New version v{}]", latest)
};
right_spans.insert(
0,
Span::styled(
badge_text,
Style::default().fg(ratatui::style::Color::LightGreen).add_modifier(Modifier::BOLD),
),
);
}
if app.implicit_network_count > 0 {
let spinner_chars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
let idx = ((app.fetch_progress / 10) % 10) as usize;
right_spans.insert(0, Span::raw(" "));
right_spans.insert(0, Span::styled(spinner_chars[idx], accent_style()));
}
block = block.title(Line::from(right_spans).alignment(Alignment::Right));
f.render_widget(block, area);
}
fn content_and_status_chunks(inner_area: Rect, status_height: u16) -> (Rect, Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(0), Constraint::Length(status_height)])
.split(inner_area);
(chunks[0], chunks[1])
}
fn item_chunks(content_area: Rect, visible_count: usize, app: &App) -> (Vec<Rect>, Vec<Rect>) {
let rows = app.get_home_rows();
let separators = app.home_pin_separators(&rows);
let upper = (app.scroll_top + visible_count).min(rows.len());
let visible_rows = &rows[app.scroll_top..upper];
let visible_separators = &separators[app.scroll_top..upper];
if app.effective_view_mode() != crate::config::HomeViewMode::Tile {
let mut constraints = Vec::new();
for (row, &separator) in visible_rows.iter().zip(visible_separators) {
let h = match row {
crate::app::HomeRow::GroupHeader { .. } => {
if app.effective_view_mode() == crate::config::HomeViewMode::Compact {
1
} else {
2
}
}
crate::app::HomeRow::Repo { path, .. } => {
if app.effective_view_mode() == crate::config::HomeViewMode::Compact {
1
} else {
let has_note = app
.config
.repo_configs
.get(path)
.and_then(|cfg| cfg.note.as_ref())
.is_some();
if has_note { 5 } else { 4 }
}
}
};
constraints.push(Constraint::Length(h));
if separator {
constraints.push(Constraint::Length(1));
}
}
constraints.push(Constraint::Min(0));
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.split(content_area);
let mut rects = Vec::with_capacity(visible_rows.len());
let mut rules = Vec::new();
let mut next = 0;
for &separator in visible_separators {
rects.push(chunks[next]);
next += 1;
if separator {
rules.push(chunks[next]);
next += 1;
}
}
return (rects, rules);
}
let cols = app.get_tile_cols();
let mut final_rects = Vec::with_capacity(visible_rows.len());
let mut rules = Vec::new();
let mut row_heights = Vec::new();
let mut current_row_items = 0;
for (row, &separator) in visible_rows.iter().zip(visible_separators) {
match row {
crate::app::HomeRow::GroupHeader { .. } => {
if current_row_items > 0 {
row_heights.push(4);
current_row_items = 0;
}
row_heights.push(2);
}
crate::app::HomeRow::Repo { .. } => {
current_row_items += 1;
if separator {
row_heights.push(4);
row_heights.push(1);
current_row_items = 0;
} else if current_row_items == cols {
row_heights.push(4);
current_row_items = 0;
}
}
}
}
if current_row_items > 0 {
row_heights.push(4);
}
row_heights.push(0);
let constraints: Vec<Constraint> = row_heights
.iter()
.map(|&h| if h == 0 { Constraint::Min(0) } else { Constraint::Length(h as u16) })
.collect();
let vertical_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.split(content_area);
let mut row_idx = 0;
let mut current_col = 0;
let tile_constraints: Vec<Constraint> =
(0..cols).map(|_| Constraint::Ratio(1, cols as u32)).collect();
for (row, &separator) in visible_rows.iter().zip(visible_separators) {
match row {
crate::app::HomeRow::GroupHeader { .. } => {
if current_col > 0 {
row_idx += 1;
current_col = 0;
}
final_rects.push(vertical_chunks[row_idx]);
row_idx += 1;
}
crate::app::HomeRow::Repo { .. } => {
let row_rect = vertical_chunks[row_idx];
let horizontal_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints(tile_constraints.clone())
.split(row_rect);
final_rects.push(horizontal_chunks[current_col]);
current_col += 1;
if separator {
current_col = 0;
row_idx += 1;
rules.push(vertical_chunks[row_idx]);
row_idx += 1;
} else if current_col == cols {
current_col = 0;
row_idx += 1;
}
}
}
}
(final_rects, rules)
}
fn draw_pin_rules(f: &mut Frame, app: &App, rules: &[Rect]) {
let indent = UNSELECTED_INDENT.len() as u16 + 1;
for rect in rules {
let width = rect.width.saturating_sub(indent * 2) as usize;
if width == 0 {
continue;
}
let dash = app.sym("rule");
let rule: String = (0..width).map(|i| if i % 2 == 0 { dash } else { " " }).collect();
let area = Rect::new(rect.x + indent, rect.y, width as u16, 1);
f.render_widget(Paragraph::new(Line::from(Span::styled(rule, rule_style()))), area);
}
}
pub struct SummaryCounts {
pub total_repos: usize,
pub visible: usize,
pub dirty: usize,
pub ahead: usize,
pub stale: usize,
}
pub fn summary_counts(app: &App) -> SummaryCounts {
let mut counts = SummaryCounts { total_repos: 0, visible: 0, dirty: 0, ahead: 0, stale: 0 };
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let stale_threshold = (app.config.stale_threshold_months as i64) * 30 * 24 * 60 * 60;
let label_ok = |idx: usize| match &app.state.active_label_filter {
Some(label) => app
.config
.items
.get(idx)
.and_then(|path| app.config.labels.get(path))
.is_some_and(|lbls| lbls.contains(label)),
None => true,
};
for (idx, status) in app.statuses.iter().enumerate() {
if !label_ok(idx) {
continue;
}
match status {
ItemStatus::GitRepo(Some(summary)) => {
counts.total_repos += 1;
if !summary.is_clean() {
counts.dirty += 1;
}
if summary.ahead > 0 {
counts.ahead += 1;
}
if let Some(t) = summary.last_commit_time {
if now - t > stale_threshold {
counts.stale += 1;
}
}
}
ItemStatus::GitRepo(None) => {
counts.total_repos += 1;
}
_ => {}
}
}
counts.visible = app.get_filtered_items().len();
counts
}
pub const SUMMARY_TAB_DIVIDER: &str = "│";
pub fn label_badge_text(app: &App) -> Option<String> {
app.state
.active_label_filter
.as_ref()
.map(|label| format!(" {} {} ", app.sym("bullet_filled"), label))
}
fn draw_label_badge(f: &mut Frame, area: Rect, app: &App) {
let Some(text) = label_badge_text(app) else {
app.label_badge_area.set(None);
return;
};
let usable = area.width.saturating_sub(2);
let width = (text.chars().count() as u16).min(usable);
let x = area.x + 1 + (usable - width) / 2;
let badge = Rect::new(x, area.y, width, 1);
f.render_widget(
Paragraph::new(Line::from(Span::styled(
text,
accent_style().add_modifier(Modifier::REVERSED | Modifier::BOLD),
))),
badge,
);
app.label_badge_area.set(Some(badge));
}
pub fn summary_tab_parts(app: &App, counts: &SummaryCounts) -> [(String, String); 4] {
let repos_count = if counts.visible < counts.total_repos {
format!(" {}/{} ", counts.visible, counts.total_repos)
} else {
format!(" {} ", counts.total_repos)
};
let stale_label = if !app.config.show_stale_projects && counts.stale > 0 {
"stale (hidden)"
} else {
"stale"
};
[
(repos_count, "repos ".to_string()),
(format!(" {} ", counts.dirty), "dirty ".to_string()),
(format!(" {} ", counts.ahead), "ahead ".to_string()),
(format!(" {} ", counts.stale), format!("{} ", stale_label)),
]
}
fn draw_global_summary_bar(f: &mut Frame, area: Rect, app: &App) {
let counts = summary_counts(app);
let parts = summary_tab_parts(app, &counts);
let colors = [
ACCENT(),
if counts.dirty > 0 { WARNING() } else { SUCCESS() },
if counts.ahead > 0 { ACCENT() } else { SUCCESS() },
if counts.stale > 0 { DANGER() } else { SUCCESS() },
];
let active = [
app.global_filter.is_none(),
app.global_filter == Some(crate::app::GlobalFilter::Dirty),
app.global_filter == Some(crate::app::GlobalFilter::Ahead),
app.global_filter == Some(crate::app::GlobalFilter::Stale),
];
let mut spans = Vec::new();
for (i, (count_text, label_text)) in parts.iter().enumerate() {
if i > 0 {
spans.push(Span::styled(SUMMARY_TAB_DIVIDER, muted_style()));
}
if active[i] {
let tab_style =
primary_style().fg(colors[i]).add_modifier(Modifier::REVERSED | Modifier::BOLD);
spans.push(Span::styled(count_text.clone(), tab_style));
spans.push(Span::styled(label_text.clone(), tab_style));
} else {
spans.push(Span::styled(count_text.clone(), primary_style().fg(colors[i])));
spans.push(Span::styled(label_text.clone(), muted_style()));
}
}
let line = Line::from(spans).alignment(Alignment::Center);
f.render_widget(Paragraph::new(line), area);
}
pub const QUICK_LABEL_DIVIDER: &str = SUMMARY_TAB_DIVIDER;
pub fn quick_label_parts(app: &App) -> Vec<(String, String)> {
app.quick_labels()
.into_iter()
.enumerate()
.map(|(slot, (label, _))| {
let key = crate::keybindings::Action::home_label_slot(slot)
.map(|a| app.keybindings.format_action_keys(a, app.config.compatibility_mode))
.unwrap_or_default();
(format!(" {} ", key), format!("{} ", label))
})
.collect()
}
struct HomeHeaderLayout {
summary: Rect,
chips: Option<Rect>,
sort_rule: Rect,
body: Rect,
}
fn home_header_layout(area: Rect, app: &App) -> HomeHeaderLayout {
if app.quick_labels().is_empty() {
let parts = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Length(1), Constraint::Min(0), ])
.split(area);
HomeHeaderLayout { summary: parts[0], chips: None, sort_rule: parts[1], body: parts[2] }
} else if !app.config.show_separators {
let parts = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Min(0), ])
.split(area);
HomeHeaderLayout {
summary: parts[0],
chips: Some(parts[1]),
sort_rule: parts[2],
body: parts[3],
}
} else {
let parts = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Min(0), ])
.split(area);
HomeHeaderLayout {
summary: parts[0],
chips: Some(parts[2]),
sort_rule: parts[3],
body: parts[4],
}
}
}
pub fn sort_caption(app: &App) -> String {
let mut caption = format!("Sort: {}", app.effective_sort_by().display_name());
if app.effective_sort_reverse() {
caption.push_str(" (Rev)");
}
if let Some(label) = app.sort_override_label() {
caption.push_str(&format!(" · {}", label));
}
caption
}
fn draw_home_header(
f: &mut Frame,
header: &HomeHeaderLayout,
app: &App,
global_summary_area: &mut Option<Rect>,
quick_label_area: &mut Option<Rect>,
) {
draw_global_summary_bar(f, header.summary, app);
*global_summary_area = Some(header.summary);
if let Some(chips) = header.chips {
if app.config.show_separators {
draw_header_rule(f, Rect::new(chips.x, chips.y.saturating_sub(1), chips.width, 1), app);
}
draw_quick_label_bar(f, chips, app);
*quick_label_area = Some(chips);
}
draw_sort_rule(f, header.sort_rule, app);
}
fn rule_style() -> Style {
muted_style()
}
fn draw_header_rule(f: &mut Frame, area: Rect, app: &App) {
let rule = app.sym("rule").repeat(area.width as usize);
f.render_widget(Paragraph::new(Line::from(Span::styled(rule, rule_style()))), area);
}
fn draw_sort_rule(f: &mut Frame, area: Rect, app: &App) {
let border_set = ratatui::symbols::border::Set {
horizontal_top: if app.config.show_separators { app.sym("rule") } else { " " },
..ratatui::symbols::border::PLAIN
};
let block = Block::default()
.borders(Borders::TOP)
.border_set(border_set)
.border_style(rule_style())
.title(
Line::from(vec![
Span::raw(" "),
Span::styled(sort_caption(app), accent_style()),
Span::raw(" "),
])
.alignment(Alignment::Center),
);
f.render_widget(block, area);
}
fn draw_quick_label_bar(f: &mut Frame, area: Rect, app: &App) {
let parts = quick_label_parts(app);
let labels = app.quick_labels();
let mut spans = Vec::new();
for (i, ((key_text, name_text), (label, _))) in parts.iter().zip(labels.iter()).enumerate() {
if i > 0 {
spans.push(Span::styled(QUICK_LABEL_DIVIDER, muted_style()));
}
if app.state.active_label_filter.as_deref() == Some(label.as_str()) {
let chip = accent_style().add_modifier(Modifier::REVERSED | Modifier::BOLD);
spans.push(Span::styled(key_text.clone(), chip));
spans.push(Span::styled(name_text.clone(), chip));
} else {
spans.push(Span::styled(key_text.clone(), accent_style().add_modifier(Modifier::BOLD)));
spans.push(Span::styled(name_text.clone(), primary_style()));
}
}
let line = Line::from(spans).alignment(Alignment::Center);
f.render_widget(Paragraph::new(line), area);
}
fn compact_branch_col_width(app: &App, total_width: u16) -> u16 {
const MIN_WIDTH: u16 = 28;
const MIN_REPO_COL: u16 = 30;
let mut desired = MIN_WIDTH as usize;
for (idx, _) in app.get_filtered_items() {
if let Some(ItemStatus::GitRepo(Some(s))) = app.statuses.get(idx) {
if let Some(b) = &s.branch {
let time_len = s
.last_commit_time
.map(|t| format_relative_time(t).chars().count() + 3) .unwrap_or(0);
desired = desired.max(2 + b.chars().count() + time_len + 1);
}
}
}
let cap = total_width.saturating_sub(STATUS_ZONE_WIDTH + MIN_REPO_COL).max(MIN_WIDTH);
(desired.min(u16::MAX as usize) as u16).clamp(MIN_WIDTH, cap)
}
fn draw_compact_headers(f: &mut Frame, area: Rect, app: &App) {
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Min(0),
Constraint::Length(compact_branch_col_width(app, area.width)),
Constraint::Length(STATUS_ZONE_WIDTH),
])
.split(area);
let header_style = muted_style().add_modifier(Modifier::BOLD);
let col_repo = Line::from(vec![Span::raw(" "), Span::styled("REPOSITORY", header_style)]);
let col_branch = Line::from(Span::styled("ACTIVE BRANCH", header_style));
let col_status = Line::from(Span::styled("STATUS", header_style)).alignment(Alignment::Right);
f.render_widget(Paragraph::new(col_repo), cols[0]);
f.render_widget(Paragraph::new(col_branch), cols[1]);
f.render_widget(Paragraph::new(col_status), cols[2]);
}
fn draw_items(f: &mut Frame, app: &App, chunks: &[Rect]) {
let rows = app.get_home_rows();
let upper = (app.scroll_top + chunks.len()).min(rows.len());
let visible_rows = &rows[app.scroll_top..upper];
let branch_col_width =
compact_branch_col_width(app, chunks.first().map(|c| c.width).unwrap_or(0));
for (i, row) in visible_rows.iter().enumerate() {
let display_index = i + app.scroll_top;
let is_selected = display_index == app.selected_index;
match row {
crate::app::HomeRow::GroupHeader { name, count, collapsed } => {
let arrow = if *collapsed { "▶" } else { "▼" };
let header_text = format!("{} [{}] ({} repos)", arrow, name, count);
let style = if is_selected {
Style::default().fg(ACCENT()).add_modifier(Modifier::BOLD)
} else {
muted_style().add_modifier(Modifier::BOLD)
};
let mut block = Block::default();
if app.effective_view_mode() != crate::config::HomeViewMode::Compact {
let border_style =
if is_selected { Style::default().fg(ACCENT()) } else { muted_style() };
block = block.borders(Borders::BOTTOM).border_style(border_style);
}
let p = Paragraph::new(Line::from(vec![
Span::styled(
if is_selected { app.sym("selection_mark") } else { UNSELECTED_INDENT },
if is_selected { Style::default().fg(ACCENT()) } else { Style::default() },
),
Span::styled(header_text, style),
]))
.block(block);
f.render_widget(p, chunks[i]);
}
crate::app::HomeRow::Repo { actual_index, path: item, .. } => {
let actual_index = *actual_index;
let fallback = ItemStatus::Missing;
let status = app.statuses.get(actual_index).unwrap_or(&fallback);
let is_git = matches!(status, ItemStatus::GitRepo(_));
let mut is_partial = false;
if let ItemStatus::GitRepo(Some(summary)) = status {
if summary.staged > 0 && (summary.modified > 0 || summary.untracked > 0) {
is_partial = true;
}
}
let pending_delete = is_selected && matches!(app.mode, Mode::ConfirmDelete);
let pending_edit = is_selected && matches!(app.mode, Mode::Editing);
let is_pinned = app.config.pinned.contains(item);
let is_starred = app.config.starred.contains(item);
let border_style = if pending_delete {
Style::default().fg(DANGER())
} else if pending_edit {
Style::default().fg(WARNING())
} else if is_selected {
Style::default().fg(ACCENT())
} else if is_partial || is_pinned {
Style::default().fg(WARNING())
} else {
muted_style()
};
let (mark, mark_style, text_style) = if is_selected {
(app.sym("selection_mark"), border_style, primary_style())
} else {
(UNSELECTED_INDENT, Style::default(), Style::default())
};
let repo_name = std::path::Path::new(item)
.file_name()
.and_then(|s| s.to_str())
.unwrap_or(item.as_str());
if app.effective_view_mode() == crate::config::HomeViewMode::Compact {
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Min(0),
Constraint::Length(branch_col_width),
Constraint::Length(STATUS_ZONE_WIDTH),
])
.split(chunks[i]);
let mut left_spans = vec![Span::styled(mark, mark_style)];
if is_git {
left_spans.push(Span::styled(
app.sym("git_repo"),
if is_selected {
primary_style().add_modifier(Modifier::BOLD)
} else {
muted_style().add_modifier(Modifier::BOLD)
},
));
}
let mut name_prefix = String::new();
if !app.multi_selected.is_empty() {
if app.multi_selected.contains(item) {
name_prefix.push_str("[x] ");
} else {
name_prefix.push_str("[ ] ");
}
}
left_spans.push(Span::styled(
format!("{}{}", name_prefix, repo_name),
if is_selected {
Style::default().fg(ACCENT()).add_modifier(Modifier::BOLD)
} else {
text_style
},
));
if let ItemStatus::GitRepo(Some(summary)) = status {
let (state_str, state_style) = match summary.state {
RepoState::Merge => (
" ⚠ MERGE_HEAD",
Style::default().fg(DANGER()).add_modifier(Modifier::BOLD),
),
RepoState::Rebase => (
" 🚧 REBASING",
Style::default().fg(WARNING()).add_modifier(Modifier::BOLD),
),
RepoState::CherryPick => (
" ⚡ CHERRY-PICK",
Style::default().fg(ACCENT()).add_modifier(Modifier::BOLD),
),
RepoState::Revert => (
" ⚡ REVERTING",
Style::default().fg(WARNING()).add_modifier(Modifier::BOLD),
),
RepoState::Bisect => (
" 🔍 BISECTING",
Style::default()
.fg(ratatui::style::Color::Magenta)
.add_modifier(Modifier::BOLD),
),
RepoState::ApplyMailbox => (
" 📬 APPLYING",
Style::default().fg(ACCENT()).add_modifier(Modifier::BOLD),
),
RepoState::Clean => (" ✓ CLEAN", muted_style()),
};
left_spans.push(Span::styled(
state_str,
if is_selected {
state_style
.remove_modifier(Modifier::DIM)
.add_modifier(Modifier::BOLD)
} else {
state_style
},
));
if summary.staged > 0 && (summary.modified > 0 || summary.untracked > 0) {
left_spans.push(Span::styled(
" ⚠ PARTIAL",
Style::default().fg(WARNING()).add_modifier(Modifier::BOLD),
));
}
}
if let Some(lbls) = app.config.labels.get(item) {
for lbl in lbls {
left_spans.push(Span::raw(" "));
left_spans.push(Span::styled(
format!("[{}]", lbl),
if is_selected {
Style::default().fg(ACCENT()).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(ACCENT()).add_modifier(Modifier::DIM)
},
));
}
}
let is_starred = app.config.starred.contains(item);
if is_starred {
left_spans.push(Span::raw(" "));
left_spans.push(Span::styled(
app.sym("star").trim(),
if is_selected {
Style::default()
.fg(ratatui::style::Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(ratatui::style::Color::Yellow)
},
));
}
if is_pinned {
left_spans.push(Span::raw(" "));
left_spans.push(Span::styled(
app.sym("pinned").trim(),
if is_selected {
Style::default().fg(WARNING()).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(WARNING())
},
));
}
f.render_widget(Paragraph::new(Line::from(left_spans)), cols[0]);
let branch_line = match status {
ItemStatus::GitRepo(Some(s)) => {
if let Some(b) = &s.branch {
let mut parts = vec![
Span::styled(
format!("{} ", app.sym("branch")),
if is_selected {
primary_style().add_modifier(Modifier::BOLD)
} else {
muted_style()
},
),
Span::styled(
b.clone(),
if is_selected {
Style::default()
.fg(ACCENT())
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(ACCENT())
},
),
];
if let Some(time) = s.last_commit_time {
parts.push(Span::styled(
format!(" ({})", format_relative_time(time)),
if is_selected {
primary_style().add_modifier(Modifier::BOLD)
} else {
muted_style()
},
));
}
Line::from(parts)
} else {
Line::from("")
}
}
_ => Line::from(""),
};
f.render_widget(Paragraph::new(branch_line), cols[1]);
let status_line =
status_indicator_line(app, status, item).alignment(Alignment::Right);
f.render_widget(Paragraph::new(status_line), cols[2]);
} else {
let border_type =
if is_selected { BorderType::LightDoubleDashed } else { CARD_BORDER() };
let block = Block::default()
.borders(Borders::ALL)
.border_type(border_type)
.border_style(border_style)
.padding(Padding::horizontal(1));
let inner = block.inner(chunks[i]);
f.render_widget(block, chunks[i]);
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(0),
])
.split(inner);
let name_cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(0), Constraint::Length(8)])
.split(rows[0]);
let mut spans = vec![Span::styled(mark, mark_style)];
if is_git {
spans.push(Span::styled(
app.sym("git_repo"),
muted_style().add_modifier(Modifier::BOLD),
));
}
let mut name_prefix = String::new();
if !app.multi_selected.is_empty() {
if app.multi_selected.contains(item) {
name_prefix.push_str("[x] ");
} else {
name_prefix.push_str("[ ] ");
}
}
spans.push(Span::styled(format!("{}{}", name_prefix, repo_name), text_style));
if let ItemStatus::GitRepo(Some(summary)) = status {
let (state_str, state_style) = match summary.state {
RepoState::Merge => (
" ⚠ MERGE_HEAD",
Style::default().fg(DANGER()).add_modifier(Modifier::BOLD),
),
RepoState::Rebase => (
" 🚧 REBASING",
Style::default().fg(WARNING()).add_modifier(Modifier::BOLD),
),
RepoState::CherryPick => (
" ⚡ CHERRY-PICK",
Style::default().fg(ACCENT()).add_modifier(Modifier::BOLD),
),
RepoState::Revert => (
" ⚡ REVERTING",
Style::default().fg(WARNING()).add_modifier(Modifier::BOLD),
),
RepoState::Bisect => (
" 🔍 BISECTING",
Style::default()
.fg(ratatui::style::Color::Magenta)
.add_modifier(Modifier::BOLD),
),
RepoState::ApplyMailbox => (
" 📬 APPLYING",
Style::default().fg(ACCENT()).add_modifier(Modifier::BOLD),
),
RepoState::Clean => (" ✓ CLEAN", muted_style()),
};
spans.push(Span::styled(state_str, state_style));
if summary.staged > 0 && (summary.modified > 0 || summary.untracked > 0) {
spans.push(Span::styled(
" ⚠ PARTIAL",
Style::default().fg(WARNING()).add_modifier(Modifier::BOLD),
));
}
}
if let Some(lbls) = app.config.labels.get(item) {
for lbl in lbls {
spans.push(Span::raw(" "));
spans.push(Span::styled(
format!("[{}]", lbl),
Style::default().fg(ACCENT()).add_modifier(Modifier::DIM),
));
}
}
let name_line = Line::from(spans);
f.render_widget(Paragraph::new(name_line), name_cols[0]);
let mut right_spans = Vec::new();
if is_starred {
right_spans.push(Span::styled(
format!("{} ", app.sym("star").trim()),
Style::default().fg(ratatui::style::Color::Yellow),
));
}
if is_pinned {
right_spans.push(Span::styled(
app.sym("pinned").trim(),
Style::default().fg(WARNING()),
));
}
if !right_spans.is_empty() {
let icon_line = Line::from(right_spans).alignment(Alignment::Right);
f.render_widget(Paragraph::new(icon_line), name_cols[1]);
}
let row1_cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(0), Constraint::Length(STATUS_ZONE_WIDTH)])
.split(rows[1]);
let branch_line = match status {
ItemStatus::GitRepo(Some(s)) => {
if let Some(b) = &s.branch {
let mut parts = vec![
Span::raw(UNSELECTED_INDENT),
Span::styled(format!("{} ", app.sym("branch")), muted_style()),
Span::styled(b.clone(), Style::default().fg(ACCENT())),
];
if let Some(time) = s.last_commit_time {
parts.push(Span::raw(" ("));
parts.push(Span::styled(
format_relative_time(time),
muted_style(),
));
parts.push(Span::raw(")"));
}
Line::from(parts)
} else {
Line::from("")
}
}
_ => Line::from(""),
};
f.render_widget(Paragraph::new(branch_line), row1_cols[0]);
let status_line =
status_indicator_line(app, status, item).alignment(Alignment::Right);
f.render_widget(Paragraph::new(status_line), row1_cols[1]);
let note_line = if let Some(repo_cfg) = app.config.repo_configs.get(item) {
if let Some(ref note) = repo_cfg.note {
Line::from(vec![
Span::raw(UNSELECTED_INDENT),
Span::styled("✎ ", muted_style()),
Span::styled(
note.clone(),
muted_style().add_modifier(Modifier::ITALIC),
),
])
} else {
Line::from("")
}
} else {
Line::from("")
};
f.render_widget(Paragraph::new(note_line), rows[2]);
}
}
}
}
}
fn draw_empty_state(f: &mut Frame, area: Rect, app: &App) {
let popup_area = crate::ui::layout::centered_rect_fixed(70, 16, area);
let block = Block::default()
.borders(Borders::ALL)
.border_type(CARD_BORDER())
.border_style(accent_style())
.title(
Line::from(vec![
Span::raw(" "),
Span::styled("Gitwig Onboarding", primary_style().add_modifier(Modifier::BOLD)),
Span::raw(" "),
])
.alignment(Alignment::Center),
)
.padding(Padding::vertical(1));
let inner = block.inner(popup_area);
f.render_widget(ratatui::widgets::Clear, popup_area);
f.render_widget(block, popup_area);
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(7), Constraint::Length(1), Constraint::Length(1), ])
.split(inner);
let is_compat = app.config.compatibility_mode;
let mut header_spans =
vec![Span::styled("Welcome to Gitwig!", primary_style().add_modifier(Modifier::BOLD))];
if !is_compat {
header_spans.push(Span::styled(" ○", Style::default().fg(BRAND_COPPER())));
}
let header = Line::from(header_spans).alignment(Alignment::Center);
f.render_widget(Paragraph::new(header), layout[0]);
let desc = Line::from(Span::styled(
"Get started by tracking your first Git repository.",
muted_style(),
))
.alignment(Alignment::Center);
f.render_widget(Paragraph::new(desc), layout[1]);
let format_shortcut = |key: &str, desc: &str| -> Line<'static> {
Line::from(vec![
Span::raw(" "),
Span::styled(format!("{:>4}", key), accent_style().add_modifier(Modifier::BOLD)),
Span::styled(" ⟩ ", muted_style()),
Span::raw(desc.to_string()),
])
};
let shortcut_lines = vec![
format_shortcut("a", "Add a new local repository path"),
format_shortcut("A", "Bulk add repositories in a directory"),
format_shortcut("i", "Import a remote repository URL"),
format_shortcut("?", "Open full shortcuts help reference"),
format_shortcut("s", "Configure global settings"),
format_shortcut("q", "Quit the application"),
];
f.render_widget(Paragraph::new(shortcut_lines), layout[3]);
let tip = Line::from(vec![
Span::styled("Tip: ", primary_style().fg(WARNING())),
Span::styled("paths support ~ expansion (e.g. ~/projects/my-repo)", muted_style()),
])
.alignment(Alignment::Center);
f.render_widget(Paragraph::new(tip), layout[5]);
}
fn draw_search_empty_state(f: &mut Frame, area: Rect, query: &str) {
let vert = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(25), Constraint::Min(0), Constraint::Percentage(40)])
.split(area);
let lines = vec![
Line::from(vec![Span::styled(
format!("No repositories matching '{}'.", query),
primary_style(),
)]),
Line::from(""),
Line::from(vec![
Span::raw("Press "),
Span::styled("Esc", accent_style()),
Span::raw(" to clear the search filter"),
]),
];
let p = Paragraph::new(lines).alignment(Alignment::Center);
f.render_widget(p, vert[1]);
}
fn draw_filter_empty_state(f: &mut Frame, area: Rect, app: &App) {
let vert = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(25), Constraint::Min(0), Constraint::Percentage(40)])
.split(area);
let tab_name = match app.global_filter {
Some(crate::app::GlobalFilter::Dirty) => Some("dirty"),
Some(crate::app::GlobalFilter::Ahead) => Some("ahead"),
Some(crate::app::GlobalFilter::Stale) => Some("stale"),
None => None,
};
let headline = match (tab_name, &app.state.active_label_filter) {
(Some(tab), Some(label)) => {
format!("No {} repositories with label '{}'.", tab, label)
}
(Some(tab), None) => format!("No repositories in the {} tab right now.", tab),
(None, Some(label)) => format!("No repositories with label '{}'.", label),
(None, None) => "All repositories are currently hidden as stale.".to_string(),
};
let mut lines = vec![
Line::from(Span::styled(headline, primary_style().add_modifier(Modifier::BOLD))),
Line::from(""),
];
if tab_name.is_some() {
lines.push(Line::from(vec![
Span::raw("Press "),
Span::styled("Tab", accent_style()),
Span::raw(" to switch tabs, or "),
Span::styled("Esc", accent_style()),
Span::raw(" to show all repositories"),
]));
}
if app.state.active_label_filter.is_some() {
let label_key = app.keybindings.format_action_keys(
crate::keybindings::Action::HomeLabelPicker,
app.config.compatibility_mode,
);
lines.push(Line::from(vec![
Span::raw("Press "),
Span::styled(label_key, accent_style()),
Span::raw(" to change or clear the label filter"),
]));
}
if tab_name.is_none() && app.state.active_label_filter.is_none() {
lines.push(Line::from(vec![
Span::raw("Enable "),
Span::styled("Show Stale Projects", accent_style()),
Span::raw(" in Settings to see them"),
]));
}
let p = Paragraph::new(lines).alignment(Alignment::Center);
f.render_widget(p, vert[1]);
}
fn status_indicator_line(app: &App, status: &ItemStatus, item: &str) -> Line<'static> {
if app.bulk_fetching.contains(item) {
let millis = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
let index = ((millis / 80) % 10) as usize;
let spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"][index];
return Line::from(vec![
Span::styled(
format!("{} ", spinner),
Style::default().fg(ACCENT()).add_modifier(Modifier::BOLD),
),
Span::styled("fetching...", Style::default().fg(ACCENT())),
]);
}
let fetch_result = app.bulk_fetch_results.get(item);
let fresh = app.bulk_fetch_completed_at.is_some() || !app.bulk_fetching.is_empty();
if let Some(res) = fetch_result {
match res {
Ok(_) if fresh => {
return Line::from(vec![
Span::styled("✓ ", Style::default().fg(SUCCESS()).add_modifier(Modifier::BOLD)),
Span::styled("done", Style::default().fg(SUCCESS())),
]);
}
Err(_) if fresh => {
let (color, label) = fetch_failure_style(app, item);
return Line::from(vec![
Span::styled("✗ ", Style::default().fg(color).add_modifier(Modifier::BOLD)),
Span::styled(label, Style::default().fg(color)),
]);
}
_ => {}
}
}
let mut line = match status {
ItemStatus::Loading => Line::from(vec![
Span::styled(app.sym("bullet_empty"), muted_style()),
Span::raw(" "),
Span::styled("loading...", muted_style()),
]),
ItemStatus::Missing => Line::from(vec![
Span::styled(app.sym("close"), Style::default().fg(DANGER())),
Span::raw(" "),
Span::styled("missing", muted_style()),
]),
ItemStatus::Directory => Line::from(vec![
Span::styled(app.sym("bullet_empty"), Style::default().fg(WARNING())),
Span::raw(" "),
Span::styled("dir", muted_style()),
]),
ItemStatus::GitRepo(None) => Line::from(vec![
Span::styled(app.sym("bullet_filled"), Style::default().fg(SUCCESS())),
Span::raw(" "),
Span::styled("?", muted_style()),
]),
ItemStatus::GitRepo(Some(summary)) => repo_indicator_line(app, summary),
};
if matches!(fetch_result, Some(Err(_))) {
let (color, _) = fetch_failure_style(app, item);
line.spans.push(Span::raw(" "));
line.spans.push(Span::styled("✗", Style::default().fg(color).add_modifier(Modifier::BOLD)));
}
line
}
fn fetch_failure_style(app: &App, item: &str) -> (ratatui::style::Color, &'static str) {
let failure = app
.bulk_fetch_failures
.get(item)
.copied()
.unwrap_or(crate::fetch_error::FetchFailure::Unknown);
let color =
if failure == crate::fetch_error::FetchFailure::NoRemote { WARNING() } else { DANGER() };
(color, failure.label())
}
fn repo_indicator_line(app: &App, summary: &RepoSummary) -> Line<'static> {
let dot_color = if summary.conflicted > 0 { DANGER() } else { SUCCESS() };
let mut spans = vec![Span::styled(app.sym("bullet_filled"), Style::default().fg(dot_color))];
if summary.unchanged() {
spans.push(Span::raw(" "));
spans.push(Span::styled("clean", muted_style()));
return Line::from(spans);
}
let ahead_style = if summary.ahead <= 3 {
Style::default().fg(SUCCESS()).add_modifier(Modifier::BOLD)
} else if summary.ahead <= 10 {
Style::default().fg(WARNING()).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(DANGER()).add_modifier(Modifier::BOLD)
};
let behind_style = if summary.behind <= 5 {
Style::default().fg(WARNING()).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(DANGER()).add_modifier(Modifier::BOLD)
};
let parts = [
(summary.staged, "+", Style::default().fg(ACCENT())),
(summary.modified, "!", Style::default().fg(WARNING())),
(summary.untracked, "?", muted_style()),
(summary.conflicted, app.sym("action").trim(), Style::default().fg(DANGER())),
(summary.ahead, app.sym("up"), ahead_style),
(summary.behind, app.sym("down"), behind_style),
];
for (count, symbol, style) in parts {
if count > 0 {
spans.push(Span::raw(" "));
spans.push(Span::styled(format!("{}{}", count, symbol), style));
}
}
Line::from(spans)
}
pub(crate) fn draw_input_status(
f: &mut Frame,
area: Rect,
verb: &str,
buffer: &str,
is_compat: bool,
cursor: usize,
) {
let mut spans = Vec::new();
spans.push(Span::styled(
"INPUT",
Style::default().fg(ratatui::style::Color::Red).add_modifier(Modifier::BOLD),
));
let mode_sep = if is_compat { " > " } else { " ⟩ " };
spans.push(Span::styled(mode_sep, muted_style()));
let prefix = format!("{} › ", verb);
spans.push(Span::styled(
prefix.clone(),
Style::default().fg(WARNING()).add_modifier(Modifier::BOLD),
));
spans.push(Span::styled(buffer.to_string(), primary_style()));
let separator = if is_compat { " > " } else { " ⟩ " };
spans.push(Span::styled(separator, muted_style()));
spans.push(Span::raw("Save"));
spans.push(Span::raw(" "));
spans.push(Span::styled("[", muted_style()));
spans.push(Span::styled("↵", accent_style()));
spans.push(Span::styled("]", muted_style()));
spans.push(Span::styled(separator, muted_style()));
spans.push(Span::raw("Cancel"));
spans.push(Span::raw(" "));
spans.push(Span::styled("[", muted_style()));
let cancel_key = if is_compat { "Esc" } else { "⎋" };
spans.push(Span::styled(cancel_key, accent_style()));
spans.push(Span::styled("]", muted_style()));
let para = Paragraph::new(Line::from(spans));
f.render_widget(para, area);
let badge_offset = 5 + 3;
let caret = cursor.min(buffer.chars().count());
let cursor_offset = (badge_offset + prefix.chars().count() + caret) as u16;
let cursor_x = area.x.saturating_add(cursor_offset.min(area.width.saturating_sub(1)));
f.set_cursor_position(Position::new(cursor_x, area.y));
}
pub(crate) fn wrap_str(s: &str, max_width: usize) -> Vec<String> {
if s.is_empty() {
return vec![String::new()];
}
let mut lines = Vec::new();
for paragraph in s.lines() {
let mut current_line = String::new();
for word in paragraph.split_whitespace() {
if current_line.is_empty() {
if word.chars().count() > max_width {
let chars: Vec<char> = word.chars().collect();
let mut i = 0;
while i < chars.len() {
let end = (i + max_width).min(chars.len());
lines.push(chars[i..end].iter().collect());
i = end;
}
} else {
current_line.push_str(word);
}
} else {
let proposed_len = current_line.chars().count() + 1 + word.chars().count();
if proposed_len <= max_width {
current_line.push(' ');
current_line.push_str(word);
} else {
lines.push(current_line);
if word.chars().count() > max_width {
let chars: Vec<char> = word.chars().collect();
let mut i = 0;
while i < chars.len() {
let end = (i + max_width).min(chars.len());
lines.push(chars[i..end].iter().collect());
i = end;
}
current_line = String::new();
} else {
current_line = word.to_string();
}
}
}
}
if !current_line.is_empty() {
lines.push(current_line);
}
}
if lines.is_empty() { vec![String::new()] } else { lines }
}
pub(crate) fn wrap_excludes(val_str: &str, max_width: usize) -> Vec<String> {
let mut lines: Vec<String> = Vec::new();
let mut current_line = String::new();
let parts: Vec<&str> = val_str.split(',').collect();
for (idx, part) in parts.iter().enumerate() {
let suffix = if idx + 1 < parts.len() { "," } else { "" };
let item = format!("{}{}", part, suffix);
if current_line.chars().count() + item.chars().count() > max_width {
if !current_line.is_empty() {
lines.push(current_line);
current_line = String::new();
}
if item.chars().count() > max_width {
let mut sub_chunks = wrap_str(&item, max_width);
if let Some(last) = sub_chunks.pop() {
current_line = last;
}
lines.extend(sub_chunks);
} else {
current_line = item;
}
} else {
current_line.push_str(&item);
}
}
if !current_line.is_empty() {
lines.push(current_line);
}
if lines.is_empty() { vec![String::new()] } else { lines }
}
#[allow(clippy::needless_range_loop)]
pub(crate) fn confirm_tag_delete_entries(
target: &str,
is_on_remote: bool,
) -> (Option<Vec<Span<'static>>>, Vec<StatusEntry>) {
let mut spans = vec![
Span::raw("Delete tag "),
Span::styled(
format!("\"{}\"", target),
Style::default().fg(DANGER()).add_modifier(Modifier::BOLD),
),
Span::raw("? "),
];
if is_on_remote {
spans.push(Span::styled(
"(will also delete from remote) ",
Style::default().fg(WARNING()).add_modifier(Modifier::BOLD),
));
}
let message_spans = Some(spans);
let entries = vec![
StatusEntry::new(vec![
Span::raw("Confirm"),
Span::raw(" "),
Span::styled("[", muted_style()),
Span::styled("y", Style::default().fg(DANGER()).add_modifier(Modifier::BOLD)),
Span::styled("]", muted_style()),
]),
StatusEntry::new(vec![
Span::styled(" ", muted_style()),
Span::raw("Cancel"),
Span::raw(" "),
Span::styled("[", muted_style()),
Span::styled("n/⎋", accent_style()),
Span::styled("]", muted_style()),
]),
];
(message_spans, entries)
}
pub(crate) fn confirm_tag_push_entries(
target: &str,
) -> (Option<Vec<Span<'static>>>, Vec<StatusEntry>) {
let message_spans = Some(vec![
Span::raw("Push tag "),
Span::styled(
format!("\"{}\"", target),
Style::default().fg(SUCCESS()).add_modifier(Modifier::BOLD),
),
Span::raw("? "),
]);
let entries = vec![
StatusEntry::new(vec![
Span::raw("Confirm"),
Span::raw(" "),
Span::styled("[", muted_style()),
Span::styled("y", Style::default().fg(SUCCESS()).add_modifier(Modifier::BOLD)),
Span::styled("]", muted_style()),
]),
StatusEntry::new(vec![
Span::styled(" ", muted_style()),
Span::raw("Cancel"),
Span::raw(" "),
Span::styled("[", muted_style()),
Span::styled("n/⎋", accent_style()),
Span::styled("]", muted_style()),
]),
];
(message_spans, entries)
}
pub(crate) fn confirm_tag_push_all_entries() -> (Option<Vec<Span<'static>>>, Vec<StatusEntry>) {
let message_spans = Some(vec![
Span::raw("Push "),
Span::styled("ALL", Style::default().fg(WARNING()).add_modifier(Modifier::BOLD)),
Span::raw(" local tags? "),
]);
let entries = vec![
StatusEntry::new(vec![
Span::raw("Confirm"),
Span::raw(" "),
Span::styled("[", muted_style()),
Span::styled("y", Style::default().fg(SUCCESS()).add_modifier(Modifier::BOLD)),
Span::styled("]", muted_style()),
]),
StatusEntry::new(vec![
Span::styled(" ", muted_style()),
Span::raw("Cancel"),
Span::raw(" "),
Span::styled("[", muted_style()),
Span::styled("n/⎋", accent_style()),
Span::styled("]", muted_style()),
]),
];
(message_spans, entries)
}
#[cfg(unix)]
#[allow(unsafe_code)]
pub(crate) fn get_process_stats(app: &App) -> (f64, f64) {
if let Ok(mut guard) = app.cpu_tracker.lock() {
let now = std::time::Instant::now();
if let Some((_, prev_time, cached_cpu, cached_rss)) = *guard {
if now.duration_since(prev_time) < std::time::Duration::from_millis(2000) {
return (cached_rss, cached_cpu);
}
}
let mut usage = std::mem::MaybeUninit::<libc::rusage>::uninit();
let res = unsafe { libc::getrusage(libc::RUSAGE_SELF, usage.as_mut_ptr()) };
if res != 0 {
if let Some((_, _, cached_cpu, cached_rss)) = *guard {
return (cached_rss, cached_cpu);
}
return (0.0, 0.0);
}
let usage = unsafe { usage.assume_init() };
#[cfg(target_os = "macos")]
let rss_bytes = usage.ru_maxrss as f64;
#[cfg(not(target_os = "macos"))]
let rss_bytes = (usage.ru_maxrss * 1024) as f64;
let rss_mb = rss_bytes / (1024.0 * 1024.0);
let user_sec = usage.ru_utime.tv_sec as f64 + (usage.ru_utime.tv_usec as f64 / 1_000_000.0);
let sys_sec = usage.ru_stime.tv_sec as f64 + (usage.ru_stime.tv_usec as f64 / 1_000_000.0);
let total_cpu_sec = user_sec + sys_sec;
let mut cpu_pct = 0.0;
if let Some((prev_cpu, prev_time, _, _)) = *guard {
let delta_cpu = total_cpu_sec - prev_cpu;
let delta_time = now.duration_since(prev_time).as_secs_f64();
if delta_time > 0.0 {
cpu_pct = (delta_cpu / delta_time) * 100.0;
}
}
*guard = Some((total_cpu_sec, now, cpu_pct, rss_mb));
(rss_mb, cpu_pct)
} else {
(0.0, 0.0)
}
}
#[cfg(not(unix))]
pub(crate) fn get_process_stats(_app: &App) -> (f64, f64) {
(0.0, 0.0)
}
pub fn draw_repo_jump_popup(f: &mut Frame, app: &App, area: Rect) {
let popup_area = crate::ui::layout::centered_rect(60, 50, area);
f.render_widget(ratatui::widgets::Clear, popup_area);
let border_style = Style::default().fg(ACCENT());
let block = Block::default()
.borders(Borders::ALL)
.border_type(CARD_BORDER())
.border_style(border_style)
.title(Line::from(vec![
Span::raw(" "),
Span::styled("Jump to Repository", primary_style().add_modifier(Modifier::BOLD)),
Span::raw(" "),
]))
.padding(Padding::horizontal(1));
let inner = block.inner(popup_area);
f.render_widget(block, popup_area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Min(1), Constraint::Length(1), ])
.split(inner);
let input_block =
Block::default().borders(Borders::ALL).border_style(muted_style()).title(" Search Query ");
let input_p = Paragraph::new(Line::from(vec![
Span::raw("> "),
Span::styled(app.input_buffer.clone(), primary_style()),
]))
.block(input_block);
f.render_widget(input_p, chunks[0]);
let matches = app.get_jump_matches();
let list_items: Vec<ratatui::widgets::ListItem> = matches
.iter()
.enumerate()
.map(|(i, (_, path, name))| {
let style = if i == app.repo_jump_selection {
accent_style().add_modifier(Modifier::BOLD | Modifier::REVERSED)
} else {
primary_style()
};
ratatui::widgets::ListItem::new(Line::from(vec![
Span::raw(" "),
Span::styled(name.clone(), style),
Span::raw(" "),
Span::styled(path.clone(), muted_style()),
]))
})
.collect();
let mut list_state = ratatui::widgets::ListState::default();
if !matches.is_empty() {
list_state.select(Some(app.repo_jump_selection));
}
f.render_stateful_widget(ratatui::widgets::List::new(list_items), chunks[1], &mut list_state);
let hint = Line::from(vec![
Span::styled("Type to filter ", muted_style()),
Span::styled("↑↓ navigate ", muted_style()),
Span::styled("Enter", accent_style().add_modifier(Modifier::BOLD)),
Span::styled(" jump ", muted_style()),
Span::styled("Esc", accent_style().add_modifier(Modifier::BOLD)),
Span::styled(" cancel", muted_style()),
]);
f.render_widget(Paragraph::new(hint), chunks[2]);
}
pub fn draw_label_picker_popup(f: &mut Frame, app: &App, area: Rect) {
let popup_area = crate::ui::layout::centered_rect(50, 50, area);
f.render_widget(ratatui::widgets::Clear, popup_area);
let block = Block::default()
.borders(Borders::ALL)
.border_type(CARD_BORDER())
.border_style(Style::default().fg(ACCENT()))
.title(Line::from(vec![
Span::raw(" "),
Span::styled("Filter by Label", primary_style().add_modifier(Modifier::BOLD)),
Span::raw(" "),
]))
.padding(Padding::horizontal(1));
let inner = block.inner(popup_area);
f.render_widget(block, popup_area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Min(1), Constraint::Length(1), ])
.split(inner);
let input_block =
Block::default().borders(Borders::ALL).border_style(muted_style()).title(" Search Labels ");
let input_p = Paragraph::new(Line::from(vec![
Span::raw("> "),
Span::styled(app.input_buffer.clone(), primary_style()),
]))
.block(input_block);
f.render_widget(input_p, chunks[0]);
let matches = app.get_label_matches();
if matches.len() == 1 && app.input_buffer.is_empty() {
let empty_hint = Paragraph::new(Line::from(Span::styled(
"No labels yet — press l on a repository to add labels",
muted_style(),
)));
f.render_widget(empty_hint, chunks[1]);
} else {
let active_marker = if app.config.compatibility_mode { "* " } else { "● " };
let quick = app.quick_labels();
let list_items: Vec<ratatui::widgets::ListItem> = matches
.iter()
.enumerate()
.map(|(i, (label, count))| {
let marker =
if *label == app.state.active_label_filter { active_marker } else { " " };
let slot_key = label
.as_deref()
.and_then(|l| quick.iter().position(|(q, _)| q == l))
.and_then(crate::keybindings::Action::home_label_slot)
.map(|a| app.keybindings.format_action_keys(a, app.config.compatibility_mode));
let slot_text =
slot_key.map(|k| format!("{} ", k)).unwrap_or_else(|| " ".to_string());
let name = label.clone().unwrap_or_else(|| "All repositories".to_string());
let style = if i == app.label_picker_selection {
accent_style().add_modifier(Modifier::BOLD | Modifier::REVERSED)
} else if label.is_none() {
muted_style()
} else {
primary_style()
};
let repos_word = if *count == 1 { "repo" } else { "repos" };
ratatui::widgets::ListItem::new(Line::from(vec![
Span::styled(marker, accent_style()),
Span::styled(slot_text, accent_style().add_modifier(Modifier::BOLD)),
Span::styled(name, style),
Span::styled(format!(" {} {}", count, repos_word), muted_style()),
]))
})
.collect();
let mut list_state = ratatui::widgets::ListState::default();
if !matches.is_empty() {
list_state.select(Some(app.label_picker_selection.min(matches.len() - 1)));
}
f.render_stateful_widget(
ratatui::widgets::List::new(list_items),
chunks[1],
&mut list_state,
);
}
let hint = Line::from(vec![
Span::styled("Type to filter ", muted_style()),
Span::styled("↑↓ navigate ", muted_style()),
Span::styled("Enter", accent_style().add_modifier(Modifier::BOLD)),
Span::styled(" select/toggle ", muted_style()),
Span::styled("→", accent_style().add_modifier(Modifier::BOLD)),
Span::styled(" settings ", muted_style()),
Span::styled("Esc", accent_style().add_modifier(Modifier::BOLD)),
Span::styled(" cancel", muted_style()),
]);
f.render_widget(Paragraph::new(hint), chunks[2]);
}
pub fn draw_global_search_popup(f: &mut Frame, app: &App, area: Rect) {
let popup_area = crate::ui::layout::centered_rect(85, 75, area);
f.render_widget(ratatui::widgets::Clear, popup_area);
let border_style = Style::default().fg(ACCENT());
let block = Block::default()
.borders(Borders::ALL)
.border_type(CARD_BORDER())
.border_style(border_style)
.title(Line::from(vec![
Span::raw(" "),
Span::styled("Global Code Search", primary_style().add_modifier(Modifier::BOLD)),
Span::raw(" "),
]))
.padding(Padding::horizontal(1));
let inner = block.inner(popup_area);
f.render_widget(block, popup_area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Min(1), Constraint::Length(1), ])
.split(inner);
let input_border_style =
if app.global_search_focus_input { Style::default().fg(ACCENT()) } else { muted_style() };
let input_block = Block::default()
.borders(Borders::ALL)
.border_style(input_border_style)
.title(" Search Query ");
let input_p = Paragraph::new(Line::from(vec![
Span::raw("> "),
Span::styled(app.input_buffer.clone(), primary_style()),
]))
.block(input_block);
f.render_widget(input_p, chunks[0]);
let list_border_style =
if !app.global_search_focus_input { Style::default().fg(ACCENT()) } else { muted_style() };
let list_block =
Block::default().borders(Borders::ALL).border_style(list_border_style).title(" Results ");
if app.global_search_running {
let loading_p = Paragraph::new(Line::from(vec![Span::styled(
" Searching across all tracked repositories...",
accent_style().add_modifier(Modifier::BOLD),
)]))
.block(list_block);
f.render_widget(loading_p, chunks[1]);
} else if app.global_search_results.is_empty() {
let empty_msg = if app.global_search_query.is_empty() {
"Type a query and press Enter to start searching."
} else {
"No matching string patterns found."
};
let empty_p = Paragraph::new(Line::from(vec![Span::styled(
format!(" {}", empty_msg),
muted_style(),
)]))
.block(list_block);
f.render_widget(empty_p, chunks[1]);
} else {
let list_items: Vec<ratatui::widgets::ListItem> = app
.global_search_results
.iter()
.enumerate()
.map(|(i, res)| {
let is_selected = i == app.global_search_selection;
let row_style = if is_selected && !app.global_search_focus_input {
accent_style().add_modifier(Modifier::BOLD | Modifier::REVERSED)
} else {
primary_style()
};
let row_line = Line::from(vec![
Span::styled(
format!("[{}] ", res.repo_name),
if is_selected && !app.global_search_focus_input {
row_style
} else {
Style::default().fg(ratatui::style::Color::Green)
},
),
Span::styled(
format!("{}:{} ", res.file_rel_path, res.line_number),
if is_selected && !app.global_search_focus_input {
row_style
} else {
Style::default().fg(ACCENT())
},
),
Span::styled(res.line_content.clone(), row_style),
]);
ratatui::widgets::ListItem::new(row_line)
})
.collect();
let mut list_state = ratatui::widgets::ListState::default();
list_state.select(Some(app.global_search_selection));
f.render_stateful_widget(
ratatui::widgets::List::new(list_items).block(list_block),
chunks[1],
&mut list_state,
);
}
let hint = Line::from(vec![
Span::styled("Tab", accent_style().add_modifier(Modifier::BOLD)),
Span::styled(" switch focus ", muted_style()),
Span::styled("Enter", accent_style().add_modifier(Modifier::BOLD)),
Span::styled(
if app.global_search_focus_input { " search " } else { " open repo " },
muted_style(),
),
Span::styled("↑↓ navigate ", muted_style()),
Span::styled("Esc", accent_style().add_modifier(Modifier::BOLD)),
Span::styled(" close", muted_style()),
]);
f.render_widget(Paragraph::new(hint), chunks[2]);
}
pub fn draw_repo_scan_popup(f: &mut Frame, app: &App, area: Rect) {
let popup_area = crate::ui::layout::centered_rect(70, 60, area);
f.render_widget(ratatui::widgets::Clear, popup_area);
let border_style = Style::default().fg(ACCENT());
let title_text = if app.repo_scan_active {
format!(" Scan & Add Repository (Discovered {}) ", app.scanned_repos.len())
} else {
format!(" Scan & Add Repository (Found {} total) ", app.scanned_repos.len())
};
let block = Block::default()
.borders(Borders::ALL)
.border_type(CARD_BORDER())
.border_style(border_style)
.title(Line::from(vec![
Span::raw(" "),
Span::styled(title_text, primary_style().add_modifier(Modifier::BOLD)),
Span::raw(" "),
]))
.padding(Padding::horizontal(1));
let inner = block.inner(popup_area);
f.render_widget(block, popup_area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Min(1), Constraint::Length(1), ])
.split(inner);
let input_block = Block::default()
.borders(Borders::ALL)
.border_style(muted_style())
.title(" Search Filter / Manual Path ");
let input_p = Paragraph::new(Line::from(vec![
Span::raw("> "),
Span::styled(app.input_buffer.clone(), primary_style()),
]))
.block(input_block);
f.render_widget(input_p, chunks[0]);
let matches = app.get_scan_matches();
let list_items: Vec<ratatui::widgets::ListItem> = matches
.iter()
.enumerate()
.map(|(i, (name, path))| {
let style = if i == app.repo_scan_selection {
accent_style().add_modifier(Modifier::BOLD | Modifier::REVERSED)
} else {
primary_style()
};
ratatui::widgets::ListItem::new(Line::from(vec![
Span::raw(" "),
Span::styled(name.clone(), style),
Span::raw(" "),
Span::styled(path.clone(), muted_style()),
]))
})
.collect();
let mut list_state = ratatui::widgets::ListState::default();
if !matches.is_empty() {
list_state.select(Some(app.repo_scan_selection));
}
f.render_stateful_widget(ratatui::widgets::List::new(list_items), chunks[1], &mut list_state);
let status_text = if app.repo_scan_active {
let spinner_chars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
let idx = (app.repo_scan_count / 10) % spinner_chars.len();
format!("{} Scanning: {} folders visited...", spinner_chars[idx], app.repo_scan_count)
} else {
"✓ Scanning completed.".to_string()
};
let hint = Line::from(vec![
Span::styled(status_text, Style::default().fg(ACCENT())),
Span::raw(" | "),
Span::styled("Type to filter/manual path ", muted_style()),
Span::styled("↑↓ navigate ", muted_style()),
Span::styled("Enter", accent_style().add_modifier(Modifier::BOLD)),
Span::styled(" add ", muted_style()),
Span::styled("Esc", accent_style().add_modifier(Modifier::BOLD)),
Span::styled(" cancel", muted_style()),
]);
f.render_widget(Paragraph::new(hint), chunks[2]);
}
pub fn draw_bulk_add_scan_popup(f: &mut Frame, app: &App, area: Rect) {
let popup_area = crate::ui::layout::centered_rect(70, 60, area);
f.render_widget(ratatui::widgets::Clear, popup_area);
let border_style = Style::default().fg(ACCENT());
let title_text = if app.repo_scan_active {
format!(" Scan & Bulk Add (Discovered {}) ", app.scanned_repos.len())
} else {
format!(" Scan & Bulk Add (Found {} total) ", app.scanned_repos.len())
};
let block = Block::default()
.borders(Borders::ALL)
.border_type(CARD_BORDER())
.border_style(border_style)
.title(Line::from(vec![
Span::raw(" "),
Span::styled(title_text, primary_style().add_modifier(Modifier::BOLD)),
Span::raw(" "),
]))
.padding(Padding::horizontal(1));
let inner = block.inner(popup_area);
f.render_widget(block, popup_area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Min(1), Constraint::Length(1), ])
.split(inner);
let input_block = Block::default()
.borders(Borders::ALL)
.border_style(muted_style())
.title(" Search Filter / Manual Path ");
let input_p = Paragraph::new(Line::from(vec![
Span::raw("> "),
Span::styled(app.input_buffer.clone(), primary_style()),
]))
.block(input_block);
f.render_widget(input_p, chunks[0]);
let matches = app.get_scan_matches();
let list_items: Vec<ratatui::widgets::ListItem> = matches
.iter()
.enumerate()
.map(|(i, (name, path))| {
let style = if i == app.repo_scan_selection {
accent_style().add_modifier(Modifier::BOLD | Modifier::REVERSED)
} else {
primary_style()
};
ratatui::widgets::ListItem::new(Line::from(vec![
Span::raw(" "),
Span::styled(name.clone(), style),
Span::raw(" "),
Span::styled(path.clone(), muted_style()),
]))
})
.collect();
let mut list_state = ratatui::widgets::ListState::default();
if !matches.is_empty() {
list_state.select(Some(app.repo_scan_selection));
}
f.render_stateful_widget(ratatui::widgets::List::new(list_items), chunks[1], &mut list_state);
let status_text = if app.repo_scan_active {
let spinner_chars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
let idx = (app.repo_scan_count / 10) % spinner_chars.len();
format!("{} Scanning: {} folders visited...", spinner_chars[idx], app.repo_scan_count)
} else {
"✓ Scanning completed.".to_string()
};
let hint = Line::from(vec![
Span::styled(status_text, Style::default().fg(ACCENT())),
Span::raw(" | "),
Span::styled("Type to filter/manual path ", muted_style()),
Span::styled("↑↓ navigate ", muted_style()),
Span::styled("Enter", accent_style().add_modifier(Modifier::BOLD)),
Span::styled(" bulk add ", muted_style()),
Span::styled("Esc", accent_style().add_modifier(Modifier::BOLD)),
Span::styled(" cancel", muted_style()),
]);
f.render_widget(Paragraph::new(hint), chunks[2]);
}
pub fn draw_branch_search_popup(f: &mut Frame, app: &App, area: Rect) {
let popup_area = crate::ui::layout::centered_rect(60, 50, area);
f.render_widget(ratatui::widgets::Clear, popup_area);
let border_style = Style::default().fg(ACCENT());
let block = Block::default()
.borders(Borders::ALL)
.border_type(CARD_BORDER())
.border_style(border_style)
.title(Line::from(vec![
Span::raw(" "),
Span::styled("Fuzzy Branch Search", primary_style().add_modifier(Modifier::BOLD)),
Span::raw(" "),
]))
.padding(Padding::horizontal(1));
let inner = block.inner(popup_area);
f.render_widget(block, popup_area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Min(1), Constraint::Length(1), ])
.split(inner);
let input_block =
Block::default().borders(Borders::ALL).border_style(muted_style()).title(" Search Branch ");
let input_p = Paragraph::new(Line::from(vec![
Span::raw("> "),
Span::styled(app.input_buffer.clone(), primary_style()),
]))
.block(input_block);
f.render_widget(input_p, chunks[0]);
let matches = app.get_branch_search_matches();
let list_items: Vec<ratatui::widgets::ListItem> = matches
.iter()
.enumerate()
.map(|(i, (name, is_remote))| {
let style = if i == app.branch_search_selection {
accent_style().add_modifier(Modifier::BOLD | Modifier::REVERSED)
} else {
primary_style()
};
let type_str = if *is_remote { "[remote] " } else { "[local] " };
let type_color =
if *is_remote { ratatui::style::Color::Red } else { ratatui::style::Color::Green };
ratatui::widgets::ListItem::new(Line::from(vec![
Span::raw(" "),
Span::styled(type_str, Style::default().fg(type_color)),
Span::styled(name.clone(), style),
]))
})
.collect();
let mut list_state = ratatui::widgets::ListState::default();
if !matches.is_empty() {
list_state.select(Some(app.branch_search_selection));
}
f.render_stateful_widget(ratatui::widgets::List::new(list_items), chunks[1], &mut list_state);
let hint = Line::from(vec![
Span::styled("Type to filter ", muted_style()),
Span::styled("↑↓ navigate ", muted_style()),
Span::styled("Enter", accent_style().add_modifier(Modifier::BOLD)),
Span::styled(" checkout ", muted_style()),
Span::styled("Esc", accent_style().add_modifier(Modifier::BOLD)),
Span::styled(" cancel", muted_style()),
]);
f.render_widget(Paragraph::new(hint), chunks[2]);
}
pub fn draw_file_search_popup(f: &mut Frame, app: &App, area: Rect) {
let popup_area = crate::ui::layout::centered_rect(80, 60, area);
f.render_widget(ratatui::widgets::Clear, popup_area);
let border_style = Style::default().fg(ACCENT());
let block = Block::default()
.borders(Borders::ALL)
.border_type(CARD_BORDER())
.border_style(border_style)
.title(Line::from(vec![
Span::raw(" "),
Span::styled("Fuzzy File Search", primary_style().add_modifier(Modifier::BOLD)),
Span::raw(" "),
]))
.padding(Padding::horizontal(1));
let inner = block.inner(popup_area);
f.render_widget(block, popup_area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Min(1), Constraint::Length(1), ])
.split(inner);
let input_block =
Block::default().borders(Borders::ALL).border_style(muted_style()).title(" Search File ");
let input_p = Paragraph::new(Line::from(vec![
Span::raw("> "),
Span::styled(app.input_buffer.clone(), primary_style()),
]))
.block(input_block);
f.render_widget(input_p, chunks[0]);
let matches = app.get_file_search_matches();
let list_items: Vec<ratatui::widgets::ListItem> = matches
.iter()
.enumerate()
.map(|(i, path)| {
let style = if i == app.file_search_selection {
accent_style().add_modifier(Modifier::BOLD | Modifier::REVERSED)
} else {
primary_style()
};
ratatui::widgets::ListItem::new(Line::from(vec![
Span::raw(" "),
Span::styled(path.clone(), style),
]))
})
.collect();
let mut list_state = ratatui::widgets::ListState::default();
if !matches.is_empty() {
list_state.select(Some(app.file_search_selection));
}
f.render_stateful_widget(ratatui::widgets::List::new(list_items), chunks[1], &mut list_state);
let hint = Line::from(vec![
Span::styled("Type to filter ", muted_style()),
Span::styled("↑↓ navigate ", muted_style()),
Span::styled("Enter", accent_style().add_modifier(Modifier::BOLD)),
Span::styled(" select ", muted_style()),
Span::styled("Esc", accent_style().add_modifier(Modifier::BOLD)),
Span::styled(" cancel", muted_style()),
]);
f.render_widget(Paragraph::new(hint), chunks[2]);
}
pub fn draw_commit_fuzzy_popup(f: &mut Frame, app: &App, area: Rect) {
let popup_area = crate::ui::layout::centered_rect(80, 60, area);
f.render_widget(ratatui::widgets::Clear, popup_area);
let border_style = Style::default().fg(ACCENT());
let block = Block::default()
.borders(Borders::ALL)
.border_type(CARD_BORDER())
.border_style(border_style)
.title(Line::from(vec![
Span::raw(" "),
Span::styled("Fuzzy Commit Search", primary_style().add_modifier(Modifier::BOLD)),
Span::raw(" "),
]))
.padding(Padding::horizontal(1));
let inner = block.inner(popup_area);
f.render_widget(block, popup_area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Min(1), Constraint::Length(1), ])
.split(inner);
let input_block = Block::default()
.borders(Borders::ALL)
.border_style(muted_style())
.title(" Search Commit (summary / author / SHA) ");
let input_p = Paragraph::new(Line::from(vec![
Span::raw("> "),
Span::styled(app.input_buffer.clone(), primary_style()),
]))
.block(input_block);
f.render_widget(input_p, chunks[0]);
let matches = app.get_commit_fuzzy_matches();
let list_items: Vec<ratatui::widgets::ListItem> = matches
.iter()
.enumerate()
.map(|(i, (_, sha, author, summary))| {
let style = if i == app.commit_search_selection {
accent_style().add_modifier(Modifier::BOLD | Modifier::REVERSED)
} else {
primary_style()
};
let short_sha = if sha.len() >= 7 { &sha[..7] } else { sha };
ratatui::widgets::ListItem::new(Line::from(vec![
Span::raw(" "),
Span::styled(
format!("{} ", short_sha),
Style::default().fg(ratatui::style::Color::Yellow),
),
Span::styled(
format!("({}) ", author),
Style::default().fg(ratatui::style::Color::Green),
),
Span::styled(summary.clone(), style),
]))
})
.collect();
let mut list_state = ratatui::widgets::ListState::default();
if !matches.is_empty() {
list_state.select(Some(app.commit_search_selection));
}
f.render_stateful_widget(ratatui::widgets::List::new(list_items), chunks[1], &mut list_state);
let hint = Line::from(vec![
Span::styled("Type to filter ", muted_style()),
Span::styled("↑↓ navigate ", muted_style()),
Span::styled("Enter", accent_style().add_modifier(Modifier::BOLD)),
Span::styled(" select & jump ", muted_style()),
Span::styled("Esc", accent_style().add_modifier(Modifier::BOLD)),
Span::styled(" cancel", muted_style()),
]);
f.render_widget(Paragraph::new(hint), chunks[2]);
}
pub fn draw_tag_search_popup(f: &mut Frame, app: &App, area: Rect) {
let popup_area = crate::ui::layout::centered_rect(60, 50, area);
f.render_widget(ratatui::widgets::Clear, popup_area);
let border_style = Style::default().fg(ACCENT());
let block = Block::default()
.borders(Borders::ALL)
.border_type(CARD_BORDER())
.border_style(border_style)
.title(Line::from(vec![
Span::raw(" "),
Span::styled("Fuzzy Tag Search", primary_style().add_modifier(Modifier::BOLD)),
Span::raw(" "),
]))
.padding(Padding::horizontal(1));
let inner = block.inner(popup_area);
f.render_widget(block, popup_area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Min(1), Constraint::Length(1), ])
.split(inner);
let input_block =
Block::default().borders(Borders::ALL).border_style(muted_style()).title(" Search Tag ");
let input_p = Paragraph::new(Line::from(vec![
Span::raw("> "),
Span::styled(app.input_buffer.clone(), primary_style()),
]))
.block(input_block);
f.render_widget(input_p, chunks[0]);
let matches = app.get_tag_search_matches();
let list_items: Vec<ratatui::widgets::ListItem> = matches
.iter()
.enumerate()
.map(|(i, name)| {
let style = if i == app.tag_search_selection {
accent_style().add_modifier(Modifier::BOLD | Modifier::REVERSED)
} else {
primary_style()
};
ratatui::widgets::ListItem::new(Line::from(vec![
Span::raw(" "),
Span::styled(name.clone(), style),
]))
})
.collect();
let mut list_state = ratatui::widgets::ListState::default();
if !matches.is_empty() {
list_state.select(Some(app.tag_search_selection));
}
f.render_stateful_widget(ratatui::widgets::List::new(list_items), chunks[1], &mut list_state);
let hint = Line::from(vec![
Span::styled("Type to filter ", muted_style()),
Span::styled("↑↓ navigate ", muted_style()),
Span::styled("Enter", accent_style().add_modifier(Modifier::BOLD)),
Span::styled(" checkout ", muted_style()),
Span::styled("Esc", accent_style().add_modifier(Modifier::BOLD)),
Span::styled(" cancel", muted_style()),
]);
f.render_widget(Paragraph::new(hint), chunks[2]);
}
fn draw_not_git_repo_popup(f: &mut Frame, area: Rect, app: &App) {
use crate::ui::layout::centered_rect_fixed;
use ratatui::style::{Color, Modifier};
use ratatui::text::Text;
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap};
let popup_area = centered_rect_fixed(56, 9, area);
f.render_widget(Clear, popup_area);
let is_compat = app.config.compatibility_mode;
let icon = if is_compat { "[!] " } else { "⚠ " };
let border_style = Style::default().fg(DANGER());
let block = Block::default()
.borders(Borders::ALL)
.border_type(CARD_BORDER())
.border_style(border_style)
.title(
ratatui::text::Line::from(vec![
ratatui::text::Span::raw(" "),
ratatui::text::Span::styled(
format!("{}Not a Git Repository", icon),
border_style.add_modifier(Modifier::BOLD),
),
ratatui::text::Span::raw(" "),
])
.alignment(ratatui::layout::Alignment::Center),
);
let path_label = app.get_selected_item().cloned().unwrap_or_else(|| "<unknown>".to_string());
let body = Text::from(vec![
ratatui::text::Line::from(""),
ratatui::text::Line::from(vec![ratatui::text::Span::styled(
path_label,
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
)]),
ratatui::text::Line::from(""),
ratatui::text::Line::from(vec![ratatui::text::Span::styled(
"This entry is not a git repository.",
Style::default(),
)]),
ratatui::text::Line::from(vec![ratatui::text::Span::styled(
"Please remove it from your list.",
muted_style(),
)]),
ratatui::text::Line::from(""),
ratatui::text::Line::from(vec![
ratatui::text::Span::styled("Press ", muted_style()),
ratatui::text::Span::styled(
"Esc / Enter / q",
accent_style().add_modifier(Modifier::BOLD),
),
ratatui::text::Span::styled(" to dismiss", muted_style()),
]),
]);
f.render_widget(block.clone(), popup_area);
let inner = block.inner(popup_area);
f.render_widget(
Paragraph::new(body)
.alignment(ratatui::layout::Alignment::Center)
.wrap(Wrap { trim: true }),
inner,
);
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::panic)]
mod tests {
use super::*;
use crate::app::{App, DetailSection};
use crate::components::cmd_bar::{detail_dismiss_entries, inspect_dismiss_entries};
use crate::config::{Config, ScanConfig, SortOrder, ThemeConfig};
use crate::repo::{FileEntry, ItemDetail, RepoInfo};
use std::path::PathBuf;
#[test]
fn test_inspect_status_bar_shortcuts() {
let config = Config {
items: vec![],
poll_interval_ms: 100,
max_commits: 0,
page_size: 10,
sort_by: SortOrder::Custom,
labels: std::collections::HashMap::new(),
sort_reverse: false,
pinned: std::collections::HashSet::new(),
theme: ThemeConfig::default(),
theme_name: "default".to_string(),
scan: ScanConfig::default(),
git_app: "gitui".to_string(),
compatibility_mode: false,
detail_cache_ttl_secs: 30,
enable_commit_signatures: false,
tab_ttl_secs: 60,
resync_on_tab_change: false,
graph_max_commits: 1000,
..Default::default()
};
let (config_path, _guard) =
crate::test_support::temp_config_path("inspect_status_bar_shortcuts");
let mut app = App::new(config, config_path);
let mut info = RepoInfo::default();
info.changes.staged.push(FileEntry { path: "file.txt".to_string(), label: "M" });
app.current_detail =
Some(ItemDetail::Repo { resolved: PathBuf::from("/dummy"), info: Box::new(info) });
app.commit_list.selection = 0; app.in_logs_ui = false;
app.detail_focus = DetailSection::Staged;
let (_, entries) = inspect_dismiss_entries(&app);
let entry_labels: Vec<String> = entries
.iter()
.map(|entry| {
entry.spans.iter().map(|s| s.content.as_ref()).collect::<Vec<&str>>().join("")
})
.collect();
assert!(entry_labels.iter().any(|label| label.contains("Unstage File [↵]")));
assert!(entry_labels.iter().any(|label| label.contains("Unstage All [a]")));
assert!(entry_labels.iter().any(|label| label.contains("Discard [x]")));
assert!(entry_labels.iter().any(|label| label.contains("Discard All [X]")));
assert!(entry_labels.iter().any(|label| label.contains("Commit/Amend [c/C]")));
app.detail_focus = DetailSection::Unstaged;
let (_, entries) = inspect_dismiss_entries(&app);
let entry_labels: Vec<String> = entries
.iter()
.map(|entry| {
entry.spans.iter().map(|s| s.content.as_ref()).collect::<Vec<&str>>().join("")
})
.collect();
assert!(entry_labels.iter().any(|label| label.contains("Stage File [↵]")));
assert!(entry_labels.iter().any(|label| label.contains("Stage All [a]")));
assert!(entry_labels.iter().any(|label| label.contains("Discard [x]")));
assert!(entry_labels.iter().any(|label| label.contains("Discard All [X]")));
app.detail_focus = DetailSection::StagingDetails;
app.last_staging_focus = DetailSection::Staged;
let (_, entries) = inspect_dismiss_entries(&app);
let entry_labels: Vec<String> = entries
.iter()
.map(|entry| {
entry.spans.iter().map(|s| s.content.as_ref()).collect::<Vec<&str>>().join("")
})
.collect();
assert!(entry_labels.iter().any(|label| label.contains("Unstage Hunk [↵/u]")));
app.detail_focus = DetailSection::StagingDetails;
app.last_staging_focus = DetailSection::Unstaged;
let (_, entries) = inspect_dismiss_entries(&app);
let entry_labels: Vec<String> = entries
.iter()
.map(|entry| {
entry.spans.iter().map(|s| s.content.as_ref()).collect::<Vec<&str>>().join("")
})
.collect();
assert!(entry_labels.iter().any(|label| label.contains("Stage Hunk [↵/s]")));
assert!(entry_labels.iter().any(|label| label.contains("Discard Hunk [x/Del]")));
app.diff.diff_line_mode = true;
let (_, entries) = inspect_dismiss_entries(&app);
let entry_labels: Vec<String> = entries
.iter()
.map(|entry| {
entry.spans.iter().map(|s| s.content.as_ref()).collect::<Vec<&str>>().join("")
})
.collect();
assert!(entry_labels.iter().any(|label| label.contains("Stage Line [↵/s]")));
assert!(entry_labels.iter().any(|label| label.contains("Discard Line [x/Del]")));
assert!(entry_labels.iter().any(|label| label.contains("Hunk Mode [l]")));
app.inspect_full_diff = true;
let (_, entries_full) = inspect_dismiss_entries(&app);
let entry_labels_full: Vec<String> = entries_full
.iter()
.map(|entry| {
entry.spans.iter().map(|s| s.content.as_ref()).collect::<Vec<&str>>().join("")
})
.collect();
assert!(entry_labels_full.iter().any(|label| label.contains("Commit/Amend [c/C]")));
app.inspect_full_diff = false;
app.in_logs_ui = true;
let (_, entries) = inspect_dismiss_entries(&app);
let entry_labels: Vec<String> = entries
.iter()
.map(|entry| {
entry.spans.iter().map(|s| s.content.as_ref()).collect::<Vec<&str>>().join("")
})
.collect();
assert!(
!entry_labels.iter().any(|label| label.contains("Stage") || label.contains("Unstage"))
);
app.in_logs_ui = false;
app.detail_focus = DetailSection::Conflicts;
let (_, entries_c) = inspect_dismiss_entries(&app);
let entry_labels_c: Vec<String> = entries_c
.iter()
.map(|entry| {
entry.spans.iter().map(|s| s.content.as_ref()).collect::<Vec<&str>>().join("")
})
.collect();
assert!(entry_labels_c.iter().any(|label| label.contains("Accept Ours [o]")));
assert!(entry_labels_c.iter().any(|label| label.contains("Accept Theirs [t]")));
assert!(entry_labels_c.iter().any(|label| label.contains("Mark Resolved [r]")));
assert!(entry_labels_c.iter().any(|label| label.contains("Abort Merge [A]")));
assert!(entry_labels_c.iter().any(|label| label.contains("Continue Merge [C]")));
assert!(entry_labels_c.iter().any(|label| label.contains("Inspect [↵/→]")));
app.detail_focus = DetailSection::ConflictDiff;
let (_, entries_cd) = inspect_dismiss_entries(&app);
let entry_labels_cd: Vec<String> = entries_cd
.iter()
.map(|entry| {
entry.spans.iter().map(|s| s.content.as_ref()).collect::<Vec<&str>>().join("")
})
.collect();
assert!(entry_labels_cd.iter().any(|label| label.contains("Accept Ours [o]")));
assert!(entry_labels_cd.iter().any(|label| label.contains("Accept Theirs [t]")));
assert!(entry_labels_cd.iter().any(|label| label.contains("Mark Resolved [r]")));
assert!(entry_labels_cd.iter().any(|label| label.contains("Abort Merge [A]")));
assert!(entry_labels_cd.iter().any(|label| label.contains("Continue Merge [C]")));
assert!(entry_labels_cd.iter().any(|label| label.contains("Scroll Diff [↑↓/⇟⇞]")));
}
#[test]
fn test_detail_dismiss_entries_shortcuts() {
let config = Config {
items: vec![],
poll_interval_ms: 100,
max_commits: 0,
page_size: 10,
sort_by: SortOrder::Custom,
labels: std::collections::HashMap::new(),
sort_reverse: false,
pinned: std::collections::HashSet::new(),
theme: ThemeConfig::default(),
theme_name: "default".to_string(),
scan: ScanConfig::default(),
git_app: "gitui".to_string(),
compatibility_mode: false,
detail_cache_ttl_secs: 30,
enable_commit_signatures: false,
tab_ttl_secs: 60,
resync_on_tab_change: false,
graph_max_commits: 1000,
..Default::default()
};
let (config_path, _guard) =
crate::test_support::temp_config_path("detail_dismiss_entries_shortcuts");
let mut app = App::new(config, config_path);
app.detail_tab = 0;
app.detail_focus = DetailSection::Commits;
let (_, entries_w) = detail_dismiss_entries(&app);
let entry_labels_w: Vec<String> = entries_w
.iter()
.map(|entry| {
entry.spans.iter().map(|s| s.content.as_ref()).collect::<Vec<&str>>().join("")
})
.collect();
assert!(entry_labels_w.iter().any(|label| label.contains("Inspect [↵/→]")));
assert!(entry_labels_w.iter().any(|label| label.contains("Tag [t/T]")));
assert!(entry_labels_w.iter().any(|label| label.contains("Checkout [o]")));
assert!(entry_labels_w.iter().any(|label| label.contains("Cherry-pick [p/P]")));
assert!(entry_labels_w.iter().any(|label| label.contains("Load More [G]")));
assert!(entry_labels_w.iter().any(|label| label.contains("Yank Hash [y/Y]")));
app.keybindings.update_action_keys(
crate::keybindings::Action::WorkspaceCreateTag,
vec!["ctrl-t".to_string()],
);
let (_, entries_rebound) = detail_dismiss_entries(&app);
let entry_labels_rebound: Vec<String> = entries_rebound
.iter()
.map(|entry| {
entry.spans.iter().map(|s| s.content.as_ref()).collect::<Vec<&str>>().join("")
})
.collect();
assert!(entry_labels_rebound.iter().any(|label| label.contains("Tag [ctrl-t]")));
assert!(!entry_labels_rebound.iter().any(|label| label.contains("Tag [t/T]")));
app.keybindings.update_action_keys(
crate::keybindings::Action::WorkspaceCreateTag,
vec!["t".to_string(), "T".to_string()],
);
let mut info = RepoInfo::default();
info.changes.staged.push(FileEntry { path: "file.txt".to_string(), label: "M" });
info.changes.unstaged.push(FileEntry { path: "other.txt".to_string(), label: "M" });
app.current_detail =
Some(ItemDetail::Repo { resolved: PathBuf::from("/dummy"), info: Box::new(info) });
app.commit_list.selection = 0;
app.detail_focus = DetailSection::Staged;
let (_, entries_s) = detail_dismiss_entries(&app);
let entry_labels_s: Vec<String> = entries_s
.iter()
.map(|entry| {
entry.spans.iter().map(|s| s.content.as_ref()).collect::<Vec<&str>>().join("")
})
.collect();
assert!(entry_labels_s.iter().any(|label| label.contains("Inspect [→]")));
assert!(entry_labels_s.iter().any(|label| label.contains("Unstage All [a/A]")));
assert!(entry_labels_s.iter().any(|label| label.contains("Discard All [X]")));
assert!(!entry_labels_s.iter().any(|label| label.contains("Tag [t/T]")));
app.detail_focus = DetailSection::Unstaged;
let (_, entries_u) = detail_dismiss_entries(&app);
let entry_labels_u: Vec<String> = entries_u
.iter()
.map(|entry| {
entry.spans.iter().map(|s| s.content.as_ref()).collect::<Vec<&str>>().join("")
})
.collect();
assert!(entry_labels_u.iter().any(|label| label.contains("Stage All [a/A]")));
assert!(entry_labels_u.iter().any(|label| label.contains("Discard All [X]")));
app.detail_focus = DetailSection::StagingDetails;
let (_, entries_sd) = detail_dismiss_entries(&app);
let entry_labels_sd: Vec<String> = entries_sd
.iter()
.map(|entry| {
entry.spans.iter().map(|s| s.content.as_ref()).collect::<Vec<&str>>().join("")
})
.collect();
assert!(entry_labels_sd.iter().any(|label| label.contains("Full Screen [→]")));
assert!(entry_labels_sd.iter().any(|label| label.contains("Line Mode [l/L]")));
assert!(entry_labels_sd.iter().any(|label| label.contains("Stage [s/S]")));
assert!(entry_labels_sd.iter().any(|label| label.contains("Unstage [u/U]")));
assert!(!entry_labels_sd.iter().any(|label| label.contains("Stash [s/S]")));
assert!(!entry_labels_sd.iter().any(|label| label.contains("Tag [t/T]")));
app.detail_tab = 1;
app.detail_focus = DetailSection::Files;
let (_, entries_f1) = detail_dismiss_entries(&app);
let entry_labels_f1: Vec<String> = entries_f1
.iter()
.map(|entry| {
entry.spans.iter().map(|s| s.content.as_ref()).collect::<Vec<&str>>().join("")
})
.collect();
assert!(entry_labels_f1.iter().any(|label| label.contains("Fuzzy Find [/]")));
assert!(entry_labels_f1.iter().any(|label| label.contains("Expand/Toggle [→/↵/>]")));
assert!(entry_labels_f1.iter().any(|label| label.contains("Collapse All [←]")));
assert!(entry_labels_f1.iter().any(|label| label.contains("History [H]")));
assert!(entry_labels_f1.iter().any(|label| label.contains("Discard [x/X]")));
assert!(!entry_labels_f1.iter().any(|label| label.contains("Open in Editor [e/o]")));
app.file_tree.visible_files.push(crate::app::FileTreeItem {
name: "src".to_string(),
full_path: "src".to_string(),
is_dir: true,
depth: 0,
is_expanded: true,
});
app.file_tree.file_list_selection = 0;
let (_, entries_f1_dir) = detail_dismiss_entries(&app);
let entry_labels_f1_dir: Vec<String> = entries_f1_dir
.iter()
.map(|entry| {
entry.spans.iter().map(|s| s.content.as_ref()).collect::<Vec<&str>>().join("")
})
.collect();
assert!(!entry_labels_f1_dir.iter().any(|label| label.contains("Open in Editor [e/o]")));
app.file_tree.visible_files.push(crate::app::FileTreeItem {
name: "main.rs".to_string(),
full_path: "src/main.rs".to_string(),
is_dir: false,
depth: 1,
is_expanded: false,
});
app.file_tree.file_list_selection = 1;
let (_, entries_f1_file) = detail_dismiss_entries(&app);
let entry_labels_f1_file: Vec<String> = entries_f1_file
.iter()
.map(|entry| {
entry.spans.iter().map(|s| s.content.as_ref()).collect::<Vec<&str>>().join("")
})
.collect();
assert!(entry_labels_f1_file.iter().any(|label| label.contains("Open in Editor [e/o]")));
app.detail_focus = DetailSection::FileContent;
let (_, entries_f2) = detail_dismiss_entries(&app);
let entry_labels_f2: Vec<String> = entries_f2
.iter()
.map(|entry| {
entry.spans.iter().map(|s| s.content.as_ref()).collect::<Vec<&str>>().join("")
})
.collect();
assert!(!entry_labels_f2.iter().any(|label| label.contains("Fuzzy Find [f]")));
assert!(!entry_labels_f2.iter().any(|label| label.contains("Expand/Collapse [←/→]")));
assert!(!entry_labels_f2.iter().any(|label| label.contains("History [H]")));
assert!(entry_labels_f2.iter().any(|label| label.contains("Full Screen [→]")));
assert!(entry_labels_f2.iter().any(|label| label.contains("Open in Editor [e/o]")));
app.inspect_full_diff = true;
let (_, entries_f2_full) = detail_dismiss_entries(&app);
let entry_labels_f2_full: Vec<String> = entries_f2_full
.iter()
.map(|entry| {
entry.spans.iter().map(|s| s.content.as_ref()).collect::<Vec<&str>>().join("")
})
.collect();
assert!(
entry_labels_f2_full.iter().any(|label| label.contains("Exit Full Screen [←/⎋/q/Q]"))
);
app.inspect_full_diff = false;
app.detail_tab = 3;
app.detail_focus = DetailSection::LocalBranches;
let (_, entries_b1) = detail_dismiss_entries(&app);
let entry_labels_b1: Vec<String> = entries_b1
.iter()
.map(|entry| {
entry.spans.iter().map(|s| s.content.as_ref()).collect::<Vec<&str>>().join("")
})
.collect();
assert!(entry_labels_b1.iter().any(|label| label.contains("Fuzzy Search [/]")));
assert!(entry_labels_b1.iter().any(|label| label.contains("Fetch [f/F]")));
assert!(entry_labels_b1.iter().any(|label| label.contains("Add Remote [a/A]")));
assert!(entry_labels_b1.iter().any(|label| label.contains("Create [c/C]")));
assert!(entry_labels_b1.iter().any(|label| label.contains("Interactive Rebase [i/I]")));
assert!(entry_labels_b1.iter().any(|label| label.contains("Merge Into [M]")));
assert!(entry_labels_b1.iter().any(|label| label.contains("Pull [p]")));
assert!(entry_labels_b1.iter().any(|label| label.contains("Push [P]")));
app.detail_focus = DetailSection::RemoteBranches;
let (_, entries_b2) = detail_dismiss_entries(&app);
let entry_labels_b2: Vec<String> = entries_b2
.iter()
.map(|entry| {
entry.spans.iter().map(|s| s.content.as_ref()).collect::<Vec<&str>>().join("")
})
.collect();
assert!(entry_labels_b2.iter().any(|label| label.contains("Fetch [f/F]")));
assert!(entry_labels_b2.iter().any(|label| label.contains("Add Remote [a/A]")));
assert!(!entry_labels_b2.iter().any(|label| label.contains("Merge Into [M]")));
assert!(!entry_labels_b2.iter().any(|label| label.contains("Pull [p]")));
assert!(!entry_labels_b2.iter().any(|label| label.contains("Push [P]")));
app.detail_tab = 6;
app.detail_focus = DetailSection::Stashes;
let (_, entries_s1) = detail_dismiss_entries(&app);
let entry_labels_s1: Vec<String> = entries_s1
.iter()
.map(|entry| {
entry.spans.iter().map(|s| s.content.as_ref()).collect::<Vec<&str>>().join("")
})
.collect();
assert!(entry_labels_s1.iter().any(|label| label.contains("Apply [a/A]")));
assert!(entry_labels_s1.iter().any(|label| label.contains("Delete [D]")));
app.detail_focus = DetailSection::StashedFiles;
let (_, entries_s2) = detail_dismiss_entries(&app);
let entry_labels_s2: Vec<String> = entries_s2
.iter()
.map(|entry| {
entry.spans.iter().map(|s| s.content.as_ref()).collect::<Vec<&str>>().join("")
})
.collect();
assert!(!entry_labels_s2.iter().any(|label| label.contains("Apply [a/A]")));
assert!(!entry_labels_s2.iter().any(|label| label.contains("Delete [D]")));
app.detail_tab = 4;
let (_, entries_tags) = detail_dismiss_entries(&app);
let entry_labels_tags: Vec<String> = entries_tags
.iter()
.map(|entry| {
entry.spans.iter().map(|s| s.content.as_ref()).collect::<Vec<&str>>().join("")
})
.collect();
assert!(entry_labels_tags.iter().any(|label| label.contains("Fuzzy Search [/]")));
assert!(entry_labels_tags.iter().any(|label| label.contains("Fetch [f/F]")));
assert!(entry_labels_tags.iter().any(|label| label.contains("Checkout [↵]")));
assert!(entry_labels_tags.iter().any(|label| label.contains("Push All [P]")));
}
#[test]
fn test_wrap_str() {
let wrapped = wrap_str("node_modules,target,build,dist", 10);
assert_eq!(
wrapped,
vec!["node_modul".to_string(), "es,target,".to_string(), "build,dist".to_string(),]
);
let wrapped_empty = wrap_str("", 10);
assert_eq!(wrapped_empty, vec!["".to_string()]);
}
#[test]
fn test_wrap_excludes() {
let w = wrap_excludes("node_modules,target", 30);
assert_eq!(w, vec!["node_modules,target"]);
let w = wrap_excludes("node_modules,target,build,dist", 20);
assert_eq!(w, vec!["node_modules,target,", "build,dist"]);
let w = wrap_excludes("averylongnamehere", 10);
assert_eq!(w, vec!["averylongn", "amehere"]);
let w = wrap_excludes("", 20);
assert_eq!(w, vec![""]);
}
#[test]
fn test_settings_val_str() {
let config = Config {
editor: "vim".to_string(),
ssh_strict_host_checking: true,
..Default::default()
};
let (config_path, _guard) = crate::test_support::temp_config_path("settings_val_str");
let mut app = App::new(config, config_path);
let val_ssh = crate::popups::settings::get_val_str(&app, 55);
assert_eq!(val_ssh, "true");
app.settings_selected_index = 56;
app.settings_editing = false;
let val_editor = crate::popups::settings::get_val_str(&app, 56);
assert_eq!(val_editor, "vim");
app.settings_editing = true;
app.set_input_buffer("nano".to_string());
let val_editor_edit = crate::popups::settings::get_val_str(&app, 56);
assert_eq!(val_editor_edit, "nano█");
}
#[test]
fn test_repo_indicator_line_divergence() {
let config = Config::default();
let (config_path, _guard) =
crate::test_support::temp_config_path("repo_indicator_line_divergence");
let app = App::new(config, config_path);
let summary1 = RepoSummary {
ahead: 2,
behind: 0,
staged: 0,
modified: 0,
untracked: 0,
conflicted: 0,
branch: None,
state: RepoState::Clean,
last_commit_time: None,
};
let line1 = repo_indicator_line(&app, &summary1);
let ahead_span = line1.spans.iter().find(|s| s.content.contains(app.sym("up"))).unwrap();
assert_eq!(ahead_span.style.fg, Some(SUCCESS()));
let summary2 = RepoSummary {
ahead: 7,
behind: 0,
staged: 0,
modified: 0,
untracked: 0,
conflicted: 0,
branch: None,
state: RepoState::Clean,
last_commit_time: None,
};
let line2 = repo_indicator_line(&app, &summary2);
let ahead_span2 = line2.spans.iter().find(|s| s.content.contains(app.sym("up"))).unwrap();
assert_eq!(ahead_span2.style.fg, Some(WARNING()));
let summary3 = RepoSummary {
ahead: 0,
behind: 3,
staged: 0,
modified: 0,
untracked: 0,
conflicted: 0,
branch: None,
state: RepoState::Clean,
last_commit_time: None,
};
let line3 = repo_indicator_line(&app, &summary3);
let behind_span = line3.spans.iter().find(|s| s.content.contains(app.sym("down"))).unwrap();
assert_eq!(behind_span.style.fg, Some(WARNING()));
let summary4 = RepoSummary {
ahead: 0,
behind: 8,
staged: 0,
modified: 0,
untracked: 0,
conflicted: 0,
branch: None,
state: RepoState::Clean,
last_commit_time: None,
};
let line4 = repo_indicator_line(&app, &summary4);
let behind_span2 =
line4.spans.iter().find(|s| s.content.contains(app.sym("down"))).unwrap();
assert_eq!(behind_span2.style.fg, Some(DANGER()));
}
#[test]
fn test_draw_quick_label_bar_marks_active_chip() {
let mut config = Config {
items: vec!["/path/to/repo_a".to_string(), "/path/to/repo_b".to_string()],
..Default::default()
};
config.labels.insert("/path/to/repo_a".to_string(), vec!["web".to_string()]);
config
.labels
.insert("/path/to/repo_b".to_string(), vec!["web".to_string(), "api".to_string()]);
let (config_path, _guard) =
crate::test_support::temp_config_path("draw_quick_label_bar_marks_active_chip");
let mut app = App::new(config, config_path);
app.state.label_slots = vec!["api".to_string(), "web".to_string()];
app.state.active_label_filter = Some("api".to_string());
let backend = ratatui::backend::TestBackend::new(80, 1);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal.draw(|f| draw_quick_label_bar(f, Rect::new(0, 0, 80, 1), &app)).unwrap();
let buffer = terminal.backend().buffer();
let text: String = (0..80).map(|x| buffer[(x, 0)].symbol()).collect();
let trimmed = text.trim();
assert!(trimmed.contains("1 api"), "Buffer contents: {}", trimmed);
assert!(trimmed.contains("2 web"), "Buffer contents: {}", trimmed);
assert!(text.find("1 api").unwrap() < text.find("2 web").unwrap());
let api_x = text.find("api").unwrap() as u16;
let web_x = text.find("web").unwrap() as u16;
assert!(buffer[(api_x, 0)].style().add_modifier.contains(Modifier::REVERSED));
assert!(!buffer[(web_x, 0)].style().add_modifier.contains(Modifier::REVERSED));
let parts = quick_label_parts(&app);
assert_eq!(
parts,
vec![(" 1 ".to_string(), "api ".to_string()), (" 2 ".to_string(), "web ".to_string())]
);
let area = Rect::new(0, 0, 80, 20);
let header = home_header_layout(area, &app);
assert_eq!(header.chips, Some(Rect::new(0, 2, 80, 1)));
assert_eq!(header.sort_rule, Rect::new(0, 3, 80, 1));
assert_eq!(header.body.y, 4);
app.state.label_slots.clear();
let header = home_header_layout(area, &app);
assert_eq!(header.chips, None);
assert_eq!(header.sort_rule, Rect::new(0, 1, 80, 1));
assert_eq!(header.body.y, 2);
app.config.sort_by = SortOrder::Alphabetical;
app.config.sort_reverse = true;
let backend = ratatui::backend::TestBackend::new(80, 1);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal.draw(|f| draw_sort_rule(f, Rect::new(0, 0, 80, 1), &app)).unwrap();
let buffer = terminal.backend().buffer();
let rule: String = (0..80).map(|x| buffer[(x, 0)].symbol()).collect();
assert!(rule.contains("Sort: Alphabetical (Rev)"), "rule: {}", rule);
let dash = app.sym("rule");
assert!(rule.starts_with(&format!("{dash}{dash}")), "rule: {}", rule);
assert!(buffer[(0, 0)].style().add_modifier.contains(Modifier::DIM));
app.config.show_separators = false;
terminal.draw(|f| draw_sort_rule(f, Rect::new(0, 0, 80, 1), &app)).unwrap();
let buffer = terminal.backend().buffer();
let rule: String = (0..80).map(|x| buffer[(x, 0)].symbol()).collect();
assert!(rule.contains("Sort: Alphabetical (Rev)"), "rule: {}", rule);
assert!(!rule.contains(dash), "rule: {}", rule);
app.state.label_slots.push("api".to_string());
let header = home_header_layout(area, &app);
assert_eq!(header.chips, Some(Rect::new(0, 1, 80, 1)));
assert_eq!(header.sort_rule, Rect::new(0, 2, 80, 1));
assert_eq!(header.body.y, 3);
}
#[test]
fn test_draw_global_summary_bar() {
let config = Config {
items: vec!["/path/to/repo_a".to_string(), "/path/to/repo_b".to_string()],
..Default::default()
};
let (config_path, _guard) =
crate::test_support::temp_config_path("draw_global_summary_bar");
let mut app = App::new(config, config_path);
let summary_clean = RepoSummary {
branch: Some("main".to_string()),
staged: 0,
modified: 0,
untracked: 0,
conflicted: 0,
ahead: 0,
behind: 0,
state: RepoState::Clean,
last_commit_time: Some(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64,
),
};
let summary_dirty = RepoSummary {
branch: Some("main".to_string()),
staged: 1,
modified: 0,
untracked: 0,
conflicted: 0,
ahead: 2,
behind: 0,
state: RepoState::Clean,
last_commit_time: Some(0), };
app.statuses = vec![
ItemStatus::GitRepo(Some(summary_clean)),
ItemStatus::GitRepo(Some(summary_dirty)),
];
let backend = ratatui::backend::TestBackend::new(80, 1);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal
.draw(|f| {
draw_global_summary_bar(f, Rect::new(0, 0, 80, 1), &app);
})
.unwrap();
let buffer = terminal.backend().buffer();
let text: String = (0..80).map(|x| buffer[(x, 0)].symbol()).collect();
let trimmed = text.trim();
assert!(trimmed.contains("2 repos"), "Buffer contents: {}", trimmed);
assert!(trimmed.contains("1 dirty"), "Buffer contents: {}", trimmed);
assert!(trimmed.contains("1 ahead"), "Buffer contents: {}", trimmed);
assert!(trimmed.contains("1 stale"), "Buffer contents: {}", trimmed);
assert!(trimmed.contains(SUMMARY_TAB_DIVIDER), "Buffer contents: {}", trimmed);
let repos_x = text.find("repos").unwrap();
assert!(
buffer[(repos_x as u16, 0)].style().add_modifier.contains(Modifier::REVERSED),
"repos tab should render as the active tab"
);
app.global_filter = Some(crate::app::GlobalFilter::Dirty);
terminal
.draw(|f| {
draw_global_summary_bar(f, Rect::new(0, 0, 80, 1), &app);
})
.unwrap();
let buffer = terminal.backend().buffer();
let text: String = (0..80).map(|x| buffer[(x, 0)].symbol()).collect();
let dirty_x = text.find("dirty").unwrap();
let repos_x = text.find("repos").unwrap();
assert!(
buffer[(dirty_x as u16, 0)].style().add_modifier.contains(Modifier::REVERSED),
"dirty tab should render as the active tab when the Dirty filter is on"
);
assert!(
!buffer[(repos_x as u16, 0)].style().add_modifier.contains(Modifier::REVERSED),
"repos tab should render inactive when the Dirty filter is on"
);
}
#[test]
fn test_filtered_empty_view_keeps_summary_bar() {
let config = Config { items: vec!["/path/to/repo_a".to_string()], ..Default::default() };
let (config_path, _guard) =
crate::test_support::temp_config_path("filtered_empty_view_keeps_summary_bar");
let mut app = App::new(config, config_path);
let clean = RepoSummary {
branch: Some("main".to_string()),
staged: 0,
modified: 0,
untracked: 0,
conflicted: 0,
ahead: 0,
behind: 0,
state: RepoState::Clean,
last_commit_time: Some(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64,
),
};
app.statuses = vec![ItemStatus::GitRepo(Some(clean))];
app.global_filter = Some(crate::app::GlobalFilter::Dirty);
assert_eq!(app.get_items_len(), 0);
let backend = ratatui::backend::TestBackend::new(80, 20);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
let mut detail_areas = crate::ui_detail::DetailAreas::default();
let mut main_areas = Vec::new();
let mut global_summary_area = None;
let mut render = |app: &App, summary_area: &mut Option<Rect>| {
terminal
.draw(|f| {
let size = f.area();
let inner = size.inner(ratatui::layout::Margin { vertical: 1, horizontal: 1 });
super::draw(
f,
app,
size,
inner,
1,
&mut detail_areas,
&mut main_areas,
summary_area,
&mut None,
);
})
.unwrap();
let buffer = terminal.backend().buffer().clone();
let mut text = String::new();
for y in 0..20 {
for x in 0..80 {
text.push_str(buffer[(x, y)].symbol());
}
text.push('\n');
}
(text, buffer)
};
let (text, _) = render(&app, &mut global_summary_area);
assert!(global_summary_area.is_some());
assert!(text.contains("dirty"), "summary bar missing:\n{}", text);
assert!(!text.contains("Gitwig Onboarding"), "onboarding shown:\n{}", text);
assert!(text.contains("No repositories in the dirty tab"), "message missing:\n{}", text);
app.global_filter = Some(crate::app::GlobalFilter::Ahead);
let (text, buffer) = render(&app, &mut global_summary_area);
assert!(text.contains("No repositories in the ahead tab"), "message missing:\n{}", text);
let bar_y = global_summary_area.unwrap().y;
let ahead_col = (0..76u16)
.find(|&x| {
(0..5).map(|i| buffer[(x + i, bar_y)].symbol()).collect::<String>() == "ahead"
})
.expect("ahead tab not rendered in summary bar");
let ahead_cell_style = buffer[(ahead_col, bar_y)].style();
assert!(ahead_cell_style.add_modifier.contains(Modifier::REVERSED));
assert_eq!(ahead_cell_style.fg, Some(SUCCESS()));
app.global_filter = None;
app.state.active_label_filter = Some("ghost".to_string());
assert_eq!(app.get_items_len(), 0);
let (text, buffer) = render(&app, &mut global_summary_area);
assert!(global_summary_area.is_some());
assert!(!text.contains("Gitwig Onboarding"), "onboarding shown:\n{}", text);
assert!(text.contains("No repositories with label 'ghost'"), "message missing:\n{}", text);
let bar_y = global_summary_area.unwrap().y;
let bar_row: String = (0..80).map(|x| buffer[(x, bar_y)].symbol()).collect();
assert!(
!bar_row.contains("ghost"),
"badge should not sit in the summary bar:\n{}",
bar_row
);
let top_row: String = (0..80).map(|x| buffer[(x, 0)].symbol()).collect();
assert!(top_row.contains("ghost"), "label badge missing from frame border:\n{}", top_row);
let badge = app.label_badge_area.get().expect("badge rect recorded");
assert_eq!(badge.y, 0);
assert!(
badge.x > 0 && badge.x + badge.width < 80,
"badge {:?} should sit inside the border",
badge
);
assert!(buffer[(badge.x, 0)].style().add_modifier.contains(Modifier::REVERSED));
app.config.items.clear();
app.statuses.clear();
let _ = render(&app, &mut global_summary_area);
assert!(global_summary_area.is_none());
assert!(app.label_badge_area.get().is_none());
}
#[test]
fn test_compact_branch_col_width_adapts() {
let config = Config {
items: vec!["/path/to/repo_a".to_string(), "/path/to/repo_b".to_string()],
..Default::default()
};
let (config_path, _guard) =
crate::test_support::temp_config_path("compact_branch_col_width_adapts");
let mut app = App::new(config, config_path);
let now =
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs()
as i64;
let summary = |branch: &str| {
ItemStatus::GitRepo(Some(RepoSummary {
branch: Some(branch.to_string()),
staged: 0,
modified: 0,
untracked: 0,
conflicted: 0,
ahead: 0,
behind: 0,
state: RepoState::Clean,
last_commit_time: Some(now),
}))
};
app.statuses = vec![summary("main"), summary("develop")];
assert_eq!(compact_branch_col_width(&app, 200), 28);
let long_branch = "feature/very-long-descriptive-branch-name";
app.statuses = vec![summary("main"), summary(long_branch)];
let width = compact_branch_col_width(&app, 200);
assert!(
width as usize > 2 + long_branch.chars().count(),
"column ({}) should fit the branch plus its symbol",
width
);
assert_eq!(compact_branch_col_width(&app, 80), 28);
assert_eq!(compact_branch_col_width(&app, 0), 28);
}
#[test]
fn test_forge_comment_wizard_draws_its_popup_over_the_detail_view() {
let config = Config { items: vec!["/path/to/repo_a".to_string()], ..Default::default() };
let (config_path, _guard) = crate::test_support::temp_config_path(
"forge_comment_wizard_draws_its_popup_over_the_detail_view",
);
let mut app = App::new(config, config_path);
app.current_detail = Some(ItemDetail::Repo {
resolved: PathBuf::from("/path/to/repo_a"),
info: Box::new(RepoInfo::default()),
});
app.advanced_tabs = true;
app.detail_tab = 11;
let backend = ratatui::backend::TestBackend::new(120, 40);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
for (mode, step) in [
(Mode::ForgeCommentPathInput, "Step 1"),
(Mode::ForgeCommentLineInput, "Step 2"),
(Mode::ForgeCommentBodyInput, "Step 3"),
] {
app.mode = mode;
let mut detail_areas = crate::ui_detail::DetailAreas::default();
terminal
.draw(|f| {
let size = f.area();
super::draw(
f,
&app,
size,
size,
1,
&mut detail_areas,
&mut Vec::new(),
&mut None,
&mut None,
);
})
.unwrap();
let buffer = terminal.backend().buffer();
let text: String = (0..40)
.map(|y| (0..120).map(|x| buffer[(x, y)].symbol()).collect::<String>() + "\n")
.collect();
assert!(text.contains("Add PR Line Comment"), "{:?}: no popup in\n{}", mode, text);
assert!(text.contains(step), "{:?}: {} missing in\n{}", mode, step, text);
assert!(text.contains("PRs [5]"), "{:?}: detail view missing in\n{}", mode, text);
}
}
#[test]
fn test_draw_partial_uncommitted_badge() {
let config = Config { items: vec!["/path/to/repo_a".to_string()], ..Default::default() };
let (config_path, _guard) =
crate::test_support::temp_config_path("draw_partial_uncommitted_badge");
let mut app = App::new(config, config_path);
let summary_partial = RepoSummary {
branch: Some("main".to_string()),
staged: 1,
modified: 1,
untracked: 0,
conflicted: 0,
ahead: 0,
behind: 0,
state: RepoState::Clean,
last_commit_time: None,
};
app.statuses = vec![ItemStatus::GitRepo(Some(summary_partial))];
let backend = ratatui::backend::TestBackend::new(80, 5);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal
.draw(|f| {
let chunks = vec![Rect::new(0, 0, 80, 4)];
draw_items(f, &app, &chunks);
})
.unwrap();
let buffer = terminal.backend().buffer();
let mut found_partial = false;
for y in 0..5 {
let row_text: String = (0..80).map(|x| buffer[(x, y)].symbol()).collect();
if row_text.contains("PARTIAL") {
found_partial = true;
break;
}
}
assert!(found_partial, "Expected to find PARTIAL badge in card rendering");
}
#[test]
fn test_actual_rendering_all_modes_and_tabs() {
let uuid =
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let temp_repo_path = std::env::temp_dir().join(format!("gitwig_test_render_repo_{}", uuid));
let _ = std::fs::create_dir_all(&temp_repo_path);
let repo = git2::Repository::init(&temp_repo_path).unwrap();
let mut config_git = repo.config().unwrap();
config_git.set_str("user.name", "Test User").unwrap();
config_git.set_str("user.email", "test@test.com").unwrap();
let file1_path = temp_repo_path.join("file1.rs");
std::fs::write(&file1_path, "fn main() {\n // initial version\n}\n").unwrap();
let mut index = repo.index().unwrap();
index.add_path(std::path::Path::new("file1.rs")).unwrap();
index.write().unwrap();
let oid = index.write_tree().unwrap();
let tree = repo.find_tree(oid).unwrap();
let signature = repo.signature().unwrap();
repo.commit(Some("HEAD"), &signature, &signature, "Initial commit", &tree, &[]).unwrap();
std::fs::write(&file1_path, "fn main() {\n println!(\"Modified\");\n}\n").unwrap();
let file2_path = temp_repo_path.join("untracked.rs");
std::fs::write(&file2_path, "// untracked file content\n").unwrap();
let repo_path_str = temp_repo_path.to_str().unwrap().to_string();
let detail = crate::repo::inspect_detail(&repo_path_str, 100, 1000, false);
let mut mock_info = match detail {
crate::repo::ItemDetail::Repo { info, .. } => *info,
_ => crate::repo::RepoInfo::default(),
};
mock_info.stashes = crate::repo::TabData::Loaded(vec![crate::repo::StashInfo {
index: 0,
message: "stash msg".to_string(),
commit_id: "abc1234".to_string(),
files: vec![],
}]);
mock_info.worktrees = crate::repo::TabData::Loaded(vec![crate::repo::WorktreeInfo {
name: "wt".to_string(),
path: temp_repo_path.join("wt"),
branch: Some("feature".to_string()),
is_locked: false,
lock_reason: None,
}]);
mock_info.submodules = crate::repo::TabData::Loaded(vec![crate::repo::SubmoduleInfo {
name: "sub".to_string(),
path: temp_repo_path.join("sub"),
url: "http://url".to_string(),
head_id: Some("abc1234".to_string()),
..Default::default()
}]);
mock_info.reflog = crate::repo::TabData::Loaded(vec![crate::repo::ReflogEntry {
index: 0,
target_oid: "abc1234".to_string(),
selector: "reflog@{0}".to_string(),
command: "commit".to_string(),
message: "feat: summary".to_string(),
when: "10 mins ago".to_string(),
date: "2026-07-02".to_string(),
}]);
mock_info.committer_stats =
crate::repo::TabData::Loaded(vec![crate::repo::CommitterStat {
name: "Author".to_string(),
email: "author@author.com".to_string(),
count: 1,
}]);
mock_info.changes.staged =
vec![crate::repo::FileEntry { path: "staged_file.rs".to_string(), label: "A" }];
mock_info.changes.unstaged =
vec![crate::repo::FileEntry { path: "unstaged_file.rs".to_string(), label: "M" }];
mock_info.changes.conflicted =
vec![crate::repo::FileEntry { path: "conflicted_file.rs".to_string(), label: "U" }];
mock_info.changes.untracked =
vec![crate::repo::FileEntry { path: "untracked_file.rs".to_string(), label: "?" }];
mock_info.local_branches = crate::repo::TabData::Loaded(vec![crate::repo::BranchInfo {
name: "main".to_string(),
is_head: true,
short_sha: "abc1234".to_string(),
short_message: "feat: commit 1".to_string(),
}]);
mock_info.remote_branches = crate::repo::TabData::Loaded(vec![crate::repo::BranchInfo {
name: "origin/main".to_string(),
is_head: false,
short_sha: "abc1234".to_string(),
short_message: "feat: commit 1".to_string(),
}]);
mock_info.local_tags = crate::repo::TabData::Loaded(vec![crate::repo::BranchInfo {
name: "v1.0.0".to_string(),
is_head: false,
short_sha: "abc1234".to_string(),
short_message: "feat: commit 1".to_string(),
}]);
mock_info.remote_tags = crate::repo::TabData::Loaded(vec![crate::repo::BranchInfo {
name: "v1.0.0".to_string(),
is_head: false,
short_sha: "abc1234".to_string(),
short_message: "feat: commit 1".to_string(),
}]);
mock_info.remotes = crate::repo::TabData::Loaded(vec![crate::repo::RemoteInfo {
name: "origin".to_string(),
url: "http://url".to_string(),
push_url: Some("http://url".to_string()),
refspecs: vec![],
}]);
mock_info.commits = vec![
crate::repo::CommitEntry {
id: "abc1234".to_string(),
oid: "abc1234abc1234abc1234abc1234abc1234abc1234".to_string(),
author: "Author 1".to_string(),
when: "10 mins ago".to_string(),
date: "2026-07-02".to_string(),
summary: "feat: commit 1".to_string(),
message: "feat: commit 1\n\nbody".to_string(),
refs: vec!["HEAD".to_string(), "main".to_string()],
files: vec![],
signature_status: "G".to_string(),
},
crate::repo::CommitEntry {
id: "def5678".to_string(),
oid: "def5678def5678def5678def5678def5678def5678".to_string(),
author: "Author 2".to_string(),
when: "20 mins ago".to_string(),
date: "2026-07-02".to_string(),
summary: "feat: commit 2".to_string(),
message: "feat: commit 2\n\nbody".to_string(),
refs: vec![],
files: vec![],
signature_status: "N".to_string(),
},
];
mock_info.graph_lines = crate::repo::TabData::Loaded(vec![
crate::repo::GraphLine {
graph: "*".to_string(),
commit: Some(crate::repo::GraphCommit {
oid: "abc1234".to_string(),
decoration: " (HEAD -> main)".to_string(),
summary: "feat: commit 1".to_string(),
author: "Author 1".to_string(),
date: "2026-07-02".to_string(),
signature_status: "G".to_string(),
}),
},
crate::repo::GraphLine {
graph: "|*".to_string(),
commit: Some(crate::repo::GraphCommit {
oid: "def5678".to_string(),
decoration: "".to_string(),
summary: "feat: commit 2".to_string(),
author: "Author 2".to_string(),
date: "2026-07-02".to_string(),
signature_status: "N".to_string(),
}),
},
]);
let config = Config { items: vec![repo_path_str], ..Default::default() };
let mut app = App::new(config, temp_repo_path.join("dummy_path.toml"));
app.current_detail = Some(crate::repo::ItemDetail::Repo {
resolved: temp_repo_path.clone(),
info: Box::new(mock_info.clone()),
});
app.file_tree.visible_files = vec![crate::app::FileTreeItem {
name: "file1.rs".to_string(),
full_path: "file1.rs".to_string(),
is_dir: false,
depth: 0,
is_expanded: false,
}];
app.branch_action_target = Some(("feature".to_string(), false));
app.tag_delete_target = Some(("v1.0.0".to_string(), false));
app.tag_push_target = Some("v1.0.0".to_string());
app.discard_target = Some(("staged_file.rs".to_string(), false));
app.cherry_pick_target = Some(("abc1234".to_string(), "feat: commit".to_string()));
app.revert_target = Some(("abc1234".to_string(), "feat: commit".to_string()));
app.stash_action_target = Some(("stash@{0}".to_string(), "wip".to_string()));
app.remote_action_target = Some("origin".to_string());
app.submodule_delete_target = Some(crate::repo::SubmoduleInfo {
name: "sub".to_string(),
path: std::path::PathBuf::from("sub"),
..Default::default()
});
app.submodule_update_target = Some(crate::app::SubmoduleUpdateTarget {
path: None,
modified: vec![std::path::PathBuf::from("sub")],
});
app.forge_checkout_target = Some(crate::app::ForgeCheckoutTarget::PullRequest {
number: 42,
title: "Add retry".to_string(),
});
let backend = ratatui::backend::TestBackend::new(120, 40);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
let mut detail_areas = crate::ui_detail::DetailAreas::default();
let mut main_areas = Vec::new();
let mut global_summary_area = None;
app.mode = Mode::Detail;
for tab in 0..10 {
app.detail_tab = tab;
terminal
.draw(|f| {
let size = f.area();
super::draw(
f,
&app,
size,
size,
1,
&mut detail_areas,
&mut main_areas,
&mut global_summary_area,
&mut None,
);
})
.unwrap();
let buffer = terminal.backend().buffer();
assert!(buffer.area.width > 0);
}
let tab_data_states: Vec<crate::repo::TabData<()>> = vec![
crate::repo::TabData::NotLoaded,
crate::repo::TabData::Loading,
crate::repo::TabData::Error("Mock Error Msg".to_string()),
];
for state in &tab_data_states {
macro_rules! map_state {
($s:expr) => {
match $s {
crate::repo::TabData::NotLoaded => crate::repo::TabData::NotLoaded,
crate::repo::TabData::Loading => crate::repo::TabData::Loading,
crate::repo::TabData::Error(e) => crate::repo::TabData::Error(e.clone()),
_ => crate::repo::TabData::NotLoaded,
}
};
}
mock_info.remotes = map_state!(state);
mock_info.graph_lines = map_state!(state);
mock_info.local_branches = map_state!(state);
mock_info.remote_branches = map_state!(state);
mock_info.local_tags = map_state!(state);
mock_info.remote_tags = map_state!(state);
mock_info.files = map_state!(state);
mock_info.stashes = map_state!(state);
mock_info.worktrees = map_state!(state);
mock_info.submodules = map_state!(state);
mock_info.reflog = map_state!(state);
mock_info.committer_stats = map_state!(state);
app.current_detail = Some(crate::repo::ItemDetail::Repo {
resolved: temp_repo_path.clone(),
info: Box::new(mock_info.clone()),
});
for tab in 0..10 {
app.detail_tab = tab;
terminal
.draw(|f| {
let size = f.area();
super::draw(
f,
&app,
size,
size,
1,
&mut detail_areas,
&mut main_areas,
&mut global_summary_area,
&mut None,
);
})
.unwrap();
}
}
let other_modes = vec![
Mode::Normal,
Mode::Adding,
Mode::Editing,
Mode::Overview,
Mode::RepoSettings,
Mode::RepoJump,
Mode::LabelPicker,
Mode::LabelSettings,
Mode::RepoScanPicker,
Mode::BulkAddScanPicker,
Mode::BranchSearchInput,
Mode::FileSearchInput,
Mode::CommitFuzzySearch,
Mode::TagSearchInput,
Mode::FileHistory,
Mode::Settings,
Mode::DebugLogs,
Mode::Help,
Mode::About,
Mode::Legend,
Mode::ConfirmDelete,
Mode::DetailHelp,
Mode::CommitInput,
Mode::BranchCreateInput,
Mode::TagCreateInput,
Mode::BranchDeleteConfirm,
Mode::BranchPushConfirm,
Mode::TagDeleteConfirm,
Mode::TagOverwriteConfirm,
Mode::TagPushConfirm,
Mode::TagPushAllConfirm,
Mode::StashDeleteConfirm,
Mode::StashApplyConfirm,
Mode::StashCreateInput,
Mode::StashingUI,
Mode::RemotePicker,
Mode::BranchMergeConfirm,
Mode::BranchMergeIntoConfirm,
Mode::BranchRebaseConfirm,
Mode::BranchInteractiveRebaseConfirm,
Mode::DiscardChangesConfirm,
Mode::Inspect,
Mode::ImportUrlInput,
Mode::ImportDestInput,
Mode::ImportNameInput,
Mode::BulkAddInput,
Mode::SearchColumnPicker,
Mode::RemoteAddNameInput,
Mode::RemoteAddUrlInput,
Mode::RemoteDeleteConfirm,
Mode::Logs,
Mode::LogsSearchInput,
Mode::BranchCheckoutConfirm,
Mode::TagCheckoutConfirm,
Mode::CommitCheckoutConfirm,
Mode::RepoSearchInput,
Mode::MergeAbortConfirm,
Mode::MergeContinueConfirm,
Mode::CherryPickConfirm,
Mode::RevertConfirm,
Mode::LabelInput,
Mode::AddRepoLabelInput,
Mode::BulkAddRepoLabelInput,
Mode::CloneRepoLabelInput,
Mode::UpdateConfirm,
Mode::WorktreeAddBranchInput,
Mode::WorktreeAddPathInput,
Mode::WorktreeLockReasonInput,
Mode::WorktreeRemoveConfirm,
Mode::SubmoduleAddUrlInput,
Mode::SubmoduleAddPathInput,
Mode::SubmoduleDeleteConfirm,
Mode::SubmoduleUpdateConfirm,
Mode::ForgeCheckoutConfirm,
Mode::NotGitRepo,
];
for mode in &other_modes {
app.mode = *mode;
terminal
.draw(|f| {
let size = f.area();
super::draw(
f,
&app,
size,
size,
1,
&mut detail_areas,
&mut main_areas,
&mut global_summary_area,
&mut None,
);
})
.unwrap();
let buffer = terminal.backend().buffer();
assert!(buffer.area.width > 0);
}
app.fetching = true;
terminal
.draw(|f| {
let size = f.area();
super::draw(
f,
&app,
size,
size,
1,
&mut detail_areas,
&mut main_areas,
&mut global_summary_area,
&mut None,
);
})
.unwrap();
app.fetching = false;
app.error_message = Some("An error occurred".to_string());
terminal
.draw(|f| {
let size = f.area();
super::draw(
f,
&app,
size,
size,
1,
&mut detail_areas,
&mut main_areas,
&mut global_summary_area,
&mut None,
);
})
.unwrap();
app.error_message = None;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let dummy_keys = vec![
KeyEvent::new(KeyCode::Up, KeyModifiers::empty()),
KeyEvent::new(KeyCode::Down, KeyModifiers::empty()),
KeyEvent::new(KeyCode::Char('k'), KeyModifiers::empty()),
KeyEvent::new(KeyCode::Char('j'), KeyModifiers::empty()),
KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()),
KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
KeyEvent::new(KeyCode::Backspace, KeyModifiers::empty()),
KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty()),
KeyEvent::new(KeyCode::Char('/'), KeyModifiers::empty()),
KeyEvent::new(KeyCode::Char('s'), KeyModifiers::empty()),
KeyEvent::new(KeyCode::Char('F'), KeyModifiers::empty()),
KeyEvent::new(KeyCode::Char('o'), KeyModifiers::empty()),
KeyEvent::new(KeyCode::Char('e'), KeyModifiers::empty()),
KeyEvent::new(KeyCode::Char('d'), KeyModifiers::empty()),
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::empty()),
KeyEvent::new(KeyCode::Char('p'), KeyModifiers::empty()),
KeyEvent::new(KeyCode::Char('r'), KeyModifiers::empty()),
KeyEvent::new(KeyCode::Char('u'), KeyModifiers::empty()),
KeyEvent::new(KeyCode::Char('q'), KeyModifiers::empty()),
KeyEvent::new(KeyCode::Tab, KeyModifiers::empty()),
KeyEvent::new(KeyCode::BackTab, KeyModifiers::empty()),
];
app.mode = Mode::Detail;
for tab in 0..10 {
app.detail_tab = tab;
let focuses = vec![
DetailSection::Staged,
DetailSection::Unstaged,
DetailSection::Conflicts,
DetailSection::CommitDetails,
DetailSection::StagingDetails,
DetailSection::ConflictDiff,
DetailSection::Commits,
DetailSection::FileContent,
DetailSection::LocalBranches,
DetailSection::RemoteBranches,
DetailSection::LocalTags,
DetailSection::RemoteTags,
DetailSection::Remotes,
DetailSection::Stashes,
DetailSection::StashedFiles,
DetailSection::Worktrees,
DetailSection::Submodules,
DetailSection::Reflog,
];
for focus in focuses {
app.detail_focus = focus;
for key in &dummy_keys {
let _ = crate::input::handle_key(&mut app, *key, 1);
}
}
}
for mode in &other_modes {
app.mode = *mode;
for key in &dummy_keys {
let _ = crate::input::handle_key(&mut app, *key, 1);
}
}
let _ = std::fs::remove_dir_all(&temp_repo_path);
}
#[test]
fn test_command_palette_draws_over_home_and_detail_frames() {
let config = Config { items: vec!["/path/to/repo".to_string()], ..Default::default() };
let (config_path, _guard) = crate::test_support::temp_config_path(
"command_palette_draws_over_home_and_detail_frames",
);
let mut app = App::new(config, config_path);
let backend = ratatui::backend::TestBackend::new(120, 40);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
let mut detail_areas = crate::ui_detail::DetailAreas::default();
let mut main_areas = Vec::new();
let mut global_summary_area = None;
let mut render = |app: &App| -> String {
terminal
.draw(|f| {
let size = f.area();
super::draw(
f,
app,
size,
size,
1,
&mut detail_areas,
&mut main_areas,
&mut global_summary_area,
&mut None,
);
})
.unwrap();
let buffer = terminal.backend().buffer();
(0..40)
.map(|y| (0..120).map(|x| buffer[(x, y)].symbol().to_string()).collect::<String>())
.collect::<Vec<_>>()
.join("\n")
};
for mode in [Mode::Normal, Mode::Detail] {
app.mode = mode;
app.close_command_palette();
assert!(
!render(&app).contains("Command Palette"),
"closed palette drawn in {:?}",
mode
);
app.open_command_palette();
let frame = render(&app);
assert!(frame.contains("Command Palette"), "{:?} frame:\n{}", mode, frame);
assert!(frame.contains("Search Actions"), "{:?} frame:\n{}", mode, frame);
assert!(frame.contains("Command palette"), "{:?} frame:\n{}", mode, frame);
}
app.error_message = Some("Palette test error".to_string());
let frame = render(&app);
assert!(frame.contains("Palette test error"));
}
}