Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ with that one.
coloured lanes, branch and tag labels and the uncommitted changes on top. Arrow keys walk it.
- **Commits**: message, author, parents and the files they changed, with the diff of each file.
- **Staging**: stage or unstage files, whole hunks or single lines, then commit or amend.
- **Discard**: throw away the changes to a file, a hunk or single lines, after you confirm.
- **Branches and tags**: create, check out, merge, rebase, delete, from the sidebar or a right click.
- **Commits from the graph**: check out, cherry-pick, revert, reset (soft, mixed, hard), copy the SHA.
- **Remotes**: fetch, pull and push, with how far ahead and behind you are.
Expand Down
30 changes: 15 additions & 15 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,27 +22,27 @@ drive it the way a user does, and keeps every benchmark as fast as before.
- [x] Clone and init from the start screen, with progress
- [x] Tabs: several repositories open at once, remembered between runs
- [x] Live refresh: watch the working tree and refs, and reload by itself
- [x] Discard changes per file, hunk or line, after a confirmation

## Next

1. **Discard** changes per file, hunk or line.
2. **Confirm destructive actions**: hard reset, deleting a branch with unmerged commits, force push.
3. **Undo and redo** of the last action, through the reflog.
4. **Conflicts**: list conflicted files, resolve them in a three-way view (ours, theirs, result),
1. **Confirm destructive actions**: hard reset, deleting a branch with unmerged commits, force push.
2. **Undo and redo** of the last action, through the reflog.
3. **Conflicts**: list conflicted files, resolve them in a three-way view (ours, theirs, result),
mark them resolved, and continue or abort a merge, rebase or cherry-pick.
5. **Interactive rebase**: reorder, squash, fix up, reword and drop commits, by dragging in the
4. **Interactive rebase**: reorder, squash, fix up, reword and drop commits, by dragging in the
graph.
6. **Credentials**: ask for a username, password or passphrase when git needs one.
7. **Push and pull options**: force with lease, push tags, pick the remote, pull with rebase.
8. **Remotes**: add, edit and remove.
9. **Search the graph** by message, author or SHA, and jump to any branch or tag.
10. **File history and blame.**
11. **Better diffs**: side by side, word-level changes, syntax colouring, images.
12. **Keyboard**: shortcuts for everything, and a command palette.
13. **Settings**: light and dark theme, font size, date format, default pull mode.
14. **Graph polish**: resizable columns, compact lanes, author avatars (optional, never by default
5. **Credentials**: ask for a username, password or passphrase when git needs one.
6. **Push and pull options**: force with lease, push tags, pick the remote, pull with rebase.
7. **Remotes**: add, edit and remove.
8. **Search the graph** by message, author or SHA, and jump to any branch or tag.
9. **File history and blame.**
10. **Better diffs**: side by side, word-level changes, syntax colouring, images.
11. **Keyboard**: shortcuts for everything, and a command palette.
12. **Settings**: light and dark theme, font size, date format, default pull mode.
13. **Graph polish**: resizable columns, compact lanes, author avatars (optional, never by default
over the network).
15. **Packaging**: installers with the icon for Windows (MSI), macOS (.app in a .dmg) and Linux
14. **Packaging**: installers with the icon for Windows (MSI), macOS (.app in a .dmg) and Linux
(AppImage, .deb, Flatpak), signed where the platform expects it.

## After 1.0
Expand Down
25 changes: 25 additions & 0 deletions crates/app/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ pub enum Action {
ToggleLine(usize, usize),
/// Ask for a name first.
Prompt(Prompt),
/// Ask whether to go ahead first.
Confirm(Confirm),
/// Open a repository in a tab of its own, or show its tab if it is open.
OpenRepo(PathBuf),
/// Show a tab, or the start screen for `None`.
Expand Down Expand Up @@ -80,6 +82,17 @@ impl Prompt {
}
}

/// An operation that throws work away, waiting for the user to go ahead.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Confirm {
/// What will be lost, as a question.
pub question: String,
/// The button that goes ahead.
pub verb: &'static str,
/// The operation.
pub op: Op,
}

/// Everything read in the background after a change.
#[derive(Debug, Default)]
pub struct Snapshot {
Expand Down Expand Up @@ -158,6 +171,8 @@ pub struct Workspace {
pub amend: bool,
/// A name being asked for.
pub prompt: Option<(Prompt, String)>,
/// An operation waiting for the user to go ahead.
pub confirm: Option<Confirm>,
/// The last thing that went wrong.
pub error: Option<String>,
/// A graph row to scroll to.
Expand Down Expand Up @@ -187,6 +202,7 @@ impl Workspace {
description: String::new(),
amend: false,
prompt: None,
confirm: None,
error: None,
scroll_to: None,
visible: 0..0,
Expand Down Expand Up @@ -248,6 +264,7 @@ impl Workspace {
}
}
Action::Prompt(prompt) => self.prompt = Some((prompt, String::new())),
Action::Confirm(confirm) => self.confirm = Some(confirm),
Action::OpenRepo(_) | Action::ShowTab(_) | Action::CloseTab(_) => unreachable!("the app handles tabs"),
}
}
Expand Down Expand Up @@ -340,7 +357,15 @@ impl Workspace {
}
}
if let Some(file) = &mut self.open_file {
// A file in the working tree without changes left, or gone altogether, closes.
let entries = &self.snapshot.status.entries;
if !matches!(file.source, Source::Commit { .. }) && !entries.iter().any(|e| e.path == file.source.path()) {
self.open_file = None;
return;
}
// The picked lines stay picked as long as the diff stays the same.
match diff::read(&self.repo.git, &file.source) {
Ok(diff) if diff == file.diff => {}
Ok(diff) => (file.diff, file.lines) = (diff, BTreeSet::new()),
Err(error) => self.error = Some(error.to_string()),
}
Expand Down
54 changes: 38 additions & 16 deletions crates/app/src/details.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
//! The panel on the right: the uncommitted changes with the commit form, or a commit.

use eframe::egui::{self, Button, Layout, RichText, TextEdit, Ui};
use eframe::egui::{self, Button, Layout, RichText, Role, TextEdit, Ui, WidgetInfo};
use gitcraft_core::ObjectId;
use gitcraft_core::diff::Source;
use gitcraft_core::git::Op;
use gitcraft_core::status::Change;

use crate::app::{Action, Selection, Workspace};
use crate::app::{Action, Confirm, Selection, Workspace};
use crate::theme;

/// Shows the panel.
Expand All @@ -20,16 +20,16 @@ pub fn show(ui: &mut Ui, ws: &mut Workspace, actions: &mut Vec<Action>) {
}
}

/// One changed file: its kind of change, its path, and a button.
fn file(ui: &mut Ui, change: Change, path: &str, open: bool, button: Option<&str>) -> (bool, bool) {
/// One changed file: its kind of change, its path, and `buttons` on the right, right to left.
/// Returns whether the path was clicked.
fn file(ui: &mut Ui, change: Change, path: &str, open: bool, buttons: impl FnOnce(&mut Ui)) -> bool {
let (letter, colour) = theme::change(change);
ui.horizontal(|ui| {
ui.with_layout(Layout::right_to_left(egui::Align::Center), |ui| {
let pressed = button.is_some_and(|text| ui.small_button(text).clicked());
buttons(ui);
ui.with_layout(Layout::left_to_right(egui::Align::Center), |ui| {
ui.label(RichText::new(letter).monospace().strong().color(colour));
let clicked = ui.add(Button::selectable(open, path).truncate()).clicked();
(clicked, pressed)
ui.add(Button::selectable(open, path).truncate()).clicked()
})
.inner
})
Expand All @@ -38,6 +38,16 @@ fn file(ui: &mut Ui, change: Change, path: &str, open: bool, button: Option<&str
.inner
}

/// Throwing away the unstaged changes to a file, or the whole file when git does not track it.
fn discard(change: Change, path: &str) -> Confirm {
if change == Change::Untracked {
let question = format!("Delete {path}? Git does not track it.");
return Confirm { question, verb: "Delete", op: Op::DeleteUntracked(vec![path.into()]) };
}
let question = format!("Discard the unstaged changes to {path}?");
Confirm { question, verb: "Discard", op: Op::Discard(vec![path.into()]) }
}

fn changes(ui: &mut Ui, ws: &mut Workspace, actions: &mut Vec<Action>) {
let open = ws.open_file.as_ref().map(|f| &f.source);
let entries = &ws.snapshot.status.entries;
Expand All @@ -59,10 +69,20 @@ fn changes(ui: &mut Ui, ws: &mut Workspace, actions: &mut Vec<Action>) {
} else {
Source::Unstaged(path.into())
};
let (clicked, stage) = file(ui, change, path, open == Some(&source), Some("Stage"));
if stage {
actions.push(Action::Run(Op::Stage(vec![path.into()])));
} else if clicked {
let clicked = file(ui, change, path, open == Some(&source), |ui| {
if ui.small_button("Stage").clicked() {
actions.push(Action::Run(Op::Stage(vec![path.into()])));
}
// Conflicts are resolved, not discarded.
if change != Change::Conflicted {
let button = ui.small_button("Discard");
button.widget_info(|| WidgetInfo::labeled(Role::Button, true, format!("Discard {path}")));
if button.clicked() {
actions.push(Action::Confirm(discard(change, path)));
}
}
});
if clicked {
actions.push(Action::Open(source));
}
}
Expand All @@ -77,10 +97,12 @@ fn changes(ui: &mut Ui, ws: &mut Workspace, actions: &mut Vec<Action>) {
egui::ScrollArea::vertical().id_salt("staged").max_height(half).auto_shrink([false, true]).show(ui, |ui| {
for &(change, path) in &staged {
let source = Source::Staged(path.into());
let (clicked, unstage) = file(ui, change, path, open == Some(&source), Some("Unstage"));
if unstage {
actions.push(Action::Run(Op::Unstage(vec![path.into()])));
} else if clicked {
let clicked = file(ui, change, path, open == Some(&source), |ui| {
if ui.small_button("Unstage").clicked() {
actions.push(Action::Run(Op::Unstage(vec![path.into()])));
}
});
if clicked {
actions.push(Action::Open(source));
}
}
Expand Down Expand Up @@ -151,7 +173,7 @@ fn commit(ui: &mut Ui, ws: &mut Workspace, id: ObjectId, actions: &mut Vec<Actio
egui::ScrollArea::vertical().auto_shrink([false, true]).show(ui, |ui| {
for change in files {
let source = Source::Commit { commit: sha.clone(), path: change.path.clone() };
if file(ui, change.change, &change.path, open == Some(&source), None).0 {
if file(ui, change.change, &change.path, open == Some(&source), |_| {}) {
actions.push(Action::Open(source));
}
}
Expand Down
52 changes: 35 additions & 17 deletions crates/app/src/diff.rs
Original file line number Diff line number Diff line change
@@ -1,23 +1,41 @@
//! The diff of one file, with staging of hunks and single lines.
//! The diff of one file, with staging and discarding of hunks and single lines.

use eframe::egui::{self, Align2, Color32, FontId, Key, Rect, RichText, Sense, Ui, Vec2};
use gitcraft_core::diff::{LineKind, Source};
use gitcraft_core::git::Op;

use crate::app::{Action, OpenFile};
use crate::app::{Action, Confirm, OpenFile};
use crate::theme;

const LINE: f32 = 18.0;
const NUMBER: f32 = 44.0;

/// What can be done with lines of the file.
fn verbs(source: &Source) -> &'static [&'static str] {
match source {
Source::Unstaged(_) => &["Stage", "Discard"],
Source::Staged(_) => &["Unstage"],
Source::Untracked(_) | Source::Commit { .. } => &[],
}
}

/// Doing `verb` with the `selected` lines, which `what` names.
fn act(file: &OpenFile, verb: &'static str, what: &str, selected: &dyn Fn(usize, usize) -> bool) -> Action {
// Unstaging and discarding both start from the lines as they are now, and take them out.
let reverse = verb != "Stage";
let patch = file.diff.patch(selected, reverse);
if verb == "Discard" {
let question = format!("Discard {what} of {}?", file.source.path());
return Action::Confirm(Confirm { question, verb, op: Op::DiscardLines(patch) });
}
Action::Run(Op::ApplyToIndex { patch, reverse })
}

/// Shows the open file.
pub fn show(ui: &mut Ui, file: &OpenFile, actions: &mut Vec<Action>) {
let reverse = matches!(file.source, Source::Staged(_));
let stageable = matches!(file.source, Source::Unstaged(_) | Source::Staged(_));
let verb = if reverse { "Unstage" } else { "Stage" };
let run = |selected: &dyn Fn(usize, usize) -> bool| {
Action::Run(Op::ApplyToIndex { patch: file.diff.patch(selected, reverse), reverse })
};
let verbs = verbs(&file.source);
let picked = |h, l| file.lines.contains(&(h, l));
let lines = theme::count(file.lines.len(), "line");
ui.horizontal(|ui| {
ui.strong(file.source.path());
ui.weak(match file.source {
Expand All @@ -30,9 +48,10 @@ pub fn show(ui: &mut Ui, file: &OpenFile, actions: &mut Vec<Action>) {
if ui.button("Close").clicked() || ui.input(|i| i.key_pressed(Key::Escape)) {
actions.push(Action::CloseFile);
}
if stageable && !file.lines.is_empty() && ui.button(format!("{verb} {} lines", file.lines.len())).clicked()
{
actions.push(run(&|h, l| file.lines.contains(&(h, l))));
for verb in verbs {
if !file.lines.is_empty() && ui.button(format!("{verb} {lines}")).clicked() {
actions.push(act(file, verb, &lines, &picked));
}
}
});
});
Expand Down Expand Up @@ -68,22 +87,21 @@ pub fn show(ui: &mut Ui, file: &OpenFile, actions: &mut Vec<Action>) {
font.clone(),
Color32::GRAY,
);
if stageable {
let button = Rect::from_min_size(rect.right_top() - Vec2::X * 110.0, Vec2::new(104.0, LINE));
for (verb, x) in verbs.iter().zip([110.0, 220.0]) {
let button = Rect::from_min_size(rect.right_top() - Vec2::X * x, Vec2::new(104.0, LINE));
if ui.put(button, egui::Button::new(RichText::new(format!("{verb} hunk")).small())).clicked() {
actions.push(run(&|hh, _| hh == h));
actions.push(act(file, verb, "this hunk", &|hh, _| hh == h));
}
}
continue;
};
let line = &hunk.lines[l];
let picked = file.lines.contains(&(h, l));
let (background, marker) = match line.kind {
LineKind::Added => (theme::ADDED, "+"),
LineKind::Removed => (theme::REMOVED, "-"),
LineKind::Context | LineKind::NoNewline => (Color32::TRANSPARENT, " "),
};
painter.rect_filled(rect, 0.0, if picked { theme::PICKED } else { background });
painter.rect_filled(rect, 0.0, if picked(h, l) { theme::PICKED } else { background });
let number = |n: Option<u32>| n.map(|n| n.to_string()).unwrap_or_default();
let at = |x: f32| rect.left_center() + Vec2::X * x;
painter.text(at(NUMBER - 6.0), Align2::RIGHT_CENTER, number(line.old), font.clone(), Color32::DARK_GRAY);
Expand All @@ -97,7 +115,7 @@ pub fn show(ui: &mut Ui, file: &OpenFile, actions: &mut Vec<Action>) {
let text = format!("{marker} {}", line.text.trim_end_matches('\r'));
painter.text(at(2.0 * NUMBER + 6.0), Align2::LEFT_CENTER, text, font.clone(), ui.visuals().text_color());
let changed = matches!(line.kind, LineKind::Added | LineKind::Removed);
if stageable && changed && response.clicked() {
if !verbs.is_empty() && changed && response.clicked() {
actions.push(Action::ToggleLine(h, l));
}
}
Expand Down
Loading
Loading