Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 87 additions & 45 deletions src/commands/picker/items.rs
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,10 @@ impl WorktreeSkimItem {
format!("○ {verb} {label}. Press {key} again to refresh.\n")
}

/// Compute preview and apply pager for diff modes.
/// Compute preview and apply pager for diff modes. Returns the
/// display-ready string and (for Log) whether the disk cache was a
/// hit — the orchestrator uses the flag to schedule a background
/// refresh.
///
/// Both the inline cache-miss path and background pre-computation use this so
/// that the cache always stores display-ready content (no pager subprocess
Expand All @@ -244,34 +247,33 @@ impl WorktreeSkimItem {
mode: PreviewMode,
width: usize,
height: usize,
) -> String {
let content = Self::compute_preview(repo, item, mode, width, height);
) -> (String, bool) {
match mode {
PreviewMode::WorkingTree | PreviewMode::BranchDiff | PreviewMode::UpstreamDiff => {
if let Some(pager_cmd) = diff_pager() {
pipe_through_pager(&content, pager_cmd, width)
} else {
content
}
}
_ => content,
PreviewMode::WorkingTree => (
Self::page_diff(Self::compute_working_tree_preview(repo, item, width), width),
false,
),
PreviewMode::Log => Self::compute_log_preview(repo, item, width, height),
PreviewMode::BranchDiff => (
Self::page_diff(Self::compute_branch_diff_preview(repo, item, width), width),
false,
),
PreviewMode::UpstreamDiff => (
Self::page_diff(
Self::compute_upstream_diff_preview(repo, item, width),
width,
),
false,
),
PreviewMode::Summary => (Self::loading_placeholder(PreviewMode::Summary), false),
}
}

/// Compute raw preview for any mode.
pub(super) fn compute_preview(
repo: &Repository,
item: &ListItem,
mode: PreviewMode,
width: usize,
height: usize,
) -> String {
match mode {
PreviewMode::WorkingTree => Self::compute_working_tree_preview(repo, item, width),
PreviewMode::Log => Self::compute_log_preview(repo, item, width, height),
PreviewMode::BranchDiff => Self::compute_branch_diff_preview(repo, item, width),
PreviewMode::UpstreamDiff => Self::compute_upstream_diff_preview(repo, item, width),
PreviewMode::Summary => Self::loading_placeholder(PreviewMode::Summary),
fn page_diff(content: String, width: usize) -> String {
if let Some(pager_cmd) = diff_pager() {
pipe_through_pager(&content, pager_cmd, width)
} else {
content
}
}

Expand Down Expand Up @@ -442,12 +444,40 @@ impl WorktreeSkimItem {
/// key out of `main`'s SHA — a `git fetch` advancing `origin/main`
/// doesn't invalidate any entry — while preserving correctness as
/// `main` and wall-clock advance.
/// Render the Log preview and report whether the disk cache was hit.
/// The orchestrator uses the flag to schedule a background refresh —
/// see [`Self::refresh_log_preview`] and the `LogCacheEntry`
/// docstring for why decorations baked into `raw_log` need
/// refreshing even though the cache key is correct.
pub(super) fn compute_log_preview(
repo: &Repository,
item: &ListItem,
width: usize,
height: usize,
) -> (String, bool) {
Self::compute_log_preview_inner(repo, item, width, height, false)
}

/// Force-recompute the Log preview, bypassing the disk-cache read but
/// writing through. Returns the freshly rendered string. Called by the
/// orchestrator's refresh worker after a cache hit so the next visit
/// sees decorations that match current ref topology.
pub(super) fn refresh_log_preview(
repo: &Repository,
item: &ListItem,
width: usize,
height: usize,
) -> String {
Self::compute_log_preview_inner(repo, item, width, height, true).0
}

fn compute_log_preview_inner(
repo: &Repository,
item: &ListItem,
width: usize,
height: usize,
force_recompute: bool,
) -> (String, bool) {
// Minimum preview width to show timestamps (adds ~7 chars: space + 4-char time + space)
// Note: preview is typically 50% of terminal width, so 50 = 100-col terminal
const TIMESTAMP_WIDTH_THRESHOLD: usize = 50;
Expand All @@ -461,7 +491,10 @@ impl WorktreeSkimItem {
let branch = item.branch_name();
let reset = Reset;
let Some(default_branch) = repo.default_branch() else {
return cformat!("{INFO_SYMBOL}{reset} <bold>{branch}</>{reset} has no commits\n");
return (
cformat!("{INFO_SYMBOL}{reset} <bold>{branch}</>{reset} has no commits\n"),
false,
);
};

// merge-base / rev-list run on every call — they're how the
Expand All @@ -473,7 +506,10 @@ impl WorktreeSkimItem {
// the preview is supplementary, users can still select worktrees
// even if a probe fails.
let Ok(merge_base_output) = repo.run_command(&["merge-base", &default_branch, head]) else {
return cformat!("{INFO_SYMBOL}{reset} <bold>{branch}</>{reset} has no commits\n");
return (
cformat!("{INFO_SYMBOL}{reset} <bold>{branch}</>{reset} has no commits\n"),
false,
);
};
let merge_base = merge_base_output.trim();
let is_default_branch = branch == default_branch;
Expand All @@ -500,32 +536,38 @@ impl WorktreeSkimItem {
// Cacheable: the raw `git log --graph` output plus per-commit
// stats. Both are pure functions of (head, width, height); on a
// disk-cache hit we skip the `git log` and `git diff-tree` calls
// entirely. On miss we compute and write through.
let entry = match preview_cache::read_log(repo, head, width, height) {
Some(cached) => cached,
None => {
let Some(fresh) =
Self::compute_log_raw_and_stats(repo, head, log_limit, show_timestamps)
else {
// Match prior behavior: empty output on `git log` failure.
return String::new();
};
preview_cache::write_log(repo, head, width, height, &fresh);
fresh
}
// entirely. On miss we compute and write through. `force_recompute`
// bypasses the read (the refresh path) but always writes.
let cached = if force_recompute {
None
} else {
preview_cache::read_log(repo, head, width, height)
};
let was_disk_hit = cached.is_some();
// On `git log` failure (effectively unreachable here — merge-base
// already validated `head` upstream), fall back to a default
// entry. `process_log_with_dimming` + `format_log_output` handle
// the empty `raw_log` gracefully and produce empty output, the
// same effective behavior as the prior explicit early return.
let entry = cached.unwrap_or_else(|| {
let fresh = Self::compute_log_raw_and_stats(repo, head, log_limit, show_timestamps)
.unwrap_or_default();
preview_cache::write_log(repo, head, width, height, &fresh);
fresh
});

let (processed, _hashes) =
process_log_with_dimming(&entry.raw_log, unique_commits.as_ref());
if show_timestamps {
let rendered = if show_timestamps {
// `format_log_output` reads `epoch_now()` so relative-time
// strings ("5m" / "2h" / "3d") track wall-clock on every call,
// even when serving from cache.
format_log_output(&processed, &entry.stats)
} else {
// Strip hash markers (SOH...NUL) since we're not using format_log_output
strip_hash_markers(&processed)
}
};
(rendered, was_disk_hit)
}

/// Run `git log --graph` and (when timestamps are shown) `batch_fetch_stats`,
Expand Down Expand Up @@ -784,7 +826,7 @@ mod tests {
let item = item_at(&repo, "feature");

assert!(super::preview_cache::read_log(&repo, item.head(), 80, 24).is_none());
let _ = WorktreeSkimItem::compute_log_preview(&repo, &item, 80, 24);
let _ = WorktreeSkimItem::compute_log_preview(&repo, &item, 80, 24).0;
let entry = super::preview_cache::read_log(&repo, item.head(), 80, 24)
.expect("cache populated after first compute");
assert!(
Expand Down Expand Up @@ -829,7 +871,7 @@ mod tests {
// which removes that escape. The dim SGR `\x1b[2m` is unsuitable
// because `format_log_output` already wraps every relative-time
// column in dim, so it appears even in bright lines.
let before = WorktreeSkimItem::compute_log_preview(&repo, &item, 80, 24);
let before = WorktreeSkimItem::compute_log_preview(&repo, &item, 80, 24).0;
let before_subject_line = before
.lines()
.find(|l| l.contains("feature commit"))
Expand All @@ -847,7 +889,7 @@ mod tests {
.unwrap();
repo.run_command(&["checkout", "feature"]).unwrap();

let after = WorktreeSkimItem::compute_log_preview(&repo, &item, 80, 24);
let after = WorktreeSkimItem::compute_log_preview(&repo, &item, 80, 24).0;
let after_subject_line = after
.lines()
.find(|l| l.contains("feature commit"))
Expand Down
15 changes: 14 additions & 1 deletion src/commands/picker/preview_cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,20 @@ const KIND: &str = "picker-preview";
/// `batch_fetch_stats`. The render path re-runs `process_log_with_dimming`
/// against fresh `unique_commits` and `format_log_output` against
/// `epoch_now()`, so output stays correct as `main` and wall-clock advance.
#[derive(Serialize, Deserialize)]
///
/// `raw_log` also embeds `git log --format=%C(auto)%d` ref decorations
/// (e.g. `HEAD -> feature, main`), which are *not* SHA-deterministic —
/// another ref starting or stopping pointing at the same commit (most
/// commonly: a squash merge landing `main` at the cached SHA) would
/// leave the decoration text stale even though the cache key is still
/// valid. The orchestrator mitigates this with a background refresh:
/// every disk-cache hit on the Log mode enqueues a task on a dedicated
/// thread that re-runs `compute_log_raw_and_stats`, overwrites this
/// entry, and updates the in-memory `PreviewCache`. The cached entry
/// served on the *current* render is still potentially stale — refresh
/// is async — but the next visit to the same row reads fresh content.
/// See `commands::picker::preview_orchestrator::spawn_refresh_thread`.
#[derive(Serialize, Deserialize, Default)]
pub(super) struct LogCacheEntry {
pub raw_log: String,
/// Empty when `width < TIMESTAMP_WIDTH_THRESHOLD` (the no-timestamp
Expand Down
Loading
Loading