diff --git a/crates/assets/assets/icons/global-off.svg b/crates/assets/assets/icons/global-off.svg deleted file mode 100644 index bd90538..0000000 --- a/crates/assets/assets/icons/global-off.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/crates/assets/assets/icons/unlock.svg b/crates/assets/assets/icons/unlock.svg new file mode 100644 index 0000000..f5434e9 --- /dev/null +++ b/crates/assets/assets/icons/unlock.svg @@ -0,0 +1 @@ + diff --git a/crates/assets/src/lib.rs b/crates/assets/src/lib.rs index 66ea625..c4ad70c 100644 --- a/crates/assets/src/lib.rs +++ b/crates/assets/src/lib.rs @@ -48,6 +48,7 @@ impl Assets { } pub enum CustomIconName { + Unlock, Filter, GlobalOn, GlobalOff, @@ -56,6 +57,7 @@ pub enum CustomIconName { impl IconNamed for CustomIconName { fn path(self) -> gpui::SharedString { match self { + CustomIconName::Unlock => "icons/unlock.svg", CustomIconName::Filter => "icons/filter.svg", CustomIconName::GlobalOn => "icons/global-on.svg", CustomIconName::GlobalOff => "icons/global-off.svg", diff --git a/crates/workspace/src/views/repo_detail.rs b/crates/workspace/src/views/repo_detail.rs deleted file mode 100644 index e2e3217..0000000 --- a/crates/workspace/src/views/repo_detail.rs +++ /dev/null @@ -1,776 +0,0 @@ -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_detail/browser.rs b/crates/workspace/src/views/repo_detail/browser.rs new file mode 100644 index 0000000..3841e8a --- /dev/null +++ b/crates/workspace/src/views/repo_detail/browser.rs @@ -0,0 +1,253 @@ +//! File explorer of the repository detail view: the file tree column and the +//! content column (README / file preview), backed by a persistent +//! [`TextViewState`] for markdown documents. + +use gpui::prelude::*; +use gpui::{AnyElement, Context, Entity, SharedString, WeakEntity, div, px}; +use gpui_component::list::ListItem; +use gpui_component::spinner::Spinner; +use gpui_component::text::{TextView, TextViewState}; +use gpui_component::tree::{TreeEntry, TreeState, tree}; +use gpui_component::{ActiveTheme, Icon, IconName, Sizable, StyledExt, h_flex, v_flex}; + +use super::RepoDetailView; +use super::helpers::{is_markdown_path, placeholder, plain_lines}; + +/// Width of the file explorer column. +const TREE_WIDTH: f32 = 240.; +/// Files larger than this are not previewed. +pub(super) const MAX_PREVIEW_BYTES: usize = 1024 * 1024; + +/// Preview state of a browsed file. +pub(super) 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 by the view 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. +pub(super) struct MarkdownView { + /// Source path; `None` means the repository README. + pub(super) path: Option, + pub(super) state: Entity, +} + +impl RepoDetailView { + /// 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)); + } + }) + } + + /// Left column: the file tree. + pub(super) fn render_tree_column( + &mut self, + tree_state: Entity, + view: WeakEntity, + cx: &mut Context, + ) -> impl IntoElement { + v_flex() + .h_full() + .w(px(TREE_WIDTH)) + .p_2() + .flex_none() + .border_r_1() + .border_color(cx.theme().border) + .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. + pub(super) 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() + .px_3() + .h_9() + .bg(cx.theme().muted) + .border_b(px(1.)) + .border_color(cx.theme().border) + .text_color(cx.theme().muted_foreground) + .child( + div() + .text_xs() + .font_semibold() + .text_ellipsis() + .whitespace_nowrap() + .child(pane_title), + ), + ) + .child( + div() + .id("repo-content-scroll") + .flex_1() + .min_h_0() + .p_4() + .overflow_y_scroll() + .child(body), + ) + } + + /// 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. + pub(super) 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() + } +} diff --git a/crates/workspace/src/views/repo_detail/helpers.rs b/crates/workspace/src/views/repo_detail/helpers.rs new file mode 100644 index 0000000..0972756 --- /dev/null +++ b/crates/workspace/src/views/repo_detail/helpers.rs @@ -0,0 +1,137 @@ +//! Pure helpers for the repository detail view: file-tree building, plain +//! text rendering and small element builders. + +use std::path::{Path, PathBuf}; + +use gpui::prelude::*; +use gpui::{AnyElement, App, div}; +use gpui_component::tree::TreeItem; +use gpui_component::{ActiveTheme, v_flex}; + +/// Build nested tree items from a flat, sorted (dirs-first) entry list. +pub(super) 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. +pub(super) 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. +pub(super) 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" + ) + }) +} + +/// A centered muted placeholder message. +pub(super) 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() +} + +#[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_detail/mod.rs b/crates/workspace/src/views/repo_detail/mod.rs new file mode 100644 index 0000000..da88e00 --- /dev/null +++ b/crates/workspace/src/views/repo_detail/mod.rs @@ -0,0 +1,355 @@ +use std::collections::{HashMap, HashSet}; +use std::path::{Component, Path, PathBuf}; + +use anyhow::Error; +use gpui::prelude::*; +use gpui::{ + App, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, + SharedString, Task, Window, div, +}; +use gpui_component::button::{Button, ButtonVariants, DropdownButton}; +use gpui_component::dock::{Panel, PanelEvent}; +use gpui_component::menu::PopupMenuItem; +use gpui_component::tree::TreeState; +use gpui_component::{ActiveTheme, IconName, StyledExt, h_flex, v_flex}; +use signed_core::Announcement; +use signed_state::{GitStore, RepoStore}; + +mod browser; +mod helpers; + +use browser::{FileContent, MAX_PREVIEW_BYTES, MarkdownView}; +use helpers::{build_tree_items, is_markdown_path}; + +/// 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, + /// 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, + /// A clone/fetch is in flight. + loading: bool, + error: Option, + focus_handle: FocusHandle, + tasks: Vec>>, +} + +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, + worktree: None, + md: None, + readme_name: None, + selected_file: None, + files: HashMap::new(), + loading_files: HashSet::new(), + loading: true, + error: None, + focus_handle: cx.focus_handle(), + tasks: Vec::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); + } +} + +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 tree_state = self.tree_state.clone(); + let view = cx.entity().downgrade(); + + let announcement = self + .store + .read(cx) + .announcement + .clone() + .unwrap_or_else(|| self.initial.clone()); + + 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 pane_title = self + .selected_file + .clone() + .or_else(|| self.readme_name.clone()) + .unwrap_or_else(|| "Overview".into()); + + let relays = announcement.relays.clone(); + + v_flex() + .id("repo") + .size_full() + .child( + h_flex() + .px_4() + .pt_2() + .pb_4() + .w_full() + .justify_between() + .border_b_1() + .border_color(cx.theme().border) + .child( + v_flex() + .flex_none() + .child(div().font_semibold().child(name)) + .child( + div() + .w_full() + .text_sm() + .text_color(cx.theme().muted_foreground) + .line_clamp(3) + .child(description), + ), + ) + .child( + h_flex() + .flex_1() + .gap_2() + .justify_end() + .child( + DropdownButton::new("relays") + .button( + Button::new("relay-trigger") + .label(format!("{} relays", relays.len())) + .ghost(), + ) + .dropdown_menu(move |menu, _window, _cx| { + let mut menu = menu; + if relays.is_empty() { + return menu.item( + PopupMenuItem::new("No relays").disabled(true), + ); + } + for relay in relays.iter() { + let url = relay.to_string(); + menu = menu.item( + PopupMenuItem::new(url.clone()).on_click( + move |_, _, cx| { + cx.write_to_clipboard( + ClipboardItem::new_string(url.clone()), + ); + }, + ), + ); + } + menu + }), + ) + .child( + Button::new("link") + .icon(IconName::ExternalLink) + .tooltip("Open in gitworkshop.dev") + .secondary(), + ) + .child( + Button::new("clone") + .icon(IconName::ArrowDown) + .tooltip("Clone") + .primary(), + ), + ), + ) + .child( + h_flex() + .flex_1() + .w_full() + .overflow_hidden() + .child(self.render_tree_column(tree_state, view, cx)) + .child(self.render_content_column(pane_title, cx)), + ) + } +} diff --git a/crates/workspace/src/views/repo_list.rs b/crates/workspace/src/views/repo_list.rs index 32acd2d..3f3ae21 100644 --- a/crates/workspace/src/views/repo_list.rs +++ b/crates/workspace/src/views/repo_list.rs @@ -3,8 +3,8 @@ use std::sync::Arc; use gpui::prelude::*; use gpui::{ - AnyElement, App, Context, ElementId, Entity, EventEmitter, FocusHandle, Focusable, Pixels, - Render, SharedString, Size, Subscription, WeakEntity, Window, div, px, size, + AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render, + SharedString, Size, Subscription, WeakEntity, Window, div, px, size, }; use gpui_component::avatar::Avatar; use gpui_component::dock::{DockArea, DockPlacement, Panel, PanelEvent}; @@ -56,13 +56,31 @@ impl RepoListView { } } + fn open_repo( + &mut self, + announcement: &Announcement, + window: &mut Window, + cx: &mut Context, + ) { + let dock_area = self.dock_area.clone(); + 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); + }); + } + } + fn render_card( &self, + ix: usize, announcement: &Announcement, last_activity: Option, - cx: &mut App, + cx: &mut Context, ) -> AnyElement { - let owner = ProfileStore::global(cx).read(cx).get(&announcement.owner); + let profile_store = ProfileStore::global(cx); + let owner = profile_store.read(cx).get(&announcement.owner); let name = announcement .name @@ -79,34 +97,13 @@ 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() - ))) + .id(ix) .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, - ); - }); - } - }) + .hover(|this| this.bg(cx.theme().list_hover)) .child( h_flex() .h_12() @@ -158,6 +155,12 @@ impl RepoListView { .child(activity), ), ) + .on_click(cx.listener({ + let announcement = announcement.clone(); + move |this, _ev, window, cx| { + this.open_repo(&announcement, window, cx); + } + })) .into_any_element() } } @@ -214,23 +217,21 @@ impl Render for RepoListView { ) }) .when(has_announcements, |this| { + let view = cx.entity().clone(); + let sizes = self.item_sizes.clone(); + this.child( - v_virtual_list( - cx.entity().clone(), - "repos", - self.item_sizes.clone(), - move |this, range, _window, cx| { - let mut items = vec![]; + v_virtual_list(view, "repos", sizes, move |this, range, _window, cx| { + let mut items = vec![]; - for ix in range { - let announcement: &Announcement = &announcements[ix]; - let activity = last_activity.get(&announcement.addr()).copied(); - items.push(this.render_card(announcement, activity, cx)); - } + for ix in range { + let announcement: &Announcement = &announcements[ix]; + let activity = last_activity.get(&announcement.addr()).copied(); + items.push(this.render_card(ix, announcement, activity, cx)); + } - items - }, - ) + items + }) .track_scroll(&self.scroll_handle) .size_full(), ) diff --git a/crates/workspace/src/views/sidebar/passphrase_dialog.rs b/crates/workspace/src/views/sidebar/passphrase_dialog.rs index e0670b3..1ad2f73 100644 --- a/crates/workspace/src/views/sidebar/passphrase_dialog.rs +++ b/crates/workspace/src/views/sidebar/passphrase_dialog.rs @@ -1,3 +1,4 @@ +use assets::CustomIconName; use gpui::prelude::*; use gpui::{AnyWindowHandle, App, Entity, SharedString, Subscription, Window, div, px}; use gpui_component::button::{Button, ButtonVariants}; @@ -78,6 +79,7 @@ pub fn open(window: &mut Window, cx: &mut App) { DialogFooter::new().justify_end().child( Button::new("unlock") .primary() + .icon(CustomIconName::Unlock) .label("Unlock") .loading(busy) .disabled(busy) diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 1a109c6..66bfe71 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -4,7 +4,7 @@ use assets::CustomIconName; use gpui::prelude::*; use gpui::{Context, Entity, Render, SharedString, Subscription, Window, div, px}; use gpui_component::button::{Button, ButtonVariants}; -use gpui_component::dock::{DockArea, DockItem}; +use gpui_component::dock::{DockArea, DockItem, PanelStyle}; use gpui_component::{ActiveTheme, Root, Sizable, StyledExt, Theme, TitleBar, h_flex, v_flex}; use signed_state::{Backend, BackendEvent}; @@ -21,9 +21,8 @@ pub struct Workspace { impl Workspace { pub fn new(window: &mut Window, cx: &mut Context) -> Self { - let backend = Backend::global(cx); - let dock = cx.new(|cx| DockArea::new("dock", Some(1), window, cx)); - + let dock = + cx.new(|cx| DockArea::new("dock", Some(1), window, cx).panel_style(PanelStyle::TabBar)); let weak_dock = dock.downgrade(); let sidebar = cx.new(|cx| SidebarPanel::new(weak_dock.clone(), cx)); @@ -39,6 +38,7 @@ impl Workspace { sidebar.update(cx, |sidebar, cx| sidebar.open_explore(window, cx)); + let backend = Backend::global(cx); let connected = backend.read(cx).is_connected(); let sync_progress = backend.read(cx).sync_progress(); diff --git a/desktop/src/main.rs b/desktop/src/main.rs index e52c9df..c289c7d 100644 --- a/desktop/src/main.rs +++ b/desktop/src/main.rs @@ -31,13 +31,14 @@ fn main() { 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); + let bounds = Bounds::centered(None, size(px(1120.0), px(720.0)), cx); // Set up the window options let opts = WindowOptions { window_background: WindowBackgroundAppearance::Opaque, window_decorations: Some(WindowDecorations::Client), window_bounds: Some(WindowBounds::Windowed(bounds)), + window_min_size: Some(size(px(960.0), px(640.0))), kind: WindowKind::Normal, app_id: Some("Signed".to_owned()), titlebar: Some(TitlebarOptions {