Compare commits
7
Commits
master
...
a97dfac23f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a97dfac23f | ||
|
|
6b13d8a8f7 | ||
|
|
8de6018e28 | ||
|
|
ed93d81a26 | ||
|
|
2ec7d14c33 | ||
|
|
fdf74327bb | ||
|
|
f497279886 |
Generated
+20
@@ -6055,6 +6055,13 @@ dependencies = [
|
|||||||
"rustc_version",
|
"rustc_version",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "paths"
|
||||||
|
version = "1.0.0"
|
||||||
|
dependencies = [
|
||||||
|
"dirs",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pbkdf2"
|
name = "pbkdf2"
|
||||||
version = "0.12.2"
|
version = "0.12.2"
|
||||||
@@ -7741,8 +7748,11 @@ dependencies = [
|
|||||||
"gpui_platform",
|
"gpui_platform",
|
||||||
"gpui_windows",
|
"gpui_windows",
|
||||||
"log",
|
"log",
|
||||||
|
"paths",
|
||||||
"reqwest_client",
|
"reqwest_client",
|
||||||
|
"signed_state",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
"workspace",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -10294,6 +10304,16 @@ version = "0.57.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "workspace"
|
||||||
|
version = "1.0.0"
|
||||||
|
dependencies = [
|
||||||
|
"gpui",
|
||||||
|
"gpui-component",
|
||||||
|
"signed_core",
|
||||||
|
"signed_state",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "workspace-hack"
|
name = "workspace-hack"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
[package]
|
||||||
|
name = "paths"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
dirs = "6"
|
||||||
@@ -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"))
|
||||||
|
}
|
||||||
@@ -68,8 +68,10 @@ pub fn announcements_by(public_key: PublicKey) -> Filter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// All repository announcements (for global discovery).
|
/// All repository announcements (for global discovery).
|
||||||
pub fn all_announcements(limit: usize) -> Filter {
|
///
|
||||||
Filter::new()
|
/// Unbounded: intended for negentropy sync, which reconciles sets
|
||||||
.kind(Kind::GitRepoAnnouncement)
|
/// efficiently regardless of size. Local database queries with this
|
||||||
.limit(limit)
|
/// filter are served by LMDB, so they stay fast as the database grows.
|
||||||
|
pub fn all_announcements() -> Filter {
|
||||||
|
Filter::new().kind(Kind::GitRepoAnnouncement)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::{Error, anyhow};
|
use anyhow::{Error, anyhow};
|
||||||
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task};
|
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task};
|
||||||
use nostr_connect::prelude::*;
|
use nostr_connect::prelude::*;
|
||||||
|
use nostr_sdk::client::SyncSummary;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use signed_core::filters;
|
use signed_core::filters;
|
||||||
use signed_nostr::{NostrBackend, SignedAuthUrlHandler, UniversalSigner, Update};
|
use signed_nostr::{NostrBackend, SignedAuthUrlHandler, UniversalSigner, Update};
|
||||||
|
|
||||||
/// Keyring entry holding the user credential (`nsec1...` or `bunker://...`).
|
/// Keyring entry holding the user credential (`nsec1...` or `bunker://...`
|
||||||
pub const USER_KEYRING: &str = "su.reya.signed#user";
|
/// with an embedded `?master=<nsec>` NIP-46 session key).
|
||||||
/// Keyring entry holding the locally generated key for NIP-46 sessions.
|
pub const USER_KEYRING: &str = "Signed Safe Storage";
|
||||||
pub const MASTER_KEYRING: &str = "su.reya.signed#master";
|
|
||||||
/// Timeout for NIP-46 signer responses.
|
/// Timeout for NIP-46 signer responses.
|
||||||
pub const NOSTR_CONNECT_TIMEOUT: u64 = 60;
|
pub const NOSTR_CONNECT_TIMEOUT: u64 = 60;
|
||||||
|
|
||||||
@@ -39,6 +40,18 @@ pub enum BackendEvent {
|
|||||||
Connected,
|
Connected,
|
||||||
/// A new event was received from a relay and stored in the database.
|
/// A new event was received from a relay and stored in the database.
|
||||||
NostrUpdate(Update),
|
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.
|
/// An event built locally was signed, broadcast and stored.
|
||||||
Published(Box<Event>),
|
Published(Box<Event>),
|
||||||
/// An error occurred.
|
/// An error occurred.
|
||||||
@@ -60,6 +73,8 @@ impl BackendEvent {
|
|||||||
pub struct Backend {
|
pub struct Backend {
|
||||||
inner: NostrBackend,
|
inner: NostrBackend,
|
||||||
current_user: Option<PublicKey>,
|
current_user: Option<PublicKey>,
|
||||||
|
connected: bool,
|
||||||
|
sync_progress: Option<(u64, u64)>,
|
||||||
tasks: Vec<Task<Result<(), Error>>>,
|
tasks: Vec<Task<Result<(), Error>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,6 +121,8 @@ impl Backend {
|
|||||||
let mut this = Self {
|
let mut this = Self {
|
||||||
inner,
|
inner,
|
||||||
current_user: None,
|
current_user: None,
|
||||||
|
connected: false,
|
||||||
|
sync_progress: None,
|
||||||
tasks: vec![pump],
|
tasks: vec![pump],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -132,7 +149,11 @@ impl Backend {
|
|||||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
match task.await {
|
match task.await {
|
||||||
Ok(()) => {
|
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) => {
|
Err(e) => {
|
||||||
this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?;
|
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 user = cx.read_credentials(USER_KEYRING);
|
||||||
let master = self.master_key(cx);
|
|
||||||
|
|
||||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
let content = match user.await {
|
let content = match user.await {
|
||||||
@@ -169,10 +189,11 @@ impl Backend {
|
|||||||
let keys = Keys::new(SecretKey::parse(&content)?);
|
let keys = Keys::new(SecretKey::parse(&content)?);
|
||||||
this.update(cx, |this, cx| this.set_signer(keys, cx))?;
|
this.update(cx, |this, cx| this.set_signer(keys, cx))?;
|
||||||
} else if content.starts_with("bunker://") {
|
} 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(
|
let mut signer = NostrConnect::new(
|
||||||
uri,
|
uri,
|
||||||
master.await,
|
keys,
|
||||||
Duration::from_secs(NOSTR_CONNECT_TIMEOUT),
|
Duration::from_secs(NOSTR_CONNECT_TIMEOUT),
|
||||||
None,
|
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
|
/// 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>) {
|
||||||
@@ -224,9 +271,11 @@ impl Backend {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Login with a `bunker://...` URI (NIP-46). The auth URL, if any, is
|
/// Login with a `bunker://...` URI (NIP-46). A fresh session key is
|
||||||
/// opened in the default browser. The credential is persisted in the
|
/// generated and embedded into the stored URI as `?master=<nsec>`, so
|
||||||
/// keyring after the signer proves reachable.
|
/// 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>) {
|
pub fn login_with_bunker(&mut self, uri: &str, cx: &mut Context<Self>) {
|
||||||
let uri_string = uri.trim().to_owned();
|
let uri_string = uri.trim().to_owned();
|
||||||
|
|
||||||
@@ -238,14 +287,15 @@ impl Backend {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let master = self.master_key(cx);
|
let keys = Keys::generate();
|
||||||
let write = cx.write_credentials(USER_KEYRING, "bunker", uri_string.as_bytes());
|
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| {
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
let result = async {
|
let result = async {
|
||||||
let mut signer = NostrConnect::new(
|
let mut signer = NostrConnect::new(
|
||||||
connect_uri,
|
connect_uri,
|
||||||
master.await,
|
keys,
|
||||||
Duration::from_secs(NOSTR_CONNECT_TIMEOUT),
|
Duration::from_secs(NOSTR_CONNECT_TIMEOUT),
|
||||||
None,
|
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
|
/// Fetch the user's grasp list (kind `10317`) and add the listed grasp
|
||||||
/// servers as relays.
|
/// servers as relays.
|
||||||
fn bootstrap_user(&mut self, public_key: PublicKey, cx: &mut Context<Self>) {
|
fn bootstrap_user(&mut self, public_key: PublicKey, cx: &mut Context<Self>) {
|
||||||
@@ -371,6 +394,16 @@ impl Backend {
|
|||||||
self.current_user
|
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,
|
/// Update the signer (any type implementing the async signer traits,
|
||||||
/// e.g. `Keys`, `NostrConnect`, a browser extension proxy).
|
/// e.g. `Keys`, `NostrConnect`, a browser extension proxy).
|
||||||
pub fn set_signer<T>(&mut self, new_signer: T, cx: &mut Context<Self>)
|
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| {
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
match task.await {
|
match task.await {
|
||||||
Ok(()) => {
|
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) => {
|
Err(e) => {
|
||||||
this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?;
|
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
|
/// Sign, broadcast and locally store an event. Emits
|
||||||
/// [`BackendEvent::Published`] on success so stores can refresh.
|
/// [`BackendEvent::Published`] on success so stores can refresh.
|
||||||
///
|
///
|
||||||
@@ -502,3 +628,59 @@ impl Backend {
|
|||||||
rx
|
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()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use anyhow::Error;
|
|||||||
use gpui::{App, Context, Entity, Global, SharedString, Subscription, Task};
|
use gpui::{App, Context, Entity, Global, SharedString, Subscription, Task};
|
||||||
use nostr_sdk::prelude::*;
|
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.
|
/// A user profile (kind `0` metadata), as plain data for the UI.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -181,6 +181,50 @@ impl ProfileStore {
|
|||||||
self.tasks.push(task);
|
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.
|
/// Drain the queue in a batched fetch, debounced to collect requests.
|
||||||
fn queue_fetch(&mut self, cx: &mut Context<Self>) {
|
fn queue_fetch(&mut self, cx: &mut Context<Self>) {
|
||||||
if self.fetching {
|
if self.fetching {
|
||||||
@@ -210,10 +254,16 @@ impl ProfileStore {
|
|||||||
.kind(Kind::Metadata)
|
.kind(Kind::Metadata)
|
||||||
.authors(batch.into_iter().collect::<Vec<PublicKey>>());
|
.authors(batch.into_iter().collect::<Vec<PublicKey>>());
|
||||||
|
|
||||||
// Gossip routes the fetch to each author's relays. Fetched
|
// Negentropy-sync with the bootstrap relays. Synced events
|
||||||
// events land in the database and surface via NostrUpdate.
|
// are written to the database directly (no NostrUpdate), so
|
||||||
if let Err(e) = client.fetch_events(filter).await {
|
// re-apply from the database afterwards.
|
||||||
log::warn!("profile fetch failed: {e}");
|
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}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -78,14 +78,20 @@ impl RepoStore {
|
|||||||
&self.addr
|
&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>) {
|
fn subscribe_remote(&mut self, cx: &mut Context<Self>) {
|
||||||
let addr = self.addr.clone();
|
let addr = self.addr.clone();
|
||||||
|
|
||||||
Backend::global(cx).update(cx, |backend, cx| {
|
Backend::global(cx).update(cx, |backend, cx| {
|
||||||
backend.subscribe(filters::announcement(&addr), cx);
|
backend.subscribe_bootstrap(
|
||||||
backend.subscribe(filters::state(&addr), cx);
|
vec![
|
||||||
backend.subscribe(filters::activity(&addr), cx);
|
filters::announcement(&addr),
|
||||||
|
filters::state(&addr),
|
||||||
|
filters::activity(&addr),
|
||||||
|
],
|
||||||
|
cx,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ pub struct RepoListStore {
|
|||||||
impl RepoListStore {
|
impl RepoListStore {
|
||||||
/// Create a store. If `author` is `None`, all announcements are listed.
|
/// Create a store. If `author` is `None`, all announcements are listed.
|
||||||
pub fn new(author: Option<PublicKey>, cx: &mut Context<Self>) -> Self {
|
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 {
|
let relevant = match event {
|
||||||
BackendEvent::NostrUpdate(update) => {
|
BackendEvent::NostrUpdate(update) => {
|
||||||
update.kind == Kind::GitRepoAnnouncement
|
update.kind == Kind::GitRepoAnnouncement
|
||||||
@@ -30,6 +31,7 @@ impl RepoListStore {
|
|||||||
event.kind == Kind::GitRepoAnnouncement
|
event.kind == Kind::GitRepoAnnouncement
|
||||||
&& this.author.is_none_or(|a| a == event.pubkey)
|
&& this.author.is_none_or(|a| a == event.pubkey)
|
||||||
}
|
}
|
||||||
|
BackendEvent::Synced | BackendEvent::SyncProgress { .. } => true,
|
||||||
_ => false,
|
_ => false,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -59,15 +61,17 @@ impl RepoListStore {
|
|||||||
self.refresh(cx);
|
self.refresh(cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Negentropy-sync announcements with the bootstrap relays.
|
||||||
fn subscribe_remote(&mut self, cx: &mut Context<Self>) {
|
fn subscribe_remote(&mut self, cx: &mut Context<Self>) {
|
||||||
|
let backend = Backend::global(cx);
|
||||||
let author = self.author;
|
let author = self.author;
|
||||||
|
|
||||||
Backend::global(cx).update(cx, |backend, cx| {
|
backend.update(cx, |backend, cx| {
|
||||||
let filter = match author {
|
let filter = match author {
|
||||||
Some(a) => filters::announcements_by(a),
|
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 {
|
loop {
|
||||||
let filter = match author {
|
let filter = match author {
|
||||||
Some(a) => filters::announcements_by(a),
|
Some(a) => filters::announcements_by(a),
|
||||||
None => filters::all_announcements(500),
|
None => filters::all_announcements(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let events = match client.database().query(filter).await {
|
let events = match client.database().query(filter).await {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
mod repo_list;
|
||||||
|
mod sidebar;
|
||||||
|
|
||||||
|
pub use repo_list::RepoListView;
|
||||||
|
pub use sidebar::SidebarPanel;
|
||||||
@@ -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(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,10 @@ name = "signed"
|
|||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
paths = { path = "../crates/paths" }
|
||||||
|
signed_state = { path = "../crates/signed_state" }
|
||||||
|
workspace = { path = "../crates/workspace" }
|
||||||
|
|
||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
gpui_platform.workspace = true
|
gpui_platform.workspace = true
|
||||||
gpui_linux.workspace = true
|
gpui_linux.workspace = true
|
||||||
|
|||||||
+5
-27
@@ -1,30 +1,8 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use gpui::*;
|
use gpui::*;
|
||||||
use gpui_component::button::*;
|
|
||||||
use gpui_component::*;
|
|
||||||
use gpui_platform::application;
|
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() {
|
fn main() {
|
||||||
// Initialize logging
|
// Initialize logging
|
||||||
tracing_subscriber::fmt::init();
|
tracing_subscriber::fmt::init();
|
||||||
@@ -34,6 +12,10 @@ fn main() {
|
|||||||
.run(move |cx| {
|
.run(move |cx| {
|
||||||
gpui_component::init(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
|
// Set up the window bounds
|
||||||
let bounds = Bounds::centered(None, size(px(960.0), px(720.0)), cx);
|
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.spawn(async move |cx| {
|
||||||
cx.open_window(opts, |window, cx| {
|
let _ = cx.open_window(opts, workspace::root);
|
||||||
let view = cx.new(|_| HelloWorld);
|
|
||||||
cx.new(|cx| Root::new(view, window, cx))
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
})
|
})
|
||||||
.detach();
|
.detach();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user