diff --git a/README.md b/README.md index 0559912..2da03b7 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ The app made for people who review multiple Pull Requests every day. [![Publish npm package](https://github.com/felipe-software/pr-run/actions/workflows/publish-npm.yml/badge.svg)](https://github.com/felipe-software/pr-run/actions/workflows/publish-npm.yml) [![Version Bump](https://github.com/felipe-software/pr-run/actions/workflows/version-bump.yaml/badge.svg)](https://github.com/felipe-software/pr-run/actions/workflows/version-bump.yaml)
+ ## Run PR-run requires [Bun](https://bun.sh/docs/installation). diff --git a/bun.lock b/bun.lock index 861305d..5c61a5f 100644 --- a/bun.lock +++ b/bun.lock @@ -5,16 +5,15 @@ "": { "name": "pr-run", "dependencies": { + "@base-ui/react": "^1.4.1", "@elysiajs/cors": "^1.4.2", "@fontsource-variable/dm-sans": "^5.2.8", "@fontsource/jetbrains-mono": "^5.2.8", - "@heroicons/react": "^2.2.0", - "@heroui/react": "^3.1.0", - "@heroui/styles": "^3.1.0", "@pierre/diffs": "^1.2.10", "@tanstack/react-query": "^5.101.0", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", + "class-variance-authority": "^0.7.1", "effect": "^3.21.3", "elysia": "^1.4.28", "ky": "^2.0.2", @@ -25,6 +24,11 @@ "pino-pretty": "^13.1.3", "react": "^19.2.7", "react-dom": "^19.2.7", + "react-markdown": "^10.1.0", + "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", + "remark-gfm": "^4.0.1", + "tailwind-merge": "^3.4.0", "zod": "^4.4.3", "zustand": "^5.0.14", }, @@ -62,18 +66,16 @@ "@actions/io": ["@actions/io@3.0.2", "", {}, "sha512-nRBchcMM+QK1pdjO7/idu86rbJI5YHUKCvKs0KxnSYbVe3F51UfGxuZX4Qy/fWlp6l7gWFwIkrOzN+oUK03kfw=="], - "@adobe/react-spectrum": ["@adobe/react-spectrum@3.47.1", "", { "dependencies": { "@internationalized/date": "^3.12.2", "@react-types/shared": "^3.35.0", "@spectrum-icons/ui": "^3.7.1", "@spectrum-icons/workflow": "^4.3.1", "@swc/helpers": "^0.5.0", "client-only": "^0.0.1", "clsx": "^2.0.0", "react-aria": "3.49.0", "react-aria-components": "1.18.0", "react-stately": "3.47.0", "react-transition-group": "^4.4.5", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-mijNyd8XmA6KoBMbqKc4Rp+S2voKdZkPydJFjQbG0SkOjOKcgqVpJdKGsKdLMZBn9Jk5SgEuklyrhc7Uvot2tA=="], - - "@adobe/react-spectrum-ui": ["@adobe/react-spectrum-ui@1.2.1", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0" } }, "sha512-wcrbEE2O/9WnEn6avBnaVRRx88S5PLFsPLr4wffzlbMfXeQsy+RMQwaJd3cbzrn18/j04Isit7f7Emfn0dhrJA=="], - - "@adobe/react-spectrum-workflow": ["@adobe/react-spectrum-workflow@2.3.5", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0" } }, "sha512-b53VIPwPWKb/T5gzE3qs+QlGP5gVrw/LnWV3xMksDU+CRl3rzOKUwxIGiZO8ICyYh1WiyqY4myGlPU/nAynBUg=="], - "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], "@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="], + "@base-ui/react": ["@base-ui/react@1.6.0", "", { "dependencies": { "@babel/runtime": "^7.29.2", "@base-ui/utils": "0.3.1", "@floating-ui/react-dom": "^2.1.8", "@floating-ui/utils": "^0.2.11", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@date-fns/tz": "^1.2.0", "@types/react": "^17 || ^18 || ^19", "date-fns": "^4.0.0", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@date-fns/tz", "@types/react", "date-fns"] }, "sha512-/jzjTWJYXhRFO45Bev9lc3cHbmjzCMpUqbMZ2AgKy/z25mY9B6shGSNcXcjQar9n5doM0KYW1W8fcFv2jZBuMw=="], + + "@base-ui/utils": ["@base-ui/utils@0.3.1", "", { "dependencies": { "@babel/runtime": "^7.29.2", "@floating-ui/utils": "^0.2.11", "reselect": "^5.2.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-gFFiltORVmW/N6IILTGxizP3PBpVpysqML1ALY5Vk0mH+7faVkCknOU31goYHN5Aoek2dkjxva1XOD2Ce9WuIg=="], + "@borewit/text-codec": ["@borewit/text-codec@0.2.2", "", {}, "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ=="], "@colors/colors": ["@colors/colors@1.5.0", "", {}, "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ=="], @@ -92,33 +94,17 @@ "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], - "@fontsource-variable/dm-sans": ["@fontsource-variable/dm-sans@5.2.8", "", {}, "sha512-AxkvMTvNWgfrmlyjiV05vlHYJa+nRQCf1EfvIrQAPBpFJW0O9VTz7oAFr9S3lvbWdmnFoBk7yFqQL86u64nl2g=="], - - "@fontsource/jetbrains-mono": ["@fontsource/jetbrains-mono@5.2.8", "", {}, "sha512-6w8/SG4kqvIMu7xd7wt6x3idn1Qux3p9N62s6G3rfldOUYHpWcc2FKrqf+Vo44jRvqWj2oAtTHrZXEP23oSKwQ=="], - - "@formatjs/ecma402-abstract": ["@formatjs/ecma402-abstract@2.3.6", "", { "dependencies": { "@formatjs/fast-memoize": "2.2.7", "@formatjs/intl-localematcher": "0.6.2", "decimal.js": "^10.4.3", "tslib": "^2.8.0" } }, "sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw=="], - - "@formatjs/fast-memoize": ["@formatjs/fast-memoize@2.2.7", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ=="], - - "@formatjs/icu-messageformat-parser": ["@formatjs/icu-messageformat-parser@2.11.4", "", { "dependencies": { "@formatjs/ecma402-abstract": "2.3.6", "@formatjs/icu-skeleton-parser": "1.8.16", "tslib": "^2.8.0" } }, "sha512-7kR78cRrPNB4fjGFZg3Rmj5aah8rQj9KPzuLsmcSn4ipLXQvC04keycTI1F7kJYDwIXtT2+7IDEto842CfZBtw=="], - - "@formatjs/icu-skeleton-parser": ["@formatjs/icu-skeleton-parser@1.8.16", "", { "dependencies": { "@formatjs/ecma402-abstract": "2.3.6", "tslib": "^2.8.0" } }, "sha512-H13E9Xl+PxBd8D5/6TVUluSpxGNvFSlN/b3coUp0e0JpuWXXnQDiavIpY3NnvSp4xhEMoXyyBvVfdFX8jglOHQ=="], + "@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="], - "@formatjs/intl-localematcher": ["@formatjs/intl-localematcher@0.6.2", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA=="], + "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="], - "@heroicons/react": ["@heroicons/react@2.2.0", "", { "peerDependencies": { "react": ">= 16 || ^19.0.0-rc" } }, "sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ=="], + "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.8", "", { "dependencies": { "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A=="], - "@heroui/react": ["@heroui/react@3.1.0", "", { "dependencies": { "@heroui/styles": "3.1.0", "@radix-ui/react-avatar": "1.1.11", "@react-aria/i18n": "3.13.0", "@react-aria/ssr": "3.10.0", "@react-aria/utils": "3.34.0", "@react-stately/utils": "3.12.0", "@react-types/color": "3.2.0", "@react-types/shared": "3.34.0", "input-otp": "1.4.2", "react-aria-components": "1.17.0", "tailwind-merge": "3.4.0", "tailwind-variants": "3.2.2" }, "peerDependencies": { "react": ">=19.0.0", "react-dom": ">=19.0.0", "tailwindcss": ">=4.0.0" } }, "sha512-q+K3Kz5h2ceOEMPuNORLGX1Pqh4McN30y31eykzZZjLbmta4nwi4sDMVAv7/w8QWnWVDOucusXz5x9s5N656nA=="], + "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], - "@heroui/styles": ["@heroui/styles@3.1.0", "", { "dependencies": { "tailwind-variants": "3.2.2", "tw-animate-css": "1.4.0" }, "peerDependencies": { "tailwindcss": ">=4.0.0" } }, "sha512-euZtlV5hk9qOWhkSbTaJIrdnm0bGxvu1FEN6nnLGt7PM7AdL40z4C3ZYQ+cW+ObSsqsWHzQRsuhmLHVt1uTS4w=="], - - "@internationalized/date": ["@internationalized/date@3.12.2", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw=="], - - "@internationalized/message": ["@internationalized/message@3.1.10", "", { "dependencies": { "@swc/helpers": "^0.5.0", "intl-messageformat": "^10.1.0" } }, "sha512-nc0Or6EdWHqZRcsXb6P9hBIpLsfSl/ILh0rk5h/OVBpzmhdExXtPy2cQtWsq8XKRBpRHwDNnAHt4OpolcB7dog=="], - - "@internationalized/number": ["@internationalized/number@3.6.7", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-3ji1fcrT+FPAK86UqEhB/psHixYo6niWPJtt7+qRaYFynt/BaJG8GhAPimtWUpEiVSTq8ZM8L5psMxGquiB/Vg=="], + "@fontsource-variable/dm-sans": ["@fontsource-variable/dm-sans@5.2.8", "", {}, "sha512-AxkvMTvNWgfrmlyjiV05vlHYJa+nRQCf1EfvIrQAPBpFJW0O9VTz7oAFr9S3lvbWdmnFoBk7yFqQL86u64nl2g=="], - "@internationalized/string": ["@internationalized/string@3.2.9", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-kzP/M/mbQxODlmOt4bIQZ2SBVUWUSqMLXooXixnX7noche8WHaQcA+nwFN1K2KCF/cp+LDUhcJsCicwkvhD1pg=="], + "@fontsource/jetbrains-mono": ["@fontsource/jetbrains-mono@5.2.8", "", {}, "sha512-6w8/SG4kqvIMu7xd7wt6x3idn1Qux3p9N62s6G3rfldOUYHpWcc2FKrqf+Vo44jRvqWj2oAtTHrZXEP23oSKwQ=="], "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], @@ -172,42 +158,6 @@ "@pnpm/npm-conf": ["@pnpm/npm-conf@3.0.3", "", { "dependencies": { "@pnpm/config.env-replace": "^1.1.0", "@pnpm/network.ca-file": "^1.0.1", "config-chain": "^1.1.11" } }, "sha512-//0sR/cow/s4ICQaYoAobOl4aU8cjU6x/V24V7XkKotb9+O+3zySIYp146vpaobYHnxa4pZX8NkV54Z5AwbDKA=="], - "@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.1.11", "", { "dependencies": { "@radix-ui/react-context": "1.1.3", "@radix-ui/react-primitive": "2.1.4", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0Qk603AHGV28BOBO34p7IgD5m+V5Sg/YovfayABkoDDBM5d3NCx0Mp4gGrjzLGes1jV5eNOE1r3itqOR33VC6Q=="], - - "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], - - "@radix-ui/react-context": ["@radix-ui/react-context@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw=="], - - "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], - - "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], - - "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="], - - "@radix-ui/react-use-is-hydrated": ["@radix-ui/react-use-is-hydrated@0.1.0", "", { "dependencies": { "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA=="], - - "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], - - "@react-aria/color": ["@react-aria/color@3.2.1", "", { "dependencies": { "@swc/helpers": "^0.5.0", "react-aria": "^3.48.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-v/pZh1yBNeayQ5Kio0xRIX0gBf6uu+KjCRW4QSydTyRpmhvk6sMzHT9i9/vIMdaDHaYHBXlXF+Tp/mvS9CG4Zw=="], - - "@react-aria/i18n": ["@react-aria/i18n@3.13.0", "", { "dependencies": { "@internationalized/date": "^3.12.1", "@internationalized/message": "^3.1.9", "@internationalized/string": "^3.2.8", "@swc/helpers": "^0.5.0", "react-aria": "3.48.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-APjw4EwmvlnIyDxixSWfjHvOFFkW2rVTyKZ4l9FV0v7hOerh+FWLE6mF1XnnX3pgz3yARkKWwhSR9xYcRK6tpg=="], - - "@react-aria/ssr": ["@react-aria/ssr@3.10.0", "", { "dependencies": { "@swc/helpers": "^0.5.0", "react-aria": "3.48.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-mnelvACtfNWWKFCT1YHebxJRmfBmmANGwHQhCFPByMVTx1L8RumcaLxChYkE87g2KPuP5xX2il/oRn1DytW+qQ=="], - - "@react-aria/utils": ["@react-aria/utils@3.34.0", "", { "dependencies": { "@swc/helpers": "^0.5.0", "react-aria": "3.48.0", "react-stately": "3.46.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-ZM1ZXIqpwGTJjjL6o3JhlZkEaBpQdxuOCqLEvwEwooaj5GsYI3E9UfOl5vy3UW6bYiEEWl9pNBntrb9CR9kItQ=="], - - "@react-spectrum/color": ["@react-spectrum/color@3.2.1", "", { "dependencies": { "@adobe/react-spectrum": "^3.47.0", "@swc/helpers": "^0.5.0", "react-stately": "^3.46.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-FbB+GaVufsN+obNJmUAe8XGZTeJjEWvEf/k/7nxDxoDCBdg/o6eEPONAZyID5Dj1f8BZKNym8VURxDZx7IIyuQ=="], - - "@react-spectrum/provider": ["@react-spectrum/provider@3.11.1", "", { "dependencies": { "@adobe/react-spectrum": "^3.47.0", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-TsoNdVdmlQ7L+75ILq5Yb3+wp/I1AtIeat0o+Y+ZBxP+TtWpwT1ZtCB5l3cplFVzHzOpZlzO0VaDrDP9ElGYDw=="], - - "@react-stately/color": ["@react-stately/color@3.10.1", "", { "dependencies": { "@swc/helpers": "^0.5.0", "react-stately": "^3.46.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-v4YlLa/oRQpPa/M9Yq1e0Bl8Z7xzGZdNDAIbfIMZuiJtacaOKriPW+AOgjjT/Ab0fNX2YDbwIYvkiMOJBnTi5A=="], - - "@react-stately/utils": ["@react-stately/utils@3.12.0", "", { "dependencies": { "@swc/helpers": "^0.5.0", "react-stately": "3.46.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-7q+iHz9cENvro1dVKgdTxNh1i1mtWuLUI6UHp10TAgpxM9DyRDvmuN35zLXYCmMDgx3WLY2xkwqoez8xd+CdxQ=="], - - "@react-types/color": ["@react-types/color@3.2.0", "", { "dependencies": { "@react-aria/color": "^3.2.0", "@react-spectrum/color": "^3.2.0", "@react-stately/color": "^3.10.0" }, "peerDependencies": { "@react-spectrum/provider": "^3.0.0", "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-beV3vz80nzZ1EuYUM7296Kyi3AHcMrbQw0qub/9yzHWVTKKc5sy/e4dCMKcWL/ArkeAyc7jDOiui190RQ4l0Fw=="], - - "@react-types/shared": ["@react-types/shared@3.34.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-gp6xo/s2lX54AlTjOiqwDnxA7UW79BNvI9dB9pr3LZTzRKCd1ZA+ZbgKw/ReIiWuvvVw/8QFJpnqeeFyLocMcQ=="], - "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.3", "", { "os": "android", "cpu": "arm64" }, "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw=="], "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA=="], @@ -280,14 +230,8 @@ "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="], - "@spectrum-icons/ui": ["@spectrum-icons/ui@3.7.1", "", { "dependencies": { "@adobe/react-spectrum-ui": "1.2.1", "@babel/runtime": "^7.24.4", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "@adobe/react-spectrum": "^3.47.0", "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-veQymocUYo5OciXQajSailOdbWe+k6+2ehfF8D4d0V923D4xOUadtT253xXZ5vEQjPat6Kyp2WDKeQNjd7kL1w=="], - - "@spectrum-icons/workflow": ["@spectrum-icons/workflow@4.3.1", "", { "dependencies": { "@adobe/react-spectrum-workflow": "2.3.5", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "@adobe/react-spectrum": "^3.47.0", "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-kDF+/EbFVyLGytotqqdYt4uSij4j/PQmDQO5km/C6DyzKjyuic3FnSBFinR+mA6oFv1OjMcLvrrDBqK3wbqRlA=="], - "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "@swc/helpers": ["@swc/helpers@0.5.23", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw=="], - "@tailwindcss/node": ["@tailwindcss/node@4.3.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.21.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.0" } }, "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g=="], "@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.0", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.0", "@tailwindcss/oxide-darwin-arm64": "4.3.0", "@tailwindcss/oxide-darwin-x64": "4.3.0", "@tailwindcss/oxide-freebsd-x64": "4.3.0", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", "@tailwindcss/oxide-linux-x64-musl": "4.3.0", "@tailwindcss/oxide-wasm32-wasi": "4.3.0", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" } }, "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg=="], @@ -330,10 +274,18 @@ "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="], + + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + + "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], + "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], + "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], + "@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], "@types/normalize-package-data": ["@types/normalize-package-data@2.4.4", "", {}, "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA=="], @@ -370,12 +322,12 @@ "argv-formatter": ["argv-formatter@1.0.0", "", {}, "sha512-F2+Hkm9xFaRg+GkaNnbwXNDV5O6pnCFEmqyhvfC/Ic5LbgOWjJh3L+mN/s91rxVL3znE7DYVpW0GJFT+4YBgWw=="], - "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], - "array-ify": ["array-ify@1.0.0", "", {}, "sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng=="], "atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="], + "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], + "before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="], "bottleneck": ["bottleneck@2.19.5", "", {}, "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw=="], @@ -392,20 +344,24 @@ "char-regex": ["char-regex@1.0.2", "", {}, "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw=="], + "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], + "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="], "character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], + "character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="], + "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], + "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], + "clean-stack": ["clean-stack@2.2.0", "", {}, "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A=="], "cli-highlight": ["cli-highlight@2.1.11", "", { "dependencies": { "chalk": "^4.0.0", "highlight.js": "^10.7.1", "mz": "^2.4.0", "parse5": "^5.1.1", "parse5-htmlparser2-tree-adapter": "^6.0.0", "yargs": "^16.0.0" }, "bin": { "highlight": "bin/highlight" } }, "sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg=="], "cli-table3": ["cli-table3@0.6.5", "", { "dependencies": { "string-width": "^4.2.0" }, "optionalDependencies": { "@colors/colors": "1.5.0" } }, "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ=="], - "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], - "cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="], "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], @@ -452,7 +408,7 @@ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], + "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], "deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="], @@ -466,8 +422,6 @@ "dir-glob": ["dir-glob@3.0.1", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="], - "dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="], - "dot-prop": ["dot-prop@5.3.0", "", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q=="], "duplexer2": ["duplexer2@0.1.4", "", { "dependencies": { "readable-stream": "^2.0.2" } }, "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA=="], @@ -486,6 +440,8 @@ "enhanced-resolve": ["enhanced-resolve@5.24.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-SkE2t82KlkkxQRVMVLAGKxLfORGQfrkx5dkj+vlgXRVNEdPc4eZcR+J/Fvj8C+yKSFH5L0q3NFlyufOVQnCcYQ=="], + "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "env-ci": ["env-ci@11.2.0", "", { "dependencies": { "execa": "^8.0.0", "java-properties": "^1.0.2" } }, "sha512-D5kWfzkmaOQDioPmiviWAVtKmpPT4/iJmMVQxWxMPJTFyTkdc5JQUfc5iXEeWxcOdsYTKSAiA/Age4NUOqKsRA=="], "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], @@ -498,12 +454,16 @@ "escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], + "estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="], + "exact-mirror": ["exact-mirror@0.2.7", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-+MeEmDcLA4o/vjK2zujgk+1VTxPR4hdp23qLqkWfStbECtAq9gmsvQa3LW6z/0GXZyHJobrCnmy1cdeE7BjsYg=="], "execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], "exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="], + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + "fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="], "fast-copy": ["fast-copy@4.0.3", "", {}, "sha512-58apWr0GUiDFM8+3afrO6eYwJBn9ZAhDOzG3L+/9llab/haCARS2UIfffmOurYLwbgDRs8n0rfr6qAAPEAuAQw=="], @@ -546,10 +506,24 @@ "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + "hast-util-from-parse5": ["hast-util-from-parse5@8.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "hastscript": "^9.0.0", "property-information": "^7.0.0", "vfile": "^6.0.0", "vfile-location": "^5.0.0", "web-namespaces": "^2.0.0" } }, "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg=="], + + "hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="], + + "hast-util-raw": ["hast-util-raw@9.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "hast-util-from-parse5": "^8.0.0", "hast-util-to-parse5": "^8.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "parse5": "^7.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw=="], + + "hast-util-sanitize": ["hast-util-sanitize@5.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "unist-util-position": "^5.0.0" } }, "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg=="], + "hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="], + "hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="], + + "hast-util-to-parse5": ["hast-util-to-parse5@8.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA=="], + "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], + "hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="], + "help-me": ["help-me@5.0.0", "", {}, "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg=="], "highlight.js": ["highlight.js@10.7.3", "", {}, "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A=="], @@ -558,6 +532,8 @@ "hosted-git-info": ["hosted-git-info@9.0.3", "", { "dependencies": { "lru-cache": "^11.1.0" } }, "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg=="], + "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="], + "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], "http-proxy-agent": ["http-proxy-agent@9.1.0", "", { "dependencies": { "agent-base": "9.0.0", "debug": "^4.3.4", "proxy-agent-negotiate": "1.1.0" } }, "sha512-2NxoveTT58mjYT4n3RPTEfCZGLMbidoO8XEieXfpSYxu+PQJ1qpx4ypwH6N+uF9twBPIvRRgvkvW5HUTYWENig=="], @@ -582,14 +558,20 @@ "ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], - "input-otp": ["input-otp@1.4.2", "", { "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA=="], + "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], - "intl-messageformat": ["intl-messageformat@10.7.18", "", { "dependencies": { "@formatjs/ecma402-abstract": "2.3.6", "@formatjs/fast-memoize": "2.2.7", "@formatjs/icu-messageformat-parser": "2.11.4", "tslib": "^2.8.0" } }, "sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g=="], + "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], + + "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], "is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="], + "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], "is-obj": ["is-obj@2.0.0", "", {}, "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w=="], @@ -670,7 +652,7 @@ "lodash.uniqby": ["lodash.uniqby@4.7.0", "", {}, "sha512-e/zcLx6CSbmaEgFHCA7BnoQKyCtKMxnuWrJygbwPs/AIn+IMKl66L8/s+wBUn5LRw2pZx3bUHibiV1b6aTWIww=="], - "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], "lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], @@ -682,24 +664,100 @@ "make-asynchronous": ["make-asynchronous@1.1.0", "", { "dependencies": { "p-event": "^6.0.0", "type-fest": "^4.6.0", "web-worker": "^1.5.0" } }, "sha512-ayF7iT+44LXdxJLTrTd3TLQpFDDvPCBxXxbv+pMUSuHA5Q8zyAfwkRP6aHHwNVFBUFWtxAHqwNJxF8vMZLAbVg=="], + "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], + "marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], "marked-terminal": ["marked-terminal@7.3.0", "", { "dependencies": { "ansi-escapes": "^7.0.0", "ansi-regex": "^6.1.0", "chalk": "^5.4.1", "cli-highlight": "^2.1.11", "cli-table3": "^0.6.5", "node-emoji": "^2.2.0", "supports-hyperlinks": "^3.1.0" }, "peerDependencies": { "marked": ">=1 <16" } }, "sha512-t4rBvPsHc57uE/2nJOLmMbZCQ4tgAccAED3ngXQqW6g+TxA488JzJ+FK3lQkzBQOI1mRV/r/Kq+1ZlJ4D0owQw=="], + "mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="], + + "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="], + + "mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="], + + "mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="], + + "mdast-util-gfm-footnote": ["mdast-util-gfm-footnote@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="], + + "mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg=="], + + "mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="], + + "mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="], + + "mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="], + + "mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="], + + "mdast-util-mdxjs-esm": ["mdast-util-mdxjs-esm@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="], + + "mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="], + "mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="], + "mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="], + + "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="], + "memoirist": ["memoirist@0.4.0", "", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="], "meow": ["meow@13.2.0", "", {}, "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA=="], "merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="], + "micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="], + + "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], + + "micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="], + + "micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="], + + "micromark-extension-gfm-footnote": ["micromark-extension-gfm-footnote@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="], + + "micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="], + + "micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg=="], + + "micromark-extension-gfm-tagfilter": ["micromark-extension-gfm-tagfilter@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="], + + "micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="], + + "micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="], + + "micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="], + + "micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="], + + "micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="], + + "micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="], + "micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], + "micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="], + + "micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="], + + "micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="], + + "micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="], + + "micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="], + "micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="], + "micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="], + + "micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="], + + "micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="], + "micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="], + "micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="], + "micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], @@ -778,11 +836,13 @@ "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], + "parse-json": ["parse-json@8.3.0", "", { "dependencies": { "@babel/code-frame": "^7.26.2", "index-to-position": "^1.1.0", "type-fest": "^4.39.1" } }, "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ=="], "parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="], - "parse5": ["parse5@5.1.1", "", {}, "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug=="], + "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], "parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@6.0.1", "", { "dependencies": { "parse5": "^6.0.1" } }, "sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA=="], @@ -828,8 +888,6 @@ "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], - "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], - "property-information": ["property-information@7.2.0", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="], "proto-list": ["proto-list@1.2.4", "", {}, "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA=="], @@ -846,17 +904,9 @@ "react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="], - "react-aria": ["react-aria@3.48.0", "", { "dependencies": { "@internationalized/date": "^3.12.1", "@internationalized/number": "^3.6.6", "@internationalized/string": "^3.2.8", "@react-types/shared": "^3.34.0", "@swc/helpers": "^0.5.0", "aria-hidden": "^1.2.3", "clsx": "^2.0.0", "react-stately": "3.46.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-jQjd4rBEIMqecBaAKYJbVGK6EqIHLa5znVQ7jwFyK5vCyljoj6KhgtiahmcIPsG5vG5vEDLw+ba+bEWn6A2P4w=="], - - "react-aria-components": ["react-aria-components@1.17.0", "", { "dependencies": { "@internationalized/date": "^3.12.1", "@react-types/shared": "^3.34.0", "@swc/helpers": "^0.5.0", "client-only": "^0.0.1", "react-aria": "3.48.0", "react-stately": "3.46.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-0EyisMgvsFJ2aML3crDYv2tW5vT2Ryf8PGzY/g63JjDdCbLshlwazhS8JNtPF1vkTkungJJ6sVJbKyX+YKSoFA=="], - "react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="], - "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], - - "react-stately": ["react-stately@3.46.0", "", { "dependencies": { "@internationalized/date": "^3.12.1", "@internationalized/number": "^3.6.6", "@internationalized/string": "^3.2.8", "@react-types/shared": "^3.34.0", "@swc/helpers": "^0.5.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-OdxhWvHgs2L4OJGIs7hnuTr5WjjMM6enhNEAMRqiekhF8+ITvA2LRwNftOZwcogaoCslGYq5S2VQTQwnm0GbCA=="], - - "react-transition-group": ["react-transition-group@4.4.5", "", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="], + "react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="], "read-package-up": ["read-package-up@11.0.0", "", { "dependencies": { "find-up-simple": "^1.0.0", "read-pkg": "^9.0.0", "type-fest": "^4.6.0" } }, "sha512-MbgfoNPANMdb4oRBNg5eqLbB2t2r+o5Ua1pNt8BqGp4I0FJZhuVSOj3PaBPni4azWuSzEdNn2evevzVmEk1ohQ=="], @@ -874,8 +924,22 @@ "registry-auth-token": ["registry-auth-token@5.1.1", "", { "dependencies": { "@pnpm/npm-conf": "^3.0.2" } }, "sha512-P7B4+jq8DeD2nMsAcdfaqHbssgHtZ7Z5+++a5ask90fvmJ8p5je4mOa+wzu+DB4vQ5tdJV/xywY+UnVFeQLV5Q=="], + "rehype-raw": ["rehype-raw@7.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-raw": "^9.0.0", "vfile": "^6.0.0" } }, "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww=="], + + "rehype-sanitize": ["rehype-sanitize@6.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-sanitize": "^5.0.0" } }, "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg=="], + + "remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="], + + "remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="], + + "remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="], + + "remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="], + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], + "reselect": ["reselect@5.2.0", "", {}, "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw=="], + "resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="], "rolldown": ["rolldown@1.0.3", "", { "dependencies": { "@oxc-project/types": "=0.133.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.3", "@rolldown/binding-darwin-arm64": "1.0.3", "@rolldown/binding-darwin-x64": "1.0.3", "@rolldown/binding-freebsd-x64": "1.0.3", "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", "@rolldown/binding-linux-arm64-gnu": "1.0.3", "@rolldown/binding-linux-arm64-musl": "1.0.3", "@rolldown/binding-linux-ppc64-gnu": "1.0.3", "@rolldown/binding-linux-s390x-gnu": "1.0.3", "@rolldown/binding-linux-x64-gnu": "1.0.3", "@rolldown/binding-linux-x64-musl": "1.0.3", "@rolldown/binding-openharmony-arm64": "1.0.3", "@rolldown/binding-wasm32-wasi": "1.0.3", "@rolldown/binding-win32-arm64-msvc": "1.0.3", "@rolldown/binding-win32-x64-msvc": "1.0.3" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g=="], @@ -944,6 +1008,10 @@ "strtok3": ["strtok3@10.3.5", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA=="], + "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="], + + "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], + "sumchecker": ["sumchecker@3.0.1", "", { "dependencies": { "debug": "^4.1.0" } }, "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg=="], "super-regex": ["super-regex@1.1.0", "", { "dependencies": { "function-timeout": "^1.0.1", "make-asynchronous": "^1.0.1", "time-span": "^5.1.0" } }, "sha512-WHkws2ZflZe41zj6AolvvmaTrWds/VuyeYr9iPVv/oQeaIoVxMKaushfFWpOGDT+GuBrM/sVqF8KUCYQlSSTdQ=="], @@ -956,8 +1024,6 @@ "tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="], - "tailwind-variants": ["tailwind-variants@3.2.2", "", { "peerDependencies": { "tailwind-merge": ">=3.0.0", "tailwindcss": "*" }, "optionalPeers": ["tailwind-merge"] }, "sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg=="], - "tailwindcss": ["tailwindcss@4.3.0", "", {}, "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q=="], "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], @@ -988,12 +1054,12 @@ "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], + "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "tunnel": ["tunnel@0.0.6", "", {}, "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg=="], - "tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="], - "type-fest": ["type-fest@5.8.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA=="], "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], @@ -1010,6 +1076,8 @@ "unicorn-magic": ["unicorn-magic@0.4.0", "", {}, "sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw=="], + "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], + "unique-string": ["unique-string@3.0.0", "", { "dependencies": { "crypto-random-string": "^4.0.0" } }, "sha512-VGXBUVwxKMBUznyffQweQABPRRW1vHZAbadFZud4pLFAqRGvv/96vafgjWFqzourzr8YonlQiPgH0YCJfawoGQ=="], "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], @@ -1036,10 +1104,14 @@ "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], + "vfile-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="], + "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], "vite": ["vite@8.0.16", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "1.0.3", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.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" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw=="], + "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], + "web-worker": ["web-worker@1.5.0", "", {}, "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw=="], "which": ["which@7.0.0", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-RancgH2dmbLdHl6LRhEqvklWMgl/Hdnun0Y90KhBOLkMefg8Qa7/Zel8Sm+8HEcP6DEjzsWzpkuBQEZok58isA=="], @@ -1070,14 +1142,6 @@ "@actions/http-client/undici": ["undici@6.26.0", "", {}, "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A=="], - "@adobe/react-spectrum/@react-types/shared": ["@react-types/shared@3.35.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-iNWvuzEwANttpQpdlu8nPBtdHb0mcCMj1ZTH//iRB5E/14IAnyRlR25rxH7pNLyzHINsPGEKnWvpwDMCT6vziQ=="], - - "@adobe/react-spectrum/react-aria": ["react-aria@3.49.0", "", { "dependencies": { "@internationalized/date": "^3.12.2", "@internationalized/number": "^3.6.7", "@internationalized/string": "^3.2.9", "@react-types/shared": "^3.35.0", "@swc/helpers": "^0.5.0", "aria-hidden": "^1.2.3", "clsx": "^2.0.0", "react-stately": "3.47.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-4+oK9FwJQWYhyA5zLfj/feOGY0zZbkE1muoF4gyxMroHVypjcYaRSTlJwvxph2zIlxt757KX6xIK2wJ5Aw1Kog=="], - - "@adobe/react-spectrum/react-aria-components": ["react-aria-components@1.18.0", "", { "dependencies": { "@internationalized/date": "^3.12.2", "@react-types/shared": "^3.35.0", "@swc/helpers": "^0.5.0", "client-only": "^0.0.1", "react-aria": "3.49.0", "react-stately": "3.47.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-FhRQjuDkH4WhgFv+O2sYTzK3JzdZTGpBeaqfRlfTo+DcSZzD8elJEkytHe7SDpcexVKeire8NVd7OruZHfCVoA=="], - - "@adobe/react-spectrum/react-stately": ["react-stately@3.47.0", "", { "dependencies": { "@internationalized/date": "^3.12.2", "@internationalized/number": "^3.6.7", "@internationalized/string": "^3.2.9", "@react-types/shared": "^3.35.0", "@swc/helpers": "^0.5.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-H3ar+SOWP920EbVg7qWfP3fZjZiwhlEJAEJQqjt+w8oKijCwFgr0+R4941PIHscOXRNRvEOjvWilitImC0DdBg=="], - "@electron/get/env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="], "@pierre/diffs/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], @@ -1108,6 +1172,8 @@ "cli-highlight/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "cli-highlight/parse5": ["parse5@5.1.1", "", {}, "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug=="], + "cli-highlight/yargs": ["yargs@16.2.2", "", { "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.0", "y18n": "^5.0.5", "yargs-parser": "^20.2.2" } }, "sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w=="], "cli-table3/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -1418,6 +1484,8 @@ "npm/yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], + "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], + "parse-json/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], "parse5-htmlparser2-tree-adapter/parse5": ["parse5@6.0.1", "", {}, "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw=="], diff --git a/docs/SIDEBAR_ITEM.md b/docs/SIDEBAR_ITEM.md index b8a0237..aa7b847 100644 --- a/docs/SIDEBAR_ITEM.md +++ b/docs/SIDEBAR_ITEM.md @@ -5,9 +5,14 @@ Sidebar branch items are split into small components under - `sidebar-branch-item.tsx` renders the clickable row, branch name, status pill, last commit age, and worktree removal action. -- `sidebar-item-icon.tsx` renders the branch or pull request icon. +- `sidebar-pr-people-tooltip.tsx` renders a PR author's avatar and the compact + review-workflow tooltip. +- `sidebar-pr-people.ts` merges requested reviewers, latest reviewers, and + assignees into the tooltip's related-people list. +- `src/lib/components/atoms/worktree-indicator.tsx` provides the shared + icon-only and labeled Git worktree indicator used across the application. - `sidebar-item-status.ts` classifies each branch item and stores the sidebar - label, icon color classes, and pill color classes in one place. + label and pill color classes in one place. - `sidebar-project-item.tsx` groups branch items by project and decides which stale items are visible. @@ -15,42 +20,59 @@ Sidebar branch items are split into small components under `getSidebarItemStatus` classifies a `BranchInfo` using this priority order: -1. `stale-worktree`: `branch.hasWorktree === true` and - `branch.isStale === true`. - - Label: `Stale Worktree` - - Color: danger/red - - Reason: this branch has a local worktree, but the backing branch is stale, - so it needs the strongest warning. -2. `worktree`: `branch.hasWorktree === true`. - - Label: `Worktree` +1. `draft`: the branch belongs to an open draft pull request. + - Label: `Draft` + - Color: muted/neutral +2. `open`: the branch belongs to an open, review-ready pull request. + - Label: `Open` - Color: success/green - - Reason: this branch has a local runnable worktree. -3. `stale`: `branch.isStale === true`. +3. `closed`: the branch belongs to a closed, unmerged pull request. + - Label: `Closed` + - Color: danger/red +4. `merged`: the branch belongs to a merged pull request. + - Label: `Merged` + - Color: done/violet +5. `stale`: a non-PR branch where `branch.isStale === true`. - Label: `Stale` - Color: warning/yellow - - Reason: this branch is stale but does not have a local worktree. -4. `pull-request`: `branch.source === "pull-request"`. - - Label: `PR` - - Color: blue - - Reason: this item came from an open pull request. -5. `branch`: fallback for every other branch. +6. `branch`: fallback for every other non-PR branch. - Label: `Branch` - Color: muted - - Reason: this is a regular branch without a worktree, stale marker, or pull - request source. -The priority order matters. For example, a pull request that is stale is shown -as `Stale`, and a stale branch with a worktree is shown as `Stale Worktree`. +Pull request state always owns the pill. Worktree and stale metadata never +replace `Draft`, `Open`, `Closed`, or `Merged`. + +Open pull requests are always listed, preserving the previous behavior. +Historical closed and merged pull requests enrich only branches that still +exist on `origin`, so deleted PR head branches do not create unusable rows. + +## Identity and State + +The leading position answers who is responsible for a pull request: + +- Pull request rows show the PR author's GitHub avatar. +- Hovering or focusing a PR row shows requested reviewers, latest review + states, and assignees. +- Plain branch and worktree rows do not reserve an identity slot because Git + does not provide a truthful branch owner. +- Non-PR branch names use reduced opacity at rest and return to full foreground + on hover, keyboard focus, or selection. +- Busy terminal state is shown beside the status pill instead of being attached + to the identity area. + +Worktree presence is separate from the status pill. A compact green square with +a filled tree icon appears immediately before the status pill. The marker is +visual-only; the row's accessible label includes the worktree state. +Hovering the marker or status pill shows its meaning after a short delay. + +The connector lands on the first meaningful content in each row: the author +avatar for a pull request, or the branch name for every other branch. ## Color Source Sidebar item colors should be changed only in `sidebar-item-status.ts`. -Both `SidebarBranchItem` and `SidebarItemIcon` consume the same status config: - - `pillClassName` controls the status pill color. -- `iconClassName` controls the icon background and icon color. -This keeps the icon and status pill visually aligned for combined states such as -`Stale Worktree`. Sidebar pills use the `custom` `StatusPill` tone so the -sidebar status config fully owns the background, border, and text colors. +Sidebar pills use the `custom` `StatusPill` tone so the sidebar status config +fully owns the background, border, and text colors. diff --git a/electron/main.ts b/electron/main.ts index 4823188..e86d214 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,10 +1,22 @@ -import { app, BrowserWindow, ipcMain, Menu, shell } from "electron"; +import { + app, + BrowserWindow, + ipcMain, + Menu, + nativeTheme, + shell, +} from "electron"; import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import net from "node:net"; import path from "node:path"; import { TerminalSessionManager } from "./terminal-session-manager.js"; import type { TerminalCreateOptions, TerminalInputOptions } from "./types.js"; +import { + getWindowChromeBackground, + getWindowChromeOptions, + type WindowTheme, +} from "./window-chrome.js"; const projectRoot = path.join(__dirname, ".."); @@ -192,6 +204,9 @@ async function loadRenderer(mainWindow: BrowserWindow, rendererUrl: string) { } async function createWindow() { + const initialTheme: WindowTheme = nativeTheme.shouldUseDarkColors + ? "dark" + : "light"; const mainWindow = new BrowserWindow({ width: 1180, height: 780, @@ -199,7 +214,8 @@ async function createWindow() { minHeight: 620, title: "PR Run", autoHideMenuBar: true, - backgroundColor: "#ffffff", + backgroundColor: getWindowChromeBackground(initialTheme), + ...getWindowChromeOptions(process.platform, initialTheme), webPreferences: { preload: path.join(__dirname, "preload.js"), contextIsolation: true, @@ -288,6 +304,30 @@ app.whenReady() return backendUrl; }); + ipcMain.handle( + "window:setTitleBarTheme", + (event, theme: WindowTheme) => { + const window = BrowserWindow.fromWebContents(event.sender); + + if (!window || window.isDestroyed()) { + return; + } + + const resolvedTheme = theme === "light" ? "light" : "dark"; + window.setBackgroundColor( + getWindowChromeBackground(resolvedTheme), + ); + + const { titleBarOverlay } = getWindowChromeOptions( + process.platform, + resolvedTheme, + ); + + if (typeof titleBarOverlay === "object") { + window.setTitleBarOverlay(titleBarOverlay); + } + }, + ); ipcMain.handle( "terminal:create", diff --git a/electron/preload.ts b/electron/preload.ts index aa9bfe4..5a78434 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -82,6 +82,13 @@ async function requestMany(path: string, init?: RequestInit): Promise { } contextBridge.exposeInMainWorld("prRun", { + platform: process.platform, + setTitleBarTheme(theme: "dark" | "light") { + return ipcRenderer.invoke( + "window:setTitleBarTheme", + theme, + ) as Promise; + }, async getBackendUrl() { return getBackendUrl(); }, diff --git a/electron/window-chrome.test.ts b/electron/window-chrome.test.ts new file mode 100644 index 0000000..2986cdd --- /dev/null +++ b/electron/window-chrome.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "bun:test"; + +import { + getWindowChromeOptions, + WORKSPACE_TITLEBAR_HEIGHT, +} from "./window-chrome"; + +describe("getWindowChromeOptions", () => { + test("uses the renderer-owned overlay outside macOS", () => { + expect(getWindowChromeOptions("win32", "dark")).toMatchObject({ + titleBarOverlay: { + height: WORKSPACE_TITLEBAR_HEIGHT, + }, + titleBarStyle: "hidden", + }); + }); + + test("keeps macOS traffic lights in the inset title bar", () => { + expect(getWindowChromeOptions("darwin", "light")).toMatchObject({ + titleBarStyle: "hiddenInset", + trafficLightPosition: { x: 14, y: 13 }, + }); + }); +}); diff --git a/electron/window-chrome.ts b/electron/window-chrome.ts new file mode 100644 index 0000000..0ff39d1 --- /dev/null +++ b/electron/window-chrome.ts @@ -0,0 +1,44 @@ +import type { BrowserWindowConstructorOptions } from "electron"; + +export const WORKSPACE_TITLEBAR_HEIGHT = 36; + +export type WindowTheme = "dark" | "light"; + +const windowColors = { + dark: { + background: "#17191c", + symbol: "#e8eaed", + }, + light: { + background: "#f5f6f7", + symbol: "#25272a", + }, +} as const; + +export function getWindowChromeOptions( + platform: NodeJS.Platform, + theme: WindowTheme, +): Pick< + BrowserWindowConstructorOptions, + "titleBarOverlay" | "titleBarStyle" | "trafficLightPosition" +> { + if (platform === "darwin") { + return { + titleBarStyle: "hiddenInset", + trafficLightPosition: { x: 14, y: 13 }, + }; + } + + return { + titleBarOverlay: { + color: windowColors[theme].background, + height: WORKSPACE_TITLEBAR_HEIGHT, + symbolColor: windowColors[theme].symbol, + }, + titleBarStyle: "hidden", + }; +} + +export function getWindowChromeBackground(theme: WindowTheme) { + return windowColors[theme].background; +} diff --git a/package.json b/package.json index 86dd5f5..76ee818 100644 --- a/package.json +++ b/package.json @@ -46,16 +46,15 @@ "typecheck": "tsc -b" }, "dependencies": { + "@base-ui/react": "^1.4.1", "@elysiajs/cors": "^1.4.2", "@fontsource-variable/dm-sans": "^5.2.8", "@fontsource/jetbrains-mono": "^5.2.8", - "@heroicons/react": "^2.2.0", - "@heroui/react": "^3.1.0", - "@heroui/styles": "^3.1.0", "@pierre/diffs": "^1.2.10", "@tanstack/react-query": "^5.101.0", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", + "class-variance-authority": "^0.7.1", "effect": "^3.21.3", "elysia": "^1.4.28", "ky": "^2.0.2", @@ -66,6 +65,11 @@ "pino-pretty": "^13.1.3", "react": "^19.2.7", "react-dom": "^19.2.7", + "react-markdown": "^10.1.0", + "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", + "remark-gfm": "^4.0.1", + "tailwind-merge": "^3.4.0", "zod": "^4.4.3", "zustand": "^5.0.14" }, diff --git a/src/App.tsx b/src/App.tsx index e5c0f78..06c6a96 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,11 @@ +import { useEffect } from "react"; + import { PrRunApp } from "@/lib/components/templates/pr-run-app"; +import { syncWindowControlsOverlayClass } from "@/lib/window-controls-overlay"; function App() { + useEffect(() => syncWindowControlsOverlayClass(), []); + return ; } diff --git a/src/backend/handlers/git/activity.test.ts b/src/backend/handlers/git/activity.test.ts new file mode 100644 index 0000000..2a16f3e --- /dev/null +++ b/src/backend/handlers/git/activity.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from "bun:test"; + +import { compareActivityItems } from "@/backend/handlers/git/activity"; +import type { WorktreeActivityItem } from "@/backend/types"; + +describe("compareActivityItems", () => { + test("sorts oldest activity first with stable ids", () => { + const items = [ + { id: "b", occurredAt: "2026-07-10T00:00:00Z" }, + { id: "c", occurredAt: "2026-07-09T00:00:00Z" }, + { id: "a", occurredAt: "2026-07-10T00:00:00Z" }, + ] as WorktreeActivityItem[]; + + expect(items.sort(compareActivityItems).map((item) => item.id)).toEqual( + ["c", "a", "b"], + ); + }); +}); diff --git a/src/backend/handlers/git/activity.ts b/src/backend/handlers/git/activity.ts new file mode 100644 index 0000000..a5c61fc --- /dev/null +++ b/src/backend/handlers/git/activity.ts @@ -0,0 +1,168 @@ +import { tryPromise } from "@/backend/handlers/error"; +import { + findGitHubRepository, + getGitHubPullRequestIdentity, + GhCommandError, +} from "@/backend/handlers/git/github"; +import { getGitHubReviewSnapshot } from "@/backend/handlers/git/github-activity"; +import { getCommitHistory } from "@/backend/handlers/git/history"; +import { logger } from "@/backend/logger"; +import type { + ProjectConfig, + WorktreeActivityItem, + WorktreeActivityResult, +} from "@/backend/types"; + +export async function getWorktreeActivity( + project: ProjectConfig, + branch: string, + baseBranch?: string, + pullRequestNumber?: number, +): Promise { + const commits = await getCommitHistory(project, branch, baseBranch); + const baseCommits = commits + .filter((commit) => !commit.isInSelectedBranch) + .reverse(); + const branchItems: WorktreeActivityItem[] = commits + .filter((commit) => commit.isInSelectedBranch) + .reverse() + .map((commit) => ({ + commit, + id: `commit:${commit.hash}`, + occurredAt: commit.date, + type: "commit", + })); + + if (!pullRequestNumber) { + return { + baseCommits, + integration: { + message: "Review activity is available for pull requests.", + reason: "not-a-pull-request", + status: "unavailable", + }, + items: branchItems, + reviewComments: [], + }; + } + + const repository = await findGitHubRepository(project); + + if (!repository) { + return unavailableActivity( + baseCommits, + branchItems, + "GitHub authentication is unavailable for this project.", + "not-authenticated", + ); + } + + const [identityError, identity] = await tryPromise( + getGitHubPullRequestIdentity(project, repository, pullRequestNumber), + ); + + if (identityError) { + if ( + identityError instanceof GhCommandError && + identityError.httpStatus === 404 + ) { + return unavailableActivity( + baseCommits, + branchItems, + `Pull request #${pullRequestNumber} was not found.`, + "pull-request-not-found", + ); + } + + return unavailableActivity( + baseCommits, + branchItems, + "GitHub review activity could not be loaded.", + "request-failed", + ); + } + + if (identity.headRefName !== branch.replace(/^origin\//, "")) { + return unavailableActivity( + baseCommits, + branchItems, + `Pull request #${pullRequestNumber} does not belong to ${branch}.`, + "pull-request-mismatch", + ); + } + + const [error, snapshot] = await tryPromise( + getGitHubReviewSnapshot(project, repository, pullRequestNumber), + ); + + if (error) { + logger.warn( + { error, projectId: project.id, pullRequestNumber }, + "failed to load pull request review activity", + ); + return unavailableActivity( + baseCommits, + branchItems, + "GitHub review activity could not be loaded.", + "request-failed", + ); + } + + const items: WorktreeActivityItem[] = [ + ...branchItems, + ...snapshot.comments.map( + (comment): WorktreeActivityItem => ({ + comment, + id: `comment:${comment.id}`, + occurredAt: comment.createdAt, + type: "comment", + }), + ), + ...snapshot.reviews.map( + (review): WorktreeActivityItem => ({ + id: `review:${review.id}`, + occurredAt: review.submittedAt, + review, + type: "review", + }), + ), + ].sort(compareActivityItems); + + return { + baseCommits, + integration: { status: "available" }, + items, + pendingReview: snapshot.pendingReview, + reviewComments: snapshot.reviewComments, + reviewDecision: snapshot.reviewDecision, + viewer: snapshot.viewer, + }; +} + +function unavailableActivity( + baseCommits: WorktreeActivityResult["baseCommits"], + items: WorktreeActivityItem[], + message: string, + reason: + | "not-authenticated" + | "pull-request-mismatch" + | "pull-request-not-found" + | "request-failed", +): WorktreeActivityResult { + return { + baseCommits, + integration: { message, reason, status: "unavailable" }, + items, + reviewComments: [], + }; +} + +export function compareActivityItems( + left: WorktreeActivityItem, + right: WorktreeActivityItem, +) { + const dateDifference = + Date.parse(left.occurredAt) - Date.parse(right.occurredAt); + + return dateDifference || left.id.localeCompare(right.id); +} diff --git a/src/backend/handlers/git/command.ts b/src/backend/handlers/git/command.ts index 13f6bd0..5478f98 100644 --- a/src/backend/handlers/git/command.ts +++ b/src/backend/handlers/git/command.ts @@ -5,34 +5,7 @@ import { logger } from "@/backend/logger"; import { clearSshPassphrase, getSshPassphrase } from "@/backend/ssh-passphrase"; import { ApiError } from "@/backend/types"; -type GitCommand = - | ["rev-parse", "--show-toplevel"] - | ["rev-parse", "--verify", string] - | ["fetch", "origin"] - | ["symbolic-ref", "--quiet", "--short", string] - | [ - "for-each-ref", - "--sort=-committerdate", - "--format=%(refname:short)|%(committerdate:unix)", - "refs/remotes/origin", - ] - | ["for-each-ref", "--format=%(refname)", string] - | ["worktree", "add", "--track", "-b", string, string, string] - | ["worktree", "add", string, string] - | ["worktree", "add", "--detach", string, string] - | ["worktree", "remove", "--force", string] - | ["reset", "--hard", string] - | ["worktree", "list", "--porcelain"] - | ["log", string, "--pretty=format:%H"] - | ["diff", "--numstat", string] - | ["diff", "--patch", string] - | [ - "log", - string, - "-n", - "30", - "--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%cI", - ]; +type GitCommand = string[]; type GitResultMode = "quiet" | "text"; type GitCommandStage = "git" | "git:sshpass"; diff --git a/src/backend/handlers/git/diff.test.ts b/src/backend/handlers/git/diff.test.ts new file mode 100644 index 0000000..4bf6891 --- /dev/null +++ b/src/backend/handlers/git/diff.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from "bun:test"; + +import { + parseDiffNameStatus, + parseDiffNumstat, +} from "@/backend/handlers/git/diff"; + +describe("diff metadata", () => { + test("parses binary files and rename destinations", () => { + expect(parseDiffNumstat("-\t-\tpublic/logo.png\n")).toEqual([ + { + additions: 0, + deletions: 0, + path: "public/logo.png", + status: "binary", + }, + ]); + expect(parseDiffNameStatus("R100\tsrc/old.ts\tsrc/new.ts\n")).toEqual([ + { + path: "src/new.ts", + previousPath: "src/old.ts", + status: "renamed", + }, + ]); + }); +}); diff --git a/src/backend/handlers/git/diff.ts b/src/backend/handlers/git/diff.ts index 8952a64..117ad9b 100644 --- a/src/backend/handlers/git/diff.ts +++ b/src/backend/handlers/git/diff.ts @@ -22,12 +22,29 @@ async function getBranchDiffFiles( return []; } - const output = await gitText(projectPath, [ - "diff", - "--numstat", - `${defaultRemoteName}...${remoteName}`, + const comparison = `${defaultRemoteName}...${remoteName}`; + const [numstatOutput, nameStatusOutput] = await Promise.all([ + gitText(projectPath, [ + "diff", + "--numstat", + "--find-renames", + comparison, + ]), + gitText(projectPath, [ + "diff", + "--name-status", + "--find-renames", + comparison, + ]), ]); + return mergeDiffFileMetadata( + parseDiffNumstat(numstatOutput), + parseDiffNameStatus(nameStatusOutput), + ); +} + +export function parseDiffNumstat(output: string): BranchDiffFile[] { return output .split("\n") .map((line) => line.trim()) @@ -35,16 +52,80 @@ async function getBranchDiffFiles( .map((line) => { const [additionsValue, deletionsValue, ...pathParts] = line.split("\t"); + const rawPath = pathParts.join("\t"); + const path = normalizeNumstatPath(rawPath); + const isBinary = additionsValue === "-" || deletionsValue === "-"; return { - path: pathParts.join("\t"), additions: numberOrZero(additionsValue), deletions: numberOrZero(deletionsValue), + path, + status: isBinary ? ("binary" as const) : ("modified" as const), }; }) .filter((file) => file.path.length > 0); } +type DiffNameStatus = Pick; + +export function parseDiffNameStatus(output: string): DiffNameStatus[] { + return output + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .map((line): DiffNameStatus => { + const [code = "M", firstPath = "", secondPath] = line.split("\t"); + const statusCode = code[0]; + + if (statusCode === "R" && secondPath) { + return { + path: secondPath, + previousPath: firstPath, + status: "renamed", + }; + } + + const status = + statusCode === "A" + ? "added" + : statusCode === "D" + ? "deleted" + : "modified"; + + return { path: firstPath, status }; + }); +} + +function mergeDiffFileMetadata( + files: BranchDiffFile[], + statuses: DiffNameStatus[], +) { + const statusByPath = new Map(statuses.map((item) => [item.path, item])); + + return files.map((file) => { + const metadata = statusByPath.get(file.path); + + return { + ...file, + previousPath: metadata?.previousPath, + status: + file.status === "binary" + ? file.status + : (metadata?.status ?? file.status), + }; + }); +} + +function normalizeNumstatPath(path: string) { + const renameMatch = path.match(/^(.*?)\{(.*?) => (.*?)\}(.*)$/); + + if (!renameMatch) { + return path; + } + + return `${renameMatch[1]}${renameMatch[3]}${renameMatch[4]}`; +} + async function getBranchDiffPatch( projectPath: string, defaultRemoteName: string | undefined, @@ -84,7 +165,15 @@ export async function getBranchDiff( ]); return { + additions: files.reduce( + (total, file) => total + file.additions, + 0, + ), branch: name, + deletions: files.reduce( + (total, file) => total + file.deletions, + 0, + ), files, patch, }; diff --git a/src/backend/handlers/git/github-activity.test.ts b/src/backend/handlers/git/github-activity.test.ts new file mode 100644 index 0000000..e1ed58e --- /dev/null +++ b/src/backend/handlers/git/github-activity.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "bun:test"; + +import { normalizeReviewComment } from "@/backend/handlers/git/github-activity"; + +const baseComment = { + body: "Comment", + created_at: "2026-07-10T12:00:00Z", + html_url: "https://github.com/example/repo/pull/1#discussion_r1", + id: 1, + path: "src/app.ts", +}; + +describe("normalizeReviewComment", () => { + test("distinguishes file comments from outdated line comments", () => { + expect( + normalizeReviewComment( + { ...baseComment, line: null, subject_type: "file" }, + "viewer", + ), + ).toMatchObject({ isOutdated: false, subjectType: "file" }); + expect( + normalizeReviewComment( + { + ...baseComment, + line: null, + original_line: 42, + subject_type: "line", + }, + "viewer", + ), + ).toMatchObject({ + isOutdated: true, + line: 42, + subjectType: "line", + }); + }); +}); diff --git a/src/backend/handlers/git/github-activity.ts b/src/backend/handlers/git/github-activity.ts new file mode 100644 index 0000000..4f77db6 --- /dev/null +++ b/src/backend/handlers/git/github-activity.ts @@ -0,0 +1,263 @@ +import { + ghText, + normalizeAuthor, + parseJson, +} from "@/backend/handlers/git/github"; +import type { + GitHubRepositoryInfo, + GitHubUserInfo, + PendingPullRequestReview, + ProjectConfig, + PullRequestGeneralComment, + PullRequestReviewActivity, + PullRequestReviewComment, + PullRequestReviewState, +} from "@/backend/types"; + +type GraphqlAuthor = { + avatarUrl?: string | null; + login?: string | null; + url?: string | null; +}; + +type PullRequestViewPayload = { + comments?: Array<{ + author?: GraphqlAuthor | null; + body?: string; + createdAt?: string; + id?: string; + url?: string; + viewerDidAuthor?: boolean; + }>; + headRefOid?: string; + reviewDecision?: string; +}; + +type ApiUser = { + avatar_url?: string; + html_url?: string; + login?: string; +}; + +type ApiReview = { + body?: string | null; + html_url?: string; + id: number; + node_id?: string; + state?: string; + submitted_at?: string | null; + user?: ApiUser; +}; + +type ApiReviewComment = { + body?: string; + created_at?: string; + html_url?: string; + id: number; + line?: number | null; + original_line?: number | null; + original_start_line?: number | null; + path?: string; + pull_request_review_id?: number; + side?: "LEFT" | "RIGHT" | null; + start_line?: number | null; + start_side?: "LEFT" | "RIGHT" | null; + subject_type?: "file" | "line"; + user?: ApiUser; +}; + +export type GitHubReviewSnapshot = { + comments: PullRequestGeneralComment[]; + headRefOid: string; + pendingReview?: PendingPullRequestReview; + reviewComments: PullRequestReviewComment[]; + reviewDecision?: string; + reviews: PullRequestReviewActivity[]; + viewer: GitHubUserInfo; +}; + +export async function getGitHubReviewSnapshot( + project: ProjectConfig, + repository: GitHubRepositoryInfo, + pullRequestNumber: number, +): Promise { + const repositoryName = repository.nameWithOwner; + const [viewOutput, reviewsOutput, reviewCommentsOutput, viewerOutput] = + await Promise.all([ + ghText( + [ + "pr", + "view", + String(pullRequestNumber), + "--repo", + repositoryName, + "--json", + "comments,headRefOid,reviewDecision", + ], + { cwd: project.path }, + ), + ghText( + [ + "api", + `repos/${repositoryName}/pulls/${pullRequestNumber}/reviews`, + "--paginate", + "--slurp", + ], + { cwd: project.path }, + ), + ghText( + [ + "api", + `repos/${repositoryName}/pulls/${pullRequestNumber}/comments`, + "--paginate", + "--slurp", + ], + { cwd: project.path }, + ), + ghText(["api", "user"], { cwd: project.path }), + ]); + const [view, apiReviews, apiReviewComments, apiViewer] = await Promise.all([ + parseJson(viewOutput), + parseJson(reviewsOutput).then((pages) => pages.flat()), + parseJson(reviewCommentsOutput).then((pages) => + pages.flat(), + ), + parseJson(viewerOutput), + ]); + const viewer = normalizeApiUser(apiViewer); + const reviewComments = apiReviewComments.map((comment) => + normalizeReviewComment(comment, viewer.login), + ); + const publishedReviews = apiReviews.filter( + (review) => normalizeReviewState(review.state) !== "PENDING", + ); + const pendingApiReview = apiReviews.find( + (review) => + normalizeReviewState(review.state) === "PENDING" && + review.user?.login === viewer.login, + ); + + return { + comments: (view.comments ?? []).flatMap((comment) => { + const author = + normalizeAuthor(comment.author) ?? + normalizeGraphqlUser(comment.author); + + if (!author || !comment.id || !comment.createdAt || !comment.url) { + return []; + } + + return [ + { + author, + body: comment.body ?? "", + createdAt: comment.createdAt, + id: comment.id, + url: comment.url, + viewerDidAuthor: Boolean(comment.viewerDidAuthor), + }, + ]; + }), + headRefOid: view.headRefOid ?? "", + pendingReview: pendingApiReview + ? { + body: pendingApiReview.body ?? "", + comments: reviewComments.filter( + (comment) => + comment.pullRequestReviewId === pendingApiReview.id, + ), + id: pendingApiReview.id, + nodeId: pendingApiReview.node_id ?? "", + } + : undefined, + reviewComments, + reviewDecision: view.reviewDecision, + reviews: publishedReviews.flatMap((review) => { + if (!review.submitted_at) { + return []; + } + + return [ + { + author: normalizeApiUser(review.user), + body: review.body ?? "", + comments: reviewComments.filter( + (comment) => comment.pullRequestReviewId === review.id, + ), + id: String(review.id), + state: normalizeReviewState(review.state), + submittedAt: review.submitted_at, + url: review.html_url, + }, + ]; + }), + viewer, + }; +} + +export function normalizeReviewComment( + comment: ApiReviewComment, + viewerLogin: string, +): PullRequestReviewComment { + return { + author: normalizeApiUser(comment.user), + body: comment.body ?? "", + createdAt: comment.created_at ?? new Date(0).toISOString(), + id: comment.id, + isOutdated: comment.subject_type !== "file" && comment.line == null, + line: comment.line ?? comment.original_line ?? undefined, + path: comment.path ?? "Unknown file", + pullRequestReviewId: comment.pull_request_review_id, + side: comment.side ?? undefined, + startLine: + comment.start_line ?? comment.original_start_line ?? undefined, + startSide: comment.start_side ?? undefined, + subjectType: comment.subject_type === "file" ? "file" : "line", + url: comment.html_url ?? "", + viewerDidAuthor: comment.user?.login === viewerLogin, + }; +} + +function normalizeApiUser(user: ApiUser | undefined): GitHubUserInfo { + const login = user?.login ?? "unknown"; + + return { + avatarUrl: + user?.avatar_url ?? + `https://github.com/identicons/${encodeURIComponent(login)}.png`, + login, + url: user?.html_url ?? `https://github.com/${login}`, + }; +} + +function normalizeGraphqlUser( + user: GraphqlAuthor | null | undefined, +): GitHubUserInfo | undefined { + if (!user?.login) { + return undefined; + } + + return { + avatarUrl: + user.avatarUrl ?? + `https://github.com/${encodeURIComponent(user.login)}.png?size=64`, + login: user.login, + url: user.url ?? `https://github.com/${user.login}`, + }; +} + +function normalizeReviewState( + state: string | undefined, +): PullRequestReviewState { + if ( + state === "APPROVED" || + state === "CHANGES_REQUESTED" || + state === "COMMENTED" || + state === "DISMISSED" || + state === "PENDING" + ) { + return state; + } + + return "COMMENTED"; +} diff --git a/src/backend/handlers/git/github-review.test.ts b/src/backend/handlers/git/github-review.test.ts new file mode 100644 index 0000000..716a862 --- /dev/null +++ b/src/backend/handlers/git/github-review.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "bun:test"; + +import { validateReviewSubmission } from "@/backend/handlers/git/github-review"; + +describe("validateReviewSubmission", () => { + test("requires content for new comment reviews", () => { + expect(() => validateReviewSubmission("COMMENT", "", 0)).toThrow(); + expect(validateReviewSubmission("COMMENT", " summary ", 0)).toBe( + "summary", + ); + }); + + test("allows pending inline comments without a summary", () => { + expect(validateReviewSubmission("COMMENT", undefined, 2)).toBe(""); + }); + + test("always requires a requested-changes explanation", () => { + expect(() => + validateReviewSubmission("REQUEST_CHANGES", "", 2), + ).toThrow(); + }); +}); diff --git a/src/backend/handlers/git/github-review.ts b/src/backend/handlers/git/github-review.ts new file mode 100644 index 0000000..90d696a --- /dev/null +++ b/src/backend/handlers/git/github-review.ts @@ -0,0 +1,352 @@ +import { tryPromise } from "@/backend/handlers/error"; +import { getGitHubReviewSnapshot } from "@/backend/handlers/git/github-activity"; +import { + ApiError, + type GitHubRepositoryInfo, + type ProjectConfig, + type PullRequestReviewMutationResult, + type ReviewCommentMode, + type ReviewEvent, +} from "@/backend/types"; + +export type ReviewCommentInput = { + body: string; + line: number; + mode: ReviewCommentMode; + path: string; + side: "LEFT" | "RIGHT"; + startLine?: number; + startSide?: "LEFT" | "RIGHT"; +}; + +type GitHubMutationPayload = { + html_url?: string; + id?: number | string; + node_id?: string; +}; + +type AddReviewThreadPayload = { + data?: { + addPullRequestReviewThread?: { + thread?: { + comments?: { + nodes?: Array<{ + databaseId?: number; + url?: string; + }>; + }; + }; + }; + }; + errors?: Array<{ message?: string }>; +}; + +export async function addPullRequestComment( + project: ProjectConfig, + repository: GitHubRepositoryInfo, + pullRequestNumber: number, + body: string, +): Promise { + return await runGitHubMutation(async () => { + const result = await ghApiJson( + project, + `repos/${repository.nameWithOwner}/issues/${pullRequestNumber}/comments`, + "POST", + { body }, + ); + + return mutationResult(result); + }); +} + +export async function addPullRequestReviewComment( + project: ProjectConfig, + repository: GitHubRepositoryInfo, + pullRequestNumber: number, + input: ReviewCommentInput, +): Promise { + return await runGitHubMutation(async () => { + const snapshot = await getGitHubReviewSnapshot( + project, + repository, + pullRequestNumber, + ); + const payload = { + body: input.body, + line: input.line, + path: input.path, + side: input.side, + ...(input.startLine + ? { + start_line: input.startLine, + start_side: input.startSide ?? input.side, + } + : {}), + }; + + if (input.mode === "immediate") { + const result = await ghApiJson( + project, + `repos/${repository.nameWithOwner}/pulls/${pullRequestNumber}/comments`, + "POST", + { ...payload, commit_id: snapshot.headRefOid }, + ); + + return mutationResult(result); + } + + const pendingReview = + snapshot.pendingReview ?? + (await (async () => { + const created = await ghApiJson( + project, + `repos/${repository.nameWithOwner}/pulls/${pullRequestNumber}/reviews`, + "POST", + { commit_id: snapshot.headRefOid }, + ); + + return created.id && created.node_id + ? { + body: "", + comments: [], + id: Number(created.id), + nodeId: created.node_id, + } + : undefined; + })()); + + if (!pendingReview?.nodeId) { + throw new Error("GitHub did not return a pending review node id."); + } + + const result = await ghGraphql( + project, + `mutation AddReviewThread($input: AddPullRequestReviewThreadInput!) { + addPullRequestReviewThread(input: $input) { + thread { comments(first: 1) { nodes { databaseId url } } } + } + }`, + { + input: { + body: input.body, + line: input.line, + path: input.path, + pullRequestReviewId: pendingReview.nodeId, + side: input.side, + ...(input.startLine + ? { + startLine: input.startLine, + startSide: input.startSide ?? input.side, + } + : {}), + }, + }, + ); + const comment = + result.data?.addPullRequestReviewThread?.thread?.comments + ?.nodes?.[0]; + + if (result.errors?.length || !comment) { + throw new Error( + result.errors?.map((error) => error.message).join("; ") || + "GitHub did not return the created review comment.", + ); + } + + return { + id: comment.databaseId ?? crypto.randomUUID(), + url: comment.url, + }; + }); +} + +export async function submitPullRequestReview( + project: ProjectConfig, + repository: GitHubRepositoryInfo, + pullRequestNumber: number, + event: ReviewEvent, + body?: string, +): Promise { + return await runGitHubMutation(async () => { + const snapshot = await getGitHubReviewSnapshot( + project, + repository, + pullRequestNumber, + ); + const trimmedBody = validateReviewSubmission( + event, + body, + snapshot.pendingReview?.comments.length ?? 0, + ); + + const endpoint = snapshot.pendingReview + ? `repos/${repository.nameWithOwner}/pulls/${pullRequestNumber}/reviews/${snapshot.pendingReview.id}/events` + : `repos/${repository.nameWithOwner}/pulls/${pullRequestNumber}/reviews`; + const result = await ghApiJson( + project, + endpoint, + "POST", + { + body: trimmedBody || undefined, + commit_id: snapshot.pendingReview + ? undefined + : snapshot.headRefOid, + event, + }, + ); + + return mutationResult(result); + }); +} + +export function validateReviewSubmission( + event: ReviewEvent, + body: string | undefined, + pendingCommentCount: number, +) { + const trimmedBody = body?.trim() ?? ""; + + if (event === "REQUEST_CHANGES" && !trimmedBody) { + throw new ApiError( + "BAD_REQUEST", + "Explain the requested changes before submitting the review.", + 400, + ); + } + + if (event === "COMMENT" && !trimmedBody && pendingCommentCount === 0) { + throw new ApiError( + "BAD_REQUEST", + "Add a review summary or an inline comment before submitting.", + 400, + ); + } + + return trimmedBody; +} + +export async function discardPendingPullRequestReview( + project: ProjectConfig, + repository: GitHubRepositoryInfo, + pullRequestNumber: number, +): Promise { + return await runGitHubMutation(async () => { + const snapshot = await getGitHubReviewSnapshot( + project, + repository, + pullRequestNumber, + ); + + if (!snapshot.pendingReview) { + throw new ApiError( + "REVIEW_NOT_FOUND", + "There is no pending review to discard.", + 404, + ); + } + + await ghApiJson( + project, + `repos/${repository.nameWithOwner}/pulls/${pullRequestNumber}/reviews/${snapshot.pendingReview.id}`, + "DELETE", + ); + + return { id: snapshot.pendingReview.id }; + }); +} + +async function runGitHubMutation(mutation: () => Promise) { + const [error, result] = await tryPromise(mutation()); + + if (error) { + if (error instanceof ApiError) { + throw error; + } + + throw new ApiError( + "GITHUB_INTEGRATION_FAILED", + "GitHub could not complete this review action.", + 502, + error.message, + ); + } + + return result; +} + +async function ghApiJson( + project: ProjectConfig, + endpoint: string, + method: "DELETE" | "POST", + payload?: Record, +) { + const childProcess = Bun.spawn( + ["gh", "api", endpoint, "--method", method, "--input", "-"], + { + cwd: project.path, + env: { ...process.env, GH_PROMPT_DISABLED: "1" }, + stderr: "pipe", + stdin: "pipe", + stdout: "pipe", + }, + ); + + childProcess.stdin.write(JSON.stringify(removeUndefined(payload ?? {}))); + childProcess.stdin.end(); + + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(childProcess.stdout).text(), + new Response(childProcess.stderr).text(), + childProcess.exited, + ]); + + if (exitCode !== 0) { + throw new Error(stderr.trim() || `gh exited with code ${exitCode}.`); + } + + return stdout.trim() ? (JSON.parse(stdout) as T) : ({} as T); +} + +async function ghGraphql( + project: ProjectConfig, + query: string, + variables: Record, +) { + const childProcess = Bun.spawn(["gh", "api", "graphql", "--input", "-"], { + cwd: project.path, + env: { ...process.env, GH_PROMPT_DISABLED: "1" }, + stderr: "pipe", + stdin: "pipe", + stdout: "pipe", + }); + + childProcess.stdin.write(JSON.stringify({ query, variables })); + childProcess.stdin.end(); + + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(childProcess.stdout).text(), + new Response(childProcess.stderr).text(), + childProcess.exited, + ]); + + if (exitCode !== 0) { + throw new Error(stderr.trim() || `gh exited with code ${exitCode}.`); + } + + return JSON.parse(stdout) as T; +} + +function removeUndefined(value: Record) { + return Object.fromEntries( + Object.entries(value).filter((entry) => entry[1] !== undefined), + ); +} + +function mutationResult( + payload: GitHubMutationPayload, +): PullRequestReviewMutationResult { + return { + id: payload.id ?? crypto.randomUUID(), + url: payload.html_url, + }; +} diff --git a/src/backend/handlers/git/github.test.ts b/src/backend/handlers/git/github.test.ts new file mode 100644 index 0000000..0a5295b --- /dev/null +++ b/src/backend/handlers/git/github.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, test } from "bun:test"; + +import { normalizeGitHubPullRequests } from "@/backend/handlers/git/github"; + +describe("normalizeGitHubPullRequests", () => { + test("normalizes PR identity and review workflow people", () => { + const [pullRequest] = normalizeGitHubPullRequests([ + { + assignees: [{ login: "assignee" }], + additions: 12, + author: { + avatarUrl: "https://avatars.example/author.png", + login: "author", + url: "https://github.com/author", + }, + baseRefName: "main", + changedFiles: 4, + deletions: 3, + headRefName: "feat/sidebar-identity", + latestReviews: [ + { + author: { login: "reviewer" }, + state: "APPROVED", + }, + ], + isDraft: false, + number: 42, + reviewRequests: [{ login: "requested-reviewer" }], + state: "OPEN", + title: "Show PR identity in the sidebar", + updatedAt: "2026-07-10T12:00:00Z", + url: "https://github.com/example/repository/pull/42", + }, + ]); + + expect(pullRequest).toMatchObject({ + additions: 12, + assignees: [ + { + avatarUrl: "https://github.com/assignee.png?size=64", + login: "assignee", + url: "https://github.com/assignee", + }, + ], + author: { + avatarUrl: "https://avatars.example/author.png", + login: "author", + url: "https://github.com/author", + }, + changedFiles: 4, + deletions: 3, + latestReviews: [ + { + author: { + avatarUrl: "https://github.com/reviewer.png?size=64", + login: "reviewer", + url: "https://github.com/reviewer", + }, + state: "APPROVED", + }, + ], + isDraft: false, + reviewRequests: [ + { + avatarUrl: + "https://github.com/requested-reviewer.png?size=64", + login: "requested-reviewer", + url: "https://github.com/requested-reviewer", + }, + ], + state: "OPEN", + }); + }); + + test("uses empty participant arrays and ignores entries without logins", () => { + const [emptyPullRequest, filteredPullRequest] = + normalizeGitHubPullRequests([ + { + baseRefName: "main", + headRefName: "empty", + isDraft: false, + number: 1, + state: "OPEN", + title: "Empty workflow", + url: "https://github.com/example/repository/pull/1", + }, + { + assignees: [null, { login: null }], + baseRefName: "main", + headRefName: "filtered", + latestReviews: [ + null, + { author: { login: null }, state: "APPROVED" }, + ], + number: 2, + reviewRequests: [null, { login: null }], + state: "CLOSED", + title: "Filtered workflow", + url: "https://github.com/example/repository/pull/2", + }, + ]); + + expect(emptyPullRequest?.assignees).toEqual([]); + expect(emptyPullRequest?.latestReviews).toEqual([]); + expect(emptyPullRequest?.reviewRequests).toEqual([]); + expect(filteredPullRequest?.assignees).toEqual([]); + expect(filteredPullRequest?.latestReviews).toEqual([]); + expect(filteredPullRequest?.reviewRequests).toEqual([]); + }); + + test.each([ + { isDraft: false, state: "OPEN" }, + { isDraft: true, state: "OPEN" }, + { isDraft: false, state: "CLOSED" }, + { isDraft: false, state: "MERGED" }, + ] as const)( + "normalizes $state with draft=$isDraft", + ({ state, isDraft }) => { + const [pullRequest] = normalizeGitHubPullRequests([ + { + baseRefName: "main", + headRefName: `${state.toLowerCase()}-${String(isDraft)}`, + isDraft, + number: 10, + state, + title: `${state} pull request`, + url: "https://github.com/example/repository/pull/10", + }, + ]); + + expect(pullRequest).toMatchObject({ isDraft, state }); + }, + ); + + test("rejects missing and unsupported PR states", () => { + const pullRequests = normalizeGitHubPullRequests([ + { + baseRefName: "main", + headRefName: "missing-state", + number: 1, + title: "Missing state", + url: "https://github.com/example/repository/pull/1", + }, + { + baseRefName: "main", + headRefName: "unsupported-state", + number: 2, + state: "UNKNOWN", + title: "Unsupported state", + url: "https://github.com/example/repository/pull/2", + }, + ]); + + expect(pullRequests).toEqual([]); + }); +}); diff --git a/src/backend/handlers/git/github.ts b/src/backend/handlers/git/github.ts index afd6c2f..cfdbc98 100644 --- a/src/backend/handlers/git/github.ts +++ b/src/backend/handlers/git/github.ts @@ -4,6 +4,9 @@ import type { GitHubRepositoryInfo, GitHubUserInfo, ProjectConfig, + PullRequestLatestReview, + PullRequestReviewState, + PullRequestState, } from "@/backend/types"; type GitHubRepositoryPayload = GitHubRepositoryInfo & { @@ -19,25 +22,46 @@ type GitHubAuthorPayload = { }; type GitHubPullRequestPayload = { + additions?: number | null; + assignees?: (GitHubAuthorPayload | null)[] | null; author?: GitHubAuthorPayload | null; baseRefName?: string | null; + changedFiles?: number | null; + deletions?: number | null; headRefName?: string | null; + isDraft?: boolean | null; + latestReviews?: (GitHubLatestReviewPayload | null)[] | null; number?: number | null; + reviewRequests?: (GitHubAuthorPayload | null)[] | null; + state?: string | null; title?: string | null; updatedAt?: string | null; url?: string | null; }; +type GitHubLatestReviewPayload = { + author?: GitHubAuthorPayload | null; + state?: string | null; +}; + type GitHubCommitPayload = { author?: GitHubAuthorPayload | null; url?: string | null; }; export type GitHubPullRequest = { + additions: number; + assignees: GitHubUserInfo[]; author?: GitHubUserInfo; baseBranchName: string; branchName: string; + changedFiles: number; + deletions: number; + isDraft: boolean; + latestReviews: PullRequestLatestReview[]; number: number; + reviewRequests: GitHubUserInfo[]; + state: PullRequestState; title: string; updatedAt: string | null; url: string; @@ -47,6 +71,37 @@ type GhCommandOptions = { cwd?: string; }; +export class GhCommandError extends Error { + httpStatus?: number; + + constructor(message: string) { + super(message); + this.name = "GhCommandError"; + const status = message.match(/\(HTTP (\d{3})\)/)?.[1]; + this.httpStatus = status ? Number(status) : undefined; + } +} + +type GitHubPullRequestListState = "closed" | "open"; + +const PULL_REQUEST_JSON_FIELDS = [ + "additions", + "number", + "title", + "headRefName", + "baseRefName", + "changedFiles", + "deletions", + "author", + "isDraft", + "url", + "updatedAt", + "state", + "assignees", + "reviewRequests", + "latestReviews", +].join(","); + function ghEnvironment() { return { ...process.env, @@ -54,7 +109,7 @@ function ghEnvironment() { }; } -async function ghText(args: string[], options: GhCommandOptions = {}) { +export async function ghText(args: string[], options: GhCommandOptions = {}) { logger.debug({ args, cwd: options.cwd }, "gh text"); const process = Bun.spawn(["gh", ...args], { @@ -70,30 +125,152 @@ async function ghText(args: string[], options: GhCommandOptions = {}) { ]); if (exitCode !== 0) { - throw new Error(stderr.trim() || `gh exited with code ${exitCode}.`); + throw new GhCommandError( + stderr.trim() || `gh exited with code ${exitCode}.`, + ); } return stdout; } -async function parseJson(value: string): Promise { +type GitHubPullRequestIdentity = { + headRefName: string; + nodeId: string; + number: number; +}; + +export async function getGitHubPullRequestIdentity( + project: ProjectConfig, + repository: GitHubRepositoryInfo, + pullRequestNumber: number, +): Promise { + const output = await ghText( + [ + "api", + `repos/${repository.nameWithOwner}/pulls/${pullRequestNumber}`, + "--jq", + "{number:.number,nodeId:.node_id,headRefName:.head.ref}", + ], + { cwd: project.path }, + ); + return await parseJson(output); +} + +export async function parseJson(value: string): Promise { return JSON.parse(value) as T; } -function normalizeAuthor( +export function normalizeAuthor( author: GitHubAuthorPayload | null | undefined, ): GitHubUserInfo | undefined { - if (!author?.login || !author.url || !author.avatarUrl) { + if (!author?.login) { return undefined; } return { - avatarUrl: author.avatarUrl, + avatarUrl: + author.avatarUrl ?? + `https://github.com/${encodeURIComponent(author.login)}.png?size=64`, login: author.login, - url: author.url, + url: author.url ?? `https://github.com/${author.login}`, }; } +function normalizeAuthors( + authors: (GitHubAuthorPayload | null)[] | null | undefined, +) { + return (authors ?? []).flatMap((author) => { + const normalizedAuthor = normalizeAuthor(author); + return normalizedAuthor ? [normalizedAuthor] : []; + }); +} + +function normalizeLatestReviewState( + state: string | null | undefined, +): PullRequestReviewState { + if ( + state === "APPROVED" || + state === "CHANGES_REQUESTED" || + state === "COMMENTED" || + state === "DISMISSED" || + state === "PENDING" + ) { + return state; + } + + return "COMMENTED"; +} + +function normalizeLatestReviews( + reviews: (GitHubLatestReviewPayload | null)[] | null | undefined, +) { + return (reviews ?? []).flatMap((review) => { + const author = normalizeAuthor(review?.author); + + if (!author) { + return []; + } + + return [ + { + author, + state: normalizeLatestReviewState(review?.state), + }, + ]; + }); +} + +function normalizePullRequestState( + state: string | null | undefined, +): PullRequestState | undefined { + if (state === "OPEN" || state === "CLOSED" || state === "MERGED") { + return state; + } + + return undefined; +} + +export function normalizeGitHubPullRequests( + pullRequests: GitHubPullRequestPayload[], +) { + return pullRequests + .map((pullRequest): GitHubPullRequest | null => { + const state = normalizePullRequestState(pullRequest.state); + + if ( + !pullRequest.number || + !pullRequest.title || + !pullRequest.url || + !pullRequest.headRefName || + !pullRequest.baseRefName || + !state + ) { + return null; + } + + return { + additions: pullRequest.additions ?? 0, + assignees: normalizeAuthors(pullRequest.assignees), + author: normalizeAuthor(pullRequest.author), + baseBranchName: pullRequest.baseRefName, + branchName: pullRequest.headRefName, + changedFiles: pullRequest.changedFiles ?? 0, + deletions: pullRequest.deletions ?? 0, + isDraft: Boolean(pullRequest.isDraft), + latestReviews: normalizeLatestReviews( + pullRequest.latestReviews, + ), + number: pullRequest.number, + reviewRequests: normalizeAuthors(pullRequest.reviewRequests), + state, + title: pullRequest.title, + updatedAt: pullRequest.updatedAt ?? null, + url: pullRequest.url, + }; + }) + .filter((item): item is GitHubPullRequest => Boolean(item)); +} + export async function findGitHubRepository(project: ProjectConfig) { const [error, output] = await tryPromise( ghText( @@ -138,17 +315,29 @@ export async function listGitHubPullRequests( return undefined; } + const [openPullRequests, historicalPullRequests] = await Promise.all([ + listGitHubPullRequestsByState(project, "open"), + listGitHubPullRequestsByState(project, "closed"), + ]); + + return [...openPullRequests, ...historicalPullRequests]; +} + +async function listGitHubPullRequestsByState( + project: ProjectConfig, + state: GitHubPullRequestListState, +) { const [error, output] = await tryPromise( ghText( [ "pr", "list", "--state", - "open", + state, "--limit", "100", "--json", - "number,title,headRefName,baseRefName,author,url,updatedAt", + PULL_REQUEST_JSON_FIELDS, ], { cwd: project.path }, ), @@ -156,7 +345,12 @@ export async function listGitHubPullRequests( if (error) { logger.warn( - { projectId: project.id, projectPath: project.path, error }, + { + projectId: project.id, + projectPath: project.path, + pullRequestState: state, + error, + }, "failed to list github pull requests", ); return []; @@ -168,35 +362,18 @@ export async function listGitHubPullRequests( if (parseError) { logger.warn( - { projectId: project.id, projectPath: project.path, parseError }, + { + projectId: project.id, + projectPath: project.path, + pullRequestState: state, + parseError, + }, "github pull request payload was invalid", ); return []; } - return pullRequests - .map((pullRequest): GitHubPullRequest | null => { - if ( - !pullRequest.number || - !pullRequest.title || - !pullRequest.url || - !pullRequest.headRefName || - !pullRequest.baseRefName - ) { - return null; - } - - return { - author: normalizeAuthor(pullRequest.author), - baseBranchName: pullRequest.baseRefName, - branchName: pullRequest.headRefName, - number: pullRequest.number, - title: pullRequest.title, - updatedAt: pullRequest.updatedAt ?? null, - url: pullRequest.url, - }; - }) - .filter((item): item is GitHubPullRequest => Boolean(item)); + return normalizeGitHubPullRequests(pullRequests); } export async function getGitHubCommit( diff --git a/src/backend/handlers/git/history.test.ts b/src/backend/handlers/git/history.test.ts new file mode 100644 index 0000000..15d266d --- /dev/null +++ b/src/backend/handlers/git/history.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from "bun:test"; + +import { parseCommitHistory } from "@/backend/handlers/git/history"; + +describe("parseCommitHistory", () => { + test("collects text and binary statistics for every commit", () => { + const output = [ + "\x1eabc123\x1fabc123\x1fAdd files\x1fAda\x1fada@example.com\x1f2026-07-09T10:00:00Z", + "12\t3\tsrc/app.ts", + "-\t-\tpublic/logo.png", + "\x1edef456\x1fdef456\x1fMerge branch\x1fGrace\x1fgrace@example.com\x1f2026-07-08T10:00:00Z", + ].join("\n"); + + expect(parseCommitHistory(output)).toEqual([ + expect.objectContaining({ + additions: 12, + deletions: 3, + hasBinaryChanges: true, + hash: "abc123", + }), + expect.objectContaining({ + additions: undefined, + deletions: undefined, + hasBinaryChanges: false, + hash: "def456", + }), + ]); + }); +}); diff --git a/src/backend/handlers/git/history.ts b/src/backend/handlers/git/history.ts index a293f21..4dc75e1 100644 --- a/src/backend/handlers/git/history.ts +++ b/src/backend/handlers/git/history.ts @@ -111,7 +111,8 @@ export async function getCommitHistory( remoteName, "-n", "30", - "--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%cI", + "--numstat", + "--format=%x1e%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%cI", ]); })(), ); @@ -129,25 +130,64 @@ export async function getCommitHistory( ); } - const commits = output - .split("\n") + const commits = parseCommitHistory(output).map((commit) => ({ + ...commit, + isInSelectedBranch: + branchOnlyCommitHashes?.has(commit.hash) ?? + marksAllCommitsAsSelectedBranch, + })); + + return await enrichCommitsWithGitHub(project, commits); +} + +export function parseCommitHistory(output: string): CommitInfo[] { + return output + .split("\x1e") + .map((record) => record.trim()) .filter(Boolean) - .map((line) => { + .map((record) => { + const [metadata = "", ...statLines] = record.split("\n"); const [hash, shortHash, subject, authorName, authorEmail, date] = - line.split("\x1f"); + metadata.split("\x1f"); + let additions = 0; + let deletions = 0; + let hasBinaryChanges = false; + let hasTextChanges = false; + + for (const line of statLines) { + const [additionValue, deletionValue] = line.split("\t"); + + if (additionValue === "-" || deletionValue === "-") { + hasBinaryChanges = true; + continue; + } + + const additionCount = Number(additionValue); + const deletionCount = Number(deletionValue); + + if ( + !Number.isFinite(additionCount) || + !Number.isFinite(deletionCount) + ) { + continue; + } + + additions += additionCount; + deletions += deletionCount; + hasTextChanges = true; + } return { + additions: hasTextChanges ? additions : undefined, + authorEmail, + authorName, + date, + deletions: hasTextChanges ? deletions : undefined, + hasBinaryChanges, hash, + isInSelectedBranch: false, shortHash, subject, - authorName, - authorEmail, - date, - isInSelectedBranch: - branchOnlyCommitHashes?.has(hash) ?? - marksAllCommitsAsSelectedBranch, }; }); - - return await enrichCommitsWithGitHub(project, commits); } diff --git a/src/backend/handlers/git/index.ts b/src/backend/handlers/git/index.ts index c840734..6416769 100644 --- a/src/backend/handlers/git/index.ts +++ b/src/backend/handlers/git/index.ts @@ -7,14 +7,101 @@ import { } from "@/backend/handlers/git/worktrees"; import { getBranchDiff } from "@/backend/handlers/git/diff"; import { getCommitHistory } from "@/backend/handlers/git/history"; +import { getOverviewSnapshot } from "@/backend/handlers/git/overview"; import { validateProjectPath } from "@/backend/handlers/git/helpers"; +import { getWorktreeActivity } from "@/backend/handlers/git/activity"; +import { findGitHubRepository } from "@/backend/handlers/git/github"; +import { + addPullRequestComment, + addPullRequestReviewComment, + discardPendingPullRequestReview, + submitPullRequestReview, + type ReviewCommentInput, +} from "@/backend/handlers/git/github-review"; +import { + ApiError, + type ProjectConfig, + type ReviewEvent, +} from "@/backend/types"; + +async function requireGitHubRepository(project: ProjectConfig) { + const repository = await findGitHubRepository(project); + + if (!repository) { + throw new ApiError( + "GITHUB_INTEGRATION_FAILED", + "GitHub is unavailable for this project.", + 503, + ); + } + + return repository; +} + +async function addReviewComment( + project: ProjectConfig, + pullRequestNumber: number, + input: ReviewCommentInput, +) { + return await addPullRequestReviewComment( + project, + await requireGitHubRepository(project), + pullRequestNumber, + input, + ); +} + +async function addGeneralComment( + project: ProjectConfig, + pullRequestNumber: number, + body: string, +) { + return await addPullRequestComment( + project, + await requireGitHubRepository(project), + pullRequestNumber, + body, + ); +} + +async function submitReview( + project: ProjectConfig, + pullRequestNumber: number, + event: ReviewEvent, + body?: string, +) { + return await submitPullRequestReview( + project, + await requireGitHubRepository(project), + pullRequestNumber, + event, + body, + ); +} + +async function discardPendingReview( + project: ProjectConfig, + pullRequestNumber: number, +) { + return await discardPendingPullRequestReview( + project, + await requireGitHubRepository(project), + pullRequestNumber, + ); +} export const gitHandler = { + addGeneralComment, + addReviewComment, checkoutBranch, + discardPendingReview, getBranchDiff, getCommitHistory, + getOverviewSnapshot, + getWorktreeActivity, listBranches, removeWorktree, + submitReview, updateProjectWorktrees, updateWorktree, validateProjectPath, diff --git a/src/backend/handlers/git/overview.test.ts b/src/backend/handlers/git/overview.test.ts new file mode 100644 index 0000000..e32bcda --- /dev/null +++ b/src/backend/handlers/git/overview.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, test } from "bun:test"; + +import { parseOverviewDiffStats } from "@/backend/handlers/git/overview"; + +describe("parseOverviewDiffStats", () => { + test("sums text changes and keeps binary files in the changed file count", () => { + expect( + parseOverviewDiffStats("12\t3\tsrc/app.ts\n-\t-\timage.png\n"), + ).toEqual({ + additions: 12, + changedFiles: 2, + deletions: 3, + }); + }); +}); diff --git a/src/backend/handlers/git/overview.ts b/src/backend/handlers/git/overview.ts new file mode 100644 index 0000000..cd75130 --- /dev/null +++ b/src/backend/handlers/git/overview.ts @@ -0,0 +1,198 @@ +import { tryPromise } from "@/backend/handlers/error"; +import { gitQuiet } from "@/backend/handlers/git/command"; +import { listBranches } from "@/backend/handlers/git/worktrees"; +import type { ProjectConfig } from "@/backend/types"; +import type { + OverviewProjectSummary, + OverviewPullRequestChange, + OverviewScope, + OverviewSnapshot, + OverviewTotals, + OverviewUnavailableProject, +} from "@/types/overview"; + +const PROJECT_CONCURRENCY = 3; +const PULL_REQUEST_CONCURRENCY = 4; + +const emptyTotals = (): OverviewTotals => ({ + additions: 0, + branches: 0, + changedFiles: 0, + deletions: 0, + openPullRequests: 0, + staleBranches: 0, + worktrees: 0, +}); + +export async function getOverviewSnapshot( + projects: ProjectConfig[], + scope: OverviewScope, +): Promise { + const results = await mapWithConcurrency( + projects, + PROJECT_CONCURRENCY, + async (project) => { + const [error, overview] = await tryPromise( + getProjectOverview(project), + ); + + if (error) { + return { + type: "unavailable" as const, + value: unavailableProject(project, error), + }; + } + + return { type: "ready" as const, value: overview }; + }, + ); + const totals = emptyTotals(); + const projectSummaries: OverviewProjectSummary[] = []; + const pullRequests: OverviewPullRequestChange[] = []; + const unavailableProjects: OverviewUnavailableProject[] = []; + + for (const result of results) { + if (result.type === "unavailable") { + unavailableProjects.push(result.value); + continue; + } + + projectSummaries.push(result.value.summary); + pullRequests.push(...result.value.pullRequests); + addTotals(totals, result.value.summary); + } + + pullRequests.sort( + (left, right) => + right.additions + + right.deletions - + (left.additions + left.deletions), + ); + + return { + generatedAt: new Date().toISOString(), + projects: projectSummaries.sort((left, right) => + left.projectName.localeCompare(right.projectName), + ), + pullRequests, + scope, + totals, + unavailableProjects, + }; +} + +async function getProjectOverview(project: ProjectConfig) { + await gitQuiet(project.path, ["fetch", "origin"]); + + const branches = await listBranches(project); + const pullRequestBranches = branches.filter( + (branch) => branch.pullRequest?.state === "OPEN", + ); + const pullRequests = await mapWithConcurrency( + pullRequestBranches, + PULL_REQUEST_CONCURRENCY, + async (branch) => { + const pullRequest = branch.pullRequest!; + return { + additions: pullRequest.additions ?? 0, + branchName: branch.name, + changedFiles: pullRequest.changedFiles ?? 0, + deletions: pullRequest.deletions ?? 0, + number: pullRequest.number, + projectId: project.id, + projectName: project.name, + title: pullRequest.title, + url: pullRequest.url, + } satisfies OverviewPullRequestChange; + }, + ); + const summary: OverviewProjectSummary = { + additions: sumBy(pullRequests, (pullRequest) => pullRequest.additions), + branches: branches.length, + changedFiles: sumBy( + pullRequests, + (pullRequest) => pullRequest.changedFiles, + ), + deletions: sumBy(pullRequests, (pullRequest) => pullRequest.deletions), + openPullRequests: pullRequestBranches.length, + projectId: project.id, + projectName: project.name, + staleBranches: branches.filter((branch) => branch.isStale).length, + worktrees: branches.filter((branch) => branch.hasWorktree).length, + }; + + return { pullRequests, summary }; +} + +export function parseOverviewDiffStats(output: string) { + const files = output + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const [additions, deletions] = line.split("\t"); + + return { + additions: parseDiffNumber(additions), + deletions: parseDiffNumber(deletions), + }; + }); + + return { + additions: sumBy(files, (file) => file.additions), + changedFiles: files.length, + deletions: sumBy(files, (file) => file.deletions), + }; +} + +function parseDiffNumber(value: string | undefined) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; +} + +function unavailableProject( + project: ProjectConfig, + error: unknown, +): OverviewUnavailableProject { + return { + message: error instanceof Error ? error.message : String(error), + projectId: project.id, + projectName: project.name, + }; +} + +function addTotals(target: OverviewTotals, source: OverviewTotals) { + target.additions += source.additions; + target.branches += source.branches; + target.changedFiles += source.changedFiles; + target.deletions += source.deletions; + target.openPullRequests += source.openPullRequests; + target.staleBranches += source.staleBranches; + target.worktrees += source.worktrees; +} + +function sumBy(items: T[], select: (item: T) => number) { + return items.reduce((total, item) => total + select(item), 0); +} + +async function mapWithConcurrency( + items: T[], + limit: number, + map: (item: T) => Promise, +): Promise { + const results: R[] = []; + let cursor = 0; + const workerCount = Math.min(limit, items.length); + + await Promise.all( + Array.from({ length: workerCount }, async () => { + while (cursor < items.length) { + const itemIndex = cursor; + cursor += 1; + results[itemIndex] = await map(items[itemIndex]!); + } + }), + ); + + return results; +} diff --git a/src/backend/handlers/git/pull-request-branches.test.ts b/src/backend/handlers/git/pull-request-branches.test.ts new file mode 100644 index 0000000..4d92ee6 --- /dev/null +++ b/src/backend/handlers/git/pull-request-branches.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, test } from "bun:test"; + +import type { GitHubPullRequest } from "@/backend/handlers/git/github"; +import { pullRequestBranchHandler } from "@/backend/handlers/git/pull-request-branches"; +import type { WorktreeInventory } from "@/backend/handlers/git/worktree-inventory"; +import type { BranchInfo } from "@/backend/types"; + +const project = { + id: "project", + name: "Project", + path: "/workspace/project", +}; +const repository = { + nameWithOwner: "example/project", + url: "https://github.com/example/project", +}; + +const branch = ( + name: string, + overrides: Partial = {}, +): BranchInfo => ({ + hasWorktree: false, + isStale: false, + lastCommitTimestamp: 100, + name, + remoteName: `origin/${name}`, + source: "branch", + worktreePath: `/workspace/project/.pr-run/${name}`, + ...overrides, +}); + +const pullRequest = ( + branchName: string, + overrides: Partial = {}, +): GitHubPullRequest => ({ + additions: 0, + assignees: [], + baseBranchName: "main", + branchName, + changedFiles: 0, + deletions: 0, + isDraft: false, + latestReviews: [], + number: 1, + reviewRequests: [], + state: "OPEN", + title: `PR for ${branchName}`, + updatedAt: "2026-07-10T12:00:00Z", + url: `https://github.com/example/project/pull/1`, + ...overrides, +}); + +const inventory = ( + records: Array<{ branch: string; path: string }> = [], +): WorktreeInventory => ({ + byBranch: new Map(records.map((record) => [record.branch, record])), + worktrees: records, +}); + +function merge( + branches: BranchInfo[], + pullRequests: GitHubPullRequest[], + worktreeInventory = inventory(), +) { + return pullRequestBranchHandler.mergeWithBranches({ + branches, + inventory: worktreeInventory, + project, + pullRequests, + repository, + }); +} + +describe("pullRequestBranchHandler.mergeWithBranches", () => { + test("replaces a matching branch with an open PR", () => { + const [result] = merge( + [branch("feature", { isStale: true })], + [pullRequest("feature")], + ); + + expect(result).toMatchObject({ + isStale: false, + name: "feature", + source: "pull-request", + pullRequest: { state: "OPEN" }, + }); + expect(result?.lastCommitTimestamp).toBe( + Date.parse("2026-07-10T12:00:00Z"), + ); + }); + + test("keeps an open PR when its remote branch is missing", () => { + const [result] = merge( + [], + [pullRequest("fork-feature")], + inventory([ + { + branch: "fork-feature", + path: "/workspace/fork-feature", + }, + ]), + ); + + expect(result).toMatchObject({ + hasWorktree: true, + name: "fork-feature", + remoteName: "origin/fork-feature", + worktreePath: "/workspace/fork-feature", + }); + }); + + test.each(["CLOSED", "MERGED"] as const)( + "enriches an existing branch with a %s PR and preserves branch state", + (state) => { + const existingBranch = branch("historical", { + hasWorktree: true, + isStale: true, + lastCommitTimestamp: 123, + worktreePath: "/workspace/historical", + }); + const [result] = merge( + [existingBranch], + [pullRequest("historical", { state })], + ); + + expect(result).toMatchObject({ + hasWorktree: true, + isStale: true, + lastCommitTimestamp: 123, + pullRequest: { state }, + source: "pull-request", + worktreePath: "/workspace/historical", + }); + }, + ); + + test("excludes historical PRs without remote branches", () => { + const result = merge([], [pullRequest("deleted", { state: "MERGED" })]); + + expect(result).toEqual([]); + }); + + test("prefers an open PR over newer historical PRs", () => { + const [result] = merge( + [branch("reused")], + [ + pullRequest("reused", { + number: 2, + state: "MERGED", + updatedAt: "2026-07-11T12:00:00Z", + }), + pullRequest("reused", { + number: 1, + state: "OPEN", + updatedAt: "2026-07-10T12:00:00Z", + }), + ], + ); + + expect(result?.pullRequest).toMatchObject({ number: 1, state: "OPEN" }); + }); + + test("uses the latest historical PR when no open PR exists", () => { + const [result] = merge( + [branch("reused")], + [ + pullRequest("reused", { + number: 1, + state: "MERGED", + updatedAt: "2026-07-09T12:00:00Z", + }), + pullRequest("reused", { + number: 2, + state: "CLOSED", + updatedAt: "2026-07-10T12:00:00Z", + }), + ], + ); + + expect(result?.pullRequest).toMatchObject({ + number: 2, + state: "CLOSED", + }); + }); +}); diff --git a/src/backend/handlers/git/pull-request-branches.ts b/src/backend/handlers/git/pull-request-branches.ts new file mode 100644 index 0000000..a6dea8c --- /dev/null +++ b/src/backend/handlers/git/pull-request-branches.ts @@ -0,0 +1,180 @@ +import { worktreePathFor } from "@/backend/handlers/git/helpers"; +import type { GitHubPullRequest } from "@/backend/handlers/git/github"; +import type { WorktreeInventory } from "@/backend/handlers/git/worktree-inventory"; +import type { + BranchInfo, + GitHubRepositoryInfo, + ProjectConfig, + PullRequestInfo, +} from "@/backend/types"; + +type MergePullRequestBranchesParams = { + branches: BranchInfo[]; + inventory: WorktreeInventory; + project: ProjectConfig; + pullRequests: GitHubPullRequest[]; + repository: GitHubRepositoryInfo; +}; + +function mergeWithBranches({ + branches, + inventory, + project, + pullRequests, + repository, +}: MergePullRequestBranchesParams) { + const branchesByName = new Map( + branches.map((branch) => [branch.name, branch]), + ); + const pullRequestsByBranch = selectPullRequestsByBranch(pullRequests); + const pullRequestBranches = [...pullRequestsByBranch.values()].flatMap( + (pullRequest) => { + const existingBranch = branchesByName.get(pullRequest.branchName); + + if (pullRequest.state !== "OPEN" && !existingBranch) { + return []; + } + + return [ + pullRequest.state === "OPEN" + ? openPullRequestBranch({ + existingBranch, + inventory, + project, + pullRequest, + repository, + }) + : historicalPullRequestBranch( + existingBranch!, + pullRequest, + repository, + ), + ]; + }, + ); + const pullRequestBranchNames = new Set( + pullRequestBranches.map((branch) => branch.name), + ); + + return [ + ...pullRequestBranches, + ...branches.filter( + (branch) => !pullRequestBranchNames.has(branch.name), + ), + ]; +} + +function selectPullRequestsByBranch(pullRequests: GitHubPullRequest[]) { + const selectedPullRequests = new Map(); + + for (const pullRequest of pullRequests) { + const selectedPullRequest = selectedPullRequests.get( + pullRequest.branchName, + ); + + if ( + !selectedPullRequest || + isPullRequestPreferred(pullRequest, selectedPullRequest) + ) { + selectedPullRequests.set(pullRequest.branchName, pullRequest); + } + } + + return selectedPullRequests; +} + +function isPullRequestPreferred( + candidate: GitHubPullRequest, + current: GitHubPullRequest, +) { + if (candidate.state === "OPEN" && current.state !== "OPEN") { + return true; + } + + if (candidate.state !== "OPEN" && current.state === "OPEN") { + return false; + } + + const candidateTimestamp = Date.parse(candidate.updatedAt ?? ""); + const currentTimestamp = Date.parse(current.updatedAt ?? ""); + const normalizedCandidateTimestamp = Number.isFinite(candidateTimestamp) + ? candidateTimestamp + : 0; + const normalizedCurrentTimestamp = Number.isFinite(currentTimestamp) + ? currentTimestamp + : 0; + + if (normalizedCandidateTimestamp !== normalizedCurrentTimestamp) { + return normalizedCandidateTimestamp > normalizedCurrentTimestamp; + } + + return candidate.number > current.number; +} + +function openPullRequestBranch(params: { + existingBranch?: BranchInfo; + inventory: WorktreeInventory; + project: ProjectConfig; + pullRequest: GitHubPullRequest; + repository: GitHubRepositoryInfo; +}): BranchInfo { + const { existingBranch, inventory, project, pullRequest, repository } = + params; + const worktree = inventory.byBranch.get(pullRequest.branchName); + const updatedTimestamp = Date.parse(pullRequest.updatedAt ?? ""); + + return { + compareBranchName: pullRequest.baseBranchName, + hasWorktree: existingBranch?.hasWorktree ?? Boolean(worktree), + isStale: false, + lastCommitTimestamp: Number.isFinite(updatedTimestamp) + ? updatedTimestamp + : null, + name: pullRequest.branchName, + pullRequest: pullRequestInfo(pullRequest), + remoteName: + existingBranch?.remoteName ?? `origin/${pullRequest.branchName}`, + repository, + source: "pull-request", + worktreePath: + existingBranch?.worktreePath ?? + worktree?.path ?? + worktreePathFor(project.path, pullRequest.branchName), + }; +} + +function historicalPullRequestBranch( + branch: BranchInfo, + pullRequest: GitHubPullRequest, + repository: GitHubRepositoryInfo, +): BranchInfo { + return { + ...branch, + compareBranchName: pullRequest.baseBranchName, + pullRequest: pullRequestInfo(pullRequest), + repository, + source: "pull-request", + }; +} + +function pullRequestInfo(pullRequest: GitHubPullRequest): PullRequestInfo { + return { + additions: pullRequest.additions, + assignees: pullRequest.assignees, + author: pullRequest.author, + baseBranchName: pullRequest.baseBranchName, + changedFiles: pullRequest.changedFiles, + deletions: pullRequest.deletions, + isDraft: pullRequest.isDraft, + latestReviews: pullRequest.latestReviews, + number: pullRequest.number, + reviewRequests: pullRequest.reviewRequests, + state: pullRequest.state, + title: pullRequest.title, + url: pullRequest.url, + }; +} + +export const pullRequestBranchHandler = { + mergeWithBranches, +}; diff --git a/src/backend/handlers/git/worktrees.ts b/src/backend/handlers/git/worktrees.ts index 6528e26..f33d25c 100644 --- a/src/backend/handlers/git/worktrees.ts +++ b/src/backend/handlers/git/worktrees.ts @@ -26,6 +26,7 @@ import { findGitHubRepository, listGitHubPullRequests, } from "@/backend/handlers/git/github"; +import { pullRequestBranchHandler } from "@/backend/handlers/git/pull-request-branches"; import { logger } from "@/backend/logger"; import { ApiError, @@ -54,45 +55,13 @@ export async function listBranches( return branches; } - const pullRequestBranchNames = new Set( - pullRequests.map((pullRequest) => pullRequest.branchName), - ); - const pullRequestBranches = pullRequests.map((pullRequest) => { - const worktree = inventory.byBranch.get(pullRequest.branchName); - const lastCommitTimestamp = pullRequest.updatedAt - ? Date.parse(pullRequest.updatedAt) - : null; - - return { - name: pullRequest.branchName, - remoteName: `origin/${pullRequest.branchName}`, - worktreePath: - worktree?.path ?? - worktreePathFor(project.path, pullRequest.branchName), - hasWorktree: Boolean(worktree), - lastCommitTimestamp: Number.isFinite(lastCommitTimestamp) - ? lastCommitTimestamp - : null, - isStale: false, - source: "pull-request" as const, - compareBranchName: pullRequest.baseBranchName, - repository, - pullRequest: { - number: pullRequest.number, - title: pullRequest.title, - url: pullRequest.url, - baseBranchName: pullRequest.baseBranchName, - author: pullRequest.author, - }, - }; + return pullRequestBranchHandler.mergeWithBranches({ + branches, + inventory, + project, + pullRequests, + repository, }); - - return [ - ...pullRequestBranches, - ...branches.filter( - (branch) => !pullRequestBranchNames.has(branch.name), - ), - ]; } async function listRemoteBranches( diff --git a/src/backend/handlers/scripts/index.ts b/src/backend/handlers/scripts/index.ts index ab7b4ff..dfa06ab 100644 --- a/src/backend/handlers/scripts/index.ts +++ b/src/backend/handlers/scripts/index.ts @@ -21,6 +21,10 @@ import { type ScriptSourceResult, type ScriptTerminalCommandResult, } from "@/backend/types"; +import { + getPackageScriptCatalog, + preparePackageScriptTerminalCommand, +} from "@/backend/handlers/scripts/package-scripts"; const SCRIPT_RUNTIME_FILE_NAME = "_runtime.ts"; const SCRIPT_RUN_PAYLOAD_PREFIX = "_run-"; @@ -572,9 +576,11 @@ export const scriptsHandler = { createScript, deleteScript, getScriptSource, + getPackageScriptCatalog, listScripts, openScript, prepareTerminalCommand, + preparePackageScriptTerminalCommand, runScript, streamScript, updateScriptSource, diff --git a/src/backend/handlers/scripts/package-scripts.test.ts b/src/backend/handlers/scripts/package-scripts.test.ts new file mode 100644 index 0000000..2f09839 --- /dev/null +++ b/src/backend/handlers/scripts/package-scripts.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { + buildPackageScriptCommand, + detectPackageManager, + rankQuickScripts, +} from "@/backend/handlers/scripts/package-scripts"; + +describe("rankQuickScripts", () => { + test("prioritizes common root scripts and limits the quick list", () => { + const names = [ + "android", + "dev", + "start", + "test", + "lint", + "typecheck", + "build", + "release", + ]; + const scripts = names.map((name) => ({ + command: name, + name, + packageName: "app", + packagePath: ".", + quick: false, + })); + + expect( + rankQuickScripts([{ name: "app", path: ".", scripts }]).map( + (script) => script.name, + ), + ).toEqual(["dev", "start", "test", "lint", "typecheck", "build"]); + }); +}); + +describe("package script commands", () => { + test("always changes to the absolute package directory", () => { + expect( + buildPackageScriptCommand( + "bun", + "/workspace/project", + { name: "test watch", packagePath: "." }, + "linux", + ), + ).toBe("cd '/workspace/project' && bun run 'test watch'"); + expect( + buildPackageScriptCommand( + "npm", + "/workspace/project", + { name: "test watch", packagePath: "packages/app" }, + "win32", + ), + ).toBe( + 'cd /d "/workspace/project/packages/app" && npm run "test watch"', + ); + }); + + test("defaults unidentified Node projects to npm", async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "pr-run-pm-")); + + expect(await detectPackageManager(directory, {})).toBe("npm"); + await writeFile(path.join(directory, "bun.lock"), ""); + expect(await detectPackageManager(directory, {})).toBe("bun"); + await rm(directory, { force: true, recursive: true }); + }); +}); diff --git a/src/backend/handlers/scripts/package-scripts.ts b/src/backend/handlers/scripts/package-scripts.ts new file mode 100644 index 0000000..7238f4b --- /dev/null +++ b/src/backend/handlers/scripts/package-scripts.ts @@ -0,0 +1,283 @@ +import { access, readFile } from "node:fs/promises"; +import path from "node:path"; + +import { tryPromise } from "@/backend/handlers/error"; +import { requireWorktreePath } from "@/backend/handlers/git/worktree-inventory"; +import { + ApiError, + type PackageManager, + type PackageScriptCatalog, + type PackageScriptGroup, + type PackageScriptInfo, + type PackageScriptTerminalCommandResult, + type ProjectConfig, +} from "@/backend/types"; + +type PackageManifest = { + name?: string; + packageManager?: string; + scripts?: Record; + workspaces?: string[] | { packages?: string[] }; +}; + +const QUICK_SCRIPT_PRIORITY = [ + "dev", + "start", + "test", + "lint", + "typecheck", + "check", + "build", +]; + +export async function getPackageScriptCatalog( + project: ProjectConfig, + branch: string, +): Promise { + const worktreePath = await requireWorktreePath(project, branch); + + if (!worktreePath) { + throw new ApiError( + "WORKTREE_NOT_FOUND", + "Create the worktree before reading package scripts.", + 404, + ); + } + + const [error, catalog] = await tryPromise(readCatalog(worktreePath)); + + if (error) { + throw new ApiError( + "PACKAGE_SCRIPTS_READ_FAILED", + "Could not read package scripts from this worktree.", + 500, + error.message, + ); + } + + return catalog; +} + +export async function preparePackageScriptTerminalCommand( + project: ProjectConfig, + branch: string, + packagePath: string, + scriptName: string, +): Promise { + const worktreePath = await requireWorktreePath(project, branch); + + if (!worktreePath) { + throw new ApiError( + "WORKTREE_NOT_FOUND", + "Create the worktree before reading package scripts.", + 404, + ); + } + + const catalog = await readCatalog(worktreePath); + const script = catalog.packages + .find((group) => group.path === packagePath) + ?.scripts.find((item) => item.name === scriptName); + + if (!script) { + throw new ApiError( + "PACKAGE_SCRIPT_NOT_FOUND", + "This package script no longer exists.", + 404, + ); + } + + const command = buildPackageScriptCommand( + catalog.manager, + worktreePath, + script, + process.platform, + ); + + return { + command, + title: script.packageName + ? `${script.packageName}: ${script.name}` + : script.name, + }; +} + +async function readCatalog( + worktreePath: string, +): Promise { + const rootManifest = await readManifest( + path.join(worktreePath, "package.json"), + ); + const manager = await detectPackageManager(worktreePath, rootManifest); + const packagePaths = await discoverWorkspacePackagePaths( + worktreePath, + rootManifest, + ); + const packages: PackageScriptGroup[] = []; + + for (const packagePath of [".", ...packagePaths]) { + const manifest = + packagePath === "." + ? rootManifest + : await readManifest( + path.join(worktreePath, packagePath, "package.json"), + ); + const scripts = Object.entries(manifest.scripts ?? {}).map( + ([name, command]): PackageScriptInfo => ({ + command, + name, + packageName: manifest.name ?? packagePath, + packagePath, + quick: false, + }), + ); + + if (scripts.length === 0) { + continue; + } + + packages.push({ + name: manifest.name ?? packagePath, + path: packagePath, + scripts, + }); + } + + const quickScripts = rankQuickScripts(packages); + const quickIds = new Set( + quickScripts.map((script) => `${script.packagePath}:${script.name}`), + ); + + return { + manager, + packages: packages.map((group) => ({ + ...group, + scripts: group.scripts.map((script) => ({ + ...script, + quick: quickIds.has(`${script.packagePath}:${script.name}`), + })), + })), + quickScripts: quickScripts.map((script) => ({ + ...script, + quick: true, + })), + }; +} + +async function readManifest(filePath: string): Promise { + const source = await readFile(filePath, "utf8"); + return JSON.parse(source) as PackageManifest; +} + +async function discoverWorkspacePackagePaths( + worktreePath: string, + manifest: PackageManifest, +) { + const patterns = Array.isArray(manifest.workspaces) + ? manifest.workspaces + : (manifest.workspaces?.packages ?? []); + const discovered = new Set(); + + for (const pattern of patterns) { + const glob = new Bun.Glob(`${pattern.replace(/\/$/, "")}/package.json`); + + for await (const match of glob.scan({ + cwd: worktreePath, + onlyFiles: true, + })) { + const packagePath = path.dirname(match); + + if ( + packagePath !== "." && + !packagePath.startsWith("..") && + !packagePath.includes("node_modules") + ) { + discovered.add(packagePath); + } + } + } + + return [...discovered].sort((left, right) => left.localeCompare(right)); +} + +export async function detectPackageManager( + worktreePath: string, + manifest: PackageManifest, +): Promise { + const declared = manifest.packageManager?.split("@", 1)[0]; + + if ( + declared === "bun" || + declared === "npm" || + declared === "pnpm" || + declared === "yarn" + ) { + return declared; + } + + const lockfiles: [string, PackageManager][] = [ + ["bun.lock", "bun"], + ["bun.lockb", "bun"], + ["pnpm-lock.yaml", "pnpm"], + ["yarn.lock", "yarn"], + ["package-lock.json", "npm"], + ]; + + for (const [fileName, manager] of lockfiles) { + const [error] = await tryPromise( + access(path.join(worktreePath, fileName)), + ); + + if (!error) { + return manager; + } + } + + return "npm"; +} + +export function rankQuickScripts(packages: PackageScriptGroup[]) { + const rootScripts = + packages.find((group) => group.path === ".")?.scripts ?? []; + const ranked = QUICK_SCRIPT_PRIORITY.flatMap((name) => { + const script = rootScripts.find((item) => item.name === name); + return script ? [script] : []; + }); + const selected = new Set(ranked.map((script) => script.name)); + + for (const script of rootScripts) { + if (ranked.length >= 6) { + break; + } + + if (!selected.has(script.name)) { + ranked.push(script); + selected.add(script.name); + } + } + + return ranked.slice(0, 6); +} + +function shellQuote(value: string) { + return `'${value.replaceAll("'", `'\\''`)}'`; +} + +function windowsQuote(value: string) { + return `"${value.replaceAll('"', '\\"')}"`; +} + +export function buildPackageScriptCommand( + manager: PackageManager, + worktreePath: string, + script: Pick, + platform: NodeJS.Platform, +) { + const packageDirectory = path.resolve(worktreePath, script.packagePath); + const quote = platform === "win32" ? windowsQuote : shellQuote; + const runCommand = `${manager} run ${quote(script.name)}`; + + return platform === "win32" + ? `cd /d ${windowsQuote(packageDirectory)} && ${runCommand}` + : `cd ${shellQuote(packageDirectory)} && ${runCommand}`; +} diff --git a/src/backend/routes.ts b/src/backend/routes.ts index 978dfcc..a597a41 100644 --- a/src/backend/routes.ts +++ b/src/backend/routes.ts @@ -85,6 +85,25 @@ export function registerRoutes(app: Elysia) { await projectConfigHandler.readConfig(), ]), ) + .get("/overview", async ({ query }) => { + const projectId = query.projectId + ? String(query.projectId) + : undefined; + const config = await projectConfigHandler.readConfig(); + const projects = config.groups.flatMap((group) => group.projects); + const selectedProjects = projectId + ? [await projectConfigHandler.findProject(projectId)] + : projects; + + return success("Overview loaded.", [ + await gitHandler.getOverviewSnapshot( + selectedProjects, + projectId + ? { projectId, type: "project" } + : { type: "all" }, + ), + ]); + }) .get("/scripts", async () => success("Scripts loaded.", await scriptsHandler.listScripts()), ) @@ -256,6 +275,179 @@ export function registerRoutes(app: Elysia) { await gitHandler.getCommitHistory(project, branch, baseBranch), ); }) + .get("/projects/:projectId/activity", async ({ params, query }) => { + const branch = String(query.branch ?? ""); + const baseBranch = query.baseBranch + ? String(query.baseBranch) + : undefined; + const pullRequestNumber = query.pullRequestNumber + ? Number(query.pullRequestNumber) + : undefined; + + if (!branch) { + throw new ApiError("BAD_REQUEST", "Enter a branch.", 400); + } + + if ( + pullRequestNumber !== undefined && + !Number.isSafeInteger(pullRequestNumber) + ) { + throw new ApiError( + "BAD_REQUEST", + "Enter a valid pull request number.", + 400, + ); + } + + const project = await projectConfigHandler.findProject( + params.projectId, + ); + return success("Worktree activity loaded.", [ + await gitHandler.getWorktreeActivity( + project, + branch, + baseBranch, + pullRequestNumber, + ), + ]); + }) + .post( + "/projects/:projectId/pull-requests/:pullRequestNumber/comments", + async ({ params, body }) => { + const payload = body as { body?: string }; + const pullRequestNumber = parsePullRequestNumber( + params.pullRequestNumber, + ); + + if (!payload.body?.trim()) { + throw new ApiError("BAD_REQUEST", "Enter a comment.", 400); + } + + const project = await projectConfigHandler.findProject( + params.projectId, + ); + return success("Comment added.", [ + await gitHandler.addGeneralComment( + project, + pullRequestNumber, + payload.body.trim(), + ), + ]); + }, + ) + .post( + "/projects/:projectId/pull-requests/:pullRequestNumber/review-comments", + async ({ params, body }) => { + const payload = body as { + body?: string; + line?: number; + mode?: string; + path?: string; + side?: string; + startLine?: number; + startSide?: string; + }; + const pullRequestNumber = parsePullRequestNumber( + params.pullRequestNumber, + ); + + if ( + !payload.body?.trim() || + !payload.path?.trim() || + !Number.isSafeInteger(payload.line) || + Number(payload.line) < 1 || + (payload.mode !== "immediate" && + payload.mode !== "pending") || + (payload.side !== "LEFT" && payload.side !== "RIGHT") || + (payload.startSide !== undefined && + payload.startSide !== "LEFT" && + payload.startSide !== "RIGHT") + ) { + throw new ApiError( + "BAD_REQUEST", + "Enter a valid review comment and line range.", + 400, + ); + } + + const project = await projectConfigHandler.findProject( + params.projectId, + ); + return success("Review comment added.", [ + await gitHandler.addReviewComment( + project, + pullRequestNumber, + { + body: payload.body.trim(), + line: Number(payload.line), + mode: payload.mode, + path: payload.path.trim(), + side: payload.side, + startLine: payload.startLine, + startSide: payload.startSide, + }, + ), + ]); + }, + ) + .post( + "/projects/:projectId/pull-requests/:pullRequestNumber/reviews/submit", + async ({ params, body }) => { + const payload = body as { body?: string; event?: string }; + const pullRequestNumber = parsePullRequestNumber( + params.pullRequestNumber, + ); + + if ( + payload.event !== "APPROVE" && + payload.event !== "COMMENT" && + payload.event !== "REQUEST_CHANGES" + ) { + throw new ApiError( + "BAD_REQUEST", + "Enter a valid review outcome.", + 400, + ); + } + + if ( + payload.event === "REQUEST_CHANGES" && + !payload.body?.trim() + ) { + throw new ApiError( + "BAD_REQUEST", + "Explain the requested changes.", + 400, + ); + } + + const project = await projectConfigHandler.findProject( + params.projectId, + ); + return success("Review submitted.", [ + await gitHandler.submitReview( + project, + pullRequestNumber, + payload.event, + payload.body, + ), + ]); + }, + ) + .delete( + "/projects/:projectId/pull-requests/:pullRequestNumber/reviews/pending", + async ({ params }) => { + const project = await projectConfigHandler.findProject( + params.projectId, + ); + return success("Pending review discarded.", [ + await gitHandler.discardPendingReview( + project, + parsePullRequestNumber(params.pullRequestNumber), + ), + ]); + }, + ) .get("/projects/:projectId/diff", async ({ params, query }) => { const branch = String(query.branch ?? ""); const baseBranch = query.baseBranch @@ -273,6 +465,26 @@ export function registerRoutes(app: Elysia) { await gitHandler.getBranchDiff(project, branch, baseBranch), ]); }) + .get( + "/projects/:projectId/package-scripts", + async ({ params, query }) => { + const branch = String(query.branch ?? ""); + + if (!branch) { + throw new ApiError("BAD_REQUEST", "Enter a branch.", 400); + } + + const project = await projectConfigHandler.findProject( + params.projectId, + ); + return success("Package scripts loaded.", [ + await scriptsHandler.getPackageScriptCatalog( + project, + branch, + ), + ]); + }, + ) .get("/projects/:projectId/docker", async ({ params, query }) => { const branch = String(query.branch ?? ""); @@ -340,6 +552,40 @@ export function registerRoutes(app: Elysia) { ]); }, ) + .post( + "/projects/:projectId/package-scripts/terminal-command", + async ({ params, body }) => { + const payload = body as { + branch?: string; + packagePath?: string; + scriptName?: string; + }; + + if ( + !payload.branch || + !payload.packagePath || + !payload.scriptName + ) { + throw new ApiError( + "BAD_REQUEST", + "Enter a branch, package path, and script name.", + 400, + ); + } + + const project = await projectConfigHandler.findProject( + params.projectId, + ); + return success("Package script command prepared.", [ + await scriptsHandler.preparePackageScriptTerminalCommand( + project, + payload.branch, + payload.packagePath, + payload.scriptName, + ), + ]); + }, + ) .post( "/projects/:projectId/scripts/:scriptId/terminal-command", async ({ params, body }) => { @@ -407,3 +653,17 @@ export function registerRoutes(app: Elysia) { }, ); } + +function parsePullRequestNumber(value: string) { + const number = Number(value); + + if (!Number.isSafeInteger(number) || number < 1) { + throw new ApiError( + "BAD_REQUEST", + "Enter a valid pull request number.", + 400, + ); + } + + return number; +} diff --git a/src/backend/types.ts b/src/backend/types.ts index e77b363..05c6e2e 100644 --- a/src/backend/types.ts +++ b/src/backend/types.ts @@ -40,21 +40,40 @@ export type GitHubUserInfo = { }; export type PullRequestInfo = { + additions?: number; + assignees: GitHubUserInfo[]; + author?: GitHubUserInfo; + baseBranchName: string; + changedFiles?: number; + deletions?: number; + isDraft: boolean; + latestReviews: PullRequestLatestReview[]; number: number; + reviewRequests: GitHubUserInfo[]; + state: PullRequestState; title: string; url: string; - baseBranchName: string; - author?: GitHubUserInfo; +}; + +export type PullRequestState = "OPEN" | "CLOSED" | "MERGED"; + +export type PullRequestLatestReview = { + author: GitHubUserInfo; + state: PullRequestReviewState; }; export type BranchDiffFile = { path: string; additions: number; deletions: number; + previousPath?: string; + status: "added" | "binary" | "deleted" | "modified" | "renamed"; }; export type BranchDiffResult = { + additions: number; branch: string; + deletions: number; files: BranchDiffFile[]; patch: string; }; @@ -194,6 +213,7 @@ export type UpdateWorktreesResult = { }; export type CommitInfo = { + additions?: number; hash: string; shortHash: string; subject: string; @@ -203,10 +223,141 @@ export type CommitInfo = { authorLogin?: string; authorUrl?: string; date: string; + deletions?: number; + hasBinaryChanges?: boolean; url?: string; isInSelectedBranch: boolean; }; +export type PullRequestReviewState = + | "APPROVED" + | "CHANGES_REQUESTED" + | "COMMENTED" + | "DISMISSED" + | "PENDING"; + +export type PullRequestReviewComment = { + author: GitHubUserInfo; + body: string; + createdAt: string; + id: number; + isOutdated: boolean; + line?: number; + path: string; + pullRequestReviewId?: number; + side?: "LEFT" | "RIGHT"; + startLine?: number; + startSide?: "LEFT" | "RIGHT"; + subjectType: "file" | "line"; + url: string; + viewerDidAuthor: boolean; +}; + +export type PullRequestReviewActivity = { + author: GitHubUserInfo; + body: string; + comments: PullRequestReviewComment[]; + id: string; + state: PullRequestReviewState; + submittedAt: string; + url?: string; +}; + +export type PullRequestGeneralComment = { + author: GitHubUserInfo; + body: string; + createdAt: string; + id: string; + url: string; + viewerDidAuthor: boolean; +}; + +export type WorktreeActivityItem = + | { + commit: CommitInfo; + id: string; + occurredAt: string; + type: "commit"; + } + | { + comment: PullRequestGeneralComment; + id: string; + occurredAt: string; + type: "comment"; + } + | { + id: string; + occurredAt: string; + review: PullRequestReviewActivity; + type: "review"; + }; + +export type GitHubIntegrationStatus = + | { status: "available" } + | { + message: string; + reason: + | "not-a-pull-request" + | "not-authenticated" + | "pull-request-mismatch" + | "pull-request-not-found" + | "request-failed"; + status: "unavailable"; + }; + +export type PendingPullRequestReview = { + body: string; + comments: PullRequestReviewComment[]; + id: number; + nodeId: string; +}; + +export type WorktreeActivityResult = { + baseCommits: CommitInfo[]; + integration: GitHubIntegrationStatus; + items: WorktreeActivityItem[]; + pendingReview?: PendingPullRequestReview; + reviewComments: PullRequestReviewComment[]; + reviewDecision?: string; + viewer?: GitHubUserInfo; +}; + +export type ReviewCommentMode = "immediate" | "pending"; + +export type ReviewEvent = "APPROVE" | "COMMENT" | "REQUEST_CHANGES"; + +export type PullRequestReviewMutationResult = { + id: number | string; + url?: string; +}; + +export type PackageManager = "bun" | "npm" | "pnpm" | "yarn"; + +export type PackageScriptInfo = { + command: string; + name: string; + packageName: string; + packagePath: string; + quick: boolean; +}; + +export type PackageScriptGroup = { + name: string; + path: string; + scripts: PackageScriptInfo[]; +}; + +export type PackageScriptCatalog = { + manager: PackageManager; + packages: PackageScriptGroup[]; + quickScripts: PackageScriptInfo[]; +}; + +export type PackageScriptTerminalCommandResult = { + command: string; + title: string; +}; + export type ScriptInfo = { id: string; title: string; @@ -276,6 +427,10 @@ export type ApiErrorCode = | "DOCKER_INSPECT_FAILED" | "DOCKER_SERVICE_NOT_FOUND" | "ENV_FILES_READ_FAILED" + | "GITHUB_INTEGRATION_FAILED" + | "PACKAGE_SCRIPT_NOT_FOUND" + | "PACKAGE_SCRIPTS_READ_FAILED" + | "REVIEW_NOT_FOUND" | "TERMINAL_SESSION_FAILED" | "BAD_REQUEST" | "NOT_FOUND"; diff --git a/src/index.css b/src/index.css index 29ad1a8..37e55c4 100644 --- a/src/index.css +++ b/src/index.css @@ -1,10 +1,34 @@ @import "tailwindcss"; -@import "@heroui/styles"; @custom-variant dark (&:is(.dark, .dark *)); +@custom-variant wco (&:is(.wco, .wco *)); + +:root { + --app-scrollbar-width: 6px; + --workspace-topbar-height: 36px; + --workspace-controls-top: 0px; + --workspace-controls-left: calc(env(safe-area-inset-left) + 0.75rem); + --workspace-controls-right: calc(env(safe-area-inset-right) + 0.75rem); + --workspace-native-controls-inset: 0px; + --workspace-titlebar-control-size: 1.75rem; +} + +.wco { + --workspace-topbar-height: env(titlebar-area-height, 36px); + --workspace-controls-top: env(titlebar-area-y, 0px); + --workspace-controls-left: calc(env(titlebar-area-x, 0px) + 0.75rem); + --workspace-controls-right: calc( + 100vw - env(titlebar-area-width, 100vw) - env(titlebar-area-x, 0px) + + 0.75rem + ); + --workspace-native-controls-inset: calc( + 100vw - env(titlebar-area-width, 100vw) - env(titlebar-area-x, 0px) + + 0.75rem + ); +} @theme inline { - --animate-skeleton: skeleton 2s -1s infinite linear; + --animate-skeleton: skeleton 0.5s -1s infinite linear; --font-sans: "DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; @@ -13,8 +37,12 @@ "Liberation Mono", Menlo, monospace; --color-background: var(--background); --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); --color-surface: var(--surface); --color-surface-foreground: var(--surface-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); --color-muted: var(--muted); --color-muted-foreground: var(--muted-foreground); --color-border: var(--border); @@ -22,10 +50,18 @@ --color-ring: var(--ring); --color-primary: var(--primary); --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); --color-success: var(--success); --color-success-foreground: var(--success-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); --color-danger: var(--danger); --color-danger-foreground: var(--danger-foreground); + --color-info: var(--info); + --color-info-foreground: var(--info-foreground); --color-warning: var(--warning); --color-warning-foreground: var(--warning-foreground); --color-sidebar: var(--sidebar); @@ -37,6 +73,7 @@ --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); --radius-xl: calc(var(--radius) + 4px); + --radius-2xl: calc(var(--radius) + 8px); @keyframes skeleton { to { @@ -52,8 +89,10 @@ --background: oklch(0.985 0.002 264); --app-chrome-background: color-mix(in srgb, var(--background) 96%, black); --foreground: oklch(0.19 0.005 264); - --surface: color-mix(in srgb, var(--sidebar) 84%, var(--foreground)); - --surface-foreground: var(--sidebar-foreground); + --card: color-mix(in srgb, var(--sidebar) 84%, var(--foreground)); + --card-foreground: var(--sidebar-foreground); + --popover: var(--card); + --popover-foreground: var(--card-foreground); --muted: color-mix(in srgb, var(--foreground) 5%, transparent); --muted-foreground: color-mix(in srgb, var(--foreground) 58%, white); --border: color-mix(in srgb, var(--foreground) 10%, transparent); @@ -61,17 +100,27 @@ --ring: oklch(0.58 0.13 164); --primary: oklch(0.54 0.13 164); --primary-foreground: white; + --secondary: color-mix(in srgb, var(--foreground) 7%, transparent); + --secondary-foreground: var(--foreground); + --accent: color-mix(in srgb, var(--foreground) 6%, transparent); + --accent-foreground: var(--foreground); --success: oklch(0.58 0.14 156); --success-foreground: oklch(0.33 0.1 156); - --danger: oklch(0.58 0.19 28); - --danger-foreground: oklch(0.42 0.16 28); + --destructive: oklch(0.58 0.19 28); + --destructive-foreground: oklch(0.42 0.16 28); + --info: oklch(0.58 0.12 245); + --info-foreground: oklch(0.38 0.1 245); --warning: oklch(0.72 0.14 78); --warning-foreground: oklch(0.42 0.1 78); - --sidebar: color-mix(in srgb, var(--background) 92%, var(--foreground)); + --sidebar: color-mix(in srgb, var(--background) 96%, var(--foreground)); --sidebar-foreground: var(--foreground); --sidebar-border: color-mix(in srgb, var(--border) 86%, transparent); --sidebar-accent: color-mix(in srgb, var(--foreground) 6%, transparent); --sidebar-accent-foreground: var(--foreground); + --surface: var(--card); + --surface-foreground: var(--card-foreground); + --danger: var(--destructive); + --danger-foreground: var(--destructive-foreground); } .dark, @@ -80,8 +129,10 @@ --background: oklch(0.145 0.004 264); --app-chrome-background: color-mix(in srgb, var(--background) 92%, black); --foreground: oklch(0.94 0.004 264); - --surface: color-mix(in srgb, var(--sidebar) 84%, black); - --surface-foreground: var(--sidebar-foreground); + --card: color-mix(in srgb, var(--sidebar) 84%, black); + --card-foreground: var(--sidebar-foreground); + --popover: var(--card); + --popover-foreground: var(--card-foreground); --muted: color-mix(in srgb, var(--foreground) 7%, transparent); --muted-foreground: color-mix(in srgb, var(--foreground) 58%, black); --border: color-mix(in srgb, var(--foreground) 9%, transparent); @@ -89,19 +140,94 @@ --ring: oklch(0.66 0.12 164); --primary: oklch(0.66 0.12 164); --primary-foreground: oklch(0.13 0.004 264); + --secondary: color-mix(in srgb, var(--foreground) 9%, transparent); + --secondary-foreground: var(--foreground); + --accent: color-mix(in srgb, var(--foreground) 8%, transparent); + --accent-foreground: var(--foreground); --success: oklch(0.68 0.13 156); --success-foreground: oklch(0.78 0.12 156); - --danger: oklch(0.68 0.17 28); - --danger-foreground: oklch(0.74 0.15 28); + --destructive: oklch(0.68 0.17 28); + --destructive-foreground: oklch(0.74 0.15 28); + --info: oklch(0.7 0.11 245); + --info-foreground: oklch(0.78 0.1 245); --warning: oklch(0.77 0.13 78); --warning-foreground: oklch(0.82 0.12 78); - --sidebar: color-mix(in srgb, var(--background) 91%, white); + --sidebar: color-mix(in srgb, var(--background) 96%, white); --sidebar-foreground: var(--foreground); --sidebar-border: color-mix(in srgb, var(--border) 82%, transparent); --sidebar-accent: color-mix(in srgb, var(--foreground) 7%, transparent); --sidebar-accent-foreground: var(--foreground); } +@layer components { + .workspace-topbar { + display: flex; + height: var(--workspace-topbar-height); + min-height: var(--workspace-topbar-height); + flex-shrink: 0; + align-items: center; + } + + .workspace-titlebar { + display: flex; + height: var(--workspace-topbar-height); + min-height: var(--workspace-topbar-height); + align-items: center; + background: var(--sidebar); + } + + @media (max-width: 63.999rem) { + .workspace-titlebar-brand { + width: 3rem !important; + padding-inline: 0.5rem; + } + + .workspace-titlebar-brand > :not(:first-child) { + display: none; + } + + .workspace-titlebar-brand-macos { + width: 7rem !important; + padding-left: 5rem !important; + } + } + + .surface-subheader { + display: flex; + height: 2.5rem; + min-height: 2.5rem; + flex-shrink: 0; + align-items: center; + border-bottom: 1px solid + color-mix(in srgb, var(--border) 60%, transparent); + background: var(--background); + } + + .drag-region { + -webkit-app-region: drag; + } + + .no-drag { + -webkit-app-region: no-drag; + } + + .drag-region button, + .drag-region input, + .drag-region textarea, + .drag-region select, + .drag-region a { + -webkit-app-region: no-drag; + } +} + +.no-transitions, +.no-transitions *, +.no-transitions *::before, +.no-transitions *::after { + transition-duration: 0s !important; + animation-duration: 0s !important; +} + @layer base { * { border-color: var(--border); diff --git a/src/lib/api.ts b/src/lib/api.ts index 53b493a..85d203c 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -10,9 +10,14 @@ import type { DockerTerminalCommandAction, DockerTerminalCommandResult, EnvFilesOverviewResult, + PackageScriptCatalog, + PackageScriptTerminalCommandResult, ProjectConfig, ProjectsConfig, RemoveWorktreeResult, + PullRequestReviewMutationResult, + ReviewCommentMode, + ReviewEvent, ScriptInfo, ScriptOpenResult, ScriptRunResult, @@ -26,11 +31,14 @@ import type { TerminalSessionSnapshot, UpdateResult, UpdateWorktreesResult, + WorktreeActivityResult, } from "@/types/pr-run"; +import type { OverviewSnapshot } from "@/types/overview"; import { type PendingRequest, useSshPassphraseStore, } from "@/lib/hooks/store/use-ssh-passphrase-store"; +import { tryPromise } from "@/lib/error"; type ApiError = Error & { action?: string; @@ -222,15 +230,19 @@ async function requestMany(pathOrUrl: string, options?: Options) { } async function clearSshPassphraseCache() { - try { - await requestOneRaw("/ssh-passphrase/clear", { - method: "POST", - }); - } catch { - // Clearing a stale passphrase is best-effort only. + const [error] = await tryPromise(clearSshPassphrase()); + + if (error) { + return; } } +async function clearSshPassphrase() { + return requestOneRaw("/ssh-passphrase/clear", { + method: "POST", + }); +} + async function requestOneRaw(pathOrUrl: string, options?: Options) { const response = await sendRaw(pathOrUrl, options); const payload = (await response.json()) as ApiEnvelope; @@ -289,11 +301,12 @@ export async function saveSshPassphrase() { method: "POST", }); - const { pendingRequest, retryAction } = + const { pendingRequest, retryActions } = useSshPassphraseStore.getState(); + const actions = Object.values(retryActions); - if (retryAction) { - await retryAction(); + if (actions.length > 0) { + await Promise.all(actions.map((action) => action())); } else if (pendingRequest) { await retryPendingRequest(pendingRequest); } @@ -342,7 +355,7 @@ export const prRunApi = { }, ); }, - clearSshPassphrase: clearSshPassphraseCache, + clearSshPassphrase, async createTerminalEventSource(sessionId: string) { return new EventSource( await toApiUrl( @@ -379,6 +392,26 @@ export const prRunApi = { `/projects/${encodeURIComponent(projectId)}/commits?${params.toString()}`, ); }, + getWorktreeActivity( + projectId: string, + branch: string, + baseBranch?: string, + pullRequestNumber?: number, + ) { + const params = new URLSearchParams({ branch }); + + if (baseBranch) { + params.set("baseBranch", baseBranch); + } + + if (pullRequestNumber) { + params.set("pullRequestNumber", String(pullRequestNumber)); + } + + return requestOne( + `/projects/${encodeURIComponent(projectId)}/activity?${params.toString()}`, + ); + }, getBranchDiff(projectId: string, branch: string, baseBranch?: string) { const params = new URLSearchParams({ branch }); @@ -404,6 +437,20 @@ export const prRunApi = { `/projects/${encodeURIComponent(projectId)}/env?${params.toString()}`, ); }, + getPackageScripts(projectId: string, branch: string) { + const params = new URLSearchParams({ branch }); + + return requestOne( + `/projects/${encodeURIComponent(projectId)}/package-scripts?${params.toString()}`, + ); + }, + getOverview(projectId?: string) { + const query = projectId + ? `?${new URLSearchParams({ projectId }).toString()}` + : ""; + + return requestOne(`/overview${query}`); + }, getConfig() { return requestOne("/config"); }, @@ -461,6 +508,68 @@ export const prRunApi = { }, ); }, + preparePackageScriptTerminalCommand( + projectId: string, + branch: string, + packagePath: string, + scriptName: string, + ) { + return requestOne( + `/projects/${encodeURIComponent(projectId)}/package-scripts/terminal-command`, + { + json: { branch, packagePath, scriptName }, + method: "POST", + }, + ); + }, + addPullRequestComment( + projectId: string, + pullRequestNumber: number, + body: string, + ) { + return requestOne( + `/projects/${encodeURIComponent(projectId)}/pull-requests/${pullRequestNumber}/comments`, + { json: { body }, method: "POST" }, + ); + }, + addPullRequestReviewComment( + projectId: string, + pullRequestNumber: number, + input: { + body: string; + line: number; + mode: ReviewCommentMode; + path: string; + side: "LEFT" | "RIGHT"; + startLine?: number; + startSide?: "LEFT" | "RIGHT"; + }, + ) { + return requestOne( + `/projects/${encodeURIComponent(projectId)}/pull-requests/${pullRequestNumber}/review-comments`, + { json: input, method: "POST" }, + ); + }, + submitPullRequestReview( + projectId: string, + pullRequestNumber: number, + event: ReviewEvent, + body?: string, + ) { + return requestOne( + `/projects/${encodeURIComponent(projectId)}/pull-requests/${pullRequestNumber}/reviews/submit`, + { json: { body, event }, method: "POST" }, + ); + }, + discardPendingPullRequestReview( + projectId: string, + pullRequestNumber: number, + ) { + return requestOne( + `/projects/${encodeURIComponent(projectId)}/pull-requests/${pullRequestNumber}/reviews/pending`, + { method: "DELETE" }, + ); + }, getScriptSource(scriptId: string) { return requestOne( `/scripts/${encodeURIComponent(scriptId)}/source`, diff --git a/src/lib/components/atoms/button.tsx b/src/lib/components/atoms/button.tsx deleted file mode 100644 index 20095fb..0000000 --- a/src/lib/components/atoms/button.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import { Button as HeroButton } from "@heroui/react"; -import type { ComponentProps } from "react"; - -import { cn } from "@/lib/utils/cn"; - -type ButtonProps = Omit< - ComponentProps, - "size" | "variant" -> & { - size?: "xs" | "sm" | "md" | "icon-xs" | "icon-sm" | "icon-md"; - tone?: "primary" | "ghost"; - variant?: "danger" | "ghost" | "outline" | "primary"; -}; - -export function Button({ - className, - isIconOnly, - size, - tone, - variant, - ...props -}: ButtonProps) { - const resolvedVariant = - variant ?? (tone === "primary" ? "primary" : "ghost"); - const resolvedSize = - size ?? - (isIconOnly ? "icon-sm" : resolvedVariant === "primary" ? "sm" : "sm"); - - return ( - - cn( - `focus-visible:ring-ring - focus-visible:ring-offset-background relative - inline-flex shrink-0 cursor-pointer items-center - justify-center gap-1.5 rounded-lg border text-xs - font-medium whitespace-nowrap shadow-none - transition-[background,border-color,color,box-shadow,transform] - duration-150 outline-none focus-visible:ring-2 - focus-visible:ring-offset-1 active:scale-[0.98] - disabled:pointer-events-none - disabled:cursor-not-allowed disabled:opacity-50 - disabled:active:scale-100 - [&_svg]:pointer-events-none [&_svg]:shrink-0`, - { - danger: "border-danger/35 bg-danger/10 text-danger-foreground data-[hover=true]:bg-danger/15", - ghost: "text-foreground data-[hover=true]:bg-muted/35 data-[hover=true]:text-foreground border-transparent bg-transparent", - outline: - "border-border/90 bg-surface text-foreground data-[hover=true]:bg-muted/30 shadow-sm/5", - primary: - "border-primary bg-primary text-primary-foreground data-[hover=true]:bg-primary/90 shadow-sm/10", - }[resolvedVariant], - { - "icon-md": - "size-9 min-w-9 px-0 [&_svg:not([class*='h-'])]:size-4", - "icon-sm": - "size-8 min-w-8 px-0 [&_svg:not([class*='h-'])]:size-4", - "icon-xs": - "size-7 min-w-7 rounded-md px-0 [&_svg:not([class*='h-'])]:size-3.5", - md: "h-9 px-3", - sm: "h-8 px-2.5", - xs: "h-7 rounded-md px-2 text-[11px]", - }[resolvedSize], - className(values), - ) - : cn( - `focus-visible:ring-ring - focus-visible:ring-offset-background relative - inline-flex shrink-0 cursor-pointer items-center - justify-center gap-1.5 rounded-lg border text-xs - font-medium whitespace-nowrap shadow-none - transition-[background,border-color,color,box-shadow,transform] - duration-150 outline-none focus-visible:ring-2 - focus-visible:ring-offset-1 active:scale-[0.98] - disabled:pointer-events-none - disabled:cursor-not-allowed disabled:opacity-50 - disabled:active:scale-100 - [&_svg]:pointer-events-none [&_svg]:shrink-0`, - { - danger: "border-danger/35 bg-danger/10 text-danger-foreground data-[hover=true]:bg-danger/15", - ghost: "text-foreground data-[hover=true]:bg-muted/35 data-[hover=true]:text-foreground border-transparent bg-transparent", - outline: - "border-border/90 bg-surface text-foreground data-[hover=true]:bg-muted/30 shadow-sm/5", - primary: - "border-primary bg-primary text-primary-foreground data-[hover=true]:bg-primary/90 shadow-sm/10", - }[resolvedVariant], - { - "icon-md": - "size-9 min-w-9 px-0 [&_svg:not([class*='h-'])]:size-4", - "icon-sm": - "size-8 min-w-8 px-0 [&_svg:not([class*='h-'])]:size-4", - "icon-xs": - "size-7 min-w-7 rounded-md px-0 [&_svg:not([class*='h-'])]:size-3.5", - md: "h-9 px-3", - sm: "h-8 px-2.5", - xs: "h-7 rounded-md px-2 text-[11px]", - }[resolvedSize], - className, - ) - } - isIconOnly={isIconOnly} - variant="ghost" - {...props} - /> - ); -} diff --git a/src/lib/components/atoms/project-avatar.tsx b/src/lib/components/atoms/project-avatar.tsx new file mode 100644 index 0000000..e916452 --- /dev/null +++ b/src/lib/components/atoms/project-avatar.tsx @@ -0,0 +1,17 @@ +import { cn } from "@/lib/utils/cn"; + +type ProjectAvatarProps = { + className?: string; + src: string; +}; + +export function ProjectAvatar({ className, src }: ProjectAvatarProps) { + return ( + + ); +} diff --git a/src/lib/components/atoms/worktree-indicator.tsx b/src/lib/components/atoms/worktree-indicator.tsx new file mode 100644 index 0000000..3f80f60 --- /dev/null +++ b/src/lib/components/atoms/worktree-indicator.tsx @@ -0,0 +1,37 @@ +import { TreeDeciduous } from "lucide-react"; +import type { ComponentPropsWithoutRef } from "react"; + +import { cn } from "@/lib/utils/cn"; + +type WorktreeIndicatorProps = ComponentPropsWithoutRef<"span"> & { + variant?: "icon" | "label"; +}; + +export function WorktreeIndicator({ + className, + variant = "icon", + ...props +}: WorktreeIndicatorProps) { + return ( + + + {variant === "label" ? Git Worktree : null} + + ); +} diff --git a/src/lib/components/molecules/branch-diff-tree.tsx b/src/lib/components/molecules/branch-diff-tree.tsx deleted file mode 100644 index 19a20b7..0000000 --- a/src/lib/components/molecules/branch-diff-tree.tsx +++ /dev/null @@ -1,274 +0,0 @@ -import { ChevronDown, ChevronRight, File, Folder } from "lucide-react"; -import { Fragment, useMemo, useState } from "react"; - -import { cn } from "@/lib/utils/cn"; -import type { BranchDiffFile } from "@/types/pr-run"; - -type BranchDiffTreeProps = { - files: BranchDiffFile[]; - selectedPath?: string; - onSelectFile?: (path: string) => void; -}; - -type DiffTreeNode = { - additions: number; - children: DiffTreeNode[]; - deletions: number; - name: string; - path: string; - type: "directory" | "file"; -}; - -type MutableDiffTreeNode = Omit & { - children: Map; -}; - -export function BranchDiffTree({ - files, - selectedPath, - onSelectFile, -}: BranchDiffTreeProps) { - const tree = useMemo(() => buildDiffTree(files), [files]); - const [collapsedFolders, setCollapsedFolders] = useState>( - () => new Set(), - ); - - if (tree.length === 0) { - return null; - } - - function toggleFolder(path: string) { - setCollapsedFolders((current) => { - const next = new Set(current); - - if (next.has(path)) { - next.delete(path); - } else { - next.add(path); - } - - return next; - }); - } - - return ( -
- {tree.map((node) => ( - - ))} -
- ); -} - -type DiffTreeNodeRowProps = { - collapsedFolders: Set; - depth: number; - node: DiffTreeNode; - selectedPath?: string; - onSelectFile?: (path: string) => void; - onToggleFolder: (path: string) => void; -}; - -function DiffTreeNodeRow({ - collapsedFolders, - depth, - node, - selectedPath, - onSelectFile, - onToggleFolder, -}: DiffTreeNodeRowProps) { - const isDirectory = node.type === "directory"; - const isCollapsed = collapsedFolders.has(node.path); - const paddingLeft = `${depth * 10}px`; - - if (!isDirectory) { - return ( - - ); - } - - return ( - - - {!isCollapsed - ? node.children.map((child) => ( - - )) - : null} - - ); -} - -type DiffCountsProps = { - additions: number; - deletions: number; -}; - -function DiffCounts({ additions, deletions }: DiffCountsProps) { - return ( - - +{additions} - -{deletions} - - ); -} - -function buildDiffTree(files: BranchDiffFile[]) { - const root = createMutableNode("", "", "directory"); - - for (const file of files) { - const parts = file.path.split("/").filter(Boolean); - - if (parts.length === 0) { - continue; - } - - let current = root; - - parts.forEach((part, index) => { - const isFile = index === parts.length - 1; - const childPath = parts.slice(0, index + 1).join("/"); - const existing = current.children.get(part); - const child = - existing ?? - createMutableNode( - part, - childPath, - isFile ? "file" : "directory", - ); - - if (!existing) { - current.children.set(part, child); - } - - if (isFile) { - child.additions += file.additions; - child.deletions += file.deletions; - } - - current = child; - }); - } - - return finalizeChildren(root); -} - -function finalizeChildren(node: MutableDiffTreeNode): DiffTreeNode[] { - return [...node.children.values()] - .sort((left, right) => { - if (left.type !== right.type) { - return left.type === "directory" ? -1 : 1; - } - - return left.name.localeCompare(right.name); - }) - .map((child) => { - const children = finalizeChildren(child); - - if (child.type === "directory") { - child.additions = sumBy(children, "additions"); - child.deletions = sumBy(children, "deletions"); - } - - return { - additions: child.additions, - children, - deletions: child.deletions, - name: child.name, - path: child.path, - type: child.type, - }; - }); -} - -function createMutableNode( - name: string, - path: string, - type: DiffTreeNode["type"], -): MutableDiffTreeNode { - return { - additions: 0, - children: new Map(), - deletions: 0, - name, - path, - type, - }; -} - -function sumBy(nodes: DiffTreeNode[], key: "additions" | "deletions") { - return nodes.reduce((total, node) => total + node[key], 0); -} diff --git a/src/lib/components/molecules/commit-history.tsx b/src/lib/components/molecules/commit-history.tsx deleted file mode 100644 index 45e4723..0000000 --- a/src/lib/components/molecules/commit-history.tsx +++ /dev/null @@ -1,179 +0,0 @@ -import { GitCommitHorizontal } from "lucide-react"; - -import { EmptyState } from "@/lib/components/atoms/empty-state"; -import { Skeleton } from "@/lib/components/atoms/skeleton"; -import { Surface } from "@/lib/components/atoms/surface"; -import { formatDate } from "@/lib/format"; -import type { CommitInfo } from "@/types/pr-run"; - -type CommitHistoryProps = { - commits: CommitInfo[]; - error?: string; - isLoading: boolean; -}; - -export function CommitHistory({ - commits, - error, - isLoading, -}: CommitHistoryProps) { - if (isLoading) { - return ( - - {Array.from({ length: 6 }).map((_, index) => ( -
- -
- - -
-
- ))} -
- ); - } - - if (error) { - return ( - - {error} - - ); - } - - if (commits.length === 0) { - return ( - - } - title="No commits found" - /> - - ); - } - - const outsideBranchStartIndex = commits.findIndex( - (commit) => !commit.isInSelectedBranch, - ); - - return ( - - {commits.map((commit, index) => { - const isOutsideBranch = !commit.isInSelectedBranch; - - return ( -
0 ? "border-border border-t" : ""} - key={commit.hash} - > - {index === outsideBranchStartIndex ? ( -
- - - Commits from the base history - -
- ) : null} -
-
- - - {commit.url ? ( - - {commit.shortHash} - - ) : ( - {commit.shortHash} - )} -
-
- {commit.url ? ( - - {commit.subject} - - ) : ( -
- {commit.subject} -
- )} -
- {commit.authorAvatarUrl ? ( - {commit.authorName} - ) : null} - {commit.authorUrl ? ( - - {commit.authorLogin ?? - commit.authorName} - - ) : ( - {commit.authorName} - )} - · - {formatDate(commit.date)} -
-
-
-
- ); - })} -
- ); -} diff --git a/src/lib/components/molecules/worktree-terminal/index.tsx b/src/lib/components/molecules/worktree-terminal/index.tsx index e1c0b1d..fdb1ad2 100644 --- a/src/lib/components/molecules/worktree-terminal/index.tsx +++ b/src/lib/components/molecules/worktree-terminal/index.tsx @@ -1,6 +1,6 @@ import { TerminalSquare } from "lucide-react"; -import { Button } from "@/lib/components/atoms/button"; +import { Button } from "@/lib/components/ui/button"; import { EmptyState } from "@/lib/components/atoms/empty-state"; import { Surface } from "@/lib/components/atoms/surface"; import { TerminalPane } from "@/lib/components/molecules/worktree-terminal/terminal-pane"; @@ -54,7 +54,7 @@ export function WorktreeTerminal({ - - - ); - })} + + + + ); + })} + + - - + ); } diff --git a/src/lib/components/molecules/worktree-terminal/use-worktree-terminal-owner.ts b/src/lib/components/molecules/worktree-terminal/use-worktree-terminal-owner.ts index 44d828b..3988250 100644 --- a/src/lib/components/molecules/worktree-terminal/use-worktree-terminal-owner.ts +++ b/src/lib/components/molecules/worktree-terminal/use-worktree-terminal-owner.ts @@ -1,4 +1,4 @@ -import { toast } from "@heroui/react"; +import { toast } from "@/lib/components/ui/toast"; import { useCallback, useEffect, useRef } from "react"; import { prRunApi } from "@/lib/api"; @@ -51,7 +51,7 @@ export function useWorktreeTerminalOwner({ tryPromise(ensureDefaultTerminal(ownerKey, worktreePath)).then( ([error]) => { if (error) { - toast.danger(getErrorMessage(error), { timeout: 3200 }); + toast.error(getErrorMessage(error), { timeout: 3200 }); } }, ); @@ -107,7 +107,7 @@ export function useWorktreeTerminalOwner({ ); if (error) { - toast.danger(getErrorMessage(error), { timeout: 3200 }); + toast.error(getErrorMessage(error), { timeout: 3200 }); } }, [createTerminal, ownerKey, worktreePath]); @@ -116,7 +116,7 @@ export function useWorktreeTerminalOwner({ const [error] = await tryPromise(closeTab(ownerKey, tabId)); if (error) { - toast.danger(getErrorMessage(error), { timeout: 3200 }); + toast.error(getErrorMessage(error), { timeout: 3200 }); } }, [closeTab, ownerKey], diff --git a/src/lib/components/templates/add-project-dialog.tsx b/src/lib/components/templates/add-project-dialog.tsx index ba1fa97..be65acc 100644 --- a/src/lib/components/templates/add-project-dialog.tsx +++ b/src/lib/components/templates/add-project-dialog.tsx @@ -1,9 +1,19 @@ -import { Input, Label } from "@heroui/react"; import { FolderPlus, X } from "lucide-react"; import { useEffect, useState } from "react"; -import { Button } from "@/lib/components/atoms/button"; -import { Surface } from "@/lib/components/atoms/surface"; +import { Alert } from "@/lib/components/ui/alert"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "@/lib/components/ui/dialog"; +import { Input } from "@/lib/components/ui/input"; +import { Label } from "@/lib/components/ui/label"; +import { Button } from "@/lib/components/ui/button"; type AddProjectDialogProps = { error?: string; @@ -28,84 +38,74 @@ export function AddProjectDialog({ } }, [isOpen]); - if (!isOpen) { - return null; - } - async function handleSubmit(event: React.FormEvent) { event.preventDefault(); await onSubmit(projectPath.trim()); } return ( -
- -
-
-
- + !open && onClose()}> + + + +
+
+ +
+
+ Add project + + Enter the local path of a Git repository. + +
-
-

- Add project -

-

- Enter the local path of a Git repository. -

-
-
+ -
- - - setProjectPath(event.target.value) - } - /> -
+ +
+ + + setProjectPath(event.target.value) + } + /> +
- {error ? ( - - {error} - - ) : null} + {error ? ( + {error} + ) : null} +
-
+ -
+ - -
+ + ); } diff --git a/src/lib/components/templates/branch-diff-panel.tsx b/src/lib/components/templates/branch-diff-panel.tsx deleted file mode 100644 index db65504..0000000 --- a/src/lib/components/templates/branch-diff-panel.tsx +++ /dev/null @@ -1,369 +0,0 @@ -import { parsePatchFiles } from "@pierre/diffs"; -import { FileDiff, type FileDiffMetadata } from "@pierre/diffs/react"; -import { useEffect, useMemo, useState } from "react"; - -import { isHandledSshPromptError } from "@/lib/api"; -import { Button } from "@/lib/components/atoms/button"; -import { EmptyState } from "@/lib/components/atoms/empty-state"; -import { Skeleton } from "@/lib/components/atoms/skeleton"; -import { Surface } from "@/lib/components/atoms/surface"; -import { BranchDiffTree } from "@/lib/components/molecules/branch-diff-tree"; -import { useBranchDiffQuery } from "@/lib/hooks/query/use-branch-diff-query"; -import { useSshPassphraseStore } from "@/lib/hooks/store/use-ssh-passphrase-store"; -import { getErrorMessage } from "@/lib/utils/get-error-message"; - -type BranchDiffPanelProps = { - baseBranchName?: string; - branchName: string; - projectId: string; -}; - -const DIFF_SIDEBAR_MIN_WIDTH = 168; -const DIFF_SIDEBAR_MAX_WIDTH = 320; -const DIFF_SIDEBAR_DEFAULT_WIDTH = 208; -const DIFF_SIDEBAR_WIDTH_STORAGE_KEY = "pr-run.diff.sidebar.width"; -const DIFF_WRAP_LINES_STORAGE_KEY = "pr-run.diff.wrap-lines"; -const DIFF_VIEW_MODE_STORAGE_KEY = "pr-run.diff.view-mode"; - -export function BranchDiffPanel({ - baseBranchName, - branchName, - projectId, -}: BranchDiffPanelProps) { - const [selectedPath, setSelectedPath] = useState(); - const [shouldWrapLines, setShouldWrapLines] = useState( - () => localStorage.getItem(DIFF_WRAP_LINES_STORAGE_KEY) === "true", - ); - const [isUnifiedView, setIsUnifiedView] = useState( - () => localStorage.getItem(DIFF_VIEW_MODE_STORAGE_KEY) === "unified", - ); - const [diffSidebarWidth, setDiffSidebarWidth] = useState(() => { - const stored = Number( - localStorage.getItem(DIFF_SIDEBAR_WIDTH_STORAGE_KEY), - ); - - return Number.isFinite(stored) - ? clamp(stored, DIFF_SIDEBAR_MIN_WIDTH, DIFF_SIDEBAR_MAX_WIDTH) - : DIFF_SIDEBAR_DEFAULT_WIDTH; - }); - const [isResizingDiffSidebar, setIsResizingDiffSidebar] = useState(false); - const branchDiffQuery = useBranchDiffQuery( - projectId, - branchName, - baseBranchName, - ); - const isAwaitingSshPassphrase = isHandledSshPromptError( - branchDiffQuery.error, - ); - - useEffect(() => { - setSelectedPath(undefined); - }, [branchName, projectId]); - - useEffect(() => { - if (!isResizingDiffSidebar) { - return; - } - - const previousCursor = document.body.style.cursor; - const previousUserSelect = document.body.style.userSelect; - - function handlePointerMove(event: PointerEvent) { - const body = document.body.getBoundingClientRect(); - const nextWidth = event.clientX - body.left - 12; - - setDiffSidebarWidth( - clamp( - nextWidth, - DIFF_SIDEBAR_MIN_WIDTH, - DIFF_SIDEBAR_MAX_WIDTH, - ), - ); - } - - function handlePointerUp() { - setIsResizingDiffSidebar(false); - } - - document.body.style.cursor = "col-resize"; - document.body.style.userSelect = "none"; - window.addEventListener("pointermove", handlePointerMove); - window.addEventListener("pointerup", handlePointerUp); - window.addEventListener("pointercancel", handlePointerUp); - - return () => { - document.body.style.cursor = previousCursor; - document.body.style.userSelect = previousUserSelect; - window.removeEventListener("pointermove", handlePointerMove); - window.removeEventListener("pointerup", handlePointerUp); - window.removeEventListener("pointercancel", handlePointerUp); - }; - }, [isResizingDiffSidebar]); - - useEffect(() => { - localStorage.setItem( - DIFF_SIDEBAR_WIDTH_STORAGE_KEY, - String(diffSidebarWidth), - ); - }, [diffSidebarWidth]); - - useEffect(() => { - localStorage.setItem( - DIFF_WRAP_LINES_STORAGE_KEY, - String(shouldWrapLines), - ); - }, [shouldWrapLines]); - - useEffect(() => { - localStorage.setItem( - DIFF_VIEW_MODE_STORAGE_KEY, - isUnifiedView ? "unified" : "split", - ); - }, [isUnifiedView]); - - useEffect(() => { - if (!isAwaitingSshPassphrase) { - return; - } - - useSshPassphraseStore - .getState() - .setRetryAction(() => - branchDiffQuery.refetch().then(() => undefined), - ); - }, [branchDiffQuery, isAwaitingSshPassphrase]); - - useEffect(() => { - const firstPath = branchDiffQuery.data?.files[0]?.path; - - if (!firstPath) { - return; - } - - setSelectedPath((current) => { - if ( - current && - branchDiffQuery.data?.files.some( - (file) => file.path === current, - ) - ) { - return current; - } - - return firstPath; - }); - }, [branchDiffQuery.data]); - - const fileDiffs = useMemo( - () => parseFileDiffs(branchDiffQuery.data?.patch ?? "", branchName), - [branchName, branchDiffQuery.data?.patch], - ); - const selectedFileDiff = useMemo( - () => findSelectedFileDiff(fileDiffs, selectedPath), - [fileDiffs, selectedPath], - ); - const error = branchDiffQuery.error - ? getErrorMessage(branchDiffQuery.error) - : undefined; - - if (branchDiffQuery.isPending) { - return ( - -
- - - - -
-
- - - - - -
-
- ); - } - - if (isAwaitingSshPassphrase) { - return ( - - Waiting for SSH passphrase... - - ); - } - - if (error) { - return ( - - {error} - - ); - } - - if (!branchDiffQuery.data || branchDiffQuery.data.files.length === 0) { - return ( - - - - ); - } - - return ( -
-
-
- {selectedPath ?? "Diff"} -
-
-
- - -
- -
-
-
- - -
- ); -} - -function clamp(value: number, min: number, max: number) { - return Math.min(Math.max(value, min), max); -} - -function parseFileDiffs(patch: string, branchName: string) { - if (!patch.trim()) { - return []; - } - - return parsePatchFiles(patch, branchName).flatMap((item) => item.files); -} - -function findSelectedFileDiff( - fileDiffs: FileDiffMetadata[], - selectedPath: string | undefined, -) { - return ( - fileDiffs.find( - (fileDiff) => - fileDiff.name === selectedPath || - fileDiff.prevName === selectedPath, - ) ?? fileDiffs[0] - ); -} diff --git a/src/lib/components/templates/branch-docker-panel.tsx b/src/lib/components/templates/branch-docker-panel.tsx index 895e546..1f5bb7e 100644 --- a/src/lib/components/templates/branch-docker-panel.tsx +++ b/src/lib/components/templates/branch-docker-panel.tsx @@ -1,7 +1,7 @@ -import { toast } from "@heroui/react"; import { RefreshCw } from "lucide-react"; -import { Button } from "@/lib/components/atoms/button"; +import { Button } from "@/lib/components/ui/button"; +import { toast } from "@/lib/components/ui/toast"; import { EmptyState } from "@/lib/components/atoms/empty-state"; import { Skeleton } from "@/lib/components/atoms/skeleton"; import { StatusPill } from "@/lib/components/atoms/status-pill"; @@ -54,7 +54,7 @@ export function BranchDockerPanel({ ); if (prepareError) { - toast.danger(getErrorMessage(prepareError), { timeout: 3200 }); + toast.error(getErrorMessage(prepareError), { timeout: 3200 }); return; } @@ -67,7 +67,7 @@ export function BranchDockerPanel({ ); if (runError) { - toast.danger(getErrorMessage(runError), { timeout: 3200 }); + toast.error(getErrorMessage(runError), { timeout: 3200 }); return; } @@ -112,7 +112,7 @@ export function BranchDockerPanel({ size="xs" type="button" variant="outline" - onPress={() => { + onClick={() => { dockerOverviewQuery.refetch(); }} > @@ -132,7 +132,7 @@ export function BranchDockerPanel({ size="sm" type="button" variant="outline" - onPress={() => { + onClick={() => { dockerOverviewQuery.refetch(); }} > @@ -170,11 +170,11 @@ export function BranchDockerPanel({
-
- - - {scriptsQuery.isPending ? ( -
- - -
- ) : scriptsQuery.error ? ( - - {getErrorMessage(scriptsQuery.error)} - - ) : scriptsQuery.data?.length ? ( -
- {scriptsQuery.data.map((script) => { - const isPreparing = - runScriptMutation.isPending && - runScriptMutation.variables?.scriptId === - script.id; - const isDeleting = - deleteScriptMutation.isPending && - deleteScriptMutation.variables === script.id; - const isActionVisible = isDeleting; - - return ( -
- { - runScript(script); - }} - > - - - - {isPreparing - ? "Preparing..." - : script.title} - - - -   - - -
- - -
-
- ); - })} -
- ) : ( - - Create a global script from the sidebar toolbar. - - )} - - {scriptPendingDelete ? ( -
- -

Delete script

-

- Delete "{scriptPendingDelete.title}" from the global - script list. -

-
- - -
-
-
- ) : null} - - ); -} diff --git a/src/lib/components/templates/create-script-dialog.tsx b/src/lib/components/templates/create-script-dialog.tsx index df79aa9..56060dd 100644 --- a/src/lib/components/templates/create-script-dialog.tsx +++ b/src/lib/components/templates/create-script-dialog.tsx @@ -1,9 +1,19 @@ -import { Input, Label } from "@heroui/react"; import { FilePlus2, X } from "lucide-react"; import { useEffect, useState } from "react"; -import { Button } from "@/lib/components/atoms/button"; -import { Surface } from "@/lib/components/atoms/surface"; +import { Alert } from "@/lib/components/ui/alert"; +import { Button } from "@/lib/components/ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "@/lib/components/ui/dialog"; +import { Input } from "@/lib/components/ui/input"; +import { Label } from "@/lib/components/ui/label"; type CreateScriptDialogProps = { error?: string; @@ -28,83 +38,75 @@ export function CreateScriptDialog({ } }, [isOpen]); - if (!isOpen) { - return null; - } - async function handleSubmit(event: React.FormEvent) { event.preventDefault(); await onSubmit(title.trim()); } return ( -
- -
-
-
- + !open && onClose()}> + + + +
+
+ +
+
+ Create script + + Create a global TypeScript script available + to every project. + +
-
-

- Create script -

-

- Create a global TypeScript script available to - every project. -

-
-
+ -
- - setTitle(event.target.value)} - /> -
+ +
+ + + setTitle(event.target.value) + } + /> +
- {error ? ( - - {error} - - ) : null} + {error ? ( + {error} + ) : null} +
-
+ -
+ - -
+ + ); } diff --git a/src/lib/components/templates/global-terminal-panel.test.ts b/src/lib/components/templates/global-terminal-panel.test.ts new file mode 100644 index 0000000..24c6892 --- /dev/null +++ b/src/lib/components/templates/global-terminal-panel.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "bun:test"; + +import { + selectGlobalTerminal, + type TerminalTreeTab, +} from "@/lib/components/templates/global-terminal-panel"; + +function terminal(ownerKey: string, id: string): TerminalTreeTab { + return { + branchName: ownerKey, + busyState: "idle", + id, + isAlive: true, + label: id, + ownerKey, + projectId: "project", + sessionId: id, + terminalKey: `${ownerKey}:${id}`, + }; +} + +describe("selectGlobalTerminal", () => { + test("never reuses a selected terminal from another preferred owner", () => { + const first = terminal("first", "one"); + const second = terminal("second", "two"); + + expect( + selectGlobalTerminal([first, second], "second", first.terminalKey), + ).toBe(second); + expect( + selectGlobalTerminal([first], "second", first.terminalKey), + ).toBeNull(); + }); +}); diff --git a/src/lib/components/templates/global-terminal-panel.tsx b/src/lib/components/templates/global-terminal-panel.tsx index 904b53f..e756012 100644 --- a/src/lib/components/templates/global-terminal-panel.tsx +++ b/src/lib/components/templates/global-terminal-panel.tsx @@ -1,10 +1,10 @@ -import { toast } from "@heroui/react"; import { ChevronDown, ChevronRight, Terminal, X } from "lucide-react"; import type { PointerEvent as ReactPointerEvent } from "react"; import { useEffect, useMemo, useState } from "react"; import { BusyIcon } from "@/lib/components/atoms/busy-icon"; -import { Button } from "@/lib/components/atoms/button"; +import { Button } from "@/lib/components/ui/button"; +import { toast } from "@/lib/components/ui/toast"; import { EmptyState } from "@/lib/components/atoms/empty-state"; import { Surface } from "@/lib/components/atoms/surface"; import { TerminalPane } from "@/lib/components/molecules/worktree-terminal/terminal-pane"; @@ -39,7 +39,7 @@ type TerminalTreeGroup = { terminals: TerminalTreeTab[]; }; -type TerminalTreeTab = { +export type TerminalTreeTab = { branchName: string; busyState: "idle" | "busy" | "unknown"; id: string; @@ -77,25 +77,12 @@ export function GlobalTerminalPanel({ [groups, owners], ); const terminals = useMemo(() => flattenTerminalTree(tree), [tree]); - const preferredTerminal = preferredOwnerKey - ? (terminals.find( - (terminal) => terminal.ownerKey === preferredOwnerKey, - ) ?? null) - : null; - const selectedTerminal = preferredOwnerKey - ? (terminals.find( - (terminal) => - terminal.ownerKey === preferredOwnerKey && - terminal.terminalKey === selectedTerminalKey, - ) ?? - preferredTerminal ?? - null) - : (terminals.find( - (terminal) => terminal.terminalKey === selectedTerminalKey, - ) ?? - terminals[0] ?? - null); - const selectedOwnerKey = preferredOwnerKey ?? selectedTerminal?.ownerKey; + const selectedTerminal = selectGlobalTerminal( + terminals, + preferredOwnerKey, + selectedTerminalKey, + ); + const selectedOwnerKey = selectedTerminal?.ownerKey ?? preferredOwnerKey; const selectedOwner = selectedOwnerKey ? owners[selectedOwnerKey] : null; const [expandedGroupIds, setExpandedGroupIds] = useState>( () => new Set(), @@ -136,7 +123,7 @@ export function GlobalTerminalPanel({ ); if (error) { - toast.danger(getErrorMessage(error), { timeout: 3200 }); + toast.error(getErrorMessage(error), { timeout: 3200 }); return; } @@ -151,7 +138,7 @@ export function GlobalTerminalPanel({ const [error] = await tryPromise(closeTab(selectedOwnerKey, tabId)); if (error) { - toast.danger(getErrorMessage(error), { timeout: 3200 }); + toast.error(getErrorMessage(error), { timeout: 3200 }); } } @@ -345,11 +332,10 @@ export function GlobalTerminalPanel({ aria-label="Close terminals panel" className="text-muted-foreground border-transparent bg-transparent shadow-none" - isIconOnly size="icon-xs" type="button" variant="ghost" - onPress={onClose} + onClick={onClose} > @@ -360,6 +346,34 @@ export function GlobalTerminalPanel({ ); } +export function selectGlobalTerminal( + terminals: TerminalTreeTab[], + preferredOwnerKey: string | null, + selectedTerminalKey: string | null, +) { + if (preferredOwnerKey) { + return ( + terminals.find( + (terminal) => + terminal.ownerKey === preferredOwnerKey && + terminal.terminalKey === selectedTerminalKey, + ) ?? + terminals.find( + (terminal) => terminal.ownerKey === preferredOwnerKey, + ) ?? + null + ); + } + + return ( + terminals.find( + (terminal) => terminal.terminalKey === selectedTerminalKey, + ) ?? + terminals[0] ?? + null + ); +} + function buildTerminalTree( groups: ProjectGroup[], owners: ReturnType["owners"], diff --git a/src/lib/components/templates/main-panel/activity/activity-avatar.tsx b/src/lib/components/templates/main-panel/activity/activity-avatar.tsx new file mode 100644 index 0000000..cae262d --- /dev/null +++ b/src/lib/components/templates/main-panel/activity/activity-avatar.tsx @@ -0,0 +1,45 @@ +import { cn } from "@/lib/utils/cn"; + +type ActivityAvatarProps = { + className?: string; + imageUrl?: string; + name: string; +}; + +export function ActivityAvatar({ + className, + imageUrl, + name, +}: ActivityAvatarProps) { + if (imageUrl) { + return ( + {`${name}'s + ); + } + + const initials = name + .split(/\s+/) + .slice(0, 2) + .map((part) => part[0]?.toUpperCase()) + .join(""); + + return ( + + ); +} diff --git a/src/lib/components/templates/main-panel/activity/activity-item.test.ts b/src/lib/components/templates/main-panel/activity/activity-item.test.ts new file mode 100644 index 0000000..5584a18 --- /dev/null +++ b/src/lib/components/templates/main-panel/activity/activity-item.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from "bun:test"; + +import { resolveGitHubMarkdownUrl } from "@/lib/components/templates/main-panel/activity/activity-item"; + +describe("resolveGitHubMarkdownUrl", () => { + test("resolves repository-relative links", () => { + const repository = "https://github.com/example/repo"; + + expect( + resolveGitHubMarkdownUrl( + "/docs/CONTRIBUTING.md", + repository, + "feature/ui", + ), + ).toBe( + "https://github.com/example/repo/blob/feature/ui/docs/CONTRIBUTING.md", + ); + expect( + resolveGitHubMarkdownUrl( + "../blob/main/image.png?raw=true", + repository, + "feature/ui", + ), + ).toBe("https://github.com/example/repo/blob/main/image.png?raw=true"); + }); + + test("keeps absolute links and rejects unsafe protocols", () => { + expect( + resolveGitHubMarkdownUrl( + "https://example.com/image.png", + "https://github.com/example/repo", + "main", + ), + ).toBe("https://example.com/image.png"); + expect( + resolveGitHubMarkdownUrl( + "javascript:alert(1)", + "https://github.com/example/repo", + "main", + ), + ).toBe(""); + }); +}); diff --git a/src/lib/components/templates/main-panel/activity/activity-item.tsx b/src/lib/components/templates/main-panel/activity/activity-item.tsx new file mode 100644 index 0000000..6662c14 --- /dev/null +++ b/src/lib/components/templates/main-panel/activity/activity-item.tsx @@ -0,0 +1,211 @@ +import { Check, CircleDot, MessageSquareText, X } from "lucide-react"; +import ReactMarkdown, { defaultUrlTransform } from "react-markdown"; +import rehypeRaw from "rehype-raw"; +import rehypeSanitize from "rehype-sanitize"; +import remarkGfm from "remark-gfm"; + +import { ActivityAvatar } from "@/lib/components/templates/main-panel/activity/activity-avatar"; +import { CommitRow } from "@/lib/components/templates/main-panel/activity/commit-row"; +import { formatDate } from "@/lib/format"; +import type { + PullRequestReviewState, + WorktreeActivityItem as WorktreeActivityItemType, +} from "@/types/pr-run"; + +type ActivityItemProps = { + branchName: string; + item: WorktreeActivityItemType; + repositoryUrl?: string; +}; + +export function ActivityItem({ + branchName, + item, + repositoryUrl, +}: ActivityItemProps) { + if (item.type === "commit") { + return ; + } + + const content = item.type === "comment" ? item.comment : item.review; + const action = + item.type === "comment" ? "commented" : reviewAction(item.review.state); + const date = + item.type === "comment" + ? item.comment.createdAt + : item.review.submittedAt; + const icon = + item.type === "comment" ? ( + + ) : ( + reviewIcon(item.review.state) + ); + + return ( + + ); +} + +function MarkdownBody({ + body, + branchName, + repositoryUrl, +}: { + body: string; + branchName: string; + repositoryUrl?: string; +}) { + return ( +
+ + resolveGitHubMarkdownUrl(url, repositoryUrl, branchName) + } + > + {body} + +
+ ); +} + +export function resolveGitHubMarkdownUrl( + url: string, + repositoryUrl: string | undefined, + branchName: string, +) { + const safeUrl = defaultUrlTransform(url); + + if ( + !safeUrl || + !repositoryUrl || + safeUrl.startsWith("#") || + safeUrl.startsWith("//") || + /^[a-z][a-z\d+.-]*:/i.test(safeUrl) + ) { + return safeUrl; + } + + const repository = repositoryUrl.replace(/\/$/, ""); + + if (safeUrl.startsWith("../blob/")) { + return `${repository}/${safeUrl.replace(/^\.\.\//, "")}`; + } + + const encodedBranch = branchName + .split("/") + .map(encodeURIComponent) + .join("/"); + const relativePath = safeUrl.startsWith("/") + ? safeUrl.slice(1) + : safeUrl.replace(/^\.\//, ""); + + return `${repository}/blob/${encodedBranch}/${relativePath}`; +} + +function reviewAction(state: PullRequestReviewState) { + if (state === "APPROVED") { + return "approved these changes"; + } + + if (state === "CHANGES_REQUESTED") { + return "requested changes"; + } + + if (state === "DISMISSED") { + return "had a review dismissed"; + } + + return "reviewed these changes"; +} + +function reviewIcon(state: PullRequestReviewState) { + if (state === "APPROVED") { + return ; + } + + if (state === "CHANGES_REQUESTED") { + return ; + } + + return ; +} diff --git a/src/lib/components/templates/main-panel/activity/commit-row.tsx b/src/lib/components/templates/main-panel/activity/commit-row.tsx new file mode 100644 index 0000000..50787c1 --- /dev/null +++ b/src/lib/components/templates/main-panel/activity/commit-row.tsx @@ -0,0 +1,104 @@ +import { Binary } from "lucide-react"; + +import { ActivityAvatar } from "@/lib/components/templates/main-panel/activity/activity-avatar"; +import { formatDate } from "@/lib/format"; +import { cn } from "@/lib/utils/cn"; +import type { CommitInfo } from "@/types/pr-run"; + +type CommitRowProps = { + commit: CommitInfo; + muted?: boolean; +}; + +export function CommitRow({ commit, muted = false }: CommitRowProps) { + const authorName = commit.authorLogin ?? commit.authorName; + const hashContent = ( + + {commit.shortHash} + + ); + + return ( +
+ +
+
+ {commit.authorUrl ? ( + + {authorName} + + ) : ( + + {authorName} + + )} + + committed + +
+
+ {commit.subject} +
+
+ + {commit.additions !== undefined ? ( + + + +{commit.additions} + {" "} + + −{commit.deletions ?? 0} + + + ) : null} + {commit.hasBinaryChanges ? ( + + + Binary files changed + + ) : null} +
+
+
+ {commit.url ? ( + + {hashContent} + + ) : ( + hashContent + )} +
+
+ ); +} diff --git a/src/lib/components/templates/main-panel/activity/index.tsx b/src/lib/components/templates/main-panel/activity/index.tsx new file mode 100644 index 0000000..9bab528 --- /dev/null +++ b/src/lib/components/templates/main-panel/activity/index.tsx @@ -0,0 +1,149 @@ +import { GitCommitHorizontal } from "lucide-react"; + +import { EmptyState } from "@/lib/components/atoms/empty-state"; +import { Skeleton } from "@/lib/components/atoms/skeleton"; +import { Surface } from "@/lib/components/atoms/surface"; +import { ActivityItem } from "@/lib/components/templates/main-panel/activity/activity-item"; +import { CommitRow } from "@/lib/components/templates/main-panel/activity/commit-row"; +import { ReviewComposer } from "@/lib/components/templates/main-panel/activity/review-composer"; +import type { WorktreeActivityResult } from "@/types/pr-run"; + +type WorktreeActivityProps = { + baseBranchName?: string; + branchName: string; + data?: WorktreeActivityResult; + error?: string; + isLoading: boolean; + projectId: string; + pullRequestNumber?: number; + repositoryUrl?: string; +}; + +export function WorktreeActivity({ + baseBranchName, + branchName, + data, + error, + isLoading, + projectId, + pullRequestNumber, + repositoryUrl, +}: WorktreeActivityProps) { + if (isLoading) { + return ; + } + + if (error) { + return ( + + {error} + + ); + } + + if ( + !data || + (data.baseCommits.length === 0 && + data.items.length === 0 && + !data.pendingReview) + ) { + return ( + + } + title="No activity found" + /> + + ); + } + + return ( + + {data.baseCommits.length > 0 ? ( +
+ +
+ {data.baseCommits.map((commit) => ( + + ))} +
+
+ ) : null} + +
+ + {data.integration.status === "unavailable" && + data.integration.reason !== "not-a-pull-request" ? ( +
+ {data.integration.message} Local commit history is still + available. +
+ ) : null} +
+ {data.items.map((item) => ( + + ))} +
+
+ + {pullRequestNumber && data.integration.status === "available" ? ( + + ) : null} +
+ ); +} + +function TimelineDivider({ label }: { label: string }) { + return ( +
+ + {label} + +
+ ); +} + +function ActivitySkeleton() { + return ( + + {Array.from({ length: 7 }).map((_, index) => ( +
+ +
+ + + +
+ +
+ ))} +
+ ); +} diff --git a/src/lib/components/templates/main-panel/activity/review-composer.tsx b/src/lib/components/templates/main-panel/activity/review-composer.tsx new file mode 100644 index 0000000..6f9ebc6 --- /dev/null +++ b/src/lib/components/templates/main-panel/activity/review-composer.tsx @@ -0,0 +1,205 @@ +import { Check, MessageSquareText, Send, Trash2, X } from "lucide-react"; +import { useState } from "react"; + +import { Button } from "@/lib/components/ui/button"; +import { Textarea } from "@/lib/components/ui/textarea"; +import { toast } from "@/lib/components/ui/toast"; +import { tryPromise } from "@/lib/error"; +import { usePullRequestReviewMutations } from "@/lib/hooks/query/use-pull-request-review-mutations"; +import { getErrorMessage } from "@/lib/utils/get-error-message"; +import type { PendingPullRequestReview, ReviewEvent } from "@/types/pr-run"; + +type ReviewComposerProps = { + baseBranchName?: string; + branchName: string; + pendingReview?: PendingPullRequestReview; + projectId: string; + pullRequestNumber: number; +}; + +export function ReviewComposer({ + baseBranchName, + branchName, + pendingReview, + projectId, + pullRequestNumber, +}: ReviewComposerProps) { + const [commentBody, setCommentBody] = useState(""); + const [reviewBody, setReviewBody] = useState(pendingReview?.body ?? ""); + const { commentMutation, discardReviewMutation, submitReviewMutation } = + usePullRequestReviewMutations({ + baseBranchName, + branchName, + projectId, + pullRequestNumber, + }); + const isReviewActionPending = + discardReviewMutation.isPending || submitReviewMutation.isPending; + const canSubmitCommentReview = Boolean( + reviewBody.trim() || pendingReview?.comments.length, + ); + + async function addComment() { + const body = commentBody.trim(); + + if (!body) { + return; + } + + const [error] = await tryPromise(commentMutation.mutateAsync(body)); + + if (error) { + toast.error(getErrorMessage(error), { timeout: 3200 }); + return; + } + + setCommentBody(""); + toast.success("Comment added.", { timeout: 2200 }); + } + + async function submitReview(event: ReviewEvent) { + const body = reviewBody.trim(); + + if (event === "REQUEST_CHANGES" && !body) { + toast.error("Explain the requested changes.", { timeout: 2600 }); + return; + } + + const [error] = await tryPromise( + submitReviewMutation.mutateAsync({ + body: body || undefined, + event, + }), + ); + + if (error) { + toast.error(getErrorMessage(error), { timeout: 3200 }); + return; + } + + setReviewBody(""); + toast.success(reviewSuccessMessage(event), { timeout: 2200 }); + } + + async function discardReview() { + const [error] = await tryPromise(discardReviewMutation.mutateAsync()); + + if (error) { + toast.error(getErrorMessage(error), { timeout: 3200 }); + return; + } + + setReviewBody(""); + toast.success("Pending review discarded.", { timeout: 2200 }); + } + + return ( +
+
+
+

+ Join the discussion +

+ + Markdown supported + +
+