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:
1 parent
0d63122ad9
commit
9ffc384185
34 files changed
+7297
-3927
No files matched your search
@@ -1,4 +1,5 @@
|
||||
**/*.db
|
||||
**/*.proptest-regressions
|
||||
**/cargo-target
|
||||
**/target
|
||||
**/venv
|
||||
|
||||
@@ -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
@@ -16079,6 +16079,7 @@ dependencies = [
|
||||
"chrono",
|
||||
"client",
|
||||
"clock",
|
||||
"db",
|
||||
"editor",
|
||||
"extension",
|
||||
"fs",
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
+809
-521
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 => {}
|
||||
});
|
||||
|
||||
|
||||
@@ -4416,7 +4416,7 @@ impl LspStore {
|
||||
worktree::Event::UpdatedGitRepositories(_)
|
||||
| worktree::Event::DeletedEntry(_)
|
||||
| worktree::Event::Deleted
|
||||
| worktree::Event::UpdatedRootRepoCommonDir => {}
|
||||
| worktree::Event::UpdatedRootRepoCommonDir { .. } => {}
|
||||
})
|
||||
.detach()
|
||||
}
|
||||
|
||||
@@ -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 { .. } => {}
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"] }
|
||||
|
||||
+831
-670
File diff suppressed because it is too large.
Load diff
+2367
-1193
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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:?}"
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user