Initial scaffold from M1 spike (tiletopia)
Tauri 2 + Svelte 5 + xterm.js + portable-pty. Single full-window WSL terminal pane with clickable distro picker. M1 verified manually on Windows: window opens, xterm.js renders, claude TUI works, resize reflows cleanly. Graduated from ~/claude/ideas/wsl-mux/ per the approved plan at ~/.claude/plans/imperative-coalescing-feigenbaum.md. See memory.md for decisions, open TODOs, and the M2-M5 roadmap. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
b352f8f049
36 changed files with 11534 additions and 0 deletions
19
.gitignore
vendored
Normal file
19
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.svelte-kit/
|
||||||
|
.pnpm-store/
|
||||||
|
|
||||||
|
# Rust / Tauri
|
||||||
|
src-tauri/target/
|
||||||
|
|
||||||
|
# Editor / OS
|
||||||
|
.DS_Store
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
|
||||||
|
# Env / secrets
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
26
CLAUDE.md
Normal file
26
CLAUDE.md
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
# Project: tiletopia
|
||||||
|
|
||||||
|
A Windows desktop app for running and arranging many WSL terminals at once. Built primarily to manage multiple `claude` sessions across projects in parallel; works for any multi-shell workflow.
|
||||||
|
|
||||||
|
## Working agreement
|
||||||
|
|
||||||
|
- This is a git repo with `origin` on Forgejo at `https://git.rdx4.com/megaproxy/tiletopia.git` (private). HTTPS auth uses the token in `~/.git-credentials` — pushes are non-interactive.
|
||||||
|
- Commit after each logical change with a one-line imperative message; `git push` after each commit (or at minimum before ending the session).
|
||||||
|
- Read `memory.md` at session start. Update it before ending the session.
|
||||||
|
- Never commit secrets — see `.gitignore` and the rules in `~/claude/CLAUDE.md`.
|
||||||
|
|
||||||
|
## Project-specific notes
|
||||||
|
|
||||||
|
- **Stack:** Tauri 2 + Svelte 5 + TypeScript + Vite + pnpm + xterm.js + `portable-pty`. Mirrors `~/claude/projects/claude-usage-widget/` for toolchain choices.
|
||||||
|
- **Build target:** Windows `.exe` only. Rust toolchain lives on the Windows host, not WSL.
|
||||||
|
- **Source location:** `D:\dev\tiletopia\` (Windows-native NTFS). Symlinked into WSL at `~/claude/projects/tiletopia` for editing convenience, but **all pnpm and cargo commands must run on the Windows host** against the `D:\` path — never the `\\wsl.localhost\...` UNC path (pnpm 11.x crashes inside `isDriveExFat`, and the underlying error gets swallowed).
|
||||||
|
- **Run:**
|
||||||
|
```powershell
|
||||||
|
cd D:\dev\tiletopia
|
||||||
|
pnpm install
|
||||||
|
pnpm tauri dev # iterate
|
||||||
|
pnpm tauri build # NSIS installer at src-tauri\target\release\bundle\nsis\
|
||||||
|
```
|
||||||
|
- **Validate in WSL:** `pnpm check` (svelte-check) runs in WSL and validates the Svelte/TS side without needing the Rust toolchain.
|
||||||
|
- **Plan reference:** `~/.claude/plans/imperative-coalescing-feigenbaum.md` — the approved plan that drove the scaffold and the full M0–M5 milestone roadmap.
|
||||||
|
- **Archived idea history:** the brainstorm phase + full session log lives at `~/claude/archive/ideas/wsl-mux/plan.md`.
|
||||||
75
README.md
Normal file
75
README.md
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
# tiletopia
|
||||||
|
|
||||||
|
A Windows desktop app for running and arranging many WSL terminals at once. Built primarily to manage multiple `claude` sessions across projects in parallel, but works for any multi-shell workflow.
|
||||||
|
|
||||||
|
Status: **early — single-pane M1 works**. Tiling layout (M2), workspace persistence (M3), and cross-pane orchestration (M4) are the next milestones. See `memory.md`.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- **Tauri 2** (Rust backend, WebView2 frontend) — small bundle, native Windows installer via NSIS.
|
||||||
|
- **Svelte 5** + TypeScript + Vite + pnpm.
|
||||||
|
- **xterm.js** + `@xterm/addon-fit` for terminal rendering.
|
||||||
|
- **`portable-pty`** (Rust crate) spawning `wsl.exe -d <distro>` PTYs.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
This project targets Windows. Dev requires:
|
||||||
|
|
||||||
|
- Windows 10/11 + [WebView2 Runtime](https://developer.microsoft.com/microsoft-edge/webview2/) (preinstalled on Win11).
|
||||||
|
- [MSVC toolchain](https://v2.tauri.app/start/prerequisites/#windows) (VS Build Tools, "C++ build tools" workload).
|
||||||
|
- [Rust](https://rustup.rs/) on the Windows host.
|
||||||
|
- Node 20+ and pnpm (`corepack use pnpm@11.2.2`).
|
||||||
|
- WSL with at least one distro installed.
|
||||||
|
|
||||||
|
**Location matters.** The source must live on a Windows-native drive (here: `D:\dev\tiletopia\`). Don't run pnpm against the `\\wsl.localhost\...` UNC path — pnpm 11.x crashes inside `isDriveExFat` and the actual error gets swallowed by the crashing error-hint formatter.
|
||||||
|
|
||||||
|
From a Windows shell:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd D:\dev\tiletopia
|
||||||
|
pnpm install
|
||||||
|
pnpm tauri dev # iterate
|
||||||
|
pnpm tauri build # NSIS installer at src-tauri\target\release\bundle\nsis\
|
||||||
|
```
|
||||||
|
|
||||||
|
The WSL-side symlink at `~/claude/projects/tiletopia` points here for in-WSL editing.
|
||||||
|
|
||||||
|
## How it works (current state)
|
||||||
|
|
||||||
|
- **Backend (`src-tauri/src/pty.rs`):** a `PtyManager` holding a `Mutex<HashMap<PaneId, PaneHandle>>` of `portable-pty` children. Each spawned pane gets a background reader thread that emits `pane://{id}/data` events to the frontend (base64-encoded byte chunks). Counterparts: `write_to_pane`, `resize_pane`, `kill_pane`. Distro enumeration parses `wsl.exe -l -q` (UTF-16LE).
|
||||||
|
- **Frontend (`src/components/XtermPane.svelte`):** xterm.js + FitAddon mounted into a div. On mount, calls `spawn_pane`, subscribes to the pane's event stream, wires `term.onData` → `write_to_pane`, and uses a `ResizeObserver` to forward dimension changes to the PTY.
|
||||||
|
- **App (`src/App.svelte`):** titlebar with clickable distro buttons (auto-picks first non-docker-desktop distro; user can override). One XtermPane wrapped in `{#key selected}` so changing distro destroys + respawns the pane.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
tiletopia/
|
||||||
|
├── CLAUDE.md, memory.md, README.md
|
||||||
|
├── .gitignore, pnpm-workspace.yaml
|
||||||
|
├── package.json, vite.config.ts, svelte.config.js, tsconfig.json, tsconfig.node.json
|
||||||
|
├── index.html
|
||||||
|
├── src/
|
||||||
|
│ ├── main.ts # mounts App, imports xterm.css
|
||||||
|
│ ├── App.svelte # titlebar + one XtermPane (M1)
|
||||||
|
│ ├── styles.css
|
||||||
|
│ ├── ipc.ts # typed Tauri command wrappers
|
||||||
|
│ └── components/
|
||||||
|
│ └── XtermPane.svelte
|
||||||
|
└── src-tauri/
|
||||||
|
├── Cargo.toml, build.rs, tauri.conf.json
|
||||||
|
├── capabilities/default.json
|
||||||
|
├── icons/ # placeholder, copied from claude-usage-widget
|
||||||
|
└── src/
|
||||||
|
├── main.rs
|
||||||
|
├── lib.rs # tauri builder, registers commands, manages PtyManager
|
||||||
|
├── pty.rs # PtyManager + list_wsl_distros
|
||||||
|
└── commands.rs # #[tauri::command] surface
|
||||||
|
```
|
||||||
|
|
||||||
|
## Known gotchas (today)
|
||||||
|
|
||||||
|
- **Don't `pnpm install` from a UNC path** (`\\wsl.localhost\...`). pnpm 11.x crashes in its `isDriveExFat` probe; the underlying error gets swallowed.
|
||||||
|
- **Console flash on `wsl.exe -l -q`:** suppressed via the `CREATE_NO_WINDOW` flag in `pty.rs`. The PTY itself doesn't allocate a console (portable-pty uses ConPTY directly).
|
||||||
|
- **base64 wire format:** xterm.js emits `string` from `onData`; we UTF-8 encode then base64. Not the fastest; switch to typed-array event payloads later if throughput is an issue.
|
||||||
|
- **No icons of our own:** copied from `claude-usage-widget`. Replace before any release.
|
||||||
|
- **Cargo build only works on Windows host** — Rust toolchain isn't installed in WSL. `pnpm check` runs in WSL and validates the Svelte/TS side.
|
||||||
12
index.html
Normal file
12
index.html
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>tiletopia</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
45
memory.md
Normal file
45
memory.md
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
# memory — tiletopia
|
||||||
|
|
||||||
|
Durable memory for this project. Read at session start, update before session end. Date format: `YYYY-MM-DD`.
|
||||||
|
|
||||||
|
## Decisions & rationale
|
||||||
|
|
||||||
|
- **Stack: Tauri 2 + Svelte 5 + TypeScript + Vite + pnpm + xterm.js + `portable-pty`.** Mirrors `claude-usage-widget` so we reuse a known-good Windows-targeting toolchain (MSVC + WebView2 + NSIS installer). No new technology bets stacked on top of the new product bet.
|
||||||
|
- **Layout model: binary tree of splits, NOT free-form rectangles.** Same as i3 / tmux / Zellij. Each internal node is HSplit/VSplit + ratio; each leaf is a terminal. Dragging a gutter mutates one parent ratio; both sibling subtrees reflow; descendants get `resize`. Adaptive resize falls out automatically with no constraint solver. Preset layouts ("3 columns", "2×2") are pre-built trees.
|
||||||
|
- **PTY backend: `portable-pty` (same crate WezTerm uses).** Spawns `wsl.exe -d <distro> --cd <path>` on Windows. Manager is a `Mutex<HashMap<PaneId, PaneHandle>>` in Rust; each pane has a background reader thread that emits `pane://{id}/data` events.
|
||||||
|
- **Wire format: base64-encoded byte chunks via Tauri events.** xterm.js's `onData` emits strings; we UTF-8 encode then base64. Slower than a typed-array payload but trivially correct. Revisit if throughput matters.
|
||||||
|
- **Source on Windows-native disk (`D:\dev\tiletopia\`), symlinked into WSL.** Same pattern as `rimlike` (`D:\godot\rimlike`) and `tavernkeep`. Forced by pnpm 11.x's `isDriveExFat` crashing on `\\wsl.localhost\...` UNC paths.
|
||||||
|
- **Don't commit `node_modules`, `src-tauri/target`, or `.pnpm-store`. DO commit `Cargo.lock`** (binary project, reproducible builds).
|
||||||
|
- **Session awareness without an in-pane agent.** Plan: poll `/proc/<pid>/cwd` of the shell's child + foreground process every ~2s. Sufficient to detect `cd` and whether `claude` is running.
|
||||||
|
|
||||||
|
## Open questions / TODOs
|
||||||
|
|
||||||
|
- [ ] **HMR distro picker reset.** After a Vite hot reload, the previously-selected distro persists in Svelte 5 `$state`, so the picker doesn't re-default. Workaround in place (clickable distro buttons in titlebar). Fix properly in M3 when workspace state lives in a separate persisted store.
|
||||||
|
- [ ] **M2 — splits-tree layout component.** Two panes side by side, draggable divider, both panes alive. Save/restore layout as JSON.
|
||||||
|
- [ ] **M3 — workspace persistence.** Save/restore layouts + per-pane (distro, cwd, label) in `%APPDATA%/tiletopia/workspaces.json`. Preset layouts (3 columns, 2×2 grid). Distro picker UX, pane labels.
|
||||||
|
- [ ] **M4 — orchestration.** Broadcast input groups, idle/finish notifications, Ctrl+K fuzzy palette.
|
||||||
|
- [ ] **M5 — Ship.** Replace placeholder icons, NSIS installer, Forgejo release. Copy `claude-usage-widget`'s release scripts.
|
||||||
|
- [ ] **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.
|
||||||
|
- [ ] **Keybinding philosophy.** Copy tmux, copy WezTerm, or invent? Decide at M3.
|
||||||
|
|
||||||
|
## Session log
|
||||||
|
|
||||||
|
### 2026-05-22
|
||||||
|
|
||||||
|
- Graduated from `ideas/wsl-mux/` to project. Renamed working name `wsl-mux` → final name `tiletopia` across Cargo/package/Tauri configs and source.
|
||||||
|
- Promoted spike contents from `D:\dev\wsl-mux\spike\` to `D:\dev\tiletopia\` (no more spike subdir; the project IS what was the spike).
|
||||||
|
- Initialized git, created private Forgejo repo `tiletopia`, pushed initial scaffold.
|
||||||
|
- M1 verified manually on the Windows host: window opens, xterm.js renders, `claude` TUI works inside the pane, resize reflows cleanly, `htop` renders. Distro auto-pick chose `docker-desktop` (Docker Desktop's BusyBox helper distro) on first try — added explicit clickable distro buttons in the titlebar as both a diagnostic and a manual override. Clicking `Ubuntu` works end-to-end.
|
||||||
|
- Old idea folder archived to `~/claude/archive/ideas/wsl-mux/` (preserves full brainstorm + session log).
|
||||||
|
|
||||||
|
## External references
|
||||||
|
|
||||||
|
- **Approved plan / roadmap:** `~/.claude/plans/imperative-coalescing-feigenbaum.md` (M0–M5 milestones with verification criteria for each)
|
||||||
|
- **Stack precedent:** `~/claude/projects/claude-usage-widget/` — same Tauri + Svelte + WebView2 toolchain, already ships a Windows installer via Forgejo releases. WSL distro-probing logic copied/adapted into `src-tauri/src/pty.rs`.
|
||||||
|
- **Archived idea history:** `~/claude/archive/ideas/wsl-mux/plan.md`
|
||||||
|
- **Forgejo repo:** https://git.rdx4.com/megaproxy/tiletopia
|
||||||
|
- **xterm.js docs:** https://xtermjs.org/
|
||||||
|
- **portable-pty crate:** https://crates.io/crates/portable-pty
|
||||||
|
- **Tauri 2 docs:** https://v2.tauri.app/
|
||||||
|
- **Prior art for splits-tree layout:** i3, tmux, Zellij, WezTerm
|
||||||
27
package.json
Normal file
27
package.json
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
{
|
||||||
|
"name": "tiletopia",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.1",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"check": "svelte-check --tsconfig ./tsconfig.json",
|
||||||
|
"tauri": "tauri"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^2.0.0",
|
||||||
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
|
"@xterm/xterm": "^5.5.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||||
|
"@tauri-apps/cli": "^2.0.0",
|
||||||
|
"@tsconfig/svelte": "^5.0.4",
|
||||||
|
"svelte": "^5.0.0",
|
||||||
|
"svelte-check": "^4.0.0",
|
||||||
|
"typescript": "^5.6.0",
|
||||||
|
"vite": "^5.4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
1054
pnpm-lock.yaml
generated
Normal file
1054
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
2
pnpm-workspace.yaml
Normal file
2
pnpm-workspace.yaml
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
allowBuilds:
|
||||||
|
esbuild: true
|
||||||
4853
src-tauri/Cargo.lock
generated
Normal file
4853
src-tauri/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
40
src-tauri/Cargo.toml
Normal file
40
src-tauri/Cargo.toml
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
[package]
|
||||||
|
name = "tiletopia"
|
||||||
|
version = "0.0.1"
|
||||||
|
description = "Tiling multi-terminal manager for WSL"
|
||||||
|
authors = ["megaproxy"]
|
||||||
|
edition = "2021"
|
||||||
|
rust-version = "1.77"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "tiletopia_lib"
|
||||||
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tauri = { version = "2", features = [] }
|
||||||
|
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
|
||||||
|
portable-pty = "0.8"
|
||||||
|
anyhow = "1"
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
once_cell = "1"
|
||||||
|
parking_lot = "0.12"
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
base64 = "0.22"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["custom-protocol"]
|
||||||
|
custom-protocol = ["tauri/custom-protocol"]
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
panic = "abort"
|
||||||
|
codegen-units = 1
|
||||||
|
lto = true
|
||||||
|
opt-level = "s"
|
||||||
|
strip = true
|
||||||
3
src-tauri/build.rs
Normal file
3
src-tauri/build.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
11
src-tauri/capabilities/default.json
Normal file
11
src-tauri/capabilities/default.json
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "Default capability set for wsl-mux spike",
|
||||||
|
"windows": ["main"],
|
||||||
|
"permissions": [
|
||||||
|
"core:default",
|
||||||
|
"core:event:default",
|
||||||
|
"core:window:default"
|
||||||
|
]
|
||||||
|
}
|
||||||
1
src-tauri/gen/schemas/acl-manifests.json
Normal file
1
src-tauri/gen/schemas/acl-manifests.json
Normal file
File diff suppressed because one or more lines are too long
1
src-tauri/gen/schemas/capabilities.json
Normal file
1
src-tauri/gen/schemas/capabilities.json
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{"default":{"identifier":"default","description":"Default capability set for wsl-mux spike","local":true,"windows":["main"],"permissions":["core:default","core:event:default","core:window:default"]}}
|
||||||
2292
src-tauri/gen/schemas/desktop-schema.json
Normal file
2292
src-tauri/gen/schemas/desktop-schema.json
Normal file
File diff suppressed because it is too large
Load diff
2292
src-tauri/gen/schemas/windows-schema.json
Normal file
2292
src-tauri/gen/schemas/windows-schema.json
Normal file
File diff suppressed because it is too large
Load diff
BIN
src-tauri/icons/128x128.png
Normal file
BIN
src-tauri/icons/128x128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.9 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
BIN
src-tauri/icons/128x128@2x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
BIN
src-tauri/icons/32x32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 KiB |
BIN
src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/icons/icon.icns
Normal file
Binary file not shown.
BIN
src-tauri/icons/icon.ico
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 836 B |
BIN
src-tauri/icons/source.png
Normal file
BIN
src-tauri/icons/source.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
57
src-tauri/src/commands.rs
Normal file
57
src-tauri/src/commands.rs
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
//! Tauri command surface. Every JS-callable function lives here.
|
||||||
|
|
||||||
|
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
|
||||||
|
use tauri::AppHandle;
|
||||||
|
|
||||||
|
use crate::pty::{list_wsl_distros, PaneId, PtyManager};
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_distros() -> Result<Vec<String>, String> {
|
||||||
|
list_wsl_distros().map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn spawn_pane(
|
||||||
|
app: AppHandle,
|
||||||
|
manager: tauri::State<'_, PtyManager>,
|
||||||
|
distro: Option<String>,
|
||||||
|
cwd: Option<String>,
|
||||||
|
cols: u16,
|
||||||
|
rows: u16,
|
||||||
|
) -> Result<PaneId, String> {
|
||||||
|
manager
|
||||||
|
.spawn_wsl(app, distro, cwd, cols, rows)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `data_b64` is base64-encoded UTF-8 bytes (xterm.js's `onData` emits
|
||||||
|
/// strings; the frontend encodes before sending).
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn write_to_pane(
|
||||||
|
manager: tauri::State<'_, PtyManager>,
|
||||||
|
id: PaneId,
|
||||||
|
data_b64: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let bytes = B64
|
||||||
|
.decode(data_b64.as_bytes())
|
||||||
|
.map_err(|e| format!("base64 decode: {e}"))?;
|
||||||
|
manager.write(id, &bytes).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn resize_pane(
|
||||||
|
manager: tauri::State<'_, PtyManager>,
|
||||||
|
id: PaneId,
|
||||||
|
cols: u16,
|
||||||
|
rows: u16,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
manager.resize(id, cols, rows).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn kill_pane(
|
||||||
|
manager: tauri::State<'_, PtyManager>,
|
||||||
|
id: PaneId,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
manager.kill(id).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
28
src-tauri/src/lib.rs
Normal file
28
src-tauri/src/lib.rs
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
//! Library entry point. `main.rs` calls `run()`.
|
||||||
|
|
||||||
|
mod commands;
|
||||||
|
mod pty;
|
||||||
|
|
||||||
|
use crate::pty::PtyManager;
|
||||||
|
|
||||||
|
pub fn run() {
|
||||||
|
let _ = tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(
|
||||||
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
||||||
|
)
|
||||||
|
.with_writer(std::io::stderr)
|
||||||
|
.try_init();
|
||||||
|
|
||||||
|
tauri::Builder::default()
|
||||||
|
.manage(PtyManager::new())
|
||||||
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
commands::list_distros,
|
||||||
|
commands::spawn_pane,
|
||||||
|
commands::write_to_pane,
|
||||||
|
commands::resize_pane,
|
||||||
|
commands::kill_pane,
|
||||||
|
])
|
||||||
|
.run(tauri::generate_context!())
|
||||||
|
.expect("error while running tauri application");
|
||||||
|
}
|
||||||
6
src-tauri/src/main.rs
Normal file
6
src-tauri/src/main.rs
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
// Hide the console window in release builds; keep it in debug for log output.
|
||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
tiletopia_lib::run();
|
||||||
|
}
|
||||||
229
src-tauri/src/pty.rs
Normal file
229
src-tauri/src/pty.rs
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
//! PTY backend. Spawns `wsl.exe` (or any command) through portable-pty,
|
||||||
|
//! reads its output on a background thread, and forwards chunks to the
|
||||||
|
//! frontend as `pane://{id}/data` events.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Context, Result};
|
||||||
|
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
|
||||||
|
use parking_lot::Mutex;
|
||||||
|
use portable_pty::{CommandBuilder, MasterPty, PtySize, native_pty_system};
|
||||||
|
use serde::Serialize;
|
||||||
|
use tauri::{AppHandle, Emitter};
|
||||||
|
|
||||||
|
pub type PaneId = u64;
|
||||||
|
|
||||||
|
/// What we keep alive for each spawned PTY.
|
||||||
|
///
|
||||||
|
/// `master` stays in scope to keep the PTY alive; we never write through it
|
||||||
|
/// directly (we use `writer` instead) and we never read through it directly
|
||||||
|
/// (the reader thread holds its own clone via `try_clone_reader`).
|
||||||
|
struct PaneHandle {
|
||||||
|
#[allow(dead_code)]
|
||||||
|
master: Box<dyn MasterPty + Send>,
|
||||||
|
writer: Box<dyn Write + Send>,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
child: Box<dyn portable_pty::Child + Send + Sync>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PtyManager {
|
||||||
|
panes: Mutex<HashMap<PaneId, PaneHandle>>,
|
||||||
|
next_id: AtomicU64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PtyManager {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
panes: Mutex::new(HashMap::new()),
|
||||||
|
next_id: AtomicU64::new(1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn `wsl.exe` (optionally `-d <distro>`, optionally `--cd <cwd>`).
|
||||||
|
/// Returns the new pane id. A background thread starts reading the PTY
|
||||||
|
/// immediately and emits `pane://{id}/data` events.
|
||||||
|
pub fn spawn_wsl(
|
||||||
|
&self,
|
||||||
|
app: AppHandle,
|
||||||
|
distro: Option<String>,
|
||||||
|
cwd: Option<String>,
|
||||||
|
cols: u16,
|
||||||
|
rows: u16,
|
||||||
|
) -> Result<PaneId> {
|
||||||
|
let pty_system = native_pty_system();
|
||||||
|
let pair = pty_system
|
||||||
|
.openpty(PtySize {
|
||||||
|
rows,
|
||||||
|
cols,
|
||||||
|
pixel_width: 0,
|
||||||
|
pixel_height: 0,
|
||||||
|
})
|
||||||
|
.context("openpty failed")?;
|
||||||
|
|
||||||
|
let mut cmd = CommandBuilder::new("wsl.exe");
|
||||||
|
if let Some(d) = distro.as_deref() {
|
||||||
|
cmd.arg("-d");
|
||||||
|
cmd.arg(d);
|
||||||
|
}
|
||||||
|
if let Some(c) = cwd.as_deref() {
|
||||||
|
cmd.arg("--cd");
|
||||||
|
cmd.arg(c);
|
||||||
|
}
|
||||||
|
// Force a login shell so .bashrc etc. run and PATH is populated.
|
||||||
|
// wsl.exe without an explicit command launches the default shell
|
||||||
|
// interactively, which is exactly what we want.
|
||||||
|
|
||||||
|
let child = pair
|
||||||
|
.slave
|
||||||
|
.spawn_command(cmd)
|
||||||
|
.context("failed to spawn wsl.exe; is WSL installed?")?;
|
||||||
|
|
||||||
|
// We need to keep the master alive (drop = close the PTY), but we
|
||||||
|
// also need the reader and writer split from it.
|
||||||
|
let mut reader = pair
|
||||||
|
.master
|
||||||
|
.try_clone_reader()
|
||||||
|
.context("try_clone_reader failed")?;
|
||||||
|
let writer = pair
|
||||||
|
.master
|
||||||
|
.take_writer()
|
||||||
|
.context("take_writer failed")?;
|
||||||
|
|
||||||
|
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
|
||||||
|
|
||||||
|
self.panes.lock().insert(
|
||||||
|
id,
|
||||||
|
PaneHandle {
|
||||||
|
master: pair.master,
|
||||||
|
writer,
|
||||||
|
child,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reader thread: pump bytes -> base64 -> emit.
|
||||||
|
let app_for_reader = app.clone();
|
||||||
|
let event_name = format!("pane://{id}/data");
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let mut buf = [0u8; 8192];
|
||||||
|
loop {
|
||||||
|
match reader.read(&mut buf) {
|
||||||
|
Ok(0) => {
|
||||||
|
tracing::info!("pane {id}: EOF");
|
||||||
|
let _ = app_for_reader.emit(&format!("pane://{id}/exit"), ());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Ok(n) => {
|
||||||
|
let chunk_b64 = B64.encode(&buf[..n]);
|
||||||
|
if let Err(e) =
|
||||||
|
app_for_reader.emit(&event_name, DataChunk { b64: chunk_b64 })
|
||||||
|
{
|
||||||
|
tracing::warn!("emit failed for pane {id}: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("pane {id} read error: {e}");
|
||||||
|
let _ = app_for_reader.emit(&format!("pane://{id}/exit"), ());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn write(&self, id: PaneId, bytes: &[u8]) -> Result<()> {
|
||||||
|
let mut panes = self.panes.lock();
|
||||||
|
let pane = panes
|
||||||
|
.get_mut(&id)
|
||||||
|
.ok_or_else(|| anyhow!("no pane with id {id}"))?;
|
||||||
|
pane.writer.write_all(bytes).context("pty write failed")?;
|
||||||
|
pane.writer.flush().ok();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resize(&self, id: PaneId, cols: u16, rows: u16) -> Result<()> {
|
||||||
|
let panes = self.panes.lock();
|
||||||
|
let pane = panes
|
||||||
|
.get(&id)
|
||||||
|
.ok_or_else(|| anyhow!("no pane with id {id}"))?;
|
||||||
|
pane.master
|
||||||
|
.resize(PtySize {
|
||||||
|
rows,
|
||||||
|
cols,
|
||||||
|
pixel_width: 0,
|
||||||
|
pixel_height: 0,
|
||||||
|
})
|
||||||
|
.context("pty resize failed")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn kill(&self, id: PaneId) -> Result<()> {
|
||||||
|
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
|
||||||
|
// closes the PTY which will unblock the reader thread.
|
||||||
|
let _ = pane.child.kill();
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Clone)]
|
||||||
|
struct DataChunk {
|
||||||
|
b64: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- distro enumeration -----------------------------------------------------
|
||||||
|
|
||||||
|
/// Run a process without flashing a console window on Windows.
|
||||||
|
fn quiet_command(program: &str) -> std::process::Command {
|
||||||
|
let mut c = std::process::Command::new(program);
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
use std::os::windows::process::CommandExt;
|
||||||
|
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||||
|
c.creation_flags(CREATE_NO_WINDOW);
|
||||||
|
}
|
||||||
|
c
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `wsl.exe -l -q` lists installed distros (one per line, UTF-16LE).
|
||||||
|
/// Returns Ok(empty) on non-Windows or when wsl.exe isn't on PATH.
|
||||||
|
pub fn list_wsl_distros() -> Result<Vec<String>> {
|
||||||
|
if !cfg!(windows) {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let out = match quiet_command("wsl.exe").args(["-l", "-q"]).output() {
|
||||||
|
Ok(o) => o,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::debug!("wsl.exe not available: {e}");
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if !out.status.success() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let raw_u16: Vec<u16> = out
|
||||||
|
.stdout
|
||||||
|
.chunks_exact(2)
|
||||||
|
.map(|b| u16::from_le_bytes([b[0], b[1]]))
|
||||||
|
.collect();
|
||||||
|
let decoded = String::from_utf16_lossy(&raw_u16);
|
||||||
|
|
||||||
|
let distros: Vec<String> = decoded
|
||||||
|
.lines()
|
||||||
|
.map(|l| {
|
||||||
|
l.trim_matches(|c: char| c == '\u{FEFF}' || c.is_whitespace())
|
||||||
|
.to_string()
|
||||||
|
})
|
||||||
|
.filter(|l| !l.is_empty())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(distros)
|
||||||
|
}
|
||||||
46
src-tauri/tauri.conf.json
Normal file
46
src-tauri/tauri.conf.json
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
|
"productName": "tiletopia",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"identifier": "com.megaproxy.tiletopia",
|
||||||
|
"build": {
|
||||||
|
"beforeDevCommand": "pnpm dev",
|
||||||
|
"beforeBuildCommand": "pnpm build",
|
||||||
|
"devUrl": "http://localhost:1420",
|
||||||
|
"frontendDist": "../dist"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"label": "main",
|
||||||
|
"title": "tiletopia",
|
||||||
|
"width": 1100,
|
||||||
|
"height": 700,
|
||||||
|
"minWidth": 480,
|
||||||
|
"minHeight": 320,
|
||||||
|
"resizable": true,
|
||||||
|
"decorations": true,
|
||||||
|
"center": true,
|
||||||
|
"visible": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security": {
|
||||||
|
"csp": "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self' ipc: http://ipc.localhost"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"plugins": {},
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"targets": ["nsis"],
|
||||||
|
"icon": [
|
||||||
|
"icons/32x32.png",
|
||||||
|
"icons/128x128.png",
|
||||||
|
"icons/128x128@2x.png",
|
||||||
|
"icons/icon.icns",
|
||||||
|
"icons/icon.ico"
|
||||||
|
],
|
||||||
|
"category": "DeveloperTool",
|
||||||
|
"shortDescription": "Tiling multi-terminal manager for WSL",
|
||||||
|
"longDescription": "A Windows desktop app for managing many WSL terminals at once. Tile them with a splits-tree layout, save and restore workspaces, broadcast input across panes, get notified when a Claude session finishes. Built primarily for running multiple Claude Code sessions across projects in parallel."
|
||||||
|
}
|
||||||
|
}
|
||||||
112
src/App.svelte
Normal file
112
src/App.svelte
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import XtermPane from "./components/XtermPane.svelte";
|
||||||
|
import { listDistros } from "./ipc";
|
||||||
|
|
||||||
|
let distros = $state<string[]>([]);
|
||||||
|
let selected = $state<string | undefined>(undefined);
|
||||||
|
let status = $state("starting…");
|
||||||
|
let statusOk = $state(true);
|
||||||
|
let loadError = $state<string | null>(null);
|
||||||
|
|
||||||
|
function isInteractiveDistro(name: string): boolean {
|
||||||
|
return !name.toLowerCase().startsWith("docker-desktop");
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
const d = await listDistros();
|
||||||
|
console.log("listDistros() returned:", d);
|
||||||
|
distros = d;
|
||||||
|
// Pick fresh every mount (HMR can preserve $state across reloads).
|
||||||
|
selected = d.find(isInteractiveDistro) ?? d[0] ?? undefined;
|
||||||
|
console.log("default selected:", selected);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("list_distros failed:", e);
|
||||||
|
loadError = String(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function pick(d: string) {
|
||||||
|
console.log("user picked distro:", d);
|
||||||
|
selected = d;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="app">
|
||||||
|
<header class="titlebar">
|
||||||
|
<span class="label">tiletopia</span>
|
||||||
|
|
||||||
|
<span class="distros">
|
||||||
|
{#if distros.length === 0}
|
||||||
|
<span class="muted">no distros enumerated</span>
|
||||||
|
{:else}
|
||||||
|
{#each distros as d}
|
||||||
|
<button
|
||||||
|
class="distro-btn"
|
||||||
|
class:active={d === selected}
|
||||||
|
onclick={() => pick(d)}
|
||||||
|
>
|
||||||
|
{d}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="status {statusOk ? 'ok' : 'err'}">{status}</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="pane-wrap">
|
||||||
|
{#if loadError}
|
||||||
|
<pre class="err-pre">listDistros failed: {loadError}</pre>
|
||||||
|
{:else if selected !== undefined || distros.length === 0}
|
||||||
|
{#key selected}
|
||||||
|
<XtermPane
|
||||||
|
distro={selected}
|
||||||
|
onStatus={(msg, ok) => {
|
||||||
|
status = msg;
|
||||||
|
statusOk = ok;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/key}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.distros {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.distro-btn {
|
||||||
|
font: inherit;
|
||||||
|
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
background: #222;
|
||||||
|
color: #aaa;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.distro-btn:hover {
|
||||||
|
background: #2a2a2a;
|
||||||
|
color: #ddd;
|
||||||
|
}
|
||||||
|
.distro-btn.active {
|
||||||
|
background: #1a3a5c;
|
||||||
|
color: #cce6ff;
|
||||||
|
border-color: #2a5a8c;
|
||||||
|
}
|
||||||
|
.muted {
|
||||||
|
color: #666;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.err-pre {
|
||||||
|
color: #d66;
|
||||||
|
padding: 12px;
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
143
src/components/XtermPane.svelte
Normal file
143
src/components/XtermPane.svelte
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy } from "svelte";
|
||||||
|
import { Terminal } from "@xterm/xterm";
|
||||||
|
import { FitAddon } from "@xterm/addon-fit";
|
||||||
|
import type { UnlistenFn } from "@tauri-apps/api/event";
|
||||||
|
import {
|
||||||
|
spawnPane,
|
||||||
|
writeToPane,
|
||||||
|
resizePane,
|
||||||
|
killPane,
|
||||||
|
onPaneData,
|
||||||
|
onPaneExit,
|
||||||
|
type PaneId,
|
||||||
|
} from "../ipc";
|
||||||
|
|
||||||
|
let {
|
||||||
|
distro = undefined,
|
||||||
|
cwd = undefined,
|
||||||
|
onStatus = (_s: string, _ok: boolean) => {},
|
||||||
|
}: {
|
||||||
|
distro?: string;
|
||||||
|
cwd?: string;
|
||||||
|
onStatus?: (msg: string, ok: boolean) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let containerEl: HTMLDivElement;
|
||||||
|
let term: Terminal | null = null;
|
||||||
|
let fit: FitAddon | null = null;
|
||||||
|
let paneId: PaneId | null = null;
|
||||||
|
let unlistenData: UnlistenFn | null = null;
|
||||||
|
let unlistenExit: UnlistenFn | null = null;
|
||||||
|
let ro: ResizeObserver | null = null;
|
||||||
|
|
||||||
|
// Decode base64 -> Uint8Array. xterm.js accepts both strings and Uint8Array;
|
||||||
|
// bytes is preferred to avoid double-decoding UTF-8.
|
||||||
|
function b64ToBytes(b64: string): Uint8Array {
|
||||||
|
const bin = atob(b64);
|
||||||
|
const out = new Uint8Array(bin.length);
|
||||||
|
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bytesToB64(bytes: Uint8Array): string {
|
||||||
|
let s = "";
|
||||||
|
for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
|
||||||
|
return btoa(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringToB64(s: string): string {
|
||||||
|
// xterm.js's onData emits a JS string; need to UTF-8 encode before base64.
|
||||||
|
return bytesToB64(new TextEncoder().encode(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
term = new Terminal({
|
||||||
|
fontFamily: '"Cascadia Mono", "JetBrains Mono", "Consolas", monospace',
|
||||||
|
fontSize: 13,
|
||||||
|
cursorBlink: true,
|
||||||
|
theme: {
|
||||||
|
background: "#0c0c0c",
|
||||||
|
foreground: "#e6e6e6",
|
||||||
|
},
|
||||||
|
scrollback: 5000,
|
||||||
|
convertEol: false,
|
||||||
|
allowProposedApi: true,
|
||||||
|
});
|
||||||
|
fit = new FitAddon();
|
||||||
|
term.loadAddon(fit);
|
||||||
|
term.open(containerEl);
|
||||||
|
|
||||||
|
// Initial size — fit before we ask the PTY for its dimensions.
|
||||||
|
fit.fit();
|
||||||
|
const cols = term.cols;
|
||||||
|
const rows = term.rows;
|
||||||
|
|
||||||
|
try {
|
||||||
|
paneId = await spawnPane({ distro, cwd, cols, rows });
|
||||||
|
onStatus(`pane ${paneId} alive`, true);
|
||||||
|
} catch (e) {
|
||||||
|
const msg = `spawn_pane failed: ${e}`;
|
||||||
|
term.write(`\r\n\x1b[31m${msg}\x1b[0m\r\n`);
|
||||||
|
onStatus(msg, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
unlistenData = await onPaneData(paneId, (b64) => {
|
||||||
|
term?.write(b64ToBytes(b64));
|
||||||
|
});
|
||||||
|
unlistenExit = await onPaneExit(paneId, () => {
|
||||||
|
term?.write("\r\n\x1b[33m[pane exited]\x1b[0m\r\n");
|
||||||
|
onStatus(`pane ${paneId} exited`, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
term.onData((data) => {
|
||||||
|
if (paneId == null) return;
|
||||||
|
void writeToPane(paneId, stringToB64(data));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-fit on container resize; forward new size to the PTY.
|
||||||
|
ro = new ResizeObserver(() => {
|
||||||
|
try {
|
||||||
|
fit?.fit();
|
||||||
|
if (paneId != null && term) {
|
||||||
|
void resizePane(paneId, term.cols, term.rows);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("resize failed", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ro.observe(containerEl);
|
||||||
|
|
||||||
|
// Focus so typing immediately lands in the terminal.
|
||||||
|
term.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
ro?.disconnect();
|
||||||
|
unlistenData?.();
|
||||||
|
unlistenExit?.();
|
||||||
|
if (paneId != null) {
|
||||||
|
void killPane(paneId);
|
||||||
|
}
|
||||||
|
term?.dispose();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="xterm-host" bind:this={containerEl}></div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.xterm-host {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* xterm.js sets inline padding=0 on its container; ensure the viewport
|
||||||
|
fills the host with no scrollbar gap. */
|
||||||
|
:global(.xterm) {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
:global(.xterm-viewport) {
|
||||||
|
background: #0c0c0c !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
32
src/ipc.ts
Normal file
32
src/ipc.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||||
|
|
||||||
|
export type PaneId = number;
|
||||||
|
|
||||||
|
export const listDistros = (): Promise<string[]> => invoke("list_distros");
|
||||||
|
|
||||||
|
export const spawnPane = (args: {
|
||||||
|
distro?: string;
|
||||||
|
cwd?: string;
|
||||||
|
cols: number;
|
||||||
|
rows: number;
|
||||||
|
}): Promise<PaneId> => invoke("spawn_pane", args);
|
||||||
|
|
||||||
|
export const writeToPane = (id: PaneId, dataB64: string): Promise<void> =>
|
||||||
|
invoke("write_to_pane", { id, dataB64 });
|
||||||
|
|
||||||
|
export const resizePane = (id: PaneId, cols: number, rows: number): Promise<void> =>
|
||||||
|
invoke("resize_pane", { id, cols, rows });
|
||||||
|
|
||||||
|
export const killPane = (id: PaneId): Promise<void> => invoke("kill_pane", { id });
|
||||||
|
|
||||||
|
export const onPaneData = (
|
||||||
|
id: PaneId,
|
||||||
|
cb: (b64: string) => void,
|
||||||
|
): Promise<UnlistenFn> =>
|
||||||
|
listen<{ b64: string }>(`pane://${id}/data`, (e) => cb(e.payload.b64));
|
||||||
|
|
||||||
|
export const onPaneExit = (
|
||||||
|
id: PaneId,
|
||||||
|
cb: () => void,
|
||||||
|
): Promise<UnlistenFn> => listen(`pane://${id}/exit`, () => cb());
|
||||||
8
src/main.ts
Normal file
8
src/main.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { mount } from "svelte";
|
||||||
|
import App from "./App.svelte";
|
||||||
|
import "@xterm/xterm/css/xterm.css";
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
|
const app = mount(App, { target: document.getElementById("app")! });
|
||||||
|
|
||||||
|
export default app;
|
||||||
65
src/styles.css
Normal file
65
src/styles.css
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
background: #0c0c0c;
|
||||||
|
color: #e6e6e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#app {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
background: #0c0c0c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titlebar {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: #1a1a1a;
|
||||||
|
border-bottom: 1px solid #2a2a2a;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #aaa;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titlebar .label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titlebar .distro {
|
||||||
|
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
|
||||||
|
color: #88c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titlebar .status {
|
||||||
|
margin-left: auto;
|
||||||
|
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titlebar .status.ok {
|
||||||
|
color: #6c6;
|
||||||
|
}
|
||||||
|
.titlebar .status.err {
|
||||||
|
color: #d66;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane-wrap {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
5
svelte.config.js
Normal file
5
svelte.config.js
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
preprocess: vitePreprocess(),
|
||||||
|
};
|
||||||
20
tsconfig.json
Normal file
20
tsconfig.json
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
{
|
||||||
|
"extends": "@tsconfig/svelte/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"strict": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"verbatimModuleSyntax": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.svelte"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
11
tsconfig.node.json
Normal file
11
tsconfig.node.json
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
19
vite.config.ts
Normal file
19
vite.config.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
||||||
|
|
||||||
|
export default defineConfig(async () => ({
|
||||||
|
plugins: [svelte()],
|
||||||
|
clearScreen: false,
|
||||||
|
server: {
|
||||||
|
port: 1420,
|
||||||
|
strictPort: true,
|
||||||
|
host: "127.0.0.1",
|
||||||
|
hmr: { protocol: "ws", host: "127.0.0.1", port: 1421 },
|
||||||
|
watch: { ignored: ["**/src-tauri/**"] },
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
target: "esnext",
|
||||||
|
minify: "esbuild",
|
||||||
|
sourcemap: false,
|
||||||
|
},
|
||||||
|
}));
|
||||||
Loading…
Add table
Add a link
Reference in a new issue