Compare commits
45 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 738fa2e901 | |||
| a72b2c3ff4 | |||
| 8c6aded5d8 | |||
| ca97fb3733 | |||
| 7e624a3f96 | |||
| 1febf2e096 | |||
| 9144ba64b6 | |||
| 8b5f65a14a | |||
| cd5500671a | |||
| 00a1e24ecf | |||
| 15c2842ce1 | |||
| a1d7919537 | |||
| bbe827af22 | |||
| 50766c3fdd | |||
| c01a4decbf | |||
| 0358128b24 | |||
| 02d97d1520 | |||
| d776f962da | |||
| 24ab7f067f | |||
| 20b60661cb | |||
| 5f8e9f92c5 | |||
| d951c360ae | |||
| b23f3d1ecb | |||
| ebbf8db407 | |||
| e3c3810ba0 | |||
| e30ac461af | |||
| 1df8c3181b | |||
| a6d3f8a9f9 | |||
| 1bbc6a5783 | |||
| baa00dfc5c | |||
| 8bb080345e | |||
| b5db68da8b | |||
| 07bba99eb5 | |||
| df159056a1 | |||
| 5ef35e3a74 | |||
| 2a1f1d41ad | |||
| 309b6024d4 | |||
| e6d0040021 | |||
| bea6cf2977 | |||
| 681d15fdc3 | |||
| 597f9ac9b7 | |||
| 6faf7e5e19 | |||
| 8ad51787fc | |||
| 1a035ad0a6 | |||
| c92847413b |
31 changed files with 3785 additions and 146 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -29,4 +29,4 @@ src-tauri/gen/
|
|||
/shot*.png
|
||||
/tiletopia-window.png
|
||||
/tilescript.ps1
|
||||
/cargo-test.log
|
||||
/cargo-test.lo*
|
||||
|
|
|
|||
29
README.md
29
README.md
|
|
@ -3,6 +3,8 @@
|
|||
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)
|
||||
|
|
@ -38,12 +40,31 @@ 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 |
|
||||
| `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. |
|
||||
|
||||
**Broadcast**
|
||||
|
||||
|
|
@ -64,6 +85,9 @@ 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**
|
||||
|
||||
|
|
@ -77,8 +101,9 @@ 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** — Grab a pane's title bar and drag it onto another pane to swap their tree positions. Useful for reorganizing without keyboard.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
<!-- SHORTCUTS:END -->
|
||||
|
|
|
|||
240
memory.md
240
memory.md
|
|
@ -34,10 +34,11 @@ 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.
|
||||
- [ ] **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] ~~**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).
|
||||
- [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:
|
||||
|
|
@ -50,8 +51,245 @@ 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\<distro>` 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/<pid>/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<PaneColors>` 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: <legacy> }]`. 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<WorkspaceId, NodeId | null>` 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<HashMap<PaneId, u32>>`. `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<HashMap<String, Vec<Value>>>, save_task: Mutex<Option<JoinHandle>> }`. 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: [<all from all windows>] }`. **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<NodeId, PaneId>`** 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<WindowsState>` and `Arc<PendingInits>` 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<Self>` 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-<micros>` (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 `<body>`, `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 ▸ <pick target>" 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.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "tiletopia",
|
||||
"private": true,
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
|
@ -18,7 +18,10 @@
|
|||
"@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",
|
||||
|
|
|
|||
36
pnpm-lock.yaml
generated
36
pnpm-lock.yaml
generated
|
|
@ -17,9 +17,18 @@ 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
|
||||
|
|
@ -584,11 +593,26 @@ 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==}
|
||||
|
||||
|
|
@ -1286,10 +1310,22 @@ 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': {}
|
||||
|
|
|
|||
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
|
|
@ -4221,7 +4221,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "tiletopia"
|
||||
version = "0.3.0"
|
||||
version = "0.4.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "tiletopia"
|
||||
version = "0.3.0"
|
||||
version = "0.4.1"
|
||||
description = "Tiling multi-terminal manager for WSL"
|
||||
authors = ["megaproxy"]
|
||||
edition = "2021"
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Default capability set for wsl-mux spike",
|
||||
"windows": ["main"],
|
||||
"windows": ["main", "pane-window-*"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:event:default",
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
|
||||
use tauri::{AppHandle, Manager};
|
||||
use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::creds;
|
||||
|
|
@ -11,6 +11,7 @@ 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";
|
||||
|
||||
|
|
@ -62,6 +63,165 @@ 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<PtyManager>>,
|
||||
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<PtyManager>>,
|
||||
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<PtyManager>>,
|
||||
id: PaneId,
|
||||
) -> Result<String, String> {
|
||||
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<PendingInits>>,
|
||||
payload: PendingInit,
|
||||
) -> Result<String, String> {
|
||||
// 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<PendingInits>>,
|
||||
label: String,
|
||||
) -> Result<Option<PendingInit>, 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<WindowsState>>,
|
||||
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<WindowsState> = (*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<f64>, Option<f64>, 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.
|
||||
|
|
|
|||
|
|
@ -6,11 +6,18 @@ 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()
|
||||
|
|
@ -40,6 +47,15 @@ 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<PendingActions> = 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<WindowsState> = 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<PendingInits> = 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())
|
||||
|
|
@ -48,12 +64,56 @@ 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<String> = 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,
|
||||
|
|
@ -71,6 +131,28 @@ pub fn run() {
|
|||
commands::mcp_policy_save,
|
||||
commands::mcp_hard_deny_labels,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
.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<String> =
|
||||
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();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -109,6 +109,16 @@ struct PaneHandle {
|
|||
pub struct PtyManager {
|
||||
panes: Mutex<HashMap<PaneId, PaneHandle>>,
|
||||
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<HashMap<PaneId, u32>>,
|
||||
}
|
||||
|
||||
impl PtyManager {
|
||||
|
|
@ -116,6 +126,27 @@ 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -258,6 +289,14 @@ 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
|
||||
|
|
|
|||
151
src-tauri/src/window_state.rs
Normal file
151
src-tauri/src/window_state.rs
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
//! 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<HashMap<String, Vec<Value>>>,
|
||||
save_task: Mutex<Option<JoinHandle<()>>>,
|
||||
}
|
||||
|
||||
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<Self>,
|
||||
app: AppHandle,
|
||||
label: String,
|
||||
workspaces: Vec<Value>,
|
||||
) {
|
||||
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<Self>, 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<Value> =
|
||||
map.get(MAIN_WINDOW_LABEL).cloned().unwrap_or_default();
|
||||
serde_json::json!({
|
||||
"version": 2,
|
||||
"workspaces": workspaces,
|
||||
})
|
||||
}
|
||||
|
||||
fn schedule_save(self: &Arc<Self>, 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<HashMap<String, PendingInit>>,
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "tiletopia",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.1",
|
||||
"identifier": "com.megaproxy.tiletopia",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
|
|
|
|||
628
src/App.tsx
628
src/App.tsx
|
|
@ -18,6 +18,11 @@ import {
|
|||
mcpPolicySave,
|
||||
writeToPane,
|
||||
killPane,
|
||||
markPaneTransferring,
|
||||
claimPane,
|
||||
createPaneWindow,
|
||||
takePendingWindowInit,
|
||||
pushWindowWorkspaces,
|
||||
type PaneId,
|
||||
type SpawnSpec,
|
||||
type SshHost,
|
||||
|
|
@ -29,12 +34,37 @@ 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<Awaited<ReturnType<typeof takePendingWindowInit>>> | 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,
|
||||
|
|
@ -47,6 +77,7 @@ import {
|
|||
changeLabel,
|
||||
toggleBroadcast as toggleBroadcastInTree,
|
||||
toggleMcpAllow as toggleMcpAllowInTree,
|
||||
setLeafColors as setLeafColorsInTree,
|
||||
setAllBroadcast,
|
||||
adjustFontSize,
|
||||
adjustAllFontSizes,
|
||||
|
|
@ -59,14 +90,16 @@ import {
|
|||
MIN_PANE_PX,
|
||||
type Direction,
|
||||
serialize,
|
||||
deserialize,
|
||||
serializeWorkspaces,
|
||||
deserializeWorkspaces,
|
||||
singletonEnvelope,
|
||||
presetSingle,
|
||||
presetTwoColumns,
|
||||
presetThreeColumns,
|
||||
presetTwoRows,
|
||||
presetTwoByTwo,
|
||||
} from "./lib/layout/tree";
|
||||
import { OrchestrationProvider, type Orchestration } from "./lib/layout/orchestration";
|
||||
import { OrchestrationProvider, type Orchestration, type NavigateIntent } from "./lib/layout/orchestration";
|
||||
import LeafPane from "./lib/layout/LeafPane";
|
||||
import Gutter from "./lib/layout/Gutter";
|
||||
import Notifications, { type Toast } from "./components/Notifications";
|
||||
|
|
@ -74,12 +107,19 @@ 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. */
|
||||
|
|
@ -114,8 +154,86 @@ function describeSpec(spec: SpawnSpec): string {
|
|||
|
||||
export default function App() {
|
||||
// ---- top-level state -----------------------------------------------------
|
||||
const [tree, setTree] = useState<TreeNode>(() => newLeaf());
|
||||
const [activeLeafId, setActiveLeafId] = useState<NodeId | null>(null);
|
||||
// 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<Workspace[]>(() => {
|
||||
const t = newLeaf();
|
||||
return [{ id: newId(), name: "Default", tree: t }];
|
||||
});
|
||||
const [currentWorkspaceId, setCurrentWorkspaceId] = useState<NodeId>(
|
||||
() => "", // 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<NodeId, NodeId | null>
|
||||
>(() => 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<React.Dispatch<React.SetStateAction<TreeNode>>>(
|
||||
(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<React.SetStateAction<NodeId | null>>
|
||||
>(
|
||||
(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 [distros, setDistros] = useState<string[]>([]);
|
||||
const [defaultShell, setDefaultShell] = useState<DefaultShell>({
|
||||
shellKind: "wsl",
|
||||
|
|
@ -129,6 +247,15 @@ 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<PaneColors>(() =>
|
||||
loadGlobalColors(),
|
||||
);
|
||||
const [colorPanelOpen, setColorPanelOpen] = useState(false);
|
||||
const [colorPanelMode, setColorPanelMode] = useState<"global" | "pane">(
|
||||
"global",
|
||||
);
|
||||
const [ready, setReady] = useState(false);
|
||||
const [notifications, setNotifications] = useState<Toast[]>([]);
|
||||
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||
|
|
@ -138,34 +265,85 @@ export default function App() {
|
|||
// ---- non-reactive lookups -----------------------------------------------
|
||||
const paneIdByLeafRef = useRef<Map<NodeId, PaneId>>(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<Map<NodeId, PaneId>>(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 () => {
|
||||
let loaded: TreeNode | null = null;
|
||||
// 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<typeof deserializeWorkspaces> = null;
|
||||
let adoptedLeafId: NodeId | null = null;
|
||||
|
||||
if (!IS_MAIN_WINDOW) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("takePendingWindowInit failed:", e);
|
||||
}
|
||||
} else {
|
||||
// Main window: load workspace.json (and legacy fallback).
|
||||
try {
|
||||
const json = await loadWorkspace();
|
||||
if (json) loaded = deserialize(json);
|
||||
if (json) initialEnvelope = deserializeWorkspaces(json);
|
||||
} catch (e) {
|
||||
console.warn("loadWorkspace failed:", e);
|
||||
}
|
||||
if (!loaded) {
|
||||
if (!initialEnvelope) {
|
||||
try {
|
||||
const legacy = localStorage.getItem(LEGACY_STORAGE_KEY);
|
||||
if (legacy) {
|
||||
loaded = deserialize(legacy);
|
||||
if (loaded) void saveWorkspace(legacy);
|
||||
initialEnvelope = deserializeWorkspaces(legacy);
|
||||
if (initialEnvelope) {
|
||||
void saveWorkspace(serializeWorkspaces(initialEnvelope));
|
||||
}
|
||||
localStorage.removeItem(LEGACY_STORAGE_KEY);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("legacy localStorage migration failed:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let resolvedDistros: string[] = [];
|
||||
try {
|
||||
|
|
@ -190,13 +368,26 @@ export default function App() {
|
|||
})();
|
||||
|
||||
if (cancelled) return;
|
||||
if (loaded) {
|
||||
if (initialDefault.shellKind === "wsl" && initialDefault.distro) {
|
||||
backfillWslDistro(loaded, initialDefault.distro);
|
||||
|
||||
let envelope = initialEnvelope;
|
||||
if (!envelope) {
|
||||
envelope = singletonEnvelope(
|
||||
newLeaf(defaultShellAsLeafProps(initialDefault)),
|
||||
);
|
||||
}
|
||||
setTree(loaded);
|
||||
} else {
|
||||
setTree(newLeaf(defaultShellAsLeafProps(initialDefault)));
|
||||
if (initialDefault.shellKind === "wsl" && initialDefault.distro) {
|
||||
for (const w of envelope.workspaces) {
|
||||
backfillWslDistro(w.tree, 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;
|
||||
});
|
||||
}
|
||||
setDistros(resolvedDistros);
|
||||
setHosts(resolvedHosts);
|
||||
|
|
@ -208,31 +399,39 @@ export default function App() {
|
|||
};
|
||||
}, []);
|
||||
|
||||
// ---- debounced save ------------------------------------------------------
|
||||
// ---- 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.
|
||||
useEffect(() => {
|
||||
if (!ready) return;
|
||||
const id = window.setTimeout(() => {
|
||||
saveWorkspace(serialize(tree)).catch((e) =>
|
||||
console.warn("saveWorkspace failed:", e),
|
||||
pushWindowWorkspaces(CURRENT_WINDOW_LABEL, JSON.stringify(workspaces)).catch(
|
||||
(e) => console.warn("pushWindowWorkspaces failed:", e),
|
||||
);
|
||||
}, SAVE_DEBOUNCE_MS);
|
||||
return () => clearTimeout(id);
|
||||
}, [tree, ready]);
|
||||
}, [workspaces, 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]");
|
||||
const id = leafEl?.getAttribute("data-leaf-id") ?? null;
|
||||
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");
|
||||
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
|
||||
|
|
@ -364,6 +563,95 @@ 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));
|
||||
}, []);
|
||||
|
|
@ -376,6 +664,34 @@ 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 {
|
||||
|
|
@ -446,6 +762,36 @@ 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), []);
|
||||
|
||||
|
|
@ -551,6 +897,7 @@ export default function App() {
|
|||
return;
|
||||
}
|
||||
|
||||
|
||||
// Ctrl+Shift+Alt+B — global broadcast all/none
|
||||
if (ctrl && shift && alt && key === "b") {
|
||||
e.preventDefault();
|
||||
|
|
@ -583,6 +930,65 @@ 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;
|
||||
|
||||
|
|
@ -629,7 +1035,7 @@ export default function App() {
|
|||
|
||||
window.addEventListener("keydown", onKey, true);
|
||||
return () => window.removeEventListener("keydown", onKey, true);
|
||||
}, [split, close, toggleBroadcast, promoteActive]);
|
||||
}, [split, close, toggleBroadcast, promoteActive, createTab, closeTab, switchTab]);
|
||||
|
||||
// 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
|
||||
|
|
@ -646,6 +1052,9 @@ 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);
|
||||
|
|
@ -655,6 +1064,80 @@ 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).
|
||||
|
|
@ -827,14 +1310,18 @@ export default function App() {
|
|||
activeLeafId,
|
||||
distros,
|
||||
hosts,
|
||||
globalColors,
|
||||
split,
|
||||
close,
|
||||
setShell,
|
||||
setLabel,
|
||||
toggleBroadcast,
|
||||
toggleMcpAllow,
|
||||
setLeafColors,
|
||||
openHostManager,
|
||||
openColorPanel,
|
||||
setActive,
|
||||
navigateTo,
|
||||
registerPaneId,
|
||||
broadcastFrom,
|
||||
notify,
|
||||
|
|
@ -844,19 +1331,25 @@ 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,
|
||||
|
|
@ -866,6 +1359,8 @@ export default function App() {
|
|||
setHeaderDragOver,
|
||||
endHeaderDrag,
|
||||
reportLeafIdle,
|
||||
moveToNewWindow,
|
||||
getInitialPaneIdFor,
|
||||
],
|
||||
);
|
||||
|
||||
|
|
@ -873,11 +1368,18 @@ 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<string, McpMirroredLeaf> = {};
|
||||
for (const leaf of walkLeaves(tree)) {
|
||||
|
|
@ -1337,6 +1839,10 @@ 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) => {
|
||||
|
|
@ -1408,11 +1914,11 @@ export default function App() {
|
|||
[paletteOpen, tree],
|
||||
);
|
||||
|
||||
// ---- 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]);
|
||||
// ---- 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).
|
||||
const paneWrapRef = useRef<HTMLDivElement>(null);
|
||||
const onGutterRatio = useCallback((splitId: NodeId, ratio: number) => {
|
||||
setTree((t) => updateSplitRatio(t, splitId, ratio));
|
||||
|
|
@ -1631,6 +2137,14 @@ export default function App() {
|
|||
>
|
||||
🤖
|
||||
</button>
|
||||
<button
|
||||
className="palette-btn"
|
||||
onClick={() => openColorPanel()}
|
||||
title="Terminal colours (global theme + per-pane overrides)"
|
||||
aria-label="Terminal colours"
|
||||
>
|
||||
🎨
|
||||
</button>
|
||||
<button
|
||||
className="palette-btn"
|
||||
onClick={() => setHelpOpen(true)}
|
||||
|
|
@ -1651,10 +2165,36 @@ export default function App() {
|
|||
</span>
|
||||
</header>
|
||||
|
||||
<TabStrip
|
||||
workspaces={workspaces}
|
||||
currentWorkspaceId={currentWorkspaceId}
|
||||
onSwitch={switchTab}
|
||||
onCreate={createTab}
|
||||
onClose={closeTab}
|
||||
onRename={renameTab}
|
||||
/>
|
||||
|
||||
<div className="pane-wrap" ref={paneWrapRef}>
|
||||
{ready && (
|
||||
<OrchestrationProvider value={orch}>
|
||||
{layout.leaves.map(({ leaf, box }) => (
|
||||
{workspaces.map((ws) => {
|
||||
const wsLayout = flattenLayout(ws.tree);
|
||||
const isCurrent = ws.id === currentWorkspaceId;
|
||||
return (
|
||||
<div
|
||||
key={ws.id}
|
||||
className={`workspace-layer${isCurrent ? " active" : ""}`}
|
||||
data-workspace-id={ws.id}
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
visibility: isCurrent ? "visible" : "hidden",
|
||||
pointerEvents: isCurrent ? "auto" : "none",
|
||||
zIndex: isCurrent ? 1 : 0,
|
||||
}}
|
||||
aria-hidden={isCurrent ? "false" : "true"}
|
||||
>
|
||||
{wsLayout.leaves.map(({ leaf, box }) => (
|
||||
<div
|
||||
key={leaf.id}
|
||||
className="leaf-slot"
|
||||
|
|
@ -1669,7 +2209,8 @@ export default function App() {
|
|||
<LeafPane leaf={leaf} />
|
||||
</div>
|
||||
))}
|
||||
{layout.gutters.map((g) => (
|
||||
{isCurrent &&
|
||||
wsLayout.gutters.map((g) => (
|
||||
<Gutter
|
||||
key={g.splitId}
|
||||
info={g}
|
||||
|
|
@ -1677,6 +2218,9 @@ export default function App() {
|
|||
onRatioChange={onGutterRatio}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</OrchestrationProvider>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -1721,6 +2265,24 @@ export default function App() {
|
|||
/>
|
||||
)}
|
||||
|
||||
{colorPanelOpen && (() => {
|
||||
const activeLeaf = activeLeafId ? findLeaf(tree, activeLeafId) : null;
|
||||
return (
|
||||
<ColorPanel
|
||||
globalColors={globalColors}
|
||||
onChangeGlobal={setGlobalColors}
|
||||
activeLeafId={activeLeaf ? activeLeafId : null}
|
||||
activeLeafLabel={activeLeaf?.label}
|
||||
activeOverride={activeLeaf?.colorOverride}
|
||||
onChangeActive={(colors) => {
|
||||
if (activeLeafId) setLeafColors(activeLeafId, colors);
|
||||
}}
|
||||
initialMode={colorPanelMode}
|
||||
onClose={() => setColorPanelOpen(false)}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
||||
{confirmQueue.length > 0 && (
|
||||
<McpConfirm
|
||||
spec={confirmQueue[0]}
|
||||
|
|
|
|||
203
src/components/ColorPanel.css
Normal file
203
src/components/ColorPanel.css
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
.color-panel {
|
||||
position: fixed;
|
||||
top: 8vh;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: min(520px, 92vw);
|
||||
max-height: 84vh;
|
||||
background: #161616;
|
||||
color: #ccc;
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
|
||||
}
|
||||
|
||||
.color-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
}
|
||||
.color-title { font-weight: 600; font-size: 13px; }
|
||||
.color-close {
|
||||
background: transparent; border: none; color: #888;
|
||||
font-size: 18px; line-height: 1; padding: 2px 8px;
|
||||
cursor: pointer; border-radius: 3px;
|
||||
}
|
||||
.color-close:hover { background: #2a2a2a; color: #ddd; }
|
||||
|
||||
/* ---- Mode toggle -------------------------------------------------------- */
|
||||
|
||||
.color-modes {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
padding: 0 10px;
|
||||
}
|
||||
.color-mode {
|
||||
position: relative;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.04em;
|
||||
background: transparent;
|
||||
color: #777;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
padding: 7px 12px 5px;
|
||||
cursor: pointer;
|
||||
transition: color 0.1s, border-color 0.1s;
|
||||
}
|
||||
.color-mode:hover:not(:disabled) { color: #bbb; }
|
||||
.color-mode:disabled { color: #555; cursor: default; }
|
||||
.color-mode--active {
|
||||
color: #cce6ff;
|
||||
border-bottom-color: #4488cc;
|
||||
}
|
||||
|
||||
/* ---- Body --------------------------------------------------------------- */
|
||||
|
||||
.color-body { padding: 14px 18px; overflow-y: auto; }
|
||||
.color-blurb { margin: 0 0 14px; font-size: 11px; line-height: 1.5; color: #999; }
|
||||
|
||||
/* ---- Colour rows -------------------------------------------------------- */
|
||||
|
||||
.color-rows { display: flex; flex-direction: column; gap: 8px; }
|
||||
.color-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.color-row-label {
|
||||
flex: 0 0 90px;
|
||||
font-size: 12px;
|
||||
color: #bbb;
|
||||
}
|
||||
.color-swatch {
|
||||
flex: 0 0 auto;
|
||||
width: 34px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
.color-swatch::-webkit-color-swatch-wrapper { padding: 2px; }
|
||||
.color-swatch::-webkit-color-swatch { border: none; border-radius: 2px; }
|
||||
.color-hex {
|
||||
flex: 0 0 96px;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
background: #0e0e0e;
|
||||
color: #ddd;
|
||||
border: 1px solid #333;
|
||||
border-radius: 4px;
|
||||
padding: 5px 8px;
|
||||
}
|
||||
.color-hex:focus { outline: none; border-color: #4488cc; }
|
||||
.color-inherit-tag {
|
||||
font-size: 10px;
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
}
|
||||
.color-clear-field {
|
||||
background: transparent;
|
||||
border: 1px solid #333;
|
||||
color: #888;
|
||||
border-radius: 4px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
.color-clear-field:hover { background: #2a2a2a; color: #ddd; }
|
||||
|
||||
/* ---- Live preview ------------------------------------------------------- */
|
||||
|
||||
.color-preview {
|
||||
margin: 16px 0;
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
overflow: hidden;
|
||||
}
|
||||
.color-preview-line { white-space: pre; }
|
||||
.color-preview-prompt { font-weight: 600; opacity: 0.85; }
|
||||
.color-preview-cursor {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 14px;
|
||||
margin-left: 2px;
|
||||
vertical-align: text-bottom;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
/* ---- Presets ------------------------------------------------------------ */
|
||||
|
||||
.color-presets { margin-top: 4px; }
|
||||
.color-presets-label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.color-presets-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.color-preset {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
color: #bbb;
|
||||
background: #1d1d1d;
|
||||
border: 1px solid #333;
|
||||
border-radius: 5px;
|
||||
padding: 5px 9px 5px 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.color-preset:hover { border-color: #4488cc; color: #eee; }
|
||||
.color-preset-swatch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 20px;
|
||||
border: 1px solid;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ---- Actions ------------------------------------------------------------ */
|
||||
|
||||
.color-actions {
|
||||
margin-top: 18px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.color-reset {
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
color: #cbb;
|
||||
background: transparent;
|
||||
border: 1px solid #443;
|
||||
border-radius: 5px;
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.color-reset:hover { background: #2a2420; color: #eed; border-color: #665; }
|
||||
258
src/components/ColorPanel.tsx
Normal file
258
src/components/ColorPanel.tsx
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import type { NodeId } from "../lib/layout/tree";
|
||||
import {
|
||||
type PaneColors,
|
||||
COLOR_PRESETS,
|
||||
resolvePaneColors,
|
||||
} from "../lib/theme";
|
||||
import "./ColorPanel.css";
|
||||
|
||||
interface ColorPanelProps {
|
||||
/** App-wide default theme. */
|
||||
globalColors: PaneColors;
|
||||
/** Persist a new global theme (pass {} to reset to built-in defaults). */
|
||||
onChangeGlobal: (colors: PaneColors) => void;
|
||||
/** Active pane being targeted in per-pane mode (null → only global mode
|
||||
* is available). */
|
||||
activeLeafId: NodeId | null;
|
||||
/** Human label for the active pane, shown in the mode toggle. */
|
||||
activeLeafLabel?: string;
|
||||
/** The active pane's current override (undefined → fully inherits global). */
|
||||
activeOverride: PaneColors | undefined;
|
||||
/** Persist the active pane's override (undefined → clear it). */
|
||||
onChangeActive: (colors: PaneColors | undefined) => void;
|
||||
/** Which target the panel opens on. */
|
||||
initialMode: "global" | "pane";
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type Mode = "global" | "pane";
|
||||
|
||||
const FIELDS: { key: keyof PaneColors; label: string }[] = [
|
||||
{ key: "background", label: "Background" },
|
||||
{ key: "foreground", label: "Foreground" },
|
||||
{ key: "cursor", label: "Cursor" },
|
||||
{ key: "selection", label: "Selection" },
|
||||
];
|
||||
|
||||
const HEX_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
|
||||
|
||||
/** Expand #rgb → #rrggbb so `<input type="color">` (which only accepts the
|
||||
* 6-digit form) always gets a valid value. */
|
||||
function expandHex(hex: string): string {
|
||||
if (/^#[0-9a-fA-F]{3}$/.test(hex)) {
|
||||
return "#" + hex.slice(1).split("").map((c) => c + c).join("");
|
||||
}
|
||||
return hex;
|
||||
}
|
||||
|
||||
export default function ColorPanel({
|
||||
globalColors,
|
||||
onChangeGlobal,
|
||||
activeLeafId,
|
||||
activeLeafLabel,
|
||||
activeOverride,
|
||||
onChangeActive,
|
||||
initialMode,
|
||||
onClose,
|
||||
}: ColorPanelProps) {
|
||||
// Fall back to global mode if asked for per-pane with no active pane.
|
||||
const [mode, setMode] = useState<Mode>(
|
||||
initialMode === "pane" && activeLeafId ? "pane" : "global",
|
||||
);
|
||||
const paneMode = mode === "pane" && !!activeLeafId;
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
|
||||
// The override layer we're editing: the leaf's override in pane mode, or
|
||||
// the global theme itself in global mode. `resolved` fills every field so
|
||||
// the swatches/preview always show a concrete colour.
|
||||
const editLayer: PaneColors = paneMode ? (activeOverride ?? {}) : globalColors;
|
||||
const resolved = paneMode
|
||||
? resolvePaneColors(globalColors, activeOverride)
|
||||
: resolvePaneColors(globalColors, undefined);
|
||||
|
||||
/** Whether a field is explicitly set on the layer we're editing (vs.
|
||||
* inherited). Only meaningful in pane mode for the "inherited" hint. */
|
||||
const isSet = (key: keyof PaneColors) => editLayer[key] !== undefined;
|
||||
|
||||
function setField(key: keyof PaneColors, value: string) {
|
||||
const next: PaneColors = { ...editLayer, [key]: value };
|
||||
if (paneMode) onChangeActive(next);
|
||||
else onChangeGlobal(next);
|
||||
}
|
||||
|
||||
/** Pane mode only: drop one field's override so it re-inherits the global. */
|
||||
function clearField(key: keyof PaneColors) {
|
||||
if (!paneMode) return;
|
||||
const next: PaneColors = { ...editLayer };
|
||||
delete next[key];
|
||||
onChangeActive(next);
|
||||
}
|
||||
|
||||
function applyPreset(colors: PaneColors) {
|
||||
if (paneMode) onChangeActive({ ...colors });
|
||||
else onChangeGlobal({ ...colors });
|
||||
}
|
||||
|
||||
function resetAll() {
|
||||
if (paneMode) onChangeActive(undefined);
|
||||
else onChangeGlobal({});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button className="backdrop" onClick={onClose} aria-label="Close" />
|
||||
<div className="color-panel" role="dialog" aria-label="Terminal colours">
|
||||
<header className="color-header">
|
||||
<span className="color-title">Terminal colours</span>
|
||||
<button className="color-close" onClick={onClose} aria-label="Close">
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{/* Target toggle: edit the global default or just the active pane. */}
|
||||
<div className="color-modes" role="tablist">
|
||||
<button
|
||||
className={`color-mode${mode === "global" ? " color-mode--active" : ""}`}
|
||||
role="tab"
|
||||
aria-selected={mode === "global"}
|
||||
onClick={() => setMode("global")}
|
||||
>
|
||||
Global default
|
||||
</button>
|
||||
<button
|
||||
className={`color-mode${paneMode ? " color-mode--active" : ""}`}
|
||||
role="tab"
|
||||
aria-selected={paneMode}
|
||||
disabled={!activeLeafId}
|
||||
onClick={() => setMode("pane")}
|
||||
title={
|
||||
activeLeafId
|
||||
? "Override colours for the active pane only"
|
||||
: "Select a pane first to override it"
|
||||
}
|
||||
>
|
||||
{activeLeafId
|
||||
? `This pane (${activeLeafLabel || "active"})`
|
||||
: "This pane"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="color-body">
|
||||
<p className="color-blurb">
|
||||
{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."}
|
||||
</p>
|
||||
|
||||
{/* Editable colour rows */}
|
||||
<div className="color-rows">
|
||||
{FIELDS.map(({ key, label }) => {
|
||||
const value = resolved[key]!;
|
||||
const inherited = paneMode && !isSet(key);
|
||||
return (
|
||||
<div className="color-row" key={key}>
|
||||
<span className="color-row-label">{label}</span>
|
||||
<input
|
||||
type="color"
|
||||
className="color-swatch"
|
||||
value={expandHex(value)}
|
||||
onChange={(e) => setField(key, e.target.value)}
|
||||
aria-label={label}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="color-hex"
|
||||
value={value}
|
||||
spellCheck={false}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value.trim();
|
||||
if (HEX_RE.test(v)) setField(key, v);
|
||||
}}
|
||||
/>
|
||||
{paneMode &&
|
||||
(inherited ? (
|
||||
<span className="color-inherit-tag" title="Inheriting the global default">
|
||||
inherited
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
className="color-clear-field"
|
||||
onClick={() => clearField(key)}
|
||||
title="Revert this colour to the global default"
|
||||
aria-label={`Revert ${label} to global`}
|
||||
>
|
||||
↺
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Live preview */}
|
||||
<div
|
||||
className="color-preview"
|
||||
style={{ background: resolved.background, color: resolved.foreground }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div className="color-preview-line">
|
||||
<span className="color-preview-prompt">user@tiletopia</span>:~$ ls -la
|
||||
</div>
|
||||
<div className="color-preview-line">
|
||||
<span style={{ background: resolved.selection }}>selected text</span>{" "}
|
||||
normal output
|
||||
<span
|
||||
className="color-preview-cursor"
|
||||
style={{ background: resolved.cursor }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Presets */}
|
||||
<div className="color-presets">
|
||||
<span className="color-presets-label">Presets</span>
|
||||
<div className="color-presets-row">
|
||||
{COLOR_PRESETS.map((p) => (
|
||||
<button
|
||||
key={p.name}
|
||||
className="color-preset"
|
||||
onClick={() => applyPreset(p.colors)}
|
||||
title={`Apply ${p.name}`}
|
||||
>
|
||||
<span
|
||||
className="color-preset-swatch"
|
||||
style={{
|
||||
background: p.colors.background,
|
||||
color: p.colors.foreground,
|
||||
borderColor: p.colors.selection,
|
||||
}}
|
||||
>
|
||||
Ab
|
||||
</span>
|
||||
{p.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="color-actions">
|
||||
<button className="color-reset" onClick={resetAll}>
|
||||
{paneMode ? "Reset pane to global" : "Reset to defaults"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
105
src/components/SearchBar.css
Normal file
105
src/components/SearchBar.css
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
/* ---------------------------------------------------------------------------
|
||||
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;
|
||||
}
|
||||
177
src/components/SearchBar.tsx
Normal file
177
src/components/SearchBar.tsx
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
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<HTMLInputElement>(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<HTMLInputElement>) {
|
||||
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<HTMLInputElement>) {
|
||||
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 (
|
||||
<div className="search-bar" role="search" aria-label="Find in terminal">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="search-input"
|
||||
placeholder="Find…"
|
||||
onChange={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
aria-label="Search term"
|
||||
spellCheck={false}
|
||||
/>
|
||||
|
||||
<button
|
||||
className="search-toggle"
|
||||
title="Case-sensitive"
|
||||
aria-label="Toggle case-sensitive"
|
||||
aria-pressed={caseSensitive ? "true" : "false"}
|
||||
onClick={toggleCase}
|
||||
>
|
||||
Aa
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="search-toggle"
|
||||
title="Regular expression"
|
||||
aria-label="Toggle regular expression"
|
||||
aria-pressed={useRegex ? "true" : "false"}
|
||||
onClick={toggleRegex}
|
||||
>
|
||||
.*
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="search-nav"
|
||||
title="Previous match (Shift+Enter)"
|
||||
aria-label="Previous match"
|
||||
onClick={findPrev}
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="search-nav"
|
||||
title="Next match (Enter)"
|
||||
aria-label="Next match"
|
||||
onClick={findNext}
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="search-close"
|
||||
title="Close (Escape)"
|
||||
aria-label="Close search"
|
||||
onClick={onClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
175
src/components/TabStrip.css
Normal file
175
src/components/TabStrip.css
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
.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 <body> (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 <body> 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;
|
||||
}
|
||||
241
src/components/TabStrip.tsx
Normal file
241
src/components/TabStrip.tsx
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
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<NodeId | null>(null);
|
||||
const [draft, setDraft] = useState("");
|
||||
const editInputRef = useRef<HTMLInputElement>(null);
|
||||
const [confirmingId, setConfirmingId] = useState<NodeId | null>(null);
|
||||
// Anchor rect (the close button's) for the confirm popover. The popover is
|
||||
// portalled to <body> 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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div className="tab-strip" role="tablist">
|
||||
{workspaces.map((w) => {
|
||||
const isActive = w.id === currentWorkspaceId;
|
||||
const isEditing = editingId === w.id;
|
||||
return (
|
||||
<div
|
||||
key={w.id}
|
||||
className={`tab-strip-item${isActive ? " active" : ""}`}
|
||||
role="tab"
|
||||
aria-selected={isActive ? "true" : "false"}
|
||||
onClick={() => onSwitch(w.id)}
|
||||
onDoubleClick={(e) => startEdit(w.id, w.name, e)}
|
||||
title={`Switch to ${w.name}`}
|
||||
>
|
||||
{isEditing ? (
|
||||
<input
|
||||
ref={editInputRef}
|
||||
className="tab-strip-rename"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={onEditKey}
|
||||
onBlur={commitEdit}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<span className="tab-strip-name">{w.name}</span>
|
||||
)}
|
||||
<button
|
||||
className="tab-strip-close"
|
||||
onClick={(e) => requestClose(w.id, e)}
|
||||
title="Close tab"
|
||||
aria-label={`Close tab ${w.name}`}
|
||||
tabIndex={-1}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
className="tab-strip-add"
|
||||
onClick={onCreate}
|
||||
title="New tab (Ctrl+T)"
|
||||
aria-label="New tab"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
{confirmingId != null &&
|
||||
confirmAnchor &&
|
||||
createPortal(
|
||||
<div
|
||||
className="tab-strip-confirm"
|
||||
role="dialog"
|
||||
aria-label="Confirm close tab"
|
||||
style={{ top: confirmAnchor.top, left: confirmAnchor.left }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="tab-strip-confirm-title">
|
||||
Close "{confirmingWorkspace?.name}"?
|
||||
</div>
|
||||
<div className="tab-strip-confirm-body">
|
||||
This will kill {confirmingPaneLabels.length} pane
|
||||
{confirmingPaneLabels.length === 1 ? "" : "s"}:
|
||||
<div className="tab-strip-confirm-labels">
|
||||
{confirmingPaneLabels.join(", ")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="tab-strip-confirm-actions">
|
||||
<button
|
||||
className="tab-strip-confirm-btn cancel"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setConfirmingId(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="tab-strip-confirm-btn destructive"
|
||||
onClick={confirmClose}
|
||||
>
|
||||
Close tab
|
||||
</button>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,11 @@
|
|||
import { useRef, useEffect } from "react";
|
||||
import { useRef, useEffect, useState } 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,
|
||||
|
|
@ -15,9 +19,17 @@ 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)
|
||||
|
|
@ -50,6 +62,12 @@ 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;
|
||||
|
|
@ -63,6 +81,15 @@ 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<PaneColors>;
|
||||
/** 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;
|
||||
|
|
@ -73,6 +100,7 @@ const DEFAULT_XTERM_FONT_SIZE = 13;
|
|||
|
||||
export default function XtermPane({
|
||||
spec,
|
||||
existingPaneId,
|
||||
onStatus,
|
||||
onSpawn,
|
||||
onInput,
|
||||
|
|
@ -80,15 +108,22 @@ export default function XtermPane({
|
|||
onFocus,
|
||||
focusTrigger = 0,
|
||||
fontSize,
|
||||
colors,
|
||||
onNavigate,
|
||||
}: XtermPaneProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const termRef = useRef<Terminal | null>(null);
|
||||
const fitRef = useRef<FitAddon | null>(null);
|
||||
const paneIdRef = useRef<PaneId | null>(null);
|
||||
const searchAddonRef = useRef<SearchAddon | null>(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.
|
||||
|
|
@ -97,12 +132,18 @@ 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
|
||||
|
|
@ -115,10 +156,12 @@ export default function XtermPane({
|
|||
fontFamily: '"Cascadia Mono", "JetBrains Mono", "Consolas", monospace',
|
||||
fontSize: initialFontSizeRef.current ?? DEFAULT_XTERM_FONT_SIZE,
|
||||
cursorBlink: true,
|
||||
theme: {
|
||||
background: "#0c0c0c",
|
||||
foreground: "#e6e6e6",
|
||||
},
|
||||
// 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),
|
||||
scrollback: 5000,
|
||||
convertEol: false,
|
||||
allowProposedApi: true,
|
||||
|
|
@ -140,6 +183,41 @@ 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();
|
||||
|
||||
|
|
@ -153,6 +231,61 @@ 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;
|
||||
paneIdRef.current = paneId;
|
||||
onStatusRef.current?.(`pane ${paneId} adopted`, 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);
|
||||
}
|
||||
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) {
|
||||
|
|
@ -174,11 +307,21 @@ export default function XtermPane({
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
||||
term?.onData((data) => {
|
||||
if (paneId == null) return;
|
||||
|
|
@ -187,18 +330,44 @@ export default function XtermPane({
|
|||
onInputRef.current?.(b64);
|
||||
});
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
//
|
||||
// Uses tauri-plugin-clipboard-manager instead of navigator.clipboard so
|
||||
// WebView2 doesn't surface its native "Allow clipboard access?" prompt.
|
||||
// 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).
|
||||
term?.attachCustomKeyEventHandler((e) => {
|
||||
if (e.type !== "keydown") return true;
|
||||
if (!e.ctrlKey || !e.shiftKey || e.altKey) 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) =>
|
||||
|
|
@ -209,6 +378,8 @@ export default function XtermPane({
|
|||
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) => {
|
||||
|
|
@ -217,6 +388,42 @@ export default function XtermPane({
|
|||
.catch((err) => console.warn("clipboard read failed:", err));
|
||||
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<string, "left" | "right" | "up" | "down"> = {
|
||||
ArrowLeft: "left",
|
||||
ArrowRight: "right",
|
||||
ArrowUp: "up",
|
||||
ArrowDown: "down",
|
||||
};
|
||||
// Vim-style HJKL
|
||||
const VIM_DIR: Record<string, "left" | "right" | "up" | "down"> = {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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;
|
||||
});
|
||||
|
||||
|
|
@ -273,8 +480,18 @@ export default function XtermPane({
|
|||
});
|
||||
ro.observe(container);
|
||||
|
||||
// Focus so typing immediately lands in the terminal.
|
||||
// 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();
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
|
|
@ -287,6 +504,7 @@ 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
|
||||
|
|
@ -333,5 +551,51 @@ export default function XtermPane({
|
|||
}
|
||||
}, [fontSize]);
|
||||
|
||||
return <div ref={containerRef} style={{ width: "100%", height: "100%" }} />;
|
||||
// -------------------------------------------------------------------------
|
||||
// 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<HTMLTextAreaElement>(
|
||||
".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 (
|
||||
<div style={{ position: "relative", width: "100%", height: "100%" }}>
|
||||
<div ref={containerRef} style={{ width: "100%", height: "100%" }} />
|
||||
{searchOpen && searchAddonRef.current && (
|
||||
<SearchBar
|
||||
searchAddon={searchAddonRef.current}
|
||||
onClose={closeSearch}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
47
src/ipc.ts
47
src/ipc.ts
|
|
@ -53,6 +53,53 @@ export const resizePane = (id: PaneId, cols: number, rows: number): Promise<void
|
|||
|
||||
export const killPane = (id: PaneId): Promise<void> => 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<void> =>
|
||||
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<void> =>
|
||||
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<string> =>
|
||||
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<string> =>
|
||||
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<PendingInit | null> =>
|
||||
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<void> =>
|
||||
invoke("push_window_workspaces", { label, workspacesJson });
|
||||
|
||||
export const onPaneData = (
|
||||
id: PaneId,
|
||||
cb: (b64: string) => void,
|
||||
|
|
|
|||
|
|
@ -84,6 +84,10 @@
|
|||
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;
|
||||
|
|
@ -242,6 +246,9 @@
|
|||
.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;
|
||||
}
|
||||
|
|
@ -264,8 +271,92 @@
|
|||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@ 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";
|
||||
|
|
@ -15,6 +17,19 @@ 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;
|
||||
|
|
@ -28,6 +43,7 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
|
|||
const [editingLabel, setEditingLabel] = useState(false);
|
||||
const [labelDraft, setLabelDraft] = useState("");
|
||||
const labelInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const startEditLabel = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
|
|
@ -142,6 +158,22 @@ 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) => {
|
||||
|
|
@ -180,11 +212,51 @@ 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<typeof orch.navigateTo>[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<HTMLDivElement>) => {
|
||||
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
|
||||
|
|
@ -193,6 +265,17 @@ 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;
|
||||
|
|
@ -232,6 +315,23 @@ 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],
|
||||
);
|
||||
|
|
@ -243,12 +343,23 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
|
|||
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
|
||||
const wasDragging = st.dragging;
|
||||
dragStartRef.current = null;
|
||||
if (wasDragging) {
|
||||
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 {
|
||||
orch.endHeaderDrag(true);
|
||||
}
|
||||
},
|
||||
[orch.endHeaderDrag],
|
||||
[orch.endHeaderDrag, orch.moveToNewWindow, leaf.id],
|
||||
);
|
||||
|
||||
const onToolbarPointerCancel = useCallback(
|
||||
|
|
@ -258,6 +369,7 @@ 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);
|
||||
|
|
@ -294,7 +406,8 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
|
|||
|
||||
return (
|
||||
<div
|
||||
className={`leaf${isActive ? " active" : ""}${isBroadcasting ? " broadcasting" : ""}${isIdle ? " idle" : ""}${isDragSource ? " drag-source" : ""}${isDragTarget ? " drag-target" : ""}`}
|
||||
ref={rootRef}
|
||||
className={`leaf${isActive ? " active" : ""}${isBroadcasting ? " broadcasting" : ""}${isIdle ? " idle" : ""}${isDragSource ? " drag-source" : ""}${isDragTarget ? " drag-target" : ""}${widthTier ? ` leaf--${widthTier}` : ""}`}
|
||||
role="group"
|
||||
aria-label={`Terminal pane: ${leaf.label ?? leaf.distro ?? "unnamed"}`}
|
||||
data-leaf-id={leaf.id}
|
||||
|
|
@ -306,6 +419,7 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
|
|||
onPointerMove={onToolbarPointerMove}
|
||||
onPointerUp={onToolbarPointerUp}
|
||||
onPointerCancel={onToolbarPointerCancel}
|
||||
onContextMenu={openContextMenu}
|
||||
>
|
||||
{editingLabel ? (
|
||||
<input
|
||||
|
|
@ -434,6 +548,22 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
|
|||
🤖
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`bcast-chip color-chip${leaf.colorOverride ? " on" : ""}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
orch.openColorPanel(leaf.id);
|
||||
}}
|
||||
title={
|
||||
leaf.colorOverride
|
||||
? "This pane has custom colours — click to edit"
|
||||
: "Set custom colours for this pane"
|
||||
}
|
||||
aria-pressed={leaf.colorOverride ? "true" : "false"}
|
||||
>
|
||||
🎨
|
||||
</button>
|
||||
|
||||
{isIdle && statusOk ? (
|
||||
<span className="pane-status idle" title={`No output for ${IDLE_THRESHOLD_MS / 1000}s+`}>
|
||||
idle
|
||||
|
|
@ -482,13 +612,16 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
|
|||
{spec ? (
|
||||
<XtermPane
|
||||
spec={spec}
|
||||
existingPaneId={orch.getInitialPaneIdFor(leaf.id)}
|
||||
onStatus={onStatus}
|
||||
onSpawn={onPaneSpawned}
|
||||
onInput={onTerminalInput}
|
||||
onDataReceived={onDataReceived}
|
||||
onFocus={onXtermFocus}
|
||||
onNavigate={onPaneNavigate}
|
||||
focusTrigger={focusTrigger}
|
||||
fontSize={resolveFontSize(leaf.fontSizeOffset)}
|
||||
colors={resolvePaneColors(orch.globalColors, leaf.colorOverride)}
|
||||
/>
|
||||
) : (
|
||||
<div className="leaf-missing-host">
|
||||
|
|
@ -500,6 +633,51 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
{menuPos && (
|
||||
<div
|
||||
className="pane-context-menu"
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: menuPos.y,
|
||||
left: menuPos.x,
|
||||
}}
|
||||
role="menu"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="pane-context-menu-item"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
closeContextMenu();
|
||||
orch.moveToNewWindow(leaf.id);
|
||||
}}
|
||||
>
|
||||
Move to new window
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{dragGhost &&
|
||||
createPortal(
|
||||
<div
|
||||
className={`pane-drag-ghost${dragGhost.detach ? " detach" : ""}`}
|
||||
style={{
|
||||
left: dragGhost.x,
|
||||
top: dragGhost.y,
|
||||
transform: `translate(${
|
||||
dragGhost.flipX ? "calc(-100% - 12px)" : "12px"
|
||||
}, ${dragGhost.flipY ? "calc(-100% - 12px)" : "12px"})`,
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span className="pane-drag-ghost-label">{labelText}</span>
|
||||
{dragGhost.detach && (
|
||||
<span className="pane-drag-ghost-hint">↗ New window</span>
|
||||
)}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { createContext, useContext, type ReactNode } from "react";
|
||||
import type { Orientation, NodeId, LeafShellSpec } from "./tree";
|
||||
import type { Orientation, NodeId, LeafShellSpec, Direction } from "./tree";
|
||||
import type { PaneId, SshHost } from "../../ipc";
|
||||
import type { PaneColors } from "../theme";
|
||||
|
||||
/**
|
||||
* Orchestration context — every piece of shared state and every operation
|
||||
|
|
@ -21,6 +22,10 @@ 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;
|
||||
|
|
@ -34,9 +39,15 @@ 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;
|
||||
|
|
@ -56,8 +67,36 @@ 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<Orchestration | null>(null);
|
||||
|
||||
export function OrchestrationProvider({
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
changeLabel,
|
||||
toggleBroadcast,
|
||||
toggleMcpAllow,
|
||||
setLeafColors,
|
||||
adjustFontSize,
|
||||
adjustAllFontSizes,
|
||||
resolveFontSize,
|
||||
|
|
@ -21,6 +22,10 @@ import {
|
|||
MAX_FONT_SIZE,
|
||||
serialize,
|
||||
deserialize,
|
||||
serializeWorkspaces,
|
||||
deserializeWorkspaces,
|
||||
singletonEnvelope,
|
||||
WORKSPACES_VERSION,
|
||||
presetSingle,
|
||||
presetTwoColumns,
|
||||
presetThreeColumns,
|
||||
|
|
@ -298,12 +303,13 @@ describe("setLeafShell", () => {
|
|||
expect(next.id).not.toBe(leaf.id);
|
||||
});
|
||||
|
||||
it("preserves label / broadcast / fontSizeOffset across the shell change", () => {
|
||||
it("preserves label / broadcast / fontSizeOffset / colorOverride 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",
|
||||
|
|
@ -311,6 +317,7 @@ describe("setLeafShell", () => {
|
|||
expect(next.label).toBe("my pane");
|
||||
expect(next.broadcast).toBe(true);
|
||||
expect(next.fontSizeOffset).toBe(2);
|
||||
expect(next.colorOverride).toEqual({ background: "#101010" });
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -385,6 +392,58 @@ 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);
|
||||
|
|
@ -665,3 +724,82 @@ 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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@
|
|||
//! 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). */
|
||||
|
|
@ -44,6 +46,13 @@ 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
|
||||
|
|
@ -111,6 +120,7 @@ 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;
|
||||
|
|
@ -294,6 +304,32 @@ 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 {
|
||||
|
|
@ -383,6 +419,7 @@ 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;
|
||||
}
|
||||
|
||||
|
|
@ -703,6 +740,92 @@ 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";
|
||||
|
||||
|
|
|
|||
|
|
@ -30,13 +30,59 @@ 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",
|
||||
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.",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -70,6 +116,18 @@ 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",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
@ -101,13 +159,17 @@ 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",
|
||||
body: "Grab a pane's title bar and drag it onto another pane to swap their tree positions. Useful for reorganizing without keyboard.",
|
||||
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: "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.",
|
||||
|
|
|
|||
79
src/lib/theme.test.ts
Normal file
79
src/lib/theme.test.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
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}$/);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
160
src/lib/theme.ts
Normal file
160
src/lib/theme.ts
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
//! 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<PaneColors> = {
|
||||
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<PaneColors>;
|
||||
}
|
||||
|
||||
/** 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<PaneColors> {
|
||||
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<PaneColors>): 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 `<input type="color">` 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<string, unknown>;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -38,28 +38,31 @@ body {
|
|||
.xterm { height: 100%; }
|
||||
.xterm-viewport { background: #0c0c0c !important; }
|
||||
|
||||
/* Themed scrollbars — Chromium pseudo-elements (WebView2 supports these). */
|
||||
.xterm-viewport::-webkit-scrollbar {
|
||||
/* 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 {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
.xterm-viewport::-webkit-scrollbar-track {
|
||||
*::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.xterm-viewport::-webkit-scrollbar-thumb {
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: #2a2a2a;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #1a1a1a;
|
||||
}
|
||||
.xterm-viewport::-webkit-scrollbar-thumb:hover {
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background: #3a3a3a;
|
||||
}
|
||||
.xterm-viewport::-webkit-scrollbar-corner {
|
||||
*::-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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue