add issues and prs button

This commit is contained in:
2026-08-17 09:13:04 +07:00
parent 2d281dfbbb
commit 9f484ea98d
4 changed files with 275 additions and 319 deletions
+7
View File
@@ -78,6 +78,13 @@ impl Announcement {
pub fn addr(&self) -> crate::RepoAddr {
crate::repo_addr(self.owner, self.id.clone())
}
/// The description of the repository, or a default if none is provided.
pub fn description(&self) -> SharedString {
self.description
.clone()
.unwrap_or(SharedString::from("No description"))
}
}
#[cfg(test)]
@@ -6,7 +6,7 @@
use gpui::prelude::*;
use gpui::{AnyElement, Context, Entity, SharedString, WeakEntity, Window, div, px};
use gpui_component::button::{Button, ButtonVariants};
use gpui_component::input::{Editor, EditorState, TabSize};
use gpui_component::input::{Editor, EditorState};
use gpui_component::list::ListItem;
use gpui_component::spinner::Spinner;
use gpui_component::text::{TextView, TextViewState};
@@ -281,12 +281,9 @@ impl RepoDetailView {
) {
let language = code_language(path.as_ref()).unwrap_or("text");
let state = cx.new(|cx| {
EditorState::new(language, window, cx)
EditorState::new(window, cx)
.language(language)
.default_value(text)
.tab_size(TabSize {
tab_size: 4,
hard_tabs: false,
})
.line_number(true)
.folding(true)
});
+192 -260
View File
@@ -8,16 +8,15 @@ use assets::CustomIconName;
use gix::Repository;
use gpui::prelude::*;
use gpui::{
AnyElement, App, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels,
Render, SharedString, Size, Subscription, Task, WeakEntity, Window, div, px, size,
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render,
SharedString, Size, Subscription, Task, WeakEntity, Window, div, px, size,
};
use gpui_component::avatar::{Avatar, AvatarGroup};
use gpui_component::button::{Button, ButtonVariants, DropdownButton};
use gpui_component::button::{Button, ButtonVariants};
use gpui_component::combobox::{
Caret, Combobox, ComboboxEvent, ComboboxState, ComboboxTriggerContext,
};
use gpui_component::dock::{DockArea, DockPlacement, Panel, PanelEvent};
use gpui_component::menu::PopupMenuItem;
use gpui_component::searchable_list::SearchableVec;
use gpui_component::tab::{Tab, TabBar};
use gpui_component::tag::Tag;
@@ -75,13 +74,8 @@ pub struct RepoDetailView {
/// Snapshot taken at open time, shown until the store's first refresh
/// completes (and as a fallback while the store has no announcement).
initial: Announcement,
/// Latest announcement from the store, cached so `render` (which runs
/// every frame) does not re-read and re-clone the store's copy.
announcement: Option<Announcement>,
/// Relay/web URLs of [`Self::announcement`] as display strings, for the
/// header dropdowns; `Rc` so the menu builders clone cheaply per frame.
relays: Rc<Vec<SharedString>>,
web: Rc<Vec<SharedString>>,
/// Per-repository nostr store (announcement, issues, PRs, statuses).
store: Entity<RepoStore>,
/// File explorer state (worktree of the local clone).
tree_state: Entity<TreeState>,
/// Root of the local clone, for reading files on demand.
@@ -135,8 +129,7 @@ pub struct RepoDetailView {
/// In-flight tasks; finished tasks are pruned on every push, so the vec
/// stays bounded by the number of concurrent loads.
tasks: Vec<Task<Result<(), Error>>>,
/// Subscriptions keeping the selectors' confirm events and the store's
/// refreshes alive.
/// Subscriptions keeping the selectors' confirm events alive.
_subscriptions: Vec<Subscription>,
}
@@ -170,36 +163,7 @@ impl RepoDetailView {
.searchable(true)
});
// Cache the announcement for the header: the store only changes it
// during debounced refreshes, but `render` runs every frame. The
// observe subscription owns the store for the view's lifetime.
let subscription = cx.observe(&store, |this, store, cx| {
let fresh = store.read(cx).announcement.clone();
if this.announcement == fresh {
return;
}
this.announcement = fresh;
// The header falls back to the open-time snapshot while the
// store has no announcement; keep its dropdown lists in sync.
let announcement = this.announcement.as_ref().unwrap_or(&this.initial);
this.relays = Rc::new(
announcement
.relays
.iter()
.map(|relay| relay.to_string().into())
.collect(),
);
this.web = Rc::new(
announcement
.web
.iter()
.map(|url| url.to_string().into())
.collect(),
);
cx.notify();
});
let mut subscriptions = vec![
let subscriptions = vec![
cx.subscribe_in(&branch_select, window, |this, _state, event, window, cx| {
// `Change` fires only when the selection actually changed
// (picking the already-selected branch emits nothing), so a
@@ -218,36 +182,16 @@ impl RepoDetailView {
}
}),
];
subscriptions.push(subscription);
// Defer loading the repository until the window is ready.
cx.defer_in(window, |this, window, cx| {
this.load_repo(window, cx);
});
// Header dropdowns of the open-time snapshot, until the store's
// first refresh replaces them.
let relays = Rc::new(
initial
.relays
.iter()
.map(|relay| relay.to_string().into())
.collect(),
);
let web = Rc::new(
initial
.web
.iter()
.map(|url| url.to_string().into())
.collect(),
);
Self {
initial,
dock_area,
announcement: None,
relays,
web,
store,
tree_state,
worktree: None,
md: None,
@@ -693,7 +637,7 @@ impl RepoDetailView {
};
// Same display name as the repo detail panel's title.
let repo_name = self.display_name();
let repo_name = self.display_name(cx);
let panel =
cx.new(|cx| CommitDiffView::new(worktree, repo_name, commit_id.into(), window, cx));
@@ -703,6 +647,18 @@ impl RepoDetailView {
});
}
/// Open the issue detail view (not implemented yet).
fn open_issue_detail(&mut self, _window: &mut Window, _cx: &mut Context<Self>) {
// TODO: open a per-issue detail view in the dock area, like
// [`Self::open_commit_diff`].
}
/// Open the pull request detail view (not implemented yet).
fn open_pull_request_detail(&mut self, _window: &mut Window, _cx: &mut Context<Self>) {
// TODO: open a per-PR detail view in the dock area, like
// [`Self::open_commit_diff`].
}
/// Check out `name` (a branch or tag picked in the header) and refresh
/// the explorer once the switch completes.
fn switch_ref(
@@ -930,164 +886,31 @@ impl RepoDetailView {
}
/// The latest announcement from the store, or the open-time snapshot.
fn announcement(&self) -> &Announcement {
self.announcement.as_ref().unwrap_or(&self.initial)
fn announcement<'a>(&'a self, cx: &'a App) -> &'a Announcement {
self.store
.read(cx)
.announcement
.as_ref()
.unwrap_or(&self.initial)
}
/// Display name: the announcement's name, or the ID if no name is set.
fn display_name(&self) -> SharedString {
let announcement = self.announcement();
fn display_name(&self, cx: &App) -> SharedString {
let announcement = self.announcement(cx);
announcement
.name
.clone()
.unwrap_or_else(|| SharedString::from(announcement.id.clone()))
}
/// Horizontal list of everyone who maintains the repository: the owner
/// shown in full, and any additional maintainers as a compact overlapping avatar group.
fn render_maintainers(&self, cx: &mut Context<Self>) -> AnyElement {
let announcement = self.announcement();
let profile_store = ProfileStore::global(cx);
let mut seen = HashSet::new();
let rest: Vec<_> = announcement
.maintainers
.iter()
.copied()
.filter(|key| *key != announcement.owner && seen.insert(*key))
.collect();
let owner = profile_store.read(cx).get(&announcement.owner);
let owner_name = owner.name();
let owner_picture = owner.picture();
h_flex()
.w_full()
.gap_3()
.items_center()
.flex_wrap()
.child(
h_flex()
.gap_1()
.items_center()
.child(
Avatar::new()
.name(owner_name.clone())
.when_some(owner_picture, |this, url| this.src(url))
.small(),
)
.child(div().text_xs().whitespace_nowrap().child(owner_name)),
)
.when(!rest.is_empty(), |this| {
this.child(AvatarGroup::new().small().limit(5).ellipsis().children(
rest.into_iter().map(|key| {
let profile = profile_store.read(cx).get(&key);
Avatar::new()
.name(profile.name())
.when_some(profile.picture(), |this, url| this.src(url))
}),
))
})
.into_any_element()
}
}
impl Panel for RepoDetailView {
fn panel_name(&self) -> &'static str {
"repo_detail"
}
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
self.display_name()
}
}
/// Read the worktree state of `repo` (no network): entries, README, refs
/// and HEAD commit. The tree is built off the main thread; the seeds are
/// plain owned strings and convert to `TreeItem`s (which hold `Rc` state)
/// on the main thread.
fn load_repo_data(repo: &Repository) -> Result<RepoData, Error> {
let entries = signed_git::worktree_entries(repo)?;
let tree = build_tree_items(&entries);
let readme_path = signed_git::find_readme(repo)?;
let readme = match &readme_path {
Some(path) => signed_git::worktree_read(repo, path)?,
None => None,
};
let worktree = repo.workdir().map(Path::to_path_buf);
// Ref listing is auxiliary UI: a broken ref must not prevent the
// explorer from loading, so failures degrade to empty selectors.
let (branches, tags, current_branch) = match &worktree {
Some(_) => (
signed_git::repo_branches(repo).unwrap_or_default(),
signed_git::repo_tags(repo).unwrap_or_default(),
signed_git::current_branch(repo).unwrap_or(None),
),
None => (Vec::new(), Vec::new(), None),
};
let head_commit = signed_git::head_commit(repo).unwrap_or(None);
Ok(RepoData {
tree,
readme_path,
readme,
worktree,
branches,
tags,
current_branch,
head_commit,
})
}
impl EventEmitter<PanelEvent> for RepoDetailView {}
impl Focusable for RepoDetailView {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for RepoDetailView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let tree_state = self.tree_state.clone();
let view = cx.entity().downgrade();
let pane_title = self
.selected_file
.clone()
.or_else(|| self.readme_name.clone())
.unwrap_or_else(|| "Overview".into());
v_flex()
.id("repo")
.size_full()
.child(self.render_header(cx))
.child(match self.active_tab {
0 => h_flex()
.flex_1()
.w_full()
.overflow_hidden()
.child(Self::render_tree_column(tree_state, view, cx))
.child(self.render_content_column(pane_title, cx))
.into_any_element(),
_ => self.render_commits_tab(cx),
})
}
}
impl RepoDetailView {
/// Header: repository name and description, relay/web/clone buttons, the
/// Files/Commits tab bar and the branch/tag selectors with the
/// latest-commit button.
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
let announcement = self.announcement();
let relays = self.relays.clone();
let web = self.web.clone();
let store = self.store.read(cx);
let announcement = store.announcement.as_ref().unwrap_or(&self.initial);
let issue_count = store.issues.len();
let pull_request_count = store.pull_requests.len();
let name = self.display_name();
let description = announcement
.description
.clone()
.unwrap_or(SharedString::from("No description"));
let name = self.display_name(cx);
let description = announcement.description();
let commits_count = self.all_commits.as_ref().map(|list| list.total);
let worktree_empty = self.switching_ref || self.worktree.is_none();
@@ -1140,58 +963,36 @@ impl RepoDetailView {
.gap_2()
.justify_end()
.child(
DropdownButton::new("relays")
.button(
Button::new("relay-trigger")
.label(format!("{} relays", relays.len()))
.small()
.ghost(),
Button::new("issues")
.child(
h_flex()
.gap_2()
.text_sm()
.child(SharedString::from("Issues"))
.child(Tag::new().xsmall().child(SharedString::from(
issue_count.to_string(),
))),
)
.dropdown_menu(move |menu, _window, _cx| {
let mut menu = menu;
if relays.is_empty() {
return menu.item(
PopupMenuItem::new("No relays").disabled(true),
);
}
for relay in relays.iter() {
let url = relay.to_string();
menu = menu.item(
PopupMenuItem::new(url.clone()).on_click(
move |_, _, cx| {
cx.write_to_clipboard(
ClipboardItem::new_string(url.clone()),
);
},
),
);
}
menu
}),
.outline()
.on_click(cx.listener(|this, _event, window, cx| {
this.open_issue_detail(window, cx);
})),
)
.child(
DropdownButton::new("web")
.button(
Button::new("web-trigger")
.label("Websites")
.small()
.ghost(),
Button::new("prs")
.child(
h_flex()
.gap_2()
.text_sm()
.child(SharedString::from("Pull Requests"))
.child(Tag::new().xsmall().child(SharedString::from(
pull_request_count.to_string(),
))),
)
.dropdown_menu(move |menu, _window, _cx| {
let mut menu = menu;
if web.is_empty() {
return menu
.item(PopupMenuItem::new("No web").disabled(true));
}
for url in web.iter() {
let href = url.to_string();
menu = menu.item(
PopupMenuItem::new(href.clone())
.on_click(move |_, _, cx| cx.open_url(&href)),
);
}
menu
}),
.outline()
.on_click(cx.listener(|this, _event, window, cx| {
this.open_pull_request_detail(window, cx);
})),
)
.child(
Button::new("link")
@@ -1306,4 +1107,135 @@ impl RepoDetailView {
)
.into_any_element()
}
/// Horizontal list of everyone who maintains the repository: the owner
/// shown in full, and any additional maintainers as a compact overlapping avatar group.
fn render_maintainers(&self, cx: &mut Context<Self>) -> AnyElement {
let announcement = self.announcement(cx);
let profile_store = ProfileStore::global(cx);
let mut seen = HashSet::new();
let rest: Vec<_> = announcement
.maintainers
.iter()
.copied()
.filter(|key| *key != announcement.owner && seen.insert(*key))
.collect();
let owner = profile_store.read(cx).get(&announcement.owner);
let owner_name = owner.name();
let owner_picture = owner.picture();
h_flex()
.w_full()
.gap_3()
.items_center()
.flex_wrap()
.child(
h_flex()
.gap_1()
.items_center()
.child(
Avatar::new()
.name(owner_name.clone())
.when_some(owner_picture, |this, url| this.src(url))
.small(),
)
.child(div().text_xs().whitespace_nowrap().child(owner_name)),
)
.when(!rest.is_empty(), |this| {
this.child(AvatarGroup::new().small().limit(5).ellipsis().children(
rest.into_iter().map(|key| {
let profile = profile_store.read(cx).get(&key);
Avatar::new()
.name(profile.name())
.when_some(profile.picture(), |this, url| this.src(url))
}),
))
})
.into_any_element()
}
}
impl Panel for RepoDetailView {
fn panel_name(&self) -> &'static str {
"repo_detail"
}
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
self.display_name(cx)
}
}
impl EventEmitter<PanelEvent> for RepoDetailView {}
impl Focusable for RepoDetailView {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for RepoDetailView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let tree_state = self.tree_state.clone();
let view = cx.entity().downgrade();
let pane_title = self
.selected_file
.clone()
.or_else(|| self.readme_name.clone())
.unwrap_or_else(|| "Overview".into());
v_flex()
.id("repo")
.size_full()
.child(self.render_header(cx))
.child(match self.active_tab {
0 => h_flex()
.flex_1()
.w_full()
.overflow_hidden()
.child(Self::render_tree_column(tree_state, view, cx))
.child(self.render_content_column(pane_title, cx))
.into_any_element(),
_ => self.render_commits_tab(cx),
})
}
}
/// Read the worktree state of `repo` (no network): entries, README, refs
/// and HEAD commit. The tree is built off the main thread; the seeds are
/// plain owned strings and convert to `TreeItem`s (which hold `Rc` state)
/// on the main thread.
fn load_repo_data(repo: &Repository) -> Result<RepoData, Error> {
let entries = signed_git::worktree_entries(repo)?;
let tree = build_tree_items(&entries);
let readme_path = signed_git::find_readme(repo)?;
let readme = match &readme_path {
Some(path) => signed_git::worktree_read(repo, path)?,
None => None,
};
let worktree = repo.workdir().map(Path::to_path_buf);
// Ref listing is auxiliary UI: a broken ref must not prevent the
// explorer from loading, so failures degrade to empty selectors.
let (branches, tags, current_branch) = match &worktree {
Some(_) => (
signed_git::repo_branches(repo).unwrap_or_default(),
signed_git::repo_tags(repo).unwrap_or_default(),
signed_git::current_branch(repo).unwrap_or(None),
),
None => (Vec::new(), Vec::new(), None),
};
let head_commit = signed_git::head_commit(repo).unwrap_or(None);
Ok(RepoData {
tree,
readme_path,
readme,
worktree,
branches,
tags,
current_branch,
head_commit,
})
}