use std::collections::{HashMap, HashSet, VecDeque}; use std::path::{Component, Path, PathBuf}; use std::rc::Rc; use std::sync::Arc; use anyhow::Error; use assets::CustomIconName; use dock::{DockArea, DockPlacement, Panel, PanelEvent}; use gix::Repository; use gpui::prelude::*; use gpui::{ AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render, SharedString, Size, Subscription, Task, WeakEntity, Window, div, px, size, }; use gpui_component::avatar::{Avatar, AvatarGroup}; use gpui_component::button::{Button, ButtonVariants}; use gpui_component::combobox::{ Caret, Combobox, ComboboxEvent, ComboboxState, ComboboxTriggerContext, }; use gpui_component::searchable_list::SearchableVec; use gpui_component::tab::{Tab, TabBar}; use gpui_component::tag::Tag; use gpui_component::tree::TreeState; use gpui_component::{ ActiveTheme, Icon, IconName, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex, }; use signed_core::Announcement; use signed_git::{CommitList, FileCommit}; use signed_state::{GitStore, ProfileStore, RepoStore}; mod browser; mod commits; mod diff; mod helpers; mod issues; mod pull_requests; use browser::{ CodeView, FileContent, MAX_PREVIEW_BYTES, MAX_PREVIEW_CACHE_BYTES, MAX_PREVIEWED_FILES, MarkdownView, }; use commits::COMMIT_ROW_HEIGHT; use diff::CommitDiffView; use helpers::{TreeItemSeed, build_tree_items, is_markdown_path, track, tree_items}; use issues::IssuesView; use pull_requests::PullRequestsView; /// What kind of ref the header selectors switch to. #[derive(Clone, Copy, PartialEq, Eq)] enum RefKind { /// A local branch (`refs/heads/*`); HEAD stays attached. Branch, /// A tag (`refs/tags/*`); HEAD becomes detached. Tag, } /// Everything loaded from the local clone for the explorer: the tree seeds, /// README, refs and HEAD commit. Computed on a background thread (see /// [`load_repo_data`]) and applied on the main thread. struct RepoData { tree: Vec, readme_path: Option, readme: Option>, worktree: Option, branches: Vec, tags: Vec, current_branch: Option, head_commit: Option, } /// 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 { focus_handle: FocusHandle, /// Dock area the detail view lives in; new panels (commit diffs) are /// added there. dock_area: WeakEntity, /// Snapshot taken at open time, shown until the store's first refresh /// completes (and as a fallback while the store has no announcement). initial: Announcement, /// Per-repository nostr store (announcement, issues, PRs, statuses). store: Entity, /// 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, /// Paths of cached previews, oldest first; feeds the eviction caps in /// [`Self::evict_previews`]. file_order: VecDeque, /// Total text bytes held by [`Self::files`]. preview_bytes: usize, /// Reads in flight, to avoid duplicate loads. loading_files: HashSet, /// Latest commit touching a previewed file (or the README), keyed by path. commits: HashMap, /// Paths queued for the next batched commit query (see [`Self::load_commits`]). pending_commits: Vec, /// A batched commit query is in flight. loading_commits: bool, /// Active header tab: 0 = Files (tree), 1 = Commits. active_tab: usize, /// Commits reachable from HEAD, newest first; `None` until the walk /// finishes (or fails). `commits` may be capped by /// [`CommitList`]; `total` feeds the tab badge. all_commits: Option, /// Commit walk in flight. loading_all_commits: bool, /// Virtual list state of the Commits tab. scroll_handle: VirtualListScrollHandle, item_sizes: Rc>>, /// A clone/fetch is in flight. loading: bool, error: Option, /// Commit HEAD currently points to, shown in the header button. head_commit: Option, /// Branch selector (header): local branches, searchable. branch_select: Entity>>, /// Tag selector (header): tags, searchable. tag_select: Entity>>, /// A branch/tag switch is in flight (checkout plus explorer reload). switching_ref: bool, /// Bumped on every branch/tag switch; in-flight loads tagged with an /// older generation are discarded when they complete. ref_generation: u64, /// In-flight tasks; finished tasks are pruned on every push, so the vec /// stays bounded by the number of concurrent loads. tasks: Vec>>, /// Subscriptions keeping the selectors' confirm events alive. _subscriptions: Vec, } impl RepoDetailView { pub fn new( dock_area: WeakEntity, initial: Announcement, window: &mut Window, cx: &mut Context, ) -> Self { // Owner/maintainer avatars shown by this view stay in the shared // cache until the panel closes; free them then. crate::image_cache::clear_on_release(&cx.entity(), window, cx); let store = cx.new(|cx| RepoStore::new(initial.addr(), cx)); let tree_state = cx.new(|cx| TreeState::new(cx)); // Empty until the clone completes; populated with the local refs. let branch_select: Entity>> = cx.new(|cx| { ComboboxState::new( SearchableVec::new(Vec::::new()), Vec::new(), window, cx, ) .searchable(true) }); let tag_select: Entity>> = cx.new(|cx| { ComboboxState::new( SearchableVec::new(Vec::::new()), Vec::new(), window, cx, ) .searchable(true) }); let subscriptions = vec![ cx.subscribe_in(&branch_select, window, |this, _state, event, window, cx| { // `Change` fires only when the selection actually changed // (picking the already-selected branch emits nothing), so a // confirmed value always means a switch. if let ComboboxEvent::Change(values) = event && let Some(name) = values.first() { this.switch_ref(RefKind::Branch, name.clone(), window, cx); } }), cx.subscribe_in(&tag_select, window, |this, _state, event, window, cx| { if let ComboboxEvent::Change(values) = event && let Some(name) = values.first() { this.switch_ref(RefKind::Tag, name.clone(), window, cx); } }), ]; // Defer loading the repository until the window is ready. cx.defer_in(window, |this, window, cx| { this.load_repo(window, cx); }); Self { initial, dock_area, store, tree_state, worktree: None, md: None, code: None, readme_name: None, selected_file: None, files: HashMap::new(), file_order: VecDeque::new(), preview_bytes: 0, loading_files: HashSet::new(), commits: HashMap::new(), pending_commits: Vec::new(), loading_commits: false, active_tab: 0, all_commits: None, loading_all_commits: false, scroll_handle: VirtualListScrollHandle::new(), item_sizes: Rc::new(Vec::new()), loading: true, error: None, head_commit: None, branch_select, tag_select, switching_ref: false, ref_generation: 0, focus_handle: cx.focus_handle(), tasks: Vec::new(), _subscriptions: subscriptions, } } /// Load the repository and populate the file explorer. The local clone /// (if any) is loaded first without touching the network, so an /// unreachable server can't block the panel; a background fetch then /// refreshes the refs and commit list (a fetch never changes the /// checked-out files, so the tree and previews are left alone). fn load_repo(&mut self, window: &mut Window, 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(); // Captured before the loads start: a branch/tag switch bumps it, and // the refresh below is discarded when that happens. let refresh_generation = self.ref_generation; let disk = { let cache = cache.clone(); let addr = addr.clone(); cx.background_spawn(async move { match cache.open(&addr)? { Some(repo) => Ok(Some(load_repo_data(&repo)?)), None => Ok(None), } }) }; let task = cx.spawn_in(window, async move |this, cx| { let disk = disk.await; let had_clone = matches!(&disk, Ok(Some(_))); // No local clone yet: clone from the network (blocking), then load. let data = match disk { Ok(Some(data)) => Ok(data), Ok(None) => { let cache = cache.clone(); let addr = addr.clone(); let clone_urls = clone_urls.clone(); cx.background_spawn(async move { let repo = cache.ensure_clone(&addr, &clone_urls)?; load_repo_data(&repo) }) .await } Err(error) => Err(error), }; this.update_in(cx, |this, window, cx| { match data { Ok(data) => this.apply_repo_data(data, window, cx), Err(error) => this.error = Some(error.to_string().into()), } this.loading = false; cx.notify(); })?; // Refresh the clone from the network in the background; when it // completes, update the refs and commit list. Loads started // before a branch/tag switch are discarded via the generation. if !had_clone { return Ok(()); } let refresh = { let cache = cache.clone(); let addr = addr.clone(); cx.background_spawn(async move { let Some(repo) = cache.open(&addr)? else { return Ok::<_, Error>(None); }; // Best-effort: a failed fetch (e.g. offline) keeps the // cached state, which is already shown. signed_git::fetch_all(&repo).ok(); let worktree = repo.workdir().map(Path::to_path_buf); let (branches, tags) = match &worktree { Some(_) => ( signed_git::repo_branches(&repo).unwrap_or_default(), signed_git::repo_tags(&repo).unwrap_or_default(), ), None => (Vec::new(), Vec::new()), }; let current_branch = signed_git::current_branch(&repo).unwrap_or(None); let head_commit = signed_git::head_commit(&repo).unwrap_or(None); Ok::<_, Error>(Some((branches, tags, current_branch, head_commit))) }) } .await; this.update_in(cx, |this, window, cx| { if refresh_generation != this.ref_generation { return; } if let Ok(Some((branches, tags, current_branch, head_commit))) = refresh { let branches: Vec = branches.into_iter().map(Into::into).collect(); let tags: Vec = tags.into_iter().map(Into::into).collect(); this.branch_select.update(cx, |state, cx| { state.set_items(SearchableVec::from(branches), window, cx); if let Some(branch) = current_branch { let branch: SharedString = branch.into(); state.set_selected_values(&[branch], window, cx); } }); this.tag_select.update(cx, |state, cx| { state.set_items(SearchableVec::from(tags), window, cx); }); this.head_commit = head_commit; // The fetch may have brought new commits: reload the list. this.all_commits = None; this.loading_all_commits = false; this.load_all_commits(cx); cx.notify(); } })?; Ok(()) }); track(&mut self.tasks, task); } /// Apply the loaded repository data: explorer tree, README preview, ref /// selectors and HEAD commit, then start the commit-list walk. fn apply_repo_data(&mut self, data: RepoData, window: &mut Window, cx: &mut Context) { let RepoData { tree, readme_path, readme, worktree, branches, tags, current_branch, head_commit, } = data; let Some(worktree) = worktree else { self.error = Some("Repository has no worktree".into()); return; }; self.worktree = Some(worktree); self.head_commit = head_commit; self.tree_state.update(cx, |state, cx| { state.set_items(tree_items(tree, false), cx); }); // Populate the branch/tag selectors with the local refs, selecting // the branch HEAD points to. let branches: Vec = branches.into_iter().map(Into::into).collect(); let tags: Vec = tags.into_iter().map(Into::into).collect(); self.branch_select.update(cx, |state, cx| { state.set_items(SearchableVec::from(branches), window, cx); if let Some(branch) = current_branch { let branch: SharedString = branch.into(); state.set_selected_values(&[branch], window, cx); } }); self.tag_select.update(cx, |state, cx| { state.set_items(SearchableVec::from(tags), window, cx); }); self.load_all_commits(cx); if let Some((path, bytes)) = readme_path.zip(readme) { self.readme_name = Some(path.to_string_lossy().into()); self.load_commit(&path.to_string_lossy(), cx); if let Ok(text) = String::from_utf8(bytes) { self.set_markdown(None, &text, cx); } } } /// 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) { // The file is cached, but the persistent markdown/code state may // still hold a different file; re-point it at this one (the parse // runs on a background task either way). Without this, the pane // would show a spinner forever. if let Some(FileContent::Text(text)) = self.files.get(path) { let text = text.clone(); if is_markdown_path(path) { if self.md.as_ref().map(|md| md.path.as_deref()) != Some(Some(path)) { self.set_markdown(Some(path.into()), &text, cx); } } else if self.code.as_ref().map(|code| code.path.as_str()) != Some(path) { self.set_code(path.into(), &text, window, cx); } } cx.notify(); return; } if 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 generation = self.ref_generation; let task = cx.spawn_in(window, async move |this, cx| { let path_for_read = path.clone(); let content = cx .background_spawn(async move { let full = worktree.join(&path_for_read); // Refuse oversized files before reading them: reading a // multi-gigabyte file just to classify it as too large // would waste the disk and memory bandwidth. let metadata = match std::fs::metadata(&full) { Ok(metadata) => metadata, Err(error) => return Err(anyhow::anyhow!("{}", error)), }; if metadata.len() > MAX_PREVIEW_BYTES as u64 { return Ok(FileContent::TooLarge); } let bytes = match std::fs::read(&full) { Ok(bytes) => bytes, Err(error) => return Err(anyhow::anyhow!("{}", error)), }; match String::from_utf8(bytes) { Ok(text) => Ok(FileContent::Text(text)), Err(_) => Ok(FileContent::Binary), } }) .await; this.update_in(cx, |this, window, cx| { // The worktree was switched while this file was reading; // the result belongs to the previous branch. Clear the // in-flight marker either way, or the path could never be // loaded again. if generation != this.ref_generation { this.loading_files.remove(&path); return; } 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, window, cx); } } this.preview_bytes += text.len(); } this.files.insert(path.clone(), kind); this.file_order.push_back(path); this.evict_previews(); } Err(error) => { this.files .insert(path, FileContent::Failed(error.to_string())); } } cx.notify(); })?; Ok(()) }); track(&mut self.tasks, task); } /// Queue `path` for the per-file commit query; requests are batched into /// one history walk (see [`Self::load_commits`]). fn load_commit(&mut self, path: &str, cx: &mut Context) { if self.commits.contains_key(path) || self.pending_commits.iter().any(|p| p == path) { return; } self.pending_commits.push(path.to_string()); if !self.loading_commits { self.load_commits(cx); } } /// Walk history once for every queued path on a background task, and /// cache the latest commit touching each of them in [`Self::commits`] /// (for the file header in the content column). /// /// Batching shares one walk (and its object decodes) across all paths /// queued while the previous walk was in flight, instead of walking the /// full history per file. fn load_commits(&mut self, cx: &mut Context) { if self.pending_commits.is_empty() || self.loading_commits { return; } let Some(worktree) = self.worktree.clone() else { self.pending_commits.clear(); return; }; self.loading_commits = true; let paths = std::mem::take(&mut self.pending_commits); let generation = self.ref_generation; let task = cx.spawn(async move |this, cx| { let rels: Vec = paths.iter().map(PathBuf::from).collect(); let result = cx .background_spawn( async move { signed_git::worktree_last_commits(&worktree, &rels) }, ) .await; this.update(cx, |this, cx| { this.loading_commits = false; if generation == this.ref_generation && let Ok(found) = result { for (path, commit) in found { this.commits .insert(path.to_string_lossy().into_owned(), commit); } } // Paths queued while the walk was in flight start the next // batch. A stale walk (branch switched mid-flight) must not // strand them, so this runs under the current generation // regardless of whether the result was applied. if !this.pending_commits.is_empty() { this.load_commits(cx); } cx.notify(); })?; Ok(()) }); track(&mut self.tasks, task); } /// Walk all commits reachable from HEAD on a background task, for the /// Commits tab and its total-count badge. The list is capped by /// [`CommitList`]; only the newest commits are materialized. fn load_all_commits(&mut self, cx: &mut Context) { if self.loading_all_commits || self.all_commits.is_some() { return; } let Some(worktree) = self.worktree.clone() else { return; }; self.loading_all_commits = true; let generation = self.ref_generation; let task = cx.spawn(async move |this, cx| { let result = cx .background_spawn(async move { signed_git::worktree_all_commits(&worktree) }) .await; this.update(cx, |this, cx| { // A stale walk (branch switched mid-flight) must not leave // the flag set, or the Commits tab would spin forever. if generation != this.ref_generation { this.loading_all_commits = false; return; } if let Ok(list) = result { let count = list.commits.len(); this.item_sizes = Rc::new(vec![size(px(0.), px(COMMIT_ROW_HEIGHT)); count]); this.all_commits = Some(list); } this.loading_all_commits = false; cx.notify(); })?; Ok(()) }); track(&mut self.tasks, task); } /// Open a new panel showing the diff of `commit_id` (all files it /// changed, with the line diff of each). Called from the Commits tab /// rows and the latest-commit button in the header. fn open_commit_diff(&mut self, commit_id: &str, window: &mut Window, cx: &mut Context) { let Some(worktree) = self.worktree.clone() else { return; }; let Some(dock_area) = self.dock_area.upgrade() else { return; }; // Same display name as the repo detail panel's title. let repo_name = self.display_name(cx); let panel = cx.new(|cx| CommitDiffView::new(worktree, repo_name, commit_id.into(), window, cx)); dock_area.update(cx, |dock_area, cx| { dock_area.add_panel(Arc::new(panel), DockPlacement::Center, None, window, cx); }); } /// Open the issues panel at the bottom of the dock area. fn open_issue_detail(&mut self, window: &mut Window, cx: &mut Context) { let Some(dock_area) = self.dock_area.upgrade() else { return; }; let panel = cx.new(|cx| IssuesView::new(self.store.clone(), self.display_name(cx), window, cx)); dock_area.update(cx, |dock_area, cx| { dock_area.add_panel(Arc::new(panel), DockPlacement::Center, None, window, cx); }); } /// Open the pull requests panel at the bottom of the dock area. fn open_pull_request_detail(&mut self, window: &mut Window, cx: &mut Context) { let Some(dock_area) = self.dock_area.upgrade() else { return; }; let panel = cx .new(|cx| PullRequestsView::new(self.store.clone(), self.display_name(cx), window, cx)); dock_area.update(cx, |dock_area, cx| { dock_area.add_panel(Arc::new(panel), DockPlacement::Center, None, window, cx); }); } /// Check out `name` (a branch or tag picked in the header) and refresh /// the explorer once the switch completes. fn switch_ref( &mut self, kind: RefKind, name: SharedString, window: &mut Window, cx: &mut Context, ) { if self.switching_ref { return; } let Some(worktree) = self.worktree.clone() else { return; }; // Branches and tags are mutually exclusive states of HEAD: selecting // one clears the other selector. Remember the previous selections so // they can be restored if the checkout fails. let previous_branch = self.branch_select.read(cx).selected_value(); let previous_tag = self.tag_select.read(cx).selected_value(); match kind { RefKind::Branch => { self.tag_select .update(cx, |state, cx| state.clear_selection(cx)); } RefKind::Tag => { self.branch_select .update(cx, |state, cx| state.clear_selection(cx)); } } self.switching_ref = true; // In-flight loads of the previous branch are discarded when they // complete. self.ref_generation += 1; cx.notify(); let checkout_name = name.clone(); let task = cx.spawn_in(window, async move |this, cx| { let result = cx .background_spawn(async move { match kind { RefKind::Branch => { signed_git::worktree_checkout_branch(&worktree, &checkout_name) } RefKind::Tag => { signed_git::worktree_checkout_tag(&worktree, &checkout_name) } } }) .await; this.update_in(cx, |this, window, cx| { match result { Ok(()) => this.reload_worktree(cx), Err(error) => { this.error = Some(format!("Failed to check out {name}: {error}").into()); this.switching_ref = false; this.restore_selection(&this.branch_select, &previous_branch, window, cx); this.restore_selection(&this.tag_select, &previous_tag, window, cx); } } cx.notify(); })?; Ok(()) }); track(&mut self.tasks, task); } /// Restore a selector to `previous`, or clear it (after a failed switch). fn restore_selection( &self, select: &Entity>>, previous: &Option, window: &mut Window, cx: &mut Context, ) { select.update(cx, |state, cx| match previous { Some(value) => state.set_selected_values(std::slice::from_ref(value), window, cx), None => state.clear_selection(cx), }); } /// Trigger body for the branch/tag selectors: the kind icon, the /// selection (or placeholder) and the caret. `Combobox` replaces its /// default trigger entirely, which is the only way to show an icon /// inside the trigger label. fn render_ref_trigger( ctx: &ComboboxTriggerContext>, icon: CustomIconName, cx: &App, ) -> AnyElement { let muted = cx.theme().muted_foreground; h_flex() .w_full() .min_w_0() .gap_1() .items_center() .child(Icon::new(icon).small().flex_shrink_0()) .child( div() .flex_1() .min_w_0() .overflow_hidden() .text_ellipsis() .whitespace_nowrap() .when(ctx.selection().is_empty(), |this| this.text_color(muted)) .child( ctx.selection() .first() .map(|(_, item)| item.clone()) .or_else(|| ctx.placeholder().cloned()) .unwrap_or_default(), ), ) .child(Caret::new(ctx.size()).text_color(muted)) .into_any_element() } /// Refresh the file explorer, preview pane and commit list after a /// successful branch or tag switch. The selectors were already updated /// by [`Self::switch_ref`]; [`Self::switching_ref`] stays set until this /// reload finishes, so a second switch cannot interleave. fn reload_worktree(&mut self, cx: &mut Context) { let Some(worktree) = self.worktree.clone() else { return; }; let task = cx.spawn(async move |this, cx| { let result = cx .background_spawn(async move { let snapshot = signed_git::worktree_snapshot(&worktree)?; // Build the tree off the main thread, like [`Self::load_repo`]. let tree = build_tree_items(&snapshot.entries); Ok::<_, Error>((snapshot, tree)) }) .await; this.update(cx, |this, cx| { this.switching_ref = false; match result { Ok((snapshot, tree)) => { this.head_commit = snapshot.head_commit; // Rebuild the tree from scratch: entries of the // previous branch are gone, and with them the // expansion state. this.tree_state.update(cx, |state, cx| { state.set_items(tree_items(tree, false), cx); }); // Drop cached previews and commits of the old branch. this.selected_file = None; this.files.clear(); this.file_order.clear(); this.preview_bytes = 0; this.loading_files.clear(); this.commits.clear(); this.pending_commits.clear(); this.loading_commits = false; this.md = None; this.code = None; this.readme_name = None; this.all_commits = None; this.loading_all_commits = false; if let Some((path, bytes)) = snapshot.readme_path.zip(snapshot.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); } } this.load_all_commits(cx); } Err(error) => { this.error = Some(error.to_string().into()); this.head_commit = None; // The tree may show files that no longer exist. this.tree_state.update(cx, |state, cx| { state.set_items(Vec::new(), cx); }); } } cx.notify(); })?; Ok(()) }); track(&mut self.tasks, task); } /// Drop the oldest previews beyond the cache caps, keeping the currently /// selected file. The parsed editor state of an evicted file is dropped /// along with its entry, so re-opening it re-parses on a background task. fn evict_previews(&mut self) { while (self.files.len() > MAX_PREVIEWED_FILES || self.preview_bytes > MAX_PREVIEW_CACHE_BYTES) && self.file_order.len() > 1 { let path = self.file_order.pop_front().expect("non-empty"); if Some(path.as_str()) == self.selected_file.as_deref() { self.file_order.push_back(path); continue; } if let Some(FileContent::Text(text)) = self.files.remove(&path) { self.preview_bytes -= text.len(); } if self.md.as_ref().map(|md| md.path.as_deref()) == Some(Some(path.as_str())) { self.md = None; } if self .code .as_ref() .is_some_and(|code| code.path.as_ref() == path.as_str()) { self.code = None; } self.commits.remove(&path); } } /// The latest announcement from the store, or the open-time snapshot. fn announcement<'a>(&'a self, cx: &'a App) -> &'a Announcement { self.store .read(cx) .announcement .as_ref() .unwrap_or(&self.initial) } /// Display name: the announcement's name, or the ID if no name is set. fn display_name(&self, cx: &App) -> SharedString { let announcement = self.announcement(cx); announcement .name .clone() .unwrap_or_else(|| SharedString::from(announcement.id.clone())) } fn render_header(&self, cx: &mut Context) -> AnyElement { let store = self.store.read(cx); let announcement = store.announcement.as_ref().unwrap_or(&self.initial); let issue_count = store.open_issue_count(); let pull_request_count = store.open_pull_request_count(); let name = self.display_name(cx); let description = announcement.description(); let commits_count = self.all_commits.as_ref().map(|list| list.total); let worktree_empty = self.switching_ref || self.worktree.is_none(); v_flex() .px_4() .pt_2() .pb_2() .w_full() .gap_8() .border_b_1() .border_color(cx.theme().border) .child( h_flex() .w_full() .gap_2() .items_start() .justify_between() .child( v_flex() .flex_1() .min_w_0() .child(div().font_semibold().child(name)) .child( div() .min_w_0() .text_xs() .text_color(cx.theme().muted_foreground) .line_clamp(2) .text_ellipsis() .child(description), ) .child( h_flex() .mt_2() .gap_2() .child( div() .text_xs() .text_color(cx.theme().muted_foreground) .font_semibold() .child("Maintainers:"), ) .child(self.render_maintainers(cx)), ), ) .child( h_flex() .flex_none() .gap_2() .justify_end() .child( Button::new("issues") .child( h_flex() .gap_2() .text_sm() .child(SharedString::from("Issues")) .child(Tag::new().xsmall().child(SharedString::from( issue_count.to_string(), ))), ) .outline() .on_click(cx.listener(|this, _event, window, cx| { this.open_issue_detail(window, cx); })), ) .child( Button::new("prs") .child( h_flex() .gap_2() .text_sm() .child(SharedString::from("Pull Requests")) .child(Tag::new().xsmall().child(SharedString::from( pull_request_count.to_string(), ))), ) .outline() .on_click(cx.listener(|this, _event, window, cx| { this.open_pull_request_detail(window, cx); })), ) .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() .items_center() .child( TabBar::new("repo-tabs") .segmented() .selected_index(self.active_tab) .child(Tab::new().label("Files")) .child(Tab::new().label("Commits").when_some( commits_count, |this, count| { this.suffix( Tag::secondary() .xsmall() .mr_1() .child(SharedString::from(count.to_string())), ) }, )) .on_click(cx.listener(|this, index, _window, cx| { this.active_tab = *index; cx.notify(); })), ) .child( h_flex() .flex_1() .gap_2() .justify_end() .child( Button::new("enc") .ghost() .when_some(self.head_commit.as_ref(), |this, commit| { this.child( div() .text_xs() .text_color(cx.theme().muted_foreground) .child(SharedString::from(&commit.id)), ) .child( div() .max_w(px(200.)) .overflow_hidden() .text_ellipsis() .whitespace_nowrap() .text_xs() .child(SharedString::from(&commit.summary)), ) }) .tooltip( self.head_commit .as_ref() .map_or_else(SharedString::default, |commit| { commit.summary.clone().into() }), ) .on_click(cx.listener(|this, _event, window, cx| { if let Some(commit) = &this.head_commit { let id = commit.id.clone(); this.open_commit_diff(&id, window, cx); } })), ) .child( div().w(px(120.)).child( Combobox::new(&self.branch_select) .placeholder("Branch") .appearance(false) .menu_width(px(200.)) .disabled(worktree_empty) .bg(cx.theme().muted) .rounded(cx.theme().radius) .render_trigger(|ctx, _window, cx| { Self::render_ref_trigger( ctx, CustomIconName::GitBranch, cx, ) }), ), ) .child( div().w(px(120.)).child( Combobox::new(&self.tag_select) .placeholder("Tag") .appearance(false) .menu_width(px(200.)) .disabled(worktree_empty) .bg(cx.theme().muted) .rounded(cx.theme().radius) .render_trigger(|ctx, _window, cx| { Self::render_ref_trigger(ctx, CustomIconName::Tag, cx) }), ), ), ), ) .into_any_element() } /// Horizontal list of everyone who maintains the repository: the owner /// shown in full, and any additional maintainers as a compact overlapping avatar group. fn render_maintainers(&self, cx: &mut Context) -> AnyElement { let announcement = self.announcement(cx); let profile_store = ProfileStore::global(cx); let mut seen = HashSet::new(); let rest: Vec<_> = announcement .maintainers .iter() .copied() .filter(|key| *key != announcement.owner && seen.insert(*key)) .collect(); let owner = profile_store.read(cx).get(&announcement.owner); let owner_name = owner.name(); let owner_picture = owner.picture(); h_flex() .w_full() .gap_3() .items_center() .flex_wrap() .child( h_flex() .gap_1() .items_center() .child( Avatar::new() .name(owner_name.clone()) .when_some(owner_picture, |this, url| this.src(url)) .small(), ) .child(div().text_xs().whitespace_nowrap().child(owner_name)), ) .when(!rest.is_empty(), |this| { this.child(AvatarGroup::new().small().limit(5).ellipsis().children( rest.into_iter().map(|key| { let profile = profile_store.read(cx).get(&key); Avatar::new() .name(profile.name()) .when_some(profile.picture(), |this, url| this.src(url)) }), )) }) .into_any_element() } } impl Panel for RepoDetailView { fn panel_name(&self) -> &'static str { "repo_detail" } fn title(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { self.display_name(cx) } } 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 pane_title = self .selected_file .clone() .or_else(|| self.readme_name.clone()) .unwrap_or_else(|| "Overview".into()); v_flex() .id("repo") .size_full() .child(self.render_header(cx)) .child(match self.active_tab { 0 => 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)) .into_any_element(), _ => self.render_commits_tab(cx), }) } } /// Read the worktree state of `repo` (no network): entries, README, refs /// and HEAD commit. The tree is built off the main thread; the seeds are /// plain owned strings and convert to `TreeItem`s (which hold `Rc` state) /// on the main thread. fn load_repo_data(repo: &Repository) -> Result { let entries = signed_git::worktree_entries(repo)?; let tree = build_tree_items(&entries); 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); // Ref listing is auxiliary UI: a broken ref must not prevent the // explorer from loading, so failures degrade to empty selectors. let (branches, tags, current_branch) = match &worktree { Some(_) => ( signed_git::repo_branches(repo).unwrap_or_default(), signed_git::repo_tags(repo).unwrap_or_default(), signed_git::current_branch(repo).unwrap_or(None), ), None => (Vec::new(), Vec::new(), None), }; let head_commit = signed_git::head_commit(repo).unwrap_or(None); Ok(RepoData { tree, readme_path, readme, worktree, branches, tags, current_branch, head_commit, }) }