diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
index 0059ff6..025c7e9 100644
--- a/.gitea/workflows/ci.yml
+++ b/.gitea/workflows/ci.yml
@@ -52,3 +52,29 @@ jobs:
- name: test
run: cargo test --all
+
+ web:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Install Node.js + pnpm
+ run: |
+ apt-get update
+ apt-get install -y --no-install-recommends curl ca-certificates
+ curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
+ apt-get install -y --no-install-recommends nodejs
+ corepack enable
+ corepack prepare pnpm@9.15.4 --activate
+
+ - name: Install deps
+ run: pnpm -C web install --frozen-lockfile
+
+ - name: typecheck
+ run: pnpm -C web typecheck
+
+ - name: test
+ run: pnpm -C web test
+
+ - name: build
+ run: pnpm -C web build
diff --git a/web/package.json b/web/package.json
index 4bed4bc..26e4793 100644
--- a/web/package.json
+++ b/web/package.json
@@ -8,7 +8,9 @@
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
- "typecheck": "tsc -b --noEmit"
+ "typecheck": "tsc -b --noEmit",
+ "test": "vitest run",
+ "test:watch": "vitest"
},
"dependencies": {
"@xterm/addon-fit": "^0.10.0",
@@ -16,6 +18,7 @@
},
"devDependencies": {
"typescript": "^5.6.3",
- "vite": "^5.4.10"
+ "vite": "^5.4.10",
+ "vitest": "^2.1.9"
}
}
diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml
index 4628288..cc85eb1 100644
--- a/web/pnpm-lock.yaml
+++ b/web/pnpm-lock.yaml
@@ -21,6 +21,9 @@ importers:
vite:
specifier: ^5.4.10
version: 5.4.21
+ vitest:
+ specifier: ^2.1.9
+ version: 2.1.9
packages:
@@ -162,6 +165,9 @@ packages:
cpu: [x64]
os: [win32]
+ '@jridgewell/sourcemap-codec@1.5.5':
+ resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
+
'@rollup/rollup-android-arm-eabi@4.60.4':
resolution: {integrity: sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==}
cpu: [arm]
@@ -196,79 +202,66 @@ packages:
resolution: {integrity: sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==}
cpu: [arm]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.60.4':
resolution: {integrity: sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==}
cpu: [arm]
os: [linux]
- libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.60.4':
resolution: {integrity: sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.60.4':
resolution: {integrity: sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.60.4':
resolution: {integrity: sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==}
cpu: [loong64]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.60.4':
resolution: {integrity: sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==}
cpu: [loong64]
os: [linux]
- libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.60.4':
resolution: {integrity: sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==}
cpu: [ppc64]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.60.4':
resolution: {integrity: sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==}
cpu: [ppc64]
os: [linux]
- libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.60.4':
resolution: {integrity: sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==}
cpu: [riscv64]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.60.4':
resolution: {integrity: sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==}
cpu: [riscv64]
os: [linux]
- libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.60.4':
resolution: {integrity: sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==}
cpu: [s390x]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.60.4':
resolution: {integrity: sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.60.4':
resolution: {integrity: sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==}
cpu: [x64]
os: [linux]
- libc: [musl]
'@rollup/rollup-openbsd-x64@4.60.4':
resolution: {integrity: sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==}
@@ -303,6 +296,35 @@ packages:
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
+ '@vitest/expect@2.1.9':
+ resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==}
+
+ '@vitest/mocker@2.1.9':
+ resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==}
+ peerDependencies:
+ msw: ^2.4.9
+ vite: ^5.0.0
+ peerDependenciesMeta:
+ msw:
+ optional: true
+ vite:
+ optional: true
+
+ '@vitest/pretty-format@2.1.9':
+ resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==}
+
+ '@vitest/runner@2.1.9':
+ resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==}
+
+ '@vitest/snapshot@2.1.9':
+ resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==}
+
+ '@vitest/spy@2.1.9':
+ resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==}
+
+ '@vitest/utils@2.1.9':
+ resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==}
+
'@xterm/addon-fit@0.10.0':
resolution: {integrity: sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==}
peerDependencies:
@@ -311,21 +333,76 @@ packages:
'@xterm/xterm@5.5.0':
resolution: {integrity: sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==}
+ assertion-error@2.0.1:
+ resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
+ engines: {node: '>=12'}
+
+ cac@6.7.14:
+ resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
+ engines: {node: '>=8'}
+
+ chai@5.3.3:
+ resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
+ engines: {node: '>=18'}
+
+ check-error@2.1.3:
+ resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
+ engines: {node: '>= 16'}
+
+ debug@4.4.3:
+ resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
+ engines: {node: '>=6.0'}
+ peerDependencies:
+ supports-color: '*'
+ peerDependenciesMeta:
+ supports-color:
+ optional: true
+
+ deep-eql@5.0.2:
+ resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
+ engines: {node: '>=6'}
+
+ es-module-lexer@1.7.0:
+ resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
+
esbuild@0.21.5:
resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==}
engines: {node: '>=12'}
hasBin: true
+ estree-walker@3.0.3:
+ resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
+
+ expect-type@1.4.0:
+ resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
+ engines: {node: '>=12.0.0'}
+
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
+ loupe@3.2.1:
+ resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
+
+ magic-string@0.30.21:
+ resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
+
+ ms@2.1.3:
+ resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
+
nanoid@3.3.12:
resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
+ pathe@1.1.2:
+ resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==}
+
+ pathval@2.0.1:
+ resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
+ engines: {node: '>= 14.16'}
+
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -338,15 +415,47 @@ packages:
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
+ siginfo@2.0.0:
+ resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
+
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
+ stackback@0.0.2:
+ resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
+
+ std-env@3.10.0:
+ resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
+
+ tinybench@2.9.0:
+ resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
+
+ tinyexec@0.3.2:
+ resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
+
+ tinypool@1.1.1:
+ resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==}
+ engines: {node: ^18.0.0 || >=20.0.0}
+
+ tinyrainbow@1.2.0:
+ resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==}
+ engines: {node: '>=14.0.0'}
+
+ tinyspy@3.0.2:
+ resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==}
+ engines: {node: '>=14.0.0'}
+
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
+ vite-node@2.1.9:
+ resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==}
+ engines: {node: ^18.0.0 || >=20.0.0}
+ hasBin: true
+
vite@5.4.21:
resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==}
engines: {node: ^18.0.0 || >=20.0.0}
@@ -378,6 +487,36 @@ packages:
terser:
optional: true
+ vitest@2.1.9:
+ resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==}
+ engines: {node: ^18.0.0 || >=20.0.0}
+ hasBin: true
+ peerDependencies:
+ '@edge-runtime/vm': '*'
+ '@types/node': ^18.0.0 || >=20.0.0
+ '@vitest/browser': 2.1.9
+ '@vitest/ui': 2.1.9
+ happy-dom: '*'
+ jsdom: '*'
+ peerDependenciesMeta:
+ '@edge-runtime/vm':
+ optional: true
+ '@types/node':
+ optional: true
+ '@vitest/browser':
+ optional: true
+ '@vitest/ui':
+ optional: true
+ happy-dom:
+ optional: true
+ jsdom:
+ optional: true
+
+ why-is-node-running@2.3.0:
+ resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
+ engines: {node: '>=8'}
+ hasBin: true
+
snapshots:
'@esbuild/aix-ppc64@0.21.5':
@@ -449,6 +588,8 @@ snapshots:
'@esbuild/win32-x64@0.21.5':
optional: true
+ '@jridgewell/sourcemap-codec@1.5.5': {}
+
'@rollup/rollup-android-arm-eabi@4.60.4':
optional: true
@@ -526,12 +667,74 @@ snapshots:
'@types/estree@1.0.8': {}
+ '@vitest/expect@2.1.9':
+ dependencies:
+ '@vitest/spy': 2.1.9
+ '@vitest/utils': 2.1.9
+ chai: 5.3.3
+ tinyrainbow: 1.2.0
+
+ '@vitest/mocker@2.1.9(vite@5.4.21)':
+ dependencies:
+ '@vitest/spy': 2.1.9
+ estree-walker: 3.0.3
+ magic-string: 0.30.21
+ optionalDependencies:
+ vite: 5.4.21
+
+ '@vitest/pretty-format@2.1.9':
+ dependencies:
+ tinyrainbow: 1.2.0
+
+ '@vitest/runner@2.1.9':
+ dependencies:
+ '@vitest/utils': 2.1.9
+ pathe: 1.1.2
+
+ '@vitest/snapshot@2.1.9':
+ dependencies:
+ '@vitest/pretty-format': 2.1.9
+ magic-string: 0.30.21
+ pathe: 1.1.2
+
+ '@vitest/spy@2.1.9':
+ dependencies:
+ tinyspy: 3.0.2
+
+ '@vitest/utils@2.1.9':
+ dependencies:
+ '@vitest/pretty-format': 2.1.9
+ loupe: 3.2.1
+ tinyrainbow: 1.2.0
+
'@xterm/addon-fit@0.10.0(@xterm/xterm@5.5.0)':
dependencies:
'@xterm/xterm': 5.5.0
'@xterm/xterm@5.5.0': {}
+ assertion-error@2.0.1: {}
+
+ cac@6.7.14: {}
+
+ chai@5.3.3:
+ dependencies:
+ assertion-error: 2.0.1
+ check-error: 2.1.3
+ deep-eql: 5.0.2
+ loupe: 3.2.1
+ pathval: 2.0.1
+
+ check-error@2.1.3: {}
+
+ debug@4.4.3:
+ dependencies:
+ ms: 2.1.3
+
+ deep-eql@5.0.2: {}
+
+ es-module-lexer@1.7.0: {}
+
esbuild@0.21.5:
optionalDependencies:
'@esbuild/aix-ppc64': 0.21.5
@@ -558,11 +761,29 @@ snapshots:
'@esbuild/win32-ia32': 0.21.5
'@esbuild/win32-x64': 0.21.5
+ estree-walker@3.0.3:
+ dependencies:
+ '@types/estree': 1.0.8
+
+ expect-type@1.4.0: {}
+
fsevents@2.3.3:
optional: true
+ loupe@3.2.1: {}
+
+ magic-string@0.30.21:
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.5.5
+
+ ms@2.1.3: {}
+
nanoid@3.3.12: {}
+ pathe@1.1.2: {}
+
+ pathval@2.0.1: {}
+
picocolors@1.1.1: {}
postcss@8.5.15:
@@ -602,10 +823,44 @@ snapshots:
'@rollup/rollup-win32-x64-msvc': 4.60.4
fsevents: 2.3.3
+ siginfo@2.0.0: {}
+
source-map-js@1.2.1: {}
+ stackback@0.0.2: {}
+
+ std-env@3.10.0: {}
+
+ tinybench@2.9.0: {}
+
+ tinyexec@0.3.2: {}
+
+ tinypool@1.1.1: {}
+
+ tinyrainbow@1.2.0: {}
+
+ tinyspy@3.0.2: {}
+
typescript@5.9.3: {}
+ vite-node@2.1.9:
+ dependencies:
+ cac: 6.7.14
+ debug: 4.4.3
+ es-module-lexer: 1.7.0
+ pathe: 1.1.2
+ vite: 5.4.21
+ transitivePeerDependencies:
+ - '@types/node'
+ - less
+ - lightningcss
+ - sass
+ - sass-embedded
+ - stylus
+ - sugarss
+ - supports-color
+ - terser
+
vite@5.4.21:
dependencies:
esbuild: 0.21.5
@@ -613,3 +868,41 @@ snapshots:
rollup: 4.60.4
optionalDependencies:
fsevents: 2.3.3
+
+ vitest@2.1.9:
+ dependencies:
+ '@vitest/expect': 2.1.9
+ '@vitest/mocker': 2.1.9(vite@5.4.21)
+ '@vitest/pretty-format': 2.1.9
+ '@vitest/runner': 2.1.9
+ '@vitest/snapshot': 2.1.9
+ '@vitest/spy': 2.1.9
+ '@vitest/utils': 2.1.9
+ chai: 5.3.3
+ debug: 4.4.3
+ expect-type: 1.4.0
+ magic-string: 0.30.21
+ pathe: 1.1.2
+ std-env: 3.10.0
+ tinybench: 2.9.0
+ tinyexec: 0.3.2
+ tinypool: 1.1.1
+ tinyrainbow: 1.2.0
+ vite: 5.4.21
+ vite-node: 2.1.9
+ why-is-node-running: 2.3.0
+ transitivePeerDependencies:
+ - less
+ - lightningcss
+ - msw
+ - sass
+ - sass-embedded
+ - stylus
+ - sugarss
+ - supports-color
+ - terser
+
+ why-is-node-running@2.3.0:
+ dependencies:
+ siginfo: 2.0.0
+ stackback: 0.0.2
diff --git a/web/src/attach.ts b/web/src/attach.ts
index 3880309..d9aed6a 100644
--- a/web/src/attach.ts
+++ b/web/src/attach.ts
@@ -3,6 +3,8 @@ import { Terminal } from "@xterm/xterm";
import "@xterm/xterm/css/xterm.css";
import { attachWsUrl } from "./routes.js";
+import { escapeHtml } from "./html.js";
+import { applyModsToChar, cursorSeq } from "./keys.js";
import type { ClientControl, Uuid } from "./types.js";
/**
@@ -44,14 +46,6 @@ export interface AttachInfo {
command: string;
}
-function esc(s: string): string {
- return s
- .replace(/&/g, "&")
- .replace(//g, ">")
- .replace(/"/g, """);
-}
-
export function createAttachConnection(
sessionId: Uuid,
opts: AttachOptions = {},
@@ -62,16 +56,16 @@ export function createAttachConnection(
// Useful identity over a bare UUID: name · command. The full id stays in the
// tooltip for when you really need it.
const titleHtml = info
- ? `${esc(info.name)}` +
+ ? `${escapeHtml(info.name)}` +
(info.command && info.command !== info.name
- ? `${esc(info.command)}`
+ ? `${escapeHtml(info.command)}`
: "")
- : `session ${esc(sessionId.slice(0, 8))}`;
+ : `session ${escapeHtml(sessionId.slice(0, 8))}`;
const titleAttr = info ? `${info.name} · ${info.command} (${sessionId})` : sessionId;
el.innerHTML = `
@@ -422,31 +416,6 @@ const KEY_DEFS: KeyDef[] = [
* Fold a single character into its control code: letters and `@[\]^_` map to
* 0x00–0x1f, space → NUL, `?` → DEL. Anything else is returned unchanged.
*/
-function toCtrl(ch: string): string {
- if (ch.length !== 1) return ch;
- const upper = ch.toUpperCase().codePointAt(0) ?? 0;
- if (upper >= 0x40 && upper <= 0x5f) return String.fromCodePoint(upper & 0x1f);
- if (ch === " ") return "\x00";
- if (ch === "?") return "\x7f";
- return ch;
-}
-
-/** Apply pending modifiers to a single typed character: Ctrl → control code,
- * Alt → ESC prefix (xterm meta). Both compose (Ctrl+Alt = ESC + control). */
-function applyModsToChar(ch: string, ctrl: boolean, alt: boolean): string {
- let out = ctrl ? toCtrl(ch) : ch;
- if (alt) out = "\x1b" + out;
- return out;
-}
-
-/** Cursor/nav-key sequence, promoted to the modified CSI form when Ctrl/Alt are
- * pending — `\x1b[1;`, n = 1 + alt(2) + ctrl(4) (xterm convention). */
-function cursorSeq(final: string, ctrl: boolean, alt: boolean): string {
- if (!ctrl && !alt) return `\x1b[${final}`;
- const n = 1 + (alt ? 2 : 0) + (ctrl ? 4 : 0);
- return `\x1b[1;${n}${final}`;
-}
-
interface KeyBar {
readonly el: HTMLElement;
/** Fold pending modifiers into typed input (soft keyboard), consuming them. */
diff --git a/web/src/catalog.ts b/web/src/catalog.ts
index fc62910..9475ca3 100644
--- a/web/src/catalog.ts
+++ b/web/src/catalog.ts
@@ -1,92 +1,16 @@
+import { escapeHtml } from "./html.js";
import { getDaemonInfo, killSession } from "./api.js";
import { HUD_TOGGLE_EVENT, isHudEnabled, toggleHud } from "./debug.js";
import { CatalogStore } from "./events.js";
import type { Session } from "./types.js";
-import { abbreviateCwd, abbreviateFolderName, sessionCwd, sessionName } from "./types.js";
+import { abbreviateFolderName, sessionName } from "./types.js";
+import type { TreeNode } from "./tree.js";
+import { buildTree } from "./tree.js";
import { openSpawnModal } from "./spawn.js";
import { createThemeToggle } from "./theme.js";
-interface TreeFolder {
- kind: "folder";
- name: string;
- path: string;
- children: TreeNode[];
-}
-
-interface TreeLeaf {
- kind: "leaf";
- session: Session;
-}
-
-type TreeNode = TreeFolder | TreeLeaf;
-
const collapsed = new Set();
-function compareNodes(a: TreeNode, b: TreeNode): number {
- if (a.kind !== b.kind) return a.kind === "folder" ? -1 : 1;
- const an = a.kind === "folder" ? a.name : sessionName(a.session);
- const bn = b.kind === "folder" ? b.name : sessionName(b.session);
- return an.localeCompare(bn);
-}
-
-function buildTree(sessions: Session[], home: string | undefined, filter: string): TreeNode {
- const root: TreeFolder = { kind: "folder", name: "", path: "", children: [] };
- const lc = filter.trim().toLowerCase();
-
- for (const s of sessions) {
- const cwd = sessionCwd(s);
- const dir = cwd ? abbreviateCwd(cwd, home) : "";
- if (lc) {
- const haystack = `${sessionName(s)} ${dir}`.toLowerCase();
- if (!haystack.includes(lc)) continue;
- }
- const parts = dir ? dir.split("/").filter(Boolean) : [];
- let cursor = root;
- for (const part of parts) {
- const path = cursor.path ? `${cursor.path}/${part}` : part;
- let next = cursor.children.find(
- (n): n is TreeFolder => n.kind === "folder" && n.name === part,
- );
- if (!next) {
- next = { kind: "folder", name: part, path, children: [] };
- cursor.children.push(next);
- }
- cursor = next;
- }
- cursor.children.push({ kind: "leaf", session: s });
- }
-
- const sortRec = (n: TreeFolder) => {
- n.children.sort(compareNodes);
- for (const c of n.children) if (c.kind === "folder") sortRec(c);
- };
- sortRec(root);
- // Collapse linear directory chains so `~/git.naps.pt/naps62/maestro` reads as
- // one compact folder rather than four nested ones.
- root.children = root.children.map((c) => (c.kind === "folder" ? mergeLinear(c) : c));
- return root;
-}
-
-/** Fold a folder with a single folder-child into one node (`a` + `b` → `a/b`),
- * repeating down the chain, then recurse into the surviving branches. The merged
- * node keeps the deepest path so collapse/expand state stays stable as it grows. */
-function mergeLinear(folder: TreeFolder): TreeFolder {
- let cur = folder;
- while (cur.children.length === 1 && cur.children[0].kind === "folder") {
- const child = cur.children[0];
- cur = {
- kind: "folder",
- name: `${cur.name}/${child.name}`,
- path: child.path,
- children: child.children,
- };
- }
- cur.children = cur.children.map((c) =>
- c.kind === "folder" ? mergeLinear(c) : c,
- );
- return cur;
-}
-
function renderNode(node: TreeNode, depth: number): HTMLElement {
if (node.kind === "folder") {
const wrap = document.createElement("div");
@@ -163,14 +87,6 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
return row;
}
-function escapeHtml(s: string): string {
- return s
- .replace(/&/g, "&")
- .replace(//g, ">")
- .replace(/"/g, """);
-}
-
function renderStatusbar(sessions: Session[]): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "statusbar";
diff --git a/web/src/dirAutocomplete.ts b/web/src/dirAutocomplete.ts
index 5e46b16..3bf9647 100644
--- a/web/src/dirAutocomplete.ts
+++ b/web/src/dirAutocomplete.ts
@@ -12,6 +12,7 @@
//
// Failures (offline daemon, permission denied) degrade to no suggestions.
+import { longestCommonPrefix, splitPath } from "./paths.js";
import { listDir } from "./api.js";
export interface DirAutocompleteHandle {
@@ -26,26 +27,6 @@ const FETCH_DEBOUNCE_MS = 90;
/** Split a path value into the directory to list and the trailing fragment
* being typed within it. The directory keeps its trailing `/` (or is empty,
* meaning "the daemon's home dir"). */
-function splitPath(value: string): { dir: string; prefix: string } {
- const slash = value.lastIndexOf("/");
- if (slash === -1) return { dir: "", prefix: value };
- return { dir: value.slice(0, slash + 1), prefix: value.slice(slash + 1) };
-}
-
-/** Longest common prefix of a list of strings (case-sensitive, preserving the
- * actual on-disk casing). */
-function longestCommonPrefix(items: string[]): string {
- if (items.length === 0) return "";
- let lcp = items[0];
- for (const s of items.slice(1)) {
- let i = 0;
- while (i < lcp.length && i < s.length && lcp[i] === s[i]) i++;
- lcp = lcp.slice(0, i);
- if (lcp === "") break;
- }
- return lcp;
-}
-
export function attachDirAutocomplete(input: HTMLInputElement): DirAutocompleteHandle {
const dropdown = document.createElement("ul");
dropdown.className = "dir-ac";
diff --git a/web/src/events.test.ts b/web/src/events.test.ts
new file mode 100644
index 0000000..ab864ca
--- /dev/null
+++ b/web/src/events.test.ts
@@ -0,0 +1,162 @@
+import { describe, expect, it, vi } from "vitest";
+
+import {
+ CatalogStore,
+ INITIAL_BACKOFF_MS,
+ MAX_BACKOFF_MS,
+ type CatalogState,
+ type Scheduler,
+} from "./events.js";
+import type { CatalogEvent, Session } from "./types.js";
+
+function session(id: string): Session {
+ return {
+ id,
+ cwd: "/home/n",
+ command: "bash",
+ env: {},
+ metadata: {},
+ status: "Running",
+ created_at: "2026-08-22T00:00:00Z",
+ } as Session;
+}
+
+/** Just enough of a WebSocket for the store to drive, plus a way to push frames. */
+class FakeSocket {
+ onmessage: ((ev: { data: string }) => void) | null = null;
+ onopen: (() => void) | null = null;
+ onclose: (() => void) | null = null;
+ onerror: (() => void) | null = null;
+ closed = false;
+
+ close(): void {
+ this.closed = true;
+ }
+
+ open(): void {
+ this.onopen?.();
+ }
+
+ emit(event: CatalogEvent): void {
+ this.onmessage?.({ data: JSON.stringify(event) });
+ }
+
+ drop(): void {
+ this.onclose?.();
+ }
+}
+
+/** Scheduler that records delays instead of waiting them out. */
+function fakeScheduler(): Scheduler & { delays: number[]; runNext(): void } {
+ const queued: Array<() => void> = [];
+ return {
+ delays: [] as number[],
+ setTimer(fn, ms) {
+ this.delays.push(ms);
+ queued.push(fn);
+ return queued.length;
+ },
+ clearTimer() {},
+ runNext() {
+ queued.shift()?.();
+ },
+ };
+}
+
+function storeWith(sockets: FakeSocket[], refetch?: () => Promise) {
+ let i = 0;
+ const scheduler = fakeScheduler();
+ const store = new CatalogStore({
+ socket: () => sockets[i++] as unknown as WebSocket,
+ scheduler,
+ refetch,
+ });
+ return { store, scheduler };
+}
+
+describe("CatalogStore reducer", () => {
+ it("replaces state on Snapshot and applies deltas", () => {
+ const ws = new FakeSocket();
+ const { store } = storeWith([ws]);
+ store.start();
+
+ ws.emit({ type: "Snapshot", sessions: [session("a")] } as CatalogEvent);
+ expect([...store.snapshot().sessions.keys()]).toEqual(["a"]);
+
+ ws.emit({ type: "SessionAdded", session: session("b") } as CatalogEvent);
+ expect([...store.snapshot().sessions.keys()].sort()).toEqual(["a", "b"]);
+
+ ws.emit({ type: "SessionRemoved", id: "a" } as CatalogEvent);
+ expect([...store.snapshot().sessions.keys()]).toEqual(["b"]);
+ });
+
+ it("ignores a frame that is not JSON", () => {
+ const ws = new FakeSocket();
+ const { store } = storeWith([ws]);
+ store.start();
+ ws.emit({ type: "Snapshot", sessions: [session("a")] } as CatalogEvent);
+
+ ws.onmessage?.({ data: "not json" });
+
+ expect([...store.snapshot().sessions.keys()]).toEqual(["a"]);
+ });
+
+ it("refetches over REST when the server reports dropped frames", async () => {
+ const ws = new FakeSocket();
+ const refetch = vi.fn(async () => [session("fresh")]);
+ const { store } = storeWith([ws], refetch);
+ store.start();
+ ws.emit({ type: "Snapshot", sessions: [session("stale")] } as CatalogEvent);
+
+ ws.emit({ type: "Lagged", skipped: 4 } as CatalogEvent);
+ await vi.waitFor(() => expect(refetch).toHaveBeenCalledOnce());
+
+ expect([...store.snapshot().sessions.keys()]).toEqual(["fresh"]);
+ });
+
+ it("hands each subscriber a copy it can diff against the next one", () => {
+ const ws = new FakeSocket();
+ const { store } = storeWith([ws]);
+ const seen: CatalogState[] = [];
+ store.subscribe((s) => seen.push(s));
+ store.start();
+
+ ws.emit({ type: "Snapshot", sessions: [session("a")] } as CatalogEvent);
+ ws.emit({ type: "SessionAdded", session: session("b") } as CatalogEvent);
+
+ const [, afterSnapshot, afterAdd] = seen;
+ expect(afterSnapshot.sessions).not.toBe(afterAdd.sessions);
+ expect(afterSnapshot.sessions.size).toBe(1);
+ expect(afterAdd.sessions.size).toBe(2);
+ });
+});
+
+describe("CatalogStore reconnect", () => {
+ it("doubles the delay per attempt and caps it", () => {
+ const sockets = Array.from({ length: 8 }, () => new FakeSocket());
+ const { store, scheduler } = storeWith(sockets);
+ store.start();
+
+ for (let i = 0; i < 7; i++) {
+ sockets[i].drop();
+ scheduler.runNext();
+ }
+
+ expect(scheduler.delays[0]).toBe(INITIAL_BACKOFF_MS);
+ expect(scheduler.delays[1]).toBe(INITIAL_BACKOFF_MS * 2);
+ expect(scheduler.delays.at(-1)).toBe(MAX_BACKOFF_MS);
+ });
+
+ it("resets the delay once a connection opens", () => {
+ const sockets = [new FakeSocket(), new FakeSocket(), new FakeSocket()];
+ const { store, scheduler } = storeWith(sockets);
+ store.start();
+
+ sockets[0].drop();
+ scheduler.runNext();
+ expect(store.backoff()).toBe(INITIAL_BACKOFF_MS * 2);
+
+ sockets[1].open();
+ expect(store.backoff()).toBe(INITIAL_BACKOFF_MS);
+ });
+});
diff --git a/web/src/events.ts b/web/src/events.ts
index ea24e7f..b982bd7 100644
--- a/web/src/events.ts
+++ b/web/src/events.ts
@@ -7,20 +7,60 @@ export interface CatalogState {
export type CatalogListener = (state: CatalogState) => void;
+/** Opens the events socket. Injectable so the reducer and the backoff schedule
+ * can be driven without a server. */
+export type SocketFactory = () => WebSocket;
+
+/** Schedules a reconnect attempt; returns a handle `clearTimer` understands. */
+export interface Scheduler {
+ setTimer(fn: () => void, ms: number): number;
+ clearTimer(handle: number): void;
+}
+
+const browserScheduler: Scheduler = {
+ setTimer: (fn, ms) => window.setTimeout(fn, ms),
+ clearTimer: (h) => window.clearTimeout(h),
+};
+
+/** Initial reconnect delay, doubling to `MAX_BACKOFF_MS`. */
+export const INITIAL_BACKOFF_MS = 500;
+export const MAX_BACKOFF_MS = 10_000;
+
+export interface CatalogStoreOptions {
+ socket?: SocketFactory;
+ scheduler?: Scheduler;
+ /** Full-state refetch used when the server reports it dropped frames. */
+ refetch?: () => Promise;
+}
+
export class CatalogStore {
private state: CatalogState = { sessions: new Map() };
private listeners = new Set();
private ws: WebSocket | null = null;
private reconnectTimer: number | null = null;
- private backoffMs = 500;
+ private backoffMs = INITIAL_BACKOFF_MS;
+ private readonly socket: SocketFactory;
+ private readonly scheduler: Scheduler;
+ private readonly refetch: () => Promise;
+
+ constructor(opts: CatalogStoreOptions = {}) {
+ this.socket = opts.socket ?? (() => new WebSocket(eventsWsUrl()));
+ this.scheduler = opts.scheduler ?? browserScheduler;
+ this.refetch = opts.refetch ?? listSessions;
+ }
start(): void {
this.connect();
}
+ /** Current reconnect delay, for tests asserting the backoff schedule. */
+ backoff(): number {
+ return this.backoffMs;
+ }
+
stop(): void {
if (this.reconnectTimer !== null) {
- clearTimeout(this.reconnectTimer);
+ this.scheduler.clearTimer(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.ws) {
@@ -36,12 +76,15 @@ export class CatalogStore {
return () => this.listeners.delete(listener);
}
+ /** A copy, so a listener can diff it against the previous one. Returning the
+ * live object meant in-place mutation was invisible and every event forced a
+ * full re-render. */
snapshot(): CatalogState {
- return this.state;
+ return { sessions: new Map(this.state.sessions) };
}
private connect(): void {
- const ws = new WebSocket(eventsWsUrl());
+ const ws = this.socket();
this.ws = ws;
ws.onmessage = (ev) => {
@@ -55,7 +98,7 @@ export class CatalogStore {
};
ws.onopen = () => {
- this.backoffMs = 500;
+ this.backoffMs = INITIAL_BACKOFF_MS;
};
ws.onclose = () => {
@@ -74,10 +117,10 @@ export class CatalogStore {
private scheduleReconnect(): void {
if (this.reconnectTimer !== null) return;
- const delay = Math.min(this.backoffMs, 10_000);
- this.reconnectTimer = window.setTimeout(() => {
+ const delay = Math.min(this.backoffMs, MAX_BACKOFF_MS);
+ this.reconnectTimer = this.scheduler.setTimer(() => {
this.reconnectTimer = null;
- this.backoffMs = Math.min(this.backoffMs * 2, 10_000);
+ this.backoffMs = Math.min(this.backoffMs * 2, MAX_BACKOFF_MS);
this.connect();
}, delay);
}
@@ -108,7 +151,7 @@ export class CatalogStore {
private async resync(): Promise {
try {
- const sessions = await listSessions();
+ const sessions = await this.refetch();
this.state = {
sessions: new Map(sessions.map((s) => [s.id, s])),
};
@@ -119,6 +162,7 @@ export class CatalogStore {
}
private emit(): void {
- for (const fn of this.listeners) fn(this.state);
+ const snap = this.snapshot();
+ for (const fn of this.listeners) fn(snap);
}
}
diff --git a/web/src/html.ts b/web/src/html.ts
new file mode 100644
index 0000000..93f5646
--- /dev/null
+++ b/web/src/html.ts
@@ -0,0 +1,10 @@
+// One HTML escaper. Previously written four times, in catalog.ts, spawn.ts,
+// types.ts and attach.ts.
+
+export function escapeHtml(s: string): string {
+ return s
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """);
+}
diff --git a/web/src/keys.test.ts b/web/src/keys.test.ts
new file mode 100644
index 0000000..b4915e6
--- /dev/null
+++ b/web/src/keys.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, it } from "vitest";
+
+import { applyModsToChar, cursorSeq, toCtrl } from "./keys.js";
+
+describe("toCtrl", () => {
+ it("maps letters to their control code, case-insensitively", () => {
+ expect(toCtrl("a")).toBe("\x01");
+ expect(toCtrl("A")).toBe("\x01");
+ expect(toCtrl("c")).toBe("\x03");
+ expect(toCtrl("[")).toBe("\x1b");
+ });
+
+ it("handles the two specials outside the @-_ range", () => {
+ expect(toCtrl(" ")).toBe("\x00");
+ expect(toCtrl("?")).toBe("\x7f");
+ });
+
+ it("passes through anything it has no mapping for", () => {
+ expect(toCtrl("1")).toBe("1");
+ expect(toCtrl("é")).toBe("é");
+ expect(toCtrl("abc")).toBe("abc");
+ expect(toCtrl("")).toBe("");
+ });
+});
+
+describe("applyModsToChar", () => {
+ it("composes ctrl and alt, with ESC outermost", () => {
+ expect(applyModsToChar("a", false, false)).toBe("a");
+ expect(applyModsToChar("a", true, false)).toBe("\x01");
+ expect(applyModsToChar("a", false, true)).toBe("\x1ba");
+ expect(applyModsToChar("a", true, true)).toBe("\x1b\x01");
+ });
+});
+
+describe("cursorSeq", () => {
+ it("uses the bare CSI form when unmodified", () => {
+ expect(cursorSeq("A", false, false)).toBe("\x1b[A");
+ });
+
+ it("uses the xterm modifier encoding n = 1 + alt(2) + ctrl(4)", () => {
+ expect(cursorSeq("A", false, true)).toBe("\x1b[1;3A");
+ expect(cursorSeq("A", true, false)).toBe("\x1b[1;5A");
+ expect(cursorSeq("A", true, true)).toBe("\x1b[1;7A");
+ });
+});
diff --git a/web/src/keys.ts b/web/src/keys.ts
new file mode 100644
index 0000000..6ef6c6e
--- /dev/null
+++ b/web/src/keys.ts
@@ -0,0 +1,28 @@
+// Terminal key encoding: control characters, meta prefixes, and cursor escape
+// sequences. Table-driven and easy to get subtly wrong, so it lives apart from
+// the DOM and is tested directly.
+
+export function toCtrl(ch: string): string {
+ if (ch.length !== 1) return ch;
+ const upper = ch.toUpperCase().codePointAt(0) ?? 0;
+ if (upper >= 0x40 && upper <= 0x5f) return String.fromCodePoint(upper & 0x1f);
+ if (ch === " ") return "\x00";
+ if (ch === "?") return "\x7f";
+ return ch;
+}
+
+/** Apply pending modifiers to a single typed character: Ctrl → control code,
+ * Alt → ESC prefix (xterm meta). Both compose (Ctrl+Alt = ESC + control). */
+export function applyModsToChar(ch: string, ctrl: boolean, alt: boolean): string {
+ let out = ctrl ? toCtrl(ch) : ch;
+ if (alt) out = "\x1b" + out;
+ return out;
+}
+
+/** Cursor/nav-key sequence, promoted to the modified CSI form when Ctrl/Alt are
+ * pending — `\x1b[1;`, n = 1 + alt(2) + ctrl(4) (xterm convention). */
+export function cursorSeq(final: string, ctrl: boolean, alt: boolean): string {
+ if (!ctrl && !alt) return `\x1b[${final}`;
+ const n = 1 + (alt ? 2 : 0) + (ctrl ? 4 : 0);
+ return `\x1b[1;${n}${final}`;
+}
diff --git a/web/src/layout.test.ts b/web/src/layout.test.ts
new file mode 100644
index 0000000..2ae13c8
--- /dev/null
+++ b/web/src/layout.test.ts
@@ -0,0 +1,89 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ emptyLayout,
+ leaves,
+ locateSession,
+ openSessions,
+ removeLeaf,
+ splitLeaf,
+ type Tab,
+} from "./layout.js";
+
+function tabWithOneLeaf(sessionId: string | null = null): Tab {
+ const layout = emptyLayout();
+ const tab = layout.tabs[0];
+ splitLeaf(tab, "", sessionId, "row", true);
+ return tab;
+}
+
+describe("splitLeaf", () => {
+ it("seeds an empty tab with a single leaf", () => {
+ const tab = tabWithOneLeaf("s1");
+ expect(leaves(tab.root)).toHaveLength(1);
+ expect(leaves(tab.root)[0].sessionId).toBe("s1");
+ });
+
+ it("splits a leaf in two and returns the new id", () => {
+ const tab = tabWithOneLeaf("s1");
+ const target = leaves(tab.root)[0].id;
+
+ const fresh = splitLeaf(tab, target, "s2", "row", true);
+
+ const ids = leaves(tab.root).map((l) => l.id);
+ expect(ids).toHaveLength(2);
+ expect(ids).toContain(fresh);
+ });
+
+ it("honours the side the caller asked for", () => {
+ const tab = tabWithOneLeaf("s1");
+ const target = leaves(tab.root)[0].id;
+ const fresh = splitLeaf(tab, target, "s2", "row", false);
+
+ expect(leaves(tab.root)[0].id).toBe(fresh);
+ });
+});
+
+describe("removeLeaf", () => {
+ it("collapses the parent split into the surviving sibling", () => {
+ const tab = tabWithOneLeaf("s1");
+ const first = leaves(tab.root)[0].id;
+ splitLeaf(tab, first, "s2", "row", true);
+
+ removeLeaf(tab, first);
+
+ const remaining = leaves(tab.root);
+ expect(remaining).toHaveLength(1);
+ expect(remaining[0].sessionId).toBe("s2");
+ expect(tab.root?.kind).toBe("leaf");
+ });
+
+ it("empties the tab when the last leaf goes", () => {
+ const tab = tabWithOneLeaf("s1");
+ removeLeaf(tab, leaves(tab.root)[0].id);
+ expect(tab.root).toBeNull();
+ });
+});
+
+describe("locateSession / openSessions", () => {
+ it("finds the tab and leaf showing a session", () => {
+ const layout = emptyLayout();
+ const tab = layout.tabs[0];
+ splitLeaf(tab, "", "s1", "row", true);
+
+ const hit = locateSession(layout, "s1");
+ expect(hit?.tab.id).toBe(tab.id);
+ expect(hit?.leaf.sessionId).toBe("s1");
+ expect(locateSession(layout, "nope")).toBeNull();
+ });
+
+ it("reports every open session once", () => {
+ const layout = emptyLayout();
+ const tab = layout.tabs[0];
+ splitLeaf(tab, "", "s1", "row", true);
+ const first = leaves(tab.root)[0].id;
+ splitLeaf(tab, first, "s2", "row", true);
+
+ expect(openSessions(layout)).toEqual(new Set(["s1", "s2"]));
+ });
+});
diff --git a/web/src/paths.test.ts b/web/src/paths.test.ts
new file mode 100644
index 0000000..7f63496
--- /dev/null
+++ b/web/src/paths.test.ts
@@ -0,0 +1,31 @@
+import { describe, expect, it } from "vitest";
+
+import { longestCommonPrefix, splitPath } from "./paths.js";
+
+describe("splitPath", () => {
+ it("splits on the last slash, keeping it with the directory", () => {
+ expect(splitPath("/home/n/wo")).toEqual({ dir: "/home/n/", prefix: "wo" });
+ expect(splitPath("/home/n/")).toEqual({ dir: "/home/n/", prefix: "" });
+ });
+
+ it("treats a slash-free value as all prefix", () => {
+ expect(splitPath("work")).toEqual({ dir: "", prefix: "work" });
+ expect(splitPath("")).toEqual({ dir: "", prefix: "" });
+ });
+});
+
+describe("longestCommonPrefix", () => {
+ it("returns the shared head", () => {
+ expect(longestCommonPrefix(["maestro", "maestro-web"])).toBe("maestro");
+ expect(longestCommonPrefix(["abc"])).toBe("abc");
+ });
+
+ it("is empty when nothing is shared", () => {
+ expect(longestCommonPrefix(["abc", "xyz"])).toBe("");
+ expect(longestCommonPrefix([])).toBe("");
+ });
+
+ it("preserves on-disk casing rather than folding it", () => {
+ expect(longestCommonPrefix(["Alpha", "alpha"])).toBe("");
+ });
+});
diff --git a/web/src/paths.ts b/web/src/paths.ts
new file mode 100644
index 0000000..6551906
--- /dev/null
+++ b/web/src/paths.ts
@@ -0,0 +1,21 @@
+// Path splitting and completion arithmetic for the working-directory input.
+
+export function splitPath(value: string): { dir: string; prefix: string } {
+ const slash = value.lastIndexOf("/");
+ if (slash === -1) return { dir: "", prefix: value };
+ return { dir: value.slice(0, slash + 1), prefix: value.slice(slash + 1) };
+}
+
+/** Longest common prefix of a list of strings (case-sensitive, preserving the
+ * actual on-disk casing). */
+export function longestCommonPrefix(items: string[]): string {
+ if (items.length === 0) return "";
+ let lcp = items[0];
+ for (const s of items.slice(1)) {
+ let i = 0;
+ while (i < lcp.length && i < s.length && lcp[i] === s[i]) i++;
+ lcp = lcp.slice(0, i);
+ if (lcp === "") break;
+ }
+ return lcp;
+}
diff --git a/web/src/persistence.test.ts b/web/src/persistence.test.ts
new file mode 100644
index 0000000..ffc00e5
--- /dev/null
+++ b/web/src/persistence.test.ts
@@ -0,0 +1,64 @@
+import { describe, expect, it } from "vitest";
+
+import { hydrate } from "./persistence.js";
+import { leaves } from "./layout.js";
+
+describe("hydrate", () => {
+ it("rejects anything that is not a tab-bearing object", () => {
+ expect(hydrate(null)).toBeNull();
+ expect(hydrate("nope")).toBeNull();
+ expect(hydrate({})).toBeNull();
+ expect(hydrate({ tabs: [] })).toBeNull();
+ expect(hydrate({ tabs: "no" })).toBeNull();
+ });
+
+ it("rebuilds tabs and re-issues ids rather than trusting stored ones", () => {
+ const layout = hydrate({
+ tabs: [{ id: "old", title: "Work", root: { kind: "leaf", id: "l1", sessionId: "s1" } }],
+ activeTabId: "old",
+ });
+
+ expect(layout).not.toBeNull();
+ expect(layout!.tabs).toHaveLength(1);
+ expect(layout!.tabs[0].title).toBe("Work");
+ expect(layout!.tabs[0].id).not.toBe("old");
+ expect(layout!.activeTabId).toBe(layout!.tabs[0].id);
+ expect(leaves(layout!.tabs[0].root)[0].sessionId).toBe("s1");
+ });
+
+ it("carries the focused leaf across the id reissue", () => {
+ const layout = hydrate({
+ tabs: [
+ {
+ id: "t",
+ root: {
+ kind: "split",
+ dir: "row",
+ a: { kind: "leaf", id: "l1", sessionId: "s1" },
+ b: { kind: "leaf", id: "l2", sessionId: "s2" },
+ },
+ focusedLeafId: "l2",
+ },
+ ],
+ activeTabId: "t",
+ });
+
+ const tab = layout!.tabs[0];
+ const focused = leaves(tab.root).find((l) => l.id === tab.focusedLeafId);
+ expect(focused?.sessionId).toBe("s2");
+ });
+
+ it("falls back to a default title and the first tab as active", () => {
+ const layout = hydrate({ tabs: [{ id: "a" }, { id: "b" }], activeTabId: "missing" });
+ expect(layout!.tabs[0].title).toBe("Tab 1");
+ expect(layout!.activeTabId).toBe(layout!.tabs[0].id);
+ });
+
+ it("drops a malformed split branch instead of failing the whole layout", () => {
+ const layout = hydrate({
+ tabs: [{ id: "t", root: { kind: "split", dir: "row", a: null, b: { kind: "leaf", id: "l", sessionId: "s" } } }],
+ activeTabId: "t",
+ });
+ expect(leaves(layout!.tabs[0].root).map((l) => l.sessionId)).toEqual(["s"]);
+ });
+});
diff --git a/web/src/persistence.ts b/web/src/persistence.ts
index fb80c37..4ac03b9 100644
--- a/web/src/persistence.ts
+++ b/web/src/persistence.ts
@@ -37,7 +37,7 @@ export function loadLayout(): Layout | null {
}
}
-function hydrate(raw: unknown): Layout | null {
+export function hydrate(raw: unknown): Layout | null {
if (!raw || typeof raw !== "object") return null;
const r = raw as { tabs?: unknown; activeTabId?: unknown };
if (!Array.isArray(r.tabs) || r.tabs.length === 0) return null;
diff --git a/web/src/spawn.ts b/web/src/spawn.ts
index ca1e675..a3c7040 100644
--- a/web/src/spawn.ts
+++ b/web/src/spawn.ts
@@ -1,3 +1,4 @@
+import { escapeHtml } from "./html.js";
import { createSession, getDaemonInfo, recentDirs, gitRoot } from "./api.js";
import { attachDirAutocomplete, type DirAutocompleteHandle } from "./dirAutocomplete.js";
import { generateName } from "./names.js";
@@ -425,11 +426,3 @@ export function openSpawnModal(prefill?: SpawnPrefill): void {
});
}
-function escapeHtml(s: string): string {
- return s
- .replace(/&/g, "&")
- .replace(//g, ">")
- .replace(/"/g, """);
-}
-
diff --git a/web/src/tree.test.ts b/web/src/tree.test.ts
new file mode 100644
index 0000000..a79aac2
--- /dev/null
+++ b/web/src/tree.test.ts
@@ -0,0 +1,97 @@
+import { describe, expect, it } from "vitest";
+
+import { buildTree, mergeLinear, type TreeFolder, type TreeNode } from "./tree.js";
+import type { Session } from "./types.js";
+
+function session(id: string, cwd: string, command = "bash"): Session {
+ return {
+ id,
+ cwd,
+ command,
+ env: {},
+ metadata: {},
+ status: "Running",
+ created_at: "2026-08-22T00:00:00Z",
+ } as Session;
+}
+
+/** Folder names at one level, in render order. */
+function names(node: TreeNode): string[] {
+ if (node.kind === "leaf") return [];
+ return node.children.map((c) => (c.kind === "folder" ? c.name : "leaf"));
+}
+
+describe("buildTree", () => {
+ it("groups sessions under their directory", () => {
+ const root = buildTree(
+ [session("a", "/home/n/work"), session("b", "/home/n/work")],
+ "/home/n",
+ "",
+ ) as TreeFolder;
+
+ expect(names(root)).toEqual(["~/work"]);
+ const folder = root.children[0] as TreeFolder;
+ expect(folder.children).toHaveLength(2);
+ });
+
+ it("collapses a linear chain into one folder", () => {
+ const root = buildTree([session("a", "/home/n/git/naps62/maestro")], "/home/n", "") as TreeFolder;
+ expect(names(root)).toEqual(["~/git/naps62/maestro"]);
+ });
+
+ it("stops collapsing where a directory branches", () => {
+ const root = buildTree(
+ [session("a", "/home/n/git/one"), session("b", "/home/n/git/two")],
+ "/home/n",
+ "",
+ ) as TreeFolder;
+
+ expect(names(root)).toEqual(["~/git"]);
+ const git = root.children[0] as TreeFolder;
+ expect(names(git).sort()).toEqual(["one", "two"]);
+ });
+
+ it("sorts folders before leaves, then by name", () => {
+ const root = buildTree(
+ [session("loose", ""), session("nested", "/home/n/work")],
+ "/home/n",
+ "",
+ ) as TreeFolder;
+
+ expect(root.children[0].kind).toBe("folder");
+ expect(root.children[1].kind).toBe("leaf");
+ });
+
+ it("filters on both session name and directory", () => {
+ const sessions = [session("a", "/home/n/alpha", "claude"), session("b", "/home/n/beta", "bash")];
+
+ const byDir = buildTree(sessions, "/home/n", "alpha") as TreeFolder;
+ expect(names(byDir)).toEqual(["~/alpha"]);
+
+ const byCommand = buildTree(sessions, "/home/n", "claude") as TreeFolder;
+ expect(names(byCommand)).toEqual(["~/alpha"]);
+
+ const noMatch = buildTree(sessions, "/home/n", "zzz") as TreeFolder;
+ expect(noMatch.children).toEqual([]);
+ });
+
+ it("ignores case and surrounding whitespace in the filter", () => {
+ const root = buildTree([session("a", "/home/n/Alpha")], "/home/n", " ALPHA ") as TreeFolder;
+ expect(root.children).toHaveLength(1);
+ });
+});
+
+describe("mergeLinear", () => {
+ it("keeps the deepest path so collapse state survives growth", () => {
+ const folder: TreeFolder = {
+ kind: "folder",
+ name: "a",
+ path: "a",
+ children: [{ kind: "folder", name: "b", path: "a/b", children: [] }],
+ };
+
+ const merged = mergeLinear(folder);
+ expect(merged.name).toBe("a/b");
+ expect(merged.path).toBe("a/b");
+ });
+});
diff --git a/web/src/tree.ts b/web/src/tree.ts
new file mode 100644
index 0000000..93e098b
--- /dev/null
+++ b/web/src/tree.ts
@@ -0,0 +1,84 @@
+// Directory grouping for the session catalog. Pure: no DOM, no module state,
+// so it can be tested directly.
+
+import type { Session } from "./types.js";
+import { abbreviateCwd, sessionCwd, sessionName } from "./types.js";
+
+export interface TreeFolder {
+ kind: "folder";
+ name: string;
+ path: string;
+ children: TreeNode[];
+}
+
+export interface TreeLeaf {
+ kind: "leaf";
+ session: Session;
+}
+
+export type TreeNode = TreeFolder | TreeLeaf;
+
+export function compareNodes(a: TreeNode, b: TreeNode): number {
+ if (a.kind !== b.kind) return a.kind === "folder" ? -1 : 1;
+ const an = a.kind === "folder" ? a.name : sessionName(a.session);
+ const bn = b.kind === "folder" ? b.name : sessionName(b.session);
+ return an.localeCompare(bn);
+}
+
+export function buildTree(sessions: Session[], home: string | undefined, filter: string): TreeNode {
+ const root: TreeFolder = { kind: "folder", name: "", path: "", children: [] };
+ const lc = filter.trim().toLowerCase();
+
+ for (const s of sessions) {
+ const cwd = sessionCwd(s);
+ const dir = cwd ? abbreviateCwd(cwd, home) : "";
+ if (lc) {
+ const haystack = `${sessionName(s)} ${dir}`.toLowerCase();
+ if (!haystack.includes(lc)) continue;
+ }
+ const parts = dir ? dir.split("/").filter(Boolean) : [];
+ let cursor = root;
+ for (const part of parts) {
+ const path = cursor.path ? `${cursor.path}/${part}` : part;
+ let next = cursor.children.find(
+ (n): n is TreeFolder => n.kind === "folder" && n.name === part,
+ );
+ if (!next) {
+ next = { kind: "folder", name: part, path, children: [] };
+ cursor.children.push(next);
+ }
+ cursor = next;
+ }
+ cursor.children.push({ kind: "leaf", session: s });
+ }
+
+ const sortRec = (n: TreeFolder) => {
+ n.children.sort(compareNodes);
+ for (const c of n.children) if (c.kind === "folder") sortRec(c);
+ };
+ sortRec(root);
+ // Collapse linear directory chains so `~/git.naps.pt/naps62/maestro` reads as
+ // one compact folder rather than four nested ones.
+ root.children = root.children.map((c) => (c.kind === "folder" ? mergeLinear(c) : c));
+ return root;
+}
+
+/** Fold a folder with a single folder-child into one node (`a` + `b` → `a/b`),
+ * repeating down the chain, then recurse into the surviving branches. The merged
+ * node keeps the deepest path so collapse/expand state stays stable as it grows. */
+export function mergeLinear(folder: TreeFolder): TreeFolder {
+ let cur = folder;
+ while (cur.children.length === 1 && cur.children[0].kind === "folder") {
+ const child = cur.children[0];
+ cur = {
+ kind: "folder",
+ name: `${cur.name}/${child.name}`,
+ path: child.path,
+ children: child.children,
+ };
+ }
+ cur.children = cur.children.map((c) =>
+ c.kind === "folder" ? mergeLinear(c) : c,
+ );
+ return cur;
+}
diff --git a/web/src/types.ts b/web/src/types.ts
index e41b7a1..2018dd4 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -1,3 +1,5 @@
+import { escapeHtml } from "./html.js";
+
// Mirror of `maestro-protocol` JSON shapes. Keep in sync with
// crates/maestro-protocol/src/lib.rs.
@@ -151,11 +153,3 @@ export function abbreviateFolderName(name: string): string {
.join("/");
return `${escapeHtml(stem)}/${escapeHtml(last)}`;
}
-
-function escapeHtml(s: string): string {
- return s
- .replace(/&/g, "&")
- .replace(//g, ">")
- .replace(/"/g, """);
-}