1use 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
35const BROWSE_HISTORY_IDX: usize = 2;
37const COMMIT_UNSTAGED_IDX: usize = 2;
39const REVIEW_BRANCHES_IDX: usize = 1;
41
42const WIP_UNSTAGED_ID: &str = "\u{1}journey-wip-unstaged";
45const WIP_STAGED_ID: &str = "\u{1}journey-wip-staged";
46
47type ReopenFn = Box<dyn Fn() -> Option<Rc<dyn RepoBackend>>>;
50
51#[derive(Clone, Copy, PartialEq, Eq, Default)]
53enum Mode {
54 #[default]
55 Browse,
56 Commit,
57 Review,
58}
59
60#[derive(Clone, Copy, PartialEq, Eq)]
63enum Side {
64 Unstaged,
65 Staged,
66}
67
68#[derive(Clone, Copy, PartialEq, Eq)]
70enum RowRef {
71 Wip(Side),
73 Commit(usize),
75}
76
77#[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 RevertSelected,
91 PerformDiscard,
94 SignOff,
95 Commit,
96 NextImage,
99 PrevImage,
100 CycleImageMode,
102 ShowImageBefore,
105 ShowImageAfter,
106}
107
108#[derive(Default)]
115struct MenuNav {
116 mode: Mode,
117 showing_image: bool,
118 can_nav_images: bool,
119}
120
121enum 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_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_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 narrow: bool,
156
157 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 dialog: Rc<RefCell<Dialog>>,
165 commands: Rc<RefCell<Vec<AppCommand>>>,
166 reopen: Option<ReopenFn>,
167 nav_state: Rc<RefCell<MenuNav>>,
170
171 rows: Vec<RowRef>,
175 last_query: String,
176 log_working: WorkingStatus,
179 current_files: Vec<FileChange>,
180 shown: Option<RowRef>,
182 shown_file: Option<usize>,
183
184 branches: Vec<BranchInfo>,
187 review_files: Vec<FileChange>,
189 shown_branch: Option<usize>,
191 shown_review_file: Option<usize>,
192
193 working: WorkingStatus,
195 prev_unstaged_sel: Option<usize>,
196 prev_staged_sel: Option<usize>,
197 last_amend: bool,
198 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 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 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 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 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 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 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 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 pub fn with_reopen(mut self, reopen: ReopenFn) -> Self {
377 self.reopen = Some(reopen);
378 self
379 }
380
381 pub fn enter_commit_mode(&mut self) {
384 self.set_mode(Mode::Commit);
385 }
386
387 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 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 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 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 if self.mode == Mode::Review {
502 self.rebuild_branches();
503 self.sync_review(true);
504 }
505 true
506 }
507
508 fn sync_browse(&mut self, force: bool) -> bool {
515 let mut changed = false;
516
517 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 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 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 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 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 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 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 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 fn rebuild_commits(&mut self, query: &str) {
629 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 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 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 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 fn sync_review(&mut self, force: bool) -> bool {
762 let mut changed = false;
763
764 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 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 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 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 fn rescan(&mut self) {
857 self.rescan_selecting(None);
858 }
859
860 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 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 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 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 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 fn sync_commit(&mut self) -> bool {
959 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 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 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 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 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 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 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 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 self.rescan_selecting(Some((side, path)));
1150 true
1151 }
1152
1153 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 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 if !self.reload() {
1235 self.shown = None;
1236 self.sync_browse(true);
1237 self.rescan();
1238 }
1239 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 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 fn active_showing_image(&self) -> bool {
1263 self.active_diff_pane().borrow().showing_image()
1264 }
1265
1266 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 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 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 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 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 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 self.active_mut().event(event, ctx);
1411 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 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 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
1466fn 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
1492fn 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
1534fn 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
1562fn 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
1599fn 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
1608fn 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
1620fn 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
1644fn short(sha: &str) -> String {
1646 sha.chars().take(8).collect()
1647}
1648
1649fn 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
1669fn 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
1680fn 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
1705fn 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
1716fn 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
1728fn 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
1743fn 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
1757fn 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
1783pub 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
1796pub fn file_row(file: &FileChange) -> ListItem {
1801 ListItem::new(file.display()).with_svg_icon(status_icon(file.status))
1802}
1803
1804fn 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 let f = files(&["a.txt", "logo.png", "notes.md", "icon.gif", "photo.jpeg"]);
1848 assert_eq!(next_image_index(&f, Some(1), true), Some(3));
1850 assert_eq!(next_image_index(&f, Some(3), false), Some(1));
1851 assert_eq!(next_image_index(&f, Some(4), true), None);
1853 assert_eq!(next_image_index(&f, Some(1), false), None);
1854 assert_eq!(next_image_index(&f, Some(2), true), Some(3));
1856 assert_eq!(next_image_index(&f, Some(2), false), Some(1));
1857 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 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 let one = files(&["a.txt", "logo.png"]);
1871 assert!(!other_image_exists(&one, Some(1)));
1872 assert!(other_image_exists(&one, Some(0)));
1874 assert!(other_image_exists(&one, None));
1875 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 assert_eq!(
1883 next_image_index(&files(&["a.txt", "b.rs"]), Some(0), true),
1884 None
1885 );
1886 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 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 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 #[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 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 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 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 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 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 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 assert_eq!(client.unstaged_list.borrow().selected_index(), Some(0));
2080 assert!(!client.commit_diff_pane.borrow().showing_image());
2081 assert!(client.has_other_image());
2083
2084 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 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 assert!(!client.cycle_image_mode());
2101 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 assert_eq!(client.branches[0].upstream.as_deref(), Some("origin/main"));
2128 assert!(!client.branches.iter().any(|b| b.name == "origin/main"));
2129 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 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 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 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}