Add keyboard shortcuts (Ctrl+Shift chord style)
| 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>
This commit is contained in:
parent
9aa0c5a828
commit
a4cd82440b
4 changed files with 188 additions and 21 deletions
14
README.md
14
README.md
|
|
@ -29,6 +29,20 @@ A Windows desktop app for running and arranging many WSL terminals at once. Buil
|
|||
- **Preset layouts** — titlebar buttons: `1` / `2H` / `3H` / `2V` / `2×2`. Confirms before replacing a multi-pane layout.
|
||||
- **Active pane** — click any pane → blue border + keyboard focus.
|
||||
- **Jump to pane** — `Ctrl+K` opens a fuzzy picker over label / distro / cwd. ↑/↓ to navigate, Enter to focus, Esc to close.
|
||||
|
||||
### Keyboard shortcuts
|
||||
|
||||
| Key | Action |
|
||||
|---|---|
|
||||
| `Ctrl+K` | open the jump-to-pane palette |
|
||||
| `Ctrl+Shift+E` | split active pane to the right |
|
||||
| `Ctrl+Shift+O` | split active pane downward |
|
||||
| `Ctrl+Shift+W` | close active pane |
|
||||
| `Ctrl+Shift+B` | toggle broadcast on active pane |
|
||||
| `Ctrl+Shift+Alt+B` | toggle broadcast on ALL panes (titlebar 📡) |
|
||||
| `Ctrl+Shift+←/→/↑/↓` | focus neighbour pane in that direction |
|
||||
|
||||
Shortcuts work while a terminal is focused (we capture before xterm.js sees the key). They DON'T fire while you're typing into a label edit or the palette input, so those still work normally.
|
||||
- **Idle toasts** — top-right notification when a pane goes quiet for 5 s. Useful for "I started a long task; tell me when it's done."
|
||||
|
||||
Layout + per-pane settings auto-save to `%APPDATA%\com.megaproxy.tiletopia\workspace.json` (debounced 500 ms).
|
||||
|
|
|
|||
122
src/App.tsx
122
src/App.tsx
|
|
@ -26,6 +26,8 @@ import {
|
|||
flattenLayout,
|
||||
updateSplitRatio,
|
||||
swapLeaves,
|
||||
findNeighborInDirection,
|
||||
type Direction,
|
||||
serialize,
|
||||
deserialize,
|
||||
presetSingle,
|
||||
|
|
@ -128,19 +130,6 @@ export default function App() {
|
|||
return () => clearTimeout(id);
|
||||
}, [tree, ready]);
|
||||
|
||||
// ---- Ctrl+K palette toggle (capture phase to beat xterm) ----------------
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setPaletteOpen((v) => !v);
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", onKey, true);
|
||||
return () => window.removeEventListener("keydown", onKey, true);
|
||||
}, []);
|
||||
|
||||
// ---- focus polling → setActive (xterm.js eats pointerdown) --------------
|
||||
useEffect(() => {
|
||||
let lastLeafId: string | null = null;
|
||||
|
|
@ -199,6 +188,107 @@ export default function App() {
|
|||
setActiveLeafId(leafId);
|
||||
}, []);
|
||||
|
||||
// ---- global keyboard shortcuts ------------------------------------------
|
||||
// Capture phase beats xterm.js's own keystroke handlers. We intentionally
|
||||
// don't intercept when the user is typing into a regular <input> (label
|
||||
// edits etc.) — but DO intercept when focus is in the xterm textarea,
|
||||
// which is what makes shortcuts work while a terminal is focused.
|
||||
const kbdStateRef = useRef({ activeLeafId, tree });
|
||||
useEffect(() => {
|
||||
kbdStateRef.current = { activeLeafId, tree };
|
||||
});
|
||||
useEffect(() => {
|
||||
const DIR_MAP: Record<string, Direction | undefined> = {
|
||||
arrowleft: "left",
|
||||
arrowright: "right",
|
||||
arrowup: "up",
|
||||
arrowdown: "down",
|
||||
};
|
||||
|
||||
function shouldIgnore(): boolean {
|
||||
const ae = document.activeElement as HTMLElement | null;
|
||||
if (!ae) return false;
|
||||
const tag = ae.tagName;
|
||||
if (tag !== "INPUT" && tag !== "TEXTAREA") return false;
|
||||
if (ae.classList.contains("xterm-helper-textarea")) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (shouldIgnore()) return;
|
||||
const ctrl = e.ctrlKey || e.metaKey;
|
||||
const shift = e.shiftKey;
|
||||
const alt = e.altKey;
|
||||
const key = e.key.toLowerCase();
|
||||
const { activeLeafId, tree } = kbdStateRef.current;
|
||||
|
||||
// Ctrl+K — palette
|
||||
if (ctrl && !shift && !alt && key === "k") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setPaletteOpen((v) => !v);
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+Shift+Alt+B — global broadcast all/none
|
||||
if (ctrl && shift && alt && key === "b") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
let anyOn = false;
|
||||
for (const leaf of walkLeaves(tree)) {
|
||||
if (leaf.broadcast) {
|
||||
anyOn = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
setTree((t) => setAllBroadcast(t, !anyOn));
|
||||
return;
|
||||
}
|
||||
|
||||
// All remaining shortcuts require Ctrl+Shift with no Alt.
|
||||
if (!ctrl || !shift || alt) return;
|
||||
|
||||
// Ctrl+Shift+Arrow — pane navigation
|
||||
const dir = DIR_MAP[key];
|
||||
if (dir) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const layout = flattenLayout(tree);
|
||||
if (!activeLeafId) {
|
||||
const first = layout.leaves[0]?.leaf.id;
|
||||
if (first) setActiveLeafId(first);
|
||||
return;
|
||||
}
|
||||
const nextId = findNeighborInDirection(layout.leaves, activeLeafId, dir);
|
||||
if (nextId) setActiveLeafId(nextId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!activeLeafId) return;
|
||||
|
||||
if (key === "e") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
split(activeLeafId, "h");
|
||||
} else if (key === "o") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
split(activeLeafId, "v");
|
||||
} else if (key === "w") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
close(activeLeafId);
|
||||
} else if (key === "b") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
toggleBroadcast(activeLeafId);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", onKey, true);
|
||||
return () => window.removeEventListener("keydown", onKey, true);
|
||||
}, [split, close, toggleBroadcast]);
|
||||
|
||||
const registerPaneId = useCallback(
|
||||
(leafId: NodeId, paneId: PaneId | null) => {
|
||||
if (paneId == null) paneIdByLeafRef.current.delete(leafId);
|
||||
|
|
@ -432,10 +522,10 @@ export default function App() {
|
|||
onClick={toggleBroadcastAll}
|
||||
title={
|
||||
broadcastStats.on === 0
|
||||
? "Click to broadcast input to ALL panes"
|
||||
? "Broadcast to ALL panes (Ctrl+Shift+Alt+B)"
|
||||
: broadcastStats.on === broadcastStats.total
|
||||
? "All panes broadcasting — click to disable"
|
||||
: `${broadcastStats.on} of ${broadcastStats.total} panes broadcasting — click to disable all`
|
||||
? "All panes broadcasting — click to disable (Ctrl+Shift+Alt+B)"
|
||||
: `${broadcastStats.on} of ${broadcastStats.total} panes broadcasting — click to disable all (Ctrl+Shift+Alt+B)`
|
||||
}
|
||||
>
|
||||
{broadcastBtnLabel}
|
||||
|
|
|
|||
|
|
@ -303,8 +303,8 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
|
|||
}}
|
||||
title={
|
||||
isBroadcasting
|
||||
? "Broadcasting (click to leave group)"
|
||||
: "Click to broadcast input to other broadcast panes"
|
||||
? "Broadcasting (click or Ctrl+Shift+B to leave group)"
|
||||
: "Click or Ctrl+Shift+B to broadcast input to other broadcast panes"
|
||||
}
|
||||
aria-pressed={isBroadcasting ? "true" : "false"}
|
||||
>
|
||||
|
|
@ -322,7 +322,7 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
|
|||
<span className="pane-actions">
|
||||
<button
|
||||
className="pane-btn"
|
||||
title="Split right"
|
||||
title="Split right (Ctrl+Shift+E)"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
orch.split(leaf.id, "h");
|
||||
|
|
@ -333,7 +333,7 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
|
|||
</button>
|
||||
<button
|
||||
className="pane-btn"
|
||||
title="Split down"
|
||||
title="Split down (Ctrl+Shift+O)"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
orch.split(leaf.id, "v");
|
||||
|
|
@ -344,7 +344,7 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
|
|||
</button>
|
||||
<button
|
||||
className="pane-btn close"
|
||||
title="Close pane"
|
||||
title="Close pane (Ctrl+Shift+W)"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
orch.close(leaf.id);
|
||||
|
|
|
|||
|
|
@ -356,6 +356,69 @@ export function updateSplitRatio(root: TreeNode, splitId: NodeId, ratio: number)
|
|||
});
|
||||
}
|
||||
|
||||
export type Direction = "left" | "right" | "up" | "down";
|
||||
|
||||
/** Spatial pane navigation: given an active leaf, find the nearest neighbor
|
||||
* in the requested direction. Used for Ctrl+Shift+Arrow shortcuts. */
|
||||
export function findNeighborInDirection(
|
||||
leaves: LeafSlot[],
|
||||
fromLeafId: NodeId,
|
||||
direction: Direction,
|
||||
): NodeId | null {
|
||||
const from = leaves.find((s) => s.leaf.id === fromLeafId);
|
||||
if (!from) return null;
|
||||
const fromCenter = {
|
||||
x: from.box.left + from.box.width / 2,
|
||||
y: from.box.top + from.box.height / 2,
|
||||
};
|
||||
const EPS = 1e-3;
|
||||
|
||||
let best: { id: NodeId; perpDist: number; primaryDist: number } | null = null;
|
||||
|
||||
for (const slot of leaves) {
|
||||
if (slot.leaf.id === fromLeafId) continue;
|
||||
const center = {
|
||||
x: slot.box.left + slot.box.width / 2,
|
||||
y: slot.box.top + slot.box.height / 2,
|
||||
};
|
||||
let primary: number;
|
||||
let perp: number;
|
||||
|
||||
switch (direction) {
|
||||
case "right":
|
||||
if (slot.box.left < from.box.left + from.box.width - EPS) continue;
|
||||
primary = center.x - fromCenter.x;
|
||||
perp = Math.abs(center.y - fromCenter.y);
|
||||
break;
|
||||
case "left":
|
||||
if (slot.box.left + slot.box.width > from.box.left + EPS) continue;
|
||||
primary = fromCenter.x - center.x;
|
||||
perp = Math.abs(center.y - fromCenter.y);
|
||||
break;
|
||||
case "down":
|
||||
if (slot.box.top < from.box.top + from.box.height - EPS) continue;
|
||||
primary = center.y - fromCenter.y;
|
||||
perp = Math.abs(center.x - fromCenter.x);
|
||||
break;
|
||||
case "up":
|
||||
if (slot.box.top + slot.box.height > from.box.top + EPS) continue;
|
||||
primary = fromCenter.y - center.y;
|
||||
perp = Math.abs(center.x - fromCenter.x);
|
||||
break;
|
||||
}
|
||||
|
||||
if (
|
||||
best === null ||
|
||||
perp < best.perpDist ||
|
||||
(perp === best.perpDist && primary < best.primaryDist)
|
||||
) {
|
||||
best = { id: slot.leaf.id, perpDist: perp, primaryDist: primary };
|
||||
}
|
||||
}
|
||||
|
||||
return best?.id ?? null;
|
||||
}
|
||||
|
||||
/** Swap two leaves' tree positions. Each leaf carries its own data
|
||||
* (id, distro, cwd, label, broadcast) into the other's slot. PTYs stay
|
||||
* alive because React keys on leaf.id and our renderer is flat. */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue