1mod chrome;
20mod highlight;
21pub(crate) mod keymap;
22mod menu;
23mod panes;
24mod rows;
25mod theme;
26
27use crate::i18n::{tr, tr_f};
28use std::io::IsTerminal;
29
30use anyhow::{Context, Result};
31use ratatui::DefaultTerminal;
32use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
33
34use crate::app::{FileEntry, Session, Side};
35use keymap::Action;
36
37pub use chrome::draw;
38pub(crate) use menu::{MenuItem, MenuSession};
39pub(crate) use theme::{detect_light, init_overrides as init_theme_overrides};
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum Outcome {
44 Completed,
46 Quit,
48}
49
50#[derive(Debug, Default)]
52pub struct UiState {
53 pub message: String,
55 pub show_help: bool,
57 pub pending_quit: bool,
59 pub(crate) theme: theme::Theme,
61 pub(crate) cache: highlight::HighlightCache,
63 pub(crate) rows: rows::RowCache,
65 pub(crate) revision: u64,
68 pub(crate) scroll_request: isize,
70}
71
72pub fn run_session(
77 session: &mut Session,
78 write_file: &mut dyn FnMut(&str, &[u8]) -> Result<()>,
79 light: bool,
80) -> Result<Outcome> {
81 if !std::io::stdout().is_terminal() {
84 anyhow::bail!("{}", tr("common.need_tty_resolve"));
85 }
86 run_session_in(ratatui::init(), session, write_file, light)
87}
88
89pub(crate) fn run_session_in(
95 mut terminal: DefaultTerminal,
96 session: &mut Session,
97 write_file: &mut dyn FnMut(&str, &[u8]) -> Result<()>,
98 light: bool,
99) -> Result<Outcome> {
100 let result = event_loop(&mut terminal, session, write_file, light);
101 ratatui::restore();
102 result
103}
104
105fn event_loop(
107 terminal: &mut DefaultTerminal,
108 session: &mut Session,
109 write_file: &mut dyn FnMut(&str, &[u8]) -> Result<()>,
110 light: bool,
111) -> Result<Outcome> {
112 let mut ui = UiState {
113 theme: theme::Theme::select(light),
114 ..UiState::default()
115 };
116 loop {
117 terminal.draw(|frame| draw(frame, session, &mut ui))?;
118 let Event::Key(key) = event::read()? else {
119 continue;
120 };
121 if key.kind != KeyEventKind::Press {
122 continue;
123 }
124
125 if ui.show_help {
127 ui.show_help = false;
128 continue;
129 }
130 let action =
133 if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
134 Some(Action::Quit)
135 } else {
136 keymap::action_for(key.code, key.modifiers)
137 };
138 if action != Some(Action::Quit) {
140 ui.pending_quit = false;
141 }
142 ui.message.clear();
143 let Some(action) = action else {
144 continue;
145 };
146
147 match action {
148 Action::Quit => {
149 if session.all_written() || ui.pending_quit {
150 return Ok(Outcome::Quit);
151 }
152 ui.pending_quit = true;
153 ui.message = tr("ui.quit_confirm").to_owned();
154 }
155 Action::Help => ui.show_help = true,
156 Action::NextFile => session.next_file(),
157 Action::ToggleFold => session.folded = !session.folded,
158 Action::ScrollDown => ui.scroll_request += 1,
160 Action::ScrollUp => ui.scroll_request -= 1,
161 Action::WriteFile => {
162 if write_current(session, write_file, &mut ui)? {
163 ui.revision += 1;
165 if session.all_written() {
166 return Ok(Outcome::Completed);
167 }
168 }
169 }
170 Action::EditChunk => {
171 if let FileEntry::Text(merge) = session.current_file_mut() {
172 let initial = merge.current_content(merge.cursor);
173 if let Some(lines) = edit_lines(terminal, &initial)? {
174 merge.set_override(lines);
175 ui.revision += 1;
176 ui.message = tr("ui.edited").to_owned();
177 } else {
178 ui.message = tr("ui.edit_cancelled").to_owned();
179 }
180 }
181 }
182 other => {
183 if handle_file_key(session, other, &mut ui) {
184 ui.revision += 1;
185 }
186 }
187 }
188 }
189}
190
191fn handle_file_key(session: &mut Session, action: Action, ui: &mut UiState) -> bool {
193 match session.current_file_mut() {
194 FileEntry::Text(merge) => {
195 merge.follow = true;
197 match action {
198 Action::TakeLocal => {
199 merge.apply(Side::Ours);
200 true
201 }
202 Action::TakeRemote => {
203 merge.apply(Side::Theirs);
204 true
205 }
206 Action::IgnoreChunk => {
208 merge.ignore(Side::Ours);
209 merge.ignore(Side::Theirs);
210 true
211 }
212 Action::UndoChunk => {
213 merge.undo();
214 true
215 }
216 Action::UndoFile => {
217 merge.undo_all();
218 ui.message = tr("ui.undone_all").to_owned();
219 true
220 }
221 Action::ApplyNonConflict => {
222 merge.apply_all_nonconflict();
223 ui.message = tr("ui.applied_all").to_owned();
224 true
225 }
226 Action::NextChange => {
227 merge.next_change();
228 false
229 }
230 Action::PrevChange => {
231 merge.prev_change();
232 false
233 }
234 Action::NextConflict => {
235 merge.next_conflict();
236 false
237 }
238 Action::PrevConflict => {
239 merge.prev_conflict();
240 false
241 }
242 Action::CopyChunk => {
245 let lines = merge.current_content(merge.cursor);
246 ui.message = copy_feedback(&lines, tr("ui.copy_chunk"));
247 false
248 }
249 Action::CopyFile => {
250 ui.message = match copy_to_clipboard(&merge.resolved_content()) {
251 Ok(()) => tr("ui.copied_file").to_owned(),
252 Err(e) => tr_f("ui.copy_failed", &[("e", &e.to_string())]),
253 };
254 false
255 }
256 Action::CopyLocal => {
257 let lines = merge.chunks[merge.cursor].ours_lines().to_vec();
258 ui.message = copy_feedback(&lines, tr("ui.copy_local"));
259 false
260 }
261 Action::CopyRemote => {
262 let lines = merge.chunks[merge.cursor].theirs_lines().to_vec();
263 ui.message = copy_feedback(&lines, tr("ui.copy_remote"));
264 false
265 }
266 _ => false,
267 }
268 }
269 FileEntry::Binary { choice, .. } => {
270 match action {
271 Action::TakeLocal => *choice = Some(Side::Ours),
272 Action::TakeRemote => *choice = Some(Side::Theirs),
273 Action::UndoChunk | Action::UndoFile => *choice = None,
274 _ => {}
275 }
276 false
277 }
278 }
279}
280
281fn copy_feedback(lines: &[String], what: &str) -> String {
283 match copy_to_clipboard(&lines.join("\n")) {
284 Ok(()) => tr_f(
285 "ui.copied",
286 &[("what", what), ("n", &lines.len().to_string())],
287 ),
288 Err(e) => tr_f("ui.copy_failed", &[("e", &e.to_string())]),
289 }
290}
291
292fn copy_to_clipboard(text: &str) -> Result<()> {
296 use std::io::Write as _;
297 use std::process::{Command, Stdio};
298
299 const TOOLS: [(&str, &[&str]); 3] = [
300 ("pbcopy", &[]),
301 ("xclip", &["-selection", "clipboard"]),
302 ("wl-copy", &[]),
303 ];
304 for (program, args) in TOOLS {
305 let Ok(mut child) = Command::new(program)
306 .args(args)
307 .stdin(Stdio::piped())
308 .stdout(Stdio::null())
309 .stderr(Stdio::null())
310 .spawn()
311 else {
312 continue;
313 };
314 if let Some(mut stdin) = child.stdin.take() {
315 let _ = stdin.write_all(text.as_bytes());
316 }
317 if child.wait().map(|s| s.success()).unwrap_or(false) {
318 return Ok(());
319 }
320 }
321 osc52_copy(text)
322}
323
324fn osc52_copy(text: &str) -> Result<()> {
326 use std::io::Write as _;
327 let mut out = std::io::stdout();
328 write!(out, "\x1b]52;c;{}\x07", base64(text.as_bytes()))?;
329 out.flush()?;
330 Ok(())
331}
332
333fn base64(data: &[u8]) -> String {
335 const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
336 let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
337 for chunk in data.chunks(3) {
338 let bytes = [
339 chunk[0],
340 *chunk.get(1).unwrap_or(&0),
341 *chunk.get(2).unwrap_or(&0),
342 ];
343 let n = (u32::from(bytes[0]) << 16) | (u32::from(bytes[1]) << 8) | u32::from(bytes[2]);
344 let sextets = [(n >> 18) & 63, (n >> 12) & 63, (n >> 6) & 63, n & 63];
345 for (i, sextet) in sextets.iter().enumerate() {
346 if i <= chunk.len() {
347 out.push(TABLE[*sextet as usize] as char);
348 } else {
349 out.push('=');
350 }
351 }
352 }
353 out
354}
355
356fn write_current(
362 session: &mut Session,
363 write_file: &mut dyn FnMut(&str, &[u8]) -> Result<()>,
364 ui: &mut UiState,
365) -> Result<bool> {
366 if !session.current_file().ready_to_write() {
367 ui.message = tr("ui.unresolved").to_owned();
368 return Ok(false);
369 }
370 let mut auto_applied = 0;
371 if let FileEntry::Text(merge) = session.current_file_mut() {
372 auto_applied = merge.pending_changes();
373 merge.apply_all_nonconflict();
374 }
375 let entry = session.current_file();
376 let path = entry.path().to_owned();
377 write_file(&path, &entry.resolved_bytes())?;
378 session.mark_written();
379 ui.message = if auto_applied > 0 {
380 tr_f(
381 "ui.written_auto",
382 &[("path", &path), ("n", &auto_applied.to_string())],
383 )
384 } else {
385 tr_f("ui.written", &[("path", &path)])
386 };
387 Ok(true)
388}
389
390static CONFIG_EDITOR: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
392
393pub(crate) fn init_editor(editor: Option<String>) {
395 let _ = CONFIG_EDITOR.set(editor);
396}
397
398fn edit_lines(terminal: &mut DefaultTerminal, initial: &[String]) -> Result<Option<Vec<String>>> {
400 let editor = resolve_editor();
401 let mut parts = editor.split_whitespace();
402 let program = parts.next().unwrap_or("vi").to_owned();
403 let args: Vec<&str> = parts.collect();
404
405 let path = std::env::temp_dir().join(format!("git-pincer-edit-{}.txt", std::process::id()));
406 std::fs::write(&path, initial.join("\n"))?;
407
408 ratatui::restore();
410 let status = std::process::Command::new(&program)
411 .args(&args)
412 .arg(&path)
413 .status();
414 *terminal = ratatui::init();
415 terminal.clear()?;
416
417 let status = status.with_context(|| tr_f("ui.editor_failed", &[("program", &program)]))?;
418 if !status.success() {
419 return Ok(None);
420 }
421 let text = std::fs::read_to_string(&path)?;
422 let _ = std::fs::remove_file(&path);
423 let text = text.strip_suffix('\n').unwrap_or(&text);
425 Ok(Some(if text.is_empty() {
426 Vec::new()
427 } else {
428 text.split('\n').map(str::to_owned).collect()
429 }))
430}
431
432fn resolve_editor() -> String {
436 let config = CONFIG_EDITOR.get().and_then(|e| e.as_deref());
437 let visual = std::env::var("VISUAL").ok();
438 let editor = std::env::var("EDITOR").ok();
439 pick_editor(config, visual.as_deref(), editor.as_deref(), on_path)
440}
441
442fn pick_editor(
444 config: Option<&str>,
445 visual: Option<&str>,
446 editor: Option<&str>,
447 exists: impl Fn(&str) -> bool,
448) -> String {
449 let non_empty = |v: Option<&str>| {
450 v.map(str::trim)
451 .filter(|v| !v.is_empty())
452 .map(str::to_owned)
453 };
454 if let Some(chosen) = non_empty(config)
455 .or_else(|| non_empty(visual))
456 .or_else(|| non_empty(editor))
457 {
458 return chosen;
459 }
460 if cfg!(windows) {
461 return "notepad".to_owned();
462 }
463 for candidate in ["vim", "vi"] {
464 if exists(candidate) {
465 return candidate.to_owned();
466 }
467 }
468 "vi".to_owned()
469}
470
471fn on_path(program: &str) -> bool {
473 let Some(paths) = std::env::var_os("PATH") else {
474 return false;
475 };
476 std::env::split_paths(&paths).any(|dir| dir.join(program).is_file())
477}
478
479#[cfg(test)]
480mod tests {
481 use super::*;
482 use crate::app::FileMerge;
483
484 #[test]
486 #[cfg(not(windows))]
487 fn editor_priority_chain() {
488 let both = |_: &str| true;
489 let none = |_: &str| false;
490 let only_vi = |p: &str| p == "vi";
491
492 assert_eq!(
494 pick_editor(Some("code --wait"), Some("nvim"), Some("nano"), both),
495 "code --wait"
496 );
497 assert_eq!(
498 pick_editor(Some(" "), Some("nvim"), Some("nano"), both),
499 "nvim"
500 );
501 assert_eq!(pick_editor(None, Some("nvim"), Some("nano"), both), "nvim");
503 assert_eq!(pick_editor(None, None, Some("nano"), both), "nano");
504 assert_eq!(pick_editor(None, None, None, both), "vim");
506 assert_eq!(pick_editor(None, None, None, only_vi), "vi");
507 assert_eq!(pick_editor(None, None, None, none), "vi");
508 }
509
510 #[test]
512 fn base64_matches_known_vectors() {
513 for (input, expected) in [
514 ("", ""),
515 ("f", "Zg=="),
516 ("fo", "Zm8="),
517 ("foo", "Zm9v"),
518 ("foob", "Zm9vYg=="),
519 ("hello", "aGVsbG8="),
520 ("多字节✓", "5aSa5a2X6IqC4pyT"),
521 ] {
522 assert_eq!(base64(input.as_bytes()), expected, "输入: {input:?}");
523 }
524 }
525
526 #[test]
529 fn write_auto_applies_pending_nonconflict_changes() {
530 let merge = FileMerge::from_three_way(
531 "demo.txt".to_owned(),
532 "a\nb\nc\nd\n",
533 "a\nX\nc\nd\n",
534 "a\nY\nc\nD\n",
535 );
536 let mut session = Session::new(vec![FileEntry::Text(merge)], "merge".to_owned());
537 let FileEntry::Text(m) = session.current_file_mut() else {
539 unreachable!()
540 };
541 m.apply(Side::Ours);
542 m.ignore(Side::Theirs);
543
544 let mut written: Vec<u8> = Vec::new();
545 let mut ui = UiState::default();
546 let ok = write_current(
547 &mut session,
548 &mut |_path, bytes| {
549 written = bytes.to_vec();
550 Ok(())
551 },
552 &mut ui,
553 )
554 .unwrap();
555 assert!(ok);
556 assert_eq!(String::from_utf8(written).unwrap(), "a\nX\nc\nD\n");
557 assert!(ui.message.contains("auto-applied"));
558 }
559}