diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 46bee55..06ee4eb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/README.md b/README.md index cb9ecb5..517a985 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Status: early

-![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 @@ -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. diff --git a/ROADMAP.md b/ROADMAP.md index f6499cd..18a9fba 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 diff --git a/crates/app/src/app.rs b/crates/app/src/app.rs index f60d235..47240b3 100644 --- a/crates/app/src/app.rs +++ b/crates/app/src/app.rs @@ -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), + /// Close a tab. + CloseTab(usize), } /// What the details panel shows. @@ -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"), } } diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index 87b22aa..d90d460 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -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"); @@ -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); + } } } @@ -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))) }), ) } diff --git a/crates/app/src/start.rs b/crates/app/src/start.rs index 3f3af92..5a003ca 100644 --- a/crates/app/src/start.rs +++ b/crates/app/src/start.rs @@ -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) { let logo = self diff --git a/crates/app/src/tests.rs b/crates/app/src/tests.rs index 142fd71..d884738 100644 --- a/crates/app/src/tests.rs +++ b/crates/app/src/tests.rs @@ -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}; @@ -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) { @@ -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); @@ -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 { + 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`. @@ -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"); } @@ -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"); @@ -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(); @@ -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()); } @@ -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); @@ -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] @@ -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()]); +} diff --git a/crates/app/src/ui.rs b/crates/app/src/ui.rs index 308511c..c9ee031 100644 --- a/crates/app/src/ui.rs +++ b/crates/app/src/ui.rs @@ -2,7 +2,7 @@ use std::path::{Path, PathBuf}; -use eframe::egui::{self, Button, Key, Ui}; +use eframe::egui::{self, Button, Key, Role, Ui, WidgetInfo}; use gitcraft_core::git::Op; use gitcraft_core::repo::{RefKind, Repo}; @@ -12,68 +12,157 @@ use crate::{details, diff, graph, sidebar, theme}; const RECENT: usize = 10; +/// Where [`App::saved`] keeps the recent repositories, the open tabs and the tab on screen. +pub const KEYS: [&str; 3] = ["recent", "tabs", "active tab"]; + /// The whole app. #[derive(Default)] pub struct App { - /// The open repository. - pub workspace: Option, + /// The open repositories, one per tab. + pub tabs: Vec, + /// The tab on screen, or `None` for the start screen. + pub active: Option, /// Recently opened repositories, newest first. pub recent: Vec, - /// The start screen, shown while no repository is open. + /// The start screen, shown while no tab is. pub start: Start, focused: bool, - pending: Option, + pending: Vec, } impl App { - /// Starts the app, opening `path` or else the last repository on the first frame. - pub fn new(recent: Vec, path: Option) -> Self { - let pending = path.or_else(|| recent.first().cloned()); + /// Starts the app with what [`Self::saved`] stored, opening those tabs and `path` on the first frame. + pub fn restore(stored: impl Fn(&str) -> Option, path: Option) -> Self { + let [recent, tabs, active] = + KEYS.map(|key| stored(key).map(|v| v.lines().map(PathBuf::from).collect::>())); + let recent = recent.unwrap_or_default(); + // Versions without tabs opened the last repository again. + let tabs = tabs.unwrap_or_else(|| recent.iter().take(1).cloned().collect()); + let pending = tabs.into_iter().chain(active.into_iter().flatten()).chain(path).collect(); Self { recent, pending, ..Self::default() } } + /// What to remember until the next run, one value for each of [`KEYS`]. + pub fn saved(&self) -> [String; 3] { + let tabs = self.tabs.iter().map(|ws| ws.repo.git.dir()); + [ + lines(self.recent.iter().map(PathBuf::as_path)), + lines(tabs), + lines(self.workspace().map(|ws| ws.repo.git.dir())), + ] + } + + /// The repository on screen. + pub fn workspace(&self) -> Option<&Workspace> { + self.tabs.get(self.active?) + } + + /// The repository on screen. + pub fn workspace_mut(&mut self) -> Option<&mut Workspace> { + self.tabs.get_mut(self.active?) + } + fn open(&mut self, ctx: &egui::Context, path: &Path) { match Repo::open(path) { Ok(repo) => { let path = repo.git.dir().to_owned(); self.recent.retain(|p| *p != path); - self.recent.insert(0, path); + self.recent.insert(0, path.clone()); self.recent.truncate(RECENT); - self.workspace = Some(Workspace::new(ctx, repo)); - self.start.error = None; + let tab = self.tabs.iter().position(|ws| ws.repo.git.dir() == path).unwrap_or_else(|| { + self.tabs.push(Workspace::new(ctx, repo)); + self.tabs.len() - 1 + }); + self.active = Some(tab); + self.start.clear(); + } + Err(error) => { + self.start.error = Some(format!("{}: {error}", path.display())); + self.active = None; } - Err(error) => self.start.error = Some(format!("{}: {error}", path.display())), } } + /// Closes a tab and shows the one that takes its place, or the start screen after the last. + fn close(&mut self, tab: usize) { + self.tabs.remove(tab); + let last = self.tabs.len().checked_sub(1); + // The tabs after the closed one move one to the left. + self.active = self.active.zip(last).map(|(active, last)| (active - usize::from(active > tab)).min(last)); + } + /// Draws a frame. pub fn show(&mut self, ui: &mut Ui) { - if let Some(path) = self.pending.take() { - self.open(ui.ctx(), &path); + let ctx = ui.ctx().clone(); + for path in std::mem::take(&mut self.pending) { + self.open(&ctx, &path); } + self.tabs.iter_mut().for_each(Workspace::poll); let mut actions = Vec::new(); + if !self.tabs.is_empty() { + egui::Panel::top("tabs").show(ui, |ui| tabs(ui, &self.tabs, self.active, &mut actions)); + } let focused = ui.input(|i| i.focused); - match &mut self.workspace { + match self.active { None => self.start.show(ui, &self.recent, &mut actions), - Some(ws) => { - ws.poll(); + Some(tab) => { if focused && !self.focused { actions.push(Action::Refresh); } - workspace(ui, ws, &mut actions); + workspace(ui, &mut self.tabs[tab], &mut actions); } } self.focused = focused; for action in actions { - match (action, &mut self.workspace) { - (Action::OpenRepo(path), _) => self.open(ui.ctx(), &path), - (action, Some(ws)) => ws.apply(ui.ctx(), action), - (_, None) => {} + match action { + Action::OpenRepo(path) => self.open(&ctx, &path), + Action::CloseTab(tab) => self.close(tab), + Action::ShowTab(tab) => { + self.active = tab; + // It may have changed while it was out of sight. + if let Some(ws) = self.workspace_mut() { + ws.apply(&ctx, Action::Refresh); + } + } + action => { + if let Some(ws) = self.workspace_mut() { + ws.apply(&ctx, action); + } + } } } } } +/// Paths one per line, the way [`App::saved`] keeps them. +fn lines<'a>(paths: impl IntoIterator) -> String { + paths.into_iter().map(|path| path.display().to_string()).collect::>().join("\n") +} + +/// The tab bar: a tab per repository, each with a close button, and a new tab. +fn tabs(ui: &mut Ui, tabs: &[Workspace], active: Option, actions: &mut Vec) { + ui.horizontal(|ui| { + for (tab, ws) in tabs.iter().enumerate() { + let name = ws.repo.name(); + let path = ws.repo.git.dir().display().to_string(); + if ui.add(Button::selectable(active == Some(tab), &name)).on_hover_text(path).clicked() { + actions.push(Action::ShowTab(Some(tab))); + } + let close = ui.add(Button::new("×").small().frame(false)).on_hover_text("Close"); + close.widget_info(|| WidgetInfo::labeled(Role::Button, true, format!("Close {name}"))); + if close.clicked() { + actions.push(Action::CloseTab(tab)); + } + ui.separator(); + } + let new = ui.add(Button::selectable(active.is_none(), "+")).on_hover_text("New tab"); + new.widget_info(|| WidgetInfo::labeled(Role::Button, true, "New tab")); + if new.clicked() { + actions.push(Action::ShowTab(None)); + } + }); +} + /// Asks for a folder. pub fn pick_folder(title: &str) -> Option { rfd::FileDialog::new().set_title(title).pick_folder() @@ -103,13 +192,7 @@ fn workspace(ui: &mut Ui, ws: &mut Workspace, actions: &mut Vec) { fn toolbar(ui: &mut Ui, ws: &Workspace, actions: &mut Vec) { let status = &ws.snapshot.status; ui.horizontal(|ui| { - if ui.button("Open…").clicked() - && let Some(folder) = pick_folder("Open a repository") - { - actions.push(Action::OpenRepo(folder)); - } - ui.strong(ws.repo.name()); - ui.weak(status.branch.as_deref().unwrap_or("detached HEAD")); + ui.strong(status.branch.as_deref().unwrap_or("detached HEAD")); ui.separator(); let idle = ws.busy().is_none(); let count = |label: &str, n: u32| if n > 0 { format!("{label} {n}") } else { label.to_owned() }; diff --git a/docs/screenshot.png b/docs/screenshot.png index 20f97d9..a628e6d 100644 Binary files a/docs/screenshot.png and b/docs/screenshot.png differ