use std::cell::RefCell;
use std::rc::Rc;
use saudade::{
Button, Checkbox, Dialog, Event, EventCtx, Key, List, ListItem, Menu, MenuBar, MenuItem,
NamedKey, Painter, PopupRequest, Rect, TextEditor, Theme, Widget,
};
use crate::backend::{
ChangeStatus, CommitInfo, Diff, DiffLine, DiffLineKind, FileChange, RefKind, RepoBackend,
WorkingStatus,
};
use crate::widgets::{
CommitList, CommitRow, 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>>,
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 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(
command_button("Stage \u{2192}", &commands, AppCommand::StageSelected),
layout::commit_stage_btn,
)
.add(
command_button("\u{2190} Unstage", &commands, AppCommand::UnstageSelected),
layout::commit_unstage_btn,
)
.add(
command_button("Rescan", &commands, AppCommand::Rescan),
layout::commit_rescan_btn,
)
.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,
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 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) {
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 — will commit ({})",
self.working.staged.len()
));
self.prev_unstaged_sel = None;
self.prev_staged_sel = None;
self.commit_diff_view.borrow_mut().set_diff(Diff::default());
if !self.working.unstaged.is_empty() {
self.apply_commit_selection(Side::Unstaged, 0);
} else if !self.working.staged.is_empty() {
self.apply_commit_selection(Side::Staged, 0);
}
}
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();
self.commit_diff_view.borrow_mut().set_diff(diff);
}
fn sync_commit(&mut self) -> bool {
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 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.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 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(format!("{} {}", file.status.badge(), file.display()))
}
#[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(""));
}
}