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
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ Its tests run against real repositories made with `git` in a temporary folder
- `app.rs` holds the state. Views never change it directly: they push an `Action`, which
`Workspace::apply` carries out after the frame. Anything that touches the repository runs on a
background thread and is followed by a reload, so the window never waits.
- `start.rs` (open, clone and create), `ui.rs` (toolbar), `graph.rs`, `sidebar.rs`, `details.rs`
- `start.rs` (open, clone and create), `ui.rs` (tabs and toolbar), `graph.rs`, `sidebar.rs`, `details.rs`
and `diff.rs` draw the views, and `theme.rs` has the colours.

The app's tests (`crates/app/src/tests.rs`) drive the whole window the way a user does, through
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
<img src="https://img.shields.io/badge/status-early-d69e2e" alt="Status: early" />
</p>

![GitCraft showing a repository: branches on the left, the commit graph with branch and tag labels in the middle, and the uncommitted changes with the commit form on the right](docs/screenshot.png)
![GitCraft with two repositories open in tabs: branches on the left, the commit graph with branch and tag labels in the middle, and the uncommitted changes with the commit form on the right](docs/screenshot.png)

## Fast

Expand All @@ -42,6 +42,7 @@ with that one.

- **Start**: open a folder, clone from a URL with git's progress as it goes, or create a new
repository.
- **Tabs**: keep several repositories open at once; they open again where you left them.
- **Graph**: every branch, remote branch and tag in one graph, children above parents, with
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.
Expand Down
34 changes: 17 additions & 17 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,29 +20,29 @@ drive it the way a user does, and keeps every benchmark as fast as before.
- [x] Stash, apply, pop and drop
- [x] Refresh when the window gets focus
- [x] Clone and init from the start screen, with progress
- [x] Tabs: several repositories open at once, remembered between runs

## Next

1. **Tabs**: several repositories open at once, remembered between runs.
2. **Live refresh**: watch the working tree and refs, and reload by itself.
3. **Discard** changes per file, hunk or line.
4. **Confirm destructive actions**: hard reset, deleting a branch with unmerged commits, force push.
5. **Undo and redo** of the last action, through the reflog.
6. **Conflicts**: list conflicted files, resolve them in a three-way view (ours, theirs, result),
1. **Live refresh**: watch the working tree and refs, and reload by itself.
2. **Discard** changes per file, hunk or line.
3. **Confirm destructive actions**: hard reset, deleting a branch with unmerged commits, force push.
4. **Undo and redo** of the last action, through the reflog.
5. **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.
7. **Interactive rebase**: reorder, squash, fix up, reword and drop commits, by dragging in the
6. **Interactive rebase**: reorder, squash, fix up, reword and drop commits, by dragging in the
graph.
8. **Credentials**: ask for a username, password or passphrase when git needs one.
9. **Push and pull options**: force with lease, push tags, pick the remote, pull with rebase.
10. **Remotes**: add, edit and remove.
11. **Search the graph** by message, author or SHA, and jump to any branch or tag.
12. **File history and blame.**
13. **Better diffs**: side by side, word-level changes, syntax colouring, images.
14. **Keyboard**: shortcuts for everything, and a command palette.
15. **Settings**: light and dark theme, font size, date format, default pull mode.
16. **Graph polish**: resizable columns, compact lanes, author avatars (optional, never by default
7. **Credentials**: ask for a username, password or passphrase when git needs one.
8. **Push and pull options**: force with lease, push tags, pick the remote, pull with rebase.
9. **Remotes**: add, edit and remove.
10. **Search the graph** by message, author or SHA, and jump to any branch or tag.
11. **File history and blame.**
12. **Better diffs**: side by side, word-level changes, syntax colouring, images.
13. **Keyboard**: shortcuts for everything, and a command palette.
14. **Settings**: light and dark theme, font size, date format, default pull mode.
15. **Graph polish**: resizable columns, compact lanes, author avatars (optional, never by default
over the network).
17. **Packaging**: installers with the icon for Windows (MSI), macOS (.app in a .dmg) and Linux
16. **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
8 changes: 6 additions & 2 deletions crates/app/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,12 @@ pub enum Action {
ToggleLine(usize, usize),
/// Ask for a name first.
Prompt(Prompt),
/// Switch to another repository.
/// 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`.
ShowTab(Option<usize>),
/// Close a tab.
CloseTab(usize),
}

/// What the details panel shows.
Expand Down Expand Up @@ -230,7 +234,7 @@ impl Workspace {
}
}
Action::Prompt(prompt) => self.prompt = Some((prompt, String::new())),
Action::OpenRepo(_) => unreachable!("the app opens repositories"),
Action::OpenRepo(_) | Action::ShowTab(_) | Action::CloseTab(_) => unreachable!("the app handles tabs"),
}
}

Expand Down
12 changes: 5 additions & 7 deletions crates/app/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,6 @@ use std::path::PathBuf;

use eframe::egui;

const RECENT: &str = "recent";

/// The logo, which is also the window icon.
const LOGO: &[u8] = include_bytes!("../../../assets/icon/256.png");

Expand All @@ -34,8 +32,9 @@ impl eframe::App for Window {
}

fn save(&mut self, storage: &mut dyn eframe::Storage) {
let recent: Vec<_> = self.0.recent.iter().map(|path| path.display().to_string()).collect();
storage.set_string(RECENT, recent.join("\n"));
for (key, value) in ui::KEYS.into_iter().zip(self.0.saved()) {
storage.set_string(key, value);
}
}
}

Expand All @@ -52,10 +51,9 @@ fn main() -> eframe::Result {
"GitCraft",
options,
Box::new(|cc| {
let recent = cc.storage.and_then(|s| s.get_string(RECENT)).unwrap_or_default();
let recent = recent.lines().map(PathBuf::from).collect();
theme::apply(&cc.egui_ctx);
Ok(Box::new(Window(ui::App::new(recent, path))))
let app = ui::App::restore(|key| cc.storage.and_then(|s| s.get_string(key)), path);
Ok(Box::new(Window(app)))
}),
)
}
6 changes: 6 additions & 0 deletions crates/app/src/start.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ pub struct Start {
}

impl Start {
/// Starts afresh once a repository is open, for the next new tab.
pub fn clear(&mut self) {
self.error = None;
self.path.clear();
}

/// Draws the start screen.
pub fn show(&mut self, ui: &mut Ui, recent: &[PathBuf], actions: &mut Vec<Action>) {
let logo = self
Expand Down
137 changes: 122 additions & 15 deletions crates/app/src/tests.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
//! The whole app, driven through its accessibility tree against real repositories.

use std::collections::HashMap;
use std::fs;
use std::path::Path;
use std::time::{Duration, Instant};
Expand All @@ -12,7 +13,7 @@ use tempfile::TempDir;

use crate::app::Selection;
use crate::start::percent;
use crate::ui::App;
use crate::ui::{App, KEYS};

/// A repository with one commit, with the tester's own git config kept out of the way.
fn repo() -> (TempDir, Git) {
Expand All @@ -37,7 +38,10 @@ fn log(git: &Git) -> String {
}

fn app(path: Option<&Path>) -> Harness<'static, App> {
let app = App::new(Vec::new(), path.map(Path::to_path_buf));
run(App::restore(|_| None, path.map(Path::to_path_buf)))
}

fn run(app: App) -> Harness<'static, App> {
let mut harness =
Harness::builder().with_size([1280.0, 720.0]).build_ui_state(|ui, app: &mut App| app.show(ui), app);
settle(&mut harness);
Expand All @@ -57,10 +61,28 @@ fn settle(harness: &mut Harness<'_, App>) {
}

fn busy(app: &App) -> bool {
match &app.workspace {
Some(ws) => ws.busy().is_some(),
None => app.start.dialog.as_ref().is_some_and(crate::start::Dialog::busy),
}
app.tabs.iter().any(|ws| ws.busy().is_some()) || app.start.dialog.as_ref().is_some_and(crate::start::Dialog::busy)
}

/// The names on the tabs.
fn tabs(app: &App) -> Vec<String> {
app.tabs.iter().map(|ws| ws.repo.name()).collect()
}

fn name(git: &Git) -> String {
git.dir().file_name().unwrap().to_string_lossy().into_owned()
}

/// Opens `git` from the start screen of a new tab.
fn open_in_new_tab(harness: &mut Harness<'_, App>, git: &Git) {
harness.get_by_label("New tab").click();
harness.run();
harness.get_by_role(Role::TextInput).click();
harness.run();
harness.get_by_role(Role::TextInput).type_text(git.dir().to_str().unwrap());
harness.run();
harness.key_press(Key::Enter);
settle(harness);
}

/// Types into the text field labelled `label`.
Expand All @@ -80,7 +102,7 @@ fn the_start_screen_offers_to_open_a_repository() {
#[test]
fn a_bad_path_explains_itself_on_the_start_screen() {
let harness = app(Some(Path::new("/surely/not/a/repository")));
assert!(harness.state().workspace.is_none());
assert!(harness.state().workspace().is_none());
harness.get_by_label_contains("/surely/not/a/repository");
}

Expand All @@ -96,7 +118,7 @@ fn clone_a_repository_from_the_start_screen() {
harness.get_by_label("Clone").click();
settle(&mut harness);

let ws = harness.state().workspace.as_ref().expect("the clone is open");
let ws = harness.state().workspace().expect("the clone is open");
let name = source_dir.path().file_name().unwrap();
assert_eq!(fs::canonicalize(ws.repo.git.dir()).unwrap(), fs::canonicalize(into.path().join(name)).unwrap());
harness.get_by_role_and_label(Role::Button, "First commit");
Expand All @@ -113,7 +135,7 @@ fn a_failing_clone_shows_git_s_message_and_stays_open() {
harness.get_by_label("Clone").click();
settle(&mut harness);

assert!(harness.state().workspace.is_none());
assert!(harness.state().workspace().is_none());
let error = harness.state().start.dialog.as_ref().and_then(|d| d.error.clone()).expect("an error");
harness.get_by_label(&error);
harness.get_by_label("Cancel").click();
Expand All @@ -132,7 +154,7 @@ fn create_a_repository_from_the_start_screen() {
harness.get_by_label("Create").click();
settle(&mut harness);

assert_eq!(harness.state().workspace.as_ref().expect("the new repository is open").repo.name(), "project");
assert_eq!(harness.state().workspace().expect("the new repository is open").repo.name(), "project");
assert!(into.path().join("project/.git").is_dir());
}

Expand All @@ -154,7 +176,7 @@ fn stage_and_commit_a_change() {
harness.run();
harness.get_by_label("Stage").click();
settle(&mut harness);
harness.state_mut().workspace.as_mut().unwrap().summary = "Second commit".into();
harness.state_mut().workspace_mut().unwrap().summary = "Second commit".into();
harness.run();
harness.get_by_label("Commit changes to 1 file").click();
settle(&mut harness);
Expand All @@ -169,15 +191,15 @@ fn arrow_keys_walk_the_history() {
let (_dir, git) = repo();
commit(&git, "Second commit");
let mut harness = app(Some(git.dir()));
let ids = harness.state().workspace.as_ref().unwrap().snapshot.history.ids.clone();
assert_eq!(harness.state().workspace.as_ref().unwrap().selection, Some(Selection::Commit(ids[0])));
let ids = harness.state().workspace().unwrap().snapshot.history.ids.clone();
assert_eq!(harness.state().workspace().unwrap().selection, Some(Selection::Commit(ids[0])));

harness.key_press(Key::ArrowDown);
harness.run();
assert_eq!(harness.state().workspace.as_ref().unwrap().selection, Some(Selection::Commit(ids[1])));
assert_eq!(harness.state().workspace().unwrap().selection, Some(Selection::Commit(ids[1])));
harness.key_press(Key::ArrowUp);
harness.run();
assert_eq!(harness.state().workspace.as_ref().unwrap().selection, Some(Selection::Commit(ids[0])));
assert_eq!(harness.state().workspace().unwrap().selection, Some(Selection::Commit(ids[0])));
}

#[test]
Expand Down Expand Up @@ -207,3 +229,88 @@ fn a_failing_operation_shows_git_s_message() {
assert!(harness.query_by_label("Dismiss").is_none());
assert_eq!(log(&git), "First commit\n");
}

#[test]
fn open_several_repositories_in_tabs() {
let (_a_dir, a) = repo();
let (_b_dir, b) = repo();
commit(&b, "Only in b");
let mut harness = app(Some(a.dir()));
open_in_new_tab(&mut harness, &b);

assert_eq!(tabs(harness.state()), [name(&a), name(&b)]);
harness.get_by_role_and_label(Role::Button, "Only in b");
harness.get_by_label(&name(&a)).click();
settle(&mut harness);
assert_eq!(harness.state().active, Some(0));
assert!(harness.query_by_label("Only in b").is_none());
}

#[test]
fn opening_a_repository_that_is_open_shows_its_tab() {
let (_a_dir, a) = repo();
let (_b_dir, b) = repo();
let mut harness = app(Some(a.dir()));
open_in_new_tab(&mut harness, &b);
open_in_new_tab(&mut harness, &a);

assert_eq!(tabs(harness.state()), [name(&a), name(&b)]);
assert_eq!(harness.state().active, Some(0));
}

#[test]
fn a_tab_shows_what_changed_while_it_was_out_of_sight() {
let (_a_dir, a) = repo();
let (_b_dir, b) = repo();
let mut harness = app(Some(a.dir()));
open_in_new_tab(&mut harness, &b);
commit(&a, "Made elsewhere");
harness.get_by_label(&name(&a)).click();
settle(&mut harness);

harness.get_by_role_and_label(Role::Button, "Made elsewhere");
}

#[test]
fn closing_tabs_shows_the_next_and_then_the_start_screen() {
let repos = [repo(), repo(), repo()];
let [a, b, c] = repos.each_ref().map(|(_, git)| git);
let mut harness = app(Some(a.dir()));
open_in_new_tab(&mut harness, b);
open_in_new_tab(&mut harness, c);
harness.get_by_label(&name(b)).click();
settle(&mut harness);

harness.get_by_label(&format!("Close {}", name(b))).click();
settle(&mut harness);
assert_eq!(tabs(harness.state()), [name(a), name(c)]);
assert_eq!(harness.state().active, Some(1));
harness.get_by_label(&format!("Close {}", name(a))).click();
settle(&mut harness);
assert_eq!(harness.state().active, Some(0));
harness.get_by_label(&format!("Close {}", name(c))).click();
settle(&mut harness);
assert!(harness.state().tabs.is_empty());
harness.get_by_label("Open a repository…");
}

#[test]
fn tabs_open_again_on_the_next_run() {
let (_a_dir, a) = repo();
let (_b_dir, b) = repo();
let (_c_dir, c) = repo();
let mut harness = app(Some(a.dir()));
open_in_new_tab(&mut harness, &b);
harness.get_by_label(&name(&a)).click();
settle(&mut harness);
let saved: HashMap<_, _> = KEYS.into_iter().zip(harness.state().saved()).collect();

let harness = run(App::restore(|key| saved.get(key).cloned(), Some(c.dir().to_owned())));
assert_eq!(tabs(harness.state()), [name(&a), name(&b), name(&c)]);
assert_eq!(harness.state().active, Some(2));
let harness = run(App::restore(|key| saved.get(key).cloned(), None));
assert_eq!(tabs(harness.state()), [name(&a), name(&b)]);
assert_eq!(harness.state().active, Some(0));
let recent: Vec<_> = harness.state().recent.iter().map(|path| fs::canonicalize(path).unwrap()).collect();
assert_eq!(recent, [a.dir(), b.dir()]);
}
Loading
Loading