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
-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();