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 @@
-
+
## 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