scan local repo
This commit is contained in:
Generated
+1
@@ -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",
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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")]);
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(())
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
@@ -193,19 +206,37 @@ impl SidebarPanel {
|
|||||||
.child(div().text_xs().font_semibold().child("All Repositories")),
|
.child(div().text_xs().font_semibold().child("All Repositories")),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
Button::new("add")
|
h_flex()
|
||||||
.icon(IconName::Plus)
|
.gap_1()
|
||||||
.small()
|
.child(
|
||||||
.ghost()
|
Button::new("rescan")
|
||||||
.on_click(cx.listener(|this, _ev, window, cx| {
|
.icon(IconName::Redo)
|
||||||
this.open_create_repo(window, cx);
|
.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(
|
||||||
|
Button::new("add")
|
||||||
|
.icon(IconName::Plus)
|
||||||
|
.small()
|
||||||
|
.ghost()
|
||||||
|
.on_click(cx.listener(|this, _ev, 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,18 +244,27 @@ 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(
|
||||||
.into_any_element()
|
&announcements,
|
||||||
|
&local_repos,
|
||||||
|
ix,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
.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
@@ -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.
|
|
||||||
|
|||||||
Reference in New Issue
Block a user