From 831a89dd1163387e0975b678d4cfcaa2ad11484a Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Mon, 10 Aug 2026 08:33:19 +0700 Subject: [PATCH] add repo detail view --- Cargo.lock | 4 + crates/signed_git/Cargo.toml | 3 + crates/signed_git/src/lib.rs | 176 +++++ crates/signed_state/Cargo.toml | 1 + crates/signed_state/src/git_store.rs | 44 ++ crates/signed_state/src/lib.rs | 12 +- crates/workspace/Cargo.toml | 3 + crates/workspace/src/views/mod.rs | 2 + crates/workspace/src/views/repo_detail.rs | 776 ++++++++++++++++++++++ crates/workspace/src/views/repo_list.rs | 40 +- crates/workspace/src/views/sidebar/mod.rs | 2 +- desktop/src/main.rs | 4 + 12 files changed, 1060 insertions(+), 7 deletions(-) create mode 100644 crates/signed_state/src/git_store.rs create mode 100644 crates/workspace/src/views/repo_detail.rs diff --git a/Cargo.lock b/Cargo.lock index c8a5c98..b131831 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7796,6 +7796,7 @@ dependencies = [ "gix", "nostr", "signed_core", + "tempfile", ] [[package]] @@ -7826,6 +7827,7 @@ dependencies = [ "nostr-sdk", "rustls", "signed_core", + "signed_git", "signed_nostr", "utils", ] @@ -10352,10 +10354,12 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" name = "workspace" version = "1.0.0" dependencies = [ + "anyhow", "assets", "gpui", "gpui-component", "signed_core", + "signed_git", "signed_state", "utils", ] diff --git a/crates/signed_git/Cargo.toml b/crates/signed_git/Cargo.toml index bdfb0c0..a853dc1 100644 --- a/crates/signed_git/Cargo.toml +++ b/crates/signed_git/Cargo.toml @@ -10,3 +10,6 @@ signed_core = { path = "../signed_core" } nostr.workspace = true gix.workspace = true anyhow.workspace = true + +[dev-dependencies] +tempfile = "3" diff --git a/crates/signed_git/src/lib.rs b/crates/signed_git/src/lib.rs index b208eb2..84b5300 100644 --- a/crates/signed_git/src/lib.rs +++ b/crates/signed_git/src/lib.rs @@ -140,6 +140,94 @@ fn sanitize_path_component(id: &str) -> String { sanitized } +/// Relative paths of all entries in the worktree (files and directories), +/// directories first, then alphabetically within each group. The `.git` +/// directory is skipped. +pub fn worktree_entries(repo: &gix::Repository) -> Result> { + let workdir = repo.workdir().context("repository has no worktree")?; + + let mut entries: Vec<(PathBuf, bool)> = Vec::new(); + collect_entries(workdir, workdir, &mut entries)?; + + entries.sort_by(|(a, a_is_dir), (b, b_is_dir)| { + b_is_dir + .cmp(a_is_dir) + .then_with(|| a.as_os_str().cmp(b.as_os_str())) + }); + Ok(entries.into_iter().map(|(path, _)| path).collect()) +} + +/// Read a file from the worktree. Returns `Ok(None)` if the path is missing +/// or not a regular file. +pub fn worktree_read(repo: &gix::Repository, rel: &Path) -> Result>> { + let workdir = repo.workdir().context("repository has no worktree")?; + let path = workdir.join(rel); + + match std::fs::read(&path) { + Ok(bytes) => Ok(Some(bytes)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) if e.kind() == std::io::ErrorKind::IsADirectory => Ok(None), + Err(e) => Err(e).with_context(|| format!("failed to read {}", path.display())), + } +} + +/// Find the README file in the repository root (returned as a path relative +/// to the worktree). Case-insensitive; prefers `README.md`, then `.markdown`, +/// `.mdown`, `.mkdn`, then any other file whose name starts with `readme`. +pub fn find_readme(repo: &gix::Repository) -> Result> { + let Some(workdir) = repo.workdir() else { + return Ok(None); + }; + + let mut candidates: Vec = Vec::new(); + for entry in std::fs::read_dir(workdir)? { + let entry = entry?; + let name = entry.file_name(); + let Some(name) = name.to_str() else { continue }; + if name.to_ascii_lowercase().starts_with("readme") { + candidates.push(entry.path()); + } + } + + candidates.sort_by_key(|path| { + let ext = path + .extension() + .map(|e| e.to_string_lossy().to_ascii_lowercase()); + match ext.as_deref() { + Some("md") => 0, + Some("markdown") => 1, + Some("mdown") => 2, + Some("mkdn") => 3, + Some(_) => 5, + None => 4, + } + }); + + Ok(candidates + .into_iter() + .next() + .and_then(|path| path.strip_prefix(workdir).ok().map(Path::to_path_buf))) +} + +fn collect_entries(root: &Path, dir: &Path, out: &mut Vec<(PathBuf, bool)>) -> Result<()> { + for entry in std::fs::read_dir(dir)? { + let entry = entry?; + if entry.file_name() == ".git" { + continue; + } + + let is_dir = entry.file_type()?.is_dir(); + let path = entry.path(); + let rel = path.strip_prefix(root)?.to_path_buf(); + out.push((rel, is_dir)); + + if is_dir { + collect_entries(root, &path, out)?; + } + } + Ok(()) +} + #[cfg(test)] mod tests { use nostr::prelude::*; @@ -181,4 +269,92 @@ mod tests { Some("_".into()) ); } + + /// Build a throwaway non-bare repository with the given files (rel → bytes). + fn fixture(files: &[(&str, &[u8])]) -> (tempfile::TempDir, gix::Repository) { + let dir = tempfile::tempdir().expect("tempdir"); + let repo = gix::init(&dir).expect("init"); + + for (rel, bytes) in files { + let path = dir.path().join(rel); + std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir"); + std::fs::write(&path, bytes).expect("write"); + } + + (dir, repo) + } + + #[test] + fn worktree_entries_lists_all_files_and_dirs() { + let (_dir, repo) = fixture(&[ + ("README.md", b"# Hi"), + ("src/main.rs", b"fn main() {}"), + ("src/lib.rs", b""), + ("docs/guide.md", b"guide"), + ]); + + let entries = worktree_entries(&repo).expect("entries"); + let entries: Vec = entries + .iter() + .map(|p| p.to_string_lossy().into_owned()) + .collect(); + + assert_eq!( + entries, + vec![ + "docs", + "src", + "README.md", + "docs/guide.md", + "src/lib.rs", + "src/main.rs" + ] + ); + } + + #[test] + fn worktree_read_returns_bytes_or_none() { + let (_dir, repo) = fixture(&[("a.txt", b"hello"), ("sub/b.bin", b"\x00\x01")]); + + assert_eq!( + worktree_read(&repo, Path::new("a.txt")).expect("read"), + Some(b"hello".to_vec()) + ); + assert_eq!( + worktree_read(&repo, Path::new("sub/b.bin")).expect("read"), + Some(vec![0x00, 0x01]) + ); + assert_eq!( + worktree_read(&repo, Path::new("missing.txt")).expect("read"), + None + ); + } + + #[test] + fn find_readme_prefers_markdown() { + let (_dir, repo) = fixture(&[("readme.txt", b"txt"), ("README.md", b"md")]); + + let readme = find_readme(&repo).expect("find"); + assert_eq!( + readme.map(|p| p.to_string_lossy().into_owned()), + Some("README.md".into()) + ); + } + + #[test] + fn find_readme_falls_back_to_any_readme() { + let (_dir, repo) = fixture(&[("README.rst", b"rst")]); + + let readme = find_readme(&repo).expect("find"); + assert_eq!( + readme.map(|p| p.to_string_lossy().into_owned()), + Some("README.rst".into()) + ); + } + + #[test] + fn find_readme_returns_none_without_one() { + let (_dir, repo) = fixture(&[("main.rs", b"")]); + assert!(find_readme(&repo).expect("find").is_none()); + } } diff --git a/crates/signed_state/Cargo.toml b/crates/signed_state/Cargo.toml index 21ddabf..5192a08 100644 --- a/crates/signed_state/Cargo.toml +++ b/crates/signed_state/Cargo.toml @@ -6,6 +6,7 @@ publish.workspace = true [dependencies] signed_core = { path = "../signed_core" } +signed_git = { path = "../signed_git" } signed_nostr = { path = "../signed_nostr" } utils = { path = "../utils" } diff --git a/crates/signed_state/src/git_store.rs b/crates/signed_state/src/git_store.rs new file mode 100644 index 0000000..ab7b5df --- /dev/null +++ b/crates/signed_state/src/git_store.rs @@ -0,0 +1,44 @@ +use std::path::PathBuf; + +use gpui::{App, Global}; +use signed_git::GitCache; + +struct GlobalGitStore(GitCache); + +impl Global for GlobalGitStore {} + +/// Global access to the on-disk git clone cache (grasp mirrors). +/// +/// Installed at startup via [`GitStore::set_global`]; see also +/// [`signed_state::init`]. +#[derive(Debug, Clone)] +pub struct GitStore(GitCache); + +impl GitStore { + /// Register the clone cache rooted at `root` as an app-wide global. + /// Replaces any previously installed store (see [`signed_state::init`], which + /// installs an empty one). + pub fn set_global(root: impl Into, cx: &mut App) -> Self { + let store = Self::new(root); + cx.set_global(GlobalGitStore(store.0.clone())); + store + } + + /// The app-wide clone cache. + /// + /// # Panics + /// + /// Panics if [`GitStore::set_global`] was never called. + pub fn global(cx: &App) -> Self { + Self(cx.global::().0.clone()) + } + + fn new(root: impl Into) -> Self { + Self(GitCache::new(root.into())) + } + + /// Underlying clone cache. + pub fn cache(&self) -> &GitCache { + &self.0 + } +} diff --git a/crates/signed_state/src/lib.rs b/crates/signed_state/src/lib.rs index 7536559..4d1ec70 100644 --- a/crates/signed_state/src/lib.rs +++ b/crates/signed_state/src/lib.rs @@ -1,18 +1,20 @@ mod backend; +mod git_store; mod profile; mod repo; mod repo_list; -use std::path::Path; +use std::path::{Path, PathBuf}; pub use backend::{Backend, BackendEvent}; +pub use git_store::GitStore; use gpui::{App, AppContext, Entity}; pub use nostr_sdk::prelude::Timestamp; pub use profile::{Profile, ProfileStore}; -pub use utils::shorten_pubkey; pub use repo::RepoStore; pub use repo_list::RepoListStore; use signed_nostr::new_backend; +pub use utils::shorten_pubkey; /// Initialize the backend and stores, and install them as globals. Call once /// at startup, before opening any window that uses the stores. @@ -35,6 +37,10 @@ pub fn init(db_path: impl AsRef, cx: &mut App) -> Entity { ProfileStore::set_global(cx.new(ProfileStore::new), cx); + // The clone cache is only meaningful on native platforms; the wasm + // build registers an empty store so `GitStore::global` still works. + GitStore::set_global(PathBuf::new(), cx); + entity } @@ -48,5 +54,7 @@ pub fn init(cx: &mut App) -> Entity { ProfileStore::set_global(cx.new(ProfileStore::new), cx); + GitStore::set_global(PathBuf::new(), cx); + entity } diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index a817502..e961ebb 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -7,8 +7,11 @@ publish.workspace = true [dependencies] assets = { path = "../assets" } signed_core = { path = "../signed_core" } +signed_git = { path = "../signed_git" } signed_state = { path = "../signed_state" } utils = { path = "../utils" } gpui.workspace = true gpui-component.workspace = true + +anyhow.workspace = true diff --git a/crates/workspace/src/views/mod.rs b/crates/workspace/src/views/mod.rs index 4ae1b91..9c95334 100644 --- a/crates/workspace/src/views/mod.rs +++ b/crates/workspace/src/views/mod.rs @@ -1,5 +1,7 @@ +mod repo_detail; mod repo_list; pub(crate) mod sidebar; +pub use repo_detail::RepoDetailView; pub use repo_list::RepoListView; pub use sidebar::SidebarPanel; diff --git a/crates/workspace/src/views/repo_detail.rs b/crates/workspace/src/views/repo_detail.rs new file mode 100644 index 0000000..e2e3217 --- /dev/null +++ b/crates/workspace/src/views/repo_detail.rs @@ -0,0 +1,776 @@ +use std::collections::{HashMap, HashSet}; +use std::path::{Component, Path, PathBuf}; + +use anyhow::Error; +use gpui::prelude::*; +use gpui::{ + AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, SharedString, + Task, WeakEntity, Window, div, px, +}; +use gpui_component::avatar::Avatar; +use gpui_component::dock::{Panel, PanelEvent}; +use gpui_component::list::ListItem; +use gpui_component::spinner::Spinner; +use gpui_component::text::{TextView, TextViewState}; +use gpui_component::tree::{TreeEntry, TreeItem, TreeState, tree}; +use gpui_component::{ActiveTheme, Icon, IconName, Sizable, StyledExt, h_flex, v_flex}; +use signed_core::Announcement; +use signed_state::{GitStore, ProfileStore, RepoStore}; +use utils::relative_time; + +/// Width of the file explorer column. +const TREE_WIDTH: f32 = 280.; +/// Files larger than this are not previewed. +const MAX_PREVIEW_BYTES: usize = 1024 * 1024; + +/// Preview state of a browsed file. +enum FileContent { + /// Decodable text content. + Text(String), + /// Not valid UTF-8. + Binary, + /// Bigger than [`MAX_PREVIEW_BYTES`]. + TooLarge, + /// Reading failed. + Failed(String), +} + +/// A markdown document loaded into a persistent [`TextViewState`]. +/// +/// The state is owned here rather than created per render (as the stateless +/// `text::markdown` helper does), so it survives branch switches in the +/// content pane. GPUI's keyed element state is dropped as soon as the +/// element is absent for a single frame, which would otherwise re-parse the +/// whole document on the main thread every time the pane switches between +/// the README, a file preview, and the loading spinner. +struct MarkdownView { + /// Source path; `None` means the repository README. + path: Option, + state: Entity, +} + +/// Detail view of a repository: header, stats, a file explorer with README +/// preview (cloned from the announcement's `clone` URLs), and metadata. +pub struct RepoDetailView { + /// Live per-repository store, refreshed from the local database. + store: Entity, + /// Snapshot taken at open time, shown until the store's first refresh completes. + initial: Announcement, + /// File explorer state (worktree of the local clone). + tree_state: Entity, + focus_handle: FocusHandle, + tasks: Vec>>, + /// A clone/fetch is in flight. + loading: bool, + error: Option, + /// Root of the local clone, for reading files on demand. + worktree: Option, + /// Markdown document currently in the preview pane (README or a file). + md: Option, + readme_name: Option, + /// Currently previewed file (relative path) and its contents. + selected_file: Option, + files: HashMap, + /// Reads in flight, to avoid duplicate loads. + loading_files: HashSet, +} + +impl RepoDetailView { + pub fn new(initial: Announcement, window: &mut Window, cx: &mut Context) -> Self { + let store = cx.new(|cx| RepoStore::new(initial.addr(), cx)); + let tree_state = cx.new(|cx| TreeState::new(cx)); + + // Defer loading the repository until the window is ready. + cx.defer_in(window, |this, _window, cx| { + this.load_repo(cx); + }); + + Self { + store, + initial, + tree_state, + focus_handle: cx.focus_handle(), + tasks: Vec::new(), + loading: true, + error: None, + worktree: None, + md: None, + readme_name: None, + selected_file: None, + files: HashMap::new(), + loading_files: HashSet::new(), + } + } + + /// Clone (or fetch) the repository and populate the file explorer. + fn load_repo(&mut self, cx: &mut Context) { + self.loading = true; + self.error = None; + cx.notify(); + + let cache = GitStore::global(cx).cache().clone(); + let addr = self.initial.addr(); + let clone_urls: Vec = self.initial.clone.iter().map(ToString::to_string).collect(); + + let load = cx.background_spawn(async move { + let repo = cache.ensure_clone(&addr, &clone_urls)?; + let entries = signed_git::worktree_entries(&repo)?; + let readme_path = signed_git::find_readme(&repo)?; + let readme = match &readme_path { + Some(path) => signed_git::worktree_read(&repo, path)?, + None => None, + }; + let worktree = repo.workdir().map(Path::to_path_buf); + + Ok::<_, Error>((entries, readme_path, readme, worktree)) + }); + + let task = cx.spawn(async move |this, cx| { + let result = load.await; + + this.update(cx, |this, cx| { + match result { + Ok((entries, readme_path, readme, Some(worktree))) => { + this.worktree = Some(worktree); + this.tree_state.update(cx, |state, cx| { + state.set_items(build_tree_items(&entries), cx); + }); + if let Some((path, bytes)) = readme_path.zip(readme) { + this.readme_name = Some(path.to_string_lossy().into()); + if let Ok(text) = String::from_utf8(bytes) { + this.set_markdown(None, &text, cx); + } + } + } + Ok((_, _, _, None)) => { + this.error = Some("Repository has no worktree".into()); + } + Err(error) => { + this.error = Some(error.to_string().into()); + } + } + this.loading = false; + cx.notify(); + })?; + + Ok(()) + }); + + self.tasks.push(task); + } + + /// Preview the file at `path` (relative to the worktree root). + fn open_file(&mut self, path: &str, _window: &mut Window, cx: &mut Context) { + self.selected_file = Some(path.into()); + + if self.files.contains_key(path) || self.loading_files.contains(path) { + cx.notify(); + return; + } + + // Paths come from our own tree walk, but never trust them: refuse + // anything that could escape the worktree. + let rel = Path::new(path); + let unsafe_path = rel.is_absolute() + || rel.components().any(|c| { + matches!( + c, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }); + let Some(worktree) = self.worktree.clone() else { + return; + }; + if unsafe_path { + return; + } + + self.loading_files.insert(path.to_string()); + let path = path.to_string(); + + let task = cx.spawn(async move |this, cx| { + let path_for_read = path.clone(); + let content = cx + .background_spawn(async move { + let full = worktree.join(&path_for_read); + let bytes = match std::fs::read(&full) { + Ok(bytes) => bytes, + Err(error) => return Err(anyhow::anyhow!("{}", error)), + }; + + let kind = if bytes.len() > MAX_PREVIEW_BYTES { + FileContent::TooLarge + } else { + match String::from_utf8(bytes) { + Ok(text) => FileContent::Text(text), + Err(_) => FileContent::Binary, + } + }; + Ok::<_, Error>(kind) + }) + .await; + + this.update(cx, |this, cx| { + this.loading_files.remove(&path); + match content { + Ok(kind) => { + if let FileContent::Text(text) = &kind + && is_markdown_path(&path) + { + let same = this.md.as_ref().map(|md| md.path.as_deref()) + == Some(Some(path.as_str())); + if !same { + this.set_markdown(Some(path.clone().into()), text, cx); + } + } + this.files.insert(path, kind); + } + Err(error) => { + this.files + .insert(path, FileContent::Failed(error.to_string())); + } + } + cx.notify(); + })?; + + Ok(()) + }); + + self.tasks.push(task); + } + + /// Load `text` into the persistent markdown TextView state. + /// + /// The state is created empty and fed via `push_str`, which parses on a + /// background task: switching files never blocks the main thread, and + /// the state lives as long as this view, so re-viewing the same document + /// does not re-parse it. + fn set_markdown(&mut self, path: Option, text: &str, cx: &mut Context) { + let state = cx.new(|cx| TextViewState::markdown("", cx)); + state.update(cx, |state, cx| state.push_str(text, cx)); + self.md = Some(MarkdownView { path, state }); + } + + /// The persistent markdown TextView for `path` (`None` = README), or a + /// spinner while the document is being loaded/parsed. + fn markdown_element(&mut self, path: Option<&str>, _cx: &mut Context) -> AnyElement { + let spinner = || { + v_flex() + .size_full() + .items_center() + .justify_center() + .child(Spinner::new().small()) + .into_any_element() + }; + + let Some(md) = &self.md else { + return spinner(); + }; + let ready = match path { + Some(path) => md.path.as_deref() == Some(path), + None => md.path.is_none(), + }; + if !ready { + return spinner(); + } + + TextView::new(&md.state).selectable(true).into_any_element() + } + + fn meta_row(label: SharedString, value: SharedString, cx: &App) -> impl IntoElement { + h_flex() + .gap_2() + .items_start() + .child( + div() + .w_24() + .flex_none() + .text_xs() + .font_semibold() + .text_color(cx.theme().muted_foreground) + .child(label), + ) + .child( + div() + .flex_1() + .text_sm() + .text_color(cx.theme().foreground) + .child(value), + ) + } + + fn stat(label: SharedString, count: usize, cx: &App) -> impl IntoElement { + v_flex() + .gap_1() + .child(div().text_lg().font_semibold().child(count.to_string())) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(label), + ) + } + + /// One row of the file tree: icon + name, indented by depth. + fn render_tree_item( + ix: usize, + entry: &TreeEntry, + selected: bool, + view: &WeakEntity, + ) -> ListItem { + let item = entry.item(); + let id = item.id.clone(); + let is_folder = entry.is_folder(); + + let icon = if is_folder { + if entry.is_expanded() { + IconName::FolderOpen + } else { + IconName::FolderClosed + } + } else { + IconName::File + }; + + let view = view.clone(); + + ListItem::new(ix) + .pl(px(8.) + px(14.) * entry.depth() as f32) + .selected(selected) + .child( + h_flex() + .gap_2() + .overflow_hidden() + .child(Icon::new(icon).small()) + .child(div().text_sm().text_ellipsis().child(item.label.clone())), + ) + .on_click(move |_event, window, cx| { + // Folders expand/collapse via the tree itself. + if is_folder { + return; + } + if let Some(view) = view.upgrade() { + view.update(cx, |this, cx| this.open_file(&id, window, cx)); + } + }) + } +} + +impl Panel for RepoDetailView { + fn panel_name(&self) -> &'static str { + "repo_detail" + } + + fn title(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let announcement = self + .store + .read(cx) + .announcement + .clone() + .unwrap_or_else(|| self.initial.clone()); + + announcement + .name + .clone() + .unwrap_or_else(|| SharedString::from(announcement.id.clone())) + } +} + +impl EventEmitter for RepoDetailView {} + +impl Focusable for RepoDetailView { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for RepoDetailView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let announcement = self + .store + .read(cx) + .announcement + .clone() + .unwrap_or_else(|| self.initial.clone()); + + let store = self.store.read(cx); + let owner = ProfileStore::global(cx).read(cx).get(&announcement.owner); + + let name = announcement + .name + .clone() + .unwrap_or_else(|| SharedString::from(announcement.id.clone())); + + let description = announcement + .description + .clone() + .unwrap_or(SharedString::from("No description")); + + let web = join_urls(&announcement.web); + let clone = join_urls(&announcement.clone); + let relays = announcement + .relays + .iter() + .map(ToString::to_string) + .collect::>() + .join(", "); + let hashtags = announcement.hashtags.join(", "); + + let pane_title = self + .selected_file + .clone() + .or_else(|| self.readme_name.clone()) + .unwrap_or_else(|| "Overview".into()); + + let tree_state = self.tree_state.clone(); + let view = cx.entity().downgrade(); + + v_flex() + .id("repo-detail") + .size_full() + .overflow_y_scroll() + .p_6() + .gap_4() + // Header + .child( + v_flex() + .gap_2() + .child( + h_flex() + .gap_2() + .items_center() + .child( + Avatar::new() + .name(owner.name()) + .when_some(owner.picture(), |this, url| this.src(url)) + .small(), + ) + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(owner.name()), + ), + ) + .child(div().text_2xl().font_semibold().child(name)) + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(description), + ), + ) + // Activity summary + .child( + h_flex() + .gap_6() + .child(Self::stat("Issues".into(), store.issues.len(), cx)) + .child(Self::stat("Patches".into(), store.patches.len(), cx)) + .child(Self::stat( + "Pull requests".into(), + store.pull_requests.len(), + cx, + )), + ) + // File explorer + README / file preview + .child( + h_flex() + .h(px(380.)) + .flex_none() + .w_full() + .rounded_md() + .border_1() + .border_color(cx.theme().border) + .overflow_hidden() + .child(self.render_tree_column(tree_state, view, cx)) + .child(self.render_content_column(pane_title, cx)), + ) + // Details + .child( + v_flex() + .gap_2() + .child(Self::meta_row( + "Created".into(), + relative_time(announcement.created_at).into(), + cx, + )) + .when_some(announcement.euc.clone(), |this, euc| { + this.child(Self::meta_row("EUC".into(), euc.into(), cx)) + }) + .when(!web.is_empty(), |this| { + this.child(Self::meta_row("Web".into(), web.into(), cx)) + }) + .when(!clone.is_empty(), |this| { + this.child(Self::meta_row("Clone".into(), clone.into(), cx)) + }) + .when(!relays.is_empty(), |this| { + this.child(Self::meta_row("Relays".into(), relays.into(), cx)) + }) + .when(!hashtags.is_empty(), |this| { + this.child(Self::meta_row("Tags".into(), hashtags.into(), cx)) + }), + ) + } +} + +impl RepoDetailView { + /// Left column: the file tree. + fn render_tree_column( + &mut self, + tree_state: Entity, + view: WeakEntity, + cx: &mut Context, + ) -> impl IntoElement { + v_flex() + .w(px(TREE_WIDTH)) + .flex_none() + .h_full() + .border_r(px(1.)) + .border_color(cx.theme().border) + .child( + h_flex() + .h_9() + .px_3() + .items_center() + .border_b(px(1.)) + .border_color(cx.theme().border) + .child(div().text_xs().font_semibold().child("Files")), + ) + .child(div().flex_1().min_h_0().child(tree( + &tree_state, + move |ix, entry, selected, _window, _cx| { + Self::render_tree_item(ix, entry, selected, &view) + }, + ))) + } + + /// Right column: README, selected file preview, or status text. + fn render_content_column( + &mut self, + pane_title: SharedString, + cx: &mut Context, + ) -> impl IntoElement { + let loading = self.loading; + let error = self.error.clone(); + let selected_file = self.selected_file.clone(); + + let body: AnyElement = if loading { + v_flex() + .size_full() + .items_center() + .justify_center() + .gap_2() + .child(Spinner::new().small()) + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child("Cloning repository…"), + ) + .into_any_element() + } else if let Some(error) = error { + v_flex() + .size_full() + .items_center() + .justify_center() + .p_4() + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(error), + ) + .into_any_element() + } else if let Some(path) = selected_file { + match self.files.get(path.as_ref()) { + Some(FileContent::Text(text)) => { + if is_markdown_path(path.as_ref()) { + self.markdown_element(Some(path.as_ref()), cx) + } else { + v_flex() + .size_full() + .children(plain_lines(text, cx)) + .into_any_element() + } + } + Some(FileContent::Binary) => placeholder("Binary file — preview not supported", cx), + Some(FileContent::TooLarge) => placeholder("File is too large to preview", cx), + Some(FileContent::Failed(message)) => placeholder(message, cx), + None => v_flex() + .size_full() + .items_center() + .justify_center() + .child(Spinner::new().small()) + .into_any_element(), + } + } else if self.readme_name.is_some() { + self.markdown_element(None, cx) + } else { + placeholder("No README found", cx) + }; + + v_flex() + .flex_1() + .min_w_0() + .h_full() + .child( + h_flex() + .h_9() + .px_3() + .items_center() + .border_b(px(1.)) + .border_color(cx.theme().border) + .child( + div() + .text_xs() + .font_semibold() + .text_ellipsis() + .whitespace_nowrap() + .child(pane_title), + ), + ) + .child( + div() + .id("repo-content-scroll") + .flex_1() + .min_h_0() + .overflow_y_scroll() + .p_4() + .child(body), + ) + } +} + +/// Build nested tree items from a flat, sorted (dirs-first) entry list. +fn build_tree_items(entries: &[PathBuf]) -> Vec { + let mut roots: Vec = Vec::new(); + + for entry in entries { + let parts: Vec = entry + .components() + .map(|c| c.as_os_str().to_string_lossy().into_owned()) + .collect(); + insert_path(&mut roots, &parts, ""); + } + + roots +} + +/// Insert `parts` (path components) into the tree rooted at `items`. +/// `prefix` is the path of `items`' parent, used to build item ids. +fn insert_path(items: &mut Vec, parts: &[String], prefix: &str) { + let Some((head, rest)) = parts.split_first() else { + return; + }; + + let id = if prefix.is_empty() { + head.clone() + } else { + format!("{prefix}/{head}") + }; + + if let Some(existing) = items.iter_mut().find(|item| &*item.label == head.as_str()) { + insert_path(&mut existing.children, rest, &id); + } else { + let mut item = TreeItem::new(id.clone(), head.clone()); + insert_path(&mut item.children, rest, &id); + items.push(item); + } +} + +/// Render text with one element per line, preserving blank lines. +fn plain_lines(text: &str, cx: &App) -> Vec { + text.lines() + .map(|line| { + // A space keeps empty lines from collapsing to zero height. + let text = if line.is_empty() { " " } else { line }; + div() + .font_family(cx.theme().mono_font_family.clone()) + .text_xs() + .text_color(cx.theme().foreground) + .child(text.to_string()) + .into_any_element() + }) + .collect() +} + +/// Whether a file path has a markdown extension. +fn is_markdown_path(path: &str) -> bool { + Path::new(path) + .extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| { + matches!( + ext.to_ascii_lowercase().as_str(), + "md" | "markdown" | "mdown" | "mkdn" + ) + }) +} + +fn placeholder(message: &str, cx: &App) -> AnyElement { + v_flex() + .size_full() + .items_center() + .justify_center() + .p_4() + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(message.to_string()), + ) + .into_any_element() +} + +fn join_urls(urls: &[T]) -> String { + urls.iter() + .map(ToString::to_string) + .collect::>() + .join(", ") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn builds_nested_tree_from_flat_entries() { + let entries = vec![ + PathBuf::from("src"), + PathBuf::from("src/lib.rs"), + PathBuf::from("README.md"), + PathBuf::from("docs/guide.md"), + ]; + + let items = build_tree_items(&entries); + + // Input order is preserved (dirs-first, as produced by worktree_entries). + assert_eq!(items.len(), 3); + assert_eq!(items[0].label, "src"); + assert_eq!(items[0].id, "src"); + assert_eq!(items[0].children.len(), 1); + assert_eq!(items[0].children[0].label, "lib.rs"); + assert_eq!(items[0].children[0].id, "src/lib.rs"); + + assert_eq!(items[1].label, "README.md"); + assert_eq!(items[1].id, "README.md"); + + assert_eq!(items[2].label, "docs"); + assert_eq!(items[2].children[0].label, "guide.md"); + assert_eq!(items[2].children[0].id, "docs/guide.md"); + } + + #[test] + fn tree_builder_handles_deep_nesting() { + let entries = vec![ + PathBuf::from("a"), + PathBuf::from("a/b"), + PathBuf::from("a/b/c.txt"), + ]; + + let items = build_tree_items(&entries); + assert_eq!(items.len(), 1); + assert_eq!(items[0].children[0].id, "a/b"); + assert_eq!(items[0].children[0].children[0].id, "a/b/c.txt"); + } +} diff --git a/crates/workspace/src/views/repo_list.rs b/crates/workspace/src/views/repo_list.rs index f6432fd..32acd2d 100644 --- a/crates/workspace/src/views/repo_list.rs +++ b/crates/workspace/src/views/repo_list.rs @@ -1,12 +1,13 @@ use std::rc::Rc; +use std::sync::Arc; use gpui::prelude::*; use gpui::{ - AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render, - SharedString, Size, Subscription, Window, div, px, size, + AnyElement, App, Context, ElementId, Entity, EventEmitter, FocusHandle, Focusable, Pixels, + Render, SharedString, Size, Subscription, WeakEntity, Window, div, px, size, }; use gpui_component::avatar::Avatar; -use gpui_component::dock::{Panel, PanelEvent}; +use gpui_component::dock::{DockArea, DockPlacement, Panel, PanelEvent}; use gpui_component::scroll::Scrollbar; use gpui_component::{ ActiveTheme, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list, @@ -15,11 +16,14 @@ use signed_core::Announcement; use signed_state::{ProfileStore, RepoListStore, Timestamp}; use utils::relative_time; +use super::RepoDetailView; + const CARD_HEIGHT: f32 = 160.; /// Browse all announced repositories (works anonymously). pub struct RepoListView { store: Entity, + dock_area: WeakEntity, focus_handle: FocusHandle, scroll_handle: VirtualListScrollHandle, item_sizes: Rc>>, @@ -27,7 +31,11 @@ pub struct RepoListView { } impl RepoListView { - pub fn new(_window: &mut Window, cx: &mut Context) -> Self { + pub fn new( + dock_area: WeakEntity, + _window: &mut Window, + cx: &mut Context, + ) -> Self { let store = cx.new(|cx| RepoListStore::new(None, cx)); let subscription = cx.observe(&store, |this, store, cx| { @@ -40,6 +48,7 @@ impl RepoListView { Self { store, + dock_area, focus_handle: cx.focus_handle(), scroll_handle: VirtualListScrollHandle::new(), item_sizes: Rc::new(vec![]), @@ -70,11 +79,34 @@ impl RepoListView { .map(|label| SharedString::from(format!("Updated {label}"))) .unwrap_or_default(); + // Open the repository in a new center tab when the card is clicked. + let dock_area = self.dock_area.clone(); + let announcement = announcement.clone(); + v_flex() + .id(ElementId::from(format!( + "repo-card-{}", + announcement.addr() + ))) .px_4() .w_full() .border_b(px(1.)) .border_color(cx.theme().border) + .cursor_pointer() + .on_click(move |_event, window, cx| { + let detail = cx.new(|cx| RepoDetailView::new(announcement.clone(), window, cx)); + if let Some(dock_area) = dock_area.upgrade() { + dock_area.update(cx, |dock_area, cx| { + dock_area.add_panel( + Arc::new(detail), + DockPlacement::Center, + None, + window, + cx, + ); + }); + } + }) .child( h_flex() .h_12() diff --git a/crates/workspace/src/views/sidebar/mod.rs b/crates/workspace/src/views/sidebar/mod.rs index f104ba5..06488f2 100644 --- a/crates/workspace/src/views/sidebar/mod.rs +++ b/crates/workspace/src/views/sidebar/mod.rs @@ -70,7 +70,7 @@ impl SidebarPanel { return; } - let panel = cx.new(|cx| RepoListView::new(window, cx)); + let panel = cx.new(|cx| RepoListView::new(self.dock_area.clone(), window, cx)); self.explore = Some(panel.downgrade()); let _ = self.dock_area.update(cx, |dock_area, cx| { diff --git a/desktop/src/main.rs b/desktop/src/main.rs index 52e6a01..e52c9df 100644 --- a/desktop/src/main.rs +++ b/desktop/src/main.rs @@ -26,6 +26,10 @@ fn main() { std::fs::create_dir_all(paths::nostr_dir()).ok(); signed_state::init(paths::nostr_dir(), cx); + // Local git clone cache for browsing repository contents. + std::fs::create_dir_all(paths::repos_dir()).ok(); + signed_state::GitStore::set_global(paths::repos_dir().clone(), cx); + // Set up the window bounds let bounds = Bounds::centered(None, size(px(980.0), px(740.0)), cx);