mod chrome;
mod highlight;
mod menu;
mod panes;
mod rows;
mod theme;
use crate::i18n::{tr, tr_f};
use std::io::IsTerminal;
use anyhow::{Context, Result};
use ratatui::DefaultTerminal;
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind};
use crate::app::{FileEntry, Session, Side};
pub use chrome::draw;
pub(crate) use menu::{MenuItem, MenuSession};
pub(crate) use theme::detect_light;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Outcome {
Completed,
Quit,
}
#[derive(Debug, Default)]
pub struct UiState {
pub message: String,
pub show_help: bool,
pub pending_quit: bool,
pub(crate) theme: theme::Theme,
pub(crate) cache: highlight::HighlightCache,
pub(crate) revision: u64,
}
pub fn run_session(
session: &mut Session,
write_file: &mut dyn FnMut(&str, &[u8]) -> Result<()>,
light: bool,
) -> Result<Outcome> {
if !std::io::stdout().is_terminal() {
anyhow::bail!("{}", tr("common.need_tty_resolve"));
}
let mut terminal = ratatui::init();
let result = event_loop(&mut terminal, session, write_file, light);
ratatui::restore();
result
}
fn event_loop(
terminal: &mut DefaultTerminal,
session: &mut Session,
write_file: &mut dyn FnMut(&str, &[u8]) -> Result<()>,
light: bool,
) -> Result<Outcome> {
let mut ui = UiState {
theme: theme::Theme::select(light),
..UiState::default()
};
loop {
terminal.draw(|frame| draw(frame, session, &mut ui))?;
let Event::Key(key) = event::read()? else {
continue;
};
if key.kind != KeyEventKind::Press {
continue;
}
if ui.show_help {
ui.show_help = false;
continue;
}
if key.code != KeyCode::Char('q') {
ui.pending_quit = false;
}
ui.message.clear();
match key.code {
KeyCode::Char('q') => {
if session.all_written() || ui.pending_quit {
return Ok(Outcome::Quit);
}
ui.pending_quit = true;
ui.message = tr("ui.quit_confirm").to_owned();
}
KeyCode::Char('?') => ui.show_help = true,
KeyCode::Tab => session.next_file(),
KeyCode::Char('z') => session.folded = !session.folded,
KeyCode::Char('w') => {
if write_current(session, write_file, &mut ui)? {
ui.revision += 1;
if session.all_written() {
return Ok(Outcome::Completed);
}
}
}
KeyCode::Char('e') => {
if let FileEntry::Text(merge) = session.current_file_mut() {
let initial = merge.current_content(merge.cursor);
if let Some(lines) = edit_lines(terminal, &initial)? {
merge.set_override(lines);
ui.revision += 1;
ui.message = tr("ui.edited").to_owned();
} else {
ui.message = tr("ui.edit_cancelled").to_owned();
}
}
}
code => {
if handle_file_key(session, code, &mut ui) {
ui.revision += 1;
}
}
}
}
}
fn handle_file_key(session: &mut Session, code: KeyCode, ui: &mut UiState) -> bool {
match session.current_file_mut() {
FileEntry::Text(merge) => match code {
KeyCode::Char('h') | KeyCode::Left => {
merge.apply(Side::Ours);
true
}
KeyCode::Char('l') | KeyCode::Right => {
merge.apply(Side::Theirs);
true
}
KeyCode::Char('x') => {
merge.ignore(Side::Ours);
merge.ignore(Side::Theirs);
true
}
KeyCode::Char('u') => {
merge.undo();
true
}
KeyCode::Char('U') => {
merge.undo_all();
ui.message = tr("ui.undone_all").to_owned();
true
}
KeyCode::Char('a') => {
merge.apply_all_nonconflict();
ui.message = tr("ui.applied_all").to_owned();
true
}
KeyCode::Char('j') | KeyCode::Down => {
merge.next_change();
false
}
KeyCode::Char('k') | KeyCode::Up => {
merge.prev_change();
false
}
KeyCode::Char('n') => {
merge.next_conflict();
false
}
KeyCode::Char('p') => {
merge.prev_conflict();
false
}
KeyCode::Char('y') => {
let lines = merge.current_content(merge.cursor);
ui.message = copy_feedback(&lines, tr("ui.copy_chunk"));
false
}
KeyCode::Char('Y') => {
ui.message = match copy_to_clipboard(&merge.resolved_content()) {
Ok(()) => tr("ui.copied_file").to_owned(),
Err(e) => tr_f("ui.copy_failed", &[("e", &e.to_string())]),
};
false
}
KeyCode::Char('H') => {
let lines = merge.chunks[merge.cursor].ours.clone();
ui.message = copy_feedback(&lines, tr("ui.copy_local"));
false
}
KeyCode::Char('L') => {
let lines = merge.chunks[merge.cursor].theirs.clone();
ui.message = copy_feedback(&lines, tr("ui.copy_remote"));
false
}
_ => false,
},
FileEntry::Binary { choice, .. } => {
match code {
KeyCode::Char('h') | KeyCode::Left => *choice = Some(Side::Ours),
KeyCode::Char('l') | KeyCode::Right => *choice = Some(Side::Theirs),
KeyCode::Char('u') | KeyCode::Char('U') => *choice = None,
_ => {}
}
false
}
}
}
fn copy_feedback(lines: &[String], what: &str) -> String {
match copy_to_clipboard(&lines.join("\n")) {
Ok(()) => tr_f(
"ui.copied",
&[("what", what), ("n", &lines.len().to_string())],
),
Err(e) => tr_f("ui.copy_failed", &[("e", &e.to_string())]),
}
}
fn copy_to_clipboard(text: &str) -> Result<()> {
use std::io::Write as _;
use std::process::{Command, Stdio};
const TOOLS: [(&str, &[&str]); 3] = [
("pbcopy", &[]),
("xclip", &["-selection", "clipboard"]),
("wl-copy", &[]),
];
for (program, args) in TOOLS {
let Ok(mut child) = Command::new(program)
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
else {
continue;
};
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(text.as_bytes());
}
if child.wait().map(|s| s.success()).unwrap_or(false) {
return Ok(());
}
}
anyhow::bail!("{}", tr("ui.no_clipboard"))
}
fn write_current(
session: &mut Session,
write_file: &mut dyn FnMut(&str, &[u8]) -> Result<()>,
ui: &mut UiState,
) -> Result<bool> {
if !session.current_file().ready_to_write() {
ui.message = tr("ui.unresolved").to_owned();
return Ok(false);
}
let mut auto_applied = 0;
if let FileEntry::Text(merge) = session.current_file_mut() {
auto_applied = merge.pending_changes();
merge.apply_all_nonconflict();
}
let entry = session.current_file();
let path = entry.path().to_owned();
write_file(&path, &entry.resolved_bytes())?;
session.mark_written();
ui.message = if auto_applied > 0 {
tr_f(
"ui.written_auto",
&[("path", &path), ("n", &auto_applied.to_string())],
)
} else {
tr_f("ui.written", &[("path", &path)])
};
Ok(true)
}
fn edit_lines(terminal: &mut DefaultTerminal, initial: &[String]) -> Result<Option<Vec<String>>> {
let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_owned());
let mut parts = editor.split_whitespace();
let program = parts.next().unwrap_or("vi").to_owned();
let args: Vec<&str> = parts.collect();
let path = std::env::temp_dir().join(format!("git-pincer-edit-{}.txt", std::process::id()));
std::fs::write(&path, initial.join("\n"))?;
ratatui::restore();
let status = std::process::Command::new(&program)
.args(&args)
.arg(&path)
.status();
*terminal = ratatui::init();
terminal.clear()?;
let status = status.with_context(|| tr_f("ui.editor_failed", &[("program", &program)]))?;
if !status.success() {
return Ok(None);
}
let text = std::fs::read_to_string(&path)?;
let _ = std::fs::remove_file(&path);
let text = text.strip_suffix('\n').unwrap_or(&text);
Ok(Some(if text.is_empty() {
Vec::new()
} else {
text.split('\n').map(str::to_owned).collect()
}))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::FileMerge;
#[test]
fn write_auto_applies_pending_nonconflict_changes() {
let merge = FileMerge::from_three_way(
"demo.txt".to_owned(),
"a\nb\nc\nd\n",
"a\nX\nc\nd\n",
"a\nY\nc\nD\n",
);
let mut session = Session::new(vec![FileEntry::Text(merge)], "merge".to_owned());
let FileEntry::Text(m) = session.current_file_mut() else {
unreachable!()
};
m.apply(Side::Ours);
m.ignore(Side::Theirs);
let mut written: Vec<u8> = Vec::new();
let mut ui = UiState::default();
let ok = write_current(
&mut session,
&mut |_path, bytes| {
written = bytes.to_vec();
Ok(())
},
&mut ui,
)
.unwrap();
assert!(ok);
assert_eq!(String::from_utf8(written).unwrap(), "a\nX\nc\nD\n");
assert!(ui.message.contains("auto-applied"));
}
}