mod app;
mod blame;
mod color;
mod config;
mod csv_preview;
mod dashboard;
mod input;
mod jless;
mod keybindings;
mod structured_preview;
mod syntax;
#[cfg(test)]
mod test_utils;
mod time_format;
mod toasts;
mod ui;
mod views;
use crate::dashboard::{Dashboard, DashboardConfig, DashboardSelection};
use crate::input::handle_app_key;
use crate::keybindings::{DashboardAction, DashboardFilterAction, Dispatch, Keybindings};
use crate::syntax::{list_syntax_themes, SyntaxEngine};
use crate::time_format::TimeFormatter;
use anyhow::{anyhow, Context, Result};
use app::{App, ViewMode};
use clap::{Parser, Subcommand};
use crossterm::{
event::{
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind,
KeyModifiers, MouseButton, MouseEventKind,
},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use oyo_core::{multi::FileSide, DirectoryScanOptions, LineKind, MultiFileDiff, ViewLine};
use ratatui::prelude::*;
use std::fs::OpenOptions;
use std::io::{self, IsTerminal};
use std::path::{Component, Path, PathBuf};
use std::process::Command as ProcessCommand;
use std::time::{Duration, Instant};
const INDEX_REF: &str = "INDEX";
const MAX_COALESCED_MOUSE_SCROLL_READS: usize = 4096;
const MAX_DIFF_MOUSE_SCROLL_ACTIONS_PER_FRAME: isize = 16;
const MAX_DISCRETE_MOUSE_SCROLL_ACTIONS_PER_FRAME: isize = 8;
const MAX_EXIT_INPUT_DRAIN_EVENTS: usize = 65_536;
const EXIT_INPUT_DRAIN: Duration = Duration::from_millis(100);
const MOUSE_SCROLL_FRAME: Duration = Duration::from_millis(16);
type TuiBackend = CrosstermBackend<Box<dyn io::Write>>;
type TuiTerminal = Terminal<TuiBackend>;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum MouseScrollTarget {
CommandPalette,
FileSearch,
FilePanel,
Step,
Diff,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct PendingMouseScroll {
target: MouseScrollTarget,
delta: isize,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct BlockedMouseScroll {
target: MouseScrollTarget,
direction: isize,
}
#[derive(Parser, Debug)]
#[command(name = "oy")]
#[command(author, version, about = "A terminal diff viewer")]
#[command(args_conflicts_with_subcommands = true)]
struct Args {
#[command(subcommand)]
command: Option<Command>,
#[arg(num_args = 0..)]
paths: Vec<PathBuf>,
#[arg(short, long, default_value = "unified")]
view: CliViewMode,
#[arg(short, long, default_value = "200")]
speed: u64,
#[arg(long)]
autoplay: bool,
#[arg(long, value_enum, global = true)]
theme_mode: Option<CliThemeMode>,
#[arg(long, global = true)]
theme_name: Option<String>,
#[arg(long = "config", value_name = "FILE", global = true)]
config_files: Vec<PathBuf>,
#[arg(long, global = true)]
syntax_theme: Option<String>,
#[arg(long, value_name = "FILE")]
dump_scopes: Option<PathBuf>,
#[arg(long, global = true, conflicts_with = "no_step")]
step: bool,
#[arg(long, global = true, conflicts_with = "step")]
no_step: bool,
#[arg(long, alias = "cached", conflicts_with = "range")]
staged: bool,
#[arg(long, value_name = "RANGE", conflicts_with = "staged")]
range: Option<String>,
#[arg(long, value_name = "FILE", global = true)]
review_output_file: Option<PathBuf>,
#[arg(long, requires = "review_output_file", global = true)]
no_print_review: bool,
#[arg(long, global = true)]
no_review_persist: bool,
#[arg(long, global = true, conflicts_with = "no_git_ignore")]
git_ignore: bool,
#[arg(long, global = true, conflicts_with = "git_ignore")]
no_git_ignore: bool,
#[arg(long, value_name = "GLOBS", global = true)]
ignore_glob: Vec<String>,
#[arg(long, global = true)]
clear_review_session: bool,
}
#[derive(Debug, Subcommand)]
enum Command {
Themes,
SyntaxThemes,
View {
#[arg(long, default_value = "200")]
limit: usize,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
enum CliThemeMode {
Dark,
Light,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
enum CliViewMode {
Unified,
#[value(alias = "sbs")]
Split,
#[value(alias = "evo")]
Evolution,
Blame,
Preview,
}
impl From<CliViewMode> for ViewMode {
fn from(mode: CliViewMode) -> Self {
match mode {
CliViewMode::Unified => ViewMode::UnifiedPane,
CliViewMode::Split => ViewMode::Split,
CliViewMode::Evolution => ViewMode::Evolution,
CliViewMode::Blame => ViewMode::Blame,
CliViewMode::Preview => ViewMode::Preview,
}
}
}
enum InputMode {
GitExternal {
display_path: PathBuf,
old_file: PathBuf,
new_file: PathBuf,
},
TwoPaths {
old_path: PathBuf,
new_path: PathBuf,
},
GitFile { path: PathBuf },
GitUncommitted,
GitStaged,
GitRange { from: String, to: String },
None,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AppExit {
Quit,
OpenDashboard,
}
fn detect_input_mode(paths: &[PathBuf]) -> InputMode {
if paths.len() == 7 {
let display_path = paths[0].clone();
let old_file = paths[1].clone();
let new_file = paths[4].clone();
InputMode::GitExternal {
display_path,
old_file,
new_file,
}
} else if paths.len() >= 2 {
InputMode::TwoPaths {
old_path: paths[0].clone(),
new_path: paths[1].clone(),
}
} else if paths.len() == 1 {
InputMode::GitFile {
path: paths[0].clone(),
}
} else if paths.is_empty() {
InputMode::GitUncommitted
} else {
InputMode::None
}
}
fn parse_range(range: &str) -> Result<(String, String)> {
if let Some((from, to)) = range.split_once("...") {
if from.is_empty() || to.is_empty() {
anyhow::bail!("Range must be in the form A..B or A...B");
}
if to.contains("..") {
anyhow::bail!("Range must be in the form A..B or A...B");
}
return Ok((from.to_string(), to.to_string()));
}
if let Some((from, to)) = range.split_once("..") {
if from.is_empty() || to.is_empty() {
anyhow::bail!("Range must be in the form A..B or A...B");
}
if to.contains("..") {
anyhow::bail!("Range must be in the form A..B or A...B");
}
return Ok((from.to_string(), to.to_string()));
}
anyhow::bail!("Range must be in the form A..B or A...B");
}
fn split_ignore_globs(values: &[String]) -> Vec<String> {
values
.iter()
.flat_map(|value| value.split('|'))
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
.collect()
}
fn looks_like_jj_external_diff_dirs(old_path: &Path, new_path: &Path) -> bool {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let old_abs = if old_path.is_absolute() {
old_path.to_path_buf()
} else {
cwd.join(old_path)
};
let new_abs = if new_path.is_absolute() {
new_path.to_path_buf()
} else {
cwd.join(new_path)
};
if old_abs.file_name().and_then(|name| name.to_str()) != Some("left") {
return false;
}
if new_abs.file_name().and_then(|name| name.to_str()) != Some("right") {
return false;
}
let Some(old_parent) = old_abs.parent() else {
return false;
};
if new_abs.parent() != Some(old_parent) {
return false;
}
old_parent
.file_name()
.and_then(|name| name.to_str())
.map(|name| name.starts_with("jj-diff-"))
.unwrap_or(false)
}
fn is_jj_diff_dir(path: &Path) -> bool {
path.file_name()
.and_then(|name| name.to_str())
.map(|name| name.starts_with("jj-diff-"))
.unwrap_or(false)
}
#[cfg(target_os = "linux")]
fn parent_pid(pid: u32) -> Option<u32> {
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
let (_, after_name) = stat.rsplit_once(") ")?;
let mut fields = after_name.split_whitespace();
fields.next()?;
fields.next()?.parse().ok()
}
#[cfg(not(target_os = "linux"))]
fn parent_pid(_pid: u32) -> Option<u32> {
None
}
#[cfg(target_os = "linux")]
fn process_cwd(pid: u32) -> Option<PathBuf> {
std::fs::read_link(format!("/proc/{pid}/cwd")).ok()
}
#[cfg(not(target_os = "linux"))]
fn process_cwd(_pid: u32) -> Option<PathBuf> {
None
}
fn infer_external_diff_workspace_root() -> Option<PathBuf> {
let mut pid = parent_pid(std::process::id())?;
for _ in 0..8 {
if let Some(cwd) = process_cwd(pid) {
if cwd.is_dir() && !is_jj_diff_dir(&cwd) {
return Some(cwd);
}
}
pid = parent_pid(pid)?;
}
None
}
fn directory_scan_options(
config: &config::Config,
args: &Args,
old_path: &Path,
new_path: &Path,
) -> DirectoryScanOptions {
let vcs_external_diff = looks_like_jj_external_diff_dirs(old_path, new_path);
let mut git_ignore = match config.files.scan.git_ignore {
config::GitIgnoreMode::Auto => !vcs_external_diff,
config::GitIgnoreMode::On => true,
config::GitIgnoreMode::Off => false,
};
if args.git_ignore {
git_ignore = true;
}
if args.no_git_ignore {
git_ignore = false;
}
let mut ignore_globs = config.files.scan.ignore_globs.clone();
ignore_globs.extend(split_ignore_globs(&args.ignore_glob));
DirectoryScanOptions {
git_ignore,
ignore_globs,
}
}
fn install_panic_terminal_restore() {
let default_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let _ = execute!(io::stderr(), DisableMouseCapture);
drain_queued_input_events();
let _ = disable_raw_mode();
let _ = execute!(io::stderr(), LeaveAlternateScreen);
default_hook(info);
}));
}
fn setup_terminal() -> Result<TuiTerminal> {
enable_raw_mode()?;
let mut stdout: Box<dyn io::Write> = if io::stdout().is_terminal() {
Box::new(io::stdout())
} else {
match OpenOptions::new().read(true).write(true).open("/dev/tty") {
Ok(file) => Box::new(file),
Err(_) => Box::new(io::stdout()),
}
};
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let terminal = Terminal::new(backend)?;
Ok(terminal)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum EditorSide {
Old,
New,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct EditorFocus {
side: EditorSide,
line: usize,
}
struct EditorTarget {
path: PathBuf,
line: Option<usize>,
cwd: Option<PathBuf>,
refresh_after_edit: bool,
}
fn resolve_editor_command(config: &config::EditorConfig) -> String {
fn non_empty(value: Option<String>) -> Option<String> {
value
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
non_empty(config.command.clone())
.or_else(|| non_empty(std::env::var("VISUAL").ok()))
.or_else(|| non_empty(std::env::var("EDITOR").ok()))
.unwrap_or_else(|| "vi".to_string())
}
fn current_editor_target(app: &mut App, needs_line: bool) -> Result<Option<EditorTarget>> {
let focus = if needs_line {
current_editor_focus(app)
} else {
None
};
let side = focus.map(|focus| focus.side).unwrap_or(EditorSide::New);
let line = focus.map(|focus| focus.line);
let file_index = app.multi_diff.selected_index;
let file = match app.multi_diff.current_file() {
Some(file) => file.clone(),
None => return Ok(None),
};
let display_path = match side {
EditorSide::Old => file.old_path.clone().unwrap_or_else(|| file.path.clone()),
EditorSide::New => file.path.clone(),
};
let file_side = match side {
EditorSide::Old => FileSide::Old,
EditorSide::New => FileSide::New,
};
if let Some(path) = app.multi_diff.existing_source_path(file_index, file_side) {
return Ok(Some(EditorTarget {
path,
line,
cwd: app.multi_diff.repo_root().map(Path::to_path_buf),
refresh_after_edit: true,
}));
}
let Some((old_content, new_content)) = app.multi_diff.file_contents(file_index) else {
return Ok(None);
};
let content = match side {
EditorSide::Old => old_content,
EditorSide::New => new_content,
};
let path = write_editor_snapshot(&display_path, side, content)?;
Ok(Some(EditorTarget {
path,
line,
cwd: None,
refresh_after_edit: false,
}))
}
fn editor_needs_line(config: &config::EditorConfig) -> bool {
if config.open_at_line {
return true;
}
config
.args
.as_ref()
.map(|args| args.iter().any(|arg| arg.contains("{line}")))
.unwrap_or(false)
}
fn render_editor_template(template: &str, line: Option<usize>, path: &Path) -> String {
let file = path.to_string_lossy();
let line = line.unwrap_or(1).to_string();
template.replace("{file}", &file).replace("{line}", &line)
}
fn render_editor_args(
config: &config::EditorConfig,
line: Option<usize>,
path: &Path,
) -> Vec<String> {
if let Some(args) = &config.args {
return args
.iter()
.map(|arg| render_editor_template(arg, line, path))
.collect();
}
let mut args = Vec::new();
if config.open_at_line {
if let Some(line) = line {
args.push(format!("+{}", line));
}
}
args.push(path.to_string_lossy().into_owned());
args
}
fn snapshot_rel_path(path: &Path) -> PathBuf {
let rel = path.components().filter_map(|component| match component {
Component::Normal(part) => Some(part),
_ => None,
});
let mut out = PathBuf::new();
for part in rel {
out.push(part);
}
if out.as_os_str().is_empty() {
out.push("file");
}
out
}
fn write_editor_snapshot(display_path: &Path, side: EditorSide, content: &str) -> Result<PathBuf> {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or(0);
let side_dir = match side {
EditorSide::Old => "old",
EditorSide::New => "new",
};
let mut path = std::env::temp_dir()
.join("oy-editor")
.join(format!("{}-{nanos}", std::process::id()))
.join(side_dir);
path.push(snapshot_rel_path(display_path));
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&path, content)?;
if let Ok(metadata) = std::fs::metadata(&path) {
let mut permissions = metadata.permissions();
permissions.set_readonly(true);
let _ = std::fs::set_permissions(&path, permissions);
}
Ok(path)
}
fn current_editor_focus(app: &mut App) -> Option<EditorFocus> {
let frame = app.animation_frame();
let view = app.current_view_with_frame(frame);
if app.stepping {
return primary_editor_focus(&view)
.or_else(|| active_editor_focus(&view))
.or_else(|| visible_editor_focus(app, &view));
}
let hunk_cursor = {
let state = app.multi_diff.current_navigator().state();
state.last_nav_was_hunk && state.cursor_change.is_some()
};
if hunk_cursor {
primary_editor_focus(&view).or_else(|| visible_editor_focus(app, &view))
} else {
visible_editor_focus(app, &view).or_else(|| primary_editor_focus(&view))
}
}
fn primary_editor_focus(view: &[ViewLine]) -> Option<EditorFocus> {
view.iter()
.find(|line| line.is_primary_active)
.and_then(editor_focus_for_line)
}
fn active_editor_focus(view: &[ViewLine]) -> Option<EditorFocus> {
view.iter()
.find(|line| line.is_active)
.and_then(editor_focus_for_line)
}
fn editor_focus_for_line(line: &ViewLine) -> Option<EditorFocus> {
if let Some(line_number) = line.new_line.filter(|line| *line > 0) {
return Some(EditorFocus {
side: EditorSide::New,
line: line_number,
});
}
line.old_line
.filter(|line| *line > 0)
.map(|line_number| EditorFocus {
side: EditorSide::Old,
line: line_number,
})
}
fn visible_editor_focus(app: &App, view: &[ViewLine]) -> Option<EditorFocus> {
let target = app.render_scroll_offset();
match app.view_mode {
ViewMode::Split => visible_split_editor_focus(app, view, target),
ViewMode::Evolution => view
.iter()
.filter(|line| !matches!(line.kind, LineKind::Deleted | LineKind::PendingDelete))
.enumerate()
.skip_while(|(idx, _)| *idx < target)
.find_map(|(_, line)| editor_focus_for_line(line)),
_ => view.iter().skip(target).find_map(editor_focus_for_line),
}
}
fn visible_split_editor_focus(app: &App, view: &[ViewLine], target: usize) -> Option<EditorFocus> {
let mut old_idx = 0usize;
let mut new_idx = 0usize;
let mut old_match = None;
let mut new_match = None;
for line in view {
let fold_line = crate::app::is_fold_line(line);
let old_present = line.old_line.is_some() || fold_line;
let new_present = (line.new_line.is_some()
&& !matches!(line.kind, LineKind::Deleted | LineKind::PendingDelete))
|| fold_line;
if old_present || (app.split_align_lines && new_present) {
if old_idx >= target && old_match.is_none() {
old_match = line
.old_line
.filter(|line| *line > 0)
.map(|line_number| EditorFocus {
side: EditorSide::Old,
line: line_number,
});
}
old_idx += 1;
}
if new_present || (app.split_align_lines && old_present) {
if new_idx >= target && new_match.is_none() {
new_match = line
.new_line
.filter(|line| *line > 0)
.map(|line_number| EditorFocus {
side: EditorSide::New,
line: line_number,
})
.or_else(|| editor_focus_for_line(line));
}
new_idx += 1;
}
if new_match.is_some() {
break;
}
}
new_match.or(old_match)
}
fn drain_queued_input_events() {
let started = Instant::now();
for _ in 0..MAX_EXIT_INPUT_DRAIN_EVENTS {
if started.elapsed() >= EXIT_INPUT_DRAIN {
break;
}
match event::poll(Duration::from_millis(1)) {
Ok(true) => {
if event::read().is_err() {
break;
}
}
Ok(false) | Err(_) => break,
}
}
}
fn restore_terminal(terminal: &mut TuiTerminal) -> Result<()> {
execute!(terminal.backend_mut(), DisableMouseCapture)?;
drain_queued_input_events();
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
Ok(())
}
fn suspend_terminal_for_child(terminal: &mut TuiTerminal) -> Result<()> {
restore_terminal(terminal)
}
fn resume_terminal_after_child(terminal: &mut TuiTerminal) -> Result<()> {
enable_raw_mode()?;
execute!(
terminal.backend_mut(),
EnterAlternateScreen,
EnableMouseCapture
)?;
terminal.clear()?;
Ok(())
}
fn run_editor_command(
command: &str,
args: &[String],
cwd: Option<&Path>,
) -> std::io::Result<std::process::ExitStatus> {
#[cfg(windows)]
let mut child = {
let mut parts = command.split_whitespace();
let exe = parts.next().unwrap_or(command);
let mut cmd = ProcessCommand::new(exe);
cmd.args(parts);
cmd
};
#[cfg(not(windows))]
let mut child = {
let mut cmd = ProcessCommand::new("sh");
cmd.arg("-c")
.arg(format!("exec {} \"$@\"", command))
.arg("oy-editor");
cmd
};
child.args(args);
if let Some(cwd) = cwd {
child.current_dir(cwd);
}
child.status()
}
fn open_current_file_in_editor(
terminal: &mut TuiTerminal,
app: &mut App,
config: &config::EditorConfig,
) -> Result<()> {
let Some(target) = current_editor_target(app, editor_needs_line(config))? else {
return Ok(());
};
let command = resolve_editor_command(config);
let args = render_editor_args(config, target.line, &target.path);
suspend_terminal_for_child(terminal)?;
let editor_result = run_editor_command(&command, &args, target.cwd.as_deref());
let resume_result = resume_terminal_after_child(terminal);
resume_result?;
if editor_result.is_ok() && target.refresh_after_edit {
app.refresh_current_file();
}
Ok(())
}
fn apply_config_to_app(app: &mut App, config: &config::Config, args: &Args, light_mode: bool) {
let mut keybinding_warnings = Vec::new();
app.keybindings =
Keybindings::from_config_with_warnings(&config.keybindings, &mut keybinding_warnings);
for warning in keybinding_warnings {
eprintln!("Warning: {warning}");
}
app.zen_mode = config.ui.zen;
app.animation_enabled = config.playback.animation;
app.animation_duration = config.playback.animation_duration;
app.file_panel_visible = config.files.panel_visible;
app.file_panel_width = config.files.panel_width;
app.file_panel_position = config.files.panel_position;
app.file_count_mode = config.files.counts;
app.auto_center = config.ui.auto_center;
app.watch = config.ui.watch;
app.overscroll = config.ui.overscroll;
app.topbar = config.ui.topbar;
app.line_wrap = config.ui.line_wrap;
app.set_fold_context_mode(config.ui.fold_context);
app.scrollbar_visible = config.ui.scrollbar;
app.strikethrough_deletions = config.ui.strikethrough_deletions;
app.gutter_signs = config.ui.gutter_signs;
app.toasts_enabled = config.ui.toasts.enabled;
app.toast_position = config.ui.toasts.position.toast_position();
app.preview_change_bars = config.ui.diff.preview_change_bars;
app.diff_bg = config.ui.diff.bg;
app.diff_fg = config.ui.diff.fg;
app.diff_highlight = config.ui.diff.highlight;
app.diff_defer = config.ui.diff.defer;
app.diff_idle_ms = config.ui.diff.idle_ms;
app.diff_extent_marker = config.ui.diff.extent_marker;
app.diff_extent_marker_scope = config.ui.diff.extent_marker_scope;
app.diff_extent_marker_context = config.ui.diff.extent_marker_context;
app.blame_enabled = config.ui.blame.enabled;
app.blame_mode = config.ui.blame.mode;
app.blame_hunk_hint_enabled = config.ui.blame.hunk_hint;
app.blame_hunk_hint_enabled = config.ui.blame.hunk_hint;
app.syntax_mode = config.ui.syntax.mode;
app.syntax_theme = config.ui.syntax.theme.clone();
app.syntax_warmup_active_lines = config.ui.syntax.warmup.active_lines;
app.syntax_warmup_pending_lines = config.ui.syntax.warmup.pending_lines;
app.syntax_warmup_idle_lines = config.ui.syntax.warmup.idle_lines;
app.syntax_warmup_debounce_ms = config.ui.syntax.warmup.debounce_ms;
app.unified_modified_step_mode = config.ui.unified.modified_step_mode;
app.split_align_lines = config.ui.split.align_lines;
app.split_align_fill = config.ui.split.align_fill.clone();
app.evo_syntax = config.ui.evo.syntax;
app.auto_step_on_enter = config.playback.auto_step_on_enter;
app.auto_step_blank_files = config.playback.auto_step_blank_files;
app.no_step_auto_jump_on_enter = config.no_step.auto_jump_on_enter;
app.review_mention_file_scope = config.comments.mentions.file_scope;
app.review_mention_finder = config.comments.mentions.finder;
app.review_hooks = config.review.hooks.clone();
app.review_actions = config.review.actions.clone();
app.selection_actions = config.selection.actions.clone();
app.hunk_wrap = config.navigation.wrap.hunk;
app.step_wrap = config.navigation.wrap.step;
app.primary_marker = config.ui.primary_marker.clone();
app.primary_marker_right = config
.ui
.primary_marker_right
.clone()
.unwrap_or_else(|| "◀".to_string());
app.extent_marker = config.ui.extent_marker_left().to_string();
app.extent_marker_right = config
.ui
.extent_marker_right
.clone()
.unwrap_or_else(|| "▐".to_string());
app.extent_marker_deleted = config.ui.extent_marker_deleted.clone();
app.theme = config.ui.theme.resolve(light_mode);
app.time_format = TimeFormatter::new(&config.ui.time);
app.theme_is_light = light_mode;
if args.step {
app.stepping = true;
} else if args.no_step {
app.stepping = false;
} else {
app.stepping = config.ui.stepping;
}
if !app.stepping {
app.enter_no_step_mode();
}
app.handle_file_enter();
}
fn emit_review_output(
review_output: Option<String>,
review_output_file: Option<&PathBuf>,
print_review: bool,
) -> Result<()> {
let output = review_output.unwrap_or_default();
if let Some(path) = review_output_file {
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent).with_context(|| {
format!(
"Failed to create review output directory: {}",
parent.display()
)
})?;
}
}
std::fs::write(path, &output)
.with_context(|| format!("Failed to write review output file: {}", path.display()))?;
}
if print_review && !output.trim().is_empty() {
println!("{output}");
}
Ok(())
}
fn build_diff_from_input_mode(
input_mode: &InputMode,
config: &config::Config,
args: &Args,
) -> Result<Option<(MultiFileDiff, Option<String>)>> {
let (multi_diff, git_branch) = match input_mode {
InputMode::GitExternal {
display_path,
old_file,
new_file,
} => {
let old_bytes = if old_file.to_string_lossy() == "/dev/null" {
Vec::new()
} else {
std::fs::read(old_file)
.context(format!("Failed to read old file: {}", old_file.display()))?
};
let new_bytes = if new_file.to_string_lossy() == "/dev/null" {
Vec::new()
} else {
std::fs::read(new_file)
.context(format!("Failed to read new file: {}", new_file.display()))?
};
let branch =
oyo_core::git::get_current_branch(&std::env::current_dir().unwrap_or_default())
.ok();
let cwd = std::env::current_dir().unwrap_or_default();
let new_source = cwd.join(display_path);
let diff = MultiFileDiff::from_file_pair_with_sources(
display_path.clone(),
old_bytes,
new_bytes,
None,
Some(new_source),
);
(diff, branch)
}
InputMode::TwoPaths { old_path, new_path } => {
let diff = if old_path.is_dir() && new_path.is_dir() {
let scan_options = directory_scan_options(config, args, old_path, new_path);
let mut diff =
MultiFileDiff::from_directories_with_options(old_path, new_path, &scan_options)
.context("Failed to create diff from directories")?;
if looks_like_jj_external_diff_dirs(old_path, new_path) {
if let Some(root) = infer_external_diff_workspace_root() {
diff.set_source_roots(root.clone(), root);
} else {
diff.clear_source_roots();
}
}
diff
} else {
let old_bytes = if old_path.to_string_lossy() == "/dev/null" {
Vec::new()
} else {
std::fs::read(old_path)
.context(format!("Failed to read: {}", old_path.display()))?
};
let new_bytes = if new_path.to_string_lossy() == "/dev/null" {
Vec::new()
} else {
std::fs::read(new_path)
.context(format!("Failed to read: {}", new_path.display()))?
};
let old_source =
(old_path.to_string_lossy() != "/dev/null").then(|| old_path.clone());
let new_source =
(new_path.to_string_lossy() != "/dev/null").then(|| new_path.clone());
MultiFileDiff::from_file_pair_with_sources(
new_path.clone(),
old_bytes,
new_bytes,
old_source,
new_source,
)
};
(diff, None)
}
InputMode::GitFile { path } => {
let cwd = std::env::current_dir().unwrap_or_default();
if !oyo_core::git::is_git_repo(&cwd) {
anyhow::bail!(
"Not in a git repository.\n\
\n\
Usage: oy <file>\n\
\n\
Or use: oy <old_file> <new_file>"
);
}
let repo_root =
oyo_core::git::get_repo_root(&cwd).context("Failed to get git repository root")?;
let abs_path = if path.is_absolute() {
path.clone()
} else {
cwd.join(path)
};
if abs_path.exists() && abs_path.is_dir() {
anyhow::bail!("Expected a file path: {}", path.display());
}
let rel_path = abs_path.strip_prefix(&repo_root).with_context(|| {
format!("Path is outside the git repository: {}", path.display())
})?;
let head_exists =
oyo_core::git::get_file_at_commit_size(&repo_root, "HEAD", rel_path).is_some();
let work_exists = abs_path.exists();
if !head_exists && !work_exists {
anyhow::bail!("File not found in HEAD or working tree: {}", path.display());
}
let old_bytes = if head_exists {
oyo_core::git::get_head_content_bytes(&repo_root, rel_path)
.context("Failed to read file from HEAD")?
} else {
Vec::new()
};
let new_bytes = if work_exists {
std::fs::read(&abs_path)
.context(format!("Failed to read: {}", abs_path.display()))?
} else {
Vec::new()
};
let diff = MultiFileDiff::from_file_pair_with_sources(
rel_path.to_path_buf(),
old_bytes,
new_bytes,
None,
Some(abs_path),
);
let branch = oyo_core::git::get_current_branch(&repo_root).ok();
(diff, branch)
}
InputMode::GitUncommitted => {
let cwd = std::env::current_dir().unwrap_or_default();
if !oyo_core::git::is_git_repo(&cwd) {
anyhow::bail!(
"Not in a git repository.\n\
\n\
Usage: oy <old_file> <new_file>\n\
\n\
Or run from a git repository to diff uncommitted changes."
);
}
let repo_root =
oyo_core::git::get_repo_root(&cwd).context("Failed to get git repository root")?;
let changes = oyo_core::git::get_uncommitted_changes(&repo_root)
.context("Failed to get uncommitted changes")?;
let branch = oyo_core::git::get_current_branch(&repo_root).ok();
let diff = MultiFileDiff::from_git_changes(repo_root, changes)
.context("Failed to create diff from git changes")?;
(diff, branch)
}
InputMode::GitStaged => {
let cwd = std::env::current_dir().unwrap_or_default();
if !oyo_core::git::is_git_repo(&cwd) {
anyhow::bail!(
"Not in a git repository.\n\
\n\
Usage: oy --staged\n\
\n\
Or run from a git repository."
);
}
let repo_root =
oyo_core::git::get_repo_root(&cwd).context("Failed to get git repository root")?;
let changes = oyo_core::git::get_staged_changes(&repo_root)
.context("Failed to get staged changes")?;
let branch = oyo_core::git::get_current_branch(&repo_root).ok();
let diff = MultiFileDiff::from_git_staged(repo_root, changes)
.context("Failed to create diff from staged changes")?;
(diff, branch)
}
InputMode::GitRange { from, to } => {
let cwd = std::env::current_dir().unwrap_or_default();
if !oyo_core::git::is_git_repo(&cwd) {
anyhow::bail!(
"Not in a git repository.\n\
\n\
Usage: oy --range A..B\n\
\n\
Or run from a git repository."
);
}
let repo_root =
oyo_core::git::get_repo_root(&cwd).context("Failed to get git repository root")?;
let is_index_from = from == INDEX_REF;
let is_index_to = to == INDEX_REF;
let (_changes, diff) = if is_index_from || is_index_to {
let (commit, to_index) = if is_index_to {
(from.clone(), true)
} else {
(to.clone(), false)
};
let reverse = !to_index;
let changes =
oyo_core::git::get_changes_between_index(&repo_root, &commit, reverse)
.context("Failed to get index range changes")?;
let diff = MultiFileDiff::from_git_index_range(
repo_root.clone(),
changes.clone(),
commit,
to_index,
)
.context("Failed to create diff from index range")?;
(changes, diff)
} else {
let changes = oyo_core::git::get_changes_between(&repo_root, from, to)
.context("Failed to get range changes")?;
let diff = MultiFileDiff::from_git_range(
repo_root.clone(),
changes.clone(),
from.clone(),
to.clone(),
)
.context("Failed to create diff from range")?;
(changes, diff)
};
let branch = oyo_core::git::get_current_branch(&repo_root).ok();
(diff, branch)
}
InputMode::None => {
anyhow::bail!(
"Usage: oy <old_file> <new_file>\n\
Usage: oy <file>\n\
\n\
Or run from a git repository to diff uncommitted changes."
);
}
};
Ok(Some((multi_diff, git_branch)))
}
fn main() -> Result<()> {
install_panic_terminal_restore();
let args = Args::parse();
let view_limit = match args.command {
Some(Command::Themes) => {
for name in config::list_ui_themes() {
println!("{name}");
}
return Ok(());
}
Some(Command::SyntaxThemes) => {
for name in list_syntax_themes() {
println!("{name}");
}
return Ok(());
}
Some(Command::View { limit }) => Some(limit),
None => None,
};
let mut config = if args.config_files.is_empty() {
config::Config::load()
} else {
config::Config::load_with_extra(&args.config_files).map_err(|e| anyhow!(e))?
};
if let Some(path) = args.dump_scopes.as_deref() {
if let Some(name) = args.theme_name.as_deref() {
config.ui.theme.name = Some(name.to_string());
}
if let Some(name) = args.syntax_theme.as_deref() {
config.ui.syntax.theme = name.to_string();
}
let light_mode = match args.theme_mode {
Some(CliThemeMode::Light) => true,
Some(CliThemeMode::Dark) => false,
None => config.ui.theme.is_light_mode(),
};
let content =
std::fs::read_to_string(path).context(format!("Failed to read: {}", path.display()))?;
let file_name = path.to_string_lossy();
let engine = SyntaxEngine::new(&config.ui.syntax.theme, light_mode);
println!("syntax: {}", engine.syntax_name_for_file(&file_name));
let mut entries: Vec<(String, usize)> = engine
.collect_scopes(&content, &file_name)
.into_iter()
.collect();
entries.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
for (scope, count) in entries {
println!("{count}\t{scope}");
}
return Ok(());
}
if let Some(name) = args.theme_name.as_deref() {
config.ui.theme.name = Some(name.to_string());
}
if let Some(name) = args.syntax_theme.as_deref() {
config.ui.syntax.theme = name.to_string();
}
if config.ui.syntax.theme.trim().is_empty() {
if let Some(name) = config.ui.theme.name.clone() {
config.ui.syntax.theme = name;
} else {
config.ui.syntax.theme = "ansi".to_string();
}
}
MultiFileDiff::set_diff_max_bytes(config.ui.diff.max_bytes);
MultiFileDiff::set_full_context_max_bytes(config.ui.diff.full_context_max_bytes);
MultiFileDiff::set_diff_defer(config.ui.diff.defer);
let light_mode = match args.theme_mode {
Some(CliThemeMode::Light) => true,
Some(CliThemeMode::Dark) => false,
None => config.ui.theme.is_light_mode(),
};
if let Some(limit) = view_limit {
let mut terminal = setup_terminal()?;
let mut input_mode = match run_commit_picker(&mut terminal, &config, light_mode, limit)? {
Some(mode) => mode,
None => {
restore_terminal(&mut terminal)?;
return Ok(());
}
};
let mut exit_message: Option<String> = None;
let mut review_output: Option<String> = None;
let mut review_hook_warnings = Vec::new();
loop {
let empty_message = match &input_mode {
InputMode::GitUncommitted => Some("No uncommitted changes found.".to_string()),
InputMode::GitStaged => Some("No staged changes found.".to_string()),
InputMode::GitRange { from, to } => {
Some(format!("No changes in range {}..{}.", from, to))
}
_ => Some("No changes found.".to_string()),
};
let (multi_diff, git_branch) =
match build_diff_from_input_mode(&input_mode, &config, &args)? {
Some(result) => result,
None => {
exit_message = empty_message;
break;
}
};
let view_mode: ViewMode = args.view.into();
let view_mode = config.parse_view_mode().unwrap_or(view_mode);
let speed = if args.speed != 200 {
args.speed
} else {
config.playback.speed
};
let autoplay = args.autoplay || config.playback.autoplay;
let mut app = App::new(multi_diff, view_mode, speed, autoplay, git_branch);
app.no_changes_message = empty_message.clone();
apply_config_to_app(&mut app, &config, &args, light_mode);
app.set_review_persist_enabled(!args.no_review_persist);
app.set_review_clear_session_on_start(args.clear_review_session);
app.enable_review_mode();
let exit = run_app(&mut terminal, &mut app, &config.editor)?;
if review_output.is_none() {
review_output = app.take_review_submission_output();
}
review_hook_warnings.extend(app.take_review_hook_warnings());
match exit {
AppExit::Quit => break,
AppExit::OpenDashboard => {
let Some(mode) = run_commit_picker(&mut terminal, &config, light_mode, limit)?
else {
break;
};
input_mode = mode;
}
}
}
restore_terminal(&mut terminal)?;
emit_review_output(
review_output,
args.review_output_file.as_ref(),
!args.no_print_review,
)?;
for warning in review_hook_warnings {
eprintln!("Warning: {warning}");
}
if let Some(message) = exit_message {
println!("{message}");
}
return Ok(());
}
let mut input_mode = if args.paths.len() == 7 {
detect_input_mode(&args.paths)
} else if args.staged || args.range.is_some() {
if !args.paths.is_empty() {
anyhow::bail!("--staged/--range cannot be used with file paths");
}
if args.staged && args.range.is_some() {
anyhow::bail!("--staged and --range are mutually exclusive");
}
if let Some(range) = args.range.as_deref() {
let (from, to) = parse_range(range)?;
InputMode::GitRange { from, to }
} else {
InputMode::GitStaged
}
} else {
detect_input_mode(&args.paths)
};
let empty_message = match &input_mode {
InputMode::GitUncommitted => Some("No uncommitted changes found.".to_string()),
InputMode::GitStaged => Some("No staged changes found.".to_string()),
InputMode::GitRange { from, to } => Some(format!("No changes in range {}..{}.", from, to)),
_ => Some("No changes found.".to_string()),
};
let prefetched = match build_diff_from_input_mode(&input_mode, &config, &args)? {
Some(result) => result,
None => {
if let Some(message) = empty_message {
println!("{message}");
}
return Ok(());
}
};
let mut terminal = setup_terminal()?;
let dashboard_limit = view_limit.unwrap_or(200);
let mut exit_message: Option<String> = None;
let mut review_output: Option<String> = None;
let mut review_hook_warnings = Vec::new();
let mut pending_diff = Some(prefetched);
loop {
let empty_message = match &input_mode {
InputMode::GitUncommitted => Some("No uncommitted changes found.".to_string()),
InputMode::GitStaged => Some("No staged changes found.".to_string()),
InputMode::GitRange { from, to } => {
Some(format!("No changes in range {}..{}.", from, to))
}
_ => Some("No changes found.".to_string()),
};
let (multi_diff, git_branch) = if let Some(result) = pending_diff.take() {
result
} else {
match build_diff_from_input_mode(&input_mode, &config, &args)? {
Some(result) => result,
None => {
exit_message = empty_message;
break;
}
}
};
let view_mode: ViewMode = args.view.into();
let view_mode = config.parse_view_mode().unwrap_or(view_mode);
let speed = if args.speed != 200 {
args.speed
} else {
config.playback.speed
};
let autoplay = args.autoplay || config.playback.autoplay;
let mut app = App::new(multi_diff, view_mode, speed, autoplay, git_branch);
app.no_changes_message = empty_message.clone();
apply_config_to_app(&mut app, &config, &args, light_mode);
app.set_review_persist_enabled(!args.no_review_persist);
app.set_review_clear_session_on_start(args.clear_review_session);
app.enable_review_mode();
let exit = run_app(&mut terminal, &mut app, &config.editor)?;
if review_output.is_none() {
review_output = app.take_review_submission_output();
}
review_hook_warnings.extend(app.take_review_hook_warnings());
match exit {
AppExit::Quit => break,
AppExit::OpenDashboard => {
let Some(mode) =
run_commit_picker(&mut terminal, &config, light_mode, dashboard_limit)?
else {
break;
};
input_mode = mode;
pending_diff = None;
}
}
}
restore_terminal(&mut terminal)?;
emit_review_output(
review_output,
args.review_output_file.as_ref(),
!args.no_print_review,
)?;
for warning in review_hook_warnings {
eprintln!("Warning: {warning}");
}
if let Some(message) = exit_message {
println!("{message}");
}
Ok(())
}
fn run_app(
terminal: &mut TuiTerminal,
app: &mut App,
editor_config: &config::EditorConfig,
) -> Result<AppExit> {
let mut pending_event: Option<Event> = None;
let mut needs_draw = true;
let mut scroll_draw_pending = false;
let mut last_scroll_draw = Instant::now() - MOUSE_SCROLL_FRAME;
let mut pending_mouse_scroll: Option<PendingMouseScroll> = None;
let mut blocked_mouse_scroll: Option<BlockedMouseScroll> = None;
loop {
if scroll_draw_pending && last_scroll_draw.elapsed() >= MOUSE_SCROLL_FRAME {
needs_draw = true;
}
if needs_draw {
let scroll_before_draw = app.scroll_offset;
let applied_mouse_scroll = pending_mouse_scroll;
if apply_pending_mouse_scroll(app, &mut pending_mouse_scroll) {
scroll_draw_pending = false;
}
terminal
.draw(|f| ui::draw(f, app))
.map_err(|e| anyhow!("{e}"))?;
needs_draw = false;
last_scroll_draw = Instant::now();
update_mouse_scroll_block(
&mut blocked_mouse_scroll,
applied_mouse_scroll,
scroll_before_draw,
app.scroll_offset,
);
if app.clear_active_on_next_render {
app.multi_diff.current_navigator().clear_active_change();
app.clear_active_on_next_render = false;
needs_draw = true;
}
if needs_draw {
continue;
}
}
let poll_timeout = if scroll_draw_pending {
MOUSE_SCROLL_FRAME
.checked_sub(last_scroll_draw.elapsed())
.unwrap_or_default()
} else {
app.redraw_interval()
};
let event = if let Some(event) = pending_event.take() {
Some(event)
} else if event::poll(poll_timeout)? {
Some(event::read()?)
} else {
None
};
if let Some(event) = event {
app.mark_user_input();
needs_draw = true;
if !is_mouse_scroll_event(&event) {
blocked_mouse_scroll = None;
if apply_pending_mouse_scroll(app, &mut pending_mouse_scroll) {
scroll_draw_pending = false;
}
}
match event {
Event::Mouse(me) => {
if app.show_help || app.show_path_popup {
continue;
}
app.reset_count();
if let Some(button) = toast_mouse_button(me.kind) {
if app.handle_toast_click(me.column, me.row, button) {
continue;
}
}
if app.command_palette_active() {
match me.kind {
MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => {
if queue_mouse_scroll(
app,
MouseScrollTarget::CommandPalette,
me.kind,
&mut pending_event,
&mut pending_mouse_scroll,
&mut blocked_mouse_scroll,
last_scroll_draw,
)? {
schedule_mouse_scroll_draw(
&mut needs_draw,
&mut scroll_draw_pending,
last_scroll_draw,
);
} else {
needs_draw = false;
scroll_draw_pending = false;
}
}
MouseEventKind::Down(MouseButton::Left) => {
app.handle_command_palette_click(me.column, me.row);
}
_ => {}
}
continue;
}
if app.file_search_active() {
match me.kind {
MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => {
if queue_mouse_scroll(
app,
MouseScrollTarget::FileSearch,
me.kind,
&mut pending_event,
&mut pending_mouse_scroll,
&mut blocked_mouse_scroll,
last_scroll_draw,
)? {
schedule_mouse_scroll_draw(
&mut needs_draw,
&mut scroll_draw_pending,
last_scroll_draw,
);
} else {
needs_draw = false;
scroll_draw_pending = false;
}
}
MouseEventKind::Down(MouseButton::Left) => {
app.handle_file_search_click(me.column, me.row);
}
_ => {}
}
continue;
}
match me.kind {
MouseEventKind::Down(MouseButton::Left) => {
if app.handle_selection_toolbar_click(me.column, me.row) {
continue;
}
if app.handle_review_editor_toolbar_click(me.column, me.row) {
continue;
}
if app.dismiss_selection_toolbar_click(me.column, me.row) {
continue;
}
if app.handle_status_comments_mouse_down(me.column, me.row) {
continue;
}
if app.handle_status_file_mouse_down(me.column, me.row) {
continue;
}
if app.handle_status_bar_mouse_down(
me.column,
me.row,
me.modifiers.contains(KeyModifiers::CONTROL),
) {
continue;
}
if app.handle_topbar_mouse_down(me.column, me.row) {
continue;
}
if app.start_file_panel_resize(me.column, me.row) {
continue;
}
if app.start_file_panel_scrollbar_drag(me.column, me.row) {
continue;
}
if app.start_diff_scrollbar_drag(me.column, me.row) {
continue;
}
if app.handle_review_line_add_click(me.column, me.row) {
continue;
}
if app.handle_review_preview_click(me.column, me.row) {
continue;
}
if app.handle_preview_link_click(me.column, me.row) {
continue;
}
if app.handle_structured_preview_click(me.column, me.row) {
continue;
}
if app.start_diff_selection(me.column, me.row) {
continue;
}
if app.handle_file_list_click(
me.column,
me.row,
me.modifiers.contains(KeyModifiers::CONTROL),
) {
continue;
}
}
MouseEventKind::Drag(MouseButton::Left) => {
if app.drag_topbar_tab(me.column, me.row) {
continue;
}
if app.drag_file_panel_scrollbar(me.row) {
continue;
}
if app.drag_diff_scrollbar(me.row) {
continue;
}
if app.drag_diff_selection(me.column, me.row) {
continue;
}
if let Ok((cols, _)) = crossterm::terminal::size() {
if app.drag_file_panel_resize(me.column, cols) {
continue;
}
}
}
MouseEventKind::Up(MouseButton::Left) => {
if app.finish_topbar_drag() {
continue;
}
if app.finish_file_panel_scrollbar_drag() {
continue;
}
if app.finish_diff_scrollbar_drag() {
continue;
}
if app.finish_diff_selection(me.column, me.row) {
continue;
}
app.end_file_panel_resize();
}
MouseEventKind::Moved if !app.update_topbar_hover(me.column, me.row) => {
needs_draw = false;
}
MouseEventKind::ScrollLeft | MouseEventKind::ScrollRight
if app.mouse_over_selection_toolbar(me.column, me.row) =>
{
let delta = if matches!(me.kind, MouseEventKind::ScrollLeft) {
-1
} else {
1
};
needs_draw = app.scroll_selection_toolbar_actions(delta);
}
MouseEventKind::ScrollUp | MouseEventKind::ScrollDown
if app.mouse_over_selection_toolbar(me.column, me.row) =>
{
let delta = if matches!(me.kind, MouseEventKind::ScrollUp) {
-1
} else {
1
};
needs_draw = app.scroll_selection_toolbar_actions(delta);
}
MouseEventKind::ScrollLeft | MouseEventKind::ScrollRight
if app.mouse_over_review_editor_toolbar(me.column, me.row) =>
{
let delta = if matches!(me.kind, MouseEventKind::ScrollLeft) {
-1
} else {
1
};
needs_draw = app.scroll_review_editor_toolbar(delta);
}
MouseEventKind::ScrollUp | MouseEventKind::ScrollDown
if app.mouse_over_review_editor_toolbar(me.column, me.row) =>
{
let delta = if matches!(me.kind, MouseEventKind::ScrollUp) {
-1
} else {
1
};
needs_draw = app.scroll_review_editor_toolbar(delta);
}
MouseEventKind::ScrollLeft | MouseEventKind::ScrollRight
if app.mouse_over_topbar(me.column, me.row) =>
{
let delta = if matches!(me.kind, MouseEventKind::ScrollLeft) {
-1
} else {
1
};
needs_draw = app.scroll_topbar_tabs(delta);
}
MouseEventKind::ScrollUp | MouseEventKind::ScrollDown
if app.mouse_over_topbar(me.column, me.row) =>
{
let delta = if matches!(me.kind, MouseEventKind::ScrollUp) {
-1
} else {
1
};
needs_draw = app.scroll_topbar_tabs(delta);
}
kind if app.mouse_over_diff_view(me.column, me.row)
&& mouse_horizontal_scroll_delta(kind, me.modifiers).is_some() =>
{
needs_draw = app.scroll_diff_horizontally(
mouse_horizontal_scroll_delta(kind, me.modifiers).unwrap_or(0),
);
}
MouseEventKind::ScrollLeft | MouseEventKind::ScrollRight => {
let delta =
mouse_horizontal_scroll_delta(me.kind, me.modifiers).unwrap_or(0);
needs_draw = app.scroll_diff_horizontally(delta);
}
MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => {
let target = if app.mouse_over_file_panel(me.column, me.row) {
MouseScrollTarget::FilePanel
} else if app.view_mode != ViewMode::Preview
&& app.stepping
&& app.current_file_diff_ready()
{
MouseScrollTarget::Step
} else {
MouseScrollTarget::Diff
};
if queue_mouse_scroll(
app,
target,
me.kind,
&mut pending_event,
&mut pending_mouse_scroll,
&mut blocked_mouse_scroll,
last_scroll_draw,
)? {
schedule_mouse_scroll_draw(
&mut needs_draw,
&mut scroll_draw_pending,
last_scroll_draw,
);
} else {
needs_draw = false;
scroll_draw_pending = false;
}
}
_ => {}
}
}
Event::Key(key)
if matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) =>
{
handle_app_key(app, key, &mut pending_event, terminal, editor_config)?;
}
_ => {}
}
} else if scroll_draw_pending {
needs_draw = true;
}
if app.tick() && !(scroll_draw_pending && last_scroll_draw.elapsed() < MOUSE_SCROLL_FRAME) {
needs_draw = true;
}
if app.open_dashboard {
app.open_dashboard = false;
return Ok(AppExit::OpenDashboard);
}
if app.should_quit {
return Ok(AppExit::Quit);
}
}
}
fn coalesce_key_repeats(
first: KeyEvent,
pending_event: &mut Option<Event>,
) -> std::io::Result<usize> {
let mut count = 1usize;
let same_key = |next: &KeyEvent| next.code == first.code && next.modifiers == first.modifiers;
while event::poll(Duration::from_millis(0))? {
let next = event::read()?;
match next {
Event::Key(key)
if same_key(&key)
&& matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) =>
{
count += 1;
}
_ => {
*pending_event = Some(next);
break;
}
}
}
Ok(count)
}
fn toast_mouse_button(kind: MouseEventKind) -> Option<ratatui_comfy_toaster::ToastMouseButton> {
match kind {
MouseEventKind::Down(MouseButton::Left) => {
Some(ratatui_comfy_toaster::ToastMouseButton::Left)
}
MouseEventKind::Down(MouseButton::Right) => {
Some(ratatui_comfy_toaster::ToastMouseButton::Right)
}
_ => None,
}
}
fn schedule_mouse_scroll_draw(
needs_draw: &mut bool,
scroll_draw_pending: &mut bool,
last_scroll_draw: Instant,
) {
*scroll_draw_pending = true;
*needs_draw = last_scroll_draw.elapsed() >= MOUSE_SCROLL_FRAME;
}
fn is_mouse_scroll_event(event: &Event) -> bool {
matches!(event, Event::Mouse(mouse) if mouse_scroll_delta(mouse.kind).is_some()
|| mouse_horizontal_scroll_delta(mouse.kind, mouse.modifiers).is_some())
}
fn mouse_horizontal_scroll_delta(kind: MouseEventKind, modifiers: KeyModifiers) -> Option<isize> {
match kind {
MouseEventKind::ScrollLeft => Some(-1),
MouseEventKind::ScrollRight => Some(1),
MouseEventKind::ScrollUp if modifiers.contains(KeyModifiers::SHIFT) => Some(-1),
MouseEventKind::ScrollDown if modifiers.contains(KeyModifiers::SHIFT) => Some(1),
_ => None,
}
}
fn mouse_scroll_delta(kind: MouseEventKind) -> Option<isize> {
match kind {
MouseEventKind::ScrollUp => Some(-1),
MouseEventKind::ScrollDown => Some(1),
_ => None,
}
}
fn collect_mouse_scroll_delta(
kind: MouseEventKind,
pending_event: &mut Option<Event>,
read_until: Instant,
) -> std::io::Result<isize> {
let mut reads = 1usize;
let mut delta = mouse_scroll_delta(kind).unwrap_or(0);
let direction = delta.signum();
while reads < MAX_COALESCED_MOUSE_SCROLL_READS {
let timeout = read_until
.checked_duration_since(Instant::now())
.unwrap_or_default();
if !event::poll(timeout)? {
break;
}
let next = event::read()?;
match next {
Event::Mouse(mouse) => {
if let Some(next_delta) = mouse_scroll_delta(mouse.kind) {
if next_delta.signum() != direction {
*pending_event = Some(Event::Mouse(mouse));
break;
}
reads = reads.saturating_add(1);
delta = delta.saturating_add(next_delta);
} else {
*pending_event = Some(Event::Mouse(mouse));
break;
}
}
_ => {
*pending_event = Some(next);
break;
}
}
}
Ok(delta)
}
fn mouse_scroll_action_cap(target: MouseScrollTarget) -> isize {
match target {
MouseScrollTarget::Diff => MAX_DIFF_MOUSE_SCROLL_ACTIONS_PER_FRAME,
MouseScrollTarget::CommandPalette
| MouseScrollTarget::FileSearch
| MouseScrollTarget::FilePanel
| MouseScrollTarget::Step => MAX_DISCRETE_MOUSE_SCROLL_ACTIONS_PER_FRAME,
}
}
fn clamp_mouse_scroll_delta(target: MouseScrollTarget, delta: isize) -> isize {
let cap = mouse_scroll_action_cap(target);
delta.clamp(-cap, cap)
}
fn blocks_mouse_scroll(
blocked: Option<BlockedMouseScroll>,
target: MouseScrollTarget,
delta: isize,
) -> bool {
delta != 0
&& blocked.is_some_and(|block| block.target == target && block.direction == delta.signum())
}
fn update_mouse_scroll_block(
blocked: &mut Option<BlockedMouseScroll>,
applied: Option<PendingMouseScroll>,
scroll_before: usize,
scroll_after: usize,
) {
let Some(scroll) = applied else {
return;
};
if scroll.target != MouseScrollTarget::Diff || scroll.delta == 0 {
return;
}
if scroll_before == scroll_after {
*blocked = Some(BlockedMouseScroll {
target: scroll.target,
direction: scroll.delta.signum(),
});
} else {
*blocked = None;
}
}
fn push_pending_mouse_scroll(
pending: &mut Option<PendingMouseScroll>,
target: MouseScrollTarget,
delta: isize,
) -> Option<PendingMouseScroll> {
if delta == 0 {
return None;
}
let Some(current) = pending.as_mut() else {
*pending = Some(PendingMouseScroll {
target,
delta: clamp_mouse_scroll_delta(target, delta),
});
return None;
};
if current.target != target {
let old = pending.take();
*pending = Some(PendingMouseScroll {
target,
delta: clamp_mouse_scroll_delta(target, delta),
});
return old;
}
if current.delta.signum() != delta.signum() {
current.delta = clamp_mouse_scroll_delta(target, delta);
} else {
current.delta = clamp_mouse_scroll_delta(target, current.delta.saturating_add(delta));
}
None
}
fn queue_mouse_scroll(
app: &mut App,
target: MouseScrollTarget,
kind: MouseEventKind,
pending_event: &mut Option<Event>,
pending: &mut Option<PendingMouseScroll>,
blocked: &mut Option<BlockedMouseScroll>,
last_scroll_draw: Instant,
) -> std::io::Result<bool> {
let delta =
collect_mouse_scroll_delta(kind, pending_event, last_scroll_draw + MOUSE_SCROLL_FRAME)?;
if blocks_mouse_scroll(*blocked, target, delta) {
return Ok(false);
}
if delta != 0 {
*blocked = None;
}
if let Some(scroll) = push_pending_mouse_scroll(pending, target, delta) {
apply_mouse_scroll(app, scroll);
}
Ok(true)
}
fn apply_pending_mouse_scroll(app: &mut App, pending: &mut Option<PendingMouseScroll>) -> bool {
let Some(scroll) = pending.take() else {
return false;
};
apply_mouse_scroll(app, scroll);
true
}
fn apply_mouse_scroll(app: &mut App, scroll: PendingMouseScroll) {
let delta = scroll.delta;
if delta == 0 {
return;
}
match scroll.target {
MouseScrollTarget::CommandPalette => {
if app.command_palette_active() {
app.move_command_palette_selection(delta);
}
}
MouseScrollTarget::FileSearch => {
if app.file_search_active() {
app.move_file_search_selection(delta);
}
}
MouseScrollTarget::FilePanel | MouseScrollTarget::Step | MouseScrollTarget::Diff => {
app.clear_diff_selection();
for _ in 0..delta.unsigned_abs() {
match scroll.target {
MouseScrollTarget::FilePanel if delta < 0 => app.scroll_file_panel_up(),
MouseScrollTarget::FilePanel => app.scroll_file_panel_down(),
MouseScrollTarget::Step
if delta < 0 && app.stepping && app.current_file_diff_ready() =>
{
app.prev_step();
}
MouseScrollTarget::Step if app.stepping && app.current_file_diff_ready() => {
app.next_step();
}
MouseScrollTarget::Diff if delta < 0 => app.scroll_up(),
MouseScrollTarget::Diff => app.scroll_down(),
_ => {}
}
}
}
}
}
fn run_dashboard<B: Backend>(
terminal: &mut Terminal<B>,
dashboard: &mut Dashboard,
) -> Result<Option<DashboardSelection>> {
let tick_rate = Duration::from_millis(250);
let mut needs_draw = true;
loop {
if needs_draw {
terminal
.draw(|f| dashboard.draw(f))
.map_err(|e| anyhow!("{e}"))?;
needs_draw = false;
}
if event::poll(tick_rate)? {
needs_draw = true;
match event::read()? {
Event::Key(key) if key.kind == KeyEventKind::Press => {
let list_height =
dashboard.list_height(terminal.size().map_err(|e| anyhow!("{e}"))?.height);
if dashboard.filter_active() {
match dashboard.keybindings_mut().dashboard_filter(key) {
Dispatch::Matched(DashboardFilterAction::Cancel) => {
dashboard.stop_filter();
}
Dispatch::Matched(DashboardFilterAction::Accept) => {
if let Some(selection) = dashboard.selection() {
return Ok(Some(selection));
}
}
Dispatch::Matched(DashboardFilterAction::Clear) => {
dashboard.clear_filter();
}
Dispatch::Matched(DashboardFilterAction::Backspace) => {
dashboard.pop_filter_char();
}
Dispatch::Matched(DashboardFilterAction::SelectNext) => {
dashboard.move_selection(1, list_height);
}
Dispatch::Matched(DashboardFilterAction::SelectPrev) => {
dashboard.move_selection(-1, list_height);
}
Dispatch::Matched(DashboardFilterAction::PageDown) => {
dashboard.page_down(list_height);
}
Dispatch::Matched(DashboardFilterAction::PageUp) => {
dashboard.page_up(list_height);
}
Dispatch::Matched(DashboardFilterAction::SelectFirst) => {
dashboard.select_first(list_height);
}
Dispatch::Matched(DashboardFilterAction::SelectLast) => {
dashboard.select_last(list_height);
}
Dispatch::Pending => {}
Dispatch::Unmatched => {
if let Some(ch) = printable_dashboard_char(key) {
dashboard.push_filter_char(ch);
}
}
}
continue;
}
match dashboard.keybindings_mut().dashboard(key) {
Dispatch::Matched(DashboardAction::Quit) => return Ok(None),
Dispatch::Matched(DashboardAction::StartFilter) => {
dashboard.start_filter();
}
Dispatch::Matched(DashboardAction::ClearPin) => {
dashboard.clear_pin();
}
Dispatch::Matched(DashboardAction::TogglePin) => {
dashboard.toggle_pin();
}
Dispatch::Matched(DashboardAction::Accept) => {
if let Some(selection) = dashboard.selection() {
return Ok(Some(selection));
}
}
Dispatch::Matched(DashboardAction::SelectNext) => {
dashboard.move_selection(1, list_height);
}
Dispatch::Matched(DashboardAction::SelectPrev) => {
dashboard.move_selection(-1, list_height);
}
Dispatch::Matched(DashboardAction::PageDown) => {
dashboard.page_down(list_height);
}
Dispatch::Matched(DashboardAction::PageUp) => {
dashboard.page_up(list_height);
}
Dispatch::Matched(DashboardAction::SelectFirst) => {
dashboard.select_first(list_height);
}
Dispatch::Matched(DashboardAction::SelectLast) => {
dashboard.select_last(list_height);
}
Dispatch::Pending | Dispatch::Unmatched => {}
}
}
Event::Mouse(mouse) => {
let list_height =
dashboard.list_height(terminal.size().map_err(|e| anyhow!("{e}"))?.height);
match mouse.kind {
MouseEventKind::ScrollUp => {
dashboard.move_selection(-3, list_height);
}
MouseEventKind::ScrollDown => {
dashboard.move_selection(3, list_height);
}
MouseEventKind::Down(MouseButton::Left) => {
let changed = dashboard.select_at_mouse(mouse.row);
if !changed {
if let Some(selection) = dashboard.selection() {
return Ok(Some(selection));
}
}
}
_ => {}
}
}
_ => {}
}
}
}
}
fn printable_dashboard_char(key: KeyEvent) -> Option<char> {
match key.code {
KeyCode::Char(ch)
if !key.modifiers.contains(KeyModifiers::CONTROL)
&& !key.modifiers.contains(KeyModifiers::ALT) =>
{
Some(ch)
}
_ => None,
}
}
fn run_commit_picker<B: Backend>(
terminal: &mut Terminal<B>,
config: &config::Config,
light_mode: bool,
limit: usize,
) -> Result<Option<InputMode>> {
let cwd = std::env::current_dir().unwrap_or_default();
if !oyo_core::git::is_git_repo(&cwd) {
anyhow::bail!("Not in a git repository.");
}
let repo_root =
oyo_core::git::get_repo_root(&cwd).context("Failed to get git repository root")?;
let branch = oyo_core::git::get_current_branch(&repo_root).ok();
let commits =
oyo_core::git::get_recent_commits(&repo_root, limit).context("Failed to get commits")?;
let working_changes = oyo_core::git::get_uncommitted_changes(&repo_root)
.context("Failed to get uncommitted changes")?;
let staged_changes =
oyo_core::git::get_staged_changes(&repo_root).context("Failed to get staged changes")?;
let theme = config.ui.theme.resolve(light_mode);
let time_format = TimeFormatter::new(&config.ui.time);
let mut dashboard = Dashboard::new(DashboardConfig {
repo_root,
branch,
commits,
working_files: working_changes.len(),
staged_files: staged_changes.len(),
theme,
primary_marker: config.ui.primary_marker.clone(),
extent_marker: config.ui.extent_marker_left().to_string(),
time_format,
keybindings: Keybindings::from_config(&config.keybindings),
});
let selection = run_dashboard(terminal, &mut dashboard)?;
let input_mode = match selection {
None => return Ok(None),
Some(DashboardSelection::Uncommitted) => InputMode::GitUncommitted,
Some(DashboardSelection::Staged) => InputMode::GitStaged,
Some(DashboardSelection::Range { from, to }) => InputMode::GitRange { from, to },
};
Ok(Some(input_mode))
}
#[cfg(test)]
mod tests {
use super::{
blocks_mouse_scroll, config, detect_input_mode, mouse_horizontal_scroll_delta, parse_range,
push_pending_mouse_scroll, render_editor_args, update_mouse_scroll_block,
BlockedMouseScroll, InputMode, MouseScrollTarget, PendingMouseScroll,
MAX_DIFF_MOUSE_SCROLL_ACTIONS_PER_FRAME, MAX_DISCRETE_MOUSE_SCROLL_ACTIONS_PER_FRAME,
};
use crossterm::event::{KeyModifiers, MouseEventKind};
use std::path::{Path, PathBuf};
#[test]
fn parse_range_accepts_double_dot() {
let (from, to) = parse_range("HEAD~1..HEAD").unwrap();
assert_eq!(from, "HEAD~1");
assert_eq!(to, "HEAD");
}
#[test]
fn parse_range_accepts_triple_dot() {
let (from, to) = parse_range("main...feature").unwrap();
assert_eq!(from, "main");
assert_eq!(to, "feature");
}
#[test]
fn parse_range_rejects_empty_bounds() {
assert!(parse_range("..HEAD").is_err());
assert!(parse_range("HEAD..").is_err());
assert!(parse_range("...HEAD").is_err());
assert!(parse_range("HEAD...").is_err());
}
#[test]
fn parse_range_rejects_extra_separators() {
assert!(parse_range("A..B..C").is_err());
assert!(parse_range("A...B..C").is_err());
}
#[test]
fn parse_range_rejects_missing_separator() {
assert!(parse_range("HEAD").is_err());
}
#[test]
fn detect_input_mode_single_path() {
let paths = vec![PathBuf::from("main.rs")];
match detect_input_mode(&paths) {
InputMode::GitFile { path } => assert_eq!(path, PathBuf::from("main.rs")),
_ => panic!("unexpected input mode"),
}
}
#[test]
fn mouse_scroll_clamps_and_reverses_pending_delta() {
let mut pending = None;
assert_eq!(
push_pending_mouse_scroll(&mut pending, MouseScrollTarget::Diff, 100),
None
);
assert_eq!(
pending,
Some(PendingMouseScroll {
target: MouseScrollTarget::Diff,
delta: MAX_DIFF_MOUSE_SCROLL_ACTIONS_PER_FRAME,
})
);
assert_eq!(
push_pending_mouse_scroll(&mut pending, MouseScrollTarget::Diff, -3),
None
);
assert_eq!(
pending,
Some(PendingMouseScroll {
target: MouseScrollTarget::Diff,
delta: -3,
})
);
}
#[test]
fn mouse_scroll_flushes_when_target_changes() {
let mut pending = Some(PendingMouseScroll {
target: MouseScrollTarget::Diff,
delta: 5,
});
assert_eq!(
push_pending_mouse_scroll(&mut pending, MouseScrollTarget::Step, -100),
Some(PendingMouseScroll {
target: MouseScrollTarget::Diff,
delta: 5,
})
);
assert_eq!(
pending,
Some(PendingMouseScroll {
target: MouseScrollTarget::Step,
delta: -MAX_DISCRETE_MOUSE_SCROLL_ACTIONS_PER_FRAME,
})
);
}
#[test]
fn mouse_scroll_blocks_same_direction_at_edge() {
let mut blocked = None;
update_mouse_scroll_block(
&mut blocked,
Some(PendingMouseScroll {
target: MouseScrollTarget::Diff,
delta: MAX_DIFF_MOUSE_SCROLL_ACTIONS_PER_FRAME,
}),
10,
10,
);
assert_eq!(
blocked,
Some(BlockedMouseScroll {
target: MouseScrollTarget::Diff,
direction: 1,
})
);
assert!(blocks_mouse_scroll(blocked, MouseScrollTarget::Diff, 1));
assert!(!blocks_mouse_scroll(blocked, MouseScrollTarget::Diff, -1));
}
#[test]
fn horizontal_mouse_scroll_supports_native_and_shift_wheel() {
assert_eq!(
mouse_horizontal_scroll_delta(MouseEventKind::ScrollLeft, KeyModifiers::empty()),
Some(-1)
);
assert_eq!(
mouse_horizontal_scroll_delta(MouseEventKind::ScrollRight, KeyModifiers::empty()),
Some(1)
);
assert_eq!(
mouse_horizontal_scroll_delta(MouseEventKind::ScrollUp, KeyModifiers::SHIFT),
Some(-1)
);
assert_eq!(
mouse_horizontal_scroll_delta(MouseEventKind::ScrollDown, KeyModifiers::SHIFT),
Some(1)
);
assert_eq!(
mouse_horizontal_scroll_delta(MouseEventKind::ScrollDown, KeyModifiers::empty()),
None
);
}
#[test]
fn mouse_scroll_does_not_block_after_movement() {
let mut blocked = Some(BlockedMouseScroll {
target: MouseScrollTarget::Diff,
direction: 1,
});
update_mouse_scroll_block(
&mut blocked,
Some(PendingMouseScroll {
target: MouseScrollTarget::Diff,
delta: MAX_DIFF_MOUSE_SCROLL_ACTIONS_PER_FRAME,
}),
10,
20,
);
assert_eq!(blocked, None);
}
#[test]
fn editor_default_args_open_at_line() {
let config = config::EditorConfig::default();
let args = render_editor_args(&config, Some(42), Path::new("src/main.rs"));
assert_eq!(args, vec!["+42", "src/main.rs"]);
}
#[test]
fn editor_template_args_replace_file_and_line() {
let config = config::EditorConfig {
command: Some("code".to_string()),
args: Some(vec!["--goto".to_string(), "{file}:{line}".to_string()]),
open_at_line: true,
};
let args = render_editor_args(&config, Some(42), Path::new("src/main.rs"));
assert_eq!(args, vec!["--goto", "src/main.rs:42"]);
}
}