This commit is contained in:
2026-08-10 13:51:46 +07:00
parent 831a89dd11
commit 1daa10e57c
11 changed files with 798 additions and 823 deletions
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M22.0361 16.3789L19.9141 18.5L22.0361 20.6221L20.6221 22.0361L18.5 19.9141L16.3789 22.0361L14.9648 20.6221L17.0859 18.5L14.9648 16.3789L16.3789 14.9648L18.5 17.0859L20.6221 14.9648L22.0361 16.3789ZM12.4785 2.01172C17.7796 2.26189 22 6.63756 22 12V13H10.0332C10.1896 15.5386 10.9356 17.9153 12.1387 19.9961C12.2207 19.9947 12.3024 19.995 12.3838 19.9912C12.5538 19.9832 12.7227 19.9696 12.8896 19.9512L13.1104 21.9395C12.9017 21.9625 12.6907 21.9783 12.4785 21.9883C12.3199 21.9958 12.1603 22 12 22C11.8397 22 11.6801 21.9958 11.5215 21.9883C6.22043 21.7381 2 17.3624 2 12C2 6.63756 6.22043 2.26189 11.5215 2.01172C11.6801 2.00424 11.8397 2 12 2C12.1603 2 12.3199 2.00424 12.4785 2.01172ZM4.06445 13C4.46083 16.1773 6.71821 18.7761 9.71289 19.668C8.74978 17.6256 8.16038 15.3738 8.03027 13H4.06445ZM9.71289 4.33105C6.71807 5.22285 4.46084 7.82258 4.06445 11H8.03027C8.1604 8.62585 8.74956 6.3736 9.71289 4.33105ZM12 4.25195C10.8789 6.27297 10.1835 8.56136 10.0332 11H13.9668C13.8165 8.56136 13.1211 6.27297 12 4.25195ZM14.2861 4.33105C15.2496 6.3737 15.8396 8.6257 15.9697 11H19.9355C19.5391 7.82226 17.2814 5.2226 14.2861 4.33105Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M7 10H20C20.5523 10 21 10.4477 21 11V21C21 21.5523 20.5523 22 20 22H4C3.44772 22 3 21.5523 3 21V11C3 10.4477 3.44772 10 4 10H5V9C5 5.13401 8.13401 2 12 2C14.7405 2 17.1131 3.5748 18.2624 5.86882L16.4731 6.76344C15.6522 5.12486 13.9575 4 12 4C9.23858 4 7 6.23858 7 9V10ZM5 12V20H19V12H5ZM10 15H14V17H10V15Z"></path></svg>

After

Width:  |  Height:  |  Size: 410 B

+2
View File
@@ -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",
-776
View File
@@ -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<SharedString>,
state: Entity<TextViewState>,
}
/// 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<RepoStore>,
/// 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<TreeState>,
focus_handle: FocusHandle,
tasks: Vec<Task<Result<(), Error>>>,
/// A clone/fetch is in flight.
loading: bool,
error: Option<SharedString>,
/// Root of the local clone, for reading files on demand.
worktree: Option<PathBuf>,
/// Markdown document currently in the preview pane (README or a file).
md: Option<MarkdownView>,
readme_name: Option<SharedString>,
/// Currently previewed file (relative path) and its contents.
selected_file: Option<SharedString>,
files: HashMap<String, FileContent>,
/// Reads in flight, to avoid duplicate loads.
loading_files: HashSet<String>,
}
impl RepoDetailView {
pub fn new(initial: Announcement, window: &mut Window, cx: &mut Context<Self>) -> 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>) {
self.loading = true;
self.error = None;
cx.notify();
let cache = GitStore::global(cx).cache().clone();
let addr = self.initial.addr();
let clone_urls: Vec<String> = 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>) {
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<SharedString>, text: &str, cx: &mut Context<Self>) {
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<Self>) -> 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<Self>,
) -> 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<Self>) -> 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<PanelEvent> 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<Self>) -> 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::<Vec<_>>()
.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<TreeState>,
view: WeakEntity<Self>,
cx: &mut Context<Self>,
) -> 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<Self>,
) -> 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<TreeItem> {
let mut roots: Vec<TreeItem> = Vec::new();
for entry in entries {
let parts: Vec<String> = 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<TreeItem>, 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<AnyElement> {
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<T: ToString>(urls: &[T]) -> String {
urls.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.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");
}
}
@@ -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<SharedString>,
pub(super) state: Entity<TextViewState>,
}
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<Self>,
) -> 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<TreeState>,
view: WeakEntity<Self>,
cx: &mut Context<Self>,
) -> 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<Self>,
) -> 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<SharedString>,
text: &str,
cx: &mut Context<Self>,
) {
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<Self>) -> 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()
}
}
@@ -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<TreeItem> {
let mut roots: Vec<TreeItem> = Vec::new();
for entry in entries {
let parts: Vec<String> = 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<TreeItem>, 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<AnyElement> {
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");
}
}
@@ -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<RepoStore>,
/// 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<TreeState>,
/// Root of the local clone, for reading files on demand.
worktree: Option<PathBuf>,
/// Markdown document currently in the preview pane (README or a file).
md: Option<MarkdownView>,
readme_name: Option<SharedString>,
/// Currently previewed file (relative path) and its contents.
selected_file: Option<SharedString>,
files: HashMap<String, FileContent>,
/// Reads in flight, to avoid duplicate loads.
loading_files: HashSet<String>,
/// A clone/fetch is in flight.
loading: bool,
error: Option<SharedString>,
focus_handle: FocusHandle,
tasks: Vec<Task<Result<(), Error>>>,
}
impl RepoDetailView {
pub fn new(initial: Announcement, window: &mut Window, cx: &mut Context<Self>) -> 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>) {
self.loading = true;
self.error = None;
cx.notify();
let cache = GitStore::global(cx).cache().clone();
let addr = self.initial.addr();
let clone_urls: Vec<String> = 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>) {
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<Self>) -> 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<PanelEvent> 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<Self>) -> 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)),
)
}
}
+42 -41
View File
@@ -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<Self>,
) {
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<Timestamp>,
cx: &mut App,
cx: &mut Context<Self>,
) -> 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(),
)
@@ -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)
+4 -4
View File
@@ -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>) -> 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();