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 = `
← - ${titleHtml} + ${titleHtml} connecting…
@@ -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, """); -}