tiletopia/src/lib/layout/LeafPane.svelte
megaproxy f5f788652e Fix active-pane detection via activeElement polling
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>
2026-05-22 15:43:30 +01:00

395 lines
9.6 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>