diff --git a/package-lock.json b/package-lock.json index 833eb96..af28989 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,59 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "6.0.7", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.7.tgz", + "integrity": "sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.3.0", + "@csstools/css-color-parser": "^4.1.10", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz", + "integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -450,6 +503,16 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -504,6 +567,19 @@ "integrity": "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==", "license": "MIT" }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, "node_modules/@chevrotain/cst-dts-gen": { "version": "11.1.2", "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.1.2.tgz", @@ -543,6 +619,146 @@ "integrity": "sha512-4mudFAQ6H+MqBTfqLmU7G1ZwRzCLfJEooL/fsF6rCX5eePMbGhoy5n4g+G4vlh2muDcsCTJtL+uKbOzWxs5LHA==", "license": "Apache-2.0" }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", + "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.0.tgz", + "integrity": "sha512-5+5LEmFuY1AjXdYhmgjTJogtQnP1evJ1zrBZGUNZ0thkpwnnmKxcHdAMn/OtFjAb25zA+jKDVYVRl+5G7rjv1A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.1", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.8.tgz", + "integrity": "sha512-CpMLjAvwQg3BL5S0IeqsZNMH7EQrEWi0kLKOC13ZBF0ZwERiLWlibNPJr8G1kdU3Ms/r2KiNrF81pUh2HwAHdg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@diffity/git": { "resolved": "packages/git", "link": true @@ -1006,6 +1222,24 @@ "node": ">=18" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@iconify/types": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", @@ -2483,6 +2717,55 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -2493,6 +2776,14 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -3051,6 +3342,17 @@ "sprintf-js": "~1.0.2" } }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -3104,6 +3406,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/brace-expansion": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", @@ -3450,6 +3762,20 @@ "layout-base": "^1.0.0" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -3964,6 +4290,35 @@ "lodash-es": "^4.17.21" } }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/dayjs": { "version": "1.11.20", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", @@ -3987,6 +4342,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decode-named-character-reference": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", @@ -4099,6 +4461,14 @@ "resolved": "packages/cli", "link": true }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dompurify": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz", @@ -4575,6 +4945,19 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -4743,6 +5126,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-wsl": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", @@ -4797,6 +5187,83 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz", + "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^6.0.5", + "@asamuzakjp/dom-selector": "^8.3.0", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.7", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.2", + "undici": "^8.9.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsdom/node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/jsesc": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", @@ -5168,6 +5635,17 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -5469,6 +5947,13 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/mermaid": { "version": "11.13.0", "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.13.0.tgz", @@ -6361,6 +6846,36 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/property-information": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", @@ -6371,6 +6886,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/react": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", @@ -6402,6 +6927,14 @@ "react-dom": ">=16.8.0" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/react-markdown": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", @@ -6604,6 +7137,16 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -6750,6 +7293,19 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -6970,6 +7526,13 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwind-merge": { "version": "3.5.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", @@ -7041,6 +7604,52 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.10.tgz", + "integrity": "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.10" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.10.tgz", + "integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -7127,6 +7736,16 @@ "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", "license": "MIT" }, + "node_modules/undici": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz", + "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/undici-types": { "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", @@ -7635,6 +8254,19 @@ "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", "license": "MIT" }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/web-namespaces": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", @@ -7645,6 +8277,41 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz", + "integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -7696,6 +8363,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -7831,9 +8515,11 @@ "devDependencies": { "@react-router/dev": "^7.13.2", "@react-router/fs-routes": "^7.13.2", + "@testing-library/react": "^16.3.2", "@types/nprogress": "^0.2.3", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "jsdom": "^30.0.1", "typescript": "^6.0.2", "vite": "^8.0.2", "vitest": "^4.1.0" diff --git a/packages/ui/package.json b/packages/ui/package.json index 30ed468..02f5baa 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -36,9 +36,11 @@ "devDependencies": { "@react-router/dev": "^7.13.2", "@react-router/fs-routes": "^7.13.2", + "@testing-library/react": "^16.3.2", "@types/nprogress": "^0.2.3", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "jsdom": "^30.0.1", "typescript": "^6.0.2", "vite": "^8.0.2", "vitest": "^4.1.0" diff --git a/packages/ui/src/components/diff/diff-page.tsx b/packages/ui/src/components/diff/diff-page.tsx index 0325f5a..7684d8c 100644 --- a/packages/ui/src/components/diff/diff-page.tsx +++ b/packages/ui/src/components/diff/diff-page.tsx @@ -8,6 +8,7 @@ import { useWrapLines } from '../../hooks/use-wrap-lines'; import { useKeyboard } from '../../hooks/use-keyboard'; import { useReviewThreads } from '../../hooks/use-review-threads'; import { useTours } from '../../hooks/use-tours'; +import { useHideWhitespace } from '../../hooks/use-hide-whitespace'; import { pickActiveTour, orderPathsByTour, stopsByPath } from '../../lib/tour-order'; import { TourStepper } from './tour-stepper'; import { useCommentActions } from '../../hooks/use-comment-actions'; @@ -41,7 +42,7 @@ export function DiffPage() { }>(); const [viewMode, setViewMode] = useState(initialViewMode || 'split'); - const [hideWhitespace, setHideWhitespace] = useState(false); + const { hideWhitespace, setHideWhitespace } = useHideWhitespace(); const [showHelp, setShowHelp] = useState(false); const { theme, toggleTheme } = useTheme(initialTheme); const { wrapLines, toggleWrapLines } = useWrapLines(); @@ -85,6 +86,16 @@ export function DiffPage() { const diffPaths = useMemo(() => (diff ? diff.files.map(file => getFilePath(file)) : []), [diff]); const tourStops = useMemo(() => stopsByPath(activeTour, diffPaths), [activeTour, diffPaths]); + const focusRangesByFile = useMemo(() => { + const ranges = new Map(); + for (const step of activeTour?.steps ?? []) { + const existing = ranges.get(step.filePath) ?? []; + existing.push({ startLine: step.startLine, endLine: step.endLine }); + ranges.set(step.filePath, existing); + } + return ranges; + }, [activeTour]); + const orderedDiff = useMemo(() => { if (!diff || !activeTour || !reviewOrderEnabled || activeTour.steps.length === 0) { return diff; @@ -404,7 +415,7 @@ export function DiffPage() { onScrollToThread={handleScrollToThread} repoName={info?.name || null} branch={info?.branch || null} - description={info?.description || null} + description={hideWhitespace ? `${info?.description ?? ''} ยท whitespace hidden` : info?.description || null} githubDetails={githubDetails} sessionId={sessionId} onGitHubPulled={() => queryClient.invalidateQueries({ queryKey: ['threads'] })} @@ -458,6 +469,7 @@ export function DiffPage() { onAddThread={handleAddThread} pendingSelection={pendingSelection} onPendingSelectionChange={setPendingSelection} + focusRangesByFile={focusRangesByFile} /> ) : null} diff --git a/packages/ui/src/components/diff/diff-view.tsx b/packages/ui/src/components/diff/diff-view.tsx index 8fcbb61..ff4b581 100644 --- a/packages/ui/src/components/diff/diff-view.tsx +++ b/packages/ui/src/components/diff/diff-view.tsx @@ -47,6 +47,8 @@ interface DiffViewProps { onAddThread: CommentActions['addThread']; pendingSelection: LineSelection | null; onPendingSelectionChange: (selection: LineSelection | null) => void; + /** Per file, the line ranges the walkthrough points at. */ + focusRangesByFile?: Map; } function estimateFileHeight(file: { hunks: { lines: { length: number } }[]; isBinary: boolean }, collapsed: boolean): number { @@ -73,6 +75,7 @@ export function DiffView(props: DiffViewProps) { handle, baseRef, canRevert, onRevert, threads, commentsEnabled, commentActions, onAddThread, pendingSelection, onPendingSelectionChange, + focusRangesByFile, } = props; const { highlight } = useHighlighter(); const scrollElementRef = useRef(null); @@ -277,6 +280,7 @@ export function DiffView(props: DiffViewProps) { ref={virtualizer.measureElement} > { if (highlightedFile === filePath) { diff --git a/packages/ui/src/components/diff/file-block.tsx b/packages/ui/src/components/diff/file-block.tsx index 4070d14..7abcabf 100644 --- a/packages/ui/src/components/diff/file-block.tsx +++ b/packages/ui/src/components/diff/file-block.tsx @@ -6,6 +6,7 @@ import type { SyntaxToken } from '../../lib/syntax-token'; import type { HighlightedTokens } from '../../hooks/use-highlighter'; import type { CommentSide, LineSelection } from '../comments/types'; import { type ViewMode, getFilePath, buildChangeGroupPatch, extractLinesFromDiff, extractLinesFromExpandedLines } from '../../lib/diff-utils'; +import { classifyHunk, hunkIntersectsRanges, MECHANICAL_LABELS } from '../../lib/hunk-attention'; import { revertHunk as apiRevertHunk } from '../../lib/api'; import { ConfirmDialog } from '../ui/confirm-dialog'; import { computeGaps, createContextLines, getExpandRange, type ExpandableGap } from '../../lib/context-expansion'; @@ -58,6 +59,8 @@ interface FileBlockProps { onPendingSelectionChange: (selection: LineSelection | null) => void; highlighted?: boolean; onHighlightEnd?: () => void; + /** Line ranges the walkthrough asked the reader to look at, on the new side. */ + focusRanges?: { startLine: number; endLine: number }[]; } interface GapExpansion { @@ -69,7 +72,7 @@ interface GapExpansion { export function FileBlock(props: FileBlockProps) { const { - file, viewMode, collapsed, onToggleCollapse, reviewed, onReviewedChange, highlightLine, baseRef, canRevert, onRevert, + file, viewMode, collapsed, onToggleCollapse, reviewed, onReviewedChange, highlightLine, baseRef, canRevert, onRevert, focusRanges, threads: allThreads, commentsEnabled, commentActions, onAddThread: rawAddThread, pendingSelection, onPendingSelectionChange, highlighted, onHighlightEnd, } = props; @@ -262,6 +265,16 @@ export function FileBlock(props: FileBlockProps) { }; }, [file, highlightLine]); + const hunkAttention = useMemo( + () => file.hunks.map(hunk => classifyHunk(file, hunk)), + [file], + ); + + const isHunkFocused = useCallback( + (hunk: DiffHunk) => hunkIntersectsRanges(hunk, focusRanges), + [focusRanges], + ); + const gaps = useMemo(() => { if (isNewFile) { return []; @@ -502,6 +515,18 @@ export function FileBlock(props: FileBlockProps) { )} {file.hunks.map((hunk, i) => { + const mechanical = hunkAttention[i]; + const focused = isHunkFocused(hunk); + const attentionClass = focused + ? 'bg-accent/5' + : mechanical + ? 'opacity-45 hover:opacity-100 transition-opacity' + : ''; + const attentionTitle = focused + ? 'The walkthrough points here' + : mechanical + ? `Dimmed: ${MECHANICAL_LABELS[mechanical]}` + : undefined; const betweenGap = i > 0 ? gapMap.get(`between-${i - 1}`) : undefined; const betweenExpansion = betweenGap ? expansions.get(betweenGap.id) : undefined; const topExpansion = i === 0 ? expansions.get('top') : undefined; @@ -510,6 +535,8 @@ export function FileBlock(props: FileBlockProps) { ; expandControls?: ExpandControls; topExpansionLines?: DiffLineType[]; @@ -275,7 +279,7 @@ export function renderSplitRows( export function HunkBlockSplit(props: HunkBlockSplitProps) { const { - hunk, syntaxMap, expandControls, topExpansionLines, bottomExpansionLines, expansionSyntaxMap, + hunk, attentionClass = '', attentionTitle, syntaxMap, expandControls, topExpansionLines, bottomExpansionLines, expansionSyntaxMap, threads, pendingSelection, currentAuthor, isLineSelected, onLineMouseDown, onLineMouseEnter, onCommentClick, onAddThread, onReply, onResolve, onUnresolve, onEditComment, onDeleteComment, onDeleteThread, @@ -345,7 +349,7 @@ export function HunkBlockSplit(props: HunkBlockSplitProps) { if (isChangeGroup && onRevertChange) { const group = changeGroups[groupIdx]; sections.push( - + {currentRows} @@ -365,7 +369,7 @@ export function HunkBlockSplit(props: HunkBlockSplitProps) { ); } else { sections.push( - + {currentRows} ); @@ -418,7 +422,7 @@ export function HunkBlockSplit(props: HunkBlockSplitProps) { return ( <> - + {expansionRows} diff --git a/packages/ui/src/components/diff/hunk-block.tsx b/packages/ui/src/components/diff/hunk-block.tsx index 2546401..9f2a7da 100644 --- a/packages/ui/src/components/diff/hunk-block.tsx +++ b/packages/ui/src/components/diff/hunk-block.tsx @@ -11,6 +11,10 @@ import { UndoIcon } from '../icons/undo-icon'; interface HunkBlockProps { hunk: DiffHunk; + /** Applied to every row group of the hunk, so a whole hunk recedes or stands out together. */ + attentionClass?: string; + /** Why it was dimmed, so the reader can always find out rather than having to trust it. */ + attentionTitle?: string; syntaxMap?: Map; expandControls?: ExpandControls; topExpansionLines?: DiffLineType[]; @@ -107,7 +111,7 @@ export function renderLineWithComments( export function HunkBlock(props: HunkBlockProps) { const { - hunk, syntaxMap, expandControls, topExpansionLines, bottomExpansionLines, expansionSyntaxMap, + hunk, attentionClass = '', attentionTitle, syntaxMap, expandControls, topExpansionLines, bottomExpansionLines, expansionSyntaxMap, threads, pendingSelection, currentAuthor, isLineSelected, onLineMouseDown, onLineMouseEnter, onCommentClick, onAddThread, onReply, onResolve, onUnresolve, onDeleteComment, onDeleteThread, @@ -163,7 +167,7 @@ export function HunkBlock(props: HunkBlockProps) { if (isChangeGroup && onRevertChange) { const group = changeGroups[groupIdx]; sections.push( - + {currentRows} @@ -183,7 +187,7 @@ export function HunkBlock(props: HunkBlockProps) { ); } else { sections.push( - + {currentRows} ); @@ -216,7 +220,7 @@ export function HunkBlock(props: HunkBlockProps) { return ( <> - + {expansionRows} diff --git a/packages/ui/src/components/diff/hunk-with-gap.tsx b/packages/ui/src/components/diff/hunk-with-gap.tsx index 0624779..3e212bf 100644 --- a/packages/ui/src/components/diff/hunk-with-gap.tsx +++ b/packages/ui/src/components/diff/hunk-with-gap.tsx @@ -17,6 +17,8 @@ interface GapExpansion { interface HunkWithGapProps { hunk: DiffHunk; + attentionClass?: string; + attentionTitle?: string; viewMode: ViewMode; syntaxMap?: Map; expandControls?: ExpandControls; @@ -46,7 +48,8 @@ interface HunkWithGapProps { export function HunkWithGap(props: HunkWithGapProps) { const { - hunk, viewMode, syntaxMap, expandControls, topExpansionLines, gapExpansion, gapId, highlightLine, + hunk, + attentionClass, attentionTitle, viewMode, syntaxMap, expandControls, topExpansionLines, gapExpansion, gapId, highlightLine, threads, pendingSelection, currentAuthor, isLineSelected, onLineMouseDown, onLineMouseEnter, onCommentClick, onAddThread, onReply, onResolve, onUnresolve, onEditComment, onDeleteComment, onDeleteThread, @@ -86,6 +89,8 @@ export function HunkWithGap(props: HunkWithGapProps) { )} 0 ? topExpansionLines : undefined} diff --git a/packages/ui/src/hooks/use-hide-whitespace.ts b/packages/ui/src/hooks/use-hide-whitespace.ts new file mode 100644 index 0000000..1e9fe64 --- /dev/null +++ b/packages/ui/src/hooks/use-hide-whitespace.ts @@ -0,0 +1,26 @@ +import { useCallback, useState } from 'react'; + +const STORAGE_KEY = 'diffity-hide-whitespace'; + +/** + * On by default: whitespace is the formatter's business, so it should not spend a reviewer's + * attention. The toolbar shows when it is on, because a filtered diff renders fewer lines than + * the forge does and that must never be silent. + */ +function readStored(): boolean { + if (typeof window === 'undefined') { + return true; + } + return localStorage.getItem(STORAGE_KEY) !== 'false'; +} + +export function useHideWhitespace() { + const [hideWhitespace, setHideWhitespace] = useState(readStored); + + const setAndRemember = useCallback((hide: boolean) => { + setHideWhitespace(hide); + localStorage.setItem(STORAGE_KEY, String(hide)); + }, []); + + return { hideWhitespace, setHideWhitespace: setAndRemember }; +} diff --git a/packages/ui/src/lib/diff-utils.ts b/packages/ui/src/lib/diff-utils.ts index dbd7876..e287144 100644 --- a/packages/ui/src/lib/diff-utils.ts +++ b/packages/ui/src/lib/diff-utils.ts @@ -88,7 +88,7 @@ function getTotalLineCount(file: DiffFile): number { return count; } -function isAutoCollapsible(file: DiffFile): boolean { +export function isAutoCollapsible(file: DiffFile): boolean { if (file.status === 'deleted' || file.status === 'renamed') { return true; } diff --git a/packages/ui/src/lib/hunk-attention.ts b/packages/ui/src/lib/hunk-attention.ts new file mode 100644 index 0000000..2335191 --- /dev/null +++ b/packages/ui/src/lib/hunk-attention.ts @@ -0,0 +1,144 @@ +import type { DiffFile, DiffHunk, DiffLine } from '@diffity/parser'; +import { isAutoCollapsible } from './diff-utils'; + +/** + * Why a hunk was judged mechanical. Every reason here is decided by a rule, never by a model: + * dimming asserts that something needs less attention, which is the one claim a review tool + * should only make when it can show its reasoning. + */ +export type MechanicalReason = 'generated' | 'whitespace' | 'imports'; + +export const MECHANICAL_LABELS: Record = { + generated: 'generated', + whitespace: 'whitespace only', + imports: 'imports only', +}; + +// Indentation is syntax in these, so a whitespace change is a real change and must not be dimmed. +const INDENTATION_SENSITIVE = [ + '.py', + '.pyi', + '.yml', + '.yaml', + '.md', + '.markdown', + '.mk', + '.nim', + '.hs', + '.sass', + '.styl', + '.pug', + '.haml', + '.slim', + '.coffee', + '.elm', +]; + +const IMPORT_PATTERNS = [ + /^\s*import\b/, + /^\s*export\s[^=]*\bfrom\b/, + /^\s*(?:const|let|var)\s+.*=\s*require\s*\(/, + /^\s*from\s+\S+\s+import\b/, + /^\s*use\s+[\w:{}*, ]+;\s*$/, + /^\s*#include\b/, + /^\s*require(?:_relative)?\s+['"]/, + /^\s*package\s+\S+\s*;?\s*$/, +]; + +export function isIndentationSensitive(path: string): boolean { + const lower = path.toLowerCase(); + if (lower.endsWith('makefile') || lower.includes('makefile.')) { + return true; + } + return INDENTATION_SENSITIVE.some(ext => lower.endsWith(ext)); +} + +function changedLines(hunk: DiffHunk): DiffLine[] { + return hunk.lines.filter(line => line.type === 'add' || line.type === 'delete'); +} + +function withoutWhitespace(content: string): string { + return content.replace(/\s+/g, ''); +} + +/** + * True when the added and removed lines are the same lines with different whitespace. A blank + * line that appeared or disappeared is a real change to the file's shape, so it does not count. + */ +export function isWhitespaceOnlyHunk(hunk: DiffHunk): boolean { + const changed = changedLines(hunk); + if (changed.length === 0) { + return false; + } + + const added = changed.filter(line => line.type === 'add').map(line => withoutWhitespace(line.content)).sort(); + const removed = changed.filter(line => line.type === 'delete').map(line => withoutWhitespace(line.content)).sort(); + + return ( + added.length > 0 && + added.length === removed.length && + added.every((line, index) => line === removed[index]) + ); +} + +/** + * True when every line the hunk touches is an import. A linter will catch an unused or unsorted + * import, but not a wrong one, so this dims rather than hides. + */ +export function isImportOnlyHunk(hunk: DiffHunk): boolean { + const changed = changedLines(hunk); + if (changed.length === 0) { + return false; + } + + let sawImport = false; + for (const line of changed) { + if (line.content.trim() === '') { + continue; + } + if (!IMPORT_PATTERNS.some(pattern => pattern.test(line.content))) { + return false; + } + sawImport = true; + } + + return sawImport; +} + +export function classifyHunk(file: DiffFile, hunk: DiffHunk): MechanicalReason | null { + if (isAutoCollapsible(file)) { + return 'generated'; + } + + const path = file.status === 'deleted' ? file.oldPath : file.newPath; + + if (!isIndentationSensitive(path) && isWhitespaceOnlyHunk(hunk)) { + return 'whitespace'; + } + + if (isImportOnlyHunk(hunk)) { + return 'imports'; + } + + return null; +} + +export interface FocusRange { + startLine: number; + endLine: number; +} + +/** + * Whether a hunk covers any of the lines the walkthrough points at. Compared on the new side, + * which is where a walkthrough step's lines are recorded. + */ +export function hunkIntersectsRanges(hunk: DiffHunk, ranges: FocusRange[] | undefined): boolean { + if (!ranges || ranges.length === 0) { + return false; + } + + const from = hunk.newStart; + const to = hunk.newStart + hunk.newCount - 1; + + return ranges.some(range => range.startLine <= to && range.endLine >= from); +} diff --git a/packages/ui/tests/file-block-attention.test.tsx b/packages/ui/tests/file-block-attention.test.tsx new file mode 100644 index 0000000..19076f9 --- /dev/null +++ b/packages/ui/tests/file-block-attention.test.tsx @@ -0,0 +1,124 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { render, cleanup } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { DiffFile, DiffHunk, DiffLine } from '@diffity/parser'; +import { FileBlock } from '../src/components/diff/file-block'; +import type { CommentActions } from '../src/hooks/use-comment-actions'; + +function line(type: DiffLine['type'], content: string, num: number): DiffLine { + return { + type, + content, + oldLineNumber: type === 'add' ? null : num, + newLineNumber: type === 'delete' ? null : num, + }; +} + +function hunkAt(newStart: number, lines: DiffLine[]): DiffHunk { + return { + header: `@@ -${newStart},${lines.length} +${newStart},${lines.length} @@`, + oldStart: newStart, + oldCount: lines.length, + newStart, + newCount: lines.length, + lines, + }; +} + +function file(path: string, hunks: DiffHunk[]): DiffFile { + return { + oldPath: path, + newPath: path, + status: 'modified', + hunks, + additions: 1, + deletions: 0, + isBinary: false, + }; +} + +// The component takes a bag of callbacks it never invokes during a plain render. +const commentActions = {} as CommentActions; + +function renderFileBlock(target: DiffFile, focusRanges?: { startLine: number; endLine: number }[]) { + // FileBlock expands context through a query, so it needs a client even when nothing fetches. + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + + return render( + + + , + ); +} + +function tbodyClasses(): string[] { + return Array.from(document.querySelectorAll('tbody')).map(body => body.className); +} + +afterEach(cleanup); + +describe('FileBlock attention', () => { + const importsHunk = hunkAt(1, [line('add', "import { a } from 'a';", 1)]); + const workHunk = hunkAt(40, [line('add', 'doWork();', 40)]); + + it('renders at all', () => { + expect(() => renderFileBlock(file('src/a.ts', [workHunk]))).not.toThrow(); + expect(document.querySelectorAll('tbody').length).toBeGreaterThan(0); + }); + + it('dims an imports-only hunk and says why', () => { + renderFileBlock(file('src/a.ts', [importsHunk])); + + expect(tbodyClasses().some(c => c.includes('opacity-45'))).toBe(true); + const titled = Array.from(document.querySelectorAll('tbody[title]')); + expect(titled.length).toBeGreaterThan(0); + expect(titled[0].getAttribute('title')).toBe('Dimmed: imports only'); + }); + + it('leaves real work at full attention', () => { + renderFileBlock(file('src/a.ts', [workHunk])); + + expect(tbodyClasses().some(c => c.includes('opacity-45'))).toBe(false); + expect(document.querySelectorAll('tbody[title]').length).toBe(0); + }); + + it('does not dim whitespace in a file where indentation is syntax', () => { + const reindent = hunkAt(1, [line('delete', ' key: value', 1), line('add', ' key: value', 1)]); + renderFileBlock(file('.github/workflows/ci.yml', [reindent])); + + expect(tbodyClasses().some(c => c.includes('opacity-45'))).toBe(false); + }); + + it('highlights a hunk the walkthrough points at, in preference to dimming it', () => { + renderFileBlock(file('src/a.ts', [importsHunk]), [{ startLine: 1, endLine: 1 }]); + + expect(tbodyClasses().some(c => c.includes('bg-accent/5'))).toBe(true); + expect(tbodyClasses().some(c => c.includes('opacity-45'))).toBe(false); + expect(document.querySelector('tbody[title]')?.getAttribute('title')).toBe( + 'The walkthrough points here', + ); + }); + + it('highlights only the hunk in range', () => { + renderFileBlock(file('src/a.ts', [importsHunk, workHunk]), [{ startLine: 40, endLine: 40 }]); + + const focused = tbodyClasses().filter(c => c.includes('bg-accent/5')); + const dimmed = tbodyClasses().filter(c => c.includes('opacity-45')); + expect(focused.length).toBeGreaterThan(0); + expect(dimmed.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/ui/tests/hunk-attention-render.test.tsx b/packages/ui/tests/hunk-attention-render.test.tsx new file mode 100644 index 0000000..3ef8dcf --- /dev/null +++ b/packages/ui/tests/hunk-attention-render.test.tsx @@ -0,0 +1,61 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { render, cleanup } from '@testing-library/react'; +import type { DiffHunk, DiffLine } from '@diffity/parser'; +import { HunkBlock } from '../src/components/diff/hunk-block'; +import { HunkBlockSplit } from '../src/components/diff/hunk-block-split'; + +function line(type: DiffLine['type'], content: string, num: number): DiffLine { + return { + type, + content, + oldLineNumber: type === 'add' ? null : num, + newLineNumber: type === 'delete' ? null : num, + }; +} + +const hunk: DiffHunk = { + header: '@@ -1,2 +1,2 @@', + oldStart: 1, + oldCount: 2, + newStart: 1, + newCount: 2, + lines: [line('context', 'const a = 1;', 1), line('add', 'const b = 2;', 2)], +}; + +// A tbody cannot live in a div, so the component gets a real table to render into. +function renderInTable(element: React.ReactElement) { + const table = document.createElement('table'); + document.body.appendChild(table); + return render(element, { container: table }); +} + +afterEach(cleanup); + +describe.each([ + ['unified', HunkBlock], + ['split', HunkBlockSplit], +])('%s hunk renderer', (_name, Component) => { + it('renders without throwing when no attention is passed', () => { + expect(() => renderInTable()).not.toThrow(); + }); + + it('puts the attention class on every row group of the hunk', () => { + renderInTable(); + + const bodies = Array.from(document.querySelectorAll('tbody')); + expect(bodies.length).toBeGreaterThan(0); + for (const body of bodies) { + expect(body.className).toContain('opacity-45'); + expect(body.getAttribute('title')).toBe('Dimmed: imports only'); + } + }); + + it('leaves the row groups unmarked when the hunk needs no attention', () => { + renderInTable(); + + for (const body of Array.from(document.querySelectorAll('tbody'))) { + expect(body.className).not.toContain('opacity-45'); + expect(body.getAttribute('title')).toBeNull(); + } + }); +}); diff --git a/packages/ui/tests/hunk-attention.test.ts b/packages/ui/tests/hunk-attention.test.ts new file mode 100644 index 0000000..ca2c7bb --- /dev/null +++ b/packages/ui/tests/hunk-attention.test.ts @@ -0,0 +1,158 @@ +import { describe, it, expect } from 'vitest'; +import type { DiffFile, DiffHunk, DiffLine } from '@diffity/parser'; +import { + classifyHunk, + hunkIntersectsRanges, + isImportOnlyHunk, + isIndentationSensitive, + isWhitespaceOnlyHunk, +} from '../src/lib/hunk-attention'; + +function line(type: DiffLine['type'], content: string): DiffLine { + return { type, content, oldLineNumber: null, newLineNumber: null }; +} + +function hunk(lines: DiffLine[]): DiffHunk { + return { header: '@@ -1,3 +1,3 @@', oldStart: 1, oldCount: 3, newStart: 1, newCount: 3, lines }; +} + +function file(path: string, hunks: DiffHunk[] = []): DiffFile { + return { + oldPath: path, + newPath: path, + status: 'modified', + hunks, + additions: 1, + deletions: 1, + isBinary: false, + }; +} + +describe('isWhitespaceOnlyHunk', () => { + it('sees a reindent', () => { + expect( + isWhitespaceOnlyHunk(hunk([line('delete', ' return 1;'), line('add', ' return 1;')])), + ).toBe(true); + }); + + it('does not see a real edit', () => { + expect( + isWhitespaceOnlyHunk(hunk([line('delete', ' return 1;'), line('add', ' return 2;')])), + ).toBe(false); + }); + + it('does not treat an added blank line as whitespace-only', () => { + expect(isWhitespaceOnlyHunk(hunk([line('add', '')]))).toBe(false); + }); + + it('does not treat a pure deletion as whitespace-only', () => { + expect(isWhitespaceOnlyHunk(hunk([line('delete', ' gone();')]))).toBe(false); + }); + + it('ignores context lines', () => { + expect( + isWhitespaceOnlyHunk( + hunk([line('context', 'unchanged'), line('delete', 'a( b )'), line('add', 'a(b)')]), + ), + ).toBe(true); + }); +}); + +describe('isImportOnlyHunk', () => { + it('sees es imports, requires, python, rust, c and go', () => { + const cases = [ + "import { a } from 'a';", + "export { b } from './b';", + "const c = require('c');", + 'from pathlib import Path', + 'use std::fs::read_to_string;', + '#include ', + 'package main', + ]; + + for (const content of cases) { + expect(isImportOnlyHunk(hunk([line('add', content)])), content).toBe(true); + } + }); + + it('tolerates blank lines among the imports', () => { + expect( + isImportOnlyHunk(hunk([line('add', "import { a } from 'a';"), line('add', '')])), + ).toBe(true); + }); + + it('refuses when anything else changed', () => { + expect( + isImportOnlyHunk(hunk([line('add', "import { a } from 'a';"), line('add', 'doWork();')])), + ).toBe(false); + }); + + it('refuses a hunk with no changed lines', () => { + expect(isImportOnlyHunk(hunk([line('context', "import { a } from 'a';")]))).toBe(false); + }); +}); + +describe('isIndentationSensitive', () => { + it('knows where whitespace is syntax', () => { + for (const path of ['a.py', 'ci.yml', 'k8s.yaml', 'README.md', 'Makefile', 'x.hs']) { + expect(isIndentationSensitive(path), path).toBe(true); + } + }); + + it('leaves brace languages alone', () => { + for (const path of ['a.ts', 'b.tsx', 'c.go', 'd.java', 'e.css']) { + expect(isIndentationSensitive(path), path).toBe(false); + } + }); +}); + +describe('classifyHunk', () => { + it('dims a generated file whatever is in it', () => { + const generated = file('pnpm-lock.yaml'); + + expect(classifyHunk(generated, hunk([line('add', 'anything')]))).toBe('generated'); + }); + + it('dims a reindent in a brace language', () => { + expect( + classifyHunk(file('a.ts'), hunk([line('delete', 'if(x){'), line('add', 'if (x) {')])), + ).toBe('whitespace'); + }); + + it('refuses to dim a reindent where indentation is syntax', () => { + const reindent = hunk([line('delete', ' key: value'), line('add', ' key: value')]); + + expect(classifyHunk(file('ci.yml'), reindent)).toBeNull(); + }); + + it('dims an imports-only hunk', () => { + expect(classifyHunk(file('a.ts'), hunk([line('add', "import { a } from 'a';")]))).toBe('imports'); + }); + + it('leaves real work alone', () => { + expect(classifyHunk(file('a.ts'), hunk([line('add', 'doWork();')]))).toBeNull(); + }); +}); + +describe('hunkIntersectsRanges', () => { + const target = { ...hunk([]), newStart: 10, newCount: 5 }; // lines 10-14 + + it('matches a range inside the hunk', () => { + expect(hunkIntersectsRanges(target, [{ startLine: 12, endLine: 12 }])).toBe(true); + }); + + it('matches a range straddling either edge', () => { + expect(hunkIntersectsRanges(target, [{ startLine: 5, endLine: 10 }])).toBe(true); + expect(hunkIntersectsRanges(target, [{ startLine: 14, endLine: 30 }])).toBe(true); + }); + + it('does not match a range that misses by one', () => { + expect(hunkIntersectsRanges(target, [{ startLine: 1, endLine: 9 }])).toBe(false); + expect(hunkIntersectsRanges(target, [{ startLine: 15, endLine: 20 }])).toBe(false); + }); + + it('is false without ranges', () => { + expect(hunkIntersectsRanges(target, [])).toBe(false); + expect(hunkIntersectsRanges(target, undefined)).toBe(false); + }); +}); diff --git a/packages/ui/vite.config.ts b/packages/ui/vite.config.ts index 11c48a7..d67866f 100644 --- a/packages/ui/vite.config.ts +++ b/packages/ui/vite.config.ts @@ -2,10 +2,17 @@ import { reactRouter } from "@react-router/dev/vite"; import tailwindcss from "@tailwindcss/vite"; import { defineConfig } from "vite"; +// The React Router plugin injects a runtime preamble that a component test has no framework to +// provide, so tests build with plain esbuild JSX instead. +const isTest = !!process.env.VITEST; + export default defineConfig({ - plugins: [tailwindcss(), reactRouter()], + plugins: isTest ? [tailwindcss()] : [tailwindcss(), reactRouter()], + esbuild: { jsx: "automatic", jsxImportSource: "react" }, test: { include: ["tests/**/*.test.{ts,tsx}"], + // Component tests render for real: a render-time ReferenceError has nothing else catching it. + environment: "jsdom", }, server: { proxy: {