use std::borrow::Cow;
use anyhow::Result;
use crossterm::event::{KeyCode, KeyModifiers, MouseButton, MouseEventKind};
use ratatui::{
Frame,
layout::{Position, Rect},
text::{Line, Span},
widgets::{ListItem, Paragraph},
};
use crate::core::diff::DiffHunk;
use crate::tui::shell::{KeyResult, PaneId, Shell, ShellApp, ShellConfig};
use crate::tui::theme::TuiTheme;
use crate::tui::widgets::common::{pane_block, pane_block_titled};
use crate::tui::widgets::diff_pane::DiffPane;
use crate::tui::widgets::hunk_view::{HunkEvent, HunkView};
use crate::tui::widgets::list_pane::ListPane;
use crate::tui::widgets::popup::{self, HelpSection};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum HunkOrigin {
Staged,
Unstaged,
Commit,
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct HunkEntry {
pub hunk: DiffHunk,
pub selected: bool,
pub origin: HunkOrigin,
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct FileEntry {
pub path: String,
pub hunks: Vec<HunkEntry>,
pub index_status: char,
pub worktree_status: char,
pub binary: bool,
}
impl FileEntry {
pub(crate) fn is_untracked(&self) -> bool {
self.index_status == '?' && self.worktree_status == '?'
}
pub(crate) fn effective_status(&self) -> (char, char) {
let will_have_staged = self.hunks.iter().any(|h| h.selected);
let will_have_unstaged = self.hunks.iter().any(|h| !h.selected);
if self.is_untracked() {
return if will_have_staged {
('A', ' ')
} else {
('?', '?')
};
}
if self.index_status == 'A' && !will_have_staged {
return ('?', '?');
}
let eff_index = if will_have_staged {
match self.index_status {
'A' | 'M' | 'D' | 'R' => self.index_status,
_ => match self.worktree_status {
'D' => 'D',
_ => 'M',
},
}
} else {
' '
};
let eff_worktree = if will_have_unstaged {
match self.worktree_status {
'M' | 'D' => self.worktree_status,
_ => match self.index_status {
'D' => 'D',
_ => 'M',
},
}
} else {
' '
};
(eff_index, eff_worktree)
}
}
pub(crate) enum Verdict {
Confirm,
Cancel,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum DisplayRow {
Directory { dir_start: usize, dir_end: usize },
File(usize),
}
pub(crate) struct HunkSelectorApp {
files: Vec<FileEntry>,
display_rows: Vec<DisplayRow>,
list: ListPane,
hunks: HunkView,
theme: TuiTheme,
action: Option<&'static str>,
help: Option<DiffPane>,
}
const HELP: &[HelpSection] = &[
(
"Navigation",
&[
(
"↑/k ↓/j",
"previous / next file, or hunk when the diff has focus",
),
("Tab", "switch pane"),
("Ctrl-←/→", "narrow / widen the left pane"),
("Mouse", "click a file or a hunk, wheel to scroll"),
],
),
(
"Selection",
&[
(
"Space",
"toggle the hunk, or every hunk of the file or directory",
),
("Enter", "confirm the selection"),
("Esc / q / Ctrl-C", "cancel, changing nothing"),
("?", "this help"),
],
),
];
fn directory_of(path: &str) -> &str {
match path.rfind('/') {
Some(pos) => &path[..pos],
None => "",
}
}
fn filename_of(path: &str) -> &str {
match path.rfind('/') {
Some(pos) => &path[pos + 1..],
None => path,
}
}
fn build_display_rows(files: &[FileEntry]) -> Vec<DisplayRow> {
let mut rows = Vec::new();
let mut i = 0;
while i < files.len() {
let dir = directory_of(&files[i].path);
if dir.is_empty() {
rows.push(DisplayRow::File(i));
i += 1;
} else {
let dir_start = i;
while i < files.len() && directory_of(&files[i].path) == dir {
i += 1;
}
let dir_end = i - 1;
rows.push(DisplayRow::Directory { dir_start, dir_end });
for j in dir_start..=dir_end {
rows.push(DisplayRow::File(j));
}
}
}
rows
}
impl HunkSelectorApp {
pub(crate) fn new(files: Vec<FileEntry>, theme: TuiTheme) -> Self {
let display_rows = build_display_rows(&files);
Self {
files,
display_rows,
list: ListPane::new(0),
hunks: HunkView::new(),
theme,
action: None,
help: None,
}
}
fn current_file_index(&self) -> Option<usize> {
match self.display_rows.get(self.list.cursor()) {
Some(DisplayRow::File(i)) => Some(*i),
_ => None,
}
}
fn render_file_list(&mut self, frame: &mut Frame, area: Rect, focused: bool) {
let items: Vec<ListItem> = self
.display_rows
.iter()
.map(|row| match row {
DisplayRow::Directory { dir_start, .. } => {
let dir = directory_of(&self.files[*dir_start].path);
let text = format!("\u{25BC} {}", dir);
ListItem::new(Line::from(Span::styled(text, self.theme.file_normal)))
}
DisplayRow::File(idx) => {
let f = &self.files[*idx];
let (eff_idx, eff_wt) = f.effective_status();
let in_dir = !directory_of(&f.path).is_empty();
let name = if in_dir {
filename_of(&f.path)
} else {
&f.path
};
let indent = if in_dir { " " } else { "" };
let is_untracked = eff_idx == '?' && eff_wt == '?';
let mut spans: Vec<Span> = if is_untracked {
vec![Span::styled(
format!("{}??", indent),
self.theme.untracked_status,
)]
} else {
vec![
Span::raw(indent.to_string()),
Span::styled(eff_idx.to_string(), self.theme.staged_status),
Span::styled(eff_wt.to_string(), self.theme.unstaged_status),
]
};
let name_style = if eff_idx == '?' || eff_wt == '?' {
self.theme.file_normal
} else if eff_idx != ' ' && eff_wt == ' ' {
self.theme.file_fully_staged
} else if eff_idx != ' ' && eff_wt != ' ' {
self.theme.file_partially_staged
} else {
self.theme.file_normal
};
spans.push(Span::styled(format!(" {}", name), name_style));
ListItem::new(Line::from(spans))
}
})
.collect();
let block = match self.action {
Some(action) => pane_block_titled(
Line::from(vec![
Span::raw(" "),
Span::styled(action, self.theme.pending_tag),
Span::raw(" "),
]),
&self.theme,
focused,
),
None => pane_block(" Files ", &self.theme, focused),
};
self.list
.render(frame, area, items, block, self.theme.file_selected);
}
fn render_diff_view(&mut self, frame: &mut Frame, area: Rect, focused: bool) {
let block = pane_block(" Diff ", &self.theme, focused);
if self.files.is_empty() {
let empty = Paragraph::new("No files").block(block);
frame.render_widget(empty, area);
return;
}
let file_idx = match self.current_file_index() {
Some(i) => i,
None => {
if let Some(DisplayRow::Directory { dir_start, dir_end }) =
self.display_rows.get(self.list.cursor())
{
let count = dir_end - dir_start + 1;
let dir = directory_of(&self.files[*dir_start].path);
let text = format!("{} file(s) in {}/", count, dir);
let p = Paragraph::new(text).block(block);
frame.render_widget(p, area);
} else {
let empty = Paragraph::new("No files").block(block);
frame.render_widget(empty, area);
}
return;
}
};
let file = &self.files[file_idx];
let lines = self.hunks.build_lines(file, &self.theme, focused);
self.hunks.render(frame, area, lines, block);
}
fn move_file_cursor(&mut self, dir: isize) {
if self
.list
.move_cursor(dir, self.display_rows.len(), |_| true)
{
self.hunks.reset();
}
}
fn navigate_up(&mut self, pane: PaneId) {
if self.display_rows.is_empty() {
return;
}
match pane {
PaneId::Left => self.move_file_cursor(-1),
PaneId::Right => {
let Some(file_idx) = self.current_file_index() else {
return;
};
if let HunkEvent::WrapToPrevFile = self.hunks.move_up(&self.files[file_idx].hunks) {
if let Some(prev) = self.prev_file_row() {
self.list.set_cursor(prev);
let file_idx = match self.display_rows[prev] {
DisplayRow::File(i) => i,
_ => unreachable!(),
};
self.hunks.focus_last(&self.files[file_idx].hunks);
}
}
}
}
}
fn navigate_down(&mut self, pane: PaneId) {
if self.display_rows.is_empty() {
return;
}
match pane {
PaneId::Left => self.move_file_cursor(1),
PaneId::Right => {
let Some(file_idx) = self.current_file_index() else {
return;
};
if let HunkEvent::WrapToNextFile = self.hunks.move_down(&self.files[file_idx].hunks)
{
if let Some(next) = self.next_file_row() {
self.list.set_cursor(next);
self.hunks.reset();
}
}
}
}
}
fn toggle(&mut self, pane: PaneId) {
if self.display_rows.is_empty() {
return;
}
match pane {
PaneId::Left => match self.display_rows[self.list.cursor()] {
DisplayRow::Directory { dir_start, dir_end } => {
let any_selected = (dir_start..=dir_end)
.any(|i| self.files[i].hunks.iter().any(|h| h.selected));
let new_state = !any_selected;
for i in dir_start..=dir_end {
for h in &mut self.files[i].hunks {
h.selected = new_state;
}
}
}
DisplayRow::File(idx) => {
let any_selected = self.files[idx].hunks.iter().any(|h| h.selected);
let new_state = !any_selected;
for h in &mut self.files[idx].hunks {
h.selected = new_state;
}
}
},
PaneId::Right => {
if let Some(file_idx) = self.current_file_index() {
self.hunks.toggle_focused(&mut self.files[file_idx].hunks);
}
}
}
}
fn prev_file_row(&self) -> Option<usize> {
let mut pos = self.list.cursor();
while pos > 0 {
pos -= 1;
if matches!(self.display_rows[pos], DisplayRow::File(_)) {
return Some(pos);
}
}
None
}
fn next_file_row(&self) -> Option<usize> {
let mut pos = self.list.cursor();
while pos + 1 < self.display_rows.len() {
pos += 1;
if matches!(self.display_rows[pos], DisplayRow::File(_)) {
return Some(pos);
}
}
None
}
}
impl ShellApp for HunkSelectorApp {
type Exit = Verdict;
fn config(&self) -> ShellConfig {
ShellConfig { split: (30, 70) }
}
fn theme(&self) -> &TuiTheme {
&self.theme
}
fn quit_exit(&mut self) -> Verdict {
Verdict::Cancel
}
fn handle_key(
&mut self,
focused: PaneId,
code: KeyCode,
modifiers: KeyModifiers,
) -> KeyResult<Verdict> {
if let Some(mut scroll) = self.help.take() {
match code {
KeyCode::Esc | KeyCode::Char('q' | '?') => {}
KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => {
return KeyResult::Exit(Verdict::Cancel);
}
KeyCode::Up | KeyCode::Char('k') => {
scroll.scroll_by(-1);
self.help = Some(scroll);
}
KeyCode::Down | KeyCode::Char('j') => {
scroll.scroll_by(1);
self.help = Some(scroll);
}
_ => self.help = Some(scroll),
}
return KeyResult::Handled;
}
match code {
KeyCode::Esc => KeyResult::Exit(Verdict::Cancel),
KeyCode::Enter => KeyResult::Exit(Verdict::Confirm),
KeyCode::Char('?') => {
self.help = Some(DiffPane::new());
KeyResult::Handled
}
KeyCode::Up | KeyCode::Char('k') => {
self.navigate_up(focused);
KeyResult::Handled
}
KeyCode::Down | KeyCode::Char('j') => {
self.navigate_down(focused);
KeyResult::Handled
}
KeyCode::Char(' ') => {
self.toggle(focused);
KeyResult::Handled
}
_ => KeyResult::Handled,
}
}
fn handle_mouse(&mut self, pane: PaneId, kind: MouseEventKind, pos: Position, area: Rect) {
match pane {
PaneId::Left => match kind {
MouseEventKind::Down(MouseButton::Left) => {
let Some(clicked) = self.list.hit_test(area, pos.y) else {
return;
};
if clicked < self.display_rows.len() {
self.list.set_cursor(clicked);
self.hunks.reset();
}
}
MouseEventKind::ScrollUp => self.move_file_cursor(-1),
MouseEventKind::ScrollDown => self.move_file_cursor(1),
_ => {}
},
PaneId::Right => match kind {
MouseEventKind::Down(MouseButton::Left) => {
let Some(file_idx) = self.current_file_index() else {
return;
};
let inner_top = area.y + 1;
let inner_height = area.height.saturating_sub(2);
if pos.y < inner_top || pos.y >= inner_top + inner_height {
return;
}
let clicked_line = (pos.y - inner_top) as usize + self.hunks.scroll() as usize;
self.hunks
.click(&mut self.files[file_idx].hunks, clicked_line);
}
MouseEventKind::ScrollUp => self.hunks.scroll_by(-3),
MouseEventKind::ScrollDown => self.hunks.scroll_by(3),
_ => {}
},
}
}
fn render_pane(&mut self, frame: &mut Frame, pane: PaneId, area: Rect, focused: bool) {
match pane {
PaneId::Left => self.render_file_list(frame, area, focused),
PaneId::Right => self.render_diff_view(frame, area, focused),
}
}
fn status_hints(&self, _focused: PaneId) -> Vec<Cow<'static, str>> {
vec![
"Toggle: space".into(),
"Confirm: Enter".into(),
"Help: ?".into(),
"Cancel: Esc".into(),
]
}
fn modal_active(&self) -> bool {
self.help.is_some()
}
fn render_overlay(&mut self, frame: &mut Frame, area: Rect) {
if let Some(scroll) = &mut self.help {
popup::render_help(frame, area, HELP, scroll, &self.theme);
}
}
}
pub fn run_hunk_selector(files: Vec<FileEntry>, theme: TuiTheme) -> Result<Option<Vec<FileEntry>>> {
refuse_in_agent_mode()?;
if files.is_empty() {
return Ok(None);
}
let (app, verdict) = Shell::new(HunkSelectorApp::new(files, theme)).run()?;
Ok(confirmed(app, verdict))
}
fn refuse_in_agent_mode() -> Result<()> {
if crate::core::agent_mode::enabled() {
anyhow::bail!(
"the hunk picker is a full-screen UI and cannot run in agent mode\n\
This path should have answered with a hunk listing (spec 019)"
);
}
Ok(())
}
pub(crate) fn run_hunk_selector_nested(
files: Vec<FileEntry>,
theme: TuiTheme,
action: &'static str,
terminal: &mut ratatui::DefaultTerminal,
) -> Result<Option<Vec<FileEntry>>> {
refuse_in_agent_mode()?;
if files.is_empty() {
return Ok(None);
}
let mut app = HunkSelectorApp::new(files, theme);
app.action = Some(action);
let (app, verdict) = Shell::new(app).run_nested(terminal)?;
Ok(confirmed(app, verdict))
}
fn confirmed(app: HunkSelectorApp, verdict: Verdict) -> Option<Vec<FileEntry>> {
match verdict {
Verdict::Confirm => Some(app.files),
Verdict::Cancel => None,
}
}
#[cfg(test)]
pub(crate) mod test_support {
use super::*;
use crate::core::graph::Theme;
pub(crate) fn sample_app() -> HunkSelectorApp {
let files = vec![FileEntry {
path: "main.rs".to_string(),
hunks: vec![HunkEntry {
hunk: DiffHunk {
text: "@@ -1,1 +1,1 @@\n-a\n+b\n".to_string(),
modified_lines: vec![],
},
selected: false,
origin: HunkOrigin::Unstaged,
}],
index_status: ' ',
worktree_status: 'M',
binary: false,
}];
HunkSelectorApp::new(files, TuiTheme::from_graph_theme(&Theme::dark()))
}
}
#[cfg(test)]
#[path = "hunk_selector_test.rs"]
mod tests;