use std::collections::{HashMap, HashSet}; use std::path::{Component, Path, PathBuf}; use anyhow::Error; use assets::CustomIconName; 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_git::FileCommit; use signed_state::{GitStore, RepoStore}; mod browser; mod helpers; use browser::{CodeView, 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, /// Code file currently in the preview pane. code: 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, /// Latest commit touching a previewed file (or the README), keyed by path. commits: HashMap, /// Commit queries in flight, to avoid duplicate loads. loading_commits: 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, code: None, readme_name: None, selected_file: None, files: HashMap::new(), loading_files: HashSet::new(), commits: HashMap::new(), loading_commits: 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()); this.load_commit(&path.to_string_lossy(), cx); 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(); self.load_commit(&path, cx); 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 { if 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); } } else { let same = this.code.as_ref().map(|code| code.path.as_str()) == Some(path.as_str()); if !same { this.set_code(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); } /// Query the latest commit touching `path` on a background task and cache /// it in [`Self::commits`], for the file header in the content column. fn load_commit(&mut self, path: &str, cx: &mut Context) { if self.commits.contains_key(path) || self.loading_commits.contains(path) { return; } let Some(worktree) = self.worktree.clone() else { return; }; self.loading_commits.insert(path.to_string()); let path = path.to_string(); let task = cx.spawn(async move |this, cx| { let path_for_query = path.clone(); let result = cx .background_spawn(async move { signed_git::worktree_last_commit(&worktree, Path::new(&path_for_query)) }) .await; this.update(cx, |this, cx| { this.loading_commits.remove(&path); if let Ok(Some(commit)) = result { this.commits.insert(path, commit); } 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(); let web = announcement.web.clone(); v_flex() .id("repo") .size_full() .child( h_flex() .px_4() .pt_2() .pb_4() .w_full() .gap_2() .items_start() .justify_between() .border_b_1() .border_color(cx.theme().border) .child( v_flex() .flex_1() .min_w_0() .child(div().font_semibold().child(name)) .child( div() .text_xs() .text_color(cx.theme().muted_foreground) .line_clamp(3) .child(description), ), ) .child( h_flex() .flex_none() .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( DropdownButton::new("web") .button(Button::new("web-trigger").label("Websites").ghost()) .dropdown_menu(move |menu, _window, _cx| { let mut menu = menu; if web.is_empty() { return menu .item(PopupMenuItem::new("No web").disabled(true)); } for url in web.iter() { let href = url.to_string(); menu = menu.item( PopupMenuItem::new(href.clone()).on_click( move |_, _, cx| { cx.open_url(&href); }, ), ); } menu }), ) .child( Button::new("link") .icon(IconName::ExternalLink) .tooltip("Open in gitworkshop.dev") .secondary(), ) .child( Button::new("clone") .icon(CustomIconName::GitClone) .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)), ) } }