mirror of
https://github.com/zed-industries/zed.git
synced 2026-06-01 03:14:56 +07:00
Implement sidebar rendering of the configured worktrees (#51342)
Implements worktree support for the agent panel sidebar Before you mark this PR as ready for review, make sure that you have: - [x] Added a solid test coverage and/or screenshots from doing manual testing - [x] Done a self-review taking into account security and performance aspects - [x] Aligned any UI changes with the [UI checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) Release Notes: - N/A
This commit is contained in:
parent
dcab464608
commit
17adc40d61
11 changed files with 777 additions and 8 deletions
|
|
@ -2642,6 +2642,12 @@ impl AgentPanel {
|
|||
}
|
||||
}
|
||||
|
||||
// TODO: The mapping from workspace root paths to git repositories needs a
|
||||
// unified approach across the codebase: this method, `sidebar::is_root_repo`,
|
||||
// thread persistence (which PathList is saved to the database), and thread
|
||||
// querying (which PathList is used to read threads back). All of these need
|
||||
// to agree on how repos are resolved for a given workspace, especially in
|
||||
// multi-root and nested-repo configurations.
|
||||
/// Partitions the project's visible worktrees into git-backed repositories
|
||||
/// and plain (non-git) paths. Git repos will have worktrees created for
|
||||
/// them; non-git paths are carried over to the new workspace as-is.
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ use project::Event as ProjectEvent;
|
|||
use settings::Settings;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::mem;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use theme::ActiveTheme;
|
||||
use ui::{
|
||||
AgentThreadStatus, ButtonStyle, HighlightedLabel, IconButtonShape, ListItem, Tab, ThreadItem,
|
||||
|
|
@ -107,6 +109,8 @@ struct ThreadEntry {
|
|||
is_live: bool,
|
||||
is_background: bool,
|
||||
highlight_positions: Vec<usize>,
|
||||
worktree_name: Option<SharedString>,
|
||||
worktree_highlight_positions: Vec<usize>,
|
||||
diff_stats: DiffStats,
|
||||
}
|
||||
|
||||
|
|
@ -172,6 +176,32 @@ fn fuzzy_match_positions(query: &str, candidate: &str) -> Option<Vec<usize>> {
|
|||
}
|
||||
}
|
||||
|
||||
// TODO: The mapping from workspace root paths to git repositories needs a
|
||||
// unified approach across the codebase: this function, `AgentPanel::classify_worktrees`,
|
||||
// thread persistence (which PathList is saved to the database), and thread
|
||||
// querying (which PathList is used to read threads back). All of these need
|
||||
// to agree on how repos are resolved for a given workspace, especially in
|
||||
// multi-root and nested-repo configurations.
|
||||
fn root_repository_snapshots(
|
||||
workspace: &Entity<Workspace>,
|
||||
cx: &App,
|
||||
) -> Vec<project::git_store::RepositorySnapshot> {
|
||||
let (path_list, _) = workspace_path_list_and_label(workspace, cx);
|
||||
let project = workspace.read(cx).project().read(cx);
|
||||
project
|
||||
.repositories(cx)
|
||||
.values()
|
||||
.filter_map(|repo| {
|
||||
let snapshot = repo.read(cx).snapshot();
|
||||
let is_root = path_list
|
||||
.paths()
|
||||
.iter()
|
||||
.any(|p| p.as_path() == snapshot.work_directory_abs_path.as_ref());
|
||||
is_root.then_some(snapshot)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn workspace_path_list_and_label(
|
||||
workspace: &Entity<Workspace>,
|
||||
cx: &App,
|
||||
|
|
@ -348,6 +378,26 @@ impl Sidebar {
|
|||
)
|
||||
.detach();
|
||||
|
||||
let git_store = workspace.read(cx).project().read(cx).git_store().clone();
|
||||
cx.subscribe_in(
|
||||
&git_store,
|
||||
window,
|
||||
|this, _, event: &project::git_store::GitStoreEvent, window, cx| {
|
||||
if matches!(
|
||||
event,
|
||||
project::git_store::GitStoreEvent::RepositoryUpdated(
|
||||
_,
|
||||
project::git_store::RepositoryEvent::GitWorktreeListChanged,
|
||||
_,
|
||||
)
|
||||
) {
|
||||
this.prune_stale_worktree_workspaces(window, cx);
|
||||
this.update_entries(cx);
|
||||
}
|
||||
},
|
||||
)
|
||||
.detach();
|
||||
|
||||
cx.subscribe_in(
|
||||
workspace,
|
||||
window,
|
||||
|
|
@ -472,7 +522,52 @@ impl Sidebar {
|
|||
// Compute active_entry_index inline during the build pass.
|
||||
let mut active_entry_index: Option<usize> = None;
|
||||
|
||||
for workspace in workspaces.iter() {
|
||||
// Identify absorbed workspaces in a single pass. A workspace is
|
||||
// "absorbed" when it points at a git worktree checkout whose main
|
||||
// repo is open as another workspace — its threads appear under the
|
||||
// main repo's header instead of getting their own.
|
||||
let mut main_repo_workspace: HashMap<Arc<Path>, usize> = HashMap::new();
|
||||
let mut absorbed: HashMap<usize, (usize, SharedString)> = HashMap::new();
|
||||
let mut pending: HashMap<Arc<Path>, Vec<(usize, SharedString)>> = HashMap::new();
|
||||
|
||||
for (i, workspace) in workspaces.iter().enumerate() {
|
||||
for snapshot in root_repository_snapshots(workspace, cx) {
|
||||
if snapshot.work_directory_abs_path == snapshot.original_repo_abs_path {
|
||||
main_repo_workspace
|
||||
.entry(snapshot.work_directory_abs_path.clone())
|
||||
.or_insert(i);
|
||||
if let Some(waiting) = pending.remove(&snapshot.work_directory_abs_path) {
|
||||
for (ws_idx, name) in waiting {
|
||||
absorbed.insert(ws_idx, (i, name));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let name: SharedString = snapshot
|
||||
.work_directory_abs_path
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
.into();
|
||||
if let Some(&main_idx) =
|
||||
main_repo_workspace.get(&snapshot.original_repo_abs_path)
|
||||
{
|
||||
absorbed.insert(i, (main_idx, name));
|
||||
} else {
|
||||
pending
|
||||
.entry(snapshot.original_repo_abs_path.clone())
|
||||
.or_default()
|
||||
.push((i, name));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (ws_index, workspace) in workspaces.iter().enumerate() {
|
||||
if absorbed.contains_key(&ws_index) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let (path_list, label) = workspace_path_list_and_label(workspace, cx);
|
||||
|
||||
let is_collapsed = self.collapsed_groups.contains(&path_list);
|
||||
|
|
@ -481,8 +576,11 @@ impl Sidebar {
|
|||
let mut threads: Vec<ThreadEntry> = Vec::new();
|
||||
|
||||
if should_load_threads {
|
||||
let mut seen_session_ids: HashSet<acp::SessionId> = HashSet::new();
|
||||
|
||||
if let Some(ref thread_store) = thread_store {
|
||||
for meta in thread_store.read(cx).threads_for_paths(&path_list) {
|
||||
seen_session_ids.insert(meta.id.clone());
|
||||
threads.push(ThreadEntry {
|
||||
session_info: meta.into(),
|
||||
icon: IconName::ZedAgent,
|
||||
|
|
@ -492,11 +590,56 @@ impl Sidebar {
|
|||
is_live: false,
|
||||
is_background: false,
|
||||
highlight_positions: Vec::new(),
|
||||
worktree_name: None,
|
||||
worktree_highlight_positions: Vec::new(),
|
||||
diff_stats: DiffStats::default(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Load threads from linked git worktrees of this workspace's repos.
|
||||
if let Some(ref thread_store) = thread_store {
|
||||
let mut linked_worktree_queries: Vec<(PathList, SharedString)> = Vec::new();
|
||||
for snapshot in root_repository_snapshots(workspace, cx) {
|
||||
if snapshot.work_directory_abs_path != snapshot.original_repo_abs_path {
|
||||
continue;
|
||||
}
|
||||
for git_worktree in snapshot.linked_worktrees() {
|
||||
let name = git_worktree
|
||||
.path
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
linked_worktree_queries.push((
|
||||
PathList::new(std::slice::from_ref(&git_worktree.path)),
|
||||
name.into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
for (worktree_path_list, worktree_name) in &linked_worktree_queries {
|
||||
for meta in thread_store.read(cx).threads_for_paths(worktree_path_list) {
|
||||
if !seen_session_ids.insert(meta.id.clone()) {
|
||||
continue;
|
||||
}
|
||||
threads.push(ThreadEntry {
|
||||
session_info: meta.into(),
|
||||
icon: IconName::ZedAgent,
|
||||
icon_from_external_svg: None,
|
||||
status: AgentThreadStatus::default(),
|
||||
workspace: workspace.clone(),
|
||||
is_live: false,
|
||||
is_background: false,
|
||||
highlight_positions: Vec::new(),
|
||||
worktree_name: Some(worktree_name.clone()),
|
||||
worktree_highlight_positions: Vec::new(),
|
||||
diff_stats: DiffStats::default(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let live_infos = Self::all_thread_infos_for_workspace(workspace, cx);
|
||||
|
||||
if !live_infos.is_empty() {
|
||||
|
|
@ -570,7 +713,16 @@ impl Sidebar {
|
|||
if let Some(positions) = fuzzy_match_positions(&query, title) {
|
||||
thread.highlight_positions = positions;
|
||||
}
|
||||
if workspace_matched || !thread.highlight_positions.is_empty() {
|
||||
if let Some(worktree_name) = &thread.worktree_name {
|
||||
if let Some(positions) = fuzzy_match_positions(&query, worktree_name) {
|
||||
thread.worktree_highlight_positions = positions;
|
||||
}
|
||||
}
|
||||
let worktree_matched = !thread.worktree_highlight_positions.is_empty();
|
||||
if workspace_matched
|
||||
|| !thread.highlight_positions.is_empty()
|
||||
|| worktree_matched
|
||||
{
|
||||
matched_threads.push(thread);
|
||||
}
|
||||
}
|
||||
|
|
@ -1024,6 +1176,52 @@ impl Sidebar {
|
|||
});
|
||||
}
|
||||
|
||||
fn prune_stale_worktree_workspaces(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(multi_workspace) = self.multi_workspace.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let workspaces = multi_workspace.read(cx).workspaces().to_vec();
|
||||
|
||||
// Collect all worktree paths that are currently listed by any main
|
||||
// repo open in any workspace.
|
||||
let mut known_worktree_paths: HashSet<std::path::PathBuf> = HashSet::new();
|
||||
for workspace in &workspaces {
|
||||
for snapshot in root_repository_snapshots(workspace, cx) {
|
||||
if snapshot.work_directory_abs_path != snapshot.original_repo_abs_path {
|
||||
continue;
|
||||
}
|
||||
for git_worktree in snapshot.linked_worktrees() {
|
||||
known_worktree_paths.insert(git_worktree.path.to_path_buf());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find workspaces that consist of exactly one root folder which is a
|
||||
// stale worktree checkout. Multi-root workspaces are never pruned —
|
||||
// losing one worktree shouldn't destroy a workspace that also
|
||||
// contains other folders.
|
||||
let mut to_remove: Vec<Entity<Workspace>> = Vec::new();
|
||||
for workspace in &workspaces {
|
||||
let (path_list, _) = workspace_path_list_and_label(workspace, cx);
|
||||
if path_list.paths().len() != 1 {
|
||||
continue;
|
||||
}
|
||||
let should_prune = root_repository_snapshots(workspace, cx)
|
||||
.iter()
|
||||
.any(|snapshot| {
|
||||
snapshot.work_directory_abs_path != snapshot.original_repo_abs_path
|
||||
&& !known_worktree_paths.contains(snapshot.work_directory_abs_path.as_ref())
|
||||
});
|
||||
if should_prune {
|
||||
to_remove.push(workspace.clone());
|
||||
}
|
||||
}
|
||||
|
||||
for workspace in &to_remove {
|
||||
self.remove_workspace(workspace, window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_workspace(
|
||||
&mut self,
|
||||
workspace: &Entity<Workspace>,
|
||||
|
|
@ -1316,6 +1514,10 @@ impl Sidebar {
|
|||
.when_some(thread.icon_from_external_svg.clone(), |this, svg| {
|
||||
this.custom_icon_from_external_svg(svg)
|
||||
})
|
||||
.when_some(thread.worktree_name.clone(), |this, name| {
|
||||
this.worktree(name)
|
||||
})
|
||||
.worktree_highlight_positions(thread.worktree_highlight_positions.clone())
|
||||
.when_some(timestamp, |this, ts| this.timestamp(ts))
|
||||
.highlight_positions(thread.highlight_positions.to_vec())
|
||||
.status(thread.status)
|
||||
|
|
@ -1913,9 +2115,14 @@ mod tests {
|
|||
} else {
|
||||
""
|
||||
};
|
||||
let worktree = thread
|
||||
.worktree_name
|
||||
.as_ref()
|
||||
.map(|name| format!(" {{{}}}", name))
|
||||
.unwrap_or_default();
|
||||
format!(
|
||||
" {}{}{}{}{}",
|
||||
title, active, status_str, notified, selected
|
||||
" {}{}{}{}{}{}",
|
||||
title, worktree, active, status_str, notified, selected
|
||||
)
|
||||
}
|
||||
ListEntry::ViewMore {
|
||||
|
|
@ -2244,6 +2451,8 @@ mod tests {
|
|||
is_live: false,
|
||||
is_background: false,
|
||||
highlight_positions: Vec::new(),
|
||||
worktree_name: None,
|
||||
worktree_highlight_positions: Vec::new(),
|
||||
diff_stats: DiffStats::default(),
|
||||
}),
|
||||
// Active thread with Running status
|
||||
|
|
@ -2263,6 +2472,8 @@ mod tests {
|
|||
is_live: true,
|
||||
is_background: false,
|
||||
highlight_positions: Vec::new(),
|
||||
worktree_name: None,
|
||||
worktree_highlight_positions: Vec::new(),
|
||||
diff_stats: DiffStats::default(),
|
||||
}),
|
||||
// Active thread with Error status
|
||||
|
|
@ -2282,6 +2493,8 @@ mod tests {
|
|||
is_live: true,
|
||||
is_background: false,
|
||||
highlight_positions: Vec::new(),
|
||||
worktree_name: None,
|
||||
worktree_highlight_positions: Vec::new(),
|
||||
diff_stats: DiffStats::default(),
|
||||
}),
|
||||
// Thread with WaitingForConfirmation status, not active
|
||||
|
|
@ -2301,6 +2514,8 @@ mod tests {
|
|||
is_live: false,
|
||||
is_background: false,
|
||||
highlight_positions: Vec::new(),
|
||||
worktree_name: None,
|
||||
worktree_highlight_positions: Vec::new(),
|
||||
diff_stats: DiffStats::default(),
|
||||
}),
|
||||
// Background thread that completed (should show notification)
|
||||
|
|
@ -2320,6 +2535,8 @@ mod tests {
|
|||
is_live: true,
|
||||
is_background: true,
|
||||
highlight_positions: Vec::new(),
|
||||
worktree_name: None,
|
||||
worktree_highlight_positions: Vec::new(),
|
||||
diff_stats: DiffStats::default(),
|
||||
}),
|
||||
// View More entry
|
||||
|
|
@ -3829,4 +4046,263 @@ mod tests {
|
|||
);
|
||||
});
|
||||
}
|
||||
|
||||
async fn save_named_thread(
|
||||
session_id: &str,
|
||||
title: &str,
|
||||
path_list: &PathList,
|
||||
cx: &mut gpui::VisualTestContext,
|
||||
) {
|
||||
let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
|
||||
let save_task = thread_store.update(cx, |store, cx| {
|
||||
store.save_thread(
|
||||
acp::SessionId::new(Arc::from(session_id)),
|
||||
make_test_thread(
|
||||
title,
|
||||
chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
|
||||
),
|
||||
path_list.clone(),
|
||||
cx,
|
||||
)
|
||||
});
|
||||
save_task.await.unwrap();
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
async fn init_test_project_with_git(
|
||||
worktree_path: &str,
|
||||
cx: &mut TestAppContext,
|
||||
) -> (Entity<project::Project>, Arc<dyn fs::Fs>) {
|
||||
init_test(cx);
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
fs.insert_tree(
|
||||
worktree_path,
|
||||
serde_json::json!({
|
||||
".git": {},
|
||||
"src": {},
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
|
||||
let project = project::Project::test(fs.clone(), [worktree_path.as_ref()], cx).await;
|
||||
(project, fs)
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_search_matches_worktree_name(cx: &mut TestAppContext) {
|
||||
let (project, fs) = init_test_project_with_git("/project", cx).await;
|
||||
|
||||
fs.as_fake()
|
||||
.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
|
||||
state.worktrees.push(git::repository::Worktree {
|
||||
path: std::path::PathBuf::from("/wt/rosewood"),
|
||||
ref_name: "refs/heads/rosewood".into(),
|
||||
sha: "abc".into(),
|
||||
});
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
project
|
||||
.update(cx, |project, cx| project.git_scans_complete(cx))
|
||||
.await;
|
||||
|
||||
let (multi_workspace, cx) =
|
||||
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
|
||||
let sidebar = setup_sidebar(&multi_workspace, cx);
|
||||
|
||||
let main_paths = PathList::new(&[std::path::PathBuf::from("/project")]);
|
||||
let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt/rosewood")]);
|
||||
save_named_thread("main-t", "Unrelated Thread", &main_paths, cx).await;
|
||||
save_named_thread("wt-t", "Fix Bug", &wt_paths, cx).await;
|
||||
|
||||
multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
|
||||
cx.run_until_parked();
|
||||
|
||||
// Search for "rosewood" — should match the worktree name, not the title.
|
||||
type_in_search(&sidebar, "rosewood", cx);
|
||||
|
||||
assert_eq!(
|
||||
visible_entries_as_strings(&sidebar, cx),
|
||||
vec!["v [project]", " Fix Bug {rosewood} <== selected"],
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_git_worktree_added_live_updates_sidebar(cx: &mut TestAppContext) {
|
||||
let (project, fs) = init_test_project_with_git("/project", cx).await;
|
||||
|
||||
project
|
||||
.update(cx, |project, cx| project.git_scans_complete(cx))
|
||||
.await;
|
||||
|
||||
let (multi_workspace, cx) =
|
||||
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
|
||||
let sidebar = setup_sidebar(&multi_workspace, cx);
|
||||
|
||||
// Save a thread against a worktree path that doesn't exist yet.
|
||||
let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt/rosewood")]);
|
||||
save_named_thread("wt-thread", "Worktree Thread", &wt_paths, cx).await;
|
||||
|
||||
multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
|
||||
cx.run_until_parked();
|
||||
|
||||
// Thread is not visible yet — no worktree knows about this path.
|
||||
assert_eq!(
|
||||
visible_entries_as_strings(&sidebar, cx),
|
||||
vec!["v [project]", " [+ New Thread]"]
|
||||
);
|
||||
|
||||
// Now add the worktree to the git state and trigger a rescan.
|
||||
fs.as_fake()
|
||||
.with_git_state(std::path::Path::new("/project/.git"), true, |state| {
|
||||
state.worktrees.push(git::repository::Worktree {
|
||||
path: std::path::PathBuf::from("/wt/rosewood"),
|
||||
ref_name: "refs/heads/rosewood".into(),
|
||||
sha: "abc".into(),
|
||||
});
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
cx.run_until_parked();
|
||||
|
||||
assert_eq!(
|
||||
visible_entries_as_strings(&sidebar, cx),
|
||||
vec!["v [project]", " Worktree Thread {rosewood}",]
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_two_worktree_workspaces_absorbed_when_main_added(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
|
||||
// Create the main repo directory (not opened as a workspace yet).
|
||||
fs.insert_tree(
|
||||
"/project",
|
||||
serde_json::json!({
|
||||
".git": {
|
||||
"worktrees": {
|
||||
"feature-a": {
|
||||
"commondir": "../../",
|
||||
"HEAD": "ref: refs/heads/feature-a",
|
||||
},
|
||||
"feature-b": {
|
||||
"commondir": "../../",
|
||||
"HEAD": "ref: refs/heads/feature-b",
|
||||
},
|
||||
},
|
||||
},
|
||||
"src": {},
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Two worktree checkouts whose .git files point back to the main repo.
|
||||
fs.insert_tree(
|
||||
"/wt-feature-a",
|
||||
serde_json::json!({
|
||||
".git": "gitdir: /project/.git/worktrees/feature-a",
|
||||
"src": {},
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
fs.insert_tree(
|
||||
"/wt-feature-b",
|
||||
serde_json::json!({
|
||||
".git": "gitdir: /project/.git/worktrees/feature-b",
|
||||
"src": {},
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
|
||||
|
||||
let project_a = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
|
||||
let project_b = project::Project::test(fs.clone(), ["/wt-feature-b".as_ref()], cx).await;
|
||||
|
||||
project_a.update(cx, |p, cx| p.git_scans_complete(cx)).await;
|
||||
project_b.update(cx, |p, cx| p.git_scans_complete(cx)).await;
|
||||
|
||||
// Open both worktrees as workspaces — no main repo yet.
|
||||
let (multi_workspace, cx) = cx
|
||||
.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
|
||||
multi_workspace.update_in(cx, |mw, window, cx| {
|
||||
mw.test_add_workspace(project_b.clone(), window, cx);
|
||||
});
|
||||
let sidebar = setup_sidebar(&multi_workspace, cx);
|
||||
|
||||
let paths_a = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
|
||||
let paths_b = PathList::new(&[std::path::PathBuf::from("/wt-feature-b")]);
|
||||
save_named_thread("thread-a", "Thread A", &paths_a, cx).await;
|
||||
save_named_thread("thread-b", "Thread B", &paths_b, cx).await;
|
||||
|
||||
multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
|
||||
cx.run_until_parked();
|
||||
|
||||
// Without the main repo, each worktree has its own header.
|
||||
assert_eq!(
|
||||
visible_entries_as_strings(&sidebar, cx),
|
||||
vec![
|
||||
"v [wt-feature-a]",
|
||||
" Thread A",
|
||||
"v [wt-feature-b]",
|
||||
" Thread B",
|
||||
]
|
||||
);
|
||||
|
||||
// Configure the main repo to list both worktrees before opening
|
||||
// it so the initial git scan picks them up.
|
||||
fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
|
||||
state.worktrees.push(git::repository::Worktree {
|
||||
path: std::path::PathBuf::from("/wt-feature-a"),
|
||||
ref_name: "refs/heads/feature-a".into(),
|
||||
sha: "aaa".into(),
|
||||
});
|
||||
state.worktrees.push(git::repository::Worktree {
|
||||
path: std::path::PathBuf::from("/wt-feature-b"),
|
||||
ref_name: "refs/heads/feature-b".into(),
|
||||
sha: "bbb".into(),
|
||||
});
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
|
||||
main_project
|
||||
.update(cx, |p, cx| p.git_scans_complete(cx))
|
||||
.await;
|
||||
|
||||
multi_workspace.update_in(cx, |mw, window, cx| {
|
||||
mw.test_add_workspace(main_project.clone(), window, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
// Both worktree workspaces should now be absorbed under the main
|
||||
// repo header, with worktree chips.
|
||||
assert_eq!(
|
||||
visible_entries_as_strings(&sidebar, cx),
|
||||
vec![
|
||||
"v [project]",
|
||||
" Thread A {wt-feature-a}",
|
||||
" Thread B {wt-feature-b}",
|
||||
]
|
||||
);
|
||||
|
||||
// Remove feature-b from the main repo's linked worktrees.
|
||||
// The feature-b workspace should be pruned automatically.
|
||||
fs.with_git_state(std::path::Path::new("/project/.git"), true, |state| {
|
||||
state
|
||||
.worktrees
|
||||
.retain(|wt| wt.path != std::path::Path::new("/wt-feature-b"));
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
cx.run_until_parked();
|
||||
|
||||
// feature-b's workspace is pruned; feature-a remains absorbed
|
||||
// under the main repo.
|
||||
assert_eq!(
|
||||
visible_entries_as_strings(&sidebar, cx),
|
||||
vec!["v [project]", " Thread A {wt-feature-a}",]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -109,6 +109,7 @@ CREATE TABLE "project_repositories" (
|
|||
"head_commit_details" VARCHAR,
|
||||
"remote_upstream_url" VARCHAR,
|
||||
"remote_origin_url" VARCHAR,
|
||||
"linked_worktrees" VARCHAR,
|
||||
PRIMARY KEY (project_id, id)
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -307,7 +307,8 @@ CREATE TABLE public.project_repositories (
|
|||
head_commit_details character varying,
|
||||
merge_message character varying,
|
||||
remote_upstream_url character varying,
|
||||
remote_origin_url character varying
|
||||
remote_origin_url character varying,
|
||||
linked_worktrees text
|
||||
);
|
||||
|
||||
CREATE TABLE public.project_repository_statuses (
|
||||
|
|
|
|||
|
|
@ -374,6 +374,9 @@ impl Database {
|
|||
merge_message: ActiveValue::set(update.merge_message.clone()),
|
||||
remote_upstream_url: ActiveValue::set(update.remote_upstream_url.clone()),
|
||||
remote_origin_url: ActiveValue::set(update.remote_origin_url.clone()),
|
||||
linked_worktrees: ActiveValue::Set(Some(
|
||||
serde_json::to_string(&update.linked_worktrees).unwrap(),
|
||||
)),
|
||||
})
|
||||
.on_conflict(
|
||||
OnConflict::columns([
|
||||
|
|
@ -388,6 +391,7 @@ impl Database {
|
|||
project_repository::Column::CurrentMergeConflicts,
|
||||
project_repository::Column::HeadCommitDetails,
|
||||
project_repository::Column::MergeMessage,
|
||||
project_repository::Column::LinkedWorktrees,
|
||||
])
|
||||
.to_owned(),
|
||||
)
|
||||
|
|
@ -883,6 +887,11 @@ impl Database {
|
|||
remote_upstream_url: db_repository_entry.remote_upstream_url.clone(),
|
||||
remote_origin_url: db_repository_entry.remote_origin_url.clone(),
|
||||
original_repo_abs_path: Some(db_repository_entry.abs_path),
|
||||
linked_worktrees: db_repository_entry
|
||||
.linked_worktrees
|
||||
.as_deref()
|
||||
.and_then(|s| serde_json::from_str(s).ok())
|
||||
.unwrap_or_default(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -799,6 +799,11 @@ impl Database {
|
|||
remote_upstream_url: db_repository.remote_upstream_url.clone(),
|
||||
remote_origin_url: db_repository.remote_origin_url.clone(),
|
||||
original_repo_abs_path: Some(db_repository.abs_path),
|
||||
linked_worktrees: db_repository
|
||||
.linked_worktrees
|
||||
.as_deref()
|
||||
.and_then(|s| serde_json::from_str(s).ok())
|
||||
.unwrap_or_default(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ pub struct Model {
|
|||
pub head_commit_details: Option<String>,
|
||||
pub remote_upstream_url: Option<String>,
|
||||
pub remote_origin_url: Option<String>,
|
||||
// JSON array of linked worktree objects
|
||||
pub linked_worktrees: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
use std::path::{Path, PathBuf};
|
||||
|
||||
use call::ActiveCall;
|
||||
use client::RECEIVE_TIMEOUT;
|
||||
use collections::HashMap;
|
||||
use git::{
|
||||
repository::RepoPath,
|
||||
repository::{RepoPath, Worktree as GitWorktree},
|
||||
status::{DiffStat, FileStatus, StatusCode, TrackedStatus},
|
||||
};
|
||||
use git_ui::{git_panel::GitPanel, project_diff::ProjectDiff};
|
||||
|
|
@ -365,6 +366,236 @@ async fn test_remote_git_worktrees(
|
|||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_linked_worktrees_sync(
|
||||
executor: BackgroundExecutor,
|
||||
cx_a: &mut TestAppContext,
|
||||
cx_b: &mut TestAppContext,
|
||||
cx_c: &mut TestAppContext,
|
||||
) {
|
||||
let mut server = TestServer::start(executor.clone()).await;
|
||||
let client_a = server.create_client(cx_a, "user_a").await;
|
||||
let client_b = server.create_client(cx_b, "user_b").await;
|
||||
let client_c = server.create_client(cx_c, "user_c").await;
|
||||
server
|
||||
.create_room(&mut [(&client_a, cx_a), (&client_b, cx_b), (&client_c, cx_c)])
|
||||
.await;
|
||||
let active_call_a = cx_a.read(ActiveCall::global);
|
||||
|
||||
// Set up a git repo with two linked worktrees already present.
|
||||
client_a
|
||||
.fs()
|
||||
.insert_tree(
|
||||
path!("/project"),
|
||||
json!({ ".git": {}, "file.txt": "content" }),
|
||||
)
|
||||
.await;
|
||||
|
||||
client_a
|
||||
.fs()
|
||||
.with_git_state(Path::new(path!("/project/.git")), true, |state| {
|
||||
state.worktrees.push(GitWorktree {
|
||||
path: PathBuf::from(path!("/project")),
|
||||
ref_name: "refs/heads/main".into(),
|
||||
sha: "aaa111".into(),
|
||||
});
|
||||
state.worktrees.push(GitWorktree {
|
||||
path: PathBuf::from(path!("/project/feature-branch")),
|
||||
ref_name: "refs/heads/feature-branch".into(),
|
||||
sha: "bbb222".into(),
|
||||
});
|
||||
state.worktrees.push(GitWorktree {
|
||||
path: PathBuf::from(path!("/project/bugfix-branch")),
|
||||
ref_name: "refs/heads/bugfix-branch".into(),
|
||||
sha: "ccc333".into(),
|
||||
});
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let (project_a, _) = client_a.build_local_project(path!("/project"), cx_a).await;
|
||||
|
||||
// Wait for git scanning to complete on the host.
|
||||
executor.run_until_parked();
|
||||
|
||||
// Verify the host sees 2 linked worktrees (main worktree is filtered out).
|
||||
let host_linked = project_a.read_with(cx_a, |project, cx| {
|
||||
let repos = project.repositories(cx);
|
||||
assert_eq!(repos.len(), 1, "host should have exactly 1 repository");
|
||||
let repo = repos.values().next().unwrap();
|
||||
repo.read(cx).linked_worktrees().to_vec()
|
||||
});
|
||||
assert_eq!(
|
||||
host_linked.len(),
|
||||
2,
|
||||
"host should have 2 linked worktrees (main filtered out)"
|
||||
);
|
||||
assert_eq!(
|
||||
host_linked[0].path,
|
||||
PathBuf::from(path!("/project/feature-branch"))
|
||||
);
|
||||
assert_eq!(
|
||||
host_linked[0].ref_name.as_ref(),
|
||||
"refs/heads/feature-branch"
|
||||
);
|
||||
assert_eq!(host_linked[0].sha.as_ref(), "bbb222");
|
||||
assert_eq!(
|
||||
host_linked[1].path,
|
||||
PathBuf::from(path!("/project/bugfix-branch"))
|
||||
);
|
||||
assert_eq!(host_linked[1].ref_name.as_ref(), "refs/heads/bugfix-branch");
|
||||
assert_eq!(host_linked[1].sha.as_ref(), "ccc333");
|
||||
|
||||
// Share the project and have client B join.
|
||||
let project_id = active_call_a
|
||||
.update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
|
||||
.await
|
||||
.unwrap();
|
||||
let project_b = client_b.join_remote_project(project_id, cx_b).await;
|
||||
|
||||
executor.run_until_parked();
|
||||
|
||||
// Verify the guest sees the same linked worktrees as the host.
|
||||
let guest_linked = project_b.read_with(cx_b, |project, cx| {
|
||||
let repos = project.repositories(cx);
|
||||
assert_eq!(repos.len(), 1, "guest should have exactly 1 repository");
|
||||
let repo = repos.values().next().unwrap();
|
||||
repo.read(cx).linked_worktrees().to_vec()
|
||||
});
|
||||
assert_eq!(
|
||||
guest_linked, host_linked,
|
||||
"guest's linked_worktrees should match host's after initial sync"
|
||||
);
|
||||
|
||||
// Now mutate: add a third linked worktree on the host side.
|
||||
client_a
|
||||
.fs()
|
||||
.with_git_state(Path::new(path!("/project/.git")), true, |state| {
|
||||
state.worktrees.push(GitWorktree {
|
||||
path: PathBuf::from(path!("/project/hotfix-branch")),
|
||||
ref_name: "refs/heads/hotfix-branch".into(),
|
||||
sha: "ddd444".into(),
|
||||
});
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// Wait for the host to re-scan and propagate the update.
|
||||
executor.run_until_parked();
|
||||
|
||||
// Verify host now sees 3 linked worktrees.
|
||||
let host_linked_updated = project_a.read_with(cx_a, |project, cx| {
|
||||
let repos = project.repositories(cx);
|
||||
let repo = repos.values().next().unwrap();
|
||||
repo.read(cx).linked_worktrees().to_vec()
|
||||
});
|
||||
assert_eq!(
|
||||
host_linked_updated.len(),
|
||||
3,
|
||||
"host should now have 3 linked worktrees"
|
||||
);
|
||||
assert_eq!(
|
||||
host_linked_updated[2].path,
|
||||
PathBuf::from(path!("/project/hotfix-branch"))
|
||||
);
|
||||
|
||||
// Verify the guest also received the update.
|
||||
let guest_linked_updated = project_b.read_with(cx_b, |project, cx| {
|
||||
let repos = project.repositories(cx);
|
||||
let repo = repos.values().next().unwrap();
|
||||
repo.read(cx).linked_worktrees().to_vec()
|
||||
});
|
||||
assert_eq!(
|
||||
guest_linked_updated, host_linked_updated,
|
||||
"guest's linked_worktrees should match host's after update"
|
||||
);
|
||||
|
||||
// Now mutate: remove one linked worktree from the host side.
|
||||
client_a
|
||||
.fs()
|
||||
.with_git_state(Path::new(path!("/project/.git")), true, |state| {
|
||||
state
|
||||
.worktrees
|
||||
.retain(|wt| wt.ref_name.as_ref() != "refs/heads/bugfix-branch");
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
executor.run_until_parked();
|
||||
|
||||
// Verify host now sees 2 linked worktrees (feature-branch and hotfix-branch).
|
||||
let host_linked_after_removal = project_a.read_with(cx_a, |project, cx| {
|
||||
let repos = project.repositories(cx);
|
||||
let repo = repos.values().next().unwrap();
|
||||
repo.read(cx).linked_worktrees().to_vec()
|
||||
});
|
||||
assert_eq!(
|
||||
host_linked_after_removal.len(),
|
||||
2,
|
||||
"host should have 2 linked worktrees after removal"
|
||||
);
|
||||
assert!(
|
||||
host_linked_after_removal
|
||||
.iter()
|
||||
.all(|wt| wt.ref_name.as_ref() != "refs/heads/bugfix-branch"),
|
||||
"bugfix-branch should have been removed"
|
||||
);
|
||||
|
||||
// Verify the guest also reflects the removal.
|
||||
let guest_linked_after_removal = project_b.read_with(cx_b, |project, cx| {
|
||||
let repos = project.repositories(cx);
|
||||
let repo = repos.values().next().unwrap();
|
||||
repo.read(cx).linked_worktrees().to_vec()
|
||||
});
|
||||
assert_eq!(
|
||||
guest_linked_after_removal, host_linked_after_removal,
|
||||
"guest's linked_worktrees should match host's after removal"
|
||||
);
|
||||
|
||||
// Test DB roundtrip: client C joins late, getting state from the database.
|
||||
// This verifies that linked_worktrees are persisted and restored correctly.
|
||||
let project_c = client_c.join_remote_project(project_id, cx_c).await;
|
||||
executor.run_until_parked();
|
||||
|
||||
let late_joiner_linked = project_c.read_with(cx_c, |project, cx| {
|
||||
let repos = project.repositories(cx);
|
||||
assert_eq!(
|
||||
repos.len(),
|
||||
1,
|
||||
"late joiner should have exactly 1 repository"
|
||||
);
|
||||
let repo = repos.values().next().unwrap();
|
||||
repo.read(cx).linked_worktrees().to_vec()
|
||||
});
|
||||
assert_eq!(
|
||||
late_joiner_linked, host_linked_after_removal,
|
||||
"late-joining client's linked_worktrees should match host's (DB roundtrip)"
|
||||
);
|
||||
|
||||
// Test reconnection: disconnect client B (guest) and reconnect.
|
||||
// After rejoining, client B should get linked_worktrees back from the DB.
|
||||
server.disconnect_client(client_b.peer_id().unwrap());
|
||||
executor.advance_clock(RECEIVE_TIMEOUT);
|
||||
executor.run_until_parked();
|
||||
|
||||
// Client B reconnects automatically.
|
||||
executor.advance_clock(RECEIVE_TIMEOUT);
|
||||
executor.run_until_parked();
|
||||
|
||||
// Verify client B still has the correct linked worktrees after reconnection.
|
||||
let guest_linked_after_reconnect = project_b.read_with(cx_b, |project, cx| {
|
||||
let repos = project.repositories(cx);
|
||||
assert_eq!(
|
||||
repos.len(),
|
||||
1,
|
||||
"guest should still have exactly 1 repository after reconnect"
|
||||
);
|
||||
let repo = repos.values().next().unwrap();
|
||||
repo.read(cx).linked_worktrees().to_vec()
|
||||
});
|
||||
assert_eq!(
|
||||
guest_linked_after_reconnect, host_linked_after_removal,
|
||||
"guest's linked_worktrees should survive guest disconnect/reconnect"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_diff_stat_sync_between_host_and_downstream_client(
|
||||
cx_a: &mut TestAppContext,
|
||||
|
|
|
|||
|
|
@ -790,7 +790,7 @@ impl GitRepository for FakeGitRepository {
|
|||
}
|
||||
|
||||
fn diff(&self, _diff: git::repository::DiffType) -> BoxFuture<'_, Result<String>> {
|
||||
unimplemented!()
|
||||
future::ready(Ok(String::new())).boxed()
|
||||
}
|
||||
|
||||
fn diff_stat(
|
||||
|
|
|
|||
|
|
@ -293,6 +293,7 @@ pub struct RepositorySnapshot {
|
|||
pub remote_origin_url: Option<String>,
|
||||
pub remote_upstream_url: Option<String>,
|
||||
pub stash_entries: GitStash,
|
||||
pub linked_worktrees: Arc<[GitWorktree]>,
|
||||
}
|
||||
|
||||
type JobId = u64;
|
||||
|
|
@ -429,6 +430,7 @@ pub enum RepositoryEvent {
|
|||
StatusesChanged,
|
||||
BranchChanged,
|
||||
StashEntriesChanged,
|
||||
GitWorktreeListChanged,
|
||||
PendingOpsChanged { pending_ops: SumTree<PendingOps> },
|
||||
GraphEvent((LogSource, LogOrder), GitGraphEvent),
|
||||
}
|
||||
|
|
@ -3575,6 +3577,7 @@ impl RepositorySnapshot {
|
|||
remote_origin_url: None,
|
||||
remote_upstream_url: None,
|
||||
stash_entries: Default::default(),
|
||||
linked_worktrees: Arc::from([]),
|
||||
path_style,
|
||||
}
|
||||
}
|
||||
|
|
@ -3613,6 +3616,11 @@ impl RepositorySnapshot {
|
|||
original_repo_abs_path: Some(
|
||||
self.original_repo_abs_path.to_string_lossy().into_owned(),
|
||||
),
|
||||
linked_worktrees: self
|
||||
.linked_worktrees
|
||||
.iter()
|
||||
.map(worktree_to_proto)
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3689,9 +3697,18 @@ impl RepositorySnapshot {
|
|||
original_repo_abs_path: Some(
|
||||
self.original_repo_abs_path.to_string_lossy().into_owned(),
|
||||
),
|
||||
linked_worktrees: self
|
||||
.linked_worktrees
|
||||
.iter()
|
||||
.map(worktree_to_proto)
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn linked_worktrees(&self) -> &[GitWorktree] {
|
||||
&self.linked_worktrees
|
||||
}
|
||||
|
||||
pub fn status(&self) -> impl Iterator<Item = StatusEntry> + '_ {
|
||||
self.statuses_by_path.iter().cloned()
|
||||
}
|
||||
|
|
@ -6145,6 +6162,15 @@ impl Repository {
|
|||
cx.emit(RepositoryEvent::StashEntriesChanged)
|
||||
}
|
||||
self.snapshot.stash_entries = new_stash_entries;
|
||||
let new_linked_worktrees: Arc<[GitWorktree]> = update
|
||||
.linked_worktrees
|
||||
.iter()
|
||||
.map(proto_to_worktree)
|
||||
.collect();
|
||||
if *self.snapshot.linked_worktrees != *new_linked_worktrees {
|
||||
cx.emit(RepositoryEvent::GitWorktreeListChanged);
|
||||
}
|
||||
self.snapshot.linked_worktrees = new_linked_worktrees;
|
||||
self.snapshot.remote_upstream_url = update.remote_upstream_url;
|
||||
self.snapshot.remote_origin_url = update.remote_origin_url;
|
||||
|
||||
|
|
@ -6901,14 +6927,20 @@ async fn compute_snapshot(
|
|||
}))
|
||||
.boxed()
|
||||
};
|
||||
let (statuses, diff_stats) = futures::future::try_join(
|
||||
let (statuses, diff_stats, all_worktrees) = futures::future::try_join3(
|
||||
backend.status(&[RepoPath::from_rel_path(
|
||||
&RelPath::new(".".as_ref(), PathStyle::local()).unwrap(),
|
||||
)]),
|
||||
diff_stat_future,
|
||||
backend.worktrees(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let linked_worktrees: Arc<[GitWorktree]> = all_worktrees
|
||||
.into_iter()
|
||||
.filter(|wt| wt.path != *work_directory_abs_path)
|
||||
.collect();
|
||||
|
||||
let diff_stat_map: HashMap<&RepoPath, DiffStat> =
|
||||
diff_stats.entries.iter().map(|(p, s)| (p, *s)).collect();
|
||||
let stash_entries = backend.stash_entries().await?;
|
||||
|
|
@ -6938,6 +6970,10 @@ async fn compute_snapshot(
|
|||
events.push(RepositoryEvent::BranchChanged);
|
||||
}
|
||||
|
||||
if *linked_worktrees != *prev_snapshot.linked_worktrees {
|
||||
events.push(RepositoryEvent::GitWorktreeListChanged);
|
||||
}
|
||||
|
||||
let remote_origin_url = backend.remote_url("origin").await;
|
||||
let remote_upstream_url = backend.remote_url("upstream").await;
|
||||
|
||||
|
|
@ -6954,6 +6990,7 @@ async fn compute_snapshot(
|
|||
remote_origin_url,
|
||||
remote_upstream_url,
|
||||
stash_entries,
|
||||
linked_worktrees,
|
||||
};
|
||||
|
||||
Ok((snapshot, events))
|
||||
|
|
|
|||
|
|
@ -126,6 +126,7 @@ message UpdateRepository {
|
|||
optional string remote_upstream_url = 14;
|
||||
optional string remote_origin_url = 15;
|
||||
optional string original_repo_abs_path = 16;
|
||||
repeated Worktree linked_worktrees = 17;
|
||||
}
|
||||
|
||||
message RemoveRepository {
|
||||
|
|
|
|||
Loading…
Reference in a new issue