1use 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
32const BROWSE_HISTORY_IDX: usize = 2;
34const COMMIT_UNSTAGED_IDX: usize = 2;
36
37const WIP_UNSTAGED_ID: &str = "\u{1}journey-wip-unstaged";
40const WIP_STAGED_ID: &str = "\u{1}journey-wip-staged";
41
42type ReopenFn = Box<dyn Fn() -> Option<Rc<dyn RepoBackend>>>;
45
46#[derive(Clone, Copy, PartialEq, Eq)]
48enum Mode {
49 Browse,
50 Commit,
51}
52
53#[derive(Clone, Copy, PartialEq, Eq)]
56enum Side {
57 Unstaged,
58 Staged,
59}
60
61#[derive(Clone, Copy, PartialEq, Eq)]
63enum RowRef {
64 Wip(Side),
66 Commit(usize),
68}
69
70#[derive(Clone, Copy)]
73enum AppCommand {
74 Reload,
75 EnterCommitMode,
76 EnterBrowseMode,
77 Rescan,
78 StageSelected,
79 StageAll,
80 UnstageSelected,
81 RevertSelected,
83 PerformDiscard,
86 SignOff,
87 Commit,
88}
89
90enum 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_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_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 narrow: bool,
125
126 dialog: Rc<RefCell<Dialog>>,
128 commands: Rc<RefCell<Vec<AppCommand>>>,
129 reopen: Option<ReopenFn>,
130
131 rows: Vec<RowRef>,
135 last_query: String,
136 log_working: WorkingStatus,
139 current_files: Vec<FileChange>,
140 shown: Option<RowRef>,
142 shown_file: Option<usize>,
143
144 working: WorkingStatus,
146 prev_unstaged_sel: Option<usize>,
147 prev_staged_sel: Option<usize>,
148 last_amend: bool,
149 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 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 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 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 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 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 pub fn with_reopen(mut self, reopen: ReopenFn) -> Self {
291 self.reopen = Some(reopen);
292 self
293 }
294
295 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 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 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 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 fn sync_browse(&mut self, force: bool) -> bool {
403 let mut changed = false;
404
405 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 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 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 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 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 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 fn rebuild_commits(&mut self, query: &str) {
490 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 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 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 fn rescan(&mut self) {
606 self.rescan_selecting(None);
607 }
608
609 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 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 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 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 fn sync_commit(&mut self) -> bool {
697 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 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 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 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 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 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 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 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 self.rescan_selecting(Some((side, path)));
888 true
889 }
890
891 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 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 if !self.reload() {
973 self.shown = None;
974 self.sync_browse(true);
975 self.rescan();
976 }
977 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 fn handle_shortcut(&mut self, event: &Event, ctx: &mut EventCtx) -> bool {
992 if self.dialog.borrow().is_open() {
994 return false;
995 }
996 let Event::KeyDown { key, modifiers } = event else {
997 return false;
998 };
999 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 if letter == Some('q') {
1011 ctx.close();
1012 return true;
1013 }
1014
1015 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 if !self.handle_shortcut(event, ctx) {
1052 self.active_mut().event(event, ctx);
1053 }
1054 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 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
1103fn 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
1129fn 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
1171fn 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
1180fn 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
1192fn 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
1216fn short(sha: &str) -> String {
1218 sha.chars().take(8).collect()
1219}
1220
1221fn 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
1241fn 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
1252fn 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
1264fn 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
1279fn 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
1293pub 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
1306pub fn file_row(file: &FileChange) -> ListItem {
1311 ListItem::new(file.display()).with_svg_icon(status_icon(file.status))
1312}
1313
1314fn 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 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 #[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 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 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 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 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}