From 33e9429cd04e39bd8ce7f6758570ebc2aeb91ede Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sun, 13 Sep 2026 15:17:51 +0700 Subject: [PATCH] update --- crates/signed_ui/src/lib.rs | 2 + crates/signed_ui/src/ref_selector.rs | 41 + crates/workspace/src/views/commit_diff/mod.rs | 185 ++++- crates/workspace/src/views/discussion.rs | 217 ++++++ crates/workspace/src/views/issues/detail.rs | 2 +- crates/workspace/src/views/mod.rs | 2 + .../src/views/pull_requests/detail.rs | 2 +- .../workspace/src/views/pull_requests/new.rs | 5 +- crates/workspace/src/views/repo/files.rs | 65 +- crates/workspace/src/views/repo/header.rs | 78 +- crates/workspace/src/views/repo/helpers.rs | 721 ------------------ crates/workspace/src/views/repo/history.rs | 3 +- crates/workspace/src/views/repo/loading.rs | 4 +- crates/workspace/src/views/repo/mod.rs | 1 - crates/workspace/src/views/repo/refs.rs | 2 +- crates/workspace/src/views/tree.rs | 153 ++++ docs/repo-view-refactor-plan.md | 291 +++++++ 17 files changed, 1032 insertions(+), 742 deletions(-) create mode 100644 crates/signed_ui/src/ref_selector.rs create mode 100644 crates/workspace/src/views/discussion.rs delete mode 100644 crates/workspace/src/views/repo/helpers.rs create mode 100644 crates/workspace/src/views/tree.rs create mode 100644 docs/repo-view-refactor-plan.md diff --git a/crates/signed_ui/src/lib.rs b/crates/signed_ui/src/lib.rs index 7f63693..7c63981 100644 --- a/crates/signed_ui/src/lib.rs +++ b/crates/signed_ui/src/lib.rs @@ -2,6 +2,7 @@ mod dropdown_button; mod nav_item; mod pixel_avatar; mod placeholder; +mod ref_selector; mod segment_button; mod setting; mod status_badge; @@ -17,6 +18,7 @@ pub use dropdown_button::DropdownButton; pub use nav_item::NavItem; pub use pixel_avatar::PixelAvatar; pub use placeholder::placeholder; +pub use ref_selector::ref_selector_trigger; pub use segment_button::{CountBadge, SegmentButton}; pub use setting::{SelectOption, setting_block, setting_row}; pub use status_badge::status_badge; diff --git a/crates/signed_ui/src/ref_selector.rs b/crates/signed_ui/src/ref_selector.rs new file mode 100644 index 0000000..1b508d0 --- /dev/null +++ b/crates/signed_ui/src/ref_selector.rs @@ -0,0 +1,41 @@ +use assets::CustomIconName; +use gpui::prelude::*; +use gpui::{AnyElement, App, SharedString, div}; +use gpui_component::combobox::{Caret, ComboboxTriggerContext}; +use gpui_component::searchable_list::SearchableVec; +use gpui_component::{ActiveTheme, Icon, Sizable, h_flex}; + +/// The kind icon, the selection or placeholder, and the caret. `Combobox` +/// replaces its default trigger entirely, the only way to show an icon inside it. +pub fn ref_selector_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() +} diff --git a/crates/workspace/src/views/commit_diff/mod.rs b/crates/workspace/src/views/commit_diff/mod.rs index 6921166..98ad225 100644 --- a/crates/workspace/src/views/commit_diff/mod.rs +++ b/crates/workspace/src/views/commit_diff/mod.rs @@ -13,17 +13,15 @@ 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, TreeState, tree}; +use gpui_component::tree::{TreeEntry, TreeItem, TreeState, tree}; use gpui_component::{ ActiveTheme, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list, }; -use signed_git::{CommitDiff, DiffStatus, FileCommit, FileDiff}; +use signed_git::{CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileCommit, FileDiff}; use signed_ui::{placeholder, tree_row}; use utils::relative_time_secs; -use crate::views::repo::helpers::{ - DIFF_ROW_HEIGHT, DiffRow, build_tree_items, diff_rows, find_item, render_diff_row, tree_items, -}; +use crate::views::tree::{build_tree_items, tree_items}; const TREE_WIDTH: f32 = 260.; @@ -510,3 +508,180 @@ impl Render for CommitDiffView { .child(resizable_panel().child(body)) } } + +const GUTTER_WIDTH: f32 = 44.; +const DIFF_ROW_HEIGHT: f32 = 20.; + +#[derive(Clone, Copy)] +enum DiffRow { + Hunk { + old_start: u32, + old_lines: u32, + new_start: u32, + new_lines: u32, + }, + Line { + hunk: usize, + line: usize, + }, +} + +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 +} + +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 } => render_diff_line(&hunks[hunk].lines[line], cx), + } +} + +fn render_diff_line(line: &DiffLine, cx: &App) -> AnyElement { + let bg = match line.kind { + DiffLineKind::Addition => Some(cx.theme().success.opacity(0.2)), + DiffLineKind::Deletion => Some(cx.theme().danger.opacity(0.2)), + DiffLineKind::Context => None, + }; + let gutter = cx.theme().muted_foreground; + + // Fixed height and nowrap, the virtual list assumes every row has the same height. + // 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() + .w(px(GUTTER_WIDTH)) + .flex_none() + .pr_2() + .text_right() + .text_color(gutter) + .child(line.old.map(|n| n.to_string()).unwrap_or_default()), + ) + .child( + div() + .w(px(GUTTER_WIDTH)) + .flex_none() + .pr_2() + .text_right() + .text_color(gutter) + .child(line.new.map(|n| n.to_string()).unwrap_or_default()), + ) + .child( + div() + .flex_1() + .min_w_0() + .overflow_hidden() + .whitespace_nowrap() + .text_color(cx.theme().foreground) + .child(line.text.clone()), + ) + .into_any_element() +} + +fn find_item<'a>(items: &'a [TreeItem], id: Option<&str>) -> Option<&'a TreeItem> { + let id = id?; + items.iter().find_map(|item| { + if item.id.as_ref() == id { + Some(item) + } else { + find_item(&item.children, Some(id)) + } + }) +} + +pub(crate) const COMMIT_ROW_HEIGHT: f32 = 56.; + +pub(crate) fn commit_row( + ix: usize, + commit: &FileCommit, + on_click: impl Fn(&mut Window, &mut App) + 'static, + cx: &App, +) -> AnyElement { + h_flex() + .id(ix) + .px_4() + .h(px(COMMIT_ROW_HEIGHT)) + .w_full() + .gap_3() + .items_center() + .border_b(px(1.)) + .border_color(cx.theme().border) + .hover(|this| this.bg(cx.theme().list_hover)) + .child( + v_flex() + .flex_1() + .min_w_0() + .gap_0p5() + .justify_center() + .child( + h_flex() + .gap_2() + .items_center() + .overflow_hidden() + .child( + div() + .font_family(cx.theme().mono_font_family.clone()) + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(commit.id.clone()), + ) + .child( + div() + .flex_1() + .min_w_0() + .text_sm() + .text_ellipsis() + .whitespace_nowrap() + .child(commit.summary.clone()), + ), + ) + .child( + h_flex() + .gap_2() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(commit.author.clone()) + .child(relative_time_secs(commit.time)), + ), + ) + .on_click(move |_event, window, cx| on_click(window, cx)) + .into_any_element() +} diff --git a/crates/workspace/src/views/discussion.rs b/crates/workspace/src/views/discussion.rs new file mode 100644 index 0000000..8f52a56 --- /dev/null +++ b/crates/workspace/src/views/discussion.rs @@ -0,0 +1,217 @@ +use assets::CustomIconName; +use gpui::prelude::*; +use gpui::{AnyElement, App, Entity, SharedString, div, px}; +use gpui_component::button::{Button, ButtonVariants}; +use gpui_component::input::{Textarea, TextareaState}; +use gpui_component::tag::Tag; +use gpui_component::{ActiveTheme, Icon, Sizable, StyledExt, h_flex, v_flex}; +use nostr::prelude::{Event, EventId, PublicKey}; +use signed_state::{ProfileStore, RepoStore}; +use signed_ui::UserAvatar; +use utils::relative_time; + +pub(crate) fn issue_roots(store: &RepoStore) -> &[Event] { + &store.issues +} + +pub(crate) fn pr_roots(store: &RepoStore) -> &[Event] { + &store.pull_requests +} + +fn sidebar_title(text: &str, cx: &App) -> AnyElement { + div() + .text_xs() + .font_semibold() + .text_color(cx.theme().muted_foreground) + .child(text.to_string()) + .into_any_element() +} + +pub(crate) fn sidebar_section( + store: &Entity, + id: EventId, + roots: fn(&RepoStore) -> &[Event], + top_gap: bool, + cx: &App, +) -> AnyElement { + let store = store.read(cx); + let Some(root) = roots(store).iter().find(|event| event.id == id) else { + // The caller bails out when the root is missing. + return div().into_any_element(); + }; + let profile_store = ProfileStore::global(cx); + + // Participants, the root author plus everyone who commented. + let mut participants: Vec = vec![root.pubkey]; + participants.extend(store.comments_of(&root.id).map(|comment| comment.pubkey)); + participants.sort_by_key(PublicKey::to_hex); + participants.dedup(); + + // Labels are NIP-34 `t` hashtag tags on the event. + let labels: Vec = root.tags.hashtags().map(|tag| tag.to_string()).collect(); + + v_flex() + .w(px(240.)) + .h_full() + .flex_none() + .px_4() + .gap_4() + .border_l(px(1.)) + .border_color(cx.theme().sidebar_border) + .child( + v_flex() + .when(top_gap, |this| this.mt_4()) + .gap_2() + .child(sidebar_title("Participants", cx)) + .children(participants.iter().map(|pubkey| { + let profile = profile_store.read(cx).get(pubkey); + let name = profile.name(); + let picture = profile.picture(); + + h_flex() + .gap_1() + .items_center() + .child(UserAvatar::new(name.clone()).picture(picture)) + .child(div().text_sm().truncate().text_ellipsis().child(name)) + .into_any_element() + })), + ) + .child( + v_flex() + .gap_2() + .child(sidebar_title("Labels", cx)) + .map(|this| { + if labels.is_empty() { + this.child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child("None yet."), + ) + } else { + this.child(h_flex().gap_1().children({ + let mut items = vec![]; + + for label in labels.iter() { + items.push( + Tag::secondary() + .outline() + .xsmall() + .child(SharedString::from(label)), + ); + } + + items + })) + } + }), + ) + .into_any_element() +} + +pub(crate) fn comments_section(store: &Entity, root: EventId, cx: &App) -> AnyElement { + let store = store.read(cx); + let comments: Vec<&Event> = store.comments_of(&root).collect(); + let title = SharedString::from(format!("Discussions {}", comments.len())); + + v_flex() + .gap_4() + .child(div().text_xs().font_semibold().child(title)) + .children(comments.iter().map(|comment| { + let profile = ProfileStore::global(cx).read(cx).get(&comment.pubkey); + let author = profile.name(); + let picture = profile.picture(); + let age = relative_time(comment.created_at); + let content = SharedString::from(comment.content.as_str()); + + v_flex() + .gap_1() + .p_3() + .border_1() + .border_color(cx.theme().border) + .rounded(cx.theme().radius) + .child( + h_flex() + .gap_2() + .text_sm() + .child( + h_flex() + .gap_1() + .child(UserAvatar::new(author.clone()).picture(picture)) + .child(author), + ) + .child( + div() + .text_color(cx.theme().muted_foreground) + .child("commented"), + ) + .child( + div() + .text_color(cx.theme().muted_foreground) + .child(SharedString::from(age)), + ), + ) + .child(div().text_sm().child(content)) + })) + .into_any_element() +} + +/// `roots` selects the root's list within the store, issues or pull requests. +pub(crate) fn comment_form( + store: &Entity, + root: EventId, + roots: fn(&RepoStore) -> &[Event], + comment_input: &Entity, + button_id: &'static str, + cx: &App, +) -> AnyElement { + let comment_input = comment_input.clone(); + let store = store.clone(); + + v_flex() + .gap_2() + .child( + Textarea::new(&comment_input) + .h_24() + .text_color(cx.theme().muted_foreground) + .bg(cx.theme().muted), + ) + .child( + h_flex() + .justify_between() + .child( + h_flex() + .gap_1() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(Icon::new(CustomIconName::Markdown).small()) + .child("Markdown is supported"), + ) + .child( + Button::new(button_id) + .primary() + .label("Comment") + .tooltip("Post comment") + .on_click(move |_event, window, cx| { + let content = comment_input.read(cx).value().trim().to_string(); + if content.is_empty() { + return; + } + let Some(root) = roots(store.read(cx)) + .iter() + .find(|event| event.id == root) + .cloned() + else { + return; + }; + store.update(cx, |store, cx| { + store.comment(&root, content, cx); + }); + comment_input.update(cx, |input, cx| { + input.set_value("", window, cx); + }); + }), + ), + ) + .into_any_element() +} diff --git a/crates/workspace/src/views/issues/detail.rs b/crates/workspace/src/views/issues/detail.rs index a714407..ecb78cb 100644 --- a/crates/workspace/src/views/issues/detail.rs +++ b/crates/workspace/src/views/issues/detail.rs @@ -13,7 +13,7 @@ use signed_state::{ProfileStore, RepoStore}; use signed_ui::{UserAvatar, placeholder, status_badge}; use utils::relative_time; -use crate::views::repo::helpers::{comment_form, comments_section, issue_roots, sidebar_section}; +use crate::views::discussion::{comment_form, comments_section, issue_roots, sidebar_section}; pub struct IssueDetailView { focus_handle: FocusHandle, diff --git a/crates/workspace/src/views/mod.rs b/crates/workspace/src/views/mod.rs index a098cb4..ba027ad 100644 --- a/crates/workspace/src/views/mod.rs +++ b/crates/workspace/src/views/mod.rs @@ -1,5 +1,6 @@ mod commit_diff; mod dialog_state; +pub(crate) mod discussion; mod inbox; mod issues; mod pull_requests; @@ -7,6 +8,7 @@ mod repo; mod repo_list; mod send_patch; pub(crate) mod sidebar; +pub(crate) mod tree; pub use inbox::InboxView; pub use repo::RepoDetailView; diff --git a/crates/workspace/src/views/pull_requests/detail.rs b/crates/workspace/src/views/pull_requests/detail.rs index 93b8508..392ad41 100644 --- a/crates/workspace/src/views/pull_requests/detail.rs +++ b/crates/workspace/src/views/pull_requests/detail.rs @@ -31,7 +31,7 @@ use signed_ui::{CountBadge, UserAvatar, placeholder, status_badge}; use utils::{relative_time, relative_time_secs}; use crate::views::commit_diff::{CommitDiffView, DiffPane}; -use crate::views::repo::helpers::{comment_form, comments_section, pr_roots, sidebar_section}; +use crate::views::discussion::{comment_form, comments_section, pr_roots, sidebar_section}; const ROW_HEIGHT: f32 = 37.; diff --git a/crates/workspace/src/views/pull_requests/new.rs b/crates/workspace/src/views/pull_requests/new.rs index 5a5d006..2241dee 100644 --- a/crates/workspace/src/views/pull_requests/new.rs +++ b/crates/workspace/src/views/pull_requests/new.rs @@ -27,10 +27,9 @@ use signed_git::{ worktree_commit_range_commits, worktree_commit_range_diff, }; use signed_state::{Backend, CheckoutsStore, GitStore, RepoListStore, RepoStore}; -use signed_ui::{CountBadge, placeholder}; +use signed_ui::{CountBadge, placeholder, ref_selector_trigger}; -use crate::views::commit_diff::{CommitDiffView, DiffPane}; -use crate::views::repo::helpers::{COMMIT_ROW_HEIGHT, commit_row, ref_selector_trigger}; +use crate::views::commit_diff::{COMMIT_ROW_HEIGHT, CommitDiffView, DiffPane, commit_row}; pub struct NewPullRequestView { focus_handle: FocusHandle, diff --git a/crates/workspace/src/views/repo/files.rs b/crates/workspace/src/views/repo/files.rs index 16c8cd4..d9c85d2 100644 --- a/crates/workspace/src/views/repo/files.rs +++ b/crates/workspace/src/views/repo/files.rs @@ -13,7 +13,6 @@ use gpui_component::{ActiveTheme, Sizable, StyledExt, h_flex, v_flex}; use signed_ui::{placeholder, tree_row}; use super::RepoDetailView; -use crate::views::repo::helpers::{code_language, is_markdown_path}; const TREE_WIDTH: f32 = 240.; pub(super) const MAX_PREVIEW_BYTES: usize = 1024 * 1024; @@ -477,3 +476,67 @@ impl RepoDetailView { } } } + +/// The markdown fence language for a file path, or `None` for plain text. +fn code_language(path: &str) -> Option<&'static str> { + let name = Path::new(path) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or_default(); + + // Some common files are recognized by name rather than extension. + match name { + "Makefile" | "makefile" => return Some("make"), + "CMakeLists.txt" => return Some("cmake"), + _ => {} + } + + let ext = Path::new(path).extension()?.to_str()?.to_ascii_lowercase(); + Some(match ext.as_str() { + "rs" => "rust", + "toml" => "toml", + "json" | "jsonc" => "json", + "py" => "python", + "js" | "mjs" | "cjs" => "javascript", + "ts" | "mts" | "cts" => "typescript", + "tsx" | "jsx" => "tsx", + "go" => "go", + "c" | "h" => "c", + "cc" | "cpp" | "cxx" | "hh" | "hpp" | "hxx" => "cpp", + "cs" => "csharp", + "java" => "java", + "kt" | "kts" | "ktm" => "kotlin", + "swift" => "swift", + "php" | "phtml" => "php", + "rb" => "ruby", + "sh" | "bash" | "zsh" => "bash", + "yml" | "yaml" => "yaml", + "css" | "scss" | "sass" => "css", + "html" | "htm" => "html", + "lua" => "lua", + "sql" => "sql", + "proto" | "protobuf" => "proto", + "cmake" => "cmake", + "zig" => "zig", + "ex" | "exs" => "elixir", + "graphql" | "gql" => "graphql", + "diff" | "patch" => "diff", + "svelte" => "svelte", + "astro" => "astro", + "scala" => "scala", + _ => return None, + }) +} + +/// Whether a file path has a markdown extension. +fn is_markdown_path(path: &str) -> bool { + Path::new(path) + .extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| { + matches!( + ext.to_ascii_lowercase().as_str(), + "md" | "markdown" | "mdown" | "mkdn" + ) + }) +} diff --git a/crates/workspace/src/views/repo/header.rs b/crates/workspace/src/views/repo/header.rs index f30e060..e0560df 100644 --- a/crates/workspace/src/views/repo/header.rs +++ b/crates/workspace/src/views/repo/header.rs @@ -7,20 +7,23 @@ use gpui::{Anchor, AnyElement, ClipboardItem, Context, SharedString, div, px, re use gpui_base::{Button as BaseButton, Disableable, Popover}; use gpui_component::button::{Button, ButtonVariants}; use gpui_component::combobox::Combobox; -use gpui_component::menu::DropdownMenu; +use gpui_component::menu::{DropdownMenu, PopupMenu}; use gpui_component::{ ActiveTheme, Colorize, Icon, IconName, Sizable, StyledExt, ThemeStyled, h_flex, v_flex, }; +use nostr::nips::nip19::Nip19Coordinate; use nostr::prelude::{RelayUrl, ToBech32}; use signed_core::Announcement; use signed_state::{Backend, ProfileStore, RepoListStore}; -use signed_ui::{CountBadge, DropdownButton, PixelAvatar, UserAvatar, copy_row}; +use signed_ui::{ + CountBadge, DropdownButton, PixelAvatar, UserAvatar, copy_row, menu_copy_row, middle_truncate, + ref_selector_trigger, +}; use super::{RepoAction, RepoDetailView}; use crate::views::issues::open_new_issue_dialog; use crate::views::pull_requests::new::open_new_pull_panel; use crate::views::repo::about::open_about_dialog; -use crate::views::repo::helpers::{ShareTargets, ref_selector_trigger}; use crate::views::send_patch::open_send_patch_panel; impl RepoDetailView { @@ -712,3 +715,72 @@ fn fork_row(announcement: &Announcement, cx: &mut Context) -> Op row.into_any_element() }) } + +struct ShareTargets { + /// NIP-19 `naddr1...` of the announcement, with its announced relays. + naddr: String, + /// Hex ID of the announcement event itself. + event_id: String, + /// NIP-34 coordinate `30617::`. + coordinate: String, + /// `https://gitworkshop.dev/` + gitworkshop: String, + /// `https://ditto.pub/` + ditto: String, +} + +impl ShareTargets { + fn from_announcement(announcement: &Announcement) -> Self { + let addr = announcement.addr(); + let coordinate = addr.to_string(); + let naddr = Nip19Coordinate::new(addr, announcement.relays.iter().cloned()) + .to_bech32() + .expect("a complete coordinate always encodes to naddr"); + + Self { + naddr: naddr.clone(), + event_id: announcement.event_id.to_bech32().unwrap(), + coordinate, + gitworkshop: format!("https://gitworkshop.dev/{naddr}"), + ditto: format!("https://ditto.pub/{naddr}"), + } + } + + fn menu(&self, menu: PopupMenu) -> PopupMenu { + menu.min_w(px(340.)) + .item(menu_copy_row( + "copy-gitworkshop", + "GitWorkshop", + truncate_naddr_link(&self.gitworkshop, 4), + self.gitworkshop.clone(), + )) + .item(menu_copy_row( + "copy-ditto", + "Ditto", + truncate_naddr_link(&self.ditto, 4), + self.ditto.clone(), + )) + .item(menu_copy_row( + "copy-event-id", + "Event ID", + middle_truncate(&self.event_id, 10, 10), + self.event_id.clone(), + )) + .item(menu_copy_row( + "copy-coordinate", + "Coordinate", + middle_truncate(&self.coordinate, 10, 10), + self.coordinate.clone(), + )) + } +} + +fn truncate_naddr_link(url: &str, tail: usize) -> String { + let Some(end) = url.find("naddr1").map(|i| i + "naddr1".len()) else { + return url.to_string(); + }; + if url.len() - end <= tail + 3 { + return url.to_string(); + } + format!("{}...{}", &url[..end], &url[url.len() - tail..]) +} diff --git a/crates/workspace/src/views/repo/helpers.rs b/crates/workspace/src/views/repo/helpers.rs deleted file mode 100644 index e29e82a..0000000 --- a/crates/workspace/src/views/repo/helpers.rs +++ /dev/null @@ -1,721 +0,0 @@ -use std::collections::HashMap; -use std::path::{Path, PathBuf}; - -use assets::CustomIconName; -use gpui::prelude::*; -use gpui::{AnyElement, App, Entity, SharedString, Window, div, px}; -use gpui_component::button::{Button, ButtonVariants}; -use gpui_component::combobox::{Caret, ComboboxTriggerContext}; -use gpui_component::input::{Textarea, TextareaState}; -use gpui_component::menu::PopupMenu; -use gpui_component::searchable_list::SearchableVec; -use gpui_component::tag::Tag; -use gpui_component::tree::TreeItem; -use gpui_component::{ActiveTheme, Icon, Sizable, StyledExt, h_flex, v_flex}; -use nostr::nips::nip19::{Nip19Coordinate, ToBech32}; -use nostr::prelude::{Event, EventId, PublicKey}; -use signed_core::Announcement; -use signed_git::{DiffHunk, DiffLine, DiffLineKind, FileCommit, FileDiff}; -use signed_state::{ProfileStore, RepoStore}; -use signed_ui::{UserAvatar, menu_copy_row, middle_truncate}; -use utils::{relative_time, relative_time_secs}; - -pub(crate) struct TreeItemSeed { - /// Path of the node, relative to the worktree root. - id: String, - label: String, - children: Vec, -} - -pub(crate) fn tree_items(seeds: Vec, expand_folders: bool) -> Vec { - fn convert(seed: TreeItemSeed, expand_folders: bool) -> TreeItem { - let mut item = TreeItem::new(seed.id, seed.label); - if expand_folders && !seed.children.is_empty() { - item = item.expanded(true); - } - item.children = seed - .children - .into_iter() - .map(|seed| convert(seed, expand_folders)) - .collect(); - item - } - - seeds - .into_iter() - .map(|seed| convert(seed, expand_folders)) - .collect() -} - -/// Build nested tree items from a flat entry list sorted dirs-first. -pub(crate) fn build_tree_items(entries: &[PathBuf]) -> Vec { - // Node indices by full path, so parents resolve in constant time while inserting. - let mut index: HashMap = HashMap::new(); - let mut nodes: Vec<(String, String, Vec)> = Vec::new(); - let mut roots: Vec = Vec::new(); - - for entry in entries { - let mut parent: Option = None; - let mut path = String::new(); - for part in entry.components() { - let label = part.as_os_str().to_string_lossy().into_owned(); - path = if path.is_empty() { - label.clone() - } else { - format!("{path}/{label}") - }; - let ix = *index.entry(path.clone()).or_insert_with(|| { - let ix = nodes.len(); - nodes.push((path.clone(), label.clone(), Vec::new())); - match parent { - Some(parent) => nodes[parent].2.push(ix), - None => roots.push(ix), - } - ix - }); - parent = Some(ix); - } - } - - fn assemble(ix: usize, nodes: &[(String, String, Vec)]) -> TreeItemSeed { - let (id, label, children) = &nodes[ix]; - TreeItemSeed { - id: id.clone(), - label: label.clone(), - children: children - .iter() - .map(|child| assemble(*child, nodes)) - .collect(), - } - } - - roots.iter().map(|root| assemble(*root, &nodes)).collect() -} - -/// Sorted relative paths of a worktree snapshot. -/// -/// Compared against the `worktree_paths` of a repository panel to skip -/// rebuilding the explorer when a refresh left the worktree unchanged. -pub(crate) fn sorted_worktree_paths(entries: &[PathBuf]) -> Vec { - let mut paths: Vec = entries - .iter() - .map(|path| path.to_string_lossy().into_owned()) - .collect(); - paths.sort(); - paths -} - -/// The markdown fence language for a file path, or `None` for plain text. -pub(crate) fn code_language(path: &str) -> Option<&'static str> { - let name = Path::new(path) - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or_default(); - - // Some common files are recognized by name rather than extension. - match name { - "Makefile" | "makefile" => return Some("make"), - "CMakeLists.txt" => return Some("cmake"), - _ => {} - } - - let ext = Path::new(path).extension()?.to_str()?.to_ascii_lowercase(); - Some(match ext.as_str() { - "rs" => "rust", - "toml" => "toml", - "json" | "jsonc" => "json", - "py" => "python", - "js" | "mjs" | "cjs" => "javascript", - "ts" | "mts" | "cts" => "typescript", - "tsx" | "jsx" => "tsx", - "go" => "go", - "c" | "h" => "c", - "cc" | "cpp" | "cxx" | "hh" | "hpp" | "hxx" => "cpp", - "cs" => "csharp", - "java" => "java", - "kt" | "kts" | "ktm" => "kotlin", - "swift" => "swift", - "php" | "phtml" => "php", - "rb" => "ruby", - "sh" | "bash" | "zsh" => "bash", - "yml" | "yaml" => "yaml", - "css" | "scss" | "sass" => "css", - "html" | "htm" => "html", - "lua" => "lua", - "sql" => "sql", - "proto" | "protobuf" => "proto", - "cmake" => "cmake", - "zig" => "zig", - "ex" | "exs" => "elixir", - "graphql" | "gql" => "graphql", - "diff" | "patch" => "diff", - "svelte" => "svelte", - "astro" => "astro", - "scala" => "scala", - _ => return None, - }) -} - -/// Whether a file path has a markdown extension. -pub(crate) fn is_markdown_path(path: &str) -> bool { - Path::new(path) - .extension() - .and_then(|ext| ext.to_str()) - .is_some_and(|ext| { - matches!( - ext.to_ascii_lowercase().as_str(), - "md" | "markdown" | "mdown" | "mkdn" - ) - }) -} - -pub(crate) struct ShareTargets { - /// NIP-19 `naddr1...` of the announcement, with its announced relays. - pub(crate) naddr: String, - /// Hex ID of the announcement event itself. - pub(crate) event_id: String, - /// NIP-34 coordinate `30617::`. - pub(crate) coordinate: String, - /// `https://gitworkshop.dev/` - pub(crate) gitworkshop: String, - /// `https://ditto.pub/` - pub(crate) ditto: String, -} - -impl ShareTargets { - pub(crate) fn from_announcement(announcement: &Announcement) -> Self { - let addr = announcement.addr(); - let coordinate = addr.to_string(); - let naddr = Nip19Coordinate::new(addr, announcement.relays.iter().cloned()) - .to_bech32() - .expect("a complete coordinate always encodes to naddr"); - - Self { - naddr: naddr.clone(), - event_id: announcement.event_id.to_bech32().unwrap(), - coordinate, - gitworkshop: format!("https://gitworkshop.dev/{naddr}"), - ditto: format!("https://ditto.pub/{naddr}"), - } - } - - pub(crate) fn menu(&self, menu: PopupMenu) -> PopupMenu { - menu.min_w(px(340.)) - .item(menu_copy_row( - "copy-gitworkshop", - "GitWorkshop", - truncate_naddr_link(&self.gitworkshop, 4), - self.gitworkshop.clone(), - )) - .item(menu_copy_row( - "copy-ditto", - "Ditto", - truncate_naddr_link(&self.ditto, 4), - self.ditto.clone(), - )) - .item(menu_copy_row( - "copy-event-id", - "Event ID", - middle_truncate(&self.event_id, 10, 10), - self.event_id.clone(), - )) - .item(menu_copy_row( - "copy-coordinate", - "Coordinate", - middle_truncate(&self.coordinate, 10, 10), - self.coordinate.clone(), - )) - } -} - -fn truncate_naddr_link(url: &str, tail: usize) -> String { - let Some(end) = url.find("naddr1").map(|i| i + "naddr1".len()) else { - return url.to_string(); - }; - if url.len() - end <= tail + 3 { - return url.to_string(); - } - format!("{}...{}", &url[..end], &url[url.len() - tail..]) -} - -pub(crate) const GUTTER_WIDTH: f32 = 44.; -pub(crate) const DIFF_ROW_HEIGHT: f32 = 20.; - -#[derive(Clone, Copy)] -pub(crate) enum DiffRow { - Hunk { - old_start: u32, - old_lines: u32, - new_start: u32, - new_lines: u32, - }, - Line { - hunk: usize, - line: usize, - }, -} - -pub(crate) 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 -} - -pub(crate) 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 } => render_diff_line(&hunks[hunk].lines[line], cx), - } -} - -pub(crate) fn render_diff_line(line: &DiffLine, cx: &App) -> AnyElement { - let bg = match line.kind { - DiffLineKind::Addition => Some(cx.theme().success.opacity(0.2)), - DiffLineKind::Deletion => Some(cx.theme().danger.opacity(0.2)), - DiffLineKind::Context => None, - }; - let gutter = cx.theme().muted_foreground; - - // Fixed height and nowrap, the virtual list assumes every row has the same height. - // 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() - .w(px(GUTTER_WIDTH)) - .flex_none() - .pr_2() - .text_right() - .text_color(gutter) - .child(line.old.map(|n| n.to_string()).unwrap_or_default()), - ) - .child( - div() - .w(px(GUTTER_WIDTH)) - .flex_none() - .pr_2() - .text_right() - .text_color(gutter) - .child(line.new.map(|n| n.to_string()).unwrap_or_default()), - ) - .child( - div() - .flex_1() - .min_w_0() - .overflow_hidden() - .whitespace_nowrap() - .text_color(cx.theme().foreground) - .child(line.text.clone()), - ) - .into_any_element() -} - -pub(crate) fn find_item<'a>(items: &'a [TreeItem], id: Option<&str>) -> Option<&'a TreeItem> { - let id = id?; - items.iter().find_map(|item| { - if item.id.as_ref() == id { - Some(item) - } else { - find_item(&item.children, Some(id)) - } - }) -} - -pub(crate) fn issue_roots(store: &RepoStore) -> &[Event] { - &store.issues -} - -pub(crate) fn pr_roots(store: &RepoStore) -> &[Event] { - &store.pull_requests -} - -/// The kind icon, the selection or placeholder, and the caret. `Combobox` -/// replaces its default trigger entirely, the only way to show an icon inside it. -pub(crate) fn ref_selector_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() -} - -pub(crate) fn sidebar_title(text: &str, cx: &App) -> AnyElement { - div() - .text_xs() - .font_semibold() - .text_color(cx.theme().muted_foreground) - .child(text.to_string()) - .into_any_element() -} - -pub(crate) fn sidebar_section( - store: &Entity, - id: EventId, - roots: fn(&RepoStore) -> &[Event], - top_gap: bool, - cx: &App, -) -> AnyElement { - let store = store.read(cx); - let Some(root) = roots(store).iter().find(|event| event.id == id) else { - // The caller bails out when the root is missing. - return div().into_any_element(); - }; - let profile_store = ProfileStore::global(cx); - - // Participants, the root author plus everyone who commented. - let mut participants: Vec = vec![root.pubkey]; - participants.extend(store.comments_of(&root.id).map(|comment| comment.pubkey)); - participants.sort_by_key(PublicKey::to_hex); - participants.dedup(); - - // Labels are NIP-34 `t` hashtag tags on the event. - let labels: Vec = root.tags.hashtags().map(|tag| tag.to_string()).collect(); - - v_flex() - .w(px(240.)) - .h_full() - .flex_none() - .px_4() - .gap_4() - .border_l(px(1.)) - .border_color(cx.theme().sidebar_border) - .child( - v_flex() - .when(top_gap, |this| this.mt_4()) - .gap_2() - .child(sidebar_title("Participants", cx)) - .children(participants.iter().map(|pubkey| { - let profile = profile_store.read(cx).get(pubkey); - let name = profile.name(); - let picture = profile.picture(); - - h_flex() - .gap_1() - .items_center() - .child(UserAvatar::new(name.clone()).picture(picture)) - .child(div().text_sm().truncate().text_ellipsis().child(name)) - .into_any_element() - })), - ) - .child( - v_flex() - .gap_2() - .child(sidebar_title("Labels", cx)) - .map(|this| { - if labels.is_empty() { - this.child( - div() - .text_sm() - .text_color(cx.theme().muted_foreground) - .child("None yet."), - ) - } else { - this.child(h_flex().gap_1().children({ - let mut items = vec![]; - - for label in labels.iter() { - items.push( - Tag::secondary() - .outline() - .xsmall() - .child(SharedString::from(label)), - ); - } - - items - })) - } - }), - ) - .into_any_element() -} - -pub(crate) fn comments_section(store: &Entity, root: EventId, cx: &App) -> AnyElement { - let store = store.read(cx); - let comments: Vec<&Event> = store.comments_of(&root).collect(); - let title = SharedString::from(format!("Discussions {}", comments.len())); - - v_flex() - .gap_4() - .child(div().text_xs().font_semibold().child(title)) - .children(comments.iter().map(|comment| { - let profile = ProfileStore::global(cx).read(cx).get(&comment.pubkey); - let author = profile.name(); - let picture = profile.picture(); - let age = relative_time(comment.created_at); - let content = SharedString::from(comment.content.as_str()); - - v_flex() - .gap_1() - .p_3() - .border_1() - .border_color(cx.theme().border) - .rounded(cx.theme().radius) - .child( - h_flex() - .gap_2() - .text_sm() - .child( - h_flex() - .gap_1() - .child(UserAvatar::new(author.clone()).picture(picture)) - .child(author), - ) - .child( - div() - .text_color(cx.theme().muted_foreground) - .child("commented"), - ) - .child( - div() - .text_color(cx.theme().muted_foreground) - .child(SharedString::from(age)), - ), - ) - .child(div().text_sm().child(content)) - })) - .into_any_element() -} - -/// `roots` selects the root's list within the store, issues or pull requests. -pub(crate) fn comment_form( - store: &Entity, - root: EventId, - roots: fn(&RepoStore) -> &[Event], - comment_input: &Entity, - button_id: &'static str, - cx: &App, -) -> AnyElement { - let comment_input = comment_input.clone(); - let store = store.clone(); - - v_flex() - .gap_2() - .child( - Textarea::new(&comment_input) - .h_24() - .text_color(cx.theme().muted_foreground) - .bg(cx.theme().muted), - ) - .child( - h_flex() - .justify_between() - .child( - h_flex() - .gap_1() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(Icon::new(CustomIconName::Markdown).small()) - .child("Markdown is supported"), - ) - .child( - Button::new(button_id) - .primary() - .label("Comment") - .tooltip("Post comment") - .on_click(move |_event, window, cx| { - let content = comment_input.read(cx).value().trim().to_string(); - if content.is_empty() { - return; - } - let Some(root) = roots(store.read(cx)) - .iter() - .find(|event| event.id == root) - .cloned() - else { - return; - }; - store.update(cx, |store, cx| { - store.comment(&root, content, cx); - }); - comment_input.update(cx, |input, cx| { - input.set_value("", window, cx); - }); - }), - ), - ) - .into_any_element() -} - -pub(crate) const COMMIT_ROW_HEIGHT: f32 = 56.; - -pub(crate) fn commit_row( - ix: usize, - commit: &FileCommit, - on_click: impl Fn(&mut Window, &mut App) + 'static, - cx: &App, -) -> AnyElement { - h_flex() - .id(ix) - .px_4() - .h(px(COMMIT_ROW_HEIGHT)) - .w_full() - .gap_3() - .items_center() - .border_b(px(1.)) - .border_color(cx.theme().border) - .hover(|this| this.bg(cx.theme().list_hover)) - .child( - v_flex() - .flex_1() - .min_w_0() - .gap_0p5() - .justify_center() - .child( - h_flex() - .gap_2() - .items_center() - .overflow_hidden() - .child( - div() - .font_family(cx.theme().mono_font_family.clone()) - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(commit.id.clone()), - ) - .child( - div() - .flex_1() - .min_w_0() - .text_sm() - .text_ellipsis() - .whitespace_nowrap() - .child(commit.summary.clone()), - ), - ) - .child( - h_flex() - .gap_2() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(commit.author.clone()) - .child(relative_time_secs(commit.time)), - ), - ) - .on_click(move |_event, window, cx| on_click(window, cx)) - .into_any_element() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn builds_nested_tree_from_flat_entries() { - let entries = vec![ - PathBuf::from("src"), - PathBuf::from("src/lib.rs"), - PathBuf::from("README.md"), - PathBuf::from("docs/guide.md"), - ]; - - let items = build_tree_items(&entries); - - // Input order is preserved, dirs-first as produced by worktree_entries. - assert_eq!(items.len(), 3); - assert_eq!(items[0].label, "src"); - assert_eq!(items[0].id, "src"); - assert_eq!(items[0].children.len(), 1); - assert_eq!(items[0].children[0].label, "lib.rs"); - assert_eq!(items[0].children[0].id, "src/lib.rs"); - - assert_eq!(items[1].label, "README.md"); - assert_eq!(items[1].id, "README.md"); - - assert_eq!(items[2].label, "docs"); - assert_eq!(items[2].children[0].label, "guide.md"); - assert_eq!(items[2].children[0].id, "docs/guide.md"); - } - - #[test] - fn tree_builder_handles_deep_nesting() { - let entries = vec![ - PathBuf::from("a"), - PathBuf::from("a/b"), - PathBuf::from("a/b/c.txt"), - ]; - - let items = build_tree_items(&entries); - assert_eq!(items.len(), 1); - assert_eq!(items[0].children[0].id, "a/b"); - assert_eq!(items[0].children[0].children[0].id, "a/b/c.txt"); - } - - #[test] - fn tree_builder_merges_shared_prefixes() { - // File children of a directory arrive after other directories' entries. - // The worktree list is dirs-first globally. - // The shared prefix must still resolve to one node. - let entries = vec![ - PathBuf::from("a/x.txt"), - PathBuf::from("b/y.txt"), - PathBuf::from("a/z.txt"), - ]; - - let items = build_tree_items(&entries); - assert_eq!(items.len(), 2); - assert_eq!(items[0].label, "a"); - assert_eq!(items[0].children.len(), 2); - assert_eq!(items[1].label, "b"); - } -} diff --git a/crates/workspace/src/views/repo/history.rs b/crates/workspace/src/views/repo/history.rs index f6fad59..599806d 100644 --- a/crates/workspace/src/views/repo/history.rs +++ b/crates/workspace/src/views/repo/history.rs @@ -11,8 +11,7 @@ use gpui_component::{ActiveTheme, Sizable, v_flex, v_virtual_list}; use signed_ui::placeholder; use super::RepoDetailView; -use crate::views::commit_diff::CommitDiffView; -use crate::views::repo::helpers::{COMMIT_ROW_HEIGHT, commit_row}; +use crate::views::commit_diff::{COMMIT_ROW_HEIGHT, CommitDiffView, commit_row}; impl RepoDetailView { pub(super) fn render_commits_tab(&self, cx: &mut Context) -> AnyElement { diff --git a/crates/workspace/src/views/repo/loading.rs b/crates/workspace/src/views/repo/loading.rs index e2c1111..edab3a8 100644 --- a/crates/workspace/src/views/repo/loading.rs +++ b/crates/workspace/src/views/repo/loading.rs @@ -11,9 +11,7 @@ use signed_git::FileCommit; use signed_state::GitStore; use super::RepoDetailView; -use crate::views::repo::helpers::{ - TreeItemSeed, build_tree_items, sorted_worktree_paths, tree_items, -}; +use crate::views::tree::{TreeItemSeed, build_tree_items, sorted_worktree_paths, tree_items}; struct RepoData { tree: Vec, diff --git a/crates/workspace/src/views/repo/mod.rs b/crates/workspace/src/views/repo/mod.rs index dc5d6bb..5702bf9 100644 --- a/crates/workspace/src/views/repo/mod.rs +++ b/crates/workspace/src/views/repo/mod.rs @@ -22,7 +22,6 @@ mod actions; mod banners; mod files; mod header; -pub(super) mod helpers; mod history; mod init_dialog; mod loading; diff --git a/crates/workspace/src/views/repo/refs.rs b/crates/workspace/src/views/repo/refs.rs index b6fc1e1..05ddb52 100644 --- a/crates/workspace/src/views/repo/refs.rs +++ b/crates/workspace/src/views/repo/refs.rs @@ -7,7 +7,7 @@ use gpui_component::combobox::ComboboxState; use gpui_component::searchable_list::SearchableVec; use super::{RefKind, RepoDetailView}; -use crate::views::repo::helpers::{build_tree_items, sorted_worktree_paths, tree_items}; +use crate::views::tree::{build_tree_items, sorted_worktree_paths, tree_items}; impl RepoDetailView { pub(super) fn switch_ref( diff --git a/crates/workspace/src/views/tree.rs b/crates/workspace/src/views/tree.rs new file mode 100644 index 0000000..8cc385d --- /dev/null +++ b/crates/workspace/src/views/tree.rs @@ -0,0 +1,153 @@ +use std::collections::HashMap; +use std::path::PathBuf; + +use gpui_component::tree::TreeItem; + +pub(crate) struct TreeItemSeed { + /// Path of the node, relative to the worktree root. + pub(crate) id: String, + pub(crate) label: String, + pub(crate) children: Vec, +} + +pub(crate) fn tree_items(seeds: Vec, expand_folders: bool) -> Vec { + fn convert(seed: TreeItemSeed, expand_folders: bool) -> TreeItem { + let mut item = TreeItem::new(seed.id, seed.label); + if expand_folders && !seed.children.is_empty() { + item = item.expanded(true); + } + item.children = seed + .children + .into_iter() + .map(|seed| convert(seed, expand_folders)) + .collect(); + item + } + + seeds + .into_iter() + .map(|seed| convert(seed, expand_folders)) + .collect() +} + +/// Build nested tree items from a flat entry list sorted dirs-first. +pub(crate) fn build_tree_items(entries: &[PathBuf]) -> Vec { + // Node indices by full path, so parents resolve in constant time while inserting. + let mut index: HashMap = HashMap::new(); + let mut nodes: Vec<(String, String, Vec)> = Vec::new(); + let mut roots: Vec = Vec::new(); + + for entry in entries { + let mut parent: Option = None; + let mut path = String::new(); + for part in entry.components() { + let label = part.as_os_str().to_string_lossy().into_owned(); + path = if path.is_empty() { + label.clone() + } else { + format!("{path}/{label}") + }; + let ix = *index.entry(path.clone()).or_insert_with(|| { + let ix = nodes.len(); + nodes.push((path.clone(), label.clone(), Vec::new())); + match parent { + Some(parent) => nodes[parent].2.push(ix), + None => roots.push(ix), + } + ix + }); + parent = Some(ix); + } + } + + fn assemble(ix: usize, nodes: &[(String, String, Vec)]) -> TreeItemSeed { + let (id, label, children) = &nodes[ix]; + TreeItemSeed { + id: id.clone(), + label: label.clone(), + children: children + .iter() + .map(|child| assemble(*child, nodes)) + .collect(), + } + } + + roots.iter().map(|root| assemble(*root, &nodes)).collect() +} + +/// Sorted relative paths of a worktree snapshot. +/// +/// Compared against the `worktree_paths` of a repository panel to skip +/// rebuilding the explorer when a refresh left the worktree unchanged. +pub(crate) fn sorted_worktree_paths(entries: &[PathBuf]) -> Vec { + let mut paths: Vec = entries + .iter() + .map(|path| path.to_string_lossy().into_owned()) + .collect(); + paths.sort(); + paths +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn builds_nested_tree_from_flat_entries() { + let entries = vec![ + PathBuf::from("src"), + PathBuf::from("src/lib.rs"), + PathBuf::from("README.md"), + PathBuf::from("docs/guide.md"), + ]; + + let items = build_tree_items(&entries); + + // Input order is preserved, dirs-first as produced by worktree_entries. + assert_eq!(items.len(), 3); + assert_eq!(items[0].label, "src"); + assert_eq!(items[0].id, "src"); + assert_eq!(items[0].children.len(), 1); + assert_eq!(items[0].children[0].label, "lib.rs"); + assert_eq!(items[0].children[0].id, "src/lib.rs"); + + assert_eq!(items[1].label, "README.md"); + assert_eq!(items[1].id, "README.md"); + + assert_eq!(items[2].label, "docs"); + assert_eq!(items[2].children[0].label, "guide.md"); + assert_eq!(items[2].children[0].id, "docs/guide.md"); + } + + #[test] + fn tree_builder_handles_deep_nesting() { + let entries = vec![ + PathBuf::from("a"), + PathBuf::from("a/b"), + PathBuf::from("a/b/c.txt"), + ]; + + let items = build_tree_items(&entries); + assert_eq!(items.len(), 1); + assert_eq!(items[0].children[0].id, "a/b"); + assert_eq!(items[0].children[0].children[0].id, "a/b/c.txt"); + } + + #[test] + fn tree_builder_merges_shared_prefixes() { + // File children of a directory arrive after other directories' entries. + // The worktree list is dirs-first globally. + // The shared prefix must still resolve to one node. + let entries = vec![ + PathBuf::from("a/x.txt"), + PathBuf::from("b/y.txt"), + PathBuf::from("a/z.txt"), + ]; + + let items = build_tree_items(&entries); + assert_eq!(items.len(), 2); + assert_eq!(items[0].label, "a"); + assert_eq!(items[0].children.len(), 2); + assert_eq!(items[1].label, "b"); + } +} diff --git a/docs/repo-view-refactor-plan.md b/docs/repo-view-refactor-plan.md new file mode 100644 index 0000000..1a5350e --- /dev/null +++ b/docs/repo-view-refactor-plan.md @@ -0,0 +1,291 @@ +# Repo view refactor plan + +## Goal + +Make `crates/workspace/src/views/repo/` easy to navigate and change: + +- Each concern owns its own state (its own struct fields), instead of all concerns sharing one 38-field struct. +- Shared UI moves to the module that consumes it, so sibling views stop importing from `views::repo`. +- No behavior change. No new global state. No new store. The GPUI patterns already used in the repo (`Entity` + observe, `TreeState`, `ComboboxState`, `VirtualListScrollHandle`) stay the only patterns used. + +## Constraints + +- Follow `.rules`: no `unwrap` in production, no silently discarded errors, full-word names, comments explain "why" only. +- Do not over-engineer. Files and History become entities because they already render and run async independently. Refs and Banners stay plain field groups on the shell. +- Keep the shell as the single load/reconcile point. The clone/worktree and `ref_generation` belong to the shell, not to child views. +- `signed_ui` does **not** depend on `signed_git` (verified in `crates/signed_ui/Cargo.toml`). Anything that takes a `signed_git` type cannot move there. + +## Current state (verified) + +| File | Lines | Content | +|---|---|---| +| `mod.rs` | ~376 | `RepoDetailView` struct (38 fields), constructors, `render`, panel impls, `display_name` | +| `store.rs` | ~98 | `attach_store`, `apply_announcement`, `refresh_ready_statuses`, `refresh_statuses` | +| `actions.rs` | ~208 | action methods + `open_repo_panel` / `open_repo_item` free functions | +| `loading.rs` | ~435 | `load_repo`, `apply_repo_data`, `sync_ref_selector`, `clone_to_folder`, `load_repo_data` | +| `refs.rs` | ~279 | `switch_ref`, `restore_selection`, `reload_worktree`, `catch_up_worktree` | +| `files.rs` | ~480 | file tree, previews, markdown/code state, eviction | +| `history.rs` | ~228 | commits tab render + per-file and full commit walks | +| `header.rs` | ~715 | header render, maintainers, fork row, clone URL | +| `banners.rs` | ~315 | ready/push suggestion banners | +| `about.rs` | ~224 | about dialog | +| `init_dialog.rs` | ~202 | publish-to-NIP-34 dialog | +| `helpers.rs` | ~722 | `pub(crate)` grab bag: tree building, diff rendering, discussion UI, share targets, commit rows | + +### Problems + +1. **`RepoDetailView` is a god object.** 38 fields across six concerns. All 12 files are `impl RepoDetailView`, so any file can read/write any field. The file split added navigation cost without encapsulation. +2. **`helpers.rs` is an inverted dependency hub.** `views/issues/detail.rs` and `views/pull_requests/*.rs` import from `views::repo::helpers` for discussion UI, diff rows and commit rows. Sibling views reaching into `repo` is backwards. +3. **Two regions have independent async + render lifecycles** (file browser, commit history) but live as shell fields, sharing `worktree` and `ref_generation` by hand. + +### Existing good pattern + +`IssuesView` (`views/issues/mod.rs`): own struct (~13 fields), `cx.observe(&store, ..)`, `rebuild()` into local state, `Render`, no shell fields. The refactor brings `RepoDetailView` in line with this. + +## Target structure + +```mermaid +graph TD + Shell["RepoDetailView shell\nstore, dock_area, tabs, header,\nload orchestration, worktree, generation"] --> Files["Entity\nfiles.rs"] + Shell --> History["Entity\nhistory.rs"] + Shell --> Refs["RefSwitcher (plain)\nrefs.rs"] + Shell --> Banners["Banners (plain)\nbanners.rs"] + Files --> Store["Entity"] + History --> Store +``` + +Field ownership after the refactor: + +| Concern | Fields | Owner | +|---|---|---| +| Files | `tree_state, worktree_paths, md, code, readme_name, selected_file, files, file_order, preview_bytes, loading_files, commits, pending_commits, loading_commits` | `RepoFilesView` | +| History | `all_commits, loading_all_commits, item_sizes, scroll_handle` | `RepoHistoryView` | +| Refs | `branch_select, tag_select, ref_branches, ref_tags, switching_ref` | `RefSwitcher` | +| Banners | `banner_dismissed, ready_requested, ready_head, ready_statuses, push_statuses` | `Banners` | +| Shell | `focus_handle, dock_area, store, repo_started, active_tab, loading, error, head_commit, worktree, ref_generation, _subscriptions` | `RepoDetailView` (11 fields) | + +Shared modules after Phase 1: + +| New / changed module | Contents | Consumers | +|---|---|---| +| `views/tree.rs` | `TreeItemSeed`, `tree_items`, `build_tree_items`, `sorted_worktree_paths` + the 3 tree tests | repo files/loading, commit_diff | +| `views/commit_diff/mod.rs` | adds `DiffRow`, `diff_rows`, `render_diff_row`, `render_diff_line`, `find_item`, `GUTTER_WIDTH`, `DIFF_ROW_HEIGHT`, `commit_row`, `COMMIT_ROW_HEIGHT` | commit_diff, PR new, repo history | +| `views/discussion.rs` | `sidebar_title`, `sidebar_section`, `comments_section`, `comment_form`, `issue_roots`, `pr_roots` | issues detail, PR detail | +| `signed_ui/src/ref_selector.rs` | `ref_selector_trigger` | repo header, PR new | +| `repo/files.rs` | `code_language`, `is_markdown_path` (only used there) | repo files | +| `repo/header.rs` | `ShareTargets`, `truncate_naddr_link` (only used there) | repo header | + +`views/repo/helpers.rs` is deleted at the end of Phase 1. + +--- + +## Phase 0 - baseline + +No code. Record the current state so each later phase can be compared. + +1. `cargo fmt --all -- --check` +2. `cargo check --offline --workspace --all-targets` +3. `cargo test --offline -p workspace` +4. `cargo clippy --offline -p workspace --all-targets` + +Do not run plain `cargo` without `--offline`; the sandbox fails the git fetch and it looks like a dependency error. + +--- + +## Phase 1 - extract shared modules (dissolve `helpers.rs`) + +Low risk, no state moves. Land it as one commit. + +### 1.1 Create `crates/workspace/src/views/tree.rs` + +Move from `repo/helpers.rs`: `TreeItemSeed`, `tree_items`, `build_tree_items`, `sorted_worktree_paths`, and the three tests (`builds_nested_tree_from_flat_entries`, `tree_builder_handles_deep_nesting`, `tree_builder_merges_shared_prefixes`). + +- Add `pub(crate) mod tree;` to `views/mod.rs`. +- Update imports in `repo/loading.rs`, `repo/refs.rs`, `commit_diff/mod.rs` to `crate::views::tree::...`. + +### 1.2 Move diff and commit-row rendering into `views/commit_diff/mod.rs` + +Move from `repo/helpers.rs`: `GUTTER_WIDTH`, `DIFF_ROW_HEIGHT`, `DiffRow`, `diff_rows`, `render_diff_row`, `render_diff_line`, `find_item`, `COMMIT_ROW_HEIGHT`, `commit_row`. + +- `commit_diff/mod.rs` already owns `DiffPane` and depends on `signed_git`, so this is its natural home and keeps `signed_ui` free of a `signed_git` dependency. +- Update imports in `views/pull_requests/new.rs` and `repo/history.rs`. + +### 1.3 Create `crates/workspace/src/views/discussion.rs` + +Move from `repo/helpers.rs`: `sidebar_title`, `sidebar_section`, `comments_section`, `comment_form`, `issue_roots`, `pr_roots`. + +- Add `pub(crate) mod discussion;` to `views/mod.rs`. +- Update imports in `views/issues/detail.rs` and `views/pull_requests/detail.rs`. After this, neither imports from `views::repo`. + +### 1.4 Move `ref_selector_trigger` into `signed_ui` + +It takes `CustomIconName` (from `assets`) and `ComboboxTriggerContext` (from `gpui_component`); both are already `signed_ui` dependencies, so no dependency changes. + +- Add `crates/signed_ui/src/ref_selector.rs`, export it from `lib.rs`. +- Update imports in `repo/header.rs` and `views/pull_requests/new.rs`. + +### 1.5 Move `code_language` and `is_markdown_path` into `repo/files.rs` + +Only `repo/files.rs` uses them. Keep them private there. + +### 1.6 Move `ShareTargets` and `truncate_naddr_link` into `repo/header.rs` + +Only `repo/header.rs` uses them. Keep them private there. + +### 1.7 Delete `repo/helpers.rs` + +Remove `pub(super) mod helpers;` from `repo/mod.rs`. Confirm no `use ...repo::helpers` remains anywhere: + +``` +grep -rn "repo::helpers" crates/workspace/src +``` + +### Phase 1 validation + +`cargo fmt --all`, `cargo check --offline -p workspace --all-targets`, `cargo test --offline -p workspace`, `cargo clippy --offline -p workspace --all-targets`. + +--- + +## Phase 2 - extract `Entity` + +Largest win: removes 14 fields and most of the preview logic from the shell. + +### 2.1 Define the view + +In `repo/files.rs`, replace `impl RepoDetailView` with `pub(super) struct RepoFilesView` holding: `tree_state`, `worktree`, `worktree_paths`, `md`, `code`, `readme_name`, `selected_file`, `files`, `file_order`, `preview_bytes`, `loading_files`, `commits`, `pending_commits`, `loading_commits`. + +Move the supporting types and helpers from the current `files.rs` into the view: `FileContent`, `MarkdownView`, `CodeView`, `MAX_PREVIEW_BYTES`, `MAX_PREVIEWED_FILES`, `MAX_PREVIEW_CACHE_BYTES`, `source_hash`, `preview_spinner`, `render_tree_item`, `render_tree_column`, `render_content_column`, `set_markdown`, `markdown_element`, `set_code`, `code_element`, `open_file`, `drop_preview_of`, `evict_previews`. + +Move from `repo/history.rs`: `load_commit`, `load_commits` (the per-file commit map). + +### 2.2 Define the view's interface + +- `pub(super) fn new(window: &mut Window, cx: &mut Context) -> Self` - creates the `TreeState`. +- `pub(super) fn set_worktree(&mut self, path: PathBuf)`. +- `pub(super) fn apply_entries(&mut self, tree: Vec, paths: Vec, window, cx)` - used by `load_repo` / `reload_worktree` / `catch_up_worktree`. +- `pub(super) fn set_readme(&mut self, path: Option, bytes: Option>, cx)`. +- `pub(super) fn clear_previews(&mut self)` - branch switch. +- `pub(super) fn catch_up(&mut self, snapshot, window, cx) -> bool` - rebuild tree, drop removed previews, re-render README; returns whether anything changed. +- `impl Render for RepoFilesView`. +- `pub(super) fn pane_title(&self) -> SharedString` - `selected_file` or `readme_name` or `"Overview"`. + +### 2.3 Move the clone loading/error display out of the file view + +`render_content_column` currently shows "Cloning repository..." / a load error from `self.loading` and `self.error`, which are shell state. Move that decision to the shell's `render`: while `self.loading`, render a spinner in the tab body; when `self.error` is set, the existing `Alert` already covers it. `render_content_column` then handles only file previews and the README. + +### 2.4 Wire the shell + +- Add `files: Entity` to `RepoDetailView`. +- In `new_common`, `let files = cx.new(|cx| RepoFilesView::new(window, cx));`. +- In `render`, the Files tab body becomes `self.files.clone()`. +- In `load_repo` (`loading.rs`) and `reload_worktree` / `catch_up_worktree` (`refs.rs`), replace direct field writes with calls on `self.files`. +- Remove the now-unused `files.rs` imports from `mod.rs` and the moved fields from the struct and constructor. + +### Phase 2 validation + +Same commands. Manual: open explore repo, click files in the tree, open the README, switch branch (previews clear), switch back, confirm no spinner sticks. + +--- + +## Phase 3 - extract `Entity` + +### 3.1 Define the view + +In `repo/history.rs`, replace the commits-tab methods with `pub(super) struct RepoHistoryView` holding: `store: Entity`, `dock_area: WeakEntity`, `worktree: Option`, `all_commits`, `loading_all_commits`, `item_sizes`, `scroll_handle`. + +Move: `render_commits_tab` (becomes `impl Render`), `load_all_commits`, `open_commit_diff`. + +### 3.2 Display name + +`open_commit_diff` uses the shell's `display_name`. Extract the `display_name` logic from `RepoDetailView` into a free function in `repo/mod.rs`: + +```rust +pub(super) fn repo_display_name(store: &RepoStore) -> SharedString +``` + +It keeps the local-path fallback that `RepoStore::name()` does not have. Use it in the shell's `Panel::title`, in the header, and in `RepoHistoryView::open_commit_diff`. + +### 3.3 Interface + +- `pub(super) fn new(store, dock_area, window, cx) -> Self`. +- `pub(super) fn set_worktree(&mut self, path: Option)`. +- `pub(super) fn reload(&mut self, cx)` - clears `all_commits` and starts the walk (called when HEAD changes or the branch switches). +- `impl Render for RepoHistoryView`. + +### 3.4 Wire the shell + +- Add `history: Entity` to `RepoDetailView`; create it in `new_common`. +- In `render`, tab 1 becomes `self.history.clone()`. +- Replace `self.all_commits` / `self.loading_all_commits` / `self.item_sizes` writes in `load_repo`, `reload_worktree`, `catch_up_worktree`, and the header-commit pill path with `self.history.update(..)` calls. +- Remove the moved fields from the struct and constructor. + +### Phase 3 validation + +Same commands. Manual: open the Commits tab, scroll a long history, click a commit (diff panel opens), switch branch and confirm the list reloads. + +--- + +## Phase 4 - group `RefSwitcher` and `Banners` + +Plain structs on the shell. No entity, no observer changes. + +### 4.1 `RefSwitcher` + +Move into a `struct RefSwitcher { branch_select, tag_select, ref_branches, ref_tags, switching_ref }` field on the shell. Update `refs.rs` and `loading.rs` methods to read/write `self.refs.*`. `switch_ref` stays on the shell because it fans out to files, history and `head_commit`. + +`ref_generation` stays on the shell: it is shared with the files and history loads. + +### 4.2 `Banners` + +Move into a `struct Banners { dismissed, ready_requested, ready_head, ready_statuses, push_statuses }` field. `banners.rs` and `store.rs` methods keep their `impl RepoDetailView` shape but read/write `self.banners.*`. + +### Phase 4 validation + +Same commands. Manual: the ready-to-contribute banner appears and dismisses, the push banner appears for an owned repo, dismissing survives a store refresh. + +--- + +## Phase 5 - fold `store.rs` and tidy + +1. Move `attach_store`, `apply_announcement`, `refresh_ready_statuses`, `refresh_statuses` into `mod.rs` and delete `repo/store.rs`. +2. Remove `mod store;` from `repo/mod.rs`. +3. Confirm `mod.rs` reads as a shell: struct, constructors, load coordination, `render`, panel impls. +4. Final validation: + +``` +cargo fmt --all +cargo check --offline --workspace --all-targets +cargo test --offline --workspace +cargo clippy --offline --workspace --all-targets +``` + +## Validation (manual smoke, after each phase) + +- Open a repo from the explore list, then open an issue and a PR. +- Deep-link straight to an issue / PR without visiting the repo panel. +- Open a local repository (never announced). +- Initialize a local repo to NIP-34, confirm it leaves the sidebar's local section. +- Clone to folder; clone again before the first clone completes. +- Switch a branch and a tag; confirm previews and the commit list reset. +- Owned repo with unpushed commits: push banner, push, republish banner. + +## Boundary test for "done" + +- No file can touch fields it does not own. +- `repo/mod.rs` is a shell, roughly 200 lines. +- `grep -rn "views::repo::helpers" crates/workspace/src` returns nothing. +- `views/issues` and `views/pull_requests` have no `use ...views::repo`. + +## Non-goals + +- No behavior change; no UI redesign. +- No new global state, no new store, no changes to `signed_state` or `dock`. +- No more `impl RepoDetailView` chapters. New files own structs, not fragments of one struct. +- Do not move `commit_row` into `signed_ui`: it takes `signed_git::FileCommit` and `signed_ui` does not depend on `signed_git`. + +## Risks and open questions + +- **Async generation.** `ref_generation` discards stale loads. It stays on the shell; when the shell pushes a snapshot into a child view, the child must not start a new load that outlives the generation. Simplest rule: only the shell starts loads, child views only render and own per-file preview fetches keyed to the current worktree. +- **Files owns the per-file commit walk.** `load_commit`/`load_commits` move with the preview state, so the shell no longer coordinates them. Confirm the README commit lookup still works after the move. +- **History is small.** After moving `load_commit`/`load_commits` to Files, `history.rs` is ~150 lines. If an entity feels heavy for that, a plain `struct History` field is an acceptable fallback; the field ownership still improves. +- **`RepoStore::name()` vs `display_name`.** `RepoStore::name()` returns `Unknown` for local repos. The extracted `repo_display_name` must keep the local-path fallback so titles are unchanged.