add ref viewer
This commit is contained in:
Generated
+161
-182
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M7.10508 15.2101C8.21506 15.6501 9 16.7334 9 18C9 19.6569 7.65685 21 6 21C4.34315 21 3 19.6569 3 18C3 16.6938 3.83481 15.5825 5 15.1707V8.82929C3.83481 8.41746 3 7.30622 3 6C3 4.34315 4.34315 3 6 3C7.65685 3 9 4.34315 9 6C9 7.30622 8.16519 8.41746 7 8.82929V11.9996C7.83566 11.3719 8.87439 11 10 11H14C15.3835 11 16.5482 10.0635 16.8949 8.78991C15.7849 8.34988 15 7.26661 15 6C15 4.34315 16.3431 3 18 3C19.6569 3 21 4.34315 21 6C21 7.3332 20.1303 8.46329 18.9274 8.85392C18.5222 11.2085 16.4703 13 14 13H10C8.61653 13 7.45179 13.9365 7.10508 15.2101ZM6 17C5.44772 17 5 17.4477 5 18C5 18.5523 5.44772 19 6 19C6.55228 19 7 18.5523 7 18C7 17.4477 6.55228 17 6 17ZM6 5C5.44772 5 5 5.44772 5 6C5 6.55228 5.44772 7 6 7C6.55228 7 7 6.55228 7 6C7 5.44772 6.55228 5 6 5ZM18 5C17.4477 5 17 5.44772 17 6C17 6.55228 17.4477 7 18 7C18.5523 7 19 6.55228 19 6C19 5.44772 18.5523 5 18 5Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 976 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M10.9042 2.10025L20.8037 3.51446L22.2179 13.414L13.0255 22.6063C12.635 22.9969 12.0019 22.9969 11.6113 22.6063L1.71184 12.7069C1.32131 12.3163 1.32131 11.6832 1.71184 11.2926L10.9042 2.10025ZM11.6113 4.22157L3.83316 11.9997L12.3184 20.485L20.0966 12.7069L19.036 5.28223L11.6113 4.22157ZM13.7327 10.5855C12.9516 9.80448 12.9516 8.53815 13.7327 7.7571C14.5137 6.97606 15.78 6.97606 16.5611 7.7571C17.3421 8.53815 17.3421 9.80448 16.5611 10.5855C15.78 11.3666 14.5137 11.3666 13.7327 10.5855Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 594 B |
@@ -53,6 +53,8 @@ pub enum CustomIconName {
|
||||
GlobalOn,
|
||||
GlobalOff,
|
||||
GitClone,
|
||||
GitBranch,
|
||||
Tag,
|
||||
}
|
||||
|
||||
impl IconNamed for CustomIconName {
|
||||
@@ -63,6 +65,8 @@ impl IconNamed for CustomIconName {
|
||||
CustomIconName::GlobalOn => "icons/global-on.svg",
|
||||
CustomIconName::GlobalOff => "icons/global-off.svg",
|
||||
CustomIconName::GitClone => "icons/git-clone.svg",
|
||||
CustomIconName::GitBranch => "icons/git-branch.svg",
|
||||
CustomIconName::Tag => "icons/tag.svg",
|
||||
}
|
||||
.into()
|
||||
}
|
||||
|
||||
+218
-16
@@ -309,6 +309,104 @@ pub fn worktree_all_commits(workdir: &Path) -> Result<Vec<FileCommit>> {
|
||||
all_commits(&gix::open(workdir)?)
|
||||
}
|
||||
|
||||
/// Short names of local branches (`refs/heads/*`), sorted alphabetically.
|
||||
pub fn worktree_branches(workdir: &Path) -> Result<Vec<String>> {
|
||||
let repo = gix::open(workdir)?;
|
||||
let mut names = Vec::new();
|
||||
for reference in repo.references()?.local_branches()? {
|
||||
let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||
names.push(String::from_utf8_lossy(reference.name().shorten()).into_owned());
|
||||
}
|
||||
names.sort();
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
/// Short names of tags (`refs/tags/*`), sorted alphabetically.
|
||||
pub fn worktree_tags(workdir: &Path) -> Result<Vec<String>> {
|
||||
let repo = gix::open(workdir)?;
|
||||
let mut names = Vec::new();
|
||||
for reference in repo.references()?.tags()? {
|
||||
let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||
names.push(String::from_utf8_lossy(reference.name().shorten()).into_owned());
|
||||
}
|
||||
names.sort();
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
/// Short name of the branch HEAD points to, or `None` when detached (e.g.
|
||||
/// after checking out a tag or a commit directly).
|
||||
pub fn current_branch(repo: &gix::Repository) -> Result<Option<String>> {
|
||||
let head = repo.head()?;
|
||||
let Some(name) = head.referent_name() else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(Some(String::from_utf8_lossy(name.shorten()).into_owned()))
|
||||
}
|
||||
|
||||
/// Everything the browser needs to refresh after a branch or tag switch.
|
||||
pub struct WorktreeSnapshot {
|
||||
/// Relative paths of all worktree entries, directories first.
|
||||
pub entries: Vec<PathBuf>,
|
||||
/// README path relative to the worktree, if any.
|
||||
pub readme_path: Option<PathBuf>,
|
||||
/// Contents of the README, if any.
|
||||
pub readme: Option<Vec<u8>>,
|
||||
/// Branch HEAD points to (`None` when detached, e.g. on a tag).
|
||||
pub current_branch: Option<String>,
|
||||
}
|
||||
|
||||
/// Snapshot the worktree after a branch/tag switch: entries, README and the
|
||||
/// branch HEAD points to, opening the repository once.
|
||||
pub fn worktree_snapshot(workdir: &Path) -> Result<WorktreeSnapshot> {
|
||||
let repo = gix::open(workdir)?;
|
||||
let readme_path = find_readme(&repo)?;
|
||||
let readme = match &readme_path {
|
||||
Some(path) => worktree_read(&repo, path)?,
|
||||
None => None,
|
||||
};
|
||||
Ok(WorktreeSnapshot {
|
||||
entries: worktree_entries(&repo)?,
|
||||
readme_path,
|
||||
readme,
|
||||
current_branch: current_branch(&repo)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Switch the checked-out ref and update the worktree to match, like
|
||||
/// `git checkout --force`. Local modifications are discarded since these
|
||||
/// clones are read-only browser copies.
|
||||
fn checkout(workdir: &Path, args: &[&str]) -> Result<()> {
|
||||
let output = Command::new("git")
|
||||
.arg("checkout")
|
||||
.arg("--force")
|
||||
.args(args)
|
||||
.current_dir(workdir)
|
||||
.output()
|
||||
.context("failed to spawn `git checkout`")?;
|
||||
if !output.status.success() {
|
||||
bail!(
|
||||
"git checkout {} failed: {}",
|
||||
args.join(" "),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check out the local branch `name`; HEAD stays attached to it.
|
||||
pub fn worktree_checkout_branch(workdir: &Path, name: &str) -> Result<()> {
|
||||
// The short name (not `refs/heads/<name>`) keeps HEAD attached; the
|
||||
// full ref name would be treated as a commit-ish and detach it.
|
||||
checkout(workdir, &[name])
|
||||
}
|
||||
|
||||
/// Check out the tag `name`; HEAD becomes detached at the tagged commit,
|
||||
/// which [`current_branch`] reports as `None`.
|
||||
pub fn worktree_checkout_tag(workdir: &Path, name: &str) -> Result<()> {
|
||||
// `--detach` pins the full tag ref so HEAD always ends up detached.
|
||||
checkout(workdir, &["--detach", &format!("refs/tags/{name}")])
|
||||
}
|
||||
|
||||
fn collect_entries(root: &Path, dir: &Path, out: &mut Vec<(PathBuf, bool)>) -> Result<()> {
|
||||
for entry in std::fs::read_dir(dir)? {
|
||||
let entry = entry?;
|
||||
@@ -433,22 +531,23 @@ mod tests {
|
||||
/// Stage everything and create a commit with the git CLI (like
|
||||
/// [`apply_patch`], the crate already shells out to the CLI).
|
||||
fn commit_all(repo: &gix::Repository, message: &str) {
|
||||
let dir = repo.workdir().expect("workdir");
|
||||
let run = |args: &[&str]| {
|
||||
let status = Command::new("git")
|
||||
.current_dir(dir)
|
||||
.env("GIT_AUTHOR_NAME", "Test Author")
|
||||
.env("GIT_AUTHOR_EMAIL", "test@example.com")
|
||||
.env("GIT_COMMITTER_NAME", "Test Author")
|
||||
.env("GIT_COMMITTER_EMAIL", "test@example.com")
|
||||
.env("GIT_EDITOR", "true")
|
||||
.args(args)
|
||||
.status()
|
||||
.expect("spawn git");
|
||||
assert!(status.success(), "git {args:?} failed");
|
||||
};
|
||||
run(&["add", "-A"]);
|
||||
run(&["commit", "-m", message]);
|
||||
git_run(repo.workdir().expect("workdir"), &["add", "-A"]);
|
||||
git_run(repo.workdir().expect("workdir"), &["commit", "-m", message]);
|
||||
}
|
||||
|
||||
/// Run a git command in `dir`, asserting success.
|
||||
fn git_run(dir: &Path, args: &[&str]) {
|
||||
let status = Command::new("git")
|
||||
.current_dir(dir)
|
||||
.env("GIT_AUTHOR_NAME", "Test Author")
|
||||
.env("GIT_AUTHOR_EMAIL", "test@example.com")
|
||||
.env("GIT_COMMITTER_NAME", "Test Author")
|
||||
.env("GIT_COMMITTER_EMAIL", "test@example.com")
|
||||
.env("GIT_EDITOR", "true")
|
||||
.args(args)
|
||||
.status()
|
||||
.expect("spawn git");
|
||||
assert!(status.success(), "git {args:?} failed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -572,4 +671,107 @@ mod tests {
|
||||
let (_dir, repo) = fixture(&[("main.rs", b"")]);
|
||||
assert!(find_readme(&repo).expect("find").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worktree_branches_and_tags_list_short_names() {
|
||||
let (dir, repo) = fixture(&[("a.txt", b"one")]);
|
||||
commit_all(&repo, "initial");
|
||||
let dir = dir.path();
|
||||
|
||||
git_run(dir, &["checkout", "-b", "feature"]);
|
||||
git_run(dir, &["tag", "v0.9"]);
|
||||
git_run(dir, &["tag", "v1.0"]);
|
||||
|
||||
// The initial branch name depends on git configuration; only the
|
||||
// branch we created is fixed.
|
||||
let branches = worktree_branches(dir).expect("branches");
|
||||
assert_eq!(branches.len(), 2);
|
||||
assert!(branches.contains(&"feature".to_string()));
|
||||
assert!(branches.windows(2).all(|pair| pair[0] <= pair[1]), "sorted");
|
||||
|
||||
assert_eq!(
|
||||
worktree_tags(dir).expect("tags"),
|
||||
vec!["v0.9".to_string(), "v1.0".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_branch_tracks_checkout() {
|
||||
let (dir, repo) = fixture(&[("a.txt", b"one")]);
|
||||
commit_all(&repo, "initial");
|
||||
let dir = dir.path();
|
||||
|
||||
let default = worktree_branches(dir)
|
||||
.expect("branches")
|
||||
.into_iter()
|
||||
.next()
|
||||
.expect("default branch");
|
||||
assert_eq!(
|
||||
current_branch(&repo).expect("branch").as_deref(),
|
||||
Some(default.as_str())
|
||||
);
|
||||
|
||||
git_run(dir, &["checkout", "-b", "feature"]);
|
||||
assert_eq!(
|
||||
current_branch(&repo).expect("branch").as_deref(),
|
||||
Some("feature")
|
||||
);
|
||||
|
||||
// Tags detach HEAD.
|
||||
git_run(dir, &["tag", "v1.0"]);
|
||||
worktree_checkout_tag(dir, "v1.0").expect("checkout tag");
|
||||
assert_eq!(current_branch(&repo).expect("branch"), None);
|
||||
|
||||
// Branches re-attach HEAD.
|
||||
worktree_checkout_branch(dir, &default).expect("checkout branch");
|
||||
assert_eq!(
|
||||
current_branch(&repo).expect("branch").as_deref(),
|
||||
Some(default.as_str())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worktree_snapshot_reflects_checked_out_ref() {
|
||||
let (dir, repo) = fixture(&[("README.md", b"# main"), ("a.txt", b"one")]);
|
||||
commit_all(&repo, "initial");
|
||||
let dir = dir.path();
|
||||
|
||||
git_run(dir, &["checkout", "-b", "feature"]);
|
||||
std::fs::write(dir.join("README.md"), b"# feature").expect("write");
|
||||
std::fs::write(dir.join("b.txt"), b"b").expect("write");
|
||||
commit_all(&repo, "feature work");
|
||||
|
||||
let snapshot = worktree_snapshot(dir).expect("snapshot");
|
||||
assert_eq!(snapshot.current_branch.as_deref(), Some("feature"));
|
||||
assert_eq!(
|
||||
String::from_utf8(snapshot.readme.expect("readme")).expect("utf8"),
|
||||
"# feature"
|
||||
);
|
||||
let entries: Vec<String> = snapshot
|
||||
.entries
|
||||
.iter()
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
.collect();
|
||||
assert!(entries.contains(&"b.txt".to_string()));
|
||||
|
||||
let default = worktree_branches(dir)
|
||||
.expect("branches")
|
||||
.into_iter()
|
||||
.find(|name| name != "feature")
|
||||
.expect("default branch");
|
||||
worktree_checkout_branch(dir, &default).expect("checkout");
|
||||
|
||||
let snapshot = worktree_snapshot(dir).expect("snapshot");
|
||||
assert_eq!(snapshot.current_branch.as_deref(), Some(default.as_str()));
|
||||
assert_eq!(
|
||||
String::from_utf8(snapshot.readme.expect("readme")).expect("utf8"),
|
||||
"# main"
|
||||
);
|
||||
assert!(
|
||||
!snapshot
|
||||
.entries
|
||||
.iter()
|
||||
.any(|p| p.to_string_lossy() == "b.txt")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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