525 lines
18 KiB
Rust
525 lines
18 KiB
Rust
//! levcs-tui: merge-review TUI per §6.7.
|
|
//!
|
|
//! Architecture:
|
|
//! * `state` — pure state machine (no terminal I/O), unit tested.
|
|
//! * `lib.rs` (this file) — terminal driver: opens raw mode, draws,
|
|
//! reads key events, calls into the state machine, applies the
|
|
//! resolutions on quit.
|
|
//!
|
|
//! The view is four panes: file list (left), then ours / base / theirs
|
|
//! laid out horizontally for the selected file. The currently-selected
|
|
//! conflict region is highlighted in each of ours/base/theirs panes by
|
|
//! reverse-video on the byte range the engine reported. A status line
|
|
//! at the bottom shows keybindings and the current per-file resolution.
|
|
//!
|
|
//! Keys (all single-press, no modifiers):
|
|
//! * j / Down — next file
|
|
//! * k / Up — previous file
|
|
//! * n — next conflict region in current file
|
|
//! * p — previous conflict region
|
|
//! * o — accept ours for current file
|
|
//! * t — accept theirs for current file
|
|
//! * c — keep current (working-tree) bytes for current file
|
|
//! * s — skip current file
|
|
//! * q / Esc — quit (caller decides whether to apply resolutions)
|
|
|
|
use std::io;
|
|
use std::path::Path;
|
|
|
|
use anyhow::Result;
|
|
use crossterm::event::{self, Event, KeyCode};
|
|
use crossterm::execute;
|
|
use crossterm::terminal::{
|
|
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
|
|
};
|
|
use ratatui::backend::CrosstermBackend;
|
|
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
|
use ratatui::style::{Modifier, Style};
|
|
use ratatui::text::{Line, Span};
|
|
use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Wrap};
|
|
use ratatui::Terminal;
|
|
|
|
use levcs_merge::{ConflictRegion, MergeStatus};
|
|
|
|
pub mod editor;
|
|
pub mod state;
|
|
|
|
pub use editor::{run_editor_on, EditError, EditOutcome};
|
|
pub use state::{ApplyReport, FileEntry, Resolution, ReviewState};
|
|
|
|
/// Backwards-compatible alias kept so older call sites that built
|
|
/// `FileMerge` continue to compile. New code should use `FileEntry`.
|
|
pub type FileMerge = FileEntry;
|
|
|
|
/// Run the interactive review loop. Blocks until the user quits.
|
|
/// Returns the per-file resolutions; the caller chooses whether to
|
|
/// apply them (e.g. via `state.apply(workdir)`).
|
|
pub fn review(files: Vec<FileEntry>) -> Result<ReviewState> {
|
|
let mut state = ReviewState::new(files);
|
|
|
|
enable_raw_mode()?;
|
|
let mut stdout = io::stdout();
|
|
execute!(stdout, EnterAlternateScreen)?;
|
|
let backend = CrosstermBackend::new(stdout);
|
|
let mut terminal = Terminal::new(backend)?;
|
|
|
|
let outcome = run_loop(&mut terminal, &mut state);
|
|
|
|
// Always restore the terminal, even if the loop bailed with an
|
|
// error — leaving raw mode enabled would render the user's shell
|
|
// unusable.
|
|
let _ = disable_raw_mode();
|
|
let _ = execute!(terminal.backend_mut(), LeaveAlternateScreen);
|
|
let _ = terminal.show_cursor();
|
|
|
|
outcome?;
|
|
Ok(state)
|
|
}
|
|
|
|
/// Run the review and apply the resolutions in one step. Convenience
|
|
/// wrapper for the common `levcs merge --review` path.
|
|
pub fn review_and_apply(files: Vec<FileEntry>, workdir: &Path) -> Result<ApplyReport> {
|
|
let state = review(files)?;
|
|
Ok(state.apply(workdir)?)
|
|
}
|
|
|
|
/// Drive the same TUI in inspect-only mode (`merge --explain`).
|
|
/// Resolution-changing keys are no-ops; navigation and the three-pane
|
|
/// diff still work. Returns when the user quits.
|
|
pub fn review_read_only(files: Vec<FileEntry>) -> Result<ReviewState> {
|
|
let mut state = ReviewState::new_read_only(files);
|
|
|
|
enable_raw_mode()?;
|
|
let mut stdout = io::stdout();
|
|
execute!(stdout, EnterAlternateScreen)?;
|
|
let backend = CrosstermBackend::new(stdout);
|
|
let mut terminal = Terminal::new(backend)?;
|
|
|
|
let outcome = run_loop(&mut terminal, &mut state);
|
|
|
|
let _ = disable_raw_mode();
|
|
let _ = execute!(terminal.backend_mut(), LeaveAlternateScreen);
|
|
let _ = terminal.show_cursor();
|
|
|
|
outcome?;
|
|
Ok(state)
|
|
}
|
|
|
|
fn run_loop(
|
|
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
|
state: &mut ReviewState,
|
|
) -> Result<()> {
|
|
while !state.quitting {
|
|
terminal.draw(|frame| draw(frame, state))?;
|
|
if let Event::Key(k) = event::read()? {
|
|
// The `e` key (external editor) needs the terminal restored
|
|
// before we hand control to vi/nano/etc., so it can't go
|
|
// through the pure state machine — we handle it here and
|
|
// route everything else to handle_key. Errors from the
|
|
// editor are swallowed to keep the user in the loop; the
|
|
// current resolution is left untouched on failure.
|
|
if k.code == KeyCode::Char('e') {
|
|
let _ = invoke_editor(terminal, state);
|
|
} else {
|
|
handle_key(state, k.code);
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Suspend the TUI, run the configured external editor on the current
|
|
/// file's bytes, then resume the TUI. The editor's saved bytes (if any)
|
|
/// become a `Resolution::Edit` on the current file. Specialized to
|
|
/// `CrosstermBackend` so we can call `execute!` on its underlying
|
|
/// stdout — generalizing to any `Backend` doesn't gain anything since
|
|
/// the rest of the driver is already crossterm-only.
|
|
fn invoke_editor(
|
|
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
|
state: &mut ReviewState,
|
|
) -> Result<()> {
|
|
let file = match state.current_file() {
|
|
Some(f) => f.clone(),
|
|
None => return Ok(()),
|
|
};
|
|
// Tear down raw mode + alt screen so the editor gets a clean
|
|
// controlling terminal. Re-enter both on return regardless of
|
|
// outcome so an editor crash doesn't leave the user stranded.
|
|
let _ = disable_raw_mode();
|
|
let _ = execute!(terminal.backend_mut(), LeaveAlternateScreen);
|
|
|
|
let outcome = editor::run_editor_on(&file.current, std::path::Path::new(&file.path));
|
|
|
|
enable_raw_mode()?;
|
|
execute!(terminal.backend_mut(), EnterAlternateScreen)?;
|
|
terminal.clear()?;
|
|
|
|
match outcome {
|
|
Ok(EditOutcome::Edited(bytes)) => {
|
|
state.set_current_resolution(Resolution::Edit { bytes });
|
|
Ok(())
|
|
}
|
|
Ok(EditOutcome::Unchanged) => Ok(()),
|
|
Err(e) => Err(anyhow::anyhow!("editor: {e}")),
|
|
}
|
|
}
|
|
|
|
/// Map a single keypress to a state-machine method. Pulled out of the
|
|
/// loop so it can be unit-tested without ratatui or a real terminal.
|
|
pub fn handle_key(state: &mut ReviewState, code: KeyCode) {
|
|
match code {
|
|
KeyCode::Char('q') | KeyCode::Esc => state.quit(),
|
|
KeyCode::Char('j') | KeyCode::Down => state.move_down(),
|
|
KeyCode::Char('k') | KeyCode::Up => state.move_up(),
|
|
KeyCode::Char('n') => state.next_region(),
|
|
KeyCode::Char('p') => state.prev_region(),
|
|
KeyCode::Char('o') => state.accept_ours(),
|
|
KeyCode::Char('t') => state.accept_theirs(),
|
|
KeyCode::Char('c') => state.keep_current(),
|
|
KeyCode::Char('s') => state.skip(),
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn draw(frame: &mut ratatui::Frame, state: &ReviewState) {
|
|
let area = frame.area();
|
|
let outer = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([
|
|
Constraint::Length(1),
|
|
Constraint::Min(3),
|
|
Constraint::Length(2),
|
|
])
|
|
.split(area);
|
|
|
|
draw_title(frame, outer[0], state);
|
|
draw_main(frame, outer[1], state);
|
|
draw_status(frame, outer[2], state);
|
|
}
|
|
|
|
fn draw_title(frame: &mut ratatui::Frame, area: Rect, state: &ReviewState) {
|
|
let total = state.files.len();
|
|
let conflicted = state
|
|
.files
|
|
.iter()
|
|
.filter(|f| matches!(f.status, MergeStatus::Conflict { .. }))
|
|
.count();
|
|
let mode = if state.read_only {
|
|
"--explain"
|
|
} else {
|
|
"--review"
|
|
};
|
|
let title = format!(" levcs merge {mode} {total} file(s), {conflicted} with conflicts ");
|
|
frame.render_widget(
|
|
Paragraph::new(title)
|
|
.style(Style::default().add_modifier(Modifier::BOLD | Modifier::REVERSED)),
|
|
area,
|
|
);
|
|
}
|
|
|
|
fn draw_main(frame: &mut ratatui::Frame, area: Rect, state: &ReviewState) {
|
|
let cols = Layout::default()
|
|
.direction(Direction::Horizontal)
|
|
.constraints([
|
|
Constraint::Percentage(22),
|
|
Constraint::Percentage(26),
|
|
Constraint::Percentage(26),
|
|
Constraint::Percentage(26),
|
|
])
|
|
.split(area);
|
|
|
|
draw_file_list(frame, cols[0], state);
|
|
if let Some(file) = state.current_file() {
|
|
let region = file.conflict_regions().get(state.selected_region);
|
|
draw_pane(
|
|
frame,
|
|
cols[1],
|
|
"ours",
|
|
&file.ours,
|
|
region.map(|r| r.ours.clone()),
|
|
);
|
|
draw_pane(
|
|
frame,
|
|
cols[2],
|
|
"base",
|
|
&file.base,
|
|
region.map(|r| r.base.clone()),
|
|
);
|
|
draw_pane(
|
|
frame,
|
|
cols[3],
|
|
"theirs",
|
|
&file.theirs,
|
|
region.map(|r| r.theirs.clone()),
|
|
);
|
|
}
|
|
}
|
|
|
|
fn draw_file_list(frame: &mut ratatui::Frame, area: Rect, state: &ReviewState) {
|
|
let items: Vec<ListItem> = state
|
|
.files
|
|
.iter()
|
|
.zip(&state.resolutions)
|
|
.enumerate()
|
|
.map(|(i, (f, r))| {
|
|
let label = match &f.status {
|
|
MergeStatus::Merged { .. } => format!("[ok] {}", f.path),
|
|
MergeStatus::Conflict { regions, .. } => {
|
|
format!("[{}] {}", regions.len(), f.path)
|
|
}
|
|
MergeStatus::NotApplicable => format!("[--] {}", f.path),
|
|
};
|
|
let tag = match r {
|
|
Resolution::KeepCurrent => "keep",
|
|
Resolution::AcceptOurs => "ours",
|
|
Resolution::AcceptTheirs => "theirs",
|
|
Resolution::Edit { .. } => "edited",
|
|
Resolution::Skip => "skip",
|
|
};
|
|
let line = format!("{label} ({tag})");
|
|
let mut style = Style::default();
|
|
if i == state.selected_file {
|
|
style = style.add_modifier(Modifier::REVERSED);
|
|
}
|
|
ListItem::new(line).style(style)
|
|
})
|
|
.collect();
|
|
let list = List::new(items).block(Block::default().borders(Borders::ALL).title("files"));
|
|
frame.render_widget(list, area);
|
|
}
|
|
|
|
fn draw_pane(
|
|
frame: &mut ratatui::Frame,
|
|
area: Rect,
|
|
title: &str,
|
|
bytes: &[u8],
|
|
highlight: Option<std::ops::Range<usize>>,
|
|
) {
|
|
// Convert to text best-effort. Non-UTF-8 bytes pass through as the
|
|
// replacement character — better than an empty pane.
|
|
let text = String::from_utf8_lossy(bytes);
|
|
|
|
// Build lines with highlighting for any byte range that falls within
|
|
// the conflict region. The highlight is computed in *byte* space
|
|
// (since that's what ConflictRegion uses) and translated to the
|
|
// current line as we walk.
|
|
let mut lines: Vec<Line> = Vec::new();
|
|
let mut byte_offset = 0usize;
|
|
for raw_line in text.split('\n') {
|
|
let line_start = byte_offset;
|
|
let line_end = line_start + raw_line.len();
|
|
// We split on '\n' so the byte itself wasn't included in
|
|
// raw_line — account for it in byte_offset.
|
|
byte_offset = line_end + 1;
|
|
|
|
let line_span = match &highlight {
|
|
Some(range) if range.start < line_end && range.end > line_start => {
|
|
let h_start = range.start.saturating_sub(line_start);
|
|
let h_end = range.end.saturating_sub(line_start).min(raw_line.len());
|
|
let mut spans = Vec::new();
|
|
if h_start > 0 {
|
|
spans.push(Span::raw(safe_substr(raw_line, 0, h_start)));
|
|
}
|
|
if h_end > h_start {
|
|
spans.push(Span::styled(
|
|
safe_substr(raw_line, h_start, h_end),
|
|
Style::default().add_modifier(Modifier::REVERSED),
|
|
));
|
|
}
|
|
if h_end < raw_line.len() {
|
|
spans.push(Span::raw(safe_substr(raw_line, h_end, raw_line.len())));
|
|
}
|
|
Line::from(spans)
|
|
}
|
|
_ => Line::from(raw_line.to_string()),
|
|
};
|
|
lines.push(line_span);
|
|
}
|
|
|
|
let p = Paragraph::new(lines)
|
|
.block(
|
|
Block::default()
|
|
.borders(Borders::ALL)
|
|
.title(title.to_string()),
|
|
)
|
|
.wrap(Wrap { trim: false });
|
|
frame.render_widget(p, area);
|
|
}
|
|
|
|
/// Slice `&str` by byte range, snapping each end to the nearest later
|
|
/// char boundary. Without this, slicing into a multi-byte UTF-8
|
|
/// sequence panics — and `ConflictRegion` ranges are byte ranges.
|
|
fn safe_substr(s: &str, start: usize, end: usize) -> String {
|
|
let mut a = start.min(s.len());
|
|
while a < s.len() && !s.is_char_boundary(a) {
|
|
a += 1;
|
|
}
|
|
let mut b = end.min(s.len());
|
|
while b < s.len() && !s.is_char_boundary(b) {
|
|
b += 1;
|
|
}
|
|
if a > b {
|
|
a = b;
|
|
}
|
|
s[a..b].to_string()
|
|
}
|
|
|
|
fn draw_status(frame: &mut ratatui::Frame, area: Rect, state: &ReviewState) {
|
|
let file = state.current_file();
|
|
let region_info = file
|
|
.map(|f| {
|
|
let n = f.conflict_regions().len();
|
|
if n == 0 {
|
|
"no conflicts".to_string()
|
|
} else {
|
|
format!("region {}/{}", state.selected_region + 1, n)
|
|
}
|
|
})
|
|
.unwrap_or_default();
|
|
let line1 = if state.read_only {
|
|
// Explain mode shows the handler that produced the file's
|
|
// outcome plus any engine notes — that's the whole point of
|
|
// stepping through auto-resolutions per §6.7.
|
|
let handler = file.map(|f| f.handler.as_str()).unwrap_or("");
|
|
let notes = file.map(|f| f.notes.as_str()).unwrap_or("");
|
|
if notes.is_empty() {
|
|
format!(" {region_info} handler: {handler}")
|
|
} else {
|
|
format!(" {region_info} handler: {handler} notes: {notes}")
|
|
}
|
|
} else {
|
|
let resolution = match state.current_resolution() {
|
|
Resolution::KeepCurrent => "keep current".to_string(),
|
|
Resolution::AcceptOurs => "accept ours".into(),
|
|
Resolution::AcceptTheirs => "accept theirs".into(),
|
|
Resolution::Edit { bytes } => format!("edited ({} bytes)", bytes.len()),
|
|
Resolution::Skip => "skip".into(),
|
|
};
|
|
format!(" {region_info} resolution: {resolution}")
|
|
};
|
|
let line2 = if state.read_only {
|
|
" j/k file n/p region q quit"
|
|
} else {
|
|
" j/k file n/p region o ours t theirs e edit c keep s skip q quit"
|
|
};
|
|
let p = Paragraph::new(vec![
|
|
Line::from(line1).style(Style::default().add_modifier(Modifier::REVERSED)),
|
|
Line::from(line2),
|
|
]);
|
|
frame.render_widget(p, area);
|
|
}
|
|
|
|
/// Build a non-interactive textual summary of structured conflicts.
|
|
/// Useful when the caller invokes `levcs merge --explain` or wants
|
|
/// to drive the JSON reporter described in §6.7.
|
|
pub fn summarize(regions: &[ConflictRegion]) -> String {
|
|
let mut s = String::new();
|
|
for r in regions {
|
|
s.push_str(&format!(
|
|
"- {} (base {}..{}, ours {}..{}, theirs {}..{})\n",
|
|
r.description,
|
|
r.base.start,
|
|
r.base.end,
|
|
r.ours.start,
|
|
r.ours.end,
|
|
r.theirs.start,
|
|
r.theirs.end
|
|
));
|
|
}
|
|
s
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod key_tests {
|
|
use super::*;
|
|
use crossterm::event::KeyCode;
|
|
use std::ops::Range;
|
|
|
|
fn entry(path: &str, regions: usize) -> FileEntry {
|
|
let regs: Vec<ConflictRegion> = (0..regions)
|
|
.map(|i| ConflictRegion {
|
|
description: format!("region {i}"),
|
|
base: Range { start: 0, end: 0 },
|
|
ours: Range { start: 0, end: 0 },
|
|
theirs: Range { start: 0, end: 0 },
|
|
})
|
|
.collect();
|
|
FileEntry {
|
|
path: path.into(),
|
|
status: if regions == 0 {
|
|
MergeStatus::Merged {
|
|
content: vec![],
|
|
notes: vec![],
|
|
}
|
|
} else {
|
|
MergeStatus::Conflict {
|
|
regions: regs,
|
|
partial: vec![],
|
|
}
|
|
},
|
|
current: vec![],
|
|
ours: vec![],
|
|
theirs: vec![],
|
|
base: vec![],
|
|
handler: String::new(),
|
|
notes: String::new(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn handle_key_routes_resolutions() {
|
|
let mut s = ReviewState::new(vec![entry("a", 1), entry("b", 1)]);
|
|
handle_key(&mut s, KeyCode::Char('o'));
|
|
assert_eq!(s.current_resolution(), Resolution::AcceptOurs);
|
|
handle_key(&mut s, KeyCode::Char('j'));
|
|
handle_key(&mut s, KeyCode::Char('t'));
|
|
assert_eq!(s.current_resolution(), Resolution::AcceptTheirs);
|
|
}
|
|
|
|
#[test]
|
|
fn handle_key_quits_on_q_and_esc() {
|
|
let mut s = ReviewState::new(vec![entry("a", 0)]);
|
|
handle_key(&mut s, KeyCode::Char('q'));
|
|
assert!(s.quitting);
|
|
|
|
let mut s2 = ReviewState::new(vec![entry("a", 0)]);
|
|
handle_key(&mut s2, KeyCode::Esc);
|
|
assert!(s2.quitting);
|
|
}
|
|
|
|
#[test]
|
|
fn handle_key_navigates_regions() {
|
|
let mut s = ReviewState::new(vec![entry("a", 4)]);
|
|
handle_key(&mut s, KeyCode::Char('n'));
|
|
handle_key(&mut s, KeyCode::Char('n'));
|
|
assert_eq!(s.selected_region, 2);
|
|
handle_key(&mut s, KeyCode::Char('p'));
|
|
assert_eq!(s.selected_region, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn handle_key_ignores_unknown_keys() {
|
|
let mut s = ReviewState::new(vec![entry("a", 1)]);
|
|
handle_key(&mut s, KeyCode::Char('x'));
|
|
handle_key(&mut s, KeyCode::F(5));
|
|
assert_eq!(s.current_resolution(), Resolution::KeepCurrent);
|
|
assert!(!s.quitting);
|
|
}
|
|
|
|
#[test]
|
|
fn safe_substr_handles_multibyte_correctly() {
|
|
let s = "héllo wörld";
|
|
// Ranges that fall inside multi-byte characters must not panic.
|
|
let _ = safe_substr(s, 1, 4);
|
|
let _ = safe_substr(s, 0, s.len() + 100);
|
|
let _ = safe_substr(s, 100, 200);
|
|
}
|
|
|
|
#[test]
|
|
fn safe_substr_returns_full_slice_when_aligned() {
|
|
let s = "hello world";
|
|
assert_eq!(safe_substr(s, 0, 5), "hello");
|
|
assert_eq!(safe_substr(s, 6, 11), "world");
|
|
}
|
|
}
|