From c466505f0ba63410b6a0f92070bdedb35885a632 Mon Sep 17 00:00:00 2001 From: iret77 <63622643+iret77@users.noreply.github.com> Date: Tue, 6 Oct 2026 12:04:39 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(panes):=20Tab=20per=20Ziehen=20als=20P?= =?UTF-8?q?ane=20in=20den=20sichtbaren=20Tab=20einf=C3=BCgen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ein Tab lässt sich aus der Tab-Leiste in den Inhaltsbereich ziehen. Über einem Pane des sichtbaren Tabs markiert eine Fläche aus Theme-Akzent die Hälfte, an der er eingefügt wird (gleiche Richtungslogik wie beim Pane-Kopfzeilen-Drag). Loslassen überführt alle sichtbaren Panes des gezogenen Tabs samt Anordnung als Split; Sessions, PTYs und Agenten laufen weiter, der leere Quelltab schließt sich. Ohne Drop-Fläche wird höchstens umsortiert. Damit der sichtbare Tab beim Ziehen eines anderen stehen bleibt, aktiviert die horizontale Tab-Leiste Tabs beim Loslassen statt beim Drücken (wie die vertikalen Tabs). Tabs mit verborgenen laufenden Panes (Hintergrundjob, Child-Agent) oder laufendem provisorischem Daemon-Start werden nicht eingefügt. Fenster-Abriss (DragTabsToWindows) bleibt unverändert. --- CHANGELOG.md | 2 + app/src/pane_group/mod.rs | 197 +++++++++++++++++++-- app/src/pane_group/pane/view/header/mod.rs | 5 +- app/src/pane_group/tree.rs | 86 +++++++++ app/src/pane_group/tree_tests.rs | 56 ++++++ app/src/tab.rs | 24 ++- app/src/workspace/view.rs | 178 +++++++++++++++++++ app/src/workspace/view_test.rs | 56 ++++++ 8 files changed, 581 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 495beabe293..bad8d907e3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ This document records key changes: the Zap/Warp release history inherited before ## [1.2.0] — 2026-10-06 +- Ein Tab lässt sich mit der Maus als Pane in den sichtbaren Tab ziehen: Über dem Inhalt markiert eine Fläche die Hälfte des Panes, an der er eingefügt wird; Loslassen übernimmt alle seine Panes samt Anordnung, ohne Verbindung, PTY oder Agent neu zu starten. Tabs werden dafür beim Loslassen statt beim Drücken aktiviert, damit der sichtbare Tab beim Ziehen eines anderen Tabs stehen bleibt. + - Tabs und Panes haben ein gemeinsames Startmenü: „+“ und alle vier Split-Richtungen zeigen Terminal, „Neuer Agent…“, die Favoriten und neu „Weitere Hosts“ mit allen übrigen registrierten Hosts. Aus einem Split heraus startet jeder Eintrag im neuen Pane, auch der Agent; Konto, Startabsicht und Prompt hängen an genau diesem Pane. Tab-spezifische Einträge (Tab-Konfigurationen, Docker-Sandbox, Worktree-Konfiguration, geschlossene Session) bleiben im „+“-Menü. - Die Integrationssuite (#472) initialisiert englische Texte vor dem Start und bedient beim Teilen eines Panes den echten Hostpicker. SSH-Prüfungen nutzen eigene kurzlebige Konten auf einem lokalen CI-SSH-Server; Passwortanmeldung, Shell-Bootstrap und ProxyCommand bleiben Teil der Prüfung. diff --git a/app/src/pane_group/mod.rs b/app/src/pane_group/mod.rs index b9a517ec497..cb4df96ca6d 100644 --- a/app/src/pane_group/mod.rs +++ b/app/src/pane_group/mod.rs @@ -178,7 +178,7 @@ pub use pane::{ AnyPaneContent, BackingView, PaneConfiguration, PaneConfigurationEvent, PaneContent, PaneEvent, PaneId, PaneView, TerminalPaneId, }; -pub use tree::{Direction, PaneData, PaneFlex, PaneNode, SplitDirection}; +pub use tree::{Direction, PaneData, PaneFlex, PaneLayout, PaneNode, SplitDirection}; pub use working_directories::{WorkingDirectoriesEvent, WorkingDirectoriesModel}; use self::pane::{DetachType, PaneViewEvent}; @@ -968,6 +968,10 @@ impl PaneMoveBundle { temporary_original: None, } } + + fn pane_id(&self) -> PaneId { + self.visible_pane.as_pane().id() + } } impl SplitPaneState { @@ -4902,13 +4906,8 @@ impl PaneGroup { direction: Direction, ctx: &mut ViewContext, ) { - let PaneMoveBundle { - visible_pane, - temporary_original, - } = pane; - let visible_pane_id = visible_pane.as_pane().id(); - let Some(added_pane_id) = self.add_pane_with_options( - visible_pane, + self.add_moved_pane( + pane, AddPaneOptions { direction, base_pane_id: None, @@ -4917,13 +4916,27 @@ impl PaneGroup { emit_app_state_changed: true, }, ctx, - ) else { - return; - }; + ); + } + + /// Adds a pane moved out of another group and restores a file manager + /// overlay's relation to the terminal it covers. Returns the added pane. + fn add_moved_pane( + &mut self, + pane: PaneMoveBundle, + options: AddPaneOptions, + ctx: &mut ViewContext, + ) -> Option { + let PaneMoveBundle { + visible_pane, + temporary_original, + } = pane; + let visible_pane_id = visible_pane.as_pane().id(); + let added_pane_id = self.add_pane_with_options(visible_pane, options, ctx)?; debug_assert_eq!(added_pane_id, visible_pane_id); let Some(original) = temporary_original else { - return; + return Some(visible_pane_id); }; let original_pane_id = original.as_pane().id(); if self.init_pane(original, ctx).is_none() { @@ -4933,7 +4946,7 @@ impl PaneGroup { self.panes.remove(visible_pane_id); self.pane_contents.remove(&visible_pane_id); self.handle_pane_count_change(ctx); - return; + return None; } if !self .panes @@ -4947,11 +4960,167 @@ impl PaneGroup { self.panes.remove(visible_pane_id); self.pane_contents.remove(&visible_pane_id); self.handle_pane_count_change(ctx); - return; + return None; } self.pane_history.push(original_pane_id); self.handle_pane_count_change(ctx); ctx.emit(Event::AppStateChanged); + Some(visible_pane_id) + } + + /// Whether this group can hand all its visible panes to another tab: no + /// hidden pane still runs something (a background job or a child agent) + /// and no pane move is in flight. + pub(crate) fn can_join_another_tab(&self) -> bool { + !self.panes.has_hidden_panes_in_use() + } + + /// Removes every visible pane, with their layout, to join another tab. + /// Panes closed for undo are discarded first, so removing the last visible + /// pane closes this group's tab. `None` leaves the group untouched. + pub(crate) fn take_panes_for_join( + &mut self, + ctx: &mut ViewContext, + ) -> Option<(PaneLayout, Vec)> { + if !self.can_join_another_tab() { + return None; + } + let layout = self.panes.visible_layout()?; + let pane_ids = layout.pane_ids(); + let all_present = pane_ids.iter().all(|pane_id| { + self.pane_contents.contains_key(pane_id) + && self + .panes + .original_pane_for_replacement(*pane_id) + .is_none_or(|original_id| self.pane_contents.contains_key(&original_id)) + }); + if !all_present { + log::error!("Could not find all pane data needed to join another tab"); + return None; + } + self.clear_hidden_closed_panes(ctx); + let panes = pane_ids + .iter() + .filter_map(|pane_id| self.remove_pane_for_move(pane_id, ctx)) + .collect::>(); + let moved = panes + .iter() + .map(PaneMoveBundle::pane_id) + .collect::>(); + let layout = layout.retain(&|pane_id| moved.contains(&pane_id))?; + Some((layout, panes)) + } + + /// Inserts panes taken from another tab ([`Self::take_panes_for_join`]) + /// as a split at `direction` of `base_pane_id`. Sibling order and nesting + /// are kept; the joined panes start with even sizes. Focuses `focus` when + /// it is among them, else the first joined pane. + pub(crate) fn insert_joined_panes( + &mut self, + base_pane_id: PaneId, + direction: Direction, + layout: PaneLayout, + panes: Vec, + focus: Option, + ctx: &mut ViewContext, + ) { + let mut panes = panes + .into_iter() + .map(|pane| (pane.pane_id(), pane)) + .collect::>(); + let first_pane = layout.first_pane(); + if self.place_joined_pane(first_pane, base_pane_id, direction, &mut panes, ctx) { + self.expand_joined_layout(&layout, &mut panes, ctx); + } + // A pane whose neighbour could not be placed must not be lost: it + // joins at the edge of the whole tab instead. + for pane_id in layout.pane_ids() { + if let Some(pane) = panes.remove(&pane_id) { + log::warn!("Joined pane {pane_id:?} placed at the tab edge"); + self.add_moved_pane( + pane, + AddPaneOptions { + direction, + base_pane_id: None, + focus_new_pane: false, + visibility: NewPaneVisibility::Visible, + emit_app_state_changed: true, + }, + ctx, + ); + } + } + let visible = self.panes.visible_pane_ids(); + let focus = focus + .filter(|pane_id| visible.contains(pane_id)) + .or_else(|| visible.contains(&first_pane).then_some(first_pane)); + if let Some(pane_id) = focus { + self.focus_pane_by_id(pane_id, ctx); + } + ctx.notify(); + } + + /// Places one joined pane next to an already placed one. + fn place_joined_pane( + &mut self, + pane_id: PaneId, + base_pane_id: PaneId, + direction: Direction, + panes: &mut HashMap, + ctx: &mut ViewContext, + ) -> bool { + if !self.panes.visible_pane_ids().contains(&base_pane_id) { + return false; + } + let Some(pane) = panes.remove(&pane_id) else { + return false; + }; + self.add_moved_pane( + pane, + AddPaneOptions { + direction, + base_pane_id: Some(base_pane_id), + focus_new_pane: false, + visibility: NewPaneVisibility::Visible, + emit_app_state_changed: true, + }, + ctx, + ) + .is_some() + } + + /// Rebuilds `layout` around its first pane, which is already placed. Each + /// split first places its children's first panes side by side, then + /// expands every child, so nesting follows the original layout. + fn expand_joined_layout( + &mut self, + layout: &PaneLayout, + panes: &mut HashMap, + ctx: &mut ViewContext, + ) { + let PaneLayout::Split { axis, children } = layout else { + return; + }; + let forward = match axis { + SplitDirection::Horizontal => Direction::Right, + SplitDirection::Vertical => Direction::Down, + }; + let mut placed = vec![&children[0]]; + for pair in children.windows(2) { + let (previous, next) = (&pair[0], &pair[1]); + if self.place_joined_pane( + next.first_pane(), + previous.first_pane(), + forward, + panes, + ctx, + ) { + placed.push(next); + } + } + for child in placed { + self.expand_joined_layout(child, panes, ctx); + } } /// We return a pane_id if the pane successfully attached diff --git a/app/src/pane_group/pane/view/header/mod.rs b/app/src/pane_group/pane/view/header/mod.rs index 0588b74dc01..c96ce3e2dd8 100644 --- a/app/src/pane_group/pane/view/header/mod.rs +++ b/app/src/pane_group/pane/view/header/mod.rs @@ -812,7 +812,10 @@ impl View for PaneHeader

{ /// /// The only caveat here is that the drag position needs to be greater than a given threshold to trigger a drag, /// otherwise this will result in a no-op. This ensures that the split is not too sensitive. -fn calculate_pane_move_direction(target_pane: RectF, drag_position: RectF) -> Option { +pub(crate) fn calculate_pane_move_direction( + target_pane: RectF, + drag_position: RectF, +) -> Option { let moved_drag_center = drag_position.center() - target_pane.center(); let normalized_drag_center = Vector2F::new( moved_drag_center.x() / target_pane.width(), diff --git a/app/src/pane_group/tree.rs b/app/src/pane_group/tree.rs index 22497dbbecb..bda5282a8ca 100644 --- a/app/src/pane_group/tree.rs +++ b/app/src/pane_group/tree.rs @@ -110,6 +110,60 @@ impl HiddenPane { } } +/// The arrangement of a group's visible panes, detached from the group so it +/// can be rebuilt in another one (a tab joined into another tab as a split). +#[derive(Debug, Clone, PartialEq)] +pub enum PaneLayout { + Leaf(PaneId), + Split { + axis: SplitDirection, + children: Vec, + }, +} + +impl PaneLayout { + /// The first pane in reading order; it stands for the whole layout until + /// the layout's other panes are placed around it. + pub fn first_pane(&self) -> PaneId { + match self { + PaneLayout::Leaf(pane_id) => *pane_id, + PaneLayout::Split { children, .. } => children[0].first_pane(), + } + } + + /// All panes in reading order (left to right, top to bottom). + pub fn pane_ids(&self) -> Vec { + match self { + PaneLayout::Leaf(pane_id) => vec![*pane_id], + PaneLayout::Split { children, .. } => { + children.iter().flat_map(PaneLayout::pane_ids).collect() + } + } + } + + /// The layout restricted to the panes `keep` accepts; splits left with a + /// single child collapse into it. `None` when no pane is kept. + pub fn retain(&self, keep: &impl Fn(PaneId) -> bool) -> Option { + match self { + PaneLayout::Leaf(pane_id) => keep(*pane_id).then_some(PaneLayout::Leaf(*pane_id)), + PaneLayout::Split { axis, children } => { + let mut children = children + .iter() + .filter_map(|child| child.retain(keep)) + .collect::>(); + match children.len() { + 0 => None, + 1 => children.pop(), + _ => Some(PaneLayout::Split { + axis: *axis, + children, + }), + } + } + } + } +} + /// Single Node in the tree of panes pub enum PaneNode { /// A collection of panes split in a specific direction @@ -552,6 +606,28 @@ impl PaneData { .any(|hidden_pane| hidden_pane.pane_id == *pane_id) } + /// The layout of the visible panes; `None` when no pane is visible. + pub fn visible_layout(&self) -> Option { + self.root + .layout() + .retain(&|pane_id| !self.is_pane_hidden(&pane_id)) + } + + /// Whether a hidden pane still has something running in it (a background + /// job or a child agent) or is in the middle of a move. Such a group cannot + /// hand over its visible panes and close, because the hidden pane would go + /// with it. + pub fn has_hidden_panes_in_use(&self) -> bool { + self.hidden_panes.iter().any(|hidden_pane| { + matches!( + hidden_pane.reason, + HiddenPaneReason::FromMove + | HiddenPaneReason::FromJob + | HiddenPaneReason::ChildAgent + ) + }) + } + pub fn len(&self) -> usize { self.len } @@ -753,6 +829,16 @@ impl PaneNode { } } + fn layout(&self) -> PaneLayout { + match self { + PaneNode::Leaf(pane) => PaneLayout::Leaf(*pane), + PaneNode::Branch(branch) => PaneLayout::Split { + axis: branch.axis, + children: branch.nodes.iter().map(|(_, node)| node.layout()).collect(), + }, + } + } + fn pane_ids(&self) -> Vec { match self { PaneNode::Leaf(pane) => vec![*pane], diff --git a/app/src/pane_group/tree_tests.rs b/app/src/pane_group/tree_tests.rs index 1e5cc5fd809..a61ac91a6d1 100644 --- a/app/src/pane_group/tree_tests.rs +++ b/app/src/pane_group/tree_tests.rs @@ -821,3 +821,59 @@ fn closing_nested_split_collapses_to_the_original_leaf() { assert_eq!(tree.visible_pane_ids(), vec![left]); assert_eq!(tree.len(), 1); } + +#[test] +fn visible_layout_mirrors_nesting_and_skips_hidden_panes() { + let [left, top_right, bottom_right, background] = [(); 4].map(|_| PaneId::dummy_pane_id()); + let mut tree = PaneData::new(left); + tree.split(left, top_right, Direction::Right); + tree.split(top_right, bottom_right, Direction::Down); + tree.split(bottom_right, background, Direction::Right); + tree.hide_pane_for_job(background); + + let layout = tree.visible_layout().expect("three panes are visible"); + assert_eq!( + layout, + PaneLayout::Split { + axis: SplitDirection::Horizontal, + children: vec![ + PaneLayout::Leaf(left), + PaneLayout::Split { + axis: SplitDirection::Vertical, + children: vec![PaneLayout::Leaf(top_right), PaneLayout::Leaf(bottom_right)], + }, + ], + } + ); + assert_eq!(layout.first_pane(), left); + assert_eq!(layout.pane_ids(), vec![left, top_right, bottom_right]); + assert_eq!( + layout.retain(&|pane_id| pane_id != top_right), + Some(PaneLayout::Split { + axis: SplitDirection::Horizontal, + children: vec![PaneLayout::Leaf(left), PaneLayout::Leaf(bottom_right)], + }) + ); + assert_eq!(layout.retain(&|_| false), None); +} + +#[test] +fn only_running_or_moving_hidden_panes_block_handing_over_a_group() { + let [visible, hidden] = [(); 2].map(|_| PaneId::dummy_pane_id()); + let mut tree = PaneData::new(visible); + tree.split(visible, hidden, Direction::Right); + assert!(!tree.has_hidden_panes_in_use()); + + tree.hide_pane_for_job(hidden); + assert!(tree.has_hidden_panes_in_use()); + tree.show_pane_for_job(hidden); + assert!(!tree.has_hidden_panes_in_use()); + + // A pane closed for undo is discarded with its group, nothing runs in it. + tree.hide_closed_pane(hidden); + assert!(!tree.has_hidden_panes_in_use()); + tree.unhide_closed_pane(hidden); + + tree.hide_pane_for_child_agent(hidden); + assert!(tree.has_hidden_panes_in_use()); +} diff --git a/app/src/tab.rs b/app/src/tab.rs index 0ac505dcc26..66124d509b3 100644 --- a/app/src/tab.rs +++ b/app/src/tab.rs @@ -35,7 +35,8 @@ use crate::workspace::{ }; use crate::BlocklistAIHistoryModel; use pathfinder_color::ColorU; -use pathfinder_geometry::vector::vec2f; +use pathfinder_geometry::rect::RectF; +use pathfinder_geometry::vector::{vec2f, Vector2F}; use serde::{Deserialize, Serialize}; use warp_core::context_flag::ContextFlag; use warp_core::ui::builder::UiBuilder; @@ -43,11 +44,11 @@ use warp_core::ui::theme::color::internal_colors; use warp_core::ui::theme::AnsiColors; use warpui::elements::{ Align, Border, ChildAnchor, Clipped, ConstrainedBox, Container, CornerRadius, - CrossAxisAlignment, DragAxis, Draggable, DraggableState, DropShadow, DropTarget, Element, - Empty, Fill, Flex, Hoverable, MainAxisAlignment, MainAxisSize, MouseStateHandle, - OffsetPositioning, Padding, ParentAnchor, ParentElement, ParentOffsetBounds, - PositionedElementAnchor, PositionedElementOffsetBounds, Radius, Rect, SavePosition, Shrinkable, - SizeConstraintCondition, SizeConstraintSwitch, Stack, Text, + CrossAxisAlignment, Draggable, DraggableState, DropShadow, DropTarget, Element, Empty, Fill, + Flex, Hoverable, MainAxisAlignment, MainAxisSize, MouseStateHandle, OffsetPositioning, Padding, + ParentAnchor, ParentElement, ParentOffsetBounds, PositionedElementAnchor, + PositionedElementOffsetBounds, Radius, Rect, SavePosition, Shrinkable, SizeConstraintCondition, + SizeConstraintSwitch, Stack, Text, }; use warpui::fonts::Weight; use warpui::text_layout::ClipConfig; @@ -1634,7 +1635,9 @@ impl UiComponent for TabComponent<'_> { // We only want the on_click action to take effect on the tab, if it's not being renamed at a moment. // Note that clicking on other tabs is still ok. if !is_tab_being_renamed { - tab = tab.on_mouse_down(move |ctx, _app, _| { + // Activate on release, not on press: dragging another tab out of + // the tab bar keeps the visible tab on screen as its drop target. + tab = tab.on_click(move |ctx, _app, _| { let is_hovered = mouse_close_state .lock() .expect("lock acquired") @@ -1697,7 +1700,12 @@ impl UiComponent for TabComponent<'_> { let draggable = if FeatureFlag::DragTabsToWindows.is_enabled() { draggable } else { - draggable.with_drag_axis(DragAxis::HorizontalOnly) + // Without window tear-off a tab may still leave the tab bar + // inside this window: over the content it joins the visible tab + // as a split pane. + draggable.with_drag_bounds_callback(|_, window_size| { + Some(RectF::new(Vector2F::zero(), window_size)) + }) }; let tab_with_drag: Box = draggable.finish(); SavePosition::new(tab_with_drag, &tab_position_id(tab_index)).finish() diff --git a/app/src/workspace/view.rs b/app/src/workspace/view.rs index c830c21fb3b..f039730fa9f 100644 --- a/app/src/workspace/view.rs +++ b/app/src/workspace/view.rs @@ -858,6 +858,17 @@ struct LaunchedAgent { terminal: Option>, } +/// Where a tab dragged out of the tab bar joins the visible tab: as a split at +/// `direction` of `pane_id`. `zone` is the highlighted half of that pane. +#[derive(Clone, Copy, Debug)] +struct TabJoinDropTarget { + source_group_id: EntityId, + target_group_id: EntityId, + pane_id: PaneId, + direction: Direction, + zone: RectF, +} + #[cfg(unix)] #[derive(Clone, Copy)] struct PendingDaemonSurface { @@ -2087,6 +2098,9 @@ pub struct Workspace { /// Split target for a Spawn-Karte opened from the split launch menu; its /// single launch lands there instead of a new tab. spawn_card_split: Option, + /// Set while a dragged tab is over a pane of the visible tab; dropping it + /// there joins the dragged tab's panes as a split. + tab_join_drop_target: Option, changelog_model: ModelHandle, palette: ViewHandle, ctrl_tab_palette: ViewHandle, @@ -4727,6 +4741,7 @@ impl Workspace { show_split_launch_menu: None, pending_split_launch: None, spawn_card_split: None, + tab_join_drop_target: None, changelog_model, welcome_tips_view_state, welcome_tips_view, @@ -30371,6 +30386,7 @@ impl TypedActionView for Workspace { // If we are renaming a tab, finish the rename before dragging. self.finish_tab_rename(ctx); self.current_workspace_state.is_tab_being_dragged = true; + self.tab_join_drop_target = None; } ZaplexDrive => { if WarpDriveSettings::is_warp_drive_enabled(ctx) { @@ -30728,6 +30744,10 @@ impl TypedActionView for Workspace { } tab.detached = false; } + if let Some(target) = self.tab_join_drop_target.take() { + self.join_tab_as_pane(target, ctx); + ctx.notify(); + } send_telemetry_from_ctx!(TelemetryEvent::DragAndDropTab, ctx); if is_cross_window { let drop_result = @@ -32082,6 +32102,27 @@ impl View for Workspace { ); } + if let Some(target) = self.tab_join_drop_target { + let theme = appearance.theme(); + stack.add_positioned_overlay_child( + ConstrainedBox::new( + Container::new(Empty::new().finish()) + .with_background(theme.accent().with_opacity(16)) + .with_border(Border::all(1.).with_border_fill(theme.accent())) + .finish(), + ) + .with_width(target.zone.width()) + .with_height(target.zone.height()) + .finish(), + OffsetPositioning::offset_from_parent( + target.zone.origin(), + ParentOffsetBounds::WindowByPosition, + ParentAnchor::TopLeft, + ChildAnchor::TopLeft, + ), + ); + } + if let Some(position) = self.show_split_launch_menu { stack.add_positioned_overlay_child( ChildView::new(&self.split_launch_menu).finish(), @@ -33661,6 +33702,24 @@ impl Workspace { } } + if !FeatureFlag::DragTabsToWindows.is_enabled() { + // Without window tear-off, a tab dragged over the content joins the + // visible tab as a split pane; it never reorders from there. + let join_target = if is_drag_outside_tab_bar { + self.tab_join_drop_target_at(current_index, position, ctx) + } else { + None + }; + let had_join_target = self.tab_join_drop_target.is_some(); + self.tab_join_drop_target = join_target; + if had_join_target || join_target.is_some() { + ctx.notify(); + } + if is_drag_outside_tab_bar { + return; + } + } + let source_is_single_tab = self.tabs.len() == 1; if (is_drag_outside_tab_bar || source_is_single_tab) && FeatureFlag::DragTabsToWindows.is_enabled() @@ -33797,6 +33856,107 @@ impl Workspace { } } + /// The visible tab's pane under a tab dragged out of the tab bar, and the + /// edge the dragged tab would join at. `None` for the visible tab itself, a + /// tab that cannot hand over its panes, or when no pane is under the drag. + fn tab_join_drop_target_at( + &self, + dragged_index: usize, + drag_position: RectF, + ctx: &AppContext, + ) -> Option { + if dragged_index == self.active_tab_index { + return None; + } + let source = &self.tabs.get(dragged_index)?.pane_group; + let target = &self.tabs.get(self.active_tab_index)?.pane_group; + if !source.as_ref(ctx).can_join_another_tab() + || self.tab_has_pending_cross_window_daemon_start(dragged_index, ctx) + { + return None; + } + let drag_center = drag_position.center(); + target + .as_ref(ctx) + .visible_pane_ids() + .into_iter() + .find_map(|pane_id| { + let pane = ctx + .element_position_by_id_at_last_frame(self.window_id, pane_id.position_id())?; + if !pane.contains_point(drag_center) { + return None; + } + let direction = + crate::pane_group::pane::view::header::calculate_pane_move_direction( + pane, + drag_position, + )?; + Some(TabJoinDropTarget { + source_group_id: source.id(), + target_group_id: target.id(), + pane_id, + direction, + zone: tab_join_drop_zone(pane, direction), + }) + }) + } + + /// Moves every pane of the dragged tab into the visible tab as a split at + /// the drop target, with their layout and running sessions. The dragged + /// tab closes once its last pane has moved. A target that went stale + /// during the drag leaves both tabs untouched. + fn join_tab_as_pane(&mut self, target: TabJoinDropTarget, ctx: &mut ViewContext) { + let Some(source_index) = self + .tabs + .iter() + .position(|tab| tab.pane_group.id() == target.source_group_id) + else { + return; + }; + let Some(target_group) = self + .tabs + .get(self.active_tab_index) + .map(|tab| tab.pane_group.clone()) + .filter(|group| group.id() == target.target_group_id) + else { + return; + }; + if source_index == self.active_tab_index + || self.tab_has_pending_cross_window_daemon_start(source_index, ctx) + || !target_group + .as_ref(ctx) + .visible_pane_ids() + .contains(&target.pane_id) + { + return; + } + let source_group = self.tabs[source_index].pane_group.clone(); + let focused = source_group.as_ref(ctx).focused_pane_id(ctx); + let Some((layout, panes)) = + source_group.update(ctx, |group, ctx| group.take_panes_for_join(ctx)) + else { + return; + }; + // A single-pane tab may still carry its host only at tab level; keep + // that route with the pane now that the tab goes away. + if let Some(node_id) = self.ssh_tab_nodes.remove(&source_group.id()) { + if let [pane_id] = layout.pane_ids().as_slice() { + self.ssh_pane_nodes.entry(*pane_id).or_insert(node_id); + } + } + target_group.update(ctx, |group, ctx| { + group.insert_joined_panes( + target.pane_id, + target.direction, + layout, + panes, + Some(focused), + ctx, + ); + }); + self.focus_active_tab(ctx); + } + /// Performs the source-workspace cleanup indicated by `DropResult`. /// Cross-workspace mutations (preview/target updates, focus) happen inside /// `CrossWindowTabDrag::on_drop`; this method only touches `self`. @@ -33943,6 +34103,24 @@ impl Workspace { } } +/// The half of `pane` on the `direction` side: where a joined tab will land. +fn tab_join_drop_zone(pane: RectF, direction: Direction) -> RectF { + let half_width = pane.width() / 2.; + let half_height = pane.height() / 2.; + match direction { + Direction::Left => RectF::new(pane.origin(), vec2f(half_width, pane.height())), + Direction::Right => RectF::new( + vec2f(pane.min_x() + half_width, pane.min_y()), + vec2f(half_width, pane.height()), + ), + Direction::Up => RectF::new(pane.origin(), vec2f(pane.width(), half_height)), + Direction::Down => RectF::new( + vec2f(pane.min_x(), pane.min_y() + half_height), + vec2f(pane.width(), half_height), + ), + } +} + /// Returns every tab-bar-equivalent rect laid out in `window_id` (horizontal /// tab bar and/or vertical tabs panel). Both must be considered because a /// window with vertical tabs still renders the horizontal bar at the top. diff --git a/app/src/workspace/view_test.rs b/app/src/workspace/view_test.rs index d9b7c65cfe0..ecf073113e9 100644 --- a/app/src/workspace/view_test.rs +++ b/app/src/workspace/view_test.rs @@ -3880,6 +3880,62 @@ fn every_split_direction_opens_the_launch_menu_without_adding_a_pane() { }); } +#[test] +fn dragging_a_tab_onto_a_pane_joins_its_panes_as_a_split() { + App::test((), |mut app| async move { + initialize_app(&mut app); + let workspace = mock_workspace(&mut app); + + let target_group_id = workspace.update(&mut app, |workspace, ctx| { + let target_group = workspace.active_tab_pane_group().clone(); + let target_pane = target_group.as_ref(ctx).focused_pane_id(ctx); + workspace.add_terminal_tab(false, ctx); + let source_group = workspace.active_tab_pane_group().clone(); + source_group.update(ctx, |group, ctx| { + group.add_terminal_pane(Direction::Down, None, ctx); + }); + let source_panes = source_group.as_ref(ctx).visible_pane_ids(); + assert_eq!(source_panes.len(), 2); + let focused_source_pane = source_group.as_ref(ctx).focused_pane_id(ctx); + workspace.set_active_tab_index(0, ctx); + + // The visible tab is never a drop target for itself. + let no_drag = RectF::new(Vector2F::zero(), Vector2F::zero()); + assert!(workspace + .tab_join_drop_target_at(workspace.active_tab_index, no_drag, ctx) + .is_none()); + + workspace.join_tab_as_pane( + TabJoinDropTarget { + source_group_id: source_group.id(), + target_group_id: target_group.id(), + pane_id: target_pane, + direction: Direction::Right, + zone: no_drag, + }, + ctx, + ); + + // The same panes (and so the same sessions) now follow the + // target pane in their original order. + let mut expected = vec![target_pane]; + expected.extend(source_panes); + assert_eq!(target_group.as_ref(ctx).visible_pane_ids(), expected); + assert_eq!( + target_group.as_ref(ctx).focused_pane_id(ctx), + focused_source_pane + ); + target_group.id() + }); + + // The emptied source tab closes once its exit event is handled. + workspace.read(&app, |workspace, _| { + assert_eq!(workspace.tabs.len(), 1); + assert_eq!(workspace.active_tab_pane_group().id(), target_group_id); + }); + }); +} + #[test] fn split_agent_launch_recaptures_its_target_until_the_source_pane_closes() { App::test((), |mut app| async move { From 5c8e302e5649b804ca84701cfe08b58d482a2790 Mon Sep 17 00:00:00 2001 From: iret77 <63622643+iret77@users.noreply.github.com> Date: Tue, 6 Oct 2026 12:45:30 +0200 Subject: [PATCH 2/2] =?UTF-8?q?feat(panes):=20Tab=20auch=20aus=20dem=20ver?= =?UTF-8?q?tikalen=20Tab-Panel=20als=20Pane=20einf=C3=BCgen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Das vertikale Tab-Panel sperrt den Tab-Drag ohne DragTabsToWindows nicht mehr auf die senkrechte Achse, sondern begrenzt ihn auf das Fenster. Über dem Inhalt greift damit dieselbe Drop-Fläche und dasselbe Einfügen als Split wie bei der horizontalen Tab-Leiste; im Panel wird weiter umsortiert. Gezogen wird wie bisher der ganze Tab mit allen Panes. --- CHANGELOG.md | 2 +- app/src/workspace/view/vertical_tabs.rs | 23 ++++++++++++----------- docs/ui/pane-launch-and-tab-join.html | 2 +- specs/GH-160/PRODUCT.md | 12 ++++++------ 4 files changed, 20 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bad8d907e3f..3317d279a3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ This document records key changes: the Zap/Warp release history inherited before ## [1.2.0] — 2026-10-06 -- Ein Tab lässt sich mit der Maus als Pane in den sichtbaren Tab ziehen: Über dem Inhalt markiert eine Fläche die Hälfte des Panes, an der er eingefügt wird; Loslassen übernimmt alle seine Panes samt Anordnung, ohne Verbindung, PTY oder Agent neu zu starten. Tabs werden dafür beim Loslassen statt beim Drücken aktiviert, damit der sichtbare Tab beim Ziehen eines anderen Tabs stehen bleibt. +- Ein Tab lässt sich mit der Maus aus der Tab-Leiste oder dem vertikalen Tab-Panel als Pane in den sichtbaren Tab ziehen: Über dem Inhalt markiert eine Fläche die Hälfte des Panes, an der er eingefügt wird; Loslassen übernimmt alle seine Panes samt Anordnung, ohne Verbindung, PTY oder Agent neu zu starten. Tabs werden dafür beim Loslassen statt beim Drücken aktiviert, damit der sichtbare Tab beim Ziehen eines anderen Tabs stehen bleibt. - Tabs und Panes haben ein gemeinsames Startmenü: „+“ und alle vier Split-Richtungen zeigen Terminal, „Neuer Agent…“, die Favoriten und neu „Weitere Hosts“ mit allen übrigen registrierten Hosts. Aus einem Split heraus startet jeder Eintrag im neuen Pane, auch der Agent; Konto, Startabsicht und Prompt hängen an genau diesem Pane. Tab-spezifische Einträge (Tab-Konfigurationen, Docker-Sandbox, Worktree-Konfiguration, geschlossene Session) bleiben im „+“-Menü. diff --git a/app/src/workspace/view/vertical_tabs.rs b/app/src/workspace/view/vertical_tabs.rs index da1d8ecf0c5..4ded1f1bd33 100644 --- a/app/src/workspace/view/vertical_tabs.rs +++ b/app/src/workspace/view/vertical_tabs.rs @@ -65,13 +65,12 @@ use warp_core::ui::Icon as WarpIcon; use warpui::elements::DispatchEventResult; use warpui::elements::{ resizable_state_handle, Border, ChildAnchor, Clipped, ClippedScrollStateHandle, - ClippedScrollable, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, DragAxis, - DragBarSide, Draggable, DropShadow, DropTarget, Element, Empty, EventHandler, Expanded, - Fill as ElementFill, Flex, Hoverable, MainAxisAlignment, MainAxisSize, MouseStateHandle, - OffsetPositioning, Padding, ParentAnchor, ParentElement, ParentOffsetBounds, - PositionedElementAnchor, PositionedElementOffsetBounds, Radius, Resizable, - ResizableStateHandle, SavePosition, ScrollTarget, ScrollToPositionMode, ScrollbarWidth, - Shrinkable, Stack, Text, + ClippedScrollable, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, DragBarSide, + Draggable, DropShadow, DropTarget, Element, Empty, EventHandler, Expanded, Fill as ElementFill, + Flex, Hoverable, MainAxisAlignment, MainAxisSize, MouseStateHandle, OffsetPositioning, Padding, + ParentAnchor, ParentElement, ParentOffsetBounds, PositionedElementAnchor, + PositionedElementOffsetBounds, Radius, Resizable, ResizableStateHandle, SavePosition, + ScrollTarget, ScrollToPositionMode, ScrollbarWidth, Shrinkable, Stack, Text, }; use warpui::fonts::{Properties, Weight}; use warpui::platform::Cursor; @@ -2106,13 +2105,15 @@ fn render_tab_group_internal( .on_drop(|ctx, _, _, _| { ctx.dispatch_typed_action(WorkspaceAction::DropTab); }); - // Only lock the drag to the vertical axis when cross-window tab drag is - // disabled. When it is enabled, the user needs to be able to drag - // horizontally out of the panel to detach the tab into a new window. + // A tab may always leave the panel: with cross-window tab drag it detaches + // into a new window, otherwise it stays inside this window and, over the + // content, joins the visible tab as a split pane. let draggable = if FeatureFlag::DragTabsToWindows.is_enabled() { draggable } else { - draggable.with_drag_axis(DragAxis::VerticalOnly) + draggable.with_drag_bounds_callback(|_, window_size| { + Some(RectF::new(Vector2F::zero(), window_size)) + }) }; let draggable = draggable.finish(); diff --git a/docs/ui/pane-launch-and-tab-join.html b/docs/ui/pane-launch-and-tab-join.html index 768aaf170b7..6274429bce0 100644 --- a/docs/ui/pane-launch-and-tab-join.html +++ b/docs/ui/pane-launch-and-tab-join.html @@ -99,7 +99,7 @@

Tab-Benennung

Tab als Pane (Maus)

  • #spec J1 Ein Klick aktiviert einen Tab beim Loslassen. Wird stattdessen gezogen, bleibt der bisher sichtbare Tab sichtbar.
  • -
  • #spec J2 Innerhalb der Tab-Leiste sortiert Ziehen wie bisher um. Verlässt der Zeiger die Tab-Leiste nach unten, zeigt das Pane unter dem Zeiger eine Drop-Zone über der Hälfte (links/rechts/oben/unten), in die der Tab eingefügt wird. Richtung wie beim Pane-Kopfzeilen-Drag.
  • +
  • #spec J2 Innerhalb der Tab-Leiste bzw. des vertikalen Tab-Panels sortiert Ziehen wie bisher um. Verlässt der Zeiger sie in den Inhaltsbereich, zeigt das Pane unter dem Zeiger eine Drop-Zone über der Hälfte (links/rechts/oben/unten), in die der Tab eingefügt wird. Richtung wie beim Pane-Kopfzeilen-Drag.
  • #spec J3 Loslassen über einer Drop-Zone fügt alle Panes des gezogenen Tabs mit ihrem Layout als Split an dieser Kante ein; der Quelltab entfällt; Fokus liegt auf dem zuletzt fokussierten Pane des gezogenen Tabs.
  • #spec J4 Der sichtbare Tab ist nie Ziel seiner selbst: beim Ziehen des sichtbaren Tabs erscheint keine Drop-Zone. Loslassen ohne Drop-Zone ändert höchstens die Reihenfolge.
  • #spec J5 Überführen startet keine Verbindung, PTY oder Agent-Session neu; Host-Bindung, Verlauf, CWD, Entwurf und Dateimanager-Modus jedes Panes bleiben.
  • diff --git a/specs/GH-160/PRODUCT.md b/specs/GH-160/PRODUCT.md index cf404c8cddb..fbb3046f2ab 100644 --- a/specs/GH-160/PRODUCT.md +++ b/specs/GH-160/PRODUCT.md @@ -237,12 +237,12 @@ The previous static illustrations are superseded, not alternative approved layou 34. **Pane geometry preserves session identity.** A sole pane fills the available workspace. Splitting affects only the chosen pane. Dragging a pane header to a target edge moves that existing pane left, right, above, or below without opening a connection, PTY, or agent. Moving - to another tab remains supported. Dragging an inactive tab out of the tab bar onto a pane of - the visible tab inserts all of its panes, with their layout, at the indicated edge; the source - tab closes and no connection, PTY, or agent restarts. Tabs activate on click so the visible tab - stays visible while another tab is dragged. Invalid or cancelled drops leave the layout - intact, and file drag, text selection, splitter resize, and header-button clicks are not pane - moves. + to another tab remains supported. Dragging an inactive tab out of the tab bar (or the vertical + tabs panel) onto a pane of the visible tab inserts all of its panes, with their layout, at the + indicated edge; the source tab closes and no connection, PTY, or agent restarts. Tabs activate + on click so the visible tab stays visible while another tab is dragged. Invalid or cancelled + drops leave the layout intact, and file drag, text selection, splitter resize, and + header-button clicks are not pane moves. 35. **Focus and restore are exact.** Each tab retains its last valid focused pane. Closing, moving, reconnecting, and restoring preserve layout, host/daemon/PTY/generation identity, working