From 3b626c8ac1b89ba5b233ae9a9c274489fa94f079 Mon Sep 17 00:00:00 2001 From: Yves Ineichen Date: Mon, 22 Dec 2025 00:50:02 +0100 Subject: [PATCH] Allow empty splits on panes (#40245) Draft as a base for continuing the discussion in #8008 : adds a `SplitOperation` enum to support bindings like `["pane::SplitLeft", {"operation": "Clear"}]` To be discussed @MrSubidubi and others: - Naming: Generally not happy with names yet and specifically `Empty` is unclear, e.g., what does this mean for terminal panes? Added placeholder code to split without cloning, but unsure what users would expect in this case. - ~~I removed `SplitAndMoveXyz` actions but I guess we should keep them for backwards compatibility?~~ - May have missed details in the move implementation. Will check the code again for opportunities to refactor more code after we agree on the approach. - ~~Tests should go to `crates/collab/src/tests/integration_tests.rs`?~~ Closes #8008 Release Notes: - Add `pane::Split` mode (`{ClonePane,EmptyPane,MovePane}`) to allow creating an empty buffer. --------- Co-authored-by: Finn Evers Co-authored-by: MrSubidubi --- crates/collab/src/tests/integration_tests.rs | 9 +- crates/file_finder/src/file_finder.rs | 11 +- crates/terminal_view/src/terminal_panel.rs | 140 ++++---- crates/vim/src/command.rs | 40 ++- crates/workspace/src/pane.rs | 351 +++++++++++++++---- crates/workspace/src/workspace.rs | 23 +- crates/zed/src/zed.rs | 2 +- crates/zed/src/zed/app_menus.rs | 8 +- 8 files changed, 417 insertions(+), 167 deletions(-) diff --git a/crates/collab/src/tests/integration_tests.rs b/crates/collab/src/tests/integration_tests.rs index 391e7355ea196dfe25d363472918837ea817f450..cbda16d11168397be92274d00beb4cd33332329f 100644 --- a/crates/collab/src/tests/integration_tests.rs +++ b/crates/collab/src/tests/integration_tests.rs @@ -6745,8 +6745,13 @@ async fn test_preview_tabs(cx: &mut TestAppContext) { }); // Split pane to the right - pane.update(cx, |pane, cx| { - pane.split(workspace::SplitDirection::Right, cx); + pane.update_in(cx, |pane, window, cx| { + pane.split( + workspace::SplitDirection::Right, + workspace::SplitMode::default(), + window, + cx, + ); }); cx.run_until_parked(); let right_pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs index 73b21bb828a598d5bbc53c0ecf4511988c30bc65..1bfd41fa2709e4c46b5177bee6851d91dc86bccb 100644 --- a/crates/file_finder/src/file_finder.rs +++ b/crates/file_finder/src/file_finder.rs @@ -1760,16 +1760,19 @@ impl PickerDelegate for FileFinderDelegate { menu.context(focus_handle) .action( "Split Left", - pane::SplitLeft.boxed_clone(), + pane::SplitLeft::default().boxed_clone(), ) .action( "Split Right", - pane::SplitRight.boxed_clone(), + pane::SplitRight::default().boxed_clone(), + ) + .action( + "Split Up", + pane::SplitUp::default().boxed_clone(), ) - .action("Split Up", pane::SplitUp.boxed_clone()) .action( "Split Down", - pane::SplitDown.boxed_clone(), + pane::SplitDown::default().boxed_clone(), ) } })) diff --git a/crates/terminal_view/src/terminal_panel.rs b/crates/terminal_view/src/terminal_panel.rs index 738a0b4502642423377bdf69b49d26250536761f..2c8779275ae57b708a3d6303ebc98bd7b9552b91 100644 --- a/crates/terminal_view/src/terminal_panel.rs +++ b/crates/terminal_view/src/terminal_panel.rs @@ -30,8 +30,8 @@ use workspace::{ ActivateNextPane, ActivatePane, ActivatePaneDown, ActivatePaneLeft, ActivatePaneRight, ActivatePaneUp, ActivatePreviousPane, DraggedSelection, DraggedTab, ItemId, MoveItemToPane, MoveItemToPaneInDirection, MovePaneDown, MovePaneLeft, MovePaneRight, MovePaneUp, NewTerminal, - Pane, PaneGroup, SplitDirection, SplitDown, SplitLeft, SplitRight, SplitUp, SwapPaneDown, - SwapPaneLeft, SwapPaneRight, SwapPaneUp, ToggleZoom, Workspace, + Pane, PaneGroup, SplitDirection, SplitDown, SplitLeft, SplitMode, SplitRight, SplitUp, + SwapPaneDown, SwapPaneLeft, SwapPaneRight, SwapPaneUp, ToggleZoom, Workspace, dock::{DockPosition, Panel, PanelEvent, PanelHandle}, item::SerializableItem, move_active_item, move_item, pane, @@ -192,10 +192,10 @@ impl TerminalPanel { split_context.clone(), |menu, split_context| menu.context(split_context), ) - .action("Split Right", SplitRight.boxed_clone()) - .action("Split Left", SplitLeft.boxed_clone()) - .action("Split Up", SplitUp.boxed_clone()) - .action("Split Down", SplitDown.boxed_clone()) + .action("Split Right", SplitRight::default().boxed_clone()) + .action("Split Left", SplitLeft::default().boxed_clone()) + .action("Split Up", SplitUp::default().boxed_clone()) + .action("Split Down", SplitDown::default().boxed_clone()) }) .into() } @@ -380,47 +380,49 @@ impl TerminalPanel { } self.serialize(cx); } - &pane::Event::Split { - direction, - clone_active_item, - } => { - if clone_active_item { - let fut = self.new_pane_with_cloned_active_terminal(window, cx); - let pane = pane.clone(); - cx.spawn_in(window, async move |panel, cx| { - let Some(new_pane) = fut.await else { + &pane::Event::Split { direction, mode } => { + match mode { + SplitMode::ClonePane | SplitMode::EmptyPane => { + let clone = matches!(mode, SplitMode::ClonePane); + let new_pane = self.new_pane_with_active_terminal(clone, window, cx); + let pane = pane.clone(); + cx.spawn_in(window, async move |panel, cx| { + let Some(new_pane) = new_pane.await else { + return; + }; + panel + .update_in(cx, |panel, window, cx| { + panel + .center + .split(&pane, &new_pane, direction, cx) + .log_err(); + window.focus(&new_pane.focus_handle(cx), cx); + }) + .ok(); + }) + .detach(); + } + SplitMode::MovePane => { + let Some(item) = + pane.update(cx, |pane, cx| pane.take_active_item(window, cx)) + else { return; }; - panel - .update_in(cx, |panel, window, cx| { - panel - .center - .split(&pane, &new_pane, direction, cx) - .log_err(); - window.focus(&new_pane.focus_handle(cx), cx); - }) - .ok(); - }) - .detach(); - } else { - let Some(item) = pane.update(cx, |pane, cx| pane.take_active_item(window, cx)) - else { - return; - }; - let Ok(project) = self - .workspace - .update(cx, |workspace, _| workspace.project().clone()) - else { - return; - }; - let new_pane = - new_terminal_pane(self.workspace.clone(), project, false, window, cx); - new_pane.update(cx, |pane, cx| { - pane.add_item(item, true, true, None, window, cx); - }); - self.center.split(&pane, &new_pane, direction, cx).log_err(); - window.focus(&new_pane.focus_handle(cx), cx); - } + let Ok(project) = self + .workspace + .update(cx, |workspace, _| workspace.project().clone()) + else { + return; + }; + let new_pane = + new_terminal_pane(self.workspace.clone(), project, false, window, cx); + new_pane.update(cx, |pane, cx| { + pane.add_item(item, true, true, None, window, cx); + }); + self.center.split(&pane, &new_pane, direction, cx).log_err(); + window.focus(&new_pane.focus_handle(cx), cx); + } + }; } pane::Event::Focus => { self.active_pane = pane.clone(); @@ -433,8 +435,9 @@ impl TerminalPanel { } } - fn new_pane_with_cloned_active_terminal( + fn new_pane_with_active_terminal( &mut self, + clone: bool, window: &mut Window, cx: &mut Context, ) -> Task>> { @@ -446,21 +449,34 @@ impl TerminalPanel { let weak_workspace = self.workspace.clone(); let project = workspace.project().clone(); let active_pane = &self.active_pane; - let terminal_view = active_pane - .read(cx) - .active_item() - .and_then(|item| item.downcast::()); - let working_directory = terminal_view - .as_ref() - .and_then(|terminal_view| { - terminal_view - .read(cx) - .terminal() - .read(cx) - .working_directory() - }) - .or_else(|| default_working_directory(workspace, cx)); - let is_zoomed = active_pane.read(cx).is_zoomed(); + let terminal_view = if clone { + active_pane + .read(cx) + .active_item() + .and_then(|item| item.downcast::()) + } else { + None + }; + let working_directory = if clone { + terminal_view + .as_ref() + .and_then(|terminal_view| { + terminal_view + .read(cx) + .terminal() + .read(cx) + .working_directory() + }) + .or_else(|| default_working_directory(workspace, cx)) + } else { + default_working_directory(workspace, cx) + }; + + let is_zoomed = if clone { + active_pane.read(cx).is_zoomed() + } else { + false + }; cx.spawn_in(window, async move |panel, cx| { let terminal = project .update(cx, |project, cx| match terminal_view { @@ -1482,7 +1498,7 @@ impl Render for TerminalPanel { window.focus(&pane.read(cx).focus_handle(cx), cx); } else { let future = - terminal_panel.new_pane_with_cloned_active_terminal(window, cx); + terminal_panel.new_pane_with_active_terminal(true, window, cx); cx.spawn_in(window, async move |terminal_panel, cx| { if let Some(new_pane) = future.await { _ = terminal_panel.update_in( diff --git a/crates/vim/src/command.rs b/crates/vim/src/command.rs index 2228c23f02beb954bdb26b2b36f078249e423d7d..caa90406c9a2ac70eb81cd7705c8223799e59f18 100644 --- a/crates/vim/src/command.rs +++ b/crates/vim/src/command.rs @@ -1468,24 +1468,28 @@ fn generate_commands(_: &App) -> Vec { action.range.replace(range.clone()); Some(Box::new(action)) }), - VimCommand::new(("sp", "lit"), workspace::SplitHorizontal).filename(|_, filename| { - Some( - VimSplit { - vertical: false, - filename, - } - .boxed_clone(), - ) - }), - VimCommand::new(("vs", "plit"), workspace::SplitVertical).filename(|_, filename| { - Some( - VimSplit { - vertical: true, - filename, - } - .boxed_clone(), - ) - }), + VimCommand::new(("sp", "lit"), workspace::SplitHorizontal::default()).filename( + |_, filename| { + Some( + VimSplit { + vertical: false, + filename, + } + .boxed_clone(), + ) + }, + ), + VimCommand::new(("vs", "plit"), workspace::SplitVertical::default()).filename( + |_, filename| { + Some( + VimSplit { + vertical: true, + filename, + } + .boxed_clone(), + ) + }, + ), VimCommand::new(("tabe", "dit"), workspace::NewFile) .filename(|_action, filename| Some(VimEdit { filename }.boxed_clone())), VimCommand::new(("tabnew", ""), workspace::NewFile) diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index dd17c338a935571f4d0fe9d46b3b10fac9ffe218..586a6100e40a0edf3728ecb843b8aece08cb36cc 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -197,6 +197,41 @@ pub struct DeploySearch { pub excluded_files: Option, } +#[derive(Clone, Copy, PartialEq, Debug, Deserialize, JsonSchema, Default)] +#[serde(deny_unknown_fields)] +pub enum SplitMode { + /// Clone the current pane. + #[default] + ClonePane, + /// Create an empty new pane. + EmptyPane, + /// Move the item into a new pane. This will map to nop if only one pane exists. + MovePane, +} + +macro_rules! split_structs { + ($($name:ident => $doc:literal),* $(,)?) => { + $( + #[doc = $doc] + #[derive(Clone, PartialEq, Debug, Deserialize, JsonSchema, Default, Action)] + #[action(namespace = pane)] + #[serde(deny_unknown_fields, default)] + pub struct $name { + pub mode: SplitMode, + } + )* + }; +} + +split_structs!( + SplitLeft => "Splits the pane to the left.", + SplitRight => "Splits the pane to the right.", + SplitUp => "Splits the pane upward.", + SplitDown => "Splits the pane downward.", + SplitHorizontal => "Splits the pane horizontally.", + SplitVertical => "Splits the pane vertically." +); + actions!( pane, [ @@ -218,14 +253,6 @@ actions!( JoinAll, /// Reopens the most recently closed item. ReopenClosedItem, - /// Splits the pane to the left, cloning the current item. - SplitLeft, - /// Splits the pane upward, cloning the current item. - SplitUp, - /// Splits the pane to the right, cloning the current item. - SplitRight, - /// Splits the pane downward, cloning the current item. - SplitDown, /// Splits the pane to the left, moving the current item. SplitAndMoveLeft, /// Splits the pane upward, moving the current item. @@ -234,10 +261,6 @@ actions!( SplitAndMoveRight, /// Splits the pane downward, moving the current item. SplitAndMoveDown, - /// Splits the pane horizontally. - SplitHorizontal, - /// Splits the pane vertically. - SplitVertical, /// Swaps the current item with the one to the left. SwapItemLeft, /// Swaps the current item with the one to the right. @@ -279,7 +302,7 @@ pub enum Event { }, Split { direction: SplitDirection, - clone_active_item: bool, + mode: SplitMode, }, ItemPinned, ItemUnpinned, @@ -311,13 +334,10 @@ impl fmt::Debug for Event { .debug_struct("RemovedItem") .field("item", &item.item_id()) .finish(), - Event::Split { - direction, - clone_active_item, - } => f + Event::Split { direction, mode } => f .debug_struct("Split") .field("direction", direction) - .field("clone_active_item", clone_active_item) + .field("mode", mode) .finish(), Event::JoinAll => f.write_str("JoinAll"), Event::JoinIntoNext => f.write_str("JoinIntoNext"), @@ -2295,10 +2315,7 @@ impl Pane { let save_task = if let Some(project_path) = project_path { let (worktree, path) = project_path.await?; let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id())?; - let new_path = ProjectPath { - worktree_id, - path: path, - }; + let new_path = ProjectPath { worktree_id, path }; pane.update_in(cx, |pane, window, cx| { if let Some(item) = pane.item_for_path(new_path.clone(), cx) { @@ -2357,19 +2374,30 @@ impl Pane { } } - pub fn split(&mut self, direction: SplitDirection, cx: &mut Context) { - cx.emit(Event::Split { - direction, - clone_active_item: true, - }); - } - - pub fn split_and_move(&mut self, direction: SplitDirection, cx: &mut Context) { - if self.items.len() > 1 { + pub fn split( + &mut self, + direction: SplitDirection, + mode: SplitMode, + window: &mut Window, + cx: &mut Context, + ) { + if self.items.len() <= 1 && mode == SplitMode::MovePane { + // MovePane with only one pane present behaves like a SplitEmpty in the opposite direction + let active_item = self.active_item(); cx.emit(Event::Split { - direction, - clone_active_item: false, + direction: direction.opposite(), + mode: SplitMode::EmptyPane, }); + // ensure that we focus the moved pane + // in this case we know that the window is the same as the active_item + if let Some(active_item) = active_item { + cx.defer_in(window, move |_, window, cx| { + let focus_handle = active_item.item_focus_handle(cx); + window.focus(&focus_handle, cx); + }); + } + } else { + cx.emit(Event::Split { direction, mode }); } } @@ -3824,16 +3852,17 @@ fn default_render_tab_bar_buttons( .with_handle(pane.split_item_context_menu_handle.clone()) .menu(move |window, cx| { ContextMenu::build(window, cx, |menu, _, _| { + let mode = SplitMode::MovePane; if can_split_move { - menu.action("Split Right", SplitAndMoveRight.boxed_clone()) - .action("Split Left", SplitAndMoveLeft.boxed_clone()) - .action("Split Up", SplitAndMoveUp.boxed_clone()) - .action("Split Down", SplitAndMoveDown.boxed_clone()) + menu.action("Split Right", SplitRight { mode }.boxed_clone()) + .action("Split Left", SplitLeft { mode }.boxed_clone()) + .action("Split Up", SplitUp { mode }.boxed_clone()) + .action("Split Down", SplitDown { mode }.boxed_clone()) } else { - menu.action("Split Right", SplitRight.boxed_clone()) - .action("Split Left", SplitLeft.boxed_clone()) - .action("Split Up", SplitUp.boxed_clone()) - .action("Split Down", SplitDown.boxed_clone()) + menu.action("Split Right", SplitRight::default().boxed_clone()) + .action("Split Left", SplitLeft::default().boxed_clone()) + .action("Split Up", SplitUp::default().boxed_clone()) + .action("Split Down", SplitDown::default().boxed_clone()) } }) .into() @@ -3892,33 +3921,35 @@ impl Render for Pane { .size_full() .flex_none() .overflow_hidden() - .on_action( - cx.listener(|pane, _: &SplitLeft, _, cx| pane.split(SplitDirection::Left, cx)), - ) - .on_action(cx.listener(|pane, _: &SplitUp, _, cx| pane.split(SplitDirection::Up, cx))) - .on_action(cx.listener(|pane, _: &SplitHorizontal, _, cx| { - pane.split(SplitDirection::horizontal(cx), cx) + .on_action(cx.listener(|pane, split: &SplitLeft, window, cx| { + pane.split(SplitDirection::Left, split.mode, window, cx) })) - .on_action(cx.listener(|pane, _: &SplitVertical, _, cx| { - pane.split(SplitDirection::vertical(cx), cx) + .on_action(cx.listener(|pane, split: &SplitUp, window, cx| { + pane.split(SplitDirection::Up, split.mode, window, cx) })) - .on_action( - cx.listener(|pane, _: &SplitRight, _, cx| pane.split(SplitDirection::Right, cx)), - ) - .on_action( - cx.listener(|pane, _: &SplitDown, _, cx| pane.split(SplitDirection::Down, cx)), - ) - .on_action(cx.listener(|pane, _: &SplitAndMoveUp, _, cx| { - pane.split_and_move(SplitDirection::Up, cx) + .on_action(cx.listener(|pane, split: &SplitHorizontal, window, cx| { + pane.split(SplitDirection::horizontal(cx), split.mode, window, cx) + })) + .on_action(cx.listener(|pane, split: &SplitVertical, window, cx| { + pane.split(SplitDirection::vertical(cx), split.mode, window, cx) })) - .on_action(cx.listener(|pane, _: &SplitAndMoveDown, _, cx| { - pane.split_and_move(SplitDirection::Down, cx) + .on_action(cx.listener(|pane, split: &SplitRight, window, cx| { + pane.split(SplitDirection::Right, split.mode, window, cx) })) - .on_action(cx.listener(|pane, _: &SplitAndMoveLeft, _, cx| { - pane.split_and_move(SplitDirection::Left, cx) + .on_action(cx.listener(|pane, split: &SplitDown, window, cx| { + pane.split(SplitDirection::Down, split.mode, window, cx) })) - .on_action(cx.listener(|pane, _: &SplitAndMoveRight, _, cx| { - pane.split_and_move(SplitDirection::Right, cx) + .on_action(cx.listener(|pane, _: &SplitAndMoveUp, window, cx| { + pane.split(SplitDirection::Up, SplitMode::MovePane, window, cx) + })) + .on_action(cx.listener(|pane, _: &SplitAndMoveDown, window, cx| { + pane.split(SplitDirection::Down, SplitMode::MovePane, window, cx) + })) + .on_action(cx.listener(|pane, _: &SplitAndMoveLeft, window, cx| { + pane.split(SplitDirection::Left, SplitMode::MovePane, window, cx) + })) + .on_action(cx.listener(|pane, _: &SplitAndMoveRight, window, cx| { + pane.split(SplitDirection::Right, SplitMode::MovePane, window, cx) })) .on_action(cx.listener(|_, _: &JoinIntoNext, _, cx| { cx.emit(Event::JoinIntoNext); @@ -4443,11 +4474,14 @@ impl Render for DraggedTab { #[cfg(test)] mod tests { - use std::num::NonZero; + use std::{iter::zip, num::NonZero}; use super::*; - use crate::item::test::{TestItem, TestProjectItem}; - use gpui::{TestAppContext, VisualTestContext, size}; + use crate::{ + Member, + item::test::{TestItem, TestProjectItem}, + }; + use gpui::{AppContext, Axis, TestAppContext, VisualTestContext, size}; use project::FakeFs; use settings::SettingsStore; use theme::LoadThemes; @@ -7125,6 +7159,32 @@ mod tests { assert_item_labels(&pane, ["A", "C*", "B"], cx); } + #[gpui::test] + async fn test_split_empty(cx: &mut TestAppContext) { + for split_direction in SplitDirection::all() { + test_single_pane_split(["A"], split_direction, SplitMode::EmptyPane, cx).await; + } + } + + #[gpui::test] + async fn test_split_clone(cx: &mut TestAppContext) { + for split_direction in SplitDirection::all() { + test_single_pane_split(["A"], split_direction, SplitMode::ClonePane, cx).await; + } + } + + #[gpui::test] + async fn test_split_move_right_on_single_pane(cx: &mut TestAppContext) { + test_single_pane_split(["A"], SplitDirection::Right, SplitMode::MovePane, cx).await; + } + + #[gpui::test] + async fn test_split_move(cx: &mut TestAppContext) { + for split_direction in SplitDirection::all() { + test_single_pane_split(["A", "B"], split_direction, SplitMode::MovePane, cx).await; + } + } + fn init_test(cx: &mut TestAppContext) { cx.update(|cx| { let settings_store = SettingsStore::test(cx); @@ -7220,4 +7280,163 @@ mod tests { "pane items do not match expectation" ); } + + // Assert the item label, with the active item label expected active index + #[track_caller] + fn assert_item_labels_active_index( + pane: &Entity, + expected_states: &[&str], + expected_active_idx: usize, + cx: &mut VisualTestContext, + ) { + let actual_states = pane.update(cx, |pane, cx| { + pane.items + .iter() + .enumerate() + .map(|(ix, item)| { + let mut state = item + .to_any_view() + .downcast::() + .unwrap() + .read(cx) + .label + .clone(); + if ix == pane.active_item_index { + assert_eq!(ix, expected_active_idx); + } + if item.is_dirty(cx) { + state.push('^'); + } + if pane.is_tab_pinned(ix) { + state.push('!'); + } + state + }) + .collect::>() + }); + assert_eq!( + actual_states, expected_states, + "pane items do not match expectation" + ); + } + + #[track_caller] + fn assert_pane_ids_on_axis( + workspace: &Entity, + expected_ids: [&EntityId; COUNT], + expected_axis: Axis, + cx: &mut VisualTestContext, + ) { + workspace.read_with(cx, |workspace, _| match &workspace.center.root { + Member::Axis(axis) => { + assert_eq!(axis.axis, expected_axis); + assert_eq!(axis.members.len(), expected_ids.len()); + assert!( + zip(expected_ids, &axis.members).all(|(e, a)| { + if let Member::Pane(p) = a { + p.entity_id() == *e + } else { + false + } + }), + "pane ids do not match expectation: {expected_ids:?} != {actual_ids:?}", + actual_ids = axis.members + ); + } + Member::Pane(_) => panic!("expected axis"), + }); + } + + async fn test_single_pane_split( + pane_labels: [&str; COUNT], + direction: SplitDirection, + operation: SplitMode, + cx: &mut TestAppContext, + ) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, None, cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); + + let mut pane_before = + workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); + for label in pane_labels { + add_labeled_item(&pane_before, label, false, cx); + } + pane_before.update_in(cx, |pane, window, cx| { + pane.split(direction, operation, window, cx) + }); + cx.executor().run_until_parked(); + let pane_after = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); + + let num_labels = pane_labels.len(); + let last_as_active = format!("{}*", String::from(pane_labels[num_labels - 1])); + + // check labels for all split operations + match operation { + SplitMode::EmptyPane => { + assert_item_labels_active_index(&pane_before, &pane_labels, num_labels - 1, cx); + assert_item_labels(&pane_after, [], cx); + } + SplitMode::ClonePane => { + assert_item_labels_active_index(&pane_before, &pane_labels, num_labels - 1, cx); + assert_item_labels(&pane_after, [&last_as_active], cx); + } + SplitMode::MovePane => { + let head = &pane_labels[..(num_labels - 1)]; + if num_labels == 1 { + // We special-case this behavior and actually execute an empty pane command + // followed by a refocus of the old pane for this case. + pane_before = workspace.read_with(cx, |workspace, _cx| { + workspace + .panes() + .into_iter() + .find(|pane| *pane != &pane_after) + .unwrap() + .clone() + }); + }; + + assert_item_labels_active_index( + &pane_before, + &head, + head.len().saturating_sub(1), + cx, + ); + assert_item_labels(&pane_after, [&last_as_active], cx); + pane_after.update_in(cx, |pane, window, cx| { + window.focused(cx).is_some_and(|focus_handle| { + focus_handle == pane.active_item().unwrap().item_focus_handle(cx) + }) + }); + } + } + + // expected axis depends on split direction + let expected_axis = match direction { + SplitDirection::Right | SplitDirection::Left => Axis::Horizontal, + SplitDirection::Up | SplitDirection::Down => Axis::Vertical, + }; + + // expected ids depends on split direction + let expected_ids = match direction { + SplitDirection::Right | SplitDirection::Down => { + [&pane_before.entity_id(), &pane_after.entity_id()] + } + SplitDirection::Left | SplitDirection::Up => { + [&pane_after.entity_id(), &pane_before.entity_id()] + } + }; + + // check pane axes for all operations + match operation { + SplitMode::EmptyPane | SplitMode::ClonePane => { + assert_pane_ids_on_axis(&workspace, expected_ids, expected_axis, cx); + } + SplitMode::MovePane => { + assert_pane_ids_on_axis(&workspace, expected_ids, expected_axis, cx); + } + } + } } diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index fa8e3a3dc2af33054907ea8a8c1ba095a3259207..eb0debc929e9bc17a5d64a7c650165446d687e4e 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -4262,16 +4262,19 @@ impl Workspace { item: item.boxed_clone(), }); } - pane::Event::Split { - direction, - clone_active_item, - } => { - if *clone_active_item { - self.split_and_clone(pane.clone(), *direction, window, cx) - .detach(); - } else { - self.split_and_move(pane.clone(), *direction, window, cx); - } + pane::Event::Split { direction, mode } => { + match mode { + SplitMode::ClonePane => { + self.split_and_clone(pane.clone(), *direction, window, cx) + .detach(); + } + SplitMode::EmptyPane => { + self.split_pane(pane.clone(), *direction, window, cx); + } + SplitMode::MovePane => { + self.split_and_move(pane.clone(), *direction, window, cx); + } + }; } pane::Event::JoinIntoNext => { self.join_pane_into_next(pane.clone(), window, cx); diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 392a57520d28be021e55cbd890d2eb968370c2f7..8c39b308f83a33a1d3408d5fb28c94658ce63c54 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -3817,7 +3817,7 @@ mod tests { }) .unwrap(); - cx.dispatch_action(window.into(), pane::SplitRight); + cx.dispatch_action(window.into(), pane::SplitRight::default()); let editor_2 = cx.update(|cx| { let pane_2 = workspace.read(cx).active_pane().clone(); assert_ne!(pane_1, pane_2); diff --git a/crates/zed/src/zed/app_menus.rs b/crates/zed/src/zed/app_menus.rs index a7961ac6d4cb663353af1e4e0d1fe66cf43a80a3..7cdeddab790dcc2227d4d91956a6261c5add5259 100644 --- a/crates/zed/src/zed/app_menus.rs +++ b/crates/zed/src/zed/app_menus.rs @@ -32,10 +32,10 @@ pub fn app_menus(cx: &mut App) -> Vec { MenuItem::submenu(Menu { name: "Editor Layout".into(), items: vec![ - MenuItem::action("Split Up", workspace::SplitUp), - MenuItem::action("Split Down", workspace::SplitDown), - MenuItem::action("Split Left", workspace::SplitLeft), - MenuItem::action("Split Right", workspace::SplitRight), + MenuItem::action("Split Up", workspace::SplitUp::default()), + MenuItem::action("Split Down", workspace::SplitDown::default()), + MenuItem::action("Split Left", workspace::SplitLeft::default()), + MenuItem::action("Split Right", workspace::SplitRight::default()), ], }), MenuItem::separator(),