From c1c7ddbca212a31b4fe117f9cb36eb30d9b4fd3d Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 14 Aug 2026 10:55:42 +0700 Subject: [PATCH] optimize --- Cargo.lock | 1 + crates/signed_git/src/lib.rs | 72 +- crates/workspace/Cargo.toml | 1 + .../src/views/repo_detail/browser.rs | 77 +- .../src/views/repo_detail/commits.rs | 8 +- .../workspace/src/views/repo_detail/diff.rs | 295 +++--- .../src/views/repo_detail/helpers.rs | 66 +- crates/workspace/src/views/repo_detail/mod.rs | 855 ++++++++++-------- 8 files changed, 796 insertions(+), 579 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 182dc49..ce7cb52 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10785,6 +10785,7 @@ version = "1.0.0" dependencies = [ "anyhow", "assets", + "gix", "gpui", "gpui-component", "signed_core", diff --git a/crates/signed_git/src/lib.rs b/crates/signed_git/src/lib.rs index e0684d2..064b6db 100644 --- a/crates/signed_git/src/lib.rs +++ b/crates/signed_git/src/lib.rs @@ -240,17 +240,24 @@ fn open_with_cache(workdir: &Path) -> Result { Ok(repo) } -/// A [`FileCommit`] from a walk commit: author, message title and shortened id. -fn file_commit(commit: &gix::Commit<'_>) -> Result { +/// A [`FileCommit`] from a walk commit: author, message title and shortened +/// id. `include_description` controls whether the message body is copied; +/// history lists never display it, so skipping it saves a string allocation +/// per listed commit (the diff panel fetches the full commit on demand). +fn file_commit(commit: &gix::Commit<'_>, include_description: bool) -> Result { let author = commit.author()?; let message = commit.message()?; Ok(FileCommit { id: commit.id().shorten_or_id().to_string(), summary: String::from_utf8_lossy(message.title).trim().to_string(), - description: message - .body - .map(|body| String::from_utf8_lossy(body).trim().to_string()) - .filter(|body| !body.is_empty()), + description: if include_description { + message + .body + .map(|body| String::from_utf8_lossy(body).trim().to_string()) + .filter(|body| !body.is_empty()) + } else { + None + }, author: String::from_utf8_lossy(author.name).trim().to_string(), time: author.time()?.seconds, }) @@ -332,7 +339,7 @@ fn last_commits(repo: &gix::Repository, rels: &[PathBuf]) -> Result Result { let info = info?; total += 1; if commits.len() < MAX_LISTED_COMMITS { - commits.push(file_commit(&info.object()?)?); + commits.push(file_commit(&info.object()?, false)?); } } Ok(CommitList { total, commits }) @@ -681,23 +688,29 @@ pub fn head_commit(repo: &gix::Repository) -> Result> { return Ok(None); }; let commit = head.object()?.into_commit(); - let author = commit.author()?; - let message = commit.message()?; - Ok(Some(FileCommit { - id: commit.id().shorten_or_id().to_string(), - summary: String::from_utf8_lossy(message.title).trim().to_string(), - description: message - .body - .map(|body| String::from_utf8_lossy(body).trim().to_string()) - .filter(|body| !body.is_empty()), - author: String::from_utf8_lossy(author.name).trim().to_string(), - time: author.time()?.seconds, - })) + Ok(Some(file_commit(&commit, true)?)) } -/// Short names of local branches (`refs/heads/*`), sorted alphabetically. -pub fn worktree_branches(workdir: &Path) -> Result> { +/// Full metadata of the commit `id` (short or full) in the repository at +/// `workdir`, like [`head_commit`] for an arbitrary commit. Returns +/// `Ok(None)` when the id cannot be resolved. +/// +/// The commit list ([`all_commits`]) omits message bodies to keep the walk +/// cheap; the diff panel uses this to fetch the full commit on demand. +pub fn worktree_commit(workdir: &Path, id: &str) -> Result> { let repo = open_with_cache(workdir)?; + match repo.rev_parse_single(id.as_bytes()) { + Ok(commit_id) => { + let commit = commit_id.object()?.into_commit(); + Ok(Some(file_commit(&commit, true)?)) + } + Err(_) => Ok(None), + } +} + +/// Short names of local branches (`refs/heads/*`) of `repo`, sorted +/// alphabetically. +pub fn repo_branches(repo: &gix::Repository) -> Result> { let mut names = Vec::new(); for reference in repo.references()?.local_branches()? { let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?; @@ -707,9 +720,8 @@ pub fn worktree_branches(workdir: &Path) -> Result> { Ok(names) } -/// Short names of tags (`refs/tags/*`), sorted alphabetically. -pub fn worktree_tags(workdir: &Path) -> Result> { - let repo = open_with_cache(workdir)?; +/// Short names of tags (`refs/tags/*`) of `repo`, sorted alphabetically. +pub fn repo_tags(repo: &gix::Repository) -> Result> { let mut names = Vec::new(); for reference in repo.references()?.tags()? { let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?; @@ -719,6 +731,16 @@ pub fn worktree_tags(workdir: &Path) -> Result> { Ok(names) } +/// Short names of local branches (`refs/heads/*`), sorted alphabetically. +pub fn worktree_branches(workdir: &Path) -> Result> { + repo_branches(&open_with_cache(workdir)?) +} + +/// Short names of tags (`refs/tags/*`), sorted alphabetically. +pub fn worktree_tags(workdir: &Path) -> Result> { + repo_tags(&open_with_cache(workdir)?) +} + /// Short name of the branch HEAD points to, or `None` when detached (e.g. /// after checking out a tag or a commit directly). pub fn current_branch(repo: &gix::Repository) -> Result> { diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index e961ebb..59949fb 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -13,5 +13,6 @@ utils = { path = "../utils" } gpui.workspace = true gpui-component.workspace = true +gix.workspace = true anyhow.workspace = true diff --git a/crates/workspace/src/views/repo_detail/browser.rs b/crates/workspace/src/views/repo_detail/browser.rs index 93b243d..3f55550 100644 --- a/crates/workspace/src/views/repo_detail/browser.rs +++ b/crates/workspace/src/views/repo_detail/browser.rs @@ -11,10 +11,10 @@ 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 gpui_component::{ActiveTheme, Sizable, StyledExt, h_flex, v_flex}; use super::RepoDetailView; -use super::helpers::{code_language, is_markdown_path, placeholder}; +use super::helpers::{code_language, is_markdown_path, placeholder, tree_row}; /// Width of the file explorer column. const TREE_WIDTH: f32 = 240.; @@ -82,46 +82,18 @@ impl RepoDetailView { selected: bool, view: &WeakEntity, ) -> ListItem { - let item = entry.item(); - let id = item.id.clone(); - let is_folder = entry.is_folder(); - - let icon = if is_folder { - if entry.is_expanded() { - IconName::FolderOpen - } else { - IconName::FolderClosed - } - } else { - IconName::File - }; - let view = view.clone(); + let id = entry.item().id.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)); - } - }) + tree_row(ix, entry, selected, move |window, cx| { + if let Some(view) = view.upgrade() { + view.update(cx, |this, cx| this.open_file(&id, window, cx)); + } + }) } /// Left column: the file tree. pub(super) fn render_tree_column( - &mut self, tree_state: Entity, view: WeakEntity, cx: &mut Context, @@ -143,7 +115,7 @@ impl RepoDetailView { /// Right column: README, selected file preview, or status text. pub(super) fn render_content_column( - &mut self, + &self, pane_title: SharedString, cx: &mut Context, ) -> impl IntoElement { @@ -190,12 +162,7 @@ impl RepoDetailView { 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(), + None => preview_spinner(), } } else if self.readme_name.is_some() { self.markdown_element(None, cx) @@ -206,19 +173,13 @@ impl RepoDetailView { // Latest commit for the current pane: the selected file, or the README // while nothing is selected. Computed after the body above, which // needs `&mut self`. - let commit = self - .selected_file - .as_ref() - .and_then(|path| self.commits.get(path.as_ref())) - .or_else(|| { - if self.selected_file.is_none() { - self.readme_name - .as_ref() - .and_then(|name| self.commits.get(name.as_ref())) - } else { - None - } - }); + let commit = match &self.selected_file { + Some(path) => self.commits.get(path.as_ref()), + None => self + .readme_name + .as_ref() + .and_then(|name| self.commits.get(name.as_ref())), + }; v_flex() .flex_1() @@ -285,7 +246,7 @@ impl RepoDetailView { /// The persistent markdown TextView for `path` (`None` = README), or a /// spinner while the document is being loaded/parsed. - fn markdown_element(&mut self, path: Option<&str>, _cx: &mut Context) -> AnyElement { + fn markdown_element(&self, path: Option<&str>, _cx: &mut Context) -> AnyElement { let Some(md) = &self.md else { return preview_spinner(); }; @@ -325,7 +286,7 @@ impl RepoDetailView { /// The persistent code editor for `path`, or a spinner while the file is /// being loaded/parsed. - fn code_element(&mut self, path: &str, _cx: &mut Context) -> AnyElement { + fn code_element(&self, path: &str, _cx: &mut Context) -> AnyElement { let Some(code) = &self.code else { return preview_spinner(); }; diff --git a/crates/workspace/src/views/repo_detail/commits.rs b/crates/workspace/src/views/repo_detail/commits.rs index bd5107b..6900101 100644 --- a/crates/workspace/src/views/repo_detail/commits.rs +++ b/crates/workspace/src/views/repo_detail/commits.rs @@ -25,7 +25,9 @@ fn commit_row( cx: &App, ) -> AnyElement { let view = view.clone(); - let commit = commit.clone(); + // Only the id is needed by the click handler: the diff panel fetches + // the full commit itself. + let id = commit.id.clone(); h_flex() .id(ix) @@ -76,7 +78,7 @@ fn commit_row( ) .on_click(move |_event, window, cx| { if let Some(view) = view.upgrade() { - view.update(cx, |this, cx| this.open_commit_diff(&commit, window, cx)); + view.update(cx, |this, cx| this.open_commit_diff(&id, window, cx)); } }) .into_any_element() @@ -85,7 +87,7 @@ fn commit_row( impl RepoDetailView { /// Full-height body of the Commits tab: all commits in a virtual /// list, or a status message while loading / when there are none. - pub(super) fn render_commits_tab(&mut self, cx: &mut Context) -> AnyElement { + pub(super) fn render_commits_tab(&self, cx: &mut Context) -> AnyElement { let Some(list) = self.all_commits.as_ref() else { return if self.loading_all_commits { v_flex() diff --git a/crates/workspace/src/views/repo_detail/diff.rs b/crates/workspace/src/views/repo_detail/diff.rs index 7f9c154..d8c3301 100644 --- a/crates/workspace/src/views/repo_detail/diff.rs +++ b/crates/workspace/src/views/repo_detail/diff.rs @@ -4,29 +4,48 @@ //! commit in the Commits tab or the latest-commit button in the header. use std::path::PathBuf; +use std::rc::Rc; use gpui::prelude::*; use gpui::{ - AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, SharedString, - WeakEntity, Window, div, px, + AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render, + ScrollStrategy, SharedString, Size, WeakEntity, Window, div, px, size, }; use gpui_component::clipboard::Clipboard; use gpui_component::dock::{Panel, PanelEvent}; use gpui_component::list::ListItem; use gpui_component::resizable::{resizable_panel, v_resizable}; +use gpui_component::scroll::{ScrollableElement, Scrollbar}; use gpui_component::spinner::Spinner; use gpui_component::tag::Tag; use gpui_component::tree::{TreeEntry, TreeItem, TreeState, tree}; -use gpui_component::{ActiveTheme, Icon, IconName, Sizable, StyledExt, h_flex, v_flex}; +use gpui_component::{ + ActiveTheme, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list, +}; use signed_git::{CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileCommit, FileDiff}; use utils::relative_time_secs; -use super::helpers::{build_tree_items, placeholder, tree_items}; +use super::helpers::{build_tree_items, placeholder, track, tree_items, tree_row}; /// Width of the changed-files column. const TREE_WIDTH: f32 = 260.; /// Width of one line-number gutter in a diff row. const GUTTER_WIDTH: f32 = 44.; +/// Height of one row in the virtual diff list. +const DIFF_ROW_HEIGHT: f32 = 20.; + +/// One row of the virtual diff list: a hunk header, or a line of a hunk. +#[derive(Clone, Copy)] +enum DiffRow { + Hunk { + old_start: u32, + old_lines: u32, + new_start: u32, + new_lines: u32, + }, + /// Line `line` of hunk `hunk` of the selected file's diff. + Line { hunk: usize, line: usize }, +} /// Detail panel showing the diff of one commit. pub struct CommitDiffView { @@ -35,7 +54,9 @@ pub struct CommitDiffView { worktree: PathBuf, /// Display name of the repository the commit belongs to. repo_name: SharedString, - /// The commit being shown (header and tab title). + /// The commit being shown (header and tab title). Starts as an id-only + /// stub; [`Self::load`] replaces it with the full metadata, which the + /// history list intentionally omits. commit: FileCommit, /// Loaded diff; `None` while loading or after a failure. diff: Option, @@ -46,7 +67,14 @@ pub struct CommitDiffView { tree_state: Entity, /// Path of the file whose diff is shown in the detail column. selected_file: Option, - /// In-flight tasks; pruned on every push (see [`Self::track`]). + /// Rows of the selected file's diff (hunk headers + lines), backing the + /// virtual list in the detail column. + rows: Vec, + /// Per-row heights of [`Self::rows`]. + item_sizes: Rc>>, + /// Virtual list state of the diff rows. + scroll_handle: VirtualListScrollHandle, + /// In-flight tasks; pruned on every push (see [`helpers::track`]). tasks: Vec>>, } @@ -54,7 +82,7 @@ impl CommitDiffView { pub fn new( worktree: PathBuf, repo_name: SharedString, - commit: FileCommit, + commit_id: String, window: &mut Window, cx: &mut Context, ) -> Self { @@ -69,17 +97,27 @@ impl CommitDiffView { focus_handle: cx.focus_handle(), worktree, repo_name, - commit, + commit: FileCommit { + id: commit_id, + summary: String::new(), + description: None, + author: String::new(), + time: 0, + }, diff: None, loading: true, error: None, tree_state, selected_file: None, + rows: Vec::new(), + item_sizes: Rc::new(Vec::new()), + scroll_handle: VirtualListScrollHandle::new(), tasks: Vec::new(), } } - /// Load the commit diff on a background task and populate the tree. + /// Load the commit diff (and the full commit metadata) on a background + /// task and populate the tree. fn load(&mut self, window: &mut Window, cx: &mut Context) { self.loading = true; self.error = None; @@ -89,13 +127,27 @@ impl CommitDiffView { let id = self.commit.id.clone(); let task = cx.spawn_in(window, async move |this, cx| { - let result = cx - .background_spawn(async move { signed_git::worktree_commit_diff(&worktree, &id) }) + let commit = cx + .background_spawn({ + let worktree = worktree.clone(); + let id = id.clone(); + async move { signed_git::worktree_commit(&worktree, &id) } + }) + .await; + let diff = cx + .background_spawn({ + let worktree = worktree.clone(); + let id = id.clone(); + async move { signed_git::worktree_commit_diff(&worktree, &id) } + }) .await; this.update_in(cx, |this, _window, cx| { this.loading = false; - match result { + if let Ok(Some(commit)) = commit { + this.commit = commit; + } + match diff { Ok(diff) => { let mut paths: Vec = diff .files @@ -113,8 +165,11 @@ impl CommitDiffView { let item = find_item(&items, first.as_deref()); state.set_selected_item(item, cx); }); - this.selected_file = first; + this.selected_file = first.clone(); this.diff = Some(diff); + if let Some(path) = first { + this.set_diff_rows(path.as_ref()); + } } Err(error) => { this.error = Some(error.to_string().into()); @@ -126,20 +181,28 @@ impl CommitDiffView { Ok(()) }); - self.track(task); + track(&mut self.tasks, task); } /// Show the diff of the file at `path` (selected in the tree). fn select_file(&mut self, path: &str, cx: &mut Context) { self.selected_file = Some(path.into()); + self.set_diff_rows(path); cx.notify(); } - /// Track `task` until it completes; finished tasks are pruned on every - /// push so the vec stays bounded by the number of in-flight loads. - fn track(&mut self, task: gpui::Task>) { - self.tasks.retain(|task| !task.is_ready()); - self.tasks.push(task); + /// Rebuild the virtual list state for the file at `path` and scroll back + /// to the top. + fn set_diff_rows(&mut self, path: &str) { + let Some(diff) = self.diff.as_ref() else { + return; + }; + let Some(file) = diff.files.iter().find(|file| file.path == path) else { + return; + }; + self.rows = diff_rows(file); + self.item_sizes = Rc::new(vec![size(px(0.), px(DIFF_ROW_HEIGHT)); self.rows.len()]); + self.scroll_handle.scroll_to_item(0, ScrollStrategy::Top); } /// One row of the changed-files tree: icon + name, indented by depth. @@ -149,45 +212,18 @@ impl CommitDiffView { selected: bool, view: &WeakEntity, ) -> ListItem { - let item = entry.item(); - let id = item.id.clone(); - let is_folder = entry.is_folder(); - - let icon = if is_folder { - if entry.is_expanded() { - IconName::FolderOpen - } else { - IconName::FolderClosed - } - } else { - IconName::File - }; - let view = view.clone(); + let id = entry.item().id.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.select_file(&id, cx)); - } - }) + tree_row(ix, entry, selected, move |_window, cx| { + if let Some(view) = view.upgrade() { + view.update(cx, |this, cx| this.select_file(&id, cx)); + } + }) } /// Left column: the changed-files tree. - fn render_tree_column(&mut self, cx: &mut Context) -> AnyElement { + fn render_tree_column(&self, cx: &mut Context) -> AnyElement { let tree_state = self.tree_state.clone(); let view = cx.entity().downgrade(); @@ -210,26 +246,14 @@ impl CommitDiffView { ) }) .when(self.diff.is_none() && !self.loading, |this| { - this.child( - v_flex() - .size_full() - .items_center() - .justify_center() - .p_4() - .child( - div() - .text_sm() - .text_color(cx.theme().muted_foreground) - .child("Failed to load diff"), - ), - ) + this.child(placeholder("Failed to load diff", cx)) }), ) .into_any_element() } /// Right column: header of the selected file plus its diff. - fn render_detail_column(&mut self, cx: &mut Context) -> AnyElement { + fn render_detail_column(&self, cx: &mut Context) -> AnyElement { if self.loading { return v_flex() .size_full() @@ -254,11 +278,12 @@ impl CommitDiffView { let Some(file) = diff.files.iter().find(|file| file.path == path.as_ref()) else { return placeholder("File not found", cx); }; - self.render_file_diff(file, cx) + self.render_file_diff(file, cx.entity(), cx) } - /// The diff of one file: a header with status and stats, then the hunks. - fn render_file_diff(&self, file: &FileDiff, cx: &App) -> AnyElement { + /// The diff of one file: a header with status and stats, then the hunks + /// in a virtual list (a large diff is never materialized per frame). + fn render_file_diff(&self, file: &FileDiff, view: Entity, cx: &App) -> AnyElement { let status_label = match file.status { DiffStatus::Added => "A", DiffStatus::Modified => "M", @@ -282,11 +307,44 @@ impl CommitDiffView { } else if file.hunks.is_empty() { placeholder("No content changes", cx) } else { + let sizes = self.item_sizes.clone(); + let scroll_handle = self.scroll_handle.clone(); v_flex() - .w_full() - .font_family(cx.theme().mono_font_family.clone()) - .text_xs() - .children(file.hunks.iter().map(|hunk| Self::render_hunk(hunk, cx))) + .size_full() + .relative() + .child( + v_virtual_list( + view, + "commit-diff-rows", + sizes, + move |this, range, _window, cx| { + let Some(diff) = this.diff.as_ref() else { + return Vec::new(); + }; + let Some(path) = this.selected_file.as_deref() else { + return Vec::new(); + }; + let Some(file) = diff.files.iter().find(|file| file.path == path) + else { + return Vec::new(); + }; + range + .map(|ix| Self::render_diff_row(&file.hunks, this.rows[ix], cx)) + .collect() + }, + ) + .track_scroll(&scroll_handle) + .size_full(), + ) + .child( + div() + .absolute() + .top_0() + .left_0() + .right_0() + .bottom_0() + .child(Scrollbar::vertical(&scroll_handle)), + ) .into_any_element() }; @@ -335,41 +393,35 @@ impl CommitDiffView { ) }), ) - .child( - div() - .id("commit-diff-body") - .flex_1() - .min_h_0() - .overflow_scroll() - .child(body), - ) + .child(div().id("commit-diff-body").flex_1().min_h_0().child(body)) .into_any_element() } - /// One hunk: the `@@ -a,b +c,d @@` header row followed by its lines. - fn render_hunk(hunk: &DiffHunk, cx: &App) -> AnyElement { - v_flex() - .w_full() - .child( - div() - .px_2() - .py_0p5() - .w_full() - .bg(cx.theme().muted) - .border_y(px(1.)) - .border_color(cx.theme().border) - .text_color(cx.theme().muted_foreground) - .child(SharedString::from(format!( - "@@ -{},{} +{},{} @@", - hunk.old_start, hunk.old_lines, hunk.new_start, hunk.new_lines - ))), - ) - .children( - hunk.lines - .iter() - .map(|line| Self::render_diff_line(line, cx)), - ) - .into_any_element() + /// One row of the virtual diff list: a hunk header or a single line. + fn render_diff_row(hunks: &[DiffHunk], row: DiffRow, cx: &App) -> AnyElement { + match row { + DiffRow::Hunk { + old_start, + old_lines, + new_start, + new_lines, + } => div() + .px_2() + .w_full() + .h(px(DIFF_ROW_HEIGHT)) + .font_family(cx.theme().mono_font_family.clone()) + .text_xs() + .bg(cx.theme().muted) + .border_y(px(1.)) + .border_color(cx.theme().border) + .text_color(cx.theme().muted_foreground) + .child(SharedString::from(format!( + "@@ -{},{} +{},{} @@", + old_start, old_lines, new_start, new_lines + ))) + .into_any_element(), + DiffRow::Line { hunk, line } => Self::render_diff_line(&hunks[hunk].lines[line], cx), + } } /// One diff line: old and new line numbers in gutters, then the content, @@ -382,8 +434,14 @@ impl CommitDiffView { }; let gutter = cx.theme().muted_foreground; + // Fixed height and nowrap: the virtual list assumes every row has + // the same height, so long lines are clipped instead of wrapped. h_flex() .w_full() + .h(px(DIFF_ROW_HEIGHT)) + .items_center() + .font_family(cx.theme().mono_font_family.clone()) + .text_xs() .when_some(bg, |this, bg| this.bg(bg)) .child( div() @@ -407,6 +465,8 @@ impl CommitDiffView { div() .flex_1() .min_w_0() + .overflow_hidden() + .whitespace_nowrap() .text_color(cx.theme().foreground) .child(line.text.clone()), ) @@ -414,7 +474,7 @@ impl CommitDiffView { } /// Header: commit id, summary, author/time and overall change stats. - fn render_header(&mut self, cx: &mut Context) -> AnyElement { + fn render_header(&self, cx: &mut Context) -> AnyElement { let commit = &self.commit; let (files, insertions, deletions) = self.diff.as_ref().map_or((0, 0, 0), |diff| { ( @@ -484,10 +544,11 @@ impl CommitDiffView { Tag::danger() .outline() .small() - .child(format!("- {insertions}")), + .child(format!("- {deletions}")), ) }), ) + .overflow_y_scrollbar() .into_any_element() } } @@ -504,6 +565,24 @@ fn find_item<'a>(items: &'a [TreeItem], id: Option<&str>) -> Option<&'a TreeItem }) } +/// The rows of `file`'s diff: one header row per hunk, then its lines. +fn diff_rows(file: &FileDiff) -> Vec { + let mut rows = Vec::new(); + for (hunk_ix, hunk) in file.hunks.iter().enumerate() { + rows.push(DiffRow::Hunk { + old_start: hunk.old_start, + old_lines: hunk.old_lines, + new_start: hunk.new_start, + new_lines: hunk.new_lines, + }); + rows.extend((0..hunk.lines.len()).map(|line| DiffRow::Line { + hunk: hunk_ix, + line, + })); + } + rows +} + impl Panel for CommitDiffView { fn panel_name(&self) -> &'static str { "commit_diff" diff --git a/crates/workspace/src/views/repo_detail/helpers.rs b/crates/workspace/src/views/repo_detail/helpers.rs index 050771d..d4dbc8f 100644 --- a/crates/workspace/src/views/repo_detail/helpers.rs +++ b/crates/workspace/src/views/repo_detail/helpers.rs @@ -4,10 +4,12 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; +use anyhow::Error; use gpui::prelude::*; -use gpui::{AnyElement, App, div}; -use gpui_component::tree::TreeItem; -use gpui_component::{ActiveTheme, v_flex}; +use gpui::{AnyElement, App, Task, Window, div, px}; +use gpui_component::list::ListItem; +use gpui_component::tree::{TreeEntry, TreeItem}; +use gpui_component::{ActiveTheme, Icon, IconName, Sizable, h_flex, v_flex}; /// A `Send` file-tree node: the tree is built on a background thread and /// converted into [`TreeItem`]s (which hold `Rc` state, so they cannot @@ -20,14 +22,6 @@ pub(super) struct TreeItemSeed { children: Vec, } -impl From for TreeItem { - fn from(seed: TreeItemSeed) -> Self { - let mut item = TreeItem::new(seed.id, seed.label); - item.children = seed.children.into_iter().map(Into::into).collect(); - item - } -} - /// Convert tree seeds into [`TreeItem`]s, expanding every folder when /// `expand_folders` is set. /// @@ -54,6 +48,51 @@ pub(super) fn tree_items(seeds: Vec, expand_folders: bool) -> Vec< .collect() } +/// One row of a file tree: icon + name, indented by depth. Clicking a file +/// runs `on_click`; folders expand/collapse via the tree itself. +pub(super) fn tree_row(ix: usize, entry: &TreeEntry, selected: bool, on_click: F) -> ListItem +where + F: Fn(&mut Window, &mut App) + 'static, +{ + let item = entry.item(); + let is_folder = entry.is_folder(); + + let icon = if is_folder { + if entry.is_expanded() { + IconName::FolderOpen + } else { + IconName::FolderClosed + } + } else { + IconName::File + }; + + 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; + } + on_click(window, cx); + }) +} + +/// Track `task` until it completes; finished tasks are pruned on every push +/// so the vec stays bounded by the number of in-flight loads. +pub(super) fn track(tasks: &mut Vec>>, task: Task>) { + tasks.retain(|task| !task.is_ready()); + tasks.push(task); +} + /// Build nested tree items from a flat, sorted (dirs-first) entry list. /// /// Returns [`TreeItemSeed`]s so the build can run off the main thread; a @@ -259,10 +298,7 @@ mod tests { PathBuf::from("README.md"), ]; - let items: Vec = build_tree_items(&entries) - .into_iter() - .map(Into::into) - .collect(); + let items: Vec = tree_items(build_tree_items(&entries), false); assert_eq!(items.len(), 2); assert_eq!(items[0].label, "src"); assert_eq!(items[0].children.len(), 1); diff --git a/crates/workspace/src/views/repo_detail/mod.rs b/crates/workspace/src/views/repo_detail/mod.rs index d602cb1..c1274cb 100644 --- a/crates/workspace/src/views/repo_detail/mod.rs +++ b/crates/workspace/src/views/repo_detail/mod.rs @@ -5,6 +5,7 @@ use std::sync::Arc; use anyhow::Error; use assets::CustomIconName; +use gix::Repository; use gpui::prelude::*; use gpui::{ AnyElement, App, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, @@ -17,7 +18,7 @@ use gpui_component::menu::PopupMenuItem; use gpui_component::searchable_list::SearchableVec; use gpui_component::tab::{Tab, TabBar}; use gpui_component::tag::Tag; -use gpui_component::tree::{TreeItem, TreeState}; +use gpui_component::tree::TreeState; use gpui_component::{ ActiveTheme, Icon, IconName, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex, }; @@ -36,7 +37,7 @@ use browser::{ }; use commits::COMMIT_ROW_HEIGHT; use diff::CommitDiffView; -use helpers::{build_tree_items, is_markdown_path}; +use helpers::{TreeItemSeed, build_tree_items, is_markdown_path, track, tree_items}; /// What kind of ref the header selectors switch to. #[derive(Clone, Copy, PartialEq, Eq)] @@ -47,6 +48,20 @@ enum RefKind { 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 { @@ -261,7 +276,11 @@ impl RepoDetailView { } } - /// Clone (or fetch) the repository and populate the file explorer. + /// 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; @@ -270,115 +289,186 @@ impl RepoDetailView { 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 load = cx.background_spawn(async move { - let repo = cache.ensure_clone(&addr, &clone_urls)?; - let entries = signed_git::worktree_entries(&repo)?; - // 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. - 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(worktree) => ( - signed_git::worktree_branches(worktree).unwrap_or_default(), - signed_git::worktree_tags(worktree).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::<_, Error>(( - tree, - readme_path, - readme, - worktree, - branches, - tags, - current_branch, - head_commit, - )) - }); + 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 result = load.await; + 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 result { - Ok(( - tree, - readme_path, - readme, - Some(worktree), - branches, - tags, - current_branch, - head_commit, - )) => { - this.worktree = Some(worktree); - this.head_commit = head_commit; - this.tree_state.update(cx, |state, cx| { - state.set_items( - tree.into_iter().map(Into::into).collect::>(), - 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(); - 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.load_all_commits(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()); - } + 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(()) }); - self.track(task); + 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) || self.loading_files.contains(path) { + 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; } @@ -435,8 +525,11 @@ impl RepoDetailView { this.update_in(cx, |this, window, cx| { // The worktree was switched while this file was reading; - // the result belongs to the previous branch. + // 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); @@ -473,7 +566,7 @@ impl RepoDetailView { Ok(()) }); - self.track(task); + track(&mut self.tasks, task); } /// Queue `path` for the per-file commit query; requests are batched into @@ -517,18 +610,19 @@ impl RepoDetailView { .await; this.update(cx, |this, cx| { - if generation != this.ref_generation { - return; - } this.loading_commits = false; - if let Ok(found) = result { + 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. + // 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); } @@ -538,7 +632,7 @@ impl RepoDetailView { Ok(()) }); - self.track(task); + track(&mut self.tasks, task); } /// Walk all commits reachable from HEAD on a background task, for the @@ -562,7 +656,10 @@ impl RepoDetailView { .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 { @@ -577,36 +674,30 @@ impl RepoDetailView { Ok(()) }); - self.track(task); + track(&mut self.tasks, task); } - /// Open a new panel showing the diff of `commit` (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: &FileCommit, - window: &mut Window, - cx: &mut Context, - ) { + /// 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; }; - // Same display name as the repo detail panel's title. - let announcement = self.announcement.as_ref().unwrap_or(&self.initial); - let repo_name = announcement - .name - .clone() - .unwrap_or_else(|| SharedString::from(announcement.id.clone())); - let panel = - cx.new(|cx| CommitDiffView::new(worktree, repo_name, commit.clone(), window, cx)); + let Some(dock_area) = self.dock_area.upgrade() else { + return; + }; - if let Some(dock_area) = self.dock_area.upgrade() { - dock_area.update(cx, |dock_area, cx| { - dock_area.add_panel(Arc::new(panel), DockPlacement::Bottom, None, window, cx); - }); - } + // Same display name as the repo detail panel's title. + let repo_name = self.display_name(); + + 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); + }); } /// Check out `name` (a branch or tag picked in the header) and refresh @@ -678,7 +769,7 @@ impl RepoDetailView { Ok(()) }); - self.track(task); + track(&mut self.tasks, task); } /// Restore a selector to `previous`, or clear it (after a failed switch). @@ -760,10 +851,7 @@ impl RepoDetailView { // previous branch are gone, and with them the // expansion state. this.tree_state.update(cx, |state, cx| { - state.set_items( - tree.into_iter().map(Into::into).collect::>(), - cx, - ); + state.set_items(tree_items(tree, false), cx); }); // Drop cached previews and commits of the old branch. @@ -805,14 +893,7 @@ impl RepoDetailView { Ok(()) }); - self.track(task); - } - - /// Track `task` until it completes; finished tasks are pruned on every - /// push so the vec stays bounded by the number of in-flight loads. - fn track(&mut self, task: Task>) { - self.tasks.retain(|task| !task.is_ready()); - self.tasks.push(task); + track(&mut self.tasks, task); } /// Drop the oldest previews beyond the cache caps, keeping the currently @@ -844,6 +925,20 @@ impl RepoDetailView { self.commits.remove(&path); } } + + /// The latest announcement from the store, or the open-time snapshot. + fn announcement(&self) -> &Announcement { + self.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) -> SharedString { + let announcement = self.announcement(); + announcement + .name + .clone() + .unwrap_or_else(|| SharedString::from(announcement.id.clone())) + } } impl Panel for RepoDetailView { @@ -852,15 +947,46 @@ impl Panel for RepoDetailView { } fn title(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - let announcement = self.announcement.as_ref().unwrap_or(&self.initial); - - announcement - .name - .clone() - .unwrap_or_else(|| SharedString::from(announcement.id.clone())) + self.display_name() } } +/// 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, + }) +} + impl EventEmitter for RepoDetailView {} impl Focusable for RepoDetailView { @@ -874,250 +1000,239 @@ impl Render for RepoDetailView { let tree_state = self.tree_state.clone(); let view = cx.entity().downgrade(); - let announcement = self.announcement.as_ref().unwrap_or(&self.initial); - let relays = self.relays.clone(); - let web = self.web.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 commits_count = self.all_commits.as_ref().map(|list| list.total); - let worktree_empty = self.switching_ref || self.worktree.is_none(); - v_flex() .id("repo") .size_full() - .child( - 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() - .text_xs() - .text_color(cx.theme().muted_foreground) - .line_clamp(3) - .text_ellipsis() - .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() - .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( - 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, - ) - }), - ), - ) - .child( - Button::new("enc") - .secondary() - .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 commit = commit.clone(); - this.open_commit_diff(&commit, window, cx); - } - })), - ), - ), - ), - ) + .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_tree_column(tree_state, view, cx)) .child(self.render_content_column(pane_title, cx)) .into_any_element(), _ => self.render_commits_tab(cx), }) } } + +impl RepoDetailView { + /// Header: repository name and description, relay/web/clone buttons, the + /// Files/Commits tab bar and the branch/tag selectors with the + /// latest-commit button. + fn render_header(&self, cx: &mut Context) -> AnyElement { + let announcement = self.announcement(); + let relays = self.relays.clone(); + let web = self.web.clone(); + + let name = self.display_name(); + let description = announcement + .description + .clone() + .unwrap_or(SharedString::from("No 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() + .text_xs() + .text_color(cx.theme().muted_foreground) + .line_clamp(3) + .text_ellipsis() + .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() + .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( + 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) + }), + ), + ) + .child( + Button::new("enc") + .secondary() + .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); + } + })), + ), + ), + ) + .into_any_element() + } +}