Add M4 orchestration: broadcast, idle notifications, palette

tree.ts
- LeafNode gains broadcast?: boolean
- walkLeaves(root) generator; toggleBroadcast helper

ops.ts (PaneOps)
- toggleBroadcast, broadcastFrom, setActivePane, registerPaneId,
  notify; activeLeafId data field.

XtermPane.svelte
- onSpawn(paneId), onInput(b64), onDataReceived(),
  and focusTrigger prop. All optional; backward-compatible.

LeafPane.svelte
- 📡 broadcast toggle; 5s idle detection -> ops.notify (once per
  idle cycle); active + broadcasting border colors; click-to-focus
  via setActivePane + focusTrigger bump.

New Notifications.svelte
- Top-right toast stack, slide-in, 5s auto-dismiss + click ×.

New Palette.svelte
- Modal overlay, backdrop, filtered leaf list with ↑/↓ + Enter,
  Escape to close.

App.svelte
- paneIdByLeaf Map for routing; notifications array + auto-dismiss;
  activeLeafId; Ctrl+K global listener; broadcastFrom routes via
  walkLeaves + writeToPane to all other broadcast leaves; ⌘K button
  in titlebar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-05-22 13:08:40 +01:00
parent 64b90ebddb
commit 3c2f6b8640
8 changed files with 578 additions and 28 deletions

View file

@ -1,19 +1,30 @@
<script lang="ts">
import { onMount } from "svelte";
import { listDistros, saveWorkspace, loadWorkspace } from "./ipc";
import {
listDistros,
saveWorkspace,
loadWorkspace,
writeToPane,
type PaneId,
} from "./ipc";
import Pane from "./lib/layout/Pane.svelte";
import Notifications, { type Toast } from "./components/Notifications.svelte";
import Palette from "./components/Palette.svelte";
import type { PaneOps } from "./lib/layout/ops";
import {
type TreeNode,
type NodeId,
type Orientation,
type LeafNode,
newLeaf,
splitLeaf,
closeLeaf,
findLeaf,
leafCount,
walkLeaves,
changeDistro,
changeLabel,
toggleBroadcast as toggleBroadcastInTree,
serialize,
deserialize,
presetSingle,
@ -30,12 +41,21 @@
let ready = $state(false);
let tree = $state<TreeNode>(newLeaf());
// ---- orchestration state (M4) -------------------------------------------
// leafId -> backend PaneId. Plain Map (no reactivity needed — only read
// from event handlers). Repopulated on every XtermPane mount.
const paneIdByLeaf = new Map<NodeId, PaneId>();
let notifications = $state<Toast[]>([]);
let nextNotifId = 1;
let activeLeafId = $state<NodeId | null>(null);
let paletteOpen = $state(false);
function isInteractiveDistro(name: string): boolean {
return !name.toLowerCase().startsWith("docker-desktop");
}
onMount(async () => {
// 1. Try the new APPDATA persistence.
// 1. Try APPDATA persistence.
let loaded: TreeNode | null = null;
try {
const json = await loadWorkspace();
@ -44,17 +64,13 @@
console.warn("loadWorkspace failed:", e);
}
// 2. Fall back to the M2 localStorage layout (one-time migration).
// 2. Migrate from M2 localStorage if APPDATA is empty.
if (!loaded) {
try {
const legacy = localStorage.getItem(LEGACY_STORAGE_KEY);
if (legacy) {
loaded = deserialize(legacy);
if (loaded) {
// Promote to APPDATA so it survives future loads even without
// localStorage. Fire-and-forget; debounced save will catch it too.
void saveWorkspace(legacy);
}
if (loaded) void saveWorkspace(legacy);
localStorage.removeItem(LEGACY_STORAGE_KEY);
}
} catch (e) {
@ -72,10 +88,7 @@
console.warn("list_distros failed:", e);
}
// 4. Backfill distro on any leaves that lack one (handles first launch
// and trees saved before defaults were resolved).
if (defaultDistro) backfillDistro(tree, defaultDistro);
ready = true;
});
@ -104,10 +117,20 @@
}, SAVE_DEBOUNCE_MS);
});
// ---- Ctrl+K palette toggle ----------------------------------------------
$effect(() => {
function onKey(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
paletteOpen = !paletteOpen;
}
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
});
// ---- pane ops ------------------------------------------------------------
function handleSplit(leafId: NodeId, orientation: Orientation) {
// Inherit distro + cwd from the parent leaf so split-from-project
// keeps both panes in the same context.
const parent = findLeaf(tree, leafId);
const inherit = parent
? { distro: parent.distro ?? defaultDistro, cwd: parent.cwd }
@ -118,6 +141,7 @@
function handleClose(leafId: NodeId) {
const next = closeLeaf(tree, leafId);
tree = next ?? newLeaf({ distro: defaultDistro });
if (activeLeafId === leafId) activeLeafId = null;
}
function handleSetDistro(leafId: NodeId, distro: string) {
@ -128,12 +152,55 @@
tree = changeLabel(tree, leafId, label);
}
function handleToggleBroadcast(leafId: NodeId) {
tree = toggleBroadcastInTree(tree, leafId);
}
function handleBroadcastFrom(originLeafId: NodeId, dataB64: string) {
for (const leaf of walkLeaves(tree)) {
if (leaf.id === originLeafId) continue;
if (!leaf.broadcast) continue;
const paneId = paneIdByLeaf.get(leaf.id);
if (paneId == null) continue;
writeToPane(paneId, dataB64).catch((e) =>
console.warn("broadcast write failed:", e),
);
}
}
function handleSetActivePane(leafId: NodeId) {
activeLeafId = leafId;
}
function handleRegisterPaneId(leafId: NodeId, paneId: PaneId | null) {
if (paneId == null) paneIdByLeaf.delete(leafId);
else paneIdByLeaf.set(leafId, paneId);
}
function handleNotify(message: string) {
const id = nextNotifId++;
notifications.push({ id, message });
setTimeout(() => {
notifications = notifications.filter((n) => n.id !== id);
}, 5000);
}
function dismissNotification(id: number) {
notifications = notifications.filter((n) => n.id !== id);
}
const ops: PaneOps = $derived({
split: handleSplit,
close: handleClose,
setDistro: handleSetDistro,
setLabel: handleSetLabel,
toggleBroadcast: handleToggleBroadcast,
broadcastFrom: handleBroadcastFrom,
setActivePane: handleSetActivePane,
registerPaneId: handleRegisterPaneId,
notify: handleNotify,
distros,
activeLeafId,
});
// ---- preset layouts ------------------------------------------------------
@ -144,6 +211,17 @@
}
tree = make({ distro: defaultDistro });
}
// ---- palette feed --------------------------------------------------------
const paletteLeaves = $derived.by<LeafNode[]>(() => {
if (!paletteOpen) return [];
return Array.from(walkLeaves(tree));
});
function onPalettePick(leafId: string) {
activeLeafId = leafId;
paletteOpen = false;
}
</script>
<div class="app">
@ -175,6 +253,10 @@
<button class="preset-btn" title="2 × 2 grid" onclick={() => applyPreset(presetTwoByTwo)}>2×2</button>
</span>
<button class="palette-btn" onclick={() => (paletteOpen = true)} title="Jump to pane (Ctrl+K)">
⌘K
</button>
<span class="layout-info">
{leafCount(tree)} pane{leafCount(tree) === 1 ? "" : "s"}
</span>
@ -185,6 +267,16 @@
<Pane node={tree} {ops} />
{/if}
</div>
<Notifications {notifications} onDismiss={dismissNotification} />
{#if paletteOpen}
<Palette
leaves={paletteLeaves}
onPick={onPalettePick}
onClose={() => (paletteOpen = false)}
/>
{/if}
</div>
<style>
@ -193,7 +285,7 @@
gap: 4px;
align-items: center;
}
.distro-btn, .preset-btn {
.distro-btn, .preset-btn, .palette-btn {
font: inherit;
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
font-size: 11px;
@ -204,7 +296,7 @@
padding: 2px 8px;
cursor: pointer;
}
.distro-btn:hover, .preset-btn:hover {
.distro-btn:hover, .preset-btn:hover, .palette-btn:hover {
background: #2a2a2a;
color: #ddd;
}