Preserve existing panes when applying a preset

Previously: clicking 1 / 2H / 3H / 2V / 2×2 in the titlebar replaced
the whole tree with brand-new empty leaves, killing every shell — and
the only safeguard was a window.confirm() that's easy to miss-click.
The user lost work whenever they reached for a preset.

New behaviour via `reshapeToPreset`:
- The preset's shape is built fresh (1, 2, 3, or 4 slots), then existing
  leaves are spliced into those slots in DFS order. Their id / distro /
  cwd / label / broadcast all carry over, so the same PaneId is still
  mapped — the PTY keeps running.
- If the preset has MORE slots than existing leaves (e.g. 1 pane → 2×2),
  the extra slots stay as fresh empty leaves and new shells spawn there.
  No prompt — pure additive change.
- If the preset has FEWER slots than existing leaves (e.g. 8 panes →
  2×2), the overflow leaves are returned in `dropped`. We confirm with
  the user, and if they accept, kill those PTYs explicitly.

Tradeoff: split ratios reset to 0.5 (the whole point of "apply preset"
is to use its layout). That's an acceptable cost.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-05-22 19:27:50 +01:00
parent 2a0c096095
commit 8c3af8f9ee
2 changed files with 68 additions and 10 deletions

View file

@ -22,6 +22,7 @@ import {
changeLabel,
toggleBroadcast as toggleBroadcastInTree,
setAllBroadcast,
reshapeToPreset,
serialize,
deserialize,
presetSingle,
@ -264,18 +265,33 @@ export default function App() {
const applyPreset = useCallback(
(make: (d: { distro?: string }) => TreeNode) => {
const count = leafCount(tree);
if (
count > 1 &&
!window.confirm(
`Replace current layout (${count} panes)? This kills all open shells.`,
)
) {
return;
const { tree: nextTree, dropped } = reshapeToPreset(tree, make, {
distro: defaultDistro,
});
if (dropped.length > 0) {
const ok = window.confirm(
`This preset has fewer slots than your current ${leafCount(tree)} panes. ${dropped.length} pane${dropped.length === 1 ? "" : "s"} will be closed (their shells will be killed). Continue?`,
);
if (!ok) return;
for (const id of dropped) {
const paneId = paneIdByLeafRef.current.get(id);
if (paneId != null) {
void killPane(paneId).catch((e) =>
console.warn("killPane failed:", e),
);
paneIdByLeafRef.current.delete(id);
}
}
if (activeLeafId && dropped.includes(activeLeafId)) {
setActiveLeafId(null);
}
}
setTree(make({ distro: defaultDistro }));
setTree(nextTree);
},
[tree, defaultDistro],
[tree, defaultDistro, activeLeafId],
);
const paletteLeaves = useMemo<LeafNode[]>(

View file

@ -198,6 +198,48 @@ export function toggleBroadcast(root: TreeNode, leafId: NodeId): TreeNode {
});
}
/**
* Reshape the tree into the structure produced by `preset`, but PRESERVE
* existing leaves (and their PTYs) by copying their id/distro/cwd/label/
* broadcast into the preset's slots, in DFS order.
*
* - If the preset has more slots than existing leaves, the extra slots stay
* as their freshly-created (empty) leaves those panes will spawn new
* shells.
* - If the preset has fewer slots than existing leaves, the overflow leaves
* are returned in `dropped` so the caller can kill their PTYs.
*
* Split ratios reset to the preset's defaults (0.5 — the user's previous
* resize work is discarded; that's the point of "apply preset").
*/
export function reshapeToPreset(
existing: TreeNode,
preset: (d: LeafDefaults) => TreeNode,
defaults: LeafDefaults,
): { tree: TreeNode; dropped: NodeId[] } {
const existingLeaves = Array.from(walkLeaves(existing));
const tree = preset(defaults);
const slots = Array.from(walkLeaves(tree));
const dropped: NodeId[] = [];
for (let i = 0; i < slots.length; i++) {
const src = existingLeaves[i];
if (!src) break;
const slot = slots[i];
slot.id = src.id;
if (src.distro !== undefined) slot.distro = src.distro;
if (src.cwd !== undefined) slot.cwd = src.cwd;
if (src.label !== undefined) slot.label = src.label;
if (src.broadcast !== undefined) slot.broadcast = src.broadcast;
}
for (let i = slots.length; i < existingLeaves.length; i++) {
dropped.push(existingLeaves[i].id);
}
return { tree, dropped };
}
/** Force every leaf's broadcast flag to `on`. Returns the same root reference
* when nothing actually changed, so callers can skip a state update if so. */
export function setAllBroadcast(root: TreeNode, on: boolean): TreeNode {