git-pincer 0.1.3

A simple and efficient terminal Git conflict resolution CLI tool.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
//! ratatui three-pane rendering and the key-event loop.
//!
//! Layout: status bar / three bordered panes (local | result | remote)
//! / key hints / message line. Change chunks are tinted as bands on the panes
//! they touch, colored by change type like IDEA (blue = modified, green =
//! added, gray = deleted, red = conflict); the band disappears once a chunk
//! is resolved, the current chunk is highlighted, and `?` shows the full key
//! reference.
//!
//! 模块拆分:
//! - [`theme`][] — 颜色集中定义
//! - [`keymap`][] — 按键绑定的单一事实来源(分发 / 提示条 / 帮助共用)
//! - [`rows`][] — 渲染行数据结构与构建(折叠 / 占位)
//! - [`highlight`][] — 词级强调与语法高亮的计算与缓存
//! - [`panes`][] — 三栏正文渲染
//! - [`chrome`][] — 界面整体绘制(状态栏 / 提示条 / 帮助浮层 / 二进制视图)
//! - 本文件 — 事件主循环与按键分发

mod chrome;
mod highlight;
pub(crate) mod keymap;
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, KeyModifiers};

use crate::app::{FileEntry, Session, Side};
use keymap::Action;

pub use chrome::draw;
pub(crate) use menu::{MenuItem, MenuSession};
pub(crate) use theme::{detect_light, init_overrides as init_theme_overrides};

/// 会话结束方式。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Outcome {
    /// 所有文件已解决并写盘
    Completed,
    /// 用户中途退出(现场保留,可再次进入)
    Quit,
}

/// UI 的瞬时状态(消息条与浮层开关)。
#[derive(Debug, Default)]
pub struct UiState {
    /// 底部消息条内容
    pub message: String,
    /// 是否显示帮助浮层
    pub show_help: bool,
    /// 是否处于「再按一次 q 退出」的确认态
    pub pending_quit: bool,
    /// 界面主题
    pub(crate) theme: theme::Theme,
    /// 高亮信息缓存(词级强调 / 语法高亮)
    pub(crate) cache: highlight::HighlightCache,
    /// 渲染行缓存(纯导航按键零重建)
    pub(crate) rows: rows::RowCache,
    /// 状态修订号:改动合并内容的按键后自增,用于结果栏语法高亮与
    /// 渲染行缓存的失效重算
    pub(crate) revision: u64,
    /// 待应用的手动滚动量(半页为单位,正数向下;绘制时消费)
    pub(crate) scroll_request: isize,
}

/// 运行交互会话直至完成或退出。
///
/// `write_file` 负责把解决后的字节落盘(git 模式下还会顺带 `git add`);
/// `light` 为 true 时使用浅色主题(适配浅色终端背景)。
pub fn run_session(
    session: &mut Session,
    write_file: &mut dyn FnMut(&str, &[u8]) -> Result<()>,
    light: bool,
) -> Result<Outcome> {
    // ratatui::init 在无 TTY 时会直接 panic,这里先行拦截给出可读错误
    // (如在管道 / CI 中误运行时)
    if !std::io::stdout().is_terminal() {
        anyhow::bail!("{}", tr("common.need_tty_resolve"));
    }
    run_session_in(ratatui::init(), session, write_file, light)
}

/// 在移交的终端现场上运行交互会话(菜单转入冲突解决时复用,
/// 全程不退出 alternate screen,避免闪屏);结束时恢复终端。
///
/// 不做预清屏:ratatui 按缓冲差量重绘,首帧一次性覆盖上一页,
/// 中间不出现空白闪帧。
pub(crate) fn run_session_in(
    mut terminal: DefaultTerminal,
    session: &mut Session,
    write_file: &mut dyn FnMut(&str, &[u8]) -> Result<()>,
    light: bool,
) -> Result<Outcome> {
    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;
        }
        // Ctrl+C 遵循终端惯例等价于退出键(raw mode 下不会产生 SIGINT,
        // 不处理会让用户以为程序卡死)
        let action =
            if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
                Some(Action::Quit)
            } else {
                keymap::action_for(key.code, key.modifiers)
            };
        // 除退出键外的任意键(含未绑定键)取消退出确认
        if action != Some(Action::Quit) {
            ui.pending_quit = false;
        }
        ui.message.clear();
        let Some(action) = action else {
            continue;
        };

        match action {
            Action::Quit => {
                if session.all_written() || ui.pending_quit {
                    return Ok(Outcome::Quit);
                }
                ui.pending_quit = true;
                ui.message = tr("ui.quit_confirm").to_owned();
            }
            Action::Help => ui.show_help = true,
            Action::NextFile => session.next_file(),
            Action::ToggleFold => session.folded = !session.folded,
            // 手动滚动只记录请求,行数与钳制在绘制时按视口高度结算
            Action::ScrollDown => ui.scroll_request += 1,
            Action::ScrollUp => ui.scroll_request -= 1,
            Action::WriteFile => {
                if write_current(session, write_file, &mut ui)? {
                    // 写盘会自动应用非冲突改动,结果栏内容可能变化
                    ui.revision += 1;
                    if session.all_written() {
                        return Ok(Outcome::Completed);
                    }
                }
            }
            Action::EditChunk => {
                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();
                    }
                }
            }
            other => {
                if handle_file_key(session, other, &mut ui) {
                    ui.revision += 1;
                }
            }
        }
    }
}

/// 处理作用于当前文件的普通动作;返回是否改动了合并内容(结果栏高亮失效用)。
fn handle_file_key(session: &mut Session, action: Action, ui: &mut UiState) -> bool {
    match session.current_file_mut() {
        FileEntry::Text(merge) => {
            // 任何作用于光标块的动作都恢复视口跟随(纯滚动不经过此处)
            merge.follow = true;
            match action {
                Action::TakeLocal => {
                    merge.apply(Side::Ours);
                    true
                }
                Action::TakeRemote => {
                    merge.apply(Side::Theirs);
                    true
                }
                // 忽略当前块所有仍待处理的侧(已取用的内容保留)
                Action::IgnoreChunk => {
                    merge.ignore(Side::Ours);
                    merge.ignore(Side::Theirs);
                    true
                }
                Action::UndoChunk => {
                    merge.undo();
                    true
                }
                Action::UndoFile => {
                    merge.undo_all();
                    ui.message = tr("ui.undone_all").to_owned();
                    true
                }
                Action::ApplyNonConflict => {
                    merge.apply_all_nonconflict();
                    ui.message = tr("ui.applied_all").to_owned();
                    true
                }
                Action::NextChange => {
                    merge.next_change();
                    false
                }
                Action::PrevChange => {
                    merge.prev_change();
                    false
                }
                Action::NextConflict => {
                    merge.next_conflict();
                    false
                }
                Action::PrevConflict => {
                    merge.prev_conflict();
                    false
                }
                // 复制动作(终端框选会横跨三栏,复制键绕开这个限制):
                // 块结果 / 整个文件结果 / 块本地侧 / 块远端侧
                Action::CopyChunk => {
                    let lines = merge.current_content(merge.cursor);
                    ui.message = copy_feedback(&lines, tr("ui.copy_chunk"));
                    false
                }
                Action::CopyFile => {
                    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
                }
                Action::CopyLocal => {
                    let lines = merge.chunks[merge.cursor].ours_lines().to_vec();
                    ui.message = copy_feedback(&lines, tr("ui.copy_local"));
                    false
                }
                Action::CopyRemote => {
                    let lines = merge.chunks[merge.cursor].theirs_lines().to_vec();
                    ui.message = copy_feedback(&lines, tr("ui.copy_remote"));
                    false
                }
                _ => false,
            }
        }
        FileEntry::Binary { choice, .. } => {
            match action {
                Action::TakeLocal => *choice = Some(Side::Ours),
                Action::TakeRemote => *choice = Some(Side::Theirs),
                Action::UndoChunk | Action::UndoFile => *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())]),
    }
}

/// 把文本写入系统剪贴板:依次尝试 pbcopy(macOS)/ xclip(X11)/ wl-copy(Wayland),
/// 都不可用时退回 OSC 52 转义序列——由终端代写剪贴板,覆盖 Windows、
/// SSH 与无剪贴板工具的最小化环境(终端不支持该序列时静默无效)。
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(());
        }
    }
    osc52_copy(text)
}

/// 通过 OSC 52 序列请求终端写入剪贴板。
fn osc52_copy(text: &str) -> Result<()> {
    use std::io::Write as _;
    let mut out = std::io::stdout();
    write!(out, "\x1b]52;c;{}\x07", base64(text.as_bytes()))?;
    out.flush()?;
    Ok(())
}

/// 标准 base64 编码(仅编码一个用途,不值得为此引入依赖)。
fn base64(data: &[u8]) -> String {
    const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
    let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
    for chunk in data.chunks(3) {
        let bytes = [
            chunk[0],
            *chunk.get(1).unwrap_or(&0),
            *chunk.get(2).unwrap_or(&0),
        ];
        let n = (u32::from(bytes[0]) << 16) | (u32::from(bytes[1]) << 8) | u32::from(bytes[2]);
        let sextets = [(n >> 18) & 63, (n >> 12) & 63, (n >> 6) & 63, n & 63];
        for (i, sextet) in sextets.iter().enumerate() {
            if i <= chunk.len() {
                out.push(TABLE[*sextet as usize] as char);
            } else {
                out.push('=');
            }
        }
    }
    out
}

/// 写盘当前文件;成功返回 true。
///
/// 冲突块必须全部解决;未处理的非冲突改动会在写盘前**自动应用**,
/// 与 git 自动合并的语义一致(想拒绝某处改动,写盘前用 x 显式忽略)。
/// 否则按 base 写盘会悄悄丢掉 git 已合并进来的对侧改动。
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)
}

/// 配置文件指定的编辑器(`[ui] editor`;进程内 init 一次)。
static CONFIG_EDITOR: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();

/// 应用配置的编辑器设定(进程内仅首次调用生效,应在进 TUI 前调用)。
pub(crate) fn init_editor(editor: Option<String>) {
    let _ = CONFIG_EDITOR.set(editor);
}

/// 调起编辑器编辑一段内容;返回 None 表示用户取消(编辑器非零退出)。
fn edit_lines(terminal: &mut DefaultTerminal, initial: &[String]) -> Result<Option<Vec<String>>> {
    let editor = resolve_editor();
    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"))?;

    // 让出终端给编辑器,结束后重建 TUI
    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()
    }))
}

/// 依优先级选择编辑器:配置 `[ui].editor` > `$VISUAL` > `$EDITOR` >
/// 平台缺省;空值视同未设置。Unix 缺省在 PATH 上依次探测 vim、vi
/// (都不存在时仍回退 vi,让启动失败给出可读错误),Windows 用 notepad。
fn resolve_editor() -> String {
    let config = CONFIG_EDITOR.get().and_then(|e| e.as_deref());
    let visual = std::env::var("VISUAL").ok();
    let editor = std::env::var("EDITOR").ok();
    pick_editor(config, visual.as_deref(), editor.as_deref(), on_path)
}

/// 编辑器选择的纯逻辑部分(便于测试)。
fn pick_editor(
    config: Option<&str>,
    visual: Option<&str>,
    editor: Option<&str>,
    exists: impl Fn(&str) -> bool,
) -> String {
    let non_empty = |v: Option<&str>| {
        v.map(str::trim)
            .filter(|v| !v.is_empty())
            .map(str::to_owned)
    };
    if let Some(chosen) = non_empty(config)
        .or_else(|| non_empty(visual))
        .or_else(|| non_empty(editor))
    {
        return chosen;
    }
    if cfg!(windows) {
        return "notepad".to_owned();
    }
    for candidate in ["vim", "vi"] {
        if exists(candidate) {
            return candidate.to_owned();
        }
    }
    "vi".to_owned()
}

/// 探测某个可执行文件是否存在于 PATH。
fn on_path(program: &str) -> bool {
    let Some(paths) = std::env::var_os("PATH") else {
        return false;
    };
    std::env::split_paths(&paths).any(|dir| dir.join(program).is_file())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::app::FileMerge;

    /// 编辑器选择优先级:配置 > VISUAL > EDITOR > vim > vi;空值视同未设置
    #[test]
    #[cfg(not(windows))]
    fn editor_priority_chain() {
        let both = |_: &str| true;
        let none = |_: &str| false;
        let only_vi = |p: &str| p == "vi";

        // 配置最优先;空白配置视同未设置
        assert_eq!(
            pick_editor(Some("code --wait"), Some("nvim"), Some("nano"), both),
            "code --wait"
        );
        assert_eq!(
            pick_editor(Some("  "), Some("nvim"), Some("nano"), both),
            "nvim"
        );
        // VISUAL 优先于 EDITOR
        assert_eq!(pick_editor(None, Some("nvim"), Some("nano"), both), "nvim");
        assert_eq!(pick_editor(None, None, Some("nano"), both), "nano");
        // 全部未设置:PATH 上 vim 优先,退化到 vi;都没有仍回退 vi
        assert_eq!(pick_editor(None, None, None, both), "vim");
        assert_eq!(pick_editor(None, None, None, only_vi), "vi");
        assert_eq!(pick_editor(None, None, None, none), "vi");
    }

    /// base64 编码与标准向量一致(OSC 52 载荷用)
    #[test]
    fn base64_matches_known_vectors() {
        for (input, expected) in [
            ("", ""),
            ("f", "Zg=="),
            ("fo", "Zm8="),
            ("foo", "Zm9v"),
            ("foob", "Zm9vYg=="),
            ("hello", "aGVsbG8="),
            ("多字节✓", "5aSa5a2X6IqC4pyT"),
        ] {
            assert_eq!(base64(input.as_bytes()), expected, "输入: {input:?}");
        }
    }

    /// 回归:写盘时未处理的非冲突改动应自动应用,而非退回 base
    /// (否则会悄悄丢掉 git 已自动合并进来的对侧改动)
    #[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());
        // 只解决冲突块(取本地、忽略远端),theirs 侧 d→D 的改动保持未处理
        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"));
    }
}