feat: push checkout (#14)

Reviewed-on: https://git.reya.su/reya/signed/pulls/14
This commit was merged in pull request #14.
This commit is contained in:
2026-09-06 13:14:11 +00:00
parent 33cbe42551
commit 00167c6a8d
85 changed files with 6282 additions and 4487 deletions
+22 -14
View File
@@ -7,8 +7,7 @@ use signed_core::Announcement;
use signed_state::ProfileStore;
use signed_ui::{UserAvatar, middle_truncate};
/// Open the "About" dialog: every field of the repository's announcement
/// event (NIP-34, kind 30617), as parsed into [`Announcement`].
/// Open the About dialog showing every field of the announcement event.
pub(super) fn open_about_dialog(announcement: Announcement, window: &mut Window, cx: &mut App) {
window.open_dialog(cx, move |dialog, _window, cx| {
let announcement = announcement.clone();
@@ -22,8 +21,7 @@ pub(super) fn open_about_dialog(announcement: Announcement, window: &mut Window,
});
}
/// The announcement's fields as labeled rows; hex identifiers carry a copy
/// button, multi-value tags one line per value.
/// The announcement's fields as labeled rows.
fn announcement_rows(announcement: &Announcement, cx: &App) -> AnyElement {
let mut rows: Vec<AnyElement> = Vec::new();
@@ -32,8 +30,9 @@ fn announcement_rows(announcement: &Announcement, cx: &App) -> AnyElement {
text(
announcement
.name
.clone()
.unwrap_or_else(|| SharedString::from("")),
.as_deref()
.map(SharedString::from)
.unwrap_or_else(|| SharedString::from("-")),
),
cx,
));
@@ -43,8 +42,9 @@ fn announcement_rows(announcement: &Announcement, cx: &App) -> AnyElement {
text(
announcement
.description
.clone()
.unwrap_or_else(|| SharedString::from("")),
.as_deref()
.map(SharedString::from)
.unwrap_or_else(|| SharedString::from("-")),
),
cx,
));
@@ -116,7 +116,7 @@ fn announcement_rows(announcement: &Announcement, cx: &App) -> AnyElement {
v_flex().gap_3().w_full().children(rows).into_any_element()
}
/// One info row: a small muted label above the value.
/// One info row with a small muted label above the value.
fn row(label: &'static str, value: AnyElement, cx: &App) -> AnyElement {
v_flex()
.gap_1()
@@ -133,7 +133,12 @@ fn row(label: &'static str, value: AnyElement, cx: &App) -> AnyElement {
}
/// Plain text value, wrapping within the dialog.
fn text(value: SharedString) -> AnyElement {
fn text<T>(value: T) -> AnyElement
where
T: Into<SharedString>,
{
let value = value.into();
div()
.text_sm()
.w_full()
@@ -159,8 +164,10 @@ fn copy_value(id: &'static str, value: String, cx: &App) -> AnyElement {
.into_any_element()
}
/// One row per maintainer: avatar and display name (falling back to a
/// shortened npub), with a copy button for the full pubkey.
/// One row per maintainer with avatar and display name.
/// The display name falls back to a shortened npub.
///
/// A copy button copies the full pubkey.
fn maintainers(maintainers: &[PublicKey], cx: &App) -> AnyElement {
let profile_store = ProfileStore::global(cx);
v_flex()
@@ -190,8 +197,9 @@ fn maintainers(maintainers: &[PublicKey], cx: &App) -> AnyElement {
.into_any_element()
}
/// One row per item of a multi-value tag: the value is truncated to a single
/// line, with a copy button that copies the full value.
/// One row per item of a multi-value tag.
///
/// The value is truncated to a single line, with a copy button for the full value.
fn list(id: &'static str, items: impl IntoIterator<Item = String>, cx: &App) -> AnyElement {
v_flex()
.gap_2()
@@ -16,8 +16,9 @@ use super::helpers::{code_language, is_markdown_path};
const TREE_WIDTH: f32 = 240.;
/// Files larger than this are not previewed.
pub(super) const MAX_PREVIEW_BYTES: usize = 1024 * 1024;
/// Preview cache caps: at most this many files (or this many text bytes)
/// are kept in memory at once; the oldest previews are evicted beyond that.
/// Preview cache caps, a file count and a text byte count.
///
/// The oldest previews are evicted beyond the caps.
pub(super) const MAX_PREVIEWED_FILES: usize = 32;
pub(super) const MAX_PREVIEW_CACHE_BYTES: usize = 8 * 1024 * 1024;
@@ -34,19 +35,13 @@ pub(super) enum FileContent {
}
/// A markdown document loaded into a persistent [`TextViewState`].
///
/// The state is owned by the view rather than created per render: GPUI
/// drops keyed element state after one absent frame, which would re-parse
/// the whole document on every pane switch (README / file / spinner).
pub(super) struct MarkdownView {
/// Source path; `None` means the repository README.
/// Source path, `None` means the repository README.
pub(super) path: Option<SharedString>,
pub(super) state: Entity<TextViewState>,
}
/// A code file loaded into a persistent [`InputState`], rendered as a
/// disabled (read-only) code editor with syntax highlighting, line numbers
/// and search. Persistent for the same reason as [`MarkdownView`].
/// A code file loaded into a persistent [`InputState`].
pub(super) struct CodeView {
/// Source path, relative to the worktree root.
pub(super) path: SharedString,
@@ -64,7 +59,7 @@ fn preview_spinner() -> AnyElement {
}
impl RepoDetailView {
/// One row of the file tree: icon + name, indented by depth.
/// One row of the file tree with icon and name, indented by depth.
fn render_tree_item(
ix: usize,
entry: &TreeEntry,
@@ -81,7 +76,7 @@ impl RepoDetailView {
})
}
/// Left column: the file tree.
/// Left column showing the file tree.
pub(super) fn render_tree_column(
tree_state: Entity<TreeState>,
view: WeakEntity<Self>,
@@ -102,7 +97,7 @@ impl RepoDetailView {
)))
}
/// Right column: README, selected file preview, or status text.
/// Right column, README, selected file preview or status text.
pub(super) fn render_content_column(
&self,
pane_title: SharedString,
@@ -148,7 +143,7 @@ impl RepoDetailView {
self.code_element(path.as_ref(), cx)
}
}
Some(FileContent::Binary) => placeholder("Binary file preview not supported", cx),
Some(FileContent::Binary) => placeholder("Binary file - preview not supported", cx),
Some(FileContent::TooLarge) => placeholder("File is too large to preview", cx),
Some(FileContent::Failed(message)) => placeholder(message, cx),
None => preview_spinner(),
@@ -159,9 +154,8 @@ impl RepoDetailView {
placeholder("No README found", cx)
};
// Latest commit for the current pane: the selected file, or the README
// while nothing is selected. Computed after the body above, which
// needs `&mut self`.
// Latest commit for the current pane, the selected file or the README.
// Computed after the body above, which needs `&mut self`.
let commit = match &self.selected_file {
Some(path) => self.commits.get(path.as_ref()),
None => self
@@ -217,9 +211,6 @@ impl RepoDetailView {
}
/// Load `text` into the persistent markdown TextView state.
///
/// The state is created empty and fed via `push_str`, which parses on a
/// background task, so switching files never blocks the main thread.
pub(super) fn set_markdown(
&mut self,
path: Option<SharedString>,
@@ -231,16 +222,19 @@ impl RepoDetailView {
self.md = Some(MarkdownView { path, state });
}
/// The persistent markdown TextView for `path` (`None` = README), or a
/// spinner while the document is being loaded/parsed.
/// The persistent markdown TextView for `path`, where `None` is the README.
///
/// Shows a spinner while the document is being loaded or parsed.
fn markdown_element(&self, path: Option<&str>, _cx: &mut Context<Self>) -> AnyElement {
let Some(md) = &self.md else {
return preview_spinner();
};
let ready = match path {
Some(path) => md.path.as_deref() == Some(path),
None => md.path.is_none(),
};
if !ready {
return preview_spinner();
}
@@ -255,9 +249,7 @@ impl RepoDetailView {
/// Load `text` into the persistent code editor state for `path`.
///
/// The state is created in code editor mode so the Input renders it as
/// a syntax-highlighted, read-only editor; the tree-sitter parse runs
/// on a background task like [`set_markdown`]'s.
/// Code editor mode makes the Input render it read-only and highlighted.
pub(super) fn set_code(
&mut self,
path: SharedString,
@@ -276,8 +268,7 @@ impl RepoDetailView {
self.code = Some(CodeView { path, state });
}
/// The persistent code editor for `path`, or a spinner while the file is
/// being loaded/parsed.
/// The persistent code editor for `path`, or a spinner while the file loads or parses.
fn code_element(&self, path: &str, _cx: &mut Context<Self>) -> AnyElement {
let Some(code) = &self.code else {
return preview_spinner();
@@ -70,8 +70,6 @@ pub(super) fn commit_row(
}
impl RepoDetailView {
/// Full-height body of the Commits tab: all commits in a virtual
/// list, or a status message while loading / when there are none.
pub(super) fn render_commits_tab(&self, cx: &mut Context<Self>) -> AnyElement {
let Some(list) = self.all_commits.as_ref() else {
return if self.loading_all_commits {
@@ -90,9 +88,9 @@ impl RepoDetailView {
return placeholder("No commits found", cx);
}
// Copy only the values the element tree needs; the list itself is
// borrowed inside the renderer below instead of being cloned per
// frame (a full history can be tens of thousands of commits).
// Copy only the values the element tree needs.
// The list is borrowed by the renderer below instead of cloned per frame.
// A full history can be tens of thousands of commits.
let view = cx.entity().clone();
let sizes = self.item_sizes.clone();
let scroll_handle = self.scroll_handle.clone();
@@ -137,7 +135,8 @@ impl RepoDetailView {
.size_full(),
)
.when(shown < total, |this| {
// The history is capped; tell the user the list is truncated.
// The history is capped.
// Tell the user the list is truncated.
this.child(
div()
.py_2()
+18 -28
View File
@@ -28,19 +28,15 @@ use super::helpers::{
/// Width of the changed-files column.
const TREE_WIDTH: f32 = 260.;
/// The tree + per-file diff body shared by the commit diff panel and the
/// compare view of the new-pull-request panel. Owns the changed-files
/// explorer and the virtual list of the selected file's hunks; the host
/// feeds it a [`CommitDiff`] via [`DiffPane::set_diff`].
/// Tree and per-file diff body, shared by the commit diff and compare views.
pub struct DiffPane {
/// Loaded diff; `None` until [`Self::set_diff`] is called.
/// Loaded diff, `None` until [`Self::set_diff`] is called.
diff: Option<CommitDiff>,
/// Changed-files explorer state.
tree_state: Entity<TreeState>,
/// Path of the file whose diff is shown in the detail column.
selected_file: Option<SharedString>,
/// Rows of the selected file's diff (hunk headers + lines), backing the
/// virtual list in the detail column.
/// Rows of the selected file's diff, hunk headers and lines.
rows: Vec<DiffRow>,
/// Per-row heights of [`Self::rows`].
item_sizes: Rc<Vec<Size<Pixels>>>,
@@ -90,8 +86,9 @@ impl DiffPane {
}
}
/// Forget the diff (e.g. when the compared branches changed): clear the
/// tree, the selection and the diff rows.
/// Forget the diff, e.g. when the compared branches changed.
///
/// Clears the tree, the selection and the diff rows.
pub fn clear(&mut self, cx: &mut Context<Self>) {
self.diff = None;
self.selected_file = None;
@@ -102,15 +99,14 @@ impl DiffPane {
});
}
/// Show the diff of the file at `path` (selected in the tree).
/// Show the diff of the file at `path`, selected in the tree.
fn select_file(&mut self, path: &str, cx: &mut Context<Self>) {
self.selected_file = Some(path.into());
self.set_diff_rows(path);
cx.notify();
}
/// Rebuild the virtual list state for the file at `path` and scroll back
/// to the top.
/// Rebuild the virtual list state for `path` and scroll back to the top.
fn set_diff_rows(&mut self, path: &str) {
let Some(diff) = self.diff.as_ref() else {
return;
@@ -123,7 +119,7 @@ impl DiffPane {
self.scroll_handle.scroll_to_item(0, ScrollStrategy::Top);
}
/// One row of the changed-files tree: icon + name, indented by depth.
/// One row of the changed-files tree, icon and name, indented by depth.
fn render_tree_item(
ix: usize,
entry: &TreeEntry,
@@ -140,7 +136,7 @@ impl DiffPane {
})
}
/// Left column: the changed-files tree.
/// Left column showing the changed-files tree.
fn render_tree_column(&self, cx: &mut Context<Self>) -> AnyElement {
let tree_state = self.tree_state.clone();
let view = cx.entity().downgrade();
@@ -170,7 +166,7 @@ impl DiffPane {
.into_any_element()
}
/// Right column: header of the selected file plus its diff.
/// Right column, header of the selected file plus its diff.
fn render_detail_column(&self, cx: &mut Context<Self>) -> AnyElement {
let Some(diff) = self.diff.as_ref() else {
return placeholder("No changes", cx);
@@ -188,8 +184,7 @@ impl DiffPane {
self.render_file_diff(file, cx.entity(), cx)
}
/// The diff of one file: a header with status and stats, then the hunks
/// in a virtual list (a large diff is never materialized per frame).
/// The diff of one file, with a header showing status and stats.
fn render_file_diff(&self, file: &FileDiff, view: Entity<Self>, cx: &App) -> AnyElement {
let status_label = match file.status {
DiffStatus::Added => "A",
@@ -316,25 +311,21 @@ impl Render for DiffPane {
}
}
/// Detail panel showing the diff of one commit: a metadata header plus the
/// shared [`DiffPane`] body.
/// Detail panel showing the diff of one commit.
pub struct CommitDiffView {
focus_handle: FocusHandle,
/// Local clone the commit lives in.
worktree: PathBuf,
/// Display name of the repository the commit belongs to.
repo_name: SharedString,
/// The commit being shown (header and tab title). Starts as an id-only
/// stub; [`Self::load`] replaces it with the full metadata, which the
/// history list intentionally omits.
/// The commit being shown in the header and tab title.
commit: FileCommit,
/// The diff is being computed on a background task.
loading: bool,
error: Option<SharedString>,
/// Changed-files explorer and per-file diff, shared with the compare
/// view of the new-pull-request panel.
/// Changed-files explorer and per-file diff, also used by the new PR panel's compare view.
pane: Entity<DiffPane>,
/// In-flight tasks; pruned on every push (see [`helpers::track`]).
/// In-flight tasks, pruned on every push.
tasks: Vec<gpui::Task<Result<(), anyhow::Error>>>,
}
@@ -371,8 +362,7 @@ impl CommitDiffView {
}
}
/// Load the commit diff (and the full commit metadata) on a background
/// task and populate the tree.
/// Load the commit diff and the full commit metadata.
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.loading = true;
self.error = None;
@@ -419,7 +409,7 @@ impl CommitDiffView {
self.tasks.push(task);
}
/// Header: commit id, summary, author/time and overall change stats.
/// Header with the commit id, summary, author/time and overall change stats.
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
let commit = &self.commit;
let (files, insertions, deletions) = self.pane.read(cx).diff().map_or((0, 0, 0), |diff| {
+284 -40
View File
@@ -1,19 +1,25 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use assets::CustomIconName;
use gpui::prelude::*;
use gpui::{AnyElement, App, SharedString, div, px};
use gpui::{AnyElement, App, Entity, SharedString, 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, h_flex};
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, FileDiff};
use signed_ui::{menu_copy_row, middle_truncate};
use signed_state::{ProfileStore, RepoStore};
use signed_ui::{UserAvatar, menu_copy_row, middle_truncate};
use utils::relative_time;
/// A `Send` file-tree node: the tree is built on a background thread and
/// converted into [`TreeItem`]s (which hold `Rc` state,
/// so they cannot cross threads) on the main thread.
pub(super) struct TreeItemSeed {
/// Path of the node, relative to the worktree root.
id: String,
@@ -22,12 +28,6 @@ pub(super) struct TreeItemSeed {
children: Vec<TreeItemSeed>,
}
/// Convert tree seeds into [`TreeItem`]s, expanding every folder
/// when `expand_folders` is set.
///
/// The commit diff explorer shows only changed files,
/// which is typically a handful of paths, so its folders start expanded;
/// the worktree explorer starts collapsed instead.
pub(super) 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);
@@ -48,14 +48,9 @@ pub(super) fn tree_items(seeds: Vec<TreeItemSeed>, expand_folders: bool) -> Vec<
.collect()
}
/// Build nested tree items from a flat, sorted (dirs-first) entry list.
///
/// Returns [`TreeItemSeed`]s so the build can run off the main thread; a
/// worktree walk can yield tens of thousands of entries. Nodes live in an
/// arena and parents are found via a path -> index map, which keeps the
/// build linear in the number of path components.
/// Build nested tree items from a flat entry list sorted dirs-first.
pub(super) fn build_tree_items(entries: &[PathBuf]) -> Vec<TreeItemSeed> {
// Node indices by full path, for O(1) parent lookup while inserting.
// 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();
@@ -99,9 +94,6 @@ pub(super) fn build_tree_items(entries: &[PathBuf]) -> Vec<TreeItemSeed> {
}
/// The markdown fence language for a file path, or `None` for plain text.
///
/// Names are chosen so `gpui_component`'s highlighter can resolve them
/// (`highlighter::Language::from_name` accepts short aliases such as `rs` and `js`).
pub(super) fn code_language(path: &str) -> Option<&'static str> {
let name = Path::new(path)
.file_name()
@@ -166,7 +158,7 @@ pub(super) fn is_markdown_path(path: &str) -> bool {
}
pub(super) struct ShareTargets {
/// NIP-19 `naddr1...` of the announcement (with its announced relays).
/// NIP-19 `naddr1...` of the announcement, with its announced relays.
pub(super) naddr: String,
/// Hex ID of the announcement event itself.
pub(super) event_id: String,
@@ -195,8 +187,9 @@ impl ShareTargets {
}
}
/// The share dropdown menu: one row per target, each showing a compact
/// label while the copy button (and row click) copy the full value.
/// The share dropdown menu, one row per target.
///
/// Each shows a compact label, the copy button and row click copy the full value.
pub(super) fn menu(&self, menu: PopupMenu) -> PopupMenu {
menu.min_w(px(340.))
.item(menu_copy_row(
@@ -226,9 +219,7 @@ impl ShareTargets {
}
}
/// Shorten an naddr link to `<url>/naddr1...[last tail chars]`, e.g.
/// `https://gitworkshop.dev/naddr1...abcd`. Only the label is shortened;
/// the value to be copied stays the full URL.
/// Shorten an naddr link to `<url>/naddr1...[last tail chars]`.
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();
@@ -244,7 +235,8 @@ pub(super) const GUTTER_WIDTH: f32 = 44.;
/// Height of one row in a virtual diff list.
pub(super) const DIFF_ROW_HEIGHT: f32 = 20.;
/// One row of a virtual diff list: a hunk header, or a line of a hunk.
/// One row of a virtual diff list, a hunk header or a line of a hunk.
///
/// Shared by the commit diff and pull request diff viewers.
#[derive(Clone, Copy)]
pub(super) enum DiffRow {
@@ -258,7 +250,7 @@ pub(super) enum DiffRow {
Line { hunk: usize, line: usize },
}
/// The rows of `file`'s diff: one header row per hunk, then its lines.
/// The rows of `file`'s diff, one header row per hunk then its lines.
pub(super) fn diff_rows(file: &FileDiff) -> Vec<DiffRow> {
let mut rows = Vec::new();
for (hunk_ix, hunk) in file.hunks.iter().enumerate() {
@@ -276,7 +268,7 @@ pub(super) fn diff_rows(file: &FileDiff) -> Vec<DiffRow> {
rows
}
/// One row of the virtual diff list: a hunk header or a single line.
/// One row of the virtual diff list, a hunk header or a single line.
pub(super) fn render_diff_row(hunks: &[DiffHunk], row: DiffRow, cx: &App) -> AnyElement {
match row {
DiffRow::Hunk {
@@ -303,8 +295,9 @@ pub(super) fn render_diff_row(hunks: &[DiffHunk], row: DiffRow, cx: &App) -> Any
}
}
/// One diff line: old and new line numbers in gutters, then the content,
/// tinted by kind (addition / deletion / context).
/// One diff line, old and new line numbers in the gutters.
///
/// The content is tinted by kind, addition, deletion or context.
pub(super) fn render_diff_line(line: &DiffLine, cx: &App) -> AnyElement {
let bg = match line.kind {
DiffLineKind::Addition => Some(cx.theme().success.opacity(0.2)),
@@ -313,8 +306,8 @@ pub(super) fn render_diff_line(line: &DiffLine, cx: &App) -> AnyElement {
};
let gutter = cx.theme().muted_foreground;
// Fixed height and nowrap: the virtual list assumes every row has
// the same height, so long lines are clipped instead of wrapped.
// 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))
@@ -364,6 +357,257 @@ pub(super) fn find_item<'a>(items: &'a [TreeItem], id: Option<&str>) -> Option<&
})
}
/// The root issue events of a repo store, for the shared detail sections.
pub(super) fn issue_roots(store: &RepoStore) -> &[Event] {
&store.issues
}
/// The root pull request events of a repo store, for the shared detail sections.
pub(super) fn pr_roots(store: &RepoStore) -> &[Event] {
&store.pull_requests
}
/// The trigger body of the branch/tag selectors.
///
/// 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(super) 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()
}
/// Section heading of a detail sidebar, shared by the issue and PR panels.
pub(super) 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()
}
/// Right sidebar with participants and labels of a root event, issue or PR.
pub(super) 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()
}
/// The comments on a root event, issue or PR, one card per comment.
pub(super) 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()
}
/// The comment form posting to an issue or PR root event.
///
/// `roots` selects the root's list within the store, issues or pull requests.
pub(super) 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()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -379,7 +623,7 @@ mod tests {
let items = build_tree_items(&entries);
// Input order is preserved (dirs-first, as produced by worktree_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");
@@ -411,9 +655,9 @@ mod tests {
#[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.
// 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"),
@@ -461,7 +705,7 @@ mod tests {
truncate_naddr_link("https://gitworkshop.dev/naddr1qqqxyzabc1234", 4),
"https://gitworkshop.dev/naddr1...1234"
);
// No naddr1 prefix: unchanged.
// Without the naddr1 prefix, unchanged.
assert_eq!(
truncate_naddr_link("https://example.com/x", 4),
"https://example.com/x"
@@ -2,7 +2,7 @@ use std::path::PathBuf;
use assets::CustomIconName;
use gpui::prelude::*;
use gpui::{App, Entity, SharedString, WeakEntity, Window, div, px};
use gpui::{App, Entity, WeakEntity, Window, px};
use gpui_base::h_flex;
use gpui_base::input::TextareaState;
use gpui_component::button::{Button, ButtonVariants};
@@ -14,22 +14,15 @@ use settings::SettingsStore;
use signed_state::Backend;
use super::RepoDetailView;
use crate::views::dialog_state::{DialogProgress, error_row};
use crate::views::sidebar::grasp_servers::{
GraspServersState, grasp_servers_field, load_user_grasp_servers,
};
/// Shared state for the Init dialog, so async results can be rendered.
#[derive(Default)]
pub struct InitRepoState {
pub busy: bool,
pub error: Option<SharedString>,
}
pub type InitRepoState = DialogProgress;
/// Open the Init dialog for the local repository at `local_path`.
///
/// The dialog loads the user's default grasp servers (kind `10317` grasp
/// list) and falls back to the shared defaults when none are set. On
/// success the dialog closes and `view` switches into NIP-34 mode.
pub fn open(
local_path: PathBuf,
view: WeakEntity<RepoDetailView>,
@@ -40,23 +33,25 @@ pub fn open(
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_default();
let grasp_settings = SettingsStore::global(cx)
.read(cx)
.settings()
.grasp_servers
.clone();
let state = cx.new(|_| InitRepoState::default());
let grasp_state = cx.new(|_| GraspServersState::new_default(&grasp_settings));
let relay_input = cx.new(|cx| InputState::new(window, cx).placeholder("relay.example.com"));
let name_input = cx.new(|cx| InputState::new(window, cx).default_value(default_name));
let desc_input = cx.new(|cx| {
TextareaState::new(window, cx)
.auto_grow(3, 5)
.placeholder("Short description")
});
let relay_input = cx.new(|cx| {
InputState::new(window, cx).placeholder("wss://relay.example.com or relay.example.com")
});
let state = cx.new(|_| InitRepoState::default());
let grasp_settings = SettingsStore::global(cx)
.read(cx)
.settings()
.grasp_servers
.clone();
let grasp_state = cx.new(|_| GraspServersState::new_default(&grasp_settings));
// Load the user's grasp servers.
load_user_grasp_servers(grasp_state.clone(), window, cx);
window.open_dialog(cx, move |dialog, _window, _cx| {
@@ -115,9 +110,7 @@ pub fn open(
)
.child(grasp_servers_field(&grasp_state, &relay_input, cx)),
)
.children(error.map(|message| {
div().text_sm().text_color(cx.theme().danger).child(message)
}))
.children(error_row(&error, cx))
.child(
DialogFooter::new().justify_end().child(
Button::new("init")
@@ -153,8 +146,9 @@ pub fn open(
});
}
/// Run the init flow; closes the dialog and switches the repository into
/// its NIP-34 mode on success.
/// Run the init flow.
///
/// Closes the dialog and switches the repository into NIP-34 mode on success.
fn init_repository(
local_path: PathBuf,
inputs: (Entity<InputState>, Entity<TextareaState>),
@@ -170,23 +164,16 @@ fn init_repository(
let servers = grasp_state.read(cx).grasp_servers.clone();
if name.is_empty() {
state.update(cx, |state, _| {
state.error = Some("Repository name is required".into());
});
state.update(cx, |state, _| state.fail("Repository name is required"));
return;
}
if servers.is_empty() {
state.update(cx, |state, _| {
state.error = Some("Add at least one grasp server".into());
});
state.update(cx, |state, _| state.fail("Add at least one grasp server"));
return;
}
state.update(cx, |state, _| {
state.busy = true;
state.error = None;
});
state.update(cx, |state, _| state.begin());
let backend = Backend::global(cx);
let task = backend.update(cx, |backend, cx| {
@@ -211,10 +198,7 @@ fn init_repository(
}
Err(e) => {
cx.update_window(handle, |_, _window, cx| {
state.update(cx, |state, _| {
state.busy = false;
state.error = Some(e.to_string().into());
});
state.update(cx, |state, _| state.fail(e.to_string()));
})
.ok();
}
@@ -1,31 +1,26 @@
use std::collections::HashMap;
use assets::CustomIconName;
use dock::{BasePanel, Panel, PanelEvent};
use gpui::prelude::*;
use gpui::{
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, SharedString,
Window, div, px, relative,
App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, SharedString, Window, div,
relative,
};
use gpui_component::button::{Button, ButtonVariants};
use gpui_component::input::{Textarea, TextareaState};
use gpui_component::input::TextareaState;
use gpui_component::scroll::ScrollableElement;
use gpui_component::tag::Tag;
use gpui_component::{ActiveTheme, Icon, Sizable, StyledExt, h_flex, v_flex};
use nostr::prelude::{Event, EventId, PublicKey};
use gpui_component::{ActiveTheme, StyledExt, h_flex, v_flex};
use nostr::prelude::EventId;
use signed_core::activity_subject;
use signed_state::{ProfileStore, RepoStore};
use signed_ui::image_cache::{MAX_IMAGES, image_cache};
use signed_ui::{UserAvatar, placeholder, status_badge};
use utils::relative_time;
use super::helpers::{comment_form, comments_section, issue_roots, sidebar_section};
/// Detail panel of a single issue.
pub struct IssueDetailView {
/// Repo store holding the issues and their statuses.
store: Entity<RepoStore>,
issue_id: EventId,
contents: HashMap<EventId, SharedString>,
/// Input state of the "leave a comment" textarea.
/// Input state of the comment textarea.
comment_input: Entity<TextareaState>,
focus_handle: FocusHandle,
}
@@ -45,193 +40,8 @@ impl IssueDetailView {
store,
issue_id,
comment_input,
contents: HashMap::new(),
}
}
fn render_sidebar(&self, cx: &mut Context<Self>) -> impl IntoElement {
let profile_store = ProfileStore::global(cx);
let store = self.store.read(cx);
let Some(issue) = store.issues.iter().find(|issue| issue.id == self.issue_id) else {
// `render` already bails out when the issue is missing.
return div().into_any_element();
};
// Participants: the issue author plus everyone who commented.
let mut participants: Vec<PublicKey> = vec![issue.pubkey];
participants.extend(store.comments_of(&issue.id).map(|comment| comment.pubkey));
participants.sort_by_key(PublicKey::to_hex);
participants.dedup();
// Issue labels are NIP-34 `t` hashtag tags on the event.
let labels: Vec<String> = issue.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()
.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()
}
fn render_comments(&mut self, id: &EventId, cx: &mut Context<Self>) -> impl IntoElement {
let store = self.store.read(cx);
let comments: Vec<&Event> = store.comments_of(id).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);
// Comment bodies are cloned into shared strings once per
// comment, not on every render.
let content = self
.contents
.entry(comment.id)
.or_insert_with(|| SharedString::from(comment.content.clone()))
.clone();
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()
}
fn render_form(&mut self, id: &EventId, cx: &mut Context<Self>) -> impl IntoElement {
let comment_input = self.comment_input.clone();
let store = self.store.clone();
let id = id.to_owned();
v_flex()
.gap_2()
.child(
Textarea::new(&self.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("comment")
.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) = store
.read(cx)
.issues
.iter()
.find(|issue| issue.id == id)
.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()
}
}
impl BasePanel for IssueDetailView {
@@ -277,17 +87,11 @@ impl Render for IssueDetailView {
let (title, author, picture, status, age, issue_id, content) = {
let profile_store = ProfileStore::global(cx);
let profile = profile_store.read(cx).get(&issue.pubkey);
let content = self
.contents
.entry(issue.id)
.or_insert_with(|| {
if issue.content.is_empty() {
SharedString::from("No description provided.")
} else {
SharedString::from(&issue.content)
}
})
.clone();
let content = if issue.content.is_empty() {
SharedString::from("No description provided.")
} else {
SharedString::from(&issue.content)
};
(
activity_subject(issue),
@@ -301,7 +105,7 @@ impl Render for IssueDetailView {
};
h_flex()
.image_cache(image_cache("issue-detail", MAX_IMAGES))
.image_cache(gpui::retain_all("issue-detail"))
.id("issue-detail")
.size_full()
.child(
@@ -358,20 +162,24 @@ impl Render for IssueDetailView {
)
.child(div().text_sm().child(content)),
)
.child(self.render_comments(&issue_id, cx))
.child(self.render_form(&issue_id, cx)),
.child(comments_section(&self.store, issue_id, cx))
.child(comment_form(
&self.store,
issue_id,
issue_roots,
&self.comment_input,
"comment",
cx,
)),
),
)
.child(self.render_sidebar(cx))
.child(sidebar_section(
&self.store,
issue_id,
issue_roots,
false,
cx,
))
.into_any_element()
}
}
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()
}
@@ -1,7 +1,7 @@
use std::rc::Rc;
use assets::CustomIconName;
use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, panel_handle};
use dock::{BasePanel, DockArea, Panel, PanelEvent, add_center_panel, panel_handle};
use gpui::prelude::*;
use gpui::{
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render,
@@ -18,14 +18,12 @@ use gpui_component::{
use nostr::prelude::EventId;
use signed_core::{RepoStatus, activity_subject};
use signed_state::{ProfileStore, RepoStore};
use signed_ui::image_cache::{MAX_IMAGES, image_cache};
use signed_ui::{SegmentButton, UserAvatar, placeholder, status_badge};
use utils::relative_time;
use super::issue_detail::IssueDetailView;
/// Height of one issue row in the virtual list: `py_2` padding, a 32px
/// title line (`h_8`), a 24px meta line (`h_6`) and the 1px bottom border.
/// Height of one issue row in the virtual list.
const ISSUE_ROW_HEIGHT: f32 = 73.;
/// Status filter of the issues list, chosen via the header's filter buttons.
@@ -35,8 +33,7 @@ enum IssueFilter {
All,
/// Issues whose resolved status is [`RepoStatus::Open`].
Open,
/// Issues whose resolved status is [`RepoStatus::Closed`] or
/// [`RepoStatus::Applied`] (both are "done" states).
/// Issues whose resolved status is [`RepoStatus::Closed`].
Closed,
}
@@ -63,14 +60,11 @@ pub struct IssuesView {
filter: IssueFilter,
/// Per-row heights of the virtual list.
item_sizes: Rc<Vec<Size<Pixels>>>,
/// Number of rows [`Self::item_sizes`] was built for (the filtered issue count).
/// The filtered issue count [`Self::item_sizes`] was built for.
issue_len: usize,
/// Indices into the store's `issues` matching [`Self::filter`]; the
/// virtual list renders this slice. Rebuilt only when the store
/// version or the filter changes, keyed by [`Self::cache_key`].
/// Indices into the store's `issues` matching [`Self::filter`].
visible_issues: Vec<usize>,
/// Header counts `(total, open, closed)`, rebuilt with
/// [`Self::visible_issues`].
/// Header counts `(total, open, closed)`, rebuilt with [`Self::visible_issues`].
counts: (usize, usize, usize),
/// Store version and filter the cached rows/counts were built from.
cache_key: Option<(u64, IssueFilter)>,
@@ -82,10 +76,11 @@ impl IssuesView {
pub fn new(
dock_area: WeakEntity<DockArea>,
store: Entity<RepoStore>,
repo_name: SharedString,
_window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let repo_name = store.read(cx).name();
Self {
focus_handle: cx.focus_handle(),
dock_area,
@@ -101,7 +96,7 @@ impl IssuesView {
}
}
/// Open the detail panel of `issue_id` at the bottom of the dock area.
/// Open the detail panel of `issue_id` in the dock area.
fn open_issue_detail(
&mut self,
issue_id: EventId,
@@ -115,12 +110,10 @@ impl IssuesView {
let panel = cx.new(|cx| IssueDetailView::new(self.store.clone(), issue_id, window, cx));
dock_area.update(cx, |dock_area, cx| {
dock_area.add_panel_view(panel_handle(panel), DockPlacement::Center, None, window, cx);
add_center_panel(dock_area, panel_handle(panel), window, cx);
});
}
/// Render one row of the issue list; `ix` is the row index and
/// `issue_ix` the index of the issue in the store's `issues`.
fn render_row(&self, ix: usize, issue_ix: usize, cx: &mut Context<Self>) -> AnyElement {
let issue = &self.store.read(cx).issues[issue_ix];
let title = activity_subject(issue);
@@ -183,8 +176,7 @@ impl IssuesView {
}
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
// Counts of the last list rebuild (`render` rebuilds first when the
// store version or filter changed, so this is never stale).
// Counts of the last list rebuild.
let (total, open, closed) = self.counts;
h_flex()
@@ -242,8 +234,7 @@ impl IssuesView {
}
}
/// Open the "new issue" dialog: a title and a content input that submit
/// through [`RepoStore::open_issue`] when confirmed.
/// Open the new issue dialog, a title and a content input.
pub(super) fn open_new_issue_dialog(store: Entity<RepoStore>, window: &mut Window, cx: &mut App) {
let subject = cx.new(|cx| InputState::new(window, cx).placeholder("Issue title"));
let content = cx.new(|cx| TextareaState::new(window, cx).placeholder("Describe the issue..."));
@@ -332,9 +323,9 @@ impl Render for IssuesView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let filter = self.filter;
// Rebuild the filtered rows and header counts only when the store
// refreshed or the filter changed; other renders reuse the cache.
// Rows and counts are rebuilt only when the store refreshed or filter changed.
let version = self.store.read(cx).version();
if self.cache_key != Some((version, filter)) {
let store = self.store.read(cx);
let mut counts = (0usize, 0usize, 0usize);
@@ -359,8 +350,8 @@ impl Render for IssuesView {
let count = self.visible_issues.len();
// The virtual list's item count comes from `item_sizes`; rebuild it
// whenever the filtered issue count changes.
// The virtual list's item count comes from `item_sizes`.
// Rebuild it whenever the filtered issue count changes.
if count != self.issue_len {
self.issue_len = count;
self.item_sizes = Rc::new(vec![size(px(0.), px(ISSUE_ROW_HEIGHT)); count]);
@@ -371,7 +362,7 @@ impl Render for IssuesView {
v_flex()
.size_full()
.image_cache(image_cache("issues", MAX_IMAGES))
.image_cache(gpui::retain_all("issues"))
.child(self.render_header(cx))
.child(
v_flex()
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,3 @@
use std::collections::HashMap;
use std::path::PathBuf;
use std::rc::Rc;
@@ -7,97 +6,68 @@ use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, panel_handle};
use gpui::prelude::*;
use gpui::{
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render,
ScrollStrategy, SharedString, Size, Subscription, Task, WeakEntity, Window, div, px, relative,
size,
SharedString, Size, Task, WeakEntity, Window, div, px, relative, size,
};
use gpui_component::button::{Button, ButtonVariants};
use gpui_component::clipboard::Clipboard;
use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle};
use gpui_component::form::{field, v_form};
use gpui_component::input::{Textarea, TextareaState};
use gpui_component::list::ListItem;
use gpui_component::scroll::{ScrollableElement, Scrollbar};
use gpui_component::spinner::Spinner;
use gpui_component::tab::{Tab, TabBar};
use gpui_component::tag::Tag;
use gpui_component::tree::{TreeEntry, TreeState, tree};
use gpui_component::{
ActiveTheme, Icon, Sizable, StyledExt, VirtualListScrollHandle, WindowExt, h_flex, v_flex,
ActiveTheme, Sizable, StyledExt, VirtualListScrollHandle, WindowExt, h_flex, v_flex,
v_virtual_list,
};
use nostr::prelude::{Event, EventId, Kind, Nip34Tag, PublicKey};
use nostr::prelude::{Event, EventId, Kind, Nip34Tag};
use signed_core::{activity_subject, pull_request_patch};
use signed_git::{CommitDiff, FileCommit, FileDiff, patch_commits, patch_diffs};
use signed_git::{FileCommit, patch_commits, patch_diffs};
use signed_state::{Backend, GitStore, ProfileStore, RepoStore};
use signed_ui::image_cache::{MAX_IMAGES, image_cache};
use signed_ui::{UserAvatar, placeholder, status_badge, tree_row};
use signed_ui::{CountBadge, UserAvatar, placeholder, status_badge};
use utils::{relative_time, relative_time_secs};
use super::diff::CommitDiffView;
use super::helpers::{
DIFF_ROW_HEIGHT, DiffRow, build_tree_items, diff_rows, find_item, render_diff_row, tree_items,
};
use super::diff::{CommitDiffView, DiffPane};
use super::helpers::{comment_form, comments_section, pr_roots, sidebar_section};
/// Width of the changed-files column.
const TREE_WIDTH: f32 = 260.;
/// Height of one commit row in the commits tab's virtual list: a single
/// text line plus the 1px bottom border.
const PR_COMMIT_ROW_HEIGHT: f32 = 37.;
/// Height of one commit row in the commits tab's virtual list.
const ROW_HEIGHT: f32 = 37.;
/// Detail panel of a single pull request.
pub struct PullRequestDetailView {
focus_handle: FocusHandle,
/// Dock area new panels (commit diffs) are added to.
/// Dock area where new panels, e.g. commit diffs, are added.
dock_area: WeakEntity<DockArea>,
/// Repo store holding the PR, its status and comments.
store: Entity<RepoStore>,
/// Event id of the root PR event (kind 1618; updates are revisions).
/// Event id of the root PR event, kind 1618.
/// Updates are revisions.
pr_id: EventId,
/// Input state of the "leave a comment" textarea.
/// Input state of the comment textarea.
comment_input: Entity<TextareaState>,
/// Display name of the repository, for panels opened from here.
repo_name: SharedString,
/// Local clone the PR's git changes come from; `None` while the diff is
/// parsed from the nostr patch set (no commit diff viewer then).
/// Local clone the PR's git changes come from.
worktree: Option<PathBuf>,
/// Root PR's content, shown as plain text.
description: SharedString,
/// Tip commit of the PR: the latest update's `c` tag, else the root's.
/// Tip commit of the PR, the latest update's `c` tag or the root's.
current_commit: Option<SharedString>,
/// Commits of the patch series, in patch order (oldest first).
/// Commits of the patch series, in patch order, oldest first.
commits: Vec<FileCommit>,
/// Parsed file changes of the patch; `None` while loading or on failure.
diff: Option<CommitDiff>,
/// The patch is being parsed on a background task.
loading: bool,
error: Option<SharedString>,
/// Active header tab: 0 = Discussion, 1 = Files, 2 = Commits.
/// Active header tab, 0 = Discussion, 1 = Files, 2 = Commits.
active_tab: usize,
/// Changed-files explorer state.
tree_state: Entity<TreeState>,
/// Path of the file whose diff is shown in the detail column.
selected_file: Option<SharedString>,
/// Rows of the selected file's diff (hunk headers + lines).
rows: Vec<DiffRow>,
/// Per-row heights of [`Self::rows`].
item_sizes: Rc<Vec<Size<Pixels>>>,
/// Virtual list state of the diff rows.
scroll_handle: VirtualListScrollHandle,
/// Per-row heights of the commits tab's virtual list, built when the
/// patch series is loaded.
/// Changed-files explorer and per-file diff, like the commit and compare views.
pane: Entity<DiffPane>,
/// Per-row heights of the commits tab's virtual list, built when the patch series loads.
commit_item_sizes: Rc<Vec<Size<Pixels>>>,
/// Virtual list state of the commits tab.
commit_scroll_handle: VirtualListScrollHandle,
/// Comment bodies as shared strings, keyed by comment event ID, so
/// re-renders don't clone full contents again (events are immutable,
/// so the cache never needs invalidation).
contents: HashMap<EventId, SharedString>,
/// In-flight tasks; finished tasks are pruned on every push, so the vec
/// stays bounded by the number of concurrent loads.
/// In-flight tasks, finished tasks are pruned on every push.
tasks: Vec<Task<Result<(), anyhow::Error>>>,
/// Subscriptions keeping the view live as the store refreshes.
_subscriptions: Vec<Subscription>,
}
impl PullRequestDetailView {
@@ -108,26 +78,12 @@ impl PullRequestDetailView {
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let tree_state = cx.new(|cx| TreeState::new(cx));
let repo_name = store.read(cx).name();
let pane = cx.new(DiffPane::new);
let comment_input =
cx.new(|cx| TextareaState::new(window, cx).placeholder("Leave a comment..."));
// Same display name as the repo detail panel's title.
let repo_name = store
.read(cx)
.announcement
.as_ref()
.map(|announcement| {
announcement
.name
.clone()
.unwrap_or_else(|| SharedString::from(announcement.id.clone()))
})
.unwrap_or_default();
// Re-render when the store refreshes (new comments, status changes).
let subscriptions = vec![cx.observe(&store, |_this, _store, cx| cx.notify())];
// Defer loading until the window is ready, like the commit diff view.
cx.defer_in(window, |this, window, cx| {
this.load(window, cx);
@@ -144,29 +100,17 @@ impl PullRequestDetailView {
description: SharedString::default(),
current_commit: None,
commits: Vec::new(),
diff: None,
loading: true,
error: None,
active_tab: 0,
tree_state,
selected_file: None,
rows: Vec::new(),
item_sizes: Rc::new(Vec::new()),
scroll_handle: VirtualListScrollHandle::new(),
pane,
commit_item_sizes: Rc::new(Vec::new()),
commit_scroll_handle: VirtualListScrollHandle::new(),
contents: HashMap::new(),
tasks: Vec::new(),
_subscriptions: subscriptions,
}
}
/// Snapshot the PR events from the store, then compute the file changes
/// and commit list on a background task and populate the tree.
///
/// The changes come from the PR's patch set (NIP-34 `e`-linked patch
/// events) when present; otherwise from the git repository (`c`,
/// `clone` and `merge-base` tags), diffing the `merge-base..tip` range.
/// Snapshot the PR events from the store.
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.loading = true;
self.error = None;
@@ -176,6 +120,7 @@ impl PullRequestDetailView {
let (description, patch, current_commit, merge_base, clone_urls, addr, has_patch_link) = {
let store = self.store.read(cx);
let Some(root) = store
.pull_requests
.iter()
@@ -186,19 +131,24 @@ impl PullRequestDetailView {
cx.notify();
return;
};
let update = latest_update(store.pull_requests.iter(), root);
let tip = update
.and_then(current_commit_of)
.or_else(|| current_commit_of(root));
let base = update
.and_then(merge_base_of)
.or_else(|| merge_base_of(root));
let clone_urls = clone_urls_of(root).or_else(|| {
store
.announcement
.as_ref()
.map(|a| a.clone.iter().map(ToString::to_string).collect())
});
(
root.content.clone(),
pull_request_patch(root, store.patches.iter()),
@@ -209,16 +159,17 @@ impl PullRequestDetailView {
root.tags.event_ids().next().is_some(),
)
};
self.description = description.into();
let task = cx.spawn_in(window, async move |this, cx| {
// Parse the nostr patch set first.
let nostr_diff = cx
.background_spawn({
let patch = patch.clone();
async move { patch_diffs(&patch) }
})
.await;
let nostr_commits = cx
.background_spawn({
let patch = patch.clone();
@@ -226,13 +177,13 @@ impl PullRequestDetailView {
})
.await;
// PRs without patch events (e.g. published by ngit) carry their
// changes in the git repository: fetch the clone and diff the
// `merge-base..tip` range.
// PRs without patch events, e.g. published by ngit, carry their changes in git.
// Fetch the clone and diff the `merge-base..tip` range.
let use_nostr = match &nostr_diff {
Ok(diff) => has_patch_link || !diff.files.is_empty(),
Err(_) => true,
};
let git = if use_nostr {
None
} else {
@@ -241,19 +192,22 @@ impl PullRequestDetailView {
let clone_urls = clone_urls.clone();
let base = merge_base.clone();
let tip = current_commit.clone();
Some(
cx.background_spawn(async move {
let repo = cache.ensure_clone(&addr, &clone_urls)?;
let workdir = repo
.workdir()
.ok_or_else(|| anyhow::anyhow!("repository has no worktree"))?
.to_path_buf();
let tip =
tip.ok_or_else(|| anyhow::anyhow!("pull request has no tip commit"))?;
let base = match base {
Some(base) => base,
// No `merge-base` tag: use the merge base of the
// tip with the default branch.
// No `merge-base` tag. Use the merge base of the tip and the default branch.
None => {
let head = repo
.head_id()
@@ -262,9 +216,11 @@ impl PullRequestDetailView {
repo.merge_base(tip_id, head)?.to_string()
}
};
let diff = signed_git::worktree_commit_range_diff(&workdir, &base, &tip)?;
let commits =
signed_git::worktree_commit_range_commits(&workdir, &base, &tip)?;
Ok::<_, anyhow::Error>((diff, commits, workdir))
})
.await,
@@ -281,37 +237,18 @@ impl PullRequestDetailView {
this.loading = false;
this.worktree = worktree;
this.current_commit = current_commit.map(SharedString::from);
this.commit_item_sizes =
Rc::new(vec![size(px(0.), px(PR_COMMIT_ROW_HEIGHT)); commits.len()]);
this.commit_item_sizes = Rc::new(vec![size(px(0.), px(ROW_HEIGHT)); commits.len()]);
this.commits = commits;
match diff {
Ok(diff) => {
let mut paths: Vec<PathBuf> = diff
.files
.iter()
.map(|file| PathBuf::from(&file.path))
.collect();
paths.sort();
let items = tree_items(build_tree_items(&paths), true);
let first = diff
.files
.first()
.map(|file| SharedString::from(file.path.as_str()));
this.tree_state.update(cx, |state, cx| {
state.set_items(items.clone(), cx);
let item = find_item(&items, first.as_deref());
state.set_selected_item(item, cx);
});
this.selected_file = first.clone();
this.diff = Some(diff);
if let Some(path) = first {
this.set_diff_rows(path.as_ref());
}
this.pane.update(cx, |pane, cx| pane.set_diff(diff, cx));
}
Err(error) => {
this.error = Some(error.to_string().into());
}
}
cx.notify();
})?;
@@ -322,27 +259,6 @@ impl PullRequestDetailView {
self.tasks.push(task);
}
/// Show the diff of the file at `path` (selected in the tree).
fn select_file(&mut self, path: &str, cx: &mut Context<Self>) {
self.selected_file = Some(path.into());
self.set_diff_rows(path);
cx.notify();
}
/// Rebuild the virtual list state for the file at `path` and scroll back
/// to the top.
fn set_diff_rows(&mut self, path: &str) {
let Some(diff) = self.diff.as_ref() else {
return;
};
let Some(file) = diff.files.iter().find(|file| file.path == path) else {
return;
};
self.rows = diff_rows(file);
self.item_sizes = Rc::new(vec![size(px(0.), px(DIFF_ROW_HEIGHT)); self.rows.len()]);
self.scroll_handle.scroll_to_item(0, ScrollStrategy::Top);
}
/// Open the diff of `commit_id` in the bottom dock of the area.
fn open_commit_diff(
&mut self,
@@ -370,204 +286,9 @@ impl PullRequestDetailView {
});
}
/// One row of the changed-files tree: icon + name, indented by depth.
fn render_tree_item(
ix: usize,
entry: &TreeEntry,
selected: bool,
view: &WeakEntity<Self>,
) -> ListItem {
let view = view.clone();
let id = entry.item().id.clone();
tree_row(ix, entry, selected, move |_window, cx| {
if let Some(view) = view.upgrade() {
view.update(cx, |this, cx| this.select_file(&id, cx));
}
})
}
/// Left column: the changed-files tree.
fn render_tree_column(&self, cx: &mut Context<Self>) -> AnyElement {
let tree_state = self.tree_state.clone();
let view = cx.entity().downgrade();
v_flex()
.h_full()
.w(px(TREE_WIDTH))
.flex_none()
.border_r_1()
.border_color(cx.theme().border)
.child(
div()
.flex_1()
.min_h_0()
.when(self.diff.is_some(), |this| {
this.child(
tree(&tree_state, move |ix, entry, selected, _window, _cx| {
Self::render_tree_item(ix, entry, selected, &view)
})
.p_2(),
)
})
.when(self.diff.is_none() && !self.loading, |this| {
this.child(placeholder("Failed to load diff", cx))
}),
)
.into_any_element()
}
/// Right column: header of the selected file plus its diff.
fn render_detail_column(&self, cx: &mut Context<Self>) -> AnyElement {
if self.loading {
return v_flex()
.size_full()
.items_center()
.justify_center()
.child(Spinner::new().small())
.into_any_element();
}
if let Some(error) = self.error.clone() {
return placeholder(&error, cx);
}
let Some(diff) = self.diff.as_ref() else {
return placeholder("Failed to load diff", cx);
};
let Some(path) = self.selected_file.clone() else {
return if diff.files.is_empty() {
placeholder("No files changed in this pull request", cx)
} else {
placeholder("Select a file", cx)
};
};
let Some(file) = diff.files.iter().find(|file| file.path == path.as_ref()) else {
return placeholder("File not found", cx);
};
self.render_file_diff(file, cx.entity(), cx)
}
/// The diff of one file: a header with status and stats, then the hunks
/// in a virtual list (a large diff is never materialized per frame).
fn render_file_diff(&self, file: &FileDiff, view: Entity<Self>, cx: &App) -> AnyElement {
let status_label = match file.status {
signed_git::DiffStatus::Added => "A",
signed_git::DiffStatus::Modified => "M",
signed_git::DiffStatus::Deleted => "D",
signed_git::DiffStatus::Renamed => "R",
signed_git::DiffStatus::Copied => "C",
};
let status_color = match file.status {
signed_git::DiffStatus::Added => cx.theme().success,
signed_git::DiffStatus::Modified => cx.theme().info,
signed_git::DiffStatus::Deleted => cx.theme().danger,
signed_git::DiffStatus::Renamed | signed_git::DiffStatus::Copied => {
cx.theme().muted_foreground
}
};
let title = match &file.old_path {
Some(old) => format!("{old}{}", file.path),
None => file.path.clone(),
};
let body: AnyElement = if file.binary {
placeholder("Diff not available", cx)
} else if file.hunks.is_empty() {
placeholder("No content changes", cx)
} else {
let sizes = self.item_sizes.clone();
let scroll_handle = self.scroll_handle.clone();
v_flex()
.size_full()
.relative()
.child(
v_virtual_list(
view,
"pr-diff-rows",
sizes,
move |this, range, _window, cx| {
let Some(diff) = this.diff.as_ref() else {
return Vec::new();
};
let Some(path) = this.selected_file.as_deref() else {
return Vec::new();
};
let Some(file) = diff.files.iter().find(|file| file.path == path)
else {
return Vec::new();
};
range
.map(|ix| render_diff_row(&file.hunks, this.rows[ix], cx))
.collect()
},
)
.track_scroll(&scroll_handle)
.size_full(),
)
.child(
div()
.absolute()
.top_0()
.left_0()
.right_0()
.bottom_0()
.child(Scrollbar::vertical(&scroll_handle)),
)
.into_any_element()
};
v_flex()
.flex_1()
.min_w_0()
.h_full()
.child(
h_flex()
.px_3()
.h_9()
.gap_2()
.items_center()
.child(
div()
.text_xs()
.font_semibold()
.text_color(status_color)
.child(status_label),
)
.child(
div()
.flex_1()
.min_w_0()
.text_xs()
.font_semibold()
.text_ellipsis()
.whitespace_nowrap()
.child(title),
)
.when(!file.binary, |this| {
this.child(
h_flex()
.gap_2()
.text_xs()
.child(
div()
.text_color(cx.theme().success)
.child(format!("+{}", file.insertions)),
)
.child(
div()
.text_color(cx.theme().danger)
.child(format!("-{}", file.deletions)),
),
)
}),
)
.child(div().id("pr-diff-body").flex_1().min_h_0().child(body))
.into_any_element()
}
/// Underline tab bar: Discussion, Files and Commits.
fn render_tabs(&self, cx: &mut Context<Self>) -> AnyElement {
let active = self.active_tab;
let files_count = self.diff.as_ref().map(|diff| diff.files.len());
let files_count = self.pane.read(cx).diff().map(|diff| diff.files.len());
let commits_count = if self.commits.is_empty() {
None
} else {
@@ -589,29 +310,19 @@ impl PullRequestDetailView {
Tab::new()
.label("Files")
.when_some(files_count, |this, count| {
this.suffix(
Tag::secondary()
.xsmall()
.child(SharedString::from(count.to_string())),
)
this.suffix(CountBadge::new(count))
}),
)
.child(
Tab::new()
.label("Commits")
.when_some(commits_count, |this, count| {
this.suffix(
Tag::secondary()
.xsmall()
.child(SharedString::from(count.to_string())),
)
this.suffix(CountBadge::new(count))
}),
)
.into_any_element()
}
/// Discussion tab: author, description and comments like the issue
/// panel, with the comment form at the end and a sidebar on the right.
fn render_discussion(&mut self, cx: &mut Context<Self>) -> AnyElement {
if self.loading {
return v_flex()
@@ -686,111 +397,49 @@ impl PullRequestDetailView {
this.child(div().text_sm().child(self.description.clone()))
}),
)
.child(self.render_comments(&root_id, cx))
.child(self.render_form(&root_id, cx)),
.child(comments_section(&self.store, root_id, cx))
.child(comment_form(
&self.store,
root_id,
pr_roots,
&self.comment_input,
"pr-comment",
cx,
)),
),
)
.child(self.render_sidebar(cx))
.child(sidebar_section(&self.store, root_id, pr_roots, true, cx))
.into_any_element()
}
/// Right sidebar: participants and labels, like the issue panel.
fn render_sidebar(&self, cx: &mut Context<Self>) -> AnyElement {
let profile_store = ProfileStore::global(cx);
let store = self.store.read(cx);
let Some(root) = store
.pull_requests
.iter()
.find(|pr| pr.id == self.pr_id && pr.kind == Kind::GitPullRequest)
else {
// `render_discussion` already bails out when the PR is missing.
return div().into_any_element();
};
// Participants: the PR 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();
// PR 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()
.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()
}
/// Files tab: the changed-files tree on the left, the diff of the
/// selected file on the right.
fn render_files_tab(&self, cx: &mut Context<Self>) -> AnyElement {
if self.loading {
return v_flex()
.flex_1()
.w_full()
.min_h_0()
.items_center()
.justify_center()
.child(Spinner::new().small())
.into_any_element();
}
if let Some(error) = self.error.clone() {
return placeholder(&error, cx);
}
h_flex()
.flex_1()
.w_full()
.min_h_0()
.overflow_hidden()
.child(self.render_tree_column(cx))
.child(self.render_detail_column(cx))
.child(self.pane.clone())
.into_any_element()
}
/// Full-height Commits tab: every commit of the patch series, or a
/// status message while loading / when there are none.
/// Full-height Commits tab.
///
/// Every commit of the patch series, or a status message while loading or empty.
fn render_commits_tab(&self, cx: &mut Context<Self>) -> AnyElement {
if self.loading {
return v_flex()
@@ -838,8 +487,9 @@ impl PullRequestDetailView {
.into_any_element()
}
/// One row of the commits tab: id, summary, author and time. Clicking a
/// row opens the commit's diff in the bottom dock.
/// One row of the commits tab, id, summary, author and time.
///
/// Clicking a row opens the commit's diff in the bottom dock.
fn render_commit_row(
&self,
ix: usize,
@@ -852,7 +502,7 @@ impl PullRequestDetailView {
h_flex()
.id(ix)
.px_4()
.h(px(PR_COMMIT_ROW_HEIGHT))
.h(px(ROW_HEIGHT))
.gap_2()
.items_center()
.text_sm()
@@ -882,8 +532,8 @@ impl PullRequestDetailView {
.child(SharedString::from(meta)),
)
})
// Commits parsed from the nostr patch set may not exist in any
// local clone; only git-backed PRs open a diff viewer.
// Commits parsed from the nostr patch set may not exist in any local clone.
// Only git-backed PRs open a diff viewer.
.when_some(self.worktree.clone(), |this, worktree| {
this.on_click(cx.listener(move |this, _event, window, cx| {
this.open_commit_diff(worktree.clone(), &id, window, cx);
@@ -892,117 +542,7 @@ impl PullRequestDetailView {
.into_any_element()
}
/// One comment card, same design as the issue panel: avatar, author,
/// "commented" and age on the header row, content below.
fn render_comments(&mut self, id: &EventId, cx: &mut Context<Self>) -> AnyElement {
let store = self.store.read(cx);
let comments: Vec<&Event> = store.comments_of(id).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);
// Comment bodies are cloned into shared strings once per
// comment, not on every render.
let content = self
.contents
.entry(comment.id)
.or_insert_with(|| SharedString::from(comment.content.clone()))
.clone();
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()
}
fn render_form(&mut self, id: &EventId, cx: &mut Context<Self>) -> AnyElement {
let comment_input = self.comment_input.clone();
let store = self.store.clone();
let id = id.to_owned();
v_flex()
.gap_2()
.child(
Textarea::new(&self.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("pr-comment")
.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) = store
.read(cx)
.pull_requests
.iter()
.find(|pr| pr.id == id)
.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()
}
/// Always-visible header: status badge and title, like the issue panel.
/// Always-visible header with a status badge and title, like the issue panel.
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
let current_commit = self.current_commit.clone();
let (title, status, branch, author) = {
@@ -1022,7 +562,7 @@ impl PullRequestDetailView {
)
};
// Only the PR author may publish revisions (kind 1619, NIP-34).
// Only the PR author may publish revisions, NIP-34 kind 1619.
let backend = Backend::global(cx);
let can_update = backend.read(cx).current_user() == Some(author);
@@ -1104,8 +644,7 @@ impl PullRequestDetailView {
}
}
/// Open the "update pull request" dialog: a patch input that submits a new
/// revision through [`RepoStore::update_pull_request`] when confirmed.
/// Open the update pull request dialog.
fn open_update_pull_request_dialog(
store: Entity<RepoStore>,
root: Event,
@@ -1115,8 +654,8 @@ fn open_update_pull_request_dialog(
let patch = cx.new(|cx| {
TextareaState::new(window, cx).placeholder("Paste the updated `git format-patch` output...")
});
// Both the dialog body and the submit button capture the root event;
// share it instead of cloning into each closure.
// Both the dialog body and submit button capture the root event.
// Share it instead of cloning into each closure.
let root = Rc::new(root);
window.open_dialog(cx, move |dialog, _window, _cx| {
@@ -1166,20 +705,9 @@ fn open_update_pull_request_dialog(
});
}
/// One sidebar section title.
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()
}
/// The `c` tag of a PR event (tip of the proposed branch), as hex.
fn current_commit_of(event: &Event) -> Option<String> {
event
.tags
/// The `c` tag of a PR event, the commit the proposal points at.
fn current_commit_of(root: &Event) -> Option<String> {
root.tags
.iter()
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
Ok(Nip34Tag::CurrentCommit(commit)) => Some(commit.to_string()),
@@ -1187,8 +715,9 @@ fn current_commit_of(event: &Event) -> Option<String> {
})
}
/// The `merge-base` tag of a PR event (most recent common ancestor with the
/// target branch), as hex.
/// The `merge-base` tag of a PR event, as hex.
///
/// The most recent common ancestor with the target branch.
fn merge_base_of(event: &Event) -> Option<String> {
event
.tags
@@ -1199,8 +728,9 @@ fn merge_base_of(event: &Event) -> Option<String> {
})
}
/// The `clone` tag of a PR event (URLs where the proposed branch can be
/// fetched), or `None` if the PR has none.
/// The `clone` tag of a PR event.
///
/// URLs where the proposed branch can be fetched, or `None` if the PR has none.
fn clone_urls_of(event: &Event) -> Option<Vec<String>> {
event
.tags
@@ -1222,9 +752,7 @@ fn branch_name_of(event: &Event) -> Option<String> {
})
}
/// The latest PR update (kind 1619) revising `root`, found via its NIP-22
/// `E` tag pointing at the root PR event. Only updates by the PR author
/// count: the tip of a PR is only mutable by its author (NIP-34).
/// The latest PR update, kind 1619, revising `root`.
fn latest_update<'a>(events: impl Iterator<Item = &'a Event>, root: &Event) -> Option<&'a Event> {
let root_hex = root.id.to_hex();
events
@@ -1238,8 +766,9 @@ fn latest_update<'a>(events: impl Iterator<Item = &'a Event>, root: &Event) -> O
.max_by_key(|e| e.created_at)
}
/// One-line commit metadata for the commits list: author and relative time,
/// whichever is available.
/// One-line commit metadata for the commits list.
///
/// Author and relative time, whichever is available.
fn commit_meta(commit: &FileCommit) -> String {
let author = commit.author.trim();
let time = commit.time > 0;
@@ -1281,7 +810,7 @@ impl Focusable for PullRequestDetailView {
impl Render for PullRequestDetailView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.image_cache(image_cache("pull-request-detail", MAX_IMAGES))
.image_cache(gpui::retain_all("pull-request-detail"))
.id("pull-request-detail")
.size_full()
.min_h_0()
@@ -1357,8 +886,7 @@ mod tests {
created_at,
)
};
// An update revising a different PR must be ignored even though it
// is newer.
// An update revising a different PR must be ignored even though it is newer.
let unrelated = signed(
Kind::GitPullRequestUpdate,
vec![Tag::parse(["E", OTHER_ROOT_HEX]).expect("valid tag")],
@@ -1386,8 +914,8 @@ mod tests {
.finalize(&other)
.expect("signed event");
// The tip of a PR is only mutable by its author: a newer update
// from anyone else must not win.
// The tip of a PR is only mutable by its author.
// A newer update from anyone else must not win.
assert!(latest_update([&stranger, &root].into_iter(), &root).is_none());
}
@@ -1,7 +1,7 @@
use std::rc::Rc;
use assets::CustomIconName;
use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, panel_handle};
use dock::{BasePanel, DockArea, Panel, PanelEvent, add_center_panel, panel_handle};
use gpui::prelude::*;
use gpui::{
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render,
@@ -16,7 +16,6 @@ use gpui_component::{
use nostr::prelude::{EventId, Kind};
use signed_core::{RepoStatus, activity_subject};
use signed_state::{ProfileStore, RepoStore};
use signed_ui::image_cache::{MAX_IMAGES, image_cache};
use signed_ui::{DropdownButton, SegmentButton, UserAvatar, placeholder, status_badge};
use utils::relative_time;
@@ -25,12 +24,10 @@ use super::new_pull_request::open_new_pull_panel;
use super::pull_request_detail::PullRequestDetailView;
use super::send_patch::open_send_patch_panel;
/// Height of one pull request row in the virtual list; same layout as an
/// issue row.
const PR_ROW_HEIGHT: f32 = 73.;
/// Height of one pull request row in the virtual list.
const ROW_HEIGHT: f32 = 73.;
/// Status filter of the pull request list, chosen via the header's filter
/// buttons.
/// Status filter of the pull request list, chosen via the header's filter buttons.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PullRequestFilter {
/// Every pull request, regardless of status.
@@ -70,17 +67,11 @@ pub struct PullRequestsView {
filter: PullRequestFilter,
/// Per-row heights of the virtual list.
item_sizes: Rc<Vec<Size<Pixels>>>,
/// Number of rows [`Self::item_sizes`] was built for (the filtered
/// pull request count); rebuilt on change.
/// The filtered pull request count [`Self::item_sizes`] was built for.
pr_len: usize,
/// Indices into the store's `pull_requests` matching [`Self::filter`]
/// (root PR events only; updates are revisions of the root); the
/// virtual list renders this slice. Rebuilt only when the store
/// version or the filter changes, keyed by [`Self::cache_key`].
/// Indices into the store's `pull_requests` matching [`Self::filter`].
visible_prs: Vec<usize>,
/// Header counts `(total, open, closed, draft, merged)` of the root
/// pull requests only (revisions are not separate PRs), rebuilt with
/// [`Self::visible_prs`].
/// Header counts `(total, open, closed, draft, merged)`.
counts: (usize, usize, usize, usize, usize),
/// Store version and filter the cached rows/counts were built from.
cache_key: Option<(u64, PullRequestFilter)>,
@@ -92,10 +83,11 @@ impl PullRequestsView {
pub fn new(
dock_area: WeakEntity<DockArea>,
store: Entity<RepoStore>,
repo_name: SharedString,
_window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let repo_name = store.read(cx).name();
Self {
focus_handle: cx.focus_handle(),
dock_area,
@@ -111,7 +103,7 @@ impl PullRequestsView {
}
}
/// Open the detail panel of `pr_id` at the bottom of the dock area.
/// Open the detail panel of `pr_id` in the dock area.
fn open_pull_request_detail(
&mut self,
pr_id: EventId,
@@ -133,12 +125,13 @@ impl PullRequestsView {
});
dock_area.update(cx, |dock_area, cx| {
dock_area.add_panel_view(panel_handle(panel), DockPlacement::Center, None, window, cx);
add_center_panel(dock_area, panel_handle(panel), window, cx);
});
}
/// Render one row of the pull request list; `ix` is the row index and
/// `pr_ix` the index of the pull request in the store's `pull_requests`.
/// Render one row of the pull request list.
///
/// `ix` is the row index, `pr_ix` the index in the store's `pull_requests`.
fn render_row(&self, ix: usize, pr_ix: usize, cx: &mut Context<Self>) -> AnyElement {
let pr = &self.store.read(cx).pull_requests[pr_ix];
let pr_id = pr.id;
@@ -203,8 +196,7 @@ impl PullRequestsView {
}
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
// Counts of the last list rebuild (`render` rebuilds first when the
// store version or filter changed, so this is never stale).
// Counts of the last list rebuild.
let (total, open, closed, draft, merged) = self.counts;
h_flex()
@@ -339,9 +331,9 @@ impl Render for PullRequestsView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let filter = self.filter;
// Rebuild the filtered rows and header counts only when the store
// refreshed or the filter changed; other renders reuse the cache.
// Rows and counts are rebuilt only when the store refreshed or filter changed.
let version = self.store.read(cx).version();
if self.cache_key != Some((version, filter)) {
let store = self.store.read(cx);
let mut counts = (0usize, 0usize, 0usize, 0usize, 0usize);
@@ -350,43 +342,43 @@ impl Render for PullRequestsView {
.iter()
.enumerate()
.filter_map(|(ix, pr)| {
// Kind-30620 patches are revisions of a root PR (NIP-34),
// not separate pull requests: count only root events, or
// the header counts inflate with every revision (which
// also default to `Open` in `status_of`).
if pr.kind != Kind::GitPullRequest {
return None;
}
let status = store.status_of(pr);
counts.0 += 1;
match status {
RepoStatus::Open => counts.1 += 1,
RepoStatus::Closed => counts.2 += 1,
RepoStatus::Draft => counts.3 += 1,
RepoStatus::Applied => counts.4 += 1,
}
filter.matches(status).then_some(ix)
})
.collect();
self.counts = counts;
self.cache_key = Some((version, filter));
}
let count = self.visible_prs.len();
// The virtual list's item count comes from `item_sizes`; rebuild it
// whenever the filtered pull request count changes.
// The virtual list's item count comes from `item_sizes`.
// Rebuild it whenever the filtered pull request count changes.
if count != self.pr_len {
self.pr_len = count;
self.item_sizes = Rc::new(vec![size(px(0.), px(PR_ROW_HEIGHT)); count]);
self.item_sizes = Rc::new(vec![size(px(0.), px(ROW_HEIGHT)); count]);
}
let sizes = self.item_sizes.clone();
let scroll_handle = self.scroll_handle.clone();
let view = cx.entity().clone();
// Non-fatal warnings and errors of the last action (e.g. creating
// or updating a PR), shown as dismissible banners above the list.
// Non-fatal warnings and errors of the last action, like creating or updating a PR.
// Shown as dismissible banners above the list.
let (last_error, last_warning) = {
let store = self.store.read(cx);
(store.last_error.clone(), store.last_warning.clone())
@@ -394,7 +386,7 @@ impl Render for PullRequestsView {
v_flex()
.size_full()
.image_cache(image_cache("pull-requests", MAX_IMAGES))
.image_cache(gpui::retain_all("pull-requests"))
.on_action(cx.listener(|this, action: &RepoAction, window, cx| {
if action == &RepoAction::SendPatch {
open_send_patch_panel(this.dock_area.clone(), this.store.clone(), window, cx);
@@ -1,11 +1,11 @@
use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, panel_handle};
use dock::{BasePanel, DockArea, Panel, PanelEvent, add_center_panel, panel_handle};
use gpui::prelude::*;
use gpui::{
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, SharedString,
Subscription, WeakEntity, Window, div, px,
WeakEntity, Window, div, px,
};
use gpui_base::{Button as BaseButton, StyledExt};
use gpui_component::input::{Input, InputEvent, InputState, Textarea, TextareaState};
use gpui_component::input::{Input, InputState, Textarea, TextareaState};
use gpui_component::scroll::ScrollableElement;
use gpui_component::spinner::Spinner;
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, h_flex, v_flex};
@@ -19,17 +19,16 @@ pub struct SendPatchView {
store: Entity<RepoStore>,
/// Display name of the repository, for the panel title.
repo_name: SharedString,
/// Title input (required).
/// Title input, required.
subject: Entity<InputState>,
/// Description input (optional).
/// Description input, optional.
description: Entity<TextareaState>,
/// The pasted `git format-patch` output (required).
/// The pasted `git format-patch` output, required.
patch: Entity<TextareaState>,
/// A submit is in flight.
submitting: bool,
/// Error of the last submit attempt (keeps the panel open).
/// Error of the last submit attempt, it keeps the panel open.
error: Option<SharedString>,
_subscriptions: Vec<Subscription>,
}
impl SendPatchView {
@@ -41,22 +40,14 @@ impl SendPatchView {
) -> Self {
let repo_name = store.read(cx).name();
let subject = cx.new(|cx| InputState::new(window, cx).placeholder("Title"));
let description = cx
.new(|cx| TextareaState::new(window, cx).placeholder("Describe the change (optional)"));
let patch = cx.new(|cx| {
TextareaState::new(window, cx).placeholder("diff --git a/file.txt b/file.txt\nindex 1234567..abcdefg 100644\n--- a/file.txt\n+++ b/file.txt")
});
// Re-evaluate the Send button's enabled state as the inputs change.
let subscriptions = vec![
cx.subscribe(&subject, |_this, _state, _event: &InputEvent, cx| {
cx.notify();
}),
cx.subscribe(&patch, |_this, _state, _event: &InputEvent, cx| {
cx.notify();
}),
];
Self {
focus_handle: cx.focus_handle(),
dock_area,
@@ -67,24 +58,23 @@ impl SendPatchView {
patch,
submitting: false,
error: None,
_subscriptions: subscriptions,
}
}
/// Publish the pull request from the pasted patch. The store validates
/// synchronously (patch shape, per-part size, sign-in); on failure the
/// panel stays open with the error inline, on success it closes — async
/// publish failures surface in the pull request list's banner.
/// Publish the pull request from the pasted patch.
fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.submitting {
return;
}
let subject = self.subject.read(cx).value().to_string();
let description = self.description.read(cx).value().to_string();
let patch = self.patch.read(cx).value().to_string();
if patch.is_empty() {
return;
}
let store = self.store.clone();
let dock_area = self.dock_area.clone();
let entity = cx.entity().clone();
@@ -93,8 +83,7 @@ impl SendPatchView {
self.error = None;
cx.notify();
// Errors the store detects before publishing are returned
// synchronously through `last_error`.
// Errors the store detects before publishing.
let sync_error = store.update(cx, |store, cx| {
store.open_pull_request(
(!subject.is_empty()).then_some(subject),
@@ -128,6 +117,7 @@ impl SendPatchView {
}
}
});
cx.notify();
}
@@ -212,7 +202,7 @@ pub(super) fn open_send_patch_panel(
let panel = cx.new(|cx| SendPatchView::new(dock_area.clone(), store, window, cx));
let _ = dock_area.update(cx, |dock_area, cx| {
dock_area.add_panel_view(panel_handle(panel), DockPlacement::Center, None, window, cx);
add_center_panel(dock_area, panel_handle(panel), window, cx);
});
}