This commit is contained in:
2026-08-13 10:15:23 +07:00
parent 650afad6ba
commit 9b1dd526a5
6 changed files with 509 additions and 156 deletions
@@ -20,6 +20,10 @@ use super::helpers::{code_language, is_markdown_path, placeholder};
const TREE_WIDTH: f32 = 240.;
/// Files larger than this are not previewed.
pub(super) const MAX_PREVIEW_BYTES: usize = 1024 * 1024;
/// Preview cache caps: at most this many files (or this many text bytes)
/// are kept in memory at once; the oldest previews are evicted beyond that.
pub(super) const MAX_PREVIEWED_FILES: usize = 32;
pub(super) const MAX_PREVIEW_CACHE_BYTES: usize = 8 * 1024 * 1024;
/// Preview state of a browsed file.
pub(super) enum FileContent {
@@ -72,7 +72,7 @@ impl RepoDetailView {
/// Full-height body of the Commits tab: all commits in a virtual
/// list, or a status message while loading / when there are none.
pub(super) fn render_commits_tab(&mut self, cx: &mut Context<Self>) -> AnyElement {
let Some(commits) = self.all_commits.clone() else {
let Some(list) = self.all_commits.as_ref() else {
return if self.loading_all_commits {
v_flex()
.size_full()
@@ -85,13 +85,18 @@ impl RepoDetailView {
};
};
if commits.is_empty() {
if list.commits.is_empty() {
return placeholder("No commits found", cx);
}
// Copy only the values the element tree needs; the list itself is
// borrowed inside the renderer below instead of being cloned per
// frame (a full history can be tens of thousands of commits).
let view = cx.entity().clone();
let sizes = self.item_sizes.clone();
let scroll_handle = self.scroll_handle.clone();
let shown = list.commits.len();
let total = list.total;
v_flex()
.relative()
@@ -103,17 +108,29 @@ impl RepoDetailView {
view,
"repo-commits",
sizes,
move |_this, range, _window, cx| {
let mut rows = Vec::with_capacity(range.len());
for ix in range {
rows.push(commit_row(ix, &commits[ix], cx));
}
rows
move |this, range, _window, cx| {
let commits = this
.all_commits
.as_ref()
.map(|list| list.commits.as_slice())
.unwrap_or(&[]);
range.map(|ix| commit_row(ix, &commits[ix], cx)).collect()
},
)
.track_scroll(&scroll_handle)
.size_full(),
)
.when(shown < total, |this| {
// The history is capped; tell the user the list is truncated.
this.child(
div()
.py_2()
.w_full()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(format!("Showing {shown} of {total} commits")),
)
})
.child(
div()
.absolute()
+101 -32
View File
@@ -1,6 +1,7 @@
//! Pure helpers for the repository detail view: file-tree building, code
//! preview helpers and small element builders.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use gpui::prelude::*;
@@ -8,43 +9,75 @@ use gpui::{AnyElement, App, div};
use gpui_component::tree::TreeItem;
use gpui_component::{ActiveTheme, v_flex};
/// Build nested tree items from a flat, sorted (dirs-first) entry list.
pub(super) fn build_tree_items(entries: &[PathBuf]) -> Vec<TreeItem> {
let mut roots: Vec<TreeItem> = Vec::new();
for entry in entries {
let parts: Vec<String> = entry
.components()
.map(|c| c.as_os_str().to_string_lossy().into_owned())
.collect();
insert_path(&mut roots, &parts, "");
}
roots
/// A `Send` file-tree node: the tree is built on a background thread and
/// converted into [`TreeItem`]s (which hold `Rc` state, so they cannot
/// cross threads) on the main thread.
pub(super) struct TreeItemSeed {
/// Path of the node, relative to the worktree root.
id: String,
/// File or directory name.
label: String,
children: Vec<TreeItemSeed>,
}
/// Insert `parts` (path components) into the tree rooted at `items`.
/// `prefix` is the path of `items`' parent, used to build item ids.
fn insert_path(items: &mut Vec<TreeItem>, parts: &[String], prefix: &str) {
let Some((head, rest)) = parts.split_first() else {
return;
};
let id = if prefix.is_empty() {
head.clone()
} else {
format!("{prefix}/{head}")
};
if let Some(existing) = items.iter_mut().find(|item| &*item.label == head.as_str()) {
insert_path(&mut existing.children, rest, &id);
} else {
let mut item = TreeItem::new(id.clone(), head.clone());
insert_path(&mut item.children, rest, &id);
items.push(item);
impl From<TreeItemSeed> for TreeItem {
fn from(seed: TreeItemSeed) -> Self {
let mut item = TreeItem::new(seed.id, seed.label);
item.children = seed.children.into_iter().map(Into::into).collect();
item
}
}
/// Build nested tree items from a flat, sorted (dirs-first) entry list.
///
/// Returns [`TreeItemSeed`]s so the build can run off the main thread; a
/// worktree walk can yield tens of thousands of entries. Nodes live in an
/// arena and parents are found via a path -> index map, which keeps the
/// build linear in the number of path components.
pub(super) fn build_tree_items(entries: &[PathBuf]) -> Vec<TreeItemSeed> {
// Node indices by full path, for O(1) parent lookup while inserting.
let mut index: HashMap<String, usize> = HashMap::new();
let mut nodes: Vec<(String, String, Vec<usize>)> = Vec::new();
let mut roots: Vec<usize> = Vec::new();
for entry in entries {
let mut parent: Option<usize> = None;
let mut path = String::new();
for part in entry.components() {
let label = part.as_os_str().to_string_lossy().into_owned();
path = if path.is_empty() {
label.clone()
} else {
format!("{path}/{label}")
};
let ix = *index.entry(path.clone()).or_insert_with(|| {
let ix = nodes.len();
nodes.push((path.clone(), label.clone(), Vec::new()));
match parent {
Some(parent) => nodes[parent].2.push(ix),
None => roots.push(ix),
}
ix
});
parent = Some(ix);
}
}
fn assemble(ix: usize, nodes: &[(String, String, Vec<usize>)]) -> TreeItemSeed {
let (id, label, children) = &nodes[ix];
TreeItemSeed {
id: id.clone(),
label: label.clone(),
children: children
.iter()
.map(|child| assemble(*child, nodes))
.collect(),
}
}
roots.iter().map(|root| assemble(*root, &nodes)).collect()
}
/// The markdown fence language for a file path, or `None` for plain text.
///
/// Names are chosen so `gpui_component`'s highlighter can resolve them
@@ -174,6 +207,42 @@ mod tests {
assert_eq!(items[0].children[0].children[0].id, "a/b/c.txt");
}
#[test]
fn tree_builder_merges_shared_prefixes() {
// File children of a directory arrive after other directories'
// entries (the worktree list is dirs-first globally); the shared
// prefix must still resolve to one node.
let entries = vec![
PathBuf::from("a/x.txt"),
PathBuf::from("b/y.txt"),
PathBuf::from("a/z.txt"),
];
let items = build_tree_items(&entries);
assert_eq!(items.len(), 2);
assert_eq!(items[0].label, "a");
assert_eq!(items[0].children.len(), 2);
assert_eq!(items[1].label, "b");
}
#[test]
fn tree_seeds_convert_to_tree_items() {
let entries = vec![
PathBuf::from("src"),
PathBuf::from("src/main.rs"),
PathBuf::from("README.md"),
];
let items: Vec<TreeItem> = build_tree_items(&entries)
.into_iter()
.map(Into::into)
.collect();
assert_eq!(items.len(), 2);
assert_eq!(items[0].label, "src");
assert_eq!(items[0].children.len(), 1);
assert_eq!(items[0].children[0].label, "main.rs");
}
#[test]
fn code_language_maps_extensions_and_names() {
assert_eq!(code_language("src/main.rs"), Some("rust"));
+223 -72
View File
@@ -1,4 +1,4 @@
use std::collections::{HashMap, HashSet};
use std::collections::{HashMap, HashSet, VecDeque};
use std::path::{Component, Path, PathBuf};
use std::rc::Rc;
@@ -16,19 +16,22 @@ 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::tree::{TreeItem, TreeState};
use gpui_component::{
ActiveTheme, Icon, IconName, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex,
};
use signed_core::Announcement;
use signed_git::FileCommit;
use signed_git::{CommitList, FileCommit};
use signed_state::{GitStore, RepoStore};
mod browser;
mod commits;
mod helpers;
use browser::{CodeView, FileContent, MAX_PREVIEW_BYTES, MarkdownView};
use browser::{
CodeView, FileContent, MAX_PREVIEW_BYTES, MAX_PREVIEW_CACHE_BYTES, MAX_PREVIEWED_FILES,
MarkdownView,
};
use commits::COMMIT_ROW_HEIGHT;
use helpers::{build_tree_items, is_markdown_path};
@@ -44,10 +47,16 @@ enum RefKind {
/// 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 {
/// Live per-repository store, refreshed from the local database.
store: Entity<RepoStore>,
/// Snapshot taken at open time, shown until the store's first refresh completes.
/// 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>>,
/// File explorer state (worktree of the local clone).
tree_state: Entity<TreeState>,
/// Root of the local clone, for reading files on demand.
@@ -60,17 +69,25 @@ pub struct RepoDetailView {
/// Currently previewed file (relative path) and its contents.
selected_file: Option<SharedString>,
files: HashMap<String, FileContent>,
/// Paths of cached previews, oldest first; feeds the eviction caps in
/// [`Self::evict_previews`].
file_order: VecDeque<String>,
/// Total text bytes held by [`Self::files`].
preview_bytes: usize,
/// Reads in flight, to avoid duplicate loads.
loading_files: HashSet<String>,
/// Latest commit touching a previewed file (or the README), keyed by path.
commits: HashMap<String, FileCommit>,
/// Commit queries in flight, to avoid duplicate loads.
loading_commits: HashSet<String>,
/// Paths queued for the next batched commit query (see [`Self::load_commits`]).
pending_commits: Vec<String>,
/// A batched commit query is in flight.
loading_commits: bool,
/// Active header tab: 0 = Files (tree), 1 = Commits.
active_tab: usize,
/// All commits reachable from HEAD, newest first; `None` until the
/// walk finishes (or fails).
all_commits: Option<Vec<FileCommit>>,
/// Commits reachable from HEAD, newest first; `None` until the walk
/// finishes (or fails). `commits` may be capped by
/// [`CommitList`]; `total` feeds the tab badge.
all_commits: Option<CommitList>,
/// Commit walk in flight.
loading_all_commits: bool,
/// Virtual list state of the Commits tab.
@@ -90,10 +107,13 @@ pub struct RepoDetailView {
/// 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,
/// 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: Vec<Subscription>,
}
impl RepoDetailView {
@@ -121,7 +141,36 @@ impl RepoDetailView {
.searchable(true)
});
let subscriptions = vec![
// 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![
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
@@ -140,15 +189,35 @@ 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 {
store,
initial,
announcement: None,
relays,
web,
tree_state,
worktree: None,
md: None,
@@ -156,9 +225,12 @@ impl RepoDetailView {
readme_name: None,
selected_file: None,
files: HashMap::new(),
file_order: VecDeque::new(),
preview_bytes: 0,
loading_files: HashSet::new(),
commits: HashMap::new(),
loading_commits: HashSet::new(),
pending_commits: Vec::new(),
loading_commits: false,
active_tab: 0,
all_commits: None,
loading_all_commits: false,
@@ -190,6 +262,10 @@ impl RepoDetailView {
let load = cx.background_spawn(async move {
let repo = cache.ensure_clone(&addr, &clone_urls)?;
let entries = signed_git::worktree_entries(&repo)?;
// 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.
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)?,
@@ -209,7 +285,7 @@ impl RepoDetailView {
let head_commit = signed_git::head_commit(&repo).unwrap_or(None);
Ok::<_, Error>((
entries,
tree,
readme_path,
readme,
worktree,
@@ -226,7 +302,7 @@ impl RepoDetailView {
this.update_in(cx, |this, window, cx| {
match result {
Ok((
entries,
tree,
readme_path,
readme,
Some(worktree),
@@ -238,7 +314,10 @@ impl RepoDetailView {
this.worktree = Some(worktree);
this.head_commit = head_commit;
this.tree_state.update(cx, |state, cx| {
state.set_items(build_tree_items(&entries), cx);
state.set_items(
tree.into_iter().map(Into::into).collect::<Vec<TreeItem>>(),
cx,
);
});
// Populate the branch/tag selectors with the local
@@ -280,7 +359,7 @@ impl RepoDetailView {
Ok(())
});
self.tasks.push(task);
self.track(task);
}
/// Preview the file at `path` (relative to the worktree root).
@@ -321,20 +400,24 @@ impl RepoDetailView {
let content = cx
.background_spawn(async move {
let full = worktree.join(&path_for_read);
// Refuse oversized files before reading them: reading a
// multi-gigabyte file just to classify it as too large
// would waste the disk and memory bandwidth.
let metadata = match std::fs::metadata(&full) {
Ok(metadata) => metadata,
Err(error) => return Err(anyhow::anyhow!("{}", error)),
};
if metadata.len() > MAX_PREVIEW_BYTES as u64 {
return Ok(FileContent::TooLarge);
}
let bytes = match std::fs::read(&full) {
Ok(bytes) => bytes,
Err(error) => return Err(anyhow::anyhow!("{}", error)),
};
let kind = if bytes.len() > MAX_PREVIEW_BYTES {
FileContent::TooLarge
} else {
match String::from_utf8(bytes) {
Ok(text) => FileContent::Text(text),
Err(_) => FileContent::Binary,
}
};
Ok::<_, Error>(kind)
match String::from_utf8(bytes) {
Ok(text) => Ok(FileContent::Text(text)),
Err(_) => Ok(FileContent::Binary),
}
})
.await;
@@ -361,8 +444,11 @@ impl RepoDetailView {
this.set_code(path.clone().into(), text, window, cx);
}
}
this.preview_bytes += text.len();
}
this.files.insert(path, kind);
this.files.insert(path.clone(), kind);
this.file_order.push_back(path);
this.evict_previews();
}
Err(error) => {
this.files
@@ -375,38 +461,64 @@ impl RepoDetailView {
Ok(())
});
self.tasks.push(task);
self.track(task);
}
/// Query the latest commit touching `path` on a background task and cache
/// it in [`Self::commits`], for the file header in the content column.
/// Queue `path` for the per-file commit query; requests are batched into
/// one history walk (see [`Self::load_commits`]).
fn load_commit(&mut self, path: &str, cx: &mut Context<Self>) {
if self.commits.contains_key(path) || self.loading_commits.contains(path) {
if self.commits.contains_key(path) || self.pending_commits.iter().any(|p| p == path) {
return;
}
self.pending_commits.push(path.to_string());
if !self.loading_commits {
self.load_commits(cx);
}
}
/// Walk history once for every queued path on a background task, and
/// cache the latest commit touching each of them in [`Self::commits`]
/// (for the file header in the content column).
///
/// Batching shares one walk (and its object decodes) across all paths
/// queued while the previous walk was in flight, instead of walking the
/// full history per file.
fn load_commits(&mut self, cx: &mut Context<Self>) {
if self.pending_commits.is_empty() || self.loading_commits {
return;
}
let Some(worktree) = self.worktree.clone() else {
self.pending_commits.clear();
return;
};
self.loading_commits.insert(path.to_string());
let path = path.to_string();
self.loading_commits = true;
let paths = std::mem::take(&mut self.pending_commits);
let generation = self.ref_generation;
let task = cx.spawn(async move |this, cx| {
let path_for_query = path.clone();
let rels: Vec<PathBuf> = paths.iter().map(PathBuf::from).collect();
let result = cx
.background_spawn(async move {
signed_git::worktree_last_commit(&worktree, Path::new(&path_for_query))
})
.background_spawn(
async move { signed_git::worktree_last_commits(&worktree, &rels) },
)
.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);
this.loading_commits = false;
if let Ok(found) = result {
for (path, commit) in found {
this.commits
.insert(path.to_string_lossy().into_owned(), commit);
}
}
// Paths queued while the walk was in flight start the next
// batch.
if !this.pending_commits.is_empty() {
this.load_commits(cx);
}
cx.notify();
})?;
@@ -414,11 +526,12 @@ impl RepoDetailView {
Ok(())
});
self.tasks.push(task);
self.track(task);
}
/// Walk all commits reachable from HEAD on a background task, for the
/// Commits tab and its total-count badge.
/// Commits tab and its total-count badge. The list is capped by
/// [`CommitList`]; only the newest commits are materialized.
fn load_all_commits(&mut self, cx: &mut Context<Self>) {
if self.loading_all_commits || self.all_commits.is_some() {
return;
@@ -440,10 +553,10 @@ impl RepoDetailView {
if generation != this.ref_generation {
return;
}
if let Ok(commits) = result {
let count = commits.len();
if let Ok(list) = result {
let count = list.commits.len();
this.item_sizes = Rc::new(vec![size(px(0.), px(COMMIT_ROW_HEIGHT)); count]);
this.all_commits = Some(commits);
this.all_commits = Some(list);
}
this.loading_all_commits = false;
cx.notify();
@@ -452,7 +565,7 @@ impl RepoDetailView {
Ok(())
});
self.tasks.push(task);
self.track(task);
}
/// Check out `name` (a branch or tag picked in the header) and refresh
@@ -524,7 +637,7 @@ impl RepoDetailView {
Ok(())
});
self.tasks.push(task);
self.track(task);
}
/// Restore a selector to `previous`, or clear it (after a failed switch).
@@ -589,27 +702,38 @@ impl RepoDetailView {
let task = cx.spawn(async move |this, cx| {
let result = cx
.background_spawn(async move { signed_git::worktree_snapshot(&worktree) })
.background_spawn(async move {
let snapshot = signed_git::worktree_snapshot(&worktree)?;
// Build the tree off the main thread, like [`Self::load_repo`].
let tree = build_tree_items(&snapshot.entries);
Ok::<_, Error>((snapshot, tree))
})
.await;
this.update(cx, |this, cx| {
this.switching_ref = false;
match result {
Ok(snapshot) => {
Ok((snapshot, tree)) => {
this.head_commit = snapshot.head_commit;
// 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);
state.set_items(
tree.into_iter().map(Into::into).collect::<Vec<TreeItem>>(),
cx,
);
});
// Drop cached previews and commits of the old branch.
this.selected_file = None;
this.files.clear();
this.file_order.clear();
this.preview_bytes = 0;
this.loading_files.clear();
this.commits.clear();
this.loading_commits.clear();
this.pending_commits.clear();
this.loading_commits = false;
this.md = None;
this.code = None;
this.readme_name = None;
@@ -640,8 +764,45 @@ impl RepoDetailView {
Ok(())
});
self.track(task);
}
/// Track `task` until it completes; finished tasks are pruned on every
/// push so the vec stays bounded by the number of in-flight loads.
fn track(&mut self, task: Task<Result<(), Error>>) {
self.tasks.retain(|task| !task.is_ready());
self.tasks.push(task);
}
/// Drop the oldest previews beyond the cache caps, keeping the currently
/// selected file. The parsed editor state of an evicted file is dropped
/// along with its entry, so re-opening it re-parses on a background task.
fn evict_previews(&mut self) {
while (self.files.len() > MAX_PREVIEWED_FILES
|| self.preview_bytes > MAX_PREVIEW_CACHE_BYTES)
&& self.file_order.len() > 1
{
let path = self.file_order.pop_front().expect("non-empty");
if Some(path.as_str()) == self.selected_file.as_deref() {
self.file_order.push_back(path);
continue;
}
if let Some(FileContent::Text(text)) = self.files.remove(&path) {
self.preview_bytes -= text.len();
}
if self.md.as_ref().map(|md| md.path.as_deref()) == Some(Some(path.as_str())) {
self.md = None;
}
if self
.code
.as_ref()
.is_some_and(|code| code.path.as_ref() == path.as_str())
{
self.code = None;
}
self.commits.remove(&path);
}
}
}
impl Panel for RepoDetailView {
@@ -649,13 +810,8 @@ impl Panel for RepoDetailView {
"repo_detail"
}
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let announcement = self
.store
.read(cx)
.announcement
.clone()
.unwrap_or_else(|| self.initial.clone());
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
let announcement = self.announcement.as_ref().unwrap_or(&self.initial);
announcement
.name
@@ -677,12 +833,9 @@ impl Render for RepoDetailView {
let tree_state = self.tree_state.clone();
let view = cx.entity().downgrade();
let announcement = self
.store
.read(cx)
.announcement
.clone()
.unwrap_or_else(|| self.initial.clone());
let announcement = self.announcement.as_ref().unwrap_or(&self.initial);
let relays = self.relays.clone();
let web = self.web.clone();
let name = announcement
.name
@@ -700,9 +853,7 @@ impl Render for RepoDetailView {
.or_else(|| self.readme_name.clone())
.unwrap_or_else(|| "Overview".into());
let relays = announcement.relays.clone();
let web = announcement.web.clone();
let commits_count = self.all_commits.as_ref().map(Vec::len);
let commits_count = self.all_commits.as_ref().map(|list| list.total);
let worktree_empty = self.switching_ref || self.worktree.is_none();
v_flex()