Skip to main content

journey/
ui.rs

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