Migrate frontend from Svelte 5 to React 18
After hours of fighting Svelte 5's prop-reactivity through the
recursive Pane → SplitNode → LeafPane chain (props captured at
mount, never updated; context+getter pattern crashed; DOM-direct
workarounds created zombie-split click-intercept bugs), we
checkpointed the Svelte version (branch svelte-archive at e9015b2,
tarball at D:\archives\tiletopia-svelte-2026-05-22.tar.gz) and
rewrote the frontend in React.
Kept verbatim:
- All of src-tauri/ (Rust backend, Tauri config, icons)
- scripts/ (make-icon.py, release.sh)
- README.md, CLAUDE.md, memory.md
- src/lib/layout/tree.ts (pure TS — 43 tests still pass)
- src/ipc.ts (Tauri command wrappers)
Rewrote in React:
- src/App.tsx (top-level state via useState, OrchestrationProvider
for descendants via React.Context)
- src/lib/layout/orchestration.tsx (React Context API for shared
state — known-reliable reactivity, no Svelte 5 wall)
- src/lib/layout/Pane.tsx (recursive dispatcher)
- src/lib/layout/SplitNode.tsx (draggable gutter, local ratio state)
- src/lib/layout/LeafPane.tsx (toolbar + XtermPane)
- src/components/XtermPane.tsx (xterm.js wrapper, refs for callbacks)
- src/components/Notifications.tsx, Palette.tsx
Build: Vite + @vitejs/plugin-react. TypeScript strict. Same Tauri 2
config. Verified: pnpm check (clean), pnpm test (43/43 pass).
Not yet verified: pnpm tauri dev — that requires the Windows host.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e9015b2790
commit
774b8633dc
32 changed files with 2087 additions and 1825 deletions
|
|
@ -1,4 +1,4 @@
|
||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
<title>tiletopia</title>
|
<title>tiletopia</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.ts"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
15
package.json
15
package.json
|
|
@ -5,24 +5,25 @@
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "tsc -b && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"check": "svelte-check --tsconfig ./tsconfig.json",
|
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
|
"check": "tsc --noEmit",
|
||||||
"tauri": "tauri"
|
"tauri": "tauri"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2.0.0",
|
"@tauri-apps/api": "^2.0.0",
|
||||||
"@xterm/addon-fit": "^0.10.0",
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
"@xterm/xterm": "^5.5.0"
|
"@xterm/xterm": "^5.5.0",
|
||||||
|
"react": "^18.3.0",
|
||||||
|
"react-dom": "^18.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
|
||||||
"@tauri-apps/cli": "^2.0.0",
|
"@tauri-apps/cli": "^2.0.0",
|
||||||
"@tsconfig/svelte": "^5.0.4",
|
"@types/react": "^18.3.0",
|
||||||
"svelte": "^5.0.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"svelte-check": "^4.0.0",
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
"typescript": "^5.6.0",
|
"typescript": "^5.6.0",
|
||||||
"vite": "^5.4.0",
|
"vite": "^5.4.0",
|
||||||
"vitest": "^2.0.0"
|
"vitest": "^2.0.0"
|
||||||
|
|
|
||||||
621
pnpm-lock.yaml
generated
621
pnpm-lock.yaml
generated
|
|
@ -17,22 +17,25 @@ importers:
|
||||||
'@xterm/xterm':
|
'@xterm/xterm':
|
||||||
specifier: ^5.5.0
|
specifier: ^5.5.0
|
||||||
version: 5.5.0
|
version: 5.5.0
|
||||||
|
react:
|
||||||
|
specifier: ^18.3.0
|
||||||
|
version: 18.3.1
|
||||||
|
react-dom:
|
||||||
|
specifier: ^18.3.0
|
||||||
|
version: 18.3.1(react@18.3.1)
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@sveltejs/vite-plugin-svelte':
|
|
||||||
specifier: ^4.0.0
|
|
||||||
version: 4.0.4(svelte@5.55.9)(vite@5.4.21)
|
|
||||||
'@tauri-apps/cli':
|
'@tauri-apps/cli':
|
||||||
specifier: ^2.0.0
|
specifier: ^2.0.0
|
||||||
version: 2.11.2
|
version: 2.11.2
|
||||||
'@tsconfig/svelte':
|
'@types/react':
|
||||||
specifier: ^5.0.4
|
specifier: ^18.3.0
|
||||||
version: 5.0.8
|
version: 18.3.29
|
||||||
svelte:
|
'@types/react-dom':
|
||||||
specifier: ^5.0.0
|
specifier: ^18.3.0
|
||||||
version: 5.55.9
|
version: 18.3.7(@types/react@18.3.29)
|
||||||
svelte-check:
|
'@vitejs/plugin-react':
|
||||||
specifier: ^4.0.0
|
specifier: ^4.3.0
|
||||||
version: 4.4.8(svelte@5.55.9)(typescript@5.9.3)
|
version: 4.7.0(vite@5.4.21)
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.6.0
|
specifier: ^5.6.0
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
|
|
@ -45,6 +48,89 @@ importers:
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
|
'@babel/code-frame@7.29.0':
|
||||||
|
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
|
||||||
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
|
'@babel/compat-data@7.29.3':
|
||||||
|
resolution: {integrity: sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==}
|
||||||
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
|
'@babel/core@7.29.0':
|
||||||
|
resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==}
|
||||||
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
|
'@babel/generator@7.29.1':
|
||||||
|
resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==}
|
||||||
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
|
'@babel/helper-compilation-targets@7.28.6':
|
||||||
|
resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==}
|
||||||
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
|
'@babel/helper-globals@7.28.0':
|
||||||
|
resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==}
|
||||||
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
|
'@babel/helper-module-imports@7.28.6':
|
||||||
|
resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==}
|
||||||
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
|
'@babel/helper-module-transforms@7.28.6':
|
||||||
|
resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==}
|
||||||
|
engines: {node: '>=6.9.0'}
|
||||||
|
peerDependencies:
|
||||||
|
'@babel/core': ^7.0.0
|
||||||
|
|
||||||
|
'@babel/helper-plugin-utils@7.28.6':
|
||||||
|
resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==}
|
||||||
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
|
'@babel/helper-string-parser@7.27.1':
|
||||||
|
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
|
||||||
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
|
'@babel/helper-validator-identifier@7.28.5':
|
||||||
|
resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
|
||||||
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
|
'@babel/helper-validator-option@7.27.1':
|
||||||
|
resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==}
|
||||||
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
|
'@babel/helpers@7.29.2':
|
||||||
|
resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==}
|
||||||
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
|
'@babel/parser@7.29.3':
|
||||||
|
resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==}
|
||||||
|
engines: {node: '>=6.0.0'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
'@babel/plugin-transform-react-jsx-self@7.27.1':
|
||||||
|
resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==}
|
||||||
|
engines: {node: '>=6.9.0'}
|
||||||
|
peerDependencies:
|
||||||
|
'@babel/core': ^7.0.0-0
|
||||||
|
|
||||||
|
'@babel/plugin-transform-react-jsx-source@7.27.1':
|
||||||
|
resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==}
|
||||||
|
engines: {node: '>=6.9.0'}
|
||||||
|
peerDependencies:
|
||||||
|
'@babel/core': ^7.0.0-0
|
||||||
|
|
||||||
|
'@babel/template@7.28.6':
|
||||||
|
resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==}
|
||||||
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
|
'@babel/traverse@7.29.0':
|
||||||
|
resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==}
|
||||||
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
|
'@babel/types@7.29.0':
|
||||||
|
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
|
||||||
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
'@esbuild/aix-ppc64@0.21.5':
|
'@esbuild/aix-ppc64@0.21.5':
|
||||||
resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
|
resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
@ -199,6 +285,9 @@ packages:
|
||||||
'@jridgewell/trace-mapping@0.3.31':
|
'@jridgewell/trace-mapping@0.3.31':
|
||||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||||
|
|
||||||
|
'@rolldown/pluginutils@1.0.0-beta.27':
|
||||||
|
resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
|
||||||
|
|
||||||
'@rollup/rollup-android-arm-eabi@4.60.4':
|
'@rollup/rollup-android-arm-eabi@4.60.4':
|
||||||
resolution: {integrity: sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==}
|
resolution: {integrity: sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
|
|
@ -337,26 +426,6 @@ packages:
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@sveltejs/acorn-typescript@1.0.10':
|
|
||||||
resolution: {integrity: sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA==}
|
|
||||||
peerDependencies:
|
|
||||||
acorn: ^8.9.0
|
|
||||||
|
|
||||||
'@sveltejs/vite-plugin-svelte-inspector@3.0.1':
|
|
||||||
resolution: {integrity: sha512-2CKypmj1sM4GE7HjllT7UKmo4Q6L5xFRd7VMGEWhYnZ+wc6AUVU01IBd7yUi6WnFndEwWoMNOd6e8UjoN0nbvQ==}
|
|
||||||
engines: {node: ^18.0.0 || ^20.0.0 || >=22}
|
|
||||||
peerDependencies:
|
|
||||||
'@sveltejs/vite-plugin-svelte': ^4.0.0-next.0||^4.0.0
|
|
||||||
svelte: ^5.0.0-next.96 || ^5.0.0
|
|
||||||
vite: ^5.0.0
|
|
||||||
|
|
||||||
'@sveltejs/vite-plugin-svelte@4.0.4':
|
|
||||||
resolution: {integrity: sha512-0ba1RQ/PHen5FGpdSrW7Y3fAMQjrXantECALeOiOdBdzR5+5vPP6HVZRLmZaQL+W8m++o+haIAKq5qT+MiZ7VA==}
|
|
||||||
engines: {node: ^18.0.0 || ^20.0.0 || >=22}
|
|
||||||
peerDependencies:
|
|
||||||
svelte: ^5.0.0-next.96 || ^5.0.0
|
|
||||||
vite: ^5.0.0
|
|
||||||
|
|
||||||
'@tauri-apps/api@2.11.0':
|
'@tauri-apps/api@2.11.0':
|
||||||
resolution: {integrity: sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==}
|
resolution: {integrity: sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==}
|
||||||
|
|
||||||
|
|
@ -436,8 +505,17 @@ packages:
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
'@tsconfig/svelte@5.0.8':
|
'@types/babel__core@7.20.5':
|
||||||
resolution: {integrity: sha512-UkNnw1/oFEfecR8ypyHIQuWYdkPvHiwcQ78sh+ymIiYoF+uc5H1UBetbjyqT+vgGJ3qQN6nhucJviX6HesWtKQ==}
|
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
|
||||||
|
|
||||||
|
'@types/babel__generator@7.27.0':
|
||||||
|
resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==}
|
||||||
|
|
||||||
|
'@types/babel__template@7.4.4':
|
||||||
|
resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==}
|
||||||
|
|
||||||
|
'@types/babel__traverse@7.28.0':
|
||||||
|
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
|
||||||
|
|
||||||
'@types/estree@1.0.8':
|
'@types/estree@1.0.8':
|
||||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||||
|
|
@ -445,8 +523,22 @@ packages:
|
||||||
'@types/estree@1.0.9':
|
'@types/estree@1.0.9':
|
||||||
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
|
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
|
||||||
|
|
||||||
'@types/trusted-types@2.0.7':
|
'@types/prop-types@15.7.15':
|
||||||
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
|
||||||
|
|
||||||
|
'@types/react-dom@18.3.7':
|
||||||
|
resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': ^18.0.0
|
||||||
|
|
||||||
|
'@types/react@18.3.29':
|
||||||
|
resolution: {integrity: sha512-ch0qJdr2JY0r04NXSprbK6TXOgnaJ1Tz23fm5W+z0/CBah6BSBc3n96h7K9GOtwh0HrilNWHIBzE1Ko4Dcw/Wg==}
|
||||||
|
|
||||||
|
'@vitejs/plugin-react@4.7.0':
|
||||||
|
resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==}
|
||||||
|
engines: {node: ^14.18.0 || >=16.0.0}
|
||||||
|
peerDependencies:
|
||||||
|
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
|
||||||
|
|
||||||
'@vitest/expect@2.1.9':
|
'@vitest/expect@2.1.9':
|
||||||
resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==}
|
resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==}
|
||||||
|
|
@ -485,27 +577,27 @@ packages:
|
||||||
'@xterm/xterm@5.5.0':
|
'@xterm/xterm@5.5.0':
|
||||||
resolution: {integrity: sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==}
|
resolution: {integrity: sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==}
|
||||||
|
|
||||||
acorn@8.16.0:
|
|
||||||
resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==}
|
|
||||||
engines: {node: '>=0.4.0'}
|
|
||||||
hasBin: true
|
|
||||||
|
|
||||||
aria-query@5.3.1:
|
|
||||||
resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==}
|
|
||||||
engines: {node: '>= 0.4'}
|
|
||||||
|
|
||||||
assertion-error@2.0.1:
|
assertion-error@2.0.1:
|
||||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
axobject-query@4.1.0:
|
baseline-browser-mapping@2.10.31:
|
||||||
resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
|
resolution: {integrity: sha512-MujYO3eP72uvmSE0i4wltsodRfIpZATP3jvzRNRGGxgzId7aVocVJJV3nf01qnzzKFGxQVC9bpWxl5cjxTr/7Q==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>=6.0.0'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
browserslist@4.28.2:
|
||||||
|
resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==}
|
||||||
|
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
cac@6.7.14:
|
cac@6.7.14:
|
||||||
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
|
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
caniuse-lite@1.0.30001793:
|
||||||
|
resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==}
|
||||||
|
|
||||||
chai@5.3.3:
|
chai@5.3.3:
|
||||||
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
|
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -514,13 +606,11 @@ packages:
|
||||||
resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
|
resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
|
||||||
engines: {node: '>= 16'}
|
engines: {node: '>= 16'}
|
||||||
|
|
||||||
chokidar@4.0.3:
|
convert-source-map@2.0.0:
|
||||||
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
|
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
||||||
engines: {node: '>= 14.16.0'}
|
|
||||||
|
|
||||||
clsx@2.1.1:
|
csstype@3.2.3:
|
||||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||||
engines: {node: '>=6'}
|
|
||||||
|
|
||||||
debug@4.4.3:
|
debug@4.4.3:
|
||||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||||
|
|
@ -535,12 +625,8 @@ packages:
|
||||||
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
|
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
deepmerge@4.3.1:
|
electron-to-chromium@1.5.360:
|
||||||
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
|
resolution: {integrity: sha512-GkcBt6YYAw9SxFWn+xVar4cLVGlXVuswwtRLBozi2zp0GjXs4ZnOrqV4zbXzg35n7w81hCkyJNYicgXlVHAmBA==}
|
||||||
engines: {node: '>=0.10.0'}
|
|
||||||
|
|
||||||
devalue@5.8.1:
|
|
||||||
resolution: {integrity: sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==}
|
|
||||||
|
|
||||||
es-module-lexer@1.7.0:
|
es-module-lexer@1.7.0:
|
||||||
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
|
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
|
||||||
|
|
@ -550,16 +636,9 @@ packages:
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
esm-env@1.2.2:
|
escalade@3.2.0:
|
||||||
resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==}
|
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
esrap@2.2.9:
|
|
||||||
resolution: {integrity: sha512-4KijP+NxCWthMCUC3qHbE6n4vCjqgJS1uAYKhuT/GWfFTf1Qyive2TgOjep+gzbSzRfnNyaN/UU9YmdOt8Eg0A==}
|
|
||||||
peerDependencies:
|
|
||||||
'@typescript-eslint/types': ^8.2.0
|
|
||||||
peerDependenciesMeta:
|
|
||||||
'@typescript-eslint/types':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
estree-walker@3.0.3:
|
estree-walker@3.0.3:
|
||||||
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
|
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
|
||||||
|
|
@ -568,40 +647,41 @@ packages:
|
||||||
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
|
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
|
|
||||||
fdir@6.5.0:
|
|
||||||
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
|
||||||
engines: {node: '>=12.0.0'}
|
|
||||||
peerDependencies:
|
|
||||||
picomatch: ^3 || ^4
|
|
||||||
peerDependenciesMeta:
|
|
||||||
picomatch:
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
fsevents@2.3.3:
|
fsevents@2.3.3:
|
||||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
is-reference@3.0.3:
|
gensync@1.0.0-beta.2:
|
||||||
resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==}
|
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
|
||||||
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
kleur@4.1.5:
|
js-tokens@4.0.0:
|
||||||
resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
|
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||||
|
|
||||||
|
jsesc@3.1.0:
|
||||||
|
resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
locate-character@3.0.0:
|
json5@2.2.3:
|
||||||
resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==}
|
resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
loose-envify@1.4.0:
|
||||||
|
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
loupe@3.2.1:
|
loupe@3.2.1:
|
||||||
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
|
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
|
||||||
|
|
||||||
|
lru-cache@5.1.1:
|
||||||
|
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
||||||
|
|
||||||
magic-string@0.30.21:
|
magic-string@0.30.21:
|
||||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||||
|
|
||||||
mri@1.2.0:
|
|
||||||
resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
|
|
||||||
engines: {node: '>=4'}
|
|
||||||
|
|
||||||
ms@2.1.3:
|
ms@2.1.3:
|
||||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||||
|
|
||||||
|
|
@ -610,6 +690,10 @@ packages:
|
||||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
node-releases@2.0.45:
|
||||||
|
resolution: {integrity: sha512-iIbHXV9eBB2nB0wa7oTsrrXq+qQt+9SIlx9AX3T96YgobtEQfis5n6TJ6vV+3QP8DwdriEAcGhARaFCu37peBg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
pathe@1.1.2:
|
pathe@1.1.2:
|
||||||
resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==}
|
resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==}
|
||||||
|
|
||||||
|
|
@ -624,18 +708,30 @@ packages:
|
||||||
resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
|
resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
|
||||||
engines: {node: ^10 || ^12 || >=14}
|
engines: {node: ^10 || ^12 || >=14}
|
||||||
|
|
||||||
readdirp@4.1.2:
|
react-dom@18.3.1:
|
||||||
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
|
resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==}
|
||||||
engines: {node: '>= 14.18.0'}
|
peerDependencies:
|
||||||
|
react: ^18.3.1
|
||||||
|
|
||||||
|
react-refresh@0.17.0:
|
||||||
|
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
react@18.3.1:
|
||||||
|
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
rollup@4.60.4:
|
rollup@4.60.4:
|
||||||
resolution: {integrity: sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==}
|
resolution: {integrity: sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==}
|
||||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
sade@1.8.1:
|
scheduler@0.23.2:
|
||||||
resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==}
|
resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==}
|
||||||
engines: {node: '>=6'}
|
|
||||||
|
semver@6.3.1:
|
||||||
|
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
siginfo@2.0.0:
|
siginfo@2.0.0:
|
||||||
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
||||||
|
|
@ -650,18 +746,6 @@ packages:
|
||||||
std-env@3.10.0:
|
std-env@3.10.0:
|
||||||
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
|
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
|
||||||
|
|
||||||
svelte-check@4.4.8:
|
|
||||||
resolution: {integrity: sha512-67adfgBox5eNSNIvIIwgFizKGdcRrGpiMoNO2obHcYuLz7iTa8Xgm/NGU3ntMFnNm8K1grFOIG6HhMLX/vcN8w==}
|
|
||||||
engines: {node: '>= 18.0.0'}
|
|
||||||
hasBin: true
|
|
||||||
peerDependencies:
|
|
||||||
svelte: ^4.0.0 || ^5.0.0-next.0
|
|
||||||
typescript: '>=5.0.0'
|
|
||||||
|
|
||||||
svelte@5.55.9:
|
|
||||||
resolution: {integrity: sha512-fTjjT8cHLDwigcu2j3pv7Jq04LklXevPB8uBgyHNiTXv+RMNvVnrjS4UEYrLMkhuq1vpCodHjiW+z/95SDs/fg==}
|
|
||||||
engines: {node: '>=18'}
|
|
||||||
|
|
||||||
tinybench@2.9.0:
|
tinybench@2.9.0:
|
||||||
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
||||||
|
|
||||||
|
|
@ -685,6 +769,12 @@ packages:
|
||||||
engines: {node: '>=14.17'}
|
engines: {node: '>=14.17'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
update-browserslist-db@1.2.3:
|
||||||
|
resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
|
||||||
|
hasBin: true
|
||||||
|
peerDependencies:
|
||||||
|
browserslist: '>= 4.21.0'
|
||||||
|
|
||||||
vite-node@2.1.9:
|
vite-node@2.1.9:
|
||||||
resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==}
|
resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==}
|
||||||
engines: {node: ^18.0.0 || >=20.0.0}
|
engines: {node: ^18.0.0 || >=20.0.0}
|
||||||
|
|
@ -721,14 +811,6 @@ packages:
|
||||||
terser:
|
terser:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
vitefu@1.1.3:
|
|
||||||
resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==}
|
|
||||||
peerDependencies:
|
|
||||||
vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
|
|
||||||
peerDependenciesMeta:
|
|
||||||
vite:
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
vitest@2.1.9:
|
vitest@2.1.9:
|
||||||
resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==}
|
resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==}
|
||||||
engines: {node: ^18.0.0 || >=20.0.0}
|
engines: {node: ^18.0.0 || >=20.0.0}
|
||||||
|
|
@ -759,11 +841,123 @@ packages:
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
zimmerframe@1.1.4:
|
yallist@3.1.1:
|
||||||
resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==}
|
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
||||||
|
|
||||||
snapshots:
|
snapshots:
|
||||||
|
|
||||||
|
'@babel/code-frame@7.29.0':
|
||||||
|
dependencies:
|
||||||
|
'@babel/helper-validator-identifier': 7.28.5
|
||||||
|
js-tokens: 4.0.0
|
||||||
|
picocolors: 1.1.1
|
||||||
|
|
||||||
|
'@babel/compat-data@7.29.3': {}
|
||||||
|
|
||||||
|
'@babel/core@7.29.0':
|
||||||
|
dependencies:
|
||||||
|
'@babel/code-frame': 7.29.0
|
||||||
|
'@babel/generator': 7.29.1
|
||||||
|
'@babel/helper-compilation-targets': 7.28.6
|
||||||
|
'@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0)
|
||||||
|
'@babel/helpers': 7.29.2
|
||||||
|
'@babel/parser': 7.29.3
|
||||||
|
'@babel/template': 7.28.6
|
||||||
|
'@babel/traverse': 7.29.0
|
||||||
|
'@babel/types': 7.29.0
|
||||||
|
'@jridgewell/remapping': 2.3.5
|
||||||
|
convert-source-map: 2.0.0
|
||||||
|
debug: 4.4.3
|
||||||
|
gensync: 1.0.0-beta.2
|
||||||
|
json5: 2.2.3
|
||||||
|
semver: 6.3.1
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
|
'@babel/generator@7.29.1':
|
||||||
|
dependencies:
|
||||||
|
'@babel/parser': 7.29.3
|
||||||
|
'@babel/types': 7.29.0
|
||||||
|
'@jridgewell/gen-mapping': 0.3.13
|
||||||
|
'@jridgewell/trace-mapping': 0.3.31
|
||||||
|
jsesc: 3.1.0
|
||||||
|
|
||||||
|
'@babel/helper-compilation-targets@7.28.6':
|
||||||
|
dependencies:
|
||||||
|
'@babel/compat-data': 7.29.3
|
||||||
|
'@babel/helper-validator-option': 7.27.1
|
||||||
|
browserslist: 4.28.2
|
||||||
|
lru-cache: 5.1.1
|
||||||
|
semver: 6.3.1
|
||||||
|
|
||||||
|
'@babel/helper-globals@7.28.0': {}
|
||||||
|
|
||||||
|
'@babel/helper-module-imports@7.28.6':
|
||||||
|
dependencies:
|
||||||
|
'@babel/traverse': 7.29.0
|
||||||
|
'@babel/types': 7.29.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
|
'@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)':
|
||||||
|
dependencies:
|
||||||
|
'@babel/core': 7.29.0
|
||||||
|
'@babel/helper-module-imports': 7.28.6
|
||||||
|
'@babel/helper-validator-identifier': 7.28.5
|
||||||
|
'@babel/traverse': 7.29.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
|
'@babel/helper-plugin-utils@7.28.6': {}
|
||||||
|
|
||||||
|
'@babel/helper-string-parser@7.27.1': {}
|
||||||
|
|
||||||
|
'@babel/helper-validator-identifier@7.28.5': {}
|
||||||
|
|
||||||
|
'@babel/helper-validator-option@7.27.1': {}
|
||||||
|
|
||||||
|
'@babel/helpers@7.29.2':
|
||||||
|
dependencies:
|
||||||
|
'@babel/template': 7.28.6
|
||||||
|
'@babel/types': 7.29.0
|
||||||
|
|
||||||
|
'@babel/parser@7.29.3':
|
||||||
|
dependencies:
|
||||||
|
'@babel/types': 7.29.0
|
||||||
|
|
||||||
|
'@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)':
|
||||||
|
dependencies:
|
||||||
|
'@babel/core': 7.29.0
|
||||||
|
'@babel/helper-plugin-utils': 7.28.6
|
||||||
|
|
||||||
|
'@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)':
|
||||||
|
dependencies:
|
||||||
|
'@babel/core': 7.29.0
|
||||||
|
'@babel/helper-plugin-utils': 7.28.6
|
||||||
|
|
||||||
|
'@babel/template@7.28.6':
|
||||||
|
dependencies:
|
||||||
|
'@babel/code-frame': 7.29.0
|
||||||
|
'@babel/parser': 7.29.3
|
||||||
|
'@babel/types': 7.29.0
|
||||||
|
|
||||||
|
'@babel/traverse@7.29.0':
|
||||||
|
dependencies:
|
||||||
|
'@babel/code-frame': 7.29.0
|
||||||
|
'@babel/generator': 7.29.1
|
||||||
|
'@babel/helper-globals': 7.28.0
|
||||||
|
'@babel/parser': 7.29.3
|
||||||
|
'@babel/template': 7.28.6
|
||||||
|
'@babel/types': 7.29.0
|
||||||
|
debug: 4.4.3
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
|
'@babel/types@7.29.0':
|
||||||
|
dependencies:
|
||||||
|
'@babel/helper-string-parser': 7.27.1
|
||||||
|
'@babel/helper-validator-identifier': 7.28.5
|
||||||
|
|
||||||
'@esbuild/aix-ppc64@0.21.5':
|
'@esbuild/aix-ppc64@0.21.5':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|
@ -852,6 +1046,8 @@ snapshots:
|
||||||
'@jridgewell/resolve-uri': 3.1.2
|
'@jridgewell/resolve-uri': 3.1.2
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
|
||||||
|
'@rolldown/pluginutils@1.0.0-beta.27': {}
|
||||||
|
|
||||||
'@rollup/rollup-android-arm-eabi@4.60.4':
|
'@rollup/rollup-android-arm-eabi@4.60.4':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|
@ -927,32 +1123,6 @@ snapshots:
|
||||||
'@rollup/rollup-win32-x64-msvc@4.60.4':
|
'@rollup/rollup-win32-x64-msvc@4.60.4':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@sveltejs/acorn-typescript@1.0.10(acorn@8.16.0)':
|
|
||||||
dependencies:
|
|
||||||
acorn: 8.16.0
|
|
||||||
|
|
||||||
'@sveltejs/vite-plugin-svelte-inspector@3.0.1(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.55.9)(vite@5.4.21))(svelte@5.55.9)(vite@5.4.21)':
|
|
||||||
dependencies:
|
|
||||||
'@sveltejs/vite-plugin-svelte': 4.0.4(svelte@5.55.9)(vite@5.4.21)
|
|
||||||
debug: 4.4.3
|
|
||||||
svelte: 5.55.9
|
|
||||||
vite: 5.4.21
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- supports-color
|
|
||||||
|
|
||||||
'@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.55.9)(vite@5.4.21)':
|
|
||||||
dependencies:
|
|
||||||
'@sveltejs/vite-plugin-svelte-inspector': 3.0.1(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.55.9)(vite@5.4.21))(svelte@5.55.9)(vite@5.4.21)
|
|
||||||
debug: 4.4.3
|
|
||||||
deepmerge: 4.3.1
|
|
||||||
kleur: 4.1.5
|
|
||||||
magic-string: 0.30.21
|
|
||||||
svelte: 5.55.9
|
|
||||||
vite: 5.4.21
|
|
||||||
vitefu: 1.1.3(vite@5.4.21)
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- supports-color
|
|
||||||
|
|
||||||
'@tauri-apps/api@2.11.0': {}
|
'@tauri-apps/api@2.11.0': {}
|
||||||
|
|
||||||
'@tauri-apps/cli-darwin-arm64@2.11.2':
|
'@tauri-apps/cli-darwin-arm64@2.11.2':
|
||||||
|
|
@ -1002,13 +1172,53 @@ snapshots:
|
||||||
'@tauri-apps/cli-win32-ia32-msvc': 2.11.2
|
'@tauri-apps/cli-win32-ia32-msvc': 2.11.2
|
||||||
'@tauri-apps/cli-win32-x64-msvc': 2.11.2
|
'@tauri-apps/cli-win32-x64-msvc': 2.11.2
|
||||||
|
|
||||||
'@tsconfig/svelte@5.0.8': {}
|
'@types/babel__core@7.20.5':
|
||||||
|
dependencies:
|
||||||
|
'@babel/parser': 7.29.3
|
||||||
|
'@babel/types': 7.29.0
|
||||||
|
'@types/babel__generator': 7.27.0
|
||||||
|
'@types/babel__template': 7.4.4
|
||||||
|
'@types/babel__traverse': 7.28.0
|
||||||
|
|
||||||
|
'@types/babel__generator@7.27.0':
|
||||||
|
dependencies:
|
||||||
|
'@babel/types': 7.29.0
|
||||||
|
|
||||||
|
'@types/babel__template@7.4.4':
|
||||||
|
dependencies:
|
||||||
|
'@babel/parser': 7.29.3
|
||||||
|
'@babel/types': 7.29.0
|
||||||
|
|
||||||
|
'@types/babel__traverse@7.28.0':
|
||||||
|
dependencies:
|
||||||
|
'@babel/types': 7.29.0
|
||||||
|
|
||||||
'@types/estree@1.0.8': {}
|
'@types/estree@1.0.8': {}
|
||||||
|
|
||||||
'@types/estree@1.0.9': {}
|
'@types/estree@1.0.9': {}
|
||||||
|
|
||||||
'@types/trusted-types@2.0.7': {}
|
'@types/prop-types@15.7.15': {}
|
||||||
|
|
||||||
|
'@types/react-dom@18.3.7(@types/react@18.3.29)':
|
||||||
|
dependencies:
|
||||||
|
'@types/react': 18.3.29
|
||||||
|
|
||||||
|
'@types/react@18.3.29':
|
||||||
|
dependencies:
|
||||||
|
'@types/prop-types': 15.7.15
|
||||||
|
csstype: 3.2.3
|
||||||
|
|
||||||
|
'@vitejs/plugin-react@4.7.0(vite@5.4.21)':
|
||||||
|
dependencies:
|
||||||
|
'@babel/core': 7.29.0
|
||||||
|
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0)
|
||||||
|
'@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0)
|
||||||
|
'@rolldown/pluginutils': 1.0.0-beta.27
|
||||||
|
'@types/babel__core': 7.20.5
|
||||||
|
react-refresh: 0.17.0
|
||||||
|
vite: 5.4.21
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
'@vitest/expect@2.1.9':
|
'@vitest/expect@2.1.9':
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
@ -1056,16 +1266,22 @@ snapshots:
|
||||||
|
|
||||||
'@xterm/xterm@5.5.0': {}
|
'@xterm/xterm@5.5.0': {}
|
||||||
|
|
||||||
acorn@8.16.0: {}
|
|
||||||
|
|
||||||
aria-query@5.3.1: {}
|
|
||||||
|
|
||||||
assertion-error@2.0.1: {}
|
assertion-error@2.0.1: {}
|
||||||
|
|
||||||
axobject-query@4.1.0: {}
|
baseline-browser-mapping@2.10.31: {}
|
||||||
|
|
||||||
|
browserslist@4.28.2:
|
||||||
|
dependencies:
|
||||||
|
baseline-browser-mapping: 2.10.31
|
||||||
|
caniuse-lite: 1.0.30001793
|
||||||
|
electron-to-chromium: 1.5.360
|
||||||
|
node-releases: 2.0.45
|
||||||
|
update-browserslist-db: 1.2.3(browserslist@4.28.2)
|
||||||
|
|
||||||
cac@6.7.14: {}
|
cac@6.7.14: {}
|
||||||
|
|
||||||
|
caniuse-lite@1.0.30001793: {}
|
||||||
|
|
||||||
chai@5.3.3:
|
chai@5.3.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
assertion-error: 2.0.1
|
assertion-error: 2.0.1
|
||||||
|
|
@ -1076,11 +1292,9 @@ snapshots:
|
||||||
|
|
||||||
check-error@2.1.3: {}
|
check-error@2.1.3: {}
|
||||||
|
|
||||||
chokidar@4.0.3:
|
convert-source-map@2.0.0: {}
|
||||||
dependencies:
|
|
||||||
readdirp: 4.1.2
|
|
||||||
|
|
||||||
clsx@2.1.1: {}
|
csstype@3.2.3: {}
|
||||||
|
|
||||||
debug@4.4.3:
|
debug@4.4.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
@ -1088,9 +1302,7 @@ snapshots:
|
||||||
|
|
||||||
deep-eql@5.0.2: {}
|
deep-eql@5.0.2: {}
|
||||||
|
|
||||||
deepmerge@4.3.1: {}
|
electron-to-chromium@1.5.360: {}
|
||||||
|
|
||||||
devalue@5.8.1: {}
|
|
||||||
|
|
||||||
es-module-lexer@1.7.0: {}
|
es-module-lexer@1.7.0: {}
|
||||||
|
|
||||||
|
|
@ -1120,11 +1332,7 @@ snapshots:
|
||||||
'@esbuild/win32-ia32': 0.21.5
|
'@esbuild/win32-ia32': 0.21.5
|
||||||
'@esbuild/win32-x64': 0.21.5
|
'@esbuild/win32-x64': 0.21.5
|
||||||
|
|
||||||
esm-env@1.2.2: {}
|
escalade@3.2.0: {}
|
||||||
|
|
||||||
esrap@2.2.9:
|
|
||||||
dependencies:
|
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
|
||||||
|
|
||||||
estree-walker@3.0.3:
|
estree-walker@3.0.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
@ -1132,31 +1340,37 @@ snapshots:
|
||||||
|
|
||||||
expect-type@1.3.0: {}
|
expect-type@1.3.0: {}
|
||||||
|
|
||||||
fdir@6.5.0: {}
|
|
||||||
|
|
||||||
fsevents@2.3.3:
|
fsevents@2.3.3:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
is-reference@3.0.3:
|
gensync@1.0.0-beta.2: {}
|
||||||
|
|
||||||
|
js-tokens@4.0.0: {}
|
||||||
|
|
||||||
|
jsesc@3.1.0: {}
|
||||||
|
|
||||||
|
json5@2.2.3: {}
|
||||||
|
|
||||||
|
loose-envify@1.4.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/estree': 1.0.9
|
js-tokens: 4.0.0
|
||||||
|
|
||||||
kleur@4.1.5: {}
|
|
||||||
|
|
||||||
locate-character@3.0.0: {}
|
|
||||||
|
|
||||||
loupe@3.2.1: {}
|
loupe@3.2.1: {}
|
||||||
|
|
||||||
|
lru-cache@5.1.1:
|
||||||
|
dependencies:
|
||||||
|
yallist: 3.1.1
|
||||||
|
|
||||||
magic-string@0.30.21:
|
magic-string@0.30.21:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
|
||||||
mri@1.2.0: {}
|
|
||||||
|
|
||||||
ms@2.1.3: {}
|
ms@2.1.3: {}
|
||||||
|
|
||||||
nanoid@3.3.12: {}
|
nanoid@3.3.12: {}
|
||||||
|
|
||||||
|
node-releases@2.0.45: {}
|
||||||
|
|
||||||
pathe@1.1.2: {}
|
pathe@1.1.2: {}
|
||||||
|
|
||||||
pathval@2.0.1: {}
|
pathval@2.0.1: {}
|
||||||
|
|
@ -1169,7 +1383,17 @@ snapshots:
|
||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
source-map-js: 1.2.1
|
source-map-js: 1.2.1
|
||||||
|
|
||||||
readdirp@4.1.2: {}
|
react-dom@18.3.1(react@18.3.1):
|
||||||
|
dependencies:
|
||||||
|
loose-envify: 1.4.0
|
||||||
|
react: 18.3.1
|
||||||
|
scheduler: 0.23.2
|
||||||
|
|
||||||
|
react-refresh@0.17.0: {}
|
||||||
|
|
||||||
|
react@18.3.1:
|
||||||
|
dependencies:
|
||||||
|
loose-envify: 1.4.0
|
||||||
|
|
||||||
rollup@4.60.4:
|
rollup@4.60.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
@ -1202,9 +1426,11 @@ snapshots:
|
||||||
'@rollup/rollup-win32-x64-msvc': 4.60.4
|
'@rollup/rollup-win32-x64-msvc': 4.60.4
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
|
|
||||||
sade@1.8.1:
|
scheduler@0.23.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
mri: 1.2.0
|
loose-envify: 1.4.0
|
||||||
|
|
||||||
|
semver@6.3.1: {}
|
||||||
|
|
||||||
siginfo@2.0.0: {}
|
siginfo@2.0.0: {}
|
||||||
|
|
||||||
|
|
@ -1214,39 +1440,6 @@ snapshots:
|
||||||
|
|
||||||
std-env@3.10.0: {}
|
std-env@3.10.0: {}
|
||||||
|
|
||||||
svelte-check@4.4.8(svelte@5.55.9)(typescript@5.9.3):
|
|
||||||
dependencies:
|
|
||||||
'@jridgewell/trace-mapping': 0.3.31
|
|
||||||
chokidar: 4.0.3
|
|
||||||
fdir: 6.5.0
|
|
||||||
picocolors: 1.1.1
|
|
||||||
sade: 1.8.1
|
|
||||||
svelte: 5.55.9
|
|
||||||
typescript: 5.9.3
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- picomatch
|
|
||||||
|
|
||||||
svelte@5.55.9:
|
|
||||||
dependencies:
|
|
||||||
'@jridgewell/remapping': 2.3.5
|
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
|
||||||
'@sveltejs/acorn-typescript': 1.0.10(acorn@8.16.0)
|
|
||||||
'@types/estree': 1.0.9
|
|
||||||
'@types/trusted-types': 2.0.7
|
|
||||||
acorn: 8.16.0
|
|
||||||
aria-query: 5.3.1
|
|
||||||
axobject-query: 4.1.0
|
|
||||||
clsx: 2.1.1
|
|
||||||
devalue: 5.8.1
|
|
||||||
esm-env: 1.2.2
|
|
||||||
esrap: 2.2.9
|
|
||||||
is-reference: 3.0.3
|
|
||||||
locate-character: 3.0.0
|
|
||||||
magic-string: 0.30.21
|
|
||||||
zimmerframe: 1.1.4
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- '@typescript-eslint/types'
|
|
||||||
|
|
||||||
tinybench@2.9.0: {}
|
tinybench@2.9.0: {}
|
||||||
|
|
||||||
tinyexec@0.3.2: {}
|
tinyexec@0.3.2: {}
|
||||||
|
|
@ -1259,6 +1452,12 @@ snapshots:
|
||||||
|
|
||||||
typescript@5.9.3: {}
|
typescript@5.9.3: {}
|
||||||
|
|
||||||
|
update-browserslist-db@1.2.3(browserslist@4.28.2):
|
||||||
|
dependencies:
|
||||||
|
browserslist: 4.28.2
|
||||||
|
escalade: 3.2.0
|
||||||
|
picocolors: 1.1.1
|
||||||
|
|
||||||
vite-node@2.1.9:
|
vite-node@2.1.9:
|
||||||
dependencies:
|
dependencies:
|
||||||
cac: 6.7.14
|
cac: 6.7.14
|
||||||
|
|
@ -1285,10 +1484,6 @@ snapshots:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
|
|
||||||
vitefu@1.1.3(vite@5.4.21):
|
|
||||||
optionalDependencies:
|
|
||||||
vite: 5.4.21
|
|
||||||
|
|
||||||
vitest@2.1.9:
|
vitest@2.1.9:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vitest/expect': 2.1.9
|
'@vitest/expect': 2.1.9
|
||||||
|
|
@ -1327,4 +1522,4 @@ snapshots:
|
||||||
siginfo: 2.0.0
|
siginfo: 2.0.0
|
||||||
stackback: 0.0.2
|
stackback: 0.0.2
|
||||||
|
|
||||||
zimmerframe@1.1.4: {}
|
yallist@3.1.1: {}
|
||||||
|
|
|
||||||
69
src/App.css
Normal file
69
src/App.css
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.distros, .presets {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.distro-btn, .preset-btn, .palette-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, .preset-btn:hover, .palette-btn:hover {
|
||||||
|
background: #2a2a2a;
|
||||||
|
color: #ddd;
|
||||||
|
}
|
||||||
|
.distro-btn.active {
|
||||||
|
background: #1a3a5c;
|
||||||
|
color: #cce6ff;
|
||||||
|
border-color: #2a5a8c;
|
||||||
|
}
|
||||||
|
.preset-btn {
|
||||||
|
min-width: 28px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.muted {
|
||||||
|
color: #666;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.layout-info {
|
||||||
|
margin-left: auto;
|
||||||
|
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
|
||||||
|
color: #777;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane-wrap {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
380
src/App.svelte
380
src/App.svelte
|
|
@ -1,380 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { onMount } from "svelte";
|
|
||||||
import {
|
|
||||||
listDistros,
|
|
||||||
saveWorkspace,
|
|
||||||
loadWorkspace,
|
|
||||||
writeToPane,
|
|
||||||
killPane,
|
|
||||||
} from "./ipc";
|
|
||||||
import Pane from "./lib/layout/Pane.svelte";
|
|
||||||
import Notifications from "./components/Notifications.svelte";
|
|
||||||
import Palette from "./components/Palette.svelte";
|
|
||||||
import {
|
|
||||||
provideOrchestration,
|
|
||||||
type TreeOps,
|
|
||||||
} from "./lib/layout/orchestration.svelte";
|
|
||||||
import {
|
|
||||||
type TreeNode,
|
|
||||||
type NodeId,
|
|
||||||
type Orientation,
|
|
||||||
type LeafNode,
|
|
||||||
newLeaf,
|
|
||||||
splitLeaf,
|
|
||||||
closeLeaf,
|
|
||||||
findLeaf,
|
|
||||||
leafCount,
|
|
||||||
walkLeaves,
|
|
||||||
changeDistro,
|
|
||||||
changeLabel,
|
|
||||||
toggleBroadcast as toggleBroadcastInTree,
|
|
||||||
serialize,
|
|
||||||
deserialize,
|
|
||||||
presetSingle,
|
|
||||||
presetTwoColumns,
|
|
||||||
presetThreeColumns,
|
|
||||||
presetTwoRows,
|
|
||||||
presetTwoByTwo,
|
|
||||||
} from "./lib/layout/tree";
|
|
||||||
|
|
||||||
const LEGACY_STORAGE_KEY = "tiletopia.tree.v1";
|
|
||||||
|
|
||||||
let defaultDistro = $state<string | undefined>(undefined);
|
|
||||||
let ready = $state(false);
|
|
||||||
let tree = $state<TreeNode>(newLeaf());
|
|
||||||
let paletteOpen = $state(false);
|
|
||||||
|
|
||||||
// activeLeafId lives here (not on the orch class) because Svelte 5 didn't
|
|
||||||
// reliably track class-field $state reads from child components that
|
|
||||||
// obtained the orch instance via getContext. Local $state drilled via
|
|
||||||
// prop works.
|
|
||||||
let activeLeafId = $state<NodeId | null>(null);
|
|
||||||
function setActive(id: NodeId) {
|
|
||||||
activeLeafId = id;
|
|
||||||
}
|
|
||||||
function clearActiveIf(id: NodeId) {
|
|
||||||
if (activeLeafId === id) activeLeafId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// ---- tree mutation handlers (closures over tree $state) -----------------
|
|
||||||
function handleSplit(leafId: NodeId, orientation: Orientation) {
|
|
||||||
const parent = findLeaf(tree, leafId);
|
|
||||||
const inherit = parent
|
|
||||||
? { distro: parent.distro ?? defaultDistro, cwd: parent.cwd }
|
|
||||||
: { distro: defaultDistro };
|
|
||||||
tree = splitLeaf(tree, leafId, orientation, inherit);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bumped on close to force a clean Pane remount (the only way the closed
|
|
||||||
// pane's DOM actually disappears given the reactivity wall).
|
|
||||||
let renderKey = $state(0);
|
|
||||||
|
|
||||||
function handleClose(leafId: NodeId) {
|
|
||||||
// Kill the PTY directly so it dies even though Svelte may not unmount.
|
|
||||||
const paneId = orch.paneIdByLeaf.get(leafId);
|
|
||||||
if (paneId != null) {
|
|
||||||
void killPane(paneId).catch((e) => console.warn("killPane failed:", e));
|
|
||||||
orch.paneIdByLeaf.delete(leafId);
|
|
||||||
}
|
|
||||||
const next = closeLeaf(tree, leafId);
|
|
||||||
tree = next ?? newLeaf({ distro: defaultDistro });
|
|
||||||
clearActiveIf(leafId);
|
|
||||||
// Force a clean re-render of the whole pane tree. Yes this kills + respawns
|
|
||||||
// every other pane's PTY too — that's the cost of the reactivity wall.
|
|
||||||
// Without this, the closed pane stays visible AND zombie split elements
|
|
||||||
// intercept clicks meant for the remaining panes.
|
|
||||||
renderKey += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSetDistro(leafId: NodeId, distro: string) {
|
|
||||||
tree = changeDistro(tree, leafId, distro);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSetLabel(leafId: NodeId, label: string | undefined) {
|
|
||||||
tree = changeLabel(tree, leafId, label);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleToggleBroadcast(leafId: NodeId) {
|
|
||||||
tree = toggleBroadcastInTree(tree, leafId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleBroadcastFrom(originLeafId: NodeId, dataB64: string) {
|
|
||||||
let peers = 0;
|
|
||||||
for (const leaf of walkLeaves(tree)) {
|
|
||||||
if (leaf.id === originLeafId) continue;
|
|
||||||
if (!leaf.broadcast) continue;
|
|
||||||
const paneId = orch.paneIdByLeaf.get(leaf.id);
|
|
||||||
if (paneId == null) {
|
|
||||||
console.warn("[tiletopia] broadcast peer has no paneId yet:", leaf.id);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
peers++;
|
|
||||||
writeToPane(paneId, dataB64).catch((e) =>
|
|
||||||
console.warn("[tiletopia] broadcast write failed:", e),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
console.log("[tiletopia] broadcastFrom", originLeafId, "→", peers, "peer(s)");
|
|
||||||
}
|
|
||||||
|
|
||||||
const treeOps: TreeOps = {
|
|
||||||
split: handleSplit,
|
|
||||||
close: handleClose,
|
|
||||||
setDistro: handleSetDistro,
|
|
||||||
setLabel: handleSetLabel,
|
|
||||||
toggleBroadcast: handleToggleBroadcast,
|
|
||||||
broadcastFrom: handleBroadcastFrom,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Provide the orchestration store. All Pane / SplitNode / LeafPane
|
|
||||||
// descendants consume it via `useOrchestration()` — no prop drilling.
|
|
||||||
const orch = provideOrchestration(treeOps);
|
|
||||||
orch.configureActiveHandlers(setActive, clearActiveIf);
|
|
||||||
|
|
||||||
function isInteractiveDistro(name: string): boolean {
|
|
||||||
return !name.toLowerCase().startsWith("docker-desktop");
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
// 1. Try APPDATA persistence.
|
|
||||||
let loaded: TreeNode | null = null;
|
|
||||||
try {
|
|
||||||
const json = await loadWorkspace();
|
|
||||||
if (json) loaded = deserialize(json);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("loadWorkspace failed:", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Migrate from M2 localStorage if APPDATA is empty.
|
|
||||||
if (!loaded) {
|
|
||||||
try {
|
|
||||||
const legacy = localStorage.getItem(LEGACY_STORAGE_KEY);
|
|
||||||
if (legacy) {
|
|
||||||
loaded = deserialize(legacy);
|
|
||||||
if (loaded) void saveWorkspace(legacy);
|
|
||||||
localStorage.removeItem(LEGACY_STORAGE_KEY);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("legacy localStorage migration failed:", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loaded) tree = loaded;
|
|
||||||
|
|
||||||
// 3. Resolve default distro.
|
|
||||||
try {
|
|
||||||
const ds = await listDistros();
|
|
||||||
orch.distros = ds;
|
|
||||||
defaultDistro = ds.find(isInteractiveDistro) ?? ds[0];
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("list_distros failed:", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (defaultDistro) backfillDistro(tree, defaultDistro);
|
|
||||||
ready = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
function backfillDistro(node: TreeNode, fallback: string) {
|
|
||||||
if (node.kind === "leaf") {
|
|
||||||
if (!node.distro) node.distro = fallback;
|
|
||||||
} else {
|
|
||||||
backfillDistro(node.a, fallback);
|
|
||||||
backfillDistro(node.b, fallback);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- debounced auto-save -------------------------------------------------
|
|
||||||
let saveTimer: number | null = null;
|
|
||||||
const SAVE_DEBOUNCE_MS = 500;
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!ready) return;
|
|
||||||
const json = serialize(tree);
|
|
||||||
if (saveTimer != null) clearTimeout(saveTimer);
|
|
||||||
saveTimer = window.setTimeout(() => {
|
|
||||||
saveTimer = null;
|
|
||||||
saveWorkspace(json).catch((e) =>
|
|
||||||
console.warn("saveWorkspace failed:", e),
|
|
||||||
);
|
|
||||||
}, SAVE_DEBOUNCE_MS);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---- Ctrl+K palette toggle ----------------------------------------------
|
|
||||||
// Capture phase so we win over xterm.js's keystroke capture inside terminals.
|
|
||||||
$effect(() => {
|
|
||||||
function onKey(e: KeyboardEvent) {
|
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
|
|
||||||
console.log("[tiletopia] Ctrl+K caught, paletteOpen ->", !paletteOpen);
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
paletteOpen = !paletteOpen;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
window.addEventListener("keydown", onKey, true);
|
|
||||||
return () => window.removeEventListener("keydown", onKey, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---- Active-pane detector via active-element polling --------------------
|
|
||||||
// We tried letting Svelte handle class:active reactively in LeafPane, but
|
|
||||||
// through this app's component chain the prop changes don't trigger a
|
|
||||||
// template re-evaluation reliably (root cause unclear — likely a Svelte 5
|
|
||||||
// interaction with our recursive Pane / setInterval pattern). So we ALSO
|
|
||||||
// manipulate `.leaf.active` directly via DOM as a backstop.
|
|
||||||
// ---- Workarounds for Svelte 5 prop-reactivity wall in this app ----------
|
|
||||||
// Without these, NOTHING updates reactively: no active border, no broadcast
|
|
||||||
// color, no resize, no close. Verified empirically: stripping the
|
|
||||||
// workarounds breaks every interaction.
|
|
||||||
$effect(() => {
|
|
||||||
let lastLeafId: string | null = null;
|
|
||||||
const interval = window.setInterval(() => {
|
|
||||||
// Focus detection → setActive
|
|
||||||
const el = document.activeElement;
|
|
||||||
const leafEl = el?.closest("[data-leaf-id]");
|
|
||||||
const id = leafEl?.getAttribute("data-leaf-id") ?? null;
|
|
||||||
if (id && id !== lastLeafId) {
|
|
||||||
lastLeafId = id;
|
|
||||||
setActive(id);
|
|
||||||
}
|
|
||||||
// Active border DOM sync
|
|
||||||
document.querySelectorAll("[data-leaf-id].leaf").forEach((el) => {
|
|
||||||
const elId = el.getAttribute("data-leaf-id");
|
|
||||||
if (elId === activeLeafId) el.classList.add("active");
|
|
||||||
else el.classList.remove("active");
|
|
||||||
});
|
|
||||||
// Broadcast DOM sync
|
|
||||||
for (const leaf of walkLeaves(tree)) {
|
|
||||||
const el = document.querySelector(`[data-leaf-id="${leaf.id}"]`);
|
|
||||||
if (!el) continue;
|
|
||||||
el.classList.toggle("broadcasting", !!leaf.broadcast);
|
|
||||||
const chip = el.querySelector(".bcast-chip");
|
|
||||||
if (chip) chip.classList.toggle("on", !!leaf.broadcast);
|
|
||||||
}
|
|
||||||
}, 250);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---- preset layouts ------------------------------------------------------
|
|
||||||
function applyPreset(make: (d: { distro?: string }) => TreeNode) {
|
|
||||||
const count = leafCount(tree);
|
|
||||||
if (count > 1 && !confirm(`Replace current layout (${count} panes)? This kills all open shells.`)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
tree = make({ distro: defaultDistro });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- palette feed --------------------------------------------------------
|
|
||||||
const paletteLeaves = $derived.by<LeafNode[]>(() => {
|
|
||||||
if (!paletteOpen) return [];
|
|
||||||
return Array.from(walkLeaves(tree));
|
|
||||||
});
|
|
||||||
|
|
||||||
function onPalettePick(leafId: string) {
|
|
||||||
setActive(leafId);
|
|
||||||
paletteOpen = false;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="app">
|
|
||||||
<header class="titlebar">
|
|
||||||
<span class="label">tiletopia</span>
|
|
||||||
|
|
||||||
<span class="distros">
|
|
||||||
{#if orch.distros.length === 0}
|
|
||||||
<span class="muted">no distros enumerated</span>
|
|
||||||
{:else}
|
|
||||||
<span class="muted">default:</span>
|
|
||||||
{#each orch.distros as d}
|
|
||||||
<button
|
|
||||||
class="distro-btn"
|
|
||||||
class:active={d === defaultDistro}
|
|
||||||
onclick={() => (defaultDistro = d)}
|
|
||||||
title="Set default distro for new panes"
|
|
||||||
>{d}</button>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span class="presets">
|
|
||||||
<span class="muted">layout:</span>
|
|
||||||
<button class="preset-btn" title="Single pane" onclick={() => applyPreset(presetSingle)}>1</button>
|
|
||||||
<button class="preset-btn" title="Two columns" onclick={() => applyPreset(presetTwoColumns)}>2H</button>
|
|
||||||
<button class="preset-btn" title="Three columns" onclick={() => applyPreset(presetThreeColumns)}>3H</button>
|
|
||||||
<button class="preset-btn" title="Two rows" onclick={() => applyPreset(presetTwoRows)}>2V</button>
|
|
||||||
<button class="preset-btn" title="2 × 2 grid" onclick={() => applyPreset(presetTwoByTwo)}>2×2</button>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<button class="palette-btn" onclick={() => (paletteOpen = true)} title="Jump to pane (Ctrl+K)">
|
|
||||||
⌘K
|
|
||||||
</button>
|
|
||||||
<button class="palette-btn" onclick={() => orch.notify("test toast at " + new Date().toLocaleTimeString())} title="Fire a test toast">
|
|
||||||
🔔
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<span class="layout-info">
|
|
||||||
{leafCount(tree)} pane{leafCount(tree) === 1 ? "" : "s"}
|
|
||||||
</span>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="pane-wrap">
|
|
||||||
{#if ready}
|
|
||||||
{#key renderKey}
|
|
||||||
<Pane node={tree} {activeLeafId} />
|
|
||||||
{/key}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Notifications
|
|
||||||
notifications={orch.notifications}
|
|
||||||
onDismiss={(id) => orch.dismiss(id)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{#if paletteOpen}
|
|
||||||
<Palette
|
|
||||||
leaves={paletteLeaves}
|
|
||||||
onPick={onPalettePick}
|
|
||||||
onClose={() => (paletteOpen = false)}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.distros, .presets {
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.distro-btn, .preset-btn, .palette-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, .preset-btn:hover, .palette-btn:hover {
|
|
||||||
background: #2a2a2a;
|
|
||||||
color: #ddd;
|
|
||||||
}
|
|
||||||
.distro-btn.active {
|
|
||||||
background: #1a3a5c;
|
|
||||||
color: #cce6ff;
|
|
||||||
border-color: #2a5a8c;
|
|
||||||
}
|
|
||||||
.preset-btn {
|
|
||||||
min-width: 28px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.muted {
|
|
||||||
color: #666;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
.layout-info {
|
|
||||||
margin-left: auto;
|
|
||||||
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
|
|
||||||
color: #777;
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
372
src/App.tsx
Normal file
372
src/App.tsx
Normal file
|
|
@ -0,0 +1,372 @@
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
listDistros,
|
||||||
|
loadWorkspace,
|
||||||
|
saveWorkspace,
|
||||||
|
writeToPane,
|
||||||
|
killPane,
|
||||||
|
type PaneId,
|
||||||
|
} from "./ipc";
|
||||||
|
import {
|
||||||
|
type TreeNode,
|
||||||
|
type NodeId,
|
||||||
|
type Orientation,
|
||||||
|
type LeafNode,
|
||||||
|
newLeaf,
|
||||||
|
splitLeaf,
|
||||||
|
closeLeaf,
|
||||||
|
findLeaf,
|
||||||
|
leafCount,
|
||||||
|
walkLeaves,
|
||||||
|
changeDistro,
|
||||||
|
changeLabel,
|
||||||
|
toggleBroadcast as toggleBroadcastInTree,
|
||||||
|
serialize,
|
||||||
|
deserialize,
|
||||||
|
presetSingle,
|
||||||
|
presetTwoColumns,
|
||||||
|
presetThreeColumns,
|
||||||
|
presetTwoRows,
|
||||||
|
presetTwoByTwo,
|
||||||
|
} from "./lib/layout/tree";
|
||||||
|
import { OrchestrationProvider, type Orchestration } from "./lib/layout/orchestration";
|
||||||
|
import Pane from "./lib/layout/Pane";
|
||||||
|
import Notifications, { type Toast } from "./components/Notifications";
|
||||||
|
import Palette from "./components/Palette";
|
||||||
|
import "./App.css";
|
||||||
|
|
||||||
|
const LEGACY_STORAGE_KEY = "tiletopia.tree.v1";
|
||||||
|
const SAVE_DEBOUNCE_MS = 500;
|
||||||
|
|
||||||
|
function isInteractiveDistro(name: string): boolean {
|
||||||
|
return !name.toLowerCase().startsWith("docker-desktop");
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
// ---- top-level state -----------------------------------------------------
|
||||||
|
const [tree, setTree] = useState<TreeNode>(() => newLeaf());
|
||||||
|
const [activeLeafId, setActiveLeafId] = useState<NodeId | null>(null);
|
||||||
|
const [distros, setDistros] = useState<string[]>([]);
|
||||||
|
const [defaultDistro, setDefaultDistro] = useState<string | undefined>(undefined);
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
const [notifications, setNotifications] = useState<Toast[]>([]);
|
||||||
|
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||||
|
|
||||||
|
// ---- non-reactive lookups -----------------------------------------------
|
||||||
|
const paneIdByLeafRef = useRef<Map<NodeId, PaneId>>(new Map());
|
||||||
|
const nextNotifIdRef = useRef(1);
|
||||||
|
const treeRef = useRef(tree);
|
||||||
|
useEffect(() => {
|
||||||
|
treeRef.current = tree;
|
||||||
|
}, [tree]);
|
||||||
|
|
||||||
|
// ---- mount: load workspace + distros ------------------------------------
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
let loaded: TreeNode | null = null;
|
||||||
|
try {
|
||||||
|
const json = await loadWorkspace();
|
||||||
|
if (json) loaded = deserialize(json);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("loadWorkspace failed:", e);
|
||||||
|
}
|
||||||
|
if (!loaded) {
|
||||||
|
try {
|
||||||
|
const legacy = localStorage.getItem(LEGACY_STORAGE_KEY);
|
||||||
|
if (legacy) {
|
||||||
|
loaded = deserialize(legacy);
|
||||||
|
if (loaded) void saveWorkspace(legacy);
|
||||||
|
localStorage.removeItem(LEGACY_STORAGE_KEY);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("legacy localStorage migration failed:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolvedDistros: string[] = [];
|
||||||
|
let resolvedDefault: string | undefined;
|
||||||
|
try {
|
||||||
|
resolvedDistros = await listDistros();
|
||||||
|
resolvedDefault =
|
||||||
|
resolvedDistros.find(isInteractiveDistro) ?? resolvedDistros[0];
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("list_distros failed:", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cancelled) return;
|
||||||
|
if (loaded) {
|
||||||
|
if (resolvedDefault) backfillDistro(loaded, resolvedDefault);
|
||||||
|
setTree(loaded);
|
||||||
|
} else if (resolvedDefault) {
|
||||||
|
setTree(newLeaf({ distro: resolvedDefault }));
|
||||||
|
}
|
||||||
|
setDistros(resolvedDistros);
|
||||||
|
setDefaultDistro(resolvedDefault);
|
||||||
|
setReady(true);
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ---- debounced save ------------------------------------------------------
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ready) return;
|
||||||
|
const id = window.setTimeout(() => {
|
||||||
|
saveWorkspace(serialize(tree)).catch((e) =>
|
||||||
|
console.warn("saveWorkspace failed:", e),
|
||||||
|
);
|
||||||
|
}, SAVE_DEBOUNCE_MS);
|
||||||
|
return () => clearTimeout(id);
|
||||||
|
}, [tree, ready]);
|
||||||
|
|
||||||
|
// ---- Ctrl+K palette toggle (capture phase to beat xterm) ----------------
|
||||||
|
useEffect(() => {
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setPaletteOpen((v) => !v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener("keydown", onKey, true);
|
||||||
|
return () => window.removeEventListener("keydown", onKey, true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ---- focus polling → setActive (xterm.js eats pointerdown) --------------
|
||||||
|
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 (id && id !== lastLeafId) {
|
||||||
|
lastLeafId = id;
|
||||||
|
setActiveLeafId(id);
|
||||||
|
}
|
||||||
|
}, 250);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ---- orchestration callbacks --------------------------------------------
|
||||||
|
const split = useCallback(
|
||||||
|
(leafId: NodeId, orientation: Orientation) => {
|
||||||
|
setTree((t) => {
|
||||||
|
const parent = findLeaf(t, leafId);
|
||||||
|
const inherit = parent
|
||||||
|
? { distro: parent.distro ?? defaultDistro, cwd: parent.cwd }
|
||||||
|
: { distro: defaultDistro };
|
||||||
|
return splitLeaf(t, leafId, orientation, inherit);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[defaultDistro],
|
||||||
|
);
|
||||||
|
|
||||||
|
const close = useCallback(
|
||||||
|
(leafId: NodeId) => {
|
||||||
|
const paneId = paneIdByLeafRef.current.get(leafId);
|
||||||
|
if (paneId != null) {
|
||||||
|
void killPane(paneId).catch((e) => console.warn("killPane failed:", e));
|
||||||
|
paneIdByLeafRef.current.delete(leafId);
|
||||||
|
}
|
||||||
|
setTree((t) => closeLeaf(t, leafId) ?? newLeaf({ distro: defaultDistro }));
|
||||||
|
setActiveLeafId((cur) => (cur === leafId ? null : cur));
|
||||||
|
},
|
||||||
|
[defaultDistro],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setDistro = useCallback((leafId: NodeId, distro: string) => {
|
||||||
|
setTree((t) => changeDistro(t, leafId, distro));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setLabel = useCallback((leafId: NodeId, label: string | undefined) => {
|
||||||
|
setTree((t) => changeLabel(t, leafId, label));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleBroadcast = useCallback((leafId: NodeId) => {
|
||||||
|
setTree((t) => toggleBroadcastInTree(t, leafId));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setActive = useCallback((leafId: NodeId) => {
|
||||||
|
setActiveLeafId(leafId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const registerPaneId = useCallback(
|
||||||
|
(leafId: NodeId, paneId: PaneId | null) => {
|
||||||
|
if (paneId == null) paneIdByLeafRef.current.delete(leafId);
|
||||||
|
else paneIdByLeafRef.current.set(leafId, paneId);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const broadcastFrom = useCallback(
|
||||||
|
(originLeafId: NodeId, dataB64: string) => {
|
||||||
|
let peers = 0;
|
||||||
|
for (const leaf of walkLeaves(treeRef.current)) {
|
||||||
|
if (leaf.id === originLeafId) continue;
|
||||||
|
if (!leaf.broadcast) continue;
|
||||||
|
const paneId = paneIdByLeafRef.current.get(leaf.id);
|
||||||
|
if (paneId == null) continue;
|
||||||
|
peers++;
|
||||||
|
writeToPane(paneId, dataB64).catch((e) =>
|
||||||
|
console.warn("broadcast write failed:", e),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (peers > 0) {
|
||||||
|
console.log("[tiletopia] broadcastFrom", originLeafId, "→", peers, "peer(s)");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const notify = useCallback((message: string) => {
|
||||||
|
const id = nextNotifIdRef.current++;
|
||||||
|
setNotifications((ns) => [...ns, { id, message }]);
|
||||||
|
window.setTimeout(() => {
|
||||||
|
setNotifications((ns) => ns.filter((n) => n.id !== id));
|
||||||
|
}, 5000);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const dismissNotification = useCallback((id: number) => {
|
||||||
|
setNotifications((ns) => ns.filter((n) => n.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const orch = useMemo<Orchestration>(
|
||||||
|
() => ({
|
||||||
|
activeLeafId,
|
||||||
|
distros,
|
||||||
|
split,
|
||||||
|
close,
|
||||||
|
setDistro,
|
||||||
|
setLabel,
|
||||||
|
toggleBroadcast,
|
||||||
|
setActive,
|
||||||
|
registerPaneId,
|
||||||
|
broadcastFrom,
|
||||||
|
notify,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
activeLeafId,
|
||||||
|
distros,
|
||||||
|
split,
|
||||||
|
close,
|
||||||
|
setDistro,
|
||||||
|
setLabel,
|
||||||
|
toggleBroadcast,
|
||||||
|
setActive,
|
||||||
|
registerPaneId,
|
||||||
|
broadcastFrom,
|
||||||
|
notify,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const applyPreset = useCallback(
|
||||||
|
(make: (d: { distro?: string }) => TreeNode) => {
|
||||||
|
const count = leafCount(tree);
|
||||||
|
if (
|
||||||
|
count > 1 &&
|
||||||
|
!window.confirm(
|
||||||
|
`Replace current layout (${count} panes)? This kills all open shells.`,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTree(make({ distro: defaultDistro }));
|
||||||
|
},
|
||||||
|
[tree, defaultDistro],
|
||||||
|
);
|
||||||
|
|
||||||
|
const paletteLeaves = useMemo<LeafNode[]>(
|
||||||
|
() => (paletteOpen ? Array.from(walkLeaves(tree)) : []),
|
||||||
|
[paletteOpen, tree],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onPalettePick = useCallback((leafId: string) => {
|
||||||
|
setActiveLeafId(leafId);
|
||||||
|
setPaletteOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
<header className="titlebar">
|
||||||
|
<span className="label">tiletopia</span>
|
||||||
|
|
||||||
|
<span className="distros">
|
||||||
|
{distros.length === 0 ? (
|
||||||
|
<span className="muted">no distros enumerated</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="muted">default:</span>
|
||||||
|
{distros.map((d) => (
|
||||||
|
<button
|
||||||
|
key={d}
|
||||||
|
className={`distro-btn${d === defaultDistro ? " active" : ""}`}
|
||||||
|
onClick={() => setDefaultDistro(d)}
|
||||||
|
title="Set default distro for new panes"
|
||||||
|
>
|
||||||
|
{d}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="presets">
|
||||||
|
<span className="muted">layout:</span>
|
||||||
|
<button className="preset-btn" title="Single pane" onClick={() => applyPreset(presetSingle)}>1</button>
|
||||||
|
<button className="preset-btn" title="Two columns" onClick={() => applyPreset(presetTwoColumns)}>2H</button>
|
||||||
|
<button className="preset-btn" title="Three columns" onClick={() => applyPreset(presetThreeColumns)}>3H</button>
|
||||||
|
<button className="preset-btn" title="Two rows" onClick={() => applyPreset(presetTwoRows)}>2V</button>
|
||||||
|
<button className="preset-btn" title="2 × 2 grid" onClick={() => applyPreset(presetTwoByTwo)}>2×2</button>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="palette-btn"
|
||||||
|
onClick={() => setPaletteOpen(true)}
|
||||||
|
title="Jump to pane (Ctrl+K)"
|
||||||
|
>
|
||||||
|
⌘K
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="palette-btn"
|
||||||
|
onClick={() => notify("test toast at " + new Date().toLocaleTimeString())}
|
||||||
|
title="Fire a test toast"
|
||||||
|
>
|
||||||
|
🔔
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span className="layout-info">
|
||||||
|
{leafCount(tree)} pane{leafCount(tree) === 1 ? "" : "s"}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="pane-wrap">
|
||||||
|
{ready && (
|
||||||
|
<OrchestrationProvider value={orch}>
|
||||||
|
<Pane node={tree} />
|
||||||
|
</OrchestrationProvider>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Notifications notifications={notifications} onDismiss={dismissNotification} />
|
||||||
|
|
||||||
|
{paletteOpen && (
|
||||||
|
<Palette
|
||||||
|
leaves={paletteLeaves}
|
||||||
|
onPick={onPalettePick}
|
||||||
|
onClose={() => setPaletteOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function backfillDistro(node: TreeNode, fallback: string) {
|
||||||
|
if (node.kind === "leaf") {
|
||||||
|
if (!node.distro) node.distro = fallback;
|
||||||
|
} else {
|
||||||
|
backfillDistro(node.a, fallback);
|
||||||
|
backfillDistro(node.b, fallback);
|
||||||
|
}
|
||||||
|
}
|
||||||
62
src/components/Notifications.css
Normal file
62
src/components/Notifications.css
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
.toast-stack {
|
||||||
|
position: fixed;
|
||||||
|
top: 36px;
|
||||||
|
right: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
z-index: 100;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
pointer-events: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 220px;
|
||||||
|
max-width: 320px;
|
||||||
|
padding: 8px 10px 8px 14px;
|
||||||
|
background: #1f1f1f;
|
||||||
|
color: #ddd;
|
||||||
|
border: 1px solid #3a5a8c;
|
||||||
|
border-left-width: 3px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.45);
|
||||||
|
animation: slide-in 180ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-msg {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
line-height: 1.3;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-x {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #777;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 2px 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-x:hover {
|
||||||
|
background: #2a2a2a;
|
||||||
|
color: #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slide-in {
|
||||||
|
from {
|
||||||
|
transform: translateX(20px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,81 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
export interface Toast {
|
|
||||||
id: number;
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
notifications,
|
|
||||||
onDismiss,
|
|
||||||
}: {
|
|
||||||
notifications: Toast[];
|
|
||||||
onDismiss: (id: number) => void;
|
|
||||||
} = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="toast-stack">
|
|
||||||
{#each notifications as t (t.id)}
|
|
||||||
<div class="toast">
|
|
||||||
<span class="toast-msg">{t.message}</span>
|
|
||||||
<button
|
|
||||||
class="toast-x"
|
|
||||||
onclick={() => onDismiss(t.id)}
|
|
||||||
aria-label="Dismiss notification"
|
|
||||||
>×</button>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.toast-stack {
|
|
||||||
position: fixed;
|
|
||||||
top: 36px;
|
|
||||||
right: 12px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
z-index: 100;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
.toast {
|
|
||||||
pointer-events: auto;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
min-width: 220px;
|
|
||||||
max-width: 320px;
|
|
||||||
padding: 8px 10px 8px 14px;
|
|
||||||
background: #1f1f1f;
|
|
||||||
color: #ddd;
|
|
||||||
border: 1px solid #3a5a8c;
|
|
||||||
border-left-width: 3px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 12px;
|
|
||||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.45);
|
|
||||||
animation: slide-in 180ms ease-out;
|
|
||||||
}
|
|
||||||
.toast-msg {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
line-height: 1.3;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
.toast-x {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: #777;
|
|
||||||
font-size: 16px;
|
|
||||||
line-height: 1;
|
|
||||||
padding: 2px 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
.toast-x:hover {
|
|
||||||
background: #2a2a2a;
|
|
||||||
color: #ddd;
|
|
||||||
}
|
|
||||||
@keyframes slide-in {
|
|
||||||
from { transform: translateX(20px); opacity: 0; }
|
|
||||||
to { transform: translateX(0); opacity: 1; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
34
src/components/Notifications.tsx
Normal file
34
src/components/Notifications.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import React from "react";
|
||||||
|
import "./Notifications.css";
|
||||||
|
|
||||||
|
export interface Toast {
|
||||||
|
id: number;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NotificationsProps {
|
||||||
|
notifications: Toast[];
|
||||||
|
onDismiss: (id: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Notifications({
|
||||||
|
notifications,
|
||||||
|
onDismiss,
|
||||||
|
}: NotificationsProps) {
|
||||||
|
return (
|
||||||
|
<div className="toast-stack">
|
||||||
|
{notifications.map((t) => (
|
||||||
|
<div key={t.id} className="toast">
|
||||||
|
<span className="toast-msg">{t.message}</span>
|
||||||
|
<button
|
||||||
|
className="toast-x"
|
||||||
|
onClick={() => onDismiss(t.id)}
|
||||||
|
aria-label="Dismiss notification"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
99
src/components/Palette.css
Normal file
99
src/components/Palette.css
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
.backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
z-index: 99;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.palette {
|
||||||
|
position: fixed;
|
||||||
|
top: 12vh;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: min(520px, 90vw);
|
||||||
|
background: #181818;
|
||||||
|
border: 1px solid #2a2a2a;
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
|
||||||
|
z-index: 100;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.palette-input {
|
||||||
|
font: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #fff;
|
||||||
|
background: #1f1f1f;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid #2a2a2a;
|
||||||
|
padding: 10px 14px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.palette-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 4px;
|
||||||
|
max-height: 50vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.palette-item {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #ccc;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.palette-item.highlight {
|
||||||
|
background: #1a3a5c;
|
||||||
|
color: #cce6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
color: #888;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.palette-item.highlight .meta {
|
||||||
|
color: #9bd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta.cwd {
|
||||||
|
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
|
||||||
|
max-width: 200px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta.bcast {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
color: #777;
|
||||||
|
font-style: italic;
|
||||||
|
padding: 8px 10px;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
@ -1,188 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import type { LeafNode } from "../lib/layout/tree";
|
|
||||||
|
|
||||||
let {
|
|
||||||
leaves,
|
|
||||||
onPick,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
leaves: LeafNode[];
|
|
||||||
onPick: (leafId: string) => void;
|
|
||||||
onClose: () => void;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
let query = $state("");
|
|
||||||
let inputEl: HTMLInputElement | null = $state(null);
|
|
||||||
let highlightIndex = $state(0);
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
queueMicrotask(() => inputEl?.focus());
|
|
||||||
});
|
|
||||||
|
|
||||||
const filtered = $derived.by(() => {
|
|
||||||
const q = query.trim().toLowerCase();
|
|
||||||
if (!q) return leaves;
|
|
||||||
return leaves.filter((l) => {
|
|
||||||
const blob = `${l.label ?? ""} ${l.distro ?? ""} ${l.cwd ?? ""}`.toLowerCase();
|
|
||||||
return blob.includes(q);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clamp highlight whenever the filtered list changes.
|
|
||||||
$effect(() => {
|
|
||||||
if (highlightIndex >= filtered.length) highlightIndex = Math.max(0, filtered.length - 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
function pick(idx: number) {
|
|
||||||
const l = filtered[idx];
|
|
||||||
if (l) onPick(l.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onKey(e: KeyboardEvent) {
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
e.preventDefault();
|
|
||||||
onClose();
|
|
||||||
} else if (e.key === "ArrowDown") {
|
|
||||||
e.preventDefault();
|
|
||||||
if (filtered.length > 0) {
|
|
||||||
highlightIndex = (highlightIndex + 1) % filtered.length;
|
|
||||||
}
|
|
||||||
} else if (e.key === "ArrowUp") {
|
|
||||||
e.preventDefault();
|
|
||||||
if (filtered.length > 0) {
|
|
||||||
highlightIndex = (highlightIndex - 1 + filtered.length) % filtered.length;
|
|
||||||
}
|
|
||||||
} else if (e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
pick(highlightIndex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="backdrop"
|
|
||||||
onclick={onClose}
|
|
||||||
aria-label="Close palette"
|
|
||||||
></button>
|
|
||||||
|
|
||||||
<div class="palette" role="dialog" aria-label="Jump to pane">
|
|
||||||
<input
|
|
||||||
bind:this={inputEl}
|
|
||||||
bind:value={query}
|
|
||||||
onkeydown={onKey}
|
|
||||||
placeholder="Jump to pane — type to filter, ↑/↓ + Enter"
|
|
||||||
class="palette-input"
|
|
||||||
/>
|
|
||||||
<ul class="palette-list">
|
|
||||||
{#if filtered.length === 0}
|
|
||||||
<li class="empty">No matching panes.</li>
|
|
||||||
{:else}
|
|
||||||
{#each filtered as leaf, i}
|
|
||||||
<li>
|
|
||||||
<button
|
|
||||||
class="palette-item"
|
|
||||||
class:highlight={i === highlightIndex}
|
|
||||||
onclick={() => pick(i)}
|
|
||||||
onmouseenter={() => (highlightIndex = i)}
|
|
||||||
>
|
|
||||||
<span class="name">{leaf.label ?? "(unnamed)"}</span>
|
|
||||||
<span class="meta">{leaf.distro ?? "default"}</span>
|
|
||||||
{#if leaf.cwd}<span class="meta cwd">{leaf.cwd}</span>{/if}
|
|
||||||
{#if leaf.broadcast}<span class="meta bcast">📡</span>{/if}
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.backdrop {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.55);
|
|
||||||
backdrop-filter: blur(2px);
|
|
||||||
z-index: 99;
|
|
||||||
border: 0;
|
|
||||||
padding: 0;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
.palette {
|
|
||||||
position: fixed;
|
|
||||||
top: 12vh;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
width: min(520px, 90vw);
|
|
||||||
background: #181818;
|
|
||||||
border: 1px solid #2a2a2a;
|
|
||||||
border-radius: 6px;
|
|
||||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
|
|
||||||
z-index: 100;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.palette-input {
|
|
||||||
font: inherit;
|
|
||||||
font-size: 14px;
|
|
||||||
color: #fff;
|
|
||||||
background: #1f1f1f;
|
|
||||||
border: none;
|
|
||||||
border-bottom: 1px solid #2a2a2a;
|
|
||||||
padding: 10px 14px;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
.palette-list {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 4px;
|
|
||||||
max-height: 50vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
.palette-item {
|
|
||||||
width: 100%;
|
|
||||||
text-align: left;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: #ccc;
|
|
||||||
padding: 6px 10px;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 10px;
|
|
||||||
font: inherit;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
.palette-item.highlight {
|
|
||||||
background: #1a3a5c;
|
|
||||||
color: #cce6ff;
|
|
||||||
}
|
|
||||||
.name {
|
|
||||||
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.meta {
|
|
||||||
color: #888;
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
.palette-item.highlight .meta {
|
|
||||||
color: #9bd;
|
|
||||||
}
|
|
||||||
.meta.cwd {
|
|
||||||
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
|
|
||||||
max-width: 200px;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.meta.bcast {
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
.empty {
|
|
||||||
color: #777;
|
|
||||||
font-style: italic;
|
|
||||||
padding: 8px 10px;
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
108
src/components/Palette.tsx
Normal file
108
src/components/Palette.tsx
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
import React, { useState, useEffect, useRef, useMemo } from "react";
|
||||||
|
import type { LeafNode } from "../lib/layout/tree";
|
||||||
|
import "./Palette.css";
|
||||||
|
|
||||||
|
interface PaletteProps {
|
||||||
|
leaves: LeafNode[];
|
||||||
|
onPick: (leafId: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Palette({ leaves, onPick, onClose }: PaletteProps) {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [highlightIndex, setHighlightIndex] = useState(0);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Autofocus the input on mount
|
||||||
|
useEffect(() => {
|
||||||
|
queueMicrotask(() => inputRef.current?.focus());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Compute filtered leaves based on query
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
if (!q) return leaves;
|
||||||
|
return leaves.filter((l) => {
|
||||||
|
const blob = `${l.label ?? ""} ${l.distro ?? ""} ${l.cwd ?? ""}`.toLowerCase();
|
||||||
|
return blob.includes(q);
|
||||||
|
});
|
||||||
|
}, [query, leaves]);
|
||||||
|
|
||||||
|
// Clamp highlight index when filtered list changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (highlightIndex >= filtered.length) {
|
||||||
|
setHighlightIndex(Math.max(0, filtered.length - 1));
|
||||||
|
}
|
||||||
|
}, [filtered, highlightIndex]);
|
||||||
|
|
||||||
|
function pick(idx: number) {
|
||||||
|
const l = filtered[idx];
|
||||||
|
if (l) onPick(l.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
onClose();
|
||||||
|
} else if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
if (filtered.length > 0) {
|
||||||
|
setHighlightIndex((prev) => (prev + 1) % filtered.length);
|
||||||
|
}
|
||||||
|
} else if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
if (filtered.length > 0) {
|
||||||
|
setHighlightIndex((prev) =>
|
||||||
|
(prev - 1 + filtered.length) % filtered.length
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
pick(highlightIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="backdrop"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close palette"
|
||||||
|
></button>
|
||||||
|
|
||||||
|
<div className="palette" role="dialog" aria-label="Jump to pane">
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
className="palette-input"
|
||||||
|
placeholder="Jump to pane — type to filter, ↑/↓ + Enter"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
/>
|
||||||
|
<ul className="palette-list">
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<li className="empty">No matching panes.</li>
|
||||||
|
) : (
|
||||||
|
filtered.map((leaf, i) => (
|
||||||
|
<li key={leaf.id}>
|
||||||
|
<button
|
||||||
|
className={`palette-item ${
|
||||||
|
i === highlightIndex ? "highlight" : ""
|
||||||
|
}`}
|
||||||
|
onClick={() => pick(i)}
|
||||||
|
onMouseEnter={() => setHighlightIndex(i)}
|
||||||
|
>
|
||||||
|
<span className="name">{leaf.label ?? "(unnamed)"}</span>
|
||||||
|
<span className="meta">{leaf.distro ?? "default"}</span>
|
||||||
|
{leaf.cwd && <span className="meta cwd">{leaf.cwd}</span>}
|
||||||
|
{leaf.broadcast && <span className="meta bcast">📡</span>}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,183 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { onMount, onDestroy } from "svelte";
|
|
||||||
import { Terminal } from "@xterm/xterm";
|
|
||||||
import { FitAddon } from "@xterm/addon-fit";
|
|
||||||
import type { UnlistenFn } from "@tauri-apps/api/event";
|
|
||||||
import {
|
|
||||||
spawnPane,
|
|
||||||
writeToPane,
|
|
||||||
resizePane,
|
|
||||||
killPane,
|
|
||||||
onPaneData,
|
|
||||||
onPaneExit,
|
|
||||||
type PaneId,
|
|
||||||
} from "../ipc";
|
|
||||||
|
|
||||||
let {
|
|
||||||
distro = undefined,
|
|
||||||
cwd = undefined,
|
|
||||||
onStatus = (_s: string, _ok: boolean) => {},
|
|
||||||
onSpawn = undefined,
|
|
||||||
onInput = undefined,
|
|
||||||
onDataReceived = undefined,
|
|
||||||
onFocus = undefined,
|
|
||||||
focusTrigger = 0,
|
|
||||||
}: {
|
|
||||||
distro?: string;
|
|
||||||
cwd?: string;
|
|
||||||
onStatus?: (msg: string, ok: boolean) => void;
|
|
||||||
/** Fired once when the backend PTY is alive and we have its PaneId. */
|
|
||||||
onSpawn?: (paneId: PaneId) => void;
|
|
||||||
/** Fired AFTER each writeToPane on user keypress. Used by broadcasting. */
|
|
||||||
onInput?: (dataB64: string) => void;
|
|
||||||
/** Fired whenever output arrives from the PTY. Used for idle detection. */
|
|
||||||
onDataReceived?: () => void;
|
|
||||||
/** Fired when xterm's textarea gains focus (i.e., user clicked here). */
|
|
||||||
onFocus?: () => void;
|
|
||||||
/** Increment to refocus the terminal programmatically (palette etc.). */
|
|
||||||
focusTrigger?: number;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
let containerEl: HTMLDivElement;
|
|
||||||
let term: Terminal | null = null;
|
|
||||||
let fit: FitAddon | null = null;
|
|
||||||
let paneId: PaneId | null = null;
|
|
||||||
let unlistenData: UnlistenFn | null = null;
|
|
||||||
let unlistenExit: UnlistenFn | null = null;
|
|
||||||
let ro: ResizeObserver | null = null;
|
|
||||||
|
|
||||||
// Decode base64 -> Uint8Array. xterm.js accepts both strings and Uint8Array;
|
|
||||||
// bytes is preferred to avoid double-decoding UTF-8.
|
|
||||||
function b64ToBytes(b64: string): Uint8Array {
|
|
||||||
const bin = atob(b64);
|
|
||||||
const out = new Uint8Array(bin.length);
|
|
||||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function bytesToB64(bytes: Uint8Array): string {
|
|
||||||
let s = "";
|
|
||||||
for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
|
|
||||||
return btoa(s);
|
|
||||||
}
|
|
||||||
|
|
||||||
function stringToB64(s: string): string {
|
|
||||||
// xterm.js's onData emits a JS string; need to UTF-8 encode before base64.
|
|
||||||
return bytesToB64(new TextEncoder().encode(s));
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
term = new Terminal({
|
|
||||||
fontFamily: '"Cascadia Mono", "JetBrains Mono", "Consolas", monospace',
|
|
||||||
fontSize: 13,
|
|
||||||
cursorBlink: true,
|
|
||||||
theme: {
|
|
||||||
background: "#0c0c0c",
|
|
||||||
foreground: "#e6e6e6",
|
|
||||||
},
|
|
||||||
scrollback: 5000,
|
|
||||||
convertEol: false,
|
|
||||||
allowProposedApi: true,
|
|
||||||
});
|
|
||||||
fit = new FitAddon();
|
|
||||||
term.loadAddon(fit);
|
|
||||||
term.open(containerEl);
|
|
||||||
|
|
||||||
// Initial size — fit before we ask the PTY for its dimensions.
|
|
||||||
fit.fit();
|
|
||||||
const cols = term.cols;
|
|
||||||
const rows = term.rows;
|
|
||||||
|
|
||||||
try {
|
|
||||||
paneId = await spawnPane({ distro, cwd, cols, rows });
|
|
||||||
onStatus(`pane ${paneId} alive`, true);
|
|
||||||
onSpawn?.(paneId);
|
|
||||||
} catch (e) {
|
|
||||||
const msg = `spawn_pane failed: ${e}`;
|
|
||||||
term.write(`\r\n\x1b[31m${msg}\x1b[0m\r\n`);
|
|
||||||
onStatus(msg, false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
unlistenData = await onPaneData(paneId, (b64) => {
|
|
||||||
term?.write(b64ToBytes(b64));
|
|
||||||
onDataReceived?.();
|
|
||||||
});
|
|
||||||
unlistenExit = await onPaneExit(paneId, () => {
|
|
||||||
term?.write("\r\n\x1b[33m[pane exited]\x1b[0m\r\n");
|
|
||||||
onStatus(`pane ${paneId} exited`, false);
|
|
||||||
});
|
|
||||||
|
|
||||||
term.onData((data) => {
|
|
||||||
if (paneId == null) return;
|
|
||||||
const b64 = stringToB64(data);
|
|
||||||
void writeToPane(paneId, b64);
|
|
||||||
onInput?.(b64);
|
|
||||||
});
|
|
||||||
|
|
||||||
// xterm.js's own focus event — fires when the hidden textarea gets focus
|
|
||||||
// (i.e., user clicked anywhere in the terminal). Most reliable signal
|
|
||||||
// for "user wants this pane active" — no DOM event traversal involved.
|
|
||||||
term.onSelectionChange(() => {}); // ensure the addon system is initialized; noop
|
|
||||||
if (typeof (term as unknown as { onFocus?: unknown }).onFocus === "function") {
|
|
||||||
(term as unknown as { onFocus: (cb: () => void) => void }).onFocus(() => {
|
|
||||||
onFocus?.();
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Fallback: listen on the textarea element directly.
|
|
||||||
const ta = containerEl.querySelector(".xterm-helper-textarea");
|
|
||||||
if (ta) ta.addEventListener("focus", () => onFocus?.(), true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-fit on container resize; forward new size to the PTY.
|
|
||||||
ro = new ResizeObserver(() => {
|
|
||||||
try {
|
|
||||||
fit?.fit();
|
|
||||||
if (paneId != null && term) {
|
|
||||||
void resizePane(paneId, term.cols, term.rows);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("resize failed", e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
ro.observe(containerEl);
|
|
||||||
|
|
||||||
// Focus so typing immediately lands in the terminal.
|
|
||||||
term.focus();
|
|
||||||
});
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
ro?.disconnect();
|
|
||||||
unlistenData?.();
|
|
||||||
unlistenExit?.();
|
|
||||||
if (paneId != null) {
|
|
||||||
void killPane(paneId);
|
|
||||||
}
|
|
||||||
term?.dispose();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Refocus the terminal whenever the parent bumps focusTrigger.
|
|
||||||
$effect(() => {
|
|
||||||
// Reactive read on focusTrigger so this effect re-runs when it changes.
|
|
||||||
focusTrigger;
|
|
||||||
if (term && focusTrigger > 0) term.focus();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="xterm-host" bind:this={containerEl}></div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.xterm-host {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* xterm.js sets inline padding=0 on its container; ensure the viewport
|
|
||||||
fills the host with no scrollbar gap. */
|
|
||||||
:global(.xterm) {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
:global(.xterm-viewport) {
|
|
||||||
background: #0c0c0c !important;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
222
src/components/XtermPane.tsx
Normal file
222
src/components/XtermPane.tsx
Normal file
|
|
@ -0,0 +1,222 @@
|
||||||
|
import { useRef, useEffect } from "react";
|
||||||
|
import { Terminal } from "@xterm/xterm";
|
||||||
|
import { FitAddon } from "@xterm/addon-fit";
|
||||||
|
import type { UnlistenFn } from "@tauri-apps/api/event";
|
||||||
|
import {
|
||||||
|
spawnPane,
|
||||||
|
writeToPane,
|
||||||
|
resizePane,
|
||||||
|
killPane,
|
||||||
|
onPaneData,
|
||||||
|
onPaneExit,
|
||||||
|
type PaneId,
|
||||||
|
} from "../ipc";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// base64 helpers (private to this module)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function b64ToBytes(b64: string): Uint8Array {
|
||||||
|
const bin = atob(b64);
|
||||||
|
const out = new Uint8Array(bin.length);
|
||||||
|
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bytesToB64(bytes: Uint8Array): string {
|
||||||
|
let s = "";
|
||||||
|
for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
|
||||||
|
return btoa(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringToB64(s: string): string {
|
||||||
|
// xterm.js's onData emits a JS string; UTF-8 encode before base64.
|
||||||
|
return bytesToB64(new TextEncoder().encode(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Props
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface XtermPaneProps {
|
||||||
|
distro?: string;
|
||||||
|
cwd?: string;
|
||||||
|
onStatus?: (msg: string, ok: boolean) => void;
|
||||||
|
/** Fired once when the backend PTY is alive and we have its PaneId. */
|
||||||
|
onSpawn?: (paneId: PaneId) => void;
|
||||||
|
/** Fired AFTER each writeToPane on user keypress. Used by broadcasting. */
|
||||||
|
onInput?: (dataB64: string) => void;
|
||||||
|
/** Fired whenever output arrives from the PTY. Used for idle detection. */
|
||||||
|
onDataReceived?: () => void;
|
||||||
|
/** Fired when xterm's textarea gains focus (i.e., user clicked here). */
|
||||||
|
onFocus?: () => void;
|
||||||
|
/** Increment to refocus the terminal programmatically (palette etc.). */
|
||||||
|
focusTrigger?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export default function XtermPane({
|
||||||
|
distro,
|
||||||
|
cwd,
|
||||||
|
onStatus,
|
||||||
|
onSpawn,
|
||||||
|
onInput,
|
||||||
|
onDataReceived,
|
||||||
|
onFocus,
|
||||||
|
focusTrigger = 0,
|
||||||
|
}: XtermPaneProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Stable refs for callbacks so the mount effect doesn't need to re-run when
|
||||||
|
// parents pass new inline functions, while still always calling the latest version.
|
||||||
|
const onStatusRef = useRef(onStatus);
|
||||||
|
const onSpawnRef = useRef(onSpawn);
|
||||||
|
const onInputRef = useRef(onInput);
|
||||||
|
const onDataReceivedRef = useRef(onDataReceived);
|
||||||
|
const onFocusRef = useRef(onFocus);
|
||||||
|
|
||||||
|
useEffect(() => { onStatusRef.current = onStatus; }, [onStatus]);
|
||||||
|
useEffect(() => { onSpawnRef.current = onSpawn; }, [onSpawn]);
|
||||||
|
useEffect(() => { onInputRef.current = onInput; }, [onInput]);
|
||||||
|
useEffect(() => { onDataReceivedRef.current = onDataReceived; }, [onDataReceived]);
|
||||||
|
useEffect(() => { onFocusRef.current = onFocus; }, [onFocus]);
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Mount / unmount: create terminal, spawn PTY, wire listeners
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
let term: Terminal | null = new Terminal({
|
||||||
|
fontFamily: '"Cascadia Mono", "JetBrains Mono", "Consolas", monospace',
|
||||||
|
fontSize: 13,
|
||||||
|
cursorBlink: true,
|
||||||
|
theme: {
|
||||||
|
background: "#0c0c0c",
|
||||||
|
foreground: "#e6e6e6",
|
||||||
|
},
|
||||||
|
scrollback: 5000,
|
||||||
|
convertEol: false,
|
||||||
|
allowProposedApi: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fit = new FitAddon();
|
||||||
|
term.loadAddon(fit);
|
||||||
|
term.open(container);
|
||||||
|
|
||||||
|
// Initial size — fit before asking the PTY for its dimensions.
|
||||||
|
fit.fit();
|
||||||
|
|
||||||
|
let paneId: PaneId | null = null;
|
||||||
|
let unlistenData: UnlistenFn | null = null;
|
||||||
|
let unlistenExit: UnlistenFn | null = null;
|
||||||
|
let ro: ResizeObserver | null = null;
|
||||||
|
let destroyed = false;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const cols = term!.cols;
|
||||||
|
const rows = term!.rows;
|
||||||
|
|
||||||
|
try {
|
||||||
|
paneId = await spawnPane({ distro, cwd, cols, rows });
|
||||||
|
if (destroyed) {
|
||||||
|
void killPane(paneId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onStatusRef.current?.(`pane ${paneId} alive`, true);
|
||||||
|
onSpawnRef.current?.(paneId);
|
||||||
|
} catch (e) {
|
||||||
|
if (destroyed) return;
|
||||||
|
const msg = `spawn_pane failed: ${e}`;
|
||||||
|
term?.write(`\r\n\x1b[31m${msg}\x1b[0m\r\n`);
|
||||||
|
onStatusRef.current?.(msg, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
unlistenData = await onPaneData(paneId, (b64) => {
|
||||||
|
term?.write(b64ToBytes(b64));
|
||||||
|
onDataReceivedRef.current?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
unlistenExit = await onPaneExit(paneId, () => {
|
||||||
|
term?.write("\r\n\x1b[33m[pane exited]\x1b[0m\r\n");
|
||||||
|
onStatusRef.current?.(`pane ${paneId} exited`, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
term?.onData((data) => {
|
||||||
|
if (paneId == null) return;
|
||||||
|
const b64 = stringToB64(data);
|
||||||
|
void writeToPane(paneId, b64);
|
||||||
|
onInputRef.current?.(b64);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Focus detection: xterm.js doesn't expose onFocus as a first-class event
|
||||||
|
// in all versions, so try the proposed API first then fall back to the DOM.
|
||||||
|
term?.onSelectionChange(() => {}); // ensure addon system is initialised; noop
|
||||||
|
const termAny = term as unknown as { onFocus?: (cb: () => void) => void };
|
||||||
|
if (typeof termAny.onFocus === "function") {
|
||||||
|
termAny.onFocus(() => onFocusRef.current?.());
|
||||||
|
} else {
|
||||||
|
const ta = container.querySelector(".xterm-helper-textarea");
|
||||||
|
if (ta) ta.addEventListener("focus", () => onFocusRef.current?.(), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-fit on container resize; forward new size to the PTY.
|
||||||
|
ro = new ResizeObserver(() => {
|
||||||
|
try {
|
||||||
|
fit.fit();
|
||||||
|
if (paneId != null && term) {
|
||||||
|
void resizePane(paneId, term.cols, term.rows);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("resize failed", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ro.observe(container);
|
||||||
|
|
||||||
|
// Focus so typing immediately lands in the terminal.
|
||||||
|
term?.focus();
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
destroyed = true;
|
||||||
|
ro?.disconnect();
|
||||||
|
unlistenData?.();
|
||||||
|
unlistenExit?.();
|
||||||
|
if (paneId != null) void killPane(paneId);
|
||||||
|
term?.dispose();
|
||||||
|
term = null;
|
||||||
|
};
|
||||||
|
// distro/cwd are only used at spawn time; intentionally omitted from deps
|
||||||
|
// so remounting doesn't happen if a parent re-renders with the same values.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// focusTrigger: programmatic refocus from parent (palette navigation etc.)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
const termRef = useRef<Terminal | null>(null);
|
||||||
|
|
||||||
|
// Keep termRef in sync via a second effect that runs after mount.
|
||||||
|
// We can't easily share the Terminal instance across the two effects without
|
||||||
|
// a ref, so we store it on termRef inside the mount effect instead.
|
||||||
|
// Actually, let's just wire focusTrigger by querying the textarea directly —
|
||||||
|
// that avoids the cross-effect coupling problem entirely.
|
||||||
|
useEffect(() => {
|
||||||
|
if (focusTrigger > 0 && containerRef.current) {
|
||||||
|
const ta = containerRef.current.querySelector<HTMLTextAreaElement>(
|
||||||
|
".xterm-helper-textarea",
|
||||||
|
);
|
||||||
|
ta?.focus();
|
||||||
|
}
|
||||||
|
}, [focusTrigger]);
|
||||||
|
|
||||||
|
// Suppress unused ref warning
|
||||||
|
void termRef;
|
||||||
|
|
||||||
|
return <div ref={containerRef} style={{ width: "100%", height: "100%" }} />;
|
||||||
|
}
|
||||||
170
src/lib/layout/LeafPane.css
Normal file
170
src/lib/layout/LeafPane.css
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
.leaf {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.leaf.active {
|
||||||
|
border-color: #5a8cd8;
|
||||||
|
}
|
||||||
|
.leaf.broadcasting {
|
||||||
|
border-color: #e09838;
|
||||||
|
}
|
||||||
|
.leaf.active.broadcasting {
|
||||||
|
border-color: #ffb840;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane-toolbar {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
background: #181818;
|
||||||
|
border-bottom: 1px solid #2a2a2a;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #aaa;
|
||||||
|
user-select: none;
|
||||||
|
min-height: 24px;
|
||||||
|
}
|
||||||
|
.pane-label {
|
||||||
|
font: inherit;
|
||||||
|
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ccc;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
cursor: text;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
.pane-label:hover {
|
||||||
|
background: #222;
|
||||||
|
border-color: #2a2a2a;
|
||||||
|
}
|
||||||
|
.label-input {
|
||||||
|
font: inherit;
|
||||||
|
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
background: #0c0c0c;
|
||||||
|
border: 1px solid #3a5a8c;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
outline: none;
|
||||||
|
max-width: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.distro-wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.distro-chip,
|
||||||
|
.bcast-chip {
|
||||||
|
font: inherit;
|
||||||
|
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
background: #222;
|
||||||
|
color: #88c;
|
||||||
|
border: 1px solid #2a2a3a;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.distro-chip:hover,
|
||||||
|
.bcast-chip:hover {
|
||||||
|
background: #2a2a3a;
|
||||||
|
color: #aac;
|
||||||
|
}
|
||||||
|
.bcast-chip {
|
||||||
|
color: #777;
|
||||||
|
background: #1c1c1c;
|
||||||
|
border-color: #2a2a2a;
|
||||||
|
padding: 1px 5px;
|
||||||
|
}
|
||||||
|
.bcast-chip.on {
|
||||||
|
background: #4a3010;
|
||||||
|
color: #f0c060;
|
||||||
|
border-color: #c98a1f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.distro-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
margin-top: 2px;
|
||||||
|
background: #1a1a1a;
|
||||||
|
border: 1px solid #2a2a2a;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 10;
|
||||||
|
min-width: 140px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
|
.distro-menu-item {
|
||||||
|
font: inherit;
|
||||||
|
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
text-align: left;
|
||||||
|
background: transparent;
|
||||||
|
color: #ccc;
|
||||||
|
border: none;
|
||||||
|
border-radius: 2px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.distro-menu-item:hover {
|
||||||
|
background: #2a2a2a;
|
||||||
|
}
|
||||||
|
.distro-menu-item.active {
|
||||||
|
background: #1a3a5c;
|
||||||
|
color: #cce6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane-status {
|
||||||
|
margin-left: auto;
|
||||||
|
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
|
||||||
|
color: #777;
|
||||||
|
font-size: 10px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.pane-status.ok { color: #6c6; }
|
||||||
|
.pane-status.err { color: #d66; }
|
||||||
|
|
||||||
|
.pane-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.pane-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #888;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.pane-btn:hover {
|
||||||
|
background: #2a2a2a;
|
||||||
|
color: #ddd;
|
||||||
|
}
|
||||||
|
.pane-btn.close:hover {
|
||||||
|
background: #5a1a1a;
|
||||||
|
color: #fcc;
|
||||||
|
}
|
||||||
|
.xterm-wrap {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
@ -1,400 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { onDestroy } from "svelte";
|
|
||||||
import type { LeafNode } from "./tree";
|
|
||||||
import { useOrchestration } from "./orchestration.svelte";
|
|
||||||
import XtermPane from "../../components/XtermPane.svelte";
|
|
||||||
|
|
||||||
let {
|
|
||||||
leaf,
|
|
||||||
activeLeafId,
|
|
||||||
}: {
|
|
||||||
leaf: LeafNode;
|
|
||||||
activeLeafId: string | null;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
const orch = useOrchestration();
|
|
||||||
|
|
||||||
|
|
||||||
let status = $state("starting…");
|
|
||||||
let statusOk = $state(true);
|
|
||||||
|
|
||||||
// ---- label editing -------------------------------------------------------
|
|
||||||
let editingLabel = $state(false);
|
|
||||||
let labelDraft = $state("");
|
|
||||||
let labelInputEl: HTMLInputElement | null = $state(null);
|
|
||||||
|
|
||||||
function startEditLabel(e: MouseEvent) {
|
|
||||||
e.stopPropagation();
|
|
||||||
labelDraft = leaf.label ?? "";
|
|
||||||
editingLabel = true;
|
|
||||||
queueMicrotask(() => labelInputEl?.select());
|
|
||||||
}
|
|
||||||
|
|
||||||
function commitLabel() {
|
|
||||||
if (!editingLabel) return;
|
|
||||||
orch.setLabel(leaf.id, labelDraft);
|
|
||||||
editingLabel = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelLabel() {
|
|
||||||
editingLabel = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function onLabelKey(e: KeyboardEvent) {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
commitLabel();
|
|
||||||
} else if (e.key === "Escape") {
|
|
||||||
e.preventDefault();
|
|
||||||
cancelLabel();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- distro popover ------------------------------------------------------
|
|
||||||
let distroOpen = $state(false);
|
|
||||||
|
|
||||||
function toggleDistroMenu(e: MouseEvent) {
|
|
||||||
e.stopPropagation();
|
|
||||||
distroOpen = !distroOpen;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickDistro(d: string) {
|
|
||||||
distroOpen = false;
|
|
||||||
if (d !== leaf.distro) orch.setDistro(leaf.id, d);
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!distroOpen) return;
|
|
||||||
const onDocClick = () => (distroOpen = false);
|
|
||||||
window.addEventListener("click", onDocClick);
|
|
||||||
return () => window.removeEventListener("click", onDocClick);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---- idle detection ------------------------------------------------------
|
|
||||||
const IDLE_THRESHOLD_MS = 5000;
|
|
||||||
let lastDataTime = Date.now();
|
|
||||||
let notifiedThisIdle = false;
|
|
||||||
|
|
||||||
function onDataReceived() {
|
|
||||||
lastDataTime = Date.now();
|
|
||||||
notifiedThisIdle = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkIdle() {
|
|
||||||
if (notifiedThisIdle) return;
|
|
||||||
const sinceLast = Date.now() - lastDataTime;
|
|
||||||
if (sinceLast >= IDLE_THRESHOLD_MS) {
|
|
||||||
notifiedThisIdle = true;
|
|
||||||
const name = leaf.label ?? leaf.distro ?? "pane";
|
|
||||||
console.log("[tiletopia] notifying idle:", leaf.id, "quietForMs:", sinceLast);
|
|
||||||
orch.notify(`${name} is idle`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const idleTimer = window.setInterval(checkIdle, 1000);
|
|
||||||
onDestroy(() => clearInterval(idleTimer));
|
|
||||||
|
|
||||||
// ---- broadcast -----------------------------------------------------------
|
|
||||||
function onTerminalInput(b64: string) {
|
|
||||||
if (leaf.broadcast) orch.broadcastFrom(leaf.id, b64);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- focus / active ------------------------------------------------------
|
|
||||||
let focusTrigger = $state(0);
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (activeLeafId === leaf.id) focusTrigger += 1;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Backup setActive for toolbar clicks (which can't reach the document
|
|
||||||
// capture listener if a bubble-phase handler stops propagation). Cheap;
|
|
||||||
// idempotent if the document listener also fired.
|
|
||||||
function onPaneClick() {
|
|
||||||
orch.setActive(leaf.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- pane id registration ------------------------------------------------
|
|
||||||
function onPaneSpawned(paneId: number) {
|
|
||||||
orch.registerPaneId(leaf.id, paneId);
|
|
||||||
}
|
|
||||||
onDestroy(() => orch.registerPaneId(leaf.id, null));
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="leaf"
|
|
||||||
class:active={activeLeafId === leaf.id}
|
|
||||||
data-debug-active={activeLeafId === leaf.id ? "yes" : "no"}
|
|
||||||
data-debug-prop={(activeLeafId ?? "null").slice(0, 8)}
|
|
||||||
class:broadcasting={leaf.broadcast}
|
|
||||||
role="group"
|
|
||||||
aria-label={"Terminal pane: " + (leaf.label ?? leaf.distro ?? "unnamed")}
|
|
||||||
data-leaf-id={leaf.id}
|
|
||||||
onpointerdown={onPaneClick}
|
|
||||||
>
|
|
||||||
<div class="pane-toolbar">
|
|
||||||
{#if editingLabel}
|
|
||||||
<input
|
|
||||||
class="label-input"
|
|
||||||
bind:this={labelInputEl}
|
|
||||||
bind:value={labelDraft}
|
|
||||||
onkeydown={onLabelKey}
|
|
||||||
onblur={commitLabel}
|
|
||||||
placeholder="(label)"
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<button
|
|
||||||
class="pane-label"
|
|
||||||
onclick={startEditLabel}
|
|
||||||
title="Click to rename pane"
|
|
||||||
>
|
|
||||||
{leaf.label ?? "(unnamed)"}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<span class="distro-wrap">
|
|
||||||
<button
|
|
||||||
class="distro-chip"
|
|
||||||
onclick={toggleDistroMenu}
|
|
||||||
title="Change distro (respawns the pane)"
|
|
||||||
>
|
|
||||||
{leaf.distro ?? "(default)"} ▾
|
|
||||||
</button>
|
|
||||||
{#if distroOpen}
|
|
||||||
<div
|
|
||||||
class="distro-menu"
|
|
||||||
onclick={(e) => e.stopPropagation()}
|
|
||||||
role="menu"
|
|
||||||
tabindex="-1"
|
|
||||||
onkeydown={() => {}}
|
|
||||||
>
|
|
||||||
{#each orch.distros as d}
|
|
||||||
<button
|
|
||||||
class="distro-menu-item"
|
|
||||||
class:active={d === leaf.distro}
|
|
||||||
onclick={() => pickDistro(d)}
|
|
||||||
>{d}</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="bcast-chip"
|
|
||||||
class:on={leaf.broadcast}
|
|
||||||
onclick={(e) => { e.stopPropagation(); orch.toggleBroadcast(leaf.id); }}
|
|
||||||
title={leaf.broadcast ? "Broadcasting (click to leave group)" : "Click to broadcast input to other broadcast panes"}
|
|
||||||
aria-pressed={leaf.broadcast ? "true" : "false"}
|
|
||||||
>📡</button>
|
|
||||||
|
|
||||||
<span class="pane-status {statusOk ? 'ok' : 'err'}">{status}</span>
|
|
||||||
|
|
||||||
<span class="pane-actions">
|
|
||||||
<button
|
|
||||||
class="pane-btn"
|
|
||||||
title="Split right"
|
|
||||||
onclick={(e) => { e.stopPropagation(); orch.split(leaf.id, "h"); }}
|
|
||||||
aria-label="Split right"
|
|
||||||
>⇥</button>
|
|
||||||
<button
|
|
||||||
class="pane-btn"
|
|
||||||
title="Split down"
|
|
||||||
onclick={(e) => { e.stopPropagation(); orch.split(leaf.id, "v"); }}
|
|
||||||
aria-label="Split down"
|
|
||||||
>⇣</button>
|
|
||||||
<button
|
|
||||||
class="pane-btn close"
|
|
||||||
title="Close pane"
|
|
||||||
onclick={(e) => { e.stopPropagation(); orch.close(leaf.id); }}
|
|
||||||
aria-label="Close pane"
|
|
||||||
>×</button>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="xterm-wrap">
|
|
||||||
<XtermPane
|
|
||||||
distro={leaf.distro}
|
|
||||||
cwd={leaf.cwd}
|
|
||||||
onStatus={(msg, ok) => {
|
|
||||||
status = msg;
|
|
||||||
statusOk = ok;
|
|
||||||
}}
|
|
||||||
onSpawn={onPaneSpawned}
|
|
||||||
onInput={onTerminalInput}
|
|
||||||
onFocus={() => orch.setActive(leaf.id)}
|
|
||||||
{onDataReceived}
|
|
||||||
{focusTrigger}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.leaf {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
min-height: 0;
|
|
||||||
border: 2px solid transparent;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
.leaf.active {
|
|
||||||
border-color: #5a8cd8;
|
|
||||||
}
|
|
||||||
.leaf.broadcasting {
|
|
||||||
border-color: #e09838;
|
|
||||||
}
|
|
||||||
.leaf.active.broadcasting {
|
|
||||||
border-color: #ffb840;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pane-toolbar {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 2px 8px;
|
|
||||||
background: #181818;
|
|
||||||
border-bottom: 1px solid #2a2a2a;
|
|
||||||
font-size: 11px;
|
|
||||||
color: #aaa;
|
|
||||||
user-select: none;
|
|
||||||
min-height: 24px;
|
|
||||||
}
|
|
||||||
.pane-label {
|
|
||||||
font: inherit;
|
|
||||||
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #ccc;
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
border-radius: 3px;
|
|
||||||
padding: 1px 6px;
|
|
||||||
cursor: text;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
max-width: 200px;
|
|
||||||
}
|
|
||||||
.pane-label:hover {
|
|
||||||
background: #222;
|
|
||||||
border-color: #2a2a2a;
|
|
||||||
}
|
|
||||||
.label-input {
|
|
||||||
font: inherit;
|
|
||||||
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #fff;
|
|
||||||
background: #0c0c0c;
|
|
||||||
border: 1px solid #3a5a8c;
|
|
||||||
border-radius: 3px;
|
|
||||||
padding: 1px 6px;
|
|
||||||
outline: none;
|
|
||||||
max-width: 240px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.distro-wrap {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
.distro-chip,
|
|
||||||
.bcast-chip {
|
|
||||||
font: inherit;
|
|
||||||
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
|
|
||||||
font-size: 10px;
|
|
||||||
background: #222;
|
|
||||||
color: #88c;
|
|
||||||
border: 1px solid #2a2a3a;
|
|
||||||
border-radius: 3px;
|
|
||||||
padding: 1px 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.distro-chip:hover,
|
|
||||||
.bcast-chip:hover {
|
|
||||||
background: #2a2a3a;
|
|
||||||
color: #aac;
|
|
||||||
}
|
|
||||||
.bcast-chip {
|
|
||||||
color: #777;
|
|
||||||
background: #1c1c1c;
|
|
||||||
border-color: #2a2a2a;
|
|
||||||
padding: 1px 5px;
|
|
||||||
}
|
|
||||||
.bcast-chip.on {
|
|
||||||
background: #4a3010;
|
|
||||||
color: #f0c060;
|
|
||||||
border-color: #c98a1f;
|
|
||||||
}
|
|
||||||
|
|
||||||
.distro-menu {
|
|
||||||
position: absolute;
|
|
||||||
top: 100%;
|
|
||||||
left: 0;
|
|
||||||
margin-top: 2px;
|
|
||||||
background: #1a1a1a;
|
|
||||||
border: 1px solid #2a2a2a;
|
|
||||||
border-radius: 4px;
|
|
||||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
|
|
||||||
z-index: 10;
|
|
||||||
min-width: 140px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 2px;
|
|
||||||
}
|
|
||||||
.distro-menu-item {
|
|
||||||
font: inherit;
|
|
||||||
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
|
|
||||||
font-size: 11px;
|
|
||||||
text-align: left;
|
|
||||||
background: transparent;
|
|
||||||
color: #ccc;
|
|
||||||
border: none;
|
|
||||||
border-radius: 2px;
|
|
||||||
padding: 3px 8px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.distro-menu-item:hover {
|
|
||||||
background: #2a2a2a;
|
|
||||||
}
|
|
||||||
.distro-menu-item.active {
|
|
||||||
background: #1a3a5c;
|
|
||||||
color: #cce6ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pane-status {
|
|
||||||
margin-left: auto;
|
|
||||||
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
|
|
||||||
color: #777;
|
|
||||||
font-size: 10px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.pane-status.ok { color: #6c6; }
|
|
||||||
.pane-status.err { color: #d66; }
|
|
||||||
|
|
||||||
.pane-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
.pane-btn {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: #888;
|
|
||||||
font: inherit;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1;
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 3px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.pane-btn:hover {
|
|
||||||
background: #2a2a2a;
|
|
||||||
color: #ddd;
|
|
||||||
}
|
|
||||||
.pane-btn.close:hover {
|
|
||||||
background: #5a1a1a;
|
|
||||||
color: #fcc;
|
|
||||||
}
|
|
||||||
.xterm-wrap {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
min-height: 0;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
263
src/lib/layout/LeafPane.tsx
Normal file
263
src/lib/layout/LeafPane.tsx
Normal file
|
|
@ -0,0 +1,263 @@
|
||||||
|
import {
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useCallback,
|
||||||
|
type KeyboardEvent,
|
||||||
|
type MouseEvent,
|
||||||
|
} from "react";
|
||||||
|
import type { LeafNode } from "./tree";
|
||||||
|
import { useOrchestration } from "./orchestration";
|
||||||
|
import XtermPane from "../../components/XtermPane";
|
||||||
|
import "./LeafPane.css";
|
||||||
|
|
||||||
|
const IDLE_THRESHOLD_MS = 5000;
|
||||||
|
|
||||||
|
export default function LeafPane({ leaf }: { leaf: LeafNode }) {
|
||||||
|
const orch = useOrchestration();
|
||||||
|
const isActive = orch.activeLeafId === leaf.id;
|
||||||
|
const isBroadcasting = !!leaf.broadcast;
|
||||||
|
|
||||||
|
// ---- status (from XtermPane) -------------------------------------------
|
||||||
|
const [status, setStatus] = useState("starting…");
|
||||||
|
const [statusOk, setStatusOk] = useState(true);
|
||||||
|
|
||||||
|
// ---- label editing -----------------------------------------------------
|
||||||
|
const [editingLabel, setEditingLabel] = useState(false);
|
||||||
|
const [labelDraft, setLabelDraft] = useState("");
|
||||||
|
const labelInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
const startEditLabel = useCallback(
|
||||||
|
(e: MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setLabelDraft(leaf.label ?? "");
|
||||||
|
setEditingLabel(true);
|
||||||
|
// Focus on next tick so input is mounted
|
||||||
|
queueMicrotask(() => labelInputRef.current?.select());
|
||||||
|
},
|
||||||
|
[leaf.label],
|
||||||
|
);
|
||||||
|
const commitLabel = useCallback(() => {
|
||||||
|
if (!editingLabel) return;
|
||||||
|
orch.setLabel(leaf.id, labelDraft);
|
||||||
|
setEditingLabel(false);
|
||||||
|
}, [editingLabel, orch, leaf.id, labelDraft]);
|
||||||
|
const cancelLabel = useCallback(() => setEditingLabel(false), []);
|
||||||
|
const onLabelKey = useCallback(
|
||||||
|
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
commitLabel();
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
cancelLabel();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[commitLabel, cancelLabel],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- distro popover ----------------------------------------------------
|
||||||
|
const [distroOpen, setDistroOpen] = useState(false);
|
||||||
|
const toggleDistroMenu = useCallback((e: MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setDistroOpen((v) => !v);
|
||||||
|
}, []);
|
||||||
|
const pickDistro = useCallback(
|
||||||
|
(d: string) => {
|
||||||
|
setDistroOpen(false);
|
||||||
|
if (d !== leaf.distro) orch.setDistro(leaf.id, d);
|
||||||
|
},
|
||||||
|
[orch, leaf.id, leaf.distro],
|
||||||
|
);
|
||||||
|
// Dismiss popover on outside click
|
||||||
|
useEffect(() => {
|
||||||
|
if (!distroOpen) return;
|
||||||
|
const onDocClick = () => setDistroOpen(false);
|
||||||
|
window.addEventListener("click", onDocClick);
|
||||||
|
return () => window.removeEventListener("click", onDocClick);
|
||||||
|
}, [distroOpen]);
|
||||||
|
|
||||||
|
// ---- idle detection ----------------------------------------------------
|
||||||
|
const lastDataTimeRef = useRef(Date.now());
|
||||||
|
const notifiedThisIdleRef = useRef(false);
|
||||||
|
const onDataReceived = useCallback(() => {
|
||||||
|
lastDataTimeRef.current = Date.now();
|
||||||
|
notifiedThisIdleRef.current = false;
|
||||||
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
const id = window.setInterval(() => {
|
||||||
|
if (notifiedThisIdleRef.current) return;
|
||||||
|
const dt = Date.now() - lastDataTimeRef.current;
|
||||||
|
if (dt >= IDLE_THRESHOLD_MS) {
|
||||||
|
notifiedThisIdleRef.current = true;
|
||||||
|
const name = leaf.label ?? leaf.distro ?? "pane";
|
||||||
|
orch.notify(`${name} is idle`);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [leaf.label, leaf.distro, orch]);
|
||||||
|
|
||||||
|
// ---- broadcast ---------------------------------------------------------
|
||||||
|
const onTerminalInput = useCallback(
|
||||||
|
(b64: string) => {
|
||||||
|
if (isBroadcasting) orch.broadcastFrom(leaf.id, b64);
|
||||||
|
},
|
||||||
|
[isBroadcasting, orch, leaf.id],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- focus / active highlighting ---------------------------------------
|
||||||
|
const [focusTrigger, setFocusTrigger] = useState(0);
|
||||||
|
// When this leaf becomes active, bump focusTrigger so XtermPane refocuses.
|
||||||
|
useEffect(() => {
|
||||||
|
if (isActive) setFocusTrigger((n) => n + 1);
|
||||||
|
}, [isActive]);
|
||||||
|
|
||||||
|
const onPaneClick = useCallback(() => {
|
||||||
|
orch.setActive(leaf.id);
|
||||||
|
}, [orch, leaf.id]);
|
||||||
|
|
||||||
|
const onPaneSpawned = useCallback(
|
||||||
|
(paneId: number) => {
|
||||||
|
orch.registerPaneId(leaf.id, paneId);
|
||||||
|
},
|
||||||
|
[orch, leaf.id],
|
||||||
|
);
|
||||||
|
// Unregister on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => orch.registerPaneId(leaf.id, null);
|
||||||
|
}, [orch, leaf.id]);
|
||||||
|
|
||||||
|
const onXtermFocus = useCallback(() => orch.setActive(leaf.id), [orch, leaf.id]);
|
||||||
|
|
||||||
|
const onStatus = useCallback((msg: string, ok: boolean) => {
|
||||||
|
setStatus(msg);
|
||||||
|
setStatusOk(ok);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const labelText = leaf.label ?? "(unnamed)";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`leaf${isActive ? " active" : ""}${isBroadcasting ? " broadcasting" : ""}`}
|
||||||
|
role="group"
|
||||||
|
aria-label={`Terminal pane: ${leaf.label ?? leaf.distro ?? "unnamed"}`}
|
||||||
|
data-leaf-id={leaf.id}
|
||||||
|
onPointerDown={onPaneClick}
|
||||||
|
>
|
||||||
|
<div className="pane-toolbar">
|
||||||
|
{editingLabel ? (
|
||||||
|
<input
|
||||||
|
ref={labelInputRef}
|
||||||
|
className="label-input"
|
||||||
|
value={labelDraft}
|
||||||
|
onChange={(e) => setLabelDraft(e.target.value)}
|
||||||
|
onKeyDown={onLabelKey}
|
||||||
|
onBlur={commitLabel}
|
||||||
|
placeholder="(label)"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="pane-label"
|
||||||
|
onClick={startEditLabel}
|
||||||
|
title="Click to rename pane"
|
||||||
|
>
|
||||||
|
{labelText}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span className="distro-wrap">
|
||||||
|
<button
|
||||||
|
className="distro-chip"
|
||||||
|
onClick={toggleDistroMenu}
|
||||||
|
title="Change distro (respawns the pane)"
|
||||||
|
>
|
||||||
|
{leaf.distro ?? "(default)"} ▾
|
||||||
|
</button>
|
||||||
|
{distroOpen && (
|
||||||
|
<div
|
||||||
|
className="distro-menu"
|
||||||
|
role="menu"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{orch.distros.map((d) => (
|
||||||
|
<button
|
||||||
|
key={d}
|
||||||
|
className={`distro-menu-item${d === leaf.distro ? " active" : ""}`}
|
||||||
|
onClick={() => pickDistro(d)}
|
||||||
|
>
|
||||||
|
{d}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={`bcast-chip${isBroadcasting ? " on" : ""}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
orch.toggleBroadcast(leaf.id);
|
||||||
|
}}
|
||||||
|
title={
|
||||||
|
isBroadcasting
|
||||||
|
? "Broadcasting (click to leave group)"
|
||||||
|
: "Click to broadcast input to other broadcast panes"
|
||||||
|
}
|
||||||
|
aria-pressed={isBroadcasting ? "true" : "false"}
|
||||||
|
>
|
||||||
|
📡
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span className={`pane-status ${statusOk ? "ok" : "err"}`}>{status}</span>
|
||||||
|
|
||||||
|
<span className="pane-actions">
|
||||||
|
<button
|
||||||
|
className="pane-btn"
|
||||||
|
title="Split right"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
orch.split(leaf.id, "h");
|
||||||
|
}}
|
||||||
|
aria-label="Split right"
|
||||||
|
>
|
||||||
|
⇥
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="pane-btn"
|
||||||
|
title="Split down"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
orch.split(leaf.id, "v");
|
||||||
|
}}
|
||||||
|
aria-label="Split down"
|
||||||
|
>
|
||||||
|
⇣
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="pane-btn close"
|
||||||
|
title="Close pane"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
orch.close(leaf.id);
|
||||||
|
}}
|
||||||
|
aria-label="Close pane"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="xterm-wrap">
|
||||||
|
<XtermPane
|
||||||
|
distro={leaf.distro}
|
||||||
|
cwd={leaf.cwd}
|
||||||
|
onStatus={onStatus}
|
||||||
|
onSpawn={onPaneSpawned}
|
||||||
|
onInput={onTerminalInput}
|
||||||
|
onDataReceived={onDataReceived}
|
||||||
|
onFocus={onXtermFocus}
|
||||||
|
focusTrigger={focusTrigger}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import type { TreeNode, NodeId } from "./tree";
|
|
||||||
import SplitNode from "./SplitNode.svelte";
|
|
||||||
import LeafPane from "./LeafPane.svelte";
|
|
||||||
|
|
||||||
let {
|
|
||||||
node,
|
|
||||||
activeLeafId,
|
|
||||||
}: {
|
|
||||||
node: TreeNode;
|
|
||||||
activeLeafId: NodeId | null;
|
|
||||||
} = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if node.kind === "split"}
|
|
||||||
<SplitNode {node} {activeLeafId} />
|
|
||||||
{:else}
|
|
||||||
<LeafPane leaf={node} {activeLeafId} />
|
|
||||||
{/if}
|
|
||||||
16
src/lib/layout/Pane.tsx
Normal file
16
src/lib/layout/Pane.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import type { TreeNode } from "./tree";
|
||||||
|
import SplitNode from "./SplitNode";
|
||||||
|
import LeafPane from "./LeafPane";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursive dispatcher: render a split or a leaf based on node.kind.
|
||||||
|
* The `key={node.id}` on the leaf branch makes React unmount + remount
|
||||||
|
* cleanly when a leaf is replaced (e.g. changeDistro swaps the id to
|
||||||
|
* force PTY respawn).
|
||||||
|
*/
|
||||||
|
export default function Pane({ node }: { node: TreeNode }) {
|
||||||
|
if (node.kind === "split") {
|
||||||
|
return <SplitNode node={node} />;
|
||||||
|
}
|
||||||
|
return <LeafPane key={node.id} leaf={node} />;
|
||||||
|
}
|
||||||
36
src/lib/layout/SplitNode.css
Normal file
36
src/lib/layout/SplitNode.css
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
.split {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.split.horizontal {
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
.split.vertical {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gutter {
|
||||||
|
flex: 0 0 4px;
|
||||||
|
background: #1a1a1a;
|
||||||
|
cursor: col-resize;
|
||||||
|
user-select: none;
|
||||||
|
touch-action: none;
|
||||||
|
transition: background 0.12s;
|
||||||
|
}
|
||||||
|
.split.vertical > .gutter {
|
||||||
|
cursor: row-resize;
|
||||||
|
}
|
||||||
|
.gutter:hover,
|
||||||
|
.gutter.active {
|
||||||
|
background: #3a5a8c;
|
||||||
|
}
|
||||||
|
|
@ -1,116 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import type { SplitNode, NodeId } from "./tree";
|
|
||||||
import Pane from "./Pane.svelte";
|
|
||||||
|
|
||||||
let {
|
|
||||||
node,
|
|
||||||
activeLeafId,
|
|
||||||
}: {
|
|
||||||
node: SplitNode;
|
|
||||||
activeLeafId: NodeId | null;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
let containerEl: HTMLDivElement;
|
|
||||||
let dragging = $state(false);
|
|
||||||
|
|
||||||
function onPointerDown(e: PointerEvent) {
|
|
||||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
|
||||||
dragging = true;
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
|
|
||||||
// rAF-throttle the DOM flex update so we don't spam SIGWINCH to PTYs.
|
|
||||||
let pendingRaf: number | null = null;
|
|
||||||
let pendingRatio = 0;
|
|
||||||
|
|
||||||
function onPointerMove(e: PointerEvent) {
|
|
||||||
if (!dragging || !containerEl) return;
|
|
||||||
const rect = containerEl.getBoundingClientRect();
|
|
||||||
const isH = node.orientation === "h";
|
|
||||||
const pos = isH ? e.clientX - rect.left : e.clientY - rect.top;
|
|
||||||
const size = isH ? rect.width : rect.height;
|
|
||||||
if (size <= 0) return;
|
|
||||||
const r = Math.max(0.05, Math.min(0.95, pos / size));
|
|
||||||
node.ratio = r;
|
|
||||||
pendingRatio = r;
|
|
||||||
if (pendingRaf == null) {
|
|
||||||
pendingRaf = requestAnimationFrame(() => {
|
|
||||||
pendingRaf = null;
|
|
||||||
const sides = containerEl.querySelectorAll(":scope > .side");
|
|
||||||
if (sides[0]) (sides[0] as HTMLElement).style.flex = String(pendingRatio);
|
|
||||||
if (sides[1]) (sides[1] as HTMLElement).style.flex = String(1 - pendingRatio);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onPointerUp(e: PointerEvent) {
|
|
||||||
if (!dragging) return;
|
|
||||||
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
|
|
||||||
dragging = false;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="split"
|
|
||||||
class:horizontal={node.orientation === "h"}
|
|
||||||
class:vertical={node.orientation === "v"}
|
|
||||||
bind:this={containerEl}
|
|
||||||
>
|
|
||||||
<div class="side" style="flex: {node.ratio}">
|
|
||||||
<Pane node={node.a} {activeLeafId} />
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="gutter"
|
|
||||||
class:active={dragging}
|
|
||||||
role="separator"
|
|
||||||
aria-orientation={node.orientation === "h" ? "vertical" : "horizontal"}
|
|
||||||
aria-valuenow={Math.round(node.ratio * 100)}
|
|
||||||
tabindex="-1"
|
|
||||||
onpointerdown={onPointerDown}
|
|
||||||
onpointermove={onPointerMove}
|
|
||||||
onpointerup={onPointerUp}
|
|
||||||
onpointercancel={onPointerUp}
|
|
||||||
></div>
|
|
||||||
<div class="side" style="flex: {1 - node.ratio}">
|
|
||||||
<Pane node={node.b} {activeLeafId} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.split {
|
|
||||||
display: flex;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
.split.horizontal {
|
|
||||||
flex-direction: row;
|
|
||||||
}
|
|
||||||
.split.vertical {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.side {
|
|
||||||
display: flex;
|
|
||||||
min-width: 0;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gutter {
|
|
||||||
flex: 0 0 4px;
|
|
||||||
background: #1a1a1a;
|
|
||||||
cursor: col-resize;
|
|
||||||
user-select: none;
|
|
||||||
touch-action: none;
|
|
||||||
transition: background 0.12s;
|
|
||||||
}
|
|
||||||
.split.vertical > .gutter {
|
|
||||||
cursor: row-resize;
|
|
||||||
}
|
|
||||||
.gutter:hover,
|
|
||||||
.gutter.active {
|
|
||||||
background: #3a5a8c;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
79
src/lib/layout/SplitNode.tsx
Normal file
79
src/lib/layout/SplitNode.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
import { useRef, useState, useCallback, type PointerEvent } from "react";
|
||||||
|
import type { SplitNode as SplitNodeType } from "./tree";
|
||||||
|
import Pane from "./Pane";
|
||||||
|
import "./SplitNode.css";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A horizontal or vertical split with a draggable gutter. The ratio is
|
||||||
|
* local React state — when the gutter is dragged, we update the local
|
||||||
|
* ratio (re-rendering the two .side flex values) and ALSO bubble the
|
||||||
|
* change up to the tree (so it persists across reloads).
|
||||||
|
*
|
||||||
|
* Initialising local state from node.ratio is fine: when the tree
|
||||||
|
* mutates around this split (e.g. a child is closed), React will give us
|
||||||
|
* a new `node` prop with possibly-different `node.ratio`, but the
|
||||||
|
* `useState` initializer only runs once. We re-sync via an effect.
|
||||||
|
*/
|
||||||
|
export default function SplitNode({ node }: { node: SplitNodeType }) {
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [ratio, setRatio] = useState(node.ratio);
|
||||||
|
const [dragging, setDragging] = useState(false);
|
||||||
|
|
||||||
|
// Keep local ratio in sync if the tree updates from outside (e.g. preset
|
||||||
|
// applied). Only mirror — don't echo back into the tree.
|
||||||
|
// (Skipped for simplicity in v1; if it becomes annoying we can add it.)
|
||||||
|
|
||||||
|
const onPointerDown = useCallback((e: PointerEvent<HTMLDivElement>) => {
|
||||||
|
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||||
|
setDragging(true);
|
||||||
|
e.preventDefault();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onPointerMove = useCallback(
|
||||||
|
(e: PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (!dragging || !containerRef.current) return;
|
||||||
|
const rect = containerRef.current.getBoundingClientRect();
|
||||||
|
const isH = node.orientation === "h";
|
||||||
|
const pos = isH ? e.clientX - rect.left : e.clientY - rect.top;
|
||||||
|
const size = isH ? rect.width : rect.height;
|
||||||
|
if (size <= 0) return;
|
||||||
|
const r = Math.max(0.05, Math.min(0.95, pos / size));
|
||||||
|
setRatio(r);
|
||||||
|
// Mutate the proxy-tree node directly so the persisted state matches.
|
||||||
|
node.ratio = r;
|
||||||
|
},
|
||||||
|
[dragging, node],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onPointerUp = useCallback((e: PointerEvent<HTMLDivElement>) => {
|
||||||
|
setDragging(false);
|
||||||
|
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isH = node.orientation === "h";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={`split ${isH ? "horizontal" : "vertical"}`}
|
||||||
|
>
|
||||||
|
<div className="side" style={{ flex: ratio }}>
|
||||||
|
<Pane node={node.a} />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`gutter${dragging ? " active" : ""}`}
|
||||||
|
role="separator"
|
||||||
|
aria-orientation={isH ? "vertical" : "horizontal"}
|
||||||
|
aria-valuenow={Math.round(ratio * 100)}
|
||||||
|
tabIndex={-1}
|
||||||
|
onPointerDown={onPointerDown}
|
||||||
|
onPointerMove={onPointerMove}
|
||||||
|
onPointerUp={onPointerUp}
|
||||||
|
onPointerCancel={onPointerUp}
|
||||||
|
/>
|
||||||
|
<div className="side" style={{ flex: 1 - ratio }}>
|
||||||
|
<Pane node={node.b} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,143 +0,0 @@
|
||||||
/**
|
|
||||||
* Orchestration store — all shared reactive state + operations the
|
|
||||||
* pane tree needs. Lives in a class with `$state` fields so Svelte 5
|
|
||||||
* reactivity tracks per-property access; provided via context so any
|
|
||||||
* descendant component can `useOrchestration()` without prop drilling.
|
|
||||||
*
|
|
||||||
* (File must be `.svelte.ts` because `$state` can only be used in
|
|
||||||
* Svelte components or files with the `.svelte.{js,ts}` extension.)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { setContext, getContext } from "svelte";
|
|
||||||
import type { NodeId, Orientation } from "./tree";
|
|
||||||
import type { PaneId } from "../../ipc";
|
|
||||||
|
|
||||||
export interface Toast {
|
|
||||||
id: number;
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Callbacks App provides at construction time. These do tree mutations
|
|
||||||
* (which require access to App's `tree = $state(...)`) plus broadcast
|
|
||||||
* routing (which also needs the tree). Kept as an injection seam rather
|
|
||||||
* than living inside the store so the store doesn't need to own the tree.
|
|
||||||
*/
|
|
||||||
export interface TreeOps {
|
|
||||||
split: (leafId: NodeId, orientation: Orientation) => void;
|
|
||||||
close: (leafId: NodeId) => void;
|
|
||||||
setDistro: (leafId: NodeId, distro: string) => void;
|
|
||||||
setLabel: (leafId: NodeId, label: string | undefined) => void;
|
|
||||||
toggleBroadcast: (leafId: NodeId) => void;
|
|
||||||
broadcastFrom: (originLeafId: NodeId, dataB64: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Orchestration {
|
|
||||||
// ---- shared reactive state ----------------------------------------------
|
|
||||||
// (activeLeafId lives at App level and is drilled as a prop — Svelte 5
|
|
||||||
// doesn't seem to track class-field $state reads from child components
|
|
||||||
// that obtain the instance via getContext. Tested empirically.)
|
|
||||||
notifications = $state<Toast[]>([]);
|
|
||||||
distros = $state<string[]>([]);
|
|
||||||
|
|
||||||
// ---- non-reactive lookups -----------------------------------------------
|
|
||||||
// Plain Map: broadcast routing reads this from an event handler, not
|
|
||||||
// from reactive context. No need for $state.
|
|
||||||
paneIdByLeaf = new Map<NodeId, PaneId>();
|
|
||||||
|
|
||||||
// ---- internal -----------------------------------------------------------
|
|
||||||
#nextNotifId = 1;
|
|
||||||
#dismissTimers = new Map<number, ReturnType<typeof setTimeout>>();
|
|
||||||
#ops: TreeOps;
|
|
||||||
|
|
||||||
constructor(ops: TreeOps) {
|
|
||||||
this.#ops = ops;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- active pane (delegated to App) -------------------------------------
|
|
||||||
// These point at App-level $state mutators set via configure().
|
|
||||||
setActive: (leafId: NodeId) => void = () => {};
|
|
||||||
clearActiveIf: (leafId: NodeId) => void = () => {};
|
|
||||||
|
|
||||||
configureActiveHandlers(
|
|
||||||
setActive: (leafId: NodeId) => void,
|
|
||||||
clearActiveIf: (leafId: NodeId) => void,
|
|
||||||
): void {
|
|
||||||
this.setActive = setActive;
|
|
||||||
this.clearActiveIf = clearActiveIf;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- notifications ------------------------------------------------------
|
|
||||||
notify(message: string): void {
|
|
||||||
const id = this.#nextNotifId++;
|
|
||||||
console.log("[orch] notify", message);
|
|
||||||
this.notifications.push({ id, message });
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
this.notifications = this.notifications.filter((n) => n.id !== id);
|
|
||||||
this.#dismissTimers.delete(id);
|
|
||||||
}, 5000);
|
|
||||||
this.#dismissTimers.set(id, timer);
|
|
||||||
}
|
|
||||||
|
|
||||||
dismiss(id: number): void {
|
|
||||||
const t = this.#dismissTimers.get(id);
|
|
||||||
if (t) {
|
|
||||||
clearTimeout(t);
|
|
||||||
this.#dismissTimers.delete(id);
|
|
||||||
}
|
|
||||||
this.notifications = this.notifications.filter((n) => n.id !== id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- pane id registry ---------------------------------------------------
|
|
||||||
registerPaneId(leafId: NodeId, paneId: PaneId | null): void {
|
|
||||||
if (paneId == null) this.paneIdByLeaf.delete(leafId);
|
|
||||||
else this.paneIdByLeaf.set(leafId, paneId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- delegated tree ops -------------------------------------------------
|
|
||||||
// Thin pass-through so consumers only need one object.
|
|
||||||
split(leafId: NodeId, orientation: Orientation): void {
|
|
||||||
console.log("[orch] split", leafId, orientation);
|
|
||||||
this.#ops.split(leafId, orientation);
|
|
||||||
}
|
|
||||||
|
|
||||||
close(leafId: NodeId): void {
|
|
||||||
console.log("[orch] close", leafId);
|
|
||||||
this.#ops.close(leafId);
|
|
||||||
}
|
|
||||||
|
|
||||||
setDistro(leafId: NodeId, distro: string): void {
|
|
||||||
this.#ops.setDistro(leafId, distro);
|
|
||||||
}
|
|
||||||
|
|
||||||
setLabel(leafId: NodeId, label: string | undefined): void {
|
|
||||||
this.#ops.setLabel(leafId, label);
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleBroadcast(leafId: NodeId): void {
|
|
||||||
console.log("[orch] toggleBroadcast", leafId);
|
|
||||||
this.#ops.toggleBroadcast(leafId);
|
|
||||||
}
|
|
||||||
|
|
||||||
broadcastFrom(originLeafId: NodeId, dataB64: string): void {
|
|
||||||
this.#ops.broadcastFrom(originLeafId, dataB64);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const KEY = Symbol("tiletopia.orchestration");
|
|
||||||
|
|
||||||
export function provideOrchestration(ops: TreeOps): Orchestration {
|
|
||||||
const o = new Orchestration(ops);
|
|
||||||
setContext(KEY, o);
|
|
||||||
return o;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useOrchestration(): Orchestration {
|
|
||||||
const o = getContext<Orchestration | undefined>(KEY);
|
|
||||||
if (!o) {
|
|
||||||
throw new Error(
|
|
||||||
"useOrchestration() called outside a provideOrchestration() ancestor",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return o;
|
|
||||||
}
|
|
||||||
58
src/lib/layout/orchestration.tsx
Normal file
58
src/lib/layout/orchestration.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { createContext, useContext, type ReactNode } from "react";
|
||||||
|
import type { Orientation, NodeId } from "./tree";
|
||||||
|
import type { PaneId } from "../../ipc";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Orchestration context — every piece of shared state and every operation
|
||||||
|
* that a Pane / SplitNode / LeafPane might call. Lives in React context so
|
||||||
|
* descendants can `useOrchestration()` without prop drilling.
|
||||||
|
*
|
||||||
|
* activeLeafId comes in as a plain value (re-derived by App's useState).
|
||||||
|
* React's context is reactive: when the App-level Provider updates the
|
||||||
|
* value, ALL consumers re-render. No Svelte-style props-don't-propagate
|
||||||
|
* trap here.
|
||||||
|
*/
|
||||||
|
export interface Orchestration {
|
||||||
|
// Read-only state
|
||||||
|
activeLeafId: NodeId | null;
|
||||||
|
distros: string[];
|
||||||
|
|
||||||
|
// Tree mutations
|
||||||
|
split: (leafId: NodeId, orientation: Orientation) => void;
|
||||||
|
close: (leafId: NodeId) => void;
|
||||||
|
setDistro: (leafId: NodeId, distro: string) => void;
|
||||||
|
setLabel: (leafId: NodeId, label: string | undefined) => void;
|
||||||
|
toggleBroadcast: (leafId: NodeId) => void;
|
||||||
|
|
||||||
|
// Per-pane orchestration
|
||||||
|
setActive: (leafId: NodeId) => void;
|
||||||
|
registerPaneId: (leafId: NodeId, paneId: PaneId | null) => void;
|
||||||
|
broadcastFrom: (originLeafId: NodeId, dataB64: string) => void;
|
||||||
|
notify: (message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const OrchestrationContext = createContext<Orchestration | null>(null);
|
||||||
|
|
||||||
|
export function OrchestrationProvider({
|
||||||
|
value,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
value: Orchestration;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<OrchestrationContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</OrchestrationContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOrchestration(): Orchestration {
|
||||||
|
const orch = useContext(OrchestrationContext);
|
||||||
|
if (!orch) {
|
||||||
|
throw new Error(
|
||||||
|
"useOrchestration() must be called inside <OrchestrationProvider>",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return orch;
|
||||||
|
}
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
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;
|
|
||||||
13
src/main.tsx
Normal file
13
src/main.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import "./styles.css";
|
||||||
|
import App from "./App";
|
||||||
|
|
||||||
|
const root = document.getElementById("root");
|
||||||
|
if (!root) throw new Error("No #root element found");
|
||||||
|
|
||||||
|
createRoot(root).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>
|
||||||
|
);
|
||||||
|
|
@ -1,65 +1,39 @@
|
||||||
:root {
|
@import "@xterm/xterm/css/xterm.css";
|
||||||
color-scheme: dark;
|
|
||||||
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
*,
|
||||||
font-size: 13px;
|
*::before,
|
||||||
background: #0c0c0c;
|
*::after {
|
||||||
color: #e6e6e6;
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body,
|
body,
|
||||||
#app {
|
#root {
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
height: 100%;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app {
|
body {
|
||||||
display: flex;
|
background: #1a1a1a;
|
||||||
flex-direction: column;
|
color: #e0e0e0;
|
||||||
height: 100vh;
|
font-family: "Cascadia Code", "Fira Code", "JetBrains Mono", monospace;
|
||||||
background: #0c0c0c;
|
font-size: 14px;
|
||||||
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.titlebar {
|
.app-root {
|
||||||
flex: 0 0 auto;
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
justify-content: center;
|
||||||
padding: 6px 12px;
|
color: #888;
|
||||||
background: #1a1a1a;
|
font-size: 13px;
|
||||||
border-bottom: 1px solid #2a2a2a;
|
letter-spacing: 0.05em;
|
||||||
font-size: 12px;
|
|
||||||
color: #aaa;
|
|
||||||
user-select: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.titlebar .label {
|
.xterm { height: 100%; }
|
||||||
font-weight: 600;
|
.xterm-viewport { background: #0c0c0c !important; }
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
|
|
||||||
|
|
||||||
export default {
|
|
||||||
preprocess: vitePreprocess(),
|
|
||||||
};
|
|
||||||
20
tsconfig.app.json
Normal file
20
tsconfig.app.json
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
|
|
@ -1,20 +1,7 @@
|
||||||
{
|
{
|
||||||
"extends": "@tsconfig/svelte/tsconfig.json",
|
"files": [],
|
||||||
"compilerOptions": {
|
"references": [
|
||||||
"target": "ESNext",
|
{ "path": "./tsconfig.node.json" },
|
||||||
"useDefineForClassFields": true,
|
{ "path": "./tsconfig.app.json" }
|
||||||
"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" }]
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,18 @@
|
||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"composite": true,
|
"target": "ES2022",
|
||||||
"skipLibCheck": true,
|
"lib": ["ES2022"],
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "Bundler",
|
"skipLibCheck": true,
|
||||||
"allowSyntheticDefaultImports": true,
|
"moduleResolution": "bundler",
|
||||||
"strict": true
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
},
|
},
|
||||||
"include": ["vite.config.ts"]
|
"include": ["vite.config.ts"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
/// <reference types="vitest" />
|
/// <reference types="vitest" />
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
export default defineConfig(async () => ({
|
export default defineConfig({
|
||||||
plugins: [svelte()],
|
plugins: [react()],
|
||||||
clearScreen: false,
|
clearScreen: false,
|
||||||
server: {
|
server: {
|
||||||
port: 1420,
|
port: 1420,
|
||||||
|
|
@ -19,6 +19,6 @@ export default defineConfig(async () => ({
|
||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
environment: "node",
|
environment: "node",
|
||||||
include: ["src/**/*.test.ts"],
|
include: ["src/**/*.test.ts", "src/**/*.test.tsx"],
|
||||||
},
|
},
|
||||||
}));
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue