Skip to main content

journey/
ui.rs

1//! The top-level [`GitClient`] widget.
2//!
3//! `GitClient` drives two screens, each a flat [`Shell`] of panes:
4//!
5//! * **Browse** — the gitk-style history browser (commit list, diff, files).
6//! * **Commit** — a `git gui`-style staging screen (unstaged / staged file
7//!   lists, a per-file diff, a message editor and a commit button).
8//!
9//! saudade widgets are callback-free, so the cross-pane wiring is done here:
10//! after each event the active screen's selections (and a small command queue
11//! menus/buttons push into) are polled, and dependent panes are rebuilt from
12//! the [`RepoBackend`].
13
14use std::cell::RefCell;
15use std::rc::Rc;
16
17use saudade::{
18    Button, Checkbox, Dialog, Event, EventCtx, Key, List, ListItem, Menu, MenuBar, MenuItem,
19    NamedKey, Painter, PopupRequest, Rect, TextEditor, Theme, Widget,
20};
21
22use crate::backend::{
23    ChangeStatus, CommitInfo, Diff, DiffLine, DiffLineKind, FileChange, RefKind, RepoBackend,
24    WorkingStatus,
25};
26use crate::widgets::{
27    CommitList, CommitRow, DiffView, Heading, SearchBar, Shared, Shell, compute_graph, layout,
28};
29
30/// Direct-child index of the history list in the browse shell (focused first).
31const BROWSE_HISTORY_IDX: usize = 2;
32/// Direct-child index of the unstaged list in the commit shell.
33const COMMIT_UNSTAGED_IDX: usize = 2;
34
35/// Sentinel commit ids for the working-tree pseudo-rows in the log graph
36/// (chosen so they never collide with a real 40-hex SHA).
37const WIP_UNSTAGED_ID: &str = "\u{1}journey-wip-unstaged";
38const WIP_STAGED_ID: &str = "\u{1}journey-wip-staged";
39
40/// A closure that re-opens the repository (used by File ▸ Reload and after a
41/// commit). `None` for fixture-backed clients in tests.
42type ReopenFn = Box<dyn Fn() -> Option<Rc<dyn RepoBackend>>>;
43
44/// Which screen is shown.
45#[derive(Clone, Copy, PartialEq, Eq)]
46enum Mode {
47    Browse,
48    Commit,
49}
50
51/// Which working-tree list a commit-mode selection came from, and which side
52/// a working-tree pseudo-row in the log represents.
53#[derive(Clone, Copy, PartialEq, Eq)]
54enum Side {
55    Unstaged,
56    Staged,
57}
58
59/// What a row in the history log refers to.
60#[derive(Clone, Copy, PartialEq, Eq)]
61enum RowRef {
62    /// A working-tree pseudo-row ("Uncommitted changes" / "Staged changes").
63    Wip(Side),
64    /// A real commit, by backend index.
65    Commit(usize),
66}
67
68/// Deferred actions menus / buttons request; drained by `GitClient` after
69/// event dispatch so they can mutate state the callbacks can't reach.
70#[derive(Clone, Copy)]
71enum AppCommand {
72    Reload,
73    EnterCommitMode,
74    EnterBrowseMode,
75    Rescan,
76    StageSelected,
77    StageAll,
78    UnstageSelected,
79    /// Ask to revert the selected unstaged file (pops the confirm dialog).
80    RevertSelected,
81    /// The confirm dialog's affirmative button fired — carry out the armed
82    /// `pending_discard`.
83    PerformDiscard,
84    SignOff,
85    Commit,
86}
87
88/// A discard armed by [`GitClient::revert_selected`] and awaiting the user's
89/// confirmation: revert a tracked file to its index copy, or delete an
90/// untracked file outright (it has nothing to revert to).
91enum PendingDiscard {
92    Revert(String),
93    Delete(String),
94}
95
96pub struct GitClient {
97    backend: Rc<dyn RepoBackend>,
98    mode: Mode,
99    bounds: Rect,
100
101    // ---- browse screen ----------------------------------------------------
102    browse_root: Shell,
103    search: Rc<RefCell<SearchBar>>,
104    commit_list: Rc<RefCell<CommitList>>,
105    file_list: Rc<RefCell<List>>,
106    diff_view: Rc<RefCell<DiffView>>,
107
108    // ---- commit screen ----------------------------------------------------
109    commit_root: Shell,
110    unstaged_list: Rc<RefCell<List>>,
111    staged_list: Rc<RefCell<List>>,
112    unstaged_heading: Rc<RefCell<Heading>>,
113    staged_heading: Rc<RefCell<Heading>>,
114    commit_diff_view: Rc<RefCell<DiffView>>,
115    message_editor: Rc<RefCell<TextEditor>>,
116    amend_check: Rc<RefCell<Checkbox>>,
117
118    // ---- shared -----------------------------------------------------------
119    dialog: Rc<RefCell<Dialog>>,
120    commands: Rc<RefCell<Vec<AppCommand>>>,
121    reopen: Option<ReopenFn>,
122
123    // ---- browse sync state ------------------------------------------------
124    /// Row references in display order: the working-tree pseudo-rows (when
125    /// present) followed by the visible commits.
126    rows: Vec<RowRef>,
127    last_query: String,
128    /// Working-tree status backing the log's pseudo-rows (and the file/diff
129    /// panes when one is selected). Refreshed by `rebuild_commits`.
130    log_working: WorkingStatus,
131    current_files: Vec<FileChange>,
132    /// The log row whose detail the file/diff panes currently show.
133    shown: Option<RowRef>,
134    shown_file: Option<usize>,
135
136    // ---- commit sync state ------------------------------------------------
137    working: WorkingStatus,
138    prev_unstaged_sel: Option<usize>,
139    prev_staged_sel: Option<usize>,
140    last_amend: bool,
141    /// The discard awaiting confirmation, set when the confirm dialog is shown
142    /// and consumed when its affirmative button drives
143    /// `AppCommand::PerformDiscard`.
144    pending_discard: Option<PendingDiscard>,
145}
146
147impl GitClient {
148    pub fn new(backend: Rc<dyn RepoBackend>) -> Self {
149        let dialog = Rc::new(RefCell::new(Dialog::new()));
150        let commands: Rc<RefCell<Vec<AppCommand>>> = Rc::new(RefCell::new(Vec::new()));
151
152        // Browse-screen widgets.
153        let search = Rc::new(RefCell::new(SearchBar::new(Rect::new(0, 0, 0, 0))));
154        let commit_list = Rc::new(RefCell::new(CommitList::new(Rect::new(0, 0, 0, 0))));
155        let file_list = Rc::new(RefCell::new(List::new(Rect::new(0, 0, 0, 0))));
156        let diff_view = Rc::new(RefCell::new(DiffView::new(Rect::new(0, 0, 0, 0))));
157
158        // Add order sets the Tab focus order: search → commits → files → diff
159        // (the menu bar isn't focusable; it works via accelerators). The file
160        // list follows the commit list so Tab walks the panes left-to-right.
161        // No flat background fill: the panes float on the window's desktop
162        // pattern, which shows through the padding around them.
163        let browse_root = Shell::new()
164            .no_background()
165            .add(
166                build_browse_menu(commands.clone(), dialog.clone()),
167                layout::browse_menu,
168            )
169            .add(Shared::new(search.clone()), layout::browse_toolbar)
170            .add(Shared::new(commit_list.clone()), layout::browse_history)
171            .add(Shared::new(file_list.clone()), layout::browse_files)
172            .add(Shared::new(diff_view.clone()), layout::browse_diff)
173            .add_overlay(Shared::new(dialog.clone()));
174
175        // Commit-screen widgets.
176        let unstaged_list = Rc::new(RefCell::new(List::new(Rect::new(0, 0, 0, 0))));
177        let staged_list = Rc::new(RefCell::new(List::new(Rect::new(0, 0, 0, 0))));
178        let unstaged_heading = Rc::new(RefCell::new(Heading::new("Unstaged Changes")));
179        let staged_heading = Rc::new(RefCell::new(Heading::new("Staged Changes")));
180        let commit_diff_view = Rc::new(RefCell::new(DiffView::new(Rect::new(0, 0, 0, 0))));
181        let message_editor = Rc::new(RefCell::new(TextEditor::new(Rect::new(0, 0, 0, 0))));
182        let amend_check = Rc::new(RefCell::new(Checkbox::new(
183            Rect::new(0, 0, 0, 0),
184            "Amend last commit",
185        )));
186
187        // No flat background fill: the staging panes float on the window's
188        // desktop pattern (git-gui style), which shows through the gaps.
189        let commit_root = Shell::new()
190            .no_background()
191            .add(
192                build_commit_menu(commands.clone(), dialog.clone()),
193                layout::commit_menu,
194            )
195            .add(
196                Shared::new(unstaged_heading.clone()),
197                layout::commit_unstaged_label,
198            )
199            .add(
200                Shared::new(unstaged_list.clone()),
201                layout::commit_unstaged_list,
202            )
203            .add(
204                Shared::new(staged_heading.clone()),
205                layout::commit_staged_label,
206            )
207            .add(Shared::new(staged_list.clone()), layout::commit_staged_list)
208            .add(
209                command_button("Stage \u{2192}", &commands, AppCommand::StageSelected),
210                layout::commit_stage_btn,
211            )
212            .add(
213                command_button("\u{2190} Unstage", &commands, AppCommand::UnstageSelected),
214                layout::commit_unstage_btn,
215            )
216            .add(
217                command_button("Rescan", &commands, AppCommand::Rescan),
218                layout::commit_rescan_btn,
219            )
220            .add(Shared::new(commit_diff_view.clone()), layout::commit_diff)
221            .add(Heading::new("Commit Message"), layout::commit_msg_label)
222            .add(Shared::new(message_editor.clone()), layout::commit_editor)
223            .add(Shared::new(amend_check.clone()), layout::commit_amend)
224            .add(
225                command_button("Commit", &commands, AppCommand::Commit),
226                layout::commit_commit_btn,
227            )
228            .add_overlay(Shared::new(dialog.clone()));
229
230        let mut client = Self {
231            backend,
232            mode: Mode::Browse,
233            bounds: Rect::new(0, 0, 0, 0),
234            browse_root,
235            search,
236            commit_list,
237            file_list,
238            diff_view,
239            commit_root,
240            unstaged_list,
241            staged_list,
242            unstaged_heading,
243            staged_heading,
244            commit_diff_view,
245            message_editor,
246            amend_check,
247            dialog,
248            commands,
249            reopen: None,
250            rows: Vec::new(),
251            last_query: String::new(),
252            log_working: WorkingStatus::default(),
253            current_files: Vec::new(),
254            shown: None,
255            shown_file: None,
256            working: WorkingStatus::default(),
257            prev_unstaged_sel: None,
258            prev_staged_sel: None,
259            last_amend: false,
260            pending_discard: None,
261        };
262        client.sync_browse(true);
263        client
264    }
265
266    /// Install the repository re-open hook used by File ▸ Reload and refresh
267    /// after a commit.
268    pub fn with_reopen(mut self, reopen: ReopenFn) -> Self {
269        self.reopen = Some(reopen);
270        self
271    }
272
273    /// Switch to the commit screen. Exposed for tests; at runtime the View
274    /// menu drives this through the command queue.
275    pub fn enter_commit_mode(&mut self) {
276        self.set_mode(Mode::Commit);
277    }
278
279    fn active(&self) -> &Shell {
280        match self.mode {
281            Mode::Browse => &self.browse_root,
282            Mode::Commit => &self.commit_root,
283        }
284    }
285
286    fn active_mut(&mut self) -> &mut Shell {
287        match self.mode {
288            Mode::Browse => &mut self.browse_root,
289            Mode::Commit => &mut self.commit_root,
290        }
291    }
292
293    fn set_mode(&mut self, mode: Mode) -> bool {
294        if self.mode == mode {
295            return false;
296        }
297        self.mode = mode;
298        match mode {
299            Mode::Commit => {
300                self.rescan();
301                self.commit_root.layout(self.bounds);
302                self.commit_root.focus_child(COMMIT_UNSTAGED_IDX);
303            }
304            Mode::Browse => {
305                self.browse_root.layout(self.bounds);
306                self.browse_root.focus_child(BROWSE_HISTORY_IDX);
307            }
308        }
309        true
310    }
311
312    /// Apply queued menu / button commands. Returns `true` if state changed.
313    fn drain_commands(&mut self) -> bool {
314        let pending: Vec<AppCommand> = self.commands.borrow_mut().drain(..).collect();
315        let mut changed = false;
316        for command in pending {
317            changed |= match command {
318                AppCommand::Reload => self.reload(),
319                AppCommand::EnterCommitMode => self.set_mode(Mode::Commit),
320                AppCommand::EnterBrowseMode => self.set_mode(Mode::Browse),
321                AppCommand::Rescan => {
322                    self.rescan();
323                    true
324                }
325                AppCommand::StageSelected => self.stage_selected(),
326                AppCommand::StageAll => self.stage_all(),
327                AppCommand::UnstageSelected => self.unstage_selected(),
328                AppCommand::RevertSelected => self.revert_selected(),
329                AppCommand::PerformDiscard => self.perform_discard(),
330                AppCommand::SignOff => self.sign_off(),
331                AppCommand::Commit => self.do_commit(),
332            };
333        }
334        changed
335    }
336
337    /// Re-open the repository and rebuild every pane. No-op (returns `false`)
338    /// without a reopen hook, e.g. fixture-backed clients.
339    fn reload(&mut self) -> bool {
340        let Some(reopen) = &self.reopen else {
341            return false;
342        };
343        let Some(backend) = reopen() else {
344            self.dialog
345                .borrow_mut()
346                .show_error("Reload failed", "Could not re-open the repository.");
347            return true;
348        };
349        self.backend = backend;
350        self.shown = None;
351        self.shown_file = None;
352        self.last_query.clear();
353        self.search.borrow_mut().clear();
354        self.sync_browse(true);
355        self.rescan();
356        true
357    }
358
359    // ---- browse screen ----------------------------------------------------
360
361    /// Reload browse panes from the current selection state. Double-clicking a
362    /// working-tree pseudo-row jumps to the commit screen; otherwise, when the
363    /// selected row changes, reload the file list and overview diff, and when
364    /// the file selection changes, narrow the diff to that file.
365    fn sync_browse(&mut self, force: bool) -> bool {
366        let mut changed = false;
367
368        // 1. Re-filter the commit list when the query changes.
369        let query = self.search.borrow().text().trim().to_lowercase();
370        if force || query != self.last_query {
371            self.last_query = query.clone();
372            self.rebuild_commits(&query);
373            self.shown = None;
374            changed = true;
375        }
376
377        // 1b. Double-clicking a working-tree row opens the staging view.
378        let activated = self.commit_list.borrow_mut().take_activated();
379        if let Some(pos) = activated
380            && matches!(self.rows.get(pos), Some(RowRef::Wip(_)))
381        {
382            self.set_mode(Mode::Commit);
383            return true;
384        }
385
386        // 2. Map the selection to a row reference; on change, reload the file
387        //    list and the overview diff.
388        let sel_pos = self.commit_list.borrow().selected_index();
389        let sel = sel_pos.and_then(|p| self.rows.get(p).copied());
390        if force || sel != self.shown {
391            self.shown = sel;
392            self.current_files = match sel {
393                Some(RowRef::Commit(idx)) => self.backend.changed_files(idx),
394                Some(RowRef::Wip(Side::Unstaged)) => self.log_working.unstaged.clone(),
395                Some(RowRef::Wip(Side::Staged)) => self.log_working.staged.clone(),
396                None => Vec::new(),
397            };
398            let items: Vec<ListItem> = self.current_files.iter().map(file_row).collect();
399            self.file_list.borrow_mut().set_items(items);
400            self.shown_file = None;
401            let diff = self.selection_diff(sel, None);
402            self.diff_view.borrow_mut().set_diff(diff);
403            changed = true;
404        }
405
406        // 3. Narrow the diff to a single file when one is selected.
407        let file_sel = self.file_list.borrow().selected_index();
408        if file_sel != self.shown_file {
409            self.shown_file = file_sel;
410            let diff = self.selection_diff(self.shown, file_sel);
411            self.diff_view.borrow_mut().set_diff(diff);
412            changed = true;
413        }
414
415        changed
416    }
417
418    /// The diff to show for a log selection: a whole-commit / whole-working-set
419    /// overview when `file_sel` is `None`, otherwise that single file's diff.
420    fn selection_diff(&self, sel: Option<RowRef>, file_sel: Option<usize>) -> Diff {
421        match sel {
422            Some(RowRef::Commit(cidx)) => match file_sel.and_then(|f| self.current_files.get(f)) {
423                Some(file) => self.backend.file_diff(cidx, &file.path),
424                None => self.commit_detail(cidx),
425            },
426            Some(RowRef::Wip(side)) => {
427                let staged = matches!(side, Side::Staged);
428                match file_sel.and_then(|f| self.current_files.get(f)) {
429                    Some(file) => self.backend.working_diff(&file.path, staged, false),
430                    None => self.wip_overview_diff(staged),
431                }
432            }
433            None => Diff::default(),
434        }
435    }
436
437    /// Concatenate the per-file working diffs of the currently-shown files into
438    /// one overview, the working-tree analogue of `commit_detail`.
439    fn wip_overview_diff(&self, staged: bool) -> Diff {
440        let mut lines = Vec::new();
441        for file in &self.current_files {
442            lines.extend(self.backend.working_diff(&file.path, staged, false).lines);
443        }
444        Diff { lines }
445    }
446
447    /// Recompute the visible rows for `query` (empty = all). On the unfiltered
448    /// view, the working tree's "Uncommitted changes" / "Staged changes"
449    /// pseudo-rows lead the list and the DAG graph includes them, chained into
450    /// `HEAD`. The selection is preserved when it survives, else falls to the
451    /// first real commit (so the log opens on `HEAD`, not a pseudo-row).
452    fn rebuild_commits(&mut self, query: &str) {
453        // Working-tree pseudo-rows only on the unfiltered view (which also
454        // carries the graph); a filter is about commit content.
455        self.log_working = if query.is_empty() {
456            self.backend.working_status(false)
457        } else {
458            WorkingStatus::default()
459        };
460        let show_unstaged = !self.log_working.unstaged.is_empty();
461        let show_staged = !self.log_working.staged.is_empty();
462
463        let commits = self.backend.commits();
464        let commit_rows: Vec<usize> = (0..commits.len())
465            .filter(|&i| query.is_empty() || commit_matches(&commits[i], query))
466            .collect();
467
468        let mut row_refs: Vec<RowRef> = Vec::new();
469        let mut display: Vec<CommitRow> = Vec::new();
470        if show_unstaged {
471            row_refs.push(RowRef::Wip(Side::Unstaged));
472            display.push(wip_row(Side::Unstaged, self.log_working.unstaged.len()));
473        }
474        if show_staged {
475            row_refs.push(RowRef::Wip(Side::Staged));
476            display.push(wip_row(Side::Staged, self.log_working.staged.len()));
477        }
478        for &i in &commit_rows {
479            row_refs.push(RowRef::Commit(i));
480            display.push(commit_row(&commits[i]));
481        }
482
483        // The DAG graph needs the full parent chain, so it's shown only on the
484        // unfiltered view; the pseudo-rows are chained into HEAD so the gutter
485        // lines up with them.
486        let graph = if query.is_empty() {
487            let head_id = head_commit_id(commits);
488            let mut dag: Vec<(String, Vec<String>)> = Vec::new();
489            if show_unstaged {
490                let parent = if show_staged {
491                    vec![WIP_STAGED_ID.to_string()]
492                } else {
493                    head_id.clone().into_iter().collect()
494                };
495                dag.push((WIP_UNSTAGED_ID.to_string(), parent));
496            }
497            if show_staged {
498                dag.push((WIP_STAGED_ID.to_string(), head_id.into_iter().collect()));
499            }
500            for &i in &commit_rows {
501                dag.push((commits[i].id.clone(), commits[i].parents.clone()));
502            }
503            Some(compute_graph(&dag))
504        } else {
505            None
506        };
507
508        self.rows = row_refs;
509        let new_pos = self
510            .shown
511            .and_then(|s| self.rows.iter().position(|&r| r == s))
512            .or_else(|| {
513                self.rows
514                    .iter()
515                    .position(|r| matches!(r, RowRef::Commit(_)))
516            })
517            .or(if self.rows.is_empty() { None } else { Some(0) });
518
519        let mut list = self.commit_list.borrow_mut();
520        list.set_rows(display);
521        list.set_graph(graph);
522        list.set_selected(new_pos);
523    }
524
525    /// Build a `git show`-style view of a commit: a metadata header (SHA,
526    /// refs, author, date, parents), the message, then the full diff.
527    fn commit_detail(&self, idx: usize) -> Diff {
528        let Some(commit) = self.backend.commits().get(idx) else {
529            return Diff::default();
530        };
531
532        let mut lines = Vec::new();
533        let header = |lines: &mut Vec<DiffLine>, text: String| {
534            lines.push(DiffLine::new(DiffLineKind::CommitHeader, text));
535        };
536        let blank = |lines: &mut Vec<DiffLine>| {
537            lines.push(DiffLine::new(DiffLineKind::Context, String::new()));
538        };
539
540        header(&mut lines, format!("commit {}", commit.id));
541        if !commit.refs.is_empty() {
542            let names: Vec<&str> = commit.refs.iter().map(|r| r.name.as_str()).collect();
543            header(&mut lines, format!("Refs:   {}", names.join(", ")));
544        }
545        header(
546            &mut lines,
547            format!("Author: {} <{}>", commit.author_name, commit.author_email),
548        );
549        header(&mut lines, format!("Date:   {}", commit.date_string()));
550        if commit.is_merge() {
551            let shorts: Vec<String> = commit.parents.iter().map(|p| short(p)).collect();
552            header(&mut lines, format!("Merge:  {}", shorts.join(" ")));
553        }
554
555        blank(&mut lines);
556        for line in commit.message.trim_end().lines() {
557            lines.push(DiffLine::new(DiffLineKind::Context, format!("    {line}")));
558        }
559        blank(&mut lines);
560
561        lines.extend(self.backend.commit_diff(idx).lines);
562        Diff { lines }
563    }
564
565    // ---- commit screen ----------------------------------------------------
566
567    /// Re-read the working tree and rebuild the staged / unstaged lists.
568    fn rescan(&mut self) {
569        let amend = self.amend_check.borrow().is_checked();
570        self.working = self.backend.working_status(amend);
571
572        let unstaged: Vec<ListItem> = self.working.unstaged.iter().map(file_row).collect();
573        let staged: Vec<ListItem> = self.working.staged.iter().map(file_row).collect();
574        self.unstaged_list.borrow_mut().set_items(unstaged);
575        self.staged_list.borrow_mut().set_items(staged);
576        self.unstaged_heading.borrow_mut().set_text(format!(
577            "Unstaged Changes ({})",
578            self.working.unstaged.len()
579        ));
580        self.staged_heading.borrow_mut().set_text(format!(
581            "Staged Changes — will commit ({})",
582            self.working.staged.len()
583        ));
584
585        self.prev_unstaged_sel = None;
586        self.prev_staged_sel = None;
587        self.commit_diff_view.borrow_mut().set_diff(Diff::default());
588
589        // Default the selection to the first file so the diff pane isn't blank.
590        if !self.working.unstaged.is_empty() {
591            self.apply_commit_selection(Side::Unstaged, 0);
592        } else if !self.working.staged.is_empty() {
593            self.apply_commit_selection(Side::Staged, 0);
594        }
595    }
596
597    /// Select file `i` in the `side` list, clear the other list's selection,
598    /// and show that file's diff.
599    fn apply_commit_selection(&mut self, side: Side, i: usize) {
600        match side {
601            Side::Unstaged => {
602                self.unstaged_list.borrow_mut().set_selected(Some(i));
603                self.staged_list.borrow_mut().set_selected(None);
604            }
605            Side::Staged => {
606                self.staged_list.borrow_mut().set_selected(Some(i));
607                self.unstaged_list.borrow_mut().set_selected(None);
608            }
609        }
610        self.prev_unstaged_sel = self.unstaged_list.borrow().selected_index();
611        self.prev_staged_sel = self.staged_list.borrow().selected_index();
612
613        let staged = matches!(side, Side::Staged);
614        let amend = self.amend_check.borrow().is_checked();
615        let files = match side {
616            Side::Unstaged => &self.working.unstaged,
617            Side::Staged => &self.working.staged,
618        };
619        let diff = files
620            .get(i)
621            .map(|f| self.backend.working_diff(&f.path, staged, amend))
622            .unwrap_or_default();
623        self.commit_diff_view.borrow_mut().set_diff(diff);
624    }
625
626    /// Poll the commit screen after an event: handle stage/unstage activations
627    /// (double-click or Enter on a list), selection-driven diff updates, and
628    /// the amend toggle.
629    fn sync_commit(&mut self) -> bool {
630        let unstaged_activated = self.unstaged_list.borrow_mut().take_activated();
631        if let Some(i) = unstaged_activated {
632            self.stage_index(i);
633            return true;
634        }
635        let staged_activated = self.staged_list.borrow_mut().take_activated();
636        if let Some(i) = staged_activated {
637            self.unstage_index(i);
638            return true;
639        }
640
641        let u = self.unstaged_list.borrow().selected_index();
642        let s = self.staged_list.borrow().selected_index();
643        if let Some(i) = u
644            && self.prev_unstaged_sel != Some(i)
645        {
646            self.apply_commit_selection(Side::Unstaged, i);
647            return true;
648        }
649        if let Some(i) = s
650            && self.prev_staged_sel != Some(i)
651        {
652            self.apply_commit_selection(Side::Staged, i);
653            return true;
654        }
655        // A selection may have been cleared elsewhere — keep trackers honest.
656        self.prev_unstaged_sel = u;
657        self.prev_staged_sel = s;
658
659        let amend = self.amend_check.borrow().is_checked();
660        if amend != self.last_amend {
661            self.last_amend = amend;
662            if amend
663                && self.message_editor.borrow().text().trim().is_empty()
664                && let Some(msg) = self.backend.head_message()
665            {
666                self.message_editor.borrow_mut().set_text(msg.trim_end());
667            }
668            // Re-base the staging view on HEAD's parent (or back on HEAD), so
669            // the already-committed changes appear in / leave the staged list.
670            self.rescan();
671            return true;
672        }
673
674        false
675    }
676
677    fn stage_selected(&mut self) -> bool {
678        let sel = self.unstaged_list.borrow().selected_index();
679        match sel {
680            Some(i) => {
681                self.stage_index(i);
682                true
683            }
684            None => false,
685        }
686    }
687
688    /// Stage every unstaged file (git gui's "Stage Changed Files To Commit").
689    fn stage_all(&mut self) -> bool {
690        if self.working.unstaged.is_empty() {
691            return false;
692        }
693        let paths: Vec<String> = self
694            .working
695            .unstaged
696            .iter()
697            .map(|f| f.path.clone())
698            .collect();
699        for path in paths {
700            if let Err(e) = self.backend.stage(&path) {
701                self.dialog.borrow_mut().show_error("Stage failed", &e);
702                break;
703            }
704        }
705        self.rescan();
706        true
707    }
708
709    /// Append a `Signed-off-by` trailer for the configured identity to the
710    /// message editor (git gui's "Sign Off").
711    fn sign_off(&mut self) -> bool {
712        let Some((name, email)) = self.backend.signature() else {
713            self.dialog.borrow_mut().show_error(
714                "Sign off",
715                "No git identity configured. Set user.name and user.email.",
716            );
717            return true;
718        };
719        let body = self.message_editor.borrow().text();
720        match with_signoff(&body, &name, &email) {
721            Some(text) => {
722                self.message_editor.borrow_mut().set_text(&text);
723                true
724            }
725            // Already signed off — nothing to change.
726            None => false,
727        }
728    }
729
730    fn unstage_selected(&mut self) -> bool {
731        let sel = self.staged_list.borrow().selected_index();
732        match sel {
733            Some(i) => {
734                self.unstage_index(i);
735                true
736            }
737            None => false,
738        }
739    }
740
741    fn stage_index(&mut self, i: usize) {
742        if let Some(file) = self.working.unstaged.get(i) {
743            let path = file.path.clone();
744            if let Err(e) = self.backend.stage(&path) {
745                self.dialog.borrow_mut().show_error("Stage failed", &e);
746            }
747        }
748        self.rescan();
749    }
750
751    fn unstage_index(&mut self, i: usize) {
752        if let Some(file) = self.working.staged.get(i) {
753            let path = file.path.clone();
754            let amend = self.amend_check.borrow().is_checked();
755            if let Err(e) = self.backend.unstage(&path, amend) {
756                self.dialog.borrow_mut().show_error("Unstage failed", &e);
757            }
758        }
759        self.rescan();
760    }
761
762    /// `git gui`'s "Revert Changes" (Ctrl+J): discard the working-tree changes
763    /// to the selected *unstaged* file. Because the change can't be undone, this
764    /// only arms the operation — it stashes what to do and pops a confirm dialog
765    /// whose affirmative button drives [`AppCommand::PerformDiscard`]. A tracked
766    /// file is reverted to its index copy; an untracked file (no committed or
767    /// staged version to fall back to) is instead offered up for deletion.
768    fn revert_selected(&mut self) -> bool {
769        let Some(i) = self.unstaged_list.borrow().selected_index() else {
770            return false;
771        };
772        let Some(file) = self.working.unstaged.get(i) else {
773            return false;
774        };
775        let display = file.display();
776        let path = file.path.clone();
777        let (title, message, affirm) = if file.status == ChangeStatus::Untracked {
778            self.pending_discard = Some(PendingDiscard::Delete(path));
779            (
780                "Delete File",
781                format!(
782                    "Delete untracked file\n{display}?\n\nIt is not tracked by git and cannot be recovered."
783                ),
784                "Delete File",
785            )
786        } else {
787            self.pending_discard = Some(PendingDiscard::Revert(path));
788            (
789                "Revert Changes",
790                format!(
791                    "Revert unstaged changes in\n{display}?\n\nThese changes will be permanently lost."
792                ),
793                "Revert Changes",
794            )
795        };
796
797        let commands = self.commands.clone();
798        self.dialog
799            .borrow_mut()
800            .show_confirm(title, message, affirm, move |cx| {
801                commands.borrow_mut().push(AppCommand::PerformDiscard);
802                cx.request_paint();
803            });
804        true
805    }
806
807    /// Carry out the revert / delete the user confirmed in
808    /// [`Self::revert_selected`].
809    fn perform_discard(&mut self) -> bool {
810        let (failure, result) = match self.pending_discard.take() {
811            Some(PendingDiscard::Revert(path)) => ("Revert failed", self.backend.revert(&path)),
812            Some(PendingDiscard::Delete(path)) => {
813                ("Delete failed", self.backend.delete_untracked(&path))
814            }
815            None => return false,
816        };
817        if let Err(e) = result {
818            self.dialog.borrow_mut().show_error(failure, &e);
819        }
820        self.rescan();
821        true
822    }
823
824    fn do_commit(&mut self) -> bool {
825        let amend = self.amend_check.borrow().is_checked();
826        let message = self.message_editor.borrow().text();
827
828        if self.working.staged.is_empty() && !amend {
829            self.dialog.borrow_mut().show_error(
830                "Nothing to commit",
831                "Stage some changes first, or enable \u{201C}Amend last commit\u{201D}.",
832            );
833            return true;
834        }
835
836        match self.backend.commit(&message, amend) {
837            Ok(()) => {
838                self.message_editor.borrow_mut().set_text("");
839                self.amend_check.borrow_mut().set_checked(false);
840                self.last_amend = false;
841                // Refresh history + working tree. Re-open when we can (so the
842                // new commit shows in the log); otherwise refresh in place.
843                if !self.reload() {
844                    self.shown = None;
845                    self.sync_browse(true);
846                    self.rescan();
847                }
848                // Return to the log view, now showing the new commit.
849                self.set_mode(Mode::Browse);
850            }
851            Err(e) => {
852                self.dialog.borrow_mut().show_error("Commit failed", &e);
853            }
854        }
855        true
856    }
857
858    /// `git gui`-style keyboard accelerators, handled before the active screen
859    /// sees the event so they fire regardless of which pane holds focus — in
860    /// particular Ctrl+Enter commits instead of inserting a newline in the
861    /// message editor. Returns `true` when the keystroke was consumed.
862    fn handle_shortcut(&mut self, event: &Event, ctx: &mut EventCtx) -> bool {
863        // While a modal dialog is up it owns the keyboard.
864        if self.dialog.borrow().is_open() {
865            return false;
866        }
867        let Event::KeyDown { key, modifiers } = event else {
868            return false;
869        };
870        // Only plain Ctrl-chords; Alt / Logo combos belong to the menu bar / OS.
871        if !modifiers.control || modifiers.alt || modifiers.logo {
872            return false;
873        }
874
875        let letter = match key {
876            Key::Char(c) => Some(c.to_ascii_lowercase()),
877            _ => None,
878        };
879
880        // Ctrl+Q quits from either screen (git gui binds quit globally).
881        if letter == Some('q') {
882            ctx.close();
883            return true;
884        }
885
886        // The remaining accelerators drive the staging screen.
887        if self.mode != Mode::Commit {
888            return false;
889        }
890        let command = if matches!(key, Key::Named(NamedKey::Enter)) {
891            AppCommand::Commit
892        } else {
893            match letter {
894                Some('r') => AppCommand::Rescan,
895                Some('t') => AppCommand::StageSelected,
896                Some('i') => AppCommand::StageAll,
897                Some('j') => AppCommand::RevertSelected,
898                Some('s') => AppCommand::SignOff,
899                _ => return false,
900            }
901        };
902        self.commands.borrow_mut().push(command);
903        true
904    }
905}
906
907impl Widget for GitClient {
908    fn bounds(&self) -> Rect {
909        self.bounds
910    }
911
912    fn paint(&mut self, painter: &mut Painter, theme: &Theme) {
913        self.active_mut().paint(painter, theme);
914    }
915
916    fn paint_overlay(&mut self, painter: &mut Painter, theme: &Theme) {
917        self.active_mut().paint_overlay(painter, theme);
918    }
919
920    fn event(&mut self, event: &Event, ctx: &mut EventCtx) {
921        // Application accelerators take precedence over the focused pane.
922        if !self.handle_shortcut(event, ctx) {
923            self.active_mut().event(event, ctx);
924        }
925        // After the tree processes the event, apply commands and sync the
926        // active screen's dependent panes.
927        let mut dirty = self.drain_commands();
928        dirty |= match self.mode {
929            Mode::Browse => self.sync_browse(false),
930            Mode::Commit => self.sync_commit(),
931        };
932        if dirty {
933            ctx.request_paint();
934        }
935    }
936
937    fn captures_pointer(&self) -> bool {
938        self.active().captures_pointer()
939    }
940
941    fn focusable(&self) -> bool {
942        self.active().focusable()
943    }
944
945    fn set_focused(&mut self, focused: bool) {
946        self.active_mut().set_focused(focused);
947    }
948
949    fn layout(&mut self, bounds: Rect) {
950        self.bounds = bounds;
951        self.browse_root.layout(bounds);
952        self.commit_root.layout(bounds);
953    }
954
955    fn focus_first(&mut self) -> bool {
956        match self.mode {
957            // Start on the commit list rather than the leading search field,
958            // so arrow keys navigate history immediately (gitk behavior).
959            Mode::Browse => self.browse_root.focus_child(BROWSE_HISTORY_IDX),
960            Mode::Commit => self.commit_root.focus_child(COMMIT_UNSTAGED_IDX),
961        }
962    }
963
964    fn popup_request(&self) -> Option<PopupRequest> {
965        self.active().popup_request()
966    }
967
968    fn wants_ticks(&self) -> bool {
969        self.active().wants_ticks()
970    }
971}
972
973/// Build the browse-screen menu bar: File ▸ Reload / Exit, View ▸ Commit
974/// Changes (switch screens), Help ▸ About.
975fn build_browse_menu(
976    commands: Rc<RefCell<Vec<AppCommand>>>,
977    dialog: Rc<RefCell<Dialog>>,
978) -> MenuBar {
979    MenuBar::new(Rect::new(0, 0, 0, 0))
980        .add_menu(Menu::new(
981            "&File",
982            vec![
983                cmd_item("&Reload", &commands, AppCommand::Reload),
984                MenuItem::separator(),
985                MenuItem::action("E&xit", |cx| cx.close()).with_accel("Ctrl+Q"),
986            ],
987        ))
988        .add_menu(Menu::new(
989            "&View",
990            vec![cmd_item(
991                "&Commit Changes",
992                &commands,
993                AppCommand::EnterCommitMode,
994            )],
995        ))
996        .add_menu(Menu::new("&Help", vec![about_item(&dialog)]))
997}
998
999/// Build the commit-screen menu bar: File, Commit (the staging actions), View
1000/// ▸ Browse History, Help.
1001fn build_commit_menu(
1002    commands: Rc<RefCell<Vec<AppCommand>>>,
1003    dialog: Rc<RefCell<Dialog>>,
1004) -> MenuBar {
1005    MenuBar::new(Rect::new(0, 0, 0, 0))
1006        .add_menu(Menu::new(
1007            "&File",
1008            vec![
1009                cmd_item("&Reload", &commands, AppCommand::Reload),
1010                MenuItem::separator(),
1011                MenuItem::action("E&xit", |cx| cx.close()).with_accel("Ctrl+Q"),
1012            ],
1013        ))
1014        .add_menu(Menu::new(
1015            "&Commit",
1016            vec![
1017                cmd_item("&Rescan", &commands, AppCommand::Rescan).with_accel("Ctrl+R"),
1018                MenuItem::separator(),
1019                cmd_item("&Stage Selected", &commands, AppCommand::StageSelected)
1020                    .with_accel("Ctrl+T"),
1021                cmd_item("Stage &All", &commands, AppCommand::StageAll).with_accel("Ctrl+I"),
1022                cmd_item("&Unstage Selected", &commands, AppCommand::UnstageSelected),
1023                cmd_item("Re&vert Changes", &commands, AppCommand::RevertSelected)
1024                    .with_accel("Ctrl+J"),
1025                MenuItem::separator(),
1026                cmd_item("Sign &Off", &commands, AppCommand::SignOff).with_accel("Ctrl+S"),
1027                cmd_item("&Commit", &commands, AppCommand::Commit).with_accel("Ctrl+Enter"),
1028            ],
1029        ))
1030        .add_menu(Menu::new(
1031            "&View",
1032            vec![cmd_item(
1033                "&Browse History",
1034                &commands,
1035                AppCommand::EnterBrowseMode,
1036            )],
1037        ))
1038        .add_menu(Menu::new("&Help", vec![about_item(&dialog)]))
1039}
1040
1041/// A menu item that pushes `command` onto the deferred-command queue.
1042fn cmd_item(label: &str, commands: &Rc<RefCell<Vec<AppCommand>>>, command: AppCommand) -> MenuItem {
1043    let commands = commands.clone();
1044    MenuItem::action(label, move |cx| {
1045        commands.borrow_mut().push(command);
1046        cx.request_paint();
1047    })
1048}
1049
1050/// The shared Help ▸ About item.
1051fn about_item(dialog: &Rc<RefCell<Dialog>>) -> MenuItem {
1052    let dialog = dialog.clone();
1053    MenuItem::action("&About", move |cx| {
1054        dialog.borrow_mut().show_info(
1055            "About Git Journey",
1056            "Git Journey\n\nA gitk-style repository browser\nbuilt on the Saudade toolkit.",
1057        );
1058        cx.request_paint();
1059    })
1060}
1061
1062/// A push button that pushes `command` onto the deferred-command queue.
1063fn command_button(
1064    label: &str,
1065    commands: &Rc<RefCell<Vec<AppCommand>>>,
1066    command: AppCommand,
1067) -> Button {
1068    let commands = commands.clone();
1069    Button::new(Rect::new(0, 0, 0, 0), label).on_click(move |cx| {
1070        commands.borrow_mut().push(command);
1071        cx.request_paint();
1072    })
1073}
1074
1075/// First 8 hex chars of a SHA, for compact parent display.
1076fn short(sha: &str) -> String {
1077    sha.chars().take(8).collect()
1078}
1079
1080/// The message text after appending a `Signed-off-by` trailer for `name` /
1081/// `email`, or `None` when that exact trailer is already the last line. A prose
1082/// body is separated from the trailer by a blank line; an existing trailer
1083/// block keeps the sign-off tight against it (no blank line), matching git gui.
1084fn with_signoff(body: &str, name: &str, email: &str) -> Option<String> {
1085    let trailer = format!("Signed-off-by: {name} <{email}>");
1086    let last_line = body.lines().next_back().unwrap_or("").trim_end();
1087    if last_line.eq_ignore_ascii_case(&trailer) {
1088        return None;
1089    }
1090    let trimmed = body.trim_end();
1091    Some(if trimmed.is_empty() {
1092        trailer
1093    } else if is_trailer_line(last_line) {
1094        format!("{trimmed}\n{trailer}")
1095    } else {
1096        format!("{trimmed}\n\n{trailer}")
1097    })
1098}
1099
1100/// Does `line` look like an RFC-822-style commit trailer (`Signed-off-by:`,
1101/// `Acked-by:`, …)? Used so a fresh sign-off stays tight against an existing
1102/// trailer block rather than getting an extra blank line before it.
1103fn is_trailer_line(line: &str) -> bool {
1104    let Some((key, _)) = line.split_once(':') else {
1105        return false;
1106    };
1107    let key = key.to_ascii_lowercase();
1108    key.ends_with("-by") && key.chars().all(|c| c.is_ascii_alphabetic() || c == '-')
1109}
1110
1111/// Build the display row for a working-tree pseudo-entry in the log.
1112fn wip_row(side: Side, count: usize) -> CommitRow {
1113    let summary = match side {
1114        Side::Unstaged => format!("Uncommitted changes ({count})"),
1115        Side::Staged => format!("Staged changes ({count})"),
1116    };
1117    CommitRow {
1118        summary,
1119        ..Default::default()
1120    }
1121}
1122
1123/// The id of the current `HEAD` commit (the one the working tree sits on), so
1124/// the working-tree pseudo-rows can chain into it in the graph. Falls back to
1125/// the newest commit, or `None` for an empty history.
1126fn head_commit_id(commits: &[CommitInfo]) -> Option<String> {
1127    commits
1128        .iter()
1129        .find(|c| {
1130            c.refs
1131                .iter()
1132                .any(|r| matches!(r.kind, RefKind::Head | RefKind::DetachedHead))
1133        })
1134        .or_else(|| commits.first())
1135        .map(|c| c.id.clone())
1136}
1137
1138/// Does a commit match a (already-lowercased) search query? Matches against
1139/// the summary, message, author name/email, ref names and the full SHA.
1140fn commit_matches(commit: &CommitInfo, query: &str) -> bool {
1141    commit.summary.to_lowercase().contains(query)
1142        || commit.message.to_lowercase().contains(query)
1143        || commit.author_name.to_lowercase().contains(query)
1144        || commit.author_email.to_lowercase().contains(query)
1145        || commit.id.contains(query)
1146        || commit
1147            .refs
1148            .iter()
1149            .any(|r| r.name.to_lowercase().contains(query))
1150}
1151
1152/// Build a commit-list row from a commit: ref badges + summary on the left,
1153/// author and short date in the right-hand columns.
1154pub fn commit_row(commit: &CommitInfo) -> CommitRow {
1155    CommitRow {
1156        id: commit.id.clone(),
1157        parents: commit.parents.clone(),
1158        summary: commit.summary.clone(),
1159        refs: commit.refs.clone(),
1160        author: commit.author_name.clone(),
1161        date: commit.short_date_string(),
1162    }
1163}
1164
1165/// Format a changed file as a list row: status badge + path.
1166pub fn file_row(file: &FileChange) -> ListItem {
1167    ListItem::new(format!("{}  {}", file.status.badge(), file.display()))
1168}
1169
1170#[cfg(test)]
1171mod tests {
1172    use super::{is_trailer_line, with_signoff};
1173
1174    const NAME: &str = "Ada Lovelace";
1175    const EMAIL: &str = "ada@example.com";
1176    const SOB: &str = "Signed-off-by: Ada Lovelace <ada@example.com>";
1177
1178    #[test]
1179    fn signoff_into_empty_message_is_just_the_trailer() {
1180        assert_eq!(with_signoff("", NAME, EMAIL).as_deref(), Some(SOB));
1181        assert_eq!(with_signoff("   \n", NAME, EMAIL).as_deref(), Some(SOB));
1182    }
1183
1184    #[test]
1185    fn signoff_after_prose_gets_a_blank_separator_line() {
1186        assert_eq!(
1187            with_signoff("Fix the thing", NAME, EMAIL).as_deref(),
1188            Some(format!("Fix the thing\n\n{SOB}").as_str())
1189        );
1190    }
1191
1192    #[test]
1193    fn signoff_after_a_trailer_block_stays_tight() {
1194        let body = "Fix the thing\n\nReviewed-by: B <b@example.com>";
1195        assert_eq!(
1196            with_signoff(body, NAME, EMAIL).as_deref(),
1197            Some(format!("{body}\n{SOB}").as_str())
1198        );
1199    }
1200
1201    #[test]
1202    fn signoff_is_idempotent_when_already_last_line() {
1203        let body = format!("Fix the thing\n\n{SOB}");
1204        assert_eq!(with_signoff(&body, NAME, EMAIL), None);
1205    }
1206
1207    #[test]
1208    fn trailer_lines_are_recognized() {
1209        assert!(is_trailer_line("Signed-off-by: A <a@x>"));
1210        assert!(is_trailer_line("Reviewed-by: B <b@x>"));
1211        assert!(is_trailer_line("Co-authored-by: C <c@x>"));
1212        assert!(!is_trailer_line("Just a normal sentence."));
1213        assert!(!is_trailer_line("Fixes: #123"));
1214        assert!(!is_trailer_line(""));
1215    }
1216}