From a617a4e356ec121bec4582a2761d38eadf7e228a Mon Sep 17 00:00:00 2001 From: Norkep Date: Mon, 3 Aug 2026 17:05:59 +0200 Subject: [PATCH 01/13] feat: add sidebar sorting and collapse controls --- package-lock.json | 476 +++++++++++++++++- package.json | 8 +- src-tauri/src/lib.rs | 20 + src/components/layout/Sidebar.tsx | 38 ++ .../layout/SidebarControls.test.tsx | 65 +++ src/components/layout/SidebarControls.tsx | 74 +++ .../layout/SidebarFolderSection.test.tsx | 84 ++++ .../layout/SidebarFolderSection.tsx | 72 +++ src/components/notes/FolderTreeView.test.tsx | 67 +++ src/components/notes/FolderTreeView.tsx | 95 ++-- src/components/notes/NoteList.tsx | 17 +- src/lib/folderTree.test.ts | 99 ++++ src/lib/folderTree.ts | 65 ++- src/types/note.ts | 2 + vitest.config.ts | 10 + 15 files changed, 1140 insertions(+), 52 deletions(-) create mode 100644 src/components/layout/SidebarControls.test.tsx create mode 100644 src/components/layout/SidebarControls.tsx create mode 100644 src/components/layout/SidebarFolderSection.test.tsx create mode 100644 src/components/layout/SidebarFolderSection.tsx create mode 100644 src/components/notes/FolderTreeView.test.tsx create mode 100644 src/lib/folderTree.test.ts create mode 100644 vitest.config.ts diff --git a/package-lock.json b/package-lock.json index 55003d64..06ff2272 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56,9 +56,11 @@ "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^4.6.0", + "happy-dom": "20.11.1", "tailwindcss": "^4.1.18", "typescript": "~5.8.3", - "vite": "^7.0.4" + "vite": "^7.0.4", + "vitest": "4.1.10" } }, "node_modules/@babel/code-frame": { @@ -1981,6 +1983,13 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tailwindcss/node": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz", @@ -3239,6 +3248,24 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -3276,6 +3303,16 @@ "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", "license": "MIT" }, + "node_modules/@types/node": { + "version": "26.1.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz", + "integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -3305,6 +3342,23 @@ "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", "license": "MIT" }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -3326,6 +3380,119 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -3344,6 +3511,16 @@ "node": ">=10" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.0", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", @@ -3411,6 +3588,19 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer-image-size": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz", + "integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + }, + "engines": { + "node": ">=4.0" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001776", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001776.tgz", @@ -3432,6 +3622,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -3574,6 +3774,13 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", @@ -3638,6 +3845,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-equals": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", @@ -3706,6 +3933,38 @@ "dev": true, "license": "ISC" }, + "node_modules/happy-dom": { + "version": "20.11.1", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.11.1.tgz", + "integrity": "sha512-XSt8tMzbW9ymE7687xztkO1ckR7qJNQ3LywY9vlYGhGi3zXrGBHuUo2Cl1ztZaICW+1eAGdkLbj6iwVqDT33kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": ">=20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "@types/ws": "^8.18.1", + "buffer-image-size": "^0.6.4", + "entities": "^7.0.1", + "whatwg-mimetype": "^3.0.0", + "ws": "^8.21.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/happy-dom/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/highlight.js": { "version": "11.11.1", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", @@ -4151,12 +4410,33 @@ "dev": true, "license": "MIT" }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/orderedmap": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -4601,6 +4881,13 @@ "semver": "bin/semver.js" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/sonner": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", @@ -4621,6 +4908,20 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwind-merge": { "version": "3.5.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", @@ -4652,6 +4953,23 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -4669,6 +4987,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tippy.js": { "version": "6.3.7", "resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz", @@ -4704,6 +5032,13 @@ "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", "license": "MIT" }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -4869,12 +5204,151 @@ } } }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "license": "MIT" }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index fb889190..dae25d48 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,9 @@ "dev": "vite", "build": "tsc && vite build", "preview": "vite preview", - "tauri": "tauri" + "tauri": "tauri", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@dnd-kit/core": "^6.3.1", @@ -58,8 +60,10 @@ "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^4.6.0", + "happy-dom": "20.11.1", "tailwindcss": "^4.1.18", "typescript": "~5.8.3", - "vite": "^7.0.4" + "vite": "^7.0.4", + "vitest": "4.1.10" } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 72b6d15d..4352ba5b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -129,6 +129,8 @@ pub struct Settings { pub ollama_model: Option, #[serde(rename = "foldersEnabled")] pub folders_enabled: Option, + #[serde(rename = "sidebarSortOrder")] + pub sidebar_sort_order: Option, #[serde(rename = "ignoredPatterns")] pub ignored_patterns: Option>, #[serde(rename = "customColorsLight")] @@ -3994,3 +3996,21 @@ fn set_title_bar_theme( } Ok(()) } + +#[cfg(test)] +mod tests { + use super::Settings; + + #[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"); + } +} 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) => ( { + const notes = [note("middle", 20), note("oldest", 10), note("newest", 30)]; + + it("sorts newest first without mutating the source list", () => { + const result = sortNotesByModified(notes, "newest"); + + expect(result.map((item) => item.id)).toEqual([ + "newest", + "middle", + "oldest", + ]); + expect(notes.map((item) => item.id)).toEqual([ + "middle", + "oldest", + "newest", + ]); + }); + + it("sorts oldest first with a deterministic filename tie-break", () => { + const result = sortNotesByModified( + [note("z-last", 10), note("a-first", 10), note("newest", 30)], + "oldest", + ); + + expect(result.map((item) => item.id)).toEqual([ + "a-first", + "z-last", + "newest", + ]); + }); +}); + +describe("buildFolderTree note order", () => { + it("applies oldest-first ordering inside folders while keeping pinned notes first", () => { + const tree = buildFolderTree( + [ + note("docs/newest", 30), + note("docs/pinned", 20), + note("docs/oldest", 10), + ], + new Set(["docs/pinned"]), + ["docs"], + "oldest", + ); + + expect(tree.folders[0]?.notes.map((item) => item.id)).toEqual([ + "docs/pinned", + "docs/oldest", + "docs/newest", + ]); + }); +}); + +describe("getVisibleItemsForFolderSection", () => { + it("hides every folder item while keeping root notes keyboard-accessible", () => { + const pinnedIds = new Set(["pinned"]); + const tree = buildFolderTree( + [ + note("pinned", 30), + note("docs/inside", 20), + note("recent", 10), + ], + pinnedIds, + ["docs"], + ); + + expect( + getVisibleItemsForFolderSection(tree, pinnedIds, new Set(), false), + ).toEqual([ + { type: "note", id: "pinned" }, + { type: "folder", path: "docs" }, + { type: "note", id: "docs/inside" }, + { type: "note", id: "recent" }, + ]); + expect( + getVisibleItemsForFolderSection(tree, pinnedIds, new Set(), true), + ).toEqual([ + { type: "note", id: "pinned" }, + { type: "note", id: "recent" }, + ]); + }); +}); diff --git a/src/lib/folderTree.ts b/src/lib/folderTree.ts index a4a2d48d..269ee123 100644 --- a/src/lib/folderTree.ts +++ b/src/lib/folderTree.ts @@ -1,14 +1,40 @@ -import type { NoteMetadata, FolderNode } from "../types/note"; +import type { + NoteMetadata, + FolderNode, + NoteSortOrder, +} from "../types/note"; export interface FolderTreeData { rootNotes: NoteMetadata[]; folders: FolderNode[]; } +function compareNotesByModified( + first: Pick, + second: Pick, + sortOrder: NoteSortOrder, +): number { + const modifiedDifference = + sortOrder === "oldest" + ? first.modified - second.modified + : second.modified - first.modified; + + return modifiedDifference || first.id.localeCompare(second.id); +} + +export function sortNotesByModified< + T extends Pick, +>(notes: readonly T[], sortOrder: NoteSortOrder): T[] { + return [...notes].sort((first, second) => + compareNotesByModified(first, second, sortOrder), + ); +} + export function buildFolderTree( notes: NoteMetadata[], pinnedIds: Set, knownFolders?: string[], + sortOrder: NoteSortOrder = "newest", ): FolderTreeData { const rootNotes: NoteMetadata[] = []; const folderMap = new Map(); @@ -51,14 +77,16 @@ export function buildFolderTree( } } + const compareFolderNotes = (first: NoteMetadata, second: NoteMetadata) => { + const firstPinned = pinnedIds.has(first.id); + const secondPinned = pinnedIds.has(second.id); + if (firstPinned !== secondPinned) return firstPinned ? -1 : 1; + return compareNotesByModified(first, second, sortOrder); + }; + function sortNode(node: FolderNode) { node.children.sort((a, b) => a.name.localeCompare(b.name)); - node.notes.sort((a, b) => { - const ap = pinnedIds.has(a.id); - const bp = pinnedIds.has(b.id); - if (ap !== bp) return ap ? -1 : 1; - return b.modified - a.modified; - }); + node.notes.sort(compareFolderNotes); node.children.forEach(sortNode); } @@ -68,13 +96,8 @@ export function buildFolderTree( topLevelFolders.sort((a, b) => a.name.localeCompare(b.name)); topLevelFolders.forEach(sortNode); - // Sort root notes: pinned first, then by modified desc - rootNotes.sort((a, b) => { - const ap = pinnedIds.has(a.id); - const bp = pinnedIds.has(b.id); - if (ap !== bp) return ap ? -1 : 1; - return b.modified - a.modified; - }); + // Keep pinned notes first, then apply the selected date order. + rootNotes.sort(compareFolderNotes); return { rootNotes, folders: topLevelFolders }; } @@ -124,6 +147,20 @@ export function getVisibleItems( return items; } +/** Hide the folder branch from keyboard navigation when its section is closed. */ +export function getVisibleItemsForFolderSection( + tree: FolderTreeData, + pinnedIds: Set, + collapsedFolders: Set, + foldersSectionCollapsed: boolean, +): TreeItem[] { + return getVisibleItems( + foldersSectionCollapsed ? { ...tree, folders: [] } : tree, + pinnedIds, + collapsedFolders, + ); +} + export function countNotesInFolder(folder: FolderNode): number { let count = folder.notes.length; for (const child of folder.children) { diff --git a/src/types/note.ts b/src/types/note.ts index 387a9b61..366e1c49 100644 --- a/src/types/note.ts +++ b/src/types/note.ts @@ -20,6 +20,7 @@ export interface ThemeSettings { export type FontFamily = "system-sans" | "serif" | "monospace"; export type TextDirection = "auto" | "ltr" | "rtl"; export type EditorWidth = "narrow" | "normal" | "wide" | "full" | "custom"; +export type NoteSortOrder = "newest" | "oldest"; export interface EditorFontSettings { baseFontFamily?: FontFamily; @@ -49,6 +50,7 @@ export interface Settings { editorFont?: EditorFontSettings; gitEnabled?: boolean; foldersEnabled?: boolean; + sidebarSortOrder?: NoteSortOrder; pinnedNoteIds?: string[]; textDirection?: TextDirection; editorWidth?: EditorWidth; diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 00000000..3894773c --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,10 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [react()], + test: { + environment: "happy-dom", + include: ["src/**/*.test.{ts,tsx}"], + }, +}); From 661b6000b062984cef1430c4a1f195628c4557dc Mon Sep 17 00:00:00 2001 From: Norkep Date: Mon, 3 Aug 2026 17:14:47 +0200 Subject: [PATCH 02/13] feat: add editor display and title-bar settings --- src-tauri/src/lib.rs | 33 +++ src/components/editor/Editor.tsx | 56 ++++-- .../editor/EditorWidthHandle.test.tsx | 54 +++++ src/components/editor/EditorWidthHandle.tsx | 50 +++-- .../settings/EditorSettingsSection.test.tsx | 159 +++++++++++++++ .../settings/EditorSettingsSection.tsx | 190 +++++++++++++++++- src/context/ThemeContext.tsx | 125 +++++++++++- src/lib/editorToolbar.test.ts | 13 ++ src/lib/editorToolbar.ts | 9 + src/lib/editorWidthResize.test.ts | 13 ++ src/lib/editorWidthResize.ts | 9 + src/lib/titleBarNoteInfo.test.ts | 117 +++++++++++ src/lib/titleBarNoteInfo.ts | 70 +++++++ src/types/note.ts | 4 + 14 files changed, 862 insertions(+), 40 deletions(-) create mode 100644 src/components/editor/EditorWidthHandle.test.tsx create mode 100644 src/components/settings/EditorSettingsSection.test.tsx create mode 100644 src/lib/editorToolbar.test.ts create mode 100644 src/lib/editorToolbar.ts create mode 100644 src/lib/editorWidthResize.test.ts create mode 100644 src/lib/editorWidthResize.ts create mode 100644 src/lib/titleBarNoteInfo.test.ts create mode 100644 src/lib/titleBarNoteInfo.ts diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4352ba5b..336cb03c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -122,6 +122,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, @@ -4013,4 +4021,29 @@ mod tests { 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); + } } diff --git a/src/components/editor/Editor.tsx b/src/components/editor/Editor.tsx index 32a8159c..a095d144 100644 --- a/src/components/editor/Editor.tsx +++ b/src/components/editor/Editor.tsx @@ -71,6 +71,7 @@ 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 { Button, IconButton, ToolbarButton, Tooltip } from "../ui"; import * as notesService from "../../services/notes"; import { downloadPdf, downloadMarkdown } from "../../services/pdf"; @@ -546,12 +547,28 @@ export function Editor({ 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); // 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(() => { @@ -2257,9 +2274,11 @@ export function Editor({ )} - - {formatDateTime(currentNote.modified)} - + {titleBarNoteInfo && ( + + {titleBarNoteInfo} + + )}
{/* 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/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 */}
- { - editorRef.current = editor; - }} + onEditorReady={(editor) => { + editorRef.current = editor; + }} + onPersistenceControllerReady={ + handlePersistenceControllerReady + } /> )} @@ -635,8 +678,21 @@ function UpdateToast({ ); } +function PreferencesApp() { + const keepPreferencesOpen = useCallback(() => {}, []); + useWindowShortcuts({ onOpenPreferences: keepPreferencesOpen }); + + return ( + + + + + + ); +} + function App() { - const { isPreview, previewFile } = useMemo(getWindowMode, []); + const { isPreview, isPreferences, previewFile } = useMemo(getWindowMode, []); // Cmd/Ctrl+W — close window (works in both preview and folder mode) useEffect(() => { @@ -658,10 +714,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) { diff --git a/src/components/editor/Editor.tsx b/src/components/editor/Editor.tsx index a095d144..e6efcae2 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"; @@ -72,8 +73,15 @@ 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 { Button, IconButton, ToolbarButton, Tooltip } from "../ui"; import * as notesService from "../../services/notes"; +import * as draftCheckpointService from "../../services/draftCheckpoint"; +import { + createDraftCheckpointScheduler, + nextCheckpointCaptureDelay, + type DraftCheckpointScheduler, +} from "../../lib/draftCheckpoint"; import { downloadPdf, downloadMarkdown } from "../../services/pdf"; import type { Settings } from "../../types/note"; import { @@ -429,15 +437,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 { @@ -448,6 +471,9 @@ interface EditorProps { onEditorReady?: (editor: TiptapEditor | null) => void; onSaveToFolder?: () => void; saveToFolderDisabled?: boolean; + onPersistenceControllerReady?: ( + controller: EditorPersistenceController | null, + ) => void; } /** @@ -511,6 +537,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(); @@ -523,6 +550,7 @@ export function Editor({ content: previewMode.content, path: previewMode.filePath, modified: previewMode.modified, + revision: previewMode.revision, } : null : (notesCtx?.currentNote ?? null); @@ -538,9 +566,13 @@ 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; @@ -555,6 +587,7 @@ export function Editor({ 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); @@ -585,6 +618,9 @@ export function Editor({ const [sourceMode, setSourceMode] = useState(false); 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; @@ -601,6 +637,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); @@ -609,6 +664,7 @@ 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; @@ -617,6 +673,7 @@ export function Editor({ // Keep ref in sync with current note ID currentNoteIdRef.current = currentNote?.id ?? null; + sourceContentRef.current = sourceContent; // Get markdown from editor const getMarkdown = useCallback( @@ -749,8 +806,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); } @@ -767,11 +824,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(() => { @@ -783,6 +848,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) { @@ -791,12 +859,137 @@ 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"); + } } }, 500); - }, [saveImmediately, getMarkdown, currentNote?.id]); + }, [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 () => { + if (sourceNeedsSaveRef.current) { + await flushSourceSave(); + return; + } + await flushPendingSave(); + }, [flushPendingSave, flushSourceSave]); + + const getOpenDraftSnapshot = useCallback(() => { + const noteId = loadedNoteIdRef.current ?? currentNoteIdRef.current; + if (sourceMode) { + return { + noteId, + content: sourceContentRef.current, + dirty: sourceNeedsSaveRef.current, + }; + } + return { + noteId, + content: editorRef.current ? getMarkdown(editorRef.current) : "", + dirty: needsSaveRef.current, + }; + }, [getMarkdown, sourceMode]); + + const persistCurrentCrashCheckpoint = useCallback(async () => { + const draft = getOpenDraftSnapshot(); + if (!draft.dirty || !draft.noteId || !currentNote) return; + checkpointScheduler.markDirty({ + key: { + windowLabel: getCurrentWindow().label, + noteId: draft.noteId, + }, + markdown: draft.content, + metadata: { + sourcePath: currentNote.path, + baseRevision: currentNote.revision ?? null, + updatedAt: new Date().toISOString(), + }, + }); + await checkpointScheduler.flush(); + }, [checkpointScheduler, currentNote, getOpenDraftSnapshot]); + + 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) { @@ -1596,21 +1789,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(); @@ -2138,15 +2329,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"); @@ -2156,7 +2361,7 @@ export function Editor({ } }, 300); }, - [currentNote, saveNote], + [checkpointScheduler, currentNote, saveNote], ); if (!currentNote) { @@ -2283,7 +2488,52 @@ export function Editor({
- {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 ? ( @@ -2301,6 +2551,15 @@ export function Editor({
+ ) : isDirty ? ( + +
+ +
+
) : (
diff --git a/src/components/preview/PreviewApp.tsx b/src/components/preview/PreviewApp.tsx index 5afe21e9..bc76b369 100644 --- a/src/components/preview/PreviewApp.tsx +++ b/src/components/preview/PreviewApp.tsx @@ -2,30 +2,129 @@ import { useState, useCallback, useRef, useEffect } from "react"; import { listen } from "@tauri-apps/api/event"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { toast } from "sonner"; -import { Editor, type PreviewModeData } from "../editor/Editor"; +import { + Editor, + type EditorPersistenceController, + type PreviewModeData, +} from "../editor/Editor"; import * as filesService from "../../services/files"; +import * as notesService from "../../services/notes"; +import * as draftCheckpointService from "../../services/draftCheckpoint"; +import { createSerializedTaskQueue } from "../../lib/serializedWriter"; +import { runSafeWindowClose } from "../../lib/windowClose"; +import { flushDirtyDraftBeforeReload } from "../../lib/standaloneReload"; +import { recreateDeletedStandaloneDraft } from "../../lib/standaloneRecreation"; +import { + runConflictResolution, + type ConflictResolutionStrategy, +} from "../../lib/conflictResolution"; +import { + closeWindowAfterSave, + openPreferencesWindow, +} from "../../services/windowLifecycle"; +import { useWindowShortcuts } from "../../lib/useWindowShortcuts"; interface PreviewAppProps { filePath: string; } - export function PreviewApp({ filePath }: PreviewAppProps) { + useWindowShortcuts({ onOpenPreferences: openPreferencesWindow }); const [content, setContent] = useState(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 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(); + + appWindow.onCloseRequested((event) => { + if (closeInProgressRef.current) return; + event.preventDefault(); + closeInProgressRef.current = true; + + void runSafeWindowClose({ + flushDraft: () => + persistenceControllerRef.current?.flush() ?? Promise.resolve(), + persistRecovery: async () => { + const draft = persistenceControllerRef.current?.getDraft(); + if (!draft?.dirty) return undefined; + return notesService.persistRecoverySnapshot({ + noteId: filePath, + sourcePath: filePath, + content: draft.content, + reason: "standalone-window-close", + }); + }, + closeWindow: closeWindowAfterSave, + }).catch((error) => { + closeInProgressRef.current = false; + if (!disposed) { + toast.error( + `Window kept open because the draft could not be saved: ${error}`, + ); + } + }); + }).then((removeListener) => { + if (disposed) removeListener(); + else unlisten = removeListener; + }); + + return () => { + disposed = true; + unlisten?.(); + }; + }, [filePath]); // Load file on mount useEffect(() => { filesService .readFileDirect(filePath) - .then((result) => { - setContent(result.content); + .then(async (result) => { + const checkpoint = await draftCheckpointService + .getDraftCheckpoint(filePath) + .catch(() => null); + const recovered = + checkpoint && checkpoint.markdown !== result.content + ? checkpoint.markdown + : result.content; + setContent(recovered); setTitle(result.title); setModified(result.modified); + revisionRef.current = result.revision; + setRevision(result.revision); + if (checkpoint && checkpoint.markdown === result.content) { + await draftCheckpointService + .clearDraftCheckpoint(checkpoint.key) + .catch(() => undefined); + } else if (checkpoint) { + setHasExternalChanges(true); + setHasSaveConflict(true); + toast.warning("Recovered an unsaved draft from an interrupted session"); + } }) .catch((error) => { console.error("Failed to load file:", error); @@ -36,14 +135,13 @@ export function PreviewApp({ filePath }: PreviewAppProps) { // 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,28 +153,48 @@ 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 () => { try { + await flushDirtyDraftBeforeReload(persistenceControllerRef.current); const result = await filesService.readFileDirect(filePath); 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) { console.error("Failed to reload file:", error); @@ -84,10 +202,84 @@ export function PreviewApp({ filePath }: PreviewAppProps) { } }, [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 +371,14 @@ export function PreviewApp({ filePath }: PreviewAppProps) { title, filePath, modified, + revision, hasExternalChanges, + hasSaveConflict, reloadVersion, save, reload, + resolveConflict, + registerPersistenceController, }; return ( diff --git a/src/components/settings/SettingsPage.test.tsx b/src/components/settings/SettingsPage.test.tsx new file mode 100644 index 00000000..34e6b271 --- /dev/null +++ b/src/components/settings/SettingsPage.test.tsx @@ -0,0 +1,76 @@ +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { TooltipProvider } from "../ui"; +import { SettingsPage } from "./SettingsPage"; + +vi.mock("./GeneralSettingsSection", () => ({ + GeneralSettingsSection: () =>
General settings
, +})); +vi.mock("./EditorSettingsSection", () => ({ + AppearanceSettingsSection: () =>
Appearance settings
, +})); +vi.mock("./ShortcutsSettingsSection", () => ({ + ShortcutsSettingsSection: () =>
Shortcut settings
, +})); +vi.mock("./AboutSettingsSection", () => ({ + AboutSettingsSection: () =>
About settings
, +})); +vi.mock("./ToolsSettingsSection", () => ({ + ToolsSettingsSection: () =>
Tool settings
, +})); + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }) + .IS_REACT_ACT_ENVIRONMENT = true; + +afterEach(() => { + document.body.replaceChildren(); +}); + +describe("SettingsPage navigation context", () => { + it("shows Back when Settings replace the main editor", () => { + const onBack = vi.fn(); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + + act(() => + root.render( + + + , + ), + ); + + const backButton = container.querySelector( + 'button[aria-label^="Back"]', + ); + expect(backButton).not.toBeNull(); + act(() => backButton?.click()); + expect(onBack).toHaveBeenCalledOnce(); + + act(() => root.unmount()); + }); + + it("hides Back when Settings are the root of a dedicated window", () => { + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + + act(() => + root.render( + + + , + ), + ); + + expect(container.textContent).toContain("Settings"); + expect(container.querySelector('button[aria-label^="Back"]')).toBeNull(); + expect(container.querySelectorAll("[data-tauri-drag-region]")).toHaveLength( + 2, + ); + + act(() => root.unmount()); + }); +}); diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx index a6bde4ce..e885af3c 100644 --- a/src/components/settings/SettingsPage.tsx +++ b/src/components/settings/SettingsPage.tsx @@ -16,9 +16,8 @@ import { ToolsSettingsSection } from "./ToolsSettingsSection"; import { mod, isMac, isWindows } from "../../lib/platform"; interface SettingsPageProps { - onBack: () => void; + onBack?: () => void; } - type SettingsTab = "general" | "tools" | "editor" | "shortcuts" | "about"; const tabs: { @@ -81,13 +80,15 @@ export function SettingsPage({ onBack }: SettingsPageProps) { {/* Header with back button and Settings title */}
-
- - - +
+ {onBack && ( + + + + )}
Settings
diff --git a/src/lib/conflictResolution.test.ts b/src/lib/conflictResolution.test.ts new file mode 100644 index 00000000..81c17629 --- /dev/null +++ b/src/lib/conflictResolution.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it, vi } from "vitest"; +import { runConflictResolution } from "./conflictResolution"; + +const draft = { content: "# Plan\n\nLocal", dirty: true }; +const remote = { content: "# Plan\n\nRemote", revision: "remote-2" }; + +describe("runConflictResolution", () => { + it("checkpoints the local draft before explicitly keeping it", async () => { + const order: string[] = []; + + await runConflictResolution("keepLocal", { draft, remote }, { + persistRecovery: async () => { + order.push("recovery"); + return "/recovery/Plan.md"; + }, + overwriteRemote: async () => { + order.push("overwrite"); + }, + recreateDeleted: async () => { + order.push("recreate"); + }, + acceptRemote: async () => { + order.push("accept"); + }, + }); + + expect(order).toEqual(["recovery", "overwrite"]); + }); + + it("checkpoints the draft before accepting the disk version", async () => { + const order: string[] = []; + + await runConflictResolution("useRemote", { draft, remote }, { + persistRecovery: async () => { + order.push("recovery"); + return "/recovery/Plan.md"; + }, + overwriteRemote: async () => undefined, + recreateDeleted: async () => undefined, + acceptRemote: async () => { + order.push("accept"); + }, + }); + + expect(order).toEqual(["recovery", "accept"]); + }); + + it("recreates a deleted note only after recovery", async () => { + const recreateDeleted = vi.fn(async () => undefined); + + await runConflictResolution("keepLocal", { draft, remote: null }, { + persistRecovery: async () => "/recovery/Plan.md", + overwriteRemote: async () => undefined, + recreateDeleted, + acceptRemote: async () => undefined, + }); + + expect(recreateDeleted).toHaveBeenCalledWith(draft); + }); + + it("aborts resolution when a dirty draft cannot be recovered", async () => { + const overwriteRemote = vi.fn(async () => undefined); + + await expect( + runConflictResolution("keepLocal", { draft, remote }, { + persistRecovery: async () => undefined, + overwriteRemote, + recreateDeleted: async () => undefined, + acceptRemote: async () => undefined, + }), + ).rejects.toThrow("Recovery snapshot was not created"); + + expect(overwriteRemote).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/conflictResolution.ts b/src/lib/conflictResolution.ts new file mode 100644 index 00000000..5edf1f33 --- /dev/null +++ b/src/lib/conflictResolution.ts @@ -0,0 +1,43 @@ +export type ConflictResolutionStrategy = "keepLocal" | "useRemote"; + +export interface ConflictDraft { + content: string; + dirty: boolean; +} + +export interface ConflictRemote { + content: string; + revision: string; +} + +export async function runConflictResolution( + strategy: ConflictResolutionStrategy, + state: { draft: ConflictDraft; remote: Remote | null }, + actions: { + persistRecovery: () => Promise; + overwriteRemote: ( + draft: ConflictDraft, + remote: Remote, + ) => Promise; + recreateDeleted: (draft: ConflictDraft) => Promise; + acceptRemote: (remote: Remote | null) => Promise; + }, +): Promise { + if (state.draft.dirty) { + const recoveryPath = await actions.persistRecovery(); + if (!recoveryPath) { + throw new Error("Recovery snapshot was not created"); + } + } + + if (strategy === "useRemote") { + await actions.acceptRemote(state.remote); + return; + } + + if (state.remote) { + await actions.overwriteRemote(state.draft, state.remote); + } else { + await actions.recreateDeleted(state.draft); + } +} diff --git a/src/lib/draftCheckpoint.test.ts b/src/lib/draftCheckpoint.test.ts new file mode 100644 index 00000000..d07d5624 --- /dev/null +++ b/src/lib/draftCheckpoint.test.ts @@ -0,0 +1,248 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + createDraftCheckpointScheduler, + nextCheckpointCaptureDelay, + reconcileDraftCheckpoint, + type DraftCheckpoint, + type DraftCheckpointKey, +} from "./draftCheckpoint"; +import type { Note } from "../types/note"; + +const key: DraftCheckpointKey = { + windowLabel: "main-window", + noteId: "notes/plan", +}; + +function checkpoint(markdown: string, updatedAt: string): DraftCheckpoint { + return { + key, + markdown, + metadata: { + sourcePath: "/workspace/Notes/Plan.md", + baseRevision: "revision-before-edit", + updatedAt, + }, + }; +} + +function deferred(): { + promise: Promise; + resolve: () => void; +} { + let resolve = () => {}; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("nextCheckpointCaptureDelay", () => { + it("keeps a trailing delay but guarantees a checkpoint during continuous typing", () => { + expect(nextCheckpointCaptureDelay(0, 250, 750)).toBe(250); + expect(nextCheckpointCaptureDelay(400, 250, 750)).toBe(250); + expect(nextCheckpointCaptureDelay(600, 250, 750)).toBe(150); + expect(nextCheckpointCaptureDelay(750, 250, 750)).toBe(0); + }); +}); + +describe("createDraftCheckpointScheduler", () => { + it("writes the exact latest dirty draft at the trailing edge", async () => { + vi.useFakeTimers(); + const write = vi.fn(async () => undefined); + const scheduler = createDraftCheckpointScheduler( + { write, clear: vi.fn(async () => undefined) }, + { delayMs: 1_000 }, + ); + const first = checkpoint("first", "2026-08-01T12:00:00Z"); + const latest = checkpoint( + "# Exact\n\ntrailing spaces \n☕\n", + "2026-08-01T12:00:00.700Z", + ); + + scheduler.markDirty(first); + await vi.advanceTimersByTimeAsync(700); + scheduler.markDirty(latest); + latest.markdown = "mutated by caller after scheduling"; + await vi.advanceTimersByTimeAsync(999); + expect(write).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + expect(write).toHaveBeenCalledOnce(); + expect(write).toHaveBeenCalledWith({ + key, + markdown: "# Exact\n\ntrailing spaces \n☕\n", + metadata: { + sourcePath: "/workspace/Notes/Plan.md", + baseRevision: "revision-before-edit", + updatedAt: "2026-08-01T12:00:00.700Z", + }, + }); + }); + + it("coalesces edits made during an in-flight write without overlapping writes", async () => { + const firstWrite = deferred(); + const started: string[] = []; + let activeWrites = 0; + let maximumActiveWrites = 0; + const write = vi.fn(async (draft: DraftCheckpoint) => { + started.push(draft.markdown); + activeWrites += 1; + maximumActiveWrites = Math.max(maximumActiveWrites, activeWrites); + if (draft.markdown === "first") await firstWrite.promise; + activeWrites -= 1; + }); + const scheduler = createDraftCheckpointScheduler( + { write, clear: vi.fn(async () => undefined) }, + { delayMs: 1_000 }, + ); + + scheduler.markDirty(checkpoint("first", "2026-08-01T12:00:00Z")); + const firstFlush = scheduler.flush(); + await Promise.resolve(); + scheduler.markDirty(checkpoint("second", "2026-08-01T12:00:01Z")); + scheduler.markDirty(checkpoint("latest", "2026-08-01T12:00:02Z")); + const latestFlush = scheduler.flush(); + + expect(started).toEqual(["first"]); + firstWrite.resolve(); + await Promise.all([firstFlush, latestFlush]); + + expect(started).toEqual(["first", "latest"]); + expect(maximumActiveWrites).toBe(1); + }); + + it("clears the selected checkpoint after a successful normal save", async () => { + vi.useFakeTimers(); + const write = vi.fn(async () => undefined); + const clear = vi.fn(async () => undefined); + const scheduler = createDraftCheckpointScheduler( + { write, clear }, + { delayMs: 1_000 }, + ); + scheduler.markDirty(checkpoint("saved normally", "2026-08-01T12:00:00Z")); + + await scheduler.handleSaveOutcome("saved", key); + await vi.runAllTimersAsync(); + + expect(clear).toHaveBeenCalledOnce(); + expect(clear).toHaveBeenCalledWith(key); + expect(write).not.toHaveBeenCalled(); + }); + + it("never clears a checkpoint when the normal save conflicts", async () => { + vi.useFakeTimers(); + const write = vi.fn(async () => undefined); + const clear = vi.fn(async () => undefined); + const expected = checkpoint("local conflict", "2026-08-01T12:00:00Z"); + const scheduler = createDraftCheckpointScheduler( + { write, clear }, + { delayMs: 1_000 }, + ); + scheduler.markDirty(expected); + + await scheduler.handleSaveOutcome("conflict", key); + await vi.advanceTimersByTimeAsync(1_000); + + expect(clear).not.toHaveBeenCalled(); + expect(write).toHaveBeenCalledWith(expected); + }); + + it("flushes the pending draft immediately when visibility becomes hidden", async () => { + vi.useFakeTimers(); + const write = vi.fn(async () => undefined); + const expected = checkpoint("hidden draft", "2026-08-01T12:00:00Z"); + const scheduler = createDraftCheckpointScheduler( + { write, clear: vi.fn(async () => undefined) }, + { delayMs: 30_000 }, + ); + scheduler.markDirty(expected); + + await scheduler.handleVisibilityChange("hidden"); + + expect(write).toHaveBeenCalledOnce(); + expect(write).toHaveBeenCalledWith(expected); + }); + + it("retains a failed checkpoint so a later flush can retry it exactly", async () => { + const expected = checkpoint("retry me", "2026-08-01T12:00:00Z"); + const write = vi + .fn<(draft: DraftCheckpoint) => Promise>() + .mockRejectedValueOnce(new Error("app data unavailable")) + .mockResolvedValueOnce(undefined); + const scheduler = createDraftCheckpointScheduler( + { write, clear: vi.fn(async () => undefined) }, + { delayMs: 1_000 }, + ); + scheduler.markDirty(expected); + + await expect(scheduler.flush()).rejects.toThrow("app data unavailable"); + await expect(scheduler.flush()).resolves.toBeUndefined(); + + expect(write).toHaveBeenCalledTimes(2); + expect(write).toHaveBeenNthCalledWith(1, expected); + expect(write).toHaveBeenNthCalledWith(2, expected); + }); + + it("flushes a pending checkpoint before disposal", async () => { + vi.useFakeTimers(); + const write = vi.fn(async () => undefined); + const expected = checkpoint("dispose safely", "2026-08-01T12:00:00Z"); + const scheduler = createDraftCheckpointScheduler( + { write, clear: vi.fn(async () => undefined) }, + { delayMs: 30_000 }, + ); + scheduler.markDirty(expected); + + await scheduler.dispose(); + await vi.runAllTimersAsync(); + + expect(write).toHaveBeenCalledOnce(); + expect(write).toHaveBeenCalledWith(expected); + }); +}); + +describe("reconcileDraftCheckpoint", () => { + const diskNote: Note = { + id: "notes/plan", + title: "Plan", + content: "# Plan\n\nSaved", + path: "/workspace/Notes/Plan.md", + modified: 1, + revision: "disk-revision", + }; + + it("restores exact unsaved Markdown while retaining disk as conflict authority", () => { + const recovered = checkpoint( + "# Plan\n\nUnsaved local text \n", + "2026-08-01T12:00:00Z", + ); + + expect(reconcileDraftCheckpoint(diskNote, recovered)).toEqual({ + note: { + ...diskNote, + content: "# Plan\n\nUnsaved local text \n", + }, + remote: diskNote, + recovered: true, + shouldClear: false, + }); + }); + + it("clears a checkpoint already identical to durable Markdown", () => { + const alreadySaved = checkpoint( + diskNote.content, + "2026-08-01T12:00:00Z", + ); + + expect(reconcileDraftCheckpoint(diskNote, alreadySaved)).toEqual({ + note: diskNote, + remote: null, + recovered: false, + shouldClear: true, + }); + }); +}); diff --git a/src/lib/draftCheckpoint.ts b/src/lib/draftCheckpoint.ts new file mode 100644 index 00000000..18bf2cf1 --- /dev/null +++ b/src/lib/draftCheckpoint.ts @@ -0,0 +1,176 @@ +export interface DraftCheckpointKey { + windowLabel: string; + noteId: string; +} + +export interface DraftCheckpointMetadata { + sourcePath: string; + baseRevision: string | null; + updatedAt: string; +} + +export interface DraftCheckpoint { + key: DraftCheckpointKey; + markdown: string; + metadata: DraftCheckpointMetadata; +} + +export interface DraftCheckpointStorage { + write(checkpoint: DraftCheckpoint): Promise; + clear(key: DraftCheckpointKey): Promise; +} + +export type DraftCheckpointSaveOutcome = "saved" | "conflict"; +export type DraftCheckpointVisibility = "hidden" | "visible"; + +export interface DraftCheckpointScheduler { + markDirty(checkpoint: DraftCheckpoint): void; + flush(): Promise; + handleSaveOutcome( + outcome: DraftCheckpointSaveOutcome, + key: DraftCheckpointKey, + ): Promise; + handleVisibilityChange(visibility: DraftCheckpointVisibility): Promise; + dispose(): Promise; +} + +export interface DraftCheckpointSchedulerOptions { + delayMs?: number; + onError?: (error: unknown) => void; +} + +const DEFAULT_DELAY_MS = 1_000; + +export function nextCheckpointCaptureDelay( + elapsedMs: number, + trailingDelayMs: number, + maximumWaitMs: number, +): number { + return Math.max( + 0, + Math.min(trailingDelayMs, maximumWaitMs - Math.max(0, elapsedMs)), + ); +} + +export function createDraftCheckpointScheduler( + storage: DraftCheckpointStorage, + options: DraftCheckpointSchedulerOptions = {}, +): DraftCheckpointScheduler { + const delayMs = options.delayMs ?? DEFAULT_DELAY_MS; + const onError = options.onError ?? (() => {}); + let pending: DraftCheckpoint | undefined; + let timer: ReturnType | undefined; + let operationTail: Promise = Promise.resolve(); + let disposed = false; + + const cancelTimer = () => { + if (timer === undefined) return; + clearTimeout(timer); + timer = undefined; + }; + + const enqueue = (operation: () => Promise): Promise => { + const result = operationTail.then(operation); + operationTail = result.then( + () => undefined, + () => undefined, + ); + return result; + }; + + const flush = (): Promise => { + cancelTimer(); + const checkpoint = pending; + if (!checkpoint) return operationTail; + pending = undefined; + + return enqueue(async () => { + try { + await storage.write(checkpoint); + } catch (error) { + pending ??= checkpoint; + throw error; + } + }); + }; + + const schedule = () => { + cancelTimer(); + timer = setTimeout(() => { + timer = undefined; + void flush().catch(onError); + }, delayMs); + }; + + return { + markDirty(checkpoint) { + if (disposed) throw new Error("Draft checkpoint scheduler is disposed"); + pending = cloneCheckpoint(checkpoint); + schedule(); + }, + + flush, + + handleSaveOutcome(outcome, keyToClear) { + if (outcome === "conflict") return operationTail; + if (pending && keysEqual(pending.key, keyToClear)) { + pending = undefined; + cancelTimer(); + } + return enqueue(() => storage.clear(cloneKey(keyToClear))); + }, + + handleVisibilityChange(visibility) { + if (visibility !== "hidden") return operationTail; + return flush(); + }, + + dispose() { + disposed = true; + return flush(); + }, + }; +} + +function cloneCheckpoint(checkpoint: DraftCheckpoint): DraftCheckpoint { + return { + key: cloneKey(checkpoint.key), + markdown: checkpoint.markdown, + metadata: { ...checkpoint.metadata }, + }; +} + +function cloneKey(key: DraftCheckpointKey): DraftCheckpointKey { + return { ...key }; +} + +function keysEqual(left: DraftCheckpointKey, right: DraftCheckpointKey): boolean { + return left.windowLabel === right.windowLabel && left.noteId === right.noteId; +} + +export function reconcileDraftCheckpoint( + diskNote: Note, + checkpoint: DraftCheckpoint, +): { + note: Note; + remote: Note | null; + recovered: boolean; + shouldClear: boolean; +} { + if (checkpoint.markdown === diskNote.content) { + return { + note: diskNote, + remote: null, + recovered: false, + shouldClear: true, + }; + } + + return { + note: { ...diskNote, content: checkpoint.markdown }, + remote: diskNote, + recovered: true, + shouldClear: false, + }; +} +import type { Note } from "../types/note"; diff --git a/src/lib/serializedWriter.test.ts b/src/lib/serializedWriter.test.ts new file mode 100644 index 00000000..e6d77a6c --- /dev/null +++ b/src/lib/serializedWriter.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it, vi } from "vitest"; +import { + createSerializedTaskQueue, + createSerializedUpdater, + createSerializedWriter, +} from "./serializedWriter"; + +function deferred(): { + promise: Promise; + resolve: () => void; +} { + let resolve = () => {}; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +describe("createSerializedWriter", () => { + it("finishes writes in request order even when the first write is delayed", async () => { + const firstWriteGate = deferred(); + const started: string[] = []; + const finished: string[] = []; + const write = vi.fn(async (value: string) => { + started.push(value); + if (value === "first") await firstWriteGate.promise; + finished.push(value); + }); + const serializedWrite = createSerializedWriter(write); + + const first = serializedWrite("first"); + const second = serializedWrite("second"); + await Promise.resolve(); + + expect(started).toEqual(["first"]); + expect(finished).toEqual([]); + + firstWriteGate.resolve(); + await Promise.all([first, second]); + + expect(started).toEqual(["first", "second"]); + expect(finished).toEqual(["first", "second"]); + }); + + it("continues after a failed write", async () => { + const finished: string[] = []; + const onError = vi.fn(); + const serializedWrite = createSerializedWriter(async (value: string) => { + if (value === "first") throw new Error("write failed"); + finished.push(value); + }, onError); + + await serializedWrite("first"); + await serializedWrite("second"); + + expect(onError).toHaveBeenCalledOnce(); + expect(finished).toEqual(["second"]); + }); +}); + +describe("createSerializedUpdater", () => { + it("applies a reset after an already queued setting change", async () => { + const firstWriteGate = deferred(); + let writeCount = 0; + let stored = { mode: "date", font: "custom" }; + const updateSettings = createSerializedUpdater( + async () => stored, + async (next) => { + writeCount += 1; + if (writeCount === 1) await firstWriteGate.promise; + stored = next; + }, + ); + + const change = updateSettings((current) => ({ + ...current, + mode: "filename", + })); + const reset = updateSettings(() => ({ mode: "date", font: "default" })); + await Promise.resolve(); + + firstWriteGate.resolve(); + await Promise.all([change, reset]); + + expect(stored).toEqual({ mode: "date", font: "default" }); + }); +}); + +describe("createSerializedTaskQueue", () => { + it("returns each task result while preventing overlapping saves", async () => { + const firstWriteGate = deferred(); + const started: string[] = []; + const enqueue = createSerializedTaskQueue(); + + const first = enqueue(async () => { + started.push("first"); + await firstWriteGate.promise; + return "revision-2"; + }); + const second = enqueue(async () => { + started.push("second"); + return "revision-3"; + }); + await Promise.resolve(); + + expect(started).toEqual(["first"]); + firstWriteGate.resolve(); + + await expect(first).resolves.toBe("revision-2"); + await expect(second).resolves.toBe("revision-3"); + expect(started).toEqual(["first", "second"]); + }); + + it("propagates one conflict and still lets a later recovery task run", async () => { + const enqueue = createSerializedTaskQueue(); + const conflict = new Error("revision conflict"); + + const first = enqueue(async () => { + throw conflict; + }); + const second = enqueue(async () => "recovered"); + + await expect(first).rejects.toBe(conflict); + await expect(second).resolves.toBe("recovered"); + }); +}); diff --git a/src/lib/serializedWriter.ts b/src/lib/serializedWriter.ts new file mode 100644 index 00000000..c637de1d --- /dev/null +++ b/src/lib/serializedWriter.ts @@ -0,0 +1,41 @@ +export type SerializedWriter = (value: T) => Promise; +export type SerializedUpdater = ( + update: (current: T) => T, +) => Promise; +export type SerializedTaskQueue = (task: () => Promise) => Promise; + +export function createSerializedTaskQueue(): SerializedTaskQueue { + let tail: Promise = Promise.resolve(); + + return (task: () => Promise): Promise => { + const result = tail.then(task); + tail = result.then( + () => undefined, + () => undefined, + ); + return result; + }; +} + +export function createSerializedWriter( + write: (value: T) => Promise, + onError: (error: unknown) => void = () => {}, +): SerializedWriter { + let queue = Promise.resolve(); + + return (value: T) => { + queue = queue.then(() => write(value)).catch(onError); + return queue; + }; +} + +export function createSerializedUpdater( + read: () => Promise, + write: (value: T) => Promise, + onError: (error: unknown) => void = () => {}, +): SerializedUpdater { + return createSerializedWriter(async (update: (current: T) => T) => { + const current = await read(); + await write(update(current)); + }, onError); +} diff --git a/src/lib/standaloneRecreation.test.ts b/src/lib/standaloneRecreation.test.ts new file mode 100644 index 00000000..de9f14e4 --- /dev/null +++ b/src/lib/standaloneRecreation.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from "vitest"; +import { recreateDeletedStandaloneDraft } from "./standaloneRecreation"; + +describe("recreateDeletedStandaloneDraft", () => { + it("returns the saved file snapshot after create-only recreation", async () => { + const recreate = vi.fn(async () => ({ + status: "saved" as const, + file: { + path: "/tmp/Deleted.md", + content: "# Deleted\n\nLocal draft", + title: "Deleted", + modified: 2, + revision: "created-revision", + }, + })); + + const file = await recreateDeletedStandaloneDraft( + "/tmp/Deleted.md", + "# Deleted\n\nLocal draft", + recreate, + ); + + expect(file.revision).toBe("created-revision"); + expect(recreate).toHaveBeenCalledWith( + "/tmp/Deleted.md", + "# Deleted\n\nLocal draft", + ); + }); + + it("rejects a concurrent recreation so the conflict UI and recovery remain", async () => { + const recreate = vi.fn(async () => ({ + status: "conflict" as const, + current: { + content: "# Deleted\n\nRecreated elsewhere", + revision: "concurrent-revision", + }, + })); + + await expect( + recreateDeletedStandaloneDraft( + "/tmp/Deleted.md", + "# Deleted\n\nLocal draft", + recreate, + ), + ).rejects.toThrow("source path was recreated elsewhere"); + }); +}); diff --git a/src/lib/standaloneRecreation.ts b/src/lib/standaloneRecreation.ts new file mode 100644 index 00000000..b994daf9 --- /dev/null +++ b/src/lib/standaloneRecreation.ts @@ -0,0 +1,20 @@ +import type { FileContent, FileSaveResult } from "../services/files"; + +type RecreateFile = ( + path: string, + content: string, +) => Promise; + +export async function recreateDeletedStandaloneDraft( + path: string, + content: string, + recreateFile: RecreateFile, +): Promise { + const result = await recreateFile(path, content); + if (result.status === "conflict") { + throw new Error( + "The source path was recreated elsewhere; conflict preserved", + ); + } + return result.file; +} diff --git a/src/lib/standaloneReload.test.ts b/src/lib/standaloneReload.test.ts new file mode 100644 index 00000000..2bda9c40 --- /dev/null +++ b/src/lib/standaloneReload.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from "vitest"; +import { flushDirtyDraftBeforeReload } from "./standaloneReload"; + +describe("flushDirtyDraftBeforeReload", () => { + it("flushes a dirty standalone draft before disk content may replace it", async () => { + const flush = vi.fn(async () => undefined); + + await flushDirtyDraftBeforeReload({ + flush, + getDraft: () => ({ noteId: "/note.md", content: "local", dirty: true }), + }); + + expect(flush).toHaveBeenCalledTimes(1); + }); + + it("does not manufacture a write for a clean standalone note", async () => { + const flush = vi.fn(async () => undefined); + + await flushDirtyDraftBeforeReload({ + flush, + getDraft: () => ({ noteId: "/note.md", content: "disk", dirty: false }), + }); + + expect(flush).not.toHaveBeenCalled(); + }); + + it("propagates a revision conflict so caller cannot reload over local text", async () => { + await expect( + flushDirtyDraftBeforeReload({ + flush: async () => { + throw new Error("revision conflict"); + }, + getDraft: () => ({ noteId: "/note.md", content: "local", dirty: true }), + }), + ).rejects.toThrow("revision conflict"); + }); +}); diff --git a/src/lib/standaloneReload.ts b/src/lib/standaloneReload.ts new file mode 100644 index 00000000..aa8a26f8 --- /dev/null +++ b/src/lib/standaloneReload.ts @@ -0,0 +1,12 @@ +export interface ReloadPersistenceController { + flush: () => Promise; + getDraft: () => { dirty: boolean }; +} + +export async function flushDirtyDraftBeforeReload( + controller: ReloadPersistenceController | null, +): Promise { + if (controller?.getDraft().dirty) { + await controller.flush(); + } +} diff --git a/src/lib/useWindowShortcuts.ts b/src/lib/useWindowShortcuts.ts new file mode 100644 index 00000000..1b8b8bce --- /dev/null +++ b/src/lib/useWindowShortcuts.ts @@ -0,0 +1,50 @@ +import { useEffect, useRef } from "react"; +import { toast } from "sonner"; +import { useTheme } from "../context/ThemeContext"; +import { resolveWindowShortcut } from "./windowShortcuts"; + +interface UseWindowShortcutsOptions { + onOpenPreferences: () => void | Promise; +} + +export function useWindowShortcuts({ + onOpenPreferences, +}: UseWindowShortcutsOptions): void { + const { interfaceZoom, setInterfaceZoom } = useTheme(); + const interfaceZoomRef = useRef(interfaceZoom); + const openPreferencesRef = useRef(onOpenPreferences); + interfaceZoomRef.current = interfaceZoom; + openPreferencesRef.current = onOpenPreferences; + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + const action = resolveWindowShortcut(event); + if (!action) return; + event.preventDefault(); + + if (action === "preferences") { + void openPreferencesRef.current(); + return; + } + + if (action === "zoom-reset") { + setInterfaceZoom(1); + toast("Zoom 100%", { id: "zoom", duration: 1500 }); + return; + } + + const delta = action === "zoom-in" ? 0.05 : -0.05; + const next = Math.round( + Math.min(Math.max(interfaceZoomRef.current + delta, 0.7), 1.5) * 20, + ) / 20; + setInterfaceZoom(next); + toast(`Zoom ${Math.round(next * 100)}%`, { + id: "zoom", + duration: 1500, + }); + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [setInterfaceZoom]); +} diff --git a/src/lib/windowClose.test.ts b/src/lib/windowClose.test.ts new file mode 100644 index 00000000..3f1cd6b3 --- /dev/null +++ b/src/lib/windowClose.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it, vi } from "vitest"; +import { runSafeWindowClose } from "./windowClose"; + +describe("runSafeWindowClose", () => { + it("requests a native close only after the pending draft is durably flushed", async () => { + const order: string[] = []; + + await runSafeWindowClose({ + flushDraft: async () => { + order.push("flush"); + }, + persistRecovery: async () => { + order.push("recovery"); + return undefined; + }, + closeWindow: async () => { + order.push("close"); + }, + }); + + expect(order).toEqual(["flush", "close"]); + }); + + it("persists recovery before closing when normal save conflicts", async () => { + const order: string[] = []; + + const result = await runSafeWindowClose({ + flushDraft: async () => { + order.push("flush"); + throw new Error("revision conflict"); + }, + persistRecovery: async () => { + order.push("recovery"); + return "/recovery/Plan.md"; + }, + closeWindow: async () => { + order.push("close"); + }, + }); + + expect(order).toEqual(["flush", "recovery", "close"]); + expect(result).toEqual({ + recoveredTo: "/recovery/Plan.md", + saveError: expect.any(Error), + }); + }); + + it("keeps the window open when both save and recovery fail", async () => { + const closeWindow = vi.fn(async () => undefined); + + await expect( + runSafeWindowClose({ + flushDraft: async () => { + throw new Error("storage offline"); + }, + persistRecovery: async () => { + throw new Error("recovery storage offline"); + }, + closeWindow, + }), + ).rejects.toThrow("recovery storage offline"); + + expect(closeWindow).not.toHaveBeenCalled(); + }); + + it("does not treat a native close failure as a save failure", async () => { + const persistRecovery = vi.fn(async () => "/recovery/Plan.md"); + const closeWindow = vi.fn(async () => { + throw new Error("native close failed"); + }); + + await expect( + runSafeWindowClose({ + flushDraft: async () => undefined, + persistRecovery, + closeWindow, + }), + ).rejects.toThrow("native close failed"); + + expect(persistRecovery).not.toHaveBeenCalled(); + expect(closeWindow).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/lib/windowClose.ts b/src/lib/windowClose.ts new file mode 100644 index 00000000..8a3beeb3 --- /dev/null +++ b/src/lib/windowClose.ts @@ -0,0 +1,26 @@ +export interface SafeWindowCloseDependencies { + flushDraft: () => Promise; + persistRecovery: () => Promise; + closeWindow: () => Promise; +} + +export interface SafeWindowCloseResult { + recoveredTo?: string; + saveError?: unknown; +} + +export async function runSafeWindowClose( + dependencies: SafeWindowCloseDependencies, +): Promise { + let result: SafeWindowCloseResult = {}; + + try { + await dependencies.flushDraft(); + } catch (saveError) { + const recoveredTo = await dependencies.persistRecovery(); + result = { recoveredTo, saveError }; + } + + await dependencies.closeWindow(); + return result; +} diff --git a/src/lib/windowCloseCallsites.test.ts b/src/lib/windowCloseCallsites.test.ts new file mode 100644 index 00000000..3b44d4ce --- /dev/null +++ b/src/lib/windowCloseCallsites.test.ts @@ -0,0 +1,21 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +const closeHandlers = [ + resolve(process.cwd(), "src/App.tsx"), + resolve(process.cwd(), "src/components/preview/PreviewApp.tsx"), +]; + +describe("safe window close call sites", () => { + it.each(closeHandlers)( + "uses the interceptable close command instead of force-destroying %s", + (sourcePath) => { + const source = readFileSync(sourcePath, "utf8"); + + expect(source).toContain("closeWindowAfterSave"); + expect(source).not.toContain("appWindow.close()"); + expect(source).not.toContain("appWindow.destroy()"); + }, + ); +}); diff --git a/src/lib/windowShortcutCallsites.test.ts b/src/lib/windowShortcutCallsites.test.ts new file mode 100644 index 00000000..e348a8d8 --- /dev/null +++ b/src/lib/windowShortcutCallsites.test.ts @@ -0,0 +1,32 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +const readSource = (path: string) => + readFileSync(resolve(process.cwd(), path), "utf8"); + +describe("global shortcut call sites", () => { + it("shares one shortcut hook between full and standalone editors", () => { + expect(readSource("src/App.tsx")).toContain("useWindowShortcuts"); + expect(readSource("src/components/preview/PreviewApp.tsx")).toContain( + "useWindowShortcuts", + ); + }); + + it("recognizes a dedicated preferences window mode", () => { + const source = readSource("src/App.tsx"); + expect(source).toContain('mode === "preferences"'); + expect(source).toContain(" { + const source = readSource("src/App.tsx"); + const preferencesStart = source.indexOf("function PreferencesApp()"); + const preferencesEnd = source.indexOf("function App()", preferencesStart); + const preferencesSource = source.slice(preferencesStart, preferencesEnd); + + expect(source).toContain(""); + expect(preferencesSource).toContain(""); + expect(preferencesSource).not.toContain("onBack="); + }); +}); diff --git a/src/lib/windowShortcuts.test.ts b/src/lib/windowShortcuts.test.ts new file mode 100644 index 00000000..2167dbdf --- /dev/null +++ b/src/lib/windowShortcuts.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { resolveWindowShortcut } from "./windowShortcuts"; + +describe("resolveWindowShortcut", () => { + it.each([ + ["=", false, "zoom-in"], + ["+", true, "zoom-in"], + ["-", false, "zoom-out"], + ["_", true, "zoom-out"], + ["0", false, "zoom-reset"], + [",", false, "preferences"], + ] as const)("maps Cmd+%s to %s", (key, shiftKey, expected) => { + expect( + resolveWindowShortcut({ + key, + metaKey: true, + ctrlKey: false, + shiftKey, + altKey: false, + }), + ).toBe(expected); + }); + + it("ignores unmodified keys and Option-modified commands", () => { + expect( + resolveWindowShortcut({ + key: "+", + metaKey: false, + ctrlKey: false, + shiftKey: true, + altKey: false, + }), + ).toBeNull(); + expect( + resolveWindowShortcut({ + key: "+", + metaKey: true, + ctrlKey: false, + shiftKey: true, + altKey: true, + }), + ).toBeNull(); + }); +}); diff --git a/src/lib/windowShortcuts.ts b/src/lib/windowShortcuts.ts new file mode 100644 index 00000000..29b2e3ab --- /dev/null +++ b/src/lib/windowShortcuts.ts @@ -0,0 +1,25 @@ +export type WindowShortcutAction = + | "zoom-in" + | "zoom-out" + | "zoom-reset" + | "preferences"; + +export interface WindowShortcutEvent { + key: string; + metaKey: boolean; + ctrlKey: boolean; + shiftKey: boolean; + altKey: boolean; +} + +export function resolveWindowShortcut( + event: WindowShortcutEvent, +): WindowShortcutAction | null { + if (!(event.metaKey || event.ctrlKey) || event.altKey) return null; + + if (event.key === "=" || event.key === "+") return "zoom-in"; + if (event.key === "-" || event.key === "_") return "zoom-out"; + if (event.key === "0") return "zoom-reset"; + if (event.key === ",") return "preferences"; + return null; +} diff --git a/src/services/draftCheckpoint.test.ts b/src/services/draftCheckpoint.test.ts new file mode 100644 index 00000000..39bb6150 --- /dev/null +++ b/src/services/draftCheckpoint.test.ts @@ -0,0 +1,56 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { invokeMock } = vi.hoisted(() => ({ invokeMock: vi.fn() })); +vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock })); + +import { + clearDraftCheckpoint, + getDraftCheckpoint, + listDraftCheckpoints, + writeDraftCheckpoint, +} from "./draftCheckpoint"; + +const checkpoint = { + key: { windowLabel: "main", noteId: "Plan" }, + markdown: "# Plan\n\nUnsaved", + metadata: { + sourcePath: "/notes/Plan.md", + baseRevision: "revision-1", + updatedAt: "2026-08-01T12:00:00.000Z", + }, +}; + +describe("draft checkpoint service", () => { + beforeEach(() => { + invokeMock.mockReset(); + invokeMock.mockResolvedValue(undefined); + }); + + it("lets backend derive trusted window identity from the caller", async () => { + await writeDraftCheckpoint(checkpoint); + + expect(invokeMock).toHaveBeenCalledWith("write_draft_checkpoint", { + noteId: "Plan", + markdown: checkpoint.markdown, + metadata: checkpoint.metadata, + }); + }); + + it("reads and clears only the caller's checkpoint for one note", async () => { + await getDraftCheckpoint("Plan"); + await clearDraftCheckpoint({ windowLabel: "spoofed", noteId: "Plan" }); + + expect(invokeMock).toHaveBeenNthCalledWith(1, "get_draft_checkpoint", { + noteId: "Plan", + }); + expect(invokeMock).toHaveBeenNthCalledWith(2, "clear_draft_checkpoint", { + noteId: "Plan", + }); + }); + + it("lists only checkpoints scoped by backend to the caller window", async () => { + await listDraftCheckpoints(); + + expect(invokeMock).toHaveBeenCalledWith("list_draft_checkpoints"); + }); +}); diff --git a/src/services/draftCheckpoint.ts b/src/services/draftCheckpoint.ts new file mode 100644 index 00000000..df425ae4 --- /dev/null +++ b/src/services/draftCheckpoint.ts @@ -0,0 +1,31 @@ +import { invoke } from "@tauri-apps/api/core"; +import type { + DraftCheckpoint, + DraftCheckpointKey, +} from "../lib/draftCheckpoint"; + +export async function writeDraftCheckpoint( + checkpoint: DraftCheckpoint, +): Promise { + return invoke("write_draft_checkpoint", { + noteId: checkpoint.key.noteId, + markdown: checkpoint.markdown, + metadata: checkpoint.metadata, + }); +} + +export async function getDraftCheckpoint( + noteId: string, +): Promise { + return invoke("get_draft_checkpoint", { noteId }); +} + +export async function clearDraftCheckpoint( + key: DraftCheckpointKey, +): Promise { + return invoke("clear_draft_checkpoint", { noteId: key.noteId }); +} + +export async function listDraftCheckpoints(): Promise { + return invoke("list_draft_checkpoints"); +} diff --git a/src/services/files.test.ts b/src/services/files.test.ts new file mode 100644 index 00000000..605af1ec --- /dev/null +++ b/src/services/files.test.ts @@ -0,0 +1,71 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { invokeMock } = vi.hoisted(() => ({ invokeMock: vi.fn() })); + +vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock })); + +import { recreateFileDirect, saveFileDirect } from "./files"; + +describe("saveFileDirect", () => { + beforeEach(() => { + invokeMock.mockReset(); + }); + + it("sends the revision loaded by the standalone editor", async () => { + invokeMock.mockResolvedValueOnce({ + status: "saved", + file: { + path: "/tmp/External.md", + content: "# External\n\nUpdated", + title: "External", + modified: 1, + revision: "revision-2", + }, + }); + + await saveFileDirect( + "/tmp/External.md", + "# External\n\nUpdated", + "revision-1", + ); + + expect(invokeMock).toHaveBeenCalledWith("save_file_direct", { + path: "/tmp/External.md", + content: "# External\n\nUpdated", + expectedRevision: "revision-1", + }); + }); +}); + +describe("recreateFileDirect", () => { + beforeEach(() => { + invokeMock.mockReset(); + }); + + it("requests a create-only recreation without an expected revision", async () => { + invokeMock.mockResolvedValueOnce({ + status: "saved", + file: { + path: "/tmp/Deleted.md", + content: "# Deleted\n\nRecovered draft", + title: "Deleted", + modified: 2, + revision: "recreated-revision", + }, + }); + + const result = await recreateFileDirect( + "/tmp/Deleted.md", + "# Deleted\n\nRecovered draft", + ); + + expect(invokeMock).toHaveBeenCalledWith("recreate_file_direct", { + path: "/tmp/Deleted.md", + content: "# Deleted\n\nRecovered draft", + }); + expect(result).toMatchObject({ + status: "saved", + file: { revision: "recreated-revision" }, + }); + }); +}); diff --git a/src/services/files.ts b/src/services/files.ts index 6e536ef6..95a06a0e 100644 --- a/src/services/files.ts +++ b/src/services/files.ts @@ -5,7 +5,14 @@ export interface FileContent { content: string; title: string; modified: number; + revision: string; } +export type FileSaveResult = + | { status: "saved"; file: FileContent } + | { + status: "conflict"; + current: { content: string; revision: string } | null; + }; export async function readFileDirect(path: string): Promise { return invoke("read_file_direct", { path }); @@ -14,8 +21,16 @@ export async function readFileDirect(path: string): Promise { export async function saveFileDirect( path: string, content: string, -): Promise { - return invoke("save_file_direct", { path, content }); + expectedRevision: string, +): Promise { + return invoke("save_file_direct", { path, content, expectedRevision }); +} + +export async function recreateFileDirect( + path: string, + content: string, +): Promise { + return invoke("recreate_file_direct", { path, content }); } export async function openFilePreview(path: string): Promise { diff --git a/src/services/notes.ts b/src/services/notes.ts index c4632627..57516c05 100644 --- a/src/services/notes.ts +++ b/src/services/notes.ts @@ -21,6 +21,19 @@ export async function saveNote(id: string | null, content: string): Promise { + return invoke("persist_recovery_snapshot", { ...input }); +} + export async function deleteNote(id: string): Promise { return invoke("delete_note", { id }); } diff --git a/src/services/windowLifecycle.test.ts b/src/services/windowLifecycle.test.ts new file mode 100644 index 00000000..e30e6884 --- /dev/null +++ b/src/services/windowLifecycle.test.ts @@ -0,0 +1,21 @@ +import { invoke } from "@tauri-apps/api/core"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { closeWindowAfterSave } from "./windowLifecycle"; + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: vi.fn(), +})); + +describe("closeWindowAfterSave", () => { + beforeEach(() => { + vi.mocked(invoke).mockReset(); + }); + + it("delegates the final close to the trusted Rust lifecycle command", async () => { + vi.mocked(invoke).mockResolvedValue(undefined); + + await closeWindowAfterSave(); + + expect(invoke).toHaveBeenCalledWith("close_window_after_save"); + }); +}); diff --git a/src/services/windowLifecycle.ts b/src/services/windowLifecycle.ts new file mode 100644 index 00000000..6c1e362a --- /dev/null +++ b/src/services/windowLifecycle.ts @@ -0,0 +1,14 @@ +import { invoke } from "@tauri-apps/api/core"; + +/** + * Completes a close that the frontend already intercepted, flushed, and approved. + * Rust owns the final destruction so no force-destroy Window plugin permission is + * exposed to the WebView. + */ +export async function closeWindowAfterSave(): Promise { + await invoke("close_window_after_save"); +} + +export async function openPreferencesWindow(): Promise { + await invoke("open_preferences_window"); +} diff --git a/src/types/note.ts b/src/types/note.ts index b72883b2..b2f0ada1 100644 --- a/src/types/note.ts +++ b/src/types/note.ts @@ -11,6 +11,8 @@ export interface Note { content: string; path: string; modified: number; + /** Content revision is present for standalone optimistic-concurrency saves. */ + revision?: string; } export interface ThemeSettings { From 12d9f2284fd7f2c7917fc88a256327c455c8357e Mon Sep 17 00:00:00 2001 From: Norkep Date: Tue, 4 Aug 2026 11:42:40 +0200 Subject: [PATCH 04/13] fix(recovery): tolerate damaged checkpoints --- src-tauri/src/draft_checkpoint.rs | 18 +++++++++++++++--- src/lib/windowShortcutCallsites.test.ts | 2 ++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/draft_checkpoint.rs b/src-tauri/src/draft_checkpoint.rs index 56c213af..b26cfb14 100644 --- a/src-tauri/src/draft_checkpoint.rs +++ b/src-tauri/src/draft_checkpoint.rs @@ -118,12 +118,24 @@ pub fn list_checkpoints( let mut checkpoints = Vec::new(); for entry in entries { - let path = entry?.path(); + let path = match entry { + Ok(entry) => entry.path(), + Err(_) => continue, + }; if path.extension().and_then(|extension| extension.to_str()) != Some("json") { continue; } - let checkpoint: DraftCheckpoint = serde_json::from_slice(&fs::read(&path)?)?; - ensure_identity_matches(&path, &checkpoint)?; + 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); } diff --git a/src/lib/windowShortcutCallsites.test.ts b/src/lib/windowShortcutCallsites.test.ts index e348a8d8..5fcda828 100644 --- a/src/lib/windowShortcutCallsites.test.ts +++ b/src/lib/windowShortcutCallsites.test.ts @@ -22,7 +22,9 @@ describe("global shortcut call sites", () => { it("keeps Back only for in-window Settings navigation", () => { const source = readSource("src/App.tsx"); const preferencesStart = source.indexOf("function PreferencesApp()"); + expect(preferencesStart).toBeGreaterThanOrEqual(0); const preferencesEnd = source.indexOf("function App()", preferencesStart); + expect(preferencesEnd).toBeGreaterThan(preferencesStart); const preferencesSource = source.slice(preferencesStart, preferencesEnd); expect(source).toContain(""); From b119b2aabb2eef32a552656e84763d1691a31ff3 Mon Sep 17 00:00:00 2001 From: Norkep Date: Tue, 4 Aug 2026 11:43:49 +0200 Subject: [PATCH 05/13] fix(persistence): support create-only writes without hard links --- src-tauri/src/persistence.rs | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/persistence.rs b/src-tauri/src/persistence.rs index b70aaab2..3c0e09ce 100644 --- a/src-tauri/src/persistence.rs +++ b/src-tauri/src/persistence.rs @@ -181,6 +181,8 @@ fn atomic_write(path: &Path, bytes: &[u8]) -> io::Result<()> { /// 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( @@ -195,7 +197,30 @@ fn atomic_create_new(path: &Path, bytes: &[u8]) -> io::Result<()> { temporary_file.sync_all()?; drop(temporary_file); - fs::hard_link(temporary_path.path(), path)?; + 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(_) => {} + } + + let create_result = OpenOptions::new() + .write(true) + .create_new(true) + .open(path) + .and_then(|mut file| file.write_all(bytes).and_then(|()| file.sync_all())); + + if let Err(error) = create_result { + let _ = fs::remove_file(path); + return Err(error); + } + fs::remove_file(temporary_path.path())?; temporary_path.commit(); sync_parent_directory(parent)?; From 68f9efa34f22039cd93866d8dde102b9fc4376d7 Mon Sep 17 00:00:00 2001 From: Norkep Date: Tue, 4 Aug 2026 11:55:13 +0200 Subject: [PATCH 06/13] fix(preview): ignore stale asynchronous file loads --- src/components/preview/PreviewApp.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/components/preview/PreviewApp.tsx b/src/components/preview/PreviewApp.tsx index bc76b369..13860638 100644 --- a/src/components/preview/PreviewApp.tsx +++ b/src/components/preview/PreviewApp.tsx @@ -101,35 +101,49 @@ export function PreviewApp({ filePath }: PreviewAppProps) { // Load file on mount useEffect(() => { + let cancelled = false; filesService .readFileDirect(filePath) .then(async (result) => { + if (cancelled) return; const checkpoint = await draftCheckpointService .getDraftCheckpoint(filePath) .catch(() => null); + if (cancelled) return; const recovered = checkpoint && checkpoint.markdown !== result.content ? checkpoint.markdown : result.content; + if (cancelled) return; setContent(recovered); + if (cancelled) return; setTitle(result.title); + if (cancelled) return; setModified(result.modified); revisionRef.current = result.revision; + if (cancelled) return; setRevision(result.revision); if (checkpoint && checkpoint.markdown === result.content) { await draftCheckpointService .clearDraftCheckpoint(checkpoint.key) .catch(() => undefined); } else if (checkpoint) { + if (cancelled) return; setHasExternalChanges(true); + if (cancelled) return; setHasSaveConflict(true); + if (cancelled) return; toast.warning("Recovered an unsaved draft from an interrupted session"); } }) .catch((error) => { + if (cancelled) return; console.error("Failed to load file:", error); toast.error(`Failed to load file: ${error}`); }); + return () => { + cancelled = true; + }; }, [filePath]); // Listen for window focus to detect external changes From 0a324bb45734354a0196fed096a3414e786fc97f Mon Sep 17 00:00:00 2001 From: Norkep Date: Tue, 4 Aug 2026 11:56:04 +0200 Subject: [PATCH 07/13] fix(shortcuts): remove render-time refs and report failures --- src/App.tsx | 33 ++++++++++++++++++++++----------- src/lib/useWindowShortcuts.ts | 16 ++++++++-------- 2 files changed, 30 insertions(+), 19 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 7aebaed2..60597e5d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -175,25 +175,36 @@ function AppContent() { }); }, [selectedNoteId]); + 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 === "notes") { - try { - await persistenceControllerRef.current?.flush(); - } catch (error) { - toast.error(`Settings not opened: ${error}`); - return; - } + if (view === "settings") { + setView("notes"); + return; } - setView((previous) => - previous === "settings" ? "notes" : "settings", - ); + try { + await persistenceControllerRef.current?.flush(); + } catch (error) { + toast.error(`Settings not opened: ${error}`); + return; + } + setView("settings"); }, [view]); const closeSettings = useCallback(() => { setView("notes"); }, []); - useWindowShortcuts({ onOpenPreferences: toggleSettings }); + useWindowShortcuts({ onOpenPreferences: openSettings }); // Go back to command palette from AI modal const handleBackToPalette = useCallback(() => { diff --git a/src/lib/useWindowShortcuts.ts b/src/lib/useWindowShortcuts.ts index 1b8b8bce..9ed2fa3c 100644 --- a/src/lib/useWindowShortcuts.ts +++ b/src/lib/useWindowShortcuts.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef } from "react"; +import { useEffect } from "react"; import { toast } from "sonner"; import { useTheme } from "../context/ThemeContext"; import { resolveWindowShortcut } from "./windowShortcuts"; @@ -11,10 +11,6 @@ export function useWindowShortcuts({ onOpenPreferences, }: UseWindowShortcutsOptions): void { const { interfaceZoom, setInterfaceZoom } = useTheme(); - const interfaceZoomRef = useRef(interfaceZoom); - const openPreferencesRef = useRef(onOpenPreferences); - interfaceZoomRef.current = interfaceZoom; - openPreferencesRef.current = onOpenPreferences; useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { @@ -23,7 +19,11 @@ export function useWindowShortcuts({ event.preventDefault(); if (action === "preferences") { - void openPreferencesRef.current(); + void Promise.resolve(onOpenPreferences()).catch((error) => { + toast.error( + `Failed to open Preferences: ${error instanceof Error ? error.message : String(error)}`, + ); + }); return; } @@ -35,7 +35,7 @@ export function useWindowShortcuts({ const delta = action === "zoom-in" ? 0.05 : -0.05; const next = Math.round( - Math.min(Math.max(interfaceZoomRef.current + delta, 0.7), 1.5) * 20, + Math.min(Math.max(interfaceZoom + delta, 0.7), 1.5) * 20, ) / 20; setInterfaceZoom(next); toast(`Zoom ${Math.round(next * 100)}%`, { @@ -46,5 +46,5 @@ export function useWindowShortcuts({ window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [setInterfaceZoom]); + }, [interfaceZoom, onOpenPreferences, setInterfaceZoom]); } From fec9317069981b2292122b2c06c78a11e8321557 Mon Sep 17 00:00:00 2001 From: Norkep Date: Tue, 4 Aug 2026 11:58:37 +0200 Subject: [PATCH 08/13] fix(window-close): keep windows open without recovery --- src/App.tsx | 4 ++-- src/lib/windowClose.ts | 5 ++++- src/lib/windowCloseCallsites.test.ts | 9 +++++++++ src/services/windowLifecycle.ts | 11 ++++++++++- 4 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 60597e5d..c33c9f62 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -36,7 +36,7 @@ import * as aiService from "./services/ai"; import * as notesService from "./services/notes"; import type { AiProvider } from "./services/ai"; import { isMac, isWindows } from "./lib/platform"; -import { closeWindowAfterSave } from "./services/windowLifecycle"; +import { closeWindowAfterSave, requestCurrentWindowClose } from "./services/windowLifecycle"; import { useWindowShortcuts } from "./lib/useWindowShortcuts"; import { runSafeWindowClose } from "./lib/windowClose"; @@ -710,7 +710,7 @@ function App() { 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); diff --git a/src/lib/windowClose.ts b/src/lib/windowClose.ts index 8a3beeb3..400ebcc7 100644 --- a/src/lib/windowClose.ts +++ b/src/lib/windowClose.ts @@ -18,7 +18,10 @@ export async function runSafeWindowClose( await dependencies.flushDraft(); } catch (saveError) { const recoveredTo = await dependencies.persistRecovery(); - result = { recoveredTo, saveError }; + if (!recoveredTo) { + throw saveError; + } + result = { recoveredTo }; } await dependencies.closeWindow(); diff --git a/src/lib/windowCloseCallsites.test.ts b/src/lib/windowCloseCallsites.test.ts index 3b44d4ce..3da3a630 100644 --- a/src/lib/windowCloseCallsites.test.ts +++ b/src/lib/windowCloseCallsites.test.ts @@ -18,4 +18,13 @@ describe("safe window close call sites", () => { expect(source).not.toContain("appWindow.destroy()"); }, ); + + it("uses requestCurrentWindowClose for Cmd/Ctrl+W in App.tsx", () => { + const source = readFileSync( + resolve(process.cwd(), "src/App.tsx"), + "utf8", + ); + + expect(source).toContain("requestCurrentWindowClose"); + }); }); diff --git a/src/services/windowLifecycle.ts b/src/services/windowLifecycle.ts index 6c1e362a..0fd0e109 100644 --- a/src/services/windowLifecycle.ts +++ b/src/services/windowLifecycle.ts @@ -1,4 +1,4 @@ -import { invoke } from "@tauri-apps/api/core"; +import { invoke, getCurrentWindow } from "@tauri-apps/api/core"; /** * Completes a close that the frontend already intercepted, flushed, and approved. @@ -12,3 +12,12 @@ export async function closeWindowAfterSave(): Promise { export async function openPreferencesWindow(): Promise { await invoke("open_preferences_window"); } + +/** + * Requests the current window to close. The close is interceptable by the + * WebView's onCloseRequested handler, allowing the frontend to flush drafts + * or persist recovery before the window is destroyed. + */ +export async function requestCurrentWindowClose(): Promise { + await getCurrentWindow().close(); +} From 23f63f6b20476158e296e1678c3e32e66826ac8b Mon Sep 17 00:00:00 2001 From: Norkep Date: Tue, 4 Aug 2026 12:01:49 +0200 Subject: [PATCH 09/13] fix(window-close): keep windows open without recovery --- src/lib/windowClose.test.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/lib/windowClose.test.ts b/src/lib/windowClose.test.ts index 3f1cd6b3..785593be 100644 --- a/src/lib/windowClose.test.ts +++ b/src/lib/windowClose.test.ts @@ -41,11 +41,10 @@ describe("runSafeWindowClose", () => { expect(order).toEqual(["flush", "recovery", "close"]); expect(result).toEqual({ recoveredTo: "/recovery/Plan.md", - saveError: expect.any(Error), }); }); - it("keeps the window open when both save and recovery fail", async () => { + it("throws the save error when recovery has no target", async () => { const closeWindow = vi.fn(async () => undefined); await expect( @@ -53,12 +52,10 @@ describe("runSafeWindowClose", () => { flushDraft: async () => { throw new Error("storage offline"); }, - persistRecovery: async () => { - throw new Error("recovery storage offline"); - }, + persistRecovery: async () => undefined, closeWindow, }), - ).rejects.toThrow("recovery storage offline"); + ).rejects.toThrow("storage offline"); expect(closeWindow).not.toHaveBeenCalled(); }); From 71623b224dd371777bcc971088a2a655b1939eba Mon Sep 17 00:00:00 2001 From: Norkep Date: Tue, 4 Aug 2026 12:03:25 +0200 Subject: [PATCH 10/13] fix(lib): log workspace resolution failures in get_settings --- src-tauri/src/lib.rs | 49 ++++++++++++++++++++++++++++++-------------- 1 file changed, 34 insertions(+), 15 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index dcecccde..96ade34a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -758,16 +758,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() + } } } @@ -783,13 +791,24 @@ 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() + } } } From f703c6c60b4489f2f91242122a48989b00ac7ecb Mon Sep 17 00:00:00 2001 From: Norkep Date: Tue, 4 Aug 2026 12:07:15 +0200 Subject: [PATCH 11/13] fix(lib): make update_git_enabled transactional --- src-tauri/src/lib.rs | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 96ade34a..62eaa207 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1825,13 +1825,11 @@ fn update_git_enabled( folder }; - { - let mut settings = state.settings.write().expect("settings write lock"); - settings.git_enabled = enabled; - } - - let settings = state.settings.read().expect("settings read lock"); - save_settings(&folder, &settings).map_err(|e| e.to_string())?; + let mut settings = state.settings.write().expect("settings write lock"); + let mut updated = settings.clone(); + updated.git_enabled = enabled; + save_settings(&folder, &updated).map_err(|e| e.to_string())?; + *settings = updated; Ok(()) } From 29c045947f87c01d721cbf9dc3a7ffceade22864 Mon Sep 17 00:00:00 2001 From: Norkep Date: Tue, 4 Aug 2026 12:14:15 +0200 Subject: [PATCH 12/13] fix(SettingsPage): make tests platform deterministic --- src/components/settings/SettingsPage.test.tsx | 12 +++- .../settings/SettingsPage.windows.test.tsx | 60 +++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 src/components/settings/SettingsPage.windows.test.tsx diff --git a/src/components/settings/SettingsPage.test.tsx b/src/components/settings/SettingsPage.test.tsx index 34e6b271..cfa0759e 100644 --- a/src/components/settings/SettingsPage.test.tsx +++ b/src/components/settings/SettingsPage.test.tsx @@ -2,7 +2,15 @@ import { act } from "react"; import { createRoot } from "react-dom/client"; import { afterEach, describe, expect, it, vi } from "vitest"; import { TooltipProvider } from "../ui"; -import { SettingsPage } from "./SettingsPage"; + +vi.mock("../../lib/platform", () => ({ + isWindows: false, + isMac: false, + mod: "Ctrl", + alt: "Alt", + shift: "Shift", + shortcut: (...parts: string[]) => parts.join("+"), +})); vi.mock("./GeneralSettingsSection", () => ({ GeneralSettingsSection: () =>
General settings
, @@ -20,6 +28,8 @@ vi.mock("./ToolsSettingsSection", () => ({ ToolsSettingsSection: () =>
Tool settings
, })); +import { SettingsPage } from "./SettingsPage"; + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }) .IS_REACT_ACT_ENVIRONMENT = true; diff --git a/src/components/settings/SettingsPage.windows.test.tsx b/src/components/settings/SettingsPage.windows.test.tsx new file mode 100644 index 00000000..897e5a64 --- /dev/null +++ b/src/components/settings/SettingsPage.windows.test.tsx @@ -0,0 +1,60 @@ +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { TooltipProvider } from "../ui"; + +vi.mock("../../lib/platform", () => ({ + isWindows: true, + isMac: false, + mod: "Ctrl", + alt: "Alt", + shift: "Shift", + shortcut: (...parts: string[]) => parts.join("+"), +})); + +vi.mock("./GeneralSettingsSection", () => ({ + GeneralSettingsSection: () =>
General settings
, +})); +vi.mock("./EditorSettingsSection", () => ({ + AppearanceSettingsSection: () =>
Appearance settings
, +})); +vi.mock("./ShortcutsSettingsSection", () => ({ + ShortcutsSettingsSection: () =>
Shortcut settings
, +})); +vi.mock("./AboutSettingsSection", () => ({ + AboutSettingsSection: () =>
About settings
, +})); +vi.mock("./ToolsSettingsSection", () => ({ + ToolsSettingsSection: () =>
Tool settings
, +})); + +import { SettingsPage } from "./SettingsPage"; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }) + .IS_REACT_ACT_ENVIRONMENT = true; + +afterEach(() => { + document.body.replaceChildren(); +}); + +describe("SettingsPage Windows context", () => { + it("renders no drag regions on Windows", () => { + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + + act(() => + root.render( + + + , + ), + ); + + expect(container.querySelectorAll("[data-tauri-drag-region]")).toHaveLength( + 0, + ); + + act(() => root.unmount()); + }); +}); From 56aa5abf3fcb3bb648da91409fa5aa2f1fa95051 Mon Sep 17 00:00:00 2001 From: Norkep Date: Wed, 5 Aug 2026 10:00:29 +0200 Subject: [PATCH 13/13] fix(standalone): harden drafts, recovery, and window close --- src-tauri/src/draft_checkpoint.rs | 253 ++++++++++++++-------- src-tauri/src/lib.rs | 268 ++++++++++++++++++------ src-tauri/src/persistence.rs | 186 +++++++--------- src-tauri/src/sha256.rs | 129 ++++++++++++ src/App.tsx | 109 ++++++---- src/components/editor/Editor.tsx | 122 ++++++++--- src/components/preview/PreviewApp.tsx | 111 ++++++---- src/context/GitContext.tsx | 30 +++ src/context/NotesContext.test.tsx | 78 +++++++ src/context/NotesContext.tsx | 4 + src/lib/draftCheckpoint.test.ts | 33 +++ src/lib/draftCheckpoint.ts | 27 ++- src/lib/draftRepresentation.test.ts | 58 +++++ src/lib/draftRepresentation.ts | 42 ++++ src/lib/recoveryNotice.test.ts | 39 ++++ src/lib/recoveryNotice.ts | 64 ++++++ src/lib/settingsEvents.test.ts | 12 ++ src/lib/settingsEvents.ts | 11 + src/lib/standaloneReload.test.ts | 65 +++++- src/lib/standaloneReload.ts | 31 +++ src/lib/useWindowShortcuts.test.tsx | 75 +++++++ src/lib/useWindowShortcuts.ts | 12 +- src/lib/windowClose.test.ts | 96 ++++++++- src/lib/windowClose.ts | 48 ++++- src/lib/windowMode.test.ts | 22 ++ src/lib/windowMode.ts | 16 ++ src/lib/windowShortcutCallsites.test.ts | 4 +- src/services/windowLifecycle.ts | 3 +- 28 files changed, 1541 insertions(+), 407 deletions(-) create mode 100644 src-tauri/src/sha256.rs create mode 100644 src/context/NotesContext.test.tsx create mode 100644 src/lib/draftRepresentation.test.ts create mode 100644 src/lib/draftRepresentation.ts create mode 100644 src/lib/recoveryNotice.test.ts create mode 100644 src/lib/recoveryNotice.ts create mode 100644 src/lib/settingsEvents.test.ts create mode 100644 src/lib/settingsEvents.ts create mode 100644 src/lib/useWindowShortcuts.test.tsx create mode 100644 src/lib/windowMode.test.ts create mode 100644 src/lib/windowMode.ts diff --git a/src-tauri/src/draft_checkpoint.rs b/src-tauri/src/draft_checkpoint.rs index b26cfb14..527a6bd2 100644 --- a/src-tauri/src/draft_checkpoint.rs +++ b/src-tauri/src/draft_checkpoint.rs @@ -4,8 +4,10 @@ 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); @@ -93,7 +95,16 @@ pub fn read_checkpoint( app_data_directory: impl AsRef, key: &DraftCheckpointKey, ) -> Result, DraftCheckpointError> { - let checkpoint_directory = checkpoint_directory(app_data_directory.as_ref()); + 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, @@ -109,7 +120,9 @@ pub fn read_checkpoint( pub fn list_checkpoints( app_data_directory: impl AsRef, ) -> Result, DraftCheckpointError> { - let checkpoint_directory = checkpoint_directory(app_data_directory.as_ref()); + 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()), @@ -150,6 +163,45 @@ pub fn list_checkpoints( 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, @@ -181,7 +233,7 @@ fn checkpoint_file_name(key: &DraftCheckpointKey) -> String { 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", hex_sha256(&identity)) + format!("{}.json", crate::sha256::hex_digest(&identity)) } fn ensure_identity_matches( @@ -323,100 +375,121 @@ fn sync_parent_directory(_parent: &Path) -> io::Result<()> { Ok(()) } -fn hex_sha256(input: &[u8]) -> String { - let digest = sha256(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); +#[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) + } } - hex -} -fn sha256(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 { - sha256_compress(&mut state, chunk, &ROUND_CONSTANTS); + impl Drop for TestDirectory { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } } - 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) { - sha256_compress(&mut state, chunk, &ROUND_CONSTANTS); + + 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(), + }, + } } - 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()); + + #[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()); } - digest -} -fn sha256_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]]); + #[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()); } - 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); + + #[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()); } - 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); + + #[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]); } - 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); } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 62eaa207..90c7c2cd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -17,9 +17,10 @@ use tauri_plugin_clipboard_manager::ClipboardExt; use tokio::fs; use tokio::io::AsyncWriteExt; +mod draft_checkpoint; mod git; mod persistence; -mod draft_checkpoint; +mod sha256; static RECOVERY_SNAPSHOT_SEQUENCE: AtomicU64 = AtomicU64::new(1); @@ -803,10 +804,7 @@ fn load_settings(notes_folder: &str) -> Settings { } }, Err(error) => { - eprintln!( - "settings read failed for {}: {error}", - path.display() - ); + eprintln!("settings read failed for {}: {error}", path.display()); Settings::default() } } @@ -1808,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()); @@ -1826,10 +1840,18 @@ fn update_git_enabled( }; let mut settings = state.settings.write().expect("settings write lock"); - let mut updated = settings.clone(); - updated.git_enabled = enabled; - save_settings(&folder, &updated).map_err(|e| e.to_string())?; - *settings = updated; + persist_git_enabled(&folder, &mut settings, enabled)?; + drop(settings); + + if let Err(error) = app.emit( + "settings-changed", + serde_json::json!({ + "notesFolder": folder, + "gitEnabled": enabled, + }), + ) { + eprintln!("Failed to broadcast settings change: {error}"); + } Ok(()) } @@ -1869,8 +1891,12 @@ pub struct FileContent { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(tag = "status", rename_all = "camelCase")] pub enum FileSaveResult { - Saved { file: FileContent }, - Conflict { current: Option }, + Saved { + file: FileContent, + }, + Conflict { + current: Option, + }, } fn note_conflict_snapshot( @@ -1908,15 +1934,7 @@ fn save_file_content_to_path( content: String, expected_revision: String, ) -> Result { - let current = persistence::read_snapshot(path).map_err(|error| error.to_string())?; - let expected = match current { - Some(snapshot) if snapshot.revision.as_str() == expected_revision => snapshot.revision, - current => { - return Ok(FileSaveResult::Conflict { - current: note_conflict_snapshot(current), - }); - } - }; + let expected = persistence::ContentRevision::from_hex(&expected_revision)?; match persistence::save_if_revision(path, &content, Some(&expected)) .map_err(|error| error.to_string())? @@ -1930,13 +1948,8 @@ fn save_file_content_to_path( } } -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())? - { +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)?, }), @@ -1961,6 +1974,9 @@ 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) } @@ -1981,7 +1997,10 @@ fn validate_preview_create_path(path: &str) -> Result { }) { return Err("Path traversal is not allowed".to_string()); } - match file_path.extension().and_then(|extension| extension.to_str()) { + 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") => {} @@ -2107,16 +2126,13 @@ async fn persist_recovery_snapshot( content: String, reason: String, ) -> Result { - let recovery_root = app.path().app_data_dir().map_err(|error| error.to_string())?; + 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()) + 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}"))? @@ -2130,7 +2146,10 @@ async fn write_draft_checkpoint( markdown: String, metadata: draft_checkpoint::DraftCheckpointMetadata, ) -> Result<(), String> { - let app_data = app.path().app_data_dir().map_err(|error| error.to_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(), @@ -2140,8 +2159,7 @@ async fn write_draft_checkpoint( metadata, }; tauri::async_runtime::spawn_blocking(move || { - draft_checkpoint::write_checkpoint(app_data, &checkpoint) - .map_err(|error| error.to_string()) + draft_checkpoint::write_checkpoint(app_data, &checkpoint).map_err(|error| error.to_string()) }) .await .map_err(|error| format!("Draft checkpoint task failed: {error}"))? @@ -2153,7 +2171,10 @@ async fn get_draft_checkpoint( window: WebviewWindow, note_id: String, ) -> Result, String> { - let app_data = app.path().app_data_dir().map_err(|error| error.to_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, @@ -2171,7 +2192,10 @@ async fn clear_draft_checkpoint( window: WebviewWindow, note_id: String, ) -> Result<(), String> { - let app_data = app.path().app_data_dir().map_err(|error| error.to_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, @@ -2188,7 +2212,10 @@ 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 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) @@ -3867,9 +3894,16 @@ fn native_preferences_menu_spec() -> NativePreferencesMenuSpec { } } -fn build_application_menu( - app_handle: &AppHandle, -) -> tauri::Result> { +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)?; @@ -3881,19 +3915,17 @@ fn build_application_menu( true, Some(spec.accelerator), )?; - let application_menu_name = app_handle - .config() - .product_name - .clone() - .unwrap_or_else(|| app_handle.package_info().name.clone()); + 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()? == application_menu_name { + if submenu.text()? == target_submenu { let separator = PredefinedMenuItem::separator(app_handle)?; - submenu.insert_items(&[&preferences, &separator], 1)?; + submenu.insert_items(&[&preferences, &separator], insertion_index)?; + break; } } @@ -3946,6 +3978,12 @@ fn runtime_window_config_from_template( 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(); @@ -3953,8 +3991,9 @@ fn create_preferences_window(app: &AppHandle) -> Result<(), String> { return Ok(()); } + let template = first_window_template(&app.config().app.windows)?; let runtime_config = runtime_window_config_from_template( - &app.config().app.windows[0], + template, "preferences", WebviewUrl::App("index.html?mode=preferences".into()), ); @@ -4011,11 +4050,9 @@ 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 runtime_config = runtime_window_config_from_template( - &app.config().app.windows[0], - &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)) @@ -4443,10 +4480,10 @@ fn set_title_bar_theme( #[cfg(test)] mod tests { use super::{ - native_preferences_menu_spec, read_file_content_from_path, - recreate_file_content_to_path, save_file_content_to_path, - should_hide_main_window_for_standalone_preview, FileSaveResult, - Settings, + 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; @@ -4460,8 +4497,7 @@ mod tests { impl StandaloneTestDirectory { fn new(name: &str) -> Self { - let sequence = - NEXT_STANDALONE_TEST_DIRECTORY.fetch_add(1, Ordering::Relaxed); + 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() @@ -4515,6 +4551,39 @@ mod tests { 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(); @@ -4523,6 +4592,31 @@ mod tests { 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)); @@ -4536,8 +4630,7 @@ mod tests { 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"); + fs::write(&path, "# External\n\nChanged outside Scratch").expect("write external edit"); let result = save_file_content_to_path( &path, @@ -4546,7 +4639,10 @@ mod tests { ) .expect("return typed conflict"); - assert!(matches!(result, FileSaveResult::Conflict { current: Some(_) })); + assert!(matches!( + result, + FileSaveResult::Conflict { current: Some(_) } + )); assert_eq!( fs::read_to_string(&path).expect("read preserved file"), "# External\n\nChanged outside Scratch" @@ -4566,4 +4662,38 @@ mod tests { 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 index 3c0e09ce..bbf15752 100644 --- a/src-tauri/src/persistence.rs +++ b/src-tauri/src/persistence.rs @@ -14,9 +14,21 @@ static PATH_LOCKS: OnceLock>>>> = OnceLock 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 { @@ -39,16 +51,7 @@ pub enum SaveResult { /// Returns a deterministic revision suitable for optimistic concurrency checks. pub fn content_revision(content: &str) -> ContentRevision { - let digest = sha256(content.as_bytes()); - let mut hex = String::with_capacity(digest.len() * 2); - 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); - } - - ContentRevision(hex) + ContentRevision(crate::sha256::hex_digest(content.as_bytes())) } /// Saves `content` only when the file still has `expected_revision`. @@ -142,6 +145,10 @@ fn lock_key(path: &Path) -> PathBuf { 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; }; @@ -210,16 +217,7 @@ fn atomic_create_new(path: &Path, bytes: &[u8]) -> io::Result<()> { Err(_) => {} } - let create_result = OpenOptions::new() - .write(true) - .create_new(true) - .open(path) - .and_then(|mut file| file.write_all(bytes).and_then(|()| file.sync_all())); - - if let Err(error) = create_result { - let _ = fs::remove_file(path); - return Err(error); - } + write_create_new_destination(path, bytes)?; fs::remove_file(temporary_path.path())?; temporary_path.commit(); @@ -227,6 +225,21 @@ fn atomic_create_new(path: &Path, bytes: &[u8]) -> io::Result<()> { 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() @@ -292,100 +305,6 @@ fn sync_parent_directory(_parent: &Path) -> io::Result<()> { Ok(()) } -fn sha256(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 { - sha256_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 }; - let bit_length = (input.len() as u64).wrapping_mul(8).to_be_bytes(); - tail[tail_length - 8..tail_length].copy_from_slice(&bit_length); - - for chunk in tail[..tail_length].chunks_exact(64) { - sha256_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 -} - -fn sha256_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::*; @@ -448,6 +367,15 @@ mod tests { 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"); @@ -541,6 +469,22 @@ mod tests { 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() { @@ -559,6 +503,20 @@ mod tests { 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"); 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 c33c9f62..8687952b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -38,23 +38,16 @@ import type { AiProvider } from "./services/ai"; import { isMac, isWindows } from "./lib/platform"; import { closeWindowAfterSave, requestCurrentWindowClose } from "./services/windowLifecycle"; import { useWindowShortcuts } from "./lib/useWindowShortcuts"; -import { runSafeWindowClose } from "./lib/windowClose"; - -// Detect preview mode from URL search params -function getWindowMode(): { - isPreview: boolean; - isPreferences: 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, - isPreferences: mode === "preferences", - previewFile: file, - }; -} +import { + beginSafeWindowClose, + resolveCloseListenerRegistration, + runSafeWindowClose, +} from "./lib/windowClose"; +import { + consumePendingRecoveryNotices, + recordPendingRecoveryNotice, +} from "./lib/recoveryNotice"; +import { getWindowMode } from "./lib/windowMode"; type ViewState = "notes" | "settings"; @@ -90,6 +83,14 @@ function AppContent() { ); 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; @@ -102,35 +103,48 @@ function AppContent() { let unlisten: (() => void) | undefined; const appWindow = getCurrentWindow(); - void appWindow.onCloseRequested((event) => { - if (closeInProgressRef.current) return; - event.preventDefault(); - closeInProgressRef.current = true; - - 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 undefined; - return notesService.persistRecoverySnapshot({ - noteId: draft.noteId, - sourcePath: note.path, - content: draft.content, - reason: "window-close", - }); - }, - closeWindow: closeWindowAfterSave, - }).catch((error) => { - closeInProgressRef.current = false; + 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( - `Window kept open because the draft could not be saved: ${error}`, - ); + toast.error(`Safe window-close protection could not start: ${error}`); } - }); - }).then((removeListener) => { + }, + ).then((removeListener) => { if (disposed) removeListener(); else unlisten = removeListener; }); @@ -703,7 +717,10 @@ function PreferencesApp() { } function App() { - const { isPreview, isPreferences, 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(() => { @@ -747,7 +764,7 @@ function App() { - + ); diff --git a/src/components/editor/Editor.tsx b/src/components/editor/Editor.tsx index e6efcae2..cdf8a056 100644 --- a/src/components/editor/Editor.tsx +++ b/src/components/editor/Editor.tsx @@ -74,10 +74,15 @@ 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, @@ -118,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, { @@ -616,6 +623,8 @@ 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(""); @@ -670,6 +679,8 @@ export function Editor({ 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; @@ -876,7 +887,7 @@ export function Editor({ toast.error("Failed to save note"); } } - }, 500); + }, AUTO_SAVE_DEBOUNCE_MS); }, [checkpointScheduler, saveImmediately, getMarkdown, currentNote?.id]); const flushSourceSave = useCallback(async () => { @@ -900,46 +911,95 @@ export function Editor({ }, [checkpointScheduler, saveImmediately]); const flushAllPendingSaves = useCallback(async () => { - if (sourceNeedsSaveRef.current) { - await flushSourceSave(); - return; - } - await flushPendingSave(); + 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; - if (sourceMode) { + 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: sourceNeedsSaveRef.current, + dirty: representation !== null, }; } return { noteId, content: editorRef.current ? getMarkdown(editorRef.current) : "", - dirty: needsSaveRef.current, + dirty: representation !== null, }; - }, [getMarkdown, sourceMode]); + }, [getMarkdown]); + const flushAllPendingSavesRef = useRef(flushAllPendingSaves); + flushAllPendingSavesRef.current = flushAllPendingSaves; const persistCurrentCrashCheckpoint = useCallback(async () => { const draft = getOpenDraftSnapshot(); - if (!draft.dirty || !draft.noteId || !currentNote) return; - checkpointScheduler.markDirty({ - key: { - windowLabel: getCurrentWindow().label, - noteId: draft.noteId, - }, - markdown: draft.content, - metadata: { - sourcePath: currentNote.path, - baseRevision: currentNote.revision ?? null, - updatedAt: new Date().toISOString(), - }, - }); + const checkpoint = createDraftCheckpointSnapshot( + getCurrentWindow().label, + draft, + currentNoteRef.current, + new Date().toISOString(), + ); + if (!checkpoint) return; + checkpointScheduler.markDirty(checkpoint); await checkpointScheduler.flush(); - }, [checkpointScheduler, currentNote, getOpenDraftSnapshot]); + }, [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; + } + checkpointSchedulerRef.current?.dispose(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); queueCheckpointCaptureRef.current = () => { const now = Date.now(); @@ -1675,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) { @@ -2359,7 +2425,7 @@ export function Editor({ setIsSaving(false); } } - }, 300); + }, AUTO_SAVE_DEBOUNCE_MS); }, [checkpointScheduler, currentNote, saveNote], ); diff --git a/src/components/preview/PreviewApp.tsx b/src/components/preview/PreviewApp.tsx index 13860638..14f201d7 100644 --- a/src/components/preview/PreviewApp.tsx +++ b/src/components/preview/PreviewApp.tsx @@ -11,8 +11,17 @@ import * as filesService from "../../services/files"; import * as notesService from "../../services/notes"; import * as draftCheckpointService from "../../services/draftCheckpoint"; import { createSerializedTaskQueue } from "../../lib/serializedWriter"; -import { runSafeWindowClose } from "../../lib/windowClose"; -import { flushDirtyDraftBeforeReload } from "../../lib/standaloneReload"; +import { + beginSafeWindowClose, + resolveCloseListenerRegistration, + runSafeWindowClose, +} from "../../lib/windowClose"; +import { recordPendingRecoveryNotice } from "../../lib/recoveryNotice"; +import { + createLatestRequestGuard, + flushDirtyDraftBeforeReload, + standaloneRecoveryBaseRevision, +} from "../../lib/standaloneReload"; import { recreateDeletedStandaloneDraft } from "../../lib/standaloneRecreation"; import { runConflictResolution, @@ -43,6 +52,7 @@ export function PreviewApp({ filePath }: PreviewAppProps) { null, ); const closeInProgressRef = useRef(false); + const fileLoadGuardRef = useRef(createLatestRequestGuard()); const registerPersistenceController = useCallback( (controller: EditorPersistenceController) => { @@ -61,34 +71,45 @@ export function PreviewApp({ filePath }: PreviewAppProps) { let unlisten: (() => void) | undefined; const appWindow = getCurrentWindow(); - appWindow.onCloseRequested((event) => { - if (closeInProgressRef.current) return; - event.preventDefault(); - closeInProgressRef.current = true; + 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 undefined; - return notesService.persistRecoverySnapshot({ - noteId: filePath, - sourcePath: filePath, - content: draft.content, - reason: "standalone-window-close", - }); - }, - closeWindow: closeWindowAfterSave, - }).catch((error) => { - closeInProgressRef.current = false; + 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( - `Window kept open because the draft could not be saved: ${error}`, - ); + toast.error(`Safe window-close protection could not start: ${error}`); } - }); - }).then((removeListener) => { + }, + ).then((removeListener) => { if (disposed) removeListener(); else unlisten = removeListener; }); @@ -102,47 +123,55 @@ export function PreviewApp({ filePath }: PreviewAppProps) { // Load file on mount useEffect(() => { let cancelled = false; + const isLatest = fileLoadGuardRef.current.begin(); + const isStale = () => cancelled || !isLatest(); filesService .readFileDirect(filePath) .then(async (result) => { - if (cancelled) return; + if (isStale()) return; const checkpoint = await draftCheckpointService .getDraftCheckpoint(filePath) .catch(() => null); - if (cancelled) return; + if (isStale()) return; const recovered = checkpoint && checkpoint.markdown !== result.content ? checkpoint.markdown : result.content; - if (cancelled) return; + if (isStale()) return; setContent(recovered); - if (cancelled) return; + if (isStale()) return; setTitle(result.title); - if (cancelled) return; + if (isStale()) return; setModified(result.modified); - revisionRef.current = result.revision; - if (cancelled) return; - setRevision(result.revision); + 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 (cancelled) return; + if (isStale()) return; setHasExternalChanges(true); - if (cancelled) return; + if (isStale()) return; setHasSaveConflict(true); - if (cancelled) return; + if (isStale()) return; toast.warning("Recovered an unsaved draft from an interrupted session"); } }) .catch((error) => { - if (cancelled) return; + if (isStale()) return; console.error("Failed to load file:", error); toast.error(`Failed to load file: ${error}`); }); return () => { cancelled = true; + fileLoadGuardRef.current.invalidate(); }; }, [filePath]); @@ -199,9 +228,12 @@ export function PreviewApp({ filePath }: PreviewAppProps) { ); 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); @@ -211,6 +243,7 @@ export function PreviewApp({ filePath }: PreviewAppProps) { setHasSaveConflict(false); setReloadVersion((v) => v + 1); } catch (error) { + if (!isLatest()) return; console.error("Failed to reload file:", error); toast.error(`Failed to reload: ${error}`); } diff --git a/src/context/GitContext.tsx b/src/context/GitContext.tsx index 11432ae8..86114faf 100644 --- a/src/context/GitContext.tsx +++ b/src/context/GitContext.tsx @@ -13,6 +13,10 @@ import * as gitService from "../services/git"; import * as notesService from "../services/notes"; import type { GitStatus } from "../services/git"; import { useNotesData } from "./NotesContext"; +import { + isGitSettingsEventForFolder, + type GitSettingsChangedEvent, +} from "../lib/settingsEvents"; interface GitContextValue { // State @@ -70,6 +74,32 @@ export function GitProvider({ children }: { children: ReactNode }) { const gitEnabledRef = useRef(gitEnabled); gitEnabledRef.current = gitEnabled; + useEffect(() => { + let disposed = false; + let unlisten: (() => void) | undefined; + + void listen("settings-changed", (event) => { + if ( + disposed || + !isGitSettingsEventForFolder(event.payload, notesFolderRef.current) + ) { + return; + } + settingsReadRequestIdRef.current += 1; + if (typeof event.payload.gitEnabled === "boolean") { + setGitEnabledState(event.payload.gitEnabled); + } + }).then((removeListener) => { + if (disposed) removeListener(); + else unlisten = removeListener; + }); + + return () => { + disposed = true; + unlisten?.(); + }; + }, []); + const refreshStatus = useCallback(async () => { if (!notesFolder || !gitEnabled) return; diff --git a/src/context/NotesContext.test.tsx b/src/context/NotesContext.test.tsx new file mode 100644 index 00000000..92e09668 --- /dev/null +++ b/src/context/NotesContext.test.tsx @@ -0,0 +1,78 @@ +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { getNotesFolderMock, listenMock, saveNoteMock } = vi.hoisted(() => ({ + getNotesFolderMock: vi.fn(), + listenMock: vi.fn(), + saveNoteMock: vi.fn(), +})); + +vi.mock("@tauri-apps/api/event", () => ({ listen: listenMock })); +vi.mock("../services/notes", () => ({ + getNotesFolder: getNotesFolderMock, + saveNote: saveNoteMock, +})); + +import { + NotesProvider, + useNotesActions, + useNotesData, +} from "./NotesContext"; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }) + .IS_REACT_ACT_ENVIRONMENT = true; + +afterEach(() => { + document.body.replaceChildren(); +}); + +describe("NotesProvider save failures", () => { + beforeEach(() => { + getNotesFolderMock.mockReset().mockResolvedValue(null); + listenMock.mockReset().mockResolvedValue(vi.fn()); + saveNoteMock.mockReset(); + }); + + it("keeps the UI error and rejects so safe close can recover the draft", async () => { + const failure = new Error("disk full"); + saveNoteMock.mockRejectedValueOnce(failure); + + let actions: ReturnType | null = null; + let error: string | null = null; + + function Probe() { + actions = useNotesActions(); + error = useNotesData().error; + return null; + } + + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + , + ); + }); + + expect(actions).not.toBeNull(); + + await act(async () => { + await expect( + actions!.saveNote("# Draft\n\nLatest content", "draft.md"), + ).rejects.toBe(failure); + }); + + expect(saveNoteMock).toHaveBeenCalledWith( + "draft.md", + "# Draft\n\nLatest content", + ); + expect(error).toBe("disk full"); + + await act(async () => root.unmount()); + }); +}); diff --git a/src/context/NotesContext.tsx b/src/context/NotesContext.tsx index 7ad2ea11..2277381b 100644 --- a/src/context/NotesContext.tsx +++ b/src/context/NotesContext.tsx @@ -246,6 +246,10 @@ export function NotesProvider({ children }: { children: ReactNode }) { // Clean up immediately on error to avoid leaving stale entries recentlySavedRef.current.delete(savingNoteId); if (updatedId) recentlySavedRef.current.delete(updatedId); + // Callers such as the safe-close workflow must know that persistence + // failed so they can create a recovery snapshot instead of clearing + // the dirty state and closing the window. + throw err; } }, [currentNote, scheduleRefresh] diff --git a/src/lib/draftCheckpoint.test.ts b/src/lib/draftCheckpoint.test.ts index d07d5624..efd4878e 100644 --- a/src/lib/draftCheckpoint.test.ts +++ b/src/lib/draftCheckpoint.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { + createDraftCheckpointSnapshot, createDraftCheckpointScheduler, nextCheckpointCaptureDelay, reconcileDraftCheckpoint, @@ -40,6 +41,38 @@ afterEach(() => { vi.useRealTimers(); }); +describe("createDraftCheckpointSnapshot", () => { + it("captures a dirty standalone note without requiring NotesContext", () => { + expect( + createDraftCheckpointSnapshot( + "preview-plan", + { noteId: "/external/Plan.md", content: "# Unsaved", dirty: true }, + { path: "/external/Plan.md", revision: "disk-revision" }, + "2026-08-04T15:00:00.000Z", + ), + ).toEqual({ + key: { windowLabel: "preview-plan", noteId: "/external/Plan.md" }, + markdown: "# Unsaved", + metadata: { + sourcePath: "/external/Plan.md", + baseRevision: "disk-revision", + updatedAt: "2026-08-04T15:00:00.000Z", + }, + }); + }); + + it("does not create a checkpoint for a clean draft", () => { + expect( + createDraftCheckpointSnapshot( + "preview-plan", + { noteId: "/external/Plan.md", content: "# Saved", dirty: false }, + { path: "/external/Plan.md", revision: "disk-revision" }, + "2026-08-04T15:00:00.000Z", + ), + ).toBeNull(); + }); +}); + describe("nextCheckpointCaptureDelay", () => { it("keeps a trailing delay but guarantees a checkpoint during continuous typing", () => { expect(nextCheckpointCaptureDelay(0, 250, 750)).toBe(250); diff --git a/src/lib/draftCheckpoint.ts b/src/lib/draftCheckpoint.ts index 18bf2cf1..1b961a40 100644 --- a/src/lib/draftCheckpoint.ts +++ b/src/lib/draftCheckpoint.ts @@ -1,3 +1,5 @@ +import type { Note } from "../types/note"; + export interface DraftCheckpointKey { windowLabel: string; noteId: string; @@ -39,6 +41,30 @@ export interface DraftCheckpointSchedulerOptions { onError?: (error: unknown) => void; } +export interface OpenDraftCheckpointSnapshot { + noteId: string | null; + content: string; + dirty: boolean; +} + +export function createDraftCheckpointSnapshot( + windowLabel: string, + draft: OpenDraftCheckpointSnapshot, + note: Pick | null, + updatedAt: string, +): DraftCheckpoint | null { + if (!draft.dirty || !draft.noteId || !note) return null; + return { + key: { windowLabel, noteId: draft.noteId }, + markdown: draft.content, + metadata: { + sourcePath: note.path, + baseRevision: note.revision || null, + updatedAt, + }, + }; +} + const DEFAULT_DELAY_MS = 1_000; export function nextCheckpointCaptureDelay( @@ -173,4 +199,3 @@ export function reconcileDraftCheckpoint( shouldClear: false, }; } -import type { Note } from "../types/note"; diff --git a/src/lib/draftRepresentation.test.ts b/src/lib/draftRepresentation.test.ts new file mode 100644 index 00000000..18d91312 --- /dev/null +++ b/src/lib/draftRepresentation.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, vi } from "vitest"; +import { + choosePendingDraftRepresentation, + flushPendingDraftRepresentation, +} from "./draftRepresentation"; + +describe("choosePendingDraftRepresentation", () => { + it("uses source when both representations are dirty in source mode", () => { + expect(choosePendingDraftRepresentation(true, true, true)).toBe("source"); + }); + + it("uses formatted content when both are dirty after leaving source mode", () => { + expect(choosePendingDraftRepresentation(false, true, true)).toBe( + "formatted", + ); + }); + + it("falls back to the only dirty representation", () => { + expect(choosePendingDraftRepresentation(false, true, false)).toBe( + "source", + ); + expect(choosePendingDraftRepresentation(true, false, true)).toBe( + "formatted", + ); + }); + + it("flushes source and discards stale formatted work in source mode", async () => { + const actions = { + discardSource: vi.fn(), + discardFormatted: vi.fn(), + flushSource: vi.fn(async () => undefined), + flushFormatted: vi.fn(async () => undefined), + }; + + await expect( + flushPendingDraftRepresentation(true, true, true, actions), + ).resolves.toBe("source"); + expect(actions.discardFormatted).toHaveBeenCalledOnce(); + expect(actions.flushSource).toHaveBeenCalledOnce(); + expect(actions.flushFormatted).not.toHaveBeenCalled(); + }); + + it("flushes formatted and discards stale source work after source mode", async () => { + const actions = { + discardSource: vi.fn(), + discardFormatted: vi.fn(), + flushSource: vi.fn(async () => undefined), + flushFormatted: vi.fn(async () => undefined), + }; + + await expect( + flushPendingDraftRepresentation(false, true, true, actions), + ).resolves.toBe("formatted"); + expect(actions.discardSource).toHaveBeenCalledOnce(); + expect(actions.flushFormatted).toHaveBeenCalledOnce(); + expect(actions.flushSource).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/draftRepresentation.ts b/src/lib/draftRepresentation.ts new file mode 100644 index 00000000..0fa26094 --- /dev/null +++ b/src/lib/draftRepresentation.ts @@ -0,0 +1,42 @@ +export type PendingDraftRepresentation = "source" | "formatted" | null; + +export function choosePendingDraftRepresentation( + sourceMode: boolean, + sourceDirty: boolean, + formattedDirty: boolean, +): PendingDraftRepresentation { + if (sourceMode) { + if (sourceDirty) return "source"; + return formattedDirty ? "formatted" : null; + } + if (formattedDirty) return "formatted"; + return sourceDirty ? "source" : null; +} + +export interface PendingDraftFlushActions { + discardSource(): void; + discardFormatted(): void; + flushSource(): Promise; + flushFormatted(): Promise; +} + +export async function flushPendingDraftRepresentation( + sourceMode: boolean, + sourceDirty: boolean, + formattedDirty: boolean, + actions: PendingDraftFlushActions, +): Promise { + const representation = choosePendingDraftRepresentation( + sourceMode, + sourceDirty, + formattedDirty, + ); + if (representation === "source") { + actions.discardFormatted(); + await actions.flushSource(); + } else if (representation === "formatted") { + actions.discardSource(); + await actions.flushFormatted(); + } + return representation; +} diff --git a/src/lib/recoveryNotice.test.ts b/src/lib/recoveryNotice.test.ts new file mode 100644 index 00000000..f5b48073 --- /dev/null +++ b/src/lib/recoveryNotice.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { + consumePendingRecoveryNotices, + recordPendingRecoveryNotice, +} from "./recoveryNotice"; + +function createStorage() { + const values = new Map(); + return { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + removeItem: (key: string) => values.delete(key), + }; +} + +describe("recovery notices", () => { + it("persists recovery locations until the next app startup consumes them", () => { + const storage = createStorage(); + + recordPendingRecoveryNotice( + "/recovery/Plan.md", + new Error("revision conflict"), + storage, + ); + + const notices = consumePendingRecoveryNotices(storage); + expect(notices).toHaveLength(1); + expect(notices[0]?.recoveredTo).toBe("/recovery/Plan.md"); + expect(notices[0]?.saveError).toContain("revision conflict"); + expect(consumePendingRecoveryNotices(storage)).toEqual([]); + }); + + it("drops malformed stored notices without throwing", () => { + const storage = createStorage(); + storage.setItem("scratch:pendingRecoveryNotices", "not-json"); + + expect(consumePendingRecoveryNotices(storage)).toEqual([]); + }); +}); diff --git a/src/lib/recoveryNotice.ts b/src/lib/recoveryNotice.ts new file mode 100644 index 00000000..0dae5b2c --- /dev/null +++ b/src/lib/recoveryNotice.ts @@ -0,0 +1,64 @@ +const STORAGE_KEY = "scratch:pendingRecoveryNotices"; + +export interface RecoveryNotice { + recoveredTo: string; + saveError: string; + createdAt: string; +} + +type RecoveryNoticeStorage = Pick< + Storage, + "getItem" | "setItem" | "removeItem" +>; + +function defaultStorage(): RecoveryNoticeStorage | undefined { + try { + return globalThis.localStorage; + } catch { + return undefined; + } +} + +function readNotices(storage: RecoveryNoticeStorage): RecoveryNotice[] { + const stored = storage.getItem(STORAGE_KEY); + if (!stored) return []; + const parsed: unknown = JSON.parse(stored); + if (!Array.isArray(parsed)) return []; + return parsed.filter( + (notice): notice is RecoveryNotice => + typeof notice === "object" && + notice !== null && + typeof notice.recoveredTo === "string" && + typeof notice.saveError === "string" && + typeof notice.createdAt === "string", + ); +} + +export function recordPendingRecoveryNotice( + recoveredTo: string, + saveError: unknown, + storage: RecoveryNoticeStorage | undefined = defaultStorage(), +): void { + if (!storage) throw new Error("Recovery notice storage is unavailable"); + const notices = readNotices(storage); + notices.push({ + recoveredTo, + saveError: String(saveError), + createdAt: new Date().toISOString(), + }); + storage.setItem(STORAGE_KEY, JSON.stringify(notices)); +} + +export function consumePendingRecoveryNotices( + storage: RecoveryNoticeStorage | undefined = defaultStorage(), +): RecoveryNotice[] { + if (!storage) return []; + try { + const notices = readNotices(storage); + storage.removeItem(STORAGE_KEY); + return notices; + } catch { + storage.removeItem(STORAGE_KEY); + return []; + } +} diff --git a/src/lib/settingsEvents.test.ts b/src/lib/settingsEvents.test.ts new file mode 100644 index 00000000..1977ce67 --- /dev/null +++ b/src/lib/settingsEvents.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; +import { isGitSettingsEventForFolder } from "./settingsEvents"; + +describe("isGitSettingsEventForFolder", () => { + it("accepts only events for the provider workspace", () => { + const event = { notesFolder: "/notes/a", gitEnabled: true }; + + expect(isGitSettingsEventForFolder(event, "/notes/a")).toBe(true); + expect(isGitSettingsEventForFolder(event, "/notes/b")).toBe(false); + expect(isGitSettingsEventForFolder(event, null)).toBe(false); + }); +}); diff --git a/src/lib/settingsEvents.ts b/src/lib/settingsEvents.ts new file mode 100644 index 00000000..48e76b11 --- /dev/null +++ b/src/lib/settingsEvents.ts @@ -0,0 +1,11 @@ +export interface GitSettingsChangedEvent { + notesFolder: string; + gitEnabled: boolean | null; +} + +export function isGitSettingsEventForFolder( + event: GitSettingsChangedEvent, + notesFolder: string | null, +): boolean { + return notesFolder !== null && event.notesFolder === notesFolder; +} diff --git a/src/lib/standaloneReload.test.ts b/src/lib/standaloneReload.test.ts index 2bda9c40..23afd7e1 100644 --- a/src/lib/standaloneReload.test.ts +++ b/src/lib/standaloneReload.test.ts @@ -1,5 +1,36 @@ import { describe, expect, it, vi } from "vitest"; -import { flushDirtyDraftBeforeReload } from "./standaloneReload"; +import type { DraftCheckpoint } from "./draftCheckpoint"; +import { + createLatestRequestGuard, + flushDirtyDraftBeforeReload, + standaloneRecoveryBaseRevision, +} from "./standaloneReload"; + +describe("createLatestRequestGuard", () => { + it("lets only the newest asynchronous file load update state", () => { + const guard = createLatestRequestGuard(); + const firstIsCurrent = guard.begin(); + const secondIsCurrent = guard.begin(); + + expect(firstIsCurrent()).toBe(false); + expect(secondIsCurrent()).toBe(true); + + guard.invalidate(); + expect(secondIsCurrent()).toBe(false); + }); +}); + +function checkpoint(markdown: string, baseRevision: string | null): DraftCheckpoint { + return { + key: { windowLabel: "preview-note", noteId: "/note.md" }, + markdown, + metadata: { + sourcePath: "/note.md", + baseRevision, + updatedAt: "2026-08-04T12:00:00.000Z", + }, + }; +} describe("flushDirtyDraftBeforeReload", () => { it("flushes a dirty standalone draft before disk content may replace it", async () => { @@ -35,3 +66,35 @@ describe("flushDirtyDraftBeforeReload", () => { ).rejects.toThrow("revision conflict"); }); }); + +describe("standaloneRecoveryBaseRevision", () => { + it("keeps the checkpoint base when recovered content differs from disk", () => { + expect( + standaloneRecoveryBaseRevision( + "current-disk-revision", + "external content", + checkpoint("local recovered content", "draft-base-revision"), + ), + ).toBe("draft-base-revision"); + }); + + it("blocks direct saves when a recovered checkpoint has no base revision", () => { + expect( + standaloneRecoveryBaseRevision( + "current-disk-revision", + "external content", + checkpoint("local recovered content", null), + ), + ).toBe(""); + }); + + it("uses the disk revision when no divergent recovery is applied", () => { + expect( + standaloneRecoveryBaseRevision( + "current-disk-revision", + "same content", + checkpoint("same content", "older-revision"), + ), + ).toBe("current-disk-revision"); + }); +}); diff --git a/src/lib/standaloneReload.ts b/src/lib/standaloneReload.ts index aa8a26f8..fb48d533 100644 --- a/src/lib/standaloneReload.ts +++ b/src/lib/standaloneReload.ts @@ -1,8 +1,39 @@ +import type { DraftCheckpoint } from "./draftCheckpoint"; + export interface ReloadPersistenceController { flush: () => Promise; getDraft: () => { dirty: boolean }; } +export interface LatestRequestGuard { + begin(): () => boolean; + invalidate(): void; +} + +export function createLatestRequestGuard(): LatestRequestGuard { + let latestRequest = 0; + return { + begin() { + const request = ++latestRequest; + return () => request === latestRequest; + }, + invalidate() { + latestRequest += 1; + }, + }; +} + +export function standaloneRecoveryBaseRevision( + diskRevision: string, + diskContent: string, + checkpoint: DraftCheckpoint | null, +): string { + if (checkpoint && checkpoint.markdown !== diskContent) { + return checkpoint.metadata.baseRevision ?? ""; + } + return diskRevision; +} + export async function flushDirtyDraftBeforeReload( controller: ReloadPersistenceController | null, ): Promise { diff --git a/src/lib/useWindowShortcuts.test.tsx b/src/lib/useWindowShortcuts.test.tsx new file mode 100644 index 00000000..31c552c6 --- /dev/null +++ b/src/lib/useWindowShortcuts.test.tsx @@ -0,0 +1,75 @@ +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useWindowShortcuts } from "./useWindowShortcuts"; + +const themeMock = vi.hoisted(() => ({ + zoom: 1, + setInterfaceZoom: vi.fn(), +})); +const toastMock = vi.hoisted(() => + Object.assign(vi.fn(), { error: vi.fn() }), +); + +vi.mock("../context/ThemeContext", () => ({ + useTheme: () => ({ + interfaceZoom: themeMock.zoom, + setInterfaceZoom: themeMock.setInterfaceZoom, + }), +})); +vi.mock("sonner", () => ({ toast: toastMock })); + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }) + .IS_REACT_ACT_ENVIRONMENT = true; + +function ShortcutProbe() { + useWindowShortcuts({ onOpenPreferences: vi.fn() }); + return null; +} + +describe("useWindowShortcuts", () => { + let container: HTMLDivElement; + let root: ReturnType; + + beforeEach(() => { + themeMock.zoom = 1; + themeMock.setInterfaceZoom.mockReset(); + themeMock.setInterfaceZoom.mockImplementation( + (update: number | ((current: number) => number)) => { + const raw = typeof update === "function" ? update(themeMock.zoom) : update; + themeMock.zoom = Math.round( + Math.min(Math.max(raw, 0.7), 1.5) * 20, + ) / 20; + }, + ); + toastMock.mockClear(); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it("keeps consecutive zoom shortcuts instead of using a stale render value", () => { + act(() => root.render()); + + act(() => { + window.dispatchEvent( + new KeyboardEvent("keydown", { key: "=", metaKey: true }), + ); + window.dispatchEvent( + new KeyboardEvent("keydown", { key: "=", metaKey: true }), + ); + }); + + expect(themeMock.setInterfaceZoom).toHaveBeenCalledTimes(2); + expect(themeMock.zoom).toBe(1.1); + expect(toastMock).toHaveBeenLastCalledWith("Zoom 110%", { + id: "zoom", + duration: 1500, + }); + }); +}); diff --git a/src/lib/useWindowShortcuts.ts b/src/lib/useWindowShortcuts.ts index 9ed2fa3c..f9452faf 100644 --- a/src/lib/useWindowShortcuts.ts +++ b/src/lib/useWindowShortcuts.ts @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { toast } from "sonner"; import { useTheme } from "../context/ThemeContext"; import { resolveWindowShortcut } from "./windowShortcuts"; @@ -11,6 +11,8 @@ export function useWindowShortcuts({ onOpenPreferences, }: UseWindowShortcutsOptions): void { const { interfaceZoom, setInterfaceZoom } = useTheme(); + const interfaceZoomRef = useRef(interfaceZoom); + interfaceZoomRef.current = interfaceZoom; useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { @@ -28,6 +30,7 @@ export function useWindowShortcuts({ } if (action === "zoom-reset") { + interfaceZoomRef.current = 1; setInterfaceZoom(1); toast("Zoom 100%", { id: "zoom", duration: 1500 }); return; @@ -35,9 +38,10 @@ export function useWindowShortcuts({ const delta = action === "zoom-in" ? 0.05 : -0.05; const next = Math.round( - Math.min(Math.max(interfaceZoom + delta, 0.7), 1.5) * 20, + Math.min(Math.max(interfaceZoomRef.current + delta, 0.7), 1.5) * 20, ) / 20; - setInterfaceZoom(next); + interfaceZoomRef.current = next; + setInterfaceZoom((current) => current + delta); toast(`Zoom ${Math.round(next * 100)}%`, { id: "zoom", duration: 1500, @@ -46,5 +50,5 @@ export function useWindowShortcuts({ window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [interfaceZoom, onOpenPreferences, setInterfaceZoom]); + }, [onOpenPreferences, setInterfaceZoom]); } diff --git a/src/lib/windowClose.test.ts b/src/lib/windowClose.test.ts index 785593be..b6a2620d 100644 --- a/src/lib/windowClose.test.ts +++ b/src/lib/windowClose.test.ts @@ -1,5 +1,38 @@ import { describe, expect, it, vi } from "vitest"; -import { runSafeWindowClose } from "./windowClose"; +import { + beginSafeWindowClose, + resolveCloseListenerRegistration, + runSafeWindowClose, +} from "./windowClose"; + +describe("beginSafeWindowClose", () => { + it("prevents every close request while starting the workflow only once", () => { + const inProgress = { current: false }; + const first = { preventDefault: vi.fn() }; + const repeated = { preventDefault: vi.fn() }; + + expect(beginSafeWindowClose(first, inProgress)).toBe(true); + expect(beginSafeWindowClose(repeated, inProgress)).toBe(false); + + expect(first.preventDefault).toHaveBeenCalledOnce(); + expect(repeated.preventDefault).toHaveBeenCalledOnce(); + }); +}); + +describe("resolveCloseListenerRegistration", () => { + it("reports a registration failure and returns a safe cleanup", async () => { + const error = new Error("listener unavailable"); + const onError = vi.fn(); + + const cleanup = await resolveCloseListenerRegistration( + Promise.reject(error), + onError, + ); + + expect(onError).toHaveBeenCalledWith(error); + expect(() => cleanup()).not.toThrow(); + }); +}); describe("runSafeWindowClose", () => { it("requests a native close only after the pending draft is durably flushed", async () => { @@ -11,7 +44,7 @@ describe("runSafeWindowClose", () => { }, persistRecovery: async () => { order.push("recovery"); - return undefined; + return { status: "not-needed" }; }, closeWindow: async () => { order.push("close"); @@ -31,7 +64,7 @@ describe("runSafeWindowClose", () => { }, persistRecovery: async () => { order.push("recovery"); - return "/recovery/Plan.md"; + return { status: "recovered", path: "/recovery/Plan.md" }; }, closeWindow: async () => { order.push("close"); @@ -41,27 +74,74 @@ describe("runSafeWindowClose", () => { expect(order).toEqual(["flush", "recovery", "close"]); expect(result).toEqual({ recoveredTo: "/recovery/Plan.md", + saveError: expect.any(Error), }); }); - it("throws the save error when recovery has no target", async () => { + it("closes after a flush failure when there is no dirty draft to recover", async () => { + const closeWindow = vi.fn(async () => undefined); + + const result = await runSafeWindowClose({ + flushDraft: async () => { + throw new Error("storage offline"); + }, + persistRecovery: async () => ({ status: "not-needed" }), + closeWindow, + }); + + expect(result.saveError).toEqual(expect.any(Error)); + expect(closeWindow).toHaveBeenCalledTimes(1); + }); + + it("retains both save and recovery errors when recovery persistence fails", async () => { + const saveError = new Error("revision conflict"); + const recoveryError = new Error("recovery disk full"); const closeWindow = vi.fn(async () => undefined); await expect( runSafeWindowClose({ flushDraft: async () => { - throw new Error("storage offline"); + throw saveError; + }, + persistRecovery: async () => { + throw recoveryError; }, - persistRecovery: async () => undefined, closeWindow, }), - ).rejects.toThrow("storage offline"); + ).rejects.toMatchObject({ + cause: { saveError, recoveryError }, + }); expect(closeWindow).not.toHaveBeenCalled(); }); + it("records the recovery result before destroying the window", async () => { + const order: string[] = []; + + await runSafeWindowClose({ + flushDraft: async () => { + throw new Error("revision conflict"); + }, + persistRecovery: async () => ({ + status: "recovered", + path: "/recovery/Plan.md", + }), + beforeClose: async () => { + order.push("notice"); + }, + closeWindow: async () => { + order.push("close"); + }, + }); + + expect(order).toEqual(["notice", "close"]); + }); + it("does not treat a native close failure as a save failure", async () => { - const persistRecovery = vi.fn(async () => "/recovery/Plan.md"); + const persistRecovery = vi.fn(async () => ({ + status: "recovered" as const, + path: "/recovery/Plan.md", + })); const closeWindow = vi.fn(async () => { throw new Error("native close failed"); }); diff --git a/src/lib/windowClose.ts b/src/lib/windowClose.ts index 400ebcc7..c72ff5c5 100644 --- a/src/lib/windowClose.ts +++ b/src/lib/windowClose.ts @@ -1,6 +1,11 @@ +export type RecoveryPersistenceResult = + | { status: "not-needed" } + | { status: "recovered"; path: string }; + export interface SafeWindowCloseDependencies { flushDraft: () => Promise; - persistRecovery: () => Promise; + persistRecovery: () => Promise; + beforeClose?: (result: SafeWindowCloseResult) => Promise; closeWindow: () => Promise; } @@ -9,6 +14,28 @@ export interface SafeWindowCloseResult { saveError?: unknown; } +export function beginSafeWindowClose( + event: { preventDefault: () => void }, + inProgress: { current: boolean }, +): boolean { + event.preventDefault(); + if (inProgress.current) return false; + inProgress.current = true; + return true; +} + +export async function resolveCloseListenerRegistration( + registration: Promise<() => void>, + onError: (error: unknown) => void, +): Promise<() => void> { + try { + return await registration; + } catch (error) { + onError(error); + return () => undefined; + } +} + export async function runSafeWindowClose( dependencies: SafeWindowCloseDependencies, ): Promise { @@ -17,13 +44,24 @@ export async function runSafeWindowClose( try { await dependencies.flushDraft(); } catch (saveError) { - const recoveredTo = await dependencies.persistRecovery(); - if (!recoveredTo) { - throw saveError; + let recovery: RecoveryPersistenceResult; + try { + recovery = await dependencies.persistRecovery(); + } catch (recoveryError) { + const combinedError = new Error( + "Could not save or recover the draft before closing.", + ) as Error & { cause?: unknown }; + combinedError.cause = { saveError, recoveryError }; + throw combinedError; } - result = { recoveredTo }; + + result = + recovery.status === "recovered" + ? { recoveredTo: recovery.path, saveError } + : { saveError }; } + await dependencies.beforeClose?.(result); await dependencies.closeWindow(); return result; } diff --git a/src/lib/windowMode.test.ts b/src/lib/windowMode.test.ts new file mode 100644 index 00000000..85241f5a --- /dev/null +++ b/src/lib/windowMode.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { getWindowMode } from "./windowMode"; + +describe("getWindowMode", () => { + it("decodes an encoded standalone path exactly once", () => { + expect( + getWindowMode("?mode=preview&file=%2Ftmp%2F100%25.md"), + ).toEqual({ + isPreview: true, + isPreferences: false, + previewFile: "/tmp/100%.md", + }); + }); + + it("recognizes a dedicated Preferences window", () => { + expect(getWindowMode("?mode=preferences")).toEqual({ + isPreview: false, + isPreferences: true, + previewFile: null, + }); + }); +}); diff --git a/src/lib/windowMode.ts b/src/lib/windowMode.ts new file mode 100644 index 00000000..b3d2301a --- /dev/null +++ b/src/lib/windowMode.ts @@ -0,0 +1,16 @@ +export interface WindowMode { + isPreview: boolean; + isPreferences: boolean; + previewFile: string | null; +} + +export function getWindowMode(search: string): WindowMode { + const params = new URLSearchParams(search); + const mode = params.get("mode"); + const file = params.get("file"); + return { + isPreview: mode === "preview" && !!file, + isPreferences: mode === "preferences", + previewFile: file, + }; +} diff --git a/src/lib/windowShortcutCallsites.test.ts b/src/lib/windowShortcutCallsites.test.ts index 5fcda828..04dbf7ba 100644 --- a/src/lib/windowShortcutCallsites.test.ts +++ b/src/lib/windowShortcutCallsites.test.ts @@ -15,7 +15,9 @@ describe("global shortcut call sites", () => { it("recognizes a dedicated preferences window mode", () => { const source = readSource("src/App.tsx"); - expect(source).toContain('mode === "preferences"'); + expect(source).toContain('import { getWindowMode } from "./lib/windowMode"'); + expect(source).toContain("getWindowMode(window.location.search)"); + expect(source).toContain("if (isPreferences)"); expect(source).toContain("