Make the agent panel have a flexible width (#52276)

Max Brunsfeld created

Release Notes:

- The agent panel now has a flexible width, similar to the center panes
of the workspace.

Change summary

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, 1,022 insertions(+), 361 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -13302,7 +13302,6 @@ dependencies = [
  "collections",
  "command_palette_hooks",
  "criterion",
- "db",
  "editor",
  "feature_flags",
  "file_icons",

crates/agent_ui/src/agent_panel.rs 🔗

@@ -131,7 +131,6 @@ fn read_legacy_serialized_panel(kvp: &KeyValueStore) -> Option<SerializedAgentPa
 
 #[derive(Serialize, Deserialize, Debug)]
 struct SerializedAgentPanel {
-    width: Option<Pixels>,
     selected_agent: Option<AgentType>,
     #[serde(default)]
     last_active_thread: Option<SerializedActiveThread>,
@@ -743,8 +742,6 @@ pub struct AgentPanel {
     agent_navigation_menu_handle: PopoverMenuHandle<ContextMenu>,
     agent_navigation_menu: Option<Entity<ContextMenu>>,
     _extension_subscription: Option<Subscription>,
-    width: Option<Pixels>,
-    height: Option<Pixels>,
     zoomed: bool,
     pending_serialization: Option<Task<Result<()>>>,
     onboarding: Entity<AgentPanelOnboarding>,
@@ -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<Pixels>, window: &mut Window, cx: &mut Context<Self>) {
-        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<Self>) {
@@ -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 {

crates/collab_ui/src/collab_panel.rs 🔗

@@ -237,7 +237,6 @@ impl ChannelEditingState {
 }
 
 pub struct CollabPanel {
-    width: Option<Pixels>,
     fs: Arc<dyn Fs>,
     focus_handle: FocusHandle,
     channel_clipboard: Option<ChannelMoveClipboard>,
@@ -263,7 +262,6 @@ pub struct CollabPanel {
 
 #[derive(Serialize, Deserialize)]
 struct SerializedCollabPanel {
-    width: Option<Pixels>,
     collapsed_channels: Option<Vec<u64>>,
 }
 
@@ -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::<Self>(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<Pixels>, window: &mut Window, cx: &mut Context<Self>) {
-        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<ui::IconName> {

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<ChannelStore>,
     notification_store: Entity<NotificationStore>,
     fs: Arc<dyn Fs>,
-    width: Option<Pixels>,
     active: bool,
     notification_list: ListState,
-    pending_serialization: Task<Option<()>>,
     subscriptions: Vec<gpui::Subscription>,
     workspace: WeakEntity<Workspace>,
     current_notification_toast: Option<(u64, Task<()>)>,
@@ -54,11 +51,6 @@ pub struct NotificationPanel {
     unseen_notifications: Vec<NotificationEntry>,
 }
 
-#[derive(Serialize, Deserialize)]
-struct SerializedNotificationPanel {
-    width: Option<Pixels>,
-}
-
 #[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<Result<Entity<Self>>> {
         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::<SerializedNotificationPanel>(&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<Self>) {
-        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<Pixels>, _: &mut Window, cx: &mut Context<Self>) {
-        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<Self>) {

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<Entity<DebugSession>>,
     project: Entity<Project>,
     workspace: WeakEntity<Workspace>,
@@ -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<Pixels>, _window: &mut Window, _cx: &mut Context<Self>) {
-        self.size = size.unwrap_or(px(300.));
+    fn default_size(&self, _window: &Window, _: &App) -> Pixels {
+        px(300.)
     }
 
     fn remote_id() -> Option<proto::PanelId> {
@@ -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)))

crates/git_ui/src/git_panel.rs 🔗

@@ -258,7 +258,6 @@ pub enum Event {
 
 #[derive(Serialize, Deserialize)]
 struct SerializedGitPanel {
-    width: Option<Pixels>,
     #[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<Pixels>,
     pub(crate) workspace: WeakEntity<Workspace>,
     context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, 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<Self>) {
-        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<Pixels>, _: &mut Window, cx: &mut Context<Self>) {
-        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<ui::IconName> {

crates/outline_panel/src/outline_panel.rs 🔗

@@ -108,7 +108,6 @@ type HighlightStyleData = Arc<OnceLock<Vec<(Range<usize>, HighlightStyle)>>>;
 
 pub struct OutlinePanel {
     fs: Arc<dyn Fs>,
-    width: Option<Pixels>,
     project: Entity<Project>,
     workspace: WeakEntity<Workspace>,
     active: bool,
@@ -663,7 +662,6 @@ pub enum Event {
 
 #[derive(Serialize, Deserialize)]
 struct SerializedOutlinePanel {
-    width: Option<Pixels>,
     active: Option<bool>,
 }
 
@@ -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<Pixels>, window: &mut Window, cx: &mut Context<Self>) {
-        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<IconName> {

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

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<ClipboardEntry>,
     _dragged_entry_destination: Option<Arc<Path>>,
     workspace: WeakEntity<Workspace>,
-    width: Option<Pixels>,
-    pending_serialization: Task<Option<()>>,
     diagnostics: HashMap<(WorktreeId, Arc<RelPath>), DiagnosticSeverity>,
     diagnostic_counts: HashMap<(WorktreeId, Arc<RelPath>), DiagnosticCount>,
     diagnostic_summary_update: Task<()>,
@@ -608,11 +605,6 @@ pub enum Event {
     Focus,
 }
 
-#[derive(Serialize, Deserialize)]
-struct SerializedProjectPanel {
-    width: Option<Pixels>,
-}
-
 struct DraggedProjectEntryView {
     selection: SelectedEntry,
     icon: Option<SharedString>,
@@ -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<Workspace>,
         mut cx: AsyncWindowContext,
     ) -> Result<Entity<Self>> {
-        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::<SerializedProjectPanel>(&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<String> {
-        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<Self>) {
-        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<Self>) {
         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<Pixels>, window: &mut Window, cx: &mut Context<Self>) {
-        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<IconName> {

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<anyhow::Result<Entity<TerminalPanel>>> {
     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<u64>,
-    pub width: Option<Pixels>,
-    pub height: Option<Pixels>,
 }
 
 #[derive(Debug, Serialize, Deserialize)]

crates/terminal_view/src/terminal_panel.rs 🔗

@@ -79,8 +79,6 @@ pub struct TerminalPanel {
     pub(crate) center: PaneGroup,
     fs: Arc<dyn Fs>,
     workspace: WeakEntity<Workspace>,
-    pub(crate) width: Option<Pixels>,
-    pub(crate) height: Option<Pixels>,
     pending_serialization: Task<Option<()>>,
     pending_terminals_to_add: usize,
     deferred_tasks: HashMap<TaskId, Task<()>>,
@@ -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<Self>) {
-        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<Pixels>, window: &mut Window, cx: &mut Context<Self>) {
-        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()
     }

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<PanelEvent> + 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<Self>);
-    fn size(&self, window: &Window, cx: &App) -> Pixels;
-    fn set_size(&mut self, size: Option<Pixels>, window: &mut Window, cx: &mut Context<Self>);
+    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<Self>) {}
+    fn supports_flexible_size(&self, _window: &Window, _cx: &App) -> bool {
+        false
+    }
     fn icon(&self, window: &Window, cx: &App) -> Option<ui::IconName>;
     fn icon_tooltip(&self, window: &Window, cx: &App) -> Option<&'static str>;
     fn toggle_action(&self) -> Box<dyn Action>;
@@ -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<proto::PanelId>;
     fn pane(&self, cx: &App) -> Option<Entity<Pane>>;
-    fn size(&self, window: &Window, cx: &App) -> Pixels;
-    fn set_size(&self, size: Option<Pixels>, 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<ui::IconName>;
     fn icon_tooltip(&self, window: &Window, cx: &App) -> Option<&'static str>;
     fn toggle_action(&self, window: &Window, cx: &App) -> Box<dyn Action>;
@@ -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<Pixels>, 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<ui::IconName> {
@@ -262,8 +280,16 @@ impl DockPosition {
     }
 }
 
+#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
+pub struct PanelSizeState {
+    pub size: Option<Pixels>,
+    #[serde(default)]
+    pub flexible_size_ratio: Option<f32>,
+}
+
 struct PanelEntry {
     panel: Arc<dyn PanelHandle>,
+    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<Pixels> {
         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<Pixels> {
+        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<PanelSizeState> {
+        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<Pixels> {
+        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<Self>,
+    ) -> 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<Pixels>,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        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<Pixels>,
+        ratio: Option<f32>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        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<Self>,
     ) {
+        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<Pixels>,
+        ratio: Option<f32>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        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<f32> {
+        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<PanelSizeState> {
+        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::<PanelSizeState>(&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<Self>) -> 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::<SettingsStore, _>(|_, _| {});
         }
 
-        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<Pixels>, _window: &mut Window, _: &mut Context<Self>) {
-            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<ui::IconName> {

crates/workspace/src/pane_group.rs 🔗

@@ -97,6 +97,10 @@ impl PaneGroup {
         }
     }
 
+    pub fn width_fraction_for_pane(&self, pane: &Entity<Pane>) -> Option<f32> {
+        self.root.width_fraction_for_pane(pane)
+    }
+
     pub fn pane_at_pixel_position(&self, coordinate: Point<Pixels>) -> Option<&Entity<Pane>> {
         match &self.root {
             Member::Pane(pane) => Some(pane),
@@ -301,6 +305,13 @@ impl Member {
             }),
         }
     }
+
+    fn width_fraction_for_pane(&self, pane: &Entity<Pane>) -> Option<f32> {
+        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<Pane>) -> Option<f32> {
+        let flexes = self.flexes.lock();
+        let total_flex = flexes.iter().copied().sum::<f32>();
+
+        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,

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<T: Panel>(&self, cx: &App) -> Option<dock::PanelSizeState> {
+        self.all_docks().into_iter().find_map(|dock| {
+            let dock = dock.read(cx);
+            let panel = dock.panel::<T>()?;
+            dock.stored_panel_size_state(&panel)
+        })
+    }
+
+    pub fn persisted_panel_size_state(
+        &self,
+        panel_key: &'static str,
+        cx: &App,
+    ) -> Option<dock::PanelSizeState> {
+        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<T: Panel>(
+        &mut self,
+        size_state: dock::PanelSizeState,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> bool {
+        let Some(panel) = self.panel::<T>(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<Pixels> {
+        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<f32> {
+        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<Pixels> {
+        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<f32> {
+        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<Pixels> {
+    #[derive(Deserialize)]
+    struct LegacyPanelState {
+        #[serde(default)]
+        width: Option<Pixels>,
+        #[serde(default)]
+        height: Option<Pixels>,
+    }
+
+    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::<LegacyPanelState>(&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::<TestPanel>()
+                    .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::<TestPanel>()
+                    .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| {

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::<AgentPanel>(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);
                 });
             }