mod app;
mod blame;
mod color;
mod config;
mod dashboard;
mod syntax;
#[cfg(test)]
mod test_utils;
mod time_format;
mod ui;
mod views;
use crate::dashboard::{Dashboard, DashboardConfig, DashboardSelection};
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::{DirectoryScanOptions, LineKind, MultiFileDiff, ViewLine};
use ratatui::prelude::*;
use std::fs::OpenOptions;
use std::io::{self, IsTerminal};
use std::path::{Path, PathBuf};
use std::process::Command as ProcessCommand;
use std::time::Duration;
const INDEX_REF: &str = "INDEX";
type TuiBackend = CrosstermBackend<Box<dyn io::Write>>;
type TuiTerminal = Terminal<TuiBackend>;
#[derive(Parser, Debug)]
#[command(name = "oy")]
#[command(author, version, about = "A step-through 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, global = true)]
syntax_theme: Option<String>,
#[arg(long, value_name = "FILE")]
dump_scopes: Option<PathBuf>,
#[arg(long, global = true)]
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,
}
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,
}
}
}
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 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 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)
}
struct EditorTarget {
path: PathBuf,
line: Option<usize>,
cwd: Option<PathBuf>,
}
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) -> Option<EditorTarget> {
let (file_path, cwd) = {
let file = app.multi_diff.current_file()?;
(
file.path.clone(),
app.multi_diff.repo_root().map(Path::to_path_buf),
)
};
let path = cwd
.as_ref()
.map(|root| root.join(&file_path))
.unwrap_or(file_path);
let line = if needs_line {
current_editor_line(app)
} else {
None
};
Some(EditorTarget { path, line, cwd })
}
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 current_editor_line(app: &mut App) -> Option<usize> {
let frame = app.animation_frame();
let view = app.current_view_with_frame(frame);
if app.stepping {
return primary_editor_line(&view)
.or_else(|| active_editor_line(&view))
.or_else(|| visible_editor_line(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_line(&view).or_else(|| visible_editor_line(app, &view))
} else {
visible_editor_line(app, &view).or_else(|| primary_editor_line(&view))
}
}
fn primary_editor_line(view: &[ViewLine]) -> Option<usize> {
view.iter()
.find(|line| line.is_primary_active)
.and_then(editor_line_number)
}
fn active_editor_line(view: &[ViewLine]) -> Option<usize> {
view.iter()
.find(|line| line.is_active)
.and_then(editor_line_number)
}
fn editor_line_number(line: &ViewLine) -> Option<usize> {
line.new_line.or(line.old_line).filter(|line| *line > 0)
}
fn visible_editor_line(app: &App, view: &[ViewLine]) -> Option<usize> {
let target = app.render_scroll_offset();
match app.view_mode {
ViewMode::Split => visible_split_editor_line(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_line_number(line)),
_ => view.iter().skip(target).find_map(editor_line_number),
}
}
fn visible_split_editor_line(app: &App, view: &[ViewLine], target: usize) -> Option<usize> {
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 = editor_line_number(line);
}
old_idx += 1;
}
if new_present || (app.split_align_lines && old_present) {
if new_idx >= target && new_match.is_none() {
new_match = editor_line_number(line);
}
new_idx += 1;
}
if new_match.is_some() {
break;
}
}
new_match.or(old_match)
}
fn suspend_terminal_for_child(terminal: &mut TuiTerminal) -> Result<()> {
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
Ok(())
}
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() {
app.refresh_current_file();
}
Ok(())
}
fn apply_config_to_app(app: &mut App, config: &config::Config, args: &Args, light_mode: bool) {
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_count_mode = config.files.counts;
app.auto_center = config.ui.auto_center;
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.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.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.clone();
app.extent_marker_right = config
.ui
.extent_marker_right
.clone()
.unwrap_or_else(|| "▐".to_string());
app.theme = config.ui.theme.resolve(light_mode);
app.time_format = TimeFormatter::new(&config.ui.time);
app.theme_is_light = light_mode;
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 diff =
MultiFileDiff::from_file_pair_bytes(display_path.clone(), old_bytes, new_bytes);
(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);
MultiFileDiff::from_directories_with_options(old_path, new_path, &scan_options)
.context("Failed to create diff from directories")?
} 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()))?
};
MultiFileDiff::from_file_pair_bytes(new_path.clone(), old_bytes, new_bytes)
};
(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_bytes(rel_path.to_path_buf(), old_bytes, new_bytes);
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")?;
if changes.is_empty() {
return Ok(None);
}
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")?;
if changes.is_empty() {
return Ok(None);
}
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")?;
if changes.is_empty() {
return Ok(None);
}
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")?;
if changes.is_empty() {
return Ok(None);
}
let diff = MultiFileDiff::from_git_range(
repo_root.clone(),
changes.clone(),
from.clone(),
to.clone(),
)
.context("Failed to create diff from range")?;
(changes, diff)
};
if changes.is_empty() {
return Ok(None);
}
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<()> {
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 = config::Config::load();
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 => {
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
return Ok(());
}
};
let mut exit_message: Option<String> = None;
let mut review_output: Option<String> = None;
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;
}
};
if multi_diff.file_count() == 0 {
exit_message = Some("No changes found.".to_string());
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);
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();
}
match exit {
AppExit::Quit => break,
AppExit::OpenDashboard => {
let Some(mode) = run_commit_picker(&mut terminal, &config, light_mode, limit)?
else {
break;
};
input_mode = mode;
}
}
}
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
emit_review_output(
review_output,
args.review_output_file.as_ref(),
!args.no_print_review,
)?;
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(());
}
};
if prefetched.0.file_count() == 0 {
println!("No changes found.");
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 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;
}
}
};
if multi_diff.file_count() == 0 {
exit_message = Some("No changes found.".to_string());
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);
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();
}
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;
}
}
}
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
emit_review_output(
review_output,
args.review_output_file.as_ref(),
!args.no_print_review,
)?;
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 tick_rate = Duration::from_millis(16);
let mut pending_event: Option<Event> = None;
loop {
terminal
.draw(|f| ui::draw(f, app))
.map_err(|e| anyhow!("{e}"))?;
if app.clear_active_on_next_render {
app.multi_diff.current_navigator().clear_active_change();
app.clear_active_on_next_render = false;
}
let event = if let Some(event) = pending_event.take() {
Some(event)
} else if event::poll(tick_rate)? {
Some(event::read()?)
} else {
None
};
if let Some(event) = event {
app.mark_user_input();
match event {
Event::Mouse(me) => {
if app.show_help || app.show_path_popup {
continue;
}
app.reset_count();
if app.command_palette_active() {
match me.kind {
MouseEventKind::ScrollUp => {
app.move_command_palette_selection(-1);
}
MouseEventKind::ScrollDown => {
app.move_command_palette_selection(1);
}
MouseEventKind::Down(MouseButton::Left) => {
app.handle_command_palette_click(me.column, me.row);
}
_ => {}
}
continue;
}
if app.file_search_active() {
match me.kind {
MouseEventKind::ScrollUp => {
app.move_file_search_selection(-1);
}
MouseEventKind::ScrollDown => {
app.move_file_search_selection(1);
}
MouseEventKind::Down(MouseButton::Left) => {
app.handle_file_search_click(me.column, me.row);
}
_ => {}
}
continue;
}
match me.kind {
MouseEventKind::Down(MouseButton::Left) => {
if app.start_file_panel_resize(me.column, me.row) {
continue;
}
if app.handle_review_preview_click(me.column, me.row) {
continue;
}
if app.handle_file_list_click(me.column, me.row) {
continue;
}
}
MouseEventKind::Drag(MouseButton::Left) => {
if let Ok((cols, _)) = crossterm::terminal::size() {
if app.drag_file_panel_resize(me.column, cols) {
continue;
}
}
}
MouseEventKind::Up(MouseButton::Left) => {
app.end_file_panel_resize();
}
MouseEventKind::ScrollUp => {
if app.mouse_over_file_panel(me.column, me.row) {
app.prev_file();
} else if app.stepping && app.current_file_diff_ready() {
app.prev_step();
} else {
app.scroll_up();
}
}
MouseEventKind::ScrollDown => {
if app.mouse_over_file_panel(me.column, me.row) {
app.next_file();
} else if app.stepping && app.current_file_diff_ready() {
app.next_step();
} else {
app.scroll_down();
}
}
_ => {}
}
}
Event::Key(key)
if matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) =>
{
if app.show_help {
match key.code {
KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('?') => {
app.toggle_help();
}
KeyCode::Down | KeyCode::Char('j') => {
app.help_scroll_down();
}
KeyCode::Up | KeyCode::Char('k') => {
app.help_scroll_up();
}
_ => {}
}
continue;
}
if app.review_editor_active() {
match key.code {
KeyCode::Esc if !app.review_cancel_mention_picker() => {
app.review_cancel_editor();
}
KeyCode::Enter if key.modifiers.contains(KeyModifiers::CONTROL) => {
app.review_save_editor();
}
KeyCode::Enter if !app.review_accept_mention() => {
app.review_insert_newline();
}
KeyCode::Tab => {
let _ = app.review_accept_mention();
}
KeyCode::Backspace => app.review_backspace(),
KeyCode::Delete => app.review_delete(),
KeyCode::Left => app.review_move_left(),
KeyCode::Right => app.review_move_right(),
KeyCode::Up => {
if app.review_mention_picker_active() {
app.review_mention_move_selection(-1);
} else {
app.review_move_up();
}
}
KeyCode::Down => {
if app.review_mention_picker_active() {
app.review_mention_move_selection(1);
} else {
app.review_move_down();
}
}
KeyCode::Home => app.review_move_home(),
KeyCode::End => app.review_move_end(),
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
app.review_clear_editor_text();
}
KeyCode::Char('n')
if key.modifiers.contains(KeyModifiers::CONTROL)
&& app.review_mention_picker_active() =>
{
app.review_mention_move_selection(1);
}
KeyCode::Char('p')
if key.modifiers.contains(KeyModifiers::CONTROL)
&& app.review_mention_picker_active() =>
{
app.review_mention_move_selection(-1);
}
KeyCode::Char(c)
if !key.modifiers.contains(KeyModifiers::CONTROL)
&& !key.modifiers.contains(KeyModifiers::ALT) =>
{
app.review_insert_char(c);
}
_ => {}
}
continue;
}
let is_ctrl_p = key.modifiers.contains(KeyModifiers::CONTROL)
&& matches!(key.code, KeyCode::Char('p') | KeyCode::Char('P'));
let is_ctrl_shift_p = key.modifiers.contains(KeyModifiers::CONTROL)
&& key.modifiers.contains(KeyModifiers::SHIFT)
&& matches!(key.code, KeyCode::Char('p') | KeyCode::Char('P'));
if is_ctrl_shift_p {
if app.file_search_active() {
app.stop_file_search();
} else {
app.start_file_search();
}
continue;
}
if is_ctrl_p {
if app.command_palette_active() {
app.stop_command_palette();
} else {
app.start_command_palette();
}
continue;
}
if app.command_palette_active() {
match key.code {
KeyCode::Esc => {
app.stop_command_palette();
}
KeyCode::Enter => {
app.apply_command_palette_selection();
}
KeyCode::Backspace => {
if app.command_palette_query().is_empty() {
app.stop_command_palette();
} else {
app.pop_command_palette_char();
}
}
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
app.clear_command_palette_text();
}
KeyCode::Down => {
app.move_command_palette_selection(1);
}
KeyCode::Up => {
app.move_command_palette_selection(-1);
}
KeyCode::Char(c)
if !key.modifiers.contains(KeyModifiers::CONTROL)
&& !key.modifiers.contains(KeyModifiers::ALT) =>
{
app.push_command_palette_char(c);
}
_ => {}
}
continue;
}
if app.file_search_active() {
match key.code {
KeyCode::Esc => {
app.stop_file_search();
}
KeyCode::Enter => {
app.apply_file_search_selection();
}
KeyCode::Backspace => {
if app.file_search_query().is_empty() {
app.stop_file_search();
} else {
app.pop_file_search_char();
}
}
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
app.clear_file_search_text();
}
KeyCode::Down => {
app.move_file_search_selection(1);
}
KeyCode::Up => {
app.move_file_search_selection(-1);
}
KeyCode::Char(c)
if !key.modifiers.contains(KeyModifiers::CONTROL)
&& !key.modifiers.contains(KeyModifiers::ALT) =>
{
app.push_file_search_char(c);
}
_ => {}
}
continue;
}
if app.file_filter_active {
match key.code {
KeyCode::Esc | KeyCode::Enter => {
app.stop_file_filter();
}
KeyCode::Backspace => {
app.pop_file_filter_char();
}
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
app.clear_file_filter();
}
KeyCode::Char(c)
if !key.modifiers.contains(KeyModifiers::CONTROL)
&& !key.modifiers.contains(KeyModifiers::ALT) =>
{
app.push_file_filter_char(c);
}
_ => {}
}
continue;
}
if app.goto_active() {
match key.code {
KeyCode::Esc => {
app.clear_goto();
}
KeyCode::Enter => {
app.apply_goto();
app.clear_goto();
}
KeyCode::Backspace => {
if app.goto_query().is_empty() {
app.clear_goto();
} else {
app.pop_goto_char();
}
}
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
app.clear_goto_text();
}
KeyCode::Char(c)
if !key.modifiers.contains(KeyModifiers::CONTROL)
&& !key.modifiers.contains(KeyModifiers::ALT) =>
{
app.push_goto_char(c);
}
_ => {}
}
continue;
}
if app.search_active() {
match key.code {
KeyCode::Esc => {
app.clear_search();
}
KeyCode::Enter => {
app.stop_search();
app.search_next();
}
KeyCode::Backspace => {
if app.search_query().is_empty() {
app.clear_search();
} else {
app.pop_search_char();
}
}
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
app.clear_search_text();
}
KeyCode::Char(c)
if !key.modifiers.contains(KeyModifiers::CONTROL)
&& !key.modifiers.contains(KeyModifiers::ALT) =>
{
app.push_search_char(c);
}
_ => {}
}
continue;
}
if app.pending_g_prefix {
let is_plain_g = matches!(key.code, KeyCode::Char('g'))
&& !key.modifiers.contains(KeyModifiers::CONTROL)
&& !key.modifiers.contains(KeyModifiers::ALT);
let is_blame_gb = matches!(key.code, KeyCode::Char('b'))
&& !key.modifiers.contains(KeyModifiers::CONTROL)
&& !key.modifiers.contains(KeyModifiers::ALT);
let is_patch_line = matches!(key.code, KeyCode::Char('y'))
&& !key.modifiers.contains(KeyModifiers::CONTROL)
&& !key.modifiers.contains(KeyModifiers::ALT);
let is_patch_hunk = matches!(key.code, KeyCode::Char('Y'))
&& !key.modifiers.contains(KeyModifiers::CONTROL)
&& !key.modifiers.contains(KeyModifiers::ALT);
if is_plain_g {
app.pending_g_prefix = false;
app.reset_count();
app.goto_start();
continue;
}
if is_blame_gb {
app.pending_g_prefix = false;
app.reset_count();
if app.blame_enabled {
app.trigger_blame_hint();
}
continue;
}
if is_patch_line {
app.pending_g_prefix = false;
app.reset_count();
app.yank_current_change_patch();
continue;
}
if is_patch_hunk {
app.pending_g_prefix = false;
app.reset_count();
app.yank_current_hunk_patch();
continue;
}
app.pending_g_prefix = false;
}
if matches!(key.code, KeyCode::Esc)
&& !app.show_help
&& !app.show_path_popup
&& (app.search_active()
|| !app.search_query().is_empty()
|| app.goto_active()
|| !app.goto_query().is_empty())
{
app.reset_count();
app.clear_search();
app.clear_goto();
continue;
}
match key.code {
KeyCode::Char(c @ '0'..='9') => {
if c == '0' && app.pending_count.is_none() {
app.scroll_to_line_start();
} else {
app.push_count_digit(c as u8 - b'0');
}
}
KeyCode::Char('$') => {
app.reset_count();
app.scroll_to_line_end();
}
KeyCode::Char('q') | KeyCode::Esc => {
app.reset_count();
if app.show_help {
app.show_help = false;
} else if app.show_path_popup {
app.show_path_popup = false;
} else {
app.submit_review_and_quit();
continue;
}
}
KeyCode::Down | KeyCode::Char('j') => {
let count = if app.pending_count.is_some() {
app.take_count()
} else {
coalesce_key_repeats(key, &mut pending_event)?
};
for _ in 0..count {
if app.file_list_focused {
app.next_file();
} else if app.stepping {
app.next_step();
} else {
app.scroll_down();
}
}
}
KeyCode::Up | KeyCode::Char('k') => {
let count = if app.pending_count.is_some() {
app.take_count()
} else {
coalesce_key_repeats(key, &mut pending_event)?
};
for _ in 0..count {
if app.file_list_focused {
app.prev_file();
} else if app.stepping {
app.prev_step();
} else {
app.scroll_up();
}
}
}
KeyCode::Right | KeyCode::Char('l') => {
let count = if app.pending_count.is_some() {
app.take_count()
} else {
coalesce_key_repeats(key, &mut pending_event)?
};
app.defer_view_build_for_jump();
for _ in 0..count {
if app.stepping {
app.next_hunk();
} else {
app.next_hunk_scroll();
}
}
}
KeyCode::Left | KeyCode::Char('h') => {
let count = if app.pending_count.is_some() {
app.take_count()
} else {
coalesce_key_repeats(key, &mut pending_event)?
};
app.defer_view_build_for_jump();
for _ in 0..count {
if app.stepping {
app.prev_hunk();
} else {
app.prev_hunk_scroll();
}
}
}
KeyCode::Char('b') => {
app.reset_count();
app.defer_view_build_for_jump();
if app.stepping {
app.goto_hunk_start();
} else {
app.goto_hunk_start_scroll();
}
}
KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => {
app.reset_count();
open_current_file_in_editor(terminal, app, editor_config)?;
}
KeyCode::Char('e') => {
app.reset_count();
app.defer_view_build_for_jump();
if app.stepping {
app.goto_hunk_end();
} else {
app.goto_hunk_end_scroll();
}
}
KeyCode::Char('p') => {
app.reset_count();
if app.stepping {
app.toggle_peek_old_change();
}
}
KeyCode::Char('P') => {
app.reset_count();
if app.stepping {
app.toggle_peek_old_hunk();
}
}
KeyCode::Char('y') => {
app.reset_count();
app.yank_current_change();
}
KeyCode::Char('Y') => {
app.reset_count();
app.yank_current_hunk();
}
KeyCode::Char('g') if key.modifiers.contains(KeyModifiers::CONTROL) => {
app.reset_count();
app.toggle_path_popup();
}
KeyCode::Char('o')
if !key.modifiers.contains(KeyModifiers::CONTROL)
&& !key.modifiers.contains(KeyModifiers::ALT) =>
{
app.reset_count();
open_current_file_in_editor(terminal, app, editor_config)?;
}
KeyCode::Home => {
app.reset_count();
app.defer_view_build_for_jump();
app.goto_start();
}
KeyCode::Char('g') => {
app.reset_count();
app.pending_g_prefix = true;
}
KeyCode::End | KeyCode::Char('G') => {
app.reset_count();
app.defer_view_build_for_jump();
app.goto_end();
}
KeyCode::Char('<') => {
app.reset_count();
app.defer_view_build_for_jump();
if app.stepping {
app.goto_first_step();
} else {
app.goto_first_hunk_scroll();
}
}
KeyCode::Char('>') => {
app.reset_count();
app.defer_view_build_for_jump();
if app.stepping {
app.goto_last_step();
} else {
app.goto_last_hunk_scroll();
}
}
KeyCode::Char('[') => {
let count = app.take_count();
for _ in 0..count {
app.prev_file();
}
}
KeyCode::Char(']') => {
let count = app.take_count();
for _ in 0..count {
app.next_file();
}
}
KeyCode::Char(' ') => {
app.reset_count();
if app.stepping {
app.toggle_autoplay();
}
}
KeyCode::Char('B') => {
app.reset_count();
if app.stepping {
app.toggle_autoplay_reverse();
}
}
KeyCode::Tab => {
app.reset_count();
app.toggle_view_mode();
}
KeyCode::BackTab => {
app.reset_count();
app.toggle_view_mode_reverse();
}
KeyCode::Char('K') => {
let count = app.take_count();
for _ in 0..count {
app.scroll_up();
}
}
KeyCode::Char('J') => {
let count = app.take_count();
for _ in 0..count {
app.scroll_down();
}
}
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
app.reset_count();
if let Ok((_, rows)) = crossterm::terminal::size() {
let viewport_height = rows.saturating_sub(6) as usize;
app.scroll_half_page_up(viewport_height);
}
}
KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
app.reset_count();
if let Ok((_, rows)) = crossterm::terminal::size() {
let viewport_height = rows.saturating_sub(6) as usize;
app.scroll_half_page_down(viewport_height);
}
}
KeyCode::Enter => {
app.reset_count();
if app.is_multi_file() {
app.file_list_focused = !app.file_list_focused;
if !app.file_list_focused {
app.stop_file_filter();
}
}
}
KeyCode::Char('+') | KeyCode::Char('=') => {
app.reset_count();
if app.is_multi_file() && app.file_list_focused {
if let Ok((cols, _)) = crossterm::terminal::size() {
app.resize_file_panel(2, cols);
}
} else {
app.increase_speed();
}
}
KeyCode::Char('-') => {
app.reset_count();
if app.is_multi_file() && app.file_list_focused {
if let Ok((cols, _)) = crossterm::terminal::size() {
app.resize_file_panel(-2, cols);
}
} else {
app.decrease_speed();
}
}
KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => {
app.reset_count();
if app.is_multi_file() {
app.file_list_focused = !app.file_list_focused;
if !app.file_list_focused {
app.stop_file_filter();
}
}
}
KeyCode::Char('a') => {
app.reset_count();
app.toggle_animation();
}
KeyCode::Char('w') => {
app.reset_count();
app.toggle_line_wrap();
}
KeyCode::Char('t') => {
app.reset_count();
app.toggle_syntax();
}
KeyCode::Char('E') => {
app.reset_count();
if app.view_mode == ViewMode::Evolution {
app.toggle_evo_syntax();
}
}
KeyCode::Char('s') => {
app.reset_count();
app.toggle_stepping();
}
KeyCode::Char('S') => {
app.reset_count();
app.toggle_strikethrough_deletions();
}
KeyCode::Char('H') => {
let count = app.take_count();
for _ in 0..count {
app.scroll_left();
}
}
KeyCode::Char('L') => {
let count = app.take_count();
for _ in 0..count {
app.scroll_right();
}
}
KeyCode::Char('z') => {
app.reset_count();
if let Ok((_, rows)) = crossterm::terminal::size() {
let viewport_height = rows.saturating_sub(4) as usize;
app.center_on_active(viewport_height);
}
}
KeyCode::Char('Z') => {
app.reset_count();
app.toggle_zen();
}
KeyCode::Char('r') => {
app.replay_step();
}
KeyCode::Char('R') => {
app.reset_count();
if app.multi_diff.is_git_mode() {
app.refresh_all_files();
} else {
app.refresh_current_file();
}
}
KeyCode::Char('f') if key.modifiers.contains(KeyModifiers::CONTROL) => {
app.reset_count();
if app.is_multi_file() {
app.toggle_file_panel();
}
}
KeyCode::Char('f') => {
app.reset_count();
app.toggle_fold_context();
}
KeyCode::Char('/') => {
app.reset_count();
if app.file_list_focused {
app.start_file_filter();
} else {
app.start_search();
}
}
KeyCode::Char(':') => {
app.reset_count();
if !app.file_list_focused {
app.start_goto();
}
}
KeyCode::Char('n') => {
app.reset_count();
app.search_next();
}
KeyCode::Char('N') => {
app.reset_count();
app.search_prev();
}
KeyCode::Char('c') => {
app.reset_count();
app.next_conflict();
}
KeyCode::Char('C') => {
app.reset_count();
app.prev_conflict();
}
KeyCode::Char('m') => {
app.reset_count();
app.start_line_comment();
}
KeyCode::Char('M') => {
app.reset_count();
app.start_hunk_comment();
}
KeyCode::Char('x') if key.modifiers.contains(KeyModifiers::CONTROL) => {
app.reset_count();
app.clear_all_review_comments();
}
KeyCode::Char('x') => {
app.reset_count();
app.remove_line_comment_at_cursor();
}
KeyCode::Char('X') => {
app.reset_count();
app.remove_hunk_comment_at_cursor();
}
KeyCode::Char('?') => {
app.reset_count();
app.toggle_help();
}
_ => {
app.reset_count();
}
}
}
_ => {}
}
}
app.tick();
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 run_dashboard<B: Backend>(
terminal: &mut Terminal<B>,
dashboard: &mut Dashboard,
) -> Result<Option<DashboardSelection>> {
let tick_rate = Duration::from_millis(16);
loop {
terminal
.draw(|f| dashboard.draw(f))
.map_err(|e| anyhow!("{e}"))?;
if event::poll(tick_rate)? {
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 key.code {
KeyCode::Esc => {
dashboard.stop_filter();
}
KeyCode::Enter => {
if let Some(selection) = dashboard.selection() {
return Ok(Some(selection));
}
}
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
dashboard.clear_filter();
}
KeyCode::Backspace => {
dashboard.pop_filter_char();
}
KeyCode::Char('j') | KeyCode::Down => {
dashboard.move_selection(1, list_height);
}
KeyCode::Char('k') | KeyCode::Up => {
dashboard.move_selection(-1, list_height);
}
KeyCode::PageDown => {
dashboard.page_down(list_height);
}
KeyCode::PageUp => {
dashboard.page_up(list_height);
}
KeyCode::Char('g') | KeyCode::Home => {
dashboard.select_first(list_height);
}
KeyCode::Char('G') | KeyCode::End => {
dashboard.select_last(list_height);
}
KeyCode::Char(ch) => {
dashboard.push_filter_char(ch);
}
_ => {}
}
continue;
}
match key.code {
KeyCode::Esc | KeyCode::Char('q') => return Ok(None),
KeyCode::Char('/') => {
dashboard.start_filter();
}
KeyCode::Char('r') => {
dashboard.clear_pin();
}
KeyCode::Char(' ') => {
dashboard.toggle_pin();
}
KeyCode::Enter => {
if let Some(selection) = dashboard.selection() {
return Ok(Some(selection));
}
}
KeyCode::Char('j') | KeyCode::Down => {
dashboard.move_selection(1, list_height);
}
KeyCode::Char('k') | KeyCode::Up => {
dashboard.move_selection(-1, list_height);
}
KeyCode::PageDown => {
dashboard.page_down(list_height);
}
KeyCode::PageUp => {
dashboard.page_up(list_height);
}
KeyCode::Char('g') | KeyCode::Home => {
dashboard.select_first(list_height);
}
KeyCode::Char('G') | KeyCode::End => {
dashboard.select_last(list_height);
}
_ => {}
}
}
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 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.clone(),
time_format,
});
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::{config, detect_input_mode, parse_range, render_editor_args, InputMode};
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 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"]);
}
}