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//! - [`rows`][] — 渲染行数据结构与构建(折叠 / 占位)
13//! - [`highlight`][] — 词级强调与语法高亮的计算与缓存
14//! - [`panes`][] — 三栏正文渲染
15//! - [`chrome`][] — 界面整体绘制(状态栏 / 提示条 / 帮助浮层 / 二进制视图)
16//! - 本文件 — 事件主循环与按键分发
17
18mod 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/// 会话结束方式。
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum Outcome {
41    /// 所有文件已解决并写盘
42    Completed,
43    /// 用户中途退出(现场保留,可再次进入)
44    Quit,
45}
46
47/// UI 的瞬时状态(消息条与浮层开关)。
48#[derive(Debug, Default)]
49pub struct UiState {
50    /// 底部消息条内容
51    pub message: String,
52    /// 是否显示帮助浮层
53    pub show_help: bool,
54    /// 是否处于「再按一次 q 退出」的确认态
55    pub pending_quit: bool,
56    /// 界面主题
57    pub(crate) theme: theme::Theme,
58    /// 高亮信息缓存(词级强调 / 语法高亮)
59    pub(crate) cache: highlight::HighlightCache,
60    /// 状态修订号:改动合并内容的按键后自增,用于结果栏语法高亮的失效重算
61    pub(crate) revision: u64,
62}
63
64/// 运行交互会话直至完成或退出。
65///
66/// `write_file` 负责把解决后的字节落盘(git 模式下还会顺带 `git add`);
67/// `light` 为 true 时使用浅色主题(适配浅色终端背景)。
68pub fn run_session(
69    session: &mut Session,
70    write_file: &mut dyn FnMut(&str, &[u8]) -> Result<()>,
71    light: bool,
72) -> Result<Outcome> {
73    // ratatui::init 在无 TTY 时会直接 panic,这里先行拦截给出可读错误
74    // (如在管道 / CI 中误运行时)
75    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
84/// 事件主循环:绘制 → 读键 → 更新状态。
85fn 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        // 帮助浮层打开时,任意键关闭
105        if ui.show_help {
106            ui.show_help = false;
107            continue;
108        }
109        // 除 q 外的任意键取消退出确认
110        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                    // 写盘会自动应用非冲突改动,结果栏内容可能变化
129                    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
156/// 处理作用于当前文件的普通按键;返回是否改动了合并内容(结果栏高亮失效用)。
157fn 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            // x = 忽略当前块所有仍待处理的侧(已取用的内容保留)
169            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            // 复制键(终端框选会横跨三栏,复制键绕开这个限制):
205            // y 块结果 / Y 整个文件结果 / H 块本地侧 / L 块远端侧
206            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
242/// 复制若干行到剪贴板并生成消息条反馈。
243fn 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
253/// 把文本写入系统剪贴板:依次尝试 pbcopy(macOS)/ xclip(X11)/ wl-copy(Wayland)。
254fn 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
283/// 写盘当前文件;成功返回 true。
284///
285/// 冲突块必须全部解决;未处理的非冲突改动会在写盘前**自动应用**,
286/// 与 git 自动合并的语义一致(想拒绝某处改动,写盘前用 x 显式忽略)。
287/// 否则按 base 写盘会悄悄丢掉 git 已合并进来的对侧改动。
288fn 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
317/// 调起 $EDITOR 编辑一段内容;返回 None 表示用户取消(编辑器非零退出)。
318fn 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    // 让出终端给编辑器,结束后重建 TUI
328    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    // 编辑器通常会补一个末尾换行,这里剥掉以免多出空行
343    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    /// 回归:写盘时未处理的非冲突改动应自动应用,而非退回 base
357    /// (否则会悄悄丢掉 git 已自动合并进来的对侧改动)
358    #[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        // 只解决冲突块(取本地、忽略远端),theirs 侧 d→D 的改动保持未处理
368        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}