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 {