scan local repo
This commit is contained in:
@@ -41,6 +41,12 @@ pub fn desktop_dir() -> PathBuf {
|
||||
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
|
||||
/// directory. Must be called before any other path operation. The directory
|
||||
/// 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_urls` (the announcement's `clone` tag), then fetch the
|
||||
/// `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]
|
||||
fn repo_ref_state_lists_branches_tags_and_head() {
|
||||
let (_dir, repo) = fixture(&[("a.txt", b"hello")]);
|
||||
|
||||
@@ -23,3 +23,4 @@ log.workspace = true
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
rustls = "0.23"
|
||||
paths = { path = "../paths" }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
mod backend;
|
||||
mod git_store;
|
||||
mod local_repos;
|
||||
mod profile;
|
||||
mod repo;
|
||||
mod repo_list;
|
||||
@@ -9,6 +10,7 @@ use std::path::{Path, PathBuf};
|
||||
pub use backend::{Backend, BackendEvent};
|
||||
pub use git_store::GitStore;
|
||||
use gpui::{App, AppContext, Entity};
|
||||
pub use local_repos::LocalReposStore;
|
||||
pub use nostr_sdk::prelude::Timestamp;
|
||||
pub use profile::{Profile, ProfileStore};
|
||||
pub use repo::RepoStore;
|
||||
@@ -16,8 +18,15 @@ pub use repo_list::{RepoActivityCounts, RepoListStore};
|
||||
use signed_nostr::new_backend;
|
||||
pub use utils::shorten_pubkey;
|
||||
|
||||
/// Initialize the backend and stores, and install them as globals. Call once
|
||||
/// at startup, before opening any window that uses the stores.
|
||||
/// The default directories scanned for local git repositories
|
||||
/// 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"))]
|
||||
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.
|
||||
@@ -25,46 +34,64 @@ pub fn init(db_path: impl AsRef<Path>, cx: &mut App) -> Entity<Backend> {
|
||||
.install_default()
|
||||
.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 path = db_path.as_ref().to_path_buf();
|
||||
new_backend(path)
|
||||
.await
|
||||
.expect("failed to initialize nostr backend")
|
||||
});
|
||||
|
||||
// Initialize the backend and stores.
|
||||
let entity = cx.new(|cx| Backend::new(client, signer, cx));
|
||||
Backend::set_global(entity.clone(), cx);
|
||||
|
||||
// Initialize the profile store.
|
||||
ProfileStore::set_global(cx.new(ProfileStore::new), cx);
|
||||
|
||||
// Start the explore list from the local database before the first
|
||||
// window opens; relay syncs continue in the background, so the list
|
||||
// never waits for them.
|
||||
// Start the explore list from the local database before
|
||||
// the first window opens, relay syncs continue in the background,
|
||||
// so the list never waits for them.
|
||||
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.
|
||||
// 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);
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
/// Initialize the backend with an in-memory database on wasm.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
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");
|
||||
|
||||
// Initialize the backend and stores.
|
||||
let entity = cx.new(|cx| Backend::new(client, signer, cx));
|
||||
Backend::set_global(entity.clone(), cx);
|
||||
|
||||
// Initialize the profile store.
|
||||
ProfileStore::set_global(cx.new(ProfileStore::new), cx);
|
||||
|
||||
// Start the explore list from the local database before the first
|
||||
// window opens; relay syncs continue in the background, so the list
|
||||
// never waits for them.
|
||||
// Start the explore list from the local database before
|
||||
// the first window opens, relay syncs continue in the background,
|
||||
// so the list never waits for them.
|
||||
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);
|
||||
|
||||
// No filesystem scan on wasm: there are no local git repositories.
|
||||
LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(Vec::new(), cx)), cx);
|
||||
|
||||
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::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use assets::CustomIconName;
|
||||
@@ -18,7 +19,7 @@ use gpui_component::button::{Button, ButtonVariants};
|
||||
use gpui_component::input::InputState;
|
||||
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, StyledExt, h_flex, v_flex};
|
||||
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 crate::image_cache::{MAX_IMAGES, image_cache};
|
||||
@@ -37,20 +38,23 @@ pub struct SidebarPanel {
|
||||
focus_handle: FocusHandle,
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
explore: Option<WeakEntity<RepoListView>>,
|
||||
logged_in: bool,
|
||||
/// Repositories announced by the current user, listed under
|
||||
/// "All Repositories". Recreated when the signer changes.
|
||||
my_repos: Option<Entity<RepoListStore>>,
|
||||
/// Observes the current user's repo store so the list re-renders.
|
||||
my_repos_subscription: Option<Subscription>,
|
||||
logged_in: bool,
|
||||
/// Banner artwork shown behind the sign-in screen,
|
||||
/// picked at random from the bundled `backgrounds/` assets.
|
||||
banner: SharedString,
|
||||
/// Observes the local-repository scan so new discoveries re-render.
|
||||
_local_repos_subscription: Subscription,
|
||||
_subscription: Subscription,
|
||||
}
|
||||
|
||||
impl SidebarPanel {
|
||||
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 logged_in = backend.read(cx).current_user().is_some();
|
||||
|
||||
@@ -71,14 +75,19 @@ impl SidebarPanel {
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
let local_repos_subscription = cx.observe(&local_repos_store, |_, _, cx| {
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
let mut panel = Self {
|
||||
focus_handle: cx.focus_handle(),
|
||||
dock_area,
|
||||
logged_in,
|
||||
explore: None,
|
||||
my_repos: None,
|
||||
my_repos_subscription: None,
|
||||
logged_in,
|
||||
banner: pick_banner(),
|
||||
_local_repos_subscription: local_repos_subscription,
|
||||
_subscription: subscription,
|
||||
};
|
||||
|
||||
@@ -168,9 +177,13 @@ impl SidebarPanel {
|
||||
|
||||
/// The "All Repositories" section: header with the create button and
|
||||
/// 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 {
|
||||
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()
|
||||
.px_2()
|
||||
@@ -193,19 +206,37 @@ impl SidebarPanel {
|
||||
.child(div().text_xs().font_semibold().child("All Repositories")),
|
||||
)
|
||||
.child(
|
||||
Button::new("add")
|
||||
.icon(IconName::Plus)
|
||||
.small()
|
||||
.ghost()
|
||||
.on_click(cx.listener(|this, _ev, window, cx| {
|
||||
this.open_create_repo(window, cx);
|
||||
})),
|
||||
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(
|
||||
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| {
|
||||
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(
|
||||
div()
|
||||
.flex_1()
|
||||
@@ -213,18 +244,27 @@ impl SidebarPanel {
|
||||
.py_1()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child("No repositories yet"),
|
||||
.child(if scanning {
|
||||
"Scanning for local repositories…"
|
||||
} else {
|
||||
"No repositories yet"
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
builder.child(
|
||||
uniform_list(
|
||||
"my-repos-list",
|
||||
announcements.len(),
|
||||
total,
|
||||
cx.processor(move |this, range: Range<usize>, _window, cx| {
|
||||
range
|
||||
.map(|ix| {
|
||||
this.render_repo_row(&announcements[ix], cx)
|
||||
.into_any_element()
|
||||
this.render_repo_row_at(
|
||||
&announcements,
|
||||
&local_repos,
|
||||
ix,
|
||||
cx,
|
||||
)
|
||||
.into_any_element()
|
||||
})
|
||||
.collect()
|
||||
}),
|
||||
@@ -236,8 +276,27 @@ impl SidebarPanel {
|
||||
})
|
||||
}
|
||||
|
||||
/// One repository row in the sidebar, styled like the nav items: a
|
||||
/// deterministic pixel avatar and the repo name.
|
||||
/// One row of the merged sidebar list: a NIP-34 repository or a local
|
||||
/// 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(
|
||||
&self,
|
||||
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.
|
||||
fn open_import(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
import_dialog::open(window, cx);
|
||||
@@ -491,8 +572,8 @@ impl Render for SidebarPanel {
|
||||
}
|
||||
|
||||
/// A single navigation entry in the sidebar: an arbitrary leading element
|
||||
/// (an icon, avatar, ...) and a text label with a hover highlight and an
|
||||
/// optional click handler.
|
||||
/// (an icon, avatar, ...) and a text label with a hover highlight,
|
||||
/// an optional trailing suffix (e.g. a status icon) and an optional click handler.
|
||||
#[allow(clippy::type_complexity)]
|
||||
#[derive(IntoElement)]
|
||||
struct NavItem {
|
||||
@@ -500,6 +581,8 @@ struct NavItem {
|
||||
style: StyleRefinement,
|
||||
icon: AnyElement,
|
||||
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>>,
|
||||
}
|
||||
|
||||
@@ -515,10 +598,17 @@ impl NavItem {
|
||||
icon: icon.into_any_element(),
|
||||
label: label.into(),
|
||||
style: StyleRefinement::default(),
|
||||
suffix: 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 {
|
||||
self.on_click = Some(Box::new(listener));
|
||||
self
|
||||
@@ -545,6 +635,9 @@ impl RenderOnce for NavItem {
|
||||
.text_ellipsis()
|
||||
.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))
|
||||
.when_some(self.on_click, |this, listener| this.on_click(listener))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user