diff --git a/.gitignore b/.gitignore index 067fcdf1b..89eb1fa0c 100644 --- a/.gitignore +++ b/.gitignore @@ -35,4 +35,4 @@ pnpm-workspace.yaml **.car -.envrc \ No newline at end of file +.envrc diff --git a/package.json b/package.json index ff6c84471..dd80d7df8 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "check:write": "pnpm run lint && pnpm run typecheck", "generate-client": "tsx scripts/update-openapi-client.ts", "prepare": "husky", - "knip": "knip" + "knip": "knip", + "test": "vitest run" }, "keywords": [ "posthog", @@ -40,14 +41,19 @@ "@electron-forge/plugin-vite": "^7.10.2", "@electron-forge/publisher-github": "^7.10.2", "@electron-forge/shared-types": "^7.10.2", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", "@types/node": "^20.19.21", "@types/react": "^18.2.48", "@types/react-dom": "^18.2.18", "@types/uuid": "^9.0.7", "@vitejs/plugin-react": "^4.2.1", + "@vitest/ui": "^4.0.10", "autoprefixer": "^10.4.17", "electron": "^28.2.0", "husky": "^9.1.7", + "jsdom": "^26.0.0", "knip": "^5.66.3", "lint-staged": "^15.5.2", "postcss": "^8.4.33", @@ -57,10 +63,13 @@ "typescript": "^5.9.3", "vite": "^5.0.12", "vite-tsconfig-paths": "^5.1.4", + "vitest": "^4.0.10", "yaml": "^2.8.1" }, "dependencies": { "@ai-sdk/openai": "^2.0.52", + "@codemirror/state": "^6.5.2", + "@codemirror/view": "^6.38.8", "@dnd-kit/react": "^0.1.21", "@phosphor-icons/react": "^2.1.10", "@posthog/agent": "1.20.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6c25f40f2..d7f1472bb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,12 @@ importers: '@ai-sdk/openai': specifier: ^2.0.52 version: 2.0.52(zod@4.1.12) + '@codemirror/state': + specifier: ^6.5.2 + version: 6.5.2 + '@codemirror/view': + specifier: ^6.38.8 + version: 6.38.8 '@dnd-kit/react': specifier: ^0.1.21 version: 0.1.21(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -144,6 +150,15 @@ importers: '@electron-forge/shared-types': specifier: ^7.10.2 version: 7.10.2 + '@testing-library/jest-dom': + specifier: ^6.9.1 + version: 6.9.1 + '@testing-library/react': + specifier: ^16.3.0 + version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.26))(@types/react@18.3.26)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@testing-library/user-event': + specifier: ^14.6.1 + version: 14.6.1(@testing-library/dom@10.4.1) '@types/node': specifier: ^20.19.21 version: 20.19.21 @@ -159,6 +174,9 @@ importers: '@vitejs/plugin-react': specifier: ^4.2.1 version: 4.7.0(vite@5.4.20(@types/node@20.19.21)(terser@5.44.0)) + '@vitest/ui': + specifier: ^4.0.10 + version: 4.0.10(vitest@4.0.10) autoprefixer: specifier: ^10.4.17 version: 10.4.21(postcss@8.5.6) @@ -168,6 +186,9 @@ importers: husky: specifier: ^9.1.7 version: 9.1.7 + jsdom: + specifier: ^26.0.0 + version: 26.1.0 knip: specifier: ^5.66.3 version: 5.66.3(@types/node@20.19.21)(typescript@5.9.3) @@ -195,12 +216,18 @@ importers: vite-tsconfig-paths: specifier: ^5.1.4 version: 5.1.4(typescript@5.9.3)(vite@5.4.20(@types/node@20.19.21)(terser@5.44.0)) + vitest: + specifier: ^4.0.10 + version: 4.0.10(@types/debug@4.1.12)(@types/node@20.19.21)(@vitest/ui@4.0.10)(jiti@1.21.7)(jsdom@26.1.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1) yaml: specifier: ^2.8.1 version: 2.8.1 packages: + '@adobe/css-tools@4.4.4': + resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + '@ai-sdk/gateway@2.0.0': resolution: {integrity: sha512-Gj0PuawK7NkZuyYgO/h5kDK/l6hFOjhLdTq3/Lli1FTl47iGmwhH1IZQpAL3Z09BeFYWakcwUmn02ovIm2wy9g==} engines: {node: '>=18'} @@ -255,6 +282,9 @@ packages: '@ark/util@0.46.0': resolution: {integrity: sha512-JPy/NGWn/lvf1WmGCPw2VGpBg5utZraE84I7wli18EDF3p3zc/e9WolT35tINeZO3l7C77SjqRJeAUoT0CvMRg==} + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + '@babel/code-frame@7.27.1': resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} @@ -326,6 +356,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/runtime@7.28.4': + resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} + engines: {node: '>=6.9.0'} + '@babel/template@7.27.2': resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} engines: {node: '>=6.9.0'} @@ -391,6 +425,40 @@ packages: cpu: [x64] os: [win32] + '@codemirror/state@6.5.2': + resolution: {integrity: sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==} + + '@codemirror/view@6.38.8': + resolution: {integrity: sha512-XcE9fcnkHCbWkjeKyi0lllwXmBLtyYb5dt89dJyx23I9+LSh5vZDIuk7OLG4VM1lgrXZQcY6cxyZyk5WVPRv/A==} + + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + '@dnd-kit/abstract@0.1.21': resolution: {integrity: sha512-6sJut6/D21xPIK8EFMu+JJeF+fBCOmQKN1BRpeUYFi5m9P1CJpTYbBwfI107h7PHObI6a5bsckiKkRpF2orHpw==} @@ -1006,6 +1074,9 @@ packages: resolution: {integrity: sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==} engines: {node: '>= 12.13.0'} + '@marijn/find-cluster-break@1.0.2': + resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==} + '@napi-rs/wasm-runtime@1.0.7': resolution: {integrity: sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw==} @@ -1204,6 +1275,9 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + '@posthog/agent@1.20.0': resolution: {integrity: sha512-+DAFi8MT94OoZKDwrGbPAvrWKbUYeVIVXhOb056ivBzMNZW4RUP7OZvOaLzL1TVDwIiHP0aYBH9Wa1dY0RyFcg==} engines: {node: '>=20.0.0'} @@ -1239,8 +1313,8 @@ packages: '@radix-ui/react-accordion@1.2.12': resolution: {integrity: sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==} peerDependencies: - '@types/react': 19.1.11 - '@types/react-dom': 19.1.8 + '@types/react': '*' + '@types/react-dom': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: @@ -1374,8 +1448,8 @@ packages: '@radix-ui/react-dialog@1.1.15': resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} peerDependencies: - '@types/react': 19.1.11 - '@types/react-dom': 19.1.8 + '@types/react': '*' + '@types/react-dom': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: @@ -1396,8 +1470,8 @@ packages: '@radix-ui/react-dismissable-layer@1.1.11': resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} peerDependencies: - '@types/react': 19.0.10 - '@types/react-dom': 19.0.4 + '@types/react': '*' + '@types/react-dom': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: @@ -1422,7 +1496,7 @@ packages: '@radix-ui/react-focus-guards@1.1.3': resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} peerDependencies: - '@types/react': 19.0.10 + '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: '@types/react': @@ -1431,8 +1505,8 @@ packages: '@radix-ui/react-focus-scope@1.1.7': resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} peerDependencies: - '@types/react': 19.0.10 - '@types/react-dom': 19.0.4 + '@types/react': '*' + '@types/react-dom': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: @@ -1575,8 +1649,8 @@ packages: '@radix-ui/react-popper@1.2.8': resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} peerDependencies: - '@types/react': 19.0.10 - '@types/react-dom': 19.0.4 + '@types/react': '*' + '@types/react-dom': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: @@ -1601,8 +1675,8 @@ packages: '@radix-ui/react-presence@1.1.5': resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} peerDependencies: - '@types/react': 19.0.10 - '@types/react-dom': 19.0.4 + '@types/react': '*' + '@types/react-dom': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: @@ -1653,8 +1727,8 @@ packages: '@radix-ui/react-roving-focus@1.1.11': resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} peerDependencies: - '@types/react': 19.0.10 - '@types/react-dom': 19.0.4 + '@types/react': '*' + '@types/react-dom': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: @@ -1872,7 +1946,7 @@ packages: '@radix-ui/react-use-previous@1.1.1': resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==} peerDependencies: - '@types/react': 19.0.10 + '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: '@types/react': @@ -2066,6 +2140,35 @@ packages: peerDependencies: react: ^18 || ^19 + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/react@16.3.0': + resolution: {integrity: sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@testing-library/user-event@14.6.1': + resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + '@tiptap/core@3.7.1': resolution: {integrity: sha512-jB6R8EGI34QUmV7EhtE+JVpjbZ6Wa0dcf0LNS36X9V7FtDQcnxl7ekRs/ftELt/6qOjubRdyhaID0wNdJVmFtw==} peerDependencies: @@ -2248,6 +2351,9 @@ packages: '@types/appdmg@0.5.5': resolution: {integrity: sha512-G+n6DgZTZFOteITE30LnWj+HRVIGr7wMlAiLWOO02uJFWVEitaPU9JVXm9wJokkgshBawb2O1OykdcsmkkZfgg==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -2263,9 +2369,15 @@ packages: '@types/cacheable-request@6.0.3': resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/eslint-scope@3.7.7': resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} @@ -2362,6 +2474,40 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitest/expect@4.0.10': + resolution: {integrity: sha512-3QkTX/lK39FBNwARCQRSQr0TP9+ywSdxSX+LgbJ2M1WmveXP72anTbnp2yl5fH+dU6SUmBzNMrDHs80G8G2DZg==} + + '@vitest/mocker@4.0.10': + resolution: {integrity: sha512-e2OfdexYkjkg8Hh3L9NVEfbwGXq5IZbDovkf30qW2tOh7Rh9sVtmSr2ztEXOFbymNxS4qjzLXUQIvATvN4B+lg==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.0.10': + resolution: {integrity: sha512-99EQbpa/zuDnvVjthwz5bH9o8iPefoQZ63WV8+bsRJZNw3qQSvSltfut8yu1Jc9mqOYi7pEbsKxYTi/rjaq6PA==} + + '@vitest/runner@4.0.10': + resolution: {integrity: sha512-EXU2iSkKvNwtlL8L8doCpkyclw0mc/t4t9SeOnfOFPyqLmQwuceMPA4zJBa6jw0MKsZYbw7kAn+gl7HxrlB8UQ==} + + '@vitest/snapshot@4.0.10': + resolution: {integrity: sha512-2N4X2ZZl7kZw0qeGdQ41H0KND96L3qX1RgwuCfy6oUsF2ISGD/HpSbmms+CkIOsQmg2kulwfhJ4CI0asnZlvkg==} + + '@vitest/spy@4.0.10': + resolution: {integrity: sha512-AsY6sVS8OLb96GV5RoG8B6I35GAbNrC49AO+jNRF9YVGb/g9t+hzNm1H6kD0NDp8tt7VJLs6hb7YMkDXqu03iw==} + + '@vitest/ui@4.0.10': + resolution: {integrity: sha512-oWtNM89Np+YsQO3ttT5i1Aer/0xbzQzp66NzuJn/U16bB7MnvSzdLKXgk1kkMLYyKSSzA2ajzqMkYheaE9opuQ==} + peerDependencies: + vitest: 4.0.10 + + '@vitest/utils@4.0.10': + resolution: {integrity: sha512-kOuqWnEwZNtQxMKg3WmPK1vmhZu9WcoX69iwWjVz+jvKTsF1emzsv3eoPcDr6ykA3qP2bsCQE7CwqfNtAVzsmg==} + '@vscode/sudo-prompt@9.3.1': resolution: {integrity: sha512-9ORTwwS74VaTn38tNbQhsA5U44zkJfcb0BdTSyyG6frP4e8KMtHuTXYmwefe5dpL8XB1aGSIVTaLjD3BbWb5iA==} @@ -2451,6 +2597,10 @@ packages: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + agentkeepalive@4.6.0: resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} engines: {node: '>= 8.0.0'} @@ -2513,6 +2663,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + ansi-styles@6.2.3: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} @@ -2540,9 +2694,20 @@ packages: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + arktype@2.1.20: resolution: {integrity: sha512-IZCEEXaJ8g+Ijd59WtSYwtjnqXiwM8sWQ5EjGamcto7+HVN9eK0C4p0zDlCuAwWhpqr6fIBkxPuYDl4/Mcj/+Q==} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + async@1.5.2: resolution: {integrity: sha512-nSVgobk4rv61R9PUSDtYt7mPVB2olxNR5RWJcAsH676/ef11bUZwvu7+RGYrYauVdDPcO519v68wRhXQtxsV9w==} @@ -2663,6 +2828,10 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@6.2.1: + resolution: {integrity: sha512-p4Z49OGG5W/WBCPSS/dH3jQ73kD6tiMmUM+bckNK6Jr5JHMG3k9bg/BvKR8lKmtVBKmOiuVaV2ws8s9oSbwysg==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -2824,14 +2993,25 @@ packages: resolution: {integrity: sha512-n63i0lZ0rvQ6FXiGQ+/JFCKAUyPFhLQYJIqKaa+tSJtfKeULF/IDNDAbdnSIxgS4NTuw2b0+lj8LzfITuq+ZxQ==} engines: {node: '>=12.10'} + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} hasBin: true + cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} + csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + date-fns@3.6.0: resolution: {integrity: sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==} @@ -2852,6 +3032,9 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + decode-named-character-reference@1.2.0: resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==} @@ -2907,6 +3090,12 @@ packages: dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dotenv@17.2.3: resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} engines: {node: '>=12'} @@ -2960,6 +3149,10 @@ packages: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -3037,6 +3230,9 @@ packages: estree-util-is-identifier-name@3.0.0: resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + eventemitter3@5.0.1: resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} @@ -3056,6 +3252,10 @@ packages: resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} engines: {node: '>=16.17'} + expect-type@1.2.2: + resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==} + engines: {node: '>=12.0.0'} + exponential-backoff@3.1.3: resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} @@ -3090,9 +3290,21 @@ packages: fd-slicer@1.1.0: resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + fflate@0.4.8: resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==} + fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + filename-reserved-regex@2.0.0: resolution: {integrity: sha512-lc1bnsSr4L4Bdif8Xb/qrtokGbq5zlsms/CYH8PP+WtCkGNF65DPiQY8vG3SakEdRn8Dlnm+gW/qWKKjS5sZzQ==} engines: {node: '>=4'} @@ -3113,6 +3325,9 @@ packages: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} + flatted@3.3.3: + resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + flora-colossus@2.0.0: resolution: {integrity: sha512-dz4HxH6pOvbUzZpZ/yXhafjbR2I8cenK5xL0KtBFb7U2ADsR+OwXifnxZjij/pZWF775uSCMzWVd+jDik2H2IA==} engines: {node: '>= 12'} @@ -3315,6 +3530,10 @@ packages: hosted-git-info@2.8.9: resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==} + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} @@ -3325,6 +3544,10 @@ packages: resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==} engines: {node: '>= 6'} + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + http2-wrapper@1.0.3: resolution: {integrity: sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==} engines: {node: '>=10.19.0'} @@ -3333,6 +3556,10 @@ packages: resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} engines: {node: '>= 6'} + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + human-signals@5.0.0: resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} engines: {node: '>=16.17.0'} @@ -3465,6 +3692,9 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-property@1.0.2: resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==} @@ -3509,6 +3739,15 @@ packages: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} hasBin: true + jsdom@26.1.0: + resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -3641,10 +3880,17 @@ packages: resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} engines: {node: '>=12'} + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + macos-alias@0.2.12: resolution: {integrity: sha512-yiLHa7cfJcGRFq4FrR4tMlpNHb4Vy4mWnpajlSSIFM5k4Lv8/7BbbDLzCAVogWNl0LlLhizRp1drXv0hK9h0Yw==} os: [darwin] + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + make-fetch-happen@10.2.1: resolution: {integrity: sha512-NgOPbRiaQM10DYXvN3/hhGVI2M5MtITFryzBGxHM5p4wnFxsVCbxkrBrDsk+EZ5OB4jEOT7AjDxtdF+KVEFT7w==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} @@ -3798,6 +4044,10 @@ packages: resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} engines: {node: '>=10'} + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -3853,6 +4103,10 @@ packages: engines: {node: '>=10'} hasBin: true + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + ms@2.0.0: resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} @@ -3944,6 +4198,9 @@ packages: resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + nwsapi@2.2.22: + resolution: {integrity: sha512-ujSMe1OWVn55euT1ihwCI1ZcAaAU3nxUiDwfDQldc51ZXaB9m2AyOn6/jh1BLe2t/G8xd6uKG1UBF2aZJeg2SQ==} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -4048,6 +4305,9 @@ packages: resolution: {integrity: sha512-QR/GGaKCkhwk1ePQNYDRKYZ3mwU9ypsKhB0XyFnLQdomyEqk3e8wpW3V5Jp88zbxK4n5ST1nqo+g9juTpownhQ==} engines: {node: '>=0.10.0'} + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + pastable@2.2.1: resolution: {integrity: sha512-K4ClMxRKpgN4sXj6VIPPrvor/TMp2yPNCGtfhvV106C73SwefQ3FuegURsH7AQHpqu0WwbvKXRl1HQxF6qax9w==} engines: {node: '>=14.x'} @@ -4205,6 +4465,10 @@ packages: engines: {node: '>=14'} hasBin: true + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + proc-log@2.0.1: resolution: {integrity: sha512-Kcmo2FhfDTXdcbfDH76N7uBYHINxc/8GW7UAVuVP9I+Va3uHSerrnKV6dLooga/gh7GlgzuCCr/eoldnL1muGw==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} @@ -4296,6 +4560,10 @@ packages: resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} engines: {node: '>=6'} + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -4342,6 +4610,9 @@ packages: react: '>=16.8.1' react-dom: '>=16.8.1' + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-markdown@10.1.0: resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==} peerDependencies: @@ -4366,7 +4637,7 @@ packages: resolution: {integrity: sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA==} engines: {node: '>=10'} peerDependencies: - '@types/react': 19.1.11 + '@types/react': '*' react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc peerDependenciesMeta: '@types/react': @@ -4419,6 +4690,10 @@ packages: resolution: {integrity: sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==} engines: {node: '>= 10.13.0'} + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + remark-parse@11.0.0: resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} @@ -4495,6 +4770,9 @@ packages: rope-sequence@1.3.4: resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==} + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -4504,6 +4782,10 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} @@ -4550,6 +4832,9 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} @@ -4557,6 +4842,10 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + slice-ansi@5.0.0: resolution: {integrity: sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==} engines: {node: '>=12'} @@ -4620,6 +4909,12 @@ packages: resolution: {integrity: sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + stream-buffers@2.2.0: resolution: {integrity: sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg==} engines: {node: '>= 0.10.0'} @@ -4666,6 +4961,10 @@ packages: resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} engines: {node: '>=12'} + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + strip-json-comments@5.0.2: resolution: {integrity: sha512-4X2FR3UwhNUE9G49aIsJW5hRRR3GXGTBTZRMfv568O60ojM8HcWjV/VxAxCDW3SUND33O6ZY66ZuRcdkj73q2g==} engines: {node: '>=14.16'} @@ -4674,6 +4973,9 @@ packages: resolution: {integrity: sha512-k55yxKHwaXnpYGsOzg4Vl8+tDrWylxDEpknGjhTiZB8dFRU5rTo9CAzeycivxV3s+zlTKwrs6WxMxR95n26kwg==} engines: {node: '>=0.10.0'} + style-mod@4.1.3: + resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==} + style-to-js@1.1.18: resolution: {integrity: sha512-JFPn62D4kJaPTnhFUI244MThx+FEGbi+9dw1b9yBBQ+1CZpV7QAT8kUtJ7b7EUNdHajjF/0x8fT+16oLJoojLg==} @@ -4701,6 +5003,9 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tailwindcss@3.4.18: resolution: {integrity: sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==} engines: {node: '>=14.0.0'} @@ -4742,6 +5047,27 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.0.3: + resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} + engines: {node: '>=14.0.0'} + + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + tmp@0.0.33: resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} engines: {node: '>=0.6.0'} @@ -4757,9 +5083,21 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -4967,9 +5305,87 @@ packages: terser: optional: true + vite@7.2.2: + resolution: {integrity: sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.0.10: + resolution: {integrity: sha512-2Fqty3MM9CDwOVet/jaQalYlbcjATZwPYGcqpiYQqgQ/dLC7GuHdISKgTYIVF/kaishKxLzleKWWfbSDklyIKg==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.0.10 + '@vitest/browser-preview': 4.0.10 + '@vitest/browser-webdriverio': 4.0.10 + '@vitest/ui': 4.0.10 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + walk-up-path@4.0.0: resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} engines: {node: 20 || >=22} @@ -4987,6 +5403,10 @@ packages: webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + webpack-sources@3.3.3: resolution: {integrity: sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==} engines: {node: '>=10.13.0'} @@ -5001,6 +5421,18 @@ packages: webpack-cli: optional: true + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} @@ -5013,6 +5445,11 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + wrap-ansi@6.2.0: resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} engines: {node: '>=8'} @@ -5032,10 +5469,29 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.18.3: + resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + xmlbuilder@15.1.1: resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} engines: {node: '>=8.0'} + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -5100,6 +5556,8 @@ packages: snapshots: + '@adobe/css-tools@4.4.4': {} + '@ai-sdk/gateway@2.0.0(zod@4.1.12)': dependencies: '@ai-sdk/provider': 2.0.0 @@ -5164,6 +5622,14 @@ snapshots: '@ark/util@0.46.0': {} + '@asamuzakjp/css-color@3.2.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 + '@babel/code-frame@7.27.1': dependencies: '@babel/helper-validator-identifier': 7.27.1 @@ -5253,6 +5719,8 @@ snapshots: '@babel/core': 7.28.4 '@babel/helper-plugin-utils': 7.27.1 + '@babel/runtime@7.28.4': {} + '@babel/template@7.27.2': dependencies: '@babel/code-frame': 7.27.1 @@ -5311,6 +5779,37 @@ snapshots: '@biomejs/cli-win32-x64@2.2.4': optional: true + '@codemirror/state@6.5.2': + dependencies: + '@marijn/find-cluster-break': 1.0.2 + + '@codemirror/view@6.38.8': + dependencies: + '@codemirror/state': 6.5.2 + crelt: 1.0.6 + style-mod: 4.1.3 + w3c-keyname: 2.2.8 + + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-tokenizer@3.0.4': {} + '@dnd-kit/abstract@0.1.21': dependencies: '@dnd-kit/geometry': 0.1.21 @@ -6085,6 +6584,8 @@ snapshots: dependencies: cross-spawn: 7.0.6 + '@marijn/find-cluster-break@1.0.2': {} + '@napi-rs/wasm-runtime@1.0.7': dependencies: '@emnapi/core': 1.6.0 @@ -6259,6 +6760,8 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@polka/url@1.0.0-next.29': {} + '@posthog/agent@1.20.0(zod@4.1.12)': dependencies: '@anthropic-ai/claude-agent-sdk': 0.1.37(zod@4.1.12) @@ -7129,6 +7632,40 @@ snapshots: '@tanstack/query-core': 5.90.3 react: 18.3.1 + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/runtime': 7.28.4 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.9.1': + dependencies: + '@adobe/css-tools': 4.4.4 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.26))(@types/react@18.3.26)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.28.4 + '@testing-library/dom': 10.4.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.26 + '@types/react-dom': 18.3.7(@types/react@18.3.26) + + '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': + dependencies: + '@testing-library/dom': 10.4.1 + '@tiptap/core@3.7.1(@tiptap/pm@3.7.1)': dependencies: '@tiptap/pm': 3.7.1 @@ -7337,6 +7874,8 @@ snapshots: '@types/node': 20.19.21 optional: true + '@types/aria-query@5.0.4': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.28.4 @@ -7365,10 +7904,17 @@ snapshots: '@types/node': 20.19.21 '@types/responselike': 1.0.3 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/debug@4.1.12': dependencies: '@types/ms': 2.1.0 + '@types/deep-eql@4.0.2': {} + '@types/eslint-scope@3.7.7': dependencies: '@types/eslint': 9.6.1 @@ -7474,6 +8020,56 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/expect@4.0.10': + dependencies: + '@standard-schema/spec': 1.0.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.0.10 + '@vitest/utils': 4.0.10 + chai: 6.2.1 + tinyrainbow: 3.0.3 + + '@vitest/mocker@4.0.10(vite@7.2.2(@types/node@20.19.21)(jiti@1.21.7)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))': + dependencies: + '@vitest/spy': 4.0.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.2.2(@types/node@20.19.21)(jiti@1.21.7)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1) + + '@vitest/pretty-format@4.0.10': + dependencies: + tinyrainbow: 3.0.3 + + '@vitest/runner@4.0.10': + dependencies: + '@vitest/utils': 4.0.10 + pathe: 2.0.3 + + '@vitest/snapshot@4.0.10': + dependencies: + '@vitest/pretty-format': 4.0.10 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.0.10': {} + + '@vitest/ui@4.0.10(vitest@4.0.10)': + dependencies: + '@vitest/utils': 4.0.10 + fflate: 0.8.2 + flatted: 3.3.3 + pathe: 2.0.3 + sirv: 3.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vitest: 4.0.10(@types/debug@4.1.12)(@types/node@20.19.21)(@vitest/ui@4.0.10)(jiti@1.21.7)(jsdom@26.1.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1) + + '@vitest/utils@4.0.10': + dependencies: + '@vitest/pretty-format': 4.0.10 + tinyrainbow: 3.0.3 + '@vscode/sudo-prompt@9.3.1': {} '@webassemblyjs/ast@1.14.1': @@ -7582,6 +8178,8 @@ snapshots: transitivePeerDependencies: - supports-color + agent-base@7.1.4: {} + agentkeepalive@4.6.0: dependencies: humanize-ms: 1.2.1 @@ -7639,6 +8237,8 @@ snapshots: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + ansi-styles@6.2.3: {} any-promise@1.3.0: {} @@ -7671,11 +8271,19 @@ snapshots: dependencies: tslib: 2.8.1 + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + arktype@2.1.20: dependencies: '@ark/schema': 0.46.0 '@ark/util': 0.46.0 + assertion-error@2.0.1: {} + async@1.5.2: optional: true @@ -7818,6 +8426,8 @@ snapshots: ccount@2.0.1: {} + chai@6.2.1: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -7962,10 +8572,22 @@ snapshots: cross-zip@4.0.1: {} + css.escape@1.5.1: {} + cssesc@3.0.0: {} + cssstyle@4.6.0: + dependencies: + '@asamuzakjp/css-color': 3.2.0 + rrweb-cssom: 0.8.0 + csstype@3.1.3: {} + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + date-fns@3.6.0: {} debug@2.6.9: @@ -7976,6 +8598,8 @@ snapshots: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + decode-named-character-reference@1.2.0: dependencies: character-entities: 2.0.2 @@ -8030,6 +8654,10 @@ snapshots: dlv@1.1.3: {} + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + dotenv@17.2.3: {} ds-store@0.1.6: @@ -8093,6 +8721,8 @@ snapshots: entities@4.5.0: {} + entities@6.0.1: {} + env-paths@2.2.1: {} environment@1.1.0: {} @@ -8199,6 +8829,10 @@ snapshots: estree-util-is-identifier-name@3.0.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + eventemitter3@5.0.1: {} events@3.3.0: {} @@ -8227,6 +8861,8 @@ snapshots: signal-exit: 4.1.0 strip-final-newline: 3.0.0 + expect-type@1.2.2: {} + exponential-backoff@3.1.3: {} extend@3.0.2: {} @@ -8271,8 +8907,14 @@ snapshots: dependencies: pend: 1.2.0 + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + fflate@0.4.8: {} + fflate@0.8.2: {} + filename-reserved-regex@2.0.0: {} filenamify@4.3.0: @@ -8294,6 +8936,8 @@ snapshots: locate-path: 6.0.0 path-exists: 4.0.0 + flatted@3.3.3: {} + flora-colossus@2.0.0: dependencies: debug: 4.4.3 @@ -8557,6 +9201,10 @@ snapshots: hosted-git-info@2.8.9: {} + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + html-url-attributes@3.0.1: {} http-cache-semantics@4.2.0: {} @@ -8569,6 +9217,13 @@ snapshots: transitivePeerDependencies: - supports-color + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + http2-wrapper@1.0.3: dependencies: quick-lru: 5.1.1 @@ -8581,6 +9236,13 @@ snapshots: transitivePeerDependencies: - supports-color + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + human-signals@5.0.0: {} humanize-ms@1.2.1: @@ -8596,7 +9258,6 @@ snapshots: iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 - optional: true idb-keyval@6.2.2: {} @@ -8684,6 +9345,8 @@ snapshots: is-plain-obj@4.1.0: {} + is-potential-custom-element-name@1.0.1: {} + is-property@1.0.2: optional: true @@ -8719,6 +9382,33 @@ snapshots: dependencies: argparse: 2.0.1 + jsdom@26.1.0: + dependencies: + cssstyle: 4.6.0 + data-urls: 5.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.22 + parse5: 7.3.0 + rrweb-cssom: 0.8.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.1.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.18.3 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -8872,11 +9562,17 @@ snapshots: lru-cache@7.18.3: {} + lz-string@1.5.0: {} + macos-alias@0.2.12: dependencies: nan: 2.23.0 optional: true + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + make-fetch-happen@10.2.1: dependencies: agentkeepalive: 4.6.0 @@ -9174,6 +9870,8 @@ snapshots: mimic-response@3.1.0: {} + min-indent@1.0.1: {} + minimatch@3.1.2: dependencies: brace-expansion: 1.1.12 @@ -9227,6 +9925,8 @@ snapshots: mkdirp@1.0.4: {} + mrmime@2.0.1: {} + ms@2.0.0: {} ms@2.1.3: {} @@ -9306,6 +10006,8 @@ snapshots: dependencies: path-key: 4.0.0 + nwsapi@2.2.22: {} + object-assign@4.1.1: {} object-hash@3.0.0: {} @@ -9428,6 +10130,10 @@ snapshots: dependencies: error-ex: 1.3.4 + parse5@7.3.0: + dependencies: + entities: 6.0.1 + pastable@2.2.1(react@18.3.1): dependencies: '@babel/core': 7.28.4 @@ -9548,6 +10254,12 @@ snapshots: prettier@3.5.3: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + proc-log@2.0.1: {} progress@2.0.3: {} @@ -9673,6 +10385,8 @@ snapshots: punycode.js@2.3.1: {} + punycode@2.3.1: {} + queue-microtask@1.2.3: {} quick-lru@5.1.1: {} @@ -9767,6 +10481,8 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + react-is@17.0.2: {} + react-markdown@10.1.0(@types/react@18.3.26)(react@18.3.1): dependencies: '@types/hast': 3.0.4 @@ -9858,6 +10574,11 @@ snapshots: dependencies: resolve: 1.22.10 + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + remark-parse@11.0.0: dependencies: '@types/mdast': 4.0.4 @@ -9965,6 +10686,8 @@ snapshots: rope-sequence@1.3.4: {} + rrweb-cssom@0.8.0: {} + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -9973,6 +10696,10 @@ snapshots: safer-buffer@2.1.2: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.23.2: dependencies: loose-envify: 1.4.0 @@ -10014,10 +10741,18 @@ snapshots: shebang-regex@3.0.0: {} + siginfo@2.0.0: {} + signal-exit@3.0.7: {} signal-exit@4.1.0: {} + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + slice-ansi@5.0.0: dependencies: ansi-styles: 6.2.3 @@ -10082,6 +10817,10 @@ snapshots: dependencies: minipass: 3.3.6 + stackback@0.0.2: {} + + std-env@3.10.0: {} + stream-buffers@2.2.0: optional: true @@ -10128,12 +10867,18 @@ snapshots: strip-final-newline@3.0.0: {} + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + strip-json-comments@5.0.2: {} strip-outer@1.0.1: dependencies: escape-string-regexp: 1.0.5 + style-mod@4.1.3: {} + style-to-js@1.1.18: dependencies: style-to-object: 1.0.11 @@ -10168,6 +10913,8 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + symbol-tree@3.2.4: {} + tailwindcss@3.4.18(tsx@4.20.6)(yaml@2.8.1): dependencies: '@alloc/quick-lru': 5.2.0 @@ -10231,6 +10978,23 @@ snapshots: dependencies: any-promise: 1.3.0 + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + tinyrainbow@3.0.3: {} + + tldts-core@6.1.86: {} + + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + tmp@0.0.33: dependencies: os-tmpdir: 1.0.2 @@ -10247,8 +11011,18 @@ snapshots: dependencies: is-number: 7.0.0 + totalist@3.0.1: {} + + tough-cookie@5.1.2: + dependencies: + tldts: 6.1.86 + tr46@0.0.3: {} + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + trim-lines@3.0.1: {} trim-repeated@1.0.0: @@ -10432,8 +11206,69 @@ snapshots: fsevents: 2.3.3 terser: 5.44.0 + vite@7.2.2(@types/node@20.19.21)(jiti@1.21.7)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1): + dependencies: + esbuild: 0.25.11 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.52.4 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 20.19.21 + fsevents: 2.3.3 + jiti: 1.21.7 + terser: 5.44.0 + tsx: 4.20.6 + yaml: 2.8.1 + + vitest@4.0.10(@types/debug@4.1.12)(@types/node@20.19.21)(@vitest/ui@4.0.10)(jiti@1.21.7)(jsdom@26.1.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1): + dependencies: + '@vitest/expect': 4.0.10 + '@vitest/mocker': 4.0.10(vite@7.2.2(@types/node@20.19.21)(jiti@1.21.7)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)) + '@vitest/pretty-format': 4.0.10 + '@vitest/runner': 4.0.10 + '@vitest/snapshot': 4.0.10 + '@vitest/spy': 4.0.10 + '@vitest/utils': 4.0.10 + debug: 4.4.3 + es-module-lexer: 1.7.0 + expect-type: 1.2.2 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vite: 7.2.2(@types/node@20.19.21)(jiti@1.21.7)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/debug': 4.1.12 + '@types/node': 20.19.21 + '@vitest/ui': 4.0.10(vitest@4.0.10) + jsdom: 26.1.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + w3c-keyname@2.2.8: {} + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + walk-up-path@4.0.0: {} watchpack@2.4.4: @@ -10449,6 +11284,8 @@ snapshots: webidl-conversions@3.0.1: {} + webidl-conversions@7.0.0: {} + webpack-sources@3.3.3: {} webpack@5.102.1: @@ -10483,6 +11320,17 @@ snapshots: - esbuild - uglify-js + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + whatwg-url@5.0.0: dependencies: tr46: 0.0.3 @@ -10496,6 +11344,11 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + wrap-ansi@6.2.0: dependencies: ansi-styles: 4.3.0 @@ -10522,8 +11375,14 @@ snapshots: wrappy@1.0.2: {} + ws@8.18.3: {} + + xml-name-validator@5.0.0: {} + xmlbuilder@15.1.1: {} + xmlchars@2.2.0: {} + xtend@4.0.2: optional: true diff --git a/src/main/preload.ts b/src/main/preload.ts index da6c98a7e..b7e43fdeb 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -202,6 +202,8 @@ contextBridge.exposeInMainWorld("electronAPI", { }>, ): Promise => ipcRenderer.invoke("save-question-answers", repoPath, taskId, answers), + readRepoFile: (repoPath: string, filePath: string): Promise => + ipcRenderer.invoke("read-repo-file", repoPath, filePath), onOpenSettings: (listener: () => void): (() => void) => { const wrapped = () => listener(); ipcRenderer.on("open-settings", wrapped); diff --git a/src/main/services/fs.ts b/src/main/services/fs.ts index 6bdf19081..6ecc6b794 100644 --- a/src/main/services/fs.ts +++ b/src/main/services/fs.ts @@ -375,4 +375,31 @@ export function registerFsIpc(): void { } }, ); + + ipcMain.handle( + "read-repo-file", + async ( + _event: IpcMainInvokeEvent, + repoPath: string, + filePath: string, + ): Promise => { + try { + const fullPath = path.join(repoPath, filePath); + const resolvedPath = path.resolve(fullPath); + const resolvedRepo = path.resolve(repoPath); + if (!resolvedPath.startsWith(resolvedRepo)) { + throw new Error("Access denied: path outside repository"); + } + + const content = await fsPromises.readFile(fullPath, "utf-8"); + return content; + } catch (error) { + console.error( + `Failed to read file ${filePath} from ${repoPath}:`, + error, + ); + return null; + } + }, + ); } diff --git a/src/renderer/features/code-editor/components/CodeEditorPanel.tsx b/src/renderer/features/code-editor/components/CodeEditorPanel.tsx new file mode 100644 index 000000000..757adcba6 --- /dev/null +++ b/src/renderer/features/code-editor/components/CodeEditorPanel.tsx @@ -0,0 +1,79 @@ +import { CodeMirrorEditor } from "@features/code-editor/components/CodeMirrorEditor"; +import { useTaskData } from "@features/task-detail/hooks/useTaskData"; +import { Box, Flex, Text } from "@radix-ui/themes"; +import type { Task } from "@shared/types"; +import { useQuery } from "@tanstack/react-query"; + +interface CodeEditorPanelProps { + taskId: string; + task: Task; + filePath: string; +} + +export function CodeEditorPanel({ + taskId, + task, + filePath, +}: CodeEditorPanelProps) { + const taskData = useTaskData({ taskId, initialTask: task }); + const repoPath = taskData.repoPath; + + const { + data: fileContent, + isLoading, + error, + } = useQuery({ + queryKey: ["repo-file", repoPath, filePath], + enabled: !!repoPath && !!filePath, + staleTime: 30000, + queryFn: async () => { + if (!window.electronAPI || !repoPath || !filePath) { + return null; + } + const content = await window.electronAPI.readRepoFile(repoPath, filePath); + return content; + }, + }); + + if (!repoPath) { + return ( + + + + No repository path available + + + + ); + } + + if (isLoading) { + return ( + + + + Loading file... + + + + ); + } + + if (error || !fileContent) { + return ( + + + + Failed to load file + + + + ); + } + + return ( + + + + ); +} diff --git a/src/renderer/features/code-editor/components/CodeMirrorEditor.tsx b/src/renderer/features/code-editor/components/CodeMirrorEditor.tsx new file mode 100644 index 000000000..b072a7687 --- /dev/null +++ b/src/renderer/features/code-editor/components/CodeMirrorEditor.tsx @@ -0,0 +1,75 @@ +import type { Extension } from "@codemirror/state"; +import { EditorState } from "@codemirror/state"; +import { + EditorView, + highlightActiveLineGutter, + lineNumbers, +} from "@codemirror/view"; +import { useEffect, useRef } from "react"; + +interface CodeMirrorEditorProps { + content: string; + readOnly?: boolean; +} + +export function CodeMirrorEditor({ + content, + readOnly = false, +}: CodeMirrorEditorProps) { + const editorRef = useRef(null); + const viewRef = useRef(null); + + useEffect(() => { + if (!editorRef.current) { + return; + } + + const extensions: Extension[] = [ + lineNumbers(), + highlightActiveLineGutter(), + EditorView.editable.of(!readOnly), + EditorView.theme({ + "&": { + height: "100%", + fontSize: "14px", + backgroundColor: "var(--color-background)", + }, + ".cm-scroller": { + overflow: "auto", + fontFamily: "var(--code-font-family)", + }, + ".cm-content": { + padding: "16px 0", + }, + ".cm-line": { + padding: "0 16px", + }, + ".cm-gutters": { + backgroundColor: "var(--color-background)", + color: "var(--gray-9)", + border: "none", + }, + ".cm-activeLineGutter": { + backgroundColor: "var(--color-background)", + }, + }), + ]; + + const state = EditorState.create({ + doc: content, + extensions, + }); + + viewRef.current = new EditorView({ + state, + parent: editorRef.current, + }); + + return () => { + viewRef.current?.destroy(); + viewRef.current = null; + }; + }, [content, readOnly]); + + return
; +} diff --git a/src/renderer/features/panels/components/DraggablePanel.tsx b/src/renderer/features/panels/components/DraggablePanel.tsx deleted file mode 100644 index e9adaef3a..000000000 --- a/src/renderer/features/panels/components/DraggablePanel.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { useDraggable, useDroppable } from "@dnd-kit/react"; -import { Box, Flex, Text } from "@radix-ui/themes"; -import type React from "react"; -import { usePanelStore } from "../store/panelStore"; - -interface DraggablePanelProps { - id: string; - label: string; - children: React.ReactNode; -} - -export const DraggablePanel: React.FC = ({ - id, - label, - children, -}) => { - const { ref: draggableRef, isDragging } = useDraggable({ - id: `panel-${id}`, - data: { panelId: id }, - }); - - const { ref: droppableRef, isDropTarget } = useDroppable({ - id: `drop-${id}`, - data: { panelId: id }, - }); - - const draggingTabPanelId = usePanelStore((state) => state.draggingTabPanelId); - - return ( - - - - {label} - - - - - {children} - - - ); -}; diff --git a/src/renderer/features/panels/components/DraggableTab.tsx b/src/renderer/features/panels/components/DraggableTab.tsx index 7ad772ff7..5ddf8f9ac 100644 --- a/src/renderer/features/panels/components/DraggableTab.tsx +++ b/src/renderer/features/panels/components/DraggableTab.tsx @@ -9,6 +9,7 @@ interface DraggableTabProps { label: string; isActive: boolean; index: number; + draggable?: boolean; onSelect: () => void; onClose?: () => void; icon?: React.ReactNode; @@ -20,6 +21,7 @@ export const DraggableTab: React.FC = ({ label, isActive, index, + draggable = true, onSelect, onClose, icon, @@ -28,15 +30,19 @@ export const DraggableTab: React.FC = ({ id: tabId, index, data: { tabId, panelId, type: "tab" }, + disabled: !draggable, }); return ( = ({ variant="ghost" color={isActive ? undefined : "gray"} className="opacity-0 transition-opacity group-hover:opacity-100" + aria-label="Close tab" onClick={(e) => { e.stopPropagation(); onClose(); diff --git a/src/renderer/features/panels/components/GroupNodeRenderer.tsx b/src/renderer/features/panels/components/GroupNodeRenderer.tsx new file mode 100644 index 000000000..6157caf04 --- /dev/null +++ b/src/renderer/features/panels/components/GroupNodeRenderer.tsx @@ -0,0 +1,47 @@ +import React from "react"; +import type { ImperativePanelGroupHandle } from "react-resizable-panels"; +import { PANEL_SIZES } from "../constants/panelConstants"; +import type { GroupPanel, PanelNode } from "../store/panelTypes"; +import { calculateDefaultSize } from "../utils/panelLayoutUtils"; +import { Panel } from "./Panel"; +import { PanelGroup } from "./PanelGroup"; +import { PanelResizeHandle } from "./PanelResizeHandle"; + +interface GroupNodeRendererProps { + node: GroupPanel; + setGroupRef: ( + groupId: string, + ref: ImperativePanelGroupHandle | null, + ) => void; + onLayout: (groupId: string, sizes: number[]) => void; + renderNode: (node: PanelNode) => React.ReactNode; +} + +export const GroupNodeRenderer: React.FC = ({ + node, + setGroupRef, + onLayout, + renderNode, +}) => { + return ( + setGroupRef(node.id, ref)} + direction={node.direction} + onLayout={(sizes) => onLayout(node.id, sizes)} + > + {node.children.map((child, index) => ( + + + {renderNode(child)} + + {index < node.children.length - 1 && } + + ))} + + ); +}; diff --git a/src/renderer/features/panels/components/LeafNodeRenderer.tsx b/src/renderer/features/panels/components/LeafNodeRenderer.tsx new file mode 100644 index 000000000..0765a556d --- /dev/null +++ b/src/renderer/features/panels/components/LeafNodeRenderer.tsx @@ -0,0 +1,48 @@ +import type { Task } from "@shared/types"; +import type React from "react"; +import { useTabInjection } from "../hooks/usePanelLayoutHooks"; +import type { LeafPanel } from "../store/panelTypes"; +import { TabbedPanel } from "./TabbedPanel"; + +interface LeafNodeRendererProps { + node: LeafPanel; + taskId: string; + task: Task; + closeTab: (taskId: string, panelId: string, tabId: string) => void; + draggingTabId: string | null; + draggingTabPanelId: string | null; + onActiveTabChange: (panelId: string, tabId: string) => void; +} + +export const LeafNodeRenderer: React.FC = ({ + node, + taskId, + task, + closeTab, + draggingTabId, + draggingTabPanelId, + onActiveTabChange, +}) => { + const tabs = useTabInjection( + node.content.tabs, + node.id, + taskId, + task, + closeTab, + ); + + const contentWithComponents = { + ...node.content, + tabs, + }; + + return ( + + ); +}; diff --git a/src/renderer/features/panels/components/PanelDropZones.tsx b/src/renderer/features/panels/components/PanelDropZones.tsx index 069887b74..408130f49 100644 --- a/src/renderer/features/panels/components/PanelDropZones.tsx +++ b/src/renderer/features/panels/components/PanelDropZones.tsx @@ -8,6 +8,7 @@ type DropZoneType = SplitDirection | "center"; interface PanelDropZonesProps { panelId: string; isDragging: boolean; + allowSplit?: boolean; // Whether to show edge drop zones for splitting } interface DropZoneProps { @@ -25,13 +26,11 @@ const DropZone: React.FC = ({ panelId, zone, style }) => { return ( ); @@ -71,9 +70,15 @@ const ZONE_CONFIGS: Array<{ zone: DropZoneType; style: React.CSSProperties }> = export const PanelDropZones: React.FC = ({ panelId, isDragging, + allowSplit = true, }) => { if (!isDragging) return null; + // Filter zones based on allowSplit + const visibleZones = allowSplit + ? ZONE_CONFIGS + : ZONE_CONFIGS.filter((config) => config.zone === "center"); + return ( = ({ zIndex: 100, }} > - {ZONE_CONFIGS.map(({ zone, style }) => ( + {visibleZones.map(({ zone, style }) => ( ))} diff --git a/src/renderer/features/panels/components/PanelLayout.tsx b/src/renderer/features/panels/components/PanelLayout.tsx index 238767883..25730b88f 100644 --- a/src/renderer/features/panels/components/PanelLayout.tsx +++ b/src/renderer/features/panels/components/PanelLayout.tsx @@ -1,155 +1,104 @@ -import React, { useEffect, useMemo, useRef, useState } from "react"; -import type { ImperativePanelGroupHandle } from "react-resizable-panels"; -import { mergeTreeContent } from "../store/panelTree"; -import type { PanelNode } from "../store/panelStore"; -import { usePanelStore } from "../store/panelStore"; -import { Panel } from "./Panel"; -import { PanelGroup } from "./PanelGroup"; -import { PanelResizeHandle } from "./PanelResizeHandle"; -import { compilePanelTree } from "./PanelTree"; -import { TabbedPanel } from "./TabbedPanel"; +import { DragDropProvider } from "@dnd-kit/react"; +import type { Task } from "@shared/types"; +import type React from "react"; +import { useCallback, useEffect } from "react"; +import { useDragDropHandlers } from "../hooks/useDragDropHandlers"; +import { + usePanelGroupRefs, + usePanelLayoutState, + usePanelSizeSync, +} from "../hooks/usePanelLayoutHooks"; +import { usePanelLayoutStore } from "../store/panelLayoutStore"; +import type { PanelNode } from "../store/panelTypes"; +import { GroupNodeRenderer } from "./GroupNodeRenderer"; +import { LeafNodeRenderer } from "./LeafNodeRenderer"; interface PanelLayoutProps { - tree: React.ReactElement; + taskId: string; + task: Task; } -const PanelLayoutRenderer: React.FC<{ node: PanelNode }> = ({ node }) => { - const [activeTabs, setActiveTabs] = useState>({}); - const updateSizes = usePanelStore((state) => state.updateSizes); - const groupRefs = useRef>(new Map()); - - const handleSetActiveTab = (panelId: string, tabId: string) => { - setActiveTabs((prev) => ({ ...prev, [panelId]: tabId })); - }; - - const setGroupRef = ( - groupId: string, - ref: ImperativePanelGroupHandle | null, - ) => { - if (ref) { - groupRefs.current.set(groupId, ref); - } else { - groupRefs.current.delete(groupId); - } - }; - - const renderNode = (currentNode: PanelNode): React.ReactNode => { - - if (currentNode.type === "leaf") { - const activeTabId = - activeTabs[currentNode.id] || currentNode.content.activeTabId; - const content = { - ...currentNode.content, - activeTabId, - }; - - return ( - - ); - } - - if (currentNode.type === "group") { - return ( - setGroupRef(currentNode.id, ref)} - direction={currentNode.direction} - onLayout={(sizes) => { - // Only update store, don't normalize here - // The library is the source of truth for sizes during user interaction - updateSizes(currentNode.id, sizes); - }} - > - {currentNode.children.map((child, index) => ( - - - {renderNode(child)} - - {index < currentNode.children.length - 1 && } - - ))} - - ); - } - - return null; - }; - - useEffect(() => { - const syncSizesToLibrary = (currentNode: PanelNode) => { - if (currentNode.type === "group" && currentNode.sizes) { - const groupRef = groupRefs.current.get(currentNode.id); - if (groupRef) { - // Get current layout from library - const currentLayout = groupRef.getLayout(); - - // Only update if sizes are significantly different (avoid feedback loops) - const isDifferent = currentLayout.some( - (size, i) => Math.abs(size - (currentNode.sizes?.[i] ?? 0)) > 0.1, - ); - - if ( - isDifferent && - currentNode.sizes.length === currentLayout.length - ) { - groupRef.setLayout(currentNode.sizes); - } - } +const PanelLayoutRenderer: React.FC<{ + node: PanelNode; + taskId: string; + task: Task; +}> = ({ node, taskId, task }) => { + const layoutState = usePanelLayoutState(taskId); + const { groupRefs, setGroupRef } = usePanelGroupRefs(); + + usePanelSizeSync(node, groupRefs.current); + + const handleSetActiveTab = useCallback( + (panelId: string, tabId: string) => { + layoutState.setActiveTab(taskId, panelId, tabId); + }, + [layoutState, taskId], + ); + + const handleLayout = useCallback( + (groupId: string, sizes: number[]) => { + layoutState.updateSizes(taskId, groupId, sizes); + }, + [layoutState, taskId], + ); + + const renderNode = useCallback( + (currentNode: PanelNode): React.ReactNode => { + if (currentNode.type === "leaf") { + return ( + + ); + } - // Recursively sync child groups - currentNode.children.forEach(syncSizesToLibrary); + if (currentNode.type === "group") { + return ( + + ); } - }; - syncSizesToLibrary(node); - }, [node]); + return null; + }, + [taskId, task, layoutState, handleSetActiveTab, setGroupRef, handleLayout], + ); return <>{renderNode(node)}; }; +export const PanelLayout: React.FC = ({ taskId, task }) => { + const layout = usePanelLayoutStore((state) => state.getLayout(taskId)); + const initializeTask = usePanelLayoutStore((state) => state.initializeTask); + const dragDropHandlers = useDragDropHandlers(taskId); -export const PanelLayout: React.FC = ({ tree }) => { - const compiledNode = useMemo(() => compilePanelTree(tree), [tree]); - const root = usePanelStore((state) => state.root); - const compiledRef = useRef(compiledNode); - const isUpdatingRef = useRef(false); - - // Track if compiled node changed - const nodeChanged = compiledRef.current !== compiledNode; - if (nodeChanged) { - compiledRef.current = compiledNode; - } - - // Initialize or update store synchronously during render - if (nodeChanged && !isUpdatingRef.current) { - isUpdatingRef.current = true; - - const currentRoot = usePanelStore.getState().root; - if (currentRoot === null) { - // First time - initialize - usePanelStore.getState().setRoot(compiledNode); - } else { - // Update components while preserving layout - const merged = mergeTreeContent(currentRoot, compiledNode); - usePanelStore.getState().setRoot(merged); + useEffect(() => { + if (!layout) { + initializeTask(taskId); } - - // Reset flag after render completes - Promise.resolve().then(() => { - isUpdatingRef.current = false; - }); + }, [taskId, layout, initializeTask]); + + if (!layout) { + return null; } - return root ? : null; -}; \ No newline at end of file + return ( + + + + ); +}; diff --git a/src/renderer/features/panels/components/PanelTree.tsx b/src/renderer/features/panels/components/PanelTree.tsx index 23d653360..c3c0c1ce3 100644 --- a/src/renderer/features/panels/components/PanelTree.tsx +++ b/src/renderer/features/panels/components/PanelTree.tsx @@ -23,6 +23,7 @@ export interface PanelGroupTreeProps { export interface PanelLeafProps { showTabs?: boolean; droppable?: boolean; + activeTabId?: string; children: React.ReactNode; } @@ -83,7 +84,12 @@ function compileNode(element: React.ReactElement, path: string): PanelNode { } if (isPanelLeaf(element)) { - const { showTabs = true, droppable = true, children } = element.props; + const { + showTabs = true, + droppable = true, + activeTabId, + children, + } = element.props; const childArray = React.Children.toArray(children); const tabs: Tab[] = []; @@ -131,7 +137,7 @@ function compileNode(element: React.ReactElement, path: string): PanelNode { content: { id: path, tabs, - activeTabId: firstTabId || "", + activeTabId: activeTabId || firstTabId || "", showTabs, droppable, }, diff --git a/src/renderer/features/panels/components/TabbedPanel.tsx b/src/renderer/features/panels/components/TabbedPanel.tsx index 4f0b84135..3e097c552 100644 --- a/src/renderer/features/panels/components/TabbedPanel.tsx +++ b/src/renderer/features/panels/components/TabbedPanel.tsx @@ -2,7 +2,6 @@ import { useDroppable } from "@dnd-kit/react"; import { Box, Flex } from "@radix-ui/themes"; import type React from "react"; import type { PanelContent } from "../store/panelStore"; -import { usePanelStore } from "../store/panelStore"; import { DraggableTab } from "./DraggableTab"; import { PanelDropZones } from "./PanelDropZones"; @@ -10,24 +9,24 @@ interface TabbedPanelProps { panelId: string; content: PanelContent; onActiveTabChange?: (panelId: string, tabId: string) => void; + draggingTabId?: string | null; + draggingTabPanelId?: string | null; } export const TabbedPanel: React.FC = ({ panelId, content, onActiveTabChange, + draggingTabId = null, + draggingTabPanelId = null, }) => { - const { setActiveTab, closeTab } = usePanelStore(); - const activeTab = content.tabs.find((tab) => tab.id === content.activeTabId); - const draggingTabId = usePanelStore((state) => state.draggingTabId); const handleCloseTab = (tabId: string) => { const tab = content.tabs.find((t) => t.id === tabId); if (tab?.onClose) { tab.onClose(); } - closeTab(panelId, tabId); }; const { ref: tabBarRef } = useDroppable({ @@ -56,12 +55,9 @@ export const TabbedPanel: React.FC = ({ label={tab.label} isActive={tab.id === content.activeTabId} index={index} + draggable={tab.draggable} onSelect={() => { - if (onActiveTabChange) { - onActiveTabChange(panelId, tab.id); - } else { - setActiveTab(panelId, tab.id); - } + onActiveTabChange?.(panelId, tab.id); tab.onSelect?.(); }} onClose={ @@ -92,7 +88,17 @@ export const TabbedPanel: React.FC = ({ )} {content.droppable && ( - + 1 tab (same-panel split), OR + // 2. Dragging from a different panel (cross-panel split) + content.tabs.length > 1 || + (draggingTabPanelId !== null && draggingTabPanelId !== panelId) + } + /> )} diff --git a/src/renderer/features/panels/components/index.tsx b/src/renderer/features/panels/components/index.tsx deleted file mode 100644 index 1a63ce250..000000000 --- a/src/renderer/features/panels/components/index.tsx +++ /dev/null @@ -1,16 +0,0 @@ -export { DraggablePanel } from "./DraggablePanel"; -export { DraggableTab } from "./DraggableTab"; -export { Panel } from "./Panel"; -export { PanelDropZones } from "./PanelDropZones"; -export { PanelGroup } from "./PanelGroup"; -export { PanelLayout } from "./PanelLayout"; -export { PanelResizeHandle } from "./PanelResizeHandle"; -export { - PanelGroupTree, - type PanelGroupTreeProps, - PanelLeaf, - type PanelLeafProps, - PanelTab, - type PanelTabProps, -} from "./PanelTree"; -export { TabbedPanel } from "./TabbedPanel"; diff --git a/src/renderer/features/panels/constants/panelConstants.ts b/src/renderer/features/panels/constants/panelConstants.ts new file mode 100644 index 000000000..af2f8cee8 --- /dev/null +++ b/src/renderer/features/panels/constants/panelConstants.ts @@ -0,0 +1,27 @@ +export const PANEL_SIZES = { + MIN_PANEL_SIZE: 15, + DEFAULT_SPLIT: [70, 30] as const, + EVEN_SPLIT: [50, 50] as const, + SIZE_DIFF_THRESHOLD: 0.1, +} as const; + +export const UI_SIZES = { + TAB_HEIGHT: 40, + TAB_LABEL_MAX_WIDTH: 200, + DROP_ZONE_SIZE: "20%", +} as const; + +export const DEFAULT_PANEL_IDS = { + ROOT: "root", + MAIN_PANEL: "main-panel", + RIGHT_GROUP: "right-group", + DETAILS_PANEL: "details-panel", + FILES_PANEL: "files-panel", +} as const; + +export const DEFAULT_TAB_IDS = { + LOGS: "logs", + SHELL: "shell", + DETAILS: "details", + FILES: "files", +} as const; diff --git a/src/renderer/features/panels/hooks/useDragDropHandlers.ts b/src/renderer/features/panels/hooks/useDragDropHandlers.ts index 2e6792962..5f68e147d 100644 --- a/src/renderer/features/panels/hooks/useDragDropHandlers.ts +++ b/src/renderer/features/panels/hooks/useDragDropHandlers.ts @@ -1,4 +1,8 @@ -import { type SplitDirection, usePanelStore } from "../store/panelStore"; +import { + type SplitDirection, + usePanelLayoutStore, +} from "../store/panelLayoutStore"; +import { findPanelById } from "../store/panelStoreHelpers"; const isSplitDirection = (zone: string): zone is SplitDirection => { return ( @@ -6,19 +10,19 @@ const isSplitDirection = (zone: string): zone is SplitDirection => { ); }; -export const useDragDropHandlers = () => { - const { moveTab, splitPanel, setDraggingTab, reorderTabs, findPanel } = - usePanelStore(); +export const useDragDropHandlers = (taskId: string) => { + const { moveTab, splitPanel, setDraggingTab, reorderTabs, getLayout } = + usePanelLayoutStore(); const handleDragStart = (event: any) => { const data = event.operation.source?.data; if (data?.type !== "tab" || !data.tabId || !data.panelId) return; - setDraggingTab(data.tabId, data.panelId); + setDraggingTab(taskId, data.tabId, data.panelId); }; const handleDragEnd = (event: any) => { - setDraggingTab(null, null); + usePanelLayoutStore.getState().clearDraggingTab(taskId); if (event.canceled) return; @@ -39,7 +43,7 @@ export const useDragDropHandlers = () => { targetIndex !== undefined && sourceIndex !== targetIndex ) { - reorderTabs(sourceData.panelId, sourceIndex, targetIndex); + reorderTabs(taskId, sourceData.panelId, sourceIndex, targetIndex); } return; } @@ -50,13 +54,16 @@ export const useDragDropHandlers = () => { targetData?.type === "tab-bar" && sourceData.panelId === targetData.panelId ) { - const panel = findPanel(sourceData.panelId); + const layout = getLayout(taskId); + const panel = layout + ? findPanelById(layout.panelTree, sourceData.panelId) + : null; if (panel && panel.type === "leaf") { const sourceIndex = event.operation.source?.index; const targetIndex = panel.content.tabs.length - 1; if (sourceIndex !== undefined && sourceIndex !== targetIndex) { - reorderTabs(sourceData.panelId, sourceIndex, targetIndex); + reorderTabs(taskId, sourceData.panelId, sourceIndex, targetIndex); } } return; @@ -78,9 +85,9 @@ export const useDragDropHandlers = () => { const { panelId: targetPanelId, zone } = targetData; if (zone === "center") { - moveTab(tabId, sourcePanelId, targetPanelId); + moveTab(taskId, tabId, sourcePanelId, targetPanelId); } else if (isSplitDirection(zone)) { - splitPanel(tabId, sourcePanelId, targetPanelId, zone); + splitPanel(taskId, tabId, sourcePanelId, targetPanelId, zone); } }; diff --git a/src/renderer/features/panels/hooks/usePanelLayoutHooks.tsx b/src/renderer/features/panels/hooks/usePanelLayoutHooks.tsx new file mode 100644 index 000000000..dfb1c3da7 --- /dev/null +++ b/src/renderer/features/panels/hooks/usePanelLayoutHooks.tsx @@ -0,0 +1,100 @@ +import { TabContentRenderer } from "@features/task-detail/components/TabContentRenderer"; +import type { Task } from "@shared/types"; +import { useCallback, useEffect, useMemo, useRef } from "react"; +import type { ImperativePanelGroupHandle } from "react-resizable-panels"; +import { usePanelLayoutStore } from "../store/panelLayoutStore"; +import type { PanelNode, Tab } from "../store/panelTypes"; +import { shouldUpdateSizes } from "../utils/panelLayoutUtils"; + +export interface PanelLayoutState { + updateSizes: (taskId: string, groupId: string, sizes: number[]) => void; + setActiveTab: (taskId: string, panelId: string, tabId: string) => void; + closeTab: (taskId: string, panelId: string, tabId: string) => void; + draggingTabId: string | null; + draggingTabPanelId: string | null; +} + +export function usePanelLayoutState(taskId: string): PanelLayoutState { + return usePanelLayoutStore( + useCallback( + (state) => ({ + updateSizes: state.updateSizes, + setActiveTab: state.setActiveTab, + closeTab: state.closeTab, + draggingTabId: state.getLayout(taskId)?.draggingTabId ?? null, + draggingTabPanelId: state.getLayout(taskId)?.draggingTabPanelId ?? null, + }), + [taskId], + ), + ); +} + +export function usePanelGroupRefs() { + const groupRefs = useRef>(new Map()); + + const setGroupRef = useCallback( + (groupId: string, ref: ImperativePanelGroupHandle | null) => { + if (ref) { + groupRefs.current.set(groupId, ref); + } else { + groupRefs.current.delete(groupId); + } + }, + [], + ); + + return { groupRefs, setGroupRef }; +} + +export function useTabInjection( + tabs: Tab[], + panelId: string, + taskId: string, + task: Task, + closeTab: (taskId: string, panelId: string, tabId: string) => void, +): Tab[] { + return useMemo( + () => + tabs.map((tab) => ({ + ...tab, + component: ( + + ), + onClose: tab.closeable + ? () => { + closeTab(taskId, panelId, tab.id); + } + : undefined, + })), + [tabs, panelId, taskId, task, closeTab], + ); +} + +function syncSizesToLibrary( + node: PanelNode, + groupRefs: Map, +): void { + if (node.type === "group" && node.sizes) { + const groupRef = groupRefs.get(node.id); + if (groupRef) { + const currentLayout = groupRef.getLayout(); + + if (shouldUpdateSizes(currentLayout, node.sizes)) { + groupRef.setLayout(node.sizes); + } + } + + for (const child of node.children) { + syncSizesToLibrary(child, groupRefs); + } + } +} + +export function usePanelSizeSync( + node: PanelNode, + groupRefs: Map, +): void { + useEffect(() => { + syncSizesToLibrary(node, groupRefs); + }, [node, groupRefs]); +} diff --git a/src/renderer/features/panels/index.ts b/src/renderer/features/panels/index.ts index 2bdfb54c4..28bb037a5 100644 --- a/src/renderer/features/panels/index.ts +++ b/src/renderer/features/panels/index.ts @@ -5,6 +5,7 @@ export { PanelTab, } from "./components/PanelTree"; export { useDragDropHandlers } from "./hooks/useDragDropHandlers"; +export { usePanelLayoutStore } from "./store/panelLayoutStore"; export { usePanelStore } from "./store/panelStore"; export type { diff --git a/src/renderer/features/panels/store/panelLayoutStore.test.ts b/src/renderer/features/panels/store/panelLayoutStore.test.ts new file mode 100644 index 000000000..36930eb4c --- /dev/null +++ b/src/renderer/features/panels/store/panelLayoutStore.test.ts @@ -0,0 +1,511 @@ +import { + assertActiveTab, + assertActiveTabInNestedPanel, + assertGroupStructure, + assertPanelLayout, + assertTabCount, + assertTabInNestedPanel, + closeMultipleTabs, + findPanelById, + type GroupNode, + getLayout, + getNestedPanel, + getPanelTree, + openMultipleFiles, + splitAndAssert, + testSizePreservation, + withRootGroup, +} from "@test/panelTestHelpers"; +import { beforeEach, describe, expect, it } from "vitest"; +import { usePanelLayoutStore } from "./panelLayoutStore"; + +describe("panelLayoutStore", () => { + beforeEach(() => { + usePanelLayoutStore.getState().clearAllLayouts(); + localStorage.clear(); + }); + + describe("initial state", () => { + it("returns null for non-existent task", () => { + const layout = usePanelLayoutStore.getState().getLayout("task-1"); + expect(layout).toBeNull(); + }); + + it("creates default layout when task is initialized", () => { + usePanelLayoutStore.getState().initializeTask("task-1"); + const layout = usePanelLayoutStore.getState().getLayout("task-1"); + + expect(layout).not.toBeNull(); + expect(layout?.panelTree.type).toBe("group"); + }); + + it("creates default layout with correct structure", () => { + usePanelLayoutStore.getState().initializeTask("task-1"); + + withRootGroup("task-1", (root: GroupNode) => { + assertGroupStructure(root, { + direction: "horizontal", + childCount: 2, + sizes: [70, 30], + }); + + assertPanelLayout(root, [ + { + panelId: "main-panel", + expectedTabs: ["logs", "shell"], + activeTab: "logs", + }, + ]); + }); + }); + }); + + describe("openFile", () => { + beforeEach(() => { + usePanelLayoutStore.getState().initializeTask("task-1"); + }); + + it("adds file tab to main panel", () => { + usePanelLayoutStore.getState().openFile("task-1", "src/App.tsx"); + + assertTabCount(getPanelTree("task-1"), "main-panel", 3); + assertPanelLayout(getPanelTree("task-1"), [ + { + panelId: "main-panel", + expectedTabs: ["logs", "shell", "file-src/App.tsx"], + }, + ]); + }); + + it("sets newly opened file as active", () => { + usePanelLayoutStore.getState().openFile("task-1", "src/App.tsx"); + + assertActiveTab(getPanelTree("task-1"), "main-panel", "file-src/App.tsx"); + }); + + it("does not duplicate file if already open", () => { + usePanelLayoutStore.getState().openFile("task-1", "src/App.tsx"); + usePanelLayoutStore.getState().openFile("task-1", "src/App.tsx"); + + const panel = findPanelById(getPanelTree("task-1"), "main-panel"); + const fileTabs = panel?.content.tabs.filter((t: { id: string }) => + t.id.startsWith("file-"), + ); + expect(fileTabs).toHaveLength(1); + }); + + it("sets existing file as active when opened again", () => { + openMultipleFiles("task-1", ["src/App.tsx", "src/Other.tsx"]); + usePanelLayoutStore.getState().openFile("task-1", "src/App.tsx"); + + assertActiveTab(getPanelTree("task-1"), "main-panel", "file-src/App.tsx"); + }); + + it("tracks open files in metadata", () => { + openMultipleFiles("task-1", ["src/App.tsx", "src/Other.tsx"]); + + const layout = getLayout("task-1"); + expect(layout.openFiles).toContain("src/App.tsx"); + expect(layout.openFiles).toContain("src/Other.tsx"); + expect(layout.openFiles).toHaveLength(2); + }); + }); + + describe("openArtifact", () => { + beforeEach(() => { + usePanelLayoutStore.getState().initializeTask("task-1"); + }); + + it("adds artifact tab to main panel", () => { + usePanelLayoutStore.getState().openArtifact("task-1", "plan.md"); + + const panel = findPanelById(getPanelTree("task-1"), "main-panel"); + const artifactTab = panel?.content.tabs.find((t: { id: string }) => + t.id.startsWith("artifact-"), + ); + expect(artifactTab?.id).toBe("artifact-plan.md"); + }); + + it("tracks open artifacts in metadata", () => { + usePanelLayoutStore.getState().openArtifact("task-1", "plan.md"); + usePanelLayoutStore.getState().openArtifact("task-1", "notes.md"); + + const layout = getLayout("task-1"); + expect(layout.openArtifacts).toContain("plan.md"); + expect(layout.openArtifacts).toContain("notes.md"); + expect(layout.openArtifacts).toHaveLength(2); + }); + }); + + describe("closeTab", () => { + beforeEach(() => { + usePanelLayoutStore.getState().initializeTask("task-1"); + openMultipleFiles("task-1", ["src/App.tsx", "src/Other.tsx"]); + }); + + it("removes tab from panel", () => { + usePanelLayoutStore + .getState() + .closeTab("task-1", "main-panel", "file-src/App.tsx"); + + const panel = findPanelById(getPanelTree("task-1"), "main-panel"); + const fileTab = panel?.content.tabs.find( + (t: { id: string }) => t.id === "file-src/App.tsx", + ); + expect(fileTab).toBeUndefined(); + }); + + it("removes file from metadata", () => { + usePanelLayoutStore + .getState() + .closeTab("task-1", "main-panel", "file-src/App.tsx"); + + const layout = getLayout("task-1"); + expect(layout.openFiles).not.toContain("src/App.tsx"); + expect(layout.openFiles).toContain("src/Other.tsx"); + }); + + it("auto-selects next tab when closing active tab", () => { + usePanelLayoutStore + .getState() + .closeTab("task-1", "main-panel", "file-src/Other.tsx"); + + assertActiveTab(getPanelTree("task-1"), "main-panel", "file-src/App.tsx"); + }); + + it("falls back to shell when last file tab closed", () => { + closeMultipleTabs("task-1", "main-panel", [ + "file-src/App.tsx", + "file-src/Other.tsx", + ]); + + assertActiveTab(getPanelTree("task-1"), "main-panel", "shell"); + }); + }); + + describe("setActiveTab", () => { + beforeEach(() => { + usePanelLayoutStore.getState().initializeTask("task-1"); + openMultipleFiles("task-1", ["src/App.tsx", "src/Other.tsx"]); + }); + + it("changes active tab in panel", () => { + usePanelLayoutStore + .getState() + .setActiveTab("task-1", "main-panel", "file-src/App.tsx"); + + assertActiveTab(getPanelTree("task-1"), "main-panel", "file-src/App.tsx"); + }); + }); + + describe("task isolation", () => { + it("keeps tasks isolated from each other", () => { + usePanelLayoutStore.getState().initializeTask("task-1"); + usePanelLayoutStore.getState().initializeTask("task-2"); + + openMultipleFiles("task-1", ["src/App.tsx"]); + openMultipleFiles("task-2", ["src/Other.tsx"]); + + const layout1 = getLayout("task-1"); + const layout2 = getLayout("task-2"); + + expect(layout1.openFiles).toContain("src/App.tsx"); + expect(layout1.openFiles).not.toContain("src/Other.tsx"); + + expect(layout2.openFiles).toContain("src/Other.tsx"); + expect(layout2.openFiles).not.toContain("src/App.tsx"); + }); + }); + + describe("panel size persistence", () => { + beforeEach(() => { + usePanelLayoutStore.getState().initializeTask("task-1"); + }); + + it( + "preserves custom panel sizes when opening a file", + testSizePreservation("opening a file", () => { + openMultipleFiles("task-1", ["src/App.tsx"]); + }, [50, 50]), + ); + + it( + "preserves custom panel sizes when switching tabs", + testSizePreservation("switching tabs", () => { + openMultipleFiles("task-1", ["src/App.tsx", "src/Other.tsx"]); + usePanelLayoutStore + .getState() + .setActiveTab("task-1", "main-panel", "file-src/App.tsx"); + }, [60, 40]), + ); + + it( + "preserves custom panel sizes when closing tabs", + testSizePreservation("closing tabs", () => { + openMultipleFiles("task-1", ["src/App.tsx", "src/Other.tsx"]); + usePanelLayoutStore + .getState() + .closeTab("task-1", "main-panel", "file-src/Other.tsx"); + }), + ); + + it( + "preserves custom panel sizes in nested groups when splitting panels", + testSizePreservation("splitting panels", () => { + openMultipleFiles("task-1", ["src/App.tsx", "src/Other.tsx"]); + usePanelLayoutStore + .getState() + .splitPanel( + "task-1", + "file-src/Other.tsx", + "main-panel", + "main-panel", + "right", + ); + }, [65, 35]), + ); + }); + + describe("persistence", () => { + it("persists state to localStorage", () => { + usePanelLayoutStore.getState().initializeTask("task-1"); + usePanelLayoutStore.getState().openFile("task-1", "src/App.tsx"); + + const storedData = localStorage.getItem("panel-layout-store"); + expect(storedData).not.toBeNull(); + + const parsed = JSON.parse(storedData!); + expect(parsed.state.taskLayouts["task-1"]).toBeDefined(); + expect(parsed.state.taskLayouts["task-1"].openFiles).toContain( + "src/App.tsx", + ); + }); + + it("restores state from localStorage", () => { + usePanelLayoutStore.getState().initializeTask("task-1"); + usePanelLayoutStore.getState().openFile("task-1", "src/App.tsx"); + + const storedData = localStorage.getItem("panel-layout-store"); + + usePanelLayoutStore.getState().clearAllLayouts(); + expect(usePanelLayoutStore.getState().getLayout("task-1")).toBeNull(); + + if (storedData) { + localStorage.setItem("panel-layout-store", storedData); + usePanelLayoutStore.persist.rehydrate(); + } + + const restoredLayout = getLayout("task-1"); + expect(restoredLayout.openFiles).toContain("src/App.tsx"); + }); + }); + + describe("drag state", () => { + beforeEach(() => { + usePanelLayoutStore.getState().initializeTask("task-1"); + usePanelLayoutStore.getState().openFile("task-1", "src/App.tsx"); + }); + + it("tracks dragging tab state", () => { + usePanelLayoutStore + .getState() + .setDraggingTab("task-1", "file-src/App.tsx", "main-panel"); + + const layout = getLayout("task-1"); + expect(layout.draggingTabId).toBe("file-src/App.tsx"); + expect(layout.draggingTabPanelId).toBe("main-panel"); + }); + + it("clears dragging tab state", () => { + usePanelLayoutStore + .getState() + .setDraggingTab("task-1", "file-src/App.tsx", "main-panel"); + usePanelLayoutStore.getState().clearDraggingTab("task-1"); + + const layout = getLayout("task-1"); + expect(layout.draggingTabId).toBeNull(); + expect(layout.draggingTabPanelId).toBeNull(); + }); + + it("isolates drag state between tasks", () => { + usePanelLayoutStore.getState().initializeTask("task-2"); + usePanelLayoutStore + .getState() + .setDraggingTab("task-1", "file-src/App.tsx", "main-panel"); + + const layout1 = getLayout("task-1"); + const layout2 = getLayout("task-2"); + + expect(layout1.draggingTabId).toBe("file-src/App.tsx"); + expect(layout2.draggingTabId).toBeNull(); + }); + }); + + describe("reorderTabs", () => { + beforeEach(() => { + usePanelLayoutStore.getState().initializeTask("task-1"); + openMultipleFiles("task-1", [ + "src/App.tsx", + "src/Other.tsx", + "src/Third.tsx", + ]); + }); + + it("reorders tabs within a panel", () => { + usePanelLayoutStore.getState().reorderTabs("task-1", "main-panel", 2, 3); + + const panel = findPanelById(getPanelTree("task-1"), "main-panel"); + const tabIds = panel?.content.tabs.map((t: { id: string }) => t.id); + expect(tabIds?.[2]).toBe("file-src/Other.tsx"); + expect(tabIds?.[3]).toBe("file-src/App.tsx"); + }); + + it("preserves active tab after reorder", () => { + usePanelLayoutStore + .getState() + .setActiveTab("task-1", "main-panel", "file-src/App.tsx"); + usePanelLayoutStore.getState().reorderTabs("task-1", "main-panel", 0, 2); + + assertActiveTabInNestedPanel("task-1", "file-src/App.tsx", "left"); + }); + }); + + describe("moveTab", () => { + beforeEach(() => { + usePanelLayoutStore.getState().initializeTask("task-1"); + usePanelLayoutStore.getState().openFile("task-1", "src/App.tsx"); + }); + + it("moves tab to different panel", () => { + usePanelLayoutStore + .getState() + .moveTab("task-1", "file-src/App.tsx", "main-panel", "details-panel"); + + assertTabInNestedPanel("task-1", "file-src/App.tsx", false, "left"); + assertTabInNestedPanel( + "task-1", + "file-src/App.tsx", + true, + "right", + "left", + ); + }); + + it("sets moved tab as active in target panel", () => { + usePanelLayoutStore + .getState() + .moveTab("task-1", "file-src/App.tsx", "main-panel", "details-panel"); + + assertActiveTabInNestedPanel( + "task-1", + "file-src/App.tsx", + "right", + "left", + ); + }); + }); + + describe("splitPanel", () => { + beforeEach(() => { + usePanelLayoutStore.getState().initializeTask("task-1"); + usePanelLayoutStore.getState().openFile("task-1", "src/App.tsx"); + }); + + it.each([ + ["right", "horizontal"], + ["left", "horizontal"], + ["top", "vertical"], + ["bottom", "vertical"], + ] as const)( + "splits panel %s creates %s layout", + (direction, expectedDirection) => { + splitAndAssert( + "task-1", + "file-src/App.tsx", + direction, + expectedDirection, + ); + }, + ); + + it("moves tab to new split panel", () => { + usePanelLayoutStore + .getState() + .splitPanel( + "task-1", + "file-src/App.tsx", + "main-panel", + "main-panel", + "right", + ); + + assertTabInNestedPanel( + "task-1", + "file-src/App.tsx", + true, + "left", + "right", + ); + assertActiveTabInNestedPanel( + "task-1", + "file-src/App.tsx", + "left", + "right", + ); + }); + }); + + describe("updateSizes", () => { + beforeEach(() => { + usePanelLayoutStore.getState().initializeTask("task-1"); + }); + + it("updates panel group sizes", () => { + usePanelLayoutStore.getState().updateSizes("task-1", "root", [60, 40]); + + withRootGroup("task-1", (root: GroupNode) => { + expect(root.sizes).toEqual([60, 40]); + }); + }); + + it("updates nested group sizes", () => { + usePanelLayoutStore + .getState() + .updateSizes("task-1", "right-group", [30, 70]); + + const rightGroup = getNestedPanel("task-1", "right"); + assertGroupStructure(rightGroup, { + direction: "vertical", + childCount: 2, + sizes: [30, 70], + }); + }); + }); + + describe("tree cleanup", () => { + beforeEach(() => { + usePanelLayoutStore.getState().initializeTask("task-1"); + usePanelLayoutStore.getState().openFile("task-1", "src/App.tsx"); + }); + + it("removes empty panels after closing all tabs", () => { + usePanelLayoutStore + .getState() + .splitPanel( + "task-1", + "file-src/App.tsx", + "main-panel", + "main-panel", + "right", + ); + + const newPanel = getNestedPanel("task-1", "left", "right"); + usePanelLayoutStore + .getState() + .closeTab("task-1", newPanel.id, "file-src/App.tsx"); + + const updatedLeftPanel = getNestedPanel("task-1", "left"); + expect(updatedLeftPanel.type).toBe("leaf"); + }); + }); +}); diff --git a/src/renderer/features/panels/store/panelLayoutStore.ts b/src/renderer/features/panels/store/panelLayoutStore.ts new file mode 100644 index 000000000..8c2c302a3 --- /dev/null +++ b/src/renderer/features/panels/store/panelLayoutStore.ts @@ -0,0 +1,471 @@ +import { persist } from "zustand/middleware"; +import { createWithEqualityFn } from "zustand/traditional"; +import { + DEFAULT_PANEL_IDS, + DEFAULT_TAB_IDS, + PANEL_SIZES, +} from "../constants/panelConstants"; +import { + addNewTabToPanel, + applyCleanupWithFallback, + createArtifactTabId, + createFileTabId, + generatePanelId, + getLeafPanel, + getSplitConfig, + selectNextTabAfterClose, + updateMetadataForTab, + updateTaskLayout, +} from "./panelStoreHelpers"; +import { + addTabToPanel, + cleanupNode, + findTabInPanel, + findTabInTree, + removeTabFromPanel, + setActiveTabInPanel, + updateTreeNode, +} from "./panelTree"; +import type { PanelNode } from "./panelTypes"; + +export interface TaskLayout { + panelTree: PanelNode; + openFiles: string[]; + openArtifacts: string[]; + draggingTabId: string | null; + draggingTabPanelId: string | null; +} + +export type SplitDirection = "left" | "right" | "top" | "bottom"; + +export interface PanelLayoutStore { + taskLayouts: Record; + + getLayout: (taskId: string) => TaskLayout | null; + initializeTask: (taskId: string) => void; + openFile: (taskId: string, filePath: string) => void; + openArtifact: (taskId: string, fileName: string) => void; + closeTab: (taskId: string, panelId: string, tabId: string) => void; + setActiveTab: (taskId: string, panelId: string, tabId: string) => void; + setDraggingTab: ( + taskId: string, + tabId: string | null, + panelId: string | null, + ) => void; + clearDraggingTab: (taskId: string) => void; + reorderTabs: ( + taskId: string, + panelId: string, + sourceIndex: number, + targetIndex: number, + ) => void; + moveTab: ( + taskId: string, + tabId: string, + sourcePanelId: string, + targetPanelId: string, + ) => void; + splitPanel: ( + taskId: string, + tabId: string, + sourcePanelId: string, + targetPanelId: string, + direction: SplitDirection, + ) => void; + updateSizes: (taskId: string, groupId: string, sizes: number[]) => void; + clearAllLayouts: () => void; +} + +function createDefaultPanelTree(): PanelNode { + return { + type: "group", + id: DEFAULT_PANEL_IDS.ROOT, + direction: "horizontal", + sizes: [...PANEL_SIZES.DEFAULT_SPLIT], + children: [ + { + type: "leaf", + id: DEFAULT_PANEL_IDS.MAIN_PANEL, + content: { + id: DEFAULT_PANEL_IDS.MAIN_PANEL, + tabs: [ + { + id: DEFAULT_TAB_IDS.LOGS, + label: "Logs", + component: null, + closeable: false, + draggable: true, + }, + { + id: DEFAULT_TAB_IDS.SHELL, + label: "Shell", + component: null, + closeable: false, + draggable: true, + }, + ], + activeTabId: DEFAULT_TAB_IDS.LOGS, + showTabs: true, + droppable: true, + }, + }, + { + type: "group", + id: DEFAULT_PANEL_IDS.RIGHT_GROUP, + direction: "vertical", + sizes: [...PANEL_SIZES.EVEN_SPLIT], + children: [ + { + type: "leaf", + id: DEFAULT_PANEL_IDS.DETAILS_PANEL, + content: { + id: DEFAULT_PANEL_IDS.DETAILS_PANEL, + tabs: [ + { + id: DEFAULT_TAB_IDS.DETAILS, + label: "Details", + component: null, + closeable: false, + draggable: false, + }, + ], + activeTabId: DEFAULT_TAB_IDS.DETAILS, + showTabs: true, + droppable: false, + }, + }, + { + type: "leaf", + id: DEFAULT_PANEL_IDS.FILES_PANEL, + content: { + id: DEFAULT_PANEL_IDS.FILES_PANEL, + tabs: [ + { + id: DEFAULT_TAB_IDS.FILES, + label: "Files", + component: null, + closeable: false, + draggable: false, + }, + ], + activeTabId: DEFAULT_TAB_IDS.FILES, + showTabs: true, + droppable: false, + }, + }, + ], + }, + ], + }; +} + +function openTab( + state: { taskLayouts: Record }, + taskId: string, + tabId: string, +): { taskLayouts: Record } { + return updateTaskLayout(state, taskId, (layout) => { + // Check if tab already exists in tree + const existingTab = findTabInTree(layout.panelTree, tabId); + + if (existingTab) { + // Tab exists, just activate it + const updatedTree = updateTreeNode( + layout.panelTree, + existingTab.panelId, + (panel) => setActiveTabInPanel(panel, tabId), + ); + + return { panelTree: updatedTree }; + } + + // Tab doesn't exist, add it to main panel + const mainPanel = getLeafPanel( + layout.panelTree, + DEFAULT_PANEL_IDS.MAIN_PANEL, + ); + if (!mainPanel) return {}; + + const updatedTree = updateTreeNode( + layout.panelTree, + DEFAULT_PANEL_IDS.MAIN_PANEL, + (panel) => addNewTabToPanel(panel, tabId, true), + ); + + const metadata = updateMetadataForTab(layout, tabId, "add"); + + return { + panelTree: updatedTree, + ...metadata, + }; + }); +} + +export const usePanelLayoutStore = createWithEqualityFn()( + persist( + (set, get) => ({ + taskLayouts: {}, + + getLayout: (taskId) => { + return get().taskLayouts[taskId] || null; + }, + + initializeTask: (taskId) => { + set((state) => ({ + taskLayouts: { + ...state.taskLayouts, + [taskId]: { + panelTree: createDefaultPanelTree(), + openFiles: [], + openArtifacts: [], + draggingTabId: null, + draggingTabPanelId: null, + }, + }, + })); + }, + + openFile: (taskId, filePath) => { + const tabId = createFileTabId(filePath); + set((state) => openTab(state, taskId, tabId)); + }, + + openArtifact: (taskId, fileName) => { + const tabId = createArtifactTabId(fileName); + set((state) => openTab(state, taskId, tabId)); + }, + + closeTab: (taskId, panelId, tabId) => { + set((state) => + updateTaskLayout(state, taskId, (layout) => { + const updatedTree = updateTreeNode( + layout.panelTree, + panelId, + (panel) => { + if (panel.type !== "leaf") return panel; + + const tabIndex = panel.content.tabs.findIndex( + (t) => t.id === tabId, + ); + const remainingTabs = panel.content.tabs.filter( + (t) => t.id !== tabId, + ); + + const newActiveTabId = selectNextTabAfterClose( + remainingTabs, + tabIndex, + panel.content.activeTabId, + tabId, + ); + + return { + ...panel, + content: { + ...panel.content, + tabs: remainingTabs, + activeTabId: newActiveTabId, + }, + }; + }, + ); + + const cleanedTree = applyCleanupWithFallback( + cleanupNode(updatedTree), + layout.panelTree, + ); + const metadata = updateMetadataForTab(layout, tabId, "remove"); + + return { + panelTree: cleanedTree, + ...metadata, + }; + }), + ); + }, + + setActiveTab: (taskId, panelId, tabId) => { + set((state) => + updateTaskLayout(state, taskId, (layout) => { + const updatedTree = updateTreeNode( + layout.panelTree, + panelId, + (panel) => setActiveTabInPanel(panel, tabId), + ); + + return { panelTree: updatedTree }; + }), + ); + }, + + setDraggingTab: (taskId, tabId, panelId) => { + set((state) => + updateTaskLayout(state, taskId, () => ({ + draggingTabId: tabId, + draggingTabPanelId: panelId, + })), + ); + }, + + clearDraggingTab: (taskId) => { + set((state) => + updateTaskLayout(state, taskId, () => ({ + draggingTabId: null, + draggingTabPanelId: null, + })), + ); + }, + + reorderTabs: (taskId, panelId, sourceIndex, targetIndex) => { + set((state) => + updateTaskLayout(state, taskId, (layout) => { + const updatedTree = updateTreeNode( + layout.panelTree, + panelId, + (panel) => { + if (panel.type !== "leaf") return panel; + + const tabs = [...panel.content.tabs]; + const [removed] = tabs.splice(sourceIndex, 1); + tabs.splice(targetIndex, 0, removed); + + return { + ...panel, + content: { + ...panel.content, + tabs, + }, + }; + }, + ); + + return { panelTree: updatedTree }; + }), + ); + }, + + moveTab: (taskId, tabId, sourcePanelId, targetPanelId) => { + set((state) => + updateTaskLayout(state, taskId, (layout) => { + const sourcePanel = getLeafPanel(layout.panelTree, sourcePanelId); + if (!sourcePanel) return {}; + + const tab = findTabInPanel(sourcePanel, tabId); + if (!tab) return {}; + + const treeAfterRemove = updateTreeNode( + layout.panelTree, + sourcePanelId, + (panel) => removeTabFromPanel(panel, tabId), + ); + + const treeAfterAdd = updateTreeNode( + treeAfterRemove, + targetPanelId, + (panel) => addTabToPanel(panel, tab), + ); + + const cleanedTree = applyCleanupWithFallback( + cleanupNode(treeAfterAdd), + layout.panelTree, + ); + + return { panelTree: cleanedTree }; + }), + ); + }, + + splitPanel: (taskId, tabId, sourcePanelId, targetPanelId, direction) => { + set((state) => + updateTaskLayout(state, taskId, (layout) => { + const sourcePanel = getLeafPanel(layout.panelTree, sourcePanelId); + if (!sourcePanel) return {}; + + const targetPanel = getLeafPanel(layout.panelTree, targetPanelId); + if (!targetPanel) return {}; + + const tab = findTabInPanel(sourcePanel, tabId); + if (!tab) return {}; + + // For same-panel splits, need > 1 tab in the panel + if ( + sourcePanelId === targetPanelId && + targetPanel.content.tabs.length <= 1 + ) { + return {}; + } + + const config = getSplitConfig(direction); + const newPanelId = generatePanelId(); + const newPanel: PanelNode = { + type: "leaf", + id: newPanelId, + content: { + id: newPanelId, + tabs: [tab], + activeTabId: tab.id, + showTabs: true, + droppable: true, + }, + }; + + // Remove tab from source panel + const treeAfterRemove = updateTreeNode( + layout.panelTree, + sourcePanelId, + (panel) => removeTabFromPanel(panel, tabId), + ); + + // Split the target panel + const updatedTree = updateTreeNode( + treeAfterRemove, + targetPanelId, + (panel) => { + const newGroup: PanelNode = { + type: "group", + id: generatePanelId(), + direction: config.splitDirection, + sizes: [50, 50], + children: config.isAfter + ? [panel, newPanel] + : [newPanel, panel], + }; + return newGroup; + }, + ); + + const cleanedTree = applyCleanupWithFallback( + cleanupNode(updatedTree), + layout.panelTree, + ); + + return { panelTree: cleanedTree }; + }), + ); + }, + + updateSizes: (taskId, groupId, sizes) => { + set((state) => + updateTaskLayout(state, taskId, (layout) => { + const updatedTree = updateTreeNode( + layout.panelTree, + groupId, + (node) => { + if (node.type !== "group") return node; + return { ...node, sizes }; + }, + ); + + return { panelTree: updatedTree }; + }), + ); + }, + + clearAllLayouts: () => { + set({ taskLayouts: {} }); + }, + }), + { + name: "panel-layout-store", + }, + ), +); diff --git a/src/renderer/features/panels/store/panelStoreHelpers.ts b/src/renderer/features/panels/store/panelStoreHelpers.ts new file mode 100644 index 000000000..30beb81d4 --- /dev/null +++ b/src/renderer/features/panels/store/panelStoreHelpers.ts @@ -0,0 +1,207 @@ +import { DEFAULT_TAB_IDS } from "../constants/panelConstants"; +import type { SplitDirection, TaskLayout } from "./panelLayoutStore"; +import type { GroupPanel, LeafPanel, PanelNode, Tab } from "./panelTypes"; + +// Constants +export const DEFAULT_FALLBACK_TAB = DEFAULT_TAB_IDS.LOGS; + +// Tab ID utilities +export type TabType = "file" | "artifact" | "system"; + +export interface ParsedTabId { + type: TabType; + value: string; +} + +export function createFileTabId(filePath: string): string { + return `file-${filePath}`; +} + +export function createArtifactTabId(fileName: string): string { + return `artifact-${fileName}`; +} + +export function parseTabId(tabId: string): ParsedTabId { + if (tabId.startsWith("file-")) { + return { type: "file", value: tabId.slice(5) }; + } + if (tabId.startsWith("artifact-")) { + return { type: "artifact", value: tabId.slice(9) }; + } + return { type: "system", value: tabId }; +} + +export function createTabLabel(tabId: string): string { + const parsed = parseTabId(tabId); + if (parsed.type === "file") { + return parsed.value.split("/").pop() || parsed.value; + } + return parsed.value; +} + +// Panel finding utilities +export function findPanelById( + node: PanelNode, + panelId: string, +): PanelNode | null { + if (node.id === panelId) { + return node; + } + + if (node.type === "group") { + for (const child of node.children) { + const found = findPanelById(child, panelId); + if (found) return found; + } + } + + return null; +} + +export function getLeafPanel( + tree: PanelNode, + panelId: string, +): LeafPanel | null { + const panel = findPanelById(tree, panelId); + return panel?.type === "leaf" ? panel : null; +} + +export function getGroupPanel( + tree: PanelNode, + panelId: string, +): GroupPanel | null { + const panel = findPanelById(tree, panelId); + return panel?.type === "group" ? panel : null; +} + +// Panel ID generation +let nextPanelId = 1; + +export function generatePanelId(): string { + return `panel-${nextPanelId++}`; +} + +export function resetPanelIdCounter(): void { + nextPanelId = 1; +} + +// State update wrapper +export function updateTaskLayout( + state: { taskLayouts: Record }, + taskId: string, + updater: (layout: TaskLayout) => Partial, +): { taskLayouts: Record } { + const layout = state.taskLayouts[taskId]; + if (!layout) return state; + + const updates = updater(layout); + + return { + taskLayouts: { + ...state.taskLayouts, + [taskId]: { + ...layout, + ...updates, + }, + }, + }; +} + +// Tree update helpers +export function createNewTab(tabId: string, closeable = true): Tab { + return { + id: tabId, + label: createTabLabel(tabId), + component: null, + closeable, + draggable: true, + }; +} + +export function addNewTabToPanel( + panel: PanelNode, + tabId: string, + closeable = true, +): PanelNode { + if (panel.type !== "leaf") return panel; + + return { + ...panel, + content: { + ...panel.content, + tabs: [...panel.content.tabs, createNewTab(tabId, closeable)], + activeTabId: tabId, + }, + }; +} + +export function selectNextTabAfterClose( + tabs: Tab[], + closedTabIndex: number, + activeTabId: string, + closedTabId: string, +): string { + if (activeTabId !== closedTabId) { + return activeTabId; + } + + if (tabs.length === 0) { + return DEFAULT_FALLBACK_TAB; + } + + const nextIndex = Math.min(closedTabIndex, tabs.length - 1); + return tabs[nextIndex].id; +} + +// Split direction utilities +export interface SplitConfig { + splitDirection: "horizontal" | "vertical"; + isAfter: boolean; +} + +export function getSplitConfig(direction: SplitDirection): SplitConfig { + const horizontalDirections: SplitDirection[] = ["left", "right"]; + const afterDirections: SplitDirection[] = ["right", "bottom"]; + + return { + splitDirection: horizontalDirections.includes(direction) + ? "horizontal" + : "vertical", + isAfter: afterDirections.includes(direction), + }; +} + +// Metadata tracking utilities +export function updateMetadataForTab( + layout: TaskLayout, + tabId: string, + action: "add" | "remove", +): Pick { + const parsed = parseTabId(tabId); + + if (parsed.type === "file") { + const openFiles = + action === "add" + ? [...layout.openFiles, parsed.value] + : layout.openFiles.filter((f) => f !== parsed.value); + return { openFiles, openArtifacts: layout.openArtifacts }; + } + + if (parsed.type === "artifact") { + const openArtifacts = + action === "add" + ? [...layout.openArtifacts, parsed.value] + : layout.openArtifacts.filter((f) => f !== parsed.value); + return { openFiles: layout.openFiles, openArtifacts }; + } + + return { openFiles: layout.openFiles, openArtifacts: layout.openArtifacts }; +} + +// Cleanup utilities +export function applyCleanupWithFallback( + cleanedTree: PanelNode | null, + originalTree: PanelNode, +): PanelNode { + return cleanedTree || originalTree; +} diff --git a/src/renderer/features/panels/store/panelTree.ts b/src/renderer/features/panels/store/panelTree.ts index 4fc064662..889a60dc1 100644 --- a/src/renderer/features/panels/store/panelTree.ts +++ b/src/renderer/features/panels/store/panelTree.ts @@ -57,6 +57,28 @@ export const findTabInPanel = ( tabId: string, ): Tab | undefined => panel.content.tabs.find((t) => t.id === tabId); +export const findTabInTree = ( + node: PanelNode, + tabId: string, +): { panelId: string; tab: Tab } | null => { + if (node.type === "leaf") { + const tab = node.content.tabs.find((t) => t.id === tabId); + if (tab) { + return { panelId: node.id, tab }; + } + return null; + } + + if (node.type === "group") { + for (const child of node.children) { + const result = findTabInTree(child, tabId); + if (result) return result; + } + } + + return null; +}; + export const updateTreeNode = ( node: PanelNode, targetId: string, @@ -143,7 +165,9 @@ export const mergeTreeContent = ( if (isLeafNode(existingTree) && isLeafNode(newTree)) { // Create a map of new tabs by ID for quick lookup - const newTabsMap = new Map(newTree.content.tabs.map((tab) => [tab.id, tab])); + const newTabsMap = new Map( + newTree.content.tabs.map((tab) => [tab.id, tab]), + ); const existingTabIds = new Set(existingTree.content.tabs.map((t) => t.id)); // Update existing tabs with new components if they exist in new tree @@ -173,7 +197,9 @@ export const mergeTreeContent = ( const finalTabs = [...updatedTabs, ...newTabsToAdd]; // Preserve the active tab if it still exists, otherwise use first tab - const activeTabId = finalTabs.some ((t) => t.id === existingTree.content.activeTabId) + const activeTabId = finalTabs.some( + (t) => t.id === existingTree.content.activeTabId, + ) ? existingTree.content.activeTabId : finalTabs[0]?.id || ""; diff --git a/src/renderer/features/panels/store/panelTypes.ts b/src/renderer/features/panels/store/panelTypes.ts index 536da37cf..cc7b70d6f 100644 --- a/src/renderer/features/panels/store/panelTypes.ts +++ b/src/renderer/features/panels/store/panelTypes.ts @@ -7,6 +7,7 @@ export type Tab = { label: string; component?: React.ReactNode; closeable?: boolean; + draggable?: boolean; onClose?: () => void; onSelect?: () => void; icon?: React.ReactNode; diff --git a/src/renderer/features/panels/utils/panelLayoutUtils.ts b/src/renderer/features/panels/utils/panelLayoutUtils.ts new file mode 100644 index 000000000..3f45bbacd --- /dev/null +++ b/src/renderer/features/panels/utils/panelLayoutUtils.ts @@ -0,0 +1,20 @@ +import { PANEL_SIZES } from "../constants/panelConstants"; +import type { GroupPanel } from "../store/panelTypes"; + +export function calculateDefaultSize(node: GroupPanel, index: number): number { + return node.sizes?.[index] ?? 100 / node.children.length; +} + +export function shouldUpdateSizes( + currentSizes: number[], + storeSizes: number[], +): boolean { + if (currentSizes.length !== storeSizes.length) { + return false; + } + + return currentSizes.some( + (size, i) => + Math.abs(size - storeSizes[i]) > PANEL_SIZES.SIZE_DIFF_THRESHOLD, + ); +} diff --git a/src/renderer/types/electron.d.ts b/src/renderer/types/electron.d.ts index 4883d8f48..a3ab6694d 100644 --- a/src/renderer/types/electron.d.ts +++ b/src/renderer/types/electron.d.ts @@ -124,6 +124,7 @@ export interface IElectronAPI { customInput?: string; }>, ) => Promise; + readRepoFile: (repoPath: string, filePath: string) => Promise; onOpenSettings: (listener: () => void) => () => void; getAppVersion: () => Promise; onUpdateReady: (listener: () => void) => () => void; diff --git a/src/test/fixtures.ts b/src/test/fixtures.ts new file mode 100644 index 000000000..c54d584f5 --- /dev/null +++ b/src/test/fixtures.ts @@ -0,0 +1,47 @@ +import type { PanelNode, Tab } from "@features/panels/store/panelTypes"; + +// Panel fixtures +export function createMockTab(overrides?: Partial): Tab { + return { + id: "test-tab", + label: "Test Tab", + component: undefined, + closeable: true, + ...overrides, + }; +} + +export function createMockLeafNode(overrides?: Partial): PanelNode { + return { + type: "leaf", + id: "test-leaf", + content: { + id: "test-leaf", + tabs: [createMockTab()], + activeTabId: "test-tab", + showTabs: true, + droppable: true, + }, + ...overrides, + } as PanelNode; +} + +export function createMockGroupNode(overrides?: Partial): PanelNode { + return { + type: "group", + id: "test-group", + direction: "horizontal", + children: [createMockLeafNode({ id: "leaf-1" })], + sizes: [100], + ...overrides, + } as PanelNode; +} + +// File fixtures +export const MOCK_FILES = [ + { path: "App.tsx", name: "App.tsx" }, + { path: "helper.ts", name: "helper.ts" }, + { path: "README.md", name: "README.md" }, +]; + +export const MOCK_FILE_CONTENT = "// file content"; diff --git a/src/test/panelTestHelpers.ts b/src/test/panelTestHelpers.ts new file mode 100644 index 000000000..c399f2128 --- /dev/null +++ b/src/test/panelTestHelpers.ts @@ -0,0 +1,386 @@ +import { usePanelLayoutStore } from "@features/panels/store/panelLayoutStore"; +import type { PanelNode } from "@features/panels/store/panelTypes"; +import type { Task } from "@shared/types"; +import { expect, vi } from "vitest"; + +export function createMockTask(overrides: Partial = {}): Task { + return { + id: "test-task-1", + task_number: 1, + slug: "test-task", + title: "Test Task", + description: "", + status: "pending", + origin_product: "test", + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + ...overrides, + }; +} + +export function mockElectronAPI( + overrides: Partial = {}, +) { + window.electronAPI = { + listRepoFiles: vi.fn().mockResolvedValue([ + { path: "App.tsx", name: "App.tsx" }, + { path: "helper.ts", name: "helper.ts" }, + { path: "README.md", name: "README.md" }, + ]), + readRepoFile: vi.fn().mockResolvedValue("// file content"), + shellCreate: vi.fn().mockResolvedValue(undefined), + shellWrite: vi.fn().mockResolvedValue(undefined), + shellResize: vi.fn().mockResolvedValue(undefined), + shellDispose: vi.fn().mockResolvedValue(undefined), + shellDestroy: vi.fn().mockResolvedValue(undefined), + onShellData: vi.fn().mockReturnValue(() => {}), + onShellExit: vi.fn().mockReturnValue(() => {}), + ...overrides, + } as any; +} + +export interface PanelStructure { + id: string; + type: "group" | "leaf"; + direction?: "horizontal" | "vertical"; + tabIds?: string[]; + activeTabId?: string; + children?: PanelStructure[]; +} + +export function getPanelStructure(node: PanelNode): PanelStructure { + if (node.type === "leaf") { + return { + id: node.id, + type: "leaf", + tabIds: node.content.tabs.map((t) => t.id), + activeTabId: node.content.activeTabId, + }; + } + + return { + id: node.id, + type: "group", + direction: node.direction, + children: node.children.map(getPanelStructure), + }; +} + +export function findPanelById( + node: PanelNode, + panelId: string, +): Extract | null { + if (node.id === panelId && node.type === "leaf") { + return node; + } + + if (node.type === "group") { + for (const child of node.children) { + const found = findPanelById(child, panelId); + if (found) return found; + } + } + + return null; +} + +export interface ExpectedPanelLayout { + panelId: string; + expectedTabs: string[]; + activeTab?: string; +} + +export function assertPanelLayout( + tree: PanelNode, + expectations: ExpectedPanelLayout[], +) { + for (const { panelId, expectedTabs, activeTab } of expectations) { + const panel = findPanelById(tree, panelId); + if (!panel) { + throw new Error(`Panel ${panelId} not found in tree`); + } + + const actualTabs = panel.content.tabs.map((t) => t.id); + + if (actualTabs.length !== expectedTabs.length) { + throw new Error( + `Panel ${panelId}: expected ${expectedTabs.length} tabs but got ${actualTabs.length}. Expected: [${expectedTabs.join(", ")}], Got: [${actualTabs.join(", ")}]`, + ); + } + + for (const expectedTab of expectedTabs) { + if (!actualTabs.includes(expectedTab)) { + throw new Error( + `Panel ${panelId}: expected tab "${expectedTab}" but it was not found. Got: [${actualTabs.join(", ")}]`, + ); + } + } + + if (activeTab && panel.content.activeTabId !== activeTab) { + throw new Error( + `Panel ${panelId}: expected active tab "${activeTab}" but got "${panel.content.activeTabId}"`, + ); + } + } +} + +export function assertTabInPanel( + tree: PanelNode, + panelId: string, + tabId: string, +) { + const panel = findPanelById(tree, panelId); + if (!panel) { + throw new Error(`Panel ${panelId} not found in tree`); + } + + const hasTab = panel.content.tabs.some((t) => t.id === tabId); + if (!hasTab) { + const actualTabs = panel.content.tabs.map((t) => t.id).join(", "); + throw new Error( + `Tab "${tabId}" not found in panel ${panelId}. Actual tabs: [${actualTabs}]`, + ); + } +} + +export function assertActiveTab( + tree: PanelNode, + panelId: string, + expectedTabId: string, +) { + const panel = findPanelById(tree, panelId); + if (!panel) { + throw new Error(`Panel ${panelId} not found in tree`); + } + + if (panel.content.activeTabId !== expectedTabId) { + throw new Error( + `Panel ${panelId}: expected active tab "${expectedTabId}" but got "${panel.content.activeTabId}"`, + ); + } +} + +export function assertTabCount( + tree: PanelNode, + panelId: string, + expectedCount: number, +) { + const panel = findPanelById(tree, panelId); + if (!panel) { + throw new Error(`Panel ${panelId} not found in tree`); + } + + if (panel.content.tabs.length !== expectedCount) { + const actualTabs = panel.content.tabs.map((t) => t.id).join(", "); + throw new Error( + `Panel ${panelId}: expected ${expectedCount} tabs but got ${panel.content.tabs.length}. Actual: [${actualTabs}]`, + ); + } +} + +export interface ExpectedGroupStructure { + direction: "horizontal" | "vertical"; + childCount: number; + sizes?: number[]; +} + +export function assertGroupStructure( + node: PanelNode, + expected: ExpectedGroupStructure, +) { + if (node.type !== "group") { + throw new Error( + `Expected node to be a group but got ${node.type} (id: ${node.id})`, + ); + } + + if (node.direction !== expected.direction) { + throw new Error( + `Group ${node.id}: expected direction "${expected.direction}" but got "${node.direction}"`, + ); + } + + if (node.children.length !== expected.childCount) { + throw new Error( + `Group ${node.id}: expected ${expected.childCount} children but got ${node.children.length}`, + ); + } + + if ( + expected.sizes && + JSON.stringify(node.sizes) !== JSON.stringify(expected.sizes) + ) { + throw new Error( + `Group ${node.id}: expected sizes [${expected.sizes.join(", ")}] but got [${node.sizes?.join(", ") ?? "undefined"}]`, + ); + } +} + +export function openMultipleFiles(taskId: string, files: string[]) { + for (const file of files) { + usePanelLayoutStore.getState().openFile(taskId, file); + } +} + +export function closeMultipleTabs( + taskId: string, + panelId: string, + tabIds: string[], +) { + for (const tabId of tabIds) { + usePanelLayoutStore.getState().closeTab(taskId, panelId, tabId); + } +} + +export type GroupNode = Extract; + +export function withRootGroup( + taskId: string, + callback: (root: GroupNode) => void, +) { + const layout = usePanelLayoutStore.getState().getLayout(taskId); + const root = layout?.panelTree; + + if (!root) { + throw new Error(`No layout found for task ${taskId}`); + } + + if (root.type !== "group") { + throw new Error( + `Expected group node for task ${taskId} but got ${root.type} (id: ${root.id})`, + ); + } + + callback(root); +} + +export function testSizePreservation( + _testName: string, + operation: () => void, + customSizes: number[] = [55, 45], +) { + return () => { + usePanelLayoutStore.getState().updateSizes("task-1", "root", customSizes); + + const layoutBefore = usePanelLayoutStore.getState().getLayout("task-1"); + const rootBefore = layoutBefore?.panelTree; + if (rootBefore && rootBefore.type === "group") { + expect(rootBefore.sizes).toEqual(customSizes); + } + + operation(); + + withRootGroup("task-1", (root) => { + expect(root.sizes).toEqual(customSizes); + }); + }; +} + +export type LeafNode = Extract; + +export function getNestedPanel( + taskId: string, + ...path: Array +): PanelNode { + const layout = usePanelLayoutStore.getState().getLayout(taskId); + const root = layout?.panelTree; + + if (!root) { + throw new Error(`No layout found for task ${taskId}`); + } + + let current: PanelNode = root; + + for (const step of path) { + if (current.type !== "group") { + throw new Error(`Cannot navigate into leaf node at step ${step}`); + } + + const index = step === "left" ? 0 : step === "right" ? 1 : step; + const child = current.children[index]; + + if (!child) { + throw new Error(`No child at index ${index} in group ${current.id}`); + } + + current = child; + } + + return current; +} + +export function assertTabInNestedPanel( + taskId: string, + tabId: string, + hasTab: boolean, + ...path: Array +) { + const panel = getNestedPanel(taskId, ...path); + + if (panel.type !== "leaf") { + throw new Error( + `Expected leaf panel but got group at path [${path.join(", ")}]`, + ); + } + + const actualHasTab = panel.content.tabs.some((t) => t.id === tabId); + + if (actualHasTab !== hasTab) { + const actualTabs = panel.content.tabs.map((t) => t.id).join(", "); + throw new Error( + hasTab + ? `Expected tab "${tabId}" in panel but it was not found. Actual tabs: [${actualTabs}]` + : `Expected tab "${tabId}" NOT to be in panel but it was found. Actual tabs: [${actualTabs}]`, + ); + } +} + +export function assertActiveTabInNestedPanel( + taskId: string, + expectedTabId: string, + ...path: Array +) { + const panel = getNestedPanel(taskId, ...path); + + if (panel.type !== "leaf") { + throw new Error( + `Expected leaf panel but got group at path [${path.join(", ")}]`, + ); + } + + if (panel.content.activeTabId !== expectedTabId) { + throw new Error( + `Panel at path [${path.join(", ")}]: expected active tab "${expectedTabId}" but got "${panel.content.activeTabId}"`, + ); + } +} + +export function getLayout(taskId: string) { + const layout = usePanelLayoutStore.getState().getLayout(taskId); + if (!layout) { + throw new Error(`No layout found for task ${taskId}`); + } + return layout; +} + +export function getPanelTree(taskId: string) { + return getLayout(taskId).panelTree; +} + +export function splitAndAssert( + taskId: string, + tabId: string, + direction: "top" | "bottom" | "left" | "right", + expectedDirection: "horizontal" | "vertical", +) { + usePanelLayoutStore + .getState() + .splitPanel(taskId, tabId, "main-panel", "main-panel", direction); + + const leftPanel = getNestedPanel(taskId, "left"); + assertGroupStructure(leftPanel, { + direction: expectedDirection, + childCount: 2, + sizes: [50, 50], + }); +} diff --git a/src/test/setup.ts b/src/test/setup.ts new file mode 100644 index 000000000..7d8938ddd --- /dev/null +++ b/src/test/setup.ts @@ -0,0 +1,49 @@ +import "@testing-library/jest-dom"; +import { cleanup } from "@testing-library/react"; +import { afterAll, afterEach, beforeAll, vi } from "vitest"; + +// Suppress act() warnings from Radix UI async updates in tests, +// we don't care about them. +const originalError = console.error; +beforeAll(() => { + console.error = (...args: any[]) => { + if ( + typeof args[0] === "string" && + args[0].includes("Warning: An update to") && + args[0].includes("inside a test was not wrapped in act") + ) { + return; + } + originalError.call(console, ...args); + }; +}); + +afterAll(() => { + console.error = originalError; +}); + +globalThis.ResizeObserver = class ResizeObserver { + observe = vi.fn(); + unobserve = vi.fn(); + disconnect = vi.fn(); +}; + +HTMLCanvasElement.prototype.getContext = vi.fn(); + +Object.defineProperty(window, "matchMedia", { + writable: true, + value: vi.fn().mockImplementation((query) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), +}); + +afterEach(() => { + cleanup(); +}); diff --git a/src/test/utils.tsx b/src/test/utils.tsx new file mode 100644 index 000000000..7dd76b32a --- /dev/null +++ b/src/test/utils.tsx @@ -0,0 +1,34 @@ +import { Theme } from "@radix-ui/themes"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { type RenderOptions, render } from "@testing-library/react"; +import type { ReactElement } from "react"; + +function createTestQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); +} + +export function renderWithProviders( + ui: ReactElement, + options?: Omit, +) { + const testQueryClient = createTestQueryClient(); + + return render(ui, { + wrapper: ({ children }) => ( + + + {children} + + + ), + ...options, + }); +} + +export * from "@testing-library/react"; diff --git a/src/test/vitest.d.ts b/src/test/vitest.d.ts new file mode 100644 index 000000000..c5dcf8283 --- /dev/null +++ b/src/test/vitest.d.ts @@ -0,0 +1,8 @@ +import "vitest"; +import type { TestingLibraryMatchers } from "@testing-library/jest-dom/matchers"; + +declare module "vitest" { + interface Assertion extends TestingLibraryMatchers {} + interface AsymmetricMatchersContaining + extends TestingLibraryMatchers {} +} diff --git a/tsconfig.json b/tsconfig.json index 70e344065..7013b04c2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -28,7 +28,8 @@ "@stores/*": ["renderer/stores/*"], "@hooks/*": ["renderer/hooks/*"], "@utils/*": ["renderer/utils/*"], - "@lib/*": ["renderer/lib/*"] + "@lib/*": ["renderer/lib/*"], + "@test/*": ["test/*"] }, "resolveJsonModule": true, "skipLibCheck": true, diff --git a/tsconfig.web.json b/tsconfig.web.json index 0d46a90e2..942d4ac73 100644 --- a/tsconfig.web.json +++ b/tsconfig.web.json @@ -5,7 +5,7 @@ "lib": ["ESNext", "DOM", "DOM.Iterable"], "outDir": "./dist/renderer", "target": "ESNext", - "types": ["vite/client"] + "types": ["vite/client", "vitest/globals", "@testing-library/jest-dom"] }, "exclude": ["src/main/**/*", "src/api/repoDetector.ts"], "extends": "./tsconfig.json", @@ -13,6 +13,7 @@ "src/renderer/**/*", "src/shared/**/*", "src/api/**/*", + "src/test/**/*", "src/vite-env.d.ts" ] } diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 000000000..fa4ce3518 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,39 @@ +import path from "node:path"; +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [react()], + test: { + globals: true, + environment: "jsdom", + setupFiles: ["./src/test/setup.ts"], + coverage: { + provider: "v8", + reporter: ["text", "json", "html"], + exclude: [ + "node_modules/", + "src/test/", + "**/*.d.ts", + "**/*.config.*", + "**/mockData.ts", + ], + }, + }, + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + "@api": path.resolve(__dirname, "./src/api"), + "@main": path.resolve(__dirname, "./src/main"), + "@renderer": path.resolve(__dirname, "./src/renderer"), + "@shared": path.resolve(__dirname, "./src/shared"), + "@features": path.resolve(__dirname, "./src/renderer/features"), + "@components": path.resolve(__dirname, "./src/renderer/components"), + "@stores": path.resolve(__dirname, "./src/renderer/stores"), + "@hooks": path.resolve(__dirname, "./src/renderer/hooks"), + "@utils": path.resolve(__dirname, "./src/renderer/utils"), + "@lib": path.resolve(__dirname, "./src/renderer/lib"), + "@test": path.resolve(__dirname, "./src/test"), + }, + }, +});