agent_ui: Unify draft and background threads into retained threads (#53737)

> **Foundation PRs:** #53732, #53733, #53734 — These three draft PRs
contain the base refactors (retained workspaces, agent panel overlay
split, ThreadId introduction) that this PR builds on.

## Goal

Remove `DraftId` and merge `draft_threads` + `background_threads` into a
single `retained_threads: HashMap<ThreadId, Entity<ConversationView>>`
in `AgentPanel`. A draft is just a thread that hasn't sent its first
message — no separate identity or storage needed.

## Changes

### agent_panel.rs
- Remove `DraftId` / `DraftIdCounter` / the `Global` impl
- Merge the two maps into `retained_threads`
- Add `thread_id: ThreadId` field to `BaseView::AgentThread`
- Rename methods: `create_draft` → `create_thread`, `activate_draft` →
`activate_retained_thread`, `remove_draft` → `remove_thread`, etc.
- Replace `clear_active_thread` with `show_or_create_empty_draft`
- Update `update_thread_work_dirs` to sync `ThreadMetadataStore` paths
when worktrees change
- Keep `load_agent_thread(...)` cleanup so activating a real thread
removes empty retained drafts

### sidebar.rs
- Remove `active_entry` derivation from `rebuild_contents` (was racing
with deferred effects)
- Add `sync_active_entry_from_panel` called from event handlers instead
- Simplify `ActiveEntry` — remove `ThreadActivation` struct, make
`session_id` optional
- Move `seen_thread_ids` to global scope (was per-group, causing
duplicate thread entries)
- Remove dead code: `clear_draft`, `render_draft_thread`
- Generalize `pending_remote_thread_activation` into
`pending_thread_activation` so all persisted-thread activations suppress
fallback draft reconciliation
- Set the activation guard before local persisted-thread activation
switches workspaces
- Make `reconcile_groups(...)` bail while a persisted-thread activation
is in flight
- On `ActiveViewChanged`, clear empty group drafts as soon as a pending
persisted-thread activation resolves

### thread_metadata_store.rs
- Make `ThreadMetadata.title` an `Option<SharedString>` with
`display_title()` fallback
- Add `update_worktree_paths` for batched path updates when project
worktrees change

### Other crates
- Update all `ThreadMetadata` construction sites and title display sites
across `agent_ui` and `sidebar`

## Fallback draft invariant

This PR now tightens the retained-thread invariant around fallback
drafts vs real thread activation/restoration:

- Fallback drafts are always empty
- User-created drafts worth preserving are non-empty
- While a persisted thread is being activated/restored/loaded, sidebar
reconciliation must not create an empty fallback draft for that target
group/workspace
- Once the real thread becomes active, empty fallback drafts in that
target group are removed

This is enforced by the sidebar-side activation guard plus existing
`AgentPanel` empty-draft cleanup after real-thread load.

## Tests

Added and/or kept focused sidebar regression coverage for:

-
`test_confirm_on_historical_thread_in_new_project_group_opens_real_thread`
-
`test_unarchive_into_inactive_existing_workspace_does_not_leave_active_draft`
-
`test_unarchive_after_removing_parent_project_group_restores_real_thread`
- `test_pending_thread_activation_suppresses_reconcile_draft_creation`

Focused test runs:

- `cargo test -p sidebar activate_archived_thread -- --nocapture`
- `cargo test -p sidebar unarchive -- --nocapture`
- `cargo test -p sidebar archive_last_thread_on_linked_worktree --
--nocapture`
- `cargo test -p sidebar
test_confirm_on_historical_thread_in_new_project_group_opens_real_thread
-- --nocapture`
- `cargo test -p sidebar
test_unarchive_into_inactive_existing_workspace_does_not_leave_active_draft
-- --nocapture`
- `cargo test -p sidebar
test_unarchive_after_removing_parent_project_group_restores_real_thread
-- --nocapture`
- `cargo test -p sidebar
test_pending_thread_activation_suppresses_reconcile_draft_creation --
--nocapture`

## Test fix

Fixed flaky `test_backfill_sets_kvp_flag` — added per-App `AppDatabase`
isolation in `setup_backfill_test` so backfill tests no longer share a
static in-memory DB.

Release Notes:

- N/A

---------

Co-authored-by: Mikayla Maki <mikayla.c.maki@gmail.com>
Co-authored-by: Mikayla Maki <mikayla@zed.dev>
This commit is contained in:
authored and GitHub committed 2026-04-13 09:07:09 +00:00
1 parent 0d63122ad9
commit 9ffc384185
34 files changed
+7297 -3927

No files matched your search

+1
View File
@@ -1,4 +1,5 @@
**/*.db
**/*.proptest-regressions
**/cargo-target
**/target
**/venv
+41
View File
@@ -0,0 +1,41 @@
# Handoff: Introduce ThreadId — Fix sidebar compilation
## Branch: `introduce-thread-id`
## Worktree: `/Users/nathan/src/worktrees/zed/pure-stork/zed`
## What's done
All `agent_ui` crate changes are complete and compiling:
- `thread_metadata_store.rs`: New `ThreadId` type, DB migrations, all store methods updated
- `thread_import.rs`, `thread_worktree_archive.rs`, `threads_archive_view.rs`: Updated
- `thread_view.rs`: Added `is_draft()` method
- `agent_ui.rs`: Re-exports `ThreadId`
- `agent_panel.rs`: Minimal fixes (`entry_by_session`, `unarchive` lookup)
Verify: `cargo check -p agent_ui` passes.
## What remains
`crates/sidebar/src/sidebar.rs` has ~58 compilation errors because `ThreadMetadata.session_id` changed from `acp::SessionId` to `Option<acp::SessionId>`, and several store methods changed signatures.
Run `cargo check -p sidebar 2>&1` to see all errors.
### Mechanical fix patterns needed:
1. **Comparisons**: `metadata.session_id == *session_id` → `metadata.session_id.as_ref() == Some(session_id)`
2. **HashSet collection**: Where `session_id` is collected into `HashSet<SessionId>`, use `.filter_map(|m| m.session_id.clone())` to extract from Option
3. **Field access**: `session_id.0` → `session_id.as_ref().unwrap().0` or `if let Some(sid) = &session_id { sid.0 ... }`
4. **CancelRestore event**: Pattern `CancelRestore { session_id }` → `CancelRestore { thread_id }`. Import `agent_ui::ThreadId`.
5. **Store lookups**: `store.entry(&session_id)` → `store.entry_by_session(&session_id)`
6. **Store mutations** (`delete`, `archive`, `unarchive`, `update_working_directories`): These now take `ThreadId`. Look up via `store.entry_by_session(&session_id).map(|t| t.thread_id)` first, then call with the `ThreadId`.
7. **`entry_ids()`**: Now returns `Iterator<Item = ThreadId>` not `SessionId`
8. **`cleanup_thread_archived_worktrees`**: Now takes `ThreadId` not `&acp::SessionId`
### After fixing sidebar:
1. Run tests: `cargo test -p agent_ui -- thread_metadata` and `cargo test -p agent_ui -- thread_import`
2. Verify: `cargo check -p sidebar`
## Reference
The full target implementation exists on the `project-group-refactor` branch at `/Users/nathan/src/zed` if you need to check anything.
Generated
+1
View File
@@ -16079,6 +16079,7 @@ dependencies = [
"chrono",
"client",
"clock",
"db",
"editor",
"extension",
"fs",
+18 -3
View File
@@ -665,6 +665,23 @@ mod test_support {
)
}
/// Test-scoped counter for generating unique session IDs across all
/// `StubAgentConnection` instances within a test case. Set as a GPUI
/// global in test init so each case starts fresh.
pub struct StubSessionCounter(pub AtomicUsize);
impl gpui::Global for StubSessionCounter {}
impl StubSessionCounter {
pub fn next(cx: &App) -> usize {
cx.try_global::<Self>()
.map(|g| g.0.fetch_add(1, Ordering::SeqCst))
.unwrap_or_else(|| {
static FALLBACK: AtomicUsize = AtomicUsize::new(0);
FALLBACK.fetch_add(1, Ordering::SeqCst)
})
}
}
#[derive(Clone)]
pub struct StubAgentConnection {
sessions: Arc<Mutex<HashMap<acp::SessionId, Session>>>,
@@ -823,9 +840,7 @@ mod test_support {
work_dirs: PathList,
cx: &mut gpui::App,
) -> Task<gpui::Result<Entity<AcpThread>>> {
static NEXT_SESSION_ID: AtomicUsize = AtomicUsize::new(0);
let session_id =
acp::SessionId::new(NEXT_SESSION_ID.fetch_add(1, Ordering::SeqCst).to_string());
let session_id = acp::SessionId::new(StubSessionCounter::next(cx).to_string());
let thread = self.create_session(session_id, project, work_dirs, None, cx);
Task::ready(Ok(thread))
}
File diff suppressed because it is too large. Load diff
+6 -2
View File
@@ -65,9 +65,12 @@ use std::any::TypeId;
use workspace::Workspace;
use crate::agent_configuration::{ConfigureContextServerModal, ManageProfilesModal};
pub use crate::agent_panel::{AgentPanel, AgentPanelEvent, DraftId, WorktreeCreationStatus};
pub use crate::agent_panel::{
AgentPanel, AgentPanelEvent, MaxIdleRetainedThreads, WorktreeCreationStatus,
};
use crate::agent_registry_ui::AgentRegistryPage;
pub use crate::inline_assistant::InlineAssistant;
pub use crate::thread_metadata_store::ThreadId;
pub use agent_diff::{AgentDiffPane, AgentDiffToolbar};
pub use conversation_view::ConversationView;
pub use external_source_prompt::ExternalSourcePrompt;
@@ -79,7 +82,7 @@ pub(crate) use thread_history_view::*;
pub use thread_import::{AcpThreadImportOnboarding, ThreadImportModal};
use zed_actions;
pub const DEFAULT_THREAD_TITLE: &str = "New Thread";
pub const DEFAULT_THREAD_TITLE: &str = "New Agent Thread";
const PARALLEL_AGENT_LAYOUT_BACKFILL_KEY: &str = "parallel_agent_layout_backfilled";
actions!(
agent,
@@ -823,6 +826,7 @@ mod tests {
.unwrap();
cx.update(|cx| {
cx.set_global(db::AppDatabase::test_new());
let store = SettingsStore::test(cx);
cx.set_global(store);
AgentSettings::register(cx);
+215 -156
View File
@@ -82,6 +82,7 @@ use crate::entry_view_state::{EntryViewEvent, ViewEvent};
use crate::message_editor::{MessageEditor, MessageEditorEvent};
use crate::profile_selector::{ProfileProvider, ProfileSelector};
use crate::thread_metadata_store::ThreadId;
use crate::ui::{AgentNotification, AgentNotificationEvent};
use crate::{
Agent, AgentDiffPane, AgentInitialContent, AgentPanel, AllowAlways, AllowOnce,
@@ -246,44 +247,46 @@ pub(crate) struct Conversation {
impl Conversation {
pub fn register_thread(&mut self, thread: Entity<AcpThread>, cx: &mut Context<Self>) {
let session_id = thread.read(cx).session_id().clone();
let subscription = cx.subscribe(&thread, move |this, _thread, event, _cx| {
this.updated_at = Some(Instant::now());
match event {
AcpThreadEvent::ToolAuthorizationRequested(id) => {
this.permission_requests
.entry(session_id.clone())
.or_default()
.push(id.clone());
}
AcpThreadEvent::ToolAuthorizationReceived(id) => {
if let Some(tool_calls) = this.permission_requests.get_mut(&session_id) {
tool_calls.retain(|tool_call_id| tool_call_id != id);
if tool_calls.is_empty() {
this.permission_requests.shift_remove(&session_id);
let subscription = cx.subscribe(&thread, {
let session_id = session_id.clone();
move |this, _thread, event, _cx| {
this.updated_at = Some(Instant::now());
match event {
AcpThreadEvent::ToolAuthorizationRequested(id) => {
this.permission_requests
.entry(session_id.clone())
.or_default()
.push(id.clone());
}
AcpThreadEvent::ToolAuthorizationReceived(id) => {
if let Some(tool_calls) = this.permission_requests.get_mut(&session_id) {
tool_calls.retain(|tool_call_id| tool_call_id != id);
if tool_calls.is_empty() {
this.permission_requests.shift_remove(&session_id);
}
}
}
AcpThreadEvent::NewEntry
| AcpThreadEvent::TitleUpdated
| AcpThreadEvent::TokenUsageUpdated
| AcpThreadEvent::EntryUpdated(_)
| AcpThreadEvent::EntriesRemoved(_)
| AcpThreadEvent::Retry(_)
| AcpThreadEvent::SubagentSpawned(_)
| AcpThreadEvent::Stopped(_)
| AcpThreadEvent::Error
| AcpThreadEvent::LoadError(_)
| AcpThreadEvent::PromptCapabilitiesUpdated
| AcpThreadEvent::Refusal
| AcpThreadEvent::AvailableCommandsUpdated(_)
| AcpThreadEvent::ModeUpdated(_)
| AcpThreadEvent::ConfigOptionsUpdated(_)
| AcpThreadEvent::WorkingDirectoriesUpdated => {}
}
AcpThreadEvent::NewEntry
| AcpThreadEvent::TitleUpdated
| AcpThreadEvent::TokenUsageUpdated
| AcpThreadEvent::EntryUpdated(_)
| AcpThreadEvent::EntriesRemoved(_)
| AcpThreadEvent::Retry(_)
| AcpThreadEvent::SubagentSpawned(_)
| AcpThreadEvent::Stopped(_)
| AcpThreadEvent::Error
| AcpThreadEvent::LoadError(_)
| AcpThreadEvent::PromptCapabilitiesUpdated
| AcpThreadEvent::Refusal
| AcpThreadEvent::AvailableCommandsUpdated(_)
| AcpThreadEvent::ModeUpdated(_)
| AcpThreadEvent::ConfigOptionsUpdated(_)
| AcpThreadEvent::WorkingDirectoriesUpdated => {}
}
});
self.subscriptions.push(subscription);
self.threads
.insert(thread.read(cx).session_id().clone(), thread);
self.threads.insert(session_id, thread);
}
pub fn pending_tool_call<'a>(
@@ -293,25 +296,21 @@ impl Conversation {
) -> Option<(acp::SessionId, acp::ToolCallId, &'a PermissionOptions)> {
let thread = self.threads.get(session_id)?;
let is_subagent = thread.read(cx).parent_session_id().is_some();
let (thread, tool_id) = if is_subagent {
let (result_session_id, thread, tool_id) = if is_subagent {
let id = self.permission_requests.get(session_id)?.iter().next()?;
(thread, id)
(session_id.clone(), thread, id)
} else {
let (id, tool_calls) = self.permission_requests.first()?;
let thread = self.threads.get(id)?;
let id = tool_calls.iter().next()?;
(thread, id)
let tool_id = tool_calls.iter().next()?;
(id.clone(), thread, tool_id)
};
let (_, tool_call) = thread.read(cx).tool_call(tool_id)?;
let ToolCallStatus::WaitingForConfirmation { options, .. } = &tool_call.status else {
return None;
};
Some((
thread.read(cx).session_id().clone(),
tool_id.clone(),
options,
))
Some((result_session_id, tool_id.clone(), options))
}
pub fn subagents_awaiting_permission(&self, cx: &App) -> Vec<(acp::SessionId, usize)> {
@@ -334,10 +333,11 @@ impl Conversation {
kind: acp::PermissionOptionKind,
cx: &mut Context<Self>,
) -> Option<()> {
let (_, tool_call_id, options) = self.pending_tool_call(session_id, cx)?;
let (authorize_session_id, tool_call_id, options) =
self.pending_tool_call(session_id, cx)?;
let option = options.first_option_of_kind(kind)?;
self.authorize_tool_call(
session_id.clone(),
authorize_session_id,
tool_call_id,
SelectedPermissionOutcome::new(option.option_id.clone(), option.kind),
cx,
@@ -356,6 +356,7 @@ impl Conversation {
return;
};
let agent_telemetry_id = thread.read(cx).connection().telemetry_id();
let session_id = thread.read(cx).session_id().clone();
telemetry::event!(
"Agent Tool Call Authorized",
@@ -379,6 +380,34 @@ impl Conversation {
}
}
pub(crate) struct RootThreadUpdated;
impl EventEmitter<RootThreadUpdated> for ConversationView {}
fn affects_thread_metadata(event: &AcpThreadEvent) -> bool {
match event {
AcpThreadEvent::NewEntry
| AcpThreadEvent::TitleUpdated
| AcpThreadEvent::EntryUpdated(_)
| AcpThreadEvent::EntriesRemoved(_)
| AcpThreadEvent::ToolAuthorizationRequested(_)
| AcpThreadEvent::ToolAuthorizationReceived(_)
| AcpThreadEvent::Retry(_)
| AcpThreadEvent::Stopped(_)
| AcpThreadEvent::Error
| AcpThreadEvent::LoadError(_)
| AcpThreadEvent::Refusal
| AcpThreadEvent::WorkingDirectoriesUpdated => true,
// --
AcpThreadEvent::TokenUsageUpdated
| AcpThreadEvent::PromptCapabilitiesUpdated
| AcpThreadEvent::AvailableCommandsUpdated(_)
| AcpThreadEvent::ModeUpdated(_)
| AcpThreadEvent::ConfigOptionsUpdated(_)
| AcpThreadEvent::SubagentSpawned(_) => false,
}
}
pub enum AcpServerViewEvent {
ActiveThreadChanged,
}
@@ -394,12 +423,17 @@ pub struct ConversationView {
project: Entity<Project>,
thread_store: Option<Entity<ThreadStore>>,
prompt_store: Option<Entity<PromptStore>>,
pub(crate) thread_id: ThreadId,
root_session_id: Option<acp::SessionId>,
server_state: ServerState,
focus_handle: FocusHandle,
notifications: Vec<WindowHandle<AgentNotification>>,
notification_subscriptions: HashMap<WindowHandle<AgentNotification>, Vec<Subscription>>,
auth_task: Option<Task<()>>,
_subscriptions: Vec<Subscription>,
/// True when this conversation was created as a new draft (no resume
/// session). False when resuming an existing session from history.
is_new_draft: bool,
}
impl ConversationView {
@@ -409,6 +443,10 @@ impl ConversationView {
})
}
pub fn is_new_draft(&self) -> bool {
self.is_new_draft
}
pub fn active_thread(&self) -> Option<&Entity<ThreadView>> {
match &self.server_state {
ServerState::Connected(connected) => connected.active_view(),
@@ -420,23 +458,29 @@ impl ConversationView {
&'a self,
cx: &'a App,
) -> Option<(acp::SessionId, acp::ToolCallId, &'a PermissionOptions)> {
let id = &self.active_thread()?.read(cx).id;
let session_id = self
.active_thread()?
.read(cx)
.thread
.read(cx)
.session_id()
.clone();
self.as_connected()?
.conversation
.read(cx)
.pending_tool_call(id, cx)
.pending_tool_call(&session_id, cx)
}
pub fn root_thread_has_pending_tool_call(&self, cx: &App) -> bool {
let Some(root_thread) = self.root_thread(cx) else {
return false;
};
let root_id = root_thread.read(cx).id.clone();
let root_session_id = root_thread.read(cx).thread.read(cx).session_id().clone();
self.as_connected().is_some_and(|connected| {
connected
.conversation
.read(cx)
.pending_tool_call(&root_id, cx)
.pending_tool_call(&root_session_id, cx)
.is_some()
})
}
@@ -445,8 +489,10 @@ impl ConversationView {
match &self.server_state {
ServerState::Connected(connected) => {
let mut current = connected.active_view()?;
while let Some(parent_id) = current.read(cx).parent_id.clone() {
if let Some(parent) = connected.threads.get(&parent_id) {
while let Some(parent_session_id) =
current.read(cx).thread.read(cx).parent_session_id()
{
if let Some(parent) = connected.threads.get(parent_session_id) {
current = parent;
} else {
break;
@@ -458,7 +504,28 @@ impl ConversationView {
}
}
pub fn thread_view(&self, session_id: &acp::SessionId) -> Option<Entity<ThreadView>> {
pub(crate) fn root_acp_thread(&self, cx: &App) -> Option<Entity<AcpThread>> {
let connected = self.as_connected()?;
let root_session_id = self.root_session_id.as_ref()?;
connected
.conversation
.read(cx)
.threads
.get(root_session_id)
.cloned()
}
pub fn root_thread_view(&self, cx: &App) -> Option<Entity<ThreadView>> {
self.root_session_id
.as_ref()
.and_then(|sid| self.thread_view(sid, cx))
}
pub fn thread_view(
&self,
session_id: &acp::SessionId,
_cx: &App,
) -> Option<Entity<ThreadView>> {
let connected = self.as_connected()?;
connected.threads.get(session_id).cloned()
}
@@ -482,7 +549,7 @@ impl ConversationView {
.and_then(|connected| connected.conversation.read(cx).updated_at)
}
pub fn navigate_to_session(
pub fn navigate_to_thread(
&mut self,
session_id: acp::SessionId,
window: &mut Window,
@@ -492,7 +559,7 @@ impl ConversationView {
return;
};
connected.navigate_to_session(session_id);
connected.navigate_to_thread(session_id);
if let Some(view) = self.active_thread() {
view.focus_handle(cx).focus(window, cx);
}
@@ -510,11 +577,8 @@ impl ConversationView {
}
enum ServerState {
Loading(Entity<LoadingView>),
LoadError {
error: LoadError,
session_id: Option<acp::SessionId>,
},
Loading { _loading: Entity<LoadingView> },
LoadError { error: LoadError },
Connected(ConnectedServerState),
}
@@ -547,7 +611,6 @@ impl AuthState {
}
struct LoadingView {
session_id: Option<acp::SessionId>,
_load_task: Task<()>,
}
@@ -561,16 +624,17 @@ impl ConnectedServerState {
.map_or(false, |view| view.read(cx).thread_error.is_some())
}
pub fn navigate_to_session(&mut self, session_id: acp::SessionId) {
pub fn navigate_to_thread(&mut self, session_id: acp::SessionId) {
if self.threads.contains_key(&session_id) {
self.active_id = Some(session_id);
}
}
pub fn close_all_sessions(&self, cx: &mut App) -> Task<()> {
let tasks = self.threads.keys().filter_map(|id| {
let tasks = self.threads.values().filter_map(|view| {
if self.connection.supports_close_session() {
Some(self.connection.clone().close_session(id, cx))
let session_id = view.read(cx).thread.read(cx).session_id().clone();
Some(self.connection.clone().close_session(&session_id, cx))
} else {
None
}
@@ -588,6 +652,7 @@ impl ConversationView {
connection_store: Entity<AgentConnectionStore>,
connection_key: Agent,
resume_session_id: Option<acp::SessionId>,
thread_id: Option<ThreadId>,
work_dirs: Option<PathList>,
title: Option<SharedString>,
initial_content: Option<AgentInitialContent>,
@@ -623,6 +688,9 @@ impl ConversationView {
})
.detach();
let is_new_draft = resume_session_id.is_none();
let thread_id = thread_id.unwrap_or_else(ThreadId::new);
Self {
agent: agent.clone(),
connection_store: connection_store.clone(),
@@ -632,6 +700,8 @@ impl ConversationView {
project: project.clone(),
thread_store,
prompt_store,
thread_id,
root_session_id: None,
server_state: Self::initial_state(
agent.clone(),
connection_store,
@@ -649,6 +719,7 @@ impl ConversationView {
auth_task: None,
_subscriptions: subscriptions,
focus_handle: cx.focus_handle(),
is_new_draft,
}
}
@@ -666,7 +737,8 @@ impl ConversationView {
let (resume_session_id, cwd, title) = self
.active_thread()
.map(|thread_view| {
let thread = thread_view.read(cx).thread.read(cx);
let tv = thread_view.read(cx);
let thread = tv.thread.read(cx);
(
Some(thread.session_id().clone()),
thread.work_dirs().cloned(),
@@ -718,7 +790,6 @@ impl ConversationView {
error: LoadError::Other(
"External agents are not yet supported in shared projects.".into(),
),
session_id: resume_session_id.clone(),
};
}
let session_work_dirs = work_dirs.unwrap_or_else(|| project.read(cx).default_path_list(cx));
@@ -741,7 +812,6 @@ impl ConversationView {
let connect_result = connection_entry.read(cx).wait_for_connection();
let load_session_id = resume_session_id.clone();
let load_task = cx.spawn_in(window, async move |this, cx| {
let (connection, history) = match connect_result.await {
Ok(AgentConnectedState {
@@ -750,7 +820,7 @@ impl ConversationView {
}) => (connection, history),
Err(err) => {
this.update_in(cx, |this, window, cx| {
this.handle_load_error(load_session_id.clone(), err, window, cx);
this.handle_load_error(err, window, cx);
cx.notify();
})
.log_err();
@@ -761,7 +831,7 @@ impl ConversationView {
telemetry::event!("Agent Thread Started", agent = connection.telemetry_id());
let mut resumed_without_history = false;
let result = if let Some(session_id) = load_session_id.clone() {
let result = if let Some(session_id) = resume_session_id.clone() {
cx.update(|_, cx| {
if connection.supports_load_session() {
connection.clone().load_session(
@@ -824,6 +894,8 @@ impl ConversationView {
this.update_in(cx, |this, window, cx| {
match result {
Ok(thread) => {
let root_session_id = thread.read(cx).session_id().clone();
let conversation = cx.new(|cx| {
let mut conversation = Conversation::default();
conversation.register_thread(thread.clone(), cx);
@@ -831,7 +903,6 @@ impl ConversationView {
});
let current = this.new_thread_view(
None,
thread,
conversation.clone(),
resumed_without_history,
@@ -849,13 +920,13 @@ impl ConversationView {
.focus(window, cx);
}
let id = current.read(cx).thread.read(cx).session_id().clone();
this.root_session_id = Some(root_session_id.clone());
this.set_server_state(
ServerState::Connected(ConnectedServerState {
connection,
auth_state: AuthState::Ok,
active_id: Some(id.clone()),
threads: HashMap::from_iter([(id, current)]),
active_id: Some(root_session_id.clone()),
threads: HashMap::from_iter([(root_session_id, current)]),
conversation,
history,
_connection_entry_subscription: connection_entry_subscription,
@@ -865,7 +936,6 @@ impl ConversationView {
}
Err(err) => {
this.handle_load_error(
load_session_id.clone(),
LoadError::Other(err.to_string().into()),
window,
cx,
@@ -877,16 +947,16 @@ impl ConversationView {
});
let loading_view = cx.new(|_cx| LoadingView {
session_id: resume_session_id,
_load_task: load_task,
});
ServerState::Loading(loading_view)
ServerState::Loading {
_loading: loading_view,
}
}
fn new_thread_view(
&self,
parent_id: Option<acp::SessionId>,
thread: Entity<AcpThread>,
conversation: Entity<Conversation>,
resumed_without_history: bool,
@@ -990,7 +1060,6 @@ impl ConversationView {
cx.observe(&action_log, |_, _, cx| cx.notify()),
];
let parent_session_id = thread.read(cx).session_id().clone();
let subagent_sessions = thread
.read(cx)
.entries()
@@ -1005,6 +1074,7 @@ impl ConversationView {
.collect::<Vec<_>>();
if !subagent_sessions.is_empty() {
let parent_session_id = thread.read(cx).session_id().clone();
cx.spawn_in(window, async move |this, cx| {
this.update_in(cx, |this, window, cx| {
for subagent_id in subagent_sessions {
@@ -1058,7 +1128,6 @@ impl ConversationView {
let weak = cx.weak_entity();
cx.new(|cx| {
ThreadView::new(
parent_id,
thread,
conversation,
weak,
@@ -1170,13 +1239,7 @@ impl ConversationView {
.ok();
}
fn handle_load_error(
&mut self,
session_id: Option<acp::SessionId>,
err: LoadError,
window: &mut Window,
cx: &mut Context<Self>,
) {
fn handle_load_error(&mut self, err: LoadError, window: &mut Window, cx: &mut Context<Self>) {
if let Some(view) = self.active_thread() {
if view
.read(cx)
@@ -1188,13 +1251,7 @@ impl ConversationView {
}
}
self.emit_load_error_telemetry(&err);
self.set_server_state(
ServerState::LoadError {
error: err,
session_id,
},
cx,
);
self.set_server_state(ServerState::LoadError { error: err }, cx);
}
fn handle_agent_servers_updated(
@@ -1208,7 +1265,7 @@ impl ConversationView {
// when agent.connect() fails during loading), retry loading the thread.
// This handles the case where a thread is restored before authentication completes.
let should_retry = match &self.server_state {
ServerState::Loading(_) => false,
ServerState::Loading { .. } => false,
ServerState::LoadError { .. } => true,
ServerState::Connected(connected) => {
connected.auth_state.is_ok() && connected.has_thread_error(cx)
@@ -1239,7 +1296,7 @@ impl ConversationView {
.active_view()
.and_then(|v| v.read(cx).thread.read(cx).title())
.unwrap_or_else(|| DEFAULT_THREAD_TITLE.into()),
ServerState::Loading(_) => "Loading…".into(),
ServerState::Loading { .. } => "Loading…".into(),
ServerState::LoadError { error, .. } => match error {
LoadError::Unsupported { .. } => {
format!("Upgrade {}", self.agent.agent_id()).into()
@@ -1261,15 +1318,8 @@ impl ConversationView {
}
}
// The parent ID is None if we haven't created a thread yet
pub fn parent_id(&self, cx: &App) -> Option<acp::SessionId> {
match &self.server_state {
ServerState::Connected(_) => self
.root_thread(cx)
.map(|thread| thread.read(cx).id.clone()),
ServerState::Loading(loading) => loading.read(cx).session_id.clone(),
ServerState::LoadError { session_id, .. } => session_id.clone(),
}
pub fn parent_id(&self) -> ThreadId {
self.thread_id
}
pub fn is_loading(&self) -> bool {
@@ -1326,13 +1376,22 @@ impl ConversationView {
window: &mut Window,
cx: &mut Context<Self>,
) {
let thread_id = thread.read(cx).session_id().clone();
let session_id = thread.read(cx).session_id().clone();
let has_thread = self
.as_connected()
.is_some_and(|connected| connected.threads.contains_key(&session_id));
if !has_thread {
return;
};
let is_subagent = thread.read(cx).parent_session_id().is_some();
if !is_subagent && affects_thread_metadata(event) {
cx.emit(RootThreadUpdated);
}
match event {
AcpThreadEvent::NewEntry => {
let len = thread.read(cx).entries().len();
let index = len - 1;
if let Some(active) = self.thread_view(&thread_id) {
if let Some(active) = self.thread_view(&session_id, cx) {
let entry_view_state = active.read(cx).entry_view_state.clone();
let list_state = active.read(cx).list_state.clone();
entry_view_state.update(cx, |view_state, cx| {
@@ -1350,7 +1409,7 @@ impl ConversationView {
}
}
AcpThreadEvent::EntryUpdated(index) => {
if let Some(active) = self.thread_view(&thread_id) {
if let Some(active) = self.thread_view(&session_id, cx) {
let entry_view_state = active.read(cx).entry_view_state.clone();
let list_state = active.read(cx).list_state.clone();
entry_view_state.update(cx, |view_state, cx| {
@@ -1363,7 +1422,7 @@ impl ConversationView {
}
}
AcpThreadEvent::EntriesRemoved(range) => {
if let Some(active) = self.thread_view(&thread_id) {
if let Some(active) = self.thread_view(&session_id, cx) {
let entry_view_state = active.read(cx).entry_view_state.clone();
let list_state = active.read(cx).list_state.clone();
entry_view_state.update(cx, |view_state, _cx| view_state.remove(range.clone()));
@@ -1373,25 +1432,22 @@ impl ConversationView {
});
}
}
AcpThreadEvent::SubagentSpawned(session_id) => self.load_subagent_session(
session_id.clone(),
thread.read(cx).session_id().clone(),
window,
cx,
),
AcpThreadEvent::SubagentSpawned(subagent_session_id) => {
self.load_subagent_session(subagent_session_id.clone(), session_id, window, cx)
}
AcpThreadEvent::ToolAuthorizationRequested(_) => {
self.notify_with_sound("Waiting for tool confirmation", IconName::Info, window, cx);
}
AcpThreadEvent::ToolAuthorizationReceived(_) => {}
AcpThreadEvent::Retry(retry) => {
if let Some(active) = self.thread_view(&thread_id) {
if let Some(active) = self.thread_view(&session_id, cx) {
active.update(cx, |active, _cx| {
active.thread_retry_status = Some(retry.clone());
});
}
}
AcpThreadEvent::Stopped(stop_reason) => {
if let Some(active) = self.thread_view(&thread_id) {
if let Some(active) = self.thread_view(&session_id, cx) {
let is_generating =
matches!(thread.read(cx).status(), ThreadStatus::Generating);
active.update(cx, |active, cx| {
@@ -1455,7 +1511,7 @@ impl ConversationView {
}
AcpThreadEvent::Refusal => {
let error = ThreadError::Refusal;
if let Some(active) = self.thread_view(&thread_id) {
if let Some(active) = self.thread_view(&session_id, cx) {
active.update(cx, |active, cx| {
active.handle_thread_error(error, cx);
active.thread_retry_status.take();
@@ -1469,7 +1525,7 @@ impl ConversationView {
}
}
AcpThreadEvent::Error => {
if let Some(active) = self.thread_view(&thread_id) {
if let Some(active) = self.thread_view(&session_id, cx) {
let is_generating =
matches!(thread.read(cx).status(), ThreadStatus::Generating);
active.update(cx, |active, cx| {
@@ -1505,14 +1561,13 @@ impl ConversationView {
self.set_server_state(
ServerState::LoadError {
error: error.clone(),
session_id: Some(thread_id),
},
cx,
);
}
AcpThreadEvent::TitleUpdated => {
if let Some(title) = thread.read(cx).title()
&& let Some(active_thread) = self.thread_view(&thread_id)
&& let Some(active_thread) = self.thread_view(&session_id, cx)
{
let title_editor = active_thread.read(cx).title_editor.clone();
title_editor.update(cx, |editor, cx| {
@@ -1524,7 +1579,7 @@ impl ConversationView {
cx.notify();
}
AcpThreadEvent::PromptCapabilitiesUpdated => {
if let Some(active) = self.thread_view(&thread_id) {
if let Some(active) = self.thread_view(&session_id, cx) {
active.update(cx, |active, _cx| {
active
.session_capabilities
@@ -1538,7 +1593,7 @@ impl ConversationView {
self.emit_token_limit_telemetry_if_needed(thread, cx);
}
AcpThreadEvent::AvailableCommandsUpdated(available_commands) => {
if let Some(thread_view) = self.thread_view(&thread_id) {
if let Some(thread_view) = self.thread_view(&session_id, cx) {
let has_commands = !available_commands.is_empty();
let agent_display_name = self
@@ -1716,7 +1771,7 @@ impl ConversationView {
fn load_subagent_session(
&mut self,
subagent_id: acp::SessionId,
parent_id: acp::SessionId,
parent_session_id: acp::SessionId,
window: &mut Window,
cx: &mut Context<Self>,
) {
@@ -1728,7 +1783,7 @@ impl ConversationView {
{
return;
}
let Some(parent_thread) = connected.threads.get(&parent_id) else {
let Some(parent_thread) = connected.threads.get(&parent_session_id) else {
return;
};
let work_dirs = parent_thread
@@ -1740,7 +1795,7 @@ impl ConversationView {
.unwrap_or_else(|| self.project.read(cx).default_path_list(cx));
let subagent_thread_task = connected.connection.clone().load_session(
subagent_id.clone(),
subagent_id,
self.project.clone(),
work_dirs,
None,
@@ -1756,11 +1811,11 @@ impl ConversationView {
else {
return;
};
let subagent_session_id = subagent_thread.read(cx).session_id().clone();
conversation.update(cx, |conversation, cx| {
conversation.register_thread(subagent_thread.clone(), cx);
});
let view = this.new_thread_view(
Some(parent_id),
subagent_thread,
conversation,
false,
@@ -1772,7 +1827,7 @@ impl ConversationView {
let Some(connected) = this.as_connected_mut() else {
return;
};
connected.threads.insert(subagent_id, view);
connected.threads.insert(subagent_session_id, view);
})
})
.detach();
@@ -3095,6 +3150,7 @@ pub(crate) mod tests {
None,
None,
None,
None,
workspace.downgrade(),
project,
Some(thread_store),
@@ -3230,6 +3286,7 @@ pub(crate) mod tests {
None,
None,
None,
None,
workspace.downgrade(),
project,
Some(thread_store),
@@ -3307,6 +3364,7 @@ pub(crate) mod tests {
connection_store,
Agent::Custom { id: "Test".into() },
Some(SessionId::new("session-1")),
None,
Some(PathList::new(&[PathBuf::from("/project/subdir")])),
None,
None,
@@ -3382,7 +3440,7 @@ pub(crate) mod tests {
other => panic!(
"Expected LoadError::Other, got: {}",
match other {
ServerState::Loading(_) => "Loading (stuck!)",
ServerState::Loading { .. } => "Loading (stuck!)",
ServerState::LoadError { .. } => "LoadError (wrong variant)",
ServerState::Connected(_) => "Connected",
}
@@ -3631,6 +3689,7 @@ pub(crate) mod tests {
None,
None,
None,
None,
workspace.downgrade(),
project.clone(),
Some(thread_store),
@@ -3742,6 +3801,7 @@ pub(crate) mod tests {
None,
None,
None,
None,
workspace1.downgrade(),
project1.clone(),
Some(thread_store),
@@ -3987,6 +4047,7 @@ pub(crate) mod tests {
None,
None,
None,
None,
initial_content,
workspace.downgrade(),
project,
@@ -4757,6 +4818,7 @@ pub(crate) mod tests {
None,
None,
None,
None,
workspace.downgrade(),
project.clone(),
Some(thread_store.clone()),
@@ -6769,6 +6831,7 @@ pub(crate) mod tests {
let project = Project::test(fs, [], cx).await;
let connection: Rc<dyn AgentConnection> = Rc::new(StubAgentConnection::new());
let session_id = acp::SessionId::new("session-1");
let (thread, conversation) = cx.update(|cx| {
let thread =
create_test_acp_thread(None, "session-1", connection.clone(), project.clone(), cx);
@@ -6784,7 +6847,6 @@ pub(crate) mod tests {
let _task2 = request_test_tool_authorization(&thread, "tc-2", "allow-2", cx);
cx.read(|cx| {
let session_id = acp::SessionId::new("session-1");
let (_, tool_call_id, _) = conversation
.read(cx)
.pending_tool_call(&session_id, cx)
@@ -6795,7 +6857,7 @@ pub(crate) mod tests {
cx.update(|cx| {
conversation.update(cx, |conversation, cx| {
conversation.authorize_tool_call(
acp::SessionId::new("session-1"),
session_id.clone(),
acp::ToolCallId::new("tc-1"),
SelectedPermissionOutcome::new(
acp::PermissionOptionId::new("allow-1"),
@@ -6809,7 +6871,6 @@ pub(crate) mod tests {
cx.run_until_parked();
cx.read(|cx| {
let session_id = acp::SessionId::new("session-1");
let (_, tool_call_id, _) = conversation
.read(cx)
.pending_tool_call(&session_id, cx)
@@ -6820,7 +6881,7 @@ pub(crate) mod tests {
cx.update(|cx| {
conversation.update(cx, |conversation, cx| {
conversation.authorize_tool_call(
acp::SessionId::new("session-1"),
session_id.clone(),
acp::ToolCallId::new("tc-2"),
SelectedPermissionOutcome::new(
acp::PermissionOptionId::new("allow-2"),
@@ -6834,7 +6895,6 @@ pub(crate) mod tests {
cx.run_until_parked();
cx.read(|cx| {
let session_id = acp::SessionId::new("session-1");
assert!(
conversation
.read(cx)
@@ -6853,6 +6913,8 @@ pub(crate) mod tests {
let project = Project::test(fs, [], cx).await;
let connection: Rc<dyn AgentConnection> = Rc::new(StubAgentConnection::new());
let parent_session_id = acp::SessionId::new("parent");
let subagent_session_id = acp::SessionId::new("subagent");
let (parent_thread, subagent_thread, conversation) = cx.update(|cx| {
let parent_thread =
create_test_acp_thread(None, "parent", connection.clone(), project.clone(), cx);
@@ -6880,24 +6942,22 @@ pub(crate) mod tests {
// Querying with the subagent's session ID returns only the
// subagent's own tool call (subagent path is scoped to its session)
cx.read(|cx| {
let subagent_id = acp::SessionId::new("subagent");
let (session_id, tool_call_id, _) = conversation
let (returned_session_id, tool_call_id, _) = conversation
.read(cx)
.pending_tool_call(&subagent_id, cx)
.pending_tool_call(&subagent_session_id, cx)
.expect("Expected subagent's pending tool call");
assert_eq!(session_id, acp::SessionId::new("subagent"));
assert_eq!(returned_session_id, subagent_session_id);
assert_eq!(tool_call_id, acp::ToolCallId::new("subagent-tc"));
});
// Querying with the parent's session ID returns the first pending
// request in FIFO order across all sessions
cx.read(|cx| {
let parent_id = acp::SessionId::new("parent");
let (session_id, tool_call_id, _) = conversation
let (returned_session_id, tool_call_id, _) = conversation
.read(cx)
.pending_tool_call(&parent_id, cx)
.pending_tool_call(&parent_session_id, cx)
.expect("Expected a pending tool call from parent query");
assert_eq!(session_id, acp::SessionId::new("parent"));
assert_eq!(returned_session_id, parent_session_id);
assert_eq!(tool_call_id, acp::ToolCallId::new("parent-tc"));
});
}
@@ -6912,6 +6972,8 @@ pub(crate) mod tests {
let project = Project::test(fs, [], cx).await;
let connection: Rc<dyn AgentConnection> = Rc::new(StubAgentConnection::new());
let session_id_a = acp::SessionId::new("thread-a");
let session_id_b = acp::SessionId::new("thread-b");
let (thread_a, thread_b, conversation) = cx.update(|cx| {
let thread_a =
create_test_acp_thread(None, "thread-a", connection.clone(), project.clone(), cx);
@@ -6932,26 +6994,23 @@ pub(crate) mod tests {
// Both threads are non-subagent, so pending_tool_call always returns
// the first entry from permission_requests (FIFO across all sessions)
cx.read(|cx| {
let session_a = acp::SessionId::new("thread-a");
let (session_id, tool_call_id, _) = conversation
let (returned_session_id, tool_call_id, _) = conversation
.read(cx)
.pending_tool_call(&session_a, cx)
.pending_tool_call(&session_id_a, cx)
.expect("Expected a pending tool call");
assert_eq!(session_id, acp::SessionId::new("thread-a"));
assert_eq!(returned_session_id, session_id_a);
assert_eq!(tool_call_id, acp::ToolCallId::new("tc-a"));
});
// Querying with thread-b also returns thread-a's tool call,
// because non-subagent queries always use permission_requests.first()
cx.read(|cx| {
let session_b = acp::SessionId::new("thread-b");
let (session_id, tool_call_id, _) = conversation
let (returned_session_id, tool_call_id, _) = conversation
.read(cx)
.pending_tool_call(&session_b, cx)
.pending_tool_call(&session_id_b, cx)
.expect("Expected a pending tool call from thread-b query");
assert_eq!(
session_id,
acp::SessionId::new("thread-a"),
returned_session_id, session_id_a,
"Non-subagent queries always return the first pending request in FIFO order"
);
assert_eq!(tool_call_id, acp::ToolCallId::new("tc-a"));
@@ -6961,7 +7020,7 @@ pub(crate) mod tests {
cx.update(|cx| {
conversation.update(cx, |conversation, cx| {
conversation.authorize_tool_call(
acp::SessionId::new("thread-a"),
session_id_a.clone(),
acp::ToolCallId::new("tc-a"),
SelectedPermissionOutcome::new(
acp::PermissionOptionId::new("allow-a"),
@@ -6975,12 +7034,11 @@ pub(crate) mod tests {
cx.run_until_parked();
cx.read(|cx| {
let session_b = acp::SessionId::new("thread-b");
let (session_id, tool_call_id, _) = conversation
let (returned_session_id, tool_call_id, _) = conversation
.read(cx)
.pending_tool_call(&session_b, cx)
.pending_tool_call(&session_id_b, cx)
.expect("Expected thread-b's tool call after thread-a's was authorized");
assert_eq!(session_id, acp::SessionId::new("thread-b"));
assert_eq!(returned_session_id, session_id_b);
assert_eq!(tool_call_id, acp::ToolCallId::new("tc-b"));
});
}
@@ -7092,6 +7150,7 @@ pub(crate) mod tests {
None,
None,
None,
None,
workspace.downgrade(),
project,
Some(thread_store),
@@ -7183,12 +7242,12 @@ pub(crate) mod tests {
!connected.connection.supports_close_session(),
"StubAgentConnection should not support close"
);
let session_id = connected
let thread_view = connected
.threads
.keys()
.values()
.next()
.expect("Should have at least one thread")
.clone();
.expect("Should have at least one thread");
let session_id = thread_view.read(cx).thread.read(cx).session_id().clone();
connected.connection.clone().close_session(&session_id, cx)
})
.await;
@@ -10,6 +10,7 @@ use editor::actions::OpenExcerpts;
use crate::StartThreadIn;
use crate::message_editor::SharedSessionCapabilities;
use gpui::{Corner, List};
use heapless::Vec as ArrayVec;
use language_model::{LanguageModelEffortLevel, Speed};
@@ -262,8 +263,8 @@ impl PermissionSelection {
}
pub struct ThreadView {
pub id: acp::SessionId,
pub parent_id: Option<acp::SessionId>,
pub session_id: acp::SessionId,
pub parent_session_id: Option<acp::SessionId>,
pub thread: Entity<AcpThread>,
pub(crate) conversation: Entity<super::Conversation>,
pub server_view: WeakEntity<ConversationView>,
@@ -294,7 +295,7 @@ pub struct ThreadView {
pub expanded_thinking_blocks: HashSet<(usize, usize)>,
auto_expanded_thinking_block: Option<(usize, usize)>,
user_toggled_thinking_blocks: HashSet<(usize, usize)>,
pub subagent_scroll_handles: RefCell<HashMap<agent_client_protocol::SessionId, ScrollHandle>>,
pub subagent_scroll_handles: RefCell<HashMap<acp::SessionId, ScrollHandle>>,
pub edits_expanded: bool,
pub plan_expanded: bool,
pub queue_expanded: bool,
@@ -337,7 +338,7 @@ pub struct ThreadView {
}
impl Focusable for ThreadView {
fn focus_handle(&self, cx: &App) -> FocusHandle {
if self.parent_id.is_some() {
if self.parent_session_id.is_some() {
self.focus_handle.clone()
} else {
self.active_editor(cx).focus_handle(cx)
@@ -357,7 +358,6 @@ pub struct TurnFields {
impl ThreadView {
pub(crate) fn new(
parent_id: Option<acp::SessionId>,
thread: Entity<AcpThread>,
conversation: Entity<super::Conversation>,
server_view: WeakEntity<ConversationView>,
@@ -383,7 +383,8 @@ impl ThreadView {
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let id = thread.read(cx).session_id().clone();
let session_id = thread.read(cx).session_id().clone();
let parent_session_id = thread.read(cx).parent_session_id().cloned();
let has_commands = !session_capabilities.read().available_commands().is_empty();
let placeholder = placeholder_text(agent_display_name.as_ref(), has_commands);
@@ -507,8 +508,8 @@ impl ThreadView {
.unwrap_or_default();
let mut this = Self {
id,
parent_id,
session_id,
parent_session_id,
focus_handle: cx.focus_handle(),
thread,
conversation,
@@ -644,6 +645,10 @@ impl ThreadView {
}
}
pub fn is_draft(&self, cx: &App) -> bool {
self.thread.read(cx).entries().is_empty()
}
pub(crate) fn as_native_connection(
&self,
cx: &App,
@@ -692,7 +697,7 @@ impl ThreadView {
}
fn is_subagent(&self) -> bool {
self.parent_id.is_some()
self.parent_session_id.is_some()
}
/// Returns the currently active editor, either for a message that is being
@@ -1739,8 +1744,9 @@ impl ThreadView {
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<()> {
let session_id = self.thread.read(cx).session_id().clone();
self.conversation.update(cx, |conversation, cx| {
conversation.authorize_pending_tool_call(&self.id, kind, cx)
conversation.authorize_pending_tool_call(&session_id, kind, cx)
})?;
if self.should_be_following {
self.workspace
@@ -1780,8 +1786,9 @@ impl ThreadView {
_ => acp::PermissionOptionKind::AllowOnce,
};
let session_id = self.thread.read(cx).session_id().clone();
self.authorize_tool_call(
self.id.clone(),
session_id,
tool_call_id,
SelectedPermissionOutcome::new(option_id, option_kind),
window,
@@ -1859,10 +1866,20 @@ impl ThreadView {
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<()> {
let (session_id, tool_call_id, options) =
self.conversation.read(cx).pending_tool_call(&self.id, cx)?;
let session_id = self.thread.read(cx).session_id().clone();
let (returned_session_id, tool_call_id, options) = self
.conversation
.read(cx)
.pending_tool_call(&session_id, cx)?;
let options = options.clone();
self.authorize_with_granularity(session_id, tool_call_id, &options, is_allow, window, cx)
self.authorize_with_granularity(
returned_session_id,
tool_call_id,
&options,
is_allow,
window,
cx,
)
}
fn authorize_with_granularity(
@@ -2548,6 +2565,35 @@ impl ThreadView {
)
}
fn collect_subagent_items_for_sessions(
entries: &[AgentThreadEntry],
awaiting_session_ids: &[acp::SessionId],
cx: &App,
) -> Vec<(SharedString, usize)> {
let tool_calls_by_session: HashMap<_, _> = entries
.iter()
.enumerate()
.filter_map(|(entry_ix, entry)| {
let AgentThreadEntry::ToolCall(tool_call) = entry else {
return None;
};
let info = tool_call.subagent_session_info.as_ref()?;
let summary_text = tool_call.label.read(cx).source().to_string();
let subagent_summary = if summary_text.is_empty() {
SharedString::from("Subagent")
} else {
SharedString::from(summary_text)
};
Some((info.session_id.clone(), (subagent_summary, entry_ix)))
})
.collect();
awaiting_session_ids
.iter()
.filter_map(|session_id| tool_calls_by_session.get(session_id).cloned())
.collect()
}
fn render_subagents_awaiting_permission(&self, cx: &Context<Self>) -> Option<AnyElement> {
let awaiting = self.conversation.read(cx).subagents_awaiting_permission(cx);
@@ -2555,30 +2601,15 @@ impl ThreadView {
return None;
}
let awaiting_session_ids: Vec<_> = awaiting
.iter()
.map(|(session_id, _)| session_id.clone())
.collect();
let thread = self.thread.read(cx);
let entries = thread.entries();
let mut subagent_items: Vec<(SharedString, usize)> = Vec::new();
for (session_id, _) in &awaiting {
for (entry_ix, entry) in entries.iter().enumerate() {
if let AgentThreadEntry::ToolCall(tool_call) = entry {
if let Some(info) = &tool_call.subagent_session_info {
if &info.session_id == session_id {
let subagent_summary: SharedString = {
let summary_text = tool_call.label.read(cx).source().to_string();
if !summary_text.is_empty() {
summary_text.into()
} else {
"Subagent".into()
}
};
subagent_items.push((subagent_summary, entry_ix));
break;
}
}
}
}
}
let subagent_items =
Self::collect_subagent_items_for_sessions(entries, &awaiting_session_ids, cx);
if subagent_items.is_empty() {
return None;
@@ -3092,7 +3123,7 @@ impl ThreadView {
}
fn is_subagent_canceled_or_failed(&self, cx: &App) -> bool {
let Some(parent_session_id) = self.parent_id.as_ref() else {
let Some(parent_session_id) = self.parent_session_id.as_ref() else {
return false;
};
@@ -3100,7 +3131,7 @@ impl ThreadView {
self.server_view
.upgrade()
.and_then(|sv| sv.read(cx).thread_view(parent_session_id))
.and_then(|sv| sv.read(cx).thread_view(parent_session_id, cx))
.is_some_and(|parent_view| {
parent_view
.read(cx)
@@ -3119,9 +3150,10 @@ impl ThreadView {
}
pub(crate) fn render_subagent_titlebar(&mut self, cx: &mut Context<Self>) -> Option<Div> {
let Some(parent_session_id) = self.parent_id.clone() else {
if self.parent_session_id.is_none() {
return None;
};
}
let parent_session_id = self.thread.read(cx).parent_session_id()?.clone();
let server_view = self.server_view.clone();
let thread = self.thread.clone();
@@ -3189,7 +3221,7 @@ impl ThreadView {
.tooltip(Tooltip::text("Minimize Subagent"))
.on_click(move |_, window, cx| {
let _ = server_view.update(cx, |server_view, cx| {
server_view.navigate_to_session(
server_view.navigate_to_thread(
parent_session_id.clone(),
window,
cx,
@@ -4690,7 +4722,7 @@ impl ThreadView {
}
AgentThreadEntry::ToolCall(tool_call) => self
.render_any_tool_call(
&self.id,
self.thread.read(cx).session_id(),
entry_ix,
tool_call,
&self.focus_handle(cx),
@@ -6124,7 +6156,7 @@ impl ThreadView {
.when_some(confirmation_options, |this, options| {
let is_first = self.is_first_tool_call(active_session_id, &tool_call.id, cx);
this.child(self.render_permission_buttons(
self.id.clone(),
self.thread.read(cx).session_id().clone(),
is_first,
options,
entry_ix,
@@ -6146,7 +6178,8 @@ impl ThreadView {
.read(cx)
.pending_tool_call(active_session_id, cx)
.map_or(false, |(pending_session_id, pending_tool_call_id, _)| {
self.id == pending_session_id && tool_call_id == &pending_tool_call_id
self.thread.read(cx).session_id() == &pending_session_id
&& tool_call_id == &pending_tool_call_id
})
}
@@ -6358,7 +6391,7 @@ impl ThreadView {
)
})
.child(self.render_permission_buttons(
self.id.clone(),
self.thread.read(cx).session_id().clone(),
self.is_first_tool_call(active_session_id, &tool_call.id, cx),
options,
entry_ix,
@@ -7117,10 +7150,10 @@ impl ThreadView {
})
.label_size(LabelSize::Small)
.on_click(cx.listener({
let session_id = session_id.clone();
let tool_call_id = tool_call_id.clone();
let option_id = option.option_id.clone();
let option_kind = option.kind;
let session_id = session_id.clone();
move |this, _, window, cx| {
this.authorize_tool_call(
session_id.clone(),
@@ -7673,11 +7706,11 @@ impl ThreadView {
window: &Window,
cx: &Context<Self>,
) -> Div {
let subagent_thread_view = subagent_session_id.and_then(|id| {
let subagent_thread_view = subagent_session_id.and_then(|session_id| {
self.server_view
.upgrade()
.and_then(|server_view| server_view.read(cx).as_connected())
.and_then(|connected| connected.threads.get(&id))
.and_then(|connected| connected.threads.get(&session_id))
});
let content = self.render_subagent_card(
@@ -7714,12 +7747,11 @@ impl ThreadView {
.map(|log| log.read(cx).changed_buffers(cx))
.unwrap_or_default();
let is_pending_tool_call = thread
let is_pending_tool_call = thread_view
.as_ref()
.and_then(|thread| {
self.conversation
.read(cx)
.pending_tool_call(thread.read(cx).session_id(), cx)
.and_then(|tv| {
let sid = tv.read(cx).thread.read(cx).session_id();
self.conversation.read(cx).pending_tool_call(sid, cx)
})
.is_some();
@@ -7945,12 +7977,13 @@ impl ThreadView {
)
.when_some(thread_view, |this, thread_view| {
let thread = &thread_view.read(cx).thread;
let tv_session_id = thread.read(cx).session_id();
let pending_tool_call = self
.conversation
.read(cx)
.pending_tool_call(thread.read(cx).session_id(), cx);
.pending_tool_call(tv_session_id, cx);
let session_id = thread.read(cx).session_id().clone();
let nav_session_id = tv_session_id.clone();
let fullscreen_toggle = h_flex()
.id(entry_ix)
@@ -7972,7 +8005,7 @@ impl ThreadView {
telemetry::event!("Subagent Maximized");
this.server_view
.update(cx, |this, cx| {
this.navigate_to_session(session_id.clone(), window, cx);
this.navigate_to_thread(nav_session_id.clone(), window, cx);
})
.ok();
}));
@@ -8069,7 +8102,7 @@ impl ThreadView {
let scroll_handle = self
.subagent_scroll_handles
.borrow_mut()
.entry(session_id.clone())
.entry(subagent_view.session_id.clone())
.or_default()
.clone();
@@ -8869,15 +8902,15 @@ impl Render for ThreadView {
.key_context("AcpThread")
.track_focus(&self.focus_handle)
.on_action(cx.listener(|this, _: &menu::Cancel, _, cx| {
if this.parent_id.is_none() {
if this.parent_session_id.is_none() {
this.cancel_generation(cx);
}
}))
.on_action(cx.listener(|this, _: &workspace::GoBack, window, cx| {
if let Some(parent_session_id) = this.parent_id.clone() {
if let Some(parent_session_id) = this.thread.read(cx).parent_session_id().cloned() {
this.server_view
.update(cx, |view, cx| {
view.navigate_to_session(parent_session_id, window, cx);
view.navigate_to_thread(parent_session_id, window, cx);
})
.ok();
}
+10
View File
@@ -73,6 +73,9 @@ pub fn init_test(cx: &mut TestAppContext) {
cx.update(|cx| {
let settings_store = SettingsStore::test(cx);
cx.set_global(settings_store);
cx.set_global(acp_thread::StubSessionCounter(
std::sync::atomic::AtomicUsize::new(0),
));
theme_settings::init(theme::LoadThemes::JustBase, cx);
editor::init(cx);
release_channel::init("0.0.0".parse().unwrap(), cx);
@@ -128,3 +131,10 @@ pub fn active_session_id(panel: &Entity<AgentPanel>, cx: &VisualTestContext) ->
thread.read(cx).session_id().clone()
})
}
pub fn active_thread_id(
panel: &Entity<AgentPanel>,
cx: &VisualTestContext,
) -> crate::thread_metadata_store::ThreadId {
panel.read_with(cx, |panel, cx| panel.active_thread_id(cx).unwrap())
}
+1 -1
View File
@@ -20,7 +20,7 @@ pub(crate) fn thread_title(entry: &AgentSessionInfo) -> SharedString {
entry
.title
.clone()
.filter(|title| !title.is_empty())
.and_then(|title| if title.is_empty() { None } else { Some(title) })
.unwrap_or_else(|| DEFAULT_THREAD_TITLE.into())
}
+21 -15
View File
@@ -23,7 +23,7 @@ use workspace::{ModalView, MultiWorkspace, Workspace};
use crate::{
Agent, AgentPanel,
agent_connection_store::AgentConnectionStore,
thread_metadata_store::{ThreadMetadata, ThreadMetadataStore, ThreadWorktreePaths},
thread_metadata_store::{ThreadId, ThreadMetadata, ThreadMetadataStore, WorktreePaths},
};
pub struct AcpThreadImportOnboarding;
@@ -206,10 +206,11 @@ impl ThreadImportModal {
.filter(|agent_id| !self.unchecked_agents.contains(agent_id))
.collect::<Vec<_>>();
let existing_sessions = ThreadMetadataStore::global(cx)
let existing_sessions: HashSet<acp::SessionId> = ThreadMetadataStore::global(cx)
.read(cx)
.entry_ids()
.collect::<HashSet<_>>();
.entries()
.filter_map(|m| m.session_id.clone())
.collect();
let task = find_threads_to_import(agent_ids, existing_sessions, stores, cx);
cx.spawn(async move |this, cx| {
@@ -520,14 +521,13 @@ fn collect_importable_threads(
continue;
};
to_insert.push(ThreadMetadata {
session_id: session.session_id,
thread_id: ThreadId::new(),
session_id: Some(session.session_id),
agent_id: agent_id.clone(),
title: session
.title
.unwrap_or_else(|| crate::DEFAULT_THREAD_TITLE.into()),
title: session.title,
updated_at: session.updated_at.unwrap_or_else(|| Utc::now()),
created_at: session.created_at,
worktree_paths: ThreadWorktreePaths::from_folder_paths(&folder_paths),
worktree_paths: WorktreePaths::from_folder_paths(&folder_paths),
remote_connection: remote_connection.clone(),
archived: true,
});
@@ -584,8 +584,8 @@ mod tests {
let result = collect_importable_threads(sessions_by_agent, existing);
assert_eq!(result.len(), 1);
assert_eq!(result[0].session_id.0.as_ref(), "new-1");
assert_eq!(result[0].title.as_ref(), "Brand New");
assert_eq!(result[0].session_id.as_ref().unwrap().0.as_ref(), "new-1");
assert_eq!(result[0].display_title(), "Brand New");
}
#[test]
@@ -605,7 +605,10 @@ mod tests {
let result = collect_importable_threads(sessions_by_agent, existing);
assert_eq!(result.len(), 1);
assert_eq!(result[0].session_id.0.as_ref(), "has-dirs");
assert_eq!(
result[0].session_id.as_ref().unwrap().0.as_ref(),
"has-dirs"
);
}
#[test]
@@ -657,11 +660,11 @@ mod tests {
assert_eq!(result.len(), 2);
let s1 = result
.iter()
.find(|t| t.session_id.0.as_ref() == "s1")
.find(|t| t.session_id.as_ref().map(|s| s.0.as_ref()) == Some("s1"))
.unwrap();
let s2 = result
.iter()
.find(|t| t.session_id.0.as_ref() == "s2")
.find(|t| t.session_id.as_ref().map(|s| s.0.as_ref()) == Some("s2"))
.unwrap();
assert_eq!(s1.agent_id.as_ref(), "agent-a");
assert_eq!(s2.agent_id.as_ref(), "agent-b");
@@ -700,7 +703,10 @@ mod tests {
let result = collect_importable_threads(sessions_by_agent, existing);
assert_eq!(result.len(), 1);
assert_eq!(result[0].session_id.0.as_ref(), "shared-session");
assert_eq!(
result[0].session_id.as_ref().unwrap().0.as_ref(),
"shared-session"
);
assert_eq!(
result[0].agent_id.as_ref(),
"agent-a",
File diff suppressed because it is too large. Load diff
+12 -23
View File
@@ -3,7 +3,6 @@ use std::{
sync::Arc,
};
use agent_client_protocol as acp;
use anyhow::{Context as _, Result, anyhow};
use gpui::{App, AsyncApp, Entity, Task};
use project::{
@@ -13,7 +12,7 @@ use project::{
use util::ResultExt;
use workspace::{AppState, MultiWorkspace, Workspace};
use crate::thread_metadata_store::{ArchivedGitWorktree, ThreadMetadataStore};
use crate::thread_metadata_store::{ArchivedGitWorktree, ThreadId, ThreadMetadataStore};
/// The plan for archiving a single git worktree root.
///
@@ -170,14 +169,14 @@ pub fn build_root_plan(
/// references `path` in its folder paths. Used to determine whether a
/// worktree can safely be removed from disk.
pub fn path_is_referenced_by_other_unarchived_threads(
current_session_id: &acp::SessionId,
current_thread_id: ThreadId,
path: &Path,
cx: &App,
) -> bool {
ThreadMetadataStore::global(cx)
.read(cx)
.entries()
.filter(|thread| thread.session_id != *current_session_id)
.filter(|thread| thread.thread_id != current_thread_id)
.filter(|thread| !thread.archived)
.any(|thread| {
thread
@@ -412,7 +411,7 @@ pub async fn persist_worktree_state(root: &RootPlan, cx: &mut AsyncApp) -> Resul
};
// Link all threads on this worktree to the archived record
let session_ids: Vec<acp::SessionId> = store.read_with(cx, |store, _cx| {
let thread_ids: Vec<ThreadId> = store.read_with(cx, |store, _cx| {
store
.entries()
.filter(|thread| {
@@ -422,18 +421,14 @@ pub async fn persist_worktree_state(root: &RootPlan, cx: &mut AsyncApp) -> Resul
.iter()
.any(|p| p.as_path() == root.root_path)
})
.map(|thread| thread.session_id.clone())
.map(|thread| thread.thread_id)
.collect()
});
for session_id in &session_ids {
for thread_id in &thread_ids {
let link_result = store
.read_with(cx, |store, cx| {
store.link_thread_to_archived_worktree(
session_id.0.to_string(),
archived_worktree_id,
cx,
)
store.link_thread_to_archived_worktree(*thread_id, archived_worktree_id, cx)
})
.await;
if let Err(error) = link_result {
@@ -636,21 +631,18 @@ pub async fn cleanup_archived_worktree_record(row: &ArchivedGitWorktree, cx: &mu
/// This unlinks the thread from all its archived worktrees and, for any
/// archived worktree that is no longer referenced by any other thread,
/// deletes the git ref and DB records.
pub async fn cleanup_thread_archived_worktrees(session_id: &acp::SessionId, cx: &mut AsyncApp) {
pub async fn cleanup_thread_archived_worktrees(thread_id: ThreadId, cx: &mut AsyncApp) {
let store = cx.update(|cx| ThreadMetadataStore::global(cx));
let archived_worktrees = store
.read_with(cx, |store, cx| {
store.get_archived_worktrees_for_thread(session_id.0.to_string(), cx)
store.get_archived_worktrees_for_thread(thread_id, cx)
})
.await;
let archived_worktrees = match archived_worktrees {
Ok(rows) => rows,
Err(error) => {
log::error!(
"Failed to fetch archived worktrees for thread {}: {error:#}",
session_id.0
);
log::error!("Failed to fetch archived worktrees for thread {thread_id:?}: {error:#}");
return;
}
};
@@ -661,14 +653,11 @@ pub async fn cleanup_thread_archived_worktrees(session_id: &acp::SessionId, cx:
if let Err(error) = store
.read_with(cx, |store, cx| {
store.unlink_thread_from_all_archived_worktrees(session_id.0.to_string(), cx)
store.unlink_thread_from_all_archived_worktrees(thread_id, cx)
})
.await
{
log::error!(
"Failed to unlink thread {} from archived worktrees: {error:#}",
session_id.0
);
log::error!("Failed to unlink thread {thread_id:?} from archived worktrees: {error:#}");
return;
}
+57 -28
View File
@@ -3,8 +3,8 @@ use std::sync::Arc;
use crate::agent_connection_store::AgentConnectionStore;
use crate::thread_metadata_store::{ThreadMetadata, ThreadMetadataStore};
use crate::{Agent, RemoveSelectedThread};
use crate::thread_metadata_store::{ThreadId, ThreadMetadata, ThreadMetadataStore};
use crate::{Agent, DEFAULT_THREAD_TITLE, RemoveSelectedThread};
use agent::ThreadStore;
use agent_client_protocol as acp;
@@ -113,7 +113,7 @@ fn fuzzy_match_positions(query: &str, text: &str) -> Option<Vec<usize>> {
pub enum ThreadsArchiveViewEvent {
Close,
Unarchive { thread: ThreadMetadata },
CancelRestore { session_id: acp::SessionId },
CancelRestore { thread_id: ThreadId },
}
impl EventEmitter<ThreadsArchiveViewEvent> for ThreadsArchiveView {}
@@ -132,7 +132,7 @@ pub struct ThreadsArchiveView {
workspace: WeakEntity<Workspace>,
agent_connection_store: WeakEntity<AgentConnectionStore>,
agent_server_store: WeakEntity<AgentServerStore>,
restoring: HashSet<acp::SessionId>,
restoring: HashSet<ThreadId>,
}
impl ThreadsArchiveView {
@@ -216,13 +216,13 @@ impl ThreadsArchiveView {
self.selection = None;
}
pub fn mark_restoring(&mut self, session_id: &acp::SessionId, cx: &mut Context<Self>) {
self.restoring.insert(session_id.clone());
pub fn mark_restoring(&mut self, thread_id: &ThreadId, cx: &mut Context<Self>) {
self.restoring.insert(*thread_id);
cx.notify();
}
pub fn clear_restoring(&mut self, session_id: &acp::SessionId, cx: &mut Context<Self>) {
self.restoring.remove(session_id);
pub fn clear_restoring(&mut self, thread_id: &ThreadId, cx: &mut Context<Self>) {
self.restoring.remove(thread_id);
cx.notify();
}
@@ -255,7 +255,14 @@ impl ThreadsArchiveView {
for session in sessions {
let highlight_positions = if !query.is_empty() {
match fuzzy_match_positions(&query, &session.title) {
match fuzzy_match_positions(
&query,
session
.title
.as_ref()
.map(|t| t.as_ref())
.unwrap_or(DEFAULT_THREAD_TITLE),
) {
Some(positions) => positions,
None => continue,
}
@@ -336,7 +343,7 @@ impl ThreadsArchiveView {
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.restoring.contains(&thread.session_id) {
if self.restoring.contains(&thread.thread_id) {
return;
}
@@ -345,7 +352,7 @@ impl ThreadsArchiveView {
return;
}
self.mark_restoring(&thread.session_id, cx);
self.mark_restoring(&thread.thread_id, cx);
self.selection = None;
self.reset_filter_editor_text(window, cx);
cx.emit(ThreadsArchiveViewEvent::Unarchive { thread });
@@ -528,9 +535,9 @@ impl ThreadsArchiveView {
IconName::Sparkle
};
let is_restoring = self.restoring.contains(&thread.session_id);
let is_restoring = self.restoring.contains(&thread.thread_id);
let base = ThreadItem::new(id, thread.title.clone())
let base = ThreadItem::new(id, thread.display_title())
.icon(icon)
.when_some(icon_from_external_svg, |this, svg| {
this.custom_icon_from_external_svg(svg)
@@ -557,11 +564,11 @@ impl ThreadsArchiveView {
.icon_color(Color::Muted)
.tooltip(Tooltip::text("Cancel Restore"))
.on_click({
let session_id = thread.session_id.clone();
let thread_id = thread.thread_id;
cx.listener(move |this, _, _, cx| {
this.clear_restoring(&session_id, cx);
this.clear_restoring(&thread_id, cx);
cx.emit(ThreadsArchiveViewEvent::CancelRestore {
session_id: session_id.clone(),
thread_id,
});
cx.stop_propagation();
})
@@ -586,10 +593,16 @@ impl ThreadsArchiveView {
})
.on_click({
let agent = thread.agent_id.clone();
let thread_id = thread.thread_id;
let session_id = thread.session_id.clone();
cx.listener(move |this, _, _, cx| {
this.preserve_selection_on_next_update = true;
this.delete_thread(session_id.clone(), agent.clone(), cx);
this.delete_thread(
thread_id,
session_id.clone(),
agent.clone(),
cx,
);
cx.stop_propagation();
})
}),
@@ -619,17 +632,22 @@ impl ThreadsArchiveView {
};
self.preserve_selection_on_next_update = true;
self.delete_thread(thread.session_id.clone(), thread.agent_id.clone(), cx);
self.delete_thread(
thread.thread_id,
thread.session_id.clone(),
thread.agent_id.clone(),
cx,
);
}
fn delete_thread(
&mut self,
session_id: acp::SessionId,
thread_id: ThreadId,
session_id: Option<acp::SessionId>,
agent: AgentId,
cx: &mut Context<Self>,
) {
ThreadMetadataStore::global(cx)
.update(cx, |store, cx| store.delete(session_id.clone(), cx));
ThreadMetadataStore::global(cx).update(cx, |store, cx| store.delete(thread_id, cx));
let agent = Agent::from(agent);
@@ -645,13 +663,16 @@ impl ThreadsArchiveView {
.wait_for_connection()
});
cx.spawn(async move |_this, cx| {
crate::thread_worktree_archive::cleanup_thread_archived_worktrees(&session_id, cx)
.await;
crate::thread_worktree_archive::cleanup_thread_archived_worktrees(thread_id, cx).await;
let state = task.await?;
let task = cx.update(|cx| {
if let Some(list) = state.connection.session_list(cx) {
list.delete_session(&session_id, cx)
if let Some(session_id) = &session_id {
if let Some(list) = state.connection.session_list(cx) {
list.delete_session(session_id, cx)
} else {
Task::ready(Ok(()))
}
} else {
Task::ready(Ok(()))
}
@@ -929,9 +950,9 @@ impl ProjectPickerDelegate {
cx: &mut Context<Picker<Self>>,
) {
self.thread.worktree_paths =
super::thread_metadata_store::ThreadWorktreePaths::from_folder_paths(&paths);
super::thread_metadata_store::WorktreePaths::from_folder_paths(&paths);
ThreadMetadataStore::global(cx).update(cx, |store, cx| {
store.update_working_directories(&self.thread.session_id, paths, cx);
store.update_working_directories(self.thread.thread_id, paths, cx);
});
self.archive_view
@@ -995,7 +1016,15 @@ impl PickerDelegate for ProjectPickerDelegate {
type ListItem = AnyElement;
fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
format!("Associate the \"{}\" thread with...", self.thread.title).into()
format!(
"Associate the \"{}\" thread with...",
self.thread
.title
.as_ref()
.map(|t| t.as_ref())
.unwrap_or(DEFAULT_THREAD_TITLE)
)
.into()
}
fn render_editor(
@@ -319,7 +319,7 @@ impl LicenseDetectionWatcher {
}
worktree::Event::DeletedEntry(_)
| worktree::Event::UpdatedGitRepositories(_)
| worktree::Event::UpdatedRootRepoCommonDir
| worktree::Event::UpdatedRootRepoCommonDir { .. }
| worktree::Event::Deleted => {}
});
+1 -1
View File
@@ -4416,7 +4416,7 @@ impl LspStore {
worktree::Event::UpdatedGitRepositories(_)
| worktree::Event::DeletedEntry(_)
| worktree::Event::Deleted
| worktree::Event::UpdatedRootRepoCommonDir => {}
| worktree::Event::UpdatedRootRepoCommonDir { .. } => {}
})
.detach()
}
+1 -1
View File
@@ -59,7 +59,7 @@ impl WorktreeRoots {
let path = TriePath::from(entry.path.as_ref());
this.roots.remove(&path);
}
WorktreeEvent::Deleted | WorktreeEvent::UpdatedRootRepoCommonDir => {}
WorktreeEvent::Deleted | WorktreeEvent::UpdatedRootRepoCommonDir { .. } => {}
}
}),
})
+81 -16
View File
@@ -51,6 +51,7 @@ pub use git_store::{
};
pub use manifest_tree::ManifestTree;
pub use project_search::{Search, SearchResults};
pub use worktree_store::WorktreePaths;
use anyhow::{Context as _, Result, anyhow};
use buffer_store::{BufferStore, BufferStoreEvent};
@@ -246,6 +247,7 @@ pub struct Project {
toolchain_store: Option<Entity<ToolchainStore>>,
agent_location: Option<AgentLocation>,
downloading_files: Arc<Mutex<HashMap<(WorktreeId, String), DownloadingFile>>>,
last_worktree_paths: WorktreePaths,
}
struct DownloadingFile {
@@ -361,6 +363,9 @@ pub enum Event {
WorktreeRemoved(WorktreeId),
WorktreeUpdatedEntries(WorktreeId, UpdatedEntriesSet),
WorktreeUpdatedRootRepoCommonDir(WorktreeId),
WorktreePathsChanged {
old_worktree_paths: WorktreePaths,
},
DiskBasedDiagnosticsStarted {
language_server_id: LanguageServerId,
},
@@ -1339,6 +1344,7 @@ impl Project {
agent_location: None,
downloading_files: Default::default(),
last_worktree_paths: WorktreePaths::default(),
}
})
}
@@ -1576,6 +1582,7 @@ impl Project {
toolchain_store: Some(toolchain_store),
agent_location: None,
downloading_files: Default::default(),
last_worktree_paths: WorktreePaths::default(),
};
// remote server -> local machine handlers
@@ -1857,6 +1864,7 @@ impl Project {
toolchain_store: None,
agent_location: None,
downloading_files: Default::default(),
last_worktree_paths: WorktreePaths::default(),
};
project.set_role(role, cx);
for worktree in worktrees {
@@ -2067,6 +2075,40 @@ impl Project {
project
}
#[cfg(any(test, feature = "test-support"))]
pub fn add_test_remote_worktree(
&mut self,
abs_path: &str,
cx: &mut Context<Self>,
) -> Entity<Worktree> {
use rpc::NoopProtoClient;
use util::paths::PathStyle;
let root_name = std::path::Path::new(abs_path)
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
let client = AnyProtoClient::new(NoopProtoClient::new());
let worktree = Worktree::remote(
0,
ReplicaId::new(1),
proto::WorktreeMetadata {
id: 100 + self.visible_worktrees(cx).count() as u64,
root_name,
visible: true,
abs_path: abs_path.to_string(),
root_repo_common_dir: None,
},
client,
PathStyle::Posix,
cx,
);
self.worktree_store
.update(cx, |store, cx| store.add(&worktree, cx));
worktree
}
#[inline]
pub fn dap_store(&self) -> Entity<DapStore> {
self.dap_store.clone()
@@ -2351,20 +2393,13 @@ impl Project {
.find(|tree| tree.read(cx).root_name() == root_name)
}
pub fn project_group_key(&self, cx: &App) -> ProjectGroupKey {
let roots = self
.visible_worktrees(cx)
.map(|worktree| {
let snapshot = worktree.read(cx).snapshot();
snapshot
.root_repo_common_dir()
.and_then(|dir| Some(dir.parent()?.to_path_buf()))
.unwrap_or(snapshot.abs_path().to_path_buf())
})
.collect::<Vec<_>>();
let host = self.remote_connection_options(cx);
let path_list = PathList::new(&roots);
ProjectGroupKey::new(host, path_list)
fn emit_group_key_changed_if_needed(&mut self, cx: &mut Context<Self>) {
let new_worktree_paths = self.worktree_paths(cx);
if new_worktree_paths != self.last_worktree_paths {
let old_worktree_paths =
std::mem::replace(&mut self.last_worktree_paths, new_worktree_paths);
cx.emit(Event::WorktreePathsChanged { old_worktree_paths });
}
}
#[inline]
@@ -3683,9 +3718,11 @@ impl Project {
WorktreeStoreEvent::WorktreeAdded(worktree) => {
self.on_worktree_added(worktree, cx);
cx.emit(Event::WorktreeAdded(worktree.read(cx).id()));
self.emit_group_key_changed_if_needed(cx);
}
WorktreeStoreEvent::WorktreeRemoved(_, id) => {
cx.emit(Event::WorktreeRemoved(*id));
self.emit_group_key_changed_if_needed(cx);
}
WorktreeStoreEvent::WorktreeReleased(_, id) => {
self.on_worktree_released(*id, cx);
@@ -3705,6 +3742,7 @@ impl Project {
WorktreeStoreEvent::WorktreeUpdatedGitRepositories(_, _) => {}
WorktreeStoreEvent::WorktreeUpdatedRootRepoCommonDir(worktree_id) => {
cx.emit(Event::WorktreeUpdatedRootRepoCommonDir(*worktree_id));
self.emit_group_key_changed_if_needed(cx);
}
}
}
@@ -6109,6 +6147,14 @@ impl Project {
worktree.read(cx).entry_for_path(rel_path).is_some()
})
}
pub fn worktree_paths(&self, cx: &App) -> WorktreePaths {
self.worktree_store.read(cx).paths(cx)
}
pub fn project_group_key(&self, cx: &App) -> ProjectGroupKey {
ProjectGroupKey::from_project(self, cx)
}
}
/// Identifies a project group by a set of paths the workspaces in this group
@@ -6116,7 +6162,7 @@ impl Project {
///
/// Paths are mapped to their main worktree path first so we can group
/// workspaces by main repos.
#[derive(PartialEq, Eq, Hash, Clone, Debug)]
#[derive(PartialEq, Eq, Hash, Clone, Debug, Default)]
pub struct ProjectGroupKey {
/// The paths of the main worktrees for this project group.
paths: PathList,
@@ -6131,6 +6177,25 @@ impl ProjectGroupKey {
Self { paths, host }
}
pub fn from_project(project: &Project, cx: &App) -> Self {
let paths = project.worktree_paths(cx);
let host = project.remote_connection_options(cx);
Self {
paths: paths.main_worktree_path_list().clone(),
host,
}
}
pub fn from_worktree_paths(
paths: &WorktreePaths,
host: Option<RemoteConnectionOptions>,
) -> Self {
Self {
paths: paths.main_worktree_path_list().clone(),
host,
}
}
pub fn path_list(&self) -> &PathList {
&self.paths
}
@@ -6140,7 +6205,7 @@ impl ProjectGroupKey {
path_detail_map: &std::collections::HashMap<PathBuf, usize>,
) -> SharedString {
let mut names = Vec::with_capacity(self.paths.paths().len());
for abs_path in self.paths.paths() {
for abs_path in self.paths.ordered_paths() {
let detail = path_detail_map.get(abs_path).copied().unwrap_or(0);
let suffix = path_suffix(abs_path, detail);
if !suffix.is_empty() {
+143 -1
View File
@@ -24,6 +24,7 @@ use rpc::{
use text::ReplicaId;
use util::{
ResultExt,
path_list::PathList,
paths::{PathStyle, RemotePathBuf, SanitizedPath},
rel_path::RelPath,
};
@@ -34,6 +35,121 @@ use worktree::{
use crate::{ProjectPath, trusted_worktrees::TrustedWorktrees};
/// The current paths for a project's worktrees. Each folder path has a corresponding
/// main worktree path at the same position. The two lists are always the
/// same length and are modified together via `add_path` / `remove_main_path`.
///
/// For non-linked worktrees, the main path and folder path are identical.
/// For linked worktrees, the main path is the original repo and the folder
/// path is the linked worktree location.
#[derive(Default, Debug, Clone)]
pub struct WorktreePaths {
paths: PathList,
main_paths: PathList,
}
impl PartialEq for WorktreePaths {
fn eq(&self, other: &Self) -> bool {
self.paths == other.paths && self.main_paths == other.main_paths
}
}
impl WorktreePaths {
/// Build from two parallel `PathList`s that already share the same
/// insertion order. Used for deserialization from DB.
///
/// Returns an error if the two lists have different lengths, which
/// indicates corrupted data from a prior migration bug.
pub fn from_path_lists(
main_worktree_paths: PathList,
folder_paths: PathList,
) -> anyhow::Result<Self> {
anyhow::ensure!(
main_worktree_paths.paths().len() == folder_paths.paths().len(),
"main_worktree_paths has {} entries but folder_paths has {}",
main_worktree_paths.paths().len(),
folder_paths.paths().len(),
);
Ok(Self {
paths: folder_paths,
main_paths: main_worktree_paths,
})
}
/// Build for non-linked worktrees where main == folder for every path.
pub fn from_folder_paths(folder_paths: &PathList) -> Self {
Self {
paths: folder_paths.clone(),
main_paths: folder_paths.clone(),
}
}
pub fn is_empty(&self) -> bool {
self.paths.is_empty()
}
/// The folder paths (for workspace matching / `threads_by_paths` index).
pub fn folder_path_list(&self) -> &PathList {
&self.paths
}
/// The main worktree paths (for group key / `threads_by_main_paths` index).
pub fn main_worktree_path_list(&self) -> &PathList {
&self.main_paths
}
/// Iterate the (main_worktree_path, folder_path) pairs in insertion order.
pub fn ordered_pairs(&self) -> impl Iterator<Item = (&PathBuf, &PathBuf)> {
self.main_paths
.ordered_paths()
.zip(self.paths.ordered_paths())
}
/// Add a new path pair. If the exact (main, folder) pair already exists,
/// this is a no-op. Rebuilds both internal `PathList`s to maintain
/// consistent ordering.
pub fn add_path(&mut self, main_path: &Path, folder_path: &Path) {
let already_exists = self
.ordered_pairs()
.any(|(m, f)| m.as_path() == main_path && f.as_path() == folder_path);
if already_exists {
return;
}
let (mut mains, mut folders): (Vec<PathBuf>, Vec<PathBuf>) = self
.ordered_pairs()
.map(|(m, f)| (m.clone(), f.clone()))
.unzip();
mains.push(main_path.to_path_buf());
folders.push(folder_path.to_path_buf());
self.main_paths = PathList::new(&mains);
self.paths = PathList::new(&folders);
}
/// Remove all pairs whose main worktree path matches the given path.
/// This removes the corresponding entries from both lists.
pub fn remove_main_path(&mut self, main_path: &Path) {
let (mains, folders): (Vec<PathBuf>, Vec<PathBuf>) = self
.ordered_pairs()
.filter(|(m, _)| m.as_path() != main_path)
.map(|(m, f)| (m.clone(), f.clone()))
.unzip();
self.main_paths = PathList::new(&mains);
self.paths = PathList::new(&folders);
}
/// Remove all pairs whose folder path matches the given path.
/// This removes the corresponding entries from both lists.
pub fn remove_folder_path(&mut self, folder_path: &Path) {
let (mains, folders): (Vec<PathBuf>, Vec<PathBuf>) = self
.ordered_pairs()
.filter(|(_, f)| f.as_path() != folder_path)
.map(|(m, f)| (m.clone(), f.clone()))
.unzip();
self.main_paths = PathList::new(&mains);
self.paths = PathList::new(&folders);
}
}
enum WorktreeStoreState {
Local {
fs: Arc<dyn Fs>,
@@ -814,7 +930,7 @@ impl WorktreeStore {
// The worktree root itself has been deleted (for single-file worktrees)
// The worktree will be removed via the observe_release callback
}
worktree::Event::UpdatedRootRepoCommonDir => {
worktree::Event::UpdatedRootRepoCommonDir { .. } => {
cx.emit(WorktreeStoreEvent::WorktreeUpdatedRootRepoCommonDir(
worktree_id,
));
@@ -1263,6 +1379,32 @@ impl WorktreeStore {
WorktreeStoreState::Remote { .. } => None,
}
}
pub fn paths(&self, cx: &App) -> WorktreePaths {
let (mains, folders): (Vec<PathBuf>, Vec<PathBuf>) = self
.visible_worktrees(cx)
.filter(|worktree| {
let worktree = worktree.read(cx);
// Remote worktrees that haven't received their first update
// don't have enough data to contribute yet.
!worktree.is_remote() || worktree.root_entry().is_some()
})
.map(|worktree| {
let snapshot = worktree.read(cx).snapshot();
let folder_path = snapshot.abs_path().to_path_buf();
let main_path = snapshot
.root_repo_common_dir()
.and_then(|dir| Some(dir.parent()?.to_path_buf()))
.unwrap_or_else(|| folder_path.clone());
(main_path, folder_path)
})
.unzip();
WorktreePaths {
paths: PathList::new(&folders),
main_paths: PathList::new(&mains),
}
}
}
#[derive(Clone, Debug)]
+251 -8
View File
@@ -32,11 +32,12 @@ use picker::{
Picker, PickerDelegate,
highlighted_match_with_paths::{HighlightedMatch, HighlightedMatchWithPaths},
};
use project::{ProjectGroupKey, Worktree, git_store::Repository};
use project::{Worktree, git_store::Repository};
pub use remote_connections::RemoteSettings;
pub use remote_servers::RemoteServerProjects;
use settings::{Settings, WorktreeId};
use ui_input::ErasedEditor;
use workspace::ProjectGroupKey;
use dev_container::{DevContainerContext, find_devcontainer_configs};
use ui::{
@@ -380,7 +381,7 @@ pub fn init(cx: &mut App) {
multi_workspace
.update(cx, |multi_workspace, window, cx| {
let window_project_groups: Vec<ProjectGroupKey> =
multi_workspace.project_group_keys().cloned().collect();
multi_workspace.project_group_keys();
let workspace = multi_workspace.workspace().clone();
workspace.update(cx, |workspace, cx| {
@@ -1613,11 +1614,22 @@ impl PickerDelegate for RecentProjectsDelegate {
.border_t_1()
.border_color(cx.theme().colors().border_variant)
.child({
let open_action = workspace::Open::default();
let open_action = workspace::Open {
create_new_window: self.create_new_window,
};
Button::new("open_local_folder", "Open Local Project")
.key_binding(KeyBinding::for_action_in(&open_action, &focus_handle, cx))
.on_click(move |_, window, cx| {
window.dispatch_action(open_action.boxed_clone(), cx)
.on_click({
let workspace = self.workspace.clone();
let create_new_window = self.create_new_window;
move |_, window, cx| {
open_local_project(
workspace.clone(),
create_new_window,
window,
cx,
);
}
})
})
.child(
@@ -1764,6 +1776,9 @@ impl PickerDelegate for RecentProjectsDelegate {
)
.menu({
let focus_handle = focus_handle.clone();
let workspace_handle = self.workspace.clone();
let create_new_window = self.create_new_window;
let open_action = workspace::Open { create_new_window };
let show_add_to_workspace = match selected_entry {
Some(ProjectPickerEntry::RecentProject(hit)) => self
.workspaces
@@ -1778,6 +1793,8 @@ impl PickerDelegate for RecentProjectsDelegate {
move |window, cx| {
Some(ContextMenu::build(window, cx, {
let focus_handle = focus_handle.clone();
let workspace_handle = workspace_handle.clone();
let open_action = open_action.clone();
move |menu, _, _| {
menu.context(focus_handle)
.when(show_add_to_workspace, |menu| {
@@ -1787,9 +1804,20 @@ impl PickerDelegate for RecentProjectsDelegate {
)
.separator()
})
.action(
.entry(
"Open Local Project",
workspace::Open::default().boxed_clone(),
Some(open_action.boxed_clone()),
{
let workspace_handle = workspace_handle.clone();
move |window, cx| {
open_local_project(
workspace_handle.clone(),
create_new_window,
window,
cx,
);
}
},
)
.action(
"Open Remote Project",
@@ -1869,6 +1897,67 @@ pub(crate) fn highlights_for_path(
},
)
}
fn open_local_project(
workspace: WeakEntity<Workspace>,
create_new_window: bool,
window: &mut Window,
cx: &mut App,
) {
use gpui::PathPromptOptions;
use project::DirectoryLister;
let Some(workspace) = workspace.upgrade() else {
return;
};
let paths = workspace.update(cx, |workspace, cx| {
workspace.prompt_for_open_path(
PathPromptOptions {
files: true,
directories: true,
multiple: false,
prompt: None,
},
DirectoryLister::Local(
workspace.project().clone(),
workspace.app_state().fs.clone(),
),
window,
cx,
)
});
let multi_workspace_handle = window.window_handle().downcast::<MultiWorkspace>();
window
.spawn(cx, async move |cx| {
let Some(paths) = paths.await.log_err().flatten() else {
return;
};
if !create_new_window {
if let Some(handle) = multi_workspace_handle {
if let Some(task) = handle
.update(cx, |multi_workspace, window, cx| {
multi_workspace.open_project(paths, OpenMode::Activate, window, cx)
})
.log_err()
{
task.await.log_err();
}
return;
}
}
if let Some(task) = workspace
.update_in(cx, |workspace, window, cx| {
workspace.open_workspace_for_paths(OpenMode::NewWindow, paths, window, cx)
})
.log_err()
{
task.await.log_err();
}
})
.detach();
}
impl RecentProjectsDelegate {
fn add_project_to_workspace(
&mut self,
@@ -2032,9 +2121,10 @@ impl RecentProjectsDelegate {
#[cfg(test)]
mod tests {
use gpui::{TestAppContext, VisualTestContext};
use gpui::{TestAppContext, UpdateGlobal, VisualTestContext};
use serde_json::json;
use settings::SettingsStore;
use util::path;
use workspace::{AppState, open_paths};
@@ -2227,6 +2317,159 @@ mod tests {
.unwrap();
}
#[gpui::test]
async fn test_open_local_project_reuses_multi_workspace_window(cx: &mut TestAppContext) {
let app_state = init_test(cx);
// Disable system path prompts so the injected mock is used.
cx.update(|cx| {
SettingsStore::update_global(cx, |store, cx| {
store.update_user_settings(cx, |settings| {
settings.workspace.use_system_path_prompts = Some(false);
});
});
});
app_state
.fs
.as_fake()
.insert_tree(
path!("/initial-project"),
json!({ "src": { "main.rs": "" } }),
)
.await;
app_state
.fs
.as_fake()
.insert_tree(path!("/new-project"), json!({ "lib": { "mod.rs": "" } }))
.await;
cx.update(|cx| {
open_paths(
&[PathBuf::from(path!("/initial-project"))],
app_state.clone(),
workspace::OpenOptions::default(),
cx,
)
})
.await
.unwrap();
let initial_window_count = cx.update(|cx| cx.windows().len());
assert_eq!(initial_window_count, 1);
let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
cx.run_until_parked();
let workspace = multi_workspace
.read_with(cx, |mw, _| mw.workspace().clone())
.unwrap();
// Set up the prompt mock to return the new project path.
workspace.update(cx, |workspace, _cx| {
workspace.set_prompt_for_open_path(Box::new(|_, _, _, _| {
let (tx, rx) = futures::channel::oneshot::channel();
tx.send(Some(vec![PathBuf::from(path!("/new-project"))]))
.ok();
rx
}));
});
// Call open_local_project with create_new_window: false.
let weak_workspace = workspace.downgrade();
multi_workspace
.update(cx, |_, window, cx| {
open_local_project(weak_workspace, false, window, cx);
})
.unwrap();
cx.run_until_parked();
// Should NOT have opened a new window.
let final_window_count = cx.update(|cx| cx.windows().len());
assert_eq!(
final_window_count, initial_window_count,
"open_local_project with create_new_window=false should reuse the current multi-workspace window"
);
}
#[gpui::test]
async fn test_open_local_project_new_window_creates_new_window(cx: &mut TestAppContext) {
let app_state = init_test(cx);
// Disable system path prompts so the injected mock is used.
cx.update(|cx| {
SettingsStore::update_global(cx, |store, cx| {
store.update_user_settings(cx, |settings| {
settings.workspace.use_system_path_prompts = Some(false);
});
});
});
app_state
.fs
.as_fake()
.insert_tree(
path!("/initial-project"),
json!({ "src": { "main.rs": "" } }),
)
.await;
app_state
.fs
.as_fake()
.insert_tree(path!("/new-project"), json!({ "lib": { "mod.rs": "" } }))
.await;
cx.update(|cx| {
open_paths(
&[PathBuf::from(path!("/initial-project"))],
app_state.clone(),
workspace::OpenOptions::default(),
cx,
)
})
.await
.unwrap();
let initial_window_count = cx.update(|cx| cx.windows().len());
assert_eq!(initial_window_count, 1);
let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
cx.run_until_parked();
let workspace = multi_workspace
.read_with(cx, |mw, _| mw.workspace().clone())
.unwrap();
// Set up the prompt mock to return the new project path.
workspace.update(cx, |workspace, _cx| {
workspace.set_prompt_for_open_path(Box::new(|_, _, _, _| {
let (tx, rx) = futures::channel::oneshot::channel();
tx.send(Some(vec![PathBuf::from(path!("/new-project"))]))
.ok();
rx
}));
});
// Call open_local_project with create_new_window: true.
let weak_workspace = workspace.downgrade();
multi_workspace
.update(cx, |_, window, cx| {
open_local_project(weak_workspace, true, window, cx);
})
.unwrap();
cx.run_until_parked();
// Should have opened a new window.
let final_window_count = cx.update(|cx| cx.windows().len());
assert_eq!(
final_window_count,
initial_window_count + 1,
"open_local_project with create_new_window=true should open a new window"
);
}
fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
cx.update(|cx| {
let state = AppState::test(cx);
@@ -10,15 +10,14 @@ use picker::{
Picker, PickerDelegate,
highlighted_match_with_paths::{HighlightedMatch, HighlightedMatchWithPaths},
};
use project::ProjectGroupKey;
use remote::RemoteConnectionOptions;
use settings::Settings;
use ui::{KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*};
use ui_input::ErasedEditor;
use util::{ResultExt, paths::PathExt};
use workspace::{
MultiWorkspace, OpenMode, OpenOptions, PathList, SerializedWorkspaceLocation, Workspace,
WorkspaceDb, WorkspaceId, notifications::DetachAndPromptErr,
MultiWorkspace, OpenMode, OpenOptions, PathList, ProjectGroupKey, SerializedWorkspaceLocation,
Workspace, WorkspaceDb, WorkspaceId, notifications::DetachAndPromptErr,
};
use zed_actions::OpenRemote;
+40
View File
@@ -547,3 +547,43 @@ fn to_any_envelope<T: EnvelopedMessage>(
payload: response,
}) as Box<_>
}
#[cfg(any(test, feature = "test-support"))]
pub struct NoopProtoClient {
handler_set: parking_lot::Mutex<ProtoMessageHandlerSet>,
}
#[cfg(any(test, feature = "test-support"))]
impl NoopProtoClient {
pub fn new() -> Arc<Self> {
Arc::new(Self {
handler_set: parking_lot::Mutex::new(ProtoMessageHandlerSet::default()),
})
}
}
#[cfg(any(test, feature = "test-support"))]
impl ProtoClient for NoopProtoClient {
fn request(
&self,
_: proto::Envelope,
_: &'static str,
) -> futures::future::BoxFuture<'static, Result<proto::Envelope>> {
unimplemented!()
}
fn send(&self, _: proto::Envelope, _: &'static str) -> Result<()> {
Ok(())
}
fn send_response(&self, _: proto::Envelope, _: &'static str) -> Result<()> {
Ok(())
}
fn message_handler_set(&self) -> &parking_lot::Mutex<ProtoMessageHandlerSet> {
&self.handler_set
}
fn is_via_collab(&self) -> bool {
false
}
fn has_wsl_interop(&self) -> bool {
false
}
}
+1
View File
@@ -65,6 +65,7 @@ git.workspace = true
gpui = { workspace = true, features = ["test-support"] }
client = { workspace = true, features = ["test-support"] }
clock = { workspace = true, features = ["test-support"] }
db = { workspace = true, features = ["test-support"] }
http_client = { workspace = true, features = ["test-support"] }
node_runtime = { workspace = true, features = ["test-support"] }
project = { workspace = true, features = ["test-support"] }
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -715,7 +715,7 @@ impl TitleBar {
.multi_workspace
.as_ref()
.and_then(|mw| mw.upgrade())
.map(|mw| mw.read(cx).project_group_keys().cloned().collect())
.map(|mw| mw.read(cx).project_group_keys())
.unwrap_or_default();
PopoverMenu::new("recent-projects-menu")
@@ -772,7 +772,7 @@ impl TitleBar {
.multi_workspace
.as_ref()
.and_then(|mw| mw.upgrade())
.map(|mw| mw.read(cx).project_group_keys().cloned().collect())
.map(|mw| mw.read(cx).project_group_keys())
.unwrap_or_default();
PopoverMenu::new("sidebar-title-recent-projects-menu")
File diff suppressed because it is too large. Load diff
+371 -8
View File
@@ -1,9 +1,10 @@
use std::path::PathBuf;
use super::*;
use client::proto;
use fs::FakeFs;
use gpui::TestAppContext;
use project::{DisableAiSettings, ProjectGroupKey};
use project::DisableAiSettings;
use serde_json::json;
use settings::SettingsStore;
use util::path;
@@ -105,9 +106,9 @@ async fn test_project_group_keys_initial(cx: &mut TestAppContext) {
});
multi_workspace.read_with(cx, |mw, _cx| {
let keys: Vec<&ProjectGroupKey> = mw.project_group_keys().collect();
let keys: Vec<ProjectGroupKey> = mw.project_group_keys();
assert_eq!(keys.len(), 1, "should have exactly one key on creation");
assert_eq!(*keys[0], expected_key);
assert_eq!(keys[0], expected_key);
});
}
@@ -135,7 +136,7 @@ async fn test_project_group_keys_add_workspace(cx: &mut TestAppContext) {
});
multi_workspace.read_with(cx, |mw, _cx| {
assert_eq!(mw.project_group_keys().count(), 1);
assert_eq!(mw.project_group_keys().len(), 1);
});
// Adding a workspace with a different project root adds a new key.
@@ -144,14 +145,14 @@ async fn test_project_group_keys_add_workspace(cx: &mut TestAppContext) {
});
multi_workspace.read_with(cx, |mw, _cx| {
let keys: Vec<&ProjectGroupKey> = mw.project_group_keys().collect();
let keys: Vec<ProjectGroupKey> = mw.project_group_keys();
assert_eq!(
keys.len(),
2,
"should have two keys after adding a second workspace"
);
assert_eq!(*keys[0], key_b);
assert_eq!(*keys[1], key_a);
assert_eq!(keys[0], key_b);
assert_eq!(keys[1], key_a);
});
}
@@ -225,7 +226,7 @@ async fn test_project_group_keys_duplicate_not_added(cx: &mut TestAppContext) {
});
multi_workspace.read_with(cx, |mw, _cx| {
let keys: Vec<&ProjectGroupKey> = mw.project_group_keys().collect();
let keys: Vec<ProjectGroupKey> = mw.project_group_keys();
assert_eq!(
keys.len(),
1,
@@ -233,3 +234,365 @@ async fn test_project_group_keys_duplicate_not_added(cx: &mut TestAppContext) {
);
});
}
#[gpui::test]
async fn test_groups_with_same_paths_merge(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree("/a", json!({ "file.txt": "" })).await;
fs.insert_tree("/b", json!({ "file.txt": "" })).await;
let project_a = Project::test(fs.clone(), ["/a".as_ref()], cx).await;
let project_b = Project::test(fs.clone(), ["/b".as_ref()], cx).await;
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
// Open the sidebar so workspaces get grouped.
multi_workspace.update(cx, |mw, cx| {
mw.open_sidebar(cx);
});
cx.run_until_parked();
// Add a second workspace, creating group_b with path [/b].
let group_a_key = multi_workspace.update_in(cx, |mw, window, cx| {
let group_a_key = mw.project_groups(cx)[0].key.clone();
mw.test_add_workspace(project_b, window, cx);
group_a_key
});
cx.run_until_parked();
// Now add /b to group_a so it has [/a, /b].
multi_workspace.update(cx, |mw, cx| {
mw.add_folders_to_project_group(&group_a_key, vec!["/b".into()], cx);
});
cx.run_until_parked();
// Verify we have two groups.
multi_workspace.read_with(cx, |mw, cx| {
assert_eq!(
mw.project_groups(cx).len(),
2,
"should have two groups before the merge"
);
});
// After adding /b, group_a's key changed. Get the updated key.
let group_a_key_updated = multi_workspace.read_with(cx, |mw, cx| {
mw.project_groups(cx)
.iter()
.find(|g| g.key.path_list().paths().contains(&PathBuf::from("/a")))
.unwrap()
.key
.clone()
});
// Remove /a from group_a, making its key [/b] — same as group_b.
multi_workspace.update(cx, |mw, cx| {
mw.remove_folder_from_project_group(&group_a_key_updated, Path::new("/a"), cx);
});
cx.run_until_parked();
// The two groups now have identical keys [/b] and should have been merged.
multi_workspace.read_with(cx, |mw, cx| {
assert_eq!(
mw.project_groups(cx).len(),
1,
"groups with identical paths should be merged into one"
);
});
}
#[gpui::test]
async fn test_adding_worktree_updates_project_group_key(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree("/root_a", json!({ "file.txt": "" })).await;
fs.insert_tree("/root_b", json!({ "other.txt": "" })).await;
let project = Project::test(fs.clone(), ["/root_a".as_ref()], cx).await;
let initial_key = project.read_with(cx, |p, cx| p.project_group_key(cx));
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
// Open sidebar to retain the workspace and create the initial group.
multi_workspace.update(cx, |mw, cx| {
mw.open_sidebar(cx);
});
cx.run_until_parked();
multi_workspace.read_with(cx, |mw, _cx| {
let keys = mw.project_group_keys();
assert_eq!(keys.len(), 1);
assert_eq!(keys[0], initial_key);
});
// Add a second worktree to the project. This triggers WorktreeAdded →
// handle_workspace_key_change, which should update the group key.
project
.update(cx, |project, cx| {
project.find_or_create_worktree("/root_b", true, cx)
})
.await
.expect("adding worktree should succeed");
cx.run_until_parked();
let updated_key = project.read_with(cx, |p, cx| p.project_group_key(cx));
assert_ne!(
initial_key, updated_key,
"adding a worktree should change the project group key"
);
multi_workspace.read_with(cx, |mw, _cx| {
let keys = mw.project_group_keys();
assert!(
keys.contains(&updated_key),
"should contain the updated key; got {keys:?}"
);
});
}
#[gpui::test]
async fn test_find_or_create_local_workspace_reuses_active_workspace_when_sidebar_closed(
cx: &mut TestAppContext,
) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree("/root_a", json!({ "file.txt": "" })).await;
let project = Project::test(fs, ["/root_a".as_ref()], cx).await;
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
let active_workspace = multi_workspace.read_with(cx, |mw, cx| {
assert!(
mw.project_groups(cx).is_empty(),
"sidebar-closed setup should start with no retained project groups"
);
mw.workspace().clone()
});
let active_workspace_id = active_workspace.entity_id();
let workspace = multi_workspace
.update_in(cx, |mw, window, cx| {
mw.find_or_create_local_workspace(
PathList::new(&[PathBuf::from("/root_a")]),
window,
cx,
)
})
.await
.expect("reopening the same local workspace should succeed");
assert_eq!(
workspace.entity_id(),
active_workspace_id,
"should reuse the current active workspace when the sidebar is closed"
);
multi_workspace.read_with(cx, |mw, _cx| {
assert_eq!(
mw.workspace().entity_id(),
active_workspace_id,
"active workspace should remain unchanged after reopening the same path"
);
assert_eq!(
mw.workspaces().count(),
1,
"reusing the active workspace should not create a second open workspace"
);
});
}
#[gpui::test]
async fn test_find_or_create_local_workspace_reuses_active_workspace_after_sidebar_open(
cx: &mut TestAppContext,
) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree("/root_a", json!({ "file.txt": "" })).await;
let project = Project::test(fs, ["/root_a".as_ref()], cx).await;
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
multi_workspace.update(cx, |mw, cx| {
mw.open_sidebar(cx);
});
cx.run_until_parked();
let active_workspace = multi_workspace.read_with(cx, |mw, cx| {
assert_eq!(
mw.project_groups(cx).len(),
1,
"opening the sidebar should retain the active workspace in a project group"
);
mw.workspace().clone()
});
let active_workspace_id = active_workspace.entity_id();
let workspace = multi_workspace
.update_in(cx, |mw, window, cx| {
mw.find_or_create_local_workspace(
PathList::new(&[PathBuf::from("/root_a")]),
window,
cx,
)
})
.await
.expect("reopening the same retained local workspace should succeed");
assert_eq!(
workspace.entity_id(),
active_workspace_id,
"should reuse the retained active workspace after the sidebar is opened"
);
multi_workspace.read_with(cx, |mw, _cx| {
assert_eq!(
mw.workspaces().count(),
1,
"reopening the same retained workspace should not create another workspace"
);
});
}
#[gpui::test]
async fn test_switching_projects_with_sidebar_closed_detaches_old_active_workspace(
cx: &mut TestAppContext,
) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree("/root_a", json!({ "file_a.txt": "" })).await;
fs.insert_tree("/root_b", json!({ "file_b.txt": "" })).await;
let project_a = Project::test(fs.clone(), ["/root_a".as_ref()], cx).await;
let project_b = Project::test(fs, ["/root_b".as_ref()], cx).await;
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
let workspace_a = multi_workspace.read_with(cx, |mw, cx| {
assert!(
mw.project_groups(cx).is_empty(),
"sidebar-closed setup should start with no retained project groups"
);
mw.workspace().clone()
});
assert!(
workspace_a.read_with(cx, |workspace, _cx| workspace.session_id().is_some()),
"initial active workspace should start attached to the session"
);
let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
mw.test_add_workspace(project_b, window, cx)
});
cx.run_until_parked();
multi_workspace.read_with(cx, |mw, _cx| {
assert_eq!(
mw.workspace().entity_id(),
workspace_b.entity_id(),
"the new workspace should become active"
);
assert_eq!(
mw.workspaces().count(),
1,
"only the new active workspace should remain open after switching with the sidebar closed"
);
});
assert!(
workspace_a.read_with(cx, |workspace, _cx| workspace.session_id().is_none()),
"the previous active workspace should be detached when switching away with the sidebar closed"
);
}
#[gpui::test]
async fn test_remote_worktree_without_git_updates_project_group(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree("/local", json!({ "file.txt": "" })).await;
let project = Project::test(fs.clone(), ["/local".as_ref()], cx).await;
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
multi_workspace.update(cx, |mw, cx| {
mw.open_sidebar(cx);
});
cx.run_until_parked();
let initial_key = project.read_with(cx, |p, cx| p.project_group_key(cx));
multi_workspace.read_with(cx, |mw, _cx| {
let keys = mw.project_group_keys();
assert_eq!(keys.len(), 1);
assert_eq!(keys[0], initial_key);
});
// Add a remote worktree without git repo info.
let remote_worktree = project.update(cx, |project, cx| {
project.add_test_remote_worktree("/remote/project", cx)
});
cx.run_until_parked();
// The remote worktree has no entries yet, so project_group_key should
// still exclude it.
let key_after_add = project.read_with(cx, |p, cx| p.project_group_key(cx));
assert_eq!(
key_after_add, initial_key,
"remote worktree without entries should not affect the group key"
);
// Send an UpdateWorktree to the remote worktree with entries but no repo.
// This triggers UpdatedRootRepoCommonDir on the first update (the fix),
// which propagates through WorktreeStore → Project → MultiWorkspace.
let worktree_id = remote_worktree.read_with(cx, |wt, _| wt.id().to_proto());
remote_worktree.update(cx, |worktree, _cx| {
worktree
.as_remote()
.unwrap()
.update_from_remote(proto::UpdateWorktree {
project_id: 0,
worktree_id,
abs_path: "/remote/project".to_string(),
root_name: "project".to_string(),
updated_entries: vec![proto::Entry {
id: 1,
is_dir: true,
path: "".to_string(),
inode: 1,
mtime: Some(proto::Timestamp {
seconds: 0,
nanos: 0,
}),
is_ignored: false,
is_hidden: false,
is_external: false,
is_fifo: false,
size: None,
canonical_path: None,
}],
removed_entries: vec![],
scan_id: 1,
is_last_update: true,
updated_repositories: vec![],
removed_repositories: vec![],
root_repo_common_dir: None,
});
});
cx.run_until_parked();
let updated_key = project.read_with(cx, |p, cx| p.project_group_key(cx));
assert_ne!(
initial_key, updated_key,
"adding a remote worktree should change the project group key"
);
multi_workspace.read_with(cx, |mw, _cx| {
let keys = mw.project_group_keys();
assert!(
keys.contains(&updated_key),
"should contain the updated key; got {keys:?}"
);
});
}
+19 -46
View File
@@ -2495,6 +2495,7 @@ pub fn delete_unloaded_items(
#[cfg(test)]
mod tests {
use super::*;
use crate::ProjectGroupKey;
use crate::{
multi_workspace::MultiWorkspace,
persistence::{
@@ -2508,7 +2509,7 @@ mod tests {
use feature_flags::FeatureFlagAppExt;
use gpui::AppContext as _;
use pretty_assertions::assert_eq;
use project::{Project, ProjectGroupKey};
use project::Project;
use remote::SshConnectionOptions;
use serde_json::json;
use std::{thread, time::Duration};
@@ -2567,10 +2568,14 @@ mod tests {
the newly activated workspace's database id"
);
// --- Remove the first workspace (index 0, which is not the active one) ---
multi_workspace.update_in(cx, |mw, window, cx| {
let ws = mw.workspaces().nth(0).unwrap().clone();
mw.remove([ws], |_, _, _| unreachable!(), window, cx)
// --- Remove the non-active workspace ---
multi_workspace.update_in(cx, |mw, _window, cx| {
let active = mw.workspace().clone();
let ws = mw
.workspaces()
.find(|ws| *ws != &active)
.expect("should have a non-active workspace");
mw.remove([ws.clone()], |_, _, _| unreachable!(), _window, cx)
.detach_and_log_err(cx);
});
@@ -4007,7 +4012,7 @@ mod tests {
window_10,
MultiWorkspaceState {
active_workspace_id: Some(WorkspaceId(2)),
project_group_keys: vec![],
project_groups: vec![],
sidebar_open: true,
sidebar_state: None,
},
@@ -4019,7 +4024,7 @@ mod tests {
window_20,
MultiWorkspaceState {
active_workspace_id: Some(WorkspaceId(3)),
project_group_keys: vec![],
project_groups: vec![],
sidebar_open: false,
sidebar_state: None,
},
@@ -4771,38 +4776,8 @@ mod tests {
mw.test_add_workspace(project_1_linked_worktree.clone(), window, cx)
});
// Assign database IDs and set up session bindings so serialization
// writes real rows.
multi_workspace.update_in(cx, |mw, _, cx| {
for workspace in mw.workspaces() {
workspace.update(cx, |ws, _cx| {
ws.set_random_database_id();
});
}
});
// Flush serialization for each individual workspace (writes to SQLite)
// and for the MultiWorkspace (writes to KVP).
let tasks = multi_workspace.update_in(cx, |mw, window, cx| {
let session_id = mw.workspace().read(cx).session_id();
let window_id_u64 = window.window_handle().window_id().as_u64();
let mut tasks: Vec<Task<()>> = Vec::new();
for workspace in mw.workspaces() {
tasks.push(workspace.update(cx, |ws, cx| ws.flush_serialization(window, cx)));
if let Some(db_id) = workspace.read(cx).database_id() {
let db = WorkspaceDb::global(cx);
let session_id = session_id.clone();
tasks.push(cx.background_spawn(async move {
db.set_session_binding(db_id, session_id, Some(window_id_u64))
.await
.log_err();
}));
}
}
mw.serialize(cx);
tasks
});
let tasks =
multi_workspace.update_in(cx, |mw, window, cx| mw.flush_all_serialization(window, cx));
cx.run_until_parked();
for task in tasks {
task.await;
@@ -4843,13 +4818,13 @@ mod tests {
serialized.active_workspace.workspace_id,
active_db_id.unwrap(),
);
assert_eq!(serialized.state.project_group_keys.len(), 2,);
assert_eq!(serialized.state.project_groups.len(), 2,);
// Verify the serialized project group keys round-trip back to the
// originals.
let restored_keys: Vec<ProjectGroupKey> = serialized
.state
.project_group_keys
.project_groups
.iter()
.cloned()
.map(Into::into)
@@ -4882,9 +4857,7 @@ mod tests {
// The restored window should have the same project group keys.
let restored_keys: Vec<ProjectGroupKey> = restored_handle
.read_with(cx, |mw: &MultiWorkspace, _cx| {
mw.project_group_keys().cloned().collect()
})
.read_with(cx, |mw: &MultiWorkspace, _cx| mw.project_group_keys())
.unwrap();
assert_eq!(
restored_keys, expected_keys,
@@ -4928,7 +4901,7 @@ mod tests {
let project_c = Project::test(fs.clone(), [dir_c.as_path()], cx).await;
// Create a multi-workspace with project A, then add B and C.
// project_group_keys stores newest first: [C, B, A].
// project_groups stores newest first: [C, B, A].
// Sidebar displays in the same order: C (top), B (middle), A (bottom).
let (multi_workspace, cx) = cx
.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
@@ -4976,7 +4949,7 @@ mod tests {
// Activate workspace A (the bottom) so removing it tests the
// "fall back upward" path.
let workspace_a =
multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().cloned().unwrap());
multi_workspace.read_with(cx, |mw, _cx| mw.workspaces().next().unwrap().clone());
multi_workspace.update_in(cx, |mw, window, cx| {
mw.activate(workspace_a.clone(), window, cx);
});
+41 -17
View File
@@ -1,7 +1,7 @@
use super::{SerializedAxis, SerializedWindowBounds};
use crate::{
Member, Pane, PaneAxis, SerializableItemRegistry, Workspace, WorkspaceId, item::ItemHandle,
path_list::PathList,
multi_workspace::SerializedProjectGroupState, path_list::PathList,
};
use anyhow::{Context, Result};
use async_recursion::async_recursion;
@@ -12,8 +12,9 @@ use db::sqlez::{
};
use gpui::{AsyncWindowContext, Entity, WeakEntity, WindowId};
use crate::ProjectGroupKey;
use language::{Toolchain, ToolchainScope};
use project::{Project, ProjectGroupKey, debugger::breakpoint_store::SourceBreakpoint};
use project::{Project, debugger::breakpoint_store::SourceBreakpoint};
use remote::RemoteConnectionOptions;
use serde::{Deserialize, Serialize};
use std::{
@@ -60,31 +61,53 @@ pub struct SessionWorkspace {
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct SerializedProjectGroupKey {
pub struct SerializedProjectGroup {
pub path_list: SerializedPathList,
pub(crate) location: SerializedWorkspaceLocation,
#[serde(default = "default_expanded")]
pub expanded: bool,
#[serde(default)]
pub visible_thread_count: Option<usize>,
}
impl From<ProjectGroupKey> for SerializedProjectGroupKey {
fn from(value: ProjectGroupKey) -> Self {
SerializedProjectGroupKey {
path_list: value.path_list().serialize(),
location: match value.host() {
fn default_expanded() -> bool {
true
}
impl SerializedProjectGroup {
pub fn from_group(
key: &ProjectGroupKey,
expanded: bool,
visible_thread_count: Option<usize>,
) -> Self {
Self {
path_list: key.path_list().serialize(),
location: match key.host() {
Some(host) => SerializedWorkspaceLocation::Remote(host),
None => SerializedWorkspaceLocation::Local,
},
expanded,
visible_thread_count,
}
}
pub fn into_restored_state(self) -> SerializedProjectGroupState {
let path_list = PathList::deserialize(&self.path_list);
let host = match self.location {
SerializedWorkspaceLocation::Local => None,
SerializedWorkspaceLocation::Remote(opts) => Some(opts),
};
SerializedProjectGroupState {
key: ProjectGroupKey::new(host, path_list),
expanded: self.expanded,
visible_thread_count: self.visible_thread_count,
}
}
}
impl From<SerializedProjectGroupKey> for ProjectGroupKey {
fn from(value: SerializedProjectGroupKey) -> Self {
let path_list = PathList::deserialize(&value.path_list);
let host = match value.location {
SerializedWorkspaceLocation::Local => None,
SerializedWorkspaceLocation::Remote(opts) => Some(opts),
};
ProjectGroupKey::new(host, path_list)
impl From<SerializedProjectGroup> for ProjectGroupKey {
fn from(value: SerializedProjectGroup) -> Self {
value.into_restored_state().key
}
}
@@ -93,7 +116,8 @@ impl From<SerializedProjectGroupKey> for ProjectGroupKey {
pub struct MultiWorkspaceState {
pub active_workspace_id: Option<WorkspaceId>,
pub sidebar_open: bool,
pub project_group_keys: Vec<SerializedProjectGroupKey>,
#[serde(alias = "project_group_keys")]
pub project_groups: Vec<SerializedProjectGroup>,
#[serde(default)]
pub sidebar_state: Option<String>,
}
+58 -31
View File
@@ -33,8 +33,9 @@ pub use dock::Panel;
pub use multi_workspace::{
CloseWorkspaceSidebar, DraggedSidebar, FocusWorkspaceSidebar, MultiWorkspace,
MultiWorkspaceEvent, NewThread, NextProject, NextThread, PreviousProject, PreviousThread,
ShowFewerThreads, ShowMoreThreads, Sidebar, SidebarEvent, SidebarHandle, SidebarRenderState,
SidebarSide, ToggleWorkspaceSidebar, sidebar_side_context_menu,
ProjectGroup, ProjectGroupKey, SerializedProjectGroupState, ShowFewerThreads, ShowMoreThreads,
Sidebar, SidebarEvent, SidebarHandle, SidebarRenderState, SidebarSide, ToggleWorkspaceSidebar,
sidebar_side_context_menu,
};
pub use path_list::{PathList, SerializedPathList};
pub use toast_layer::{ToastAction, ToastLayer, ToastView};
@@ -86,14 +87,14 @@ pub use persistence::{
WorkspaceDb, delete_unloaded_items,
model::{
DockStructure, ItemId, MultiWorkspaceState, SerializedMultiWorkspace,
SerializedProjectGroupKey, SerializedWorkspaceLocation, SessionWorkspace,
SerializedProjectGroup, SerializedWorkspaceLocation, SessionWorkspace,
},
read_serialized_multi_workspaces, resolve_worktree_workspaces,
};
use postage::stream::Stream;
use project::{
DirectoryLister, Project, ProjectEntryId, ProjectGroupKey, ProjectPath, ResolvedPath, Worktree,
WorktreeId, WorktreeSettings,
DirectoryLister, Project, ProjectEntryId, ProjectPath, ResolvedPath, Worktree, WorktreeId,
WorktreeSettings,
debugger::{breakpoint_store::BreakpointStoreEvent, session::ThreadStatus},
project_settings::ProjectSettings,
toolchain_store::ToolchainStoreEvent,
@@ -215,6 +216,16 @@ pub trait DebuggerProvider {
fn active_thread_state(&self, cx: &App) -> Option<ThreadStatus>;
}
pub trait WorkspaceSidebarDelegate: Send + Sync {
fn reconcile_group(
&self,
workspace: &mut Workspace,
group_key: &ProjectGroupKey,
window: &mut Window,
cx: &mut Context<Workspace>,
) -> bool;
}
/// Opens a file or directory.
#[derive(Clone, PartialEq, Deserialize, JsonSchema, Action)]
#[action(namespace = workspace)]
@@ -1371,6 +1382,7 @@ pub struct Workspace {
_panels_task: Option<Task<Result<()>>>,
sidebar_focus_handle: Option<FocusHandle>,
multi_workspace: Option<WeakEntity<MultiWorkspace>>,
sidebar_delegate: Option<Arc<dyn WorkspaceSidebarDelegate>>,
}
impl EventEmitter<Event> for Workspace {}
@@ -1799,6 +1811,7 @@ impl Workspace {
removing: false,
sidebar_focus_handle: None,
multi_workspace,
sidebar_delegate: None,
open_in_dev_container: false,
_dev_container_task: None,
}
@@ -2459,6 +2472,14 @@ impl Workspace {
self.multi_workspace = Some(multi_workspace);
}
pub fn set_sidebar_delegate(&mut self, delegate: Arc<dyn WorkspaceSidebarDelegate>) {
self.sidebar_delegate = Some(delegate);
}
pub fn sidebar_delegate(&self) -> Option<Arc<dyn WorkspaceSidebarDelegate>> {
self.sidebar_delegate.clone()
}
pub fn app_state(&self) -> &Arc<AppState> {
&self.app_state
}
@@ -2847,6 +2868,9 @@ impl Workspace {
window: &mut Window,
cx: &mut Context<Self>,
) -> oneshot::Receiver<Option<Vec<PathBuf>>> {
// TODO: If `on_prompt_for_open_path` is set, we should always use it
// rather than gating on `use_system_path_prompts`. This would let tests
// inject a mock without also having to disable the setting.
if !lister.is_local(cx) || !WorkspaceSettings::get_global(cx).use_system_path_prompts {
let prompt = self.on_prompt_for_open_path.take().unwrap();
let rx = prompt(self, lister, window, cx);
@@ -8726,7 +8750,7 @@ pub async fn restore_multiworkspace(
log::error!("Failed to restore active workspace: {err:#}");
let mut fallback_handle = None;
for key in &state.project_group_keys {
for key in &state.project_groups {
let key: ProjectGroupKey = key.clone().into();
let paths = key.path_list().paths().to_vec();
match cx
@@ -8776,20 +8800,21 @@ pub async fn apply_restored_multiworkspace_state(
) {
let MultiWorkspaceState {
sidebar_open,
project_group_keys,
project_groups,
sidebar_state,
..
} = state;
if !project_group_keys.is_empty() {
if !project_groups.is_empty() {
// Resolve linked worktree paths to their main repo paths so
// stale keys from previous sessions get normalized and deduped.
let mut resolved_keys: Vec<ProjectGroupKey> = Vec::new();
for key in project_group_keys
.iter()
.cloned()
.map(ProjectGroupKey::from)
{
let mut resolved_groups: Vec<SerializedProjectGroupState> = Vec::new();
for serialized in project_groups.iter().cloned() {
let SerializedProjectGroupState {
key,
expanded,
visible_thread_count,
} = serialized.into_restored_state();
if key.path_list().paths().is_empty() {
continue;
}
@@ -8806,14 +8831,18 @@ pub async fn apply_restored_multiworkspace_state(
}
}
let resolved = ProjectGroupKey::new(key.host(), PathList::new(&resolved_paths));
if !resolved_keys.contains(&resolved) {
resolved_keys.push(resolved);
if !resolved_groups.iter().any(|g| g.key == resolved) {
resolved_groups.push(SerializedProjectGroupState {
key: resolved,
expanded,
visible_thread_count,
});
}
}
window_handle
.update(cx, |multi_workspace, _window, _cx| {
multi_workspace.restore_project_group_keys(resolved_keys);
.update(cx, |multi_workspace, _window, cx| {
multi_workspace.restore_project_groups(resolved_groups, cx);
})
.ok();
}
@@ -9885,7 +9914,7 @@ async fn open_remote_project_inner(
});
if let Some(project_group_key) = provisional_project_group_key.clone() {
multi_workspace.set_workspace_group_key(&new_workspace, project_group_key);
multi_workspace.retain_workspace(new_workspace.clone(), project_group_key, cx);
}
multi_workspace.activate(new_workspace.clone(), window, cx);
new_workspace
@@ -9957,14 +9986,13 @@ pub fn join_in_room_project(
.and_then(|window_handle| {
window_handle
.update(cx, |multi_workspace, _window, cx| {
for workspace in multi_workspace.workspaces() {
if workspace.read(cx).project().read(cx).remote_id()
== Some(project_id)
{
return Some((window_handle, workspace.clone()));
}
}
None
multi_workspace
.workspaces()
.find(|workspace| {
workspace.read(cx).project().read(cx).remote_id()
== Some(project_id)
})
.map(|workspace| (window_handle, workspace.clone()))
})
.unwrap_or(None)
})
@@ -10915,8 +10943,7 @@ mod tests {
// Activate workspace A
multi_workspace_handle
.update(cx, |mw, window, cx| {
let workspace = mw.workspaces().next().unwrap().clone();
mw.activate(workspace, window, cx);
mw.activate(workspace_a.clone(), window, cx);
})
.unwrap();
@@ -11039,7 +11066,7 @@ mod tests {
assert!(!removed, "removal should have been cancelled");
multi_workspace_handle
.read_with(cx, |mw, _| {
.read_with(cx, |mw, _cx| {
assert_eq!(
mw.workspace(),
&workspace_b,
@@ -11067,7 +11094,7 @@ mod tests {
// Should be back on workspace A, and B should be gone.
multi_workspace_handle
.read_with(cx, |mw, _| {
.read_with(cx, |mw, _cx| {
assert_eq!(
mw.workspace(),
&workspace_a,
+26 -8
View File
@@ -165,6 +165,7 @@ pub struct RemoteWorktree {
replica_id: ReplicaId,
visible: bool,
disconnected: bool,
received_initial_update: bool,
}
#[derive(Clone)]
@@ -370,7 +371,9 @@ struct UpdateObservationState {
pub enum Event {
UpdatedEntries(UpdatedEntriesSet),
UpdatedGitRepositories(UpdatedGitRepositoriesSet),
UpdatedRootRepoCommonDir,
UpdatedRootRepoCommonDir {
old: Option<Arc<SanitizedPath>>,
},
DeletedEntry(ProjectEntryId),
/// The worktree root itself has been deleted (for single-file worktrees)
Deleted,
@@ -550,6 +553,7 @@ impl Worktree {
snapshot_subscriptions: Default::default(),
visible: worktree.visible,
disconnected: false,
received_initial_update: false,
};
// Apply updates to a separate snapshot in a background task, then
@@ -574,9 +578,16 @@ impl Worktree {
cx.spawn(async move |this, cx| {
while (snapshot_updated_rx.recv().await).is_some() {
this.update(cx, |this, cx| {
let mut entries_changed = false;
let this = this.as_remote_mut().unwrap();
// The watch channel delivers an initial signal before
// any real updates arrive. Skip these spurious wakeups.
if this.background_snapshot.lock().1.is_empty() {
return;
}
let old_root_repo_common_dir = this.snapshot.root_repo_common_dir.clone();
let mut entries_changed = false;
{
let mut lock = this.background_snapshot.lock();
this.snapshot = lock.0.clone();
@@ -592,8 +603,14 @@ impl Worktree {
if entries_changed {
cx.emit(Event::UpdatedEntries(Arc::default()));
}
if this.snapshot.root_repo_common_dir != old_root_repo_common_dir {
cx.emit(Event::UpdatedRootRepoCommonDir);
let is_first_update = !this.received_initial_update;
this.received_initial_update = true;
if this.snapshot.root_repo_common_dir != old_root_repo_common_dir
|| (is_first_update && this.snapshot.root_repo_common_dir.is_none())
{
cx.emit(Event::UpdatedRootRepoCommonDir {
old: old_root_repo_common_dir,
});
}
cx.notify();
while let Some((scan_id, _)) = this.snapshot_subscriptions.front() {
@@ -1221,8 +1238,9 @@ impl LocalWorktree {
.local_repo_for_work_directory_path(RelPath::empty())
.map(|repo| SanitizedPath::from_arc(repo.common_dir_abs_path.clone()));
let root_repo_common_dir_changed =
self.snapshot.root_repo_common_dir != new_snapshot.root_repo_common_dir;
let old_root_repo_common_dir = (self.snapshot.root_repo_common_dir
!= new_snapshot.root_repo_common_dir)
.then(|| self.snapshot.root_repo_common_dir.clone());
self.snapshot = new_snapshot;
if let Some(share) = self.update_observer.as_mut() {
@@ -1238,8 +1256,8 @@ impl LocalWorktree {
if !repo_changes.is_empty() {
cx.emit(Event::UpdatedGitRepositories(repo_changes));
}
if root_repo_common_dir_changed {
cx.emit(Event::UpdatedRootRepoCommonDir);
if let Some(old) = old_root_repo_common_dir {
cx.emit(Event::UpdatedRootRepoCommonDir { old });
}
while let Some((scan_id, _)) = self.snapshot_subscriptions.front() {
+192 -1
View File
@@ -9,6 +9,7 @@ use parking_lot::Mutex;
use postage::stream::Stream;
use pretty_assertions::assert_eq;
use rand::prelude::*;
use rpc::{AnyProtoClient, NoopProtoClient, proto};
use worktree::{Entry, EntryKind, Event, PathChange, Worktree, WorktreeModelHandle};
use serde_json::json;
@@ -2807,7 +2808,7 @@ async fn test_root_repo_common_dir(executor: BackgroundExecutor, cx: &mut TestAp
let event_count = event_count.clone();
|_, cx| {
cx.subscribe(&cx.entity(), move |_, _, event, _| {
if matches!(event, Event::UpdatedRootRepoCommonDir) {
if matches!(event, Event::UpdatedRootRepoCommonDir { .. }) {
event_count.set(event_count.get() + 1);
}
})
@@ -3345,3 +3346,193 @@ async fn test_single_file_worktree_deleted(cx: &mut TestAppContext) {
"Should receive Deleted event when single-file worktree root is deleted"
);
}
#[gpui::test]
async fn test_remote_worktree_without_git_emits_root_repo_event_after_first_update(
cx: &mut TestAppContext,
) {
cx.update(|cx| {
let store = SettingsStore::test(cx);
cx.set_global(store);
});
let client = AnyProtoClient::new(NoopProtoClient::new());
let worktree = cx.update(|cx| {
Worktree::remote(
1,
clock::ReplicaId::new(1),
proto::WorktreeMetadata {
id: 1,
root_name: "project".to_string(),
visible: true,
abs_path: "/home/user/project".to_string(),
root_repo_common_dir: None,
},
client,
PathStyle::Posix,
cx,
)
});
let events: Arc<std::sync::Mutex<Vec<&'static str>>> =
Arc::new(std::sync::Mutex::new(Vec::new()));
let events_clone = events.clone();
cx.update(|cx| {
cx.subscribe(&worktree, move |_, event, _cx| {
if matches!(event, Event::UpdatedRootRepoCommonDir { .. }) {
events_clone
.lock()
.unwrap()
.push("UpdatedRootRepoCommonDir");
}
if matches!(event, Event::UpdatedEntries(_)) {
events_clone.lock().unwrap().push("UpdatedEntries");
}
})
.detach();
});
// Send an update with entries but no repo info (plain directory).
worktree.update(cx, |worktree, _cx| {
worktree
.as_remote()
.unwrap()
.update_from_remote(proto::UpdateWorktree {
project_id: 1,
worktree_id: 1,
abs_path: "/home/user/project".to_string(),
root_name: "project".to_string(),
updated_entries: vec![proto::Entry {
id: 1,
is_dir: true,
path: "".to_string(),
inode: 1,
mtime: Some(proto::Timestamp {
seconds: 0,
nanos: 0,
}),
is_ignored: false,
is_hidden: false,
is_external: false,
is_fifo: false,
size: None,
canonical_path: None,
}],
removed_entries: vec![],
scan_id: 1,
is_last_update: true,
updated_repositories: vec![],
removed_repositories: vec![],
root_repo_common_dir: None,
});
});
cx.run_until_parked();
let fired = events.lock().unwrap();
assert!(
fired.contains(&"UpdatedEntries"),
"UpdatedEntries should fire after remote update"
);
assert!(
fired.contains(&"UpdatedRootRepoCommonDir"),
"UpdatedRootRepoCommonDir should fire after first remote update even when \
root_repo_common_dir is None, to signal that repo state is now known"
);
}
#[gpui::test]
async fn test_remote_worktree_with_git_emits_root_repo_event_when_repo_info_arrives(
cx: &mut TestAppContext,
) {
cx.update(|cx| {
let store = SettingsStore::test(cx);
cx.set_global(store);
});
let client = AnyProtoClient::new(NoopProtoClient::new());
let worktree = cx.update(|cx| {
Worktree::remote(
1,
clock::ReplicaId::new(1),
proto::WorktreeMetadata {
id: 1,
root_name: "project".to_string(),
visible: true,
abs_path: "/home/user/project".to_string(),
root_repo_common_dir: None,
},
client,
PathStyle::Posix,
cx,
)
});
let events: Arc<std::sync::Mutex<Vec<&'static str>>> =
Arc::new(std::sync::Mutex::new(Vec::new()));
let events_clone = events.clone();
cx.update(|cx| {
cx.subscribe(&worktree, move |_, event, _cx| {
if matches!(event, Event::UpdatedRootRepoCommonDir { .. }) {
events_clone
.lock()
.unwrap()
.push("UpdatedRootRepoCommonDir");
}
})
.detach();
});
// Send an update where repo info arrives (None -> Some).
worktree.update(cx, |worktree, _cx| {
worktree
.as_remote()
.unwrap()
.update_from_remote(proto::UpdateWorktree {
project_id: 1,
worktree_id: 1,
abs_path: "/home/user/project".to_string(),
root_name: "project".to_string(),
updated_entries: vec![proto::Entry {
id: 1,
is_dir: true,
path: "".to_string(),
inode: 1,
mtime: Some(proto::Timestamp {
seconds: 0,
nanos: 0,
}),
is_ignored: false,
is_hidden: false,
is_external: false,
is_fifo: false,
size: None,
canonical_path: None,
}],
removed_entries: vec![],
scan_id: 1,
is_last_update: true,
updated_repositories: vec![],
removed_repositories: vec![],
root_repo_common_dir: Some("/home/user/project/.git".to_string()),
});
});
cx.run_until_parked();
let fired = events.lock().unwrap();
assert!(
fired.contains(&"UpdatedRootRepoCommonDir"),
"UpdatedRootRepoCommonDir should fire when repo info arrives (None -> Some)"
);
assert_eq!(
fired
.iter()
.filter(|e| **e == "UpdatedRootRepoCommonDir")
.count(),
1,
"should fire exactly once, not duplicate"
);
}
+161 -5
View File
@@ -1508,7 +1508,7 @@ fn quit(_: &Quit, cx: &mut App) {
for window in &workspace_windows {
let window = *window;
let workspaces = window
.update(cx, |multi_workspace, _, _| {
.update(cx, |multi_workspace, _, _cx| {
multi_workspace.workspaces().cloned().collect::<Vec<_>>()
})
.log_err();
@@ -6130,10 +6130,9 @@ mod tests {
#[gpui::test]
async fn test_multi_workspace_session_restore(cx: &mut TestAppContext) {
use collections::HashMap;
use project::ProjectGroupKey;
use session::Session;
use util::path_list::PathList;
use workspace::{OpenMode, Workspace, WorkspaceId};
use workspace::{OpenMode, ProjectGroupKey, Workspace, WorkspaceId};
let app_state = init_test(cx);
@@ -6324,7 +6323,7 @@ mod tests {
restored_a
.read_with(cx, |mw, _| {
assert_eq!(
mw.project_group_keys().cloned().collect::<Vec<_>>(),
mw.project_group_keys(),
vec![
ProjectGroupKey::new(None, PathList::new(&[dir2])),
ProjectGroupKey::new(None, PathList::new(&[dir1])),
@@ -6338,11 +6337,168 @@ mod tests {
restored_b
.read_with(cx, |mw, _| {
assert_eq!(
mw.project_group_keys().cloned().collect::<Vec<_>>(),
mw.project_group_keys(),
vec![ProjectGroupKey::new(None, PathList::new(&[dir3]))]
);
assert_eq!(mw.workspaces().count(), 1);
})
.unwrap();
}
#[gpui::test]
async fn test_restored_project_groups_survive_workspace_key_change(cx: &mut TestAppContext) {
use session::Session;
use util::path_list::PathList;
use workspace::{OpenMode, ProjectGroupKey};
let app_state = init_test(cx);
let fs = app_state.fs.clone();
let fake_fs = fs.as_fake();
fake_fs
.insert_tree(path!("/root_a"), json!({ "file.txt": "" }))
.await;
fake_fs
.insert_tree(path!("/root_b"), json!({ "file.txt": "" }))
.await;
fake_fs
.insert_tree(path!("/root_c"), json!({ "file.txt": "" }))
.await;
fake_fs
.insert_tree(path!("/root_d"), json!({ "other.txt": "" }))
.await;
let session_id = cx.read(|cx| app_state.session.read(cx).id().to_owned());
// --- Phase 1: Build a multi-workspace with 3 project groups ---
let workspace::OpenResult { window, .. } = cx
.update(|cx| {
workspace::Workspace::new_local(
vec![path!("/root_a").into()],
app_state.clone(),
None,
None,
None,
OpenMode::Activate,
cx,
)
})
.await
.expect("failed to open workspace");
window.update(cx, |mw, _, cx| mw.open_sidebar(cx)).unwrap();
window
.update(cx, |mw, window, cx| {
mw.open_project(vec![path!("/root_b").into()], OpenMode::Add, window, cx)
})
.unwrap()
.await
.expect("failed to add root_b");
window
.update(cx, |mw, window, cx| {
mw.open_project(vec![path!("/root_c").into()], OpenMode::Add, window, cx)
})
.unwrap()
.await
.expect("failed to add root_c");
cx.run_until_parked();
let key_b = ProjectGroupKey::new(None, PathList::new(&[path!("/root_b")]));
let key_c = ProjectGroupKey::new(None, PathList::new(&[path!("/root_c")]));
// Make root_a the active workspace so it's the one eagerly restored.
window
.update(cx, |mw, window, cx| {
let workspace_a = mw
.workspaces()
.find(|ws| {
ws.read(cx)
.root_paths(cx)
.iter()
.any(|p| p.as_ref() == Path::new(path!("/root_a")))
})
.expect("workspace_a should exist")
.clone();
mw.activate(workspace_a, window, cx);
})
.unwrap();
cx.run_until_parked();
// --- Phase 2: Serialize, close, and restore ---
flush_workspace_serialization(&window, cx).await;
cx.run_until_parked();
window
.update(cx, |_, window, _| window.remove_window())
.unwrap();
cx.run_until_parked();
cx.update(|cx| {
app_state.session.update(cx, |app_session, _cx| {
app_session
.replace_session_for_test(Session::test_with_old_session(session_id.clone()));
});
});
let mut async_cx = cx.to_async();
crate::restore_or_create_workspace(app_state.clone(), &mut async_cx)
.await
.expect("failed to restore workspace");
cx.run_until_parked();
let restored_windows: Vec<WindowHandle<MultiWorkspace>> = cx.read(|cx| {
cx.windows()
.into_iter()
.filter_map(|w| w.downcast::<MultiWorkspace>())
.collect()
});
assert_eq!(restored_windows.len(), 1);
let restored = &restored_windows[0];
// Verify the restored window has all 3 project groups.
restored
.read_with(cx, |mw, _cx| {
let keys = mw.project_group_keys();
assert_eq!(
keys.len(),
3,
"restored window should have 3 groups; got {keys:?}"
);
assert!(keys.contains(&key_b), "should contain key_b");
assert!(keys.contains(&key_c), "should contain key_c");
})
.unwrap();
// --- Phase 3: Trigger a workspace key change and verify survival ---
let active_project = restored
.read_with(cx, |mw, cx| mw.workspace().read(cx).project().clone())
.unwrap();
active_project
.update(cx, |project, cx| {
project.find_or_create_worktree(path!("/root_d"), true, cx)
})
.await
.expect("adding worktree should succeed");
cx.run_until_parked();
restored
.read_with(cx, |mw, _cx| {
let keys = mw.project_group_keys();
assert!(
keys.contains(&key_b),
"restored group key_b should survive a workspace key change; got {keys:?}"
);
assert!(
keys.contains(&key_c),
"restored group key_c should survive a workspace key change; got {keys:?}"
);
})
.unwrap();
}
}