diff --git a/Cargo.lock b/Cargo.lock index fe4b891..e14dad8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/crates/paths/Cargo.toml b/crates/paths/Cargo.toml new file mode 100644 index 0000000..da558cc --- /dev/null +++ b/crates/paths/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "paths" +version.workspace = true +edition.workspace = true +publish.workspace = true + +[dependencies] +dirs = "6" diff --git a/crates/paths/src/lib.rs b/crates/paths/src/lib.rs new file mode 100644 index 0000000..9d51d04 --- /dev/null +++ b/crates/paths/src/lib.rs @@ -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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = OnceLock::new(); + KEYMAP_FILE.get_or_init(|| config_dir().join("keymap.json")) +} diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml new file mode 100644 index 0000000..7df7966 --- /dev/null +++ b/crates/workspace/Cargo.toml @@ -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 diff --git a/crates/workspace/src/lib.rs b/crates/workspace/src/lib.rs new file mode 100644 index 0000000..213cb6c --- /dev/null +++ b/crates/workspace/src/lib.rs @@ -0,0 +1,15 @@ +mod views; +mod workspace; + +pub use views::RepoListView; +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 { + let view = cx.new(|cx| Workspace::new(window, cx)); + cx.new(|cx| Root::new(view, window, cx)) +} diff --git a/crates/workspace/src/views/mod.rs b/crates/workspace/src/views/mod.rs new file mode 100644 index 0000000..7d8f7f2 --- /dev/null +++ b/crates/workspace/src/views/mod.rs @@ -0,0 +1,3 @@ +mod repo_list; + +pub use repo_list::RepoListView; diff --git a/crates/workspace/src/views/repo_list.rs b/crates/workspace/src/views/repo_list.rs new file mode 100644 index 0000000..db5636b --- /dev/null +++ b/crates/workspace/src/views/repo_list.rs @@ -0,0 +1,105 @@ +use gpui::prelude::*; +use gpui::{Context, Entity, Render, SharedString, Subscription, Window, div, px}; +use gpui_component::scroll::ScrollableElement; +use gpui_component::{ActiveTheme, StyledExt}; +use signed_core::Announcement; +use signed_state::{ProfileStore, RepoListStore}; + +/// Browse all announced repositories. +pub struct RepoListView { + store: Entity, + _subscription: Subscription, +} + +impl RepoListView { + pub fn new(_window: &mut Window, cx: &mut Context) -> Self { + let store = cx.new(|cx| RepoListStore::new(None, cx)); + let subscription = cx.observe(&store, |_this, _store, cx| cx.notify()); + + Self { + store, + _subscription: subscription, + } + } + + fn render_card(&self, announcement: &Announcement, cx: &mut Context) -> impl IntoElement { + 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(); + + div() + .v_flex() + .gap_1() + .px_4() + .py_3() + .border_b(px(1.)) + .border_color(cx.theme().border) + .child( + div() + .h_flex() + .gap_2() + .items_center() + .child(div().text_sm().font_semibold().child(name)) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(owner), + ), + ) + .children(announcement.description.as_ref().map(|description| { + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(description.clone()) + })) + } +} + +impl Render for RepoListView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let announcements = self.store.read(cx).announcements.clone(); + let count = announcements.len(); + + let mut list = div().v_flex().flex_1().overflow_y_scrollbar(); + + if announcements.is_empty() { + list = list.child( + div().size_full().items_center().justify_center().child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child("No repositories found. Waiting for relays..."), + ), + ); + } else { + for announcement in &announcements { + list = list.child(self.render_card(announcement, cx)); + } + } + + 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})"))), + ), + ) + .child(list) + } +} diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs new file mode 100644 index 0000000..47b6ee7 --- /dev/null +++ b/crates/workspace/src/workspace.rs @@ -0,0 +1,73 @@ +use gpui::prelude::*; +use gpui::{AnyView, Context, Render, SharedString, Subscription, Window, div, px}; +use gpui_component::{ActiveTheme, StyledExt}; +use signed_state::{Backend, BackendEvent}; + +use crate::views::RepoListView; + +/// Root view of the app: header, active screen, status bar. +pub struct Workspace { + active_screen: AnyView, + status: SharedString, + _subscription: Subscription, +} + +impl Workspace { + pub fn new(window: &mut Window, cx: &mut Context) -> Self { + let repo_list = cx.new(|cx| RepoListView::new(window, cx)); + + let subscription = cx.subscribe(&Backend::global(cx), |this, _backend, event, cx| { + match event { + BackendEvent::Connected => this.status = "Connected".into(), + BackendEvent::Error(error) => this.status = error.clone().into(), + _ => return, + } + cx.notify(); + }); + + Self { + active_screen: repo_list.into(), + status: SharedString::from("Connecting..."), + _subscription: subscription, + } + } +} + +impl Render for Workspace { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + div() + .v_flex() + .size_full() + .bg(cx.theme().background) + .text_color(cx.theme().foreground) + .child( + div() + .h_flex() + .px_4() + .py_2() + .border_b(px(1.)) + .border_color(cx.theme().border) + .child(div().text_lg().font_semibold().child("Signed")), + ) + .child( + div() + .flex_1() + .overflow_hidden() + .child(self.active_screen.clone()), + ) + .child( + div() + .h_flex() + .px_4() + .py_1() + .border_t(px(1.)) + .border_color(cx.theme().border) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(self.status.clone()), + ), + ) + } +} diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index ed55d1c..6551e17 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -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 diff --git a/desktop/src/main.rs b/desktop/src/main.rs index 7c707a1..8f3bb5c 100644 --- a/desktop/src/main.rs +++ b/desktop/src/main.rs @@ -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) -> 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();