After exhausting event-based approaches that all failed in WebView2: - per-leaf onpointerdown: xterm.js stopPropagation - document-capture pointerdown: only first event ever delivered - document-capture mousedown/click: never delivered at all - document-capture focusin: silently fails - term.onFocus: no such xterm.js API The bulletproof fallback: poll document.activeElement every 250ms and call orch.setActive on its closest [data-leaf-id] ancestor. No DOM events involved. Verified working with automation: clicking pane 2 turns its border blue, clicking pane 1 moves the border to pane 1, etc. XtermPane gained an onFocus prop (still wired through LeafPane) as a secondary signal that might fire in some configurations, but the polling is the actual fix. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
395 lines
9.6 KiB
Svelte
395 lines
9.6 KiB
Svelte
<script lang="ts">
|
||
import { onDestroy } from "svelte";
|
||
import type { LeafNode } from "./tree";
|
||
import { useOrchestration } from "./orchestration.svelte";
|
||
import XtermPane from "../../components/XtermPane.svelte";
|
||
|
||
let { leaf }: { leaf: LeafNode } = $props();
|
||
|
||
const orch = useOrchestration();
|
||
|
||
// Derives directly from orch.activeLeafId — Svelte 5 tracks the class
|
||
// field access on every re-evaluation. No prop drilling involved.
|
||
const active = $derived(orch.activeLeafId === leaf.id);
|
||
|
||
let status = $state("starting…");
|
||
let statusOk = $state(true);
|
||
|
||
// ---- label editing -------------------------------------------------------
|
||
let editingLabel = $state(false);
|
||
let labelDraft = $state("");
|
||
let labelInputEl: HTMLInputElement | null = $state(null);
|
||
|
||
function startEditLabel(e: MouseEvent) {
|
||
e.stopPropagation();
|
||
labelDraft = leaf.label ?? "";
|
||
editingLabel = true;
|
||
queueMicrotask(() => labelInputEl?.select());
|
||
}
|
||
|
||
function commitLabel() {
|
||
if (!editingLabel) return;
|
||
orch.setLabel(leaf.id, labelDraft);
|
||
editingLabel = false;
|
||
}
|
||
|
||
function cancelLabel() {
|
||
editingLabel = false;
|
||
}
|
||
|
||
function onLabelKey(e: KeyboardEvent) {
|
||
if (e.key === "Enter") {
|
||
e.preventDefault();
|
||
commitLabel();
|
||
} else if (e.key === "Escape") {
|
||
e.preventDefault();
|
||
cancelLabel();
|
||
}
|
||
}
|
||
|
||
// ---- distro popover ------------------------------------------------------
|
||
let distroOpen = $state(false);
|
||
|
||
function toggleDistroMenu(e: MouseEvent) {
|
||
e.stopPropagation();
|
||
distroOpen = !distroOpen;
|
||
}
|
||
|
||
function pickDistro(d: string) {
|
||
distroOpen = false;
|
||
if (d !== leaf.distro) orch.setDistro(leaf.id, d);
|
||
}
|
||
|
||
$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;
|
||
|
||
function onDataReceived() {
|
||
lastDataTime = Date.now();
|
||
notifiedThisIdle = false;
|
||
}
|
||
|
||
function checkIdle() {
|
||
if (notifiedThisIdle) return;
|
||
const sinceLast = Date.now() - lastDataTime;
|
||
if (sinceLast >= IDLE_THRESHOLD_MS) {
|
||
notifiedThisIdle = true;
|
||
const name = leaf.label ?? leaf.distro ?? "pane";
|
||
console.log("[tiletopia] notifying idle:", leaf.id, "quietForMs:", sinceLast);
|
||
orch.notify(`${name} is idle`);
|
||
}
|
||
}
|
||
|
||
const idleTimer = window.setInterval(checkIdle, 1000);
|
||
onDestroy(() => clearInterval(idleTimer));
|
||
|
||
// ---- broadcast -----------------------------------------------------------
|
||
function onTerminalInput(b64: string) {
|
||
if (leaf.broadcast) orch.broadcastFrom(leaf.id, b64);
|
||
}
|
||
|
||
// ---- focus / active ------------------------------------------------------
|
||
let focusTrigger = $state(0);
|
||
|
||
$effect(() => {
|
||
if (active) focusTrigger += 1;
|
||
});
|
||
|
||
// Backup setActive for toolbar clicks (which can't reach the document
|
||
// capture listener if a bubble-phase handler stops propagation). Cheap;
|
||
// idempotent if the document listener also fired.
|
||
function onPaneClick() {
|
||
orch.setActive(leaf.id);
|
||
}
|
||
|
||
// ---- pane id registration ------------------------------------------------
|
||
function onPaneSpawned(paneId: number) {
|
||
orch.registerPaneId(leaf.id, paneId);
|
||
}
|
||
onDestroy(() => orch.registerPaneId(leaf.id, null));
|
||
</script>
|
||
|
||
<div
|
||
class="leaf"
|
||
class:active={active}
|
||
class:broadcasting={leaf.broadcast}
|
||
role="group"
|
||
aria-label={"Terminal pane: " + (leaf.label ?? leaf.distro ?? "unnamed")}
|
||
data-leaf-id={leaf.id}
|
||
onpointerdown={onPaneClick}
|
||
>
|
||
<div class="pane-toolbar">
|
||
{#if editingLabel}
|
||
<input
|
||
class="label-input"
|
||
bind:this={labelInputEl}
|
||
bind:value={labelDraft}
|
||
onkeydown={onLabelKey}
|
||
onblur={commitLabel}
|
||
placeholder="(label)"
|
||
/>
|
||
{:else}
|
||
<button
|
||
class="pane-label"
|
||
onclick={startEditLabel}
|
||
title="Click to rename pane"
|
||
>
|
||
{leaf.label ?? "(unnamed)"}
|
||
</button>
|
||
{/if}
|
||
|
||
<span class="distro-wrap">
|
||
<button
|
||
class="distro-chip"
|
||
onclick={toggleDistroMenu}
|
||
title="Change distro (respawns the pane)"
|
||
>
|
||
{leaf.distro ?? "(default)"} ▾
|
||
</button>
|
||
{#if distroOpen}
|
||
<div
|
||
class="distro-menu"
|
||
onclick={(e) => e.stopPropagation()}
|
||
role="menu"
|
||
tabindex="-1"
|
||
onkeydown={() => {}}
|
||
>
|
||
{#each orch.distros as d}
|
||
<button
|
||
class="distro-menu-item"
|
||
class:active={d === leaf.distro}
|
||
onclick={() => pickDistro(d)}
|
||
>{d}</button>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
</span>
|
||
|
||
<button
|
||
class="bcast-chip"
|
||
class:on={leaf.broadcast}
|
||
onclick={(e) => { e.stopPropagation(); orch.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={(e) => { e.stopPropagation(); orch.split(leaf.id, "h"); }}
|
||
aria-label="Split right"
|
||
>⇥</button>
|
||
<button
|
||
class="pane-btn"
|
||
title="Split down"
|
||
onclick={(e) => { e.stopPropagation(); orch.split(leaf.id, "v"); }}
|
||
aria-label="Split down"
|
||
>⇣</button>
|
||
<button
|
||
class="pane-btn close"
|
||
title="Close pane"
|
||
onclick={(e) => { e.stopPropagation(); orch.close(leaf.id); }}
|
||
aria-label="Close pane"
|
||
>×</button>
|
||
</span>
|
||
</div>
|
||
<div class="xterm-wrap">
|
||
<XtermPane
|
||
distro={leaf.distro}
|
||
cwd={leaf.cwd}
|
||
onStatus={(msg, ok) => {
|
||
status = msg;
|
||
statusOk = ok;
|
||
}}
|
||
onSpawn={onPaneSpawned}
|
||
onInput={onTerminalInput}
|
||
onFocus={() => orch.setActive(leaf.id)}
|
||
{onDataReceived}
|
||
{focusTrigger}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<style>
|
||
.leaf {
|
||
display: flex;
|
||
flex-direction: column;
|
||
width: 100%;
|
||
height: 100%;
|
||
min-width: 0;
|
||
min-height: 0;
|
||
border: 2px solid transparent;
|
||
box-sizing: border-box;
|
||
}
|
||
.leaf.active {
|
||
border-color: #5a8cd8;
|
||
}
|
||
.leaf.broadcasting {
|
||
border-color: #e09838;
|
||
}
|
||
.leaf.active.broadcasting {
|
||
border-color: #ffb840;
|
||
}
|
||
|
||
.pane-toolbar {
|
||
flex: 0 0 auto;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 2px 8px;
|
||
background: #181818;
|
||
border-bottom: 1px solid #2a2a2a;
|
||
font-size: 11px;
|
||
color: #aaa;
|
||
user-select: none;
|
||
min-height: 24px;
|
||
}
|
||
.pane-label {
|
||
font: inherit;
|
||
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
|
||
font-weight: 600;
|
||
color: #ccc;
|
||
background: transparent;
|
||
border: 1px solid transparent;
|
||
border-radius: 3px;
|
||
padding: 1px 6px;
|
||
cursor: text;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
max-width: 200px;
|
||
}
|
||
.pane-label:hover {
|
||
background: #222;
|
||
border-color: #2a2a2a;
|
||
}
|
||
.label-input {
|
||
font: inherit;
|
||
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
|
||
font-weight: 600;
|
||
color: #fff;
|
||
background: #0c0c0c;
|
||
border: 1px solid #3a5a8c;
|
||
border-radius: 3px;
|
||
padding: 1px 6px;
|
||
outline: none;
|
||
max-width: 240px;
|
||
}
|
||
|
||
.distro-wrap {
|
||
position: relative;
|
||
}
|
||
.distro-chip,
|
||
.bcast-chip {
|
||
font: inherit;
|
||
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
|
||
font-size: 10px;
|
||
background: #222;
|
||
color: #88c;
|
||
border: 1px solid #2a2a3a;
|
||
border-radius: 3px;
|
||
padding: 1px 6px;
|
||
cursor: pointer;
|
||
}
|
||
.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%;
|
||
left: 0;
|
||
margin-top: 2px;
|
||
background: #1a1a1a;
|
||
border: 1px solid #2a2a2a;
|
||
border-radius: 4px;
|
||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
|
||
z-index: 10;
|
||
min-width: 140px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
padding: 2px;
|
||
}
|
||
.distro-menu-item {
|
||
font: inherit;
|
||
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
|
||
font-size: 11px;
|
||
text-align: left;
|
||
background: transparent;
|
||
color: #ccc;
|
||
border: none;
|
||
border-radius: 2px;
|
||
padding: 3px 8px;
|
||
cursor: pointer;
|
||
}
|
||
.distro-menu-item:hover {
|
||
background: #2a2a2a;
|
||
}
|
||
.distro-menu-item.active {
|
||
background: #1a3a5c;
|
||
color: #cce6ff;
|
||
}
|
||
|
||
.pane-status {
|
||
margin-left: auto;
|
||
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
|
||
color: #777;
|
||
font-size: 10px;
|
||
white-space: nowrap;
|
||
}
|
||
.pane-status.ok { color: #6c6; }
|
||
.pane-status.err { color: #d66; }
|
||
|
||
.pane-actions {
|
||
display: flex;
|
||
gap: 2px;
|
||
}
|
||
.pane-btn {
|
||
background: transparent;
|
||
border: none;
|
||
color: #888;
|
||
font: inherit;
|
||
font-size: 14px;
|
||
line-height: 1;
|
||
padding: 2px 6px;
|
||
border-radius: 3px;
|
||
cursor: pointer;
|
||
}
|
||
.pane-btn:hover {
|
||
background: #2a2a2a;
|
||
color: #ddd;
|
||
}
|
||
.pane-btn.close:hover {
|
||
background: #5a1a1a;
|
||
color: #fcc;
|
||
}
|
||
.xterm-wrap {
|
||
flex: 1 1 auto;
|
||
min-height: 0;
|
||
position: relative;
|
||
}
|
||
</style>
|