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:
parent
64b90ebddb
commit
3c2f6b8640
8 changed files with 578 additions and 28 deletions
122
src/App.svelte
122
src/App.svelte
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue