use std::cell::Cell;
use std::path::PathBuf;
use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Paragraph},
};
use crate::editor::buffer::Buffer;
use crate::input::{Key, KeyEvent, Modifiers};
use crate::operation::{Event, HistoryEditorOp, Operation};
use crate::settings::Settings;
use crate::vcs::CommitInfo;
use crate::views::View;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EditorMode {
List,
Reword,
Squash { target_idx: usize },
}
#[derive(Debug, Clone)]
pub struct UndoEntry {
pub pre_op_sha: String,
pub description: String,
}
#[derive(Debug)]
pub struct GitHistoryEditorView {
pub repo_path: PathBuf,
pub commits: Vec<CommitInfo>,
pub cursor: usize,
scroll: Cell<usize>,
last_height: Cell<usize>,
pub mode: EditorMode,
pub message_buf: Buffer,
pub undo_stack: Vec<UndoEntry>,
pub pending_move_origin: Option<usize>,
pub pending_delete_sha: Option<String>,
pub status_msg: Option<String>,
pub loading: bool,
}
impl GitHistoryEditorView {
pub fn new(repo_path: PathBuf) -> Self {
Self {
repo_path,
commits: Vec::new(),
cursor: 0,
scroll: Cell::new(0),
last_height: Cell::new(0),
mode: EditorMode::List,
message_buf: Buffer::from_lines(vec![String::new()], None),
undo_stack: Vec::new(),
pending_move_origin: None,
pending_delete_sha: None,
status_msg: None,
loading: true,
}
}
fn move_up(&mut self) {
if self.cursor > 0 {
self.cursor -= 1;
if self.cursor < self.scroll.get() {
self.scroll.set(self.cursor);
}
}
}
fn move_down(&mut self) {
if !self.commits.is_empty() && self.cursor + 1 < self.commits.len() {
self.cursor += 1;
let vh = self.last_height.get();
if vh > 0 && self.cursor >= self.scroll.get() + vh {
self.scroll.set(self.cursor + 1 - vh);
}
}
}
pub fn swap_toward_older(&mut self) {
let i = self.cursor;
if i + 1 < self.commits.len() {
self.commits.swap(i, i + 1);
self.cursor = i + 1;
let vh = self.last_height.get();
if vh > 0 && self.cursor >= self.scroll.get() + vh {
self.scroll.set(self.cursor + 1 - vh);
}
}
}
pub fn swap_toward_newer(&mut self) {
let i = self.cursor;
if i > 0 {
self.commits.swap(i, i - 1);
self.cursor = i - 1;
if self.cursor < self.scroll.get() {
self.scroll.set(self.cursor);
}
}
}
pub fn enter_reword_mode(&mut self) {
let msg = self
.commits
.get(self.cursor)
.map(|c| {
if c.message.is_empty() { c.summary.clone() } else { c.message.clone() }
})
.unwrap_or_default();
let mut lines: Vec<String> = msg.lines().map(|l| l.to_owned()).collect();
while lines.last().map(|l: &String| l.trim().is_empty()).unwrap_or(false) {
lines.pop();
}
if lines.is_empty() {
lines.push(String::new());
}
self.message_buf = Buffer::from_lines(lines, None);
if let Some((last_row, last_len)) = self.message_buf.lines().len().checked_sub(1).map(|r| {
let len = self.message_buf.lines()[r].len();
(r, len)
}) {
self.message_buf.set_cursor(crate::editor::position::Position::new(last_row, last_len));
}
self.mode = EditorMode::Reword;
self.status_msg = None;
}
pub fn enter_squash_mode(&mut self, target_idx: usize) {
let full_msg = |c: &CommitInfo| {
if c.message.is_empty() { c.summary.clone() } else { c.message.clone() }
};
let newer_msg = self.commits.get(self.cursor).map(full_msg).unwrap_or_default();
let older_msg = self.commits.get(target_idx).map(full_msg).unwrap_or_default();
let combined = format!("{}\n\n{}", newer_msg.trim_end(), older_msg.trim_end());
self.message_buf = Buffer::from_lines(
combined.lines().map(|l| l.to_owned()).collect(),
None,
);
self.mode = EditorMode::Squash { target_idx };
self.status_msg = None;
}
pub fn current_message(&self) -> String {
self.message_buf.lines().join("\n")
}
pub fn message_is_empty(&self) -> bool {
self.message_buf.lines().iter().all(|l| l.is_empty())
}
}
impl View for GitHistoryEditorView {
const KIND: crate::views::ViewKind = crate::views::ViewKind::Primary;
fn save_state(&mut self, _app: &mut crate::app_state::AppState) {}
fn handle_key(&self, key: KeyEvent) -> Vec<Operation> {
match &self.mode {
EditorMode::List => handle_key_list(self, key),
EditorMode::Reword | EditorMode::Squash { .. } => handle_key_editor(self, key),
}
}
fn handle_operation(&mut self, op: &Operation, _settings: &Settings) -> Option<Event> {
match op {
Operation::NavigateUp => {
if self.mode == EditorMode::List {
self.move_up();
return Some(Event::applied("history_editor", op.clone()));
}
let pos = self.message_buf.offset_up(self.message_buf.cursor());
self.message_buf.set_cursor(pos);
Some(Event::applied("history_editor", op.clone()))
}
Operation::NavigateDown => {
if self.mode == EditorMode::List {
self.move_down();
return Some(Event::applied("history_editor", op.clone()));
}
let pos = self.message_buf.offset_down(self.message_buf.cursor());
self.message_buf.set_cursor(pos);
Some(Event::applied("history_editor", op.clone()))
}
Operation::NavigatePageUp => {
if self.mode == EditorMode::List {
for _ in 0..10 {
self.move_up();
}
}
Some(Event::applied("history_editor", op.clone()))
}
Operation::NavigatePageDown => {
if self.mode == EditorMode::List {
for _ in 0..10 {
self.move_down();
}
}
Some(Event::applied("history_editor", op.clone()))
}
Operation::Close if self.mode != EditorMode::List => {
self.mode = EditorMode::List;
self.message_buf = Buffer::from_lines(vec![String::new()], None);
self.status_msg = None;
Some(Event::applied("history_editor", op.clone()))
}
Operation::HistoryEditorLocal(he_op) => {
match he_op {
HistoryEditorOp::CommitsLoaded(commits) => {
self.commits = commits.clone();
if !self.commits.is_empty() {
self.cursor = self.cursor.min(self.commits.len() - 1);
} else {
self.cursor = 0;
}
self.loading = false;
}
HistoryEditorOp::OperationCompleted { commits, pre_sha, description } => {
self.commits = commits.clone();
self.loading = false;
self.status_msg = None;
if let Some(sha) = pre_sha
&& !sha.is_empty() {
self.undo_stack.push(UndoEntry {
pre_op_sha: sha.clone(),
description: description.clone(),
});
}
if !self.commits.is_empty() {
self.cursor = self.cursor.min(self.commits.len() - 1);
}
}
HistoryEditorOp::OperationFailed(msg) => {
self.loading = false;
self.status_msg = Some(format!("Error: {msg}"));
if let Some(origin) = self.pending_move_origin.take() {
self.cursor = origin;
}
}
HistoryEditorOp::MoveUp
| HistoryEditorOp::MoveDown
| HistoryEditorOp::ConfirmMove
| HistoryEditorOp::StartReword
| HistoryEditorOp::StartSquash { .. }
| HistoryEditorOp::ConfirmEdit
| HistoryEditorOp::DeleteCommit
| HistoryEditorOp::UndoLast => {}
HistoryEditorOp::EditorInsertChar(c) => {
self.message_buf.begin_transaction(
crate::editor::history::ChangeKind::InsertText,
);
self.message_buf.insert(&c.to_string());
self.message_buf.end_transaction();
}
HistoryEditorOp::EditorInsertNewline => {
self.message_buf.begin_transaction(
crate::editor::history::ChangeKind::InsertText,
);
self.message_buf.insert_newline();
self.message_buf.end_transaction();
}
HistoryEditorOp::EditorBackspace => {
self.message_buf.begin_transaction(
crate::editor::history::ChangeKind::DeleteText,
);
self.message_buf.delete_backward();
self.message_buf.end_transaction();
}
HistoryEditorOp::EditorCursorLeft => {
let pos = self.message_buf.offset_left(self.message_buf.cursor());
self.message_buf.set_cursor(pos);
}
HistoryEditorOp::EditorCursorRight => {
let pos = self.message_buf.offset_right(self.message_buf.cursor());
self.message_buf.set_cursor(pos);
}
}
Some(Event::applied("history_editor", op.clone()))
}
_ => None,
}
}
fn render(&self, frame: &mut Frame, area: Rect, theme: &crate::theme::Theme) {
match &self.mode {
EditorMode::List => render_list_mode(self, frame, area, theme),
EditorMode::Reword | EditorMode::Squash { .. } => {
render_editor_mode(self, frame, area, theme)
}
}
}
}
fn handle_key_list(view: &GitHistoryEditorView, key: KeyEvent) -> Vec<Operation> {
if key.key == Key::Char('z') && key.modifiers.contains(Modifiers::CTRL) {
return vec![Operation::HistoryEditorLocal(HistoryEditorOp::UndoLast)];
}
match key.key {
Key::ArrowUp | Key::Char('k') => vec![Operation::NavigateUp],
Key::ArrowDown | Key::Char('j') => vec![Operation::NavigateDown],
Key::PageUp => vec![Operation::NavigatePageUp],
Key::PageDown => vec![Operation::NavigatePageDown],
Key::Enter => {
if view.pending_move_origin.is_some() {
vec![Operation::HistoryEditorLocal(HistoryEditorOp::ConfirmMove)]
} else {
vec![]
}
}
Key::Escape => {
if view.pending_move_origin.is_some() {
vec![Operation::HistoryEditorLocal(HistoryEditorOp::ConfirmMove)]
} else {
vec![Operation::Close]
}
}
Key::Char('r') if !view.loading && !view.commits.is_empty() => {
vec![Operation::HistoryEditorLocal(HistoryEditorOp::StartReword)]
}
Key::Char('u') if !view.loading && !view.commits.is_empty() => {
vec![Operation::HistoryEditorLocal(HistoryEditorOp::MoveUp)]
}
Key::Char('d') if !view.loading && !view.commits.is_empty() => {
vec![Operation::HistoryEditorLocal(HistoryEditorOp::MoveDown)]
}
Key::Char('s') if !view.loading && view.cursor + 1 < view.commits.len() => {
let target_idx = view.cursor + 1;
vec![Operation::HistoryEditorLocal(HistoryEditorOp::StartSquash {
target_idx,
})]
}
Key::Delete | Key::Backspace if !view.loading && !view.commits.is_empty() => {
vec![Operation::HistoryEditorLocal(HistoryEditorOp::DeleteCommit)]
}
_ => vec![],
}
}
fn handle_key_editor(_view: &GitHistoryEditorView, key: KeyEvent) -> Vec<Operation> {
if key.key == Key::Char('s') && key.modifiers.contains(Modifiers::CTRL) {
return vec![Operation::HistoryEditorLocal(HistoryEditorOp::ConfirmEdit)];
}
match (key.modifiers, key.key) {
(_, Key::Escape) => vec![Operation::Close],
(_, Key::ArrowUp) => vec![Operation::NavigateUp],
(_, Key::ArrowDown) => vec![Operation::NavigateDown],
(_, Key::ArrowLeft) => {
vec![Operation::HistoryEditorLocal(HistoryEditorOp::EditorCursorLeft)]
}
(_, Key::ArrowRight) => {
vec![Operation::HistoryEditorLocal(HistoryEditorOp::EditorCursorRight)]
}
(_, Key::Enter) => {
vec![Operation::HistoryEditorLocal(HistoryEditorOp::EditorInsertNewline)]
}
(_, Key::Backspace) => {
vec![Operation::HistoryEditorLocal(HistoryEditorOp::EditorBackspace)]
}
(mods, Key::Char(c))
if !mods.contains(Modifiers::CTRL) && !mods.contains(Modifiers::ALT) =>
{
vec![Operation::HistoryEditorLocal(HistoryEditorOp::EditorInsertChar(c))]
}
_ => vec![],
}
}
fn render_list_mode(
view: &GitHistoryEditorView,
frame: &mut Frame,
area: Rect,
theme: &crate::theme::Theme,
) {
let bg = theme.bg();
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Min(1), Constraint::Length(1), ])
.split(area);
render_header(view, frame, rows[0], theme);
render_commit_list(view, frame, rows[1], theme, false, None);
render_hint(view, frame, rows[2], theme);
frame.render_widget(Block::default().style(Style::default().bg(bg)), area);
render_header(view, frame, rows[0], theme);
render_commit_list(view, frame, rows[1], theme, false, None);
render_hint(view, frame, rows[2], theme);
}
fn render_header(
view: &GitHistoryEditorView,
frame: &mut Frame,
area: Rect,
theme: &crate::theme::Theme,
) {
let bg = theme.bg();
let accent = theme.accent();
let fg_dim = theme.fg_dim();
let undo_count = view.undo_stack.len();
let undo_str = if undo_count > 0 {
format!(" [ undo: {undo_count} ] ")
} else {
String::new()
};
let title = " Git History Editor ";
let title_len = title.chars().count();
let undo_len = undo_str.chars().count();
let spacer_len = (area.width as usize)
.saturating_sub(title_len)
.saturating_sub(undo_len);
let spacer = " ".repeat(spacer_len);
let header_line = Line::from(vec![
Span::styled(title, Style::default().fg(accent).add_modifier(Modifier::BOLD).bg(bg)),
Span::styled(spacer, Style::default().bg(bg)),
Span::styled(undo_str, Style::default().fg(fg_dim).bg(bg)),
]);
frame.render_widget(
Paragraph::new(header_line).style(Style::default().bg(bg)),
area,
);
}
fn render_commit_list(
view: &GitHistoryEditorView,
frame: &mut Frame,
area: Rect,
theme: &crate::theme::Theme,
dimmed: bool,
editor_cursor_row: Option<usize>,
) {
let bg = if dimmed { theme.bg_inactive() } else { theme.bg() };
let fg = if dimmed { theme.fg_dim() } else { theme.fg() };
let fg_dim = theme.fg_dim();
let sel_bg = theme.selection_bg();
let sel_fg = theme.selection_fg();
let accent = theme.accent();
if view.loading {
frame.render_widget(
Paragraph::new(Span::styled(" Loading…", Style::default().fg(fg_dim).bg(bg))),
area,
);
return;
}
if view.commits.is_empty() {
frame.render_widget(
Paragraph::new(Span::styled(" (no commits)", Style::default().fg(fg_dim).bg(bg))),
area,
);
return;
}
let visible_h = area.height as usize;
view.last_height.set(visible_h);
let scroll = view.scroll.get();
let visible = &view.commits[scroll..(scroll + visible_h).min(view.commits.len())];
let mut ratatui_lines: Vec<Line> = Vec::with_capacity(visible_h);
for (i, commit) in visible.iter().enumerate() {
let abs_idx = scroll + i;
let is_cursor = abs_idx == view.cursor && editor_cursor_row.is_none();
let is_move_origin = view
.pending_move_origin
.map(|o| o == abs_idx)
.unwrap_or(false);
let is_active_in_editor = editor_cursor_row == Some(abs_idx);
let item_bg = if is_cursor || is_active_in_editor {
sel_bg
} else {
bg
};
let item_fg = if is_cursor || is_active_in_editor {
sel_fg
} else {
fg
};
let indicator = if is_cursor || is_active_in_editor {
"▶ "
} else {
" "
};
let short_oid: String = commit.oid.chars().take(8).collect();
let msg = if is_cursor && view.pending_move_origin.is_some() {
format!("[MOVING] {}", truncate(&commit.summary, 28))
} else if is_active_in_editor {
match &view.mode {
EditorMode::Reword => format!("[REWORDING] {}", truncate(&commit.summary, 24)),
EditorMode::Squash { target_idx } if *target_idx == abs_idx => {
format!("[SQUASH TARGET] {}", truncate(&commit.summary, 20))
}
EditorMode::Squash { .. } => {
format!("[SQUASHING] {}", truncate(&commit.summary, 22))
}
_ => truncate(&commit.summary, 36),
}
} else {
truncate(&commit.summary, 36)
};
let move_suffix = if is_move_origin && !is_cursor {
" ↕"
} else {
""
};
let oid_style = Style::default()
.fg(if is_cursor { sel_fg } else { fg_dim })
.bg(item_bg);
let msg_style = Style::default()
.fg(if is_cursor { accent } else { item_fg })
.bg(item_bg)
.add_modifier(if is_cursor {
Modifier::BOLD
} else {
Modifier::empty()
});
let meta_style = Style::default().fg(fg_dim).bg(item_bg);
let line = Line::from(vec![
Span::styled(indicator, Style::default().fg(item_fg).bg(item_bg)),
Span::styled(format!("{short_oid} "), oid_style),
Span::styled(format!("{msg}{move_suffix}"), msg_style),
Span::styled(
format!(" {} {}", commit.author_name, commit.date_relative),
meta_style,
),
]);
ratatui_lines.push(line);
}
while ratatui_lines.len() < visible_h {
ratatui_lines.push(Line::from(Span::styled("", Style::default().bg(bg))));
}
frame.render_widget(
Paragraph::new(ratatui_lines).style(Style::default().bg(bg)),
area,
);
}
fn render_hint(
view: &GitHistoryEditorView,
frame: &mut Frame,
area: Rect,
theme: &crate::theme::Theme,
) {
let bg = theme.bg();
let fg_dim = theme.fg_dim();
let text = if let Some(ref msg) = view.status_msg {
Span::styled(format!(" {msg}"), Style::default().fg(theme.level_warn()).bg(bg))
} else if view.pending_move_origin.is_some() {
Span::styled(
" u/d: move Enter: confirm Esc: cancel",
Style::default().fg(fg_dim).bg(bg),
)
} else {
Span::styled(
" r:reword u/d:move s:squash del:drop ctrl+z:undo",
Style::default().fg(fg_dim).bg(bg),
)
};
frame.render_widget(Paragraph::new(Line::from(text)).style(Style::default().bg(bg)), area);
}
fn render_editor_mode(
view: &GitHistoryEditorView,
frame: &mut Frame,
area: Rect,
theme: &crate::theme::Theme,
) {
let bg = theme.bg();
let msg_bg = theme.bg_inactive();
let msg_line_count = view.message_buf.lines().len() as u16;
let desired_editor = (msg_line_count + 2).max(8).min(area.height / 2);
let editor_height = desired_editor.max(8u16.min(area.height));
let list_height = area.height.saturating_sub(editor_height);
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(list_height), Constraint::Length(1), Constraint::Min(1), Constraint::Length(1), ])
.split(area);
let editor_cursor_row = Some(view.cursor);
frame.render_widget(Block::default().style(Style::default().bg(msg_bg)), rows[0]);
render_commit_list(view, frame, rows[0], theme, true, editor_cursor_row);
let label = match &view.mode {
EditorMode::Squash { .. } => " Squash Message ",
_ => " Commit Message ",
};
frame.render_widget(
Paragraph::new(Span::styled(
label,
Style::default()
.fg(theme.accent())
.add_modifier(Modifier::BOLD)
.bg(bg),
))
.block(
Block::default()
.borders(Borders::TOP)
.border_type(BorderType::Plain)
.style(Style::default().bg(bg)),
),
rows[1],
);
render_message_buffer(view, frame, rows[2], theme);
frame.render_widget(
Paragraph::new(Span::styled(
" ctrl+s: apply Esc: cancel",
Style::default().fg(theme.fg_dim()).bg(bg),
))
.style(Style::default().bg(bg)),
rows[3],
);
}
fn render_message_buffer(
view: &GitHistoryEditorView,
frame: &mut Frame,
area: Rect,
theme: &crate::theme::Theme,
) {
let bg = theme.bg_inactive();
let fg = theme.fg_active();
let lines = view.message_buf.lines();
let cursor = view.message_buf.cursor();
let area_h = area.height as usize;
let scroll = if cursor.line < area_h {
0
} else {
cursor.line - area_h + 1
};
let mut ratatui_lines: Vec<Line> = Vec::with_capacity(area_h);
for (row_idx, line) in lines.iter().enumerate().skip(scroll).take(area_h) {
if 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().saturating_sub(col));
let after = if after_start < line.len() { &line[after_start..] } else { "" };
ratatui_lines.push(Line::from(vec![
Span::styled(before, Style::default().fg(fg).bg(bg)),
Span::styled(
cursor_char.to_string(),
Style::default()
.fg(Color::Black)
.bg(Color::White)
.add_modifier(Modifier::BOLD),
),
Span::styled(after.to_string(), Style::default().fg(fg).bg(bg)),
]));
} else {
ratatui_lines.push(Line::from(Span::styled(
format!(" {line}"),
Style::default().fg(fg).bg(bg),
)));
}
}
frame.render_widget(
Paragraph::new(ratatui_lines).style(Style::default().bg(bg)),
area,
);
}
fn truncate(s: &str, max_chars: usize) -> String {
let chars: Vec<char> = s.chars().collect();
if chars.len() <= max_chars {
s.to_owned()
} else {
let mut t: String = chars[..max_chars.saturating_sub(1)].iter().collect();
t.push('…');
t
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::operation::HistoryEditorOp;
fn make_commit(oid: &str, summary: &str) -> CommitInfo {
CommitInfo {
oid: oid.to_owned(),
summary: summary.to_owned(),
message: summary.to_owned(),
author_name: "Test".to_owned(),
date_relative: "1d ago".to_owned(),
}
}
fn settings() -> Settings {
Settings::new(std::path::Path::new("/nonexistent/config.yaml"))
.unwrap_or_else(|_| Settings::new(std::path::Path::new(".")).unwrap())
}
fn loaded_view() -> GitHistoryEditorView {
let mut v = GitHistoryEditorView::new(PathBuf::from("/tmp/test_repo"));
let commits = vec![
make_commit("aaaaaa0000000000000000000000000000000000", "Commit A (newest)"),
make_commit("bbbbbb0000000000000000000000000000000000", "Commit B"),
make_commit("cccccc0000000000000000000000000000000000", "Commit C (oldest)"),
];
v.handle_operation(
&Operation::HistoryEditorLocal(HistoryEditorOp::CommitsLoaded(commits)),
&settings(),
);
v
}
#[test]
fn new_view_starts_loading() {
let v = GitHistoryEditorView::new(PathBuf::from("/tmp/repo"));
assert!(v.loading);
assert!(v.commits.is_empty());
assert_eq!(v.mode, EditorMode::List);
}
#[test]
fn commits_loaded_clears_loading_flag() {
let v = loaded_view();
assert!(!v.loading);
assert_eq!(v.commits.len(), 3);
}
#[test]
fn navigate_down_moves_cursor() {
let mut v = loaded_view();
assert_eq!(v.cursor, 0);
v.handle_operation(&Operation::NavigateDown, &settings());
assert_eq!(v.cursor, 1);
}
#[test]
fn navigate_up_does_not_go_below_zero() {
let mut v = loaded_view();
v.handle_operation(&Operation::NavigateUp, &settings());
assert_eq!(v.cursor, 0);
}
#[test]
fn start_reword_enters_reword_mode_with_prefilled_message() {
let mut v = loaded_view();
v.enter_reword_mode();
assert_eq!(v.mode, EditorMode::Reword);
assert_eq!(v.current_message(), "Commit A (newest)");
}
#[test]
fn start_squash_enters_squash_mode_with_combined_message() {
let mut v = loaded_view();
v.cursor = 0;
v.enter_squash_mode(1);
assert!(matches!(v.mode, EditorMode::Squash { target_idx: 1 }));
let msg = v.current_message();
assert!(msg.contains("Commit A"), "expected A in msg: {msg}");
assert!(msg.contains("Commit B"), "expected B in msg: {msg}");
}
#[test]
fn swap_toward_older_moves_commit_down() {
let mut v = loaded_view();
v.cursor = 0;
v.swap_toward_older();
assert_eq!(v.commits[0].summary, "Commit B");
assert_eq!(v.commits[1].summary, "Commit A (newest)");
assert_eq!(v.cursor, 1);
}
#[test]
fn swap_toward_newer_moves_commit_up() {
let mut v = loaded_view();
v.cursor = 1;
v.swap_toward_newer();
assert_eq!(v.commits[0].summary, "Commit B");
assert_eq!(v.commits[1].summary, "Commit A (newest)");
assert_eq!(v.cursor, 0);
}
#[test]
fn operation_failed_sets_status_message() {
let mut v = loaded_view();
v.handle_operation(
&Operation::HistoryEditorLocal(HistoryEditorOp::OperationFailed(
"rebase failed".to_owned(),
)),
&settings(),
);
assert!(v.status_msg.as_deref().unwrap_or("").contains("rebase failed"));
}
#[test]
fn operation_completed_refreshes_commit_list() {
let mut v = loaded_view();
let new_commits = vec![
make_commit("dddddd0000000000000000000000000000000000", "Commit D"),
];
v.handle_operation(
&Operation::HistoryEditorLocal(HistoryEditorOp::OperationCompleted {
commits: new_commits,
pre_sha: None,
description: String::new(),
}),
&settings(),
);
assert_eq!(v.commits.len(), 1);
assert_eq!(v.commits[0].summary, "Commit D");
}
}