add ref viewer
This commit is contained in:
@@ -6,17 +6,19 @@ use anyhow::Error;
|
||||
use assets::CustomIconName;
|
||||
use gpui::prelude::*;
|
||||
use gpui::{
|
||||
App, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render,
|
||||
SharedString, Size, Task, Window, div, px, size,
|
||||
AnyElement, App, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels,
|
||||
Render, SharedString, Size, Subscription, Task, Window, div, px, size,
|
||||
};
|
||||
use gpui_component::button::{Button, ButtonVariants, DropdownButton};
|
||||
use gpui_component::combobox::{Caret, Combobox, ComboboxEvent, ComboboxState, ComboboxTriggerCtx};
|
||||
use gpui_component::dock::{Panel, PanelEvent};
|
||||
use gpui_component::menu::PopupMenuItem;
|
||||
use gpui_component::searchable_list::SearchableVec;
|
||||
use gpui_component::tab::{Tab, TabBar};
|
||||
use gpui_component::tag::Tag;
|
||||
use gpui_component::tree::TreeState;
|
||||
use gpui_component::{
|
||||
ActiveTheme, IconName, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex,
|
||||
ActiveTheme, Icon, IconName, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex,
|
||||
};
|
||||
use signed_core::Announcement;
|
||||
use signed_git::FileCommit;
|
||||
@@ -30,6 +32,15 @@ use browser::{CodeView, FileContent, MAX_PREVIEW_BYTES, MarkdownView};
|
||||
use commits::COMMIT_ROW_HEIGHT;
|
||||
use helpers::{build_tree_items, is_markdown_path};
|
||||
|
||||
/// What kind of ref the header selectors switch to.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum RefKind {
|
||||
/// A local branch (`refs/heads/*`); HEAD stays attached.
|
||||
Branch,
|
||||
/// A tag (`refs/tags/*`); HEAD becomes detached.
|
||||
Tag,
|
||||
}
|
||||
|
||||
/// Detail view of a repository: header, stats, a file explorer with README
|
||||
/// preview (cloned from the announcement's `clone` URLs), and metadata.
|
||||
pub struct RepoDetailView {
|
||||
@@ -68,6 +79,17 @@ pub struct RepoDetailView {
|
||||
/// A clone/fetch is in flight.
|
||||
loading: bool,
|
||||
error: Option<SharedString>,
|
||||
/// Branch selector (header): local branches, searchable.
|
||||
branch_select: Entity<ComboboxState<SearchableVec<SharedString>>>,
|
||||
/// Tag selector (header): tags, searchable.
|
||||
tag_select: Entity<ComboboxState<SearchableVec<SharedString>>>,
|
||||
/// A branch/tag switch is in flight (checkout plus explorer reload).
|
||||
switching_ref: bool,
|
||||
/// Bumped on every branch/tag switch; in-flight loads tagged with an
|
||||
/// older generation are discarded when they complete.
|
||||
ref_generation: u64,
|
||||
/// Subscriptions keeping the selectors' confirm events alive.
|
||||
_subscriptions: Vec<Subscription>,
|
||||
focus_handle: FocusHandle,
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
}
|
||||
@@ -77,9 +99,49 @@ impl RepoDetailView {
|
||||
let store = cx.new(|cx| RepoStore::new(initial.addr(), cx));
|
||||
let tree_state = cx.new(|cx| TreeState::new(cx));
|
||||
|
||||
// Empty until the clone completes; populated with the local refs.
|
||||
let branch_select: Entity<ComboboxState<SearchableVec<SharedString>>> = cx.new(|cx| {
|
||||
ComboboxState::new(
|
||||
SearchableVec::new(Vec::<SharedString>::new()),
|
||||
Vec::new(),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
.searchable(true)
|
||||
});
|
||||
let tag_select: Entity<ComboboxState<SearchableVec<SharedString>>> = cx.new(|cx| {
|
||||
ComboboxState::new(
|
||||
SearchableVec::new(Vec::<SharedString>::new()),
|
||||
Vec::new(),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
.searchable(true)
|
||||
});
|
||||
|
||||
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
|
||||
// confirmed value always means a switch.
|
||||
if let ComboboxEvent::Change(values) = event
|
||||
&& let Some(name) = values.first()
|
||||
{
|
||||
this.switch_ref(RefKind::Branch, name.clone(), window, cx);
|
||||
}
|
||||
}),
|
||||
cx.subscribe_in(&tag_select, window, |this, _state, event, window, cx| {
|
||||
if let ComboboxEvent::Change(values) = event
|
||||
&& let Some(name) = values.first()
|
||||
{
|
||||
this.switch_ref(RefKind::Tag, name.clone(), window, cx);
|
||||
}
|
||||
}),
|
||||
];
|
||||
|
||||
// Defer loading the repository until the window is ready.
|
||||
cx.defer_in(window, |this, _window, cx| {
|
||||
this.load_repo(cx);
|
||||
cx.defer_in(window, |this, window, cx| {
|
||||
this.load_repo(window, cx);
|
||||
});
|
||||
|
||||
Self {
|
||||
@@ -102,13 +164,18 @@ impl RepoDetailView {
|
||||
item_sizes: Rc::new(Vec::new()),
|
||||
loading: true,
|
||||
error: None,
|
||||
branch_select,
|
||||
tag_select,
|
||||
switching_ref: false,
|
||||
ref_generation: 0,
|
||||
_subscriptions: subscriptions,
|
||||
focus_handle: cx.focus_handle(),
|
||||
tasks: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Clone (or fetch) the repository and populate the file explorer.
|
||||
fn load_repo(&mut self, cx: &mut Context<Self>) {
|
||||
fn load_repo(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.loading = true;
|
||||
self.error = None;
|
||||
cx.notify();
|
||||
@@ -126,20 +193,63 @@ impl RepoDetailView {
|
||||
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(worktree) => (
|
||||
signed_git::worktree_branches(worktree).unwrap_or_default(),
|
||||
signed_git::worktree_tags(worktree).unwrap_or_default(),
|
||||
signed_git::current_branch(&repo).unwrap_or(None),
|
||||
),
|
||||
None => (Vec::new(), Vec::new(), None),
|
||||
};
|
||||
|
||||
Ok::<_, Error>((entries, readme_path, readme, worktree))
|
||||
Ok::<_, Error>((
|
||||
entries,
|
||||
readme_path,
|
||||
readme,
|
||||
worktree,
|
||||
branches,
|
||||
tags,
|
||||
current_branch,
|
||||
))
|
||||
});
|
||||
|
||||
let task = cx.spawn(async move |this, cx| {
|
||||
let task = cx.spawn_in(window, async move |this, cx| {
|
||||
let result = load.await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
match result {
|
||||
Ok((entries, readme_path, readme, Some(worktree))) => {
|
||||
Ok((
|
||||
entries,
|
||||
readme_path,
|
||||
readme,
|
||||
Some(worktree),
|
||||
branches,
|
||||
tags,
|
||||
current_branch,
|
||||
)) => {
|
||||
this.worktree = Some(worktree);
|
||||
this.tree_state.update(cx, |state, cx| {
|
||||
state.set_items(build_tree_items(&entries), cx);
|
||||
});
|
||||
|
||||
// Populate the branch/tag selectors with the local
|
||||
// refs, selecting the branch HEAD points to.
|
||||
let branches: Vec<SharedString> =
|
||||
branches.into_iter().map(Into::into).collect();
|
||||
let tags: Vec<SharedString> = tags.into_iter().map(Into::into).collect();
|
||||
this.branch_select.update(cx, |state, cx| {
|
||||
state.set_items(SearchableVec::from(branches), window, cx);
|
||||
if let Some(branch) = current_branch {
|
||||
let branch: SharedString = branch.into();
|
||||
state.set_selected_values(&[branch], window, cx);
|
||||
}
|
||||
});
|
||||
this.tag_select.update(cx, |state, cx| {
|
||||
state.set_items(SearchableVec::from(tags), window, cx);
|
||||
});
|
||||
|
||||
this.load_all_commits(cx);
|
||||
if let Some((path, bytes)) = readme_path.zip(readme) {
|
||||
this.readme_name = Some(path.to_string_lossy().into());
|
||||
@@ -149,7 +259,7 @@ impl RepoDetailView {
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((_, _, _, None)) => {
|
||||
Ok((_, _, _, None, _, _, _)) => {
|
||||
this.error = Some("Repository has no worktree".into());
|
||||
}
|
||||
Err(error) => {
|
||||
@@ -197,6 +307,7 @@ impl RepoDetailView {
|
||||
self.loading_files.insert(path.to_string());
|
||||
let path = path.to_string();
|
||||
self.load_commit(&path, cx);
|
||||
let generation = self.ref_generation;
|
||||
|
||||
let task = cx.spawn(async move |this, cx| {
|
||||
let path_for_read = path.clone();
|
||||
@@ -221,6 +332,11 @@ impl RepoDetailView {
|
||||
.await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
// The worktree was switched while this file was reading;
|
||||
// the result belongs to the previous branch.
|
||||
if generation != this.ref_generation {
|
||||
return;
|
||||
}
|
||||
this.loading_files.remove(&path);
|
||||
match content {
|
||||
Ok(kind) => {
|
||||
@@ -267,6 +383,7 @@ impl RepoDetailView {
|
||||
|
||||
self.loading_commits.insert(path.to_string());
|
||||
let path = path.to_string();
|
||||
let generation = self.ref_generation;
|
||||
|
||||
let task = cx.spawn(async move |this, cx| {
|
||||
let path_for_query = path.clone();
|
||||
@@ -277,6 +394,9 @@ impl RepoDetailView {
|
||||
.await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
if generation != this.ref_generation {
|
||||
return;
|
||||
}
|
||||
this.loading_commits.remove(&path);
|
||||
if let Ok(Some(commit)) = result {
|
||||
this.commits.insert(path, commit);
|
||||
@@ -302,6 +422,7 @@ impl RepoDetailView {
|
||||
};
|
||||
|
||||
self.loading_all_commits = true;
|
||||
let generation = self.ref_generation;
|
||||
|
||||
let task = cx.spawn(async move |this, cx| {
|
||||
let result = cx
|
||||
@@ -309,6 +430,9 @@ impl RepoDetailView {
|
||||
.await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
if generation != this.ref_generation {
|
||||
return;
|
||||
}
|
||||
if let Ok(commits) = result {
|
||||
let count = commits.len();
|
||||
this.item_sizes = Rc::new(vec![size(px(0.), px(COMMIT_ROW_HEIGHT)); count]);
|
||||
@@ -323,6 +447,192 @@ impl RepoDetailView {
|
||||
|
||||
self.tasks.push(task);
|
||||
}
|
||||
|
||||
/// Check out `name` (a branch or tag picked in the header) and refresh
|
||||
/// the explorer once the switch completes.
|
||||
fn switch_ref(
|
||||
&mut self,
|
||||
kind: RefKind,
|
||||
name: SharedString,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if self.switching_ref {
|
||||
return;
|
||||
}
|
||||
let Some(worktree) = self.worktree.clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Branches and tags are mutually exclusive states of HEAD: selecting
|
||||
// one clears the other selector. Remember the previous selections so
|
||||
// they can be restored if the checkout fails.
|
||||
let previous_branch = self.branch_select.read(cx).selected_value();
|
||||
let previous_tag = self.tag_select.read(cx).selected_value();
|
||||
|
||||
match kind {
|
||||
RefKind::Branch => {
|
||||
self.tag_select
|
||||
.update(cx, |state, cx| state.clear_selection(cx));
|
||||
}
|
||||
RefKind::Tag => {
|
||||
self.branch_select
|
||||
.update(cx, |state, cx| state.clear_selection(cx));
|
||||
}
|
||||
}
|
||||
self.switching_ref = true;
|
||||
// In-flight loads of the previous branch are discarded when they
|
||||
// complete.
|
||||
self.ref_generation += 1;
|
||||
cx.notify();
|
||||
|
||||
let checkout_name = name.clone();
|
||||
let task = cx.spawn_in(window, async move |this, cx| {
|
||||
let result = cx
|
||||
.background_spawn(async move {
|
||||
match kind {
|
||||
RefKind::Branch => {
|
||||
signed_git::worktree_checkout_branch(&worktree, &checkout_name)
|
||||
}
|
||||
RefKind::Tag => {
|
||||
signed_git::worktree_checkout_tag(&worktree, &checkout_name)
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
match result {
|
||||
Ok(()) => this.reload_worktree(cx),
|
||||
Err(error) => {
|
||||
this.error = Some(format!("Failed to check out {name}: {error}").into());
|
||||
this.switching_ref = false;
|
||||
this.restore_selection(&this.branch_select, &previous_branch, window, cx);
|
||||
this.restore_selection(&this.tag_select, &previous_tag, window, cx);
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
self.tasks.push(task);
|
||||
}
|
||||
|
||||
/// Restore a selector to `previous`, or clear it (after a failed switch).
|
||||
fn restore_selection(
|
||||
&self,
|
||||
select: &Entity<ComboboxState<SearchableVec<SharedString>>>,
|
||||
previous: &Option<SharedString>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
select.update(cx, |state, cx| match previous {
|
||||
Some(value) => state.set_selected_values(std::slice::from_ref(value), window, cx),
|
||||
None => state.clear_selection(cx),
|
||||
});
|
||||
}
|
||||
|
||||
/// Trigger body for the branch/tag selectors: the kind icon, the
|
||||
/// selection (or placeholder) and the caret. `Combobox` replaces its
|
||||
/// default trigger entirely, which is the only way to show an icon
|
||||
/// inside the trigger label.
|
||||
fn render_ref_trigger(
|
||||
ctx: &ComboboxTriggerCtx<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()
|
||||
}
|
||||
|
||||
/// Refresh the file explorer, preview pane and commit list after a
|
||||
/// successful branch or tag switch. The selectors were already updated
|
||||
/// by [`Self::switch_ref`]; [`Self::switching_ref`] stays set until this
|
||||
/// reload finishes, so a second switch cannot interleave.
|
||||
fn reload_worktree(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(worktree) = self.worktree.clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let task = cx.spawn(async move |this, cx| {
|
||||
let result = cx
|
||||
.background_spawn(async move { signed_git::worktree_snapshot(&worktree) })
|
||||
.await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.switching_ref = false;
|
||||
match result {
|
||||
Ok(snapshot) => {
|
||||
// Rebuild the tree from scratch: entries of the
|
||||
// previous branch are gone, and with them the
|
||||
// expansion state.
|
||||
this.tree_state.update(cx, |state, cx| {
|
||||
state.set_items(build_tree_items(&snapshot.entries), cx);
|
||||
});
|
||||
|
||||
// Drop cached previews and commits of the old branch.
|
||||
this.selected_file = None;
|
||||
this.files.clear();
|
||||
this.loading_files.clear();
|
||||
this.commits.clear();
|
||||
this.loading_commits.clear();
|
||||
this.md = None;
|
||||
this.code = None;
|
||||
this.readme_name = None;
|
||||
this.all_commits = None;
|
||||
this.loading_all_commits = false;
|
||||
|
||||
if let Some((path, bytes)) = snapshot.readme_path.zip(snapshot.readme) {
|
||||
this.readme_name = Some(path.to_string_lossy().into());
|
||||
this.load_commit(&path.to_string_lossy(), cx);
|
||||
if let Ok(text) = String::from_utf8(bytes) {
|
||||
this.set_markdown(None, &text, cx);
|
||||
}
|
||||
}
|
||||
this.load_all_commits(cx);
|
||||
}
|
||||
Err(error) => {
|
||||
this.error = Some(error.to_string().into());
|
||||
// The tree may show files that no longer exist.
|
||||
this.tree_state.update(cx, |state, cx| {
|
||||
state.set_items(Vec::new(), cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
self.tasks.push(task);
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for RepoDetailView {
|
||||
@@ -384,6 +694,7 @@ impl Render for RepoDetailView {
|
||||
let relays = announcement.relays.clone();
|
||||
let web = announcement.web.clone();
|
||||
let commits_count = self.all_commits.as_ref().map(Vec::len);
|
||||
let worktree_empty = self.switching_ref || self.worktree.is_none();
|
||||
|
||||
v_flex()
|
||||
.id("repo")
|
||||
@@ -394,7 +705,7 @@ impl Render for RepoDetailView {
|
||||
.pt_2()
|
||||
.pb_2()
|
||||
.w_full()
|
||||
.gap_4()
|
||||
.gap_8()
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().border)
|
||||
.child(
|
||||
@@ -497,6 +808,7 @@ impl Render for RepoDetailView {
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.items_center()
|
||||
.child(
|
||||
TabBar::new("repo-tabs")
|
||||
.segmented()
|
||||
@@ -518,7 +830,48 @@ impl Render for RepoDetailView {
|
||||
cx.notify();
|
||||
})),
|
||||
)
|
||||
.child(div().flex_1()),
|
||||
.child(
|
||||
h_flex()
|
||||
.flex_1()
|
||||
.gap_2()
|
||||
.justify_end()
|
||||
.child(
|
||||
div().w(px(120.)).child(
|
||||
Combobox::new(&self.branch_select)
|
||||
.placeholder("Branch")
|
||||
.appearance(false)
|
||||
.menu_width(px(200.))
|
||||
.disabled(worktree_empty)
|
||||
.bg(cx.theme().muted)
|
||||
.rounded(cx.theme().radius)
|
||||
.render_trigger(|ctx, _window, cx| {
|
||||
Self::render_ref_trigger(
|
||||
ctx,
|
||||
CustomIconName::GitBranch,
|
||||
cx,
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div().w(px(120.)).child(
|
||||
Combobox::new(&self.tag_select)
|
||||
.placeholder("Tag")
|
||||
.appearance(false)
|
||||
.menu_width(px(200.))
|
||||
.disabled(worktree_empty)
|
||||
.bg(cx.theme().muted)
|
||||
.rounded(cx.theme().radius)
|
||||
.render_trigger(|ctx, _window, cx| {
|
||||
Self::render_ref_trigger(
|
||||
ctx,
|
||||
CustomIconName::Tag,
|
||||
cx,
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(match self.active_tab {
|
||||
|
||||
Reference in New Issue
Block a user