This commit is contained in:
2026-09-04 16:02:51 +07:00
parent 17de4f6376
commit 1d224218df
38 changed files with 894 additions and 2482 deletions
-1
View File
@@ -23,6 +23,5 @@ gix.workspace = true
nostr.workspace = true
anyhow.workspace = true
chrono.workspace = true
futures.workspace = true
log.workspace = true
-1
View File
@@ -3,7 +3,6 @@ mod workspace;
use gpui::{App, AppContext, Entity, Window};
use gpui_component::Root;
pub use signed_ui::image_cache;
pub use views::{RepoListView, SidebarPanel};
pub use workspace::Workspace;
@@ -0,0 +1,36 @@
use gpui::prelude::*;
use gpui::{AnyElement, App, SharedString, div};
use gpui_component::ActiveTheme;
/// Progress of an async dialog action: a busy flag disabling the form,
/// and an error line shown under it.
#[derive(Debug, Default)]
pub struct DialogProgress {
pub busy: bool,
pub error: Option<SharedString>,
}
impl DialogProgress {
/// An action started, disable the form and clear the previous error.
pub fn begin(&mut self) {
self.busy = true;
self.error = None;
}
/// An action failed, re-enable the form and surface `message`.
pub fn fail(&mut self, message: impl Into<SharedString>) {
self.busy = false;
self.error = Some(message.into());
}
}
/// The shared error line under a dialog form, `None` when there is no error.
pub fn error_row(error: &Option<SharedString>, cx: &App) -> Option<AnyElement> {
error.as_ref().map(|message| {
div()
.text_sm()
.text_color(cx.theme().danger)
.child(message.clone())
.into_any_element()
})
}
+1
View File
@@ -1,3 +1,4 @@
mod dialog_state;
mod repo_detail;
mod repo_list;
pub(crate) mod sidebar;
@@ -325,7 +325,7 @@ pub struct CommitDiffView {
error: Option<SharedString>,
/// 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>>>,
}
@@ -1,15 +1,22 @@
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::input::{Textarea, TextareaState};
use gpui_component::menu::PopupMenu;
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;
pub(super) struct TreeItemSeed {
/// Path of the node, relative to the worktree root.
@@ -348,6 +355,229 @@ 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
}
/// 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.
///
/// Comment bodies become shared strings once per comment, not per render.
pub(super) fn comments_section(
store: &Entity<RepoStore>,
root: EventId,
contents: &mut HashMap<EventId, SharedString>,
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 = 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()
}
/// 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::*;
@@ -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,16 +14,13 @@ 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`.
pub fn open(
@@ -113,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")
@@ -169,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| {
@@ -210,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,24 +1,22 @@
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.
@@ -48,189 +46,6 @@ impl IssueDetailView {
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 become shared strings once per comment, not per 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 {
@@ -300,7 +115,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(
@@ -357,20 +172,29 @@ 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,
&mut self.contents,
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()
}
@@ -18,7 +18,6 @@ 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;
@@ -363,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()
@@ -33,7 +33,6 @@ use signed_state::{
Backend, CheckoutStatus, CheckoutsStore, GitStore, LocalReposStore, ProfileStore,
RepoListStore, RepoStore, pr_proposes_checkout,
};
use signed_ui::image_cache::{MAX_IMAGES, image_cache};
use signed_ui::{DropdownButton, PixelAvatar, UserAvatar, copy_row};
mod about;
@@ -2345,7 +2344,7 @@ impl Render for RepoDetailView {
.or_else(|| self.render_push_banner(cx));
v_flex()
.image_cache(image_cache("repo", MAX_IMAGES))
.image_cache(gpui::retain_all("repo"))
.id("repo")
.size_full()
.child(self.render_header(cx))
@@ -7,38 +7,30 @@ 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, 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::{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,
};
/// Width of the changed-files column.
const TREE_WIDTH: f32 = 260.;
use super::diff::{CommitDiffView, DiffPane};
use super::helpers::{comment_form, comments_section, pr_roots, sidebar_section};
/// Height of one commit row in the commits tab's virtual list.
const ROW_HEIGHT: f32 = 37.;
@@ -65,23 +57,13 @@ pub struct PullRequestDetailView {
current_commit: Option<SharedString>,
/// 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_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 and 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,
/// 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.
@@ -101,7 +83,7 @@ impl PullRequestDetailView {
cx: &mut Context<Self>,
) -> Self {
let repo_name = store.read(cx).name();
let tree_state = cx.new(|cx| TreeState::new(cx));
let pane = cx.new(DiffPane::new);
let comment_input =
cx.new(|cx| TextareaState::new(window, cx).placeholder("Leave a comment..."));
@@ -122,15 +104,10 @@ 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(),
@@ -270,27 +247,7 @@ impl PullRequestDetailView {
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());
@@ -307,26 +264,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,
@@ -354,206 +291,9 @@ impl PullRequestDetailView {
});
}
/// One row of the changed-files tree, icon and 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));
}
})
}
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()
}
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)
}
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()
}
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 {
@@ -670,104 +410,48 @@ 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,
&mut self.contents,
cx,
))
.child(comment_form(
&self.store,
root_id,
pr_roots,
&self.comment_input,
"pr-comment",
cx,
)),
),
)
.child(self.render_sidebar(cx))
.into_any_element()
}
/// Right sidebar with 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
}))
}
}),
)
.child(sidebar_section(&self.store, root_id, pr_roots, true, cx))
.into_any_element()
}
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()
}
@@ -876,114 +560,6 @@ impl PullRequestDetailView {
.into_any_element()
}
/// One comment card, same design as the issue panel.
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 become shared strings once per comment, not per 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 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();
@@ -1147,20 +723,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, the 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()),
@@ -1263,7 +828,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()
@@ -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;
@@ -387,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 -2
View File
@@ -15,7 +15,6 @@ use gpui_component::{
};
use signed_core::Announcement;
use signed_state::{ProfileStore, RepoListStore, Timestamp};
use signed_ui::image_cache::{MAX_IMAGES, image_cache};
use signed_ui::{SegmentButton, UserAvatar};
use utils::relative_time;
@@ -397,7 +396,7 @@ impl Render for RepoListView {
v_flex()
.relative()
.image_cache(image_cache("repos", MAX_IMAGES))
.image_cache(gpui::retain_all("repos"))
.size_full()
.child(self.render_header(count, cx))
.when(!has_repos, |this| {
@@ -2,26 +2,23 @@ use std::path::PathBuf;
use dock::DockArea;
use gpui::prelude::*;
use gpui::{App, Entity, PathPromptOptions, SharedString, WeakEntity, Window, div, px};
use gpui::{App, Entity, PathPromptOptions, WeakEntity, Window, div, px};
use gpui_base::input::TextareaState;
use gpui_component::button::{Button, ButtonVariants};
use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle};
use gpui_component::form::{field, v_form};
use gpui_component::input::{Input, InputState, Textarea};
use gpui_component::{ActiveTheme, Disableable, IconName, WindowExt, h_flex};
use gpui_component::{Disableable, IconName, WindowExt, h_flex};
use settings::SettingsStore;
use signed_core::Announcement;
use signed_state::{Backend, CheckoutsStore};
use super::super::open_repo_panel;
use super::grasp_servers::{GraspServersState, grasp_servers_field, load_user_grasp_servers};
use crate::views::dialog_state::{DialogProgress, error_row};
/// Shared state for the Create Repository dialog, so async results can be rendered.
#[derive(Default)]
pub struct CreateRepoState {
pub busy: bool,
pub error: Option<SharedString>,
}
pub type CreateRepoState = DialogProgress;
/// Open the Create Repository dialog.
pub fn open(dock_area: WeakEntity<DockArea>, window: &mut Window, cx: &mut App) {
@@ -117,9 +114,7 @@ pub fn open(dock_area: WeakEntity<DockArea>, window: &mut Window, cx: &mut App)
)
.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("create")
@@ -211,22 +206,15 @@ fn create_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| {
@@ -254,10 +242,7 @@ fn create_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();
}
@@ -6,7 +6,6 @@ use gpui_component::input::{Input, InputState};
use gpui_component::{ActiveTheme, IconName, Sizable, h_flex, v_flex};
use nostr::prelude::*;
use settings::{DEFAULT_GRASP_SERVERS, GraspServersSettings};
use signed_core::filters;
use signed_state::Backend;
/// State of the grasp-server section of a publish dialog, so async results can be rendered.
@@ -238,30 +237,7 @@ pub fn load_user_grasp_servers(
let handle = window.window_handle();
cx.spawn(async move |cx| {
let result: anyhow::Result<Vec<RelayUrl>> = async {
let mut events: Vec<Event> = client
.database()
.query(filters::grasp_list(user))
.await?
.into_iter()
.collect();
events.sort_by_key(|event| event.created_at);
Ok(events
.into_iter()
.last()
.map(|event| {
event
.tags
.iter()
.filter(|tag| tag.kind() == "g")
.filter_map(|tag| tag.content())
.filter_map(|url| RelayUrl::parse(url).ok())
.collect()
})
.unwrap_or_default())
}
.await;
let result = signed_state::user_grasp_list_servers(client, user).await;
let _ = cx.update_window(handle, |_, _window, cx| {
state.update(cx, |state, _| {
+1 -2
View File
@@ -18,7 +18,6 @@ use signed_core::{Announcement, identifier_from_name};
use signed_state::{
Backend, BackendEvent, CheckoutsStore, LocalReposStore, Profile, ProfileStore, RepoListStore,
};
use signed_ui::image_cache::{MAX_IMAGES, image_cache};
use signed_ui::{CountBadge, NavItem, PixelAvatar, UserAvatar, title_bar_drag_handlers};
use super::{RepoDetailView, RepoListView, open_repo_panel};
@@ -559,7 +558,7 @@ impl Render for SidebarPanel {
v_flex()
.size_full()
.justify_between()
.image_cache(image_cache("sidebar", MAX_IMAGES))
.image_cache(gpui::retain_all("sidebar"))
.bg(cx.theme().sidebar)
.text_color(cx.theme().sidebar_foreground)
.child(
@@ -1,18 +1,16 @@
use gpui::prelude::*;
use gpui::{App, Entity, SharedString, Window, div, px};
use gpui::{App, Entity, Window, px};
use gpui_component::button::{Button, ButtonVariants};
use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle};
use gpui_component::form::{field, v_form};
use gpui_component::input::{Input, InputState};
use gpui_component::{ActiveTheme, Disableable, WindowExt};
use gpui_component::{Disableable, WindowExt};
use signed_state::Backend;
use crate::views::dialog_state::{DialogProgress, error_row};
/// Shared state for the Onboarding dialog, so async results can be rendered.
#[derive(Default)]
pub struct OnboardingState {
pub busy: bool,
pub error: Option<SharedString>,
}
pub type OnboardingState = DialogProgress;
/// Open the Onboarding dialog for creating a new identity.
pub fn open(
@@ -62,9 +60,7 @@ pub fn open(
)
.child(field().required(true).child(Input::new(&repass_input))),
)
.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("continue")
@@ -87,17 +83,12 @@ pub fn open(
if pass != repass {
state.update(cx, |state, _| {
state.busy = false;
state.error =
Some("Passphrases do not match".into());
state.fail("Passphrases do not match");
});
return;
}
state.update(cx, |state, _| {
state.busy = true;
state.error = None;
});
state.update(cx, |state, _| state.begin());
let task = backend.update(cx, |backend, cx| {
backend.create_identity(&name, &pass, cx)
@@ -115,8 +106,7 @@ pub fn open(
Err(e) => {
cx.update_window(handle, |_, _window, cx| {
state.update(cx, |state, _| {
state.busy = false;
state.error = Some(e.to_string().into());
state.fail(e.to_string());
});
})
.ok();
@@ -1,18 +1,20 @@
use assets::CustomIconName;
use gpui::prelude::*;
use gpui::{AnyWindowHandle, App, Entity, SharedString, Subscription, Window, div};
use gpui::{AnyWindowHandle, App, Entity, Subscription, Window};
use gpui_component::button::{Button, ButtonVariants};
use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle};
use gpui_component::form::{field, v_form};
use gpui_component::input::{Input, InputEvent, InputState};
use gpui_component::{ActiveTheme, Disableable, WindowExt};
use gpui_component::{Disableable, WindowExt};
use signed_state::Backend;
use crate::views::dialog_state::{DialogProgress, error_row};
/// Shared state for the passphrase dialog, so async results can be rendered.
#[derive(Default)]
pub struct PassphraseState {
pub busy: bool,
pub error: Option<SharedString>,
/// Progress of the unlock flow.
pub progress: DialogProgress,
/// Keeps the Enter-to-submit subscription alive while the dialog is open.
_enter_subscription: Option<Subscription>,
}
@@ -50,8 +52,8 @@ pub fn open(window: &mut Window, cx: &mut App) {
.overlay_closable(false)
.keyboard(false)
.content(move |content, _window, cx| {
let busy = state.read(cx).busy;
let error = state.read(cx).error.clone();
let busy = state.read(cx).progress.busy;
let error = state.read(cx).progress.error.clone();
content
.child(
@@ -70,9 +72,7 @@ pub fn open(window: &mut Window, cx: &mut App) {
.child(Input::new(&pass_input)),
),
)
.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("unlock")
@@ -107,15 +107,12 @@ fn unlock(
if pass.is_empty() {
state.update(cx, |state, _| {
state.error = Some("Passphrase must not be empty".into());
state.progress.fail("Passphrase must not be empty");
});
return;
}
state.update(cx, |state, _| {
state.busy = true;
state.error = None;
});
state.update(cx, |state, _| state.progress.begin());
let task = backend.update(cx, |backend, cx| backend.restore_with_passphrase(&pass, cx));
let handle = *handle;
@@ -130,10 +127,7 @@ fn unlock(
}
Err(e) => {
cx.update_window(handle, |_this, _window, cx| {
state.update(cx, |state, _| {
state.busy = false;
state.error = Some(e.to_string().into());
});
state.update(cx, |state, _| state.progress.fail(e.to_string()));
})
.ok();
}