use std::borrow::Cow;
use std::collections::HashSet;
use std::sync::Arc;
use anstyle::Reset;
use color_print::cformat;
use dashmap::DashMap;
use skim::prelude::*;
use worktrunk::git::Repository;
use worktrunk::styling::INFO_SYMBOL;
use super::super::list::model::ListItem;
use super::log_formatter::{
FIELD_DELIM, batch_fetch_stats, format_log_output, process_log_with_dimming, strip_hash_markers,
};
use super::pager::{diff_pager, pipe_through_pager};
use super::preview::{PreviewMode, PreviewStateData};
pub(super) type PreviewCacheKey = (String, PreviewMode);
pub(super) type PreviewCache = Arc<DashMap<PreviewCacheKey, String>>;
pub(super) struct HeaderSkimItem {
pub display_text: String,
pub display_text_with_ansi: String,
}
impl SkimItem for HeaderSkimItem {
fn text(&self) -> Cow<'_, str> {
Cow::Borrowed(&self.display_text)
}
fn display<'a>(&'a self, _context: skim::DisplayContext<'a>) -> skim::AnsiString<'a> {
skim::AnsiString::parse(&self.display_text_with_ansi)
}
fn output(&self) -> Cow<'_, str> {
Cow::Borrowed("") }
}
fn compute_diff_preview(args: &[&str], no_changes_msg: &str, width: usize) -> String {
let mut output = String::new();
let Ok(repo) = Repository::current() else {
return format!("{no_changes_msg}\n");
};
let mut stat_args = args.to_vec();
stat_args.push("--stat");
stat_args.push("--color=always");
let stat_width_arg = format!("--stat-width={}", width);
stat_args.push(&stat_width_arg);
if let Ok(stat) = repo.run_command(&stat_args)
&& !stat.trim().is_empty()
{
output.push_str(&stat);
let mut diff_args = args.to_vec();
diff_args.push("--color=always");
if let Ok(diff) = repo.run_command(&diff_args) {
output.push_str(&diff);
}
} else {
output.push_str(no_changes_msg);
output.push('\n');
}
output
}
pub(super) struct WorktreeSkimItem {
pub display_text: String,
pub display_text_with_ansi: String,
pub branch_name: String,
pub item: Arc<ListItem>,
pub preview_cache: PreviewCache,
}
impl SkimItem for WorktreeSkimItem {
fn text(&self) -> Cow<'_, str> {
Cow::Borrowed(&self.display_text)
}
fn display<'a>(&'a self, _context: skim::DisplayContext<'a>) -> skim::AnsiString<'a> {
skim::AnsiString::parse(&self.display_text_with_ansi)
}
fn output(&self) -> Cow<'_, str> {
Cow::Borrowed(&self.branch_name)
}
fn preview(&self, context: PreviewContext<'_>) -> ItemPreview {
let mode = PreviewStateData::read_mode();
let mut result = Self::render_preview_tabs(mode);
result.push_str(&self.preview_for_mode(mode, context.width, context.height));
ItemPreview::AnsiText(result)
}
}
impl WorktreeSkimItem {
pub(super) fn render_preview_tabs(mode: PreviewMode) -> String {
let reset = Reset;
fn format_tab(label: &str, is_active: bool) -> String {
if is_active {
cformat!("<bold>{}</>", label)
} else {
cformat!("<dim>{}</>", label)
}
}
let tab1 = format_tab("1: HEAD±", mode == PreviewMode::WorkingTree);
let tab2 = format_tab("2: log", mode == PreviewMode::Log);
let tab3 = format_tab("3: main…±", mode == PreviewMode::BranchDiff);
let tab4 = format_tab("4: remote⇅", mode == PreviewMode::UpstreamDiff);
let tab5 = format_tab("5: summary", mode == PreviewMode::Summary);
let controls = cformat!(
"<dim,yellow>Enter: switch | alt-c: create | Esc: cancel | ctrl-u/d: scroll | alt-p: toggle</>"
);
format!(
"{tab1}{reset} | {tab2}{reset} | {tab3}{reset} | {tab4}{reset} | {tab5}{reset}\n{controls}{reset}\n\n"
)
}
fn preview_for_mode(&self, mode: PreviewMode, width: usize, height: usize) -> String {
let cache_key = (self.branch_name.clone(), mode);
let content = self
.preview_cache
.entry(cache_key)
.or_insert_with(|| Self::compute_preview(&self.item, mode, width, height))
.value()
.clone();
match mode {
PreviewMode::Summary => super::summary::render_summary(&content, width),
PreviewMode::WorkingTree | PreviewMode::BranchDiff | PreviewMode::UpstreamDiff => {
if let Some(pager_cmd) = diff_pager() {
pipe_through_pager(&content, pager_cmd, width)
} else {
content
}
}
PreviewMode::Log => content,
}
}
pub(super) fn compute_preview(
item: &ListItem,
mode: PreviewMode,
width: usize,
height: usize,
) -> String {
match mode {
PreviewMode::WorkingTree => Self::compute_working_tree_preview(item, width),
PreviewMode::Log => Self::compute_log_preview(item, width, height),
PreviewMode::BranchDiff => Self::compute_branch_diff_preview(item, width),
PreviewMode::UpstreamDiff => Self::compute_upstream_diff_preview(item, width),
PreviewMode::Summary => Self::compute_summary_preview(item),
}
}
fn compute_working_tree_preview(item: &ListItem, width: usize) -> String {
let Some(wt_info) = item.worktree_data() else {
let branch = item.branch_name();
return format!(
"{INFO_SYMBOL} {branch} is branch only — press Enter to create worktree\n"
);
};
let branch = item.branch_name();
let path = wt_info.path.display().to_string();
compute_diff_preview(
&["-C", &path, "diff", "HEAD"],
&cformat!("{INFO_SYMBOL} <bold>{branch}</> has no uncommitted changes"),
width,
)
}
fn compute_branch_diff_preview(item: &ListItem, width: usize) -> String {
let branch = item.branch_name();
let Ok(repo) = Repository::current() else {
return cformat!("{INFO_SYMBOL} <bold>{branch}</> has no commits ahead of main\n");
};
let Some(default_branch) = repo.default_branch() else {
return cformat!("{INFO_SYMBOL} <bold>{branch}</> has no commits ahead of main\n");
};
if item.counts.is_some_and(|c| c.ahead == 0) {
return cformat!(
"{INFO_SYMBOL} <bold>{branch}</> has no commits ahead of <bold>{default_branch}</>\n"
);
}
let merge_base = format!("{}...{}", default_branch, item.head());
compute_diff_preview(
&["diff", &merge_base],
&cformat!(
"{INFO_SYMBOL} <bold>{branch}</> has no file changes vs <bold>{default_branch}</>"
),
width,
)
}
fn compute_upstream_diff_preview(item: &ListItem, width: usize) -> String {
let branch = item.branch_name();
let Some(active) = item.upstream.as_ref().and_then(|u| u.active()) else {
return cformat!("{INFO_SYMBOL} <bold>{branch}</> has no upstream tracking branch\n");
};
let upstream_ref = format!("{}@{{u}}", branch);
if active.ahead == 0 && active.behind == 0 {
return cformat!("{INFO_SYMBOL} <bold>{branch}</> is up to date with upstream\n");
}
if active.ahead > 0 && active.behind > 0 {
let range = format!("{}...{}", upstream_ref, item.head());
compute_diff_preview(
&["diff", &range],
&cformat!(
"{INFO_SYMBOL} <bold>{branch}</> has diverged (⇡{} ⇣{}) but no unique file changes",
active.ahead,
active.behind
),
width,
)
} else if active.ahead > 0 {
let range = format!("{}...{}", upstream_ref, item.head());
compute_diff_preview(
&["diff", &range],
&cformat!("{INFO_SYMBOL} <bold>{branch}</> has no unpushed file changes"),
width,
)
} else {
let range = format!("{}...{}", item.head(), upstream_ref);
compute_diff_preview(
&["diff", &range],
&cformat!(
"{INFO_SYMBOL} <bold>{branch}</> is behind upstream (⇣{}) but no file changes",
active.behind
),
width,
)
}
}
pub(super) fn compute_log_preview(item: &ListItem, width: usize, height: usize) -> String {
const TIMESTAMP_WIDTH_THRESHOLD: usize = 50;
const HEADER_LINES: usize = 3;
let mut output = String::new();
let show_timestamps = width >= TIMESTAMP_WIDTH_THRESHOLD;
let log_limit = height.saturating_sub(HEADER_LINES).max(1);
let head = item.head();
let branch = item.branch_name();
let Ok(repo) = Repository::current() else {
output.push_str(&cformat!(
"{INFO_SYMBOL} <bold>{branch}</> has no commits\n"
));
return output;
};
let Some(default_branch) = repo.default_branch() else {
output.push_str(&cformat!(
"{INFO_SYMBOL} <bold>{branch}</> has no commits\n"
));
return output;
};
let Ok(merge_base_output) = repo.run_command(&["merge-base", &default_branch, head]) else {
output.push_str(&cformat!(
"{INFO_SYMBOL} <bold>{branch}</> has no commits\n"
));
return output;
};
let merge_base = merge_base_output.trim();
let is_default_branch = branch == default_branch;
let timestamp_format = format!(
"--format=%x01%H%x00%C(auto)%h{}%ct{}%C(auto)%d%C(reset) %s",
FIELD_DELIM, FIELD_DELIM
);
let no_timestamp_format = "--format=%x01%H%x00%C(auto)%h%C(auto)%d%C(reset) %s";
let log_limit_str = log_limit.to_string();
let unique_commits: Option<HashSet<String>> = if is_default_branch {
None
} else {
let range = format!("{}...{}", merge_base, head);
let commits = repo
.run_command(&["rev-list", &range, "--right-only", "-n", &log_limit_str])
.map(|out| out.lines().map(String::from).collect())
.unwrap_or_default();
Some(commits) };
let format: &str = if show_timestamps {
×tamp_format
} else {
no_timestamp_format
};
let args = vec![
"log",
"--graph",
"--no-show-signature",
format,
"--color=always",
"-n",
&log_limit_str,
head,
];
if let Ok(log_output) = repo.run_command(&args) {
let (processed, hashes) =
process_log_with_dimming(&log_output, unique_commits.as_ref());
if show_timestamps {
let stats = batch_fetch_stats(&repo, &hashes);
output.push_str(&format_log_output(&processed, &stats));
} else {
output.push_str(&strip_hash_markers(&processed));
}
}
output
}
fn compute_summary_preview(_item: &ListItem) -> String {
format!(
"{INFO_SYMBOL} Generating summary...\n\n\
Press 5 again to refresh once generation completes.\n"
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use insta::assert_snapshot;
#[test]
fn test_render_preview_tabs() {
for (name, mode) in [
("working_tree", PreviewMode::WorkingTree),
("log", PreviewMode::Log),
("branch_diff", PreviewMode::BranchDiff),
("upstream_diff", PreviewMode::UpstreamDiff),
("summary", PreviewMode::Summary),
] {
assert_snapshot!(name, WorktreeSkimItem::render_preview_tabs(mode));
}
}
#[test]
fn test_compute_summary_preview() {
use crate::commands::list::model::{ItemKind, WorktreeData};
let mut main_item = ListItem::new_branch("abc123".to_string(), "main".to_string());
main_item.kind = ItemKind::Worktree(Box::new(WorktreeData {
is_main: true,
..Default::default()
}));
let feature_item = ListItem::new_branch("abc123".to_string(), "feature".to_string());
assert_snapshot!(
"direct",
WorktreeSkimItem::compute_summary_preview(&main_item)
);
assert_snapshot!(
"via_compute_preview",
WorktreeSkimItem::compute_preview(&feature_item, PreviewMode::Summary, 80, 40)
);
}
#[test]
fn test_preview_for_mode_summary_cache() {
let item = Arc::new(ListItem::new_branch(
"abc123".to_string(),
"feature".to_string(),
));
let cache_hit = {
let preview_cache: PreviewCache = Arc::new(DashMap::new());
preview_cache.insert(
("feature".to_string(), PreviewMode::Summary),
"Add auth module\n\nImplements JWT-based authentication.".to_string(),
);
WorktreeSkimItem {
display_text: String::new(),
display_text_with_ansi: String::new(),
branch_name: "feature".to_string(),
item: Arc::clone(&item),
preview_cache,
}
};
let cache_miss = {
let preview_cache: PreviewCache = Arc::new(DashMap::new());
WorktreeSkimItem {
display_text: String::new(),
display_text_with_ansi: String::new(),
branch_name: "feature".to_string(),
item: Arc::clone(&item),
preview_cache,
}
};
assert_snapshot!(
"cache_hit",
cache_hit.preview_for_mode(PreviewMode::Summary, 80, 40)
);
assert_snapshot!(
"cache_miss",
cache_miss.preview_for_mode(PreviewMode::Summary, 80, 40)
);
}
#[test]
fn test_render_preview_tabs_ansi_codes() {
let output = WorktreeSkimItem::render_preview_tabs(PreviewMode::WorkingTree);
let first_line = output.lines().next().unwrap();
let second_line = output.lines().nth(1).unwrap();
let full_reset = "\x1b[0m";
assert_eq!(first_line.matches(full_reset).count(), 5);
let parts: Vec<&str> = first_line.split(" | ").collect();
assert_eq!(parts.len(), 5);
assert!(parts.iter().all(|part| part.ends_with(full_reset)));
assert!(second_line.ends_with(full_reset));
}
}