diff --git a/package-lock.json b/package-lock.json index 55003d64..06ff2272 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56,9 +56,11 @@ "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^4.6.0", + "happy-dom": "20.11.1", "tailwindcss": "^4.1.18", "typescript": "~5.8.3", - "vite": "^7.0.4" + "vite": "^7.0.4", + "vitest": "4.1.10" } }, "node_modules/@babel/code-frame": { @@ -1981,6 +1983,13 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tailwindcss/node": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz", @@ -3239,6 +3248,24 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -3276,6 +3303,16 @@ "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", "license": "MIT" }, + "node_modules/@types/node": { + "version": "26.1.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz", + "integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -3305,6 +3342,23 @@ "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", "license": "MIT" }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -3326,6 +3380,119 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -3344,6 +3511,16 @@ "node": ">=10" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.0", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", @@ -3411,6 +3588,19 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer-image-size": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz", + "integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + }, + "engines": { + "node": ">=4.0" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001776", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001776.tgz", @@ -3432,6 +3622,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -3574,6 +3774,13 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", @@ -3638,6 +3845,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-equals": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", @@ -3706,6 +3933,38 @@ "dev": true, "license": "ISC" }, + "node_modules/happy-dom": { + "version": "20.11.1", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.11.1.tgz", + "integrity": "sha512-XSt8tMzbW9ymE7687xztkO1ckR7qJNQ3LywY9vlYGhGi3zXrGBHuUo2Cl1ztZaICW+1eAGdkLbj6iwVqDT33kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": ">=20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "@types/ws": "^8.18.1", + "buffer-image-size": "^0.6.4", + "entities": "^7.0.1", + "whatwg-mimetype": "^3.0.0", + "ws": "^8.21.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/happy-dom/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/highlight.js": { "version": "11.11.1", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", @@ -4151,12 +4410,33 @@ "dev": true, "license": "MIT" }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/orderedmap": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -4601,6 +4881,13 @@ "semver": "bin/semver.js" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/sonner": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", @@ -4621,6 +4908,20 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "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", @@ -4652,6 +4953,23 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -4669,6 +4987,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tippy.js": { "version": "6.3.7", "resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz", @@ -4704,6 +5032,13 @@ "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", "license": "MIT" }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -4869,12 +5204,151 @@ } } }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "license": "MIT" }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "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", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index fb889190..dae25d48 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,9 @@ "dev": "vite", "build": "tsc && vite build", "preview": "vite preview", - "tauri": "tauri" + "tauri": "tauri", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@dnd-kit/core": "^6.3.1", @@ -58,8 +60,10 @@ "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^4.6.0", + "happy-dom": "20.11.1", "tailwindcss": "^4.1.18", "typescript": "~5.8.3", - "vite": "^7.0.4" + "vite": "^7.0.4", + "vitest": "4.1.10" } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 72b6d15d..8a1b9a60 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -129,6 +129,8 @@ pub struct Settings { pub ollama_model: Option, #[serde(rename = "foldersEnabled")] pub folders_enabled: Option, + #[serde(rename = "sidebarSortOrder")] + pub sidebar_sort_order: Option, #[serde(rename = "ignoredPatterns")] pub ignored_patterns: Option>, #[serde(rename = "customColorsLight")] @@ -1795,6 +1797,39 @@ fn update_git_enabled( Ok(()) } +#[tauri::command] +fn persist_sidebar_sort_order( + notes_folder: &str, + settings: &mut Settings, + order: String, +) -> Result<(), String> { + if order != "newest" && order != "oldest" { + return Err("Invalid sidebar sort order".to_string()); + } + + let previous_order = settings.sidebar_sort_order.replace(order); + if let Err(error) = save_settings(notes_folder, settings) { + settings.sidebar_sort_order = previous_order; + return Err(error.to_string()); + } + + Ok(()) +} + +#[tauri::command] +fn update_sidebar_sort_order(order: String, state: State) -> Result<(), String> { + let folder = { + let app_config = state.app_config.read().expect("app_config read lock"); + app_config + .notes_folder + .clone() + .ok_or("Notes folder not set")? + }; + + let mut settings = state.settings.write().expect("settings write lock"); + persist_sidebar_sort_order(&folder, &mut settings, order) +} + #[tauri::command] async fn write_file(path: String, contents: Vec) -> Result<(), String> { fs::write(&path, contents) @@ -3854,6 +3889,7 @@ pub fn run() { get_settings, update_settings, update_git_enabled, + update_sidebar_sort_order, preview_note_name, write_file, search_notes, @@ -3994,3 +4030,82 @@ fn set_title_bar_theme( } Ok(()) } + +#[cfg(test)] +mod tests { + use super::{load_settings, persist_sidebar_sort_order, Settings}; + use std::path::PathBuf; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn unique_test_path(name: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after Unix epoch") + .as_nanos(); + std::env::temp_dir().join(format!( + "scratch-sidebar-sort-{name}-{}-{nonce}", + std::process::id(), + )) + } + + #[test] + fn settings_preserve_sidebar_note_sort_order() { + let settings: Settings = + serde_json::from_str(r#"{"theme":{"mode":"system"},"sidebarSortOrder":"oldest"}"#) + .expect("settings should deserialize"); + + assert_eq!(settings.sidebar_sort_order.as_deref(), Some("oldest")); + + let serialized = serde_json::to_value(settings).expect("settings should serialize"); + assert_eq!(serialized["sidebarSortOrder"], "oldest"); + } + + #[test] + fn sidebar_sort_patch_persists_only_valid_orders() { + let notes_folder = unique_test_path("persist"); + std::fs::create_dir_all(¬es_folder).expect("test folder should be created"); + let notes_folder_string = notes_folder.to_string_lossy().into_owned(); + let mut settings = Settings { + sidebar_sort_order: Some("newest".to_string()), + ..Settings::default() + }; + + persist_sidebar_sort_order(¬es_folder_string, &mut settings, "oldest".to_string()) + .expect("valid order should persist"); + assert_eq!(settings.sidebar_sort_order.as_deref(), Some("oldest")); + assert_eq!( + load_settings(¬es_folder_string) + .sidebar_sort_order + .as_deref(), + Some("oldest"), + ); + + let error = persist_sidebar_sort_order( + ¬es_folder_string, + &mut settings, + "unexpected".to_string(), + ) + .expect_err("invalid order should fail"); + assert_eq!(error, "Invalid sidebar sort order"); + assert_eq!(settings.sidebar_sort_order.as_deref(), Some("oldest")); + + std::fs::remove_dir_all(notes_folder).expect("test folder should be removed"); + } + + #[test] + fn sidebar_sort_patch_rolls_back_when_persistence_fails() { + let notes_folder = unique_test_path("rollback"); + std::fs::write(¬es_folder, b"not a directory").expect("blocking file should be created"); + let notes_folder_string = notes_folder.to_string_lossy().into_owned(); + let mut settings = Settings { + sidebar_sort_order: Some("newest".to_string()), + ..Settings::default() + }; + + persist_sidebar_sort_order(¬es_folder_string, &mut settings, "oldest".to_string()) + .expect_err("write through a file path should fail"); + assert_eq!(settings.sidebar_sort_order.as_deref(), Some("newest")); + + std::fs::remove_file(notes_folder).expect("blocking file should be removed"); + } +} diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 2aa1d2db..aa8db45e 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -26,6 +26,8 @@ import { import { mod, shift, isMac, isWindows } from "../../lib/platform"; import * as notesService from "../../services/notes"; import { FolderNameDialog } from "../notes/FolderNameDialog"; +import { NoteSortMenu } from "./SidebarControls"; +import type { NoteSortOrder } from "../../types/note"; interface SidebarProps { onOpenSettings?: () => void; @@ -49,6 +51,8 @@ export function Sidebar({ onOpenSettings }: SidebarProps) { const [folderDialogOpen, setFolderDialogOpen] = useState(false); const [folderDialogParent, setFolderDialogParent] = useState(""); const [foldersEnabled, setFoldersEnabled] = useState(true); + const [noteSortOrder, setNoteSortOrder] = + useState("newest"); const [dragLabel, setDragLabel] = useState(null); const [dragCount, setDragCount] = useState(1); const [multiSelectedNoteIds, setMultiSelectedNoteIds] = useState>(new Set()); @@ -169,12 +173,35 @@ export function Sidebar({ onOpenSettings }: SidebarProps) { useEffect(() => { notesService.getSettings().then((s) => { setFoldersEnabled(s.foldersEnabled === true); + setNoteSortOrder( + s.sidebarSortOrder === "oldest" ? "oldest" : "newest", + ); }).catch((error) => { console.error("Failed to load settings:", error); setFoldersEnabled(false); }); }, []); + const handleNoteSortOrderChange = useCallback( + (nextSortOrder: NoteSortOrder) => { + if (nextSortOrder === noteSortOrder) return; + + const previousSortOrder = noteSortOrder; + setNoteSortOrder(nextSortOrder); + + void notesService + .updateSidebarSortOrder(nextSortOrder) + .catch((error) => { + console.error("Failed to save note sort order:", error); + setNoteSortOrder((current) => + current === nextSortOrder ? previousSortOrder : current, + ); + toast.error("Failed to save note sort order"); + }); + }, + [noteSortOrder], + ); + // Sync input with search query useEffect(() => { setInputValue(searchQuery); @@ -321,6 +348,10 @@ export function Sidebar({ onOpenSettings }: SidebarProps) {
+ { + document.body.replaceChildren(); +}); + +describe("NoteSortMenu", () => { + it("offers newest and oldest ordering and reports the selected option", () => { + const onChange = vi.fn(); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + + act(() => { + root.render( + + + , + ); + }); + + const trigger = container.querySelector( + 'button[aria-label="Sort notes: Newest first"]', + ); + expect(trigger).not.toBeNull(); + expect(trigger?.tabIndex).toBe(0); + + act(() => { + trigger?.dispatchEvent( + new PointerEvent("pointerdown", { + bubbles: true, + button: 0, + pointerType: "mouse", + }), + ); + }); + + const options = Array.from( + document.body.querySelectorAll('[role="menuitemradio"]'), + ); + expect(options.map((option) => option.textContent?.trim())).toEqual([ + "Newest first", + "Oldest first", + ]); + expect(options[0]?.getAttribute("aria-checked")).toBe("true"); + + act(() => { + options[1]?.click(); + }); + + expect(onChange).toHaveBeenCalledOnce(); + expect(onChange).toHaveBeenCalledWith("oldest"); + + act(() => root.unmount()); + }); + + it("does not prevent Radix focus restoration on the sort trigger", () => { + const onChange = vi.fn(); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + + act(() => { + root.render( + + + , + ); + }); + + const source = container.innerHTML; + expect(source).not.toContain("onCloseAutoFocus"); + + act(() => root.unmount()); + }); +}); diff --git a/src/components/layout/SidebarControls.tsx b/src/components/layout/SidebarControls.tsx new file mode 100644 index 00000000..27ad856d --- /dev/null +++ b/src/components/layout/SidebarControls.tsx @@ -0,0 +1,73 @@ +import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; +import type { NoteSortOrder } from "../../types/note"; +import { + ArrowDownIcon, + ArrowUpIcon, + CheckIcon, +} from "../icons"; +import { IconButton } from "../ui"; + +interface NoteSortMenuProps { + sortOrder: NoteSortOrder; + onChange: (sortOrder: NoteSortOrder) => void; +} + +const radioItemClass = + "relative flex cursor-pointer items-center gap-2 px-3 py-1.5 pr-8 text-sm text-text outline-none hover:bg-bg-muted focus:bg-bg-muted data-[state=checked]:font-medium"; + +export function NoteSortMenu({ + sortOrder, + onChange, +}: NoteSortMenuProps) { + const newestFirst = sortOrder === "newest"; + + return ( + + + + {newestFirst ? ( + + ) : ( + + )} + + + + + + Sort notes + + { + if (value === "newest" || value === "oldest") onChange(value); + }} + > + + + Newest first + + + + + + + Oldest first + + + + + + + + + ); +} diff --git a/src/components/layout/SidebarFolderSection.test.tsx b/src/components/layout/SidebarFolderSection.test.tsx new file mode 100644 index 00000000..97d67e3f --- /dev/null +++ b/src/components/layout/SidebarFolderSection.test.tsx @@ -0,0 +1,87 @@ +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + SidebarFolderSection, + loadFolderSectionCollapsed, + saveFolderSectionCollapsed, +} from "./SidebarFolderSection"; + +afterEach(() => { + document.body.replaceChildren(); +}); + +describe("folder section persistence", () => { + it("loads only an explicitly collapsed section and saves the next state", () => { + const values = new Map(); + const storage = { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + }; + + expect(loadFolderSectionCollapsed(storage)).toBe(false); + values.set("scratch:foldersSectionCollapsed", "true"); + expect(loadFolderSectionCollapsed(storage)).toBe(true); + + saveFolderSectionCollapsed(false, storage); + expect(values.get("scratch:foldersSectionCollapsed")).toBe("false"); + }); +}); + +describe("SidebarFolderSection", () => { + it("uses one disclosure control to hide and reveal the complete folder group", () => { + const onCollapsedChange = vi.fn(); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + + act(() => { + root.render( + +
Folder tree
+
, + ); + }); + + const collapseButton = container.querySelector( + 'button[aria-label="Collapse Folders"]', + ); + expect(collapseButton?.getAttribute("aria-expanded")).toBe("true"); + const contentId = collapseButton?.getAttribute("aria-controls"); + expect(contentId).toBeTruthy(); + expect(container.querySelector(`#${contentId}`)).not.toBeNull(); + expect(container.textContent).toContain("Folders"); + expect(container.querySelector('[data-testid="folder-group"]')).not.toBeNull(); + + act(() => collapseButton?.click()); + expect(onCollapsedChange).toHaveBeenCalledWith(true); + + act(() => { + root.render( + +
Folder tree
+
, + ); + }); + + const expandButton = container.querySelector( + 'button[aria-label="Expand Folders"]', + ); + expect(expandButton?.getAttribute("aria-expanded")).toBe("false"); + expect(expandButton?.getAttribute("aria-controls")).toBe(contentId); + const collapsedContent = container.querySelector(`#${contentId}`); + expect(collapsedContent).not.toBeNull(); + expect(collapsedContent?.hidden).toBe(true); + + act(() => expandButton?.click()); + expect(onCollapsedChange).toHaveBeenLastCalledWith(false); + + act(() => root.unmount()); + }); +}); diff --git a/src/components/layout/SidebarFolderSection.tsx b/src/components/layout/SidebarFolderSection.tsx new file mode 100644 index 00000000..02214401 --- /dev/null +++ b/src/components/layout/SidebarFolderSection.tsx @@ -0,0 +1,74 @@ +import { useId, type ReactNode } from "react"; +import { ChevronRightIcon } from "../icons"; + +const STORAGE_KEY = "scratch:foldersSectionCollapsed"; + +type SidebarStorage = Pick; + +function defaultStorage(): SidebarStorage | undefined { + try { + return globalThis.localStorage; + } catch { + return undefined; + } +} + +export function loadFolderSectionCollapsed( + storage: SidebarStorage | undefined = defaultStorage(), +): boolean { + try { + return storage?.getItem(STORAGE_KEY) === "true"; + } catch { + return false; + } +} + +export function saveFolderSectionCollapsed( + collapsed: boolean, + storage: SidebarStorage | undefined = defaultStorage(), +): void { + try { + storage?.setItem(STORAGE_KEY, String(collapsed)); + } catch { + // Keep the disclosure usable when storage is unavailable. + } +} + +interface SidebarFolderSectionProps { + collapsed: boolean; + onCollapsedChange: (collapsed: boolean) => void; + children: ReactNode; +} + +export function SidebarFolderSection({ + collapsed, + onCollapsedChange, + children, +}: SidebarFolderSectionProps) { + const contentId = useId(); + const expanded = !collapsed; + + return ( +
+ + +
+ ); +} diff --git a/src/components/notes/FolderTreeView.test.tsx b/src/components/notes/FolderTreeView.test.tsx new file mode 100644 index 00000000..768907bf --- /dev/null +++ b/src/components/notes/FolderTreeView.test.tsx @@ -0,0 +1,286 @@ +import { DndContext } from "@dnd-kit/core"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + buildFolderTree, + getVisibleItemsForFolderSection, +} from "../../lib/folderTree"; +import type { FolderNode, NoteMetadata } from "../../types/note"; +import { FolderItemComponent, FolderTreeView } from "./FolderTreeView"; + +// Hoisted mocks for FolderTreeView integration test +const { mockUseNotes, mockListFolders } = vi.hoisted(() => ({ + mockUseNotes: vi.fn(), + mockListFolders: vi.fn(), +})); + +vi.mock("../../context/NotesContext", () => ({ + useNotes: mockUseNotes, +})); + +vi.mock("../../services/notes", () => ({ + listFolders: mockListFolders, + getNotesFolder: vi.fn().mockResolvedValue("/tmp/notes"), + getSettings: vi.fn(), + updateSettings: vi.fn(), +})); + +afterEach(() => { + document.body.replaceChildren(); +}); + +function note(id: string, modified: number): NoteMetadata { + return { id, title: id, preview: "", modified }; +} + +describe("FolderItemComponent", () => { + it("does not expose a per-folder descendant-collapse action", () => { + const child: FolderNode = { + name: "docs", + path: "Point/docs", + children: [], + notes: [], + }; + const parent: FolderNode = { + name: "Point", + path: "Point", + children: [child], + notes: [], + }; + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + + act(() => { + root.render( + + undefined)} + onUnpinNote={vi.fn(async () => undefined)} + onDuplicateNote={vi.fn(async () => undefined)} + onDeleteNote={vi.fn()} + onMoveNoteToParent={vi.fn()} + onMoveFolderToParent={vi.fn()} + /> + , + ); + }); + + expect( + container.querySelector( + 'button[aria-label="Collapse all subfolders in Point"]', + ), + ).toBeNull(); + expect(container.querySelectorAll("button")).toHaveLength(0); + + act(() => root.unmount()); + container.remove(); + }); +}); + +describe("getVisibleItemsForFolderSection keyboard navigation", () => { + it("excludes folder items from keyboard navigation while section is collapsed and includes them when expanded", () => { + const tree = buildFolderTree( + [note("pinned", 30), note("docs/inside", 20), note("recent", 10)], + new Set(["pinned"]), + ["docs"], + ); + + // When collapsed: folder items are excluded from keyboard navigation + const collapsed = getVisibleItemsForFolderSection( + tree, + new Set(["pinned"]), + new Set(), + true, + ); + expect(collapsed).toEqual([ + { type: "note", id: "pinned" }, + { type: "note", id: "recent" }, + ]); + expect(collapsed.some((item) => item.type === "folder")).toBe(false); + + // When expanded: folder items are included in keyboard navigation + const expanded = getVisibleItemsForFolderSection( + tree, + new Set(["pinned"]), + new Set(), + false, + ); + expect( + expanded.some((item) => item.type === "folder" && item.path === "docs"), + ).toBe(true); + expect( + expanded.some( + (item) => item.type === "note" && item.id === "docs/inside", + ), + ).toBe(true); + }); +}); + +describe("FolderTreeView folder section collapse and reopening", () => { + beforeEach(() => { + if (typeof localStorage !== "undefined" && localStorage !== null) { + localStorage.clear(); + } + vi.clearAllMocks(); + mockListFolders.mockResolvedValue(["docs"]); + mockUseNotes.mockReturnValue({ + notes: [note("note1", 1000), note("docs/note2", 2000)], + selectedNoteId: null, + currentNote: null, + notesFolder: null, + isLoading: false, + error: null, + searchQuery: "", + searchResults: [], + isSearching: false, + hasExternalChanges: false, + reloadVersion: 0, + selectNote: vi.fn(), + createNote: vi.fn(), + consumePendingNewNote: vi.fn(), + saveNote: vi.fn(), + deleteNote: vi.fn(), + duplicateNote: vi.fn(), + refreshNotes: vi.fn(), + reloadCurrentNote: vi.fn(), + setNotesFolder: vi.fn(), + syncNotesFolder: vi.fn(), + search: vi.fn(), + clearSearch: vi.fn(), + pinNote: vi.fn(), + unpinNote: vi.fn(), + createNoteInFolder: vi.fn(), + createFolder: vi.fn(), + deleteFolder: vi.fn(), + renameFolder: vi.fn(), + moveNote: vi.fn(), + moveFolder: vi.fn(), + }); + }); + + it("reopens a collapsed Folders section when an expand-folder event fires", async () => { + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + , + ); + }); + + // Wait for async listFolders to resolve and tree to build + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + + // Initially expanded (default localStorage state is false/expanded) + expect( + container.querySelector('button[aria-label="Collapse Folders"]'), + ).not.toBeNull(); + expect( + container.querySelector('button[aria-label="Expand Folders"]'), + ).toBeNull(); + + // Collapse the section + act(() => { + ( + container.querySelector( + 'button[aria-label="Collapse Folders"]', + ) as HTMLButtonElement | null + )?.click(); + }); + + // Section is now collapsed + expect( + container.querySelector('button[aria-label="Expand Folders"]'), + ).not.toBeNull(); + expect( + container.querySelector('button[aria-label="Collapse Folders"]'), + ).toBeNull(); + + // Dispatch expand-folder event to reopen + act(() => { + window.dispatchEvent(new CustomEvent("expand-folder", { detail: "docs" })); + }); + + // Section is now expanded again + expect( + container.querySelector('button[aria-label="Collapse Folders"]'), + ).not.toBeNull(); + expect( + container.querySelector('button[aria-label="Expand Folders"]'), + ).toBeNull(); + + act(() => root.unmount()); + container.remove(); + }); + + it("does not block native Enter activation on the Folders disclosure", async () => { + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + , + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + const disclosure = container.querySelector( + 'button[aria-label="Collapse Folders"]', + ); + expect(disclosure).not.toBeNull(); + disclosure?.focus(); + + const enter = new KeyboardEvent("keydown", { + key: "Enter", + bubbles: true, + cancelable: true, + }); + act(() => { + disclosure?.dispatchEvent(enter); + }); + + expect(enter.defaultPrevented).toBe(false); + + act(() => root.unmount()); + container.remove(); + }); +}); diff --git a/src/components/notes/FolderTreeView.tsx b/src/components/notes/FolderTreeView.tsx index 3f7998a3..a7f00c98 100644 --- a/src/components/notes/FolderTreeView.tsx +++ b/src/components/notes/FolderTreeView.tsx @@ -5,7 +5,7 @@ import { useNotes } from "../../context/NotesContext"; import { buildFolderTree, countNotesInFolder, - getVisibleItems, + getVisibleItemsForFolderSection, type TreeItem, } from "../../lib/folderTree"; import { FolderNameDialog } from "./FolderNameDialog"; @@ -35,7 +35,17 @@ import { ArrowUpIcon, } from "../icons"; import * as notesService from "../../services/notes"; -import type { FolderNode, NoteMetadata, Settings } from "../../types/note"; +import { + SidebarFolderSection, + loadFolderSectionCollapsed, + saveFolderSectionCollapsed, +} from "../layout/SidebarFolderSection"; +import type { + FolderNode, + NoteMetadata, + NoteSortOrder, + Settings, +} from "../../types/note"; const STORAGE_KEY = "scratch:collapsedFolders"; @@ -269,7 +279,7 @@ interface FolderItemProps { onMoveFolderToParent: (path: string, targetParent: string) => void; } -const FolderItemComponent = memo(function FolderItem({ +export const FolderItemComponent = memo(function FolderItem({ folder, depth, collapsedFolders, @@ -474,6 +484,7 @@ const FolderItemComponent = memo(function FolderItem({ }); interface FolderTreeViewProps { + sortOrder: NoteSortOrder; pinnedIds: Set; settings: Settings | null; multiSelectedNoteIds: Set; @@ -483,6 +494,7 @@ interface FolderTreeViewProps { } export function FolderTreeView({ + sortOrder, pinnedIds, settings: _settings, multiSelectedNoteIds, @@ -508,6 +520,9 @@ export function FolderTreeView({ const [collapsedFolders, setCollapsedFolders] = useState>(loadCollapsedFolders); + const [foldersSectionCollapsed, setFoldersSectionCollapsed] = useState( + loadFolderSectionCollapsed, + ); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [folderToDelete, setFolderToDelete] = useState(null); const [renameDialogOpen, setRenameDialogOpen] = useState(false); @@ -534,9 +549,13 @@ export function FolderTreeView({ saveCollapsedFolders(collapsedFolders); }, [collapsedFolders]); + useEffect(() => { + saveFolderSectionCollapsed(foldersSectionCollapsed); + }, [foldersSectionCollapsed]); + const tree = useMemo( - () => buildFolderTree(notes, pinnedIds, knownFolders), - [notes, pinnedIds, knownFolders], + () => buildFolderTree(notes, pinnedIds, knownFolders, sortOrder), + [notes, pinnedIds, knownFolders, sortOrder], ); const handleToggleCollapse = useCallback((path: string) => { @@ -554,6 +573,7 @@ export function FolderTreeView({ // Expand a folder and all its ancestors const expandFolder = useCallback((folderPath: string) => { if (!folderPath) return; + setFoldersSectionCollapsed(false); setCollapsedFolders((prev) => { const next = new Set(prev); // Expand this folder and every ancestor @@ -663,8 +683,14 @@ export function FolderTreeView({ // Flat list of visible items for keyboard navigation const visibleItems = useMemo( - () => getVisibleItems(tree, pinnedIds, collapsedFolders), - [tree, pinnedIds, collapsedFolders], + () => + getVisibleItemsForFolderSection( + tree, + pinnedIds, + collapsedFolders, + foldersSectionCollapsed, + ), + [tree, pinnedIds, collapsedFolders, foldersSectionCollapsed], ); // Visible note IDs in order (for Shift+Click range computation) @@ -774,12 +800,13 @@ export function FolderTreeView({ } if (visibleItems.length === 0) return; - e.preventDefault(); - e.stopPropagation(); - const currentIndex = visibleItems.findIndex( (item) => itemKey(item) === focusedItemKey, ); + if (e.key === "Enter" && currentIndex < 0) return; + + e.preventDefault(); + e.stopPropagation(); if (e.key === "ArrowDown" || e.key === "ArrowUp") { let newIndex: number; @@ -796,7 +823,6 @@ export function FolderTreeView({ selectNote(item.id); } } else if (e.key === "Enter") { - if (currentIndex < 0) return; const item = visibleItems[currentIndex]; if (item.type === "folder") { handleToggleCollapse(item.path); @@ -875,30 +901,37 @@ export function FolderTreeView({ ))} {/* Folders */} - {tree.folders.map((folder) => ( - - ))} + {tree.folders.length > 0 && ( + + {tree.folders.map((folder) => ( + + ))} + + )} {/* Unpinned root notes */} {unpinnedRootNotes.map((note) => ( diff --git a/src/components/notes/NoteList.tsx b/src/components/notes/NoteList.tsx index 3e761f39..4733fe4f 100644 --- a/src/components/notes/NoteList.tsx +++ b/src/components/notes/NoteList.tsx @@ -21,7 +21,8 @@ import { CopyIcon, TrashIcon, } from "../icons"; -import type { Settings } from "../../types/note"; +import type { NoteSortOrder, Settings } from "../../types/note"; +import { orderNoteListItems } from "../../lib/folderTree"; const menuItemClass = "px-3 py-1.5 text-sm text-text cursor-pointer outline-none hover:bg-bg-muted focus:bg-bg-muted flex items-center gap-2 rounded-sm"; @@ -227,6 +228,7 @@ const NoteItemWithMenu = memo(function NoteItemWithMenu({ }); interface NoteListProps { + sortOrder: NoteSortOrder; multiSelectedNoteIds: Set; setMultiSelectedNoteIds: React.Dispatch>>; lastClickedNoteId: string | null; @@ -234,6 +236,7 @@ interface NoteListProps { } export function NoteList({ + sortOrder, multiSelectedNoteIds, setMultiSelectedNoteIds, lastClickedNoteId, @@ -307,6 +310,13 @@ export function NoteList({ return notes; }, [searchQuery, searchResults, notes]); + const isSearching = searchQuery.trim().length > 0; + + const sortedDisplayItems = useMemo( + () => orderNoteListItems(displayItems, sortOrder, pinnedIds, isSearching), + [displayItems, sortOrder, pinnedIds, isSearching], + ); + // Listen for focus request from editor (when Escape is pressed) useEffect(() => { const handleFocusNoteList = () => { @@ -331,8 +341,6 @@ export function NoteList({ }, [openDeleteDialogForNote]); const foldersEnabled = settings?.foldersEnabled === true; - const isSearching = searchQuery.trim().length > 0; - if (isLoading && notes.length === 0) { return (
@@ -341,7 +349,7 @@ export function NoteList({ ); } - if (isSearching && displayItems.length === 0) { + if (isSearching && sortedDisplayItems.length === 0) { return (
No results found @@ -349,7 +357,7 @@ export function NoteList({ ); } - if (displayItems.length === 0) { + if (sortedDisplayItems.length === 0) { return (
No notes yet @@ -362,6 +370,7 @@ export function NoteList({ return ( <> - {displayItems.map((item) => ( + {sortedDisplayItems.map((item) => ( { + const notes = [note("middle", 20), note("oldest", 10), note("newest", 30)]; + + it("sorts newest first without mutating the source list", () => { + const result = sortNotesByModified(notes, "newest"); + + expect(result.map((item) => item.id)).toEqual([ + "newest", + "middle", + "oldest", + ]); + expect(notes.map((item) => item.id)).toEqual([ + "middle", + "oldest", + "newest", + ]); + }); + + it("sorts oldest first with a deterministic filename tie-break", () => { + const result = sortNotesByModified( + [note("z-last", 10), note("a-first", 10), note("newest", 30)], + "oldest", + ); + + expect(result.map((item) => item.id)).toEqual([ + "a-first", + "z-last", + "newest", + ]); + }); + + it("keeps pinned notes before unpinned notes in the flat list", () => { + const result = sortNotesByModified( + [note("newest", 30), note("pinned-oldest", 10), note("middle", 20)], + "newest", + new Set(["pinned-oldest"]), + ); + + expect(result.map((item) => item.id)).toEqual([ + "pinned-oldest", + "newest", + "middle", + ]); + }); +}); + +describe("orderNoteListItems", () => { + it("preserves backend relevance order while searching", () => { + const relevanceOrder = [ + note("best-match-but-old", 10), + note("pinned-newer-match", 30), + note("third-match", 20), + ]; + + const result = orderNoteListItems( + relevanceOrder, + "newest", + new Set(["pinned-newer-match"]), + true, + ); + + expect(result.map((item) => item.id)).toEqual([ + "best-match-but-old", + "pinned-newer-match", + "third-match", + ]); + expect(relevanceOrder.map((item) => item.id)).toEqual([ + "best-match-but-old", + "pinned-newer-match", + "third-match", + ]); + }); +}); + +describe("buildFolderTree note order", () => { + it("applies oldest-first ordering inside folders while keeping pinned notes first", () => { + const tree = buildFolderTree( + [ + note("docs/newest", 30), + note("docs/pinned", 20), + note("docs/oldest", 10), + ], + new Set(["docs/pinned"]), + ["docs"], + "oldest", + ); + + expect(tree.folders[0]?.notes.map((item) => item.id)).toEqual([ + "docs/pinned", + "docs/oldest", + "docs/newest", + ]); + }); +}); + +describe("getVisibleItemsForFolderSection", () => { + it("hides every folder item while keeping root notes keyboard-accessible", () => { + const pinnedIds = new Set(["pinned"]); + const tree = buildFolderTree( + [ + note("pinned", 30), + note("docs/inside", 20), + note("recent", 10), + ], + pinnedIds, + ["docs"], + ); + + expect( + getVisibleItemsForFolderSection(tree, pinnedIds, new Set(), false), + ).toEqual([ + { type: "note", id: "pinned" }, + { type: "folder", path: "docs" }, + { type: "note", id: "docs/inside" }, + { type: "note", id: "recent" }, + ]); + expect( + getVisibleItemsForFolderSection(tree, pinnedIds, new Set(), true), + ).toEqual([ + { type: "note", id: "pinned" }, + { type: "note", id: "recent" }, + ]); + }); +}); diff --git a/src/lib/folderTree.ts b/src/lib/folderTree.ts index a4a2d48d..660872fc 100644 --- a/src/lib/folderTree.ts +++ b/src/lib/folderTree.ts @@ -1,14 +1,62 @@ -import type { NoteMetadata, FolderNode } from "../types/note"; +import type { + NoteMetadata, + FolderNode, + NoteSortOrder, +} from "../types/note"; export interface FolderTreeData { rootNotes: NoteMetadata[]; folders: FolderNode[]; } +function compareNotesByModified( + first: Pick, + second: Pick, + sortOrder: NoteSortOrder, +): number { + const modifiedDifference = + sortOrder === "oldest" + ? first.modified - second.modified + : second.modified - first.modified; + + return modifiedDifference || first.id.localeCompare(second.id); +} + +export function sortNotesByModified< + T extends Pick, +>( + notes: readonly T[], + sortOrder: NoteSortOrder, + pinnedIds: ReadonlySet = new Set(), +): T[] { + return [...notes].sort((first, second) => { + const firstPinned = pinnedIds.has(first.id); + const secondPinned = pinnedIds.has(second.id); + if (firstPinned !== secondPinned) return firstPinned ? -1 : 1; + return compareNotesByModified(first, second, sortOrder); + }); +} + +export function orderNoteListItems< + T extends Pick, +>( + notes: readonly T[], + sortOrder: NoteSortOrder, + pinnedIds: ReadonlySet, + isSearching: boolean, +): T[] { + // Search service returns relevance-ranked results. Date and pin sorting is + // only a browsing preference and must not overwrite that ranking. + return isSearching + ? [...notes] + : sortNotesByModified(notes, sortOrder, pinnedIds); +} + export function buildFolderTree( notes: NoteMetadata[], pinnedIds: Set, knownFolders?: string[], + sortOrder: NoteSortOrder = "newest", ): FolderTreeData { const rootNotes: NoteMetadata[] = []; const folderMap = new Map(); @@ -51,14 +99,16 @@ export function buildFolderTree( } } + const compareFolderNotes = (first: NoteMetadata, second: NoteMetadata) => { + const firstPinned = pinnedIds.has(first.id); + const secondPinned = pinnedIds.has(second.id); + if (firstPinned !== secondPinned) return firstPinned ? -1 : 1; + return compareNotesByModified(first, second, sortOrder); + }; + function sortNode(node: FolderNode) { node.children.sort((a, b) => a.name.localeCompare(b.name)); - node.notes.sort((a, b) => { - const ap = pinnedIds.has(a.id); - const bp = pinnedIds.has(b.id); - if (ap !== bp) return ap ? -1 : 1; - return b.modified - a.modified; - }); + node.notes.sort(compareFolderNotes); node.children.forEach(sortNode); } @@ -68,13 +118,8 @@ export function buildFolderTree( topLevelFolders.sort((a, b) => a.name.localeCompare(b.name)); topLevelFolders.forEach(sortNode); - // Sort root notes: pinned first, then by modified desc - rootNotes.sort((a, b) => { - const ap = pinnedIds.has(a.id); - const bp = pinnedIds.has(b.id); - if (ap !== bp) return ap ? -1 : 1; - return b.modified - a.modified; - }); + // Keep pinned notes first, then apply the selected date order. + rootNotes.sort(compareFolderNotes); return { rootNotes, folders: topLevelFolders }; } @@ -124,6 +169,20 @@ export function getVisibleItems( return items; } +/** Hide the folder branch from keyboard navigation when its section is closed. */ +export function getVisibleItemsForFolderSection( + tree: FolderTreeData, + pinnedIds: Set, + collapsedFolders: Set, + foldersSectionCollapsed: boolean, +): TreeItem[] { + return getVisibleItems( + foldersSectionCollapsed ? { ...tree, folders: [] } : tree, + pinnedIds, + collapsedFolders, + ); +} + export function countNotesInFolder(folder: FolderNode): number { let count = folder.notes.length; for (const child of folder.children) { diff --git a/src/services/notes.ts b/src/services/notes.ts index c4632627..74f294fa 100644 --- a/src/services/notes.ts +++ b/src/services/notes.ts @@ -72,6 +72,10 @@ export async function updateSettings(settings: Settings): Promise { return invoke("update_settings", { newSettings: settings }); } +export async function updateSidebarSortOrder(order: string): Promise { + return invoke("update_sidebar_sort_order", { order }); +} + export async function updateGitEnabled( enabled: boolean, expectedFolder: string, diff --git a/src/test/setup.ts b/src/test/setup.ts new file mode 100644 index 00000000..910384d4 --- /dev/null +++ b/src/test/setup.ts @@ -0,0 +1,2 @@ +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }) + .IS_REACT_ACT_ENVIRONMENT = true; diff --git a/src/types/note.ts b/src/types/note.ts index 387a9b61..366e1c49 100644 --- a/src/types/note.ts +++ b/src/types/note.ts @@ -20,6 +20,7 @@ export interface ThemeSettings { export type FontFamily = "system-sans" | "serif" | "monospace"; export type TextDirection = "auto" | "ltr" | "rtl"; export type EditorWidth = "narrow" | "normal" | "wide" | "full" | "custom"; +export type NoteSortOrder = "newest" | "oldest"; export interface EditorFontSettings { baseFontFamily?: FontFamily; @@ -49,6 +50,7 @@ export interface Settings { editorFont?: EditorFontSettings; gitEnabled?: boolean; foldersEnabled?: boolean; + sidebarSortOrder?: NoteSortOrder; pinnedNoteIds?: string[]; textDirection?: TextDirection; editorWidth?: EditorWidth; diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 00000000..29b3e0d8 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,11 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [react()], + test: { + environment: "happy-dom", + include: ["src/**/*.test.{ts,tsx}"], + setupFiles: ["./src/test/setup.ts"], + }, +});