Skip to main content

journey/
ui.rs

1//! The top-level [`GitClient`] widget.
2//!
3//! `GitClient` drives three 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//! * **Review** — a branch reviewer: every local and remote branch, with the
9//!   aggregated diff of all the changes the selected branch contains.
10//!
11//! saudade widgets are callback-free, so the cross-pane wiring is done here:
12//! after each event the active screen's selections (and a small command queue
13//! menus/buttons push into) are polled, and dependent panes are rebuilt from
14//! the [`RepoBackend`].
15
16use std::cell::RefCell;
17use std::collections::BTreeSet;
18use std::rc::Rc;
19
20use saudade::{
21    Button, Checkbox, Dialog, Event, EventCtx, List, ListItem, Menu, MenuBar, MenuItem,
22    ModifierScheme, Painter, PopupRequest, Rect, SvgImage, TextEditor, Theme, Widget, include_svg,
23};
24
25use crate::backend::{
26    BlobPair, BranchInfo, ChangeStatus, CommitInfo, Diff, DiffLine, DiffLineKind, FileChange,
27    PartialMode, RefKind, RefLabel, RepoBackend, WorkingStatus, build_partial_patch,
28};
29use crate::imagediff::{ImageComparison, is_image_path};
30use crate::widgets::{
31    CommitList, CommitRow, DiffMode, DiffPane, Heading, SearchBar, Shared, Shell, compute_graph,
32    layout,
33};
34
35/// Direct-child index of the history list in the browse shell (focused first).
36const BROWSE_HISTORY_IDX: usize = 2;
37/// Direct-child index of the unstaged list in the commit shell.
38const COMMIT_UNSTAGED_IDX: usize = 2;
39/// Direct-child index of the branch list in the review shell.
40const REVIEW_BRANCHES_IDX: usize = 1;
41
42/// Sentinel commit ids for the working-tree pseudo-rows in the log graph
43/// (chosen so they never collide with a real 40-hex SHA).
44const WIP_UNSTAGED_ID: &str = "\u{1}journey-wip-unstaged";
45const WIP_STAGED_ID: &str = "\u{1}journey-wip-staged";
46
47/// A closure that re-opens the repository (used by File ▸ Reload and after a
48/// commit). `None` for fixture-backed clients in tests.
49type ReopenFn = Box<dyn Fn() -> Option<Rc<dyn RepoBackend>>>;
50
51/// Which screen is shown.
52#[derive(Clone, Copy, PartialEq, Eq, Default)]
53enum Mode {
54    #[default]
55    Browse,
56    Commit,
57    Review,
58}
59
60/// Which working-tree list a commit-mode selection came from, and which side
61/// a working-tree pseudo-row in the log represents.
62#[derive(Clone, Copy, PartialEq, Eq)]
63enum Side {
64    Unstaged,
65    Staged,
66}
67
68/// What a row in the history log refers to.
69#[derive(Clone, Copy, PartialEq, Eq)]
70enum RowRef {
71    /// A working-tree pseudo-row ("Uncommitted changes" / "Staged changes").
72    Wip(Side),
73    /// A real commit, by backend index.
74    Commit(usize),
75}
76
77/// Deferred actions menus / buttons request; drained by `GitClient` after
78/// event dispatch so they can mutate state the callbacks can't reach.
79#[derive(Clone, Copy, PartialEq, Eq, Debug)]
80enum AppCommand {
81    Reload,
82    EnterCommitMode,
83    EnterBrowseMode,
84    EnterReviewMode,
85    Rescan,
86    StageSelected,
87    StageAll,
88    UnstageSelected,
89    /// Ask to revert the selected unstaged file (pops the confirm dialog).
90    RevertSelected,
91    /// The confirm dialog's affirmative button fired — carry out the armed
92    /// `pending_discard`.
93    PerformDiscard,
94    SignOff,
95    Commit,
96    /// Select the next / previous image file in the active list (View ▸ Next /
97    /// Previous Image, Ctrl+N / Ctrl+P).
98    NextImage,
99    PrevImage,
100    /// Cycle the shown image comparison mode (View ▸ Switch Mode, Ctrl+M).
101    CycleImageMode,
102    /// Show just the before / after side of the shown image (View ▸ Before /
103    /// After Image, Ctrl+Left / Ctrl+Right).
104    ShowImageBefore,
105    ShowImageAfter,
106}
107
108/// Live state the View menu reads, refreshed after every event (see
109/// [`GitClient::update_menu_nav`]) so the menu reflects the current screen: it
110/// greys the image actions that don't apply — whether the active diff pane
111/// shows an image (Switch Mode / Before / After) and whether the active file
112/// list holds another image to jump to (Next / Previous) — and checkmarks the
113/// active mode against the Browse History / Commit Changes entries.
114#[derive(Default)]
115struct MenuNav {
116    mode: Mode,
117    showing_image: bool,
118    can_nav_images: bool,
119}
120
121/// A discard armed by [`GitClient::revert_selected`] and awaiting the user's
122/// confirmation: revert a tracked file to its index copy, or delete an
123/// untracked file outright (it has nothing to revert to).
124enum PendingDiscard {
125    Revert(String),
126    Delete(String),
127}
128
129pub struct GitClient {
130    backend: Rc<dyn RepoBackend>,
131    mode: Mode,
132    bounds: Rect,
133
134    // ---- browse screen ----------------------------------------------------
135    browse_root: Shell,
136    search: Rc<RefCell<SearchBar>>,
137    commit_list: Rc<RefCell<CommitList>>,
138    file_list: Rc<RefCell<List>>,
139    diff_pane: Rc<RefCell<DiffPane>>,
140
141    // ---- commit screen ----------------------------------------------------
142    commit_root: Shell,
143    unstaged_list: Rc<RefCell<List>>,
144    staged_list: Rc<RefCell<List>>,
145    unstaged_heading: Rc<RefCell<Heading>>,
146    staged_heading: Rc<RefCell<Heading>>,
147    commit_diff_pane: Rc<RefCell<DiffPane>>,
148    message_editor: Rc<RefCell<TextEditor>>,
149    amend_check: Rc<RefCell<Checkbox>>,
150    stage_btn: Rc<RefCell<Button>>,
151    unstage_btn: Rc<RefCell<Button>>,
152    rescan_btn: Rc<RefCell<Button>>,
153    /// Whether the last layout was at a narrow width; the Stage/Unstage/Rescan
154    /// buttons drop their text in narrow mode (see [`Self::apply_narrow`]).
155    narrow: bool,
156
157    // ---- review screen ------------------------------------------------------
158    review_root: Shell,
159    branch_list: Rc<RefCell<CommitList>>,
160    review_file_list: Rc<RefCell<List>>,
161    review_diff_pane: Rc<RefCell<DiffPane>>,
162
163    // ---- shared -----------------------------------------------------------
164    dialog: Rc<RefCell<Dialog>>,
165    commands: Rc<RefCell<Vec<AppCommand>>>,
166    reopen: Option<ReopenFn>,
167    /// Enable state the View-menu image actions read; kept current by
168    /// [`Self::update_menu_nav`].
169    nav_state: Rc<RefCell<MenuNav>>,
170
171    // ---- browse sync state ------------------------------------------------
172    /// Row references in display order: the working-tree pseudo-rows (when
173    /// present) followed by the visible commits.
174    rows: Vec<RowRef>,
175    last_query: String,
176    /// Working-tree status backing the log's pseudo-rows (and the file/diff
177    /// panes when one is selected). Refreshed by `rebuild_commits`.
178    log_working: WorkingStatus,
179    current_files: Vec<FileChange>,
180    /// The log row whose detail the file/diff panes currently show.
181    shown: Option<RowRef>,
182    shown_file: Option<usize>,
183
184    // ---- review sync state --------------------------------------------------
185    /// The branches shown in the review list, aligned 1:1 with its rows.
186    branches: Vec<BranchInfo>,
187    /// The aggregated file changes of the branch the review panes show.
188    review_files: Vec<FileChange>,
189    /// The branch row whose detail the review file/diff panes currently show.
190    shown_branch: Option<usize>,
191    shown_review_file: Option<usize>,
192
193    // ---- commit sync state ------------------------------------------------
194    working: WorkingStatus,
195    prev_unstaged_sel: Option<usize>,
196    prev_staged_sel: Option<usize>,
197    last_amend: bool,
198    /// The discard awaiting confirmation, set when the confirm dialog is shown
199    /// and consumed when its affirmative button drives
200    /// `AppCommand::PerformDiscard`.
201    pending_discard: Option<PendingDiscard>,
202}
203
204impl GitClient {
205    pub fn new(backend: Rc<dyn RepoBackend>) -> Self {
206        Self::with_menu_scheme(backend, ModifierScheme::native())
207    }
208
209    /// Like [`GitClient::new`], but pins the accelerator modifier scheme
210    /// instead of using the build platform's. Tests pin [`ModifierScheme::Pc`]
211    /// so chords match and menus render their accel labels identically on
212    /// every host.
213    pub fn with_menu_scheme(backend: Rc<dyn RepoBackend>, scheme: ModifierScheme) -> Self {
214        let dialog = Rc::new(RefCell::new(Dialog::new()));
215        let commands: Rc<RefCell<Vec<AppCommand>>> = Rc::new(RefCell::new(Vec::new()));
216        let nav_state: Rc<RefCell<MenuNav>> = Rc::new(RefCell::new(MenuNav::default()));
217
218        // Browse-screen widgets.
219        let search = Rc::new(RefCell::new(SearchBar::new(Rect::new(0, 0, 0, 0))));
220        let commit_list = Rc::new(RefCell::new(CommitList::new(Rect::new(0, 0, 0, 0))));
221        let file_list = Rc::new(RefCell::new(List::new(Rect::new(0, 0, 0, 0))));
222        let diff_pane = Rc::new(RefCell::new(DiffPane::new(Rect::new(0, 0, 0, 0))));
223
224        // Add order sets the Tab focus order: search → commits → files → diff
225        // (the menu bar isn't focusable; it works via accelerators). The file
226        // list follows the commit list so Tab walks the panes left-to-right.
227        // No flat background fill: the panes float on the window's desktop
228        // pattern, which shows through the padding around them.
229        let browse_root = Shell::new()
230            .no_background()
231            .add(
232                build_browse_menu(commands.clone(), dialog.clone(), nav_state.clone(), scheme),
233                layout::browse_menu,
234            )
235            .add(Shared::new(search.clone()), layout::browse_toolbar)
236            .add(Shared::new(commit_list.clone()), layout::browse_history)
237            .add(Shared::new(file_list.clone()), layout::browse_files)
238            .add(Shared::new(diff_pane.clone()), layout::browse_diff)
239            .add_overlay(Shared::new(dialog.clone()));
240
241        // Review-screen widgets. The branch list reuses `CommitList`: each row
242        // carries the branch name as a colored ref badge plus the tip commit's
243        // summary / author / date in the usual columns.
244        let branch_list = Rc::new(RefCell::new(CommitList::new(Rect::new(0, 0, 0, 0))));
245        let review_file_list = Rc::new(RefCell::new(List::new(Rect::new(0, 0, 0, 0))));
246        let review_diff_pane = Rc::new(RefCell::new(DiffPane::new(Rect::new(0, 0, 0, 0))));
247
248        let review_root = Shell::new()
249            .no_background()
250            .add(
251                build_browse_menu(commands.clone(), dialog.clone(), nav_state.clone(), scheme),
252                layout::review_menu,
253            )
254            .add(Shared::new(branch_list.clone()), layout::review_branches)
255            .add(Shared::new(review_file_list.clone()), layout::review_files)
256            .add(Shared::new(review_diff_pane.clone()), layout::review_diff)
257            .add_overlay(Shared::new(dialog.clone()));
258
259        // Commit-screen widgets.
260        let unstaged_list = Rc::new(RefCell::new(List::new(Rect::new(0, 0, 0, 0))));
261        let staged_list = Rc::new(RefCell::new(List::new(Rect::new(0, 0, 0, 0))));
262        let unstaged_heading = Rc::new(RefCell::new(Heading::new("Unstaged Changes")));
263        let staged_heading = Rc::new(RefCell::new(Heading::new("Staged Changes")));
264        let commit_diff_pane = Rc::new(RefCell::new(DiffPane::new(Rect::new(0, 0, 0, 0))));
265        let message_editor = Rc::new(RefCell::new(TextEditor::new(Rect::new(0, 0, 0, 0))));
266        let amend_check = Rc::new(RefCell::new(Checkbox::new(
267            Rect::new(0, 0, 0, 0),
268            "Amend last commit",
269        )));
270        // Created with the wide (symbol + text) labels; `layout` swaps them for
271        // symbol-only when the window is narrow.
272        let [stage_lbl, unstage_lbl, rescan_lbl] = left_btn_labels(false);
273        let stage_btn = Rc::new(RefCell::new(command_button(
274            stage_lbl,
275            &commands,
276            AppCommand::StageSelected,
277        )));
278        let unstage_btn = Rc::new(RefCell::new(command_button(
279            unstage_lbl,
280            &commands,
281            AppCommand::UnstageSelected,
282        )));
283        let rescan_btn = Rc::new(RefCell::new(command_button(
284            rescan_lbl,
285            &commands,
286            AppCommand::Rescan,
287        )));
288
289        // No flat background fill: the staging panes float on the window's
290        // desktop pattern (git-gui style), which shows through the gaps.
291        let commit_root = Shell::new()
292            .no_background()
293            .add(
294                build_commit_menu(commands.clone(), dialog.clone(), nav_state.clone(), scheme),
295                layout::commit_menu,
296            )
297            .add(
298                Shared::new(unstaged_heading.clone()),
299                layout::commit_unstaged_label,
300            )
301            .add(
302                Shared::new(unstaged_list.clone()),
303                layout::commit_unstaged_list,
304            )
305            .add(
306                Shared::new(staged_heading.clone()),
307                layout::commit_staged_label,
308            )
309            .add(Shared::new(staged_list.clone()), layout::commit_staged_list)
310            .add(Shared::new(stage_btn.clone()), layout::commit_stage_btn)
311            .add(Shared::new(unstage_btn.clone()), layout::commit_unstage_btn)
312            .add(Shared::new(rescan_btn.clone()), layout::commit_rescan_btn)
313            .add(Heading::new("Diff"), layout::commit_diff_label)
314            .add(Shared::new(commit_diff_pane.clone()), layout::commit_diff)
315            .add(Heading::new("Commit Message"), layout::commit_msg_label)
316            .add(Shared::new(message_editor.clone()), layout::commit_editor)
317            .add(Shared::new(amend_check.clone()), layout::commit_amend)
318            .add(
319                command_button("Commit", &commands, AppCommand::Commit),
320                layout::commit_commit_btn,
321            )
322            .add_overlay(Shared::new(dialog.clone()));
323
324        let mut client = Self {
325            backend,
326            mode: Mode::Browse,
327            bounds: Rect::new(0, 0, 0, 0),
328            browse_root,
329            search,
330            commit_list,
331            file_list,
332            diff_pane,
333            commit_root,
334            unstaged_list,
335            staged_list,
336            unstaged_heading,
337            staged_heading,
338            commit_diff_pane,
339            message_editor,
340            amend_check,
341            stage_btn,
342            unstage_btn,
343            rescan_btn,
344            narrow: false,
345            review_root,
346            branch_list,
347            review_file_list,
348            review_diff_pane,
349            dialog,
350            commands,
351            reopen: None,
352            nav_state,
353            rows: Vec::new(),
354            last_query: String::new(),
355            log_working: WorkingStatus::default(),
356            current_files: Vec::new(),
357            shown: None,
358            shown_file: None,
359            branches: Vec::new(),
360            review_files: Vec::new(),
361            shown_branch: None,
362            shown_review_file: None,
363            working: WorkingStatus::default(),
364            prev_unstaged_sel: None,
365            prev_staged_sel: None,
366            last_amend: false,
367            pending_discard: None,
368        };
369        client.sync_browse(true);
370        client.update_menu_nav();
371        client
372    }
373
374    /// Install the repository re-open hook used by File ▸ Reload and refresh
375    /// after a commit.
376    pub fn with_reopen(mut self, reopen: ReopenFn) -> Self {
377        self.reopen = Some(reopen);
378        self
379    }
380
381    /// Switch to the commit screen. Exposed for tests; at runtime the View
382    /// menu drives this through the command queue.
383    pub fn enter_commit_mode(&mut self) {
384        self.set_mode(Mode::Commit);
385    }
386
387    /// Switch to the branch-review screen. Exposed for tests; at runtime the
388    /// View menu drives this through the command queue.
389    pub fn enter_review_mode(&mut self) {
390        self.set_mode(Mode::Review);
391    }
392
393    fn active(&self) -> &Shell {
394        match self.mode {
395            Mode::Browse => &self.browse_root,
396            Mode::Commit => &self.commit_root,
397            Mode::Review => &self.review_root,
398        }
399    }
400
401    fn active_mut(&mut self) -> &mut Shell {
402        match self.mode {
403            Mode::Browse => &mut self.browse_root,
404            Mode::Commit => &mut self.commit_root,
405            Mode::Review => &mut self.review_root,
406        }
407    }
408
409    /// Apply width-only affordances: in narrow mode the Stage/Unstage/Rescan
410    /// buttons shrink to share a third-width column (see `layout`), so they drop
411    /// their text and keep just the symbol. Cheap no-op when the state is
412    /// unchanged.
413    fn apply_narrow(&mut self, narrow: bool) {
414        if narrow == self.narrow {
415            return;
416        }
417        self.narrow = narrow;
418        let [stage, unstage, rescan] = left_btn_labels(narrow);
419        self.stage_btn.borrow_mut().label = stage.to_string();
420        self.unstage_btn.borrow_mut().label = unstage.to_string();
421        self.rescan_btn.borrow_mut().label = rescan.to_string();
422    }
423
424    fn set_mode(&mut self, mode: Mode) -> bool {
425        if self.mode == mode {
426            return false;
427        }
428        self.mode = mode;
429        match mode {
430            Mode::Commit => {
431                self.rescan();
432                self.commit_root.layout(self.bounds);
433                self.commit_root.focus_child(COMMIT_UNSTAGED_IDX);
434            }
435            Mode::Browse => {
436                self.browse_root.layout(self.bounds);
437                self.browse_root.focus_child(BROWSE_HISTORY_IDX);
438            }
439            Mode::Review => {
440                self.rebuild_branches();
441                self.sync_review(true);
442                self.review_root.layout(self.bounds);
443                self.review_root.focus_child(REVIEW_BRANCHES_IDX);
444            }
445        }
446        true
447    }
448
449    /// Apply queued menu / button commands. Returns `true` if state changed.
450    fn drain_commands(&mut self) -> bool {
451        let pending: Vec<AppCommand> = self.commands.borrow_mut().drain(..).collect();
452        let mut changed = false;
453        for command in pending {
454            changed |= match command {
455                AppCommand::Reload => self.reload(),
456                AppCommand::EnterCommitMode => self.set_mode(Mode::Commit),
457                AppCommand::EnterBrowseMode => self.set_mode(Mode::Browse),
458                AppCommand::EnterReviewMode => self.set_mode(Mode::Review),
459                AppCommand::Rescan => {
460                    self.rescan();
461                    true
462                }
463                AppCommand::StageSelected => self.stage_selected(),
464                AppCommand::StageAll => self.stage_all(),
465                AppCommand::UnstageSelected => self.unstage_selected(),
466                AppCommand::RevertSelected => self.revert_selected(),
467                AppCommand::PerformDiscard => self.perform_discard(),
468                AppCommand::SignOff => self.sign_off(),
469                AppCommand::Commit => self.do_commit(),
470                AppCommand::NextImage => self.navigate_image(true),
471                AppCommand::PrevImage => self.navigate_image(false),
472                AppCommand::CycleImageMode => self.cycle_image_mode(),
473                AppCommand::ShowImageBefore => self.show_image_side(true),
474                AppCommand::ShowImageAfter => self.show_image_side(false),
475            };
476        }
477        changed
478    }
479
480    /// Re-open the repository and rebuild every pane. No-op (returns `false`)
481    /// without a reopen hook, e.g. fixture-backed clients.
482    fn reload(&mut self) -> bool {
483        let Some(reopen) = &self.reopen else {
484            return false;
485        };
486        let Some(backend) = reopen() else {
487            self.dialog
488                .borrow_mut()
489                .show_error("Reload failed", "Could not re-open the repository.");
490            return true;
491        };
492        self.backend = backend;
493        self.shown = None;
494        self.shown_file = None;
495        self.last_query.clear();
496        self.search.borrow_mut().clear();
497        self.sync_browse(true);
498        self.rescan();
499        // The branch list is rebuilt on every entry into review mode; only an
500        // on-screen review needs refreshing here.
501        if self.mode == Mode::Review {
502            self.rebuild_branches();
503            self.sync_review(true);
504        }
505        true
506    }
507
508    // ---- browse screen ----------------------------------------------------
509
510    /// Reload browse panes from the current selection state. Double-clicking a
511    /// working-tree pseudo-row jumps to the commit screen; otherwise, when the
512    /// selected row changes, reload the file list and overview diff, and when
513    /// the file selection changes, narrow the diff to that file.
514    fn sync_browse(&mut self, force: bool) -> bool {
515        let mut changed = false;
516
517        // 1. Re-filter the commit list when the query changes.
518        let query = self.search.borrow().text().trim().to_lowercase();
519        if force || query != self.last_query {
520            self.last_query = query.clone();
521            self.rebuild_commits(&query);
522            self.shown = None;
523            changed = true;
524        }
525
526        // 1b. Double-clicking a working-tree row opens the staging view.
527        let activated = self.commit_list.borrow_mut().take_activated();
528        if let Some(pos) = activated
529            && matches!(self.rows.get(pos), Some(RowRef::Wip(_)))
530        {
531            self.set_mode(Mode::Commit);
532            return true;
533        }
534
535        // 2. Map the selection to a row reference; on change, reload the file
536        //    list and the overview diff.
537        let sel_pos = self.commit_list.borrow().selected_index();
538        let sel = sel_pos.and_then(|p| self.rows.get(p).copied());
539        if force || sel != self.shown {
540            self.shown = sel;
541            self.current_files = match sel {
542                Some(RowRef::Commit(idx)) => self.backend.changed_files(idx),
543                Some(RowRef::Wip(Side::Unstaged)) => self.log_working.unstaged.clone(),
544                Some(RowRef::Wip(Side::Staged)) => self.log_working.staged.clone(),
545                None => Vec::new(),
546            };
547            let items: Vec<ListItem> = self.current_files.iter().map(file_row).collect();
548            self.file_list.borrow_mut().set_items(items);
549            self.shown_file = None;
550            self.show_browse_diff(sel, None);
551            changed = true;
552        }
553
554        // 3. Narrow the diff to a single file when one is selected.
555        let file_sel = self.file_list.borrow().selected_index();
556        if file_sel != self.shown_file {
557            self.shown_file = file_sel;
558            self.show_browse_diff(self.shown, file_sel);
559            changed = true;
560        }
561
562        changed
563    }
564
565    /// Update the browse diff pane for a log selection: a graphical image
566    /// comparison when a single image file is selected, otherwise the text diff
567    /// (whole-commit overview when no single file is picked).
568    fn show_browse_diff(&self, sel: Option<RowRef>, file_sel: Option<usize>) {
569        if let Some(file) = file_sel.and_then(|f| self.current_files.get(f))
570            && is_image_path(&file.path)
571            && let Some(cmp) = self.browse_image(sel, &file.path)
572        {
573            self.diff_pane.borrow_mut().show_image(cmp);
574            return;
575        }
576        let diff = self.selection_diff(sel, file_sel);
577        self.diff_pane.borrow_mut().set_diff(diff);
578    }
579
580    /// Build the image comparison for `path` under the current log selection,
581    /// pulling the two blobs from the commit or the working tree. `None` when
582    /// neither side decodes (the caller then shows the text diff).
583    fn browse_image(&self, sel: Option<RowRef>, path: &str) -> Option<ImageComparison> {
584        let blobs: BlobPair = match sel? {
585            RowRef::Commit(cidx) => self.backend.commit_file_blobs(cidx, path),
586            RowRef::Wip(side) => {
587                self.backend
588                    .working_file_blobs(path, matches!(side, Side::Staged), false)
589            }
590        };
591        ImageComparison::from_blobs(&blobs)
592    }
593
594    /// The diff to show for a log selection: a whole-commit / whole-working-set
595    /// overview when `file_sel` is `None`, otherwise that single file's diff.
596    fn selection_diff(&self, sel: Option<RowRef>, file_sel: Option<usize>) -> Diff {
597        match sel {
598            Some(RowRef::Commit(cidx)) => match file_sel.and_then(|f| self.current_files.get(f)) {
599                Some(file) => self.backend.file_diff(cidx, &file.path),
600                None => self.commit_detail(cidx),
601            },
602            Some(RowRef::Wip(side)) => {
603                let staged = matches!(side, Side::Staged);
604                match file_sel.and_then(|f| self.current_files.get(f)) {
605                    Some(file) => self.backend.working_diff(&file.path, staged, false),
606                    None => self.wip_overview_diff(staged),
607                }
608            }
609            None => Diff::default(),
610        }
611    }
612
613    /// Concatenate the per-file working diffs of the currently-shown files into
614    /// one overview, the working-tree analogue of `commit_detail`.
615    fn wip_overview_diff(&self, staged: bool) -> Diff {
616        let mut lines = Vec::new();
617        for file in &self.current_files {
618            lines.extend(self.backend.working_diff(&file.path, staged, false).lines);
619        }
620        Diff { lines }
621    }
622
623    /// Recompute the visible rows for `query` (empty = all). On the unfiltered
624    /// view, the working tree's "Uncommitted changes" / "Staged changes"
625    /// pseudo-rows lead the list and the DAG graph includes them, chained into
626    /// `HEAD`. The selection is preserved when it survives, else falls to the
627    /// first real commit (so the log opens on `HEAD`, not a pseudo-row).
628    fn rebuild_commits(&mut self, query: &str) {
629        // Working-tree pseudo-rows only on the unfiltered view (which also
630        // carries the graph); a filter is about commit content.
631        self.log_working = if query.is_empty() {
632            self.backend.working_status(false)
633        } else {
634            WorkingStatus::default()
635        };
636        let show_unstaged = !self.log_working.unstaged.is_empty();
637        let show_staged = !self.log_working.staged.is_empty();
638
639        let commits = self.backend.commits();
640        let commit_rows: Vec<usize> = (0..commits.len())
641            .filter(|&i| query.is_empty() || commit_matches(&commits[i], query))
642            .collect();
643
644        let mut row_refs: Vec<RowRef> = Vec::new();
645        let mut display: Vec<CommitRow> = Vec::new();
646        if show_unstaged {
647            row_refs.push(RowRef::Wip(Side::Unstaged));
648            display.push(wip_row(Side::Unstaged, self.log_working.unstaged.len()));
649        }
650        if show_staged {
651            row_refs.push(RowRef::Wip(Side::Staged));
652            display.push(wip_row(Side::Staged, self.log_working.staged.len()));
653        }
654        for &i in &commit_rows {
655            row_refs.push(RowRef::Commit(i));
656            display.push(commit_row(&commits[i]));
657        }
658
659        // The DAG graph needs the full parent chain, so it's shown only on the
660        // unfiltered view; the pseudo-rows are chained into HEAD so the gutter
661        // lines up with them.
662        let graph = if query.is_empty() {
663            let head_id = head_commit_id(commits);
664            let mut dag: Vec<(String, Vec<String>)> = Vec::new();
665            if show_unstaged {
666                let parent = if show_staged {
667                    vec![WIP_STAGED_ID.to_string()]
668                } else {
669                    head_id.clone().into_iter().collect()
670                };
671                dag.push((WIP_UNSTAGED_ID.to_string(), parent));
672            }
673            if show_staged {
674                dag.push((WIP_STAGED_ID.to_string(), head_id.into_iter().collect()));
675            }
676            for &i in &commit_rows {
677                dag.push((commits[i].id.clone(), commits[i].parents.clone()));
678            }
679            Some(compute_graph(&dag))
680        } else {
681            None
682        };
683
684        self.rows = row_refs;
685        let new_pos = self
686            .shown
687            .and_then(|s| self.rows.iter().position(|&r| r == s))
688            .or_else(|| {
689                self.rows
690                    .iter()
691                    .position(|r| matches!(r, RowRef::Commit(_)))
692            })
693            .or(if self.rows.is_empty() { None } else { Some(0) });
694
695        let mut list = self.commit_list.borrow_mut();
696        list.set_rows(display);
697        list.set_graph(graph);
698        list.set_selected(new_pos);
699    }
700
701    /// Build a `git show`-style view of a commit: a metadata header (SHA,
702    /// refs, author, date, parents), the message, then the full diff.
703    fn commit_detail(&self, idx: usize) -> Diff {
704        let Some(commit) = self.backend.commits().get(idx) else {
705            return Diff::default();
706        };
707
708        let mut lines = Vec::new();
709        let header = |lines: &mut Vec<DiffLine>, text: String| {
710            lines.push(DiffLine::new(DiffLineKind::CommitHeader, text));
711        };
712        let blank = |lines: &mut Vec<DiffLine>| {
713            lines.push(DiffLine::new(DiffLineKind::Context, String::new()));
714        };
715
716        header(&mut lines, format!("commit {}", commit.id));
717        if !commit.refs.is_empty() {
718            let names: Vec<&str> = commit.refs.iter().map(|r| r.name.as_str()).collect();
719            header(&mut lines, format!("Refs:   {}", names.join(", ")));
720        }
721        header(
722            &mut lines,
723            format!("Author: {} <{}>", commit.author_name, commit.author_email),
724        );
725        header(&mut lines, format!("Date:   {}", commit.date_string()));
726        if commit.is_merge() {
727            let shorts: Vec<String> = commit.parents.iter().map(|p| short(p)).collect();
728            header(&mut lines, format!("Merge:  {}", shorts.join(" ")));
729        }
730
731        blank(&mut lines);
732        for line in commit.message.trim_end().lines() {
733            lines.push(DiffLine::new(DiffLineKind::Context, format!("    {line}")));
734        }
735        blank(&mut lines);
736
737        lines.extend(self.backend.commit_diff(idx).lines);
738        Diff { lines }
739    }
740
741    // ---- review screen ------------------------------------------------------
742
743    /// Re-read the branches from the backend and rebuild the review list's
744    /// rows. The selection defaults to the first row — the checked-out branch.
745    fn rebuild_branches(&mut self) {
746        self.branches = self.backend.branches();
747        let rows: Vec<CommitRow> = self.branches.iter().map(branch_row).collect();
748        let selected = if self.branches.is_empty() {
749            None
750        } else {
751            Some(0)
752        };
753        let mut list = self.branch_list.borrow_mut();
754        list.set_rows(rows);
755        list.set_selected(selected);
756    }
757
758    /// Reload review panes from the current selection state: when the selected
759    /// branch changes, reload its aggregated file list and overview diff, and
760    /// when the file selection changes, narrow the diff to that file.
761    fn sync_review(&mut self, force: bool) -> bool {
762        let mut changed = false;
763
764        // 1. On a branch change, reload the aggregated files and the overview.
765        let sel = self.branch_list.borrow().selected_index();
766        if force || sel != self.shown_branch {
767            self.shown_branch = sel;
768            self.review_files = sel
769                .and_then(|i| self.branches.get(i))
770                .map(|b| self.backend.branch_files(b))
771                .unwrap_or_default();
772            let items: Vec<ListItem> = self.review_files.iter().map(file_row).collect();
773            self.review_file_list.borrow_mut().set_items(items);
774            self.shown_review_file = None;
775            self.show_review_diff(sel, None);
776            changed = true;
777        }
778
779        // 2. Narrow the diff to a single file when one is selected.
780        let file_sel = self.review_file_list.borrow().selected_index();
781        if file_sel != self.shown_review_file {
782            self.shown_review_file = file_sel;
783            self.show_review_diff(self.shown_branch, file_sel);
784            changed = true;
785        }
786
787        changed
788    }
789
790    /// Update the review diff pane for a branch selection: a graphical image
791    /// comparison when a single image file is selected, otherwise the text diff
792    /// (the whole-branch overview when no single file is picked).
793    fn show_review_diff(&self, sel: Option<usize>, file_sel: Option<usize>) {
794        let branch = sel.and_then(|i| self.branches.get(i));
795        let file = file_sel.and_then(|f| self.review_files.get(f));
796        if let (Some(branch), Some(file)) = (branch, file)
797            && is_image_path(&file.path)
798            && let Some(cmp) =
799                ImageComparison::from_blobs(&self.backend.branch_file_blobs(branch, &file.path))
800        {
801            self.review_diff_pane.borrow_mut().show_image(cmp);
802            return;
803        }
804        let diff = match (branch, file) {
805            (Some(branch), Some(file)) => self.backend.branch_file_diff(branch, &file.path),
806            (Some(branch), None) => self.branch_detail(branch),
807            _ => Diff::default(),
808        };
809        self.review_diff_pane.borrow_mut().set_diff(diff);
810    }
811
812    /// Build the overview shown when a branch (and no single file) is
813    /// selected: a metadata header naming the branch, its tip and the review
814    /// base, then the aggregated diff of everything the branch contains — the
815    /// branch analogue of [`Self::commit_detail`].
816    fn branch_detail(&self, branch: &BranchInfo) -> Diff {
817        let mut lines = Vec::new();
818        let header = |lines: &mut Vec<DiffLine>, text: String| {
819            lines.push(DiffLine::new(DiffLineKind::CommitHeader, text));
820        };
821        match &branch.upstream {
822            Some(upstream) => header(&mut lines, format!("branch {} = {upstream}", branch.name)),
823            None => header(&mut lines, format!("branch {}", branch.name)),
824        }
825        header(
826            &mut lines,
827            format!("Tip:    {} {}", short(&branch.tip_id), branch.summary),
828        );
829        match &branch.base_id {
830            Some(id) => header(
831                &mut lines,
832                format!("Base:   {} @ {}", branch.base_name, short(id)),
833            ),
834            None => header(
835                &mut lines,
836                format!("Base:   none ({} shares no history)", branch.base_name),
837            ),
838        }
839        lines.push(DiffLine::new(DiffLineKind::Context, String::new()));
840
841        let diff = self.backend.branch_diff(branch);
842        if diff.is_empty() {
843            lines.push(DiffLine::new(
844                DiffLineKind::Meta,
845                format!("No changes relative to {}.", branch.base_name),
846            ));
847        } else {
848            lines.extend(diff.lines);
849        }
850        Diff { lines }
851    }
852
853    // ---- commit screen ----------------------------------------------------
854
855    /// Re-read the working tree and rebuild the staged / unstaged lists.
856    fn rescan(&mut self) {
857        self.rescan_selecting(None);
858    }
859
860    /// Re-read the working tree and rebuild the staged / unstaged lists. When
861    /// `prefer` names a `(side, path)` that survives the rescan, that file stays
862    /// selected (so partial staging keeps the same file focused in the diff);
863    /// otherwise the selection defaults to the first file.
864    fn rescan_selecting(&mut self, prefer: Option<(Side, String)>) {
865        let amend = self.amend_check.borrow().is_checked();
866        self.working = self.backend.working_status(amend);
867
868        let unstaged: Vec<ListItem> = self.working.unstaged.iter().map(file_row).collect();
869        let staged: Vec<ListItem> = self.working.staged.iter().map(file_row).collect();
870        self.unstaged_list.borrow_mut().set_items(unstaged);
871        self.staged_list.borrow_mut().set_items(staged);
872        self.unstaged_heading.borrow_mut().set_text(format!(
873            "Unstaged Changes ({})",
874            self.working.unstaged.len()
875        ));
876        self.staged_heading
877            .borrow_mut()
878            .set_text(format!("Staged Changes ({})", self.working.staged.len()));
879
880        self.prev_unstaged_sel = None;
881        self.prev_staged_sel = None;
882        {
883            let mut view = self.commit_diff_pane.borrow_mut();
884            view.set_mode(DiffMode::Plain);
885            view.set_diff(Diff::default());
886        }
887
888        // Keep the preferred file selected when it's still present; otherwise
889        // default to the first file so the diff pane isn't blank.
890        let target = prefer.and_then(|(side, path)| {
891            let files = match side {
892                Side::Unstaged => &self.working.unstaged,
893                Side::Staged => &self.working.staged,
894            };
895            files.iter().position(|f| f.path == path).map(|i| (side, i))
896        });
897        match target {
898            Some((side, i)) => self.apply_commit_selection(side, i),
899            None if !self.working.unstaged.is_empty() => {
900                self.apply_commit_selection(Side::Unstaged, 0)
901            }
902            None if !self.working.staged.is_empty() => self.apply_commit_selection(Side::Staged, 0),
903            None => {}
904        }
905    }
906
907    /// Select file `i` in the `side` list, clear the other list's selection,
908    /// and show that file's diff.
909    fn apply_commit_selection(&mut self, side: Side, i: usize) {
910        match side {
911            Side::Unstaged => {
912                self.unstaged_list.borrow_mut().set_selected(Some(i));
913                self.staged_list.borrow_mut().set_selected(None);
914            }
915            Side::Staged => {
916                self.staged_list.borrow_mut().set_selected(Some(i));
917                self.unstaged_list.borrow_mut().set_selected(None);
918            }
919        }
920        self.prev_unstaged_sel = self.unstaged_list.borrow().selected_index();
921        self.prev_staged_sel = self.staged_list.borrow().selected_index();
922
923        let staged = matches!(side, Side::Staged);
924        let amend = self.amend_check.borrow().is_checked();
925        let files = match side {
926            Side::Unstaged => &self.working.unstaged,
927            Side::Staged => &self.working.staged,
928        };
929        // Unstaged files offer per-line staging; staged files, per-line
930        // unstaging. (Only the commit screen's diff view is ever non-Plain.)
931        let mode = match side {
932            Side::Unstaged => DiffMode::Stage,
933            Side::Staged => DiffMode::Unstage,
934        };
935        let mut view = self.commit_diff_pane.borrow_mut();
936        view.set_mode(mode);
937        // An image file shows the graphical comparison; line-range staging
938        // doesn't apply to it (the image view has no selectable lines).
939        if let Some(file) = files.get(i)
940            && is_image_path(&file.path)
941            && let Some(cmp) = ImageComparison::from_blobs(
942                &self.backend.working_file_blobs(&file.path, staged, amend),
943            )
944        {
945            view.show_image(cmp);
946            return;
947        }
948        let diff = files
949            .get(i)
950            .map(|f| self.backend.working_diff(&f.path, staged, amend))
951            .unwrap_or_default();
952        view.set_diff(diff);
953    }
954
955    /// Poll the commit screen after an event: handle stage/unstage activations
956    /// (double-click or Enter on a list), selection-driven diff updates, and
957    /// the amend toggle.
958    fn sync_commit(&mut self) -> bool {
959        // The Stage/Unstage button floating over a highlighted diff range.
960        let action = self.commit_diff_pane.borrow_mut().take_action();
961        if let Some((lo, hi)) = action {
962            return self.apply_partial(lo, hi);
963        }
964
965        let unstaged_activated = self.unstaged_list.borrow_mut().take_activated();
966        if let Some(i) = unstaged_activated {
967            self.stage_index(i);
968            return true;
969        }
970        let staged_activated = self.staged_list.borrow_mut().take_activated();
971        if let Some(i) = staged_activated {
972            self.unstage_index(i);
973            return true;
974        }
975
976        let u = self.unstaged_list.borrow().selected_index();
977        let s = self.staged_list.borrow().selected_index();
978        if let Some(i) = u
979            && self.prev_unstaged_sel != Some(i)
980        {
981            self.apply_commit_selection(Side::Unstaged, i);
982            return true;
983        }
984        if let Some(i) = s
985            && self.prev_staged_sel != Some(i)
986        {
987            self.apply_commit_selection(Side::Staged, i);
988            return true;
989        }
990        // A selection may have been cleared elsewhere — keep trackers honest.
991        self.prev_unstaged_sel = u;
992        self.prev_staged_sel = s;
993
994        let amend = self.amend_check.borrow().is_checked();
995        if amend != self.last_amend {
996            self.last_amend = amend;
997            if amend
998                && self.message_editor.borrow().text().trim().is_empty()
999                && let Some(msg) = self.backend.head_message()
1000            {
1001                self.message_editor.borrow_mut().set_text(msg.trim_end());
1002            }
1003            // Re-base the staging view on HEAD's parent (or back on HEAD), so
1004            // the already-committed changes appear in / leave the staged list.
1005            self.rescan();
1006            return true;
1007        }
1008
1009        false
1010    }
1011
1012    fn stage_selected(&mut self) -> bool {
1013        let sel = self.unstaged_list.borrow().selected_index();
1014        match sel {
1015            Some(i) => {
1016                self.stage_index(i);
1017                true
1018            }
1019            None => false,
1020        }
1021    }
1022
1023    /// Stage every unstaged file (git gui's "Stage Changed Files To Commit").
1024    fn stage_all(&mut self) -> bool {
1025        if self.working.unstaged.is_empty() {
1026            return false;
1027        }
1028        let paths: Vec<String> = self
1029            .working
1030            .unstaged
1031            .iter()
1032            .map(|f| f.path.clone())
1033            .collect();
1034        for path in paths {
1035            if let Err(e) = self.backend.stage(&path) {
1036                self.dialog.borrow_mut().show_error("Stage failed", &e);
1037                break;
1038            }
1039        }
1040        self.rescan();
1041        true
1042    }
1043
1044    /// Append a `Signed-off-by` trailer for the configured identity to the
1045    /// message editor (git gui's "Sign Off").
1046    fn sign_off(&mut self) -> bool {
1047        let Some((name, email)) = self.backend.signature() else {
1048            self.dialog.borrow_mut().show_error(
1049                "Sign off",
1050                "No git identity configured. Set user.name and user.email.",
1051            );
1052            return true;
1053        };
1054        let body = self.message_editor.borrow().text();
1055        match with_signoff(&body, &name, &email) {
1056            Some(text) => {
1057                self.message_editor.borrow_mut().set_text(&text);
1058                true
1059            }
1060            // Already signed off — nothing to change.
1061            None => false,
1062        }
1063    }
1064
1065    fn unstage_selected(&mut self) -> bool {
1066        let sel = self.staged_list.borrow().selected_index();
1067        match sel {
1068            Some(i) => {
1069                self.unstage_index(i);
1070                true
1071            }
1072            None => false,
1073        }
1074    }
1075
1076    fn stage_index(&mut self, i: usize) {
1077        if let Some(file) = self.working.unstaged.get(i) {
1078            let path = file.path.clone();
1079            if let Err(e) = self.backend.stage(&path) {
1080                self.dialog.borrow_mut().show_error("Stage failed", &e);
1081            }
1082        }
1083        self.rescan();
1084    }
1085
1086    fn unstage_index(&mut self, i: usize) {
1087        if let Some(file) = self.working.staged.get(i) {
1088            let path = file.path.clone();
1089            let amend = self.amend_check.borrow().is_checked();
1090            if let Err(e) = self.backend.unstage(&path, amend) {
1091                self.dialog.borrow_mut().show_error("Unstage failed", &e);
1092            }
1093        }
1094        self.rescan();
1095    }
1096
1097    /// The file whose diff the commit screen is currently showing, as
1098    /// `(side, index)` — whichever list holds the selection.
1099    fn current_commit_target(&self) -> Option<(Side, usize)> {
1100        if let Some(i) = self.unstaged_list.borrow().selected_index() {
1101            Some((Side::Unstaged, i))
1102        } else {
1103            self.staged_list
1104                .borrow()
1105                .selected_index()
1106                .map(|i| (Side::Staged, i))
1107        }
1108    }
1109
1110    /// Stage (or unstage) just the highlighted rows `lo..=hi` of the current
1111    /// file's diff: rebuild a patch covering only those lines and apply it to
1112    /// the index. An unstaged file stages the selection; a staged one unstages
1113    /// it. A no-op when the range covers no actual change.
1114    fn apply_partial(&mut self, lo: usize, hi: usize) -> bool {
1115        let Some((side, i)) = self.current_commit_target() else {
1116            return false;
1117        };
1118        let staged = matches!(side, Side::Staged);
1119        let files = match side {
1120            Side::Unstaged => &self.working.unstaged,
1121            Side::Staged => &self.working.staged,
1122        };
1123        let Some(path) = files.get(i).map(|f| f.path.clone()) else {
1124            return false;
1125        };
1126
1127        let amend = self.amend_check.borrow().is_checked();
1128        let diff = self.backend.working_diff(&path, staged, amend);
1129        let mode = if staged {
1130            PartialMode::Unstage
1131        } else {
1132            PartialMode::Stage
1133        };
1134        let selected: BTreeSet<usize> = (lo..=hi).collect();
1135        let Some(patch) = build_partial_patch(&diff, &selected, mode) else {
1136            return false;
1137        };
1138
1139        if let Err(e) = self.backend.apply_to_index(&patch) {
1140            let title = if staged {
1141                "Unstage failed"
1142            } else {
1143                "Stage failed"
1144            };
1145            self.dialog.borrow_mut().show_error(title, &e);
1146        }
1147        // Keep the same file focused on the same side so the user can stage the
1148        // next chunk without the selection jumping back to the first file.
1149        self.rescan_selecting(Some((side, path)));
1150        true
1151    }
1152
1153    /// `git gui`'s "Revert Changes" (Ctrl+J): discard the working-tree changes
1154    /// to the selected *unstaged* file. Because the change can't be undone, this
1155    /// only arms the operation — it stashes what to do and pops a confirm dialog
1156    /// whose affirmative button drives [`AppCommand::PerformDiscard`]. A tracked
1157    /// file is reverted to its index copy; an untracked file (no committed or
1158    /// staged version to fall back to) is instead offered up for deletion.
1159    fn revert_selected(&mut self) -> bool {
1160        let Some(i) = self.unstaged_list.borrow().selected_index() else {
1161            return false;
1162        };
1163        let Some(file) = self.working.unstaged.get(i) else {
1164            return false;
1165        };
1166        let display = file.display();
1167        let path = file.path.clone();
1168        let (title, message, affirm) = if file.status == ChangeStatus::Untracked {
1169            self.pending_discard = Some(PendingDiscard::Delete(path));
1170            (
1171                "Delete File",
1172                format!(
1173                    "Delete untracked file\n{display}?\n\nIt is not tracked by git and cannot be recovered."
1174                ),
1175                "Delete File",
1176            )
1177        } else {
1178            self.pending_discard = Some(PendingDiscard::Revert(path));
1179            (
1180                "Revert Changes",
1181                format!(
1182                    "Revert unstaged changes in\n{display}?\n\nThese changes will be permanently lost."
1183                ),
1184                "Revert Changes",
1185            )
1186        };
1187
1188        let commands = self.commands.clone();
1189        self.dialog
1190            .borrow_mut()
1191            .show_confirm(title, message, affirm, move |cx| {
1192                commands.borrow_mut().push(AppCommand::PerformDiscard);
1193                cx.request_paint();
1194            });
1195        true
1196    }
1197
1198    /// Carry out the revert / delete the user confirmed in
1199    /// [`Self::revert_selected`].
1200    fn perform_discard(&mut self) -> bool {
1201        let (failure, result) = match self.pending_discard.take() {
1202            Some(PendingDiscard::Revert(path)) => ("Revert failed", self.backend.revert(&path)),
1203            Some(PendingDiscard::Delete(path)) => {
1204                ("Delete failed", self.backend.delete_untracked(&path))
1205            }
1206            None => return false,
1207        };
1208        if let Err(e) = result {
1209            self.dialog.borrow_mut().show_error(failure, &e);
1210        }
1211        self.rescan();
1212        true
1213    }
1214
1215    fn do_commit(&mut self) -> bool {
1216        let amend = self.amend_check.borrow().is_checked();
1217        let message = self.message_editor.borrow().text();
1218
1219        if self.working.staged.is_empty() && !amend {
1220            self.dialog.borrow_mut().show_error(
1221                "Nothing to commit",
1222                "Stage some changes first, or enable \u{201C}Amend last commit\u{201D}.",
1223            );
1224            return true;
1225        }
1226
1227        match self.backend.commit(&message, amend) {
1228            Ok(()) => {
1229                self.message_editor.borrow_mut().set_text("");
1230                self.amend_check.borrow_mut().set_checked(false);
1231                self.last_amend = false;
1232                // Refresh history + working tree. Re-open when we can (so the
1233                // new commit shows in the log); otherwise refresh in place.
1234                if !self.reload() {
1235                    self.shown = None;
1236                    self.sync_browse(true);
1237                    self.rescan();
1238                }
1239                // Return to the log view, now showing the new commit.
1240                self.set_mode(Mode::Browse);
1241            }
1242            Err(e) => {
1243                self.dialog.borrow_mut().show_error("Commit failed", &e);
1244            }
1245        }
1246        true
1247    }
1248
1249    // ---- graphical image diff (both screens) ------------------------------
1250
1251    /// The diff pane of the active screen.
1252    fn active_diff_pane(&self) -> Rc<RefCell<DiffPane>> {
1253        match self.mode {
1254            Mode::Browse => self.diff_pane.clone(),
1255            Mode::Commit => self.commit_diff_pane.clone(),
1256            Mode::Review => self.review_diff_pane.clone(),
1257        }
1258    }
1259
1260    /// Whether the active diff pane is currently showing a graphical image diff
1261    /// (vs. a text diff) — the enable state for Switch Mode / Before / After.
1262    fn active_showing_image(&self) -> bool {
1263        self.active_diff_pane().borrow().showing_image()
1264    }
1265
1266    /// The commit-screen list a navigation acts on: whichever holds the
1267    /// selection, else the first non-empty list (unstaged preferred).
1268    fn active_commit_side(&self) -> Side {
1269        if self.unstaged_list.borrow().selected_index().is_some() {
1270            Side::Unstaged
1271        } else if self.staged_list.borrow().selected_index().is_some() {
1272            Side::Staged
1273        } else if !self.working.unstaged.is_empty() {
1274            Side::Unstaged
1275        } else {
1276            Side::Staged
1277        }
1278    }
1279
1280    /// Whether the active file list holds an image file other than the current
1281    /// selection — the enable state for Next / Previous Image.
1282    fn has_other_image(&self) -> bool {
1283        match self.mode {
1284            Mode::Browse => {
1285                let sel = self.file_list.borrow().selected_index();
1286                other_image_exists(&self.current_files, sel)
1287            }
1288            Mode::Review => {
1289                let sel = self.review_file_list.borrow().selected_index();
1290                other_image_exists(&self.review_files, sel)
1291            }
1292            Mode::Commit => match self.active_commit_side() {
1293                Side::Unstaged => other_image_exists(
1294                    &self.working.unstaged,
1295                    self.unstaged_list.borrow().selected_index(),
1296                ),
1297                Side::Staged => other_image_exists(
1298                    &self.working.staged,
1299                    self.staged_list.borrow().selected_index(),
1300                ),
1301            },
1302        }
1303    }
1304
1305    /// Move the active list's selection to the next / previous image file. The
1306    /// normal selection-sync then shows it. Returns `true` when it moved.
1307    fn navigate_image(&mut self, forward: bool) -> bool {
1308        match self.mode {
1309            Mode::Browse => {
1310                let sel = self.file_list.borrow().selected_index();
1311                let Some(target) = next_image_index(&self.current_files, sel, forward) else {
1312                    return false;
1313                };
1314                self.file_list.borrow_mut().set_selected(Some(target));
1315                true
1316            }
1317            Mode::Review => {
1318                let sel = self.review_file_list.borrow().selected_index();
1319                let Some(target) = next_image_index(&self.review_files, sel, forward) else {
1320                    return false;
1321                };
1322                self.review_file_list
1323                    .borrow_mut()
1324                    .set_selected(Some(target));
1325                true
1326            }
1327            Mode::Commit => {
1328                let side = self.active_commit_side();
1329                let (target, list) = match side {
1330                    Side::Unstaged => (
1331                        next_image_index(
1332                            &self.working.unstaged,
1333                            self.unstaged_list.borrow().selected_index(),
1334                            forward,
1335                        ),
1336                        &self.unstaged_list,
1337                    ),
1338                    Side::Staged => (
1339                        next_image_index(
1340                            &self.working.staged,
1341                            self.staged_list.borrow().selected_index(),
1342                            forward,
1343                        ),
1344                        &self.staged_list,
1345                    ),
1346                };
1347                let Some(target) = target else { return false };
1348                list.borrow_mut().set_selected(Some(target));
1349                true
1350            }
1351        }
1352    }
1353
1354    /// Cycle the shown image comparison mode (no-op unless an image is shown).
1355    fn cycle_image_mode(&mut self) -> bool {
1356        let pane = self.active_diff_pane();
1357        let mut pane = pane.borrow_mut();
1358        if !pane.showing_image() {
1359            return false;
1360        }
1361        pane.cycle_image_mode();
1362        true
1363    }
1364
1365    /// Show just the before / after side of the shown image (no-op unless an
1366    /// image is shown).
1367    fn show_image_side(&mut self, before: bool) -> bool {
1368        let pane = self.active_diff_pane();
1369        let mut pane = pane.borrow_mut();
1370        if !pane.showing_image() {
1371            return false;
1372        }
1373        pane.show_image_side(before);
1374        true
1375    }
1376
1377    /// Refresh the View-menu state from the active screen: the image actions'
1378    /// enable state (so the menu greys what doesn't currently apply) and the
1379    /// active mode (so the Browse / Commit entries show the checkmark).
1380    fn update_menu_nav(&self) {
1381        let showing_image = self.active_showing_image();
1382        let can_nav_images = self.has_other_image();
1383        let mut nav = self.nav_state.borrow_mut();
1384        nav.mode = self.mode;
1385        nav.showing_image = showing_image;
1386        nav.can_nav_images = can_nav_images;
1387    }
1388}
1389
1390impl Widget for GitClient {
1391    fn bounds(&self) -> Rect {
1392        self.bounds
1393    }
1394
1395    fn paint(&mut self, painter: &mut Painter, theme: &Theme) {
1396        self.active_mut().paint(painter, theme);
1397    }
1398
1399    fn paint_overlay(&mut self, painter: &mut Painter, theme: &Theme) {
1400        self.active_mut().paint_overlay(painter, theme);
1401    }
1402
1403    fn event(&mut self, event: &Event, ctx: &mut EventCtx) {
1404        // Keyboard accelerators live on each screen's menu bar (`with_accel`):
1405        // the Shell's accelerator pass hands every key to the bar before the
1406        // focused pane, so e.g. Ctrl+Enter commits instead of inserting a
1407        // newline in the message editor. A chord whose item is disabled falls
1408        // through to the focused widget, and an open dialog overlay owns the
1409        // keyboard outright — both come with the routing, no gating here.
1410        self.active_mut().event(event, ctx);
1411        // After the tree processes the event, apply commands and sync the
1412        // active screen's dependent panes.
1413        let mut dirty = self.drain_commands();
1414        dirty |= match self.mode {
1415            Mode::Browse => self.sync_browse(false),
1416            Mode::Commit => self.sync_commit(),
1417            Mode::Review => self.sync_review(false),
1418        };
1419        // Keep the View-menu image actions' enable state current for the next
1420        // paint (e.g. when the menu is about to open).
1421        self.update_menu_nav();
1422        if dirty {
1423            ctx.request_paint();
1424        }
1425    }
1426
1427    fn captures_pointer(&self) -> bool {
1428        self.active().captures_pointer()
1429    }
1430
1431    fn focusable(&self) -> bool {
1432        self.active().focusable()
1433    }
1434
1435    fn set_focused(&mut self, focused: bool) {
1436        self.active_mut().set_focused(focused);
1437    }
1438
1439    fn layout(&mut self, bounds: Rect) {
1440        self.bounds = bounds;
1441        self.apply_narrow(bounds.w <= layout::NARROW_W);
1442        self.browse_root.layout(bounds);
1443        self.commit_root.layout(bounds);
1444        self.review_root.layout(bounds);
1445    }
1446
1447    fn focus_first(&mut self) -> bool {
1448        match self.mode {
1449            // Start on the commit list rather than the leading search field,
1450            // so arrow keys navigate history immediately (gitk behavior).
1451            Mode::Browse => self.browse_root.focus_child(BROWSE_HISTORY_IDX),
1452            Mode::Commit => self.commit_root.focus_child(COMMIT_UNSTAGED_IDX),
1453            Mode::Review => self.review_root.focus_child(REVIEW_BRANCHES_IDX),
1454        }
1455    }
1456
1457    fn popup_request(&self) -> Option<PopupRequest> {
1458        self.active().popup_request()
1459    }
1460
1461    fn wants_ticks(&self) -> bool {
1462        self.active().wants_ticks()
1463    }
1464}
1465
1466/// Build the browse-screen menu bar: File ▸ Reload / Exit, View ▸ the mode
1467/// switches + the image-diff actions, Help ▸ About. The review screen carries
1468/// no actions of its own, so it uses this same bar.
1469fn build_browse_menu(
1470    commands: Rc<RefCell<Vec<AppCommand>>>,
1471    dialog: Rc<RefCell<Dialog>>,
1472    nav: Rc<RefCell<MenuNav>>,
1473    scheme: ModifierScheme,
1474) -> MenuBar {
1475    let mut view = mode_items(&commands, &nav);
1476    view.push(MenuItem::separator());
1477    view.extend(image_view_items(&commands, &nav));
1478    MenuBar::new(Rect::new(0, 0, 0, 0))
1479        .with_scheme(scheme)
1480        .add_menu(Menu::new(
1481            "&File",
1482            vec![
1483                cmd_item("&Reload", &commands, AppCommand::Reload),
1484                MenuItem::separator(),
1485                MenuItem::action("E&xit", |cx| cx.close()).with_accel("Ctrl+Q"),
1486            ],
1487        ))
1488        .add_menu(Menu::new("&View", view))
1489        .add_menu(Menu::new("&Help", vec![about_item(&dialog)]))
1490}
1491
1492/// Build the commit-screen menu bar: File, Commit (the staging actions), View
1493/// ▸ the Browse / Commit mode switches + the image-diff actions, Help.
1494fn build_commit_menu(
1495    commands: Rc<RefCell<Vec<AppCommand>>>,
1496    dialog: Rc<RefCell<Dialog>>,
1497    nav: Rc<RefCell<MenuNav>>,
1498    scheme: ModifierScheme,
1499) -> MenuBar {
1500    let mut view = mode_items(&commands, &nav);
1501    view.push(MenuItem::separator());
1502    view.extend(image_view_items(&commands, &nav));
1503    MenuBar::new(Rect::new(0, 0, 0, 0))
1504        .with_scheme(scheme)
1505        .add_menu(Menu::new(
1506            "&File",
1507            vec![
1508                cmd_item("&Reload", &commands, AppCommand::Reload),
1509                MenuItem::separator(),
1510                MenuItem::action("E&xit", |cx| cx.close()).with_accel("Ctrl+Q"),
1511            ],
1512        ))
1513        .add_menu(Menu::new(
1514            "&Commit",
1515            vec![
1516                cmd_item("&Rescan", &commands, AppCommand::Rescan).with_accel("Ctrl+R"),
1517                MenuItem::separator(),
1518                cmd_item("&Stage Selected", &commands, AppCommand::StageSelected)
1519                    .with_accel("Ctrl+T"),
1520                cmd_item("Stage &All", &commands, AppCommand::StageAll).with_accel("Ctrl+I"),
1521                cmd_item("&Unstage Selected", &commands, AppCommand::UnstageSelected)
1522                    .with_accel("Ctrl+U"),
1523                cmd_item("Re&vert Changes", &commands, AppCommand::RevertSelected)
1524                    .with_accel("Ctrl+J"),
1525                MenuItem::separator(),
1526                cmd_item("Sign &Off", &commands, AppCommand::SignOff).with_accel("Ctrl+S"),
1527                cmd_item("&Commit", &commands, AppCommand::Commit).with_accel("Ctrl+Enter"),
1528            ],
1529        ))
1530        .add_menu(Menu::new("&View", view))
1531        .add_menu(Menu::new("&Help", vec![about_item(&dialog)]))
1532}
1533
1534/// The View-menu mode switches, shared by every screen: Browse History, Commit
1535/// Changes and Review Branches, each checkmarked when its screen is the active
1536/// one (read live from `nav`). Picking the entry that's already checked is a
1537/// harmless no-op, so all stay enabled. The accelerators are Ctrl+1 / Ctrl+2 /
1538/// Ctrl+3 — view switching à la GitHub Desktop — deliberately clear of every
1539/// editing chord (Ctrl+C must stay copy in the commit message editor), so no
1540/// fall-through gating is needed.
1541fn mode_items(
1542    commands: &Rc<RefCell<Vec<AppCommand>>>,
1543    nav: &Rc<RefCell<MenuNav>>,
1544) -> Vec<MenuItem> {
1545    let is_mode = |mode: Mode| {
1546        let nav = nav.clone();
1547        move || nav.borrow().mode == mode
1548    };
1549    vec![
1550        cmd_item("&Browse History", commands, AppCommand::EnterBrowseMode)
1551            .with_accel("Ctrl+1")
1552            .with_checked(is_mode(Mode::Browse)),
1553        cmd_item("&Commit Changes", commands, AppCommand::EnterCommitMode)
1554            .with_accel("Ctrl+2")
1555            .with_checked(is_mode(Mode::Commit)),
1556        cmd_item("&Review Branches", commands, AppCommand::EnterReviewMode)
1557            .with_accel("Ctrl+3")
1558            .with_checked(is_mode(Mode::Review)),
1559    ]
1560}
1561
1562/// The View-menu entries that drive the graphical image diff, shared by both
1563/// screens. Next / Previous Image walk the image files in the active list
1564/// (disabled when there's no other image to jump to); Switch Mode / Before /
1565/// After act on the shown comparison (disabled unless an image is on screen).
1566/// The enable predicates read live [`MenuNav`] state via the captured `nav`.
1567fn image_view_items(
1568    commands: &Rc<RefCell<Vec<AppCommand>>>,
1569    nav: &Rc<RefCell<MenuNav>>,
1570) -> Vec<MenuItem> {
1571    let can_nav = || {
1572        let nav = nav.clone();
1573        move || nav.borrow().can_nav_images
1574    };
1575    let showing = || {
1576        let nav = nav.clone();
1577        move || nav.borrow().showing_image
1578    };
1579    vec![
1580        cmd_item("&Next Image", commands, AppCommand::NextImage)
1581            .with_accel("Ctrl+N")
1582            .with_enabled(can_nav()),
1583        cmd_item("&Previous Image", commands, AppCommand::PrevImage)
1584            .with_accel("Ctrl+P")
1585            .with_enabled(can_nav()),
1586        MenuItem::separator(),
1587        cmd_item("Switch &Mode", commands, AppCommand::CycleImageMode)
1588            .with_accel("Ctrl+M")
1589            .with_enabled(showing()),
1590        cmd_item("Be&fore Image", commands, AppCommand::ShowImageBefore)
1591            .with_accel("Ctrl+Left")
1592            .with_enabled(showing()),
1593        cmd_item("&After Image", commands, AppCommand::ShowImageAfter)
1594            .with_accel("Ctrl+Right")
1595            .with_enabled(showing()),
1596    ]
1597}
1598
1599/// A menu item that pushes `command` onto the deferred-command queue.
1600fn cmd_item(label: &str, commands: &Rc<RefCell<Vec<AppCommand>>>, command: AppCommand) -> MenuItem {
1601    let commands = commands.clone();
1602    MenuItem::action(label, move |cx| {
1603        commands.borrow_mut().push(command);
1604        cx.request_paint();
1605    })
1606}
1607
1608/// The shared Help ▸ About item.
1609fn about_item(dialog: &Rc<RefCell<Dialog>>) -> MenuItem {
1610    let dialog = dialog.clone();
1611    MenuItem::action("&About", move |cx| {
1612        dialog.borrow_mut().show_info(
1613            "About Git Journey",
1614            "Git Journey\n\nA gitk-style repository browser\nbuilt on the Saudade toolkit.",
1615        );
1616        cx.request_paint();
1617    })
1618}
1619
1620/// A push button that pushes `command` onto the deferred-command queue.
1621/// Labels for the Stage / Unstage / Rescan buttons. The arrow-from-bar and
1622/// refresh symbols always lead; in narrow mode that's all there's room for, so
1623/// the words are dropped.
1624fn left_btn_labels(narrow: bool) -> [&'static str; 3] {
1625    if narrow {
1626        ["\u{21A7}", "\u{21A5}", "\u{21BB}"]
1627    } else {
1628        ["\u{21A7} Stage", "\u{21A5} Unstage", "\u{21BB} Rescan"]
1629    }
1630}
1631
1632fn command_button(
1633    label: &str,
1634    commands: &Rc<RefCell<Vec<AppCommand>>>,
1635    command: AppCommand,
1636) -> Button {
1637    let commands = commands.clone();
1638    Button::new(Rect::new(0, 0, 0, 0), label).on_click(move |cx| {
1639        commands.borrow_mut().push(command);
1640        cx.request_paint();
1641    })
1642}
1643
1644/// First 8 hex chars of a SHA, for compact parent display.
1645fn short(sha: &str) -> String {
1646    sha.chars().take(8).collect()
1647}
1648
1649/// The message text after appending a `Signed-off-by` trailer for `name` /
1650/// `email`, or `None` when that exact trailer is already the last line. A prose
1651/// body is separated from the trailer by a blank line; an existing trailer
1652/// block keeps the sign-off tight against it (no blank line), matching git gui.
1653fn with_signoff(body: &str, name: &str, email: &str) -> Option<String> {
1654    let trailer = format!("Signed-off-by: {name} <{email}>");
1655    let last_line = body.lines().next_back().unwrap_or("").trim_end();
1656    if last_line.eq_ignore_ascii_case(&trailer) {
1657        return None;
1658    }
1659    let trimmed = body.trim_end();
1660    Some(if trimmed.is_empty() {
1661        trailer
1662    } else if is_trailer_line(last_line) {
1663        format!("{trimmed}\n{trailer}")
1664    } else {
1665        format!("{trimmed}\n\n{trailer}")
1666    })
1667}
1668
1669/// Does `line` look like an RFC-822-style commit trailer (`Signed-off-by:`,
1670/// `Acked-by:`, …)? Used so a fresh sign-off stays tight against an existing
1671/// trailer block rather than getting an extra blank line before it.
1672fn is_trailer_line(line: &str) -> bool {
1673    let Some((key, _)) = line.split_once(':') else {
1674        return false;
1675    };
1676    let key = key.to_ascii_lowercase();
1677    key.ends_with("-by") && key.chars().all(|c| c.is_ascii_alphabetic() || c == '-')
1678}
1679
1680/// Index of the next (`forward`) or previous image file in `files` relative to
1681/// the selection `cur`. Navigation does not wrap: `None` once there is no further
1682/// image in the chosen direction. When `cur` isn't itself an image, the nearest
1683/// image in the chosen direction is picked.
1684fn next_image_index(files: &[FileChange], cur: Option<usize>, forward: bool) -> Option<usize> {
1685    let images: Vec<usize> = files
1686        .iter()
1687        .enumerate()
1688        .filter(|(_, f)| is_image_path(&f.path))
1689        .map(|(i, _)| i)
1690        .collect();
1691    let cur = cur.map(|c| c as i32);
1692    if forward {
1693        match cur {
1694            Some(c) => images.iter().copied().find(|&i| i as i32 > c),
1695            None => images.first().copied(),
1696        }
1697    } else {
1698        match cur {
1699            Some(c) => images.iter().rev().copied().find(|&i| (i as i32) < c),
1700            None => images.last().copied(),
1701        }
1702    }
1703}
1704
1705/// Whether `files` holds an image file other than the one already at `cur` — the
1706/// condition that enables the Next / Previous Image actions. Unlike
1707/// [`next_image_index`] this is direction-agnostic, so the actions stay enabled
1708/// at either end of the list (where one direction has nowhere to go).
1709fn other_image_exists(files: &[FileChange], cur: Option<usize>) -> bool {
1710    files
1711        .iter()
1712        .enumerate()
1713        .any(|(i, f)| is_image_path(&f.path) && Some(i) != cur)
1714}
1715
1716/// Build the display row for a working-tree pseudo-entry in the log.
1717fn wip_row(side: Side, count: usize) -> CommitRow {
1718    let summary = match side {
1719        Side::Unstaged => format!("Uncommitted changes ({count})"),
1720        Side::Staged => format!("Staged changes ({count})"),
1721    };
1722    CommitRow {
1723        summary,
1724        ..Default::default()
1725    }
1726}
1727
1728/// The id of the current `HEAD` commit (the one the working tree sits on), so
1729/// the working-tree pseudo-rows can chain into it in the graph. Falls back to
1730/// the newest commit, or `None` for an empty history.
1731fn head_commit_id(commits: &[CommitInfo]) -> Option<String> {
1732    commits
1733        .iter()
1734        .find(|c| {
1735            c.refs
1736                .iter()
1737                .any(|r| matches!(r.kind, RefKind::Head | RefKind::DetachedHead))
1738        })
1739        .or_else(|| commits.first())
1740        .map(|c| c.id.clone())
1741}
1742
1743/// Does a commit match a (already-lowercased) search query? Matches against
1744/// the summary, message, author name/email, ref names and the full SHA.
1745fn commit_matches(commit: &CommitInfo, query: &str) -> bool {
1746    commit.summary.to_lowercase().contains(query)
1747        || commit.message.to_lowercase().contains(query)
1748        || commit.author_name.to_lowercase().contains(query)
1749        || commit.author_email.to_lowercase().contains(query)
1750        || commit.id.contains(query)
1751        || commit
1752            .refs
1753            .iter()
1754            .any(|r| r.name.to_lowercase().contains(query))
1755}
1756
1757/// Build a review-list row from a branch: the branch name as a colored ref
1758/// badge (the same colors the log's labels use), then the tip commit's
1759/// summary, author and date in the usual columns. A remote-tracking upstream
1760/// folded into this row (same tip, see [`BranchInfo::upstream`]) appears as a
1761/// second badge.
1762fn branch_row(branch: &BranchInfo) -> CommitRow {
1763    let mut refs = vec![RefLabel {
1764        name: branch.name.clone(),
1765        kind: branch.kind,
1766    }];
1767    if let Some(upstream) = &branch.upstream {
1768        refs.push(RefLabel {
1769            name: upstream.clone(),
1770            kind: RefKind::RemoteBranch,
1771        });
1772    }
1773    CommitRow {
1774        id: branch.tip_id.clone(),
1775        parents: Vec::new(),
1776        summary: branch.summary.clone(),
1777        refs,
1778        author: branch.author.clone(),
1779        date: branch.short_date_string(),
1780    }
1781}
1782
1783/// Build a commit-list row from a commit: ref badges + summary on the left,
1784/// author and short date in the right-hand columns.
1785pub fn commit_row(commit: &CommitInfo) -> CommitRow {
1786    CommitRow {
1787        id: commit.id.clone(),
1788        parents: commit.parents.clone(),
1789        summary: commit.summary.clone(),
1790        refs: commit.refs.clone(),
1791        author: commit.author_name.clone(),
1792        date: commit.short_date_string(),
1793    }
1794}
1795
1796/// Format a changed file as a list row: a colored status marker in the icon
1797/// gutter (see [`status_icon`]) followed by the path. The marker replaces the
1798/// old single-letter text badge — the same A/M/D/… letters, but baked from SVG
1799/// so they stay crisp at any DPI and legible on the navy selection band.
1800pub fn file_row(file: &FileChange) -> ListItem {
1801    ListItem::new(file.display()).with_svg_icon(status_icon(file.status))
1802}
1803
1804/// The compile-time-baked status marker for a [`ChangeStatus`] — a small chip
1805/// carrying the letter [`ChangeStatus::badge`] would print. Each SVG lives in
1806/// `assets/status/`; `include_svg!` flattens it to polygons at build time, so no
1807/// SVG parser ships in the binary (see saudade's `include_svg!`).
1808fn status_icon(status: ChangeStatus) -> SvgImage {
1809    const ADDED: SvgImage = include_svg!("assets/status/added.svg");
1810    const MODIFIED: SvgImage = include_svg!("assets/status/modified.svg");
1811    const DELETED: SvgImage = include_svg!("assets/status/deleted.svg");
1812    const RENAMED: SvgImage = include_svg!("assets/status/renamed.svg");
1813    const COPIED: SvgImage = include_svg!("assets/status/copied.svg");
1814    const TYPECHANGE: SvgImage = include_svg!("assets/status/typechange.svg");
1815    const UNKNOWN: SvgImage = include_svg!("assets/status/unknown.svg");
1816
1817    match status {
1818        ChangeStatus::Added => ADDED,
1819        ChangeStatus::Modified => MODIFIED,
1820        ChangeStatus::Deleted => DELETED,
1821        ChangeStatus::Renamed => RENAMED,
1822        ChangeStatus::Copied => COPIED,
1823        ChangeStatus::TypeChange => TYPECHANGE,
1824        ChangeStatus::Untracked | ChangeStatus::Other => UNKNOWN,
1825    }
1826}
1827
1828#[cfg(test)]
1829mod tests {
1830    use super::{is_trailer_line, next_image_index, other_image_exists, with_signoff};
1831    use crate::backend::{ChangeStatus, FileChange};
1832
1833    fn files(paths: &[&str]) -> Vec<FileChange> {
1834        paths
1835            .iter()
1836            .map(|p| FileChange {
1837                path: (*p).to_string(),
1838                old_path: None,
1839                status: ChangeStatus::Modified,
1840            })
1841            .collect()
1842    }
1843
1844    #[test]
1845    fn next_image_navigates_only_images_without_wrapping() {
1846        // Images sit at indices 1, 3, 4; text files at 0 and 2.
1847        let f = files(&["a.txt", "logo.png", "notes.md", "icon.gif", "photo.jpeg"]);
1848        // Forward / backward step image-to-image.
1849        assert_eq!(next_image_index(&f, Some(1), true), Some(3));
1850        assert_eq!(next_image_index(&f, Some(3), false), Some(1));
1851        // …but stop at the ends rather than wrapping.
1852        assert_eq!(next_image_index(&f, Some(4), true), None);
1853        assert_eq!(next_image_index(&f, Some(1), false), None);
1854        // From a non-image row, jump to the nearest image in that direction.
1855        assert_eq!(next_image_index(&f, Some(2), true), Some(3));
1856        assert_eq!(next_image_index(&f, Some(2), false), Some(1));
1857        // No selection starts at the first / last image.
1858        assert_eq!(next_image_index(&f, None, true), Some(1));
1859        assert_eq!(next_image_index(&f, None, false), Some(4));
1860    }
1861
1862    #[test]
1863    fn other_image_exists_stays_true_at_the_ends() {
1864        // Three images: navigation can't wrap, but the Next / Previous actions
1865        // stay enabled at either end because the *other* direction still moves.
1866        let f = files(&["a.txt", "logo.png", "notes.md", "icon.gif", "photo.jpeg"]);
1867        assert!(other_image_exists(&f, Some(4)));
1868        assert!(other_image_exists(&f, Some(1)));
1869        // A lone image already selected leaves nowhere to go.
1870        let one = files(&["a.txt", "logo.png"]);
1871        assert!(!other_image_exists(&one, Some(1)));
1872        // …but it's reachable from a non-image row, and from no selection.
1873        assert!(other_image_exists(&one, Some(0)));
1874        assert!(other_image_exists(&one, None));
1875        // No images at all.
1876        assert!(!other_image_exists(&files(&["a.txt", "b.rs"]), None));
1877    }
1878
1879    #[test]
1880    fn next_image_is_none_when_no_other_image() {
1881        // No images at all.
1882        assert_eq!(
1883            next_image_index(&files(&["a.txt", "b.rs"]), Some(0), true),
1884            None
1885        );
1886        // The only image is already selected — nowhere to go either way.
1887        let one = files(&["a.txt", "logo.png"]);
1888        assert_eq!(next_image_index(&one, Some(1), true), None);
1889        assert_eq!(next_image_index(&one, Some(1), false), None);
1890        // …but from a non-image row that single image is reachable.
1891        assert_eq!(next_image_index(&one, Some(0), true), Some(1));
1892    }
1893
1894    const NAME: &str = "Ada Lovelace";
1895    const EMAIL: &str = "ada@example.com";
1896    const SOB: &str = "Signed-off-by: Ada Lovelace <ada@example.com>";
1897
1898    #[test]
1899    fn signoff_into_empty_message_is_just_the_trailer() {
1900        assert_eq!(with_signoff("", NAME, EMAIL).as_deref(), Some(SOB));
1901        assert_eq!(with_signoff("   \n", NAME, EMAIL).as_deref(), Some(SOB));
1902    }
1903
1904    #[test]
1905    fn signoff_after_prose_gets_a_blank_separator_line() {
1906        assert_eq!(
1907            with_signoff("Fix the thing", NAME, EMAIL).as_deref(),
1908            Some(format!("Fix the thing\n\n{SOB}").as_str())
1909        );
1910    }
1911
1912    #[test]
1913    fn signoff_after_a_trailer_block_stays_tight() {
1914        let body = "Fix the thing\n\nReviewed-by: B <b@example.com>";
1915        assert_eq!(
1916            with_signoff(body, NAME, EMAIL).as_deref(),
1917            Some(format!("{body}\n{SOB}").as_str())
1918        );
1919    }
1920
1921    #[test]
1922    fn signoff_is_idempotent_when_already_last_line() {
1923        let body = format!("Fix the thing\n\n{SOB}");
1924        assert_eq!(with_signoff(&body, NAME, EMAIL), None);
1925    }
1926
1927    #[test]
1928    fn trailer_lines_are_recognized() {
1929        assert!(is_trailer_line("Signed-off-by: A <a@x>"));
1930        assert!(is_trailer_line("Reviewed-by: B <b@x>"));
1931        assert!(is_trailer_line("Co-authored-by: C <c@x>"));
1932        assert!(!is_trailer_line("Just a normal sentence."));
1933        assert!(!is_trailer_line("Fixes: #123"));
1934        assert!(!is_trailer_line(""));
1935    }
1936}
1937
1938#[cfg(test)]
1939mod commit_focus_tests {
1940    use super::*;
1941    use crate::backend::{Git2Backend, is_change_line};
1942    use std::time::{SystemTime, UNIX_EPOCH};
1943
1944    /// A throwaway repo with two committed files, each then given two unstaged
1945    /// edits far enough apart to land in separate hunks. Returns the scratch dir
1946    /// (delete when done) and an opened backend.
1947    fn two_dirty_files() -> (std::path::PathBuf, Git2Backend) {
1948        let nanos = SystemTime::now()
1949            .duration_since(UNIX_EPOCH)
1950            .unwrap()
1951            .as_nanos();
1952        let dir =
1953            std::env::temp_dir().join(format!("journey-focus-{}-{nanos}", std::process::id()));
1954        std::fs::create_dir_all(&dir).unwrap();
1955        let repo = git2::Repository::init(&dir).unwrap();
1956        let sig =
1957            git2::Signature::new("T", "t@example.com", &git2::Time::new(1_700_000_000, 0)).unwrap();
1958
1959        let base: String = (1..=20).map(|n| format!("l{n:02}\n")).collect();
1960        for name in ["a.txt", "b.txt"] {
1961            std::fs::write(dir.join(name), &base).unwrap();
1962        }
1963        {
1964            let mut index = repo.index().unwrap();
1965            index.add_path(std::path::Path::new("a.txt")).unwrap();
1966            index.add_path(std::path::Path::new("b.txt")).unwrap();
1967            index.write().unwrap();
1968            let tree = repo.find_tree(index.write_tree().unwrap()).unwrap();
1969            repo.commit(Some("HEAD"), &sig, &sig, "base\n", &tree, &[])
1970                .unwrap();
1971        }
1972        let edited = base
1973            .replace("l02\n", "l02-edited\n")
1974            .replace("l18\n", "l18-edited\n");
1975        for name in ["a.txt", "b.txt"] {
1976            std::fs::write(dir.join(name), &edited).unwrap();
1977        }
1978
1979        let backend = Git2Backend::open(dir.to_str().unwrap()).unwrap();
1980        (dir, backend)
1981    }
1982
1983    /// Partially staging a file keeps that same file selected and shown in the
1984    /// diff, rather than snapping the selection back to the first file.
1985    #[test]
1986    fn partial_stage_keeps_the_same_file_focused() {
1987        let (dir, backend) = two_dirty_files();
1988        let mut client = GitClient::new(Rc::new(backend));
1989        client.enter_commit_mode();
1990
1991        // Select the *second* unstaged file, so a jump-to-first would be visible.
1992        let b = client
1993            .working
1994            .unstaged
1995            .iter()
1996            .position(|f| f.path == "b.txt")
1997            .expect("b.txt is unstaged");
1998        assert_ne!(b, 0, "b.txt must not already be the first row");
1999        client.apply_commit_selection(Side::Unstaged, b);
2000
2001        // Stage only b.txt's first change (its two `l02` rows), leaving the
2002        // line-18 change unstaged so the file stays in the unstaged list.
2003        let diff = client.backend.working_diff("b.txt", false, false);
2004        let rows: Vec<usize> = diff
2005            .lines
2006            .iter()
2007            .enumerate()
2008            .filter(|(_, l)| is_change_line(l.kind) && l.text.contains("l02"))
2009            .map(|(i, _)| i)
2010            .collect();
2011        let (lo, hi) = (rows[0], *rows.last().unwrap());
2012        assert!(client.apply_partial(lo, hi));
2013
2014        // The change moved to the index but b.txt is still dirty…
2015        assert!(client.working.staged.iter().any(|f| f.path == "b.txt"));
2016        let still = client
2017            .working
2018            .unstaged
2019            .iter()
2020            .position(|f| f.path == "b.txt")
2021            .expect("b.txt still has unstaged changes");
2022        // …and it is still the selected/shown file, not the first one.
2023        assert_eq!(
2024            client.unstaged_list.borrow().selected_index(),
2025            Some(still),
2026            "the partially-staged file stays focused"
2027        );
2028        assert_eq!(client.staged_list.borrow().selected_index(), None);
2029
2030        std::fs::remove_dir_all(&dir).ok();
2031    }
2032
2033    /// A tiny solid-color PNG, just enough that `ImageComparison` decodes it.
2034    fn tiny_png() -> Vec<u8> {
2035        let img = image::RgbaImage::from_pixel(4, 4, image::Rgba([1, 2, 3, 255]));
2036        let mut bytes = Vec::new();
2037        image::DynamicImage::ImageRgba8(img)
2038            .write_to(
2039                &mut std::io::Cursor::new(&mut bytes),
2040                image::ImageFormat::Png,
2041            )
2042            .unwrap();
2043        bytes
2044    }
2045
2046    /// Next Image (Ctrl+N) jumps the commit-screen selection from a text file to
2047    /// the next image and the diff pane switches to the graphical comparison;
2048    /// stepping again advances to the following image.
2049    fn nav_client() -> GitClient {
2050        let mut be = crate::backend::FixtureBackend::new("/tmp/journey-nav");
2051        be.add_working(
2052            "notes.md",
2053            ChangeStatus::Modified,
2054            false,
2055            &[(DiffLineKind::Addition, "+note")],
2056        );
2057        be.add_working_image(
2058            "a.png",
2059            ChangeStatus::Modified,
2060            false,
2061            Some(tiny_png()),
2062            Some(tiny_png()),
2063        );
2064        be.add_working_image(
2065            "b.png",
2066            ChangeStatus::Modified,
2067            false,
2068            Some(tiny_png()),
2069            Some(tiny_png()),
2070        );
2071        GitClient::new(Rc::new(be))
2072    }
2073
2074    #[test]
2075    fn next_image_jumps_to_images_and_shows_the_comparison() {
2076        let mut client = nav_client();
2077        client.enter_commit_mode();
2078        // The first unstaged row (the text file) is auto-selected; no image yet.
2079        assert_eq!(client.unstaged_list.borrow().selected_index(), Some(0));
2080        assert!(!client.commit_diff_pane.borrow().showing_image());
2081        // The menu enable state reflects that: there *is* an image to jump to.
2082        assert!(client.has_other_image());
2083
2084        // Ctrl+N → first image (a.png, row 1); the sync then shows it.
2085        assert!(client.navigate_image(true));
2086        assert_eq!(client.unstaged_list.borrow().selected_index(), Some(1));
2087        client.sync_commit();
2088        assert!(client.commit_diff_pane.borrow().showing_image());
2089
2090        // Ctrl+N again → the next image (b.png, row 2).
2091        assert!(client.navigate_image(true));
2092        assert_eq!(client.unstaged_list.borrow().selected_index(), Some(2));
2093    }
2094
2095    #[test]
2096    fn switch_mode_only_applies_while_an_image_is_shown() {
2097        let mut client = nav_client();
2098        client.enter_commit_mode();
2099        // On the text file no image is shown, so Switch Mode is a no-op.
2100        assert!(!client.cycle_image_mode());
2101        // Move onto an image; now it applies.
2102        client.navigate_image(true);
2103        client.sync_commit();
2104        assert!(client.cycle_image_mode());
2105        assert!(client.show_image_side(true));
2106    }
2107}
2108
2109#[cfg(test)]
2110mod review_tests {
2111    use super::*;
2112    use crate::backend::FixtureBackend;
2113
2114    fn review_client() -> GitClient {
2115        let mut client = GitClient::new(Rc::new(FixtureBackend::sample()));
2116        client.enter_review_mode();
2117        client
2118    }
2119
2120    #[test]
2121    fn entering_review_mode_lists_branches_and_selects_the_head_branch() {
2122        let client = review_client();
2123        assert_eq!(client.branches.len(), 3);
2124        assert_eq!(client.branches[0].kind, RefKind::Head);
2125        assert_eq!(client.branch_list.borrow().selected_index(), Some(0));
2126        // main is in sync with its tracked origin/main: one row for the two.
2127        assert_eq!(client.branches[0].upstream.as_deref(), Some("origin/main"));
2128        assert!(!client.branches.iter().any(|b| b.name == "origin/main"));
2129        // main is the review base itself: no files of its own, but the diff
2130        // pane still shows the branch overview header.
2131        assert!(client.review_files.is_empty());
2132        assert!(!client.review_diff_pane.borrow().is_empty());
2133    }
2134
2135    #[test]
2136    fn branch_row_shows_the_synced_upstream_as_a_second_badge() {
2137        let client = review_client();
2138        // main folds its in-sync origin/main: the row carries both badges.
2139        let folded = branch_row(&client.branches[0]);
2140        let badges: Vec<(&str, RefKind)> = folded
2141            .refs
2142            .iter()
2143            .map(|r| (r.name.as_str(), r.kind))
2144            .collect();
2145        assert_eq!(
2146            badges,
2147            [
2148                ("main", RefKind::Head),
2149                ("origin/main", RefKind::RemoteBranch)
2150            ]
2151        );
2152        // A branch without a synced upstream keeps a single badge.
2153        assert_eq!(branch_row(&client.branches[1]).refs.len(), 1);
2154    }
2155
2156    #[test]
2157    fn selecting_a_feature_branch_shows_its_aggregated_changes() {
2158        let mut client = review_client();
2159        let feature = client
2160            .branches
2161            .iter()
2162            .position(|b| b.kind == RefKind::LocalBranch)
2163            .expect("the sample fixture has a local feature branch");
2164
2165        client.branch_list.borrow_mut().set_selected(Some(feature));
2166        assert!(client.sync_review(false));
2167
2168        let paths: Vec<&str> = client
2169            .review_files
2170            .iter()
2171            .map(|f| f.path.as_str())
2172            .collect();
2173        assert_eq!(paths, ["assets/status/added.svg", "src/widgets/list.rs"]);
2174        assert!(!client.review_diff_pane.borrow().is_empty());
2175
2176        // Selecting a file narrows the diff to it.
2177        client.review_file_list.borrow_mut().set_selected(Some(1));
2178        assert!(client.sync_review(false));
2179        assert_eq!(client.shown_review_file, Some(1));
2180    }
2181}