scan local repo

This commit is contained in:
2026-08-31 07:54:41 +07:00
parent 767638eda2
commit 6fcc945dae
8 changed files with 366 additions and 34 deletions
Generated
+1
View File
@@ -7924,6 +7924,7 @@ dependencies = [
"nostr", "nostr",
"nostr-connect", "nostr-connect",
"nostr-sdk", "nostr-sdk",
"paths",
"rustls", "rustls",
"signed_core", "signed_core",
"signed_git", "signed_git",
+6
View File
@@ -41,6 +41,12 @@ pub fn desktop_dir() -> PathBuf {
dirs::desktop_dir().unwrap_or_else(|| dirs::home_dir().unwrap_or_default()) dirs::desktop_dir().unwrap_or_else(|| dirs::home_dir().unwrap_or_default())
} }
/// Returns the current user's Documents folder, falling back to the home
/// directory (or an empty path) when it can't be determined.
pub fn documents_dir() -> PathBuf {
dirs::document_dir().unwrap_or_else(|| dirs::home_dir().unwrap_or_default())
}
/// Sets a custom directory for all user data, overriding the default data /// Sets a custom directory for all user data, overriding the default data
/// directory. Must be called before any other path operation. The directory /// directory. Must be called before any other path operation. The directory
/// is created if it doesn't exist and canonicalized to an absolute path. /// is created if it doesn't exist and canonicalized to an absolute path.
+101
View File
@@ -63,6 +63,65 @@ impl GitCache {
} }
} }
/// Maximum directory nesting depth when scanning for local repositories,
/// so pathological trees can't stall the scan.
const SCAN_MAX_DEPTH: usize = 12;
/// Directories never descended into during a scan: dependency caches that
/// can be enormous without ever containing user repositories.
const SCAN_SKIPPED_DIRS: [&str; 1] = ["node_modules"];
/// Walk `root` recursively and collect the paths of git repositories
/// (directories containing a `.git` entry) below it.
///
/// Hidden entries and symlinks are skipped, and directories that are
/// themselves repositories are not descended into (so nested repositories,
/// like submodule worktrees, are not reported). Results are canonicalized,
/// deduplicated and sorted by path.
pub fn find_git_repos(root: &Path) -> Vec<PathBuf> {
let mut repos = Vec::new();
if !root.is_dir() {
return repos;
}
let mut stack = vec![(root.to_path_buf(), 0usize)];
while let Some((dir, depth)) = stack.pop() {
if depth > SCAN_MAX_DEPTH {
continue;
}
// A directory containing a `.git` entry is a repository (a linked
// worktree has a `.git` file instead of a directory); don't descend.
if dir.join(".git").exists() {
if let Ok(path) = dir.canonicalize() {
repos.push(path);
}
continue;
}
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let Ok(file_type) = entry.file_type() else {
continue;
};
if !file_type.is_dir() || file_type.is_symlink() {
continue;
}
let file_name = entry.file_name();
let name = file_name.to_string_lossy();
if name.starts_with('.') || SCAN_SKIPPED_DIRS.contains(&name.as_ref()) {
continue;
}
stack.push((entry.path(), depth + 1));
}
}
repos.sort();
repos.dedup();
repos
}
/// Clone a repository into `path` from the first working URL in /// Clone a repository into `path` from the first working URL in
/// `clone_urls` (the announcement's `clone` tag), then fetch the /// `clone_urls` (the announcement's `clone` tag), then fetch the
/// `refs/nostr/*` PR refs like the cache clone does. The destination must /// `refs/nostr/*` PR refs like the cache clone does. The destination must
@@ -1552,6 +1611,48 @@ mod tests {
); );
} }
#[test]
fn find_git_repos_discovers_repositories_recursively() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
// Repositories are found at any depth; a linked worktree (a `.git`
// file instead of a directory) counts too.
let nested = root.join("a/b/project");
std::fs::create_dir_all(nested.join(".git")).unwrap();
let worktree = root.join("wt");
std::fs::create_dir_all(&worktree).unwrap();
std::fs::write(
worktree.join(".git"),
"gitdir: ../a/b/project/.git/worktrees/wt",
)
.unwrap();
// Plain directories are not repositories.
std::fs::create_dir_all(root.join("plain")).unwrap();
// Hidden entries and dependency caches are skipped.
std::fs::create_dir_all(root.join(".hidden/repo/.git")).unwrap();
std::fs::create_dir_all(root.join("node_modules/pkg/.git")).unwrap();
// A repository is not descended into, so repositories inside it
// (submodule worktrees) are not reported.
let outer = root.join("outer");
std::fs::create_dir_all(outer.join(".git")).unwrap();
std::fs::create_dir_all(outer.join("sub/other/.git")).unwrap();
let mut found = find_git_repos(root);
found.sort();
let mut expected = vec![
nested.canonicalize().unwrap(),
worktree.canonicalize().unwrap(),
outer.canonicalize().unwrap(),
];
expected.sort();
assert_eq!(found, expected);
}
#[test] #[test]
fn repo_ref_state_lists_branches_tags_and_head() { fn repo_ref_state_lists_branches_tags_and_head() {
let (_dir, repo) = fixture(&[("a.txt", b"hello")]); let (_dir, repo) = fixture(&[("a.txt", b"hello")]);
+1
View File
@@ -23,3 +23,4 @@ log.workspace = true
[target.'cfg(not(target_arch = "wasm32"))'.dependencies] [target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rustls = "0.23" rustls = "0.23"
paths = { path = "../paths" }
+38 -11
View File
@@ -1,5 +1,6 @@
mod backend; mod backend;
mod git_store; mod git_store;
mod local_repos;
mod profile; mod profile;
mod repo; mod repo;
mod repo_list; mod repo_list;
@@ -9,6 +10,7 @@ use std::path::{Path, PathBuf};
pub use backend::{Backend, BackendEvent}; pub use backend::{Backend, BackendEvent};
pub use git_store::GitStore; pub use git_store::GitStore;
use gpui::{App, AppContext, Entity}; use gpui::{App, AppContext, Entity};
pub use local_repos::LocalReposStore;
pub use nostr_sdk::prelude::Timestamp; pub use nostr_sdk::prelude::Timestamp;
pub use profile::{Profile, ProfileStore}; pub use profile::{Profile, ProfileStore};
pub use repo::RepoStore; pub use repo::RepoStore;
@@ -16,8 +18,15 @@ pub use repo_list::{RepoActivityCounts, RepoListStore};
use signed_nostr::new_backend; use signed_nostr::new_backend;
pub use utils::shorten_pubkey; pub use utils::shorten_pubkey;
/// Initialize the backend and stores, and install them as globals. Call once /// The default directories scanned for local git repositories
/// at startup, before opening any window that uses the stores. /// on every platform: the user's Desktop and Documents folders.
#[cfg(not(target_arch = "wasm32"))]
fn default_scan_paths() -> Vec<PathBuf> {
vec![paths::desktop_dir(), paths::documents_dir()]
}
/// Initialize the backend and stores, and install them as globals.
/// Call once at startup, before opening any window that uses the stores.
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
pub fn init(db_path: impl AsRef<Path>, cx: &mut App) -> Entity<Backend> { pub fn init(db_path: impl AsRef<Path>, cx: &mut App) -> Entity<Backend> {
// rustls uses the `aws_lc_rs` provider by default; ignore if already installed. // rustls uses the `aws_lc_rs` provider by default; ignore if already installed.
@@ -25,46 +34,64 @@ pub fn init(db_path: impl AsRef<Path>, cx: &mut App) -> Entity<Backend> {
.install_default() .install_default()
.ok(); .ok();
let path = db_path.as_ref().to_path_buf(); // Initialize the nostr client and universal signer.
let (client, signer) = cx.foreground_executor().block_on(async move { let (client, signer) = cx.foreground_executor().block_on(async move {
let path = db_path.as_ref().to_path_buf();
new_backend(path) new_backend(path)
.await .await
.expect("failed to initialize nostr backend") .expect("failed to initialize nostr backend")
}); });
// Initialize the backend and stores.
let entity = cx.new(|cx| Backend::new(client, signer, cx)); let entity = cx.new(|cx| Backend::new(client, signer, cx));
Backend::set_global(entity.clone(), cx); Backend::set_global(entity.clone(), cx);
// Initialize the profile store.
ProfileStore::set_global(cx.new(ProfileStore::new), cx); ProfileStore::set_global(cx.new(ProfileStore::new), cx);
// Start the explore list from the local database before the first // Start the explore list from the local database before
// window opens; relay syncs continue in the background, so the list // the first window opens, relay syncs continue in the background,
// never waits for them. // so the list never waits for them.
RepoListStore::set_global(cx.new(|cx| RepoListStore::new(None, cx)), cx); RepoListStore::set_global(cx.new(|cx| RepoListStore::new(None, cx)), cx);
// The clone cache is only meaningful on native platforms; the wasm // The clone cache is only meaningful on native platforms,
// build registers an empty store so `GitStore::global` still works. // the wasm build registers an empty store so `GitStore::global` still works.
GitStore::set_global(PathBuf::new(), cx); GitStore::set_global(PathBuf::new(), cx);
// Scan the default directories (Desktop, Documents) for local git
// repositories; the sidebar lists them next to the user's NIP-34 repos.
LocalReposStore::set_global(
cx.new(|cx| LocalReposStore::new(default_scan_paths(), cx)),
cx,
);
entity entity
} }
/// Initialize the backend with an in-memory database on wasm. /// Initialize the backend with an in-memory database on wasm.
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
pub fn init(cx: &mut App) -> Entity<Backend> { pub fn init(cx: &mut App) -> Entity<Backend> {
// Initialize the nostr client and universal signer.
let (client, signer) = new_backend().expect("failed to initialize nostr backend"); let (client, signer) = new_backend().expect("failed to initialize nostr backend");
// Initialize the backend and stores.
let entity = cx.new(|cx| Backend::new(client, signer, cx)); let entity = cx.new(|cx| Backend::new(client, signer, cx));
Backend::set_global(entity.clone(), cx); Backend::set_global(entity.clone(), cx);
// Initialize the profile store.
ProfileStore::set_global(cx.new(ProfileStore::new), cx); ProfileStore::set_global(cx.new(ProfileStore::new), cx);
// Start the explore list from the local database before the first // Start the explore list from the local database before
// window opens; relay syncs continue in the background, so the list // the first window opens, relay syncs continue in the background,
// never waits for them. // so the list never waits for them.
RepoListStore::set_global(cx.new(|cx| RepoListStore::new(None, cx)), cx); RepoListStore::set_global(cx.new(|cx| RepoListStore::new(None, cx)), cx);
// The clone cache is only meaningful on native platforms,
// the wasm build registers an empty store so `GitStore::global` still works.
GitStore::set_global(PathBuf::new(), cx); GitStore::set_global(PathBuf::new(), cx);
// No filesystem scan on wasm: there are no local git repositories.
LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(Vec::new(), cx)), cx);
entity entity
} }
+101
View File
@@ -0,0 +1,101 @@
use std::path::PathBuf;
use std::sync::Arc;
use anyhow::Error;
use gpui::{App, AppContext, Context, Entity, Global, Task};
use signed_git::find_git_repos;
struct GlobalLocalReposStore(Entity<LocalReposStore>);
impl Global for GlobalLocalReposStore {}
/// Store of the git repositories discovered under a set of scan paths.
///
/// Created at startup by [`crate::init`] with the default scan paths
/// (the Desktop and Documents folders; empty on wasm, where no scan runs),
/// then installed as a global so the sidebar can list local repositories.
/// The scan runs on a background thread; only the results cross back into the entity.
pub struct LocalReposStore {
/// The directories being scanned.
pub roots: Arc<Vec<PathBuf>>,
/// Git repositories discovered under [`Self::roots`], sorted by path.
pub repos: Arc<Vec<PathBuf>>,
/// A scan is currently running.
pub scanning: bool,
/// A scan was requested while one was already running.
scan_dirty: bool,
tasks: Vec<Task<Result<(), Error>>>,
}
impl LocalReposStore {
/// Retrieve the global local-repositories store
/// (created at startup by [`crate::init`]).
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalLocalReposStore>().0.clone()
}
pub(crate) fn set_global(entity: Entity<Self>, cx: &mut App) {
cx.set_global(GlobalLocalReposStore(entity));
}
/// Create a store scanning `roots` right away
/// (a no-op when the list is empty, e.g. on wasm).
pub fn new(roots: Vec<PathBuf>, cx: &mut Context<Self>) -> Self {
let mut store = Self {
roots: Arc::new(roots),
repos: Arc::new(Vec::new()),
scanning: false,
scan_dirty: false,
tasks: Vec::new(),
};
store.rescan(cx);
store
}
/// Re-run the scan. Requests that arrive while a scan is running are
/// folded into one follow-up scan; the results replace the list atomically.
pub fn rescan(&mut self, cx: &mut Context<Self>) {
if self.scanning {
self.scan_dirty = true;
return;
}
if self.roots.is_empty() {
return;
}
self.scanning = true;
cx.notify();
let roots = self.roots.clone();
let work = cx.background_spawn(async move {
let mut repos = Vec::new();
for root in roots.iter() {
repos.extend(find_git_repos(root));
}
repos.sort();
repos.dedup();
repos
});
self.tasks.push(cx.spawn(async move |this, cx| {
let repos = work.await;
let again = this.update(cx, |this, cx| {
this.repos = Arc::new(repos);
this.scanning = false;
cx.notify();
let dirty = this.scan_dirty;
this.scan_dirty = false;
dirty
})?;
// Scans requested while this one was running are coalesced into
// a single follow-up scan.
if again {
this.update(cx, |this, cx| this.rescan(cx))?;
}
Ok(())
}));
}
}
+105 -12
View File
@@ -1,4 +1,5 @@
use std::ops::Range; use std::ops::Range;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use assets::CustomIconName; use assets::CustomIconName;
@@ -18,7 +19,7 @@ use gpui_component::button::{Button, ButtonVariants};
use gpui_component::input::InputState; use gpui_component::input::InputState;
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, StyledExt, h_flex, v_flex}; use gpui_component::{ActiveTheme, Icon, IconName, Sizable, StyledExt, h_flex, v_flex};
use signed_core::Announcement; use signed_core::Announcement;
use signed_state::{Backend, BackendEvent, Profile, ProfileStore, RepoListStore}; use signed_state::{Backend, BackendEvent, LocalReposStore, Profile, ProfileStore, RepoListStore};
use super::{RepoDetailView, RepoListView}; use super::{RepoDetailView, RepoListView};
use crate::image_cache::{MAX_IMAGES, image_cache}; use crate::image_cache::{MAX_IMAGES, image_cache};
@@ -37,20 +38,23 @@ pub struct SidebarPanel {
focus_handle: FocusHandle, focus_handle: FocusHandle,
dock_area: WeakEntity<DockArea>, dock_area: WeakEntity<DockArea>,
explore: Option<WeakEntity<RepoListView>>, explore: Option<WeakEntity<RepoListView>>,
logged_in: bool,
/// Repositories announced by the current user, listed under /// Repositories announced by the current user, listed under
/// "All Repositories". Recreated when the signer changes. /// "All Repositories". Recreated when the signer changes.
my_repos: Option<Entity<RepoListStore>>, my_repos: Option<Entity<RepoListStore>>,
/// Observes the current user's repo store so the list re-renders. /// Observes the current user's repo store so the list re-renders.
my_repos_subscription: Option<Subscription>, my_repos_subscription: Option<Subscription>,
logged_in: bool,
/// Banner artwork shown behind the sign-in screen, /// Banner artwork shown behind the sign-in screen,
/// picked at random from the bundled `backgrounds/` assets. /// picked at random from the bundled `backgrounds/` assets.
banner: SharedString, banner: SharedString,
/// Observes the local-repository scan so new discoveries re-render.
_local_repos_subscription: Subscription,
_subscription: Subscription, _subscription: Subscription,
} }
impl SidebarPanel { impl SidebarPanel {
pub fn new(dock_area: WeakEntity<DockArea>, cx: &mut Context<Self>) -> Self { pub fn new(dock_area: WeakEntity<DockArea>, cx: &mut Context<Self>) -> Self {
let local_repos_store = LocalReposStore::global(cx);
let backend = Backend::global(cx); let backend = Backend::global(cx);
let logged_in = backend.read(cx).current_user().is_some(); let logged_in = backend.read(cx).current_user().is_some();
@@ -71,14 +75,19 @@ impl SidebarPanel {
cx.notify(); cx.notify();
}); });
let local_repos_subscription = cx.observe(&local_repos_store, |_, _, cx| {
cx.notify();
});
let mut panel = Self { let mut panel = Self {
focus_handle: cx.focus_handle(), focus_handle: cx.focus_handle(),
dock_area, dock_area,
logged_in,
explore: None, explore: None,
my_repos: None, my_repos: None,
my_repos_subscription: None, my_repos_subscription: None,
logged_in,
banner: pick_banner(), banner: pick_banner(),
_local_repos_subscription: local_repos_subscription,
_subscription: subscription, _subscription: subscription,
}; };
@@ -168,9 +177,13 @@ impl SidebarPanel {
/// The "All Repositories" section: header with the create button and /// The "All Repositories" section: header with the create button and
/// the current user's repositories below it, lazily rendered through a /// the current user's repositories below it, lazily rendered through a
/// [`uniform_list`]. /// [`uniform_list`], followed by the local git repositories discovered
/// by the startup scan.
fn render_my_repos(&self, cx: &mut Context<Self>) -> impl IntoElement { fn render_my_repos(&self, cx: &mut Context<Self>) -> impl IntoElement {
let store = self.my_repos.as_ref(); let store = self.my_repos.as_ref();
let local = LocalReposStore::global(cx);
let local_repos = local.read(cx).repos.clone();
let scanning = local.read(cx).scanning;
v_flex() v_flex()
.px_2() .px_2()
@@ -192,6 +205,20 @@ impl SidebarPanel {
.child(Icon::new(CustomIconName::Filter).small()) .child(Icon::new(CustomIconName::Filter).small())
.child(div().text_xs().font_semibold().child("All Repositories")), .child(div().text_xs().font_semibold().child("All Repositories")),
) )
.child(
h_flex()
.gap_1()
.child(
Button::new("rescan")
.icon(IconName::Redo)
.small()
.ghost()
.tooltip("Rescan for local repositories")
.on_click(cx.listener(|_this, _ev, _window, cx| {
LocalReposStore::global(cx)
.update(cx, |store, cx| store.rescan(cx));
})),
)
.child( .child(
Button::new("add") Button::new("add")
.icon(IconName::Plus) .icon(IconName::Plus)
@@ -201,11 +228,15 @@ impl SidebarPanel {
this.open_create_repo(window, cx); this.open_create_repo(window, cx);
})), })),
), ),
),
) )
.when_some(store, |builder, store| { .when_some(store, |builder, store| {
let announcements = store.read(cx).announcements.clone(); let announcements = store.read(cx).announcements.clone();
// One merged list: the user's NIP-34 repositories first,
// then the local repositories discovered by the scan.
let total = announcements.len() + local_repos.len();
if announcements.is_empty() { if total == 0 {
builder.child( builder.child(
div() div()
.flex_1() .flex_1()
@@ -213,17 +244,26 @@ impl SidebarPanel {
.py_1() .py_1()
.text_xs() .text_xs()
.text_color(cx.theme().muted_foreground) .text_color(cx.theme().muted_foreground)
.child("No repositories yet"), .child(if scanning {
"Scanning for local repositories…"
} else {
"No repositories yet"
}),
) )
} else { } else {
builder.child( builder.child(
uniform_list( uniform_list(
"my-repos-list", "my-repos-list",
announcements.len(), total,
cx.processor(move |this, range: Range<usize>, _window, cx| { cx.processor(move |this, range: Range<usize>, _window, cx| {
range range
.map(|ix| { .map(|ix| {
this.render_repo_row(&announcements[ix], cx) this.render_repo_row_at(
&announcements,
&local_repos,
ix,
cx,
)
.into_any_element() .into_any_element()
}) })
.collect() .collect()
@@ -236,8 +276,27 @@ impl SidebarPanel {
}) })
} }
/// One repository row in the sidebar, styled like the nav items: a /// One row of the merged sidebar list: a NIP-34 repository or a local
/// deterministic pixel avatar and the repo name. /// repository.
fn render_repo_row_at(
&self,
announcements: &[Announcement],
local_repos: &[PathBuf],
ix: usize,
cx: &mut Context<Self>,
) -> AnyElement {
if ix < announcements.len() {
return self
.render_repo_row(&announcements[ix], cx)
.into_any_element();
}
let local_ix = ix - announcements.len();
let path = &local_repos[local_ix];
self.render_local_row(path, cx).into_any_element()
}
fn render_repo_row( fn render_repo_row(
&self, &self,
announcement: &Announcement, announcement: &Announcement,
@@ -255,6 +314,28 @@ impl SidebarPanel {
) )
} }
/// One local repository row: a deterministic pixel avatar seeded from
/// the path, the directory name, and a warning suffix marking it as
/// not yet set up for NIP-34. The row has no click handler — announcing
/// local repositories is future work.
fn render_local_row(&self, path: &Path, cx: &mut Context<Self>) -> impl IntoElement {
let name = path
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| path.display().to_string());
NavItem::new(
format!("local-repo:{}", path.display()),
name,
PixelAvatar::new(path.to_string_lossy()),
)
.suffix(
Icon::new(IconName::TriangleAlert)
.small()
.text_color(cx.theme().warning),
)
}
/// Show the Import Identity dialog. /// Show the Import Identity dialog.
fn open_import(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn open_import(&mut self, window: &mut Window, cx: &mut Context<Self>) {
import_dialog::open(window, cx); import_dialog::open(window, cx);
@@ -491,8 +572,8 @@ impl Render for SidebarPanel {
} }
/// A single navigation entry in the sidebar: an arbitrary leading element /// A single navigation entry in the sidebar: an arbitrary leading element
/// (an icon, avatar, ...) and a text label with a hover highlight and an /// (an icon, avatar, ...) and a text label with a hover highlight,
/// optional click handler. /// an optional trailing suffix (e.g. a status icon) and an optional click handler.
#[allow(clippy::type_complexity)] #[allow(clippy::type_complexity)]
#[derive(IntoElement)] #[derive(IntoElement)]
struct NavItem { struct NavItem {
@@ -500,6 +581,8 @@ struct NavItem {
style: StyleRefinement, style: StyleRefinement,
icon: AnyElement, icon: AnyElement,
label: SharedString, label: SharedString,
/// Trailing element rendered at the right edge of the row, after the (ellipsized) label.
suffix: Option<AnyElement>,
on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>, on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
} }
@@ -515,10 +598,17 @@ impl NavItem {
icon: icon.into_any_element(), icon: icon.into_any_element(),
label: label.into(), label: label.into(),
style: StyleRefinement::default(), style: StyleRefinement::default(),
suffix: None,
on_click: None, on_click: None,
} }
} }
/// A trailing element rendered at the right edge of the row
fn suffix(mut self, suffix: impl IntoElement) -> Self {
self.suffix = Some(suffix.into_any_element());
self
}
fn on_click(mut self, listener: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static) -> Self { fn on_click(mut self, listener: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static) -> Self {
self.on_click = Some(Box::new(listener)); self.on_click = Some(Box::new(listener));
self self
@@ -545,6 +635,9 @@ impl RenderOnce for NavItem {
.text_ellipsis() .text_ellipsis()
.child(self.label), .child(self.label),
) )
.when_some(self.suffix, |this, suffix| {
this.child(div().flex_shrink_0().child(suffix))
})
.hover(|this| this.bg(cx.theme().list_hover)) .hover(|this| this.bg(cx.theme().list_hover))
.when_some(self.on_click, |this, listener| this.on_click(listener)) .when_some(self.on_click, |this, listener| this.on_click(listener))
} }
+5 -3
View File
@@ -1,7 +1,9 @@
# TODO # TODO
## Local repository scan
- [ ] Allow the user to configure which directories are scanned for local git repositories (currently fixed to the Desktop and Documents folders).
## Create repository dialog ## Create repository dialog
- [ ] Persist the user's preferred local repository folder (the one picked in - [ ] Persist the user's preferred local repository folder (the one picked in the create-repository dialog, defaulting to Desktop) and use it as the default next time the dialog opens.
the create-repository dialog, defaulting to Desktop) and use it as the
default next time the dialog opens.