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/capabilities/default.json b/src-tauri/capabilities/default.json index 715b6b03..6f8e840e 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -2,7 +2,7 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "Capability for all app windows", - "windows": ["main", "preview-*"], + "windows": ["main", "preview-*", "preferences"], "permissions": [ "core:default", "core:window:allow-close", diff --git a/src-tauri/src/draft_checkpoint.rs b/src-tauri/src/draft_checkpoint.rs new file mode 100644 index 00000000..527a6bd2 --- /dev/null +++ b/src-tauri/src/draft_checkpoint.rs @@ -0,0 +1,495 @@ +use serde::{Deserialize, Serialize}; +use std::fmt; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Duration, SystemTime}; + +pub const CHECKPOINT_DIRECTORY_NAME: &str = "draft-checkpoints"; +const CHECKPOINT_RETENTION: Duration = Duration::from_secs(30 * 24 * 60 * 60); + +static NEXT_TEMPORARY_FILE: AtomicU64 = AtomicU64::new(0); + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DraftCheckpointKey { + pub window_label: String, + pub note_id: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DraftCheckpointMetadata { + pub source_path: String, + pub base_revision: Option, + pub updated_at: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DraftCheckpoint { + pub key: DraftCheckpointKey, + pub markdown: String, + pub metadata: DraftCheckpointMetadata, +} + +#[derive(Debug)] +pub enum DraftCheckpointError { + Io(io::Error), + Json(serde_json::Error), + IdentityMismatch { path: PathBuf }, +} + +impl fmt::Display for DraftCheckpointError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io(error) => write!(formatter, "draft checkpoint I/O failed: {error}"), + Self::Json(error) => write!(formatter, "draft checkpoint JSON is invalid: {error}"), + Self::IdentityMismatch { path } => write!( + formatter, + "draft checkpoint identity does not match its hashed file name: {}", + path.display() + ), + } + } +} + +impl std::error::Error for DraftCheckpointError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Io(error) => Some(error), + Self::Json(error) => Some(error), + Self::IdentityMismatch { .. } => None, + } + } +} + +impl From for DraftCheckpointError { + fn from(error: io::Error) -> Self { + Self::Io(error) + } +} + +impl From for DraftCheckpointError { + fn from(error: serde_json::Error) -> Self { + Self::Json(error) + } +} + +/// Atomically creates or replaces only the checkpoint identified by `checkpoint.key`. +/// All filesystem writes remain below the supplied application-data directory. +pub fn write_checkpoint( + app_data_directory: impl AsRef, + checkpoint: &DraftCheckpoint, +) -> Result<(), DraftCheckpointError> { + let checkpoint_directory = checkpoint_directory(app_data_directory.as_ref()); + fs::create_dir_all(&checkpoint_directory)?; + let path = checkpoint_path(&checkpoint_directory, &checkpoint.key); + let serialized = serde_json::to_vec(checkpoint)?; + atomic_write(&path, &serialized)?; + Ok(()) +} + +pub fn read_checkpoint( + app_data_directory: impl AsRef, + key: &DraftCheckpointKey, +) -> Result, DraftCheckpointError> { + read_checkpoint_at(app_data_directory.as_ref(), key, SystemTime::now()) +} + +fn read_checkpoint_at( + app_data_directory: &Path, + key: &DraftCheckpointKey, + now: SystemTime, +) -> Result, DraftCheckpointError> { + prune_expired_checkpoints_at(app_data_directory, now); + let checkpoint_directory = checkpoint_directory(app_data_directory); + let path = checkpoint_path(&checkpoint_directory, key); + let bytes = match fs::read(&path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error.into()), + }; + let checkpoint: DraftCheckpoint = serde_json::from_slice(&bytes)?; + ensure_identity_matches(&path, &checkpoint)?; + Ok(Some(checkpoint)) +} + +/// Lists checkpoints newest first. Equal timestamps use identity ordering for stability. +pub fn list_checkpoints( + app_data_directory: impl AsRef, +) -> Result, DraftCheckpointError> { + let app_data_directory = app_data_directory.as_ref(); + prune_expired_checkpoints_at(app_data_directory, SystemTime::now()); + let checkpoint_directory = checkpoint_directory(app_data_directory); + let entries = match fs::read_dir(&checkpoint_directory) { + Ok(entries) => entries, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => return Err(error.into()), + }; + let mut checkpoints = Vec::new(); + + for entry in entries { + let path = match entry { + Ok(entry) => entry.path(), + Err(_) => continue, + }; + if path.extension().and_then(|extension| extension.to_str()) != Some("json") { + continue; + } + let bytes = match fs::read(&path) { + Ok(bytes) => bytes, + Err(_) => continue, + }; + let checkpoint: DraftCheckpoint = match serde_json::from_slice(&bytes) { + Ok(checkpoint) => checkpoint, + Err(_) => continue, + }; + if ensure_identity_matches(&path, &checkpoint).is_err() { + continue; + } + checkpoints.push(checkpoint); + } + + checkpoints.sort_by(|left, right| { + right + .metadata + .updated_at + .cmp(&left.metadata.updated_at) + .then_with(|| left.key.window_label.cmp(&right.key.window_label)) + .then_with(|| left.key.note_id.cmp(&right.key.note_id)) + }); + Ok(checkpoints) +} + +fn prune_expired_checkpoints_at(app_data_directory: &Path, now: SystemTime) { + if let Some(cutoff) = now.checked_sub(CHECKPOINT_RETENTION) { + let _ = prune_checkpoints_older_than(app_data_directory, cutoff); + } +} + +pub fn prune_checkpoints_older_than( + app_data_directory: impl AsRef, + cutoff: SystemTime, +) -> Result { + let checkpoint_directory = checkpoint_directory(app_data_directory.as_ref()); + let entries = match fs::read_dir(&checkpoint_directory) { + Ok(entries) => entries, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(0), + Err(error) => return Err(error.into()), + }; + let mut removed = 0; + + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|extension| extension.to_str()) != Some("json") { + continue; + } + let is_expired = entry + .metadata() + .and_then(|metadata| metadata.modified()) + .map(|modified| modified < cutoff) + .unwrap_or(false); + if is_expired && fs::remove_file(path).is_ok() { + removed += 1; + } + } + + if removed > 0 { + sync_parent_directory(&checkpoint_directory)?; + } + Ok(removed) +} + +/// Idempotently removes only the selected checkpoint. +pub fn clear_checkpoint( + app_data_directory: impl AsRef, + key: &DraftCheckpointKey, +) -> Result<(), DraftCheckpointError> { + let checkpoint_directory = checkpoint_directory(app_data_directory.as_ref()); + let path = checkpoint_path(&checkpoint_directory, key); + match fs::remove_file(path) { + Ok(()) => { + sync_parent_directory(&checkpoint_directory)?; + Ok(()) + } + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error.into()), + } +} + +fn checkpoint_directory(app_data_directory: &Path) -> PathBuf { + app_data_directory.join(CHECKPOINT_DIRECTORY_NAME) +} + +fn checkpoint_path(directory: &Path, key: &DraftCheckpointKey) -> PathBuf { + directory.join(checkpoint_file_name(key)) +} + +fn checkpoint_file_name(key: &DraftCheckpointKey) -> String { + let mut identity = Vec::with_capacity(key.window_label.len() + key.note_id.len() + 16); + identity.extend_from_slice(&(key.window_label.len() as u64).to_be_bytes()); + identity.extend_from_slice(key.window_label.as_bytes()); + identity.extend_from_slice(&(key.note_id.len() as u64).to_be_bytes()); + identity.extend_from_slice(key.note_id.as_bytes()); + format!("{}.json", crate::sha256::hex_digest(&identity)) +} + +fn ensure_identity_matches( + path: &Path, + checkpoint: &DraftCheckpoint, +) -> Result<(), DraftCheckpointError> { + let actual = path.file_name().and_then(|name| name.to_str()); + if actual == Some(&checkpoint_file_name(&checkpoint.key)) { + return Ok(()); + } + Err(DraftCheckpointError::IdentityMismatch { + path: path.to_path_buf(), + }) +} + +fn atomic_write(path: &Path, bytes: &[u8]) -> io::Result<()> { + let parent = path.parent().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "draft checkpoint target has no parent directory", + ) + })?; + let (mut temporary_file, mut temporary_path) = create_temporary_file(path, parent)?; + temporary_file.write_all(bytes)?; + temporary_file.flush()?; + temporary_file.sync_all()?; + drop(temporary_file); + + replace_file(temporary_path.path(), path)?; + temporary_path.commit(); + sync_parent_directory(parent) +} + +#[cfg(not(windows))] +fn replace_file(source: &Path, destination: &Path) -> io::Result<()> { + fs::rename(source, destination) +} + +#[cfg(windows)] +fn replace_file(source: &Path, destination: &Path) -> io::Result<()> { + use std::iter; + use std::os::windows::ffi::OsStrExt; + + const MOVEFILE_REPLACE_EXISTING: u32 = 0x1; + const MOVEFILE_WRITE_THROUGH: u32 = 0x8; + + #[link(name = "kernel32")] + extern "system" { + fn MoveFileExW( + existing_file_name: *const u16, + new_file_name: *const u16, + flags: u32, + ) -> i32; + } + + let source = source + .as_os_str() + .encode_wide() + .chain(iter::once(0)) + .collect::>(); + let destination = destination + .as_os_str() + .encode_wide() + .chain(iter::once(0)) + .collect::>(); + let result = unsafe { + MoveFileExW( + source.as_ptr(), + destination.as_ptr(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, + ) + }; + if result == 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) +} + +fn create_temporary_file(path: &Path, parent: &Path) -> io::Result<(File, TemporaryPath)> { + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("checkpoint.json"); + loop { + let sequence = NEXT_TEMPORARY_FILE.fetch_add(1, Ordering::Relaxed); + let temporary_path = parent.join(format!( + ".{file_name}.scratch-checkpoint-{}-{sequence}.tmp", + std::process::id() + )); + match OpenOptions::new() + .write(true) + .create_new(true) + .open(&temporary_path) + { + Ok(file) => return Ok((file, TemporaryPath::new(temporary_path))), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, + Err(error) => return Err(error), + } + } +} + +struct TemporaryPath { + path: PathBuf, + committed: bool, +} + +impl TemporaryPath { + fn new(path: PathBuf) -> Self { + Self { + path, + committed: false, + } + } + + fn path(&self) -> &Path { + &self.path + } + + fn commit(&mut self) { + self.committed = true; + } +} + +impl Drop for TemporaryPath { + fn drop(&mut self) { + if !self.committed { + let _ = fs::remove_file(&self.path); + } + } +} + +#[cfg(unix)] +fn sync_parent_directory(parent: &Path) -> io::Result<()> { + File::open(parent)?.sync_all() +} + +#[cfg(not(unix))] +fn sync_parent_directory(_parent: &Path) -> io::Result<()> { + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{ + checkpoint_directory, checkpoint_path, list_checkpoints, prune_checkpoints_older_than, + read_checkpoint, read_checkpoint_at, write_checkpoint, DraftCheckpoint, DraftCheckpointKey, + DraftCheckpointMetadata, CHECKPOINT_RETENTION, + }; + use std::fs; + use std::path::PathBuf; + use std::time::{Duration, SystemTime, UNIX_EPOCH}; + + struct TestDirectory(PathBuf); + + impl TestDirectory { + fn new() -> Self { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after Unix epoch") + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "scratch-checkpoint-retention-{}-{nonce}", + std::process::id(), + )); + fs::create_dir_all(&path).expect("create checkpoint test directory"); + Self(path) + } + } + + impl Drop for TestDirectory { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + fn checkpoint() -> DraftCheckpoint { + DraftCheckpoint { + key: DraftCheckpointKey { + window_label: "preview-note".to_string(), + note_id: "/note.md".to_string(), + }, + markdown: "# Draft".to_string(), + metadata: DraftCheckpointMetadata { + source_path: "/note.md".to_string(), + base_revision: Some("revision".to_string()), + updated_at: "2026-08-04T12:00:00.000Z".to_string(), + }, + } + } + + #[test] + fn retention_prunes_expired_checkpoints_and_keeps_recent_ones() { + let directory = TestDirectory::new(); + let checkpoint = checkpoint(); + write_checkpoint(&directory.0, &checkpoint).expect("write checkpoint"); + + let old_cutoff = UNIX_EPOCH + Duration::from_secs(1); + assert_eq!( + prune_checkpoints_older_than(&directory.0, old_cutoff).unwrap(), + 0, + ); + assert!(read_checkpoint(&directory.0, &checkpoint.key) + .unwrap() + .is_some()); + + let future_cutoff = SystemTime::now() + Duration::from_secs(1); + assert_eq!( + prune_checkpoints_older_than(&directory.0, future_cutoff).unwrap(), + 1, + ); + assert!(read_checkpoint(&directory.0, &checkpoint.key) + .unwrap() + .is_none()); + } + + #[test] + fn reading_a_checkpoint_runs_the_retention_sweep() { + let directory = TestDirectory::new(); + let checkpoint = checkpoint(); + write_checkpoint(&directory.0, &checkpoint).expect("write checkpoint"); + + let after_retention = SystemTime::now() + CHECKPOINT_RETENTION + Duration::from_secs(1); + + assert!( + read_checkpoint_at(&directory.0, &checkpoint.key, after_retention) + .unwrap() + .is_none() + ); + assert!(list_checkpoints(&directory.0).unwrap().is_empty()); + } + + #[test] + fn listing_skips_identity_mismatches_after_valid_deserialization() { + let directory = TestDirectory::new(); + let original = checkpoint(); + write_checkpoint(&directory.0, &original).expect("write checkpoint"); + let path = checkpoint_path(&checkpoint_directory(&directory.0), &original.key); + let mut mismatched = original; + mismatched.key.note_id = "/different.md".to_string(); + fs::write(&path, serde_json::to_vec(&mismatched).unwrap()) + .expect("write mismatched checkpoint"); + + assert!(list_checkpoints(&directory.0).unwrap().is_empty()); + } + + #[test] + fn corrupt_checkpoint_does_not_hide_a_valid_checkpoint() { + let directory = TestDirectory::new(); + let valid = checkpoint(); + write_checkpoint(&directory.0, &valid).expect("write valid checkpoint"); + fs::write( + checkpoint_directory(&directory.0).join("corrupt.json"), + b"not-json", + ) + .expect("write corrupt checkpoint"); + + assert_eq!(list_checkpoints(&directory.0).unwrap(), vec![valid]); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 72b6d15d..90c7c2cd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4,6 +4,7 @@ use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher}; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex, RwLock}; use std::time::{Duration, Instant}; use tantivy::collector::TopDocs; @@ -11,12 +12,17 @@ use tantivy::query::QueryParser; use tantivy::schema::*; use tantivy::{doc, Index, IndexReader, IndexWriter, ReloadPolicy}; use tauri::{AppHandle, Emitter, Manager, State, WebviewUrl}; -use tauri::webview::WebviewWindowBuilder; +use tauri::webview::{WebviewWindow, WebviewWindowBuilder}; use tauri_plugin_clipboard_manager::ClipboardExt; use tokio::fs; use tokio::io::AsyncWriteExt; +mod draft_checkpoint; mod git; +mod persistence; +mod sha256; + +static RECOVERY_SNAPSHOT_SEQUENCE: AtomicU64 = AtomicU64::new(1); // Note metadata for list display #[derive(Debug, Clone, Serialize, Deserialize)] @@ -44,6 +50,13 @@ pub struct Note { pub modified: i64, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct NoteConflictSnapshot { + pub content: String, + pub revision: String, +} + // Theme color customization #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] @@ -122,6 +135,14 @@ pub struct Settings { pub interface_zoom: Option, #[serde(rename = "customEditorWidthPx")] pub custom_editor_width_px: Option, + #[serde(rename = "editorWidthResizeEnabled")] + pub editor_width_resize_enabled: Option, + #[serde(rename = "editorToolbarVisible")] + pub editor_toolbar_visible: Option, + #[serde(rename = "titleBarModifiedDateVisible")] + pub title_bar_modified_date_visible: Option, + #[serde(rename = "titleBarFilenameVisible")] + pub title_bar_filename_visible: Option, /// Custom sidebar width in px; `None` means the default width is used. #[serde(rename = "sidebarWidthPx")] pub sidebar_width_px: Option, @@ -129,6 +150,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")] @@ -736,16 +759,24 @@ fn get_search_index_path(app: &AppHandle) -> Result { fn load_app_config(app: &AppHandle) -> AppConfig { let path = match get_app_config_path(app) { Ok(p) => p, - Err(_) => return AppConfig::default(), + Err(error) => { + eprintln!("app config path resolution failed: {error}"); + return AppConfig::default(); + } }; - if path.exists() { - std::fs::read_to_string(&path) - .ok() - .and_then(|content| serde_json::from_str(&content).ok()) - .unwrap_or_default() - } else { - AppConfig::default() + match std::fs::read_to_string(&path) { + Ok(content) => match serde_json::from_str(&content) { + Ok(config) => config, + Err(error) => { + eprintln!("app config deserialization failed: {error}"); + AppConfig::default() + } + }, + Err(error) => { + eprintln!("app config read failed: {error}"); + AppConfig::default() + } } } @@ -761,13 +792,21 @@ fn save_app_config(app: &AppHandle, config: &AppConfig) -> Result<()> { fn load_settings(notes_folder: &str) -> Settings { let path = get_settings_path(notes_folder); - if path.exists() { - std::fs::read_to_string(&path) - .ok() - .and_then(|content| serde_json::from_str(&content).ok()) - .unwrap_or_default() - } else { - Settings::default() + match std::fs::read_to_string(&path) { + Ok(content) => match serde_json::from_str(&content) { + Ok(settings) => settings, + Err(error) => { + eprintln!( + "settings deserialization failed for {}: {error}", + path.display() + ); + Settings::default() + } + }, + Err(error) => { + eprintln!("settings read failed for {}: {error}", path.display()); + Settings::default() + } } } @@ -1767,15 +1806,31 @@ fn update_settings( Ok(()) } +fn persist_git_enabled( + notes_folder: &str, + settings: &mut Settings, + enabled: Option, +) -> Result<(), String> { + let mut updated = settings.clone(); + updated.git_enabled = enabled; + save_settings(notes_folder, &updated).map_err(|error| error.to_string())?; + *settings = updated; + Ok(()) +} + #[tauri::command] fn update_git_enabled( enabled: Option, expected_folder: String, + app: AppHandle, state: State, ) -> Result<(), String> { let folder = { let app_config = state.app_config.read().expect("app_config read lock"); - let folder = app_config.notes_folder.clone().ok_or("Notes folder not set")?; + let folder = app_config + .notes_folder + .clone() + .ok_or("Notes folder not set")?; if folder != expected_folder { return Err("Notes folder changed".to_string()); @@ -1784,13 +1839,19 @@ fn update_git_enabled( folder }; - { - let mut settings = state.settings.write().expect("settings write lock"); - settings.git_enabled = enabled; - } + let mut settings = state.settings.write().expect("settings write lock"); + persist_git_enabled(&folder, &mut settings, enabled)?; + drop(settings); - let settings = state.settings.read().expect("settings read lock"); - save_settings(&folder, &settings).map_err(|e| e.to_string())?; + if let Err(error) = app.emit( + "settings-changed", + serde_json::json!({ + "notesFolder": folder, + "gitEnabled": enabled, + }), + ) { + eprintln!("Failed to broadcast settings change: {error}"); + } Ok(()) } @@ -1818,12 +1879,84 @@ fn preview_note_name(template: String) -> Result { } // Preview mode: file content returned by read_file_direct / save_file_direct -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct FileContent { pub path: String, pub content: String, pub title: String, pub modified: i64, + pub revision: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "status", rename_all = "camelCase")] +pub enum FileSaveResult { + Saved { + file: FileContent, + }, + Conflict { + current: Option, + }, +} + +fn note_conflict_snapshot( + snapshot: Option, +) -> Option { + snapshot.map(|snapshot| NoteConflictSnapshot { + content: snapshot.content, + revision: snapshot.revision.to_string(), + }) +} + +fn read_file_content_from_path(path: &Path) -> Result { + let snapshot = persistence::read_snapshot(path) + .map_err(|error| error.to_string())? + .ok_or_else(|| "File not found".to_string())?; + let metadata = std::fs::metadata(path).map_err(|error| error.to_string())?; + let modified = metadata + .modified() + .ok() + .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|duration| duration.as_secs() as i64) + .unwrap_or(0); + + Ok(FileContent { + path: path.to_string_lossy().into_owned(), + title: extract_title(&snapshot.content), + content: snapshot.content, + modified, + revision: snapshot.revision.to_string(), + }) +} + +fn save_file_content_to_path( + path: &Path, + content: String, + expected_revision: String, +) -> Result { + let expected = persistence::ContentRevision::from_hex(&expected_revision)?; + + match persistence::save_if_revision(path, &content, Some(&expected)) + .map_err(|error| error.to_string())? + { + persistence::SaveResult::Saved { .. } => Ok(FileSaveResult::Saved { + file: read_file_content_from_path(path)?, + }), + persistence::SaveResult::Conflict { current } => Ok(FileSaveResult::Conflict { + current: note_conflict_snapshot(current), + }), + } +} + +fn recreate_file_content_to_path(path: &Path, content: String) -> Result { + match persistence::save_if_revision(path, &content, None).map_err(|error| error.to_string())? { + persistence::SaveResult::Saved { .. } => Ok(FileSaveResult::Saved { + file: read_file_content_from_path(path)?, + }), + persistence::SaveResult::Conflict { current } => Ok(FileSaveResult::Conflict { + current: note_conflict_snapshot(current), + }), + } } /// Validate a file path for preview mode direct file operations. @@ -1841,10 +1974,55 @@ fn validate_preview_path(path: &str) -> Result { let canonical = file_path .canonicalize() .map_err(|e| format!("Cannot resolve file path: {}", e))?; + if !is_markdown_extension(&canonical) { + return Err("Resolved file must be Markdown".to_string()); + } Ok(canonical) } +/// Validate a missing direct-file target through its existing canonical parent. +/// The final component remains unresolved so create-only persistence can detect +/// a concurrent recreation without ever replacing it. +fn validate_preview_create_path(path: &str) -> Result { + let file_path = PathBuf::from(path); + if !file_path.is_absolute() { + return Err("Standalone recreation requires an absolute path".to_string()); + } + if file_path.components().any(|component| { + matches!( + component, + std::path::Component::ParentDir | std::path::Component::CurDir + ) + }) { + return Err("Path traversal is not allowed".to_string()); + } + match file_path + .extension() + .and_then(|extension| extension.to_str()) + { + Some(extension) + if extension.eq_ignore_ascii_case("md") + || extension.eq_ignore_ascii_case("markdown") => {} + _ => return Err("Only .md and .markdown files are allowed".to_string()), + } + + let file_name = file_path + .file_name() + .ok_or_else(|| "Standalone recreation target has no file name".to_string())?; + let parent = file_path + .parent() + .ok_or_else(|| "Standalone recreation target has no parent".to_string())?; + let canonical_parent = parent + .canonicalize() + .map_err(|error| format!("Cannot resolve parent directory: {error}"))?; + if !canonical_parent.is_dir() { + return Err(format!("Not a directory: {}", parent.display())); + } + + Ok(canonical_parent.join(file_name)) +} + #[tauri::command] async fn read_file_direct(path: String) -> Result { let canonical = validate_preview_path(&path)?; @@ -1853,32 +2031,17 @@ async fn read_file_direct(path: String) -> Result { return Err(format!("Not a file: {}", path)); } - let content = fs::read_to_string(&canonical) - .await - .map_err(|_| "Failed to read file".to_string())?; - let metadata = fs::metadata(&canonical) + tauri::async_runtime::spawn_blocking(move || read_file_content_from_path(&canonical)) .await - .map_err(|_| "Failed to read metadata".to_string())?; - - let modified = metadata - .modified() - .ok() - .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|d| d.as_secs() as i64) - .unwrap_or(0); - - let title = extract_title(&content); - - Ok(FileContent { - path, - content, - title, - modified, - }) + .map_err(|error| format!("Direct file read task failed: {error}"))? } #[tauri::command] -async fn save_file_direct(path: String, content: String) -> Result { +async fn save_file_direct( + path: String, + content: String, + expected_revision: String, +) -> Result { // For save, the file must already exist (we validate extension + path security) let canonical = validate_preview_path(&path)?; @@ -1886,28 +2049,186 @@ async fn save_file_direct(path: String, content: String) -> Result Result { + let create_path = validate_preview_create_path(&path)?; + + tauri::async_runtime::spawn_blocking(move || { + recreate_file_content_to_path(&create_path, content) + }) + .await + .map_err(|error| format!("Direct file recreation task failed: {error}"))? +} + +fn write_recovery_snapshot( + recovery_root: &Path, + note_id: &str, + source_path: &str, + content: &str, + reason: &str, +) -> Result { + let recovery_directory = recovery_root.join("recovery"); + std::fs::create_dir_all(&recovery_directory).map_err(|error| error.to_string())?; + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_millis()) .unwrap_or(0); + let sequence = RECOVERY_SNAPSHOT_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let safe_note_id = sanitize_filename(¬e_id.replace('/', "-")); + let safe_reason = sanitize_filename(reason); + let stem = format!("{timestamp}-{sequence}-{safe_reason}-{safe_note_id}"); + let recovery_path = recovery_directory.join(format!("{stem}.md")); + + match persistence::save_if_revision(&recovery_path, content, None) + .map_err(|error| error.to_string())? + { + persistence::SaveResult::Saved { .. } => {} + persistence::SaveResult::Conflict { .. } => { + return Err("Recovery snapshot path unexpectedly already exists".to_string()); + } + } - let title = extract_title(&content); + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct RecoveryMetadata<'a> { + note_id: &'a str, + source_path: &'a str, + reason: &'a str, + created_at_ms: u128, + } - Ok(FileContent { - path, - content, - title, - modified, + let metadata = RecoveryMetadata { + note_id, + source_path, + reason, + created_at_ms: timestamp, + }; + if let Ok(metadata_content) = serde_json::to_string_pretty(&metadata) { + let metadata_path = recovery_directory.join(format!("{stem}.json")); + let _ = persistence::save_if_revision(&metadata_path, &metadata_content, None); + } + + Ok(recovery_path) +} + +#[tauri::command] +async fn persist_recovery_snapshot( + app: AppHandle, + note_id: String, + source_path: String, + content: String, + reason: String, +) -> Result { + let recovery_root = app + .path() + .app_data_dir() + .map_err(|error| error.to_string())?; + tauri::async_runtime::spawn_blocking(move || { + write_recovery_snapshot(&recovery_root, ¬e_id, &source_path, &content, &reason) + .map(|path| path.to_string_lossy().into_owned()) }) + .await + .map_err(|error| format!("Recovery snapshot task failed: {error}"))? +} + +#[tauri::command] +async fn write_draft_checkpoint( + app: AppHandle, + window: WebviewWindow, + note_id: String, + markdown: String, + metadata: draft_checkpoint::DraftCheckpointMetadata, +) -> Result<(), String> { + let app_data = app + .path() + .app_data_dir() + .map_err(|error| error.to_string())?; + let checkpoint = draft_checkpoint::DraftCheckpoint { + key: draft_checkpoint::DraftCheckpointKey { + window_label: window.label().to_string(), + note_id, + }, + markdown, + metadata, + }; + tauri::async_runtime::spawn_blocking(move || { + draft_checkpoint::write_checkpoint(app_data, &checkpoint).map_err(|error| error.to_string()) + }) + .await + .map_err(|error| format!("Draft checkpoint task failed: {error}"))? +} + +#[tauri::command] +async fn get_draft_checkpoint( + app: AppHandle, + window: WebviewWindow, + note_id: String, +) -> Result, String> { + let app_data = app + .path() + .app_data_dir() + .map_err(|error| error.to_string())?; + let key = draft_checkpoint::DraftCheckpointKey { + window_label: window.label().to_string(), + note_id, + }; + tauri::async_runtime::spawn_blocking(move || { + draft_checkpoint::read_checkpoint(app_data, &key).map_err(|error| error.to_string()) + }) + .await + .map_err(|error| format!("Draft checkpoint read task failed: {error}"))? +} + +#[tauri::command] +async fn clear_draft_checkpoint( + app: AppHandle, + window: WebviewWindow, + note_id: String, +) -> Result<(), String> { + let app_data = app + .path() + .app_data_dir() + .map_err(|error| error.to_string())?; + let key = draft_checkpoint::DraftCheckpointKey { + window_label: window.label().to_string(), + note_id, + }; + tauri::async_runtime::spawn_blocking(move || { + draft_checkpoint::clear_checkpoint(app_data, &key).map_err(|error| error.to_string()) + }) + .await + .map_err(|error| format!("Draft checkpoint clear task failed: {error}"))? +} + +#[tauri::command] +async fn list_draft_checkpoints( + app: AppHandle, + window: WebviewWindow, +) -> Result, String> { + let app_data = app + .path() + .app_data_dir() + .map_err(|error| error.to_string())?; + let window_label = window.label().to_string(); + tauri::async_runtime::spawn_blocking(move || { + draft_checkpoint::list_checkpoints(app_data) + .map(|checkpoints| { + checkpoints + .into_iter() + .filter(|checkpoint| checkpoint.key.window_label == window_label) + .collect() + }) + .map_err(|error| error.to_string()) + }) + .await + .map_err(|error| format!("Draft checkpoint list task failed: {error}"))? } #[tauri::command] @@ -3558,6 +3879,153 @@ fn is_markdown_extension(path: &Path) -> bool { .unwrap_or(false) } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct NativePreferencesMenuSpec { + id: &'static str, + label: &'static str, + accelerator: &'static str, +} + +fn native_preferences_menu_spec() -> NativePreferencesMenuSpec { + NativePreferencesMenuSpec { + id: "preferences", + label: "Preferences…", + accelerator: "CmdOrCtrl+,", + } +} + +fn preferences_submenu_target(os: &str, application_name: &str) -> (String, usize) { + match os { + "macos" => (application_name.to_string(), 1), + "windows" => ("File".to_string(), 0), + "linux" | "dragonfly" | "freebsd" | "netbsd" | "openbsd" => ("Help".to_string(), 0), + _ => ("File".to_string(), 0), + } +} + +fn build_application_menu(app_handle: &AppHandle) -> tauri::Result> { + use tauri::menu::{Menu, MenuItem, PredefinedMenuItem}; + + let menu = Menu::default(app_handle)?; + let spec = native_preferences_menu_spec(); + let preferences = MenuItem::with_id( + app_handle, + spec.id, + spec.label, + true, + Some(spec.accelerator), + )?; + let (target_submenu, insertion_index) = + preferences_submenu_target(std::env::consts::OS, &app_handle.package_info().name); + + for item in menu.items()? { + let Some(submenu) = item.as_submenu() else { + continue; + }; + if submenu.text()? == target_submenu { + let separator = PredefinedMenuItem::separator(app_handle)?; + submenu.insert_items(&[&preferences, &separator], insertion_index)?; + break; + } + } + + Ok(menu) +} + +fn should_hide_main_window_for_standalone_preview( + opened_preview: bool, + has_notes_folder: bool, +) -> bool { + opened_preview && has_notes_folder +} + +fn has_configured_notes_folder(app: &AppHandle) -> bool { + let Some(state) = app.try_state::() else { + return false; + }; + let has_notes_folder = state + .app_config + .read() + .expect("app_config read lock") + .notes_folder + .is_some(); + has_notes_folder +} + +fn hide_main_window_for_standalone_preview(app: &AppHandle, opened_preview: bool) -> bool { + let should_hide = should_hide_main_window_for_standalone_preview( + opened_preview, + has_configured_notes_folder(app), + ); + if should_hide { + if let Some(main_window) = app.get_webview_window("main") { + let _ = main_window.hide(); + } + } + should_hide +} + +fn runtime_window_config_from_template( + template: &tauri::utils::config::WindowConfig, + label: &str, + url: WebviewUrl, +) -> tauri::utils::config::WindowConfig { + let mut runtime = template.clone(); + runtime.label = label.to_string(); + runtime.url = url; + runtime.create = false; + runtime.visible = true; + runtime +} + +fn first_window_template(windows: &[T]) -> Result<&T, String> { + windows + .first() + .ok_or_else(|| "No window template is configured".to_string()) +} + +fn create_preferences_window(app: &AppHandle) -> Result<(), String> { + if let Some(window) = app.get_webview_window("preferences") { + let _ = window.show(); + window.set_focus().map_err(|error| error.to_string())?; + return Ok(()); + } + + let template = first_window_template(&app.config().app.windows)?; + let runtime_config = runtime_window_config_from_template( + template, + "preferences", + WebviewUrl::App("index.html?mode=preferences".into()), + ); + let window = WebviewWindowBuilder::from_config(app, &runtime_config) + .map_err(|error| format!("Failed to configure Preferences window: {error}"))? + .title("Preferences") + .inner_size(900.0, 650.0) + .min_inner_size(720.0, 500.0) + .resizable(true) + .decorations(true) + .center() + .build() + .map_err(|error| format!("Failed to create Preferences window: {error}"))?; + let _ = window.show(); + window.set_focus().map_err(|error| error.to_string())?; + Ok(()) +} + +#[tauri::command] +fn open_preferences_window(app: AppHandle) -> Result<(), String> { + create_preferences_window(&app) +} + +/// Finalize a close only after the WebView has flushed the draft or persisted +/// a recovery snapshot. Destruction remains in trusted Rust. +#[tauri::command] +fn close_window_after_save(window: WebviewWindow) -> Result<(), String> { + window + .destroy() + .map_err(|error| format!("Failed to close window after save: {error}")) +} + // Preview mode: create a lightweight window for editing a single file fn create_preview_window(app: &AppHandle, file_path: &str) -> Result<(), String> { use std::collections::hash_map::DefaultHasher; @@ -3582,18 +4050,17 @@ fn create_preview_window(app: &AppHandle, file_path: &str) -> Result<(), String> let encoded_path = urlencoding::encode(file_path); let url = format!("index.html?mode=preview&file={}", encoded_path); - let builder = WebviewWindowBuilder::new(app, &label, WebviewUrl::App(url.into())) + let template = first_window_template(&app.config().app.windows)?; + let runtime_config = + runtime_window_config_from_template(template, &label, WebviewUrl::App(url.into())); + let builder = WebviewWindowBuilder::from_config(app, &runtime_config) + .map_err(|error| format!("Failed to configure preview window: {error}"))? .title(format!("{} — Scratch", filename)) .inner_size(800.0, 600.0) .min_inner_size(400.0, 300.0) .resizable(true) .decorations(true); - #[cfg(target_os = "macos")] - let builder = builder - .title_bar_style(tauri::TitleBarStyle::Overlay) - .hidden_title(true); - let window = builder .build() .map_err(|e| format!("Failed to create preview window: {}", e))?; @@ -3714,13 +4181,22 @@ pub fn run() { let app = tauri::Builder::default() // Single-instance: forward CLI args from subsequent launches to the running instance .plugin(tauri_plugin_single_instance::init(|app, args, cwd| { - handle_cli_args(app, &args, &cwd); + let opened_preview = handle_cli_args(app, &args, &cwd); + hide_main_window_for_standalone_preview(app, opened_preview); })) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_clipboard_manager::init()) .plugin(tauri_plugin_updater::Builder::new().build()) + .menu(build_application_menu) + .on_menu_event(|app, event| { + if event.id() == native_preferences_menu_spec().id { + if let Err(error) = create_preferences_window(app) { + eprintln!("Failed to open Preferences: {error}"); + } + } + }) .setup(|app| { // Load app config on startup (contains notes folder path) let mut app_config = load_app_config(app.handle()); @@ -3800,15 +4276,10 @@ pub fn run() { }; if let Some(main_window) = app.get_webview_window("main") { - let has_notes_folder = app - .state::() - .app_config - .read() - .expect("app_config read lock") - .notes_folder - .is_some(); - - if opened_preview && has_notes_folder { + if should_hide_main_window_for_standalone_preview( + opened_preview, + has_configured_notes_folder(app.handle()), + ) { // Existing user: notes folder is configured and a standalone preview // was opened. Close the hidden main window so only the preview is visible. let _ = main_window.hide(); @@ -3839,6 +4310,8 @@ pub fn run() { }) .invoke_handler(tauri::generate_handler![ get_notes_folder, + close_window_after_save, + open_preferences_window, set_notes_folder, list_notes, read_note, @@ -3887,6 +4360,12 @@ pub fn run() { ai_execute_ollama, read_file_direct, save_file_direct, + recreate_file_direct, + persist_recovery_snapshot, + write_draft_checkpoint, + get_draft_checkpoint, + clear_draft_checkpoint, + list_draft_checkpoints, import_file_to_folder, open_file_preview, install_cli, @@ -3902,16 +4381,19 @@ pub fn run() { app.run(|_app_handle, _event| { #[cfg(target_os = "macos")] if let tauri::RunEvent::Opened { urls } = _event { + let mut opened_preview = false; for url in urls { if let Ok(path) = url.to_file_path() { if is_markdown_extension(&path) && path.is_file() && !try_select_in_notes_folder(_app_handle, &path) { - let _ = create_preview_window(_app_handle, &path.to_string_lossy()); + opened_preview |= + create_preview_window(_app_handle, &path.to_string_lossy()).is_ok(); } } } + hide_main_window_for_standalone_preview(_app_handle, opened_preview); } }); } @@ -3994,3 +4476,224 @@ fn set_title_bar_theme( } Ok(()) } + +#[cfg(test)] +mod tests { + use super::{ + first_window_template, native_preferences_menu_spec, persist_git_enabled, + preferences_submenu_target, read_file_content_from_path, recreate_file_content_to_path, + save_file_content_to_path, should_hide_main_window_for_standalone_preview, + validate_preview_path, FileSaveResult, Settings, + }; + use std::fs; + use std::path::PathBuf; + use std::sync::atomic::{AtomicU64, Ordering}; + + static NEXT_STANDALONE_TEST_DIRECTORY: AtomicU64 = AtomicU64::new(0); + + struct StandaloneTestDirectory { + path: PathBuf, + } + + impl StandaloneTestDirectory { + fn new(name: &str) -> Self { + let sequence = NEXT_STANDALONE_TEST_DIRECTORY.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "scratch-standalone-{name}-{}-{sequence}", + std::process::id() + )); + fs::create_dir_all(&path).expect("create standalone test directory"); + Self { path } + } + } + + impl Drop for StandaloneTestDirectory { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } + } + + #[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 settings_preserve_editor_display_preferences() { + let settings: Settings = serde_json::from_str( + r#"{ + "theme":{"mode":"system"}, + "editorWidthResizeEnabled":false, + "editorToolbarVisible":true, + "titleBarModifiedDateVisible":false, + "titleBarFilenameVisible":true + }"#, + ) + .expect("settings should deserialize"); + + assert_eq!(settings.editor_width_resize_enabled, Some(false)); + assert_eq!(settings.editor_toolbar_visible, Some(true)); + assert_eq!(settings.title_bar_modified_date_visible, Some(false)); + assert_eq!(settings.title_bar_filename_visible, Some(true)); + + let serialized = serde_json::to_value(settings).expect("settings should serialize"); + assert_eq!(serialized["editorWidthResizeEnabled"], false); + assert_eq!(serialized["editorToolbarVisible"], true); + assert_eq!(serialized["titleBarModifiedDateVisible"], false); + assert_eq!(serialized["titleBarFilenameVisible"], true); + } + + #[test] + fn git_setting_persistence_updates_memory_only_after_disk_save() { + let directory = StandaloneTestDirectory::new("git-setting"); + let notes_folder = directory.path.to_string_lossy().into_owned(); + let mut settings = Settings { + git_enabled: Some(false), + ..Settings::default() + }; + + persist_git_enabled(¬es_folder, &mut settings, Some(true)).expect("persist git setting"); + + assert_eq!(settings.git_enabled, Some(true)); + let stored = super::load_settings(¬es_folder); + assert_eq!(stored.git_enabled, Some(true)); + } + + #[test] + fn git_setting_persistence_keeps_memory_when_disk_save_fails() { + let directory = StandaloneTestDirectory::new("git-setting-failure"); + let blocked_path = directory.path.join("not-a-directory"); + fs::write(&blocked_path, "blocking file").expect("write blocking file"); + let notes_folder = blocked_path.to_string_lossy().into_owned(); + let mut settings = Settings { + git_enabled: Some(false), + ..Settings::default() + }; + + persist_git_enabled(¬es_folder, &mut settings, Some(true)) + .expect_err("disk save should fail"); + + assert_eq!(settings.git_enabled, Some(false)); + } + + #[test] + fn preferences_menu_uses_the_native_shortcut() { + let spec = native_preferences_menu_spec(); + assert_eq!(spec.id, "preferences"); + assert_eq!(spec.label, "Preferences…"); + assert_eq!(spec.accelerator, "CmdOrCtrl+,"); + } + + #[test] + fn preferences_menu_targets_platform_default_submenus() { + assert_eq!( + preferences_submenu_target("macos", "Scratch"), + ("Scratch".to_string(), 1), + ); + assert_eq!( + preferences_submenu_target("windows", "Scratch"), + ("File".to_string(), 0), + ); + assert_eq!( + preferences_submenu_target("linux", "Scratch"), + ("Help".to_string(), 0), + ); + } + + #[test] + fn window_template_lookup_returns_an_error_instead_of_panicking() { + assert_eq!(first_window_template(&["main"]).unwrap(), &"main"); + assert_eq!( + first_window_template::<&str>(&[]).unwrap_err(), + "No window template is configured", + ); + } + + #[test] + fn standalone_preview_hides_main_only_for_configured_users() { + assert!(should_hide_main_window_for_standalone_preview(true, true)); + assert!(!should_hide_main_window_for_standalone_preview(true, false)); + assert!(!should_hide_main_window_for_standalone_preview(false, true)); + } + + #[test] + fn standalone_save_preserves_an_external_edit_as_a_conflict() { + let directory = StandaloneTestDirectory::new("conflict"); + let path = directory.path.join("External.md"); + fs::write(&path, "# External\n\nOriginal").expect("write initial note"); + let loaded = read_file_content_from_path(&path).expect("read initial note"); + fs::write(&path, "# External\n\nChanged outside Scratch").expect("write external edit"); + + let result = save_file_content_to_path( + &path, + "# External\n\nStale local draft".to_string(), + loaded.revision, + ) + .expect("return typed conflict"); + + assert!(matches!( + result, + FileSaveResult::Conflict { current: Some(_) } + )); + assert_eq!( + fs::read_to_string(&path).expect("read preserved file"), + "# External\n\nChanged outside Scratch" + ); + } + + #[test] + fn standalone_recreation_is_create_only() { + let directory = StandaloneTestDirectory::new("recreate"); + let path = directory.path.join("Deleted.md"); + let first = recreate_file_content_to_path(&path, "local draft".to_string()) + .expect("create missing note"); + let second = recreate_file_content_to_path(&path, "later draft".to_string()) + .expect("return typed conflict"); + + assert!(matches!(first, FileSaveResult::Saved { .. })); + assert!(matches!(second, FileSaveResult::Conflict { .. })); + assert_eq!(fs::read_to_string(path).unwrap(), "local draft"); + } + + #[cfg(unix)] + #[test] + fn standalone_symlink_cannot_relabel_a_non_markdown_target() { + use std::os::unix::fs::symlink; + + let directory = StandaloneTestDirectory::new("non-markdown-symlink"); + let target = directory.path.join("secret.txt"); + let alias = directory.path.join("secret.md"); + fs::write(&target, "not markdown").expect("write target"); + symlink(&target, &alias).expect("create symlink"); + + assert_eq!( + validate_preview_path(&alias.to_string_lossy()).unwrap_err(), + "Resolved file must be Markdown", + ); + } + + #[cfg(unix)] + #[test] + fn standalone_symlink_to_markdown_resolves_to_its_target() { + use std::os::unix::fs::symlink; + + let directory = StandaloneTestDirectory::new("markdown-symlink"); + let target = directory.path.join("Plan.md"); + let alias = directory.path.join("Alias.md"); + fs::write(&target, "# Plan").expect("write target"); + symlink(&target, &alias).expect("create symlink"); + + assert_eq!( + validate_preview_path(&alias.to_string_lossy()).unwrap(), + target.canonicalize().unwrap(), + ); + } +} diff --git a/src-tauri/src/persistence.rs b/src-tauri/src/persistence.rs new file mode 100644 index 00000000..bbf15752 --- /dev/null +++ b/src-tauri/src/persistence.rs @@ -0,0 +1,582 @@ +use std::collections::HashMap; +use std::fmt; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, OnceLock, Weak}; + +static NEXT_TEMPORARY_FILE: AtomicU64 = AtomicU64::new(0); +static PATH_LOCKS: OnceLock>>>> = OnceLock::new(); + +/// Stable SHA-256 identifier for one exact UTF-8 note content. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct ContentRevision(String); + +impl ContentRevision { + #[cfg(test)] + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn from_hex(value: &str) -> Result { + if value.len() != 64 + || !value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err("Revision must be 64 lowercase hexadecimal characters".to_string()); + } + Ok(Self(value.to_string())) + } +} + +impl fmt::Display for ContentRevision { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FileSnapshot { + pub revision: ContentRevision, + pub content: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum SaveResult { + Saved { revision: ContentRevision }, + Conflict { current: Option }, +} + +/// Returns a deterministic revision suitable for optimistic concurrency checks. +pub fn content_revision(content: &str) -> ContentRevision { + ContentRevision(crate::sha256::hex_digest(content.as_bytes())) +} + +/// Saves `content` only when the file still has `expected_revision`. +/// +/// `None` means the caller expects the file not to exist. A stale or missing +/// current revision is returned as a typed conflict and never mutates `path`. +pub fn save_if_revision( + path: impl AsRef, + content: &str, + expected_revision: Option<&ContentRevision>, +) -> io::Result { + let path = path.as_ref(); + let path_lock = lock_for_path(path); + let _save_guard = path_lock + .lock() + .map_err(|_| io::Error::other("note persistence lock poisoned"))?; + + let current = read_snapshot(path)?; + let revision_matches = match (expected_revision, current.as_ref()) { + (None, None) => true, + (Some(expected), Some(snapshot)) => expected == &snapshot.revision, + _ => false, + }; + + if !revision_matches { + return Ok(SaveResult::Conflict { current }); + } + + let next_revision = content_revision(content); + if current + .as_ref() + .is_some_and(|snapshot| snapshot.revision == next_revision) + { + return Ok(SaveResult::Saved { + revision: next_revision, + }); + } + + let write_result = if expected_revision.is_none() { + atomic_create_new(path, content.as_bytes()) + } else { + atomic_write(path, content.as_bytes()) + }; + + if let Err(error) = write_result { + if expected_revision.is_none() && error.kind() == io::ErrorKind::AlreadyExists { + return Ok(SaveResult::Conflict { + current: read_snapshot(path)?, + }); + } + return Err(error); + } + + Ok(SaveResult::Saved { + revision: next_revision, + }) +} + +pub(crate) fn read_snapshot(path: &Path) -> io::Result> { + match fs::read_to_string(path) { + Ok(content) => Ok(Some(FileSnapshot { + revision: content_revision(&content), + content, + })), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error), + } +} + +fn lock_for_path(path: &Path) -> Arc> { + let key = lock_key(path); + let locks = PATH_LOCKS.get_or_init(|| Mutex::new(HashMap::new())); + let mut locks = locks + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + locks.retain(|_, lock| lock.strong_count() > 0); + + if let Some(lock) = locks.get(&key).and_then(Weak::upgrade) { + return lock; + } + + let lock = Arc::new(Mutex::new(())); + locks.insert(key, Arc::downgrade(&lock)); + lock +} + +fn lock_key(path: &Path) -> PathBuf { + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir().unwrap_or_default().join(path) + }; + + if let Ok(canonical) = absolute.canonicalize() { + return canonical; + } + + let Some(file_name) = absolute.file_name() else { + return absolute; + }; + absolute + .parent() + .and_then(|parent| parent.canonicalize().ok()) + .map(|parent| parent.join(file_name)) + .unwrap_or(absolute) +} + +fn atomic_write(path: &Path, bytes: &[u8]) -> io::Result<()> { + let parent = path.parent().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "atomic save target has no parent directory", + ) + })?; + let existing_permissions = fs::metadata(path) + .ok() + .map(|metadata| metadata.permissions()); + let (mut temporary_file, mut temporary_path) = create_temporary_file(path, parent)?; + + temporary_file.write_all(bytes)?; + temporary_file.flush()?; + if let Some(permissions) = existing_permissions { + temporary_file.set_permissions(permissions)?; + } + temporary_file.sync_all()?; + drop(temporary_file); + + fs::rename(temporary_path.path(), path)?; + temporary_path.commit(); + sync_parent_directory(parent)?; + Ok(()) +} + +/// Publishes a brand-new file without ever replacing an existing directory +/// entry. The hard-link operation is atomic within the destination directory: +/// another process either wins first or receives `AlreadyExists`. +/// Filesystems without hard-link support fall back to a direct create-only +/// publication so that create-new saves still succeed. +fn atomic_create_new(path: &Path, bytes: &[u8]) -> io::Result<()> { + let parent = path.parent().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "atomic create target has no parent directory", + ) + })?; + let (mut temporary_file, mut temporary_path) = create_temporary_file(path, parent)?; + + temporary_file.write_all(bytes)?; + temporary_file.flush()?; + temporary_file.sync_all()?; + drop(temporary_file); + + match fs::hard_link(temporary_path.path(), path) { + Ok(()) => { + fs::remove_file(temporary_path.path())?; + temporary_path.commit(); + sync_parent_directory(parent)?; + return Ok(()); + } + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => { + return Err(error); + } + Err(_) => {} + } + + write_create_new_destination(path, bytes)?; + + fs::remove_file(temporary_path.path())?; + temporary_path.commit(); + sync_parent_directory(parent)?; + Ok(()) +} + +fn write_create_new_destination(path: &Path, bytes: &[u8]) -> io::Result<()> { + let mut file = OpenOptions::new().write(true).create_new(true).open(path)?; + let write_result = file + .write_all(bytes) + .and_then(|()| file.flush()) + .and_then(|()| file.sync_all()); + drop(file); + + if let Err(error) = write_result { + let _ = fs::remove_file(path); + return Err(error); + } + Ok(()) +} + +fn create_temporary_file(path: &Path, parent: &Path) -> io::Result<(File, TemporaryPath)> { + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("note"); + + loop { + let sequence = NEXT_TEMPORARY_FILE.fetch_add(1, Ordering::Relaxed); + let temporary_path = parent.join(format!( + ".{file_name}.scratch-save-{}-{sequence}.tmp", + std::process::id() + )); + + match OpenOptions::new() + .write(true) + .create_new(true) + .open(&temporary_path) + { + Ok(file) => return Ok((file, TemporaryPath::new(temporary_path))), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, + Err(error) => return Err(error), + } + } +} + +struct TemporaryPath { + path: PathBuf, + committed: bool, +} + +impl TemporaryPath { + fn new(path: PathBuf) -> Self { + Self { + path, + committed: false, + } + } + + fn path(&self) -> &Path { + &self.path + } + + fn commit(&mut self) { + self.committed = true; + } +} + +impl Drop for TemporaryPath { + fn drop(&mut self) { + if !self.committed { + let _ = fs::remove_file(&self.path); + } + } +} + +#[cfg(unix)] +fn sync_parent_directory(parent: &Path) -> io::Result<()> { + File::open(parent)?.sync_all() +} + +#[cfg(not(unix))] +fn sync_parent_directory(_parent: &Path) -> io::Result<()> { + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::path::{Path, PathBuf}; + use std::sync::atomic::{AtomicU64, Ordering}; + + static NEXT_TEST_DIRECTORY: AtomicU64 = AtomicU64::new(0); + + struct TestDirectory { + path: PathBuf, + } + + impl TestDirectory { + fn new(test_name: &str) -> Self { + let sequence = NEXT_TEST_DIRECTORY.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "scratch-persistence-{test_name}-{}-{sequence}", + std::process::id() + )); + fs::create_dir_all(&path).expect("create persistence test directory"); + Self { path } + } + + fn note_path(&self) -> PathBuf { + self.path.join("note.md") + } + } + + impl Drop for TestDirectory { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } + } + + fn temporary_artifacts(parent: &Path) -> Vec { + fs::read_dir(parent) + .expect("read test directory") + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.contains(".scratch-save-")) + }) + .collect() + } + + #[test] + fn revision_is_deterministic_and_uses_known_sha256_values() { + assert_eq!( + content_revision("").as_str(), + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + assert_eq!( + content_revision("abc").as_str(), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + assert_eq!(content_revision("same"), content_revision("same")); + assert_ne!(content_revision("same"), content_revision("changed")); + } + + #[test] + fn revision_parser_rejects_non_sha256_identifiers() { + let valid = content_revision("valid").to_string(); + assert_eq!(ContentRevision::from_hex(&valid).unwrap().as_str(), valid); + assert!(ContentRevision::from_hex("short").is_err()); + assert!(ContentRevision::from_hex(&"A".repeat(64)).is_err()); + assert!(ContentRevision::from_hex(&"g".repeat(64)).is_err()); + } + + #[test] + fn save_to_missing_file_is_atomic_and_returns_new_revision() { + let directory = TestDirectory::new("initial-save"); + let path = directory.note_path(); + + let result = save_if_revision(&path, "first version", None).expect("save new note"); + + assert_eq!( + result, + SaveResult::Saved { + revision: content_revision("first version") + } + ); + assert_eq!(fs::read_to_string(&path).unwrap(), "first version"); + assert!(temporary_artifacts(&directory.path).is_empty()); + } + + #[test] + fn stale_revision_returns_current_snapshot_without_modifying_final_file() { + let directory = TestDirectory::new("stale-revision"); + let path = directory.note_path(); + fs::write(&path, "version one").unwrap(); + let stale_revision = content_revision("version one"); + fs::write(&path, "version two from another window").unwrap(); + + let result = save_if_revision(&path, "stale local edit", Some(&stale_revision)) + .expect("conflict is a typed result"); + + assert_eq!( + result, + SaveResult::Conflict { + current: Some(FileSnapshot { + revision: content_revision("version two from another window"), + content: "version two from another window".to_string(), + }) + } + ); + assert_eq!( + fs::read_to_string(&path).unwrap(), + "version two from another window" + ); + assert!(temporary_artifacts(&directory.path).is_empty()); + } + + #[test] + fn expected_revision_for_deleted_file_returns_missing_conflict() { + let directory = TestDirectory::new("deleted-file"); + let path = directory.note_path(); + let deleted_revision = content_revision("deleted elsewhere"); + + let result = save_if_revision(&path, "local edit", Some(&deleted_revision)).unwrap(); + + assert_eq!(result, SaveResult::Conflict { current: None }); + assert!(!path.exists()); + } + + #[test] + fn create_only_save_refuses_to_replace_existing_file() { + let directory = TestDirectory::new("create-only"); + let path = directory.note_path(); + fs::write(&path, "already exists").unwrap(); + + let result = save_if_revision(&path, "new content", None).unwrap(); + + assert_eq!( + result, + SaveResult::Conflict { + current: Some(FileSnapshot { + revision: content_revision("already exists"), + content: "already exists".to_string(), + }) + } + ); + assert_eq!(fs::read_to_string(&path).unwrap(), "already exists"); + } + + #[test] + fn atomic_create_new_never_replaces_an_external_file() { + let directory = TestDirectory::new("external-create-race"); + let path = directory.note_path(); + fs::write(&path, "created by another process").unwrap(); + + let error = atomic_create_new(&path, b"local draft") + .expect_err("create-only publication must reject an existing destination"); + + assert_eq!(error.kind(), io::ErrorKind::AlreadyExists); + assert_eq!( + fs::read_to_string(&path).unwrap(), + "created by another process" + ); + assert!(temporary_artifacts(&directory.path).is_empty()); + } + + #[test] + fn create_new_fallback_never_deletes_an_existing_destination() { + let directory = TestDirectory::new("fallback-existing-race"); + let path = directory.note_path(); + fs::write(&path, "created by another process").unwrap(); + + let error = write_create_new_destination(&path, b"local draft") + .expect_err("create-only fallback must reject an existing destination"); + + assert_eq!(error.kind(), io::ErrorKind::AlreadyExists); + assert_eq!( + fs::read_to_string(&path).unwrap(), + "created by another process" + ); + } + + #[cfg(unix)] + #[test] + fn atomic_create_new_never_replaces_a_dangling_symlink() { + use std::os::unix::fs::symlink; + + let directory = TestDirectory::new("dangling-symlink-race"); + let path = directory.note_path(); + let missing_target = directory.path.join("missing.md"); + symlink(&missing_target, &path).unwrap(); + + let error = atomic_create_new(&path, b"local draft") + .expect_err("create-only publication must preserve a dangling symlink"); + + assert_eq!(error.kind(), io::ErrorKind::AlreadyExists); + assert_eq!(fs::read_link(&path).unwrap(), missing_target); + assert!(temporary_artifacts(&directory.path).is_empty()); + } + + #[cfg(unix)] + #[test] + fn existing_symlink_aliases_share_one_persistence_lock_key() { + use std::os::unix::fs::symlink; + + let directory = TestDirectory::new("symlink-lock-key"); + let target = directory.note_path(); + let alias = directory.path.join("alias.md"); + fs::write(&target, "base").unwrap(); + symlink(&target, &alias).unwrap(); + + assert_eq!(lock_key(&target), lock_key(&alias)); + } + + #[test] + fn simultaneous_saves_with_same_revision_have_one_winner_and_one_conflict() { + let directory = TestDirectory::new("simultaneous-save"); + let path = directory.note_path(); + fs::write(&path, "base").unwrap(); + let expected = content_revision("base"); + + let first_path = path.clone(); + let first_expected = expected.clone(); + let first = std::thread::spawn(move || { + save_if_revision(&first_path, "edit from window one", Some(&first_expected)) + .expect("first concurrent save") + }); + + let second_path = path.clone(); + let second_expected = expected.clone(); + let second = std::thread::spawn(move || { + save_if_revision(&second_path, "edit from window two", Some(&second_expected)) + .expect("second concurrent save") + }); + + let results = [first.join().unwrap(), second.join().unwrap()]; + assert_eq!( + results + .iter() + .filter(|result| matches!(result, SaveResult::Saved { .. })) + .count(), + 1 + ); + assert_eq!( + results + .iter() + .filter(|result| matches!(result, SaveResult::Conflict { .. })) + .count(), + 1 + ); + + let final_content = fs::read_to_string(&path).unwrap(); + let saved_revision = results + .iter() + .find_map(|result| match result { + SaveResult::Saved { revision } => Some(revision), + SaveResult::Conflict { .. } => None, + }) + .unwrap(); + assert_eq!(&content_revision(&final_content), saved_revision); + assert!(temporary_artifacts(&directory.path).is_empty()); + } + + #[test] + fn atomic_write_removes_temporary_file_when_rename_fails() { + let directory = TestDirectory::new("rename-error"); + let destination_is_a_directory = directory.path.join("destination"); + fs::create_dir(&destination_is_a_directory).unwrap(); + + let error = atomic_write(&destination_is_a_directory, b"content") + .expect_err("renaming a file over a directory must fail"); + + assert_ne!(error.kind(), std::io::ErrorKind::NotFound); + assert!(destination_is_a_directory.is_dir()); + assert!(temporary_artifacts(&directory.path).is_empty()); + } +} diff --git a/src-tauri/src/sha256.rs b/src-tauri/src/sha256.rs new file mode 100644 index 00000000..ee22ebbb --- /dev/null +++ b/src-tauri/src/sha256.rs @@ -0,0 +1,129 @@ +pub(crate) fn digest(input: &[u8]) -> [u8; 32] { + const INITIAL: [u32; 8] = [ + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, + 0x5be0cd19, + ]; + const ROUND_CONSTANTS: [u32; 64] = [ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, + 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, + 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, + 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, + 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, + 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, + 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, + 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, + 0xc67178f2, + ]; + + let mut state = INITIAL; + let mut chunks = input.chunks_exact(64); + for chunk in &mut chunks { + compress(&mut state, chunk, &ROUND_CONSTANTS); + } + + let remainder = chunks.remainder(); + let mut tail = [0_u8; 128]; + tail[..remainder.len()].copy_from_slice(remainder); + tail[remainder.len()] = 0x80; + let tail_length = if remainder.len() < 56 { 64 } else { 128 }; + tail[tail_length - 8..tail_length] + .copy_from_slice(&(input.len() as u64).wrapping_mul(8).to_be_bytes()); + + for chunk in tail[..tail_length].chunks_exact(64) { + compress(&mut state, chunk, &ROUND_CONSTANTS); + } + + let mut digest = [0_u8; 32]; + for (output, word) in digest.chunks_exact_mut(4).zip(state) { + output.copy_from_slice(&word.to_be_bytes()); + } + digest +} + +pub(crate) fn hex_digest(input: &[u8]) -> String { + let digest = digest(input); + let mut hex = String::with_capacity(64); + const DIGITS: &[u8; 16] = b"0123456789abcdef"; + for byte in digest { + hex.push(DIGITS[(byte >> 4) as usize] as char); + hex.push(DIGITS[(byte & 0x0f) as usize] as char); + } + hex +} + +fn compress(state: &mut [u32; 8], chunk: &[u8], constants: &[u32; 64]) { + let mut schedule = [0_u32; 64]; + for (index, word) in chunk.chunks_exact(4).enumerate() { + schedule[index] = u32::from_be_bytes([word[0], word[1], word[2], word[3]]); + } + for index in 16..64 { + let s0 = schedule[index - 15].rotate_right(7) + ^ schedule[index - 15].rotate_right(18) + ^ (schedule[index - 15] >> 3); + let s1 = schedule[index - 2].rotate_right(17) + ^ schedule[index - 2].rotate_right(19) + ^ (schedule[index - 2] >> 10); + schedule[index] = schedule[index - 16] + .wrapping_add(s0) + .wrapping_add(schedule[index - 7]) + .wrapping_add(s1); + } + + let [mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut h] = *state; + for index in 0..64 { + let big_s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25); + let choice = (e & f) ^ ((!e) & g); + let temp1 = h + .wrapping_add(big_s1) + .wrapping_add(choice) + .wrapping_add(constants[index]) + .wrapping_add(schedule[index]); + let big_s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22); + let majority = (a & b) ^ (a & c) ^ (b & c); + let temp2 = big_s0.wrapping_add(majority); + + h = g; + g = f; + f = e; + e = d.wrapping_add(temp1); + d = c; + c = b; + b = a; + a = temp1.wrapping_add(temp2); + } + + state[0] = state[0].wrapping_add(a); + state[1] = state[1].wrapping_add(b); + state[2] = state[2].wrapping_add(c); + state[3] = state[3].wrapping_add(d); + state[4] = state[4].wrapping_add(e); + state[5] = state[5].wrapping_add(f); + state[6] = state[6].wrapping_add(g); + state[7] = state[7].wrapping_add(h); +} + +#[cfg(test)] +mod tests { + use super::hex_digest; + + #[test] + fn matches_standard_lowercase_sha256_vectors() { + assert_eq!( + hex_digest(b""), + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + ); + assert_eq!( + hex_digest(b"abc"), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + ); + assert_eq!( + hex_digest(&[b'a'; 56]), + "b35439a4ac6f0948b6d6f9e3c6af0f5f590ce20f1bde7090ef7970686ec6738a", + ); + assert_eq!( + hex_digest(b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"), + "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1", + ); + } +} diff --git a/src/App.tsx b/src/App.tsx index 0e5224e9..8687952b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,7 +8,10 @@ import { TooltipProvider, Toaster } from "./components/ui"; import { Sidebar } from "./components/layout/Sidebar"; import { SidebarResizeHandle } from "./components/layout/SidebarResizeHandle"; import { SIDEBAR_DEFAULT_PX } from "./lib/sidebar"; -import { Editor } from "./components/editor/Editor"; +import { + Editor, + type EditorPersistenceController, +} from "./components/editor/Editor"; import type { Editor as TiptapEditor } from "@tiptap/react"; import { FolderPicker } from "./components/layout/FolderPicker"; import { CommandPalette } from "./components/command-palette/CommandPalette"; @@ -30,22 +33,21 @@ import { } from "@tauri-apps/plugin-updater"; import { getCurrentWindow } from "@tauri-apps/api/window"; import * as aiService from "./services/ai"; +import * as notesService from "./services/notes"; import type { AiProvider } from "./services/ai"; import { isMac, isWindows } from "./lib/platform"; - -// Detect preview mode from URL search params -function getWindowMode(): { - isPreview: boolean; - previewFile: string | null; -} { - const params = new URLSearchParams(window.location.search); - const mode = params.get("mode"); - const file = params.get("file"); - return { - isPreview: mode === "preview" && !!file, - previewFile: file, - }; -} +import { closeWindowAfterSave, requestCurrentWindowClose } from "./services/windowLifecycle"; +import { useWindowShortcuts } from "./lib/useWindowShortcuts"; +import { + beginSafeWindowClose, + resolveCloseListenerRegistration, + runSafeWindowClose, +} from "./lib/windowClose"; +import { + consumePendingRecoveryNotices, + recordPendingRecoveryNotice, +} from "./lib/recoveryNotice"; +import { getWindowMode } from "./lib/windowMode"; type ViewState = "notes" | "settings"; @@ -64,9 +66,7 @@ function AppContent() { currentNote, syncNotesFolder, } = useNotes(); - const { interfaceZoom, setInterfaceZoom, reloadSettings } = useTheme(); - const interfaceZoomRef = useRef(interfaceZoom); - interfaceZoomRef.current = interfaceZoom; + const { reloadSettings } = useTheme(); const currentNoteRef = useRef(currentNote); currentNoteRef.current = currentNote; const [paletteOpen, setPaletteOpen] = useState(false); @@ -78,6 +78,82 @@ function AppContent() { const [focusMode, setFocusMode] = useState(false); const [aiProvider, setAiProvider] = useState("claude"); const editorRef = useRef(null); + const persistenceControllerRef = useRef( + null, + ); + const closeInProgressRef = useRef(false); + + useEffect(() => { + for (const notice of consumePendingRecoveryNotices()) { + toast.warning( + `A draft that could not be saved was recovered to ${notice.recoveredTo}`, + ); + } + }, []); + + const handlePersistenceControllerReady = useCallback( + (controller: EditorPersistenceController | null) => { + persistenceControllerRef.current = controller; + }, + [], + ); + + useEffect(() => { + let disposed = false; + let unlisten: (() => void) | undefined; + const appWindow = getCurrentWindow(); + + void resolveCloseListenerRegistration( + appWindow.onCloseRequested((event) => { + if (!beginSafeWindowClose(event, closeInProgressRef)) return; + + void runSafeWindowClose({ + flushDraft: () => + persistenceControllerRef.current?.flush() ?? Promise.resolve(), + persistRecovery: async () => { + const draft = persistenceControllerRef.current?.getDraft(); + const note = currentNoteRef.current; + if (!draft?.dirty || !draft.noteId || !note) { + return { status: "not-needed" } as const; + } + const path = await notesService.persistRecoverySnapshot({ + noteId: draft.noteId, + sourcePath: note.path, + content: draft.content, + reason: "window-close", + }); + return { status: "recovered", path } as const; + }, + beforeClose: async ({ recoveredTo, saveError }) => { + if (recoveredTo && saveError) { + recordPendingRecoveryNotice(recoveredTo, saveError); + } + }, + closeWindow: closeWindowAfterSave, + }).catch((error) => { + closeInProgressRef.current = false; + if (!disposed) { + toast.error( + `Window kept open because the draft could not be saved: ${error}`, + ); + } + }); + }), + (error) => { + if (!disposed) { + toast.error(`Safe window-close protection could not start: ${error}`); + } + }, + ).then((removeListener) => { + if (disposed) removeListener(); + else unlisten = removeListener; + }); + + return () => { + disposed = true; + unlisten?.(); + }; + }, []); // Listen for set-notes-folder event from CLI (scratch .) // Placed here in AppContent where both NotesContext and ThemeContext are available @@ -113,14 +189,37 @@ function AppContent() { }); }, [selectedNoteId]); - const toggleSettings = useCallback(() => { - setView((prev) => (prev === "settings" ? "notes" : "settings")); - }, []); + const openSettings = useCallback(async () => { + if (view === "settings") return; + try { + await persistenceControllerRef.current?.flush(); + } catch (error) { + toast.error(`Settings not opened: ${error}`); + return; + } + setView("settings"); + }, [view]); + + const toggleSettings = useCallback(async () => { + if (view === "settings") { + setView("notes"); + return; + } + try { + await persistenceControllerRef.current?.flush(); + } catch (error) { + toast.error(`Settings not opened: ${error}`); + return; + } + setView("settings"); + }, [view]); const closeSettings = useCallback(() => { setView("notes"); }, []); + useWindowShortcuts({ onOpenPreferences: openSettings }); + // Go back to command palette from AI modal const handleBackToPalette = useCallback(() => { setAiModalOpen(false); @@ -206,39 +305,6 @@ function AppContent() { const isEditorEmpty = isInEditor && currentNoteRef.current?.content.trim() === ""; - // Cmd+, - Toggle settings (always works, even in settings) - if ((e.metaKey || e.ctrlKey) && e.key === ",") { - e.preventDefault(); - toggleSettings(); - return; - } - - // Cmd+= or Cmd++ - Zoom in (works everywhere, including settings) - if ((e.metaKey || e.ctrlKey) && (e.key === "=" || e.key === "+")) { - e.preventDefault(); - setInterfaceZoom((prev) => prev + 0.05); - const newZoom = Math.round(Math.min(interfaceZoomRef.current + 0.05, 1.5) * 20) / 20; - toast(`Zoom ${Math.round(newZoom * 100)}%`, { id: "zoom", duration: 1500 }); - return; - } - - // Cmd+- - Zoom out (works everywhere, including settings) - if ((e.metaKey || e.ctrlKey) && (e.key === "-" || e.key === "_")) { - e.preventDefault(); - setInterfaceZoom((prev) => prev - 0.05); - const newZoom = Math.round(Math.max(interfaceZoomRef.current - 0.05, 0.7) * 20) / 20; - toast(`Zoom ${Math.round(newZoom * 100)}%`, { id: "zoom", duration: 1500 }); - return; - } - - // Cmd+0 - Reset zoom (works everywhere, including settings) - if ((e.metaKey || e.ctrlKey) && e.key === "0") { - e.preventDefault(); - setInterfaceZoom(1.0); - toast("Zoom 100%", { id: "zoom", duration: 1500 }); - return; - } - // Block all other shortcuts when in settings view if (view === "settings") { return; @@ -443,7 +509,6 @@ function AppContent() { toggleFocusMode, focusMode, view, - setInterfaceZoom, ]); const handleClosePalette = useCallback(() => { @@ -481,13 +546,16 @@ function AppContent() { {sidebarVisible && !focusMode && } - { - editorRef.current = editor; - }} + onEditorReady={(editor) => { + editorRef.current = editor; + }} + onPersistenceControllerReady={ + handlePersistenceControllerReady + } /> )} @@ -635,15 +703,31 @@ function UpdateToast({ ); } +function PreferencesApp() { + const keepPreferencesOpen = useCallback(() => {}, []); + useWindowShortcuts({ onOpenPreferences: keepPreferencesOpen }); + + return ( + + + + + + ); +} + function App() { - const { isPreview, previewFile } = useMemo(getWindowMode, []); + const { isPreview, isPreferences, previewFile } = useMemo( + () => getWindowMode(window.location.search), + [], + ); // Cmd/Ctrl+W — close window (works in both preview and folder mode) useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === "w") { e.preventDefault(); - getCurrentWindow().close().catch(console.error); + void requestCurrentWindowClose().catch(console.error); } }; window.addEventListener("keydown", handleKeyDown); @@ -658,10 +742,21 @@ function App() { // Check for app updates on startup (folder mode only) useEffect(() => { - if (isPreview) return; + if (isPreview || isPreferences) return; const timer = setTimeout(() => showUpdateToast(), 3000); return () => clearTimeout(timer); - }, [isPreview]); + }, [isPreferences, isPreview]); + + if (isPreferences) { + return ( + + + + + + + ); + } // Preview mode: lightweight editor without sidebar, search, git if (isPreview && previewFile) { @@ -669,7 +764,7 @@ function App() { - + ); diff --git a/src/components/editor/Editor.tsx b/src/components/editor/Editor.tsx index 32a8159c..cdf8a056 100644 --- a/src/components/editor/Editor.tsx +++ b/src/components/editor/Editor.tsx @@ -35,6 +35,7 @@ import tippy, { type Instance as TippyInstance } from "tippy.js"; import { open as openDialog } from "@tauri-apps/plugin-dialog"; import { openUrl } from "@tauri-apps/plugin-opener"; import { invoke, convertFileSrc } from "@tauri-apps/api/core"; +import { getCurrentWindow } from "@tauri-apps/api/window"; import { join } from "@tauri-apps/api/path"; import { toast } from "sonner"; import { mod, alt, shift, isMac, isWindows } from "../../lib/platform"; @@ -71,8 +72,21 @@ import { EditorWidthHandles } from "./EditorWidthHandle"; import { ScratchBlockMath, normalizeBlockMath } from "./MathExtensions"; import { cn } from "../../lib/utils"; import { plainTextFromMarkdown } from "../../lib/plainText"; +import { getTitleBarNoteInfoText } from "../../lib/titleBarNoteInfo"; +import type { ConflictResolutionStrategy } from "../../lib/conflictResolution"; +import { + choosePendingDraftRepresentation, + flushPendingDraftRepresentation, +} from "../../lib/draftRepresentation"; import { Button, IconButton, ToolbarButton, Tooltip } from "../ui"; import * as notesService from "../../services/notes"; +import * as draftCheckpointService from "../../services/draftCheckpoint"; +import { + createDraftCheckpointSnapshot, + createDraftCheckpointScheduler, + nextCheckpointCaptureDelay, + type DraftCheckpointScheduler, +} from "../../lib/draftCheckpoint"; import { downloadPdf, downloadMarkdown } from "../../services/pdf"; import type { Settings } from "../../types/note"; import { @@ -109,6 +123,8 @@ import { FolderPlusIcon, } from "../icons"; +const AUTO_SAVE_DEBOUNCE_MS = 300; + function formatDateTime(timestamp: number): string { const date = new Date(timestamp * 1000); return date.toLocaleDateString(undefined, { @@ -428,15 +444,30 @@ function FormatBar({ } // Data source for preview mode — bypasses NotesContext +export interface EditorPersistenceController { + flush: () => Promise; + getDraft: () => { + noteId: string | null; + content: string; + dirty: boolean; + }; +} + export interface PreviewModeData { content: string | null; title: string; filePath: string; modified: number; + revision: string; hasExternalChanges: boolean; + hasSaveConflict: boolean; reloadVersion: number; save: (content: string) => Promise; reload: () => Promise; + resolveConflict: (strategy: ConflictResolutionStrategy) => Promise; + registerPersistenceController: ( + controller: EditorPersistenceController, + ) => () => void; } interface EditorProps { @@ -447,6 +478,9 @@ interface EditorProps { onEditorReady?: (editor: TiptapEditor | null) => void; onSaveToFolder?: () => void; saveToFolderDisabled?: boolean; + onPersistenceControllerReady?: ( + controller: EditorPersistenceController | null, + ) => void; } /** @@ -510,6 +544,7 @@ export function Editor({ previewMode, onSaveToFolder, saveToFolderDisabled, + onPersistenceControllerReady, }: EditorProps) { // Always call the hook (rules of hooks), but it returns null outside NotesProvider const notesCtx = useOptionalNotes(); @@ -522,6 +557,7 @@ export function Editor({ content: previewMode.content, path: previewMode.filePath, modified: previewMode.modified, + revision: previewMode.revision, } : null : (notesCtx?.currentNote ?? null); @@ -537,21 +573,42 @@ export function Editor({ const hasExternalChanges = previewMode ? previewMode.hasExternalChanges : notesCtx!.hasExternalChanges; + const hasSaveConflict = previewMode ? previewMode.hasSaveConflict : false; const reloadCurrentNote = previewMode ? previewMode.reload : notesCtx!.reloadCurrentNote; + const resolveNoteConflict = previewMode + ? previewMode.resolveConflict + : undefined; const reloadVersion = previewMode ? previewMode.reloadVersion : notesCtx!.reloadVersion; const pinNote = notesCtx?.pinNote; const unpinNote = notesCtx?.unpinNote; const notes = notesCtx?.notes; - const { textDirection } = useTheme(); + const { + textDirection, + editorWidthResizeEnabled, + editorToolbarVisible, + titleBarModifiedDateVisible, + titleBarFilenameVisible, + } = useTheme(); const [isSaving, setIsSaving] = useState(false); + const [isDirty, setIsDirty] = useState(false); // Force re-render when selection changes to update toolbar active states const [, setSelectionKey] = useState(0); const [copyMenuOpen, setCopyMenuOpen] = useState(false); const [settings, setSettings] = useState(null); + const titleBarNoteInfo = currentNote + ? getTitleBarNoteInfoText( + { + modifiedDateVisible: titleBarModifiedDateVisible, + filenameVisible: titleBarFilenameVisible, + }, + currentNote, + formatDateTime, + ) + : null; // Delay transition classes until after initial mount to avoid format bar height animation on note load const [hasTransitioned, setHasTransitioned] = useState(false); useEffect(() => { @@ -566,8 +623,13 @@ export function Editor({ const isSidebarActive = sidebarVisible && !focusMode; // Source mode state const [sourceMode, setSourceMode] = useState(false); + const sourceModeRef = useRef(sourceMode); + sourceModeRef.current = sourceMode; const [sourceContent, setSourceContent] = useState(""); const sourceTimeoutRef = useRef(null); + const sourceContentRef = useRef(""); + const sourceNeedsSaveRef = useRef(false); + const sourceSaveGenerationRef = useRef(0); const sourceModeTransitionRef = useRef<{ topBlockIndex: number; cursorBlockIndex: number; @@ -584,6 +646,25 @@ export function Editor({ const [currentMatchIndex, setCurrentMatchIndex] = useState(0); const searchInputRef = useRef(null); const saveTimeoutRef = useRef(null); + const checkpointCaptureTimerRef = useRef(null); + const checkpointCaptureStartedAtRef = useRef(null); + const queueCheckpointCaptureRef = useRef<() => void>(() => undefined); + const checkpointSchedulerRef = useRef(null); + if (!checkpointSchedulerRef.current) { + checkpointSchedulerRef.current = createDraftCheckpointScheduler( + { + write: draftCheckpointService.writeDraftCheckpoint, + clear: draftCheckpointService.clearDraftCheckpoint, + }, + { + delayMs: 250, + onError: (error) => { + console.error("Failed to persist crash checkpoint:", error); + }, + }, + ); + } + const checkpointScheduler = checkpointSchedulerRef.current; const linkPopupRef = useRef(null); const blockMathPopupRef = useRef(null); const isLoadingRef = useRef(false); @@ -592,14 +673,18 @@ export function Editor({ const currentNoteIdRef = useRef(null); // Track if we need to save (use ref to avoid computing markdown on every keystroke) const needsSaveRef = useRef(false); + const saveGenerationRef = useRef(0); // Stable refs for wikilink click handler (avoids re-registering listener on every notes change) const notesRef = useRef(notes); notesRef.current = notes; const notesCtxRef = useRef(notesCtx); notesCtxRef.current = notesCtx; + const currentNoteRef = useRef(currentNote); + currentNoteRef.current = currentNote; // Keep ref in sync with current note ID currentNoteIdRef.current = currentNote?.id ?? null; + sourceContentRef.current = sourceContent; // Get markdown from editor const getMarkdown = useCallback( @@ -732,8 +817,8 @@ export function Editor({ async (noteId: string, content: string) => { setIsSaving(true); try { - lastSaveRef.current = { noteId, content }; await saveNote(content, noteId); + lastSaveRef.current = { noteId, content }; } finally { setIsSaving(false); } @@ -750,11 +835,19 @@ export function Editor({ // Use loadedNoteIdRef (the note in the editor) not currentNoteIdRef (which may have changed) if (needsSaveRef.current && editorRef.current && loadedNoteIdRef.current) { - needsSaveRef.current = false; + const generation = saveGenerationRef.current; const markdown = getMarkdown(editorRef.current); await saveImmediately(loadedNoteIdRef.current, markdown); + if (saveGenerationRef.current === generation) { + needsSaveRef.current = false; + setIsDirty(sourceNeedsSaveRef.current); + await checkpointScheduler.handleSaveOutcome("saved", { + windowLabel: getCurrentWindow().label, + noteId: loadedNoteIdRef.current, + }); + } } - }, [saveImmediately, getMarkdown]); + }, [checkpointScheduler, saveImmediately, getMarkdown]); // Schedule a debounced save (markdown computed only when timer fires) const scheduleSave = useCallback(() => { @@ -766,6 +859,9 @@ export function Editor({ if (!savingNoteId) return; needsSaveRef.current = true; + setIsDirty(true); + queueCheckpointCaptureRef.current(); + const generation = ++saveGenerationRef.current; saveTimeoutRef.current = window.setTimeout(async () => { if (currentNoteIdRef.current !== savingNoteId || !needsSaveRef.current) { @@ -774,12 +870,186 @@ export function Editor({ // Compute markdown only now, when we actually save if (editorRef.current) { - needsSaveRef.current = false; const markdown = getMarkdown(editorRef.current); - await saveImmediately(savingNoteId, markdown); + try { + await saveImmediately(savingNoteId, markdown); + if (saveGenerationRef.current === generation) { + needsSaveRef.current = false; + setIsDirty(sourceNeedsSaveRef.current); + await checkpointScheduler.handleSaveOutcome("saved", { + windowLabel: getCurrentWindow().label, + noteId: savingNoteId, + }); + } + } catch (error) { + needsSaveRef.current = true; + console.error("Failed to save note:", error); + toast.error("Failed to save note"); + } + } + }, AUTO_SAVE_DEBOUNCE_MS); + }, [checkpointScheduler, saveImmediately, getMarkdown, currentNote?.id]); + + const flushSourceSave = useCallback(async () => { + if (sourceTimeoutRef.current) { + clearTimeout(sourceTimeoutRef.current); + sourceTimeoutRef.current = null; + } + const noteId = loadedNoteIdRef.current ?? currentNoteIdRef.current; + if (!sourceNeedsSaveRef.current || !noteId) return; + + const generation = sourceSaveGenerationRef.current; + await saveImmediately(noteId, sourceContentRef.current); + if (sourceSaveGenerationRef.current === generation) { + sourceNeedsSaveRef.current = false; + setIsDirty(needsSaveRef.current); + await checkpointScheduler.handleSaveOutcome("saved", { + windowLabel: getCurrentWindow().label, + noteId, + }); + } + }, [checkpointScheduler, saveImmediately]); + + const flushAllPendingSaves = useCallback(async () => { + await flushPendingDraftRepresentation( + sourceModeRef.current, + sourceNeedsSaveRef.current, + needsSaveRef.current, + { + discardSource: () => { + if (sourceTimeoutRef.current) { + clearTimeout(sourceTimeoutRef.current); + sourceTimeoutRef.current = null; + } + sourceNeedsSaveRef.current = false; + sourceSaveGenerationRef.current += 1; + }, + discardFormatted: () => { + if (saveTimeoutRef.current) { + clearTimeout(saveTimeoutRef.current); + saveTimeoutRef.current = null; + } + needsSaveRef.current = false; + saveGenerationRef.current += 1; + }, + flushSource: flushSourceSave, + flushFormatted: flushPendingSave, + }, + ); + }, [flushPendingSave, flushSourceSave]); + + const getOpenDraftSnapshot = useCallback(() => { + const noteId = loadedNoteIdRef.current ?? currentNoteIdRef.current; + const representation = choosePendingDraftRepresentation( + sourceModeRef.current, + sourceNeedsSaveRef.current, + needsSaveRef.current, + ); + const useSource = + representation === "source" || + (representation === null && sourceModeRef.current); + if (useSource) { + return { + noteId, + content: sourceContentRef.current, + dirty: representation !== null, + }; + } + return { + noteId, + content: editorRef.current ? getMarkdown(editorRef.current) : "", + dirty: representation !== null, + }; + }, [getMarkdown]); + const flushAllPendingSavesRef = useRef(flushAllPendingSaves); + flushAllPendingSavesRef.current = flushAllPendingSaves; + + const persistCurrentCrashCheckpoint = useCallback(async () => { + const draft = getOpenDraftSnapshot(); + const checkpoint = createDraftCheckpointSnapshot( + getCurrentWindow().label, + draft, + currentNoteRef.current, + new Date().toISOString(), + ); + if (!checkpoint) return; + checkpointScheduler.markDirty(checkpoint); + await checkpointScheduler.flush(); + }, [checkpointScheduler, getOpenDraftSnapshot]); + const persistCurrentCrashCheckpointRef = useRef(persistCurrentCrashCheckpoint); + persistCurrentCrashCheckpointRef.current = persistCurrentCrashCheckpoint; + + useLayoutEffect(() => { + return () => { + const hasPendingSave = + needsSaveRef.current || sourceNeedsSaveRef.current; + if (hasPendingSave) { + void flushAllPendingSavesRef.current().catch(() => { + // Best-effort: ignore async failures during unmount + }); + } + void persistCurrentCrashCheckpointRef.current().catch(() => { + // Best-effort: ignore async failures during unmount + }); + if (checkpointCaptureTimerRef.current) { + clearTimeout(checkpointCaptureTimerRef.current); + checkpointCaptureTimerRef.current = null; + checkpointCaptureStartedAtRef.current = null; } - }, 500); - }, [saveImmediately, getMarkdown, currentNote?.id]); + checkpointSchedulerRef.current?.dispose(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + queueCheckpointCaptureRef.current = () => { + const now = Date.now(); + checkpointCaptureStartedAtRef.current ??= now; + if (checkpointCaptureTimerRef.current) { + clearTimeout(checkpointCaptureTimerRef.current); + } + const delay = nextCheckpointCaptureDelay( + now - checkpointCaptureStartedAtRef.current, + 250, + 750, + ); + checkpointCaptureTimerRef.current = window.setTimeout(() => { + checkpointCaptureTimerRef.current = null; + checkpointCaptureStartedAtRef.current = null; + void persistCurrentCrashCheckpoint(); + }, delay); + }; + + useEffect(() => { + const handleVisibilityChange = () => { + if (document.visibilityState !== "hidden") return; + if (checkpointCaptureTimerRef.current) { + clearTimeout(checkpointCaptureTimerRef.current); + checkpointCaptureTimerRef.current = null; + } + checkpointCaptureStartedAtRef.current = null; + void persistCurrentCrashCheckpoint(); + }; + document.addEventListener("visibilitychange", handleVisibilityChange); + return () => + document.removeEventListener("visibilitychange", handleVisibilityChange); + }, [persistCurrentCrashCheckpoint]); + + useEffect(() => { + const controller: EditorPersistenceController = { + flush: flushAllPendingSaves, + getDraft: getOpenDraftSnapshot, + }; + if (previewMode) { + return previewMode.registerPersistenceController(controller); + } + onPersistenceControllerReady?.(controller); + return () => onPersistenceControllerReady?.(null); + }, [ + flushAllPendingSaves, + getOpenDraftSnapshot, + onPersistenceControllerReady, + previewMode, + ]); const closeBlockMathPopup = useCallback(() => { if (blockMathPopupRef.current) { @@ -1465,9 +1735,15 @@ export function Editor({ } } - // Flush any pending save before switching to a different note - if (!isSameNote && needsSaveRef.current) { - flushPendingSave(); + // Flush the active representation before switching to a different note. + if ( + !isSameNote && + (needsSaveRef.current || sourceNeedsSaveRef.current) + ) { + void flushAllPendingSavesRef.current().catch((error) => { + console.error("Failed to save before switching notes:", error); + toast.error("Failed to save before switching notes"); + }); } // Reset source mode when genuinely switching notes (renames return early above) if (!isSameNote) { @@ -1579,21 +1855,19 @@ export function Editor({ scrollContainerRef.current?.scrollTo(0, 0); }, []); - // Cleanup on unmount - flush pending saves + // Save barriers run before settings and window transitions. React cleanup + // cannot await I/O, so it must never fire-and-forget the only draft. useEffect(() => { return () => { if (saveTimeoutRef.current) { clearTimeout(saveTimeoutRef.current); } - // Flush any pending save before unmounting - if (needsSaveRef.current && editorRef.current) { - needsSaveRef.current = false; - const manager = editorRef.current.storage.markdown?.manager; - const markdown = manager - ? manager.serialize(editorRef.current.getJSON()) - : editorRef.current.getText(); - // Fire and forget - save will complete in background - saveNote(markdown); + if (sourceTimeoutRef.current) { + clearTimeout(sourceTimeoutRef.current); + } + if (checkpointCaptureTimerRef.current) { + clearTimeout(checkpointCaptureTimerRef.current); + checkpointCaptureStartedAtRef.current = null; } if (linkPopupRef.current) { linkPopupRef.current.destroy(); @@ -2121,15 +2395,29 @@ export function Editor({ const handleSourceChange = useCallback( (value: string) => { setSourceContent(value); + sourceContentRef.current = value; + sourceNeedsSaveRef.current = true; + setIsDirty(true); + queueCheckpointCaptureRef.current(); + const generation = ++sourceSaveGenerationRef.current; if (sourceTimeoutRef.current) { clearTimeout(sourceTimeoutRef.current); } sourceTimeoutRef.current = window.setTimeout(async () => { + sourceTimeoutRef.current = null; if (currentNote) { setIsSaving(true); try { - lastSaveRef.current = { noteId: currentNote.id, content: value }; await saveNote(value, currentNote.id); + lastSaveRef.current = { noteId: currentNote.id, content: value }; + if (sourceSaveGenerationRef.current === generation) { + sourceNeedsSaveRef.current = false; + setIsDirty(needsSaveRef.current); + await checkpointScheduler.handleSaveOutcome("saved", { + windowLabel: getCurrentWindow().label, + noteId: currentNote.id, + }); + } } catch (error) { console.error("Failed to save note:", error); toast.error("Failed to save note"); @@ -2137,9 +2425,9 @@ export function Editor({ setIsSaving(false); } } - }, 300); + }, AUTO_SAVE_DEBOUNCE_MS); }, - [currentNote, saveNote], + [checkpointScheduler, currentNote, saveNote], ); if (!currentNote) { @@ -2257,14 +2545,61 @@ export function Editor({ )} - - {formatDateTime(currentNote.modified)} - + {titleBarNoteInfo && ( + + {titleBarNoteInfo} + + )}
- {hasExternalChanges ? ( + {hasSaveConflict && resolveNoteConflict ? ( + + + + + + + + + { + void resolveNoteConflict("keepLocal").catch((error) => { + toast.error(`Conflict remains: ${error}`); + }); + }} + > + Keep My Changes + + { + void resolveNoteConflict("useRemote").catch((error) => { + toast.error(`Conflict remains: ${error}`); + }); + }} + > + Use Version on Disk + + +
+ A recovery copy is created before either action. +
+
+
+
+ ) : hasExternalChanges ? ( @@ -2282,6 +2617,15 @@ export function Editor({
+ ) : isDirty ? ( + +
+ +
+
) : (
@@ -2432,22 +2776,27 @@ export function Editor({
{/* Format Bar – transition only after initial mount to avoid height animation on note load */} -
- -
+ {editorToolbarVisible && ( +
+ +
+ )} {/* Editor content area with resize handles overlay */}
{!focusMode && !sourceMode && ( - + )}
{ + it("measures the rendered page instead of its unconstrained max-width", () => { + const container = document.createElement("div"); + const editor = document.createElement("div"); + editor.className = "ProseMirror"; + editor.style.maxWidth = "576px"; + editor.getBoundingClientRect = () => ({ + x: 37, + y: 0, + left: 37, + top: 0, + right: 563, + bottom: 800, + width: 526, + height: 800, + toJSON: () => ({}), + }); + Object.defineProperty(container, "clientWidth", { value: 600 }); + container.append(editor); + + expect(getRenderedEditorWidth(container)).toBe(526); + }); + + it("mounts no resize interaction when mouse resizing is disabled", () => { + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + + act(() => { + root.render( + ()} + />, + ); + }); + + expect(container.childElementCount).toBe(0); + + act(() => root.unmount()); + container.remove(); + }); +}); diff --git a/src/components/editor/EditorWidthHandle.tsx b/src/components/editor/EditorWidthHandle.tsx index 44ca86d6..1f532903 100644 --- a/src/components/editor/EditorWidthHandle.tsx +++ b/src/components/editor/EditorWidthHandle.tsx @@ -21,9 +21,33 @@ const SNAP_THRESHOLD = 20; interface EditorWidthHandlesProps { containerRef: RefObject; + enabled: boolean; } -export function EditorWidthHandles({ containerRef }: EditorWidthHandlesProps) { +export function getRenderedEditorWidth( + container: HTMLDivElement, +): number | null { + const proseMirror = container.querySelector(".ProseMirror"); + if (!proseMirror) return null; + + const renderedWidth = proseMirror.getBoundingClientRect().width; + if (!Number.isFinite(renderedWidth) || renderedWidth <= 0) return null; + + return Math.min(renderedWidth, container.clientWidth); +} + +export function EditorWidthHandles({ + containerRef, + enabled, +}: EditorWidthHandlesProps) { + if (!enabled) return null; + + return ; +} + +function ActiveEditorWidthHandles({ + containerRef, +}: Pick) { const { editorWidth, customEditorWidthPx, @@ -48,17 +72,10 @@ export function EditorWidthHandles({ containerRef }: EditorWidthHandlesProps) { const updateHandleOffset = useCallback(() => { if (!containerRef.current) return; const containerWidth = containerRef.current.clientWidth; - const proseMirror = - containerRef.current.querySelector(".ProseMirror"); - if (proseMirror) { - const maxWidth = getComputedStyle(proseMirror).maxWidth; - if (maxWidth && maxWidth !== "none") { - const editorPx = - maxWidth === "100%" ? containerWidth : parseFloat(maxWidth); - const clampedEditor = Math.min(editorPx, containerWidth); - setHandleOffset((containerWidth - clampedEditor) / 2); - return; - } + const renderedWidth = getRenderedEditorWidth(containerRef.current); + if (renderedWidth !== null) { + setHandleOffset((containerWidth - renderedWidth) / 2); + return; } setHandleOffset(0); }, [containerRef]); @@ -75,13 +92,8 @@ export function EditorWidthHandles({ containerRef }: EditorWidthHandlesProps) { const getCurrentEditorWidth = useCallback((): number => { if (!containerRef.current) return 768; - const proseMirror = containerRef.current.querySelector(".ProseMirror"); - if (proseMirror) { - const maxWidth = getComputedStyle(proseMirror).maxWidth; - if (maxWidth && maxWidth !== "none" && maxWidth !== "100%") { - return parseFloat(maxWidth); - } - } + const renderedWidth = getRenderedEditorWidth(containerRef.current); + if (renderedWidth !== null) return renderedWidth; if (editorWidth === "custom") return customEditorWidthPx; if (editorWidth === "full") return containerRef.current.clientWidth; const preset = PRESET_PX.find((p) => p.width === editorWidth); diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 2aa1d2db..e967129f 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,41 @@ 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 + .getSettings() + .then((settings) => + notesService.updateSettings({ + ...settings, + sidebarSortOrder: 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 +354,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()); + }); +}); diff --git a/src/components/layout/SidebarControls.tsx b/src/components/layout/SidebarControls.tsx new file mode 100644 index 00000000..dc17a851 --- /dev/null +++ b/src/components/layout/SidebarControls.tsx @@ -0,0 +1,74 @@ +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 ? ( + + ) : ( + + )} + + + + event.preventDefault()} + > + + 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..c40e1f83 --- /dev/null +++ b/src/components/layout/SidebarFolderSection.test.tsx @@ -0,0 +1,84 @@ +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + SidebarFolderSection, + loadFolderSectionCollapsed, + saveFolderSectionCollapsed, +} from "./SidebarFolderSection"; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }) + .IS_REACT_ACT_ENVIRONMENT = true; + +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"); + 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(container.querySelector('[data-testid="folder-group"]')).toBeNull(); + + 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..5431ebba --- /dev/null +++ b/src/components/layout/SidebarFolderSection.tsx @@ -0,0 +1,72 @@ +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 ( +
+ + {expanded &&
{children}
} +
+ ); +} diff --git a/src/components/notes/FolderTreeView.test.tsx b/src/components/notes/FolderTreeView.test.tsx new file mode 100644 index 00000000..ac9f83cd --- /dev/null +++ b/src/components/notes/FolderTreeView.test.tsx @@ -0,0 +1,67 @@ +import { DndContext } from "@dnd-kit/core"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { describe, expect, it, vi } from "vitest"; +import type { FolderNode } from "../../types/note"; +import { FolderItemComponent } from "./FolderTreeView"; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }) + .IS_REACT_ACT_ENVIRONMENT = true; + +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(); + }); +}); diff --git a/src/components/notes/FolderTreeView.tsx b/src/components/notes/FolderTreeView.tsx index 3f7998a3..d0b8a981 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) @@ -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..8d5a32b9 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 { sortNotesByModified } 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,11 @@ export function NoteList({ return notes; }, [searchQuery, searchResults, notes]); + const sortedDisplayItems = useMemo( + () => sortNotesByModified(displayItems, sortOrder), + [displayItems, sortOrder], + ); + // Listen for focus request from editor (when Escape is pressed) useEffect(() => { const handleFocusNoteList = () => { @@ -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) => ( (null); const [title, setTitle] = useState(""); const [modified, setModified] = useState(0); + const [revision, setRevision] = useState(""); const [hasExternalChanges, setHasExternalChanges] = useState(false); + const [hasSaveConflict, setHasSaveConflict] = useState(false); const [reloadVersion, setReloadVersion] = useState(0); const [focusMode, setFocusMode] = useState(false); - const recentlySavedRef = useRef(false); + const revisionRef = useRef(""); + const saveQueueRef = useRef(createSerializedTaskQueue()); + const persistenceControllerRef = useRef( + null, + ); + const closeInProgressRef = useRef(false); + const fileLoadGuardRef = useRef(createLatestRequestGuard()); + + const registerPersistenceController = useCallback( + (controller: EditorPersistenceController) => { + persistenceControllerRef.current = controller; + return () => { + if (persistenceControllerRef.current === controller) { + persistenceControllerRef.current = null; + } + }; + }, + [], + ); + + useEffect(() => { + let disposed = false; + let unlisten: (() => void) | undefined; + const appWindow = getCurrentWindow(); + + void resolveCloseListenerRegistration( + appWindow.onCloseRequested((event) => { + if (!beginSafeWindowClose(event, closeInProgressRef)) return; + + void runSafeWindowClose({ + flushDraft: () => + persistenceControllerRef.current?.flush() ?? Promise.resolve(), + persistRecovery: async () => { + const draft = persistenceControllerRef.current?.getDraft(); + if (!draft?.dirty) return { status: "not-needed" } as const; + const path = await notesService.persistRecoverySnapshot({ + noteId: filePath, + sourcePath: filePath, + content: draft.content, + reason: "standalone-window-close", + }); + return { status: "recovered", path } as const; + }, + beforeClose: async ({ recoveredTo, saveError }) => { + if (recoveredTo && saveError) { + recordPendingRecoveryNotice(recoveredTo, saveError); + } + }, + closeWindow: closeWindowAfterSave, + }).catch((error) => { + closeInProgressRef.current = false; + if (!disposed) { + toast.error( + `Window kept open because the draft could not be saved: ${error}`, + ); + } + }); + }), + (error) => { + if (!disposed) { + toast.error(`Safe window-close protection could not start: ${error}`); + } + }, + ).then((removeListener) => { + if (disposed) removeListener(); + else unlisten = removeListener; + }); + + return () => { + disposed = true; + unlisten?.(); + }; + }, [filePath]); // Load file on mount useEffect(() => { + let cancelled = false; + const isLatest = fileLoadGuardRef.current.begin(); + const isStale = () => cancelled || !isLatest(); filesService .readFileDirect(filePath) - .then((result) => { - setContent(result.content); + .then(async (result) => { + if (isStale()) return; + const checkpoint = await draftCheckpointService + .getDraftCheckpoint(filePath) + .catch(() => null); + if (isStale()) return; + const recovered = + checkpoint && checkpoint.markdown !== result.content + ? checkpoint.markdown + : result.content; + if (isStale()) return; + setContent(recovered); + if (isStale()) return; setTitle(result.title); + if (isStale()) return; setModified(result.modified); + const recoveryRevision = standaloneRecoveryBaseRevision( + result.revision, + result.content, + checkpoint, + ); + revisionRef.current = recoveryRevision; + if (isStale()) return; + setRevision(recoveryRevision); + if (checkpoint && checkpoint.markdown === result.content) { + await draftCheckpointService + .clearDraftCheckpoint(checkpoint.key) + .catch(() => undefined); + } else if (checkpoint) { + if (isStale()) return; + setHasExternalChanges(true); + if (isStale()) return; + setHasSaveConflict(true); + if (isStale()) return; + toast.warning("Recovered an unsaved draft from an interrupted session"); + } }) .catch((error) => { + if (isStale()) return; console.error("Failed to load file:", error); toast.error(`Failed to load file: ${error}`); }); + return () => { + cancelled = true; + fileLoadGuardRef.current.invalidate(); + }; }, [filePath]); // Listen for window focus to detect external changes useEffect(() => { const handleFocus = async () => { - if (recentlySavedRef.current) { - recentlySavedRef.current = false; - return; - } try { const result = await filesService.readFileDirect(filePath); - if (result.modified !== modified && content !== null) { + if (result.revision !== revisionRef.current && content !== null) { setHasExternalChanges(true); + if (persistenceControllerRef.current?.getDraft().dirty) { + setHasSaveConflict(true); + } } } catch { // File may have been deleted @@ -55,39 +196,137 @@ export function PreviewApp({ filePath }: PreviewAppProps) { }, [filePath, modified, content]); const save = useCallback( - async (newContent: string) => { - try { - const result = await filesService.saveFileDirect(filePath, newContent); - recentlySavedRef.current = true; - setModified(result.modified); - setTitle(result.title); - setHasExternalChanges(false); - } catch (error) { - console.error("Failed to save file:", error); - toast.error(`Failed to save: ${error}`); - } - }, + (newContent: string) => + saveQueueRef.current(async () => { + try { + if (!revisionRef.current) { + throw new Error("Missing base revision for standalone note"); + } + const result = await filesService.saveFileDirect( + filePath, + newContent, + revisionRef.current, + ); + if (result.status === "conflict") { + setHasExternalChanges(true); + setHasSaveConflict(true); + throw new Error("Save conflict: local draft was preserved"); + } + revisionRef.current = result.file.revision; + setRevision(result.file.revision); + setModified(result.file.modified); + setTitle(result.file.title); + setHasExternalChanges(false); + setHasSaveConflict(false); + } catch (error) { + console.error("Failed to save file:", error); + toast.error(`Failed to save: ${error}`); + throw error; + } + }), [filePath], ); const reload = useCallback(async () => { + const isLatest = fileLoadGuardRef.current.begin(); try { + await flushDirtyDraftBeforeReload(persistenceControllerRef.current); + if (!isLatest()) return; const result = await filesService.readFileDirect(filePath); + if (!isLatest()) return; setContent(result.content); setTitle(result.title); setModified(result.modified); + revisionRef.current = result.revision; + setRevision(result.revision); setHasExternalChanges(false); + setHasSaveConflict(false); setReloadVersion((v) => v + 1); } catch (error) { + if (!isLatest()) return; console.error("Failed to reload file:", error); toast.error(`Failed to reload: ${error}`); } }, [filePath]); + const resolveConflict = useCallback( + async (strategy: ConflictResolutionStrategy) => { + const draft = persistenceControllerRef.current?.getDraft(); + if (!draft) throw new Error("No open draft to resolve"); + + let remote: filesService.FileContent | null = null; + try { + remote = await filesService.readFileDirect(filePath); + } catch { + remote = null; + } + + const applyFile = (file: filesService.FileContent) => { + setContent(file.content); + setTitle(file.title); + setModified(file.modified); + revisionRef.current = file.revision; + setRevision(file.revision); + setHasExternalChanges(false); + setHasSaveConflict(false); + setReloadVersion((version) => version + 1); + }; + + await runConflictResolution( + strategy, + { draft, remote }, + { + persistRecovery: () => + notesService.persistRecoverySnapshot({ + noteId: filePath, + sourcePath: filePath, + content: draft.content, + reason: `standalone-conflict-${strategy}`, + }), + overwriteRemote: async (localDraft, current) => { + const result = await filesService.saveFileDirect( + filePath, + localDraft.content, + current.revision, + ); + if (result.status === "conflict") { + throw new Error("The disk version changed again; conflict preserved"); + } + applyFile(result.file); + }, + recreateDeleted: async (localDraft) => { + const recreated = await recreateDeletedStandaloneDraft( + filePath, + localDraft.content, + filesService.recreateFileDirect, + ); + applyFile(recreated); + }, + acceptRemote: async (current) => { + if (!current) { + throw new Error( + "Source file was deleted; local changes are safe in recovery storage", + ); + } + applyFile(current); + }, + }, + ); + await draftCheckpointService.clearDraftCheckpoint({ + windowLabel: "", + noteId: filePath, + }); + }, + [filePath], + ); + // Listen for preview-file-change events useEffect(() => { const unlisten = listen("preview-file-change", () => { setHasExternalChanges(true); + if (persistenceControllerRef.current?.getDraft().dirty) { + setHasSaveConflict(true); + } }); return () => { unlisten.then((fn) => fn()); @@ -179,10 +418,14 @@ export function PreviewApp({ filePath }: PreviewAppProps) { title, filePath, modified, + revision, hasExternalChanges, + hasSaveConflict, reloadVersion, save, reload, + resolveConflict, + registerPersistenceController, }; return ( diff --git a/src/components/settings/EditorSettingsSection.test.tsx b/src/components/settings/EditorSettingsSection.test.tsx new file mode 100644 index 00000000..ead5d157 --- /dev/null +++ b/src/components/settings/EditorSettingsSection.test.tsx @@ -0,0 +1,159 @@ +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { describe, expect, it, vi } from "vitest"; +import { + EditorToolbarVisibilityControl, + EditorWidthResizeControl, + TitleBarNoteInfoControls, +} from "./EditorSettingsSection"; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }) + .IS_REACT_ACT_ENVIRONMENT = true; + +describe("EditorWidthResizeControl", () => { + it("exposes the current state and lets the user disable mouse resizing", () => { + const onChange = vi.fn(); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + + act(() => { + root.render( + , + ); + }); + + const group = container.querySelector( + '[role="group"][aria-label="Resize editor with mouse"]', + ); + const [offButton, onButton] = Array.from( + container.querySelectorAll("button"), + ); + + expect(group).not.toBeNull(); + expect(offButton.textContent).toBe("Off"); + expect(offButton.getAttribute("aria-pressed")).toBe("false"); + expect(onButton.textContent).toBe("On"); + expect(onButton.getAttribute("aria-pressed")).toBe("true"); + + act(() => offButton.click()); + expect(onChange).toHaveBeenCalledWith(false); + + act(() => root.unmount()); + container.remove(); + }); +}); + +describe("EditorToolbarVisibilityControl", () => { + it("exposes the hidden default and lets the user show the toolbar", () => { + const onChange = vi.fn(); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + + act(() => { + root.render( + , + ); + }); + + const group = container.querySelector( + '[role="group"][aria-label="Show formatting toolbar"]', + ); + const [offButton, onButton] = Array.from( + container.querySelectorAll("button"), + ); + + expect(group).not.toBeNull(); + expect(offButton.textContent).toBe("Off"); + expect(offButton.getAttribute("aria-pressed")).toBe("true"); + expect(onButton.textContent).toBe("On"); + expect(onButton.getAttribute("aria-pressed")).toBe("false"); + + act(() => onButton.click()); + expect(onChange).toHaveBeenCalledWith(true); + + act(() => root.unmount()); + container.remove(); + }); +}); + +describe("TitleBarNoteInfoControls", () => { + it("offers one exclusive title-bar information menu", () => { + const onModifiedDateChange = vi.fn(); + const onFilenameChange = vi.fn(); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + + act(() => { + root.render( + , + ); + }); + + const select = container.querySelector( + 'select[aria-label="Title bar information"]', + ); + expect(select).not.toBeNull(); + expect(container.querySelectorAll("select")).toHaveLength(1); + expect(Array.from(select?.options ?? []).map((option) => option.text)).toEqual( + ["Modification Date", "Filename", "None"], + ); + expect(select?.value).toBe("modifiedDate"); + expect(container.textContent).not.toContain("On"); + expect(container.textContent).not.toContain("Off"); + + act(() => { + if (!select) return; + select.value = "filename"; + select.dispatchEvent(new Event("change", { bubbles: true })); + }); + expect(onFilenameChange).toHaveBeenCalledWith(true); + expect(onModifiedDateChange).not.toHaveBeenCalled(); + + act(() => root.unmount()); + container.remove(); + }); + + it("maps None to the single active persisted setting", () => { + const onModifiedDateChange = vi.fn(); + const onFilenameChange = vi.fn(); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + + act(() => { + root.render( + , + ); + }); + + const select = container.querySelector( + 'select[aria-label="Title bar information"]', + ); + expect(select?.value).toBe("filename"); + + act(() => { + if (!select) return; + select.value = "none"; + select.dispatchEvent(new Event("change", { bubbles: true })); + }); + + expect(onFilenameChange).toHaveBeenCalledWith(false); + expect(onModifiedDateChange).not.toHaveBeenCalled(); + + act(() => root.unmount()); + container.remove(); + }); +}); diff --git a/src/components/settings/EditorSettingsSection.tsx b/src/components/settings/EditorSettingsSection.tsx index 997e9180..96702692 100644 --- a/src/components/settings/EditorSettingsSection.tsx +++ b/src/components/settings/EditorSettingsSection.tsx @@ -56,6 +56,161 @@ const boldWeightOptions = [ { value: 800, label: "Extra Bold", excludeForMonospace: false }, ]; +interface EditorWidthResizeControlProps { + enabled: boolean; + onChange: (enabled: boolean) => void; +} + +interface BinarySettingControlProps { + label: string; + description: string; + ariaLabel: string; + value: boolean; + onChange: (value: boolean) => void; +} + +function BinarySettingControl({ + label, + description, + ariaLabel, + value, + onChange, +}: BinarySettingControlProps) { + return ( +
+
+
{label}
+

{description}

+
+
+ + +
+
+ ); +} + +export function EditorWidthResizeControl({ + enabled, + onChange, +}: EditorWidthResizeControlProps) { + return ( + + ); +} + +interface EditorToolbarVisibilityControlProps { + visible: boolean; + onChange: (visible: boolean) => void; +} + +export function EditorToolbarVisibilityControl({ + visible, + onChange, +}: EditorToolbarVisibilityControlProps) { + return ( + + ); +} + +interface TitleBarNoteInfoControlsProps { + modifiedDateVisible: boolean; + filenameVisible: boolean; + onModifiedDateChange: (visible: boolean) => void; + onFilenameChange: (visible: boolean) => void; +} + +type TitleBarNoteInfoMode = "modifiedDate" | "filename" | "none"; + +export function TitleBarNoteInfoControls({ + modifiedDateVisible, + filenameVisible, + onModifiedDateChange, + onFilenameChange, +}: TitleBarNoteInfoControlsProps) { + let mode: TitleBarNoteInfoMode = "none"; + if (filenameVisible) { + mode = "filename"; + } else if (modifiedDateVisible) { + mode = "modifiedDate"; + } + + const handleModeChange = (nextMode: TitleBarNoteInfoMode) => { + if (nextMode === "filename") { + onFilenameChange(true); + return; + } + + if (nextMode === "modifiedDate") { + onModifiedDateChange(true); + return; + } + + if (filenameVisible) { + onFilenameChange(false); + } else if (modifiedDateVisible) { + onModifiedDateChange(false); + } + }; + + return ( +
+
+
+ Title Bar Information +
+

+ Choose what appears beside the note icon +

+
+ +
+ ); +} + export function AppearanceSettingsSection() { const { theme, @@ -72,6 +227,14 @@ export function AppearanceSettingsSection() { setInterfaceZoom, customEditorWidthPx, setCustomEditorWidthPx, + editorWidthResizeEnabled, + setEditorWidthResizeEnabled, + editorToolbarVisible, + setEditorToolbarVisible, + titleBarModifiedDateVisible, + setTitleBarModifiedDateVisible, + titleBarFilenameVisible, + setTitleBarFilenameVisible, customColorsLight, customColorsDark, setCustomColor, @@ -92,14 +255,18 @@ export function AppearanceSettingsSection() { setEditorFontSetting(field, clamped); }; - // Check if settings differ from defaults - const hasCustomFonts = + // Check if appearance settings differ from defaults + const hasCustomAppearanceSettings = editorFontSettings.baseFontFamily !== "system-sans" || editorFontSettings.baseFontSize !== 15 || editorFontSettings.boldWeight !== 600 || editorFontSettings.lineHeight !== 1.6 || textDirection !== "auto" || editorWidth !== "normal" || + !editorWidthResizeEnabled || + editorToolbarVisible || + !titleBarModifiedDateVisible || + titleBarFilenameVisible || Math.round(interfaceZoom * 100) !== 100; // Filter weight options based on font family @@ -183,7 +350,7 @@ export function AppearanceSettingsSection() {

Typography

- {hasCustomFonts && ( + {hasCustomAppearanceSettings && ( @@ -324,6 +491,23 @@ export function AppearanceSettingsSection() {
)} + + + + + + {/* Interface Zoom */}