From aca52097616f5fa03b7b1f38a35487fb4794dd00 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Tue, 24 Mar 2026 11:45:43 -0700 Subject: [PATCH] Make the agent panel have a flexible width (#52276) Release Notes: - The agent panel now has a flexible width, similar to the center panes of the workspace. --- Cargo.lock | 1 - crates/agent_ui/src/agent_panel.rs | 48 +- crates/collab_ui/src/collab_panel.rs | 42 +- crates/collab_ui/src/notification_panel.rs | 66 +-- crates/debugger_ui/src/debugger_panel.rs | 17 +- crates/git_ui/src/git_panel.rs | 17 +- crates/outline_panel/src/outline_panel.rs | 28 +- crates/project_panel/Cargo.toml | 1 - crates/project_panel/src/project_panel.rs | 90 +-- crates/terminal_view/src/persistence.rs | 11 +- crates/terminal_view/src/terminal_panel.rs | 27 +- crates/workspace/src/dock.rs | 332 ++++++++++- crates/workspace/src/pane_group.rs | 45 ++ crates/workspace/src/workspace.rs | 655 +++++++++++++++++++-- crates/zed/src/visual_test_runner.rs | 3 +- 15 files changed, 1022 insertions(+), 361 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e6feea12955d8d4ce2bceba9ed108fa4fe40b49c..65deb7b368c78e485132bed31ef1317b46578101 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13302,7 +13302,6 @@ dependencies = [ "collections", "command_palette_hooks", "criterion", - "db", "editor", "feature_flags", "file_icons", diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 714e07e0de93d88b76964be77348d2ee567059ad..aa88773680faee1dd7b8ceb0d60f93ecc13016c7 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -131,7 +131,6 @@ fn read_legacy_serialized_panel(kvp: &KeyValueStore) -> Option, selected_agent: Option, #[serde(default)] last_active_thread: Option, @@ -743,8 +742,6 @@ pub struct AgentPanel { agent_navigation_menu_handle: PopoverMenuHandle, agent_navigation_menu: Option>, _extension_subscription: Option, - width: Option, - height: Option, zoomed: bool, pending_serialization: Option>>, onboarding: Entity, @@ -766,7 +763,6 @@ impl AgentPanel { return; }; - let width = self.width; let selected_agent_type = self.selected_agent_type.clone(); let start_thread_in = Some(self.start_thread_in); @@ -787,7 +783,6 @@ impl AgentPanel { save_serialized_panel( workspace_id, SerializedAgentPanel { - width, selected_agent: Some(selected_agent_type), last_active_thread, start_thread_in, @@ -876,7 +871,6 @@ impl AgentPanel { 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.clone() { panel.selected_agent_type = selected_agent; } @@ -1079,8 +1073,6 @@ impl AgentPanel { agent_navigation_menu_handle: PopoverMenuHandle::default(), agent_navigation_menu: None, _extension_subscription: extension_subscription, - width: None, - height: None, zoomed: false, pending_serialization: None, onboarding, @@ -3150,23 +3142,16 @@ impl Panel for AgentPanel { }); } - fn size(&self, window: &Window, cx: &App) -> Pixels { + fn default_size(&self, window: &Window, cx: &App) -> Pixels { let settings = AgentSettings::get_global(cx); match self.position(window, cx) { - DockPosition::Left | DockPosition::Right => { - self.width.unwrap_or(settings.default_width) - } - DockPosition::Bottom => self.height.unwrap_or(settings.default_height), + DockPosition::Left | DockPosition::Right => settings.default_width, + DockPosition::Bottom => settings.default_height, } } - fn set_size(&mut self, size: Option, window: &mut Window, cx: &mut Context) { - match self.position(window, cx) { - DockPosition::Left | DockPosition::Right => self.width = size, - DockPosition::Bottom => self.height = size, - } - self.serialize(cx); - cx.notify(); + fn supports_flexible_size(&self, _window: &Window, _cx: &App) -> bool { + true } fn set_active(&mut self, active: bool, window: &mut Window, cx: &mut Context) { @@ -5055,16 +5040,12 @@ mod tests { let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx); - // --- Set up workspace A: width=300, with an active thread --- + // --- Set up workspace A: 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()), @@ -5084,14 +5065,13 @@ mod tests { let agent_type_a = panel_a.read_with(cx, |panel, _cx| panel.selected_agent_type.clone()); - // --- Set up workspace B: ClaudeCode, width=400, no active thread --- + // --- Set up workspace B: ClaudeCode, 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_type = AgentType::Custom { id: "claude-acp".into(), }; @@ -5117,13 +5097,8 @@ mod tests { .expect("panel B load should succeed"); cx.run_until_parked(); - // Workspace A should restore its thread, width, and agent type + // Workspace A should restore its thread 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_type, agent_type_a, "workspace A agent type should be restored" @@ -5134,13 +5109,8 @@ mod tests { ); }); - // Workspace B should restore its own width and agent type, with no thread + // Workspace B should restore its own 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_type, AgentType::Custom { diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index 63fe636dab3cf756ef8cee5eb2f31e45d69c6cdc..3c32e76fea6dcd64b2a8b74c565544954af28c44 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -237,7 +237,6 @@ impl ChannelEditingState { } pub struct CollabPanel { - width: Option, fs: Arc, focus_handle: FocusHandle, channel_clipboard: Option, @@ -263,7 +262,6 @@ pub struct CollabPanel { #[derive(Serialize, Deserialize)] struct SerializedCollabPanel { - width: Option, collapsed_channels: Option>, } @@ -371,7 +369,6 @@ impl CollabPanel { .detach(); let mut this = Self { - width: None, focus_handle: cx.focus_handle(), channel_clipboard: None, fs: workspace.app_state().fs.clone(), @@ -461,7 +458,6 @@ impl CollabPanel { let panel = CollabPanel::new(workspace, window, cx); if let Some(serialized_panel) = serialized_panel { panel.update(cx, |panel, cx| { - panel.width = serialized_panel.width.map(|w| w.round()); panel.collapsed_channels = serialized_panel .collapsed_channels .unwrap_or_else(Vec::new) @@ -492,19 +488,17 @@ impl CollabPanel { else { return; }; - let width = self.width; - let collapsed_channels = self.collapsed_channels.clone(); + let collapsed_channels = if self.collapsed_channels.is_empty() { + None + } else { + Some(self.collapsed_channels.iter().map(|id| id.0).collect()) + }; let kvp = KeyValueStore::global(cx); self.pending_serialization = cx.background_spawn( async move { kvp.write_kvp( serialization_key, - serde_json::to_string(&SerializedCollabPanel { - width, - collapsed_channels: Some( - collapsed_channels.iter().map(|cid| cid.0).collect(), - ), - })?, + serde_json::to_string(&SerializedCollabPanel { collapsed_channels })?, ) .await?; anyhow::Ok(()) @@ -2915,7 +2909,16 @@ impl CollabPanel { Some(result) }; - let width = self.width.unwrap_or(px(240.)); + let width = self + .workspace + .read_with(cx, |workspace, cx| { + workspace + .panel_size_state::(cx) + .and_then(|size_state| size_state.size) + }) + .ok() + .flatten() + .unwrap_or(px(240.)); let root_id = channel.root_id(); div() @@ -3193,17 +3196,8 @@ impl Panel for CollabPanel { }); } - fn size(&self, _window: &Window, cx: &App) -> Pixels { - self.width - .unwrap_or_else(|| CollaborationPanelSettings::get_global(cx).default_width) - } - - fn set_size(&mut self, size: Option, window: &mut Window, cx: &mut Context) { - self.width = size; - cx.notify(); - cx.defer_in(window, |this, _, cx| { - this.serialize(cx); - }); + fn default_size(&self, _window: &Window, cx: &App) -> Pixels { + CollaborationPanelSettings::get_global(cx).default_width } fn icon(&self, _window: &Window, cx: &App) -> Option { diff --git a/crates/collab_ui/src/notification_panel.rs b/crates/collab_ui/src/notification_panel.rs index be11132ac0a781a69a7b2471d9fb96f447e113f7..a40dc154b0f4e498f435080572ed5a7161917ab3 100644 --- a/crates/collab_ui/src/notification_panel.rs +++ b/crates/collab_ui/src/notification_panel.rs @@ -3,7 +3,6 @@ use anyhow::Result; use channel::ChannelStore; use client::{ChannelId, Client, Notification, User, UserStore}; use collections::HashMap; -use db::kvp::KeyValueStore; use futures::StreamExt; use gpui::{ AnyElement, App, AsyncWindowContext, ClickEvent, Context, DismissEvent, Element, Entity, @@ -14,14 +13,14 @@ use gpui::{ use notifications::{NotificationEntry, NotificationEvent, NotificationStore}; use project::Fs; use rpc::proto; -use serde::{Deserialize, Serialize}; + use settings::{Settings, SettingsStore}; use std::{sync::Arc, time::Duration}; use time::{OffsetDateTime, UtcOffset}; use ui::{ Avatar, Button, Icon, IconButton, IconName, Label, Tab, Tooltip, h_flex, prelude::*, v_flex, }; -use util::{ResultExt, TryFutureExt}; +use util::ResultExt; use workspace::notifications::{ Notification as WorkspaceNotification, NotificationId, SuppressEvent, }; @@ -41,10 +40,8 @@ pub struct NotificationPanel { channel_store: Entity, notification_store: Entity, fs: Arc, - width: Option, active: bool, notification_list: ListState, - pending_serialization: Task>, subscriptions: Vec, workspace: WeakEntity, current_notification_toast: Option<(u64, Task<()>)>, @@ -54,11 +51,6 @@ pub struct NotificationPanel { unseen_notifications: Vec, } -#[derive(Serialize, Deserialize)] -struct SerializedNotificationPanel { - width: Option, -} - #[derive(Debug)] pub enum Event { DockPositionChanged, @@ -146,15 +138,13 @@ impl NotificationPanel { channel_store: ChannelStore::global(cx), notification_store: NotificationStore::global(cx), notification_list, - pending_serialization: Task::ready(None), workspace: workspace_handle, focus_handle: cx.focus_handle(), + subscriptions: Default::default(), current_notification_toast: None, - subscriptions: Vec::new(), active: false, - mark_as_read_tasks: HashMap::default(), - width: None, - unseen_notifications: Vec::new(), + mark_as_read_tasks: Default::default(), + unseen_notifications: Default::default(), }; let mut old_dock_position = this.position(window, cx); @@ -186,43 +176,10 @@ impl NotificationPanel { cx: AsyncWindowContext, ) -> Task>> { cx.spawn(async move |cx| { - let kvp = cx.update(|_, cx| KeyValueStore::global(cx))?; - let serialized_panel = - if let Some(panel) = kvp.read_kvp(NOTIFICATION_PANEL_KEY).log_err().flatten() { - Some(serde_json::from_str::(&panel)?) - } else { - None - }; - - workspace.update_in(cx, |workspace, window, cx| { - let panel = Self::new(workspace, window, cx); - if let Some(serialized_panel) = serialized_panel { - panel.update(cx, |panel, cx| { - panel.width = serialized_panel.width.map(|w| w.round()); - cx.notify(); - }); - } - panel - }) + workspace.update_in(cx, |workspace, window, cx| Self::new(workspace, window, cx)) }) } - fn serialize(&mut self, cx: &mut Context) { - let width = self.width; - let kvp = KeyValueStore::global(cx); - self.pending_serialization = cx.background_spawn( - async move { - kvp.write_kvp( - NOTIFICATION_PANEL_KEY.into(), - serde_json::to_string(&SerializedNotificationPanel { width })?, - ) - .await?; - anyhow::Ok(()) - } - .log_err(), - ); - } - fn render_notification( &mut self, ix: usize, @@ -632,15 +589,8 @@ impl Panel for NotificationPanel { }); } - fn size(&self, _: &Window, cx: &App) -> Pixels { - self.width - .unwrap_or_else(|| NotificationPanelSettings::get_global(cx).default_width) - } - - fn set_size(&mut self, size: Option, _: &mut Window, cx: &mut Context) { - self.width = size; - self.serialize(cx); - cx.notify(); + fn default_size(&self, _: &Window, cx: &App) -> Pixels { + NotificationPanelSettings::get_global(cx).default_width } fn set_active(&mut self, active: bool, _: &mut Window, cx: &mut Context) { diff --git a/crates/debugger_ui/src/debugger_panel.rs b/crates/debugger_ui/src/debugger_panel.rs index 39c0dc9d7a79afae19ae27cba244253e37460117..3f2ba98de7519e8343f4bc1791a6d8f7f36b3e86 100644 --- a/crates/debugger_ui/src/debugger_panel.rs +++ b/crates/debugger_ui/src/debugger_panel.rs @@ -55,7 +55,6 @@ impl FeatureFlag for DebuggerHistoryFeatureFlag { const DEBUG_PANEL_KEY: &str = "DebugPanel"; pub struct DebugPanel { - size: Pixels, active_session: Option>, project: Entity, workspace: WeakEntity, @@ -93,7 +92,6 @@ impl DebugPanel { ); Self { - size: px(300.), sessions_with_children: Default::default(), active_session: None, focus_handle, @@ -1572,12 +1570,8 @@ impl Panel for DebugPanel { }); } - fn size(&self, _window: &Window, _: &App) -> Pixels { - self.size - } - - fn set_size(&mut self, size: Option, _window: &mut Window, _cx: &mut Context) { - self.size = size.unwrap_or(px(300.)); + fn default_size(&self, _window: &Window, _: &App) -> Pixels { + px(300.) } fn remote_id() -> Option { @@ -1637,13 +1631,6 @@ impl Render for DebugPanel { } v_flex() - .when(!self.is_zoomed, |this| { - this.when_else( - self.position(window, cx) == DockPosition::Bottom, - |this| this.max_h(self.size), - |this| this.max_w(self.size), - ) - }) .size_full() .key_context("DebugPanel") .child(h_flex().children(self.top_controls_strip(window, cx))) diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 3eb118f82943a26a6a35a8ebac8916d5a5a10ad0..d640a3cd15cfc8368e72dd163687dd00fddd6b4d 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -258,7 +258,6 @@ pub enum Event { #[derive(Serialize, Deserialize)] struct SerializedGitPanel { - width: Option, #[serde(default)] amend_pending: bool, #[serde(default)] @@ -645,7 +644,6 @@ pub struct GitPanel { tracked_count: usize, tracked_staged_count: usize, update_visible_entries_task: Task<()>, - width: Option, pub(crate) workspace: WeakEntity, context_menu: Option<(Entity, Point, Subscription)>, modal_open: bool, @@ -832,7 +830,6 @@ impl GitPanel { tracked_count: 0, tracked_staged_count: 0, update_visible_entries_task: Task::ready(()), - width: None, show_placeholders: false, local_committer: None, local_committer_task: None, @@ -925,7 +922,6 @@ impl GitPanel { } fn serialize(&mut self, cx: &mut Context) { - let width = self.width; let amend_pending = self.amend_pending; let signoff_enabled = self.signoff_enabled; let kvp = KeyValueStore::global(cx); @@ -952,7 +948,6 @@ impl GitPanel { kvp.write_kvp( serialization_key, serde_json::to_string(&SerializedGitPanel { - width, amend_pending, signoff_enabled, })?, @@ -5564,7 +5559,6 @@ impl GitPanel { if let Some(serialized_panel) = serialized_panel { panel.update(cx, |panel, cx| { - panel.width = serialized_panel.width; panel.amend_pending = serialized_panel.amend_pending; panel.signoff_enabled = serialized_panel.signoff_enabled; cx.notify(); @@ -5793,15 +5787,8 @@ impl Panel for GitPanel { }); } - fn size(&self, _: &Window, cx: &App) -> Pixels { - self.width - .unwrap_or_else(|| GitPanelSettings::get_global(cx).default_width) - } - - fn set_size(&mut self, size: Option, _: &mut Window, cx: &mut Context) { - self.width = size; - self.serialize(cx); - cx.notify(); + fn default_size(&self, _: &Window, cx: &App) -> Pixels { + GitPanelSettings::get_global(cx).default_width } fn icon(&self, _: &Window, cx: &App) -> Option { diff --git a/crates/outline_panel/src/outline_panel.rs b/crates/outline_panel/src/outline_panel.rs index cad070392c5279ec3a6aeea0dbf22f6b9585b31f..6b69e8d0cfd8e4acaa6eaff017c3b1d3f23833b9 100644 --- a/crates/outline_panel/src/outline_panel.rs +++ b/crates/outline_panel/src/outline_panel.rs @@ -108,7 +108,6 @@ type HighlightStyleData = Arc, HighlightStyle)>>>; pub struct OutlinePanel { fs: Arc, - width: Option, project: Entity, workspace: WeakEntity, active: bool, @@ -663,7 +662,6 @@ pub enum Event { #[derive(Serialize, Deserialize)] struct SerializedOutlinePanel { - width: Option, active: Option, } @@ -710,12 +708,7 @@ impl OutlinePanel { workspace.update_in(&mut cx, |workspace, window, cx| { let panel = Self::new(workspace, serialized_panel.as_ref(), window, cx); - if let Some(serialized_panel) = serialized_panel { - panel.update(cx, |panel, cx| { - panel.width = serialized_panel.width.map(|px| px.round()); - cx.notify(); - }); - } + panel.update(cx, |_, cx| cx.notify()); panel }) } @@ -911,7 +904,6 @@ impl OutlinePanel { unfolded_dirs: HashMap::default(), selected_entry: SelectedEntry::None, context_menu: None, - width: None, active_item: None, pending_serialization: Task::ready(None), new_entries_for_fs_update: HashSet::default(), @@ -958,14 +950,13 @@ impl OutlinePanel { else { return; }; - let width = self.width; - let active = Some(self.active); + let active = self.active.then_some(true); let kvp = KeyValueStore::global(cx); self.pending_serialization = cx.background_spawn( async move { kvp.write_kvp( serialization_key, - serde_json::to_string(&SerializedOutlinePanel { width, active })?, + serde_json::to_string(&SerializedOutlinePanel { active })?, ) .await?; anyhow::Ok(()) @@ -5000,17 +4991,8 @@ impl Panel for OutlinePanel { }); } - fn size(&self, _: &Window, cx: &App) -> Pixels { - self.width - .unwrap_or_else(|| OutlinePanelSettings::get_global(cx).default_width) - } - - fn set_size(&mut self, size: Option, window: &mut Window, cx: &mut Context) { - self.width = size; - cx.notify(); - cx.defer_in(window, |this, _, cx| { - this.serialize(cx); - }); + fn default_size(&self, _: &Window, cx: &App) -> Pixels { + OutlinePanelSettings::get_global(cx).default_width } fn icon(&self, _: &Window, cx: &App) -> Option { diff --git a/crates/project_panel/Cargo.toml b/crates/project_panel/Cargo.toml index 4306a25132ba460e1b3e48437226bf56020b6834..add85d91c26866edfae1b5790c279ee609edf477 100644 --- a/crates/project_panel/Cargo.toml +++ b/crates/project_panel/Cargo.toml @@ -20,7 +20,6 @@ doctest = false anyhow.workspace = true collections.workspace = true command_palette_hooks.workspace = true -db.workspace = true editor.workspace = true file_icons.workspace = true git_ui.workspace = true diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 41acd58c3cd2fb06ac68d1673a6c9fb21bc46bb5..30d1c9b88155f2d9bc98c5ae9784bb46b8d4bb1f 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -6,7 +6,6 @@ use anyhow::{Context as _, Result}; use client::{ErrorCode, ErrorExt}; use collections::{BTreeSet, HashMap, hash_map}; use command_palette_hooks::CommandPaletteFilter; -use db::kvp::KeyValueStore; use editor::{ Editor, EditorEvent, MultiBufferOffset, items::{ @@ -42,7 +41,7 @@ use project::{ use project_panel_settings::ProjectPanelSettings; use rayon::slice::ParallelSliceMut; use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; use settings::{ DockSide, ProjectPanelEntrySpacing, Settings, SettingsStore, ShowDiagnostics, ShowIndentGuides, update_settings_file, @@ -148,8 +147,6 @@ pub struct ProjectPanel { clipboard: Option, _dragged_entry_destination: Option>, workspace: WeakEntity, - width: Option, - pending_serialization: Task>, diagnostics: HashMap<(WorktreeId, Arc), DiagnosticSeverity>, diagnostic_counts: HashMap<(WorktreeId, Arc), DiagnosticCount>, diagnostic_summary_update: Task<()>, @@ -608,11 +605,6 @@ pub enum Event { Focus, } -#[derive(Serialize, Deserialize)] -struct SerializedProjectPanel { - width: Option, -} - struct DraggedProjectEntryView { selection: SelectedEntry, icon: Option, @@ -878,8 +870,6 @@ impl ProjectPanel { clipboard: None, _dragged_entry_destination: None, workspace: workspace.weak_handle(), - width: None, - pending_serialization: Task::ready(None), diagnostics: Default::default(), diagnostic_counts: Default::default(), diagnostic_summary_update: Task::ready(()), @@ -1000,37 +990,8 @@ impl ProjectPanel { workspace: WeakEntity, mut cx: AsyncWindowContext, ) -> Result> { - let serialized_panel = match workspace - .read_with(&cx, |workspace, _| { - ProjectPanel::serialization_key(workspace) - }) - .ok() - .flatten() - { - Some(serialization_key) => { - let kvp = cx.update(|_, cx| KeyValueStore::global(cx))?; - cx.background_spawn(async move { kvp.read_kvp(&serialization_key) }) - .await - .context("loading project panel") - .log_err() - .flatten() - .map(|panel| serde_json::from_str::(&panel)) - .transpose() - .log_err() - .flatten() - } - None => None, - }; - workspace.update_in(&mut cx, |workspace, window, cx| { - let panel = ProjectPanel::new(workspace, window, cx); - if let Some(serialized_panel) = serialized_panel { - panel.update(cx, |panel, cx| { - panel.width = serialized_panel.width.map(|px| px.round()); - cx.notify(); - }); - } - panel + ProjectPanel::new(workspace, window, cx) }) } @@ -1104,40 +1065,6 @@ impl ProjectPanel { .or_insert(diagnostic_severity); } - fn serialization_key(workspace: &Workspace) -> Option { - workspace - .database_id() - .map(|id| i64::from(id).to_string()) - .or(workspace.session_id()) - .map(|id| format!("{}-{:?}", PROJECT_PANEL_KEY, id)) - } - - fn serialize(&mut self, cx: &mut Context) { - let Some(serialization_key) = self - .workspace - .read_with(cx, |workspace, _| { - ProjectPanel::serialization_key(workspace) - }) - .ok() - .flatten() - else { - return; - }; - let width = self.width; - let kvp = KeyValueStore::global(cx); - self.pending_serialization = cx.background_spawn( - async move { - kvp.write_kvp( - serialization_key, - serde_json::to_string(&SerializedProjectPanel { width })?, - ) - .await?; - anyhow::Ok(()) - } - .log_err(), - ); - } - fn focus_in(&mut self, window: &mut Window, cx: &mut Context) { if !self.focus_handle.contains_focused(window, cx) { cx.emit(Event::Focus); @@ -7252,17 +7179,8 @@ impl Panel for ProjectPanel { }); } - fn size(&self, _: &Window, cx: &App) -> Pixels { - self.width - .unwrap_or_else(|| ProjectPanelSettings::get_global(cx).default_width) - } - - fn set_size(&mut self, size: Option, window: &mut Window, cx: &mut Context) { - self.width = size; - cx.notify(); - cx.defer_in(window, |this, _, cx| { - this.serialize(cx); - }); + fn default_size(&self, _: &Window, cx: &App) -> Pixels { + ProjectPanelSettings::get_global(cx).default_width } fn icon(&self, _: &Window, cx: &App) -> Option { diff --git a/crates/terminal_view/src/persistence.rs b/crates/terminal_view/src/persistence.rs index 8a022e4f74d52e993f2256dadc546a126fe23c9b..50b1e350fa91a4936691b5a35efe0a1666aba9cc 100644 --- a/crates/terminal_view/src/persistence.rs +++ b/crates/terminal_view/src/persistence.rs @@ -6,7 +6,7 @@ use gpui::{AppContext as _, AsyncWindowContext, Axis, Entity, Task, WeakEntity}; use project::Project; use serde::{Deserialize, Serialize}; use std::path::PathBuf; -use ui::{App, Context, Pixels, Window}; +use ui::{App, Context, Window}; use util::ResultExt as _; use db::{ @@ -97,12 +97,7 @@ pub(crate) fn deserialize_terminal_panel( ) -> Task>> { window.spawn(cx, async move |cx| { let terminal_panel = workspace.update_in(cx, |workspace, window, cx| { - cx.new(|cx| { - let mut panel = TerminalPanel::new(workspace, window, cx); - panel.height = serialized_panel.height.map(|h| h.round()); - panel.width = serialized_panel.width.map(|w| w.round()); - panel - }) + cx.new(|cx| TerminalPanel::new(workspace, window, cx)) })?; match &serialized_panel.items { SerializedItems::NoSplits(item_ids) => { @@ -317,8 +312,6 @@ pub(crate) struct SerializedTerminalPanel { pub items: SerializedItems, // A deprecated field, kept for backwards compatibility for the code before terminal splits were introduced. pub active_item_id: Option, - pub width: Option, - pub height: Option, } #[derive(Debug, Serialize, Deserialize)] diff --git a/crates/terminal_view/src/terminal_panel.rs b/crates/terminal_view/src/terminal_panel.rs index 81dbbcb741fe3f3091b4488636c3a7b3cada487b..76639e74fdfd856e896dbf74d645a2d2059d40ed 100644 --- a/crates/terminal_view/src/terminal_panel.rs +++ b/crates/terminal_view/src/terminal_panel.rs @@ -79,8 +79,6 @@ pub struct TerminalPanel { pub(crate) center: PaneGroup, fs: Arc, workspace: WeakEntity, - pub(crate) width: Option, - pub(crate) height: Option, pending_serialization: Task>, pending_terminals_to_add: usize, deferred_tasks: HashMap>, @@ -100,8 +98,6 @@ impl TerminalPanel { fs: workspace.app_state().fs.clone(), workspace: workspace.weak_handle(), pending_serialization: Task::ready(None), - width: None, - height: None, pending_terminals_to_add: 0, deferred_tasks: HashMap::default(), assistant_enabled: false, @@ -928,8 +924,6 @@ impl TerminalPanel { } fn serialize(&mut self, cx: &mut Context) { - let height = self.height; - let width = self.width; let Some(serialization_key) = self .workspace .read_with(cx, |workspace, _| { @@ -960,8 +954,6 @@ impl TerminalPanel { serde_json::to_string(&SerializedTerminalPanel { items, active_item_id: None, - height, - width, })?, ) .await?; @@ -1553,27 +1545,14 @@ impl Panel for TerminalPanel { }); } - fn size(&self, window: &Window, cx: &App) -> Pixels { + fn default_size(&self, window: &Window, cx: &App) -> Pixels { let settings = TerminalSettings::get_global(cx); match self.position(window, cx) { - DockPosition::Left | DockPosition::Right => { - self.width.unwrap_or(settings.default_width) - } - DockPosition::Bottom => self.height.unwrap_or(settings.default_height), + DockPosition::Left | DockPosition::Right => settings.default_width, + DockPosition::Bottom => settings.default_height, } } - fn set_size(&mut self, size: Option, window: &mut Window, cx: &mut Context) { - match self.position(window, cx) { - DockPosition::Left | DockPosition::Right => self.width = size, - DockPosition::Bottom => self.height = size, - } - cx.notify(); - cx.defer_in(window, |this, _, cx| { - this.serialize(cx); - }) - } - fn is_zoomed(&self, _window: &Window, cx: &App) -> bool { self.active_pane.read(cx).is_zoomed() } diff --git a/crates/workspace/src/dock.rs b/crates/workspace/src/dock.rs index 1774539559cd75e621dc8e37261872294746a325..96323ef4e2ed0b826144ce49a8fa261f482ac1bc 100644 --- a/crates/workspace/src/dock.rs +++ b/crates/workspace/src/dock.rs @@ -3,6 +3,7 @@ use crate::{DraggedDock, Event, ModalLayer, Pane}; use crate::{Workspace, status_bar::StatusItemView}; use anyhow::Context as _; use client::proto; +use db::kvp::KeyValueStore; use gpui::{ Action, AnyView, App, Axis, Context, Corner, Entity, EntityId, EventEmitter, FocusHandle, @@ -10,6 +11,7 @@ use gpui::{ Render, SharedString, StyleRefinement, Styled, Subscription, WeakEntity, Window, deferred, div, px, }; +use serde::{Deserialize, Serialize}; use settings::SettingsStore; use std::sync::Arc; use ui::{ @@ -35,8 +37,14 @@ pub trait Panel: Focusable + EventEmitter + Render + Sized { fn position(&self, window: &Window, cx: &App) -> DockPosition; fn position_is_valid(&self, position: DockPosition) -> bool; fn set_position(&mut self, position: DockPosition, window: &mut Window, cx: &mut Context); - fn size(&self, window: &Window, cx: &App) -> Pixels; - fn set_size(&mut self, size: Option, window: &mut Window, cx: &mut Context); + fn default_size(&self, window: &Window, cx: &App) -> Pixels; + fn initial_size_state(&self, _window: &Window, _cx: &App) -> PanelSizeState { + PanelSizeState::default() + } + fn size_state_changed(&mut self, _window: &mut Window, _cx: &mut Context) {} + fn supports_flexible_size(&self, _window: &Window, _cx: &App) -> bool { + false + } fn icon(&self, window: &Window, cx: &App) -> Option; fn icon_tooltip(&self, window: &Window, cx: &App) -> Option<&'static str>; fn toggle_action(&self) -> Box; @@ -75,8 +83,10 @@ pub trait PanelHandle: Send + Sync { fn set_active(&self, active: bool, window: &mut Window, cx: &mut App); fn remote_id(&self) -> Option; fn pane(&self, cx: &App) -> Option>; - fn size(&self, window: &Window, cx: &App) -> Pixels; - fn set_size(&self, size: Option, window: &mut Window, cx: &mut App); + fn default_size(&self, window: &Window, cx: &App) -> Pixels; + fn initial_size_state(&self, window: &Window, cx: &App) -> PanelSizeState; + fn size_state_changed(&self, window: &mut Window, cx: &mut App); + fn supports_flexible_size(&self, window: &Window, cx: &App) -> bool; fn icon(&self, window: &Window, cx: &App) -> Option; fn icon_tooltip(&self, window: &Window, cx: &App) -> Option<&'static str>; fn toggle_action(&self, window: &Window, cx: &App) -> Box; @@ -150,12 +160,20 @@ where T::remote_id() } - fn size(&self, window: &Window, cx: &App) -> Pixels { - self.read(cx).size(window, cx) + fn default_size(&self, window: &Window, cx: &App) -> Pixels { + self.read(cx).default_size(window, cx) + } + + fn initial_size_state(&self, window: &Window, cx: &App) -> PanelSizeState { + self.read(cx).initial_size_state(window, cx) + } + + fn size_state_changed(&self, window: &mut Window, cx: &mut App) { + self.update(cx, |this, cx| this.size_state_changed(window, cx)) } - fn set_size(&self, size: Option, window: &mut Window, cx: &mut App) { - self.update(cx, |this, cx| this.set_size(size, window, cx)) + fn supports_flexible_size(&self, window: &Window, cx: &App) -> bool { + self.read(cx).supports_flexible_size(window, cx) } fn icon(&self, window: &Window, cx: &App) -> Option { @@ -262,8 +280,16 @@ impl DockPosition { } } +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct PanelSizeState { + pub size: Option, + #[serde(default)] + pub flexible_size_ratio: Option, +} + struct PanelEntry { panel: Arc, + size_state: PanelSizeState, _subscriptions: [Subscription; 3], } @@ -272,6 +298,8 @@ pub struct PanelButtons { _settings_subscription: Subscription, } +pub(crate) const PANEL_SIZE_STATE_KEY: &str = "dock_panel_size"; + impl Dock { pub fn new( position: DockPosition, @@ -493,20 +521,34 @@ impl Dock { return; }; + let panel_id = Entity::entity_id(&panel); let was_visible = this.is_open() - && this.visible_panel().is_some_and(|active_panel| { - active_panel.panel_id() == Entity::entity_id(&panel) - }); + && this + .visible_panel() + .is_some_and(|active_panel| active_panel.panel_id() == panel_id); + let size_state = this + .panel_entries + .iter() + .find(|entry| entry.panel.panel_id() == panel_id) + .map(|entry| entry.size_state) + .unwrap_or_default(); + + let previous_axis = this.position.axis(); + let next_axis = new_position.axis(); + let size_state = if previous_axis == next_axis { + size_state + } else { + PanelSizeState::default() + }; this.remove_panel(&panel, window, cx); - new_dock.update(cx, |new_dock, cx| { - new_dock.remove_panel(&panel, window, cx); - }); - new_dock.update(cx, |new_dock, cx| { let index = new_dock.add_panel(panel.clone(), workspace.clone(), window, cx); + if let Some(added_panel) = new_dock.panel_for_id(panel_id).cloned() { + new_dock.set_panel_size_state(added_panel.as_ref(), size_state, cx); + } if was_visible { new_dock.set_open(true, window, cx); new_dock.activate_panel(index, window, cx); @@ -597,10 +639,13 @@ impl Dock { { *active_index += 1; } + let size_state = panel.read(cx).initial_size_state(window, cx); + self.panel_entries.insert( index, PanelEntry { panel: Arc::new(panel.clone()), + size_state, _subscriptions: subscriptions, }, ); @@ -718,28 +763,113 @@ impl Dock { self.panel_entries .iter() .find(|entry| entry.panel.panel_id() == panel.panel_id()) - .map(|entry| entry.panel.size(window, cx)) + .map(|entry| self.resolved_panel_size(entry, window, cx)) } pub fn active_panel_size(&self, window: &Window, cx: &App) -> Option { if self.is_open { self.active_panel_entry() - .map(|entry| entry.panel.size(window, cx)) + .map(|entry| self.resolved_panel_size(entry, window, cx)) } else { None } } + pub fn stored_panel_size( + &self, + panel: &dyn PanelHandle, + window: &Window, + cx: &App, + ) -> Option { + self.panel_entries + .iter() + .find(|entry| entry.panel.panel_id() == panel.panel_id()) + .map(|entry| { + entry + .size_state + .size + .unwrap_or_else(|| entry.panel.default_size(window, cx)) + }) + } + + pub fn stored_panel_size_state(&self, panel: &dyn PanelHandle) -> Option { + self.panel_entries + .iter() + .find(|entry| entry.panel.panel_id() == panel.panel_id()) + .map(|entry| entry.size_state) + } + + pub fn stored_active_panel_size(&self, window: &Window, cx: &App) -> Option { + if self.is_open { + self.active_panel_entry().map(|entry| { + entry + .size_state + .size + .unwrap_or_else(|| entry.panel.default_size(window, cx)) + }) + } else { + None + } + } + + pub fn set_panel_size_state( + &mut self, + panel: &dyn PanelHandle, + size_state: PanelSizeState, + cx: &mut Context, + ) -> bool { + if let Some(entry) = self + .panel_entries + .iter_mut() + .find(|entry| entry.panel.panel_id() == panel.panel_id()) + { + entry.size_state = size_state; + cx.notify(); + true + } else { + false + } + } + pub fn resize_active_panel( &mut self, size: Option, window: &mut Window, cx: &mut Context, ) { - if let Some(entry) = self.active_panel_entry() { + let ratio = size.and_then(|size| self.flexible_size_ratio_for_pixels(size, window, cx)); + self.resize_active_panel_with_ratio(size, ratio, window, cx); + } + + pub fn resize_active_panel_with_ratio( + &mut self, + size: Option, + ratio: Option, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(index) = self.active_panel_index + && let Some(entry) = self.panel_entries.get_mut(index) + { let size = size.map(|size| size.max(RESIZE_HANDLE_SIZE).round()); - entry.panel.set_size(size, window, cx); + if entry.panel.supports_flexible_size(window, cx) { + entry.size_state.flexible_size_ratio = ratio; + } else { + entry.size_state.size = size; + } + + let panel_key = entry.panel.panel_key(); + let size_state = entry.size_state; + let workspace = self.workspace.clone(); + entry.panel.size_state_changed(window, cx); + cx.defer(move |cx| { + if let Some(workspace) = workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + workspace.persist_panel_size_state(panel_key, size_state, cx); + }); + } + }); cx.notify(); } } @@ -750,10 +880,41 @@ impl Dock { window: &mut Window, cx: &mut Context, ) { + let ratio = size.and_then(|size| self.flexible_size_ratio_for_pixels(size, window, cx)); + self.resize_all_panels_with_ratio(size, ratio, window, cx); + } + + pub fn resize_all_panels_with_ratio( + &mut self, + size: Option, + ratio: Option, + window: &mut Window, + cx: &mut Context, + ) { + let mut size_states_to_persist = Vec::new(); + for entry in &mut self.panel_entries { let size = size.map(|size| size.max(RESIZE_HANDLE_SIZE).round()); - entry.panel.set_size(size, window, cx); + if entry.panel.supports_flexible_size(window, cx) { + entry.size_state.flexible_size_ratio = ratio; + } else { + entry.size_state.size = size; + } + entry.panel.size_state_changed(window, cx); + size_states_to_persist.push((entry.panel.panel_key(), entry.size_state)); } + + let workspace = self.workspace.clone(); + cx.defer(move |cx| { + if let Some(workspace) = workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + for (panel_key, size_state) in size_states_to_persist { + workspace.persist_panel_size_state(panel_key, size_state, cx); + } + }); + } + }); + cx.notify(); } @@ -772,21 +933,110 @@ impl Dock { dispatch_context } - pub fn clamp_panel_size(&mut self, max_size: Pixels, window: &mut Window, cx: &mut App) { + pub fn clamp_panel_size(&mut self, max_size: Pixels, window: &Window, cx: &mut App) { let max_size = (max_size - RESIZE_HANDLE_SIZE).abs(); - for panel in self.panel_entries.iter().map(|entry| &entry.panel) { - if panel.size(window, cx) > max_size { - panel.set_size(Some(max_size.max(RESIZE_HANDLE_SIZE)), window, cx); + for entry in &mut self.panel_entries { + if entry.panel.supports_flexible_size(window, cx) { + continue; + } + + let size = entry + .size_state + .size + .unwrap_or_else(|| entry.panel.default_size(window, cx)); + if size > max_size { + entry.size_state.size = Some(max_size.max(RESIZE_HANDLE_SIZE)); + } + } + } + + fn resolved_panel_size(&self, entry: &PanelEntry, window: &Window, cx: &App) -> Pixels { + if self.position.axis() == Axis::Horizontal + && entry.panel.supports_flexible_size(window, cx) + { + if let Some(workspace) = self.workspace.upgrade() { + let workspace = workspace.read(cx); + return resolve_panel_size( + entry.size_state, + entry.panel.as_ref(), + self.position, + workspace, + window, + cx, + ); } } + entry + .size_state + .size + .unwrap_or_else(|| entry.panel.default_size(window, cx)) + } + + fn flexible_size_ratio_for_pixels( + &self, + size: Pixels, + window: &Window, + cx: &App, + ) -> Option { + let workspace = self.workspace.upgrade()?; + workspace + .read(cx) + .flexible_dock_ratio_for_size(self.position, size, window, cx) + } + + pub(crate) fn load_persisted_size_state( + workspace: &Workspace, + panel_key: &'static str, + cx: &App, + ) -> Option { + let workspace_id = workspace + .database_id() + .map(|id| i64::from(id).to_string()) + .or(workspace.session_id())?; + let kvp = KeyValueStore::global(cx); + let scope = kvp.scoped(PANEL_SIZE_STATE_KEY); + scope + .read(&format!("{workspace_id}:{panel_key}")) + .log_err() + .flatten() + .and_then(|json| serde_json::from_str::(&json).log_err()) + } +} + +pub(crate) fn resolve_panel_size( + size_state: PanelSizeState, + panel: &dyn PanelHandle, + position: DockPosition, + workspace: &Workspace, + window: &Window, + cx: &App, +) -> Pixels { + if position.axis() == Axis::Horizontal && panel.supports_flexible_size(window, cx) { + let ratio = size_state + .flexible_size_ratio + .or_else(|| workspace.default_flexible_dock_ratio(position, cx)); + + if let Some(ratio) = ratio { + return workspace + .flexible_dock_size(position, ratio, window, cx) + .unwrap_or_else(|| { + size_state + .size + .unwrap_or_else(|| panel.default_size(window, cx)) + }); + } } + + size_state + .size + .unwrap_or_else(|| panel.default_size(window, cx)) } impl Render for Dock { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let dispatch_context = Self::dispatch_context(); if let Some(entry) = self.visible_entry() { - let size = entry.panel.size(window, cx); + let size = self.resolved_panel_size(entry, window, cx); let position = self.position; let create_resize_handle = || { @@ -1046,7 +1296,8 @@ pub mod test { pub zoomed: bool, pub active: bool, pub focus_handle: FocusHandle, - pub size: Pixels, + pub default_size: Pixels, + pub flexible: bool, pub activation_priority: u32, } actions!(test_only, [ToggleTestPanel]); @@ -1060,10 +1311,22 @@ pub mod test { zoomed: false, active: false, focus_handle: cx.focus_handle(), - size: px(300.), + default_size: px(300.), + flexible: false, activation_priority, } } + + pub fn new_flexible( + position: DockPosition, + activation_priority: u32, + cx: &mut App, + ) -> Self { + Self { + flexible: true, + ..Self::new(position, activation_priority, cx) + } + } } impl Render for TestPanel { @@ -1094,12 +1357,19 @@ pub mod test { cx.update_global::(|_, _| {}); } - fn size(&self, _window: &Window, _: &App) -> Pixels { - self.size + fn default_size(&self, _window: &Window, _: &App) -> Pixels { + self.default_size + } + + fn initial_size_state(&self, _window: &Window, _: &App) -> PanelSizeState { + PanelSizeState { + size: None, + flexible_size_ratio: None, + } } - fn set_size(&mut self, size: Option, _window: &mut Window, _: &mut Context) { - self.size = size.unwrap_or(px(300.)); + fn supports_flexible_size(&self, _window: &Window, _: &App) -> bool { + self.flexible } fn icon(&self, _window: &Window, _: &App) -> Option { diff --git a/crates/workspace/src/pane_group.rs b/crates/workspace/src/pane_group.rs index 0921a19486718c5375ed17ebbb3d7e314546f8d7..3fa4800afb6088e0d106c8b60a835073978e598c 100644 --- a/crates/workspace/src/pane_group.rs +++ b/crates/workspace/src/pane_group.rs @@ -97,6 +97,10 @@ impl PaneGroup { } } + pub fn width_fraction_for_pane(&self, pane: &Entity) -> Option { + self.root.width_fraction_for_pane(pane) + } + pub fn pane_at_pixel_position(&self, coordinate: Point) -> Option<&Entity> { match &self.root { Member::Pane(pane) => Some(pane), @@ -301,6 +305,13 @@ impl Member { }), } } + + fn width_fraction_for_pane(&self, pane: &Entity) -> Option { + match self { + Member::Pane(found) => (found == pane).then_some(1.0), + Member::Axis(axis) => axis.width_fraction_for_pane(pane), + } + } } #[derive(Clone, Copy)] @@ -884,6 +895,40 @@ impl PaneAxis { None } + fn width_fraction_for_pane(&self, pane: &Entity) -> Option { + let flexes = self.flexes.lock(); + let total_flex = flexes.iter().copied().sum::(); + + for (index, member) in self.members.iter().enumerate() { + let child_fraction = if total_flex > 0.0 { + flexes[index] / total_flex + } else { + 1.0 / self.members.len() as f32 + }; + + match member { + Member::Pane(found) => { + if found == pane { + return Some(match self.axis { + Axis::Horizontal => child_fraction, + Axis::Vertical => 1.0, + }); + } + } + Member::Axis(axis) => { + if let Some(descendant_fraction) = axis.width_fraction_for_pane(pane) { + return Some(match self.axis { + Axis::Horizontal => child_fraction * descendant_fraction, + Axis::Vertical => descendant_fraction, + }); + } + } + } + } + + None + } + fn render( &self, basis: usize, diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 8ca13b0eb3b9e334498f4e0e28ed9ad71ed4ac80..3cab18c65575bae6a15a3b501cebd6f7a2ed2e39 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -51,8 +51,8 @@ use futures::{ future::{Shared, try_join_all}, }; use gpui::{ - Action, AnyEntity, AnyView, AnyWeakView, App, AsyncApp, AsyncWindowContext, Bounds, Context, - CursorStyle, Decorations, DragMoveEvent, Entity, EntityId, EventEmitter, FocusHandle, + Action, AnyEntity, AnyView, AnyWeakView, App, AsyncApp, AsyncWindowContext, Axis, Bounds, + Context, CursorStyle, Decorations, DragMoveEvent, Entity, EntityId, EventEmitter, FocusHandle, Focusable, Global, HitboxBehavior, Hsla, KeyContext, Keystroke, ManagedView, MouseButton, PathPromptOptions, Point, PromptLevel, Render, ResizeEdge, Size, Stateful, Subscription, SystemWindowTabController, Task, Tiling, WeakEntity, WindowBounds, WindowHandle, WindowId, @@ -2127,6 +2127,159 @@ impl Workspace { } } + pub fn panel_size_state(&self, cx: &App) -> Option { + self.all_docks().into_iter().find_map(|dock| { + let dock = dock.read(cx); + let panel = dock.panel::()?; + dock.stored_panel_size_state(&panel) + }) + } + + pub fn persisted_panel_size_state( + &self, + panel_key: &'static str, + cx: &App, + ) -> Option { + dock::Dock::load_persisted_size_state(self, panel_key, cx) + } + + pub fn persist_panel_size_state( + &self, + panel_key: &str, + size_state: dock::PanelSizeState, + cx: &mut App, + ) { + let Some(workspace_id) = self + .database_id() + .map(|id| i64::from(id).to_string()) + .or(self.session_id()) + else { + return; + }; + + let kvp = db::kvp::KeyValueStore::global(cx); + let panel_key = panel_key.to_string(); + cx.background_spawn(async move { + let scope = kvp.scoped(dock::PANEL_SIZE_STATE_KEY); + scope + .write( + format!("{workspace_id}:{panel_key}"), + serde_json::to_string(&size_state)?, + ) + .await + }) + .detach_and_log_err(cx); + } + + pub fn set_panel_size_state( + &mut self, + size_state: dock::PanelSizeState, + window: &mut Window, + cx: &mut Context, + ) -> bool { + let Some(panel) = self.panel::(cx) else { + return false; + }; + + let dock = self.dock_at_position(panel.position(window, cx)); + let did_set = dock.update(cx, |dock, cx| { + dock.set_panel_size_state(&panel, size_state, cx) + }); + + if did_set { + self.persist_panel_size_state(T::panel_key(), size_state, cx); + } + + did_set + } + + pub fn flexible_dock_size( + &self, + position: DockPosition, + ratio: f32, + window: &Window, + cx: &App, + ) -> Option { + if position.axis() != Axis::Horizontal { + return None; + } + + let available_width = self.available_width_for_horizontal_dock(position, window, cx)?; + Some((available_width * ratio.clamp(0.0, 1.0)).max(RESIZE_HANDLE_SIZE)) + } + + pub fn resolved_dock_panel_size( + &self, + dock: &Dock, + panel: &dyn PanelHandle, + window: &Window, + cx: &App, + ) -> Pixels { + let size_state = dock.stored_panel_size_state(panel).unwrap_or_default(); + dock::resolve_panel_size(size_state, panel, dock.position(), self, window, cx) + } + + pub fn flexible_dock_ratio_for_size( + &self, + position: DockPosition, + size: Pixels, + window: &Window, + cx: &App, + ) -> Option { + if position.axis() != Axis::Horizontal { + return None; + } + + let available_width = self.available_width_for_horizontal_dock(position, window, cx)?; + let available_width = available_width.max(RESIZE_HANDLE_SIZE); + Some((size / available_width).clamp(0.0, 1.0)) + } + + fn available_width_for_horizontal_dock( + &self, + position: DockPosition, + window: &Window, + cx: &App, + ) -> Option { + let workspace_width = self.bounds.size.width; + if workspace_width <= Pixels::ZERO { + return None; + } + + let opposite_position = match position { + DockPosition::Left => DockPosition::Right, + DockPosition::Right => DockPosition::Left, + DockPosition::Bottom => return None, + }; + + let opposite_width = self + .dock_at_position(opposite_position) + .read(cx) + .stored_active_panel_size(window, cx) + .unwrap_or(Pixels::ZERO); + + Some((workspace_width - opposite_width).max(RESIZE_HANDLE_SIZE)) + } + + pub fn default_flexible_dock_ratio(&self, position: DockPosition, cx: &App) -> Option { + if position.axis() != Axis::Horizontal { + return None; + } + + if self + .center + .panes() + .iter() + .all(|pane| pane.read(cx).items_len() == 0) + { + return Some(1.0); + } + + let pane = self.last_active_center_pane.clone()?.upgrade()?; + let pane_fraction = self.center.width_fraction_for_pane(&pane).unwrap_or(1.0); + Some((pane_fraction / (1.0 + pane_fraction)).clamp(0.0, 1.0)) + } + pub fn is_edited(&self) -> bool { self.window_edited } @@ -2144,9 +2297,25 @@ impl Workspace { let dock_position = panel.position(window, cx); let dock = self.dock_at_position(dock_position); let any_panel = panel.to_any(); + let persisted_size_state = + self.persisted_panel_size_state(T::panel_key(), cx) + .or_else(|| { + load_legacy_panel_size(T::panel_key(), dock_position, self, cx).map(|size| { + let state = dock::PanelSizeState { + size: Some(size), + flexible_size_ratio: None, + }; + self.persist_panel_size_state(T::panel_key(), state, cx); + state + }) + }); dock.update(cx, |dock, cx| { - dock.add_panel(panel, self.weak_self.clone(), window, cx) + let index = dock.add_panel(panel.clone(), self.weak_self.clone(), window, cx); + if let Some(size_state) = persisted_size_state { + dock.set_panel_size_state(&panel, size_state, cx); + } + index }); cx.emit(Event::PanelAdded(any_panel)); @@ -4730,11 +4899,15 @@ impl Workspace { .into_iter() .find(|dock| dock.focus_handle(cx).contains_focused(window, cx)); - if let Some(dock) = active_dock { - let Some(panel_size) = dock.read(cx).active_panel_size(window, cx) else { + if let Some(dock_entity) = active_dock { + let dock = dock_entity.read(cx); + let Some(panel_size) = dock + .active_panel() + .map(|panel| self.resolved_dock_panel_size(&dock, panel.as_ref(), window, cx)) + else { return; }; - match dock.read(cx).position() { + match dock.position() { DockPosition::Left => self.resize_left_dock(panel_size + amount, window, cx), DockPosition::Bottom => self.resize_bottom_dock(panel_size + amount, window, cx), DockPosition::Right => self.resize_right_dock(panel_size + amount, window, cx), @@ -6754,24 +6927,33 @@ impl Workspace { |workspace: &mut Workspace, _: &ResetActiveDockSize, window, cx| { for dock in workspace.all_docks() { if dock.focus_handle(cx).contains_focused(window, cx) { - let Some(panel) = dock.read(cx).active_panel() else { - return; - }; - - // Set to `None`, then the size will fall back to the default. - panel.clone().set_size(None, window, cx); - + let panel = dock.read(cx).active_panel().cloned(); + if let Some(panel) = panel { + dock.update(cx, |dock, cx| { + dock.set_panel_size_state( + panel.as_ref(), + dock::PanelSizeState::default(), + cx, + ); + }); + } return; } } }, )) .on_action(cx.listener( - |workspace: &mut Workspace, _: &ResetOpenDocksSize, window, cx| { + |workspace: &mut Workspace, _: &ResetOpenDocksSize, _window, cx| { for dock in workspace.all_docks() { - if let Some(panel) = dock.read(cx).visible_panel() { - // Set to `None`, then the size will fall back to the default. - panel.clone().set_size(None, window, cx); + let panel = dock.read(cx).visible_panel().cloned(); + if let Some(panel) = panel { + dock.update(cx, |dock, cx| { + dock.set_panel_size_state( + panel.as_ref(), + dock::PanelSizeState::default(), + cx, + ); + }); } } }, @@ -7183,21 +7365,22 @@ impl Workspace { self.right_dock.read_with(cx, |right_dock, cx| { let right_dock_size = right_dock - .active_panel_size(window, cx) + .stored_active_panel_size(window, cx) .unwrap_or(Pixels::ZERO); if right_dock_size + size > workspace_width { size = workspace_width - right_dock_size } }); + let ratio = self.flexible_dock_ratio_for_size(DockPosition::Left, size, window, cx); self.left_dock.update(cx, |left_dock, cx| { if WorkspaceSettings::get_global(cx) .resize_all_panels_in_dock .contains(&DockPosition::Left) { - left_dock.resize_all_panels(Some(size), window, cx); + left_dock.resize_all_panels_with_ratio(Some(size), ratio, window, cx); } else { - left_dock.resize_active_panel(Some(size), window, cx); + left_dock.resize_active_panel_with_ratio(Some(size), ratio, window, cx); } }); } @@ -7207,20 +7390,21 @@ impl Workspace { let mut size = new_size.min(workspace_width - RESIZE_HANDLE_SIZE); self.left_dock.read_with(cx, |left_dock, cx| { let left_dock_size = left_dock - .active_panel_size(window, cx) + .stored_active_panel_size(window, cx) .unwrap_or(Pixels::ZERO); if left_dock_size + size > workspace_width { size = workspace_width - left_dock_size } }); + let ratio = self.flexible_dock_ratio_for_size(DockPosition::Right, size, window, cx); self.right_dock.update(cx, |right_dock, cx| { if WorkspaceSettings::get_global(cx) .resize_all_panels_in_dock .contains(&DockPosition::Right) { - right_dock.resize_all_panels(Some(size), window, cx); + right_dock.resize_all_panels_with_ratio(Some(size), ratio, window, cx); } else { - right_dock.resize_active_panel(Some(size), window, cx); + right_dock.resize_active_panel_with_ratio(Some(size), ratio, window, cx); } }); } @@ -7615,7 +7799,10 @@ fn adjust_active_dock_size_by_px( return; }; let dock = active_dock.read(cx); - let Some(panel_size) = dock.active_panel_size(window, cx) else { + let Some(panel_size) = dock + .active_panel() + .map(|panel| workspace.resolved_dock_panel_size(&dock, panel.as_ref(), window, cx)) + else { return; }; let dock_pos = dock.position(); @@ -7631,10 +7818,12 @@ fn adjust_open_docks_size_by_px( let docks = workspace .all_docks() .into_iter() - .filter_map(|dock| { - if dock.read(cx).is_open() { - let dock = dock.read(cx); - let panel_size = dock.active_panel_size(window, cx)?; + .filter_map(|dock_entity| { + let dock = dock_entity.read(cx); + if dock.is_open() { + let panel_size = dock.active_panel().map(|panel| { + workspace.resolved_dock_panel_size(&dock, panel.as_ref(), window, cx) + })?; let dock_pos = dock.position(); Some((panel_size, dock_pos, px)) } else { @@ -10111,6 +10300,57 @@ pub fn with_active_or_new_workspace( } } +/// Reads a panel's pixel size from its legacy KVP format and deletes the legacy +/// key. This migration path only runs once per panel per workspace. +fn load_legacy_panel_size( + panel_key: &str, + dock_position: DockPosition, + workspace: &Workspace, + cx: &mut App, +) -> Option { + #[derive(Deserialize)] + struct LegacyPanelState { + #[serde(default)] + width: Option, + #[serde(default)] + height: Option, + } + + let workspace_id = workspace + .database_id() + .map(|id| i64::from(id).to_string()) + .or_else(|| workspace.session_id())?; + + let legacy_key = match panel_key { + "ProjectPanel" => { + format!("{}-{:?}", "ProjectPanel", workspace_id) + } + "OutlinePanel" => { + format!("{}-{:?}", "OutlinePanel", workspace_id) + } + "GitPanel" => { + format!("{}-{:?}", "GitPanel", workspace_id) + } + "TerminalPanel" => { + format!("{:?}-{:?}", "TerminalPanel", workspace_id) + } + _ => return None, + }; + + let kvp = db::kvp::KeyValueStore::global(cx); + let json = kvp.read_kvp(&legacy_key).log_err().flatten()?; + let state = serde_json::from_str::(&json).log_err()?; + let size = match dock_position { + DockPosition::Bottom => state.height, + DockPosition::Left | DockPosition::Right => state.width, + }?; + + cx.background_spawn(async move { kvp.delete_kvp(legacy_key).await }) + .detach_and_log_err(cx); + + Some(size) +} + #[cfg(test)] mod tests { use std::{cell::RefCell, rc::Rc, sync::Arc, time::Duration}; @@ -11887,6 +12127,357 @@ mod tests { assert_eq!(active_item.item_id(), last_item.item_id()); }); } + + #[gpui::test] + async fn test_flexible_dock_sizing(cx: &mut gpui::TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + let project = Project::test(fs, [], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + + workspace.update(cx, |workspace, _cx| { + workspace.bounds.size.width = px(800.); + }); + + workspace.update_in(cx, |workspace, window, cx| { + let panel = cx.new(|cx| TestPanel::new_flexible(DockPosition::Right, 100, cx)); + workspace.add_panel(panel, window, cx); + workspace.toggle_dock(DockPosition::Right, window, cx); + + let dock = workspace.right_dock().read(cx); + let workspace_width = workspace.bounds.size.width; + let expanded_width = dock + .active_panel() + .map(|panel| workspace.resolved_dock_panel_size(&dock, panel.as_ref(), window, cx)) + .expect("flexible dock should fill the center when there are no tabs"); + + assert_eq!(expanded_width, workspace_width); + }); + + let (panel, resized_width, ratio_basis_width) = + workspace.update_in(cx, |workspace, window, cx| { + let item = cx.new(|cx| { + TestItem::new(cx).with_project_items(&[TestProjectItem::new(1, "one.txt", cx)]) + }); + workspace.add_item_to_active_pane(Box::new(item), None, true, window, cx); + + let dock = workspace.right_dock().read(cx); + let workspace_width = workspace.bounds.size.width; + let initial_width = dock + .active_panel() + .map(|panel| { + workspace.resolved_dock_panel_size(&dock, panel.as_ref(), window, cx) + }) + .expect("flexible dock should have an initial width"); + + assert_eq!(initial_width, workspace_width / 2.); + + workspace.resize_right_dock(px(300.), window, cx); + + let dock = workspace.right_dock().read(cx); + let resized_width = dock + .active_panel() + .map(|panel| { + workspace.resolved_dock_panel_size(&dock, panel.as_ref(), window, cx) + }) + .expect("flexible dock should keep its resized width"); + + assert_eq!(resized_width, px(300.)); + + let panel = workspace + .right_dock() + .read(cx) + .visible_panel() + .expect("flexible dock should have a visible panel") + .panel_id(); + + (panel, resized_width, workspace_width) + }); + + workspace.update_in(cx, |workspace, window, cx| { + workspace.toggle_dock(DockPosition::Right, window, cx); + workspace.toggle_dock(DockPosition::Right, window, cx); + + let dock = workspace.right_dock().read(cx); + let reopened_width = dock + .active_panel() + .map(|panel| workspace.resolved_dock_panel_size(&dock, panel.as_ref(), window, cx)) + .expect("flexible dock should restore when reopened"); + + assert_eq!(reopened_width, resized_width); + + let right_dock = workspace.right_dock().read(cx); + let flexible_panel = right_dock + .visible_panel() + .expect("flexible dock should still have a visible panel"); + assert_eq!(flexible_panel.panel_id(), panel); + assert_eq!( + right_dock + .stored_panel_size_state(flexible_panel.as_ref()) + .and_then(|size_state| size_state.flexible_size_ratio), + Some(resized_width.to_f64() as f32 / workspace.bounds.size.width.to_f64() as f32) + ); + }); + + workspace.update_in(cx, |workspace, window, cx| { + workspace.split_pane( + workspace.active_pane().clone(), + SplitDirection::Right, + window, + cx, + ); + + let dock = workspace.right_dock().read(cx); + let split_width = dock + .active_panel() + .map(|panel| workspace.resolved_dock_panel_size(&dock, panel.as_ref(), window, cx)) + .expect("flexible dock should keep its user-resized proportion"); + + assert_eq!(split_width, px(300.)); + + workspace.bounds.size.width = px(1600.); + + let dock = workspace.right_dock().read(cx); + let resized_window_width = dock + .active_panel() + .map(|panel| workspace.resolved_dock_panel_size(&dock, panel.as_ref(), window, cx)) + .expect("flexible dock should preserve proportional size on window resize"); + + assert_eq!( + resized_window_width, + workspace.bounds.size.width + * (resized_width.to_f64() as f32 / ratio_basis_width.to_f64() as f32) + ); + }); + } + + #[gpui::test] + async fn test_panel_size_state_persistence(cx: &mut gpui::TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + // Fixed-width panel: pixel size is persisted to KVP and restored on re-add. + { + let project = Project::test(fs.clone(), [], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + + workspace.update(cx, |workspace, _cx| { + workspace.set_random_database_id(); + workspace.bounds.size.width = px(800.); + }); + + let panel = workspace.update_in(cx, |workspace, window, cx| { + let panel = cx.new(|cx| TestPanel::new(DockPosition::Left, 100, cx)); + workspace.add_panel(panel.clone(), window, cx); + workspace.toggle_dock(DockPosition::Left, window, cx); + panel + }); + + workspace.update_in(cx, |workspace, window, cx| { + workspace.resize_left_dock(px(350.), window, cx); + }); + + cx.run_until_parked(); + + let persisted = workspace.read_with(cx, |workspace, cx| { + workspace.persisted_panel_size_state(TestPanel::panel_key(), cx) + }); + assert_eq!( + persisted.and_then(|s| s.size), + Some(px(350.)), + "fixed-width panel size should be persisted to KVP" + ); + + // Remove the panel and re-add a fresh instance with the same key. + // The new instance should have its size state restored from KVP. + workspace.update_in(cx, |workspace, window, cx| { + workspace.remove_panel(&panel, window, cx); + }); + + workspace.update_in(cx, |workspace, window, cx| { + let new_panel = cx.new(|cx| TestPanel::new(DockPosition::Left, 100, cx)); + workspace.add_panel(new_panel, window, cx); + + let left_dock = workspace.left_dock().read(cx); + let size_state = left_dock + .panel::() + .and_then(|p| left_dock.stored_panel_size_state(&p)); + assert_eq!( + size_state.and_then(|s| s.size), + Some(px(350.)), + "re-added fixed-width panel should restore persisted size from KVP" + ); + }); + } + + // Flexible panel: both pixel size and ratio are persisted and restored. + { + let project = Project::test(fs.clone(), [], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + + workspace.update(cx, |workspace, _cx| { + workspace.set_random_database_id(); + workspace.bounds.size.width = px(800.); + }); + + let panel = workspace.update_in(cx, |workspace, window, cx| { + let item = cx.new(|cx| { + TestItem::new(cx).with_project_items(&[TestProjectItem::new(1, "one.txt", cx)]) + }); + workspace.add_item_to_active_pane(Box::new(item), None, true, window, cx); + + let panel = cx.new(|cx| TestPanel::new_flexible(DockPosition::Right, 100, cx)); + workspace.add_panel(panel.clone(), window, cx); + workspace.toggle_dock(DockPosition::Right, window, cx); + panel + }); + + workspace.update_in(cx, |workspace, window, cx| { + workspace.resize_right_dock(px(300.), window, cx); + }); + + cx.run_until_parked(); + + let persisted = workspace + .read_with(cx, |workspace, cx| { + workspace.persisted_panel_size_state(TestPanel::panel_key(), cx) + }) + .expect("flexible panel state should be persisted to KVP"); + assert_eq!( + persisted.size, None, + "flexible panel should not persist a redundant pixel size" + ); + let original_ratio = persisted + .flexible_size_ratio + .expect("flexible panel ratio should be persisted"); + + // Remove the panel and re-add: both size and ratio should be restored. + workspace.update_in(cx, |workspace, window, cx| { + workspace.remove_panel(&panel, window, cx); + }); + + workspace.update_in(cx, |workspace, window, cx| { + let new_panel = cx.new(|cx| TestPanel::new_flexible(DockPosition::Right, 100, cx)); + workspace.add_panel(new_panel, window, cx); + + let right_dock = workspace.right_dock().read(cx); + let size_state = right_dock + .panel::() + .and_then(|p| right_dock.stored_panel_size_state(&p)) + .expect("re-added flexible panel should have restored size state from KVP"); + assert_eq!( + size_state.size, None, + "re-added flexible panel should not have a persisted pixel size" + ); + assert_eq!( + size_state.flexible_size_ratio, + Some(original_ratio), + "re-added flexible panel should restore persisted ratio" + ); + }); + } + } + + #[gpui::test] + async fn test_flexible_panel_left_dock_sizing(cx: &mut gpui::TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + let project = Project::test(fs, [], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + + workspace.update(cx, |workspace, _cx| { + workspace.bounds.size.width = px(900.); + }); + + // Step 1: Add a tab to the center pane then open a flexible panel in the left + // dock. With one full-width center pane the default ratio is 0.5, so the panel + // and the center pane each take half the workspace width. + workspace.update_in(cx, |workspace, window, cx| { + let item = cx.new(|cx| { + TestItem::new(cx).with_project_items(&[TestProjectItem::new(1, "one.txt", cx)]) + }); + workspace.add_item_to_active_pane(Box::new(item), None, true, window, cx); + + let panel = cx.new(|cx| TestPanel::new_flexible(DockPosition::Left, 100, cx)); + workspace.add_panel(panel, window, cx); + workspace.toggle_dock(DockPosition::Left, window, cx); + + let left_dock = workspace.left_dock().read(cx); + let left_width = left_dock + .active_panel() + .map(|p| workspace.resolved_dock_panel_size(&left_dock, p.as_ref(), window, cx)) + .expect("left dock should have an active panel"); + + assert_eq!( + left_width, + workspace.bounds.size.width / 2., + "flexible left panel should split evenly with the center pane" + ); + }); + + // Step 2: Split the center pane vertically (top/bottom). Vertical splits do not + // change horizontal width fractions, so the flexible panel stays at the same + // width as each half of the split. + workspace.update_in(cx, |workspace, window, cx| { + workspace.split_pane( + workspace.active_pane().clone(), + SplitDirection::Down, + window, + cx, + ); + + let left_dock = workspace.left_dock().read(cx); + let left_width = left_dock + .active_panel() + .map(|p| workspace.resolved_dock_panel_size(&left_dock, p.as_ref(), window, cx)) + .expect("left dock should still have an active panel after vertical split"); + + assert_eq!( + left_width, + workspace.bounds.size.width / 2., + "flexible left panel width should match each vertically-split pane" + ); + }); + + // Step 3: Open a fixed-width panel in the right dock. The right dock's default + // size reduces the available width, so the flexible left panel and the center + // panes all shrink proportionally to accommodate it. + workspace.update_in(cx, |workspace, window, cx| { + let panel = cx.new(|cx| TestPanel::new(DockPosition::Right, 200, cx)); + workspace.add_panel(panel, window, cx); + workspace.toggle_dock(DockPosition::Right, window, cx); + + let right_dock = workspace.right_dock().read(cx); + let right_width = right_dock + .active_panel() + .map(|p| workspace.resolved_dock_panel_size(&right_dock, p.as_ref(), window, cx)) + .expect("right dock should have an active panel"); + + let left_dock = workspace.left_dock().read(cx); + let left_width = left_dock + .active_panel() + .map(|p| workspace.resolved_dock_panel_size(&left_dock, p.as_ref(), window, cx)) + .expect("left dock should still have an active panel"); + + let available_width = workspace.bounds.size.width - right_width; + assert_eq!( + left_width, + available_width / 2., + "flexible left panel should shrink proportionally as the right dock takes space" + ); + }); + } + struct TestModal(FocusHandle); impl TestModal { @@ -11940,12 +12531,10 @@ mod tests { ); assert_eq!( left_dock.read(cx).active_panel_size(window, cx).unwrap(), - panel_1.size(window, cx) + px(300.) ); - left_dock.update(cx, |left_dock, cx| { - left_dock.resize_active_panel(Some(px(1337.)), window, cx) - }); + workspace.resize_left_dock(px(1337.), window, cx); assert_eq!( workspace .right_dock() @@ -12031,7 +12620,7 @@ mod tests { let bottom_dock = workspace.bottom_dock(); assert_eq!( bottom_dock.read(cx).active_panel_size(window, cx).unwrap(), - panel_1.size(window, cx), + px(300.), ); // Close bottom dock and move panel_1 back to the left. bottom_dock.update(cx, |bottom_dock, cx| { diff --git a/crates/zed/src/visual_test_runner.rs b/crates/zed/src/visual_test_runner.rs index b2e88c1d0f9fb861522bce869478c7303aae54eb..e20c8f034833c2f7ebb3ce132c843b37fe1816be 100644 --- a/crates/zed/src/visual_test_runner.rs +++ b/crates/zed/src/visual_test_runner.rs @@ -119,7 +119,7 @@ use { time::Duration, }, util::ResultExt as _, - workspace::{AppState, MultiWorkspace, Panel as _, Workspace}, + workspace::{AppState, MultiWorkspace, Workspace}, zed_actions::OpenSettingsAt, }; @@ -3545,7 +3545,6 @@ edition = "2021" new_workspace.update(cx, |workspace, cx| { if let Some(new_panel) = workspace.panel::(cx) { new_panel.update(cx, |panel, cx| { - panel.set_size(Some(px(480.0)), window, cx); panel.open_external_thread_with_server(stub_agent.clone(), window, cx); }); }