feat(tabs): drag a tab in as a pane, and a pane out as a tab (#651)

* feat(tabs): drag a tab in as a pane, and a pane out as a tab

A tab dragged by its chip or its sidebar row can be dropped over the
panes to become one of them, and a pane dragged by its grip can be
dropped on the strip or the sidebar to become a tab of its own. Both
carry the panes across as they are: nothing is spawned and nothing is
killed, so a shell mid-command, an SSH session or an agent mid-turn
keeps running.

The landing is read the way a pane drag's already is, minus the middle:
an arriving tab has nothing here to trade places with, so a pane's core
means "split it the way it is longest". A tab that was itself split
arrives with its own shape intact and takes one share of the row or
column it joined. A pane on its way out is offered a caret between two
tabs, and the last pane in a tab is offered nothing, being a tab of its
own already.

Picking a tab up no longer switches to it: the strip and the sidebar
now activate on the click rather than on the press. Without that the
merge cannot be expressed at all — pressing the tab to drag it would
put it on screen, leaving no other tab to drop it into.

Two things in the machine tree had to follow:

* Panes that change tabs are told as PaneMove, one at a time, rather
  than as a tab closing and another being rebuilt around them.
* The tabs the machine already has are reconciled before new ones are
  created, so a pane leaving for a tab of its own is given up by the
  old tab before the new one asks to register it. The machine refuses a
  pane that is in two tabs at once, and the refusal desynced the window.

Closes #621

* test(tree-sync): a tab grafted above a whole layout still converges

* fix(tabs): keep a click on the close button from switching tabs

Switching on the release rather than the press means every click inside
a chip or a sidebar row now reaches the row itself, and gpui-component's
`Button` does not stop propagation on a click it handled. So one click on
a tab's close button ran `close_tab(i)` and then `activate(i)` — with `i`
by then naming whichever tab had slid into that slot, which moved the
active tab somewhere nobody asked for. A click into the rename field did
the same: it switched away from the tab whose name was being typed, and
took the focus out of the field with it.

Both now hold the click where they handled it, the way they already held
the press.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
This commit is contained in:
l0ng-ai
2026-08-15 17:05:34 +08:00
committed by GitHub
co-authored by l0ng-ai
parent 3ef644d267
commit 9fc0f331e8
10 changed files with 1203 additions and 54 deletions
+16
View File
@@ -9,6 +9,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- **A tab can be dropped into another tab, as a pane of it** (#621). Drag a tab
by its chip or by its sidebar row, out over the panes, and it lands where the
highlight says — the same reading as dragging a pane, minus the middle, which
for a tab means "split this pane the way it is longest" rather than "trade
places". A tab that was itself split arrives with its panes still arranged the
way you left them and takes one share of the row or column it joined. Nothing
restarts on the way over: a shell mid-command, an SSH session, an agent
halfway through a turn all carry on, and only the tab they were in goes away.
Picking a tab up no longer switches to it, so the tab you drop into is the one
you were already looking at; a plain click still switches.
- **And back out again: a pane dragged onto the tab bar becomes a tab of its
own** (#621). Take a pane by its grip up to the strip — or out to the sidebar,
wherever the tabs are — and a caret says which two tabs it would go between.
The last pane in a tab is offered nothing, being a tab of its own already.
- **Give the prompt back to the shell** — a new **Settings → Input → Prompt →
Prompt editor** switch (`prompt_editor` in `config.json`, on by default).
Turned off, tty7 stops editing the shell prompt: every keystroke there —
+6
View File
@@ -58,6 +58,12 @@ Drag a row to reorder it within its group, or drag a whole group header to move
the group. A row cannot be dragged into a different group: a tab's group comes
from its working directory, so `cd` is what moves it.
Drag a row out over the panes instead and it stops being a session of its own:
it lands as a pane of the tab on screen, wherever the highlight says. Dragging a
pane the other way — by its grip, onto the sidebar — gives it a row of its own,
between whichever two the caret lands between. See
[making one tab a pane of another](/window/tabs-and-splits#making-one-tab-a-pane-of-another).
## Naming
Almost no tab has a name of its own, so the sidebar falls back:
+29
View File
@@ -82,6 +82,35 @@ Where you drop it decides what happens:
The landing lights up while you drag, and only ever lights up when the drop
would actually change the layout.
## Making one tab a pane of another
Two sessions you keep switching between belong in one tab. Drag a tab — by its
chip in the tab bar, or by its row in the sidebar — out over the panes and drop
it where you want it: it becomes a pane of the tab you are looking at, and the
tab it came from is gone.
The landing reads exactly as it does for a pane, minus the middle: a tab has
nothing here to trade places with, so a pane's middle means "split it the way it
is longest". A tab that was itself split arrives with its panes still arranged
the way you left them, taking one share of whatever row or column it joined.
Nothing restarts on the way over. A shell mid-command, an SSH session, a coding
agent halfway through a turn — all of them keep running; only the tab they were
in goes away. The tab's *name* does not come along.
Picking a tab up does not switch to it, so the tab you drop into is the one you
were already looking at. A plain click still switches, as always.
## Making a pane a tab of its own
The same move backwards. Drag a pane by its grip up to the tab bar — or out to
the sidebar, if that is where your tabs live — and a caret shows which two tabs
it would go between. Drop it and it leaves the split it was in and becomes a tab
of its own, at that spot.
Nothing restarts here either. The last pane in a tab has nowhere to go, since it
is already a tab on its own, so no caret appears for it.
## Closing something that is busy
Closing a pane or tab with a command still running asks first, and says what is
+358 -3
View File
@@ -1,6 +1,6 @@
use gpui::{
App, Axis, Bounds, Context, Entity, Focusable, Pixels, PromptLevel, Subscription, Window, div,
img, prelude::*, px,
img, point, prelude::*, px, size,
};
use gpui_component::color_picker::{ColorPickerEvent, ColorPickerState};
use gpui_component::input::{InputEvent, InputState};
@@ -644,6 +644,31 @@ pub struct Tty7App {
/// The pane the pointer is over, so only that one offers its drag handle.
pub(crate) pane_hover: Rc<Cell<Option<gpui::EntityId>>>,
pub(crate) pane_drag: crate::ui::pane_drag::PaneDragState,
/// Where a tab held over the layout would be grafted in, as the last
/// painted frame read it. The same bargain the pane drag's landing keeps:
/// offered only once the tree agrees it changes something, so releasing
/// over a highlight always does what the highlight showed.
pub(crate) tab_merge: Cell<
Option<(
tty7_core::core::machine::TabId,
crate::ui::pane_drag::DropZone<gpui::EntityId>,
)>,
>,
/// The pane a drag would put down as a tab of its own, and where among the
/// tabs it would go. Read back a frame later by the drop, like the two
/// landings above.
pub(crate) pane_detach: Cell<Option<(gpui::EntityId, usize)>>,
/// Where the strip drew each tab's chip and where the sidebar drew each
/// tab's row, by tab — the geometry a pane dropped on either of them reads
/// its new place out of.
///
/// Written from paint, so what a frame reads is where things were on the
/// frame before. That is exactly as good as it needs to be: neither the
/// strip nor the sidebar moves while a drag is in flight. A tab with no
/// rectangle was not drawn — scrolled out of a full strip, filtered out of
/// the sidebar — and takes no part in the reading.
pub(crate) strip_slots: Rc<RefCell<Vec<Bounds<Pixels>>>>,
pub(crate) sidebar_slots: Rc<RefCell<Vec<Bounds<Pixels>>>>,
/// Where the active tab's panes were last drawn, which is the frame of
/// reference a drag's landing is worked out in.
pub(crate) pane_area: Rc<Cell<Option<Bounds<Pixels>>>>,
@@ -1209,6 +1234,10 @@ impl Tty7App {
reorder: Rc::new(RefCell::new(None)),
pane_hover: Rc::new(Cell::new(None)),
pane_drag: Rc::new(RefCell::new(None)),
tab_merge: Cell::new(None),
pane_detach: Cell::new(None),
strip_slots: Rc::new(RefCell::new(Vec::new())),
sidebar_slots: Rc::new(RefCell::new(Vec::new())),
pane_area: Rc::new(Cell::new(None)),
sidebar_search,
_sidebar_search_sub: sidebar_search_sub,
@@ -3518,6 +3547,309 @@ impl Tty7App {
)
}
/// The patch of the layout a tab held over it would be grafted into, lit
/// up — and, while that is on offer, the strip held still underneath it.
///
/// A tab is dragged with the same grip that reorders it, so the two
/// readings share one gesture: over the strip or the sidebar it is a
/// reorder, out over the panes it is a merge. Suspending the reorder is
/// what keeps the drop from being both.
fn tab_landing(&self, window: &Window, cx: &App) -> Option<gpui::AnyElement> {
self.tab_merge.set(None);
let dragged = crate::ui::reorder::dragged_tab(&self.reorder);
let offer = dragged.and_then(|id| self.tab_landing_rect(id, window));
crate::ui::reorder::suspend(&self.reorder, offer.is_some());
let (zone, rect) = offer?;
let area = self.pane_area.get()?;
self.tab_merge.set(Some((dragged?, zone)));
let accent = cx.theme().drag_border;
Some(
div()
.absolute()
.left(rect.origin.x - area.origin.x)
.top(rect.origin.y - area.origin.y)
.w(rect.size.width)
.h(rect.size.height)
.rounded(px(6.))
.border_2()
.border_color(accent)
.bg(accent.opacity(0.15))
.into_any_element(),
)
}
/// Where the tab `id` would land in the tab on screen, if it can land there
/// at all — it cannot land in itself, and there is nothing to land in while
/// a pane is zoomed over the layout.
fn tab_landing_rect(
&self,
id: tty7_core::core::machine::TabId,
window: &Window,
) -> Option<(
crate::ui::pane_drag::DropZone<gpui::EntityId>,
Bounds<Pixels>,
)> {
use crate::ui::pane_drag;
if self.maximized.is_some() {
return None;
}
let area = self.pane_area.get()?;
let host = self.tabs.get(self.active)?;
if host.tree_id.get() == id {
return None;
}
let sub = &self.tabs.iter().find(|t| t.tree_id.get() == id)?.pane;
let leaves = host.pane.leaves();
let bounds = pane_drag::leaf_bounds(&host.pane, area);
let zone = pane_drag::tab_zone_at(area, &bounds, window.mouse_position())?;
// Named by pane rather than by position for the drop a frame later,
// the same way a pane drag's landing is.
let here = zone.map(|i| leaves.get(i).cloned())?;
let pinned = zone.map(|i| leaves.get(i).map(|l| l.entity_id()))?;
let rect = pane_drag::graft_landing(&host.pane, sub, here, area)?;
Some((pinned, rect))
}
/// Merges a dragged tab into the tab on screen, where the last painted
/// frame said it would go.
///
/// The panes come across as they were arranged, and the tab they came from
/// goes away with them — nothing is killed and nothing is respawned, so a
/// shell mid-command carries on through the move. What the source tab held
/// besides panes is the tab's own: its name goes, and its overlays come
/// along only where the tab receiving them has none of its own to lose.
fn merge_tab(
&mut self,
source: tty7_core::core::machine::TabId,
zone: crate::ui::pane_drag::DropZone<gpui::EntityId>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.pane_hover.set(None);
let Some(from) = self.tabs.iter().position(|t| t.tree_id.get() == source) else {
return;
};
let Some(host) = self.tabs.get(self.active) else {
return;
};
if host.tree_id.get() == source {
return;
}
let leaves = host.pane.leaves();
let Some(zone) = zone.map(|id| leaves.iter().find(|l| l.entity_id() == id).cloned()) else {
return;
};
let mut moved = self.tabs.remove(from);
if from < self.active {
self.active -= 1;
}
// Lifted out rather than cloned: these are the live terminals, and a
// graft that finds nowhere to put them has to be able to hand them
// back intact.
let sub = std::mem::replace(&mut moved.pane, crate::ui::pane::Pane::Empty);
let first = sub.first_leaf();
let grafted = match self.tabs.get_mut(self.active) {
Some(host) => crate::ui::pane_drag::graft(&mut host.pane, sub, zone),
None => Err(sub),
};
if let Err(sub) = grafted {
moved.pane = sub;
self.tabs.insert(from, moved);
if from <= self.active {
self.active += 1;
}
return;
}
let host = &mut self.tabs[self.active];
if host.code.is_none() {
host.code = moved.code;
}
if host.diff_overlay.is_none() {
host.diff_overlay = moved.diff_overlay;
}
if self
.renaming
.as_ref()
.is_some_and(|r| !self.tabs.iter().any(|t| t.tree_id.get() == r.tab))
{
self.renaming = None;
}
self.maximized = None;
if let Some(leaf) = first {
self.focus_leaf(&leaf, window, cx);
}
self.save_session(cx);
cx.notify();
}
/// The caret between two tabs where a pane carried up to the strip — or
/// out to the sidebar — would become a tab of its own.
///
/// The reverse of grafting a tab in, and read the same way: offered only
/// when the drop would change something, which for a detach means the pane
/// has somewhere to leave. The last pane in a tab is already a tab, so
/// nothing lights up for it.
fn detach_caret(&self, window: &Window, cx: &App) -> Option<gpui::AnyElement> {
self.pane_detach.set(None);
let pane = crate::ui::pane_drag::lifted(&self.pane_drag)?;
let tab = self.tabs.get(self.active)?;
if tab.pane.leaves().len() < 2 {
return None;
}
if !tab.pane.leaves().iter().any(|l| l.entity_id() == pane) {
return None;
}
// The pane's own landing is read later in the frame and answers nothing
// while the pointer is up here, so the two are never both on offer;
// the drop takes this one first regardless.
let (at, caret) = self.detach_slot(window, cx)?;
self.pane_detach.set(Some((pane, at)));
let accent = cx.theme().drag_border;
Some(
div()
.absolute()
.left(caret.origin.x)
.top(caret.origin.y)
.w(caret.size.width)
.h(caret.size.height)
.rounded_full()
.bg(accent)
.into_any_element(),
)
}
/// Which gap between tabs the pointer is in, as the tab a newcomer would
/// be inserted before and the caret marking it — on the strip, or on the
/// sidebar, whichever the pointer is over.
fn detach_slot(&self, window: &Window, cx: &App) -> Option<(usize, Bounds<Pixels>)> {
/// How far above its first row the sidebar's band starts, so the gap
/// over that row is inside it.
const BAND_REACH: f32 = 6.;
let pointer = window.mouse_position();
// The two surfaces never share a window: the sidebar *is* the tab bar
// when it is up, and the strip is what stands in for it when it is not.
let vertical = matches!(cx.global::<Config>().tab_bar_position, TabBarPosition::Left)
&& !self.tabs.is_empty();
let viewport = window.viewport_size();
// The band each surface claims, read off the tabs it drew rather than
// measured as an element of its own: the chips and the rows are the
// only part of either surface a drop has anything to say about, and a
// band derived from them cannot disagree with the gaps measured inside
// it. Stretched past the outermost tab so the empty space beyond —
// where the strip keeps its New Tab button, where the sidebar keeps
// nothing at all — still reads as "after everything".
let band = |axis: Axis, drawn: &[(usize, Bounds<Pixels>)]| {
let top = drawn.iter().map(|(_, b)| b.origin.y).reduce(Pixels::min)?;
let left = drawn.iter().map(|(_, b)| b.origin.x).reduce(Pixels::min)?;
let right = drawn
.iter()
.map(|(_, b)| b.origin.x + b.size.width)
.reduce(Pixels::max)?;
Some(match axis {
Axis::Horizontal => Bounds {
origin: point(px(0.), px(0.)),
size: size(viewport.width, px(TITLE_BAR_HEIGHT)),
},
Axis::Vertical => Bounds {
origin: point(left, top - px(BAND_REACH)),
size: size(
right - left,
(viewport.height - top + px(BAND_REACH)).max(px(0.)),
),
},
})
};
// Sorted by where they were drawn rather than by tab: the sidebar
// groups its rows, and a strip that has scrolled shows a window of
// chips. What the pointer is between is a matter of the screen.
let measured = |slots: &[Bounds<Pixels>]| -> Vec<(usize, Bounds<Pixels>)> {
slots
.iter()
.enumerate()
.filter(|(_, b)| b.size.width > px(0.) && b.size.height > px(0.))
.map(|(i, b)| (i, *b))
.collect()
};
let surfaces = [
(!vertical).then(|| (Axis::Horizontal, measured(&self.strip_slots.borrow()))),
self.sidebar_open(cx)
.then(|| (Axis::Vertical, measured(&self.sidebar_slots.borrow()))),
];
let (axis, mut drawn) = surfaces
.into_iter()
.flatten()
.find(|(axis, drawn)| band(*axis, drawn).is_some_and(|b| b.contains(&pointer)))?;
let lead = |b: &Bounds<Pixels>| match axis {
Axis::Horizontal => b.origin.x,
Axis::Vertical => b.origin.y,
};
drawn.sort_by(|(_, a), (_, b)| lead(a).as_f32().total_cmp(&lead(b).as_f32()));
let row: Vec<Bounds<Pixels>> = drawn.iter().map(|(_, b)| *b).collect();
let (gap, caret) = crate::ui::pane_drag::insertion(&row, axis, pointer)?;
// The gap counts tabs on screen; what an insert needs is a place in the
// list. Past the last tab drawn is the end of the list, which is not
// the same thing as the last tab drawn plus one: a strip too narrow to
// show every chip has tabs on either side of the ones it drew.
let at = drawn.get(gap).map(|(i, _)| *i).unwrap_or(self.tabs.len());
Some((at, caret))
}
/// Takes a dragged pane out of its tab and gives it one of its own, where
/// the last painted frame's caret said.
///
/// Nothing is spawned and nothing is killed: the pane that leaves is the
/// same pane, still running whatever it was running.
fn detach_pane(
&mut self,
pane: gpui::EntityId,
at: usize,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.pane_hover.set(None);
// Which pane the tab being left comes back to, settled while the one
// that is leaving is still in it to be ruled out.
self.remember_active_pane(window, cx);
let Some(tab) = self.tabs.get_mut(self.active) else {
return;
};
let Some(slot) = tab
.pane
.leaves()
.into_iter()
.find(|l| l.entity_id() == pane)
else {
return;
};
// Refused for the last pane in a tab, which is what makes a drop that
// would change nothing do nothing.
let Some(slot) = tab.pane.take_leaf(&slot) else {
return;
};
let cwd = slot
.terminal()
.and_then(|view| view.read(cx).spawnable_cwd());
let group = self.spawn_group(cwd.as_deref(), cx);
let fresh = Tab::new(crate::ui::pane::Pane::leaf(slot));
if let Some(group) = group {
*fresh.sidebar_group.borrow_mut() = group;
}
let at = at.min(self.tabs.len());
self.tabs.insert(at, fresh);
self.maximized = None;
self.active = at;
self.focus_active(window, cx);
self.save_session(cx);
cx.notify();
}
/// Puts a dragged pane down where the last painted frame said it would go.
///
/// Both ends of the drop are named by pane rather than by position, so a
@@ -6480,10 +6812,20 @@ impl Render for Tty7App {
crate::ui::reorder::clear_pending(&self.reorder);
crate::ui::pane_drag::clear_landing(&self.pane_drag);
} else {
if let Some(order) = crate::ui::reorder::take_pending(&self.reorder) {
// Taken first either way: this is what ends the drag, and the merge
// below must not find the tab it just moved still in the air.
let order = crate::ui::reorder::take_pending(&self.reorder);
if let Some((tab, zone)) = self.tab_merge.take() {
self.merge_tab(tab, zone, window, cx);
} else if let Some(order) = order {
self.apply_tab_order(&order, cx);
}
if let Some((from, zone)) = crate::ui::pane_drag::take_landing(&self.pane_drag) {
// Also what ends the pane drag, so it is taken whichever of the two
// readings the last frame left behind.
let landing = crate::ui::pane_drag::take_landing(&self.pane_drag);
if let Some((pane, at)) = self.pane_detach.take() {
self.detach_pane(pane, at, window, cx);
} else if let Some((from, zone)) = landing {
self.drop_pane(from, zone, window, cx);
}
}
@@ -6506,6 +6848,13 @@ impl Render for Tty7App {
// two spellings of "is the rail up" is one more than the layout can
// afford to have disagree.
let rail = self.sidebar_open(cx);
// Both read before the strip and the sidebar are built: a tab held out
// over the layout suspends the reorder, which is what those two ask
// what to draw, and a pane held over *them* is measured against where
// they put their tabs last frame — which is what they are about to
// blank and write again.
let tab_landing = self.tab_landing(window, cx);
let detach_caret = self.detach_caret(window, cx);
let strip = self.tab_strip(!vertical, window, cx);
let sidebar = rail.then(|| self.tab_sidebar(window, cx));
let ssh_status = self
@@ -6566,6 +6915,7 @@ impl Render for Tty7App {
)
.child(body)
.when_some(self.pane_landing(window, cx), |this, el| this.child(el))
.when_some(tab_landing, |this, el| this.child(el))
.when_some(ssh_status, |this, el| this.child(el))
.when_some(self.render_remote_workspace_strip(cx), |this, el| {
this.child(el)
@@ -6994,6 +7344,11 @@ impl Render for Tty7App {
.on_action(cx.listener(|_, _: &ReportIssue, _window, cx| cx.open_url(ISSUES_URL)))
.children(bg_image)
.child(main_layout)
// Window-level because the strip lives in the title bar and the
// sidebar down the side: the caret between two tabs is in
// neither of the boxes the rest of the drag feedback is drawn
// in.
.when_some(detach_caret, |this, caret| this.child(caret))
.when_some(settings_overlay, |this, overlay| this.child(overlay))
// Window-level, like the switcher and the palette: the prompt
// blocks the whole app, so its scrim has to reach the title bar
+172
View File
@@ -217,6 +217,35 @@ impl<L: Clone> Pane<L> {
}
}
/// Puts a whole subtree where the leaf `is_target` names stands.
///
/// The subtree arrives in an `Option` the walk takes it out of: a tree of
/// live panes cannot be cloned for every branch that might hold the target,
/// and a caller whose target was not there gets it back to put somewhere
/// else.
fn replace_leaf_with(
&mut self,
is_target: &impl Fn(&L) -> bool,
new: &mut Option<Pane<L>>,
) -> bool {
match self {
Pane::Leaf(v) => {
if !is_target(v) {
return false;
}
let Some(new) = new.take() else {
return false;
};
*self = new;
true
}
Pane::Split { a, b, .. } => {
a.replace_leaf_with(is_target, new) || b.replace_leaf_with(is_target, new)
}
Pane::Empty => false,
}
}
fn replace_leaf_where(&mut self, is_target: &impl Fn(&L) -> bool, new: L) -> bool {
match self {
Pane::Leaf(v) => {
@@ -580,6 +609,75 @@ impl<L: Clone> Pane<L> {
self.move_leaf_to_edge_where(&|v| v == src, dir)
}
/// Lifts a leaf out of this tree, for whoever is taking it somewhere this
/// tree cannot reach — a tab of its own, say.
///
/// `None` when the tree holds nothing else: the last pane in a tab has
/// nowhere to go that is not where it already is.
pub fn take_leaf(&mut self, target: &L) -> Option<L>
where
L: PartialEq,
{
self.take_leaf_where(&|v| v == target)
}
/// Grafts a whole subtree in on the `dir` side of `dst`.
///
/// The same reading as [`Self::move_leaf_beside`], with a tab's worth of
/// panes arriving instead of one of this tab's own: the newcomer joins the
/// run it faces as an equal where there is one, and splits `dst` where
/// there is not. Whatever shape it brought with it it keeps.
///
/// A graft with nowhere to go hands the subtree back rather than dropping
/// it: what is being passed around is the live panes themselves, and a
/// refusal that swallowed them would take a tab's worth of terminals down.
///
/// Carried out as a one-pane move and then a swap — one of the newcomer's
/// own panes goes in first and is traded for the whole tab once the row it
/// joined has shared itself out. Grafting the tab straight in would have it
/// dissolve into that row wherever the two split the same way: three panes
/// arriving into a row of two would come out as five columns sharing a
/// fifth each, rather than as one column of three beside the other two.
pub fn graft_beside(&mut self, sub: Pane<L>, dst: &L, dir: Dir) -> Result<(), Pane<L>>
where
L: PartialEq,
{
let Some(anchor) = sub.first_leaf() else {
return Err(sub);
};
let mut next = self.shallow_clone();
if !next.split_leaf_where(&|v| v == dst, dir.axis(), dir.leads(), anchor.clone()) {
return Err(sub);
}
next.share_out_run(dir.axis(), &|v| *v == anchor, dir.leads());
let mut held = Some(sub);
if !next.replace_leaf_with(&|v| *v == anchor, &mut held) {
return Err(held.expect("the anchor this graft just planted is still here"));
}
*self = next;
Ok(())
}
/// Grafts a whole subtree against an outer edge of the tab, beside
/// everything already here — one more band along `dir`, sized like the
/// bands it joins. Refused the same way as [`Self::graft_beside`].
pub fn graft_at_edge(&mut self, sub: Pane<L>, dir: Dir) -> Result<(), Pane<L>> {
if sub.leaves().is_empty() || matches!(self, Pane::Empty) {
return Err(sub);
}
let slices = self.slices_along(dir.axis()).max(1);
let share = 1. / (slices + 1) as f32;
let rest = self.shallow_clone();
let (a, b) = if dir.leads() {
(sub, rest)
} else {
(rest, sub)
};
let ratio = if dir.leads() { share } else { 1. - share };
*self = Pane::split_node(dir.axis(), ratio, a, b);
Ok(())
}
/// Trades two panes' places, each keeping the other's size.
pub fn swap_leaves(&mut self, a: &L, b: &L) -> bool
where
@@ -1811,6 +1909,80 @@ mod tests {
assert_well_formed(&pane);
}
/// The tab arriving from somewhere else: two panes, side by side.
fn newcomer() -> TestPane {
TestPane::split_node(Axis::Horizontal, 0.5, Pane::Leaf(8), Pane::Leaf(9))
}
#[test]
fn a_grafted_tab_splits_the_pane_it_landed_on() {
let mut pane = TestPane::leaf(0);
assert!(pane.graft_beside(newcomer(), &0, Dir::Right).is_ok());
assert_eq!(pane.leaves(), vec![0, 8, 9]);
match &pane {
Pane::Split { axis, a, b, .. } => {
assert!(matches!(axis, Axis::Horizontal));
assert!(matches!(**a, Pane::Leaf(0)));
assert_eq!(b.leaves(), vec![8, 9], "the tab kept its own shape");
}
_ => panic!("the leaf should have become a split"),
}
assert_eq!(widths(&pane), vec![0.5, 0.25, 0.25]);
assert_well_formed(&pane);
}
#[test]
fn a_grafted_tab_joins_a_row_as_one_of_its_columns() {
let mut pane = row_over();
assert!(pane.graft_beside(newcomer(), &0, Dir::Right).is_ok());
assert_eq!(pane.leaves(), vec![0, 8, 9, 1, 2]);
assert_eq!(
widths(&pane),
vec![0.333, 0.167, 0.167, 0.333, 1.0],
"the newcomer is one column of three, split between its own two"
);
assert_well_formed(&pane);
}
#[test]
fn a_tab_grafted_at_an_edge_is_a_band_beside_everything() {
let mut pane = grid();
assert!(pane.graft_at_edge(newcomer(), Dir::Right).is_ok());
assert_eq!(pane.leaves(), vec![0, 1, 2, 3, 8, 9]);
match &pane {
Pane::Split {
axis, a, b, ratio, ..
} => {
assert!(matches!(axis, Axis::Horizontal));
assert_eq!(a.leaves(), vec![0, 1, 2, 3]);
assert_eq!(b.leaves(), vec![8, 9]);
assert!(
(ratio.get() - 2. / 3.).abs() < 1e-6,
"two columns receive a third, not a half"
);
}
_ => panic!("an edge graft is always a split at the root"),
}
assert_well_formed(&pane);
}
#[test]
fn a_graft_with_nowhere_to_go_hands_the_panes_back() {
let mut pane = TestPane::leaf(0);
let refused = pane
.graft_beside(newcomer(), &99, Dir::Right)
.expect_err("there is no pane 99 to land beside");
assert_eq!(refused.leaves(), vec![8, 9], "the tab came back intact");
assert_eq!(pane.leaves(), vec![0]);
let empty = pane
.graft_beside(Pane::Empty, &0, Dir::Right)
.expect_err("an empty tab has nothing to graft");
assert!(matches!(empty, Pane::Empty));
assert_eq!(pane.leaves(), vec![0]);
assert_well_formed(&pane);
}
#[test]
fn swap_leaf_indices_trades_payloads_in_place() {
let mut pane = TestPane::leaf(0);
+246
View File
@@ -308,6 +308,139 @@ pub(crate) fn apply<L: Clone + PartialEq>(pane: &mut Pane<L>, from: &L, zone: Dr
}
}
/// Grafts a whole tab's worth of panes in the way `zone` says, handing them
/// back when the zone has nowhere to put them.
pub(crate) fn graft<L: Clone + PartialEq>(
pane: &mut Pane<L>,
sub: Pane<L>,
zone: DropZone<L>,
) -> Result<(), Pane<L>> {
match zone {
DropZone::Edge(dir) => pane.graft_at_edge(sub, dir),
DropZone::Side(dst, dir) => pane.graft_beside(sub, &dst, dir),
// A tab arriving has no place of its own to trade for one here.
DropZone::Swap(_) => Err(sub),
}
}
/// Where a dragged tab would end up, as the patch of screen its panes would
/// fill between them.
///
/// Measured the same way as a pane's landing — by carrying the drop out on a
/// copy — so the highlight is wrong exactly when the drop is.
pub(crate) fn graft_landing<L: Clone + PartialEq>(
pane: &Pane<L>,
sub: &Pane<L>,
zone: DropZone<L>,
area: Bounds<Pixels>,
) -> Option<Bounds<Pixels>> {
let arrivals = sub.leaves();
let mut trial = pane.deep_clone();
graft(&mut trial, sub.deep_clone(), zone).ok()?;
let bounds = leaf_bounds(&trial, area);
trial
.leaves()
.iter()
.zip(bounds)
.filter(|(leaf, _)| arrivals.contains(leaf))
.map(|(_, b)| b)
.reduce(union)
}
/// The smallest rectangle holding both — the patch a grafted tab's panes cover
/// between them, which is the one rectangle they always tile.
fn union(a: Bounds<Pixels>, b: Bounds<Pixels>) -> Bounds<Pixels> {
let x = a.origin.x.min(b.origin.x);
let y = a.origin.y.min(b.origin.y);
let right = (a.origin.x + a.size.width).max(b.origin.x + b.size.width);
let bottom = (a.origin.y + a.size.height).max(b.origin.y + b.size.height);
Bounds {
origin: point(x, y),
size: size(right - x, bottom - y),
}
}
/// Where the pointer is asking a dragged *tab* to go.
///
/// The same reading as a pane's, minus the middle: a tab has nothing here to
/// trade places with, so a pane's core means "split it the way it is longest"
/// instead. Without that, the middle of a single-pane tab — most of the window
/// it is showing — would be a dead spot.
pub(crate) fn tab_zone_at(
area: Bounds<Pixels>,
leaves: &[Bounds<Pixels>],
pointer: Point<Pixels>,
) -> Option<DropZone> {
match zone_at(area, leaves, pointer)? {
DropZone::Swap(index) => {
let leaf = leaves.get(index)?;
let dir = if leaf.size.width >= leaf.size.height {
Dir::Right
} else {
Dir::Down
};
Some(DropZone::Side(index, dir))
}
zone => Some(zone),
}
}
/// How thick the caret between two tabs is, and how far past the outermost
/// tab it sits when the gap it marks is at one end of the row.
const CARET: f32 = 3.;
const CARET_REACH: f32 = 5.;
/// Which gap in a row of tabs the pointer is in — how many of them it is past
/// — and the caret that marks it.
///
/// `row` is the tabs in the order they were drawn, along `axis`. The answer
/// counts gaps, so a row of three has four of them: `0` before the first tab
/// and `3` after the last.
pub(crate) fn insertion(
row: &[Bounds<Pixels>],
axis: gpui::Axis,
pointer: Point<Pixels>,
) -> Option<(usize, Bounds<Pixels>)> {
let across = axis == gpui::Axis::Vertical;
let lead = |b: &Bounds<Pixels>| if across { b.origin.y } else { b.origin.x };
let trail = |b: &Bounds<Pixels>| {
if across {
b.origin.y + b.size.height
} else {
b.origin.x + b.size.width
}
};
let along = if across { pointer.y } else { pointer.x };
// Past a tab's middle is past the tab: the gap the pointer is in is the one
// it is nearest, and the halfway line is where "nearest" changes hands.
let gap = row
.iter()
.take_while(|b| (lead(b) + trail(b)) / 2. < along)
.count();
let before = gap.checked_sub(1).and_then(|k| row.get(k));
let after = row.get(gap);
let cut = match (before, after) {
(Some(a), Some(b)) => (trail(a) + lead(b)) / 2.,
(Some(a), None) => trail(a) + px(CARET_REACH),
(None, Some(b)) => lead(b) - px(CARET_REACH),
(None, None) => return None,
};
// Drawn as tall as the tab it is beside, so the caret reads as belonging to
// the row rather than to the window.
let neighbour = after.or(before)?;
let caret = match axis {
gpui::Axis::Horizontal => Bounds {
origin: point(cut - px(CARET / 2.), neighbour.origin.y),
size: size(px(CARET), neighbour.size.height),
},
gpui::Axis::Vertical => Bounds {
origin: point(neighbour.origin.x, cut - px(CARET / 2.)),
size: size(neighbour.size.width, px(CARET)),
},
};
Some((gap, caret))
}
/// Where the dragged pane would end up, as the patch of screen it would fill.
///
/// Worked out by carrying the drop out on a copy and measuring where the pane
@@ -602,6 +735,119 @@ mod tests {
);
}
#[test]
fn a_tab_has_nothing_to_trade_places_with_so_the_middle_splits() {
let square = rect(0., 0., 400., 400.);
let wide = rect(0., 0., 900., 300.);
let tall = rect(0., 0., 300., 900.);
let middle =
|b: Bounds<Pixels>| tab_zone_at(b, &[b], point(b.size.width / 2., b.size.height / 2.));
assert_eq!(middle(wide), Some(DropZone::Side(0, Dir::Right)));
assert_eq!(middle(tall), Some(DropZone::Side(0, Dir::Down)));
assert_eq!(
middle(square),
Some(DropZone::Side(0, Dir::Right)),
"a square pane splits the way a split command would"
);
assert_eq!(
tab_zone_at(area(), &grid(), point(px(4.), px(150.))),
Some(DropZone::Edge(Dir::Left)),
"everything outside the middle reads as it does for a pane"
);
}
#[test]
fn a_grafted_tab_lands_on_the_patch_its_panes_fill_between_them() {
let tab = rect(0., 0., 900., 600.);
let sub = Pane::split_node(gpui::Axis::Vertical, 0.5, Pane::leaf(8), Pane::leaf(9));
let at = |zone| graft_landing(&columns(), &sub, zone, tab);
assert_eq!(
at(DropZone::Side(0, Dir::Right)),
Some(rect(337.5, 0., 225., 600.)),
"the tab is one column of the four, with its own two stacked inside"
);
assert_eq!(
at(DropZone::Edge(Dir::Left)),
Some(rect(0., 0., 225., 600.)),
"a band beside three columns is the fourth of them"
);
assert_eq!(
at(DropZone::Swap(0)),
None,
"there is nothing here for an arriving tab to trade places with"
);
}
/// Three chips of 100, 10 apart, along a strip.
fn chips() -> Vec<Bounds<Pixels>> {
(0..3)
.map(|i| rect(20. + i as f32 * 110., 4., 100., 30.))
.collect()
}
#[test]
fn a_pane_carried_to_the_strip_lands_in_the_gap_it_is_nearest() {
let at = |x: f32| {
insertion(&chips(), gpui::Axis::Horizontal, point(px(x), px(18.))).map(|(gap, _)| gap)
};
assert_eq!(at(0.), Some(0), "before the first chip");
assert_eq!(
at(60.),
Some(0),
"the near half of a chip is the gap before it"
);
assert_eq!(at(80.), Some(1), "the far half is the gap after it");
assert_eq!(at(135.), Some(1), "the gap itself");
assert_eq!(
at(400.),
Some(3),
"past the last chip is the end of the row"
);
}
#[test]
fn the_caret_stands_in_the_gap_and_is_as_tall_as_the_tabs() {
let caret = |x: f32| {
insertion(&chips(), gpui::Axis::Horizontal, point(px(x), px(18.))).map(|(_, c)| c)
};
assert_eq!(
caret(135.),
Some(rect(123.5, 4., 3., 30.)),
"between two chips it splits the space between them"
);
assert_eq!(
caret(0.),
Some(rect(13.5, 4., 3., 30.)),
"at the head of the row it stands off the first chip"
);
assert_eq!(
caret(400.),
Some(rect(343.5, 4., 3., 30.)),
"at the end it stands off the last one"
);
}
#[test]
fn a_sidebar_reads_the_same_way_down_its_rows() {
let rows: Vec<Bounds<Pixels>> = (0..3)
.map(|i| rect(0., 50. + i as f32 * 44., 220., 40.))
.collect();
let at = |y: f32| insertion(&rows, gpui::Axis::Vertical, point(px(110.), px(y)));
assert_eq!(at(55.).map(|(gap, _)| gap), Some(0));
assert_eq!(at(140.).map(|(gap, _)| gap), Some(2));
assert_eq!(
at(300.),
Some((3, rect(0., 181.5, 220., 3.))),
"below the last row the caret lies across it"
);
assert_eq!(
insertion(&[], gpui::Axis::Vertical, point(px(0.), px(0.))),
None,
"no rows drawn, no gap to name"
);
}
#[test]
fn a_landing_leaves_the_layout_it_was_measured_on_alone() {
let live = columns();
+41 -1
View File
@@ -2,6 +2,7 @@ use gpui::{Axis, Bounds, Pixels, Point, Styled, px};
use std::cell::{Cell, RefCell};
use std::path::PathBuf;
use std::rc::Rc;
use tty7_core::core::machine::TabId;
pub(crate) type ReorderState = Rc<RefCell<Option<Reorder>>>;
@@ -34,7 +35,10 @@ pub(crate) fn preview(
pointer: Point<Pixels>,
) -> Option<Preview> {
let state = state.borrow();
let r = state.as_ref().filter(|r| r.covers(surface, len))?;
let r = state
.as_ref()
.filter(|r| !r.suspended.get())
.filter(|r| r.covers(surface, len))?;
let target = r.target(pointer);
let (generation, prev) = r.begin_frame(target);
Some(Preview {
@@ -65,6 +69,29 @@ pub(crate) fn take_pending(state: &ReorderState) -> Option<Vec<usize>> {
state.borrow_mut().take()?.pending.into_inner()
}
/// The tab a drag in flight picked up, when it picked one up.
///
/// A tab is dragged to reorder it, and — once the pointer leaves the strip for
/// the layout — to merge it into the tab on screen. Both are the same drag, so
/// the reorder is where the answer to "which tab is in the air" lives.
pub(crate) fn dragged_tab(state: &ReorderState) -> Option<TabId> {
state.borrow().as_ref()?.tab
}
/// Holds the reorder off while the drag is asking for something else.
///
/// A suspended reorder offers no preview and records nothing pending, so the
/// chips sit still while the pointer is out over the layout and a drop there
/// cannot also shuffle the strip.
pub(crate) fn suspend(state: &ReorderState, yes: bool) {
if let Some(r) = state.borrow().as_ref() {
r.suspended.set(yes);
if yes {
r.pending.borrow_mut().take();
}
}
}
#[derive(Clone, PartialEq, Eq, Debug)]
pub(crate) enum Surface {
Strip,
@@ -82,6 +109,11 @@ pub(crate) struct Reorder {
prev: Cell<usize>,
generation: Cell<usize>,
pending: RefCell<Option<Vec<usize>>>,
/// The tab this drag picked up, for the surfaces that drag tabs. `None` on
/// a surface that drags something else — a sidebar group, say, which is
/// several tabs and cannot be merged into one.
tab: Option<TabId>,
suspended: Cell<bool>,
}
impl Reorder {
@@ -103,9 +135,17 @@ impl Reorder {
prev: Cell::new(from),
generation: Cell::new(0),
pending: RefCell::new(None),
tab: None,
suspended: Cell::new(false),
}
}
/// Names the tab this drag is carrying.
pub(crate) fn of_tab(mut self, tab: TabId) -> Self {
self.tab = Some(tab);
self
}
pub(crate) fn covers(&self, surface: &Surface, len: usize) -> bool {
self.surface == *surface && self.rects.len() == len && self.from < len
}
+44 -15
View File
@@ -132,6 +132,10 @@ impl Tty7App {
let max_width = self.sidebar_max_px(window, cx);
let width = self.sidebar_width.get().clamp(MIN_SIDEBAR_WIDTH, max_width);
let query = self.sidebar_search.read(cx).value().trim().to_lowercase();
// Blanked here, written again from paint: a row filtered out by the
// search — or hidden with its collapsed group — must leave no rectangle
// behind for a pane to be dropped between.
*self.sidebar_slots.borrow_mut() = vec![Bounds::default(); self.tabs.len()];
// Every group drops itself when its rows filter out, so a query that
// matches nothing left the sidebar showing only its own search box.
let mut any_rows = false;
@@ -495,6 +499,12 @@ impl Tty7App {
.flex_1()
.min_w_0()
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
// The row switches tabs on the *release* now, so
// holding the press back is no longer enough: a click
// landing in the field would reach the row behind it
// and switch away from the name being typed, taking
// the focus with it.
.on_click(|_, _, cx| cx.stop_propagation())
.child(Input::new(&input).appearance(false))
.into_any_element(),
None => v_flex()
@@ -620,16 +630,20 @@ impl Tty7App {
let state = self.reorder.clone();
let slots = row_slots.clone();
let group_key = group_key.clone();
let id = tab.tree_id.get();
move |_drag, grab, _window, cx| {
cx.stop_propagation();
*state.borrow_mut() = Some(Reorder::new(
Surface::SidebarRows(group_key.clone()),
slot,
slots.borrow().clone(),
Axis::Vertical,
px(ROW_GAP),
grab,
));
*state.borrow_mut() = Some(
Reorder::new(
Surface::SidebarRows(group_key.clone()),
slot,
slots.borrow().clone(),
Axis::Vertical,
px(ROW_GAP),
grab,
)
.of_tab(id),
);
cx.new(|_| DragTab)
}
})
@@ -656,10 +670,17 @@ impl Tty7App {
canvas(
{
let slots = row_slots.clone();
// The row by tab as well as by slot: reordering
// reads the slots of one group, a pane dropped
// on the sidebar reads every row there is.
let by_tab = self.sidebar_slots.clone();
move |bounds, _window, _cx| {
if let Some(s) = slots.borrow_mut().get_mut(slot) {
*s = bounds;
}
if let Some(s) = by_tab.borrow_mut().get_mut(i) {
*s = bounds;
}
}
},
|_, _, _, _| {},
@@ -667,13 +688,15 @@ impl Tty7App {
.absolute()
.inset_0(),
)
.on_mouse_down(
MouseButton::Left,
cx.listener(move |this, _: &MouseDownEvent, window, cx| {
cx.stop_propagation();
this.activate(i, window, cx);
}),
)
// Switched on the release, not the press: a press that turns
// into a drag is the tab being picked up, and a tab on its
// way into another tab's layout must not put itself on
// screen on the way there.
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
.on_click(cx.listener(move |this, _, window, cx| {
cx.stop_propagation();
this.activate(i, window, cx);
}))
.child(self.tab_avatar(
("sidebar-avatar", i),
agent,
@@ -735,8 +758,14 @@ impl Tty7App {
.xsmall(),
)
.tooltip(t(L10nKey::TabContextCloseTab))
// Held here, because the row behind it
// switches tabs on the release too:
// without this the same click closes
// tab `i` and then activates whichever
// tab slid into its place.
.on_click(
cx.listener(move |this, _, window, cx| {
cx.stop_propagation();
this.close_tab(i, window, cx);
}),
),
+47 -25
View File
@@ -1,14 +1,12 @@
use gpui::{
Animation, AnimationExt as _, AnyElement, App, Axis, Bounds, Context, FontWeight, MouseButton,
MouseDownEvent, Pixels, SharedString, Window, canvas, deferred, div, ease_out_quint,
linear_color_stop, linear_gradient, prelude::*, px, relative,
Pixels, SharedString, Window, canvas, deferred, div, ease_out_quint, linear_color_stop,
linear_gradient, prelude::*, px, relative,
};
use gpui_component::button::{Button, ButtonCustomVariant, ButtonVariants as _};
use gpui_component::input::Input;
use gpui_component::menu::{ContextMenuExt as _, DropdownMenu as _, PopupMenu, PopupMenuItem};
use gpui_component::{ActiveTheme as _, Icon, IconName, Selectable as _, Sizable as _, h_flex};
use std::cell::RefCell;
use std::rc::Rc;
use unicode_segmentation::UnicodeSegmentation as _;
use crate::core::actions::{
@@ -1552,8 +1550,13 @@ impl Tty7App {
.max_w(chips_avail)
.overflow_hidden();
let slots: Rc<RefCell<Vec<Bounds<Pixels>>>> =
Rc::new(RefCell::new(vec![Bounds::default(); self.tabs.len()]));
// Held by the app rather than by the frame: a pane dropped up here has
// to read the gaps between the chips, and it is asking a frame later
// than the one that drew them. Blanked here and written again from
// paint, so a chip that is not drawn this time — the strip is hidden,
// or the chip scrolled out of it — leaves nothing behind to aim at.
let slots = self.strip_slots.clone();
*slots.borrow_mut() = vec![Bounds::default(); self.tabs.len()];
let preview = reorder::preview(
&self.reorder,
&Surface::Strip,
@@ -1594,6 +1597,11 @@ impl Tty7App {
.flex_1()
.min_w_0()
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
// The chip switches tabs on the *release* now, so holding
// the press back is no longer enough: a click landing in
// the field would reach the chip behind it and switch away
// from the name being typed, taking the focus with it.
.on_click(|_, _, cx| cx.stop_propagation())
.child(Input::new(&input).appearance(false))
.into_any_element(),
None => div()
@@ -1617,16 +1625,20 @@ impl Tty7App {
.on_drag(DragTab, {
let state = self.reorder.clone();
let slots = slots.clone();
let id = tab.tree_id.get();
move |_drag, grab, _window, cx| {
cx.stop_propagation();
*state.borrow_mut() = Some(Reorder::new(
Surface::Strip,
i,
slots.borrow().clone(),
Axis::Horizontal,
px(CHIP_GAP),
grab,
));
*state.borrow_mut() = Some(
Reorder::new(
Surface::Strip,
i,
slots.borrow().clone(),
Axis::Horizontal,
px(CHIP_GAP),
grab,
)
.of_tab(id),
);
cx.new(|_| DragTab)
}
})
@@ -1665,17 +1677,21 @@ impl Tty7App {
.absolute()
.inset_0(),
)
.on_mouse_down(
MouseButton::Left,
cx.listener(move |this, ev: &MouseDownEvent, window, cx| {
cx.stop_propagation();
if ev.click_count >= 2 {
window.titlebar_double_click();
} else {
this.activate(i, window, cx);
}
}),
)
// The press is kept from the title bar under it, but it is the
// release that switches tabs: a press that turns into a drag is
// the tab being picked up, and picking a tab up to drop it into
// another one must not first put it on screen.
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
.on_click(cx.listener(move |this, ev: &gpui::ClickEvent, window, cx| {
cx.stop_propagation();
let double =
matches!(ev, gpui::ClickEvent::Mouse(e) if e.down.click_count >= 2);
if double {
window.titlebar_double_click();
} else {
this.activate(i, window, cx);
}
}))
.when_some(ssh_dot, |c, rgb| {
c.child(
div()
@@ -1747,8 +1763,14 @@ impl Tty7App {
.xsmall(),
)
.tooltip(t(L10nKey::TabContextCloseTab))
// Held here, because the chip behind it
// switches tabs on the release too: without
// this the same click closes tab `i` and
// then activates whichever tab slid into
// its place.
.on_click(cx.listener(
move |this, _, window, cx| {
cx.stop_propagation();
this.close_tab(i, window, cx);
},
)),
+244 -10
View File
@@ -277,6 +277,7 @@ pub(crate) fn diff(
let mut ops = Vec::new();
if scope == SyncScope::Full {
migrate_panes(workspace, mirror, desired, &mut ops);
let mut index = 0;
while index < mirror.tabs.len() {
let id = mirror.tabs[index].id;
@@ -293,18 +294,26 @@ pub(crate) fn diff(
}
}
for (index, want) in desired.iter().enumerate() {
match mirror.tabs.iter().position(|t| t.id == want.id) {
None => {
let at = match scope {
SyncScope::Full => index,
SyncScope::Additive => mirror.tabs.len(),
};
create_tab(workspace, mirror, at, want, &mut ops);
}
Some(at) => reconcile_tab(workspace, mirror, at, want, &mut ops),
// The tabs the machine already has are settled first, and only then are the
// new ones built. A pane that left its tab to become one of its own is in
// both halves of that — the machine refuses to register a pane it already
// holds, so the tab it left has to give it up before the tab it is
// becoming can ask for it.
for want in desired {
if let Some(at) = mirror.tabs.iter().position(|t| t.id == want.id) {
reconcile_tab(workspace, mirror, at, want, &mut ops);
}
}
for (index, want) in desired.iter().enumerate() {
if mirror.tabs.iter().any(|t| t.id == want.id) {
continue;
}
let at = match scope {
SyncScope::Full => index,
SyncScope::Additive => mirror.tabs.len(),
};
create_tab(workspace, mirror, at, want, &mut ops);
}
if scope == SyncScope::Additive || !held.is_empty() {
return ops;
@@ -341,6 +350,119 @@ pub(crate) fn diff(
ops
}
/// Carries panes across to the tab that now wants them, before anything else
/// gets a chance to read their old tab as one to close.
///
/// This is a tab dragged into another tab's layout: every pane it brought
/// keeps running and changes tab, and the tab it came from goes away once it
/// has nothing left. Told as `PaneMove`, which is the one op that can say that
/// — closing the old tab and building the new one would say instead that a
/// tab's worth of panes went away and a tab's worth arrived.
///
/// One move per round, because each move changes where the next one can go:
/// a pane may only land beside a pane its destination already holds, so a
/// two-pane tab crosses as its first pane and then the rest beside it. A round
/// that finds nothing left to do ends the pass, which is also what happens
/// when a move cannot be spelled this way at all — the passes below then treat
/// it as the reshape it is.
fn migrate_panes(
workspace: WorkspaceId,
mirror: &mut WsMirror,
desired: &[DesiredTab],
ops: &mut Vec<ControlRequest>,
) {
while let Some((pane, to, axis, first)) = next_migration(mirror, desired) {
let holders = |m: &WsMirror, p: u64| m.tabs.iter().position(|t| t.root.contains(p));
let (Some(from), Some(dest)) = (holders(mirror, pane), holders(mirror, to)) else {
return;
};
// The destination takes the pane before its old tab gives it up, so a
// split that somehow will not take leaves the mirror as it was rather
// than a pane short of the tree the daemon has.
if !mirror.tabs[dest]
.root
.split_leaf(to, pane, axis, 0.5, first)
{
return;
}
// The rest of the bookkeeping `Machine::pane_move` does at the other
// end: a tab that has just lost its last pane is gone, and the active
// tab heals onto whatever took its place.
if mirror.tabs[from].root.remove_leaf(pane).is_none() {
mirror.tabs.remove(from);
heal_active(mirror, from);
}
ops.push(ControlRequest::PaneMove {
workspace,
pane,
to,
axis,
first,
});
}
}
/// The next pane sitting in a tab that no longer wants it, and the pane in the
/// tab that does that it can be put beside.
fn next_migration(mirror: &WsMirror, desired: &[DesiredTab]) -> Option<(u64, u64, TreeAxis, bool)> {
for want in desired {
let Some(at) = mirror.tabs.iter().position(|t| t.id == want.id) else {
continue;
};
let root = want.root.to_pane_node();
// Panes the machine has never heard of are splits, not moves: a side
// holding one of those is not a side that can cross over.
let arriving = |node: &PaneNode| {
let ids = node.pane_ids();
!ids.is_empty()
&& ids.iter().all(|p| {
mirror
.tabs
.iter()
.position(|t| t.root.contains(*p))
.is_some_and(|holder| holder != at)
})
};
let settled = |node: &PaneNode| match node {
PaneNode::Leaf { pane } => mirror.tabs[at].root.contains(*pane).then_some(*pane),
_ => None,
};
if let Some(step) = arrival_site(&root, &arriving, &settled) {
return Some(step);
}
}
None
}
/// The split in the shape a tab wants where panes still living in another tab
/// meet a pane that is already here, read as a pane to move and the pane to
/// put it beside.
///
/// The side that is arriving may be a whole subtree — only its first pane
/// crosses on this round, and the ones behind it follow on later rounds, by
/// which time this same reading finds them the sites they want inside it. The
/// side that is staying has to be a single pane, because that is all a move can
/// split. Nothing else can be said in one move: a tab dropped against the outer
/// edge of a layout that is more than one pane deep has to go in above the
/// whole of it, and the passes after this one rebuild the tab instead.
fn arrival_site(
node: &PaneNode,
arriving: &impl Fn(&PaneNode) -> bool,
settled: &impl Fn(&PaneNode) -> Option<u64>,
) -> Option<(u64, u64, TreeAxis, bool)> {
let PaneNode::Split { axis, a, b, .. } = node else {
return None;
};
let first = |side: &PaneNode| side.pane_ids().first().copied();
if let (Some(to), true) = (settled(a), arriving(b)) {
return Some((first(b)?, to, *axis, false));
}
if let (true, Some(to)) = (arriving(a), settled(b)) {
return Some((first(a)?, to, *axis, true));
}
arrival_site(a, arriving, settled).or_else(|| arrival_site(b, arriving, settled))
}
fn heal_active(mirror: &mut WsMirror, removed: usize) {
let named = mirror
.active
@@ -3677,6 +3799,118 @@ mod tests {
assert_converged(&mirror, &after);
}
#[test]
fn merging_a_tab_into_another_moves_its_panes_and_takes_the_tab_with_them() {
let ws = WorkspaceId::new();
let (host, guest) = (TabId::new(), TabId::new());
let mut mirror = WsMirror::default();
let before = vec![
tab(host, leaf(1)),
tab(guest, split(TreeAxis::Horizontal, 0.5, leaf(2), leaf(3))),
];
diff(ws, &mut mirror, &before, Some(host), SyncScope::Full, &[]);
// The guest tab dropped on the right of pane 1, arriving as the column
// of two it already was.
let after = vec![tab(
host,
split(
TreeAxis::Horizontal,
0.5,
leaf(1),
split(TreeAxis::Vertical, 0.5, leaf(2), leaf(3)),
),
)];
let ops = diff(ws, &mut mirror, &after, Some(host), SyncScope::Full, &[]);
assert_eq!(
ops,
vec![
ControlRequest::PaneMove {
workspace: ws,
pane: 2,
to: 1,
axis: TreeAxis::Horizontal,
first: false,
},
ControlRequest::PaneMove {
workspace: ws,
pane: 3,
to: 2,
axis: TreeAxis::Vertical,
first: false,
},
],
"the panes cross one at a time and the emptied tab goes with them, \
rather than a tab's worth of panes being closed and respawned"
);
assert_converged(&mirror, &after);
assert_eq!(mirror.active, Some(host));
}
#[test]
fn a_tab_grafted_above_a_whole_layout_still_converges() {
let ws = WorkspaceId::new();
let (host, guest) = (TabId::new(), TabId::new());
let mut mirror = WsMirror::default();
let before = vec![
tab(host, split(TreeAxis::Horizontal, 0.5, leaf(1), leaf(2))),
tab(guest, leaf(3)),
];
diff(ws, &mut mirror, &before, Some(host), SyncScope::Full, &[]);
// Dropped against the host's outer edge, so the newcomer sits above the
// whole two-pane layout rather than beside one of its panes. No single
// `PaneMove` can say that, and `migrate_panes` says nothing at all: the
// passes after it have to land the tab anyway, by the rebuild they have
// always fallen back to.
let after = vec![tab(
host,
split(
TreeAxis::Horizontal,
0.33,
leaf(3),
split(TreeAxis::Horizontal, 0.5, leaf(1), leaf(2)),
),
)];
diff(ws, &mut mirror, &after, Some(host), SyncScope::Full, &[]);
assert_converged(&mirror, &after);
}
#[test]
fn a_pane_leaving_for_a_tab_of_its_own_gives_it_up_before_it_asks_for_it() {
let ws = WorkspaceId::new();
let (held, fresh) = (TabId::new(), TabId::new());
let mut mirror = WsMirror::default();
let before = vec![tab(
held,
split(TreeAxis::Horizontal, 0.5, leaf(1), leaf(2)),
)];
diff(ws, &mut mirror, &before, Some(held), SyncScope::Full, &[]);
// Pane 2 dropped on the strip ahead of the tab it came from, so the tab
// it becomes is desired *first*.
let after = vec![tab(fresh, leaf(2)), tab(held, leaf(1))];
let ops = diff(ws, &mut mirror, &after, Some(fresh), SyncScope::Full, &[]);
assert_eq!(
ops,
vec![
ControlRequest::PaneClose {
workspace: ws,
pane: 2,
},
ControlRequest::TabCreate {
workspace: ws,
at: Some(0),
pane: seed(2),
tab: Some(fresh),
},
],
"the machine refuses a pane that is in two tabs at once, so the old \
tab lets go before the new one is built"
);
assert_converged(&mirror, &after);
}
#[test]
fn a_move_that_lands_on_a_new_ratio_settles_it_after_the_move() {
let ws = WorkspaceId::new();