diff --git a/CHANGELOG.md b/CHANGELOG.md index 3317d279a3b..2672f62d67d 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 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. +- 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. Das eingefügte Layout teilt sich die markierte Hälfte des Ziel-Panes, Nachbar-Panes behalten ihre Größe. Editor-Panes werden nur in Tabs ohne Editor eingefügt, und Child-Agent-Zuordnungen wandern mit. - 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/pane_group/mod.rs b/app/src/pane_group/mod.rs index ca8d08a9771..b0a557b9b4d 100644 --- a/app/src/pane_group/mod.rs +++ b/app/src/pane_group/mod.rs @@ -961,6 +961,20 @@ pub(crate) struct PaneMoveBundle { temporary_original: Option>, } +/// Panes taken out of a tab to join another one ([`PaneGroup::take_panes_for_join`]), +/// with their layout and the child agents they host. +pub(crate) struct JoinedPanes { + layout: PaneLayout, + panes: Vec, + child_agent_panes: HashMap, +} + +impl JoinedPanes { + pub(crate) fn pane_ids(&self) -> Vec { + self.layout.pane_ids() + } +} + impl PaneMoveBundle { pub(crate) fn single(pane: Box) -> Self { Self { @@ -4949,6 +4963,21 @@ impl PaneGroup { let Some(original) = temporary_original else { return Some(visible_pane_id); }; + if !self.restore_temporary_original(visible_pane_id, original, ctx) { + return None; + } + ctx.emit(Event::AppStateChanged); + Some(visible_pane_id) + } + + /// Re-attaches the terminal that a moved file manager overlay covers and + /// restores their relation. On failure both panes are cleaned up. + fn restore_temporary_original( + &mut self, + visible_pane_id: PaneId, + original: Box, + ctx: &mut ViewContext, + ) -> bool { let original_pane_id = original.as_pane().id(); if self.init_pane(original, ctx).is_none() { log::error!("Could not attach the original pane for a moved file manager overlay"); @@ -4957,7 +4986,7 @@ impl PaneGroup { self.panes.remove(visible_pane_id); self.pane_contents.remove(&visible_pane_id); self.handle_pane_count_change(ctx); - return None; + return false; } if !self .panes @@ -4971,29 +5000,42 @@ impl PaneGroup { self.panes.remove(visible_pane_id); self.pane_contents.remove(&visible_pane_id); self.handle_pane_count_change(ctx); - return None; + return false; } self.pane_history.push(original_pane_id); self.handle_pane_count_change(ctx); - ctx.emit(Event::AppStateChanged); - Some(visible_pane_id) + true } /// 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 { + /// hidden pane still runs something (a background job or a child agent), + /// no pane move is in flight, and no conversation restore or summarization + /// dialog is still bound to one of its panes. + fn can_hand_over_panes(&self) -> bool { !self.panes.has_hidden_panes_in_use() + && self + .pending_ambient_agent_conversation_restorations + .is_empty() + && self.terminal_with_open_summarization_dialog.is_none() + } + + /// Whether this group's panes can join the tab of `target`. Editor panes + /// join only a tab without editors: an editor whose file is already open + /// in the target refuses to attach there (`CodePane::pre_attach` focuses + /// the existing editor instead) and would be lost. + pub(crate) fn can_join_tab(&self, target: &PaneGroup) -> bool { + self.can_hand_over_panes() && !(self.has_code_panes() && target.has_code_panes()) } - /// 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. + /// Removes every visible pane, with their layout and the child agents they + /// host, 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() { + ) -> Option { + if !self.can_hand_over_panes() { return None; } let layout = self.panes.visible_layout()?; @@ -5018,49 +5060,72 @@ impl PaneGroup { .iter() .map(PaneMoveBundle::pane_id) .collect::>(); + let (child_agent_panes, staying) = std::mem::take(&mut self.child_agent_panes) + .into_iter() + .partition(|(_, pane_id)| moved.contains(pane_id)); + self.child_agent_panes = staying; let layout = layout.retain(&|pane_id| moved.contains(&pane_id))?; - Some((layout, panes)) + Some(JoinedPanes { + layout, + panes, + child_agent_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. + /// Inserts panes taken from another tab ([`Self::take_panes_for_join`]) as + /// one split at `direction` of `base_pane_id`. Their layout keeps its own + /// structure inside the base pane's former space (the half the drop zone + /// showed); other panes keep their sizes. Falls back to the tab edge when + /// the base pane is gone. 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, + joined: JoinedPanes, 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 JoinedPanes { + layout, + panes, + child_agent_panes, + } = joined; + let mut attached = Vec::new(); + let mut originals = Vec::new(); + for pane in panes { + let PaneMoveBundle { + visible_pane, + temporary_original, + } = pane; + let pane_id = visible_pane.as_pane().id(); + if self.init_pane(visible_pane, ctx).is_none() { + log::error!("Joined pane {pane_id:?} refused to attach"); + continue; + } + attached.push(pane_id); + if let Some(original) = temporary_original { + originals.push((pane_id, original)); } } + let Some(layout) = layout.retain(&|pane_id| attached.contains(&pane_id)) else { + return; + }; + if !self.panes.graft(base_pane_id, &layout, direction) { + log::warn!("Join target {base_pane_id:?} is gone; joining at the tab edge"); + self.panes.graft_at_root(&layout, direction); + } + for (pane_id, original) in originals { + self.restore_temporary_original(pane_id, original, ctx); + } + self.child_agent_panes.extend( + child_agent_panes + .into_iter() + .filter(|(_, pane_id)| attached.contains(pane_id)), + ); + self.handle_pane_count_change(ctx); + + let first_pane = layout.first_pane(); let visible = self.panes.visible_pane_ids(); let focus = focus .filter(|pane_id| visible.contains(pane_id)) @@ -5069,69 +5134,7 @@ impl PaneGroup { 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); - } + ctx.emit(Event::AppStateChanged); } /// We return a pane_id if the pane successfully attached diff --git a/app/src/pane_group/tree.rs b/app/src/pane_group/tree.rs index bda5282a8ca..7a73bfdf2a0 100644 --- a/app/src/pane_group/tree.rs +++ b/app/src/pane_group/tree.rs @@ -162,6 +162,23 @@ impl PaneLayout { } } } + + /// A tree node with this layout; sibling panes start with even sizes. + fn to_node(&self) -> PaneNode { + match self { + PaneLayout::Leaf(pane_id) => PaneNode::Leaf(*pane_id), + PaneLayout::Split { axis, children } => PaneNode::Branch(PaneBranch { + axis: *axis, + nodes: children + .iter() + .map(|child| (DEFAULT_FLEX_SIZE, child.to_node())) + .collect(), + dividers: iter::repeat_with(Divider::new) + .take(children.len().saturating_sub(1)) + .collect(), + }), + } + } } /// Single Node in the tree of panes @@ -613,6 +630,26 @@ impl PaneData { .retain(&|pane_id| !self.is_pane_hidden(&pane_id)) } + /// Places `layout`, whose panes are not in this tree yet, next to `target` + /// in `direction`. The target's leaf becomes a split of the target and the + /// whole layout, so the layout keeps its own structure and shares only the + /// target's former space; neighbouring panes keep their sizes. Returns + /// false when `target` is not a leaf of this tree. + pub fn graft(&mut self, target: PaneId, layout: &PaneLayout, direction: Direction) -> bool { + let grafted = self.root.graft(target, layout, direction); + if grafted { + self.len += layout.pane_ids().len(); + } + grafted + } + + /// Places `layout` at the `direction` edge of the whole tree. + pub fn graft_at_root(&mut self, layout: &PaneLayout, direction: Direction) { + let root = mem::replace(&mut self.root, PaneNode::Leaf(layout.first_pane())); + self.root = PaneNode::Branch(PaneBranch::new(root, layout.to_node(), direction)); + self.len += layout.pane_ids().len(); + } + /// 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 @@ -829,6 +866,24 @@ impl PaneNode { } } + fn graft(&mut self, target: PaneId, layout: &PaneLayout, direction: Direction) -> bool { + match self { + PaneNode::Leaf(pane_id) if *pane_id == target => { + *self = PaneNode::Branch(PaneBranch::new( + PaneNode::Leaf(target), + layout.to_node(), + direction, + )); + true + } + PaneNode::Leaf(_) => false, + PaneNode::Branch(branch) => branch + .nodes + .iter_mut() + .any(|(_, node)| node.graft(target, layout, direction)), + } + } + fn layout(&self) -> PaneLayout { match self { PaneNode::Leaf(pane) => PaneLayout::Leaf(*pane), diff --git a/app/src/pane_group/tree_tests.rs b/app/src/pane_group/tree_tests.rs index a61ac91a6d1..3d635514f49 100644 --- a/app/src/pane_group/tree_tests.rs +++ b/app/src/pane_group/tree_tests.rs @@ -877,3 +877,56 @@ fn only_running_or_moving_hidden_panes_block_handing_over_a_group() { tree.hide_pane_for_child_agent(hidden); assert!(tree.has_hidden_panes_in_use()); } + +#[test] +fn graft_keeps_the_joined_layout_inside_the_target_pane() { + let [neighbour, target, a, b] = [(); 4].map(|_| PaneId::dummy_pane_id()); + let mut tree = PaneData::new(neighbour); + tree.split(neighbour, target, Direction::Right); + let joined = PaneLayout::Split { + axis: SplitDirection::Horizontal, + children: vec![PaneLayout::Leaf(a), PaneLayout::Leaf(b)], + }; + + assert!(tree.graft(target, &joined, Direction::Right)); + + // Same axis as the parent split, yet nested: the joined panes share only + // the target's former space and the neighbour is untouched. + assert_eq!( + tree.visible_layout(), + Some(PaneLayout::Split { + axis: SplitDirection::Horizontal, + children: vec![ + PaneLayout::Leaf(neighbour), + PaneLayout::Split { + axis: SplitDirection::Horizontal, + children: vec![PaneLayout::Leaf(target), joined.clone()], + }, + ], + }) + ); + assert_eq!(tree.len(), 4); + assert_eq!(tree.visible_pane_ids(), vec![neighbour, target, a, b]); +} + +#[test] +fn graft_before_the_target_and_at_the_root() { + let [target, joined_pane, missing, edge] = [(); 4].map(|_| PaneId::dummy_pane_id()); + let mut tree = PaneData::new(target); + + assert!(tree.graft(target, &PaneLayout::Leaf(joined_pane), Direction::Up)); + assert_eq!( + tree.visible_layout(), + Some(PaneLayout::Split { + axis: SplitDirection::Vertical, + children: vec![PaneLayout::Leaf(joined_pane), PaneLayout::Leaf(target)], + }) + ); + + assert!(!tree.graft(missing, &PaneLayout::Leaf(edge), Direction::Right)); + assert_eq!(tree.len(), 2); + + tree.graft_at_root(&PaneLayout::Leaf(edge), Direction::Right); + assert_eq!(tree.visible_pane_ids(), vec![joined_pane, target, edge]); + assert_eq!(tree.len(), 3); +} diff --git a/app/src/workspace/view.rs b/app/src/workspace/view.rs index c20cc159507..63fa97a8d25 100644 --- a/app/src/workspace/view.rs +++ b/app/src/workspace/view.rs @@ -34137,7 +34137,7 @@ impl Workspace { } 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() + if !source.as_ref(ctx).can_join_tab(target.as_ref(ctx)) || self.tab_has_pending_cross_window_daemon_start(dragged_index, ctx) { return None; @@ -34188,8 +34188,12 @@ impl Workspace { else { return; }; + let source_group = self.tabs[source_index].pane_group.clone(); if source_index == self.active_tab_index || self.tab_has_pending_cross_window_daemon_start(source_index, ctx) + || !source_group + .as_ref(ctx) + .can_join_tab(target_group.as_ref(ctx)) || !target_group .as_ref(ctx) .visible_pane_ids() @@ -34197,29 +34201,20 @@ impl Workspace { { 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)) + let Some(joined) = 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() { + if let [pane_id] = joined.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, - ); + group.insert_joined_panes(target.pane_id, target.direction, joined, Some(focused), ctx); }); self.focus_active_tab(ctx); } diff --git a/docs/ui/pane-launch-and-tab-join.html b/docs/ui/pane-launch-and-tab-join.html index 6274429bce0..4faf908378e 100644 --- a/docs/ui/pane-launch-and-tab-join.html +++ b/docs/ui/pane-launch-and-tab-join.html @@ -100,10 +100,10 @@

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 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 J3 Loslassen über einer Drop-Zone fügt alle Panes des gezogenen Tabs mit ihrem Layout als Split an dieser Kante ein. Das Layout teilt sich den bisherigen Platz des Ziel-Panes (die markierte Hälfte), übrige Panes behalten ihre Größe; 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.
  • -
  • #spec J6 Ein Tab mit noch laufendem provisorischen Daemon-/Managed-Start kann nicht eingefügt werden (wie #spec R1 beim Fenster-Drag).
  • +
  • #spec J6 Keine Drop-Zone und kein Einfügen für einen Tab mit noch laufendem provisorischen Daemon-/Managed-Start (wie #spec R1 beim Fenster-Drag), mit verborgenen laufenden Panes (Hintergrundjob, Child-Agent) oder offener Konversations-Wiederherstellung bzw. Zusammenfassung. Editor-Panes werden nur in einen Tab ohne Editor eingefügt, weil ein dort schon geöffnetes File das Pane sonst verwirft. Child-Agent-Zuordnungen wandern mit.
  • #spec J7 Gegenrichtung bleibt: Pane-Kopfzeile auf die Tab-Leiste ziehen erzeugt einen eigenen Tab.
  • #spec J8 Drop-Zone nutzt ausschließlich Theme-Rollen (Akzent als Rand und transparente Fläche); keine dauerhaften Farbränder.
diff --git a/specs/GH-160/PRODUCT.md b/specs/GH-160/PRODUCT.md index fb822fbffad..cc14cd0b524 100644 --- a/specs/GH-160/PRODUCT.md +++ b/specs/GH-160/PRODUCT.md @@ -284,8 +284,11 @@ The previous static illustrations are superseded, not alternative approved layou 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 (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 + indicated edge, inside that pane's former space; other panes keep their sizes. The source tab + closes and no connection, PTY, or agent restarts; child-agent bindings move along. Tabs with + hidden running panes, a pending conversation restore or summarization, or a provisional daemon + start offer no drop target, and editor panes join only a tab without editors. 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.