init local repo

This commit is contained in:
2026-08-31 10:25:28 +07:00
parent 6fcc945dae
commit 675e40ca1c
8 changed files with 1119 additions and 183 deletions
@@ -0,0 +1,466 @@
//! Dialog guiding the user through publishing an existing local repository
//! to NIP-34 (the "Init" button of a local repository's detail view).
//!
//! Mirrors the flow of `ngit init` / `nak git init`: pick a name,
//! description and grasp servers; on submit the backend publishes the
//! announcement and repository state, then pushes every branch and tag to
//! the grasp servers. On success the dialog closes and the repository
//! switches into its NIP-34 mode in place.
use std::path::PathBuf;
use gpui::prelude::*;
use gpui::{App, Entity, SharedString, WeakEntity, Window, div, px};
use gpui_base::input::TextareaState;
use gpui_component::button::{Button, ButtonVariants, Toggle, ToggleVariants};
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::filters;
use signed_state::Backend;
use 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",
];
/// Shared state for the Init dialog, so async results can be rendered.
#[derive(Default)]
pub struct InitRepoState {
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 InitRepoState {
/// 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 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 [`DEFAULT_GRASP_SERVERS`] 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::new_default());
load_user_grasp_servers(state.clone(), window, cx);
window.open_dialog(cx, move |dialog, _window, _cx| {
const DESC: &str = "Publish this local repository to NIP-34. Its branches and tags are pushed to the grasp servers.";
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 relay_input = relay_input.clone();
let state = 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();
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(
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)),
)
.child(
field()
.label("Description")
.child(Textarea::new(&desc_input)),
)
.child(
field()
.label("Folder")
.description("The local repository being published")
.child(
div()
.h_8()
.w_full()
.px_2()
.items_center()
.bg(cx.theme().muted)
.text_sm()
.text_color(cx.theme().muted_foreground)
.rounded(cx.theme().radius)
.child(local_path.display().to_string()),
),
)
.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..."),
)
},
)
})),
),
)
.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(IconName::ArrowRight)
.tooltip("Publish to NIP-34")
.loading(busy)
.disabled(busy)
.on_click({
let name_input = name_input.clone();
let desc_input = desc_input.clone();
let state = 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(),
view.clone(),
window,
cx,
);
}
}),
),
)
})
});
}
/// A grasp server row: the host as a tag plus a remove button.
fn render_server_row(
ix: usize,
relay: &RelayUrl,
state: Entity<InitRepoState>,
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<InitRepoState>,
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 init flow; closes the dialog and switches the repository into
/// its NIP-34 mode on success.
fn init_repository(
local_path: PathBuf,
name_input: Entity<InputState>,
desc_input: Entity<TextareaState>,
state: Entity<InitRepoState>,
view: WeakEntity<RepoDetailView>,
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();
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();
}
/// 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<InitRepoState>, 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();
}
+372 -171
View File
@@ -38,6 +38,7 @@ mod browser;
mod commits;
mod diff;
mod helpers;
mod init_dialog;
mod issue_detail;
mod issues;
mod pull_request_detail;
@@ -98,9 +99,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.
@@ -161,6 +168,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 +179,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 +260,7 @@ impl RepoDetailView {
initial,
dock_area,
store,
local_path,
tree_state,
worktree: None,
md: None,
@@ -254,19 +293,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 +503,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();
@@ -759,6 +830,9 @@ impl RepoDetailView {
/// 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 +840,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 +854,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 +864,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,27 +1102,52 @@ 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()) 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());
@@ -1054,9 +1156,6 @@ impl RepoDetailView {
let avatar = PixelAvatar::new(format!("{}:{}", announcement.owner, announcement.id));
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 ngit_command = SharedString::from(format!("git clone {nostr_url}"));
let nak_command = SharedString::from(format!("nak git clone {nostr_url}"));
@@ -1066,10 +1165,14 @@ 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);
}
}
}),
)
@@ -1232,11 +1335,9 @@ impl RepoDetailView {
.tooltip("About")
.secondary()
.on_click(cx.listener(|this, _event, window, cx| {
open_about_dialog(
this.announcement(cx).clone(),
window,
cx,
);
if let Some(announcement) = this.announcement(cx) {
open_about_dialog(announcement.clone(), window, cx);
}
})),
)
.child({
@@ -1342,157 +1443,257 @@ 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>,
) {
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);
self.local_path = None;
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();
+27 -6
View File
@@ -175,6 +175,23 @@ 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`], followed by the local git repositories discovered
@@ -210,13 +227,13 @@ impl SidebarPanel {
.gap_1()
.child(
Button::new("rescan")
.icon(IconName::Redo)
.icon(CustomIconName::Refresh)
.small()
.ghost()
.tooltip("Rescan for local repositories")
.on_click(cx.listener(|_this, _ev, _window, cx| {
LocalReposStore::global(cx)
.update(cx, |store, cx| store.rescan(cx));
let local_repos = LocalReposStore::global(cx);
local_repos.update(cx, |store, cx| store.rescan(cx));
})),
)
.child(
@@ -253,7 +270,7 @@ impl SidebarPanel {
} else {
builder.child(
uniform_list(
"my-repos-list",
"repos",
total,
cx.processor(move |this, range: Range<usize>, _window, cx| {
range
@@ -316,13 +333,14 @@ impl SidebarPanel {
/// One local repository row: a deterministic pixel avatar seeded from
/// the path, the directory name, and a warning suffix marking it as
/// not yet set up for NIP-34. The row has no click handler — announcing
/// local repositories is future work.
/// 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()),
@@ -334,6 +352,9 @@ impl SidebarPanel {
.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.