Compare commits

...
7 Commits
Author SHA1 Message Date
reya a97dfac23f . 2026-08-05 17:07:50 +07:00
reya 6b13d8a8f7 add workspace 2026-08-05 11:07:12 +07:00
reya 8de6018e28 update nostr connect 2026-08-05 10:37:33 +07:00
reya ed93d81a26 sync all repos 2026-08-05 10:20:12 +07:00
reya 2ec7d14c33 use negentropy sync 2026-08-05 10:08:01 +07:00
reya fdf74327bb update repo list 2026-08-05 09:24:46 +07:00
reya f497279886 add basic repo browser 2026-08-05 09:03:42 +07:00
16 changed files with 935 additions and 87 deletions
Generated
+20
View File
@@ -6055,6 +6055,13 @@ dependencies = [
"rustc_version",
]
[[package]]
name = "paths"
version = "1.0.0"
dependencies = [
"dirs",
]
[[package]]
name = "pbkdf2"
version = "0.12.2"
@@ -7741,8 +7748,11 @@ dependencies = [
"gpui_platform",
"gpui_windows",
"log",
"paths",
"reqwest_client",
"signed_state",
"tracing-subscriber",
"workspace",
]
[[package]]
@@ -10294,6 +10304,16 @@ version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "workspace"
version = "1.0.0"
dependencies = [
"gpui",
"gpui-component",
"signed_core",
"signed_state",
]
[[package]]
name = "workspace-hack"
version = "0.1.0"
+8
View File
@@ -0,0 +1,8 @@
[package]
name = "paths"
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
dirs = "6"
+166
View File
@@ -0,0 +1,166 @@
//! Paths to locations used by Signed.
//!
//! Follows the same pattern as Zed's `paths` crate: platform-correct base
//! directories, resolved once and cached, with an optional custom data dir
//! override for portable/dev installs.
use std::path::PathBuf;
use std::sync::OnceLock;
/// The application name, used to derive platform-specific data, config and
/// cache directory paths.
pub const APP_NAME: &str = "Signed";
/// Lowercased form of [`APP_NAME`], for use in XDG-style paths on
/// Linux/FreeBSD and the macOS `~/.config` fallback.
pub const APP_NAME_LOWERCASE: &str = "signed";
/// A custom data directory override, set only by [`set_custom_data_dir`].
static CUSTOM_DATA_DIR: OnceLock<PathBuf> = OnceLock::new();
/// The resolved data directory.
/// On macOS, this is `~/Library/Application Support/Signed`.
/// On Linux/FreeBSD, this is `$XDG_DATA_HOME/signed`.
/// On Windows, this is `%LOCALAPPDATA%\Signed`.
static CURRENT_DATA_DIR: OnceLock<PathBuf> = OnceLock::new();
/// The resolved config directory.
/// On macOS, this is `~/.config/signed`.
/// On Linux/FreeBSD, this is `$XDG_CONFIG_HOME/signed`.
/// On Windows, this is `%APPDATA%\Signed`.
static CONFIG_DIR: OnceLock<PathBuf> = OnceLock::new();
/// Returns the current user's home directory.
pub fn home_dir() -> PathBuf {
dirs::home_dir().expect("failed to determine home directory")
}
/// Sets a custom directory for all user data, overriding the default data
/// directory. Must be called before any other path operation. The directory
/// is created if it doesn't exist and canonicalized to an absolute path.
///
/// # Panics
///
/// Panics if called after [`data_dir`] or [`config_dir`] was initialized, or
/// if the directory cannot be created/canonicalized.
pub fn set_custom_data_dir(dir: &str) -> &'static PathBuf {
if CURRENT_DATA_DIR.get().is_some() || CONFIG_DIR.get().is_some() {
panic!("set_custom_data_dir called after data_dir or config_dir was initialized");
}
CUSTOM_DATA_DIR.get_or_init(|| {
let path = PathBuf::from(dir);
std::fs::create_dir_all(&path).expect("failed to create custom data directory");
path.canonicalize()
.expect("failed to canonicalize custom data directory")
})
}
/// Returns the path to the configuration directory.
pub fn config_dir() -> &'static PathBuf {
CONFIG_DIR.get_or_init(|| {
if let Some(custom_dir) = CUSTOM_DATA_DIR.get() {
custom_dir.join("config")
} else if cfg!(target_os = "windows") {
dirs::config_dir()
.expect("failed to determine RoamingAppData directory")
.join(APP_NAME)
} else if cfg!(any(target_os = "linux", target_os = "freebsd")) {
if let Ok(flatpak_xdg_config) = std::env::var("FLATPAK_XDG_CONFIG_HOME") {
flatpak_xdg_config.into()
} else {
dirs::config_dir().expect("failed to determine XDG_CONFIG_HOME directory")
}
.join(APP_NAME_LOWERCASE)
} else {
home_dir().join(".config").join(APP_NAME_LOWERCASE)
}
})
}
/// Returns the path to the data directory.
pub fn data_dir() -> &'static PathBuf {
CURRENT_DATA_DIR.get_or_init(|| {
if let Some(custom_dir) = CUSTOM_DATA_DIR.get() {
custom_dir.clone()
} else if cfg!(target_os = "macos") {
home_dir()
.join("Library/Application Support")
.join(APP_NAME)
} else if cfg!(any(target_os = "linux", target_os = "freebsd")) {
if let Ok(flatpak_xdg_data) = std::env::var("FLATPAK_XDG_DATA_HOME") {
flatpak_xdg_data.into()
} else {
dirs::data_local_dir().expect("failed to determine XDG_DATA_HOME directory")
}
.join(APP_NAME_LOWERCASE)
} else if cfg!(target_os = "windows") {
dirs::data_local_dir()
.expect("failed to determine LocalAppData directory")
.join(APP_NAME)
} else {
config_dir().clone()
}
})
}
/// Returns the path to the cache directory.
pub fn cache_dir() -> &'static PathBuf {
static CACHE_DIR: OnceLock<PathBuf> = OnceLock::new();
CACHE_DIR.get_or_init(|| {
if cfg!(target_os = "macos") {
dirs::cache_dir()
.expect("failed to determine caches directory")
.join(APP_NAME)
} else if cfg!(target_os = "windows") {
dirs::cache_dir()
.expect("failed to determine LocalAppData directory")
.join(APP_NAME)
} else if cfg!(any(target_os = "linux", target_os = "freebsd")) {
if let Ok(flatpak_xdg_cache) = std::env::var("FLATPAK_XDG_CACHE_HOME") {
flatpak_xdg_cache.into()
} else {
dirs::cache_dir().expect("failed to determine XDG_CACHE_HOME directory")
}
.join(APP_NAME_LOWERCASE)
} else {
home_dir().join(".cache").join(APP_NAME_LOWERCASE)
}
})
}
/// Returns the path to the logs directory.
pub fn logs_dir() -> &'static PathBuf {
static LOGS_DIR: OnceLock<PathBuf> = OnceLock::new();
LOGS_DIR.get_or_init(|| {
if cfg!(target_os = "macos") {
home_dir().join("Library/Logs").join(APP_NAME)
} else {
data_dir().join("logs")
}
})
}
/// Returns the path to the nostr database directory (LMDB).
pub fn nostr_dir() -> &'static PathBuf {
static NOSTR_DIR: OnceLock<PathBuf> = OnceLock::new();
NOSTR_DIR.get_or_init(|| data_dir().join("nostr"))
}
/// Returns the path to the local git clone cache (grasp mirrors).
pub fn repos_dir() -> &'static PathBuf {
static REPOS_DIR: OnceLock<PathBuf> = OnceLock::new();
REPOS_DIR.get_or_init(|| data_dir().join("repos"))
}
/// Returns the path to the `settings.json` file.
pub fn settings_file() -> &'static PathBuf {
static SETTINGS_FILE: OnceLock<PathBuf> = OnceLock::new();
SETTINGS_FILE.get_or_init(|| config_dir().join("settings.json"))
}
/// Returns the path to the `keymap.json` file.
pub fn keymap_file() -> &'static PathBuf {
static KEYMAP_FILE: OnceLock<PathBuf> = OnceLock::new();
KEYMAP_FILE.get_or_init(|| config_dir().join("keymap.json"))
}
+6 -4
View File
@@ -68,8 +68,10 @@ pub fn announcements_by(public_key: PublicKey) -> Filter {
}
/// All repository announcements (for global discovery).
pub fn all_announcements(limit: usize) -> Filter {
Filter::new()
.kind(Kind::GitRepoAnnouncement)
.limit(limit)
///
/// Unbounded: intended for negentropy sync, which reconciles sets
/// efficiently regardless of size. Local database queries with this
/// filter are served by LMDB, so they stay fast as the database grows.
pub fn all_announcements() -> Filter {
Filter::new().kind(Kind::GitRepoAnnouncement)
}
+224 -42
View File
@@ -1,16 +1,17 @@
use std::collections::HashMap;
use std::time::Duration;
use anyhow::{Error, anyhow};
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task};
use nostr_connect::prelude::*;
use nostr_sdk::client::SyncSummary;
use nostr_sdk::prelude::*;
use signed_core::filters;
use signed_nostr::{NostrBackend, SignedAuthUrlHandler, UniversalSigner, Update};
/// Keyring entry holding the user credential (`nsec1...` or `bunker://...`).
pub const USER_KEYRING: &str = "su.reya.signed#user";
/// Keyring entry holding the locally generated key for NIP-46 sessions.
pub const MASTER_KEYRING: &str = "su.reya.signed#master";
/// Keyring entry holding the user credential (`nsec1...` or `bunker://...`
/// with an embedded `?master=<nsec>` NIP-46 session key).
pub const USER_KEYRING: &str = "Signed Safe Storage";
/// Timeout for NIP-46 signer responses.
pub const NOSTR_CONNECT_TIMEOUT: u64 = 60;
@@ -39,6 +40,18 @@ pub enum BackendEvent {
Connected,
/// A new event was received from a relay and stored in the database.
NostrUpdate(Update),
/// A negentropy sync completed; the database was updated directly,
/// so stores should re-query (no [`BackendEvent::NostrUpdate`] is fired
/// for synced events).
Synced,
/// A negentropy sync is in flight. Stores may re-query to render
/// incrementally; UI can show `current`/`total` progress.
SyncProgress {
/// Total events to process.
total: u64,
/// Events processed so far.
current: u64,
},
/// An event built locally was signed, broadcast and stored.
Published(Box<Event>),
/// An error occurred.
@@ -60,6 +73,8 @@ impl BackendEvent {
pub struct Backend {
inner: NostrBackend,
current_user: Option<PublicKey>,
connected: bool,
sync_progress: Option<(u64, u64)>,
tasks: Vec<Task<Result<(), Error>>>,
}
@@ -106,6 +121,8 @@ impl Backend {
let mut this = Self {
inner,
current_user: None,
connected: false,
sync_progress: None,
tasks: vec![pump],
};
@@ -132,7 +149,11 @@ impl Backend {
self.tasks.push(cx.spawn(async move |this, cx| {
match task.await {
Ok(()) => {
this.update(cx, |_this, cx| cx.emit(BackendEvent::Connected))?;
this.update(cx, |this, cx| {
this.connected = true;
cx.emit(BackendEvent::Connected);
cx.notify();
})?;
}
Err(e) => {
this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?;
@@ -153,7 +174,6 @@ impl Backend {
}
let user = cx.read_credentials(USER_KEYRING);
let master = self.master_key(cx);
self.tasks.push(cx.spawn(async move |this, cx| {
let content = match user.await {
@@ -169,10 +189,11 @@ impl Backend {
let keys = Keys::new(SecretKey::parse(&content)?);
this.update(cx, |this, cx| this.set_signer(keys, cx))?;
} else if content.starts_with("bunker://") {
let uri = NostrConnectUri::parse(&content)?;
let (base, keys) = extract_master_key(&content);
let uri = NostrConnectUri::parse(base)?;
let mut signer = NostrConnect::new(
uri,
master.await,
keys,
Duration::from_secs(NOSTR_CONNECT_TIMEOUT),
None,
)?;
@@ -197,6 +218,32 @@ impl Backend {
}));
}
/// Login with an `nsec1...` key or a `bunker://...` URI, dispatching on
/// the credential's prefix.
pub fn login(&mut self, credential: &str, cx: &mut Context<Self>) {
let credential = credential.trim();
if credential.starts_with("nsec1") {
self.login_with_nsec(credential, cx);
} else if credential.starts_with("bunker://") {
self.login_with_bunker(credential, cx);
} else {
cx.emit(BackendEvent::error(
"Unsupported credential, expected nsec1... or bunker://...",
));
}
}
/// Create a fresh identity and login with it. The generated key is
/// persisted in the keyring like any other `nsec` credential.
pub fn login_with_new_identity(&mut self, cx: &mut Context<Self>) {
let nsec = Keys::generate()
.secret_key()
.to_bech32()
.expect("infallible");
self.login_with_nsec(&nsec, cx);
}
/// Login with an `nsec1...` secret key. The credential is verified by
/// the signer flow and persisted in the keyring.
pub fn login_with_nsec(&mut self, nsec: &str, cx: &mut Context<Self>) {
@@ -224,9 +271,11 @@ impl Backend {
}));
}
/// Login with a `bunker://...` URI (NIP-46). The auth URL, if any, is
/// opened in the default browser. The credential is persisted in the
/// keyring after the signer proves reachable.
/// Login with a `bunker://...` URI (NIP-46). A fresh session key is
/// generated and embedded into the stored URI as `?master=<nsec>`, so
/// no separate keyring entry is needed. The auth URL, if any, is opened
/// in the default browser. The credential is persisted in the keyring
/// after the signer proves reachable.
pub fn login_with_bunker(&mut self, uri: &str, cx: &mut Context<Self>) {
let uri_string = uri.trim().to_owned();
@@ -238,14 +287,15 @@ impl Backend {
}
};
let master = self.master_key(cx);
let write = cx.write_credentials(USER_KEYRING, "bunker", uri_string.as_bytes());
let keys = Keys::generate();
let credential = with_master_key(&uri_string, &keys);
let write = cx.write_credentials(USER_KEYRING, "bunker", credential.as_bytes());
self.tasks.push(cx.spawn(async move |this, cx| {
let result = async {
let mut signer = NostrConnect::new(
connect_uri,
master.await,
keys,
Duration::from_secs(NOSTR_CONNECT_TIMEOUT),
None,
)?;
@@ -288,33 +338,6 @@ impl Backend {
}));
}
/// Get (or generate and persist) the key used for NIP-46 sessions.
fn master_key(&self, cx: &App) -> Task<Keys> {
let task = cx.read_credentials(MASTER_KEYRING);
cx.spawn(async move |cx| {
let (keys, new_key) = match task.await {
Ok(Some((_user, secret))) => match SecretKey::from_slice(&secret) {
Ok(secret_key) => (Keys::new(secret_key), false),
_ => (Keys::generate(), true),
},
_ => (Keys::generate(), true),
};
if new_key {
let username = keys.public_key().to_hex();
let password = keys.secret_key().to_secret_bytes();
cx.update(|cx| {
let task = cx.write_credentials(MASTER_KEYRING, &username, &password);
cx.background_spawn(async move { task.await.ok() }).detach();
});
}
keys
})
}
/// Fetch the user's grasp list (kind `10317`) and add the listed grasp
/// servers as relays.
fn bootstrap_user(&mut self, public_key: PublicKey, cx: &mut Context<Self>) {
@@ -371,6 +394,16 @@ impl Backend {
self.current_user
}
/// Whether the relay bootstrap has completed.
pub fn is_connected(&self) -> bool {
self.connected
}
/// Progress of the in-flight negentropy sync, if any: `(total, current)`.
pub fn sync_progress(&self) -> Option<(u64, u64)> {
self.sync_progress
}
/// Update the signer (any type implementing the async signer traits,
/// e.g. `Keys`, `NostrConnect`, a browser extension proxy).
pub fn set_signer<T>(&mut self, new_signer: T, cx: &mut Context<Self>)
@@ -418,7 +451,11 @@ impl Backend {
self.tasks.push(cx.spawn(async move |this, cx| {
match task.await {
Ok(()) => {
this.update(cx, |_this, cx| cx.emit(BackendEvent::Connected))?;
this.update(cx, |this, cx| {
this.connected = true;
cx.emit(BackendEvent::Connected);
cx.notify();
})?;
}
Err(e) => {
this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?;
@@ -464,6 +501,95 @@ impl Backend {
}));
}
/// Start a one-shot subscription targeted only at the bootstrap relays,
/// auto-closing after EOSE or a short timeout. Matching events are stored
/// in the database and surface as [`BackendEvent::NostrUpdate`] while the
/// subscription is open.
pub fn subscribe_bootstrap(&mut self, filters: Vec<Filter>, cx: &mut Context<Self>) {
let backend = self.inner.clone();
let task = cx.background_spawn(async move {
subscribe_bootstrap_only(&backend.client(), filters).await
});
self.tasks.push(cx.spawn(async move |this, cx| {
if let Err(e) = task.await {
this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?;
}
Ok(())
}));
}
/// Negentropy-sync the given filter against the bootstrap relays:
/// reconciles the local database with the relays in both directions.
/// Emits [`BackendEvent::SyncProgress`] while running (throttled to
/// whole-percent changes) and [`BackendEvent::Synced`] on completion.
pub fn sync_bootstrap(&mut self, filter: Filter, cx: &mut Context<Self>) {
let backend = self.inner.clone();
self.sync_progress = Some((0, 0));
cx.notify();
let (tx, mut rx) = SyncProgress::channel();
self.tasks.push(cx.spawn(async move |this, cx| {
let mut last_percent: u64 = 0;
while rx.changed().await.is_ok() {
let progress = *rx.borrow_and_update();
let percent = (progress.percentage() * 100.0) as u64;
if progress.current > 0 && percent != last_percent {
last_percent = percent;
let alive = this.update(cx, |this, cx| {
this.sync_progress = Some((progress.total, progress.current));
cx.emit(BackendEvent::SyncProgress {
total: progress.total,
current: progress.current,
});
cx.notify();
});
if alive.is_err() {
break;
}
}
}
Ok(())
}));
let task = cx.background_spawn(async move {
let opts = SyncOptions::default().progress(tx);
sync_bootstrap_only(&backend.client(), filter, opts).await
});
self.tasks.push(cx.spawn(async move |this, cx| {
match task.await {
Ok(summary) => {
log::debug!(
"sync done: {} received, {} sent",
summary.received.len(),
summary.sent.len()
);
this.update(cx, |this, cx| {
this.sync_progress = None;
cx.emit(BackendEvent::Synced);
cx.notify();
})?;
}
Err(e) => {
this.update(cx, |this, cx| {
this.sync_progress = None;
cx.emit(BackendEvent::error(e.to_string()))
})?;
}
}
Ok(())
}));
}
/// Sign, broadcast and locally store an event. Emits
/// [`BackendEvent::Published`] on success so stores can refresh.
///
@@ -502,3 +628,59 @@ impl Backend {
rx
}
}
/// Subscribe only on the bootstrap relays, auto-closing after EOSE or a
/// short timeout. Use for one-shot data fetches (repo events, profiles)
/// instead of persistent gossip-routed subscriptions.
pub(crate) async fn subscribe_bootstrap_only(
client: &Client,
filters: Vec<Filter>,
) -> Result<(), Error> {
let opts = SubscribeAutoCloseOptions::default()
.exit_policy(ReqExitPolicy::ExitOnEOSE)
.timeout(Some(Duration::from_secs(10)));
let target: HashMap<&str, Vec<Filter>> = BOOTSTRAP_RELAYS
.iter()
.map(|relay| (*relay, filters.clone()))
.collect();
client.subscribe(target).close_on(opts).await?;
Ok(())
}
/// Negentropy-sync the filter against the bootstrap relays only.
pub(crate) async fn sync_bootstrap_only(
client: &Client,
filter: Filter,
opts: SyncOptions,
) -> Result<SyncSummary, Error> {
let output = client
.sync(filter)
.with(BOOTSTRAP_RELAYS)
.opts(opts)
.await?;
Ok(output.value)
}
/// Embed a NIP-46 session key into a bunker URI as `?master=<nsec>`.
fn with_master_key(uri: &str, keys: &Keys) -> String {
let separator = if uri.contains('?') { '&' } else { '?' };
let nsec = keys.secret_key().to_bech32().expect("infallible");
format!("{uri}{separator}master={nsec}")
}
/// Split a stored bunker credential into the plain URI and the session key.
/// Credentials without an embedded key (legacy) get a fresh one.
fn extract_master_key(credential: &str) -> (&str, Keys) {
match credential.split_once("master=") {
Some((base, nsec)) => {
let keys = SecretKey::parse(nsec)
.map(Keys::new)
.unwrap_or_else(|_| Keys::generate());
(base.trim_end_matches(['?', '&']), keys)
}
None => (credential, Keys::generate()),
}
}
+55 -5
View File
@@ -5,7 +5,7 @@ use anyhow::Error;
use gpui::{App, Context, Entity, Global, SharedString, Subscription, Task};
use nostr_sdk::prelude::*;
use crate::backend::{Backend, BackendEvent};
use crate::backend::{Backend, BackendEvent, sync_bootstrap_only};
/// A user profile (kind `0` metadata), as plain data for the UI.
#[derive(Debug, Clone)]
@@ -181,6 +181,50 @@ impl ProfileStore {
self.tasks.push(task);
}
/// Re-read the latest metadata of every requested author from the local
/// database (used after a sync, which produces no NostrUpdate events).
fn apply_seen(&mut self, cx: &mut Context<Self>) {
if self.seen.is_empty() {
return;
}
let client = Backend::global(cx).read(cx).client();
let authors: Vec<PublicKey> = self.seen.iter().copied().collect();
let task = cx.spawn(async move |this, cx| {
let filter = Filter::new().kind(Kind::Metadata).authors(authors);
let events = client.database().query(filter).await?;
let mut latest: HashMap<PublicKey, (Timestamp, Metadata)> = HashMap::new();
for event in events {
match latest.get(&event.pubkey) {
Some((ts, _)) if *ts >= event.created_at => {}
_ => {
latest.insert(
event.pubkey,
(
event.created_at,
Metadata::from_json(&event.content).unwrap_or_default(),
),
);
}
}
}
this.update(cx, |this, cx| {
for (public_key, (_, metadata)) in latest {
this.profiles
.insert(public_key, Profile::new(public_key, metadata));
}
cx.notify();
})?;
Ok(())
});
self.tasks.push(task);
}
/// Drain the queue in a batched fetch, debounced to collect requests.
fn queue_fetch(&mut self, cx: &mut Context<Self>) {
if self.fetching {
@@ -210,10 +254,16 @@ impl ProfileStore {
.kind(Kind::Metadata)
.authors(batch.into_iter().collect::<Vec<PublicKey>>());
// Gossip routes the fetch to each author's relays. Fetched
// events land in the database and surface via NostrUpdate.
if let Err(e) = client.fetch_events(filter).await {
log::warn!("profile fetch failed: {e}");
// Negentropy-sync with the bootstrap relays. Synced events
// are written to the database directly (no NostrUpdate), so
// re-apply from the database afterwards.
match sync_bootstrap_only(&client, filter, SyncOptions::default()).await {
Ok(_) => {
this.update(cx, |this, cx| this.apply_seen(cx))?;
}
Err(e) => {
log::warn!("profile sync failed: {e}");
}
}
}
+10 -4
View File
@@ -78,14 +78,20 @@ impl RepoStore {
&self.addr
}
/// Subscribe the relay pool to this repository's activity.
/// Fetch this repository's events from the bootstrap relays (one-shot,
/// auto-closing subscription).
fn subscribe_remote(&mut self, cx: &mut Context<Self>) {
let addr = self.addr.clone();
Backend::global(cx).update(cx, |backend, cx| {
backend.subscribe(filters::announcement(&addr), cx);
backend.subscribe(filters::state(&addr), cx);
backend.subscribe(filters::activity(&addr), cx);
backend.subscribe_bootstrap(
vec![
filters::announcement(&addr),
filters::state(&addr),
filters::activity(&addr),
],
cx,
);
});
}
+9 -5
View File
@@ -20,7 +20,8 @@ pub struct RepoListStore {
impl RepoListStore {
/// Create a store. If `author` is `None`, all announcements are listed.
pub fn new(author: Option<PublicKey>, cx: &mut Context<Self>) -> Self {
let subscription = cx.subscribe(&Backend::global(cx), |this, _backend, event, cx| {
let backend = Backend::global(cx);
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| {
let relevant = match event {
BackendEvent::NostrUpdate(update) => {
update.kind == Kind::GitRepoAnnouncement
@@ -30,6 +31,7 @@ impl RepoListStore {
event.kind == Kind::GitRepoAnnouncement
&& this.author.is_none_or(|a| a == event.pubkey)
}
BackendEvent::Synced | BackendEvent::SyncProgress { .. } => true,
_ => false,
};
@@ -59,15 +61,17 @@ impl RepoListStore {
self.refresh(cx);
}
/// Negentropy-sync announcements with the bootstrap relays.
fn subscribe_remote(&mut self, cx: &mut Context<Self>) {
let backend = Backend::global(cx);
let author = self.author;
Backend::global(cx).update(cx, |backend, cx| {
backend.update(cx, |backend, cx| {
let filter = match author {
Some(a) => filters::announcements_by(a),
None => filters::all_announcements(500),
None => filters::all_announcements(),
};
backend.subscribe(filter, cx);
backend.sync_bootstrap(filter, cx);
});
}
@@ -89,7 +93,7 @@ impl RepoListStore {
loop {
let filter = match author {
Some(a) => filters::announcements_by(a),
None => filters::all_announcements(500),
None => filters::all_announcements(),
};
let events = match client.database().query(filter).await {
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "workspace"
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
signed_core = { path = "../signed_core" }
signed_state = { path = "../signed_state" }
gpui.workspace = true
gpui-component.workspace = true
+15
View File
@@ -0,0 +1,15 @@
mod views;
mod workspace;
pub use views::{RepoListView, SidebarPanel};
pub use workspace::Workspace;
use gpui::{App, AppContext, Entity, Window};
use gpui_component::Root;
/// Build the root view tree. Requires `signed_state::init` and
/// `gpui_component::init` to have been called first.
pub fn root(window: &mut Window, cx: &mut App) -> Entity<Root> {
let view = cx.new(|cx| Workspace::new(window, cx));
cx.new(|cx| Root::new(view, window, cx))
}
+5
View File
@@ -0,0 +1,5 @@
mod repo_list;
mod sidebar;
pub use repo_list::RepoListView;
pub use sidebar::SidebarPanel;
+159
View File
@@ -0,0 +1,159 @@
use gpui::prelude::*;
use gpui::{
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, SharedString,
Subscription, Window, div, px, uniform_list,
};
use gpui_component::dock::{Panel, PanelEvent};
use gpui_component::{ActiveTheme, StyledExt};
use signed_core::Announcement;
use signed_state::{ProfileStore, RepoListStore};
/// Browse all announced repositories (works anonymously).
pub struct RepoListView {
store: Entity<RepoListStore>,
focus_handle: FocusHandle,
_subscription: Subscription,
}
impl RepoListView {
pub fn new(_window: &mut Window, cx: &mut Context<Self>) -> Self {
let store = cx.new(|cx| RepoListStore::new(None, cx));
let subscription = cx.observe(&store, |_this, _store, cx| cx.notify());
Self {
store,
focus_handle: cx.focus_handle(),
_subscription: subscription,
}
}
fn render_card(&self, announcement: &Announcement, cx: &mut App) -> AnyElement {
let name = announcement
.name
.clone()
.unwrap_or_else(|| announcement.id.clone());
let owner = ProfileStore::global(cx)
.update(cx, |store, cx| store.get(announcement.owner, cx))
.name();
let description = announcement.description.clone().unwrap_or_default();
div()
.v_flex()
.h(px(60.))
.w_full()
.justify_center()
.gap_1()
.px_4()
.border_b(px(1.))
.border_color(cx.theme().border)
.child(
div()
.h_flex()
.gap_2()
.items_center()
.child(
div()
.text_sm()
.font_semibold()
.whitespace_nowrap()
.text_ellipsis()
.child(name),
)
.child(
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.whitespace_nowrap()
.child(owner),
),
)
.child(
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.whitespace_nowrap()
.text_ellipsis()
.child(description),
)
.into_any_element()
}
}
impl Panel for RepoListView {
fn panel_name(&self) -> &'static str {
"repo_list"
}
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
"Explore"
}
}
impl EventEmitter<PanelEvent> for RepoListView {}
impl Focusable for RepoListView {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for RepoListView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let announcements = self.store.read(cx).announcements.clone();
let has_announcements = !announcements.is_empty();
let count = announcements.len();
div()
.v_flex()
.size_full()
.child(
div()
.h_flex()
.px_4()
.py_2()
.items_center()
.child(div().text_sm().font_semibold().child("Repositories"))
.child(
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(SharedString::from(format!(" ({count})"))),
),
)
.when(!has_announcements, |this| {
this.child(
div()
.size_full()
.v_flex()
.items_center()
.justify_center()
.child(
div()
.text_sm()
.text_color(cx.theme().muted_foreground)
.child("No repositories found. Waiting for relays..."),
),
)
})
.when(has_announcements, |this| {
this.child(
uniform_list(
"repos",
count,
cx.processor(move |this, range, _window, cx| {
let mut items = vec![];
for ix in range {
items.push(this.render_card(&announcements[ix], cx));
}
items
}),
)
.size_full(),
)
})
}
}
+134
View File
@@ -0,0 +1,134 @@
use std::sync::Arc;
use gpui::prelude::*;
use gpui::{
App, Context, EventEmitter, FocusHandle, Focusable, Render, Subscription, WeakEntity, Window,
div,
};
use gpui_component::button::{Button, ButtonVariants};
use gpui_component::dock::{DockArea, DockPlacement, Panel, PanelEvent};
use gpui_component::{ActiveTheme, v_flex};
use signed_state::{Backend, BackendEvent};
use crate::views::RepoListView;
/// Left-dock panel with navigation entries. Entries open content panels in
/// the dock area.
pub struct SidebarPanel {
focus_handle: FocusHandle,
dock_area: WeakEntity<DockArea>,
explore: Option<WeakEntity<RepoListView>>,
logged_in: bool,
_subscription: Subscription,
}
impl SidebarPanel {
pub fn new(dock_area: WeakEntity<DockArea>, cx: &mut Context<Self>) -> Self {
let backend = Backend::global(cx);
let logged_in = backend.read(cx).current_user().is_some();
let subscription = cx.subscribe(&backend, |this, backend, event, cx| {
match event {
BackendEvent::SignerChanged => {
this.logged_in = backend.read(cx).current_user().is_some();
}
BackendEvent::SignerRequired => {
this.logged_in = false;
}
_ => return,
}
cx.notify();
});
Self {
focus_handle: cx.focus_handle(),
dock_area,
explore: None,
logged_in,
_subscription: subscription,
}
}
/// Open the Explore (repository list) panel in the center of the dock
/// area. No-op if it's already open.
pub fn open_explore(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self
.explore
.as_ref()
.and_then(WeakEntity::upgrade)
.is_some()
{
return;
}
let panel = cx.new(|cx| RepoListView::new(window, cx));
self.explore = Some(panel.downgrade());
let _ = self.dock_area.update(cx, |dock_area, cx| {
dock_area.add_panel(Arc::new(panel), DockPlacement::Center, None, window, cx);
});
}
}
impl Panel for SidebarPanel {
fn panel_name(&self) -> &'static str {
"sidebar"
}
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div()
}
fn closable(&self, _cx: &App) -> bool {
false
}
fn inner_padding(&self, _cx: &App) -> bool {
false
}
}
impl EventEmitter<PanelEvent> for SidebarPanel {}
impl Focusable for SidebarPanel {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for SidebarPanel {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
if self.logged_in {
v_flex().child(
Button::new("explore")
.label("Explore")
.w_full()
.on_click(cx.listener(|this, _, window, cx| this.open_explore(window, cx))),
)
} else {
v_flex()
.p_4()
.size_full()
.items_center()
.justify_center()
.gap_2()
.child(
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child("Sign in to continue"),
)
.child(
Button::new("get-started")
.label("Get started")
.primary()
.w_full(),
)
.child(
Button::new("import-identity")
.label("Import identity")
.w_full(),
)
}
}
}
+103
View File
@@ -0,0 +1,103 @@
use gpui::prelude::*;
use gpui::{Context, Entity, Render, SharedString, Subscription, Window, div, px};
use gpui_component::dock::{DockArea, DockItem};
use gpui_component::{ActiveTheme, Root, StyledExt, TitleBar, h_flex, v_flex};
use signed_state::{Backend, BackendEvent};
use crate::views::SidebarPanel;
/// Root view of the app: title bar, dock area, status bar.
pub struct Workspace {
dock: Entity<DockArea>,
status: SharedString,
_subscription: Subscription,
}
impl Workspace {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let backend = Backend::global(cx);
let dock = cx.new(|cx| DockArea::new("dock", Some(1), window, cx));
let weak_dock = dock.downgrade();
let sidebar = cx.new(|cx| SidebarPanel::new(weak_dock.clone(), cx));
dock.update(cx, |dock_area, cx| {
dock_area.set_left_dock(
DockItem::tab(sidebar.clone(), &weak_dock, window, cx),
Some(px(240.)),
true,
window,
cx,
);
});
sidebar.update(cx, |sidebar, cx| sidebar.open_explore(window, cx));
let connected = backend.read(cx).is_connected();
let sync_progress = backend.read(cx).sync_progress();
let status = if let Some((total, current)) = sync_progress {
format!("Syncing repositories... {current}/{total}").into()
} else if connected {
"Connected".into()
} else {
"Connecting...".into()
};
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| {
match event {
BackendEvent::Connected => this.status = "Connected".into(),
BackendEvent::SyncProgress { total, current } => {
this.status = format!("Syncing repositories... {current}/{total}").into()
}
BackendEvent::Synced => this.status = "Connected".into(),
BackendEvent::Error(error) => this.status = error.clone().into(),
_ => return,
}
cx.notify();
});
Self {
dock,
status,
_subscription: subscription,
}
}
}
impl Render for Workspace {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let dialog_layer = Root::render_dialog_layer(window, cx);
let notification_layer = Root::render_notification_layer(window, cx);
div()
.id("workspace")
.v_flex()
.size_full()
.child(
v_flex()
.size_full()
// Title Bar
.child(
TitleBar::new()
// Left
.child(div())
// Right
.child(
h_flex().px_2().child(
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(self.status.clone()),
),
),
)
// Dock Area
.child(self.dock.clone()),
)
// Notifications
.children(notification_layer)
// Modals
.children(dialog_layer)
}
}
+4
View File
@@ -9,6 +9,10 @@ name = "signed"
path = "src/main.rs"
[dependencies]
paths = { path = "../crates/paths" }
signed_state = { path = "../crates/signed_state" }
workspace = { path = "../crates/workspace" }
gpui.workspace = true
gpui_platform.workspace = true
gpui_linux.workspace = true
+5 -27
View File
@@ -1,30 +1,8 @@
use std::sync::Arc;
use gpui::*;
use gpui_component::button::*;
use gpui_component::*;
use gpui_platform::application;
pub struct HelloWorld;
impl Render for HelloWorld {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
div()
.v_flex()
.gap_2()
.size_full()
.items_center()
.justify_center()
.child("Hello, World!")
.child(
Button::new("ok")
.primary()
.label("Let's Go!")
.on_click(|_ev, _window, _cx| println!("Clicked!")),
)
}
}
fn main() {
// Initialize logging
tracing_subscriber::fmt::init();
@@ -34,6 +12,10 @@ fn main() {
.run(move |cx| {
gpui_component::init(cx);
// Initialize backend and stores (connects relays, restores session)
std::fs::create_dir_all(paths::nostr_dir()).ok();
signed_state::init(paths::nostr_dir(), cx);
// Set up the window bounds
let bounds = Bounds::centered(None, size(px(960.0), px(720.0)), cx);
@@ -53,11 +35,7 @@ fn main() {
};
cx.spawn(async move |cx| {
cx.open_window(opts, |window, cx| {
let view = cx.new(|_| HelloWorld);
cx.new(|cx| Root::new(view, window, cx))
})
.ok();
let _ = cx.open_window(opts, workspace::root);
})
.detach();