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
|
|
@ -1,4 +1,5 @@
|
|||
<script lang="ts">
|
||||
import { onDestroy } from "svelte";
|
||||
import type { LeafNode } from "./tree";
|
||||
import type { PaneOps } from "./ops";
|
||||
import XtermPane from "../../components/XtermPane.svelte";
|
||||
|
|
@ -11,6 +12,8 @@
|
|||
ops: PaneOps;
|
||||
} = $props();
|
||||
|
||||
const active = $derived(ops.activeLeafId === leaf.id);
|
||||
|
||||
let status = $state("starting…");
|
||||
let statusOk = $state(true);
|
||||
|
||||
|
|
@ -19,10 +22,10 @@
|
|||
let labelDraft = $state("");
|
||||
let labelInputEl: HTMLInputElement | null = $state(null);
|
||||
|
||||
function startEditLabel() {
|
||||
function startEditLabel(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
labelDraft = leaf.label ?? "";
|
||||
editingLabel = true;
|
||||
// Focus the input after Svelte renders it.
|
||||
queueMicrotask(() => labelInputEl?.select());
|
||||
}
|
||||
|
||||
|
|
@ -59,16 +62,71 @@
|
|||
if (d !== leaf.distro) ops.setDistro(leaf.id, d);
|
||||
}
|
||||
|
||||
// Dismiss popover on outside click.
|
||||
$effect(() => {
|
||||
if (!distroOpen) return;
|
||||
const onDocClick = () => (distroOpen = false);
|
||||
window.addEventListener("click", onDocClick);
|
||||
return () => window.removeEventListener("click", onDocClick);
|
||||
});
|
||||
|
||||
// ---- idle detection ------------------------------------------------------
|
||||
const IDLE_THRESHOLD_MS = 5000;
|
||||
let lastDataTime = Date.now();
|
||||
let notifiedThisIdle = false;
|
||||
let idleTimer: number | null = null;
|
||||
|
||||
function onDataReceived() {
|
||||
lastDataTime = Date.now();
|
||||
notifiedThisIdle = false;
|
||||
}
|
||||
|
||||
function checkIdle() {
|
||||
if (notifiedThisIdle) return;
|
||||
if (Date.now() - lastDataTime >= IDLE_THRESHOLD_MS) {
|
||||
notifiedThisIdle = true;
|
||||
const name = leaf.label ?? leaf.distro ?? "pane";
|
||||
ops.notify(`${name} is idle`);
|
||||
}
|
||||
}
|
||||
|
||||
idleTimer = window.setInterval(checkIdle, 1000);
|
||||
onDestroy(() => {
|
||||
if (idleTimer != null) clearInterval(idleTimer);
|
||||
});
|
||||
|
||||
// ---- broadcast -----------------------------------------------------------
|
||||
function onTerminalInput(b64: string) {
|
||||
if (leaf.broadcast) ops.broadcastFrom(leaf.id, b64);
|
||||
}
|
||||
|
||||
// ---- focus / active ------------------------------------------------------
|
||||
let focusTrigger = $state(0);
|
||||
|
||||
$effect(() => {
|
||||
if (active) focusTrigger += 1;
|
||||
});
|
||||
|
||||
function onPaneClick() {
|
||||
if (!active) ops.setActivePane(leaf.id);
|
||||
}
|
||||
|
||||
// ---- pane id registration ------------------------------------------------
|
||||
function onPaneSpawned(paneId: number) {
|
||||
ops.registerPaneId(leaf.id, paneId);
|
||||
}
|
||||
onDestroy(() => {
|
||||
ops.registerPaneId(leaf.id, null);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="leaf">
|
||||
<div
|
||||
class="leaf"
|
||||
class:active
|
||||
class:broadcasting={leaf.broadcast}
|
||||
role="group"
|
||||
aria-label={"Terminal pane: " + (leaf.label ?? leaf.distro ?? "unnamed")}
|
||||
onpointerdown={onPaneClick}
|
||||
>
|
||||
<div class="pane-toolbar">
|
||||
{#if editingLabel}
|
||||
<input
|
||||
|
|
@ -98,7 +156,13 @@
|
|||
{leaf.distro ?? "(default)"} ▾
|
||||
</button>
|
||||
{#if distroOpen}
|
||||
<div class="distro-menu" onclick={(e) => e.stopPropagation()} role="menu" tabindex="-1" onkeydown={() => {}}>
|
||||
<div
|
||||
class="distro-menu"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
role="menu"
|
||||
tabindex="-1"
|
||||
onkeydown={() => {}}
|
||||
>
|
||||
{#each ops.distros as d}
|
||||
<button
|
||||
class="distro-menu-item"
|
||||
|
|
@ -110,25 +174,33 @@
|
|||
{/if}
|
||||
</span>
|
||||
|
||||
<button
|
||||
class="bcast-chip"
|
||||
class:on={leaf.broadcast}
|
||||
onclick={(e) => { e.stopPropagation(); ops.toggleBroadcast(leaf.id); }}
|
||||
title={leaf.broadcast ? "Broadcasting (click to leave group)" : "Click to broadcast input to other broadcast panes"}
|
||||
aria-pressed={leaf.broadcast ? "true" : "false"}
|
||||
>📡</button>
|
||||
|
||||
<span class="pane-status {statusOk ? 'ok' : 'err'}">{status}</span>
|
||||
|
||||
<span class="pane-actions">
|
||||
<button
|
||||
class="pane-btn"
|
||||
title="Split right"
|
||||
onclick={() => ops.split(leaf.id, "h")}
|
||||
onclick={(e) => { e.stopPropagation(); ops.split(leaf.id, "h"); }}
|
||||
aria-label="Split right"
|
||||
>⇥</button>
|
||||
<button
|
||||
class="pane-btn"
|
||||
title="Split down"
|
||||
onclick={() => ops.split(leaf.id, "v")}
|
||||
onclick={(e) => { e.stopPropagation(); ops.split(leaf.id, "v"); }}
|
||||
aria-label="Split down"
|
||||
>⇣</button>
|
||||
<button
|
||||
class="pane-btn close"
|
||||
title="Close pane"
|
||||
onclick={() => ops.close(leaf.id)}
|
||||
onclick={(e) => { e.stopPropagation(); ops.close(leaf.id); }}
|
||||
aria-label="Close pane"
|
||||
>×</button>
|
||||
</span>
|
||||
|
|
@ -141,6 +213,10 @@
|
|||
status = msg;
|
||||
statusOk = ok;
|
||||
}}
|
||||
onSpawn={onPaneSpawned}
|
||||
onInput={onTerminalInput}
|
||||
{onDataReceived}
|
||||
{focusTrigger}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -153,7 +229,19 @@
|
|||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
border: 1px solid transparent;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.leaf.active {
|
||||
border-color: #3a5a8c;
|
||||
}
|
||||
.leaf.broadcasting {
|
||||
border-color: #c98a1f;
|
||||
}
|
||||
.leaf.active.broadcasting {
|
||||
border-color: #e0a432;
|
||||
}
|
||||
|
||||
.pane-toolbar {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
|
|
@ -202,7 +290,8 @@
|
|||
.distro-wrap {
|
||||
position: relative;
|
||||
}
|
||||
.distro-chip {
|
||||
.distro-chip,
|
||||
.bcast-chip {
|
||||
font: inherit;
|
||||
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
|
||||
font-size: 10px;
|
||||
|
|
@ -213,10 +302,23 @@
|
|||
padding: 1px 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.distro-chip:hover {
|
||||
.distro-chip:hover,
|
||||
.bcast-chip:hover {
|
||||
background: #2a2a3a;
|
||||
color: #aac;
|
||||
}
|
||||
.bcast-chip {
|
||||
color: #777;
|
||||
background: #1c1c1c;
|
||||
border-color: #2a2a2a;
|
||||
padding: 1px 5px;
|
||||
}
|
||||
.bcast-chip.on {
|
||||
background: #4a3010;
|
||||
color: #f0c060;
|
||||
border-color: #c98a1f;
|
||||
}
|
||||
|
||||
.distro-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { NodeId, Orientation } from "./tree";
|
||||
import type { PaneId } from "../../ipc";
|
||||
|
||||
/**
|
||||
* Bundle of operations + data that any pane in the tree may need.
|
||||
|
|
@ -6,10 +7,30 @@ import type { NodeId, Orientation } from "./tree";
|
|||
* prop drilling.
|
||||
*/
|
||||
export interface PaneOps {
|
||||
// ---- tree mutation
|
||||
split: (leafId: NodeId, orientation: Orientation) => void;
|
||||
close: (leafId: NodeId) => void;
|
||||
setDistro: (leafId: NodeId, distro: string) => void;
|
||||
setLabel: (leafId: NodeId, label: string | undefined) => void;
|
||||
toggleBroadcast: (leafId: NodeId) => void;
|
||||
|
||||
// ---- orchestration (M4)
|
||||
/**
|
||||
* Called from a broadcasting pane when its user types. App looks up
|
||||
* every other broadcast-enabled leaf and writes the same bytes to it.
|
||||
* Origin pane's own PTY is written by XtermPane directly.
|
||||
*/
|
||||
broadcastFrom: (originLeafId: NodeId, dataB64: string) => void;
|
||||
/** Mark a leaf as the active (focused) pane. */
|
||||
setActivePane: (leafId: NodeId) => void;
|
||||
/** LeafPane reports its backend PaneId once spawned, or null on destroy. */
|
||||
registerPaneId: (leafId: NodeId, paneId: PaneId | null) => void;
|
||||
/** Append a transient toast to the notification stack. */
|
||||
notify: (message: string) => void;
|
||||
|
||||
// ---- data
|
||||
/** All distros known to the backend; populated once at app start. */
|
||||
distros: string[];
|
||||
/** The currently-focused pane, if any. */
|
||||
activeLeafId: NodeId | null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,12 @@ export interface LeafNode {
|
|||
cwd?: string;
|
||||
/** Optional user label shown in the pane toolbar. */
|
||||
label?: string;
|
||||
/**
|
||||
* If true, keystrokes typed in this pane are mirrored to every other
|
||||
* leaf with `broadcast === true`. Toggle via the 📡 button in the
|
||||
* pane toolbar.
|
||||
*/
|
||||
broadcast?: boolean;
|
||||
}
|
||||
|
||||
export interface SplitNode {
|
||||
|
|
@ -174,6 +180,24 @@ export function leafCount(root: TreeNode): number {
|
|||
return leafCount(root.a) + leafCount(root.b);
|
||||
}
|
||||
|
||||
/** Iterate all leaves in left-to-right order. */
|
||||
export function* walkLeaves(root: TreeNode): Generator<LeafNode> {
|
||||
if (root.kind === "leaf") {
|
||||
yield root;
|
||||
} else {
|
||||
yield* walkLeaves(root.a);
|
||||
yield* walkLeaves(root.b);
|
||||
}
|
||||
}
|
||||
|
||||
/** Toggle a leaf's broadcast flag. Metadata-only — does NOT swap the id, so the pane is not respawned. */
|
||||
export function toggleBroadcast(root: TreeNode, leafId: NodeId): TreeNode {
|
||||
return replaceById(root, leafId, (node) => {
|
||||
if (node.kind !== "leaf") return node;
|
||||
return { ...node, broadcast: !node.broadcast };
|
||||
});
|
||||
}
|
||||
|
||||
export function serialize(root: TreeNode): string {
|
||||
return JSON.stringify(root);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue