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
+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M18.5 19.0615C20.6627 18.4544 22.25 16.4502 22.25 14.0714C22.25 11.2114 19.9555 8.89286 17.125 8.89286C16.5661 8.89286 16.0281 8.98326 15.5245 9.15037C14.4289 6.56294 11.8865 4.75 8.925 4.75C4.96236 4.75 1.75 7.99594 1.75 12C1.75 14.7508 3.26609 17.1437 5.5 18.3722M14.5 16.25L12 13.75L9.5 16.25M12 20V14.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 488 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M13 21C13.5523 21 14 20.5523 14 20C14 19.4477 13.5523 19 13 19C12.4477 19 12 19.4477 12 20C12 20.5523 12.4477 21 13 21Z" fill="currentColor"/><path d="M21 11C21 10.4477 20.5523 9.99999 20 9.99999C19.4477 9.99999 19 10.4477 19 11C19 11.5523 19.4477 12 20 12C20.5523 12 21 11.5523 21 11Z" fill="currentColor"/><path d="M19.9295 14.2679C20.4078 14.5441 20.5716 15.1557 20.2955 15.634C20.0193 16.1123 19.4078 16.2761 18.9295 16C18.4512 15.7238 18.2873 15.1123 18.5634 14.634C18.8396 14.1557 19.4512 13.9918 19.9295 14.2679Z" fill="currentColor"/><path d="M17.3676 19.2942C17.8459 19.0181 18.0098 18.4065 17.7336 17.9282C17.4575 17.4499 16.8459 17.286 16.3676 17.5621C15.8893 17.8383 15.7254 18.4499 16.0016 18.9282C16.2777 19.4065 16.8893 19.5703 17.3676 19.2942Z" fill="currentColor"/><path d="M18.9269 7.99998C18.4487 8.27612 17.8371 8.11225 17.5609 7.63396C17.2848 7.15566 17.4487 6.54407 17.9269 6.26793C18.4052 5.99179 19.0168 6.15566 19.293 6.63396C19.5691 7.11225 19.4052 7.72384 18.9269 7.99998Z" fill="currentColor"/><path d="M9.25 14.75V20.25H3.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M15.2493 4.41452C14.2521 3.98683 13.1537 3.75 12 3.75C7.44365 3.75 3.75 7.44365 3.75 12C3.75 15.498 5.92698 18.4875 9 19.6876" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+4
View File
@@ -74,7 +74,9 @@ pub enum CustomIconName {
Share, Share,
Trending, Trending,
Recent, Recent,
Refresh,
Grid, Grid,
Init,
} }
impl IconNamed for CustomIconName { impl IconNamed for CustomIconName {
@@ -101,8 +103,10 @@ impl IconNamed for CustomIconName {
CustomIconName::Markdown => "icons/markdown.svg", CustomIconName::Markdown => "icons/markdown.svg",
CustomIconName::Share => "icons/share.svg", CustomIconName::Share => "icons/share.svg",
CustomIconName::Trending => "icons/trending.svg", CustomIconName::Trending => "icons/trending.svg",
CustomIconName::Refresh => "icons/refresh.svg",
CustomIconName::Recent => "icons/recent.svg", CustomIconName::Recent => "icons/recent.svg",
CustomIconName::Grid => "icons/grid.svg", CustomIconName::Grid => "icons/grid.svg",
CustomIconName::Init => "icons/init.svg",
} }
.into() .into()
} }
+76
View File
@@ -289,6 +289,57 @@ pub fn push_main(repo_path: &Path, base_url: &str, owner: &str, repo_id: &str) -
Ok(()) Ok(())
} }
/// Push every local branch and tag of the repository at `repo_path` to a
/// grasp server (like `git push <url> --all --tags`), so an initialized
/// repository's whole history is mirrored, not just `main`.
pub fn push_all(repo_path: &Path, base_url: &str, owner: &str, repo_id: &str) -> Result<()> {
let url = format!("{base_url}/{owner}/{repo_id}.git");
let output = Command::new("git")
.arg("-C")
.arg(repo_path)
.args(["push", "--all", "--tags"])
.arg(&url)
.env("GIT_TERMINAL_PROMPT", "0")
.stderr(Stdio::piped())
.output()
.context("failed to spawn `git push`")?;
if !output.status.success() {
bail!(
"git push to {base_url} failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
}
Ok(())
}
/// The earliest unique commit of the repository at `repo_path` (a root
/// commit, like `git rev-list --max-parents=0 HEAD`), used as the NIP-34
/// announcement's `euc` marker. `None` for a repository without commits.
pub fn root_commit(repo_path: &Path) -> Result<Option<String>> {
let output = Command::new("git")
.arg("-C")
.arg(repo_path)
.args(["rev-list", "--max-parents=0", "HEAD"])
.env("GIT_TERMINAL_PROMPT", "0")
.stderr(Stdio::piped())
.output()
.context("failed to spawn `git rev-list`")?;
// An unborn HEAD (no commits yet) makes `rev-list` fail,
// there is no unique commit to report then.
if !output.status.success() {
return Ok(None);
}
Ok(String::from_utf8_lossy(&output.stdout)
.lines()
.next()
.map(str::to_owned)
.filter(|id| id.len() == 40))
}
/// Add `origin` pointing at `url` when the repository has no remote yet. /// Add `origin` pointing at `url` when the repository has no remote yet.
/// No-op if `origin` already exists. /// No-op if `origin` already exists.
pub fn ensure_origin(repo_path: &Path, url: &str) -> Result<()> { pub fn ensure_origin(repo_path: &Path, url: &str) -> Result<()> {
@@ -1653,6 +1704,31 @@ mod tests {
assert_eq!(found, expected); assert_eq!(found, expected);
} }
#[test]
fn root_commit_reports_the_first_ancestor() {
let (dir, repo) = fixture(&[("a.txt", b"one")]);
commit_all(&repo, "initial");
let dir = dir.path();
let root = root_commit(dir).expect("root").expect("commit");
assert_eq!(root.len(), 40);
// The root commit does not change when history grows.
std::fs::write(dir.join("b.txt"), b"two").expect("write");
commit_all(&repo, "second");
assert_eq!(
root_commit(dir).expect("root").as_deref(),
Some(root.as_str())
);
}
#[test]
fn root_commit_is_none_without_commits() {
let (_dir, repo) = fixture(&[("a.txt", b"one")]);
let workdir = repo.workdir().expect("workdir");
assert_eq!(root_commit(workdir).expect("root"), None);
}
#[test] #[test]
fn repo_ref_state_lists_branches_tags_and_head() { fn repo_ref_state_lists_branches_tags_and_head() {
let (_dir, repo) = fixture(&[("a.txt", b"hello")]); let (_dir, repo) = fixture(&[("a.txt", b"hello")]);
+168 -6
View File
@@ -1,4 +1,5 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::path::PathBuf;
use std::str::FromStr; use std::str::FromStr;
use std::time::Duration; use std::time::Duration;
@@ -549,6 +550,169 @@ impl Backend {
}) })
} }
/// Publish an existing local repository to NIP-34: read its current
/// branches, tags and HEAD, publish the announcement and the repository
/// state to the grasp relays, then push every branch and tag to each
/// grasp server. Also points `origin` at the first grasp server.
///
/// The events must reach the grasp servers *before* the push, like
/// [`Self::create_repository`]: GRASP servers hold the signed state
/// event in "purgatory" and only accept a push while that
/// authorization is pending.
///
/// The git work (ref listing, push) runs on background threads. The
/// returned task yields the published announcement on success, so
/// callers can switch the repository into its NIP-34 mode.
pub fn publish_local_repo(
&mut self,
path: PathBuf,
name: &str,
description: &str,
grasp_servers: Vec<RelayUrl>,
cx: &mut Context<Self>,
) -> Task<Result<Announcement, Error>> {
let name = name.trim().to_owned();
let description = description.trim().to_owned();
if name.is_empty() {
return Task::ready(Err(anyhow!("Repository name is required")));
}
if grasp_servers.is_empty() {
return Task::ready(Err(anyhow!("Add at least one grasp server")));
}
let Some(public_key) = self.current_user else {
return Task::ready(Err(anyhow!("Sign in to publish a repository")));
};
// The repository identifier is derived from the name, like
// [`Self::create_repository`]: spaces become hyphens, other
// non-alphanumeric characters (except `/`) become hyphens.
let repo_id = identifier_from_name(&name);
if repo_id.is_empty() || repo_id.len() > 100 {
return Task::ready(Err(anyhow!(
"Repository name must produce an identifier of 1-100 characters"
)));
}
if !repo_id.chars().any(|c| c.is_ascii_alphanumeric()) {
return Task::ready(Err(anyhow!(
"Repository name must contain at least one alphanumeric character"
)));
}
let owner = public_key.to_bech32().unwrap();
let servers = grasp_servers.clone();
cx.spawn(async move |this, cx| {
// 1. Read the local repository's refs (branches, tags, HEAD)
// and its root commit on a background thread.
let work = cx.background_spawn({
let path = path.clone();
async move {
let state = signed_git::worktree_ref_state(&path)?;
let euc = signed_git::root_commit(&path)?;
Ok::<_, Error>((state, euc))
}
});
let (state, euc) = work.await?;
// 2. Ensure the grasp servers are in the relay pool; the nostr
// client queues events until each relay is connected.
this.update(cx, |this, cx| {
let urls: Vec<String> = servers.iter().map(ToString::to_string).collect();
this.add_relays(urls, cx);
})?;
// 3. Publish the announcement, then the state event, to the
// grasp relays. The state event is the push authorization
// ("purgatory"), so it must be accepted before step 4.
let announcement = GitRepositoryAnnouncement {
id: repo_id.clone(),
name: Some(name.clone()),
description: (!description.is_empty()).then_some(description.clone()),
web: Vec::new(),
clone: servers
.iter()
.filter_map(|relay| grasp_clone_url(relay, &owner, &repo_id))
.collect(),
relays: servers.clone(),
euc: euc.and_then(|commit| Sha1Hash::from_str(&commit).ok()),
maintainers: Vec::new(),
};
let event = this
.update(cx, |this, cx| {
this.send(announcement.into_event_builder(), cx)
})?
.await?;
let refs = state.refs.clone();
let head = state.head.clone();
this.update(cx, |this, cx| {
let builder = build_state(&repo_id, &refs, head.as_deref());
this.send(builder, cx)
})?
.await?;
// 4. Push every branch and tag to each grasp server. A server
// that fails to accept the push is logged, but the init only
// fails when no server accepted it. An empty repository
// (no refs yet) has nothing to push.
if !refs.is_empty() {
let push = cx.background_spawn({
let path = path.clone();
let owner = owner.clone();
let repo_id = repo_id.clone();
let servers = servers.clone();
async move {
let mut failures = Vec::new();
let mut pushed = 0;
for relay in &servers {
let Some(base_url) = grasp_base_url(relay) else {
failures.push(format!("{relay}: no domain"));
continue;
};
match signed_git::push_all(&path, &base_url, &owner, &repo_id) {
Ok(()) => pushed += 1,
Err(e) => failures.push(format!("{relay}: {e}")),
}
}
if pushed == 0 {
bail!(
"could not push the repository to any grasp server: {}",
failures.join("; ")
);
}
for failure in failures {
log::warn!("grasp push failed: {failure}");
}
Ok::<_, Error>(())
}
});
push.await?;
}
// 5. Point `origin` at the first grasp server so later pushes
// have a target, like the create flow.
if let Some(base) = servers.first().and_then(grasp_base_url) {
let url = format!("{base}/{owner}/{repo_id}.git");
let path = path.clone();
cx.background_spawn(async move {
signed_git::ensure_origin(&path, &url).ok();
})
.await;
}
Announcement::from_event(&event).ok_or_else(|| anyhow!("failed to parse announcement"))
})
}
/// Login with an `nsec1...` key or a `bunker://...` URI, dispatching on /// Login with an `nsec1...` key or a `bunker://...` URI, dispatching on
/// the credential's prefix. /// the credential's prefix.
pub fn login(&mut self, credential: &str, cx: &mut Context<Self>) { pub fn login(&mut self, credential: &str, cx: &mut Context<Self>) {
@@ -578,9 +742,7 @@ impl Backend {
/// Login with an `nsec1...` secret key. The credential is verified by /// Login with an `nsec1...` secret key. The credential is verified by
/// the signer flow and persisted in the keyring. /// the signer flow and persisted in the keyring.
pub fn login_with_nsec(&mut self, nsec: &str, cx: &mut Context<Self>) { pub fn login_with_nsec(&mut self, nsec: &str, cx: &mut Context<Self>) {
let nsec = nsec.trim().to_owned(); let keys = match SecretKey::parse(nsec) {
let keys = match SecretKey::parse(&nsec) {
Ok(secret) => Keys::new(secret), Ok(secret) => Keys::new(secret),
Err(e) => { Err(e) => {
cx.emit(BackendEvent::error(e.to_string())); cx.emit(BackendEvent::error(e.to_string()));
@@ -588,15 +750,15 @@ impl Backend {
} }
}; };
let write = let nsec = nsec.trim().to_owned();
cx.write_credentials(USER_KEYRING, &keys.public_key().to_hex(), nsec.as_bytes()); let pubkey = keys.public_key().to_hex();
let write = cx.write_credentials(USER_KEYRING, &pubkey, nsec.as_bytes());
self.tasks.push(cx.spawn(async move |this, cx| { self.tasks.push(cx.spawn(async move |this, cx| {
if let Err(e) = write.await { if let Err(e) = write.await {
this.update(cx, |_, cx| cx.emit(BackendEvent::error(e.to_string())))?; this.update(cx, |_, cx| cx.emit(BackendEvent::error(e.to_string())))?;
return Ok(()); return Ok(());
} }
this.update(cx, |this, cx| this.set_signer(keys, cx))?; this.update(cx, |this, cx| this.set_signer(keys, cx))?;
Ok(()) Ok(())
})); }));
@@ -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();
}
+239 -38
View File
@@ -38,6 +38,7 @@ mod browser;
mod commits; mod commits;
mod diff; mod diff;
mod helpers; mod helpers;
mod init_dialog;
mod issue_detail; mod issue_detail;
mod issues; mod issues;
mod pull_request_detail; mod pull_request_detail;
@@ -98,9 +99,15 @@ pub struct RepoDetailView {
dock_area: WeakEntity<DockArea>, dock_area: WeakEntity<DockArea>,
/// Snapshot taken at open time, shown until the store's first refresh /// Snapshot taken at open time, shown until the store's first refresh
/// completes (and as a fallback while the store has no announcement). /// 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). /// 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). /// File explorer state (worktree of the local clone).
tree_state: Entity<TreeState>, tree_state: Entity<TreeState>,
/// Root of the local clone, for reading files on demand. /// Root of the local clone, for reading files on demand.
@@ -161,6 +168,8 @@ pub struct RepoDetailView {
} }
impl 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( pub fn new(
dock_area: WeakEntity<DockArea>, dock_area: WeakEntity<DockArea>,
initial: Announcement, initial: Announcement,
@@ -170,7 +179,36 @@ impl RepoDetailView {
// The announcement we opened from already carries the repository's // The announcement we opened from already carries the repository's
// NIP-34 `relays` tag, so the store can connect to those relays // NIP-34 `relays` tag, so the store can connect to those relays
// immediately instead of waiting for the bootstrap fetch. // 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)); let tree_state = cx.new(|cx| TreeState::new(cx));
// Empty until the clone completes; populated with the local refs. // Empty until the clone completes; populated with the local refs.
@@ -222,6 +260,7 @@ impl RepoDetailView {
initial, initial,
dock_area, dock_area,
store, store,
local_path,
tree_state, tree_state,
worktree: None, worktree: None,
md: None, md: None,
@@ -254,19 +293,49 @@ impl RepoDetailView {
} }
} }
/// Load the repository and populate the file explorer. The local clone /// Load the repository and populate the file explorer. A local
/// (if any) is loaded first without touching the network, so an /// (not yet published) repository is opened straight from disk. An
/// unreachable server can't block the panel; a background fetch then /// announced repository's local clone (if any) is loaded first without
/// refreshes the refs and commit list (a fetch never changes the /// touching the network, so an unreachable server can't block the
/// checked-out files, so the tree and previews are left alone). /// 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>) { fn load_repo(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.loading = true; self.loading = true;
self.error = None; self.error = None;
cx.notify(); 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 cache = GitStore::global(cx).cache().clone();
let addr = self.initial.addr(); let addr = initial.addr();
let clone_urls: Vec<String> = self.initial.clone.iter().map(ToString::to_string).collect(); 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 // Captured before the loads start: a branch/tag switch bumps it, and
// the refresh below is discarded when that happens. // the refresh below is discarded when that happens.
let refresh_generation = self.ref_generation; let refresh_generation = self.ref_generation;
@@ -434,7 +503,9 @@ impl RepoDetailView {
} }
let (clone_urls, name) = { let (clone_urls, name) = {
let announcement = self.announcement(cx); let Some(announcement) = self.announcement(cx) else {
return;
};
let addr = announcement.addr(); let addr = announcement.addr();
let clone_urls: Vec<String> = let clone_urls: Vec<String> =
announcement.clone.iter().map(ToString::to_string).collect(); 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. /// Open the issues panel at the bottom of the dock area.
fn open_issue_detail(&mut self, window: &mut Window, cx: &mut Context<Self>) { 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 { let Some(dock_area) = self.dock_area.upgrade() else {
return; return;
}; };
@@ -766,7 +840,7 @@ impl RepoDetailView {
let panel = cx.new(|cx| { let panel = cx.new(|cx| {
IssuesView::new( IssuesView::new(
self.dock_area.clone(), self.dock_area.clone(),
self.store.clone(), store,
self.display_name(cx), self.display_name(cx),
window, window,
cx, cx,
@@ -780,6 +854,9 @@ impl RepoDetailView {
/// Open the pull requests panel at the bottom of the dock area. /// 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>) { 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 { let Some(dock_area) = self.dock_area.upgrade() else {
return; return;
}; };
@@ -787,7 +864,7 @@ impl RepoDetailView {
let panel = cx.new(|cx| { let panel = cx.new(|cx| {
PullRequestsView::new( PullRequestsView::new(
self.dock_area.clone(), self.dock_area.clone(),
self.store.clone(), store,
self.display_name(cx), self.display_name(cx),
window, window,
cx, cx,
@@ -1025,27 +1102,52 @@ impl RepoDetailView {
} }
} }
/// The latest announcement from the store, or the open-time snapshot. /// The latest announcement from the store, or the open-time snapshot;
fn announcement<'a>(&'a self, cx: &'a App) -> &'a Announcement { /// `None` for local repositories that haven't been published yet.
self.store fn announcement<'a>(&'a self, cx: &'a App) -> Option<&'a Announcement> {
let store = self.store.as_ref()?;
store
.read(cx) .read(cx)
.announcement .announcement
.as_ref() .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 { fn display_name(&self, cx: &App) -> SharedString {
let announcement = self.announcement(cx); 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 announcement
.name .name
.clone() .clone()
.unwrap_or_else(|| SharedString::from(announcement.id.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 { fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
let store = self.store.read(cx); if self.local_path.is_some() {
let announcement = store.announcement.as_ref().unwrap_or(&self.initial); 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 issue_count = SharedString::from(store.issue_count().to_string());
let pr_count = SharedString::from(store.pull_request_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 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 ngit_command = SharedString::from(format!("git clone {nostr_url}"));
let nak_command = SharedString::from(format!("nak git clone {nostr_url}")); let nak_command = SharedString::from(format!("nak git clone {nostr_url}"));
@@ -1066,10 +1165,14 @@ impl RepoDetailView {
.on_action( .on_action(
cx.listener(|this, action: &RepoAction, window, cx| match action { cx.listener(|this, action: &RepoAction, window, cx| match action {
RepoAction::NewIssue => { 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 => { 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") .tooltip("About")
.secondary() .secondary()
.on_click(cx.listener(|this, _event, window, cx| { .on_click(cx.listener(|this, _event, window, cx| {
open_about_dialog( if let Some(announcement) = this.announcement(cx) {
this.announcement(cx).clone(), open_about_dialog(announcement.clone(), window, cx);
window, }
cx,
);
})), })),
) )
.child({ .child({
@@ -1342,7 +1443,110 @@ 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( .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() h_flex()
.items_center() .items_center()
.gap_2() .gap_2()
@@ -1464,11 +1668,7 @@ impl RepoDetailView {
.bg(cx.theme().muted) .bg(cx.theme().muted)
.rounded(cx.theme().radius) .rounded(cx.theme().radius)
.render_trigger(|ctx, _window, cx| { .render_trigger(|ctx, _window, cx| {
Self::render_ref_trigger( Self::render_ref_trigger(ctx, CustomIconName::GitBranch, cx)
ctx,
CustomIconName::GitBranch,
cx,
)
}), }),
), ),
) )
@@ -1486,13 +1686,14 @@ impl RepoDetailView {
}), }),
), ),
), ),
),
) )
.into_any_element() .into_any_element()
} }
fn render_maintainers(&self, cx: &mut Context<Self>) -> AnyElement { 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 profile_store = ProfileStore::global(cx);
let mut seen = HashSet::new(); 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 "All Repositories" section: header with the create button and
/// the current user's repositories below it, lazily rendered through a /// the current user's repositories below it, lazily rendered through a
/// [`uniform_list`], followed by the local git repositories discovered /// [`uniform_list`], followed by the local git repositories discovered
@@ -210,13 +227,13 @@ impl SidebarPanel {
.gap_1() .gap_1()
.child( .child(
Button::new("rescan") Button::new("rescan")
.icon(IconName::Redo) .icon(CustomIconName::Refresh)
.small() .small()
.ghost() .ghost()
.tooltip("Rescan for local repositories") .tooltip("Rescan for local repositories")
.on_click(cx.listener(|_this, _ev, _window, cx| { .on_click(cx.listener(|_this, _ev, _window, cx| {
LocalReposStore::global(cx) let local_repos = LocalReposStore::global(cx);
.update(cx, |store, cx| store.rescan(cx)); local_repos.update(cx, |store, cx| store.rescan(cx));
})), })),
) )
.child( .child(
@@ -253,7 +270,7 @@ impl SidebarPanel {
} else { } else {
builder.child( builder.child(
uniform_list( uniform_list(
"my-repos-list", "repos",
total, total,
cx.processor(move |this, range: Range<usize>, _window, cx| { cx.processor(move |this, range: Range<usize>, _window, cx| {
range range
@@ -316,13 +333,14 @@ impl SidebarPanel {
/// One local repository row: a deterministic pixel avatar seeded from /// One local repository row: a deterministic pixel avatar seeded from
/// the path, the directory name, and a warning suffix marking it as /// 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 /// not yet set up for NIP-34. Clicking it opens the repository's
/// local repositories is future work. /// detail view, which offers to initialize it.
fn render_local_row(&self, path: &Path, cx: &mut Context<Self>) -> impl IntoElement { fn render_local_row(&self, path: &Path, cx: &mut Context<Self>) -> impl IntoElement {
let name = path let name = path
.file_name() .file_name()
.map(|name| name.to_string_lossy().into_owned()) .map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| path.display().to_string()); .unwrap_or_else(|| path.display().to_string());
let path = path.to_path_buf();
NavItem::new( NavItem::new(
format!("local-repo:{}", path.display()), format!("local-repo:{}", path.display()),
@@ -334,6 +352,9 @@ impl SidebarPanel {
.small() .small()
.text_color(cx.theme().warning), .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. /// Show the Import Identity dialog.