| Ctrl+K | palette |
| Ctrl+Shift+E | split active pane right |
| Ctrl+Shift+O | split active pane down |
| Ctrl+Shift+W | close active pane |
| Ctrl+Shift+B | toggle broadcast on active |
| Ctrl+Shift+Alt+B | toggle broadcast on ALL panes |
| Ctrl+Shift+Arrow | focus neighbour pane in that direction |
The handler attaches at capture phase on window so it wins against
xterm.js. It bails when a non-terminal <input>/<textarea> is focused
so label edits and the palette input keep working normally.
Spatial neighbour-finding lives in tree.ts as findNeighborInDirection
— picks the leaf whose centre is most aligned in the perpendicular
axis, breaking ties by primary-axis distance.
Tooltips on toolbar/titlebar buttons now mention their shortcuts;
README has a key-binding table.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New interaction: click-and-drag any pane's toolbar onto another pane
to swap their positions in the tree. The shells / scrollback stay
intact (each leaf keeps its data; only the tree slot it occupies
changes).
Implementation:
- tree.ts: `swapLeaves(root, idA, idB)` walks the tree once,
substituting one leaf for the other at each occurrence. The leaf
objects themselves carry their id/distro/cwd/label/broadcast across,
so React preserves the LeafPane instances via the flat-list keying.
- orchestration.tsx: add drag lifecycle to the context —
dragSourceId / dragOverId (reactive) plus beginHeaderDrag,
setHeaderDragOver, endHeaderDrag (stable methods).
- App.tsx: implement those methods. endHeaderDrag(true) swaps if
source and over are different leaves.
- LeafPane.tsx: pointerdown on .pane-toolbar (skipped if the target
is a button/input). 5px movement threshold before drag commits to
prevent accidental swaps when clicking a chip etc. Pointer-capture
the toolbar so we keep getting move events even outside it. Use
document.elementFromPoint to find the leaf under the cursor.
- CSS: source pane fades to 40% opacity during drag; target pane
shows a 3px dashed blue outline; toolbar shows grab/grabbing
cursors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The fix for the real preset bug: previously, presetSingle/2H/3H/2V/2×2
appeared to preserve panes (we copied id/distro/cwd/label/broadcast
into the preset's slots), but React's reconciliation tore down every
LeafPane and re-mounted it because the tree structure changed —
killing all PTYs and spawning fresh shells. The "preservation" was
data-only; the React components didn't survive.
Solution: stop rendering the Pane → SplitNode → LeafPane recursion.
Walk the tree to produce a FLAT layout of `{leaf, box}` entries (each
box is top/left/width/height as fractions 0–1). Render all leaves as
siblings of a relative-positioned container, each absolutely
positioned by its box. Key each one by leaf.id — React preserves the
component (and its XtermPane → PTY) across any tree reshape; only the
inline style changes.
Gutters render as separate sibling overlays at the split boundaries,
each with its own pointer handlers. Dragging mutates the split's
ratio via `updateSplitRatio(tree, splitId, r)`; the layout
recomputes; leaf boxes change; nothing remounts.
Now: clicking 2×2 on 4 stacked panes keeps all 4 shells alive and
just rearranges them into the grid. Same for any preset that doesn't
overflow.
Side benefit: removed the recursive Pane.tsx + SplitNode.tsx + their
CSS. The render path is now straightforward, no recursion, easier to
reason about.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Two small QoL additions:
- styles.css: WebKit pseudo-element styling for .xterm-viewport
scrollbars (8px wide, dark thumb #2a2a2a on transparent track,
hover lighten). Matches the rest of the dark theme so the right
edge of each terminal stops looking like default OS chrome.
- tree.ts: setAllBroadcast(root, on) helper that flips every leaf's
broadcast flag to the given value, preserving object identity
where nothing changed.
- App.tsx: titlebar 📡 button showing global broadcast state
("all off" / "all on" / "N/M"). Click toggles every pane between
all-broadcasting and all-off. Orange when any panes are
broadcasting; darker orange when partial.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Backend:
- save_workspace / load_workspace Tauri commands writing to
%APPDATA%\com.megaproxy.tiletopia\workspace.json with atomic
tmp+rename. Path from app.path().app_config_dir() (no dirs crate).
Layout helpers:
- tree.ts: changeDistro (with id swap to force XtermPane remount via
{#key}), changeLabel, presetSingle / TwoColumns / ThreeColumns /
TwoRows / TwoByTwo.
- New ops.ts with PaneOps interface bundling split / close /
setDistro / setLabel / distros, drilled through Pane chain
instead of individual callbacks.
UI:
- LeafPane: in-toolbar editable label (click to rename, Enter
saves, Esc cancels) and distro chip popover. Picking a different
distro respawns the pane.
- App.svelte: migrated from localStorage to APPDATA via the new
Tauri commands, debounced 500ms. One-time localStorage migration
on boot. Split inherits parent's distro+cwd. Titlebar preset
buttons with confirm when replacing >1 pane.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- src/lib/layout/tree.ts: pure helpers + types (newLeaf, splitLeaf,
closeLeaf, replaceById, serialize/deserialize with shape-checking).
- SplitNode.svelte: flex container with pointer-captured gutter drag.
- LeafPane.svelte: per-pane toolbar (split-right ⇥, split-down ⇣,
close ×) over the existing XtermPane.
- Pane.svelte: recursive dispatcher between SplitNode and LeafPane,
keyed on leaf.id so swaps unmount XtermPane cleanly (kills PTY).
- App.svelte: tree-as-state with split/close handlers, auto-save to
localStorage on every \$effect tick. Titlebar shows clickable distro
buttons setting the default for new panes; existing panes keep theirs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>