1use std::cell::RefCell;
15use std::rc::Rc;
16
17use saudade::{
18 Button, Checkbox, Dialog, Event, EventCtx, Key, List, ListItem, Menu, MenuBar, MenuItem,
19 NamedKey, Painter, PopupRequest, Rect, TextEditor, Theme, Widget,
20};
21
22use crate::backend::{
23 ChangeStatus, CommitInfo, Diff, DiffLine, DiffLineKind, FileChange, RefKind, RepoBackend,
24 WorkingStatus,
25};
26use crate::widgets::{
27 CommitList, CommitRow, DiffView, Heading, SearchBar, Shared, Shell, compute_graph, layout,
28};
29
30const BROWSE_HISTORY_IDX: usize = 2;
32const COMMIT_UNSTAGED_IDX: usize = 2;
34
35const WIP_UNSTAGED_ID: &str = "\u{1}journey-wip-unstaged";
38const WIP_STAGED_ID: &str = "\u{1}journey-wip-staged";
39
40type ReopenFn = Box<dyn Fn() -> Option<Rc<dyn RepoBackend>>>;
43
44#[derive(Clone, Copy, PartialEq, Eq)]
46enum Mode {
47 Browse,
48 Commit,
49}
50
51#[derive(Clone, Copy, PartialEq, Eq)]
54enum Side {
55 Unstaged,
56 Staged,
57}
58
59#[derive(Clone, Copy, PartialEq, Eq)]
61enum RowRef {
62 Wip(Side),
64 Commit(usize),
66}
67
68#[derive(Clone, Copy)]
71enum AppCommand {
72 Reload,
73 EnterCommitMode,
74 EnterBrowseMode,
75 Rescan,
76 StageSelected,
77 StageAll,
78 UnstageSelected,
79 RevertSelected,
81 PerformDiscard,
84 SignOff,
85 Commit,
86}
87
88enum PendingDiscard {
92 Revert(String),
93 Delete(String),
94}
95
96pub struct GitClient {
97 backend: Rc<dyn RepoBackend>,
98 mode: Mode,
99 bounds: Rect,
100
101 browse_root: Shell,
103 search: Rc<RefCell<SearchBar>>,
104 commit_list: Rc<RefCell<CommitList>>,
105 file_list: Rc<RefCell<List>>,
106 diff_view: Rc<RefCell<DiffView>>,
107
108 commit_root: Shell,
110 unstaged_list: Rc<RefCell<List>>,
111 staged_list: Rc<RefCell<List>>,
112 unstaged_heading: Rc<RefCell<Heading>>,
113 staged_heading: Rc<RefCell<Heading>>,
114 commit_diff_view: Rc<RefCell<DiffView>>,
115 message_editor: Rc<RefCell<TextEditor>>,
116 amend_check: Rc<RefCell<Checkbox>>,
117
118 dialog: Rc<RefCell<Dialog>>,
120 commands: Rc<RefCell<Vec<AppCommand>>>,
121 reopen: Option<ReopenFn>,
122
123 rows: Vec<RowRef>,
127 last_query: String,
128 log_working: WorkingStatus,
131 current_files: Vec<FileChange>,
132 shown: Option<RowRef>,
134 shown_file: Option<usize>,
135
136 working: WorkingStatus,
138 prev_unstaged_sel: Option<usize>,
139 prev_staged_sel: Option<usize>,
140 last_amend: bool,
141 pending_discard: Option<PendingDiscard>,
145}
146
147impl GitClient {
148 pub fn new(backend: Rc<dyn RepoBackend>) -> Self {
149 let dialog = Rc::new(RefCell::new(Dialog::new()));
150 let commands: Rc<RefCell<Vec<AppCommand>>> = Rc::new(RefCell::new(Vec::new()));
151
152 let search = Rc::new(RefCell::new(SearchBar::new(Rect::new(0, 0, 0, 0))));
154 let commit_list = Rc::new(RefCell::new(CommitList::new(Rect::new(0, 0, 0, 0))));
155 let file_list = Rc::new(RefCell::new(List::new(Rect::new(0, 0, 0, 0))));
156 let diff_view = Rc::new(RefCell::new(DiffView::new(Rect::new(0, 0, 0, 0))));
157
158 let browse_root = Shell::new()
164 .no_background()
165 .add(
166 build_browse_menu(commands.clone(), dialog.clone()),
167 layout::browse_menu,
168 )
169 .add(Shared::new(search.clone()), layout::browse_toolbar)
170 .add(Shared::new(commit_list.clone()), layout::browse_history)
171 .add(Shared::new(file_list.clone()), layout::browse_files)
172 .add(Shared::new(diff_view.clone()), layout::browse_diff)
173 .add_overlay(Shared::new(dialog.clone()));
174
175 let unstaged_list = Rc::new(RefCell::new(List::new(Rect::new(0, 0, 0, 0))));
177 let staged_list = Rc::new(RefCell::new(List::new(Rect::new(0, 0, 0, 0))));
178 let unstaged_heading = Rc::new(RefCell::new(Heading::new("Unstaged Changes")));
179 let staged_heading = Rc::new(RefCell::new(Heading::new("Staged Changes")));
180 let commit_diff_view = Rc::new(RefCell::new(DiffView::new(Rect::new(0, 0, 0, 0))));
181 let message_editor = Rc::new(RefCell::new(TextEditor::new(Rect::new(0, 0, 0, 0))));
182 let amend_check = Rc::new(RefCell::new(Checkbox::new(
183 Rect::new(0, 0, 0, 0),
184 "Amend last commit",
185 )));
186
187 let commit_root = Shell::new()
190 .no_background()
191 .add(
192 build_commit_menu(commands.clone(), dialog.clone()),
193 layout::commit_menu,
194 )
195 .add(
196 Shared::new(unstaged_heading.clone()),
197 layout::commit_unstaged_label,
198 )
199 .add(
200 Shared::new(unstaged_list.clone()),
201 layout::commit_unstaged_list,
202 )
203 .add(
204 Shared::new(staged_heading.clone()),
205 layout::commit_staged_label,
206 )
207 .add(Shared::new(staged_list.clone()), layout::commit_staged_list)
208 .add(
209 command_button("Stage \u{2192}", &commands, AppCommand::StageSelected),
210 layout::commit_stage_btn,
211 )
212 .add(
213 command_button("\u{2190} Unstage", &commands, AppCommand::UnstageSelected),
214 layout::commit_unstage_btn,
215 )
216 .add(
217 command_button("Rescan", &commands, AppCommand::Rescan),
218 layout::commit_rescan_btn,
219 )
220 .add(Shared::new(commit_diff_view.clone()), layout::commit_diff)
221 .add(Heading::new("Commit Message"), layout::commit_msg_label)
222 .add(Shared::new(message_editor.clone()), layout::commit_editor)
223 .add(Shared::new(amend_check.clone()), layout::commit_amend)
224 .add(
225 command_button("Commit", &commands, AppCommand::Commit),
226 layout::commit_commit_btn,
227 )
228 .add_overlay(Shared::new(dialog.clone()));
229
230 let mut client = Self {
231 backend,
232 mode: Mode::Browse,
233 bounds: Rect::new(0, 0, 0, 0),
234 browse_root,
235 search,
236 commit_list,
237 file_list,
238 diff_view,
239 commit_root,
240 unstaged_list,
241 staged_list,
242 unstaged_heading,
243 staged_heading,
244 commit_diff_view,
245 message_editor,
246 amend_check,
247 dialog,
248 commands,
249 reopen: None,
250 rows: Vec::new(),
251 last_query: String::new(),
252 log_working: WorkingStatus::default(),
253 current_files: Vec::new(),
254 shown: None,
255 shown_file: None,
256 working: WorkingStatus::default(),
257 prev_unstaged_sel: None,
258 prev_staged_sel: None,
259 last_amend: false,
260 pending_discard: None,
261 };
262 client.sync_browse(true);
263 client
264 }
265
266 pub fn with_reopen(mut self, reopen: ReopenFn) -> Self {
269 self.reopen = Some(reopen);
270 self
271 }
272
273 pub fn enter_commit_mode(&mut self) {
276 self.set_mode(Mode::Commit);
277 }
278
279 fn active(&self) -> &Shell {
280 match self.mode {
281 Mode::Browse => &self.browse_root,
282 Mode::Commit => &self.commit_root,
283 }
284 }
285
286 fn active_mut(&mut self) -> &mut Shell {
287 match self.mode {
288 Mode::Browse => &mut self.browse_root,
289 Mode::Commit => &mut self.commit_root,
290 }
291 }
292
293 fn set_mode(&mut self, mode: Mode) -> bool {
294 if self.mode == mode {
295 return false;
296 }
297 self.mode = mode;
298 match mode {
299 Mode::Commit => {
300 self.rescan();
301 self.commit_root.layout(self.bounds);
302 self.commit_root.focus_child(COMMIT_UNSTAGED_IDX);
303 }
304 Mode::Browse => {
305 self.browse_root.layout(self.bounds);
306 self.browse_root.focus_child(BROWSE_HISTORY_IDX);
307 }
308 }
309 true
310 }
311
312 fn drain_commands(&mut self) -> bool {
314 let pending: Vec<AppCommand> = self.commands.borrow_mut().drain(..).collect();
315 let mut changed = false;
316 for command in pending {
317 changed |= match command {
318 AppCommand::Reload => self.reload(),
319 AppCommand::EnterCommitMode => self.set_mode(Mode::Commit),
320 AppCommand::EnterBrowseMode => self.set_mode(Mode::Browse),
321 AppCommand::Rescan => {
322 self.rescan();
323 true
324 }
325 AppCommand::StageSelected => self.stage_selected(),
326 AppCommand::StageAll => self.stage_all(),
327 AppCommand::UnstageSelected => self.unstage_selected(),
328 AppCommand::RevertSelected => self.revert_selected(),
329 AppCommand::PerformDiscard => self.perform_discard(),
330 AppCommand::SignOff => self.sign_off(),
331 AppCommand::Commit => self.do_commit(),
332 };
333 }
334 changed
335 }
336
337 fn reload(&mut self) -> bool {
340 let Some(reopen) = &self.reopen else {
341 return false;
342 };
343 let Some(backend) = reopen() else {
344 self.dialog
345 .borrow_mut()
346 .show_error("Reload failed", "Could not re-open the repository.");
347 return true;
348 };
349 self.backend = backend;
350 self.shown = None;
351 self.shown_file = None;
352 self.last_query.clear();
353 self.search.borrow_mut().clear();
354 self.sync_browse(true);
355 self.rescan();
356 true
357 }
358
359 fn sync_browse(&mut self, force: bool) -> bool {
366 let mut changed = false;
367
368 let query = self.search.borrow().text().trim().to_lowercase();
370 if force || query != self.last_query {
371 self.last_query = query.clone();
372 self.rebuild_commits(&query);
373 self.shown = None;
374 changed = true;
375 }
376
377 let activated = self.commit_list.borrow_mut().take_activated();
379 if let Some(pos) = activated
380 && matches!(self.rows.get(pos), Some(RowRef::Wip(_)))
381 {
382 self.set_mode(Mode::Commit);
383 return true;
384 }
385
386 let sel_pos = self.commit_list.borrow().selected_index();
389 let sel = sel_pos.and_then(|p| self.rows.get(p).copied());
390 if force || sel != self.shown {
391 self.shown = sel;
392 self.current_files = match sel {
393 Some(RowRef::Commit(idx)) => self.backend.changed_files(idx),
394 Some(RowRef::Wip(Side::Unstaged)) => self.log_working.unstaged.clone(),
395 Some(RowRef::Wip(Side::Staged)) => self.log_working.staged.clone(),
396 None => Vec::new(),
397 };
398 let items: Vec<ListItem> = self.current_files.iter().map(file_row).collect();
399 self.file_list.borrow_mut().set_items(items);
400 self.shown_file = None;
401 let diff = self.selection_diff(sel, None);
402 self.diff_view.borrow_mut().set_diff(diff);
403 changed = true;
404 }
405
406 let file_sel = self.file_list.borrow().selected_index();
408 if file_sel != self.shown_file {
409 self.shown_file = file_sel;
410 let diff = self.selection_diff(self.shown, file_sel);
411 self.diff_view.borrow_mut().set_diff(diff);
412 changed = true;
413 }
414
415 changed
416 }
417
418 fn selection_diff(&self, sel: Option<RowRef>, file_sel: Option<usize>) -> Diff {
421 match sel {
422 Some(RowRef::Commit(cidx)) => match file_sel.and_then(|f| self.current_files.get(f)) {
423 Some(file) => self.backend.file_diff(cidx, &file.path),
424 None => self.commit_detail(cidx),
425 },
426 Some(RowRef::Wip(side)) => {
427 let staged = matches!(side, Side::Staged);
428 match file_sel.and_then(|f| self.current_files.get(f)) {
429 Some(file) => self.backend.working_diff(&file.path, staged, false),
430 None => self.wip_overview_diff(staged),
431 }
432 }
433 None => Diff::default(),
434 }
435 }
436
437 fn wip_overview_diff(&self, staged: bool) -> Diff {
440 let mut lines = Vec::new();
441 for file in &self.current_files {
442 lines.extend(self.backend.working_diff(&file.path, staged, false).lines);
443 }
444 Diff { lines }
445 }
446
447 fn rebuild_commits(&mut self, query: &str) {
453 self.log_working = if query.is_empty() {
456 self.backend.working_status(false)
457 } else {
458 WorkingStatus::default()
459 };
460 let show_unstaged = !self.log_working.unstaged.is_empty();
461 let show_staged = !self.log_working.staged.is_empty();
462
463 let commits = self.backend.commits();
464 let commit_rows: Vec<usize> = (0..commits.len())
465 .filter(|&i| query.is_empty() || commit_matches(&commits[i], query))
466 .collect();
467
468 let mut row_refs: Vec<RowRef> = Vec::new();
469 let mut display: Vec<CommitRow> = Vec::new();
470 if show_unstaged {
471 row_refs.push(RowRef::Wip(Side::Unstaged));
472 display.push(wip_row(Side::Unstaged, self.log_working.unstaged.len()));
473 }
474 if show_staged {
475 row_refs.push(RowRef::Wip(Side::Staged));
476 display.push(wip_row(Side::Staged, self.log_working.staged.len()));
477 }
478 for &i in &commit_rows {
479 row_refs.push(RowRef::Commit(i));
480 display.push(commit_row(&commits[i]));
481 }
482
483 let graph = if query.is_empty() {
487 let head_id = head_commit_id(commits);
488 let mut dag: Vec<(String, Vec<String>)> = Vec::new();
489 if show_unstaged {
490 let parent = if show_staged {
491 vec![WIP_STAGED_ID.to_string()]
492 } else {
493 head_id.clone().into_iter().collect()
494 };
495 dag.push((WIP_UNSTAGED_ID.to_string(), parent));
496 }
497 if show_staged {
498 dag.push((WIP_STAGED_ID.to_string(), head_id.into_iter().collect()));
499 }
500 for &i in &commit_rows {
501 dag.push((commits[i].id.clone(), commits[i].parents.clone()));
502 }
503 Some(compute_graph(&dag))
504 } else {
505 None
506 };
507
508 self.rows = row_refs;
509 let new_pos = self
510 .shown
511 .and_then(|s| self.rows.iter().position(|&r| r == s))
512 .or_else(|| {
513 self.rows
514 .iter()
515 .position(|r| matches!(r, RowRef::Commit(_)))
516 })
517 .or(if self.rows.is_empty() { None } else { Some(0) });
518
519 let mut list = self.commit_list.borrow_mut();
520 list.set_rows(display);
521 list.set_graph(graph);
522 list.set_selected(new_pos);
523 }
524
525 fn commit_detail(&self, idx: usize) -> Diff {
528 let Some(commit) = self.backend.commits().get(idx) else {
529 return Diff::default();
530 };
531
532 let mut lines = Vec::new();
533 let header = |lines: &mut Vec<DiffLine>, text: String| {
534 lines.push(DiffLine::new(DiffLineKind::CommitHeader, text));
535 };
536 let blank = |lines: &mut Vec<DiffLine>| {
537 lines.push(DiffLine::new(DiffLineKind::Context, String::new()));
538 };
539
540 header(&mut lines, format!("commit {}", commit.id));
541 if !commit.refs.is_empty() {
542 let names: Vec<&str> = commit.refs.iter().map(|r| r.name.as_str()).collect();
543 header(&mut lines, format!("Refs: {}", names.join(", ")));
544 }
545 header(
546 &mut lines,
547 format!("Author: {} <{}>", commit.author_name, commit.author_email),
548 );
549 header(&mut lines, format!("Date: {}", commit.date_string()));
550 if commit.is_merge() {
551 let shorts: Vec<String> = commit.parents.iter().map(|p| short(p)).collect();
552 header(&mut lines, format!("Merge: {}", shorts.join(" ")));
553 }
554
555 blank(&mut lines);
556 for line in commit.message.trim_end().lines() {
557 lines.push(DiffLine::new(DiffLineKind::Context, format!(" {line}")));
558 }
559 blank(&mut lines);
560
561 lines.extend(self.backend.commit_diff(idx).lines);
562 Diff { lines }
563 }
564
565 fn rescan(&mut self) {
569 let amend = self.amend_check.borrow().is_checked();
570 self.working = self.backend.working_status(amend);
571
572 let unstaged: Vec<ListItem> = self.working.unstaged.iter().map(file_row).collect();
573 let staged: Vec<ListItem> = self.working.staged.iter().map(file_row).collect();
574 self.unstaged_list.borrow_mut().set_items(unstaged);
575 self.staged_list.borrow_mut().set_items(staged);
576 self.unstaged_heading.borrow_mut().set_text(format!(
577 "Unstaged Changes ({})",
578 self.working.unstaged.len()
579 ));
580 self.staged_heading.borrow_mut().set_text(format!(
581 "Staged Changes — will commit ({})",
582 self.working.staged.len()
583 ));
584
585 self.prev_unstaged_sel = None;
586 self.prev_staged_sel = None;
587 self.commit_diff_view.borrow_mut().set_diff(Diff::default());
588
589 if !self.working.unstaged.is_empty() {
591 self.apply_commit_selection(Side::Unstaged, 0);
592 } else if !self.working.staged.is_empty() {
593 self.apply_commit_selection(Side::Staged, 0);
594 }
595 }
596
597 fn apply_commit_selection(&mut self, side: Side, i: usize) {
600 match side {
601 Side::Unstaged => {
602 self.unstaged_list.borrow_mut().set_selected(Some(i));
603 self.staged_list.borrow_mut().set_selected(None);
604 }
605 Side::Staged => {
606 self.staged_list.borrow_mut().set_selected(Some(i));
607 self.unstaged_list.borrow_mut().set_selected(None);
608 }
609 }
610 self.prev_unstaged_sel = self.unstaged_list.borrow().selected_index();
611 self.prev_staged_sel = self.staged_list.borrow().selected_index();
612
613 let staged = matches!(side, Side::Staged);
614 let amend = self.amend_check.borrow().is_checked();
615 let files = match side {
616 Side::Unstaged => &self.working.unstaged,
617 Side::Staged => &self.working.staged,
618 };
619 let diff = files
620 .get(i)
621 .map(|f| self.backend.working_diff(&f.path, staged, amend))
622 .unwrap_or_default();
623 self.commit_diff_view.borrow_mut().set_diff(diff);
624 }
625
626 fn sync_commit(&mut self) -> bool {
630 let unstaged_activated = self.unstaged_list.borrow_mut().take_activated();
631 if let Some(i) = unstaged_activated {
632 self.stage_index(i);
633 return true;
634 }
635 let staged_activated = self.staged_list.borrow_mut().take_activated();
636 if let Some(i) = staged_activated {
637 self.unstage_index(i);
638 return true;
639 }
640
641 let u = self.unstaged_list.borrow().selected_index();
642 let s = self.staged_list.borrow().selected_index();
643 if let Some(i) = u
644 && self.prev_unstaged_sel != Some(i)
645 {
646 self.apply_commit_selection(Side::Unstaged, i);
647 return true;
648 }
649 if let Some(i) = s
650 && self.prev_staged_sel != Some(i)
651 {
652 self.apply_commit_selection(Side::Staged, i);
653 return true;
654 }
655 self.prev_unstaged_sel = u;
657 self.prev_staged_sel = s;
658
659 let amend = self.amend_check.borrow().is_checked();
660 if amend != self.last_amend {
661 self.last_amend = amend;
662 if amend
663 && self.message_editor.borrow().text().trim().is_empty()
664 && let Some(msg) = self.backend.head_message()
665 {
666 self.message_editor.borrow_mut().set_text(msg.trim_end());
667 }
668 self.rescan();
671 return true;
672 }
673
674 false
675 }
676
677 fn stage_selected(&mut self) -> bool {
678 let sel = self.unstaged_list.borrow().selected_index();
679 match sel {
680 Some(i) => {
681 self.stage_index(i);
682 true
683 }
684 None => false,
685 }
686 }
687
688 fn stage_all(&mut self) -> bool {
690 if self.working.unstaged.is_empty() {
691 return false;
692 }
693 let paths: Vec<String> = self
694 .working
695 .unstaged
696 .iter()
697 .map(|f| f.path.clone())
698 .collect();
699 for path in paths {
700 if let Err(e) = self.backend.stage(&path) {
701 self.dialog.borrow_mut().show_error("Stage failed", &e);
702 break;
703 }
704 }
705 self.rescan();
706 true
707 }
708
709 fn sign_off(&mut self) -> bool {
712 let Some((name, email)) = self.backend.signature() else {
713 self.dialog.borrow_mut().show_error(
714 "Sign off",
715 "No git identity configured. Set user.name and user.email.",
716 );
717 return true;
718 };
719 let body = self.message_editor.borrow().text();
720 match with_signoff(&body, &name, &email) {
721 Some(text) => {
722 self.message_editor.borrow_mut().set_text(&text);
723 true
724 }
725 None => false,
727 }
728 }
729
730 fn unstage_selected(&mut self) -> bool {
731 let sel = self.staged_list.borrow().selected_index();
732 match sel {
733 Some(i) => {
734 self.unstage_index(i);
735 true
736 }
737 None => false,
738 }
739 }
740
741 fn stage_index(&mut self, i: usize) {
742 if let Some(file) = self.working.unstaged.get(i) {
743 let path = file.path.clone();
744 if let Err(e) = self.backend.stage(&path) {
745 self.dialog.borrow_mut().show_error("Stage failed", &e);
746 }
747 }
748 self.rescan();
749 }
750
751 fn unstage_index(&mut self, i: usize) {
752 if let Some(file) = self.working.staged.get(i) {
753 let path = file.path.clone();
754 let amend = self.amend_check.borrow().is_checked();
755 if let Err(e) = self.backend.unstage(&path, amend) {
756 self.dialog.borrow_mut().show_error("Unstage failed", &e);
757 }
758 }
759 self.rescan();
760 }
761
762 fn revert_selected(&mut self) -> bool {
769 let Some(i) = self.unstaged_list.borrow().selected_index() else {
770 return false;
771 };
772 let Some(file) = self.working.unstaged.get(i) else {
773 return false;
774 };
775 let display = file.display();
776 let path = file.path.clone();
777 let (title, message, affirm) = if file.status == ChangeStatus::Untracked {
778 self.pending_discard = Some(PendingDiscard::Delete(path));
779 (
780 "Delete File",
781 format!(
782 "Delete untracked file\n{display}?\n\nIt is not tracked by git and cannot be recovered."
783 ),
784 "Delete File",
785 )
786 } else {
787 self.pending_discard = Some(PendingDiscard::Revert(path));
788 (
789 "Revert Changes",
790 format!(
791 "Revert unstaged changes in\n{display}?\n\nThese changes will be permanently lost."
792 ),
793 "Revert Changes",
794 )
795 };
796
797 let commands = self.commands.clone();
798 self.dialog
799 .borrow_mut()
800 .show_confirm(title, message, affirm, move |cx| {
801 commands.borrow_mut().push(AppCommand::PerformDiscard);
802 cx.request_paint();
803 });
804 true
805 }
806
807 fn perform_discard(&mut self) -> bool {
810 let (failure, result) = match self.pending_discard.take() {
811 Some(PendingDiscard::Revert(path)) => ("Revert failed", self.backend.revert(&path)),
812 Some(PendingDiscard::Delete(path)) => {
813 ("Delete failed", self.backend.delete_untracked(&path))
814 }
815 None => return false,
816 };
817 if let Err(e) = result {
818 self.dialog.borrow_mut().show_error(failure, &e);
819 }
820 self.rescan();
821 true
822 }
823
824 fn do_commit(&mut self) -> bool {
825 let amend = self.amend_check.borrow().is_checked();
826 let message = self.message_editor.borrow().text();
827
828 if self.working.staged.is_empty() && !amend {
829 self.dialog.borrow_mut().show_error(
830 "Nothing to commit",
831 "Stage some changes first, or enable \u{201C}Amend last commit\u{201D}.",
832 );
833 return true;
834 }
835
836 match self.backend.commit(&message, amend) {
837 Ok(()) => {
838 self.message_editor.borrow_mut().set_text("");
839 self.amend_check.borrow_mut().set_checked(false);
840 self.last_amend = false;
841 if !self.reload() {
844 self.shown = None;
845 self.sync_browse(true);
846 self.rescan();
847 }
848 self.set_mode(Mode::Browse);
850 }
851 Err(e) => {
852 self.dialog.borrow_mut().show_error("Commit failed", &e);
853 }
854 }
855 true
856 }
857
858 fn handle_shortcut(&mut self, event: &Event, ctx: &mut EventCtx) -> bool {
863 if self.dialog.borrow().is_open() {
865 return false;
866 }
867 let Event::KeyDown { key, modifiers } = event else {
868 return false;
869 };
870 if !modifiers.control || modifiers.alt || modifiers.logo {
872 return false;
873 }
874
875 let letter = match key {
876 Key::Char(c) => Some(c.to_ascii_lowercase()),
877 _ => None,
878 };
879
880 if letter == Some('q') {
882 ctx.close();
883 return true;
884 }
885
886 if self.mode != Mode::Commit {
888 return false;
889 }
890 let command = if matches!(key, Key::Named(NamedKey::Enter)) {
891 AppCommand::Commit
892 } else {
893 match letter {
894 Some('r') => AppCommand::Rescan,
895 Some('t') => AppCommand::StageSelected,
896 Some('i') => AppCommand::StageAll,
897 Some('j') => AppCommand::RevertSelected,
898 Some('s') => AppCommand::SignOff,
899 _ => return false,
900 }
901 };
902 self.commands.borrow_mut().push(command);
903 true
904 }
905}
906
907impl Widget for GitClient {
908 fn bounds(&self) -> Rect {
909 self.bounds
910 }
911
912 fn paint(&mut self, painter: &mut Painter, theme: &Theme) {
913 self.active_mut().paint(painter, theme);
914 }
915
916 fn paint_overlay(&mut self, painter: &mut Painter, theme: &Theme) {
917 self.active_mut().paint_overlay(painter, theme);
918 }
919
920 fn event(&mut self, event: &Event, ctx: &mut EventCtx) {
921 if !self.handle_shortcut(event, ctx) {
923 self.active_mut().event(event, ctx);
924 }
925 let mut dirty = self.drain_commands();
928 dirty |= match self.mode {
929 Mode::Browse => self.sync_browse(false),
930 Mode::Commit => self.sync_commit(),
931 };
932 if dirty {
933 ctx.request_paint();
934 }
935 }
936
937 fn captures_pointer(&self) -> bool {
938 self.active().captures_pointer()
939 }
940
941 fn focusable(&self) -> bool {
942 self.active().focusable()
943 }
944
945 fn set_focused(&mut self, focused: bool) {
946 self.active_mut().set_focused(focused);
947 }
948
949 fn layout(&mut self, bounds: Rect) {
950 self.bounds = bounds;
951 self.browse_root.layout(bounds);
952 self.commit_root.layout(bounds);
953 }
954
955 fn focus_first(&mut self) -> bool {
956 match self.mode {
957 Mode::Browse => self.browse_root.focus_child(BROWSE_HISTORY_IDX),
960 Mode::Commit => self.commit_root.focus_child(COMMIT_UNSTAGED_IDX),
961 }
962 }
963
964 fn popup_request(&self) -> Option<PopupRequest> {
965 self.active().popup_request()
966 }
967
968 fn wants_ticks(&self) -> bool {
969 self.active().wants_ticks()
970 }
971}
972
973fn build_browse_menu(
976 commands: Rc<RefCell<Vec<AppCommand>>>,
977 dialog: Rc<RefCell<Dialog>>,
978) -> MenuBar {
979 MenuBar::new(Rect::new(0, 0, 0, 0))
980 .add_menu(Menu::new(
981 "&File",
982 vec![
983 cmd_item("&Reload", &commands, AppCommand::Reload),
984 MenuItem::separator(),
985 MenuItem::action("E&xit", |cx| cx.close()).with_accel("Ctrl+Q"),
986 ],
987 ))
988 .add_menu(Menu::new(
989 "&View",
990 vec![cmd_item(
991 "&Commit Changes",
992 &commands,
993 AppCommand::EnterCommitMode,
994 )],
995 ))
996 .add_menu(Menu::new("&Help", vec![about_item(&dialog)]))
997}
998
999fn build_commit_menu(
1002 commands: Rc<RefCell<Vec<AppCommand>>>,
1003 dialog: Rc<RefCell<Dialog>>,
1004) -> MenuBar {
1005 MenuBar::new(Rect::new(0, 0, 0, 0))
1006 .add_menu(Menu::new(
1007 "&File",
1008 vec![
1009 cmd_item("&Reload", &commands, AppCommand::Reload),
1010 MenuItem::separator(),
1011 MenuItem::action("E&xit", |cx| cx.close()).with_accel("Ctrl+Q"),
1012 ],
1013 ))
1014 .add_menu(Menu::new(
1015 "&Commit",
1016 vec![
1017 cmd_item("&Rescan", &commands, AppCommand::Rescan).with_accel("Ctrl+R"),
1018 MenuItem::separator(),
1019 cmd_item("&Stage Selected", &commands, AppCommand::StageSelected)
1020 .with_accel("Ctrl+T"),
1021 cmd_item("Stage &All", &commands, AppCommand::StageAll).with_accel("Ctrl+I"),
1022 cmd_item("&Unstage Selected", &commands, AppCommand::UnstageSelected),
1023 cmd_item("Re&vert Changes", &commands, AppCommand::RevertSelected)
1024 .with_accel("Ctrl+J"),
1025 MenuItem::separator(),
1026 cmd_item("Sign &Off", &commands, AppCommand::SignOff).with_accel("Ctrl+S"),
1027 cmd_item("&Commit", &commands, AppCommand::Commit).with_accel("Ctrl+Enter"),
1028 ],
1029 ))
1030 .add_menu(Menu::new(
1031 "&View",
1032 vec![cmd_item(
1033 "&Browse History",
1034 &commands,
1035 AppCommand::EnterBrowseMode,
1036 )],
1037 ))
1038 .add_menu(Menu::new("&Help", vec![about_item(&dialog)]))
1039}
1040
1041fn cmd_item(label: &str, commands: &Rc<RefCell<Vec<AppCommand>>>, command: AppCommand) -> MenuItem {
1043 let commands = commands.clone();
1044 MenuItem::action(label, move |cx| {
1045 commands.borrow_mut().push(command);
1046 cx.request_paint();
1047 })
1048}
1049
1050fn about_item(dialog: &Rc<RefCell<Dialog>>) -> MenuItem {
1052 let dialog = dialog.clone();
1053 MenuItem::action("&About", move |cx| {
1054 dialog.borrow_mut().show_info(
1055 "About Git Journey",
1056 "Git Journey\n\nA gitk-style repository browser\nbuilt on the Saudade toolkit.",
1057 );
1058 cx.request_paint();
1059 })
1060}
1061
1062fn command_button(
1064 label: &str,
1065 commands: &Rc<RefCell<Vec<AppCommand>>>,
1066 command: AppCommand,
1067) -> Button {
1068 let commands = commands.clone();
1069 Button::new(Rect::new(0, 0, 0, 0), label).on_click(move |cx| {
1070 commands.borrow_mut().push(command);
1071 cx.request_paint();
1072 })
1073}
1074
1075fn short(sha: &str) -> String {
1077 sha.chars().take(8).collect()
1078}
1079
1080fn with_signoff(body: &str, name: &str, email: &str) -> Option<String> {
1085 let trailer = format!("Signed-off-by: {name} <{email}>");
1086 let last_line = body.lines().next_back().unwrap_or("").trim_end();
1087 if last_line.eq_ignore_ascii_case(&trailer) {
1088 return None;
1089 }
1090 let trimmed = body.trim_end();
1091 Some(if trimmed.is_empty() {
1092 trailer
1093 } else if is_trailer_line(last_line) {
1094 format!("{trimmed}\n{trailer}")
1095 } else {
1096 format!("{trimmed}\n\n{trailer}")
1097 })
1098}
1099
1100fn is_trailer_line(line: &str) -> bool {
1104 let Some((key, _)) = line.split_once(':') else {
1105 return false;
1106 };
1107 let key = key.to_ascii_lowercase();
1108 key.ends_with("-by") && key.chars().all(|c| c.is_ascii_alphabetic() || c == '-')
1109}
1110
1111fn wip_row(side: Side, count: usize) -> CommitRow {
1113 let summary = match side {
1114 Side::Unstaged => format!("Uncommitted changes ({count})"),
1115 Side::Staged => format!("Staged changes ({count})"),
1116 };
1117 CommitRow {
1118 summary,
1119 ..Default::default()
1120 }
1121}
1122
1123fn head_commit_id(commits: &[CommitInfo]) -> Option<String> {
1127 commits
1128 .iter()
1129 .find(|c| {
1130 c.refs
1131 .iter()
1132 .any(|r| matches!(r.kind, RefKind::Head | RefKind::DetachedHead))
1133 })
1134 .or_else(|| commits.first())
1135 .map(|c| c.id.clone())
1136}
1137
1138fn commit_matches(commit: &CommitInfo, query: &str) -> bool {
1141 commit.summary.to_lowercase().contains(query)
1142 || commit.message.to_lowercase().contains(query)
1143 || commit.author_name.to_lowercase().contains(query)
1144 || commit.author_email.to_lowercase().contains(query)
1145 || commit.id.contains(query)
1146 || commit
1147 .refs
1148 .iter()
1149 .any(|r| r.name.to_lowercase().contains(query))
1150}
1151
1152pub fn commit_row(commit: &CommitInfo) -> CommitRow {
1155 CommitRow {
1156 id: commit.id.clone(),
1157 parents: commit.parents.clone(),
1158 summary: commit.summary.clone(),
1159 refs: commit.refs.clone(),
1160 author: commit.author_name.clone(),
1161 date: commit.short_date_string(),
1162 }
1163}
1164
1165pub fn file_row(file: &FileChange) -> ListItem {
1167 ListItem::new(format!("{} {}", file.status.badge(), file.display()))
1168}
1169
1170#[cfg(test)]
1171mod tests {
1172 use super::{is_trailer_line, with_signoff};
1173
1174 const NAME: &str = "Ada Lovelace";
1175 const EMAIL: &str = "ada@example.com";
1176 const SOB: &str = "Signed-off-by: Ada Lovelace <ada@example.com>";
1177
1178 #[test]
1179 fn signoff_into_empty_message_is_just_the_trailer() {
1180 assert_eq!(with_signoff("", NAME, EMAIL).as_deref(), Some(SOB));
1181 assert_eq!(with_signoff(" \n", NAME, EMAIL).as_deref(), Some(SOB));
1182 }
1183
1184 #[test]
1185 fn signoff_after_prose_gets_a_blank_separator_line() {
1186 assert_eq!(
1187 with_signoff("Fix the thing", NAME, EMAIL).as_deref(),
1188 Some(format!("Fix the thing\n\n{SOB}").as_str())
1189 );
1190 }
1191
1192 #[test]
1193 fn signoff_after_a_trailer_block_stays_tight() {
1194 let body = "Fix the thing\n\nReviewed-by: B <b@example.com>";
1195 assert_eq!(
1196 with_signoff(body, NAME, EMAIL).as_deref(),
1197 Some(format!("{body}\n{SOB}").as_str())
1198 );
1199 }
1200
1201 #[test]
1202 fn signoff_is_idempotent_when_already_last_line() {
1203 let body = format!("Fix the thing\n\n{SOB}");
1204 assert_eq!(with_signoff(&body, NAME, EMAIL), None);
1205 }
1206
1207 #[test]
1208 fn trailer_lines_are_recognized() {
1209 assert!(is_trailer_line("Signed-off-by: A <a@x>"));
1210 assert!(is_trailer_line("Reviewed-by: B <b@x>"));
1211 assert!(is_trailer_line("Co-authored-by: C <c@x>"));
1212 assert!(!is_trailer_line("Just a normal sentence."));
1213 assert!(!is_trailer_line("Fixes: #123"));
1214 assert!(!is_trailer_line(""));
1215 }
1216}