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, ITEM_HEIGHT, Mode};
use crate::components::cmd_bar::StatusEntry;
use crate::config::SortOrder;
use crate::repo::{ItemStatus, RepoSummary};
pub use super::style::*;
const STATUS_ZONE_WIDTH: u16 = 22;
const UNSELECTED_INDENT: &str = " ";
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>,
) {
draw_outer_frame(f, area, app);
let (content_area, status_chunk) = content_and_status_chunks(inner_area, app.status_height());
if app.loading_repo_path.is_some() {
crate::popups::loading::draw_loading_screen(f, content_area, app);
} else if matches!(
app.mode,
Mode::Detail
| Mode::DetailHelp
| Mode::CommitInput
| Mode::BranchCreateInput
| Mode::TagCreateInput
| Mode::StashingUI
| Mode::BranchDeleteConfirm
| Mode::BranchCheckoutConfirm
| Mode::TagCheckoutConfirm
| Mode::BranchPushConfirm
| Mode::BranchMergeConfirm
| Mode::BranchRebaseConfirm
| Mode::BranchInteractiveRebaseConfirm
| Mode::TagDeleteConfirm
| 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
) {
if let Some(detail) = &app.current_detail {
let item_name = app.get_selected_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.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);
} else if app.get_items_len() == 0 {
if let Some(ref query) = app.repo_search_query {
draw_search_empty_state(f, content_area, query);
} else {
draw_empty_state(f, content_area);
}
} else {
let list_chunks = item_chunks(content_area, visible_count);
*main_areas = list_chunks.clone();
draw_items(f, app, &list_chunks);
}
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::ImportUrlInput | Mode::ImportDestInput | Mode::ImportNameInput) {
crate::popups::import::draw_import_popup(f, area, app);
}
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);
}
}
fn draw_outer_frame(f: &mut Frame, area: Rect, app: &App) {
let show_sort = matches!(
app.mode,
Mode::Normal
| Mode::Adding
| Mode::Editing
| Mode::ConfirmDelete
| Mode::Help
| Mode::About
| Mode::BulkAddInput
);
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),
);
if show_sort {
let sort_label = match app.config.sort_by {
SortOrder::Custom => "Sort: Custom",
SortOrder::Alphabetical => "Sort: Alphabetical",
SortOrder::RecentVisit => "Sort: Recent Visit",
SortOrder::LatestChanges => "Sort: Latest Changes",
};
let sort_label_with_dir = if app.config.sort_reverse {
format!("{} (Rev)", sort_label)
} else {
sort_label.to_string()
};
block = block.title(
Line::from(vec![
Span::raw(" "),
Span::styled(sort_label_with_dir, accent_style()),
Span::raw(" "),
])
.alignment(Alignment::Center),
);
}
block = block.title(
Line::from(format!(" v{} ", env!("CARGO_PKG_VERSION")))
.style(muted_style())
.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) -> Vec<Rect> {
let mut constraints = vec![Constraint::Length(ITEM_HEIGHT); visible_count];
constraints.push(Constraint::Min(0));
let chunks: Vec<Rect> = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.split(content_area)
.to_vec();
chunks[..visible_count].to_vec()
}
fn draw_items(f: &mut Frame, app: &App, chunks: &[Rect]) {
let filtered_items = app.get_filtered_items();
let upper = (app.scroll_top + chunks.len()).min(filtered_items.len());
let visible_items = &filtered_items[app.scroll_top..upper];
for (i, &(actual_index, item)) in visible_items.iter().enumerate() {
let display_index = i + app.scroll_top;
let is_selected = display_index == app.selected_index;
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 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_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 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::Min(0)])
.split(inner);
let name_cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(0), Constraint::Length(4)])
.split(rows[0]);
let repo_name = std::path::Path::new(item)
.file_name()
.and_then(|s| s.to_str())
.unwrap_or(item.as_str());
let fallback = ItemStatus::Missing;
let status = app.statuses.get(actual_index).unwrap_or(&fallback);
let is_git = matches!(status, ItemStatus::GitRepo(_));
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),
));
}
spans.push(Span::styled(repo_name, text_style));
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]);
if is_pinned {
let pin_line =
Line::from(Span::styled(app.sym("pinned").trim(), Style::default().fg(WARNING())))
.alignment(Alignment::Right);
f.render_widget(Paragraph::new(pin_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 = match status {
ItemStatus::GitRepo(Some(s)) => s.branch.clone(),
_ => None,
};
let branch_line = match branch {
Some(b) => Line::from(vec![
Span::raw(UNSELECTED_INDENT), Span::styled(format!("{} ", app.sym("branch")), muted_style()),
Span::styled(b, Style::default().fg(ACCENT())),
]),
None => Line::from(""),
};
f.render_widget(Paragraph::new(branch_line), row1_cols[0]);
let status_line = status_indicator_line(app, status).alignment(Alignment::Right);
f.render_widget(Paragraph::new(status_line), row1_cols[1]);
}
}
fn draw_empty_state(f: &mut Frame, area: Rect) {
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("No repositories tracked yet.", primary_style())]),
Line::from(""),
Line::from(vec![
Span::raw("Press "),
Span::styled("a", accent_style()),
Span::raw(" to add a repository or directory path"),
]),
Line::from(vec![
Span::raw("Press "),
Span::styled("e", accent_style()),
Span::raw(" to edit the selected item"),
]),
Line::from(vec![
Span::raw("Press "),
Span::styled("d", accent_style()),
Span::raw(" to delete the selected item"),
]),
Line::from(vec![
Span::raw("Press "),
Span::styled("?", accent_style()),
Span::raw(" to see all shortcuts"),
]),
Line::from(vec![
Span::raw("Press "),
Span::styled("q", accent_style()),
Span::raw(" to quit"),
]),
Line::from(""),
Line::from(vec![
Span::styled("Tip: ", muted_style()),
Span::styled("paths support ~ expansion (e.g. ~/code/my-project)", muted_style()),
]),
];
let para = Paragraph::new(lines).alignment(Alignment::Center);
f.render_widget(para, vert[1]);
}
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 status_indicator_line(app: &App, status: &ItemStatus) -> Line<'static> {
match status {
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),
}
}
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 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"), primary_style()),
(summary.behind, app.sym("down"), Style::default().fg(WARNING())),
];
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,
) {
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 cursor_offset = (badge_offset + prefix.chars().count() + buffer.chars().count()) 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 chunks = Vec::new();
let chars: Vec<char> = s.chars().collect();
let mut i = 0;
while i < chars.len() {
let end = (i + max_width).min(chars.len());
chunks.push(chars[i..end].iter().collect());
i = end;
}
chunks
}
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)
}
#[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, FzfConfig, SortOrder, ThemeConfig};
use crate::repo::{FileEntry, ItemDetail, RepoInfo};
use std::collections::HashMap;
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,
visits: HashMap::new(),
labels: std::collections::HashMap::new(),
sort_reverse: false,
pinned: std::collections::HashSet::new(),
theme: ThemeConfig::default(),
theme_name: "default".to_string(),
fzf: FzfConfig::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,
};
let mut app = App::new(config, PathBuf::from("dummy_path.toml"));
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 [↵]")));
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 [↵]")));
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 [↵]")));
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,
visits: HashMap::new(),
labels: std::collections::HashMap::new(),
sort_reverse: false,
pinned: std::collections::HashSet::new(),
theme: ThemeConfig::default(),
theme_name: "default".to_string(),
fzf: FzfConfig::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,
};
let mut app = App::new(config, PathBuf::from("dummy_path.toml"));
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]")));
assert!(entry_labels_w.iter().any(|label| label.contains("Load More [G]")));
assert!(entry_labels_w.iter().any(|label| label.contains("Yank Hash [y]")));
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]")));
assert!(entry_labels_s.iter().any(|label| label.contains("Discard All [X]")));
assert!(!entry_labels_s.iter().any(|label| label.contains("Tag [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]")));
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("Inspect [→]")));
assert!(!entry_labels_sd.iter().any(|label| label.contains("Tag [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 [f]")));
assert!(entry_labels_f1.iter().any(|label| label.contains("Expand/Collapse [←/→]")));
assert!(entry_labels_f1.iter().any(|label| label.contains("History [⇧H]")));
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 [→]")));
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]"))
);
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("Fetch [f/F]")));
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("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]")));
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]")));
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("Fetch [f/F]")));
}
#[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![""]);
}
}