diff --git a/.gitignore b/.gitignore index fa15af7..81ba218 100644 --- a/.gitignore +++ b/.gitignore @@ -29,4 +29,4 @@ src-tauri/gen/ /shot*.png /tiletopia-window.png /tilescript.ps1 -/cargo-test.lo* +/cargo-test.log diff --git a/README.md b/README.md index 23ec0cb..15ac12d 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,6 @@ A Windows desktop app for running and arranging many WSL terminals at once. Built primarily for managing multiple `claude` sessions across projects in parallel; works for any multi-shell workflow. - Tiling layout — recursive splits, draggable dividers, drag-to-swap pane headers, preset layouts (single / 2-col / 3-col / 2-row / 2×2) -- Tabs — each tab is an independent tile layout (one per project); PTYs in inactive tabs keep running -- Multi-window — pop a pane into its own window (right-click its toolbar, or drag it past the window edge); the PTY survives the move and scrollback replays - Three shell kinds per pane: WSL distros, PowerShell, saved SSH hosts (with optional Windows Credential Manager–stored passwords for auto-typing at the prompt) - Per-pane distro + cwd + label + font-size + broadcast state, persisted across restarts - Broadcast input to a group of panes (per-pane 📡 chip, or global toggle in the titlebar) @@ -40,31 +38,12 @@ A Windows desktop app for running and arranging many WSL terminals at once. Buil | `Ctrl+Shift+W` | Close active pane | | `Ctrl+Shift+P` | Promote active pane out one level (turns a nested pane into a full row/column; self-inverse) | -**Tabs** - -| Key | Action | -|---|---| -| `Ctrl+T` | New tab (blank workspace, one pane) | -| `Ctrl+Shift+T` | Close current tab (confirms when the tab has live panes) | -| `Ctrl+PageDown / Ctrl+PageUp` | Switch to next / previous tab | -| `Ctrl+1 … Ctrl+9` | Switch to tab 1 … 9 | - -**Multi-window** - -| Key | Action | -|---|---| -| `Right-click pane toolbar → Move to new window` | Pop the active pane into a fresh tiletopia window (PTY survives the move; scrollback ring replays) | -| `Drag pane toolbar past the window edge` | Same as the right-click action — release the drag well outside the window to detach into a new window | - **Navigation** | Key | Action | |---|---| | `Ctrl+K` | Open jump-to-pane palette | -| `Ctrl+Shift+← / → / ↑ / ↓` | Focus neighbour pane in that direction (window-level — works even when no terminal is focused) | -| `Ctrl+Alt+← / → / ↑ / ↓` | Focus neighbour pane in that direction (from inside the terminal — intercepted before the PTY sees it) | -| `Ctrl+Alt+H / J / K / L` | Same as Ctrl+Alt+Arrow but in Vim-style HJKL order (left / down / up / right) | -| `Alt+1 … Alt+9` | Focus the Nth pane in layout order (DFS: left-to-right, top-to-bottom); clamped to pane count. Note: swallows bare Alt+digit — shells using readline digit-argument or vim buffer-jump may conflict. | +| `Ctrl+Shift+← / → / ↑ / ↓` | Focus neighbour pane in that direction | **Broadcast** @@ -85,9 +64,6 @@ A Windows desktop app for running and arranging many WSL terminals at once. Buil | Key | Action | |---|---| | `Ctrl+Shift+C / Ctrl+Shift+V` | Copy selection / paste in terminal | -| `Ctrl+Shift+F` | Open find-in-scrollback bar for the focused pane | -| `Enter / Shift+Enter` | Next / previous match (while search bar is focused) | -| `Escape` | Close find bar and return focus to terminal | **Help** @@ -101,9 +77,8 @@ A Windows desktop app for running and arranging many WSL terminals at once. Buil - **SSH host manager** — Titlebar 🔑 SSH hosts opens the manager. Add hostname / user / port / identity file / jump host / extra ssh args. Saved hosts appear in every pane's dropdown. - **Saved passwords** — Optionally save a host's password — stored in Windows Credential Manager (DPAPI-encrypted), never written to hosts.json. When ssh prompts on connect it's typed automatically. Hosts with a saved password show 🔒 in the list. - **Clickable links** — http and https URLs in terminal output get underlined and open in your default browser on click. -- **Drag pane headers to swap or detach** — Grab a pane's title bar and drag onto another pane to swap their tree positions. Drag well outside the window edge (more than ~60px past) and release to detach the pane into a new window — same mechanism as the right-click 'Move to new window' action, PTY stays alive. +- **Drag pane headers to swap** — Grab a pane's title bar and drag it onto another pane to swap their tree positions. Useful for reorganizing without keyboard. - **Workspace persistence** — Layout, labels, distro choices, and SSH hosts auto-save to %APPDATA%/com.megaproxy.tiletopia (debounced 500ms). Closed panes don't come back — only the structure is restored, shells spawn fresh on next launch. -- **Tabs (workspaces)** — Each tab is an independent tile layout — useful for keeping one tab per project. PTYs in non-active tabs keep running (a Claude session in tab A keeps going while you work in tab B). New tab starts with one default-shell pane; close confirms when the tab has live panes. Tabs auto-save to the same workspace.json. - **MCP server (let Claude drive the workspace)** — Titlebar 🤖 opens the MCP control panel. Start the server, then for Claude Desktop click 'Download .mcpb' and drag the file into Settings → Extensions — zero-config because the bundle reads your bearer token from %APPDATA% at launch (no copy-paste, survives token rotation). For Claude Code (terminal CLI) use the fallback snippet in the panel: it wires npx mcp-remote as a stdio shim because Claude Code's HTTP-MCP client ignores static bearer auth and tries OAuth instead. URL + token persist across restarts; Regenerate the token in the panel if it leaks. Default-deny per pane: toggle 🤖 on each pane's toolbar to expose it to MCP. diff --git a/memory.md b/memory.md index cb38d7a..006e88f 100644 --- a/memory.md +++ b/memory.md @@ -34,11 +34,10 @@ Durable memory for this project. Read at session start, update before session en - [ ] **Configurable idle threshold.** Hardcoded 5000ms in `LeafPane.svelte`. Should move into a settings panel; M5 territory. - [x] ~~**Logic tests for `tree.ts`.**~~ Vitest, 43 cases, runs via `pnpm test`. Done 2026-05-22. - [ ] **Component-level tests** (vitest + jsdom + @testing-library/svelte) — would have caught the M4 active-border reactivity bug. Useful when the Svelte component surface stops being trivial; defer until/unless something else goes sideways. -- [x] ~~**Multi-workspace tabs.**~~ Done 2026-05-28. Implementation lives under "Tabs + multi-window pane transfer" session log. Envelope shape ended up as `{ version: 2, workspaces: [{ id, name, tree }] }` (no separate `current` field — per-window in React state only). +- [ ] **Multi-workspace tabs.** Several independent layouts the user can switch between. Saved as `workspaces.json` with `{ current: id, list: [{ id, name, tree }] }`. Not on the M0–M5 critical path; either bolt on after M5 ship or fold into a "tabs" minor milestone. - [x] ~~**M5 — Ship infrastructure.**~~ Custom icon, version bumped to 0.1.0, `scripts/release.sh` for one-shot tag+upload, README install section. Done 2026-05-22. **Next step (user action):** run `pnpm tauri build` on Windows then `scripts/release.sh v0.1.0` from WSL to cut the actual release. - [ ] **Native Windows shells (cmd / pwsh)?** `portable-pty` supports them for free; keep the option open. Decide whether to expose in UI at M3. - [ ] **Persistent scrollback across app restarts.** Would need an out-of-process mux daemon. Big scope creep; explicitly deferred past v1. -- [ ] **Code markup / syntax highlighting in-app (VSCode-style).** User idea 2026-05-28 — "would be kind of neat." Two readings, different feasibility: (a) **highlight code in terminal output** — not really doable in xterm.js; it renders raw bytes/ANSI and has no concept of "this region is Python." Would need to detect code blocks and re-emit ANSI color, which is fragile and fights TUIs like claude that already color their own output. (b) **a dedicated editor/viewer pane type** alongside terminal panes — embed Monaco or CodeMirror as a new LeafNode kind, open a file from the pane's cwd, get real VSCode-grade highlighting + read/scroll (maybe edit). This is the tractable version: the layout tree already supports heterogeneous leaves, so it's "add a non-xterm pane kind" rather than reworking the renderer. Scope: pick editor lib (CodeMirror 6 is lighter than Monaco for an embed), file-open IPC over WSL paths, decide read-only vs editable. Defer — nice-to-have, not core to the multi-terminal purpose. - [ ] **Keybinding philosophy.** Copy tmux, copy WezTerm, or invent? Decide at M3. - [ ] **Help (?) overlay.** Small `?` icon in the titlebar, opens a modal listing all keyboard shortcuts (split / close / promote / broadcast / palette / font size / nav) and quick tips on shell-picker dropdown + SSH host manager + saved-password autotype. Same modal style as `Palette` / `HostManager`. Source of truth lives in one place — refactor the README shortcuts table to be generated from it (or vice versa) so they can't drift. - [ ] **MCP server: Claude controls tiletopia.** Expose a Model Context Protocol server (stdio transport, runs inside the Tauri app or a sidecar) so a Claude session — running anywhere, including inside one of tiletopia's own panes — can drive the workspace. Capabilities to expose as MCP tools / resources: @@ -51,245 +50,8 @@ Durable memory for this project. Read at session start, update before session en - Tauri integration: Rust-side MCP server using a published crate (or hand-rolled JSON-RPC); reuses the existing `PtyManager` + `hosts.json` + workspace state. Frontend gets read-only events when the MCP causes a layout change so the UI reflects it without races. Big — milestone-scale work; needs a design doc before code. - **Status:** v1 (read-only, 2026-05-25) + v2 (write surface, 2026-05-26 across PRs 1–4) shipped. All 11 originally-planned write tools are live: set_label, close_pane, swap_panes, promote_pane, apply_preset, spawn_pane, connect_host, write_pane, add_host, delete_host. Open polish items live in the per-session-log "follow-ups" sections. -## Feature backlog — 2026-05-28 fan-out research - -Four-agent research pass (terminal-landscape, AI-orchestration, xterm/Tauri ecosystem, codebase gap-analysis) into things to add. **Headline finding:** tiletopia already owns the hard primitives (tiling, multi-window, broadcast, MCP control surface); the real gap vs Conductor/Crystal/claude-squad/Vibe-Kanban is *git-worktree isolation + per-session status/cost/diff visibility*. Full agent deliverables are in this session's conversation; condensed here. - -**→ Exploring first (user-selected 2026-05-28):** -- [x] ~~**Per-session cost / token tracking.**~~ Done (code) 2026-05-28 — **WSL-only v1, pending Windows runtime verify.** Backend `src-tauri/src/usage.rs` (`get_claude_usage(distros)` command): probes `$HOME` per distro via `wsl.exe`, reads `~/.claude/projects/*/*.jsonl` over the `\\wsl.localhost\` UNC share, tallies `message.usage` **per model per assistant line** (sessions can switch models). Cached by `(path,size,mtime)`; recency-capped 30d/50 sessions. Frontend: `src/lib/usage.ts` holds the editable pricing table (per-MTok, matched by opus/sonnet/haiku substring) + cost/format helpers; `UsagePanel.tsx` (MCP-panel modal pattern) lists sessions, highlights those whose transcript `cwd` matches an open pane (`[pane: label]`); titlebar 💰 total chip; App polls 20s (visible) / 5s (panel open); **Ctrl+Shift+U** opens it. **Design choice:** session-list attribution (not 1:1 pane binding) — avoids the unsolvable "2 claudes in one cwd" ambiguity. **Caveats:** cost is an estimate (cache-creation priced at 5m rate; rates hardcoded, may drift); panes with no explicit cwd (`~`) won't highlight; PowerShell/SSH show nothing. Plan: `~/.claude/plans/greedy-cooking-flask.md`. - - **PIVOTED 2026-05-28 → per-pane context-fill indicator (replaces the panel).** User decided lifetime token totals + $ aren't worth it on a subscription; what's actionable is *current context-window occupancy* per pane (spot the one needing `/compact`). Removed `UsagePanel`, the 💰 titlebar chip, and `Ctrl+Shift+U`. Repurposed `usage.rs`: `get_pane_context` returns each recent session's **current** occupancy = the LAST assistant turn's `input + cache_read + cache_creation` tokens (verified ~274k on this 1M session). `src/lib/usage.ts` now does window inference (200k vs 1M by whether occupancy already exceeds 200k — model id doesn't encode the variant), %, color ramp. App polls 15s (visibility-gated) → `cwd→SessionContext` map via orchestration; `LeafPane` renders a slim fill bar + % in the header, matched by `leaf.cwd`. **Also fixed narrow-pane toolbar** (user report: close × clipped when slim): a `ResizeObserver` in LeafPane sets `leaf--narrow`/`leaf--xnarrow` tiers; label shrinks first, split/status/secondary chips drop by tier, close × + context indicator stay pinned-right + visible down to the 180px min. Plan: `~/.claude/plans/greedy-cooking-flask.md` (rewritten for the pivot). **Pending Windows runtime verify.** Window-size 200k/1M is inferred (approx near boundary); `~`-spawned / cd'd panes may not match their session. - - **Windows test 2026-05-28:** narrow-pane toolbar reflow (close × stays visible when shrunk, leaf--narrow/xnarrow tiers) **VERIFIED working.** BUT the context bar **does not show** — root-caused: it keys on `leaf.cwd`, which is ~always `undefined` (`newLeaf` sets no cwd; the shell picker never supplies one; only split-inheritance propagates it). So the cwd↔transcript match never hits for normal panes. Needs the pane's *live* cwd to work — leading options: capture via OSC 7 (default WSL bash under tiletopia doesn't emit it → would need injecting a PROMPT_COMMAND at spawn, shell-specific), or an "active pane shows its distro's currently-active session" heuristic gated on recent mtime. Decision pending with user. - - **Fix implemented 2026-05-28 (OSC 7 live cwd, user chose this) — PENDING re-test.** `pty.rs` Wsl arm now sets `PROMPT_COMMAND` (forwarded via `WSLENV=…:PROMPT_COMMAND/u`) to `printf '\033]7;file://%s%s\033\\' "$HOSTNAME" "$PWD"` so the shell emits OSC 7 each prompt; default Ubuntu bash inherits an env-provided PROMPT_COMMAND (a hard-assigning rc or non-bash shell won't report → bar hidden, no breakage). `XtermPane` registers `term.parser.registerOscHandler(7, …)`, decodes the path, fires new `onCwd` prop. `LeafPane` tracks `liveCwd` and matches on `(liveCwd ?? leaf.cwd)`. OSC 7 fires at the bash prompt right before `claude` launches → `liveCwd` = claude's launch cwd; also follows `cd`. **If still blank after re-test:** check the shell actually emits OSC 7 (it won't if the user's rc hard-sets PROMPT_COMMAND, or default shell isn't bash) and that backend `get_pane_context` returns sessions (UNC/$HOME probe). - - **SHELVED 2026-05-28 (user decision).** After getting OSC 7 + the queueMicrotask render-phase fix working (matching confirmed via console diagnostics), the remaining wall was unsolvable from transcripts: **can't distinguish "claude is live in this pane" from "a shell sitting in a directory that recently had a claude session."** No reliable signal — claude Code renders **inline (not the alternate-screen buffer)** so alt-screen detection fails; no WSL foreground-process access from the Windows host (wsl.exe PID ≠ linux shell PID); and any mtime recency gate can't separate an idle-but-live session (was 18min idle) from a stale neighbouring shell (52min). Also the 200k-vs-1M window isn't in the transcript (`model` is bare `claude-opus-4-7`; the `[1m]` in `/context` is display-only) so % is unreliable (showed absolute tokens instead). Removed the indicator, OSC 7 injection (pty.rs), `usage.rs`/`get_pane_context`, `src/lib/usage.ts`, orchestration `paneContext`, and the App poll. **KEPT: the narrow-pane toolbar reflow** (`leaf--narrow`/`leaf--xnarrow` width tiers via ResizeObserver, label shrinks first, close × stays pinned/visible to the 180px min) — verified working, independent of the context feature. **If ever revisited:** the only correct approach is a WSL foreground-process probe (the deferred "is claude foreground" idle-detection backlog item) to know which pane is actually running claude. - - **Windows test #2 2026-05-28 (OSC 7) + refinements:** OSC 7 injection **confirmed working** (`echo $PROMPT_COMMAND` shows our printf; a fresh pane lit up). Two issues found + fixed: (1) **bars appeared on plain bash panes** sitting in a dir that once had a claude session → added a **recency gate** (`CONTEXT_ACTIVE_MS = 10min`): only show when the matched session was written recently, so it tracks a live claude not a dormant transcript. (2) **The `[1m]` 1M-context marker is NOT in the transcript** — model id is bare (`claude-opus-4-7`), `[1m]` is display-only in `/context`. So the 200k-vs-1M window is unknowable from transcripts; the old `<200k→200k` guess overstated % for 1M users (42k read 21% vs claude's real 4%). Fix: indicator **label now shows absolute token count** (`formatTokens`, accurate regardless of window); the fill bar **assumes 1M**. A long-running claude pane spawned by the OLD binary won't have OSC 7 → no bar until respawned. **Still pending: confirm a freshly-spawned claude pane shows the right number.** - - **Superseded — original lifetime-token panel refinements (kept for history):** (1) **Scope** — panel + titlebar chip now default to sessions matching open panes ("this workspace"), with an "open panes / all recent" toggle. The first cut summed *every* recent session on the distro (all projects, `/mnt` + home), which read as inflated. **Investigated the "double counting mounted folders + projects" report: NOT a real double count** — every transcript file is read exactly once, and no two project dirs share a cwd because claude resolves symlinks/mounts to the real path before mangling the project-dir name (e.g. the `~/claude/projects/tiletopia → /mnt/d/dev/tiletopia` symlink yields only `-mnt-d-dev-tiletopia`). The inflation was purely the global scope. (2) **Metric framing** — user is on a Pro/Max subscription where $ is meaningless (and `/usage` rate-limit quota can't be derived from transcripts); **tokens are now the headline**, the API-cost estimate is a labeled secondary `~$` kept visible so the user can validate it against real API billing at work. **Open question:** accuracy of the $ estimate vs actual API billing — user will check at work. -- [ ] **Smart link providers.** `terminal.registerLinkProvider()` to make file paths (`src/foo.ts:12:3`), `localhost:PORT`, and error locations clickable — more flexible than the regex-only web-links addon already loaded. Open file in editor / browser. Difficulty: medium. -- [x] ~~**Find in scrollback.**~~ Done + **verified on Windows 2026-05-28** — `@xterm/addon-search` + new `src/components/SearchBar.tsx`/`.css` overlay, Ctrl+Shift+F open / Enter / Shift+Enter / Esc, regex + case toggles, decoration highlight. -- [x] ~~**Unicode 11 + grapheme width.**~~ Done + **verified on Windows 2026-05-28** — `@xterm/addon-unicode11` loaded after CanvasAddon, `term.unicode.activeVersion = '11'`. (Skipped the separate `addon-unicode-graphemes` for now.) -- [x] ~~**Pane navigation key handler.**~~ Done + **verified on Windows 2026-05-28** — Ctrl+Alt+Arrow / Ctrl+Alt+HJKL (spatial via `findNeighborInDirection`) + Alt+1..9 (Nth `walkLeaves` leaf). New `NavigateIntent` union in orchestration.tsx; XtermPane emits intent via new `onNavigate` prop → LeafPane → App `navigateTo` sets active leaf (reuses isActive→focusTrigger refocus). All chords share the one `attachCustomKeyEventHandler`. **Caveats:** Alt+1..9 swallows bare Alt+digit (breaks readline digit-arg / vim buffer-jump); Ctrl+Alt+Arrow may collide with Windows virtual-desktop switching — both noted in shortcuts.ts, v2 mitigation = opt-out toggle or Ctrl+Alt+Shift+Arrow. - -**Stuck/ghost cursor bug — FIXED + verified on Windows 2026-05-28.** The DOM renderer (xterm default) draws the cursor as a separate layered DOM element; under the Claude TUI's rapid cursor hide/show (`\x1b[?25l/h`) + `cursorBlink` it left a stale white block frozen at the old cursor position. Fix: load `@xterm/addon-canvas` in XtermPane after `term.open()` (composites the cursor into the text surface), wrapped in try/catch that falls back to the DOM renderer on init failure. Chose canvas over WebGL because tiletopia runs many panes and WebView2 caps live WebGL contexts at ~16. User confirmed the marker no longer sticks. - -**Implementation note:** the three above were built in one fan-out workflow (parallel design on haiku/sonnet → single sonnet implementer applying to shared files), since all three touch `XtermPane`'s mount + its single `attachCustomKeyEventHandler` (xterm replaces the handler on each call, so they MUST coexist in one registration — don't add a second `attachCustomKeyEventHandler` anywhere). - -**Parked — circle back (saved, not yet prioritized):** - -*Tier 1 — core "many claudes" mission (highest leverage):* -- [ ] **Git worktree per session.** Spawn each claude pane into its own auto-created worktree+branch so parallel sessions on one repo can't clobber each other. The defining feature of every dedicated tool in the space (Crystal, Conductor, claude-squad, Vibe Kanban); Claude Code itself has `--worktree`. Unlocks best-of-N variants side-by-side. Fiddly part is worktree lifecycle/cleanup-on-close. Difficulty: medium. -- [ ] **Session status: working / waiting-for-input / done.** Existing idle detection conflates "blocked on a permission prompt" with "finished." Pattern-match claude's prompt strings (`Do you want to proceed?`, `❯`, y/n) to distinguish *needs-me* vs *done*. This is what lets one human supervise 8 agents; makes native notifications 10× more useful. Difficulty: medium. -- [ ] **Cross-session diff review.** Per-pane side tab rendering `git diff` in that session's worktree, with accept/reject. With worktrees, reviewing N branches is the bottleneck. Difficulty: medium. -- [ ] **Prompt queueing per pane.** Queue follow-up prompts that auto-send when claude returns to idle. Builds on existing idle detection + broadcast plumbing. Difficulty: easy. -- [ ] **Session templates / "spawn N".** Named launch presets (cwd, worktree scheme, initial prompt, env) + "spawn 3 copies, each a different approach." Difficulty: easy. -- [ ] **Auto-restart / resume on crash or context-limit.** Watch PTY exit codes, distinguish clean vs crash, re-spawn with `claude --resume`/`--continue` to keep long unattended runs alive. Difficulty: medium. -- [ ] **Per-session budget caps w/ auto-pause.** Token/$ ceiling per session/workspace; auto-pause or notify at ~85%, flag sessions stuck retrying. Layers on cost tracking. Difficulty: medium. -- [ ] **Kanban/task-board view over sessions.** Card = task = worktree = agent, moving queued → running → needs-review → merged (à la Vibe Kanban). MCP server makes Claude-driven task decomposition feasible. Substantial 2nd UI paradigm — defer until the Tier-1 cluster lands. Difficulty: hard. - -*Tier 2 — terminal power-user:* -- [ ] **Layout restore across restarts (lighter version).** `@xterm/addon-serialize` snapshots screen+scrollback so reopening restores live-looking terminals. The 80% version of the already-deferred "persistent scrollback" (which needs an out-of-process mux daemon). Difficulty: medium. -- [ ] **Output triggers (regex → action).** iTerm2-style: watch each PTY stream for user regex, fire notify/highlight/auto-keystroke/mark. Reuses the idle-detection data tap; more precise than generic idle. Difficulty: medium. -- [ ] **Quick-select / hints mode.** Overlay short labels on URLs/paths/hashes in the visible buffer; type label to copy/open (WezTerm quick-select / Kitty hints). Difficulty: medium. -- [ ] **Activity markers / decorations.** `registerMarker()` + `registerDecoration()` to mark prompt boundaries / errors / command-finished in the gutter + jump between them. Difficulty: medium. -- [ ] **Stacked / floating panes.** Zellij-style: collapse 10+ panes into stacks (thin title bars, expand on focus), or float a scratch terminal over the grid. Scales past where pure tiling breaks (~8 panes). Difficulty: medium. -- [ ] **Capture / pipe pane output.** tmux capture-pane / pipe-pane: dump scrollback to file or tee live output to a log/command. Auto-logging each claude session → searchable transcripts. Difficulty: easy. -- [ ] **Pane fuzzy switcher.** Extend the Ctrl+K palette with a pane-target source: fuzzy-find any pane across tabs/windows by title/cwd/project/command. Difficulty: easy. -- [ ] **Saved command/prompt snippet library.** Reusable parameterized commands/prompts inserted into any pane (or broadcast) via the palette (Warp Workflows). Difficulty: easy. -- [ ] **System clipboard addon (OSC 52).** `@xterm/addon-clipboard` so a claude session inside WSL can set the host clipboard. Difficulty: easy. -- [ ] **Inline images (sixel / iTerm IIP).** `@xterm/addon-image` to render images CLIs emit (charts, previews, imgcat). Niche; needs memory tuning. Difficulty: medium. -- [ ] **Inline file/markdown/diff preview.** Click a path in output → side-panel preview (markdown render, image, diff) without leaving the app (Wave Terminal). Difficulty: hard. - -*Tier 3 — platform & polish (some overlap existing backlog):* -- [ ] **System tray + minimize-to-tray.** `TrayIcon` (`@tauri-apps/api/tray`) — keep tiletopia resident, restore/jump-to-workspace from tray. Difficulty: easy–medium. -- [ ] **Single-instance + window-state persistence.** `tauri-plugin-single-instance` + `tauri-plugin-window-state` — no duplicate launches, restore window geometry (the per-window-geometry gap noted elsewhere in this file). Difficulty: easy. -- [ ] **Global summon hotkey.** `tauri-plugin-global-shortcut` — system-wide hotkey to raise tiletopia from any app. Difficulty: easy. -- [ ] **Settings panel.** A home for the already-deferred configurable idle threshold + MCP port + theme toggle, all currently hardcoded. Difficulty: easy–medium. -- [ ] **Small UX wins (codebase agent):** auto-save MCP policy rules (debounce like workspace save); `Ctrl+Shift+N` for new pane; 5s undo-toast on pane close (toast infra exists); narrow-window titlebar overflow menu; stronger broadcast-group visual tint; change-cwd-without-respawn (needs `/proc//cwd` probe). - -(Native OS notifications, configurable idle threshold, and persistent scrollback already appear in the top checklist — not duplicated here; the research reinforces their priority and the status-detection item above multiplies the notification payoff.) - ## Session log -### 2026-06-11 — NEW user-reported cursor bug (diagnosis pending user A/B test) - -**Symptom:** typing in a pane, the cursor "gets stuck" / shows a gap between typed text and the cursor block; after a few seconds of not typing the gap "vanishes" (display snaps correct). User Q&A: only noticed **inside claude** (not confirmed at plain bash); **a few seconds** to self-correct; unknown whether visual-only or a real eaten character. Distinct from the 2026-05-28 stuck/ghost cursor (that was the DOM renderer leaving a stale block; fixed via canvas addon). - -**Leading hypothesis: Claude Code TUI input-render buffering, not tiletopia.** Claude's Ink TUI does render+stdin on one event loop; under load it buffers keystroke echo and flushes in a batch — cursor lags/gaps then catches up. Documented upstream: claude-code #58498 (input invisible/cursor frozen, dumps at once), #63504 (Windows host CPU pressure starves input loop), #29366, #2847. Running many parallel claudes (tiletopia's whole purpose) = exactly the CPU-contention trigger. - -**Decisive test (user to run):** same distro, run `claude` in Windows Terminal, type fast mid-session — if it reproduces there, it's claude upstream, not tiletopia. Also check whether it correlates with number of busy panes. - -**If tiletopia-implicated:** note `@xterm/addon-canvas` is now **deprecated upstream** (no fixes, removed in xterm v6; webgl is the recommended path — would need context-pool management given the ~16 WebGL context cap with many panes; xterm 5.5's DOM renderer is faster than when we abandoned it but would regress the 05-28 ghost-cursor fix). Renderer swap is the lever ONLY if the A/B test pins it on tiletopia. - -### 2026-06-01 — Customizable terminal colors (global theme + per-pane override), v0.4.1 - -**Feature:** user-editable terminal colors. Scope = **global default + per-pane override** (both, per the user's choice). Editable colors = **background / foreground / cursor / selection** only (NOT the full 16-color ANSI ramp — explicitly out of scope). UI = **modal + presets**. - -**New `src/lib/theme.ts`** is the model: `PaneColors` type (4 optional hex fields); `DEFAULT_PANE_COLORS` (the historical palette: bg `#0c0c0c`, fg `#c5c8c6`, cursor `#ffffff`, selection `#3a3a3a`); `COLOR_PRESETS` (Tiletopia Dark, Solarized Dark, Gruvbox Dark, Dracula, Nord, Light); `resolvePaneColors(global, override)` (override > global > default, field-by-field, always returns all 4); `toXtermTheme()` → xterm `ITheme` (maps `selection`→`selectionBackground` per xterm 5.5 rename, pins `cursorAccent`=background, and keeps the fixed softened `white #c5c8c6`/`brightWhite #e0e0e0` slice in `BASE_XTERM_THEME`); `loadGlobalColors`/`saveGlobalColors` (localStorage, hex-validated). - -**Persistence split — NO Rust changes needed.** Global default → **localStorage** (`tiletopia.globalColors.v1`), shared per-origin across windows, live cross-window sync via the `storage` event. Per-pane → new optional **`LeafNode.colorOverride`** riding in the workspace tree; the Rust backend stores the tree as opaque `serde_json::Value` (`window_state.rs`), so any new optional leaf field round-trips for free — confirmed before coding (same reason `fontSizeOffset`/`broadcast`/`mcpAllow` persist). `colorOverride` preserved across `setLeafShell` + `reshapeToPreset`; new metadata-only `setLeafColors` mutator (clears override when passed undefined/all-undefined). - -**Live apply:** `XtermPane` gained a `colors?: Required` prop; mount theme = `toXtermTheme(initialColorsRef ?? DEFAULT_PANE_COLORS)`; a new effect (keyed on the 4 fields, not object identity) sets `term.options.theme` + `term.refresh()` on change — mirrors the existing fontSize effect. No fit/resize (color doesn't change cell geometry). **This subsumed a pre-existing uncommitted softened-foreground tweak** (the old literal `theme:{background,foreground}` block) into theme.ts. - -**Wiring:** orchestration gained `globalColors`, `setLeafColors`, `openColorPanel(leafId?)`. New `ColorPanel.tsx`/`.css` modal (mirrors McpPanel style): **Global default / This pane** tab toggle, 4 color-picker+hex rows (per-row "↺ revert to global" in pane mode), live preview swatch, preset buttons, reset action. Titlebar **🎨** button → global mode; per-pane toolbar **🎨** chip (lights up when overridden) → that pane. - -**Tests:** added `setLeafColors` describe + extended `setLeafShell` preservation test in `tree.test.ts`; new `theme.test.ts` (resolve precedence, toXtermTheme mapping, preset shape). `vitest` **cannot run in WSL** — `node_modules` holds the Windows rollup native binary, not `@rollup/rollup-linux-x64-gnu`; do NOT install it from WSL (corrupts the Windows build tree). `tsc -b` passes (covers src + tests via tsconfig.app's `include:["src"]`). Run `pnpm test` on the Windows host. - -**Commits:** `7e624a3` (feature), `ca97fb3` (bump 0.4.0→**0.4.1** in package.json + tauri.conf.json + Cargo.toml + Cargo.lock), `8c6aded` (this memory entry). Pushed to origin/main. Then released `v0.4.1` via `scripts/release.sh v0.4.1`. - -**⚠️ UNRESOLVED — wrong installer attached to the v0.4.1 release.** The git tag `v0.4.1` and the Forgejo release entry (title v0.4.1) are correct, but the attached `.exe` is **`tiletopia_0.4.0_x64-setup.exe`**, not 0.4.1. Cause: `release.sh` picks the newest `*-setup.exe` by **mtime** (`ls -1t | head -n1`); a stale 0.4.0 build (23:44) was newest when release.sh ran (23:51); the correct 0.4.1 build landed at 23:56, after publish. `tiletopia.mcpb` asset is fine. **Fix (needs running — was auto-denied as an outward-facing release-asset edit; user to authorize/run):** -``` -tea releases assets create --login rdx4 v0.4.1 src-tauri/target/release/bundle/nsis/tiletopia_0.4.1_x64-setup.exe -tea releases assets delete --login rdx4 --confirm v0.4.1 tiletopia_0.4.0_x64-setup.exe -``` -**TODO — harden `scripts/release.sh`** so this can't recur: select `tiletopia_${pkg_version}_x64-setup.exe` explicitly (fail if missing) instead of newest-by-mtime; optionally bail if no installer is newer than the bump commit. - -### 2026-05-30 — FIX: closing any window killed all windows (Tokio-runtime panic) - -**Symptom:** after dragging a pane out (or spawning) a daughter window, closing *either* the main or a daughter window closed them all, dumping `exit code 101`. - -**Root cause (confirmed via a 3-agent Workflow + reading the installed `tauri-runtime-wry-2.11.2` / `tauri-2.11.2` source):** NOT the exit logic and NOT WebView2. It was a **panic on the main thread**. The synchronous `on_window_event` `CloseRequested` handler in `lib.rs` calls `WindowsState::forget()` → `schedule_save()` → `tokio::spawn` (`window_state.rs:95`). That callback runs on the wry event-loop main thread with **no ambient Tokio runtime**, so `tokio::spawn` panics (`there is no reactor running…`); an unhandled main-thread panic aborts the whole process, taking every window + PTY down. `push_window_workspaces` hit the same `schedule_save` line but never crashed because it's an `async #[tauri::command]` that already runs inside Tauri's managed Tokio runtime — the bug only fired on the window-close path. - -**Fix (`src-tauri/src/window_state.rs`):** swap `tokio::spawn` → **`tauri::async_runtime::spawn`**, which schedules onto Tauri's global lazily-init'd Tokio runtime and works from *any* thread (incl. sync callbacks). Verified against `tauri-2.11.2/src/async_runtime.rs`: same `JoinHandle` shape, has `.abort()` (needed for the debounce cancel), and `tokio::time::sleep` still works inside the spawned future. Imports: `JoinHandle`+`spawn` now from `tauri::async_runtime`, `Duration` from `std::time`, `sleep` from `tokio::time`. **Rule learned: never call `tokio::spawn`/`tokio::*` runtime APIs from `on_window_event`, the `RunEvent` `.run()` closure, `Drop` impls, or any sync helper reachable from them — use `tauri::async_runtime::spawn`. Audit found this was the ONLY unsafe instance (`mcp.rs:800` and `mcp.rs:1502` are in async contexts → safe).** - -**Also `src-tauri/src/lib.rs` (defensive, not the primary fix):** switched `.run(generate_context!())` → `.build(…).run(|app, event| …)` and on `RunEvent::ExitRequested` call `api.prevent_exit()` iff `code.is_none() && !webview_windows().is_empty()` — belt-and-suspenders so no future path can tear down the process (and orphan live PTYs) while any window remains; explicit `AppHandle::exit(Some)` is always honored. Verified-from-source semantics: wry emits `ExitRequested{code:None}` **only** when the last window is destroyed (window store empty), and `manager.on_window_close` removes the window from `webview_windows()` *before* `ExitRequested` fires, so the count is accurate and there's no zombie risk. Window close/destroy logging demoted `warn!`→`debug!` (run `RUST_LOG=tiletopia=debug` to trace). - -**Status: VERIFIED on Windows 2026-05-30** (`pnpm tauri dev`) — closing a daughter (and the main) no longer kills the other windows; no exit-101. Fix committed in `9144ba6`. **Known minor follow-up:** a deliberately-closed window's *own* panes leak their PTYs (webview JS doesn't run XtermPane unmount cleanup on OS close), so those WSL shells linger orphaned — lower priority than persistence, not fixed. - -### 2026-05-28/29 — bug fix + feature batch from the backlog (post-0.4.0) - -Started from a user-reported **stuck/ghost cursor** in panes; fixed by switching xterm from the DOM renderer to `@xterm/addon-canvas` (DOM renderer leaves a stale cursor block under the Claude TUI's rapid hide/show + blink). User verified fixed on Windows. - -Then a 4-agent fan-out research pass into features (logged in the "Feature backlog — 2026-05-28 fan-out research" section above), from which the user picked 5 to explore. Shipped + **verified on Windows**: **find-in-scrollback** (Ctrl+Shift+F + SearchBar overlay), **Unicode 11**, **keyboard pane navigation** (Ctrl+Alt+arrows/HJKL + Alt+1..9) — built via a Workflow (parallel design on haiku/sonnet → one sonnet implementer). Plus a **narrow-pane toolbar reflow** (close × stays visible to the 180px min via `leaf--narrow`/`xnarrow` width tiers) — verified. - -**Per-session token tracking → context bar → SHELVED.** Built a WSL transcript reader + usage panel, then pivoted (user feedback) to a per-pane context-fill indicator, then **shelved it entirely** — couldn't reliably tell "claude is live in this pane" from "a shell in a dir that recently had a claude session" (no alt-screen, no WSL foreground-process access, no usable mtime cutoff; 200k/1M window not in transcripts). Full postmortem under the per-session-cost backlog item above. The narrow-toolbar fix was kept; everything else from that thread was reverted. - -Backlog added: **"reattach window to existing window."** Misc cleanups: removed an accidental `dev` npm package + stale `inotify` lockfile/workspace cruft from the Windows side. **Still open in the "explore first" set: smart link providers.** All commits pushed to `main` (tip `cd55006`); deps (canvas/search/unicode11) installed + lockfile committed. No version bump / release cut this session. - -### 2026-05-28 — **v0.4.0 shipped** (tabs + multi-window made actually working) - -Resume session that took the 2026-05-28 tabs/multi-window feature from "authored, unverified, buggy" to a shipped release. User built the NSIS `.exe` on Windows and ran `scripts/release.sh v0.4.0` (which also attaches `tiletopia.mcpb` now — the script was updated since the earlier session log note claimed it didn't). Version bumped 0.3.0 → 0.4.0 across package.json + Cargo.toml + tauri.conf.json + Cargo.lock atomically (commit `2a1f1d4`). README highlights list got tabs + multi-window bullets (`5ef35e3`); body sections + shortcut tables were already current, hard-deny count already 14, `gen:readme --check` clean. - -Commits this session: `bea6cf2` (capability + StrictMode adopt fix), `e6d0040` (accumulation + tab-close + scrollbars + drag ghost), `309b602` (XtermPane listener leak), `2a1f1d4` (version), `5ef35e3` (README). **Full technical detail for all fixes is in the "RESOLVED 2026-05-28 (resume session)" block under the original feature's session log below** — capability glob, destructive-read×StrictMode session loss, drag ghost (B1), drag-out registration wait, workspace-accumulation aggregator fix + corrupted-file reset, tab-close popover portal, global scrollbars, and the pre-release 3-agent audit (1 medium fixed, 1 high deferred). - -**Known deferred follow-up (carried):** the HIGH-severity transfer-refcount/PTY leak if a detached window closes mid-adopt — low-probability, ship-now decision. Proper fix sketched in the audit notes below (label→paneId adopting registry + close-handler force-kill). - -### 2026-05-28 — Tabs + multi-window pane transfer (3 phases, pushed) - -Two big features the user asked for in one session. Three commits on `main`: `1a035ad` (Phase 1 tabs), `8ad5178` (Phase 2 transfer), `6faf7e5` (Phase 3 drag-out). **Rust side authored in WSL — cargo build still needs verification on Windows host before this is runnable.** - -**Phase 1 — tabbed workspaces.** Tab strip above the existing pane area; each tab owns an independent tile tree. - -- **Persistence shape:** workspace.json migrated from bare `TreeNode` to `{ version: 2, workspaces: [{ id, name, tree }] }`. Legacy v1 is auto-detected in `deserializeWorkspaces` and wrapped as `[{ name: "Default", tree: }]`. Per-leaf `migrateLegacyLeaves` (PowerShell sentinel etc.) still applies per-tree. -- **PTYs survive tab switches via render-all-panes.** Every workspace's panes mount at once; inactive workspace layers use `visibility: hidden; pointer-events: none; z-index: 0` while keeping `position: absolute; inset: 0`. `visibility: hidden` (vs `display: none`) preserves the container's bounding rect so xterm.js's fit() reads valid dims; the existing per-pane resize dedupe in XtermPane (`lastSentCols/Rows` check) absorbs no-op SIGWINCHes. -- **`tree` / `setTree` kept as identity-stable derived wrappers** that read `currentWorkspaceIdRef.current`. Means the bulk of App.tsx didn't change despite the state model shift. Same trick for `activeLeafId` / `setActiveLeafId` — backed by `activeLeafByWorkspace: Map` so each tab remembers its own focus. -- **Hidden-tab focus guard (plan-agent catch).** XtermPane's mount-time `term.focus()` would yank focus into hidden tabs on app boot. Guarded with `getComputedStyle(container).visibility !== "hidden"`. CSS visibility is inherited, so the computed value on the container reflects the workspace-layer's setting. Focus poller in App.tsx:223 also scoped to the active workspace layer via `data-workspace-id` ancestor check. -- **Shortcuts:** Ctrl+T new tab, Ctrl+Shift+T close (window.confirm when there are live panes), Ctrl+PageDown/PageUp navigate, Ctrl+1..9 switch. shortcuts.ts is SoT; README + Help auto-regenerate via `pnpm gen:readme`. -- **Tab close confirm is inline popover** anchored to the X button (per plan-agent: not modal-queue style — close is user-initiated, not a stream of unsolicited prompts like MCP). - -**Phase 2 — multi-window pane transfer.** Right-click pane toolbar → "Move to new window" pops the pane into a fresh tiletopia window with its PTY intact. New window is a full peer with its own tab strip. - -- **The load-bearing facts** (verified by reading pty.rs / lib.rs / ipc.ts): - 1. `PaneId = u64`, never reused, sequence-assigned. Stable across windows. - 2. `pane://{id}/data` events go through `AppHandle::emit` — Tauri 2 event system is **process-wide**, so any window that `listen()`s on the same id gets the same stream. - 3. `PtyManager` lives in `Arc<>` managed state; one process, one manager, every window shares it. -- **Transfer-suppression: Rust-side refcount, NOT a JS module Set.** `PtyManager.transferring: Mutex>`. `kill_pane` becomes a no-op while refcount > 0. Source window's unmount calls `kill_pane` → silently dropped; target window's `claim_pane` decrements after subscribing. The JS-side "in-flight set" the plan-agent vetoed would have raced cross-window React event loops. -- **Scrollback replay shipped in v1** (plan-agent's other ship-in-v1 call). `get_pane_ring(id) -> base64` returns the existing PaneRing snapshot (256 KiB ≈ 3000 lines @ 80 cols). New window's XtermPane writes the ring to xterm.js BEFORE attaching the live `onPaneData` listener. Without this, a transferred Claude session looks blank until the next prompt repaint. -- **Cross-window save coordination via backend aggregator** (plan-agent's third correction). Each window debouncing its own write to workspace.json would race. New `window_state.rs`: `WindowsState { per_window: Mutex>>, save_task: Mutex> }`. Frontends call `push_window_workspaces(label, json)`; backend stores per-window, debounces save with a 500ms tokio sleep, atomic-writes the merged `{ version: 2, workspaces: [] }`. **Workspaces stored as `serde_json::Value`** — backend stays agnostic of tree shape across future LeafNode changes. -- **Non-main window close drops its entry** via `Tauri::WindowEvent::CloseRequested` in lib.rs `on_window_event`. Matches Chrome-style "closing a detached window discards its tabs". Main window's entry persists across the app lifetime so on next launch all of main's tabs reopen. -- **MCP scoped to main window only.** Both the mirror push and `onMcpRequest` subscription gated on `IS_MAIN_WINDOW = getCurrentWebviewWindow().label === "main"`. `paneIdByLeafRef` is per-window, so a request targeting a leaf in another window would fail to resolve anyway. Documented as "MCP sees main's current tab" — future extension could expose `list_windows()` / `switch_window()` MCP tools. - -**Phase 3 — drag-out gesture.** Extended the existing pointer-drag for header swap: release more than 60px past any viewport edge → drag-out via the same `moveToNewWindow` path. The 60px margin avoids triggering on accidental release over the OS titlebar (~30px). No backend changes — just a second entry point into Phase 2's mechanism. - -**Architecture artefacts worth remembering:** - -- **`getCurrentWebviewWindow().label`** is sync-available at module-load time (not async!) — captured into module-level `CURRENT_WINDOW_LABEL` and `IS_MAIN_WINDOW` constants. Cleaner than `useEffect`-awaiting it. -- **`transferredPaneIdsRef: Map`** is a one-shot side channel populated BEFORE `setWorkspaces` during mount, consumed in `registerPaneId`. LeafPane reads it via `orch.getInitialPaneIdFor(leaf.id)` and passes `existingPaneId` to XtermPane to skip spawn. Cleaner than threading the id through LeafNode (which is persisted state). -- **`WindowEvent::CloseRequested` closure captures `Arc` and `Arc` by move.** `windows_state_for_event.forget(label)` is the cleanup path; `pending_inits_for_event.by_label.lock().remove(&label)` removes any unconsumed init payload (the consumed-then-window-died case). - -**Phase 2 verification needed** (user, on Windows host): -1. `cd D:\dev\tiletopia\src-tauri && cargo check` — the Rust changes have to compile. **Note: `Cargo.toml` lives in `src-tauri/`, NOT the project root** (Tauri layout). I got this wrong in the original verification steps; user had to point it out. Added a preflight-checks rule to global `~/claude/CLAUDE.md`. Watch in the check output for: tauri 2 `WebviewWindowBuilder::new` signature, `on_window_event` handler closure types, my `Arc` method receiver style on WindowsState. - -**Uncommitted local fix (as of 2026-05-28 wrap-up):** - -`src-tauri/src/lib.rs` has an added `use tauri::Manager;` import — needed because `Window::app_handle()` is a trait method (Manager trait) used in the new `on_window_event` handler. Same pattern as the `Emitter` trait stumble in v0.3.0. Cargo check went clean after this. **Not committed yet** — user wanted to smoke-test the feature first, then found the bug list below. Commit this fix at the same time as the bug-fix commit. - -**Detached-window bug list (deferred — user will resume):** - -Smoke test on Windows revealed bugs specific to detached (non-main) windows. Main window is unaffected. - -- **B1** — Drag-out has no ghost image during drag (cosmetic, user OK with deferring). -- **B2** — Detached window: transferred pane is blank, "idle" within 5s. No input, no output. -- **B3** — Detached window: shell-picker swap (Ubuntu → PowerShell → Ubuntu) doesn't spawn a working terminal. Fresh `spawn_pane` call from the detached window — toolbar updates but no PTY output. -- **B4** — Detached window: new tab (Ctrl+T or + button) creates the tab but no terminal. Same blank/idle symptom. -- **B5** — Right-click "Move to new window" produces the same broken detached window as drag-out. Confirms the bug is detached-window-scoped, not gesture-scoped. -- **B6** (control) — Main window: new tab, new pane, normal ops all work. - -**Strongest single hypothesis** for B2–B5: **Tauri 2's capability system gates `invoke` and `listen` per window-label.** Default capability config in `src-tauri/capabilities/default.json` (or similar) usually scopes to `"windows": ["main"]`. Newly-built `pane-window-*` labels match nothing → all IPC and events silently fail. One config fix (add wildcard window pattern, or programmatically attach a capability to each new window before `.build()`) would explain ALL of B2-B5 in one go. - -**Where to look first when resuming:** -1. `src-tauri/capabilities/*.json` — read the existing capability config to confirm scoping. -2. Try `"windows": ["main", "pane-window-*"]` (Tauri 2 supports glob patterns in capability window targets). -3. If that doesn't work: `AppHandle::add_capability(...)` on the new window before `.build()` in `commands.rs::create_pane_window`. -4. Verify by re-testing B4 first (simplest: fresh new tab in a detached window — needs only `invoke("spawn_pane")` and `listen("pane://...")` to work). - -**RESOLVED 2026-05-28 (resume session) — two root causes, both fixed:** - -- **B2–B5 (blank/dead detached windows) = the capability hypothesis, confirmed.** `src-tauri/capabilities/default.json` had `"windows": ["main"]`; detached labels are `pane-window-` (commands.rs:122) → matched nothing → every `invoke`/`listen` silently denied. Fix: `"windows": ["main", "pane-window-*"]`. Tauri 2 glob pattern works; one line cleared all four. (App-defined commands aren't individually permission-gated — they're available to any window the capability is *applied* to, i.e. listed in `windows`.) -- **Session-loss-on-adopt (surfaced after B2–B5 cleared) = destructive read × StrictMode.** Once IPC worked, drag-out still spawned a FRESH pty (new id, tab named "Default", status `alive` not `adopted`) instead of adopting. Cause: `take_pending_window_init` is a **destructive** backend read (`by_label.remove`); React StrictMode runs the mount effect twice in dev — pass 1 consumed the payload then bailed on the `cancelled` flag, pass 2 got `null` → fell back to `singletonEnvelope` (fresh "Default" + fresh spawn). The `cancelled`-flag pattern guards against *using* stale async results but cannot un-consume a destructive backend call. Fix: module-level memoized `consumePendingWindowInit()` in App.tsx so the take fires **exactly once per window** and both StrictMode passes share the payload. Dev-only symptom (prod StrictMode doesn't double-invoke effects) but fixed for robustness. **Lesson: any destructive/once-only backend read called from a mount effect must be memoized at module scope, not just guarded by `cancelled`.** -- **Verified:** user confirmed adopt works (scrollback intact, same pane id, live input). `tsc -b` clean. -- Committed (`bea6cf2`) together with the carried-over `use tauri::Manager;` lib.rs import. - -**Follow-on fixes same session (commit after `bea6cf2`):** - -- **B1 drag ghost (done).** Cursor-following chip via `createPortal` in LeafPane, `pointer-events:none` so it doesn't disturb the `elementFromPoint` drop-target hit-test. Turns orange "↗ New window" past the 60px edge margin. A webview **can't paint outside its own OS window**, so the chip is clamped to the viewport edge and flips to the cursor's inner side near right/bottom rather than vanishing — that's the best achievable; a ghost floating over the desktop is impossible. Hoisted `PANE_DRAG_OUT_MARGIN` + `isFarOutsideViewport()` to module scope so move-handler (preview) and up-handler (release) can't drift. -- **Drag-out "PTY not ready" (mitigated).** `moveToNewWindow` now `await waitForPaneRegistration(leafId, 5000)` instead of failing instantly when the id isn't registered yet — covers the race where a just-spawned/just-adopted pane is dragged before its async spawn round-trip registers. Resolves instantly if already registered. -- **Tab accumulation (root-caused + fixed).** The cross-window save aggregator (`window_state.rs::build_envelope`) concatenated EVERY window's workspaces into the saved file; on launch main loaded the whole blob and adopted it as its own tabs, then re-saved under "main" → unbounded growth (hit 14 tabs incl. `Pane 28`/`Pane 38` drag-out artifacts + piles of `Default` from pre-fix detached boots). Fix: `build_envelope` persists **only `MAIN_WINDOW_LABEL`'s** workspaces — detached windows are ephemeral by design (discarded on close), so they're now structurally unable to pollute the file. **Reset the corrupted `workspace.json`** (backed up to `workspace.json.corrupt-backup` in app config dir, then deleted; main reboots a clean single Default). Detached windows still `push_window_workspaces` (harmless; backend just ignores non-main for persistence). -- **Can't close tabs (fixed).** Tab strip is `overflow-x:auto`, which per spec coerces `overflow-y` to auto too → the in-strip absolutely-positioned close-confirm popover got clipped once enough tabs forced horizontal scroll. Fix: `createPortal` the confirm to ``, `position:fixed`, fixed `width:300px` (matches `CONFIRM_POPOVER_WIDTH` const in TabStrip.tsx), right-aligned to the × button then **clamped into the viewport** so a left-side tab doesn't run off the left edge. -- **Native scrollbars (fixed).** `::-webkit-scrollbar` theming was scoped to `.xterm-viewport` only; made it global (`*::-webkit-scrollbar` + `* { scrollbar-width/color }`) so the tab strip / panels / menus match the dark theme. -- **Capability fix recap:** `default.json` `"windows": ["main", "pane-window-*"]` — the load-bearing fix for the whole detached-window feature (B2–B5). Confirmed: app-defined Tauri commands aren't individually permission-gated; they're available to any window the capability is *applied* to (listed in `windows`). - -**Pre-release audit (2026-05-28, 3-agent fan-out) — findings + dispositions:** - -- **(FIXED, medium) XtermPane IPC listener leak on unmount-during-await.** After `unlistenData = await onPaneData(...)` / `unlistenExit = await onPaneExit(...)` there was no `destroyed` re-check, so if the pane unmounted during the await (StrictMode, fast moveToNewWindow/closeTab) the sync cleanup had already captured a null unlisten and the `pane://{id}/data`/exit subscription leaked. Added `if (destroyed) { unlistenData?.(); unlistenExit?.(); return; }` after each assignment in both adopt and spawn paths. -- **(DEFERRED, high — known low-risk) Transferred-PTY/refcount leak if a detached window closes mid-adopt.** `mark_pane_transferring` bumps a refcount that suppresses `kill`; only `claim_pane` (from the target XtermPane mount) drops it. The `CloseRequested` handler (lib.rs:74) forgets workspaces + pending-init but never releases the refcount or kills the pane → if the window closes before adopt's `claim_pane`, that PTY + reader thread leak for the app lifetime. **In practice very low-probability**: adopt of a transferred pane is near-instant (paneId known synchronously, no spawn wait), so `claim` runs within ms of mount — by the time a user sees and closes the window, it's already claimed. User chose ship-now. **Proper fix when revisited:** keep a `label→paneId` "adopting" registry (set when `take_pending_window_init` consumes the payload, cleared by `claim_pane`), and have the close handler force-kill (drop refcount + kill) any still-unclaimed paneId for the closing label. The unconsumed-pending-init subset can be handled more cheaply (close handler already has the PendingInit.pane_id when the entry is still present). -- **(NOT FIXING, low) waitForPaneRegistration doesn't settle on early unmount** — `registerPaneId(leafId, null)` doesn't reject a pending waiter, so moveToNewWindow/MCP-spawn stalls until the timeout instead of failing fast. Functionally safe (timeout fires). -- tabs/LeafPane/TabStrip reviewer: no findings. - -2. `pnpm tauri dev` — smoke test: - - Existing workspace loads as one tab named "Default" ✓ migrate - - Ctrl+T spawns new tab with default-shell pane - - Switch tabs while a `sleep 60` is running in another tab — countdown continues - - Right-click any pane → "Move to new window" → new window appears with the pane, PTY content visible (ring replay) - - Resize new window → `tput cols` in the moved pane shows new dims - - Close new window → reopen the app → those tabs should NOT come back (the close-discards-tabs Chrome behavior) - - With MCP running, `list_panes` from Claude only sees main's current tab - -**Known follow-ups specific to this session** (none ship-blocking; all v0.4.0+ territory): - -- **Per-tab MCP visibility.** Today Claude only sees main's current tab; switching tabs in main changes Claude's view mid-conversation. Could expose `list_workspaces()` + `switch_workspace(id)` MCP tools. Defer until requested. -- **Window position persistence across restart.** User chose "tabs persist, not windows" in the design Q&A so this is by design, but if a power user ever wants restored window geometry, the `WindowsState` map already has the structure to track it; just add inner_size/outer_position to the per-window entry. -- **Drag-out across monitors with mismatched DPI.** Tauri 2's `outerPosition()` is physical px while `clientX/Y` is CSS px. My implementation only uses clientX/Y (no async query at drag start), so multi-monitor drag works as long as the user releases far enough from the source window's edge. New window appears at the OS default position; user manually drags it to the target monitor. Acceptable v1. -- **Drag a pane INTO an existing other window.** Only NEW-window drag in v1. Adding "drag to existing window" needs cross-window pointer-event coordination (Tauri 2 doesn't expose this). Defer. -- **Reattach window to an existing window** (user request 2026-05-28). The inverse of drag-out: take a detached window's pane(s) and merge them back into another open window as new tab(s) or splits, then close the now-empty source window. Same hard problem as the pane-into-window item above — Tauri 2 doesn't expose cross-window pointer drag, so this likely needs a non-drag affordance instead: e.g. a "Send to window ▸ " entry in the pane toolbar right-click menu (reuses the existing PTY-transfer path — `mark_pane_transferring` → target adopts via `existingPaneId`/`claim_pane` — just targeting an existing window's label instead of `create_pane_window`). Needs a live window/label registry the menu can list. Defer. -- **CLAUDE.md still says Svelte 5** (called out in 5+ session logs now). Bump it next time someone touches the file. - -### 2026-05-26 — **v0.3.0 shipped to Forgejo releases** - -Cut after a marathon session that took MCP from read-only v1 → full write surface + policy engine + audit + safeguards + .mcpb bundle. Tag `v0.3.0`, both `tiletopia_0.3.0_x64-setup.exe` and `tiletopia.mcpb` attached. - -**Release-time hiccups** (all fixed in subsequent commits — read these before the next release): - -- `pnpm tauri build` failed type-check on a `a.spec!.hostId` non-null assertion that drops the `kind === "ssh"` narrowing inside a `hosts.find` closure. `pnpm check` ran `tsc --noEmit` which had been silently missing the bug; `tsc -b` (what `pnpm build` uses) caught it. Fixed the line + switched the check script to `tsc -b` (both project-reference tsconfigs already have `noEmit: true`, so no emission). Commits `e1ceaab`, `7e285b2`. -- After the Windows build I ran `rm -rf src-tauri/target` from WSL to clear tsc cache — wiped the cargo target dir *including the freshly-built installer*. /mnt/d/ is the real Windows filesystem. Lesson: `src-tauri/target/` is cargo's output dir, NOT just tsc cache; do not touch without rebuild plan. The user rebuilt; cost a single `pnpm tauri build` cycle. -- `pnpm run build:mcpb` from `release.sh` hung indefinitely when run from WSL — pnpm auto-runs `pnpm install` first, which walks `node_modules` across the /mnt/d/ filesystem boundary and stalls for minutes. The bundle script is pure Node + fs, no deps to install. Switched release.sh to call `node scripts/build-mcpb.mjs` directly. Commit `1db8b26`. -- `Cargo.lock` needed committing separately after the version bump (cargo updated it during `pnpm tauri build`). Worth doing the version bump + `cargo check` together next time so the lock-file change is atomic with the version commit. - -**For the next release:** -1. Bump version in `package.json` + `src-tauri/Cargo.toml` + `src-tauri/tauri.conf.json` -2. Run `cargo check` (or any cargo command) to update `Cargo.lock` -3. Commit all four files + push -4. `pnpm tauri build` on Windows -5. `./scripts/release.sh vX.Y.Z` from WSL -6. Edit the auto-generated release note on Forgejo with a proper changelog - ### 2026-05-26 — Clear cargo warnings: drop v2.1 classifier scaffold, annotate rmcp tool_router Four pre-existing dead-code warnings out of every cargo build. Three were the v2.1 classifier scaffold sitting unused in `mcp_policy.rs` (`ClassifierHint` enum, `PolicyClassifier` trait, `NoopClassifier` struct + impl). Deleted — the scaffold being unused for weeks was a stronger "no plan" signal than its presence was a "TODO" signal. If we actually want classifier upgrade-on-Ask later (v0.4.0 candidate), trivial to re-add; the design questions (Anthropic vs Ollama, API key UX, monthly cost cap, privacy disclosure) need a focused session. diff --git a/package.json b/package.json index e77cff9..8ce45a3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "tiletopia", "private": true, - "version": "0.4.1", + "version": "0.3.0", "type": "module", "scripts": { "dev": "vite", @@ -18,10 +18,7 @@ "@tauri-apps/api": "^2.0.0", "@tauri-apps/plugin-clipboard-manager": "^2.0.0", "@tauri-apps/plugin-opener": "^2.0.0", - "@xterm/addon-canvas": "^0.7.0", "@xterm/addon-fit": "^0.10.0", - "@xterm/addon-search": "^0.15.0", - "@xterm/addon-unicode11": "^0.8.0", "@xterm/addon-web-links": "^0.12.0", "@xterm/xterm": "^5.5.0", "react": "^18.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 21bfa82..3eb8b88 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,18 +17,9 @@ importers: '@tauri-apps/plugin-opener': specifier: ^2.0.0 version: 2.5.4 - '@xterm/addon-canvas': - specifier: ^0.7.0 - version: 0.7.0(@xterm/xterm@5.5.0) '@xterm/addon-fit': specifier: ^0.10.0 version: 0.10.0(@xterm/xterm@5.5.0) - '@xterm/addon-search': - specifier: ^0.15.0 - version: 0.15.0(@xterm/xterm@5.5.0) - '@xterm/addon-unicode11': - specifier: ^0.8.0 - version: 0.8.0(@xterm/xterm@5.5.0) '@xterm/addon-web-links': specifier: ^0.12.0 version: 0.12.0 @@ -593,26 +584,11 @@ packages: '@vitest/utils@2.1.9': resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} - '@xterm/addon-canvas@0.7.0': - resolution: {integrity: sha512-LF5LYcfvefJuJ7QotNRdRSPc9YASAVDeoT5uyXS/nZshZXjYplGXRECBGiznwvhNL2I8bq1Lf5MzRwstsYQ2Iw==} - peerDependencies: - '@xterm/xterm': ^5.0.0 - '@xterm/addon-fit@0.10.0': resolution: {integrity: sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==} peerDependencies: '@xterm/xterm': ^5.0.0 - '@xterm/addon-search@0.15.0': - resolution: {integrity: sha512-ZBZKLQ+EuKE83CqCmSSz5y1tx+aNOCUaA7dm6emgOX+8J9H1FWXZyrKfzjwzV+V14TV3xToz1goIeRhXBS5qjg==} - peerDependencies: - '@xterm/xterm': ^5.0.0 - - '@xterm/addon-unicode11@0.8.0': - resolution: {integrity: sha512-LxinXu8SC4OmVa6FhgwsVCBZbr8WoSGzBl2+vqe8WcQ6hb1r6Gj9P99qTNdPiFPh4Ceiu2pC8xukZ6+2nnh49Q==} - peerDependencies: - '@xterm/xterm': ^5.0.0 - '@xterm/addon-web-links@0.12.0': resolution: {integrity: sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==} @@ -1310,22 +1286,10 @@ snapshots: loupe: 3.2.1 tinyrainbow: 1.2.0 - '@xterm/addon-canvas@0.7.0(@xterm/xterm@5.5.0)': - dependencies: - '@xterm/xterm': 5.5.0 - '@xterm/addon-fit@0.10.0(@xterm/xterm@5.5.0)': dependencies: '@xterm/xterm': 5.5.0 - '@xterm/addon-search@0.15.0(@xterm/xterm@5.5.0)': - dependencies: - '@xterm/xterm': 5.5.0 - - '@xterm/addon-unicode11@0.8.0(@xterm/xterm@5.5.0)': - dependencies: - '@xterm/xterm': 5.5.0 - '@xterm/addon-web-links@0.12.0': {} '@xterm/xterm@5.5.0': {} diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index d7a99cd..c9b4ec0 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4221,7 +4221,7 @@ dependencies = [ [[package]] name = "tiletopia" -version = "0.4.1" +version = "0.3.0" dependencies = [ "anyhow", "axum", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f8c7e52..9f36451 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tiletopia" -version = "0.4.1" +version = "0.3.0" description = "Tiling multi-terminal manager for WSL" authors = ["megaproxy"] edition = "2021" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 144512e..0b5585b 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -2,7 +2,7 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "Default capability set for wsl-mux spike", - "windows": ["main", "pane-window-*"], + "windows": ["main"], "permissions": [ "core:default", "core:event:default", diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 732ad35..dd21f6a 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use base64::{engine::general_purpose::STANDARD as B64, Engine as _}; -use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder}; +use tauri::{AppHandle, Manager}; use tokio::sync::RwLock; use crate::creds; @@ -11,7 +11,6 @@ use crate::hosts::{self, SshHost, SshHostView}; use crate::mcp::{self, McpMirror, McpServerHandle, McpState, PendingActions, RunningServer}; use crate::mcp_policy::McpPolicy; use crate::pty::{list_wsl_distros, PaneId, PtyManager, SpawnSpec}; -use crate::window_state::{PendingInit, PendingInits, WindowsState}; const WORKSPACE_FILE: &str = "workspace.json"; @@ -63,165 +62,6 @@ pub async fn kill_pane( manager.kill(id).map_err(|e| e.to_string()) } -/// Bump the per-pane "do not kill during transfer" refcount. Called by the -/// source window just before removing the leaf from its tree (which triggers -/// React to unmount XtermPane, which calls `kill_pane`). The kill is then a -/// no-op until {@link claim_pane} drops the refcount. -#[tauri::command] -pub async fn mark_pane_transferring( - manager: tauri::State<'_, Arc>, - id: PaneId, -) -> Result<(), String> { - manager.mark_transferring(id); - Ok(()) -} - -/// Drop the transfer refcount one. Called by the target window's XtermPane -/// mount once it has subscribed to the pane's events and replayed the -/// scrollback ring — at which point the PTY is safely "owned" by the -/// target. -#[tauri::command] -pub async fn claim_pane( - manager: tauri::State<'_, Arc>, - id: PaneId, -) -> Result<(), String> { - manager.claim(id); - Ok(()) -} - -/// Return the per-pane scrollback ring snapshot as base64. The target -/// window's XtermPane writes it into xterm.js BEFORE attaching the live -/// pane://{id}/data listener, so the user sees recent output (covers -/// "Claude is in the middle of a thought" — a transferred pane that's -/// idle shouldn't look blank). Bounded by PANE_RING_CAPACITY (~256 KiB). -#[tauri::command] -pub async fn get_pane_ring( - manager: tauri::State<'_, Arc>, - id: PaneId, -) -> Result { - let ring = manager - .ring(id) - .ok_or_else(|| format!("no pane with id {id}"))?; - let (bytes, _seq) = ring.lock().snapshot(); - Ok(B64.encode(&bytes)) -} - -/// Spawn a new app window and stash the pending-init payload keyed by the -/// new window's label. The target window pulls it via -/// {@link take_pending_window_init} during App mount. -/// -/// Returns the new window's label so the caller can correlate. -#[tauri::command] -pub async fn create_pane_window( - app: AppHandle, - pendings: tauri::State<'_, Arc>, - payload: PendingInit, -) -> Result { - // Generate a label that's deterministic-but-unique. Tauri requires - // labels to be ASCII-alphanumeric + dashes/underscores. - let label = format!( - "pane-window-{}", - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_micros()) - .unwrap_or(0) - ); - - // Stash BEFORE building the window — the target may finish bootstrapping - // and call take_pending_window_init before we return from build(). - pendings.by_label.lock().insert(label.clone(), payload); - - // Position the new window offset from the source's outer rect so it - // doesn't land exactly on top. If we can't query the source, fall back - // to the OS-default (center). - let (px, py, w, h) = source_window_geometry(&app); - - let mut builder = WebviewWindowBuilder::new( - &app, - label.clone(), - WebviewUrl::App("index.html".into()), - ) - .title("tiletopia") - .inner_size(w, h) - .min_inner_size(480.0, 320.0) - .resizable(true) - .decorations(true) - .visible(true); - if let (Some(x), Some(y)) = (px, py) { - builder = builder.position(x + 60.0, y + 60.0); - } else { - builder = builder.center(); - } - if let Err(e) = builder.build() { - // Clean up our pending entry so we don't leak it. - pendings.by_label.lock().remove(&label); - return Err(format!("create webview window: {e}")); - } - - Ok(label) -} - -/// Read and remove the pending-init for the current window. Returns None -/// when there is no pending payload (main window startup; window opened -/// without a transfer; second call after the first consumed it). -#[tauri::command] -pub async fn take_pending_window_init( - pendings: tauri::State<'_, Arc>, - label: String, -) -> Result, String> { - Ok(pendings.by_label.lock().remove(&label)) -} - -/// Push this window's workspaces snapshot to the backend aggregator. Called -/// every time the React state changes (debounced inside Rust); the next -/// debounce tick writes the aggregated envelope to disk. -/// -/// `workspaces_json` is the per-window list as JSON (an array of -/// `{ id, name, tree }` objects — matches the frontend's envelope.workspaces -/// shape). Stored as serde Values so this module doesn't need to know -/// anything about the tree shape. -#[tauri::command] -pub async fn push_window_workspaces( - app: AppHandle, - state: tauri::State<'_, Arc>, - label: String, - workspaces_json: String, -) -> Result<(), String> { - let parsed: serde_json::Value = serde_json::from_str(&workspaces_json) - .map_err(|e| format!("invalid workspaces JSON: {e}"))?; - let arr = parsed - .as_array() - .ok_or_else(|| "workspaces JSON must be an array".to_string())?; - let owned = arr.to_vec(); - let state_arc: Arc = (*state).clone(); - state_arc.push(app, label, owned); - Ok(()) -} - -/// Best-effort: read outer position + inner size of the main window so the -/// new window opens nearby instead of slamming the OS default. Returns -/// (Some(x), Some(y), w, h) when available; falls back to a reasonable -/// default size when the main window query fails. -fn source_window_geometry(app: &AppHandle) -> (Option, Option, f64, f64) { - // Try the focused window first, then fall back to the main one. - let win = app - .webview_windows() - .into_iter() - .find_map(|(_, w)| if w.is_focused().unwrap_or(false) { Some(w) } else { None }) - .or_else(|| app.get_webview_window("main")); - let Some(win) = win else { - return (None, None, 1100.0, 700.0); - }; - let pos = win.outer_position().ok(); - let size = win.inner_size().ok(); - let scale = win.scale_factor().unwrap_or(1.0); - let w = size.as_ref().map(|s| s.width as f64 / scale).unwrap_or(1100.0); - let h = size.as_ref().map(|s| s.height as f64 / scale).unwrap_or(700.0); - let px = pos.as_ref().map(|p| p.x as f64 / scale); - let py = pos.as_ref().map(|p| p.y as f64 / scale); - (px, py, w, h) -} - /// Write the workspace JSON to `%APPDATA%\com.megaproxy.tiletopia\workspace.json`. /// Writes to a `.tmp` and renames over the real file so a crash mid-write /// can't leave a partial file readable. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c8cf4f9..40ec343 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,18 +6,11 @@ mod hosts; mod mcp; mod mcp_policy; mod pty; -mod window_state; use std::sync::Arc; -// `Manager` trait must be in scope to call `.app_handle()` on the `&Window` -// passed to the `on_window_event` closure below. Same pattern as the -// `Emitter` trait needed for `.emit()` (see 2026-05-26 PR-1 session log). -use tauri::Manager; - use crate::mcp::{McpServerHandle, McpState, PendingActions}; use crate::pty::PtyManager; -use crate::window_state::{PendingInits, WindowsState, MAIN_WINDOW_LABEL}; pub fn run() { let _ = tracing_subscriber::fmt() @@ -47,15 +40,6 @@ pub fn run() { // Pending action registry — separate managed state so mcp_action_reply can // grab it without needing to lock McpState or reach into TileService. let pending_actions: Arc = Arc::new(PendingActions::default()); - // Cross-window workspace aggregator: every window pushes its tab list - // here; backend debounces + writes the merged envelope to workspace.json. - let windows_state: Arc = Arc::new(WindowsState::default()); - // Pane-transfer pending-init registry: source window stashes a payload - // keyed by the new window's label; target window pulls it during mount. - let pending_inits: Arc = Arc::new(PendingInits::default()); - - let windows_state_for_event = Arc::clone(&windows_state); - let pending_inits_for_event = Arc::clone(&pending_inits); tauri::Builder::default() .plugin(tauri_plugin_clipboard_manager::init()) @@ -64,56 +48,12 @@ pub fn run() { .manage(mcp_state) .manage(McpServerHandle::default()) .manage(pending_actions) - .manage(windows_state) - .manage(pending_inits) - .on_window_event(move |window, event| { - let label = window.label().to_string(); - - // Window-lifecycle tracing for the multi-window close behavior. - // Silent at the default `info` level; run with - // `RUST_LOG=tiletopia=debug` to confirm the event sequence when a - // window closes (which windows the runtime still tracks, whether a - // close triggers an app-exit). Verified against tauri-runtime-wry - // 2.11: closing a non-last window emits NO ExitRequested, so other - // windows survive; only the last window's Destroyed triggers exit. - match event { - tauri::WindowEvent::CloseRequested { .. } - | tauri::WindowEvent::Destroyed => { - let open: Vec = window - .app_handle() - .webview_windows() - .keys() - .cloned() - .collect(); - tracing::debug!("window {event:?} label={label} open_windows={open:?}"); - } - _ => {} - } - - // When a non-main window closes, drop its workspaces from the - // aggregator AND any unconsumed pending-init payload so neither - // resurrect on next launch. Matches Chrome-style "closing a - // detached window discards its tabs" intent. - if let tauri::WindowEvent::CloseRequested { .. } = event { - if label != MAIN_WINDOW_LABEL { - pending_inits_for_event.by_label.lock().remove(&label); - windows_state_for_event - .forget(window.app_handle().clone(), &label); - } - } - }) .invoke_handler(tauri::generate_handler![ commands::list_distros, commands::spawn_pane, commands::write_to_pane, commands::resize_pane, commands::kill_pane, - commands::mark_pane_transferring, - commands::claim_pane, - commands::get_pane_ring, - commands::create_pane_window, - commands::take_pending_window_init, - commands::push_window_workspaces, commands::save_workspace, commands::load_workspace, commands::list_ssh_hosts, @@ -131,28 +71,6 @@ pub fn run() { commands::mcp_policy_save, commands::mcp_hard_deny_labels, ]) - .build(tauri::generate_context!()) - .expect("error while building tauri application") - .run(|app_handle, event| { - // Keep the process alive as long as ANY window is open. Every - // window (main + drag-out "daughter" windows) shares one process, - // and every PTY is owned by the single PtyManager in it. Tauri/wry - // emits `ExitRequested { code: None }` only when the LAST window is - // destroyed (tauri-runtime-wry 2.11 emits it solely when the window - // store goes empty); an explicit `AppHandle::exit(n)` carries - // `code: Some(n)`. By the time this fires, the closed window has - // already been removed from `webview_windows()`, so the check is - // accurate. We only ever reach the empty-set case here, but guard - // defensively: if any window somehow remains, refuse the exit so a - // stray close can't tear the process down and orphan live PTYs. - // An explicit exit (Some) is always honored. - if let tauri::RunEvent::ExitRequested { code, api, .. } = event { - let open: Vec = - app_handle.webview_windows().keys().cloned().collect(); - tracing::debug!("RunEvent::ExitRequested code={code:?} open_windows={open:?}"); - if code.is_none() && !open.is_empty() { - api.prevent_exit(); - } - } - }); + .run(tauri::generate_context!()) + .expect("error while running tauri application"); } diff --git a/src-tauri/src/pty.rs b/src-tauri/src/pty.rs index c404fdf..2f90930 100644 --- a/src-tauri/src/pty.rs +++ b/src-tauri/src/pty.rs @@ -109,16 +109,6 @@ struct PaneHandle { pub struct PtyManager { panes: Mutex>, next_id: AtomicU64, - /// Per-pane "this PTY is mid-transfer between windows; do not kill it - /// even if some window's XtermPane unmounts" refcount. Incremented by - /// {@link mark_transferring} when a transfer begins; decremented by - /// {@link claim} when the target window finishes mounting. While >0, - /// {@link kill} is a no-op for that id. - /// - /// Refcount (vs. plain flag) so concurrent transfers — or the rare - /// case where a transfer is retried before the previous one fully - /// releases — don't drop the suppression early. - transferring: Mutex>, } impl PtyManager { @@ -126,27 +116,6 @@ impl PtyManager { Self { panes: Mutex::new(HashMap::new()), next_id: AtomicU64::new(1), - transferring: Mutex::new(HashMap::new()), - } - } - - /// Bump the transferring refcount for a pane. While >0, {@link kill} is - /// a no-op so the source window's React unmount-cleanup can't tear - /// down the PTY mid-transfer. - pub fn mark_transferring(&self, id: PaneId) { - *self.transferring.lock().entry(id).or_insert(0) += 1; - } - - /// Decrement the transferring refcount. When it reaches zero the entry - /// is removed and {@link kill} can act on this pane again. - pub fn claim(&self, id: PaneId) { - let mut map = self.transferring.lock(); - if let Some(rc) = map.get_mut(&id) { - if *rc > 1 { - *rc -= 1; - } else { - map.remove(&id); - } } } @@ -289,14 +258,6 @@ impl PtyManager { } pub fn kill(&self, id: PaneId) -> Result<()> { - // If a transfer is in flight for this pane, suppress the kill so - // the source window's unmount-cleanup can't race the target - // window's mount-claim. The target's claim() will decrement the - // refcount; the next caller of kill() (if any) will actually kill. - if self.transferring.lock().contains_key(&id) { - tracing::debug!("pty kill suppressed during transfer for pane {id}"); - return Ok(()); - } let mut panes = self.panes.lock(); if let Some(mut pane) = panes.remove(&id) { // Best-effort: ask the child to die. Dropping `master` after this diff --git a/src-tauri/src/window_state.rs b/src-tauri/src/window_state.rs deleted file mode 100644 index 36f6014..0000000 --- a/src-tauri/src/window_state.rs +++ /dev/null @@ -1,151 +0,0 @@ -//! Cross-window workspace state aggregator. -//! -//! Each window owns its own list of workspaces (tabs) in its React state. -//! When that list changes, the window calls `push_window_workspaces` to -//! ship a snapshot down here. This module merges every window's snapshot -//! into one envelope and persists it to `workspace.json` on a debounced -//! timer — same `{ version: 2, workspaces: [...] }` shape the frontend -//! reads at startup. -//! -//! The Rust side stays agnostic of the per-tree shape: workspaces are -//! stored as `serde_json::Value` so this module never needs to be updated -//! when LeafNode / SplitNode fields change. -//! -//! Lifetime of per-window entries: -//! - Created/updated on every `push_window_workspaces` call. -//! - The main window pushes initially after loading from disk; detached -//! windows push after takeing their pending-init payload. -//! - On detached-window close (handled in lib.rs), the entry is removed -//! so the next save doesn't resurrect tabs the user explicitly closed. -//! The main window's entry persists across the app lifetime. - -use std::collections::HashMap; -use std::sync::Arc; -use std::time::Duration; - -use anyhow::{Context, Result}; -use parking_lot::Mutex; -use serde_json::Value; -// `async_runtime::spawn` schedules onto Tauri's global Tokio runtime and works -// from ANY thread — including the synchronous `on_window_event` callback that -// reaches `schedule_save` via `forget()` on window close. Plain `tokio::spawn` -// panics there ("no reactor running") because that callback has no ambient -// runtime, and a main-thread panic aborts the whole process, taking every -// window + PTY with it. See the close-crash fix. -use tauri::async_runtime::{spawn, JoinHandle}; -use tauri::{AppHandle, Manager}; -use tokio::time::sleep; - -const WORKSPACE_FILE: &str = "workspace.json"; -const SAVE_DEBOUNCE: Duration = Duration::from_millis(500); - -/// The label of the main (boot) window. Matches `tauri.conf.json`'s -/// `windows[0].label`. Used to decide whether a window-close should -/// retain or discard that window's tabs. -pub const MAIN_WINDOW_LABEL: &str = "main"; - -#[derive(Default)] -pub struct WindowsState { - per_window: Mutex>>, - save_task: Mutex>>, -} - -impl WindowsState { - /// Replace this window's workspaces snapshot and schedule a debounced - /// save. Subsequent calls within the debounce window cancel the - /// previous save task — so a flurry of UI mutations only writes once. - pub fn push( - self: &Arc, - app: AppHandle, - label: String, - workspaces: Vec, - ) { - self.per_window.lock().insert(label, workspaces); - self.schedule_save(app); - } - - /// Drop a window's snapshot from the aggregate. Called on close of a - /// non-main window so its tabs don't reappear on next launch. - pub fn forget(self: &Arc, app: AppHandle, label: &str) { - let removed = self.per_window.lock().remove(label).is_some(); - if removed { - self.schedule_save(app); - } - } - - /// Build the on-disk envelope from ONLY the main window's workspaces. - /// - /// Detached windows are ephemeral — their tabs are discarded on close - /// (Chrome-style), and only the main window's tabs are meant to survive - /// a restart. Persisting every window's workspaces (the original design) - /// let detached windows' tabs — and the `Pane N` adopt-targets from - /// drag-out — leak into the saved file; on the next launch the main - /// window loaded the whole blob and adopted them all, so they - /// accumulated without bound. Keying the persisted set to the main label - /// makes detached state structurally unable to pollute it. - fn build_envelope(&self) -> Value { - let map = self.per_window.lock(); - let workspaces: Vec = - map.get(MAIN_WINDOW_LABEL).cloned().unwrap_or_default(); - serde_json::json!({ - "version": 2, - "workspaces": workspaces, - }) - } - - fn schedule_save(self: &Arc, app: AppHandle) { - let me = Arc::clone(self); - let mut slot = self.save_task.lock(); - if let Some(prev) = slot.take() { - prev.abort(); - } - let handle = spawn(async move { - sleep(SAVE_DEBOUNCE).await; - if let Err(e) = me.save_now(&app).await { - tracing::warn!("debounced workspace save failed: {e:#}"); - } - }); - *slot = Some(handle); - } - - async fn save_now(&self, app: &AppHandle) -> Result<()> { - let envelope = self.build_envelope(); - let json = serde_json::to_string(&envelope).context("serialize envelope")?; - let dir = app - .path() - .app_config_dir() - .map_err(|e| anyhow::anyhow!("app_config_dir: {e}"))?; - std::fs::create_dir_all(&dir).context("create_dir_all")?; - let path = dir.join(WORKSPACE_FILE); - let tmp = dir.join(format!("{WORKSPACE_FILE}.tmp")); - std::fs::write(&tmp, json.as_bytes()).context("write tmp")?; - std::fs::rename(&tmp, &path).context("rename tmp -> final")?; - Ok(()) - } -} - -// --------------------------------------------------------------------------- -// Pane-transfer pending-init registry -// --------------------------------------------------------------------------- - -/// Payload the source window stashes in the backend before opening a new -/// window; the target window pulls it during App mount via -/// `take_pending_window_init`. -/// -/// `leaf_json` and `workspace_name` are owned by the source — the backend -/// doesn't parse the leaf shape. `pane_id` is the existing PTY id the -/// target window's XtermPane should attach to (instead of spawning). -#[derive(Clone, serde::Serialize, serde::Deserialize)] -pub struct PendingInit { - #[serde(rename = "leafJson")] - pub leaf_json: String, - #[serde(rename = "paneId")] - pub pane_id: crate::pty::PaneId, - #[serde(rename = "workspaceName")] - pub workspace_name: String, -} - -#[derive(Default)] -pub struct PendingInits { - pub by_label: Mutex>, -} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index cb37dc1..056056e 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "tiletopia", - "version": "0.4.1", + "version": "0.3.0", "identifier": "com.megaproxy.tiletopia", "build": { "beforeDevCommand": "pnpm dev", diff --git a/src/App.tsx b/src/App.tsx index 766058d..276bd4b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,11 +18,6 @@ import { mcpPolicySave, writeToPane, killPane, - markPaneTransferring, - claimPane, - createPaneWindow, - takePendingWindowInit, - pushWindowWorkspaces, type PaneId, type SpawnSpec, type SshHost, @@ -34,37 +29,12 @@ import { type McpAuditEntry, } from "./ipc"; import { listen } from "@tauri-apps/api/event"; -import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; - -const MAIN_WINDOW_LABEL = "main"; -/** Current window label, captured once at module load — used to decide - * load path (load_workspace vs take_pending_window_init) and to push - * this window's state to the cross-window aggregator. */ -const CURRENT_WINDOW_LABEL = getCurrentWebviewWindow().label; -const IS_MAIN_WINDOW = CURRENT_WINDOW_LABEL === MAIN_WINDOW_LABEL; - -/** `take_pending_window_init` is a DESTRUCTIVE backend read (it removes the - * entry). React StrictMode runs the mount effect twice in dev, so a plain - * call would consume the payload on the first (cancelled) pass and hand the - * second pass `null` — booting a fresh "Default" workspace and spawning a new - * PTY instead of adopting the transferred one (session lost). Memoize the - * promise at module scope so the backend take happens exactly once per window - * and every effect pass awaits the same result. */ -let pendingInitOnce: Promise>> | null = - null; -const consumePendingWindowInit = () => { - if (!pendingInitOnce) { - pendingInitOnce = takePendingWindowInit(CURRENT_WINDOW_LABEL); - } - return pendingInitOnce; -}; import { type TreeNode, type NodeId, type Orientation, type LeafNode, type LeafShellSpec, - type Workspace, newLeaf, newId, splitLeaf, @@ -77,7 +47,6 @@ import { changeLabel, toggleBroadcast as toggleBroadcastInTree, toggleMcpAllow as toggleMcpAllowInTree, - setLeafColors as setLeafColorsInTree, setAllBroadcast, adjustFontSize, adjustAllFontSizes, @@ -90,16 +59,14 @@ import { MIN_PANE_PX, type Direction, serialize, - serializeWorkspaces, - deserializeWorkspaces, - singletonEnvelope, + deserialize, presetSingle, presetTwoColumns, presetThreeColumns, presetTwoRows, presetTwoByTwo, } from "./lib/layout/tree"; -import { OrchestrationProvider, type Orchestration, type NavigateIntent } from "./lib/layout/orchestration"; +import { OrchestrationProvider, type Orchestration } from "./lib/layout/orchestration"; import LeafPane from "./lib/layout/LeafPane"; import Gutter from "./lib/layout/Gutter"; import Notifications, { type Toast } from "./components/Notifications"; @@ -107,19 +74,12 @@ import Palette from "./components/Palette"; import HostManager from "./components/HostManager"; import Help from "./components/Help"; import McpPanel from "./components/McpPanel"; -import ColorPanel from "./components/ColorPanel"; -import { - type PaneColors, - GLOBAL_COLORS_STORAGE_KEY, - loadGlobalColors, - saveGlobalColors, -} from "./lib/theme"; import McpConfirm, { type McpConfirmSpec } from "./components/McpConfirm"; -import TabStrip from "./components/TabStrip"; import "./App.css"; import "./lib/layout/Gutter.css"; const LEGACY_STORAGE_KEY = "tiletopia.tree.v1"; +const SAVE_DEBOUNCE_MS = 500; /** Picker default for *new* panes. SSH never lives here — SSH connections * are always explicit, never a default. */ @@ -154,86 +114,8 @@ function describeSpec(spec: SpawnSpec): string { export default function App() { // ---- top-level state ----------------------------------------------------- - // Workspaces (tabs). Each is one independent tile tree. The mount effect - // seeds an initial singleton workspace if the persisted envelope is empty. - const [workspaces, setWorkspaces] = useState(() => { - const t = newLeaf(); - return [{ id: newId(), name: "Default", tree: t }]; - }); - const [currentWorkspaceId, setCurrentWorkspaceId] = useState( - () => "", // filled on mount alongside workspaces - ); - /** Per-workspace remembered active leaf — each tab keeps its own focus - * cursor so switching back doesn't dump the user into a random pane. */ - const [activeLeafByWorkspace, setActiveLeafByWorkspace] = useState< - Map - >(() => new Map()); - - // ---- workspace-aware tree wrappers -------------------------------------- - // These mirror the v0.3.0 single-tree API (`tree`, `setTree(updater)`, - // `activeLeafId`, `setActiveLeafId`) but operate on whichever workspace is - // current. Keeps the bulk of App.tsx unchanged across the tabs refactor. - // - // Refs first so the setter useCallbacks below capture stable references - // and don't need currentWorkspaceId in their dep arrays. - const currentWorkspaceIdRef = useRef(currentWorkspaceId); - const workspacesRef = useRef(workspaces); - - const currentWorkspace = useMemo( - () => workspaces.find((w) => w.id === currentWorkspaceId) ?? null, - [workspaces, currentWorkspaceId], - ); - const tree: TreeNode = currentWorkspace?.tree ?? workspaces[0]?.tree ?? newLeaf(); - - // Identity-stable across renders — both read the live currentWorkspaceId - // from a ref so every useCallback that captures these doesn't need to - // list them in its deps (mirroring the v0.3.0 stable-setter contract). - // The ref is updated in the workspacesRef / currentWorkspaceIdRef effects - // below; it's seeded synchronously by useState in the same render so - // first-render calls see the initial id. - const setTree = useCallback>>( - (updater) => { - setWorkspaces((ws) => { - if (ws.length === 0) return ws; - const targetId = currentWorkspaceIdRef.current || ws[0].id; - return ws.map((w) => { - if (w.id !== targetId) return w; - const next = - typeof updater === "function" - ? (updater as (prev: TreeNode) => TreeNode)(w.tree) - : updater; - if (next === w.tree) return w; - return { ...w, tree: next }; - }); - }); - }, - [], - ); - - const activeLeafId: NodeId | null = - (currentWorkspaceId && activeLeafByWorkspace.get(currentWorkspaceId)) ?? null; - - const setActiveLeafId = useCallback< - React.Dispatch> - >( - (updater) => { - setActiveLeafByWorkspace((prev) => { - const wsId = currentWorkspaceIdRef.current; - if (!wsId) return prev; - const cur = prev.get(wsId) ?? null; - const next = - typeof updater === "function" - ? (updater as (prev: NodeId | null) => NodeId | null)(cur) - : updater; - if (next === cur) return prev; - const m = new Map(prev); - m.set(wsId, next); - return m; - }); - }, - [], - ); - + const [tree, setTree] = useState(() => newLeaf()); + const [activeLeafId, setActiveLeafId] = useState(null); const [distros, setDistros] = useState([]); const [defaultShell, setDefaultShell] = useState({ shellKind: "wsl", @@ -247,15 +129,6 @@ export default function App() { token: null, }); const [mcpPanelOpen, setMcpPanelOpen] = useState(false); - // App-wide default terminal colours, loaded from localStorage. Per-pane - // overrides live on the LeafNode (colorOverride); this is the fallback. - const [globalColors, setGlobalColors] = useState(() => - loadGlobalColors(), - ); - const [colorPanelOpen, setColorPanelOpen] = useState(false); - const [colorPanelMode, setColorPanelMode] = useState<"global" | "pane">( - "global", - ); const [ready, setReady] = useState(false); const [notifications, setNotifications] = useState([]); const [paletteOpen, setPaletteOpen] = useState(false); @@ -265,83 +138,32 @@ export default function App() { // ---- non-reactive lookups ----------------------------------------------- const paneIdByLeafRef = useRef>(new Map()); const nextNotifIdRef = useRef(1); - /** Leaves that just arrived via a window transfer, mapped to the - * existing PaneId their XtermPane should adopt. One-shot: cleared in - * registerPaneId once the pane registers. */ - const transferredPaneIdsRef = useRef>(new Map()); const treeRef = useRef(tree); useEffect(() => { treeRef.current = tree; }, [tree]); - // workspacesRef + currentWorkspaceIdRef are declared up by the tree - // wrappers so setTree / setActiveLeafId can capture them; sync here. - useEffect(() => { - workspacesRef.current = workspaces; - }, [workspaces]); - useEffect(() => { - currentWorkspaceIdRef.current = currentWorkspaceId; - }, [currentWorkspaceId]); // ---- mount: load workspace + distros + hosts ---------------------------- useEffect(() => { let cancelled = false; (async () => { - // First: is this a detached window with a pending transfer payload? - // Non-main windows ALWAYS go through this path (they never read - // workspace.json — only main owns it). A detached window with no - // pending init is the dev-reload / edge case; we boot with a blank - // default workspace. - let initialEnvelope: ReturnType = null; - let adoptedLeafId: NodeId | null = null; - - if (!IS_MAIN_WINDOW) { + let loaded: TreeNode | null = null; + try { + const json = await loadWorkspace(); + if (json) loaded = deserialize(json); + } catch (e) { + console.warn("loadWorkspace failed:", e); + } + if (!loaded) { try { - const pending = await consumePendingWindowInit(); - if (pending) { - try { - const adoptedLeaf = JSON.parse(pending.leafJson) as LeafNode; - if (adoptedLeaf && adoptedLeaf.kind === "leaf") { - transferredPaneIdsRef.current.set(adoptedLeaf.id, pending.paneId); - adoptedLeafId = adoptedLeaf.id; - initialEnvelope = { - version: 2, - workspaces: [ - { - id: newId(), - name: pending.workspaceName || "Detached", - tree: adoptedLeaf, - }, - ], - }; - } - } catch (e) { - console.warn("invalid pending leafJson:", e); - } + const legacy = localStorage.getItem(LEGACY_STORAGE_KEY); + if (legacy) { + loaded = deserialize(legacy); + if (loaded) void saveWorkspace(legacy); + localStorage.removeItem(LEGACY_STORAGE_KEY); } } catch (e) { - console.warn("takePendingWindowInit failed:", e); - } - } else { - // Main window: load workspace.json (and legacy fallback). - try { - const json = await loadWorkspace(); - if (json) initialEnvelope = deserializeWorkspaces(json); - } catch (e) { - console.warn("loadWorkspace failed:", e); - } - if (!initialEnvelope) { - try { - const legacy = localStorage.getItem(LEGACY_STORAGE_KEY); - if (legacy) { - initialEnvelope = deserializeWorkspaces(legacy); - if (initialEnvelope) { - void saveWorkspace(serializeWorkspaces(initialEnvelope)); - } - localStorage.removeItem(LEGACY_STORAGE_KEY); - } - } catch (e) { - console.warn("legacy localStorage migration failed:", e); - } + console.warn("legacy localStorage migration failed:", e); } } @@ -368,26 +190,13 @@ export default function App() { })(); if (cancelled) return; - - let envelope = initialEnvelope; - if (!envelope) { - envelope = singletonEnvelope( - newLeaf(defaultShellAsLeafProps(initialDefault)), - ); - } - if (initialDefault.shellKind === "wsl" && initialDefault.distro) { - for (const w of envelope.workspaces) { - backfillWslDistro(w.tree, initialDefault.distro); + if (loaded) { + if (initialDefault.shellKind === "wsl" && initialDefault.distro) { + backfillWslDistro(loaded, initialDefault.distro); } - } - setWorkspaces(envelope.workspaces); - setCurrentWorkspaceId(envelope.workspaces[0].id); - if (adoptedLeafId) { - setActiveLeafByWorkspace((prev) => { - const m = new Map(prev); - m.set(envelope!.workspaces[0].id, adoptedLeafId); - return m; - }); + setTree(loaded); + } else { + setTree(newLeaf(defaultShellAsLeafProps(initialDefault))); } setDistros(resolvedDistros); setHosts(resolvedHosts); @@ -399,39 +208,31 @@ export default function App() { }; }, []); - // ---- workspace sync to backend aggregator ------------------------------- - // Every window pushes its own workspaces snapshot; the backend merges - // across windows and debounces the actual workspace.json write (500ms - // tokio sleep inside Rust). This replaces the v0.3.0 per-window - // saveWorkspace path which would race when two windows wrote at once. + // ---- debounced save ------------------------------------------------------ useEffect(() => { if (!ready) return; - pushWindowWorkspaces(CURRENT_WINDOW_LABEL, JSON.stringify(workspaces)).catch( - (e) => console.warn("pushWindowWorkspaces failed:", e), - ); - }, [workspaces, ready]); + const id = window.setTimeout(() => { + saveWorkspace(serialize(tree)).catch((e) => + console.warn("saveWorkspace failed:", e), + ); + }, SAVE_DEBOUNCE_MS); + return () => clearTimeout(id); + }, [tree, ready]); // ---- focus polling → setActive (xterm.js eats pointerdown) -------------- - // Scoped to the current workspace layer so a hidden tab's lingering - // textarea focus (visibility:hidden doesn't auto-blur on tab switch) - // doesn't yank activeLeafId into a tab the user can't see. useEffect(() => { let lastLeafId: string | null = null; const interval = window.setInterval(() => { const el = document.activeElement; const leafEl = el?.closest("[data-leaf-id]"); - if (!leafEl) return; - const wsEl = leafEl.closest("[data-workspace-id]"); - const wsId = wsEl?.getAttribute("data-workspace-id") ?? null; - if (wsId !== currentWorkspaceIdRef.current) return; - const id = leafEl.getAttribute("data-leaf-id"); + const id = leafEl?.getAttribute("data-leaf-id") ?? null; if (id && id !== lastLeafId) { lastLeafId = id; setActiveLeafId(id); } }, 250); return () => clearInterval(interval); - }, [setActiveLeafId]); + }, []); // notify is defined up here (and not next to dismissNotification) because // the split callback below uses it to warn when a pane is too small to @@ -563,95 +364,6 @@ export default function App() { setTree((t) => setLeafShell(t, leafId, spec)); }, []); - // ---- tab (workspace) operations ---------------------------------------- - /** Append a fresh blank workspace using the current default shell, then - * switch to it. */ - const createTab = useCallback(() => { - const idx = workspacesRef.current.length + 1; - const w: Workspace = { - id: newId(), - name: `Tab ${idx}`, - tree: newLeaf(defaultShellAsLeafProps(defaultShell)), - }; - setWorkspaces((ws) => [...ws, w]); - setCurrentWorkspaceId(w.id); - }, [defaultShell]); - - /** Switch the visible workspace. No-op if the id isn't in the list. */ - const switchTab = useCallback((id: NodeId) => { - if (!workspacesRef.current.some((w) => w.id === id)) return; - setCurrentWorkspaceId(id); - }, []); - - const renameTab = useCallback((id: NodeId, name: string) => { - setWorkspaces((ws) => ws.map((w) => (w.id === id ? { ...w, name } : w))); - }, []); - - /** Close a tab. Kills every PTY in its tree first (so the call site - * doesn't need to walk leaves itself). If the closing tab was current, - * switch to the adjacent one. If it was the only tab, replace it with a - * fresh default — tiletopia must always have at least one workspace. */ - const closeTab = useCallback( - (id: NodeId) => { - const target = workspacesRef.current.find((w) => w.id === id); - if (!target) return; - for (const leaf of walkLeaves(target.tree)) { - const paneId = paneIdByLeafRef.current.get(leaf.id); - if (paneId != null) { - void killPane(paneId).catch((e) => - console.warn("killPane failed:", e), - ); - paneIdByLeafRef.current.delete(leaf.id); - } - } - - const idx = workspacesRef.current.findIndex((w) => w.id === id); - const wasCurrent = currentWorkspaceIdRef.current === id; - - setWorkspaces((prev) => { - const next = prev.filter((w) => w.id !== id); - if (next.length === 0) { - return [ - { - id: newId(), - name: "Default", - tree: newLeaf(defaultShellAsLeafProps(defaultShell)), - }, - ]; - } - return next; - }); - - setActiveLeafByWorkspace((prev) => { - if (!prev.has(id)) return prev; - const m = new Map(prev); - m.delete(id); - return m; - }); - - if (wasCurrent) { - // Use the snapshot of workspaces BEFORE removal to find a neighbor. - const remaining = workspacesRef.current.filter((w) => w.id !== id); - if (remaining.length > 0) { - const nextIdx = Math.min(idx, remaining.length - 1); - setCurrentWorkspaceId(remaining[nextIdx].id); - } - // else: setWorkspaces above will populate a fresh default; the - // workspaces effect runs and updates currentWorkspaceIdRef below. - } - }, - [defaultShell], - ); - - // When the workspaces list changes (load, close last tab, etc.), make sure - // currentWorkspaceId points at something real. - useEffect(() => { - if (workspaces.length === 0) return; - if (!workspaces.some((w) => w.id === currentWorkspaceId)) { - setCurrentWorkspaceId(workspaces[0].id); - } - }, [workspaces, currentWorkspaceId]); - const setLabel = useCallback((leafId: NodeId, label: string | undefined) => { setTree((t) => changeLabel(t, leafId, label)); }, []); @@ -664,34 +376,6 @@ export default function App() { setTree((t) => toggleMcpAllowInTree(t, leafId)); }, []); - const setLeafColors = useCallback( - (leafId: NodeId, colors: PaneColors | undefined) => { - setTree((t) => setLeafColorsInTree(t, leafId, colors)); - }, - [], - ); - - const openColorPanel = useCallback((leafId?: NodeId) => { - if (leafId) setActiveLeafId(leafId); - setColorPanelMode(leafId ? "pane" : "global"); - setColorPanelOpen(true); - }, [setActiveLeafId]); - - // Persist the global theme on every change, and pick up edits made in - // OTHER windows. localStorage is shared per-origin: the `storage` event - // fires only in windows that did NOT make the write, so this can't loop. - useEffect(() => { - saveGlobalColors(globalColors); - }, [globalColors]); - useEffect(() => { - function onStorage(e: StorageEvent) { - if (e.key !== GLOBAL_COLORS_STORAGE_KEY) return; - setGlobalColors(loadGlobalColors()); - } - window.addEventListener("storage", onStorage); - return () => window.removeEventListener("storage", onStorage); - }, []); - // ---- MCP server lifecycle ------------------------------------------------ const refreshMcpStatus = useCallback(async () => { try { @@ -762,36 +446,6 @@ export default function App() { setActiveLeafId(leafId); }, []); - // navigateTo — called from XtermPane's attachCustomKeyEventHandler via - // LeafPane's onNavigate prop. Resolves the target leaf from the current - // layout tree and sets it active; the LeafPane isActive→focusTrigger - // effect then refocuses the xterm textarea automatically. - const navigateTo = useCallback((intent: NavigateIntent) => { - const currentTree = treeRef.current; - const currentActiveId = activeLeafId; - - if (intent.kind === "direction") { - const layout = flattenLayout(currentTree); - if (!currentActiveId) { - const first = layout.leaves[0]?.leaf.id; - if (first) setActiveLeafId(first); - return; - } - const nextId = findNeighborInDirection( - layout.leaves, - currentActiveId, - intent.dir, - ); - if (nextId) setActiveLeafId(nextId); - } else { - // intent.kind === "index" - const leaves = Array.from(walkLeaves(currentTree)); - // Clamp: Alt+9 with 3 panes picks the 3rd pane. - const target = leaves[Math.min(intent.n, leaves.length) - 1]; - if (target) setActiveLeafId(target.id); - } - }, [activeLeafId]); // treeRef is a ref — stable, intentionally not listed - const openHostManager = useCallback(() => setHostManagerOpen(true), []); const closeHostManager = useCallback(() => setHostManagerOpen(false), []); @@ -897,7 +551,6 @@ export default function App() { return; } - // Ctrl+Shift+Alt+B — global broadcast all/none if (ctrl && shift && alt && key === "b") { e.preventDefault(); @@ -930,65 +583,6 @@ export default function App() { return; } - // Ctrl+T — new tab. - if (ctrl && !shift && !alt && key === "t") { - e.preventDefault(); - e.stopPropagation(); - createTab(); - return; - } - - // Ctrl+Shift+T — close current tab. Confirms via window.confirm when - // the tab has live panes (matches the X-button popover's intent - // without re-using its anchored UI from the keyboard path). - if (ctrl && shift && !alt && key === "t") { - e.preventDefault(); - e.stopPropagation(); - const wsId = currentWorkspaceIdRef.current; - const ws = workspacesRef.current.find((w) => w.id === wsId); - if (!ws) return; - const paneCount = leafCount(ws.tree); - if (paneCount > 0) { - const labels = Array.from(walkLeaves(ws.tree)) - .map((l) => l.label ?? l.distro ?? `(${l.shellKind})`) - .join(", "); - const ok = window.confirm( - `Close tab "${ws.name}"? This will kill ${paneCount} pane${paneCount === 1 ? "" : "s"}: ${labels}`, - ); - if (!ok) return; - } - closeTab(wsId); - return; - } - - // Ctrl+PageDown / Ctrl+PageUp — switch to next / previous tab. - if (ctrl && !shift && !alt && (key === "pagedown" || key === "pageup")) { - e.preventDefault(); - e.stopPropagation(); - const ws = workspacesRef.current; - if (ws.length < 2) return; - const idx = ws.findIndex((w) => w.id === currentWorkspaceIdRef.current); - if (idx < 0) return; - const delta = key === "pagedown" ? 1 : -1; - const nextIdx = (idx + delta + ws.length) % ws.length; - switchTab(ws[nextIdx].id); - return; - } - - // Ctrl+1..9 — switch to tab N (1-indexed, capped at 9 even if more). - if (ctrl && !shift && !alt && e.code.startsWith("Digit")) { - const n = parseInt(e.code.slice(5), 10); - if (n >= 1 && n <= 9) { - const ws = workspacesRef.current; - if (ws[n - 1]) { - e.preventDefault(); - e.stopPropagation(); - switchTab(ws[n - 1].id); - return; - } - } - } - // All remaining shortcuts require Ctrl+Shift with no Alt. if (!ctrl || !shift || alt) return; @@ -1035,7 +629,7 @@ export default function App() { window.addEventListener("keydown", onKey, true); return () => window.removeEventListener("keydown", onKey, true); - }, [split, close, toggleBroadcast, promoteActive, createTab, closeTab, switchTab]); + }, [split, close, toggleBroadcast, promoteActive]); // Waiters keyed by leaf id — used by the MCP spawn_pane / connect_host // handlers, which must reply with the new paneId but can only get one @@ -1052,9 +646,6 @@ export default function App() { return; } paneIdByLeafRef.current.set(leafId, paneId); - // One-shot: now that the pane has registered, the transferred-id - // hint is consumed. - transferredPaneIdsRef.current.delete(leafId); const waiter = pendingPaneRegistrations.current.get(leafId); if (waiter) { pendingPaneRegistrations.current.delete(leafId); @@ -1064,80 +655,6 @@ export default function App() { [], ); - const getInitialPaneIdFor = useCallback( - (leafId: NodeId): PaneId | undefined => - transferredPaneIdsRef.current.get(leafId), - [], - ); - - /** Pop the given leaf into a fresh top-level window. The source's - * XtermPane will unmount as the leaf leaves this window's tree; - * markPaneTransferring keeps the underlying PTY alive until the new - * window's XtermPane adopts it via existingPaneId. */ - const moveToNewWindow = useCallback( - async (leafId: NodeId) => { - const leaf = findLeaf(treeRef.current, leafId); - if (!leaf || leaf.kind !== "leaf") { - notify("Cannot move — pane not found"); - return; - } - // The pane's id is registered only after its XtermPane finishes the - // async spawn/adopt round-trip. If the user drags out a pane that's - // still completing that (e.g. just after a shell-swap, or a pane in a - // freshly-detached window), wait for registration instead of failing - // outright. Resolves immediately if already registered. - let paneId = paneIdByLeafRef.current.get(leafId); - if (paneId == null) { - try { - paneId = await waitForPaneRegistration(leafId, 5000); - } catch { - notify("Cannot move — PTY not ready yet"); - return; - } - } - - try { - await markPaneTransferring(paneId); - } catch (e) { - notify(`mark_pane_transferring failed: ${e}`); - return; - } - - // Snapshot the leaf BEFORE removing — closeLeaf may produce a tree - // where this leaf is no longer present, breaking findLeaf later. - const leafJson = JSON.stringify(leaf); - const workspaceName = leaf.label ?? `Pane ${paneId}`; - - // Remove from current tree (sibling promotes naturally via closeLeaf). - // If this leaf was the entire tree, fall back to a fresh default so - // the source workspace never becomes empty (matches close behavior). - setTree( - (t) => - closeLeaf(t, leafId) ?? newLeaf(defaultShellAsLeafProps(defaultShell)), - ); - paneIdByLeafRef.current.delete(leafId); - setActiveLeafByWorkspace((prev) => { - const wsId = currentWorkspaceIdRef.current; - if (!wsId) return prev; - if (prev.get(wsId) !== leafId) return prev; - const m = new Map(prev); - m.set(wsId, null); - return m; - }); - - try { - await createPaneWindow({ leafJson, paneId, workspaceName }); - } catch (e) { - notify(`Failed to open new window: ${e}`); - // The leaf is already gone from our tree and the PTY is orphaned - // in transferring state. Drop the refcount so a manual kill could - // eventually succeed; but the leaf no longer exists in any tree. - void claimPane(paneId).catch(() => {}); - } - }, - [defaultShell, notify], - ); - /** Insert a new leaf into the tree from a SpawnSpec — used by the MCP * spawn_pane and connect_host handlers. Returns the new leaf's id * (caller awaits waitForPaneRegistration on it for the paneId). @@ -1310,18 +827,14 @@ export default function App() { activeLeafId, distros, hosts, - globalColors, split, close, setShell, setLabel, toggleBroadcast, toggleMcpAllow, - setLeafColors, openHostManager, - openColorPanel, setActive, - navigateTo, registerPaneId, broadcastFrom, notify, @@ -1331,25 +844,19 @@ export default function App() { setHeaderDragOver, endHeaderDrag, reportLeafIdle, - moveToNewWindow, - getInitialPaneIdFor, }), [ activeLeafId, distros, hosts, - globalColors, split, close, setShell, setLabel, toggleBroadcast, toggleMcpAllow, - setLeafColors, openHostManager, - openColorPanel, setActive, - navigateTo, registerPaneId, broadcastFrom, notify, @@ -1359,8 +866,6 @@ export default function App() { setHeaderDragOver, endHeaderDrag, reportLeafIdle, - moveToNewWindow, - getInitialPaneIdFor, ], ); @@ -1368,18 +873,11 @@ export default function App() { // Whenever the tree, hosts, or active selection change AND the MCP server // is running, push a fresh mirror down to the backend. Per-leaf mcpAllow // gates whether each leaf appears in the mirror (default-deny). - // - // Multi-window scoping: only the MAIN window pushes the mirror. Detached - // windows have their own current-workspace tree but Claude sees ONE - // workspace surface — main's current tab. Otherwise two windows would - // overwrite each other's mirrors on every keystroke and Claude's view - // would flap unpredictably. const allowedPaneCount = useMemo( () => Array.from(walkLeaves(tree)).filter((l) => l.mcpAllow).length, [tree], ); useEffect(() => { - if (!IS_MAIN_WINDOW) return; if (!mcpStatus.running) return; const leaves: Record = {}; for (const leaf of walkLeaves(tree)) { @@ -1839,10 +1337,6 @@ export default function App() { ); useEffect(() => { - // Only the main window handles MCP requests — paneIdByLeafRef is - // per-window so a request targeting a leaf in another window would - // fail anyway. Keeps responsibility clean: MCP sees main, period. - if (!IS_MAIN_WINDOW) return; let cancelled = false; let unlisten: (() => void) | undefined; void onMcpRequest(async (req: McpActionRequest) => { @@ -1914,11 +1408,11 @@ export default function App() { [paletteOpen, tree], ); - // ---- flat layout per workspace — leaves as siblings keyed by id --------- - // Each workspace gets its own .workspace-layer (rendered below). Layouts - // are recomputed inline per tab; the per-workspace render preserves - // LeafPane (and its PTY) across any tree reshape and across tab switches - // (since non-active layers are visibility:hidden rather than unmounted). + // ---- flat layout — leaves as siblings keyed by id; gutters separate ----- + // This lets React preserve LeafPane (and its PTY) across any tree reshape + // — split, close, preset application, etc. The tree changes, the boxes + // change, the leaves re-position but DON'T unmount. + const layout = useMemo(() => flattenLayout(tree), [tree]); const paneWrapRef = useRef(null); const onGutterRatio = useCallback((splitId: NodeId, ratio: number) => { setTree((t) => updateSplitRatio(t, splitId, ratio)); @@ -2137,14 +1631,6 @@ export default function App() { > 🤖 - - - - {/* Target toggle: edit the global default or just the active pane. */} -
- - -
- -
-

- {paneMode - ? "These colours override the global theme for the active pane only. Unset rows inherit the global default." - : "These colours apply to every pane that doesn't have its own override. Saved across restarts and shared with new windows."} -

- - {/* Editable colour rows */} -
- {FIELDS.map(({ key, label }) => { - const value = resolved[key]!; - const inherited = paneMode && !isSet(key); - return ( -
- {label} - setField(key, e.target.value)} - aria-label={label} - /> - { - const v = e.target.value.trim(); - if (HEX_RE.test(v)) setField(key, v); - }} - /> - {paneMode && - (inherited ? ( - - inherited - - ) : ( - - ))} -
- ); - })} -
- - {/* Live preview */} - - - {/* Presets */} -
- Presets -
- {COLOR_PRESETS.map((p) => ( - - ))} -
-
- -
- -
-
- - - ); -} diff --git a/src/components/SearchBar.css b/src/components/SearchBar.css deleted file mode 100644 index 0f389bd..0000000 --- a/src/components/SearchBar.css +++ /dev/null @@ -1,105 +0,0 @@ -/* --------------------------------------------------------------------------- - SearchBar — find-in-scrollback overlay. - - Positioned absolutely inside XtermPane's container div (which must be - position: relative). Sits at the top-right of the pane, z-index 10 so it - floats above the xterm canvas but below any app-level modals (z-index 100). - Colour palette matches Palette.css / Help.css: #181818 surface, #2a2a2a - borders, #e6e6e6 text, #1a3a5c accent. ---------------------------------------------------------------------------- */ - -.search-bar { - position: absolute; - top: 4px; - right: 4px; - z-index: 10; - display: flex; - align-items: center; - gap: 3px; - background: #181818; - border: 1px solid #2a2a2a; - border-radius: 5px; - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.55); - padding: 3px 4px; - font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace; - font-size: 12px; - color: #e6e6e6; -} - -.search-input { - font: inherit; - font-size: 12px; - color: #e6e6e6; - background: #1f1f1f; - border: 1px solid #2a2a2a; - border-radius: 3px; - padding: 3px 7px; - outline: none; - width: 180px; - caret-color: #e6e6e6; -} - -.search-input:focus { - border-color: #1a3a5c; - box-shadow: 0 0 0 1px #1a3a5c; -} - -.search-input::placeholder { - color: #555; -} - -/* Toggle buttons (Aa / .*) */ -.search-toggle { - font: inherit; - font-size: 11px; - background: transparent; - border: 1px solid #2a2a2a; - border-radius: 3px; - color: #888; - padding: 2px 5px; - cursor: pointer; - line-height: 1; - transition: background 0.1s, color 0.1s; -} - -.search-toggle:hover, -.search-toggle[aria-pressed="true"] { - background: #1a3a5c; - border-color: #1a5c8a; - color: #cce6ff; -} - -/* Prev / Next navigation arrows */ -.search-nav { - font: inherit; - font-size: 13px; - background: transparent; - border: 1px solid #2a2a2a; - border-radius: 3px; - color: #aaa; - padding: 1px 6px; - cursor: pointer; - line-height: 1; -} - -.search-nav:hover { - background: #2a2a2a; - color: #e6e6e6; -} - -/* Close button */ -.search-close { - background: transparent; - border: none; - color: #666; - font-size: 16px; - line-height: 1; - padding: 1px 5px; - cursor: pointer; - border-radius: 3px; -} - -.search-close:hover { - background: #2a2a2a; - color: #ddd; -} diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx deleted file mode 100644 index 1d41a99..0000000 --- a/src/components/SearchBar.tsx +++ /dev/null @@ -1,177 +0,0 @@ -import { useRef, useEffect, useState } from "react"; -import type { SearchAddon } from "@xterm/addon-search"; -import "./SearchBar.css"; - -// --------------------------------------------------------------------------- -// SearchBar — per-pane find-in-scrollback overlay. -// -// Rendered as an absolutely-positioned sibling of the xterm canvas inside -// XtermPane's container div (position: relative). The SearchAddon instance -// is owned by XtermPane and passed down as a prop; no IPC or Context needed. -// -// Toggle state (caseSensitive, regex) uses useState so aria-pressed reflects -// the live value on every render — refs alone don't trigger re-renders. -// --------------------------------------------------------------------------- - -interface SearchBarProps { - searchAddon: SearchAddon; - onClose: () => void; -} - -export default function SearchBar({ searchAddon, onClose }: SearchBarProps) { - const inputRef = useRef(null); - const queryRef = useRef(""); - const [caseSensitive, setCaseSensitive] = useState(false); - const [useRegex, setUseRegex] = useState(false); - - // Keep stable refs to toggle values so findNext/findPrev closures always - // see the current value without needing to be recreated on each state change. - const caseSensitiveRef = useRef(caseSensitive); - const useRegexRef = useRef(useRegex); - useEffect(() => { caseSensitiveRef.current = caseSensitive; }, [caseSensitive]); - useEffect(() => { useRegexRef.current = useRegex; }, [useRegex]); - - // Autofocus the input when the bar mounts. - useEffect(() => { - queueMicrotask(() => inputRef.current?.focus()); - }, []); - - function getOptions() { - return { - caseSensitive: caseSensitiveRef.current, - regex: useRegexRef.current, - // Highlight all matches and mark the active one distinctly. - decorations: { - matchBackground: "#3a3a00", - matchBorder: "#888800", - matchOverviewRuler: "#888800", - activeMatchBackground: "#b5890080", - activeMatchBorder: "#e6c000", - activeMatchColorOverviewRuler: "#e6c000", - }, - }; - } - - function findNext() { - if (!queryRef.current) return; - searchAddon.findNext(queryRef.current, getOptions()); - } - - function findPrev() { - if (!queryRef.current) return; - searchAddon.findPrevious(queryRef.current, getOptions()); - } - - function handleInput(e: React.ChangeEvent) { - queryRef.current = e.target.value; - // Live-search: jump to next match as you type. - if (queryRef.current) { - searchAddon.findNext(queryRef.current, getOptions()); - } - } - - function handleKeyDown(e: React.KeyboardEvent) { - if (e.key === "Escape") { - e.preventDefault(); - onClose(); - } else if (e.key === "Enter") { - e.preventDefault(); - if (e.shiftKey) { - findPrev(); - } else { - findNext(); - } - } - } - - function toggleCase() { - setCaseSensitive((v) => { - const next = !v; - caseSensitiveRef.current = next; - // Re-run with the new option so decorations update immediately. - if (queryRef.current) { - searchAddon.findNext(queryRef.current, { - ...getOptions(), - caseSensitive: next, - }); - } - return next; - }); - } - - function toggleRegex() { - setUseRegex((v) => { - const next = !v; - useRegexRef.current = next; - if (queryRef.current) { - searchAddon.findNext(queryRef.current, { - ...getOptions(), - regex: next, - }); - } - return next; - }); - } - - return ( -
- - - - - - - - - - - -
- ); -} diff --git a/src/components/TabStrip.css b/src/components/TabStrip.css deleted file mode 100644 index 212d024..0000000 --- a/src/components/TabStrip.css +++ /dev/null @@ -1,175 +0,0 @@ -.tab-strip { - flex: 0 0 auto; - display: flex; - align-items: stretch; - gap: 2px; - padding: 4px 8px 0 8px; - background: #161616; - border-bottom: 1px solid #2a2a2a; - font-size: 12px; - color: #aaa; - user-select: none; - overflow-x: auto; - min-height: 28px; - box-sizing: border-box; - white-space: nowrap; - /* The confirm popover is portalled to (see TabStrip.tsx), so it is - not clipped by this strip's overflow. */ -} - -.tab-strip-item { - position: relative; - display: inline-flex; - align-items: center; - gap: 6px; - padding: 4px 4px 4px 10px; - border: 1px solid #2a2a2a; - border-bottom: none; - border-top-left-radius: 4px; - border-top-right-radius: 4px; - background: #1a1a1a; - color: #999; - cursor: pointer; - max-width: 200px; - min-width: 80px; - flex-shrink: 0; -} -.tab-strip-item:hover { - background: #232323; - color: #ccc; -} -.tab-strip-item.active { - background: #0c0c0c; - color: #e6e6e6; - border-color: #2a5a8c; - /* Pull the active tab visually onto the pane area below it. */ - margin-bottom: -1px; -} - -.tab-strip-name { - font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace; - font-size: 11px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - flex: 1 1 auto; - min-width: 0; -} - -.tab-strip-rename { - font: inherit; - font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace; - font-size: 11px; - background: #0c0c0c; - color: #e6e6e6; - border: 1px solid #2a5a8c; - border-radius: 2px; - padding: 1px 4px; - width: 100%; - flex: 1 1 auto; - min-width: 0; - outline: none; -} - -.tab-strip-close { - font: inherit; - font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace; - font-size: 13px; - line-height: 1; - background: transparent; - color: #777; - border: none; - border-radius: 2px; - padding: 0 4px; - cursor: pointer; - flex: 0 0 auto; -} -.tab-strip-close:hover { - background: #c94040; - color: #fff; -} - -.tab-strip-add { - font: inherit; - font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace; - font-size: 14px; - line-height: 1; - background: #1a1a1a; - color: #aaa; - border: 1px solid #2a2a2a; - border-radius: 4px; - padding: 2px 10px; - cursor: pointer; - align-self: center; - margin-left: 4px; - flex: 0 0 auto; -} -.tab-strip-add:hover { - background: #1a3a5c; - color: #fff; - border-color: #2a5a8c; -} - -/* Confirm popover anchored to the close button. Portalled to and - positioned `fixed` (top/right set inline) so the horizontally-scrolling - tab strip — overflow-x:auto forces overflow-y:auto, which would clip an - in-strip popover — can't hide it. Plain matte panel; app palette. */ -.tab-strip-confirm { - position: fixed; - z-index: 1000; - /* width must match CONFIRM_POPOVER_WIDTH in TabStrip.tsx (clamp math). */ - width: 300px; - background: #1a1a1a; - color: #e6e6e6; - border: 1px solid #c98a1f; - border-radius: 4px; - padding: 10px 12px; - font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace; - font-size: 11px; - white-space: normal; - box-shadow: 0 6px 24px rgba(0, 0, 0, 0.6); - cursor: default; -} -.tab-strip-confirm-title { - font-weight: 600; - color: #f0c060; - margin-bottom: 6px; -} -.tab-strip-confirm-body { - color: #ccc; - margin-bottom: 10px; -} -.tab-strip-confirm-labels { - color: #e6e6e6; - font-size: 11px; - margin-top: 4px; - word-break: break-word; -} -.tab-strip-confirm-actions { - display: flex; - justify-content: flex-end; - gap: 6px; -} -.tab-strip-confirm-btn { - font: inherit; - font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace; - font-size: 11px; - background: #2a2a2a; - color: #ddd; - border: 1px solid #333; - border-radius: 3px; - padding: 4px 10px; - cursor: pointer; -} -.tab-strip-confirm-btn.cancel:hover { - background: #333; -} -.tab-strip-confirm-btn.destructive { - background: #4a1010; - color: #f8c0c0; - border-color: #c94040; -} -.tab-strip-confirm-btn.destructive:hover { - background: #6a1818; - color: #fff; -} diff --git a/src/components/TabStrip.tsx b/src/components/TabStrip.tsx deleted file mode 100644 index 8a64071..0000000 --- a/src/components/TabStrip.tsx +++ /dev/null @@ -1,241 +0,0 @@ -import { - useState, - useRef, - useEffect, - useCallback, - useMemo, - type KeyboardEvent as ReactKeyboardEvent, - type MouseEvent as ReactMouseEvent, -} from "react"; -import { createPortal } from "react-dom"; -import { walkLeaves, leafCount, type Workspace, type NodeId } from "../lib/layout/tree"; -import "./TabStrip.css"; - -/** Fixed width of the close-confirm popover — must match the `width` in - * TabStrip.css so the viewport-clamp math positions it accurately. */ -const CONFIRM_POPOVER_WIDTH = 300; - -interface TabStripProps { - workspaces: Workspace[]; - currentWorkspaceId: NodeId | null; - onSwitch: (id: NodeId) => void; - onCreate: () => void; - /** Caller MUST handle PTY teardown for the tab's leaves before removing it - * from the workspaces list. TabStrip just gates the action on user - * confirm. */ - onClose: (id: NodeId) => void; - onRename: (id: NodeId, name: string) => void; -} - -/** Tab strip displayed above the pane area. One pill per workspace; click to - * switch, double-click name to rename, × to close (with inline confirm if - * the tab has live panes), + at the end to spawn a new blank workspace. */ -export default function TabStrip({ - workspaces, - currentWorkspaceId, - onSwitch, - onCreate, - onClose, - onRename, -}: TabStripProps) { - const [editingId, setEditingId] = useState(null); - const [draft, setDraft] = useState(""); - const editInputRef = useRef(null); - const [confirmingId, setConfirmingId] = useState(null); - // Anchor rect (the close button's) for the confirm popover. The popover is - // portalled to with position:fixed because the tab strip scrolls - // horizontally (overflow-x:auto, which forces overflow-y to auto too), - // so an in-strip absolutely-positioned popover would be clipped. - const [confirmAnchor, setConfirmAnchor] = useState<{ - top: number; - left: number; - } | null>(null); - - const startEdit = useCallback( - (id: NodeId, current: string, e: ReactMouseEvent) => { - e.stopPropagation(); - setEditingId(id); - setDraft(current); - queueMicrotask(() => editInputRef.current?.select()); - }, - [], - ); - const commitEdit = useCallback(() => { - if (editingId == null) return; - const trimmed = draft.trim(); - if (trimmed) onRename(editingId, trimmed); - setEditingId(null); - }, [editingId, draft, onRename]); - const cancelEdit = useCallback(() => setEditingId(null), []); - const onEditKey = useCallback( - (e: ReactKeyboardEvent) => { - if (e.key === "Enter") { - e.preventDefault(); - commitEdit(); - } else if (e.key === "Escape") { - e.preventDefault(); - cancelEdit(); - } - }, - [commitEdit, cancelEdit], - ); - - // Outside-click dismissal for the inline confirm popover. - useEffect(() => { - if (confirmingId == null) return; - const onDocClick = () => setConfirmingId(null); - // Run on next tick so the click that opened the confirm doesn't immediately close it. - const id = window.setTimeout( - () => window.addEventListener("click", onDocClick), - 0, - ); - return () => { - clearTimeout(id); - window.removeEventListener("click", onDocClick); - }; - }, [confirmingId]); - - const confirmingWorkspace = useMemo( - () => workspaces.find((w) => w.id === confirmingId) ?? null, - [workspaces, confirmingId], - ); - - const confirmingPaneLabels = useMemo(() => { - if (!confirmingWorkspace) return [] as string[]; - return Array.from(walkLeaves(confirmingWorkspace.tree)).map( - (l) => l.label ?? l.distro ?? `(${l.shellKind})`, - ); - }, [confirmingWorkspace]); - - const requestClose = useCallback( - (id: NodeId, e: ReactMouseEvent) => { - e.stopPropagation(); - const w = workspaces.find((ws) => ws.id === id); - if (!w) return; - // Silent close when the tab has no live panes (e.g. empty default leaf - // with no PTY yet — but every leaf has one, so effectively never zero). - // The leafCount check leaves room for a future "empty tab" state. - if (leafCount(w.tree) === 0) { - onClose(id); - return; - } - const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); - // Right-align the popover to the close button by default, then clamp - // both edges into the viewport so a left-side tab doesn't push it off - // the left edge (or a right-side tab off the right). - const pad = 8; - const left = Math.max( - pad, - Math.min( - rect.right - CONFIRM_POPOVER_WIDTH, - window.innerWidth - CONFIRM_POPOVER_WIDTH - pad, - ), - ); - setConfirmAnchor({ top: rect.bottom + 4, left }); - setConfirmingId(id); - }, - [workspaces, onClose], - ); - - const confirmClose = useCallback( - (e: ReactMouseEvent) => { - e.stopPropagation(); - if (confirmingId == null) return; - const id = confirmingId; - setConfirmingId(null); - onClose(id); - }, - [confirmingId, onClose], - ); - - return ( -
- {workspaces.map((w) => { - const isActive = w.id === currentWorkspaceId; - const isEditing = editingId === w.id; - return ( -
onSwitch(w.id)} - onDoubleClick={(e) => startEdit(w.id, w.name, e)} - title={`Switch to ${w.name}`} - > - {isEditing ? ( - setDraft(e.target.value)} - onKeyDown={onEditKey} - onBlur={commitEdit} - onClick={(e) => e.stopPropagation()} - /> - ) : ( - {w.name} - )} - -
- ); - })} - - {confirmingId != null && - confirmAnchor && - createPortal( -
e.stopPropagation()} - > -
- Close "{confirmingWorkspace?.name}"? -
-
- This will kill {confirmingPaneLabels.length} pane - {confirmingPaneLabels.length === 1 ? "" : "s"}: -
- {confirmingPaneLabels.join(", ")} -
-
-
- - -
-
, - document.body, - )} -
- ); -} diff --git a/src/components/XtermPane.tsx b/src/components/XtermPane.tsx index 402b729..36f5839 100644 --- a/src/components/XtermPane.tsx +++ b/src/components/XtermPane.tsx @@ -1,11 +1,7 @@ -import { useRef, useEffect, useState } from "react"; +import { useRef, useEffect } from "react"; import { Terminal } from "@xterm/xterm"; import { FitAddon } from "@xterm/addon-fit"; import { WebLinksAddon } from "@xterm/addon-web-links"; -import { CanvasAddon } from "@xterm/addon-canvas"; -import { SearchAddon } from "@xterm/addon-search"; -import { Unicode11Addon } from "@xterm/addon-unicode11"; -import SearchBar from "./SearchBar"; import type { UnlistenFn } from "@tauri-apps/api/event"; import { readText as clipboardReadText, @@ -19,17 +15,9 @@ import { killPane, onPaneData, onPaneExit, - getPaneRing, - claimPane, type PaneId, type SpawnSpec, } from "../ipc"; -import type { NavigateIntent } from "../lib/layout/orchestration"; -import { - type PaneColors, - DEFAULT_PANE_COLORS, - toXtermTheme, -} from "../lib/theme"; // --------------------------------------------------------------------------- // base64 helpers (private to this module) @@ -62,12 +50,6 @@ interface XtermPaneProps { * changing it later does NOT respawn — callers force a respawn by * changing the React `key` (see Pane.svelte / LeafPane). */ spec: SpawnSpec; - /** Attach to an existing PTY (transferred from another window) instead of - * spawning a new one. When set: spec is ignored at the spawn step, the - * scrollback ring is replayed into xterm.js, the live data listener is - * attached, and the transfer refcount is claimed (decremented) so the - * source window's killPane is no longer suppressed. */ - existingPaneId?: PaneId; onStatus?: (msg: string, ok: boolean) => void; /** Fired once when the backend PTY is alive and we have its PaneId. */ onSpawn?: (paneId: PaneId) => void; @@ -81,15 +63,6 @@ interface XtermPaneProps { focusTrigger?: number; /** Absolute font size in px. Changes are applied live (fit + PTY resize). */ fontSize?: number; - /** Fully-resolved terminal colours (global theme merged with any per-pane - * override). Changes are applied live to the running terminal. */ - colors?: Required; - /** Called when the user presses a tiling-WM navigation chord inside the - * terminal. XtermPane only emits the intent; the parent (LeafPane/App) - * resolves the target leaf from the current layout and sets it active. - * Defined as an optional callback so single-pane windows don't require - * wiring it up. */ - onNavigate?: (intent: NavigateIntent) => void; } const DEFAULT_XTERM_FONT_SIZE = 13; @@ -100,7 +73,6 @@ const DEFAULT_XTERM_FONT_SIZE = 13; export default function XtermPane({ spec, - existingPaneId, onStatus, onSpawn, onInput, @@ -108,22 +80,15 @@ export default function XtermPane({ onFocus, focusTrigger = 0, fontSize, - colors, - onNavigate, }: XtermPaneProps) { const containerRef = useRef(null); const termRef = useRef(null); const fitRef = useRef(null); const paneIdRef = useRef(null); - const searchAddonRef = useRef(null); - const [searchOpen, setSearchOpen] = useState(false); // Stash the most recent `fontSize` prop so the mount effect can pick // up the initial value without re-running when it changes (the secondary // effect below handles dynamic updates). const initialFontSizeRef = useRef(fontSize); - // Same trick for the initial theme — the mount effect reads this once; the - // secondary effect below applies later changes live. - const initialColorsRef = useRef(colors); // 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. @@ -132,18 +97,12 @@ export default function XtermPane({ const onInputRef = useRef(onInput); const onDataReceivedRef = useRef(onDataReceived); const onFocusRef = useRef(onFocus); - const onNavigateRef = useRef(onNavigate); - // Stable ref for setSearchOpen so it can be called from inside the - // attachCustomKeyEventHandler closure without the closure going stale. - const setSearchOpenRef = useRef<(v: boolean) => void>(setSearchOpen); 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]); - useEffect(() => { onNavigateRef.current = onNavigate; }, [onNavigate]); - useEffect(() => { setSearchOpenRef.current = setSearchOpen; }, [setSearchOpen]); // ------------------------------------------------------------------------- // Mount / unmount: create terminal, spawn PTY, wire listeners @@ -156,12 +115,10 @@ export default function XtermPane({ fontFamily: '"Cascadia Mono", "JetBrains Mono", "Consolas", monospace', fontSize: initialFontSizeRef.current ?? DEFAULT_XTERM_FONT_SIZE, cursorBlink: true, - // Theme is resolved by the parent (global default merged with any - // per-pane override) and applied live by the effect below. The fixed - // slice — softened white/brightWhite that tame the Claude TUI's - // emphasis slots so nothing hits glaring pure white — lives in - // toXtermTheme / BASE_XTERM_THEME (see lib/theme.ts). - theme: toXtermTheme(initialColorsRef.current ?? DEFAULT_PANE_COLORS), + theme: { + background: "#0c0c0c", + foreground: "#e6e6e6", + }, scrollback: 5000, convertEol: false, allowProposedApi: true, @@ -183,41 +140,6 @@ export default function XtermPane({ ); term.open(container); - // Use the canvas renderer instead of xterm's default DOM renderer. - // The DOM renderer draws the cursor as a separate layered element and, - // under the Claude TUI's rapid hide/show (\x1b[?25l/h) + cursorBlink, - // leaves a stale cursor block frozen where the cursor used to be (the - // "stuck white marker"). The canvas renderer composites the cursor into - // the same surface as the text, so hide/show transitions clear cleanly. - // Chosen over the WebGL addon because tiletopia runs many panes at once - // and Chromium/WebView2 caps live WebGL contexts (~16) — canvas has no - // such hard limit. Loaded after open() so the core renderer exists. - try { - term.loadAddon(new CanvasAddon()); - } catch (e) { - // If canvas init fails for any reason, xterm falls back to the DOM - // renderer on its own — degrade gracefully rather than blank the pane. - console.warn("CanvasAddon load failed; using DOM renderer:", e); - } - - // Load Unicode 11 addon for correct width handling of emoji, CJK, and - // box-drawing characters. This prevents cursor drift in TUIs that rely on - // Unicode 11 character widths. Loaded after CanvasAddon so the renderer - // surface is set before width calculations begin. - try { - term.loadAddon(new Unicode11Addon()); - term.unicode.activeVersion = "11"; - } catch (e) { - console.warn("Unicode11Addon load failed:", e); - } - - // Load the search addon so find-in-scrollback works. Must be loaded - // after open() so the terminal viewport exists for decoration rendering, - // and after CanvasAddon since it decorates the same canvas surface. - const search = new SearchAddon(); - searchAddonRef.current = search; - term.loadAddon(search); - // Initial size — fit before asking the PTY for its dimensions. fit.fit(); @@ -231,98 +153,33 @@ export default function XtermPane({ const cols = term!.cols; const rows = term!.rows; - if (existingPaneId != null) { - // Adoption path: a window-transfer landed us here with an existing - // PTY id. Don't spawn — replay the scrollback ring first (so the - // user sees recent output like a thinking Claude session), then - // attach the live listener, resize the PTY to this window's grid, - // and release the transfer-refcount. - paneId = existingPaneId; + try { + paneId = await spawnPane({ spec, cols, rows }); + if (destroyed) { + void killPane(paneId); + return; + } paneIdRef.current = paneId; - onStatusRef.current?.(`pane ${paneId} adopted`, true); + onStatusRef.current?.(`pane ${paneId} alive`, true); onSpawnRef.current?.(paneId); - try { - const ringB64 = await getPaneRing(paneId); - if (destroyed) return; - if (ringB64) { - term?.write(b64ToBytes(ringB64)); - } - } catch (e) { - console.warn("getPaneRing failed:", e); - } + } catch (e) { if (destroyed) return; - unlistenData = await onPaneData(paneId, (b64) => { - term?.write(b64ToBytes(b64)); - onDataReceivedRef.current?.(); - }); - // `destroyed` may have flipped during the await — the sync cleanup - // already ran and captured a null unlisten, so unlisten here or the - // subscription leaks. - if (destroyed) { - unlistenData?.(); - return; - } - unlistenExit = await onPaneExit(paneId, () => { - term?.write("\r\n\x1b[33m[pane exited]\x1b[0m\r\n"); - onStatusRef.current?.(`pane ${paneId} exited`, false); - }); - if (destroyed) { - unlistenData?.(); - unlistenExit?.(); - return; - } - // Match the PTY to our cell grid (the source window may have had - // different dimensions). - try { - await resizePane(paneId, cols, rows); - } catch (e) { - console.warn("resizePane on adopt failed:", e); - } - // Release the transfer refcount so future killPane calls on this - // id are no longer suppressed. - try { - await claimPane(paneId); - } catch (e) { - console.warn("claimPane failed:", e); - } - } else { - try { - paneId = await spawnPane({ spec, cols, rows }); - if (destroyed) { - void killPane(paneId); - return; - } - paneIdRef.current = paneId; - 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?.(); - }); - if (destroyed) { - unlistenData?.(); - return; - } - - unlistenExit = await onPaneExit(paneId, () => { - term?.write("\r\n\x1b[33m[pane exited]\x1b[0m\r\n"); - onStatusRef.current?.(`pane ${paneId} exited`, false); - }); - if (destroyed) { - unlistenData?.(); - unlistenExit?.(); - 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); @@ -330,100 +187,36 @@ export default function XtermPane({ onInputRef.current?.(b64); }); - // Intercept tiling-WM chords before the PTY sees them. All families - // share ONE attachCustomKeyEventHandler call — xterm.js replaces the - // previous handler on every call, so a second call anywhere would - // silently discard all earlier interceptions. + // Ctrl+Shift+C / Ctrl+Shift+V — copy selection / paste from clipboard. + // Runs before xterm consumes the key, so the textarea never sees a raw + // Ctrl+V (which would otherwise inject ^V into the PTY). term.paste() + // routes through onData → writeToPane, so broadcasting and bracketed + // paste both keep working for free. // - // Family 1: Ctrl+Shift+C / Ctrl+Shift+V — copy selection / paste. - // Uses tauri-plugin-clipboard-manager so WebView2 never shows its - // native "Allow clipboard access?" prompt. term.paste() routes - // through onData → writeToPane so broadcasting + bracketed paste - // keep working for free. - // - // Family 2: Ctrl+Shift+F — open/focus the find-in-scrollback bar. - // Swallowed before xterm or the PTY sees the raw keypress. Uses the - // stable setSearchOpenRef so the closure never goes stale. - // - // Family 3: Ctrl+Alt+Arrow / Ctrl+Alt+H/J/K/L — spatial pane focus. - // XtermPane emits onNavigate({ kind: "direction", dir }) and returns - // false so the chord is swallowed before it reaches the PTY. The - // parent (LeafPane → App) resolves the neighbour and bumps - // focusTrigger on the new active pane. - // - // Family 4: Alt+1..9 — index-based pane focus. - // Emits onNavigate({ kind: "index", n }) and swallows. Note: bare - // Alt+digit is used by some shells (readline digit-argument, vim/nvim) - // — this interception is an accepted v1 trade-off (see shortcuts.ts). + // Uses tauri-plugin-clipboard-manager instead of navigator.clipboard so + // WebView2 doesn't surface its native "Allow clipboard access?" prompt. term?.attachCustomKeyEventHandler((e) => { if (e.type !== "keydown") return true; - - // --- Family 1 & 2: Ctrl+Shift+* (no Alt) --------------------------- - if (e.ctrlKey && e.shiftKey && !e.altKey) { - if (e.code === "KeyF") { - // Ctrl+Shift+F — open find-in-scrollback bar. - e.preventDefault(); - setSearchOpenRef.current(true); - return false; - } - if (e.code === "KeyC") { - // Ctrl+Shift+C — copy selection to clipboard. - const sel = term?.getSelection(); - if (sel) { - void clipboardWriteText(sel).catch((err) => - console.warn("clipboard write failed:", err), - ); - } - e.preventDefault(); - return false; - } - if (e.code === "KeyV") { - // Ctrl+Shift+V — paste from clipboard via term.paste() so - // broadcasting and bracketed paste work for free. - e.preventDefault(); - clipboardReadText() - .then((text) => { - if (text && term) term.paste(text); - }) - .catch((err) => console.warn("clipboard read failed:", err)); - return false; + if (!e.ctrlKey || !e.shiftKey || e.altKey) return true; + if (e.code === "KeyC") { + const sel = term?.getSelection(); + if (sel) { + void clipboardWriteText(sel).catch((err) => + console.warn("clipboard write failed:", err), + ); } + e.preventDefault(); + return false; } - - // --- Family 3: Ctrl+Alt+Arrow / Ctrl+Alt+H/J/K/L (spatial nav) ----- - if (e.ctrlKey && e.altKey && !e.shiftKey && onNavigateRef.current) { - // Arrow keys - const ARROW_DIR: Record = { - ArrowLeft: "left", - ArrowRight: "right", - ArrowUp: "up", - ArrowDown: "down", - }; - // Vim-style HJKL - const VIM_DIR: Record = { - KeyH: "left", - KeyJ: "down", - KeyK: "up", - KeyL: "right", - }; - const dir = ARROW_DIR[e.code] ?? VIM_DIR[e.code]; - if (dir) { - e.preventDefault(); - onNavigateRef.current({ kind: "direction", dir }); - return false; - } + if (e.code === "KeyV") { + e.preventDefault(); + clipboardReadText() + .then((text) => { + if (text && term) term.paste(text); + }) + .catch((err) => console.warn("clipboard read failed:", err)); + return false; } - - // --- Family 4: Alt+1..9 (index-based pane focus) ------------------- - if (e.altKey && !e.ctrlKey && !e.shiftKey && onNavigateRef.current) { - const digit = e.code.match(/^Digit([1-9])$/); - if (digit) { - e.preventDefault(); - onNavigateRef.current({ kind: "index", n: parseInt(digit[1], 10) }); - return false; - } - } - return true; }); @@ -480,18 +273,8 @@ export default function XtermPane({ }); ro.observe(container); - // Focus so typing immediately lands in the terminal — but ONLY if the - // host container is actually visible. With multiple tabs (workspaces), - // a pane in a hidden tab still mounts and spawns; we must not yank - // focus into a tab the user can't see. CSS `visibility: hidden` is - // inherited, so the computed style on the container reflects whether - // any ancestor (workspace-layer) is hiding us. - if ( - container.isConnected && - getComputedStyle(container).visibility !== "hidden" - ) { - term?.focus(); - } + // Focus so typing immediately lands in the terminal. + term?.focus(); })(); return () => { @@ -504,7 +287,6 @@ export default function XtermPane({ term = null; termRef.current = null; fitRef.current = null; - searchAddonRef.current = null; paneIdRef.current = null; }; // spec is read once at mount; intentionally omitted from deps so we @@ -551,51 +333,5 @@ export default function XtermPane({ } }, [fontSize]); - // ------------------------------------------------------------------------- - // Live colour-theme changes (global theme edit, per-pane override, preset). - // - // Setting term.options.theme re-tints the renderer immediately; a refresh - // forces the canvas surface to repaint already-drawn cells with the new - // palette (xterm only re-tints on the next write otherwise). Cell geometry - // is unaffected, so no fit()/resize is needed — unlike the font-size path. - // ------------------------------------------------------------------------- - useEffect(() => { - const term = termRef.current; - if (!term || !colors) return; - try { - term.options.theme = toXtermTheme(colors); - term.refresh(0, term.rows - 1); - } catch (e) { - console.warn("theme apply failed", e); - } - // Depend on the individual fields rather than the object identity so a - // parent that rebuilds an equal colours object each render doesn't churn. - }, [colors?.background, colors?.foreground, colors?.cursor, colors?.selection]); - - // Close the search bar and return focus to the xterm textarea so the user - // can resume typing immediately. Queries the well-known xterm helper - // textarea selector — the same pattern used in the focusTrigger effect. - function closeSearch() { - setSearchOpen(false); - const ta = containerRef.current?.querySelector( - ".xterm-helper-textarea", - ); - ta?.focus(); - } - - // The outer wrapper is position:relative so the absolutely-positioned - // SearchBar anchors inside the pane without escaping to a positioned - // ancestor further up the tree. The FitAddon measures containerRef's div - // (the inner one), which still fills 100% of the wrapper — no sizing break. - return ( -
-
- {searchOpen && searchAddonRef.current && ( - - )} -
- ); + return
; } diff --git a/src/ipc.ts b/src/ipc.ts index 6660ed8..e1d48c8 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -53,53 +53,6 @@ export const resizePane = (id: PaneId, cols: number, rows: number): Promise => invoke("kill_pane", { id }); -/** Increment the "do not kill" transfer refcount for a pane. Source window - * calls this BEFORE removing the leaf from its tree so the unmount-driven - * kill_pane on the source becomes a no-op until the target window's - * XtermPane has claimed it. */ -export const markPaneTransferring = (id: PaneId): Promise => - invoke("mark_pane_transferring", { id }); - -/** Decrement the transfer refcount. Target window's XtermPane calls this - * after subscribing to pane://{id}/data and replaying the ring snapshot. */ -export const claimPane = (id: PaneId): Promise => - invoke("claim_pane", { id }); - -/** Snapshot of the per-pane scrollback ring as base64. Target window's - * XtermPane writes it into xterm.js before attaching the live data - * listener so a transferred pane doesn't open blank. */ -export const getPaneRing = (id: PaneId): Promise => - invoke("get_pane_ring", { id }); - -// ---- multi-window pane transfer ------------------------------------------- - -export interface PendingInit { - leafJson: string; - paneId: PaneId; - workspaceName: string; -} - -/** Open a new window and stash the pending-init payload keyed by the new - * window's label. Returns the new label. */ -export const createPaneWindow = (payload: PendingInit): Promise => - invoke("create_pane_window", { payload }); - -/** Read and remove the pending-init for the current window. Null when there - * is no pending payload (main window startup, or this call already - * consumed it). */ -export const takePendingWindowInit = ( - label: string, -): Promise => - invoke("take_pending_window_init", { label }); - -/** Push this window's workspaces snapshot to the backend aggregator. The - * backend debounces and writes the merged envelope to workspace.json. */ -export const pushWindowWorkspaces = ( - label: string, - workspacesJson: string, -): Promise => - invoke("push_window_workspaces", { label, workspacesJson }); - export const onPaneData = ( id: PaneId, cb: (b64: string) => void, diff --git a/src/lib/layout/LeafPane.css b/src/lib/layout/LeafPane.css index 521c69d..97e6074 100644 --- a/src/lib/layout/LeafPane.css +++ b/src/lib/layout/LeafPane.css @@ -84,10 +84,6 @@ overflow: hidden; text-overflow: ellipsis; max-width: 200px; - /* Give up width first when the pane is narrow, so the chips, context - indicator, and close button stay visible (overrides .pane-toolbar > *). */ - flex-shrink: 1; - min-width: 0; } .pane-label:hover { background: #222; @@ -246,9 +242,6 @@ .pane-status.idle { color: #d96060; } .pane-actions { - /* Final fallback right-anchor (non-claude pane has no .pane-ctx, and at - narrow tiers .pane-status is hidden) so the close button stays pinned right. */ - margin-left: auto; display: flex; gap: 2px; } @@ -271,92 +264,8 @@ background: #5a1a1a; color: #fcc; } - -/* ---- narrow-pane reflow ------------------------------------------------- - The close button stays visible at every width; lower-priority toolbar items - drop out by tier so a 180px pane keeps its close ×. */ -.leaf--narrow .pane-status, -.leaf--narrow .pane-actions .pane-btn:not(.close) { - display: none; -} -.leaf--xnarrow .pane-status, -.leaf--xnarrow .pane-actions .pane-btn:not(.close), -.leaf--xnarrow .distro-wrap, -.leaf--xnarrow .bcast-chip { - display: none; -} .xterm-wrap { flex: 1 1 auto; min-height: 0; position: relative; } - -/* Right-click context menu on the pane toolbar. Fixed-positioned popover - floating in the viewport; the LeafPane parent renders it inside its - own DOM tree so clicks within the menu still get the - stop-propagation chain. */ -.pane-context-menu { - z-index: 200; - min-width: 180px; - background: #1a1a1a; - color: #e6e6e6; - border: 1px solid #2a5a8c; - border-radius: 4px; - padding: 4px; - font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace; - font-size: 12px; - box-shadow: 0 6px 24px rgba(0, 0, 0, 0.6); -} -.pane-context-menu-item { - display: block; - width: 100%; - text-align: left; - background: transparent; - color: #e6e6e6; - border: none; - border-radius: 2px; - padding: 6px 10px; - font: inherit; - cursor: pointer; -} -.pane-context-menu-item:hover { - background: #2a5a8c; - color: #fff; -} - -/* Cursor-following ghost shown while dragging a pane toolbar (B1). Rendered - into document.body via a portal, offset from the cursor, and pointer-events - none so it never disturbs the elementFromPoint hit-test that drives the - drop-target highlight. */ -.pane-drag-ghost { - position: fixed; - z-index: 1000; - /* transform set inline so the chip can flip to the cursor's inner side - near the right/bottom edges (keeps it visible while pinned to the edge). */ - pointer-events: none; - display: flex; - align-items: center; - gap: 8px; - max-width: 320px; - padding: 4px 10px; - border: 1px solid #5a8cd8; - border-radius: 4px; - background: rgba(20, 28, 40, 0.95); - box-shadow: 0 4px 14px rgba(0, 0, 0, 0.5); - font: inherit; - font-size: 12px; - color: #cfe0f5; - white-space: nowrap; -} -.pane-drag-ghost-label { - overflow: hidden; - text-overflow: ellipsis; -} -.pane-drag-ghost.detach { - border-color: #e09838; - color: #ffd9a0; -} -.pane-drag-ghost-hint { - font-weight: 600; - color: #ffb840; -} diff --git a/src/lib/layout/LeafPane.tsx b/src/lib/layout/LeafPane.tsx index 35b1c7e..d02f13f 100644 --- a/src/lib/layout/LeafPane.tsx +++ b/src/lib/layout/LeafPane.tsx @@ -7,9 +7,7 @@ import { type MouseEvent, type PointerEvent as ReactPointerEvent, } from "react"; -import { createPortal } from "react-dom"; import { type LeafNode, resolveFontSize, type LeafShellSpec } from "./tree"; -import { resolvePaneColors } from "../theme"; import { useOrchestration } from "./orchestration"; import XtermPane from "../../components/XtermPane"; import type { SpawnSpec } from "../../ipc"; @@ -17,19 +15,6 @@ import "./LeafPane.css"; const IDLE_THRESHOLD_MS = 5000; -/** How far past a viewport edge the cursor must travel before a release is - * treated as "drag pane out of window" instead of "drop on empty space - * inside this window". Picked so an accidental release on the OS titlebar - * (~30px tall) stays inside the threshold. */ -const PANE_DRAG_OUT_MARGIN = 60; - -/** True when a point is past any viewport edge by PANE_DRAG_OUT_MARGIN. */ -const isFarOutsideViewport = (x: number, y: number) => - x < -PANE_DRAG_OUT_MARGIN || - x > window.innerWidth + PANE_DRAG_OUT_MARGIN || - y < -PANE_DRAG_OUT_MARGIN || - y > window.innerHeight + PANE_DRAG_OUT_MARGIN; - export default function LeafPane({ leaf }: { leaf: LeafNode }) { const orch = useOrchestration(); const isActive = orch.activeLeafId === leaf.id; @@ -43,7 +28,6 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) { const [editingLabel, setEditingLabel] = useState(false); const [labelDraft, setLabelDraft] = useState(""); const labelInputRef = useRef(null); - const rootRef = useRef(null); const startEditLabel = useCallback( (e: MouseEvent) => { @@ -158,22 +142,6 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) { return () => orch.reportLeafIdle(leaf.id, false); }, [leaf.id, orch.reportLeafIdle]); - // ---- width tier --------------------------------------------------------- - // Drives which toolbar items collapse on a narrow pane (CSS does the hiding). - // The close button + context indicator stay visible at every tier; min pane - // width is 180px (MIN_PANE_PX), so "xnarrow" must keep those reachable. - const [widthTier, setWidthTier] = useState<"" | "narrow" | "xnarrow">(""); - useEffect(() => { - const el = rootRef.current; - if (!el) return; - const ro = new ResizeObserver(() => { - const w = el.clientWidth; - setWidthTier(w < 230 ? "xnarrow" : w < 320 ? "narrow" : ""); - }); - ro.observe(el); - return () => ro.disconnect(); - }, []); - // ---- broadcast --------------------------------------------------------- const onTerminalInput = useCallback( (b64: string) => { @@ -212,51 +180,11 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) { [orch.setActive, leaf.id], ); - // Delegate keyboard navigation intents from XtermPane up to App via - // orch.navigateTo. XtermPane stays dumb (emits intent only); App resolves - // the target leaf from the current layout and bumps focusTrigger. - const onPaneNavigate = useCallback( - (intent: Parameters[0]) => orch.navigateTo(intent), - [orch.navigateTo], - ); - const onStatus = useCallback((msg: string, ok: boolean) => { setStatus(msg); setStatusOk(ok); }, []); - // ---- right-click context menu ------------------------------------------ - // Single entry in v1: "Move to new window" (pops the pane out into a - // fresh top-level tiletopia window without losing the PTY). - const [menuPos, setMenuPos] = useState<{ x: number; y: number } | null>(null); - const openContextMenu = useCallback( - (e: MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - setMenuPos({ x: e.clientX, y: e.clientY }); - }, - [], - ); - const closeContextMenu = useCallback(() => setMenuPos(null), []); - useEffect(() => { - if (!menuPos) return; - const onDocClick = () => setMenuPos(null); - const onEsc = (e: globalThis.KeyboardEvent) => { - if (e.key === "Escape") setMenuPos(null); - }; - // Defer attaching the click listener so the click that opened the menu - // doesn't immediately close it. - const t = window.setTimeout(() => { - window.addEventListener("click", onDocClick); - window.addEventListener("keydown", onEsc, true); - }, 0); - return () => { - clearTimeout(t); - window.removeEventListener("click", onDocClick); - window.removeEventListener("keydown", onEsc, true); - }; - }, [menuPos]); - // ---- header-drag swap --------------------------------------------------- // Drag the toolbar onto another pane's toolbar/body to swap their tree // positions. Uses a movement threshold so accidental tiny moves while @@ -265,17 +193,6 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) { const dragStartRef = useRef<{ x: number; y: number; armed: boolean; dragging: boolean } | null>( null, ); - // Cursor-following ghost shown while dragging the toolbar. `detach` flips - // true once the cursor is past the viewport edge by PANE_DRAG_OUT_MARGIN, - // mirroring the release condition in onToolbarPointerUp so the ghost - // previews what a release right now would do. - const [dragGhost, setDragGhost] = useState<{ - x: number; - y: number; - detach: boolean; - flipX: boolean; - flipY: boolean; - } | null>(null); const isDragSource = orch.dragSourceId === leaf.id; const isDragTarget = orch.dragOverId === leaf.id && orch.dragSourceId !== leaf.id; @@ -315,23 +232,6 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) { const tEl = el?.closest("[data-leaf-id]"); const targetId = tEl?.getAttribute("data-leaf-id") ?? null; orch.setHeaderDragOver(targetId); - // Move the cursor-following ghost (B1). It has pointer-events:none so - // it doesn't interfere with the elementFromPoint hit-test above. - // A webview can't paint outside its own OS window, so once the cursor - // crosses the edge we clamp the chip to the viewport (and flip it to - // the cursor's inner side near right/bottom) so it stays visible and - // its `detach` styling is what previews the release. `detach` itself - // is computed from the RAW cursor position so the preview is accurate. - const GHOST_PAD = 4; - const FLIP_X_ZONE = 180; // ~max chip width - const FLIP_Y_ZONE = 48; - setDragGhost({ - x: Math.max(GHOST_PAD, Math.min(e.clientX, window.innerWidth - GHOST_PAD)), - y: Math.max(GHOST_PAD, Math.min(e.clientY, window.innerHeight - GHOST_PAD)), - detach: isFarOutsideViewport(e.clientX, e.clientY), - flipX: e.clientX > window.innerWidth - FLIP_X_ZONE, - flipY: e.clientY > window.innerHeight - FLIP_Y_ZONE, - }); }, [orch.beginHeaderDrag, orch.setHeaderDragOver, leaf.id], ); @@ -343,23 +243,12 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) { (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); const wasDragging = st.dragging; dragStartRef.current = null; - setDragGhost(null); - if (!wasDragging) return; - document.body.style.cursor = ""; - - const releasedFarOutside = isFarOutsideViewport(e.clientX, e.clientY); - - if (releasedFarOutside) { - // Cancel any in-flight swap state without committing, then pop - // this pane into a fresh window. moveToNewWindow handles the - // PTY-handoff + closeLeaf in the source. - orch.endHeaderDrag(false); - orch.moveToNewWindow(leaf.id); - } else { + if (wasDragging) { + document.body.style.cursor = ""; orch.endHeaderDrag(true); } }, - [orch.endHeaderDrag, orch.moveToNewWindow, leaf.id], + [orch.endHeaderDrag], ); const onToolbarPointerCancel = useCallback( @@ -369,7 +258,6 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) { (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); const wasDragging = st.dragging; dragStartRef.current = null; - setDragGhost(null); if (wasDragging) { document.body.style.cursor = ""; orch.endHeaderDrag(false); @@ -406,8 +294,7 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) { return (
{editingLabel ? ( - - {isIdle && statusOk ? ( idle @@ -612,16 +482,13 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) { {spec ? ( ) : (
@@ -633,51 +500,6 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
)}
- {menuPos && ( -
e.stopPropagation()} - onContextMenu={(e) => e.preventDefault()} - > - -
- )} - {dragGhost && - createPortal( - , - document.body, - )}
); } diff --git a/src/lib/layout/orchestration.tsx b/src/lib/layout/orchestration.tsx index 10d90d5..754c9b7 100644 --- a/src/lib/layout/orchestration.tsx +++ b/src/lib/layout/orchestration.tsx @@ -1,7 +1,6 @@ import { createContext, useContext, type ReactNode } from "react"; -import type { Orientation, NodeId, LeafShellSpec, Direction } from "./tree"; +import type { Orientation, NodeId, LeafShellSpec } from "./tree"; import type { PaneId, SshHost } from "../../ipc"; -import type { PaneColors } from "../theme"; /** * Orchestration context — every piece of shared state and every operation @@ -22,10 +21,6 @@ export interface Orchestration { /** Saved SSH hosts loaded from `hosts.json`. Reactive — changes when the * user edits hosts via {@link openHostManager}. */ hosts: SshHost[]; - /** App-wide default terminal colours. Reactive — edited via the colour - * panel. Each leaf resolves its effective theme from this plus its own - * {@link LeafNode.colorOverride}. */ - globalColors: PaneColors; // Tree mutations split: (leafId: NodeId, orientation: Orientation) => void; @@ -39,15 +34,9 @@ export interface Orchestration { /** Flip the per-pane mcpAllow flag. Default-deny; chip in the pane * toolbar drives this. */ toggleMcpAllow: (leafId: NodeId) => void; - /** Set or clear a leaf's per-pane colour override (undefined → fall back - * to the global theme). */ - setLeafColors: (leafId: NodeId, colors: PaneColors | undefined) => void; // SSH host management openHostManager: () => void; - /** Open the colour panel. When `leafId` is given the panel starts in - * per-pane mode targeting that leaf; otherwise it edits the global theme. */ - openColorPanel: (leafId?: NodeId) => void; // Per-pane orchestration setActive: (leafId: NodeId) => void; @@ -67,36 +56,8 @@ export interface Orchestration { // own quiet-state crosses the threshold; App aggregates so the titlebar // can show an "N idle" count without spamming toast notifications. reportLeafIdle: (leafId: NodeId, idle: boolean) => void; - - // Multi-window pane transfer --------------------------------------------- - /** Pop a pane out of the current workspace into a fresh top-level window. - * The PTY stays alive across the move (the new window's XtermPane - * adopts the existing PaneId; scrollback ring is replayed). */ - moveToNewWindow: (leafId: NodeId) => void; - /** - * Navigate focus from within a pane's key-handler. XtermPane emits the - * intent; LeafPane/App resolve the target leaf and set it active. - * - * `{ kind: "direction", dir }` — move to the spatial neighbour in that - * direction using the same flattenLayout geometry as Ctrl+Shift+Arrow. - * `{ kind: "index", n }` — focus the Nth leaf in DFS (walkLeaves) order, - * 1-indexed, clamped to the leaf count (so Alt+9 with 3 panes picks pane 3). - */ - navigateTo: (intent: NavigateIntent) => void; - /** Returns a PaneId only for leaves that just arrived via a window - * transfer (so LeafPane can pass `existingPaneId` to XtermPane to skip - * the spawn). One-shot — App clears the entry once the pane has - * registered. */ - getInitialPaneIdFor: (leafId: NodeId) => PaneId | undefined; } -/** Discriminated intent emitted by XtermPane's key handler. App resolves - * the actual target leaf from the current tree without XtermPane needing - * to know anything about layout geometry or leaf ordering. */ -export type NavigateIntent = - | { kind: "direction"; dir: Direction } - | { kind: "index"; n: number }; - const OrchestrationContext = createContext(null); export function OrchestrationProvider({ diff --git a/src/lib/layout/tree.test.ts b/src/lib/layout/tree.test.ts index 2d14c60..58a755a 100644 --- a/src/lib/layout/tree.test.ts +++ b/src/lib/layout/tree.test.ts @@ -13,7 +13,6 @@ import { changeLabel, toggleBroadcast, toggleMcpAllow, - setLeafColors, adjustFontSize, adjustAllFontSizes, resolveFontSize, @@ -22,10 +21,6 @@ import { MAX_FONT_SIZE, serialize, deserialize, - serializeWorkspaces, - deserializeWorkspaces, - singletonEnvelope, - WORKSPACES_VERSION, presetSingle, presetTwoColumns, presetThreeColumns, @@ -303,13 +298,12 @@ describe("setLeafShell", () => { expect(next.id).not.toBe(leaf.id); }); - it("preserves label / broadcast / fontSizeOffset / colorOverride across the shell change", () => { + it("preserves label / broadcast / fontSizeOffset across the shell change", () => { const leaf = newLeaf({ distro: "Ubuntu", label: "my pane", broadcast: true, fontSizeOffset: 2, - colorOverride: { background: "#101010" }, }); const next = setLeafShell(leaf, leaf.id, { shellKind: "powershell", @@ -317,7 +311,6 @@ describe("setLeafShell", () => { expect(next.label).toBe("my pane"); expect(next.broadcast).toBe(true); expect(next.fontSizeOffset).toBe(2); - expect(next.colorOverride).toEqual({ background: "#101010" }); }); }); @@ -392,58 +385,6 @@ describe("toggleMcpAllow", () => { }); }); -describe("setLeafColors", () => { - it("sets an override on a leaf with none", () => { - const leaf = newLeaf(); - expect(leaf.colorOverride).toBeUndefined(); - const next = setLeafColors(leaf, leaf.id, { - background: "#001122", - foreground: "#ddeeff", - }) as LeafNode; - expect(next.colorOverride).toEqual({ - background: "#001122", - foreground: "#ddeeff", - }); - }); - - it("replaces an existing override wholesale", () => { - const leaf = newLeaf({ colorOverride: { background: "#000000" } }); - const next = setLeafColors(leaf, leaf.id, { cursor: "#ff0000" }) as LeafNode; - expect(next.colorOverride).toEqual({ cursor: "#ff0000" }); - }); - - it("clears the override when passed undefined", () => { - const leaf = newLeaf({ colorOverride: { background: "#000000" } }); - const next = setLeafColors(leaf, leaf.id, undefined) as LeafNode; - expect(next.colorOverride).toBeUndefined(); - expect("colorOverride" in next).toBe(false); - }); - - it("clears the override when passed an all-undefined object", () => { - const leaf = newLeaf({ colorOverride: { background: "#000000" } }); - const next = setLeafColors(leaf, leaf.id, { - background: undefined, - foreground: undefined, - cursor: undefined, - selection: undefined, - }) as LeafNode; - expect(next.colorOverride).toBeUndefined(); - expect("colorOverride" in next).toBe(false); - }); - - it("returns the same reference when clearing an already-unset override", () => { - const leaf = newLeaf(); - const next = setLeafColors(leaf, leaf.id, undefined); - expect(next).toBe(leaf); - }); - - it("MUST NOT swap the leaf id (metadata-only, no PTY respawn)", () => { - const leaf = newLeaf(); - const next = setLeafColors(leaf, leaf.id, { background: "#123456" }) as LeafNode; - expect(next.id).toBe(leaf.id); - }); -}); - describe("resolveFontSize", () => { it("returns the default when offset is undefined or 0", () => { expect(resolveFontSize(undefined)).toBe(DEFAULT_FONT_SIZE); @@ -724,82 +665,3 @@ describe("serialize / deserialize", () => { expect(back.sshHostId).toBe("h-1"); }); }); - -describe("workspaces envelope", () => { - it("roundtrips a multi-workspace envelope", () => { - const env = { - version: WORKSPACES_VERSION, - workspaces: [ - { id: "w1", name: "alpha", tree: newLeaf({ distro: "Ubuntu" }) }, - { - id: "w2", - name: "beta", - tree: newSplit("h", newLeaf({ label: "left" }), newLeaf()), - }, - ], - }; - const back = deserializeWorkspaces(serializeWorkspaces(env)); - expect(back).toEqual(env); - }); - - it("returns null on invalid JSON", () => { - expect(deserializeWorkspaces("not json")).toBeNull(); - }); - - it("returns null when version is wrong or workspaces is missing", () => { - expect(deserializeWorkspaces('{"version": 99, "workspaces": []}')).toBeNull(); - expect(deserializeWorkspaces('{"version": 2}')).toBeNull(); - }); - - it("returns null when an envelope has zero valid workspaces", () => { - expect( - deserializeWorkspaces('{"version": 2, "workspaces": [{"id": 1}]}'), - ).toBeNull(); - }); - - it("migrates a legacy v1 bare-tree JSON into a single 'Default' workspace", () => { - const legacy = JSON.stringify({ - kind: "split", - id: "s1", - orientation: "h", - ratio: 0.5, - a: { kind: "leaf", id: "a", distro: "Ubuntu" }, - b: { kind: "leaf", id: "b", distro: "PowerShell" }, - }); - const env = deserializeWorkspaces(legacy); - expect(env).not.toBeNull(); - expect(env!.version).toBe(WORKSPACES_VERSION); - expect(env!.workspaces.length).toBe(1); - expect(env!.workspaces[0].name).toBe("Default"); - // Per-leaf legacy migration also applied — PowerShell sentinel mapped. - const tree = env!.workspaces[0].tree as SplitNode; - expect((tree.a as LeafNode).shellKind).toBe("wsl"); - expect((tree.b as LeafNode).shellKind).toBe("powershell"); - expect((tree.b as LeafNode).distro).toBeUndefined(); - }); - - it("singletonEnvelope wraps a tree with a fresh workspace id", () => { - const t = newLeaf({ label: "only" }); - const env = singletonEnvelope(t, "Main"); - expect(env.workspaces.length).toBe(1); - expect(env.workspaces[0].name).toBe("Main"); - expect(env.workspaces[0].tree).toBe(t); - expect(typeof env.workspaces[0].id).toBe("string"); - expect(env.workspaces[0].id).not.toBe(t.id); - }); - - it("skips malformed workspaces but keeps the valid ones", () => { - const env = { - version: WORKSPACES_VERSION, - workspaces: [ - { id: "ok", name: "alpha", tree: { kind: "leaf", id: "L" } }, - { id: 42, name: "bad-id", tree: { kind: "leaf", id: "L2" } }, - { id: "no-tree", name: "still-bad" }, - ], - }; - const back = deserializeWorkspaces(JSON.stringify(env)); - expect(back).not.toBeNull(); - expect(back!.workspaces.length).toBe(1); - expect(back!.workspaces[0].id).toBe("ok"); - }); -}); diff --git a/src/lib/layout/tree.ts b/src/lib/layout/tree.ts index e02904b..ce383cd 100644 --- a/src/lib/layout/tree.ts +++ b/src/lib/layout/tree.ts @@ -5,8 +5,6 @@ //! tmux / i3 / Zellij use — dragging a gutter mutates one parent ratio, //! both sibling subtrees reflow automatically. -import type { PaneColors } from "../theme"; - export type NodeId = string; /** 'h' = side-by-side (a on left, b on right). 'v' = stacked (a on top, b below). */ @@ -46,13 +44,6 @@ export interface LeafNode { * later doesn't require migrating saved workspaces. */ fontSizeOffset?: number; - /** - * Per-pane colour override. Any field set here wins over the app-wide - * global theme (see {@link resolvePaneColors}); unset fields fall through. - * Undefined / empty means "use the global theme". Metadata-only — changing - * it never respawns the PTY. - */ - colorOverride?: PaneColors; /** * If true, this pane is visible to the MCP server (Claude can list it, * read its scrollback, etc.). Default-DENY: when undefined or false, the @@ -120,7 +111,6 @@ export function setLeafShell( label: node.label, broadcast: node.broadcast, fontSizeOffset: node.fontSizeOffset, - colorOverride: node.colorOverride, }; if (spec.shellKind === "wsl") { if (spec.distro !== undefined) base.distro = spec.distro; @@ -304,32 +294,6 @@ export function toggleMcpAllow(root: TreeNode, leafId: NodeId): TreeNode { }); } -/** Set (or clear) a leaf's per-pane colour override. Pass `undefined` or an - * empty object to drop the override so the pane falls back to the global - * theme. Metadata-only — does NOT swap the id, so the PTY keeps running. */ -export function setLeafColors( - root: TreeNode, - leafId: NodeId, - colors: PaneColors | undefined, -): TreeNode { - return replaceById(root, leafId, (node) => { - if (node.kind !== "leaf") return node; - const empty = - !colors || - (colors.background === undefined && - colors.foreground === undefined && - colors.cursor === undefined && - colors.selection === undefined); - if (empty) { - if (node.colorOverride === undefined) return node; - const next: LeafNode = { ...node }; - delete next.colorOverride; - return next; - } - return { ...node, colorOverride: colors }; - }); -} - /** Compute the actual pixel font size from a leaf's offset, clamped to * [MIN_FONT_SIZE, MAX_FONT_SIZE]. */ export function resolveFontSize(offset: number | undefined): number { @@ -419,7 +383,6 @@ export function reshapeToPreset( if (src.label !== undefined) slot.label = src.label; if (src.broadcast !== undefined) slot.broadcast = src.broadcast; if (src.fontSizeOffset !== undefined) slot.fontSizeOffset = src.fontSizeOffset; - if (src.colorOverride !== undefined) slot.colorOverride = src.colorOverride; if (src.mcpAllow !== undefined) slot.mcpAllow = src.mcpAllow; } @@ -740,92 +703,6 @@ export function deserialize(json: string): TreeNode | null { } } -// ---- workspaces envelope --------------------------------------------------- - -/** One named tab in the tab strip. Each workspace owns its own tile tree; - * leaf NodeIds remain globally unique across workspaces so the app-level - * paneIdByLeaf map continues to work without partitioning. */ -export interface Workspace { - id: NodeId; - name: string; - tree: TreeNode; -} - -/** Top-level persistence shape. `version` bumps when the envelope schema - * changes; the v1 shape was a bare TreeNode at the JSON root, migrated - * automatically by {@link deserializeWorkspaces}. */ -export interface WorkspacesEnvelope { - version: 2; - workspaces: Workspace[]; -} - -export const WORKSPACES_VERSION = 2 as const; - -/** Construct an envelope wrapping a single workspace with the given tree. - * Used for first-launch and as the destination of the v1→v2 migration. */ -export function singletonEnvelope(tree: TreeNode, name = "Default"): WorkspacesEnvelope { - return { - version: WORKSPACES_VERSION, - workspaces: [{ id: newId(), name, tree }], - }; -} - -export function serializeWorkspaces(env: WorkspacesEnvelope): string { - return JSON.stringify(env); -} - -/** Parse a persisted workspaces envelope. Accepts: - * - Current shape: `{ version: 2, workspaces: [{ id, name, tree }] }` - * - Legacy v1 shape: a bare {@link TreeNode} — wrapped as one workspace - * named "Default" with a fresh id. - * Per-leaf legacy migrations ({@link migrateLegacyLeaves}) still apply to - * each workspace's tree. Returns null when the JSON is unrecognisable. */ -export function deserializeWorkspaces(json: string): WorkspacesEnvelope | null { - let parsed: unknown; - try { - parsed = JSON.parse(json); - } catch { - return null; - } - - // v1: bare TreeNode at the root - if (isTreeNode(parsed)) { - return singletonEnvelope(migrateLegacyLeaves(parsed)); - } - - // v2: envelope - if ( - typeof parsed === "object" && - parsed !== null && - (parsed as { version?: unknown }).version === WORKSPACES_VERSION && - Array.isArray((parsed as { workspaces?: unknown }).workspaces) - ) { - const raw = (parsed as { workspaces: unknown[] }).workspaces; - const workspaces: Workspace[] = []; - for (const w of raw) { - if ( - typeof w !== "object" || - w === null || - typeof (w as { id?: unknown }).id !== "string" || - typeof (w as { name?: unknown }).name !== "string" || - !isTreeNode((w as { tree?: unknown }).tree) - ) { - continue; - } - const tw = w as { id: string; name: string; tree: TreeNode }; - workspaces.push({ - id: tw.id, - name: tw.name, - tree: migrateLegacyLeaves(tw.tree), - }); - } - if (workspaces.length === 0) return null; - return { version: WORKSPACES_VERSION, workspaces }; - } - - return null; -} - /** Sentinel used in pre-shellKind workspaces to mark PowerShell panes. */ const LEGACY_POWERSHELL_DISTRO = "PowerShell"; diff --git a/src/lib/shortcuts.ts b/src/lib/shortcuts.ts index a72cddb..b3692d1 100644 --- a/src/lib/shortcuts.ts +++ b/src/lib/shortcuts.ts @@ -30,59 +30,13 @@ export const SHORTCUT_SECTIONS: ShortcutSection[] = [ }, ], }, - { - title: "Tabs", - items: [ - { keys: "Ctrl+T", description: "New tab (blank workspace, one pane)" }, - { - keys: "Ctrl+Shift+T", - description: "Close current tab (confirms when the tab has live panes)", - }, - { - keys: "Ctrl+PageDown / Ctrl+PageUp", - description: "Switch to next / previous tab", - }, - { keys: "Ctrl+1 … Ctrl+9", description: "Switch to tab 1 … 9" }, - ], - }, - { - title: "Multi-window", - items: [ - { - keys: "Right-click pane toolbar → Move to new window", - description: - "Pop the active pane into a fresh tiletopia window (PTY survives the move; scrollback ring replays)", - }, - { - keys: "Drag pane toolbar past the window edge", - description: - "Same as the right-click action — release the drag well outside the window to detach into a new window", - }, - ], - }, { title: "Navigation", items: [ { keys: "Ctrl+K", description: "Open jump-to-pane palette" }, { keys: "Ctrl+Shift+← / → / ↑ / ↓", - description: - "Focus neighbour pane in that direction (window-level — works even when no terminal is focused)", - }, - { - keys: "Ctrl+Alt+← / → / ↑ / ↓", - description: - "Focus neighbour pane in that direction (from inside the terminal — intercepted before the PTY sees it)", - }, - { - keys: "Ctrl+Alt+H / J / K / L", - description: - "Same as Ctrl+Alt+Arrow but in Vim-style HJKL order (left / down / up / right)", - }, - { - keys: "Alt+1 … Alt+9", - description: - "Focus the Nth pane in layout order (DFS: left-to-right, top-to-bottom); clamped to pane count. Note: swallows bare Alt+digit — shells using readline digit-argument or vim buffer-jump may conflict.", + description: "Focus neighbour pane in that direction", }, ], }, @@ -116,18 +70,6 @@ export const SHORTCUT_SECTIONS: ShortcutSection[] = [ keys: "Ctrl+Shift+C / Ctrl+Shift+V", description: "Copy selection / paste in terminal", }, - { - keys: "Ctrl+Shift+F", - description: "Open find-in-scrollback bar for the focused pane", - }, - { - keys: "Enter / Shift+Enter", - description: "Next / previous match (while search bar is focused)", - }, - { - keys: "Escape", - description: "Close find bar and return focus to terminal", - }, ], }, { @@ -159,17 +101,13 @@ export const TIPS: TipSpec[] = [ body: "http and https URLs in terminal output get underlined and open in your default browser on click.", }, { - title: "Drag pane headers to swap or detach", - body: "Grab a pane's title bar and drag onto another pane to swap their tree positions. Drag well outside the window edge (more than ~60px past) and release to detach the pane into a new window — same mechanism as the right-click 'Move to new window' action, PTY stays alive.", + title: "Drag pane headers to swap", + body: "Grab a pane's title bar and drag it onto another pane to swap their tree positions. Useful for reorganizing without keyboard.", }, { title: "Workspace persistence", body: "Layout, labels, distro choices, and SSH hosts auto-save to %APPDATA%/com.megaproxy.tiletopia (debounced 500ms). Closed panes don't come back — only the structure is restored, shells spawn fresh on next launch.", }, - { - title: "Tabs (workspaces)", - body: "Each tab is an independent tile layout — useful for keeping one tab per project. PTYs in non-active tabs keep running (a Claude session in tab A keeps going while you work in tab B). New tab starts with one default-shell pane; close confirms when the tab has live panes. Tabs auto-save to the same workspace.json.", - }, { title: "MCP server (let Claude drive the workspace)", body: "Titlebar 🤖 opens the MCP control panel. Start the server, then for Claude Desktop click 'Download .mcpb' and drag the file into Settings → Extensions — zero-config because the bundle reads your bearer token from %APPDATA% at launch (no copy-paste, survives token rotation). For Claude Code (terminal CLI) use the fallback snippet in the panel: it wires npx mcp-remote as a stdio shim because Claude Code's HTTP-MCP client ignores static bearer auth and tries OAuth instead. URL + token persist across restarts; Regenerate the token in the panel if it leaks. Default-deny per pane: toggle 🤖 on each pane's toolbar to expose it to MCP.", diff --git a/src/lib/theme.test.ts b/src/lib/theme.test.ts deleted file mode 100644 index e17c1d5..0000000 --- a/src/lib/theme.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { - resolvePaneColors, - toXtermTheme, - DEFAULT_PANE_COLORS, - COLOR_PRESETS, - type PaneColors, -} from "./theme"; - -describe("resolvePaneColors", () => { - it("falls back to defaults when nothing is set", () => { - expect(resolvePaneColors(undefined, undefined)).toEqual(DEFAULT_PANE_COLORS); - }); - - it("uses global values over defaults", () => { - const global: PaneColors = { background: "#111111", cursor: "#abcdef" }; - const r = resolvePaneColors(global, undefined); - expect(r.background).toBe("#111111"); - expect(r.cursor).toBe("#abcdef"); - // Unset fields still come from defaults. - expect(r.foreground).toBe(DEFAULT_PANE_COLORS.foreground); - expect(r.selection).toBe(DEFAULT_PANE_COLORS.selection); - }); - - it("per-pane override wins over global, field by field", () => { - const global: PaneColors = { background: "#111111", foreground: "#222222" }; - const override: PaneColors = { background: "#999999" }; - const r = resolvePaneColors(global, override); - expect(r.background).toBe("#999999"); // override wins - expect(r.foreground).toBe("#222222"); // inherits global - expect(r.cursor).toBe(DEFAULT_PANE_COLORS.cursor); // inherits default - }); - - it("always returns all four fields defined", () => { - const r = resolvePaneColors({}, {}); - expect(Object.keys(r).sort()).toEqual([ - "background", - "cursor", - "foreground", - "selection", - ]); - }); -}); - -describe("toXtermTheme", () => { - it("maps resolved colours onto the xterm ITheme shape", () => { - const theme = toXtermTheme({ - background: "#0c0c0c", - foreground: "#c5c8c6", - cursor: "#ffffff", - selection: "#3a3a3a", - }); - expect(theme.background).toBe("#0c0c0c"); - expect(theme.foreground).toBe("#c5c8c6"); - expect(theme.cursor).toBe("#ffffff"); - // selection maps to xterm 5.x's renamed property. - expect(theme.selectionBackground).toBe("#3a3a3a"); - // cursorAccent is pinned to the background for block-cursor legibility. - expect(theme.cursorAccent).toBe("#0c0c0c"); - }); - - it("keeps the fixed softened white/brightWhite slice", () => { - const theme = toXtermTheme(DEFAULT_PANE_COLORS); - expect(theme.white).toBe("#c5c8c6"); - expect(theme.brightWhite).toBe("#e0e0e0"); - }); -}); - -describe("COLOR_PRESETS", () => { - it("starts with the tiletopia default and every preset is fully specified", () => { - expect(COLOR_PRESETS[0].name).toBe("Tiletopia Dark"); - expect(COLOR_PRESETS[0].colors).toEqual(DEFAULT_PANE_COLORS); - for (const p of COLOR_PRESETS) { - for (const key of ["background", "foreground", "cursor", "selection"] as const) { - expect(p.colors[key]).toMatch(/^#[0-9a-fA-F]{6}$/); - } - } - }); -}); diff --git a/src/lib/theme.ts b/src/lib/theme.ts deleted file mode 100644 index ca70c3d..0000000 --- a/src/lib/theme.ts +++ /dev/null @@ -1,160 +0,0 @@ -//! Terminal colour theming. -//! -//! tiletopia ships one hard-coded dark palette historically baked into -//! XtermPane. This module turns that into a customisable model: -//! -//! - a GLOBAL default theme (persisted to localStorage, app-wide), and -//! - optional PER-PANE overrides (stored on the LeafNode, persisted with the -//! workspace tree). -//! -//! Only four colours are user-editable — background, foreground, cursor, and -//! selection — the ones that actually move the needle on readability. The -//! rest of xterm's ITheme (the 16-colour ANSI palette, etc.) stays fixed in -//! {@link BASE_XTERM_THEME}: notably `white`/`brightWhite` keep the softened -//! values that tame the Claude TUI's emphasis slots (see XtermPane history). - -import type { ITheme } from "@xterm/xterm"; - -/** The four user-editable colours. All optional: an undefined field on a - * per-pane override falls through to the global default; an undefined field - * on the global default falls through to {@link DEFAULT_PANE_COLORS}. */ -export interface PaneColors { - /** Terminal background. */ - background?: string; - /** Default text colour. */ - foreground?: string; - /** Cursor block colour. */ - cursor?: string; - /** Selection highlight background. */ - selection?: string; -} - -/** Fixed slice of the xterm theme that is NOT user-editable. The softened - * white/brightWhite values date back to the original hard-coded theme — they - * keep the Claude TUI's emphasis text from hitting glaring pure white. */ -const BASE_XTERM_THEME: ITheme = { - white: "#c5c8c6", - brightWhite: "#e0e0e0", -}; - -/** Ground-truth defaults — the historical tiletopia palette. Every editable - * field resolves to one of these when nothing overrides it. Also exposed as - * the first preset ("Tiletopia Dark"). */ -export const DEFAULT_PANE_COLORS: Required = { - background: "#0c0c0c", - foreground: "#c5c8c6", - cursor: "#ffffff", - selection: "#3a3a3a", -}; - -/** A named, ready-to-apply colour set shown as a one-click starting point in - * the colour panel. */ -export interface ColorPreset { - name: string; - colors: Required; -} - -/** Built-in presets. The first is the tiletopia default; the rest are - * well-known community palettes (background/foreground/cursor/selection - * only — the ANSI ramp is left to {@link BASE_XTERM_THEME}). */ -export const COLOR_PRESETS: ColorPreset[] = [ - { name: "Tiletopia Dark", colors: DEFAULT_PANE_COLORS }, - { - name: "Solarized Dark", - colors: { background: "#002b36", foreground: "#839496", cursor: "#93a1a1", selection: "#073642" }, - }, - { - name: "Gruvbox Dark", - colors: { background: "#282828", foreground: "#ebdbb2", cursor: "#ebdbb2", selection: "#504945" }, - }, - { - name: "Dracula", - colors: { background: "#282a36", foreground: "#f8f8f2", cursor: "#f8f8f2", selection: "#44475a" }, - }, - { - name: "Nord", - colors: { background: "#2e3440", foreground: "#d8dee9", cursor: "#d8dee9", selection: "#434c5e" }, - }, - { - name: "Light", - colors: { background: "#fafafa", foreground: "#1c1c1c", cursor: "#1c1c1c", selection: "#cfe0ff" }, - }, -]; - -/** Merge a per-pane override on top of the global default, then fill any - * still-missing field from {@link DEFAULT_PANE_COLORS}. The result always - * has all four fields defined. */ -export function resolvePaneColors( - global: PaneColors | undefined, - override: PaneColors | undefined, -): Required { - return { - background: - override?.background ?? global?.background ?? DEFAULT_PANE_COLORS.background, - foreground: - override?.foreground ?? global?.foreground ?? DEFAULT_PANE_COLORS.foreground, - cursor: override?.cursor ?? global?.cursor ?? DEFAULT_PANE_COLORS.cursor, - selection: - override?.selection ?? global?.selection ?? DEFAULT_PANE_COLORS.selection, - }; -} - -/** Build a full xterm ITheme from resolved colours. cursorAccent is pinned to - * the background so a block cursor's glyph stays readable. */ -export function toXtermTheme(colors: Required): ITheme { - return { - ...BASE_XTERM_THEME, - background: colors.background, - foreground: colors.foreground, - cursor: colors.cursor, - cursorAccent: colors.background, - selectionBackground: colors.selection, - }; -} - -// --------------------------------------------------------------------------- -// Global-default persistence (localStorage; frontend-only, no backend hop). -// localStorage is shared across all windows of the same origin, so a new -// window picks up the saved theme at startup, and the `storage` event lets -// open windows react live (see App's listener). -// --------------------------------------------------------------------------- - -export const GLOBAL_COLORS_STORAGE_KEY = "tiletopia.globalColors.v1"; - -/** #rgb / #rrggbb hex validator — what `` emits and what - * xterm accepts. We reject anything else so a corrupt localStorage value - * can't poison the theme. */ -const HEX_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/; - -function sanitizeColors(raw: unknown): PaneColors { - if (typeof raw !== "object" || raw === null) return {}; - const o = raw as Record; - const out: PaneColors = {}; - for (const key of ["background", "foreground", "cursor", "selection"] as const) { - const v = o[key]; - if (typeof v === "string" && HEX_RE.test(v)) out[key] = v; - } - return out; -} - -/** Read the saved global theme. Returns {} (→ all defaults) when absent or - * unparseable. */ -export function loadGlobalColors(): PaneColors { - try { - const raw = localStorage.getItem(GLOBAL_COLORS_STORAGE_KEY); - if (!raw) return {}; - return sanitizeColors(JSON.parse(raw)); - } catch { - return {}; - } -} - -/** Persist the global theme. Empty object is stored as-is (means "all - * defaults"), keeping the round-trip lossless. */ -export function saveGlobalColors(colors: PaneColors): void { - try { - localStorage.setItem(GLOBAL_COLORS_STORAGE_KEY, JSON.stringify(colors)); - } catch (e) { - console.warn("saveGlobalColors failed:", e); - } -} diff --git a/src/styles.css b/src/styles.css index 78989e6..51390ff 100644 --- a/src/styles.css +++ b/src/styles.css @@ -38,31 +38,28 @@ body { .xterm { height: 100%; } .xterm-viewport { background: #0c0c0c !important; } -/* Themed scrollbars — Chromium pseudo-elements (WebView2 supports these). - Applied globally so every scroll container (tab strip, panels, menus, - xterm viewport) matches the dark theme instead of falling back to the - native WebView2 scrollbar. */ -*::-webkit-scrollbar { +/* Themed scrollbars — Chromium pseudo-elements (WebView2 supports these). */ +.xterm-viewport::-webkit-scrollbar { width: 8px; height: 8px; } -*::-webkit-scrollbar-track { +.xterm-viewport::-webkit-scrollbar-track { background: transparent; } -*::-webkit-scrollbar-thumb { +.xterm-viewport::-webkit-scrollbar-thumb { background: #2a2a2a; border-radius: 4px; border: 1px solid #1a1a1a; } -*::-webkit-scrollbar-thumb:hover { +.xterm-viewport::-webkit-scrollbar-thumb:hover { background: #3a3a3a; } -*::-webkit-scrollbar-corner { +.xterm-viewport::-webkit-scrollbar-corner { background: transparent; } /* Firefox fallback (and the new spec) — not strictly needed in WebView2 but free-and-correct. */ -* { +.xterm-viewport { scrollbar-width: thin; scrollbar-color: #2a2a2a transparent; }