diff --git a/frontend/package.json b/frontend/package.json index 854240cea..e92fcd403 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -3,11 +3,13 @@ "private": true, "version": "0.0.0", "type": "module", + "packageManager": "pnpm@11.3.0", "scripts": { "dev": "vite", "build:dev": "tsc && vite build --minify false --mode development", "build": "tsc && vite build --mode production", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run" }, "dependencies": { "@base-ui/react": "^1.6.0", @@ -32,6 +34,7 @@ "@vitejs/plugin-react": "^6.0.0", "tailwindcss": "^4.3.2", "typescript": "^5.2.2", - "vite": "^8.0.5" + "vite": "^8.0.5", + "vitest": "^4.1.10" } } diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 0eada4c53..c870e2307 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -72,6 +72,9 @@ importers: vite: specifier: ^8.0.5 version: 8.1.3(@types/node@26.1.0)(jiti@2.7.0) + vitest: + specifier: ^4.1.10 + version: 4.1.10(@types/node@26.1.0)(vite@8.1.3(@types/node@26.1.0)(jiti@2.7.0)) packages: @@ -427,6 +430,9 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@tailwindcss/node@4.3.2': resolution: {integrity: sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==} @@ -527,6 +533,15 @@ packages: '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/node@26.1.0': resolution: {integrity: sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==} @@ -557,6 +572,35 @@ packages: babel-plugin-react-compiler: optional: true + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + '@wailsio/runtime@3.0.0-alpha.97': resolution: {integrity: sha512-alvEG6B0YqW7AcBcNaUWNMD9q8JWqx5AO6BPOZpijBeq4ZjhQxSom3jvvq8Z1mWSMKPEjSvlFirKIh9NLI3ajA==} @@ -598,6 +642,10 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + ast-types@0.16.1: resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} engines: {node: '>=4'} @@ -655,6 +703,10 @@ packages: caniuse-lite@1.0.30001802: resolution: {integrity: sha512-vmv8ub2xwTNmljSKf82mtCk5JH7hC+YgzLj3P5zotvA0tPQ9016tdNNOG8WRca1IxOnhSsivB+J0z5FeE5LOUw==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk@5.6.2: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} @@ -842,6 +894,9 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-module-lexer@2.3.0: + resolution: {integrity: sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==} + es-object-atoms@1.1.2: resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} @@ -858,6 +913,9 @@ packages: engines: {node: '>=4'} hasBin: true + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + etag@1.8.1: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} @@ -878,6 +936,10 @@ packages: resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} engines: {node: ^18.19.0 || >=20.5.0} + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + express-rate-limit@8.5.2: resolution: {integrity: sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==} engines: {node: '>= 16'} @@ -1359,6 +1421,10 @@ packages: resolution: {integrity: sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A==} engines: {node: '>= 10'} + obug@2.1.3: + resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} + engines: {node: '>=12.20.0'} + on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} @@ -1432,6 +1498,9 @@ packages: path-to-regexp@8.4.2: resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1613,6 +1682,9 @@ packages: resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} @@ -1631,10 +1703,16 @@ packages: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + stdin-discarder@0.2.2: resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} engines: {node: '>=18'} @@ -1686,10 +1764,21 @@ packages: tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + tinyglobby@0.2.17: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -1804,6 +1893,47 @@ packages: yaml: optional: true + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.10 + '@vitest/browser-preview': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 + '@vitest/coverage-istanbul': 4.1.10 + '@vitest/coverage-v8': 4.1.10 + '@vitest/ui': 4.1.10 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -1814,6 +1944,11 @@ packages: engines: {node: ^16.13.0 || >=18.0.0} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -2230,6 +2365,8 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} + '@standard-schema/spec@1.1.0': {} + '@tailwindcss/node@4.3.2': dependencies: '@jridgewell/remapping': 2.3.5 @@ -2309,6 +2446,15 @@ snapshots: tslib: 2.8.1 optional: true + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + '@types/node@26.1.0': dependencies: undici-types: 8.3.0 @@ -2331,6 +2477,47 @@ snapshots: '@rolldown/pluginutils': 1.0.1 vite: 8.1.3(@types/node@26.1.0)(jiti@2.7.0) + '@vitest/expect@4.1.10': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.10(vite@8.1.3(@types/node@26.1.0)(jiti@2.7.0))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.1.3(@types/node@26.1.0)(jiti@2.7.0) + + '@vitest/pretty-format@4.1.10': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.10': + dependencies: + '@vitest/utils': 4.1.10 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.10': {} + + '@vitest/utils@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + '@wailsio/runtime@3.0.0-alpha.97': {} accepts@2.0.0: @@ -2361,6 +2548,8 @@ snapshots: argparse@2.0.1: {} + assertion-error@2.0.1: {} + ast-types@0.16.1: dependencies: tslib: 2.8.1 @@ -2421,6 +2610,8 @@ snapshots: caniuse-lite@1.0.30001802: {} + chai@6.2.2: {} + chalk@5.6.2: {} class-variance-authority@0.7.1: @@ -2561,6 +2752,8 @@ snapshots: es-errors@1.3.0: {} + es-module-lexer@2.3.0: {} + es-object-atoms@1.1.2: dependencies: es-errors: 1.3.0 @@ -2571,6 +2764,10 @@ snapshots: esprima@4.0.1: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + etag@1.8.1: {} eventsource-parser@3.1.0: {} @@ -2606,6 +2803,8 @@ snapshots: strip-final-newline: 4.0.0 yoctocolors: 2.1.2 + expect-type@1.4.0: {} + express-rate-limit@8.5.2(express@5.2.1): dependencies: express: 5.2.1 @@ -2999,6 +3198,8 @@ snapshots: object-treeify@1.1.33: {} + obug@2.1.3: {} + on-finished@2.4.1: dependencies: ee-first: 1.1.1 @@ -3077,6 +3278,8 @@ snapshots: path-to-regexp@8.4.2: {} + pathe@2.0.3: {} + picocolors@1.1.1: {} picomatch@2.3.2: {} @@ -3327,6 +3530,8 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + signal-exit@3.0.7: {} signal-exit@4.1.0: {} @@ -3337,8 +3542,12 @@ snapshots: source-map@0.6.1: {} + stackback@0.0.2: {} + statuses@2.0.2: {} + std-env@4.1.0: {} + stdin-discarder@0.2.2: {} string-width@7.2.0: @@ -3377,11 +3586,17 @@ snapshots: tiny-invariant@1.3.3: {} + tinybench@2.9.0: {} + + tinyexec@1.2.4: {} + tinyglobby@0.2.17: dependencies: fdir: 6.5.0(picomatch@4.0.5) picomatch: 4.0.5 + tinyrainbow@3.1.0: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -3449,6 +3664,33 @@ snapshots: fsevents: 2.3.3 jiti: 2.7.0 + vitest@4.1.10(@types/node@26.1.0)(vite@8.1.3(@types/node@26.1.0)(jiti@2.7.0)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@8.1.3(@types/node@26.1.0)(jiti@2.7.0)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.0 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.3 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.1.3(@types/node@26.1.0)(jiti@2.7.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 26.1.0 + transitivePeerDependencies: + - msw + which@2.0.2: dependencies: isexe: 2.0.0 @@ -3457,6 +3699,11 @@ snapshots: dependencies: isexe: 3.1.5 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + wrappy@1.0.2: {} wsl-utils@0.3.1: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f87c8eedf..d4ead8556 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,22 +1,26 @@ import { HashRouter, Outlet, Route, Routes } from "react-router-dom" import { SettingsProvider } from "@/lib/settings" import { ProjectsProvider } from "@/lib/projects" -import { Rail } from "@/components/Rail" +import { ProjectTabs } from "@/components/ProjectTabs" +import { SessionSidebar } from "@/components/SessionSidebar" import { TerminalHost } from "@/components/TerminalHost" import { Home } from "@/components/Home" import { Settings } from "@/components/Settings" -// Layout is persistent across navigation: the Rail and the TerminalHost stay -// mounted while the Outlet swaps screens (Home, Settings) on top of the -// terminals. +// Layout is persistent across navigation: the project tabs, session sidebar and +// TerminalHost stay mounted while the Outlet swaps screens (Home, Settings) on +// top of the terminals. function Layout() { return ( -
- -
- - -
+
+ +
+ +
+ + +
+
) } @@ -31,7 +35,7 @@ function App() { } /> {/* Terminals are rendered by TerminalHost; the route only selects which one is visible, so this element is empty. */} - + } /> diff --git a/frontend/src/components/ProjectTabs.tsx b/frontend/src/components/ProjectTabs.tsx new file mode 100644 index 000000000..91ca9636c --- /dev/null +++ b/frontend/src/components/ProjectTabs.tsx @@ -0,0 +1,84 @@ +import { NavLink } from "react-router-dom" +import { Plus, Settings, X } from "lucide-react" +import { cn } from "@/lib/utils" +import { useProjects } from "@/lib/projects" +import type { Project } from "../../bindings/github.com/skipodotdev/skipo/internals/project" + +// ProjectTab is a browser-style tab: the project name, active underline, and a +// close affordance that appears on hover. +function ProjectTab({ + project, + onClose, +}: { + project: Project + onClose: () => void +}) { + return ( + + cn( + "group flex h-8 max-w-52 items-center gap-2 rounded-lg px-3 text-sm text-muted-foreground transition-colors hover:bg-accent/60", + isActive && "bg-accent text-accent-foreground", + ) + } + > + {project.name} + { + event.preventDefault() + onClose() + }} + className="flex size-4 shrink-0 items-center justify-center rounded opacity-0 transition-opacity hover:bg-foreground/15 group-hover:opacity-100" + > + + + + ) +} + +// ProjectTabs is the top strip: open projects as tabs, a button to open another, +// and settings pinned to the right. +export function ProjectTabs() { + const { projects, openProject, closeProject } = useProjects() + + return ( +
+
+ {projects.map((project) => ( + closeProject(project.id)} + /> + ))} + +
+ + + cn( + "flex size-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground", + isActive && "bg-accent text-accent-foreground", + ) + } + > + + +
+ ) +} diff --git a/frontend/src/components/Rail.tsx b/frontend/src/components/Rail.tsx deleted file mode 100644 index 83bb995b7..000000000 --- a/frontend/src/components/Rail.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { NavLink } from "react-router-dom" -import { Plus, Settings, Trash2 } from "lucide-react" -import type { ReactNode } from "react" -import { cn } from "@/lib/utils" -import { useProjects } from "@/lib/projects" -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuTrigger, -} from "@/components/ui/context-menu" - -// RailLink is a Discord-style rail item: a rounded icon tile with a left "pill" -// indicator that grows when the route is active. -function RailLink({ - to, - label, - children, -}: { - to: string - label: string - children: ReactNode -}) { - return ( - - {({ isActive }) => ( - <> - - - {children} - - - )} - - ) -} - -// RailButton is a rail item that fires an action instead of navigating. -function RailButton({ - label, - onClick, - children, -}: { - label: string - onClick: () => void - children: ReactNode -}) { - return ( - - ) -} - -// Rail is the Discord-style vertical navigation: home, the list of open -// projects, an "open project" button, and settings pinned to the bottom. -export function Rail() { - const { projects, openProject, closeProject } = useProjects() - - return ( - - ) -} diff --git a/frontend/src/components/SessionSidebar.tsx b/frontend/src/components/SessionSidebar.tsx new file mode 100644 index 000000000..9a7e9e851 --- /dev/null +++ b/frontend/src/components/SessionSidebar.tsx @@ -0,0 +1,156 @@ +import { useRef, useState } from "react" +import type { PointerEvent as ReactPointerEvent } from "react" +import { useMatch } from "react-router-dom" +import { Plus, X } from "lucide-react" +import { cn } from "@/lib/utils" +import { useProjects } from "@/lib/projects" +import { activeSessionId, sessionsOf, type Session } from "@/lib/sessions" + +// Sidebar width bounds in rem, matching the Tailwind v4 spacing scale. State and +// storage stay in rem; the pointer drag delta arrives in CSS pixels and is +// converted with the 16px root font size Tailwind assumes. +const REM_PX = 16 +const MIN_WIDTH_REM = 12 +const MAX_WIDTH_REM = 30 +const DEFAULT_WIDTH_REM = 15 +const WIDTH_STORAGE_KEY = "skipo.sidebar.width" + +const clampWidth = (rem: number): number => + Math.min(MAX_WIDTH_REM, Math.max(MIN_WIDTH_REM, rem)) + +function readWidth(): number { + const stored = Number(localStorage.getItem(WIDTH_STORAGE_KEY)) + return Number.isFinite(stored) && stored > 0 + ? clampWidth(stored) + : DEFAULT_WIDTH_REM +} + +// SessionRow is one session entry: the label plus a close button on hover. +// +// ponytail: this is where git branch/diff and inline rename will hang off a +// session once those services exist — the row already reserves the room. +function SessionRow({ + session, + active, + onSelect, + onClose, +}: { + session: Session + active: boolean + onSelect: () => void + onClose: () => void +}) { + return ( + + ) +} + +// SessionSidebar lists the active project's sessions and can be drag-resized +// within a fixed pixel range. Width persists across restarts. It renders nothing +// when no project is active (Home, Settings), so it never competes with those +// screens. +// +// Resizing only changes this element's width; the terminal keeps its PTY in sync +// on its own via a ResizeObserver, so the sidebar does not need to know about it. +export function SessionSidebar() { + const { sessions, newSession, closeSession, activateSession } = useProjects() + const match = useMatch("/projects/:projectId") + const projectId = match?.params.projectId + const [width, setWidth] = useState(readWidth) + const dragRef = useRef<{ startX: number; startWidth: number } | null>(null) + + if (!projectId) { + return null + } + + const list = sessionsOf(sessions, projectId) + const activeId = activeSessionId(sessions, projectId) + + const startDrag = (event: ReactPointerEvent) => { + event.preventDefault() + dragRef.current = { startX: event.clientX, startWidth: width } + event.currentTarget.setPointerCapture(event.pointerId) + } + + const onDrag = (event: ReactPointerEvent) => { + const drag = dragRef.current + if (!drag) { + return + } + setWidth(clampWidth(drag.startWidth + (event.clientX - drag.startX) / REM_PX)) + } + + const endDrag = (event: ReactPointerEvent) => { + const drag = dragRef.current + if (!drag) { + return + } + const finalWidth = clampWidth( + drag.startWidth + (event.clientX - drag.startX) / REM_PX, + ) + dragRef.current = null + event.currentTarget.releasePointerCapture(event.pointerId) + setWidth(finalWidth) + localStorage.setItem(WIDTH_STORAGE_KEY, String(finalWidth)) + } + + return ( + + ) +} diff --git a/frontend/src/components/Settings.tsx b/frontend/src/components/Settings.tsx index ab923ffa4..1bba5a67e 100644 --- a/frontend/src/components/Settings.tsx +++ b/frontend/src/components/Settings.tsx @@ -21,7 +21,7 @@ type SectionId = (typeof SECTIONS)[number]["id"] // Settings is a full screen (not a modal): it fills the main area and sits on // top of the persistent terminals, which stay mounted and running behind it. A -// category nav on the left mirrors the terminal Rail; content is on the right. +// category nav sits on the left; content is on the right. export function Settings() { const [active, setActive] = useState("terminal") const [query, setQuery] = useState("") diff --git a/frontend/src/components/TerminalHost.tsx b/frontend/src/components/TerminalHost.tsx index 08cb6284e..b766b8538 100644 --- a/frontend/src/components/TerminalHost.tsx +++ b/frontend/src/components/TerminalHost.tsx @@ -1,35 +1,41 @@ import { useMatch } from "react-router-dom" import { TerminalView } from "@/components/TerminalView" import { useProjects } from "@/lib/projects" +import { activeSessionId, sessionsOf } from "@/lib/sessions" -// TerminalHost keeps one persistent terminal per open project stacked in the -// same area. The router only decides which one is visible — terminals are never -// unmounted by navigation, so background sessions keep running. Inactive layers -// use visibility:hidden (not display:none) so they retain layout size and fit() -// stays correct. +// TerminalHost keeps one persistent terminal per session, across every open +// project, stacked in the same area. The router picks the active project and the +// per-project active session decides which layer is visible — terminals are +// never unmounted by navigation, so background sessions keep running. Inactive +// layers use visibility:hidden (not display:none) so they retain layout size and +// fit() stays correct. export function TerminalHost() { - const { projects } = useProjects() - const match = useMatch("/projects/:id") - const activeId = match?.params.id ?? null + const { projects, sessions } = useProjects() + const match = useMatch("/projects/:projectId") + const activeProjectId = match?.params.projectId ?? null return ( <> - {projects.map((project) => { - const visible = project.id === activeId - return ( -
- -
- ) + {projects.flatMap((project) => { + const projectActiveId = activeSessionId(sessions, project.id) + return sessionsOf(sessions, project.id).map((session) => { + const visible = + project.id === activeProjectId && session.id === projectActiveId + return ( +
+ +
+ ) + }) })} ) diff --git a/frontend/src/components/TerminalView.tsx b/frontend/src/components/TerminalView.tsx index f4a760448..e8ad36531 100644 --- a/frontend/src/components/TerminalView.tsx +++ b/frontend/src/components/TerminalView.tsx @@ -6,7 +6,7 @@ import { useSettings } from "@/lib/settings" import type { ResolvedTheme } from "@/lib/settings" // Event name prefixes mirror the backend (internals/terminal); the concrete -// event carries the project ID as a suffix. +// event carries the session ID as a suffix. const DATA_EVENT_PREFIX = "terminal:data:" const EXIT_EVENT_PREFIX = "terminal:exit:" @@ -52,12 +52,12 @@ function decodeBase64(data: string): Uint8Array { } interface TerminalViewProps { - projectId: string + sessionId: string cwd: string visible: boolean } -export function TerminalView({ projectId, cwd, visible }: TerminalViewProps) { +export function TerminalView({ sessionId, cwd, visible }: TerminalViewProps) { const { font, resolvedTerminalTheme } = useSettings() const containerRef = useRef(null) const termRef = useRef(null) @@ -70,9 +70,9 @@ export function TerminalView({ projectId, cwd, visible }: TerminalViewProps) { fontRef.current = font themeRef.current = resolvedTerminalTheme - // Create the terminal and its PTY session once per project. The session runs - // in the background regardless of visibility and is only torn down when the - // project is closed (this component unmounts) — never on navigation. + // Create the terminal and its PTY session once per session id. The session + // runs in the background regardless of visibility and is only torn down when + // it is closed (this component unmounts) — never on navigation. useEffect(() => { const container = containerRef.current if (!container) { @@ -82,12 +82,40 @@ export function TerminalView({ projectId, cwd, visible }: TerminalViewProps) { let disposed = false const cleanups: Array<() => void> = [] - const onWindowResize = () => { - const term = termRef.current - fitRef.current?.fit() - if (term && visibleRef.current) { - void Service.Resize(projectId, term.cols, term.rows) + // Refit the grid whenever the container changes size — window resize, sidebar + // drag, zoom. Guard on integer dimensions: fit() nudges the canvas, which can + // make the observer re-fire at sub-pixel deltas and loop into a flicker. + // Ignoring sub-1px changes breaks that loop. Coalesced to one refit per frame. + // Only the visible terminal pushes its new size to the PTY; hidden ones refit + // but sync their PTY when shown. + let lastWidth = 0 + let lastHeight = 0 + let refitFrame = 0 + const scheduleRefit = (entries: ResizeObserverEntry[]) => { + const rect = entries[0]?.contentRect + if (rect) { + const width = Math.round(rect.width) + const height = Math.round(rect.height) + if (width === lastWidth && height === lastHeight) { + return + } + lastWidth = width + lastHeight = height } + if (refitFrame) { + return + } + refitFrame = requestAnimationFrame(() => { + refitFrame = 0 + const term = termRef.current + if (!term) { + return + } + fitRef.current?.fit() + if (visibleRef.current) { + void Service.Resize(sessionId, term.cols, term.rows) + } + }) } void (async () => { @@ -111,26 +139,32 @@ export function TerminalView({ projectId, cwd, visible }: TerminalViewProps) { termRef.current = term fitRef.current = fit - const dataInput = term.onData((data) => Service.Write(projectId, data)) + const dataInput = term.onData((data) => Service.Write(sessionId, data)) const resizeInput = term.onResize(({ cols, rows }) => { if (visibleRef.current) { - void Service.Resize(projectId, cols, rows) + void Service.Resize(sessionId, cols, rows) } }) cleanups.push(() => dataInput.dispose(), () => resizeInput.dispose()) - const offData = Events.On(DATA_EVENT_PREFIX + projectId, (event) => { + const offData = Events.On(DATA_EVENT_PREFIX + sessionId, (event) => { term.write(decodeBase64(event.data as string)) }) - const offExit = Events.On(EXIT_EVENT_PREFIX + projectId, () => { + const offExit = Events.On(EXIT_EVENT_PREFIX + sessionId, () => { term.write("\r\n[process exited]\r\n") }) cleanups.push(offData, offExit) - window.addEventListener("resize", onWindowResize) - cleanups.push(() => window.removeEventListener("resize", onWindowResize)) + const resizeObserver = new ResizeObserver(scheduleRefit) + resizeObserver.observe(container) + cleanups.push(() => { + if (refitFrame) { + cancelAnimationFrame(refitFrame) + } + resizeObserver.disconnect() + }) - await Service.Start(projectId, cwd, term.cols, term.rows) + await Service.Start(sessionId, cwd, term.cols, term.rows) if (visibleRef.current) { term.focus() } @@ -141,12 +175,12 @@ export function TerminalView({ projectId, cwd, visible }: TerminalViewProps) { for (const cleanup of cleanups) { cleanup() } - void Service.Close(projectId) + void Service.Close(sessionId) termRef.current?.dispose() termRef.current = null fitRef.current = null } - }, [projectId, cwd]) + }, [sessionId, cwd]) // Apply a font change live to the running terminal. useEffect(() => { @@ -160,10 +194,10 @@ export function TerminalView({ projectId, cwd, visible }: TerminalViewProps) { // Font metrics changed: recompute the grid and sync the visible PTY. fitRef.current?.fit() if (visibleRef.current) { - void Service.Resize(projectId, term.cols, term.rows) + void Service.Resize(sessionId, term.cols, term.rows) } })() - }, [font, projectId]) + }, [font, sessionId]) // Apply a terminal theme change live to the running terminal. useEffect(() => { @@ -181,9 +215,9 @@ export function TerminalView({ projectId, cwd, visible }: TerminalViewProps) { return } fitRef.current?.fit() - void Service.Resize(projectId, term.cols, term.rows) + void Service.Resize(sessionId, term.cols, term.rows) term.focus() - }, [visible, projectId]) + }, [visible, sessionId]) return
} diff --git a/frontend/src/lib/projects.tsx b/frontend/src/lib/projects.tsx index f8c722c6c..1593ea48b 100644 --- a/frontend/src/lib/projects.tsx +++ b/frontend/src/lib/projects.tsx @@ -5,21 +5,40 @@ import { Project, Service as ProjectService, } from "../../bindings/github.com/skipodotdev/skipo/internals/project" +import { + addSession, + closeSession as removeSession, + removeProject, + sessionsOf, + setActiveSession, + type SessionState, +} from "./sessions" interface ProjectsValue { projects: Project[] + /** Sessions keyed by project id, with the active session per project. */ + sessions: SessionState /** Show the OS directory picker, add the chosen project and navigate to it. */ openProject: () => Promise - /** Remove a project (unmounts its terminal, which closes the PTY). */ + /** Remove a project (unmounts its terminals, which closes their PTYs). */ closeProject: (id: string) => void + /** Open a new terminal session in a project and focus it. */ + newSession: (projectId: string) => void + /** Close a session; closing the last one recreates an empty one. */ + closeSession: (projectId: string, sessionId: string) => void + /** Focus an existing session within a project. */ + activateSession: (projectId: string, sessionId: string) => void } const ProjectsContext = createContext(null) -// ponytail: open projects are kept in memory only. Persist to a config file -// (and restore on launch) when projects should survive restarts. +const newSessionId = (): string => crypto.randomUUID() + +// ponytail: open projects and their sessions are kept in memory only. Persist to +// a config file (and restore on launch) when they should survive restarts. export function ProjectsProvider({ children }: { children: ReactNode }) { const [projects, setProjects] = useState([]) + const [sessions, setSessions] = useState({}) const navigate = useNavigate() const openProject = useCallback(async () => { @@ -30,19 +49,51 @@ export function ProjectsProvider({ children }: { children: ReactNode }) { setProjects((prev) => prev.some((project) => project.id === picked.id) ? prev : [...prev, picked], ) + setSessions((prev) => + prev[picked.id] ? prev : addSession(prev, picked.id, newSessionId()), + ) navigate(`/projects/${picked.id}`) }, [navigate]) const closeProject = useCallback( (id: string) => { setProjects((prev) => prev.filter((project) => project.id !== id)) + setSessions((prev) => removeProject(prev, id)) navigate("/") }, [navigate], ) + const newSession = useCallback((projectId: string) => { + setSessions((prev) => addSession(prev, projectId, newSessionId())) + }, []) + + const closeSession = useCallback((projectId: string, sessionId: string) => { + setSessions((prev) => { + const next = removeSession(prev, projectId, sessionId) + // A project always keeps at least one session; recreate when emptied. + return sessionsOf(next, projectId).length === 0 + ? addSession(next, projectId, newSessionId()) + : next + }) + }, []) + + const activateSession = useCallback((projectId: string, sessionId: string) => { + setSessions((prev) => setActiveSession(prev, projectId, sessionId)) + }, []) + return ( - + {children} ) diff --git a/frontend/src/lib/sessions.test.ts b/frontend/src/lib/sessions.test.ts new file mode 100644 index 000000000..e0e9acbb5 --- /dev/null +++ b/frontend/src/lib/sessions.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "vitest" +import { + activeSessionId, + addSession, + closeSession, + createProjectSessions, + removeProject, + sessionsOf, + setActiveSession, + type SessionState, +} from "./sessions" + +const P = "project-1" + +// buildState creates a project with `n` sessions (ids "s1".."sn"), the last one +// active. +function buildState(n: number): SessionState { + let state: SessionState = { [P]: createProjectSessions("s1") } + for (let i = 2; i <= n; i++) { + state = addSession(state, P, `s${i}`) + } + return state +} + +describe("createProjectSessions", () => { + it("seeds one active session labeled Session 1", () => { + const project = createProjectSessions("s1") + expect(project.sessions).toEqual([{ id: "s1", label: "Session 1" }]) + expect(project.activeId).toBe("s1") + expect(project.nextSeq).toBe(2) + }) +}) + +describe("addSession", () => { + it("creates the project entry when absent", () => { + const state = addSession({}, P, "s1") + expect(sessionsOf(state, P)).toHaveLength(1) + expect(activeSessionId(state, P)).toBe("s1") + }) + + it("appends, focuses the new session and advances the label sequence", () => { + const state = addSession(buildState(1), P, "s2") + expect(sessionsOf(state, P).map((s) => s.label)).toEqual([ + "Session 1", + "Session 2", + ]) + expect(activeSessionId(state, P)).toBe("s2") + }) + + it("does not mutate the input state", () => { + const before = buildState(1) + addSession(before, P, "s2") + expect(sessionsOf(before, P)).toHaveLength(1) + }) +}) + +describe("closeSession", () => { + it("removes a non-active session and keeps the active one", () => { + const state = closeSession(buildState(3), P, "s1") // active is s3 + expect(sessionsOf(state, P).map((s) => s.id)).toEqual(["s2", "s3"]) + expect(activeSessionId(state, P)).toBe("s3") + }) + + it("moves focus to the slot filler when the active session closes", () => { + // s1, s2, s3 with s2 active → closing s2 focuses s3 (fills index 1). + let state = buildState(3) + state = setActiveSession(state, P, "s2") + state = closeSession(state, P, "s2") + expect(activeSessionId(state, P)).toBe("s3") + }) + + it("falls back to the previous session when the last one closes", () => { + const state = closeSession(buildState(3), P, "s3") // active is s3 (last) + expect(activeSessionId(state, P)).toBe("s2") + }) + + it("empties the project but preserves nextSeq for recreate", () => { + const state = closeSession(buildState(1), P, "s1") + expect(sessionsOf(state, P)).toHaveLength(0) + expect(activeSessionId(state, P)).toBe("") + // A recreate keeps counting up instead of reusing "Session 1". + const recreated = addSession(state, P, "s2") + expect(sessionsOf(recreated, P)[0].label).toBe("Session 2") + }) + + it("ignores unknown project or session ids", () => { + const state = buildState(2) + expect(closeSession(state, "nope", "s1")).toBe(state) + expect(closeSession(state, P, "ghost")).toBe(state) + }) +}) + +describe("setActiveSession", () => { + it("focuses an existing session", () => { + const state = setActiveSession(buildState(3), P, "s1") + expect(activeSessionId(state, P)).toBe("s1") + }) + + it("ignores unknown ids", () => { + const state = buildState(2) + expect(setActiveSession(state, P, "ghost")).toBe(state) + }) +}) + +describe("removeProject", () => { + it("drops the project and its sessions", () => { + const state = removeProject(buildState(2), P) + expect(sessionsOf(state, P)).toHaveLength(0) + expect(P in state).toBe(false) + }) + + it("is a no-op for an unknown project", () => { + const state = buildState(1) + expect(removeProject(state, "nope")).toBe(state) + }) +}) diff --git a/frontend/src/lib/sessions.ts b/frontend/src/lib/sessions.ts new file mode 100644 index 000000000..b0d4ffcfa --- /dev/null +++ b/frontend/src/lib/sessions.ts @@ -0,0 +1,119 @@ +// Session state model for multi-session-per-project. A project owns an ordered +// list of terminal sessions and tracks which one is active. Sessions decouple +// from the project: each has its own opaque id used as the backend PTY key, so +// the terminal service needs no knowledge of projects at all. +// +// Every function here is pure — it returns a new state and never mutates the +// input — which keeps the reducer logic testable without React or a PTY. + +export interface Session { + id: string + label: string +} + +export interface ProjectSessions { + sessions: Session[] + activeId: string + // Monotonic per-project counter for default labels. It only grows, so closing + // a session never renumbers the survivors. + nextSeq: number +} + +export type SessionState = Record + +// createProjectSessions seeds a project with its first session. +export function createProjectSessions(firstSessionId: string): ProjectSessions { + return { + sessions: [{ id: firstSessionId, label: "Session 1" }], + activeId: firstSessionId, + nextSeq: 2, + } +} + +// addSession appends a session to a project and makes it active. If the project +// has no entry yet, it is created with this session as the first. +export function addSession( + state: SessionState, + projectId: string, + sessionId: string, +): SessionState { + const current = state[projectId] + if (!current) { + return { ...state, [projectId]: createProjectSessions(sessionId) } + } + const session: Session = { id: sessionId, label: `Session ${current.nextSeq}` } + return { + ...state, + [projectId]: { + sessions: [...current.sessions, session], + activeId: sessionId, + nextSeq: current.nextSeq + 1, + }, + } +} + +// closeSession removes a session. When the active one is closed, focus moves to +// a neighbor. The project entry is kept even when empty so nextSeq (and the +// caller's recreate-on-empty policy) survive. +export function closeSession( + state: SessionState, + projectId: string, + sessionId: string, +): SessionState { + const current = state[projectId] + if (!current) { + return state + } + const index = current.sessions.findIndex((s) => s.id === sessionId) + if (index === -1) { + return state + } + const sessions = current.sessions.filter((s) => s.id !== sessionId) + const activeId = + current.activeId === sessionId ? neighborId(sessions, index) : current.activeId + return { ...state, [projectId]: { ...current, sessions, activeId } } +} + +// neighborId picks the session that fills the closed slot, falling back to the +// previous one, or "" when the list is now empty. +function neighborId(sessions: Session[], removedIndex: number): string { + if (sessions.length === 0) { + return "" + } + const next = sessions[removedIndex] ?? sessions[removedIndex - 1] + return next.id +} + +// setActiveSession focuses an existing session; unknown ids are ignored. +export function setActiveSession( + state: SessionState, + projectId: string, + sessionId: string, +): SessionState { + const current = state[projectId] + if (!current || !current.sessions.some((s) => s.id === sessionId)) { + return state + } + return { ...state, [projectId]: { ...current, activeId: sessionId } } +} + +// removeProject drops a project and all its sessions. +export function removeProject( + state: SessionState, + projectId: string, +): SessionState { + if (!(projectId in state)) { + return state + } + const next = { ...state } + delete next[projectId] + return next +} + +export function sessionsOf(state: SessionState, projectId: string): Session[] { + return state[projectId]?.sessions ?? [] +} + +export function activeSessionId(state: SessionState, projectId: string): string { + return state[projectId]?.activeId ?? "" +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index fc3e4e3a0..5709ce0b1 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -28,4 +28,5 @@ } }, "include": ["src", "bindings"], + "exclude": ["src/**/*.test.ts"], } diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 000000000..c63ce1eaa --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,17 @@ +import path from "node:path" +import { defineConfig } from "vitest/config" + +// Standalone from vite.config.ts: the Wails runtime plugin needs the desktop +// bridge, which is absent under the test runner. Session logic is pure, so a +// plain node environment is enough. +export default defineConfig({ + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, + test: { + environment: "node", + include: ["src/**/*.test.ts"], + }, +}) diff --git a/internals/project/project.go b/internals/project/project.go index b35577d1c..e6a97c03d 100644 --- a/internals/project/project.go +++ b/internals/project/project.go @@ -1,6 +1,7 @@ // Package project opens project directories through the OS file picker. Project -// metadata (list, active) lives in the frontend; this service only turns a -// picked directory into a stable identity the terminal sessions can key on. +// metadata (list, active, sessions) lives in the frontend; this service only +// turns a picked directory into a stable identity the frontend can group its +// terminal sessions under. package project import ( diff --git a/internals/terminal/terminal.go b/internals/terminal/terminal.go index 23815c07f..5f3e54be2 100644 --- a/internals/terminal/terminal.go +++ b/internals/terminal/terminal.go @@ -1,7 +1,7 @@ // Package terminal spawns PTY-backed shell sessions and bridges their I/O to the -// frontend over Wails events. Sessions are keyed by project ID and run +// frontend over Wails events. Sessions are keyed by an opaque session ID and run // independently of the frontend, so navigating away from a project (or hiding -// its terminal) never kills its shell. +// its terminal) never kills its shell. A project may own several sessions. package terminal import ( @@ -33,7 +33,7 @@ type session struct { cmd *exec.Cmd } -// Service manages PTY-backed shell sessions keyed by project ID. +// Service manages PTY-backed shell sessions keyed by session ID. type Service struct { mu sync.Mutex sessions map[string]*session @@ -53,9 +53,9 @@ func defaultShell() string { return "/bin/sh" } -// Start spawns the user's shell for project id, attached to a new PTY sized to +// Start spawns the user's shell for session id, attached to a new PTY sized to // cols x rows and rooted at cwd, then streams its output to the frontend. An -// empty cwd defaults to the user's home directory. Starting a project that is +// empty cwd defaults to the user's home directory. Starting a session that is // already running is a no-op. func (s *Service) Start(id, cwd string, cols, rows int) error { s.mu.Lock() @@ -112,7 +112,7 @@ func (s *Service) stream(id string, ptmx *os.File, cmd *exec.Cmd) { application.Get().Event.Emit(exitEventPrefix + id) } -// Write forwards keyboard input from the frontend to a project's PTY. +// Write forwards keyboard input from the frontend to a session's PTY. func (s *Service) Write(id, data string) error { ptmx := s.ptmxOf(id) if ptmx == nil { @@ -122,7 +122,7 @@ func (s *Service) Write(id, data string) error { return err } -// Resize updates a project's PTY window size. The frontend only calls this for +// Resize updates a session's PTY window size. The frontend only calls this for // the visible terminal; a hidden terminal is resized on the next time it is // shown. func (s *Service) Resize(id string, cols, rows int) error { @@ -133,7 +133,7 @@ func (s *Service) Resize(id string, cols, rows int) error { return pty.Setsize(ptmx, &pty.Winsize{Rows: uint16(rows), Cols: uint16(cols)}) } -// Close terminates a project's shell session, if any. +// Close terminates a session's shell, if any. func (s *Service) Close(id string) error { s.mu.Lock() sess, ok := s.sessions[id]