feat: add local repository scanning (#8)

Reviewed-on: https://git.reya.su/reya/signed/pulls/8
This commit was merged in pull request #8.
This commit is contained in:
2026-08-31 08:12:39 +00:00
parent 767638eda2
commit d2468545d6
22 changed files with 2029 additions and 572 deletions
@@ -2,56 +2,29 @@ use dock::{DockArea, DockPlacement, panel_handle};
use gpui::prelude::*;
use gpui::{App, Entity, PathPromptOptions, SharedString, WeakEntity, Window, div, px};
use gpui_base::input::TextareaState;
use gpui_component::button::{Button, ButtonVariants, Toggle, ToggleVariants};
use gpui_component::button::{Button, ButtonVariants};
use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle};
use gpui_component::form::{field, v_form};
use gpui_component::input::{Input, InputState, Textarea};
use gpui_component::{ActiveTheme, Disableable, IconName, Sizable, WindowExt, h_flex, v_flex};
use nostr::prelude::*;
use signed_core::{Announcement, filters};
use gpui_component::{ActiveTheme, Disableable, IconName, WindowExt, h_flex};
use signed_core::Announcement;
use signed_state::Backend;
use super::super::RepoDetailView;
/// Grasp servers offered when the user hasn't published a grasp list (kind `10317`) yet.
const DEFAULT_GRASP_SERVERS: [&str; 3] = [
"wss://relay.ngit.dev",
"wss://gitnostr.com",
"wss://git.shakespeare.diy",
];
use super::grasp_servers::{GraspServersState, grasp_servers_field, load_user_grasp_servers};
/// Shared state for the Create Repository dialog, so async results can be rendered.
#[derive(Default)]
pub struct CreateRepoState {
pub busy: bool,
/// The user's grasp list (kind `10317`) is being loaded.
pub loading_servers: bool,
pub error: Option<SharedString>,
pub grasp_servers: Vec<RelayUrl>,
/// Whether the grasp server section is shown; defaults to shown.
pub servers_enabled: bool,
}
impl CreateRepoState {
/// Defaults until the user's grasp list arrives; replaced by it when it lists any servers.
fn new_default() -> Self {
Self {
loading_servers: true,
servers_enabled: false,
grasp_servers: DEFAULT_GRASP_SERVERS
.iter()
.filter_map(|url| RelayUrl::parse(url).ok())
.collect(),
..Default::default()
}
}
}
/// Open the Create Repository dialog.
///
/// The dialog loads the user's default grasp servers (kind `10317` grasp
/// list) and falls back to [`DEFAULT_GRASP_SERVERS`] when none are set.
/// On success the dialog closes and the new repository opens in the dock.
/// list) and falls back to the shared defaults when none are set. On
/// success the dialog closes and the new repository opens in the dock.
pub fn open(dock_area: WeakEntity<DockArea>, window: &mut Window, cx: &mut App) {
let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("Repository name"));
let desc_input = cx.new(|cx| {
@@ -66,21 +39,21 @@ pub fn open(dock_area: WeakEntity<DockArea>, window: &mut Window, cx: &mut App)
let relay_input = cx.new(|cx| {
InputState::new(window, cx).placeholder("wss://relay.example.com or relay.example.com")
});
let state = cx.new(|_| CreateRepoState::new_default());
let state = cx.new(|_| CreateRepoState::default());
let grasp_state = cx.new(|_| GraspServersState::new_default());
load_user_grasp_servers(state.clone(), window, cx);
load_user_grasp_servers(grasp_state.clone(), window, cx);
window.open_dialog(cx, move |dialog, _window, _cx| {
const DESC: &str = "Publish a new repository to your grasp servers.";
const FOLDER_NOTE: &str = "Where the repository is stored, defaults to your Desktop";
const SERVER_NOTE: &str =
"Where the repository is hosted, the initial push goes to each server";
let name_input = name_input.clone();
let desc_input = desc_input.clone();
let folder_input = folder_input.clone();
let relay_input = relay_input.clone();
let state = state.clone();
let grasp_state = grasp_state.clone();
let dock_area = dock_area.clone();
dialog
@@ -89,9 +62,6 @@ pub fn open(dock_area: WeakEntity<DockArea>, window: &mut Window, cx: &mut App)
.content(move |content, _window, cx| {
let busy = state.read(cx).busy;
let error = state.read(cx).error.clone();
let servers = state.read(cx).grasp_servers.clone();
let loading_servers = state.read(cx).loading_servers;
let servers_enabled = state.read(cx).servers_enabled;
content
.child(
@@ -137,89 +107,7 @@ pub fn open(dock_area: WeakEntity<DockArea>, window: &mut Window, cx: &mut App)
),
),
)
.child(
field()
.label_fn({
let state = state.clone();
move |_window, cx| {
let enabled = state.read(cx).servers_enabled;
h_flex()
.w_full()
.justify_between()
.items_center()
.gap_1()
.child(
Toggle::new("grasp-servers-toggle")
.xsmall()
.ghost()
.icon({
if enabled {
IconName::ChevronDown
} else {
IconName::ChevronUp
}
})
.checked(enabled)
.on_click({
let state = state.clone();
move |checked, _window, cx| {
state.update(cx, |state, cx| {
state.servers_enabled =
*checked;
cx.notify();
});
}
}),
)
.child(div().child("Grasp servers"))
}
})
.when(servers_enabled, |this| this.description(SERVER_NOTE))
.child(v_flex().gap_1().when(servers_enabled, |this| {
this.children(servers.iter().enumerate().map(
|(ix, relay)| {
render_server_row(ix, relay, state.clone(), cx)
},
))
.child(
h_flex()
.gap_1()
.items_center()
.child(
div().flex_1().child(Input::new(&relay_input)),
)
.child(
Button::new("add-relay")
.icon(IconName::Plus)
.ghost()
.tooltip("Add grasp server")
.on_click({
let state = state.clone();
let relay_input = relay_input.clone();
move |_ev, window, cx| {
add_relay(
&state,
&relay_input,
window,
cx,
);
}
}),
),
)
.when(
loading_servers,
|this| {
this.child(
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child("Loading your grasp servers..."),
)
},
)
})),
),
.child(grasp_servers_field(&grasp_state, &relay_input, cx)),
)
.children(error.map(|message| {
div().text_sm().text_color(cx.theme().danger).child(message)
@@ -237,6 +125,7 @@ pub fn open(dock_area: WeakEntity<DockArea>, window: &mut Window, cx: &mut App)
let name_input = name_input.clone();
let desc_input = desc_input.clone();
let state = state.clone();
let grasp_state = grasp_state.clone();
let dock_area = dock_area.clone();
move |_ev, window, cx| {
@@ -244,6 +133,7 @@ pub fn open(dock_area: WeakEntity<DockArea>, window: &mut Window, cx: &mut App)
name_input.clone(),
desc_input.clone(),
state.clone(),
grasp_state.clone(),
dock_area.clone(),
window,
cx,
@@ -256,53 +146,6 @@ pub fn open(dock_area: WeakEntity<DockArea>, window: &mut Window, cx: &mut App)
});
}
/// A grasp server row: the host as a tag plus a remove button.
fn render_server_row(
ix: usize,
relay: &RelayUrl,
state: Entity<CreateRepoState>,
cx: &App,
) -> impl IntoElement {
h_flex()
.w_full()
.gap_1()
.items_center()
.child(
h_flex()
.h_8()
.w_full()
.px_2()
.bg(cx.theme().muted)
.text_color(cx.theme().muted_foreground)
.text_sm()
.rounded(cx.theme().radius)
.child(display_server(relay)),
)
.child(
Button::new(format!("remove-relay:{ix}"))
.icon(IconName::Close)
.ghost()
.flex_shrink_0()
.tooltip("Remove")
.on_click({
let state = state.clone();
move |_ev, _window, cx| {
state.update(cx, |state, _| {
state.grasp_servers.remove(ix);
});
}
}),
)
}
/// The bare host of a grasp server (defaults are entered without a scheme).
fn display_server(relay: &RelayUrl) -> SharedString {
relay
.domain()
.map(SharedString::from)
.unwrap_or_else(|| SharedString::from(relay.to_string()))
}
/// Prompt the user to pick the folder the repository will be stored in, using
/// the platform's native folder picker, and show the result in the disabled
/// folder input.
@@ -331,54 +174,19 @@ fn choose_folder(folder_input: &Entity<InputState>, window: &mut Window, cx: &mu
.detach();
}
/// Parse the relay input (accepting a bare host) and append it to the list.
fn add_relay(
state: &Entity<CreateRepoState>,
input: &Entity<InputState>,
window: &mut Window,
cx: &mut App,
) {
let value = input.read(cx).value().trim().to_owned();
if value.is_empty() {
return;
}
let normalized = if value.contains("://") {
value.clone()
} else {
format!("wss://{value}")
};
match RelayUrl::parse(&normalized) {
Ok(relay) => {
state.update(cx, |state, _| {
state.error = None;
if !state.grasp_servers.contains(&relay) {
state.grasp_servers.push(relay);
}
});
input.update(cx, |input, cx| input.set_value("", window, cx));
}
Err(_) => {
state.update(cx, |state, _| {
state.error = Some(format!("Invalid grasp server URL: {value}").into());
});
}
}
}
/// Run the create-repository flow; closes the dialog and opens the new repository on success.
fn create_repository(
name_input: Entity<InputState>,
desc_input: Entity<TextareaState>,
state: Entity<CreateRepoState>,
grasp_state: Entity<GraspServersState>,
dock_area: WeakEntity<DockArea>,
window: &mut Window,
cx: &mut App,
) {
let name = name_input.read(cx).value().trim().to_owned();
let description = desc_input.read(cx).value().trim().to_owned();
let servers = state.read(cx).grasp_servers.clone();
let servers = grasp_state.read(cx).grasp_servers.clone();
if name.is_empty() {
state.update(cx, |state, _| {
@@ -444,54 +252,3 @@ fn open_repo(
dock_area.add_panel_view(panel_handle(panel), DockPlacement::Center, None, window, cx);
});
}
/// Load the user's grasp list (kind `10317`) from the local database and
/// replace the defaults with it when it lists any servers.
fn load_user_grasp_servers(state: Entity<CreateRepoState>, window: &mut Window, cx: &mut App) {
let backend = Backend::global(cx);
let Some(user) = backend.read(cx).current_user() else {
state.update(cx, |state, _| state.loading_servers = false);
return;
};
let client = backend.read(cx).client();
let handle = window.window_handle();
cx.spawn(async move |cx| {
let result: anyhow::Result<Vec<RelayUrl>> = async {
let mut events: Vec<Event> = client
.database()
.query(filters::grasp_list(user))
.await?
.into_iter()
.collect();
events.sort_by_key(|event| event.created_at);
Ok(events
.into_iter()
.last()
.map(|event| {
event
.tags
.iter()
.filter(|tag| tag.kind() == "g")
.filter_map(|tag| tag.content())
.filter_map(|url| RelayUrl::parse(url).ok())
.collect()
})
.unwrap_or_default())
}
.await;
let _ = cx.update_window(handle, |_, _window, cx| {
state.update(cx, |state, _| {
state.loading_servers = false;
if let Ok(servers) = result
&& !servers.is_empty()
{
state.grasp_servers = servers;
}
});
});
})
.detach();
}
@@ -0,0 +1,278 @@
use gpui::prelude::*;
use gpui::{App, Entity, SharedString, Window, div};
use gpui_component::button::{Button, ButtonVariants, Toggle, ToggleVariants};
use gpui_component::form::{Field, field};
use gpui_component::input::{Input, InputState};
use gpui_component::{ActiveTheme, IconName, Sizable, h_flex, v_flex};
use nostr::prelude::*;
use signed_core::filters;
use signed_state::Backend;
/// Grasp servers offered when the user hasn't published a grasp list (kind `10317`) yet.
const DEFAULT_GRASP_SERVERS: [&str; 3] = [
"wss://relay.ngit.dev",
"wss://gitnostr.com",
"wss://git.shakespeare.diy",
];
/// State of the grasp-server section of a publish dialog, so async
/// results can be rendered.
#[derive(Default)]
pub struct GraspServersState {
/// The user's grasp list (kind `10317`) is being loaded.
pub loading_servers: bool,
pub grasp_servers: Vec<RelayUrl>,
/// Whether the grasp server section is shown; defaults to shown.
pub servers_enabled: bool,
/// Error of the last grasp-server edit (e.g. an invalid relay URL).
pub error: Option<SharedString>,
}
impl GraspServersState {
/// Defaults until the user's grasp list arrives; replaced by it when it lists any servers.
pub fn new_default() -> Self {
Self {
loading_servers: true,
servers_enabled: false,
grasp_servers: DEFAULT_GRASP_SERVERS
.iter()
.filter_map(|url| RelayUrl::parse(url).ok())
.collect(),
..Default::default()
}
}
}
/// The "Grasp servers" form field shared by the publish dialogs: an
/// expandable toggle, the configured servers (each removable) and an
/// add-relay input, with a loading hint while the user's grasp list
/// (kind `10317`) is being fetched.
pub fn grasp_servers_field(
state: &Entity<GraspServersState>,
relay_input: &Entity<InputState>,
cx: &App,
) -> Field {
const SERVER_NOTE: &str =
"Where the repository is hosted, the initial push goes to each server";
let state = state.clone();
let relay_input = relay_input.clone();
let servers = state.read(cx).grasp_servers.clone();
let loading_servers = state.read(cx).loading_servers;
let servers_enabled = state.read(cx).servers_enabled;
let error = state.read(cx).error.clone();
field()
.label_fn({
let state = state.clone();
move |_window, cx| {
let enabled = state.read(cx).servers_enabled;
h_flex()
.w_full()
.justify_between()
.items_center()
.gap_1()
.child(
Toggle::new("grasp-servers-toggle")
.xsmall()
.ghost()
.icon({
if enabled {
IconName::ChevronDown
} else {
IconName::ChevronUp
}
})
.checked(enabled)
.on_click({
let state = state.clone();
move |checked, _window, cx| {
state.update(cx, |state, cx| {
state.servers_enabled = *checked;
cx.notify();
});
}
}),
)
.child(div().child("Grasp servers"))
}
})
.when(servers_enabled, |this| this.description(SERVER_NOTE))
.child(v_flex().gap_1().when(servers_enabled, |this| {
this.children(
servers
.iter()
.enumerate()
.map(|(ix, relay)| render_server_row(ix, relay, state.clone(), cx)),
)
.child(
h_flex()
.gap_1()
.items_center()
.child(div().flex_1().child(Input::new(&relay_input)))
.child(
Button::new("add-relay")
.icon(IconName::Plus)
.ghost()
.tooltip("Add grasp server")
.on_click({
let state = state.clone();
let relay_input = relay_input.clone();
move |_ev, window, cx| {
add_relay(&state, &relay_input, window, cx);
}
}),
),
)
.when(loading_servers, |this| {
this.child(
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child("Loading your grasp servers..."),
)
})
.when_some(error, |this, error| {
this.child(div().text_xs().text_color(cx.theme().danger).child(error))
})
}))
}
/// A grasp server row: the host as a tag plus a remove button.
fn render_server_row(
ix: usize,
relay: &RelayUrl,
state: Entity<GraspServersState>,
cx: &App,
) -> impl IntoElement {
h_flex()
.w_full()
.gap_1()
.items_center()
.child(
h_flex()
.h_8()
.w_full()
.px_2()
.bg(cx.theme().muted)
.text_color(cx.theme().muted_foreground)
.text_sm()
.rounded(cx.theme().radius)
.child(display_server(relay)),
)
.child(
Button::new(format!("remove-relay:{ix}"))
.icon(IconName::Close)
.ghost()
.flex_shrink_0()
.tooltip("Remove")
.on_click({
let state = state.clone();
move |_ev, _window, cx| {
state.update(cx, |state, _| {
state.grasp_servers.remove(ix);
});
}
}),
)
}
/// The bare host of a grasp server (defaults are entered without a scheme).
fn display_server(relay: &RelayUrl) -> SharedString {
relay
.domain()
.map(SharedString::from)
.unwrap_or_else(|| SharedString::from(relay.to_string()))
}
/// Parse the relay input (accepting a bare host) and append it to the list.
fn add_relay(
state: &Entity<GraspServersState>,
input: &Entity<InputState>,
window: &mut Window,
cx: &mut App,
) {
let value = input.read(cx).value().trim().to_owned();
if value.is_empty() {
return;
}
let normalized = if value.contains("://") {
value.clone()
} else {
format!("wss://{value}")
};
match RelayUrl::parse(&normalized) {
Ok(relay) => {
state.update(cx, |state, _| {
state.error = None;
if !state.grasp_servers.contains(&relay) {
state.grasp_servers.push(relay);
}
});
input.update(cx, |input, cx| input.set_value("", window, cx));
}
Err(_) => {
state.update(cx, |state, _| {
state.error = Some(format!("Invalid grasp server URL: {value}").into());
});
}
}
}
/// Load the user's grasp list (kind `10317`) from the local database and
/// replace the defaults with it when it lists any servers.
pub fn load_user_grasp_servers(
state: Entity<GraspServersState>,
window: &mut Window,
cx: &mut App,
) {
let backend = Backend::global(cx);
let Some(user) = backend.read(cx).current_user() else {
state.update(cx, |state, _| state.loading_servers = false);
return;
};
let client = backend.read(cx).client();
let handle = window.window_handle();
cx.spawn(async move |cx| {
let result: anyhow::Result<Vec<RelayUrl>> = async {
let mut events: Vec<Event> = client
.database()
.query(filters::grasp_list(user))
.await?
.into_iter()
.collect();
events.sort_by_key(|event| event.created_at);
Ok(events
.into_iter()
.last()
.map(|event| {
event
.tags
.iter()
.filter(|tag| tag.kind() == "g")
.filter_map(|tag| tag.content())
.filter_map(|url| RelayUrl::parse(url).ok())
.collect()
})
.unwrap_or_default())
}
.await;
let _ = cx.update_window(handle, |_, _window, cx| {
state.update(cx, |state, _| {
state.loading_servers = false;
if let Ok(servers) = result
&& !servers.is_empty()
{
state.grasp_servers = servers;
}
});
});
})
.detach();
}
+155 -22
View File
@@ -1,4 +1,6 @@
use std::collections::HashSet;
use std::ops::Range;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use assets::CustomIconName;
@@ -17,14 +19,15 @@ use gpui_component::avatar::Avatar;
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_core::{Announcement, identifier_from_name};
use signed_state::{Backend, BackendEvent, LocalReposStore, Profile, ProfileStore, RepoListStore};
use super::{RepoDetailView, RepoListView};
use crate::image_cache::{MAX_IMAGES, image_cache};
use crate::pixel_avatar::PixelAvatar;
mod create_repo_dialog;
pub(crate) mod grasp_servers;
mod import_dialog;
mod onboarding_dialog;
pub(crate) mod passphrase_dialog;
@@ -37,20 +40,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 +77,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,
};
@@ -166,11 +177,32 @@ impl SidebarPanel {
});
}
/// Open a local repository's detail view in the dock's center; the
/// detail view offers to publish it to NIP-34.
fn open_local_repo(&mut self, path: PathBuf, window: &mut Window, cx: &mut Context<Self>) {
let detail =
cx.new(|cx| RepoDetailView::new_local(self.dock_area.clone(), path, window, cx));
let _ = self.dock_area.update(cx, |dock_area, cx| {
dock_area.add_panel_view(
panel_handle(detail),
DockPlacement::Center,
None,
window,
cx,
);
});
}
/// 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 +225,54 @@ 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(CustomIconName::Refresh)
.small()
.ghost()
.tooltip("Rescan for local repositories")
.on_click(cx.listener(|_this, _ev, _window, cx| {
let local_repos = LocalReposStore::global(cx);
local_repos.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();
// Local repositories that have already been published to
// NIP-34 are listed among the user's repositories above;
// hide them from the local section (matched by the
// identifier derived from the directory name, like the
// init dialog's default name).
let announced_ids: HashSet<String> =
announcements.iter().map(|a| a.id.clone()).collect();
let local_repos: Vec<PathBuf> = local_repos
.iter()
.filter(|path| {
let Some(name) = path.file_name() else {
return true;
};
!announced_ids.contains(&identifier_from_name(&name.to_string_lossy()))
})
.cloned()
.collect();
// 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 +280,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(),
"repos",
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 +312,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 +350,32 @@ 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. Clicking it opens the repository's
/// detail view, which offers to initialize it.
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());
let path = path.to_path_buf();
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),
)
.on_click(cx.listener(move |this, _ev, window, cx| {
this.open_local_repo(path.clone(), window, cx);
}))
}
/// Show the Import Identity dialog.
fn open_import(&mut self, window: &mut Window, cx: &mut Context<Self>) {
import_dialog::open(window, cx);
@@ -491,8 +612,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 +621,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 +638,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 +675,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))
}