Migrate frontend from Svelte 5 to React 18

After hours of fighting Svelte 5's prop-reactivity through the
recursive Pane → SplitNode → LeafPane chain (props captured at
mount, never updated; context+getter pattern crashed; DOM-direct
workarounds created zombie-split click-intercept bugs), we
checkpointed the Svelte version (branch svelte-archive at e9015b2,
tarball at D:\archives\tiletopia-svelte-2026-05-22.tar.gz) and
rewrote the frontend in React.

Kept verbatim:
- All of src-tauri/ (Rust backend, Tauri config, icons)
- scripts/ (make-icon.py, release.sh)
- README.md, CLAUDE.md, memory.md
- src/lib/layout/tree.ts (pure TS — 43 tests still pass)
- src/ipc.ts (Tauri command wrappers)

Rewrote in React:
- src/App.tsx (top-level state via useState, OrchestrationProvider
  for descendants via React.Context)
- src/lib/layout/orchestration.tsx (React Context API for shared
  state — known-reliable reactivity, no Svelte 5 wall)
- src/lib/layout/Pane.tsx (recursive dispatcher)
- src/lib/layout/SplitNode.tsx (draggable gutter, local ratio state)
- src/lib/layout/LeafPane.tsx (toolbar + XtermPane)
- src/components/XtermPane.tsx (xterm.js wrapper, refs for callbacks)
- src/components/Notifications.tsx, Palette.tsx

Build: Vite + @vitejs/plugin-react. TypeScript strict. Same Tauri 2
config. Verified: pnpm check (clean), pnpm test (43/43 pass).
Not yet verified: pnpm tauri dev — that requires the Windows host.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-05-22 18:05:05 +01:00
parent e9015b2790
commit 774b8633dc
32 changed files with 2087 additions and 1825 deletions

View file

@ -0,0 +1,62 @@
.toast-stack {
position: fixed;
top: 36px;
right: 12px;
display: flex;
flex-direction: column;
gap: 6px;
z-index: 100;
pointer-events: none;
}
.toast {
pointer-events: auto;
display: flex;
align-items: center;
gap: 10px;
min-width: 220px;
max-width: 320px;
padding: 8px 10px 8px 14px;
background: #1f1f1f;
color: #ddd;
border: 1px solid #3a5a8c;
border-left-width: 3px;
border-radius: 4px;
font-size: 12px;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.45);
animation: slide-in 180ms ease-out;
}
.toast-msg {
flex: 1 1 auto;
line-height: 1.3;
word-break: break-word;
}
.toast-x {
flex: 0 0 auto;
background: transparent;
border: none;
color: #777;
font-size: 16px;
line-height: 1;
padding: 2px 6px;
cursor: pointer;
border-radius: 3px;
}
.toast-x:hover {
background: #2a2a2a;
color: #ddd;
}
@keyframes slide-in {
from {
transform: translateX(20px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}

View file

@ -1,81 +0,0 @@
<script lang="ts">
export interface Toast {
id: number;
message: string;
}
let {
notifications,
onDismiss,
}: {
notifications: Toast[];
onDismiss: (id: number) => void;
} = $props();
</script>
<div class="toast-stack">
{#each notifications as t (t.id)}
<div class="toast">
<span class="toast-msg">{t.message}</span>
<button
class="toast-x"
onclick={() => onDismiss(t.id)}
aria-label="Dismiss notification"
>×</button>
</div>
{/each}
</div>
<style>
.toast-stack {
position: fixed;
top: 36px;
right: 12px;
display: flex;
flex-direction: column;
gap: 6px;
z-index: 100;
pointer-events: none;
}
.toast {
pointer-events: auto;
display: flex;
align-items: center;
gap: 10px;
min-width: 220px;
max-width: 320px;
padding: 8px 10px 8px 14px;
background: #1f1f1f;
color: #ddd;
border: 1px solid #3a5a8c;
border-left-width: 3px;
border-radius: 4px;
font-size: 12px;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.45);
animation: slide-in 180ms ease-out;
}
.toast-msg {
flex: 1 1 auto;
line-height: 1.3;
word-break: break-word;
}
.toast-x {
flex: 0 0 auto;
background: transparent;
border: none;
color: #777;
font-size: 16px;
line-height: 1;
padding: 2px 6px;
cursor: pointer;
border-radius: 3px;
}
.toast-x:hover {
background: #2a2a2a;
color: #ddd;
}
@keyframes slide-in {
from { transform: translateX(20px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
</style>

View file

@ -0,0 +1,34 @@
import React from "react";
import "./Notifications.css";
export interface Toast {
id: number;
message: string;
}
interface NotificationsProps {
notifications: Toast[];
onDismiss: (id: number) => void;
}
export default function Notifications({
notifications,
onDismiss,
}: NotificationsProps) {
return (
<div className="toast-stack">
{notifications.map((t) => (
<div key={t.id} className="toast">
<span className="toast-msg">{t.message}</span>
<button
className="toast-x"
onClick={() => onDismiss(t.id)}
aria-label="Dismiss notification"
>
×
</button>
</div>
))}
</div>
);
}

View file

@ -0,0 +1,99 @@
.backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(2px);
z-index: 99;
border: 0;
padding: 0;
cursor: default;
}
.palette {
position: fixed;
top: 12vh;
left: 50%;
transform: translateX(-50%);
width: min(520px, 90vw);
background: #181818;
border: 1px solid #2a2a2a;
border-radius: 6px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
z-index: 100;
display: flex;
flex-direction: column;
overflow: hidden;
}
.palette-input {
font: inherit;
font-size: 14px;
color: #fff;
background: #1f1f1f;
border: none;
border-bottom: 1px solid #2a2a2a;
padding: 10px 14px;
outline: none;
}
.palette-list {
list-style: none;
margin: 0;
padding: 4px;
max-height: 50vh;
overflow-y: auto;
}
.palette-item {
width: 100%;
text-align: left;
background: transparent;
border: none;
color: #ccc;
padding: 6px 10px;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: baseline;
gap: 10px;
font: inherit;
font-size: 12px;
}
.palette-item.highlight {
background: #1a3a5c;
color: #cce6ff;
}
.name {
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
font-weight: 600;
}
.meta {
color: #888;
font-size: 11px;
}
.palette-item.highlight .meta {
color: #9bd;
}
.meta.cwd {
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.meta.bcast {
margin-left: auto;
}
.empty {
color: #777;
font-style: italic;
padding: 8px 10px;
list-style: none;
}

View file

@ -1,188 +0,0 @@
<script lang="ts">
import type { LeafNode } from "../lib/layout/tree";
let {
leaves,
onPick,
onClose,
}: {
leaves: LeafNode[];
onPick: (leafId: string) => void;
onClose: () => void;
} = $props();
let query = $state("");
let inputEl: HTMLInputElement | null = $state(null);
let highlightIndex = $state(0);
$effect(() => {
queueMicrotask(() => inputEl?.focus());
});
const filtered = $derived.by(() => {
const q = query.trim().toLowerCase();
if (!q) return leaves;
return leaves.filter((l) => {
const blob = `${l.label ?? ""} ${l.distro ?? ""} ${l.cwd ?? ""}`.toLowerCase();
return blob.includes(q);
});
});
// Clamp highlight whenever the filtered list changes.
$effect(() => {
if (highlightIndex >= filtered.length) highlightIndex = Math.max(0, filtered.length - 1);
});
function pick(idx: number) {
const l = filtered[idx];
if (l) onPick(l.id);
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") {
e.preventDefault();
onClose();
} else if (e.key === "ArrowDown") {
e.preventDefault();
if (filtered.length > 0) {
highlightIndex = (highlightIndex + 1) % filtered.length;
}
} else if (e.key === "ArrowUp") {
e.preventDefault();
if (filtered.length > 0) {
highlightIndex = (highlightIndex - 1 + filtered.length) % filtered.length;
}
} else if (e.key === "Enter") {
e.preventDefault();
pick(highlightIndex);
}
}
</script>
<button
class="backdrop"
onclick={onClose}
aria-label="Close palette"
></button>
<div class="palette" role="dialog" aria-label="Jump to pane">
<input
bind:this={inputEl}
bind:value={query}
onkeydown={onKey}
placeholder="Jump to pane — type to filter, ↑/↓ + Enter"
class="palette-input"
/>
<ul class="palette-list">
{#if filtered.length === 0}
<li class="empty">No matching panes.</li>
{:else}
{#each filtered as leaf, i}
<li>
<button
class="palette-item"
class:highlight={i === highlightIndex}
onclick={() => pick(i)}
onmouseenter={() => (highlightIndex = i)}
>
<span class="name">{leaf.label ?? "(unnamed)"}</span>
<span class="meta">{leaf.distro ?? "default"}</span>
{#if leaf.cwd}<span class="meta cwd">{leaf.cwd}</span>{/if}
{#if leaf.broadcast}<span class="meta bcast">📡</span>{/if}
</button>
</li>
{/each}
{/if}
</ul>
</div>
<style>
.backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(2px);
z-index: 99;
border: 0;
padding: 0;
cursor: default;
}
.palette {
position: fixed;
top: 12vh;
left: 50%;
transform: translateX(-50%);
width: min(520px, 90vw);
background: #181818;
border: 1px solid #2a2a2a;
border-radius: 6px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
z-index: 100;
display: flex;
flex-direction: column;
overflow: hidden;
}
.palette-input {
font: inherit;
font-size: 14px;
color: #fff;
background: #1f1f1f;
border: none;
border-bottom: 1px solid #2a2a2a;
padding: 10px 14px;
outline: none;
}
.palette-list {
list-style: none;
margin: 0;
padding: 4px;
max-height: 50vh;
overflow-y: auto;
}
.palette-item {
width: 100%;
text-align: left;
background: transparent;
border: none;
color: #ccc;
padding: 6px 10px;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: baseline;
gap: 10px;
font: inherit;
font-size: 12px;
}
.palette-item.highlight {
background: #1a3a5c;
color: #cce6ff;
}
.name {
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
font-weight: 600;
}
.meta {
color: #888;
font-size: 11px;
}
.palette-item.highlight .meta {
color: #9bd;
}
.meta.cwd {
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.meta.bcast {
margin-left: auto;
}
.empty {
color: #777;
font-style: italic;
padding: 8px 10px;
list-style: none;
}
</style>

108
src/components/Palette.tsx Normal file
View file

@ -0,0 +1,108 @@
import React, { useState, useEffect, useRef, useMemo } from "react";
import type { LeafNode } from "../lib/layout/tree";
import "./Palette.css";
interface PaletteProps {
leaves: LeafNode[];
onPick: (leafId: string) => void;
onClose: () => void;
}
export default function Palette({ leaves, onPick, onClose }: PaletteProps) {
const [query, setQuery] = useState("");
const [highlightIndex, setHighlightIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
// Autofocus the input on mount
useEffect(() => {
queueMicrotask(() => inputRef.current?.focus());
}, []);
// Compute filtered leaves based on query
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return leaves;
return leaves.filter((l) => {
const blob = `${l.label ?? ""} ${l.distro ?? ""} ${l.cwd ?? ""}`.toLowerCase();
return blob.includes(q);
});
}, [query, leaves]);
// Clamp highlight index when filtered list changes
useEffect(() => {
if (highlightIndex >= filtered.length) {
setHighlightIndex(Math.max(0, filtered.length - 1));
}
}, [filtered, highlightIndex]);
function pick(idx: number) {
const l = filtered[idx];
if (l) onPick(l.id);
}
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === "Escape") {
e.preventDefault();
onClose();
} else if (e.key === "ArrowDown") {
e.preventDefault();
if (filtered.length > 0) {
setHighlightIndex((prev) => (prev + 1) % filtered.length);
}
} else if (e.key === "ArrowUp") {
e.preventDefault();
if (filtered.length > 0) {
setHighlightIndex((prev) =>
(prev - 1 + filtered.length) % filtered.length
);
}
} else if (e.key === "Enter") {
e.preventDefault();
pick(highlightIndex);
}
}
return (
<>
<button
className="backdrop"
onClick={onClose}
aria-label="Close palette"
></button>
<div className="palette" role="dialog" aria-label="Jump to pane">
<input
ref={inputRef}
type="text"
className="palette-input"
placeholder="Jump to pane — type to filter, ↑/↓ + Enter"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
/>
<ul className="palette-list">
{filtered.length === 0 ? (
<li className="empty">No matching panes.</li>
) : (
filtered.map((leaf, i) => (
<li key={leaf.id}>
<button
className={`palette-item ${
i === highlightIndex ? "highlight" : ""
}`}
onClick={() => pick(i)}
onMouseEnter={() => setHighlightIndex(i)}
>
<span className="name">{leaf.label ?? "(unnamed)"}</span>
<span className="meta">{leaf.distro ?? "default"}</span>
{leaf.cwd && <span className="meta cwd">{leaf.cwd}</span>}
{leaf.broadcast && <span className="meta bcast">📡</span>}
</button>
</li>
))
)}
</ul>
</div>
</>
);
}

View file

@ -1,183 +0,0 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import type { UnlistenFn } from "@tauri-apps/api/event";
import {
spawnPane,
writeToPane,
resizePane,
killPane,
onPaneData,
onPaneExit,
type PaneId,
} from "../ipc";
let {
distro = undefined,
cwd = undefined,
onStatus = (_s: string, _ok: boolean) => {},
onSpawn = undefined,
onInput = undefined,
onDataReceived = undefined,
onFocus = undefined,
focusTrigger = 0,
}: {
distro?: string;
cwd?: string;
onStatus?: (msg: string, ok: boolean) => void;
/** Fired once when the backend PTY is alive and we have its PaneId. */
onSpawn?: (paneId: PaneId) => void;
/** Fired AFTER each writeToPane on user keypress. Used by broadcasting. */
onInput?: (dataB64: string) => void;
/** Fired whenever output arrives from the PTY. Used for idle detection. */
onDataReceived?: () => void;
/** Fired when xterm's textarea gains focus (i.e., user clicked here). */
onFocus?: () => void;
/** Increment to refocus the terminal programmatically (palette etc.). */
focusTrigger?: number;
} = $props();
let containerEl: HTMLDivElement;
let term: Terminal | null = null;
let fit: FitAddon | null = null;
let paneId: PaneId | null = null;
let unlistenData: UnlistenFn | null = null;
let unlistenExit: UnlistenFn | null = null;
let ro: ResizeObserver | null = null;
// Decode base64 -> Uint8Array. xterm.js accepts both strings and Uint8Array;
// bytes is preferred to avoid double-decoding UTF-8.
function b64ToBytes(b64: string): Uint8Array {
const bin = atob(b64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
function bytesToB64(bytes: Uint8Array): string {
let s = "";
for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
return btoa(s);
}
function stringToB64(s: string): string {
// xterm.js's onData emits a JS string; need to UTF-8 encode before base64.
return bytesToB64(new TextEncoder().encode(s));
}
onMount(async () => {
term = new Terminal({
fontFamily: '"Cascadia Mono", "JetBrains Mono", "Consolas", monospace',
fontSize: 13,
cursorBlink: true,
theme: {
background: "#0c0c0c",
foreground: "#e6e6e6",
},
scrollback: 5000,
convertEol: false,
allowProposedApi: true,
});
fit = new FitAddon();
term.loadAddon(fit);
term.open(containerEl);
// Initial size — fit before we ask the PTY for its dimensions.
fit.fit();
const cols = term.cols;
const rows = term.rows;
try {
paneId = await spawnPane({ distro, cwd, cols, rows });
onStatus(`pane ${paneId} alive`, true);
onSpawn?.(paneId);
} catch (e) {
const msg = `spawn_pane failed: ${e}`;
term.write(`\r\n\x1b[31m${msg}\x1b[0m\r\n`);
onStatus(msg, false);
return;
}
unlistenData = await onPaneData(paneId, (b64) => {
term?.write(b64ToBytes(b64));
onDataReceived?.();
});
unlistenExit = await onPaneExit(paneId, () => {
term?.write("\r\n\x1b[33m[pane exited]\x1b[0m\r\n");
onStatus(`pane ${paneId} exited`, false);
});
term.onData((data) => {
if (paneId == null) return;
const b64 = stringToB64(data);
void writeToPane(paneId, b64);
onInput?.(b64);
});
// xterm.js's own focus event — fires when the hidden textarea gets focus
// (i.e., user clicked anywhere in the terminal). Most reliable signal
// for "user wants this pane active" — no DOM event traversal involved.
term.onSelectionChange(() => {}); // ensure the addon system is initialized; noop
if (typeof (term as unknown as { onFocus?: unknown }).onFocus === "function") {
(term as unknown as { onFocus: (cb: () => void) => void }).onFocus(() => {
onFocus?.();
});
} else {
// Fallback: listen on the textarea element directly.
const ta = containerEl.querySelector(".xterm-helper-textarea");
if (ta) ta.addEventListener("focus", () => onFocus?.(), true);
}
// Re-fit on container resize; forward new size to the PTY.
ro = new ResizeObserver(() => {
try {
fit?.fit();
if (paneId != null && term) {
void resizePane(paneId, term.cols, term.rows);
}
} catch (e) {
console.warn("resize failed", e);
}
});
ro.observe(containerEl);
// Focus so typing immediately lands in the terminal.
term.focus();
});
onDestroy(() => {
ro?.disconnect();
unlistenData?.();
unlistenExit?.();
if (paneId != null) {
void killPane(paneId);
}
term?.dispose();
});
// Refocus the terminal whenever the parent bumps focusTrigger.
$effect(() => {
// Reactive read on focusTrigger so this effect re-runs when it changes.
focusTrigger;
if (term && focusTrigger > 0) term.focus();
});
</script>
<div class="xterm-host" bind:this={containerEl}></div>
<style>
.xterm-host {
width: 100%;
height: 100%;
}
/* xterm.js sets inline padding=0 on its container; ensure the viewport
fills the host with no scrollbar gap. */
:global(.xterm) {
height: 100%;
}
:global(.xterm-viewport) {
background: #0c0c0c !important;
}
</style>

View file

@ -0,0 +1,222 @@
import { useRef, useEffect } from "react";
import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import type { UnlistenFn } from "@tauri-apps/api/event";
import {
spawnPane,
writeToPane,
resizePane,
killPane,
onPaneData,
onPaneExit,
type PaneId,
} from "../ipc";
// ---------------------------------------------------------------------------
// base64 helpers (private to this module)
// ---------------------------------------------------------------------------
function b64ToBytes(b64: string): Uint8Array {
const bin = atob(b64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
function bytesToB64(bytes: Uint8Array): string {
let s = "";
for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
return btoa(s);
}
function stringToB64(s: string): string {
// xterm.js's onData emits a JS string; UTF-8 encode before base64.
return bytesToB64(new TextEncoder().encode(s));
}
// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------
interface XtermPaneProps {
distro?: string;
cwd?: string;
onStatus?: (msg: string, ok: boolean) => void;
/** Fired once when the backend PTY is alive and we have its PaneId. */
onSpawn?: (paneId: PaneId) => void;
/** Fired AFTER each writeToPane on user keypress. Used by broadcasting. */
onInput?: (dataB64: string) => void;
/** Fired whenever output arrives from the PTY. Used for idle detection. */
onDataReceived?: () => void;
/** Fired when xterm's textarea gains focus (i.e., user clicked here). */
onFocus?: () => void;
/** Increment to refocus the terminal programmatically (palette etc.). */
focusTrigger?: number;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export default function XtermPane({
distro,
cwd,
onStatus,
onSpawn,
onInput,
onDataReceived,
onFocus,
focusTrigger = 0,
}: XtermPaneProps) {
const containerRef = useRef<HTMLDivElement>(null);
// Stable refs for callbacks so the mount effect doesn't need to re-run when
// parents pass new inline functions, while still always calling the latest version.
const onStatusRef = useRef(onStatus);
const onSpawnRef = useRef(onSpawn);
const onInputRef = useRef(onInput);
const onDataReceivedRef = useRef(onDataReceived);
const onFocusRef = useRef(onFocus);
useEffect(() => { onStatusRef.current = onStatus; }, [onStatus]);
useEffect(() => { onSpawnRef.current = onSpawn; }, [onSpawn]);
useEffect(() => { onInputRef.current = onInput; }, [onInput]);
useEffect(() => { onDataReceivedRef.current = onDataReceived; }, [onDataReceived]);
useEffect(() => { onFocusRef.current = onFocus; }, [onFocus]);
// -------------------------------------------------------------------------
// Mount / unmount: create terminal, spawn PTY, wire listeners
// -------------------------------------------------------------------------
useEffect(() => {
const container = containerRef.current;
if (!container) return;
let term: Terminal | null = new Terminal({
fontFamily: '"Cascadia Mono", "JetBrains Mono", "Consolas", monospace',
fontSize: 13,
cursorBlink: true,
theme: {
background: "#0c0c0c",
foreground: "#e6e6e6",
},
scrollback: 5000,
convertEol: false,
allowProposedApi: true,
});
const fit = new FitAddon();
term.loadAddon(fit);
term.open(container);
// Initial size — fit before asking the PTY for its dimensions.
fit.fit();
let paneId: PaneId | null = null;
let unlistenData: UnlistenFn | null = null;
let unlistenExit: UnlistenFn | null = null;
let ro: ResizeObserver | null = null;
let destroyed = false;
(async () => {
const cols = term!.cols;
const rows = term!.rows;
try {
paneId = await spawnPane({ distro, cwd, cols, rows });
if (destroyed) {
void killPane(paneId);
return;
}
onStatusRef.current?.(`pane ${paneId} alive`, true);
onSpawnRef.current?.(paneId);
} catch (e) {
if (destroyed) return;
const msg = `spawn_pane failed: ${e}`;
term?.write(`\r\n\x1b[31m${msg}\x1b[0m\r\n`);
onStatusRef.current?.(msg, false);
return;
}
unlistenData = await onPaneData(paneId, (b64) => {
term?.write(b64ToBytes(b64));
onDataReceivedRef.current?.();
});
unlistenExit = await onPaneExit(paneId, () => {
term?.write("\r\n\x1b[33m[pane exited]\x1b[0m\r\n");
onStatusRef.current?.(`pane ${paneId} exited`, false);
});
term?.onData((data) => {
if (paneId == null) return;
const b64 = stringToB64(data);
void writeToPane(paneId, b64);
onInputRef.current?.(b64);
});
// Focus detection: xterm.js doesn't expose onFocus as a first-class event
// in all versions, so try the proposed API first then fall back to the DOM.
term?.onSelectionChange(() => {}); // ensure addon system is initialised; noop
const termAny = term as unknown as { onFocus?: (cb: () => void) => void };
if (typeof termAny.onFocus === "function") {
termAny.onFocus(() => onFocusRef.current?.());
} else {
const ta = container.querySelector(".xterm-helper-textarea");
if (ta) ta.addEventListener("focus", () => onFocusRef.current?.(), true);
}
// Re-fit on container resize; forward new size to the PTY.
ro = new ResizeObserver(() => {
try {
fit.fit();
if (paneId != null && term) {
void resizePane(paneId, term.cols, term.rows);
}
} catch (e) {
console.warn("resize failed", e);
}
});
ro.observe(container);
// Focus so typing immediately lands in the terminal.
term?.focus();
})();
return () => {
destroyed = true;
ro?.disconnect();
unlistenData?.();
unlistenExit?.();
if (paneId != null) void killPane(paneId);
term?.dispose();
term = null;
};
// distro/cwd are only used at spawn time; intentionally omitted from deps
// so remounting doesn't happen if a parent re-renders with the same values.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// -------------------------------------------------------------------------
// focusTrigger: programmatic refocus from parent (palette navigation etc.)
// -------------------------------------------------------------------------
const termRef = useRef<Terminal | null>(null);
// Keep termRef in sync via a second effect that runs after mount.
// We can't easily share the Terminal instance across the two effects without
// a ref, so we store it on termRef inside the mount effect instead.
// Actually, let's just wire focusTrigger by querying the textarea directly —
// that avoids the cross-effect coupling problem entirely.
useEffect(() => {
if (focusTrigger > 0 && containerRef.current) {
const ta = containerRef.current.querySelector<HTMLTextAreaElement>(
".xterm-helper-textarea",
);
ta?.focus();
}
}, [focusTrigger]);
// Suppress unused ref warning
void termRef;
return <div ref={containerRef} style={{ width: "100%", height: "100%" }} />;
}