add issues and prs button
This commit is contained in:
@@ -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)
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user