Skip to main content

git_pincer/ui/
mod.rs

1//! ratatui three-pane rendering and the key-event loop.
2//!
3//! Layout: status bar / three bordered panes (local | result | remote)
4//! / key hints / message line. Change chunks are tinted as bands on the panes
5//! they touch, colored by change type like IDEA (blue = modified, green =
6//! added, gray = deleted, red = conflict); the band disappears once a chunk
7//! is resolved, the current chunk is highlighted, and `?` shows the full key
8//! reference.
9//!
10//! 模块拆分:
11//! - [`theme`][] — 颜色集中定义
12//! - [`keymap`][] — 按键绑定的单一事实来源(分发 / 提示条 / 帮助共用)
13//! - [`rows`][] — 渲染行数据结构与构建(折叠 / 占位)
14//! - [`highlight`][] — 词级强调与语法高亮的计算与缓存
15//! - [`panes`][] — 三栏正文渲染
16//! - [`chrome`][] — 界面整体绘制(状态栏 / 提示条 / 帮助浮层 / 二进制视图)
17//! - 本文件 — 事件主循环与按键分发
18
19mod 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/// 会话结束方式。
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum Outcome {
44    /// 所有文件已解决并写盘
45    Completed,
46    /// 用户中途退出(现场保留,可再次进入)
47    Quit,
48}
49
50/// UI 的瞬时状态(消息条与浮层开关)。
51#[derive(Debug, Default)]
52pub struct UiState {
53    /// 底部消息条内容
54    pub message: String,
55    /// 是否显示帮助浮层
56    pub show_help: bool,
57    /// 是否处于「再按一次 q 退出」的确认态
58    pub pending_quit: bool,
59    /// 界面主题
60    pub(crate) theme: theme::Theme,
61    /// 高亮信息缓存(词级强调 / 语法高亮)
62    pub(crate) cache: highlight::HighlightCache,
63    /// 渲染行缓存(纯导航按键零重建)
64    pub(crate) rows: rows::RowCache,
65    /// 状态修订号:改动合并内容的按键后自增,用于结果栏语法高亮与
66    /// 渲染行缓存的失效重算
67    pub(crate) revision: u64,
68    /// 待应用的手动滚动量(半页为单位,正数向下;绘制时消费)
69    pub(crate) scroll_request: isize,
70}
71
72/// 运行交互会话直至完成或退出。
73///
74/// `write_file` 负责把解决后的字节落盘(git 模式下还会顺带 `git add`);
75/// `light` 为 true 时使用浅色主题(适配浅色终端背景)。
76pub fn run_session(
77    session: &mut Session,
78    write_file: &mut dyn FnMut(&str, &[u8]) -> Result<()>,
79    light: bool,
80) -> Result<Outcome> {
81    // ratatui::init 在无 TTY 时会直接 panic,这里先行拦截给出可读错误
82    // (如在管道 / CI 中误运行时)
83    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
89/// 在移交的终端现场上运行交互会话(菜单转入冲突解决时复用,
90/// 全程不退出 alternate screen,避免闪屏);结束时恢复终端。
91///
92/// 不做预清屏:ratatui 按缓冲差量重绘,首帧一次性覆盖上一页,
93/// 中间不出现空白闪帧。
94pub(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
105/// 事件主循环:绘制 → 读键 → 更新状态。
106fn 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        // 帮助浮层打开时,任意键关闭
126        if ui.show_help {
127            ui.show_help = false;
128            continue;
129        }
130        // Ctrl+C 遵循终端惯例等价于退出键(raw mode 下不会产生 SIGINT,
131        // 不处理会让用户以为程序卡死)
132        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        // 除退出键外的任意键(含未绑定键)取消退出确认
139        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            // 手动滚动只记录请求,行数与钳制在绘制时按视口高度结算
159            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                    // 写盘会自动应用非冲突改动,结果栏内容可能变化
164                    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
191/// 处理作用于当前文件的普通动作;返回是否改动了合并内容(结果栏高亮失效用)。
192fn handle_file_key(session: &mut Session, action: Action, ui: &mut UiState) -> bool {
193    match session.current_file_mut() {
194        FileEntry::Text(merge) => {
195            // 任何作用于光标块的动作都恢复视口跟随(纯滚动不经过此处)
196            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                // 忽略当前块所有仍待处理的侧(已取用的内容保留)
207                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                // 复制动作(终端框选会横跨三栏,复制键绕开这个限制):
243                // 块结果 / 整个文件结果 / 块本地侧 / 块远端侧
244                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
281/// 复制若干行到剪贴板并生成消息条反馈。
282fn 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
292/// 把文本写入系统剪贴板:依次尝试 pbcopy(macOS)/ xclip(X11)/ wl-copy(Wayland),
293/// 都不可用时退回 OSC 52 转义序列——由终端代写剪贴板,覆盖 Windows、
294/// SSH 与无剪贴板工具的最小化环境(终端不支持该序列时静默无效)。
295fn 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
324/// 通过 OSC 52 序列请求终端写入剪贴板。
325fn 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
333/// 标准 base64 编码(仅编码一个用途,不值得为此引入依赖)。
334fn 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
356/// 写盘当前文件;成功返回 true。
357///
358/// 冲突块必须全部解决;未处理的非冲突改动会在写盘前**自动应用**,
359/// 与 git 自动合并的语义一致(想拒绝某处改动,写盘前用 x 显式忽略)。
360/// 否则按 base 写盘会悄悄丢掉 git 已合并进来的对侧改动。
361fn 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
390/// 配置文件指定的编辑器(`[ui] editor`;进程内 init 一次)。
391static CONFIG_EDITOR: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
392
393/// 应用配置的编辑器设定(进程内仅首次调用生效,应在进 TUI 前调用)。
394pub(crate) fn init_editor(editor: Option<String>) {
395    let _ = CONFIG_EDITOR.set(editor);
396}
397
398/// 调起编辑器编辑一段内容;返回 None 表示用户取消(编辑器非零退出)。
399fn 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    // 让出终端给编辑器,结束后重建 TUI
409    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    // 编辑器通常会补一个末尾换行,这里剥掉以免多出空行
424    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
432/// 依优先级选择编辑器:配置 `[ui].editor` > `$VISUAL` > `$EDITOR` >
433/// 平台缺省;空值视同未设置。Unix 缺省在 PATH 上依次探测 vim、vi
434/// (都不存在时仍回退 vi,让启动失败给出可读错误),Windows 用 notepad。
435fn 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
442/// 编辑器选择的纯逻辑部分(便于测试)。
443fn 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
471/// 探测某个可执行文件是否存在于 PATH。
472fn 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    /// 编辑器选择优先级:配置 > VISUAL > EDITOR > vim > vi;空值视同未设置
485    #[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        // 配置最优先;空白配置视同未设置
493        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        // VISUAL 优先于 EDITOR
502        assert_eq!(pick_editor(None, Some("nvim"), Some("nano"), both), "nvim");
503        assert_eq!(pick_editor(None, None, Some("nano"), both), "nano");
504        // 全部未设置:PATH 上 vim 优先,退化到 vi;都没有仍回退 vi
505        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    /// base64 编码与标准向量一致(OSC 52 载荷用)
511    #[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    /// 回归:写盘时未处理的非冲突改动应自动应用,而非退回 base
527    /// (否则会悄悄丢掉 git 已自动合并进来的对侧改动)
528    #[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        // 只解决冲突块(取本地、忽略远端),theirs 侧 d→D 的改动保持未处理
538        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}