Detailed changes
@@ -4942,6 +4942,7 @@ checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04"
name = "dev_container"
version = "0.1.0"
dependencies = [
+ "fs",
"futures 0.3.31",
"gpui",
"http 1.3.1",
@@ -4951,10 +4952,12 @@ dependencies = [
"node_runtime",
"paths",
"picker",
+ "project",
"serde",
"serde_json",
"settings",
"smol",
+ "theme",
"ui",
"util",
"workspace",
@@ -8492,7 +8495,6 @@ dependencies = [
"fuzzy",
"gpui",
"language",
- "platform_title_bar",
"project",
"serde_json",
"serde_json_lenient",
@@ -12380,6 +12382,7 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
name = "platform_title_bar"
version = "0.1.0"
dependencies = [
+ "feature_flags",
"gpui",
"settings",
"smallvec",
@@ -15339,6 +15342,30 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
+[[package]]
+name = "sidebar"
+version = "0.1.0"
+dependencies = [
+ "acp_thread",
+ "agent_ui",
+ "db",
+ "editor",
+ "feature_flags",
+ "fs",
+ "fuzzy",
+ "gpui",
+ "picker",
+ "project",
+ "recent_projects",
+ "serde_json",
+ "settings",
+ "theme",
+ "ui",
+ "ui_input",
+ "util",
+ "workspace",
+]
+
[[package]]
name = "signal-hook"
version = "0.3.18"
@@ -17218,6 +17245,7 @@ dependencies = [
"cloud_api_types",
"collections",
"db",
+ "feature_flags",
"git_ui",
"gpui",
"http_client",
@@ -21103,6 +21131,7 @@ dependencies = [
"settings_profile_selector",
"settings_ui",
"shellexpand 2.1.2",
+ "sidebar",
"smol",
"snippet_provider",
"snippets_ui",
@@ -155,6 +155,7 @@ members = [
"crates/schema_generator",
"crates/search",
"crates/session",
+ "crates/sidebar",
"crates/settings",
"crates/settings_content",
"crates/settings_json",
@@ -395,6 +396,7 @@ rules_library = { path = "crates/rules_library" }
scheduler = { path = "crates/scheduler" }
search = { path = "crates/search" }
session = { path = "crates/session" }
+sidebar = { path = "crates/sidebar" }
settings = { path = "crates/settings" }
settings_content = { path = "crates/settings_content" }
settings_json = { path = "crates/settings_json" }
@@ -853,6 +855,7 @@ refineable = { codegen-units = 1 }
release_channel = { codegen-units = 1 }
reqwest_client = { codegen-units = 1 }
session = { codegen-units = 1 }
+sidebar = { codegen-units = 1 }
snippet = { codegen-units = 1 }
snippets_ui = { codegen-units = 1 }
story = { codegen-units = 1 }
@@ -0,0 +1,5 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<rect opacity="0.2" width="7" height="12" rx="2" transform="matrix(-1 0 0 1 9 2)" fill="#C6CAD0"/>
+<path d="M9 2V14" stroke="#C6CAD0" stroke-width="1.2"/>
+<rect x="2" y="2" width="12" height="12" rx="2" stroke="#C6CAD0" stroke-width="1.2"/>
+</svg>
@@ -0,0 +1,5 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<rect width="7" height="12" rx="2" transform="matrix(-1 0 0 1 9 2)" fill="#C6CAD0"/>
+<path d="M9 2V14" stroke="#C6CAD0" stroke-width="1.2"/>
+<rect x="2" y="2" width="12" height="12" rx="2" stroke="#C6CAD0" stroke-width="1.2"/>
+</svg>
@@ -594,6 +594,7 @@
"ctrl-alt-b": "workspace::ToggleRightDock",
"ctrl-b": "workspace::ToggleLeftDock",
"ctrl-j": "workspace::ToggleBottomDock",
+ "ctrl-alt-j": "multi_workspace::ToggleWorkspaceSidebar",
"ctrl-alt-y": "workspace::ToggleAllDocks",
"ctrl-alt-0": "workspace::ResetActiveDockSize",
// For 0px parameter, uses UI font size value.
@@ -653,6 +654,13 @@
"ctrl-w": "workspace::CloseActiveDock",
},
},
+ {
+ "context": "WorkspaceSidebar",
+ "use_key_equivalents": true,
+ "bindings": {
+ "ctrl-n": "multi_workspace::NewWorkspaceInWindow",
+ },
+ },
{
"context": "Workspace && debugger_running",
"bindings": {
@@ -655,6 +655,7 @@
"cmd-alt-b": "workspace::ToggleRightDock",
"cmd-r": "workspace::ToggleRightDock",
"cmd-j": "workspace::ToggleBottomDock",
+ "cmd-alt-j": "multi_workspace::ToggleWorkspaceSidebar",
"alt-cmd-y": "workspace::ToggleAllDocks",
// For 0px parameter, uses UI font size value.
"ctrl-alt-0": "workspace::ResetActiveDockSize",
@@ -714,6 +715,13 @@
// "foo-bar": ["task::Spawn", { "task_tag": "MyTag" }],
},
},
+ {
+ "context": "WorkspaceSidebar",
+ "use_key_equivalents": true,
+ "bindings": {
+ "cmd-n": "multi_workspace::NewWorkspaceInWindow",
+ },
+ },
{
"context": "Workspace && debugger_running",
"use_key_equivalents": true,
@@ -589,6 +589,7 @@
"ctrl-alt-b": "workspace::ToggleRightDock",
"ctrl-b": "workspace::ToggleLeftDock",
"ctrl-j": "workspace::ToggleBottomDock",
+ "ctrl-alt-j": "multi_workspace::ToggleWorkspaceSidebar",
"ctrl-shift-y": "workspace::ToggleAllDocks",
"alt-r": "workspace::ResetActiveDockSize",
// For 0px parameter, uses UI font size value.
@@ -657,6 +658,13 @@
"f5": "debugger::Continue",
},
},
+ {
+ "context": "WorkspaceSidebar",
+ "use_key_equivalents": true,
+ "bindings": {
+ "ctrl-n": "multi_workspace::NewWorkspaceInWindow",
+ },
+ },
{
"context": "ApplicationMenu",
"use_key_equivalents": true,
@@ -5,7 +5,7 @@ mod mode_selector;
mod model_selector;
mod model_selector_popover;
mod thread_history;
-mod thread_view;
+pub(crate) mod thread_view;
pub use mode_selector::ModeSelector;
pub use model_selector::AcpModelSelector;
@@ -815,8 +815,13 @@ impl MessageEditor {
}
if self.prompt_capabilities.borrow().image
- && let Some(task) =
- paste_images_as_context(self.editor.clone(), self.mention_set.clone(), window, cx)
+ && let Some(task) = paste_images_as_context(
+ self.editor.clone(),
+ self.mention_set.clone(),
+ self.workspace.clone(),
+ window,
+ cx,
+ )
{
task.detach();
return;
@@ -1084,6 +1089,7 @@ impl MessageEditor {
let editor = self.editor.clone();
let mention_set = self.mention_set.clone();
+ let workspace = self.workspace.clone();
let paths_receiver = cx.prompt_for_paths(gpui::PathPromptOptions {
files: true,
@@ -1134,7 +1140,14 @@ impl MessageEditor {
images.push(gpui::Image::from_bytes(format, content));
}
- crate::mention_set::insert_images_as_context(images, editor, mention_set, cx).await;
+ crate::mention_set::insert_images_as_context(
+ images,
+ editor,
+ mention_set,
+ workspace,
+ cx,
+ )
+ .await;
Ok(())
})
.detach_and_log_err(cx);
@@ -57,7 +57,9 @@ use ui::{
};
use util::defer;
use util::{ResultExt, size::format_file_size, time::duration_alt_display};
-use workspace::{CollaboratorId, NewTerminal, Toast, Workspace, notifications::NotificationId};
+use workspace::{
+ CollaboratorId, MultiWorkspace, NewTerminal, Toast, Workspace, notifications::NotificationId,
+};
use zed_actions::agent::{Chat, ToggleModelSelector};
use zed_actions::assistant::OpenRulesLibrary;
@@ -1985,9 +1987,30 @@ impl AcpServerView {
self.show_notification(caption, icon, window, cx);
}
+ fn agent_is_visible(&self, window: &Window, cx: &App) -> bool {
+ if window.is_window_active() {
+ let workspace_is_foreground = window
+ .root::<MultiWorkspace>()
+ .flatten()
+ .and_then(|mw| {
+ let mw = mw.read(cx);
+ self.workspace.upgrade().map(|ws| mw.workspace() == &ws)
+ })
+ .unwrap_or(true);
+
+ if workspace_is_foreground {
+ if let Some(workspace) = self.workspace.upgrade() {
+ return AgentPanel::is_visible(&workspace, cx);
+ }
+ }
+ }
+
+ false
+ }
+
fn play_notification_sound(&self, window: &Window, cx: &mut App) {
let settings = AgentSettings::get_global(cx);
- if settings.play_sound_when_agent_done && !window.is_window_active() {
+ if settings.play_sound_when_agent_done && !self.agent_is_visible(window, cx) {
Audio::play_sound(Sound::AgentDone, cx);
}
}
@@ -2005,14 +2028,7 @@ impl AcpServerView {
let settings = AgentSettings::get_global(cx);
- let window_is_inactive = !window.is_window_active();
- let panel_is_hidden = self
- .workspace
- .upgrade()
- .map(|workspace| AgentPanel::is_hidden(&workspace, cx))
- .unwrap_or(true);
-
- let should_notify = window_is_inactive || panel_is_hidden;
+ let should_notify = !self.agent_is_visible(window, cx);
if !should_notify {
return;
@@ -2075,19 +2091,22 @@ impl AcpServerView {
.push(cx.subscribe_in(&pop_up, window, {
|this, _, event, window, cx| match event {
AgentNotificationEvent::Accepted => {
- let handle = window.window_handle();
+ let Some(handle) = window.window_handle().downcast::<MultiWorkspace>()
+ else {
+ log::error!("root view should be a MultiWorkspace");
+ return;
+ };
cx.activate(true);
let workspace_handle = this.workspace.clone();
- // If there are multiple Zed windows, activate the correct one.
cx.defer(move |cx| {
handle
- .update(cx, |_view, window, _cx| {
+ .update(cx, |multi_workspace, window, cx| {
window.activate_window();
-
if let Some(workspace) = workspace_handle.upgrade() {
- workspace.update(_cx, |workspace, cx| {
+ multi_workspace.activate(workspace.clone(), cx);
+ workspace.update(cx, |workspace, cx| {
workspace.focus_panel::<AgentPanel>(window, cx);
});
}
@@ -2112,12 +2131,12 @@ impl AcpServerView {
.push({
let pop_up_weak = pop_up.downgrade();
- cx.observe_window_activation(window, move |_, window, cx| {
- if window.is_window_active()
+ cx.observe_window_activation(window, move |this, window, cx| {
+ if this.agent_is_visible(window, cx)
&& let Some(pop_up) = pop_up_weak.upgrade()
{
- pop_up.update(cx, |_, cx| {
- cx.emit(AgentNotificationEvent::Dismissed);
+ pop_up.update(cx, |notification, cx| {
+ notification.dismiss(cx);
});
}
})
@@ -2368,6 +2387,7 @@ pub(crate) mod tests {
use action_log::ActionLog;
use agent::{AgentTool, EditFileTool, FetchTool, TerminalTool, ToolPermissionContext};
use agent_client_protocol::SessionId;
+ use assistant_text_thread::TextThreadStore;
use editor::MultiBufferOffset;
use fs::FakeFs;
use gpui::{EventEmitter, TestAppContext, VisualTestContext};
@@ -2377,7 +2397,7 @@ pub(crate) mod tests {
use std::any::Any;
use std::path::Path;
use std::rc::Rc;
- use workspace::Item;
+ use workspace::{Item, MultiWorkspace};
use super::*;
@@ -2677,6 +2697,138 @@ pub(crate) mod tests {
);
}
+ #[gpui::test]
+ async fn test_notification_when_workspace_is_background_in_multi_workspace(
+ cx: &mut TestAppContext,
+ ) {
+ init_test(cx);
+
+ // Enable multi-workspace feature flag and init globals needed by AgentPanel
+ let fs = FakeFs::new(cx.executor());
+
+ cx.update(|cx| {
+ cx.update_flags(true, vec!["agent-v2".to_string()]);
+ agent::ThreadStore::init_global(cx);
+ language_model::LanguageModelRegistry::test(cx);
+ <dyn Fs>::set_global(fs.clone(), cx);
+ });
+
+ let project1 = Project::test(fs.clone(), [], cx).await;
+
+ // Create a MultiWorkspace window with one workspace
+ let multi_workspace_handle =
+ cx.add_window(|window, cx| MultiWorkspace::test_new(project1.clone(), window, cx));
+
+ // Get workspace 1 (the initial workspace)
+ let workspace1 = multi_workspace_handle
+ .read_with(cx, |mw, _cx| mw.workspace().clone())
+ .unwrap();
+
+ let cx = &mut VisualTestContext::from_window(multi_workspace_handle.into(), cx);
+
+ workspace1.update_in(cx, |workspace, window, cx| {
+ let text_thread_store =
+ cx.new(|cx| TextThreadStore::fake(workspace.project().clone(), cx));
+ let panel =
+ cx.new(|cx| crate::AgentPanel::new(workspace, text_thread_store, None, window, cx));
+ workspace.add_panel(panel, window, cx);
+
+ // Open the dock and activate the agent panel so it's visible
+ workspace.focus_panel::<crate::AgentPanel>(window, cx);
+ });
+
+ cx.run_until_parked();
+
+ cx.read(|cx| {
+ assert!(
+ crate::AgentPanel::is_visible(&workspace1, cx),
+ "AgentPanel should be visible in workspace1's dock"
+ );
+ });
+
+ // Set up thread view in workspace 1
+ let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
+ let history = cx.update(|window, cx| cx.new(|cx| AcpThreadHistory::new(None, window, cx)));
+
+ let agent = StubAgentServer::default_response();
+ let thread_view = cx.update(|window, cx| {
+ cx.new(|cx| {
+ AcpServerView::new(
+ Rc::new(agent),
+ None,
+ None,
+ workspace1.downgrade(),
+ project1.clone(),
+ Some(thread_store),
+ None,
+ history,
+ window,
+ cx,
+ )
+ })
+ });
+ cx.run_until_parked();
+
+ let message_editor = message_editor(&thread_view, cx);
+ message_editor.update_in(cx, |editor, window, cx| {
+ editor.set_text("Hello", window, cx);
+ });
+
+ // Create a second workspace and switch to it.
+ // This makes workspace1 the "background" workspace.
+ let project2 = Project::test(fs, [], cx).await;
+ multi_workspace_handle
+ .update(cx, |mw, window, cx| {
+ let workspace2 = cx.new(|cx| Workspace::test_new(project2, window, cx));
+ mw.activate(workspace2, cx);
+ })
+ .unwrap();
+
+ cx.run_until_parked();
+
+ // Verify workspace1 is no longer the active workspace
+ multi_workspace_handle
+ .read_with(cx, |mw, _cx| {
+ assert_eq!(mw.active_workspace_index(), 1);
+ assert_ne!(mw.workspace(), &workspace1);
+ })
+ .unwrap();
+
+ // Window is active, agent panel is visible in workspace1, but workspace1
+ // is in the background. The notification should show because the user
+ // can't actually see the agent panel.
+ active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx));
+
+ cx.run_until_parked();
+
+ assert!(
+ cx.windows()
+ .iter()
+ .any(|window| window.downcast::<AgentNotification>().is_some()),
+ "Expected notification when workspace is in background within MultiWorkspace"
+ );
+
+ // Also verify: clicking "View Panel" should switch to workspace1.
+ cx.windows()
+ .iter()
+ .find_map(|window| window.downcast::<AgentNotification>())
+ .unwrap()
+ .update(cx, |window, _, cx| window.accept(cx))
+ .unwrap();
+
+ cx.run_until_parked();
+
+ multi_workspace_handle
+ .read_with(cx, |mw, _cx| {
+ assert_eq!(
+ mw.workspace(),
+ &workspace1,
+ "Expected workspace1 to become the active workspace after accepting notification"
+ );
+ })
+ .unwrap();
+ }
+
#[gpui::test]
async fn test_notification_respects_never_setting(cx: &mut TestAppContext) {
init_test(cx);
@@ -2839,18 +2991,18 @@ pub(crate) mod tests {
}
}
- struct StubAgentServer<C> {
+ pub(crate) struct StubAgentServer<C> {
connection: C,
}
impl<C> StubAgentServer<C> {
- fn new(connection: C) -> Self {
+ pub(crate) fn new(connection: C) -> Self {
Self { connection }
}
}
impl StubAgentServer<StubAgentConnection> {
- fn default_response() -> Self {
+ pub(crate) fn default_response() -> Self {
let conn = StubAgentConnection::new();
conn.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
acp::ContentChunk::new("Default response".into()),
@@ -1352,10 +1352,10 @@ impl AgentDiff {
self.update_reviewing_editors(workspace, window, cx);
}
}
- AcpThreadEvent::Stopped
- | AcpThreadEvent::Error
- | AcpThreadEvent::LoadError(_)
- | AcpThreadEvent::Refusal => {
+ AcpThreadEvent::Stopped => {
+ self.update_reviewing_editors(workspace, window, cx);
+ }
+ AcpThreadEvent::Error | AcpThreadEvent::LoadError(_) | AcpThreadEvent::Refusal => {
self.update_reviewing_editors(workspace, window, cx);
}
AcpThreadEvent::TitleUpdated
@@ -81,10 +81,50 @@ const AGENT_PANEL_KEY: &str = "agent_panel";
const RECENTLY_UPDATED_MENU_LIMIT: usize = 6;
const DEFAULT_THREAD_TITLE: &str = "New Thread";
-#[derive(Serialize, Deserialize, Debug)]
+fn read_serialized_panel(workspace_id: workspace::WorkspaceId) -> Option<SerializedAgentPanel> {
+ let scope = KEY_VALUE_STORE.scoped(AGENT_PANEL_KEY);
+ let key = i64::from(workspace_id).to_string();
+ scope
+ .read(&key)
+ .log_err()
+ .flatten()
+ .and_then(|json| serde_json::from_str::<SerializedAgentPanel>(&json).log_err())
+}
+
+async fn save_serialized_panel(
+ workspace_id: workspace::WorkspaceId,
+ panel: SerializedAgentPanel,
+) -> Result<()> {
+ let scope = KEY_VALUE_STORE.scoped(AGENT_PANEL_KEY);
+ let key = i64::from(workspace_id).to_string();
+ scope.write(key, serde_json::to_string(&panel)?).await?;
+ Ok(())
+}
+
+/// Migration: reads the original single-panel format stored under the
+/// `"agent_panel"` KVP key before per-workspace keying was introduced.
+fn read_legacy_serialized_panel() -> Option<SerializedAgentPanel> {
+ KEY_VALUE_STORE
+ .read_kvp(AGENT_PANEL_KEY)
+ .log_err()
+ .flatten()
+ .and_then(|json| serde_json::from_str::<SerializedAgentPanel>(&json).log_err())
+}
+
+#[derive(Serialize, Deserialize, Debug, Clone)]
struct SerializedAgentPanel {
width: Option<Pixels>,
selected_agent: Option<AgentType>,
+ #[serde(default)]
+ last_active_thread: Option<SerializedActiveThread>,
+}
+
+#[derive(Serialize, Deserialize, Debug, Clone)]
+struct SerializedActiveThread {
+ session_id: String,
+ agent_type: AgentType,
+ title: Option<String>,
+ cwd: Option<std::path::PathBuf>,
}
pub fn init(cx: &mut App) {
@@ -428,6 +468,7 @@ pub struct AgentPanel {
focus_handle: FocusHandle,
active_view: ActiveView,
previous_view: Option<ActiveView>,
+ _active_view_observation: Option<Subscription>,
new_thread_menu_handle: PopoverMenuHandle<ContextMenu>,
agent_panel_menu_handle: PopoverMenuHandle<ContextMenu>,
agent_navigation_menu_handle: PopoverMenuHandle<ContextMenu>,
@@ -445,18 +486,44 @@ pub struct AgentPanel {
impl AgentPanel {
fn serialize(&mut self, cx: &mut Context<Self>) {
+ let workspace_id = self
+ .workspace
+ .read_with(cx, |workspace, _| workspace.database_id())
+ .ok()
+ .flatten();
+
+ let Some(workspace_id) = workspace_id else {
+ return;
+ };
+
let width = self.width;
let selected_agent = self.selected_agent.clone();
+
+ let last_active_thread = self.active_agent_thread(cx).map(|thread| {
+ let thread = thread.read(cx);
+ let title = thread.title();
+ SerializedActiveThread {
+ session_id: thread.session_id().0.to_string(),
+ agent_type: self.selected_agent.clone(),
+ title: if title.as_ref() != DEFAULT_THREAD_TITLE {
+ Some(title.to_string())
+ } else {
+ None
+ },
+ cwd: None,
+ }
+ });
+
self.pending_serialization = Some(cx.background_spawn(async move {
- KEY_VALUE_STORE
- .write_kvp(
- AGENT_PANEL_KEY.into(),
- serde_json::to_string(&SerializedAgentPanel {
- width,
- selected_agent: Some(selected_agent),
- })?,
- )
- .await?;
+ save_serialized_panel(
+ workspace_id,
+ SerializedAgentPanel {
+ width,
+ selected_agent: Some(selected_agent),
+ last_active_thread,
+ },
+ )
+ .await?;
anyhow::Ok(())
}));
}
@@ -472,16 +539,18 @@ impl AgentPanel {
Ok(prompt_store) => prompt_store.await.ok(),
Err(_) => None,
};
- let serialized_panel = if let Some(panel) = cx
- .background_spawn(async move { KEY_VALUE_STORE.read_kvp(AGENT_PANEL_KEY) })
- .await
- .log_err()
- .flatten()
- {
- serde_json::from_str::<SerializedAgentPanel>(&panel).log_err()
- } else {
- None
- };
+ let workspace_id = workspace
+ .read_with(cx, |workspace, _| workspace.database_id())
+ .ok()
+ .flatten();
+
+ let serialized_panel = cx
+ .background_spawn(async move {
+ workspace_id
+ .and_then(read_serialized_panel)
+ .or_else(read_legacy_serialized_panel)
+ })
+ .await;
let slash_commands = Arc::new(SlashCommandWorkingSet::default());
let text_thread_store = workspace
@@ -500,15 +569,30 @@ impl AgentPanel {
let panel =
cx.new(|cx| Self::new(workspace, text_thread_store, prompt_store, window, cx));
- if let Some(serialized_panel) = serialized_panel {
+ if let Some(serialized_panel) = &serialized_panel {
panel.update(cx, |panel, cx| {
panel.width = serialized_panel.width.map(|w| w.round());
- if let Some(selected_agent) = serialized_panel.selected_agent {
+ if let Some(selected_agent) = serialized_panel.selected_agent.clone() {
panel.selected_agent = selected_agent;
}
cx.notify();
});
}
+
+ if let Some(thread_info) = serialized_panel.and_then(|p| p.last_active_thread) {
+ let agent_type = thread_info.agent_type.clone();
+ let session_info = AgentSessionInfo {
+ session_id: acp::SessionId::new(thread_info.session_id),
+ cwd: thread_info.cwd,
+ title: thread_info.title.map(SharedString::from),
+ updated_at: None,
+ meta: None,
+ };
+ panel.update(cx, |panel, cx| {
+ panel.selected_agent = agent_type;
+ panel.load_agent_thread(session_info, window, cx);
+ });
+ }
panel
})?;
@@ -516,7 +600,7 @@ impl AgentPanel {
})
}
- fn new(
+ pub(crate) fn new(
workspace: &Workspace,
text_thread_store: Entity<assistant_text_thread::TextThreadStore>,
prompt_store: Option<Entity<PromptStore>>,
@@ -646,6 +730,7 @@ impl AgentPanel {
focus_handle: cx.focus_handle(),
context_server_registry,
previous_view: None,
+ _active_view_observation: None,
new_thread_menu_handle: PopoverMenuHandle::default(),
agent_panel_menu_handle: PopoverMenuHandle::default(),
agent_navigation_menu_handle: PopoverMenuHandle::default(),
@@ -714,7 +799,7 @@ impl AgentPanel {
&self.context_server_registry
}
- pub fn is_hidden(workspace: &Entity<Workspace>, cx: &App) -> bool {
+ pub fn is_visible(workspace: &Entity<Workspace>, cx: &App) -> bool {
let workspace_read = workspace.read(cx);
workspace_read
@@ -722,15 +807,13 @@ impl AgentPanel {
.map(|panel| {
let panel_id = Entity::entity_id(&panel);
- let is_visible = workspace_read.all_docks().iter().any(|dock| {
+ workspace_read.all_docks().iter().any(|dock| {
dock.read(cx)
.visible_panel()
.is_some_and(|visible_panel| visible_panel.panel_id() == panel_id)
- });
-
- !is_visible
+ })
})
- .unwrap_or(true)
+ .unwrap_or(false)
}
pub(crate) fn active_thread_view(&self) -> Option<&Entity<AcpServerView>> {
@@ -1023,6 +1106,7 @@ impl AgentPanel {
ActiveView::Configuration | ActiveView::History { .. } => {
if let Some(previous_view) = self.previous_view.take() {
self.active_view = previous_view;
+ cx.emit(AgentPanelEvent::ActiveViewChanged);
match &self.active_view {
ActiveView::AgentThread { thread_view } => {
@@ -1419,7 +1503,7 @@ impl AgentPanel {
}
}
- pub(crate) fn active_agent_thread(&self, cx: &App) -> Option<Entity<AcpThread>> {
+ pub fn active_agent_thread(&self, cx: &App) -> Option<Entity<AcpThread>> {
match &self.active_view {
ActiveView::AgentThread { thread_view, .. } => thread_view
.read(cx)
@@ -1475,9 +1559,21 @@ impl AgentPanel {
self.active_view = new_view;
}
+ self._active_view_observation = match &self.active_view {
+ ActiveView::AgentThread { thread_view } => {
+ Some(cx.observe(thread_view, |this, _, cx| {
+ cx.emit(AgentPanelEvent::ActiveViewChanged);
+ this.serialize(cx);
+ cx.notify();
+ }))
+ }
+ _ => None,
+ };
+
if focus {
self.focus_handle(cx).focus(window, cx);
}
+ cx.emit(AgentPanelEvent::ActiveViewChanged);
}
fn populate_recently_updated_menu_section(
@@ -1750,7 +1846,12 @@ fn agent_panel_dock_position(cx: &App) -> DockPosition {
AgentSettings::get_global(cx).dock.into()
}
+pub enum AgentPanelEvent {
+ ActiveViewChanged,
+}
+
impl EventEmitter<PanelEvent> for AgentPanel {}
+impl EventEmitter<AgentPanelEvent> for AgentPanel {}
impl Panel for AgentPanel {
fn persistent_name() -> &'static str {
@@ -3284,3 +3385,151 @@ impl AgentPanel {
self.active_thread_view()
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::acp::thread_view::tests::{StubAgentServer, init_test};
+ use assistant_text_thread::TextThreadStore;
+ use feature_flags::FeatureFlagAppExt;
+ use fs::FakeFs;
+ use gpui::{TestAppContext, VisualTestContext};
+ use project::Project;
+ use workspace::{MultiWorkspace, Workspace};
+
+ #[gpui::test]
+ async fn test_active_thread_serialize_and_load_round_trip(cx: &mut TestAppContext) {
+ init_test(cx);
+ cx.update(|cx| {
+ cx.update_flags(true, vec!["agent-v2".to_string()]);
+ agent::ThreadStore::init_global(cx);
+ language_model::LanguageModelRegistry::test(cx);
+ });
+
+ // --- Create a MultiWorkspace window with two workspaces ---
+ let fs = FakeFs::new(cx.executor());
+ let project_a = Project::test(fs.clone(), [], cx).await;
+ let project_b = Project::test(fs, [], cx).await;
+
+ let multi_workspace =
+ cx.add_window(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
+
+ let workspace_a = multi_workspace
+ .read_with(cx, |multi_workspace, _cx| {
+ multi_workspace.workspace().clone()
+ })
+ .unwrap();
+
+ let workspace_b = multi_workspace
+ .update(cx, |multi_workspace, window, cx| {
+ let workspace = cx.new(|cx| Workspace::test_new(project_b.clone(), window, cx));
+ multi_workspace.activate(workspace.clone(), cx);
+ workspace
+ })
+ .unwrap();
+
+ workspace_a.update(cx, |workspace, _cx| {
+ workspace.set_random_database_id();
+ });
+ workspace_b.update(cx, |workspace, _cx| {
+ workspace.set_random_database_id();
+ });
+
+ let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
+
+ // --- Set up workspace A: width=300, with an active thread ---
+ let panel_a = workspace_a.update_in(cx, |workspace, window, cx| {
+ let text_thread_store = cx.new(|cx| TextThreadStore::fake(project_a.clone(), cx));
+ cx.new(|cx| AgentPanel::new(workspace, text_thread_store, None, window, cx))
+ });
+
+ panel_a.update(cx, |panel, _cx| {
+ panel.width = Some(px(300.0));
+ });
+
+ panel_a.update_in(cx, |panel, window, cx| {
+ panel.open_external_thread_with_server(
+ Rc::new(StubAgentServer::default_response()),
+ window,
+ cx,
+ );
+ });
+
+ cx.run_until_parked();
+
+ panel_a.read_with(cx, |panel, cx| {
+ assert!(
+ panel.active_agent_thread(cx).is_some(),
+ "workspace A should have an active thread after connection"
+ );
+ });
+
+ let agent_type_a = panel_a.read_with(cx, |panel, _cx| panel.selected_agent.clone());
+
+ // --- Set up workspace B: ClaudeCode, width=400, no active thread ---
+ let panel_b = workspace_b.update_in(cx, |workspace, window, cx| {
+ let text_thread_store = cx.new(|cx| TextThreadStore::fake(project_b.clone(), cx));
+ cx.new(|cx| AgentPanel::new(workspace, text_thread_store, None, window, cx))
+ });
+
+ panel_b.update(cx, |panel, _cx| {
+ panel.width = Some(px(400.0));
+ panel.selected_agent = AgentType::ClaudeCode;
+ });
+
+ // --- Serialize both panels ---
+ panel_a.update(cx, |panel, cx| panel.serialize(cx));
+ panel_b.update(cx, |panel, cx| panel.serialize(cx));
+ cx.run_until_parked();
+
+ // --- Load fresh panels for each workspace and verify independent state ---
+ let prompt_builder = Arc::new(prompt_store::PromptBuilder::new(None).unwrap());
+
+ let async_cx = cx.update(|window, cx| window.to_async(cx));
+ let loaded_a = AgentPanel::load(workspace_a.downgrade(), prompt_builder.clone(), async_cx)
+ .await
+ .expect("panel A load should succeed");
+ cx.run_until_parked();
+
+ let async_cx = cx.update(|window, cx| window.to_async(cx));
+ let loaded_b = AgentPanel::load(workspace_b.downgrade(), prompt_builder.clone(), async_cx)
+ .await
+ .expect("panel B load should succeed");
+ cx.run_until_parked();
+
+ // Workspace A should restore its thread, width, and agent type
+ loaded_a.read_with(cx, |panel, _cx| {
+ assert_eq!(
+ panel.width,
+ Some(px(300.0)),
+ "workspace A width should be restored"
+ );
+ assert_eq!(
+ panel.selected_agent, agent_type_a,
+ "workspace A agent type should be restored"
+ );
+ assert!(
+ panel.active_thread_view().is_some(),
+ "workspace A should have its active thread restored"
+ );
+ });
+
+ // Workspace B should restore its own width and agent type, with no thread
+ loaded_b.read_with(cx, |panel, _cx| {
+ assert_eq!(
+ panel.width,
+ Some(px(400.0)),
+ "workspace B width should be restored"
+ );
+ assert_eq!(
+ panel.selected_agent,
+ AgentType::ClaudeCode,
+ "workspace B agent type should be restored"
+ );
+ assert!(
+ panel.active_thread_view().is_none(),
+ "workspace B should have no active thread"
+ );
+ });
+ }
+}
@@ -49,7 +49,7 @@ use std::any::TypeId;
use workspace::Workspace;
use crate::agent_configuration::{ConfigureContextServerModal, ManageProfilesModal};
-pub use crate::agent_panel::{AgentPanel, ConcreteAssistantPanelDelegate};
+pub use crate::agent_panel::{AgentPanel, AgentPanelEvent, ConcreteAssistantPanelDelegate};
use crate::agent_registry_ui::AgentRegistryPage;
pub use crate::inline_assistant::InlineAssistant;
pub use agent_diff::{AgentDiffPane, AgentDiffToolbar};
@@ -418,6 +418,12 @@ fn update_command_palette_filter(cx: &mut App) {
filter.hide_action_types(&[TypeId::of::<zed_actions::agent::ToggleAgentPane>()]);
}
}
+
+ if agent_v2_enabled {
+ filter.show_namespace("multi_workspace");
+ } else {
+ filter.hide_namespace("multi_workspace");
+ }
});
}
@@ -417,8 +417,13 @@ impl<T: 'static> PromptEditor<T> {
fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context<Self>) {
if inline_assistant_model_supports_images(cx)
- && let Some(task) =
- paste_images_as_context(self.editor.clone(), self.mention_set.clone(), window, cx)
+ && let Some(task) = paste_images_as_context(
+ self.editor.clone(),
+ self.mention_set.clone(),
+ self.workspace.clone(),
+ window,
+ cx,
+ )
{
task.detach();
}
@@ -297,8 +297,9 @@ impl MentionSet {
self.mentions.insert(crease_id, (mention_uri, task.clone()));
// Notify the user if we failed to load the mentioned context
- cx.spawn_in(window, async move |this, cx| {
- let result = task.await.notify_async_err(cx);
+ let workspace = workspace.downgrade();
+ cx.spawn(async move |this, mut cx| {
+ let result = task.await.notify_workspace_async_err(workspace, &mut cx);
drop(tx);
if result.is_none() {
this.update(cx, |this, cx| {
@@ -644,6 +645,7 @@ pub(crate) async fn insert_images_as_context(
images: Vec<gpui::Image>,
editor: Entity<Editor>,
mention_set: Entity<MentionSet>,
+ workspace: WeakEntity<Workspace>,
cx: &mut gpui::AsyncWindowContext,
) {
if images.is_empty() {
@@ -723,7 +725,11 @@ pub(crate) async fn insert_images_as_context(
mention_set.insert_mention(crease_id, MentionUri::PastedImage, task.clone())
});
- if task.await.notify_async_err(cx).is_none() {
+ if task
+ .await
+ .notify_workspace_async_err(workspace.clone(), cx)
+ .is_none()
+ {
editor.update(cx, |editor, cx| {
editor.edit([(start_anchor..end_anchor, "")], cx);
});
@@ -737,11 +743,12 @@ pub(crate) async fn insert_images_as_context(
pub(crate) fn paste_images_as_context(
editor: Entity<Editor>,
mention_set: Entity<MentionSet>,
+ workspace: WeakEntity<Workspace>,
window: &mut Window,
cx: &mut App,
) -> Option<Task<()>> {
let clipboard = cx.read_from_clipboard()?;
- Some(window.spawn(cx, async move |cx| {
+ Some(window.spawn(cx, async move |mut cx| {
use itertools::Itertools;
let (mut images, paths) = clipboard
.into_entries()
@@ -788,7 +795,7 @@ pub(crate) fn paste_images_as_context(
})
.ok();
- insert_images_as_context(images, editor, mention_set, cx).await;
+ insert_images_as_context(images, editor, mention_set, workspace, &mut cx).await;
}))
}
@@ -75,6 +75,16 @@ pub enum AgentNotificationEvent {
impl EventEmitter<AgentNotificationEvent> for AgentNotification {}
+impl AgentNotification {
+ pub fn accept(&mut self, cx: &mut Context<Self>) {
+ cx.emit(AgentNotificationEvent::Accepted);
+ }
+
+ pub fn dismiss(&mut self, cx: &mut Context<Self>) {
+ cx.emit(AgentNotificationEvent::Dismissed);
+ }
+}
+
impl Render for AgentNotification {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let ui_font = theme::setup_ui_font(window, cx);
@@ -174,14 +184,14 @@ impl Render for AgentNotification {
.style(ButtonStyle::Tinted(ui::TintColor::Accent))
.full_width()
.on_click({
- cx.listener(move |_this, _event, _, cx| {
- cx.emit(AgentNotificationEvent::Accepted);
+ cx.listener(move |this, _event, _, cx| {
+ this.accept(cx);
})
}),
)
.child(Button::new("dismiss", "Dismiss").full_width().on_click({
- cx.listener(move |_, _event, _, cx| {
- cx.emit(AgentNotificationEvent::Dismissed);
+ cx.listener(move |this, _event, _, cx| {
+ this.dismiss(cx);
})
})),
)
@@ -34,9 +34,11 @@ async fn test_channel_guests(
cx_a.executor().run_until_parked();
// Client B joins channel A as a guest
- cx_b.update(|cx| workspace::join_channel(channel_id, client_b.app_state.clone(), None, cx))
- .await
- .unwrap();
+ cx_b.update(|cx| {
+ workspace::join_channel(channel_id, client_b.app_state.clone(), None, None, cx)
+ })
+ .await
+ .unwrap();
// b should be following a in the shared project.
// B is a guest,
@@ -76,9 +78,11 @@ async fn test_channel_guest_promotion(cx_a: &mut TestAppContext, cx_b: &mut Test
.await;
let project_a = client_a.build_test_project(cx_a).await;
- cx_a.update(|cx| workspace::join_channel(channel_id, client_a.app_state.clone(), None, cx))
- .await
- .unwrap();
+ cx_a.update(|cx| {
+ workspace::join_channel(channel_id, client_a.app_state.clone(), None, None, cx)
+ })
+ .await
+ .unwrap();
// Client A shares a project in the channel
active_call_a
@@ -88,9 +92,11 @@ async fn test_channel_guest_promotion(cx_a: &mut TestAppContext, cx_b: &mut Test
cx_a.run_until_parked();
// Client B joins channel A as a guest
- cx_b.update(|cx| workspace::join_channel(channel_id, client_b.app_state.clone(), None, cx))
- .await
- .unwrap();
+ cx_b.update(|cx| {
+ workspace::join_channel(channel_id, client_b.app_state.clone(), None, None, cx)
+ })
+ .await
+ .unwrap();
cx_a.run_until_parked();
// client B opens 1.txt as a guest
@@ -19,7 +19,8 @@ use fs::Fs;
use futures::{SinkExt, StreamExt, channel::mpsc, lock::Mutex};
use git::repository::repo_path;
use gpui::{
- App, Rgba, SharedString, TestAppContext, UpdateGlobal, VisualContext, VisualTestContext,
+ App, AppContext as _, Entity, Rgba, SharedString, TestAppContext, UpdateGlobal, VisualContext,
+ VisualTestContext,
};
use indoc::indoc;
use language::{FakeLspAdapter, language_settings::language_settings, rust_lang};
@@ -51,7 +52,7 @@ use std::{
};
use text::Point;
use util::{path, rel_path::rel_path, uri};
-use workspace::{CloseIntent, Workspace};
+use workspace::{CloseIntent, MultiWorkspace, Workspace};
#[gpui::test(iterations = 10)]
async fn test_host_disconnect(
@@ -95,34 +96,46 @@ async fn test_host_disconnect(
assert!(worktree_a.read_with(cx_a, |tree, _| tree.has_update_observer()));
- let workspace_b = cx_b.add_window(|window, cx| {
- Workspace::new(
- None,
- project_b.clone(),
- client_b.app_state.clone(),
- window,
- cx,
- )
+ let window_b = cx_b.add_window(|window, cx| {
+ let workspace = cx.new(|cx| {
+ Workspace::new(
+ None,
+ project_b.clone(),
+ client_b.app_state.clone(),
+ window,
+ cx,
+ )
+ });
+ MultiWorkspace::new(workspace, cx)
});
- let cx_b = &mut VisualTestContext::from_window(*workspace_b, cx_b);
- let workspace_b_view = workspace_b.root(cx_b).unwrap();
+ let cx_b = &mut VisualTestContext::from_window(*window_b, cx_b);
+ let workspace_b = window_b
+ .root(cx_b)
+ .unwrap()
+ .read_with(cx_b, |multi_workspace, _| {
+ multi_workspace.workspace().clone()
+ });
- let editor_b = workspace_b
- .update(cx_b, |workspace, window, cx| {
+ let editor_b: Entity<Editor> = workspace_b
+ .update_in(cx_b, |workspace, window, cx| {
workspace.open_path((worktree_id, rel_path("b.txt")), None, true, window, cx)
})
- .unwrap()
.await
.unwrap()
.downcast::<Editor>()
.unwrap();
//TODO: focus
- assert!(cx_b.update_window_entity(&editor_b, |editor, window, _| editor.is_focused(window)));
- editor_b.update_in(cx_b, |editor, window, cx| editor.insert("X", window, cx));
+ assert!(
+ cx_b.update_window_entity(&editor_b, |editor: &mut Editor, window, _| editor
+ .is_focused(window))
+ );
+ editor_b.update_in(cx_b, |editor: &mut Editor, window, cx| {
+ editor.insert("X", window, cx)
+ });
cx_b.update(|_, cx| {
- assert!(workspace_b_view.read(cx).is_edited());
+ assert!(workspace_b.read(cx).is_edited());
});
// Drop client A's connection. Collaborators should disappear and the project should not be shown as shared.
@@ -140,19 +153,16 @@ async fn test_host_disconnect(
assert!(worktree_a.read_with(cx_a, |tree, _| !tree.has_update_observer()));
// Ensure client B's edited state is reset and that the whole window is blurred.
- workspace_b
- .update(cx_b, |workspace, _, cx| {
- assert!(workspace.active_modal::<DisconnectedOverlay>(cx).is_some());
- assert!(!workspace.is_edited());
- })
- .unwrap();
+ workspace_b.update(cx_b, |workspace, cx| {
+ assert!(workspace.active_modal::<DisconnectedOverlay>(cx).is_some());
+ assert!(!workspace.is_edited());
+ });
// Ensure client B is not prompted to save edits when closing window after disconnecting.
- let can_close = workspace_b
- .update(cx_b, |workspace, window, cx| {
+ let can_close: bool = workspace_b
+ .update_in(cx_b, |workspace, window, cx| {
workspace.prepare_to_close(CloseIntent::Quit, window, cx)
})
- .unwrap()
.await
.unwrap();
assert!(can_close);
@@ -17,7 +17,7 @@ use serde_json::json;
use settings::SettingsStore;
use text::{Point, ToPoint};
use util::{path, rel_path::rel_path, test::sample_text};
-use workspace::{CollaboratorId, SplitDirection, Workspace, item::ItemHandle as _};
+use workspace::{CollaboratorId, MultiWorkspace, SplitDirection, Workspace, item::ItemHandle as _};
use super::TestClient;
@@ -1555,9 +1555,9 @@ async fn test_following_across_workspaces(cx_a: &mut TestAppContext, cx_b: &mut
let mut cx_b2 = VisualTestContext::from_window(window_b_project_a, cx_b);
let workspace_b_project_a = window_b_project_a
- .downcast::<Workspace>()
+ .downcast::<MultiWorkspace>()
.unwrap()
- .root(cx_b)
+ .read_with(cx_b, |mw, _| mw.workspace().clone())
.unwrap();
// assert that b is following a in project a in w.rs
@@ -1657,9 +1657,9 @@ async fn test_following_across_workspaces(cx_a: &mut TestAppContext, cx_b: &mut
.unwrap();
let cx_a2 = &mut VisualTestContext::from_window(window_a_project_b, cx_a);
let workspace_a_project_b = window_a_project_b
- .downcast::<Workspace>()
+ .downcast::<MultiWorkspace>()
.unwrap()
- .root(cx_a)
+ .read_with(cx_a, |mw, _| mw.workspace().clone())
.unwrap();
executor.run_until_parked();
@@ -2144,7 +2144,7 @@ pub(crate) async fn join_channel(
client: &TestClient,
cx: &mut TestAppContext,
) -> anyhow::Result<()> {
- cx.update(|cx| workspace::join_channel(channel_id, client.app_state.clone(), None, cx))
+ cx.update(|cx| workspace::join_channel(channel_id, client.app_state.clone(), None, None, cx))
.await
}
@@ -3,11 +3,11 @@ use std::path::Path;
use call::ActiveCall;
use git::status::{FileStatus, StatusCode, TrackedStatus};
use git_ui::project_diff::ProjectDiff;
-use gpui::{TestAppContext, VisualTestContext};
+use gpui::{AppContext as _, TestAppContext, VisualTestContext};
use project::ProjectPath;
use serde_json::json;
use util::{path, rel_path::rel_path};
-use workspace::Workspace;
+use workspace::{MultiWorkspace, Workspace};
//
use crate::TestServer;
@@ -57,17 +57,25 @@ async fn test_project_diff(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext)
cx_b.update(editor::init);
cx_b.update(git_ui::init);
let project_b = client_b.join_remote_project(project_id, cx_b).await;
- let workspace_b = cx_b.add_window(|window, cx| {
- Workspace::new(
- None,
- project_b.clone(),
- client_b.app_state.clone(),
- window,
- cx,
- )
+ let window_b = cx_b.add_window(|window, cx| {
+ let workspace = cx.new(|cx| {
+ Workspace::new(
+ None,
+ project_b.clone(),
+ client_b.app_state.clone(),
+ window,
+ cx,
+ )
+ });
+ MultiWorkspace::new(workspace, cx)
});
- let cx_b = &mut VisualTestContext::from_window(*workspace_b, cx_b);
- let workspace_b = workspace_b.root(cx_b).unwrap();
+ let cx_b = &mut VisualTestContext::from_window(*window_b, cx_b);
+ let workspace_b = window_b
+ .root(cx_b)
+ .unwrap()
+ .read_with(cx_b, |multi_workspace, _| {
+ multi_workspace.workspace().clone()
+ });
cx_b.update(|window, cx| {
window
@@ -8,7 +8,9 @@ use editor::{Editor, EditorMode, MultiBuffer};
use extension::ExtensionHostProxy;
use fs::{FakeFs, Fs as _, RemoveOptions};
use futures::StreamExt as _;
-use gpui::{AppContext as _, BackgroundExecutor, TestAppContext, UpdateGlobal as _, VisualContext};
+use gpui::{
+ AppContext as _, BackgroundExecutor, TestAppContext, UpdateGlobal as _, VisualContext as _,
+};
use http_client::BlockedHttpClient;
use language::{
FakeLspAdapter, Language, LanguageConfig, LanguageMatcher, LanguageRegistry,
@@ -663,7 +665,7 @@ async fn test_remote_server_debugger(
let workspace_window = cx_a
.window_handle()
- .downcast::<workspace::Workspace>()
+ .downcast::<workspace::MultiWorkspace>()
.unwrap();
let session = debugger_ui::tests::start_debug_session(&workspace_window, cx_a, |_| {}).unwrap();
@@ -671,13 +673,16 @@ async fn test_remote_server_debugger(
debug_panel.update(cx_a, |debug_panel, cx| {
assert_eq!(
debug_panel.active_session().unwrap().read(cx).session(cx),
- session
+ session.clone()
)
});
- session.update(cx_a, |session, _| {
- assert_eq!(session.binary().unwrap().command.as_deref(), Some("mock"));
- });
+ session.update(
+ cx_a,
+ |session: &mut project::debugger::session::Session, _| {
+ assert_eq!(session.binary().unwrap().command.as_deref(), Some("mock"));
+ },
+ );
let shutdown_session = workspace.update(cx_a, |workspace, cx| {
workspace.project().update(cx, |project, cx| {
@@ -772,7 +777,7 @@ async fn test_slow_adapter_startup_retries(
let workspace_window = cx_a
.window_handle()
- .downcast::<workspace::Workspace>()
+ .downcast::<workspace::MultiWorkspace>()
.unwrap();
let count = Arc::new(AtomicUsize::new(0));
@@ -804,7 +809,10 @@ async fn test_slow_adapter_startup_retries(
.unwrap();
cx_a.run_until_parked();
- let client = session.update(cx_a, |session, _| session.adapter_client().unwrap());
+ let client = session.update(
+ cx_a,
+ |session: &mut project::debugger::session::Session, _| session.adapter_client().unwrap(),
+ );
client
.fake_event(dap::messages::Events::Stopped(dap::StoppedEvent {
reason: dap::StoppedEventReason::Pause,
@@ -45,7 +45,7 @@ use std::{
},
};
use util::path;
-use workspace::{Workspace, WorkspaceStore};
+use workspace::{MultiWorkspace, Workspace, WorkspaceStore};
use livekit_client::test::TestServer as LivekitTestServer;
@@ -843,7 +843,7 @@ impl TestClient {
channel_id: ChannelId,
cx: &'a mut TestAppContext,
) -> (Entity<Workspace>, &'a mut VisualTestContext) {
- cx.update(|cx| workspace::join_channel(channel_id, self.app_state.clone(), None, cx))
+ cx.update(|cx| workspace::join_channel(channel_id, self.app_state.clone(), None, None, cx))
.await
.unwrap();
cx.run_until_parked();
@@ -897,10 +897,19 @@ impl TestClient {
project: &Entity<Project>,
cx: &'a mut TestAppContext,
) -> (Entity<Workspace>, &'a mut VisualTestContext) {
- cx.add_window_view(|window, cx| {
+ let app_state = self.app_state.clone();
+ let project = project.clone();
+ let window = cx.add_window(|window, cx| {
window.activate_window();
- Workspace::new(None, project.clone(), self.app_state.clone(), window, cx)
- })
+ let workspace = cx.new(|cx| Workspace::new(None, project, app_state, window, cx));
+ MultiWorkspace::new(workspace, cx)
+ });
+ let cx = VisualTestContext::from_window(*window, cx).into_mut();
+ cx.run_until_parked();
+ let workspace = window
+ .read_with(cx, |mw, _| mw.workspace().clone())
+ .unwrap();
+ (workspace, cx)
}
pub async fn build_test_workspace<'a>(
@@ -908,19 +917,33 @@ impl TestClient {
cx: &'a mut TestAppContext,
) -> (Entity<Workspace>, &'a mut VisualTestContext) {
let project = self.build_test_project(cx).await;
- cx.add_window_view(|window, cx| {
+ let app_state = self.app_state.clone();
+ let window = cx.add_window(|window, cx| {
window.activate_window();
- Workspace::new(None, project.clone(), self.app_state.clone(), window, cx)
- })
+ let workspace = cx.new(|cx| Workspace::new(None, project, app_state, window, cx));
+ MultiWorkspace::new(workspace, cx)
+ });
+ let cx = VisualTestContext::from_window(*window, cx).into_mut();
+ let workspace = window
+ .read_with(cx, |mw, _| mw.workspace().clone())
+ .unwrap();
+ (workspace, cx)
}
pub fn active_workspace<'a>(
&'a self,
cx: &'a mut TestAppContext,
) -> (Entity<Workspace>, &'a mut VisualTestContext) {
- let window = cx.update(|cx| cx.active_window().unwrap().downcast::<Workspace>().unwrap());
+ let window = cx.update(|cx| {
+ cx.active_window()
+ .unwrap()
+ .downcast::<MultiWorkspace>()
+ .unwrap()
+ });
- let entity = window.root(cx).unwrap();
+ let entity = window
+ .read_with(cx, |mw, _| mw.workspace().clone())
+ .unwrap();
let cx = VisualTestContext::from_window(*window.deref(), cx).into_mut();
// it might be nice to try and cleanup these at the end of each test.
(entity, cx)
@@ -931,8 +954,15 @@ pub fn open_channel_notes(
channel_id: ChannelId,
cx: &mut VisualTestContext,
) -> Task<anyhow::Result<Entity<ChannelView>>> {
- let window = cx.update(|_, cx| cx.active_window().unwrap().downcast::<Workspace>().unwrap());
- let entity = window.root(cx).unwrap();
+ let window = cx.update(|_, cx| {
+ cx.active_window()
+ .unwrap()
+ .downcast::<MultiWorkspace>()
+ .unwrap()
+ });
+ let entity = window
+ .read_with(cx, |mw, _| mw.workspace().clone())
+ .unwrap();
cx.update(|window, cx| ChannelView::open(channel_id, None, entity.clone(), window, cx))
}
@@ -36,7 +36,8 @@ use ui::{
};
use util::{ResultExt, TryFutureExt, maybe};
use workspace::{
- CopyRoomId, Deafen, LeaveCall, Mute, OpenChannelNotes, ScreenShare, ShareProject, Workspace,
+ CopyRoomId, Deafen, LeaveCall, MultiWorkspace, Mute, OpenChannelNotes, ScreenShare,
+ ShareProject, Workspace,
dock::{DockPosition, Panel, PanelEvent},
notifications::{DetachAndPromptErr, NotifyResultExt},
};
@@ -120,6 +121,7 @@ pub fn init(cx: &mut App) {
if let Some(room) = ActiveCall::global(cx).read(cx).room() {
let romo_id_fut = room.read(cx).room_id();
+ let workspace_handle = cx.weak_entity();
cx.spawn(async move |workspace, cx| {
let room_id = romo_id_fut.await.context("Failed to get livekit room")?;
workspace.update(cx, |workspace, cx| {
@@ -134,7 +136,7 @@ pub fn init(cx: &mut App) {
);
})
})
- .detach_and_notify_err(window, cx);
+ .detach_and_notify_err(workspace_handle, window, cx);
} else {
workspace.show_error(&"There’s no active call; join one first.", cx);
}
@@ -2177,12 +2179,13 @@ impl CollabPanel {
&["Remove", "Cancel"],
cx,
);
- cx.spawn_in(window, async move |this, cx| {
+ let workspace = self.workspace.clone();
+ cx.spawn_in(window, async move |this, mut cx| {
if answer.await? == 0 {
channel_store
.update(cx, |channels, _| channels.remove_channel(channel_id))
.await
- .notify_async_err(cx);
+ .notify_workspace_async_err(workspace, &mut cx);
this.update_in(cx, |_, window, cx| cx.focus_self(window))
.ok();
}
@@ -2211,12 +2214,13 @@ impl CollabPanel {
&["Remove", "Cancel"],
cx,
);
- cx.spawn_in(window, async move |_, cx| {
+ let workspace = self.workspace.clone();
+ cx.spawn_in(window, async move |_, mut cx| {
if answer.await? == 0 {
user_store
.update(cx, |store, cx| store.remove_contact(user_id, cx))
.await
- .notify_async_err(cx);
+ .notify_workspace_async_err(workspace, &mut cx);
}
anyhow::Ok(())
})
@@ -2267,13 +2271,15 @@ impl CollabPanel {
let Some(workspace) = self.workspace.upgrade() else {
return;
};
- let Some(handle) = window.window_handle().downcast::<Workspace>() else {
+
+ let Some(handle) = window.window_handle().downcast::<MultiWorkspace>() else {
return;
};
workspace::join_channel(
channel_id,
workspace.read(cx).app_state().clone(),
Some(handle),
+ Some(self.workspace.clone()),
cx,
)
.detach_and_prompt_err("Failed to join channel", window, cx, |_, _, _| None)
@@ -2316,12 +2322,13 @@ impl CollabPanel {
.full_width()
.on_click(cx.listener(|this, _, window, cx| {
let client = this.client.clone();
- cx.spawn_in(window, async move |_, cx| {
+ let workspace = this.workspace.clone();
+ cx.spawn_in(window, async move |_, mut cx| {
client
- .connect(true, cx)
+ .connect(true, &mut cx)
.await
.into_response()
- .notify_async_err(cx);
+ .notify_workspace_async_err(workspace, &mut cx);
})
.detach()
})),
@@ -1,3 +1,4 @@
+use anyhow::Context as _;
use gpui::App;
use sqlez_macros::sql;
use util::ResultExt as _;
@@ -13,12 +14,22 @@ pub struct KeyValueStore(crate::sqlez::thread_safe_connection::ThreadSafeConnect
impl Domain for KeyValueStore {
const NAME: &str = stringify!(KeyValueStore);
- const MIGRATIONS: &[&str] = &[sql!(
- CREATE TABLE IF NOT EXISTS kv_store(
- key TEXT PRIMARY KEY,
- value TEXT NOT NULL
- ) STRICT;
- )];
+ const MIGRATIONS: &[&str] = &[
+ sql!(
+ CREATE TABLE IF NOT EXISTS kv_store(
+ key TEXT PRIMARY KEY,
+ value TEXT NOT NULL
+ ) STRICT;
+ ),
+ sql!(
+ CREATE TABLE IF NOT EXISTS scoped_kv_store(
+ namespace TEXT NOT NULL,
+ key TEXT NOT NULL,
+ value TEXT NOT NULL,
+ PRIMARY KEY(namespace, key)
+ ) STRICT;
+ ),
+ ];
}
crate::static_connection!(KEY_VALUE_STORE, KeyValueStore, []);
@@ -69,6 +80,64 @@ impl KeyValueStore {
DELETE FROM kv_store WHERE key = (?)
}
}
+
+ pub fn scoped<'a>(&'a self, namespace: &'a str) -> ScopedKeyValueStore<'a> {
+ ScopedKeyValueStore {
+ store: self,
+ namespace,
+ }
+ }
+}
+
+pub struct ScopedKeyValueStore<'a> {
+ store: &'a KeyValueStore,
+ namespace: &'a str,
+}
+
+impl ScopedKeyValueStore<'_> {
+ pub fn read(&self, key: &str) -> anyhow::Result<Option<String>> {
+ self.store.select_row_bound::<(&str, &str), String>(
+ "SELECT value FROM scoped_kv_store WHERE namespace = (?) AND key = (?)",
+ )?((self.namespace, key))
+ .context("Failed to read from scoped_kv_store")
+ }
+
+ pub async fn write(&self, key: String, value: String) -> anyhow::Result<()> {
+ let namespace = self.namespace.to_owned();
+ self.store
+ .write(move |connection| {
+ connection.exec_bound::<(&str, &str, &str)>(
+ "INSERT OR REPLACE INTO scoped_kv_store(namespace, key, value) VALUES ((?), (?), (?))",
+ )?((&namespace, &key, &value))
+ .context("Failed to write to scoped_kv_store")
+ })
+ .await
+ }
+
+ pub async fn delete(&self, key: String) -> anyhow::Result<()> {
+ let namespace = self.namespace.to_owned();
+ self.store
+ .write(move |connection| {
+ connection.exec_bound::<(&str, &str)>(
+ "DELETE FROM scoped_kv_store WHERE namespace = (?) AND key = (?)",
+ )?((&namespace, &key))
+ .context("Failed to delete from scoped_kv_store")
+ })
+ .await
+ }
+
+ pub async fn delete_all(&self) -> anyhow::Result<()> {
+ let namespace = self.namespace.to_owned();
+ self.store
+ .write(move |connection| {
+ connection
+ .exec_bound::<&str>("DELETE FROM scoped_kv_store WHERE namespace = (?)")?(
+ &namespace,
+ )
+ .context("Failed to delete_all from scoped_kv_store")
+ })
+ .await
+ }
}
#[cfg(test)]
@@ -99,6 +168,52 @@ mod tests {
db.delete_kvp("key-1".to_string()).await.unwrap();
assert_eq!(db.read_kvp("key-1").unwrap(), None);
}
+
+ #[gpui::test]
+ async fn test_scoped_kvp() {
+ let db = KeyValueStore::open_test_db("test_scoped_kvp").await;
+
+ let scope_a = db.scoped("namespace-a");
+ let scope_b = db.scoped("namespace-b");
+
+ // Reading a missing key returns None
+ assert_eq!(scope_a.read("key-1").unwrap(), None);
+
+ // Writing and reading back a key works
+ scope_a
+ .write("key-1".to_string(), "value-a1".to_string())
+ .await
+ .unwrap();
+ assert_eq!(scope_a.read("key-1").unwrap(), Some("value-a1".to_string()));
+
+ // Two namespaces with the same key don't collide
+ scope_b
+ .write("key-1".to_string(), "value-b1".to_string())
+ .await
+ .unwrap();
+ assert_eq!(scope_a.read("key-1").unwrap(), Some("value-a1".to_string()));
+ assert_eq!(scope_b.read("key-1").unwrap(), Some("value-b1".to_string()));
+
+ // delete removes a single key without affecting others in the namespace
+ scope_a
+ .write("key-2".to_string(), "value-a2".to_string())
+ .await
+ .unwrap();
+ scope_a.delete("key-1".to_string()).await.unwrap();
+ assert_eq!(scope_a.read("key-1").unwrap(), None);
+ assert_eq!(scope_a.read("key-2").unwrap(), Some("value-a2".to_string()));
+ assert_eq!(scope_b.read("key-1").unwrap(), Some("value-b1".to_string()));
+
+ // delete_all removes all keys in a namespace without affecting other namespaces
+ scope_a
+ .write("key-3".to_string(), "value-a3".to_string())
+ .await
+ .unwrap();
+ scope_a.delete_all().await.unwrap();
+ assert_eq!(scope_a.read("key-2").unwrap(), None);
+ assert_eq!(scope_a.read("key-3").unwrap(), None);
+ assert_eq!(scope_b.read("key-1").unwrap(), Some("value-b1".to_string()));
+ }
}
pub struct GlobalKeyValueStore(ThreadSafeConnection);
@@ -8,7 +8,7 @@ use project::{Project, debugger::session::Session};
use settings::SettingsStore;
use task::SharedTaskContext;
use terminal_view::terminal_panel::TerminalPanel;
-use workspace::Workspace;
+use workspace::MultiWorkspace;
use crate::{debugger_panel::DebugPanel, session::DebugSession};
@@ -52,14 +52,16 @@ pub fn init_test(cx: &mut gpui::TestAppContext) {
pub async fn init_test_workspace(
project: &Entity<Project>,
cx: &mut TestAppContext,
-) -> WindowHandle<Workspace> {
+) -> WindowHandle<MultiWorkspace> {
let workspace_handle =
- cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
+ cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let debugger_panel = workspace_handle
- .update(cx, |_, window, cx| {
- cx.spawn_in(window, async move |this, cx| {
- DebugPanel::load(this, cx).await
+ .update(cx, |multi, window, cx| {
+ multi.workspace().update(cx, |_workspace, cx| {
+ cx.spawn_in(window, async move |this, cx| {
+ DebugPanel::load(this, cx).await
+ })
})
})
.unwrap()
@@ -67,9 +69,10 @@ pub async fn init_test_workspace(
.expect("Failed to load debug panel");
let terminal_panel = workspace_handle
- .update(cx, |_, window, cx| {
- cx.spawn_in(window, async |this, cx| {
- TerminalPanel::load(this, cx.clone()).await
+ .update(cx, |multi, window, cx| {
+ let weak_workspace = multi.workspace().downgrade();
+ cx.spawn_in(window, async move |_, cx| {
+ TerminalPanel::load(weak_workspace, cx.clone()).await
})
})
.unwrap()
@@ -77,9 +80,11 @@ pub async fn init_test_workspace(
.expect("Failed to load terminal panel");
workspace_handle
- .update(cx, |workspace, window, cx| {
- workspace.add_panel(debugger_panel, window, cx);
- workspace.add_panel(terminal_panel, window, cx);
+ .update(cx, |multi, window, cx| {
+ multi.workspace().update(cx, |workspace, cx| {
+ workspace.add_panel(debugger_panel, window, cx);
+ workspace.add_panel(terminal_panel, window, cx);
+ });
})
.unwrap();
workspace_handle
@@ -87,39 +92,45 @@ pub async fn init_test_workspace(
#[track_caller]
pub fn active_debug_session_panel(
- workspace: WindowHandle<Workspace>,
+ workspace: WindowHandle<MultiWorkspace>,
cx: &mut TestAppContext,
) -> Entity<DebugSession> {
workspace
- .update(cx, |workspace, _window, cx| {
- let debug_panel = workspace.panel::<DebugPanel>(cx).unwrap();
- debug_panel
- .update(cx, |this, _| this.active_session())
- .unwrap()
+ .update(cx, |multi, _window, cx| {
+ multi.workspace().update(cx, |workspace, cx| {
+ let debug_panel = workspace.panel::<DebugPanel>(cx).unwrap();
+ debug_panel
+ .update(cx, |this, _| this.active_session())
+ .unwrap()
+ })
})
.unwrap()
}
pub fn start_debug_session_with<T: Fn(&Arc<DebugAdapterClient>) + 'static>(
- workspace: &WindowHandle<Workspace>,
+ workspace: &WindowHandle<MultiWorkspace>,
cx: &mut gpui::TestAppContext,
config: DebugTaskDefinition,
configure: T,
) -> Result<Entity<Session>> {
let _subscription = project::debugger::test::intercept_debug_sessions(cx, configure);
- workspace.update(cx, |workspace, window, cx| {
- workspace.start_debug_session(
- config.to_scenario(),
- SharedTaskContext::default(),
- None,
- None,
- window,
- cx,
- )
+ workspace.update(cx, |multi, window, cx| {
+ multi.workspace().update(cx, |workspace, cx| {
+ workspace.start_debug_session(
+ config.to_scenario(),
+ SharedTaskContext::default(),
+ None,
+ None,
+ window,
+ cx,
+ )
+ })
})?;
cx.run_until_parked();
let session = workspace.read_with(cx, |workspace, cx| {
workspace
+ .workspace()
+ .read(cx)
.panel::<DebugPanel>(cx)
.and_then(|panel| panel.read(cx).active_session())
.map(|session| session.read(cx).running_state().read(cx).session())
@@ -131,7 +142,7 @@ pub fn start_debug_session_with<T: Fn(&Arc<DebugAdapterClient>) + 'static>(
}
pub fn start_debug_session<T: Fn(&Arc<DebugAdapterClient>) + 'static>(
- workspace: &WindowHandle<Workspace>,
+ workspace: &WindowHandle<MultiWorkspace>,
cx: &mut gpui::TestAppContext,
configure: T,
) -> Result<Entity<Session>> {
@@ -60,7 +60,13 @@ async fn test_direct_attach_to_process(executor: BackgroundExecutor, cx: &mut Te
// assert we didn't show the attach modal
workspace
.update(cx, |workspace, _window, cx| {
- assert!(workspace.active_modal::<AttachModal>(cx).is_none());
+ assert!(
+ workspace
+ .workspace()
+ .read(cx)
+ .active_modal::<AttachModal>(cx)
+ .is_none()
+ );
})
.unwrap();
}
@@ -97,9 +103,9 @@ async fn test_show_attach_modal_and_select_process(
});
});
let attach_modal = workspace
- .update(cx, |workspace, window, cx| {
- let workspace_handle = cx.weak_entity();
- workspace.toggle_modal(window, cx, |window, cx| {
+ .update(cx, |multi, window, cx| {
+ let workspace_handle = multi.workspace().downgrade();
+ multi.toggle_modal(window, cx, |window, cx| {
AttachModal::with_processes(
workspace_handle,
vec![
@@ -133,7 +139,7 @@ async fn test_show_attach_modal_and_select_process(
)
});
- workspace.active_modal::<AttachModal>(cx).unwrap()
+ multi.active_modal::<AttachModal>(cx).unwrap()
})
.unwrap();
@@ -208,24 +214,26 @@ async fn test_attach_with_pick_pid_variable(executor: BackgroundExecutor, cx: &m
let pick_pid_placeholder = task::VariableName::PickProcessId.template_value();
workspace
- .update(cx, |workspace, window, cx| {
- workspace.start_debug_session(
- DebugTaskDefinition {
- adapter: FakeAdapter::ADAPTER_NAME.into(),
- label: "attach with picker".into(),
- config: json!({
- "request": "attach",
- "process_id": pick_pid_placeholder,
- }),
- tcp_connection: None,
- }
- .to_scenario(),
- SharedTaskContext::default(),
- None,
- None,
- window,
- cx,
- )
+ .update(cx, |multi, window, cx| {
+ multi.workspace().update(cx, |workspace, cx| {
+ workspace.start_debug_session(
+ DebugTaskDefinition {
+ adapter: FakeAdapter::ADAPTER_NAME.into(),
+ label: "attach with picker".into(),
+ config: json!({
+ "request": "attach",
+ "process_id": pick_pid_placeholder,
+ }),
+ tcp_connection: None,
+ }
+ .to_scenario(),
+ SharedTaskContext::default(),
+ None,
+ None,
+ window,
+ cx,
+ );
+ })
})
.unwrap();
@@ -145,15 +145,17 @@ async fn test_debug_session_substitutes_variables_and_relativizes_paths(
};
workspace
- .update(cx, |workspace, window, cx| {
- workspace.start_debug_session(
- scenario,
- task_context.clone(),
- None,
- None,
- window,
- cx,
- )
+ .update(cx, |multi, window, cx| {
+ multi.workspace().update(cx, |workspace, cx| {
+ workspace.start_debug_session(
+ scenario,
+ task_context.clone(),
+ None,
+ None,
+ window,
+ cx,
+ );
+ })
})
.unwrap();
@@ -182,8 +184,10 @@ async fn test_save_debug_scenario_to_file(executor: BackgroundExecutor, cx: &mut
let cx = &mut VisualTestContext::from_window(*workspace, cx);
workspace
- .update(cx, |workspace, window, cx| {
- NewProcessModal::show(workspace, window, NewProcessMode::Debug, None, cx);
+ .update(cx, |multi, window, cx| {
+ multi.workspace().update(cx, |workspace, cx| {
+ NewProcessModal::show(workspace, window, NewProcessMode::Debug, None, cx);
+ });
})
.unwrap();
@@ -324,8 +328,10 @@ async fn test_debug_modal_subtitles_with_multiple_worktrees(
let cx = &mut VisualTestContext::from_window(*workspace, cx);
workspace
- .update(cx, |workspace, window, cx| {
- NewProcessModal::show(workspace, window, NewProcessMode::Debug, None, cx);
+ .update(cx, |multi, window, cx| {
+ multi.workspace().update(cx, |workspace, cx| {
+ NewProcessModal::show(workspace, window, NewProcessMode::Debug, None, cx);
+ });
})
.unwrap();
@@ -1113,8 +1113,8 @@ async fn test_stack_frame_filter_persistence(
let workspace = init_test_workspace(&project, cx).await;
let cx = &mut VisualTestContext::from_window(*workspace, cx);
workspace
- .update(cx, |workspace, _, _| {
- workspace.set_random_database_id();
+ .update(cx, |workspace, _, cx| {
+ workspace.set_random_database_id(cx);
})
.unwrap();
@@ -1211,7 +1211,7 @@ async fn test_stack_frame_filter_persistence(
cx.run_until_parked();
let workspace_id = workspace
- .update(cx, |workspace, _window, _cx| workspace.database_id())
+ .update(cx, |workspace, _window, cx| workspace.database_id(cx))
.ok()
.flatten()
.expect("workspace id has to be some for this test to work properly");
@@ -23,7 +23,12 @@ util.workspace = true
workspace.workspace = true
[dev-dependencies]
+fs = { workspace = true, features = ["test-support"] }
gpui = { workspace = true, features = ["test-support"] }
+project = { workspace = true, features = ["test-support"] }
+settings = { workspace = true, features = ["test-support"] }
+theme.workspace = true
+workspace = { workspace = true, features = ["test-support"] }
[lints]
workspace = true
@@ -2,18 +2,16 @@ use std::{
collections::{HashMap, HashSet},
fmt::Display,
path::{Path, PathBuf},
- sync::Arc,
};
-use gpui::AsyncWindowContext;
use node_runtime::NodeRuntime;
use serde::Deserialize;
-use settings::{DevContainerConnection, Settings as _};
+use settings::DevContainerConnection;
use smol::{fs, process::Command};
use util::rel_path::RelPath;
use workspace::Workspace;
-use crate::{DevContainerFeature, DevContainerSettings, DevContainerTemplate};
+use crate::{DevContainerContext, DevContainerFeature, DevContainerTemplate};
/// Represents a discovered devcontainer configuration
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -59,6 +57,31 @@ pub(crate) struct DevContainerConfigurationOutput {
configuration: DevContainerConfiguration,
}
+pub(crate) struct DevContainerCli {
+ pub path: PathBuf,
+ node_runtime_path: Option<PathBuf>,
+}
+
+impl DevContainerCli {
+ fn command(&self, use_podman: bool) -> Command {
+ let mut command = if let Some(node_runtime_path) = &self.node_runtime_path {
+ let mut command = util::command::new_smol_command(
+ node_runtime_path.as_os_str().display().to_string(),
+ );
+ command.arg(self.path.display().to_string());
+ command
+ } else {
+ util::command::new_smol_command(self.path.display().to_string())
+ };
+
+ if use_podman {
+ command.arg("--docker-path");
+ command.arg("podman");
+ }
+ command
+ }
+}
+
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DevContainerError {
DockerNotAvailable,
@@ -99,58 +122,6 @@ impl Display for DevContainerError {
}
}
-pub(crate) async fn read_devcontainer_configuration_for_project(
- cx: &mut AsyncWindowContext,
- node_runtime: &NodeRuntime,
-) -> Result<DevContainerConfigurationOutput, DevContainerError> {
- let (path_to_devcontainer_cli, found_in_path) = ensure_devcontainer_cli(&node_runtime).await?;
-
- let Some(directory) = project_directory(cx) else {
- return Err(DevContainerError::NotInValidProject);
- };
-
- devcontainer_read_configuration(
- &path_to_devcontainer_cli,
- found_in_path,
- node_runtime,
- &directory,
- None,
- use_podman(cx),
- )
- .await
-}
-
-pub(crate) async fn apply_dev_container_template(
- template: &DevContainerTemplate,
- options_selected: &HashMap<String, String>,
- features_selected: &HashSet<DevContainerFeature>,
- cx: &mut AsyncWindowContext,
- node_runtime: &NodeRuntime,
-) -> Result<DevContainerApply, DevContainerError> {
- let (path_to_devcontainer_cli, found_in_path) = ensure_devcontainer_cli(&node_runtime).await?;
-
- let Some(directory) = project_directory(cx) else {
- return Err(DevContainerError::NotInValidProject);
- };
-
- devcontainer_template_apply(
- template,
- options_selected,
- features_selected,
- &path_to_devcontainer_cli,
- found_in_path,
- node_runtime,
- &directory,
- false, // devcontainer template apply does not use --docker-path option
- )
- .await
-}
-
-fn use_podman(cx: &mut AsyncWindowContext) -> bool {
- cx.update(|_, cx| DevContainerSettings::get_global(cx).use_podman)
- .unwrap_or(false)
-}
-
/// Finds all available devcontainer configurations in the project.
///
/// This function scans for:
@@ -158,160 +129,124 @@ fn use_podman(cx: &mut AsyncWindowContext) -> bool {
/// 2. `.devcontainer/<subfolder>/devcontainer.json` (named configurations)
///
/// Returns a list of found configurations, or an empty list if none are found.
-pub fn find_devcontainer_configs(cx: &mut AsyncWindowContext) -> Vec<DevContainerConfig> {
- let Some(workspace) = cx.window_handle().downcast::<Workspace>() else {
- log::debug!("find_devcontainer_configs: No workspace found");
- return Vec::new();
- };
-
- let Ok(configs) = workspace.update(cx, |workspace, _, cx| {
- let project = workspace.project().read(cx);
+pub fn find_devcontainer_configs(workspace: &Workspace, cx: &gpui::App) -> Vec<DevContainerConfig> {
+ let project = workspace.project().read(cx);
- let worktree = project
- .visible_worktrees(cx)
- .find_map(|tree| tree.read(cx).root_entry()?.is_dir().then_some(tree));
+ let worktree = project
+ .visible_worktrees(cx)
+ .find_map(|tree| tree.read(cx).root_entry()?.is_dir().then_some(tree));
- let Some(worktree) = worktree else {
- log::debug!("find_devcontainer_configs: No worktree found");
- return Vec::new();
- };
+ let Some(worktree) = worktree else {
+ log::debug!("find_devcontainer_configs: No worktree found");
+ return Vec::new();
+ };
- let worktree = worktree.read(cx);
- let mut configs = Vec::new();
+ let worktree = worktree.read(cx);
+ let mut configs = Vec::new();
- let devcontainer_path = RelPath::unix(".devcontainer").expect("valid path");
+ let devcontainer_path = RelPath::unix(".devcontainer").expect("valid path");
- let Some(devcontainer_entry) = worktree.entry_for_path(devcontainer_path) else {
- log::debug!("find_devcontainer_configs: .devcontainer directory not found in worktree");
- return Vec::new();
- };
+ let Some(devcontainer_entry) = worktree.entry_for_path(devcontainer_path) else {
+ log::debug!("find_devcontainer_configs: .devcontainer directory not found in worktree");
+ return Vec::new();
+ };
- if !devcontainer_entry.is_dir() {
- log::debug!("find_devcontainer_configs: .devcontainer is not a directory");
- return Vec::new();
- }
+ if !devcontainer_entry.is_dir() {
+ log::debug!("find_devcontainer_configs: .devcontainer is not a directory");
+ return Vec::new();
+ }
- log::debug!("find_devcontainer_configs: Scanning .devcontainer directory");
- let devcontainer_json_path =
- RelPath::unix(".devcontainer/devcontainer.json").expect("valid path");
- for entry in worktree.child_entries(devcontainer_path) {
- log::debug!(
- "find_devcontainer_configs: Found entry: {:?}, is_file: {}, is_dir: {}",
- entry.path.as_unix_str(),
- entry.is_file(),
- entry.is_dir()
- );
+ log::debug!("find_devcontainer_configs: Scanning .devcontainer directory");
+ let devcontainer_json_path =
+ RelPath::unix(".devcontainer/devcontainer.json").expect("valid path");
+ for entry in worktree.child_entries(devcontainer_path) {
+ log::debug!(
+ "find_devcontainer_configs: Found entry: {:?}, is_file: {}, is_dir: {}",
+ entry.path.as_unix_str(),
+ entry.is_file(),
+ entry.is_dir()
+ );
- if entry.is_file() && entry.path.as_ref() == devcontainer_json_path {
- log::debug!("find_devcontainer_configs: Found default devcontainer.json");
- configs.push(DevContainerConfig::default_config());
- } else if entry.is_dir() {
- let subfolder_name = entry
- .path
- .file_name()
- .map(|n| n.to_string())
- .unwrap_or_default();
-
- let config_json_path = format!("{}/devcontainer.json", entry.path.as_unix_str());
- if let Ok(rel_config_path) = RelPath::unix(&config_json_path) {
- if worktree.entry_for_path(rel_config_path).is_some() {
- log::debug!(
- "find_devcontainer_configs: Found config in subfolder: {}",
- subfolder_name
- );
- configs.push(DevContainerConfig {
- name: subfolder_name,
- config_path: PathBuf::from(&config_json_path),
- });
- } else {
- log::debug!(
- "find_devcontainer_configs: Subfolder {} has no devcontainer.json",
- subfolder_name
- );
- }
+ if entry.is_file() && entry.path.as_ref() == devcontainer_json_path {
+ log::debug!("find_devcontainer_configs: Found default devcontainer.json");
+ configs.push(DevContainerConfig::default_config());
+ } else if entry.is_dir() {
+ let subfolder_name = entry
+ .path
+ .file_name()
+ .map(|n| n.to_string())
+ .unwrap_or_default();
+
+ let config_json_path = format!("{}/devcontainer.json", entry.path.as_unix_str());
+ if let Ok(rel_config_path) = RelPath::unix(&config_json_path) {
+ if worktree.entry_for_path(rel_config_path).is_some() {
+ log::debug!(
+ "find_devcontainer_configs: Found config in subfolder: {}",
+ subfolder_name
+ );
+ configs.push(DevContainerConfig {
+ name: subfolder_name,
+ config_path: PathBuf::from(&config_json_path),
+ });
+ } else {
+ log::debug!(
+ "find_devcontainer_configs: Subfolder {} has no devcontainer.json",
+ subfolder_name
+ );
}
}
}
+ }
- log::info!(
- "find_devcontainer_configs: Found {} configurations",
- configs.len()
- );
-
- configs.sort_by(|a, b| {
- if a.name == "default" {
- std::cmp::Ordering::Less
- } else if b.name == "default" {
- std::cmp::Ordering::Greater
- } else {
- a.name.cmp(&b.name)
- }
- });
+ log::info!(
+ "find_devcontainer_configs: Found {} configurations",
+ configs.len()
+ );
- configs
- }) else {
- log::debug!("find_devcontainer_configs: Failed to update workspace");
- return Vec::new();
- };
+ configs.sort_by(|a, b| {
+ if a.name == "default" {
+ std::cmp::Ordering::Less
+ } else if b.name == "default" {
+ std::cmp::Ordering::Greater
+ } else {
+ a.name.cmp(&b.name)
+ }
+ });
configs
}
pub async fn start_dev_container_with_config(
- cx: &mut AsyncWindowContext,
- node_runtime: NodeRuntime,
+ context: DevContainerContext,
config: Option<DevContainerConfig>,
) -> Result<(DevContainerConnection, String), DevContainerError> {
- let use_podman = use_podman(cx);
- check_for_docker(use_podman).await?;
+ check_for_docker(context.use_podman).await?;
+ let cli = ensure_devcontainer_cli(&context.node_runtime).await?;
+ let config_path = config.map(|c| context.project_directory.join(&c.config_path));
- let (path_to_devcontainer_cli, found_in_path) = ensure_devcontainer_cli(&node_runtime).await?;
-
- let Some(directory) = project_directory(cx) else {
- return Err(DevContainerError::NotInValidProject);
- };
-
- let config_path = config.map(|c| directory.join(&c.config_path));
-
- match devcontainer_up(
- &path_to_devcontainer_cli,
- found_in_path,
- &node_runtime,
- directory.clone(),
- config_path.clone(),
- use_podman,
- )
- .await
- {
+ match devcontainer_up(&context, &cli, config_path.as_deref()).await {
Ok(DevContainerUp {
container_id,
remote_workspace_folder,
remote_user,
..
}) => {
- let project_name = match devcontainer_read_configuration(
- &path_to_devcontainer_cli,
- found_in_path,
- &node_runtime,
- &directory,
- config_path.as_ref(),
- use_podman,
- )
- .await
- {
- Ok(DevContainerConfigurationOutput {
- configuration:
- DevContainerConfiguration {
- name: Some(project_name),
- },
- }) => project_name,
- _ => get_backup_project_name(&remote_workspace_folder, &container_id),
- };
+ let project_name =
+ match read_devcontainer_configuration(&context, &cli, config_path.as_deref()).await
+ {
+ Ok(DevContainerConfigurationOutput {
+ configuration:
+ DevContainerConfiguration {
+ name: Some(project_name),
+ },
+ }) => project_name,
+ _ => get_backup_project_name(&remote_workspace_folder, &container_id),
+ };
let connection = DevContainerConnection {
name: project_name,
- container_id: container_id,
- use_podman,
+ container_id,
+ use_podman: context.use_podman,
remote_user,
};
@@ -355,9 +290,9 @@ async fn check_for_docker(use_podman: bool) -> Result<(), DevContainerError> {
}
}
-async fn ensure_devcontainer_cli(
+pub(crate) async fn ensure_devcontainer_cli(
node_runtime: &NodeRuntime,
-) -> Result<(PathBuf, bool), DevContainerError> {
+) -> Result<DevContainerCli, DevContainerError> {
let mut command = util::command::new_smol_command(&dev_container_cli());
command.arg("--version");
@@ -395,7 +330,10 @@ async fn ensure_devcontainer_cli(
Ok(output) => {
if output.status.success() {
log::info!("Found devcontainer CLI in Data dir");
- return Ok((datadir_cli_path.clone(), false));
+ return Ok(DevContainerCli {
+ path: datadir_cli_path.clone(),
+ node_runtime_path: Some(node_runtime_path.clone()),
+ });
} else {
log::error!(
"Could not run devcontainer CLI from data_dir. Will try once more to install. Output: {:?}",
@@ -435,32 +373,29 @@ async fn ensure_devcontainer_cli(
);
Err(DevContainerError::DevContainerCliNotAvailable)
} else {
- Ok((datadir_cli_path, false))
+ Ok(DevContainerCli {
+ path: datadir_cli_path,
+ node_runtime_path: Some(node_runtime_path),
+ })
}
} else {
log::info!("Found devcontainer cli on $PATH, using it");
- Ok((PathBuf::from(&dev_container_cli()), true))
+ Ok(DevContainerCli {
+ path: PathBuf::from(&dev_container_cli()),
+ node_runtime_path: None,
+ })
}
}
async fn devcontainer_up(
- path_to_cli: &PathBuf,
- found_in_path: bool,
- node_runtime: &NodeRuntime,
- path: Arc<Path>,
- config_path: Option<PathBuf>,
- use_podman: bool,
+ context: &DevContainerContext,
+ cli: &DevContainerCli,
+ config_path: Option<&Path>,
) -> Result<DevContainerUp, DevContainerError> {
- let Ok(node_runtime_path) = node_runtime.binary_path().await else {
- log::error!("Unable to find node runtime path");
- return Err(DevContainerError::NodeRuntimeNotAvailable);
- };
-
- let mut command =
- devcontainer_cli_command(path_to_cli, found_in_path, &node_runtime_path, use_podman);
+ let mut command = cli.command(context.use_podman);
command.arg("up");
command.arg("--workspace-folder");
- command.arg(path.display().to_string());
+ command.arg(context.project_directory.display().to_string());
if let Some(config) = config_path {
command.arg("--config");
@@ -493,24 +428,15 @@ async fn devcontainer_up(
}
}
-async fn devcontainer_read_configuration(
- path_to_cli: &PathBuf,
- found_in_path: bool,
- node_runtime: &NodeRuntime,
- path: &Arc<Path>,
- config_path: Option<&PathBuf>,
- use_podman: bool,
+pub(crate) async fn read_devcontainer_configuration(
+ context: &DevContainerContext,
+ cli: &DevContainerCli,
+ config_path: Option<&Path>,
) -> Result<DevContainerConfigurationOutput, DevContainerError> {
- let Ok(node_runtime_path) = node_runtime.binary_path().await else {
- log::error!("Unable to find node runtime path");
- return Err(DevContainerError::NodeRuntimeNotAvailable);
- };
-
- let mut command =
- devcontainer_cli_command(path_to_cli, found_in_path, &node_runtime_path, use_podman);
+ let mut command = cli.command(context.use_podman);
command.arg("read-configuration");
command.arg("--workspace-folder");
- command.arg(path.display().to_string());
+ command.arg(context.project_directory.display().to_string());
if let Some(config) = config_path {
command.arg("--config");
@@ -540,23 +466,14 @@ async fn devcontainer_read_configuration(
}
}
-async fn devcontainer_template_apply(
+pub(crate) async fn apply_dev_container_template(
template: &DevContainerTemplate,
template_options: &HashMap<String, String>,
features_selected: &HashSet<DevContainerFeature>,
- path_to_cli: &PathBuf,
- found_in_path: bool,
- node_runtime: &NodeRuntime,
- path: &Arc<Path>,
- use_podman: bool,
+ context: &DevContainerContext,
+ cli: &DevContainerCli,
) -> Result<DevContainerApply, DevContainerError> {
- let Ok(node_runtime_path) = node_runtime.binary_path().await else {
- log::error!("Unable to find node runtime path");
- return Err(DevContainerError::NodeRuntimeNotAvailable);
- };
-
- let mut command =
- devcontainer_cli_command(path_to_cli, found_in_path, &node_runtime_path, use_podman);
+ let mut command = cli.command(context.use_podman);
let Ok(serialized_options) = serde_json::to_string(template_options) else {
log::error!("Unable to serialize options for {:?}", template_options);
@@ -566,7 +483,7 @@ async fn devcontainer_template_apply(
command.arg("templates");
command.arg("apply");
command.arg("--workspace-folder");
- command.arg(path.display().to_string());
+ command.arg(context.project_directory.display().to_string());
command.arg("--template-id");
command.arg(format!(
"{}/{}",
@@ -630,28 +547,6 @@ fn parse_json_from_cli<T: serde::de::DeserializeOwned>(raw: &str) -> Result<T, D
})
}
-fn devcontainer_cli_command(
- path_to_cli: &PathBuf,
- found_in_path: bool,
- node_runtime_path: &PathBuf,
- use_podman: bool,
-) -> Command {
- let mut command = if found_in_path {
- util::command::new_smol_command(path_to_cli.display().to_string())
- } else {
- let mut command =
- util::command::new_smol_command(node_runtime_path.as_os_str().display().to_string());
- command.arg(path_to_cli.display().to_string());
- command
- };
-
- if use_podman {
- command.arg("--docker-path");
- command.arg("podman");
- }
- command
-}
-
fn get_backup_project_name(remote_workspace_folder: &str, container_id: &str) -> String {
Path::new(remote_workspace_folder)
.file_name()
@@ -660,22 +555,6 @@ fn get_backup_project_name(remote_workspace_folder: &str, container_id: &str) ->
.unwrap_or_else(|| container_id.to_string())
}
-fn project_directory(cx: &mut AsyncWindowContext) -> Option<Arc<Path>> {
- let Some(workspace) = cx.window_handle().downcast::<Workspace>() else {
- return None;
- };
-
- match workspace.update(cx, |workspace, _, cx| {
- workspace.project().read(cx).active_project_directory(cx)
- }) {
- Ok(dir) => dir,
- Err(e) => {
- log::error!("Error getting project directory from workspace: {:?}", e);
- None
- }
- }
-}
-
fn template_features_to_json(features_selected: &HashSet<DevContainerFeature>) -> String {
let features_map = features_selected
.iter()
@@ -701,7 +580,160 @@ fn template_features_to_json(features_selected: &HashSet<DevContainerFeature>) -
#[cfg(test)]
mod tests {
- use crate::devcontainer_api::{DevContainerUp, parse_json_from_cli};
+ use crate::devcontainer_api::{DevContainerUp, find_devcontainer_configs, parse_json_from_cli};
+ use fs::FakeFs;
+ use gpui::TestAppContext;
+ use project::Project;
+ use serde_json::json;
+ use settings::SettingsStore;
+ use workspace::Workspace;
+
+ fn init_test(cx: &mut TestAppContext) {
+ cx.update(|cx| {
+ let settings_store = SettingsStore::test(cx);
+ cx.set_global(settings_store);
+ theme::init(theme::LoadThemes::JustBase, cx);
+ });
+ }
+
+ #[gpui::test]
+ async fn test_find_devcontainer_configs_no_devcontainer_dir(cx: &mut TestAppContext) {
+ init_test(cx);
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(
+ "/project",
+ json!({
+ "src": { "main.rs": "fn main() {}" }
+ }),
+ )
+ .await;
+
+ let project = Project::test(fs, ["/project".as_ref()], cx).await;
+ let (workspace, cx) =
+ cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
+
+ let configs = cx.read(|cx| find_devcontainer_configs(workspace.read(cx), cx));
+ assert!(
+ configs.is_empty(),
+ "Expected no configs when .devcontainer dir is absent, got: {configs:?}"
+ );
+ }
+
+ #[gpui::test]
+ async fn test_find_devcontainer_configs_single_default(cx: &mut TestAppContext) {
+ init_test(cx);
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(
+ "/project",
+ json!({
+ ".devcontainer": {
+ "devcontainer.json": r#"{"image": "ubuntu"}"#
+ }
+ }),
+ )
+ .await;
+
+ let project = Project::test(fs, ["/project".as_ref()], cx).await;
+ let (workspace, cx) =
+ cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
+
+ let configs = cx.read(|cx| find_devcontainer_configs(workspace.read(cx), cx));
+ assert_eq!(
+ configs.len(),
+ 1,
+ "Expected exactly one config, got: {configs:?}"
+ );
+ assert_eq!(configs[0].name, "default");
+ assert_eq!(
+ configs[0].config_path.to_str().unwrap(),
+ ".devcontainer/devcontainer.json"
+ );
+ }
+
+ #[gpui::test]
+ async fn test_find_devcontainer_configs_multiple_subfolders(cx: &mut TestAppContext) {
+ init_test(cx);
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(
+ "/project",
+ json!({
+ ".devcontainer": {
+ "python": { "devcontainer.json": r#"{}"# },
+ "node": { "devcontainer.json": r#"{}"# }
+ }
+ }),
+ )
+ .await;
+
+ let project = Project::test(fs, ["/project".as_ref()], cx).await;
+ let (workspace, cx) =
+ cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
+
+ let configs = cx.read(|cx| find_devcontainer_configs(workspace.read(cx), cx));
+ assert_eq!(configs.len(), 2, "Expected two configs, got: {configs:?}");
+ assert_eq!(configs[0].name, "node");
+ assert_eq!(configs[1].name, "python");
+ }
+
+ #[gpui::test]
+ async fn test_find_devcontainer_configs_default_plus_subfolders(cx: &mut TestAppContext) {
+ init_test(cx);
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(
+ "/project",
+ json!({
+ ".devcontainer": {
+ "devcontainer.json": r#"{}"#,
+ "python": { "devcontainer.json": r#"{}"# },
+ "node": { "devcontainer.json": r#"{}"# }
+ }
+ }),
+ )
+ .await;
+
+ let project = Project::test(fs, ["/project".as_ref()], cx).await;
+ let (workspace, cx) =
+ cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
+
+ let configs = cx.read(|cx| find_devcontainer_configs(workspace.read(cx), cx));
+ assert_eq!(configs.len(), 3, "Expected three configs, got: {configs:?}");
+ assert_eq!(
+ configs[0].name, "default",
+ "Default config should be sorted first"
+ );
+ assert_eq!(configs[1].name, "node");
+ assert_eq!(configs[2].name, "python");
+ }
+
+ #[gpui::test]
+ async fn test_find_devcontainer_configs_subfolder_without_json(cx: &mut TestAppContext) {
+ init_test(cx);
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(
+ "/project",
+ json!({
+ ".devcontainer": {
+ "devcontainer.json": r#"{}"#,
+ "has_config": { "devcontainer.json": r#"{}"# },
+ "no_config": { "README.md": "not a devcontainer" }
+ }
+ }),
+ )
+ .await;
+
+ let project = Project::test(fs, ["/project".as_ref()], cx).await;
+ let (workspace, cx) =
+ cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
+
+ let configs = cx.read(|cx| find_devcontainer_configs(workspace.read(cx), cx));
+ assert_eq!(
+ configs.len(),
+ 2,
+ "Subfolder without devcontainer.json should be skipped, got: {configs:?}"
+ );
+ assert_eq!(configs[0].name, "default");
+ assert_eq!(configs[1].name, "has_config");
+ }
#[test]
fn should_parse_from_devcontainer_json() {
@@ -1,3 +1,5 @@
+use std::path::Path;
+
use gpui::AppContext;
use gpui::Entity;
use gpui::Task;
@@ -41,7 +43,8 @@ use http_client::{AsyncBody, HttpClient};
mod devcontainer_api;
-use devcontainer_api::read_devcontainer_configuration_for_project;
+use devcontainer_api::ensure_devcontainer_cli;
+use devcontainer_api::read_devcontainer_configuration;
use crate::devcontainer_api::DevContainerError;
use crate::devcontainer_api::apply_dev_container_template;
@@ -50,11 +53,34 @@ pub use devcontainer_api::{
DevContainerConfig, find_devcontainer_configs, start_dev_container_with_config,
};
+pub struct DevContainerContext {
+ pub project_directory: Arc<Path>,
+ pub use_podman: bool,
+ pub node_runtime: node_runtime::NodeRuntime,
+}
+
+impl DevContainerContext {
+ pub fn from_workspace(workspace: &Workspace, cx: &App) -> Option<Self> {
+ let project_directory = workspace.project().read(cx).active_project_directory(cx)?;
+ let use_podman = DevContainerSettings::get_global(cx).use_podman;
+ let node_runtime = workspace.app_state().node_runtime.clone();
+ Some(Self {
+ project_directory,
+ use_podman,
+ node_runtime,
+ })
+ }
+}
+
#[derive(RegisterSetting)]
struct DevContainerSettings {
use_podman: bool,
}
+pub fn use_podman(cx: &App) -> bool {
+ DevContainerSettings::get_global(cx).use_podman
+}
+
impl Settings for DevContainerSettings {
fn from_settings(content: &settings::SettingsContent) -> Self {
Self {
@@ -1419,22 +1445,41 @@ fn dispatch_apply_templates(
cx: &mut Context<DevContainerModal>,
) {
cx.spawn_in(window, async move |this, cx| {
- if let Some(tree_id) = workspace.update(cx, |workspace, cx| {
- let project = workspace.project().clone();
- let worktree = project.read(cx).visible_worktrees(cx).find_map(|tree| {
- tree.read(cx)
- .root_entry()?
- .is_dir()
- .then_some(tree.read(cx))
- });
- worktree.map(|w| w.id())
- }) {
- let node_runtime = workspace.read_with(cx, |workspace, _| {
- workspace.app_state().node_runtime.clone()
- });
+ let Some((tree_id, context)) = workspace.update(cx, |workspace, cx| {
+ let worktree = workspace
+ .project()
+ .read(cx)
+ .visible_worktrees(cx)
+ .find_map(|tree| {
+ tree.read(cx)
+ .root_entry()?
+ .is_dir()
+ .then_some(tree.read(cx))
+ });
+ let tree_id = worktree.map(|w| w.id())?;
+ let context = DevContainerContext::from_workspace(workspace, cx)?;
+ Some((tree_id, context))
+ }) else {
+ return;
+ };
+ let Ok(cli) = ensure_devcontainer_cli(&context.node_runtime).await else {
+ this.update_in(cx, |this, window, cx| {
+ this.accept_message(
+ DevContainerMessage::FailedToWriteTemplate(
+ DevContainerError::DevContainerCliNotAvailable,
+ ),
+ window,
+ cx,
+ );
+ })
+ .log_err();
+ return;
+ };
+
+ {
if check_for_existing
- && read_devcontainer_configuration_for_project(cx, &node_runtime)
+ && read_devcontainer_configuration(&context, &cli, None)
.await
.is_ok()
{
@@ -1453,8 +1498,8 @@ fn dispatch_apply_templates(
&template_entry.template,
&template_entry.options_selected,
&template_entry.features_selected,
- cx,
- &node_runtime,
+ &context,
+ &cli,
)
.await
{
@@ -1496,8 +1541,6 @@ fn dispatch_apply_templates(
this.dismiss(&menu::Cancel, window, cx);
})
.ok();
- } else {
- return;
}
})
.detach();
@@ -3105,6 +3105,24 @@ impl Editor {
self.workspace.as_ref()?.0.upgrade()
}
+ /// Detaches a task and shows an error notification in the workspace if available,
+ /// otherwise just logs the error.
+ pub fn detach_and_notify_err<R, E>(
+ &self,
+ task: Task<Result<R, E>>,
+ window: &mut Window,
+ cx: &mut App,
+ ) where
+ E: std::fmt::Debug + std::fmt::Display + 'static,
+ R: 'static,
+ {
+ if let Some(workspace) = self.workspace() {
+ task.detach_and_notify_err(workspace.downgrade(), window, cx);
+ } else {
+ task.detach_and_log_err(cx);
+ }
+ }
+
/// Returns the workspace serialization ID if this editor should be serialized.
fn workspace_serialization_id(&self, _cx: &App) -> Option<WorkspaceId> {
self.workspace
@@ -11461,8 +11479,8 @@ impl Editor {
let Some(project) = self.project.clone() else {
return;
};
- self.reload(project, window, cx)
- .detach_and_notify_err(window, cx);
+ let task = self.reload(project, window, cx);
+ self.detach_and_notify_err(task, window, cx);
}
pub fn restore_file(
@@ -99,7 +99,6 @@ use workspace::{
CollaboratorId, ItemHandle, ItemSettings, OpenInTerminal, OpenTerminal, RevealInProjectPanel,
Workspace,
item::{BreadcrumbText, Item, ItemBufferKind},
- notifications::NotifyTaskExt,
};
/// Determines what kinds of highlights should be applied to a lines background.
@@ -541,21 +540,21 @@ impl EditorElement {
register_action(editor, window, |editor, action, window, cx| {
if let Some(task) = editor.format(action, window, cx) {
- task.detach_and_notify_err(window, cx);
+ editor.detach_and_notify_err(task, window, cx);
} else {
cx.propagate();
}
});
register_action(editor, window, |editor, action, window, cx| {
if let Some(task) = editor.format_selections(action, window, cx) {
- task.detach_and_notify_err(window, cx);
+ editor.detach_and_notify_err(task, window, cx);
} else {
cx.propagate();
}
});
register_action(editor, window, |editor, action, window, cx| {
if let Some(task) = editor.organize_imports(action, window, cx) {
- task.detach_and_notify_err(window, cx);
+ editor.detach_and_notify_err(task, window, cx);
} else {
cx.propagate();
}
@@ -565,49 +564,49 @@ impl EditorElement {
register_action(editor, window, Editor::show_character_palette);
register_action(editor, window, |editor, action, window, cx| {
if let Some(task) = editor.confirm_completion(action, window, cx) {
- task.detach_and_notify_err(window, cx);
+ editor.detach_and_notify_err(task, window, cx);
} else {
cx.propagate();
}
});
register_action(editor, window, |editor, action, window, cx| {
if let Some(task) = editor.confirm_completion_replace(action, window, cx) {
- task.detach_and_notify_err(window, cx);
+ editor.detach_and_notify_err(task, window, cx);
} else {
cx.propagate();
}
});
register_action(editor, window, |editor, action, window, cx| {
if let Some(task) = editor.confirm_completion_insert(action, window, cx) {
- task.detach_and_notify_err(window, cx);
+ editor.detach_and_notify_err(task, window, cx);
} else {
cx.propagate();
}
});
register_action(editor, window, |editor, action, window, cx| {
if let Some(task) = editor.compose_completion(action, window, cx) {
- task.detach_and_notify_err(window, cx);
+ editor.detach_and_notify_err(task, window, cx);
} else {
cx.propagate();
}
});
register_action(editor, window, |editor, action, window, cx| {
if let Some(task) = editor.confirm_code_action(action, window, cx) {
- task.detach_and_notify_err(window, cx);
+ editor.detach_and_notify_err(task, window, cx);
} else {
cx.propagate();
}
});
register_action(editor, window, |editor, action, window, cx| {
if let Some(task) = editor.rename(action, window, cx) {
- task.detach_and_notify_err(window, cx);
+ editor.detach_and_notify_err(task, window, cx);
} else {
cx.propagate();
}
});
register_action(editor, window, |editor, action, window, cx| {
if let Some(task) = editor.confirm_rename(action, window, cx) {
- task.detach_and_notify_err(window, cx);
+ editor.detach_and_notify_err(task, window, cx);
} else {
cx.propagate();
}
@@ -16,6 +16,10 @@ pub struct AgentV2FeatureFlag;
impl FeatureFlag for AgentV2FeatureFlag {
const NAME: &'static str = "agent-v2";
+
+ fn enabled_for_staff() -> bool {
+ true
+ }
}
pub struct AcpBetaFeatureFlag;
@@ -1566,9 +1566,12 @@ impl PickerDelegate for FileFinderDelegate {
.unwrap_or(0)
.saturating_sub(1);
let finder = self.file_finder.clone();
+ let workspace = self.workspace.clone();
- cx.spawn_in(window, async move |_, cx| {
- let item = open_task.await.notify_async_err(cx)?;
+ cx.spawn_in(window, async move |_, mut cx| {
+ let item = open_task
+ .await
+ .notify_workspace_async_err(workspace, &mut cx)?;
if let Some(row) = row
&& let Some(active_editor) = item.downcast::<Editor>()
{
@@ -9,7 +9,9 @@ use project::{FS_WATCH_LATENCY, RemoveOptions};
use serde_json::json;
use settings::SettingsStore;
use util::{path, rel_path::rel_path};
-use workspace::{AppState, CloseActiveItem, OpenOptions, ToggleFileFinder, Workspace, open_paths};
+use workspace::{
+ AppState, CloseActiveItem, MultiWorkspace, OpenOptions, ToggleFileFinder, Workspace, open_paths,
+};
#[ctor::ctor]
fn init_logger() {
@@ -2534,8 +2536,14 @@ async fn test_search_results_refreshed_on_standalone_file_creation(cx: &mut gpui
.await;
let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
- let (workspace, cx) =
- cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
+ let window = cx.add_window({
+ let project = project.clone();
+ |window, cx| MultiWorkspace::test_new(project, window, cx)
+ });
+ let cx = VisualTestContext::from_window(*window, cx).into_mut();
+ let workspace = window
+ .read_with(cx, |mw, _| mw.workspace().clone())
+ .unwrap();
cx.update(|_, cx| {
open_paths(
@@ -774,7 +774,7 @@ impl CommitView {
callback(repo, &sha, stash, commit_view_entity, workspace_weak, cx).await?;
anyhow::Ok(())
})
- .detach_and_notify_err(window, cx);
+ .detach_and_notify_err(workspace.weak_handle(), window, cx);
}
async fn close_commit_view(
@@ -6,7 +6,7 @@ use editor::{Editor, EditorEvent, MultiBuffer};
use futures::{FutureExt, select_biased};
use gpui::{
AnyElement, App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, FocusHandle,
- Focusable, IntoElement, Render, Task, Window,
+ Focusable, IntoElement, Render, Task, WeakEntity, Window,
};
use language::{Buffer, LanguageRegistry};
use project::Project;
@@ -39,11 +39,10 @@ impl FileDiffView {
pub fn open(
old_path: PathBuf,
new_path: PathBuf,
- workspace: &Workspace,
+ workspace: WeakEntity<Workspace>,
window: &mut Window,
cx: &mut App,
) -> Task<Result<Entity<Self>>> {
- let workspace = workspace.weak_handle();
window.spawn(cx, async move |cx| {
let project = workspace.update(cx, |workspace, _| workspace.project().clone())?;
let old_buffer = project
@@ -406,7 +405,7 @@ mod tests {
FileDiffView::open(
path!("/test/old_file.txt").into(),
path!("/test/new_file.txt").into(),
- workspace,
+ workspace.weak_handle(),
window,
cx,
)
@@ -540,7 +539,7 @@ mod tests {
FileDiffView::open(
PathBuf::from(path!("/test/old_file.txt")),
PathBuf::from(path!("/test/new_file.txt")),
- workspace,
+ workspace.weak_handle(),
window,
cx,
)
@@ -1274,10 +1274,11 @@ impl GitPanel {
})
.ok()?;
+ let workspace = self.workspace.clone();
cx.spawn_in(window, async move |_, mut cx| {
let item = open_task
.await
- .notify_async_err(&mut cx)
+ .notify_workspace_async_err(workspace, &mut cx)
.ok_or_else(|| anyhow::anyhow!("Failed to open file"))?;
if let Some(active_editor) = item.downcast::<Editor>() {
if let Some(diff_task) =
@@ -124,6 +124,7 @@ impl ProjectDiff {
return;
}
let workspace = cx.entity();
+ let workspace_weak = workspace.downgrade();
window
.spawn(cx, async move |cx| {
let this = cx
@@ -138,7 +139,7 @@ impl ProjectDiff {
.ok();
anyhow::Ok(())
})
- .detach_and_notify_err(window, cx);
+ .detach_and_notify_err(workspace_weak, window, cx);
}
pub fn deploy_at(
@@ -4,8 +4,8 @@ use fuzzy::StringMatchCandidate;
use git::repository::Worktree as GitWorktree;
use gpui::{
- Action, App, AsyncApp, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
- InteractiveElement, IntoElement, Modifiers, ModifiersChangedEvent, ParentElement,
+ Action, App, AsyncWindowContext, Context, DismissEvent, Entity, EventEmitter, FocusHandle,
+ Focusable, InteractiveElement, IntoElement, Modifiers, ModifiersChangedEvent, ParentElement,
PathPromptOptions, Render, SharedString, Styled, Subscription, Task, WeakEntity, Window,
actions, rems,
};
@@ -20,7 +20,7 @@ use remote_connection::{RemoteConnectionModal, connect};
use std::{path::PathBuf, sync::Arc};
use ui::{HighlightedLabel, KeyBinding, ListItem, ListItemSpacing, prelude::*};
use util::ResultExt;
-use workspace::{ModalView, Workspace, notifications::DetachAndPromptErr};
+use workspace::{ModalView, MultiWorkspace, Workspace, notifications::DetachAndPromptErr};
actions!(git, [WorktreeFromDefault, WorktreeFromDefaultOnWindow]);
@@ -289,7 +289,6 @@ impl WorktreeListDelegate {
};
let branch = worktree_branch.to_string();
- let window_handle = window.window_handle();
let workspace = self.workspace.clone();
cx.spawn_in(window, async move |_, cx| {
let Some(paths) = worktree_path.await? else {
@@ -355,7 +354,7 @@ impl WorktreeListDelegate {
connection_options,
vec![new_worktree_path],
app_state,
- window_handle,
+ workspace.clone(),
replace_current_window,
cx,
)
@@ -407,13 +406,12 @@ impl WorktreeListDelegate {
|e, _, _| Some(e.to_string()),
);
} else if let Some(connection_options) = connection_options {
- let window_handle = window.window_handle();
cx.spawn_in(window, async move |_, cx| {
open_remote_worktree(
connection_options,
vec![path],
app_state,
- window_handle,
+ workspace,
replace_current_window,
cx,
)
@@ -441,15 +439,16 @@ async fn open_remote_worktree(
connection_options: RemoteConnectionOptions,
paths: Vec<PathBuf>,
app_state: Arc<workspace::AppState>,
- window: gpui::AnyWindowHandle,
+ workspace: WeakEntity<Workspace>,
replace_current_window: bool,
- cx: &mut AsyncApp,
+ cx: &mut AsyncWindowContext,
) -> anyhow::Result<()> {
- let workspace_window = window
- .downcast::<Workspace>()
+ let workspace_window = cx
+ .window_handle()
+ .downcast::<MultiWorkspace>()
.ok_or_else(|| anyhow::anyhow!("Window is not a Workspace window"))?;
- let connect_task = workspace_window.update(cx, |workspace, window, cx| {
+ let connect_task = workspace.update_in(cx, |workspace, window, cx| {
workspace.toggle_modal(window, cx, |window, cx| {
RemoteConnectionModal::new(&connection_options, Vec::new(), window, cx)
});
@@ -473,17 +472,19 @@ async fn open_remote_worktree(
let session = connect_task.await;
- workspace_window.update(cx, |workspace, _window, cx| {
- if let Some(prompt) = workspace.active_modal::<RemoteConnectionModal>(cx) {
- prompt.update(cx, |prompt, cx| prompt.finished(cx))
- }
- })?;
+ workspace
+ .update_in(cx, |workspace, _window, cx| {
+ if let Some(prompt) = workspace.active_modal::<RemoteConnectionModal>(cx) {
+ prompt.update(cx, |prompt, cx| prompt.finished(cx))
+ }
+ })
+ .ok();
let Some(Some(session)) = session else {
return Ok(());
};
- let new_project: Entity<project::Project> = cx.update(|cx| {
+ let new_project: Entity<project::Project> = cx.update(|_, cx| {
project::Project::remote(
session,
app_state.client.clone(),
@@ -494,29 +495,30 @@ async fn open_remote_worktree(
true,
cx,
)
- });
+ })?;
let window_to_use = if replace_current_window {
workspace_window
} else {
let workspace_position = cx
- .update(|cx| {
+ .update(|_, cx| {
workspace::remote_workspace_position_from_db(connection_options.clone(), &paths, cx)
- })
+ })?
.await
.context("fetching workspace position from db")?;
let mut options =
- cx.update(|cx| (app_state.build_window_options)(workspace_position.display, cx));
+ cx.update(|_, cx| (app_state.build_window_options)(workspace_position.display, cx))?;
options.window_bounds = workspace_position.window_bounds;
cx.open_window(options, |window, cx| {
- cx.new(|cx| {
+ let workspace = cx.new(|cx| {
let mut workspace =
Workspace::new(None, new_project.clone(), app_state.clone(), window, cx);
workspace.centered_layout = workspace_position.centered_layout;
workspace
- })
+ });
+ cx.new(|cx| MultiWorkspace::new(workspace, cx))
})?
};
@@ -265,6 +265,8 @@ pub enum IconName {
UserRoundPen,
Warning,
WholeWord,
+ WorkspaceNavClosed,
+ WorkspaceNavOpen,
XCircle,
XCircleFilled,
ZedAgent,
@@ -18,7 +18,6 @@ editor.workspace = true
fuzzy.workspace = true
gpui.workspace = true
language.workspace = true
-platform_title_bar.workspace = true
project.workspace = true
serde_json.workspace = true
serde_json_lenient.workspace = true
@@ -1,8 +1,7 @@
use anyhow::{Context as _, anyhow};
use gpui::{App, DivInspectorState, Inspector, InspectorElementId, IntoElement, Window};
-use platform_title_bar::PlatformTitleBar;
use std::{cell::OnceCell, path::Path, sync::Arc};
-use ui::{Label, Tooltip, prelude::*};
+use ui::{Label, Tooltip, prelude::*, utils::platform_title_bar_height};
use util::{ResultExt as _, command::new_smol_command};
use workspace::AppState;
@@ -61,7 +60,7 @@ fn render_inspector(
let ui_font = theme::setup_ui_font(window, cx);
let colors = cx.theme().colors();
let inspector_id = inspector.active_element_id();
- let toolbar_height = PlatformTitleBar::height(window);
+ let toolbar_height = platform_title_bar_height(window);
v_flex()
.size_full()
@@ -118,17 +118,20 @@ pub fn new_journal_entry(workspace: &Workspace, window: &mut Window, cx: &mut Ap
})?
.await?;
new_workspace
- .update(cx, |workspace, window, cx| {
- workspace.open_paths(
- vec![entry_path],
- workspace::OpenOptions {
- visible: Some(OpenVisible::All),
- ..Default::default()
- },
- None,
- window,
- cx,
- )
+ .update(cx, |multi_workspace, window, cx| {
+ let workspace = multi_workspace.workspace().clone();
+ workspace.update(cx, |workspace, cx| {
+ workspace.open_paths(
+ vec![entry_path],
+ workspace::OpenOptions {
+ visible: Some(OpenVisible::All),
+ ..Default::default()
+ },
+ None,
+ window,
+ cx,
+ )
+ })
})?
.await
} else {
@@ -1319,7 +1319,7 @@ impl KeymapEditor {
cx.spawn(async move |_, _| {
remove_keybinding(to_remove, &fs, keyboard_mapper.as_ref()).await
})
- .detach_and_notify_err(window, cx);
+ .detach_and_notify_err(self.workspace.clone(), window, cx);
}
fn copy_context_to_clipboard(
@@ -8,7 +8,7 @@ use std::{
use gpui::{
App, AppContext, ClipboardItem, Context, Div, Entity, Hsla, InteractiveElement,
ParentElement as _, Render, SerializedTaskTiming, SharedString, StatefulInteractiveElement,
- Styled, Task, TaskTiming, TitlebarOptions, UniformListScrollHandle, WindowBounds, WindowHandle,
+ Styled, Task, TaskTiming, TitlebarOptions, UniformListScrollHandle, WeakEntity, WindowBounds,
WindowOptions, div, prelude::FluentBuilder, px, relative, size, uniform_list,
};
use util::ResultExt;
@@ -22,13 +22,10 @@ use workspace::{
use zed_actions::OpenPerformanceProfiler;
pub fn init(startup_time: Instant, cx: &mut App) {
- cx.observe_new(move |workspace: &mut workspace::Workspace, _, _| {
- workspace.register_action(move |workspace, _: &OpenPerformanceProfiler, window, cx| {
- let window_handle = window
- .window_handle()
- .downcast::<Workspace>()
- .expect("Workspaces are root Windows");
- open_performance_profiler(startup_time, workspace, window_handle, cx);
+ cx.observe_new(move |workspace: &mut workspace::Workspace, _, cx| {
+ let workspace_handle = cx.entity().downgrade();
+ workspace.register_action(move |_workspace, _: &OpenPerformanceProfiler, window, cx| {
+ open_performance_profiler(startup_time, workspace_handle.clone(), window, cx);
});
})
.detach();
@@ -36,8 +33,8 @@ pub fn init(startup_time: Instant, cx: &mut App) {
fn open_performance_profiler(
startup_time: Instant,
- _workspace: &mut workspace::Workspace,
- workspace_handle: WindowHandle<Workspace>,
+ workspace_handle: WeakEntity<Workspace>,
+ _window: &mut gpui::Window,
cx: &mut App,
) {
let existing_window = cx
@@ -48,7 +45,7 @@ fn open_performance_profiler(
if let Some(existing_window) = existing_window {
existing_window
.update(cx, |profiler_window, window, _cx| {
- profiler_window.workspace = Some(workspace_handle);
+ profiler_window.workspace = Some(workspace_handle.clone());
window.activate_window();
})
.log_err();
@@ -97,14 +94,14 @@ pub struct ProfilerWindow {
include_self_timings: ToggleState,
autoscroll: bool,
scroll_handle: UniformListScrollHandle,
- workspace: Option<WindowHandle<Workspace>>,
+ workspace: Option<WeakEntity<Workspace>>,
_refresh: Option<Task<()>>,
}
impl ProfilerWindow {
pub fn new(
startup_time: Instant,
- workspace_handle: Option<WindowHandle<Workspace>>,
+ workspace_handle: Option<WeakEntity<Workspace>>,
cx: &mut App,
) -> Entity<Self> {
let entity = cx.new(|cx| ProfilerWindow {
@@ -280,7 +277,7 @@ impl Render for ProfilerWindow {
Button::new("export-data", "Save")
.style(ButtonStyle::Filled)
.on_click(cx.listener(|this, _, _window, cx| {
- let Some(workspace) = this.workspace else {
+ let Some(workspace) = this.workspace.as_ref() else {
return;
};
@@ -297,7 +294,7 @@ impl Render for ProfilerWindow {
.log_err()
.flatten()
.and_then(|p| p.parent().map(|p| p.to_owned()))
- .unwrap_or_else(|| PathBuf::default());
+ .unwrap_or_else(PathBuf::default);
let path = cx.prompt_for_new_path(
&active_path,
@@ -238,15 +238,16 @@ impl Onboarding {
go_to_welcome_page(cx);
}
- fn handle_sign_in(_: &SignIn, window: &mut Window, cx: &mut App) {
+ fn handle_sign_in(&mut self, _: &SignIn, window: &mut Window, cx: &mut Context<Self>) {
let client = Client::global(cx);
+ let workspace = self.workspace.clone();
window
- .spawn(cx, async move |cx| {
+ .spawn(cx, async move |mut cx| {
client
- .sign_in_with_optional_connect(true, cx)
+ .sign_in_with_optional_connect(true, &cx)
.await
- .notify_async_err(cx);
+ .notify_workspace_async_err(workspace, &mut cx);
})
.detach();
}
@@ -274,7 +275,7 @@ impl Render for Onboarding {
.size_full()
.bg(cx.theme().colors().editor_background)
.on_action(Self::on_finish)
- .on_action(Self::handle_sign_in)
+ .on_action(cx.listener(Self::handle_sign_in))
.on_action(Self::handle_open_account)
.on_action(cx.listener(|_, _: &menu::SelectNext, window, cx| {
window.focus_next(cx);
@@ -20,7 +20,7 @@ use settings::Settings;
use theme::{ActiveTheme, ThemeSettings};
use ui::{ListItem, ListItemSpacing, prelude::*};
use util::ResultExt;
-use workspace::{DismissDecision, ModalView, Workspace};
+use workspace::{DismissDecision, ModalView};
pub fn init(cx: &mut App) {
cx.observe_new(OutlineView::register).detach();
@@ -48,7 +48,8 @@ pub fn toggle(
.snapshot(cx)
.outline(Some(cx.theme().syntax()));
- let workspace = window.root::<Workspace>().flatten();
+ let workspace = editor.read(cx).workspace();
+
if let Some((workspace, outline)) = workspace.zip(outline) {
workspace.update(cx, |workspace, cx| {
workspace.toggle_modal(window, cx, |window, cx| {
@@ -13,6 +13,7 @@ path = "src/platform_title_bar.rs"
doctest = false
[dependencies]
+feature_flags.workspace = true
gpui.workspace = true
settings.workspace = true
smallvec.workspace = true
@@ -1,16 +1,21 @@
mod platforms;
mod system_window_tabs;
+use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt};
use gpui::{
- AnyElement, Context, Decorations, Entity, Hsla, InteractiveElement, IntoElement, MouseButton,
- ParentElement, Pixels, StatefulInteractiveElement, Styled, Window, WindowControlArea, div, px,
+ AnyElement, App, Context, Decorations, Entity, Hsla, InteractiveElement, IntoElement,
+ MouseButton, ParentElement, StatefulInteractiveElement, Styled, Window, WindowControlArea, div,
+ px,
};
use smallvec::SmallVec;
use std::mem;
-use ui::prelude::*;
+use ui::{
+ prelude::*,
+ utils::{TRAFFIC_LIGHT_PADDING, platform_title_bar_height},
+};
use crate::{
- platforms::{platform_linux, platform_mac, platform_windows},
+ platforms::{platform_linux, platform_windows},
system_window_tabs::SystemWindowTabs,
};
@@ -24,6 +29,8 @@ pub struct PlatformTitleBar {
children: SmallVec<[AnyElement; 2]>,
should_move: bool,
system_window_tabs: Entity<SystemWindowTabs>,
+ workspace_sidebar_open: bool,
+ sidebar_has_notifications: bool,
}
impl PlatformTitleBar {
@@ -37,20 +44,11 @@ impl PlatformTitleBar {
children: SmallVec::new(),
should_move: false,
system_window_tabs,
+ workspace_sidebar_open: false,
+ sidebar_has_notifications: false,
}
}
- #[cfg(not(target_os = "windows"))]
- pub fn height(window: &mut Window) -> Pixels {
- (1.75 * window.rem_size()).max(px(34.))
- }
-
- #[cfg(target_os = "windows")]
- pub fn height(_window: &mut Window) -> Pixels {
- // todo(windows) instead of hard coded size report the actual size to the Windows platform API
- px(32.)
- }
-
pub fn title_bar_color(&self, window: &mut Window, cx: &mut Context<Self>) -> Hsla {
if cfg!(any(target_os = "linux", target_os = "freebsd")) {
if window.is_window_active() && !self.should_move {
@@ -73,17 +71,46 @@ impl PlatformTitleBar {
pub fn init(cx: &mut App) {
SystemWindowTabs::init(cx);
}
+
+ pub fn is_workspace_sidebar_open(&self) -> bool {
+ self.workspace_sidebar_open
+ }
+
+ pub fn set_workspace_sidebar_open(&mut self, open: bool, cx: &mut Context<Self>) {
+ self.workspace_sidebar_open = open;
+ cx.notify();
+ }
+
+ pub fn sidebar_has_notifications(&self) -> bool {
+ self.sidebar_has_notifications
+ }
+
+ pub fn set_sidebar_has_notifications(
+ &mut self,
+ has_notifications: bool,
+ cx: &mut Context<Self>,
+ ) {
+ self.sidebar_has_notifications = has_notifications;
+ cx.notify();
+ }
+
+ pub fn is_multi_workspace_enabled(cx: &App) -> bool {
+ cx.has_flag::<AgentV2FeatureFlag>()
+ }
}
impl Render for PlatformTitleBar {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let supported_controls = window.window_controls();
let decorations = window.window_decorations();
- let height = Self::height(window);
+ let height = platform_title_bar_height(window);
let titlebar_color = self.title_bar_color(window, cx);
let close_action = Box::new(workspace::CloseWindow);
let children = mem::take(&mut self.children);
+ let is_multiworkspace_sidebar_open =
+ PlatformTitleBar::is_multi_workspace_enabled(cx) && self.is_workspace_sidebar_open();
+
let title_bar = h_flex()
.window_control_area(WindowControlArea::Drag)
.w_full()
@@ -132,8 +159,10 @@ impl Render for PlatformTitleBar {
.map(|this| {
if window.is_fullscreen() {
this.pl_2()
- } else if self.platform_style == PlatformStyle::Mac {
- this.pl(px(platform_mac::TRAFFIC_LIGHT_PADDING))
+ } else if self.platform_style == PlatformStyle::Mac
+ && !is_multiworkspace_sidebar_open
+ {
+ this.pl(px(TRAFFIC_LIGHT_PADDING))
} else {
this.pl_2()
}
@@ -1,3 +1,2 @@
pub mod platform_linux;
-pub mod platform_mac;
pub mod platform_windows;
@@ -1,10 +0,0 @@
-// Use pixels here instead of a rem-based size because the macOS traffic
-// lights are a static size, and don't scale with the rest of the UI.
-//
-// Magic number: There is one extra pixel of padding on the left side due to
-// the 1px border around the window on macOS apps.
-#[cfg(macos_sdk_26)]
-pub const TRAFFIC_LIGHT_PADDING: f32 = 78.;
-
-#[cfg(not(macos_sdk_26))]
-pub const TRAFFIC_LIGHT_PADDING: f32 = 71.;
@@ -756,7 +756,11 @@ impl ProjectPanel {
{
match project_panel.confirm_edit(false, window, cx) {
Some(task) => {
- task.detach_and_notify_err(window, cx);
+ task.detach_and_notify_err(
+ project_panel.workspace.clone(),
+ window,
+ cx,
+ );
}
None => {
project_panel.discard_edit_state(window, cx);
@@ -1631,7 +1635,7 @@ impl ProjectPanel {
fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
if let Some(task) = self.confirm_edit(true, window, cx) {
- task.detach_and_notify_err(window, cx);
+ task.detach_and_notify_err(self.workspace.clone(), window, cx);
}
}
@@ -3002,20 +3006,25 @@ impl ProjectPanel {
}
let item_count = paste_tasks.len();
+ let workspace = self.workspace.clone();
- cx.spawn_in(window, async move |project_panel, cx| {
+ cx.spawn_in(window, async move |project_panel, mut cx| {
let mut last_succeed = None;
for task in paste_tasks {
match task {
PasteTask::Rename(task) => {
- if let Some(CreatedEntry::Included(entry)) =
- task.await.notify_async_err(cx)
+ if let Some(CreatedEntry::Included(entry)) = task
+ .await
+ .notify_workspace_async_err(workspace.clone(), &mut cx)
{
last_succeed = Some(entry);
}
}
PasteTask::Copy(task) => {
- if let Some(Some(entry)) = task.await.notify_async_err(cx) {
+ if let Some(Some(entry)) = task
+ .await
+ .notify_workspace_async_err(workspace.clone(), &mut cx)
+ {
last_succeed = Some(entry);
}
}
@@ -3357,7 +3366,7 @@ impl ProjectPanel {
if let Some((file_path1, file_path2)) = selected_files {
self.workspace
.update(cx, |workspace, cx| {
- FileDiffView::open(file_path1, file_path2, workspace, window, cx)
+ FileDiffView::open(file_path1, file_path2, workspace.weak_handle(), window, cx)
.detach_and_log_err(cx);
})
.ok();
@@ -23,6 +23,7 @@ db.workspace = true
dev_container.workspace = true
editor.workspace = true
extension_host.workspace = true
+fs.workspace = true
futures.workspace = true
fuzzy.workspace = true
gpui.workspace = true
@@ -66,6 +67,7 @@ language = { workspace = true, features = ["test-support"] }
project = { workspace = true, features = ["test-support"] }
release_channel.workspace = true
remote = { workspace = true, features = ["test-support"] }
+remote_connection = { workspace = true, features = ["test-support"] }
remote_server.workspace = true
serde_json.workspace = true
settings = { workspace = true, features = ["test-support"] }
@@ -7,7 +7,9 @@ use ui::{
HeadlineSize, IconName, IconPosition, InteractiveElement, IntoElement, Label, Modal,
ModalFooter, ModalHeader, ParentElement, Section, Styled, StyledExt, Window, div, h_flex, rems,
};
-use workspace::{ModalView, OpenOptions, Workspace, notifications::DetachAndPromptErr};
+use workspace::{
+ ModalView, MultiWorkspace, OpenOptions, Workspace, notifications::DetachAndPromptErr,
+};
use crate::open_remote_project;
@@ -109,7 +111,7 @@ impl DisconnectedOverlay {
return;
};
- let Some(window_handle) = window.window_handle().downcast::<Workspace>() else {
+ let Some(window_handle) = window.window_handle().downcast::<MultiWorkspace>() else {
return;
};
@@ -4,7 +4,9 @@ mod remote_connections;
mod remote_servers;
mod ssh_config;
-use std::path::PathBuf;
+use std::{path::PathBuf, sync::Arc};
+
+use fs::Fs;
#[cfg(target_os = "windows")]
mod wsl_picker;
@@ -27,11 +29,11 @@ use picker::{
pub use remote_connections::RemoteSettings;
pub use remote_servers::RemoteServerProjects;
use settings::Settings;
-use std::{path::Path, sync::Arc};
+use std::path::Path;
use ui::{KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*, tooltip_container};
use util::{ResultExt, paths::PathExt};
use workspace::{
- CloseIntent, HistoryManager, ModalView, OpenOptions, PathList, SerializedWorkspaceLocation,
+ HistoryManager, ModalView, MultiWorkspace, OpenOptions, PathList, SerializedWorkspaceLocation,
WORKSPACE_DB, Workspace, WorkspaceId, notifications::DetachAndPromptErr,
with_active_or_new_workspace,
};
@@ -48,9 +50,10 @@ pub struct RecentProjectEntry {
pub async fn get_recent_projects(
current_workspace_id: Option<WorkspaceId>,
limit: Option<usize>,
+ fs: Arc<dyn fs::Fs>,
) -> Vec<RecentProjectEntry> {
let workspaces = WORKSPACE_DB
- .recent_workspaces_on_disk()
+ .recent_workspaces_on_disk(fs.as_ref())
.await
.unwrap_or_default();
@@ -176,7 +179,7 @@ pub fn init(cx: &mut App) {
let fs = workspace.project().read(cx).fs().clone();
add_wsl_distro(fs, &open_wsl.distro, cx);
let open_options = OpenOptions {
- replace_window: window.window_handle().downcast::<Workspace>(),
+ replace_window: window.window_handle().downcast::<MultiWorkspace>(),
..Default::default()
};
@@ -232,10 +235,8 @@ pub fn init(cx: &mut App) {
cx.on_action(|_: &OpenDevContainer, cx| {
with_active_or_new_workspace(cx, move |workspace, window, cx| {
- let is_local = workspace.project().read(cx).is_local();
-
- cx.spawn_in(window, async move |_, cx| {
- if !is_local {
+ if !workspace.project().read(cx).is_local() {
+ cx.spawn_in(window, async move |_, cx| {
cx.prompt(
gpui::PromptLevel::Critical,
"Cannot open Dev Container from remote project",
@@ -244,21 +245,16 @@ pub fn init(cx: &mut App) {
)
.await
.ok();
- return;
- }
-
- cx.update(|_, cx| {
- with_active_or_new_workspace(cx, move |workspace, window, cx| {
- let fs = workspace.project().read(cx).fs().clone();
- let handle = cx.entity().downgrade();
- workspace.toggle_modal(window, cx, |window, cx| {
- RemoteServerProjects::new_dev_container(fs, window, handle, cx)
- });
- });
})
- .log_err();
- })
- .detach();
+ .detach();
+ return;
+ }
+
+ let fs = workspace.project().read(cx).fs().clone();
+ let handle = cx.entity().downgrade();
+ workspace.toggle_modal(window, cx, |window, cx| {
+ RemoteServerProjects::new_dev_container(fs, window, handle, cx)
+ });
});
});
@@ -334,6 +330,7 @@ impl ModalView for RecentProjects {}
impl RecentProjects {
fn new(
delegate: RecentProjectsDelegate,
+ fs: Option<Arc<dyn Fs>>,
rem_width: f32,
window: &mut Window,
cx: &mut Context<Self>,
@@ -350,8 +347,9 @@ impl RecentProjects {
// We do not want to block the UI on a potentially lengthy call to DB, so we're gonna swap
// out workspace locations once the future runs to completion.
cx.spawn_in(window, async move |this, cx| {
+ let Some(fs) = fs else { return };
let workspaces = WORKSPACE_DB
- .recent_workspaces_on_disk()
+ .recent_workspaces_on_disk(fs.as_ref())
.await
.log_err()
.unwrap_or_default();
@@ -361,7 +359,7 @@ impl RecentProjects {
picker.update_matches(picker.query(cx), window, cx)
})
})
- .ok()
+ .ok();
})
.detach();
Self {
@@ -379,10 +377,11 @@ impl RecentProjects {
cx: &mut Context<Workspace>,
) {
let weak = cx.entity().downgrade();
+ let fs = Some(workspace.app_state().fs.clone());
workspace.toggle_modal(window, cx, |window, cx| {
let delegate = RecentProjectsDelegate::new(weak, create_new_window, true, focus_handle);
- Self::new(delegate, 34., window, cx)
+ Self::new(delegate, fs, 34., window, cx)
})
}
@@ -393,10 +392,13 @@ impl RecentProjects {
window: &mut Window,
cx: &mut App,
) -> Entity<Self> {
+ let fs = workspace
+ .upgrade()
+ .map(|ws| ws.read(cx).app_state().fs.clone());
cx.new(|cx| {
let delegate =
RecentProjectsDelegate::new(workspace, create_new_window, true, focus_handle);
- let list = Self::new(delegate, 34., window, cx);
+ let list = Self::new(delegate, fs, 34., window, cx);
list.picker.focus_handle(cx).focus(window, cx);
list
})
@@ -580,27 +582,21 @@ impl PickerDelegate for RecentProjectsDelegate {
SerializedWorkspaceLocation::Local => {
let paths = candidate_workspace_paths.paths().to_vec();
if replace_current_window {
- cx.spawn_in(window, async move |workspace, cx| {
- let continue_replacing = workspace
- .update_in(cx, |workspace, window, cx| {
- workspace.prepare_to_close(
- CloseIntent::ReplaceWindow,
- window,
- cx,
- )
- })?
- .await?;
- if continue_replacing {
- workspace
- .update_in(cx, |workspace, window, cx| {
- workspace
- .open_workspace_for_paths(true, paths, window, cx)
- })?
- .await
- } else {
- Ok(())
- }
- })
+ if let Some(handle) =
+ window.window_handle().downcast::<MultiWorkspace>()
+ {
+ cx.defer(move |cx| {
+ if let Some(task) = handle
+ .update(cx, |multi_workspace, window, cx| {
+ multi_workspace.open_project(paths, window, cx)
+ })
+ .log_err()
+ {
+ task.detach_and_log_err(cx);
+ }
+ });
+ }
+ return;
} else {
workspace.open_workspace_for_paths(false, paths, window, cx)
}
@@ -609,7 +605,7 @@ impl PickerDelegate for RecentProjectsDelegate {
let app_state = workspace.app_state().clone();
let replace_window = if replace_current_window {
- window.window_handle().downcast::<Workspace>()
+ window.window_handle().downcast::<MultiWorkspace>()
} else {
None
};
@@ -884,10 +880,18 @@ impl RecentProjectsDelegate {
) {
if let Some(selected_match) = self.matches.get(ix) {
let (workspace_id, _, _) = self.workspaces[selected_match.candidate_id];
+ let fs = self
+ .workspace
+ .upgrade()
+ .map(|ws| ws.read(cx).app_state().fs.clone());
cx.spawn_in(window, async move |this, cx| {
- let _ = WORKSPACE_DB.delete_workspace_by_id(workspace_id).await;
+ WORKSPACE_DB
+ .delete_workspace_by_id(workspace_id)
+ .await
+ .log_err();
+ let Some(fs) = fs else { return };
let workspaces = WORKSPACE_DB
- .recent_workspaces_on_disk()
+ .recent_workspaces_on_disk(fs.as_ref())
.await
.unwrap_or_default();
this.update_in(cx, move |picker, window, cx| {
@@ -904,6 +908,7 @@ impl RecentProjectsDelegate {
.update(cx, |this, cx| this.delete_history(workspace_id, cx));
}
})
+ .ok();
})
.detach();
}
@@ -951,7 +956,7 @@ mod tests {
use super::*;
#[gpui::test]
- async fn test_prompts_on_dirty_before_submit(cx: &mut TestAppContext) {
+ async fn test_dirty_workspace_survives_when_opening_recent_project(cx: &mut TestAppContext) {
let app_state = init_test(cx);
cx.update(|cx| {
@@ -975,6 +980,11 @@ mod tests {
}),
)
.await;
+ app_state
+ .fs
+ .as_fake()
+ .insert_tree(path!("/test/path"), json!({}))
+ .await;
cx.update(|cx| {
open_paths(
&[PathBuf::from(path!("/dir/main.ts"))],
@@ -987,31 +997,40 @@ mod tests {
.unwrap();
assert_eq!(cx.update(|cx| cx.windows().len()), 1);
- let workspace = cx.update(|cx| cx.windows()[0].downcast::<Workspace>().unwrap());
- workspace
- .update(cx, |workspace, _, _| assert!(!workspace.is_edited()))
+ let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
+ multi_workspace
+ .update(cx, |multi_workspace, _, cx| {
+ assert!(!multi_workspace.workspace().read(cx).is_edited())
+ })
.unwrap();
- let editor = workspace
- .read_with(cx, |workspace, cx| {
- workspace
+ let editor = multi_workspace
+ .read_with(cx, |multi_workspace, cx| {
+ multi_workspace
+ .workspace()
+ .read(cx)
.active_item(cx)
.unwrap()
.downcast::<Editor>()
.unwrap()
})
.unwrap();
- workspace
+ multi_workspace
.update(cx, |_, window, cx| {
editor.update(cx, |editor, cx| editor.insert("EDIT", window, cx));
})
.unwrap();
- workspace
- .update(cx, |workspace, _, _| assert!(workspace.is_edited(), "After inserting more text into the editor without saving, we should have a dirty project"))
+ multi_workspace
+ .update(cx, |multi_workspace, _, cx| {
+ assert!(
+ multi_workspace.workspace().read(cx).is_edited(),
+ "After inserting more text into the editor without saving, we should have a dirty project"
+ )
+ })
.unwrap();
- let recent_projects_picker = open_recent_projects(&workspace, cx);
- workspace
+ let recent_projects_picker = open_recent_projects(&multi_workspace, cx);
+ multi_workspace
.update(cx, |_, _, cx| {
recent_projects_picker.update(cx, |picker, cx| {
assert_eq!(picker.query(cx), "");
@@ -1035,47 +1054,64 @@ mod tests {
!cx.has_pending_prompt(),
"Should have no pending prompt on dirty project before opening the new recent project"
);
- cx.dispatch_action(*workspace, menu::Confirm);
- workspace
- .update(cx, |workspace, _, cx| {
+ let dirty_workspace = multi_workspace
+ .read_with(cx, |multi_workspace, _cx| {
+ multi_workspace.workspace().clone()
+ })
+ .unwrap();
+
+ cx.dispatch_action(*multi_workspace, menu::Confirm);
+ cx.run_until_parked();
+
+ multi_workspace
+ .update(cx, |multi_workspace, _, cx| {
assert!(
- workspace.active_modal::<RecentProjects>(cx).is_none(),
+ multi_workspace
+ .workspace()
+ .read(cx)
+ .active_modal::<RecentProjects>(cx)
+ .is_none(),
"Should remove the modal after selecting new recent project"
- )
+ );
+
+ assert!(
+ multi_workspace.workspaces().len() >= 2,
+ "Should have at least 2 workspaces: the dirty one and the newly opened one"
+ );
+
+ assert!(
+ multi_workspace.workspaces().contains(&dirty_workspace),
+ "The original dirty workspace should still be present"
+ );
+
+ assert!(
+ dirty_workspace.read(cx).is_edited(),
+ "The original workspace should still be dirty"
+ );
})
.unwrap();
- assert!(
- cx.has_pending_prompt(),
- "Dirty workspace should prompt before opening the new recent project"
- );
- cx.simulate_prompt_answer("Cancel");
+
assert!(
!cx.has_pending_prompt(),
- "Should have no pending prompt after cancelling"
+ "No save prompt in multi-workspace mode — dirty workspace survives in background"
);
- workspace
- .update(cx, |workspace, _, _| {
- assert!(
- workspace.is_edited(),
- "Should be in the same dirty project after cancelling"
- )
- })
- .unwrap();
}
fn open_recent_projects(
- workspace: &WindowHandle<Workspace>,
+ multi_workspace: &WindowHandle<MultiWorkspace>,
cx: &mut TestAppContext,
) -> Entity<Picker<RecentProjectsDelegate>> {
cx.dispatch_action(
- (*workspace).into(),
+ (*multi_workspace).into(),
OpenRecent {
create_new_window: false,
},
);
- workspace
- .update(cx, |workspace, _, cx| {
- workspace
+ multi_workspace
+ .update(cx, |multi_workspace, _, cx| {
+ multi_workspace
+ .workspace()
+ .read(cx)
.active_modal::<RecentProjects>(cx)
.unwrap()
.read(cx)
@@ -19,7 +19,7 @@ use remote::{
pub use settings::SshConnection;
use settings::{DevContainerConnection, ExtendingVec, RegisterSetting, Settings, WslConnection};
use util::paths::PathWithPosition;
-use workspace::{AppState, Workspace};
+use workspace::{AppState, MultiWorkspace, Workspace};
pub use remote_connection::{
RemoteClientDelegate, RemoteConnectionModal, RemoteConnectionPrompt, SshConnectionHeader,
@@ -131,8 +131,11 @@ pub async fn open_remote_project(
cx: &mut AsyncApp,
) -> Result<()> {
let created_new_window = open_options.replace_window.is_none();
- let window = if let Some(window) = open_options.replace_window {
- window
+ let (window, initial_workspace) = if let Some(window) = open_options.replace_window {
+ let workspace = window.update(cx, |multi_workspace, _, _| {
+ multi_workspace.workspace().clone()
+ })?;
+ (window, workspace)
} else {
let workspace_position = cx
.update(|cx| {
@@ -145,7 +148,7 @@ pub async fn open_remote_project(
cx.update(|cx| (app_state.build_window_options)(workspace_position.display, cx));
options.window_bounds = workspace_position.window_bounds;
- cx.open_window(options, |window, cx| {
+ let window = cx.open_window(options, |window, cx| {
let project = project::Project::local(
app_state.client.clone(),
app_state.node_runtime.clone(),
@@ -159,12 +162,17 @@ pub async fn open_remote_project(
},
cx,
);
- cx.new(|cx| {
+ let workspace = cx.new(|cx| {
let mut workspace = Workspace::new(None, project, app_state.clone(), window, cx);
workspace.centered_layout = workspace_position.centered_layout;
workspace
- })
- })?
+ });
+ cx.new(|cx| MultiWorkspace::new(workspace, cx))
+ })?;
+ let workspace = window.update(cx, |multi_workspace, _, _cx| {
+ multi_workspace.workspace().clone()
+ })?;
+ (window, workspace)
};
loop {
@@ -172,35 +180,38 @@ pub async fn open_remote_project(
let delegate = window.update(cx, {
let paths = paths.clone();
let connection_options = connection_options.clone();
- move |workspace, window, cx| {
+ let initial_workspace = initial_workspace.clone();
+ move |_multi_workspace: &mut MultiWorkspace, window, cx| {
window.activate_window();
- workspace.hide_modal(window, cx);
- workspace.toggle_modal(window, cx, |window, cx| {
- RemoteConnectionModal::new(&connection_options, paths, window, cx)
- });
-
- let ui = workspace
- .active_modal::<RemoteConnectionModal>(cx)?
- .read(cx)
- .prompt
- .clone();
-
- ui.update(cx, |ui, _cx| {
- ui.set_cancellation_tx(cancel_tx);
- });
-
- Some(Arc::new(RemoteClientDelegate::new(
- window.window_handle(),
- ui.downgrade(),
- if let RemoteConnectionOptions::Ssh(options) = &connection_options {
- options
- .password
- .as_deref()
- .and_then(|pw| EncryptedPassword::try_from(pw).ok())
- } else {
- None
- },
- )))
+ initial_workspace.update(cx, |workspace, cx| {
+ workspace.hide_modal(window, cx);
+ workspace.toggle_modal(window, cx, |window, cx| {
+ RemoteConnectionModal::new(&connection_options, paths, window, cx)
+ });
+
+ let ui = workspace
+ .active_modal::<RemoteConnectionModal>(cx)?
+ .read(cx)
+ .prompt
+ .clone();
+
+ ui.update(cx, |ui, _cx| {
+ ui.set_cancellation_tx(cancel_tx);
+ });
+
+ Some(Arc::new(RemoteClientDelegate::new(
+ window.window_handle(),
+ ui.downgrade(),
+ if let RemoteConnectionOptions::Ssh(options) = &connection_options {
+ options
+ .password
+ .as_deref()
+ .and_then(|pw| EncryptedPassword::try_from(pw).ok())
+ } else {
+ None
+ },
+ )))
+ })
}
})?;
@@ -209,13 +220,11 @@ pub async fn open_remote_project(
let connection = remote::connect(connection_options.clone(), delegate.clone(), cx);
let connection = select! {
_ = cancel_rx => {
- window
- .update(cx, |workspace, _, cx| {
- if let Some(ui) = workspace.active_modal::<RemoteConnectionModal>(cx) {
- ui.update(cx, |modal, cx| modal.finished(cx))
- }
- })
- .ok();
+ initial_workspace.update(cx, |workspace, cx| {
+ if let Some(ui) = workspace.active_modal::<RemoteConnectionModal>(cx) {
+ ui.update(cx, |modal, cx| modal.finished(cx))
+ }
+ });
break;
},
@@ -224,13 +233,11 @@ pub async fn open_remote_project(
let remote_connection = match connection {
Ok(connection) => connection,
Err(e) => {
- window
- .update(cx, |workspace, _, cx| {
- if let Some(ui) = workspace.active_modal::<RemoteConnectionModal>(cx) {
- ui.update(cx, |modal, cx| modal.finished(cx))
- }
- })
- .ok();
+ initial_workspace.update(cx, |workspace, cx| {
+ if let Some(ui) = workspace.active_modal::<RemoteConnectionModal>(cx) {
+ ui.update(cx, |modal, cx| modal.finished(cx))
+ }
+ });
log::error!("Failed to open project: {e:#}");
let response = window
.update(cx, |_, window, cx| {
@@ -284,13 +291,11 @@ pub async fn open_remote_project(
})
.await;
- window
- .update(cx, |workspace, _, cx| {
- if let Some(ui) = workspace.active_modal::<RemoteConnectionModal>(cx) {
- ui.update(cx, |modal, cx| modal.finished(cx))
- }
- })
- .ok();
+ initial_workspace.update(cx, |workspace, cx| {
+ if let Some(ui) = workspace.active_modal::<RemoteConnectionModal>(cx) {
+ ui.update(cx, |modal, cx| modal.finished(cx))
+ }
+ });
match opened_items {
Err(e) => {
@@ -320,20 +325,20 @@ pub async fn open_remote_project(
continue;
}
- window
- .update(cx, |workspace, window, cx| {
- if created_new_window {
- window.remove_window();
- }
- trusted_worktrees::track_worktree_trust(
- workspace.project().read(cx).worktree_store(),
- None,
- None,
- None,
- cx,
- );
- })
- .ok();
+ if created_new_window {
+ window
+ .update(cx, |_, window, _| window.remove_window())
+ .ok();
+ }
+ initial_workspace.update(cx, |workspace, cx| {
+ trusted_worktrees::track_worktree_trust(
+ workspace.project().read(cx).worktree_store(),
+ None,
+ None,
+ None,
+ cx,
+ );
+ });
}
Ok(items) => {
@@ -366,14 +371,20 @@ pub async fn open_remote_project(
break;
}
+ // Register the remote client with extensions. We use `multi_workspace.workspace()` here
+ // (not `initial_workspace`) because `open_remote_project_inner` activated the new remote
+ // workspace, so the active workspace is now the one with the remote project.
window
- .update(cx, |workspace, _, cx| {
- if let Some(client) = workspace.project().read(cx).remote_client() {
- if let Some(extension_store) = ExtensionStore::try_global(cx) {
- extension_store
- .update(cx, |store, cx| store.register_remote_client(client, cx));
+ .update(cx, |multi_workspace: &mut MultiWorkspace, _, cx| {
+ let workspace = multi_workspace.workspace().clone();
+ workspace.update(cx, |workspace, cx| {
+ if let Some(client) = workspace.project().read(cx).remote_client() {
+ if let Some(extension_store) = ExtensionStore::try_global(cx) {
+ extension_store
+ .update(cx, |store, cx| store.register_remote_client(client, cx));
+ }
}
- }
+ });
})
.ok();
Ok(())
@@ -500,12 +511,16 @@ mod tests {
let windows = cx.update(|cx| cx.windows().len());
assert_eq!(windows, 1, "Should have opened a window");
- let workspace_handle = cx.update(|cx| cx.windows()[0].downcast::<Workspace>().unwrap());
+ let multi_workspace_handle =
+ cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
- workspace_handle
- .update(cx, |workspace, _, cx| {
- let project = workspace.project().read(cx);
- assert!(project.is_remote(), "Project should be a remote project");
+ multi_workspace_handle
+ .update(cx, |multi_workspace, _, cx| {
+ let workspace = multi_workspace.workspace().clone();
+ workspace.update(cx, |workspace, cx| {
+ let project = workspace.project().read(cx);
+ assert!(project.is_remote(), "Project should be a remote project");
+ });
})
.unwrap();
}
@@ -6,7 +6,8 @@ use crate::{
ssh_config::parse_ssh_config_hosts,
};
use dev_container::{
- DevContainerConfig, find_devcontainer_configs, start_dev_container_with_config,
+ DevContainerConfig, DevContainerContext, find_devcontainer_configs,
+ start_dev_container_with_config,
};
use editor::Editor;
@@ -51,7 +52,7 @@ use util::{
rel_path::RelPath,
};
use workspace::{
- ModalView, OpenLog, OpenOptions, Toast, Workspace,
+ ModalView, MultiWorkspace, OpenLog, OpenOptions, Toast, Workspace,
notifications::{DetachAndPromptErr, NotificationId},
open_remote_project_with_existing_connection,
};
@@ -478,10 +479,11 @@ impl ProjectPicker {
.log_err()?;
let window = cx
.open_window(options, |window, cx| {
- cx.new(|cx| {
+ let workspace = cx.new(|cx| {
telemetry::event!("SSH Project Created");
Workspace::new(None, project.clone(), app_state.clone(), window, cx)
- })
+ });
+ cx.new(|cx| MultiWorkspace::new(workspace, cx))
})
.log_err()?;
@@ -808,11 +810,18 @@ impl RemoteServerProjects {
workspace: WeakEntity<Workspace>,
cx: &mut Context<Self>,
) -> Self {
- let this = Self::new_inner(
- Mode::CreateRemoteDevContainer(CreateRemoteDevContainer::new(
- DevContainerCreationProgress::Creating,
- cx,
- )),
+ let configs = workspace
+ .read_with(cx, |workspace, cx| find_devcontainer_configs(workspace, cx))
+ .unwrap_or_default();
+
+ let initial_mode = if configs.len() > 1 {
+ DevContainerCreationProgress::SelectingConfig
+ } else {
+ DevContainerCreationProgress::Creating
+ };
+
+ let mut this = Self::new_inner(
+ Mode::CreateRemoteDevContainer(CreateRemoteDevContainer::new(initial_mode, cx)),
false,
fs,
window,
@@ -820,35 +829,15 @@ impl RemoteServerProjects {
cx,
);
- // Spawn a task to scan for configs and then start the container
- cx.spawn_in(window, async move |entity, cx| {
- let configs = find_devcontainer_configs(cx);
-
- entity
- .update_in(cx, |this, window, cx| {
- if configs.len() > 1 {
- // Multiple configs found - show selection UI
- let delegate = DevContainerPickerDelegate::new(configs, cx.weak_entity());
- this.dev_container_picker = Some(
- cx.new(|cx| Picker::uniform_list(delegate, window, cx).modal(false)),
- );
-
- let state = CreateRemoteDevContainer::new(
- DevContainerCreationProgress::SelectingConfig,
- cx,
- );
- this.mode = Mode::CreateRemoteDevContainer(state);
- cx.notify();
- } else {
- // Single or no config - proceed with opening
- let config = configs.into_iter().next();
- this.open_dev_container(config, window, cx);
- this.view_in_progress_dev_container(window, cx);
- }
- })
- .log_err();
- })
- .detach();
+ if configs.len() > 1 {
+ let delegate = DevContainerPickerDelegate::new(configs, cx.weak_entity());
+ this.dev_container_picker =
+ Some(cx.new(|cx| Picker::uniform_list(delegate, window, cx).modal(false)));
+ } else {
+ let config = configs.into_iter().next();
+ this.open_dev_container(config, window, cx);
+ this.view_in_progress_dev_container(window, cx);
+ }
this
}
@@ -1551,7 +1540,9 @@ impl RemoteServerProjects {
let replace_window = match (create_new_window, secondary_confirm) {
(true, false) | (false, true) => None,
- (true, true) | (false, false) => window.window_handle().downcast::<Workspace>(),
+ (true, true) | (false, false) => {
+ window.window_handle().downcast::<MultiWorkspace>()
+ }
};
cx.spawn_in(window, async move |_, cx| {
@@ -1803,25 +1794,25 @@ impl RemoteServerProjects {
}
fn init_dev_container_mode(&mut self, window: &mut Window, cx: &mut Context<Self>) {
- cx.spawn_in(window, async move |entity, cx| {
- let configs = find_devcontainer_configs(cx);
+ let configs = self
+ .workspace
+ .read_with(cx, |workspace, cx| find_devcontainer_configs(workspace, cx))
+ .unwrap_or_default();
- entity
- .update_in(cx, |this, window, cx| {
- let delegate = DevContainerPickerDelegate::new(configs, cx.weak_entity());
- this.dev_container_picker =
- Some(cx.new(|cx| Picker::uniform_list(delegate, window, cx).modal(false)));
+ if configs.len() > 1 {
+ let delegate = DevContainerPickerDelegate::new(configs, cx.weak_entity());
+ self.dev_container_picker =
+ Some(cx.new(|cx| Picker::uniform_list(delegate, window, cx).modal(false)));
- let state = CreateRemoteDevContainer::new(
- DevContainerCreationProgress::SelectingConfig,
- cx,
- );
- this.mode = Mode::CreateRemoteDevContainer(state);
- cx.notify();
- })
- .log_err();
- })
- .detach();
+ let state =
+ CreateRemoteDevContainer::new(DevContainerCreationProgress::SelectingConfig, cx);
+ self.mode = Mode::CreateRemoteDevContainer(state);
+ cx.notify();
+ } else {
+ let config = configs.into_iter().next();
+ self.open_dev_container(config, window, cx);
+ self.view_in_progress_dev_container(window, cx);
+ }
}
fn open_dev_container(
@@ -1830,21 +1821,25 @@ impl RemoteServerProjects {
window: &mut Window,
cx: &mut Context<Self>,
) {
- let Some(app_state) = self
+ let Some((app_state, context)) = self
.workspace
- .read_with(cx, |workspace, _| workspace.app_state().clone())
+ .read_with(cx, |workspace, cx| {
+ let app_state = workspace.app_state().clone();
+ let context = DevContainerContext::from_workspace(workspace, cx)?;
+ Some((app_state, context))
+ })
.log_err()
+ .flatten()
else {
+ log::error!("No active project directory for Dev Container");
return;
};
- let replace_window = window.window_handle().downcast::<Workspace>();
+ let replace_window = window.window_handle().downcast::<MultiWorkspace>();
cx.spawn_in(window, async move |entity, cx| {
let (connection, starting_dir) =
- match start_dev_container_with_config(cx, app_state.node_runtime.clone(), config)
- .await
- {
+ match start_dev_container_with_config(context, config).await {
Ok((c, s)) => (Connection::DevContainer(c), s),
Err(e) => {
log::error!("Failed to start dev container: {:?}", e);
@@ -8,7 +8,7 @@ use ui::{
Render, Styled, StyledExt, Toggleable, Window, div, h_flex, rems, v_flex,
};
use util::ResultExt as _;
-use workspace::{ModalView, Workspace};
+use workspace::{ModalView, MultiWorkspace};
use crate::open_remote_project;
@@ -249,7 +249,7 @@ impl WslOpenModal {
false => !secondary,
};
let replace_window = match replace_current_window {
- true => window.window_handle().downcast::<Workspace>(),
+ true => window.window_handle().downcast::<MultiWorkspace>(),
false => None,
};
@@ -24,7 +24,7 @@ use theme::ThemeSettings;
use ui::{Divider, ListItem, ListItemSpacing, ListSubHeader, Tooltip, prelude::*};
use ui_input::ErasedEditor;
use util::{ResultExt, TryFutureExt};
-use workspace::{Workspace, WorkspaceSettings, client_side_decorations};
+use workspace::{MultiWorkspace, Workspace, WorkspaceSettings, client_side_decorations};
use zed_actions::assistant::InlineAssist;
use prompt_store::*;
@@ -968,12 +968,14 @@ impl RulesLibrary {
.assist(rule_editor, initial_prompt, window, cx);
} else {
for window in cx.windows() {
- if let Some(workspace) = window.downcast::<Workspace>() {
- let panel = workspace
- .update(cx, |workspace, window, cx| {
+ if let Some(multi_workspace) = window.downcast::<MultiWorkspace>() {
+ let panel = multi_workspace
+ .update(cx, |multi_workspace, window, cx| {
window.activate_window();
- self.inline_assist_delegate
- .focus_agent_panel(workspace, window, cx)
+ multi_workspace.workspace().update(cx, |workspace, cx| {
+ self.inline_assist_delegate
+ .focus_agent_panel(workspace, window, cx)
+ })
})
.ok();
if panel == Some(true) {
@@ -47,6 +47,15 @@ impl Session {
}
}
+ #[cfg(any(test, feature = "test-support"))]
+ pub fn test_with_old_session(old_session_id: String) -> Self {
+ Self {
+ session_id: uuid::Uuid::new_v4().to_string(),
+ old_session_id: Some(old_session_id),
+ old_window_ids: None,
+ }
+ }
+
pub fn id(&self) -> &str {
&self.session_id
}
@@ -109,6 +118,11 @@ impl AppSession {
self.session.old_session_id.as_deref()
}
+ #[cfg(any(test, feature = "test-support"))]
+ pub fn replace_session_for_test(&mut self, session: Session) {
+ self.session = session;
+ }
+
pub fn last_session_window_stack(&self) -> Option<Vec<WindowId>> {
self.session.old_window_ids.clone()
}
@@ -287,7 +287,7 @@ mod tests {
use serde_json::json;
use settings::Settings;
use theme::{self, ThemeSettings};
- use workspace::{self, AppState};
+ use workspace::{self, AppState, MultiWorkspace};
use zed_actions::settings_profile_selector;
async fn init_test(
@@ -320,8 +320,11 @@ mod tests {
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, ["/test".as_ref()], cx).await;
- let (workspace, cx) =
- cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
+ let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
+ let cx = VisualTestContext::from_window(*window, cx).into_mut();
+ let workspace = window
+ .read_with(cx, |mw, _| mw.workspace().clone())
+ .unwrap();
cx.update(|_, cx| {
assert!(!cx.has_global::<ActiveSettingsProfileName>());
@@ -40,7 +40,9 @@ use ui::{
};
use util::{ResultExt as _, paths::PathStyle, rel_path::RelPath};
-use workspace::{AppState, OpenOptions, OpenVisible, Workspace, client_side_decorations};
+use workspace::{
+ AppState, MultiWorkspace, OpenOptions, OpenVisible, Workspace, client_side_decorations,
+};
use zed_actions::{OpenProjectSettings, OpenSettings, OpenSettingsAt};
use crate::components::{
@@ -394,7 +396,7 @@ pub fn init(cx: &mut App) {
|workspace, OpenSettingsAt { path }: &OpenSettingsAt, window, cx| {
let window_handle = window
.window_handle()
- .downcast::<Workspace>()
+ .downcast::<MultiWorkspace>()
.expect("Workspaces are root Windows");
open_settings_editor(workspace, Some(&path), false, window_handle, cx);
},
@@ -402,14 +404,14 @@ pub fn init(cx: &mut App) {
.register_action(|workspace, _: &OpenSettings, window, cx| {
let window_handle = window
.window_handle()
- .downcast::<Workspace>()
+ .downcast::<MultiWorkspace>()
.expect("Workspaces are root Windows");
open_settings_editor(workspace, None, false, window_handle, cx);
})
.register_action(|workspace, _: &OpenProjectSettings, window, cx| {
let window_handle = window
.window_handle()
- .downcast::<Workspace>()
+ .downcast::<MultiWorkspace>()
.expect("Workspaces are root Windows");
open_settings_editor(workspace, None, true, window_handle, cx);
});
@@ -547,7 +549,7 @@ pub fn open_settings_editor(
_workspace: &mut Workspace,
path: Option<&str>,
open_project_settings: bool,
- workspace_handle: WindowHandle<Workspace>,
+ workspace_handle: WindowHandle<MultiWorkspace>,
cx: &mut App,
) {
telemetry::event!("Settings Viewed");
@@ -715,7 +717,7 @@ fn active_language_mut() -> Option<std::sync::RwLockWriteGuard<'static, Option<S
pub struct SettingsWindow {
title_bar: Option<Entity<PlatformTitleBar>>,
- original_window: Option<WindowHandle<Workspace>>,
+ original_window: Option<WindowHandle<MultiWorkspace>>,
files: Vec<(SettingsUiFile, FocusHandle)>,
worktree_root_dirs: HashMap<WorktreeId, String>,
current_file: SettingsUiFile,
@@ -1447,7 +1449,7 @@ impl SettingsUiFile {
impl SettingsWindow {
fn new(
- original_window: Option<WindowHandle<Workspace>>,
+ original_window: Option<WindowHandle<MultiWorkspace>>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
@@ -1518,34 +1520,21 @@ impl SettingsWindow {
.detach();
if let Some(app_state) = AppState::global(cx).upgrade() {
- for project in app_state
+ let workspaces: Vec<Entity<Workspace>> = app_state
.workspace_store
.read(cx)
.workspaces()
- .iter()
- .filter_map(|space| {
- space
- .read(cx)
- .ok()
- .map(|workspace| workspace.project().clone())
- })
- .collect::<Vec<_>>()
- {
+ .filter_map(|weak| weak.upgrade())
+ .collect();
+
+ for workspace in workspaces {
+ let project = workspace.read(cx).project().clone();
cx.observe_release_in(&project, window, |this, _, window, cx| {
this.fetch_files(window, cx)
})
.detach();
cx.subscribe_in(&project, window, Self::handle_project_event)
.detach();
- }
-
- for workspace in app_state
- .workspace_store
- .read(cx)
- .workspaces()
- .iter()
- .filter_map(|space| space.entity(cx).ok())
- {
cx.observe_release_in(&workspace, window, |this, _, window, cx| {
this.fetch_files(window, cx)
})
@@ -3320,56 +3309,19 @@ impl SettingsWindow {
return;
};
original_window
- .update(cx, |workspace, window, cx| {
- workspace
- .with_local_or_wsl_workspace(window, cx, |workspace, window, cx| {
- let project = workspace.project().clone();
-
- cx.spawn_in(window, async move |workspace, cx| {
- let (config_dir, settings_file) =
- project.update(cx, |project, cx| {
- (
- project.try_windows_path_to_wsl(
- paths::config_dir().as_path(),
- cx,
- ),
- project.try_windows_path_to_wsl(
- paths::settings_file().as_path(),
- cx,
- ),
- )
- });
- let config_dir = config_dir.await?;
- let settings_file = settings_file.await?;
- project
- .update(cx, |project, cx| {
- project.find_or_create_worktree(&config_dir, false, cx)
- })
- .await
- .ok();
- workspace
- .update_in(cx, |workspace, window, cx| {
- workspace.open_paths(
- vec![settings_file],
- OpenOptions {
- visible: Some(OpenVisible::None),
- ..Default::default()
- },
- None,
- window,
- cx,
- )
- })?
- .await;
-
- workspace.update_in(cx, |_, window, cx| {
- window.activate_window();
- cx.notify();
- })
- })
- .detach();
- })
- .detach();
+ .update(cx, |multi_workspace, window, cx| {
+ multi_workspace
+ .workspace()
+ .clone()
+ .update(cx, |workspace, cx| {
+ workspace
+ .with_local_or_wsl_workspace(
+ window,
+ cx,
+ open_user_settings_in_workspace,
+ )
+ .detach();
+ });
})
.ok();
@@ -3381,22 +3333,22 @@ impl SettingsWindow {
return;
};
- let Some((worktree, corresponding_workspace)) = app_state
+ let Some((workspace_window, worktree, corresponding_workspace)) = app_state
.workspace_store
.read(cx)
- .workspaces()
- .iter()
- .find_map(|workspace| {
+ .workspaces_with_windows()
+ .filter_map(|(window_handle, weak)| {
+ let workspace = weak.upgrade()?;
+ let window = window_handle.downcast::<MultiWorkspace>()?;
+ Some((window, workspace))
+ })
+ .find_map(|(window, workspace): (_, Entity<Workspace>)| {
workspace
- .read_with(cx, |workspace, cx| {
- workspace
- .project()
- .read(cx)
- .worktree_for_id(*worktree_id, cx)
- })
- .ok()
- .flatten()
- .zip(Some(*workspace))
+ .read(cx)
+ .project()
+ .read(cx)
+ .worktree_for_id(*worktree_id, cx)
+ .map(|worktree| (window, worktree, workspace))
})
else {
log::error!(
@@ -3424,14 +3376,15 @@ impl SettingsWindow {
// TODO: move zed::open_local_file() APIs to this crate, and
// re-implement the "initial_contents" behavior
- corresponding_workspace
+ let workspace_weak = corresponding_workspace.downgrade();
+ workspace_window
.update(cx, |_, window, cx| {
- cx.spawn_in(window, async move |workspace, cx| {
+ cx.spawn_in(window, async move |_, cx| {
if let Some(create_task) = create_task {
create_task.await.ok()?;
};
- workspace
+ workspace_weak
.update_in(cx, |workspace, window, cx| {
workspace.open_path(
(worktree_id, settings_path.clone()),
@@ -3445,7 +3398,7 @@ impl SettingsWindow {
.await
.log_err()?;
- workspace
+ workspace_weak
.update_in(cx, |_, window, cx| {
window.activate_window();
cx.notify();
@@ -3752,7 +3705,7 @@ impl Render for SettingsWindow {
}
fn all_projects(
- window: Option<&WindowHandle<Workspace>>,
+ window: Option<&WindowHandle<MultiWorkspace>>,
cx: &App,
) -> impl Iterator<Item = Entity<Project>> {
let mut seen_project_ids = std::collections::HashSet::new();
@@ -3763,10 +3716,19 @@ fn all_projects(
.workspace_store
.read(cx)
.workspaces()
- .iter()
- .filter_map(|workspace| Some(workspace.read(cx).ok()?.project().clone()))
+ .filter_map(|weak| weak.upgrade())
+ .map(|workspace: Entity<Workspace>| workspace.read(cx).project().clone())
.chain(
- window.and_then(|workspace| Some(workspace.read(cx).ok()?.project().clone())),
+ window
+ .and_then(|handle| handle.read(cx).ok())
+ .into_iter()
+ .flat_map(|multi_workspace| {
+ multi_workspace
+ .workspaces()
+ .iter()
+ .map(|workspace| workspace.read(cx).project().clone())
+ .collect::<Vec<_>>()
+ }),
)
.filter(move |project| seen_project_ids.insert(project.entity_id()))
})
@@ -3774,6 +3736,51 @@ fn all_projects(
.flatten()
}
+fn open_user_settings_in_workspace(
+ workspace: &mut Workspace,
+ window: &mut Window,
+ cx: &mut Context<Workspace>,
+) {
+ let project = workspace.project().clone();
+
+ cx.spawn_in(window, async move |workspace, cx| {
+ let (config_dir, settings_file) = project.update(cx, |project, cx| {
+ (
+ project.try_windows_path_to_wsl(paths::config_dir().as_path(), cx),
+ project.try_windows_path_to_wsl(paths::settings_file().as_path(), cx),
+ )
+ });
+ let config_dir = config_dir.await?;
+ let settings_file = settings_file.await?;
+ project
+ .update(cx, |project, cx| {
+ project.find_or_create_worktree(&config_dir, false, cx)
+ })
+ .await
+ .ok();
+ workspace
+ .update_in(cx, |workspace, window, cx| {
+ workspace.open_paths(
+ vec![settings_file],
+ OpenOptions {
+ visible: Some(OpenVisible::None),
+ ..Default::default()
+ },
+ None,
+ window,
+ cx,
+ )
+ })?
+ .await;
+
+ workspace.update_in(cx, |_, window, cx| {
+ window.activate_window();
+ cx.notify();
+ })
+ })
+ .detach();
+}
+
fn update_settings_file(
file: SettingsUiFile,
file_name: Option<&'static str>,
@@ -4754,29 +4761,33 @@ pub mod test {
.await
.expect("Failed to create worktree_c");
- let (_workspace1, cx) = cx.add_window_view(|window, cx| {
- Workspace::new(
- Default::default(),
- project1.clone(),
- app_state.clone(),
- window,
- cx,
- )
+ let (_multi_workspace1, cx) = cx.add_window_view(|window, cx| {
+ let workspace = cx.new(|cx| {
+ Workspace::new(
+ Default::default(),
+ project1.clone(),
+ app_state.clone(),
+ window,
+ cx,
+ )
+ });
+ MultiWorkspace::new(workspace, cx)
});
- let _workspace1_handle = cx.window_handle().downcast::<Workspace>().unwrap();
-
- let (_workspace2, cx) = cx.add_window_view(|window, cx| {
- Workspace::new(
- Default::default(),
- project2.clone(),
- app_state.clone(),
- window,
- cx,
- )
+ let (_multi_workspace2, cx) = cx.add_window_view(|window, cx| {
+ let workspace = cx.new(|cx| {
+ Workspace::new(
+ Default::default(),
+ project2.clone(),
+ app_state.clone(),
+ window,
+ cx,
+ )
+ });
+ MultiWorkspace::new(workspace, cx)
});
- let workspace2_handle = cx.window_handle().downcast::<Workspace>().unwrap();
+ let workspace2_handle = cx.window_handle().downcast::<MultiWorkspace>().unwrap();
cx.run_until_parked();
@@ -4895,17 +4906,20 @@ pub mod test {
.await
.expect("Failed to create worktree_a");
- let (_workspace1, cx) = cx.add_window_view(|window, cx| {
- Workspace::new(
- Default::default(),
- project1.clone(),
- app_state.clone(),
- window,
- cx,
- )
+ let (_multi_workspace1, cx) = cx.add_window_view(|window, cx| {
+ let workspace = cx.new(|cx| {
+ Workspace::new(
+ Default::default(),
+ project1.clone(),
+ app_state.clone(),
+ window,
+ cx,
+ )
+ });
+ MultiWorkspace::new(workspace, cx)
});
- let workspace1_handle = cx.window_handle().downcast::<Workspace>().unwrap();
+ let workspace1_handle = cx.window_handle().downcast::<MultiWorkspace>().unwrap();
cx.run_until_parked();
@@ -4942,14 +4956,17 @@ pub mod test {
.await
.expect("Failed to create worktree_b");
- let (_workspace2, cx) = cx.add_window_view(|window, cx| {
- Workspace::new(
- Default::default(),
- project2.clone(),
- app_state.clone(),
- window,
- cx,
- )
+ let (_multi_workspace2, cx) = cx.add_window_view(|window, cx| {
+ let workspace = cx.new(|cx| {
+ Workspace::new(
+ Default::default(),
+ project2.clone(),
+ app_state.clone(),
+ window,
+ cx,
+ )
+ });
+ MultiWorkspace::new(workspace, cx)
});
cx.run_until_parked();
@@ -0,0 +1,43 @@
+[package]
+name = "sidebar"
+version = "0.1.0"
+edition.workspace = true
+publish.workspace = true
+license = "GPL-3.0-or-later"
+
+[lints]
+workspace = true
+
+[lib]
+path = "src/sidebar.rs"
+
+[features]
+default = []
+test-support = []
+
+[dependencies]
+acp_thread.workspace = true
+agent_ui.workspace = true
+db.workspace = true
+fs.workspace = true
+fuzzy.workspace = true
+serde_json.workspace = true
+gpui.workspace = true
+picker.workspace = true
+project.workspace = true
+recent_projects.workspace = true
+theme.workspace = true
+ui.workspace = true
+ui_input.workspace = true
+util.workspace = true
+workspace.workspace = true
+
+[dev-dependencies]
+editor.workspace = true
+feature_flags.workspace = true
+fs = { workspace = true, features = ["test-support"] }
+gpui = { workspace = true, features = ["test-support"] }
+project = { workspace = true, features = ["test-support"] }
+recent_projects = { workspace = true, features = ["test-support"] }
+settings = { workspace = true, features = ["test-support"] }
+workspace = { workspace = true, features = ["test-support"] }
@@ -0,0 +1 @@
+../../LICENSE-GPL
@@ -0,0 +1,1304 @@
+use acp_thread::ThreadStatus;
+use agent_ui::{AgentPanel, AgentPanelEvent};
+use db::kvp::KEY_VALUE_STORE;
+use fs::Fs;
+use fuzzy::StringMatchCandidate;
+use gpui::{
+ App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render, SharedString,
+ Subscription, Task, Window, px,
+};
+use picker::{Picker, PickerDelegate};
+use project::Event as ProjectEvent;
+use recent_projects::{RecentProjectEntry, get_recent_projects};
+
+use std::collections::{HashMap, HashSet};
+
+use std::path::{Path, PathBuf};
+use std::sync::Arc;
+use theme::ActiveTheme;
+use ui::utils::TRAFFIC_LIGHT_PADDING;
+use ui::{CommonAnimationExt, Divider, HighlightedLabel, ListItem, Tab, Tooltip, prelude::*};
+use ui_input::ErasedEditor;
+use util::ResultExt as _;
+use workspace::{
+ MultiWorkspace, NewWorkspaceInWindow, Sidebar as WorkspaceSidebar, SidebarEvent,
+ ToggleWorkspaceSidebar, Workspace,
+};
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum AgentThreadStatus {
+ Running,
+ Completed,
+}
+
+#[derive(Clone, Debug)]
+struct AgentThreadInfo {
+ title: SharedString,
+ status: AgentThreadStatus,
+}
+
+const LAST_THREAD_TITLES_KEY: &str = "sidebar-last-thread-titles";
+
+const DEFAULT_WIDTH: Pixels = px(320.0);
+const MIN_WIDTH: Pixels = px(200.0);
+const MAX_WIDTH: Pixels = px(800.0);
+const MAX_MATCHES: usize = 100;
+
+#[derive(Clone)]
+struct WorkspaceThreadEntry {
+ index: usize,
+ worktree_label: SharedString,
+ full_path: SharedString,
+ thread_info: Option<AgentThreadInfo>,
+}
+
+impl WorkspaceThreadEntry {
+ fn new(
+ index: usize,
+ workspace: &Entity<Workspace>,
+ persisted_titles: &HashMap<String, String>,
+ cx: &App,
+ ) -> Self {
+ let workspace_ref = workspace.read(cx);
+
+ let worktrees: Vec<_> = workspace_ref
+ .worktrees(cx)
+ .map(|worktree| worktree.read(cx).abs_path())
+ .collect();
+
+ let worktree_names: Vec<String> = worktrees
+ .iter()
+ .filter_map(|path| {
+ path.file_name()
+ .map(|name| name.to_string_lossy().to_string())
+ })
+ .collect();
+
+ let worktree_label: SharedString = if worktree_names.is_empty() {
+ format!("Workspace {}", index + 1).into()
+ } else {
+ worktree_names.join(", ").into()
+ };
+
+ let full_path: SharedString = worktrees
+ .iter()
+ .map(|path| path.to_string_lossy().to_string())
+ .collect::<Vec<_>>()
+ .join("\n")
+ .into();
+
+ let thread_info = Self::thread_info(workspace, cx).or_else(|| {
+ if worktrees.is_empty() {
+ return None;
+ }
+ let path_key = sorted_paths_key(&worktrees);
+ let title = persisted_titles.get(&path_key)?;
+ Some(AgentThreadInfo {
+ title: SharedString::from(title.clone()),
+ status: AgentThreadStatus::Completed,
+ })
+ });
+
+ Self {
+ index,
+ worktree_label,
+ full_path,
+ thread_info,
+ }
+ }
+
+ fn thread_info(workspace: &Entity<Workspace>, cx: &App) -> Option<AgentThreadInfo> {
+ let agent_panel = workspace.read(cx).panel::<AgentPanel>(cx)?;
+ let thread = agent_panel.read(cx).active_agent_thread(cx)?;
+ let thread_ref = thread.read(cx);
+ let title = thread_ref.title();
+ let status = match thread_ref.status() {
+ ThreadStatus::Generating => AgentThreadStatus::Running,
+ ThreadStatus::Idle => AgentThreadStatus::Completed,
+ };
+ Some(AgentThreadInfo { title, status })
+ }
+}
+
+#[derive(Clone)]
+enum SidebarEntry {
+ Separator(SharedString),
+ WorkspaceThread(WorkspaceThreadEntry),
+ RecentProject(RecentProjectEntry),
+}
+
+impl SidebarEntry {
+ fn searchable_text(&self) -> &str {
+ match self {
+ SidebarEntry::Separator(_) => "",
+ SidebarEntry::WorkspaceThread(entry) => entry.worktree_label.as_ref(),
+ SidebarEntry::RecentProject(entry) => entry.name.as_ref(),
+ }
+ }
+}
+
+#[derive(Clone)]
+struct SidebarMatch {
+ entry: SidebarEntry,
+ positions: Vec<usize>,
+}
+
+struct WorkspacePickerDelegate {
+ multi_workspace: Entity<MultiWorkspace>,
+ entries: Vec<SidebarEntry>,
+ active_workspace_index: usize,
+ workspace_thread_count: usize,
+ /// All recent projects including what's filtered out of entries
+ /// used to add unopened projects to entries on rebuild
+ recent_projects: Vec<RecentProjectEntry>,
+ recent_project_thread_titles: HashMap<SharedString, SharedString>,
+ matches: Vec<SidebarMatch>,
+ selected_index: usize,
+ query: String,
+ notified_workspaces: HashSet<usize>,
+}
+
+impl WorkspacePickerDelegate {
+ fn new(multi_workspace: Entity<MultiWorkspace>) -> Self {
+ Self {
+ multi_workspace,
+ entries: Vec::new(),
+ active_workspace_index: 0,
+ workspace_thread_count: 0,
+ recent_projects: Vec::new(),
+ recent_project_thread_titles: HashMap::new(),
+ matches: Vec::new(),
+ selected_index: 0,
+ query: String::new(),
+ notified_workspaces: HashSet::new(),
+ }
+ }
+
+ fn set_entries(
+ &mut self,
+ workspace_threads: Vec<WorkspaceThreadEntry>,
+ active_workspace_index: usize,
+ cx: &App,
+ ) {
+ let old_statuses: HashMap<usize, AgentThreadStatus> = self
+ .entries
+ .iter()
+ .filter_map(|entry| match entry {
+ SidebarEntry::WorkspaceThread(thread) => thread
+ .thread_info
+ .as_ref()
+ .map(|info| (thread.index, info.status.clone())),
+ _ => None,
+ })
+ .collect();
+
+ for thread in &workspace_threads {
+ if let Some(info) = &thread.thread_info {
+ if info.status == AgentThreadStatus::Completed
+ && thread.index != active_workspace_index
+ {
+ if old_statuses.get(&thread.index) == Some(&AgentThreadStatus::Running) {
+ self.notified_workspaces.insert(thread.index);
+ }
+ }
+ }
+ }
+
+ if self.active_workspace_index != active_workspace_index {
+ self.notified_workspaces.remove(&active_workspace_index);
+ }
+ self.active_workspace_index = active_workspace_index;
+ self.workspace_thread_count = workspace_threads.len();
+ self.rebuild_entries(workspace_threads, cx);
+ }
+
+ fn set_recent_projects(&mut self, recent_projects: Vec<RecentProjectEntry>, cx: &App) {
+ self.recent_project_thread_titles.clear();
+ if let Some(map) = read_thread_title_map() {
+ for entry in &recent_projects {
+ let path_key = sorted_paths_key(&entry.paths);
+ if let Some(title) = map.get(&path_key) {
+ self.recent_project_thread_titles
+ .insert(entry.full_path.clone(), title.clone().into());
+ }
+ }
+ }
+
+ self.recent_projects = recent_projects;
+
+ let workspace_threads: Vec<WorkspaceThreadEntry> = self
+ .entries
+ .iter()
+ .filter_map(|entry| match entry {
+ SidebarEntry::WorkspaceThread(thread) => Some(thread.clone()),
+ _ => None,
+ })
+ .collect();
+ self.rebuild_entries(workspace_threads, cx);
+ }
+
+ fn open_workspace_path_sets(&self, cx: &App) -> Vec<Vec<Arc<Path>>> {
+ self.multi_workspace
+ .read(cx)
+ .workspaces()
+ .iter()
+ .map(|workspace| {
+ let mut paths = workspace.read(cx).root_paths(cx);
+ paths.sort();
+ paths
+ })
+ .collect()
+ }
+
+ fn rebuild_entries(&mut self, workspace_threads: Vec<WorkspaceThreadEntry>, cx: &App) {
+ let open_path_sets = self.open_workspace_path_sets(cx);
+
+ self.entries.clear();
+
+ if !workspace_threads.is_empty() {
+ self.entries
+ .push(SidebarEntry::Separator("Active Workspaces".into()));
+ for thread in workspace_threads {
+ self.entries.push(SidebarEntry::WorkspaceThread(thread));
+ }
+ }
+
+ let recent: Vec<_> = self
+ .recent_projects
+ .iter()
+ .filter(|project| {
+ let mut project_paths: Vec<&Path> =
+ project.paths.iter().map(|p| p.as_path()).collect();
+ project_paths.sort();
+ !open_path_sets.iter().any(|open_paths| {
+ open_paths.len() == project_paths.len()
+ && open_paths
+ .iter()
+ .zip(&project_paths)
+ .all(|(a, b)| a.as_ref() == *b)
+ })
+ })
+ .cloned()
+ .collect();
+
+ if !recent.is_empty() {
+ self.entries
+ .push(SidebarEntry::Separator("Recent Projects".into()));
+ for project in recent {
+ self.entries.push(SidebarEntry::RecentProject(project));
+ }
+ }
+ }
+
+ fn open_recent_project(paths: Vec<PathBuf>, window: &mut Window, cx: &mut App) {
+ let Some(handle) = window.window_handle().downcast::<MultiWorkspace>() else {
+ return;
+ };
+
+ cx.defer(move |cx| {
+ if let Some(task) = handle
+ .update(cx, |multi_workspace, window, cx| {
+ multi_workspace.open_project(paths, window, cx)
+ })
+ .log_err()
+ {
+ task.detach_and_log_err(cx);
+ }
+ });
+ }
+}
+
+impl PickerDelegate for WorkspacePickerDelegate {
+ type ListItem = AnyElement;
+
+ fn match_count(&self) -> usize {
+ self.matches.len()
+ }
+
+ fn selected_index(&self) -> usize {
+ self.selected_index
+ }
+
+ fn set_selected_index(
+ &mut self,
+ ix: usize,
+ _window: &mut Window,
+ _cx: &mut Context<Picker<Self>>,
+ ) {
+ self.selected_index = ix;
+ }
+
+ fn can_select(
+ &mut self,
+ ix: usize,
+ _window: &mut Window,
+ _cx: &mut Context<Picker<Self>>,
+ ) -> bool {
+ match self.matches.get(ix) {
+ Some(SidebarMatch {
+ entry: SidebarEntry::Separator(_),
+ ..
+ }) => false,
+ _ => true,
+ }
+ }
+
+ fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
+ "Search…".into()
+ }
+
+ fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
+ if self.query.is_empty() {
+ None
+ } else {
+ Some("No threads match your search.".into())
+ }
+ }
+
+ fn update_matches(
+ &mut self,
+ query: String,
+ window: &mut Window,
+ cx: &mut Context<Picker<Self>>,
+ ) -> Task<()> {
+ self.query = query.clone();
+ let entries = self.entries.clone();
+
+ if query.is_empty() {
+ self.matches = entries
+ .into_iter()
+ .map(|entry| SidebarMatch {
+ entry,
+ positions: Vec::new(),
+ })
+ .collect();
+
+ let separator_offset = if self.workspace_thread_count > 0 {
+ 1
+ } else {
+ 0
+ };
+ self.selected_index = (self.active_workspace_index + separator_offset)
+ .min(self.matches.len().saturating_sub(1));
+ return Task::ready(());
+ }
+
+ let executor = cx.background_executor().clone();
+ cx.spawn_in(window, async move |picker, cx| {
+ let matches = cx
+ .background_spawn(async move {
+ let data_entries: Vec<(usize, &SidebarEntry)> = entries
+ .iter()
+ .enumerate()
+ .filter(|(_, entry)| !matches!(entry, SidebarEntry::Separator(_)))
+ .collect();
+
+ let candidates: Vec<StringMatchCandidate> = data_entries
+ .iter()
+ .enumerate()
+ .map(|(candidate_index, (_, entry))| {
+ StringMatchCandidate::new(candidate_index, entry.searchable_text())
+ })
+ .collect();
+
+ let search_matches = fuzzy::match_strings(
+ &candidates,
+ &query,
+ false,
+ true,
+ MAX_MATCHES,
+ &Default::default(),
+ executor,
+ )
+ .await;
+
+ let mut workspace_matches = Vec::new();
+ let mut project_matches = Vec::new();
+
+ for search_match in search_matches {
+ let (original_index, _) = data_entries[search_match.candidate_id];
+ let entry = entries[original_index].clone();
+ let sidebar_match = SidebarMatch {
+ positions: search_match.positions,
+ entry: entry.clone(),
+ };
+ match entry {
+ SidebarEntry::WorkspaceThread(_) => {
+ workspace_matches.push(sidebar_match)
+ }
+ SidebarEntry::RecentProject(_) => project_matches.push(sidebar_match),
+ SidebarEntry::Separator(_) => {}
+ }
+ }
+
+ let mut result = Vec::new();
+ if !workspace_matches.is_empty() {
+ result.push(SidebarMatch {
+ entry: SidebarEntry::Separator("Active Workspaces".into()),
+ positions: Vec::new(),
+ });
+ result.extend(workspace_matches);
+ }
+ if !project_matches.is_empty() {
+ result.push(SidebarMatch {
+ entry: SidebarEntry::Separator("Recent Projects".into()),
+ positions: Vec::new(),
+ });
+ result.extend(project_matches);
+ }
+ result
+ })
+ .await;
+
+ picker
+ .update_in(cx, |picker, _window, _cx| {
+ picker.delegate.matches = matches;
+ if picker.delegate.matches.is_empty() {
+ picker.delegate.selected_index = 0;
+ } else {
+ let first_selectable = picker
+ .delegate
+ .matches
+ .iter()
+ .position(|m| !matches!(m.entry, SidebarEntry::Separator(_)))
+ .unwrap_or(0);
+ picker.delegate.selected_index = first_selectable;
+ }
+ })
+ .log_err();
+ })
+ }
+
+ fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
+ let Some(selected_match) = self.matches.get(self.selected_index) else {
+ return;
+ };
+
+ match &selected_match.entry {
+ SidebarEntry::Separator(_) => {}
+ SidebarEntry::WorkspaceThread(thread_entry) => {
+ let target_index = thread_entry.index;
+ self.multi_workspace.update(cx, |multi_workspace, cx| {
+ multi_workspace.activate_index(target_index, window, cx);
+ });
+ }
+ SidebarEntry::RecentProject(project_entry) => {
+ let paths = project_entry.paths.clone();
+ Self::open_recent_project(paths, window, cx);
+ }
+ }
+ }
+
+ fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context<Picker<Self>>) {}
+
+ fn render_match(
+ &self,
+ index: usize,
+ selected: bool,
+ _window: &mut Window,
+ _cx: &mut Context<Picker<Self>>,
+ ) -> Option<Self::ListItem> {
+ let match_entry = self.matches.get(index)?;
+ let SidebarMatch { entry, positions } = match_entry;
+
+ fn render_title(text: SharedString, positions: &[usize]) -> AnyElement {
+ if positions.is_empty() {
+ div()
+ .p_0p5()
+ .child(Label::new(text).truncate())
+ .into_any_element()
+ } else {
+ div()
+ .p_0p5()
+ .child(HighlightedLabel::new(text, positions.to_vec()).truncate())
+ .into_any_element()
+ }
+ }
+
+ fn render_thread_status_icon(
+ workspace_index: usize,
+ status: &AgentThreadStatus,
+ has_notification: bool,
+ ) -> AnyElement {
+ match status {
+ AgentThreadStatus::Running => Icon::new(IconName::LoadCircle)
+ .size(IconSize::XSmall)
+ .color(Color::Muted)
+ .with_keyed_rotate_animation(
+ SharedString::from(format!("workspace-{}-spinner", workspace_index)),
+ 3,
+ )
+ .into_any_element(),
+ AgentThreadStatus::Completed => {
+ let color = if has_notification {
+ Color::Accent
+ } else {
+ Color::Muted
+ };
+ Icon::new(IconName::Check)
+ .size(IconSize::XSmall)
+ .color(color)
+ .into_any_element()
+ }
+ }
+ }
+
+ fn render_project_row(
+ title: AnyElement,
+ thread_subtitle: Option<SharedString>,
+ status_icon: Option<AnyElement>,
+ cx: &App,
+ ) -> Div {
+ h_flex()
+ .items_start()
+ .gap(DynamicSpacing::Base06.rems(cx))
+ .child(
+ div().pt(px(4.0)).child(
+ Icon::new(IconName::Folder)
+ .color(Color::Muted)
+ .size(IconSize::XSmall),
+ ),
+ )
+ .child(v_flex().overflow_hidden().child(title).when_some(
+ thread_subtitle,
+ |this, subtitle| {
+ this.child(
+ h_flex()
+ .gap_1()
+ .items_center()
+ .px_0p5()
+ .when_some(status_icon, |this, icon| this.child(icon))
+ .child(
+ Label::new(subtitle)
+ .size(LabelSize::Small)
+ .color(Color::Muted)
+ .truncate(),
+ ),
+ )
+ },
+ ))
+ }
+
+ match entry {
+ SidebarEntry::Separator(title) => Some(
+ div()
+ .px_0p5()
+ .when(index > 0, |this| this.mt_1().child(Divider::horizontal()))
+ .child(
+ ListItem::new("section_header").selectable(false).child(
+ Label::new(title.clone())
+ .size(LabelSize::XSmall)
+ .color(Color::Muted)
+ .when(index > 0, |this| this.mt_1p5())
+ .mb_1(),
+ ),
+ )
+ .into_any_element(),
+ ),
+ SidebarEntry::WorkspaceThread(thread_entry) => {
+ let worktree_label = thread_entry.worktree_label.clone();
+ let full_path = thread_entry.full_path.clone();
+ let title = render_title(worktree_label.clone(), positions);
+ let thread_info = thread_entry.thread_info.clone();
+ let workspace_index = thread_entry.index;
+ let multi_workspace = self.multi_workspace.clone();
+ let workspace_count = self.multi_workspace.read(_cx).workspaces().len();
+
+ let close_button = if workspace_count > 1 {
+ Some(
+ IconButton::new(
+ SharedString::from(format!("close-workspace-{}", workspace_index)),
+ IconName::Close,
+ )
+ .icon_size(IconSize::XSmall)
+ .icon_color(Color::Muted)
+ .tooltip(Tooltip::text("Close Workspace"))
+ .on_click({
+ let multi_workspace = multi_workspace;
+ move |_, window, cx| {
+ multi_workspace.update(cx, |mw, cx| {
+ mw.remove_workspace(workspace_index, window, cx);
+ });
+ }
+ }),
+ )
+ } else {
+ None
+ };
+
+ let has_notification = self.notified_workspaces.contains(&workspace_index);
+ let (thread_subtitle, status_icon) = match thread_info {
+ Some(info) => (
+ Some(info.title),
+ Some(render_thread_status_icon(
+ workspace_index,
+ &info.status,
+ has_notification,
+ )),
+ ),
+ None => (None, None),
+ };
+
+ Some(
+ ListItem::new(("workspace-item", thread_entry.index))
+ .toggle_state(selected)
+ .when_some(close_button, |item, button| item.end_hover_slot(button))
+ .child(render_project_row(title, thread_subtitle, status_icon, _cx))
+ .when(!full_path.is_empty(), |item| {
+ item.tooltip(move |_, cx| {
+ Tooltip::with_meta(
+ worktree_label.clone(),
+ None,
+ full_path.clone(),
+ cx,
+ )
+ })
+ })
+ .into_any_element(),
+ )
+ }
+ SidebarEntry::RecentProject(project_entry) => {
+ let name = project_entry.name.clone();
+ let full_path = project_entry.full_path.clone();
+ let title = render_title(name.clone(), positions);
+ let item_id: SharedString =
+ format!("recent-project-{:?}", project_entry.workspace_id).into();
+ let thread_title = self
+ .recent_project_thread_titles
+ .get(&project_entry.full_path)
+ .cloned();
+
+ Some(
+ ListItem::new(item_id)
+ .toggle_state(selected)
+ .child(render_project_row(title, thread_title, None, _cx))
+ .tooltip(move |_, cx| {
+ Tooltip::with_meta(name.clone(), None, full_path.clone(), cx)
+ })
+ .into_any_element(),
+ )
+ }
+ }
+ }
+
+ fn render_editor(
+ &self,
+ editor: &Arc<dyn ErasedEditor>,
+ window: &mut Window,
+ cx: &mut Context<Picker<Self>>,
+ ) -> Div {
+ h_flex()
+ .h(Tab::container_height(cx))
+ .w_full()
+ .px_2()
+ .gap_2()
+ .justify_between()
+ .border_b_1()
+ .border_color(cx.theme().colors().border)
+ .child(
+ Icon::new(IconName::MagnifyingGlass)
+ .color(Color::Muted)
+ .size(IconSize::Small),
+ )
+ .child(editor.render(window, cx))
+ }
+}
+
+pub struct Sidebar {
+ multi_workspace: Entity<MultiWorkspace>,
+ width: Pixels,
+ picker: Entity<Picker<WorkspacePickerDelegate>>,
+ _subscription: Subscription,
+ _project_subscriptions: Vec<Subscription>,
+ _agent_panel_subscriptions: Vec<Subscription>,
+ _thread_subscriptions: Vec<Subscription>,
+ #[cfg(any(test, feature = "test-support"))]
+ test_thread_infos: HashMap<usize, AgentThreadInfo>,
+ #[cfg(any(test, feature = "test-support"))]
+ test_recent_project_thread_titles: HashMap<SharedString, SharedString>,
+ _fetch_recent_projects: Task<()>,
+}
+
+impl EventEmitter<SidebarEvent> for Sidebar {}
+
+impl Sidebar {
+ pub fn new(
+ multi_workspace: Entity<MultiWorkspace>,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> Self {
+ let delegate = WorkspacePickerDelegate::new(multi_workspace.clone());
+ let picker = cx.new(|cx| {
+ Picker::list(delegate, window, cx)
+ .max_height(None)
+ .show_scrollbar(true)
+ .modal(false)
+ });
+
+ let subscription = cx.observe_in(
+ &multi_workspace,
+ window,
+ |this, multi_workspace, window, cx| {
+ this.queue_refresh(multi_workspace, window, cx);
+ },
+ );
+
+ let fetch_recent_projects = {
+ let picker = picker.downgrade();
+ let fs = <dyn Fs>::global(cx);
+ cx.spawn_in(window, async move |_this, cx| {
+ let projects = get_recent_projects(None, None, fs).await;
+
+ cx.update(|window, cx| {
+ if let Some(picker) = picker.upgrade() {
+ picker.update(cx, |picker, cx| {
+ picker.delegate.set_recent_projects(projects, cx);
+ let query = picker.query(cx);
+ picker.update_matches(query, window, cx);
+ });
+ }
+ })
+ .log_err();
+ })
+ };
+
+ let mut this = Self {
+ multi_workspace,
+ width: DEFAULT_WIDTH,
+ picker,
+ _subscription: subscription,
+ _project_subscriptions: Vec::new(),
+ _agent_panel_subscriptions: Vec::new(),
+ _thread_subscriptions: Vec::new(),
+ #[cfg(any(test, feature = "test-support"))]
+ test_thread_infos: HashMap::new(),
+ #[cfg(any(test, feature = "test-support"))]
+ test_recent_project_thread_titles: HashMap::new(),
+ _fetch_recent_projects: fetch_recent_projects,
+ };
+ this.queue_refresh(this.multi_workspace.clone(), window, cx);
+ this
+ }
+
+ fn subscribe_to_projects(
+ &mut self,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> Vec<Subscription> {
+ let projects: Vec<_> = self
+ .multi_workspace
+ .read(cx)
+ .workspaces()
+ .iter()
+ .map(|w| w.read(cx).project().clone())
+ .collect();
+
+ projects
+ .iter()
+ .map(|project| {
+ cx.subscribe_in(
+ project,
+ window,
+ |this, _project, event, window, cx| match event {
+ ProjectEvent::WorktreeAdded(_)
+ | ProjectEvent::WorktreeRemoved(_)
+ | ProjectEvent::WorktreeOrderChanged => {
+ this.queue_refresh(this.multi_workspace.clone(), window, cx);
+ }
+ _ => {}
+ },
+ )
+ })
+ .collect()
+ }
+
+ fn build_workspace_thread_entries(
+ &self,
+ multi_workspace: &MultiWorkspace,
+ cx: &App,
+ ) -> (Vec<WorkspaceThreadEntry>, usize) {
+ let persisted_titles = read_thread_title_map().unwrap_or_default();
+
+ #[allow(unused_mut)]
+ let mut entries: Vec<WorkspaceThreadEntry> = multi_workspace
+ .workspaces()
+ .iter()
+ .enumerate()
+ .map(|(index, workspace)| {
+ WorkspaceThreadEntry::new(index, workspace, &persisted_titles, cx)
+ })
+ .collect();
+
+ #[cfg(any(test, feature = "test-support"))]
+ for (index, info) in &self.test_thread_infos {
+ if let Some(entry) = entries.get_mut(*index) {
+ entry.thread_info = Some(info.clone());
+ }
+ }
+
+ (entries, multi_workspace.active_workspace_index())
+ }
+
+ #[cfg(any(test, feature = "test-support"))]
+ pub fn set_test_recent_projects(
+ &self,
+ projects: Vec<RecentProjectEntry>,
+ cx: &mut Context<Self>,
+ ) {
+ self.picker.update(cx, |picker, _cx| {
+ picker.delegate.recent_projects = projects;
+ });
+ }
+
+ #[cfg(any(test, feature = "test-support"))]
+ pub fn set_test_thread_info(
+ &mut self,
+ index: usize,
+ title: SharedString,
+ status: AgentThreadStatus,
+ ) {
+ self.test_thread_infos
+ .insert(index, AgentThreadInfo { title, status });
+ }
+
+ #[cfg(any(test, feature = "test-support"))]
+ pub fn set_test_recent_project_thread_title(
+ &mut self,
+ full_path: SharedString,
+ title: SharedString,
+ cx: &mut Context<Self>,
+ ) {
+ self.test_recent_project_thread_titles
+ .insert(full_path.clone(), title.clone());
+ self.picker.update(cx, |picker, _cx| {
+ picker
+ .delegate
+ .recent_project_thread_titles
+ .insert(full_path, title);
+ });
+ }
+
+ fn subscribe_to_agent_panels(
+ &mut self,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> Vec<Subscription> {
+ let workspaces: Vec<_> = self.multi_workspace.read(cx).workspaces().to_vec();
+
+ workspaces
+ .iter()
+ .map(|workspace| {
+ if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
+ cx.subscribe_in(
+ &agent_panel,
+ window,
+ |this, _, _event: &AgentPanelEvent, window, cx| {
+ this.queue_refresh(this.multi_workspace.clone(), window, cx);
+ },
+ )
+ } else {
+ // Panel hasn't loaded yet — observe the workspace so we
+ // re-subscribe once the panel appears on its dock.
+ cx.observe_in(workspace, window, |this, _, window, cx| {
+ this.queue_refresh(this.multi_workspace.clone(), window, cx);
+ })
+ }
+ })
+ .collect()
+ }
+
+ fn subscribe_to_threads(
+ &mut self,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> Vec<Subscription> {
+ let workspaces: Vec<_> = self.multi_workspace.read(cx).workspaces().to_vec();
+
+ workspaces
+ .iter()
+ .filter_map(|workspace| {
+ let agent_panel = workspace.read(cx).panel::<AgentPanel>(cx)?;
+ let thread = agent_panel.read(cx).active_agent_thread(cx)?;
+ Some(cx.observe_in(&thread, window, |this, _, window, cx| {
+ this.queue_refresh(this.multi_workspace.clone(), window, cx);
+ }))
+ })
+ .collect()
+ }
+
+ fn persist_thread_titles(
+ &self,
+ entries: &[WorkspaceThreadEntry],
+ multi_workspace: &Entity<MultiWorkspace>,
+ cx: &mut Context<Self>,
+ ) {
+ let mut map = read_thread_title_map().unwrap_or_default();
+ let workspaces = multi_workspace.read(cx).workspaces().to_vec();
+ let mut changed = false;
+
+ for (workspace, entry) in workspaces.iter().zip(entries.iter()) {
+ if let Some(ref info) = entry.thread_info {
+ let paths: Vec<_> = workspace
+ .read(cx)
+ .worktrees(cx)
+ .map(|wt| wt.read(cx).abs_path())
+ .collect();
+ if paths.is_empty() {
+ continue;
+ }
+ let path_key = sorted_paths_key(&paths);
+ let title = info.title.to_string();
+ if map.get(&path_key) != Some(&title) {
+ map.insert(path_key, title);
+ changed = true;
+ }
+ }
+ }
+
+ if changed {
+ if let Some(json) = serde_json::to_string(&map).log_err() {
+ cx.background_spawn(async move {
+ KEY_VALUE_STORE
+ .write_kvp(LAST_THREAD_TITLES_KEY.into(), json)
+ .await
+ .log_err();
+ })
+ .detach();
+ }
+ }
+ }
+
+ fn queue_refresh(
+ &mut self,
+ multi_workspace: Entity<MultiWorkspace>,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ cx.defer_in(window, move |this, window, cx| {
+ this._project_subscriptions = this.subscribe_to_projects(window, cx);
+ this._agent_panel_subscriptions = this.subscribe_to_agent_panels(window, cx);
+ this._thread_subscriptions = this.subscribe_to_threads(window, cx);
+ let (entries, active_index) = multi_workspace.read_with(cx, |multi_workspace, cx| {
+ this.build_workspace_thread_entries(multi_workspace, cx)
+ });
+
+ this.persist_thread_titles(&entries, &multi_workspace, cx);
+
+ let had_notifications = !this.picker.read(cx).delegate.notified_workspaces.is_empty();
+ this.picker.update(cx, |picker, cx| {
+ picker.delegate.set_entries(entries, active_index, cx);
+ let query = picker.query(cx);
+ picker.update_matches(query, window, cx);
+ });
+ let has_notifications = !this.picker.read(cx).delegate.notified_workspaces.is_empty();
+ if had_notifications != has_notifications {
+ multi_workspace.update(cx, |_, cx| cx.notify());
+ }
+ });
+ }
+}
+
+impl WorkspaceSidebar for Sidebar {
+ fn width(&self, _cx: &App) -> Pixels {
+ self.width
+ }
+
+ fn set_width(&mut self, width: Option<Pixels>, cx: &mut Context<Self>) {
+ self.width = width.unwrap_or(DEFAULT_WIDTH).clamp(MIN_WIDTH, MAX_WIDTH);
+ cx.notify();
+ }
+
+ fn has_notifications(&self, cx: &App) -> bool {
+ !self.picker.read(cx).delegate.notified_workspaces.is_empty()
+ }
+}
+
+impl Focusable for Sidebar {
+ fn focus_handle(&self, cx: &App) -> FocusHandle {
+ self.picker.read(cx).focus_handle(cx)
+ }
+}
+
+fn sorted_paths_key<P: AsRef<Path>>(paths: &[P]) -> String {
+ let mut sorted: Vec<String> = paths
+ .iter()
+ .map(|p| p.as_ref().to_string_lossy().to_string())
+ .collect();
+ sorted.sort();
+ sorted.join("\n")
+}
+
+fn read_thread_title_map() -> Option<HashMap<String, String>> {
+ let json = KEY_VALUE_STORE
+ .read_kvp(LAST_THREAD_TITLES_KEY)
+ .log_err()
+ .flatten()?;
+ serde_json::from_str(&json).log_err()
+}
+
+impl Render for Sidebar {
+ fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+ let titlebar_height = ui::utils::platform_title_bar_height(window);
+ let ui_font = theme::setup_ui_font(window, cx);
+
+ v_flex()
+ .id("workspace-sidebar")
+ .key_context("WorkspaceSidebar")
+ .font(ui_font)
+ .h_full()
+ .w(self.width)
+ .bg(cx.theme().colors().surface_background)
+ .border_r_1()
+ .border_color(cx.theme().colors().border)
+ .child(
+ h_flex()
+ .flex_none()
+ .h(titlebar_height)
+ .w_full()
+ .mt_px()
+ .pb_px()
+ .pr_2()
+ .when(cfg!(target_os = "macos"), |this| {
+ this.pl(px(TRAFFIC_LIGHT_PADDING))
+ })
+ .justify_between()
+ .border_b_1()
+ .border_color(cx.theme().colors().border)
+ .child(
+ IconButton::new("close-sidebar", IconName::WorkspaceNavOpen)
+ .icon_size(IconSize::Small)
+ .tooltip(|_window, cx| {
+ Tooltip::for_action("Close Sidebar", &ToggleWorkspaceSidebar, cx)
+ })
+ .on_click(cx.listener(|_this, _, _window, cx| {
+ cx.emit(SidebarEvent::Close);
+ })),
+ )
+ .child(
+ IconButton::new("new-workspace", IconName::Plus)
+ .icon_size(IconSize::Small)
+ .tooltip(|_window, cx| {
+ Tooltip::for_action("New Workspace", &NewWorkspaceInWindow, cx)
+ })
+ .on_click(cx.listener(|this, _, window, cx| {
+ this.multi_workspace.update(cx, |multi_workspace, cx| {
+ multi_workspace.create_workspace(window, cx);
+ });
+ })),
+ ),
+ )
+ .child(self.picker.clone())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use feature_flags::FeatureFlagAppExt as _;
+ use fs::FakeFs;
+ use gpui::TestAppContext;
+ use settings::SettingsStore;
+
+ fn init_test(cx: &mut TestAppContext) {
+ cx.update(|cx| {
+ let settings_store = SettingsStore::test(cx);
+ cx.set_global(settings_store);
+ theme::init(theme::LoadThemes::JustBase, cx);
+ editor::init(cx);
+ cx.update_flags(false, vec!["agent-v2".into()]);
+ });
+ }
+
+ fn set_thread_info_and_refresh(
+ sidebar: &Entity<Sidebar>,
+ multi_workspace: &Entity<MultiWorkspace>,
+ index: usize,
+ title: &str,
+ status: AgentThreadStatus,
+ cx: &mut gpui::VisualTestContext,
+ ) {
+ sidebar.update_in(cx, |s, _window, _cx| {
+ s.set_test_thread_info(index, SharedString::from(title.to_string()), status.clone());
+ });
+ multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
+ cx.run_until_parked();
+ }
+
+ fn has_notifications(sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext) -> bool {
+ sidebar.read_with(cx, |s, cx| s.has_notifications(cx))
+ }
+
+ #[gpui::test]
+ async fn test_notification_on_running_to_completed_transition(cx: &mut TestAppContext) {
+ init_test(cx);
+ let fs = FakeFs::new(cx.executor());
+ cx.update(|cx| <dyn Fs>::set_global(fs.clone(), cx));
+ let project = project::Project::test(fs, [], cx).await;
+
+ let (multi_workspace, cx) =
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+
+ let sidebar = multi_workspace.update_in(cx, |_mw, window, cx| {
+ let mw_handle = cx.entity();
+ cx.new(|cx| Sidebar::new(mw_handle, window, cx))
+ });
+ multi_workspace.update_in(cx, |mw, window, cx| {
+ mw.register_sidebar(sidebar.clone(), window, cx);
+ });
+ cx.run_until_parked();
+
+ // Create a second workspace and switch to it so workspace 0 is background.
+ multi_workspace.update_in(cx, |mw, window, cx| {
+ mw.create_workspace(window, cx);
+ });
+ cx.run_until_parked();
+ multi_workspace.update_in(cx, |mw, window, cx| {
+ mw.activate_index(1, window, cx);
+ });
+ cx.run_until_parked();
+
+ assert!(
+ !has_notifications(&sidebar, cx),
+ "should have no notifications initially"
+ );
+
+ set_thread_info_and_refresh(
+ &sidebar,
+ &multi_workspace,
+ 0,
+ "Test Thread",
+ AgentThreadStatus::Running,
+ cx,
+ );
+
+ assert!(
+ !has_notifications(&sidebar, cx),
+ "Running status alone should not create a notification"
+ );
+
+ set_thread_info_and_refresh(
+ &sidebar,
+ &multi_workspace,
+ 0,
+ "Test Thread",
+ AgentThreadStatus::Completed,
+ cx,
+ );
+
+ assert!(
+ has_notifications(&sidebar, cx),
+ "Running → Completed transition should create a notification"
+ );
+ }
+
+ #[gpui::test]
+ async fn test_no_notification_for_active_workspace(cx: &mut TestAppContext) {
+ init_test(cx);
+ let fs = FakeFs::new(cx.executor());
+ cx.update(|cx| <dyn Fs>::set_global(fs.clone(), cx));
+ let project = project::Project::test(fs, [], cx).await;
+
+ let (multi_workspace, cx) =
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+
+ let sidebar = multi_workspace.update_in(cx, |_mw, window, cx| {
+ let mw_handle = cx.entity();
+ cx.new(|cx| Sidebar::new(mw_handle, window, cx))
+ });
+ multi_workspace.update_in(cx, |mw, window, cx| {
+ mw.register_sidebar(sidebar.clone(), window, cx);
+ });
+ cx.run_until_parked();
+
+ // Workspace 0 is the active workspace — thread completes while
+ // the user is already looking at it.
+ set_thread_info_and_refresh(
+ &sidebar,
+ &multi_workspace,
+ 0,
+ "Test Thread",
+ AgentThreadStatus::Running,
+ cx,
+ );
+ set_thread_info_and_refresh(
+ &sidebar,
+ &multi_workspace,
+ 0,
+ "Test Thread",
+ AgentThreadStatus::Completed,
+ cx,
+ );
+
+ assert!(
+ !has_notifications(&sidebar, cx),
+ "should not notify for the workspace the user is already looking at"
+ );
+ }
+
+ #[gpui::test]
+ async fn test_notification_cleared_on_workspace_activation(cx: &mut TestAppContext) {
+ init_test(cx);
+ let fs = FakeFs::new(cx.executor());
+ cx.update(|cx| <dyn Fs>::set_global(fs.clone(), cx));
+ let project = project::Project::test(fs, [], cx).await;
+
+ let (multi_workspace, cx) =
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+
+ let sidebar = multi_workspace.update_in(cx, |_mw, window, cx| {
+ let mw_handle = cx.entity();
+ cx.new(|cx| Sidebar::new(mw_handle, window, cx))
+ });
+ multi_workspace.update_in(cx, |mw, window, cx| {
+ mw.register_sidebar(sidebar.clone(), window, cx);
+ });
+ cx.run_until_parked();
+
+ // Create a second workspace so we can switch away and back.
+ multi_workspace.update_in(cx, |mw, window, cx| {
+ mw.create_workspace(window, cx);
+ });
+ cx.run_until_parked();
+
+ // Switch to workspace 1 so workspace 0 becomes a background workspace.
+ multi_workspace.update_in(cx, |mw, window, cx| {
+ mw.activate_index(1, window, cx);
+ });
+ cx.run_until_parked();
+
+ // Thread on workspace 0 transitions Running → Completed while
+ // the user is looking at workspace 1.
+ set_thread_info_and_refresh(
+ &sidebar,
+ &multi_workspace,
+ 0,
+ "Test Thread",
+ AgentThreadStatus::Running,
+ cx,
+ );
+ set_thread_info_and_refresh(
+ &sidebar,
+ &multi_workspace,
+ 0,
+ "Test Thread",
+ AgentThreadStatus::Completed,
+ cx,
+ );
+
+ assert!(
+ has_notifications(&sidebar, cx),
+ "background workspace completion should create a notification"
+ );
+
+ // Switching back to workspace 0 should clear the notification.
+ multi_workspace.update_in(cx, |mw, window, cx| {
+ mw.activate_index(0, window, cx);
+ });
+ cx.run_until_parked();
+
+ assert!(
+ !has_notifications(&sidebar, cx),
+ "notification should be cleared when workspace becomes active"
+ );
+ }
+}
@@ -38,6 +38,7 @@ chrono.workspace = true
client.workspace = true
cloud_api_types.workspace = true
db.workspace = true
+feature_flags.workspace = true
git_ui.workspace = true
gpui = { workspace = true, features = ["screen-capture"] }
menu.workspace = true
@@ -11,7 +11,8 @@ use project::{Project, Worktree, git_store::Repository};
use recent_projects::{RecentProjectEntry, delete_recent_project, get_recent_projects};
use settings::WorktreeId;
use ui::{ContextMenu, DocumentationAside, DocumentationSide, Tooltip, prelude::*};
-use workspace::{CloseIntent, Workspace};
+use util::ResultExt as _;
+use workspace::{MultiWorkspace, Workspace};
actions!(project_dropdown, [RemoveSelectedFolder]);
@@ -66,8 +67,12 @@ impl ProjectDropdown {
let recent_projects_for_fetch = recent_projects.clone();
let menu_shell_for_fetch = menu_shell.clone();
let workspace_for_fetch = workspace.clone();
+ let fs = workspace
+ .upgrade()
+ .map(|ws| ws.read(cx).app_state().fs.clone());
cx.spawn_in(window, async move |_this, cx| {
+ let Some(fs) = fs else { return };
let current_workspace_id = cx
.update(|_, cx| {
workspace_for_fetch
@@ -77,7 +82,7 @@ impl ProjectDropdown {
.ok()
.flatten();
- let projects = get_recent_projects(current_workspace_id, None).await;
+ let projects = get_recent_projects(current_workspace_id, None, fs).await;
cx.update(|window, cx| {
*recent_projects_for_fetch.borrow_mut() = projects;
@@ -88,7 +93,7 @@ impl ProjectDropdown {
});
}
})
- .ok()
+ .ok();
})
.detach();
@@ -396,36 +401,31 @@ impl ProjectDropdown {
window: &mut Window,
cx: &mut App,
) {
- let Some(workspace) = workspace.upgrade() else {
- return;
- };
+ if create_new_window {
+ let Some(workspace) = workspace.upgrade() else {
+ return;
+ };
+ workspace.update(cx, |workspace, cx| {
+ workspace
+ .open_workspace_for_paths(false, paths, window, cx)
+ .detach_and_log_err(cx);
+ });
+ } else {
+ let Some(handle) = window.window_handle().downcast::<MultiWorkspace>() else {
+ return;
+ };
- workspace.update(cx, |workspace, cx| {
- if create_new_window {
- workspace.open_workspace_for_paths(false, paths, window, cx)
- } else {
- cx.spawn_in(window, {
- let paths = paths.clone();
- async move |workspace, cx| {
- let continue_replacing = workspace
- .update_in(cx, |workspace, window, cx| {
- workspace.prepare_to_close(CloseIntent::ReplaceWindow, window, cx)
- })?
- .await?;
- if continue_replacing {
- workspace
- .update_in(cx, |workspace, window, cx| {
- workspace.open_workspace_for_paths(true, paths, window, cx)
- })?
- .await
- } else {
- Ok(())
- }
- }
- })
- }
- .detach_and_log_err(cx);
- });
+ cx.defer(move |cx| {
+ if let Some(task) = handle
+ .update(cx, |multi_workspace, window, cx| {
+ multi_workspace.open_project(paths, window, cx)
+ })
+ .log_err()
+ {
+ task.detach_and_log_err(cx);
+ }
+ });
+ }
}
/// Get all projects sorted alphabetically with their branch info.
@@ -22,6 +22,7 @@ use auto_update::AutoUpdateStatus;
use call::ActiveCall;
use client::{Client, UserStore, zed_urls};
use cloud_api_types::Plan;
+use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt};
use gpui::{
Action, AnyElement, App, Context, Corner, Element, Entity, FocusHandle, Focusable,
InteractiveElement, IntoElement, MouseButton, ParentElement, Render,
@@ -38,10 +39,13 @@ use theme::ActiveTheme;
use title_bar_settings::TitleBarSettings;
use ui::{
Avatar, ButtonLike, Chip, ContextMenu, IconWithIndicator, Indicator, PopoverMenu,
- PopoverMenuHandle, TintColor, Tooltip, prelude::*,
+ PopoverMenuHandle, TintColor, Tooltip, prelude::*, utils::platform_title_bar_height,
};
use util::ResultExt;
-use workspace::{SwitchProject, ToggleWorktreeSecurity, Workspace, notifications::NotifyResultExt};
+use workspace::{
+ MultiWorkspace, SwitchProject, ToggleWorkspaceSidebar, ToggleWorktreeSecurity, Workspace,
+ notifications::NotifyResultExt,
+};
use zed_actions::OpenRemote;
pub use onboarding_banner::restore_banner;
@@ -158,7 +162,7 @@ impl Render for TitleBar {
children.push(
h_flex()
- .gap_1()
+ .gap_0p5()
.map(|title_bar| {
let mut render_project_items = title_bar_settings.show_branch_name
|| title_bar_settings.show_project_items;
@@ -171,6 +175,7 @@ impl Render for TitleBar {
title_bar.child(menu)
},
)
+ .children(self.render_workspace_sidebar_toggle(window, cx))
.children(self.render_restricted_mode(cx))
.when(render_project_items, |title_bar| {
title_bar
@@ -232,7 +237,7 @@ impl Render for TitleBar {
);
});
- let height = PlatformTitleBar::height(window);
+ let height = platform_title_bar_height(window);
let title_bar_color = self.platform_titlebar.update(cx, |platform_titlebar, cx| {
platform_titlebar.title_bar_color(window, cx)
});
@@ -340,6 +345,48 @@ impl TitleBar {
let platform_titlebar = cx.new(|cx| PlatformTitleBar::new(id, cx));
+ // Set up observer to sync sidebar state from MultiWorkspace to PlatformTitleBar.
+ {
+ let platform_titlebar = platform_titlebar.clone();
+ let window_handle = window.window_handle();
+ cx.spawn(async move |this: WeakEntity<TitleBar>, cx| {
+ let Some(multi_workspace_handle) = window_handle.downcast::<MultiWorkspace>()
+ else {
+ return;
+ };
+
+ let _ = cx.update(|cx| {
+ let Ok(multi_workspace) = multi_workspace_handle.entity(cx) else {
+ return;
+ };
+
+ let is_open = multi_workspace.read(cx).is_sidebar_open();
+ let has_notifications = multi_workspace.read(cx).sidebar_has_notifications(cx);
+ platform_titlebar.update(cx, |titlebar, cx| {
+ titlebar.set_workspace_sidebar_open(is_open, cx);
+ titlebar.set_sidebar_has_notifications(has_notifications, cx);
+ });
+
+ let platform_titlebar = platform_titlebar.clone();
+ let subscription = cx.observe(&multi_workspace, move |mw, cx| {
+ let is_open = mw.read(cx).is_sidebar_open();
+ let has_notifications = mw.read(cx).sidebar_has_notifications(cx);
+ platform_titlebar.update(cx, |titlebar, cx| {
+ titlebar.set_workspace_sidebar_open(is_open, cx);
+ titlebar.set_sidebar_has_notifications(has_notifications, cx);
+ });
+ });
+
+ if let Some(this) = this.upgrade() {
+ this.update(cx, |this, _| {
+ this._subscriptions.push(subscription);
+ });
+ }
+ });
+ })
+ .detach();
+ }
+
Self {
platform_titlebar,
application_menu,
@@ -627,6 +674,41 @@ impl TitleBar {
)
}
+ fn render_workspace_sidebar_toggle(
+ &self,
+ _window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> Option<AnyElement> {
+ if !cx.has_flag::<AgentV2FeatureFlag>() {
+ return None;
+ }
+
+ let is_sidebar_open = self.platform_titlebar.read(cx).is_workspace_sidebar_open();
+
+ if is_sidebar_open {
+ return None;
+ }
+
+ let has_notifications = self.platform_titlebar.read(cx).sidebar_has_notifications();
+
+ Some(
+ IconButton::new("toggle-workspace-sidebar", IconName::WorkspaceNavClosed)
+ .icon_size(IconSize::Small)
+ .when(has_notifications, |button| {
+ button
+ .indicator(Indicator::dot().color(Color::Accent))
+ .indicator_border_color(Some(cx.theme().colors().title_bar_background))
+ })
+ .tooltip(move |_, cx| {
+ Tooltip::for_action("Open Workspace Sidebar", &ToggleWorkspaceSidebar, cx)
+ })
+ .on_click(|_, window, cx| {
+ window.dispatch_action(ToggleWorkspaceSidebar.boxed_clone(), cx);
+ })
+ .into_any_element(),
+ )
+ }
+
pub fn render_project_name(&self, cx: &mut Context<Self>) -> impl IntoElement {
let workspace = self.workspace.clone();
@@ -911,16 +993,18 @@ impl TitleBar {
pub fn render_sign_in_button(&mut self, _: &mut Context<Self>) -> Button {
let client = self.client.clone();
+ let workspace = self.workspace.clone();
Button::new("sign_in", "Sign In")
.label_size(LabelSize::Small)
.on_click(move |_, window, cx| {
let client = client.clone();
+ let workspace = workspace.clone();
window
- .spawn(cx, async move |cx| {
+ .spawn(cx, async move |mut cx| {
client
.sign_in_with_optional_connect(true, cx)
.await
- .notify_async_err(cx);
+ .notify_workspace_async_err(workspace, &mut cx);
})
.detach();
})
@@ -1,5 +1,6 @@
use crate::{
- Chip, DecoratedIcon, DiffStat, IconDecoration, IconDecorationKind, SpinnerLabel, prelude::*,
+ DecoratedIcon, DiffStat, HighlightedLabel, IconDecoration, IconDecorationKind, SpinnerLabel,
+ prelude::*,
};
use gpui::{ClickEvent, SharedString};
@@ -8,6 +9,7 @@ pub struct ThreadItem {
id: ElementId,
icon: IconName,
title: SharedString,
+ highlight_positions: Vec<usize>,
timestamp: SharedString,
running: bool,
generation_done: bool,
@@ -24,6 +26,7 @@ impl ThreadItem {
id: id.into(),
icon: IconName::ZedAgent,
title: title.into(),
+ highlight_positions: Vec::new(),
timestamp: "".into(),
running: false,
generation_done: false,
@@ -75,6 +78,11 @@ impl ThreadItem {
self
}
+ pub fn highlight_positions(mut self, positions: Vec<usize>) -> Self {
+ self.highlight_positions = positions;
+ self
+ }
+
pub fn on_click(
mut self,
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
@@ -112,7 +120,17 @@ impl RenderOnce for ThreadItem {
agent_icon.into_any_element()
};
- let has_no_changes = self.added.is_none() && self.removed.is_none();
+ // let has_no_changes = self.added.is_none() && self.removed.is_none();
+
+ let title = self.title;
+ let highlight_positions = self.highlight_positions;
+ let title_label = if highlight_positions.is_empty() {
+ Label::new(title).truncate().into_any_element()
+ } else {
+ HighlightedLabel::new(title, highlight_positions)
+ .truncate()
+ .into_any_element()
+ };
v_flex()
.id(self.id.clone())
@@ -127,7 +145,7 @@ impl RenderOnce for ThreadItem {
.w_full()
.gap_1p5()
.child(icon)
- .child(Label::new(self.title).truncate())
+ .child(title_label)
.when(self.running, |this| {
this.child(icon_container().child(SpinnerLabel::new().color(Color::Accent)))
}),
@@ -137,26 +155,32 @@ impl RenderOnce for ThreadItem {
.gap_1p5()
.child(icon_container()) // Icon Spacing
.when_some(self.worktree, |this, name| {
- this.child(Chip::new(name).label_size(LabelSize::XSmall))
+ this.child(Label::new(name).size(LabelSize::Small).color(Color::Muted))
})
- .child(
- Label::new(self.timestamp)
- .size(LabelSize::Small)
- .color(Color::Muted),
- )
.child(
Label::new("•")
.size(LabelSize::Small)
.color(Color::Muted)
.alpha(0.5),
)
- .when(has_no_changes, |this| {
- this.child(
- Label::new("No Changes")
- .size(LabelSize::Small)
- .color(Color::Muted),
- )
- })
+ .child(
+ Label::new(self.timestamp)
+ .size(LabelSize::Small)
+ .color(Color::Muted),
+ )
+ // .child(
+ // Label::new("•")
+ // .size(LabelSize::Small)
+ // .color(Color::Muted)
+ // .alpha(0.5),
+ // )
+ // .when(has_no_changes, |this| {
+ // this.child(
+ // Label::new("No Changes")
+ // .size(LabelSize::Small)
+ // .color(Color::Muted),
+ // )
+ // })
.when(self.added.is_some() || self.removed.is_some(), |this| {
this.child(DiffStat::new(
self.id,
@@ -5,6 +5,7 @@ use theme::ActiveTheme;
mod apca_contrast;
mod color_contrast;
+mod constants;
mod corner_solver;
mod format_distance;
mod search_input;
@@ -12,6 +13,7 @@ mod with_rem_size;
pub use apca_contrast::*;
pub use color_contrast::*;
+pub use constants::*;
pub use corner_solver::{CornerSolver, inner_corner_radius};
pub use format_distance::*;
pub use search_input::*;
@@ -0,0 +1,27 @@
+use gpui::{Pixels, Window, px};
+
+// Use pixels here instead of a rem-based size because the macOS traffic
+// lights are a static size, and don't scale with the rest of the UI.
+//
+// Magic number: There is one extra pixel of padding on the left side due to
+// the 1px border around the window on macOS apps.
+#[cfg(macos_sdk_26)]
+pub const TRAFFIC_LIGHT_PADDING: f32 = 78.;
+
+#[cfg(not(macos_sdk_26))]
+pub const TRAFFIC_LIGHT_PADDING: f32 = 71.;
+
+/// Returns the platform-appropriate title bar height.
+///
+/// On Windows, this returns a fixed height of 32px.
+/// On other platforms, it scales with the window's rem size (1.75x) with a minimum of 34px.
+#[cfg(not(target_os = "windows"))]
+pub fn platform_title_bar_height(window: &Window) -> Pixels {
+ (1.75 * window.rem_size()).max(px(34.))
+}
+
+#[cfg(target_os = "windows")]
+pub fn platform_title_bar_height(_window: &Window) -> Pixels {
+ // todo(windows) instead of hard coded size report the actual size to the Windows platform API
+ px(32.)
+}
@@ -36,7 +36,7 @@ use ui::{
use util::ResultExt;
use util::rel_path::RelPath;
use workspace::searchable::Direction;
-use workspace::{Workspace, WorkspaceDb, WorkspaceId};
+use workspace::{MultiWorkspace, Workspace, WorkspaceDb, WorkspaceId};
#[derive(Clone, Copy, Default, Debug, PartialEq, Serialize, Deserialize)]
pub enum Mode {
@@ -731,12 +731,16 @@ impl VimGlobals {
});
GlobalCommandPaletteInterceptor::set(cx, command_interceptor);
for window in cx.windows() {
- if let Some(workspace) = window.downcast::<Workspace>() {
- workspace
- .update(cx, |workspace, _, cx| {
- Vim::update_globals(cx, |globals, cx| {
- globals.register_workspace(workspace, cx)
- });
+ if let Some(multi_workspace) = window.downcast::<MultiWorkspace>() {
+ multi_workspace
+ .update(cx, |multi_workspace, _, cx| {
+ for workspace in multi_workspace.workspaces() {
+ workspace.update(cx, |workspace, cx| {
+ Vim::update_globals(cx, |globals, cx| {
+ globals.register_workspace(workspace, cx)
+ });
+ });
+ }
})
.ok();
}
@@ -79,6 +79,7 @@ db = { workspace = true, features = ["test-support"] }
fs = { workspace = true, features = ["test-support"] }
gpui = { workspace = true, features = ["test-support"] }
project = { workspace = true, features = ["test-support"] }
+remote = { workspace = true, features = ["test-support"] }
session = { workspace = true, features = ["test-support"] }
settings = { workspace = true, features = ["test-support"] }
http_client = { workspace = true, features = ["test-support"] }
@@ -1,5 +1,6 @@
-use std::path::PathBuf;
+use std::{path::PathBuf, sync::Arc};
+use fs::Fs;
use gpui::{AppContext, Entity, Global, MenuItem};
use smallvec::SmallVec;
use ui::{App, Context};
@@ -9,10 +10,10 @@ use crate::{
NewWindow, SerializedWorkspaceLocation, WORKSPACE_DB, WorkspaceId, path_list::PathList,
};
-pub fn init(cx: &mut App) {
+pub fn init(fs: Arc<dyn Fs>, cx: &mut App) {
let manager = cx.new(|_| HistoryManager::new());
HistoryManager::set_global(manager.clone(), cx);
- HistoryManager::init(manager, cx);
+ HistoryManager::init(manager, fs, cx);
}
pub struct HistoryManager {
@@ -38,10 +39,10 @@ impl HistoryManager {
}
}
- fn init(this: Entity<HistoryManager>, cx: &App) {
+ fn init(this: Entity<HistoryManager>, fs: Arc<dyn Fs>, cx: &App) {
cx.spawn(async move |cx| {
let recent_folders = WORKSPACE_DB
- .recent_workspaces_on_disk()
+ .recent_workspaces_on_disk(fs.as_ref())
.await
.unwrap_or_default()
.into_iter()
@@ -0,0 +1,513 @@
+use anyhow::Result;
+use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt};
+use gpui::{
+ AnyView, App, Context, DragMoveEvent, Entity, EntityId, EventEmitter, Focusable, ManagedView,
+ MouseButton, Pixels, Render, Subscription, Task, Window, actions, deferred, px,
+};
+use project::Project;
+use std::path::PathBuf;
+use ui::prelude::*;
+
+const SIDEBAR_RESIZE_HANDLE_SIZE: Pixels = px(6.0);
+
+use crate::{
+ DockPosition, Item, ModalView, Panel, Workspace, WorkspaceId, client_side_decorations,
+};
+
+actions!(
+ multi_workspace,
+ [
+ /// Creates a new workspace within the current window.
+ NewWorkspaceInWindow,
+ /// Switches to the next workspace within the current window.
+ NextWorkspaceInWindow,
+ /// Switches to the previous workspace within the current window.
+ PreviousWorkspaceInWindow,
+ /// Toggles the workspace switcher sidebar.
+ ToggleWorkspaceSidebar,
+ ]
+);
+
+pub enum SidebarEvent {
+ Open,
+ Close,
+}
+
+pub trait Sidebar: EventEmitter<SidebarEvent> + Focusable + Render + Sized {
+ fn width(&self, cx: &App) -> Pixels;
+ fn set_width(&mut self, width: Option<Pixels>, cx: &mut Context<Self>);
+ fn has_notifications(&self, cx: &App) -> bool;
+}
+
+pub trait SidebarHandle: 'static + Send + Sync {
+ fn width(&self, cx: &App) -> Pixels;
+ fn set_width(&self, width: Option<Pixels>, cx: &mut App);
+ fn focus(&self, window: &mut Window, cx: &mut App);
+ fn has_notifications(&self, cx: &App) -> bool;
+ fn to_any(&self) -> AnyView;
+ fn entity_id(&self) -> EntityId;
+}
+
+#[derive(Clone)]
+pub struct DraggedSidebar;
+
+impl Render for DraggedSidebar {
+ fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
+ gpui::Empty
+ }
+}
+
+impl<T: Sidebar> SidebarHandle for Entity<T> {
+ fn width(&self, cx: &App) -> Pixels {
+ self.read(cx).width(cx)
+ }
+
+ fn set_width(&self, width: Option<Pixels>, cx: &mut App) {
+ self.update(cx, |this, cx| this.set_width(width, cx))
+ }
+
+ fn focus(&self, window: &mut Window, cx: &mut App) {
+ let handle = self.read(cx).focus_handle(cx);
+ window.focus(&handle, cx);
+ }
+
+ fn has_notifications(&self, cx: &App) -> bool {
+ self.read(cx).has_notifications(cx)
+ }
+
+ fn to_any(&self) -> AnyView {
+ self.clone().into()
+ }
+
+ fn entity_id(&self) -> EntityId {
+ Entity::entity_id(self)
+ }
+}
+
+pub struct MultiWorkspace {
+ workspaces: Vec<Entity<Workspace>>,
+ active_workspace_index: usize,
+ sidebar: Option<Box<dyn SidebarHandle>>,
+ sidebar_open: bool,
+ _sidebar_subscription: Option<Subscription>,
+}
+
+impl MultiWorkspace {
+ pub fn new(workspace: Entity<Workspace>, _cx: &mut Context<Self>) -> Self {
+ Self {
+ workspaces: vec![workspace],
+ active_workspace_index: 0,
+ sidebar: None,
+ sidebar_open: false,
+ _sidebar_subscription: None,
+ }
+ }
+
+ pub fn register_sidebar<T: Sidebar>(
+ &mut self,
+ sidebar: Entity<T>,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ let subscription =
+ cx.subscribe_in(&sidebar, window, |this, _, event, window, cx| match event {
+ SidebarEvent::Open => this.toggle_sidebar(window, cx),
+ SidebarEvent::Close => {
+ this.close_sidebar(window, cx);
+ }
+ });
+ self.sidebar = Some(Box::new(sidebar));
+ self._sidebar_subscription = Some(subscription);
+ }
+
+ pub fn sidebar(&self) -> Option<&dyn SidebarHandle> {
+ self.sidebar.as_deref()
+ }
+
+ pub fn sidebar_open(&self) -> bool {
+ self.sidebar_open && self.sidebar.is_some()
+ }
+
+ pub fn sidebar_has_notifications(&self, cx: &App) -> bool {
+ self.sidebar
+ .as_ref()
+ .map_or(false, |s| s.has_notifications(cx))
+ }
+
+ pub(crate) fn multi_workspace_enabled(&self, cx: &App) -> bool {
+ cx.has_flag::<AgentV2FeatureFlag>()
+ }
+
+ pub fn toggle_sidebar(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+ if !self.multi_workspace_enabled(cx) {
+ return;
+ }
+
+ if self.sidebar_open {
+ self.close_sidebar(window, cx);
+ let pane = self.workspace().read(cx).active_pane().clone();
+ window.focus(&pane.read(cx).focus_handle(cx), cx);
+ } else {
+ self.open_sidebar(window, cx);
+ if let Some(sidebar) = &self.sidebar {
+ sidebar.focus(window, cx);
+ }
+ }
+ }
+
+ pub fn open_sidebar(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+ self.sidebar_open = true;
+ self.serialize(window, cx);
+ cx.notify();
+ }
+
+ fn close_sidebar(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+ self.sidebar_open = false;
+ self.serialize(window, cx);
+ cx.notify();
+ }
+
+ pub fn is_sidebar_open(&self) -> bool {
+ self.sidebar_open
+ }
+
+ pub fn workspace(&self) -> &Entity<Workspace> {
+ &self.workspaces[self.active_workspace_index]
+ }
+
+ pub fn workspaces(&self) -> &[Entity<Workspace>] {
+ &self.workspaces
+ }
+
+ pub fn active_workspace_index(&self) -> usize {
+ self.active_workspace_index
+ }
+
+ pub fn activate(&mut self, workspace: Entity<Workspace>, cx: &mut Context<Self>) {
+ if !self.multi_workspace_enabled(cx) {
+ self.workspaces[0] = workspace;
+ self.active_workspace_index = 0;
+ cx.notify();
+ return;
+ }
+
+ let index = self.add_workspace(workspace, cx);
+ if self.active_workspace_index != index {
+ self.active_workspace_index = index;
+ cx.notify();
+ }
+ }
+
+ /// Adds a workspace to this window without changing which workspace is active.
+ /// Returns the index of the workspace (existing or newly inserted).
+ pub fn add_workspace(&mut self, workspace: Entity<Workspace>, cx: &mut Context<Self>) -> usize {
+ if let Some(index) = self.workspaces.iter().position(|w| *w == workspace) {
+ index
+ } else {
+ self.workspaces.push(workspace);
+ cx.notify();
+ self.workspaces.len() - 1
+ }
+ }
+
+ pub fn activate_index(&mut self, index: usize, window: &mut Window, cx: &mut Context<Self>) {
+ debug_assert!(
+ index < self.workspaces.len(),
+ "workspace index out of bounds"
+ );
+ self.active_workspace_index = index;
+ self.serialize(window, cx);
+ self.focus_active_workspace(window, cx);
+ cx.notify();
+ }
+
+ pub fn activate_next_workspace(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+ if self.workspaces.len() > 1 {
+ let next_index = (self.active_workspace_index + 1) % self.workspaces.len();
+ self.activate_index(next_index, window, cx);
+ }
+ }
+
+ pub fn activate_previous_workspace(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+ if self.workspaces.len() > 1 {
+ let prev_index = if self.active_workspace_index == 0 {
+ self.workspaces.len() - 1
+ } else {
+ self.active_workspace_index - 1
+ };
+ self.activate_index(prev_index, window, cx);
+ }
+ }
+
+ fn serialize(&self, window: &mut Window, cx: &mut App) {
+ let window_id = window.window_handle().window_id();
+ let state = crate::persistence::model::MultiWorkspaceState {
+ active_workspace_id: self.workspace().read(cx).database_id(),
+ sidebar_open: self.sidebar_open,
+ };
+ cx.background_spawn(async move {
+ crate::persistence::write_multi_workspace_state(window_id, state).await;
+ })
+ .detach();
+ }
+
+ fn focus_active_workspace(&self, window: &mut Window, cx: &mut App) {
+ let pane = self.workspace().read(cx).active_pane().clone();
+ let focus_handle = pane.read(cx).focus_handle(cx);
+ window.focus(&focus_handle, cx);
+ }
+
+ pub fn panel<T: Panel>(&self, cx: &App) -> Option<Entity<T>> {
+ self.workspace().read(cx).panel::<T>(cx)
+ }
+
+ pub fn active_modal<V: ManagedView + 'static>(&self, cx: &App) -> Option<Entity<V>> {
+ self.workspace().read(cx).active_modal::<V>(cx)
+ }
+
+ pub fn add_panel<T: Panel>(
+ &mut self,
+ panel: Entity<T>,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ self.workspace().update(cx, |workspace, cx| {
+ workspace.add_panel(panel, window, cx);
+ });
+ }
+
+ pub fn focus_panel<T: Panel>(
+ &mut self,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> Option<Entity<T>> {
+ self.workspace()
+ .update(cx, |workspace, cx| workspace.focus_panel::<T>(window, cx))
+ }
+
+ pub fn toggle_modal<V: ModalView, B>(
+ &mut self,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ build: B,
+ ) where
+ B: FnOnce(&mut Window, &mut gpui::Context<V>) -> V,
+ {
+ self.workspace().update(cx, |workspace, cx| {
+ workspace.toggle_modal(window, cx, build);
+ });
+ }
+
+ pub fn toggle_dock(
+ &mut self,
+ dock_side: DockPosition,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ self.workspace().update(cx, |workspace, cx| {
+ workspace.toggle_dock(dock_side, window, cx);
+ });
+ }
+
+ pub fn active_item_as<I: 'static>(&self, cx: &App) -> Option<Entity<I>> {
+ self.workspace().read(cx).active_item_as::<I>(cx)
+ }
+
+ pub fn items_of_type<'a, T: Item>(
+ &'a self,
+ cx: &'a App,
+ ) -> impl 'a + Iterator<Item = Entity<T>> {
+ self.workspace().read(cx).items_of_type::<T>(cx)
+ }
+
+ pub fn database_id(&self, cx: &App) -> Option<WorkspaceId> {
+ self.workspace().read(cx).database_id()
+ }
+
+ #[cfg(any(test, feature = "test-support"))]
+ pub fn set_random_database_id(&mut self, cx: &mut Context<Self>) {
+ self.workspace().update(cx, |workspace, _cx| {
+ workspace.set_random_database_id();
+ });
+ }
+
+ #[cfg(any(test, feature = "test-support"))]
+ pub fn test_new(project: Entity<Project>, window: &mut Window, cx: &mut Context<Self>) -> Self {
+ let workspace = cx.new(|cx| Workspace::test_new(project, window, cx));
+ Self::new(workspace, cx)
+ }
+
+ pub fn create_workspace(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+ if !self.multi_workspace_enabled(cx) {
+ return;
+ }
+ let app_state = self.workspace().read(cx).app_state().clone();
+ let project = Project::local(
+ app_state.client.clone(),
+ app_state.node_runtime.clone(),
+ app_state.user_store.clone(),
+ app_state.languages.clone(),
+ app_state.fs.clone(),
+ None,
+ project::LocalProjectFlags::default(),
+ cx,
+ );
+ let new_workspace = cx.new(|cx| Workspace::new(None, project, app_state, window, cx));
+ self.activate(new_workspace, cx);
+ self.focus_active_workspace(window, cx);
+ }
+
+ pub fn remove_workspace(&mut self, index: usize, window: &mut Window, cx: &mut Context<Self>) {
+ if self.workspaces.len() <= 1 || index >= self.workspaces.len() {
+ return;
+ }
+
+ self.workspaces.remove(index);
+
+ if self.active_workspace_index >= self.workspaces.len() {
+ self.active_workspace_index = self.workspaces.len() - 1;
+ } else if self.active_workspace_index > index {
+ self.active_workspace_index -= 1;
+ }
+
+ self.focus_active_workspace(window, cx);
+ cx.notify();
+ }
+
+ pub fn open_project(
+ &mut self,
+ paths: Vec<PathBuf>,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> Task<Result<()>> {
+ let workspace = self.workspace().clone();
+
+ if self.multi_workspace_enabled(cx) {
+ workspace.update(cx, |workspace, cx| {
+ workspace.open_workspace_for_paths(true, paths, window, cx)
+ })
+ } else {
+ cx.spawn_in(window, async move |_this, cx| {
+ let should_continue = workspace
+ .update_in(cx, |workspace, window, cx| {
+ workspace.prepare_to_close(crate::CloseIntent::ReplaceWindow, window, cx)
+ })?
+ .await?;
+ if should_continue {
+ workspace
+ .update_in(cx, |workspace, window, cx| {
+ workspace.open_workspace_for_paths(true, paths, window, cx)
+ })?
+ .await
+ } else {
+ Ok(())
+ }
+ })
+ }
+ }
+}
+
+impl Render for MultiWorkspace {
+ fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+ let multi_workspace_enabled = self.multi_workspace_enabled(cx);
+
+ let sidebar: Option<AnyElement> = if multi_workspace_enabled && self.sidebar_open {
+ self.sidebar.as_ref().map(|sidebar_handle| {
+ let weak = cx.weak_entity();
+
+ let sidebar_width = sidebar_handle.width(cx);
+ let resize_handle = deferred(
+ div()
+ .id("sidebar-resize-handle")
+ .absolute()
+ .right(-SIDEBAR_RESIZE_HANDLE_SIZE / 2.)
+ .top(px(0.))
+ .h_full()
+ .w(SIDEBAR_RESIZE_HANDLE_SIZE)
+ .cursor_col_resize()
+ .on_drag(DraggedSidebar, |dragged, _, _, cx| {
+ cx.stop_propagation();
+ cx.new(|_| dragged.clone())
+ })
+ .on_mouse_down(MouseButton::Left, |_, _, cx| {
+ cx.stop_propagation();
+ })
+ .on_mouse_up(MouseButton::Left, move |event, _, cx| {
+ if event.click_count == 2 {
+ weak.update(cx, |this, cx| {
+ if let Some(sidebar) = this.sidebar.as_mut() {
+ sidebar.set_width(None, cx);
+ }
+ })
+ .ok();
+ cx.stop_propagation();
+ }
+ })
+ .occlude(),
+ );
+
+ div()
+ .id("sidebar-container")
+ .relative()
+ .h_full()
+ .w(sidebar_width)
+ .flex_shrink_0()
+ .child(sidebar_handle.to_any())
+ .child(resize_handle)
+ .into_any_element()
+ })
+ } else {
+ None
+ };
+
+ client_side_decorations(
+ h_flex()
+ .key_context("Workspace")
+ .size_full()
+ .on_action(
+ cx.listener(|this: &mut Self, _: &NewWorkspaceInWindow, window, cx| {
+ this.create_workspace(window, cx);
+ }),
+ )
+ .on_action(
+ cx.listener(|this: &mut Self, _: &NextWorkspaceInWindow, window, cx| {
+ this.activate_next_workspace(window, cx);
+ }),
+ )
+ .on_action(cx.listener(
+ |this: &mut Self, _: &PreviousWorkspaceInWindow, window, cx| {
+ this.activate_previous_workspace(window, cx);
+ },
+ ))
+ .on_action(cx.listener(
+ |this: &mut Self, _: &ToggleWorkspaceSidebar, window, cx| {
+ this.toggle_sidebar(window, cx);
+ },
+ ))
+ .when(
+ self.sidebar_open() && self.multi_workspace_enabled(cx),
+ |this| {
+ this.on_drag_move(cx.listener(
+ |this: &mut Self, e: &DragMoveEvent<DraggedSidebar>, _window, cx| {
+ if let Some(sidebar) = &this.sidebar {
+ let new_width = e.event.position.x;
+ sidebar.set_width(Some(new_width), cx);
+ }
+ },
+ ))
+ .children(sidebar)
+ },
+ )
+ .child(
+ div()
+ .flex()
+ .flex_1()
+ .size_full()
+ .overflow_hidden()
+ .child(self.workspace().clone()),
+ ),
+ window,
+ cx,
+ )
+ }
+}
@@ -1,9 +1,9 @@
-use crate::{SuppressNotification, Toast, Workspace};
+use crate::{MultiWorkspace, SuppressNotification, Toast, Workspace};
use anyhow::Context as _;
use gpui::{
- AnyEntity, AnyView, App, AppContext as _, AsyncWindowContext, ClickEvent, Context,
+ AnyEntity, AnyView, App, AppContext as _, AsyncApp, AsyncWindowContext, ClickEvent, Context,
DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, PromptLevel, Render, ScrollHandle,
- Task, TextStyleRefinement, UnderlineStyle, svg,
+ Task, TextStyleRefinement, UnderlineStyle, WeakEntity, svg,
};
use markdown::{Markdown, MarkdownElement, MarkdownStyle};
use parking_lot::Mutex;
@@ -1037,14 +1037,18 @@ pub fn show_app_notification<V: Notification + 'static>(
.insert(id.clone(), build_notification.clone());
for window in cx.windows() {
- if let Some(workspace_window) = window.downcast::<Workspace>() {
- workspace_window
- .update(cx, |workspace, _window, cx| {
- workspace.show_notification_without_handling_dismiss_events(
- &id,
- cx,
- |cx| build_notification(cx),
- );
+ if let Some(multi_workspace) = window.downcast::<MultiWorkspace>() {
+ multi_workspace
+ .update(cx, |multi_workspace, _window, cx| {
+ for workspace in multi_workspace.workspaces() {
+ workspace.update(cx, |workspace, cx| {
+ workspace.show_notification_without_handling_dismiss_events(
+ &id,
+ cx,
+ |cx| build_notification(cx),
+ );
+ });
+ }
})
.ok(); // Doesn't matter if the windows are dropped
}
@@ -1058,11 +1062,15 @@ pub fn dismiss_app_notification(id: &NotificationId, cx: &mut App) {
cx.defer(move |cx| {
GLOBAL_APP_NOTIFICATIONS.lock().remove(&id);
for window in cx.windows() {
- if let Some(workspace_window) = window.downcast::<Workspace>() {
+ if let Some(multi_workspace) = window.downcast::<MultiWorkspace>() {
let id = id.clone();
- workspace_window
- .update(cx, |workspace, _window, cx| {
- workspace.dismiss_notification(&id, cx)
+ multi_workspace
+ .update(cx, |multi_workspace, _window, cx| {
+ for workspace in multi_workspace.workspaces() {
+ workspace.update(cx, |workspace, cx| {
+ workspace.dismiss_notification(&id, cx)
+ });
+ }
})
.ok();
}
@@ -1076,7 +1084,11 @@ pub trait NotifyResultExt {
fn notify_err(self, workspace: &mut Workspace, cx: &mut Context<Workspace>)
-> Option<Self::Ok>;
- fn notify_async_err(self, cx: &mut AsyncWindowContext) -> Option<Self::Ok>;
+ fn notify_workspace_async_err(
+ self,
+ workspace: WeakEntity<Workspace>,
+ cx: &mut AsyncApp,
+ ) -> Option<Self::Ok>;
/// Notifies the active workspace if there is one, otherwise notifies all workspaces.
fn notify_app_err(self, cx: &mut App) -> Option<Self::Ok>;
@@ -1099,17 +1111,18 @@ where
}
}
- fn notify_async_err(self, cx: &mut AsyncWindowContext) -> Option<T> {
+ fn notify_workspace_async_err(
+ self,
+ workspace: WeakEntity<Workspace>,
+ cx: &mut AsyncApp,
+ ) -> Option<T> {
match self {
Ok(value) => Some(value),
Err(err) => {
log::error!("{err:?}");
- cx.update_root(|view, _, cx| {
- if let Ok(workspace) = view.downcast::<Workspace>() {
- workspace.update(cx, |workspace, cx| workspace.show_error(&err, cx))
- }
- })
- .ok();
+ workspace
+ .update(cx, |workspace, cx| workspace.show_error(&err, cx))
+ .ok();
None
}
}
@@ -1137,7 +1150,12 @@ where
}
pub trait NotifyTaskExt {
- fn detach_and_notify_err(self, window: &mut Window, cx: &mut App);
+ fn detach_and_notify_err(
+ self,
+ workspace: WeakEntity<Workspace>,
+ window: &mut Window,
+ cx: &mut App,
+ );
}
impl<R, E> NotifyTaskExt for Task<std::result::Result<R, E>>
@@ -1145,9 +1163,16 @@ where
E: std::fmt::Debug + std::fmt::Display + Sized + 'static,
R: 'static,
{
- fn detach_and_notify_err(self, window: &mut Window, cx: &mut App) {
+ fn detach_and_notify_err(
+ self,
+ workspace: WeakEntity<Workspace>,
+ window: &mut Window,
+ cx: &mut App,
+ ) {
window
- .spawn(cx, async move |cx| self.await.notify_async_err(cx))
+ .spawn(cx, async move |mut cx| {
+ self.await.notify_workspace_async_err(workspace, &mut cx)
+ })
.detach();
}
}
@@ -3881,9 +3881,10 @@ impl Pane {
.path_for_entry(project_entry_id, cx)
{
let load_path_task = workspace.load_path(project_path.clone(), window, cx);
- cx.spawn_in(window, async move |workspace, cx| {
- if let Some((project_entry_id, build_item)) =
- load_path_task.await.notify_async_err(cx)
+ cx.spawn_in(window, async move |workspace, mut cx| {
+ if let Some((project_entry_id, build_item)) = load_path_task
+ .await
+ .notify_workspace_async_err(workspace.clone(), &mut cx)
{
let (to_pane, new_item_handle) = workspace
.update_in(cx, |workspace, window, cx| {
@@ -8,6 +8,8 @@ use std::{
sync::Arc,
};
+use fs::Fs;
+
use anyhow::{Context as _, Result, bail};
use collections::{HashMap, HashSet, IndexSet};
use db::{
@@ -48,7 +50,7 @@ use model::{
SerializedPaneGroup, SerializedWorkspace,
};
-use self::model::{DockStructure, SerializedWorkspaceLocation};
+use self::model::{DockStructure, SerializedWorkspaceLocation, SessionWorkspace};
// https://www.sqlite.org/limits.html
// > <..> the maximum value of a host parameter number is SQLITE_MAX_VARIABLE_NUMBER,
@@ -281,6 +283,64 @@ impl From<WindowBoundsJson> for WindowBounds {
}
}
+fn multi_workspace_states() -> db::kvp::ScopedKeyValueStore<'static> {
+ KEY_VALUE_STORE.scoped("multi_workspace_state")
+}
+
+fn read_multi_workspace_state(window_id: WindowId) -> model::MultiWorkspaceState {
+ multi_workspace_states()
+ .read(&window_id.as_u64().to_string())
+ .log_err()
+ .flatten()
+ .and_then(|json| serde_json::from_str(&json).ok())
+ .unwrap_or_default()
+}
+
+pub async fn write_multi_workspace_state(window_id: WindowId, state: model::MultiWorkspaceState) {
+ if let Ok(json_str) = serde_json::to_string(&state) {
+ multi_workspace_states()
+ .write(window_id.as_u64().to_string(), json_str)
+ .await
+ .log_err();
+ }
+}
+
+pub fn read_serialized_multi_workspaces(
+ session_workspaces: Vec<model::SessionWorkspace>,
+) -> Vec<model::SerializedMultiWorkspace> {
+ let mut window_groups: Vec<Vec<model::SessionWorkspace>> = Vec::new();
+ let mut window_id_to_group: HashMap<WindowId, usize> = HashMap::default();
+
+ for session_workspace in session_workspaces {
+ match session_workspace.window_id {
+ Some(window_id) => {
+ let group_index = *window_id_to_group.entry(window_id).or_insert_with(|| {
+ window_groups.push(Vec::new());
+ window_groups.len() - 1
+ });
+ window_groups[group_index].push(session_workspace);
+ }
+ None => {
+ window_groups.push(vec![session_workspace]);
+ }
+ }
+ }
+
+ window_groups
+ .into_iter()
+ .map(|group| {
+ let window_id = group.first().and_then(|sw| sw.window_id);
+ let state = window_id
+ .map(read_multi_workspace_state)
+ .unwrap_or_default();
+ model::SerializedMultiWorkspace {
+ workspaces: group,
+ state,
+ }
+ })
+ .collect()
+}
+
const DEFAULT_DOCK_STATE_KEY: &str = "default_dock_state";
pub fn read_default_dock_state() -> Option<DockStructure> {
@@ -1708,10 +1768,26 @@ impl WorkspaceDb {
}
}
+ async fn all_paths_exist_with_a_directory(paths: &[PathBuf], fs: &dyn Fs) -> bool {
+ let mut any_dir = false;
+ for path in paths {
+ match fs.metadata(path).await.ok().flatten() {
+ None => return false,
+ Some(meta) => {
+ if meta.is_dir {
+ any_dir = true;
+ }
+ }
+ }
+ }
+ any_dir
+ }
+
// Returns the recent locations which are still valid on disk and deletes ones which no longer
// exist.
pub async fn recent_workspaces_on_disk(
&self,
+ fs: &dyn Fs,
) -> Result<Vec<(WorkspaceId, SerializedWorkspaceLocation, PathList)>> {
let mut result = Vec::new();
let mut delete_tasks = Vec::new();
@@ -1744,11 +1820,8 @@ impl WorkspaceDb {
// If a local workspace points to WSL, this check will cause us to wait for the
// WSL VM and file server to boot up. This can block for many seconds.
// Supported scenarios use remote workspaces.
- if !has_wsl_path && paths.paths().iter().all(|path| path.exists()) {
- // Only show directories in recent projects
- if paths.paths().iter().any(|path| path.is_dir()) {
- result.push((id, SerializedWorkspaceLocation::Local, paths));
- }
+ if !has_wsl_path && Self::all_paths_exist_with_a_directory(paths.paths(), fs).await {
+ result.push((id, SerializedWorkspaceLocation::Local, paths));
} else {
delete_tasks.push(self.delete_workspace_by_id(id));
}
@@ -1760,65 +1833,67 @@ impl WorkspaceDb {
pub async fn last_workspace(
&self,
+ fs: &dyn Fs,
) -> Result<Option<(WorkspaceId, SerializedWorkspaceLocation, PathList)>> {
- Ok(self.recent_workspaces_on_disk().await?.into_iter().next())
+ Ok(self.recent_workspaces_on_disk(fs).await?.into_iter().next())
}
// Returns the locations of the workspaces that were still opened when the last
// session was closed (i.e. when Zed was quit).
// If `last_session_window_order` is provided, the returned locations are ordered
// according to that.
- pub fn last_session_workspace_locations(
+ pub async fn last_session_workspace_locations(
&self,
last_session_id: &str,
last_session_window_stack: Option<Vec<WindowId>>,
- ) -> Result<Vec<(WorkspaceId, SerializedWorkspaceLocation, PathList)>> {
+ fs: &dyn Fs,
+ ) -> Result<Vec<SessionWorkspace>> {
let mut workspaces = Vec::new();
for (workspace_id, paths, window_id, remote_connection_id) in
self.session_workspaces(last_session_id.to_owned())?
{
+ let window_id = window_id.map(WindowId::from);
+
if let Some(remote_connection_id) = remote_connection_id {
- workspaces.push((
+ workspaces.push(SessionWorkspace {
workspace_id,
- SerializedWorkspaceLocation::Remote(
+ location: SerializedWorkspaceLocation::Remote(
self.remote_connection(remote_connection_id)?,
),
paths,
- window_id.map(WindowId::from),
- ));
+ window_id,
+ });
} else if paths.is_empty() {
// Empty workspace with items (drafts, files) - include for restoration
- workspaces.push((
- workspace_id,
- SerializedWorkspaceLocation::Local,
- paths,
- window_id.map(WindowId::from),
- ));
- } else if paths.paths().iter().all(|path| path.exists())
- && paths.paths().iter().any(|path| path.is_dir())
- {
- workspaces.push((
+ workspaces.push(SessionWorkspace {
workspace_id,
- SerializedWorkspaceLocation::Local,
+ location: SerializedWorkspaceLocation::Local,
paths,
- window_id.map(WindowId::from),
- ));
+ window_id,
+ });
+ } else {
+ if Self::all_paths_exist_with_a_directory(paths.paths(), fs).await {
+ workspaces.push(SessionWorkspace {
+ workspace_id,
+ location: SerializedWorkspaceLocation::Local,
+ paths,
+ window_id,
+ });
+ }
}
}
if let Some(stack) = last_session_window_stack {
- workspaces.sort_by_key(|(_, _, _, window_id)| {
- window_id
+ workspaces.sort_by_key(|workspace| {
+ workspace
+ .window_id
.and_then(|id| stack.iter().position(|&order_id| order_id == id))
.unwrap_or(usize::MAX)
});
}
- Ok(workspaces
- .into_iter()
- .map(|(workspace_id, location, paths, _)| (workspace_id, location, paths))
- .collect::<Vec<_>>())
+ Ok(workspaces)
}
fn get_center_pane_group(&self, workspace_id: WorkspaceId) -> Result<SerializedPaneGroup> {
@@ -2272,11 +2347,12 @@ pub fn delete_unloaded_items(
mod tests {
use super::*;
use crate::persistence::model::{
- SerializedItem, SerializedPane, SerializedPaneGroup, SerializedWorkspace,
+ SerializedItem, SerializedPane, SerializedPaneGroup, SerializedWorkspace, SessionWorkspace,
};
use gpui;
use pretty_assertions::assert_eq;
use remote::SshConnectionOptions;
+ use serde_json::json;
use std::{thread, time::Duration};
#[gpui::test]
@@ -3040,12 +3116,18 @@ mod tests {
}
#[gpui::test]
- async fn test_last_session_workspace_locations() {
+ async fn test_last_session_workspace_locations(cx: &mut gpui::TestAppContext) {
let dir1 = tempfile::TempDir::with_prefix("dir1").unwrap();
let dir2 = tempfile::TempDir::with_prefix("dir2").unwrap();
let dir3 = tempfile::TempDir::with_prefix("dir3").unwrap();
let dir4 = tempfile::TempDir::with_prefix("dir4").unwrap();
+ let fs = fs::FakeFs::new(cx.executor());
+ fs.insert_tree(dir1.path(), json!({})).await;
+ fs.insert_tree(dir2.path(), json!({})).await;
+ fs.insert_tree(dir3.path(), json!({})).await;
+ fs.insert_tree(dir4.path(), json!({})).await;
+
let db =
WorkspaceDb::open_test_db("test_serializing_workspaces_last_session_workspaces").await;
@@ -3088,47 +3170,55 @@ mod tests {
]));
let locations = db
- .last_session_workspace_locations("one-session", stack)
+ .last_session_workspace_locations("one-session", stack, fs.as_ref())
+ .await
.unwrap();
assert_eq!(
locations,
[
- (
- WorkspaceId(4),
- SerializedWorkspaceLocation::Local,
- PathList::new(&[dir4.path()])
- ),
- (
- WorkspaceId(3),
- SerializedWorkspaceLocation::Local,
- PathList::new(&[dir3.path()])
- ),
- (
- WorkspaceId(2),
- SerializedWorkspaceLocation::Local,
- PathList::new(&[dir2.path()])
- ),
- (
- WorkspaceId(1),
- SerializedWorkspaceLocation::Local,
- PathList::new(&[dir1.path()])
- ),
- (
- WorkspaceId(5),
- SerializedWorkspaceLocation::Local,
- PathList::new(&[dir1.path(), dir2.path(), dir3.path()])
- ),
- (
- WorkspaceId(6),
- SerializedWorkspaceLocation::Local,
- PathList::new(&[dir4.path(), dir3.path(), dir2.path()])
- ),
+ SessionWorkspace {
+ workspace_id: WorkspaceId(4),
+ location: SerializedWorkspaceLocation::Local,
+ paths: PathList::new(&[dir4.path()]),
+ window_id: Some(WindowId::from(2u64)),
+ },
+ SessionWorkspace {
+ workspace_id: WorkspaceId(3),
+ location: SerializedWorkspaceLocation::Local,
+ paths: PathList::new(&[dir3.path()]),
+ window_id: Some(WindowId::from(8u64)),
+ },
+ SessionWorkspace {
+ workspace_id: WorkspaceId(2),
+ location: SerializedWorkspaceLocation::Local,
+ paths: PathList::new(&[dir2.path()]),
+ window_id: Some(WindowId::from(5u64)),
+ },
+ SessionWorkspace {
+ workspace_id: WorkspaceId(1),
+ location: SerializedWorkspaceLocation::Local,
+ paths: PathList::new(&[dir1.path()]),
+ window_id: Some(WindowId::from(9u64)),
+ },
+ SessionWorkspace {
+ workspace_id: WorkspaceId(5),
+ location: SerializedWorkspaceLocation::Local,
+ paths: PathList::new(&[dir1.path(), dir2.path(), dir3.path()]),
+ window_id: Some(WindowId::from(3u64)),
+ },
+ SessionWorkspace {
+ workspace_id: WorkspaceId(6),
+ location: SerializedWorkspaceLocation::Local,
+ paths: PathList::new(&[dir4.path(), dir3.path(), dir2.path()]),
+ window_id: Some(WindowId::from(4u64)),
+ },
]
);
}
#[gpui::test]
- async fn test_last_session_workspace_locations_remote() {
+ async fn test_last_session_workspace_locations_remote(cx: &mut gpui::TestAppContext) {
+ let fs = fs::FakeFs::new(cx.executor());
let db =
WorkspaceDb::open_test_db("test_serializing_workspaces_last_session_workspaces_remote")
.await;
@@ -3190,40 +3280,45 @@ mod tests {
]));
let have = db
- .last_session_workspace_locations("one-session", stack)
+ .last_session_workspace_locations("one-session", stack, fs.as_ref())
+ .await
.unwrap();
assert_eq!(have.len(), 4);
assert_eq!(
have[0],
- (
- WorkspaceId(4),
- SerializedWorkspaceLocation::Remote(remote_connections[3].clone()),
- PathList::default()
- )
+ SessionWorkspace {
+ workspace_id: WorkspaceId(4),
+ location: SerializedWorkspaceLocation::Remote(remote_connections[3].clone()),
+ paths: PathList::default(),
+ window_id: Some(WindowId::from(2u64)),
+ }
);
assert_eq!(
have[1],
- (
- WorkspaceId(3),
- SerializedWorkspaceLocation::Remote(remote_connections[2].clone()),
- PathList::default()
- )
+ SessionWorkspace {
+ workspace_id: WorkspaceId(3),
+ location: SerializedWorkspaceLocation::Remote(remote_connections[2].clone()),
+ paths: PathList::default(),
+ window_id: Some(WindowId::from(8u64)),
+ }
);
assert_eq!(
have[2],
- (
- WorkspaceId(2),
- SerializedWorkspaceLocation::Remote(remote_connections[1].clone()),
- PathList::default()
- )
+ SessionWorkspace {
+ workspace_id: WorkspaceId(2),
+ location: SerializedWorkspaceLocation::Remote(remote_connections[1].clone()),
+ paths: PathList::default(),
+ window_id: Some(WindowId::from(5u64)),
+ }
);
assert_eq!(
have[3],
- (
- WorkspaceId(1),
- SerializedWorkspaceLocation::Remote(remote_connections[0].clone()),
- PathList::default()
- )
+ SessionWorkspace {
+ workspace_id: WorkspaceId(1),
+ location: SerializedWorkspaceLocation::Remote(remote_connections[0].clone()),
+ paths: PathList::default(),
+ window_id: Some(WindowId::from(9u64)),
+ }
);
}
@@ -3555,4 +3650,192 @@ mod tests {
assert!(retrieved.display.is_some());
assert_eq!(retrieved.display.unwrap(), display_uuid);
}
+
+ #[gpui::test]
+ async fn test_last_session_workspace_locations_groups_by_window_id(
+ cx: &mut gpui::TestAppContext,
+ ) {
+ let dir1 = tempfile::TempDir::with_prefix("dir1").unwrap();
+ let dir2 = tempfile::TempDir::with_prefix("dir2").unwrap();
+ let dir3 = tempfile::TempDir::with_prefix("dir3").unwrap();
+ let dir4 = tempfile::TempDir::with_prefix("dir4").unwrap();
+ let dir5 = tempfile::TempDir::with_prefix("dir5").unwrap();
+
+ let fs = fs::FakeFs::new(cx.executor());
+ fs.insert_tree(dir1.path(), json!({})).await;
+ fs.insert_tree(dir2.path(), json!({})).await;
+ fs.insert_tree(dir3.path(), json!({})).await;
+ fs.insert_tree(dir4.path(), json!({})).await;
+ fs.insert_tree(dir5.path(), json!({})).await;
+
+ let db =
+ WorkspaceDb::open_test_db("test_last_session_workspace_locations_groups_by_window_id")
+ .await;
+
+ // Simulate two MultiWorkspace windows each containing two workspaces,
+ // plus one single-workspace window:
+ // Window 10: workspace 1, workspace 2
+ // Window 20: workspace 3, workspace 4
+ // Window 30: workspace 5 (only one)
+ //
+ // On session restore, the caller should be able to group these by
+ // window_id to reconstruct the MultiWorkspace windows.
+ let workspaces_data: Vec<(i64, &Path, u64)> = vec![
+ (1, dir1.path(), 10),
+ (2, dir2.path(), 10),
+ (3, dir3.path(), 20),
+ (4, dir4.path(), 20),
+ (5, dir5.path(), 30),
+ ];
+
+ for (id, dir, window_id) in &workspaces_data {
+ db.save_workspace(SerializedWorkspace {
+ id: WorkspaceId(*id),
+ paths: PathList::new(&[*dir]),
+ location: SerializedWorkspaceLocation::Local,
+ center_group: Default::default(),
+ window_bounds: Default::default(),
+ display: Default::default(),
+ docks: Default::default(),
+ centered_layout: false,
+ session_id: Some("test-session".to_owned()),
+ breakpoints: Default::default(),
+ window_id: Some(*window_id),
+ user_toolchains: Default::default(),
+ })
+ .await;
+ }
+
+ let locations = db
+ .last_session_workspace_locations("test-session", None, fs.as_ref())
+ .await
+ .unwrap();
+
+ // All 5 workspaces should be returned with their window_ids.
+ assert_eq!(locations.len(), 5);
+
+ // Every entry should have a window_id so the caller can group them.
+ for session_workspace in &locations {
+ assert!(
+ session_workspace.window_id.is_some(),
+ "workspace {:?} missing window_id",
+ session_workspace.workspace_id
+ );
+ }
+
+ // Group by window_id, simulating what the restoration code should do.
+ let mut by_window: HashMap<WindowId, Vec<WorkspaceId>> = HashMap::default();
+ for session_workspace in &locations {
+ if let Some(window_id) = session_workspace.window_id {
+ by_window
+ .entry(window_id)
+ .or_default()
+ .push(session_workspace.workspace_id);
+ }
+ }
+
+ // Should produce 3 windows, not 5.
+ assert_eq!(
+ by_window.len(),
+ 3,
+ "Expected 3 window groups, got {}: {:?}",
+ by_window.len(),
+ by_window
+ );
+
+ // Window 10 should contain workspaces 1 and 2.
+ let window_10 = by_window.get(&WindowId::from(10u64)).unwrap();
+ assert_eq!(window_10.len(), 2);
+ assert!(window_10.contains(&WorkspaceId(1)));
+ assert!(window_10.contains(&WorkspaceId(2)));
+
+ // Window 20 should contain workspaces 3 and 4.
+ let window_20 = by_window.get(&WindowId::from(20u64)).unwrap();
+ assert_eq!(window_20.len(), 2);
+ assert!(window_20.contains(&WorkspaceId(3)));
+ assert!(window_20.contains(&WorkspaceId(4)));
+
+ // Window 30 should contain only workspace 5.
+ let window_30 = by_window.get(&WindowId::from(30u64)).unwrap();
+ assert_eq!(window_30.len(), 1);
+ assert!(window_30.contains(&WorkspaceId(5)));
+ }
+
+ #[gpui::test]
+ async fn test_read_serialized_multi_workspaces_with_state() {
+ use crate::persistence::model::MultiWorkspaceState;
+
+ // Write multi-workspace state for two windows via the scoped KVP.
+ let window_10 = WindowId::from(10u64);
+ let window_20 = WindowId::from(20u64);
+
+ write_multi_workspace_state(
+ window_10,
+ MultiWorkspaceState {
+ active_workspace_id: Some(WorkspaceId(2)),
+ sidebar_open: true,
+ },
+ )
+ .await;
+
+ write_multi_workspace_state(
+ window_20,
+ MultiWorkspaceState {
+ active_workspace_id: Some(WorkspaceId(3)),
+ sidebar_open: false,
+ },
+ )
+ .await;
+
+ // Build session workspaces: two in window 10, one in window 20, one with no window.
+ let session_workspaces = vec![
+ SessionWorkspace {
+ workspace_id: WorkspaceId(1),
+ location: SerializedWorkspaceLocation::Local,
+ paths: PathList::new(&["/a"]),
+ window_id: Some(window_10),
+ },
+ SessionWorkspace {
+ workspace_id: WorkspaceId(2),
+ location: SerializedWorkspaceLocation::Local,
+ paths: PathList::new(&["/b"]),
+ window_id: Some(window_10),
+ },
+ SessionWorkspace {
+ workspace_id: WorkspaceId(3),
+ location: SerializedWorkspaceLocation::Local,
+ paths: PathList::new(&["/c"]),
+ window_id: Some(window_20),
+ },
+ SessionWorkspace {
+ workspace_id: WorkspaceId(4),
+ location: SerializedWorkspaceLocation::Local,
+ paths: PathList::new(&["/d"]),
+ window_id: None,
+ },
+ ];
+
+ let results = read_serialized_multi_workspaces(session_workspaces);
+
+ // Should produce 3 groups: window 10, window 20, and the orphan.
+ assert_eq!(results.len(), 3);
+
+ // Window 10 group: 2 workspaces, active_workspace_id = 2, sidebar open.
+ let group_10 = &results[0];
+ assert_eq!(group_10.workspaces.len(), 2);
+ assert_eq!(group_10.state.active_workspace_id, Some(WorkspaceId(2)));
+ assert_eq!(group_10.state.sidebar_open, true);
+
+ // Window 20 group: 1 workspace, active_workspace_id = 3, sidebar closed.
+ let group_20 = &results[1];
+ assert_eq!(group_20.workspaces.len(), 1);
+ assert_eq!(group_20.state.active_workspace_id, Some(WorkspaceId(3)));
+ assert_eq!(group_20.state.sidebar_open, false);
+
+ // Orphan group: no window_id, so state is default.
+ let group_none = &results[2];
+ assert_eq!(group_none.workspaces.len(), 1);
+ assert_eq!(group_none.state.active_workspace_id, None);
+ assert_eq!(group_none.state.sidebar_open, false);
+ }
}
@@ -10,7 +10,7 @@ use db::sqlez::{
bindable::{Bind, Column, StaticColumnCount},
statement::Statement,
};
-use gpui::{AsyncWindowContext, Entity, WeakEntity};
+use gpui::{AsyncWindowContext, Entity, WeakEntity, WindowId};
use language::{Toolchain, ToolchainScope};
use project::{Project, debugger::breakpoint_store::SourceBreakpoint};
@@ -49,6 +49,32 @@ impl SerializedWorkspaceLocation {
}
}
+/// A workspace entry from a previous session, containing all the info needed
+/// to restore it including which window it belonged to (for MultiWorkspace grouping).
+#[derive(Debug, PartialEq, Clone)]
+pub struct SessionWorkspace {
+ pub workspace_id: WorkspaceId,
+ pub location: SerializedWorkspaceLocation,
+ pub paths: PathList,
+ pub window_id: Option<WindowId>,
+}
+
+/// Per-window state for a MultiWorkspace, persisted to KVP.
+#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
+pub struct MultiWorkspaceState {
+ pub active_workspace_id: Option<WorkspaceId>,
+ pub sidebar_open: bool,
+}
+
+/// The serialized state of a single MultiWorkspace window from a previous session:
+/// all workspaces that shared the window, which one was active, and whether the
+/// sidebar was open.
+#[derive(Debug, Clone)]
+pub struct SerializedMultiWorkspace {
+ pub workspaces: Vec<SessionWorkspace>,
+ pub state: MultiWorkspaceState,
+}
+
#[derive(Debug, PartialEq, Clone)]
pub(crate) struct SerializedWorkspace {
pub(crate) id: WorkspaceId,
@@ -114,7 +114,9 @@ impl RenderOnce for SectionButton {
.size(rems_from_px(12.)),
),
)
- .on_click(move |_, window, cx| window.dispatch_action(self.action.boxed_clone(), cx))
+ .on_click(move |_, window, cx| {
+ self.focus_handle.dispatch_action(&*self.action, window, cx)
+ })
}
}
@@ -225,9 +227,13 @@ impl WelcomePage {
.detach();
if fallback_to_recent_projects {
+ let fs = workspace
+ .upgrade()
+ .map(|ws| ws.read(cx).app_state().fs.clone());
cx.spawn_in(window, async move |this: WeakEntity<Self>, cx| {
+ let Some(fs) = fs else { return };
let workspaces = WORKSPACE_DB
- .recent_workspaces_on_disk()
+ .recent_workspaces_on_disk(fs.as_ref())
.await
.log_err()
.unwrap_or_default();
@@ -267,21 +273,18 @@ impl WelcomePage {
) {
if let Some(recent_workspaces) = &self.recent_workspaces {
if let Some((_workspace_id, location, paths)) = recent_workspaces.get(action.index) {
- let paths = paths.clone();
- let location = location.clone();
let is_local = matches!(location, SerializedWorkspaceLocation::Local);
- let workspace = self.workspace.clone();
if is_local {
+ let paths = paths.clone();
let paths = paths.paths().to_vec();
- cx.spawn_in(window, async move |_, cx| {
- let _ = workspace.update_in(cx, |workspace, window, cx| {
+ self.workspace
+ .update(cx, |workspace, cx| {
workspace
.open_workspace_for_paths(true, paths, window, cx)
- .detach();
- });
- })
- .detach();
+ .detach_and_log_err(cx);
+ })
+ .log_err();
} else {
use zed_actions::OpenRecent;
window.dispatch_action(OpenRecent::default().boxed_clone(), cx);
@@ -3,6 +3,7 @@ pub mod history_manager;
pub mod invalid_item_view;
pub mod item;
mod modal_layer;
+mod multi_workspace;
pub mod notifications;
pub mod pane;
pub mod pane_group;
@@ -22,6 +23,10 @@ mod workspace_settings;
pub use crate::notifications::NotificationFrame;
pub use dock::Panel;
+pub use multi_workspace::{
+ DraggedSidebar, MultiWorkspace, NewWorkspaceInWindow, NextWorkspaceInWindow,
+ PreviousWorkspaceInWindow, Sidebar, SidebarEvent, SidebarHandle, ToggleWorkspaceSidebar,
+};
pub use path_list::PathList;
pub use toast_layer::{ToastAction, ToastLayer, ToastView};
@@ -71,7 +76,8 @@ pub use pane_group::{
use persistence::{DB, SerializedWindowBounds, model::SerializedWorkspace};
pub use persistence::{
DB as WORKSPACE_DB, WorkspaceDb, delete_unloaded_items,
- model::{ItemId, SerializedWorkspaceLocation},
+ model::{ItemId, SerializedMultiWorkspace, SerializedWorkspaceLocation, SessionWorkspace},
+ read_serialized_multi_workspaces,
};
use postage::stream::Stream;
use project::{
@@ -562,9 +568,27 @@ pub struct OpenTerminal {
pub local: bool,
}
-#[derive(Clone, Copy, Debug, Default, Hash, PartialEq, Eq, PartialOrd, Ord)]
+#[derive(
+ Clone,
+ Copy,
+ Debug,
+ Default,
+ Hash,
+ PartialEq,
+ Eq,
+ PartialOrd,
+ Ord,
+ serde::Serialize,
+ serde::Deserialize,
+)]
pub struct WorkspaceId(i64);
+impl WorkspaceId {
+ pub fn from_i64(value: i64) -> Self {
+ Self(value)
+ }
+}
+
impl StaticColumnCount for WorkspaceId {}
impl Bind for WorkspaceId {
fn bind(&self, statement: &Statement, start_index: i32) -> Result<i32> {
@@ -599,11 +623,14 @@ fn prompt_and_open_paths(app_state: Arc<AppState>, options: PathPromptOptions, c
cx.update(|cx| {
if let Some(workspace_window) = cx
.active_window()
- .and_then(|window| window.downcast::<Workspace>())
+ .and_then(|window| window.downcast::<MultiWorkspace>())
{
workspace_window
- .update(cx, |workspace, _, cx| {
- workspace.show_portal_error(err.to_string(), cx);
+ .update(cx, |multi_workspace, _, cx| {
+ let workspace = multi_workspace.workspace().clone();
+ workspace.update(cx, |workspace, cx| {
+ workspace.show_portal_error(err.to_string(), cx);
+ });
})
.ok();
}
@@ -618,7 +645,7 @@ pub fn init(app_state: Arc<AppState>, cx: &mut App) {
component::init();
theme_preview::init(cx);
toast_layer::init(cx);
- history_manager::init(cx);
+ history_manager::init(app_state.fs.clone(), cx);
cx.on_action(|_: &CloseWindow, cx| Workspace::close_global(cx))
.on_action(|_: &Reload, cx| reload(cx))
@@ -969,7 +996,7 @@ struct GlobalAppState(Weak<AppState>);
impl Global for GlobalAppState {}
pub struct WorkspaceStore {
- workspaces: HashSet<WindowHandle<Workspace>>,
+ workspaces: HashSet<(gpui::AnyWindowHandle, WeakEntity<Workspace>)>,
client: Arc<Client>,
_subscriptions: Vec<client::Subscription>,
}
@@ -1455,9 +1482,11 @@ impl Workspace {
cx.emit(Event::PaneAdded(center_pane.clone()));
- let window_handle = window.window_handle().downcast::<Workspace>().unwrap();
+ let any_window_handle = window.window_handle();
app_state.workspace_store.update(cx, |store, _| {
- store.workspaces.insert(window_handle);
+ store
+ .workspaces
+ .insert((any_window_handle, weak_handle.clone()));
});
let mut current_user = app_state.user_store.read(cx).watch_current_user();
@@ -1582,10 +1611,13 @@ impl Workspace {
GlobalTheme::reload_theme(cx);
GlobalTheme::reload_icon_theme(cx);
}),
- cx.on_release(move |this, cx| {
- this.app_state.workspace_store.update(cx, move |store, _| {
- store.workspaces.remove(&window_handle);
- })
+ cx.on_release({
+ let weak_handle = weak_handle.clone();
+ move |this, cx| {
+ this.app_state.workspace_store.update(cx, move |store, _| {
+ store.workspaces.retain(|(_, weak)| weak != &weak_handle);
+ })
+ }
}),
];
@@ -1659,13 +1691,13 @@ impl Workspace {
pub fn new_local(
abs_paths: Vec<PathBuf>,
app_state: Arc<AppState>,
- requesting_window: Option<WindowHandle<Workspace>>,
+ requesting_window: Option<WindowHandle<MultiWorkspace>>,
env: Option<HashMap<String, String>>,
init: Option<Box<dyn FnOnce(&mut Workspace, &mut Window, &mut Context<Workspace>) + Send>>,
cx: &mut App,
) -> Task<
anyhow::Result<(
- WindowHandle<Workspace>,
+ WindowHandle<MultiWorkspace>,
Vec<Option<anyhow::Result<Box<dyn ItemHandle>>>>,
)>,
> {
@@ -1763,71 +1795,23 @@ impl Workspace {
});
}
- let window = if let Some(window) = requesting_window {
- let centered_layout = serialized_workspace
- .as_ref()
- .map(|w| w.centered_layout)
- .unwrap_or(false);
-
- cx.update_window(window.into(), |_, window, cx| {
- window.replace_root(cx, |window, cx| {
- let mut workspace = Workspace::new(
- Some(workspace_id),
- project_handle.clone(),
- app_state.clone(),
- window,
- cx,
- );
-
- workspace.centered_layout = centered_layout;
-
- // Call init callback to add items before window renders
- if let Some(init) = init {
- init(&mut workspace, window, cx);
- }
-
- workspace
- });
- })?;
- window
- } else {
- let window_bounds_override = window_bounds_env_override();
-
- let (window_bounds, display) = if let Some(bounds) = window_bounds_override {
- (Some(WindowBounds::Windowed(bounds)), None)
- } else if let Some(workspace) = serialized_workspace.as_ref()
- && let Some(display) = workspace.display
- && let Some(bounds) = workspace.window_bounds.as_ref()
- {
- // Reopening an existing workspace - restore its saved bounds
- (Some(bounds.0), Some(display))
- } else if let Some((display, bounds)) = persistence::read_default_window_bounds() {
- // New or empty workspace - use the last known window bounds
- (Some(bounds), Some(display))
- } else {
- // New window - let GPUI's default_bounds() handle cascading
- (None, None)
- };
+ let (window, workspace): (WindowHandle<MultiWorkspace>, Entity<Workspace>) =
+ if let Some(window) = requesting_window {
+ let centered_layout = serialized_workspace
+ .as_ref()
+ .map(|w| w.centered_layout)
+ .unwrap_or(false);
- // Use the serialized workspace to construct the new window
- let mut options = cx.update(|cx| (app_state.build_window_options)(display, cx));
- options.window_bounds = window_bounds;
- let centered_layout = serialized_workspace
- .as_ref()
- .map(|w| w.centered_layout)
- .unwrap_or(false);
- cx.open_window(options, {
- let app_state = app_state.clone();
- let project_handle = project_handle.clone();
- move |window, cx| {
- cx.new(|cx| {
+ let workspace = window.update(cx, |multi_workspace, window, cx| {
+ let workspace = cx.new(|cx| {
let mut workspace = Workspace::new(
Some(workspace_id),
- project_handle,
- app_state,
+ project_handle.clone(),
+ app_state.clone(),
window,
cx,
);
+
workspace.centered_layout = centered_layout;
// Call init callback to add items before window renders
@@ -1836,10 +1820,69 @@ impl Workspace {
}
workspace
- })
- }
- })?
- };
+ });
+ multi_workspace.activate(workspace.clone(), cx);
+ workspace
+ })?;
+ (window, workspace)
+ } else {
+ let window_bounds_override = window_bounds_env_override();
+
+ let (window_bounds, display) = if let Some(bounds) = window_bounds_override {
+ (Some(WindowBounds::Windowed(bounds)), None)
+ } else if let Some(workspace) = serialized_workspace.as_ref()
+ && let Some(display) = workspace.display
+ && let Some(bounds) = workspace.window_bounds.as_ref()
+ {
+ // Reopening an existing workspace - restore its saved bounds
+ (Some(bounds.0), Some(display))
+ } else if let Some((display, bounds)) =
+ persistence::read_default_window_bounds()
+ {
+ // New or empty workspace - use the last known window bounds
+ (Some(bounds), Some(display))
+ } else {
+ // New window - let GPUI's default_bounds() handle cascading
+ (None, None)
+ };
+
+ // Use the serialized workspace to construct the new window
+ let mut options = cx.update(|cx| (app_state.build_window_options)(display, cx));
+ options.window_bounds = window_bounds;
+ let centered_layout = serialized_workspace
+ .as_ref()
+ .map(|w| w.centered_layout)
+ .unwrap_or(false);
+ let window = cx.open_window(options, {
+ let app_state = app_state.clone();
+ let project_handle = project_handle.clone();
+ move |window, cx| {
+ let workspace = cx.new(|cx| {
+ let mut workspace = Workspace::new(
+ Some(workspace_id),
+ project_handle,
+ app_state,
+ window,
+ cx,
+ );
+ workspace.centered_layout = centered_layout;
+
+ // Call init callback to add items before window renders
+ if let Some(init) = init {
+ init(&mut workspace, window, cx);
+ }
+
+ workspace
+ });
+ cx.new(|cx| MultiWorkspace::new(workspace, cx))
+ }
+ })?;
+ let workspace =
+ window.update(cx, |multi_workspace: &mut MultiWorkspace, _, _cx| {
+ multi_workspace.workspace().clone()
+ })?;
+ (window, workspace)
+ };
notify_if_database_failed(window, cx);
// Check if this is an empty workspace (no paths to open)
@@ -1852,8 +1895,10 @@ impl Workspace {
.unwrap_or(false);
let opened_items = window
- .update(cx, |_workspace, window, cx| {
- open_items(serialized_workspace, project_paths, window, cx)
+ .update(cx, |_, window, cx| {
+ workspace.update(cx, |_workspace: &mut Workspace, cx| {
+ open_items(serialized_workspace, project_paths, window, cx)
+ })
})?
.await
.unwrap_or_default();
@@ -1865,29 +1910,30 @@ impl Workspace {
if is_empty_workspace && !serialized_workspace_has_paths {
if let Some(default_docks) = persistence::read_default_dock_state() {
window
- .update(cx, |workspace, window, cx| {
- for (dock, serialized_dock) in [
- (&mut workspace.right_dock, default_docks.right),
- (&mut workspace.left_dock, default_docks.left),
- (&mut workspace.bottom_dock, default_docks.bottom),
- ]
- .iter_mut()
- {
- dock.update(cx, |dock, cx| {
- dock.serialized_dock = Some(serialized_dock.clone());
- dock.restore_state(window, cx);
- });
- }
- cx.notify();
+ .update(cx, |_, window, cx| {
+ workspace.update(cx, |workspace, cx| {
+ for (dock, serialized_dock) in [
+ (&workspace.right_dock, &default_docks.right),
+ (&workspace.left_dock, &default_docks.left),
+ (&workspace.bottom_dock, &default_docks.bottom),
+ ] {
+ dock.update(cx, |dock, cx| {
+ dock.serialized_dock = Some(serialized_dock.clone());
+ dock.restore_state(window, cx);
+ });
+ }
+ cx.notify();
+ });
})
.log_err();
}
}
window
- .update(cx, |workspace, window, cx| {
- window.activate_window();
- workspace.update_history(cx);
+ .update(cx, |_, _window, cx| {
+ workspace.update(cx, |this: &mut Workspace, cx| {
+ this.update_history(cx);
+ });
})
.log_err();
Ok((window, opened_items))
@@ -2493,8 +2539,11 @@ impl Workspace {
let env = self.project.read(cx).cli_environment(cx);
let task = Self::new_local(Vec::new(), self.app_state.clone(), None, env, None, cx);
cx.spawn_in(window, async move |_vh, cx| {
- let (workspace, _) = task.await?;
- workspace.update(cx, callback)
+ let (multi_workspace_window, _) = task.await?;
+ multi_workspace_window.update(cx, |multi_workspace, window, cx| {
+ let workspace = multi_workspace.workspace().clone();
+ workspace.update(cx, |workspace, cx| callback(workspace, window, cx))
+ })
})
}
}
@@ -2520,8 +2569,11 @@ impl Workspace {
let env = self.project.read(cx).cli_environment(cx);
let task = Self::new_local(Vec::new(), self.app_state.clone(), None, env, None, cx);
cx.spawn_in(window, async move |_vh, cx| {
- let (workspace, _) = task.await?;
- workspace.update(cx, callback)
+ let (multi_workspace_window, _) = task.await?;
+ multi_workspace_window.update(cx, |multi_workspace, window, cx| {
+ let workspace = multi_workspace.workspace().clone();
+ workspace.update(cx, |workspace, cx| callback(workspace, window, cx))
+ })
})
}
}
@@ -2623,7 +2675,7 @@ impl Workspace {
let workspace_count = cx.update(|_window, cx| {
cx.windows()
.iter()
- .filter(|window| window.downcast::<Workspace>().is_some())
+ .filter(|window| window.downcast::<MultiWorkspace>().is_some())
.count()
})?;
@@ -2636,10 +2688,12 @@ impl Workspace {
let remaining_workspaces = cx.update(|_window, cx| {
cx.windows()
.iter()
- .filter_map(|window| window.downcast::<Workspace>())
- .filter_map(|workspace| {
- workspace
- .update(cx, |workspace, _, _| workspace.removing)
+ .filter_map(|window| window.downcast::<MultiWorkspace>())
+ .filter_map(|multi_workspace| {
+ multi_workspace
+ .update(cx, |multi_workspace, _, cx| {
+ multi_workspace.workspace().read(cx).removing
+ })
.ok()
})
.filter(|removing| !removing)
@@ -2675,13 +2729,18 @@ impl Workspace {
}
if close_intent == CloseIntent::ReplaceWindow {
_ = active_call.update(cx, |this, cx| {
- let workspace = cx
+ let multi_workspace = cx
.windows()
.iter()
- .filter_map(|window| window.downcast::<Workspace>())
+ .filter_map(|window| window.downcast::<MultiWorkspace>())
.next()
.unwrap();
- let project = workspace.read(cx)?.project.clone();
+ let project = multi_workspace
+ .read(cx)?
+ .workspace()
+ .read(cx)
+ .project
+ .clone();
if project.read(cx).is_shared() {
this.unshare_project(project, cx)?;
}
@@ -2889,7 +2948,7 @@ impl Workspace {
window: &mut Window,
cx: &mut Context<Self>,
) -> Task<Result<()>> {
- let window_handle = window.window_handle().downcast::<Self>();
+ let window_handle = window.window_handle().downcast::<MultiWorkspace>();
let is_remote = self.project.read(cx).is_via_collab();
let has_worktree = self.project.read(cx).worktrees(cx).next().is_some();
let has_dirty_items = self.items(cx).any(|item| item.is_dirty(cx));
@@ -5074,21 +5133,27 @@ impl Workspace {
self.update_window_edited(window, cx);
return;
}
- if let Some(window_handle) = window.window_handle().downcast::<Self>() {
- let s = item.on_release(
- cx,
- Box::new(move |cx| {
- window_handle
- .update(cx, |this, window, cx| {
- this.dirty_items.remove(&item_id);
- this.update_window_edited(window, cx)
+
+ let workspace = self.weak_handle();
+ let Some(window_handle) = window.window_handle().downcast::<MultiWorkspace>() else {
+ return;
+ };
+ let on_release_callback = Box::new(move |cx: &mut App| {
+ window_handle
+ .update(cx, |_, window, cx| {
+ workspace
+ .update(cx, |workspace, cx| {
+ workspace.dirty_items.remove(&item_id);
+ workspace.update_window_edited(window, cx)
})
.ok();
- }),
- );
- self.dirty_items.insert(item_id, s);
- self.update_window_edited(window, cx);
- }
+ })
+ .ok();
+ });
+
+ let s = item.on_release(cx, on_release_callback);
+ self.dirty_items.insert(item_id, s);
+ self.update_window_edited(window, cx);
}
fn render_notifications(&self, _window: &mut Window, _cx: &mut Context<Self>) -> Option<Div> {
@@ -7042,27 +7107,30 @@ enum ActivateInDirectionTarget {
Dock(Entity<Dock>),
}
-fn notify_if_database_failed(workspace: WindowHandle<Workspace>, cx: &mut AsyncApp) {
- workspace
- .update(cx, |workspace, _, cx| {
- if (*db::ALL_FILE_DB_FAILED).load(std::sync::atomic::Ordering::Acquire) {
- struct DatabaseFailedNotification;
-
- workspace.show_notification(
- NotificationId::unique::<DatabaseFailedNotification>(),
- cx,
- |cx| {
- cx.new(|cx| {
- MessageNotification::new("Failed to load the database file.", cx)
- .primary_message("File an Issue")
- .primary_icon(IconName::Plus)
- .primary_on_click(|window, cx| {
- window.dispatch_action(Box::new(FileBugReport), cx)
- })
- })
- },
- );
- }
+fn notify_if_database_failed(window: WindowHandle<MultiWorkspace>, cx: &mut AsyncApp) {
+ window
+ .update(cx, |multi_workspace, _, cx| {
+ let workspace = multi_workspace.workspace().clone();
+ workspace.update(cx, |workspace, cx| {
+ if (*db::ALL_FILE_DB_FAILED).load(std::sync::atomic::Ordering::Acquire) {
+ struct DatabaseFailedNotification;
+
+ workspace.show_notification(
+ NotificationId::unique::<DatabaseFailedNotification>(),
+ cx,
+ |cx| {
+ cx.new(|cx| {
+ MessageNotification::new("Failed to load the database file.", cx)
+ .primary_message("File an Issue")
+ .primary_icon(IconName::Plus)
+ .primary_on_click(|window, cx| {
+ window.dispatch_action(Box::new(FileBugReport), cx)
+ })
+ })
+ },
+ );
+ }
+ });
})
.log_err();
}
@@ -7216,15 +7284,14 @@ impl Render for Workspace {
.collect::<Vec<_>>();
let bottom_dock_layout = WorkspaceSettings::get_global(cx).bottom_dock_layout;
- client_side_decorations(
- self.actions(div(), window, cx)
- .key_context(context)
- .relative()
- .size_full()
- .flex()
- .flex_col()
- .font(ui_font)
- .gap_0()
+ self.actions(div(), window, cx)
+ .key_context(context)
+ .relative()
+ .size_full()
+ .flex()
+ .flex_col()
+ .font(ui_font)
+ .gap_0()
.justify_start()
.items_start()
.text_color(colors.text)
@@ -7707,10 +7774,7 @@ impl Render for Workspace {
})
.child(self.modal_layer.clone())
.child(self.toast_layer.clone()),
- ),
- window,
- cx,
- )
+ )
}
}
@@ -7755,16 +7819,22 @@ impl WorkspaceStore {
};
let mut response = proto::FollowResponse::default();
- this.workspaces.retain(|workspace| {
- workspace
- .update(cx, |workspace, window, cx| {
- let handler_response =
- workspace.handle_follow(follower.project_id, window, cx);
- if let Some(active_view) = handler_response.active_view
- && workspace.project.read(cx).remote_id() == follower.project_id
- {
- response.active_view = Some(active_view)
- }
+
+ this.workspaces.retain(|(window_handle, weak_workspace)| {
+ let Some(workspace) = weak_workspace.upgrade() else {
+ return false;
+ };
+ window_handle
+ .update(cx, |_, window, cx| {
+ workspace.update(cx, |workspace, cx| {
+ let handler_response =
+ workspace.handle_follow(follower.project_id, window, cx);
+ if let Some(active_view) = handler_response.active_view
+ && workspace.project.read(cx).remote_id() == follower.project_id
+ {
+ response.active_view = Some(active_view)
+ }
+ });
})
.is_ok()
});
@@ -7782,14 +7852,24 @@ impl WorkspaceStore {
let update = envelope.payload;
this.update(&mut cx, |this, cx| {
- this.workspaces.retain(|workspace| {
- workspace
- .update(cx, |workspace, window, cx| {
- let project_id = workspace.project.read(cx).remote_id();
- if update.project_id != project_id && update.project_id.is_some() {
- return;
- }
- workspace.handle_update_followers(leader_id, update.clone(), window, cx);
+ this.workspaces.retain(|(window_handle, weak_workspace)| {
+ let Some(workspace) = weak_workspace.upgrade() else {
+ return false;
+ };
+ window_handle
+ .update(cx, |_, window, cx| {
+ workspace.update(cx, |workspace, cx| {
+ let project_id = workspace.project.read(cx).remote_id();
+ if update.project_id != project_id && update.project_id.is_some() {
+ return;
+ }
+ workspace.handle_update_followers(
+ leader_id,
+ update.clone(),
+ window,
+ cx,
+ );
+ });
})
.is_ok()
});
@@ -7797,8 +7877,14 @@ impl WorkspaceStore {
})
}
- pub fn workspaces(&self) -> &HashSet<WindowHandle<Workspace>> {
- &self.workspaces
+ pub fn workspaces(&self) -> impl Iterator<Item = &WeakEntity<Workspace>> {
+ self.workspaces.iter().map(|(_, weak)| weak)
+ }
+
+ pub fn workspaces_with_windows(
+ &self,
+ ) -> impl Iterator<Item = (gpui::AnyWindowHandle, &WeakEntity<Workspace>)> {
+ self.workspaces.iter().map(|(window, weak)| (*window, weak))
}
}
@@ -7850,19 +7936,119 @@ impl WorkspaceHandle for Entity<Workspace> {
}
}
-pub async fn last_opened_workspace_location()
--> Option<(WorkspaceId, SerializedWorkspaceLocation, PathList)> {
- DB.last_workspace().await.log_err().flatten()
+pub async fn last_opened_workspace_location(
+ fs: &dyn fs::Fs,
+) -> Option<(WorkspaceId, SerializedWorkspaceLocation, PathList)> {
+ DB.last_workspace(fs).await.log_err().flatten()
}
-pub fn last_session_workspace_locations(
+pub async fn last_session_workspace_locations(
last_session_id: &str,
last_session_window_stack: Option<Vec<WindowId>>,
-) -> Option<Vec<(WorkspaceId, SerializedWorkspaceLocation, PathList)>> {
- DB.last_session_workspace_locations(last_session_id, last_session_window_stack)
+ fs: &dyn fs::Fs,
+) -> Option<Vec<SessionWorkspace>> {
+ DB.last_session_workspace_locations(last_session_id, last_session_window_stack, fs)
+ .await
.log_err()
}
+pub async fn restore_multiworkspace(
+ multi_workspace: SerializedMultiWorkspace,
+ app_state: Arc<AppState>,
+ cx: &mut AsyncApp,
+) -> anyhow::Result<WindowHandle<MultiWorkspace>> {
+ let SerializedMultiWorkspace { workspaces, state } = multi_workspace;
+ let mut group_iter = workspaces.into_iter();
+ let first = group_iter
+ .next()
+ .context("window group must not be empty")?;
+
+ let window_handle = if first.paths.is_empty() {
+ cx.update(|cx| open_workspace_by_id(first.workspace_id, app_state.clone(), None, cx))
+ .await?
+ } else {
+ let (window, _items) = cx
+ .update(|cx| {
+ Workspace::new_local(
+ first.paths.paths().to_vec(),
+ app_state.clone(),
+ None,
+ None,
+ None,
+ cx,
+ )
+ })
+ .await?;
+ window
+ };
+
+ for session_workspace in group_iter {
+ if session_workspace.paths.is_empty() {
+ cx.update(|cx| {
+ open_workspace_by_id(
+ session_workspace.workspace_id,
+ app_state.clone(),
+ Some(window_handle),
+ cx,
+ )
+ })
+ .await?;
+ } else {
+ cx.update(|cx| {
+ Workspace::new_local(
+ session_workspace.paths.paths().to_vec(),
+ app_state.clone(),
+ Some(window_handle),
+ None,
+ None,
+ cx,
+ )
+ })
+ .await?;
+ }
+ }
+
+ if let Some(target_id) = state.active_workspace_id {
+ window_handle
+ .update(cx, |multi_workspace, window, cx| {
+ let target_index = multi_workspace
+ .workspaces()
+ .iter()
+ .position(|ws| ws.read(cx).database_id() == Some(target_id));
+ if let Some(index) = target_index {
+ multi_workspace.activate_index(index, window, cx);
+ } else if !multi_workspace.workspaces().is_empty() {
+ multi_workspace.activate_index(0, window, cx);
+ }
+ })
+ .ok();
+ } else {
+ window_handle
+ .update(cx, |multi_workspace, window, cx| {
+ if !multi_workspace.workspaces().is_empty() {
+ multi_workspace.activate_index(0, window, cx);
+ }
+ })
+ .ok();
+ }
+
+ if state.sidebar_open {
+ window_handle
+ .update(cx, |multi_workspace, window, cx| {
+ multi_workspace.open_sidebar(window, cx);
+ })
+ .ok();
+ }
+
+ window_handle
+ .update(cx, |_, window, _cx| {
+ window.activate_window();
+ })
+ .ok();
+
+ Ok(window_handle)
+}
+
actions!(
collab,
[
@@ -7902,7 +8088,8 @@ actions!(
async fn join_channel_internal(
channel_id: ChannelId,
app_state: &Arc<AppState>,
- requesting_window: Option<WindowHandle<Workspace>>,
+ requesting_window: Option<WindowHandle<MultiWorkspace>>,
+ requesting_workspace: Option<WeakEntity<Workspace>>,
active_call: &Entity<ActiveCall>,
cx: &mut AsyncApp,
) -> Result<bool> {
@@ -7938,8 +8125,8 @@ async fn join_channel_internal(
}
if should_prompt {
- if let Some(workspace) = requesting_window {
- let answer = workspace
+ if let Some(multi_workspace) = requesting_window {
+ let answer = multi_workspace
.update(cx, |_, window, cx| {
window.prompt(
PromptLevel::Warning,
@@ -8008,9 +8195,9 @@ async fn join_channel_internal(
// If you are the first to join a channel, see if you should share your project.
if room.remote_participants().is_empty()
&& !room.local_participant_is_guest()
- && let Some(workspace) = requesting_window
+ && let Some(workspace) = requesting_workspace.as_ref().and_then(|w| w.upgrade())
{
- let project = workspace.update(cx, |workspace, _, cx| {
+ let project = workspace.update(cx, |workspace, cx| {
let project = workspace.project.read(cx);
if !CallSettings::get_global(cx).share_on_join {
@@ -8029,7 +8216,7 @@ async fn join_channel_internal(
None
}
});
- if let Ok(Some(project)) = project {
+ if let Some(project) = project {
return Some(cx.spawn(async move |room, cx| {
room.update(cx, |room, cx| room.share_project(project, cx))?
.await?;
@@ -8050,14 +8237,21 @@ async fn join_channel_internal(
pub fn join_channel(
channel_id: ChannelId,
app_state: Arc<AppState>,
- requesting_window: Option<WindowHandle<Workspace>>,
+ requesting_window: Option<WindowHandle<MultiWorkspace>>,
+ requesting_workspace: Option<WeakEntity<Workspace>>,
cx: &mut App,
) -> Task<Result<()>> {
let active_call = ActiveCall::global(cx);
cx.spawn(async move |cx| {
- let result =
- join_channel_internal(channel_id, &app_state, requesting_window, &active_call, cx)
- .await;
+ let result = join_channel_internal(
+ channel_id,
+ &app_state,
+ requesting_window,
+ requesting_workspace,
+ &active_call,
+ cx,
+ )
+ .await;
// join channel succeeded, and opened a window
if matches!(result, Ok(true)) {
@@ -8081,6 +8275,12 @@ pub fn join_channel(
})
.await?;
+ window_handle
+ .update(cx, |_, window, _cx| {
+ window.activate_window();
+ })
+ .ok();
+
if result.is_ok() {
cx.update(|cx| {
cx.dispatch_action(&OpenChannelNotes);
@@ -8135,10 +8335,10 @@ pub fn join_channel(
})
}
-pub async fn get_any_active_workspace(
+pub async fn get_any_active_multi_workspace(
app_state: Arc<AppState>,
mut cx: AsyncApp,
-) -> anyhow::Result<WindowHandle<Workspace>> {
+) -> anyhow::Result<WindowHandle<MultiWorkspace>> {
// find an existing workspace to focus and show call controls
let active_window = activate_any_workspace_window(&mut cx);
if active_window.is_none() {
@@ -8148,17 +8348,17 @@ pub async fn get_any_active_workspace(
activate_any_workspace_window(&mut cx).context("could not open zed")
}
-fn activate_any_workspace_window(cx: &mut AsyncApp) -> Option<WindowHandle<Workspace>> {
+fn activate_any_workspace_window(cx: &mut AsyncApp) -> Option<WindowHandle<MultiWorkspace>> {
cx.update(|cx| {
if let Some(workspace_window) = cx
.active_window()
- .and_then(|window| window.downcast::<Workspace>())
+ .and_then(|window| window.downcast::<MultiWorkspace>())
{
return Some(workspace_window);
}
for window in cx.windows() {
- if let Some(workspace_window) = window.downcast::<Workspace>() {
+ if let Some(workspace_window) = window.downcast::<MultiWorkspace>() {
workspace_window
.update(cx, |_, window, _| window.activate_window())
.ok();
@@ -8169,14 +8369,17 @@ fn activate_any_workspace_window(cx: &mut AsyncApp) -> Option<WindowHandle<Works
})
}
-pub fn local_workspace_windows(cx: &App) -> Vec<WindowHandle<Workspace>> {
+pub fn local_workspace_windows(cx: &App) -> Vec<WindowHandle<MultiWorkspace>> {
cx.windows()
.into_iter()
- .filter_map(|window| window.downcast::<Workspace>())
- .filter(|workspace| {
- workspace
- .read(cx)
- .is_ok_and(|workspace| workspace.project.read(cx).is_local())
+ .filter_map(|window| window.downcast::<MultiWorkspace>())
+ .filter(|multi_workspace| {
+ multi_workspace.read(cx).is_ok_and(|multi_workspace| {
+ multi_workspace
+ .workspaces()
+ .iter()
+ .any(|workspace| workspace.read(cx).project.read(cx).is_local())
+ })
})
.collect()
}
@@ -8187,7 +8390,7 @@ pub struct OpenOptions {
pub focus: Option<bool>,
pub open_new_workspace: Option<bool>,
pub prefer_focused_window: bool,
- pub replace_window: Option<WindowHandle<Workspace>>,
+ pub replace_window: Option<WindowHandle<MultiWorkspace>>,
pub env: Option<HashMap<String, String>>,
}
@@ -8195,8 +8398,9 @@ pub struct OpenOptions {
pub fn open_workspace_by_id(
workspace_id: WorkspaceId,
app_state: Arc<AppState>,
+ requesting_window: Option<WindowHandle<MultiWorkspace>>,
cx: &mut App,
-) -> Task<anyhow::Result<WindowHandle<Workspace>>> {
+) -> Task<anyhow::Result<WindowHandle<MultiWorkspace>>> {
let project_handle = Project::local(
app_state.client.clone(),
app_state.node_runtime.clone(),
@@ -8216,52 +8420,87 @@ pub fn open_workspace_by_id(
.workspace_for_id(workspace_id)
.with_context(|| format!("Workspace {workspace_id:?} not found"))?;
- let window_bounds_override = window_bounds_env_override();
-
- let (window_bounds, display) = if let Some(bounds) = window_bounds_override {
- (Some(WindowBounds::Windowed(bounds)), None)
- } else if let Some(display) = serialized_workspace.display
- && let Some(bounds) = serialized_workspace.window_bounds.as_ref()
- {
- (Some(bounds.0), Some(display))
- } else if let Some((display, bounds)) = persistence::read_default_window_bounds() {
- (Some(bounds), Some(display))
- } else {
- (None, None)
- };
-
- let options = cx.update(|cx| {
- let mut options = (app_state.build_window_options)(display, cx);
- options.window_bounds = window_bounds;
- options
- });
let centered_layout = serialized_workspace.centered_layout;
- let window = cx.open_window(options, {
- let app_state = app_state.clone();
- let project_handle = project_handle.clone();
- move |window, cx| {
- cx.new(|cx| {
- let mut workspace =
- Workspace::new(Some(workspace_id), project_handle, app_state, window, cx);
+ let (window, workspace) = if let Some(window) = requesting_window {
+ let workspace = window.update(cx, |multi_workspace, window, cx| {
+ let workspace = cx.new(|cx| {
+ let mut workspace = Workspace::new(
+ Some(workspace_id),
+ project_handle.clone(),
+ app_state.clone(),
+ window,
+ cx,
+ );
workspace.centered_layout = centered_layout;
workspace
- })
- }
- })?;
+ });
+ multi_workspace.add_workspace(workspace.clone(), cx);
+ workspace
+ })?;
+ (window, workspace)
+ } else {
+ let window_bounds_override = window_bounds_env_override();
+
+ let (window_bounds, display) = if let Some(bounds) = window_bounds_override {
+ (Some(WindowBounds::Windowed(bounds)), None)
+ } else if let Some(display) = serialized_workspace.display
+ && let Some(bounds) = serialized_workspace.window_bounds.as_ref()
+ {
+ (Some(bounds.0), Some(display))
+ } else if let Some((display, bounds)) = persistence::read_default_window_bounds() {
+ (Some(bounds), Some(display))
+ } else {
+ (None, None)
+ };
+
+ let options = cx.update(|cx| {
+ let mut options = (app_state.build_window_options)(display, cx);
+ options.window_bounds = window_bounds;
+ options
+ });
+
+ let window = cx.open_window(options, {
+ let app_state = app_state.clone();
+ let project_handle = project_handle.clone();
+ move |window, cx| {
+ let workspace = cx.new(|cx| {
+ let mut workspace = Workspace::new(
+ Some(workspace_id),
+ project_handle,
+ app_state,
+ window,
+ cx,
+ );
+ workspace.centered_layout = centered_layout;
+ workspace
+ });
+ cx.new(|cx| MultiWorkspace::new(workspace, cx))
+ }
+ })?;
+
+ let workspace = window.update(cx, |multi_workspace: &mut MultiWorkspace, _, _cx| {
+ multi_workspace.workspace().clone()
+ })?;
+
+ (window, workspace)
+ };
notify_if_database_failed(window, cx);
// Restore items from the serialized workspace
window
- .update(cx, |_workspace, window, cx| {
- open_items(Some(serialized_workspace), vec![], window, cx)
+ .update(cx, |_, window, cx| {
+ workspace.update(cx, |_workspace, cx| {
+ open_items(Some(serialized_workspace), vec![], window, cx)
+ })
})?
.await?;
- window.update(cx, |workspace, window, cx| {
- window.activate_window();
- workspace.serialize_workspace(window, cx);
+ window.update(cx, |_, window, cx| {
+ workspace.update(cx, |workspace, cx| {
+ workspace.serialize_workspace(window, cx);
+ });
})?;
Ok(window)
@@ -49,6 +49,7 @@ visual-tests = [
"language_model/test-support",
"fs/test-support",
"recent_projects/test-support",
+ "sidebar/test-support",
"title_bar/test-support",
]
@@ -187,6 +188,7 @@ settings.workspace = true
settings_profile_selector.workspace = true
settings_ui.workspace = true
shellexpand.workspace = true
+sidebar.workspace = true
smol.workspace = true
snippet_provider.workspace = true
snippets_ui.workspace = true
@@ -54,8 +54,8 @@ use theme::{ActiveTheme, GlobalTheme, ThemeRegistry};
use util::{ResultExt, TryFutureExt, maybe};
use uuid::Uuid;
use workspace::{
- AppState, PathList, SerializedWorkspaceLocation, Toast, Workspace, WorkspaceId,
- WorkspaceSettings, WorkspaceStore, notifications::NotificationId,
+ AppState, MultiWorkspace, SerializedWorkspaceLocation, SessionWorkspace, Toast,
+ WorkspaceSettings, WorkspaceStore, notifications::NotificationId, restore_multiworkspace,
};
use zed::{
OpenListener, OpenRequest, RawOpenRequest, app_menus, build_window_options,
@@ -511,15 +511,13 @@ fn main() {
let workspace_store = workspace_store.clone();
Arc::new(move |cx: &mut App| {
workspace_store.update(cx, |workspace_store, cx| {
- workspace_store
+ Ok(workspace_store
.workspaces()
- .iter()
- .map(|workspace| {
- workspace.update(cx, |workspace, _, cx| {
- workspace.project().read(cx).lsp_store()
- })
+ .filter_map(|weak| weak.upgrade())
+ .map(|workspace: gpui::Entity<workspace::Workspace>| {
+ workspace.read(cx).project().read(cx).lsp_store()
})
- .collect()
+ .collect())
})
})
}),
@@ -849,7 +847,7 @@ fn handle_open_request(request: OpenRequest, app_state: Arc<AppState>, cx: &mut
OpenRequestKind::Extension { extension_id } => {
cx.spawn(async move |cx| {
let workspace =
- workspace::get_any_active_workspace(app_state, cx.clone()).await?;
+ workspace::get_any_active_multi_workspace(app_state, cx.clone()).await?;
workspace.update(cx, |_, window, cx| {
window.dispatch_action(
Box::new(zed_actions::Extensions {
@@ -864,31 +862,40 @@ fn handle_open_request(request: OpenRequest, app_state: Arc<AppState>, cx: &mut
}
OpenRequestKind::AgentPanel { initial_prompt } => {
cx.spawn(async move |cx| {
- let workspace =
- workspace::get_any_active_workspace(app_state, cx.clone()).await?;
- workspace.update(cx, |workspace, window, cx| {
- if let Some(panel) = workspace.focus_panel::<AgentPanel>(window, cx) {
- panel.update(cx, |panel, cx| {
- panel.new_external_thread_with_text(initial_prompt, window, cx);
- });
- }
+ let multi_workspace =
+ workspace::get_any_active_multi_workspace(app_state, cx.clone()).await?;
+
+ multi_workspace.update(cx, |multi_workspace, window, cx| {
+ multi_workspace.workspace().update(cx, |workspace, cx| {
+ if let Some(panel) = workspace.focus_panel::<AgentPanel>(window, cx) {
+ panel.update(cx, |panel, cx| {
+ panel.new_external_thread_with_text(initial_prompt, window, cx);
+ });
+ }
+ });
})
})
.detach_and_log_err(cx);
}
OpenRequestKind::SharedAgentThread { session_id } => {
cx.spawn(async move |cx| {
+ let multi_workspace =
+ workspace::get_any_active_multi_workspace(app_state.clone(), cx.clone())
+ .await?;
+
let workspace =
- workspace::get_any_active_workspace(app_state.clone(), cx.clone()).await?;
+ multi_workspace.read_with(cx, |mw, _| mw.workspace().clone())?;
let (client, thread_store) =
- workspace.update(cx, |workspace, _window, cx| {
- let client = workspace.project().read(cx).client();
- let thread_store: Option<gpui::Entity<ThreadStore>> = workspace
- .panel::<AgentPanel>(cx)
- .map(|panel| panel.read(cx).thread_store().clone());
- (client, thread_store)
- })?;
+ multi_workspace.update(cx, |_, _window, cx| {
+ workspace.update(cx, |workspace, cx| {
+ let client = workspace.project().read(cx).client();
+ let thread_store: Option<gpui::Entity<ThreadStore>> = workspace
+ .panel::<AgentPanel>(cx)
+ .map(|panel| panel.read(cx).thread_store().clone());
+ anyhow::Ok((client, thread_store))
+ })
+ })??;
let Some(thread_store): Option<gpui::Entity<ThreadStore>> = thread_store else {
anyhow::bail!("Agent panel not available");
@@ -921,25 +928,27 @@ fn handle_open_request(request: OpenRequest, app_state: Arc<AppState>, cx: &mut
meta: None,
};
- workspace.update(cx, |workspace, window, cx| {
- if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
- panel.update(cx, |panel, cx| {
- panel.open_thread(thread_metadata, window, cx);
- });
- panel.focus_handle(cx).focus(window, cx);
- }
- })?;
+ let sharer_username = response.sharer_username.clone();
- workspace.update(cx, |workspace, _window, cx| {
- struct ImportedThreadToast;
- workspace.show_toast(
- Toast::new(
- NotificationId::unique::<ImportedThreadToast>(),
- format!("Imported shared thread from {}", response.sharer_username),
- )
- .autohide(),
- cx,
- );
+ multi_workspace.update(cx, |_, window, cx| {
+ workspace.update(cx, |workspace, cx| {
+ if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
+ panel.update(cx, |panel, cx| {
+ panel.open_thread(thread_metadata, window, cx);
+ });
+ panel.focus_handle(cx).focus(window, cx);
+ }
+
+ struct ImportedThreadToast;
+ workspace.show_toast(
+ Toast::new(
+ NotificationId::unique::<ImportedThreadToast>(),
+ format!("Imported shared thread from {}", sharer_username),
+ )
+ .autohide(),
+ cx,
+ );
+ });
})?;
anyhow::Ok(())
@@ -1014,7 +1023,7 @@ fn handle_open_request(request: OpenRequest, app_state: Arc<AppState>, cx: &mut
// [ languages $(language) tab_size]
cx.spawn(async move |cx| {
let workspace =
- workspace::get_any_active_workspace(app_state, cx.clone()).await?;
+ workspace::get_any_active_multi_workspace(app_state, cx.clone()).await?;
workspace.update(cx, |_, window, cx| match setting_path {
None => window.dispatch_action(Box::new(zed_actions::OpenSettings), cx),
@@ -1076,23 +1085,29 @@ fn handle_open_request(request: OpenRequest, app_state: Arc<AppState>, cx: &mut
.await?;
workspace
- .update(cx, |workspace, window, cx| {
- let Some(repo) = workspace.project().read(cx).active_repository(cx)
- else {
- log::error!("no active repository found for commit view");
- return Err(anyhow::anyhow!("no active repository found"));
- };
-
- git_ui::commit_view::CommitView::open(
- sha,
- repo.downgrade(),
- workspace.weak_handle(),
- None,
- None,
- window,
- cx,
- );
- Ok(())
+ .update(cx, |multi_workspace, window, cx| {
+ multi_workspace
+ .workspace()
+ .clone()
+ .update(cx, |workspace, cx| {
+ let Some(repo) =
+ workspace.project().read(cx).active_repository(cx)
+ else {
+ log::error!("no active repository found for commit view");
+ return Err(anyhow::anyhow!("no active repository found"));
+ };
+
+ git_ui::commit_view::CommitView::open(
+ sha,
+ repo.downgrade(),
+ workspace.weak_handle(),
+ None,
+ None,
+ window,
+ cx,
+ );
+ Ok(())
+ })
})
.log_err();
@@ -1162,6 +1177,7 @@ fn handle_open_request(request: OpenRequest, app_state: Arc<AppState>, cx: &mut
client::ChannelId(channel_id),
app_state.clone(),
None,
+ None,
cx,
)
})
@@ -1169,8 +1185,9 @@ fn handle_open_request(request: OpenRequest, app_state: Arc<AppState>, cx: &mut
}
let workspace_window =
- workspace::get_any_active_workspace(app_state, cx.clone()).await?;
- let workspace = workspace_window.entity(cx)?;
+ workspace::get_any_active_multi_workspace(app_state, cx.clone()).await?;
+
+ let workspace = workspace_window.read_with(cx, |mw, _| mw.workspace().clone())?;
let mut promises = Vec::new();
for (channel_id, heading) in request.open_channel_notes {
@@ -1260,78 +1277,53 @@ async fn installation_id() -> Result<IdType> {
Ok(IdType::New(installation_id))
}
-async fn restore_or_create_workspace(app_state: Arc<AppState>, cx: &mut AsyncApp) -> Result<()> {
- if let Some(locations) = restorable_workspace_locations(cx, &app_state).await {
- let use_system_window_tabs =
- cx.update(|cx| WorkspaceSettings::get_global(cx).use_system_window_tabs);
+pub(crate) async fn restore_or_create_workspace(
+ app_state: Arc<AppState>,
+ cx: &mut AsyncApp,
+) -> Result<()> {
+ if let Some((multi_workspaces, remote_workspaces)) = restorable_workspaces(cx, &app_state).await
+ {
let mut results: Vec<Result<(), Error>> = Vec::new();
let mut tasks = Vec::new();
- for (index, (workspace_id, location, paths)) in locations.into_iter().enumerate() {
- match location {
- SerializedWorkspaceLocation::Local if paths.is_empty() => {
- // Restore empty workspace by ID (has items like drafts but no folders)
- let app_state = app_state.clone();
- let task = cx.spawn(async move |cx| {
- let open_task = cx.update(|cx| {
- workspace::open_workspace_by_id(workspace_id, app_state, cx)
- });
- open_task.await.map(|_| ())
- });
+ let mut local_results = Vec::new();
+ for multi_workspace in multi_workspaces {
+ local_results
+ .push(restore_multiworkspace(multi_workspace, app_state.clone(), cx).await);
+ }
- if use_system_window_tabs && index == 0 {
- results.push(task.await);
- } else {
- tasks.push(task);
- }
- }
- SerializedWorkspaceLocation::Local => {
- let app_state = app_state.clone();
- let task = cx.spawn(async move |cx| {
- let open_task = cx.update(|cx| {
- workspace::open_paths(
- &paths.paths(),
- app_state,
- workspace::OpenOptions::default(),
- cx,
- )
- });
- open_task.await.map(|_| ())
- });
-
- // If we're using system window tabs and this is the first workspace,
- // wait for it to finish so that the other windows can be added as tabs.
- if use_system_window_tabs && index == 0 {
- results.push(task.await);
- } else {
- tasks.push(task);
- }
- }
- SerializedWorkspaceLocation::Remote(mut connection_options) => {
- let app_state = app_state.clone();
- if let RemoteConnectionOptions::Ssh(options) = &mut connection_options {
- cx.update(|cx| {
- RemoteSettings::get_global(cx)
- .fill_connection_options_from_settings(options)
- });
- }
- let task = cx.spawn(async move |cx| {
- recent_projects::open_remote_project(
- connection_options,
- paths.paths().into_iter().map(PathBuf::from).collect(),
- app_state,
- workspace::OpenOptions::default(),
- cx,
- )
- .await
- .map_err(|e| anyhow::anyhow!(e))
- });
- tasks.push(task);
- }
+ for result in local_results {
+ results.push(result.map(|_| ()));
+ }
+
+ for session_workspace in remote_workspaces {
+ let app_state = app_state.clone();
+ let SerializedWorkspaceLocation::Remote(mut connection_options) =
+ session_workspace.location
+ else {
+ continue;
+ };
+ let paths = session_workspace.paths;
+ if let RemoteConnectionOptions::Ssh(options) = &mut connection_options {
+ cx.update(|cx| {
+ RemoteSettings::get_global(cx).fill_connection_options_from_settings(options)
+ });
}
+ let task = cx.spawn(async move |cx| {
+ recent_projects::open_remote_project(
+ connection_options,
+ paths.paths().iter().map(PathBuf::from).collect(),
+ app_state,
+ workspace::OpenOptions::default(),
+ cx,
+ )
+ .await
+ .map_err(|e| anyhow::anyhow!(e))
+ });
+ tasks.push(task);
}
- // Wait for all workspaces to open concurrently
+ // Wait for all window groups and remote workspaces to open concurrently
results.extend(future::join_all(tasks).await);
// Show notifications for any errors that occurred
@@ -1356,12 +1348,16 @@ async fn restore_or_create_workspace(app_state: Arc<AppState>, cx: &mut AsyncApp
// Try to find an active workspace to show the toast
let toast_shown = cx.update(|cx| {
if let Some(window) = cx.active_window()
- && let Some(workspace) = window.downcast::<Workspace>()
+ && let Some(multi_workspace) = window.downcast::<MultiWorkspace>()
{
- workspace
- .update(cx, |workspace, _, cx| {
- workspace
- .show_toast(Toast::new(NotificationId::unique::<()>(), message), cx)
+ multi_workspace
+ .update(cx, |multi_workspace, _, cx| {
+ multi_workspace.workspace().update(cx, |workspace, cx| {
+ workspace.show_toast(
+ Toast::new(NotificationId::unique::<()>(), message),
+ cx,
+ )
+ });
})
.ok();
return true;
@@ -1402,10 +1398,25 @@ async fn restore_or_create_workspace(app_state: Arc<AppState>, cx: &mut AsyncApp
Ok(())
}
+async fn restorable_workspaces(
+ cx: &mut AsyncApp,
+ app_state: &Arc<AppState>,
+) -> Option<(
+ Vec<workspace::SerializedMultiWorkspace>,
+ Vec<SessionWorkspace>,
+)> {
+ let locations = restorable_workspace_locations(cx, app_state).await?;
+ let (remote_workspaces, local_workspaces) = locations
+ .into_iter()
+ .partition(|sw| matches!(sw.location, SerializedWorkspaceLocation::Remote(_)));
+ let multi_workspaces = workspace::read_serialized_multi_workspaces(local_workspaces);
+ Some((multi_workspaces, remote_workspaces))
+}
+
pub(crate) async fn restorable_workspace_locations(
cx: &mut AsyncApp,
app_state: &Arc<AppState>,
-) -> Option<Vec<(WorkspaceId, SerializedWorkspaceLocation, PathList)>> {
+) -> Option<Vec<SessionWorkspace>> {
let mut restore_behavior = cx.update(|cx| WorkspaceSettings::get(None, cx).restore_on_startup);
let session_handle = app_state.session.clone();
@@ -1429,9 +1440,16 @@ pub(crate) async fn restorable_workspace_locations(
match restore_behavior {
workspace::RestoreOnStartupBehavior::LastWorkspace => {
- workspace::last_opened_workspace_location()
+ workspace::last_opened_workspace_location(app_state.fs.as_ref())
.await
- .map(|location| vec![location])
+ .map(|(workspace_id, location, paths)| {
+ vec![SessionWorkspace {
+ workspace_id,
+ location,
+ paths,
+ window_id: None,
+ }]
+ })
}
workspace::RestoreOnStartupBehavior::LastSession => {
if let Some(last_session_id) = last_session_id {
@@ -1440,7 +1458,9 @@ pub(crate) async fn restorable_workspace_locations(
let mut locations = workspace::last_session_workspace_locations(
&last_session_id,
last_session_window_stack,
+ app_state.fs.as_ref(),
)
+ .await
.filter(|locations| !locations.is_empty());
// Since last_session_window_order returns the windows ordered front-to-back
@@ -59,6 +59,7 @@ use {
},
image::RgbaImage,
project_panel::ProjectPanel,
+ recent_projects::RecentProjectEntry,
settings::{NotifyWhenAgentWaiting, Settings as _},
settings_ui::SettingsWindow,
std::{
@@ -70,7 +71,7 @@ use {
},
util::ResultExt as _,
watch,
- workspace::{AppState, Workspace},
+ workspace::{AppState, MultiWorkspace, Workspace, WorkspaceId},
zed_actions::OpenSettingsAt,
};
@@ -435,7 +436,24 @@ fn run_visual_tests(project_path: PathBuf, update_baseline: bool) -> Result<()>
}
}
- // Run Test 3: Agent Thread View tests
+ // Run Test 3: Multi-workspace sidebar visual tests
+ println!("\n--- Test 3: multi_workspace_sidebar ---");
+ match run_multi_workspace_sidebar_visual_tests(app_state.clone(), &mut cx, update_baseline) {
+ Ok(TestResult::Passed) => {
+ println!("✓ multi_workspace_sidebar: PASSED");
+ passed += 1;
+ }
+ Ok(TestResult::BaselineUpdated(_)) => {
+ println!("✓ multi_workspace_sidebar: Baselines updated");
+ updated += 1;
+ }
+ Err(e) => {
+ eprintln!("✗ multi_workspace_sidebar: FAILED - {}", e);
+ failed += 1;
+ }
+ }
+
+ // Run Test 4: Agent Thread View tests
#[cfg(feature = "visual-tests")]
{
println!("\n--- Test 3: agent_thread_with_image (collapsed + expanded) ---");
@@ -2781,3 +2799,300 @@ fn run_tool_permissions_visual_tests(
// Return success - we're just capturing screenshots, not comparing baselines
Ok(TestResult::Passed)
}
+
+#[cfg(target_os = "macos")]
+fn run_multi_workspace_sidebar_visual_tests(
+ app_state: Arc<AppState>,
+ cx: &mut VisualTestAppContext,
+ update_baseline: bool,
+) -> Result<TestResult> {
+ // Create temporary directories to act as worktrees for active workspaces
+ let temp_dir = tempfile::tempdir()?;
+ let temp_path = temp_dir.keep();
+ let canonical_temp = temp_path.canonicalize()?;
+
+ let workspace1_dir = canonical_temp.join("private-test-remote");
+ let workspace2_dir = canonical_temp.join("zed");
+ std::fs::create_dir_all(&workspace1_dir)?;
+ std::fs::create_dir_all(&workspace2_dir)?;
+
+ // Create directories for recent projects (they must exist on disk for display)
+ let recent1_dir = canonical_temp.join("tiny-project");
+ let recent2_dir = canonical_temp.join("font-kit");
+ let recent3_dir = canonical_temp.join("ideas");
+ let recent4_dir = canonical_temp.join("tmp");
+ std::fs::create_dir_all(&recent1_dir)?;
+ std::fs::create_dir_all(&recent2_dir)?;
+ std::fs::create_dir_all(&recent3_dir)?;
+ std::fs::create_dir_all(&recent4_dir)?;
+
+ // Enable the agent-v2 feature flag so multi-workspace is active
+ cx.update(|cx| {
+ cx.update_flags(true, vec!["agent-v2".to_string()]);
+ });
+
+ // Create both projects upfront so we can build both workspaces during
+ // window creation, before the MultiWorkspace entity exists.
+ // This avoids a re-entrant read panic that occurs when Workspace::new
+ // tries to access the window root (MultiWorkspace) while it's being updated.
+ let project1 = cx.update(|cx| {
+ project::Project::local(
+ app_state.client.clone(),
+ app_state.node_runtime.clone(),
+ app_state.user_store.clone(),
+ app_state.languages.clone(),
+ app_state.fs.clone(),
+ None,
+ project::LocalProjectFlags {
+ init_worktree_trust: false,
+ ..Default::default()
+ },
+ cx,
+ )
+ });
+
+ let project2 = cx.update(|cx| {
+ project::Project::local(
+ app_state.client.clone(),
+ app_state.node_runtime.clone(),
+ app_state.user_store.clone(),
+ app_state.languages.clone(),
+ app_state.fs.clone(),
+ None,
+ project::LocalProjectFlags {
+ init_worktree_trust: false,
+ ..Default::default()
+ },
+ cx,
+ )
+ });
+
+ let window_size = size(px(1280.0), px(800.0));
+ let bounds = Bounds {
+ origin: point(px(0.0), px(0.0)),
+ size: window_size,
+ };
+
+ // Open a MultiWorkspace window with both workspaces created at construction time
+ let multi_workspace_window: WindowHandle<MultiWorkspace> = cx
+ .update(|cx| {
+ cx.open_window(
+ WindowOptions {
+ window_bounds: Some(WindowBounds::Windowed(bounds)),
+ focus: false,
+ show: false,
+ ..Default::default()
+ },
+ |window, cx| {
+ let workspace1 = cx.new(|cx| {
+ Workspace::new(None, project1.clone(), app_state.clone(), window, cx)
+ });
+ let workspace2 = cx.new(|cx| {
+ Workspace::new(None, project2.clone(), app_state.clone(), window, cx)
+ });
+ cx.new(|cx| {
+ let mut multi_workspace = MultiWorkspace::new(workspace1, cx);
+ multi_workspace.activate(workspace2, cx);
+ multi_workspace
+ })
+ },
+ )
+ })
+ .context("Failed to open MultiWorkspace window")?;
+
+ cx.run_until_parked();
+
+ // Add worktree to workspace 1 (index 0) so it shows as "private-test-remote"
+ let add_worktree1_task = multi_workspace_window
+ .update(cx, |multi_workspace, _window, cx| {
+ let workspace1 = &multi_workspace.workspaces()[0];
+ let project = workspace1.read(cx).project().clone();
+ project.update(cx, |project, cx| {
+ project.find_or_create_worktree(&workspace1_dir, true, cx)
+ })
+ })
+ .context("Failed to start adding worktree 1")?;
+
+ cx.background_executor.allow_parking();
+ cx.foreground_executor
+ .block_test(add_worktree1_task)
+ .context("Failed to add worktree 1")?;
+ cx.background_executor.forbid_parking();
+
+ cx.run_until_parked();
+
+ // Add worktree to workspace 2 (index 1) so it shows as "zed"
+ let add_worktree2_task = multi_workspace_window
+ .update(cx, |multi_workspace, _window, cx| {
+ let workspace2 = &multi_workspace.workspaces()[1];
+ let project = workspace2.read(cx).project().clone();
+ project.update(cx, |project, cx| {
+ project.find_or_create_worktree(&workspace2_dir, true, cx)
+ })
+ })
+ .context("Failed to start adding worktree 2")?;
+
+ cx.background_executor.allow_parking();
+ cx.foreground_executor
+ .block_test(add_worktree2_task)
+ .context("Failed to add worktree 2")?;
+ cx.background_executor.forbid_parking();
+
+ cx.run_until_parked();
+
+ // Switch to workspace 1 so it's highlighted as active (index 0)
+ multi_workspace_window
+ .update(cx, |multi_workspace, window, cx| {
+ multi_workspace.activate_index(0, window, cx);
+ })
+ .context("Failed to activate workspace 1")?;
+
+ cx.run_until_parked();
+
+ // Create the sidebar and register it on the MultiWorkspace
+ let sidebar = multi_workspace_window
+ .update(cx, |_multi_workspace, window, cx| {
+ let multi_workspace_handle = cx.entity();
+ cx.new(|cx| sidebar::Sidebar::new(multi_workspace_handle, window, cx))
+ })
+ .context("Failed to create sidebar")?;
+
+ multi_workspace_window
+ .update(cx, |multi_workspace, window, cx| {
+ multi_workspace.register_sidebar(sidebar.clone(), window, cx);
+ })
+ .context("Failed to register sidebar")?;
+
+ cx.run_until_parked();
+
+ // Inject recent project entries into the sidebar.
+ // We update the sidebar entity directly (not through the MultiWorkspace window update)
+ // to avoid a re-entrant read panic: rebuild_entries reads MultiWorkspace, so we can't
+ // be inside a MultiWorkspace update when that happens.
+ cx.update(|cx| {
+ sidebar.update(cx, |sidebar, cx| {
+ let recent_projects = vec![
+ RecentProjectEntry {
+ name: "tiny-project".into(),
+ full_path: recent1_dir.to_string_lossy().to_string().into(),
+ paths: vec![recent1_dir.clone()],
+ workspace_id: WorkspaceId::default(),
+ },
+ RecentProjectEntry {
+ name: "font-kit".into(),
+ full_path: recent2_dir.to_string_lossy().to_string().into(),
+ paths: vec![recent2_dir.clone()],
+ workspace_id: WorkspaceId::default(),
+ },
+ RecentProjectEntry {
+ name: "ideas".into(),
+ full_path: recent3_dir.to_string_lossy().to_string().into(),
+ paths: vec![recent3_dir.clone()],
+ workspace_id: WorkspaceId::default(),
+ },
+ RecentProjectEntry {
+ name: "tmp".into(),
+ full_path: recent4_dir.to_string_lossy().to_string().into(),
+ paths: vec![recent4_dir.clone()],
+ workspace_id: WorkspaceId::default(),
+ },
+ ];
+ sidebar.set_test_recent_projects(recent_projects, cx);
+ });
+ });
+
+ // Set thread info directly on the sidebar for visual testing
+ cx.update(|cx| {
+ sidebar.update(cx, |sidebar, _cx| {
+ sidebar.set_test_thread_info(
+ 0,
+ "Refine thread view scrolling behavior".into(),
+ sidebar::AgentThreadStatus::Completed,
+ );
+ sidebar.set_test_thread_info(
+ 1,
+ "Add line numbers option to FileEditBlock".into(),
+ sidebar::AgentThreadStatus::Running,
+ );
+ });
+ });
+
+ // Set last-worked-on thread titles on some recent projects for visual testing
+ cx.update(|cx| {
+ sidebar.update(cx, |sidebar, cx| {
+ sidebar.set_test_recent_project_thread_title(
+ recent1_dir.to_string_lossy().to_string().into(),
+ "Fix flaky test in CI pipeline".into(),
+ cx,
+ );
+ sidebar.set_test_recent_project_thread_title(
+ recent2_dir.to_string_lossy().to_string().into(),
+ "Upgrade font rendering engine".into(),
+ cx,
+ );
+ });
+ });
+
+ cx.run_until_parked();
+
+ // Open the sidebar
+ multi_workspace_window
+ .update(cx, |multi_workspace, window, cx| {
+ multi_workspace.toggle_sidebar(window, cx);
+ })
+ .context("Failed to toggle sidebar")?;
+
+ // Let rendering settle
+ for _ in 0..10 {
+ cx.advance_clock(Duration::from_millis(100));
+ cx.run_until_parked();
+ }
+
+ // Refresh the window
+ cx.update_window(multi_workspace_window.into(), |_, window, _cx| {
+ window.refresh();
+ })?;
+
+ cx.run_until_parked();
+
+ // Capture: sidebar open with active workspaces and recent projects
+ let test_result = run_visual_test(
+ "multi_workspace_sidebar_open",
+ multi_workspace_window.into(),
+ cx,
+ update_baseline,
+ )?;
+
+ // Clean up worktrees
+ multi_workspace_window
+ .update(cx, |multi_workspace, _window, cx| {
+ for workspace in multi_workspace.workspaces() {
+ let project = workspace.read(cx).project().clone();
+ project.update(cx, |project, cx| {
+ let worktree_ids: Vec<_> =
+ project.worktrees(cx).map(|wt| wt.read(cx).id()).collect();
+ for id in worktree_ids {
+ project.remove_worktree(id, cx);
+ }
+ });
+ }
+ })
+ .log_err();
+
+ cx.run_until_parked();
+
+ // Close the window
+ cx.update_window(multi_workspace_window.into(), |_, window, _cx| {
+ window.remove_window();
+ })
+ .log_err();
+
+ cx.run_until_parked();
+
+ for _ in 0..15 {
+ cx.advance_clock(Duration::from_millis(100));
+ cx.run_until_parked();
+ }
+
+ Ok(test_result)
+}
@@ -68,6 +68,7 @@ use settings::{
initial_local_debug_tasks_content, initial_project_settings_content, initial_tasks_content,
update_settings_file,
};
+use sidebar::Sidebar;
use std::time::Duration;
use std::{
borrow::Cow,
@@ -88,9 +89,9 @@ use workspace::notifications::{
};
use workspace::utility_pane::utility_slot_for_dock_position;
use workspace::{
- AppState, NewFile, NewWindow, OpenLog, Panel, Toast, Workspace, WorkspaceSettings,
- create_and_open_local_file, notifications::simple_message_notification::MessageNotification,
- open_new,
+ AppState, MultiWorkspace, NewFile, NewWindow, OpenLog, Panel, Toast, Workspace,
+ WorkspaceSettings, create_and_open_local_file,
+ notifications::simple_message_notification::MessageNotification, open_new,
};
use workspace::{
CloseIntent, CloseProject, CloseWindow, NotificationFrame, RestoreBanner,
@@ -370,6 +371,16 @@ pub fn initialize_workspace(
})
.detach();
+ cx.observe_new(|multi_workspace: &mut MultiWorkspace, window, cx| {
+ let Some(window) = window else {
+ return;
+ };
+ let multi_workspace_handle = cx.entity();
+ let sidebar = cx.new(|cx| Sidebar::new(multi_workspace_handle, window, cx));
+ multi_workspace.register_sidebar(sidebar, window, cx);
+ })
+ .detach();
+
cx.observe_new(move |workspace: &mut Workspace, window, cx| {
let Some(window) = window else {
return;
@@ -1152,7 +1163,7 @@ fn register_actions(
.register_action({
let app_state = Arc::downgrade(&app_state);
move |_, _: &CloseProject, window, cx| {
- let Some(window_handle) = window.window_handle().downcast::<Workspace>() else {
+ let Some(window_handle) = window.window_handle().downcast::<MultiWorkspace>() else {
return;
};
if let Some(app_state) = app_state.upgrade() {
@@ -1248,6 +1259,7 @@ fn initialize_pane(
window: &mut Window,
cx: &mut Context<Workspace>,
) {
+ let workspace_handle = cx.weak_entity();
pane.update(cx, |pane, cx| {
pane.toolbar().update(cx, |toolbar, cx| {
let multibuffer_hint = cx.new(|_| MultibufferHint::new());
@@ -1280,11 +1292,12 @@ fn initialize_pane(
toolbar.add_item(telemetry_log_item, window, cx);
let syntax_tree_item = cx.new(|_| language_tools::SyntaxTreeToolbarItemView::new());
toolbar.add_item(syntax_tree_item, window, cx);
+ let migration_banner =
+ cx.new(|inner_cx| MigrationBanner::new(workspace_handle.clone(), inner_cx));
+ toolbar.add_item(migration_banner, window, cx);
let highlights_tree_item =
cx.new(|_| language_tools::HighlightsTreeToolbarItemView::new());
toolbar.add_item(highlights_tree_item, window, cx);
- let migration_banner = cx.new(|cx| MigrationBanner::new(workspace, cx));
- toolbar.add_item(migration_banner, window, cx);
let project_diff_toolbar = cx.new(|cx| ProjectDiffToolbar::new(workspace, cx));
toolbar.add_item(project_diff_toolbar, window, cx);
let branch_diff_toolbar = cx.new(BranchDiffToolbar::new);
@@ -1359,10 +1372,10 @@ fn quit(_: &Quit, cx: &mut App) {
let should_confirm = WorkspaceSettings::get_global(cx).confirm_quit;
cx.spawn(async move |cx| {
- let mut workspace_windows: Vec<WindowHandle<Workspace>> = cx.update(|cx| {
+ let mut workspace_windows: Vec<WindowHandle<MultiWorkspace>> = cx.update(|cx| {
cx.windows()
.into_iter()
- .filter_map(|window| window.downcast::<Workspace>())
+ .filter_map(|window| window.downcast::<MultiWorkspace>())
.collect::<Vec<_>>()
});
@@ -1372,8 +1385,8 @@ fn quit(_: &Quit, cx: &mut App) {
workspace_windows.sort_by_key(|window| window.is_active(cx) == Some(false));
});
- if should_confirm && let Some(workspace) = workspace_windows.first() {
- let answer = workspace
+ if should_confirm && let Some(multi_workspace) = workspace_windows.first() {
+ let answer = multi_workspace
.update(cx, |_, window, cx| {
window.prompt(
PromptLevel::Info,
@@ -1397,14 +1410,30 @@ fn quit(_: &Quit, cx: &mut App) {
// If the user cancels any save prompt, then keep the app open.
for window in workspace_windows {
- if let Some(should_close) = window
- .update(cx, |workspace, window, cx| {
- workspace.prepare_to_close(CloseIntent::Quit, window, cx)
+ let workspaces = window
+ .update(cx, |multi_workspace, _, _| {
+ multi_workspace.workspaces().to_vec()
})
- .log_err()
- {
- if !should_close.await? {
- return Ok(());
+ .log_err();
+
+ let Some(workspaces) = workspaces else {
+ continue;
+ };
+
+ for workspace in workspaces {
+ if let Some(should_close) = window
+ .update(cx, |multi_workspace, window, cx| {
+ multi_workspace.activate(workspace.clone(), cx);
+ window.activate_window();
+ workspace.update(cx, |workspace, cx| {
+ workspace.prepare_to_close(CloseIntent::Quit, window, cx)
+ })
+ })
+ .log_err()
+ {
+ if !should_close.await? {
+ return Ok(());
+ }
}
}
}
@@ -2356,6 +2385,7 @@ mod tests {
use settings::{SaturatingBool, SettingsStore, watch_config_file};
use std::{
path::{Path, PathBuf},
+ sync::Arc,
time::Duration,
};
use theme::ThemeRegistry;
@@ -2363,6 +2393,7 @@ mod tests {
path,
rel_path::{RelPath, rel_path},
};
+ use workspace::MultiWorkspace;
use workspace::{
NewFile, OpenOptions, OpenVisible, SERIALIZATION_THROTTLE_TIME, SaveIntent, SplitDirection,
WorkspaceHandle,
@@ -2398,10 +2429,12 @@ mod tests {
.unwrap();
assert_eq!(cx.read(|cx| cx.windows().len()), 1);
- let workspace = cx.windows()[0].downcast::<Workspace>().unwrap();
- workspace
- .update(cx, |workspace, _, cx| {
- assert!(workspace.active_item_as::<Editor>(cx).is_some())
+ let multi_workspace = cx.windows()[0].downcast::<MultiWorkspace>().unwrap();
+ multi_workspace
+ .update(cx, |multi_workspace, _, cx| {
+ multi_workspace.workspace().update(cx, |workspace, cx| {
+ assert!(workspace.active_item_as::<Editor>(cx).is_some())
+ });
})
.unwrap();
}
@@ -2409,6 +2442,10 @@ mod tests {
#[gpui::test]
async fn test_open_paths_action(cx: &mut TestAppContext) {
let app_state = init_test(cx);
+ cx.update(|cx| {
+ use feature_flags::FeatureFlagAppExt as _;
+ cx.update_flags(false, vec!["agent-v2".to_string()]);
+ });
app_state
.fs
.as_fake()
@@ -2462,21 +2499,23 @@ mod tests {
.await
.unwrap();
assert_eq!(cx.read(|cx| cx.windows().len()), 1);
- let workspace_1 = cx
- .read(|cx| cx.windows()[0].downcast::<Workspace>())
+ let multi_workspace_1 = cx
+ .read(|cx| cx.windows()[0].downcast::<MultiWorkspace>())
.unwrap();
cx.run_until_parked();
- workspace_1
- .update(cx, |workspace, window, cx| {
- assert_eq!(workspace.worktrees(cx).count(), 2);
- assert!(workspace.left_dock().read(cx).is_open());
- assert!(
- workspace
- .active_pane()
- .read(cx)
- .focus_handle(cx)
- .is_focused(window)
- );
+ multi_workspace_1
+ .update(cx, |multi_workspace, window, cx| {
+ multi_workspace.workspace().update(cx, |workspace, cx| {
+ assert_eq!(workspace.worktrees(cx).count(), 2);
+ assert!(workspace.left_dock().read(cx).is_open());
+ assert!(
+ workspace
+ .active_pane()
+ .read(cx)
+ .focus_handle(cx)
+ .is_focused(window)
+ );
+ });
})
.unwrap();
@@ -2494,7 +2533,7 @@ mod tests {
// Replace existing windows
let window = cx
- .update(|cx| cx.windows()[0].downcast::<Workspace>())
+ .update(|cx| cx.windows()[0].downcast::<MultiWorkspace>())
.unwrap();
cx.update(|cx| {
open_paths(
@@ -2511,11 +2550,12 @@ mod tests {
.unwrap();
cx.background_executor.run_until_parked();
assert_eq!(cx.read(|cx| cx.windows().len()), 2);
- let workspace_1 = cx
- .update(|cx| cx.windows()[0].downcast::<Workspace>())
+ let multi_workspace_1 = cx
+ .update(|cx| cx.windows()[0].downcast::<MultiWorkspace>())
.unwrap();
- workspace_1
- .update(cx, |workspace, window, cx| {
+ multi_workspace_1
+ .update(cx, |multi_workspace, window, cx| {
+ let workspace = multi_workspace.workspace().read(cx);
assert_eq!(
workspace
.worktrees(cx)
@@ -2687,17 +2727,21 @@ mod tests {
assert_eq!(cx.update(|cx| cx.windows().len()), 1);
// When opening the workspace, the window is not in a edited state.
- let window = cx.update(|cx| cx.windows()[0].downcast::<Workspace>().unwrap());
+ let window = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
- let window_is_edited = |window: WindowHandle<Workspace>, cx: &mut TestAppContext| {
- cx.update(|cx| window.read(cx).unwrap().is_edited())
+ let window_is_edited = |window: WindowHandle<MultiWorkspace>, cx: &mut TestAppContext| {
+ cx.update(|cx| window.read(cx).unwrap().workspace().read(cx).is_edited())
};
let pane = window
- .read_with(cx, |workspace, _| workspace.active_pane().clone())
+ .read_with(cx, |multi_workspace, cx| {
+ multi_workspace.workspace().read(cx).active_pane().clone()
+ })
.unwrap();
let editor = window
- .read_with(cx, |workspace, cx| {
- workspace
+ .read_with(cx, |multi_workspace, cx| {
+ multi_workspace
+ .workspace()
+ .read(cx)
.active_item(cx)
.unwrap()
.downcast::<Editor>()
@@ -2770,22 +2814,26 @@ mod tests {
executor.run_until_parked();
window
- .update(cx, |workspace, _, cx| {
- let editor = workspace
- .active_item(cx)
- .unwrap()
- .downcast::<Editor>()
- .unwrap();
+ .update(cx, |multi_workspace, _, cx| {
+ multi_workspace.workspace().update(cx, |workspace, cx| {
+ let editor = workspace
+ .active_item(cx)
+ .unwrap()
+ .downcast::<Editor>()
+ .unwrap();
- editor.update(cx, |editor, cx| {
- assert_eq!(editor.text(cx), "hey");
+ editor.update(cx, |editor, cx| {
+ assert_eq!(editor.text(cx), "hey");
+ });
});
})
.unwrap();
let editor = window
- .read_with(cx, |workspace, cx| {
- workspace
+ .read_with(cx, |multi_workspace, cx| {
+ multi_workspace
+ .workspace()
+ .read(cx)
.active_item(cx)
.unwrap()
.downcast::<Editor>()
@@ -2838,15 +2886,17 @@ mod tests {
assert_eq!(cx.update(|cx| cx.windows().len()), 1);
// When opening the workspace, the window is not in a edited state.
- let window = cx.update(|cx| cx.windows()[0].downcast::<Workspace>().unwrap());
+ let window = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
- let window_is_edited = |window: WindowHandle<Workspace>, cx: &mut TestAppContext| {
- cx.update(|cx| window.read(cx).unwrap().is_edited())
+ let window_is_edited = |window: WindowHandle<MultiWorkspace>, cx: &mut TestAppContext| {
+ cx.update(|cx| window.read(cx).unwrap().workspace().read(cx).is_edited())
};
let editor = window
- .read_with(cx, |workspace, cx| {
- workspace
+ .read_with(cx, |multi_workspace, cx| {
+ multi_workspace
+ .workspace()
+ .read(cx)
.active_item(cx)
.unwrap()
.downcast::<Editor>()
@@ -2893,22 +2943,27 @@ mod tests {
cx.run_until_parked();
// When opening the workspace, the window is not in a edited state.
- let window = cx.update(|cx| cx.active_window().unwrap().downcast::<Workspace>().unwrap());
+ let window = cx.update(|cx| {
+ cx.active_window()
+ .unwrap()
+ .downcast::<MultiWorkspace>()
+ .unwrap()
+ });
assert!(window_is_edited(window, cx));
window
- .update(cx, |workspace, _, cx| {
- let editor = workspace
- .active_item(cx)
- .unwrap()
- .downcast::<editor::Editor>()
- .unwrap();
- editor.update(cx, |editor, cx| {
- assert_eq!(editor.text(cx), "EDIThey");
- assert!(editor.is_dirty(cx));
+ .update(cx, |multi_workspace, _, cx| {
+ multi_workspace.workspace().update(cx, |workspace, cx| {
+ let editor = workspace
+ .active_item(cx)
+ .unwrap()
+ .downcast::<editor::Editor>()
+ .unwrap();
+ editor.update(cx, |editor, cx| {
+ assert_eq!(editor.text(cx), "EDIThey");
+ assert!(editor.is_dirty(cx));
+ });
});
-
- editor
})
.unwrap();
}
@@ -2930,36 +2985,40 @@ mod tests {
.unwrap();
cx.run_until_parked();
- let workspace = cx
- .update(|cx| cx.windows().first().unwrap().downcast::<Workspace>())
+ let multi_workspace = cx
+ .update(|cx| cx.windows().first().unwrap().downcast::<MultiWorkspace>())
.unwrap();
- let editor = workspace
- .update(cx, |workspace, _, cx| {
- let editor = workspace
- .active_item(cx)
- .unwrap()
- .downcast::<editor::Editor>()
- .unwrap();
- editor.update(cx, |editor, cx| {
- assert!(editor.text(cx).is_empty());
- assert!(!editor.is_dirty(cx));
- });
+ let editor = multi_workspace
+ .update(cx, |multi_workspace, _, cx| {
+ multi_workspace.workspace().update(cx, |workspace, cx| {
+ let editor = workspace
+ .active_item(cx)
+ .unwrap()
+ .downcast::<editor::Editor>()
+ .unwrap();
+ editor.update(cx, |editor, cx| {
+ assert!(editor.text(cx).is_empty());
+ assert!(!editor.is_dirty(cx));
+ });
- editor
+ editor
+ })
})
.unwrap();
- let save_task = workspace
- .update(cx, |workspace, window, cx| {
- workspace.save_active_item(SaveIntent::Save, window, cx)
+ let save_task = multi_workspace
+ .update(cx, |multi_workspace, window, cx| {
+ multi_workspace.workspace().update(cx, |workspace, cx| {
+ workspace.save_active_item(SaveIntent::Save, window, cx)
+ })
})
.unwrap();
app_state.fs.create_dir(Path::new("/root")).await.unwrap();
cx.background_executor.run_until_parked();
cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/the-new-name")));
save_task.await.unwrap();
- workspace
+ multi_workspace
.update(cx, |_, _, cx| {
editor.update(cx, |editor, cx| {
assert!(!editor.is_dirty(cx));
@@ -3140,8 +3199,10 @@ mod tests {
.unwrap();
cx.run_until_parked();
assert_eq!(cx.update(|cx| cx.windows().len()), 1);
- let window = cx.update(|cx| cx.windows()[0].downcast::<Workspace>().unwrap());
- let workspace = window.root(cx).unwrap();
+ let window = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
+ let workspace = window
+ .read_with(cx, |mw, _| mw.workspace().clone())
+ .unwrap();
#[track_caller]
fn assert_project_panel_selection(
@@ -3176,17 +3237,19 @@ mod tests {
// Open a file within an existing worktree.
window
- .update(cx, |workspace, window, cx| {
- workspace.open_paths(
- vec![path!("/dir1/a.txt").into()],
- OpenOptions {
- visible: Some(OpenVisible::All),
- ..Default::default()
- },
- None,
- window,
- cx,
- )
+ .update(cx, |multi_workspace, window, cx| {
+ multi_workspace.workspace().update(cx, |workspace, cx| {
+ workspace.open_paths(
+ vec![path!("/dir1/a.txt").into()],
+ OpenOptions {
+ visible: Some(OpenVisible::All),
+ ..Default::default()
+ },
+ None,
+ window,
+ cx,
+ )
+ })
})
.unwrap()
.await;
@@ -3215,17 +3278,19 @@ mod tests {
// Open a file outside of any existing worktree.
window
- .update(cx, |workspace, window, cx| {
- workspace.open_paths(
- vec![path!("/dir2/b.txt").into()],
- OpenOptions {
- visible: Some(OpenVisible::All),
- ..Default::default()
- },
- None,
- window,
- cx,
- )
+ .update(cx, |multi_workspace, window, cx| {
+ multi_workspace.workspace().update(cx, |workspace, cx| {
+ workspace.open_paths(
+ vec![path!("/dir2/b.txt").into()],
+ OpenOptions {
+ visible: Some(OpenVisible::All),
+ ..Default::default()
+ },
+ None,
+ window,
+ cx,
+ )
+ })
})
.unwrap()
.await;
@@ -3265,17 +3330,19 @@ mod tests {
// Ensure opening a directory and one of its children only adds one worktree.
window
- .update(cx, |workspace, window, cx| {
- workspace.open_paths(
- vec![path!("/dir3").into(), path!("/dir3/c.txt").into()],
- OpenOptions {
- visible: Some(OpenVisible::All),
- ..Default::default()
- },
- None,
- window,
- cx,
- )
+ .update(cx, |multi_workspace, window, cx| {
+ multi_workspace.workspace().update(cx, |workspace, cx| {
+ workspace.open_paths(
+ vec![path!("/dir3").into(), path!("/dir3/c.txt").into()],
+ OpenOptions {
+ visible: Some(OpenVisible::All),
+ ..Default::default()
+ },
+ None,
+ window,
+ cx,
+ )
+ })
})
.unwrap()
.await;
@@ -3315,17 +3382,19 @@ mod tests {
// Ensure opening invisibly a file outside an existing worktree adds a new, invisible worktree.
window
- .update(cx, |workspace, window, cx| {
- workspace.open_paths(
- vec![path!("/d.txt").into()],
- OpenOptions {
- visible: Some(OpenVisible::None),
- ..Default::default()
- },
- None,
- window,
- cx,
- )
+ .update(cx, |multi_workspace, window, cx| {
+ multi_workspace.workspace().update(cx, |workspace, cx| {
+ workspace.open_paths(
+ vec![path!("/d.txt").into()],
+ OpenOptions {
+ visible: Some(OpenVisible::None),
+ ..Default::default()
+ },
+ None,
+ window,
+ cx,
+ )
+ })
})
.unwrap()
.await;
@@ -3419,8 +3488,13 @@ mod tests {
let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
project.update(cx, |project, _cx| project.languages().add(markdown_lang()));
- let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx));
- let workspace = window.root(cx).unwrap();
+ let window = cx.add_window({
+ let project = project.clone();
+ |window, cx| MultiWorkspace::test_new(project, window, cx)
+ });
+ let workspace = window
+ .read_with(cx, |mw, _| mw.workspace().clone())
+ .unwrap();
let initial_entries = cx.read(|cx| workspace.file_project_paths(cx));
let paths_to_open = [
@@ -3441,7 +3515,9 @@ mod tests {
.unwrap();
assert_eq!(
- opened_workspace.root(cx).unwrap().entity_id(),
+ opened_workspace
+ .read_with(cx, |mw, _| mw.workspace().entity_id())
+ .unwrap(),
workspace.entity_id(),
"Excluded files in subfolders of a workspace root should be opened in the workspace"
);
@@ -4864,6 +4940,7 @@ mod tests {
"lsp_tool",
"markdown",
"menu",
+ "multi_workspace",
"new_process_modal",
"notebook",
"notification_panel",
@@ -4951,7 +5028,7 @@ mod tests {
cx.update(init);
let project = Project::test(app_state.fs.clone(), [], cx).await;
- let _window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx));
+ let _window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
cx.update(|cx| {
cx.dispatch_action(&OpenDefaultSettings);
@@ -4960,10 +5037,12 @@ mod tests {
assert_eq!(cx.read(|cx| cx.windows().len()), 1);
- let workspace = cx.windows()[0].downcast::<Workspace>().unwrap();
- let active_editor = workspace
- .update(cx, |workspace, _, cx| {
- workspace.active_item_as::<Editor>(cx)
+ let multi_workspace = cx.windows()[0].downcast::<MultiWorkspace>().unwrap();
+ let active_editor = multi_workspace
+ .update(cx, |multi_workspace, _, cx| {
+ multi_workspace
+ .workspace()
+ .update(cx, |workspace, cx| workspace.active_item_as::<Editor>(cx))
})
.unwrap();
assert!(
@@ -5267,16 +5346,22 @@ mod tests {
.await;
let project_a = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
- let window_a =
- cx.add_window(|window, cx| Workspace::test_new(project_a.clone(), window, cx));
+ let window_a = cx.add_window({
+ let project = project_a.clone();
+ |window, cx| MultiWorkspace::test_new(project, window, cx)
+ });
let project_b = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
- let window_b =
- cx.add_window(|window, cx| Workspace::test_new(project_b.clone(), window, cx));
+ let window_b = cx.add_window({
+ let project = project_b.clone();
+ |window, cx| MultiWorkspace::test_new(project, window, cx)
+ });
let project_c = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
- let window_c =
- cx.add_window(|window, cx| Workspace::test_new(project_c.clone(), window, cx));
+ let window_c = cx.add_window({
+ let project = project_c.clone();
+ |window, cx| MultiWorkspace::test_new(project, window, cx)
+ });
for window in [window_a, window_b, window_c] {
let _ = cx.update_window(*window, |_, window, _| {
@@ -5297,8 +5382,8 @@ mod tests {
cx.update_window(*window, |_, window, _| assert!(window.is_window_active()))
.unwrap();
- let _ = window.read_with(cx, |workspace, cx| {
- let pane = workspace.active_pane().read(cx);
+ let _ = window.read_with(cx, |multi_workspace, cx| {
+ let pane = multi_workspace.workspace().read(cx).active_pane().read(cx);
let project_path = pane.active_item().unwrap().project_path(cx).unwrap();
assert_eq!(
@@ -5308,4 +5393,707 @@ mod tests {
});
}
}
+
+ #[gpui::test]
+ async fn test_open_paths_switches_to_best_workspace(cx: &mut TestAppContext) {
+ let app_state = init_test(cx);
+ cx.update(|cx| {
+ use feature_flags::FeatureFlagAppExt as _;
+ cx.update_flags(false, vec!["agent-v2".to_string()]);
+ });
+
+ app_state
+ .fs
+ .as_fake()
+ .insert_tree(
+ path!("/"),
+ json!({
+ "dir1": {
+ "a.txt": "content a"
+ },
+ "dir2": {
+ "b.txt": "content b"
+ },
+ "dir3": {
+ "c.txt": "content c"
+ }
+ }),
+ )
+ .await;
+
+ // Create a window with workspace 0 containing /dir1
+ let project1 = Project::test(app_state.fs.clone(), [path!("/dir1").as_ref()], cx).await;
+
+ let window = cx.add_window({
+ let project = project1.clone();
+ |window, cx| MultiWorkspace::test_new(project, window, cx)
+ });
+
+ cx.run_until_parked();
+ assert_eq!(cx.windows().len(), 1, "Should start with 1 window");
+
+ // Create workspace 2 with /dir2
+ let project2 = Project::test(app_state.fs.clone(), [path!("/dir2").as_ref()], cx).await;
+ let workspace2 = window
+ .update(cx, |_, window, cx| {
+ cx.new(|cx| Workspace::test_new(project2.clone(), window, cx))
+ })
+ .unwrap();
+
+ // Create workspace 3 with /dir3
+ let project3 = Project::test(app_state.fs.clone(), [path!("/dir3").as_ref()], cx).await;
+ let workspace3 = window
+ .update(cx, |_, window, cx| {
+ cx.new(|cx| Workspace::test_new(project3.clone(), window, cx))
+ })
+ .unwrap();
+
+ let workspace1 = window
+ .read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone())
+ .unwrap();
+
+ window
+ .update(cx, |multi_workspace, _, cx| {
+ multi_workspace.activate(workspace2.clone(), cx);
+ multi_workspace.activate(workspace3.clone(), cx);
+ // Switch back to workspace1 for test setup
+ multi_workspace.activate(workspace1, cx);
+ assert_eq!(multi_workspace.active_workspace_index(), 0);
+ })
+ .unwrap();
+
+ cx.run_until_parked();
+
+ // Verify setup: 3 workspaces, workspace 0 active, still 1 window
+ window
+ .read_with(cx, |multi_workspace, _| {
+ assert_eq!(multi_workspace.workspaces().len(), 3);
+ assert_eq!(multi_workspace.active_workspace_index(), 0);
+ })
+ .unwrap();
+ assert_eq!(cx.windows().len(), 1);
+
+ // Open a file in /dir3 - should switch to workspace 3 (not just "the other one")
+ cx.update(|cx| {
+ open_paths(
+ &[PathBuf::from(path!("/dir3/c.txt"))],
+ app_state.clone(),
+ OpenOptions::default(),
+ cx,
+ )
+ })
+ .await
+ .unwrap();
+
+ cx.run_until_parked();
+
+ // Verify workspace 2 is active and file opened there
+ window
+ .read_with(cx, |multi_workspace, cx| {
+ assert_eq!(
+ multi_workspace.active_workspace_index(),
+ 2,
+ "Should have switched to workspace 3 which contains /dir3"
+ );
+ let active_item = multi_workspace
+ .workspace()
+ .read(cx)
+ .active_pane()
+ .read(cx)
+ .active_item()
+ .expect("Should have an active item");
+ assert_eq!(active_item.tab_content_text(0, cx), "c.txt");
+ })
+ .unwrap();
+ assert_eq!(cx.windows().len(), 1, "Should reuse existing window");
+
+ // Open a file in /dir2 - should switch to workspace 2
+ cx.update(|cx| {
+ open_paths(
+ &[PathBuf::from(path!("/dir2/b.txt"))],
+ app_state.clone(),
+ OpenOptions::default(),
+ cx,
+ )
+ })
+ .await
+ .unwrap();
+
+ cx.run_until_parked();
+
+ // Verify workspace 1 is active and file opened there
+ window
+ .read_with(cx, |multi_workspace, cx| {
+ assert_eq!(
+ multi_workspace.active_workspace_index(),
+ 1,
+ "Should have switched to workspace 2 which contains /dir2"
+ );
+ let active_item = multi_workspace
+ .workspace()
+ .read(cx)
+ .active_pane()
+ .read(cx)
+ .active_item()
+ .expect("Should have an active item");
+ assert_eq!(active_item.tab_content_text(0, cx), "b.txt");
+ })
+ .unwrap();
+
+ // Verify c.txt is still in workspace 3 (file opened in correct workspace, not active one)
+ workspace3.read_with(cx, |workspace, cx| {
+ let active_item = workspace
+ .active_pane()
+ .read(cx)
+ .active_item()
+ .expect("Workspace 2 should have an active item");
+ assert_eq!(
+ active_item.tab_content_text(0, cx),
+ "c.txt",
+ "c.txt should have been opened in workspace 3, not the active workspace"
+ );
+ });
+
+ assert_eq!(cx.windows().len(), 1, "Should still have only 1 window");
+
+ // Open a file in /dir1 - should switch back to workspace 0
+ cx.update(|cx| {
+ open_paths(
+ &[PathBuf::from(path!("/dir1/a.txt"))],
+ app_state.clone(),
+ OpenOptions::default(),
+ cx,
+ )
+ })
+ .await
+ .unwrap();
+
+ cx.run_until_parked();
+
+ // Verify workspace 0 is active and file opened there
+ window
+ .read_with(cx, |multi_workspace, cx| {
+ assert_eq!(
+ multi_workspace.active_workspace_index(),
+ 0,
+ "Should have switched back to workspace 0 which contains /dir1"
+ );
+ let active_item = multi_workspace
+ .workspace()
+ .read(cx)
+ .active_pane()
+ .read(cx)
+ .active_item()
+ .expect("Should have an active item");
+ assert_eq!(active_item.tab_content_text(0, cx), "a.txt");
+ })
+ .unwrap();
+ assert_eq!(cx.windows().len(), 1, "Should still have only 1 window");
+ }
+
+ #[gpui::test]
+ async fn test_quit_checks_all_workspaces_for_dirty_items(cx: &mut TestAppContext) {
+ let app_state = init_test(cx);
+ cx.update(init);
+ cx.update(|cx| {
+ use feature_flags::FeatureFlagAppExt as _;
+ cx.update_flags(false, vec!["agent-v2".to_string()]);
+ });
+
+ app_state
+ .fs
+ .as_fake()
+ .insert_tree(
+ path!("/"),
+ json!({
+ "dir1": {
+ "a.txt": "content a"
+ },
+ "dir2": {
+ "b.txt": "content b"
+ },
+ "dir3": {
+ "c.txt": "content c"
+ }
+ }),
+ )
+ .await;
+
+ // === Setup Window 1 with two workspaces ===
+ let project1 = Project::test(app_state.fs.clone(), [path!("/dir1").as_ref()], cx).await;
+ let window1 = cx.add_window({
+ let project = project1.clone();
+ |window, cx| MultiWorkspace::test_new(project, window, cx)
+ });
+
+ cx.run_until_parked();
+
+ let project2 = Project::test(app_state.fs.clone(), [path!("/dir2").as_ref()], cx).await;
+ let workspace1_1 = window1
+ .read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone())
+ .unwrap();
+ let workspace1_2 = window1
+ .update(cx, |_, window, cx| {
+ cx.new(|cx| Workspace::test_new(project2.clone(), window, cx))
+ })
+ .unwrap();
+
+ window1
+ .update(cx, |multi_workspace, _, cx| {
+ multi_workspace.activate(workspace1_2.clone(), cx);
+ multi_workspace.activate(workspace1_1.clone(), cx);
+ })
+ .unwrap();
+
+ // === Setup Window 2 with one workspace ===
+ let project3 = Project::test(app_state.fs.clone(), [path!("/dir3").as_ref()], cx).await;
+ let window2 = cx.add_window({
+ let project = project3.clone();
+ |window, cx| MultiWorkspace::test_new(project, window, cx)
+ });
+
+ cx.run_until_parked();
+ assert_eq!(cx.windows().len(), 2);
+
+ // === Case 1: Active workspace has dirty item, quit can be cancelled ===
+ let worktree1_id = project1.update(cx, |project, cx| {
+ project.worktrees(cx).next().unwrap().read(cx).id()
+ });
+
+ let editor1 = window1
+ .update(cx, |_, window, cx| {
+ workspace1_1.update(cx, |workspace, cx| {
+ workspace.open_path((worktree1_id, rel_path("a.txt")), None, true, window, cx)
+ })
+ })
+ .unwrap()
+ .await
+ .unwrap()
+ .downcast::<Editor>()
+ .unwrap();
+
+ window1
+ .update(cx, |_, window, cx| {
+ editor1.update(cx, |editor, cx| {
+ editor.insert("dirty in active workspace", window, cx);
+ });
+ })
+ .unwrap();
+
+ cx.run_until_parked();
+
+ // Verify workspace1_1 is active
+ window1
+ .read_with(cx, |multi_workspace, _| {
+ assert_eq!(multi_workspace.active_workspace_index(), 0);
+ })
+ .unwrap();
+
+ cx.dispatch_action(*window1, Quit);
+ cx.run_until_parked();
+
+ assert!(
+ cx.has_pending_prompt(),
+ "Case 1: Should prompt to save dirty item in active workspace"
+ );
+
+ cx.simulate_prompt_answer("Cancel");
+ cx.run_until_parked();
+
+ assert_eq!(
+ cx.windows().len(),
+ 2,
+ "Case 1: Windows should still exist after cancelling quit"
+ );
+
+ // Clean up Case 1: Close the dirty item without saving
+ let close_task = window1
+ .update(cx, |_, window, cx| {
+ workspace1_1.update(cx, |workspace, cx| {
+ workspace.active_pane().update(cx, |pane, cx| {
+ pane.close_active_item(&Default::default(), window, cx)
+ })
+ })
+ })
+ .unwrap();
+ cx.run_until_parked();
+ cx.simulate_prompt_answer("Don't Save");
+ close_task.await.ok();
+ cx.run_until_parked();
+
+ // === Case 2: Non-active workspace (same window) has dirty item ===
+ let worktree2_id = project2.update(cx, |project, cx| {
+ project.worktrees(cx).next().unwrap().read(cx).id()
+ });
+
+ let editor2 = window1
+ .update(cx, |_, window, cx| {
+ workspace1_2.update(cx, |workspace, cx| {
+ workspace.open_path((worktree2_id, rel_path("b.txt")), None, true, window, cx)
+ })
+ })
+ .unwrap()
+ .await
+ .unwrap()
+ .downcast::<Editor>()
+ .unwrap();
+
+ window1
+ .update(cx, |_, window, cx| {
+ editor2.update(cx, |editor, cx| {
+ editor.insert("dirty in non-active workspace", window, cx);
+ });
+ })
+ .unwrap();
+
+ cx.run_until_parked();
+
+ // Verify workspace1_1 is still active (not workspace1_2 with dirty item)
+ window1
+ .read_with(cx, |multi_workspace, _| {
+ assert_eq!(multi_workspace.active_workspace_index(), 0);
+ })
+ .unwrap();
+
+ cx.dispatch_action(*window1, Quit);
+ cx.run_until_parked();
+
+ // Verify the non-active workspace got activated to show the dirty item
+ window1
+ .read_with(cx, |multi_workspace, _| {
+ assert_eq!(
+ multi_workspace.active_workspace_index(),
+ 1,
+ "Case 2: Non-active workspace should be activated when it has dirty item"
+ );
+ })
+ .unwrap();
+
+ assert!(
+ cx.has_pending_prompt(),
+ "Case 2: Should prompt to save dirty item in non-active workspace"
+ );
+
+ cx.simulate_prompt_answer("Cancel");
+ cx.run_until_parked();
+
+ assert_eq!(
+ cx.windows().len(),
+ 2,
+ "Case 2: Windows should still exist after cancelling quit"
+ );
+
+ // Clean up Case 2: Close the dirty item without saving
+ let close_task = window1
+ .update(cx, |_, window, cx| {
+ workspace1_2.update(cx, |workspace, cx| {
+ workspace.active_pane().update(cx, |pane, cx| {
+ pane.close_active_item(&Default::default(), window, cx)
+ })
+ })
+ })
+ .unwrap();
+ cx.run_until_parked();
+ cx.simulate_prompt_answer("Don't Save");
+ close_task.await.ok();
+ cx.run_until_parked();
+
+ // === Case 3: Non-active window has dirty item ===
+ let workspace3 = window2
+ .read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone())
+ .unwrap();
+
+ let worktree3_id = project3.update(cx, |project, cx| {
+ project.worktrees(cx).next().unwrap().read(cx).id()
+ });
+
+ let editor3 = window2
+ .update(cx, |_, window, cx| {
+ workspace3.update(cx, |workspace, cx| {
+ workspace.open_path((worktree3_id, rel_path("c.txt")), None, true, window, cx)
+ })
+ })
+ .unwrap()
+ .await
+ .unwrap()
+ .downcast::<Editor>()
+ .unwrap();
+
+ window2
+ .update(cx, |_, window, cx| {
+ editor3.update(cx, |editor, cx| {
+ editor.insert("dirty in other window", window, cx);
+ });
+ })
+ .unwrap();
+
+ cx.run_until_parked();
+
+ // Activate window1 explicitly (editing in window2 may have activated it)
+ window1
+ .update(cx, |_, window, _| window.activate_window())
+ .unwrap();
+ cx.run_until_parked();
+
+ // Verify window2 is not active (window1 should still be active)
+ assert_eq!(
+ cx.update(|cx| window2.is_active(cx)),
+ Some(false),
+ "Case 3: window2 should not be active before quit"
+ );
+
+ // Dispatch quit from window1 (window2 has the dirty item)
+ cx.dispatch_action(*window1, Quit);
+ cx.run_until_parked();
+
+ // Verify window2 is now active (quit handler activated it to show dirty item)
+ assert_eq!(
+ cx.update(|cx| window2.is_active(cx)),
+ Some(true),
+ "Case 3: window2 should be activated when it has dirty item"
+ );
+
+ assert!(
+ cx.has_pending_prompt(),
+ "Case 3: Should prompt to save dirty item in non-active window"
+ );
+
+ cx.simulate_prompt_answer("Cancel");
+ cx.run_until_parked();
+
+ assert_eq!(
+ cx.windows().len(),
+ 2,
+ "Case 3: Windows should still exist after cancelling quit"
+ );
+ }
+
+ #[gpui::test]
+ async fn test_multi_workspace_session_restore(cx: &mut TestAppContext) {
+ use collections::HashMap;
+ use session::Session;
+ use workspace::{Workspace, WorkspaceId};
+
+ let app_state = init_test(cx);
+
+ cx.update(|cx| {
+ use feature_flags::FeatureFlagAppExt as _;
+ cx.update_flags(false, vec!["agent-v2".to_string()]);
+ });
+
+ let dir1 = path!("/dir1");
+ let dir2 = path!("/dir2");
+ let dir3 = path!("/dir3");
+
+ let fs = app_state.fs.clone();
+ let fake_fs = fs.as_fake();
+ fake_fs.insert_tree(dir1, json!({})).await;
+ fake_fs.insert_tree(dir2, json!({})).await;
+ fake_fs.insert_tree(dir3, json!({})).await;
+
+ let session_id = cx.read(|cx| app_state.session.read(cx).id().to_owned());
+
+ // --- Create 3 workspaces in 2 windows ---
+ //
+ // Window A: workspace for dir1, workspace for dir2
+ // Window B: workspace for dir3
+ let (window_a, _) = cx
+ .update(|cx| {
+ Workspace::new_local(vec![dir1.into()], app_state.clone(), None, None, None, cx)
+ })
+ .await
+ .expect("failed to open first workspace");
+
+ window_a
+ .update(cx, |multi_workspace, window, cx| {
+ multi_workspace.open_project(vec![dir2.into()], window, cx)
+ })
+ .unwrap()
+ .await
+ .expect("failed to open second workspace into window A");
+ cx.run_until_parked();
+
+ let (window_b, _) = cx
+ .update(|cx| {
+ Workspace::new_local(vec![dir3.into()], app_state.clone(), None, None, None, cx)
+ })
+ .await
+ .expect("failed to open third workspace");
+
+ // Currently dir2 is active because it was added last.
+ // So, switch window_a's active workspace to dir1 (index 0).
+ // This sets up a non-trivial assertion: after restore, dir1 should
+ // still be active rather than whichever workspace happened to restore last.
+ window_a
+ .update(cx, |multi_workspace, window, cx| {
+ multi_workspace.activate_index(0, window, cx);
+ })
+ .unwrap();
+
+ // --- Flush serialization ---
+ cx.executor().advance_clock(SERIALIZATION_THROTTLE_TIME);
+ cx.run_until_parked();
+
+ // Verify all workspaces retained their session_ids.
+ let locations = workspace::last_session_workspace_locations(&session_id, None, fs.as_ref())
+ .await
+ .expect("expected session workspace locations");
+ assert_eq!(
+ locations.len(),
+ 3,
+ "all 3 workspaces should have session_ids in the DB"
+ );
+
+ // Close the original windows.
+ window_a
+ .update(cx, |_, window, _| window.remove_window())
+ .unwrap();
+ window_b
+ .update(cx, |_, window, _| window.remove_window())
+ .unwrap();
+ cx.run_until_parked();
+
+ // Simulate a new session launch: replace the session so that
+ // `last_session_id()` returns the ID used during workspace creation.
+ // `restore_on_startup` defaults to `LastSession`, which is what we need.
+ 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()));
+ });
+ });
+
+ // --- Read back from DB and verify grouping ---
+ let locations = workspace::last_session_workspace_locations(&session_id, None, fs.as_ref())
+ .await
+ .expect("expected session workspace locations");
+
+ assert_eq!(locations.len(), 3, "expected 3 session workspaces");
+
+ let mut groups_by_window: HashMap<gpui::WindowId, Vec<WorkspaceId>> = HashMap::default();
+ for session_workspace in &locations {
+ if let Some(window_id) = session_workspace.window_id {
+ groups_by_window
+ .entry(window_id)
+ .or_default()
+ .push(session_workspace.workspace_id);
+ }
+ }
+ assert_eq!(
+ groups_by_window.len(),
+ 2,
+ "expected 2 window groups, got {groups_by_window:?}"
+ );
+ assert!(
+ groups_by_window.values().any(|g| g.len() == 2),
+ "expected one group with 2 workspaces"
+ );
+ assert!(
+ groups_by_window.values().any(|g| g.len() == 1),
+ "expected one group with 1 workspace"
+ );
+
+ let mut async_cx = cx.to_async();
+ crate::restore_or_create_workspace(app_state.clone(), &mut async_cx)
+ .await
+ .expect("failed to restore workspaces");
+ cx.run_until_parked();
+
+ // --- Verify the restored windows ---
+ let restored_windows: Vec<WindowHandle<MultiWorkspace>> = cx.read(|cx| {
+ cx.windows()
+ .into_iter()
+ .filter_map(|window| window.downcast::<MultiWorkspace>())
+ .collect()
+ });
+
+ assert_eq!(
+ restored_windows.len(),
+ 2,
+ "expected 2 restored windows, got {}",
+ restored_windows.len()
+ );
+
+ let workspace_counts: Vec<usize> = restored_windows
+ .iter()
+ .map(|window| {
+ window
+ .read_with(cx, |multi_workspace, _| multi_workspace.workspaces().len())
+ .unwrap()
+ })
+ .collect();
+ let mut sorted_counts = workspace_counts.clone();
+ sorted_counts.sort();
+ assert_eq!(
+ sorted_counts,
+ vec![1, 2],
+ "expected one window with 1 workspace and one with 2, got {workspace_counts:?}"
+ );
+
+ let dir1_path: Arc<Path> = Path::new(dir1).into();
+ let dir2_path: Arc<Path> = Path::new(dir2).into();
+ let dir3_path: Arc<Path> = Path::new(dir3).into();
+
+ let all_restored_paths: Vec<Vec<Vec<Arc<Path>>>> = restored_windows
+ .iter()
+ .map(|window| {
+ window
+ .read_with(cx, |multi_workspace, cx| {
+ multi_workspace
+ .workspaces()
+ .iter()
+ .map(|ws| ws.read(cx).root_paths(cx))
+ .collect()
+ })
+ .unwrap()
+ })
+ .collect();
+
+ let two_ws_window = all_restored_paths
+ .iter()
+ .find(|paths| paths.len() == 2)
+ .expect("expected a window with 2 workspaces");
+ assert!(
+ two_ws_window.iter().any(|p| p.contains(&dir1_path)),
+ "2-workspace window should contain dir1, got {two_ws_window:?}"
+ );
+ assert!(
+ two_ws_window.iter().any(|p| p.contains(&dir2_path)),
+ "2-workspace window should contain dir2, got {two_ws_window:?}"
+ );
+
+ let one_ws_window = all_restored_paths
+ .iter()
+ .find(|paths| paths.len() == 1)
+ .expect("expected a window with 1 workspace");
+ assert!(
+ one_ws_window[0].contains(&dir3_path),
+ "1-workspace window should contain dir3, got {one_ws_window:?}"
+ );
+
+ // --- Verify the active workspace is preserved ---
+ for window in &restored_windows {
+ let (active_paths, workspace_count) = window
+ .read_with(cx, |multi_workspace, cx| {
+ let active = multi_workspace.workspace();
+ (
+ active.read(cx).root_paths(cx),
+ multi_workspace.workspaces().len(),
+ )
+ })
+ .unwrap();
+
+ if workspace_count == 2 {
+ assert!(
+ active_paths.contains(&dir1_path),
+ "2-workspace window should have dir1 active, got {active_paths:?}"
+ );
+ } else {
+ assert!(
+ active_paths.contains(&dir3_path),
+ "1-workspace window should have dir3 active, got {active_paths:?}"
+ );
+ }
+ }
+ }
}
@@ -1,6 +1,7 @@
use anyhow::{Context as _, Result};
use editor::Editor;
use fs::Fs;
+use gpui::WeakEntity;
use migrator::{migrate_keymap, migrate_settings};
use settings::{KeymapFile, Settings, SettingsStore};
use util::ResultExt;
@@ -22,6 +23,7 @@ pub enum MigrationType {
}
pub struct MigrationBanner {
+ workspace: WeakEntity<Workspace>,
migration_type: Option<MigrationType>,
should_migrate_task: Option<Task<()>>,
markdown: Option<Entity<Markdown>>,
@@ -54,7 +56,7 @@ struct GlobalMigrationNotification(Entity<MigrationNotification>);
impl Global for GlobalMigrationNotification {}
impl MigrationBanner {
- pub fn new(_: &Workspace, cx: &mut Context<Self>) -> Self {
+ pub fn new(workspace: WeakEntity<Workspace>, cx: &mut Context<Self>) -> Self {
if let Some(notifier) = MigrationNotification::try_global(cx) {
cx.subscribe(
¬ifier,
@@ -65,6 +67,7 @@ impl MigrationBanner {
.detach();
}
Self {
+ workspace,
migration_type: None,
should_migrate_task: None,
markdown: None,
@@ -235,22 +238,22 @@ impl Render for MigrationBanner {
),
)
.child(
- Button::new("backup-and-migrate", "Backup and Update").on_click(
+ Button::new("backup-and-migrate", "Backup and Update").on_click({
+ let workspace = self.workspace.clone();
move |_, window, cx| {
let fs = <dyn Fs>::global(cx);
- match migration_type {
+ let task = match migration_type {
Some(MigrationType::Keymap) => {
cx.background_spawn(write_keymap_migration(fs.clone()))
- .detach_and_notify_err(window, cx);
}
Some(MigrationType::Settings) => {
cx.background_spawn(write_settings_migration(fs.clone()))
- .detach_and_notify_err(window, cx);
}
None => unreachable!(),
- }
- },
- ),
+ };
+ task.detach_and_notify_err(workspace.clone(), window, cx);
+ }
+ }),
)
.into_any_element()
}
@@ -1,5 +1,5 @@
use crate::handle_open_request;
-use crate::restorable_workspace_locations;
+use crate::restore_or_create_workspace;
use anyhow::{Context as _, Result, anyhow};
use cli::{CliRequest, CliResponse, ipc::IpcSender};
use cli::{IpcHandshake, ipc};
@@ -30,7 +30,7 @@ use util::ResultExt;
use util::paths::PathWithPosition;
use workspace::PathList;
use workspace::item::ItemHandle;
-use workspace::{AppState, OpenOptions, SerializedWorkspaceLocation, Workspace};
+use workspace::{AppState, MultiWorkspace, OpenOptions, SerializedWorkspaceLocation};
#[derive(Default, Debug)]
pub struct OpenRequest {
@@ -337,7 +337,7 @@ pub async fn open_paths_with_positions(
open_options: workspace::OpenOptions,
cx: &mut AsyncApp,
) -> Result<(
- WindowHandle<Workspace>,
+ WindowHandle<MultiWorkspace>,
Vec<Option<Result<Box<dyn ItemHandle>>>>,
)> {
let mut caret_positions = HashMap::default();
@@ -357,24 +357,29 @@ pub async fn open_paths_with_positions(
})
.collect::<Vec<_>>();
- let (workspace, mut items) = cx
+ let (multi_workspace, mut items) = cx
.update(|cx| workspace::open_paths(&paths, app_state, open_options, cx))
.await?;
if diff_all && !diff_paths.is_empty() {
- if let Ok(diff_view) = workspace.update(cx, |workspace, window, cx| {
- MultiDiffView::open(diff_paths.to_vec(), workspace, window, cx)
+ if let Ok(diff_view) = multi_workspace.update(cx, |multi_workspace, window, cx| {
+ multi_workspace.workspace().update(cx, |workspace, cx| {
+ MultiDiffView::open(diff_paths.to_vec(), workspace, window, cx)
+ })
}) {
if let Some(diff_view) = diff_view.await.log_err() {
items.push(Some(Ok(Box::new(diff_view))));
}
}
} else {
+ let workspace_weak = multi_workspace.read_with(cx, |multi_workspace, _cx| {
+ multi_workspace.workspace().downgrade()
+ })?;
for diff_pair in diff_paths {
let old_path = Path::new(&diff_pair[0]).canonicalize()?;
let new_path = Path::new(&diff_pair[1]).canonicalize()?;
- if let Ok(diff_view) = workspace.update(cx, |workspace, window, cx| {
- FileDiffView::open(old_path, new_path, workspace, window, cx)
+ if let Ok(diff_view) = multi_workspace.update(cx, |_multi_workspace, window, cx| {
+ FileDiffView::open(old_path, new_path, workspace_weak.clone(), window, cx)
}) {
if let Some(diff_view) = diff_view.await.log_err() {
items.push(Some(Ok(Box::new(diff_view))))
@@ -395,7 +400,7 @@ pub async fn open_paths_with_positions(
continue;
};
if let Some(active_editor) = item.downcast::<Editor>() {
- workspace
+ multi_workspace
.update(cx, |_, window, cx| {
active_editor.update(cx, |editor, cx| {
editor.go_to_singleton_buffer_point(point, window, cx);
@@ -405,7 +410,7 @@ pub async fn open_paths_with_positions(
}
}
- Ok((workspace, items))
+ Ok((multi_workspace, items))
}
pub async fn handle_cli_connection(
@@ -488,20 +493,13 @@ async fn open_workspaces(
env: Option<collections::HashMap<String, String>>,
cx: &mut AsyncApp,
) -> Result<()> {
+ if paths.is_empty() && diff_paths.is_empty() && open_new_workspace != Some(true) {
+ return restore_or_create_workspace(app_state, cx).await;
+ }
+
let grouped_locations: Vec<(SerializedWorkspaceLocation, PathList)> =
if paths.is_empty() && diff_paths.is_empty() {
- if open_new_workspace == Some(true) {
- Vec::new()
- } else {
- // The workspace_id from the database is not used;
- // open_paths will assign a new WorkspaceId when opening the workspace.
- restorable_workspace_locations(cx, &app_state)
- .await
- .unwrap_or_default()
- .into_iter()
- .map(|(_workspace_id, location, paths)| (location, paths))
- .collect()
- }
+ Vec::new()
} else {
vec![(
SerializedWorkspaceLocation::Local,
@@ -755,7 +753,7 @@ mod tests {
use serde_json::json;
use std::{sync::Arc, task::Poll};
use util::path;
- use workspace::{AppState, Workspace};
+ use workspace::{AppState, MultiWorkspace};
#[gpui::test]
fn test_parse_ssh_url(cx: &mut TestAppContext) {
@@ -891,10 +889,12 @@ mod tests {
open_workspace_file(path!("/root/dir1"), None, app_state.clone(), cx).await;
assert_eq!(cx.windows().len(), 1);
- let workspace = cx.windows()[0].downcast::<Workspace>().unwrap();
- workspace
- .update(cx, |workspace, _, cx| {
- assert!(workspace.active_item_as::<Editor>(cx).is_none())
+ let multi_workspace = cx.windows()[0].downcast::<MultiWorkspace>().unwrap();
+ multi_workspace
+ .update(cx, |multi_workspace, _, cx| {
+ multi_workspace.workspace().update(cx, |workspace, cx| {
+ assert!(workspace.active_item_as::<Editor>(cx).is_none())
+ });
})
.unwrap();
@@ -902,9 +902,11 @@ mod tests {
open_workspace_file(path!("/root/dir1/file1.txt"), None, app_state.clone(), cx).await;
assert_eq!(cx.windows().len(), 1);
- workspace
- .update(cx, |workspace, _, cx| {
- assert!(workspace.active_item_as::<Editor>(cx).is_some());
+ multi_workspace
+ .update(cx, |multi_workspace, _, cx| {
+ multi_workspace.workspace().update(cx, |workspace, cx| {
+ assert!(workspace.active_item_as::<Editor>(cx).is_some());
+ });
})
.unwrap();
@@ -919,12 +921,14 @@ mod tests {
assert_eq!(cx.windows().len(), 2);
- let workspace_2 = cx.windows()[1].downcast::<Workspace>().unwrap();
- workspace_2
- .update(cx, |workspace, _, cx| {
- assert!(workspace.active_item_as::<Editor>(cx).is_some());
- let items = workspace.items(cx).collect::<Vec<_>>();
- assert_eq!(items.len(), 1, "Workspace should have two items");
+ let multi_workspace_2 = cx.windows()[1].downcast::<MultiWorkspace>().unwrap();
+ multi_workspace_2
+ .update(cx, |multi_workspace, _, cx| {
+ multi_workspace.workspace().update(cx, |workspace, cx| {
+ assert!(workspace.active_item_as::<Editor>(cx).is_some());
+ let items = workspace.items(cx).collect::<Vec<_>>();
+ assert_eq!(items.len(), 1, "Workspace should have two items");
+ });
})
.unwrap();
}
@@ -1000,10 +1004,12 @@ mod tests {
open_workspace_file(path!("/root/file5.txt"), None, app_state.clone(), cx).await;
assert_eq!(cx.windows().len(), 1);
- let workspace_1 = cx.windows()[0].downcast::<Workspace>().unwrap();
- workspace_1
- .update(cx, |workspace, _, cx| {
- assert!(workspace.active_item_as::<Editor>(cx).is_some())
+ let multi_workspace_1 = cx.windows()[0].downcast::<MultiWorkspace>().unwrap();
+ multi_workspace_1
+ .update(cx, |multi_workspace, _, cx| {
+ multi_workspace.workspace().update(cx, |workspace, cx| {
+ assert!(workspace.active_item_as::<Editor>(cx).is_some())
+ });
})
.unwrap();
@@ -1012,10 +1018,12 @@ mod tests {
open_workspace_file(path!("/root/file6.txt"), Some(false), app_state.clone(), cx).await;
assert_eq!(cx.windows().len(), 1);
- workspace_1
- .update(cx, |workspace, _, cx| {
- let items = workspace.items(cx).collect::<Vec<_>>();
- assert_eq!(items.len(), 2, "Workspace should have two items");
+ multi_workspace_1
+ .update(cx, |multi_workspace, _, cx| {
+ multi_workspace.workspace().update(cx, |workspace, cx| {
+ let items = workspace.items(cx).collect::<Vec<_>>();
+ assert_eq!(items.len(), 2, "Workspace should have two items");
+ });
})
.unwrap();
@@ -1024,11 +1032,13 @@ mod tests {
open_workspace_file(path!("/root/file7.txt"), Some(true), app_state.clone(), cx).await;
assert_eq!(cx.windows().len(), 2);
- let workspace_2 = cx.windows()[1].downcast::<Workspace>().unwrap();
- workspace_2
- .update(cx, |workspace, _, cx| {
- let items = workspace.items(cx).collect::<Vec<_>>();
- assert_eq!(items.len(), 1, "Workspace should have two items");
+ let multi_workspace_2 = cx.windows()[1].downcast::<MultiWorkspace>().unwrap();
+ multi_workspace_2
+ .update(cx, |multi_workspace, _, cx| {
+ multi_workspace.workspace().update(cx, |workspace, cx| {
+ let items = workspace.items(cx).collect::<Vec<_>>();
+ assert_eq!(items.len(), 1, "Workspace should have two items");
+ });
})
.unwrap();
}