1mod chrome;
19mod highlight;
20mod menu;
21mod panes;
22mod rows;
23mod theme;
24
25use crate::i18n::{tr, tr_f};
26use std::io::IsTerminal;
27
28use anyhow::{Context, Result};
29use ratatui::DefaultTerminal;
30use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind};
31
32use crate::app::{FileEntry, Session, Side};
33
34pub use chrome::draw;
35pub(crate) use menu::{MenuItem, MenuSession};
36pub(crate) use theme::detect_light;
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum Outcome {
41 Completed,
43 Quit,
45}
46
47#[derive(Debug, Default)]
49pub struct UiState {
50 pub message: String,
52 pub show_help: bool,
54 pub pending_quit: bool,
56 pub(crate) theme: theme::Theme,
58 pub(crate) cache: highlight::HighlightCache,
60 pub(crate) revision: u64,
62}
63
64pub fn run_session(
69 session: &mut Session,
70 write_file: &mut dyn FnMut(&str, &[u8]) -> Result<()>,
71 light: bool,
72) -> Result<Outcome> {
73 if !std::io::stdout().is_terminal() {
76 anyhow::bail!("{}", tr("common.need_tty_resolve"));
77 }
78 let mut terminal = ratatui::init();
79 let result = event_loop(&mut terminal, session, write_file, light);
80 ratatui::restore();
81 result
82}
83
84fn event_loop(
86 terminal: &mut DefaultTerminal,
87 session: &mut Session,
88 write_file: &mut dyn FnMut(&str, &[u8]) -> Result<()>,
89 light: bool,
90) -> Result<Outcome> {
91 let mut ui = UiState {
92 theme: theme::Theme::select(light),
93 ..UiState::default()
94 };
95 loop {
96 terminal.draw(|frame| draw(frame, session, &mut ui))?;
97 let Event::Key(key) = event::read()? else {
98 continue;
99 };
100 if key.kind != KeyEventKind::Press {
101 continue;
102 }
103
104 if ui.show_help {
106 ui.show_help = false;
107 continue;
108 }
109 if key.code != KeyCode::Char('q') {
111 ui.pending_quit = false;
112 }
113 ui.message.clear();
114
115 match key.code {
116 KeyCode::Char('q') => {
117 if session.all_written() || ui.pending_quit {
118 return Ok(Outcome::Quit);
119 }
120 ui.pending_quit = true;
121 ui.message = tr("ui.quit_confirm").to_owned();
122 }
123 KeyCode::Char('?') => ui.show_help = true,
124 KeyCode::Tab => session.next_file(),
125 KeyCode::Char('z') => session.folded = !session.folded,
126 KeyCode::Char('w') => {
127 if write_current(session, write_file, &mut ui)? {
128 ui.revision += 1;
130 if session.all_written() {
131 return Ok(Outcome::Completed);
132 }
133 }
134 }
135 KeyCode::Char('e') => {
136 if let FileEntry::Text(merge) = session.current_file_mut() {
137 let initial = merge.current_content(merge.cursor);
138 if let Some(lines) = edit_lines(terminal, &initial)? {
139 merge.set_override(lines);
140 ui.revision += 1;
141 ui.message = tr("ui.edited").to_owned();
142 } else {
143 ui.message = tr("ui.edit_cancelled").to_owned();
144 }
145 }
146 }
147 code => {
148 if handle_file_key(session, code, &mut ui) {
149 ui.revision += 1;
150 }
151 }
152 }
153 }
154}
155
156fn handle_file_key(session: &mut Session, code: KeyCode, ui: &mut UiState) -> bool {
158 match session.current_file_mut() {
159 FileEntry::Text(merge) => match code {
160 KeyCode::Char('h') | KeyCode::Left => {
161 merge.apply(Side::Ours);
162 true
163 }
164 KeyCode::Char('l') | KeyCode::Right => {
165 merge.apply(Side::Theirs);
166 true
167 }
168 KeyCode::Char('x') => {
170 merge.ignore(Side::Ours);
171 merge.ignore(Side::Theirs);
172 true
173 }
174 KeyCode::Char('u') => {
175 merge.undo();
176 true
177 }
178 KeyCode::Char('U') => {
179 merge.undo_all();
180 ui.message = tr("ui.undone_all").to_owned();
181 true
182 }
183 KeyCode::Char('a') => {
184 merge.apply_all_nonconflict();
185 ui.message = tr("ui.applied_all").to_owned();
186 true
187 }
188 KeyCode::Char('j') | KeyCode::Down => {
189 merge.next_change();
190 false
191 }
192 KeyCode::Char('k') | KeyCode::Up => {
193 merge.prev_change();
194 false
195 }
196 KeyCode::Char('n') => {
197 merge.next_conflict();
198 false
199 }
200 KeyCode::Char('p') => {
201 merge.prev_conflict();
202 false
203 }
204 KeyCode::Char('y') => {
207 let lines = merge.current_content(merge.cursor);
208 ui.message = copy_feedback(&lines, tr("ui.copy_chunk"));
209 false
210 }
211 KeyCode::Char('Y') => {
212 ui.message = match copy_to_clipboard(&merge.resolved_content()) {
213 Ok(()) => tr("ui.copied_file").to_owned(),
214 Err(e) => tr_f("ui.copy_failed", &[("e", &e.to_string())]),
215 };
216 false
217 }
218 KeyCode::Char('H') => {
219 let lines = merge.chunks[merge.cursor].ours.clone();
220 ui.message = copy_feedback(&lines, tr("ui.copy_local"));
221 false
222 }
223 KeyCode::Char('L') => {
224 let lines = merge.chunks[merge.cursor].theirs.clone();
225 ui.message = copy_feedback(&lines, tr("ui.copy_remote"));
226 false
227 }
228 _ => false,
229 },
230 FileEntry::Binary { choice, .. } => {
231 match code {
232 KeyCode::Char('h') | KeyCode::Left => *choice = Some(Side::Ours),
233 KeyCode::Char('l') | KeyCode::Right => *choice = Some(Side::Theirs),
234 KeyCode::Char('u') | KeyCode::Char('U') => *choice = None,
235 _ => {}
236 }
237 false
238 }
239 }
240}
241
242fn copy_feedback(lines: &[String], what: &str) -> String {
244 match copy_to_clipboard(&lines.join("\n")) {
245 Ok(()) => tr_f(
246 "ui.copied",
247 &[("what", what), ("n", &lines.len().to_string())],
248 ),
249 Err(e) => tr_f("ui.copy_failed", &[("e", &e.to_string())]),
250 }
251}
252
253fn copy_to_clipboard(text: &str) -> Result<()> {
255 use std::io::Write as _;
256 use std::process::{Command, Stdio};
257
258 const TOOLS: [(&str, &[&str]); 3] = [
259 ("pbcopy", &[]),
260 ("xclip", &["-selection", "clipboard"]),
261 ("wl-copy", &[]),
262 ];
263 for (program, args) in TOOLS {
264 let Ok(mut child) = Command::new(program)
265 .args(args)
266 .stdin(Stdio::piped())
267 .stdout(Stdio::null())
268 .stderr(Stdio::null())
269 .spawn()
270 else {
271 continue;
272 };
273 if let Some(mut stdin) = child.stdin.take() {
274 let _ = stdin.write_all(text.as_bytes());
275 }
276 if child.wait().map(|s| s.success()).unwrap_or(false) {
277 return Ok(());
278 }
279 }
280 anyhow::bail!("{}", tr("ui.no_clipboard"))
281}
282
283fn write_current(
289 session: &mut Session,
290 write_file: &mut dyn FnMut(&str, &[u8]) -> Result<()>,
291 ui: &mut UiState,
292) -> Result<bool> {
293 if !session.current_file().ready_to_write() {
294 ui.message = tr("ui.unresolved").to_owned();
295 return Ok(false);
296 }
297 let mut auto_applied = 0;
298 if let FileEntry::Text(merge) = session.current_file_mut() {
299 auto_applied = merge.pending_changes();
300 merge.apply_all_nonconflict();
301 }
302 let entry = session.current_file();
303 let path = entry.path().to_owned();
304 write_file(&path, &entry.resolved_bytes())?;
305 session.mark_written();
306 ui.message = if auto_applied > 0 {
307 tr_f(
308 "ui.written_auto",
309 &[("path", &path), ("n", &auto_applied.to_string())],
310 )
311 } else {
312 tr_f("ui.written", &[("path", &path)])
313 };
314 Ok(true)
315}
316
317fn edit_lines(terminal: &mut DefaultTerminal, initial: &[String]) -> Result<Option<Vec<String>>> {
319 let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_owned());
320 let mut parts = editor.split_whitespace();
321 let program = parts.next().unwrap_or("vi").to_owned();
322 let args: Vec<&str> = parts.collect();
323
324 let path = std::env::temp_dir().join(format!("git-pincer-edit-{}.txt", std::process::id()));
325 std::fs::write(&path, initial.join("\n"))?;
326
327 ratatui::restore();
329 let status = std::process::Command::new(&program)
330 .args(&args)
331 .arg(&path)
332 .status();
333 *terminal = ratatui::init();
334 terminal.clear()?;
335
336 let status = status.with_context(|| tr_f("ui.editor_failed", &[("program", &program)]))?;
337 if !status.success() {
338 return Ok(None);
339 }
340 let text = std::fs::read_to_string(&path)?;
341 let _ = std::fs::remove_file(&path);
342 let text = text.strip_suffix('\n').unwrap_or(&text);
344 Ok(Some(if text.is_empty() {
345 Vec::new()
346 } else {
347 text.split('\n').map(str::to_owned).collect()
348 }))
349}
350
351#[cfg(test)]
352mod tests {
353 use super::*;
354 use crate::app::FileMerge;
355
356 #[test]
359 fn write_auto_applies_pending_nonconflict_changes() {
360 let merge = FileMerge::from_three_way(
361 "demo.txt".to_owned(),
362 "a\nb\nc\nd\n",
363 "a\nX\nc\nd\n",
364 "a\nY\nc\nD\n",
365 );
366 let mut session = Session::new(vec![FileEntry::Text(merge)], "merge".to_owned());
367 let FileEntry::Text(m) = session.current_file_mut() else {
369 unreachable!()
370 };
371 m.apply(Side::Ours);
372 m.ignore(Side::Theirs);
373
374 let mut written: Vec<u8> = Vec::new();
375 let mut ui = UiState::default();
376 let ok = write_current(
377 &mut session,
378 &mut |_path, bytes| {
379 written = bytes.to_vec();
380 Ok(())
381 },
382 &mut ui,
383 )
384 .unwrap();
385 assert!(ok);
386 assert_eq!(String::from_utf8(written).unwrap(), "a\nX\nc\nD\n");
387 assert!(ui.message.contains("auto-applied"));
388 }
389}