use std::{path::PathBuf, sync::Arc};
use rho_tools::tool_card::DiffRow;
use super::{
background_tasks::{TaskId, UiOutput},
diff_pane::{patch_rows, row_stats},
local_diff::{self, ChangedFile, DiffSection, FileChange, WorktreeStatus},
picker::{
DetailBlock, DetailSheet, DiffDetail, LabelOverflow, OverlayChrome, OverlayShape,
PickerDetail,
},
App, ComposerMode, Entry, PickerBadge, PickerBadgeTone, PickerItem, PickerKeyHints,
PickerLayout, TabKey, UiPicker,
};
#[derive(Debug)]
pub(super) struct DiffViewer {
repo_root: PathBuf,
slots: Vec<FileSlot>,
}
#[derive(Debug)]
struct FileSlot {
file: ChangedFile,
loaded: bool,
}
impl App {
pub(super) fn execute_diff_command(&mut self) -> anyhow::Result<()> {
let status = match local_diff::collect_status(&self.info.runtime.cwd) {
Ok(status) => status,
Err(error) => {
self.insert_entry(&Entry::Error(format!("could not show git diff: {error}")));
self.set_status("git diff unavailable");
return Ok(());
}
};
let clean = status.files.is_empty();
self.tasks.abort(|id| *id == TaskId::DiffPatch);
self.input_ui
.set_composer(ComposerMode::Picker(diff_picker(&status)));
self.diff_viewer = Some(DiffViewer {
repo_root: status.repo_root,
slots: status
.files
.into_iter()
.map(|file| FileSlot {
file,
loaded: false,
})
.collect(),
});
self.set_status(if clean {
"worktree clean"
} else {
"worktree diff"
});
self.request_selected_diff();
Ok(())
}
pub(super) fn request_selected_diff(&mut self) {
let Some(index) = self.selected_diff_index() else {
return;
};
let Some(viewer) = self.diff_viewer.as_mut() else {
return;
};
let Some(slot) = viewer.slots.get(index) else {
return;
};
if slot.loaded || self.tasks.contains(|id| *id == TaskId::DiffPatch) {
return;
}
let repo_root = viewer.repo_root.clone();
let file = slot.file.clone();
self.tasks.spawn_blocking(
TaskId::DiffPatch,
move || local_diff::file_patch(&repo_root, &file).map(|patch| patch_rows(&patch)),
move |result| {
let result = result
.unwrap_or_else(|error| Err(anyhow::anyhow!("patch load failed: {error}")));
UiOutput::DiffPatch(index, result).into()
},
);
}
pub(super) fn drop_closed_diff_viewer(&mut self) {
if self.diff_viewer.is_some()
&& !matches!(self.input_ui.composer(), ComposerMode::Picker(picker) if picker.is_view_diff())
{
self.diff_viewer = None;
self.tasks.abort(|id| *id == TaskId::DiffPatch);
}
}
pub(super) fn apply_diff_patch(
&mut self,
index: usize,
result: anyhow::Result<Vec<DiffRow>>,
) -> bool {
self.drop_closed_diff_viewer();
if self.diff_viewer.is_none() {
return false;
}
self.apply_loaded_patch(index, result);
self.request_selected_diff();
true
}
fn selected_diff_index(&self) -> Option<usize> {
match self.input_ui.composer() {
ComposerMode::Picker(picker) if picker.is_view_diff() => picker.selected_index(),
_ => None,
}
}
fn apply_loaded_patch(&mut self, index: usize, result: anyhow::Result<Vec<DiffRow>>) {
let Some(slot) = self
.diff_viewer
.as_mut()
.and_then(|viewer| viewer.slots.get_mut(index))
else {
return;
};
slot.loaded = true;
let file = &slot.file;
let ComposerMode::Picker(picker) = self.input_ui.composer_mut() else {
return;
};
let Some(item) = picker.items.get_mut(index) else {
return;
};
item.detail = Some(match result {
Ok(rows) => {
if file.change == FileChange::Untracked {
item.badge = Some(file_badge(file, Some(row_stats(&rows))));
}
PickerDetail::Diff(Arc::new(DiffDetail {
heading: file_heading(file),
path: file.path.clone(),
rows,
}))
}
Err(error) => PickerDetail::Sheet(DetailSheet {
blocks: vec![
DetailBlock::Title {
text: file_heading(file),
tag: None,
},
DetailBlock::Error(format!("could not load diff: {error}")),
],
}),
});
}
}
fn diff_picker(status: &WorktreeStatus) -> UiPicker {
let branch = status.branch.as_deref().unwrap_or("detached HEAD");
let summary = match status.files.len() {
0 => "clean".to_string(),
1 => "1 file".to_string(),
count => format!("{count} files"),
};
let tab = if status.files.is_empty() {
TabKey::None
} else {
TabKey::CycleItems
};
let items = status.files.iter().map(file_item).collect();
UiPicker::view_diff(format!("Diff · {branch} · {summary}"), items)
.with_layout(PickerLayout::Overlay)
.with_overlay_shape(OverlayShape::Viewer)
.with_overlay_chrome(OverlayChrome {
nav_label: " FILES".into(),
detail_label: Some(" DIFF".into()),
nav_keys_hint: "↑↓ files".into(),
})
.with_label_overflow(LabelOverflow::KeepEnd)
.with_key_hints(PickerKeyHints {
tab,
..PickerKeyHints::default()
})
.with_fuzzy_filter()
.with_empty_message("worktree clean")
}
fn file_item(file: &ChangedFile) -> PickerItem {
PickerItem {
label: file.path.clone(),
section: Some(section_label(file.section).into()),
detail: Some(PickerDetail::Text("loading diff…".into())),
preview: None,
badge: Some(file_badge(file, file.stats)),
value: file.path.clone(),
selection_verb: None,
allow_filter_completion: false,
}
}
fn section_label(section: DiffSection) -> &'static str {
match section {
DiffSection::Staged => "Staged",
DiffSection::Unstaged => "Unstaged",
DiffSection::Untracked => "Untracked",
}
}
fn file_badge(file: &ChangedFile, stats: Option<(u64, u64)>) -> PickerBadge {
let (marker, tone) = match file.change {
FileChange::Modified => ("M", PickerBadgeTone::Muted),
FileChange::Added => ("A", PickerBadgeTone::Healthy),
FileChange::Deleted => ("D", PickerBadgeTone::Error),
FileChange::Renamed => ("R", PickerBadgeTone::Muted),
FileChange::Copied => ("C", PickerBadgeTone::Muted),
FileChange::TypeChanged => ("T", PickerBadgeTone::Muted),
FileChange::Unmerged => ("U", PickerBadgeTone::Warning),
FileChange::Untracked => ("??", PickerBadgeTone::Healthy),
};
let text = match stats {
Some((added, removed)) => format!("{marker} +{added} -{removed}"),
None => marker.to_string(),
};
PickerBadge { text, tone }
}
fn file_heading(file: &ChangedFile) -> String {
match &file.orig_path {
Some(orig) => format!("{orig} → {}", file.path),
None => file.path.clone(),
}
}