update
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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<SearchableVec<SharedString>>,
|
||||
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()
|
||||
}
|
||||
@@ -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<DiffRow> {
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -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<RepoStore>,
|
||||
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<PublicKey> = 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<String> = 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<RepoStore>, 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<RepoStore>,
|
||||
root: EventId,
|
||||
roots: fn(&RepoStore) -> &[Event],
|
||||
comment_input: &Entity<TextareaState>,
|
||||
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()
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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<RepoDetailView>) -> 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:<pubkey>:<repo-id>`.
|
||||
coordinate: String,
|
||||
/// `https://gitworkshop.dev/<naddr>`
|
||||
gitworkshop: String,
|
||||
/// `https://ditto.pub/<naddr>`
|
||||
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..])
|
||||
}
|
||||
|
||||
@@ -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<TreeItemSeed>,
|
||||
}
|
||||
|
||||
pub(crate) fn tree_items(seeds: Vec<TreeItemSeed>, expand_folders: bool) -> Vec<TreeItem> {
|
||||
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<TreeItemSeed> {
|
||||
// Node indices by full path, so parents resolve in constant time while inserting.
|
||||
let mut index: HashMap<String, usize> = HashMap::new();
|
||||
let mut nodes: Vec<(String, String, Vec<usize>)> = Vec::new();
|
||||
let mut roots: Vec<usize> = Vec::new();
|
||||
|
||||
for entry in entries {
|
||||
let mut parent: Option<usize> = 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<usize>)]) -> 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<String> {
|
||||
let mut paths: Vec<String> = 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:<pubkey>:<repo-id>`.
|
||||
pub(crate) coordinate: String,
|
||||
/// `https://gitworkshop.dev/<naddr>`
|
||||
pub(crate) gitworkshop: String,
|
||||
/// `https://ditto.pub/<naddr>`
|
||||
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<DiffRow> {
|
||||
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<SearchableVec<SharedString>>,
|
||||
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<RepoStore>,
|
||||
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<PublicKey> = 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<String> = 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<RepoStore>, 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<RepoStore>,
|
||||
root: EventId,
|
||||
roots: fn(&RepoStore) -> &[Event],
|
||||
comment_input: &Entity<TextareaState>,
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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<Self>) -> AnyElement {
|
||||
|
||||
@@ -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<TreeItemSeed>,
|
||||
|
||||
@@ -22,7 +22,6 @@ mod actions;
|
||||
mod banners;
|
||||
mod files;
|
||||
mod header;
|
||||
pub(super) mod helpers;
|
||||
mod history;
|
||||
mod init_dialog;
|
||||
mod loading;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<TreeItemSeed>,
|
||||
}
|
||||
|
||||
pub(crate) fn tree_items(seeds: Vec<TreeItemSeed>, expand_folders: bool) -> Vec<TreeItem> {
|
||||
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<TreeItemSeed> {
|
||||
// Node indices by full path, so parents resolve in constant time while inserting.
|
||||
let mut index: HashMap<String, usize> = HashMap::new();
|
||||
let mut nodes: Vec<(String, String, Vec<usize>)> = Vec::new();
|
||||
let mut roots: Vec<usize> = Vec::new();
|
||||
|
||||
for entry in entries {
|
||||
let mut parent: Option<usize> = 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<usize>)]) -> 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<String> {
|
||||
let mut paths: Vec<String> = 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");
|
||||
}
|
||||
}
|
||||
@@ -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<RepoFilesView>\nfiles.rs"]
|
||||
Shell --> History["Entity<RepoHistoryView>\nhistory.rs"]
|
||||
Shell --> Refs["RefSwitcher (plain)\nrefs.rs"]
|
||||
Shell --> Banners["Banners (plain)\nbanners.rs"]
|
||||
Files --> Store["Entity<RepoStore>"]
|
||||
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<RepoFilesView>`
|
||||
|
||||
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>) -> Self` - creates the `TreeState`.
|
||||
- `pub(super) fn set_worktree(&mut self, path: PathBuf)`.
|
||||
- `pub(super) fn apply_entries(&mut self, tree: Vec<TreeItemSeed>, paths: Vec<String>, window, cx)` - used by `load_repo` / `reload_worktree` / `catch_up_worktree`.
|
||||
- `pub(super) fn set_readme(&mut self, path: Option<PathBuf>, bytes: Option<Vec<u8>>, 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<RepoFilesView>` 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<RepoHistoryView>`
|
||||
|
||||
### 3.1 Define the view
|
||||
|
||||
In `repo/history.rs`, replace the commits-tab methods with `pub(super) struct RepoHistoryView` holding: `store: Entity<RepoStore>`, `dock_area: WeakEntity<DockArea>`, `worktree: Option<PathBuf>`, `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<PathBuf>)`.
|
||||
- `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<RepoHistoryView>` 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.
|
||||
Reference in New Issue
Block a user