use std::cell::RefCell;
use std::collections::BTreeSet;
use std::rc::Rc;
use saudade::{
Button, Checkbox, Dialog, Event, EventCtx, Key, List, ListItem, Menu, MenuBar, MenuItem,
NamedKey, Painter, PopupRequest, Rect, SvgImage, TextEditor, Theme, Widget, include_svg,
};
use crate::backend::{
ChangeStatus, CommitInfo, Diff, DiffLine, DiffLineKind, FileChange, PartialMode, RefKind,
RepoBackend, WorkingStatus, build_partial_patch,
};
use crate::widgets::{
CommitList, CommitRow, DiffMode, DiffView, Heading, SearchBar, Shared, Shell, compute_graph,
layout,
};
const BROWSE_HISTORY_IDX: usize = 2;
const COMMIT_UNSTAGED_IDX: usize = 2;
const WIP_UNSTAGED_ID: &str = "\u{1}journey-wip-unstaged";
const WIP_STAGED_ID: &str = "\u{1}journey-wip-staged";
type ReopenFn = Box<dyn Fn() -> Option<Rc<dyn RepoBackend>>>;
#[derive(Clone, Copy, PartialEq, Eq)]
enum Mode {
Browse,
Commit,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Side {
Unstaged,
Staged,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum RowRef {
Wip(Side),
Commit(usize),
}
#[derive(Clone, Copy)]
enum AppCommand {
Reload,
EnterCommitMode,
EnterBrowseMode,
Rescan,
StageSelected,
StageAll,
UnstageSelected,
RevertSelected,
PerformDiscard,
SignOff,
Commit,
}
enum PendingDiscard {
Revert(String),
Delete(String),
}
pub struct GitClient {
backend: Rc<dyn RepoBackend>,
mode: Mode,
bounds: Rect,
browse_root: Shell,
search: Rc<RefCell<SearchBar>>,
commit_list: Rc<RefCell<CommitList>>,
file_list: Rc<RefCell<List>>,
diff_view: Rc<RefCell<DiffView>>,
commit_root: Shell,
unstaged_list: Rc<RefCell<List>>,
staged_list: Rc<RefCell<List>>,
unstaged_heading: Rc<RefCell<Heading>>,
staged_heading: Rc<RefCell<Heading>>,
commit_diff_view: Rc<RefCell<DiffView>>,
message_editor: Rc<RefCell<TextEditor>>,
amend_check: Rc<RefCell<Checkbox>>,
stage_btn: Rc<RefCell<Button>>,
unstage_btn: Rc<RefCell<Button>>,
rescan_btn: Rc<RefCell<Button>>,
narrow: bool,
dialog: Rc<RefCell<Dialog>>,
commands: Rc<RefCell<Vec<AppCommand>>>,
reopen: Option<ReopenFn>,
rows: Vec<RowRef>,
last_query: String,
log_working: WorkingStatus,
current_files: Vec<FileChange>,
shown: Option<RowRef>,
shown_file: Option<usize>,
working: WorkingStatus,
prev_unstaged_sel: Option<usize>,
prev_staged_sel: Option<usize>,
last_amend: bool,
pending_discard: Option<PendingDiscard>,
}
impl GitClient {
pub fn new(backend: Rc<dyn RepoBackend>) -> Self {
let dialog = Rc::new(RefCell::new(Dialog::new()));
let commands: Rc<RefCell<Vec<AppCommand>>> = Rc::new(RefCell::new(Vec::new()));
let search = Rc::new(RefCell::new(SearchBar::new(Rect::new(0, 0, 0, 0))));
let commit_list = Rc::new(RefCell::new(CommitList::new(Rect::new(0, 0, 0, 0))));
let file_list = Rc::new(RefCell::new(List::new(Rect::new(0, 0, 0, 0))));
let diff_view = Rc::new(RefCell::new(DiffView::new(Rect::new(0, 0, 0, 0))));
let browse_root = Shell::new()
.no_background()
.add(
build_browse_menu(commands.clone(), dialog.clone()),
layout::browse_menu,
)
.add(Shared::new(search.clone()), layout::browse_toolbar)
.add(Shared::new(commit_list.clone()), layout::browse_history)
.add(Shared::new(file_list.clone()), layout::browse_files)
.add(Shared::new(diff_view.clone()), layout::browse_diff)
.add_overlay(Shared::new(dialog.clone()));
let unstaged_list = Rc::new(RefCell::new(List::new(Rect::new(0, 0, 0, 0))));
let staged_list = Rc::new(RefCell::new(List::new(Rect::new(0, 0, 0, 0))));
let unstaged_heading = Rc::new(RefCell::new(Heading::new("Unstaged Changes")));
let staged_heading = Rc::new(RefCell::new(Heading::new("Staged Changes")));
let commit_diff_view = Rc::new(RefCell::new(DiffView::new(Rect::new(0, 0, 0, 0))));
let message_editor = Rc::new(RefCell::new(TextEditor::new(Rect::new(0, 0, 0, 0))));
let amend_check = Rc::new(RefCell::new(Checkbox::new(
Rect::new(0, 0, 0, 0),
"Amend last commit",
)));
let [stage_lbl, unstage_lbl, rescan_lbl] = left_btn_labels(false);
let stage_btn = Rc::new(RefCell::new(command_button(
stage_lbl,
&commands,
AppCommand::StageSelected,
)));
let unstage_btn = Rc::new(RefCell::new(command_button(
unstage_lbl,
&commands,
AppCommand::UnstageSelected,
)));
let rescan_btn = Rc::new(RefCell::new(command_button(
rescan_lbl,
&commands,
AppCommand::Rescan,
)));
let commit_root = Shell::new()
.no_background()
.add(
build_commit_menu(commands.clone(), dialog.clone()),
layout::commit_menu,
)
.add(
Shared::new(unstaged_heading.clone()),
layout::commit_unstaged_label,
)
.add(
Shared::new(unstaged_list.clone()),
layout::commit_unstaged_list,
)
.add(
Shared::new(staged_heading.clone()),
layout::commit_staged_label,
)
.add(Shared::new(staged_list.clone()), layout::commit_staged_list)
.add(Shared::new(stage_btn.clone()), layout::commit_stage_btn)
.add(Shared::new(unstage_btn.clone()), layout::commit_unstage_btn)
.add(Shared::new(rescan_btn.clone()), layout::commit_rescan_btn)
.add(Heading::new("Diff"), layout::commit_diff_label)
.add(Shared::new(commit_diff_view.clone()), layout::commit_diff)
.add(Heading::new("Commit Message"), layout::commit_msg_label)
.add(Shared::new(message_editor.clone()), layout::commit_editor)
.add(Shared::new(amend_check.clone()), layout::commit_amend)
.add(
command_button("Commit", &commands, AppCommand::Commit),
layout::commit_commit_btn,
)
.add_overlay(Shared::new(dialog.clone()));
let mut client = Self {
backend,
mode: Mode::Browse,
bounds: Rect::new(0, 0, 0, 0),
browse_root,
search,
commit_list,
file_list,
diff_view,
commit_root,
unstaged_list,
staged_list,
unstaged_heading,
staged_heading,
commit_diff_view,
message_editor,
amend_check,
stage_btn,
unstage_btn,
rescan_btn,
narrow: false,
dialog,
commands,
reopen: None,
rows: Vec::new(),
last_query: String::new(),
log_working: WorkingStatus::default(),
current_files: Vec::new(),
shown: None,
shown_file: None,
working: WorkingStatus::default(),
prev_unstaged_sel: None,
prev_staged_sel: None,
last_amend: false,
pending_discard: None,
};
client.sync_browse(true);
client
}
pub fn with_reopen(mut self, reopen: ReopenFn) -> Self {
self.reopen = Some(reopen);
self
}
pub fn enter_commit_mode(&mut self) {
self.set_mode(Mode::Commit);
}
fn active(&self) -> &Shell {
match self.mode {
Mode::Browse => &self.browse_root,
Mode::Commit => &self.commit_root,
}
}
fn active_mut(&mut self) -> &mut Shell {
match self.mode {
Mode::Browse => &mut self.browse_root,
Mode::Commit => &mut self.commit_root,
}
}
fn apply_narrow(&mut self, narrow: bool) {
if narrow == self.narrow {
return;
}
self.narrow = narrow;
let [stage, unstage, rescan] = left_btn_labels(narrow);
self.stage_btn.borrow_mut().label = stage.to_string();
self.unstage_btn.borrow_mut().label = unstage.to_string();
self.rescan_btn.borrow_mut().label = rescan.to_string();
}
fn set_mode(&mut self, mode: Mode) -> bool {
if self.mode == mode {
return false;
}
self.mode = mode;
match mode {
Mode::Commit => {
self.rescan();
self.commit_root.layout(self.bounds);
self.commit_root.focus_child(COMMIT_UNSTAGED_IDX);
}
Mode::Browse => {
self.browse_root.layout(self.bounds);
self.browse_root.focus_child(BROWSE_HISTORY_IDX);
}
}
true
}
fn drain_commands(&mut self) -> bool {
let pending: Vec<AppCommand> = self.commands.borrow_mut().drain(..).collect();
let mut changed = false;
for command in pending {
changed |= match command {
AppCommand::Reload => self.reload(),
AppCommand::EnterCommitMode => self.set_mode(Mode::Commit),
AppCommand::EnterBrowseMode => self.set_mode(Mode::Browse),
AppCommand::Rescan => {
self.rescan();
true
}
AppCommand::StageSelected => self.stage_selected(),
AppCommand::StageAll => self.stage_all(),
AppCommand::UnstageSelected => self.unstage_selected(),
AppCommand::RevertSelected => self.revert_selected(),
AppCommand::PerformDiscard => self.perform_discard(),
AppCommand::SignOff => self.sign_off(),
AppCommand::Commit => self.do_commit(),
};
}
changed
}
fn reload(&mut self) -> bool {
let Some(reopen) = &self.reopen else {
return false;
};
let Some(backend) = reopen() else {
self.dialog
.borrow_mut()
.show_error("Reload failed", "Could not re-open the repository.");
return true;
};
self.backend = backend;
self.shown = None;
self.shown_file = None;
self.last_query.clear();
self.search.borrow_mut().clear();
self.sync_browse(true);
self.rescan();
true
}
fn sync_browse(&mut self, force: bool) -> bool {
let mut changed = false;
let query = self.search.borrow().text().trim().to_lowercase();
if force || query != self.last_query {
self.last_query = query.clone();
self.rebuild_commits(&query);
self.shown = None;
changed = true;
}
let activated = self.commit_list.borrow_mut().take_activated();
if let Some(pos) = activated
&& matches!(self.rows.get(pos), Some(RowRef::Wip(_)))
{
self.set_mode(Mode::Commit);
return true;
}
let sel_pos = self.commit_list.borrow().selected_index();
let sel = sel_pos.and_then(|p| self.rows.get(p).copied());
if force || sel != self.shown {
self.shown = sel;
self.current_files = match sel {
Some(RowRef::Commit(idx)) => self.backend.changed_files(idx),
Some(RowRef::Wip(Side::Unstaged)) => self.log_working.unstaged.clone(),
Some(RowRef::Wip(Side::Staged)) => self.log_working.staged.clone(),
None => Vec::new(),
};
let items: Vec<ListItem> = self.current_files.iter().map(file_row).collect();
self.file_list.borrow_mut().set_items(items);
self.shown_file = None;
let diff = self.selection_diff(sel, None);
self.diff_view.borrow_mut().set_diff(diff);
changed = true;
}
let file_sel = self.file_list.borrow().selected_index();
if file_sel != self.shown_file {
self.shown_file = file_sel;
let diff = self.selection_diff(self.shown, file_sel);
self.diff_view.borrow_mut().set_diff(diff);
changed = true;
}
changed
}
fn selection_diff(&self, sel: Option<RowRef>, file_sel: Option<usize>) -> Diff {
match sel {
Some(RowRef::Commit(cidx)) => match file_sel.and_then(|f| self.current_files.get(f)) {
Some(file) => self.backend.file_diff(cidx, &file.path),
None => self.commit_detail(cidx),
},
Some(RowRef::Wip(side)) => {
let staged = matches!(side, Side::Staged);
match file_sel.and_then(|f| self.current_files.get(f)) {
Some(file) => self.backend.working_diff(&file.path, staged, false),
None => self.wip_overview_diff(staged),
}
}
None => Diff::default(),
}
}
fn wip_overview_diff(&self, staged: bool) -> Diff {
let mut lines = Vec::new();
for file in &self.current_files {
lines.extend(self.backend.working_diff(&file.path, staged, false).lines);
}
Diff { lines }
}
fn rebuild_commits(&mut self, query: &str) {
self.log_working = if query.is_empty() {
self.backend.working_status(false)
} else {
WorkingStatus::default()
};
let show_unstaged = !self.log_working.unstaged.is_empty();
let show_staged = !self.log_working.staged.is_empty();
let commits = self.backend.commits();
let commit_rows: Vec<usize> = (0..commits.len())
.filter(|&i| query.is_empty() || commit_matches(&commits[i], query))
.collect();
let mut row_refs: Vec<RowRef> = Vec::new();
let mut display: Vec<CommitRow> = Vec::new();
if show_unstaged {
row_refs.push(RowRef::Wip(Side::Unstaged));
display.push(wip_row(Side::Unstaged, self.log_working.unstaged.len()));
}
if show_staged {
row_refs.push(RowRef::Wip(Side::Staged));
display.push(wip_row(Side::Staged, self.log_working.staged.len()));
}
for &i in &commit_rows {
row_refs.push(RowRef::Commit(i));
display.push(commit_row(&commits[i]));
}
let graph = if query.is_empty() {
let head_id = head_commit_id(commits);
let mut dag: Vec<(String, Vec<String>)> = Vec::new();
if show_unstaged {
let parent = if show_staged {
vec![WIP_STAGED_ID.to_string()]
} else {
head_id.clone().into_iter().collect()
};
dag.push((WIP_UNSTAGED_ID.to_string(), parent));
}
if show_staged {
dag.push((WIP_STAGED_ID.to_string(), head_id.into_iter().collect()));
}
for &i in &commit_rows {
dag.push((commits[i].id.clone(), commits[i].parents.clone()));
}
Some(compute_graph(&dag))
} else {
None
};
self.rows = row_refs;
let new_pos = self
.shown
.and_then(|s| self.rows.iter().position(|&r| r == s))
.or_else(|| {
self.rows
.iter()
.position(|r| matches!(r, RowRef::Commit(_)))
})
.or(if self.rows.is_empty() { None } else { Some(0) });
let mut list = self.commit_list.borrow_mut();
list.set_rows(display);
list.set_graph(graph);
list.set_selected(new_pos);
}
fn commit_detail(&self, idx: usize) -> Diff {
let Some(commit) = self.backend.commits().get(idx) else {
return Diff::default();
};
let mut lines = Vec::new();
let header = |lines: &mut Vec<DiffLine>, text: String| {
lines.push(DiffLine::new(DiffLineKind::CommitHeader, text));
};
let blank = |lines: &mut Vec<DiffLine>| {
lines.push(DiffLine::new(DiffLineKind::Context, String::new()));
};
header(&mut lines, format!("commit {}", commit.id));
if !commit.refs.is_empty() {
let names: Vec<&str> = commit.refs.iter().map(|r| r.name.as_str()).collect();
header(&mut lines, format!("Refs: {}", names.join(", ")));
}
header(
&mut lines,
format!("Author: {} <{}>", commit.author_name, commit.author_email),
);
header(&mut lines, format!("Date: {}", commit.date_string()));
if commit.is_merge() {
let shorts: Vec<String> = commit.parents.iter().map(|p| short(p)).collect();
header(&mut lines, format!("Merge: {}", shorts.join(" ")));
}
blank(&mut lines);
for line in commit.message.trim_end().lines() {
lines.push(DiffLine::new(DiffLineKind::Context, format!(" {line}")));
}
blank(&mut lines);
lines.extend(self.backend.commit_diff(idx).lines);
Diff { lines }
}
fn rescan(&mut self) {
self.rescan_selecting(None);
}
fn rescan_selecting(&mut self, prefer: Option<(Side, String)>) {
let amend = self.amend_check.borrow().is_checked();
self.working = self.backend.working_status(amend);
let unstaged: Vec<ListItem> = self.working.unstaged.iter().map(file_row).collect();
let staged: Vec<ListItem> = self.working.staged.iter().map(file_row).collect();
self.unstaged_list.borrow_mut().set_items(unstaged);
self.staged_list.borrow_mut().set_items(staged);
self.unstaged_heading.borrow_mut().set_text(format!(
"Unstaged Changes ({})",
self.working.unstaged.len()
));
self.staged_heading
.borrow_mut()
.set_text(format!("Staged Changes ({})", self.working.staged.len()));
self.prev_unstaged_sel = None;
self.prev_staged_sel = None;
{
let mut view = self.commit_diff_view.borrow_mut();
view.set_mode(DiffMode::Plain);
view.set_diff(Diff::default());
}
let target = prefer.and_then(|(side, path)| {
let files = match side {
Side::Unstaged => &self.working.unstaged,
Side::Staged => &self.working.staged,
};
files.iter().position(|f| f.path == path).map(|i| (side, i))
});
match target {
Some((side, i)) => self.apply_commit_selection(side, i),
None if !self.working.unstaged.is_empty() => {
self.apply_commit_selection(Side::Unstaged, 0)
}
None if !self.working.staged.is_empty() => self.apply_commit_selection(Side::Staged, 0),
None => {}
}
}
fn apply_commit_selection(&mut self, side: Side, i: usize) {
match side {
Side::Unstaged => {
self.unstaged_list.borrow_mut().set_selected(Some(i));
self.staged_list.borrow_mut().set_selected(None);
}
Side::Staged => {
self.staged_list.borrow_mut().set_selected(Some(i));
self.unstaged_list.borrow_mut().set_selected(None);
}
}
self.prev_unstaged_sel = self.unstaged_list.borrow().selected_index();
self.prev_staged_sel = self.staged_list.borrow().selected_index();
let staged = matches!(side, Side::Staged);
let amend = self.amend_check.borrow().is_checked();
let files = match side {
Side::Unstaged => &self.working.unstaged,
Side::Staged => &self.working.staged,
};
let diff = files
.get(i)
.map(|f| self.backend.working_diff(&f.path, staged, amend))
.unwrap_or_default();
let mode = match side {
Side::Unstaged => DiffMode::Stage,
Side::Staged => DiffMode::Unstage,
};
let mut view = self.commit_diff_view.borrow_mut();
view.set_mode(mode);
view.set_diff(diff);
}
fn sync_commit(&mut self) -> bool {
let action = self.commit_diff_view.borrow_mut().take_action();
if let Some((lo, hi)) = action {
return self.apply_partial(lo, hi);
}
let unstaged_activated = self.unstaged_list.borrow_mut().take_activated();
if let Some(i) = unstaged_activated {
self.stage_index(i);
return true;
}
let staged_activated = self.staged_list.borrow_mut().take_activated();
if let Some(i) = staged_activated {
self.unstage_index(i);
return true;
}
let u = self.unstaged_list.borrow().selected_index();
let s = self.staged_list.borrow().selected_index();
if let Some(i) = u
&& self.prev_unstaged_sel != Some(i)
{
self.apply_commit_selection(Side::Unstaged, i);
return true;
}
if let Some(i) = s
&& self.prev_staged_sel != Some(i)
{
self.apply_commit_selection(Side::Staged, i);
return true;
}
self.prev_unstaged_sel = u;
self.prev_staged_sel = s;
let amend = self.amend_check.borrow().is_checked();
if amend != self.last_amend {
self.last_amend = amend;
if amend
&& self.message_editor.borrow().text().trim().is_empty()
&& let Some(msg) = self.backend.head_message()
{
self.message_editor.borrow_mut().set_text(msg.trim_end());
}
self.rescan();
return true;
}
false
}
fn stage_selected(&mut self) -> bool {
let sel = self.unstaged_list.borrow().selected_index();
match sel {
Some(i) => {
self.stage_index(i);
true
}
None => false,
}
}
fn stage_all(&mut self) -> bool {
if self.working.unstaged.is_empty() {
return false;
}
let paths: Vec<String> = self
.working
.unstaged
.iter()
.map(|f| f.path.clone())
.collect();
for path in paths {
if let Err(e) = self.backend.stage(&path) {
self.dialog.borrow_mut().show_error("Stage failed", &e);
break;
}
}
self.rescan();
true
}
fn sign_off(&mut self) -> bool {
let Some((name, email)) = self.backend.signature() else {
self.dialog.borrow_mut().show_error(
"Sign off",
"No git identity configured. Set user.name and user.email.",
);
return true;
};
let body = self.message_editor.borrow().text();
match with_signoff(&body, &name, &email) {
Some(text) => {
self.message_editor.borrow_mut().set_text(&text);
true
}
None => false,
}
}
fn unstage_selected(&mut self) -> bool {
let sel = self.staged_list.borrow().selected_index();
match sel {
Some(i) => {
self.unstage_index(i);
true
}
None => false,
}
}
fn stage_index(&mut self, i: usize) {
if let Some(file) = self.working.unstaged.get(i) {
let path = file.path.clone();
if let Err(e) = self.backend.stage(&path) {
self.dialog.borrow_mut().show_error("Stage failed", &e);
}
}
self.rescan();
}
fn unstage_index(&mut self, i: usize) {
if let Some(file) = self.working.staged.get(i) {
let path = file.path.clone();
let amend = self.amend_check.borrow().is_checked();
if let Err(e) = self.backend.unstage(&path, amend) {
self.dialog.borrow_mut().show_error("Unstage failed", &e);
}
}
self.rescan();
}
fn current_commit_target(&self) -> Option<(Side, usize)> {
if let Some(i) = self.unstaged_list.borrow().selected_index() {
Some((Side::Unstaged, i))
} else {
self.staged_list
.borrow()
.selected_index()
.map(|i| (Side::Staged, i))
}
}
fn apply_partial(&mut self, lo: usize, hi: usize) -> bool {
let Some((side, i)) = self.current_commit_target() else {
return false;
};
let staged = matches!(side, Side::Staged);
let files = match side {
Side::Unstaged => &self.working.unstaged,
Side::Staged => &self.working.staged,
};
let Some(path) = files.get(i).map(|f| f.path.clone()) else {
return false;
};
let amend = self.amend_check.borrow().is_checked();
let diff = self.backend.working_diff(&path, staged, amend);
let mode = if staged {
PartialMode::Unstage
} else {
PartialMode::Stage
};
let selected: BTreeSet<usize> = (lo..=hi).collect();
let Some(patch) = build_partial_patch(&diff, &selected, mode) else {
return false;
};
if let Err(e) = self.backend.apply_to_index(&patch) {
let title = if staged {
"Unstage failed"
} else {
"Stage failed"
};
self.dialog.borrow_mut().show_error(title, &e);
}
self.rescan_selecting(Some((side, path)));
true
}
fn revert_selected(&mut self) -> bool {
let Some(i) = self.unstaged_list.borrow().selected_index() else {
return false;
};
let Some(file) = self.working.unstaged.get(i) else {
return false;
};
let display = file.display();
let path = file.path.clone();
let (title, message, affirm) = if file.status == ChangeStatus::Untracked {
self.pending_discard = Some(PendingDiscard::Delete(path));
(
"Delete File",
format!(
"Delete untracked file\n{display}?\n\nIt is not tracked by git and cannot be recovered."
),
"Delete File",
)
} else {
self.pending_discard = Some(PendingDiscard::Revert(path));
(
"Revert Changes",
format!(
"Revert unstaged changes in\n{display}?\n\nThese changes will be permanently lost."
),
"Revert Changes",
)
};
let commands = self.commands.clone();
self.dialog
.borrow_mut()
.show_confirm(title, message, affirm, move |cx| {
commands.borrow_mut().push(AppCommand::PerformDiscard);
cx.request_paint();
});
true
}
fn perform_discard(&mut self) -> bool {
let (failure, result) = match self.pending_discard.take() {
Some(PendingDiscard::Revert(path)) => ("Revert failed", self.backend.revert(&path)),
Some(PendingDiscard::Delete(path)) => {
("Delete failed", self.backend.delete_untracked(&path))
}
None => return false,
};
if let Err(e) = result {
self.dialog.borrow_mut().show_error(failure, &e);
}
self.rescan();
true
}
fn do_commit(&mut self) -> bool {
let amend = self.amend_check.borrow().is_checked();
let message = self.message_editor.borrow().text();
if self.working.staged.is_empty() && !amend {
self.dialog.borrow_mut().show_error(
"Nothing to commit",
"Stage some changes first, or enable \u{201C}Amend last commit\u{201D}.",
);
return true;
}
match self.backend.commit(&message, amend) {
Ok(()) => {
self.message_editor.borrow_mut().set_text("");
self.amend_check.borrow_mut().set_checked(false);
self.last_amend = false;
if !self.reload() {
self.shown = None;
self.sync_browse(true);
self.rescan();
}
self.set_mode(Mode::Browse);
}
Err(e) => {
self.dialog.borrow_mut().show_error("Commit failed", &e);
}
}
true
}
fn handle_shortcut(&mut self, event: &Event, ctx: &mut EventCtx) -> bool {
if self.dialog.borrow().is_open() {
return false;
}
let Event::KeyDown { key, modifiers } = event else {
return false;
};
if !modifiers.control || modifiers.alt || modifiers.logo {
return false;
}
let letter = match key {
Key::Char(c) => Some(c.to_ascii_lowercase()),
_ => None,
};
if letter == Some('q') {
ctx.close();
return true;
}
if self.mode != Mode::Commit {
return false;
}
let command = if matches!(key, Key::Named(NamedKey::Enter)) {
AppCommand::Commit
} else {
match letter {
Some('r') => AppCommand::Rescan,
Some('t') => AppCommand::StageSelected,
Some('i') => AppCommand::StageAll,
Some('j') => AppCommand::RevertSelected,
Some('s') => AppCommand::SignOff,
_ => return false,
}
};
self.commands.borrow_mut().push(command);
true
}
}
impl Widget for GitClient {
fn bounds(&self) -> Rect {
self.bounds
}
fn paint(&mut self, painter: &mut Painter, theme: &Theme) {
self.active_mut().paint(painter, theme);
}
fn paint_overlay(&mut self, painter: &mut Painter, theme: &Theme) {
self.active_mut().paint_overlay(painter, theme);
}
fn event(&mut self, event: &Event, ctx: &mut EventCtx) {
if !self.handle_shortcut(event, ctx) {
self.active_mut().event(event, ctx);
}
let mut dirty = self.drain_commands();
dirty |= match self.mode {
Mode::Browse => self.sync_browse(false),
Mode::Commit => self.sync_commit(),
};
if dirty {
ctx.request_paint();
}
}
fn captures_pointer(&self) -> bool {
self.active().captures_pointer()
}
fn focusable(&self) -> bool {
self.active().focusable()
}
fn set_focused(&mut self, focused: bool) {
self.active_mut().set_focused(focused);
}
fn layout(&mut self, bounds: Rect) {
self.bounds = bounds;
self.apply_narrow(bounds.w <= layout::NARROW_W);
self.browse_root.layout(bounds);
self.commit_root.layout(bounds);
}
fn focus_first(&mut self) -> bool {
match self.mode {
Mode::Browse => self.browse_root.focus_child(BROWSE_HISTORY_IDX),
Mode::Commit => self.commit_root.focus_child(COMMIT_UNSTAGED_IDX),
}
}
fn popup_request(&self) -> Option<PopupRequest> {
self.active().popup_request()
}
fn wants_ticks(&self) -> bool {
self.active().wants_ticks()
}
}
fn build_browse_menu(
commands: Rc<RefCell<Vec<AppCommand>>>,
dialog: Rc<RefCell<Dialog>>,
) -> MenuBar {
MenuBar::new(Rect::new(0, 0, 0, 0))
.add_menu(Menu::new(
"&File",
vec![
cmd_item("&Reload", &commands, AppCommand::Reload),
MenuItem::separator(),
MenuItem::action("E&xit", |cx| cx.close()).with_accel("Ctrl+Q"),
],
))
.add_menu(Menu::new(
"&View",
vec![cmd_item(
"&Commit Changes",
&commands,
AppCommand::EnterCommitMode,
)],
))
.add_menu(Menu::new("&Help", vec![about_item(&dialog)]))
}
fn build_commit_menu(
commands: Rc<RefCell<Vec<AppCommand>>>,
dialog: Rc<RefCell<Dialog>>,
) -> MenuBar {
MenuBar::new(Rect::new(0, 0, 0, 0))
.add_menu(Menu::new(
"&File",
vec![
cmd_item("&Reload", &commands, AppCommand::Reload),
MenuItem::separator(),
MenuItem::action("E&xit", |cx| cx.close()).with_accel("Ctrl+Q"),
],
))
.add_menu(Menu::new(
"&Commit",
vec![
cmd_item("&Rescan", &commands, AppCommand::Rescan).with_accel("Ctrl+R"),
MenuItem::separator(),
cmd_item("&Stage Selected", &commands, AppCommand::StageSelected)
.with_accel("Ctrl+T"),
cmd_item("Stage &All", &commands, AppCommand::StageAll).with_accel("Ctrl+I"),
cmd_item("&Unstage Selected", &commands, AppCommand::UnstageSelected),
cmd_item("Re&vert Changes", &commands, AppCommand::RevertSelected)
.with_accel("Ctrl+J"),
MenuItem::separator(),
cmd_item("Sign &Off", &commands, AppCommand::SignOff).with_accel("Ctrl+S"),
cmd_item("&Commit", &commands, AppCommand::Commit).with_accel("Ctrl+Enter"),
],
))
.add_menu(Menu::new(
"&View",
vec![cmd_item(
"&Browse History",
&commands,
AppCommand::EnterBrowseMode,
)],
))
.add_menu(Menu::new("&Help", vec![about_item(&dialog)]))
}
fn cmd_item(label: &str, commands: &Rc<RefCell<Vec<AppCommand>>>, command: AppCommand) -> MenuItem {
let commands = commands.clone();
MenuItem::action(label, move |cx| {
commands.borrow_mut().push(command);
cx.request_paint();
})
}
fn about_item(dialog: &Rc<RefCell<Dialog>>) -> MenuItem {
let dialog = dialog.clone();
MenuItem::action("&About", move |cx| {
dialog.borrow_mut().show_info(
"About Git Journey",
"Git Journey\n\nA gitk-style repository browser\nbuilt on the Saudade toolkit.",
);
cx.request_paint();
})
}
fn left_btn_labels(narrow: bool) -> [&'static str; 3] {
if narrow {
["\u{21A7}", "\u{21A5}", "\u{21BB}"]
} else {
["\u{21A7} Stage", "\u{21A5} Unstage", "\u{21BB} Rescan"]
}
}
fn command_button(
label: &str,
commands: &Rc<RefCell<Vec<AppCommand>>>,
command: AppCommand,
) -> Button {
let commands = commands.clone();
Button::new(Rect::new(0, 0, 0, 0), label).on_click(move |cx| {
commands.borrow_mut().push(command);
cx.request_paint();
})
}
fn short(sha: &str) -> String {
sha.chars().take(8).collect()
}
fn with_signoff(body: &str, name: &str, email: &str) -> Option<String> {
let trailer = format!("Signed-off-by: {name} <{email}>");
let last_line = body.lines().next_back().unwrap_or("").trim_end();
if last_line.eq_ignore_ascii_case(&trailer) {
return None;
}
let trimmed = body.trim_end();
Some(if trimmed.is_empty() {
trailer
} else if is_trailer_line(last_line) {
format!("{trimmed}\n{trailer}")
} else {
format!("{trimmed}\n\n{trailer}")
})
}
fn is_trailer_line(line: &str) -> bool {
let Some((key, _)) = line.split_once(':') else {
return false;
};
let key = key.to_ascii_lowercase();
key.ends_with("-by") && key.chars().all(|c| c.is_ascii_alphabetic() || c == '-')
}
fn wip_row(side: Side, count: usize) -> CommitRow {
let summary = match side {
Side::Unstaged => format!("Uncommitted changes ({count})"),
Side::Staged => format!("Staged changes ({count})"),
};
CommitRow {
summary,
..Default::default()
}
}
fn head_commit_id(commits: &[CommitInfo]) -> Option<String> {
commits
.iter()
.find(|c| {
c.refs
.iter()
.any(|r| matches!(r.kind, RefKind::Head | RefKind::DetachedHead))
})
.or_else(|| commits.first())
.map(|c| c.id.clone())
}
fn commit_matches(commit: &CommitInfo, query: &str) -> bool {
commit.summary.to_lowercase().contains(query)
|| commit.message.to_lowercase().contains(query)
|| commit.author_name.to_lowercase().contains(query)
|| commit.author_email.to_lowercase().contains(query)
|| commit.id.contains(query)
|| commit
.refs
.iter()
.any(|r| r.name.to_lowercase().contains(query))
}
pub fn commit_row(commit: &CommitInfo) -> CommitRow {
CommitRow {
id: commit.id.clone(),
parents: commit.parents.clone(),
summary: commit.summary.clone(),
refs: commit.refs.clone(),
author: commit.author_name.clone(),
date: commit.short_date_string(),
}
}
pub fn file_row(file: &FileChange) -> ListItem {
ListItem::new(file.display()).with_svg_icon(status_icon(file.status))
}
fn status_icon(status: ChangeStatus) -> SvgImage {
const ADDED: SvgImage = include_svg!("assets/status/added.svg");
const MODIFIED: SvgImage = include_svg!("assets/status/modified.svg");
const DELETED: SvgImage = include_svg!("assets/status/deleted.svg");
const RENAMED: SvgImage = include_svg!("assets/status/renamed.svg");
const COPIED: SvgImage = include_svg!("assets/status/copied.svg");
const TYPECHANGE: SvgImage = include_svg!("assets/status/typechange.svg");
const UNKNOWN: SvgImage = include_svg!("assets/status/unknown.svg");
match status {
ChangeStatus::Added => ADDED,
ChangeStatus::Modified => MODIFIED,
ChangeStatus::Deleted => DELETED,
ChangeStatus::Renamed => RENAMED,
ChangeStatus::Copied => COPIED,
ChangeStatus::TypeChange => TYPECHANGE,
ChangeStatus::Untracked | ChangeStatus::Other => UNKNOWN,
}
}
#[cfg(test)]
mod tests {
use super::{is_trailer_line, with_signoff};
const NAME: &str = "Ada Lovelace";
const EMAIL: &str = "ada@example.com";
const SOB: &str = "Signed-off-by: Ada Lovelace <ada@example.com>";
#[test]
fn signoff_into_empty_message_is_just_the_trailer() {
assert_eq!(with_signoff("", NAME, EMAIL).as_deref(), Some(SOB));
assert_eq!(with_signoff(" \n", NAME, EMAIL).as_deref(), Some(SOB));
}
#[test]
fn signoff_after_prose_gets_a_blank_separator_line() {
assert_eq!(
with_signoff("Fix the thing", NAME, EMAIL).as_deref(),
Some(format!("Fix the thing\n\n{SOB}").as_str())
);
}
#[test]
fn signoff_after_a_trailer_block_stays_tight() {
let body = "Fix the thing\n\nReviewed-by: B <b@example.com>";
assert_eq!(
with_signoff(body, NAME, EMAIL).as_deref(),
Some(format!("{body}\n{SOB}").as_str())
);
}
#[test]
fn signoff_is_idempotent_when_already_last_line() {
let body = format!("Fix the thing\n\n{SOB}");
assert_eq!(with_signoff(&body, NAME, EMAIL), None);
}
#[test]
fn trailer_lines_are_recognized() {
assert!(is_trailer_line("Signed-off-by: A <a@x>"));
assert!(is_trailer_line("Reviewed-by: B <b@x>"));
assert!(is_trailer_line("Co-authored-by: C <c@x>"));
assert!(!is_trailer_line("Just a normal sentence."));
assert!(!is_trailer_line("Fixes: #123"));
assert!(!is_trailer_line(""));
}
}
#[cfg(test)]
mod commit_focus_tests {
use super::*;
use crate::backend::{Git2Backend, is_change_line};
use std::time::{SystemTime, UNIX_EPOCH};
fn two_dirty_files() -> (std::path::PathBuf, Git2Backend) {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir =
std::env::temp_dir().join(format!("journey-focus-{}-{nanos}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let repo = git2::Repository::init(&dir).unwrap();
let sig =
git2::Signature::new("T", "t@example.com", &git2::Time::new(1_700_000_000, 0)).unwrap();
let base: String = (1..=20).map(|n| format!("l{n:02}\n")).collect();
for name in ["a.txt", "b.txt"] {
std::fs::write(dir.join(name), &base).unwrap();
}
{
let mut index = repo.index().unwrap();
index.add_path(std::path::Path::new("a.txt")).unwrap();
index.add_path(std::path::Path::new("b.txt")).unwrap();
index.write().unwrap();
let tree = repo.find_tree(index.write_tree().unwrap()).unwrap();
repo.commit(Some("HEAD"), &sig, &sig, "base\n", &tree, &[])
.unwrap();
}
let edited = base
.replace("l02\n", "l02-edited\n")
.replace("l18\n", "l18-edited\n");
for name in ["a.txt", "b.txt"] {
std::fs::write(dir.join(name), &edited).unwrap();
}
let backend = Git2Backend::open(dir.to_str().unwrap()).unwrap();
(dir, backend)
}
#[test]
fn partial_stage_keeps_the_same_file_focused() {
let (dir, backend) = two_dirty_files();
let mut client = GitClient::new(Rc::new(backend));
client.enter_commit_mode();
let b = client
.working
.unstaged
.iter()
.position(|f| f.path == "b.txt")
.expect("b.txt is unstaged");
assert_ne!(b, 0, "b.txt must not already be the first row");
client.apply_commit_selection(Side::Unstaged, b);
let diff = client.backend.working_diff("b.txt", false, false);
let rows: Vec<usize> = diff
.lines
.iter()
.enumerate()
.filter(|(_, l)| is_change_line(l.kind) && l.text.contains("l02"))
.map(|(i, _)| i)
.collect();
let (lo, hi) = (rows[0], *rows.last().unwrap());
assert!(client.apply_partial(lo, hi));
assert!(client.working.staged.iter().any(|f| f.path == "b.txt"));
let still = client
.working
.unstaged
.iter()
.position(|f| f.path == "b.txt")
.expect("b.txt still has unstaged changes");
assert_eq!(
client.unstaged_list.borrow().selected_index(),
Some(still),
"the partially-staged file stays focused"
);
assert_eq!(client.staged_list.borrow().selected_index(), None);
std::fs::remove_dir_all(&dir).ok();
}
}