use crossterm::event::{Event, KeyEventKind, MouseEvent, MouseEventKind};
use crate::app::modal;
use crate::config::sections::{DEFAULT_HANDLER, VIM_HANDLER};
use crate::config::{Action, Config, KeyBindingOverrides, KeyMap, Theme};
use crate::editor::{edit_ops, EditorState};
use crate::input::mode_handler::default::DefaultHandler;
use crate::input::ModeHandler;
use crate::terminal::ColorDepth;
use crate::ui::{settings_overlay, ModalKind};
use super::flash::MessageKind;
use super::modal::ModalOutcome;
use super::App;
pub(super) const NOT_YET_IMPLEMENTED: &[Action] = &[Action::Open];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct ActionCaps {
pub mutates_buffer: bool,
pub navigates_away: bool,
pub needs_path: bool,
pub read_only_nav: bool,
pub always_safe: bool,
}
pub(super) fn action_caps(action: &Action) -> ActionCaps {
use Action::*;
let mutates_buffer = matches!(
action,
InsertChar(_)
| InsertTab
| Newline
| DeleteCharBack
| DeleteCharForward
| DeleteWordBack
| DeleteWordForward
| DeleteLine
| Cut
| Paste
| BoldSelection
| ItalicizeSelection
| InlineCodeSelection
| StrikethroughSelection
| HighlightSelection
| Undo
| Redo
| ToggleCheckbox
| TableNextCell
| TablePrevCell
| TableNextRow
| TablePrevRow
| TableMoveRowUp
| TableMoveRowDown
| TableMoveColumnLeft
| TableMoveColumnRight
| TableInsertRowAbove
| TableInsertRowBelow
| TableInsertColumnLeft
| TableInsertColumnRight
| TableDeleteRow
| TableDeleteColumn
| TableInsertBreak
| InsertTable
| InsertImage
| InsertLink
| InsertFootnote
| DeleteFootnote
| RenumberFootnotes
| FixListNumbering
| SearchReplace
| SearchReplaceAll
);
let navigates_away = matches!(
action,
FollowLinkUnderCursor | NavigateBack | NavigateForward | GoToSection | OpenDoc(_) | Open
);
let needs_path = matches!(action, Save | SaveAs | ExportHtml | OpenInExternalEditor);
let read_only_nav = matches!(
action,
MoveLeft
| MoveRight
| MoveUp
| MoveDown
| MoveWordLeft
| MoveWordRight
| MoveLineStart
| MoveLineEnd
| MoveDocStart
| MoveDocEnd
| SelectLeft
| SelectRight
| SelectUp
| SelectDown
| SelectAll
| Copy
);
let always_safe = matches!(
action,
ScrollUp
| ScrollDown
| ScrollPageUp
| ScrollPageDown
| ScrollToTop
| ScrollToBottom
| Quit
| ShowCommandPalette
| ShowMarkdownCheatSheet
| ShowAbout
| CheckForUpdates
| OpenSettings
| OpenWelcome
| OpenKeybinds
| SwitchTheme
| CreateCustomTheme
| OpenConfigFolder
);
match action {
ScrollUp | ScrollDown | ScrollPageUp | ScrollPageDown | ScrollToTop | ScrollToBottom
| Quit | ShowCommandPalette | ShowMarkdownCheatSheet | ShowAbout | CheckForUpdates
| OpenSettings | OpenWelcome | OpenKeybinds | SwitchTheme | CreateCustomTheme
| OpenConfigFolder | MoveLeft | MoveRight | MoveUp | MoveDown | MoveWordLeft
| MoveWordRight | MoveLineStart | MoveLineEnd | MoveDocStart | MoveDocEnd | SelectLeft
| SelectRight | SelectUp | SelectDown | SelectAll | Copy | InsertChar(_) | InsertTab
| Newline | DeleteCharBack | DeleteCharForward | DeleteWordBack | DeleteWordForward
| DeleteLine | Cut | Paste | BoldSelection | ItalicizeSelection | InlineCodeSelection
| StrikethroughSelection | HighlightSelection | Undo | Redo | ToggleCheckbox
| TableNextCell | TablePrevCell | TableNextRow | TablePrevRow | TableMoveRowUp
| TableMoveRowDown | TableMoveColumnLeft | TableMoveColumnRight | TableInsertRowAbove
| TableInsertRowBelow | TableInsertColumnLeft | TableInsertColumnRight | TableDeleteRow
| TableDeleteColumn | TableInsertBreak | InsertTable | InsertImage | InsertLink
| InsertFootnote | DeleteFootnote | RenumberFootnotes | FixListNumbering | SearchReplace
| SearchReplaceAll | FollowLinkUnderCursor | NavigateBack | NavigateForward
| GoToSection | OpenDoc(_) | Open | Save | SaveAs | ExportHtml
| OpenInExternalEditor
| EnterEditMode | ExitToPreview | ToggleRawMode | ToggleTableButtons | ToggleBigH1
| ToggleLineNumbers | ToggleBlinkCursor | ToggleAutosave | ToggleVisualLineNav
| ToggleVimMode | ToggleLimitWidth | ToggleDiffOnChange
| OpenSearch | SearchNext | SearchPrev | SearchExit | DiffNext | DiffPrev
| DiffAcceptHunk | DiffRejectHunk | DiffAcceptAll | DiffRejectAll | DiffResetHunk
| DiffExit => {}
}
ActionCaps {
mutates_buffer,
navigates_away,
needs_path,
read_only_nav,
always_safe,
}
}
pub(super) fn diff_safe_action(action: &Action) -> Option<Action> {
use Action::*;
let allowed = action_caps(action).always_safe
|| matches!(
action,
DiffNext
| DiffPrev
| DiffAcceptHunk
| DiffRejectHunk
| DiffAcceptAll
| DiffRejectAll
| DiffResetHunk
| DiffExit
);
allowed.then(|| action.clone())
}
pub(super) fn search_safe_action(action: &Action) -> Option<Action> {
use Action::*;
let caps = action_caps(action);
let allowed = caps.always_safe
|| caps.read_only_nav
|| matches!(
action,
OpenSearch
| SearchNext
| SearchPrev
| SearchReplace
| SearchReplaceAll
| SearchExit
| Undo
| Redo
| Save
| SaveAs
);
allowed.then(|| action.clone())
}
pub(super) fn readonly_safe_action(action: &Action) -> bool {
let caps = action_caps(action);
!caps.mutates_buffer && !caps.needs_path
}
pub(super) fn cursor_in_table(state: &EditorState) -> bool {
if state.readonly {
return false;
}
let cursor_byte = state.buffer.rope().char_to_byte(state.cursor.offset);
let source = state.buffer.contents();
crate::editor::table_edit::find_table_at(&source, cursor_byte).is_some()
}
fn linewise_paste_payload(
buffer: &crate::document::Buffer,
range: &std::ops::Range<usize>,
clipboard: String,
) -> Option<String> {
if clipboard.is_empty() {
return None;
}
let replaced_ends_with_newline =
range.end > range.start && buffer.slice_to_string(range.end - 1, range.end) == "\n";
if replaced_ends_with_newline && !clipboard.ends_with('\n') {
return Some(clipboard + "\n");
}
Some(clipboard)
}
pub(super) fn modal_wheel_delta(event: &MouseEvent, wheel_step: usize) -> i32 {
let step = wheel_step.max(1) as i32;
match event.kind {
MouseEventKind::ScrollUp => -step,
MouseEventKind::ScrollDown => step,
_ => 0,
}
}
pub(super) trait HandleEvent {
fn handle_event(&mut self, event: Event, state: &EditorState) -> Option<crate::config::Action>;
}
impl<'k> HandleEvent for DefaultHandler<'k> {
fn handle_event(&mut self, event: Event, state: &EditorState) -> Option<crate::config::Action> {
match event {
Event::Key(key) if key.kind == KeyEventKind::Press => self.handle(key, state),
_ => None,
}
}
}
impl App {
pub(super) fn any_modal_open(&self) -> bool {
!self.modal_stack.is_empty()
}
fn normalize_context_action(&self, action: Action) -> Action {
match action {
Action::TableMoveColumnLeft if !cursor_in_table(&self.editor) => Action::NavigateBack,
Action::TableMoveColumnRight if !cursor_in_table(&self.editor) => {
Action::NavigateForward
}
other => other,
}
}
pub(super) fn handle_app_action(
&mut self,
action: &Action,
doc_height: usize,
doc_width: usize,
) -> bool {
match action {
Action::FollowLinkUnderCursor => {
if let Some(target) = self.resolve_link_at_cursor() {
self.follow_link(target, doc_height, doc_width);
}
true
}
Action::ShowAbout => {
self.open_about_modal();
true
}
Action::CheckForUpdates => {
self.open_update_modal();
true
}
Action::NavigateBack => {
self.navigate_back(doc_height, doc_width);
true
}
Action::NavigateForward => {
self.navigate_forward(doc_height, doc_width);
true
}
Action::ShowCommandPalette => {
self.open_command_palette();
true
}
Action::GoToSection => {
self.open_section_picker(doc_width);
true
}
Action::OpenSearch => {
self.open_search_modal();
true
}
Action::ShowMarkdownCheatSheet => {
self.open_markdown_cheat_sheet();
true
}
Action::OpenDoc(id) => {
if self.editor.dirty {
self.open_dirty_guard(crate::app::nav::NavPending::Doc(*id), None);
} else {
self.open_doc_page(*id, None, doc_height, doc_width);
}
true
}
Action::OpenSettings => {
self.open_settings_overlay();
true
}
Action::OpenWelcome => {
self.open_welcome_modal();
true
}
Action::OpenKeybinds => {
self.open_keybinds_overlay();
true
}
Action::SwitchTheme => {
self.open_theme_picker();
true
}
Action::CreateCustomTheme => {
self.open_export_theme_modal();
true
}
Action::ExportHtml => {
self.open_export_modal();
true
}
Action::OpenConfigFolder => {
if let Some(dir) = Config::config_dir() {
self.spawn_open_worker(dir.display().to_string());
} else {
self.notify("No config directory available", ModalKind::Error);
}
true
}
a if NOT_YET_IMPLEMENTED.contains(a) => {
self.notify_not_implemented();
true
}
Action::OpenInExternalEditor => {
if self.editor.buffer.path().is_none() {
self.notify("No file path for buffer", ModalKind::Error);
} else {
self.pending_open_file_in_editor = true;
self.needs_draw = true;
}
true
}
Action::ToggleTableButtons => {
if self.capabilities.mouse {
self.config.table.show_buttons = !self.config.table.show_buttons;
self.toggle_persisted_setting(
settings_overlay::LABEL_TABLE_BUTTONS,
self.config.table.show_buttons,
);
} else {
self.notify("Mouse not supported on this terminal", ModalKind::Error);
self.needs_draw = true;
}
true
}
Action::ToggleBigH1 => {
self.config.editor.big_h1 = !self.config.editor.big_h1;
self.toggle_persisted_setting(
settings_overlay::LABEL_BIG_H1,
self.config.editor.big_h1,
);
true
}
Action::ToggleLineNumbers => {
self.config.editor.show_line_numbers = !self.config.editor.show_line_numbers;
self.toggle_persisted_setting(
settings_overlay::LABEL_LINE_NUMBERS,
self.config.editor.show_line_numbers,
);
true
}
Action::ToggleBlinkCursor => {
self.config.editor.cursor_blink = !self.config.editor.cursor_blink;
self.toggle_persisted_setting(
settings_overlay::LABEL_BLINK_CURSOR,
self.config.editor.cursor_blink,
);
true
}
Action::ToggleAutosave => {
self.config.editor.autosave_enabled = !self.config.editor.autosave_enabled;
self.toggle_persisted_setting(
settings_overlay::LABEL_AUTOSAVE,
self.config.editor.autosave_enabled,
);
true
}
Action::ToggleVisualLineNav => {
self.config.editor.visual_line_nav = !self.config.editor.visual_line_nav;
self.toggle_persisted_setting(
settings_overlay::LABEL_VISUAL_LINE_NAV,
self.config.editor.visual_line_nav,
);
true
}
Action::ToggleVimMode => {
let enabling = self.config.modal.handler != VIM_HANDLER;
self.config.modal.handler = if enabling {
VIM_HANDLER
} else {
DEFAULT_HANDLER
}
.to_owned();
self.toggle_persisted_setting(settings_overlay::LABEL_VIM_MODE, enabling);
true
}
Action::ToggleLimitWidth => {
self.config.editor.max_width_enabled = !self.config.editor.max_width_enabled;
self.toggle_persisted_setting(
settings_overlay::LABEL_LIMIT_WIDTH,
self.config.editor.max_width_enabled,
);
true
}
Action::ToggleDiffOnChange => {
self.config.editor.diff_on_change = !self.config.editor.diff_on_change;
self.toggle_persisted_setting(
settings_overlay::LABEL_DIFF_ON_CHANGE,
self.config.editor.diff_on_change,
);
true
}
Action::InsertTable => {
let source = self.editor.buffer.contents();
let cursor_byte = self
.editor
.buffer
.rope()
.char_to_byte(self.editor.cursor.offset);
if crate::editor::table_edit::cursor_line_is_blank(&source, cursor_byte) {
self.open_insert_table_modal();
} else {
self.notify("Insert Table requires a blank line", ModalKind::Warning);
}
self.needs_draw = true;
true
}
Action::InsertImage | Action::InsertLink => {
let is_image = matches!(action, Action::InsertImage);
let inserted = if is_image {
crate::editor::edit_ops::insert_image_at_cursor(
&mut self.editor,
doc_height,
doc_width,
)
} else {
crate::editor::edit_ops::insert_link_at_cursor(
&mut self.editor,
doc_height,
doc_width,
)
};
if !inserted {
let what = if is_image { "an image" } else { "a link" };
self.notify(
format!("Cannot insert {what} inside this block"),
ModalKind::Warning,
);
}
self.needs_draw = true;
true
}
Action::InsertFootnote => {
crate::editor::edit_ops::insert_footnote_at_cursor(
&mut self.editor,
doc_height,
doc_width,
);
self.needs_draw = true;
true
}
Action::DeleteFootnote => {
if !crate::editor::edit_ops::delete_footnote_at_cursor(
&mut self.editor,
doc_height,
doc_width,
) {
self.flash("Cursor is not on a footnote", MessageKind::Info);
}
self.needs_draw = true;
true
}
Action::RenumberFootnotes => {
if !crate::editor::edit_ops::renumber_footnotes(
&mut self.editor,
doc_height,
doc_width,
) {
self.flash("Footnotes already in order", MessageKind::Info);
}
self.needs_draw = true;
true
}
Action::FixListNumbering => {
use crate::editor::edit_ops::FixListNumbering;
match crate::editor::edit_ops::fix_list_numbering(
&mut self.editor,
doc_height,
doc_width,
) {
FixListNumbering::Fixed => {}
FixListNumbering::AlreadyCorrect => {
self.flash("List numbering already correct", MessageKind::Info);
}
FixListNumbering::NotOrdered => {
self.flash("Cursor is not in an ordered list", MessageKind::Info);
}
}
self.needs_draw = true;
true
}
Action::SaveAs => {
self.open_save_as_modal(None);
self.needs_draw = true;
true
}
Action::Save => {
if self.editor.mode == crate::editor::Mode::Diff {
self.flash("Resolve diff before saving", MessageKind::Info);
return true;
}
if self.editor.buffer.path().is_none() {
self.open_save_as_modal(None);
self.needs_draw = true;
return true;
}
let dirty_before = self.editor.dirty;
if let Err(e) = self.save_buffer() {
tracing::warn!(error = %e, "save failed");
}
self.flash_for_action(&Action::Save, dirty_before);
true
}
_ => false,
}
}
fn toggle_persisted_setting(&mut self, label: &str, new_state: bool) {
let saved = self.config.save();
modal::settings::apply_live_update(label, self);
match saved {
Ok(()) => {
let state = if new_state { "on" } else { "off" };
let msg = format!(
"{}: {state}{}",
label.trim(),
crate::config::unpersisted_suffix()
);
self.flash(msg, MessageKind::Info);
}
Err(e) => {
tracing::warn!(error = %e, "failed to persist palette setting toggle");
self.notify(format!("Config save failed: {e}"), ModalKind::Error);
}
}
self.needs_draw = true;
}
pub(super) fn dispatch_modal_key(
&mut self,
key: crossterm::event::KeyEvent,
doc_height: usize,
doc_width: usize,
) {
let Some(mut top) = self.modal_stack.pop() else {
return;
};
let outcome = top.handle_key(key, self, doc_height, doc_width);
match outcome {
ModalOutcome::Continue => self.modal_stack.push(top),
ModalOutcome::ContinueAnd(cb) => {
self.modal_stack.push(top);
cb(self);
}
ModalOutcome::Close => {}
ModalOutcome::CloseAnd(cb) => cb(self),
}
}
pub(super) fn dispatch_modal_paste(&mut self, text: &str) {
let Some(mut top) = self.modal_stack.pop() else {
return;
};
let outcome = top.handle_paste(text);
match outcome {
ModalOutcome::Continue => self.modal_stack.push(top),
ModalOutcome::ContinueAnd(cb) => {
self.modal_stack.push(top);
cb(self);
}
ModalOutcome::Close => {}
ModalOutcome::CloseAnd(cb) => cb(self),
}
}
pub(super) fn dispatch_modal_click(&mut self, col: u16, row: u16) {
let Some(mut top) = self.modal_stack.pop() else {
return;
};
let outcome = top.handle_click(col, row, self);
match outcome {
ModalOutcome::Continue => self.modal_stack.push(top),
ModalOutcome::ContinueAnd(cb) => {
self.modal_stack.push(top);
cb(self);
}
ModalOutcome::Close => {}
ModalOutcome::CloseAnd(cb) => cb(self),
}
}
pub(super) fn notify_not_implemented(&mut self) {
self.notify("This feature is not implemented yet.", ModalKind::Normal);
}
pub(super) fn open_quit_confirm(&mut self) {
let display = self
.file_path
.as_deref()
.and_then(|p| p.file_name())
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "Current buffer".to_owned());
self.modal_stack
.push(Box::new(modal::QuitConfirmModal::new(&display)));
}
pub fn open_markdown_cheat_sheet(&mut self) {
self.modal_stack
.push(Box::new(modal::CheatSheetModal::new()));
}
pub fn open_about_modal(&mut self) {
if self.modal_stack.contains::<modal::AboutModal>() {
return;
}
self.modal_stack.push(Box::new(modal::AboutModal::new()));
self.needs_draw = true;
}
pub fn open_command_palette(&mut self) {
let keymap = self.ensure_keymap_clone();
self.modal_stack
.push(Box::new(modal::CommandPaletteModal::new(&keymap)));
}
pub(super) fn ensure_keymap_clone(&mut self) -> KeyMap {
if self.keymap.is_none() {
match KeyMap::build(&self.keybindings) {
Ok(km) => self.keymap = Some(km),
Err(e) => {
tracing::warn!(error = %e, "failed to build KeyMap on demand");
return KeyMap::build(&KeyBindingOverrides::default())
.expect("default keymap always builds");
}
}
}
self.keymap.as_ref().unwrap().clone()
}
pub fn dispatch_action(&mut self, action: Action, doc_height: usize, doc_width: usize) {
let action = self.normalize_context_action(action);
if self.editor.readonly && !readonly_safe_action(&action) {
return;
}
if self.search_flow_captures() {
let Some(safe) = search_safe_action(&action) else {
self.flash_action_unavailable("search");
return;
};
self.dispatch_search_action(safe, doc_height, doc_width);
return;
}
if self.editor.search.is_some()
&& matches!(
action,
Action::SearchNext | Action::SearchPrev | Action::SearchExit
)
{
self.dispatch_search_action(action, doc_height, doc_width);
return;
}
if matches!(action, Action::Copy | Action::Cut | Action::Paste) {
if let Some(kind) = self.vim.as_ref().and_then(|v| v.visual_kind()) {
self.dispatch_visual_clipboard(action, kind, doc_height, doc_width);
return;
}
}
if self.editor.mode == crate::editor::Mode::Diff && diff_safe_action(&action).is_none() {
self.flash_action_unavailable("diff review");
return;
}
let handled = self.handle_app_action(&action, doc_height, doc_width);
if !handled {
if self.editor.mode == crate::editor::Mode::Diff {
self.dispatch_diff_action(action, doc_height, doc_width);
return;
}
if matches!(action, Action::Quit) && self.editor.dirty {
self.open_quit_confirm();
return;
}
let dirty_before = self.editor.dirty;
let scroll_before = self.editor.scroll;
let quit = edit_ops::apply(&mut self.editor, action.clone(), doc_height, doc_width);
if quit {
self.should_quit = true;
}
if self.editor.scroll != scroll_before {
self.mark_scrolling();
}
self.flash_for_action(&action, dirty_before);
if let Some(target) = self.editor.pending_link_follow.take() {
self.follow_link(target, doc_height, doc_width);
}
}
}
fn dispatch_visual_clipboard(
&mut self,
action: Action,
kind: crate::editor::vim_ops::VisualKind,
doc_height: usize,
doc_width: usize,
) {
let Some(sel) = self.editor.selection else {
return;
};
let range = crate::editor::vim_ops::visual_span(&sel, &self.editor.buffer, Some(kind));
let payload = match action {
Action::Paste if kind == crate::editor::vim_ops::VisualKind::Line => {
let clipboard = edit_ops::clipboard_text(&self.editor);
let Some(text) = linewise_paste_payload(&self.editor.buffer, &range, clipboard)
else {
return;
};
Some(text)
}
Action::Paste => {
if edit_ops::clipboard_text(&self.editor).is_empty() {
return;
}
None
}
_ => None,
};
let widened = crate::document::Selection {
anchor: range.start,
active: range.end,
};
self.editor.selection = Some(widened);
let dirty_before = self.editor.dirty;
match &payload {
Some(text) => edit_ops::paste_text(&mut self.editor, text, doc_height, doc_width),
None => {
edit_ops::apply(&mut self.editor, action.clone(), doc_height, doc_width);
}
}
self.flash_for_action(&action, dirty_before);
if matches!(action, Action::Cut | Action::Paste) {
if let Some(vim) = self.vim.as_mut() {
vim.sub_mode = crate::input::VimSubMode::Normal;
vim.visual_anchor = None;
}
self.editor.selection = None;
} else {
self.editor.selection = Some(sel);
}
self.needs_draw = true;
}
pub(super) fn dispatch_flow_scroll(
&mut self,
action: &Action,
doc_height: usize,
doc_width: usize,
) -> bool {
match action {
Action::ScrollUp => {
if self.editor.scroll > 0 {
self.editor.scroll = self.editor.scroll.saturating_sub(1);
self.mark_scrolling();
self.needs_draw = true;
}
}
Action::ScrollDown => {
let total = self.editor.total_visual_rows_for_mode(doc_width);
let max = total.saturating_sub(1);
if self.editor.scroll < max {
self.editor.scroll += 1;
self.mark_scrolling();
self.needs_draw = true;
}
}
Action::ScrollPageUp => {
self.editor.scroll = self.editor.scroll.saturating_sub(doc_height.max(1));
self.mark_scrolling();
self.needs_draw = true;
}
Action::ScrollPageDown => {
let total = self.editor.total_visual_rows_for_mode(doc_width);
let max = total.saturating_sub(1);
self.editor.scroll = (self.editor.scroll + doc_height.max(1)).min(max);
self.mark_scrolling();
self.needs_draw = true;
}
Action::ScrollToTop => {
self.editor.scroll = 0;
self.mark_scrolling();
self.needs_draw = true;
}
Action::ScrollToBottom => {
let total = self.editor.total_visual_rows_for_mode(doc_width);
self.editor.scroll = total.saturating_sub(doc_height.max(1));
self.mark_scrolling();
self.needs_draw = true;
}
_ => return false,
}
true
}
pub(super) fn dispatch_diff_action(
&mut self,
action: Action,
doc_height: usize,
doc_width: usize,
) {
use crate::diff::Decision;
if self.dispatch_flow_scroll(&action, doc_height, doc_width) {
return;
}
if self.editor.diff.as_ref().is_some_and(|d| d.read_only)
&& matches!(
action,
Action::DiffAcceptHunk
| Action::DiffRejectHunk
| Action::DiffAcceptAll
| Action::DiffRejectAll
| Action::DiffResetHunk
)
{
self.flash("This review is read-only", MessageKind::Info);
return;
}
match action {
Action::DiffNext => {
self.cancel_diff_advance();
if let Some(d) = self.editor.diff.as_mut() {
d.advance_focus();
self.editor
.scroll_focused_hunk_into_view(doc_height, doc_width);
self.needs_draw = true;
}
}
Action::DiffPrev => {
self.cancel_diff_advance();
if let Some(d) = self.editor.diff.as_mut() {
d.retreat_focus();
self.editor
.scroll_focused_hunk_into_view(doc_height, doc_width);
self.needs_draw = true;
}
}
Action::DiffAcceptHunk => self.decide_focused_hunk(Decision::Accepted),
Action::DiffRejectHunk => self.decide_focused_hunk(Decision::Rejected),
Action::DiffAcceptAll => self.open_diff_bulk_confirm(Decision::Accepted),
Action::DiffRejectAll => self.open_diff_bulk_confirm(Decision::Rejected),
Action::DiffResetHunk => {
self.cancel_diff_advance();
let reset = self.editor.diff.as_mut().is_some_and(|d| d.reset_focused());
if reset {
self.needs_draw = true;
}
}
Action::DiffExit => {
self.cancel_diff_advance();
if self.editor.diff.as_ref().is_some_and(|d| d.read_only) {
self.should_quit = true;
} else if self.editor.diff.as_ref().is_some_and(|d| d.all_resolved()) {
self.check_diff_resolution();
} else {
self.flash(
"Resolve every hunk before exiting diff mode",
MessageKind::Info,
);
}
}
Action::Quit => {
if self.editor.diff.as_ref().is_some_and(|d| d.read_only) {
self.diff_stop_walk = true;
self.should_quit = true;
return;
}
if !self
.modal_stack
.contains::<crate::app::modal::DiffQuitConfirmModal>()
{
self.modal_stack
.push(Box::new(crate::app::modal::DiffQuitConfirmModal::new()));
self.needs_draw = true;
}
}
Action::ShowCommandPalette => {
self.open_command_palette();
}
Action::ShowMarkdownCheatSheet => {
self.open_markdown_cheat_sheet();
}
Action::ShowAbout => {
self.open_about_modal();
}
Action::CheckForUpdates => {
self.open_update_modal();
}
Action::OpenSettings => {
self.open_settings_overlay();
}
Action::OpenWelcome => {
self.open_welcome_modal();
}
Action::OpenKeybinds => {
self.open_keybinds_overlay();
}
Action::SwitchTheme => {
self.open_theme_picker();
}
Action::CreateCustomTheme => {
self.open_export_theme_modal();
}
Action::OpenConfigFolder => {
if let Some(dir) = Config::config_dir() {
self.spawn_open_worker(dir.display().to_string());
}
}
_ => {}
}
}
fn decide_focused_hunk(&mut self, decision: crate::diff::Decision) {
if self.diff_advance_pending_since.is_some() {
self.apply_diff_advance();
}
let decided = self
.editor
.diff
.as_mut()
.is_some_and(|d| d.decide_focused(decision));
if decided {
self.needs_draw = true;
self.arm_diff_advance();
}
}
fn open_diff_bulk_confirm(&mut self, decision: crate::diff::Decision) {
self.cancel_diff_advance();
if self.editor.diff.is_none() {
return;
}
if self
.modal_stack
.contains::<crate::app::modal::DiffBulkConfirmModal>()
{
return;
}
self.modal_stack
.push(Box::new(crate::app::modal::DiffBulkConfirmModal::new(
decision,
)));
self.needs_draw = true;
}
pub(crate) fn apply_diff_bulk_decision(&mut self, decision: crate::diff::Decision) {
self.cancel_diff_advance();
if let Some(d) = self.editor.diff.as_mut() {
d.bulk_decide(decision);
self.needs_draw = true;
}
self.check_diff_resolution();
}
pub(crate) fn check_diff_resolution(&mut self) {
let Some(diff) = self.editor.diff.as_ref() else {
return;
};
if !diff.all_resolved() {
return;
}
if self
.modal_stack
.contains::<crate::app::modal::DiffResolveConfirmModal>()
{
return;
}
let accepted = diff
.decisions
.iter()
.filter(|d| **d == crate::diff::Decision::Accepted)
.count();
let rejected = diff.decisions.len() - accepted;
self.modal_stack
.push(Box::new(crate::app::modal::DiffResolveConfirmModal::new(
accepted, rejected,
)));
self.needs_draw = true;
}
pub(crate) fn enter_diff_mode(&mut self, on_disk: String) {
if let Some(vim) = self.vim.as_mut() {
crate::editor::vim_ops::end_incsearch(&mut self.editor, &mut vim.incsearch);
vim.cmdline = None;
vim.reset_pending();
}
crate::editor::vim_ops::clear_substitute_preview(
&mut self.editor,
true,
);
self.exit_search_flow();
let old = self.editor.buffer.contents();
let Some(diff_state) = crate::diff::DiffState::new(&old, &on_disk) else {
self.flash("No differences to review", MessageKind::Info);
return;
};
let uneven_table_fallback = diff_state.uneven_table_fallback;
self.editor.enter_diff_mode(diff_state);
if self.config.editor.show_diff_intro
&& !self
.modal_stack
.contains::<crate::app::modal::DiffIntroModal>()
{
self.modal_stack
.push(Box::new(crate::app::modal::DiffIntroModal::new()));
}
if uneven_table_fallback {
self.flash(
"Table has uneven row widths — not split into per-row hunks",
MessageKind::Info,
);
}
self.needs_draw = true;
}
pub fn enter_read_only_diff(&mut self, old: String, new: String) -> bool {
let Some(mut diff_state) = crate::diff::DiffState::new(&old, &new) else {
return false;
};
diff_state.read_only = true;
let uneven_table_fallback = diff_state.uneven_table_fallback;
self.editor
.replace_buffer(crate::document::Buffer::from_str(&old));
self.editor.enter_diff_mode(diff_state);
self.on_document_contents_swapped();
if uneven_table_fallback {
self.flash(
"Table has uneven row widths — not split into per-row hunks",
MessageKind::Info,
);
}
self.needs_draw = true;
true
}
pub fn set_diff_label(&mut self, label: Option<String>) {
self.diff_label = label;
}
pub(crate) fn apply_diff_resolution(&mut self) {
self.cancel_diff_advance();
let Some(diff) = self.editor.diff.as_ref() else {
return;
};
let Some(resolved) = diff.resolved_rope() else {
self.flash("Diff is not fully resolved", MessageKind::Info);
return;
};
let new_text = diff.new_buffer.contents();
let resolved_text = resolved.to_string();
let differs_from_disk = resolved_text != new_text;
let merge_delta = crate::document::EditDelta {
offset: 0,
removed: diff.old_rope.to_string(),
inserted: resolved_text,
};
self.editor.buffer.set_rope(resolved);
self.editor.cursor.offset = 0;
self.editor.cursor.preferred_col = 0;
self.editor.history.reset_with(merge_delta);
self.editor.dirty = differs_from_disk;
self.editor.refresh_parsed();
self.editor.update_cursor_block();
self.editor.exit_diff_mode();
self.on_document_contents_swapped();
self.flash("Diff resolved", MessageKind::Success);
self.needs_draw = true;
}
pub(crate) fn exit_diff_mode_discarding(&mut self) {
self.cancel_diff_advance();
self.editor.exit_diff_mode();
self.modal_stack
.remove_first::<crate::app::modal::DiffResolveConfirmModal>();
self.modal_stack
.remove_first::<crate::app::modal::DiffIntroModal>();
self.modal_stack
.remove_first::<crate::app::modal::DiffBulkConfirmModal>();
self.modal_stack
.remove_first::<crate::app::modal::DiffQuitConfirmModal>();
self.needs_draw = true;
}
pub(super) fn save_buffer(&mut self) -> anyhow::Result<()> {
self.editor.buffer.save_file()?;
self.editor.dirty = false;
let bytes = self.editor.buffer.contents();
self.set_disk_hash(bytes.as_bytes());
Ok(())
}
pub(super) fn save_buffer_as(&mut self, path: &std::path::Path) -> anyhow::Result<()> {
self.editor.buffer.save_as(path)?;
self.editor.dirty = false;
self.file_path = Some(path.to_owned());
let bytes = self.editor.buffer.contents();
self.set_disk_hash(bytes.as_bytes());
if let Some(w) = self.watcher.as_mut() {
if let Err(e) = w.watch(path) {
tracing::warn!(
target: "watcher",
path = %path.display(),
error = %e,
"watch swap failed after save-as",
);
}
}
Ok(())
}
pub(crate) fn set_disk_hash(&mut self, bytes: &[u8]) {
self.last_disk_hash = Some(seahash::hash(bytes));
}
pub fn open_settings_overlay(&mut self) {
self.modal_stack
.push(Box::new(modal::SettingsOverlayModal::new()));
}
pub fn open_welcome_modal(&mut self) {
if self.modal_stack.contains::<modal::WelcomeModal>() {
return;
}
self.modal_stack.push(Box::new(modal::WelcomeModal::new(
&self.capabilities,
&self.config,
)));
}
pub fn open_theme_picker(&mut self) {
let current = self.config.theme.clone();
let mode =
crate::config::theme::theme_appearance(¤t).unwrap_or(self.config.appearance);
let themes = crate::config::theme::list_theme_names_for_mode(mode);
self.modal_stack.push(Box::new(modal::ThemePickerModal::new(
themes, current, mode,
)));
}
pub fn open_keybinds_overlay(&mut self) {
let keymap = self.ensure_keymap_clone();
let overrides = self.keybindings.clone();
let vim_enabled = self.vim.is_some();
self.modal_stack
.push(Box::new(modal::KeybindsOverlayModal::new(
&keymap,
&overrides,
vim_enabled,
)));
}
pub fn open_insert_table_modal(&mut self) {
self.modal_stack
.push(Box::new(modal::InsertTableModal::new()));
}
pub fn open_save_as_modal(&mut self, after_save: Option<modal::save_as::AfterSave>) {
let m = modal::SaveAsModal::for_buffer_path(self.editor.buffer.path(), after_save);
self.modal_stack.push(Box::new(m));
}
pub(super) fn save_buffer_as_confirmed(
&mut self,
path: std::path::PathBuf,
force: bool,
after: Option<modal::save_as::AfterSave>,
) {
if !force && self.editor.buffer.would_overwrite(&path) {
self.modal_stack
.push(Box::new(modal::OverwriteConfirmModal::new(path, after)));
return;
}
match self.save_buffer_as(&path) {
Ok(()) => {
self.flash(format!("Saved to {}", path.display()), MessageKind::Success);
if let Some(after) = after {
after(self);
}
}
Err(e) => self.notify(format!("Save failed: {e}"), ModalKind::Error),
}
}
pub(super) fn save_copy_confirmed(
&mut self,
path: std::path::PathBuf,
force: bool,
after: Option<modal::save_as::AfterSave>,
) {
if !force && self.editor.buffer.would_overwrite(&path) {
self.modal_stack
.push(Box::new(modal::OverwriteConfirmModal::for_copy(
path, after,
)));
return;
}
match self.editor.buffer.save_copy(&path) {
Ok(()) => {
self.flash(
format!("Copy saved to {}", path.display()),
MessageKind::Success,
);
if let Some(after) = after {
after(self);
}
}
Err(e) => self.notify(format!("Save failed: {e}"), ModalKind::Error),
}
}
pub(super) fn handle_pending_column_widths(&mut self) {
let Some(table_byte_start) = self.editor.pending_column_widths_commit else {
return;
};
let already_has_comment = self.editor.table_has_tui_columns_comment(table_byte_start);
if already_has_comment || !self.config.table.warn_on_width_injection {
self.editor.commit_pending_column_widths();
return;
}
self.modal_stack
.push(Box::new(modal::WidthInjectionWarning::new()));
}
pub(super) fn apply_active_theme(&mut self) {
let truecolor = self.capabilities.color_depth == ColorDepth::TrueColor;
let (theme_file, warnings) = Config::load_theme(&self.config.theme, truecolor);
let monochrome = self.capabilities.color_depth == ColorDepth::NoColor;
let new_theme: &'static Theme =
Box::leak(Box::new(Theme::from_file(&theme_file, monochrome)));
self.theme = new_theme;
self.editor.set_theme(new_theme);
self.needs_draw = true;
if let Some(modal) = modal::ConfigWarningModal::from_warnings(&warnings) {
self.modal_stack.push(Box::new(modal));
}
}
}
#[cfg(test)]
mod tests {
use super::{action_caps, readonly_safe_action, search_safe_action};
#[test]
fn an_app_level_action_off_the_allowlist_is_refused_in_diff_review() {
let mut app = app_with_buffer("alpha\n", 0);
app.enter_diff_mode("bravo\n".to_owned());
assert_eq!(app.editor.mode, crate::editor::Mode::Diff);
assert!(
diff_safe_action(&Action::InsertTable).is_none(),
"precondition: InsertTable is not diff-safe"
);
let before = app.modal_stack.len();
app.dispatch_action(Action::InsertTable, 20, 80);
assert_eq!(
app.modal_stack.len(),
before,
"the insert-table modal must not open over a diff review"
);
}
#[test]
fn following_a_link_is_refused_in_diff_review() {
let mut app = app_with_buffer("[x](other.md)\n", 0);
app.enter_diff_mode("bravo\n".to_owned());
assert!(diff_safe_action(&Action::FollowLinkUnderCursor).is_none());
app.dispatch_action(Action::FollowLinkUnderCursor, 20, 80);
assert_eq!(
app.editor.mode,
crate::editor::Mode::Diff,
"a link follow must not navigate out of a review"
);
}
#[test]
fn an_allowed_app_level_action_still_runs_in_diff_review() {
let mut app = app_with_buffer("alpha\n", 0);
app.enter_diff_mode("bravo\n".to_owned());
assert!(diff_safe_action(&Action::ShowCommandPalette).is_some());
let before = app.modal_stack.len();
app.dispatch_action(Action::ShowCommandPalette, 20, 80);
assert_eq!(
app.modal_stack.len(),
before + 1,
"the palette still opens during a review"
);
}
use crossterm::event::{
KeyModifiers as CtKeyModifiers, MouseButton, MouseEvent, MouseEventKind,
};
use super::{diff_safe_action, edit_ops, linewise_paste_payload, modal_wheel_delta};
use crate::app::test_utils::{app_with_buffer, make_app};
use crate::config::Action;
use crate::document::Buffer;
#[test]
fn enter_diff_mode_with_show_intro_pushes_intro_modal() {
use crate::app::modal::DiffIntroModal;
let mut app = make_app();
app.editor.buffer.insert(0, "alpha\nbeta\n");
assert!(app.config.editor.show_diff_intro);
app.enter_diff_mode("alpha\nGAMMA\n".to_owned());
assert_eq!(app.editor.mode, crate::editor::Mode::Diff);
assert!(app.editor.diff.is_some());
assert!(
app.modal_stack.contains::<DiffIntroModal>(),
"first-time entry must push the intro modal",
);
}
#[test]
fn reentering_diff_mode_does_not_stack_a_second_intro_modal() {
use crate::app::modal::DiffIntroModal;
let mut app = make_app();
app.editor.buffer.insert(0, "alpha\nbeta\n");
app.enter_diff_mode("alpha\nGAMMA\n".to_owned());
app.enter_diff_mode("alpha\nDELTA\n".to_owned());
assert_eq!(
app.modal_stack.count::<DiffIntroModal>(),
1,
"re-entry must not stack a second intro modal",
);
}
#[test]
fn visual_line_copy_flashes_copied() {
use crate::config::Action;
use crate::document::Selection;
use crate::input::vim::state::{VimState, VimSubMode};
let mut app = make_app();
app.editor.buffer.insert(0, "alpha\nbeta\n");
app.editor.mode = crate::editor::Mode::Rendered;
app.editor.refresh_parsed();
app.editor.selection = Some(Selection {
anchor: 0,
active: 0,
});
app.vim = Some(VimState {
sub_mode: VimSubMode::VisualLine,
visual_anchor: Some(0),
..Default::default()
});
app.dispatch_action(Action::Copy, 24, 80);
let text = app.transient.as_ref().map(|t| t.text.clone());
assert_eq!(
text.as_deref(),
Some("Copied"),
"V-LINE copy must flash Copied like the charwise path"
);
}
#[test]
fn linewise_paste_payload_keeps_the_line_structure() {
let buf = Buffer::from_str("alpha\nbeta\ngamma\n");
let range = 0..11;
assert_eq!(
linewise_paste_payload(&buf, &range, "foo".to_owned()),
Some("foo\n".to_owned()),
"charwise text gets a newline so it can't weld onto the next line"
);
assert_eq!(
linewise_paste_payload(&buf, &range, "x\ny\n".to_owned()),
Some("x\ny\n".to_owned()),
"already-linewise text is used as-is — no blank line inserted"
);
assert_eq!(
linewise_paste_payload(&buf, &range, String::new()),
None,
"nothing to paste must not replace the lines with a bare newline"
);
}
#[test]
fn linewise_paste_payload_leaves_a_final_line_without_a_newline() {
let buf = Buffer::from_str("alpha\nbeta");
let range = 6..10;
assert_eq!(
linewise_paste_payload(&buf, &range, "foo".to_owned()),
Some("foo".to_owned()),
);
}
#[test]
fn visual_line_paste_of_charwise_text_keeps_the_line_intact() {
use crate::document::Selection;
let mut app = make_app();
app.editor.buffer.insert(0, "alpha\nbeta\ngamma\n");
app.editor.mode = crate::editor::Mode::Rendered;
app.editor.refresh_parsed();
let sel = Selection {
anchor: 8,
active: 8,
};
let range = crate::editor::vim_ops::visual_line_char_range(&sel, &app.editor.buffer);
assert_eq!(range, 6..11, "V-LINE on line 1 widens to \"beta\\n\"");
let payload = linewise_paste_payload(&app.editor.buffer, &range, "REPLACED".to_owned())
.expect("non-empty clipboard");
app.editor.selection = Some(Selection {
anchor: range.start,
active: range.end,
});
edit_ops::paste_text(&mut app.editor, &payload, 24, 80);
assert_eq!(
app.editor.buffer.contents(),
"alpha\nREPLACED\ngamma\n",
"the line is replaced whole and `gamma` keeps its own row"
);
}
#[test]
fn entering_diff_mode_clears_an_open_vim_command_line() {
use crate::input::vim::state::{CmdLineKind, CmdLineState, VimState};
use crate::ui::HintContent;
let mut app = make_app();
app.editor.buffer.insert(0, "alpha\nbeta\n");
app.vim = Some(VimState {
cmdline: Some(CmdLineState::new(CmdLineKind::Ex)),
count: Some(3),
..Default::default()
});
app.enter_diff_mode("alpha\nGAMMA\n".to_owned());
let vim = app.vim.as_ref().unwrap();
assert!(vim.cmdline.is_none(), "command line must be cleared");
assert_eq!(vim.count, None, "pending parse must be reset");
assert!(matches!(app.hint_content(), HintContent::Chords(_)));
}
#[test]
fn entering_diff_mode_ends_a_live_incsearch_session() {
use crate::editor::vim_ops::update_incsearch;
use crate::input::vim::state::{CmdLineKind, CmdLineState, VimState};
let mut app = make_app();
app.editor.buffer.insert(0, "foo bar\nfoo\n");
app.editor.refresh_parsed();
let mut vim = VimState {
cmdline: Some(CmdLineState::new(CmdLineKind::SearchForward)),
..Default::default()
};
update_incsearch(&mut app.editor, &mut vim.incsearch, "foo", true, 24, 80);
assert!(vim.incsearch.is_some(), "live session while typing");
assert_eq!(app.editor.cursor.offset, 8, "parked on the live focus");
app.vim = Some(vim);
app.enter_diff_mode("foo bar\nGAMMA\n".to_owned());
let vim = app.vim.as_ref().unwrap();
assert!(
vim.incsearch.is_none(),
"session must not dangle on VimState — the next `/` would reuse its stale saved view",
);
assert!(vim.cmdline.is_none());
assert_eq!(app.editor.cursor.offset, 0, "pre-prompt cursor restored");
assert!(app.editor.search.is_none(), "transient session torn down");
}
#[test]
fn entering_diff_mode_reverts_a_live_substitute_preview() {
use crate::editor::vim_ops::update_substitute_preview;
use crate::input::vim::state::{CmdLineKind, CmdLineState, VimState};
let mut app = make_app();
app.editor.buffer.insert(0, "alpha\nbeta\n");
app.editor.refresh_parsed();
app.vim = Some(VimState {
cmdline: Some(CmdLineState::new(CmdLineKind::Ex)),
..Default::default()
});
update_substitute_preview(&mut app.editor, "%s/alpha/OMEGA/", None, 24, 80);
assert!(app.editor.substitute_preview.is_some(), "preview active");
assert!(app.editor.buffer.contents().contains("OMEGA"));
app.enter_diff_mode("alpha\nGAMMA\n".to_owned());
assert!(
app.editor.substitute_preview.is_none(),
"preview must revert on diff entry — its gates would otherwise hold forever",
);
assert_eq!(
app.editor.buffer.contents(),
"alpha\nbeta\n",
"diff is taken against the pristine buffer, not preview text",
);
assert!(app.editor.diff.is_some());
}
fn read_only_app(old: &str, new: &str) -> crate::app::App {
let mut app = make_app();
assert!(app.enter_read_only_diff(old.to_owned(), new.to_owned()));
app
}
#[test]
fn enter_read_only_diff_marks_the_review_and_seeds_the_old_side() {
let app = read_only_app("alpha\nbeta\n", "alpha\nBETA\n");
assert_eq!(app.editor.mode, crate::editor::Mode::Diff);
let diff = app.editor.diff.as_ref().expect("review installed");
assert!(diff.read_only);
assert_eq!(app.editor.buffer.contents(), "alpha\nbeta\n");
assert!(!app.editor.dirty);
}
#[test]
fn a_read_only_review_pushes_no_intro_modal() {
use crate::app::modal::DiffIntroModal;
let app = read_only_app("alpha\nbeta\n", "alpha\nBETA\n");
assert!(app.config.editor.show_diff_intro, "intro is on by default");
assert!(!app.modal_stack.contains::<DiffIntroModal>());
}
#[test]
fn enter_read_only_diff_declines_identical_files() {
let mut app = make_app();
assert!(!app.enter_read_only_diff("same\n".to_owned(), "same\n".to_owned()));
assert!(app.editor.diff.is_none());
assert_ne!(app.editor.mode, crate::editor::Mode::Diff);
}
#[test]
fn a_read_only_review_refuses_every_decision_action() {
use crate::config::Action;
for action in [
Action::DiffAcceptHunk,
Action::DiffRejectHunk,
Action::DiffAcceptAll,
Action::DiffRejectAll,
Action::DiffResetHunk,
] {
let mut app = read_only_app("alpha\nbeta\n", "alpha\nBETA\n");
app.dispatch_action(action.clone(), 40, 80);
let diff = app.editor.diff.as_ref().expect("still reviewing");
assert!(
diff.decisions
.iter()
.all(|d| *d == crate::diff::Decision::Pending),
"{action:?} changed a decision in a read-only review"
);
assert!(!app.should_quit, "{action:?} must not end the session");
}
}
#[test]
fn esc_quits_a_read_only_review_without_resolving() {
use crate::config::Action;
let mut app = read_only_app("alpha\nbeta\n", "alpha\nBETA\n");
app.dispatch_action(Action::DiffExit, 40, 80);
assert!(app.should_quit);
assert!(!app.diff_stop_walk(), "Esc moves on to the next file");
}
#[test]
fn quit_aborts_a_read_only_review_without_confirmation() {
use crate::app::modal::DiffQuitConfirmModal;
use crate::config::Action;
let mut app = read_only_app("alpha\nbeta\n", "alpha\nBETA\n");
app.dispatch_action(Action::Quit, 40, 80);
assert!(app.should_quit);
assert!(app.diff_stop_walk());
assert!(!app.modal_stack.contains::<DiffQuitConfirmModal>());
}
#[test]
fn a_read_only_review_asks_the_media_prompts_for_its_document() {
use crate::app::modal::DiagramsEnabledPromptModal;
let old = "# Doc\n\n```mermaid\ngraph TD;\nA-->B;\n```\n\nbefore\n";
let new = "# Doc\n\n```mermaid\ngraph TD;\nA-->B;\n```\n\nafter\n";
let app = read_only_app(old, new);
assert!(
matches!(
app.config.diagrams.enabled,
crate::config::DiagramsEnabled::Ask
),
"ask is the default policy this test depends on",
);
assert!(
app.modal_stack.contains::<DiagramsEnabledPromptModal>(),
"the diagrams prompt must be queued for the reviewed document",
);
}
#[test]
fn enter_diff_mode_with_intro_off_skips_intro_modal() {
use crate::app::modal::DiffIntroModal;
let mut app = make_app();
app.editor.buffer.insert(0, "alpha\nbeta\n");
app.config.editor.show_diff_intro = false;
app.enter_diff_mode("alpha\nGAMMA\n".to_owned());
assert_eq!(app.editor.mode, crate::editor::Mode::Diff);
assert!(!app.modal_stack.contains::<DiffIntroModal>());
}
#[test]
fn diff_accept_all_then_apply_swaps_resolved_rope() {
use crate::app::modal::DiffBulkConfirmModal;
let mut app = make_app();
app.editor.buffer.insert(0, "alpha\nbeta\ngamma\n");
app.enter_diff_mode("alpha\nBETA\ngamma\n".to_owned());
app.modal_stack
.remove_first::<crate::app::modal::DiffIntroModal>();
app.dispatch_diff_action(crate::config::Action::DiffAcceptAll, 24, 80);
assert!(app.modal_stack.contains::<DiffBulkConfirmModal>());
app.apply_diff_bulk_decision(crate::diff::Decision::Accepted);
app.apply_diff_resolution();
assert_eq!(app.editor.mode, crate::editor::Mode::Rendered);
assert!(app.editor.diff.is_none());
assert_eq!(app.editor.buffer.contents(), "alpha\nBETA\ngamma\n");
}
#[test]
fn apply_diff_resolution_records_single_merge_revert_entry() {
use crate::config::Action;
let mut app = app_in_diff("alpha\nbeta\ngamma\n", "alpha\nBETA\ngamma\n");
resolve_all(&mut app, crate::diff::Decision::Accepted);
app.apply_diff_resolution();
assert!(app.editor.diff.is_none());
assert_eq!(app.editor.buffer.contents(), "alpha\nBETA\ngamma\n");
assert_eq!(app.editor.history.undo_depth(), 1);
crate::editor::edit_ops::apply(&mut app.editor, Action::Undo, 24, 80);
assert_eq!(app.editor.buffer.contents(), "alpha\nbeta\ngamma\n");
crate::editor::edit_ops::apply(&mut app.editor, Action::Redo, 24, 80);
assert_eq!(app.editor.buffer.contents(), "alpha\nBETA\ngamma\n");
}
fn app_in_diff(buffer: &str, disk: &str) -> crate::app::App {
let mut app = make_app();
app.editor.buffer.insert(0, buffer);
app.enter_diff_mode(disk.to_owned());
app.modal_stack
.remove_first::<crate::app::modal::DiffIntroModal>();
app
}
fn resolve_all(app: &mut crate::app::App, decision: crate::diff::Decision) {
for d in app.editor.diff.as_mut().unwrap().decisions.iter_mut() {
*d = decision;
}
}
#[test]
fn resolving_a_diff_that_adds_an_image_queues_the_images_prompt() {
use crate::app::modal::ImagesEnabledPromptModal;
let mut app = app_in_diff("Just prose.\n", "Just prose.\n\n\n");
assert!(!app.modal_stack.contains::<ImagesEnabledPromptModal>());
resolve_all(&mut app, crate::diff::Decision::Accepted);
app.apply_diff_resolution();
assert!(app.editor.buffer.contents().contains(""));
assert!(
app.modal_stack.contains::<ImagesEnabledPromptModal>(),
"the merged document's images must raise the prompt that enables them",
);
assert!(app.images_dirty, "the image cache needs reconciling");
}
#[test]
fn resolving_a_diff_does_not_re_ask_an_answered_question() {
use crate::app::modal::ImagesEnabledPromptModal;
let mut app = app_in_diff("\n", "\n\n\n");
app.modal_stack.remove_first::<ImagesEnabledPromptModal>();
app.session_images_enabled = Some(false);
resolve_all(&mut app, crate::diff::Decision::Accepted);
app.apply_diff_resolution();
assert!(!app.modal_stack.contains::<ImagesEnabledPromptModal>());
}
#[test]
fn diff_denied_action_flashes_not_available() {
use crate::config::Action;
let mut app = app_in_diff("old text\n", "new text\n");
app.dispatch_action(Action::InsertChar('x'), 24, 80);
let text = app.transient.as_ref().map(|t| t.text.clone());
assert_eq!(text.as_deref(), Some("Not available during diff review"));
assert_eq!(app.editor.mode, crate::editor::Mode::Diff);
}
#[test]
fn diff_navigation_never_opens_confirm_modal_when_resolved() {
use crate::app::modal::DiffResolveConfirmModal;
use crate::config::Action;
use crate::diff::Decision;
let mut app = app_in_diff("a\nb\nc\nd\ne\n", "A\nb\nC\nd\nE\n");
resolve_all(&mut app, Decision::Accepted);
app.dispatch_diff_action(Action::DiffNext, 24, 80);
assert!(!app.modal_stack.contains::<DiffResolveConfirmModal>());
app.dispatch_diff_action(Action::DiffPrev, 24, 80);
assert!(!app.modal_stack.contains::<DiffResolveConfirmModal>());
assert!(
app.editor.diff.is_some(),
"navigation must not exit diff mode"
);
}
#[test]
fn diff_reject_all_gates_behind_bulk_confirm_then_overrides() {
use crate::app::modal::{DiffBulkConfirmModal, DiffResolveConfirmModal};
use crate::config::Action;
use crate::diff::Decision;
let mut app = app_in_diff("a\nb\nc\n", "A\nb\nC\n");
resolve_all(&mut app, Decision::Accepted);
app.dispatch_diff_action(Action::DiffRejectAll, 24, 80);
assert!(app.modal_stack.contains::<DiffBulkConfirmModal>());
assert!(
app.editor
.diff
.as_ref()
.unwrap()
.decisions
.iter()
.all(|d| *d == Decision::Accepted),
"bulk-confirm must not flip decisions before the user confirms",
);
app.dispatch_diff_action(Action::DiffRejectAll, 24, 80);
assert_eq!(app.modal_stack.count::<DiffBulkConfirmModal>(), 1);
app.apply_diff_bulk_decision(Decision::Rejected);
assert!(
app.editor
.diff
.as_ref()
.unwrap()
.decisions
.iter()
.all(|d| *d == Decision::Rejected),
"reject-all must override prior accepted decisions on confirm",
);
assert!(app.modal_stack.contains::<DiffResolveConfirmModal>());
}
#[test]
fn diff_bulk_confirm_dismissed_leaves_decisions_intact() {
use crate::app::modal::DiffBulkConfirmModal;
use crate::config::Action;
use crate::diff::Decision;
let mut app = app_in_diff("a\nb\nc\n", "A\nb\nC\n");
{
let d = app.editor.diff.as_mut().unwrap();
d.decisions[0] = Decision::Accepted;
d.decisions[1] = Decision::Rejected;
}
let before = app.editor.diff.as_ref().unwrap().decisions.clone();
app.dispatch_diff_action(Action::DiffAcceptAll, 24, 80);
assert!(app.modal_stack.contains::<DiffBulkConfirmModal>());
app.modal_stack.remove_first::<DiffBulkConfirmModal>();
assert_eq!(
app.editor.diff.as_ref().unwrap().decisions,
before,
"dismissing the bulk-confirm must not change any decision",
);
}
#[test]
fn diff_quit_warns_instead_of_discarding_immediately() {
use crate::app::modal::DiffQuitConfirmModal;
use crate::config::Action;
let mut app = app_in_diff("a\nb\nc\n", "A\nb\nC\n");
app.dispatch_action(Action::Quit, 24, 80);
assert!(
app.modal_stack.contains::<DiffQuitConfirmModal>(),
"Quit in diff mode must open the discard-confirm modal",
);
assert!(!app.should_quit, "Quit must not fire before confirmation");
assert!(app.editor.diff.is_some(), "the review must stay active");
app.dispatch_action(Action::Quit, 24, 80);
assert_eq!(app.modal_stack.count::<DiffQuitConfirmModal>(), 1);
}
#[test]
fn diff_esc_is_gated_on_full_resolution() {
use crate::app::modal::DiffResolveConfirmModal;
use crate::config::Action;
use crate::diff::Decision;
let mut app = app_in_diff("a\nb\nc\n", "A\nb\nC\n");
app.dispatch_diff_action(Action::DiffExit, 24, 80);
assert!(
app.editor.diff.is_some(),
"Esc with pending hunks must not exit diff mode",
);
assert!(!app.modal_stack.contains::<DiffResolveConfirmModal>());
resolve_all(&mut app, Decision::Accepted);
app.dispatch_diff_action(Action::DiffExit, 24, 80);
assert!(
app.editor.diff.is_some(),
"Esc with all hunks resolved must stay in diff mode until applied",
);
assert!(app.modal_stack.contains::<DiffResolveConfirmModal>());
}
#[test]
fn save_buffer_clears_dirty_on_success() {
let mut app = make_app();
let tmp = tempfile::NamedTempFile::new().expect("temp file");
app.editor.buffer = Buffer::for_new_file(tmp.path());
let len = app.editor.buffer.len_chars();
app.editor.buffer.insert_char(len, 'z');
app.editor.dirty = true;
app.save_buffer().expect("save");
assert!(!app.editor.dirty);
let on_disk = std::fs::read_to_string(tmp.path()).expect("read back");
assert!(on_disk.ends_with('z'));
}
#[test]
fn save_buffer_returns_err_when_buffer_has_no_path() {
let mut app = make_app();
assert!(app.editor.buffer.path().is_none());
app.editor.dirty = true;
let result = app.save_buffer();
assert!(result.is_err(), "unnamed buffer must fail to save");
assert!(app.editor.dirty, "failed save must leave dirty set");
}
#[test]
fn modal_wheel_delta_translates_scroll_direction() {
let scroll_up = MouseEvent {
kind: MouseEventKind::ScrollUp,
column: 0,
row: 0,
modifiers: CtKeyModifiers::NONE,
};
let scroll_down = MouseEvent {
kind: MouseEventKind::ScrollDown,
..scroll_up
};
let click = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
..scroll_up
};
assert_eq!(modal_wheel_delta(&scroll_up, 1), -1);
assert_eq!(modal_wheel_delta(&scroll_down, 1), 1);
assert_eq!(modal_wheel_delta(&scroll_down, 4), 4);
assert_eq!(modal_wheel_delta(&scroll_up, 0), -1);
assert_eq!(modal_wheel_delta(&click, 1), 0);
}
fn every_action() -> Vec<Action> {
let mut all = crate::config::keymap::EVERY_UNIT_ACTION.to_vec();
all.push(Action::InsertChar('a'));
all.push(Action::OpenDoc(crate::docs::DocId::Index));
all
}
#[test]
fn always_safe_is_exactly_the_scroll_openers_and_quit() {
use Action::*;
let expected = [
ScrollUp,
ScrollDown,
ScrollPageUp,
ScrollPageDown,
ScrollToTop,
ScrollToBottom,
Quit,
ShowCommandPalette,
ShowMarkdownCheatSheet,
ShowAbout,
CheckForUpdates,
OpenSettings,
OpenWelcome,
OpenKeybinds,
SwitchTheme,
CreateCustomTheme,
OpenConfigFolder,
];
for action in &expected {
assert!(action_caps(action).always_safe, "{action} should be safe");
assert!(diff_safe_action(action).is_some(), "{action} in diff");
assert!(search_safe_action(action).is_some(), "{action} in search");
assert!(readonly_safe_action(action), "{action} while read-only");
}
}
#[test]
fn read_only_navigation_never_mutates_or_needs_a_path() {
for action in every_action() {
let caps = action_caps(&action);
if caps.read_only_nav {
assert!(!caps.mutates_buffer, "{action} both reads and writes");
assert!(!caps.needs_path, "{action} both reads and needs a path");
}
if caps.always_safe {
assert!(!caps.mutates_buffer, "{action} is safe yet writes");
assert!(!caps.needs_path, "{action} is safe yet needs a path");
}
}
}
#[test]
fn diff_allows_only_its_own_commands_and_the_always_safe_set() {
for action in every_action() {
let allowed = diff_safe_action(&action).is_some();
let expected = action_caps(&action).always_safe
|| matches!(
action,
Action::DiffNext
| Action::DiffPrev
| Action::DiffAcceptHunk
| Action::DiffRejectHunk
| Action::DiffAcceptAll
| Action::DiffRejectAll
| Action::DiffResetHunk
| Action::DiffExit
);
assert_eq!(allowed, expected, "{action}");
}
}
#[test]
fn a_capturing_search_flow_admits_no_other_buffer_mutation() {
for action in every_action() {
if !action_caps(&action).mutates_buffer {
continue;
}
let allowed = search_safe_action(&action).is_some();
let expected = matches!(
action,
Action::SearchReplace | Action::SearchReplaceAll | Action::Undo | Action::Redo
);
assert_eq!(allowed, expected, "{action}");
}
}
#[test]
fn a_read_only_document_denies_every_write_and_allows_every_navigation() {
for action in every_action() {
let caps = action_caps(&action);
let allowed = readonly_safe_action(&action);
if caps.mutates_buffer || caps.needs_path {
assert!(!allowed, "{action} must be denied while read-only");
} else {
assert!(allowed, "{action} must be allowed while read-only");
}
if caps.navigates_away {
assert!(allowed, "{action} navigates and must stay available");
}
}
}
}