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:
@@ -204,21 +204,22 @@ fn list(id: &'static str, items: impl IntoIterator<Item = String>, cx: &App) ->
|
||||
.min_w_0()
|
||||
.children(items.into_iter().enumerate().map(|(ix, item)| {
|
||||
h_flex()
|
||||
.id(ix)
|
||||
.h_8()
|
||||
.px_2()
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.min_w_0()
|
||||
.bg(cx.theme().secondary)
|
||||
.hover(|this| this.bg(cx.theme().secondary_hover))
|
||||
.rounded(cx.theme().radius)
|
||||
.text_color(cx.theme().secondary_foreground)
|
||||
.child(
|
||||
h_flex()
|
||||
.h_5()
|
||||
.px_1()
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.overflow_hidden()
|
||||
.whitespace_nowrap()
|
||||
.text_ellipsis()
|
||||
.text_sm()
|
||||
.bg(cx.theme().muted)
|
||||
.rounded(cx.theme().radius)
|
||||
.child(SharedString::from(middle_truncate(&item, 28, 16))),
|
||||
)
|
||||
.child(
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use assets::CustomIconName;
|
||||
use gpui::prelude::*;
|
||||
use gpui::{App, Entity, SharedString, WeakEntity, Window, div, px};
|
||||
use gpui_base::h_flex;
|
||||
use gpui_base::input::TextareaState;
|
||||
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, WindowExt};
|
||||
use signed_state::Backend;
|
||||
|
||||
use super::RepoDetailView;
|
||||
use crate::views::sidebar::grasp_servers::{
|
||||
GraspServersState, grasp_servers_field, load_user_grasp_servers,
|
||||
};
|
||||
|
||||
/// Shared state for the Init dialog, so async results can be rendered.
|
||||
#[derive(Default)]
|
||||
pub struct InitRepoState {
|
||||
pub busy: bool,
|
||||
pub error: Option<SharedString>,
|
||||
}
|
||||
|
||||
/// Open the Init dialog for the local repository at `local_path`.
|
||||
///
|
||||
/// The dialog loads the user's default grasp servers (kind `10317` grasp
|
||||
/// list) and falls back to the shared defaults when none are set. On
|
||||
/// success the dialog closes and `view` switches into NIP-34 mode.
|
||||
pub fn open(
|
||||
local_path: PathBuf,
|
||||
view: WeakEntity<RepoDetailView>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let default_name = local_path
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
let name_input = cx.new(|cx| InputState::new(window, cx).default_value(default_name));
|
||||
let desc_input = cx.new(|cx| {
|
||||
TextareaState::new(window, cx)
|
||||
.auto_grow(3, 5)
|
||||
.placeholder("Short description")
|
||||
});
|
||||
let relay_input = cx.new(|cx| {
|
||||
InputState::new(window, cx).placeholder("wss://relay.example.com or relay.example.com")
|
||||
});
|
||||
let state = cx.new(|_| InitRepoState::default());
|
||||
let grasp_state = cx.new(|_| GraspServersState::new_default());
|
||||
|
||||
load_user_grasp_servers(grasp_state.clone(), window, cx);
|
||||
|
||||
window.open_dialog(cx, move |dialog, _window, _cx| {
|
||||
const DESC: &str = "Publish this local repository to Nostr.";
|
||||
|
||||
let name_input = name_input.clone();
|
||||
let desc_input = desc_input.clone();
|
||||
let relay_input = relay_input.clone();
|
||||
let state = state.clone();
|
||||
let grasp_state = grasp_state.clone();
|
||||
let local_path = local_path.clone();
|
||||
let view = view.clone();
|
||||
|
||||
dialog
|
||||
.width(px(520.))
|
||||
.margin_top(px(50.))
|
||||
.content(move |content, _window, cx| {
|
||||
let busy = state.read(cx).busy;
|
||||
let error = state.read(cx).error.clone();
|
||||
|
||||
content
|
||||
.child(
|
||||
DialogHeader::new()
|
||||
.child(DialogTitle::new().child("Initialize repository"))
|
||||
.child(DialogDescription::new().child(DESC)),
|
||||
)
|
||||
.child(
|
||||
v_form()
|
||||
.child(
|
||||
field()
|
||||
.label("Repository name")
|
||||
.description("Max 100 characters")
|
||||
.required(true)
|
||||
.child(Input::new(&name_input).readonly(true)),
|
||||
)
|
||||
.child(
|
||||
field()
|
||||
.label("Description")
|
||||
.child(Textarea::new(&desc_input)),
|
||||
)
|
||||
.child(
|
||||
field()
|
||||
.label("Folder")
|
||||
.description("The local repository being published")
|
||||
.child(
|
||||
h_flex()
|
||||
.h_8()
|
||||
.w_full()
|
||||
.px_2()
|
||||
.bg(cx.theme().muted)
|
||||
.text_sm()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.rounded(cx.theme().radius)
|
||||
.child(local_path.display().to_string()),
|
||||
),
|
||||
)
|
||||
.child(grasp_servers_field(&grasp_state, &relay_input, cx)),
|
||||
)
|
||||
.children(error.map(|message| {
|
||||
div().text_sm().text_color(cx.theme().danger).child(message)
|
||||
}))
|
||||
.child(
|
||||
DialogFooter::new().justify_end().child(
|
||||
Button::new("init")
|
||||
.primary()
|
||||
.label("Initialize")
|
||||
.icon(CustomIconName::Init)
|
||||
.tooltip("Publish to Nostr")
|
||||
.loading(busy)
|
||||
.disabled(busy)
|
||||
.on_click({
|
||||
let name_input = name_input.clone();
|
||||
let desc_input = desc_input.clone();
|
||||
let state = state.clone();
|
||||
let grasp_state = grasp_state.clone();
|
||||
let local_path = local_path.clone();
|
||||
let view = view.clone();
|
||||
|
||||
move |_ev, window, cx| {
|
||||
init_repository(
|
||||
local_path.clone(),
|
||||
(name_input.clone(), desc_input.clone()),
|
||||
state.clone(),
|
||||
grasp_state.clone(),
|
||||
view.clone(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/// Run the init flow; closes the dialog and switches the repository into
|
||||
/// its NIP-34 mode on success.
|
||||
fn init_repository(
|
||||
local_path: PathBuf,
|
||||
inputs: (Entity<InputState>, Entity<TextareaState>),
|
||||
state: Entity<InitRepoState>,
|
||||
grasp_state: Entity<GraspServersState>,
|
||||
view: WeakEntity<RepoDetailView>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let (name_input, desc_input) = inputs;
|
||||
let name = name_input.read(cx).value().trim().to_owned();
|
||||
let description = desc_input.read(cx).value().trim().to_owned();
|
||||
let servers = grasp_state.read(cx).grasp_servers.clone();
|
||||
|
||||
if name.is_empty() {
|
||||
state.update(cx, |state, _| {
|
||||
state.error = Some("Repository name is required".into());
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if servers.is_empty() {
|
||||
state.update(cx, |state, _| {
|
||||
state.error = Some("Add at least one grasp server".into());
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
state.update(cx, |state, _| {
|
||||
state.busy = true;
|
||||
state.error = None;
|
||||
});
|
||||
|
||||
let backend = Backend::global(cx);
|
||||
let task = backend.update(cx, |backend, cx| {
|
||||
backend.publish_local_repo(local_path.clone(), &name, &description, servers, cx)
|
||||
});
|
||||
|
||||
let handle = window.window_handle();
|
||||
let state = state.clone();
|
||||
let view = view.clone();
|
||||
|
||||
cx.spawn(async move |cx| match task.await {
|
||||
Ok(announcement) => {
|
||||
cx.update_window(handle, |_, window, cx| {
|
||||
window.close_dialog(cx);
|
||||
if let Some(view) = view.upgrade() {
|
||||
view.update(cx, |this, cx| {
|
||||
this.apply_announcement(announcement, cx);
|
||||
});
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
cx.update_window(handle, |_, _window, cx| {
|
||||
state.update(cx, |state, _| {
|
||||
state.busy = false;
|
||||
state.error = Some(e.to_string().into());
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
@@ -13,12 +13,14 @@ use gpui::{
|
||||
Task, WeakEntity, Window, div, px, relative, size,
|
||||
};
|
||||
use gpui_base::{Button as BaseButton, Disableable, Popover};
|
||||
use gpui_component::alert::Alert;
|
||||
use gpui_component::avatar::Avatar;
|
||||
use gpui_component::button::{Button, ButtonVariants};
|
||||
use gpui_component::clipboard::Clipboard;
|
||||
use gpui_component::combobox::{
|
||||
Caret, Combobox, ComboboxEvent, ComboboxState, ComboboxTriggerContext,
|
||||
};
|
||||
use gpui_component::menu::DropdownMenu;
|
||||
use gpui_component::searchable_list::SearchableVec;
|
||||
use gpui_component::tree::TreeState;
|
||||
use gpui_component::{
|
||||
@@ -28,7 +30,7 @@ use gpui_component::{
|
||||
use nostr::prelude::{RelayUrl, ToBech32};
|
||||
use signed_core::Announcement;
|
||||
use signed_git::{CommitList, FileCommit};
|
||||
use signed_state::{GitStore, ProfileStore, RepoStore};
|
||||
use signed_state::{Backend, GitStore, LocalReposStore, ProfileStore, RepoStore};
|
||||
|
||||
use crate::image_cache::{MAX_IMAGES, image_cache};
|
||||
use crate::pixel_avatar::PixelAvatar;
|
||||
@@ -38,6 +40,7 @@ mod browser;
|
||||
mod commits;
|
||||
mod diff;
|
||||
mod helpers;
|
||||
mod init_dialog;
|
||||
mod issue_detail;
|
||||
mod issues;
|
||||
mod pull_request_detail;
|
||||
@@ -73,6 +76,12 @@ enum RepoAction {
|
||||
NewIssue,
|
||||
/// Open the "new pull request" dialog.
|
||||
NewPR,
|
||||
/// Open the about dialog.
|
||||
About,
|
||||
/// Re-push the repository to its grasp servers.
|
||||
Push,
|
||||
/// Delete the repository from nostr (owner only).
|
||||
Delete,
|
||||
}
|
||||
|
||||
/// Everything loaded from the local clone for the explorer: the tree seeds,
|
||||
@@ -98,9 +107,15 @@ pub struct RepoDetailView {
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
/// Snapshot taken at open time, shown until the store's first refresh
|
||||
/// completes (and as a fallback while the store has no announcement).
|
||||
initial: Announcement,
|
||||
/// `None` for local repositories that haven't been published yet.
|
||||
initial: Option<Announcement>,
|
||||
/// Per-repository nostr store (announcement, issues, PRs, statuses).
|
||||
store: Entity<RepoStore>,
|
||||
/// `None` until a local repository is initialized (published) to
|
||||
/// NIP-34.
|
||||
store: Option<Entity<RepoStore>>,
|
||||
/// Path of the local repository when opened from the scan; `None` once
|
||||
/// it has been initialized to NIP-34 (or for announced repositories).
|
||||
local_path: Option<PathBuf>,
|
||||
/// File explorer state (worktree of the local clone).
|
||||
tree_state: Entity<TreeState>,
|
||||
/// Root of the local clone, for reading files on demand.
|
||||
@@ -141,6 +156,8 @@ pub struct RepoDetailView {
|
||||
loading: bool,
|
||||
/// The header clone button is cloning into a user-chosen folder.
|
||||
cloning: bool,
|
||||
/// A push to the grasp servers is in flight.
|
||||
pushing: bool,
|
||||
error: Option<SharedString>,
|
||||
/// Commit HEAD currently points to, shown in the header button.
|
||||
head_commit: Option<FileCommit>,
|
||||
@@ -161,6 +178,8 @@ pub struct RepoDetailView {
|
||||
}
|
||||
|
||||
impl RepoDetailView {
|
||||
/// Open a repository announced on NIP-34: the store connects to the
|
||||
/// announcement's relays and loads issues, PRs and statuses.
|
||||
pub fn new(
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
initial: Announcement,
|
||||
@@ -170,7 +189,36 @@ impl RepoDetailView {
|
||||
// The announcement we opened from already carries the repository's
|
||||
// NIP-34 `relays` tag, so the store can connect to those relays
|
||||
// immediately instead of waiting for the bootstrap fetch.
|
||||
let store = cx.new(|cx| RepoStore::new(initial.addr(), initial.relays.clone(), cx));
|
||||
let addr = initial.addr();
|
||||
let relays = initial.relays.clone();
|
||||
let store = cx.new(|cx| RepoStore::new(addr, relays, cx));
|
||||
|
||||
Self::new_common(dock_area, Some(initial), Some(store), None, window, cx)
|
||||
}
|
||||
|
||||
/// Open a local repository discovered by the scan. There is no
|
||||
/// announcement and no nostr store until the user initializes
|
||||
/// (publishes) it to NIP-34, so the header shows an Init button
|
||||
/// instead of the NIP-34 actions.
|
||||
pub fn new_local(
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
local_path: PathBuf,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
Self::new_common(dock_area, None, None, Some(local_path), window, cx)
|
||||
}
|
||||
|
||||
/// Shared construction: file explorer state, ref selectors and the
|
||||
/// deferred repository load.
|
||||
fn new_common(
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
initial: Option<Announcement>,
|
||||
store: Option<Entity<RepoStore>>,
|
||||
local_path: Option<PathBuf>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let tree_state = cx.new(|cx| TreeState::new(cx));
|
||||
|
||||
// Empty until the clone completes; populated with the local refs.
|
||||
@@ -222,6 +270,7 @@ impl RepoDetailView {
|
||||
initial,
|
||||
dock_area,
|
||||
store,
|
||||
local_path,
|
||||
tree_state,
|
||||
worktree: None,
|
||||
md: None,
|
||||
@@ -242,6 +291,7 @@ impl RepoDetailView {
|
||||
item_sizes: Rc::new(Vec::new()),
|
||||
loading: true,
|
||||
cloning: false,
|
||||
pushing: false,
|
||||
error: None,
|
||||
head_commit: None,
|
||||
branch_select,
|
||||
@@ -254,19 +304,49 @@ impl RepoDetailView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Load the repository and populate the file explorer. The local clone
|
||||
/// (if any) is loaded first without touching the network, so an
|
||||
/// unreachable server can't block the panel; a background fetch then
|
||||
/// refreshes the refs and commit list (a fetch never changes the
|
||||
/// checked-out files, so the tree and previews are left alone).
|
||||
/// Load the repository and populate the file explorer. A local
|
||||
/// (not yet published) repository is opened straight from disk. An
|
||||
/// announced repository's local clone (if any) is loaded first without
|
||||
/// touching the network, so an unreachable server can't block the
|
||||
/// panel; a background fetch then refreshes the refs and commit list
|
||||
/// (a fetch never changes the checked-out files, so the tree and
|
||||
/// previews are left alone).
|
||||
fn load_repo(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.loading = true;
|
||||
self.error = None;
|
||||
cx.notify();
|
||||
|
||||
// Local repositories live on disk at their scan path; there is no
|
||||
// clone to ensure and no network refresh.
|
||||
if let Some(local_path) = self.local_path.clone() {
|
||||
let task = cx.spawn_in(window, async move |this, cx| {
|
||||
let data = cx
|
||||
.background_spawn(async move {
|
||||
let repo = gix::open(&local_path)?;
|
||||
load_repo_data(&repo)
|
||||
})
|
||||
.await;
|
||||
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
match data {
|
||||
Ok(data) => this.apply_repo_data(data, window, cx),
|
||||
Err(error) => this.error = Some(error.to_string().into()),
|
||||
}
|
||||
this.loading = false;
|
||||
cx.notify();
|
||||
})?;
|
||||
Ok(())
|
||||
});
|
||||
self.tasks.push(task);
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(initial) = self.initial.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let cache = GitStore::global(cx).cache().clone();
|
||||
let addr = self.initial.addr();
|
||||
let clone_urls: Vec<String> = self.initial.clone.iter().map(ToString::to_string).collect();
|
||||
let addr = initial.addr();
|
||||
let clone_urls: Vec<String> = initial.clone.iter().map(ToString::to_string).collect();
|
||||
// Captured before the loads start: a branch/tag switch bumps it, and
|
||||
// the refresh below is discarded when that happens.
|
||||
let refresh_generation = self.ref_generation;
|
||||
@@ -434,7 +514,9 @@ impl RepoDetailView {
|
||||
}
|
||||
|
||||
let (clone_urls, name) = {
|
||||
let announcement = self.announcement(cx);
|
||||
let Some(announcement) = self.announcement(cx) else {
|
||||
return;
|
||||
};
|
||||
let addr = announcement.addr();
|
||||
let clone_urls: Vec<String> =
|
||||
announcement.clone.iter().map(ToString::to_string).collect();
|
||||
@@ -757,8 +839,65 @@ impl RepoDetailView {
|
||||
});
|
||||
}
|
||||
|
||||
/// Re-push the repository's refs to its announced grasp servers; the
|
||||
/// menu trigger shows a spinner while the push is in flight, failures
|
||||
/// appear in the panel's error banner.
|
||||
fn push_repository(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.pushing {
|
||||
return;
|
||||
}
|
||||
let Some(announcement) = self.announcement(cx).cloned() else {
|
||||
return;
|
||||
};
|
||||
self.pushing = true;
|
||||
self.error = None;
|
||||
cx.notify();
|
||||
|
||||
let backend = Backend::global(cx);
|
||||
let task = backend.update(cx, |backend, cx| backend.push_repository(announcement, cx));
|
||||
|
||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||
let result = task.await;
|
||||
this.update_in(cx, |this, _window, cx| {
|
||||
if let Err(error) = result {
|
||||
this.error = Some(format!("Push failed: {error}").into());
|
||||
}
|
||||
this.pushing = false;
|
||||
cx.notify();
|
||||
})?;
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Delete the repository from nostr (announcement, state and activity);
|
||||
/// only offered to the repository owner. The sidebar list updates when
|
||||
/// the deletion events arrive.
|
||||
fn delete_repository(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(announcement) = self.announcement(cx).cloned() else {
|
||||
return;
|
||||
};
|
||||
let backend = Backend::global(cx);
|
||||
let task = backend.update(cx, |backend, cx| {
|
||||
backend.delete_repository(announcement.addr(), cx)
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||
let result = task.await;
|
||||
this.update_in(cx, |this, _window, cx| {
|
||||
if let Err(error) = result {
|
||||
this.error = Some(format!("Delete failed: {error}").into());
|
||||
}
|
||||
cx.notify();
|
||||
})?;
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Open the issues panel at the bottom of the dock area.
|
||||
fn open_issue_detail(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(store) = self.store.clone() else {
|
||||
return;
|
||||
};
|
||||
let Some(dock_area) = self.dock_area.upgrade() else {
|
||||
return;
|
||||
};
|
||||
@@ -766,7 +905,7 @@ impl RepoDetailView {
|
||||
let panel = cx.new(|cx| {
|
||||
IssuesView::new(
|
||||
self.dock_area.clone(),
|
||||
self.store.clone(),
|
||||
store,
|
||||
self.display_name(cx),
|
||||
window,
|
||||
cx,
|
||||
@@ -780,6 +919,9 @@ impl RepoDetailView {
|
||||
|
||||
/// Open the pull requests panel at the bottom of the dock area.
|
||||
fn open_pull_request_detail(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(store) = self.store.clone() else {
|
||||
return;
|
||||
};
|
||||
let Some(dock_area) = self.dock_area.upgrade() else {
|
||||
return;
|
||||
};
|
||||
@@ -787,7 +929,7 @@ impl RepoDetailView {
|
||||
let panel = cx.new(|cx| {
|
||||
PullRequestsView::new(
|
||||
self.dock_area.clone(),
|
||||
self.store.clone(),
|
||||
store,
|
||||
self.display_name(cx),
|
||||
window,
|
||||
cx,
|
||||
@@ -1025,39 +1167,66 @@ impl RepoDetailView {
|
||||
}
|
||||
}
|
||||
|
||||
/// The latest announcement from the store, or the open-time snapshot.
|
||||
fn announcement<'a>(&'a self, cx: &'a App) -> &'a Announcement {
|
||||
self.store
|
||||
/// The latest announcement from the store, or the open-time snapshot;
|
||||
/// `None` for local repositories that haven't been published yet.
|
||||
fn announcement<'a>(&'a self, cx: &'a App) -> Option<&'a Announcement> {
|
||||
let store = self.store.as_ref()?;
|
||||
store
|
||||
.read(cx)
|
||||
.announcement
|
||||
.as_ref()
|
||||
.unwrap_or(&self.initial)
|
||||
.or(self.initial.as_ref())
|
||||
}
|
||||
|
||||
/// Display name: the announcement's name, or the ID if no name is set.
|
||||
/// Display name: the announcement's name (or ID) for announced
|
||||
/// repositories, the directory name for local ones.
|
||||
fn display_name(&self, cx: &App) -> SharedString {
|
||||
let announcement = self.announcement(cx);
|
||||
announcement
|
||||
.name
|
||||
.clone()
|
||||
.unwrap_or_else(|| SharedString::from(announcement.id.clone()))
|
||||
if let Some(path) = &self.local_path {
|
||||
return SharedString::from(
|
||||
path.file_name()
|
||||
.map(|name| name.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| path.display().to_string()),
|
||||
);
|
||||
}
|
||||
self.announcement(cx)
|
||||
.map(|announcement| {
|
||||
announcement
|
||||
.name
|
||||
.clone()
|
||||
.unwrap_or_else(|| SharedString::from(announcement.id.clone()))
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The NIP-34 header (actions, issues/PR counts) or, for a local
|
||||
/// repository that hasn't been published yet, the local header with an
|
||||
/// Init button.
|
||||
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let store = self.store.read(cx);
|
||||
let announcement = store.announcement.as_ref().unwrap_or(&self.initial);
|
||||
if self.local_path.is_some() {
|
||||
return self.render_local_header(cx);
|
||||
}
|
||||
|
||||
let Some(store_entity) = self.store.as_ref() else {
|
||||
return div().into_any_element();
|
||||
};
|
||||
let store = store_entity.read(cx);
|
||||
let Some(announcement) = store
|
||||
.announcement
|
||||
.as_ref()
|
||||
.or(self.initial.as_ref())
|
||||
.cloned()
|
||||
else {
|
||||
return div().into_any_element();
|
||||
};
|
||||
let issue_count = SharedString::from(store.issue_count().to_string());
|
||||
let pr_count = SharedString::from(store.pull_request_count().to_string());
|
||||
|
||||
let name = self.display_name(cx);
|
||||
let description = announcement.description();
|
||||
let avatar = PixelAvatar::new(format!("{}:{}", announcement.owner, announcement.id));
|
||||
let share = ShareTargets::from_announcement(announcement);
|
||||
let share = ShareTargets::from_announcement(&announcement);
|
||||
|
||||
let commits_count = self.all_commits.as_ref().map(|list| list.total);
|
||||
let worktree_empty = self.switching_ref || self.worktree.is_none();
|
||||
|
||||
let nostr_url = nostr_clone_url(announcement, cx);
|
||||
let nostr_url = nostr_clone_url(&announcement, cx);
|
||||
let ngit_command = SharedString::from(format!("git clone {nostr_url}"));
|
||||
let nak_command = SharedString::from(format!("nak git clone {nostr_url}"));
|
||||
let git_commands = announcement.clone_urls();
|
||||
@@ -1066,11 +1235,22 @@ impl RepoDetailView {
|
||||
.on_action(
|
||||
cx.listener(|this, action: &RepoAction, window, cx| match action {
|
||||
RepoAction::NewIssue => {
|
||||
open_new_issue_dialog(this.store.clone(), window, cx);
|
||||
if let Some(store) = this.store.clone() {
|
||||
open_new_issue_dialog(store, window, cx);
|
||||
}
|
||||
}
|
||||
RepoAction::NewPR => {
|
||||
open_new_pull_request_dialog(this.store.clone(), window, cx);
|
||||
if let Some(store) = this.store.clone() {
|
||||
open_new_pull_request_dialog(store, window, cx);
|
||||
}
|
||||
}
|
||||
RepoAction::About => {
|
||||
if let Some(announcement) = this.announcement(cx) {
|
||||
open_about_dialog(announcement.clone(), window, cx);
|
||||
}
|
||||
}
|
||||
RepoAction::Push => this.push_repository(window, cx),
|
||||
RepoAction::Delete => this.delete_repository(window, cx),
|
||||
}),
|
||||
)
|
||||
.px_4()
|
||||
@@ -1160,11 +1340,13 @@ impl RepoDetailView {
|
||||
})),
|
||||
)
|
||||
.dropdown_menu(|menu, _, _| {
|
||||
menu.menu_element_with_icon(
|
||||
IconName::Plus,
|
||||
Box::new(RepoAction::NewIssue),
|
||||
|_, _| div().text_xs().child("New issue"),
|
||||
)
|
||||
menu.menu_element(Box::new(RepoAction::NewIssue), |_, _| {
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(Icon::new(IconName::Plus))
|
||||
.child("New issue")
|
||||
})
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
@@ -1201,11 +1383,13 @@ impl RepoDetailView {
|
||||
})),
|
||||
)
|
||||
.dropdown_menu(|menu, _, _| {
|
||||
menu.menu_element_with_icon(
|
||||
IconName::Plus,
|
||||
Box::new(RepoAction::NewPR),
|
||||
|_, _| div().text_xs().child("New PR"),
|
||||
)
|
||||
menu.menu_element(Box::new(RepoAction::NewPR), |_, _| {
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(Icon::new(IconName::Plus))
|
||||
.child("New PR")
|
||||
})
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
@@ -1227,17 +1411,50 @@ impl RepoDetailView {
|
||||
.dropdown_menu(move |menu, _, _| share.menu(menu)),
|
||||
)
|
||||
.child(
|
||||
Button::new("info")
|
||||
.icon(IconName::Info)
|
||||
.tooltip("About")
|
||||
Button::new("repo-menu-open")
|
||||
.icon(IconName::EllipsisVertical)
|
||||
.tooltip("Repository management")
|
||||
.compact()
|
||||
.secondary()
|
||||
.on_click(cx.listener(|this, _event, window, cx| {
|
||||
open_about_dialog(
|
||||
this.announcement(cx).clone(),
|
||||
window,
|
||||
cx,
|
||||
.loading(self.pushing)
|
||||
.disabled(self.pushing)
|
||||
.dropdown_menu(move |menu, _, cx| {
|
||||
let backend = Backend::global(cx);
|
||||
let current_user = backend.read(cx).current_user();
|
||||
let owner = current_user == Some(announcement.owner);
|
||||
|
||||
let menu = menu.menu_element(
|
||||
Box::new(RepoAction::About),
|
||||
|_, _| {
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(Icon::new(IconName::Info))
|
||||
.child("About")
|
||||
},
|
||||
);
|
||||
})),
|
||||
|
||||
if owner {
|
||||
menu.menu_element(Box::new(RepoAction::Push), |_, _| {
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(Icon::new(CustomIconName::Init))
|
||||
.child("Republish")
|
||||
})
|
||||
.separator()
|
||||
.menu_element(Box::new(RepoAction::Delete), |_, cx| {
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().danger)
|
||||
.child(Icon::new(IconName::Delete))
|
||||
.child("Delete")
|
||||
})
|
||||
} else {
|
||||
menu
|
||||
}
|
||||
}),
|
||||
)
|
||||
.child({
|
||||
let view = cx.entity();
|
||||
@@ -1342,157 +1559,261 @@ impl RepoDetailView {
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(self.render_header_tabs(cx))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Header for a local (not yet published) repository: the directory
|
||||
/// name and path with an Init button instead of the NIP-34 actions
|
||||
/// (issues, pull requests, share, info, clone).
|
||||
fn render_local_header(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let name = self.display_name(cx);
|
||||
let path = self
|
||||
.local_path
|
||||
.as_ref()
|
||||
.map(|path| path.display().to_string())
|
||||
.unwrap_or_default();
|
||||
let avatar = PixelAvatar::new(path.clone());
|
||||
|
||||
v_flex()
|
||||
.px_4()
|
||||
.pb_4()
|
||||
.w_full()
|
||||
.gap_8()
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().border)
|
||||
.child(
|
||||
h_flex()
|
||||
.w_full()
|
||||
.gap_4()
|
||||
.items_start()
|
||||
.justify_between()
|
||||
.child(
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.gap_1()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.min_h_8()
|
||||
.font_semibold()
|
||||
.child(avatar.size_6())
|
||||
.child(name),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.min_w_0()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.line_clamp(2)
|
||||
.line_height(relative(1.25))
|
||||
.text_ellipsis()
|
||||
.child(path),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Button::new("init")
|
||||
.icon(CustomIconName::Init)
|
||||
.label("Initialize on Nostr")
|
||||
.primary()
|
||||
.tooltip("Publish this repository to Nostr")
|
||||
.on_click(cx.listener(|this, _event, window, cx| {
|
||||
this.open_init_dialog(window, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
.child(self.render_header_tabs(cx))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Open the dialog guiding the user through publishing the local
|
||||
/// repository to NIP-34.
|
||||
fn open_init_dialog(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(local_path) = self.local_path.clone() else {
|
||||
return;
|
||||
};
|
||||
let view = cx.entity().downgrade();
|
||||
init_dialog::open(local_path, view, window, cx);
|
||||
}
|
||||
|
||||
/// Switch the repository into its NIP-34 mode after a successful init:
|
||||
/// create the nostr store for the announced repository and drop the
|
||||
/// local (scan) identity. The worktree is unchanged, so the file
|
||||
/// explorer keeps its loaded content.
|
||||
pub(crate) fn apply_announcement(
|
||||
&mut self,
|
||||
announcement: Announcement,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
// The repository is no longer a bare local repo: drop it from the
|
||||
// scan results so it leaves the sidebar's local section immediately.
|
||||
if let Some(path) = self.local_path.take() {
|
||||
LocalReposStore::global(cx).update(cx, |store, cx| store.remove(&path, cx));
|
||||
}
|
||||
let store =
|
||||
cx.new(|cx| RepoStore::new(announcement.addr(), announcement.relays.clone(), cx));
|
||||
// Re-render when the store refreshes (issues, PRs, statuses).
|
||||
self._subscriptions
|
||||
.push(cx.observe(&store, |_this, _store, cx| cx.notify()));
|
||||
self.store = Some(store);
|
||||
self.initial = Some(announcement);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// The tab row shared by both header variants: Files/Commits tabs, the
|
||||
/// HEAD commit button and the branch/tag selectors.
|
||||
fn render_header_tabs(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let commits_count = self.all_commits.as_ref().map(|list| list.total);
|
||||
let worktree_empty = self.switching_ref || self.worktree.is_none();
|
||||
|
||||
h_flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(
|
||||
BaseButton::new("files-tab")
|
||||
.flex()
|
||||
.items_center()
|
||||
.h_8()
|
||||
.px_2()
|
||||
.gap_2()
|
||||
.child(
|
||||
BaseButton::new("files-tab")
|
||||
.flex()
|
||||
.items_center()
|
||||
.h_8()
|
||||
.px_2()
|
||||
.gap_2()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.child(Icon::new(CustomIconName::GitFile).small())
|
||||
.child("Files"),
|
||||
)
|
||||
.text_color(cx.theme().button_foreground)
|
||||
.rounded(cx.theme().radius)
|
||||
.hover(|this| this.bg(cx.theme().button_hover))
|
||||
.active(|this| this.bg(cx.theme().button_active))
|
||||
.selected(self.active_tab == 0)
|
||||
.when(self.active_tab == 0, |this| {
|
||||
this.bg(cx.theme().button_active)
|
||||
})
|
||||
.on_click(cx.listener(|this, _event, _window, cx| {
|
||||
this.active_tab = 0;
|
||||
cx.notify();
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
BaseButton::new("commits-tab")
|
||||
.flex()
|
||||
.items_center()
|
||||
.h_8()
|
||||
.px_2()
|
||||
.gap_2()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.child(Icon::new(CustomIconName::GitCommit).small())
|
||||
.child("Commits"),
|
||||
)
|
||||
.when_some(commits_count, |this, count| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.justify_center()
|
||||
.px_1()
|
||||
.py_0p5()
|
||||
.min_w_4()
|
||||
.text_size(px(8.))
|
||||
.bg(cx.theme().muted)
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.rounded(cx.theme().radius)
|
||||
.line_height(relative(1.))
|
||||
.child(SharedString::from(count.to_string())),
|
||||
)
|
||||
})
|
||||
.text_color(cx.theme().button_foreground)
|
||||
.rounded(cx.theme().radius)
|
||||
.hover(|this| this.bg(cx.theme().button_hover))
|
||||
.active(|this| this.bg(cx.theme().button_active))
|
||||
.selected(self.active_tab == 1)
|
||||
.when(self.active_tab == 1, |this| {
|
||||
this.bg(cx.theme().button_active)
|
||||
})
|
||||
.on_click(cx.listener(|this, _event, _window, cx| {
|
||||
this.active_tab = 1;
|
||||
cx.notify();
|
||||
})),
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.child(Icon::new(CustomIconName::GitFile).small())
|
||||
.child("Files"),
|
||||
)
|
||||
.text_color(cx.theme().button_foreground)
|
||||
.rounded(cx.theme().radius)
|
||||
.hover(|this| this.bg(cx.theme().button_hover))
|
||||
.active(|this| this.bg(cx.theme().button_active))
|
||||
.selected(self.active_tab == 0)
|
||||
.when(self.active_tab == 0, |this| {
|
||||
this.bg(cx.theme().button_active)
|
||||
})
|
||||
.on_click(cx.listener(|this, _event, _window, cx| {
|
||||
this.active_tab = 0;
|
||||
cx.notify();
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
BaseButton::new("commits-tab")
|
||||
.flex()
|
||||
.items_center()
|
||||
.h_8()
|
||||
.px_2()
|
||||
.gap_2()
|
||||
.child(
|
||||
h_flex()
|
||||
.flex_1()
|
||||
.gap_2()
|
||||
.justify_end()
|
||||
.child(
|
||||
Button::new("enc")
|
||||
.ghost()
|
||||
.when_some(self.head_commit.as_ref(), |this, commit| {
|
||||
this.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(SharedString::from(&commit.id)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.max_w(px(200.))
|
||||
.overflow_hidden()
|
||||
.text_ellipsis()
|
||||
.whitespace_nowrap()
|
||||
.text_xs()
|
||||
.child(SharedString::from(&commit.summary)),
|
||||
)
|
||||
})
|
||||
.tooltip(
|
||||
self.head_commit
|
||||
.as_ref()
|
||||
.map_or_else(SharedString::default, |commit| {
|
||||
commit.summary.clone().into()
|
||||
}),
|
||||
)
|
||||
.on_click(cx.listener(|this, _event, window, cx| {
|
||||
if let Some(commit) = &this.head_commit {
|
||||
let id = commit.id.clone();
|
||||
this.open_commit_diff(&id, window, cx);
|
||||
}
|
||||
})),
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.child(Icon::new(CustomIconName::GitCommit).small())
|
||||
.child("Commits"),
|
||||
)
|
||||
.when_some(commits_count, |this, count| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.justify_center()
|
||||
.px_1()
|
||||
.py_0p5()
|
||||
.min_w_4()
|
||||
.text_size(px(8.))
|
||||
.bg(cx.theme().muted)
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.rounded(cx.theme().radius)
|
||||
.line_height(relative(1.))
|
||||
.child(SharedString::from(count.to_string())),
|
||||
)
|
||||
})
|
||||
.text_color(cx.theme().button_foreground)
|
||||
.rounded(cx.theme().radius)
|
||||
.hover(|this| this.bg(cx.theme().button_hover))
|
||||
.active(|this| this.bg(cx.theme().button_active))
|
||||
.selected(self.active_tab == 1)
|
||||
.when(self.active_tab == 1, |this| {
|
||||
this.bg(cx.theme().button_active)
|
||||
})
|
||||
.on_click(cx.listener(|this, _event, _window, cx| {
|
||||
this.active_tab = 1;
|
||||
cx.notify();
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.flex_1()
|
||||
.gap_2()
|
||||
.justify_end()
|
||||
.child(
|
||||
Button::new("enc")
|
||||
.ghost()
|
||||
.when_some(self.head_commit.as_ref(), |this, commit| {
|
||||
this.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(SharedString::from(&commit.id)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.max_w(px(200.))
|
||||
.overflow_hidden()
|
||||
.text_ellipsis()
|
||||
.whitespace_nowrap()
|
||||
.text_xs()
|
||||
.child(SharedString::from(&commit.summary)),
|
||||
)
|
||||
})
|
||||
.tooltip(
|
||||
self.head_commit
|
||||
.as_ref()
|
||||
.map_or_else(SharedString::default, |commit| {
|
||||
commit.summary.clone().into()
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
div().w(px(120.)).child(
|
||||
Combobox::new(&self.branch_select)
|
||||
.placeholder("Branch")
|
||||
.appearance(false)
|
||||
.menu_width(px(200.))
|
||||
.disabled(worktree_empty)
|
||||
.bg(cx.theme().muted)
|
||||
.rounded(cx.theme().radius)
|
||||
.render_trigger(|ctx, _window, cx| {
|
||||
Self::render_ref_trigger(
|
||||
ctx,
|
||||
CustomIconName::GitBranch,
|
||||
cx,
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div().w(px(120.)).child(
|
||||
Combobox::new(&self.tag_select)
|
||||
.placeholder("Tag")
|
||||
.appearance(false)
|
||||
.menu_width(px(200.))
|
||||
.disabled(worktree_empty)
|
||||
.bg(cx.theme().muted)
|
||||
.rounded(cx.theme().radius)
|
||||
.render_trigger(|ctx, _window, cx| {
|
||||
Self::render_ref_trigger(ctx, CustomIconName::Tag, cx)
|
||||
}),
|
||||
),
|
||||
),
|
||||
.on_click(cx.listener(|this, _event, window, cx| {
|
||||
if let Some(commit) = &this.head_commit {
|
||||
let id = commit.id.clone();
|
||||
this.open_commit_diff(&id, window, cx);
|
||||
}
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
div().w(px(120.)).child(
|
||||
Combobox::new(&self.branch_select)
|
||||
.placeholder("Branch")
|
||||
.appearance(false)
|
||||
.menu_width(px(200.))
|
||||
.disabled(worktree_empty)
|
||||
.bg(cx.theme().muted)
|
||||
.rounded(cx.theme().radius)
|
||||
.render_trigger(|ctx, _window, cx| {
|
||||
Self::render_ref_trigger(ctx, CustomIconName::GitBranch, cx)
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div().w(px(120.)).child(
|
||||
Combobox::new(&self.tag_select)
|
||||
.placeholder("Tag")
|
||||
.appearance(false)
|
||||
.menu_width(px(200.))
|
||||
.disabled(worktree_empty)
|
||||
.bg(cx.theme().muted)
|
||||
.rounded(cx.theme().radius)
|
||||
.render_trigger(|ctx, _window, cx| {
|
||||
Self::render_ref_trigger(ctx, CustomIconName::Tag, cx)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_maintainers(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let announcement = self.announcement(cx);
|
||||
let Some(announcement) = self.announcement(cx) else {
|
||||
return div().into_any_element();
|
||||
};
|
||||
let profile_store = ProfileStore::global(cx);
|
||||
|
||||
let mut seen = HashSet::new();
|
||||
@@ -1576,6 +1897,16 @@ impl Render for RepoDetailView {
|
||||
.id("repo")
|
||||
.size_full()
|
||||
.child(self.render_header(cx))
|
||||
.when_some(self.error.clone(), |this, error| {
|
||||
this.child(
|
||||
Alert::error("repo-error", error)
|
||||
.banner()
|
||||
.on_close(cx.listener(|this, _event, _window, cx| {
|
||||
this.error = None;
|
||||
cx.notify();
|
||||
})),
|
||||
)
|
||||
})
|
||||
.map(|this| match self.active_tab {
|
||||
0 => this.child(
|
||||
h_flex()
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user