use std::path::PathBuf;
use std::cell::{Cell, RefCell};
use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Position, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, List, ListItem, Paragraph},
};
use crate::editor::buffer::Buffer;
use crate::interraction::hit_test;
use crate::input::{Key, KeyEvent, Modifiers, MouseButton, MouseEvent, MouseEventKind};
use crate::operation::{CommitOp, Event, Operation};
use crate::settings::Settings;
use crate::vcs::{DiffLine, GitMode, StatusEntry};
use crate::path_format::width;
use crate::views::View;
use crate::widgets::DiffWidget;
use crate::widgets::focus::FocusRing;
use crate::widgets::focusable::FocusOp;
use crate::widgets::input_field::InputField;
use crate::widgets::status_bar::StatusBarBuilder;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CommitTab {
Commit,
Diff,
}
#[derive(Debug, Clone)]
pub(crate) enum BranchPopup {
Selector { cursor: usize },
}
const FOCUS_STAGED: &str = "staged";
const FOCUS_UNSTAGED: &str = "unstaged";
const FOCUS_MESSAGE: &str = "message";
const FOCUS_COMMIT_BTN: &str = "commit_btn";
const FOCUS_FILE_LIST: &str = "file_list";
const FOCUS_DIFF_LINES: &str = "diff_lines";
#[derive(Debug)]
pub struct CommitWindow {
pub repo_path: PathBuf,
pub(crate) tab: CommitTab,
pub(crate) loading: bool,
pub(crate) staged: Vec<StatusEntry>,
pub(crate) unstaged: Vec<StatusEntry>,
pub(crate) commit_focus: FocusRing,
pub(crate) staged_cursor: usize,
pub(crate) unstaged_cursor: usize,
pub(crate) message_buf: Buffer,
pub(crate) branches: Vec<String>,
pub(crate) current_branch: String,
pub(crate) branch_input: Option<InputField>,
pub(crate) branch_popup: Option<BranchPopup>,
pub(crate) diff_file: Option<PathBuf>,
pub(crate) diff_staged: bool,
pub(crate) diff_lines_raw: Vec<DiffLine>,
pub(crate) diff_widget: Option<DiffWidget>,
pub(crate) diff_list_cursor: usize,
pub(crate) diff_focus: FocusRing,
rect_tab_bar: Cell<Rect>,
rect_staged_list: Cell<Rect>,
rect_unstaged_list: Cell<Rect>,
rect_message: Cell<Rect>,
rect_commit_button: Cell<Rect>,
rect_cancel_button: Cell<Rect>,
rect_diff_file_list: Cell<Rect>,
rect_diff: Cell<Rect>,
rect_view_area: Cell<Rect>,
staged_items_map: RefCell<Vec<Option<usize>>>,
unstaged_items_map: RefCell<Vec<Option<usize>>>,
diff_items_map: RefCell<Vec<Option<usize>>>,
pub(crate) status_msg: Option<String>,
pub(crate) amend_mode: bool,
pub(crate) base_commit_oid: Option<String>,
pub(crate) git_mode: GitMode,
pub(crate) has_conflicts: bool,
}
impl CommitWindow {
pub fn new(repo_path: PathBuf) -> Self {
Self {
repo_path,
tab: CommitTab::Commit,
loading: true,
staged: Vec::new(),
unstaged: Vec::new(),
commit_focus: FocusRing::new(vec![
FOCUS_STAGED, FOCUS_UNSTAGED, FOCUS_MESSAGE, FOCUS_COMMIT_BTN,
]),
staged_cursor: 0,
unstaged_cursor: 0,
message_buf: Buffer::from_lines(vec![String::new()], None),
branches: Vec::new(),
current_branch: String::new(),
branch_input: None,
branch_popup: None,
diff_file: None,
diff_staged: false,
diff_lines_raw: Vec::new(),
diff_widget: None,
diff_list_cursor: 0,
diff_focus: FocusRing::new(vec![FOCUS_FILE_LIST, FOCUS_DIFF_LINES]),
rect_tab_bar: Cell::new(Rect::default()),
rect_staged_list: Cell::new(Rect::default()),
rect_unstaged_list: Cell::new(Rect::default()),
rect_message: Cell::new(Rect::default()),
rect_commit_button: Cell::new(Rect::default()),
rect_cancel_button: Cell::new(Rect::default()),
rect_diff_file_list: Cell::new(Rect::default()),
rect_diff: Cell::new(Rect::default()),
rect_view_area: Cell::new(Rect::default()),
staged_items_map: RefCell::new(Vec::new()),
unstaged_items_map: RefCell::new(Vec::new()),
diff_items_map: RefCell::new(Vec::new()),
status_msg: None,
amend_mode: false,
base_commit_oid: None,
git_mode: GitMode::Normal,
has_conflicts: false,
}
}
fn clamp(idx: usize, len: usize) -> usize {
if len == 0 { 0 } else { idx.min(len - 1) }
}
fn clamp_all(&mut self) {
self.staged_cursor = Self::clamp(self.staged_cursor, self.staged.len());
self.unstaged_cursor = Self::clamp(self.unstaged_cursor, self.unstaged.len());
let n = self.staged.len() + self.unstaged.len();
self.diff_list_cursor = Self::clamp(self.diff_list_cursor, n);
}
pub(crate) fn message_is_empty(&self) -> bool {
let lines = self.message_buf.lines();
lines.len() == 1 && lines[0].is_empty()
}
pub(crate) fn message_text(&self) -> String {
self.message_buf.lines().join("\n")
}
fn active_pane(&self) -> &str {
self.commit_focus.current()
}
fn active_diff_focus(&self) -> &str {
self.diff_focus.current()
}
pub fn selected_file_path(&self) -> Option<PathBuf> {
match self.active_pane() {
FOCUS_STAGED => self.staged.get(self.staged_cursor).map(|e| e.path.clone()),
FOCUS_UNSTAGED => self
.unstaged
.get(self.unstaged_cursor)
.map(|e| e.path.clone()),
_ => None,
}
}
pub fn is_selected_staged(&self) -> bool {
self.active_pane() == FOCUS_STAGED
}
pub fn diff_stage_info(&self) -> Option<(PathBuf, bool, Vec<usize>, Vec<DiffLine>)> {
let widget = self.diff_widget.as_ref()?;
if !widget.has_staged() {
return None;
}
let pairs = widget.staged_lines(&self.diff_lines_raw);
let indices: Vec<usize> = pairs.iter().map(|(i, _)| *i).collect();
Some((
self.diff_file.clone()?,
self.diff_staged,
indices,
self.diff_lines_raw.clone(),
))
}
pub fn diff_jump_line(&self) -> usize {
self.diff_widget
.as_ref()
.and_then(|w| w.selected_line_no())
.unwrap_or(1)
.saturating_sub(1)
}
pub fn all_diff_files(&self) -> Vec<(PathBuf, bool)> {
let mut files: Vec<(PathBuf, bool)> = self
.staged
.iter()
.map(|e| (e.path.clone(), true))
.chain(self.unstaged.iter().map(|e| (e.path.clone(), false)))
.collect();
files.sort_by(|a, b| a.0.cmp(&b.0));
files
}
fn load_diff_op_for_index(&self, idx: usize) -> Option<Operation> {
let files = self.all_diff_files();
files.get(idx).map(|(path, staged)| {
Operation::CommitLocal(CommitOp::LoadDiff {
path: path.clone(),
staged: *staged,
})
})
}
fn load_diff_for_selected(&self) -> Vec<Operation> {
self.load_diff_op_for_index(self.diff_list_cursor).into_iter().collect()
}
}
impl View for CommitWindow {
const KIND: crate::views::ViewKind = crate::views::ViewKind::Primary;
fn save_state(&mut self, _app: &mut crate::app_state::AppState) {}
fn status_bar(&self, _state: &crate::app_state::AppState, bar: &mut StatusBarBuilder) {
let branch = if self.current_branch.is_empty() { "(detached)".to_string() } else { self.current_branch.clone() };
bar.menu(
format!("branch: {}", branch),
crate::commands::CommandId::new_static("vcs", "open_branch_selector"),
);
}
fn handle_key(&self, key: KeyEvent) -> Vec<Operation> {
if let Some(ref bi) = self.branch_input {
return match (key.modifiers, key.key) {
(_, Key::Escape) => vec![Operation::CommitLocal(CommitOp::BranchInputCancel)],
(_, Key::Enter) => vec![Operation::CommitLocal(CommitOp::BranchCreate)],
_ => {
if let Some(field_op) = crate::widgets::input_field::key_to_op(key) {
let mut f = bi.clone();
f.apply(&field_op);
vec![Operation::CommitLocal(CommitOp::BranchChanged(
f.text().to_owned(),
))]
} else {
vec![]
}
}
};
}
if let Some(BranchPopup::Selector { cursor }) = &self.branch_popup {
return match key.key {
Key::Escape => vec![Operation::CommitLocal(CommitOp::BranchSelectorCancel)],
Key::Enter => {
let idx = *cursor;
if idx < self.branches.len() {
let name = self.branches[idx].clone();
vec![Operation::CommitLocal(CommitOp::BranchCheckout(name))]
} else {
vec![]
}
}
Key::ArrowUp => vec![Operation::NavigateUp],
Key::ArrowDown => vec![Operation::NavigateDown],
_ => vec![],
};
}
match (key.modifiers, key.key) {
(_, Key::Escape) if self.git_mode == GitMode::Rebase => {
vec![Operation::CommitLocal(CommitOp::AbortRebase)]
}
(_, Key::Escape) if self.git_mode == GitMode::Merge => {
vec![Operation::CommitLocal(CommitOp::AbortMerge)]
}
(_, Key::Tab) => vec![Operation::CommitLocal(CommitOp::SwitchTab)],
(m, Key::Char('s')) if m.contains(Modifiers::CTRL) => {
if self.message_is_empty() {
vec![]
} else if self.amend_mode {
vec![Operation::CommitLocal(CommitOp::ConfirmAmend)]
} else {
vec![Operation::CommitLocal(CommitOp::Confirm)]
}
}
_ if self.tab == CommitTab::Diff => {
let df = self.active_diff_focus();
match (key.modifiers, key.key) {
(_, Key::ArrowLeft) if df == FOCUS_DIFF_LINES => {
vec![Operation::Focus(FocusOp::Left)]
}
(_, Key::ArrowRight) if df == FOCUS_FILE_LIST => {
vec![Operation::Focus(FocusOp::Right)]
}
(_, Key::ArrowUp) if df == FOCUS_FILE_LIST => {
let target = self.diff_list_cursor.saturating_sub(1);
let mut ops = vec![Operation::NavigateUp];
if let Some(load) = self.load_diff_op_for_index(target) {
ops.push(load);
}
ops
}
(_, Key::ArrowDown) if df == FOCUS_FILE_LIST => {
let total = self.staged.len() + self.unstaged.len();
if total == 0 {
vec![]
} else {
let target = (self.diff_list_cursor + 1).min(total - 1);
let mut ops = vec![Operation::NavigateDown];
if let Some(load) = self.load_diff_op_for_index(target) {
ops.push(load);
}
ops
}
}
(_, Key::Enter) if df == FOCUS_FILE_LIST => {
let mut ops = self.load_diff_for_selected();
ops.push(Operation::Focus(FocusOp::Right));
ops
}
(_, Key::ArrowUp) => vec![Operation::NavigateUp],
(_, Key::ArrowDown) => vec![Operation::NavigateDown],
(_, Key::PageUp) => vec![Operation::NavigatePageUp],
(_, Key::PageDown) => vec![Operation::NavigatePageDown],
(_, Key::Char(' ')) => {
vec![Operation::CommitLocal(CommitOp::ToggleLineStage)]
}
(_, Key::Char('r')) => vec![Operation::CommitLocal(CommitOp::RevertLine)],
(_, Key::Char('e')) => {
if let (Some(path), row) = (self.diff_file.clone(), self.diff_jump_line())
{
vec![
Operation::OpenFile { path },
Operation::MoveCursor {
path: None,
cursor: crate::editor::Position::new(row, 0),
},
]
} else {
vec![]
}
}
_ => vec![],
}
}
_ if self.tab == CommitTab::Commit && self.active_pane() == FOCUS_COMMIT_BTN => {
match (key.modifiers, key.key) {
(_, Key::Enter) | (_, Key::Char(' ')) => {
if !self.message_is_empty() {
if self.amend_mode {
vec![Operation::CommitLocal(CommitOp::ConfirmAmend)]
} else {
vec![Operation::CommitLocal(CommitOp::Confirm)]
}
} else {
vec![]
}
}
(_, Key::ArrowLeft)
| (_, Key::ArrowRight)
| (_, Key::ArrowUp)
| (_, Key::ArrowDown) => {
vec![Operation::Focus(FocusOp::Next)]
}
_ => vec![],
}
}
_ if self.tab == CommitTab::Commit && self.active_pane() == FOCUS_MESSAGE => {
match (key.modifiers, key.key) {
(_, Key::ArrowLeft) => vec![Operation::CommitLocal(CommitOp::MessageCursorLeft)],
(_, Key::ArrowRight) => vec![Operation::CommitLocal(CommitOp::MessageCursorRight)],
(_, Key::ArrowUp) => vec![Operation::CommitLocal(CommitOp::MessageCursorUp)],
(_, Key::ArrowDown) => vec![Operation::CommitLocal(CommitOp::MessageCursorDown)],
(_, Key::Enter) => vec![Operation::CommitLocal(CommitOp::MessageInsertNewline)],
(_, Key::Backspace) => vec![Operation::CommitLocal(CommitOp::MessageBackspace)],
(mods, Key::Char(c))
if !mods.contains(Modifiers::CTRL) && !mods.contains(Modifiers::ALT) =>
{
vec![Operation::CommitLocal(CommitOp::MessageInsertChar(c))]
}
_ => vec![],
}
}
_ if self.tab == CommitTab::Commit => match (key.modifiers, key.key) {
(_, Key::ArrowUp) => vec![Operation::NavigateUp],
(_, Key::ArrowDown) => vec![Operation::NavigateDown],
(_, Key::PageUp) => vec![Operation::NavigatePageUp],
(_, Key::PageDown) => vec![Operation::NavigatePageDown],
(_, Key::ArrowLeft) | (_, Key::ArrowRight) => {
vec![Operation::Focus(FocusOp::Next)]
}
(_, Key::Char(' ')) => vec![Operation::CommitLocal(CommitOp::ToggleFileStage)],
(_, Key::Char('n')) => {
vec![Operation::CommitLocal(CommitOp::BranchInputOpen)]
}
_ => vec![],
},
_ => vec![],
}
}
fn handle_mouse(&self, mouse: MouseEvent) -> Vec<Operation> {
let col = mouse.column;
let row = mouse.row;
match mouse.kind {
MouseEventKind::Down(MouseButton::Left) => {
if hit_test((col, row), self.rect_tab_bar.get()) {
return vec![Operation::CommitLocal(CommitOp::SwitchTab)];
}
let show_abort = self.amend_mode || self.git_mode != GitMode::Normal;
if show_abort && hit_test((col, row), self.rect_cancel_button.get()) {
return match self.git_mode {
GitMode::Rebase => vec![Operation::CommitLocal(CommitOp::AbortRebase)],
GitMode::Merge => vec![Operation::CommitLocal(CommitOp::AbortMerge)],
GitMode::Normal => vec![Operation::CommitLocal(CommitOp::CancelAmend)],
};
}
if hit_test((col, row), self.rect_commit_button.get())
&& !self.message_is_empty()
&& !self.has_conflicts
{
if self.amend_mode {
return vec![Operation::CommitLocal(CommitOp::ConfirmAmend)];
}
return vec![Operation::CommitLocal(CommitOp::Confirm)];
}
if self.tab == CommitTab::Commit {
if let Some(row_idx) = list_click_index(self.rect_staged_list.get(), row) {
let mapping = self.staged_items_map.borrow();
let target_idx_opt = mapping.get(row_idx).and_then(|&o| o).or_else(|| {
mapping.iter().skip(row_idx + 1).find_map(|&o| o)
}).or_else(|| {
mapping.iter().take(row_idx).rev().find_map(|&o| o)
});
if let Some(target_idx) = target_idx_opt {
return navigate_to(FOCUS_STAGED, self.active_pane(), target_idx, self.staged_cursor);
}
}
if let Some(row_idx) = list_click_index(self.rect_unstaged_list.get(), row) {
let mapping = self.unstaged_items_map.borrow();
let target_idx_opt = mapping.get(row_idx).and_then(|&o| o).or_else(|| {
mapping.iter().skip(row_idx + 1).find_map(|&o| o)
}).or_else(|| {
mapping.iter().take(row_idx).rev().find_map(|&o| o)
});
if let Some(target_idx) = target_idx_opt {
return navigate_to(FOCUS_UNSTAGED, self.active_pane(), target_idx, self.unstaged_cursor);
}
}
if self.rect_message.get().contains(Position::new(col, row))
&& self.active_pane() != FOCUS_MESSAGE
{
return vec![Operation::Focus(FocusOp::Next)];
}
}
if self.tab == CommitTab::Diff {
if let Some(row_idx) = list_click_index(self.rect_diff_file_list.get(), row) {
let mapping = self.diff_items_map.borrow();
let target_idx_opt = mapping.get(row_idx).and_then(|&o| o).or_else(|| {
mapping.iter().skip(row_idx + 1).find_map(|&o| o)
}).or_else(|| {
mapping.iter().take(row_idx).rev().find_map(|&o| o)
});
if let Some(target_idx) = target_idx_opt {
let mut ops = Vec::new();
let cur = self.diff_list_cursor;
if target_idx > cur {
for _ in 0..(target_idx - cur) {
ops.push(Operation::NavigateDown);
}
} else {
for _ in 0..(cur - target_idx) {
ops.push(Operation::NavigateUp);
}
}
if let Some(load) = self.load_diff_op_for_index(target_idx) {
ops.push(load);
}
return ops;
}
}
if let Some(click_pos) = list_click_index(self.rect_diff.get(), row)
&& let Some(widget) = &self.diff_widget
{
let inner_h = self.rect_diff.get().height.saturating_sub(2) as usize;
let cur = widget.cursor_flat_idx();
let scroll = if cur >= inner_h { cur + 1 - inner_h } else { 0 };
let target =
(scroll + click_pos).min(widget.visible_count().saturating_sub(1));
let mut ops = Vec::new();
if self.active_diff_focus() != FOCUS_DIFF_LINES {
ops.push(Operation::Focus(FocusOp::Right));
}
if target > cur {
for _ in 0..(target - cur) {
ops.push(Operation::NavigateDown);
}
} else {
for _ in 0..(cur - target) {
ops.push(Operation::NavigateUp);
}
}
if !ops.is_empty() {
return ops;
}
}
}
vec![]
}
MouseEventKind::ScrollUp => match self.tab {
CommitTab::Commit => vec![Operation::NavigateUp],
CommitTab::Diff => {
let target = self.diff_list_cursor.saturating_sub(1);
let mut ops = vec![Operation::NavigateUp];
if let Some(load) = self.load_diff_op_for_index(target) {
ops.push(load);
}
ops
},
},
MouseEventKind::ScrollDown => match self.tab {
CommitTab::Commit => vec![Operation::NavigateDown],
CommitTab::Diff => {
let total = self.staged.len() + self.unstaged.len();
if total == 0 {
vec![]
} else {
let target = (self.diff_list_cursor + 1).min(total - 1);
let mut ops = vec![Operation::NavigateDown];
if let Some(load) = self.load_diff_op_for_index(target) {
ops.push(load);
}
ops
}
},
},
_ => vec![],
}
}
fn handle_operation(&mut self, op: &Operation, _settings: &Settings) -> Option<Event> {
if let Operation::Focus(focus_op) = op {
match self.tab {
CommitTab::Commit => match focus_op {
FocusOp::Next => self.commit_focus.focus_next(),
FocusOp::Prev => self.commit_focus.focus_prev(),
_ => {}
},
CommitTab::Diff => match focus_op {
FocusOp::Next | FocusOp::Right => self.diff_focus.focus_next(),
FocusOp::Prev | FocusOp::Left => self.diff_focus.focus_prev(),
_ => {}
},
}
return None;
}
let Operation::CommitLocal(commit_op) = op else {
match op {
Operation::NavigateUp => {
if let Some(BranchPopup::Selector { cursor }) = &mut self.branch_popup {
if *cursor > 0 { *cursor -= 1; }
} else if self.tab == CommitTab::Commit {
let pane = self.active_pane();
if pane == FOCUS_STAGED {
self.staged_cursor = self.staged_cursor.saturating_sub(1);
} else if pane == FOCUS_UNSTAGED {
self.unstaged_cursor = self.unstaged_cursor.saturating_sub(1);
}
} else if self.tab == CommitTab::Diff {
if self.active_diff_focus() == FOCUS_FILE_LIST {
self.diff_list_cursor = self.diff_list_cursor.saturating_sub(1);
} else if let Some(w) = self.diff_widget.as_mut() {
w.move_up();
}
}
}
Operation::NavigateDown => {
if let Some(BranchPopup::Selector { cursor }) = &mut self.branch_popup {
*cursor = (*cursor + 1).min(self.branches.len().saturating_sub(1));
} else if self.tab == CommitTab::Commit {
let pane = self.active_pane();
if pane == FOCUS_STAGED && !self.staged.is_empty() {
self.staged_cursor = (self.staged_cursor + 1).min(self.staged.len() - 1);
} else if pane == FOCUS_UNSTAGED && !self.unstaged.is_empty() {
self.unstaged_cursor = (self.unstaged_cursor + 1).min(self.unstaged.len() - 1);
}
} else if self.tab == CommitTab::Diff {
if self.active_diff_focus() == FOCUS_FILE_LIST {
let total = self.staged.len() + self.unstaged.len();
if total > 0 {
self.diff_list_cursor = (self.diff_list_cursor + 1).min(total - 1);
}
} else if let Some(w) = self.diff_widget.as_mut() {
w.move_down();
}
}
}
Operation::NavigatePageUp => {
if self.tab == CommitTab::Diff
&& let Some(w) = self.diff_widget.as_mut()
{
w.page_up(20);
}
}
Operation::NavigatePageDown => {
if self.tab == CommitTab::Diff
&& let Some(w) = self.diff_widget.as_mut()
{
w.page_down(20);
}
}
_ => {}
}
return None;
};
match commit_op {
CommitOp::SwitchTab => {
self.tab = match self.tab {
CommitTab::Commit => CommitTab::Diff,
CommitTab::Diff => CommitTab::Commit,
};
if self.tab == CommitTab::Diff {
let (path, is_staged) = if self.active_pane() == FOCUS_STAGED {
(
self.staged.get(self.staged_cursor).map(|e| e.path.clone()),
true,
)
} else {
(
self.unstaged
.get(self.unstaged_cursor)
.map(|e| e.path.clone()),
false,
)
};
if let Some(p) = path {
let changed_file = self.diff_file.as_ref() != Some(&p);
let changed_staged = self.diff_staged != is_staged;
if changed_file || changed_staged {
self.diff_file = Some(p);
self.diff_staged = is_staged;
self.diff_lines_raw.clear();
self.diff_widget = None;
}
}
}
}
CommitOp::SwitchPane => {
self.commit_focus.focus_next();
}
CommitOp::DiffFocusToggle => {
self.diff_focus.focus_next();
}
CommitOp::MessageChanged(s) => {
self.message_buf = Buffer::from_lines(
if s.is_empty() {
vec![String::new()]
} else {
s.lines().map(|l| l.to_string()).collect()
},
None,
);
}
CommitOp::MessageInsertChar(c) => {
self.message_buf.begin_transaction(crate::editor::history::ChangeKind::InsertText);
self.message_buf.insert(&c.to_string());
self.message_buf.end_transaction();
}
CommitOp::MessageInsertNewline => {
self.message_buf.begin_transaction(crate::editor::history::ChangeKind::InsertText);
self.message_buf.insert_newline();
self.message_buf.end_transaction();
}
CommitOp::MessageBackspace => {
self.message_buf.begin_transaction(crate::editor::history::ChangeKind::DeleteText);
self.message_buf.delete_backward();
self.message_buf.end_transaction();
}
CommitOp::MessageCursorLeft => {
let pos = self.message_buf.offset_left(self.message_buf.cursor());
self.message_buf.set_cursor(pos);
}
CommitOp::MessageCursorRight => {
let pos = self.message_buf.offset_right(self.message_buf.cursor());
self.message_buf.set_cursor(pos);
}
CommitOp::MessageCursorUp => {
let pos = self.message_buf.offset_up(self.message_buf.cursor());
self.message_buf.set_cursor(pos);
}
CommitOp::MessageCursorDown => {
let pos = self.message_buf.offset_down(self.message_buf.cursor());
self.message_buf.set_cursor(pos);
}
CommitOp::BranchInputOpen => {
self.branch_input = Some(InputField::new("Branch"));
}
CommitOp::BranchInputCancel => {
self.branch_input = None;
}
CommitOp::BranchChanged(s) => {
if let Some(ref mut field) = self.branch_input {
field.set_text(s.clone());
}
}
CommitOp::BranchSelectorOpen => {
let cursor = self
.branches
.iter()
.position(|b| b == &self.current_branch)
.unwrap_or(0);
self.branch_popup = Some(BranchPopup::Selector { cursor });
}
CommitOp::BranchSelectorCancel => {
self.branch_popup = None;
}
CommitOp::BranchCheckout(_name) => {
self.branch_popup = None;
}
CommitOp::ToggleLineStage => {
if let Some(w) = self.diff_widget.as_mut() {
w.toggle_stage_or_fold();
}
}
CommitOp::StatusLoaded {
staged,
unstaged,
branches,
current_branch,
} => {
self.staged = staged.clone();
self.unstaged = unstaged.clone();
self.branches = branches.clone();
self.current_branch = current_branch.clone();
self.loading = false;
self.clamp_all();
}
CommitOp::DiffLoaded {
path,
staged,
lines,
} => {
self.diff_file = Some(path.clone());
self.diff_staged = *staged;
self.diff_lines_raw = lines.clone();
self.diff_widget = Some(DiffWidget::new(lines));
let files = self.all_diff_files();
if let Some(pos) = files.iter().position(|(p, _)| p == path) {
self.diff_list_cursor = pos;
}
}
CommitOp::GitOpSuccess(msg) => {
self.status_msg = Some(msg.clone());
self.loading = false;
}
CommitOp::GitOpError(msg) => {
self.status_msg = Some(format!("Error: {}", msg));
self.loading = false;
}
CommitOp::AmendModeSet(flag, oid) => {
self.amend_mode = *flag;
self.base_commit_oid = oid.clone();
if *flag {
self.status_msg = Some("Amend mode".into());
}
}
CommitOp::CancelAmend => {
self.amend_mode = false;
self.base_commit_oid = None;
self.message_buf = Buffer::from_lines(vec![String::new()], None);
self.status_msg = Some("Amend cancelled".into());
}
CommitOp::GitModeDetected { mode, has_conflicts, message } => {
self.git_mode = *mode;
self.has_conflicts = *has_conflicts;
if *mode != GitMode::Normal && self.message_is_empty() && !message.is_empty() {
self.message_buf = Buffer::from_lines(
message.lines().map(|l| l.to_string()).collect(),
None,
);
}
if *has_conflicts {
self.status_msg = Some("Cannot commit: unresolved conflicts".into());
} else if *mode == GitMode::Normal {
if let Some(ref msg) = self.status_msg
&& msg.starts_with("Cannot commit") {
self.status_msg = None;
}
}
self.loading = false;
}
CommitOp::ToggleFileStage
| CommitOp::Confirm
| CommitOp::ConfirmAmend
| CommitOp::RevertLine
| CommitOp::BranchCreate
| CommitOp::LoadDiff { .. }
| CommitOp::EnterAmendMode
| CommitOp::AbortRebase
| CommitOp::AbortMerge => {}
}
Some(Event::applied("commit", op.clone()))
}
fn render(&self, frame: &mut Frame, area: Rect, theme: &crate::theme::Theme) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(0)])
.split(area);
self.rect_tab_bar.set(chunks[0]);
let tab_bg = theme.bg_active();
let active_tab_fg = theme.fg_active();
let inactive_tab_fg = theme.fg_dim();
let tab_style = |active: bool| {
if active {
Style::default()
.fg(active_tab_fg)
.bg(tab_bg)
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED)
} else {
Style::default().fg(inactive_tab_fg).bg(tab_bg)
}
};
let loading_span = if self.loading {
Span::styled(" Loading…", Style::default().fg(theme.level_warn()).bg(tab_bg))
} else {
Span::raw("")
};
let tab_line = Line::from(vec![
Span::styled(" ", Style::default().bg(tab_bg)),
Span::styled("Commit", tab_style(self.tab == CommitTab::Commit)),
Span::styled(" ", Style::default().bg(tab_bg)),
Span::styled("Diff & Lines", tab_style(self.tab == CommitTab::Diff)),
Span::styled(" ", Style::default().bg(tab_bg)),
loading_span,
]);
frame.render_widget(Block::default().style(Style::new().bg(tab_bg)), chunks[0]);
frame.render_widget(Paragraph::new(tab_line), chunks[0]);
if self.git_mode != GitMode::Normal {
let (badge_text, badge_bg) = match self.git_mode {
GitMode::Rebase => (" REBASE ", Color::Rgb(140, 80, 0)),
GitMode::Merge => (" MERGE ", Color::Rgb(60, 100, 0)),
GitMode::Normal => unreachable!(),
};
let badge_w = badge_text.len() as u16;
if badge_w < chunks[0].width {
let badge_x = chunks[0].x + chunks[0].width.saturating_sub(badge_w);
let badge_rect = Rect { x: badge_x, y: chunks[0].y, width: badge_w, height: 1 };
frame.render_widget(
Paragraph::new(Span::styled(
badge_text,
Style::new().fg(Color::White).bg(badge_bg).add_modifier(Modifier::BOLD),
)),
badge_rect,
);
}
}
match self.tab {
CommitTab::Commit => self.render_commit_tab(frame, chunks[1], theme),
CommitTab::Diff => self.render_diff_tab(frame, chunks[1], theme),
}
}
}
impl CommitWindow {
fn render_commit_tab(&self, frame: &mut Frame, area: Rect, theme: &crate::theme::Theme) {
self.rect_view_area.set(area);
let conflict_banner_h: u16 = if self.has_conflicts { 1 } else { 0 };
let inner_area = Rect {
y: area.y + conflict_banner_h,
height: area.height.saturating_sub(conflict_banner_h),
..area
};
if self.has_conflicts {
let warn_bg = Color::Rgb(100, 30, 30);
let banner_rect = Rect { height: 1, ..area };
frame.render_widget(Block::default().style(Style::new().bg(warn_bg)), banner_rect);
frame.render_widget(
Paragraph::new(Span::styled(
" \u{26a0} Unresolved conflicts — resolve conflicts and stage files before committing",
Style::new().fg(Color::White).bg(warn_bg).add_modifier(Modifier::BOLD),
)),
banner_rect,
);
}
let total_h = inner_area.height as usize;
let footer_h = 1 + 1; let file_lists_min = 4_usize;
let desired_msg_lines = self.message_buf.lines().len().max(1);
let max_msg_space = total_h.saturating_sub(file_lists_min + footer_h);
let msg_h = desired_msg_lines.min(max_msg_space.max(1));
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(file_lists_min as u16), Constraint::Length(msg_h as u16), Constraint::Length(1), Constraint::Length(1), ])
.split(inner_area);
let file_cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(rows[0]);
self.rect_staged_list.set(file_cols[0]);
self.rect_unstaged_list.set(file_cols[1]);
let unstaged_paths: std::collections::HashSet<&PathBuf> =
self.unstaged.iter().map(|e| &e.path).collect();
let partial_paths: std::collections::HashSet<&PathBuf> = self
.staged
.iter()
.map(|e| &e.path)
.filter(|p| unstaged_paths.contains(p))
.collect();
let cur_pane = self.active_pane();
let (staged_items, staged_map) = crate::widgets::file_tree_list::build_tree_list_items_with_map_from_status(
&self.staged,
self.staged_cursor,
cur_pane == FOCUS_STAGED,
&partial_paths,
);
*self.staged_items_map.borrow_mut() = staged_map;
let empty_partial = std::collections::HashSet::new();
let (unstaged_items, unstaged_map) = crate::widgets::file_tree_list::build_tree_list_items_with_map_from_status(
&self.unstaged,
self.unstaged_cursor,
cur_pane == FOCUS_UNSTAGED,
&empty_partial,
);
*self.unstaged_items_map.borrow_mut() = unstaged_map;
for (col_area, pane_id, title, items) in [
(file_cols[0], FOCUS_STAGED, " Staged", staged_items),
(file_cols[1], FOCUS_UNSTAGED, " Unstaged", unstaged_items),
] {
let is_active = cur_pane == pane_id;
let pane_bg = if is_active { theme.bg_active() } else { theme.bg_inactive() };
let title_fg = if is_active { theme.accent() } else { theme.fg_dim() };
frame.render_widget(Block::default().style(Style::new().bg(pane_bg)), col_area);
let title_area = Rect { height: 1, ..col_area };
frame.render_widget(
Paragraph::new(Span::styled(
title,
Style::new().fg(title_fg).bg(pane_bg).add_modifier(Modifier::BOLD),
)),
title_area,
);
let content_area = Rect {
y: col_area.y + 1,
height: col_area.height.saturating_sub(1),
..col_area
};
frame.render_widget(List::new(items), content_area);
}
let msg_cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(0), Constraint::Length(10), Constraint::Length(10)])
.split(rows[1]);
self.rect_message.set(msg_cols[0]);
self.rect_commit_button.set(msg_cols[1]);
self.rect_cancel_button.set(msg_cols[2]);
let msg_bg = if cur_pane == FOCUS_MESSAGE { theme.bg_active() } else { theme.bg_inactive() };
let msg_focused = cur_pane == FOCUS_MESSAGE;
frame.render_widget(Block::default().style(Style::new().bg(msg_bg)), msg_cols[0]);
if self.message_is_empty() && !msg_focused {
frame.render_widget(
Paragraph::new(Span::styled(
" (type a commit message)",
Style::new().fg(theme.fg_dim()).bg(msg_bg),
)),
msg_cols[0],
);
} else {
let lines = self.message_buf.lines();
let cursor = self.message_buf.cursor();
let fg = theme.fg_active();
let mut ratatui_lines: Vec<Line> = Vec::with_capacity(lines.len());
for (row_idx, line) in lines.iter().enumerate() {
if msg_focused && row_idx == cursor.line {
let col = cursor.column.min(line.len());
let before = format!(" {}", &line[..col]);
let cursor_char = line[col..].chars().next().unwrap_or(' ');
let after_start = col + cursor_char.len_utf8().min(line.len() - col);
let after = if after_start < line.len() { &line[after_start..] } else { "" };
ratatui_lines.push(Line::from(vec![
Span::styled(before, Style::new().fg(fg).bg(msg_bg)),
Span::styled(
cursor_char.to_string(),
Style::new().fg(Color::Black).bg(Color::White).add_modifier(Modifier::BOLD),
),
Span::styled(after.to_string(), Style::new().fg(fg).bg(msg_bg)),
]));
} else {
ratatui_lines.push(Line::from(Span::styled(
format!(" {}", line),
Style::new().fg(fg).bg(msg_bg),
)));
}
}
frame.render_widget(Paragraph::new(ratatui_lines), msg_cols[0]);
}
let can_commit = !self.message_is_empty() && !self.has_conflicts;
let btn_fg = if cur_pane == FOCUS_COMMIT_BTN {
theme.selection_bg()
} else if can_commit {
theme.accent()
} else {
theme.fg_dim()
};
let btn_bg = if cur_pane == FOCUS_COMMIT_BTN { theme.accent() } else { theme.bg_inactive() };
frame.render_widget(Block::default().style(Style::new().bg(btn_bg)), msg_cols[1]);
let commit_label = match self.git_mode {
GitMode::Rebase => " Continue",
GitMode::Merge => " Merge",
GitMode::Normal => if self.amend_mode { " Amend" } else { " Commit" },
};
frame.render_widget(
Paragraph::new(Span::styled(
commit_label,
Style::new().fg(btn_fg).bg(btn_bg).add_modifier(Modifier::BOLD),
)),
msg_cols[1],
);
frame.render_widget(Block::default().style(Style::new().bg(btn_bg)), msg_cols[2]);
let show_abort = self.amend_mode || self.git_mode != GitMode::Normal;
if show_abort {
let abort_label = match self.git_mode {
GitMode::Rebase => " Abort",
GitMode::Merge => " Abort",
GitMode::Normal => " Cancel",
};
let abort_fg = theme.fg_dim();
frame.render_widget(
Paragraph::new(Span::styled(
abort_label,
Style::new().fg(abort_fg).bg(btn_bg).add_modifier(Modifier::BOLD),
)),
msg_cols[2],
);
}
let branch_bg = theme.bg_inactive();
let branch_text = if let Some(ref input) = self.branch_input {
format!(" Branch: {} New: {}_", self.current_branch, input.text())
} else if self.amend_mode {
format!(" [AMEND] Branch: {}", self.current_branch)
} else {
format!(" Branch: {}", self.current_branch)
};
frame.render_widget(Block::default().style(Style::new().bg(branch_bg)), rows[2]);
frame.render_widget(
Paragraph::new(Span::styled(branch_text, Style::new().fg(theme.fg_dim()).bg(branch_bg))),
rows[2],
);
if let Some(ref msg) = self.status_msg {
frame.render_widget(
Paragraph::new(msg.as_str()).style(Style::default().fg(theme.level_warn())),
rows[3],
);
}
if let Some(BranchPopup::Selector { cursor }) = &self.branch_popup {
self.render_branch_selector_popup(frame, area, *cursor);
}
}
fn render_branch_selector_popup(&self, frame: &mut Frame, area: Rect, cursor: usize) {
let n = self.branches.len();
if n == 0 {
return;
}
let height = (n as u16 + 2).min(area.height);
let width = self
.branches
.iter()
.map(|b| b.chars().count())
.max()
.unwrap_or(10) as u16
+ 4;
let width = width.min(area.width).max(14);
let y = area.bottom().saturating_sub(height);
let popup_rect = Rect { x: area.x + 1, y, width, height };
let branch_names: Vec<&str> = self.branches.iter().map(|s| s.as_str()).collect();
let menu = crate::widgets::button::Menu::new(&branch_names).cursor(cursor);
frame.render_widget(menu, popup_rect);
}
fn render_diff_tab(&self, frame: &mut Frame, area: Rect, theme: &crate::theme::Theme) {
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(25), Constraint::Percentage(75)])
.split(area);
self.rect_diff_file_list.set(cols[0]);
let file_list_active = self.active_diff_focus() == FOCUS_FILE_LIST;
let left_bg = if file_list_active { theme.bg_active() } else { theme.bg_inactive() };
let left_title_fg = if file_list_active { theme.accent() } else { theme.fg_dim() };
frame.render_widget(Block::default().style(Style::new().bg(left_bg)), cols[0]);
let left_title_area = Rect { height: 1, ..cols[0] };
frame.render_widget(
Paragraph::new(Span::styled(
" Files",
Style::new().fg(left_title_fg).bg(left_bg).add_modifier(Modifier::BOLD),
)),
left_title_area,
);
let left_content_area = Rect {
y: cols[0].y + 1,
height: cols[0].height.saturating_sub(1),
..cols[0]
};
let files = self.all_diff_files();
let (file_items, file_map) = crate::widgets::file_tree_list::build_tree_list_items_with_map_from_paths_staged(
&files,
self.diff_list_cursor,
file_list_active,
theme.selection_bg(),
theme.selection_fg(),
theme.accent(),
theme.fg_dim(),
);
*self.diff_items_map.borrow_mut() = file_map;
frame.render_widget(List::new(file_items), left_content_area);
let diff_active = self.active_diff_focus() == FOCUS_DIFF_LINES;
let right_bg = if diff_active { theme.bg_active() } else { theme.bg_inactive() };
frame.render_widget(Block::default().style(Style::new().bg(right_bg)), cols[1]);
let right_rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Min(1), Constraint::Length(1), ])
.split(cols[1]);
let header_line = match &self.diff_file {
Some(p) => {
let label = if self.diff_staged { "(staged)" } else { "(unstaged)" };
let base_bg = Style::default().bg(right_bg);
let filename_style = base_bg.fg(theme.accent()).add_modifier(Modifier::BOLD);
let path_style = base_bg.fg(theme.fg_dim());
let dim_style = base_bg.fg(theme.fg_dim()).add_modifier(Modifier::DIM);
let sep_style = base_bg.fg(theme.fg_dim()).add_modifier(Modifier::DIM);
let cfg = crate::path_format::StyleConfig {
filename: filename_style,
path: path_style,
dim: dim_style,
separator: sep_style,
};
let header_w = right_rows[0].width as usize;
let leading_w = width(" ");
let label_w = width(label);
let reserved = leading_w + label_w;
let avail = header_w.saturating_sub(reserved);
let mut spans = Vec::new();
spans.push(Span::raw(" "));
spans.extend(crate::path_format::format_path_spans(p, avail, cfg));
spans.push(Span::styled(format!(" {}", label), base_bg.fg(theme.fg_dim())));
Line::from(spans)
}
None => Line::from(Span::styled(
" (select a file from the list)",
Style::default().fg(theme.fg_dim()).bg(right_bg),
)),
};
frame.render_widget(Paragraph::new(header_line), right_rows[0]);
self.rect_diff.set(right_rows[1]);
let inner_area = right_rows[1];
let active = diff_active;
match &self.diff_widget {
None => {
let msg = if self.diff_file.is_none() {
"(no file selected)"
} else {
"(no diff — file may be binary or has no changes)"
};
frame.render_widget(
Paragraph::new(Span::styled(msg, Style::default().fg(theme.fg_dim()))),
inner_area,
);
}
Some(w) => {
w.render_into(inner_area, frame.buffer_mut(), active);
}
}
if let Some(ref msg) = self.status_msg {
frame.render_widget(
Paragraph::new(msg.as_str()).style(Style::default().fg(theme.level_warn())),
right_rows[2],
);
}
}
}
#[allow(dead_code)]
fn build_tree_list_items(
entries: &[StatusEntry],
cursor: usize,
active: bool,
partial_paths: &std::collections::HashSet<&PathBuf>,
) -> Vec<ListItem<'static>> {
crate::widgets::file_tree_list::build_tree_list_items_from_status(entries, cursor, active, partial_paths)
}
fn list_click_index(rect: Rect, row: u16) -> Option<usize> {
let inner_top = rect.y + 1;
let inner_bottom = rect.y + rect.height.saturating_sub(1);
if row >= inner_top && row < inner_bottom {
Some((row - inner_top) as usize)
} else {
None
}
}
fn navigate_to(
target_pane: &str,
current_pane: &str,
target_idx: usize,
current_idx: usize,
) -> Vec<Operation> {
let mut ops = Vec::new();
if current_pane != target_pane {
ops.push(Operation::Focus(FocusOp::Next));
}
if target_idx > current_idx {
for _ in 0..(target_idx - current_idx) {
ops.push(Operation::NavigateDown);
}
} else {
for _ in 0..(current_idx - target_idx) {
ops.push(Operation::NavigateUp);
}
}
ops
}
#[cfg(test)]
mod tests {
use super::*;
use crate::vcs::GitMode;
fn make_cw() -> CommitWindow {
CommitWindow::new(std::path::PathBuf::from("/tmp/test-repo"))
}
#[test]
fn new_window_starts_in_normal_mode() {
let cw = make_cw();
assert_eq!(cw.git_mode, GitMode::Normal);
assert!(!cw.has_conflicts);
}
#[test]
fn git_mode_detected_sets_rebase_mode() {
let mut cw = make_cw();
let op = Operation::CommitLocal(CommitOp::GitModeDetected {
mode: GitMode::Rebase,
has_conflicts: false,
message: "fix: something".into(),
});
cw.handle_operation(&op, &crate::settings::Settings::new(std::path::Path::new("/nonexistent/config.yaml")).unwrap_or_else(|_| crate::settings::Settings::new(std::path::Path::new(".")).unwrap()));
assert_eq!(cw.git_mode, GitMode::Rebase);
assert!(!cw.has_conflicts);
assert_eq!(cw.message_text(), "fix: something");
}
#[test]
fn git_mode_detected_sets_merge_mode() {
let mut cw = make_cw();
let op = Operation::CommitLocal(CommitOp::GitModeDetected {
mode: GitMode::Merge,
has_conflicts: false,
message: "Merge branch 'feature'".into(),
});
cw.handle_operation(&op, &crate::settings::Settings::new(std::path::Path::new("/nonexistent/config.yaml")).unwrap_or_else(|_| crate::settings::Settings::new(std::path::Path::new(".")).unwrap()));
assert_eq!(cw.git_mode, GitMode::Merge);
assert_eq!(cw.message_text(), "Merge branch 'feature'");
}
#[test]
fn git_mode_detected_with_conflicts_sets_status_msg() {
let mut cw = make_cw();
let op = Operation::CommitLocal(CommitOp::GitModeDetected {
mode: GitMode::Merge,
has_conflicts: true,
message: String::new(),
});
cw.handle_operation(&op, &crate::settings::Settings::new(std::path::Path::new("/nonexistent/config.yaml")).unwrap_or_else(|_| crate::settings::Settings::new(std::path::Path::new(".")).unwrap()));
assert!(cw.has_conflicts);
assert!(
cw.status_msg.as_deref().unwrap_or("").contains("conflict"),
"status_msg should mention conflicts"
);
}
#[test]
fn git_mode_detected_does_not_overwrite_existing_message() {
let mut cw = make_cw();
cw.message_buf = crate::editor::buffer::Buffer::from_lines(
vec!["already typed".to_string()],
None,
);
let op = Operation::CommitLocal(CommitOp::GitModeDetected {
mode: GitMode::Rebase,
has_conflicts: false,
message: "pre-filled from rebase".into(),
});
cw.handle_operation(&op, &crate::settings::Settings::new(std::path::Path::new("/nonexistent/config.yaml")).unwrap_or_else(|_| crate::settings::Settings::new(std::path::Path::new(".")).unwrap()));
assert_eq!(cw.message_text(), "already typed");
}
#[test]
fn git_mode_detected_normal_clears_conflict_status() {
let mut cw = make_cw();
cw.has_conflicts = true;
cw.status_msg = Some("Cannot commit: unresolved conflicts".into());
let op = Operation::CommitLocal(CommitOp::GitModeDetected {
mode: GitMode::Normal,
has_conflicts: false,
message: String::new(),
});
cw.handle_operation(&op, &crate::settings::Settings::new(std::path::Path::new("/nonexistent/config.yaml")).unwrap_or_else(|_| crate::settings::Settings::new(std::path::Path::new(".")).unwrap()));
assert!(!cw.has_conflicts);
assert!(cw.status_msg.is_none(), "conflict status should be cleared");
}
#[test]
fn escape_key_in_rebase_mode_emits_abort_rebase() {
let mut cw = make_cw();
cw.git_mode = GitMode::Rebase;
let key = KeyEvent { key: Key::Escape, modifiers: crate::input::Modifiers::empty() };
let ops = cw.handle_key(key);
assert!(
ops.iter().any(|o| matches!(o, Operation::CommitLocal(CommitOp::AbortRebase))),
"Escape in rebase mode should emit AbortRebase"
);
}
#[test]
fn escape_key_in_merge_mode_emits_abort_merge() {
let mut cw = make_cw();
cw.git_mode = GitMode::Merge;
let key = KeyEvent { key: Key::Escape, modifiers: crate::input::Modifiers::empty() };
let ops = cw.handle_key(key);
assert!(
ops.iter().any(|o| matches!(o, Operation::CommitLocal(CommitOp::AbortMerge))),
"Escape in merge mode should emit AbortMerge"
);
}
#[test]
fn escape_key_in_normal_mode_does_not_abort() {
let cw = make_cw();
let key = KeyEvent { key: Key::Escape, modifiers: crate::input::Modifiers::empty() };
let ops = cw.handle_key(key);
assert!(
!ops.iter().any(|o| matches!(
o,
Operation::CommitLocal(CommitOp::AbortRebase)
| Operation::CommitLocal(CommitOp::AbortMerge)
)),
"Escape in normal mode should not emit abort ops"
);
}
}