From a617a4e356ec121bec4582a2761d38eadf7e228a Mon Sep 17 00:00:00 2001 From: Norkep Date: Mon, 3 Aug 2026 17:05:59 +0200 Subject: [PATCH 01/32] 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 25d474460e0eb748f2e2bc64c49defdeda423274 Mon Sep 17 00:00:00 2001 From: Norkep Date: Tue, 4 Aug 2026 07:37:42 +0200 Subject: [PATCH 02/32] fix(sidebar): restore focus after sort menu close --- src/components/layout/SidebarControls.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/layout/SidebarControls.tsx b/src/components/layout/SidebarControls.tsx index dc17a851..27ad856d 100644 --- a/src/components/layout/SidebarControls.tsx +++ b/src/components/layout/SidebarControls.tsx @@ -41,7 +41,6 @@ export function NoteSortMenu({ className="z-50 min-w-44 rounded-md border border-border bg-bg py-1 shadow-lg" sideOffset={5} align="end" - onCloseAutoFocus={(event) => event.preventDefault()} > Sort notes From 8e8eca1e57a6fe3d12555cb837e839caee37a29a Mon Sep 17 00:00:00 2001 From: Norkep Date: Mon, 3 Aug 2026 17:14:47 +0200 Subject: [PATCH 03/32] 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 fd663cbd22ff0e581312a13ac8d18929d82063b4 Mon Sep 17 00:00:00 2001 From: Norkep Date: Mon, 3 Aug 2026 20:55:56 +0200 Subject: [PATCH 06/32] feat: add multiple windows and workspaces --- src-tauri/capabilities/default.json | 6 +- src-tauri/src/lib.rs | 4868 ++++++++++++++---- src-tauri/src/note_persistence_tests.rs | 581 +++ src-tauri/src/watcher_debounce.rs | 107 + src/App.tsx | 128 +- src/components/editor/Editor.tsx | 35 +- src/components/layout/Sidebar.tsx | 109 +- src/components/layout/WorkspaceMenu.test.tsx | 289 ++ src/components/layout/WorkspaceMenu.tsx | 224 + src/components/notes/NoteList.tsx | 11 + src/context/GitContext.tsx | 15 +- src/context/NotesContext.tsx | 682 ++- src/context/ThemeContext.tsx | 163 +- src/lib/documentMutationSafety.test.ts | 44 + src/lib/documentMutationSafety.ts | 26 + src/lib/noteSync.test.ts | 151 + src/lib/noteSync.ts | 71 + src/lib/settingsScope.test.ts | 35 + src/lib/settingsScope.ts | 14 + src/lib/useWindowSessionPersistence.ts | 185 + src/lib/windowSession.test.ts | 134 + src/lib/windowSession.ts | 163 + src/lib/workspace.test.ts | 10 + src/lib/workspace.ts | 3 + src/lib/workspaceSwitch.test.ts | 58 + src/lib/workspaceSwitch.ts | 15 + src/services/files.ts | 1 + src/services/notes.test.ts | 167 + src/services/notes.ts | 65 +- src/services/windowSession.test.ts | 33 + src/services/windowSession.ts | 15 + src/types/note.ts | 12 +- 32 files changed, 7220 insertions(+), 1200 deletions(-) create mode 100644 src-tauri/src/note_persistence_tests.rs create mode 100644 src-tauri/src/watcher_debounce.rs create mode 100644 src/components/layout/WorkspaceMenu.test.tsx create mode 100644 src/components/layout/WorkspaceMenu.tsx create mode 100644 src/lib/documentMutationSafety.test.ts create mode 100644 src/lib/documentMutationSafety.ts create mode 100644 src/lib/noteSync.test.ts create mode 100644 src/lib/noteSync.ts create mode 100644 src/lib/settingsScope.test.ts create mode 100644 src/lib/settingsScope.ts create mode 100644 src/lib/useWindowSessionPersistence.ts create mode 100644 src/lib/windowSession.test.ts create mode 100644 src/lib/windowSession.ts create mode 100644 src/lib/workspace.test.ts create mode 100644 src/lib/workspace.ts create mode 100644 src/lib/workspaceSwitch.test.ts create mode 100644 src/lib/workspaceSwitch.ts create mode 100644 src/services/notes.test.ts create mode 100644 src/services/windowSession.test.ts create mode 100644 src/services/windowSession.ts diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 6f8e840e..95bb0efd 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -2,7 +2,7 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "Capability for all app windows", - "windows": ["main", "preview-*", "preferences"], + "windows": ["main", "preview-*", "workspace-*", "preferences"], "permissions": [ "core:default", "core:window:allow-close", @@ -10,6 +10,10 @@ "core:window:allow-set-focus", "core:webview:allow-print", "opener:default", + { + "identifier": "opener:allow-open-path", + "allow": [{ "path": "$HOME/**" }] + }, "opener:allow-open-url", "opener:allow-reveal-item-in-dir", "dialog:default", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index dcecccde..9d892732 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4,9 +4,8 @@ use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher}; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex, RwLock}; -use std::time::{Duration, Instant}; use tantivy::collector::TopDocs; use tantivy::query::QueryParser; use tantivy::schema::*; @@ -20,8 +19,12 @@ use tokio::io::AsyncWriteExt; mod git; mod persistence; mod draft_checkpoint; +mod watcher_debounce; -static RECOVERY_SNAPSHOT_SEQUENCE: AtomicU64 = AtomicU64::new(1); +use watcher_debounce::{WatcherDebounce, WATCHER_DEBOUNCE_WINDOW}; + +#[cfg(test)] +mod note_persistence_tests; // Note metadata for list display #[derive(Debug, Clone, Serialize, Deserialize)] @@ -40,13 +43,14 @@ pub struct CliStatus { } // Full note content -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct Note { pub id: String, pub title: String, pub content: String, pub path: String, pub modified: i64, + pub revision: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -56,8 +60,15 @@ pub struct NoteConflictSnapshot { pub revision: String, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "status", rename_all = "camelCase")] +pub enum NoteSaveResult { + Saved { note: Note }, + Conflict { current: Option }, +} + // Theme color customization -#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] #[serde(rename_all = "camelCase")] pub struct ThemeColors { pub bg: Option, @@ -72,7 +83,7 @@ pub struct ThemeColors { } // Theme settings -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct ThemeSettings { pub mode: String, // "light" | "dark" | "system" @@ -91,7 +102,7 @@ impl Default for ThemeSettings { } // Editor font settings (simplified) -#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] #[serde(rename_all = "camelCase")] pub struct EditorFontSettings { pub base_font_family: Option, // "system-sans" | "serif" | "monospace" @@ -112,10 +123,481 @@ pub enum TextDirection { #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct AppConfig { pub notes_folder: Option, + #[serde(default)] + pub workspaces: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub global_settings: Option, + #[serde(default, rename = "windowSessions", alias = "records")] + pub records: HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WindowGeometry { + pub x: i32, + pub y: i32, + pub width: u32, + pub height: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WindowSession { + pub workspace: String, + pub selected_note_id: Option, + pub sidebar_visible: bool, + pub focus_mode: bool, + pub geometry: Option, +} + +impl WindowSession { + fn for_workspace(workspace: String) -> Self { + Self { + workspace, + selected_note_id: None, + sidebar_visible: true, + focus_mode: false, + geometry: None, + } + } +} + +fn upsert_window_session_workspace( + config: &mut AppConfig, + label: &str, + workspace: String, +) { + config + .records + .entry(label.to_string()) + .and_modify(|record| record.workspace = workspace.clone()) + .or_insert_with(|| WindowSession::for_workspace(workspace)); +} + +fn remove_window_session_on_destroy( + config: &mut AppConfig, + label: &str, + is_quitting: bool, + preserve_for_preview: bool, +) -> bool { + !is_quitting && !preserve_for_preview && config.records.remove(label).is_some() +} + +fn restorable_window_sessions( + config: &AppConfig, + standalone_preview: bool, + workspace_is_available: impl Fn(&str) -> bool, +) -> Vec<(String, WindowSession)> { + if standalone_preview { + return Vec::new(); + } + + let mut records: Vec<_> = config + .records + .iter() + .filter(|(label, record)| { + !label.starts_with("preview-") && workspace_is_available(&record.workspace) + }) + .map(|(label, record)| (label.clone(), record.clone())) + .collect(); + records.sort_by(|(left, _), (right, _)| { + (left != "main", left).cmp(&(right != "main", right)) + }); + records +} + +fn fallback_main_session( + config: &AppConfig, + default_workspace: Option<&str>, + standalone_preview: bool, + main_restored: bool, + workspace_is_available: impl Fn(&str) -> bool, +) -> Option { + if standalone_preview || main_restored { + return None; + } + + let workspace = default_workspace?.to_string(); + if !workspace_is_available(&workspace) { + return None; + } + + let mut fallback = config + .records + .get("main") + .cloned() + .unwrap_or_else(|| WindowSession::for_workspace(workspace.clone())); + fallback.workspace = workspace; + Some(fallback) +} + +fn remember_workspace(config: &mut AppConfig, path: String) { + if !config.workspaces.iter().any(|workspace| workspace == &path) { + config.workspaces.push(path); + } +} + +fn workspace_is_remembered(config: &AppConfig, path: &str) -> bool { + config.notes_folder.as_deref() == Some(path) + || config.workspaces.iter().any(|workspace| workspace == path) +} + +/// Forget only Scratch's reference to a workspace. No filesystem operation is +/// performed, so notes and folders on disk remain untouched. +fn forget_workspace(config: &mut AppConfig, path: &str) -> bool { + if !workspace_is_remembered(config, path) { + return false; + } + + config.workspaces.retain(|workspace| workspace != path); + if config.notes_folder.as_deref() == Some(path) { + config.notes_folder = config.workspaces.first().cloned(); + } + config.records.retain(|_, record| record.workspace != path); + true +} + +static WORKSPACE_WINDOW_SEQUENCE: AtomicU64 = AtomicU64::new(1); +static RECOVERY_SNAPSHOT_SEQUENCE: AtomicU64 = AtomicU64::new(1); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct NativeNewWindowMenuSpec { + parent_menu: &'static str, + id: &'static str, + label: &'static str, + accelerator: &'static str, +} + +fn native_new_window_menu_spec() -> NativeNewWindowMenuSpec { + NativeNewWindowMenuSpec { + parent_menu: "File", + id: "new-window", + label: "New Window", + accelerator: "CmdOrCtrl+Shift+N", + } +} + +fn native_open_folder_menu_spec() -> NativeNewWindowMenuSpec { + NativeNewWindowMenuSpec { + parent_menu: "File", + id: "open-folder", + label: "Open Folder…", + accelerator: "CmdOrCtrl+O", + } +} + +fn native_preferences_menu_spec() -> NativeNewWindowMenuSpec { + NativeNewWindowMenuSpec { + parent_menu: "Application", + id: "preferences", + label: "Preferences…", + accelerator: "CmdOrCtrl+,", + } +} + +fn focused_window_target<'a>( + windows: impl IntoIterator, +) -> Option<&'a str> { + let mut main = None; + let mut first = None; + + for (label, is_focused) in windows { + if is_focused { + return Some(label); + } + if label == "main" { + main = Some(label); + } + first.get_or_insert(label); + } + + main.or(first) +} + +fn new_window_event_target<'a>( + windows: impl IntoIterator, +) -> Option<&'a str> { + let mut main = None; + let mut first_full_editor = None; + + for (label, is_focused) in windows { + if label.starts_with("preview-") || label == "preferences" { + continue; + } + if is_focused { + return Some(label); + } + if label == "main" { + main = Some(label); + } + first_full_editor.get_or_insert(label); + } + + main.or(first_full_editor) +} + +fn build_application_menu( + app_handle: &AppHandle, +) -> tauri::Result> { + use tauri::menu::{Menu, MenuItem, PredefinedMenuItem, Submenu}; + + let menu = Menu::default(app_handle)?; + let spec = native_new_window_menu_spec(); + let open_folder_spec = native_open_folder_menu_spec(); + let preferences_spec = native_preferences_menu_spec(); + let new_window = MenuItem::with_id( + app_handle, + spec.id, + spec.label, + true, + Some(spec.accelerator), + )?; + let open_folder = MenuItem::with_id( + app_handle, + open_folder_spec.id, + open_folder_spec.label, + true, + Some(open_folder_spec.accelerator), + )?; + let preferences = MenuItem::with_id( + app_handle, + preferences_spec.id, + preferences_spec.label, + true, + Some(preferences_spec.accelerator), + )?; + + let application_menu_name = app_handle + .config() + .product_name + .clone() + .unwrap_or_else(|| app_handle.package_info().name.clone()); + let mut has_file_menu = false; + + for item in menu.items()? { + let Some(submenu) = item.as_submenu() else { + continue; + }; + let text = submenu.text()?; + if text == spec.parent_menu { + has_file_menu = true; + let separator = PredefinedMenuItem::separator(app_handle)?; + submenu.prepend_items(&[&open_folder, &new_window, &separator])?; + } else if text == application_menu_name { + let separator = PredefinedMenuItem::separator(app_handle)?; + submenu.insert_items(&[&preferences, &separator], 1)?; + } + } + + if has_file_menu { + return Ok(menu); + } + + // Tauri omits a default File menu on a few Unix desktop targets. + // Keep the explicit new-window entry available there without replacing + // any of the other platform-native default menus. + let file_menu = Submenu::with_items( + app_handle, + spec.parent_menu, + true, + &[&open_folder, &new_window], + )?; + menu.prepend(&file_menu)?; + Ok(menu) +} + +fn emit_native_new_window_request(app: &AppHandle) { + let window_states = app + .webview_windows() + .into_iter() + .map(|(label, window)| { + let is_focused = window.is_focused().unwrap_or(false); + (label, is_focused) + }) + .collect::>(); + let target = new_window_event_target( + window_states + .iter() + .map(|(label, is_focused)| (label.as_str(), *is_focused)), + ); + + if let Some(label) = target { + let _ = app.emit_to(label, "open-folder-in-new-window", ()); + } +} + +fn emit_native_open_folder_request(app: &AppHandle) { + let window_states = app + .webview_windows() + .into_iter() + .map(|(label, window)| (label, window.is_focused().unwrap_or(false))) + .collect::>(); + let target = focused_window_target( + window_states + .iter() + .map(|(label, is_focused)| (label.as_str(), *is_focused)), + ); + + if let Some(label) = target { + let _ = app.emit_to(label, "open-folder-in-new-window", ()); + } +} + +fn workspace_path_key(path: &Path) -> String { + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + + let mut hasher = DefaultHasher::new(); + path.hash(&mut hasher); + format!("workspace-{:x}", hasher.finish()) +} + +fn workspace_window_label(path: &Path, sequence: u64) -> String { + format!("{}-{}", workspace_path_key(path), sequence) +} + +fn next_workspace_window_label(path: &Path) -> String { + workspace_window_label( + path, + WORKSPACE_WINDOW_SEQUENCE.fetch_add(1, Ordering::Relaxed), + ) +} + +#[derive(Debug, Default)] +struct WorkspaceBindings { + by_window: HashMap, +} + +impl WorkspaceBindings { + fn bind( + &mut self, + window_label: impl Into, + path: impl Into, + ) -> Option { + let window_label = window_label.into(); + let path = path.into(); + + self.by_window.insert(window_label, path) + } + + fn unbind(&mut self, window_label: &str) -> Option { + self.by_window.remove(window_label) + } + + fn path_for_window(&self, window_label: &str) -> Option<&str> { + self.by_window.get(window_label).map(String::as_str) + } + + fn window_for_path(&self, path: &str) -> Option<&str> { + self.by_window + .iter() + .find_map(|(label, workspace)| (workspace == path).then_some(label.as_str())) + } + + fn windows_for_path(&self, path: &str) -> Vec { + let mut labels: Vec = self + .by_window + .iter() + .filter_map(|(label, workspace)| (workspace == path).then_some(label.clone())) + .collect(); + labels.sort(); + labels + } + + fn window_for_note_path(&self, note_path: &Path) -> Option<&str> { + self.by_window + .iter() + .filter(|(_, workspace)| note_path.starts_with(Path::new(workspace))) + .max_by_key(|(_, workspace)| Path::new(workspace).components().count()) + .map(|(label, _)| label.as_str()) + } + + fn windows_for_note_path(&self, note_path: &Path) -> Vec { + let max_depth = self + .by_window + .values() + .filter(|workspace| note_path.starts_with(Path::new(workspace))) + .map(|workspace| Path::new(workspace).components().count()) + .max(); + + let Some(max_depth) = max_depth else { + return Vec::new(); + }; + + let mut labels: Vec = self + .by_window + .iter() + .filter(|(_, workspace)| { + note_path.starts_with(Path::new(workspace)) + && Path::new(workspace).components().count() == max_depth + }) + .map(|(label, _)| label.clone()) + .collect(); + labels.sort(); + labels + } +} + +fn note_change_targets( + bindings: &WorkspaceBindings, + note_path: &Path, + origin_window: Option<&str>, +) -> Vec { + bindings + .windows_for_note_path(note_path) + .into_iter() + .filter(|label| origin_window != Some(label.as_str())) + .collect() +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceInfo { + pub path: String, + pub name: String, + pub is_default: bool, + pub is_current: bool, + pub is_open: bool, +} + +fn build_workspace_infos( + config: &AppConfig, + bindings: &WorkspaceBindings, + current_window_label: &str, +) -> Vec { + let mut paths = Vec::new(); + if let Some(default_path) = config.notes_folder.clone() { + paths.push(default_path); + } + for workspace in &config.workspaces { + if !paths.iter().any(|path| path == workspace) { + paths.push(workspace.clone()); + } + } + + let current_path = bindings.path_for_window(current_window_label); + paths + .into_iter() + .map(|path| { + let name = Path::new(&path) + .file_name() + .and_then(|name| name.to_str()) + .filter(|name| !name.is_empty()) + .unwrap_or(&path) + .to_string(); + WorkspaceInfo { + is_default: config.notes_folder.as_deref() == Some(path.as_str()), + is_current: current_path == Some(path.as_str()), + is_open: bindings.window_for_path(&path).is_some(), + path, + name, + } + }) + .collect() } // Per-folder settings (stored in .scratch/settings.json within notes folder) -#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] pub struct Settings { pub theme: ThemeSettings, #[serde(rename = "editorFont")] @@ -159,32 +641,291 @@ pub struct Settings { pub custom_colors_dark: Option>, } -// Search result -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SearchResult { - pub id: String, - pub title: String, - pub preview: String, - pub modified: i64, - pub score: f32, -} - -// AI execution result -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AiExecutionResult { - pub success: bool, - pub output: String, - pub error: Option, -} - -// File watcher state -pub struct FileWatcherState { - #[allow(dead_code)] - watcher: RecommendedWatcher, +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +pub struct GlobalSettings { + pub theme: ThemeSettings, + #[serde(rename = "editorFont")] + pub editor_font: Option, + #[serde(rename = "textDirection")] + pub text_direction: Option, + #[serde(rename = "editorWidth")] + pub editor_width: Option, + #[serde(rename = "defaultNoteName")] + pub default_note_name: Option, + #[serde(rename = "interfaceZoom")] + 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, + #[serde(rename = "sidebarWidthPx")] + pub sidebar_width_px: Option, + #[serde(rename = "ollamaModel")] + pub ollama_model: Option, + #[serde(rename = "customColorsLight")] + pub custom_colors_light: Option>, + #[serde(rename = "customColorsDark")] + pub custom_colors_dark: Option>, } -// Tantivy search index state +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +pub struct WorkspaceSettings { + #[serde(rename = "gitEnabled")] + pub git_enabled: Option, + #[serde(rename = "pinnedNoteIds")] + pub pinned_note_ids: Option>, + #[serde(rename = "foldersEnabled")] + pub folders_enabled: Option, + #[serde(rename = "sidebarSortOrder")] + pub sidebar_sort_order: Option, + #[serde(rename = "ignoredPatterns")] + pub ignored_patterns: Option>, +} + +fn split_settings(settings: Settings) -> (GlobalSettings, WorkspaceSettings) { + let Settings { + theme, + editor_font, + git_enabled, + pinned_note_ids, + text_direction, + editor_width, + default_note_name, + interface_zoom, + custom_editor_width_px, + editor_width_resize_enabled, + editor_toolbar_visible, + title_bar_modified_date_visible, + title_bar_filename_visible, + sidebar_width_px, + ollama_model, + folders_enabled, + sidebar_sort_order, + ignored_patterns, + custom_colors_light, + custom_colors_dark, + } = settings; + + ( + GlobalSettings { + theme, + editor_font, + text_direction, + editor_width, + default_note_name, + interface_zoom, + custom_editor_width_px, + editor_width_resize_enabled, + editor_toolbar_visible, + title_bar_modified_date_visible, + title_bar_filename_visible, + sidebar_width_px, + ollama_model, + custom_colors_light, + custom_colors_dark, + }, + WorkspaceSettings { + git_enabled, + pinned_note_ids, + folders_enabled, + sidebar_sort_order, + ignored_patterns, + }, + ) +} + +fn merge_settings(global: &GlobalSettings, workspace: &WorkspaceSettings) -> Settings { + Settings { + theme: global.theme.clone(), + editor_font: global.editor_font.clone(), + git_enabled: workspace.git_enabled, + pinned_note_ids: workspace.pinned_note_ids.clone(), + text_direction: global.text_direction.clone(), + editor_width: global.editor_width.clone(), + default_note_name: global.default_note_name.clone(), + interface_zoom: global.interface_zoom, + custom_editor_width_px: global.custom_editor_width_px, + editor_width_resize_enabled: global.editor_width_resize_enabled, + editor_toolbar_visible: global.editor_toolbar_visible, + title_bar_modified_date_visible: global.title_bar_modified_date_visible, + title_bar_filename_visible: global.title_bar_filename_visible, + sidebar_width_px: global.sidebar_width_px, + ollama_model: global.ollama_model.clone(), + folders_enabled: workspace.folders_enabled, + sidebar_sort_order: workspace.sidebar_sort_order.clone(), + ignored_patterns: workspace.ignored_patterns.clone(), + custom_colors_light: global.custom_colors_light.clone(), + custom_colors_dark: global.custom_colors_dark.clone(), + } +} + +fn merge_json_patch(target: &mut serde_json::Value, patch: serde_json::Value) { + match (target, patch) { + (serde_json::Value::Object(target), serde_json::Value::Object(patch)) => { + for (key, value) in patch { + match target.get_mut(&key) { + Some(current) => merge_json_patch(current, value), + None => { + target.insert(key, value); + } + } + } + } + (target, patch) => *target = patch, + } +} + +fn apply_settings_patch(current: &T, patch: serde_json::Value) -> Result +where + T: Serialize + serde::de::DeserializeOwned, +{ + if !patch.is_object() { + return Err("Settings patch must be an object".to_string()); + } + + let mut merged = serde_json::to_value(current).map_err(|error| error.to_string())?; + merge_json_patch(&mut merged, patch); + serde_json::from_value(merged).map_err(|error| error.to_string()) +} + +fn migrate_legacy_settings( + config: &mut AppConfig, + legacy_settings: Settings, +) -> Option { + if config.global_settings.is_some() { + return None; + } + + let (global, workspace) = split_settings(legacy_settings); + config.global_settings = Some(global); + Some(workspace) +} + +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +enum SettingsChangeScope { + Global, + Workspace, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct SettingsChangedPayload { + scope: SettingsChangeScope, + workspace: Option, +} + +#[derive(Debug, PartialEq, Eq)] +enum SettingsChangeTargets { + All, + Windows(Vec), +} + +fn settings_change_targets( + bindings: &WorkspaceBindings, + scope: SettingsChangeScope, + workspace: Option<&str>, +) -> SettingsChangeTargets { + match scope { + SettingsChangeScope::Global => SettingsChangeTargets::All, + SettingsChangeScope::Workspace => SettingsChangeTargets::Windows( + workspace + .map(|path| bindings.windows_for_path(path)) + .unwrap_or_default(), + ), + } +} + +// Search result +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SearchResult { + pub id: String, + pub title: String, + pub preview: String, + pub modified: i64, + pub score: f32, +} + +// AI execution result +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AiExecutionResult { + pub success: bool, + pub output: String, + pub error: Option, +} + +// File watcher state +pub struct FileWatcherState { + #[allow(dead_code)] + watcher: RecommendedWatcher, +} + +fn ensure_shared_resource( + slot: &Mutex>, + create: impl FnOnce() -> Result, +) -> Result { + let mut resource = slot + .lock() + .map_err(|_| "shared workspace resource lock poisoned".to_string())?; + if resource.is_some() { + return Ok(false); + } + + *resource = Some(create()?); + Ok(true) +} + +fn get_or_initialize_shared( + registry: &RwLock>>, + initializers: &Mutex>>>, + key: K, + create: impl FnOnce() -> Result, +) -> Result, E> +where + K: Clone + Eq + std::hash::Hash, +{ + if let Some(existing) = registry + .read() + .expect("shared registry read lock") + .get(&key) + .cloned() + { + return Ok(existing); + } + + let initializer = initializers + .lock() + .expect("shared initializer registry lock") + .entry(key.clone()) + .or_insert_with(|| Arc::new(Mutex::new(()))) + .clone(); + let _initializing = initializer.lock().expect("shared initializer lock"); + + if let Some(existing) = registry + .read() + .expect("shared registry read lock") + .get(&key) + .cloned() + { + return Ok(existing); + } + + let created = Arc::new(create()?); + registry + .write() + .expect("shared registry write lock") + .insert(key, Arc::clone(&created)); + Ok(created) +} + +// Tantivy search index state pub struct SearchIndex { index: Index, reader: IndexReader, @@ -354,29 +1095,334 @@ impl SearchIndex { } } +pub struct WorkspaceSession { + pub notes_folder: String, + pub settings: RwLock, + pub notes_cache: RwLock>, + pub file_watcher: Mutex>, + pub search_index: Mutex>, + pub(crate) debounce_map: Arc>, +} + +impl WorkspaceSession { + #[cfg(test)] + fn empty(notes_folder: String) -> Self { + Self { + notes_folder, + settings: RwLock::new(WorkspaceSettings::default()), + notes_cache: RwLock::new(HashMap::new()), + file_watcher: Mutex::new(None), + search_index: Mutex::new(None), + debounce_map: Arc::new(Mutex::new(WatcherDebounce::default())), + } + } + + fn initialize(app: &AppHandle, path: &Path) -> Result { + let (canonical_path, notes_folder) = prepare_notes_folder(path)?; + Self::initialize_prepared(app, canonical_path, notes_folder) + } + + fn initialize_prepared( + app: &AppHandle, + canonical_path: PathBuf, + notes_folder: String, + ) -> Result { + let settings = load_settings(¬es_folder); + let ignored_dirs = get_effective_ignored_dirs(&settings); + let search_index = get_workspace_search_index_path(app, &canonical_path) + .ok() + .and_then(|index_path| SearchIndex::new(&index_path).ok()) + .inspect(|index| { + let _ = index.rebuild_index(&canonical_path, &ignored_dirs); + }); + + let _ = app + .asset_protocol_scope() + .allow_directory(&canonical_path, true); + + Ok(Self { + notes_folder, + settings: RwLock::new(settings), + notes_cache: RwLock::new(HashMap::new()), + file_watcher: Mutex::new(None), + search_index: Mutex::new(search_index), + debounce_map: Arc::new(Mutex::new(WatcherDebounce::default())), + }) + } +} + // App state with improved structure pub struct AppState { pub app_config: RwLock, // notes_folder path (stored in app data) - pub settings: RwLock, // per-folder settings (stored in .scratch/) + pub settings: RwLock, // per-folder settings (stored in .scratch/) pub notes_cache: RwLock>, pub file_watcher: Mutex>, pub search_index: Mutex>, - pub debounce_map: Arc>>, + pub(crate) debounce_map: Arc>, + workspace_sessions: RwLock>>, + workspace_initializers: Mutex>>>, + workspace_bindings: RwLock, + is_quitting: AtomicBool, + preserve_session_labels: Mutex>, +} + +enum WorkspaceRuntime<'a> { + Main(&'a AppState), + Session(Arc), +} + +impl WorkspaceRuntime<'_> { + fn notes_folder(&self) -> Result { + match self { + Self::Main(state) => state + .workspace_bindings + .read() + .expect("workspace bindings read lock") + .path_for_window("main") + .map(str::to_string) + .or_else(|| { + state + .app_config + .read() + .expect("app_config read lock") + .notes_folder + .clone() + }) + .ok_or_else(|| "Notes folder not set".to_string()), + Self::Session(session) => Ok(session.notes_folder.clone()), + } + } + + fn settings(&self) -> &RwLock { + match self { + Self::Main(state) => &state.settings, + Self::Session(session) => &session.settings, + } + } + + fn note_path(&self, id: &str) -> Result { + abs_path_from_id(&PathBuf::from(self.notes_folder()?), id) + } + + fn notes_cache(&self) -> &RwLock> { + match self { + Self::Main(state) => &state.notes_cache, + Self::Session(session) => &session.notes_cache, + } + } + + fn file_watcher(&self) -> &Mutex> { + match self { + Self::Main(state) => &state.file_watcher, + Self::Session(session) => &session.file_watcher, + } + } + + fn search_index(&self) -> &Mutex> { + match self { + Self::Main(state) => &state.search_index, + Self::Session(session) => &session.search_index, + } + } + + fn debounce_map(&self) -> &Arc> { + match self { + Self::Main(state) => &state.debounce_map, + Self::Session(session) => &session.debounce_map, + } + } +} + +fn uses_default_workspace_fallback(window_label: &str) -> bool { + window_label == "main" + || window_label == "preferences" + || window_label.starts_with("preview-") +} + +impl AppState { + fn get_or_initialize_workspace_session( + &self, + app: &AppHandle, + path: &Path, + ) -> Result, String> { + let (canonical_path, notes_folder) = prepare_notes_folder(path)?; + get_or_initialize_shared( + &self.workspace_sessions, + &self.workspace_initializers, + notes_folder.clone(), + || WorkspaceSession::initialize_prepared(app, canonical_path, notes_folder), + ) + } + + fn workspace_for_window(&self, window_label: &str) -> Result, String> { + let session = if uses_default_workspace_fallback(window_label) { + self.workspace_session("main") + } else { + self.workspace_session(window_label) + }; + if let Some(session) = session { + return Ok(WorkspaceRuntime::Session(session)); + } + + if uses_default_workspace_fallback(window_label) { + return Ok(WorkspaceRuntime::Main(self)); + } + + self.workspace_session(window_label) + .map(WorkspaceRuntime::Session) + .ok_or_else(|| format!("Workspace session not found for window: {}", window_label)) + } + + fn register_workspace_session( + &self, + window_label: impl Into, + session: WorkspaceSession, + ) -> Arc { + let window_label = window_label.into(); + let notes_folder = session.notes_folder.clone(); + let shared_session = { + let mut sessions = self + .workspace_sessions + .write() + .expect("workspace sessions write lock"); + sessions + .entry(notes_folder.clone()) + .or_insert_with(|| Arc::new(session)) + .clone() + }; + + let previous_folder = self + .workspace_bindings + .write() + .expect("workspace bindings write lock") + .bind(window_label.clone(), notes_folder.clone()); + if let Some(previous_folder) = previous_folder { + if previous_folder != notes_folder { + let has_remaining_windows = !self + .workspace_bindings + .read() + .expect("workspace bindings read lock") + .windows_for_path(&previous_folder) + .is_empty(); + if !has_remaining_windows { + self.workspace_sessions + .write() + .expect("workspace sessions write lock") + .remove(&previous_folder); + self.workspace_initializers + .lock() + .expect("workspace initializers lock") + .remove(&previous_folder); + } + } + } + shared_session + } + + fn workspace_session(&self, window_label: &str) -> Option> { + let notes_folder = self + .workspace_bindings + .read() + .expect("workspace bindings read lock") + .path_for_window(window_label)? + .to_string(); + self.workspace_sessions + .read() + .expect("workspace sessions read lock") + .get(¬es_folder) + .cloned() + } + + fn workspace_session_for_path(&self, notes_folder: &str) -> Option> { + self.workspace_sessions + .read() + .expect("workspace sessions read lock") + .get(notes_folder) + .cloned() + } + + fn bind_existing_workspace_session( + &self, + window_label: impl Into, + notes_folder: impl Into, + ) -> Result, String> { + let window_label = window_label.into(); + let notes_folder = notes_folder.into(); + let session = self + .workspace_session_for_path(¬es_folder) + .ok_or_else(|| format!("Workspace session not found for path: {}", notes_folder))?; + self.workspace_bindings + .write() + .expect("workspace bindings write lock") + .bind(window_label, notes_folder); + Ok(session) + } + + fn remove_workspace_session(&self, window_label: &str) -> Option> { + let (notes_folder, has_remaining_windows) = { + let mut bindings = self + .workspace_bindings + .write() + .expect("workspace bindings write lock"); + let notes_folder = bindings.unbind(window_label)?; + let has_remaining_windows = !bindings.windows_for_path(¬es_folder).is_empty(); + (notes_folder, has_remaining_windows) + }; + + if has_remaining_windows { + return self + .workspace_sessions + .read() + .expect("workspace sessions read lock") + .get(¬es_folder) + .cloned(); + } + + let removed = self.workspace_sessions + .write() + .expect("workspace sessions write lock") + .remove(¬es_folder); + self.workspace_initializers + .lock() + .expect("workspace initializers lock") + .remove(¬es_folder); + removed + } + + #[cfg(test)] + fn workspace_session_count(&self) -> usize { + self.workspace_sessions + .read() + .expect("workspace sessions read lock") + .len() + } } impl Default for AppState { fn default() -> Self { Self { app_config: RwLock::new(AppConfig::default()), - settings: RwLock::new(Settings::default()), + settings: RwLock::new(WorkspaceSettings::default()), notes_cache: RwLock::new(HashMap::new()), file_watcher: Mutex::new(None), search_index: Mutex::new(None), - debounce_map: Arc::new(Mutex::new(HashMap::new())), + debounce_map: Arc::new(Mutex::new(WatcherDebounce::default())), + workspace_sessions: RwLock::new(HashMap::new()), + workspace_initializers: Mutex::new(HashMap::new()), + workspace_bindings: RwLock::new(WorkspaceBindings::default()), + is_quitting: AtomicBool::new(false), + preserve_session_labels: Mutex::new(HashSet::new()), } } } +type PendingOpenRequest = (Vec, String); + +// macOS open-file and single-instance callbacks may arrive before `setup` +// manages AppState. Keep those requests until the notes configuration is ready. +static PENDING_SINGLE_INSTANCE_OPENS: Mutex> = Mutex::new(Vec::new()); +static PENDING_OPENED_MARKDOWN_PATHS: Mutex> = Mutex::new(Vec::new()); + // Utility: Sanitize filename from title fn sanitize_filename(title: &str) -> String { let sanitized: String = title @@ -647,7 +1693,7 @@ const DEFAULT_IGNORED_DIRS: &[&str] = &[ ]; /// Get the effective ignored directories from settings (or defaults if not customized). -fn get_effective_ignored_dirs(settings: &Settings) -> Vec { +fn get_effective_ignored_dirs(settings: &WorkspaceSettings) -> Vec { settings.ignored_patterns.clone().unwrap_or_else(|| { DEFAULT_IGNORED_DIRS.iter().map(|s| s.to_string()).collect() }) @@ -747,11 +1793,11 @@ fn get_settings_path(notes_folder: &str) -> PathBuf { scratch_dir.join("settings.json") } -// Get search index path -fn get_search_index_path(app: &AppHandle) -> Result { +fn get_workspace_search_index_path(app: &AppHandle, notes_folder: &Path) -> Result { let app_data = app.path().app_data_dir()?; - std::fs::create_dir_all(&app_data)?; - Ok(app_data.join("search_index")) + let indexes = app_data.join("workspace_search_indexes"); + std::fs::create_dir_all(&indexes)?; + Ok(indexes.join(workspace_path_key(notes_folder))) } // Load app config from disk (notes folder path) @@ -780,7 +1826,20 @@ fn save_app_config(app: &AppHandle, config: &AppConfig) -> Result<()> { } // Load per-folder settings from disk -fn load_settings(notes_folder: &str) -> Settings { +fn load_settings(notes_folder: &str) -> WorkspaceSettings { + 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 { + WorkspaceSettings::default() + } +} + +fn load_legacy_settings(notes_folder: &str) -> Settings { let path = get_settings_path(notes_folder); if path.exists() { @@ -794,20 +1853,13 @@ fn load_settings(notes_folder: &str) -> Settings { } // Save per-folder settings to disk -fn save_settings(notes_folder: &str, settings: &Settings) -> Result<()> { +fn save_settings(notes_folder: &str, settings: &WorkspaceSettings) -> Result<()> { let path = get_settings_path(notes_folder); let content = serde_json::to_string_pretty(settings)?; std::fs::write(path, content)?; Ok(()) } -// Clean up old entries from debounce map (entries older than 5 seconds) -fn cleanup_debounce_map(map: &Mutex>) { - let mut map = map.lock().expect("debounce map mutex"); - let now = Instant::now(); - map.retain(|_, last| now.duration_since(*last) < Duration::from_secs(5)); -} - // Normalize notes folder path from plain paths and legacy file:// URIs. fn normalize_notes_folder_path(path: &str) -> Result { let trimmed = path.trim(); @@ -826,146 +1878,521 @@ fn normalize_notes_folder_path(path: &str) -> Result { Ok(PathBuf::from(trimmed)) } -/// Shared initialization logic for setting a notes folder. -/// Creates required directories, verifies write access, updates config/settings, -/// adds asset protocol scope, and rebuilds the search index. -fn initialize_notes_folder(app: &AppHandle, path_buf: &PathBuf, state: &AppState) -> Result { - let normalized_path = path_buf.to_string_lossy().into_owned(); - - // Verify it's a valid directory +fn prepare_notes_folder(path_buf: &Path) -> Result<(PathBuf, String), String> { if !path_buf.exists() { std::fs::create_dir_all(path_buf).map_err(|e| e.to_string())?; } - // Create assets folder - let assets = path_buf.join("assets"); - std::fs::create_dir_all(&assets).map_err(|e| e.to_string())?; + let canonical = std::fs::canonicalize(path_buf).map_err(|e| e.to_string())?; + std::fs::create_dir_all(canonical.join("assets")).map_err(|e| e.to_string())?; - // Create .scratch config folder - let scratch_dir = path_buf.join(".scratch"); + let scratch_dir = canonical.join(".scratch"); std::fs::create_dir_all(&scratch_dir).map_err(|e| e.to_string())?; - // Verify write access early to avoid later silent failures let write_test_path = scratch_dir.join(".write-test"); std::fs::write(&write_test_path, b"ok") .map_err(|e| format!("Notes folder is not writable: {}", e))?; let _ = std::fs::remove_file(&write_test_path); - // Load per-folder settings (starts fresh with defaults if none exist) - let settings = load_settings(&normalized_path); - - // Update app config - { - let mut app_config = state.app_config.write().expect("app_config write lock"); - app_config.notes_folder = Some(normalized_path.clone()); - } + let normalized = canonical.to_string_lossy().into_owned(); + Ok((canonical, normalized)) +} - // Update settings in memory - { - let mut current_settings = state.settings.write().expect("settings write lock"); - *current_settings = settings; - } +fn configure_main_workspace( + app: &AppHandle, + path_buf: &Path, + state: &AppState, + make_default: bool, +) -> Result { + let session = WorkspaceSession::initialize(app, path_buf)?; + let normalized_path = session.notes_folder.clone(); - // Save app config to disk { - let app_config = state.app_config.read().expect("app_config read lock"); - save_app_config(app, &app_config).map_err(|e| e.to_string())?; - } - - // Add notes folder to asset protocol scope so images can be served - let _ = app.asset_protocol_scope().allow_directory(path_buf, true); - - // Initialize search index - if let Ok(index_path) = get_search_index_path(app) { - if let Ok(search_index) = SearchIndex::new(&index_path) { - let ignored_dirs = { - let settings = state.settings.read().expect("settings read lock"); - get_effective_ignored_dirs(&settings) - }; - let _ = search_index.rebuild_index(path_buf, &ignored_dirs); - let mut index = state.search_index.lock().expect("search index mutex"); - *index = Some(search_index); + let mut config = state.app_config.write().expect("app_config write lock"); + let mut next = config.clone(); + if make_default { + next.notes_folder = Some(normalized_path.clone()); } + remember_workspace(&mut next, normalized_path.clone()); + upsert_window_session_workspace(&mut next, "main", normalized_path.clone()); + save_app_config(app, &next).map_err(|error| error.to_string())?; + *config = next; } + state.register_workspace_session("main", session); Ok(normalized_path) } +/// Sets and persists the main/default notes folder. +fn initialize_notes_folder( + app: &AppHandle, + path_buf: &Path, + state: &AppState, +) -> Result { + configure_main_workspace(app, path_buf, state, true) +} + +/// Rebinds the main window while preserving the configured default folder. +fn switch_main_workspace( + app: &AppHandle, + path_buf: &Path, + state: &AppState, +) -> Result { + configure_main_workspace(app, path_buf, state, false) +} + // TAURI COMMANDS #[tauri::command] -fn get_notes_folder(state: State) -> Option { +fn get_notes_folder(window: WebviewWindow, state: State) -> Option { state + .workspace_for_window(window.label()) + .ok()? + .notes_folder() + .ok() +} + +#[tauri::command] +fn get_window_session(window: WebviewWindow, state: State) -> Option { + let mut record = state .app_config .read() - .expect("app_config read lock") - .notes_folder - .clone() + .expect("app config read lock") + .records + .get(window.label()) + .cloned()?; + if let Some(workspace) = state + .workspace_bindings + .read() + .expect("workspace bindings read lock") + .path_for_window(window.label()) + { + record.workspace = workspace.to_string(); + } + Some(record) } #[tauri::command] -fn set_notes_folder(app: AppHandle, path: String, state: State) -> Result<(), String> { - let path_buf = normalize_notes_folder_path(&path)?; - initialize_notes_folder(&app, &path_buf, &state)?; +fn update_window_session( + patch: serde_json::Value, + window: WebviewWindow, + state: State, +) -> Result<(), String> { + let workspace = state + .workspace_for_window(window.label())? + .notes_folder()?; + let mut patch = patch; + let patch_object = patch + .as_object_mut() + .ok_or_else(|| "Window session patch must be an object".to_string())?; + patch_object.remove("workspace"); + + let mut config = state.app_config.write().expect("app config write lock"); + if !workspace_is_remembered(&config, &workspace) { + return Ok(()); + } + let current = config + .records + .get(window.label()) + .cloned() + .unwrap_or_else(|| WindowSession::for_workspace(workspace.clone())); + let mut updated = apply_settings_patch(¤t, patch)?; + updated.workspace = workspace; + + let mut next = config.clone(); + next.records.insert(window.label().to_string(), updated); + save_app_config(window.app_handle(), &next).map_err(|error| error.to_string())?; + *config = next; Ok(()) } -#[tauri::command] -async fn list_notes(state: State<'_, AppState>) -> Result, String> { - let folder = { - let app_config = state.app_config.read().expect("app_config read lock"); - app_config - .notes_folder - .clone() - .ok_or("Notes folder not set")? - }; - - let path = PathBuf::from(&folder); - if !path.exists() { - return Ok(vec![]); +fn clear_window_session_record( + app: &AppHandle, + state: &AppState, + label: &str, +) -> Result { + let mut config = state.app_config.write().expect("app config write lock"); + if !config.records.contains_key(label) { + return Ok(false); } - let ignored_dirs = { - let settings = state.settings.read().expect("settings read lock"); - get_effective_ignored_dirs(&settings) - }; + let mut next = config.clone(); + next.records.remove(label); + save_app_config(app, &next).map_err(|error| error.to_string())?; + *config = next; + Ok(true) +} - let path_clone = path.clone(); - let discovered = tokio::task::spawn_blocking(move || { - use walkdir::WalkDir; - let mut results: Vec<(String, String, String, i64)> = Vec::new(); - for entry in WalkDir::new(&path_clone) - .max_depth(10) - .into_iter() - .filter_entry(|e| is_visible_notes_entry(e, &ignored_dirs)) - .flatten() - { - let file_path = entry.path(); - if !file_path.is_file() { - continue; - } - if let Some(id) = id_from_abs_path(&path_clone, file_path, &ignored_dirs) { - if let Ok(content) = std::fs::read_to_string(file_path) { - let modified = entry - .metadata() - .ok() - .and_then(|m| m.modified().ok()) - .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|d| d.as_secs() as i64) - .unwrap_or(0); - let title = extract_title(&content); - let preview = generate_preview(&content); - results.push((id, title, preview, modified)); +fn handle_window_destroyed(app: &AppHandle, label: &str) { + let Some(state) = app.try_state::() else { + return; + }; + let is_quitting = state.is_quitting.load(Ordering::SeqCst); + let preserve_for_preview = state + .preserve_session_labels + .lock() + .expect("preserved session labels mutex") + .remove(label); + { + let mut config = state.app_config.write().expect("app config write lock"); + let mut next = config.clone(); + if remove_window_session_on_destroy( + &mut next, + label, + is_quitting, + preserve_for_preview, + ) { + match save_app_config(app, &next) { + Ok(()) => *config = next, + Err(error) => { + eprintln!("Failed to clear closed window session {label}: {error}") } } } - results - }) - .await - .map_err(|e| e.to_string())?; + } + state.remove_workspace_session(label); + let _ = app.emit("workspaces-changed", ()); +} - let mut notes: Vec = discovered - .into_iter() +fn suppress_full_windows_for_preview(app: &AppHandle) { + let Some(state) = app.try_state::() else { + return; + }; + let windows: Vec<_> = app + .webview_windows() + .into_iter() + .filter(|(label, _)| !label.starts_with("preview-")) + .collect(); + { + let mut preserved = state + .preserve_session_labels + .lock() + .expect("preserved session labels mutex"); + preserved.extend(windows.iter().map(|(label, _)| label.clone())); + } + for (_, window) in windows { + let _ = window.destroy(); + } +} + +#[tauri::command] +fn clear_window_session( + window: WebviewWindow, + state: State, +) -> Result<(), String> { + clear_window_session_record(window.app_handle(), &state, window.label())?; + Ok(()) +} + +/// Finalize a close request only after the WebView has flushed the draft or +/// persisted a recovery snapshot. Keeping force-destroy in trusted Rust avoids +/// exposing `plugin:window|destroy` to editor WebViews. +#[tauri::command] +fn close_window_after_save(window: WebviewWindow) -> Result<(), String> { + window + .destroy() + .map_err(|error| format!("Failed to close window after save: {error}")) +} + +#[tauri::command] +fn set_notes_folder( + app: AppHandle, + window: WebviewWindow, + path: String, + state: State, +) -> Result<(), String> { + let path_buf = normalize_notes_folder_path(&path)?; + if window.label() == "main" { + initialize_notes_folder(&app, &path_buf, &state)?; + let _ = app.emit("workspaces-changed", ()); + return Ok(()); + } + + let session = WorkspaceSession::initialize(&app, &path_buf)?; + let notes_folder = session.notes_folder.clone(); + { + let mut config = state.app_config.write().expect("app_config write lock"); + let mut next = config.clone(); + remember_workspace(&mut next, notes_folder.clone()); + upsert_window_session_workspace(&mut next, window.label(), notes_folder.clone()); + save_app_config(&app, &next).map_err(|e| e.to_string())?; + *config = next; + } + state.register_workspace_session(window.label(), session); + let _ = app.emit("workspaces-changed", ()); + Ok(()) +} + +#[tauri::command] +fn list_workspaces(window: WebviewWindow, state: State) -> Vec { + let config = state + .app_config + .read() + .expect("app_config read lock") + .clone(); + let bindings = state + .workspace_bindings + .read() + .expect("workspace bindings read lock"); + build_workspace_infos(&config, &bindings, window.label()) +} + +#[tauri::command] +fn remove_workspace_from_list( + app: AppHandle, + path: String, + state: State, +) -> Result<(), String> { + let path = path.trim(); + if path.is_empty() { + return Err("Workspace path is empty".to_string()); + } + + { + let mut config = state.app_config.write().expect("app config write lock"); + let mut next = config.clone(); + if !forget_workspace(&mut next, path) { + return Ok(()); + } + save_app_config(&app, &next).map_err(|error| error.to_string())?; + *config = next; + } + + let _ = app.emit("workspaces-changed", ()); + Ok(()) +} + +#[tauri::command] +fn switch_workspace( + app: AppHandle, + window: WebviewWindow, + path: String, + state: State, +) -> Result { + let path_buf = normalize_notes_folder_path(&path)?; + let normalized_path = if window.label() == "main" { + switch_main_workspace(&app, &path_buf, &state)? + } else { + let session = WorkspaceSession::initialize(&app, &path_buf)?; + let notes_folder = session.notes_folder.clone(); + { + let mut config = state.app_config.write().expect("app_config write lock"); + let mut next = config.clone(); + remember_workspace(&mut next, notes_folder.clone()); + upsert_window_session_workspace( + &mut next, + window.label(), + notes_folder.clone(), + ); + save_app_config(&app, &next).map_err(|error| error.to_string())?; + *config = next; + } + state.register_workspace_session(window.label(), session); + notes_folder + }; + + let _ = app.emit("workspaces-changed", ()); + Ok(normalized_path) +} + +fn restore_window_session( + app: &AppHandle, + state: &AppState, + label: &str, + record: &WindowSession, +) -> Result<(), String> { + let workspace_path = Path::new(&record.workspace); + let existing_session = state.workspace_session_for_path(&record.workspace); + if let Some(session) = existing_session { + state + .workspace_bindings + .write() + .expect("workspace bindings write lock") + .bind(label, session.notes_folder.clone()); + } else { + let session = WorkspaceSession::initialize(app, workspace_path)?; + state.register_workspace_session(label, session); + } + + if label == "main" { + let window = app + .get_webview_window("main") + .ok_or_else(|| "Main window not found".to_string())?; + if let Some(geometry) = &record.geometry { + let _ = window.set_position(tauri::PhysicalPosition::new(geometry.x, geometry.y)); + let _ = window.set_size(tauri::PhysicalSize::new( + geometry.width, + geometry.height, + )); + } + return Ok(()); + } + + if app.get_webview_window(label).is_some() { + return Ok(()); + } + + let title = workspace_path + .file_name() + .and_then(|name| name.to_str()) + .filter(|name| !name.is_empty()) + .unwrap_or("Scratch"); + let runtime_config = runtime_window_config_from_template( + &app.config().app.windows[0], + label, + WebviewUrl::App("index.html?mode=workspace".into()), + ); + let mut builder = WebviewWindowBuilder::from_config(app, &runtime_config) + .map_err(|error| format!("Failed to configure workspace window {label}: {error}"))? + .title(format!("{} — Scratch", title)) + .inner_size(1080.0, 720.0) + .min_inner_size(600.0, 400.0) + .resizable(true) + .decorations(true); + if let Some(geometry) = &record.geometry { + builder = builder + .position(geometry.x as f64, geometry.y as f64) + .inner_size(geometry.width as f64, geometry.height as f64); + } + + let window = builder.build().map_err(|error| { + state.remove_workspace_session(label); + format!("Failed to restore workspace window {label}: {error}") + })?; + let _ = window.show(); + Ok(()) +} + +#[tauri::command] +async fn open_workspace_window( + app: AppHandle, + path: String, + state: State<'_, AppState>, +) -> Result { + let path_buf = normalize_notes_folder_path(&path)?; + let init_app = app.clone(); + let session = tauri::async_runtime::spawn_blocking(move || { + let state = init_app.state::(); + state.get_or_initialize_workspace_session(&init_app, &path_buf) + }) + .await + .map_err(|error| format!("Workspace initialization failed: {}", error))??; + let notes_folder = session.notes_folder.clone(); + + let label = loop { + let candidate = next_workspace_window_label(Path::new(¬es_folder)); + let record_exists = state + .app_config + .read() + .expect("app config read lock") + .records + .contains_key(&candidate); + if app.get_webview_window(&candidate).is_none() && !record_exists { + break candidate; + } + }; + let title = Path::new(¬es_folder) + .file_name() + .and_then(|name| name.to_str()) + .filter(|name| !name.is_empty()) + .unwrap_or("Scratch") + .to_string(); + + { + let mut config = state.app_config.write().expect("app_config write lock"); + let mut next = config.clone(); + remember_workspace(&mut next, notes_folder.clone()); + upsert_window_session_workspace(&mut next, &label, notes_folder.clone()); + save_app_config(&app, &next).map_err(|error| error.to_string())?; + *config = next; + } + let binding_result = state + .bind_existing_workspace_session(&label, notes_folder.clone()) + .map(|_| ()); + if let Err(error) = binding_result { + let _ = clear_window_session_record(&app, &state, &label); + return Err(error); + } + + let runtime_config = runtime_window_config_from_template( + &app.config().app.windows[0], + &label, + WebviewUrl::App("index.html?mode=workspace".into()), + ); + let builder = WebviewWindowBuilder::from_config(&app, &runtime_config) + .map_err(|error| format!("Failed to configure workspace window: {error}"))? + .title(format!("{} — Scratch", title)) + .inner_size(1080.0, 720.0) + .min_inner_size(600.0, 400.0) + .resizable(true) + .decorations(true); + + let window = builder.build().map_err(|error| { + state.remove_workspace_session(&label); + let _ = clear_window_session_record(&app, &state, &label); + format!("Failed to create workspace window: {}", error) + })?; + let _ = window.show(); + window.set_focus().map_err(|error| error.to_string())?; + let _ = app.emit("workspaces-changed", ()); + + Ok(label) +} + +#[tauri::command] +async fn list_notes( + window: WebviewWindow, + state: State<'_, AppState>, +) -> Result, String> { + let workspace = state.workspace_for_window(window.label())?; + let folder = workspace.notes_folder()?; + + let path = PathBuf::from(&folder); + if !path.exists() { + return Ok(vec![]); + } + + let ignored_dirs = { + let settings = workspace.settings().read().expect("settings read lock"); + get_effective_ignored_dirs(&settings) + }; + + let path_clone = path.clone(); + let discovered = tokio::task::spawn_blocking(move || { + use walkdir::WalkDir; + let mut results: Vec<(String, String, String, i64)> = Vec::new(); + for entry in WalkDir::new(&path_clone) + .max_depth(10) + .into_iter() + .filter_entry(|e| is_visible_notes_entry(e, &ignored_dirs)) + .flatten() + { + let file_path = entry.path(); + if !file_path.is_file() { + continue; + } + if let Some(id) = id_from_abs_path(&path_clone, file_path, &ignored_dirs) { + if let Ok(content) = std::fs::read_to_string(file_path) { + let modified = entry + .metadata() + .ok() + .and_then(|m| m.modified().ok()) + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + let title = extract_title(&content); + let preview = generate_preview(&content); + results.push((id, title, preview, modified)); + } + } + } + results + }) + .await + .map_err(|e| e.to_string())?; + + let mut notes: Vec = discovered + .into_iter() .map(|(id, title, preview, modified)| NoteMetadata { id, title, @@ -976,7 +2403,7 @@ async fn list_notes(state: State<'_, AppState>) -> Result, Str // Load pinned note IDs from settings let pinned_ids: HashSet = { - let settings = state.settings.read().expect("settings read lock"); + let settings = workspace.settings().read().expect("settings read lock"); settings .pinned_note_ids .as_ref() @@ -998,7 +2425,7 @@ async fn list_notes(state: State<'_, AppState>) -> Result, Str // Update cache efficiently { - let mut cache = state.notes_cache.write().expect("cache write lock"); + let mut cache = workspace.notes_cache().write().expect("cache write lock"); cache.clear(); for note in ¬es { cache.insert(note.id.clone(), note.clone()); @@ -1009,73 +2436,297 @@ async fn list_notes(state: State<'_, AppState>) -> Result, Str } #[tauri::command] -async fn read_note(id: String, state: State<'_, AppState>) -> Result { - let folder = { - let app_config = state.app_config.read().expect("app_config read lock"); - app_config - .notes_folder - .clone() - .ok_or("Notes folder not set")? - }; - - let folder_path = PathBuf::from(&folder); - let file_path = abs_path_from_id(&folder_path, &id)?; +async fn read_note( + id: String, + window: WebviewWindow, + state: State<'_, AppState>, +) -> Result { + let workspace = state.workspace_for_window(window.label())?; + let file_path = workspace.note_path(&id)?; if !file_path.exists() { return Err("Note not found".to_string()); } - let content = fs::read_to_string(&file_path) - .await - .map_err(|e| e.to_string())?; - let metadata = fs::metadata(&file_path) + tauri::async_runtime::spawn_blocking(move || read_note_from_path(id, &file_path)) .await - .map_err(|e| e.to_string())?; + .map_err(|error| format!("Note read task failed: {error}"))? +} +fn read_note_from_path(id: String, file_path: &Path) -> Result { + let snapshot = persistence::read_snapshot(file_path) + .map_err(|error| error.to_string())? + .ok_or_else(|| "Note not found".to_string())?; + let metadata = std::fs::metadata(file_path).map_err(|error| error.to_string())?; let modified = metadata .modified() .ok() - .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|d| d.as_secs() as i64) + .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|duration| duration.as_secs() as i64) .unwrap_or(0); Ok(Note { id, - title: extract_title(&content), - content, + title: extract_title(&snapshot.content), + content: snapshot.content, path: file_path.to_string_lossy().into_owned(), modified, + revision: snapshot.revision.to_string(), }) } -#[tauri::command] -async fn save_note( - id: Option, +fn note_conflict_snapshot( + current: Option, +) -> Option { + current.map(|snapshot| NoteConflictSnapshot { + content: snapshot.content, + revision: snapshot.revision.to_string(), + }) +} + +fn save_note_to_path( + id: String, + file_path: &Path, content: String, - state: State<'_, AppState>, -) -> Result { - let folder = { - let app_config = state.app_config.read().expect("app_config read lock"); - app_config - .notes_folder - .clone() - .ok_or("Notes folder not set")? + expected_revision: String, +) -> Result { + let current = persistence::read_snapshot(file_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(NoteSaveResult::Conflict { + current: note_conflict_snapshot(current), + }); + } }; - let folder_path = PathBuf::from(&folder); - - let title = extract_title(&content); - let sanitized_leaf = sanitize_filename(&title); - // Determine the file ID and path, handling renames - let (final_id, file_path, old_id) = if let Some(existing_id) = id { - // Preserve directory prefix for notes in subfolders - let (dir_prefix, desired_id) = if let Some(pos) = existing_id.rfind('/') { - let prefix = &existing_id[..pos]; - (Some(prefix.to_string()), format!("{}/{}", prefix, sanitized_leaf)) - } else { - (None, sanitized_leaf.clone()) - }; + match persistence::save_if_revision(file_path, &content, Some(&expected)) + .map_err(|error| error.to_string())? + { + persistence::SaveResult::Saved { .. } => Ok(NoteSaveResult::Saved { + note: read_note_from_path(id, file_path)?, + }), + persistence::SaveResult::Conflict { current } => Ok(NoteSaveResult::Conflict { + current: note_conflict_snapshot(current), + }), + } +} - let old_file_path = abs_path_from_id(&folder_path, &existing_id)?; +fn write_recovery_snapshot( + recovery_root: &Path, + note_id: &str, + source_path: &str, + content: &str, + reason: &str, +) -> Result { + let recovery_directory = recovery_root.join("recovery"); + std::fs::create_dir_all(&recovery_directory).map_err(|error| error.to_string())?; + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_millis()) + .unwrap_or(0); + let sequence = RECOVERY_SNAPSHOT_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let safe_note_id = sanitize_filename(¬e_id.replace('/', "-")); + let safe_reason = sanitize_filename(reason); + let stem = format!("{timestamp}-{sequence}-{safe_reason}-{safe_note_id}"); + let recovery_path = recovery_directory.join(format!("{stem}.md")); + + match persistence::save_if_revision(&recovery_path, content, None) + .map_err(|error| error.to_string())? + { + persistence::SaveResult::Saved { .. } => {} + persistence::SaveResult::Conflict { .. } => { + return Err("Recovery snapshot path unexpectedly already exists".to_string()); + } + } + + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct RecoveryMetadata<'a> { + note_id: &'a str, + source_path: &'a str, + reason: &'a str, + created_at_ms: u128, + } + + let metadata = RecoveryMetadata { + note_id, + source_path, + reason, + created_at_ms: timestamp, + }; + if let Ok(metadata_content) = serde_json::to_string_pretty(&metadata) { + let metadata_path = recovery_directory.join(format!("{stem}.json")); + let _ = persistence::save_if_revision(&metadata_path, &metadata_content, None); + } + + Ok(recovery_path) +} + +#[tauri::command] +async fn persist_recovery_snapshot( + app: AppHandle, + note_id: String, + source_path: String, + content: String, + reason: String, +) -> Result { + let recovery_root = app.path().app_data_dir().map_err(|error| error.to_string())?; + tauri::async_runtime::spawn_blocking(move || { + write_recovery_snapshot( + &recovery_root, + ¬e_id, + &source_path, + &content, + &reason, + ) + .map(|path| path.to_string_lossy().into_owned()) + }) + .await + .map_err(|error| format!("Recovery snapshot task failed: {error}"))? +} + +#[tauri::command] +async fn write_draft_checkpoint( + app: AppHandle, + window: WebviewWindow, + note_id: String, + markdown: String, + metadata: draft_checkpoint::DraftCheckpointMetadata, +) -> Result<(), String> { + let app_data = app.path().app_data_dir().map_err(|error| error.to_string())?; + let checkpoint = draft_checkpoint::DraftCheckpoint { + key: draft_checkpoint::DraftCheckpointKey { + window_label: window.label().to_string(), + note_id, + }, + markdown, + metadata, + }; + tauri::async_runtime::spawn_blocking(move || { + draft_checkpoint::write_checkpoint(app_data, &checkpoint) + .map_err(|error| error.to_string()) + }) + .await + .map_err(|error| format!("Draft checkpoint task failed: {error}"))? +} + +#[tauri::command] +async fn get_draft_checkpoint( + app: AppHandle, + window: WebviewWindow, + note_id: String, +) -> Result, String> { + let app_data = app.path().app_data_dir().map_err(|error| error.to_string())?; + let key = draft_checkpoint::DraftCheckpointKey { + window_label: window.label().to_string(), + note_id, + }; + tauri::async_runtime::spawn_blocking(move || { + draft_checkpoint::read_checkpoint(app_data, &key).map_err(|error| error.to_string()) + }) + .await + .map_err(|error| format!("Draft checkpoint read task failed: {error}"))? +} + +#[tauri::command] +async fn clear_draft_checkpoint( + app: AppHandle, + window: WebviewWindow, + note_id: String, +) -> Result<(), String> { + let app_data = app.path().app_data_dir().map_err(|error| error.to_string())?; + let key = draft_checkpoint::DraftCheckpointKey { + window_label: window.label().to_string(), + note_id, + }; + tauri::async_runtime::spawn_blocking(move || { + draft_checkpoint::clear_checkpoint(app_data, &key).map_err(|error| error.to_string()) + }) + .await + .map_err(|error| format!("Draft checkpoint clear task failed: {error}"))? +} + +#[tauri::command] +async fn list_draft_checkpoints( + app: AppHandle, + window: WebviewWindow, +) -> Result, String> { + let app_data = app.path().app_data_dir().map_err(|error| error.to_string())?; + let window_label = window.label().to_string(); + tauri::async_runtime::spawn_blocking(move || { + draft_checkpoint::list_checkpoints(app_data) + .map(|checkpoints| { + checkpoints + .into_iter() + .filter(|checkpoint| checkpoint.key.window_label == window_label) + .collect() + }) + .map_err(|error| error.to_string()) + }) + .await + .map_err(|error| format!("Draft checkpoint list task failed: {error}"))? +} + +#[tauri::command] +async fn save_note( + app: AppHandle, + id: Option, + content: String, + expected_revision: Option, + window: WebviewWindow, + state: State<'_, AppState>, +) -> Result { + let workspace = state.workspace_for_window(window.label())?; + let workspace_path = workspace.notes_folder()?; + let previous_id = id.clone(); + let result = save_note_in_workspace(id, content, expected_revision, &workspace).await?; + if let NoteSaveResult::Saved { note } = &result { + let kind = if previous_id.as_deref().is_some_and(|id| id != note.id) { + "renamed" + } else { + "modified" + }; + emit_workspace_file_change( + &app, + &state, + Path::new(¬e.path), + semantic_file_change_event( + workspace_path, + kind, + previous_id.filter(|id| id != ¬e.id), + Some(note), + Some(window.label().to_string()), + ), + Some(window.label()), + ); + } + Ok(result) +} + +async fn save_note_in_workspace( + id: Option, + content: String, + expected_revision: Option, + workspace: &WorkspaceRuntime<'_>, +) -> Result { + let folder = workspace.notes_folder()?; + let folder_path = PathBuf::from(&folder); + + let title = extract_title(&content); + let sanitized_leaf = sanitize_filename(&title); + let creates_new_note = id.is_none(); + + // Determine the file ID and path, handling renames + let (final_id, file_path, old_id) = if let Some(existing_id) = id { + // Preserve directory prefix for notes in subfolders + let (dir_prefix, desired_id) = if let Some(pos) = existing_id.rfind('/') { + let prefix = &existing_id[..pos]; + (Some(prefix.to_string()), format!("{}/{}", prefix, sanitized_leaf)) + } else { + (None, sanitized_leaf.clone()) + }; + + let old_file_path = abs_path_from_id(&folder_path, &existing_id)?; if existing_id != desired_id { let mut new_id = desired_id.clone(); @@ -1116,31 +2767,91 @@ async fn save_note( (new_id, new_file_path, None) }; - // Write the file to the new path - fs::write(&file_path, &content) + let initial_id = old_id + .as_ref() + .map(|(old_id, _)| old_id.clone()) + .unwrap_or_else(|| final_id.clone()); + let initial_path = old_id + .as_ref() + .map(|(_, old_path)| old_path.clone()) + .unwrap_or_else(|| file_path.clone()); + + let initial_result = if let Some(expected_revision) = expected_revision { + let save_id = initial_id.clone(); + let save_path = initial_path.clone(); + let save_content = content.clone(); + tauri::async_runtime::spawn_blocking(move || -> Result { + save_note_to_path(save_id, &save_path, save_content, expected_revision) + }) .await - .map_err(|e| e.to_string())?; + .map_err(|error| format!("Note save task failed: {error}"))?? + } else if creates_new_note { + let create_path = file_path.clone(); + let create_content = content.clone(); + let create_id = final_id.clone(); + tauri::async_runtime::spawn_blocking(move || -> Result { + match persistence::save_if_revision(&create_path, &create_content, None) + .map_err(|error| error.to_string())? + { + persistence::SaveResult::Saved { .. } => Ok(NoteSaveResult::Saved { + note: read_note_from_path(create_id, &create_path)?, + }), + persistence::SaveResult::Conflict { current } => { + Ok(NoteSaveResult::Conflict { + current: note_conflict_snapshot(current), + }) + } + } + }) + .await + .map_err(|error| format!("Note create task failed: {error}"))?? + } else { + return Err("Expected revision is required when saving an existing note".to_string()); + }; + + let mut saved_note = match initial_result { + NoteSaveResult::Conflict { current } => { + return Ok(NoteSaveResult::Conflict { current }); + } + NoteSaveResult::Saved { note } => note, + }; - // Delete old file AFTER successful write (to prevent data loss) + // A title change renames the note only after the original path has passed + // compare-and-swap. The target is create-only, so a concurrent file can + // never be overwritten. Failure leaves the durable updated source intact. if let Some((_, ref old_file_path)) = old_id { - if old_file_path.exists() && *old_file_path != file_path { - let _ = fs::remove_file(old_file_path).await; + if *old_file_path != file_path { + let target_path = file_path.clone(); + let target_content = content.clone(); + let target_result = tauri::async_runtime::spawn_blocking(move || { + persistence::save_if_revision(&target_path, &target_content, None) + .map_err(|error| error.to_string()) + }) + .await + .map_err(|error| format!("Note rename task failed: {error}"))??; + + if matches!(target_result, persistence::SaveResult::Conflict { .. }) { + return Err("Rename target changed while saving; source note was preserved".into()); + } + + fs::remove_file(old_file_path) + .await + .map_err(|error| format!("Saved renamed note but could not remove source: {error}"))?; + let renamed_id = final_id.clone(); + let renamed_path = file_path.clone(); + saved_note = tauri::async_runtime::spawn_blocking(move || { + read_note_from_path(renamed_id, &renamed_path) + }) + .await + .map_err(|error| format!("Renamed note read task failed: {error}"))??; } } - let metadata = fs::metadata(&file_path) - .await - .map_err(|e| e.to_string())?; - let modified = metadata - .modified() - .ok() - .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|d| d.as_secs() as i64) - .unwrap_or(0); + let modified = saved_note.modified; // Update search index (delete old entry if renamed, then add new) { - let index = state.search_index.lock().expect("search index mutex"); + let index = workspace.search_index().lock().expect("search index mutex"); if let Some(ref search_index) = *index { if let Some((ref old_id_str, _)) = old_id { let _ = search_index.delete_note(old_id_str); @@ -1151,31 +2862,23 @@ async fn save_note( // Update cache (remove old entry if renamed) if let Some((ref old_id_str, _)) = old_id { - let mut cache = state.notes_cache.write().expect("cache write lock"); + let mut cache = workspace.notes_cache().write().expect("cache write lock"); cache.remove(old_id_str); } - Ok(Note { - id: final_id, - title, - content, - path: file_path.to_string_lossy().into_owned(), - modified, - }) + Ok(NoteSaveResult::Saved { note: saved_note }) } #[tauri::command] -async fn delete_note(id: String, state: State<'_, AppState>) -> Result<(), String> { - let folder = { - let app_config = state.app_config.read().expect("app_config read lock"); - app_config - .notes_folder - .clone() - .ok_or("Notes folder not set")? - }; - - let folder_path = PathBuf::from(&folder); - let file_path = abs_path_from_id(&folder_path, &id)?; +async fn delete_note( + app: AppHandle, + id: String, + window: WebviewWindow, + state: State<'_, AppState>, +) -> Result<(), String> { + let workspace = state.workspace_for_window(window.label())?; + let workspace_path = workspace.notes_folder()?; + let file_path = workspace.note_path(&id)?; if file_path.exists() { fs::remove_file(&file_path) .await @@ -1184,7 +2887,7 @@ async fn delete_note(id: String, state: State<'_, AppState>) -> Result<(), Strin // Update search index { - let index = state.search_index.lock().expect("search index mutex"); + let index = workspace.search_index().lock().expect("search index mutex"); if let Some(ref search_index) = *index { let _ = search_index.delete_note(&id); } @@ -1192,30 +2895,44 @@ async fn delete_note(id: String, state: State<'_, AppState>) -> Result<(), Strin // Remove from cache { - let mut cache = state.notes_cache.write().expect("cache write lock"); + let mut cache = workspace.notes_cache().write().expect("cache write lock"); cache.remove(&id); } + emit_workspace_file_change( + &app, + &state, + &file_path, + semantic_file_change_event( + workspace_path, + "deleted", + Some(id), + None, + Some(window.label().to_string()), + ), + Some(window.label()), + ); + Ok(()) } #[tauri::command] -async fn create_note(target_folder: Option, state: State<'_, AppState>) -> Result { - let folder = { - let app_config = state.app_config.read().expect("app_config read lock"); - app_config - .notes_folder - .clone() - .ok_or("Notes folder not set")? - }; +async fn create_note( + target_folder: Option, + window: WebviewWindow, + state: State<'_, AppState>, +) -> Result { + let workspace = state.workspace_for_window(window.label())?; + let folder = workspace.notes_folder()?; let folder_path = PathBuf::from(&folder); - // Get template from settings (default "Untitled") + // Get global template (default "Untitled") let template = { - let settings = state.settings.read().expect("settings read lock"); - settings - .default_note_name - .clone() + let config = state.app_config.read().expect("app config read lock"); + config + .global_settings + .as_ref() + .and_then(|settings| settings.default_note_name.clone()) .unwrap_or_else(|| "Untitled".to_string()) }; @@ -1284,7 +3001,7 @@ async fn create_note(target_folder: Option, state: State<'_, AppState>) // Update search index { - let index = state.search_index.lock().expect("search index mutex"); + let index = workspace.search_index().lock().expect("search index mutex"); if let Some(ref search_index) = *index { let _ = search_index.index_note(&final_id, &display_title, &content, modified); } @@ -1293,6 +3010,7 @@ async fn create_note(target_folder: Option, state: State<'_, AppState>) Ok(Note { id: final_id, title: display_title, + revision: persistence::content_revision(&content).to_string(), content, path: file_path.to_string_lossy().into_owned(), modified, @@ -1334,18 +3052,16 @@ fn validate_folder_path(path: &str) -> Result<(), String> { } #[tauri::command] -async fn list_folders(state: State<'_, AppState>) -> Result, String> { - let folder = { - let app_config = state.app_config.read().expect("app_config read lock"); - app_config - .notes_folder - .clone() - .ok_or("Notes folder not set")? - }; +async fn list_folders( + window: WebviewWindow, + state: State<'_, AppState>, +) -> Result, String> { + let workspace = state.workspace_for_window(window.label())?; + let folder = workspace.notes_folder()?; let folder_path = PathBuf::from(&folder); let ignored_dirs = { - let settings = state.settings.read().expect("settings read lock"); + let settings = workspace.settings().read().expect("settings read lock"); get_effective_ignored_dirs(&settings) }; @@ -1376,14 +3092,14 @@ async fn list_folders(state: State<'_, AppState>) -> Result, String> } #[tauri::command] -async fn create_folder(path: String, state: State<'_, AppState>) -> Result<(), String> { - let folder = { - let app_config = state.app_config.read().expect("app_config read lock"); - app_config - .notes_folder - .clone() - .ok_or("Notes folder not set")? - }; +async fn create_folder( + path: String, + window: WebviewWindow, + state: State<'_, AppState>, +) -> Result<(), String> { + let folder = state + .workspace_for_window(window.label())? + .notes_folder()?; validate_folder_path(&path)?; @@ -1401,14 +3117,13 @@ async fn create_folder(path: String, state: State<'_, AppState>) -> Result<(), S } #[tauri::command] -async fn delete_folder(path: String, state: State<'_, AppState>) -> Result<(), String> { - let folder = { - let app_config = state.app_config.read().expect("app_config read lock"); - app_config - .notes_folder - .clone() - .ok_or("Notes folder not set")? - }; +async fn delete_folder( + path: String, + window: WebviewWindow, + state: State<'_, AppState>, +) -> Result<(), String> { + let workspace = state.workspace_for_window(window.label())?; + let folder = workspace.notes_folder()?; validate_folder_path(&path)?; @@ -1424,9 +3139,9 @@ async fn delete_folder(path: String, state: State<'_, AppState>) -> Result<(), S // Remove notes from search index { - let index = state.search_index.lock().expect("search index mutex"); + let index = workspace.search_index().lock().expect("search index mutex"); if let Some(ref search_index) = *index { - let cache = state.notes_cache.read().expect("cache read lock"); + let cache = workspace.notes_cache().read().expect("cache read lock"); let prefix = format!("{}/", path); for note_id in cache.keys() { if note_id.starts_with(&prefix) { @@ -1438,7 +3153,7 @@ async fn delete_folder(path: String, state: State<'_, AppState>) -> Result<(), S // Remove notes from cache { - let mut cache = state.notes_cache.write().expect("cache write lock"); + let mut cache = workspace.notes_cache().write().expect("cache write lock"); let prefix = format!("{}/", path); cache.retain(|id, _| !id.starts_with(&prefix)); } @@ -1454,15 +3169,11 @@ async fn delete_folder(path: String, state: State<'_, AppState>) -> Result<(), S async fn rename_folder( old_path: String, new_name: String, + window: WebviewWindow, state: State<'_, AppState>, ) -> Result<(), String> { - let folder = { - let app_config = state.app_config.read().expect("app_config read lock"); - app_config - .notes_folder - .clone() - .ok_or("Notes folder not set")? - }; + let workspace = state.workspace_for_window(window.label())?; + let folder = workspace.notes_folder()?; validate_folder_path(&old_path)?; @@ -1512,7 +3223,7 @@ async fn rename_folder( // Update pinned note IDs in settings { - let mut settings = state.settings.write().expect("settings write lock"); + let mut settings = workspace.settings().write().expect("settings write lock"); if let Some(ref mut pinned) = settings.pinned_note_ids { for id in pinned.iter_mut() { if id.starts_with(&old_prefix) { @@ -1528,7 +3239,7 @@ async fn rename_folder( // Update cache { - let mut cache = state.notes_cache.write().expect("cache write lock"); + let mut cache = workspace.notes_cache().write().expect("cache write lock"); let updates: Vec<(String, String)> = cache .keys() .filter(|id| id.starts_with(&old_prefix)) @@ -1547,10 +3258,10 @@ async fn rename_folder( // Rebuild search index for affected notes { - let index = state.search_index.lock().expect("search index mutex"); + let index = workspace.search_index().lock().expect("search index mutex"); if let Some(ref search_index) = *index { let ignored_dirs = { - let settings = state.settings.read().expect("settings read lock"); + let settings = workspace.settings().read().expect("settings read lock"); get_effective_ignored_dirs(&settings) }; let _ = search_index.rebuild_index(&folder_root, &ignored_dirs); @@ -1562,17 +3273,14 @@ async fn rename_folder( #[tauri::command] async fn move_note( + app: AppHandle, id: String, target_folder: String, + window: WebviewWindow, state: State<'_, AppState>, ) -> Result { - let folder = { - let app_config = state.app_config.read().expect("app_config read lock"); - app_config - .notes_folder - .clone() - .ok_or("Notes folder not set")? - }; + let workspace = state.workspace_for_window(window.label())?; + let folder = workspace.notes_folder()?; let folder_root = PathBuf::from(&folder); let source_path = abs_path_from_id(&folder_root, &id)?; @@ -1613,7 +3321,7 @@ async fn move_note( // Update pinned note IDs { - let mut settings = state.settings.write().expect("settings write lock"); + let mut settings = workspace.settings().write().expect("settings write lock"); if let Some(ref mut pinned) = settings.pinned_note_ids { for pin_id in pinned.iter_mut() { if *pin_id == id { @@ -1626,7 +3334,7 @@ async fn move_note( // Update cache { - let mut cache = state.notes_cache.write().expect("cache write lock"); + let mut cache = workspace.notes_cache().write().expect("cache write lock"); if let Some(mut meta) = cache.remove(&id) { meta.id = new_id.clone(); cache.insert(new_id.clone(), meta); @@ -1635,16 +3343,38 @@ async fn move_note( // Rebuild search index { - let index = state.search_index.lock().expect("search index mutex"); + let index = workspace.search_index().lock().expect("search index mutex"); if let Some(ref search_index) = *index { let ignored_dirs = { - let settings = state.settings.read().expect("settings read lock"); + let settings = workspace.settings().read().expect("settings read lock"); get_effective_ignored_dirs(&settings) }; let _ = search_index.rebuild_index(&folder_root, &ignored_dirs); } } + let moved_note = { + let moved_id = new_id.clone(); + let moved_path = dest_path.clone(); + tauri::async_runtime::spawn_blocking(move || read_note_from_path(moved_id, &moved_path)) + .await + .map_err(|error| format!("Moved note read task failed: {error}"))?? + }; + + emit_workspace_file_change( + &app, + &state, + &dest_path, + semantic_file_change_event( + folder, + "moved", + Some(id), + Some(&moved_note), + Some(window.label().to_string()), + ), + Some(window.label()), + ); + Ok(new_id) } @@ -1652,15 +3382,11 @@ async fn move_note( async fn move_folder( path: String, target_parent: String, + window: WebviewWindow, state: State<'_, AppState>, ) -> Result<(), String> { - let folder = { - let app_config = state.app_config.read().expect("app_config read lock"); - app_config - .notes_folder - .clone() - .ok_or("Notes folder not set")? - }; + let workspace = state.workspace_for_window(window.label())?; + let folder = workspace.notes_folder()?; validate_folder_path(&path)?; if !target_parent.is_empty() { @@ -1718,7 +3444,7 @@ async fn move_folder( // Update pinned note IDs { - let mut settings = state.settings.write().expect("settings write lock"); + let mut settings = workspace.settings().write().expect("settings write lock"); if let Some(ref mut pinned) = settings.pinned_note_ids { for pin_id in pinned.iter_mut() { if pin_id.starts_with(&old_prefix) { @@ -1731,7 +3457,7 @@ async fn move_folder( // Update cache { - let mut cache = state.notes_cache.write().expect("cache write lock"); + let mut cache = workspace.notes_cache().write().expect("cache write lock"); let updates: Vec<(String, String)> = cache .keys() .filter(|id| id.starts_with(&old_prefix)) @@ -1750,10 +3476,10 @@ async fn move_folder( // Rebuild search index { - let index = state.search_index.lock().expect("search index mutex"); + let index = workspace.search_index().lock().expect("search index mutex"); if let Some(ref search_index) = *index { let ignored_dirs = { - let settings = state.settings.read().expect("settings read lock"); + let settings = workspace.settings().read().expect("settings read lock"); get_effective_ignored_dirs(&settings) }; let _ = search_index.rebuild_index(&folder_root, &ignored_dirs); @@ -1764,27 +3490,139 @@ async fn move_folder( } #[tauri::command] -fn get_settings(state: State) -> Settings { - state.settings.read().expect("settings read lock").clone() +fn get_settings(window: WebviewWindow, state: State) -> Settings { + let workspace_settings = state + .workspace_for_window(window.label()) + .map(|workspace| { + workspace + .settings() + .read() + .expect("settings read lock") + .clone() + }) + .unwrap_or_default(); + let global_settings = state + .app_config + .read() + .expect("app config read lock") + .global_settings + .clone() + .unwrap_or_default(); + + merge_settings(&global_settings, &workspace_settings) +} + +fn emit_settings_changed( + app: &AppHandle, + state: &AppState, + scope: SettingsChangeScope, + workspace: Option, +) { + let payload = SettingsChangedPayload { scope, workspace }; + let targets = { + let bindings = state + .workspace_bindings + .read() + .expect("workspace bindings read lock"); + settings_change_targets(&bindings, scope, payload.workspace.as_deref()) + }; + + match targets { + SettingsChangeTargets::All => { + let _ = app.emit("settings-changed", payload); + } + SettingsChangeTargets::Windows(labels) => { + for label in labels { + let _ = app.emit_to(&label, "settings-changed", payload.clone()); + } + } + } } #[tauri::command] -fn update_settings( - new_settings: Settings, +fn update_global_settings( + patch: serde_json::Value, + window: WebviewWindow, state: State, ) -> Result<(), String> { - let folder = { - let app_config = state.app_config.read().expect("app_config read lock"); - app_config.notes_folder.clone().ok_or("Notes folder not set")? - }; + { + let mut config = state.app_config.write().expect("app config write lock"); + let current = config.global_settings.clone().unwrap_or_default(); + let updated = apply_settings_patch(¤t, patch)?; + let mut next_config = config.clone(); + next_config.global_settings = Some(updated); + save_app_config(window.app_handle(), &next_config).map_err(|error| error.to_string())?; + *config = next_config; + } + + emit_settings_changed( + window.app_handle(), + &state, + SettingsChangeScope::Global, + None, + ); + Ok(()) +} +#[tauri::command] +fn update_workspace_settings( + patch: serde_json::Value, + window: WebviewWindow, + state: State, +) -> Result<(), String> { + let workspace = state.workspace_for_window(window.label())?; + let folder = workspace.notes_folder()?; { - let mut settings = state.settings.write().expect("settings write lock"); - *settings = new_settings; + let mut settings = workspace.settings().write().expect("settings write lock"); + let updated = apply_settings_patch(&*settings, patch)?; + save_settings(&folder, &updated).map_err(|error| error.to_string())?; + *settings = updated; } - let settings = state.settings.read().expect("settings read lock"); - save_settings(&folder, &settings).map_err(|e| e.to_string())?; + emit_settings_changed( + window.app_handle(), + &state, + SettingsChangeScope::Workspace, + Some(folder), + ); + Ok(()) +} + +#[tauri::command] +fn update_settings( + new_settings: Settings, + window: WebviewWindow, + state: State, +) -> Result<(), String> { + let workspace = state.workspace_for_window(window.label())?; + let folder = workspace.notes_folder()?; + let (global_settings, workspace_settings) = split_settings(new_settings); + + { + // Compatibility full-replacement path. Keep both scope locks through + // persistence so it cannot interleave with the atomic patch commands. + let mut config = state.app_config.write().expect("app config write lock"); + let mut settings = workspace.settings().write().expect("settings write lock"); + let mut next = config.clone(); + next.global_settings = Some(global_settings); + save_app_config(window.app_handle(), &next).map_err(|error| error.to_string())?; + save_settings(&folder, &workspace_settings).map_err(|error| error.to_string())?; + *config = next; + *settings = workspace_settings; + } + + emit_settings_changed( + window.app_handle(), + &state, + SettingsChangeScope::Global, + None, + ); + emit_settings_changed( + window.app_handle(), + &state, + SettingsChangeScope::Workspace, + Some(folder), + ); Ok(()) } @@ -1793,25 +3631,21 @@ fn update_settings( fn update_git_enabled( enabled: Option, expected_folder: String, + window: WebviewWindow, 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")?; - - if folder != expected_folder { - return Err("Notes folder changed".to_string()); - } - - folder - }; + let workspace = state.workspace_for_window(window.label())?; + let folder = workspace.notes_folder()?; + if folder != expected_folder { + return Err("Notes folder changed".to_string()); + } { - let mut settings = state.settings.write().expect("settings write lock"); + let mut settings = workspace.settings().write().expect("settings write lock"); settings.git_enabled = enabled; } - let settings = state.settings.read().expect("settings read lock"); + let settings = workspace.settings().read().expect("settings read lock"); save_settings(&folder, &settings).map_err(|e| e.to_string())?; Ok(()) @@ -1856,15 +3690,6 @@ pub enum FileSaveResult { Conflict { current: Option }, } -fn note_conflict_snapshot( - snapshot: Option, -) -> Option { - snapshot.map(|snapshot| NoteConflictSnapshot { - content: snapshot.content, - revision: snapshot.revision.to_string(), - }) -} - fn read_file_content_from_path(path: &Path) -> Result { let snapshot = persistence::read_snapshot(path) .map_err(|error| error.to_string())? @@ -2031,166 +3856,11 @@ async fn recreate_file_direct(path: String, content: String) -> Result Result { - let recovery_directory = recovery_root.join("recovery"); - std::fs::create_dir_all(&recovery_directory).map_err(|error| error.to_string())?; - let timestamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|duration| duration.as_millis()) - .unwrap_or(0); - let sequence = RECOVERY_SNAPSHOT_SEQUENCE.fetch_add(1, Ordering::Relaxed); - let safe_note_id = sanitize_filename(¬e_id.replace('/', "-")); - let safe_reason = sanitize_filename(reason); - let stem = format!("{timestamp}-{sequence}-{safe_reason}-{safe_note_id}"); - let recovery_path = recovery_directory.join(format!("{stem}.md")); - - match persistence::save_if_revision(&recovery_path, content, None) - .map_err(|error| error.to_string())? - { - persistence::SaveResult::Saved { .. } => {} - persistence::SaveResult::Conflict { .. } => { - return Err("Recovery snapshot path unexpectedly already exists".to_string()); - } - } - - #[derive(Serialize)] - #[serde(rename_all = "camelCase")] - struct RecoveryMetadata<'a> { - note_id: &'a str, - source_path: &'a str, - reason: &'a str, - created_at_ms: u128, - } - - let metadata = RecoveryMetadata { - note_id, - source_path, - reason, - created_at_ms: timestamp, - }; - if let Ok(metadata_content) = serde_json::to_string_pretty(&metadata) { - let metadata_path = recovery_directory.join(format!("{stem}.json")); - let _ = persistence::save_if_revision(&metadata_path, &metadata_content, None); - } - - Ok(recovery_path) -} - -#[tauri::command] -async fn persist_recovery_snapshot( - app: AppHandle, - note_id: String, - source_path: String, - content: String, - reason: String, -) -> Result { - let recovery_root = app.path().app_data_dir().map_err(|error| error.to_string())?; - tauri::async_runtime::spawn_blocking(move || { - write_recovery_snapshot( - &recovery_root, - ¬e_id, - &source_path, - &content, - &reason, - ) - .map(|path| path.to_string_lossy().into_owned()) - }) - .await - .map_err(|error| format!("Recovery snapshot task failed: {error}"))? -} - -#[tauri::command] -async fn write_draft_checkpoint( - app: AppHandle, - window: WebviewWindow, - note_id: String, - markdown: String, - metadata: draft_checkpoint::DraftCheckpointMetadata, -) -> Result<(), String> { - let app_data = app.path().app_data_dir().map_err(|error| error.to_string())?; - let checkpoint = draft_checkpoint::DraftCheckpoint { - key: draft_checkpoint::DraftCheckpointKey { - window_label: window.label().to_string(), - note_id, - }, - markdown, - metadata, - }; - tauri::async_runtime::spawn_blocking(move || { - draft_checkpoint::write_checkpoint(app_data, &checkpoint) - .map_err(|error| error.to_string()) - }) - .await - .map_err(|error| format!("Draft checkpoint task failed: {error}"))? -} - -#[tauri::command] -async fn get_draft_checkpoint( - app: AppHandle, - window: WebviewWindow, - note_id: String, -) -> Result, String> { - let app_data = app.path().app_data_dir().map_err(|error| error.to_string())?; - let key = draft_checkpoint::DraftCheckpointKey { - window_label: window.label().to_string(), - note_id, - }; - tauri::async_runtime::spawn_blocking(move || { - draft_checkpoint::read_checkpoint(app_data, &key).map_err(|error| error.to_string()) - }) - .await - .map_err(|error| format!("Draft checkpoint read task failed: {error}"))? -} - -#[tauri::command] -async fn clear_draft_checkpoint( - app: AppHandle, - window: WebviewWindow, - note_id: String, -) -> Result<(), String> { - let app_data = app.path().app_data_dir().map_err(|error| error.to_string())?; - let key = draft_checkpoint::DraftCheckpointKey { - window_label: window.label().to_string(), - note_id, - }; - tauri::async_runtime::spawn_blocking(move || { - draft_checkpoint::clear_checkpoint(app_data, &key).map_err(|error| error.to_string()) - }) - .await - .map_err(|error| format!("Draft checkpoint clear task failed: {error}"))? -} - -#[tauri::command] -async fn list_draft_checkpoints( - app: AppHandle, - window: WebviewWindow, -) -> Result, String> { - let app_data = app.path().app_data_dir().map_err(|error| error.to_string())?; - let window_label = window.label().to_string(); - tauri::async_runtime::spawn_blocking(move || { - draft_checkpoint::list_checkpoints(app_data) - .map(|checkpoints| { - checkpoints - .into_iter() - .filter(|checkpoint| checkpoint.key.window_label == window_label) - .collect() - }) - .map_err(|error| error.to_string()) - }) - .await - .map_err(|error| format!("Draft checkpoint list task failed: {error}"))? -} - #[tauri::command] async fn import_file_to_folder( app: AppHandle, path: String, + window: WebviewWindow, state: State<'_, AppState>, ) -> Result { let source = validate_preview_path(&path)?; @@ -2198,13 +3868,8 @@ async fn import_file_to_folder( return Err(format!("Not a file: {}", path)); } - let folder = { - let app_config = state.app_config.read().expect("app_config read lock"); - app_config - .notes_folder - .clone() - .ok_or("Notes folder not set")? - }; + let workspace = state.workspace_for_window(window.label())?; + let folder = workspace.notes_folder()?; let folder_path = PathBuf::from(&folder); // Read the source file content @@ -2259,7 +3924,7 @@ async fn import_file_to_folder( // Update search index { - let index = state.search_index.lock().expect("search index mutex"); + let index = workspace.search_index().lock().expect("search index mutex"); if let Some(ref search_index) = *index { let _ = search_index.index_note(&final_id, &extracted_title, &content, modified); } @@ -2282,13 +3947,14 @@ async fn import_file_to_folder( // Update notes cache so fallback search sees the imported note immediately { - let mut cache = state.notes_cache.write().expect("cache write lock"); + let mut cache = workspace.notes_cache().write().expect("cache write lock"); cache.insert(metadata.id.clone(), metadata.clone()); } // Tell the main window to select the imported note and focus it let _ = app.emit_to("main", "select-note", &metadata.id); if let Some(main_window) = app.get_webview_window("main") { + let _ = main_window.show(); let _ = main_window.set_focus(); } @@ -2296,15 +3962,20 @@ async fn import_file_to_folder( } #[tauri::command] -async fn search_notes(query: String, state: State<'_, AppState>) -> Result, String> { +async fn search_notes( + query: String, + window: WebviewWindow, + state: State<'_, AppState>, +) -> Result, String> { let trimmed_query = query.trim().to_string(); if trimmed_query.is_empty() { return Ok(vec![]); } + let workspace = state.workspace_for_window(window.label())?; // Check if search index is available and use it (scoped to drop lock before await) let indexed_result = { - let index = state.search_index.lock().expect("search index mutex"); + let index = workspace.search_index().lock().expect("search index mutex"); (*index).as_ref().map(|search_index| { search_index.search(&trimmed_query, 20).map_err(|e| e.to_string()) }) @@ -2314,34 +3985,29 @@ async fn search_notes(query: String, state: State<'_, AppState>) -> Result Ok(results), Some(Ok(_)) => { // Tantivy can miss partial/fuzzy matches; fall back to substring search. - fallback_search(&trimmed_query, &state).await + fallback_search(&trimmed_query, &workspace).await } Some(Err(e)) => { eprintln!("Tantivy search error, falling back to substring search: {}", e); - fallback_search(&trimmed_query, &state).await + fallback_search(&trimmed_query, &workspace).await } None => { // Fallback to simple search if index not available - fallback_search(&trimmed_query, &state).await + fallback_search(&trimmed_query, &workspace).await } } } // Fallback search when Tantivy index isn't available - searches title and full content -async fn fallback_search(query: &str, state: &State<'_, AppState>) -> Result, String> { - let folder = { - let app_config = state.app_config.read().expect("app_config read lock"); - app_config.notes_folder.clone() - }; - - let folder = match folder { - Some(f) => f, - None => return Ok(vec![]), - }; +async fn fallback_search( + query: &str, + workspace: &WorkspaceRuntime<'_>, +) -> Result, String> { + let folder = workspace.notes_folder()?; // Collect cache data upfront to avoid holding lock during async operations let cache_data: Vec<(String, String, String, i64)> = { - let cache = state.notes_cache.read().expect("cache read lock"); + let cache = workspace.notes_cache().read().expect("cache read lock"); cache .values() .map(|note| { @@ -2402,20 +4068,159 @@ async fn fallback_search(query: &str, state: &State<'_, AppState>) -> Result, + revision: Option, + origin_window: Option, + previous_id: Option, + current_id: Option, +} + +fn semantic_file_change_event( + workspace: String, + kind: &str, + previous_id: Option, + current_note: Option<&Note>, + origin_window: Option, +) -> FileChangeEvent { + let current_id = current_note.map(|note| note.id.clone()); + let mut changed_ids = Vec::new(); + if let Some(id) = previous_id.as_ref() { + changed_ids.push(id.clone()); + } + if let Some(id) = current_id.as_ref() { + if !changed_ids.contains(id) { + changed_ids.push(id.clone()); + } + } + + let path = current_note.map(|note| note.path.clone()).unwrap_or_else(|| { + let id = previous_id.as_deref().unwrap_or_default(); + PathBuf::from(&workspace) + .join(format!("{id}.md")) + .to_string_lossy() + .into_owned() + }); + + FileChangeEvent { + workspace, + kind: kind.to_string(), + path, + changed_ids, + revision: current_note.map(|note| note.revision.clone()), + origin_window, + previous_id, + current_id, + } +} + +fn emit_workspace_file_change( + app: &AppHandle, + state: &AppState, + note_path: &Path, + payload: FileChangeEvent, + origin_window: Option<&str>, +) { + let labels = { + let bindings = state + .workspace_bindings + .read() + .expect("workspace bindings read lock"); + note_change_targets(&bindings, note_path, origin_window) + }; + for label in labels { + let _ = app.emit_to(&label, "file-change", payload.clone()); + } +} + +fn process_external_note_change( + app_handle: AppHandle, + workspace_path: String, + legacy_main_runtime: bool, + path: PathBuf, + note_id: String, + kind: String, +) { + if let Some(state) = app_handle.try_state::() { + let workspace = if legacy_main_runtime { + Some(WorkspaceRuntime::Main(state.inner())) + } else { + state + .workspace_session_for_path(&workspace_path) + .map(WorkspaceRuntime::Session) + }; + if let Some(workspace) = workspace { + let index = workspace.search_index().lock().expect("search index mutex"); + if let Some(ref search_index) = *index { + match kind.as_str() { + "created" | "modified" => match std::fs::read_to_string(&path) { + Ok(content) => { + let title = extract_title(&content); + let modified = std::fs::metadata(&path) + .ok() + .and_then(|metadata| metadata.modified().ok()) + .and_then(|time| { + time.duration_since(std::time::UNIX_EPOCH).ok() + }) + .map(|duration| duration.as_secs() as i64) + .unwrap_or(0); + let _ = search_index.index_note( + ¬e_id, + &title, + &content, + modified, + ); + } + Err(_) if !path.exists() => { + let _ = search_index.delete_note(¬e_id); + } + Err(_) => {} + }, + "deleted" => { + let _ = search_index.delete_note(¬e_id); + } + _ => {} + } + } + } + } + + let effective_kind = if kind == "modified" && !path.exists() { + "deleted" + } else { + kind.as_str() + }; + let revision = std::fs::read_to_string(&path) + .ok() + .map(|content| persistence::content_revision(&content).to_string()); + let payload = FileChangeEvent { + workspace: workspace_path, + kind: effective_kind.to_string(), + path: path.to_string_lossy().into_owned(), + changed_ids: vec![note_id.clone()], + revision, + origin_window: None, + previous_id: (effective_kind == "deleted").then_some(note_id.clone()), + current_id: (effective_kind != "deleted").then_some(note_id), + }; + if let Some(state) = app_handle.try_state::() { + emit_workspace_file_change(&app_handle, &state, &path, payload, None); + } } fn setup_file_watcher( app: AppHandle, notes_folder: &str, - debounce_map: Arc>>, + legacy_main_runtime: bool, + debounce_map: Arc>, ) -> Result { let folder_path = PathBuf::from(notes_folder); let notes_root = folder_path.clone(); + let workspace_path = notes_folder.to_string(); let app_handle = app.clone(); let watcher = RecommendedWatcher::new( @@ -2424,8 +4229,22 @@ fn setup_file_watcher( for path in event.paths.iter() { // Read current ignored patterns from settings let ignored_dirs = if let Some(state) = app_handle.try_state::() { - let settings = state.settings.read().expect("settings read lock"); - get_effective_ignored_dirs(&settings) + let workspace = if legacy_main_runtime { + Some(WorkspaceRuntime::Main(state.inner())) + } else { + state + .workspace_session_for_path(&workspace_path) + .map(WorkspaceRuntime::Session) + }; + workspace + .map(|workspace| { + let settings = + workspace.settings().read().expect("settings read lock"); + get_effective_ignored_dirs(&settings) + }) + .unwrap_or_else(|| { + DEFAULT_IGNORED_DIRS.iter().map(|s| s.to_string()).collect() + }) } else { DEFAULT_IGNORED_DIRS.iter().map(|s| s.to_string()).collect() }; @@ -2435,23 +4254,6 @@ fn setup_file_watcher( None => continue, }; - // Debounce with cleanup - { - let mut map = debounce_map.lock().expect("debounce map mutex"); - let now = Instant::now(); - - if map.len() > 100 { - map.retain(|_, last| now.duration_since(*last) < Duration::from_secs(5)); - } - - if let Some(last) = map.get(path) { - if now.duration_since(*last) < Duration::from_millis(500) { - continue; - } - } - map.insert(path.clone(), now); - } - let kind = match event.kind { notify::EventKind::Create(_) => "created", notify::EventKind::Modify(_) => "modified", @@ -2460,56 +4262,36 @@ fn setup_file_watcher( notify::EventKind::Any => "modified", _ => continue, }; - - // Update search index for external file changes - if let Some(state) = app_handle.try_state::() { - let index = state.search_index.lock().expect("search index mutex"); - if let Some(ref search_index) = *index { - match kind { - "created" | "modified" => { - match std::fs::read_to_string(path) { - Ok(content) => { - let title = extract_title(&content); - let modified = std::fs::metadata(path) - .ok() - .and_then(|m| m.modified().ok()) - .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|d| d.as_secs() as i64) - .unwrap_or(0); - let _ = search_index.index_note(¬e_id, &title, &content, modified); - } - Err(_) => { - // File gone between event and read — treat as deletion - if !path.exists() { - let _ = search_index.delete_note(¬e_id); - } - } - } - } - "deleted" => { - let _ = search_index.delete_note(¬e_id); - } - _ => {} - } + let path = path.clone(); + let token = debounce_map + .lock() + .expect("debounce map mutex") + .schedule(path.clone()); + let pending = Arc::clone(&debounce_map); + let task_app = app_handle.clone(); + let task_workspace = workspace_path.clone(); + let kind = kind.to_string(); + tauri::async_runtime::spawn(async move { + tokio::time::sleep(WATCHER_DEBOUNCE_WINDOW).await; + let is_latest = pending + .lock() + .expect("debounce map mutex") + .take_if_latest(&path, token); + if !is_latest { + return; } - } - - // Determine the actual kind for the frontend event - // (a "modified" event on a non-existent file is really a delete) - let effective_kind = if kind == "modified" && !path.exists() { - "deleted" - } else { - kind - }; - - let _ = app_handle.emit( - "file-change", - FileChangeEvent { - kind: effective_kind.to_string(), - path: path.to_string_lossy().into_owned(), - changed_ids: vec![note_id.clone()], - }, - ); + let _ = tauri::async_runtime::spawn_blocking(move || { + process_external_note_change( + task_app, + task_workspace, + legacy_main_runtime, + path, + note_id, + kind, + ); + }) + .await; + }); } } }, @@ -2528,26 +4310,23 @@ fn setup_file_watcher( } #[tauri::command] -fn start_file_watcher(app: AppHandle, state: State) -> Result<(), String> { - let folder = { - let app_config = state.app_config.read().expect("app_config read lock"); - app_config - .notes_folder - .clone() - .ok_or("Notes folder not set")? - }; - - // Clean up debounce map before starting - cleanup_debounce_map(&state.debounce_map); - - let watcher_state = setup_file_watcher( - app, - &folder, - Arc::clone(&state.debounce_map), - )?; - - let mut file_watcher = state.file_watcher.lock().expect("file watcher mutex"); - *file_watcher = Some(watcher_state); +fn start_file_watcher( + app: AppHandle, + window: WebviewWindow, + state: State, +) -> Result<(), String> { + let workspace = state.workspace_for_window(window.label())?; + let folder = workspace.notes_folder()?; + let legacy_main_runtime = matches!(&workspace, WorkspaceRuntime::Main(_)); + + ensure_shared_resource(workspace.file_watcher(), || { + setup_file_watcher( + app, + &folder, + legacy_main_runtime, + Arc::clone(workspace.debounce_map()), + ) + })?; Ok(()) } @@ -2560,6 +4339,7 @@ fn copy_to_clipboard(app: AppHandle, text: String) -> Result<(), String> { #[tauri::command] async fn save_clipboard_image( base64_data: String, + window: WebviewWindow, state: State<'_, AppState>, ) -> Result { // Guard against empty clipboard payload @@ -2567,13 +4347,9 @@ async fn save_clipboard_image( return Err("Clipboard data is empty".to_string()); } - let folder = { - let app_config = state.app_config.read().expect("app_config read lock"); - app_config - .notes_folder - .clone() - .ok_or("Notes folder not set")? - }; + let folder = state + .workspace_for_window(window.label())? + .notes_folder()?; // Decode base64 let image_data = base64::engine::general_purpose::STANDARD @@ -2619,15 +4395,12 @@ async fn save_clipboard_image( #[tauri::command] async fn copy_image_to_assets( source_path: String, + window: WebviewWindow, state: State<'_, AppState>, ) -> Result { - let folder = { - let app_config = state.app_config.read().expect("app_config read lock"); - app_config - .notes_folder - .clone() - .ok_or("Notes folder not set")? - }; + let folder = state + .workspace_for_window(window.label())? + .notes_folder()?; let source = PathBuf::from(&source_path); if !source.exists() { @@ -2684,21 +4457,19 @@ async fn copy_image_to_assets( } #[tauri::command] -fn rebuild_search_index(state: State) -> Result<(), String> { - let folder = { - let app_config = state.app_config.read().expect("app_config read lock"); - app_config - .notes_folder - .clone() - .ok_or("Notes folder not set")? - }; +fn rebuild_search_index( + window: WebviewWindow, + state: State, +) -> Result<(), String> { + let workspace = state.workspace_for_window(window.label())?; + let folder = workspace.notes_folder()?; let ignored_dirs = { - let settings = state.settings.read().expect("settings read lock"); + let settings = workspace.settings().read().expect("settings read lock"); get_effective_ignored_dirs(&settings) }; - let index = state.search_index.lock().expect("search index mutex"); + let index = workspace.search_index().lock().expect("search index mutex"); match index.as_ref() { Some(search_index) => search_index .rebuild_index(&PathBuf::from(&folder), &ignored_dirs) @@ -2712,8 +4483,6 @@ fn get_default_ignored_patterns() -> Vec { DEFAULT_IGNORED_DIRS.iter().map(|s| s.to_string()).collect() } -// UI helper commands - wrap Tauri plugins for consistent invoke-based API - #[tauri::command] async fn open_folder_dialog( app: AppHandle, @@ -2806,11 +4575,14 @@ async fn git_is_available() -> bool { } #[tauri::command] -async fn git_get_status(state: State<'_, AppState>) -> Result { - let folder = { - let app_config = state.app_config.read().expect("app_config read lock"); - app_config.notes_folder.clone() - }; +async fn git_get_status( + window: WebviewWindow, + state: State<'_, AppState>, +) -> Result { + let folder = state + .workspace_for_window(window.label()) + .ok() + .and_then(|workspace| workspace.notes_folder().ok()); match folder { Some(path) => { @@ -2825,11 +4597,13 @@ async fn git_get_status(state: State<'_, AppState>) -> Result) -> Result<(), String> { - let folder = { - let app_config = state.app_config.read().expect("app_config read lock"); - app_config.notes_folder.clone().ok_or("Notes folder not set")? - }; +async fn git_init_repo( + window: WebviewWindow, + state: State<'_, AppState>, +) -> Result<(), String> { + let folder = state + .workspace_for_window(window.label())? + .notes_folder()?; tauri::async_runtime::spawn_blocking(move || { git::git_init(&PathBuf::from(folder)) @@ -2839,11 +4613,15 @@ async fn git_init_repo(state: State<'_, AppState>) -> Result<(), String> { } #[tauri::command] -async fn git_commit(message: String, state: State<'_, AppState>) -> Result { - let folder = { - let app_config = state.app_config.read().expect("app_config read lock"); - app_config.notes_folder.clone() - }; +async fn git_commit( + message: String, + window: WebviewWindow, + state: State<'_, AppState>, +) -> Result { + let folder = state + .workspace_for_window(window.label()) + .ok() + .and_then(|workspace| workspace.notes_folder().ok()); match folder { Some(path) => { @@ -2862,11 +4640,14 @@ async fn git_commit(message: String, state: State<'_, AppState>) -> Result) -> Result { - let folder = { - let app_config = state.app_config.read().expect("app_config read lock"); - app_config.notes_folder.clone() - }; +async fn git_push( + window: WebviewWindow, + state: State<'_, AppState>, +) -> Result { + let folder = state + .workspace_for_window(window.label()) + .ok() + .and_then(|workspace| workspace.notes_folder().ok()); match folder { Some(path) => { @@ -2885,11 +4666,14 @@ async fn git_push(state: State<'_, AppState>) -> Result } #[tauri::command] -async fn git_fetch(state: State<'_, AppState>) -> Result { - let folder = { - let app_config = state.app_config.read().expect("app_config read lock"); - app_config.notes_folder.clone() - }; +async fn git_fetch( + window: WebviewWindow, + state: State<'_, AppState>, +) -> Result { + let folder = state + .workspace_for_window(window.label()) + .ok() + .and_then(|workspace| workspace.notes_folder().ok()); match folder { Some(path) => { @@ -2908,11 +4692,14 @@ async fn git_fetch(state: State<'_, AppState>) -> Result } #[tauri::command] -async fn git_pull(state: State<'_, AppState>) -> Result { - let folder = { - let app_config = state.app_config.read().expect("app_config read lock"); - app_config.notes_folder.clone() - }; +async fn git_pull( + window: WebviewWindow, + state: State<'_, AppState>, +) -> Result { + let folder = state + .workspace_for_window(window.label()) + .ok() + .and_then(|workspace| workspace.notes_folder().ok()); match folder { Some(path) => { @@ -2931,11 +4718,15 @@ async fn git_pull(state: State<'_, AppState>) -> Result } #[tauri::command] -async fn git_add_remote(url: String, state: State<'_, AppState>) -> Result { - let folder = { - let app_config = state.app_config.read().expect("app_config read lock"); - app_config.notes_folder.clone() - }; +async fn git_add_remote( + url: String, + window: WebviewWindow, + state: State<'_, AppState>, +) -> Result { + let folder = state + .workspace_for_window(window.label()) + .ok() + .and_then(|workspace| workspace.notes_folder().ok()); match folder { Some(path) => { @@ -2954,11 +4745,15 @@ async fn git_add_remote(url: String, state: State<'_, AppState>) -> Result) -> Result { - let folder = { - let app_config = state.app_config.read().expect("app_config read lock"); - app_config.notes_folder.clone() - }; +async fn git_set_remote_url( + url: String, + window: WebviewWindow, + state: State<'_, AppState>, +) -> Result { + let folder = state + .workspace_for_window(window.label()) + .ok() + .and_then(|workspace| workspace.notes_folder().ok()); match folder { Some(path) => { @@ -2977,11 +4772,14 @@ async fn git_set_remote_url(url: String, state: State<'_, AppState>) -> Result) -> Result { - let folder = { - let app_config = state.app_config.read().expect("app_config read lock"); - app_config.notes_folder.clone() - }; +async fn git_remove_remote( + window: WebviewWindow, + state: State<'_, AppState>, +) -> Result { + let folder = state + .workspace_for_window(window.label()) + .ok() + .and_then(|workspace| workspace.notes_folder().ok()); match folder { Some(path) => { @@ -3000,11 +4798,14 @@ async fn git_remove_remote(state: State<'_, AppState>) -> Result) -> Result { - let folder = { - let app_config = state.app_config.read().expect("app_config read lock"); - app_config.notes_folder.clone() - }; +async fn git_push_with_upstream( + window: WebviewWindow, + state: State<'_, AppState>, +) -> Result { + let folder = state + .workspace_for_window(window.label()) + .ok() + .and_then(|workspace| workspace.notes_folder().ok()); match folder { Some(path) => { @@ -3503,12 +5304,12 @@ async fn execute_ai_cli( async fn ai_execute_claude( file_path: String, prompt: String, + window: WebviewWindow, state: State<'_, AppState>, ) -> Result { - let folder = { - let app_config = state.app_config.read().expect("app_config read lock"); - app_config.notes_folder.clone().ok_or("Notes folder not set")? - }; + let folder = state + .workspace_for_window(window.label())? + .notes_folder()?; let path = PathBuf::from(&file_path); let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); if !ext.eq_ignore_ascii_case("md") && !ext.eq_ignore_ascii_case("markdown") { @@ -3571,12 +5372,12 @@ async fn ai_execute_codex(file_path: String, prompt: String) -> Result, ) -> Result { - let folder = { - let app_config = state.app_config.read().expect("app_config read lock"); - app_config.notes_folder.clone().ok_or("Notes folder not set")? - }; + let folder = state + .workspace_for_window(window.label())? + .notes_folder()?; let path = PathBuf::from(&file_path); let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); if !ext.eq_ignore_ascii_case("md") && !ext.eq_ignore_ascii_case("markdown") { @@ -3638,12 +5439,12 @@ async fn ai_execute_ollama( file_path: String, prompt: String, model: String, + window: WebviewWindow, state: State<'_, AppState>, ) -> Result { - let folder = { - let app_config = state.app_config.read().expect("app_config read lock"); - app_config.notes_folder.clone().ok_or("Notes folder not set")? - }; + let folder = state + .workspace_for_window(window.label())? + .notes_folder()?; let path = PathBuf::from(&file_path); let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); if !ext.eq_ignore_ascii_case("md") && !ext.eq_ignore_ascii_case("markdown") { @@ -3774,8 +5575,8 @@ async fn ai_execute_ollama( } } -/// Check if a markdown file is inside the configured notes folder. -/// If so, emit a "select-note" event to the main window and focus it, returning true. +/// Check if a markdown file belongs to any open workspace. +/// If so, select it in the owning window and focus that window. /// Returns false on any failure so callers can fall back to create_preview_window. fn try_select_in_notes_folder(app: &AppHandle, path: &Path) -> bool { let state = match app.try_state::() { @@ -3783,23 +5584,31 @@ fn try_select_in_notes_folder(app: &AppHandle, path: &Path) -> bool { None => return false, }; - let notes_folder = state - .app_config + let canonical_file = match path.canonicalize() { + Ok(path) => path, + Err(_) => return false, + }; + let window_label = state + .workspace_bindings .read() - .expect("app_config read lock") - .notes_folder - .clone(); - - let folder = match notes_folder { - Some(f) => f, + .expect("workspace bindings read lock") + .window_for_note_path(&canonical_file) + .map(str::to_string); + let window_label = match window_label { + Some(label) => label, None => return false, }; - - let folder_path = PathBuf::from(&folder); - let (canonical_file, canonical_folder) = match (path.canonicalize(), folder_path.canonicalize()) + let workspace = match state.workspace_for_window(&window_label) { + Ok(workspace) => workspace, + Err(_) => return false, + }; + let canonical_folder = match workspace + .notes_folder() + .ok() + .and_then(|folder| PathBuf::from(folder).canonicalize().ok()) { - (Ok(f), Ok(d)) => (f, d), - _ => return false, + Some(folder) => folder, + None => return false, }; if !canonical_file.starts_with(&canonical_folder) { @@ -3807,7 +5616,7 @@ fn try_select_in_notes_folder(app: &AppHandle, path: &Path) -> bool { } let ignored_dirs = { - let settings = state.settings.read().expect("settings read lock"); + let settings = workspace.settings().read().expect("settings read lock"); get_effective_ignored_dirs(&settings) }; @@ -3816,10 +5625,10 @@ fn try_select_in_notes_folder(app: &AppHandle, path: &Path) -> bool { None => return false, }; - let _ = app.emit_to("main", "select-note", note_id); - if let Some(main_window) = app.get_webview_window("main") { - let _ = main_window.show(); - let _ = main_window.set_focus(); + let _ = app.emit_to(&window_label, "select-note", note_id); + if let Some(workspace_window) = app.get_webview_window(&window_label) { + let _ = workspace_window.show(); + let _ = workspace_window.set_focus(); } true } @@ -3835,87 +5644,99 @@ fn is_markdown_extension(path: &Path) -> bool { .unwrap_or(false) } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct NativePreferencesMenuSpec { - id: &'static str, - label: &'static str, - accelerator: &'static str, +fn should_hide_main_window_for_standalone_preview( + opened_preview: bool, + has_notes_folder: bool, +) -> bool { + opened_preview && has_notes_folder } -fn native_preferences_menu_spec() -> NativePreferencesMenuSpec { - NativePreferencesMenuSpec { - id: "preferences", - label: "Preferences…", - accelerator: "CmdOrCtrl+,", +fn should_defer_open_request(app_state_ready: bool) -> bool { + !app_state_ready +} + +fn defer_single_instance_open_if_needed( + app: &AppHandle, + args: Vec, + cwd: String, +) -> Option { + let mut pending = PENDING_SINGLE_INSTANCE_OPENS + .lock() + .expect("pending single-instance opens mutex"); + + let app_state_ready = app.try_state::().is_some(); + if !should_defer_open_request(app_state_ready) { + return Some((args, cwd)); } + + pending.push((args, cwd)); + None } -fn build_application_menu( - app_handle: &AppHandle, -) -> tauri::Result> { - use tauri::menu::{Menu, MenuItem, PredefinedMenuItem}; +fn take_pending_single_instance_opens() -> Vec { + let mut pending = PENDING_SINGLE_INSTANCE_OPENS + .lock() + .expect("pending single-instance opens mutex"); + std::mem::take(&mut *pending) +} - let menu = Menu::default(app_handle)?; - let spec = native_preferences_menu_spec(); - let preferences = MenuItem::with_id( - app_handle, - spec.id, - spec.label, - true, - Some(spec.accelerator), - )?; - let application_menu_name = app_handle - .config() - .product_name - .clone() - .unwrap_or_else(|| app_handle.package_info().name.clone()); +fn defer_opened_markdown_paths_if_needed( + app: &AppHandle, + paths: Vec, +) -> Option> { + let mut pending = PENDING_OPENED_MARKDOWN_PATHS + .lock() + .expect("pending opened markdown paths mutex"); - for item in menu.items()? { - let Some(submenu) = item.as_submenu() else { - continue; - }; - if submenu.text()? == application_menu_name { - let separator = PredefinedMenuItem::separator(app_handle)?; - submenu.insert_items(&[&preferences, &separator], 1)?; - } + let app_state_ready = app.try_state::().is_some(); + if !should_defer_open_request(app_state_ready) { + return Some(paths); } - Ok(menu) + pending.extend(paths); + None } -fn should_hide_main_window_for_standalone_preview( - opened_preview: bool, - has_notes_folder: bool, -) -> bool { - opened_preview && has_notes_folder +fn take_pending_opened_markdown_paths() -> Vec { + let mut pending = PENDING_OPENED_MARKDOWN_PATHS + .lock() + .expect("pending opened markdown paths mutex"); + std::mem::take(&mut *pending) } fn has_configured_notes_folder(app: &AppHandle) -> bool { let Some(state) = app.try_state::() else { return false; }; + let has_notes_folder = state .app_config .read() .expect("app_config read lock") .notes_folder .is_some(); + has_notes_folder } fn hide_main_window_for_standalone_preview(app: &AppHandle, opened_preview: bool) -> bool { - let should_hide = should_hide_main_window_for_standalone_preview( - opened_preview, - has_configured_notes_folder(app), - ); + let has_notes_folder = has_configured_notes_folder(app); + let should_hide = + should_hide_main_window_for_standalone_preview(opened_preview, has_notes_folder); + if should_hide { if let Some(main_window) = app.get_webview_window("main") { let _ = main_window.hide(); } } + should_hide } +fn should_show_main_window(main_window_hidden: bool) -> bool { + !main_window_hidden +} + fn runtime_window_config_from_template( template: &tauri::utils::config::WindowConfig, label: &str, @@ -3961,15 +5782,6 @@ fn open_preferences_window(app: AppHandle) -> Result<(), String> { create_preferences_window(&app) } -/// Finalize a close only after the WebView has flushed the draft or persisted -/// a recovery snapshot. Destruction remains in trusted Rust. -#[tauri::command] -fn close_window_after_save(window: WebviewWindow) -> Result<(), String> { - window - .destroy() - .map_err(|error| format!("Failed to close window after save: {error}")) -} - // Preview mode: create a lightweight window for editing a single file fn create_preview_window(app: &AppHandle, file_path: &str) -> Result<(), String> { use std::collections::hash_map::DefaultHasher; @@ -4000,7 +5812,7 @@ fn create_preview_window(app: &AppHandle, file_path: &str) -> Result<(), String> WebviewUrl::App(url.into()), ); let builder = WebviewWindowBuilder::from_config(app, &runtime_config) - .map_err(|error| format!("Failed to configure preview window: {error}"))? + .map_err(|e| format!("Failed to configure preview window: {e}"))? .title(format!("{} — Scratch", filename)) .inner_size(800.0, 600.0) .min_inner_size(400.0, 300.0) @@ -4036,6 +5848,13 @@ fn open_file_preview(app: AppHandle, path: String) -> Result<(), String> { Ok(()) } +fn open_markdown_file(app: &AppHandle, path: &Path) -> bool { + is_markdown_extension(path) + && path.is_file() + && !try_select_in_notes_folder(app, path) + && create_preview_window(app, &path.to_string_lossy()).is_ok() +} + // Handle CLI arguments: open .md files in preview mode. // Returns true if a standalone preview window was created (file outside notes folder). fn handle_cli_args(app: &AppHandle, args: &[String], cwd: &str) -> bool { @@ -4056,11 +5875,7 @@ fn handle_cli_args(app: &AppHandle, args: &[String], cwd: &str) -> bool { if is_markdown_extension(&path) && path.is_file() { opened_file = true; - if !try_select_in_notes_folder(app, &path) - && create_preview_window(app, &path.to_string_lossy()).is_ok() - { - opened_preview = true; - } + opened_preview |= open_markdown_file(app, &path); } else if path.is_dir() { let canonical = path.canonicalize().unwrap_or(path.clone()); let state = app.state::(); @@ -4069,7 +5884,7 @@ fn handle_cli_args(app: &AppHandle, args: &[String], cwd: &str) -> bool { match initialize_notes_folder(app, &canonical, &state) { Ok(normalized_path) => { // Emit event for when app is already running (single-instance) - let _ = app.emit("set-notes-folder", normalized_path); + let _ = app.emit_to("main", "set-notes-folder", normalized_path); opened_file = true; } Err(e) => { @@ -4127,8 +5942,13 @@ pub fn run() { let app = tauri::Builder::default() // Single-instance: forward CLI args from subsequent launches to the running instance .plugin(tauri_plugin_single_instance::init(|app, args, cwd| { - let opened_preview = handle_cli_args(app, &args, &cwd); - hide_main_window_for_standalone_preview(app, opened_preview); + if let Some((args, cwd)) = defer_single_instance_open_if_needed(app, args, cwd) { + let opened_preview = handle_cli_args(app, &args, &cwd); + if opened_preview { + suppress_full_windows_for_preview(app); + } + hide_main_window_for_standalone_preview(app, opened_preview); + } })) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_fs::init()) @@ -4137,7 +5957,11 @@ pub fn run() { .plugin(tauri_plugin_updater::Builder::new().build()) .menu(build_application_menu) .on_menu_event(|app, event| { - if event.id() == native_preferences_menu_spec().id { + if event.id() == native_new_window_menu_spec().id { + emit_native_new_window_request(app); + } else if event.id() == native_open_folder_menu_spec().id { + emit_native_open_folder_request(app); + } else if event.id() == native_preferences_menu_spec().id { if let Err(error) = create_preferences_window(app) { eprintln!("Failed to open Preferences: {error}"); } @@ -4169,34 +5993,45 @@ pub fn run() { } } - // Load per-folder settings if notes folder is set - let settings = if let Some(ref folder) = app_config.notes_folder { - load_settings(folder) - } else { - Settings::default() - }; + if let Some(default_workspace) = app_config.notes_folder.clone() { + let previous_workspace_count = app_config.workspaces.len(); + remember_workspace(&mut app_config, default_workspace); + if app_config.workspaces.len() != previous_workspace_count { + let _ = save_app_config(app.handle(), &app_config); + } + } - // Initialize search index if notes folder is set - let ignored_dirs = get_effective_ignored_dirs(&settings); - let search_index = if let Some(ref folder) = app_config.notes_folder { - if let Ok(index_path) = get_search_index_path(app.handle()) { - SearchIndex::new(&index_path).ok().inspect(|idx| { - let _ = idx.rebuild_index(&PathBuf::from(folder), &ignored_dirs); - }) - } else { - None + let default_workspace = app_config.notes_folder.clone(); + if app_config.global_settings.is_none() { + if let Some(default_workspace) = default_workspace.as_deref() { + let legacy_settings = load_legacy_settings(default_workspace); + if let Some(workspace_settings) = + migrate_legacy_settings(&mut app_config, legacy_settings) + { + if let Err(error) = save_app_config(app.handle(), &app_config) { + eprintln!("Failed to persist global settings migration: {error}"); + app_config.global_settings = None; + } else if let Err(error) = + save_settings(default_workspace, &workspace_settings) + { + eprintln!("Failed to clean migrated workspace settings: {error}"); + } + } } - } else { - None - }; + } let state = AppState { app_config: RwLock::new(app_config), - settings: RwLock::new(settings), + settings: RwLock::new(WorkspaceSettings::default()), notes_cache: RwLock::new(HashMap::new()), file_watcher: Mutex::new(None), - search_index: Mutex::new(search_index), - debounce_map: Arc::new(Mutex::new(HashMap::new())), + search_index: Mutex::new(None), + debounce_map: Arc::new(Mutex::new(WatcherDebounce::default())), + workspace_sessions: RwLock::new(HashMap::new()), + workspace_initializers: Mutex::new(HashMap::new()), + workspace_bindings: RwLock::new(WorkspaceBindings::default()), + is_quitting: AtomicBool::new(false), + preserve_session_labels: Mutex::new(HashSet::new()), }; app.manage(state); @@ -4207,29 +6042,91 @@ pub fn run() { // Handle CLI args on first launch; determine whether to show the main window. // When a standalone preview is opened (file outside the notes folder) and the - // notes folder is already configured, the main window is closed so users only + // notes folder is already configured, the main window is hidden so users only // see the preview. When no notes folder is configured yet, the main window is // always shown so new users can complete onboarding via the FolderPicker. + let mut opened_preview = false; + for path in take_pending_opened_markdown_paths() { + opened_preview |= open_markdown_file(app.handle(), &path); + } + + for (args, cwd) in take_pending_single_instance_opens() { + let pending_opened_preview = handle_cli_args(app.handle(), &args, &cwd); + opened_preview |= pending_opened_preview; + } + let args: Vec = std::env::args().collect(); - let opened_preview = if args.len() > 1 { + if args.len() > 1 { let cwd = std::env::current_dir() .unwrap_or_default() .to_string_lossy() .into_owned(); - handle_cli_args(app.handle(), &args, &cwd) - } else { - false - }; + opened_preview |= handle_cli_args(app.handle(), &args, &cwd); + } + if opened_preview { + suppress_full_windows_for_preview(app.handle()); + } - if let Some(main_window) = app.get_webview_window("main") { - if should_hide_main_window_for_standalone_preview( - opened_preview, - has_configured_notes_folder(app.handle()), - ) { - // Existing user: notes folder is configured and a standalone preview - // was opened. Close the hidden main window so only the preview is visible. - let _ = main_window.hide(); - } else { + let app_state = app.state::(); + let mut config_snapshot = app_state + .app_config + .read() + .expect("app config read lock") + .clone(); + let had_main_preference = config_snapshot.records.contains_key("main"); + if !opened_preview && !had_main_preference { + if let Some(default_workspace) = default_workspace + .as_ref() + .filter(|workspace| Path::new(workspace).is_dir()) + { + let mut next = config_snapshot.clone(); + upsert_window_session_workspace( + &mut next, + "main", + default_workspace.clone(), + ); + if save_app_config(app.handle(), &next).is_ok() { + *app_state + .app_config + .write() + .expect("app config write lock") = next.clone(); + config_snapshot = next; + } + } + } + + let restore_candidates = restorable_window_sessions( + &config_snapshot, + opened_preview, + |workspace| Path::new(workspace).is_dir(), + ); + let mut main_restored = false; + for (label, record) in restore_candidates { + match restore_window_session(app.handle(), &app_state, &label, &record) { + Ok(()) => main_restored |= label == "main", + Err(error) => eprintln!("Window session restore skipped for {label}: {error}"), + } + } + if let Some(fallback) = fallback_main_session( + &config_snapshot, + default_workspace.as_deref(), + opened_preview, + main_restored, + |workspace| Path::new(workspace).is_dir(), + ) { + match restore_window_session(app.handle(), &app_state, "main", &fallback) { + Ok(()) => {} + Err(error) => { + eprintln!("Fallback main window session restore skipped: {error}") + } + } + } + + let main_window_hidden = + hide_main_window_for_standalone_preview(app.handle(), opened_preview); + + if should_show_main_window(main_window_hidden) { + if let Some(main_window) = app.get_webview_window("main") { // Show the main window when: // - No standalone preview was opened (normal launch), OR // - No notes folder is configured yet (new user needs FolderPicker @@ -4241,6 +6138,11 @@ pub fn run() { Ok(()) }) .on_window_event(|window, event| { + if matches!(event, tauri::WindowEvent::Destroyed) { + handle_window_destroyed(window.app_handle(), window.label()); + return; + } + // Handle drag-and-drop of .md files onto any window if let tauri::WindowEvent::DragDrop(tauri::DragDropEvent::Drop { paths, .. }) = event { let app = window.app_handle(); @@ -4256,12 +6158,24 @@ pub fn run() { }) .invoke_handler(tauri::generate_handler![ get_notes_folder, + get_window_session, close_window_after_save, - open_preferences_window, + update_window_session, + clear_window_session, set_notes_folder, + list_workspaces, + remove_workspace_from_list, + switch_workspace, + open_workspace_window, + open_preferences_window, list_notes, read_note, save_note, + persist_recovery_snapshot, + write_draft_checkpoint, + get_draft_checkpoint, + clear_draft_checkpoint, + list_draft_checkpoints, delete_note, create_note, list_folders, @@ -4271,6 +6185,8 @@ pub fn run() { move_note, move_folder, get_settings, + update_global_settings, + update_workspace_settings, update_settings, update_git_enabled, preview_note_name, @@ -4307,11 +6223,6 @@ pub fn run() { read_file_direct, save_file_direct, recreate_file_direct, - persist_recovery_snapshot, - write_draft_checkpoint, - get_draft_checkpoint, - clear_draft_checkpoint, - list_draft_checkpoints, import_file_to_folder, open_file_preview, install_cli, @@ -4325,21 +6236,30 @@ pub fn run() { // Use .run() callback to handle macOS "Open With" file events // RunEvent::Opened is macOS-only in Tauri v2 app.run(|_app_handle, _event| { + if matches!(&_event, tauri::RunEvent::ExitRequested { .. }) { + if let Some(state) = _app_handle.try_state::() { + state.is_quitting.store(true, Ordering::SeqCst); + } + } + #[cfg(target_os = "macos")] if let tauri::RunEvent::Opened { urls } = _event { - let mut opened_preview = false; - for url in urls { - if let Ok(path) = url.to_file_path() { - if is_markdown_extension(&path) - && path.is_file() - && !try_select_in_notes_folder(_app_handle, &path) - { - opened_preview |= - create_preview_window(_app_handle, &path.to_string_lossy()).is_ok(); - } + let paths = urls + .into_iter() + .filter_map(|url| url.to_file_path().ok()) + .collect::>(); + + if let Some(paths) = defer_opened_markdown_paths_if_needed(_app_handle, paths) { + let mut opened_preview = false; + for path in paths { + opened_preview |= open_markdown_file(_app_handle, &path); } + + if opened_preview { + suppress_full_windows_for_preview(_app_handle); + } + hide_main_window_for_standalone_preview(_app_handle, opened_preview); } - hide_main_window_for_standalone_preview(_app_handle, opened_preview); } }); } @@ -4402,7 +6322,7 @@ mod windows_title_bar { #[tauri::command] fn set_title_bar_theme( - app: AppHandle, + window: WebviewWindow, is_dark: bool, r: u8, g: u8, @@ -4410,55 +6330,22 @@ fn set_title_bar_theme( ) -> Result<(), String> { #[cfg(target_os = "windows")] { - for (label, window) in app.webview_windows() { - if label == "main" || label.starts_with("preview-") { - windows_title_bar::apply_title_bar_theme(&window, is_dark, (r, g, b)); - } - } + windows_title_bar::apply_title_bar_theme(&window, is_dark, (r, g, b)); } #[cfg(not(target_os = "windows"))] { - let _ = (app, is_dark, r, g, b); + let _ = (window, is_dark, r, g, b); } Ok(()) } #[cfg(test)] -mod tests { +mod settings_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, + merge_settings, migrate_legacy_settings, settings_change_targets, split_settings, + apply_settings_patch, AppConfig, GlobalSettings, Settings, SettingsChangedPayload, + SettingsChangeScope, SettingsChangeTargets, WorkspaceBindings, WorkspaceSettings, }; - use std::fs; - use std::path::PathBuf; - use std::sync::atomic::{AtomicU64, Ordering}; - - static NEXT_STANDALONE_TEST_DIRECTORY: AtomicU64 = AtomicU64::new(0); - - struct StandaloneTestDirectory { - path: PathBuf, - } - - impl StandaloneTestDirectory { - fn new(name: &str) -> Self { - let sequence = - NEXT_STANDALONE_TEST_DIRECTORY.fetch_add(1, Ordering::Relaxed); - let path = std::env::temp_dir().join(format!( - "scratch-standalone-{name}-{}-{sequence}", - std::process::id() - )); - fs::create_dir_all(&path).expect("create standalone test directory"); - Self { path } - } - } - - impl Drop for StandaloneTestDirectory { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.path); - } - } #[test] fn settings_preserve_sidebar_note_sort_order() { @@ -4474,79 +6361,1198 @@ mod tests { } #[test] - fn settings_preserve_editor_display_preferences() { + fn split_and_merge_keep_theme_global_and_pins_workspace_scoped() { let settings: Settings = serde_json::from_str( r#"{ - "theme":{"mode":"system"}, - "editorWidthResizeEnabled":false, - "editorToolbarVisible":true, - "titleBarModifiedDateVisible":false, - "titleBarFilenameVisible":true + "theme":{"mode":"dark"}, + "editorWidth":"wide", + "gitEnabled":true, + "pinnedNoteIds":["Plans/Roadmap"], + "foldersEnabled":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 (global, workspace) = split_settings(settings.clone()); + let global_json = serde_json::to_value(&global).expect("global settings serialize"); + let workspace_json = + serde_json::to_value(&workspace).expect("workspace settings serialize"); + + assert_eq!(global_json["theme"]["mode"], "dark"); + assert_eq!(global_json["editorWidth"], "wide"); + assert!(global_json.get("pinnedNoteIds").is_none()); + assert!(global_json.get("gitEnabled").is_none()); + assert_eq!(workspace_json["pinnedNoteIds"][0], "Plans/Roadmap"); + assert_eq!(workspace_json["gitEnabled"], true); + assert!(workspace_json.get("theme").is_none()); + assert!(workspace_json.get("editorWidth").is_none()); + + let merged = merge_settings(&global, &workspace); + assert_eq!(merged.theme.mode, "dark"); + assert_eq!(merged.editor_width.as_deref(), Some("wide")); + assert_eq!( + merged.pinned_note_ids.as_deref(), + Some(["Plans/Roadmap".to_string()].as_slice()) + ); + assert_eq!(merged.git_enabled, Some(true)); + } - let serialized = serde_json::to_value(settings).expect("settings should serialize"); - assert_eq!(serialized["editorWidthResizeEnabled"], false); - assert_eq!(serialized["editorToolbarVisible"], true); - assert_eq!(serialized["titleBarModifiedDateVisible"], false); - assert_eq!(serialized["titleBarFilenameVisible"], true); + #[test] + fn legacy_default_workspace_settings_migrate_only_once() { + let mut config: AppConfig = serde_json::from_str( + r#"{"notes_folder":"/notes/main","workspaces":["/notes/main"]}"#, + ) + .expect("legacy config should deserialize"); + let legacy: Settings = serde_json::from_str( + r#"{"theme":{"mode":"dark"},"pinnedNoteIds":["Main"]}"#, + ) + .expect("legacy settings should deserialize"); + + let workspace = migrate_legacy_settings(&mut config, legacy) + .expect("missing global config should trigger migration"); + + assert_eq!( + config + .global_settings + .as_ref() + .expect("global settings persisted in config") + .theme + .mode, + "dark" + ); + assert_eq!(workspace.pinned_note_ids, Some(vec!["Main".to_string()])); + let config_json = serde_json::to_value(&config).expect("migrated config should serialize"); + assert_eq!(config_json["global_settings"]["theme"]["mode"], "dark"); + assert!(config_json["global_settings"].get("pinnedNoteIds").is_none()); + + let later_legacy: Settings = serde_json::from_str( + r#"{"theme":{"mode":"light"},"pinnedNoteIds":["Other"]}"#, + ) + .expect("later legacy settings should deserialize"); + assert!(migrate_legacy_settings(&mut config, later_legacy).is_none()); + assert_eq!( + config.global_settings.as_ref().unwrap().theme.mode, + "dark", + "an existing global config is the migration marker" + ); } #[test] - fn preferences_menu_uses_the_native_shortcut() { - let spec = native_preferences_menu_spec(); - assert_eq!(spec.id, "preferences"); - assert_eq!(spec.label, "Preferences…"); - assert_eq!(spec.accelerator, "CmdOrCtrl+,"); + fn global_settings_changes_target_every_application_window() { + let mut bindings = WorkspaceBindings::default(); + bindings.bind("main", "/notes/shared"); + bindings.bind("workspace-copy", "/notes/shared"); + bindings.bind("workspace-other", "/notes/other"); + + assert_eq!( + settings_change_targets(&bindings, SettingsChangeScope::Global, None), + SettingsChangeTargets::All + ); } #[test] - fn standalone_preview_hides_main_only_for_configured_users() { - assert!(should_hide_main_window_for_standalone_preview(true, true)); - assert!(!should_hide_main_window_for_standalone_preview(true, false)); - assert!(!should_hide_main_window_for_standalone_preview(false, true)); + fn workspace_settings_changes_target_only_windows_with_same_binding() { + let mut bindings = WorkspaceBindings::default(); + bindings.bind("main", "/notes/shared"); + bindings.bind("workspace-copy", "/notes/shared"); + bindings.bind("workspace-other", "/notes/other"); + + assert_eq!( + settings_change_targets( + &bindings, + SettingsChangeScope::Workspace, + Some("/notes/shared"), + ), + SettingsChangeTargets::Windows(vec![ + "main".to_string(), + "workspace-copy".to_string(), + ]) + ); } #[test] - fn standalone_save_preserves_an_external_edit_as_a_conflict() { - let directory = StandaloneTestDirectory::new("conflict"); - let path = directory.path.join("External.md"); - fs::write(&path, "# External\n\nOriginal").expect("write initial note"); - let loaded = read_file_content_from_path(&path).expect("read initial note"); - fs::write(&path, "# External\n\nChanged outside Scratch") - .expect("write external edit"); - - let result = save_file_content_to_path( - &path, - "# External\n\nStale local draft".to_string(), - loaded.revision, - ) - .expect("return typed conflict"); + fn settings_changed_payload_matches_the_frontend_contract() { + let global = serde_json::to_value(SettingsChangedPayload { + scope: SettingsChangeScope::Global, + workspace: None, + }) + .expect("global payload should serialize"); + let workspace = serde_json::to_value(SettingsChangedPayload { + scope: SettingsChangeScope::Workspace, + workspace: Some("/notes/shared".to_string()), + }) + .expect("workspace payload should serialize"); - assert!(matches!(result, FileSaveResult::Conflict { current: Some(_) })); + assert_eq!(global, serde_json::json!({"scope":"global","workspace":null})); assert_eq!( - fs::read_to_string(&path).expect("read preserved file"), - "# External\n\nChanged outside Scratch" + workspace, + serde_json::json!({"scope":"workspace","workspace":"/notes/shared"}) ); } #[test] - fn standalone_recreation_is_create_only() { - let directory = StandaloneTestDirectory::new("recreate"); - let path = directory.path.join("Deleted.md"); - let first = recreate_file_content_to_path(&path, "local draft".to_string()) - .expect("create missing note"); - let second = recreate_file_content_to_path(&path, "later draft".to_string()) - .expect("return typed conflict"); - - assert!(matches!(first, FileSaveResult::Saved { .. })); - assert!(matches!(second, FileSaveResult::Conflict { .. })); - assert_eq!(fs::read_to_string(path).unwrap(), "local draft"); + fn global_patch_preserves_unmentioned_global_fields() { + let current: GlobalSettings = serde_json::from_str( + r#"{"theme":{"mode":"dark"},"editorWidth":"wide"}"#, + ) + .expect("current global settings should deserialize"); + + let updated = apply_settings_patch( + ¤t, + serde_json::json!({"editorWidth":"narrow"}), + ) + .expect("global patch should apply"); + + assert_eq!(updated.theme.mode, "dark"); + assert_eq!(updated.editor_width.as_deref(), Some("narrow")); + } + + #[test] + fn workspace_patch_preserves_unmentioned_workspace_fields() { + let current: WorkspaceSettings = serde_json::from_str( + r#"{"gitEnabled":true,"pinnedNoteIds":["Roadmap"]}"#, + ) + .expect("current workspace settings should deserialize"); + + let updated = apply_settings_patch( + ¤t, + serde_json::json!({"pinnedNoteIds":["Roadmap","Inbox"]}), + ) + .expect("workspace patch should apply"); + + assert_eq!(updated.git_enabled, Some(true)); + assert_eq!( + updated.pinned_note_ids, + Some(vec!["Roadmap".to_string(), "Inbox".to_string()]) + ); + } +} + +#[cfg(test)] +mod window_session_tests { + use super::{ + apply_settings_patch, remove_window_session_on_destroy, restorable_window_sessions, + fallback_main_session, upsert_window_session_workspace, AppConfig, WindowGeometry, + WindowSession, + }; + + #[test] + fn legacy_app_config_deserializes_with_no_window_records() { + let config: AppConfig = serde_json::from_str( + r#"{"notes_folder":"/notes/main","workspaces":["/notes/main"]}"#, + ) + .expect("legacy config should deserialize"); + + assert!(config.records.is_empty()); + } + + #[test] + fn partial_window_patch_preserves_workspace_sidebar_and_geometry() { + let current = WindowSession { + workspace: "/notes/client".to_string(), + selected_note_id: Some("Inbox".to_string()), + sidebar_visible: true, + focus_mode: false, + geometry: Some(WindowGeometry { + x: 80, + y: 120, + width: 1080, + height: 720, + }), + }; + + let updated = apply_settings_patch( + ¤t, + serde_json::json!({"selectedNoteId":"Roadmap","focusMode":true}), + ) + .expect("window patch should apply"); + + assert_eq!(updated.workspace, "/notes/client"); + assert_eq!(updated.selected_note_id.as_deref(), Some("Roadmap")); + assert!(updated.sidebar_visible); + assert!(updated.focus_mode); + assert_eq!(updated.geometry, current.geometry); + } + + #[test] + fn window_geometry_uses_frontend_camel_case_contract() { + let session = WindowSession { + workspace: "/notes/client".to_string(), + selected_note_id: None, + sidebar_visible: false, + focus_mode: true, + geometry: Some(WindowGeometry { + x: -20, + y: 40, + width: 900, + height: 640, + }), + }; + + let json = serde_json::to_value(session).expect("session should serialize"); + assert_eq!(json["workspace"], "/notes/client"); + assert_eq!(json["selectedNoteId"], serde_json::Value::Null); + assert_eq!(json["sidebarVisible"], false); + assert_eq!(json["focusMode"], true); + assert_eq!( + json["geometry"], + serde_json::json!({"x":-20,"y":40,"width":900,"height":640}) + ); + } + + #[test] + fn standalone_preview_bypasses_all_full_window_restoration() { + let mut config = AppConfig::default(); + upsert_window_session_workspace(&mut config, "main", "/notes/main".to_string()); + upsert_window_session_workspace( + &mut config, + "workspace-client", + "/notes/client".to_string(), + ); + + let restored = restorable_window_sessions(&config, true, |_| true); + + assert!(restored.is_empty()); + assert_eq!(config.records.len(), 2, "preview bypass must not delete preferences"); + } + + #[test] + fn inaccessible_workspace_is_filtered_without_deleting_its_record() { + let mut config = AppConfig::default(); + upsert_window_session_workspace(&mut config, "main", "/notes/main".to_string()); + upsert_window_session_workspace( + &mut config, + "workspace-missing", + "/notes/missing".to_string(), + ); + + let restored = restorable_window_sessions(&config, false, |path| path != "/notes/missing"); + + assert_eq!(restored.len(), 1); + assert_eq!(restored[0].0, "main"); + assert!(config.records.contains_key("workspace-missing")); + } + + #[test] + fn inaccessible_main_record_falls_back_to_valid_default_without_erasing_preference() { + let mut config = AppConfig::default(); + upsert_window_session_workspace(&mut config, "main", "/notes/missing".to_string()); + + let fallback = fallback_main_session( + &config, + Some("/notes/default"), + false, + false, + |path| path == "/notes/default", + ) + .expect("valid default should keep a normal launch visible"); + + assert_eq!(fallback.workspace, "/notes/default"); + assert_eq!(config.records["main"].workspace, "/notes/missing"); + } + + #[test] + fn individual_close_removes_record_but_quit_preserves_open_records() { + let mut config = AppConfig::default(); + upsert_window_session_workspace(&mut config, "main", "/notes/main".to_string()); + upsert_window_session_workspace( + &mut config, + "workspace-client", + "/notes/client".to_string(), + ); + + assert!(remove_window_session_on_destroy( + &mut config, + "workspace-client", + false, + false, + )); + assert!(!config.records.contains_key("workspace-client")); + assert!(!remove_window_session_on_destroy( + &mut config, + "main", + true, + false, + )); + assert!(config.records.contains_key("main")); + + assert!(!remove_window_session_on_destroy( + &mut config, + "main", + false, + true, + )); + assert!(config.records.contains_key("main")); + } + + #[test] + fn workspace_rebind_updates_record_without_losing_ui_state() { + let mut config = AppConfig::default(); + config.records.insert( + "main".to_string(), + WindowSession { + workspace: "/notes/old".to_string(), + selected_note_id: Some("Roadmap".to_string()), + sidebar_visible: false, + focus_mode: true, + geometry: None, + }, + ); + + upsert_window_session_workspace(&mut config, "main", "/notes/new".to_string()); + + let session = config.records.get("main").unwrap(); + assert_eq!(session.workspace, "/notes/new"); + assert_eq!(session.selected_note_id.as_deref(), Some("Roadmap")); + assert!(!session.sidebar_visible); + assert!(session.focus_mode); + } + + #[test] + fn opening_workspace_window_creates_initial_record() { + let mut config = AppConfig::default(); + + upsert_window_session_workspace( + &mut config, + "workspace-client", + "/notes/client".to_string(), + ); + + assert_eq!( + config.records["workspace-client"], + WindowSession::for_workspace("/notes/client".to_string()) + ); + } +} + +#[cfg(test)] +mod standalone_window_tests { + use super::{ + should_defer_open_request, + should_hide_main_window_for_standalone_preview, + should_show_main_window, + }; + + #[test] + fn hides_main_window_for_a_standalone_preview_when_notes_are_configured() { + assert!(should_hide_main_window_for_standalone_preview(true, true)); + } + + #[test] + fn keeps_main_window_for_onboarding_without_a_notes_folder() { + assert!(!should_hide_main_window_for_standalone_preview(true, false)); + } + + #[test] + fn keeps_main_window_for_a_normal_launch() { + assert!(!should_hide_main_window_for_standalone_preview(false, true)); + } + + #[test] + fn defers_file_open_requests_until_app_state_is_ready() { + assert!(should_defer_open_request(false)); + assert!(!should_defer_open_request(true)); + } + + #[test] + fn normal_launch_shows_main_even_when_no_workspace_can_be_restored() { + assert!(should_show_main_window(false)); + assert!(!should_show_main_window(true)); + } +} + +#[cfg(test)] +mod window_chrome_tests { + use super::{runtime_window_config_from_template, WebviewUrl}; + + #[test] + fn release_config_uses_the_canonical_scratch_identity() { + let config: serde_json::Value = serde_json::from_str(include_str!("../tauri.conf.json")) + .expect("tauri config should deserialize"); + + assert_eq!(config["productName"].as_str(), Some("Scratch")); + assert_eq!(config["identifier"].as_str(), Some("com.scratch.app")); + } + + #[test] + fn every_macos_window_uses_the_requested_traffic_light_geometry() { + let config: serde_json::Value = serde_json::from_str(include_str!("../tauri.conf.json")) + .expect("tauri config should deserialize"); + let main_window = &config["app"]["windows"][0]; + + let configured_position = &main_window["trafficLightPosition"]; + assert_eq!(configured_position["x"].as_f64(), Some(16.0)); + assert_eq!(configured_position["y"].as_f64(), Some(24.0)); + let template: tauri::utils::config::WindowConfig = + serde_json::from_value(main_window.clone()).expect("main window config should parse"); + let runtime = runtime_window_config_from_template( + &template, + "preview-alignment-test", + WebviewUrl::App("index.html?mode=preview".into()), + ); + + assert_eq!( + runtime.traffic_light_position, + template.traffic_light_position + ); + assert_eq!(runtime.title_bar_style, template.title_bar_style); + assert_eq!(runtime.hidden_title, template.hidden_title); + assert!(runtime.visible, "runtime windows must still be shown"); + } +} + +#[cfg(test)] +mod native_new_window_menu_tests { + use super::{ + focused_window_target, native_new_window_menu_spec, native_open_folder_menu_spec, + native_preferences_menu_spec, new_window_event_target, + }; + + #[test] + fn file_menu_exposes_new_window_with_the_requested_shortcut() { + let spec = native_new_window_menu_spec(); + + assert_eq!(spec.parent_menu, "File"); + assert_eq!(spec.id, "new-window"); + assert_eq!(spec.label, "New Window"); + assert_eq!(spec.accelerator, "CmdOrCtrl+Shift+N"); + } + + #[test] + fn native_menus_expose_open_folder_and_preferences() { + let open_folder = native_open_folder_menu_spec(); + assert_eq!(open_folder.parent_menu, "File"); + assert_eq!(open_folder.id, "open-folder"); + assert_eq!(open_folder.label, "Open Folder…"); + assert_eq!(open_folder.accelerator, "CmdOrCtrl+O"); + + let preferences = native_preferences_menu_spec(); + assert_eq!(preferences.id, "preferences"); + assert_eq!(preferences.label, "Preferences…"); + assert_eq!(preferences.accelerator, "CmdOrCtrl+,"); + } + + #[test] + fn native_new_window_action_targets_one_full_editor_window() { + let windows = [ + ("main", false), + ("workspace-client", true), + ("preview-note", false), + ]; + assert_eq!( + new_window_event_target(windows.iter().copied()), + Some("workspace-client") + ); + + let preview_focused = [("main", false), ("preview-note", true)]; + assert_eq!( + new_window_event_target(preview_focused.iter().copied()), + Some("main") + ); + } + + #[test] + fn open_folder_targets_the_focused_window_including_standalone_preview() { + let windows = [("main", false), ("preview-note", true)]; + assert_eq!(focused_window_target(windows.iter().copied()), Some("preview-note")); + } +} + +#[cfg(test)] +mod workspace_registry_tests { + use std::collections::HashMap; + use std::path::Path; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{Arc, Barrier, Mutex, RwLock}; + + use super::{ + build_workspace_infos, ensure_shared_resource, get_or_initialize_shared, + forget_workspace, remember_workspace, workspace_is_remembered, + uses_default_workspace_fallback, + note_change_targets, save_note_in_workspace, semantic_file_change_event, + workspace_window_label, + AppConfig, AppState, WorkspaceBindings, WorkspaceSession, + WindowSession, WorkspaceRuntime, + }; + + #[test] + fn legacy_app_config_deserializes_without_workspaces() { + let config: AppConfig = serde_json::from_str(r#"{"notes_folder":"/notes/main"}"#) + .expect("legacy config should deserialize"); + + assert_eq!(config.notes_folder.as_deref(), Some("/notes/main")); + assert!(config.workspaces.is_empty()); + } + + #[test] + fn remembering_an_additional_workspace_preserves_the_default_folder() { + let mut config = AppConfig { + notes_folder: Some("/notes/main".to_string()), + workspaces: vec!["/notes/main".to_string()], + global_settings: None, + records: Default::default(), + }; + + remember_workspace(&mut config, "/notes/client".to_string()); + remember_workspace(&mut config, "/notes/client".to_string()); + + assert_eq!(config.notes_folder.as_deref(), Some("/notes/main")); + assert_eq!( + config.workspaces, + vec!["/notes/main".to_string(), "/notes/client".to_string()] + ); + } + + #[test] + fn forgetting_a_workspace_removes_restore_records_without_touching_other_folders() { + let mut config = AppConfig { + notes_folder: Some("/notes/main".to_string()), + workspaces: vec!["/notes/main".to_string(), "/notes/client".to_string()], + global_settings: None, + records: HashMap::from([ + ("main".to_string(), WindowSession::for_workspace("/notes/main".to_string())), + ("client".to_string(), WindowSession::for_workspace("/notes/client".to_string())), + ]), + }; + + assert!(forget_workspace(&mut config, "/notes/main")); + assert_eq!(config.notes_folder.as_deref(), Some("/notes/client")); + assert_eq!(config.workspaces, vec!["/notes/client".to_string()]); + assert!(!config.records.contains_key("main")); + assert!(config.records.contains_key("client")); + assert!(!workspace_is_remembered(&config, "/notes/main")); + assert!(workspace_is_remembered(&config, "/notes/client")); + } + + #[test] + fn preferences_and_preview_use_the_default_workspace_settings_fallback() { + assert!(uses_default_workspace_fallback("main")); + assert!(uses_default_workspace_fallback("preview-note")); + assert!(uses_default_workspace_fallback("preferences")); + assert!(!uses_default_workspace_fallback("workspace-client")); + } + + #[test] + fn explicit_workspace_window_labels_are_unique_and_path_scoped() { + let first = workspace_window_label(Path::new("/notes/client-a"), 1); + let same_path_second_window = workspace_window_label(Path::new("/notes/client-a"), 2); + let other = workspace_window_label(Path::new("/notes/client-b"), 1); + + assert_ne!(first, same_path_second_window); + assert_ne!(first, other); + assert!(first.starts_with("workspace-")); + assert!(first + .chars() + .all(|character| character.is_ascii_alphanumeric() || character == '-')); + } + + #[test] + fn explicit_windows_can_bind_the_same_workspace() { + let mut bindings = WorkspaceBindings::default(); + bindings.bind("main", "/notes/shared"); + bindings.bind("workspace-copy", "/notes/shared"); + + assert_eq!(bindings.path_for_window("main"), Some("/notes/shared")); + assert_eq!( + bindings.path_for_window("workspace-copy"), + Some("/notes/shared") + ); + assert_eq!( + bindings.windows_for_path("/notes/shared"), + vec!["main".to_string(), "workspace-copy".to_string()] + ); + } + + #[test] + fn workspace_change_targets_every_window_bound_to_the_note_path() { + let mut bindings = WorkspaceBindings::default(); + bindings.bind("main", "/notes/shared"); + bindings.bind("workspace-copy", "/notes/shared"); + bindings.bind("workspace-other", "/notes/other"); + + assert_eq!( + bindings.windows_for_note_path(Path::new("/notes/shared/a.md")), + vec!["main".to_string(), "workspace-copy".to_string()] + ); + } + + #[test] + fn semantic_save_event_targets_every_peer_but_not_its_origin_window() { + let mut bindings = WorkspaceBindings::default(); + bindings.bind("main", "/notes/shared"); + bindings.bind("workspace-copy", "/notes/shared"); + bindings.bind("workspace-other", "/notes/other"); + + assert_eq!( + note_change_targets( + &bindings, + Path::new("/notes/shared/a.md"), + Some("main"), + ), + vec!["workspace-copy".to_string()] + ); + } + + #[test] + fn semantic_rename_event_carries_both_ids_and_new_revision() { + let note = super::Note { + id: "Archive/Plan".to_string(), + title: "Plan".to_string(), + content: "# Plan".to_string(), + path: "/notes/Archive/Plan.md".to_string(), + modified: 1, + revision: "revision-2".to_string(), + }; + + let event = semantic_file_change_event( + "/notes".to_string(), + "renamed", + Some("Projects/Plan".to_string()), + Some(¬e), + Some("main".to_string()), + ); + + assert_eq!(event.changed_ids, vec!["Projects/Plan", "Archive/Plan"]); + assert_eq!(event.previous_id.as_deref(), Some("Projects/Plan")); + assert_eq!(event.current_id.as_deref(), Some("Archive/Plan")); + assert_eq!(event.revision.as_deref(), Some("revision-2")); + assert_eq!(event.path, "/notes/Archive/Plan.md"); + } + + #[test] + fn semantic_delete_event_has_no_current_id_or_revision() { + let event = semantic_file_change_event( + "/notes".to_string(), + "deleted", + Some("Plan".to_string()), + None, + Some("main".to_string()), + ); + + assert_eq!(event.changed_ids, vec!["Plan"]); + assert_eq!(event.previous_id.as_deref(), Some("Plan")); + assert_eq!(event.current_id, None); + assert_eq!(event.revision, None); + assert_eq!(event.path, "/notes/Plan.md"); + } + + #[test] + fn starting_one_shared_workspace_resource_twice_runs_factory_once() { + let slot = Mutex::new(None); + let factory_calls = AtomicUsize::new(0); + + assert!(ensure_shared_resource(&slot, || { + factory_calls.fetch_add(1, Ordering::Relaxed); + Ok::<_, String>("watcher") + }) + .unwrap()); + assert!(!ensure_shared_resource(&slot, || { + factory_calls.fetch_add(1, Ordering::Relaxed); + Ok::<_, String>("replacement") + }) + .unwrap()); + + assert_eq!(factory_calls.load(Ordering::Relaxed), 1); + assert_eq!(*slot.lock().unwrap(), Some("watcher")); + } + + #[test] + fn concurrent_workspace_acquisition_initializes_one_shared_runtime() { + let registry = Arc::new(RwLock::new(HashMap::>::new())); + let initializers = Arc::new(Mutex::new(HashMap::new())); + let barrier = Arc::new(Barrier::new(2)); + let factory_calls = Arc::new(AtomicUsize::new(0)); + + let handles = (0..2) + .map(|_| { + let registry = Arc::clone(®istry); + let initializers = Arc::clone(&initializers); + let barrier = Arc::clone(&barrier); + let factory_calls = Arc::clone(&factory_calls); + std::thread::spawn(move || { + barrier.wait(); + get_or_initialize_shared( + ®istry, + &initializers, + "workspace-a".to_string(), + || { + factory_calls.fetch_add(1, Ordering::SeqCst); + std::thread::sleep(std::time::Duration::from_millis(20)); + Ok::(42) + }, + ) + .expect("shared workspace") + }) + }) + .collect::>(); + + let first = handles[0].thread().id(); + let sessions = handles + .into_iter() + .map(|handle| handle.join().expect("workspace acquisition thread")) + .collect::>(); + + assert_ne!(first, std::thread::current().id()); + assert_eq!(factory_calls.load(Ordering::SeqCst), 1); + assert!(Arc::ptr_eq(&sessions[0], &sessions[1])); + assert_eq!(*sessions[0], 42); + } + + #[test] + fn window_bindings_keep_identical_note_ids_in_separate_workspaces() { + let mut bindings = WorkspaceBindings::default(); + bindings.bind("main", "/notes/main"); + bindings.bind("workspace-client", "/notes/client"); + + assert_eq!(bindings.path_for_window("main"), Some("/notes/main")); + assert_eq!( + bindings.path_for_window("workspace-client"), + Some("/notes/client") + ); + assert_eq!(bindings.window_for_path("/notes/main"), Some("main")); + assert_eq!( + bindings.window_for_path("/notes/client"), + Some("workspace-client") + ); + } + + #[test] + fn note_paths_resolve_to_the_most_specific_open_workspace() { + let mut bindings = WorkspaceBindings::default(); + bindings.bind("main", "/notes"); + bindings.bind("workspace-client", "/notes/client"); + + assert_eq!( + bindings.window_for_note_path(Path::new("/notes/client/shared.md")), + Some("workspace-client") + ); + assert_eq!( + bindings.window_for_note_path(Path::new("/notes/general.md")), + Some("main") + ); + assert_eq!( + bindings.window_for_note_path(Path::new("/outside/note.md")), + None + ); + } + + #[test] + fn multiple_windows_bound_to_one_workspace_are_not_a_conflict() { + let mut bindings = WorkspaceBindings::default(); + bindings.bind("main", "/notes/main"); + bindings.bind("workspace-client", "/notes/client"); + bindings.bind("workspace-client-copy", "/notes/client"); + + assert_eq!( + bindings.windows_for_path("/notes/client"), + vec![ + "workspace-client".to_string(), + "workspace-client-copy".to_string(), + ] + ); + } + + #[test] + fn removing_one_window_binding_preserves_other_workspaces() { + let mut bindings = WorkspaceBindings::default(); + bindings.bind("main", "/notes/main"); + bindings.bind("workspace-client", "/notes/client"); + + assert_eq!( + bindings.unbind("workspace-client"), + Some("/notes/client".to_string()) + ); + assert_eq!(bindings.path_for_window("workspace-client"), None); + assert_eq!(bindings.path_for_window("main"), Some("/notes/main")); + } + + #[test] + fn workspace_sessions_keep_settings_and_cache_isolated() { + let first = WorkspaceSession::empty("/notes/client-a".to_string()); + let second = WorkspaceSession::empty("/notes/client-b".to_string()); + + first + .settings + .write() + .expect("first settings write lock") + .git_enabled = Some(true); + first + .notes_cache + .write() + .expect("first cache write lock") + .insert( + "shared-id".to_string(), + super::NoteMetadata { + id: "shared-id".to_string(), + title: "Client A".to_string(), + preview: String::new(), + modified: 0, + }, + ); + + assert_eq!(first.notes_folder, "/notes/client-a"); + assert_eq!(second.notes_folder, "/notes/client-b"); + assert_eq!( + second + .settings + .read() + .expect("second settings read lock") + .git_enabled, + None + ); + assert!(second + .notes_cache + .read() + .expect("second cache read lock") + .is_empty()); + } + + #[test] + fn app_state_resolves_sessions_by_window_without_cross_talk() { + let state = AppState::default(); + state.register_workspace_session( + "workspace-a", + WorkspaceSession::empty("/notes/client-a".to_string()), + ); + state.register_workspace_session( + "workspace-b", + WorkspaceSession::empty("/notes/client-b".to_string()), + ); + + assert_eq!( + state + .workspace_session("workspace-a") + .expect("workspace A session") + .notes_folder, + "/notes/client-a" + ); + assert_eq!( + state + .workspace_session("workspace-b") + .expect("workspace B session") + .notes_folder, + "/notes/client-b" + ); + + state.remove_workspace_session("workspace-a"); + + assert!(state.workspace_session("workspace-a").is_none()); + assert!(state.workspace_session("workspace-b").is_some()); + } + + #[test] + fn windows_on_the_same_workspace_share_one_runtime_until_the_last_close() { + let state = AppState::default(); + state.register_workspace_session( + "workspace-a", + WorkspaceSession::empty("/notes/shared".to_string()), + ); + state.register_workspace_session( + "workspace-b", + WorkspaceSession::empty("/notes/shared".to_string()), + ); + + let first = state + .workspace_session("workspace-a") + .expect("first window session"); + let second = state + .workspace_session("workspace-b") + .expect("second window session"); + assert!(std::sync::Arc::ptr_eq(&first, &second)); + assert_eq!(state.workspace_session_count(), 1); + + state.remove_workspace_session("workspace-a"); + assert!(state.workspace_session("workspace-b").is_some()); + assert_eq!(state.workspace_session_count(), 1); + + state.remove_workspace_session("workspace-b"); + assert_eq!(state.workspace_session_count(), 0); + } + + #[test] + fn twenty_windows_share_one_runtime_and_release_it_after_the_last_close() { + let state = AppState::default(); + for index in 0..20 { + state.register_workspace_session( + format!("workspace-{index}"), + WorkspaceSession::empty("/notes/shared".to_string()), + ); + } + + assert_eq!(state.workspace_session_count(), 1); + for index in 0..19 { + state.remove_workspace_session(&format!("workspace-{index}")); + assert_eq!(state.workspace_session_count(), 1); + } + state.remove_workspace_session("workspace-19"); + assert_eq!(state.workspace_session_count(), 0); + } + + #[test] + fn main_and_secondary_window_share_the_same_canonical_runtime() { + let state = AppState::default(); + let main_session = state.register_workspace_session( + "main", + WorkspaceSession::empty("/notes/shared".to_string()), + ); + let copy_session = state + .bind_existing_workspace_session("workspace-copy", "/notes/shared") + .expect("bind second window to main workspace"); + + let main_runtime = state + .workspace_for_window("main") + .expect("main runtime"); + let copy_runtime = state + .workspace_for_window("workspace-copy") + .expect("copy runtime"); + let (WorkspaceRuntime::Session(main_from_router), WorkspaceRuntime::Session(copy_from_router)) = + (main_runtime, copy_runtime) + else { + panic!("all full editor windows must route through canonical sessions"); + }; + + assert!(std::sync::Arc::ptr_eq(&main_session, ©_session)); + assert!(std::sync::Arc::ptr_eq( + &main_from_router, + ©_from_router, + )); + assert_eq!(state.workspace_session_count(), 1); + } + + #[test] + fn rebinding_the_last_window_releases_its_previous_runtime() { + let state = AppState::default(); + state.register_workspace_session( + "workspace-window", + WorkspaceSession::empty("/notes/old".to_string()), + ); + + state.register_workspace_session( + "workspace-window", + WorkspaceSession::empty("/notes/new".to_string()), + ); + + assert!(state.workspace_session_for_path("/notes/old").is_none()); + assert!(state.workspace_session_for_path("/notes/new").is_some()); + assert_eq!(state.workspace_session_count(), 1); + } + + #[test] + fn workspace_resolution_uses_main_default_and_additional_session_roots() { + let state = AppState::default(); + state + .app_config + .write() + .expect("app config write lock") + .notes_folder = Some("/notes/main".to_string()); + state.register_workspace_session( + "workspace-client", + WorkspaceSession::empty("/notes/client".to_string()), + ); + + let main = state + .workspace_for_window("main") + .expect("main workspace should resolve"); + let client = state + .workspace_for_window("workspace-client") + .expect("client workspace should resolve"); + + assert_eq!(main.notes_folder().unwrap(), "/notes/main"); + assert_eq!(client.notes_folder().unwrap(), "/notes/client"); + assert!(state.workspace_for_window("missing-window").is_err()); + } + + #[test] + fn main_window_binding_can_change_without_mutating_the_default_workspace() { + let state = AppState::default(); + state + .app_config + .write() + .expect("app config write lock") + .notes_folder = Some("/notes/default".to_string()); + state + .workspace_bindings + .write() + .expect("workspace bindings write lock") + .bind("main", "/notes/client"); + + let main = state + .workspace_for_window("main") + .expect("main workspace should resolve from its current binding"); + + assert_eq!(main.notes_folder().unwrap(), "/notes/client"); + assert_eq!( + state + .app_config + .read() + .expect("app config read lock") + .notes_folder + .as_deref(), + Some("/notes/default") + ); + } + + #[test] + fn workspace_resolution_isolates_same_relative_note_path() { + let state = AppState::default(); + state + .app_config + .write() + .expect("app config write lock") + .notes_folder = Some("/notes/main".to_string()); + state.register_workspace_session( + "workspace-client", + WorkspaceSession::empty("/notes/client".to_string()), + ); + + let main_root = state + .workspace_for_window("main") + .unwrap() + .note_path("shared") + .unwrap(); + let client_root = state + .workspace_for_window("workspace-client") + .unwrap() + .note_path("shared") + .unwrap(); + + assert_eq!(main_root, Path::new("/notes/main/shared.md")); + assert_eq!(client_root, Path::new("/notes/client/shared.md")); + assert_ne!(main_root, client_root); + } + + #[test] + fn workspace_infos_identify_current_default_and_open_windows() { + let config = AppConfig { + notes_folder: Some("/notes/main".to_string()), + workspaces: vec![ + "/notes/main".to_string(), + "/notes/client".to_string(), + "/notes/archive".to_string(), + ], + global_settings: None, + records: Default::default(), + }; + let mut bindings = WorkspaceBindings::default(); + bindings.bind("main", "/notes/main"); + bindings.bind("workspace-client", "/notes/client"); + + let infos = build_workspace_infos(&config, &bindings, "workspace-client"); + + assert_eq!(infos.len(), 3); + assert_eq!(infos[0].name, "main"); + assert!(infos[0].is_default); + assert!(infos[0].is_open); + assert!(!infos[0].is_current); + assert_eq!(infos[1].name, "client"); + assert!(!infos[1].is_default); + assert!(infos[1].is_open); + assert!(infos[1].is_current); + assert_eq!(infos[2].name, "archive"); + assert!(!infos[2].is_open); + assert!(!infos[2].is_current); + } + + #[test] + fn desktop_capability_uses_interceptable_close_without_force_destroy() { + let capability: serde_json::Value = + serde_json::from_str(include_str!("../capabilities/default.json")) + .expect("desktop capability should deserialize"); + let windows = capability["windows"] + .as_array() + .expect("desktop capability windows"); + + assert!(windows.iter().any(|window| window == "workspace-*")); + + let permissions = capability["permissions"] + .as_array() + .expect("desktop capability permissions"); + assert!(permissions + .iter() + .any(|permission| permission == "core:window:allow-close")); + assert!(!permissions + .iter() + .any(|permission| permission == "core:window:allow-destroy")); + } + + #[test] + fn preferences_window_has_titlebar_dragging_capability() { + let capability: serde_json::Value = + serde_json::from_str(include_str!("../capabilities/default.json")) + .expect("desktop capability should deserialize"); + let windows = capability["windows"] + .as_array() + .expect("desktop capability windows"); + let permissions = capability["permissions"] + .as_array() + .expect("desktop capability permissions"); + + assert!( + windows.iter().any(|window| window == "preferences"), + "the dedicated Preferences window must receive the drag permission" + ); + assert!(permissions + .iter() + .any(|permission| permission == "core:window:allow-start-dragging")); + } + + #[test] + fn same_note_id_saves_to_each_workspace_without_cross_talk() { + let root = std::env::temp_dir().join(format!( + "scratch-workspace-save-test-{}", + std::process::id() + )); + let alpha_root = root.join("alpha"); + let beta_root = root.join("beta"); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&alpha_root).expect("create alpha workspace"); + std::fs::create_dir_all(&beta_root).expect("create beta workspace"); + let initial = "# shared\n\nInitial.\n"; + std::fs::write(alpha_root.join("shared.md"), initial).expect("seed alpha note"); + std::fs::write(beta_root.join("shared.md"), initial).expect("seed beta note"); + let initial_revision = super::persistence::content_revision(initial).to_string(); + + let state = AppState::default(); + state.register_workspace_session( + "workspace-alpha", + WorkspaceSession::empty(alpha_root.to_string_lossy().into_owned()), + ); + state.register_workspace_session( + "workspace-beta", + WorkspaceSession::empty(beta_root.to_string_lossy().into_owned()), + ); + + tauri::async_runtime::block_on(async { + let alpha = state.workspace_for_window("workspace-alpha").unwrap(); + save_note_in_workspace( + Some("shared".to_string()), + "# shared\n\nAlpha window edit.\n".to_string(), + Some(initial_revision.clone()), + &alpha, + ) + .await + .expect("save alpha note"); + + let beta = state.workspace_for_window("workspace-beta").unwrap(); + save_note_in_workspace( + Some("shared".to_string()), + "# shared\n\nBeta window edit.\n".to_string(), + Some(initial_revision), + &beta, + ) + .await + .expect("save beta note"); + }); + + assert_eq!( + std::fs::read_to_string(alpha_root.join("shared.md")).unwrap(), + "# shared\n\nAlpha window edit.\n" + ); + assert_eq!( + std::fs::read_to_string(beta_root.join("shared.md")).unwrap(), + "# shared\n\nBeta window edit.\n" + ); + + std::fs::remove_dir_all(root).expect("remove workspace save test"); } } diff --git a/src-tauri/src/note_persistence_tests.rs b/src-tauri/src/note_persistence_tests.rs new file mode 100644 index 00000000..297ed5af --- /dev/null +++ b/src-tauri/src/note_persistence_tests.rs @@ -0,0 +1,581 @@ +use crate::persistence::{ + content_revision, read_snapshot, save_if_revision, FileSnapshot, SaveResult, +}; +use crate::{ + read_file_content_from_path, read_note_from_path, recreate_file_content_to_path, + save_file_content_to_path, save_note_to_path, validate_preview_create_path, + write_recovery_snapshot, FileSaveResult, NoteConflictSnapshot, NoteSaveResult, +}; +use std::fs; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Barrier}; + +static NEXT_TEST_DIRECTORY: AtomicU64 = AtomicU64::new(0); + +struct TestDirectory { + path: PathBuf, +} + +impl TestDirectory { + fn new(test_name: &str) -> Self { + let sequence = NEXT_TEST_DIRECTORY.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "scratch-note-persistence-{test_name}-{}-{sequence}", + std::process::id() + )); + fs::create_dir(&path).expect("create isolated note persistence test directory"); + Self { path } + } + + fn note_path(&self, file_name: &str) -> PathBuf { + self.path.join(file_name) + } +} + +impl Drop for TestDirectory { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } +} + +#[test] +fn reading_the_same_note_returns_the_same_content_revision() { + let directory = TestDirectory::new("deterministic-read"); + let first_path = directory.note_path("first.md"); + let second_path = directory.note_path("second.md"); + let content = "# Deterministic\n\nSame Markdown bytes.\n"; + fs::write(&first_path, content).expect("write first note"); + fs::write(&second_path, content).expect("write second note"); + + let first_read = read_snapshot(&first_path) + .expect("read first note") + .expect("first note exists"); + let repeated_read = read_snapshot(&first_path) + .expect("read first note again") + .expect("first note still exists"); + let same_content_elsewhere = read_snapshot(&second_path) + .expect("read second note") + .expect("second note exists"); + + assert_eq!(first_read.content, content); + assert_eq!(first_read.revision, repeated_read.revision); + assert_eq!(first_read.revision, same_content_elsewhere.revision); + assert_eq!(first_read.revision, content_revision(content)); +} + +#[test] +fn save_with_the_current_revision_replaces_the_note_and_returns_the_new_revision() { + let directory = TestDirectory::new("expected-revision"); + let path = directory.note_path("note.md"); + fs::write(&path, "version one").expect("write initial note"); + let initial = read_snapshot(&path) + .expect("read initial note") + .expect("initial note exists"); + + let result = save_if_revision(&path, "version two", Some(&initial.revision)) + .expect("save with current revision"); + let SaveResult::Saved { revision } = result else { + panic!("current revision must allow the save"); + }; + let persisted = read_snapshot(&path) + .expect("read saved note") + .expect("saved note exists"); + + assert_eq!( + persisted, + FileSnapshot { + revision: revision.clone(), + content: "version two".to_string(), + } + ); + assert_ne!(revision, initial.revision); +} + +#[test] +fn save_with_a_stale_revision_returns_typed_conflict_without_overwriting() { + let directory = TestDirectory::new("stale-revision"); + let path = directory.note_path("note.md"); + fs::write(&path, "version one").expect("write initial note"); + let stale = read_snapshot(&path) + .expect("read initial note") + .expect("initial note exists"); + fs::write(&path, "newer version from another window").expect("write external update"); + let newer = read_snapshot(&path) + .expect("read external update") + .expect("externally updated note exists"); + + let result = save_if_revision(&path, "stale local draft", Some(&stale.revision)) + .expect("stale save returns a typed result"); + + assert_eq!( + result, + SaveResult::Conflict { + current: Some(newer.clone()), + } + ); + assert_eq!( + read_snapshot(&path).expect("read note after conflict"), + Some(newer) + ); +} + +#[test] +fn save_after_external_deletion_returns_missing_conflict_and_does_not_recreate_file() { + let directory = TestDirectory::new("deleted-note"); + let path = directory.note_path("note.md"); + fs::write(&path, "deleted elsewhere").expect("write initial note"); + let deleted = read_snapshot(&path) + .expect("read initial note") + .expect("initial note exists"); + fs::remove_file(&path).expect("delete note externally"); + + let result = save_if_revision(&path, "stale local draft", Some(&deleted.revision)) + .expect("save after deletion returns a typed result"); + + assert_eq!(result, SaveResult::Conflict { current: None }); + assert_eq!( + read_snapshot(&path).expect("check deleted note after conflict"), + None + ); + assert!(!path.exists()); +} + +#[test] +fn simultaneous_saves_from_one_revision_yield_exactly_one_save_and_one_conflict() { + let directory = TestDirectory::new("simultaneous-saves"); + let path = directory.note_path("note.md"); + fs::write(&path, "shared base").expect("write shared base"); + let shared_revision = read_snapshot(&path) + .expect("read shared base") + .expect("shared base exists") + .revision; + let start = Arc::new(Barrier::new(3)); + + let first_path = path.clone(); + let first_revision = shared_revision.clone(); + let first_start = Arc::clone(&start); + let first = std::thread::spawn(move || { + first_start.wait(); + save_if_revision(&first_path, "draft from window one", Some(&first_revision)) + .expect("first simultaneous save returns a typed result") + }); + + let second_path = path.clone(); + let second_revision = shared_revision.clone(); + let second_start = Arc::clone(&start); + let second = std::thread::spawn(move || { + second_start.wait(); + save_if_revision( + &second_path, + "draft from window two", + Some(&second_revision), + ) + .expect("second simultaneous save returns a typed result") + }); + + start.wait(); + let results = [ + first.join().expect("first save thread completes"), + second.join().expect("second save thread completes"), + ]; + + assert_eq!( + results + .iter() + .filter(|result| matches!(result, SaveResult::Saved { .. })) + .count(), + 1 + ); + assert_eq!( + results + .iter() + .filter(|result| matches!(result, SaveResult::Conflict { .. })) + .count(), + 1 + ); + + let persisted = read_snapshot(&path) + .expect("read winner") + .expect("winning save keeps note present"); + let saved_revision = results + .iter() + .find_map(|result| match result { + SaveResult::Saved { revision } => Some(revision), + SaveResult::Conflict { .. } => None, + }) + .expect("one saved revision"); + let conflict_snapshot = results + .iter() + .find_map(|result| match result { + SaveResult::Conflict { + current: Some(snapshot), + } => Some(snapshot), + _ => None, + }) + .expect("losing save observes the winner"); + + assert_eq!(&persisted.revision, saved_revision); + assert_eq!(&persisted, conflict_snapshot); + assert!(matches!( + persisted.content.as_str(), + "draft from window one" | "draft from window two" + )); +} + +#[test] +fn command_read_exposes_the_note_content_and_its_deterministic_revision() { + let directory = TestDirectory::new("command-read-revision"); + let path = directory.note_path("command-note.md"); + let content = "# Command note\n\nRead through the command contract.\n"; + fs::write(&path, content).expect("write command note"); + + let note = read_note_from_path("command-note".to_string(), &path) + .expect("command reads existing note"); + + assert_eq!(note.id, "command-note"); + assert_eq!(note.content, content); + assert_eq!(note.revision, content_revision(content).to_string()); +} + +#[test] +fn command_save_with_current_revision_returns_saved_note_with_new_revision() { + let directory = TestDirectory::new("command-current-save"); + let path = directory.note_path("command-note.md"); + fs::write(&path, "version one").expect("write initial command note"); + let initial = + read_note_from_path("command-note".to_string(), &path).expect("command reads initial note"); + let updated_content = "version two from the command"; + + let result = save_note_to_path( + "command-note".to_string(), + &path, + updated_content.to_string(), + initial.revision.clone(), + ) + .expect("command save with current revision returns a typed result"); + let NoteSaveResult::Saved { note } = result else { + panic!("current revision must return NoteSaveResult::Saved"); + }; + + assert_eq!(note.id, "command-note"); + assert_eq!(note.content, updated_content); + assert_eq!(note.revision, content_revision(updated_content).to_string()); + assert_ne!(note.revision, initial.revision); + assert_eq!( + fs::read_to_string(&path).expect("read command-saved file"), + updated_content + ); +} + +#[test] +fn command_save_with_stale_revision_returns_current_conflict_without_overwriting() { + let directory = TestDirectory::new("command-stale-save"); + let path = directory.note_path("command-note.md"); + fs::write(&path, "version one").expect("write initial command note"); + let stale = + read_note_from_path("command-note".to_string(), &path).expect("command reads initial note"); + let external_content = "newer version from another window"; + fs::write(&path, external_content).expect("write external update"); + + let result = save_note_to_path( + "command-note".to_string(), + &path, + "stale local draft".to_string(), + stale.revision, + ) + .expect("stale command save returns a typed result"); + + assert_eq!( + result, + NoteSaveResult::Conflict { + current: Some(NoteConflictSnapshot { + content: external_content.to_string(), + revision: content_revision(external_content).to_string(), + }), + } + ); + assert_eq!( + fs::read_to_string(&path).expect("read file after command conflict"), + external_content + ); +} + +#[test] +fn command_save_after_deletion_returns_missing_conflict_without_recreating_file() { + let directory = TestDirectory::new("command-deleted-save"); + let path = directory.note_path("command-note.md"); + fs::write(&path, "deleted elsewhere").expect("write initial command note"); + let deleted = read_note_from_path("command-note".to_string(), &path) + .expect("command reads note before deletion"); + fs::remove_file(&path).expect("delete command note externally"); + + let result = save_note_to_path( + "command-note".to_string(), + &path, + "stale local draft".to_string(), + deleted.revision, + ) + .expect("command save after deletion returns a typed result"); + + assert_eq!(result, NoteSaveResult::Conflict { current: None }); + assert!(!path.exists()); +} + +#[test] +fn note_read_contract_exposes_the_revision_used_by_the_next_save() { + let directory = TestDirectory::new("note-read-contract"); + let path = directory.note_path("Plan.md"); + fs::write(&path, "# Plan\n\nOriginal").expect("write note"); + + let note = read_note_from_path("Plan".to_string(), &path).expect("read note contract"); + + assert_eq!(note.content, "# Plan\n\nOriginal"); + assert_eq!(note.revision, content_revision(¬e.content).to_string()); +} + +#[test] +fn note_save_contract_returns_a_new_revision_after_atomic_save() { + let directory = TestDirectory::new("note-save-contract"); + let path = directory.note_path("Plan.md"); + fs::write(&path, "# Plan\n\nOriginal").expect("write note"); + let expected_revision = content_revision("# Plan\n\nOriginal").to_string(); + + let result = save_note_to_path( + "Plan".to_string(), + &path, + "# Plan\n\nWindow A".to_string(), + expected_revision, + ) + .expect("save note contract"); + + let NoteSaveResult::Saved { note } = result else { + panic!("current revision must save"); + }; + assert_eq!(note.content, "# Plan\n\nWindow A"); + assert_eq!(note.revision, content_revision(¬e.content).to_string()); + assert_eq!(fs::read_to_string(path).unwrap(), note.content); +} + +#[test] +fn note_save_contract_returns_remote_snapshot_instead_of_overwriting() { + let directory = TestDirectory::new("note-save-conflict-contract"); + let path = directory.note_path("Plan.md"); + fs::write(&path, "# Plan\n\nOriginal").expect("write note"); + let stale_revision = content_revision("# Plan\n\nOriginal").to_string(); + fs::write(&path, "# Plan\n\nWindow B").expect("write remote update"); + + let result = save_note_to_path( + "Plan".to_string(), + &path, + "# Plan\n\nStale window A draft".to_string(), + stale_revision, + ) + .expect("conflict is typed"); + + assert_eq!( + result, + NoteSaveResult::Conflict { + current: Some(NoteConflictSnapshot { + content: "# Plan\n\nWindow B".to_string(), + revision: content_revision("# Plan\n\nWindow B").to_string(), + }), + } + ); + assert_eq!(fs::read_to_string(path).unwrap(), "# Plan\n\nWindow B"); +} + +#[test] +fn standalone_read_and_save_use_the_same_revision_conflict_contract() { + let directory = TestDirectory::new("standalone-save-contract"); + let path = directory.note_path("External.md"); + fs::write(&path, "# External\n\nOriginal").expect("write standalone note"); + let loaded = read_file_content_from_path(&path).expect("read standalone note"); + fs::write(&path, "# External\n\nChanged outside Scratch") + .expect("write external update"); + + let result = save_file_content_to_path( + &path, + "# External\n\nStale Scratch draft".to_string(), + loaded.revision, + ) + .expect("standalone conflict is typed"); + + assert_eq!( + result, + FileSaveResult::Conflict { + current: Some(NoteConflictSnapshot { + content: "# External\n\nChanged outside Scratch".to_string(), + revision: content_revision("# External\n\nChanged outside Scratch").to_string(), + }), + } + ); + assert_eq!( + fs::read_to_string(path).unwrap(), + "# External\n\nChanged outside Scratch" + ); +} + +#[test] +fn standalone_recreate_create_only_returns_saved_file_and_revision() { + let directory = TestDirectory::new("standalone-recreate"); + let path = directory.note_path("Deleted.md"); + let draft = "# Deleted\n\nRecovered local draft"; + + let result = recreate_file_content_to_path(&path, draft.to_string()) + .expect("create-only recreation returns a typed result"); + + assert_eq!( + result, + FileSaveResult::Saved { + file: crate::FileContent { + path: path.to_string_lossy().into_owned(), + content: draft.to_string(), + title: "Deleted".to_string(), + modified: fs::metadata(&path) + .unwrap() + .modified() + .unwrap() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64, + revision: content_revision(draft).to_string(), + } + } + ); + assert_eq!(fs::read_to_string(path).unwrap(), draft); +} + +#[test] +fn simultaneous_standalone_recreate_has_one_winner_and_never_overwrites_it() { + let directory = TestDirectory::new("standalone-recreate-race"); + let path = directory.note_path("Deleted.md"); + let start = Arc::new(Barrier::new(3)); + + let first_path = path.clone(); + let first_start = Arc::clone(&start); + let first = std::thread::spawn(move || { + first_start.wait(); + recreate_file_content_to_path(&first_path, "draft from window one".to_string()) + .expect("first recreation returns a typed result") + }); + + let second_path = path.clone(); + let second_start = Arc::clone(&start); + let second = std::thread::spawn(move || { + second_start.wait(); + recreate_file_content_to_path(&second_path, "draft from window two".to_string()) + .expect("second recreation returns a typed result") + }); + + start.wait(); + let results = [first.join().unwrap(), second.join().unwrap()]; + assert_eq!( + results + .iter() + .filter(|result| matches!(result, FileSaveResult::Saved { .. })) + .count(), + 1 + ); + assert_eq!( + results + .iter() + .filter(|result| matches!(result, FileSaveResult::Conflict { .. })) + .count(), + 1 + ); + + let persisted = fs::read_to_string(&path).unwrap(); + let saved = results + .iter() + .find_map(|result| match result { + FileSaveResult::Saved { file } => Some(file), + FileSaveResult::Conflict { .. } => None, + }) + .unwrap(); + let conflict = results + .iter() + .find_map(|result| match result { + FileSaveResult::Conflict { + current: Some(current), + } => Some(current), + _ => None, + }) + .unwrap(); + + assert_eq!(persisted, saved.content); + assert_eq!(conflict.content, saved.content); + assert_eq!(conflict.revision, saved.revision); +} + +#[test] +fn deleted_preview_path_uses_canonical_parent_and_rejects_traversal() { + let directory = TestDirectory::new("standalone-recreate-path"); + let missing = directory.note_path("Deleted.markdown"); + + let validated = validate_preview_create_path(&missing.to_string_lossy()) + .expect("missing Markdown path with existing parent is valid"); + + assert_eq!( + validated, + directory.path.canonicalize().unwrap().join("Deleted.markdown") + ); + assert!(!validated.exists()); + + let nested = directory.path.join("nested"); + fs::create_dir(&nested).unwrap(); + let traversal = nested.join("..").join("Escaped.md"); + assert!(validate_preview_create_path(&traversal.to_string_lossy()).is_err()); + assert!(validate_preview_create_path( + &directory.note_path("NotMarkdown.txt").to_string_lossy() + ) + .is_err()); +} + +#[test] +fn recovery_snapshot_preserves_the_exact_dirty_markdown_in_a_hidden_store() { + let directory = TestDirectory::new("recovery-snapshot"); + let dirty_markdown = "# Plan\n\n- [x] local task\n\n[link](other.md)\n"; + + let recovery_path = write_recovery_snapshot( + &directory.path, + "folder/Plan", + "/notes/folder/Plan.md", + dirty_markdown, + "save-conflict", + ) + .expect("persist recovery snapshot"); + + assert!(recovery_path.starts_with(directory.path.join("recovery"))); + assert_eq!(fs::read_to_string(&recovery_path).unwrap(), dirty_markdown); + assert_eq!( + recovery_path.extension().and_then(|extension| extension.to_str()), + Some("md") + ); +} + +#[test] +fn repeated_recovery_snapshots_never_replace_an_earlier_draft() { + let directory = TestDirectory::new("unique-recovery-snapshot"); + + let first = write_recovery_snapshot( + &directory.path, + "Plan", + "/notes/Plan.md", + "first draft", + "window-close", + ) + .unwrap(); + let second = write_recovery_snapshot( + &directory.path, + "Plan", + "/notes/Plan.md", + "second draft", + "window-close", + ) + .unwrap(); + + assert_ne!(first, second); + assert_eq!(fs::read_to_string(first).unwrap(), "first draft"); + assert_eq!(fs::read_to_string(second).unwrap(), "second draft"); +} diff --git a/src-tauri/src/watcher_debounce.rs b/src-tauri/src/watcher_debounce.rs new file mode 100644 index 00000000..88a1886d --- /dev/null +++ b/src-tauri/src/watcher_debounce.rs @@ -0,0 +1,107 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +pub(crate) const WATCHER_DEBOUNCE_WINDOW: Duration = Duration::from_millis(500); + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct DebounceToken(u64); + +#[derive(Debug, Default)] +pub(crate) struct WatcherDebounce { + pending_by_path: HashMap, + next_generation: u64, +} + +impl WatcherDebounce { + pub(crate) fn schedule(&mut self, path: PathBuf) -> DebounceToken { + self.next_generation = self + .next_generation + .checked_add(1) + .expect("watcher debounce generation exhausted"); + let token = DebounceToken(self.next_generation); + self.pending_by_path.insert(path, token); + token + } + + pub(crate) fn take_if_latest(&mut self, path: &Path, token: DebounceToken) -> bool { + if self.pending_by_path.get(path) != Some(&token) { + return false; + } + + self.pending_by_path.remove(path); + true + } +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use super::{WatcherDebounce, WATCHER_DEBOUNCE_WINDOW}; + + #[test] + fn debounce_window_is_500_milliseconds() { + assert_eq!(WATCHER_DEBOUNCE_WINDOW.as_millis(), 500); + } + + #[test] + fn only_latest_event_in_a_burst_becomes_ready() { + let mut debounce = WatcherDebounce::default(); + let path = Path::new("notes/roadmap.md"); + + let first = debounce.schedule(path.to_path_buf()); + let second = debounce.schedule(path.to_path_buf()); + let third = debounce.schedule(path.to_path_buf()); + + assert!(!debounce.take_if_latest(path, first)); + assert!(!debounce.take_if_latest(path, second)); + assert!(debounce.take_if_latest(path, third)); + assert!(!debounce.take_if_latest(path, third)); + } + + #[test] + fn distinct_paths_are_debounced_independently() { + let mut debounce = WatcherDebounce::default(); + let alpha = Path::new("notes/alpha.md"); + let beta = Path::new("notes/beta.md"); + + let alpha_first = debounce.schedule(alpha.to_path_buf()); + let beta_only = debounce.schedule(beta.to_path_buf()); + let alpha_latest = debounce.schedule(alpha.to_path_buf()); + + assert!(debounce.take_if_latest(beta, beta_only)); + assert!(!debounce.take_if_latest(alpha, alpha_first)); + assert!(debounce.take_if_latest(alpha, alpha_latest)); + } + + #[test] + fn stale_event_cannot_remove_the_latest_event() { + let mut debounce = WatcherDebounce::default(); + let path = Path::new("notes/inbox.md"); + + let stale = debounce.schedule(path.to_path_buf()); + let latest = debounce.schedule(path.to_path_buf()); + + assert!(!debounce.take_if_latest(path, stale)); + assert!(debounce.take_if_latest(path, latest)); + } + + #[test] + fn pending_state_stays_bounded_and_is_cleaned_after_latest_events() { + let mut debounce = WatcherDebounce::default(); + let alpha = Path::new("notes/alpha.md"); + let beta = Path::new("notes/beta.md"); + + let mut alpha_latest = debounce.schedule(alpha.to_path_buf()); + for _ in 0..100 { + alpha_latest = debounce.schedule(alpha.to_path_buf()); + } + let beta_latest = debounce.schedule(beta.to_path_buf()); + + assert_eq!(debounce.pending_by_path.len(), 2); + assert!(debounce.take_if_latest(alpha, alpha_latest)); + assert!(debounce.take_if_latest(beta, beta_latest)); + assert!(debounce.pending_by_path.is_empty()); + } +} diff --git a/src/App.tsx b/src/App.tsx index 7aebaed2..8c610931 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,10 +8,7 @@ import { TooltipProvider, Toaster } from "./components/ui"; import { Sidebar } from "./components/layout/Sidebar"; import { SidebarResizeHandle } from "./components/layout/SidebarResizeHandle"; import { SIDEBAR_DEFAULT_PX } from "./lib/sidebar"; -import { - Editor, - type EditorPersistenceController, -} from "./components/editor/Editor"; +import { Editor } from "./components/editor/Editor"; import type { Editor as TiptapEditor } from "@tiptap/react"; import { FolderPicker } from "./components/layout/FolderPicker"; import { CommandPalette } from "./components/command-palette/CommandPalette"; @@ -32,13 +29,16 @@ import { type Update, } from "@tauri-apps/plugin-updater"; import { getCurrentWindow } from "@tauri-apps/api/window"; +import { open } from "@tauri-apps/plugin-dialog"; 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 { shouldSyncMainFolderLocation } from "./lib/workspace"; +import { runSafeWindowClose } from "./lib/windowClose"; +import { useWindowSessionPersistence } from "./lib/useWindowSessionPersistence"; import { closeWindowAfterSave } from "./services/windowLifecycle"; import { useWindowShortcuts } from "./lib/useWindowShortcuts"; -import { runSafeWindowClose } from "./lib/windowClose"; // Detect preview mode from URL search params function getWindowMode(): { @@ -72,6 +72,10 @@ function AppContent() { reloadCurrentNote, currentNote, syncNotesFolder, + flushCurrentDraft, + persistCurrentDraftRecovery, + restoredWindowSession, + isWindowSessionRestored, } = useNotes(); const { reloadSettings } = useTheme(); const currentNoteRef = useRef(currentNote); @@ -85,51 +89,50 @@ function AppContent() { const [focusMode, setFocusMode] = useState(false); const [aiProvider, setAiProvider] = useState("claude"); const editorRef = useRef(null); - const persistenceControllerRef = useRef( - null, - ); const closeInProgressRef = useRef(false); - - const handlePersistenceControllerReady = useCallback( - (controller: EditorPersistenceController | null) => { - persistenceControllerRef.current = controller; - }, - [], - ); + const flushWindowSession = useWindowSessionPersistence({ + notesFolder, + selectedNoteId, + sidebarVisible, + setSidebarVisible, + focusMode, + setFocusMode, + restoredSession: restoredWindowSession, + isRestored: isWindowSessionRestored, + }); useEffect(() => { let disposed = false; let unlisten: (() => void) | undefined; const appWindow = getCurrentWindow(); - void appWindow.onCloseRequested((event) => { + 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", - }); + flushDraft: flushCurrentDraft, + persistRecovery: () => + persistCurrentDraftRecovery("window-close"), + closeWindow: async () => { + await flushWindowSession(); + await closeWindowAfterSave(); }, - closeWindow: closeWindowAfterSave, - }).catch((error) => { - closeInProgressRef.current = false; - if (!disposed) { - toast.error( - `Window kept open because the draft could not be saved: ${error}`, - ); - } - }); + }) + .then((result) => { + if (result.recoveredTo) { + toast.warning(`Draft recovered to ${result.recoveredTo}`); + } + }) + .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; @@ -139,7 +142,7 @@ function AppContent() { disposed = true; unlisten?.(); }; - }, []); + }, [flushCurrentDraft, flushWindowSession, persistCurrentDraftRecovery]); // Listen for set-notes-folder event from CLI (scratch .) // Placed here in AppContent where both NotesContext and ThemeContext are available @@ -147,6 +150,7 @@ function AppContent() { let cancelled = false; let unlisten: (() => void) | undefined; listen("set-notes-folder", async (event) => { + if (!shouldSyncMainFolderLocation(getCurrentWindow().label)) return; await syncNotesFolder(event.payload); await reloadSettings(); }).then((fn) => { @@ -178,16 +182,14 @@ function AppContent() { const toggleSettings = useCallback(async () => { if (view === "notes") { try { - await persistenceControllerRef.current?.flush(); + await flushCurrentDraft(); } catch (error) { toast.error(`Settings not opened: ${error}`); return; } } - setView((previous) => - previous === "settings" ? "notes" : "settings", - ); - }, [view]); + setView((previous) => (previous === "settings" ? "notes" : "settings")); + }, [flushCurrentDraft, view]); const closeSettings = useCallback(() => { setView("notes"); @@ -521,16 +523,13 @@ function AppContent() { {sidebarVisible && !focusMode && }
- { - editorRef.current = editor; - }} - onPersistenceControllerReady={ - handlePersistenceControllerReady - } + onEditorReady={(editor) => { + editorRef.current = editor; + }} /> )} @@ -706,6 +705,35 @@ function App() { return () => window.removeEventListener("keydown", handleKeyDown); }, []); + // Native File > Open Folder… works from any focused window, including a + // standalone Markdown editor or Preferences. + useEffect(() => { + let disposed = false; + let unlisten: (() => void) | undefined; + void listen("open-folder-in-new-window", async () => { + try { + const selected = await open({ + directory: true, + multiple: false, + title: "Open Folder", + }); + if (!disposed && typeof selected === "string") { + await notesService.openWorkspaceWindow(selected); + } + } catch (error) { + console.error("Failed to open folder:", error); + if (!disposed) toast.error("Failed to open folder"); + } + }).then((cleanup) => { + if (disposed) cleanup(); + else unlisten = cleanup; + }); + return () => { + disposed = true; + unlisten?.(); + }; + }, []); + // Add platform class for OS-specific styling (e.g., keyboard shortcuts) useEffect(() => { const os = isMac ? "mac" : isWindows ? "windows" : "linux"; diff --git a/src/components/editor/Editor.tsx b/src/components/editor/Editor.tsx index e6efcae2..3353940b 100644 --- a/src/components/editor/Editor.tsx +++ b/src/components/editor/Editor.tsx @@ -73,6 +73,7 @@ import { ScratchBlockMath, normalizeBlockMath } from "./MathExtensions"; import { cn } from "../../lib/utils"; import { plainTextFromMarkdown } from "../../lib/plainText"; import { getTitleBarNoteInfoText } from "../../lib/titleBarNoteInfo"; +import { SETTINGS_CHANGED_DOM_EVENT } from "../../lib/settingsScope"; import type { ConflictResolutionStrategy } from "../../lib/conflictResolution"; import { Button, IconButton, ToolbarButton, Tooltip } from "../ui"; import * as notesService from "../../services/notes"; @@ -560,19 +561,24 @@ export function Editor({ await previewMode.save(content); } : notesCtx!.saveNote; + const registerWorkspaceTransitionFlush = + notesCtx?.registerWorkspaceTransitionFlush; + const registerOpenNoteDraft = notesCtx?.registerOpenNoteDraft; const createNote = notesCtx?.createNote; const consumePendingNewNote = notesCtx?.consumePendingNewNote; const hasExternalChanges = previewMode ? previewMode.hasExternalChanges : notesCtx!.hasExternalChanges; - const hasSaveConflict = previewMode ? previewMode.hasSaveConflict : false; + const hasSaveConflict = previewMode + ? previewMode.hasSaveConflict + : Boolean(notesCtx!.noteConflict); const reloadCurrentNote = previewMode ? previewMode.reload : notesCtx!.reloadCurrentNote; const resolveNoteConflict = previewMode ? previewMode.resolveConflict - : undefined; + : notesCtx!.resolveNoteConflict; const reloadVersion = previewMode ? previewMode.reloadVersion : notesCtx!.reloadVersion; @@ -704,6 +710,21 @@ export function Editor({ } }, [currentNote?.id, notes, previewMode]); + useEffect(() => { + if (previewMode) return; + const handleSettingsChanged = () => { + void notesService.getSettings().then(setSettings).catch((error) => { + console.error("Failed to reload editor settings:", error); + }); + }; + window.addEventListener(SETTINGS_CHANGED_DOM_EVENT, handleSettingsChanged); + return () => + window.removeEventListener( + SETTINGS_CHANGED_DOM_EVENT, + handleSettingsChanged, + ); + }, [previewMode]); + // Calculate if current note is pinned const isPinned = settings?.pinnedNoteIds?.includes(currentNote?.id || "") || false; @@ -907,6 +928,11 @@ export function Editor({ await flushPendingSave(); }, [flushPendingSave, flushSourceSave]); + useEffect(() => { + if (!registerWorkspaceTransitionFlush) return; + return registerWorkspaceTransitionFlush(flushAllPendingSaves); + }, [flushAllPendingSaves, registerWorkspaceTransitionFlush]); + const getOpenDraftSnapshot = useCallback(() => { const noteId = loadedNoteIdRef.current ?? currentNoteIdRef.current; if (sourceMode) { @@ -923,6 +949,11 @@ export function Editor({ }; }, [getMarkdown, sourceMode]); + useEffect(() => { + if (!registerOpenNoteDraft) return; + return registerOpenNoteDraft(getOpenDraftSnapshot); + }, [getOpenDraftSnapshot, registerOpenNoteDraft]); + const persistCurrentCrashCheckpoint = useCallback(async () => { const draft = getOpenDraftSnapshot(); if (!draft.dirty || !draft.noteId || !currentNote) return; diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index e967129f..4bfe3d3c 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -1,5 +1,7 @@ import { useCallback, useEffect, useRef, useState, type RefObject } from "react"; import { toast } from "sonner"; +import { listen } from "@tauri-apps/api/event"; +import { open } from "@tauri-apps/plugin-dialog"; import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; import { DndContext, @@ -11,6 +13,7 @@ import { type DragEndEvent, } from "@dnd-kit/core"; import { useNotes } from "../../context/NotesContext"; +import { useTheme } from "../../context/ThemeContext"; import { NoteList } from "../notes/NoteList"; import { Footer } from "./Footer"; import { IconButton, Input } from "../ui"; @@ -27,7 +30,9 @@ import { mod, shift, isMac, isWindows } from "../../lib/platform"; import * as notesService from "../../services/notes"; import { FolderNameDialog } from "../notes/FolderNameDialog"; import { NoteSortMenu } from "./SidebarControls"; +import { WorkspaceMenu } from "./WorkspaceMenu"; import type { NoteSortOrder } from "../../types/note"; +import { SETTINGS_CHANGED_DOM_EVENT } from "../../lib/settingsScope"; interface SidebarProps { onOpenSettings?: () => void; @@ -42,9 +47,12 @@ export function Sidebar({ onOpenSettings }: SidebarProps) { searchQuery, clearSearch, selectedNoteId, + notesFolder, + switchWorkspace, moveNote, moveFolder, } = useNotes(); + const { reloadSettings } = useTheme(); const [searchOpen, setSearchOpen] = useState(false); const [inputValue, setInputValue] = useState(searchQuery); const [plusMenuOpen, setPlusMenuOpen] = useState(false); @@ -53,6 +61,7 @@ export function Sidebar({ onOpenSettings }: SidebarProps) { const [foldersEnabled, setFoldersEnabled] = useState(true); const [noteSortOrder, setNoteSortOrder] = useState("newest"); + const [workspaces, setWorkspaces] = useState([]); const [dragLabel, setDragLabel] = useState(null); const [dragCount, setDragCount] = useState(1); const [multiSelectedNoteIds, setMultiSelectedNoteIds] = useState>(new Set()); @@ -62,6 +71,74 @@ export function Sidebar({ onOpenSettings }: SidebarProps) { const multiSelectedRef = useRef(multiSelectedNoteIds) as RefObject>; multiSelectedRef.current = multiSelectedNoteIds; + const refreshWorkspaces = useCallback(async () => { + try { + setWorkspaces(await notesService.listWorkspaces()); + } catch (error) { + console.error("Failed to load workspaces:", error); + } + }, []); + + useEffect(() => { + void refreshWorkspaces(); + let cancelled = false; + let unlisten: (() => void) | undefined; + listen("workspaces-changed", () => { + void refreshWorkspaces(); + }).then((cleanup) => { + if (cancelled) cleanup(); + else unlisten = cleanup; + }); + return () => { + cancelled = true; + unlisten?.(); + }; + }, [refreshWorkspaces]); + + const handleSwitchWorkspace = useCallback( + async (path: string) => { + try { + await switchWorkspace(path); + await reloadSettings(); + await refreshWorkspaces(); + } catch (error) { + console.error("Failed to switch workspace:", error); + toast.error("Workspace switch cancelled"); + } + }, + [refreshWorkspaces, reloadSettings, switchWorkspace], + ); + + const handleAddWorkspace = useCallback(async () => { + try { + const selected = await open({ + directory: true, + multiple: false, + title: "Add Folder", + }); + if (typeof selected === "string") { + await handleSwitchWorkspace(selected); + } + } catch (error) { + console.error("Failed to add workspace:", error); + toast.error("Failed to add folder"); + } + }, [handleSwitchWorkspace]); + + const handleRemoveWorkspace = useCallback( + async (path: string) => { + try { + await notesService.removeWorkspaceFromList(path); + await refreshWorkspaces(); + toast.success("Folder removed from list — files remain on disk"); + } catch (error) { + console.error("Failed to remove workspace from list:", error); + toast.error("Failed to remove folder from list"); + } + }, + [refreshWorkspaces], + ); + // dnd-kit const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), @@ -169,9 +246,8 @@ export function Sidebar({ onOpenSettings }: SidebarProps) { [moveNote, moveFolder], ); - // Load folders setting - useEffect(() => { - notesService.getSettings().then((s) => { + const loadWorkspaceSettings = useCallback(() => { + return notesService.getSettings().then((s) => { setFoldersEnabled(s.foldersEnabled === true); setNoteSortOrder( s.sidebarSortOrder === "oldest" ? "oldest" : "newest", @@ -182,6 +258,18 @@ export function Sidebar({ onOpenSettings }: SidebarProps) { }); }, []); + // Workspace settings update live in every window bound to that workspace. + useEffect(() => { + void loadWorkspaceSettings(); + const handleSettingsChanged = () => void loadWorkspaceSettings(); + window.addEventListener(SETTINGS_CHANGED_DOM_EVENT, handleSettingsChanged); + return () => + window.removeEventListener( + SETTINGS_CHANGED_DOM_EVENT, + handleSettingsChanged, + ); + }, [loadWorkspaceSettings]); + const handleNoteSortOrderChange = useCallback( (nextSortOrder: NoteSortOrder) => { if (nextSortOrder === noteSortOrder) return; @@ -190,13 +278,7 @@ export function Sidebar({ onOpenSettings }: SidebarProps) { setNoteSortOrder(nextSortOrder); void notesService - .getSettings() - .then((settings) => - notesService.updateSettings({ - ...settings, - sidebarSortOrder: nextSortOrder, - }), - ) + .updateWorkspaceSettings({ sidebarSortOrder: nextSortOrder }) .catch((error) => { console.error("Failed to save note sort order:", error); setNoteSortOrder((current) => @@ -346,6 +428,13 @@ export function Sidebar({ onOpenSettings }: SidebarProps) {
{/* Drag region */} {!isWindows &&
} + void handleSwitchWorkspace(path)} + onAddWorkspace={() => void handleAddWorkspace()} + onRemoveWorkspace={(path) => void handleRemoveWorkspace(path)} + />
Notes
diff --git a/src/components/layout/WorkspaceMenu.test.tsx b/src/components/layout/WorkspaceMenu.test.tsx new file mode 100644 index 00000000..5b09d7b5 --- /dev/null +++ b/src/components/layout/WorkspaceMenu.test.tsx @@ -0,0 +1,289 @@ +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { WorkspaceInfo } from "../../services/notes"; +import { WorkspaceMenu } from "./WorkspaceMenu"; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }) + .IS_REACT_ACT_ENVIRONMENT = true; + +afterEach(() => { + document.body.replaceChildren(); +}); + +const workspaces: WorkspaceInfo[] = [ + { + path: "/notes/main", + name: "main", + isDefault: true, + isCurrent: true, + isOpen: true, + }, + { + path: "/notes/client", + name: "client", + isDefault: false, + isCurrent: false, + isOpen: true, + }, + { + path: "/notes/archive", + name: "archive", + isDefault: false, + isCurrent: false, + isOpen: false, + }, +]; + +describe("WorkspaceMenu", () => { + it("shows the current folder and switches space inside the same window", () => { + const onSwitchWorkspace = 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="Switch workspace"]', + ); + expect(trigger?.textContent).toContain("main"); + expect(trigger?.textContent).not.toContain("Spaces"); + expect(trigger?.getAttribute("aria-expanded")).toBe("false"); + expect(container.querySelector('[role="menu"]')).toBeNull(); + + act(() => trigger?.click()); + + expect(trigger?.getAttribute("aria-expanded")).toBe("true"); + expect(container.querySelector('[role="menu"]')).not.toBeNull(); + const current = container.querySelector( + 'button[role="menuitemradio"][data-workspace-path="/notes/main"]', + ); + expect(current?.getAttribute("aria-checked")).toBe("true"); + + const client = container.querySelector( + 'button[role="menuitemradio"][data-workspace-path="/notes/client"]', + ); + act(() => client?.click()); + expect(onSwitchWorkspace).toHaveBeenCalledWith("/notes/client"); + + act(() => root.unmount()); + }); + + it("opens a per-folder actions dropdown before removing a remembered folder", () => { + const onAddWorkspace = vi.fn(); + const onRemoveWorkspace = vi.fn(); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + + act(() => { + root.render( + , + ); + }); + + expect( + container.querySelector('button[aria-label="Open Folder in New Window"]'), + ).toBeNull(); + expect( + container.querySelector('button[aria-label="Add Folder"]'), + ).toBeNull(); + + const trigger = container.querySelector( + 'button[aria-label="Switch workspace"]', + ); + act(() => trigger?.click()); + + const addFolder = container.querySelector( + 'button[role="menuitem"][aria-label="Add Folder"]', + ); + const archiveActions = container.querySelector( + 'button[aria-label="Actions for archive"]', + ); + expect(addFolder).not.toBeNull(); + expect(archiveActions).not.toBeNull(); + expect(archiveActions?.getAttribute("aria-haspopup")).toBe("menu"); + expect(archiveActions?.getAttribute("aria-expanded")).toBe("false"); + expect( + container.querySelector( + 'button[role="menuitem"][aria-label="Remove archive from List"]', + ), + ).toBeNull(); + expect( + container.querySelector( + 'button[role="menuitem"][aria-label="Open Folder in New Window"]', + ), + ).toBeNull(); + + act(() => archiveActions?.click()); + expect(onRemoveWorkspace).not.toHaveBeenCalled(); + expect(archiveActions?.getAttribute("aria-expanded")).toBe("true"); + expect( + container.querySelector('[role="menu"][aria-label="Actions for archive"]'), + ).not.toBeNull(); + + const removeArchive = container.querySelector( + 'button[role="menuitem"][aria-label="Remove archive from List"]', + ); + act(() => removeArchive?.click()); + expect(onRemoveWorkspace).toHaveBeenCalledWith("/notes/archive"); + expect( + container.querySelector('[role="menu"][aria-label="Actions for archive"]'), + ).toBeNull(); + expect(container.querySelector('[role="menu"][aria-label="Workspaces"]')).not.toBeNull(); + + act(() => addFolder?.click()); + expect(onAddWorkspace).toHaveBeenCalledOnce(); + expect(container.querySelector('[role="menu"]')).toBeNull(); + + act(() => root.unmount()); + }); + + it("closes the nested folder actions menu before the workspace switcher", () => { + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + + act(() => { + root.render( + , + ); + }); + + const workspaceTrigger = container.querySelector( + 'button[aria-label="Switch workspace"]', + ); + act(() => workspaceTrigger?.click()); + const archiveActions = container.querySelector( + 'button[aria-label="Actions for archive"]', + ); + act(() => archiveActions?.click()); + + act(() => { + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + }); + + expect( + container.querySelector('[role="menu"][aria-label="Actions for archive"]'), + ).toBeNull(); + expect(container.querySelector('[role="menu"][aria-label="Workspaces"]')).not.toBeNull(); + expect(document.activeElement).toBe(archiveActions); + + act(() => { + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + }); + expect(container.querySelector('[role="menu"][aria-label="Workspaces"]')).toBeNull(); + expect(document.activeElement).toBe(workspaceTrigger); + + act(() => root.unmount()); + }); + + it("closes the workspace menu without removing the compact trigger", () => { + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + + act(() => { + root.render( + , + ); + }); + + const trigger = container.querySelector( + 'button[aria-label="Switch workspace"]', + ); + act(() => trigger?.click()); + expect(container.querySelector('[role="menu"]')).not.toBeNull(); + + act(() => trigger?.click()); + expect(container.querySelector('[role="menu"]')).toBeNull(); + expect(trigger).not.toBeNull(); + + act(() => root.unmount()); + }); + + it("keeps the active folder label while the remembered list is empty", () => { + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + + act(() => { + root.render( + , + ); + }); + + const trigger = container.querySelector( + 'button[aria-label="Switch workspace"]', + ); + expect(trigger?.textContent).toContain("Professional"); + act(() => trigger?.click()); + expect(container.textContent).toContain("No folders in list"); + + act(() => root.unmount()); + }); + + it("does not replace a forgotten active folder label with another remembered folder", () => { + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + + act(() => { + root.render( + ({ + ...workspace, + isCurrent: false, + }))} + currentWorkspacePath="/notes/Professional" + onSwitchWorkspace={vi.fn()} + onAddWorkspace={vi.fn()} + onRemoveWorkspace={vi.fn()} + />, + ); + }); + + const trigger = container.querySelector( + 'button[aria-label="Switch workspace"]', + ); + expect(trigger?.textContent).toContain("Professional"); + expect(trigger?.textContent).not.toContain("main"); + + act(() => root.unmount()); + }); +}); diff --git a/src/components/layout/WorkspaceMenu.tsx b/src/components/layout/WorkspaceMenu.tsx new file mode 100644 index 00000000..5f3218a4 --- /dev/null +++ b/src/components/layout/WorkspaceMenu.tsx @@ -0,0 +1,224 @@ +import { useEffect, useRef, useState } from "react"; +import type { WorkspaceInfo } from "../../services/notes"; +import { + CheckIcon, + ChevronDownIcon, + ExternalLinkIcon, + FolderPlusIcon, + MinusIcon, + MoreVerticalIcon, +} from "../icons"; + +interface WorkspaceMenuProps { + workspaces: WorkspaceInfo[]; + currentWorkspacePath: string | null; + onSwitchWorkspace: (path: string) => void; + onAddWorkspace: () => void; + onRemoveWorkspace: (path: string) => void; +} + +export function WorkspaceMenu({ + workspaces, + currentWorkspacePath, + onSwitchWorkspace, + onAddWorkspace, + onRemoveWorkspace, +}: WorkspaceMenuProps) { + const [open, setOpen] = useState(false); + const [actionMenuPath, setActionMenuPath] = useState(null); + const rootRef = useRef(null); + const triggerRef = useRef(null); + const actionMenuRef = useRef(null); + const actionTriggerRefs = useRef(new Map()); + const currentWorkspace = workspaces.find((workspace) => workspace.isCurrent); + const currentPathSegments = + currentWorkspacePath?.split(/[\\/]/).filter(Boolean) ?? []; + const currentWorkspaceName = currentWorkspace?.name ?? + currentPathSegments[currentPathSegments.length - 1] ?? + "Select Space"; + + useEffect(() => { + if (!open) return; + + const handlePointerDown = (event: PointerEvent) => { + const target = event.target as Node; + if (!rootRef.current?.contains(target)) { + setActionMenuPath(null); + setOpen(false); + return; + } + + if ( + actionMenuPath && + !actionMenuRef.current?.contains(target) && + !actionTriggerRefs.current.get(actionMenuPath)?.contains(target) + ) { + setActionMenuPath(null); + } + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + event.preventDefault(); + if (actionMenuPath) { + const actionTrigger = actionTriggerRefs.current.get(actionMenuPath); + setActionMenuPath(null); + actionTrigger?.focus(); + return; + } + setOpen(false); + triggerRef.current?.focus(); + }; + + document.addEventListener("pointerdown", handlePointerDown); + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("pointerdown", handlePointerDown); + document.removeEventListener("keydown", handleKeyDown); + }; + }, [actionMenuPath, open]); + + return ( +
+
+ +
+ + {open && ( +
+ {workspaces.length === 0 ? ( +
+ No folders in list +
+ ) : ( + workspaces.map((workspace, index) => ( +
+ + + {actionMenuPath === workspace.path && ( + + )} +
+ )) + )} +
+ +
+ )} +
+ ); +} diff --git a/src/components/notes/NoteList.tsx b/src/components/notes/NoteList.tsx index 8d5a32b9..a38c8256 100644 --- a/src/components/notes/NoteList.tsx +++ b/src/components/notes/NoteList.tsx @@ -23,6 +23,7 @@ import { } from "../icons"; import type { NoteSortOrder, Settings } from "../../types/note"; import { sortNotesByModified } from "../../lib/folderTree"; +import { SETTINGS_CHANGED_DOM_EVENT } from "../../lib/settingsScope"; 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"; @@ -297,6 +298,16 @@ export function NoteList({ notesService.getSettings().then(setSettings); }, []); + useEffect(() => { + const handleSettingsChanged = () => refreshSettings(); + window.addEventListener(SETTINGS_CHANGED_DOM_EVENT, handleSettingsChanged); + return () => + window.removeEventListener( + SETTINGS_CHANGED_DOM_EVENT, + handleSettingsChanged, + ); + }, [refreshSettings]); + // Memoize display items to prevent recalculation on every render const displayItems = useMemo(() => { if (searchQuery.trim()) { diff --git a/src/context/GitContext.tsx b/src/context/GitContext.tsx index 11432ae8..0502c45e 100644 --- a/src/context/GitContext.tsx +++ b/src/context/GitContext.tsx @@ -13,6 +13,7 @@ import * as gitService from "../services/git"; import * as notesService from "../services/notes"; import type { GitStatus } from "../services/git"; import { useNotesData } from "./NotesContext"; +import { SETTINGS_CHANGED_DOM_EVENT } from "../lib/settingsScope"; interface GitContextValue { // State @@ -58,6 +59,7 @@ export function GitProvider({ children }: { children: ReactNode }) { const [gitEnabled, setGitEnabledState] = useState(false); const [isUpdatingGitEnabled, setIsUpdatingGitEnabled] = useState(false); const [lastError, setLastError] = useState(null); + const [settingsRevision, setSettingsRevision] = useState(0); // Use refs to avoid dependency cycles const hasLoadedRef = useRef(false); @@ -338,6 +340,17 @@ export function GitProvider({ children }: { children: ReactNode }) { gitService.isGitAvailable().then(setGitAvailable); }, []); + useEffect(() => { + const handleSettingsChanged = () => + setSettingsRevision((revision) => revision + 1); + window.addEventListener(SETTINGS_CHANGED_DOM_EVENT, handleSettingsChanged); + return () => + window.removeEventListener( + SETTINGS_CHANGED_DOM_EVENT, + handleSettingsChanged, + ); + }, []); + // Load per-folder git visibility setting // If explicitly set in settings, use that. Otherwise auto-detect: enable if the folder is a git repo. useEffect(() => { @@ -374,7 +387,7 @@ export function GitProvider({ children }: { children: ReactNode }) { return () => { cancelled = true; }; - }, [notesFolder]); + }, [notesFolder, settingsRevision]); // Clear git-specific UI state when disabled useEffect(() => { diff --git a/src/context/NotesContext.tsx b/src/context/NotesContext.tsx index 7ad2ea11..95e08c8b 100644 --- a/src/context/NotesContext.tsx +++ b/src/context/NotesContext.tsx @@ -12,6 +12,40 @@ import { listen } from "@tauri-apps/api/event"; import type { Note, NoteMetadata } from "../types/note"; import * as notesService from "../services/notes"; import type { SearchResult } from "../services/notes"; +import { runWorkspaceSwitch } from "../lib/workspaceSwitch"; +import { + reconcileRemoteNote, + resolveRemoteNoteId, + type NoteSyncConflict, +} from "../lib/noteSync"; +import { createSerializedTaskQueue } from "../lib/serializedWriter"; +import { + flushDraftBeforeRelocation, + preserveDraftBeforeDeletion, +} from "../lib/documentMutationSafety"; +import { + runConflictResolution, + type ConflictResolutionStrategy, +} from "../lib/conflictResolution"; +import { + DEFAULT_RESTORED_WINDOW_SESSION, + readRestoredNote, + restoreWindowSession, + type RestoredWindowSession, +} from "../lib/windowSession"; +import { getWindowSession } from "../services/windowSession"; +import { + clearDraftCheckpoint, + getDraftCheckpoint, + listDraftCheckpoints, +} from "../services/draftCheckpoint"; +import { reconcileDraftCheckpoint } from "../lib/draftCheckpoint"; + +export interface OpenNoteDraftSnapshot { + noteId: string | null; + content: string; + dirty: boolean; +} // Separate contexts to prevent unnecessary re-renders // Data context: changes frequently, only subscribed by components that need the data @@ -26,7 +60,10 @@ interface NotesDataContextValue { searchResults: SearchResult[]; isSearching: boolean; hasExternalChanges: boolean; + noteConflict: NoteSyncConflict | null; reloadVersion: number; + restoredWindowSession: RestoredWindowSession; + isWindowSessionRestored: boolean; } // Actions context: stable references, rarely causes re-renders @@ -40,7 +77,21 @@ interface NotesActionsContextValue { refreshNotes: () => Promise; reloadCurrentNote: () => Promise; setNotesFolder: (path: string) => Promise; + switchWorkspace: (path: string) => Promise; syncNotesFolder: (path: string) => Promise; + registerWorkspaceTransitionFlush: ( + handler: () => Promise, + ) => () => void; + registerOpenNoteDraft: ( + handler: () => OpenNoteDraftSnapshot, + ) => () => void; + flushCurrentDraft: () => Promise; + persistCurrentDraftRecovery: ( + reason: string, + ) => Promise; + resolveNoteConflict: ( + strategy: ConflictResolutionStrategy, + ) => Promise; search: (query: string) => Promise; clearSearch: () => void; pinNote: (id: string) => Promise; @@ -67,16 +118,31 @@ export function NotesProvider({ children }: { children: ReactNode }) { const [searchResults, setSearchResults] = useState([]); const [isSearching, setIsSearching] = useState(false); const [hasExternalChanges, setHasExternalChanges] = useState(false); + const [noteConflict, setNoteConflict] = useState( + null, + ); // Increments when user manually refreshes, so Editor knows to reload content const [reloadVersion, setReloadVersion] = useState(0); + const [restoredWindowSession, setRestoredWindowSession] = + useState(DEFAULT_RESTORED_WINDOW_SESSION); + const [isWindowSessionRestored, setIsWindowSessionRestored] = useState(false); - // Track recently saved note IDs to ignore file-change events from our own saves - const recentlySavedRef = useRef>(new Set()); // Track pending refresh timeout to debounce refreshes during rapid saves const refreshTimeoutRef = useRef(null); // Ref to access selectedNoteId in file watcher without re-registering listener const selectedNoteIdRef = useRef(null); selectedNoteIdRef.current = selectedNoteId; + const currentNoteRef = useRef(null); + currentNoteRef.current = currentNote; + const notesFolderRef = useRef(null); + notesFolderRef.current = notesFolder; + const noteRevisionByIdRef = useRef>(new Map()); + const saveQueueRef = useRef(createSerializedTaskQueue()); + const openNoteDraftRef = useRef<() => OpenNoteDraftSnapshot>(() => ({ + noteId: null, + content: "", + dirty: false, + })); // Ref to access notes in search callback without re-creating it on every notes change const notesRef = useRef([]); notesRef.current = notes; @@ -86,6 +152,55 @@ export function NotesProvider({ children }: { children: ReactNode }) { const searchRequestIdRef = useRef(0); // Tracks the ID of a newly created note so Editor can focus its title. const pendingNewNoteIdRef = useRef(null); + const workspaceTransitionFlushRef = useRef<() => Promise>( + async () => undefined, + ); + const workspaceTransitionQueueRef = useRef>(Promise.resolve()); + + const registerWorkspaceTransitionFlush = useCallback( + (handler: () => Promise) => { + workspaceTransitionFlushRef.current = handler; + return () => { + if (workspaceTransitionFlushRef.current === handler) { + workspaceTransitionFlushRef.current = async () => undefined; + } + }; + }, + [], + ); + + const registerOpenNoteDraft = useCallback( + (handler: () => OpenNoteDraftSnapshot) => { + openNoteDraftRef.current = handler; + return () => { + if (openNoteDraftRef.current === handler) { + openNoteDraftRef.current = () => ({ + noteId: null, + content: "", + dirty: false, + }); + } + }; + }, + [], + ); + + const flushCurrentDraft = useCallback( + () => workspaceTransitionFlushRef.current(), + [], + ); + + const persistCurrentDraftRecovery = useCallback(async (reason: string) => { + const draft = openNoteDraftRef.current(); + if (!draft.dirty || !draft.noteId) return undefined; + const note = currentNoteRef.current; + return notesService.persistRecoverySnapshot({ + noteId: draft.noteId, + sourcePath: note?.path ?? "", + content: draft.content, + reason, + }); + }, []); const refreshNotes = useCallback(async () => { if (!notesFolder) return; @@ -109,14 +224,22 @@ export function NotesProvider({ children }: { children: ReactNode }) { }, [refreshNotes]); const selectNote = useCallback(async (id: string) => { - const requestId = ++selectRequestIdRef.current; try { + if ( + selectedNoteIdRef.current && + selectedNoteIdRef.current !== id && + openNoteDraftRef.current().dirty + ) { + await workspaceTransitionFlushRef.current(); + } + const requestId = ++selectRequestIdRef.current; if (pendingNewNoteIdRef.current !== id) { pendingNewNoteIdRef.current = null; } // Set selected ID immediately for responsive UI setSelectedNoteId(id); setHasExternalChanges(false); + setNoteConflict(null); // Expand parent folders so the note is visible in the tree const lastSlash = id.lastIndexOf("/"); if (lastSlash > 0) { @@ -128,9 +251,10 @@ export function NotesProvider({ children }: { children: ReactNode }) { } const note = await notesService.readNote(id); if (requestId !== selectRequestIdRef.current) return; + noteRevisionByIdRef.current.set(note.id, note.revision); + currentNoteRef.current = note; setCurrentNote(note); } catch (err) { - if (requestId !== selectRequestIdRef.current) return; setError(err instanceof Error ? err.message : "Failed to load note"); } }, []); @@ -138,9 +262,15 @@ export function NotesProvider({ children }: { children: ReactNode }) { const reloadCurrentNote = useCallback(async () => { if (!selectedNoteIdRef.current) return; try { + if (openNoteDraftRef.current().dirty) { + await workspaceTransitionFlushRef.current(); + } const note = await notesService.readNote(selectedNoteIdRef.current); + noteRevisionByIdRef.current.set(note.id, note.revision); + currentNoteRef.current = note; setCurrentNote(note); setHasExternalChanges(false); + setNoteConflict(null); setReloadVersion((v) => v + 1); } catch (err) { setError(err instanceof Error ? err.message : "Failed to reload note"); @@ -149,6 +279,9 @@ export function NotesProvider({ children }: { children: ReactNode }) { const createNote = useCallback(async () => { try { + if (openNoteDraftRef.current().dirty) { + await workspaceTransitionFlushRef.current(); + } // Derive target folder from the selected note's parent path let targetFolder: string | undefined; if (selectedNoteIdRef.current) { @@ -160,17 +293,16 @@ export function NotesProvider({ children }: { children: ReactNode }) { const note = await notesService.createNote(targetFolder); selectRequestIdRef.current += 1; pendingNewNoteIdRef.current = note.id; - // Mark as recently saved to ignore file-change events from our own creation - recentlySavedRef.current.add(note.id); + noteRevisionByIdRef.current.set(note.id, note.revision); await refreshNotes(); + currentNoteRef.current = note; setCurrentNote(note); setSelectedNoteId(note.id); + selectedNoteIdRef.current = note.id; + setNoteConflict(null); // Clear search when creating a new note setSearchQuery(""); setSearchResults([]); - setTimeout(() => { - recentlySavedRef.current.delete(note.id); - }, 1000); } catch (err) { setError(err instanceof Error ? err.message : "Failed to create note"); } @@ -186,74 +318,161 @@ export function NotesProvider({ children }: { children: ReactNode }) { }, []); const saveNote = useCallback( - async (content: string, noteId?: string) => { - // Use provided noteId (for flush saves) or fall back to currentNote.id - const savingNoteId = noteId || currentNote?.id; - if (!savingNoteId) return; - let updatedId: string | null = null; + (content: string, noteId?: string): Promise => { + const savingNoteId = noteId || currentNoteRef.current?.id; + if (!savingNoteId) return Promise.resolve(); + + return saveQueueRef.current(async () => { + try { + const expectedRevision = + noteRevisionByIdRef.current.get(savingNoteId) ?? + (currentNoteRef.current?.id === savingNoteId + ? currentNoteRef.current.revision + : null); + if (!expectedRevision) { + throw new Error(`Missing base revision for ${savingNoteId}`); + } - try { - // Mark this note as recently saved to ignore file-change events from our own save - recentlySavedRef.current.add(savingNoteId); - - const updated = await notesService.saveNote(savingNoteId, content); - updatedId = updated.id; - - // If the note was renamed (ID changed), also mark the new ID - if (updated.id !== savingNoteId) { - recentlySavedRef.current.add(updated.id); - - // Transfer pin status to new ID - const currentSettings = await notesService.getSettings(); - const pinnedIds = currentSettings.pinnedNoteIds || []; - if (pinnedIds.includes(savingNoteId)) { - const updatedSettings = { - ...currentSettings, - pinnedNoteIds: pinnedIds.map((id) => - id === savingNoteId ? updated.id : id - ), - }; - await notesService.updateSettings(updatedSettings); + const result = await notesService.saveNote( + savingNoteId, + content, + expectedRevision, + ); + if (result.status === "conflict") { + let remote: Note | null = null; + if (result.current) { + try { + remote = await notesService.readNote(savingNoteId); + } catch { + remote = null; + } + } + if (selectedNoteIdRef.current === savingNoteId) { + setNoteConflict( + remote + ? { kind: "modified", remote } + : { kind: "deleted", remote: null }, + ); + setHasExternalChanges(true); + } + throw new Error( + remote + ? "Save conflict: a newer version is already on disk" + : "Save conflict: the source note was deleted or moved", + ); } - } - // Clear external changes flag - if it was set by our own save, we want to ignore it - setHasExternalChanges(false); + const updated = result.note; + noteRevisionByIdRef.current.delete(savingNoteId); + noteRevisionByIdRef.current.set(updated.id, updated.revision); + + if (updated.id !== savingNoteId) { + const currentSettings = await notesService.getSettings(); + const pinnedIds = currentSettings.pinnedNoteIds || []; + if (pinnedIds.includes(savingNoteId)) { + await notesService.updateWorkspaceSettings({ + pinnedNoteIds: pinnedIds.map((id) => + id === savingNoteId ? updated.id : id, + ), + }); + } + } - // Only update state if we're still on the same note we started saving - // This prevents race conditions when user switches notes during save - setSelectedNoteId((prevId) => { - if (prevId === savingNoteId) { - // Update to the new ID if the note was renamed + if (selectedNoteIdRef.current === savingNoteId) { + selectedNoteIdRef.current = updated.id; + currentNoteRef.current = updated; + setSelectedNoteId(updated.id); setCurrentNote(updated); - return updated.id; + setNoteConflict(null); + setHasExternalChanges(false); } - // User switched to a different note, don't update current note - return prevId; - }); - - // Schedule refresh with debounce - avoids blocking typing during rapid saves - scheduleRefresh(); + scheduleRefresh(); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to save note"); + throw err; + } + }); + }, + [scheduleRefresh], + ); - // Clear the recently saved flag after a short delay - // (longer than the file watcher debounce of 500ms) - setTimeout(() => { - recentlySavedRef.current.delete(savingNoteId); - if (updatedId) recentlySavedRef.current.delete(updatedId); - }, 1000); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to save note"); - // Clean up immediately on error to avoid leaving stale entries - recentlySavedRef.current.delete(savingNoteId); - if (updatedId) recentlySavedRef.current.delete(updatedId); + const resolveNoteConflict = useCallback( + async (strategy: ConflictResolutionStrategy) => { + const conflict = noteConflict; + if (!conflict) return; + const draft = openNoteDraftRef.current(); + if (!draft.noteId) { + throw new Error("No open draft to resolve"); } + + const applyResolvedNote = (note: Note | null) => { + noteRevisionByIdRef.current.delete(draft.noteId!); + currentNoteRef.current = note; + selectedNoteIdRef.current = note?.id ?? null; + if (note) { + noteRevisionByIdRef.current.set(note.id, note.revision); + } + setCurrentNote(note); + setSelectedNoteId(note?.id ?? null); + setNoteConflict(null); + setHasExternalChanges(false); + setReloadVersion((version) => version + 1); + }; + + await runConflictResolution( + strategy, + { draft, remote: conflict.remote }, + { + persistRecovery: () => + persistCurrentDraftRecovery(`conflict-${strategy}`), + overwriteRemote: async (localDraft, remote) => { + const result = await notesService.saveNote( + remote.id, + localDraft.content, + remote.revision, + ); + if (result.status === "conflict") { + throw new Error("The disk version changed again; conflict preserved"); + } + applyResolvedNote(result.note); + }, + recreateDeleted: async (localDraft) => { + const slash = draft.noteId!.lastIndexOf("/"); + const folder = slash > 0 ? draft.noteId!.slice(0, slash) : undefined; + const created = await notesService.createNote(folder); + const result = await notesService.saveNote( + created.id, + localDraft.content, + created.revision, + ); + if (result.status === "conflict") { + throw new Error("Could not recreate the deleted note safely"); + } + applyResolvedNote(result.note); + }, + acceptRemote: async (remote) => { + applyResolvedNote(remote); + }, + }, + ); + await clearDraftCheckpoint({ + windowLabel: "", + noteId: draft.noteId, + }); + await refreshNotes(); }, - [currentNote, scheduleRefresh] + [noteConflict, persistCurrentDraftRecovery, refreshNotes], ); const deleteNote = useCallback( async (id: string) => { try { + const draft = openNoteDraftRef.current(); + await preserveDraftBeforeDeletion( + draft, + draft.noteId === id, + () => persistCurrentDraftRecovery("delete-note"), + ); await notesService.deleteNote(id); // Clean up pinned status for deleted note @@ -261,16 +480,18 @@ export function NotesProvider({ children }: { children: ReactNode }) { const pinnedIds = currentSettings.pinnedNoteIds || []; if (pinnedIds.includes(id)) { const updatedSettings = { - ...currentSettings, pinnedNoteIds: pinnedIds.filter((pinId) => pinId !== id), }; - await notesService.updateSettings(updatedSettings); + await notesService.updateWorkspaceSettings(updatedSettings); } // Only clear selection if we're deleting the currently selected note setSelectedNoteId((prevId) => { if (prevId === id) { + selectedNoteIdRef.current = null; + currentNoteRef.current = null; setCurrentNote(null); + setNoteConflict(null); return null; } return prevId; @@ -280,22 +501,24 @@ export function NotesProvider({ children }: { children: ReactNode }) { setError(err instanceof Error ? err.message : "Failed to delete note"); } }, - [refreshNotes] + [persistCurrentDraftRecovery, refreshNotes] ); const duplicateNote = useCallback( async (id: string) => { try { + if (openNoteDraftRef.current().dirty) { + await workspaceTransitionFlushRef.current(); + } const newNote = await notesService.duplicateNote(id); selectRequestIdRef.current += 1; - // Mark as recently saved to ignore file-change events from our own creation - recentlySavedRef.current.add(newNote.id); + noteRevisionByIdRef.current.set(newNote.id, newNote.revision); await refreshNotes(); + currentNoteRef.current = newNote; + selectedNoteIdRef.current = newNote.id; setCurrentNote(newNote); setSelectedNoteId(newNote.id); - setTimeout(() => { - recentlySavedRef.current.delete(newNote.id); - }, 1000); + setNoteConflict(null); } catch (err) { setError(err instanceof Error ? err.message : "Failed to duplicate note"); } @@ -311,10 +534,9 @@ export function NotesProvider({ children }: { children: ReactNode }) { if (!pinnedIds.includes(id)) { const updatedSettings = { - ...currentSettings, pinnedNoteIds: [...pinnedIds, id], }; - await notesService.updateSettings(updatedSettings); + await notesService.updateWorkspaceSettings(updatedSettings); await refreshNotes(); } } catch (err) { @@ -331,10 +553,9 @@ export function NotesProvider({ children }: { children: ReactNode }) { const pinnedIds = currentSettings.pinnedNoteIds || []; const updatedSettings = { - ...currentSettings, pinnedNoteIds: pinnedIds.filter((pinId) => pinId !== id), }; - await notesService.updateSettings(updatedSettings); + await notesService.updateWorkspaceSettings(updatedSettings); await refreshNotes(); } catch (err) { setError(err instanceof Error ? err.message : "Failed to unpin note"); @@ -346,18 +567,21 @@ export function NotesProvider({ children }: { children: ReactNode }) { const createNoteInFolder = useCallback( async (folderPath: string) => { try { + if (openNoteDraftRef.current().dirty) { + await workspaceTransitionFlushRef.current(); + } const note = await notesService.createNote(folderPath); selectRequestIdRef.current += 1; pendingNewNoteIdRef.current = note.id; - recentlySavedRef.current.add(note.id); + noteRevisionByIdRef.current.set(note.id, note.revision); await refreshNotes(); + currentNoteRef.current = note; + selectedNoteIdRef.current = note.id; setCurrentNote(note); setSelectedNoteId(note.id); + setNoteConflict(null); setSearchQuery(""); setSearchResults([]); - setTimeout(() => { - recentlySavedRef.current.delete(note.id); - }, 1000); } catch (err) { setError( err instanceof Error ? err.message : "Failed to create note" @@ -385,6 +609,12 @@ export function NotesProvider({ children }: { children: ReactNode }) { const deleteFolderAction = useCallback( async (path: string) => { try { + const draft = openNoteDraftRef.current(); + await preserveDraftBeforeDeletion( + draft, + Boolean(draft.noteId?.startsWith(`${path}/`)), + () => persistCurrentDraftRecovery("delete-folder"), + ); await notesService.deleteFolder(path); // If the selected note was inside the deleted folder, clear selection setSelectedNoteId((prevId) => { @@ -401,12 +631,18 @@ export function NotesProvider({ children }: { children: ReactNode }) { ); } }, - [refreshNotes] + [persistCurrentDraftRecovery, refreshNotes] ); const renameFolderAction = useCallback( async (oldPath: string, newName: string) => { try { + const draft = openNoteDraftRef.current(); + await flushDraftBeforeRelocation( + draft, + Boolean(draft.noteId?.startsWith(`${oldPath}/`)), + () => workspaceTransitionFlushRef.current(), + ); await notesService.renameFolder(oldPath, newName); // Compute new folder path @@ -445,6 +681,12 @@ export function NotesProvider({ children }: { children: ReactNode }) { const moveNoteAction = useCallback( async (id: string, targetFolder: string) => { try { + const draft = openNoteDraftRef.current(); + await flushDraftBeforeRelocation( + draft, + draft.noteId === id, + () => workspaceTransitionFlushRef.current(), + ); const newId = await notesService.moveNote(id, targetFolder); // Update selection if we moved the selected note setSelectedNoteId((prevId) => { @@ -469,6 +711,12 @@ export function NotesProvider({ children }: { children: ReactNode }) { const moveFolderAction = useCallback( async (path: string, targetParent: string) => { try { + const draft = openNoteDraftRef.current(); + await flushDraftBeforeRelocation( + draft, + Boolean(draft.noteId?.startsWith(`${path}/`)), + () => workspaceTransitionFlushRef.current(), + ); await notesService.moveFolder(path, targetParent); // Compute new folder path @@ -503,35 +751,153 @@ export function NotesProvider({ children }: { children: ReactNode }) { [refreshNotes] ); - const setNotesFolder = useCallback(async (path: string) => { - try { - await notesService.setNotesFolder(path); - setNotesFolderState(path); - // Start file watcher after setting folder - await notesService.startFileWatcher(); - } catch (err) { - setError( - err instanceof Error ? err.message : "Failed to set notes folder" + const restoreWorkspaceSession = useCallback( + async (workspace: string, notesList: readonly NoteMetadata[]) => { + setIsWindowSessionRestored(false); + const restored = await restoreWindowSession({ + isPreview: false, + workspace, + noteIds: notesList.map((note) => note.id), + load: getWindowSession, + }); + const diskNote = await readRestoredNote( + restored.selectedNoteId, + notesService.readNote, ); - } - }, []); + let restoredNote = diskNote; + let recoveredRemote: Note | null = null; + if (diskNote) { + const checkpoint = await getDraftCheckpoint(diskNote.id).catch( + () => null, + ); + if (checkpoint) { + const reconciled = reconcileDraftCheckpoint(diskNote, checkpoint); + restoredNote = reconciled.note; + recoveredRemote = reconciled.remote; + if (reconciled.shouldClear) { + await clearDraftCheckpoint(checkpoint.key).catch(() => undefined); + } + } + } else { + const orphaned = await listDraftCheckpoints().catch(() => []); + if (orphaned.length > 0) { + setError( + `${orphaned.length} recovered draft${orphaned.length === 1 ? " is" : "s are"} available from a previous interrupted session`, + ); + } + } + + const appliedSession = restoredNote + ? restored + : { ...restored, selectedNoteId: null, focusMode: false }; + + selectRequestIdRef.current += 1; + selectedNoteIdRef.current = restoredNote?.id ?? null; + currentNoteRef.current = restoredNote; + setSelectedNoteId(restoredNote?.id ?? null); + setCurrentNote(restoredNote); + setNoteConflict( + recoveredRemote ? { kind: "modified", remote: recoveredRemote } : null, + ); + setHasExternalChanges(Boolean(recoveredRemote)); + if (restoredNote) { + noteRevisionByIdRef.current.set( + restoredNote.id, + restoredNote.revision, + ); + } + setRestoredWindowSession(appliedSession); + setIsWindowSessionRestored(true); + }, + [], + ); + + const loadWorkspaceState = useCallback(async (path: string) => { + setIsWindowSessionRestored(false); + selectRequestIdRef.current += 1; + searchRequestIdRef.current += 1; + pendingNewNoteIdRef.current = null; + noteRevisionByIdRef.current.clear(); + currentNoteRef.current = null; + selectedNoteIdRef.current = null; + setNotesFolderState(path); + setSelectedNoteId(null); + setCurrentNote(null); + setNotes([]); + setSearchQuery(""); + setSearchResults([]); + setIsSearching(false); + setHasExternalChanges(false); + setNoteConflict(null); + + const notesList = await notesService.listNotes(); + setNotes(notesList); + await restoreWorkspaceSession(path, notesList); + await notesService.startFileWatcher(); + }, [restoreWorkspaceSession]); + + const queueWorkspaceTransition = useCallback( + ( + path: string, + switchBackendWorkspace: (path: string) => Promise, + ) => { + const transition = workspaceTransitionQueueRef.current.then(async () => { + await runWorkspaceSwitch(path, { + flushCurrentDraft: () => workspaceTransitionFlushRef.current(), + switchBackendWorkspace, + loadWorkspace: loadWorkspaceState, + }); + }); + workspaceTransitionQueueRef.current = transition.catch(() => undefined); + return transition; + }, + [loadWorkspaceState], + ); + + const setNotesFolder = useCallback( + async (path: string) => { + try { + await queueWorkspaceTransition(path, async (requestedPath) => { + await notesService.setNotesFolder(requestedPath); + return (await notesService.getNotesFolder()) ?? requestedPath; + }); + } catch (err) { + setError( + err instanceof Error ? err.message : "Failed to set notes folder", + ); + throw err; + } + }, + [queueWorkspaceTransition], + ); + + const switchWorkspace = useCallback( + async (path: string) => { + try { + await queueWorkspaceTransition(path, notesService.switchWorkspace); + } catch (err) { + setError( + err instanceof Error ? err.message : "Failed to switch workspace", + ); + throw err; + } + }, + [queueWorkspaceTransition], + ); // Update local state only (backend already initialized the folder). // Used when the CLI sets the notes folder and emits an event. const syncNotesFolder = useCallback(async (path: string) => { try { - setNotesFolderState(path); - setSelectedNoteId(null); - setCurrentNote(null); - const notesList = await notesService.listNotes(); - setNotes(notesList); - await notesService.startFileWatcher(); + await workspaceTransitionFlushRef.current(); + await loadWorkspaceState(path); } catch (err) { setError( err instanceof Error ? err.message : "Failed to sync notes folder" ); + throw err; } - }, []); + }, [loadWorkspaceState]); const search = useCallback(async (query: string) => { const requestId = ++searchRequestIdRef.current; @@ -606,8 +972,12 @@ export function NotesProvider({ children }: { children: ReactNode }) { if (folder) { const notesList = await notesService.listNotes(); setNotes(notesList); + await restoreWorkspaceSession(folder, notesList); // Start file watcher await notesService.startFileWatcher(); + } else { + setRestoredWindowSession(DEFAULT_RESTORED_WINDOW_SESSION); + setIsWindowSessionRestored(true); } } catch (err) { setError(err instanceof Error ? err.message : "Failed to initialize"); @@ -616,34 +986,98 @@ export function NotesProvider({ children }: { children: ReactNode }) { } } init(); - }, []); + }, [restoreWorkspaceSession]); - // Listen for file change events and notify if current note changed externally + // Reconcile watcher events by workspace + revision. A clean editor reloads + // immediately; a dirty editor keeps its exact draft and enters conflict. useEffect(() => { let isCancelled = false; let unlisten: (() => void) | undefined; - listen<{ changed_ids: string[] }>("file-change", (event) => { - // Don't process if effect was cleaned up + listen<{ + workspace?: string; + kind: string; + changed_ids: string[]; + previous_id?: string | null; + current_id?: string | null; + }>("file-change", (event) => { if (isCancelled) return; + if ( + event.payload.workspace && + event.payload.workspace !== notesFolderRef.current + ) { + return; + } const changedIds = event.payload.changed_ids || []; + if (changedIds.length === 0) return; + void refreshNotes(); + + const current = currentNoteRef.current; + if (!current) return; + const remoteId = resolveRemoteNoteId(current.id, { + ...event.payload, + changed_ids: changedIds, + }); + if (remoteId === undefined) return; + const observedId = current.id; + + void (async () => { + let remote: Note | null = null; + if (remoteId !== null) { + try { + remote = await notesService.readNote(remoteId); + } catch { + remote = null; + } + } + if ( + isCancelled || + selectedNoteIdRef.current !== observedId || + currentNoteRef.current?.id !== observedId + ) { + return; + } - // Filter out notes we recently saved ourselves - const externalChanges = changedIds.filter( - (id) => !recentlySavedRef.current.has(id) - ); - - // Only refresh if there are external changes - if (externalChanges.length > 0) { - refreshNotes(); + const draft = openNoteDraftRef.current(); + const syncState = { + note: currentNoteRef.current, + draft: + draft.noteId === observedId ? draft.content : current.content, + dirty: draft.noteId === observedId && draft.dirty, + conflict: noteConflict, + }; + const next = reconcileRemoteNote(syncState, remote); + if (next === syncState) return; - // If the currently selected note was changed externally, set flag (don't auto-reload) - const currentId = selectedNoteIdRef.current; - if (currentId && externalChanges.includes(currentId)) { + if (next.conflict) { + setNoteConflict(next.conflict); setHasExternalChanges(true); + return; } - } + + noteRevisionByIdRef.current.delete(observedId); + if (!next.note) { + selectedNoteIdRef.current = null; + currentNoteRef.current = null; + setSelectedNoteId(null); + setCurrentNote(null); + setNoteConflict(null); + setHasExternalChanges(false); + return; + } + + currentNoteRef.current = next.note; + noteRevisionByIdRef.current.set(next.note.id, next.note.revision); + if (next.note.id !== observedId) { + selectedNoteIdRef.current = next.note.id; + setSelectedNoteId(next.note.id); + } + setCurrentNote(next.note); + setNoteConflict(null); + setHasExternalChanges(false); + setReloadVersion((version) => version + 1); + })(); }).then((fn) => { if (isCancelled) { // Effect was cleaned up before listener registered, clean up immediately @@ -659,7 +1093,7 @@ export function NotesProvider({ children }: { children: ReactNode }) { unlisten(); } }; - }, [refreshNotes]); + }, [noteConflict, refreshNotes]); // Listen for "select-note" events from the backend (CLI, drag-drop, Open With, import from preview) useEffect(() => { @@ -693,7 +1127,10 @@ export function NotesProvider({ children }: { children: ReactNode }) { searchResults, isSearching, hasExternalChanges, + noteConflict, reloadVersion, + restoredWindowSession, + isWindowSessionRestored, }), [ notes, @@ -706,7 +1143,10 @@ export function NotesProvider({ children }: { children: ReactNode }) { searchResults, isSearching, hasExternalChanges, + noteConflict, reloadVersion, + restoredWindowSession, + isWindowSessionRestored, ] ); @@ -722,7 +1162,13 @@ export function NotesProvider({ children }: { children: ReactNode }) { refreshNotes, reloadCurrentNote, setNotesFolder, + switchWorkspace, syncNotesFolder, + registerWorkspaceTransitionFlush, + registerOpenNoteDraft, + flushCurrentDraft, + persistCurrentDraftRecovery, + resolveNoteConflict, search, clearSearch, pinNote, @@ -744,7 +1190,13 @@ export function NotesProvider({ children }: { children: ReactNode }) { refreshNotes, reloadCurrentNote, setNotesFolder, + switchWorkspace, syncNotesFolder, + registerWorkspaceTransitionFlush, + registerOpenNoteDraft, + flushCurrentDraft, + persistCurrentDraftRecovery, + resolveNoteConflict, search, clearSearch, pinNote, diff --git a/src/context/ThemeContext.tsx b/src/context/ThemeContext.tsx index 4e7495e6..b2359189 100644 --- a/src/context/ThemeContext.tsx +++ b/src/context/ThemeContext.tsx @@ -9,7 +9,8 @@ import { type ReactNode, } from "react"; import { invoke } from "@tauri-apps/api/core"; -import { getSettings, updateSettings } from "../services/notes"; +import { listen } from "@tauri-apps/api/event"; +import { getSettings, updateGlobalSettings } from "../services/notes"; import { SIDEBAR_MIN_PX, SIDEBAR_MAX_PX } from "../lib/sidebar"; import { resolveEditorWidthResizeEnabled } from "../lib/editorWidthResize"; import { resolveEditorToolbarVisible } from "../lib/editorToolbar"; @@ -28,9 +29,12 @@ import type { EditorWidth, CustomColors, ThemeColorKey, - Settings, } from "../types/note"; import { toast } from "sonner"; +import { + SETTINGS_CHANGED_DOM_EVENT, + type SettingsChangedEvent, +} from "../lib/settingsScope"; type ThemeMode = "light" | "dark" | "system"; @@ -233,12 +237,6 @@ export function ThemeProvider({ children }: ThemeProviderProps) { }, [], ); - - const updateSettingsPatch = useCallback(async (patch: Partial) => { - const settings = await getSettings(); - await updateSettings({ ...settings, ...patch }); - }, []); - const [systemTheme, setSystemTheme] = useState<"light" | "dark">(() => { return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" @@ -332,6 +330,33 @@ export function ThemeProvider({ children }: ThemeProviderProps) { }); }, [loadSettingsFromBackend]); + // One native listener per window fans scoped changes out to local React + // consumers. Global appearance changes are reloaded immediately everywhere. + useEffect(() => { + let disposed = false; + let unlisten: (() => void) | undefined; + + void listen("settings-changed", (event) => { + if (disposed) return; + if (event.payload.scope === "global") { + void loadSettingsFromBackend(); + } + window.dispatchEvent( + new CustomEvent(SETTINGS_CHANGED_DOM_EVENT, { + detail: event.payload, + }), + ); + }).then((removeListener) => { + if (disposed) removeListener(); + else unlisten = removeListener; + }); + + return () => { + disposed = true; + unlisten?.(); + }; + }, [loadSettingsFromBackend]); + // Listen for system theme changes useEffect(() => { const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); @@ -358,14 +383,10 @@ export function ThemeProvider({ children }: ThemeProviderProps) { // Save theme mode to backend const saveThemeSettings = useCallback(async (newMode: ThemeMode) => { try { - const settings = await getSettings(); const themeSettings: ThemeSettings = { mode: newMode, }; - await updateSettings({ - ...settings, - theme: themeSettings, - }); + await updateGlobalSettings({ theme: themeSettings }); } catch (error) { console.error("Failed to save theme settings:", error); } @@ -420,11 +441,7 @@ export function ThemeProvider({ children }: ThemeProviderProps) { const saveFontSettings = useCallback( async (newFontSettings: Required) => { try { - const settings = await getSettings(); - await updateSettings({ - ...settings, - editorFont: newFontSettings, - }); + await updateGlobalSettings({ editorFont: newFontSettings }); } catch (error) { console.error("Failed to save font settings:", error); } @@ -460,22 +477,19 @@ export function ThemeProvider({ children }: ThemeProviderProps) { setSidebarWidthPxState(null); setCustomColorsLightState({}); setCustomColorsDarkState({}); - try { - const settings = await getSettings(); - await updateSettings({ - ...settings, + await updateGlobalSettings({ editorFont: defaultEditorFontSettings, textDirection: "auto", editorWidth: "normal", interfaceZoom: 1.0, - customEditorWidthPx: undefined, - editorWidthResizeEnabled: undefined, - editorToolbarVisible: undefined, - titleBarModifiedDateVisible: undefined, - titleBarFilenameVisible: undefined, - sidebarWidthPx: undefined, - customColorsLight: undefined, - customColorsDark: undefined, + customEditorWidthPx: null, + editorWidthResizeEnabled: null, + editorToolbarVisible: null, + titleBarModifiedDateVisible: null, + titleBarFilenameVisible: null, + sidebarWidthPx: null, + customColorsLight: null, + customColorsDark: null, }); } catch (error) { console.error("Failed to reset appearance settings:", error); @@ -488,8 +502,7 @@ export function ThemeProvider({ children }: ThemeProviderProps) { const setTextDirection = useCallback(async (dir: TextDirection) => { setTextDirectionState(dir); try { - const settings = await getSettings(); - await updateSettings({ ...settings, textDirection: dir }); + await updateGlobalSettings({ textDirection: dir }); } catch (error) { console.error("Failed to save text direction:", error); } @@ -499,8 +512,7 @@ export function ThemeProvider({ children }: ThemeProviderProps) { const setEditorWidth = useCallback(async (width: EditorWidth) => { setEditorWidthState(width); try { - const settings = await getSettings(); - await updateSettings({ ...settings, editorWidth: width }); + await updateGlobalSettings({ editorWidth: width }); } catch (error) { console.error("Failed to save editor width:", error); } @@ -523,10 +535,7 @@ export function ThemeProvider({ children }: ThemeProviderProps) { // Persist interface zoom changes to backend useEffect(() => { if (!isInitialized) return; - getSettings() - .then((settings) => - updateSettings({ ...settings, interfaceZoom }), - ) + updateGlobalSettings({ interfaceZoom }) .catch((error) => console.error("Failed to save interface zoom:", error), ); @@ -537,9 +546,7 @@ export function ThemeProvider({ children }: ThemeProviderProps) { setEditorWidthState("custom"); setCustomEditorWidthPxState(px); try { - const settings = await getSettings(); - await updateSettings({ - ...settings, + await updateGlobalSettings({ editorWidth: "custom", customEditorWidthPx: px, }); @@ -548,29 +555,23 @@ export function ThemeProvider({ children }: ThemeProviderProps) { } }, []); - const setEditorWidthResizeEnabled = useCallback( - async (enabled: boolean) => { - setEditorWidthResizeEnabledState(enabled); - try { - await updateSettingsPatch({ editorWidthResizeEnabled: enabled }); - } catch (error) { - console.error("Failed to save editor width resize setting:", error); - } - }, - [updateSettingsPatch], - ); + const setEditorWidthResizeEnabled = useCallback(async (enabled: boolean) => { + setEditorWidthResizeEnabledState(enabled); + try { + await updateGlobalSettings({ editorWidthResizeEnabled: enabled }); + } catch (error) { + console.error("Failed to save editor width resize setting:", error); + } + }, []); - const setEditorToolbarVisible = useCallback( - async (visible: boolean) => { - setEditorToolbarVisibleState(visible); - try { - await updateSettingsPatch({ editorToolbarVisible: visible }); - } catch (error) { - console.error("Failed to save editor toolbar setting:", error); - } - }, - [updateSettingsPatch], - ); + const setEditorToolbarVisible = useCallback(async (visible: boolean) => { + setEditorToolbarVisibleState(visible); + try { + await updateGlobalSettings({ editorToolbarVisible: visible }); + } catch (error) { + console.error("Failed to save editor toolbar setting:", error); + } + }, []); const updateTitleBarNoteInfo = useCallback( (kind: TitleBarNoteInfoKind, visible: boolean) => { @@ -580,23 +581,25 @@ export function ThemeProvider({ children }: ThemeProviderProps) { visible, ); applyTitleBarNoteInfoVisibility(next); - void updateSettingsPatch({ + void updateGlobalSettings({ titleBarModifiedDateVisible: next.modifiedDateVisible, titleBarFilenameVisible: next.filenameVisible, - }).catch((error) => { - console.error("Failed to save title bar information setting:", error); }); }, - [applyTitleBarNoteInfoVisibility, updateSettingsPatch], + [applyTitleBarNoteInfoVisibility], ); const setTitleBarModifiedDateVisible = useCallback( - (visible: boolean) => updateTitleBarNoteInfo("modifiedDate", visible), + (visible: boolean) => { + void updateTitleBarNoteInfo("modifiedDate", visible); + }, [updateTitleBarNoteInfo], ); const setTitleBarFilenameVisible = useCallback( - (visible: boolean) => updateTitleBarNoteInfo("filename", visible), + (visible: boolean) => { + void updateTitleBarNoteInfo("filename", visible); + }, [updateTitleBarNoteInfo], ); @@ -608,8 +611,7 @@ export function ThemeProvider({ children }: ThemeProviderProps) { if (px === null) { setSidebarWidthPxState(null); try { - const settings = await getSettings(); - await updateSettings({ ...settings, sidebarWidthPx: undefined }); + await updateGlobalSettings({ sidebarWidthPx: null }); } catch (error) { console.error("Failed to reset sidebar width:", error); } @@ -617,8 +619,7 @@ export function ThemeProvider({ children }: ThemeProviderProps) { const clamped = Math.round(Math.min(Math.max(px, SIDEBAR_MIN_PX), SIDEBAR_MAX_PX)); setSidebarWidthPxState(clamped); try { - const settings = await getSettings(); - await updateSettings({ ...settings, sidebarWidthPx: clamped }); + await updateGlobalSettings({ sidebarWidthPx: clamped }); } catch (error) { console.error("Failed to save sidebar width:", error); } @@ -661,8 +662,7 @@ export function ThemeProvider({ children }: ThemeProviderProps) { setter((prev) => { const updated = { ...prev, [key]: value }; // Persist in background - getSettings() - .then((settings) => updateSettings({ ...settings, [settingsKey]: updated })) + updateGlobalSettings({ [settingsKey]: updated }) .catch((err) => console.error("Failed to save custom color:", err)); return updated; }); @@ -678,13 +678,9 @@ export function ThemeProvider({ children }: ThemeProviderProps) { setter((prev) => { const updated = { ...prev }; delete updated[key]; - getSettings() - .then((settings) => - updateSettings({ - ...settings, - [settingsKey]: Object.keys(updated).length > 0 ? updated : undefined, - }), - ) + updateGlobalSettings({ + [settingsKey]: Object.keys(updated).length > 0 ? updated : null, + }) .catch((err) => console.error("Failed to reset custom color:", err)); return updated; }); @@ -699,8 +695,7 @@ export function ThemeProvider({ children }: ThemeProviderProps) { const settingsKey = mode === "light" ? "customColorsLight" : "customColorsDark"; setter({}); try { - const settings = await getSettings(); - await updateSettings({ ...settings, [settingsKey]: undefined }); + await updateGlobalSettings({ [settingsKey]: null }); } catch (err) { console.error("Failed to reset all custom colors:", err); } diff --git a/src/lib/documentMutationSafety.test.ts b/src/lib/documentMutationSafety.test.ts new file mode 100644 index 00000000..b7b3956c --- /dev/null +++ b/src/lib/documentMutationSafety.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from "vitest"; +import { + flushDraftBeforeRelocation, + preserveDraftBeforeDeletion, +} from "./documentMutationSafety"; + +const dirtyDraft = { + noteId: "Projects/Plan", + content: "# Plan\n\nUnsaved", + dirty: true, +}; + +describe("document mutation safety", () => { + it("flushes a dirty affected note before moving or renaming it", async () => { + const flush = vi.fn(async () => undefined); + + await flushDraftBeforeRelocation(dirtyDraft, true, flush); + + expect(flush).toHaveBeenCalledTimes(1); + }); + + it("does not delay an unrelated move", async () => { + const flush = vi.fn(async () => undefined); + + await flushDraftBeforeRelocation(dirtyDraft, false, flush); + + expect(flush).not.toHaveBeenCalled(); + }); + + it("requires a durable recovery copy before deleting a dirty affected note", async () => { + const persist = vi.fn(async () => "/recovery/Plan.md"); + + await expect( + preserveDraftBeforeDeletion(dirtyDraft, true, persist), + ).resolves.toBe("/recovery/Plan.md"); + expect(persist).toHaveBeenCalledTimes(1); + }); + + it("blocks deletion if a dirty affected draft cannot be recovered", async () => { + await expect( + preserveDraftBeforeDeletion(dirtyDraft, true, async () => undefined), + ).rejects.toThrow("Recovery snapshot was not created"); + }); +}); diff --git a/src/lib/documentMutationSafety.ts b/src/lib/documentMutationSafety.ts new file mode 100644 index 00000000..e10e8bd1 --- /dev/null +++ b/src/lib/documentMutationSafety.ts @@ -0,0 +1,26 @@ +export interface DocumentDraftSnapshot { + dirty: boolean; +} + +export async function flushDraftBeforeRelocation( + draft: DocumentDraftSnapshot, + affectsDraft: boolean, + flush: () => Promise, +): Promise { + if (affectsDraft && draft.dirty) { + await flush(); + } +} + +export async function preserveDraftBeforeDeletion( + draft: DocumentDraftSnapshot, + affectsDraft: boolean, + persistRecovery: () => Promise, +): Promise { + if (!affectsDraft || !draft.dirty) return undefined; + const recoveryPath = await persistRecovery(); + if (!recoveryPath) { + throw new Error("Recovery snapshot was not created"); + } + return recoveryPath; +} diff --git a/src/lib/noteSync.test.ts b/src/lib/noteSync.test.ts new file mode 100644 index 00000000..66046ae2 --- /dev/null +++ b/src/lib/noteSync.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from "vitest"; +import { + reconcileRemoteNote, + resolveRemoteNoteId, + type OpenNoteSyncState, +} from "./noteSync"; +import type { Note } from "../types/note"; + +function note(content: string, revision: string): Note { + return { + id: "Plan", + title: "Plan", + content, + path: "/notes/Plan.md", + modified: 1, + revision, + }; +} + +describe("reconcileRemoteNote", () => { + it("reloads a clean editor immediately when another window saves", () => { + const state: OpenNoteSyncState = { + note: note("# Plan\n\nOriginal", "revision-1"), + draft: "# Plan\n\nOriginal", + dirty: false, + conflict: null, + }; + + expect( + reconcileRemoteNote(state, note("# Plan\n\nWindow B", "revision-2")), + ).toEqual({ + note: note("# Plan\n\nWindow B", "revision-2"), + draft: "# Plan\n\nWindow B", + dirty: false, + conflict: null, + }); + }); + + it.each([ + ["plain text", "# Plan\n\nWindow B"], + ["task check", "# Plan\n\n- [x] shipped"], + ["link edit", "# Plan\n\n[Scratch](https://example.com)"], + ])("synchronizes %s through the same revision contract", (_kind, content) => { + const state: OpenNoteSyncState = { + note: note("# Plan\n\nOriginal", "revision-1"), + draft: "# Plan\n\nOriginal", + dirty: false, + conflict: null, + }; + + expect(reconcileRemoteNote(state, note(content, "revision-2"))).toMatchObject({ + note: { content, revision: "revision-2" }, + draft: content, + dirty: false, + conflict: null, + }); + }); + + it("keeps a dirty local draft and records the remote version as a conflict", () => { + const state: OpenNoteSyncState = { + note: note("# Plan\n\nOriginal", "revision-1"), + draft: "# Plan\n\nUnsaved local edit", + dirty: true, + conflict: null, + }; + const remote = note("# Plan\n\nWindow B", "revision-2"); + + expect(reconcileRemoteNote(state, remote)).toEqual({ + ...state, + conflict: { kind: "modified", remote }, + }); + }); + + it("ignores duplicate watcher events for the already loaded revision", () => { + const state: OpenNoteSyncState = { + note: note("# Plan\n\nCurrent", "revision-2"), + draft: "# Plan\n\nCurrent", + dirty: false, + conflict: null, + }; + + expect( + reconcileRemoteNote(state, note("# Plan\n\nCurrent", "revision-2")), + ).toBe(state); + }); + + it("keeps a dirty draft when the source file is deleted", () => { + const state: OpenNoteSyncState = { + note: note("# Plan\n\nOriginal", "revision-1"), + draft: "# Plan\n\nUnsaved local edit", + dirty: true, + conflict: null, + }; + + expect(reconcileRemoteNote(state, null)).toEqual({ + ...state, + conflict: { kind: "deleted", remote: null }, + }); + }); + + it("clears a clean editor when its file is deleted in another window", () => { + const state: OpenNoteSyncState = { + note: note("# Plan\n\nOriginal", "revision-1"), + draft: "# Plan\n\nOriginal", + dirty: false, + conflict: null, + }; + + expect(reconcileRemoteNote(state, null)).toEqual({ + note: null, + draft: "", + dirty: false, + conflict: null, + }); + }); +}); + +describe("resolveRemoteNoteId", () => { + it("follows a semantic rename or move from old id to new id", () => { + expect( + resolveRemoteNoteId("Projects/Plan", { + kind: "moved", + changed_ids: ["Projects/Plan", "Archive/Plan"], + previous_id: "Projects/Plan", + current_id: "Archive/Plan", + }), + ).toBe("Archive/Plan"); + }); + + it("maps semantic deletion to a missing remote note", () => { + expect( + resolveRemoteNoteId("Plan", { + kind: "deleted", + changed_ids: ["Plan"], + previous_id: "Plan", + current_id: null, + }), + ).toBeNull(); + }); + + it("ignores unrelated workspace note events", () => { + expect( + resolveRemoteNoteId("Plan", { + kind: "modified", + changed_ids: ["Other"], + previous_id: null, + current_id: "Other", + }), + ).toBeUndefined(); + }); +}); diff --git a/src/lib/noteSync.ts b/src/lib/noteSync.ts new file mode 100644 index 00000000..83b38e35 --- /dev/null +++ b/src/lib/noteSync.ts @@ -0,0 +1,71 @@ +import type { Note } from "../types/note"; + +export type NoteSyncConflict = + | { kind: "modified"; remote: Note } + | { kind: "deleted"; remote: null }; + +export interface OpenNoteSyncState { + note: Note | null; + draft: string; + dirty: boolean; + conflict: NoteSyncConflict | null; +} + +export function reconcileRemoteNote( + state: OpenNoteSyncState, + remote: Note | null, +): OpenNoteSyncState { + if (remote === null) { + if (!state.dirty) { + return { + note: null, + draft: "", + dirty: false, + conflict: null, + }; + } + return { + ...state, + conflict: { kind: "deleted", remote: null }, + }; + } + + if (remote.revision === state.note?.revision) { + return state; + } + + if (state.dirty) { + return { + ...state, + conflict: { kind: "modified", remote }, + }; + } + + return { + note: remote, + draft: remote.content, + dirty: false, + conflict: null, + }; +} + +export interface NoteFileChangeDescriptor { + kind: string; + changed_ids: string[]; + previous_id?: string | null; + current_id?: string | null; +} + +export function resolveRemoteNoteId( + currentId: string, + event: NoteFileChangeDescriptor, +): string | null | undefined { + if (event.previous_id === currentId) { + if (event.kind === "deleted") return null; + return event.current_id ?? null; + } + + if (!event.changed_ids.includes(currentId)) return undefined; + if (event.kind === "deleted") return null; + return event.current_id ?? currentId; +} diff --git a/src/lib/settingsScope.test.ts b/src/lib/settingsScope.test.ts new file mode 100644 index 00000000..9c6bbd23 --- /dev/null +++ b/src/lib/settingsScope.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { shouldApplySettingsChange } from "./settingsScope"; + +describe("shouldApplySettingsChange", () => { + it("applies global settings to every workspace window", () => { + expect( + shouldApplySettingsChange( + { scope: "global", workspace: null }, + "/notes/client-a", + ), + ).toBe(true); + expect( + shouldApplySettingsChange( + { scope: "global", workspace: null }, + "/notes/client-b", + ), + ).toBe(true); + }); + + it("applies workspace settings only to windows bound to that workspace", () => { + const event = { scope: "workspace" as const, workspace: "/notes/client-a" }; + + expect(shouldApplySettingsChange(event, "/notes/client-a")).toBe(true); + expect(shouldApplySettingsChange(event, "/notes/client-b")).toBe(false); + }); + + it("does not leak a workspace event into a standalone window", () => { + expect( + shouldApplySettingsChange( + { scope: "workspace", workspace: "/notes/client-a" }, + null, + ), + ).toBe(false); + }); +}); diff --git a/src/lib/settingsScope.ts b/src/lib/settingsScope.ts new file mode 100644 index 00000000..04804282 --- /dev/null +++ b/src/lib/settingsScope.ts @@ -0,0 +1,14 @@ +export const SETTINGS_CHANGED_DOM_EVENT = "scratch-settings-changed"; + +export interface SettingsChangedEvent { + scope: "global" | "workspace"; + workspace: string | null; +} + +export function shouldApplySettingsChange( + event: SettingsChangedEvent, + currentWorkspace: string | null, +): boolean { + if (event.scope === "global") return true; + return currentWorkspace !== null && event.workspace === currentWorkspace; +} diff --git a/src/lib/useWindowSessionPersistence.ts b/src/lib/useWindowSessionPersistence.ts new file mode 100644 index 00000000..a264b47a --- /dev/null +++ b/src/lib/useWindowSessionPersistence.ts @@ -0,0 +1,185 @@ +import { + useCallback, + useEffect, + useRef, + useState, + type Dispatch, + type SetStateAction, +} from "react"; +import { getCurrentWindow } from "@tauri-apps/api/window"; +import { updateWindowSession } from "../services/windowSession"; +import { + createWindowSessionPatchWriter, + type RestoredWindowSession, + type WindowSessionPatch, + type WindowSessionPatchWriter, +} from "./windowSession"; + +interface UseWindowSessionPersistenceOptions { + notesFolder: string | null; + selectedNoteId: string | null; + sidebarVisible: boolean; + setSidebarVisible: Dispatch>; + focusMode: boolean; + setFocusMode: Dispatch>; + restoredSession: RestoredWindowSession; + isRestored: boolean; +} + +interface PersistedUiState { + selectedNoteId: string | null; + sidebarVisible: boolean; + focusMode: boolean; +} + +export function useWindowSessionPersistence({ + notesFolder, + selectedNoteId, + sidebarVisible, + setSidebarVisible, + focusMode, + setFocusMode, + restoredSession, + isRestored, +}: UseWindowSessionPersistenceOptions): () => Promise { + const geometryCaptureRef = useRef<() => Promise>(async () => undefined); + const writerRef = useRef(null); + if (!writerRef.current) { + writerRef.current = createWindowSessionPatchWriter(updateWindowSession, { + onError: (error) => { + console.warn("Window session update failed", error); + }, + }); + } + const writer = writerRef.current; + const lastPersistedRef = useRef(null); + const skipHydrationPersistenceRef = useRef(false); + const [hydrationVersion, setHydrationVersion] = useState(0); + + useEffect(() => { + if (!isRestored) return; + + skipHydrationPersistenceRef.current = true; + lastPersistedRef.current = { + selectedNoteId: restoredSession.selectedNoteId, + sidebarVisible: restoredSession.sidebarVisible, + focusMode: restoredSession.focusMode, + }; + setSidebarVisible(restoredSession.sidebarVisible); + setFocusMode(restoredSession.focusMode); + setHydrationVersion((version) => version + 1); + }, [isRestored, restoredSession, setFocusMode, setSidebarVisible]); + + useEffect(() => { + if (!isRestored || !notesFolder) return; + if (skipHydrationPersistenceRef.current) { + skipHydrationPersistenceRef.current = false; + return; + } + + const current = { selectedNoteId, sidebarVisible, focusMode }; + const previous = lastPersistedRef.current; + const patch: WindowSessionPatch = {}; + if (!previous || previous.selectedNoteId !== current.selectedNoteId) { + patch.selectedNoteId = current.selectedNoteId; + } + if (!previous || previous.sidebarVisible !== current.sidebarVisible) { + patch.sidebarVisible = current.sidebarVisible; + } + if (!previous || previous.focusMode !== current.focusMode) { + patch.focusMode = current.focusMode; + } + + lastPersistedRef.current = current; + if (Object.keys(patch).length > 0) writer.queue(patch); + }, [ + focusMode, + hydrationVersion, + isRestored, + notesFolder, + selectedNoteId, + sidebarVisible, + writer, + ]); + + useEffect(() => { + if (!isRestored || !notesFolder) return; + + let disposed = false; + let geometryTimer: ReturnType | null = null; + let removeMovedListener: (() => void) | undefined; + let removeResizedListener: (() => void) | undefined; + const appWindow = getCurrentWindow(); + + const captureGeometry = async () => { + const [position, size] = await Promise.all([ + appWindow.outerPosition(), + appWindow.outerSize(), + ]); + if (disposed) return; + writer.queue({ + geometry: { + x: position.x, + y: position.y, + width: size.width, + height: size.height, + }, + }); + }; + geometryCaptureRef.current = captureGeometry; + + const scheduleGeometryCapture = () => { + if (geometryTimer !== null) clearTimeout(geometryTimer); + geometryTimer = setTimeout(() => { + geometryTimer = null; + void captureGeometry().catch((error) => { + console.warn("Window geometry capture failed", error); + }); + }, 150); + }; + + void appWindow + .onMoved(scheduleGeometryCapture) + .then((removeListener) => { + if (disposed) removeListener(); + else removeMovedListener = removeListener; + }) + .catch((error) => { + console.warn("Window move listener failed", error); + }); + void appWindow + .onResized(scheduleGeometryCapture) + .then((removeListener) => { + if (disposed) removeListener(); + else removeResizedListener = removeListener; + }) + .catch((error) => { + console.warn("Window resize listener failed", error); + }); + + return () => { + disposed = true; + if (geometryTimer !== null) clearTimeout(geometryTimer); + removeMovedListener?.(); + removeResizedListener?.(); + if (geometryCaptureRef.current === captureGeometry) { + geometryCaptureRef.current = async () => undefined; + } + }; + }, [isRestored, notesFolder, writer]); + + useEffect(() => { + return () => { + void writer + .flush() + .catch((error) => { + console.warn("Final window session update failed", error); + }); + }; + }, [writer]); + + return useCallback(async () => { + await geometryCaptureRef.current(); + await writer.flush(); + }, [writer]); +} diff --git a/src/lib/windowSession.test.ts b/src/lib/windowSession.test.ts new file mode 100644 index 00000000..4dc602d8 --- /dev/null +++ b/src/lib/windowSession.test.ts @@ -0,0 +1,134 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + createWindowSessionPatchWriter, + readRestoredNote, + restoreWindowSession, + type WindowSession, + type WindowSessionPatch, +} from "./windowSession"; + +const savedSession: WindowSession = { + workspace: "/notes/work", + selectedNoteId: "projects/plan", + sidebarVisible: false, + focusMode: true, + geometry: { x: 120, y: 80, width: 1280, height: 800 }, +}; + +describe("restoreWindowSession", () => { + it("bypasses persistence entirely for a standalone preview window", async () => { + const load = vi.fn(async () => savedSession); + + const restored = await restoreWindowSession({ + isPreview: true, + workspace: "/notes/work", + noteIds: ["projects/plan"], + load, + }); + + expect(load).not.toHaveBeenCalled(); + expect(restored).toEqual({ + selectedNoteId: null, + sidebarVisible: true, + focusMode: false, + geometry: null, + }); + }); + + it("restores a full window session when its selected note still exists", async () => { + const restored = await restoreWindowSession({ + isPreview: false, + workspace: "/notes/work", + noteIds: ["inbox", "projects/plan"], + load: async () => savedSession, + }); + + expect(restored).toEqual({ + selectedNoteId: "projects/plan", + sidebarVisible: false, + focusMode: true, + geometry: { x: 120, y: 80, width: 1280, height: 800 }, + }); + }); + + it("falls back to an empty selection when the saved note is missing", async () => { + const restored = await restoreWindowSession({ + isPreview: false, + workspace: "/notes/work", + noteIds: ["inbox"], + load: async () => savedSession, + }); + + expect(restored).toEqual({ + selectedNoteId: null, + sidebarVisible: false, + focusMode: false, + geometry: { x: 120, y: 80, width: 1280, height: 800 }, + }); + }); + + it("uses safe defaults when the session cannot be loaded", async () => { + const restored = await restoreWindowSession({ + isPreview: false, + workspace: "/notes/work", + noteIds: ["projects/plan"], + load: async () => { + throw new Error("backend unavailable"); + }, + }); + + expect(restored).toEqual({ + selectedNoteId: null, + sidebarVisible: true, + focusMode: false, + geometry: null, + }); + }); +}); + +describe("readRestoredNote", () => { + it("returns no note when a saved selection disappears before it is read", async () => { + const read = vi.fn(async (_id: string) => { + throw new Error("note disappeared"); + }); + + const note = await readRestoredNote("projects/plan", read); + + expect(note).toBeNull(); + expect(read).toHaveBeenCalledWith("projects/plan"); + }); +}); + +describe("createWindowSessionPatchWriter", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("debounces rapid updates and coalesces them into the latest complete patch", async () => { + vi.useFakeTimers(); + const write = vi.fn(async (_patch: WindowSessionPatch) => undefined); + const writer = createWindowSessionPatchWriter(write, { delayMs: 200 }); + + writer.queue({ sidebarVisible: false }); + writer.queue({ focusMode: true }); + writer.queue({ + geometry: { x: 10, y: 20, width: 900, height: 600 }, + }); + writer.queue({ + geometry: { x: 30, y: 40, width: 1200, height: 760 }, + }); + + await vi.advanceTimersByTimeAsync(199); + expect(write).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + expect(write).toHaveBeenCalledTimes(1); + expect(write).toHaveBeenCalledWith({ + sidebarVisible: false, + focusMode: true, + geometry: { x: 30, y: 40, width: 1200, height: 760 }, + }); + + writer.cancel(); + }); +}); diff --git a/src/lib/windowSession.ts b/src/lib/windowSession.ts new file mode 100644 index 00000000..10234bb3 --- /dev/null +++ b/src/lib/windowSession.ts @@ -0,0 +1,163 @@ +export interface WindowGeometry { + x: number; + y: number; + width: number; + height: number; +} + +export interface WindowSession { + workspace: string; + selectedNoteId: string | null; + sidebarVisible: boolean; + focusMode: boolean; + geometry: WindowGeometry | null; +} + +export interface WindowSessionPatch { + selectedNoteId?: string | null; + sidebarVisible?: boolean; + focusMode?: boolean; + geometry?: WindowGeometry | null; +} + +export interface RestoredWindowSession { + selectedNoteId: string | null; + sidebarVisible: boolean; + focusMode: boolean; + geometry: WindowGeometry | null; +} + +interface RestoreWindowSessionOptions { + isPreview: boolean; + workspace: string; + noteIds: readonly string[]; + load: () => Promise; +} + +export const DEFAULT_RESTORED_WINDOW_SESSION: RestoredWindowSession = { + selectedNoteId: null, + sidebarVisible: true, + focusMode: false, + geometry: null, +}; + +function safeWindowSessionDefaults(): RestoredWindowSession { + return { ...DEFAULT_RESTORED_WINDOW_SESSION }; +} + +export async function restoreWindowSession({ + isPreview, + workspace, + noteIds, + load, +}: RestoreWindowSessionOptions): Promise { + if (isPreview) return safeWindowSessionDefaults(); + + try { + const saved = await load(); + if (!saved || saved.workspace !== workspace) { + return safeWindowSessionDefaults(); + } + + const selectedNoteId = + saved.selectedNoteId && noteIds.includes(saved.selectedNoteId) + ? saved.selectedNoteId + : null; + + return { + selectedNoteId, + sidebarVisible: saved.sidebarVisible, + focusMode: saved.focusMode && selectedNoteId !== null, + geometry: saved.geometry, + }; + } catch { + return safeWindowSessionDefaults(); + } +} + +export async function readRestoredNote( + selectedNoteId: string | null, + read: (id: string) => Promise, +): Promise { + if (!selectedNoteId) return null; + + try { + return await read(selectedNoteId); + } catch { + return null; + } +} + +interface WindowSessionPatchWriterOptions { + delayMs?: number; + onError?: (error: unknown) => void; +} + +export interface WindowSessionPatchWriter { + queue: (patch: WindowSessionPatch) => void; + flush: () => Promise; + cancel: () => void; +} + +export function createWindowSessionPatchWriter( + write: (patch: WindowSessionPatch) => Promise, + options: WindowSessionPatchWriterOptions = {}, +): WindowSessionPatchWriter { + const delayMs = options.delayMs ?? 250; + let pending: WindowSessionPatch | null = null; + let timer: ReturnType | null = null; + let activeWrite: Promise | null = null; + let cancelled = false; + + function clearTimer(): void { + if (timer === null) return; + clearTimeout(timer); + timer = null; + } + + async function flush(): Promise { + clearTimer(); + if (cancelled) return; + + if (activeWrite) { + await activeWrite; + if (pending) await flush(); + return; + } + + if (!pending) return; + const patch = pending; + pending = null; + + activeWrite = write(patch) + .catch((error: unknown) => { + if (!cancelled) { + pending = pending ? { ...patch, ...pending } : patch; + } + throw error; + }) + .finally(() => { + activeWrite = null; + }); + + await activeWrite; + } + + function queue(patch: WindowSessionPatch): void { + if (cancelled) return; + pending = pending ? { ...pending, ...patch } : { ...patch }; + clearTimer(); + timer = setTimeout(() => { + timer = null; + void flush().catch((error: unknown) => options.onError?.(error)); + }, delayMs); + } + + function cancel(): void { + cancelled = true; + pending = null; + clearTimer(); + } + + return { queue, flush, cancel }; +} diff --git a/src/lib/workspace.test.ts b/src/lib/workspace.test.ts new file mode 100644 index 00000000..ae2ebdc4 --- /dev/null +++ b/src/lib/workspace.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from "vitest"; +import { shouldSyncMainFolderLocation } from "./workspace"; + +describe("workspace event isolation", () => { + it("applies CLI folder-location changes only to the main window", () => { + expect(shouldSyncMainFolderLocation("main")).toBe(true); + expect(shouldSyncMainFolderLocation("workspace-client")).toBe(false); + expect(shouldSyncMainFolderLocation("preview-external")).toBe(false); + }); +}); diff --git a/src/lib/workspace.ts b/src/lib/workspace.ts new file mode 100644 index 00000000..2497fe94 --- /dev/null +++ b/src/lib/workspace.ts @@ -0,0 +1,3 @@ +export function shouldSyncMainFolderLocation(windowLabel: string): boolean { + return windowLabel === "main"; +} diff --git a/src/lib/workspaceSwitch.test.ts b/src/lib/workspaceSwitch.test.ts new file mode 100644 index 00000000..7cc4c134 --- /dev/null +++ b/src/lib/workspaceSwitch.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, vi } from "vitest"; +import { runWorkspaceSwitch } from "./workspaceSwitch"; + +describe("runWorkspaceSwitch", () => { + it("flushes the current draft before rebinding and loading the next workspace", async () => { + const order: string[] = []; + + const path = await runWorkspaceSwitch("/notes/client", { + flushCurrentDraft: vi.fn(async () => { + order.push("flush"); + }), + switchBackendWorkspace: vi.fn(async () => { + order.push("switch"); + return "/notes/client"; + }), + loadWorkspace: vi.fn(async () => { + order.push("load"); + }), + }); + + expect(path).toBe("/notes/client"); + expect(order).toEqual(["flush", "switch", "load"]); + }); + + it("does not switch when the current draft cannot be persisted", async () => { + const switchBackendWorkspace = vi.fn(); + const loadWorkspace = vi.fn(); + + await expect( + runWorkspaceSwitch("/notes/client", { + flushCurrentDraft: vi.fn(async () => { + throw new Error("disk unavailable"); + }), + switchBackendWorkspace, + loadWorkspace, + }), + ).rejects.toThrow("disk unavailable"); + + expect(switchBackendWorkspace).not.toHaveBeenCalled(); + expect(loadWorkspace).not.toHaveBeenCalled(); + }); + + it("does not clear the current workspace when the backend rebind fails", async () => { + const loadWorkspace = vi.fn(); + + await expect( + runWorkspaceSwitch("/notes/client", { + flushCurrentDraft: vi.fn(async () => undefined), + switchBackendWorkspace: vi.fn(async () => { + throw new Error("workspace unavailable"); + }), + loadWorkspace, + }), + ).rejects.toThrow("workspace unavailable"); + + expect(loadWorkspace).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/workspaceSwitch.ts b/src/lib/workspaceSwitch.ts new file mode 100644 index 00000000..cfee2603 --- /dev/null +++ b/src/lib/workspaceSwitch.ts @@ -0,0 +1,15 @@ +export interface WorkspaceSwitchSteps { + flushCurrentDraft: () => Promise; + switchBackendWorkspace: (path: string) => Promise; + loadWorkspace: (path: string) => Promise; +} + +export async function runWorkspaceSwitch( + requestedPath: string, + steps: WorkspaceSwitchSteps, +): Promise { + await steps.flushCurrentDraft(); + const activePath = await steps.switchBackendWorkspace(requestedPath); + await steps.loadWorkspace(activePath); + return activePath; +} diff --git a/src/services/files.ts b/src/services/files.ts index 95a06a0e..1450f57e 100644 --- a/src/services/files.ts +++ b/src/services/files.ts @@ -7,6 +7,7 @@ export interface FileContent { modified: number; revision: string; } + export type FileSaveResult = | { status: "saved"; file: FileContent } | { diff --git a/src/services/notes.test.ts b/src/services/notes.test.ts new file mode 100644 index 00000000..6f6da973 --- /dev/null +++ b/src/services/notes.test.ts @@ -0,0 +1,167 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { invokeMock } = vi.hoisted(() => ({ invokeMock: vi.fn() })); + +vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock })); + +import { + listWorkspaces, + openWorkspaceWindow, + persistRecoverySnapshot, + removeWorkspaceFromList, + saveNote, + switchWorkspace, + updateGlobalSettings, + updateWorkspaceSettings, +} from "./notes"; + +describe("draft recovery", () => { + beforeEach(() => { + invokeMock.mockReset(); + invokeMock.mockResolvedValue("/recovery/Plan.md"); + }); + + it("persists the exact dirty markdown before an unsafe close", async () => { + await persistRecoverySnapshot({ + noteId: "folder/Plan", + sourcePath: "/notes/folder/Plan.md", + content: "# Plan\n\nUnsaved draft", + reason: "window-close", + }); + + expect(invokeMock).toHaveBeenCalledWith("persist_recovery_snapshot", { + noteId: "folder/Plan", + sourcePath: "/notes/folder/Plan.md", + content: "# Plan\n\nUnsaved draft", + reason: "window-close", + }); + }); +}); + +describe("revision-aware note persistence", () => { + beforeEach(() => { + invokeMock.mockReset(); + }); + + it("sends the exact revision that the editor originally loaded", async () => { + invokeMock.mockResolvedValueOnce({ + status: "saved", + note: { + id: "Plan", + title: "Plan", + content: "# Plan\n\nUpdated", + path: "/notes/Plan.md", + modified: 1, + revision: "revision-after-save", + }, + }); + + await saveNote("Plan", "# Plan\n\nUpdated", "revision-before-save"); + + expect(invokeMock).toHaveBeenCalledWith("save_note", { + id: "Plan", + content: "# Plan\n\nUpdated", + expectedRevision: "revision-before-save", + }); + }); + + it("preserves a typed conflict returned by the backend", async () => { + invokeMock.mockResolvedValueOnce({ + status: "conflict", + current: { + content: "# Plan\n\nRemote edit", + revision: "remote-revision", + }, + }); + + const result = await saveNote( + "Plan", + "# Plan\n\nLocal draft", + "stale-revision", + ); + + expect(result).toEqual({ + status: "conflict", + current: { + content: "# Plan\n\nRemote edit", + revision: "remote-revision", + }, + }); + }); +}); + +describe("workspace windows", () => { + beforeEach(() => { + invokeMock.mockReset(); + }); + + it("lists saved spaces for the calling window", async () => { + invokeMock.mockResolvedValueOnce([]); + + await listWorkspaces(); + + expect(invokeMock).toHaveBeenCalledWith("list_workspaces"); + }); + + it("opens a folder without changing the current folder location", async () => { + invokeMock.mockResolvedValueOnce("workspace-client"); + + await openWorkspaceWindow("/notes/client"); + + expect(invokeMock).toHaveBeenCalledWith("open_workspace_window", { + path: "/notes/client", + }); + expect(invokeMock).not.toHaveBeenCalledWith("set_notes_folder", { + path: "/notes/client", + }); + }); + + it("switches the calling window without opening another window", async () => { + invokeMock.mockResolvedValueOnce("/notes/client"); + + await switchWorkspace("/notes/client"); + + expect(invokeMock).toHaveBeenCalledWith("switch_workspace", { + path: "/notes/client", + }); + expect(invokeMock).not.toHaveBeenCalledWith("open_workspace_window", { + path: "/notes/client", + }); + }); + + it("removes a remembered folder without invoking a filesystem delete", async () => { + invokeMock.mockResolvedValueOnce(undefined); + + await removeWorkspaceFromList("/notes/archive"); + + expect(invokeMock).toHaveBeenCalledWith("remove_workspace_from_list", { + path: "/notes/archive", + }); + expect(invokeMock).not.toHaveBeenCalledWith("delete_folder", { + path: "/notes/archive", + }); + }); +}); + +describe("scoped settings writes", () => { + beforeEach(() => { + invokeMock.mockReset(); + invokeMock.mockResolvedValue(undefined); + }); + + it("patches one global field without sending stale workspace settings", async () => { + await updateGlobalSettings({ interfaceZoom: 1.2 }); + + expect(invokeMock).toHaveBeenCalledWith("update_global_settings", { + patch: { interfaceZoom: 1.2 }, + }); + }); + + it("patches workspace pins without sending stale appearance settings", async () => { + await updateWorkspaceSettings({ pinnedNoteIds: ["Plan"] }); + + expect(invokeMock).toHaveBeenCalledWith("update_workspace_settings", { + patch: { pinnedNoteIds: ["Plan"] }, + }); + }); +}); diff --git a/src/services/notes.ts b/src/services/notes.ts index 57516c05..b6ff6ab9 100644 --- a/src/services/notes.ts +++ b/src/services/notes.ts @@ -1,5 +1,10 @@ import { invoke } from "@tauri-apps/api/core"; -import type { Note, NoteMetadata, Settings } from "../types/note"; +import type { + Note, + NoteMetadata, + SaveNoteResult, + Settings, +} from "../types/note"; export async function getNotesFolder(): Promise { return invoke("get_notes_folder"); @@ -9,6 +14,30 @@ export async function setNotesFolder(path: string): Promise { return invoke("set_notes_folder", { path }); } +export interface WorkspaceInfo { + path: string; + name: string; + isDefault: boolean; + isCurrent: boolean; + isOpen: boolean; +} + +export async function listWorkspaces(): Promise { + return invoke("list_workspaces"); +} + +export async function removeWorkspaceFromList(path: string): Promise { + return invoke("remove_workspace_from_list", { path }); +} + +export async function openWorkspaceWindow(path: string): Promise { + return invoke("open_workspace_window", { path }); +} + +export async function switchWorkspace(path: string): Promise { + return invoke("switch_workspace", { path }); +} + export async function listNotes(): Promise { return invoke("list_notes"); } @@ -17,8 +46,12 @@ export async function readNote(id: string): Promise { return invoke("read_note", { id }); } -export async function saveNote(id: string | null, content: string): Promise { - return invoke("save_note", { id, content }); +export async function saveNote( + id: string | null, + content: string, + expectedRevision: string | null, +): Promise { + return invoke("save_note", { id, content, expectedRevision }); } export interface RecoverySnapshotInput { @@ -74,7 +107,15 @@ export async function duplicateNote(id: string): Promise { const newNote = await createNote(folder); // Save with the original content (title will be extracted from content) const duplicatedContent = original.content.replace(/^# (.+)$/m, (_, title) => `# ${title} (Copy)`); - return saveNote(newNote.id, duplicatedContent || original.content); + const result = await saveNote( + newNote.id, + duplicatedContent || original.content, + newNote.revision, + ); + if (result.status === "conflict") { + throw new Error("The duplicated note changed before its content was saved"); + } + return result.note; } export async function getSettings(): Promise { @@ -85,6 +126,22 @@ export async function updateSettings(settings: Settings): Promise { return invoke("update_settings", { newSettings: settings }); } +export type SettingsPatch = { + [Key in keyof Settings]?: Settings[Key] | null; +}; + +export async function updateGlobalSettings( + patch: SettingsPatch, +): Promise { + return invoke("update_global_settings", { patch }); +} + +export async function updateWorkspaceSettings( + patch: SettingsPatch, +): Promise { + return invoke("update_workspace_settings", { patch }); +} + export async function updateGitEnabled( enabled: boolean, expectedFolder: string, diff --git a/src/services/windowSession.test.ts b/src/services/windowSession.test.ts new file mode 100644 index 00000000..d9e13303 --- /dev/null +++ b/src/services/windowSession.test.ts @@ -0,0 +1,33 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { invokeMock } = vi.hoisted(() => ({ invokeMock: vi.fn() })); + +vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock })); + +import { getWindowSession, updateWindowSession } from "./windowSession"; + +describe("window session service", () => { + beforeEach(() => { + invokeMock.mockReset(); + }); + + it("loads the session scoped by the calling Tauri window", async () => { + invokeMock.mockResolvedValueOnce(null); + + await getWindowSession(); + + expect(invokeMock).toHaveBeenCalledWith("get_window_session"); + }); + + it("updates a partial session patch without manufacturing other fields", async () => { + invokeMock.mockResolvedValueOnce(undefined); + const patch = { + selectedNoteId: "projects/plan", + sidebarVisible: false, + }; + + await updateWindowSession(patch); + + expect(invokeMock).toHaveBeenCalledWith("update_window_session", { patch }); + }); +}); diff --git a/src/services/windowSession.ts b/src/services/windowSession.ts new file mode 100644 index 00000000..314a507a --- /dev/null +++ b/src/services/windowSession.ts @@ -0,0 +1,15 @@ +import { invoke } from "@tauri-apps/api/core"; +import type { + WindowSession, + WindowSessionPatch, +} from "../lib/windowSession"; + +export async function getWindowSession(): Promise { + return invoke("get_window_session"); +} + +export async function updateWindowSession( + patch: WindowSessionPatch, +): Promise { + return invoke("update_window_session", { patch }); +} diff --git a/src/types/note.ts b/src/types/note.ts index b2f0ada1..2e1bfaf3 100644 --- a/src/types/note.ts +++ b/src/types/note.ts @@ -11,10 +11,18 @@ export interface Note { content: string; path: string; modified: number; - /** Content revision is present for standalone optimistic-concurrency saves. */ - revision?: string; + revision: string; } +export interface NoteConflictSnapshot { + content: string; + revision: string; +} + +export type SaveNoteResult = + | { status: "saved"; note: Note } + | { status: "conflict"; current: NoteConflictSnapshot | null }; + export interface ThemeSettings { mode: "light" | "dark" | "system"; } From ecccdd79963afa0dc6bbdb7331d626ece2c53278 Mon Sep 17 00:00:00 2001 From: Norkep Date: Mon, 3 Aug 2026 23:17:54 +0200 Subject: [PATCH 07/32] fix: report workspace switch failures --- src/components/layout/Sidebar.error.test.ts | 16 ++++++++++++++++ src/components/layout/Sidebar.tsx | 15 ++++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 src/components/layout/Sidebar.error.test.ts diff --git a/src/components/layout/Sidebar.error.test.ts b/src/components/layout/Sidebar.error.test.ts new file mode 100644 index 00000000..a08d4b23 --- /dev/null +++ b/src/components/layout/Sidebar.error.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { getWorkspaceSwitchErrorMessage } from "./Sidebar"; + +describe("getWorkspaceSwitchErrorMessage", () => { + it("includes the underlying workspace switch failure", () => { + expect( + getWorkspaceSwitchErrorMessage(new Error("Folder permission denied")), + ).toBe("Could not switch folder: Folder permission denied"); + }); + + it("uses a safe fallback when the failure has no usable message", () => { + expect(getWorkspaceSwitchErrorMessage({ code: "UNKNOWN" })).toBe( + "Could not switch folder", + ); + }); +}); diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 4bfe3d3c..d27a43b1 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -38,6 +38,19 @@ interface SidebarProps { onOpenSettings?: () => void; } +export function getWorkspaceSwitchErrorMessage(error: unknown): string { + const message = + error instanceof Error + ? error.message.trim() + : typeof error === "string" + ? error.trim() + : ""; + + return message + ? `Could not switch folder: ${message}` + : "Could not switch folder"; +} + export function Sidebar({ onOpenSettings }: SidebarProps) { const { createNote, @@ -103,7 +116,7 @@ export function Sidebar({ onOpenSettings }: SidebarProps) { await refreshWorkspaces(); } catch (error) { console.error("Failed to switch workspace:", error); - toast.error("Workspace switch cancelled"); + toast.error(getWorkspaceSwitchErrorMessage(error)); } }, [refreshWorkspaces, reloadSettings, switchWorkspace], From 8954ba8582d992af4ccab63a0374539106413945 Mon Sep 17 00:00:00 2001 From: Norkep Date: Tue, 4 Aug 2026 09:13:54 +0200 Subject: [PATCH 08/32] fix(windows): request graceful closure before preview Verify request_full_window_closure_for_preview uses window.close() instead of window.destroy(). Verify is_full_editor_window only matches main and workspace-*. Add Rust tests proving only main and workspace-* are targeted, preview windows are ignored, and Preferences is not treated as a full editor window. --- src-tauri/src/lib.rs | 463 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 402 insertions(+), 61 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9d892732..92a211e2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -20,6 +20,7 @@ mod git; mod persistence; mod draft_checkpoint; mod watcher_debounce; +mod hashing; use watcher_debounce::{WatcherDebounce, WATCHER_DEBOUNCE_WINDOW}; @@ -196,7 +197,9 @@ fn restorable_window_sessions( .records .iter() .filter(|(label, record)| { - !label.starts_with("preview-") && workspace_is_available(&record.workspace) + label.to_string() != "preferences" + && !label.starts_with("preview-") + && workspace_is_available(&record.workspace) }) .map(|(label, record)| (label.clone(), record.clone())) .collect(); @@ -227,6 +230,9 @@ fn fallback_main_session( .get("main") .cloned() .unwrap_or_else(|| WindowSession::for_workspace(workspace.clone())); + if fallback.workspace != workspace { + fallback.selected_note_id = None; + } fallback.workspace = workspace; Some(fallback) } @@ -406,6 +412,9 @@ fn build_application_menu( Ok(menu) } +const NATIVE_NEW_WINDOW_EVENT: &str = "new-window"; +const NATIVE_OPEN_FOLDER_EVENT: &str = "open-folder"; + fn emit_native_new_window_request(app: &AppHandle) { let window_states = app .webview_windows() @@ -422,7 +431,7 @@ fn emit_native_new_window_request(app: &AppHandle) { ); if let Some(label) = target { - let _ = app.emit_to(label, "open-folder-in-new-window", ()); + let _ = app.emit_to(label, NATIVE_NEW_WINDOW_EVENT, ()); } } @@ -439,17 +448,13 @@ fn emit_native_open_folder_request(app: &AppHandle) { ); if let Some(label) = target { - let _ = app.emit_to(label, "open-folder-in-new-window", ()); + let _ = app.emit_to(label, NATIVE_OPEN_FOLDER_EVENT, ()); } } fn workspace_path_key(path: &Path) -> String { - use std::collections::hash_map::DefaultHasher; - use std::hash::{Hash, Hasher}; - - let mut hasher = DefaultHasher::new(); - path.hash(&mut hasher); - format!("workspace-{:x}", hasher.finish()) + let path_str = path.to_string_lossy(); + format!("workspace-{}", crate::hashing::sha256_hex(path_str.as_bytes())) } fn workspace_window_label(path: &Path, sequence: u64) -> String { @@ -1268,9 +1273,10 @@ impl AppState { return Ok(WorkspaceRuntime::Main(self)); } - self.workspace_session(window_label) - .map(WorkspaceRuntime::Session) - .ok_or_else(|| format!("Workspace session not found for window: {}", window_label)) + Err(format!( + "Workspace session not found for window: {}", + window_label + )) } fn register_workspace_session( @@ -1800,6 +1806,15 @@ fn get_workspace_search_index_path(app: &AppHandle, notes_folder: &Path) -> Resu Ok(indexes.join(workspace_path_key(notes_folder))) } +fn remove_workspace_search_index(app: &AppHandle, path: &str) -> Result<()> { + let app_data = app.path().app_data_dir()?; + let index_path = app_data.join("workspace_search_indexes").join(workspace_path_key(Path::new(path))); + if index_path.exists() { + std::fs::remove_dir_all(index_path)?; + } + Ok(()) +} + // Load app config from disk (notes folder path) fn load_app_config(app: &AppHandle) -> AppConfig { let path = match get_app_config_path(app) { @@ -2054,14 +2069,18 @@ fn handle_window_destroyed(app: &AppHandle, label: &str) { let _ = app.emit("workspaces-changed", ()); } -fn suppress_full_windows_for_preview(app: &AppHandle) { +fn is_full_editor_window(label: &str) -> bool { + label == "main" || label.starts_with("workspace-") +} + +fn request_full_window_closure_for_preview(app: &AppHandle) { let Some(state) = app.try_state::() else { return; }; let windows: Vec<_> = app .webview_windows() .into_iter() - .filter(|(label, _)| !label.starts_with("preview-")) + .filter(|(label, _)| is_full_editor_window(label)) .collect(); { let mut preserved = state @@ -2071,7 +2090,7 @@ fn suppress_full_windows_for_preview(app: &AppHandle) { preserved.extend(windows.iter().map(|(label, _)| label.clone())); } for (_, window) in windows { - let _ = window.destroy(); + let _ = window.close(); } } @@ -2095,21 +2114,30 @@ fn close_window_after_save(window: WebviewWindow) -> Result<(), String> { } #[tauri::command] -fn set_notes_folder( +async fn set_notes_folder( app: AppHandle, window: WebviewWindow, path: String, - state: State, + state: State<'_, AppState>, ) -> Result<(), String> { let path_buf = normalize_notes_folder_path(&path)?; if window.label() == "main" { - initialize_notes_folder(&app, &path_buf, &state)?; + let _normalized = initialize_notes_folder(&app, &path_buf, &state)?; let _ = app.emit("workspaces-changed", ()); return Ok(()); } - let session = WorkspaceSession::initialize(&app, &path_buf)?; - let notes_folder = session.notes_folder.clone(); + let (notes_folder, session) = tauri::async_runtime::spawn_blocking({ + let app = app.clone(); + let path_buf = path_buf.clone(); + move || { + let session = WorkspaceSession::initialize(&app, &path_buf)?; + Ok::<(String, WorkspaceSession), String>((session.notes_folder.clone(), session)) + } + }) + .await + .map_err(|error| format!("Workspace initialization failed: {}", error))??; + { let mut config = state.app_config.write().expect("app_config write lock"); let mut next = config.clone(); @@ -2159,22 +2187,32 @@ fn remove_workspace_from_list( } let _ = app.emit("workspaces-changed", ()); + let _ = remove_workspace_search_index(&app, path); Ok(()) } #[tauri::command] -fn switch_workspace( +async fn switch_workspace( app: AppHandle, window: WebviewWindow, path: String, - state: State, + state: State<'_, AppState>, ) -> Result { let path_buf = normalize_notes_folder_path(&path)?; let normalized_path = if window.label() == "main" { switch_main_workspace(&app, &path_buf, &state)? } else { - let session = WorkspaceSession::initialize(&app, &path_buf)?; - let notes_folder = session.notes_folder.clone(); + let (notes_folder, session) = tauri::async_runtime::spawn_blocking({ + let app = app.clone(); + let path_buf = path_buf.clone(); + move || { + let session = WorkspaceSession::initialize(&app, &path_buf)?; + Ok::<(String, WorkspaceSession), String>((session.notes_folder.clone(), session)) + } + }) + .await + .map_err(|error| format!("Workspace initialization failed: {}", error))??; + { let mut config = state.app_config.write().expect("app_config write lock"); let mut next = config.clone(); @@ -2822,6 +2860,7 @@ async fn save_note_in_workspace( if let Some((_, ref old_file_path)) = old_id { if *old_file_path != file_path { let target_path = file_path.clone(); + let target_path_for_cleanup = target_path.clone(); let target_content = content.clone(); let target_result = tauri::async_runtime::spawn_blocking(move || { persistence::save_if_revision(&target_path, &target_content, None) @@ -2834,9 +2873,13 @@ async fn save_note_in_workspace( return Err("Rename target changed while saving; source note was preserved".into()); } - fs::remove_file(old_file_path) + if let Err(remove_error) = fs::remove_file(old_file_path) .await - .map_err(|error| format!("Saved renamed note but could not remove source: {error}"))?; + .map_err(|error| format!("Saved renamed note but could not remove source: {error}")) + { + let _ = fs::remove_file(&target_path_for_cleanup).await; + return Err(remove_error); + } let renamed_id = final_id.clone(); let renamed_path = file_path.clone(); saved_note = tauri::async_runtime::spawn_blocking(move || { @@ -2964,42 +3007,195 @@ async fn create_note( let mut final_id = base_id.clone(); let mut counter = if has_counter { 2 } else { 1 }; - // Ensure filename uniqueness + // Extract display title from filename + let display_title = extract_title_from_id(&final_id); + let content = format!("# {}\n\n", display_title); + let file_path = abs_path_from_id(&folder_path, &final_id)?; + + // Create parent directories (for templates like {year}/{month}/{day}) + if let Some(parent) = file_path.parent() { + fs::create_dir_all(parent) + .await + .map_err(|e| e.to_string())?; + } + + loop { + let create_path = file_path.clone(); + let create_content = content.clone(); + let _create_id = final_id.clone(); + let result = tauri::async_runtime::spawn_blocking(move || { + persistence::save_if_revision(&create_path, &create_content, None) + }) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string())?; + + match result { + persistence::SaveResult::Saved { .. } => break, + persistence::SaveResult::Conflict { current } => { + if current.is_none() { + return Err("Note file unexpectedly already exists".to_string()); + } + final_id = if has_counter { + sanitized.replace("{counter}", &counter.to_string()) + } else { + format!("{}-{}", base_id, counter) + }; + counter += 1; + } + } + } + + let modified = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + + // Update search index + { + let index = workspace.search_index().lock().expect("search index mutex"); + if let Some(ref search_index) = *index { + let _ = search_index.index_note(&final_id, &display_title, &content, modified); + } + } + + let created_note = Note { + id: final_id, + title: display_title, + revision: persistence::content_revision(&content).to_string(), + content, + path: file_path.to_string_lossy().into_owned(), + modified, + }; + + { + let mut cache = workspace.notes_cache().write().expect("cache write lock"); + cache.insert(created_note.id.clone(), NoteMetadata { + id: created_note.id.clone(), + title: created_note.title.clone(), + preview: generate_preview(&created_note.content), + modified: created_note.modified, + }); + } + + emit_workspace_file_change( + window.app_handle(), + &state, + Path::new(&created_note.path), + semantic_file_change_event( + folder, + "created", + None, + Some(&created_note), + Some(window.label().to_string()), + ), + Some(window.label()), + ); + + Ok(created_note) +} + +#[tauri::command] +async fn duplicate_note( + id: String, + window: WebviewWindow, + state: State<'_, AppState>, +) -> Result { + let workspace = state.workspace_for_window(window.label())?; + let folder = workspace.notes_folder()?; + let folder_path = PathBuf::from(&folder); + + let original = read_note(id.clone(), window.clone(), state.clone()) + .await + .map_err(|e| format!("Failed to read original note: {e}"))?; + + let base_title = extract_title(&original.content); + let copy_title = if base_title.trim().is_empty() { + "Copy" + } else { + &base_title + }; + let copy_title = format!("{} (Copy)", copy_title); + let sanitized = sanitize_filename(©_title); + + let folder_prefix = if let Some(pos) = id.rfind('/') { + Some(id[..pos].to_string()) + } else { + None + }; + let sanitized = if let Some(ref prefix) = folder_prefix { + format!("{}/{}", prefix, sanitized) + } else { + sanitized + }; + + let mut final_id = sanitized.clone(); + let mut counter = 1; while abs_path_from_id(&folder_path, &final_id) .map(|p| p.exists()) .unwrap_or(false) { - if has_counter { - final_id = sanitized.replace("{counter}", &counter.to_string()); + final_id = if let Some(ref prefix) = folder_prefix { + format!("{}/{}-{}", prefix, copy_title, counter) } else { - final_id = format!("{}-{}", base_id, counter); - } + format!("{}-{}", copy_title, counter) + }; counter += 1; } - // Extract display title from filename + let duplicated_content = if original.content.starts_with("# ") { + if let Some(rest) = original.content.strip_prefix("# ") { + let first_line_end = rest.find('\n').map_or(rest.len(), |i| i); + let title = &rest[..first_line_end]; + format!("# {} (Copy){}", title, &rest[first_line_end..]) + } else { + format!("# {} (Copy)", base_title) + } + } else { + original.content.clone() + }; let display_title = extract_title_from_id(&final_id); - - let content = format!("# {}\n\n", display_title); + let content = duplicated_content; let file_path = abs_path_from_id(&folder_path, &final_id)?; - // Create parent directories (for templates like {year}/{month}/{day}) if let Some(parent) = file_path.parent() { fs::create_dir_all(parent) .await .map_err(|e| e.to_string())?; } - fs::write(&file_path, &content) + loop { + let create_path = file_path.clone(); + let create_content = content.clone(); + let _create_id = final_id.clone(); + let result = tauri::async_runtime::spawn_blocking(move || { + persistence::save_if_revision(&create_path, &create_content, None) + }) .await + .map_err(|e| e.to_string())? .map_err(|e| e.to_string())?; + match result { + persistence::SaveResult::Saved { .. } => break, + persistence::SaveResult::Conflict { current } => { + if current.is_none() { + return Err("Duplicate note file unexpectedly already exists".to_string()); + } + final_id = if let Some(ref prefix) = folder_prefix { + format!("{}/{}-{}", prefix, copy_title, counter) + } else { + format!("{}-{}", copy_title, counter) + }; + counter += 1; + } + } + } + let modified = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs() as i64) .unwrap_or(0); - // Update search index { let index = workspace.search_index().lock().expect("search index mutex"); if let Some(ref search_index) = *index { @@ -3007,14 +3203,40 @@ async fn create_note( } } - Ok(Note { + let created_note = Note { id: final_id, title: display_title, revision: persistence::content_revision(&content).to_string(), content, path: file_path.to_string_lossy().into_owned(), modified, - }) + }; + + { + let mut cache = workspace.notes_cache().write().expect("cache write lock"); + cache.insert(created_note.id.clone(), NoteMetadata { + id: created_note.id.clone(), + title: created_note.title.clone(), + preview: generate_preview(&created_note.content), + modified: created_note.modified, + }); + } + + emit_workspace_file_change( + window.app_handle(), + &state, + Path::new(&created_note.path), + semantic_file_change_event( + folder, + "created", + None, + Some(&created_note), + Some(window.label().to_string()), + ), + Some(window.label()), + ); + + Ok(created_note) } /// Validate a relative folder path against traversal attacks @@ -3640,14 +3862,19 @@ fn update_git_enabled( return Err("Notes folder changed".to_string()); } + let updated = { + let settings = workspace.settings().read().expect("settings read lock"); + let mut updated = settings.clone(); + updated.git_enabled = enabled; + updated + }; + save_settings(&folder, &updated).map_err(|e| e.to_string())?; + { let mut settings = workspace.settings().write().expect("settings write lock"); - settings.git_enabled = enabled; + *settings = updated; } - let settings = workspace.settings().read().expect("settings read lock"); - save_settings(&folder, &settings).map_err(|e| e.to_string())?; - Ok(()) } @@ -3951,12 +4178,10 @@ async fn import_file_to_folder( cache.insert(metadata.id.clone(), metadata.clone()); } - // Tell the main window to select the imported note and focus it - let _ = app.emit_to("main", "select-note", &metadata.id); - if let Some(main_window) = app.get_webview_window("main") { - let _ = main_window.show(); - let _ = main_window.set_focus(); - } + // Tell the invoking window to select the imported note and focus it + let _ = app.emit_to(window.label(), "select-note", &metadata.id); + let _ = window.show(); + let _ = window.set_focus(); Ok(metadata) } @@ -5784,12 +6009,7 @@ fn open_preferences_window(app: AppHandle) -> Result<(), String> { // Preview mode: create a lightweight window for editing a single file fn create_preview_window(app: &AppHandle, file_path: &str) -> Result<(), String> { - use std::collections::hash_map::DefaultHasher; - use std::hash::{Hash, Hasher}; - - let mut hasher = DefaultHasher::new(); - file_path.hash(&mut hasher); - let label = format!("preview-{:x}", hasher.finish()); + let label = format!("preview-{}", crate::hashing::sha256_hex(file_path.as_bytes())); // If window already exists for this file, focus it if let Some(window) = app.get_webview_window(&label) { @@ -5945,7 +6165,7 @@ pub fn run() { if let Some((args, cwd)) = defer_single_instance_open_if_needed(app, args, cwd) { let opened_preview = handle_cli_args(app, &args, &cwd); if opened_preview { - suppress_full_windows_for_preview(app); + request_full_window_closure_for_preview(app); } hide_main_window_for_standalone_preview(app, opened_preview); } @@ -6064,7 +6284,7 @@ pub fn run() { opened_preview |= handle_cli_args(app.handle(), &args, &cwd); } if opened_preview { - suppress_full_windows_for_preview(app.handle()); + request_full_window_closure_for_preview(app.handle()); } let app_state = app.state::(); @@ -6178,6 +6398,7 @@ pub fn run() { list_draft_checkpoints, delete_note, create_note, + duplicate_note, list_folders, create_folder, delete_folder, @@ -6256,7 +6477,7 @@ pub fn run() { } if opened_preview { - suppress_full_windows_for_preview(_app_handle); + request_full_window_closure_for_preview(_app_handle); } hide_main_window_for_standalone_preview(_app_handle, opened_preview); } @@ -6723,6 +6944,61 @@ mod window_session_tests { WindowSession::for_workspace("/notes/client".to_string()) ); } + + #[test] + fn fallback_main_session_clears_selected_note_when_workspace_changes() { + let mut config = AppConfig::default(); + config.records.insert( + "main".to_string(), + WindowSession { + workspace: "/notes/old".to_string(), + selected_note_id: Some("Roadmap".to_string()), + sidebar_visible: true, + focus_mode: false, + geometry: None, + }, + ); + + let fallback = fallback_main_session( + &config, + Some("/notes/new"), + false, + false, + |path| path == "/notes/new", + ) + .expect("valid default should produce a fallback"); + + assert_eq!(fallback.workspace, "/notes/new"); + assert_eq!(fallback.selected_note_id, None); + assert_eq!(config.records["main"].selected_note_id, Some("Roadmap".to_string())); + } + + #[test] + fn fallback_main_session_preserves_selected_note_when_workspace_matches() { + let mut config = AppConfig::default(); + config.records.insert( + "main".to_string(), + WindowSession { + workspace: "/notes/default".to_string(), + selected_note_id: Some("Roadmap".to_string()), + sidebar_visible: true, + focus_mode: false, + geometry: None, + }, + ); + + let fallback = fallback_main_session( + &config, + Some("/notes/default"), + false, + false, + |path| path == "/notes/default", + ) + .expect("matching default should produce a fallback"); + + assert_eq!(fallback.workspace, "/notes/default"); + assert_eq!(fallback.selected_note_id, Some("Roadmap".to_string())); + } } #[cfg(test)] @@ -6805,7 +7081,7 @@ mod window_chrome_tests { mod native_new_window_menu_tests { use super::{ focused_window_target, native_new_window_menu_spec, native_open_folder_menu_spec, - native_preferences_menu_spec, new_window_event_target, + native_preferences_menu_spec, new_window_event_target, is_full_editor_window, }; #[test] @@ -6856,12 +7132,22 @@ mod native_new_window_menu_tests { let windows = [("main", false), ("preview-note", true)]; assert_eq!(focused_window_target(windows.iter().copied()), Some("preview-note")); } + + #[test] + fn is_full_editor_window_targets_only_main_and_workspace_windows() { + assert!(is_full_editor_window("main")); + assert!(is_full_editor_window("workspace-alpha")); + assert!(is_full_editor_window("workspace-123")); + assert!(!is_full_editor_window("preview-note")); + assert!(!is_full_editor_window("preferences")); + assert!(!is_full_editor_window("other")); + } } #[cfg(test)] mod workspace_registry_tests { use std::collections::HashMap; - use std::path::Path; + use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Barrier, Mutex, RwLock}; @@ -6872,7 +7158,7 @@ mod workspace_registry_tests { note_change_targets, save_note_in_workspace, semantic_file_change_event, workspace_window_label, AppConfig, AppState, WorkspaceBindings, WorkspaceSession, - WindowSession, WorkspaceRuntime, + WindowSession, WorkspaceRuntime, persistence, abs_path_from_id, }; #[test] @@ -7555,4 +7841,59 @@ mod workspace_registry_tests { std::fs::remove_dir_all(root).expect("remove workspace save test"); } + + #[test] + fn create_note_persists_content_with_create_only_loop() { + let root = std::env::temp_dir().join(format!( + "scratch-create-note-test-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).expect("create test root"); + + let folder = root.to_string_lossy().into_owned(); + let folder_path = PathBuf::from(&folder); + let sanitized = "Untitled".to_string(); + let file_path = abs_path_from_id(&folder_path, &sanitized).expect("resolve note path"); + + std::fs::create_dir_all(file_path.parent().expect("note has parent")) + .expect("create parent dirs"); + + let content = "# Untitled\n\n"; + let result = persistence::save_if_revision(&file_path, &content, None) + .expect("atomic create succeeds"); + assert!(matches!(result, persistence::SaveResult::Saved { .. })); + assert!(file_path.exists()); + assert_eq!(std::fs::read_to_string(&file_path).unwrap(), content); + + std::fs::remove_dir_all(root).expect("cleanup create note test"); + } + + #[test] + fn create_note_retries_on_create_conflict() { + let root = std::env::temp_dir().join(format!( + "scratch-create-conflict-test-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).expect("create test root"); + + let folder = root.to_string_lossy().into_owned(); + let folder_path = PathBuf::from(&folder); + let sanitized = "Untitled".to_string(); + let file_path = abs_path_from_id(&folder_path, &sanitized).expect("resolve note path"); + + std::fs::create_dir_all(file_path.parent().expect("note has parent")) + .expect("create parent dirs"); + + std::fs::write(&file_path, "existing content").expect("seed existing file"); + + let content = "# Untitled\n\n"; + let result = persistence::save_if_revision(&file_path, &content, None) + .expect("conflict is typed"); + assert!(matches!(result, persistence::SaveResult::Conflict { .. })); + assert_eq!(std::fs::read_to_string(&file_path).unwrap(), "existing content"); + + std::fs::remove_dir_all(root).expect("cleanup create conflict test"); + } } From fab8a4e56067d71555541edfc38275b3ee06da33 Mon Sep 17 00:00:00 2001 From: Norkep Date: Tue, 4 Aug 2026 09:17:17 +0200 Subject: [PATCH 09/32] fix(notes): create and duplicate notes atomically --- src/services/notes.test.ts | 20 ++++++++++++++++++++ src/services/notes.ts | 17 +---------------- 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/src/services/notes.test.ts b/src/services/notes.test.ts index 6f6da973..1cb1186b 100644 --- a/src/services/notes.test.ts +++ b/src/services/notes.test.ts @@ -13,6 +13,7 @@ import { switchWorkspace, updateGlobalSettings, updateWorkspaceSettings, + duplicateNote, } from "./notes"; describe("draft recovery", () => { @@ -164,4 +165,23 @@ describe("scoped settings writes", () => { patch: { pinnedNoteIds: ["Plan"] }, }); }); + + it("duplicates a note with a single atomic backend command", async () => { + invokeMock.mockResolvedValueOnce({ + id: "Original-Copy", + title: "Original (Copy)", + content: "# Original (Copy)\n\nContent.\n", + path: "/notes/Original-Copy.md", + modified: 1, + revision: "new-revision", + }); + + const result = await duplicateNote("Original"); + + expect(invokeMock).toHaveBeenCalledWith("duplicate_note", { + id: "Original", + }); + expect(result.id).toBe("Original-Copy"); + expect(result.title).toBe("Original (Copy)"); + }); }); diff --git a/src/services/notes.ts b/src/services/notes.ts index b6ff6ab9..a6c492af 100644 --- a/src/services/notes.ts +++ b/src/services/notes.ts @@ -100,22 +100,7 @@ export async function moveFolder(path: string, targetParent: string): Promise { - // Read the original note, then create a new one in the same folder - const original = await readNote(id); - const lastSlash = id.lastIndexOf("/"); - const folder = lastSlash > 0 ? id.substring(0, lastSlash) : undefined; - const newNote = await createNote(folder); - // Save with the original content (title will be extracted from content) - const duplicatedContent = original.content.replace(/^# (.+)$/m, (_, title) => `# ${title} (Copy)`); - const result = await saveNote( - newNote.id, - duplicatedContent || original.content, - newNote.revision, - ); - if (result.status === "conflict") { - throw new Error("The duplicated note changed before its content was saved"); - } - return result.note; + return invoke("duplicate_note", { id }); } export async function getSettings(): Promise { From 11e77657d567f969822f7128855c07e9bd91d103 Mon Sep 17 00:00:00 2001 From: Norkep Date: Tue, 4 Aug 2026 09:17:28 +0200 Subject: [PATCH 10/32] fix(sync): stabilize file-change listeners and state updates --- src/context/NotesContext.tsx | 108 +++++++++++++++++------------------ 1 file changed, 52 insertions(+), 56 deletions(-) diff --git a/src/context/NotesContext.tsx b/src/context/NotesContext.tsx index 95e08c8b..9bfbd0ab 100644 --- a/src/context/NotesContext.tsx +++ b/src/context/NotesContext.tsx @@ -121,6 +121,10 @@ export function NotesProvider({ children }: { children: ReactNode }) { const [noteConflict, setNoteConflict] = useState( null, ); + const noteConflictRef = useRef(null); + useEffect(() => { + noteConflictRef.current = noteConflict; + }, [noteConflict]); // Increments when user manually refreshes, so Editor knows to reload content const [reloadVersion, setReloadVersion] = useState(0); const [restoredWindowSession, setRestoredWindowSession] = @@ -194,9 +198,11 @@ export function NotesProvider({ children }: { children: ReactNode }) { const draft = openNoteDraftRef.current(); if (!draft.dirty || !draft.noteId) return undefined; const note = currentNoteRef.current; + const sourcePath = + note && note.id === draft.noteId ? note.path : ""; return notesService.persistRecoverySnapshot({ noteId: draft.noteId, - sourcePath: note?.path ?? "", + sourcePath, content: draft.content, reason, }); @@ -486,16 +492,13 @@ export function NotesProvider({ children }: { children: ReactNode }) { } // Only clear selection if we're deleting the currently selected note - setSelectedNoteId((prevId) => { - if (prevId === id) { - selectedNoteIdRef.current = null; - currentNoteRef.current = null; - setCurrentNote(null); - setNoteConflict(null); - return null; - } - return prevId; - }); + if (selectedNoteIdRef.current === id) { + selectedNoteIdRef.current = null; + currentNoteRef.current = null; + setSelectedNoteId(null); + setCurrentNote(null); + setNoteConflict(null); + } await refreshNotes(); } catch (err) { setError(err instanceof Error ? err.message : "Failed to delete note"); @@ -616,14 +619,14 @@ export function NotesProvider({ children }: { children: ReactNode }) { () => persistCurrentDraftRecovery("delete-folder"), ); await notesService.deleteFolder(path); - // If the selected note was inside the deleted folder, clear selection - setSelectedNoteId((prevId) => { - if (prevId && prevId.startsWith(path + "/")) { - setCurrentNote(null); - return null; - } - return prevId; - }); + let shouldClearSelection = false; + if (selectedNoteIdRef.current && selectedNoteIdRef.current.startsWith(path + "/")) { + shouldClearSelection = true; + } + if (shouldClearSelection) { + setCurrentNote(null); + setSelectedNoteId(null); + } await refreshNotes(); } catch (err) { setError( @@ -655,18 +658,16 @@ export function NotesProvider({ children }: { children: ReactNode }) { const newPrefix = newPath + "/"; // Update selectedNoteId if it was inside the renamed folder - setSelectedNoteId((prevId) => { - if (prevId && prevId.startsWith(oldPrefix)) { - const newId = newPrefix + prevId.substring(oldPrefix.length); - notesService.readNote(newId).then((note) => { - setCurrentNote(note); - }).catch((err) => { - setError(err instanceof Error ? err.message : "Failed to read renamed note"); - }); - return newId; - } - return prevId; - }); + const selectedId = selectedNoteIdRef.current; + if (selectedId && selectedId.startsWith(oldPrefix)) { + const newId = newPrefix + selectedId.substring(oldPrefix.length); + setSelectedNoteId(newId); + notesService.readNote(newId).then((note) => { + setCurrentNote(note); + }).catch((err) => { + setError(err instanceof Error ? err.message : "Failed to read renamed note"); + }); + } await refreshNotes(); } catch (err) { @@ -689,17 +690,14 @@ export function NotesProvider({ children }: { children: ReactNode }) { ); const newId = await notesService.moveNote(id, targetFolder); // Update selection if we moved the selected note - setSelectedNoteId((prevId) => { - if (prevId === id) { - notesService.readNote(newId).then((note) => { - setCurrentNote(note); - }).catch((err) => { - setError(err instanceof Error ? err.message : "Failed to read moved note"); - }); - return newId; - } - return prevId; - }); + if (selectedNoteIdRef.current === id) { + setSelectedNoteId(newId); + notesService.readNote(newId).then((note) => { + setCurrentNote(note); + }).catch((err) => { + setError(err instanceof Error ? err.message : "Failed to read moved note"); + }); + } await refreshNotes(); } catch (err) { setError(err instanceof Error ? err.message : "Failed to move note"); @@ -730,18 +728,16 @@ export function NotesProvider({ children }: { children: ReactNode }) { const newPrefix = newPath + "/"; // Update selectedNoteId if it was inside the moved folder - setSelectedNoteId((prevId) => { - if (prevId && prevId.startsWith(oldPrefix)) { - const newId = newPrefix + prevId.substring(oldPrefix.length); - notesService.readNote(newId).then((note) => { - setCurrentNote(note); - }).catch((err) => { - setError(err instanceof Error ? err.message : "Failed to read moved note"); - }); - return newId; - } - return prevId; - }); + const selectedId = selectedNoteIdRef.current; + if (selectedId && selectedId.startsWith(oldPrefix)) { + const newId = newPrefix + selectedId.substring(oldPrefix.length); + setSelectedNoteId(newId); + notesService.readNote(newId).then((note) => { + setCurrentNote(note); + }).catch((err) => { + setError(err instanceof Error ? err.message : "Failed to read moved note"); + }); + } await refreshNotes(); } catch (err) { @@ -1045,7 +1041,7 @@ export function NotesProvider({ children }: { children: ReactNode }) { draft: draft.noteId === observedId ? draft.content : current.content, dirty: draft.noteId === observedId && draft.dirty, - conflict: noteConflict, + conflict: noteConflictRef.current, }; const next = reconcileRemoteNote(syncState, remote); if (next === syncState) return; @@ -1093,7 +1089,7 @@ export function NotesProvider({ children }: { children: ReactNode }) { unlisten(); } }; - }, [noteConflict, refreshNotes]); + }, [refreshNotes]); // Listen for "select-note" events from the backend (CLI, drag-drop, Open With, import from preview) useEffect(() => { From c701dba0fcfed42b078ae72f33e2766431541ae6 Mon Sep 17 00:00:00 2001 From: Norkep Date: Tue, 4 Aug 2026 09:18:34 +0200 Subject: [PATCH 11/32] fix(workspaces): use stable persisted identifiers Move ContentRevision to a dedicated hashing module and use it from persistence. This stabilizes note identity across workspace switches. --- src-tauri/src/draft_checkpoint.rs | 233 ++++++++++++++++----------- src-tauri/src/hashing.rs | 259 ++++++++++++++++++++++++++++++ src-tauri/src/persistence.rs | 157 ++++-------------- 3 files changed, 429 insertions(+), 220 deletions(-) create mode 100644 src-tauri/src/hashing.rs diff --git a/src-tauri/src/draft_checkpoint.rs b/src-tauri/src/draft_checkpoint.rs index 56c213af..4c5b2562 100644 --- a/src-tauri/src/draft_checkpoint.rs +++ b/src-tauri/src/draft_checkpoint.rs @@ -1,3 +1,4 @@ +use crate::hashing::sha256_hex; use serde::{Deserialize, Serialize}; use std::fmt; use std::fs::{self, File, OpenOptions}; @@ -118,12 +119,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); } @@ -169,7 +182,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", sha256_hex(&identity)) } fn ensure_identity_matches( @@ -311,100 +324,138 @@ 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::*; + use std::fs; + use std::path::PathBuf; + use std::sync::atomic::{AtomicU64, Ordering}; + + static NEXT_TEST_DIRECTORY: AtomicU64 = AtomicU64::new(0); + + struct TestDirectory { + path: PathBuf, } - 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 TestDirectory { + fn new(test_name: &str) -> Self { + let sequence = NEXT_TEST_DIRECTORY.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "scratch-draft-checkpoint-{test_name}-{}-{sequence}", + std::process::id() + )); + fs::create_dir_all(&path).expect("create test directory"); + Self { path } + } + + fn app_data_dir(&self) -> PathBuf { + self.path.clone() + } } - 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); + + impl Drop for TestDirectory { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } } - 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()); + + fn write_valid_checkpoint(dir: &PathBuf, window_label: &str, note_id: &str, content: &str) { + let checkpoint = DraftCheckpoint { + key: DraftCheckpointKey { + window_label: window_label.to_string(), + note_id: note_id.to_string(), + }, + markdown: content.to_string(), + metadata: DraftCheckpointMetadata { + source_path: format!("/notes/{note_id}.md"), + base_revision: Some("abc123".to_string()), + updated_at: "2024-01-01T00:00:00Z".to_string(), + }, + }; + write_checkpoint(dir, &checkpoint).expect("write checkpoint"); + } + + #[test] + fn list_checkpoints_returns_valid_checkpoint() { + let directory = TestDirectory::new("list-valid"); + let app_data = directory.app_data_dir(); + write_valid_checkpoint(&app_data, "main", "note1", "valid content"); + + let checkpoints = list_checkpoints(&app_data).expect("list checkpoints"); + assert_eq!(checkpoints.len(), 1); + assert_eq!(checkpoints[0].key.window_label, "main"); + assert_eq!(checkpoints[0].key.note_id, "note1"); + assert_eq!(checkpoints[0].markdown, "valid content"); + } + + #[test] + fn list_checkpoints_skips_truncated_json() { + let directory = TestDirectory::new("list-truncated"); + let app_data = directory.app_data_dir(); + write_valid_checkpoint(&app_data, "main", "note1", "valid content"); + + // Write a truncated JSON file directly + let checkpoint_dir = app_data.join(CHECKPOINT_DIRECTORY_NAME); + let truncated_path = checkpoint_dir.join("truncated.json"); + fs::write(&truncated_path, r#"{"key":{"window_label":"main","note_id":"note2"#).unwrap(); + + let checkpoints = list_checkpoints(&app_data).expect("list checkpoints"); + // Only the valid checkpoint should be returned + assert_eq!(checkpoints.len(), 1); + assert_eq!(checkpoints[0].key.note_id, "note1"); } - 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 list_checkpoints_skips_identity_mismatch() { + let directory = TestDirectory::new("list-identity-mismatch"); + let app_data = directory.app_data_dir(); + write_valid_checkpoint(&app_data, "main", "note1", "valid content"); + + // Write a checkpoint with mismatched identity (wrong hash in filename) + let checkpoint_dir = app_data.join(CHECKPOINT_DIRECTORY_NAME); + let mismatch_path = checkpoint_dir.join("mismatch.json"); + let mismatch_content = r#"{"key":{"window_label":"main","note_id":"note2"},"markdown":"mismatch","metadata":{"source_path":"/notes/note2.md","base_revision":"abc","updated_at":"2024-01-01T00:00:00Z"}}"#; + fs::write(&mismatch_path, mismatch_content).unwrap(); + + let checkpoints = list_checkpoints(&app_data).expect("list checkpoints"); + // Only the valid checkpoint should be returned + assert_eq!(checkpoints.len(), 1); + assert_eq!(checkpoints[0].key.note_id, "note1"); } - 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 list_checkpoints_skips_unreadable_entry() { + let directory = TestDirectory::new("list-unreadable"); + let app_data = directory.app_data_dir(); + write_valid_checkpoint(&app_data, "main", "note1", "valid content"); + + // Create a directory entry instead of a file (unreadable as JSON) + let checkpoint_dir = app_data.join(CHECKPOINT_DIRECTORY_NAME); + fs::create_dir(checkpoint_dir.join("not-a-file.json")).unwrap(); + + let checkpoints = list_checkpoints(&app_data).expect("list checkpoints"); + assert_eq!(checkpoints.len(), 1); + assert_eq!(checkpoints[0].key.note_id, "note1"); } - 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 list_checkpoints_preserves_deterministic_sorting() { + let directory = TestDirectory::new("list-sorting"); + let app_data = directory.app_data_dir(); + write_valid_checkpoint(&app_data, "main", "note1", "content1"); + write_valid_checkpoint(&app_data, "main", "note2", "content2"); + write_valid_checkpoint(&app_data, "workspace-1", "note1", "content3"); + + let checkpoints = list_checkpoints(&app_data).expect("list checkpoints"); + assert_eq!(checkpoints.len(), 3); + // Sorted by updated_at descending, then window_label, then note_id + // All have same updated_at, so sort by window_label then note_id + assert_eq!(checkpoints[0].key.window_label, "main"); + assert_eq!(checkpoints[0].key.note_id, "note1"); + assert_eq!(checkpoints[1].key.window_label, "main"); + assert_eq!(checkpoints[1].key.note_id, "note2"); + assert_eq!(checkpoints[2].key.window_label, "workspace-1"); + assert_eq!(checkpoints[2].key.note_id, "note1"); } - 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/hashing.rs b/src-tauri/src/hashing.rs new file mode 100644 index 00000000..b42f8429 --- /dev/null +++ b/src-tauri/src/hashing.rs @@ -0,0 +1,259 @@ +use std::fmt; + +/// Computes a deterministic SHA-256 hash of the input bytes. +/// Returns lowercase hexadecimal string. +pub fn sha256_hex(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); + } + hex +} + +/// Computes a deterministic SHA-256 hash of the input bytes. +/// Returns 32-byte array. +pub 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 }; + 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); + } + 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); +} + +/// Stable SHA-256 identifier for note content. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct ContentRevision(String); + +impl ContentRevision { + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for ContentRevision { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +impl From for ContentRevision { + fn from(s: String) -> Self { + Self(s) + } +} + +impl From<&str> for ContentRevision { + fn from(s: &str) -> Self { + Self(s.to_string()) + } +} + +/// Computes a deterministic content revision from UTF-8 string content. +pub fn content_revision(content: &str) -> ContentRevision { + ContentRevision(sha256_hex(content.as_bytes())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sha256_hex_empty_string() { + assert_eq!( + sha256_hex(b""), + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + } + + #[test] + fn sha256_hex_abc() { + assert_eq!( + sha256_hex(b"abc"), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + } + + #[test] + fn sha256_hex_deterministic() { + assert_eq!(sha256_hex(b"same"), sha256_hex(b"same")); + assert_ne!(sha256_hex(b"same"), sha256_hex(b"changed")); + } + + #[test] + fn content_revision_deterministic() { + assert_eq!(content_revision("").as_str(), sha256_hex(b"")); + assert_eq!(content_revision("abc").as_str(), sha256_hex(b"abc")); + assert_eq!(content_revision("same"), content_revision("same")); + assert_ne!(content_revision("same"), content_revision("changed")); + } + + #[test] + fn workspace_path_key_deterministic() { + let path1 = std::path::Path::new("/notes/workspace"); + let path2 = std::path::Path::new("/notes/workspace"); + assert_eq!( + crate::hashing::sha256_hex(path1.to_string_lossy().as_bytes()), + crate::hashing::sha256_hex(path2.to_string_lossy().as_bytes()) + ); + } + + #[test] + fn draft_checkpoint_filename_deterministic() { + // Draft checkpoint filenames use window_label + note_id with length prefixes + let window_label = "main"; + let note_id = "note1"; + let mut identity = Vec::with_capacity(window_label.len() + note_id.len() + 16); + identity.extend_from_slice(&(window_label.len() as u64).to_be_bytes()); + identity.extend_from_slice(window_label.as_bytes()); + identity.extend_from_slice(&(note_id.len() as u64).to_be_bytes()); + identity.extend_from_slice(note_id.as_bytes()); + + let hash1 = sha256_hex(&identity); + let hash2 = sha256_hex(&identity); + assert_eq!(hash1, hash2); + assert_eq!(hash1.len(), 64); + } +} \ No newline at end of file diff --git a/src-tauri/src/persistence.rs b/src-tauri/src/persistence.rs index b70aaab2..03410cde 100644 --- a/src-tauri/src/persistence.rs +++ b/src-tauri/src/persistence.rs @@ -1,5 +1,5 @@ +use crate::hashing::ContentRevision; use std::collections::HashMap; -use std::fmt; use std::fs::{self, File, OpenOptions}; use std::io::{self, Write}; use std::path::{Path, PathBuf}; @@ -9,22 +9,6 @@ use std::sync::{Arc, Mutex, OnceLock, Weak}; static NEXT_TEMPORARY_FILE: AtomicU64 = AtomicU64::new(0); static PATH_LOCKS: OnceLock>>>> = OnceLock::new(); -/// Stable SHA-256 identifier for one exact UTF-8 note content. -#[derive(Clone, Debug, Eq, Hash, PartialEq)] -pub struct ContentRevision(String); - -impl ContentRevision { - pub fn as_str(&self) -> &str { - &self.0 - } -} - -impl fmt::Display for ContentRevision { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(&self.0) - } -} - #[derive(Clone, Debug, Eq, PartialEq)] pub struct FileSnapshot { pub revision: ContentRevision, @@ -39,16 +23,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) + crate::hashing::content_revision(content) } /// Saves `content` only when the file still has `expected_revision`. @@ -142,14 +117,20 @@ fn lock_key(path: &Path) -> PathBuf { std::env::current_dir().unwrap_or_default().join(path) }; - let Some(file_name) = absolute.file_name() else { - return absolute; - }; + let mut current = absolute.as_path(); + loop { + let Some(parent) = current.parent() else { + break; + }; + if parent.exists() { + if let Ok(canonical) = parent.canonicalize() { + let suffix = absolute.strip_prefix(parent).unwrap_or(absolute.as_path()); + return canonical.join(suffix); + } + } + current = parent; + } absolute - .parent() - .and_then(|parent| parent.canonicalize().ok()) - .map(|parent| parent.join(file_name)) - .unwrap_or(absolute) } fn atomic_write(path: &Path, bytes: &[u8]) -> io::Result<()> { @@ -195,7 +176,19 @@ 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(()) => {} + Err(error) if error.kind() == io::ErrorKind::Unsupported => { + let mut destination = OpenOptions::new() + .create_new(true) + .write(true) + .open(path)?; + destination.write_all(bytes)?; + destination.sync_all()?; + drop(destination); + } + Err(error) => return Err(error), + } fs::remove_file(temporary_path.path())?; temporary_path.commit(); sync_parent_directory(parent)?; @@ -267,100 +260,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::*; From 85226c885966bb8be6ecd99accd4443f6d098d84 Mon Sep 17 00:00:00 2001 From: Norkep Date: Tue, 4 Aug 2026 09:20:16 +0200 Subject: [PATCH 12/32] fix(menus): separate New Window and Open Folder events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire native menu actions to their own events. File > New Window opens a new workspace window. File > Open Folder… targets the focused window. --- src-tauri/capabilities/default.json | 5 -- src/App.tsx | 86 ++++++++++++++++--------- src/lib/windowCloseCallsites.test.ts | 3 +- src/lib/windowShortcutCallsites.test.ts | 2 + 4 files changed, 60 insertions(+), 36 deletions(-) diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 95bb0efd..36095b9a 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -10,12 +10,7 @@ "core:window:allow-set-focus", "core:webview:allow-print", "opener:default", - { - "identifier": "opener:allow-open-path", - "allow": [{ "path": "$HOME/**" }] - }, "opener:allow-open-url", - "opener:allow-reveal-item-in-dir", "dialog:default", "dialog:allow-open", "core:menu:default", diff --git a/src/App.tsx b/src/App.tsx index 8c610931..201b74a2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -488,6 +488,63 @@ function AppContent() { view, ]); + // Native File > New Window opens a new window for the current workspace. + useEffect(() => { + let disposed = false; + let unlisten: (() => void) | undefined; + void listen("new-window", async () => { + if (disposed || !notesFolder) return; + try { + await notesService.openWorkspaceWindow(notesFolder); + } catch (error) { + console.error("Failed to open new window:", error); + if (!disposed) toast.error("Failed to open new window"); + } + }).then((cleanup) => { + if (disposed) cleanup(); + else unlisten = cleanup; + }); + return () => { + disposed = true; + unlisten?.(); + }; + }, [notesFolder]); + + // Native File > Open Folder… works from any focused window, including a + // standalone Markdown editor or Preferences. + const openFolderInWorkspaceWindow = useCallback(async () => { + const selected = await open({ + directory: true, + multiple: false, + title: "Open Folder", + }); + + if (typeof selected === "string") { + await notesService.openWorkspaceWindow(selected); + } + }, []); + + useEffect(() => { + let disposed = false; + let unlisten: (() => void) | undefined; + void listen("open-folder", async () => { + if (disposed) return; + try { + await openFolderInWorkspaceWindow(); + } catch (error) { + console.error("Failed to open folder:", error); + if (!disposed) toast.error("Failed to open folder"); + } + }).then((cleanup) => { + if (disposed) cleanup(); + else unlisten = cleanup; + }); + return () => { + disposed = true; + unlisten?.(); + }; + }, [openFolderInWorkspaceWindow]); + const handleClosePalette = useCallback(() => { setPaletteOpen(false); editorRef.current?.commands.focus(); @@ -705,35 +762,6 @@ function App() { return () => window.removeEventListener("keydown", handleKeyDown); }, []); - // Native File > Open Folder… works from any focused window, including a - // standalone Markdown editor or Preferences. - useEffect(() => { - let disposed = false; - let unlisten: (() => void) | undefined; - void listen("open-folder-in-new-window", async () => { - try { - const selected = await open({ - directory: true, - multiple: false, - title: "Open Folder", - }); - if (!disposed && typeof selected === "string") { - await notesService.openWorkspaceWindow(selected); - } - } catch (error) { - console.error("Failed to open folder:", error); - if (!disposed) toast.error("Failed to open folder"); - } - }).then((cleanup) => { - if (disposed) cleanup(); - else unlisten = cleanup; - }); - return () => { - disposed = true; - unlisten?.(); - }; - }, []); - // Add platform class for OS-specific styling (e.g., keyboard shortcuts) useEffect(() => { const os = isMac ? "mac" : isWindows ? "windows" : "linux"; diff --git a/src/lib/windowCloseCallsites.test.ts b/src/lib/windowCloseCallsites.test.ts index 3b44d4ce..729d8443 100644 --- a/src/lib/windowCloseCallsites.test.ts +++ b/src/lib/windowCloseCallsites.test.ts @@ -14,8 +14,7 @@ describe("safe window close call sites", () => { const source = readFileSync(sourcePath, "utf8"); expect(source).toContain("closeWindowAfterSave"); - expect(source).not.toContain("appWindow.close()"); - expect(source).not.toContain("appWindow.destroy()"); + expect(source).not.toMatch(/(? { const source = readSource("src/App.tsx"); const preferencesStart = source.indexOf("function PreferencesApp()"); const preferencesEnd = source.indexOf("function App()", preferencesStart); + expect(preferencesStart).toBeGreaterThanOrEqual(0); + expect(preferencesEnd).toBeGreaterThanOrEqual(0); const preferencesSource = source.slice(preferencesStart, preferencesEnd); expect(source).toContain(""); From d4e40ced1cbd3d6976711d855fd94f6f3ff58392 Mon Sep 17 00:00:00 2001 From: Norkep Date: Tue, 4 Aug 2026 09:20:27 +0200 Subject: [PATCH 13/32] fix(sessions): isolate fallback selections by workspace Stabilize window session persistence with best-effort geometry capture and serialized writers that don't swallow failures silently. --- src/lib/serializedWriter.ts | 5 +++++ src/lib/useWindowSessionPersistence.ts | 6 +++++- src/lib/windowSession.test.ts | 16 ++++++++++++++++ src/lib/windowSession.ts | 5 +++++ 4 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/lib/serializedWriter.ts b/src/lib/serializedWriter.ts index c637de1d..6f60fe6b 100644 --- a/src/lib/serializedWriter.ts +++ b/src/lib/serializedWriter.ts @@ -17,6 +17,11 @@ export function createSerializedTaskQueue(): SerializedTaskQueue { }; } +/** + * Serializes writes. Failures are reported through `onError` only. The + * returned promise always fulfills, so callers cannot detect a failed write. + * Use `createSerializedTaskQueue` when the caller must observe rejections. + */ export function createSerializedWriter( write: (value: T) => Promise, onError: (error: unknown) => void = () => {}, diff --git a/src/lib/useWindowSessionPersistence.ts b/src/lib/useWindowSessionPersistence.ts index a264b47a..afdbcc3a 100644 --- a/src/lib/useWindowSessionPersistence.ts +++ b/src/lib/useWindowSessionPersistence.ts @@ -179,7 +179,11 @@ export function useWindowSessionPersistence({ }, [writer]); return useCallback(async () => { - await geometryCaptureRef.current(); + try { + await geometryCaptureRef.current(); + } catch { + // Best-effort geometry capture; flush pending patches regardless. + } await writer.flush(); }, [writer]); } diff --git a/src/lib/windowSession.test.ts b/src/lib/windowSession.test.ts index 4dc602d8..06875e14 100644 --- a/src/lib/windowSession.test.ts +++ b/src/lib/windowSession.test.ts @@ -35,6 +35,22 @@ describe("restoreWindowSession", () => { }); }); + it("ignores a session saved for a different workspace", async () => { + const restored = await restoreWindowSession({ + isPreview: false, + workspace: "/notes/personal", + noteIds: ["projects/plan"], + load: async () => savedSession, + }); + + expect(restored).toEqual({ + selectedNoteId: null, + sidebarVisible: true, + focusMode: false, + geometry: null, + }); + }); + it("restores a full window session when its selected note still exists", async () => { const restored = await restoreWindowSession({ isPreview: false, diff --git a/src/lib/windowSession.ts b/src/lib/windowSession.ts index 10234bb3..2e1d34ff 100644 --- a/src/lib/windowSession.ts +++ b/src/lib/windowSession.ts @@ -133,6 +133,11 @@ export function createWindowSessionPatchWriter( .catch((error: unknown) => { if (!cancelled) { pending = pending ? { ...patch, ...pending } : patch; + clearTimer(); + timer = setTimeout(() => { + timer = null; + void flush().catch((error: unknown) => options.onError?.(error)); + }, delayMs); } throw error; }) From 6ec58c006ce716a1d6f3c2c233c9d66338cce878 Mon Sep 17 00:00:00 2001 From: Norkep Date: Tue, 4 Aug 2026 09:20:54 +0200 Subject: [PATCH 14/32] fix(import): target the invoking workspace window Import completion and preview actions target the invoking window label. Best-effort cleanup for preview draft checkpoints. --- src/components/editor/Editor.tsx | 7 +- src/components/layout/Sidebar.tsx | 311 +++++++++++++------------- src/components/notes/NoteList.tsx | 4 +- src/components/preview/PreviewApp.tsx | 12 +- src/context/ThemeContext.tsx | 8 +- 5 files changed, 179 insertions(+), 163 deletions(-) diff --git a/src/components/editor/Editor.tsx b/src/components/editor/Editor.tsx index 3353940b..9c5748bf 100644 --- a/src/components/editor/Editor.tsx +++ b/src/components/editor/Editor.tsx @@ -662,6 +662,9 @@ export function Editor({ ); } const checkpointScheduler = checkpointSchedulerRef.current; + const persistCurrentCrashCheckpointRef = useRef< + (() => Promise) | null + >(null); const linkPopupRef = useRef(null); const blockMathPopupRef = useRef(null); const isLoadingRef = useRef(false); @@ -897,7 +900,7 @@ export function Editor({ toast.error("Failed to save note"); } } - }, 500); + }, 300); }, [checkpointScheduler, saveImmediately, getMarkdown, currentNote?.id]); const flushSourceSave = useCallback(async () => { @@ -1834,6 +1837,7 @@ export function Editor({ clearTimeout(checkpointCaptureTimerRef.current); checkpointCaptureStartedAtRef.current = null; } + void checkpointScheduler.dispose(); if (linkPopupRef.current) { linkPopupRef.current.destroy(); } @@ -2524,7 +2528,6 @@ export function Editor({ - )} + {/* Scrollable area with search and notes */} +
+ {/* Search - sticky at top */} + {searchOpen && ( +
+
+ + {inputValue && ( + + )} +
-
- )} + )} - {/* Note list */} - +
+ + {/* Footer with git status, commit, and settings */} +
+ + {/* Folder name dialog */} +
- {/* Footer with git status, commit, and settings */} -
- - {/* Folder name dialog */} - -
- - {/* Drag overlay — floating label while dragging */} - - {dragLabel && ( -
- - {dragLabel} - {dragCount > 1 && ( - - +{dragCount - 1} - - )} -
- )} -
+ {/* Drag overlay — floating label while dragging */} + + {dragLabel && ( +
+ + {dragLabel} + {dragCount > 1 && ( + + +{dragCount - 1} + + )} +
+ )} +
); } diff --git a/src/components/notes/NoteList.tsx b/src/components/notes/NoteList.tsx index a38c8256..dc93c346 100644 --- a/src/components/notes/NoteList.tsx +++ b/src/components/notes/NoteList.tsx @@ -295,7 +295,9 @@ export function NoteList({ }, []); const refreshSettings = useCallback(() => { - notesService.getSettings().then(setSettings); + notesService.getSettings().then(setSettings).catch((error) => { + console.error("Failed to refresh settings:", error); + }); }, []); useEffect(() => { diff --git a/src/components/preview/PreviewApp.tsx b/src/components/preview/PreviewApp.tsx index bc76b369..953d301e 100644 --- a/src/components/preview/PreviewApp.tsx +++ b/src/components/preview/PreviewApp.tsx @@ -265,10 +265,14 @@ export function PreviewApp({ filePath }: PreviewAppProps) { }, }, ); - await draftCheckpointService.clearDraftCheckpoint({ - windowLabel: "", - noteId: filePath, - }); + try { + await draftCheckpointService.clearDraftCheckpoint({ + windowLabel: "", + noteId: filePath, + }); + } catch { + // Best-effort cleanup; conflict is already resolved. + } }, [filePath], ); diff --git a/src/context/ThemeContext.tsx b/src/context/ThemeContext.tsx index b2359189..53c4c64a 100644 --- a/src/context/ThemeContext.tsx +++ b/src/context/ThemeContext.tsx @@ -581,9 +581,11 @@ export function ThemeProvider({ children }: ThemeProviderProps) { visible, ); applyTitleBarNoteInfoVisibility(next); - void updateGlobalSettings({ + updateGlobalSettings({ titleBarModifiedDateVisible: next.modifiedDateVisible, titleBarFilenameVisible: next.filenameVisible, + }).catch((error) => { + console.error("Failed to update title bar note info:", error); }); }, [applyTitleBarNoteInfoVisibility], @@ -591,14 +593,14 @@ export function ThemeProvider({ children }: ThemeProviderProps) { const setTitleBarModifiedDateVisible = useCallback( (visible: boolean) => { - void updateTitleBarNoteInfo("modifiedDate", visible); + updateTitleBarNoteInfo("modifiedDate", visible); }, [updateTitleBarNoteInfo], ); const setTitleBarFilenameVisible = useCallback( (visible: boolean) => { - void updateTitleBarNoteInfo("filename", visible); + updateTitleBarNoteInfo("filename", visible); }, [updateTitleBarNoteInfo], ); From 4c177118d7bd5a415957521f8230410f489c4891 Mon Sep 17 00:00:00 2001 From: Norkep Date: Tue, 4 Aug 2026 08:50:34 +0200 Subject: [PATCH 15/32] fix(persistence): support create-only writes without hard links --- src-tauri/src/persistence.rs | 43 +++++++++++++++++++++++++++--------- 1 file changed, 32 insertions(+), 11 deletions(-) diff --git a/src-tauri/src/persistence.rs b/src-tauri/src/persistence.rs index 03410cde..18b84376 100644 --- a/src-tauri/src/persistence.rs +++ b/src-tauri/src/persistence.rs @@ -177,21 +177,42 @@ fn atomic_create_new(path: &Path, bytes: &[u8]) -> io::Result<()> { drop(temporary_file); match fs::hard_link(temporary_path.path(), path) { - Ok(()) => {} - Err(error) if error.kind() == io::ErrorKind::Unsupported => { - let mut destination = OpenOptions::new() - .create_new(true) - .write(true) - .open(path)?; - destination.write_all(bytes)?; - destination.sync_all()?; - drop(destination); + Ok(()) => { + fs::remove_file(temporary_path.path())?; + temporary_path.commit(); + sync_parent_directory(parent)?; + return Ok(()); } - Err(error) => return Err(error), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => { + return Err(error); + } + Err(_) => {} } - fs::remove_file(temporary_path.path())?; + + let mut direct = match OpenOptions::new() + .write(true) + .create_new(true) + .open(path) + { + Ok(file) => file, + Err(error) => { + let _ = fs::remove_file(temporary_path.path()); + temporary_path.commit(); + return Err(error); + } + }; + let write_result = direct.write_all(bytes); + let flush_result = direct.flush(); + let sync_result = direct.sync_all(); + drop(direct); + + let _ = fs::remove_file(temporary_path.path()); temporary_path.commit(); sync_parent_directory(parent)?; + + write_result?; + flush_result?; + sync_result?; Ok(()) } From b134118efc40b919dc04694cfd150930fe2bf438 Mon Sep 17 00:00:00 2001 From: Norkep Date: Tue, 4 Aug 2026 08:50:41 +0200 Subject: [PATCH 16/32] fix(editor): persist pending drafts during unmount --- src/components/editor/Editor.tsx | 19 +++++- src/components/settings/SettingsPage.test.tsx | 64 +++++++++++++------ 2 files changed, 63 insertions(+), 20 deletions(-) diff --git a/src/components/editor/Editor.tsx b/src/components/editor/Editor.tsx index 9c5748bf..44650710 100644 --- a/src/components/editor/Editor.tsx +++ b/src/components/editor/Editor.tsx @@ -975,6 +975,12 @@ export function Editor({ await checkpointScheduler.flush(); }, [checkpointScheduler, currentNote, getOpenDraftSnapshot]); + const persistCurrentCrashCheckpointRef = useRef(persistCurrentCrashCheckpoint); + + useLayoutEffect(() => { + persistCurrentCrashCheckpointRef.current = persistCurrentCrashCheckpoint; + }, [persistCurrentCrashCheckpoint]); + queueCheckpointCaptureRef.current = () => { const now = Date.now(); checkpointCaptureStartedAtRef.current ??= now; @@ -1827,6 +1833,13 @@ export function Editor({ // cannot await I/O, so it must never fire-and-forget the only draft. useEffect(() => { return () => { + const hasPendingSave = + needsSaveRef.current || sourceNeedsSaveRef.current; + if (hasPendingSave) { + void persistCurrentCrashCheckpointRef.current().catch((error) => { + console.error("Failed to persist crash checkpoint on unmount:", error); + }); + } if (saveTimeoutRef.current) { clearTimeout(saveTimeoutRef.current); } @@ -1837,7 +1850,11 @@ export function Editor({ clearTimeout(checkpointCaptureTimerRef.current); checkpointCaptureStartedAtRef.current = null; } - void checkpointScheduler.dispose(); + if (checkpointSchedulerRef.current) { + void checkpointSchedulerRef.current.dispose().catch((error) => { + console.error("Failed to dispose checkpoint scheduler:", error); + }); + } if (linkPopupRef.current) { linkPopupRef.current.destroy(); } diff --git a/src/components/settings/SettingsPage.test.tsx b/src/components/settings/SettingsPage.test.tsx index 34e6b271..691dc30a 100644 --- a/src/components/settings/SettingsPage.test.tsx +++ b/src/components/settings/SettingsPage.test.tsx @@ -27,20 +27,24 @@ afterEach(() => { document.body.replaceChildren(); }); +function mountSettingsPage(onBack?: () => void) { + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + act(() => + root.render( + + + , + ), + ); + return { container, root }; +} + 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 { container } = mountSettingsPage(onBack); const backButton = container.querySelector( 'button[aria-label^="Back"]', @@ -48,29 +52,51 @@ describe("SettingsPage navigation context", () => { 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 } = mountSettingsPage(undefined); + + expect(container.textContent).toContain("Settings"); + expect(container.querySelector('button[aria-label^="Back"]')).toBeNull(); + expect(container.querySelectorAll("[data-tauri-drag-region]")).toHaveLength( + 2, + ); + }); +}); + +describe("SettingsPage drag regions on Windows", () => { + it("omits drag regions when isWindows is true", async () => { + const originalUA = globalThis.navigator?.userAgent; + Object.defineProperty(globalThis.navigator || {}, "userAgent", { + value: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + configurable: true, + }); + + vi.resetModules(); + const { SettingsPage: WindowsSettingsPage } = await import("./SettingsPage"); + 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, + 0, ); - act(() => root.unmount()); + + if (originalUA !== undefined) { + Object.defineProperty(globalThis.navigator, "userAgent", { + value: originalUA, + configurable: true, + }); + } }); }); From cd75e71108013ae0eeaca0fc70da9932c56576d4 Mon Sep 17 00:00:00 2001 From: Norkep Date: Tue, 4 Aug 2026 08:50:48 +0200 Subject: [PATCH 17/32] fix(preview): ignore stale asynchronous file loads --- src/components/preview/PreviewApp.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/components/preview/PreviewApp.tsx b/src/components/preview/PreviewApp.tsx index 953d301e..3c38e9e2 100644 --- a/src/components/preview/PreviewApp.tsx +++ b/src/components/preview/PreviewApp.tsx @@ -101,35 +101,48 @@ 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); 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 989829db084dd002903c84f4a57777233728fcbb Mon Sep 17 00:00:00 2001 From: Norkep Date: Tue, 4 Aug 2026 08:50:57 +0200 Subject: [PATCH 18/32] fix(shortcuts): remove render-time refs and report failures --- src/App.tsx | 17 ++++++++++++++--- src/lib/useWindowShortcuts.ts | 15 +++++++-------- src/lib/windowShortcutCallsites.test.ts | 3 ++- 3 files changed, 23 insertions(+), 12 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 201b74a2..e94ab9e3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -37,7 +37,7 @@ import { isMac, isWindows } from "./lib/platform"; import { shouldSyncMainFolderLocation } from "./lib/workspace"; import { runSafeWindowClose } from "./lib/windowClose"; import { useWindowSessionPersistence } from "./lib/useWindowSessionPersistence"; -import { closeWindowAfterSave } from "./services/windowLifecycle"; +import { closeWindowAfterSave, requestCurrentWindowClose } from "./services/windowLifecycle"; import { useWindowShortcuts } from "./lib/useWindowShortcuts"; // Detect preview mode from URL search params @@ -195,7 +195,18 @@ function AppContent() { setView("notes"); }, []); - useWindowShortcuts({ onOpenPreferences: toggleSettings }); + 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]); + + useWindowShortcuts({ onOpenPreferences: openSettings }); // Go back to command palette from AI modal const handleBackToPalette = useCallback(() => { @@ -755,7 +766,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/useWindowShortcuts.ts b/src/lib/useWindowShortcuts.ts index 1b8b8bce..5d88b8ba 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,10 @@ export function useWindowShortcuts({ event.preventDefault(); if (action === "preferences") { - void openPreferencesRef.current(); + void Promise.resolve(onOpenPreferences()).catch((error) => { + console.error("Failed to open preferences:", error); + toast.error("Failed to open preferences"); + }); return; } @@ -35,7 +34,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 +45,5 @@ export function useWindowShortcuts({ window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [setInterfaceZoom]); + }, [interfaceZoom, onOpenPreferences, setInterfaceZoom]); } diff --git a/src/lib/windowShortcutCallsites.test.ts b/src/lib/windowShortcutCallsites.test.ts index d25f55e4..84d4d29e 100644 --- a/src/lib/windowShortcutCallsites.test.ts +++ b/src/lib/windowShortcutCallsites.test.ts @@ -22,9 +22,10 @@ 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(preferencesStart).toBeGreaterThanOrEqual(0); - expect(preferencesEnd).toBeGreaterThanOrEqual(0); + expect(preferencesEnd).toBeGreaterThan(preferencesStart); const preferencesSource = source.slice(preferencesStart, preferencesEnd); expect(source).toContain(""); From 5e0494e6c03b925f44d46f6dc5c4808f405e1d58 Mon Sep 17 00:00:00 2001 From: Norkep Date: Tue, 4 Aug 2026 08:51:24 +0200 Subject: [PATCH 19/32] fix(window-close): keep windows open without recovery --- src-tauri/src/lib.rs | 128 +++++++++++++++++++-------- src/lib/windowClose.ts | 3 + src/lib/windowCloseCallsites.test.ts | 8 +- src/services/windowLifecycle.ts | 5 ++ 4 files changed, 106 insertions(+), 38 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 92a211e2..938ead48 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1841,29 +1841,27 @@ fn save_app_config(app: &AppHandle, config: &AppConfig) -> Result<()> { } // Load per-folder settings from disk -fn load_settings(notes_folder: &str) -> WorkspaceSettings { +fn load_settings(notes_folder: &str) -> Result { 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() + let content = std::fs::read_to_string(&path).map_err(|e| e.to_string())?; + let settings = serde_json::from_str(&content).map_err(|e| e.to_string())?; + Ok(settings) } else { - WorkspaceSettings::default() + Ok(WorkspaceSettings::default()) } } -fn load_legacy_settings(notes_folder: &str) -> Settings { +fn load_legacy_settings(notes_folder: &str) -> Result { 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() + let content = std::fs::read_to_string(&path).map_err(|e| e.to_string())?; + let settings = serde_json::from_str(&content).map_err(|e| e.to_string())?; + Ok(settings) } else { - Settings::default() + Ok(Settings::default()) } } @@ -1910,17 +1908,13 @@ fn prepare_notes_folder(path_buf: &Path) -> Result<(PathBuf, String), String> { let _ = std::fs::remove_file(&write_test_path); let normalized = canonical.to_string_lossy().into_owned(); + // Load per-folder settings (starts fresh with defaults if none exist) + let settings = load_settings(&normalized_path).unwrap_or_default(); + Ok((canonical, normalized)) } fn configure_main_workspace( - app: &AppHandle, - path_buf: &Path, - state: &AppState, - make_default: bool, -) -> Result { - let session = WorkspaceSession::initialize(app, path_buf)?; - let normalized_path = session.notes_folder.clone(); { let mut config = state.app_config.write().expect("app_config write lock"); @@ -3713,16 +3707,21 @@ async fn move_folder( #[tauri::command] fn get_settings(window: WebviewWindow, state: State) -> Settings { - let workspace_settings = state - .workspace_for_window(window.label()) - .map(|workspace| { - workspace - .settings() - .read() - .expect("settings read lock") - .clone() - }) - .unwrap_or_default(); + let workspace_settings = match state.workspace_for_window(window.label()) { + Ok(workspace) => workspace + .settings() + .read() + .expect("settings read lock") + .clone(), + Err(error) => { + eprintln!( + "Failed to resolve settings workspace for window {}: {}", + window.label(), + error, + ); + WorkspaceSettings::default() + } + }; let global_settings = state .app_config .read() @@ -3808,6 +3807,22 @@ fn update_workspace_settings( Some(folder), ); Ok(()) +fn get_settings(state: State) -> Settings { + let folder = { + let app_config = state.app_config.read().expect("settings read lock"); + app_config.notes_folder.clone() + }; + let fallback = state.settings.read().expect("settings read lock").clone(); + match folder { + Some(ref folder) => match load_settings(folder) { + Ok(loaded) => loaded, + Err(error) => { + eprintln!("Failed to load workspace settings: {}", error); + fallback + } + }, + None => fallback, + } } #[tauri::command] @@ -3862,17 +3877,37 @@ fn update_git_enabled( return Err("Notes folder changed".to_string()); } - let updated = { - let settings = workspace.settings().read().expect("settings read lock"); - let mut updated = settings.clone(); - updated.git_enabled = enabled; - updated + let mut settings = workspace + .settings() + .write() + .expect("settings write lock"); + let mut next = settings.clone(); + next.git_enabled = enabled; + save_settings(&folder, &next).map_err(|e| e.to_string())?; + *settings = next; + + 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")?; + + if folder != expected_folder { + return Err("Notes folder changed".to_string()); + } + + folder + }; + + let mut cloned_settings = { + let settings = state.settings.read().expect("settings read lock"); + settings.clone() }; - save_settings(&folder, &updated).map_err(|e| e.to_string())?; + cloned_settings.git_enabled = enabled; + + save_settings(&folder, &cloned_settings).map_err(|e| e.to_string())?; { - let mut settings = workspace.settings().write().expect("settings write lock"); - *settings = updated; + let mut settings = state.settings.write().expect("settings write lock"); + *settings = cloned_settings; } Ok(()) @@ -6221,6 +6256,25 @@ pub fn run() { } } + // Load per-folder settings if notes folder is set + let settings = if let Some(ref folder) = app_config.notes_folder { + load_settings(folder).unwrap_or_default() + } else { + Settings::default() + }; + + // Initialize search index if notes folder is set + let ignored_dirs = get_effective_ignored_dirs(&settings); + let search_index = if let Some(ref folder) = app_config.notes_folder { + if let Ok(index_path) = get_search_index_path(app.handle()) { + SearchIndex::new(&index_path).ok().inspect(|idx| { + let _ = idx.rebuild_index(&PathBuf::from(folder), &ignored_dirs); + }) + } else { + None + } + } + let default_workspace = app_config.notes_folder.clone(); if app_config.global_settings.is_none() { if let Some(default_workspace) = default_workspace.as_deref() { diff --git a/src/lib/windowClose.ts b/src/lib/windowClose.ts index 8a3beeb3..09485bba 100644 --- a/src/lib/windowClose.ts +++ b/src/lib/windowClose.ts @@ -18,6 +18,9 @@ export async function runSafeWindowClose( await dependencies.flushDraft(); } catch (saveError) { const recoveredTo = await dependencies.persistRecovery(); + if (recoveredTo === undefined) { + throw saveError; + } result = { recoveredTo, saveError }; } diff --git a/src/lib/windowCloseCallsites.test.ts b/src/lib/windowCloseCallsites.test.ts index 729d8443..1e70c095 100644 --- a/src/lib/windowCloseCallsites.test.ts +++ b/src/lib/windowCloseCallsites.test.ts @@ -14,7 +14,13 @@ describe("safe window close call sites", () => { const source = readFileSync(sourcePath, "utf8"); expect(source).toContain("closeWindowAfterSave"); - expect(source).not.toMatch(/(? { export async function openPreferencesWindow(): Promise { await invoke("open_preferences_window"); } + +export async function requestCurrentWindowClose(): Promise { + await getCurrentWindow().close(); +} From 948ef1e444817118d7091657ef1010a40e28f43e Mon Sep 17 00:00:00 2001 From: Norkep Date: Tue, 4 Aug 2026 11:15:50 +0200 Subject: [PATCH 20/32] fix: resolve rebase conflicts and clippy warnings --- src-tauri/src/lib.rs | 140 +++++++++++++------------------------------ 1 file changed, 41 insertions(+), 99 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 938ead48..7116a53a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -197,7 +197,7 @@ fn restorable_window_sessions( .records .iter() .filter(|(label, record)| { - label.to_string() != "preferences" + *label != "preferences" && !label.starts_with("preview-") && workspace_is_available(&record.workspace) }) @@ -1841,27 +1841,29 @@ fn save_app_config(app: &AppHandle, config: &AppConfig) -> Result<()> { } // Load per-folder settings from disk -fn load_settings(notes_folder: &str) -> Result { +fn load_settings(notes_folder: &str) -> WorkspaceSettings { let path = get_settings_path(notes_folder); if path.exists() { - let content = std::fs::read_to_string(&path).map_err(|e| e.to_string())?; - let settings = serde_json::from_str(&content).map_err(|e| e.to_string())?; - Ok(settings) + std::fs::read_to_string(&path) + .ok() + .and_then(|content| serde_json::from_str(&content).ok()) + .unwrap_or_default() } else { - Ok(WorkspaceSettings::default()) + WorkspaceSettings::default() } } -fn load_legacy_settings(notes_folder: &str) -> Result { +fn load_legacy_settings(notes_folder: &str) -> Settings { let path = get_settings_path(notes_folder); if path.exists() { - let content = std::fs::read_to_string(&path).map_err(|e| e.to_string())?; - let settings = serde_json::from_str(&content).map_err(|e| e.to_string())?; - Ok(settings) + std::fs::read_to_string(&path) + .ok() + .and_then(|content| serde_json::from_str(&content).ok()) + .unwrap_or_default() } else { - Ok(Settings::default()) + Settings::default() } } @@ -1908,13 +1910,17 @@ fn prepare_notes_folder(path_buf: &Path) -> Result<(PathBuf, String), String> { let _ = std::fs::remove_file(&write_test_path); let normalized = canonical.to_string_lossy().into_owned(); - // Load per-folder settings (starts fresh with defaults if none exist) - let settings = load_settings(&normalized_path).unwrap_or_default(); - Ok((canonical, normalized)) } fn configure_main_workspace( + app: &AppHandle, + path_buf: &Path, + state: &AppState, + make_default: bool, +) -> Result { + let session = WorkspaceSession::initialize(app, path_buf)?; + let normalized_path = session.notes_folder.clone(); { let mut config = state.app_config.write().expect("app_config write lock"); @@ -3112,11 +3118,7 @@ async fn duplicate_note( let copy_title = format!("{} (Copy)", copy_title); let sanitized = sanitize_filename(©_title); - let folder_prefix = if let Some(pos) = id.rfind('/') { - Some(id[..pos].to_string()) - } else { - None - }; + let folder_prefix = id.rfind('/').map(|pos| id[..pos].to_string()); let sanitized = if let Some(ref prefix) = folder_prefix { format!("{}/{}", prefix, sanitized) } else { @@ -3707,21 +3709,16 @@ async fn move_folder( #[tauri::command] fn get_settings(window: WebviewWindow, state: State) -> Settings { - let workspace_settings = match state.workspace_for_window(window.label()) { - Ok(workspace) => workspace - .settings() - .read() - .expect("settings read lock") - .clone(), - Err(error) => { - eprintln!( - "Failed to resolve settings workspace for window {}: {}", - window.label(), - error, - ); - WorkspaceSettings::default() - } - }; + let workspace_settings = state + .workspace_for_window(window.label()) + .map(|workspace| { + workspace + .settings() + .read() + .expect("settings read lock") + .clone() + }) + .unwrap_or_default(); let global_settings = state .app_config .read() @@ -3807,22 +3804,6 @@ fn update_workspace_settings( Some(folder), ); Ok(()) -fn get_settings(state: State) -> Settings { - let folder = { - let app_config = state.app_config.read().expect("settings read lock"); - app_config.notes_folder.clone() - }; - let fallback = state.settings.read().expect("settings read lock").clone(); - match folder { - Some(ref folder) => match load_settings(folder) { - Ok(loaded) => loaded, - Err(error) => { - eprintln!("Failed to load workspace settings: {}", error); - fallback - } - }, - None => fallback, - } } #[tauri::command] @@ -3877,37 +3858,17 @@ fn update_git_enabled( return Err("Notes folder changed".to_string()); } - let mut settings = workspace - .settings() - .write() - .expect("settings write lock"); - let mut next = settings.clone(); - next.git_enabled = enabled; - save_settings(&folder, &next).map_err(|e| e.to_string())?; - *settings = next; - - 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")?; - - if folder != expected_folder { - return Err("Notes folder changed".to_string()); - } - - folder - }; - - let mut cloned_settings = { - let settings = state.settings.read().expect("settings read lock"); - settings.clone() + let updated = { + let settings = workspace.settings().read().expect("settings read lock"); + let mut updated = settings.clone(); + updated.git_enabled = enabled; + updated }; - cloned_settings.git_enabled = enabled; - - save_settings(&folder, &cloned_settings).map_err(|e| e.to_string())?; + save_settings(&folder, &updated).map_err(|e| e.to_string())?; { - let mut settings = state.settings.write().expect("settings write lock"); - *settings = cloned_settings; + let mut settings = workspace.settings().write().expect("settings write lock"); + *settings = updated; } Ok(()) @@ -6256,25 +6217,6 @@ pub fn run() { } } - // Load per-folder settings if notes folder is set - let settings = if let Some(ref folder) = app_config.notes_folder { - load_settings(folder).unwrap_or_default() - } else { - Settings::default() - }; - - // Initialize search index if notes folder is set - let ignored_dirs = get_effective_ignored_dirs(&settings); - let search_index = if let Some(ref folder) = app_config.notes_folder { - if let Ok(index_path) = get_search_index_path(app.handle()) { - SearchIndex::new(&index_path).ok().inspect(|idx| { - let _ = idx.rebuild_index(&PathBuf::from(folder), &ignored_dirs); - }) - } else { - None - } - } - let default_workspace = app_config.notes_folder.clone(); if app_config.global_settings.is_none() { if let Some(default_workspace) = default_workspace.as_deref() { @@ -7914,7 +7856,7 @@ mod workspace_registry_tests { .expect("create parent dirs"); let content = "# Untitled\n\n"; - let result = persistence::save_if_revision(&file_path, &content, None) + let result = persistence::save_if_revision(&file_path, content, None) .expect("atomic create succeeds"); assert!(matches!(result, persistence::SaveResult::Saved { .. })); assert!(file_path.exists()); @@ -7943,7 +7885,7 @@ mod workspace_registry_tests { std::fs::write(&file_path, "existing content").expect("seed existing file"); let content = "# Untitled\n\n"; - let result = persistence::save_if_revision(&file_path, &content, None) + let result = persistence::save_if_revision(&file_path, content, None) .expect("conflict is typed"); assert!(matches!(result, persistence::SaveResult::Conflict { .. })); assert_eq!(std::fs::read_to_string(&file_path).unwrap(), "existing content"); From 2d101d50ceafff16ea1085a6dd5845c2aee39a44 Mon Sep 17 00:00:00 2001 From: Norkep Date: Tue, 4 Aug 2026 10:24:15 +0200 Subject: [PATCH 21/32] style: format Rust code with cargo fmt --- src-tauri/src/draft_checkpoint.rs | 7 +- src-tauri/src/git.rs | 75 ++- src-tauri/src/hashing.rs | 82 +-- src-tauri/src/lib.rs | 702 ++++++++++++------------ src-tauri/src/note_persistence_tests.rs | 13 +- src-tauri/src/persistence.rs | 12 +- 6 files changed, 442 insertions(+), 449 deletions(-) diff --git a/src-tauri/src/draft_checkpoint.rs b/src-tauri/src/draft_checkpoint.rs index 4c5b2562..28ba7bc8 100644 --- a/src-tauri/src/draft_checkpoint.rs +++ b/src-tauri/src/draft_checkpoint.rs @@ -324,7 +324,6 @@ fn sync_parent_directory(_parent: &Path) -> io::Result<()> { Ok(()) } - #[cfg(test)] mod tests { use super::*; @@ -398,7 +397,11 @@ mod tests { // Write a truncated JSON file directly let checkpoint_dir = app_data.join(CHECKPOINT_DIRECTORY_NAME); let truncated_path = checkpoint_dir.join("truncated.json"); - fs::write(&truncated_path, r#"{"key":{"window_label":"main","note_id":"note2"#).unwrap(); + fs::write( + &truncated_path, + r#"{"key":{"window_label":"main","note_id":"note2"#, + ) + .unwrap(); let checkpoints = list_checkpoints(&app_data).expect("list checkpoints"); // Only the valid checkpoint should be returned diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index 93b1c11d..87e5b086 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -26,7 +26,7 @@ pub struct GitStatus { pub has_upstream: bool, // Whether the current branch tracks an upstream pub remote_url: Option, // URL of the 'origin' remote pub changed_count: usize, - pub ahead_count: i32, // -1 if no upstream tracking + pub ahead_count: i32, // -1 if no upstream tracking pub behind_count: i32, // -1 if no upstream tracking pub current_branch: Option, pub error: Option, @@ -97,13 +97,9 @@ pub fn get_status(path: &Path) -> GitStatus { } // Check for remote - if let Ok(output) = git_cmd() - .args(["remote"]) - .current_dir(path) - .output() - { - status.has_remote = output.status.success() - && !String::from_utf8_lossy(&output.stdout).trim().is_empty(); + if let Ok(output) = git_cmd().args(["remote"]).current_dir(path).output() { + status.has_remote = + output.status.success() && !String::from_utf8_lossy(&output.stdout).trim().is_empty(); // Get remote URL if remote exists if status.has_remote { @@ -164,11 +160,7 @@ pub fn get_status(path: &Path) -> GitStatus { /// Stage all changes and commit pub fn commit_all(path: &Path, message: &str) -> GitResult { // Stage all changes - let stage_output = match git_cmd() - .args(["add", "-A"]) - .current_dir(path) - .output() - { + let stage_output = match git_cmd().args(["add", "-A"]).current_dir(path).output() { Ok(output) => output, Err(e) => { return GitResult { @@ -189,7 +181,11 @@ pub fn commit_all(path: &Path, message: &str) -> GitResult { error: Some(format!( "Failed to stage changes: {}{}", stderr, - if stdout.is_empty() { String::new() } else { format!("\n{}", stdout) } + if stdout.is_empty() { + String::new() + } else { + format!("\n{}", stdout) + } )), }; } @@ -237,7 +233,13 @@ pub fn commit_all(path: &Path, message: &str) -> GitResult { /// Push to remote pub fn push(path: &Path) -> GitResult { let output = git_cmd() - .args(["-c", "http.lowSpeedLimit=1000", "-c", "http.lowSpeedTime=10", "push"]) + .args([ + "-c", + "http.lowSpeedLimit=1000", + "-c", + "http.lowSpeedTime=10", + "push", + ]) .env("GIT_SSH_COMMAND", "ssh -o ConnectTimeout=10") .current_dir(path) .output(); @@ -269,7 +271,14 @@ pub fn push(path: &Path) -> GitResult { /// Fetch from remote to update tracking refs pub fn fetch(path: &Path) -> GitResult { let output = git_cmd() - .args(["-c", "http.lowSpeedLimit=1000", "-c", "http.lowSpeedTime=10", "fetch", "--quiet"]) + .args([ + "-c", + "http.lowSpeedLimit=1000", + "-c", + "http.lowSpeedTime=10", + "fetch", + "--quiet", + ]) .env("GIT_SSH_COMMAND", "ssh -o ConnectTimeout=10") .current_dir(path) .output(); @@ -301,7 +310,15 @@ pub fn fetch(path: &Path) -> GitResult { /// Pull from remote pub fn pull(path: &Path) -> GitResult { let output = git_cmd() - .args(["-c", "http.lowSpeedLimit=1000", "-c", "http.lowSpeedTime=10", "-c", "pull.rebase=false", "pull"]) + .args([ + "-c", + "http.lowSpeedLimit=1000", + "-c", + "http.lowSpeedTime=10", + "-c", + "pull.rebase=false", + "pull", + ]) .env("GIT_SSH_COMMAND", "ssh -o ConnectTimeout=10") .current_dir(path) .output(); @@ -360,7 +377,10 @@ pub fn add_remote(path: &Path, url: &str) -> GitResult { return GitResult { success: false, message: None, - error: Some("Invalid remote URL format. URL must start with https://, http://, or git@".to_string()), + error: Some( + "Invalid remote URL format. URL must start with https://, http://, or git@" + .to_string(), + ), }; } @@ -410,7 +430,10 @@ pub fn set_remote_url(path: &Path, url: &str) -> GitResult { return GitResult { success: false, message: None, - error: Some("Invalid remote URL format. URL must start with https://, http://, or git@".to_string()), + error: Some( + "Invalid remote URL format. URL must start with https://, http://, or git@" + .to_string(), + ), }; } @@ -496,7 +519,16 @@ pub fn remove_remote(path: &Path) -> GitResult { /// Push to remote and set upstream tracking (git push -u origin ) pub fn push_with_upstream(path: &Path, branch: &str) -> GitResult { let output = git_cmd() - .args(["-c", "http.lowSpeedLimit=1000", "-c", "http.lowSpeedTime=10", "push", "-u", "origin", branch]) + .args([ + "-c", + "http.lowSpeedLimit=1000", + "-c", + "http.lowSpeedTime=10", + "push", + "-u", + "origin", + branch, + ]) .env("GIT_SSH_COMMAND", "ssh -o ConnectTimeout=10") .current_dir(path) .output(); @@ -554,7 +586,8 @@ fn parse_pull_error(stderr: &str) -> String { } else if stderr.contains("CONFLICT") || stderr.contains("Merge conflict") { "Pull failed due to merge conflicts. Resolve conflicts manually.".to_string() } else if stderr.contains("not possible to fast-forward") { - "Pull failed: local and remote have diverged. Try pulling with rebase or merging manually.".to_string() + "Pull failed: local and remote have diverged. Try pulling with rebase or merging manually." + .to_string() } else if stderr.contains("unrelated histories") { "Pull failed: repositories have unrelated histories. Merge them manually or re-run with --allow-unrelated-histories.".to_string() } else { diff --git a/src-tauri/src/hashing.rs b/src-tauri/src/hashing.rs index b42f8429..2efca487 100644 --- a/src-tauri/src/hashing.rs +++ b/src-tauri/src/hashing.rs @@ -17,79 +17,19 @@ pub fn sha256_hex(input: &[u8]) -> String { /// Returns 32-byte array. pub fn sha256(input: &[u8]) -> [u8; 32] { const INITIAL: [u32; 8] = [ - 0x6a09e667, - 0xbb67ae85, - 0x3c6ef372, - 0xa54ff53a, - 0x510e527f, - 0x9b05688c, - 0x1f83d9ab, + 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, + 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, ]; @@ -256,4 +196,4 @@ mod tests { assert_eq!(hash1, hash2); assert_eq!(hash1.len(), 64); } -} \ No newline at end of file +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7116a53a..7ffe2d47 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -10,17 +10,17 @@ use tantivy::collector::TopDocs; use tantivy::query::QueryParser; use tantivy::schema::*; use tantivy::{doc, Index, IndexReader, IndexWriter, ReloadPolicy}; -use tauri::{AppHandle, Emitter, Manager, State, WebviewUrl}; use tauri::webview::{WebviewWindow, WebviewWindowBuilder}; +use tauri::{AppHandle, Emitter, Manager, State, WebviewUrl}; use tauri_plugin_clipboard_manager::ClipboardExt; use tokio::fs; use tokio::io::AsyncWriteExt; +mod draft_checkpoint; mod git; +mod hashing; mod persistence; -mod draft_checkpoint; mod watcher_debounce; -mod hashing; use watcher_debounce::{WatcherDebounce, WATCHER_DEBOUNCE_WINDOW}; @@ -64,8 +64,12 @@ pub struct NoteConflictSnapshot { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(tag = "status", rename_all = "camelCase")] pub enum NoteSaveResult { - Saved { note: Note }, - Conflict { current: Option }, + Saved { + note: Note, + }, + Conflict { + current: Option, + }, } // Theme color customization @@ -163,11 +167,7 @@ impl WindowSession { } } -fn upsert_window_session_workspace( - config: &mut AppConfig, - label: &str, - workspace: String, -) { +fn upsert_window_session_workspace(config: &mut AppConfig, label: &str, workspace: String) { config .records .entry(label.to_string()) @@ -203,9 +203,7 @@ fn restorable_window_sessions( }) .map(|(label, record)| (label.clone(), record.clone())) .collect(); - records.sort_by(|(left, _), (right, _)| { - (left != "main", left).cmp(&(right != "main", right)) - }); + records.sort_by(|(left, _), (right, _)| (left != "main", left).cmp(&(right != "main", right))); records } @@ -342,9 +340,7 @@ fn new_window_event_target<'a>( main.or(first_full_editor) } -fn build_application_menu( - app_handle: &AppHandle, -) -> tauri::Result> { +fn build_application_menu(app_handle: &AppHandle) -> tauri::Result> { use tauri::menu::{Menu, MenuItem, PredefinedMenuItem, Submenu}; let menu = Menu::default(app_handle)?; @@ -454,7 +450,10 @@ fn emit_native_open_folder_request(app: &AppHandle) { fn workspace_path_key(path: &Path) -> String { let path_str = path.to_string_lossy(); - format!("workspace-{}", crate::hashing::sha256_hex(path_str.as_bytes())) + format!( + "workspace-{}", + crate::hashing::sha256_hex(path_str.as_bytes()) + ) } fn workspace_window_label(path: &Path, sequence: u64) -> String { @@ -474,11 +473,7 @@ struct WorkspaceBindings { } impl WorkspaceBindings { - fn bind( - &mut self, - window_label: impl Into, - path: impl Into, - ) -> Option { + fn bind(&mut self, window_label: impl Into, path: impl Into) -> Option { let window_label = window_label.into(); let path = path.into(); @@ -1158,7 +1153,7 @@ impl WorkspaceSession { // App state with improved structure pub struct AppState { - pub app_config: RwLock, // notes_folder path (stored in app data) + pub app_config: RwLock, // notes_folder path (stored in app data) pub settings: RwLock, // per-folder settings (stored in .scratch/) pub notes_cache: RwLock>, pub file_watcher: Mutex>, @@ -1239,9 +1234,7 @@ impl WorkspaceRuntime<'_> { } fn uses_default_workspace_fallback(window_label: &str) -> bool { - window_label == "main" - || window_label == "preferences" - || window_label.starts_with("preview-") + window_label == "main" || window_label == "preferences" || window_label.starts_with("preview-") } impl AppState { @@ -1384,7 +1377,8 @@ impl AppState { .cloned(); } - let removed = self.workspace_sessions + let removed = self + .workspace_sessions .write() .expect("workspace sessions write lock") .remove(¬es_folder); @@ -1590,7 +1584,12 @@ fn strip_markdown(text: &str) -> String { while let Some(start) = result.find("~~") { if let Some(end) = result[start + 2..].find("~~") { let inner = &result[start + 2..start + 2 + end]; - result = format!("{}{}{}", &result[..start], inner, &result[start + 4 + end..]); + result = format!( + "{}{}{}", + &result[..start], + inner, + &result[start + 4 + end..] + ); } else { break; } @@ -1600,7 +1599,12 @@ fn strip_markdown(text: &str) -> String { while let Some(start) = result.find("**") { if let Some(end) = result[start + 2..].find("**") { let inner = &result[start + 2..start + 2 + end]; - result = format!("{}{}{}", &result[..start], inner, &result[start + 4 + end..]); + result = format!( + "{}{}{}", + &result[..start], + inner, + &result[start + 4 + end..] + ); } else { break; } @@ -1608,7 +1612,12 @@ fn strip_markdown(text: &str) -> String { while let Some(start) = result.find("__") { if let Some(end) = result[start + 2..].find("__") { let inner = &result[start + 2..start + 2 + end]; - result = format!("{}{}{}", &result[..start], inner, &result[start + 4 + end..]); + result = format!( + "{}{}{}", + &result[..start], + inner, + &result[start + 4 + end..] + ); } else { break; } @@ -1618,7 +1627,12 @@ fn strip_markdown(text: &str) -> String { while let Some(start) = result.find('`') { if let Some(end) = result[start + 1..].find('`') { let inner = &result[start + 1..start + 1 + end]; - result = format!("{}{}{}", &result[..start], inner, &result[start + 2 + end..]); + result = format!( + "{}{}{}", + &result[..start], + inner, + &result[start + 2 + end..] + ); } else { break; } @@ -1638,7 +1652,12 @@ fn strip_markdown(text: &str) -> String { if let Some(end) = result[start + 1..].find('*') { if end > 0 { let inner = &result[start + 1..start + 1 + end]; - result = format!("{}{}{}", &result[..start], inner, &result[start + 2 + end..]); + result = format!( + "{}{}{}", + &result[..start], + inner, + &result[start + 2 + end..] + ); } else { break; } @@ -1651,7 +1670,12 @@ fn strip_markdown(text: &str) -> String { if let Some(end) = result[start + 1..].find('_') { if end > 0 { let inner = &result[start + 1..start + 1 + end]; - result = format!("{}{}{}", &result[..start], inner, &result[start + 2 + end..]); + result = format!( + "{}{}{}", + &result[..start], + inner, + &result[start + 2 + end..] + ); } else { break; } @@ -1700,9 +1724,10 @@ const DEFAULT_IGNORED_DIRS: &[&str] = &[ /// Get the effective ignored directories from settings (or defaults if not customized). fn get_effective_ignored_dirs(settings: &WorkspaceSettings) -> Vec { - settings.ignored_patterns.clone().unwrap_or_else(|| { - DEFAULT_IGNORED_DIRS.iter().map(|s| s.to_string()).collect() - }) + settings + .ignored_patterns + .clone() + .unwrap_or_else(|| DEFAULT_IGNORED_DIRS.iter().map(|s| s.to_string()).collect()) } /// Filter for WalkDir: skips excluded and user-ignored directories. @@ -1716,7 +1741,11 @@ fn is_visible_notes_entry(entry: &walkdir::DirEntry, ignored_dirs: &[String]) -> /// Convert an absolute file path to a note ID (relative path from notes root, no .md extension, POSIX separators). /// Returns None if the path is outside the root, not a .md file, or in an excluded/ignored directory. -fn id_from_abs_path(notes_root: &Path, file_path: &Path, ignored_dirs: &[String]) -> Option { +fn id_from_abs_path( + notes_root: &Path, + file_path: &Path, + ignored_dirs: &[String], +) -> Option { let rel = file_path.strip_prefix(notes_root).ok()?; // Skip files inside excluded or ignored directories. @@ -1739,7 +1768,9 @@ fn id_from_abs_path(notes_root: &Path, file_path: &Path, ignored_dirs: &[String] // Strip .md by converting to string and trimming (avoids with_extension // which breaks on stems containing dots like "meeting.2024-01-15.md"). let rel_str = rel.to_str()?; - let id = rel_str.strip_suffix(".md")?.replace(std::path::MAIN_SEPARATOR, "/"); + let id = rel_str + .strip_suffix(".md")? + .replace(std::path::MAIN_SEPARATOR, "/"); if id.is_empty() { None @@ -1808,7 +1839,9 @@ fn get_workspace_search_index_path(app: &AppHandle, notes_folder: &Path) -> Resu fn remove_workspace_search_index(app: &AppHandle, path: &str) -> Result<()> { let app_data = app.path().app_data_dir()?; - let index_path = app_data.join("workspace_search_indexes").join(workspace_path_key(Path::new(path))); + let index_path = app_data + .join("workspace_search_indexes") + .join(workspace_path_key(Path::new(path))); if index_path.exists() { std::fs::remove_dir_all(index_path)?; } @@ -1993,9 +2026,7 @@ fn update_window_session( window: WebviewWindow, state: State, ) -> Result<(), String> { - let workspace = state - .workspace_for_window(window.label())? - .notes_folder()?; + let workspace = state.workspace_for_window(window.label())?.notes_folder()?; let mut patch = patch; let patch_object = patch .as_object_mut() @@ -2051,12 +2082,7 @@ fn handle_window_destroyed(app: &AppHandle, label: &str) { { let mut config = state.app_config.write().expect("app config write lock"); let mut next = config.clone(); - if remove_window_session_on_destroy( - &mut next, - label, - is_quitting, - preserve_for_preview, - ) { + if remove_window_session_on_destroy(&mut next, label, is_quitting, preserve_for_preview) { match save_app_config(app, &next) { Ok(()) => *config = next, Err(error) => { @@ -2095,10 +2121,7 @@ fn request_full_window_closure_for_preview(app: &AppHandle) { } #[tauri::command] -fn clear_window_session( - window: WebviewWindow, - state: State, -) -> Result<(), String> { +fn clear_window_session(window: WebviewWindow, state: State) -> Result<(), String> { clear_window_session_record(window.app_handle(), &state, window.label())?; Ok(()) } @@ -2217,11 +2240,7 @@ async fn switch_workspace( let mut config = state.app_config.write().expect("app_config write lock"); let mut next = config.clone(); remember_workspace(&mut next, notes_folder.clone()); - upsert_window_session_workspace( - &mut next, - window.label(), - notes_folder.clone(), - ); + upsert_window_session_workspace(&mut next, window.label(), notes_folder.clone()); save_app_config(&app, &next).map_err(|error| error.to_string())?; *config = next; } @@ -2258,10 +2277,7 @@ fn restore_window_session( .ok_or_else(|| "Main window not found".to_string())?; if let Some(geometry) = &record.geometry { let _ = window.set_position(tauri::PhysicalPosition::new(geometry.x, geometry.y)); - let _ = window.set_size(tauri::PhysicalSize::new( - geometry.width, - geometry.height, - )); + let _ = window.set_size(tauri::PhysicalSize::new(geometry.width, geometry.height)); } return Ok(()); } @@ -2455,9 +2471,9 @@ async fn list_notes( let b_pinned = pinned_ids.contains(&b.id); match (a_pinned, b_pinned) { - (true, false) => std::cmp::Ordering::Less, // a pinned, b not -> a first + (true, false) => std::cmp::Ordering::Less, // a pinned, b not -> a first (false, true) => std::cmp::Ordering::Greater, // b pinned, a not -> b first - _ => b.modified.cmp(&a.modified), // both same status -> sort by date (newest first) + _ => b.modified.cmp(&a.modified), // both same status -> sort by date (newest first) } }); @@ -2608,16 +2624,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}"))? @@ -2631,7 +2644,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(), @@ -2641,8 +2657,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}"))? @@ -2654,7 +2669,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, @@ -2672,7 +2690,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, @@ -2689,7 +2710,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) @@ -2759,7 +2783,10 @@ async fn save_note_in_workspace( // Preserve directory prefix for notes in subfolders let (dir_prefix, desired_id) = if let Some(pos) = existing_id.rfind('/') { let prefix = &existing_id[..pos]; - (Some(prefix.to_string()), format!("{}/{}", prefix, sanitized_leaf)) + ( + Some(prefix.to_string()), + format!("{}/{}", prefix, sanitized_leaf), + ) } else { (None, sanitized_leaf.clone()) }; @@ -2834,11 +2861,9 @@ async fn save_note_in_workspace( persistence::SaveResult::Saved { .. } => Ok(NoteSaveResult::Saved { note: read_note_from_path(create_id, &create_path)?, }), - persistence::SaveResult::Conflict { current } => { - Ok(NoteSaveResult::Conflict { - current: note_conflict_snapshot(current), - }) - } + persistence::SaveResult::Conflict { current } => Ok(NoteSaveResult::Conflict { + current: note_conflict_snapshot(current), + }), } }) .await @@ -3070,12 +3095,15 @@ async fn create_note( { let mut cache = workspace.notes_cache().write().expect("cache write lock"); - cache.insert(created_note.id.clone(), NoteMetadata { - id: created_note.id.clone(), - title: created_note.title.clone(), - preview: generate_preview(&created_note.content), - modified: created_note.modified, - }); + cache.insert( + created_note.id.clone(), + NoteMetadata { + id: created_note.id.clone(), + title: created_note.title.clone(), + preview: generate_preview(&created_note.content), + modified: created_note.modified, + }, + ); } emit_workspace_file_change( @@ -3210,12 +3238,15 @@ async fn duplicate_note( { let mut cache = workspace.notes_cache().write().expect("cache write lock"); - cache.insert(created_note.id.clone(), NoteMetadata { - id: created_note.id.clone(), - title: created_note.title.clone(), - preview: generate_preview(&created_note.content), - modified: created_note.modified, - }); + cache.insert( + created_note.id.clone(), + NoteMetadata { + id: created_note.id.clone(), + title: created_note.title.clone(), + preview: generate_preview(&created_note.content), + modified: created_note.modified, + }, + ); } emit_workspace_file_change( @@ -3315,9 +3346,7 @@ async fn create_folder( window: WebviewWindow, state: State<'_, AppState>, ) -> Result<(), String> { - let folder = state - .workspace_for_window(window.label())? - .notes_folder()?; + let folder = state.workspace_for_window(window.label())?.notes_folder()?; validate_folder_path(&path)?; @@ -3525,7 +3554,9 @@ async fn move_note( // Ensure target directory exists if let Some(parent) = dest_path.parent() { - fs::create_dir_all(parent).await.map_err(|e| e.to_string())?; + fs::create_dir_all(parent) + .await + .map_err(|e| e.to_string())?; } // Handle collision @@ -3644,7 +3675,9 @@ async fn move_folder( // Ensure target parent exists if let Some(parent) = dest.parent() { - fs::create_dir_all(parent).await.map_err(|e| e.to_string())?; + fs::create_dir_all(parent) + .await + .map_err(|e| e.to_string())?; } // Compute old and new path prefixes for updating IDs @@ -3909,8 +3942,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 read_file_content_from_path(path: &Path) -> Result { @@ -3961,13 +3998,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)?, }), @@ -4012,7 +4044,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") => {} @@ -4138,7 +4173,7 @@ async fn import_file_to_folder( } Err(_) => return Err("Failed to create file".to_string()), } - }; + } let modified = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -4198,7 +4233,9 @@ async fn search_notes( let indexed_result = { let index = workspace.search_index().lock().expect("search index mutex"); (*index).as_ref().map(|search_index| { - search_index.search(&trimmed_query, 20).map_err(|e| e.to_string()) + search_index + .search(&trimmed_query, 20) + .map_err(|e| e.to_string()) }) }; @@ -4209,7 +4246,10 @@ async fn search_notes( fallback_search(&trimmed_query, &workspace).await } Some(Err(e)) => { - eprintln!("Tantivy search error, falling back to substring search: {}", e); + eprintln!( + "Tantivy search error, falling back to substring search: {}", + e + ); fallback_search(&trimmed_query, &workspace).await } None => { @@ -4282,7 +4322,11 @@ async fn fallback_search( } } - results.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal)); + results.sort_by(|a, b| { + b.score + .partial_cmp(&a.score) + .unwrap_or(std::cmp::Ordering::Equal) + }); results.truncate(20); Ok(results) @@ -4319,13 +4363,15 @@ fn semantic_file_change_event( } } - let path = current_note.map(|note| note.path.clone()).unwrap_or_else(|| { - let id = previous_id.as_deref().unwrap_or_default(); - PathBuf::from(&workspace) - .join(format!("{id}.md")) - .to_string_lossy() - .into_owned() - }); + let path = current_note + .map(|note| note.path.clone()) + .unwrap_or_else(|| { + let id = previous_id.as_deref().unwrap_or_default(); + PathBuf::from(&workspace) + .join(format!("{id}.md")) + .to_string_lossy() + .into_owned() + }); FileChangeEvent { workspace, @@ -4384,17 +4430,10 @@ fn process_external_note_change( let modified = std::fs::metadata(&path) .ok() .and_then(|metadata| metadata.modified().ok()) - .and_then(|time| { - time.duration_since(std::time::UNIX_EPOCH).ok() - }) + .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok()) .map(|duration| duration.as_secs() as i64) .unwrap_or(0); - let _ = search_index.index_note( - ¬e_id, - &title, - &content, - modified, - ); + let _ = search_index.index_note(¬e_id, &title, &content, modified); } Err(_) if !path.exists() => { let _ = search_index.delete_note(¬e_id); @@ -4568,9 +4607,7 @@ async fn save_clipboard_image( return Err("Clipboard data is empty".to_string()); } - let folder = state - .workspace_for_window(window.label())? - .notes_folder()?; + let folder = state.workspace_for_window(window.label())?.notes_folder()?; // Decode base64 let image_data = base64::engine::general_purpose::STANDARD @@ -4619,9 +4656,7 @@ async fn copy_image_to_assets( window: WebviewWindow, state: State<'_, AppState>, ) -> Result { - let folder = state - .workspace_for_window(window.label())? - .notes_folder()?; + let folder = state.workspace_for_window(window.label())?.notes_folder()?; let source = PathBuf::from(&source_path); if !source.exists() { @@ -4678,10 +4713,7 @@ async fn copy_image_to_assets( } #[tauri::command] -fn rebuild_search_index( - window: WebviewWindow, - state: State, -) -> Result<(), String> { +fn rebuild_search_index(window: WebviewWindow, state: State) -> Result<(), String> { let workspace = state.workspace_for_window(window.label())?; let folder = workspace.notes_folder()?; @@ -4807,30 +4839,21 @@ async fn git_get_status( match folder { Some(path) => { - tauri::async_runtime::spawn_blocking(move || { - git::get_status(&PathBuf::from(path)) - }) - .await - .map_err(|e| e.to_string()) + tauri::async_runtime::spawn_blocking(move || git::get_status(&PathBuf::from(path))) + .await + .map_err(|e| e.to_string()) } None => Ok(git::GitStatus::default()), } } #[tauri::command] -async fn git_init_repo( - window: WebviewWindow, - state: State<'_, AppState>, -) -> Result<(), String> { - let folder = state - .workspace_for_window(window.label())? - .notes_folder()?; +async fn git_init_repo(window: WebviewWindow, state: State<'_, AppState>) -> Result<(), String> { + let folder = state.workspace_for_window(window.label())?.notes_folder()?; - tauri::async_runtime::spawn_blocking(move || { - git::git_init(&PathBuf::from(folder)) - }) - .await - .map_err(|e| e.to_string())? + tauri::async_runtime::spawn_blocking(move || git::git_init(&PathBuf::from(folder))) + .await + .map_err(|e| e.to_string())? } #[tauri::command] @@ -4845,13 +4868,11 @@ async fn git_commit( .and_then(|workspace| workspace.notes_folder().ok()); match folder { - Some(path) => { - tauri::async_runtime::spawn_blocking(move || { - git::commit_all(&PathBuf::from(path), &message) - }) - .await - .map_err(|e| e.to_string()) - } + Some(path) => tauri::async_runtime::spawn_blocking(move || { + git::commit_all(&PathBuf::from(path), &message) + }) + .await + .map_err(|e| e.to_string()), None => Ok(git::GitResult { success: false, message: None, @@ -4871,13 +4892,9 @@ async fn git_push( .and_then(|workspace| workspace.notes_folder().ok()); match folder { - Some(path) => { - tauri::async_runtime::spawn_blocking(move || { - git::push(&PathBuf::from(path)) - }) + Some(path) => tauri::async_runtime::spawn_blocking(move || git::push(&PathBuf::from(path))) .await - .map_err(|e| e.to_string()) - } + .map_err(|e| e.to_string()), None => Ok(git::GitResult { success: false, message: None, @@ -4898,11 +4915,9 @@ async fn git_fetch( match folder { Some(path) => { - tauri::async_runtime::spawn_blocking(move || { - git::fetch(&PathBuf::from(path)) - }) - .await - .map_err(|e| e.to_string()) + tauri::async_runtime::spawn_blocking(move || git::fetch(&PathBuf::from(path))) + .await + .map_err(|e| e.to_string()) } None => Ok(git::GitResult { success: false, @@ -4923,13 +4938,9 @@ async fn git_pull( .and_then(|workspace| workspace.notes_folder().ok()); match folder { - Some(path) => { - tauri::async_runtime::spawn_blocking(move || { - git::pull(&PathBuf::from(path)) - }) + Some(path) => tauri::async_runtime::spawn_blocking(move || git::pull(&PathBuf::from(path))) .await - .map_err(|e| e.to_string()) - } + .map_err(|e| e.to_string()), None => Ok(git::GitResult { success: false, message: None, @@ -4950,13 +4961,11 @@ async fn git_add_remote( .and_then(|workspace| workspace.notes_folder().ok()); match folder { - Some(path) => { - tauri::async_runtime::spawn_blocking(move || { - git::add_remote(&PathBuf::from(path), &url) - }) - .await - .map_err(|e| e.to_string()) - } + Some(path) => tauri::async_runtime::spawn_blocking(move || { + git::add_remote(&PathBuf::from(path), &url) + }) + .await + .map_err(|e| e.to_string()), None => Ok(git::GitResult { success: false, message: None, @@ -4977,13 +4986,11 @@ async fn git_set_remote_url( .and_then(|workspace| workspace.notes_folder().ok()); match folder { - Some(path) => { - tauri::async_runtime::spawn_blocking(move || { - git::set_remote_url(&PathBuf::from(path), &url) - }) - .await - .map_err(|e| e.to_string()) - } + Some(path) => tauri::async_runtime::spawn_blocking(move || { + git::set_remote_url(&PathBuf::from(path), &url) + }) + .await + .map_err(|e| e.to_string()), None => Ok(git::GitResult { success: false, message: None, @@ -5004,11 +5011,9 @@ async fn git_remove_remote( match folder { Some(path) => { - tauri::async_runtime::spawn_blocking(move || { - git::remove_remote(&PathBuf::from(path)) - }) - .await - .map_err(|e| e.to_string()) + tauri::async_runtime::spawn_blocking(move || git::remove_remote(&PathBuf::from(path))) + .await + .map_err(|e| e.to_string()) } None => Ok(git::GitResult { success: false, @@ -5035,10 +5040,9 @@ async fn git_push_with_upstream( let status = git::get_status(&PathBuf::from(&path)); match status.current_branch { Some(branch) => { - if !branch - .chars() - .all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '-' | '_' | '.')) - { + if !branch.chars().all(|c| { + c.is_ascii_alphanumeric() || matches!(c, '/' | '-' | '_' | '.') + }) { return git::GitResult { success: false, message: None, @@ -5170,26 +5174,42 @@ fn cli_target_path() -> PathBuf { #[tauri::command] fn get_cli_status() -> Result { #[cfg(not(target_os = "macos"))] - return Ok(CliStatus { supported: false, installed: false, path: None }); + return Ok(CliStatus { + supported: false, + installed: false, + path: None, + }); #[cfg(target_os = "macos")] { let target = cli_target_path(); if !target.exists() && target.symlink_metadata().is_err() { - return Ok(CliStatus { supported: true, installed: false, path: None }); + return Ok(CliStatus { + supported: true, + installed: false, + path: None, + }); } // Verify this is our wrapper (has marker) and points to the current binary let content = std::fs::read_to_string(&target).unwrap_or_default(); if !content.contains(SCRATCH_CLI_MARKER) { // Foreign binary at this path — don't claim it as ours - return Ok(CliStatus { supported: true, installed: false, path: None }); + return Ok(CliStatus { + supported: true, + installed: false, + path: None, + }); } let current_exe = std::env::current_exe() .map(|p| p.to_string_lossy().into_owned()) .unwrap_or_default(); if !current_exe.is_empty() && !content.contains(¤t_exe) { // Our wrapper but points to a moved/deleted binary — needs reinstall - return Ok(CliStatus { supported: true, installed: false, path: None }); + return Ok(CliStatus { + supported: true, + installed: false, + path: None, + }); } Ok(CliStatus { supported: true, @@ -5228,8 +5248,8 @@ fn install_cli() -> Result { .map_err(|e| format!("Failed to remove existing file: {}", e))?; } - let exe_path = std::env::current_exe() - .map_err(|e| format!("Cannot find exe path: {}", e))?; + let exe_path = + std::env::current_exe().map_err(|e| format!("Cannot find exe path: {}", e))?; // Shell-escape the exe path using single quotes to prevent // interpretation of $, `, ", and other metacharacters. @@ -5240,8 +5260,7 @@ fn install_cli() -> Result { // the terminal is not blocked waiting for the GUI app to exit. let script = format!( "#!/bin/sh\n{}\nnohup {} \"$@\" >/dev/null 2>&1 &\n", - SCRATCH_CLI_MARKER, - escaped_exe + SCRATCH_CLI_MARKER, escaped_exe ); std::fs::write(&target, script.as_bytes()) .map_err(|e| format!("Failed to write CLI script: {}", e))?; @@ -5528,9 +5547,7 @@ async fn ai_execute_claude( window: WebviewWindow, state: State<'_, AppState>, ) -> Result { - let folder = state - .workspace_for_window(window.label())? - .notes_folder()?; + let folder = state.workspace_for_window(window.label())?.notes_folder()?; let path = PathBuf::from(&file_path); let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); if !ext.eq_ignore_ascii_case("md") && !ext.eq_ignore_ascii_case("markdown") { @@ -5596,9 +5613,7 @@ async fn ai_execute_opencode( window: WebviewWindow, state: State<'_, AppState>, ) -> Result { - let folder = state - .workspace_for_window(window.label())? - .notes_folder()?; + let folder = state.workspace_for_window(window.label())?.notes_folder()?; let path = PathBuf::from(&file_path); let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); if !ext.eq_ignore_ascii_case("md") && !ext.eq_ignore_ascii_case("markdown") { @@ -5663,9 +5678,7 @@ async fn ai_execute_ollama( window: WebviewWindow, state: State<'_, AppState>, ) -> Result { - let folder = state - .workspace_for_window(window.label())? - .notes_folder()?; + let folder = state.workspace_for_window(window.label())?.notes_folder()?; let path = PathBuf::from(&file_path); let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); if !ext.eq_ignore_ascii_case("md") && !ext.eq_ignore_ascii_case("markdown") { @@ -5766,7 +5779,10 @@ async fn ai_execute_ollama( return Ok(AiExecutionResult { success: false, output: String::new(), - error: Some("Authentication required. Run `ollama login` in your terminal to sign in.".to_string()), + error: Some( + "Authentication required. Run `ollama login` in your terminal to sign in." + .to_string(), + ), }); } } @@ -6005,7 +6021,10 @@ fn open_preferences_window(app: AppHandle) -> Result<(), String> { // Preview mode: create a lightweight window for editing a single file fn create_preview_window(app: &AppHandle, file_path: &str) -> Result<(), String> { - let label = format!("preview-{}", crate::hashing::sha256_hex(file_path.as_bytes())); + let label = format!( + "preview-{}", + crate::hashing::sha256_hex(file_path.as_bytes()) + ); // If window already exists for this file, focus it if let Some(window) = app.get_webview_window(&label) { @@ -6200,7 +6219,10 @@ pub fn run() { Ok(normalized) => { // Path is structurally valid but not currently a directory // (e.g., unmounted drive). Preserve the user's preference. - eprintln!("Notes folder not found (may be temporarily unavailable): {:?}", normalized); + eprintln!( + "Notes folder not found (may be temporarily unavailable): {:?}", + normalized + ); } Err(_) => { app_config.notes_folder = None; @@ -6252,7 +6274,14 @@ pub fn run() { app.manage(state); // Add notes folder to asset protocol scope so images can be served - if let Some(ref folder) = app.state::().app_config.read().expect("app_config read lock").notes_folder.clone() { + if let Some(ref folder) = app + .state::() + .app_config + .read() + .expect("app_config read lock") + .notes_folder + .clone() + { let _ = app.asset_protocol_scope().allow_directory(folder, true); } @@ -6296,26 +6325,19 @@ pub fn run() { .filter(|workspace| Path::new(workspace).is_dir()) { let mut next = config_snapshot.clone(); - upsert_window_session_workspace( - &mut next, - "main", - default_workspace.clone(), - ); + upsert_window_session_workspace(&mut next, "main", default_workspace.clone()); if save_app_config(app.handle(), &next).is_ok() { - *app_state - .app_config - .write() - .expect("app config write lock") = next.clone(); + *app_state.app_config.write().expect("app config write lock") = + next.clone(); config_snapshot = next; } } } - let restore_candidates = restorable_window_sessions( - &config_snapshot, - opened_preview, - |workspace| Path::new(workspace).is_dir(), - ); + let restore_candidates = + restorable_window_sessions(&config_snapshot, opened_preview, |workspace| { + Path::new(workspace).is_dir() + }); let mut main_restored = false; for (label, record) in restore_candidates { match restore_window_session(app.handle(), &app_state, &label, &record) { @@ -6509,8 +6531,7 @@ mod windows_title_bar { // Windows COLORREF is little-endian 0x00BBGGRR let (r, g, b) = rgb; - let caption_color: u32 = - ((b as u32) << 16) | ((g as u32) << 8) | (r as u32); + let caption_color: u32 = ((b as u32) << 16) | ((g as u32) << 8) | (r as u32); unsafe { let set_attr = |attr: u32, value: *const std::ffi::c_void, size: u32| { @@ -6559,17 +6580,16 @@ fn set_title_bar_theme( #[cfg(test)] mod settings_tests { use super::{ - merge_settings, migrate_legacy_settings, settings_change_targets, split_settings, - apply_settings_patch, AppConfig, GlobalSettings, Settings, SettingsChangedPayload, - SettingsChangeScope, SettingsChangeTargets, WorkspaceBindings, WorkspaceSettings, + apply_settings_patch, merge_settings, migrate_legacy_settings, settings_change_targets, + split_settings, AppConfig, GlobalSettings, Settings, SettingsChangeScope, + SettingsChangeTargets, SettingsChangedPayload, WorkspaceBindings, WorkspaceSettings, }; #[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"); + 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")); @@ -6616,14 +6636,12 @@ mod settings_tests { #[test] fn legacy_default_workspace_settings_migrate_only_once() { - let mut config: AppConfig = serde_json::from_str( - r#"{"notes_folder":"/notes/main","workspaces":["/notes/main"]}"#, - ) - .expect("legacy config should deserialize"); - let legacy: Settings = serde_json::from_str( - r#"{"theme":{"mode":"dark"},"pinnedNoteIds":["Main"]}"#, - ) - .expect("legacy settings should deserialize"); + let mut config: AppConfig = + serde_json::from_str(r#"{"notes_folder":"/notes/main","workspaces":["/notes/main"]}"#) + .expect("legacy config should deserialize"); + let legacy: Settings = + serde_json::from_str(r#"{"theme":{"mode":"dark"},"pinnedNoteIds":["Main"]}"#) + .expect("legacy settings should deserialize"); let workspace = migrate_legacy_settings(&mut config, legacy) .expect("missing global config should trigger migration"); @@ -6640,12 +6658,13 @@ mod settings_tests { assert_eq!(workspace.pinned_note_ids, Some(vec!["Main".to_string()])); let config_json = serde_json::to_value(&config).expect("migrated config should serialize"); assert_eq!(config_json["global_settings"]["theme"]["mode"], "dark"); - assert!(config_json["global_settings"].get("pinnedNoteIds").is_none()); + assert!(config_json["global_settings"] + .get("pinnedNoteIds") + .is_none()); - let later_legacy: Settings = serde_json::from_str( - r#"{"theme":{"mode":"light"},"pinnedNoteIds":["Other"]}"#, - ) - .expect("later legacy settings should deserialize"); + let later_legacy: Settings = + serde_json::from_str(r#"{"theme":{"mode":"light"},"pinnedNoteIds":["Other"]}"#) + .expect("later legacy settings should deserialize"); assert!(migrate_legacy_settings(&mut config, later_legacy).is_none()); assert_eq!( config.global_settings.as_ref().unwrap().theme.mode, @@ -6680,10 +6699,7 @@ mod settings_tests { SettingsChangeScope::Workspace, Some("/notes/shared"), ), - SettingsChangeTargets::Windows(vec![ - "main".to_string(), - "workspace-copy".to_string(), - ]) + SettingsChangeTargets::Windows(vec!["main".to_string(), "workspace-copy".to_string(),]) ); } @@ -6700,7 +6716,10 @@ mod settings_tests { }) .expect("workspace payload should serialize"); - assert_eq!(global, serde_json::json!({"scope":"global","workspace":null})); + assert_eq!( + global, + serde_json::json!({"scope":"global","workspace":null}) + ); assert_eq!( workspace, serde_json::json!({"scope":"workspace","workspace":"/notes/shared"}) @@ -6709,16 +6728,12 @@ mod settings_tests { #[test] fn global_patch_preserves_unmentioned_global_fields() { - let current: GlobalSettings = serde_json::from_str( - r#"{"theme":{"mode":"dark"},"editorWidth":"wide"}"#, - ) - .expect("current global settings should deserialize"); + let current: GlobalSettings = + serde_json::from_str(r#"{"theme":{"mode":"dark"},"editorWidth":"wide"}"#) + .expect("current global settings should deserialize"); - let updated = apply_settings_patch( - ¤t, - serde_json::json!({"editorWidth":"narrow"}), - ) - .expect("global patch should apply"); + let updated = apply_settings_patch(¤t, serde_json::json!({"editorWidth":"narrow"})) + .expect("global patch should apply"); assert_eq!(updated.theme.mode, "dark"); assert_eq!(updated.editor_width.as_deref(), Some("narrow")); @@ -6726,10 +6741,9 @@ mod settings_tests { #[test] fn workspace_patch_preserves_unmentioned_workspace_fields() { - let current: WorkspaceSettings = serde_json::from_str( - r#"{"gitEnabled":true,"pinnedNoteIds":["Roadmap"]}"#, - ) - .expect("current workspace settings should deserialize"); + let current: WorkspaceSettings = + serde_json::from_str(r#"{"gitEnabled":true,"pinnedNoteIds":["Roadmap"]}"#) + .expect("current workspace settings should deserialize"); let updated = apply_settings_patch( ¤t, @@ -6748,17 +6762,16 @@ mod settings_tests { #[cfg(test)] mod window_session_tests { use super::{ - apply_settings_patch, remove_window_session_on_destroy, restorable_window_sessions, - fallback_main_session, upsert_window_session_workspace, AppConfig, WindowGeometry, + apply_settings_patch, fallback_main_session, remove_window_session_on_destroy, + restorable_window_sessions, upsert_window_session_workspace, AppConfig, WindowGeometry, WindowSession, }; #[test] fn legacy_app_config_deserializes_with_no_window_records() { - let config: AppConfig = serde_json::from_str( - r#"{"notes_folder":"/notes/main","workspaces":["/notes/main"]}"#, - ) - .expect("legacy config should deserialize"); + let config: AppConfig = + serde_json::from_str(r#"{"notes_folder":"/notes/main","workspaces":["/notes/main"]}"#) + .expect("legacy config should deserialize"); assert!(config.records.is_empty()); } @@ -6830,7 +6843,11 @@ mod window_session_tests { let restored = restorable_window_sessions(&config, true, |_| true); assert!(restored.is_empty()); - assert_eq!(config.records.len(), 2, "preview bypass must not delete preferences"); + assert_eq!( + config.records.len(), + 2, + "preview bypass must not delete preferences" + ); } #[test] @@ -6855,14 +6872,11 @@ mod window_session_tests { let mut config = AppConfig::default(); upsert_window_session_workspace(&mut config, "main", "/notes/missing".to_string()); - let fallback = fallback_main_session( - &config, - Some("/notes/default"), - false, - false, - |path| path == "/notes/default", - ) - .expect("valid default should keep a normal launch visible"); + let fallback = + fallback_main_session(&config, Some("/notes/default"), false, false, |path| { + path == "/notes/default" + }) + .expect("valid default should keep a normal launch visible"); assert_eq!(fallback.workspace, "/notes/default"); assert_eq!(config.records["main"].workspace, "/notes/missing"); @@ -6955,18 +6969,17 @@ mod window_session_tests { }, ); - let fallback = fallback_main_session( - &config, - Some("/notes/new"), - false, - false, - |path| path == "/notes/new", - ) + let fallback = fallback_main_session(&config, Some("/notes/new"), false, false, |path| { + path == "/notes/new" + }) .expect("valid default should produce a fallback"); assert_eq!(fallback.workspace, "/notes/new"); assert_eq!(fallback.selected_note_id, None); - assert_eq!(config.records["main"].selected_note_id, Some("Roadmap".to_string())); + assert_eq!( + config.records["main"].selected_note_id, + Some("Roadmap".to_string()) + ); } #[test] @@ -6983,14 +6996,11 @@ mod window_session_tests { }, ); - let fallback = fallback_main_session( - &config, - Some("/notes/default"), - false, - false, - |path| path == "/notes/default", - ) - .expect("matching default should produce a fallback"); + let fallback = + fallback_main_session(&config, Some("/notes/default"), false, false, |path| { + path == "/notes/default" + }) + .expect("matching default should produce a fallback"); assert_eq!(fallback.workspace, "/notes/default"); assert_eq!(fallback.selected_note_id, Some("Roadmap".to_string())); @@ -7000,8 +7010,7 @@ mod window_session_tests { #[cfg(test)] mod standalone_window_tests { use super::{ - should_defer_open_request, - should_hide_main_window_for_standalone_preview, + should_defer_open_request, should_hide_main_window_for_standalone_preview, should_show_main_window, }; @@ -7076,8 +7085,8 @@ mod window_chrome_tests { #[cfg(test)] mod native_new_window_menu_tests { use super::{ - focused_window_target, native_new_window_menu_spec, native_open_folder_menu_spec, - native_preferences_menu_spec, new_window_event_target, is_full_editor_window, + focused_window_target, is_full_editor_window, native_new_window_menu_spec, + native_open_folder_menu_spec, native_preferences_menu_spec, new_window_event_target, }; #[test] @@ -7126,7 +7135,10 @@ mod native_new_window_menu_tests { #[test] fn open_folder_targets_the_focused_window_including_standalone_preview() { let windows = [("main", false), ("preview-note", true)]; - assert_eq!(focused_window_target(windows.iter().copied()), Some("preview-note")); + assert_eq!( + focused_window_target(windows.iter().copied()), + Some("preview-note") + ); } #[test] @@ -7148,13 +7160,11 @@ mod workspace_registry_tests { use std::sync::{Arc, Barrier, Mutex, RwLock}; use super::{ - build_workspace_infos, ensure_shared_resource, get_or_initialize_shared, - forget_workspace, remember_workspace, workspace_is_remembered, - uses_default_workspace_fallback, - note_change_targets, save_note_in_workspace, semantic_file_change_event, - workspace_window_label, - AppConfig, AppState, WorkspaceBindings, WorkspaceSession, - WindowSession, WorkspaceRuntime, persistence, abs_path_from_id, + abs_path_from_id, build_workspace_infos, ensure_shared_resource, forget_workspace, + get_or_initialize_shared, note_change_targets, persistence, remember_workspace, + save_note_in_workspace, semantic_file_change_event, uses_default_workspace_fallback, + workspace_is_remembered, workspace_window_label, AppConfig, AppState, WindowSession, + WorkspaceBindings, WorkspaceRuntime, WorkspaceSession, }; #[test] @@ -7192,8 +7202,14 @@ mod workspace_registry_tests { workspaces: vec!["/notes/main".to_string(), "/notes/client".to_string()], global_settings: None, records: HashMap::from([ - ("main".to_string(), WindowSession::for_workspace("/notes/main".to_string())), - ("client".to_string(), WindowSession::for_workspace("/notes/client".to_string())), + ( + "main".to_string(), + WindowSession::for_workspace("/notes/main".to_string()), + ), + ( + "client".to_string(), + WindowSession::for_workspace("/notes/client".to_string()), + ), ]), }; @@ -7266,11 +7282,7 @@ mod workspace_registry_tests { bindings.bind("workspace-other", "/notes/other"); assert_eq!( - note_change_targets( - &bindings, - Path::new("/notes/shared/a.md"), - Some("main"), - ), + note_change_targets(&bindings, Path::new("/notes/shared/a.md"), Some("main"),), vec!["workspace-copy".to_string()] ); } @@ -7581,23 +7593,20 @@ mod workspace_registry_tests { .bind_existing_workspace_session("workspace-copy", "/notes/shared") .expect("bind second window to main workspace"); - let main_runtime = state - .workspace_for_window("main") - .expect("main runtime"); + let main_runtime = state.workspace_for_window("main").expect("main runtime"); let copy_runtime = state .workspace_for_window("workspace-copy") .expect("copy runtime"); - let (WorkspaceRuntime::Session(main_from_router), WorkspaceRuntime::Session(copy_from_router)) = - (main_runtime, copy_runtime) + let ( + WorkspaceRuntime::Session(main_from_router), + WorkspaceRuntime::Session(copy_from_router), + ) = (main_runtime, copy_runtime) else { panic!("all full editor windows must route through canonical sessions"); }; assert!(std::sync::Arc::ptr_eq(&main_session, ©_session)); - assert!(std::sync::Arc::ptr_eq( - &main_from_router, - ©_from_router, - )); + assert!(std::sync::Arc::ptr_eq(&main_from_router, ©_from_router,)); assert_eq!(state.workspace_session_count(), 1); } @@ -7840,10 +7849,8 @@ mod workspace_registry_tests { #[test] fn create_note_persists_content_with_create_only_loop() { - let root = std::env::temp_dir().join(format!( - "scratch-create-note-test-{}", - std::process::id() - )); + let root = + std::env::temp_dir().join(format!("scratch-create-note-test-{}", std::process::id())); let _ = std::fs::remove_dir_all(&root); std::fs::create_dir_all(&root).expect("create test root"); @@ -7885,10 +7892,13 @@ mod workspace_registry_tests { std::fs::write(&file_path, "existing content").expect("seed existing file"); let content = "# Untitled\n\n"; - let result = persistence::save_if_revision(&file_path, content, None) - .expect("conflict is typed"); + let result = + persistence::save_if_revision(&file_path, content, None).expect("conflict is typed"); assert!(matches!(result, persistence::SaveResult::Conflict { .. })); - assert_eq!(std::fs::read_to_string(&file_path).unwrap(), "existing content"); + assert_eq!( + std::fs::read_to_string(&file_path).unwrap(), + "existing content" + ); std::fs::remove_dir_all(root).expect("cleanup create conflict test"); } diff --git a/src-tauri/src/note_persistence_tests.rs b/src-tauri/src/note_persistence_tests.rs index 297ed5af..c264075a 100644 --- a/src-tauri/src/note_persistence_tests.rs +++ b/src-tauri/src/note_persistence_tests.rs @@ -391,8 +391,7 @@ fn standalone_read_and_save_use_the_same_revision_conflict_contract() { let path = directory.note_path("External.md"); fs::write(&path, "# External\n\nOriginal").expect("write standalone note"); let loaded = read_file_content_from_path(&path).expect("read standalone note"); - fs::write(&path, "# External\n\nChanged outside Scratch") - .expect("write external update"); + fs::write(&path, "# External\n\nChanged outside Scratch").expect("write external update"); let result = save_file_content_to_path( &path, @@ -518,7 +517,11 @@ fn deleted_preview_path_uses_canonical_parent_and_rejects_traversal() { assert_eq!( validated, - directory.path.canonicalize().unwrap().join("Deleted.markdown") + directory + .path + .canonicalize() + .unwrap() + .join("Deleted.markdown") ); assert!(!validated.exists()); @@ -549,7 +552,9 @@ fn recovery_snapshot_preserves_the_exact_dirty_markdown_in_a_hidden_store() { assert!(recovery_path.starts_with(directory.path.join("recovery"))); assert_eq!(fs::read_to_string(&recovery_path).unwrap(), dirty_markdown); assert_eq!( - recovery_path.extension().and_then(|extension| extension.to_str()), + recovery_path + .extension() + .and_then(|extension| extension.to_str()), Some("md") ); } diff --git a/src-tauri/src/persistence.rs b/src-tauri/src/persistence.rs index 18b84376..a33e9b2e 100644 --- a/src-tauri/src/persistence.rs +++ b/src-tauri/src/persistence.rs @@ -177,11 +177,13 @@ fn atomic_create_new(path: &Path, bytes: &[u8]) -> io::Result<()> { drop(temporary_file); match fs::hard_link(temporary_path.path(), path) { - Ok(()) => { - fs::remove_file(temporary_path.path())?; - temporary_path.commit(); - sync_parent_directory(parent)?; - return Ok(()); + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::Unsupported => { + let mut destination = OpenOptions::new().create_new(true).write(true).open(path)?; + destination.write_all(bytes)?; + destination.sync_all()?; + drop(destination); + } } Err(error) if error.kind() == io::ErrorKind::AlreadyExists => { return Err(error); From 9dad62953fb0239f12e488edd018cd0fb058cf14 Mon Sep 17 00:00:00 2001 From: Norkep Date: Tue, 4 Aug 2026 11:20:36 +0200 Subject: [PATCH 22/32] fix: remove unused ref and fix ThemeContext syntax --- src/components/editor/Editor.tsx | 3 --- src/context/ThemeContext.tsx | 3 ++- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/components/editor/Editor.tsx b/src/components/editor/Editor.tsx index 44650710..c9b75b84 100644 --- a/src/components/editor/Editor.tsx +++ b/src/components/editor/Editor.tsx @@ -662,9 +662,6 @@ export function Editor({ ); } const checkpointScheduler = checkpointSchedulerRef.current; - const persistCurrentCrashCheckpointRef = useRef< - (() => Promise) | null - >(null); const linkPopupRef = useRef(null); const blockMathPopupRef = useRef(null); const isLoadingRef = useRef(false); diff --git a/src/context/ThemeContext.tsx b/src/context/ThemeContext.tsx index 53c4c64a..c01a3042 100644 --- a/src/context/ThemeContext.tsx +++ b/src/context/ThemeContext.tsx @@ -477,7 +477,8 @@ export function ThemeProvider({ children }: ThemeProviderProps) { setSidebarWidthPxState(null); setCustomColorsLightState({}); setCustomColorsDarkState({}); - await updateGlobalSettings({ + try { + await updateGlobalSettings({ editorFont: defaultEditorFontSettings, textDirection: "auto", editorWidth: "normal", From 32e7ec5a7d015e939cc1c1f6bac3f5589aeeefd6 Mon Sep 17 00:00:00 2001 From: Norkep Date: Tue, 4 Aug 2026 11:34:04 +0200 Subject: [PATCH 23/32] fix: correct atomic_create_new fallback logic --- src-tauri/src/persistence.rs | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/src-tauri/src/persistence.rs b/src-tauri/src/persistence.rs index a33e9b2e..50f9514f 100644 --- a/src-tauri/src/persistence.rs +++ b/src-tauri/src/persistence.rs @@ -177,13 +177,11 @@ fn atomic_create_new(path: &Path, bytes: &[u8]) -> io::Result<()> { drop(temporary_file); match fs::hard_link(temporary_path.path(), path) { - Ok(()) => {} - Err(error) if error.kind() == io::ErrorKind::Unsupported => { - let mut destination = OpenOptions::new().create_new(true).write(true).open(path)?; - destination.write_all(bytes)?; - destination.sync_all()?; - drop(destination); - } + 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); @@ -191,11 +189,7 @@ fn atomic_create_new(path: &Path, bytes: &[u8]) -> io::Result<()> { Err(_) => {} } - let mut direct = match OpenOptions::new() - .write(true) - .create_new(true) - .open(path) - { + let mut direct = match OpenOptions::new().write(true).create_new(true).open(path) { Ok(file) => file, Err(error) => { let _ = fs::remove_file(temporary_path.path()); From dd6bf9a4cca78e7f2f51c8c5b51548f59f1b6340 Mon Sep 17 00:00:00 2001 From: Norkep Date: Mon, 3 Aug 2026 22:09:09 +0200 Subject: [PATCH 24/32] feat: add selection formatting and improved tables --- package-lock.json | 735 +++---- package.json | 38 +- src/App.css | 586 ++++- src/components/editor/Editor.tsx | 169 +- src/components/editor/editorHistory.test.ts | 117 + src/components/editor/editorHistory.ts | 45 + .../editor/notion/NotionMenus.test.tsx | 394 ++++ src/components/editor/notion/NotionMenus.tsx | 433 ++++ .../editor/notion/TableControls.test.tsx | 723 +++++++ .../editor/notion/TableControls.tsx | 1924 +++++++++++++++++ .../editor/notion/interfaceGeometry.test.ts | 26 + .../editor/notion/interfaceGeometry.ts | 8 + .../editor/notion/markdownDocument.test.ts | 787 +++++++ .../editor/notion/markdownDocument.ts | 595 +++++ .../editor/notion/markdownMarks.test.ts | 310 +++ src/components/editor/notion/markdownMarks.ts | 154 ++ .../editor/notion/selectionDecoration.test.ts | 326 +++ .../editor/notion/selectionDecoration.ts | 107 + .../editor/notion/tableAutoScroll.test.ts | 71 + .../editor/notion/tableAutoScroll.ts | 51 + .../editor/notion/tableClipboard.test.ts | 300 +++ .../editor/notion/tableClipboard.ts | 275 +++ .../editor/notion/tableEdgeDrag.test.ts | 64 + src/components/editor/notion/tableEdgeDrag.ts | 29 + .../editor/notion/tableExtensions.test.ts | 237 ++ .../editor/notion/tableExtensions.ts | 83 + .../editor/notion/tableIntegrity.test.ts | 171 ++ .../editor/notion/tableIntegrity.ts | 155 ++ src/components/editor/notion/tableMetadata.ts | 101 + .../editor/notion/tableNonRegression.test.ts | 217 ++ .../editor/notion/tablePerformance.test.ts | 116 + .../editor/notion/tablePointerDrag.test.ts | 99 + .../editor/notion/tablePointerDrag.ts | 114 + .../editor/notion/tableProximity.test.ts | 74 + .../editor/notion/tableProximity.ts | 104 + .../editor/notion/tableRowResize.test.ts | 70 + .../editor/notion/tableRowResize.ts | 77 + .../editor/notion/tableTransactions.test.ts | 578 +++++ .../editor/notion/tableTransactions.ts | 934 ++++++++ .../editor/notion/tableView.test.ts | 252 +++ src/components/editor/notion/tableView.ts | 225 ++ 41 files changed, 11398 insertions(+), 476 deletions(-) create mode 100644 src/components/editor/editorHistory.test.ts create mode 100644 src/components/editor/editorHistory.ts create mode 100644 src/components/editor/notion/NotionMenus.test.tsx create mode 100644 src/components/editor/notion/NotionMenus.tsx create mode 100644 src/components/editor/notion/TableControls.test.tsx create mode 100644 src/components/editor/notion/TableControls.tsx create mode 100644 src/components/editor/notion/interfaceGeometry.test.ts create mode 100644 src/components/editor/notion/interfaceGeometry.ts create mode 100644 src/components/editor/notion/markdownDocument.test.ts create mode 100644 src/components/editor/notion/markdownDocument.ts create mode 100644 src/components/editor/notion/markdownMarks.test.ts create mode 100644 src/components/editor/notion/markdownMarks.ts create mode 100644 src/components/editor/notion/selectionDecoration.test.ts create mode 100644 src/components/editor/notion/selectionDecoration.ts create mode 100644 src/components/editor/notion/tableAutoScroll.test.ts create mode 100644 src/components/editor/notion/tableAutoScroll.ts create mode 100644 src/components/editor/notion/tableClipboard.test.ts create mode 100644 src/components/editor/notion/tableClipboard.ts create mode 100644 src/components/editor/notion/tableEdgeDrag.test.ts create mode 100644 src/components/editor/notion/tableEdgeDrag.ts create mode 100644 src/components/editor/notion/tableExtensions.test.ts create mode 100644 src/components/editor/notion/tableExtensions.ts create mode 100644 src/components/editor/notion/tableIntegrity.test.ts create mode 100644 src/components/editor/notion/tableIntegrity.ts create mode 100644 src/components/editor/notion/tableMetadata.ts create mode 100644 src/components/editor/notion/tableNonRegression.test.ts create mode 100644 src/components/editor/notion/tablePerformance.test.ts create mode 100644 src/components/editor/notion/tablePointerDrag.test.ts create mode 100644 src/components/editor/notion/tablePointerDrag.ts create mode 100644 src/components/editor/notion/tableProximity.test.ts create mode 100644 src/components/editor/notion/tableProximity.ts create mode 100644 src/components/editor/notion/tableRowResize.test.ts create mode 100644 src/components/editor/notion/tableRowResize.ts create mode 100644 src/components/editor/notion/tableTransactions.test.ts create mode 100644 src/components/editor/notion/tableTransactions.ts create mode 100644 src/components/editor/notion/tableView.test.ts create mode 100644 src/components/editor/notion/tableView.ts diff --git a/package-lock.json b/package-lock.json index 06ff2272..2268787d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,23 +20,27 @@ "@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-updater": "^2.10.0", - "@tiptap/core": "^3.19.0", - "@tiptap/extension-code-block-lowlight": "^3.20.0", - "@tiptap/extension-image": "^3.18.0", - "@tiptap/extension-link": "^3.18.0", - "@tiptap/extension-mathematics": "^3.18.0", - "@tiptap/extension-placeholder": "^3.18.0", - "@tiptap/extension-table": "^3.19.0", - "@tiptap/extension-table-cell": "^3.19.0", - "@tiptap/extension-table-header": "^3.19.0", - "@tiptap/extension-table-row": "^3.19.0", - "@tiptap/extension-task-item": "^3.18.0", - "@tiptap/extension-task-list": "^3.18.0", - "@tiptap/markdown": "^3.18.0", - "@tiptap/pm": "^3.18.0", - "@tiptap/react": "^3.18.0", - "@tiptap/starter-kit": "^3.18.0", - "@tiptap/suggestion": "^3.18.0", + "@tiptap/core": "3.29.2", + "@tiptap/extension-code-block-lowlight": "3.29.2", + "@tiptap/extension-highlight": "3.29.2", + "@tiptap/extension-image": "3.29.2", + "@tiptap/extension-link": "3.29.2", + "@tiptap/extension-mathematics": "3.29.2", + "@tiptap/extension-placeholder": "3.29.2", + "@tiptap/extension-subscript": "3.29.2", + "@tiptap/extension-superscript": "3.29.2", + "@tiptap/extension-table": "3.29.2", + "@tiptap/extension-table-cell": "3.29.2", + "@tiptap/extension-table-header": "3.29.2", + "@tiptap/extension-table-row": "3.29.2", + "@tiptap/extension-task-item": "3.29.2", + "@tiptap/extension-task-list": "3.29.2", + "@tiptap/extension-text-style": "3.29.2", + "@tiptap/markdown": "3.29.2", + "@tiptap/pm": "3.29.2", + "@tiptap/react": "3.29.2", + "@tiptap/starter-kit": "3.29.2", + "@tiptap/suggestion": "3.29.2", "beautiful-mermaid": "^1.1.3", "clsx": "^2.1.1", "highlight.js": "^11.11.1", @@ -1620,12 +1624,6 @@ "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", "license": "MIT" }, - "node_modules/@remirror/core-constants": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz", - "integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==", - "license": "MIT" - }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -2603,48 +2601,49 @@ } }, "node_modules/@tiptap/core": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.20.0.tgz", - "integrity": "sha512-aC9aROgia/SpJqhsXFiX9TsligL8d+oeoI8W3u00WI45s0VfsqjgeKQLDLF7Tu7hC+7F02teC84SAHuup003VQ==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.29.2.tgz", + "integrity": "sha512-oKUkiPUB7noilVYxI9lNzUD4rX17sHub+PYjMfHMWHG9A3nvIy+FdePIVIIhThKWF7ijhr3eIqHY51Bn+GAFtw==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/pm": "^3.20.0" + "@tiptap/pm": "3.29.2" } }, "node_modules/@tiptap/extension-blockquote": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.20.0.tgz", - "integrity": "sha512-LQzn6aGtL4WXz2+rYshl/7/VnP2qJTpD7fWL96GXAzhqviPEY1bJES7poqJb3MU/gzl8VJUVzVzU1VoVfUKlbA==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.29.2.tgz", + "integrity": "sha512-ca4OzKDh0yaxg2+Z56bC2QnWsNsFp2YMRfVig1PDXyMVFMNJpLcnhxgq/9btn+xYAlYrj8RymOeCTYREOR6Zjg==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.20.0" + "@tiptap/core": "3.29.2", + "@tiptap/pm": "3.29.2" } }, "node_modules/@tiptap/extension-bold": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.20.0.tgz", - "integrity": "sha512-sQklEWiyf58yDjiHtm5vmkVjfIc/cBuSusmCsQ0q9vGYnEF1iOHKhGpvnCeEXNeqF3fiJQRlquzt/6ymle3Iwg==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.29.2.tgz", + "integrity": "sha512-elYbGxJsYnBb4leqrcjdIJuiG380BcOgN+UUzvOv+qEjfGVzHodFOMBl3qnmD6urYHNu5/qQK2S0qSSRXKCLNQ==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.20.0" + "@tiptap/core": "3.29.2" } }, "node_modules/@tiptap/extension-bubble-menu": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.20.0.tgz", - "integrity": "sha512-MDosUfs8Tj+nwg8RC+wTMWGkLJORXmbR6YZgbiX4hrc7G90Gopdd6kj6ht5/T8t7dLLaX7N0+DEHdUEPGED7dw==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.29.2.tgz", + "integrity": "sha512-kzcWarcpr031rZu+R3Hzut5uxb3Qj/xxMDEYZIQ3uiq1yb5vEMXj8/SIyWautk6Nu8Rr1leV3rGdR4NzhzyFAA==", "license": "MIT", "optional": true, "dependencies": { @@ -2655,96 +2654,97 @@ "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.20.0", - "@tiptap/pm": "^3.20.0" + "@tiptap/core": "3.29.2", + "@tiptap/pm": "3.29.2" } }, "node_modules/@tiptap/extension-bullet-list": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.20.0.tgz", - "integrity": "sha512-OcKMeopBbqWzhSi6o8nNz0aayogg1sfOAhto3NxJu3Ya32dwBFqmHXSYM6uW4jOphNvVPyjiq9aNRh3qTdd1dw==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.29.2.tgz", + "integrity": "sha512-3bWcCUPbCHv0XttlMdnAtXLNYWx2pblByMgxmGsaP9FU0QnslGXty6A6gHCqI33ygRg1vrA6U5Wtpwbi5aKu5g==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/extension-list": "^3.20.0" + "@tiptap/extension-list": "3.29.2" } }, "node_modules/@tiptap/extension-code": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.20.0.tgz", - "integrity": "sha512-TYDWFeSQ9umiyrqsT6VecbuhL8XIHkUhO+gEk0sVvH67ZLwjFDhAIIgWIr1/dbIGPcvMZM19E7xUUhAdIaXaOQ==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.29.2.tgz", + "integrity": "sha512-c6W5UGuB7WNLpYocsgRzpO2OOTI4QjaI9jjHRMuty9z+s9DtaYM/HrRLNwVh6MopkHb+i/89Wkv8gCS34fftig==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.20.0" + "@tiptap/core": "3.29.2" } }, "node_modules/@tiptap/extension-code-block": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.20.0.tgz", - "integrity": "sha512-lBbmNek14aCjrHcBcq3PRqWfNLvC6bcRa2Osc6e/LtmXlcpype4f6n+Yx+WZ+f2uUh0UmDRCz7BEyUETEsDmlQ==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.29.2.tgz", + "integrity": "sha512-w153ct8g6dLiPTdXQ6SOIMxX4SEo5Q50AmjdEEEcJ7ZcYUcde/ScSskLHfOYmyt5ZFAiyEwr121+pux+p3/oAQ==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.20.0", - "@tiptap/pm": "^3.20.0" + "@tiptap/core": "3.29.2", + "@tiptap/pm": "3.29.2" } }, "node_modules/@tiptap/extension-code-block-lowlight": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block-lowlight/-/extension-code-block-lowlight-3.20.0.tgz", - "integrity": "sha512-9lN9rn07lOWkLnByT5C1axtq56MHpOI7MpLaCmX3p+x1bDl6Uvixm6AoBdTLfZUmUYeEFBsf7t5cR+QepMbkiA==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block-lowlight/-/extension-code-block-lowlight-3.29.2.tgz", + "integrity": "sha512-iUtg33uSlbnU0y6c3nUSWdw8ILmbuc+HeAouqZ7Vl4leYddLf7wLP4s+RJ8PKDJe8m1r6GTK6VVpCOUK5eIFzQ==", + "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.20.0", - "@tiptap/extension-code-block": "^3.20.0", - "@tiptap/pm": "^3.20.0", + "@tiptap/core": "3.29.2", + "@tiptap/extension-code-block": "3.29.2", + "@tiptap/pm": "3.29.2", "highlight.js": "^11", "lowlight": "^2 || ^3" } }, "node_modules/@tiptap/extension-document": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.20.0.tgz", - "integrity": "sha512-oJfLIG3vAtZo/wg29WiBcyWt22KUgddpP8wqtCE+kY5Dw8znLR9ehNmVWlSWJA5OJUMO0ntAHx4bBT+I2MBd5w==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.29.2.tgz", + "integrity": "sha512-YUamvefLnsqu6124GavVTI7nqcFlQJ12ROB0oSwG69eSBZYNjg1tIs05LFrBxkwf4Xgqd6YzfJ9+FeG428RvzQ==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.20.0" + "@tiptap/core": "3.29.2" } }, "node_modules/@tiptap/extension-dropcursor": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.20.0.tgz", - "integrity": "sha512-d+cxplRlktVgZPwatnc34IArlppM0IFKS1J5wLk+ba1jidizsbMVh45tP/BTK2flhyfRqcNoB5R0TArhUpbkNQ==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.29.2.tgz", + "integrity": "sha512-KKno7cU9r1HdR48CRrsDu69/1UjZdoslq/UcE+Kx+tdhAv/aljXMkRSNzGMrBNOBDmHRgS1+58zm21WQWdQzwA==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/extensions": "^3.20.0" + "@tiptap/extensions": "3.29.2" } }, "node_modules/@tiptap/extension-floating-menu": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.20.0.tgz", - "integrity": "sha512-rYs4Bv5pVjqZ/2vvR6oe7ammZapkAwN51As/WDbemvYDjfOGRqK58qGauUjYZiDzPOEIzI2mxGwsZ4eJhPW4Ig==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.29.2.tgz", + "integrity": "sha512-CBTq4Xs5aGWr65uFCIkKBms796OhmirWf/ax//iJ4fl9IfwqjwFuc2vQs9PmuwpKn9ctDHo2sMTtvyeCvIt5LQ==", "license": "MIT", "optional": true, "funding": { @@ -2753,336 +2753,390 @@ }, "peerDependencies": { "@floating-ui/dom": "^1.0.0", - "@tiptap/core": "^3.20.0", - "@tiptap/pm": "^3.20.0" + "@tiptap/core": "3.29.2", + "@tiptap/pm": "3.29.2" } }, "node_modules/@tiptap/extension-gapcursor": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.20.0.tgz", - "integrity": "sha512-P/LasfvG9/qFq43ZAlNbAnPnXC+/RJf49buTrhtFvI9Zg0+Lbpjx1oh6oMHB19T88Y28KtrckfFZ8aTSUWDq6w==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.29.2.tgz", + "integrity": "sha512-8Q39UR4/Tit759IeW9xZIe3NMwN11GsuA3FLheDyyGn7RrW02HD3HhUDlazE54Ki4HoosjFmChPlN4Ik2ubdRQ==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/extensions": "^3.20.0" + "@tiptap/extensions": "3.29.2" } }, "node_modules/@tiptap/extension-hard-break": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.20.0.tgz", - "integrity": "sha512-rqvhMOw4f+XQmEthncbvDjgLH6fz8L9splnKZC7OeS0eX8b0qd7+xI1u5kyxF3KA2Z0BnigES++jjWuecqV6mA==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.29.2.tgz", + "integrity": "sha512-eUW3LN3fq8rXnjEUeI3D2QONYdLsU3yYQm4jxlErs2h4cfwrFjgf19VSUFVmm6LrFbbQ0OnDVPeVLL6iOwDw2w==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.20.0" + "@tiptap/core": "3.29.2" } }, "node_modules/@tiptap/extension-heading": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.20.0.tgz", - "integrity": "sha512-JgJhurnCe3eN6a0lEsNQM/46R1bcwzwWWZEFDSb1P9dR8+t1/5v7cMZWsSInpD7R4/74iJn0+M5hcXLwCmBmYA==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.29.2.tgz", + "integrity": "sha512-6W4aIy70Mh7BNlbG9zZ5FBLhJhU2UUEzgZJ/jwYSCcB30o8McLxJSEjhtoHiX8R78Ah2/JzBGvIe5olZlbeE4A==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.20.0" + "@tiptap/core": "3.29.2" + } + }, + "node_modules/@tiptap/extension-highlight": { + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-highlight/-/extension-highlight-3.29.2.tgz", + "integrity": "sha512-bSeZ1C8OlthyK7BzgtqOGfQymdap9V38W2hbr9KGlhVOIAmTqneOwjm27iFcu7jKqkMArVu6WBYN18Lom6umHQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.29.2" } }, "node_modules/@tiptap/extension-horizontal-rule": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.20.0.tgz", - "integrity": "sha512-6uvcutFMv+9wPZgptDkbRDjAm3YVxlibmkhWD5GuaWwS9L/yUtobpI3GycujRSUZ8D3q6Q9J7LqpmQtQRTalWA==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.29.2.tgz", + "integrity": "sha512-8/ZPzbB9X85Mc9/7xVLZupQKBr2UVcQTGr512xtqMW+XkCQRHCph46tRo828YE13IMWI5fWn/FaNCqXG9cULSw==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.20.0", - "@tiptap/pm": "^3.20.0" + "@tiptap/core": "3.29.2", + "@tiptap/pm": "3.29.2" } }, "node_modules/@tiptap/extension-image": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-3.20.0.tgz", - "integrity": "sha512-0t7HYncV0kYEQS79NFczxdlZoZ8zu8X4VavDqt+mbSAUKRq3gCvgtZ5Zyd778sNmtmbz3arxkEYMIVou2swD0g==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-3.29.2.tgz", + "integrity": "sha512-F+S4iru247mNVZdkNwNDZHeb281DkjsBJinaOGsOyJTvXY+KU5Uq7vY1LQTn493dTfU48Z0yMBUXwJffCT92Mg==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.20.0" + "@tiptap/core": "3.29.2" } }, "node_modules/@tiptap/extension-italic": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.20.0.tgz", - "integrity": "sha512-/DhnKQF8yN8RxtuL8abZ28wd5281EaGoE2Oha35zXSOF1vNYnbyt8Ymkv/7u1BcWEWTvRPgaju0YCGXisPRLYw==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.29.2.tgz", + "integrity": "sha512-iH63V/5wsaMnY4Jz0+meaAGhaec4AiOzOduzl6ZZr5IyGhZ1kthyW84ELt0dyLI3hNceAUhaNWc+I7+vX0aoXA==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.20.0" + "@tiptap/core": "3.29.2" } }, "node_modules/@tiptap/extension-link": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.20.0.tgz", - "integrity": "sha512-qI/5A+R0ZWBxo/8HxSn1uOyr7odr3xHBZ/gzOR1GUJaZqjlJxkWFX0RtXMbLKEGEvT25o345cF7b0wFznEh8qA==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.29.2.tgz", + "integrity": "sha512-DcVer5SqrexKCEP6Ip1UPxJUMvcRCCItSv0wxoGytanrimBh2smvcg6X0DWnjlsi5H0updhyl+atYCmmQXUIXA==", "license": "MIT", "dependencies": { - "linkifyjs": "^4.3.2" + "linkifyjs": "^4.3.3" }, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.20.0", - "@tiptap/pm": "^3.20.0" + "@tiptap/core": "3.29.2", + "@tiptap/pm": "3.29.2" } }, "node_modules/@tiptap/extension-list": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.20.0.tgz", - "integrity": "sha512-+V0/gsVWAv+7vcY0MAe6D52LYTIicMSHw00wz3ISZgprSb2yQhJ4+4gurOnUrQ4Du3AnRQvxPROaofwxIQ66WQ==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.29.2.tgz", + "integrity": "sha512-WPZ9BHAPT6QeIm1vdVkuoOWvy9a8/EZeJwV2VhU8LXyTAttvzyj4rsbbHyJWvYWlUSTt/QF2AZ2zhKo7u1w3/A==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.20.0", - "@tiptap/pm": "^3.20.0" + "@tiptap/core": "3.29.2", + "@tiptap/pm": "3.29.2" } }, "node_modules/@tiptap/extension-list-item": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.20.0.tgz", - "integrity": "sha512-qEtjaaGPuqaFB4VpLrGDoIe9RHnckxPfu6d3rc22ap6TAHCDyRv05CEyJogqccnFceG/v5WN4znUBER8RWnWHA==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.29.2.tgz", + "integrity": "sha512-s8vBVHHFT0Qpu7CzAZ7S1kYmSiVaDvUNvMNcZUWnxj6VPfiwmx0eXd9FsjePrRCMoM5tFmPnhFTHDxY3D/eZeQ==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/extension-list": "^3.20.0" + "@tiptap/extension-list": "3.29.2" } }, "node_modules/@tiptap/extension-list-keymap": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.20.0.tgz", - "integrity": "sha512-Z4GvKy04Ms4cLFN+CY6wXswd36xYsT2p/YL0V89LYFMZTerOeTjFYlndzn6svqL8NV1PRT5Diw4WTTxJSmcJPA==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.29.2.tgz", + "integrity": "sha512-R+3k8OLnxdCH7Xy9ieOwUt5m2Je74u8mikothGmsYVO2Zyq48fIbmZ+X6RBPCu7DBOI2FIUhHEFbKQeDWvDNmA==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/extension-list": "^3.20.0" + "@tiptap/extension-list": "3.29.2" } }, "node_modules/@tiptap/extension-mathematics": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-mathematics/-/extension-mathematics-3.20.0.tgz", - "integrity": "sha512-tVdAqew37SVLlymYGqraz1huWUlkuMj986hvrYOwsOtre2Zbvd8ULxN92TnbuxJSQ6Nt4zrElRbiPrFL2dG8nQ==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-mathematics/-/extension-mathematics-3.29.2.tgz", + "integrity": "sha512-v2qFI635nsOi+IiMg9nWw6t1SUunU53qFj2lceDIiIeEX26bW2YjMYoju0T6WpySCtbTunkKGcVerGn0DtYC9Q==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.20.0", - "@tiptap/pm": "^3.20.0", - "katex": "^0.16.4" + "@tiptap/core": "3.29.2", + "@tiptap/pm": "3.29.2", + "katex": "^0.16.4 || ^0.17.0" } }, "node_modules/@tiptap/extension-ordered-list": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.20.0.tgz", - "integrity": "sha512-jVKnJvrizLk7etwBMfyoj6H2GE4M+PD4k7Bwp6Bh1ohBWtfIA1TlngdS842Mx5i1VB2e3UWIwr8ZH46gl6cwMA==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.29.2.tgz", + "integrity": "sha512-ndCunC+UsYOpkOtL7vGnDz21UNa45WUlcO9wMT1fbuYow2QnRhsuMlCWENXI52YPPARWuQ0RDgN7q6TaxPERBg==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/extension-list": "^3.20.0" + "@tiptap/extension-list": "3.29.2" } }, "node_modules/@tiptap/extension-paragraph": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.20.0.tgz", - "integrity": "sha512-mM99zK4+RnEXIMCv6akfNATAs0Iija6FgyFA9J9NZ6N4o8y9QiNLLa6HjLpAC+W+VoCgQIekyoF/Q9ftxmAYDQ==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.29.2.tgz", + "integrity": "sha512-7qJj5YTr11vvjNgjDN1ypOfwTovc0QOCYcit/rskeuVgnmQZOZQzC/BbyKLLG7UGnpRLemU/mEGbW9pAqjAXkQ==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.20.0" + "@tiptap/core": "3.29.2" } }, "node_modules/@tiptap/extension-placeholder": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-3.20.0.tgz", - "integrity": "sha512-ZhYD3L5m16ydSe2z8vqz+RdtAG/iOQaFHHedFct70tKRoLqi2ajF5kgpemu8DwpaRTcyiCN4G99J/+MqehKNjQ==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-3.29.2.tgz", + "integrity": "sha512-/BlpPIq2JZk6zLaeYVOXazi6dmd0iRriTymNEnFn1doManN1y5HED9LsMeb/AhNhauL5AgmcHgpvAjbsN9k4SA==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/extensions": "^3.20.0" + "@tiptap/extensions": "3.29.2" } }, "node_modules/@tiptap/extension-strike": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.20.0.tgz", - "integrity": "sha512-0vcTZRRAiDfon3VM1mHBr9EFmTkkUXMhm0Xtdtn0bGe+sIqufyi+hUYTEw93EQOD9XNsPkrud6jzQNYpX2H3AQ==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.29.2.tgz", + "integrity": "sha512-aEvLAbddUQZ+FukCreV3q4G2HfNI+odE7E9U+wbq6XsSWKyo8/pDu1muz+TFKNre4blSMOQ3JQmw5UeHDKy+fg==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.20.0" + "@tiptap/core": "3.29.2" + } + }, + "node_modules/@tiptap/extension-subscript": { + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-subscript/-/extension-subscript-3.29.2.tgz", + "integrity": "sha512-3fpp1B2kZWBfmVYZ1ak3yFWiKR3eV9GR/vYGPDMrkZoTdG9LKylRAJs3Fk2gDgTtOPmbrtilGQjl2o55RuoGxw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.29.2", + "@tiptap/pm": "3.29.2" + } + }, + "node_modules/@tiptap/extension-superscript": { + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-superscript/-/extension-superscript-3.29.2.tgz", + "integrity": "sha512-/bPgdY7zUHtvToHizvzR6Yf4uIWO35cKj9EZsl/vyFb2mXyJ3a2mysCZgZq+5uvhZS/16eNNKOcezlkayhhkMA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.29.2", + "@tiptap/pm": "3.29.2" } }, "node_modules/@tiptap/extension-table": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-3.20.0.tgz", - "integrity": "sha512-vaaMtQ2KnSSr8WVwgWf7NYNzPwrHx/6T0ekA5CxV8qNUEpXIaLXa5+tE7tJHWEdNR2KY3gUJ46D3lfOkxyFrBQ==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-3.29.2.tgz", + "integrity": "sha512-HY3JWD8i/F8W1oWw56DMqEMqxScIAZFRrhL3rUwiuowHG8LLDqDdQjBV3w1DY8lUZOtNY9grpuMtGlPUFrOVAA==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.20.0", - "@tiptap/pm": "^3.20.0" + "@tiptap/core": "3.29.2", + "@tiptap/pm": "3.29.2" } }, "node_modules/@tiptap/extension-table-cell": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-table-cell/-/extension-table-cell-3.20.0.tgz", - "integrity": "sha512-9Dg4zda3UWwtpBwSG7b9BeQy5oT27a/yEIBeARuxe19bloMLZgqpPRtnSrOK0OAITtVnjA+NZdKPcVLRMS2E8A==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-table-cell/-/extension-table-cell-3.29.2.tgz", + "integrity": "sha512-SAdjUM9AdtEI7Uyd9wEKpYao7xJ/WVjkLoRm07uWB/J71JE9ewSilV9887C9JPPdFNhzMEL+OcO0be8d3z4qrA==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/extension-table": "^3.20.0" + "@tiptap/extension-table": "3.29.2" } }, "node_modules/@tiptap/extension-table-header": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-table-header/-/extension-table-header-3.20.0.tgz", - "integrity": "sha512-2tVHHlihpeHO/gh2uU46gAX3NTGdKR+yDmfLlO2l0QAvx2TXNfNzX2pOM4MmyostW5Ko9TCWV4x0D9h3IQDhPw==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-table-header/-/extension-table-header-3.29.2.tgz", + "integrity": "sha512-5xtCctg9hT2YFg8Rrn8K43nJdPbVw1XtD/mnXW+vTtKgHnPnRhSsQn6TCRp+cVmcGU0MN1GK3busKFVhQ+T1zQ==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/extension-table": "^3.20.0" + "@tiptap/extension-table": "3.29.2" } }, "node_modules/@tiptap/extension-table-row": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-table-row/-/extension-table-row-3.20.0.tgz", - "integrity": "sha512-clkfQahkYW/U48QBh1rPZv3AWWSC9AqGKp2DLTH/SGIorM/NwI0jpPtBETMlvowyQu0ivlH9B896smEph+Do2A==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-table-row/-/extension-table-row-3.29.2.tgz", + "integrity": "sha512-37FCBkwf1+FrJKyac75GCeNks0xWE/kBB9sHZ24f9WSZy1aqNhiuchqzGwdeOQGY9AywrCQgVseGDhI9wEoGtQ==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/extension-table": "^3.20.0" + "@tiptap/extension-table": "3.29.2" } }, "node_modules/@tiptap/extension-task-item": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-task-item/-/extension-task-item-3.20.0.tgz", - "integrity": "sha512-UArl5ffRNKyiOGJYNtjF50+A0qgYF2DXIEFGyMgJsbp1KYQpDj+UrezWvnGOHZfX64eBWDIO6Bum5jS7o32dYQ==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-task-item/-/extension-task-item-3.29.2.tgz", + "integrity": "sha512-mi5p2/FbUO9LFl7qPmRBThME+bFpd0vyeOD6IiWnD32hYZ720/N5VDkDypPmz+IaxTFaiLeGVmGC1SxGtxksKA==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/extension-list": "^3.20.0" + "@tiptap/extension-list": "3.29.2" } }, "node_modules/@tiptap/extension-task-list": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-task-list/-/extension-task-list-3.20.0.tgz", - "integrity": "sha512-Ab2hfun+5xMG6kQhRjL7s16zTe3XaQRXe3/deGo9hnZrAzTQpWVwDN7H36BX8MemrWnAw3PiuwBH5iOrwNRGjA==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-task-list/-/extension-task-list-3.29.2.tgz", + "integrity": "sha512-7ZkVoMcM3FAuvilZs3XliRRowPVMrVoKhfklfbNkTj9nVOvVToM+xcXKCcsZnf1mjMAyjw65m8doXJQKGCH7DA==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/extension-list": "^3.20.0" + "@tiptap/extension-list": "3.29.2" } }, "node_modules/@tiptap/extension-text": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.20.0.tgz", - "integrity": "sha512-tf8bE8tSaOEWabCzPm71xwiUhyMFKqY9jkP5af3Kr1/F45jzZFIQAYZooHI/+zCHRrgJ99MQHKHe1ZNvODrKHQ==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.29.2.tgz", + "integrity": "sha512-Ubko45JWWHe8glBt2PiGNF8hcbys/JNalFhiR7Y1X4iOOtAxAKJJxh3+eq+//NTlGuBPdWGp7zw8EEUp7anjKA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.29.2" + } + }, + "node_modules/@tiptap/extension-text-style": { + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-3.29.2.tgz", + "integrity": "sha512-aQad9V9ROaEi3hE4g5z9wQ6lv5FSnzlnWFMVJxRjNlwXgm7H0fymxb4rGfca+pAwYkiRwpKYh1LatpJw2ECQAA==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.20.0" + "@tiptap/core": "3.29.2" } }, "node_modules/@tiptap/extension-underline": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.20.0.tgz", - "integrity": "sha512-LzNXuy2jwR/y+ymoUqC72TiGzbOCjioIjsDu0MNYpHuHqTWPK5aV9Mh0nbZcYFy/7fPlV1q0W139EbJeYBZEAQ==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.29.2.tgz", + "integrity": "sha512-K7XwH/xS/5AIREWQ00VTEf/W5U0olp7j6wwit7cdd/8nHv6h6AGr1+iEApHKoLXWQZLfGQKzJlT9W61LAl+fHA==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.20.0" + "@tiptap/core": "3.29.2" } }, "node_modules/@tiptap/extensions": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.20.0.tgz", - "integrity": "sha512-HIsXX942w3nbxEQBlMAAR/aa6qiMBEP7CsSMxaxmTIVAmW35p6yUASw6GdV1u0o3lCZjXq2OSRMTskzIqi5uLg==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.29.2.tgz", + "integrity": "sha512-BCz+FCAChSYtUe4BFj97HEO+nSK+J7GxbJgZG4Hg7DT/gI+hRyeNndU8efiQAx3WGdzsFi3UxRpcF1tTQM7iMQ==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.20.0", - "@tiptap/pm": "^3.20.0" + "@tiptap/core": "3.29.2", + "@tiptap/pm": "3.29.2" } }, "node_modules/@tiptap/markdown": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/markdown/-/markdown-3.20.0.tgz", - "integrity": "sha512-3vUxs8tsVIf/KWKLWjFsTqrjuaTYJY9rawDL5sio9NwlqFWDuWpHEVJcqbQXJUrgQSh12AZoTKyfgiEqkAGI3Q==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/markdown/-/markdown-3.29.2.tgz", + "integrity": "sha512-Knf9YqG3fxOyFuS3pe3eapDz0ya9eqPGzmyMSd5PuHNmR9/Ir6i5qk5+Md6bCPgDNjYskztNm7DoBXPfbXbO/w==", "license": "MIT", "dependencies": { "marked": "^17.0.1" @@ -3092,34 +3146,29 @@ "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.20.0", - "@tiptap/pm": "^3.20.0" + "@tiptap/core": "3.29.2", + "@tiptap/pm": "3.29.2" } }, "node_modules/@tiptap/pm": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.20.0.tgz", - "integrity": "sha512-jn+2KnQZn+b+VXr8EFOJKsnjVNaA4diAEr6FOazupMt8W8ro1hfpYtZ25JL87Kao/WbMze55sd8M8BDXLUKu1A==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.29.2.tgz", + "integrity": "sha512-GCOme7xHaS+DSoaA4CDcAD3l6JyBlvZhvCyfsy2Vp6j8tEoBkZWio7soYVosmlyn7zq8/64VeFZP5s47yfG7fQ==", "license": "MIT", "dependencies": { - "prosemirror-changeset": "^2.3.0", - "prosemirror-collab": "^1.3.1", - "prosemirror-commands": "^1.6.2", - "prosemirror-dropcursor": "^1.8.1", - "prosemirror-gapcursor": "^1.3.2", - "prosemirror-history": "^1.4.1", - "prosemirror-inputrules": "^1.4.0", - "prosemirror-keymap": "^1.2.2", - "prosemirror-markdown": "^1.13.1", - "prosemirror-menu": "^1.2.4", - "prosemirror-model": "^1.24.1", - "prosemirror-schema-basic": "^1.2.3", - "prosemirror-schema-list": "^1.5.0", - "prosemirror-state": "^1.4.3", - "prosemirror-tables": "^1.6.4", - "prosemirror-trailing-node": "^3.0.0", - "prosemirror-transform": "^1.10.2", - "prosemirror-view": "^1.38.1" + "prosemirror-changeset": "^2.4.1", + "prosemirror-commands": "^1.7.1", + "prosemirror-dropcursor": "^1.8.2", + "prosemirror-gapcursor": "^1.4.1", + "prosemirror-history": "^1.5.0", + "prosemirror-inputrules": "^1.5.1", + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.11", + "prosemirror-schema-list": "^1.5.1", + "prosemirror-state": "^1.4.4", + "prosemirror-tables": "^1.8.5", + "prosemirror-transform": "^1.12.0", + "prosemirror-view": "^1.41.9" }, "funding": { "type": "github", @@ -3127,9 +3176,9 @@ } }, "node_modules/@tiptap/react": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.20.0.tgz", - "integrity": "sha512-jFLNzkmn18zqefJwPje0PPd9VhZ7Oy28YHiSvSc7YpBnQIbuN/HIxZ2lrOsKyEHta0WjRZjfU5X1pGxlbcGwOA==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.29.2.tgz", + "integrity": "sha512-ZMKgteYmZXnq7C22U9fbCTC6jAF8XlQM5n2K2FLWCdYAlP4FNV3XeQ9hY2a13KSQ0Q3yltWXZlcWBpt5ClxScg==", "license": "MIT", "dependencies": { "@types/use-sync-external-store": "^0.0.6", @@ -3141,12 +3190,12 @@ "url": "https://github.com/sponsors/ueberdosis" }, "optionalDependencies": { - "@tiptap/extension-bubble-menu": "^3.20.0", - "@tiptap/extension-floating-menu": "^3.20.0" + "@tiptap/extension-bubble-menu": "^3.29.2", + "@tiptap/extension-floating-menu": "^3.29.2" }, "peerDependencies": { - "@tiptap/core": "^3.20.0", - "@tiptap/pm": "^3.20.0", + "@tiptap/core": "3.29.2", + "@tiptap/pm": "3.29.2", "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", @@ -3154,35 +3203,35 @@ } }, "node_modules/@tiptap/starter-kit": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.20.0.tgz", - "integrity": "sha512-W4+1re35pDNY/7rpXVg+OKo/Fa4Gfrn08Bq3E3fzlJw6gjE3tYU8dY9x9vC2rK9pd9NOp7Af11qCFDaWpohXkw==", - "license": "MIT", - "dependencies": { - "@tiptap/core": "^3.20.0", - "@tiptap/extension-blockquote": "^3.20.0", - "@tiptap/extension-bold": "^3.20.0", - "@tiptap/extension-bullet-list": "^3.20.0", - "@tiptap/extension-code": "^3.20.0", - "@tiptap/extension-code-block": "^3.20.0", - "@tiptap/extension-document": "^3.20.0", - "@tiptap/extension-dropcursor": "^3.20.0", - "@tiptap/extension-gapcursor": "^3.20.0", - "@tiptap/extension-hard-break": "^3.20.0", - "@tiptap/extension-heading": "^3.20.0", - "@tiptap/extension-horizontal-rule": "^3.20.0", - "@tiptap/extension-italic": "^3.20.0", - "@tiptap/extension-link": "^3.20.0", - "@tiptap/extension-list": "^3.20.0", - "@tiptap/extension-list-item": "^3.20.0", - "@tiptap/extension-list-keymap": "^3.20.0", - "@tiptap/extension-ordered-list": "^3.20.0", - "@tiptap/extension-paragraph": "^3.20.0", - "@tiptap/extension-strike": "^3.20.0", - "@tiptap/extension-text": "^3.20.0", - "@tiptap/extension-underline": "^3.20.0", - "@tiptap/extensions": "^3.20.0", - "@tiptap/pm": "^3.20.0" + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.29.2.tgz", + "integrity": "sha512-oTu0tysiqk4zgjEtxRHjAQgxUKaAevZwueOWwSWubHdokqp7SpcbE5n9USJv89HKuTUDm3GjnQH6q8HNn/2DsA==", + "license": "MIT", + "dependencies": { + "@tiptap/core": "^3.29.2", + "@tiptap/extension-blockquote": "^3.29.2", + "@tiptap/extension-bold": "^3.29.2", + "@tiptap/extension-bullet-list": "^3.29.2", + "@tiptap/extension-code": "^3.29.2", + "@tiptap/extension-code-block": "^3.29.2", + "@tiptap/extension-document": "^3.29.2", + "@tiptap/extension-dropcursor": "^3.29.2", + "@tiptap/extension-gapcursor": "^3.29.2", + "@tiptap/extension-hard-break": "^3.29.2", + "@tiptap/extension-heading": "^3.29.2", + "@tiptap/extension-horizontal-rule": "^3.29.2", + "@tiptap/extension-italic": "^3.29.2", + "@tiptap/extension-link": "^3.29.2", + "@tiptap/extension-list": "^3.29.2", + "@tiptap/extension-list-item": "^3.29.2", + "@tiptap/extension-list-keymap": "^3.29.2", + "@tiptap/extension-ordered-list": "^3.29.2", + "@tiptap/extension-paragraph": "^3.29.2", + "@tiptap/extension-strike": "^3.29.2", + "@tiptap/extension-text": "^3.29.2", + "@tiptap/extension-underline": "^3.29.2", + "@tiptap/extensions": "^3.29.2", + "@tiptap/pm": "^3.29.2" }, "funding": { "type": "github", @@ -3190,17 +3239,18 @@ } }, "node_modules/@tiptap/suggestion": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/suggestion/-/suggestion-3.20.0.tgz", - "integrity": "sha512-OA9Fe+1Q/Ex0ivTcpRcVFiLnNsVdIBmiEoctt/gu4H2ayCYmZ906veioXNdc1m/3MtVVUIuEnvwwsrOZXlfDEw==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/suggestion/-/suggestion-3.29.2.tgz", + "integrity": "sha512-ZEhRm0gnRCwCScR9IrnIBhm9sr6U8vpR9oeznYk3hiedZ48zsgohqvgoIYpWcwYWrT2Pb0NrfhCT8IuSzdJHzQ==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.20.0", - "@tiptap/pm": "^3.20.0" + "@floating-ui/dom": "^1.0.0", + "@tiptap/core": "3.29.2", + "@tiptap/pm": "3.29.2" } }, "node_modules/@types/babel__core": { @@ -3281,28 +3331,6 @@ "@types/unist": "*" } }, - "node_modules/@types/linkify-it": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", - "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", - "license": "MIT" - }, - "node_modules/@types/markdown-it": { - "version": "14.1.2", - "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", - "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", - "license": "MIT", - "dependencies": { - "@types/linkify-it": "^5", - "@types/mdurl": "^2" - } - }, - "node_modules/@types/mdurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", - "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", @@ -3493,12 +3521,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "license": "Python-2.0" - }, "node_modules/aria-hidden": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", @@ -3657,12 +3679,6 @@ "dev": true, "license": "MIT" }, - "node_modules/crelt": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", - "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", - "license": "MIT" - }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -3762,18 +3778,6 @@ "node": ">=10.13.0" } }, - "node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "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", @@ -3833,18 +3837,6 @@ "node": ">=6" } }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "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", @@ -4293,19 +4285,10 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/linkify-it": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", - "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", - "license": "MIT", - "dependencies": { - "uc.micro": "^2.0.0" - } - }, "node_modules/linkifyjs": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz", - "integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz", + "integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==", "license": "MIT" }, "node_modules/lowlight": { @@ -4342,23 +4325,6 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/markdown-it": { - "version": "14.1.1", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", - "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1", - "entities": "^4.4.0", - "linkify-it": "^5.0.0", - "mdurl": "^2.0.0", - "punycode.js": "^2.3.1", - "uc.micro": "^2.1.0" - }, - "bin": { - "markdown-it": "bin/markdown-it.mjs" - } - }, "node_modules/marked": { "version": "17.0.3", "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.3.tgz", @@ -4371,12 +4337,6 @@ "node": ">= 20" } }, - "node_modules/mdurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", - "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", - "license": "MIT" - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -4501,23 +4461,14 @@ } }, "node_modules/prosemirror-changeset": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.0.tgz", - "integrity": "sha512-LvqH2v7Q2SF6yxatuPP2e8vSUKS/L+xAU7dPDC4RMyHMhZoGDfBC74mYuyYF4gLqOEG758wajtyhNnsTkuhvng==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz", + "integrity": "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==", "license": "MIT", "dependencies": { "prosemirror-transform": "^1.0.0" } }, - "node_modules/prosemirror-collab": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz", - "integrity": "sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==", - "license": "MIT", - "dependencies": { - "prosemirror-state": "^1.0.0" - } - }, "node_modules/prosemirror-commands": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz", @@ -4541,9 +4492,9 @@ } }, "node_modules/prosemirror-gapcursor": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.0.tgz", - "integrity": "sha512-z00qvurSdCEWUIulij/isHaqu4uLS8r/Fi61IbjdIPJEonQgggbJsLnstW7Lgdk4zQ68/yr6B6bf7sJXowIgdQ==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz", + "integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==", "license": "MIT", "dependencies": { "prosemirror-keymap": "^1.0.0", @@ -4584,47 +4535,15 @@ "w3c-keyname": "^2.2.0" } }, - "node_modules/prosemirror-markdown": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.4.tgz", - "integrity": "sha512-D98dm4cQ3Hs6EmjK500TdAOew4Z03EV71ajEFiWra3Upr7diytJsjF4mPV2dW+eK5uNectiRj0xFxYI9NLXDbw==", - "license": "MIT", - "dependencies": { - "@types/markdown-it": "^14.0.0", - "markdown-it": "^14.0.0", - "prosemirror-model": "^1.25.0" - } - }, - "node_modules/prosemirror-menu": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.3.0.tgz", - "integrity": "sha512-TImyPXCHPcDsSka2/lwJ6WjTASr4re/qWq1yoTTuLOqfXucwF6VcRa2LWCkM/EyTD1UO3CUwiH8qURJoWJRxwg==", - "license": "MIT", - "dependencies": { - "crelt": "^1.0.0", - "prosemirror-commands": "^1.0.0", - "prosemirror-history": "^1.0.0", - "prosemirror-state": "^1.0.0" - } - }, "node_modules/prosemirror-model": { - "version": "1.25.4", - "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz", - "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==", + "version": "1.25.11", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.11.tgz", + "integrity": "sha512-QWg9RhnpLlogAmp3p96uEFrE5txQpFynd4vhBAELkwgOCWQs/X0yCzB3/hrHqiPwf91RG5KyWq6553zs9JqIOQ==", "license": "MIT", "dependencies": { "orderedmap": "^2.0.0" } }, - "node_modules/prosemirror-schema-basic": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz", - "integrity": "sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==", - "license": "MIT", - "dependencies": { - "prosemirror-model": "^1.25.0" - } - }, "node_modules/prosemirror-schema-list": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz", @@ -4660,50 +4579,26 @@ "prosemirror-view": "^1.41.4" } }, - "node_modules/prosemirror-trailing-node": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz", - "integrity": "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==", - "license": "MIT", - "dependencies": { - "@remirror/core-constants": "3.0.0", - "escape-string-regexp": "^4.0.0" - }, - "peerDependencies": { - "prosemirror-model": "^1.22.1", - "prosemirror-state": "^1.4.2", - "prosemirror-view": "^1.33.8" - } - }, "node_modules/prosemirror-transform": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.11.0.tgz", - "integrity": "sha512-4I7Ce4KpygXb9bkiPS3hTEk4dSHorfRw8uI0pE8IhxlK2GXsqv5tIA7JUSxtSu7u8APVOTtbUBxTmnHIxVkIJw==", + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz", + "integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==", "license": "MIT", "dependencies": { "prosemirror-model": "^1.21.0" } }, "node_modules/prosemirror-view": { - "version": "1.41.6", - "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.6.tgz", - "integrity": "sha512-mxpcDG4hNQa/CPtzxjdlir5bJFDlm0/x5nGBbStB2BWX+XOQ9M8ekEG+ojqB5BcVu2Rc80/jssCMZzSstJuSYg==", + "version": "1.42.2", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.2.tgz", + "integrity": "sha512-Pdg0l5kXm8aLDquFAnQFTCITg0q44sLqBlHlpsVLD9segdOao8TOfQdAhCrCXyVgPSRr6UDDROOIWA3bIrN9YQ==", "license": "MIT", "dependencies": { - "prosemirror-model": "^1.20.0", + "prosemirror-model": "^1.25.8", "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.1.0" } }, - "node_modules/punycode.js": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", - "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/react": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", @@ -5026,12 +4921,6 @@ "node": ">=14.17" } }, - "node_modules/uc.micro": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", - "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", diff --git a/package.json b/package.json index dae25d48..cc036c5d 100644 --- a/package.json +++ b/package.json @@ -24,23 +24,27 @@ "@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-updater": "^2.10.0", - "@tiptap/core": "^3.19.0", - "@tiptap/extension-code-block-lowlight": "^3.20.0", - "@tiptap/extension-image": "^3.18.0", - "@tiptap/extension-link": "^3.18.0", - "@tiptap/extension-mathematics": "^3.18.0", - "@tiptap/extension-placeholder": "^3.18.0", - "@tiptap/extension-table": "^3.19.0", - "@tiptap/extension-table-cell": "^3.19.0", - "@tiptap/extension-table-header": "^3.19.0", - "@tiptap/extension-table-row": "^3.19.0", - "@tiptap/extension-task-item": "^3.18.0", - "@tiptap/extension-task-list": "^3.18.0", - "@tiptap/markdown": "^3.18.0", - "@tiptap/pm": "^3.18.0", - "@tiptap/react": "^3.18.0", - "@tiptap/starter-kit": "^3.18.0", - "@tiptap/suggestion": "^3.18.0", + "@tiptap/core": "3.29.2", + "@tiptap/extension-code-block-lowlight": "3.29.2", + "@tiptap/extension-highlight": "3.29.2", + "@tiptap/extension-image": "3.29.2", + "@tiptap/extension-link": "3.29.2", + "@tiptap/extension-mathematics": "3.29.2", + "@tiptap/extension-placeholder": "3.29.2", + "@tiptap/extension-subscript": "3.29.2", + "@tiptap/extension-superscript": "3.29.2", + "@tiptap/extension-table": "3.29.2", + "@tiptap/extension-table-cell": "3.29.2", + "@tiptap/extension-table-header": "3.29.2", + "@tiptap/extension-table-row": "3.29.2", + "@tiptap/extension-task-item": "3.29.2", + "@tiptap/extension-task-list": "3.29.2", + "@tiptap/extension-text-style": "3.29.2", + "@tiptap/markdown": "3.29.2", + "@tiptap/pm": "3.29.2", + "@tiptap/react": "3.29.2", + "@tiptap/starter-kit": "3.29.2", + "@tiptap/suggestion": "3.29.2", "beautiful-mermaid": "^1.1.3", "clsx": "^2.1.1", "highlight.js": "^11.11.1", diff --git a/src/App.css b/src/App.css index d094bedf..f8339b4c 100644 --- a/src/App.css +++ b/src/App.css @@ -16,6 +16,8 @@ --color-border: rgba(28, 25, 23, 0.08); --color-accent: #1c1917; --color-selection: rgba(250, 204, 21, 0.4); /* Tailwind yellow-400 */ + --editor-selection-bg: #fef9c3; + --editor-selection-text: var(--color-text); /* Editor font settings - simplified (overridden by ThemeContext) */ --editor-font-family: @@ -61,6 +63,8 @@ --color-border: rgba(250, 249, 249, 0.07); --color-accent: #fafaf9; --color-selection: rgba(253, 224, 71, 0.35); /* Tailwind yellow-300 */ + --editor-selection-bg: #33301c; + --editor-selection-text: var(--color-text); /* Syntax highlighting colors - Dark (GitHub) */ --color-syntax-keyword: #ea4a5a; @@ -154,6 +158,63 @@ body { background-color: var(--color-selection); } +/* Keep native selection paint. This decoration remains visible when editor + focus moves to the contextual formatting controls. */ +.scratch-text-selection { + color: inherit; + background-color: var(--editor-selection-bg); + border-radius: 2px; + box-shadow: 0 0 0 1px var(--editor-selection-bg); + -webkit-box-decoration-break: clone; + box-decoration-break: clone; +} + +.ProseMirror [data-text-color] { + color: var(--scratch-text-color-light); +} + +.dark .ProseMirror [data-text-color] { + color: var(--scratch-text-color-dark); +} + +.ProseMirror mark { + --scratch-highlight-surface: var( + --scratch-highlight-color-light, + #fef9c3 + ); + + color: inherit; + background-color: var(--scratch-highlight-surface); + border-radius: 2px; + box-shadow: 0 0 0 1px var(--scratch-highlight-surface); + -webkit-box-decoration-break: clone; + box-decoration-break: clone; +} + +.dark .ProseMirror mark { + --scratch-highlight-surface: var( + --scratch-highlight-color-dark, + #33301c + ); +} + +@media (prefers-contrast: more) { + .scratch-text-selection, + .ProseMirror mark { + box-shadow: 0 0 0 1px var(--editor-selection-text); + } +} + +@media (forced-colors: active) { + .scratch-text-selection, + .ProseMirror mark { + color: HighlightText; + background-color: Highlight; + box-shadow: 0 0 0 1px Highlight; + forced-color-adjust: none; + } +} + /* Hide scrollbar while allowing scroll */ .scrollbar-none { scrollbar-width: none; /* Firefox */ @@ -842,7 +903,8 @@ table.not-prose td { padding: 0.5em 0.75em; text-align: left; vertical-align: top; - min-width: 120px; + /* Match TableKit cellMinWidth and persisted width normalization. */ + min-width: 80px; } /* Avoid double lines with separate border model */ @@ -887,6 +949,17 @@ table.not-prose th > * { pointer-events: none; } +.scratch-table-cell-focused { + box-shadow: inset 0 0 0 2px + color-mix(in srgb, var(--color-accent) 68%, transparent); +} + +@media (prefers-contrast: more) { + .scratch-table-cell-focused { + box-shadow: inset 0 0 0 3px var(--color-accent); + } +} + /* stylelint-disable selector-class-pattern */ /* Table wrapper - scrollable container with border (class name generated by TipTap) */ @@ -909,9 +982,26 @@ table.not-prose th > * { .tableWrapper table { margin: 0; - width: max-content !important; + width: max-content; + max-width: none; + min-width: 0; + table-layout: fixed; +} + +.tableWrapper table[data-fit-to-width="true"] { + width: 100% !important; max-width: 100% !important; - min-width: 0 !important; + table-layout: fixed; +} + +.ProseMirror td[data-table-background-color], +.ProseMirror th[data-table-background-color] { + background-color: var(--scratch-table-background-light); +} + +.dark .ProseMirror td[data-table-background-color], +.dark .ProseMirror th[data-table-background-color] { + background-color: var(--scratch-table-background-dark); } /* stylelint-enable selector-class-pattern */ @@ -923,6 +1013,496 @@ table.not-prose th { position: relative; } +/* Selection formatting and table controls */ +/* Notion-like contextual editor controls */ +.notion-selection-toolbar, +.notion-table-actionbar { + display: flex; + align-items: center; + gap: 2px; + padding: 4px; + color: var(--color-text); + background: color-mix(in srgb, var(--color-bg) 96%, transparent); + border: 1px solid color-mix(in srgb, var(--color-text) 12%, transparent); + border-radius: 8px; + box-shadow: + 0 10px 30px rgba(0, 0, 0, 0.12), + 0 2px 8px rgba(0, 0, 0, 0.08); + backdrop-filter: blur(14px); +} + +.notion-selection-menu { + z-index: 70; + overflow: visible; +} + +.notion-selection-toolbar { + max-width: min(94vw, 760px); + overflow-x: auto; + scrollbar-width: none; +} + +.notion-selection-toolbar::-webkit-scrollbar { + display: none; +} + +.notion-menu-button, +.notion-table-action { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 29px; + height: 29px; + padding: 0 7px; + color: var(--color-text-muted); + font-size: 12px; + line-height: 1; + white-space: nowrap; + border: 0; + border-radius: 5px; + background: transparent; + cursor: pointer; +} + +.notion-menu-button:hover, +.notion-table-action:hover:not(:disabled), +.notion-menu-button.is-active { + color: var(--color-text); + background: var(--color-bg-emphasis); +} + +.notion-block-style-trigger { + min-width: 72px; + gap: 5px; + justify-content: flex-start; + color: var(--color-text); + font-weight: 500; +} + +.notion-block-style-chevron { + margin-left: auto; + color: var(--color-text-muted); +} + +.notion-block-style-menu { + z-index: 90; + width: 190px; + max-height: min(360px, calc(100vh - 24px)); + padding: 5px; + color: var(--color-text); + background: var(--color-bg); + border: 1px solid var(--color-border); + border-radius: 8px; + box-shadow: 0 12px 34px rgba(0, 0, 0, 0.16); + outline: none; + overflow-y: auto; +} + +.notion-block-style-separator { + height: 1px; + margin: 4px 6px; + background: var(--color-border); +} + +.notion-block-style-item { + display: grid; + grid-template-columns: 20px 1fr 16px; + align-items: center; + gap: 8px; + min-height: 32px; + padding: 4px 7px; + color: var(--color-text-muted); + font-size: 12px; + border-radius: 5px; + outline: none; + cursor: pointer; +} + +.notion-block-style-item:hover, +.notion-block-style-item:focus, +.notion-block-style-item[data-highlighted], +.notion-block-style-item.is-active { + color: var(--color-text); + background: var(--color-bg-emphasis); +} + +.notion-table-action:disabled { + opacity: 0.3; + cursor: default; +} + +.notion-menu-separator, +.notion-table-action-separator { + width: 1px; + height: 20px; + margin: 0 3px; + background: var(--color-border); +} + +.notion-color-trigger { + position: relative; + flex-direction: column; + gap: 1px; +} + +.notion-color-indicator { + width: 15px; + height: 2px; + border-radius: 999px; +} + +.notion-color-indicator[data-theme-color="true"], +.notion-color-swatch { + background-color: var(--notion-color-light); +} + +.dark .notion-color-indicator[data-theme-color="true"], +.dark .notion-color-swatch { + background-color: var(--notion-color-dark); +} + +.notion-color-menu { + z-index: 90; + width: 160px; + padding: 7px; + color: var(--color-text); + background: var(--color-bg); + border: 1px solid var(--color-border); + border-radius: 8px; + box-shadow: 0 12px 34px rgba(0, 0, 0, 0.16); +} + +.notion-color-clear { + padding: 6px 7px; + margin-bottom: 5px; + font-size: 12px; + border-radius: 5px; + outline: none; + cursor: pointer; +} + +.notion-color-clear:hover, +.notion-color-clear:focus { + background: var(--color-bg-emphasis); +} + +.notion-color-grid { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 5px; +} + +.notion-color-swatch { + width: 24px; + height: 24px; + border: 2px solid var(--color-bg); + border-radius: 5px; + outline: 1px solid color-mix(in srgb, var(--color-text) 12%, transparent); + cursor: pointer; +} + +.notion-color-swatch.is-active { + outline: 2px solid var(--color-accent); +} + +.notion-table-controls { + position: fixed; + inset: 0; + z-index: 55; + pointer-events: none; +} + +.notion-table-actionbar { + position: fixed; + z-index: 58; + max-width: calc(100vw - 16px); + overflow-x: auto; + scrollbar-width: none; + pointer-events: auto; +} + +.notion-table-actionbar::-webkit-scrollbar { + display: none; +} + +.notion-table-action-search { + width: 116px; + min-width: 96px; + height: 29px; + padding: 0 8px; + color: var(--color-text); + font: inherit; + font-size: 12px; + border: 1px solid var(--color-border); + border-radius: 5px; + outline: none; + background: var(--color-bg); +} + +.notion-table-action-search:focus-visible { + border-color: color-mix(in srgb, var(--color-accent) 65%, var(--color-border)); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-accent) 22%, transparent); +} + +.notion-table-color-chip { + width: 14px; + height: 14px; + border: 1px solid color-mix(in srgb, var(--color-text) 22%, transparent); + border-radius: 3px; + background: var(--color-accent); +} + +.notion-table-color-palette { + display: inline-flex; + align-items: center; + gap: 3px; + padding-left: 4px; + border-left: 1px solid var(--color-border); +} + +.notion-table-color-swatch { + width: 28px; + min-width: 28px; + height: 28px; + padding: 0; + border: 1px solid color-mix(in srgb, var(--color-text) 16%, transparent); + border-radius: 5px; + outline: none; + background: var(--scratch-table-swatch-light); + cursor: pointer; +} + +.dark .notion-table-color-swatch { + background: var(--scratch-table-swatch-dark); +} + +.notion-table-color-swatch:hover, +.notion-table-color-swatch:focus-visible { + border-color: color-mix(in srgb, var(--color-text) 48%, transparent); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-accent) 24%, transparent); +} + +.notion-table-action:focus-visible { + color: var(--color-text); + outline: 2px solid color-mix(in srgb, var(--color-accent) 65%, transparent); + outline-offset: 1px; + background: var(--color-bg-emphasis); +} + +.notion-table-action-label { + padding: 0 3px; + color: var(--color-text-muted); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.notion-table-row-handle, +.notion-table-column-handle, +.notion-table-add-control, +.notion-table-edge-resize { + position: fixed; + z-index: 57; + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + color: var(--color-text-muted); + font-size: 14px; + border: 0; + border-radius: 5px; + background: color-mix(in srgb, var(--color-bg) 94%, transparent); + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08); + cursor: grab; + pointer-events: auto; + touch-action: none; + user-select: none; + -webkit-user-drag: none; +} + +.notion-table-row-handle:hover, +.notion-table-column-handle:hover, +.notion-table-row-handle.is-active, +.notion-table-column-handle.is-active { + color: var(--color-text); + background: var(--color-bg-emphasis); +} + +.notion-table-row-handle.is-disabled { + opacity: 0.35; + cursor: not-allowed; +} + +.notion-table-row-handle.is-active, +.notion-table-column-handle.is-active { + color: var(--color-accent); + background: color-mix(in srgb, var(--color-accent) 16%, var(--color-bg)); + box-shadow: inset 0 0 0 1px + color-mix(in srgb, var(--color-accent) 42%, transparent); +} + +.notion-table-add-control { + cursor: pointer; +} + +.notion-table-add-control:hover { + color: var(--color-text); + background: var(--color-bg-emphasis); +} + +.notion-table-edge-resize { + cursor: ns-resize; +} + +.notion-table-edge-resize.is-column { + cursor: ew-resize; +} + +.notion-table-edge-resize::before { + content: ""; + width: 12px; + height: 4px; + border-block: 1px solid currentColor; +} + +.notion-table-edge-resize.is-column::before { + width: 4px; + height: 12px; + border-block: 0; + border-inline: 1px solid currentColor; +} + +.notion-table-edge-resize:hover, +.notion-table-edge-resize[data-resizing="true"] { + color: var(--color-accent); + background: color-mix(in srgb, var(--color-accent) 12%, var(--color-bg)); +} + +.notion-table-row-resize { + position: fixed; + z-index: 56; + height: 16px; + padding: 0; + border: 0; + background: transparent; + cursor: row-resize; + pointer-events: auto; + touch-action: none; + user-select: none; +} + +.notion-table-column-resize { + position: fixed; + z-index: 57; + width: 16px; + padding: 0; + border: 0; + background: transparent; + cursor: col-resize; + pointer-events: auto; + touch-action: none; + user-select: none; +} + +.notion-table-column-resize::after { + content: ""; + position: absolute; + top: 0; + right: 7px; + bottom: 0; + width: 2px; + border-radius: 999px; + background: transparent; + pointer-events: none; +} + +.notion-table-column-resize:hover::after, +.notion-table-column-resize[data-resizing="true"]::after { + background: color-mix(in srgb, var(--color-accent) 55%, transparent); +} + +.notion-table-row-resize::after { + content: ""; + position: absolute; + top: 7px; + right: 0; + left: 0; + height: 2px; + border-radius: 999px; + background: transparent; + pointer-events: none; +} + +.notion-table-row-resize:hover::after, +.notion-table-row-resize[data-resizing="true"]::after { + background: color-mix(in srgb, var(--color-accent) 55%, transparent); +} + +body.notion-table-row-resizing, +body.notion-table-row-resizing * { + cursor: row-resize !important; + user-select: none !important; +} + +body.notion-table-column-resizing, +body.notion-table-column-resizing * { + cursor: col-resize !important; + user-select: none !important; +} + +.ProseMirror[data-table-pointer-dragging] { + cursor: grabbing; + user-select: none; +} + +.notion-table-drop-indicator { + position: fixed; + z-index: 60; + border-radius: 999px; + background: var(--color-accent); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-bg) 80%, transparent); + pointer-events: none; +} + +.notion-table-announcement { + position: fixed; + width: 1px; + height: 1px; + padding: 0; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; +} + +.ProseMirror.resize-cursor, +.ProseMirror.resize-cursor * { + cursor: col-resize !important; +} + +.column-resize-handle { + position: absolute; + top: 0; + right: -3px; + bottom: -1px; + z-index: 5; + width: 6px; + background: color-mix(in srgb, var(--color-accent) 45%, transparent); + pointer-events: none; +} + +@media print { + .notion-selection-menu, + .notion-table-controls, + .notion-block-style-menu, + .notion-color-menu { + display: none !important; + } +} + /* Radix UI tooltip animations */ @keyframes tooltip-fade-in { from { diff --git a/src/components/editor/Editor.tsx b/src/components/editor/Editor.tsx index c9b75b84..f4e4316e 100644 --- a/src/components/editor/Editor.tsx +++ b/src/components/editor/Editor.tsx @@ -70,6 +70,29 @@ import { Wikilink, type WikilinkStorage } from "./Wikilink"; import { WikilinkSuggestion } from "./WikilinkSuggestion"; import { EditorWidthHandles } from "./EditorWidthHandle"; import { ScratchBlockMath, normalizeBlockMath } from "./MathExtensions"; +import { + ScratchColor, + ScratchHighlight, + ScratchSubscript, + ScratchSuperscript, + ScratchTextStyle, +} from "./notion/markdownMarks"; +import { ScratchTextSelection } from "./notion/selectionDecoration"; +import { + parseMarkdownDocument, + serializeMarkdownDocument, +} from "./notion/markdownDocument"; +import { ScratchTableRow } from "./notion/tableExtensions"; +import { ScratchTableMetadata } from "./notion/tableMetadata"; +import { ScratchTableView } from "./notion/tableView"; +import { replaceEditorContentWithoutHistory } from "./editorHistory"; +import { + pasteTableTsv, + serializeTableCellSelectionToTsv, + shouldRejectTablePaste, +} from "./notion/tableClipboard"; +import { SelectionMenu } from "./notion/NotionMenus"; +import { TableControls } from "./notion/TableControls"; import { cn } from "../../lib/utils"; import { plainTextFromMarkdown } from "../../lib/plainText"; import { getTitleBarNoteInfoText } from "../../lib/titleBarNoteInfo"; @@ -687,7 +710,10 @@ export function Editor({ if (!editorInstance) return ""; const manager = editorInstance.storage.markdown?.manager; if (manager) { - let markdown = manager.serialize(editorInstance.getJSON()); + let markdown = serializeMarkdownDocument( + manager, + editorInstance.getJSON(), + ); // Clean up nbsp entities that TipTap inserts (especially in table cells) markdown = markdown.replace(/ | /g, " "); return markdown; @@ -1300,6 +1326,7 @@ export function Editor({ levels: [1, 2, 3, 4, 5, 6], }, codeBlock: false, + link: false, }), CodeBlockLowlight.extend({ addNodeView() { @@ -1350,15 +1377,28 @@ export function Editor({ nested: true, }), TableKit.configure({ + tableRow: false, table: { - resizable: false, + resizable: true, + handleWidth: 6, + cellMinWidth: 80, + lastColumnResizable: true, + View: ScratchTableView, HTMLAttributes: { class: "not-prose", }, }, }), + ScratchTableRow, + ScratchTableMetadata, + ScratchTextStyle, + ScratchColor, + ScratchHighlight.configure({ multicolor: true }), + ScratchSubscript, + ScratchSuperscript, Frontmatter, Markdown.configure({}), + ScratchTextSelection, SearchHighlight.configure({ matches: [], currentIndex: 0, @@ -1387,12 +1427,17 @@ export function Editor({ }, // Serialize copied text as markdown instead of plain text clipboardTextSerializer: (slice) => { + const currentEditor = editorRef.current; + const tableTsv = currentEditor + ? serializeTableCellSelectionToTsv(currentEditor) + : null; + if (tableTsv !== null) return tableTsv; + const fallback = slice.content.textBetween( 0, slice.content.size, "\n\n", ); - const currentEditor = editorRef.current; const manager = currentEditor?.storage.markdown?.manager; if (!currentEditor || !manager) return fallback; try { @@ -1400,7 +1445,7 @@ export function Editor({ null, slice.content, ); - return manager.serialize(doc.toJSON()); + return serializeMarkdownDocument(manager, doc.toJSON()); } catch { return fallback; } @@ -1465,9 +1510,34 @@ export function Editor({ } } + const currentEditor = editorRef.current; + const isInsideTableCell = Boolean( + currentEditor && + (currentEditor.isActive("tableCell") || + currentEditor.isActive("tableHeader")), + ); + const clipboardHtml = clipboardData.getData("text/html"); + if ( + shouldRejectTablePaste({ + isInsideTableCell, + html: clipboardHtml, + parsedContent: null, + }) + ) { + toast.error("A table can’t be pasted inside a table cell"); + return true; + } + // Handle markdown text paste const text = clipboardData.getData("text/plain"); if (!text) return false; + if ( + isInsideTableCell && + currentEditor && + pasteTableTsv(currentEditor, text) + ) { + return true; + } // Check if text looks like markdown (has common markdown patterns) const markdownPatterns = @@ -1478,14 +1548,23 @@ export function Editor({ } // Parse markdown and insert using editor ref - const currentEditor = editorRef.current; if (!currentEditor) return false; const manager = currentEditor.storage.markdown?.manager; if (manager && typeof manager.parse === "function") { try { - const parsed = manager.parse(text); + const parsed = parseMarkdownDocument(manager, text); if (parsed) { + if ( + shouldRejectTablePaste({ + isInsideTableCell, + html: clipboardHtml, + parsedContent: parsed, + }) + ) { + toast.error("A table can’t be pasted inside a table cell"); + return true; + } currentEditor.commands.insertContent(parsed); return true; } @@ -1500,8 +1579,9 @@ export function Editor({ onCreate: ({ editor: editorInstance }) => { editorRef.current = editorInstance; }, - onUpdate: () => { + onUpdate: ({ transaction }) => { if (isLoadingRef.current) return; + if (transaction.getMeta("scratchTableRowResizePreview")) return; scheduleSave(); }, onSelectionUpdate: () => { @@ -1732,17 +1812,20 @@ export function Editor({ // Manual reload - update the editor content lastReloadVersionRef.current = reloadVersion; loadedModifiedRef.current = currentNote.modified; + needsSaveRef.current = false; + sourceNeedsSaveRef.current = false; + setIsDirty(false); isLoadingRef.current = true; const manager = editor.storage.markdown?.manager; if (manager) { try { - const parsed = manager.parse(currentNote.content); - editor.commands.setContent(parsed); + const parsed = parseMarkdownDocument(manager, currentNote.content); + replaceEditorContentWithoutHistory(editor, parsed); } catch { - editor.commands.setContent(currentNote.content); + replaceEditorContentWithoutHistory(editor, currentNote.content); } } else { - editor.commands.setContent(currentNote.content); + replaceEditorContentWithoutHistory(editor, currentNote.content); } isLoadingRef.current = false; return; @@ -1758,6 +1841,9 @@ export function Editor({ loadedNoteIdRef.current = loadingNoteId; loadedModifiedRef.current = currentNote.modified; + needsSaveRef.current = false; + sourceNeedsSaveRef.current = false; + setIsDirty(false); isLoadingRef.current = true; @@ -1768,14 +1854,14 @@ export function Editor({ const manager = editor.storage.markdown?.manager; if (manager) { try { - const parsed = manager.parse(currentNote.content); - editor.commands.setContent(parsed); + const parsed = parseMarkdownDocument(manager, currentNote.content); + replaceEditorContentWithoutHistory(editor, parsed); } catch { // Fallback to plain text if parsing fails - editor.commands.setContent(currentNote.content); + replaceEditorContentWithoutHistory(editor, currentNote.content); } } else { - editor.commands.setContent(currentNote.content); + replaceEditorContentWithoutHistory(editor, currentNote.content); } // Scroll to top after content is set (must be after setContent to work reliably) @@ -2284,13 +2370,13 @@ export function Editor({ const manager = editor.storage.markdown?.manager; if (manager) { try { - const parsed = manager.parse(sourceContent); - editor.commands.setContent(parsed); + const parsed = parseMarkdownDocument(manager, sourceContent); + replaceEditorContentWithoutHistory(editor, parsed); } catch { - editor.commands.setContent(sourceContent); + replaceEditorContentWithoutHistory(editor, sourceContent); } } else { - editor.commands.setContent(sourceContent); + replaceEditorContentWithoutHistory(editor, sourceContent); } setSourceMode(false); } @@ -2950,13 +3036,15 @@ export function Editor({ editor.chain().focus().addColumnAfter().run(), }), ); - menuItems.push( - await MenuItem.new({ - text: "Delete Column", - action: () => - editor.chain().focus().deleteColumn().run(), - }), - ); + if (rowNode.childCount > 1) { + menuItems.push( + await MenuItem.new({ + text: "Delete Column", + action: () => + editor.chain().focus().deleteColumn().run(), + }), + ); + } menuItems.push( await PredefinedMenuItem.new({ item: "Separator" }), ); @@ -2978,12 +3066,15 @@ export function Editor({ editor.chain().focus().addRowAfter().run(), }), ); - menuItems.push( - await MenuItem.new({ - text: "Delete Row", - action: () => editor.chain().focus().deleteRow().run(), - }), - ); + if (tableNode.childCount > 1) { + menuItems.push( + await MenuItem.new({ + text: "Delete Row", + action: () => + editor.chain().focus().deleteRow().run(), + }), + ); + } menuItems.push( await PredefinedMenuItem.new({ item: "Separator" }), ); @@ -3020,7 +3111,19 @@ export function Editor({ } }} > - + + {editor && ( + <> + + + + )}
)} diff --git a/src/components/editor/editorHistory.test.ts b/src/components/editor/editorHistory.test.ts new file mode 100644 index 00000000..79211ebd --- /dev/null +++ b/src/components/editor/editorHistory.test.ts @@ -0,0 +1,117 @@ +import { Editor, type JSONContent } from "@tiptap/core"; +import { TableKit } from "@tiptap/extension-table"; +import StarterKit from "@tiptap/starter-kit"; +import { describe, expect, it } from "vitest"; +import { replaceEditorContentWithoutHistory } from "./editorHistory"; + +describe("editor note history", () => { + it("never lets undo erase the content loaded from the note", () => { + const editor = new Editor({ + extensions: [StarterKit], + content: "

", + }); + + try { + const loadedContent = "Contenu chargé depuis la note"; + + replaceEditorContentWithoutHistory( + editor, + `

${loadedContent}

`, + ); + + const paragraphEnd = editor.state.doc.content.size - 1; + editor.commands.insertContentAt(paragraphEnd, " + saisie"); + expect(editor.getText()).toBe(`${loadedContent} + saisie`); + + expect(editor.commands.undo()).toBe(true); + expect(editor.getText()).toBe(loadedContent); + + expect(editor.commands.undo()).toBe(false); + expect(editor.getText()).toBe(loadedContent); + } finally { + editor.destroy(); + } + }); + + it("flattens nested tables while preserving parent and nested cell text", () => { + const editor = new Editor({ + extensions: [StarterKit, TableKit], + content: "

", + }); + + const loadedContent: JSONContent = { + type: "doc", + content: [ + { + type: "table", + content: [ + { + type: "tableRow", + content: [ + { + type: "tableCell", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Parent cell" }], + }, + { + type: "table", + content: [ + { + type: "tableRow", + content: [ + { + type: "tableCell", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "Nested cell" }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }; + + try { + replaceEditorContentWithoutHistory(editor, loadedContent); + + const cellsContainingNestedTables: string[] = []; + editor.state.doc.descendants((node) => { + if (node.type.name !== "tableCell" && node.type.name !== "tableHeader") { + return; + } + + let containsTable = false; + node.descendants((descendant) => { + if (descendant.type.name === "table") { + containsTable = true; + return false; + } + }); + + if (containsTable) { + cellsContainingNestedTables.push(node.textContent); + } + }); + + expect(cellsContainingNestedTables).toEqual([]); + expect(editor.state.doc.textContent).toContain("Parent cell"); + expect(editor.state.doc.textContent).toContain("Nested cell"); + } finally { + editor.destroy(); + } + }); +}); diff --git a/src/components/editor/editorHistory.ts b/src/components/editor/editorHistory.ts new file mode 100644 index 00000000..fe5ffdf6 --- /dev/null +++ b/src/components/editor/editorHistory.ts @@ -0,0 +1,45 @@ +import type { Content, Editor } from "@tiptap/core"; +import { DOMParser as ProseMirrorDOMParser } from "@tiptap/pm/model"; +import { EditorState } from "@tiptap/pm/state"; +import { normalizeNestedTablesInJson } from "./notion/tableIntegrity"; + +function normalizeLoadedContent(editor: Editor, content: Content): Content { + if (typeof content === "string") { + const container = document.createElement("div"); + container.innerHTML = content; + const parsed = ProseMirrorDOMParser.fromSchema(editor.schema).parse(container); + return normalizeNestedTablesInJson(parsed.toJSON()); + } + + if (Array.isArray(content)) { + return ( + normalizeNestedTablesInJson({ type: "doc", content }).content ?? [] + ); + } + + return content ? normalizeNestedTablesInJson(content) : content; +} + +/** + * Replace the document loaded from disk and start a fresh undo history. + * + * A note load is not a user edit. Keeping the setContent transaction in the + * ProseMirror history lets a second Cmd+Z restore the previous (often empty) + * document, which can then be autosaved over the note on disk. + */ +export function replaceEditorContentWithoutHistory( + editor: Editor, + content: Content, +): void { + editor.commands.setContent(normalizeLoadedContent(editor, content)); + + const loadedState = editor.state; + editor.view.updateState( + EditorState.create({ + schema: loadedState.schema, + doc: loadedState.doc, + selection: loadedState.selection, + plugins: loadedState.plugins, + }), + ); +} diff --git a/src/components/editor/notion/NotionMenus.test.tsx b/src/components/editor/notion/NotionMenus.test.tsx new file mode 100644 index 00000000..074898a3 --- /dev/null +++ b/src/components/editor/notion/NotionMenus.test.tsx @@ -0,0 +1,394 @@ +import { Editor } from "@tiptap/core"; +import Image from "@tiptap/extension-image"; +import TaskItem from "@tiptap/extension-task-item"; +import TaskList from "@tiptap/extension-task-list"; +import StarterKit from "@tiptap/starter-kit"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { SelectionMenu, shouldShowSelectionMenu } from "./NotionMenus"; +import { + ScratchColor, + ScratchHighlight, + ScratchTextStyle, +} from "./markdownMarks"; + +(globalThis as unknown as Record).IS_REACT_ACT_ENVIRONMENT = + true; + +describe("selection menu icon consistency", () => { + afterEach(() => { + document.body.replaceChildren(); + }); + + it("uses the shared SVG icon family for actions also present in the main toolbar", async () => { + const editor = new Editor({ + extensions: [StarterKit], + content: "

Selected text

", + }); + const host = document.createElement("div"); + const controls = document.createElement("div"); + host.append(editor.view.dom, controls); + document.body.append(host); + const root = createRoot(controls); + editor.commands.setTextSelection({ from: 1, to: 9 }); + + try { + await act(async () => { + root.render(); + }); + + for (const label of [ + "Bold", + "Italic", + "Strikethrough", + "Inline code", + "Add link", + ]) { + expect( + document.querySelector(`button[aria-label="${label}"] svg`), + `${label} should use the shared SVG icon family`, + ).not.toBeNull(); + } + } finally { + await act(async () => { + root.unmount(); + }); + editor.destroy(); + } + }); + + it("exposes accessible light and dark theme values on color swatches", async () => { + const editor = new Editor({ + extensions: [StarterKit], + content: "

Selected text

", + }); + const host = document.createElement("div"); + const controls = document.createElement("div"); + host.append(editor.view.dom, controls); + document.body.append(host); + const root = createRoot(controls); + editor.commands.setTextSelection({ from: 1, to: 9 }); + + try { + await act(async () => { + root.render(); + }); + + const trigger = document.querySelector( + 'button[aria-label="Text color"]', + ); + await act(async () => { + trigger?.dispatchEvent( + new PointerEvent("pointerdown", { + bubbles: true, + button: 0, + isPrimary: true, + pointerId: 1, + }), + ); + }); + + const red = document.querySelector( + '[aria-label="text #dc2626"]', + ); + expect(red).not.toBeNull(); + expect(red?.style.getPropertyValue("--notion-color-light")).toBe( + "#b91c1c", + ); + expect(red?.style.getPropertyValue("--notion-color-dark")).toBe( + "#fca5a5", + ); + } finally { + await act(async () => { + root.unmount(); + }); + editor.destroy(); + } + }); + + it("keeps selection while applying text color then highlight from both menus", async () => { + const editor = new Editor({ + extensions: [ + StarterKit, + ScratchTextStyle, + ScratchColor, + ScratchHighlight.configure({ multicolor: true }), + ], + content: "

Selected text

", + }); + const host = document.createElement("div"); + const controls = document.createElement("div"); + host.append(editor.view.dom, controls); + document.body.append(host); + const root = createRoot(controls); + editor.commands.setTextSelection({ from: 1, to: 9 }); + + const openMenu = async (label: string) => { + const trigger = document.querySelector( + `button[aria-label="${label}"]`, + ); + await act(async () => { + trigger?.dispatchEvent( + new PointerEvent("pointerdown", { + bubbles: true, + button: 0, + isPrimary: true, + pointerId: 1, + }), + ); + }); + }; + + try { + await act(async () => { + root.render(); + }); + + await openMenu("Text color"); + await act(async () => { + document.querySelector('[aria-label="text #dc2626"]')?.click(); + }); + expect(editor.state.selection).toMatchObject({ from: 1, to: 9 }); + + await openMenu("Highlight color"); + await act(async () => { + document + .querySelector('[aria-label="highlight #fde047"]') + ?.click(); + }); + + const selectedText = editor.state.doc.child(0).child(0); + expect(editor.state.selection).toMatchObject({ from: 1, to: 9 }); + expect( + selectedText.marks.find((mark) => mark.type.name === "textStyle") + ?.attrs.color, + ).toBe("#dc2626"); + expect( + selectedText.marks.find((mark) => mark.type.name === "highlight") + ?.attrs.color, + ).toBe("#fde047"); + expect( + editor.view.dom.querySelector('[data-text-color="#dc2626"]'), + ).not.toBeNull(); + expect( + editor.view.dom.querySelector('mark[data-color="#fde047"]'), + ).not.toBeNull(); + } finally { + await act(async () => { + root.unmount(); + }); + editor.destroy(); + } + }); + + it("changes the selected block style from text to heading 2", async () => { + const editor = new Editor({ + extensions: [StarterKit], + content: "

Selected text

", + }); + const host = document.createElement("div"); + const controls = document.createElement("div"); + host.append(editor.view.dom, controls); + document.body.append(host); + const root = createRoot(controls); + editor.commands.setTextSelection({ from: 1, to: 9 }); + + try { + await act(async () => { + root.render(); + }); + + const trigger = document.querySelector( + 'button[aria-label="Block style: Text"]', + ); + expect(trigger).not.toBeNull(); + + await act(async () => { + trigger?.dispatchEvent( + new PointerEvent("pointerdown", { + bubbles: true, + button: 0, + isPrimary: true, + pointerId: 1, + }), + ); + }); + + const heading2 = Array.from( + document.querySelectorAll('[role="menuitem"]'), + ).find((item) => item.textContent?.includes("Heading 2")); + expect( + Array.from( + document.querySelectorAll('[role="menuitem"]'), + ).map((item) => item.textContent?.trim()), + ).toEqual([ + "Text", + "Heading 1", + "Heading 2", + "Heading 3", + "Heading 4", + "Bulleted list", + "Numbered list", + "Task list", + "Quote", + ]); + expect(heading2).not.toBeNull(); + + await act(async () => { + heading2?.click(); + }); + + expect(editor.getJSON().content?.[0]).toMatchObject({ + type: "heading", + attrs: { level: 2 }, + }); + expect( + document.querySelector('button[aria-label="Block style: H2"]'), + ).not.toBeNull(); + } finally { + await act(async () => { + root.unmount(); + }); + editor.destroy(); + } + }); + + it.each([ + ["Bulleted list", "Bullets", "bulletList"], + ["Numbered list", "Numbered", "orderedList"], + ["Task list", "Tasks", "taskList"], + ["Quote", "Quote", "blockquote"], + ])( + "changes selected text to %s", + async (label, shortLabel, expectedNodeType) => { + const editor = new Editor({ + extensions: [ + StarterKit, + TaskList, + TaskItem.configure({ nested: true }), + ], + content: "

Selected text

", + }); + const host = document.createElement("div"); + const controls = document.createElement("div"); + host.append(editor.view.dom, controls); + document.body.append(host); + const root = createRoot(controls); + editor.commands.setTextSelection({ from: 1, to: 9 }); + + try { + await act(async () => { + root.render(); + }); + + const trigger = document.querySelector( + 'button[aria-label="Block style: Text"]', + ); + await act(async () => { + trigger?.dispatchEvent( + new PointerEvent("pointerdown", { + bubbles: true, + button: 0, + isPrimary: true, + pointerId: 1, + }), + ); + }); + + const item = document.querySelector( + `[aria-label="Set block style to ${label}"]`, + ); + expect(item).not.toBeNull(); + + await act(async () => { + item?.click(); + }); + + expect(editor.getJSON().content?.[0]?.type).toBe(expectedNodeType); + expect( + document.querySelector( + `button[aria-label="Block style: ${shortLabel}"]`, + ), + ).not.toBeNull(); + } finally { + await act(async () => { + root.unmount(); + }); + editor.destroy(); + } + }, + ); + + it("changes a bulleted list to a numbered list in one undoable transaction", async () => { + const editor = new Editor({ + extensions: [StarterKit], + content: "
  • Selected text

", + }); + const host = document.createElement("div"); + const controls = document.createElement("div"); + host.append(editor.view.dom, controls); + document.body.append(host); + const root = createRoot(controls); + editor.commands.setTextSelection({ from: 3, to: 11 }); + expect(editor.isActive("bulletList")).toBe(true); + + try { + await act(async () => { + root.render(); + }); + + const trigger = document.querySelector( + 'button[aria-label="Block style: Bullets"]', + ); + expect(trigger).not.toBeNull(); + await act(async () => { + trigger?.dispatchEvent( + new PointerEvent("pointerdown", { + bubbles: true, + button: 0, + isPrimary: true, + pointerId: 1, + }), + ); + }); + + const numberedList = document.querySelector( + '[aria-label="Set block style to Numbered list"]', + ); + expect(numberedList).not.toBeNull(); + await act(async () => { + numberedList?.click(); + }); + + expect(editor.getJSON().content?.[0]?.type).toBe("orderedList"); + expect( + document.querySelector('button[aria-label="Block style: Numbered"]'), + ).not.toBeNull(); + + await act(async () => { + editor.commands.undo(); + }); + expect(editor.getJSON().content?.[0]?.type).toBe("bulletList"); + } finally { + await act(async () => { + root.unmount(); + }); + editor.destroy(); + } + }); + + it("stays hidden when an image node is selected", () => { + const editor = new Editor({ + extensions: [StarterKit, Image.configure({ inline: false })], + content: '

Before

', + }); + + try { + editor.commands.setNodeSelection(editor.state.doc.child(0).nodeSize); + expect(shouldShowSelectionMenu(editor, editor.state)).toBe(false); + } finally { + editor.destroy(); + } + }); +}); diff --git a/src/components/editor/notion/NotionMenus.tsx b/src/components/editor/notion/NotionMenus.tsx new file mode 100644 index 00000000..16a05c65 --- /dev/null +++ b/src/components/editor/notion/NotionMenus.tsx @@ -0,0 +1,433 @@ +import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; +import { NodeSelection, type EditorState } from "@tiptap/pm/state"; +import { useEditorState, type Editor } from "@tiptap/react"; +import { BubbleMenu } from "@tiptap/react/menus"; +import { + Fragment, + useLayoutEffect, + useRef, + useState, +} from "react"; +import { + BoldIcon, + CheckIcon, + CheckSquareIcon, + ChevronDownIcon, + Heading1Icon, + Heading2Icon, + Heading3Icon, + Heading4Icon, + InlineCodeIcon, + ItalicIcon, + LinkIcon, + ListIcon, + ListOrderedIcon, + PilcrowIcon, + QuoteIcon, + StrikethroughIcon, +} from "../../icons"; +import { + HIGHLIGHT_COLOR_OPTIONS, + TEXT_COLOR_OPTIONS, +} from "./markdownMarks"; + +interface SelectionMenuProps { + editor: Editor; + onEditLink: () => void; +} + +export function shouldShowSelectionMenu( + editor: Editor, + state: EditorState, +): boolean { + return ( + !(state.selection instanceof NodeSelection) && + !state.selection.empty && + editor.isEditable && + !editor.isActive("codeBlock") && + !editor.isActive("frontmatter") + ); +} + +interface MenuButtonProps { + label: string; + active?: boolean; + onClick: () => void; + children: React.ReactNode; +} + +function MenuButton({ + label, + active = false, + onClick, + children, +}: MenuButtonProps) { + return ( + + ); +} + +const BLOCK_STYLES = [ + { value: "text", label: "Text", shortLabel: "Text", icon: PilcrowIcon }, + { value: "h1", label: "Heading 1", shortLabel: "H1", icon: Heading1Icon }, + { value: "h2", label: "Heading 2", shortLabel: "H2", icon: Heading2Icon }, + { value: "h3", label: "Heading 3", shortLabel: "H3", icon: Heading3Icon }, + { value: "h4", label: "Heading 4", shortLabel: "H4", icon: Heading4Icon }, + { + value: "bulletList", + label: "Bulleted list", + shortLabel: "Bullets", + icon: ListIcon, + }, + { + value: "orderedList", + label: "Numbered list", + shortLabel: "Numbered", + icon: ListOrderedIcon, + }, + { + value: "taskList", + label: "Task list", + shortLabel: "Tasks", + icon: CheckSquareIcon, + }, + { value: "quote", label: "Quote", shortLabel: "Quote", icon: QuoteIcon }, +] as const; + +type BlockStyle = (typeof BLOCK_STYLES)[number]["value"]; + +function getActiveBlockStyle(editor: Editor): BlockStyle { + if (editor.isActive("taskList")) return "taskList"; + if (editor.isActive("bulletList")) return "bulletList"; + if (editor.isActive("orderedList")) return "orderedList"; + if (editor.isActive("blockquote")) return "quote"; + if (editor.isActive("heading", { level: 1 })) return "h1"; + if (editor.isActive("heading", { level: 2 })) return "h2"; + if (editor.isActive("heading", { level: 3 })) return "h3"; + if (editor.isActive("heading", { level: 4 })) return "h4"; + return "text"; +} + +function applyBlockStyle(editor: Editor, style: BlockStyle): void { + const activeStyle = getActiveBlockStyle(editor); + const chain = editor.chain().focus(); + + if (activeStyle === style) { + chain.run(); + return; + } + + if (activeStyle === "bulletList") chain.toggleBulletList(); + if (activeStyle === "orderedList") chain.toggleOrderedList(); + if (activeStyle === "taskList") chain.toggleTaskList(); + if (activeStyle === "quote") chain.toggleBlockquote(); + + if (style === "text") chain.setParagraph(); + if (style === "h1") chain.setHeading({ level: 1 }); + if (style === "h2") chain.setHeading({ level: 2 }); + if (style === "h3") chain.setHeading({ level: 3 }); + if (style === "h4") chain.setHeading({ level: 4 }); + if (style === "bulletList") chain.setParagraph().toggleBulletList(); + if (style === "orderedList") chain.setParagraph().toggleOrderedList(); + if (style === "taskList") chain.setParagraph().toggleTaskList(); + if (style === "quote") chain.setParagraph().toggleBlockquote(); + + chain.run(); +} + +function BlockStyleMenu({ + editor, + activeStyle, + portalContainer, +}: { + editor: Editor; + activeStyle: BlockStyle; + portalContainer: HTMLElement | null; +}) { + const activeOption = + BLOCK_STYLES.find((option) => option.value === activeStyle) ?? + BLOCK_STYLES[0]; + const ActiveIcon = activeOption.icon; + + return ( + + + + + + event.preventDefault()} + > + {BLOCK_STYLES.map((option) => { + const OptionIcon = option.icon; + const isActive = option.value === activeStyle; + + return ( + + {option.value === "bulletList" ? ( + + ) : null} + applyBlockStyle(editor, option.value)} + > + + {option.label} + {isActive ? ( + + ) : ( + + + ); + })} + + + + ); +} + +function ColorMenu({ + editor, + kind, + activeColor, + portalContainer, +}: { + editor: Editor; + kind: "text" | "highlight"; + activeColor: string | null; + portalContainer: HTMLElement | null; +}) { + const colors = + kind === "text" ? TEXT_COLOR_OPTIONS : HIGHLIGHT_COLOR_OPTIONS; + const activeOption = colors.find(({ value }) => value === activeColor); + const getThemeStyle = (color: (typeof colors)[number]) => + ({ + "--notion-color-light": color.light, + "--notion-color-dark": color.dark, + }) as React.CSSProperties; + + return ( + + + + + + event.preventDefault()} + > + { + if (kind === "text") { + editor.chain().focus().unsetColor().run(); + } else { + editor.chain().focus().unsetHighlight().run(); + } + }} + > + Default + +
+ {colors.map((color) => ( + { + if (kind === "text") { + editor.chain().focus().setColor(color.value).run(); + } else { + editor + .chain() + .focus() + .setHighlight({ color: color.value }) + .run(); + } + }} + /> + ))} +
+
+
+
+ ); +} + +export function SelectionMenu({ editor, onEditLink }: SelectionMenuProps) { + const menuRef = useRef(null); + const [menuElement, setMenuElement] = useState(null); + useLayoutEffect(() => { + setMenuElement(menuRef.current); + }, []); + const active = useEditorState({ + editor, + selector: ({ editor: currentEditor }) => ({ + bold: currentEditor.isActive("bold"), + italic: currentEditor.isActive("italic"), + underline: currentEditor.isActive("underline"), + strike: currentEditor.isActive("strike"), + code: currentEditor.isActive("code"), + subscript: currentEditor.isActive("subscript"), + superscript: currentEditor.isActive("superscript"), + link: currentEditor.isActive("link"), + blockStyle: getActiveBlockStyle(currentEditor), + textColor: + (currentEditor.getAttributes("textStyle").color as string | undefined) ?? + null, + highlightColor: + (currentEditor.getAttributes("highlight").color as string | undefined) ?? + null, + }), + }); + + return ( + shouldShowSelectionMenu(editor, state)} + className="notion-selection-menu" + > +
+ + + editor.chain().focus().toggleBold().run()} + > + + + editor.chain().focus().toggleItalic().run()} + > + + + editor.chain().focus().toggleUnderline().run()} + > + U + + editor.chain().focus().toggleStrike().run()} + > + + + editor.chain().focus().toggleCode().run()} + > + + + editor.chain().focus().unsetAllMarks().run()} + > + Tx + + + + + + editor.chain().focus().toggleSubscript().run()} + > + X2 + + editor.chain().focus().toggleSuperscript().run()} + > + X2 + + + + +
+
+ ); +} diff --git a/src/components/editor/notion/TableControls.test.tsx b/src/components/editor/notion/TableControls.test.tsx new file mode 100644 index 00000000..7f9f3d02 --- /dev/null +++ b/src/components/editor/notion/TableControls.test.tsx @@ -0,0 +1,723 @@ +import { Editor } from "@tiptap/core"; +import { TableKit } from "@tiptap/extension-table"; +import { undoDepth } from "@tiptap/pm/history"; +import StarterKit from "@tiptap/starter-kit"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ScratchTableRow } from "./tableExtensions"; +import { ScratchTableMetadata } from "./tableMetadata"; +import { TableControls } from "./TableControls"; + +(globalThis as unknown as Record).IS_REACT_ACT_ENVIRONMENT = + true; + +const REMOVED_TABLE_CONTROL_SELECTOR = [ + ".notion-table-row-handle", + ".notion-table-column-handle", + ".notion-table-add-control", +].join(", "); + +function createRect( + left: number, + top: number, + right: number, + bottom: number, +): DOMRect { + return { + x: left, + y: top, + left, + top, + right, + bottom, + width: right - left, + height: bottom - top, + toJSON: () => ({}), + }; +} + +async function flushLayout(): Promise { + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + await new Promise((resolve) => requestAnimationFrame(() => resolve())); +} + +interface TableFixture { + editor: Editor; + root: Root; + editorHost: HTMLElement; + rows: HTMLTableRowElement[]; + cells: HTMLTableCellElement[][]; +} + +function createTableFixture(): TableFixture { + const editor = new Editor({ + extensions: [ + StarterKit, + TableKit.configure({ + tableRow: false, + table: { + resizable: true, + handleWidth: 6, + cellMinWidth: 80, + lastColumnResizable: true, + }, + }), + ScratchTableRow, + ScratchTableMetadata, + ], + content: [ + "", + "", + "", + "", + "", + "

A1

B1

A2

B2

", + ].join(""), + }); + + const editorHost = document.createElement("div"); + const controlsHost = document.createElement("div"); + editorHost.append(editor.view.dom, controlsHost); + document.body.append(editorHost); + const root = createRoot(controlsHost); + + const table = editor.view.dom.querySelector("table"); + if (!table) throw new Error("Missing table fixture"); + const rows = Array.from(table.rows); + const cells = rows.map((row) => Array.from(row.cells)); + if (rows.length !== 2 || cells.some((row) => row.length !== 2)) { + throw new Error("Expected a 2 x 2 table fixture"); + } + + editor.view.dom.getBoundingClientRect = () => createRect(0, 0, 800, 600); + table.getBoundingClientRect = () => createRect(100, 100, 500, 220); + rows[0].getBoundingClientRect = () => createRect(100, 100, 500, 160); + rows[1].getBoundingClientRect = () => createRect(100, 160, 500, 220); + cells[0][0].getBoundingClientRect = () => createRect(100, 100, 300, 160); + cells[0][1].getBoundingClientRect = () => createRect(300, 100, 500, 160); + cells[1][0].getBoundingClientRect = () => createRect(100, 160, 300, 220); + cells[1][1].getBoundingClientRect = () => createRect(300, 160, 500, 220); + + let firstTextPosition: number | null = null; + editor.state.doc.descendants((node, position) => { + if (firstTextPosition === null && node.isText) { + firstTextPosition = position; + } + }); + if (firstTextPosition === null) throw new Error("Missing table cell text"); + editor.commands.setTextSelection(firstTextPosition); + + return { editor, root, editorHost, rows, cells }; +} + +async function movePointer( + fixture: TableFixture, + left: number, + top: number, + target: EventTarget = fixture.editor.view.dom, +): Promise { + await act(async () => { + target.dispatchEvent( + new PointerEvent("pointermove", { + bubbles: true, + clientX: left, + clientY: top, + isPrimary: true, + pointerId: 1, + }), + ); + await flushLayout(); + }); +} + +async function dragRowResizeHandle( + handle: HTMLButtonElement, + start: { left: number; top: number }, + end: { left: number; top: number }, + pointerId: number, +): Promise { + await act(async () => { + handle.dispatchEvent( + new PointerEvent("pointerdown", { + bubbles: true, + button: 0, + clientX: start.left, + clientY: start.top, + isPrimary: true, + pointerId, + }), + ); + window.dispatchEvent( + new PointerEvent("pointermove", { + bubbles: true, + button: 0, + clientX: end.left, + clientY: end.top, + isPrimary: true, + pointerId, + }), + ); + await flushLayout(); + window.dispatchEvent( + new PointerEvent("pointerup", { + bubbles: true, + button: 0, + clientX: end.left, + clientY: end.top, + isPrimary: true, + pointerId, + }), + ); + await flushLayout(); + }); +} + +describe("TableControls without postponed structural controls", () => { + let fixture: TableFixture | null = null; + + beforeEach(() => { + class ResizeObserverMock implements ResizeObserver { + observe(): void {} + unobserve(): void {} + disconnect(): void {} + } + + vi.stubGlobal("ResizeObserver", ResizeObserverMock); + }); + + afterEach(async () => { + if (fixture) { + const currentFixture = fixture; + fixture = null; + await act(async () => currentFixture.root.unmount()); + currentFixture.editor.destroy(); + } + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + document.body.replaceChildren(); + document.documentElement.style.removeProperty("zoom"); + }); + + async function renderFixture(): Promise { + fixture = createTableFixture(); + await act(async () => { + fixture?.root.render(); + await flushLayout(); + }); + return fixture; + } + + it("does not render postponed controls before pointer interaction", async () => { + await renderFixture(); + + expect(document.querySelector(REMOVED_TABLE_CONTROL_SELECTOR)).toBeNull(); + }); + + it("never renders row grabs, column grabs, or add controls around a table", async () => { + const currentFixture = await renderFixture(); + + for (const point of [ + { left: 96, top: 190 }, + { left: 400, top: 96 }, + { left: 300, top: 236 }, + { left: 516, top: 160 }, + ]) { + await movePointer(currentFixture, point.left, point.top); + expect(document.querySelector(".notion-table-row-handle")).toBeNull(); + expect(document.querySelector(".notion-table-column-handle")).toBeNull(); + expect(document.querySelector(".notion-table-add-control")).toBeNull(); + expect(document.querySelector(".notion-table-edge-resize")).toBeNull(); + } + }); + + it("resizes one column live without moving its neighbors or mutating history early", async () => { + const currentFixture = await renderFixture(); + const initialDocument = currentFixture.editor.state.doc; + const historyBefore = undoDepth(currentFixture.editor.state); + const table = currentFixture.editor.view.dom.querySelector("table"); + if (!table) throw new Error("Missing live resize table"); + + await movePointer(currentFixture, 300, 130, currentFixture.cells[0][0]); + const handle = document.querySelector( + '.notion-table-column-resize[aria-label="Resize column 1"]', + ); + if (!handle) throw new Error("Missing column border resize handle"); + expect(handle.style.left).toBe("292px"); + + await act(async () => { + handle.dispatchEvent( + new PointerEvent("pointerdown", { + bubbles: true, + button: 0, + clientX: 300, + clientY: 130, + isPrimary: true, + pointerId: 91, + }), + ); + for (const clientX of [320, 340, 360]) { + window.dispatchEvent( + new PointerEvent("pointermove", { + bubbles: true, + button: 0, + clientX, + clientY: 130, + isPrimary: true, + pointerId: 91, + }), + ); + } + await flushLayout(); + }); + + const columns = Array.from(table.querySelectorAll("col")); + expect(columns.map((column) => column.style.width)).toEqual([ + "260px", + "200px", + ]); + expect(table.style.width).toBe("460px"); + expect(handle.style.left).toBe("352px"); + expect(currentFixture.editor.state.doc.eq(initialDocument)).toBe(true); + expect(undoDepth(currentFixture.editor.state)).toBe(historyBefore); + + const selectStart = new Event("selectstart", { + bubbles: true, + cancelable: true, + }); + const dragStart = new Event("dragstart", { + bubbles: true, + cancelable: true, + }); + window.dispatchEvent(selectStart); + window.dispatchEvent(dragStart); + expect(selectStart.defaultPrevented).toBe(true); + expect(dragStart.defaultPrevented).toBe(true); + + await act(async () => { + window.dispatchEvent( + new PointerEvent("pointerup", { + bubbles: true, + button: 0, + clientX: 360, + clientY: 130, + isPrimary: true, + pointerId: 91, + }), + ); + await flushLayout(); + }); + + const resizedTable = currentFixture.editor.state.doc.firstChild!; + expect( + Array.from({ length: resizedTable.childCount }, (_, rowIndex) => + Array.from( + { length: resizedTable.child(rowIndex).childCount }, + (_, columnIndex) => + resizedTable.child(rowIndex).child(columnIndex).attrs.colwidth?.[0], + ), + ), + ).toEqual([ + [260, 200], + [260, 200], + ]); + expect(undoDepth(currentFixture.editor.state)).toBe(historyBefore + 1); + expect(document.body.classList.contains("notion-table-column-resizing")) + .toBe(false); + expect(currentFixture.editor.commands.undo()).toBe(true); + expect(currentFixture.editor.state.doc.eq(initialDocument)).toBe(true); + expect(document.querySelector(REMOVED_TABLE_CONTROL_SELECTOR)).toBeNull(); + }); + + it("keeps rapid 120% column resizing pointer-aligned and clamps the minimum", async () => { + document.documentElement.style.zoom = "1.2"; + const currentFixture = createTableFixture(); + fixture = currentFixture; + const table = currentFixture.editor.view.dom.querySelector("table"); + if (!table) throw new Error("Missing zoomed resize table"); + table.getBoundingClientRect = () => createRect(120, 120, 600, 264); + currentFixture.rows[0].getBoundingClientRect = () => + createRect(120, 120, 600, 192); + currentFixture.rows[1].getBoundingClientRect = () => + createRect(120, 192, 600, 264); + currentFixture.cells[0][0].getBoundingClientRect = () => + createRect(120, 120, 360, 192); + currentFixture.cells[0][1].getBoundingClientRect = () => + createRect(360, 120, 600, 192); + currentFixture.cells[1][0].getBoundingClientRect = () => + createRect(120, 192, 360, 264); + currentFixture.cells[1][1].getBoundingClientRect = () => + createRect(360, 192, 600, 264); + + await act(async () => { + currentFixture.root.render( + , + ); + await flushLayout(); + }); + + await movePointer(currentFixture, 360, 150, currentFixture.cells[0][0]); + const handle = document.querySelector( + '.notion-table-column-resize[aria-label="Resize column 1"]', + ); + if (!handle) throw new Error("Missing zoomed column border resize handle"); + expect(handle.style.left).toBe("292px"); + + await act(async () => { + handle.dispatchEvent( + new PointerEvent("pointerdown", { + bubbles: true, + button: 0, + clientX: 360, + clientY: 150, + isPrimary: true, + pointerId: 92, + }), + ); + for (const clientX of [384, 96, 408]) { + window.dispatchEvent( + new PointerEvent("pointermove", { + bubbles: true, + button: 0, + clientX, + clientY: 150, + isPrimary: true, + pointerId: 92, + }), + ); + } + await flushLayout(); + }); + + expect( + Array.from(table.querySelectorAll("col")).map( + (column) => column.style.width, + ), + ).toEqual(["240px", "200px"]); + expect(handle.style.left).toBe("332px"); + expect(table.style.width).toBe("440px"); + + await act(async () => { + window.dispatchEvent( + new PointerEvent("pointermove", { + bubbles: true, + button: 0, + clientX: 96, + clientY: 150, + isPrimary: true, + pointerId: 92, + }), + ); + await flushLayout(); + }); + expect( + Array.from(table.querySelectorAll("col")).map( + (column) => column.style.width, + ), + ).toEqual(["80px", "200px"]); + + await act(async () => { + window.dispatchEvent( + new PointerEvent("pointercancel", { + bubbles: true, + pointerId: 92, + }), + ); + await flushLayout(); + }); + expect(currentFixture.editor.state.doc.firstChild?.child(0).child(0).attrs + .colwidth).toBeNull(); + expect(document.querySelector(REMOVED_TABLE_CONTROL_SELECTOR)).toBeNull(); + }); + + it("keeps the table block gutter free for global editor controls", async () => { + const currentFixture = await renderFixture(); + + await movePointer(currentFixture, 72, 72); + + expect(document.querySelector(".notion-table-options-handle")).toBeNull(); + expect(document.querySelector(REMOVED_TABLE_CONTROL_SELECTOR)).toBeNull(); + }); + + it("preserves TableKit Tab navigation between cells", async () => { + const currentFixture = await renderFixture(); + + await act(async () => { + currentFixture.editor.view.dom.dispatchEvent( + new KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + key: "Tab", + }), + ); + await flushLayout(); + }); + + expect(currentFixture.editor.state.selection.$from.parent.textContent).toBe( + "B1", + ); + expect(document.querySelector(REMOVED_TABLE_CONTROL_SELECTOR)).toBeNull(); + }); + + it("does not swallow the postponed row-action shortcut while grips are absent", async () => { + const currentFixture = await renderFixture(); + const shortcut = new KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + key: "†", + code: "KeyT", + altKey: true, + shiftKey: true, + }); + + await act(async () => { + currentFixture.editor.view.dom.dispatchEvent(shortcut); + await flushLayout(); + }); + + expect(shortcut.defaultPrevented).toBe(false); + expect(document.querySelector(REMOVED_TABLE_CONTROL_SELECTOR)).toBeNull(); + }); + + it("preserves existing row-height resizing without exposing reorder grabs", async () => { + const currentFixture = await renderFixture(); + const initialDocument = currentFixture.editor.state.doc; + const historyBefore = undoDepth(currentFixture.editor.state); + + await movePointer(currentFixture, 200, 160); + const resizeHandle = document.querySelector( + '.notion-table-row-resize[aria-label="Resize row 1"]', + ); + if (!resizeHandle) throw new Error("Missing existing row resize handle"); + + await dragRowResizeHandle( + resizeHandle, + { left: 200, top: 160 }, + { left: 200, top: 180 }, + 51, + ); + + expect(currentFixture.editor.state.doc.firstChild?.child(0).attrs.rowHeight) + .toBe(80); + expect(undoDepth(currentFixture.editor.state)).toBe(historyBefore + 1); + expect(document.querySelector(REMOVED_TABLE_CONTROL_SELECTOR)).toBeNull(); + expect(currentFixture.editor.commands.undo()).toBe(true); + expect(currentFixture.editor.state.doc.eq(initialDocument)).toBe(true); + }); + + it("keeps rapid row resizing continuous, pointer-aligned, and selection-free", async () => { + const currentFixture = await renderFixture(); + const initialDocument = currentFixture.editor.state.doc; + const historyBefore = undoDepth(currentFixture.editor.state); + + await movePointer(currentFixture, 200, 160); + const resizeHandle = document.querySelector( + '.notion-table-row-resize[aria-label="Resize row 1"]', + ); + if (!resizeHandle) throw new Error("Missing rapid row resize handle"); + expect(resizeHandle.style.top).toBe("152px"); + + await act(async () => { + resizeHandle.dispatchEvent( + new PointerEvent("pointerdown", { + bubbles: true, + button: 0, + clientX: 200, + clientY: 160, + isPrimary: true, + pointerId: 52, + }), + ); + for (const clientY of [170, 200, 180]) { + window.dispatchEvent( + new PointerEvent("pointermove", { + bubbles: true, + button: 0, + clientX: 200, + clientY, + isPrimary: true, + pointerId: 52, + }), + ); + } + await flushLayout(); + }); + + expect(currentFixture.editor.state.doc.firstChild?.child(0).attrs.rowHeight) + .toBeNull(); + expect(currentFixture.editor.state.doc.firstChild?.child(1).attrs.rowHeight) + .toBeNull(); + const previewStyle = document.querySelector( + "[data-scratch-table-row-resize-preview]", + ); + const previewRule = previewStyle?.sheet?.cssRules[0]; + expect(previewRule).toBeInstanceOf(CSSStyleRule); + expect((previewRule as CSSStyleRule).style.height).toBe("80px"); + expect(document.querySelector((previewRule as CSSStyleRule).selectorText)) + .toBe(currentFixture.rows[0]); + expect(currentFixture.rows[0].style.height).toBe(""); + expect(currentFixture.rows[1].style.height).toBe(""); + expect(resizeHandle.style.top).toBe("172px"); + expect(undoDepth(currentFixture.editor.state)).toBe(historyBefore); + const selectStart = new Event("selectstart", { + bubbles: true, + cancelable: true, + }); + const dragStart = new Event("dragstart", { + bubbles: true, + cancelable: true, + }); + window.dispatchEvent(selectStart); + window.dispatchEvent(dragStart); + expect(selectStart.defaultPrevented).toBe(true); + expect(dragStart.defaultPrevented).toBe(true); + + await act(async () => { + window.dispatchEvent( + new PointerEvent("pointerup", { + bubbles: true, + button: 0, + clientX: 200, + clientY: 180, + isPrimary: true, + pointerId: 52, + }), + ); + await flushLayout(); + }); + + expect(undoDepth(currentFixture.editor.state)).toBe(historyBefore + 1); + expect(currentFixture.editor.state.doc.firstChild?.child(0).attrs.rowHeight) + .toBe(80); + expect(currentFixture.editor.commands.undo()).toBe(true); + expect(currentFixture.editor.state.doc.eq(initialDocument)).toBe(true); + }); + + it("keeps rapid row resizing pointer-aligned at 120% interface zoom", async () => { + document.documentElement.style.zoom = "1.2"; + const currentFixture = createTableFixture(); + fixture = currentFixture; + const table = currentFixture.editor.view.dom.querySelector("table"); + if (!table) throw new Error("Missing zoomed row resize table"); + table.getBoundingClientRect = () => createRect(120, 120, 600, 264); + currentFixture.rows[0].getBoundingClientRect = () => + createRect(120, 120, 600, 192); + currentFixture.rows[1].getBoundingClientRect = () => + createRect(120, 192, 600, 264); + currentFixture.cells[0][0].getBoundingClientRect = () => + createRect(120, 120, 360, 192); + currentFixture.cells[0][1].getBoundingClientRect = () => + createRect(360, 120, 600, 192); + currentFixture.cells[1][0].getBoundingClientRect = () => + createRect(120, 192, 360, 264); + currentFixture.cells[1][1].getBoundingClientRect = () => + createRect(360, 192, 600, 264); + const initialDocument = currentFixture.editor.state.doc; + const historyBefore = undoDepth(currentFixture.editor.state); + + await act(async () => { + currentFixture.root.render( + , + ); + await flushLayout(); + }); + await movePointer(currentFixture, 240, 192); + const resizeHandle = document.querySelector( + '.notion-table-row-resize[aria-label="Resize row 1"]', + ); + if (!resizeHandle) throw new Error("Missing zoomed row resize handle"); + expect(resizeHandle.style.top).toBe("152px"); + + await act(async () => { + resizeHandle.dispatchEvent( + new PointerEvent("pointerdown", { + bubbles: true, + button: 0, + clientX: 240, + clientY: 192, + isPrimary: true, + pointerId: 53, + }), + ); + for (const clientY of [204, 240, 216]) { + window.dispatchEvent( + new PointerEvent("pointermove", { + bubbles: true, + button: 0, + clientX: 240, + clientY, + isPrimary: true, + pointerId: 53, + }), + ); + } + await flushLayout(); + }); + + const previewStyle = document.querySelector( + "[data-scratch-table-row-resize-preview]", + ); + const previewRule = previewStyle?.sheet?.cssRules[0]; + expect((previewRule as CSSStyleRule).style.height).toBe("80px"); + expect(resizeHandle.style.top).toBe("172px"); + expect(undoDepth(currentFixture.editor.state)).toBe(historyBefore); + + await act(async () => { + window.dispatchEvent( + new PointerEvent("pointerup", { + bubbles: true, + button: 0, + clientX: 240, + clientY: 216, + isPrimary: true, + pointerId: 53, + }), + ); + await flushLayout(); + }); + + expect(currentFixture.editor.state.doc.firstChild?.child(0).attrs.rowHeight) + .toBe(80); + expect(undoDepth(currentFixture.editor.state)).toBe(historyBefore + 1); + expect(currentFixture.editor.commands.undo()).toBe(true); + expect(currentFixture.editor.state.doc.eq(initialDocument)).toBe(true); + }); + + it("keeps removed controls absent at 120% interface zoom", async () => { + document.documentElement.style.zoom = "1.2"; + const currentFixture = await renderFixture(); + const table = currentFixture.editor.view.dom.querySelector("table"); + if (!table) throw new Error("Missing zoomed table"); + table.getBoundingClientRect = () => createRect(120, 120, 600, 264); + currentFixture.rows[0].getBoundingClientRect = () => + createRect(120, 120, 600, 192); + currentFixture.rows[1].getBoundingClientRect = () => + createRect(120, 192, 600, 264); + currentFixture.cells[0][0].getBoundingClientRect = () => + createRect(120, 120, 360, 192); + currentFixture.cells[0][1].getBoundingClientRect = () => + createRect(360, 120, 600, 192); + currentFixture.cells[1][0].getBoundingClientRect = () => + createRect(120, 192, 360, 264); + currentFixture.cells[1][1].getBoundingClientRect = () => + createRect(360, 192, 600, 264); + + for (const point of [ + { left: 115.2, top: 228 }, + { left: 480, top: 115.2 }, + { left: 360, top: 283.2 }, + { left: 619.2, top: 192 }, + ]) { + await movePointer(currentFixture, point.left, point.top); + expect(document.querySelector(REMOVED_TABLE_CONTROL_SELECTOR)).toBeNull(); + } + }); + + it("keeps absent controls outside the keyboard focus order", async () => { + await renderFixture(); + + expect(document.querySelectorAll(REMOVED_TABLE_CONTROL_SELECTOR)).toHaveLength( + 0, + ); + }); +}); diff --git a/src/components/editor/notion/TableControls.tsx b/src/components/editor/notion/TableControls.tsx new file mode 100644 index 00000000..81ff1940 --- /dev/null +++ b/src/components/editor/notion/TableControls.tsx @@ -0,0 +1,1924 @@ +import type { Editor } from "@tiptap/react"; +import { TextSelection } from "@tiptap/pm/state"; +import { CellSelection } from "@tiptap/pm/tables"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { + ArrowDownToLineIcon, + ArrowLeftToLineIcon, + ArrowRightToLineIcon, + ArrowUpToLineIcon, +} from "../../icons"; +import { + clearTableColumn, + clearTableRow, + deleteTableColumn, + deleteTableRow, + duplicateTableColumn, + duplicateTableRow, + fitTableColumnsToWidth, + hasPinnedTableHeaderColumn, + hasPinnedTableHeaderRow, + insertTableColumn, + insertTableRow, + moveTableColumn, + moveTableRow, + setTableColumnBackgroundColor, + setTableColumnWidths, + setTableRowBackgroundColor, + setTableRowHeight, + resizeTableAtEnd, + toggleTableHeaderColumn, + toggleTableHeaderRow, +} from "./tableTransactions"; +import { + MIN_TABLE_COLUMN_WIDTH, + TABLE_BACKGROUND_COLOR_OPTIONS, +} from "./tableMetadata"; +import { createScratchTableColumnResizePreview } from "./tableView"; +import { + hasExceededTablePointerDragThreshold, + resolveTablePointerDrop, + type TableDragAxis, + type TablePointerPoint, + type TablePointerDrop, +} from "./tablePointerDrag"; +import { + resolveTableProximityTarget, + type TableProximityTarget, +} from "./tableProximity"; +import { + createTableRowResizePreview, + getTableRowResizeHeight, +} from "./tableRowResize"; +import { + getInterfaceZoom, + viewportValueToInterface, +} from "./interfaceGeometry"; +import { + resolveTableEdgeDragDelta, + type TableEdgeDragAxis, +} from "./tableEdgeDrag"; +import { resolveTableAutoScrollDelta } from "./tableAutoScroll"; + +interface TableSelectionContext { + tablePos: number; + rowIndex: number; + columnIndex: number; +} + +interface TableLayout extends TableSelectionContext { + tableRect: DOMRect; + rowRects: DOMRect[]; + columnRects: DOMRect[]; + rowElements: HTMLTableRowElement[]; +} + +interface TableDragIndicator { + axis: TableDragAxis; + left: number; + top: number; + width: number; + height: number; +} + +const TABLE_BACKGROUND_COLOR_NAMES = [ + "Gray", + "Red", + "Orange", + "Yellow", + "Green", + "Blue", + "Purple", + "Pink", +] as const; + +// Row/column reorder grips and edge add controls are intentionally withheld +// from the product UI until their pointer interactions are stable enough to +// ship without conflicting with text editing or native table resizing. +const TABLE_STRUCTURE_CONTROLS_VISIBLE = false; + +function sameProximityTarget( + current: TableProximityTarget | null, + next: TableProximityTarget | null, +): boolean { + if (current === next) return true; + if (!current || !next || current.kind !== next.kind) return false; + return ( + !("index" in current) || + !("index" in next) || + current.index === next.index + ); +} + +function getTableSelectionContext(editor: Editor): TableSelectionContext | null { + const { $anchor } = editor.state.selection; + let tableDepth = -1; + let rowDepth = -1; + + for (let depth = $anchor.depth; depth > 0; depth -= 1) { + const name = $anchor.node(depth).type.name; + if (rowDepth < 0 && name === "tableRow") rowDepth = depth; + if (name === "table") { + tableDepth = depth; + break; + } + } + + if (tableDepth < 0 || rowDepth < 0) return null; + return { + tablePos: $anchor.before(tableDepth), + rowIndex: $anchor.index(tableDepth), + columnIndex: $anchor.index(rowDepth), + }; +} + +function findTableElement(editor: Editor, tablePos: number): HTMLTableElement | null { + const nodeDom = editor.view.nodeDOM(tablePos); + if (nodeDom instanceof HTMLTableElement) return nodeDom; + if (nodeDom instanceof HTMLElement) return nodeDom.querySelector("table"); + return null; +} + +function getTableCellPosition( + editor: Editor, + tablePos: number, + rowIndex: number, + columnIndex: number, +): number | null { + const table = editor.state.doc.nodeAt(tablePos); + if ( + !table || + table.type.name !== "table" || + rowIndex < 0 || + rowIndex >= table.childCount + ) { + return null; + } + + let rowPos = tablePos + 1; + for (let index = 0; index < rowIndex; index += 1) { + rowPos += table.child(index).nodeSize; + } + + const row = table.child(rowIndex); + if (columnIndex < 0 || columnIndex >= row.childCount) return null; + + let cellPos = rowPos + 1; + for (let index = 0; index < columnIndex; index += 1) { + cellPos += row.child(index).nodeSize; + } + return cellPos; +} + +function selectTableAxis( + editor: Editor, + layout: TableLayout, + axis: TableDragAxis, + index: number, +): boolean { + const table = editor.state.doc.nodeAt(layout.tablePos); + if (!table || table.type.name !== "table" || table.childCount === 0) { + return false; + } + + const anchorPos = + axis === "row" + ? getTableCellPosition(editor, layout.tablePos, index, 0) + : getTableCellPosition(editor, layout.tablePos, 0, index); + const headPos = + axis === "row" + ? getTableCellPosition( + editor, + layout.tablePos, + index, + table.child(index)?.childCount - 1, + ) + : getTableCellPosition( + editor, + layout.tablePos, + table.childCount - 1, + index, + ); + if (anchorPos === null || headPos === null) return false; + + const $anchorCell = editor.state.doc.resolve(anchorPos); + const $headCell = editor.state.doc.resolve(headPos); + const selection = + axis === "row" + ? CellSelection.rowSelection($anchorCell, $headCell) + : CellSelection.colSelection($anchorCell, $headCell); + editor.view.dispatch(editor.state.tr.setSelection(selection).scrollIntoView()); + return true; +} + +function applyTableEdgeDelta( + editor: Editor, + layout: TableLayout, + axis: TableEdgeDragAxis, + delta: number, +): boolean { + return resizeTableAtEnd(editor, layout.tablePos, axis, delta); +} + +function autoScrollTableDrag( + editor: Editor, + axis: TableDragAxis, + pointer: TablePointerPoint, +): boolean { + const scrollElement = editor.view.dom.closest( + "[data-editor-scroll]", + ); + if (!scrollElement) return false; + + const delta = resolveTableAutoScrollDelta({ + axis, + pointer, + viewport: scrollElement.getBoundingClientRect(), + }); + if (delta === 0) return false; + + const previousPosition = + axis === "column" ? scrollElement.scrollLeft : scrollElement.scrollTop; + scrollElement.scrollBy({ + left: axis === "column" ? delta : 0, + top: axis === "row" ? delta : 0, + behavior: "auto", + }); + const currentPosition = + axis === "column" ? scrollElement.scrollLeft : scrollElement.scrollTop; + return currentPosition !== previousPosition; +} + +function getAvailableTableWidth(editor: Editor, layout: TableLayout): number { + const tableElement = layout.rowElements[0]?.closest("table"); + const wrapperWidth = tableElement + ?.closest(".tableWrapper") + ?.getBoundingClientRect().width; + if (wrapperWidth && wrapperWidth > 0) return wrapperWidth; + + const editorWidth = editor.view.dom.getBoundingClientRect().width; + return editorWidth > 0 ? editorWidth : layout.tableRect.width; +} + +function measureTable(editor: Editor): TableLayout | null { + const context = getTableSelectionContext(editor); + if (!context) return null; + const tableElement = findTableElement(editor, context.tablePos); + return tableElement ? measureTableElement(editor, tableElement, context) : null; +} + +function tablePositionFromDom( + editor: Editor, + tableElement: HTMLTableElement, +): number | null { + const domCandidates: Node[] = [tableElement]; + if (tableElement.parentNode) domCandidates.push(tableElement.parentNode); + + for (const dom of domCandidates) { + let position: number; + try { + position = editor.view.posAtDOM(dom, 0); + } catch { + continue; + } + const boundedPosition = Math.max( + 0, + Math.min(position, editor.state.doc.content.size), + ); + const directNode = editor.state.doc.nodeAt(boundedPosition); + if (directNode?.type.name === "table") return boundedPosition; + + const $position = editor.state.doc.resolve(boundedPosition); + for (let depth = $position.depth; depth > 0; depth -= 1) { + if ($position.node(depth).type.name === "table") { + return $position.before(depth); + } + } + if ($position.nodeAfter?.type.name === "table") return boundedPosition; + if ($position.nodeBefore?.type.name === "table") { + return boundedPosition - $position.nodeBefore.nodeSize; + } + } + + return null; +} + +function measureTableElement( + editor: Editor, + tableElement: HTMLTableElement, + selectedContext = getTableSelectionContext(editor), +): TableLayout | null { + const tablePos = tablePositionFromDom(editor, tableElement); + if (tablePos === null) return null; + + const rowElements = Array.from(tableElement.rows); + const firstRow = rowElements[0]; + if (!firstRow) return null; + + const context = + selectedContext?.tablePos === tablePos + ? selectedContext + : { tablePos, rowIndex: 0, columnIndex: 0 }; + + return { + ...context, + tableRect: tableElement.getBoundingClientRect(), + rowRects: rowElements.map((row) => row.getBoundingClientRect()), + columnRects: Array.from(firstRow.cells).map((cell) => + cell.getBoundingClientRect(), + ), + rowElements, + }; +} + +function distanceToRect(rect: DOMRect, point: TablePointerPoint): number { + const horizontal = + point.left < rect.left + ? rect.left - point.left + : point.left > rect.right + ? point.left - rect.right + : 0; + const vertical = + point.top < rect.top + ? rect.top - point.top + : point.top > rect.bottom + ? point.top - rect.bottom + : 0; + return horizontal * horizontal + vertical * vertical; +} + +function findTableAtPointer( + editor: Editor, + point: TablePointerPoint, + currentLayout: TableLayout | null, +): { layout: TableLayout; target: TableProximityTarget } | null { + const selectedContext = getTableSelectionContext(editor); + if (currentLayout) { + const currentElement = findTableElement(editor, currentLayout.tablePos); + const refreshedLayout = currentElement + ? measureTableElement(editor, currentElement, selectedContext) + : null; + const currentTarget = refreshedLayout + ? resolveTableProximityTarget(refreshedLayout, point) + : null; + if (refreshedLayout && currentTarget) { + return { layout: refreshedLayout, target: currentTarget }; + } + } + + let best: + | { layout: TableLayout; target: TableProximityTarget; distance: number } + | null = null; + + for (const tableElement of Array.from( + editor.view.dom.querySelectorAll("table"), + )) { + const candidateLayout = measureTableElement( + editor, + tableElement, + selectedContext, + ); + if (!candidateLayout || candidateLayout.tablePos === currentLayout?.tablePos) { + continue; + } + const target = resolveTableProximityTarget(candidateLayout, point); + if (!target) continue; + const distance = distanceToRect(candidateLayout.tableRect, point); + if (!best || distance < best.distance) { + best = { layout: candidateLayout, target, distance }; + } + } + + return best ? { layout: best.layout, target: best.target } : null; +} + +function ActionButton({ + label, + disabled = false, + hidden = false, + pressed, + onClick, + children, +}: { + label: string; + disabled?: boolean; + hidden?: boolean; + pressed?: boolean; + onClick: () => void; + children: React.ReactNode; +}) { + if (hidden) return null; + + return ( + + ); +} + +export function TableControls({ editor }: { editor: Editor }) { + const [layout, setLayout] = useState(null); + const [proximityTarget, setProximityTarget] = + useState(null); + const [actionsOpen, setActionsOpen] = useState(false); + const [actionQuery, setActionQuery] = useState(""); + const [colorTarget, setColorTarget] = useState<"row" | "column" | null>( + null, + ); + const [dragIndicator, setDragIndicator] = + useState(null); + const [announcement, setAnnouncement] = useState(""); + const resizeCleanupRef = useRef<(() => void) | null>(null); + const dragCleanupRef = useRef<(() => void) | null>(null); + const layoutRef = useRef(null); + const proximityTargetRef = useRef(null); + const activeInteractionRef = useRef(null); + const lastPointerRef = useRef<{ left: number; top: number } | null>(null); + const hideTimerRef = useRef(null); + const pointerFrameRef = useRef(null); + const keyboardFocusRowRef = useRef(null); + const actionTokens = actionQuery.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean); + const actionMatches = (...labels: string[]) => + actionTokens.length === 0 || + actionTokens.every((token) => + labels.some((label) => label.toLocaleLowerCase().includes(token)), + ); + + useEffect(() => { + if (actionsOpen) return; + setActionQuery(""); + setColorTarget(null); + }, [actionsOpen]); + + const showProximityTarget = useCallback( + (nextTarget: TableProximityTarget | null) => { + if (sameProximityTarget(proximityTargetRef.current, nextTarget)) return; + proximityTargetRef.current = nextTarget; + setProximityTarget(nextTarget); + }, + [], + ); + + const clearHideTimer = useCallback(() => { + if (hideTimerRef.current === null) return; + window.clearTimeout(hideTimerRef.current); + hideTimerRef.current = null; + }, []); + + const resolveLastPointer = useCallback(() => { + if (activeInteractionRef.current) { + showProximityTarget(activeInteractionRef.current); + return; + } + const currentLayout = layoutRef.current; + const point = lastPointerRef.current; + showProximityTarget( + currentLayout && point + ? resolveTableProximityTarget(currentLayout, point) + : null, + ); + }, [showProximityTarget]); + + const update = useCallback(() => { + if (editor.isDestroyed) return null; + // Pointer-resize sessions own their geometry until commit/cancel. An + // observer frame queued just before pointerdown must not replace the + // portal layout with transient measurements while the table is previewed. + if (activeInteractionRef.current?.kind === "rowResize" || + activeInteractionRef.current?.kind === "columnResize") { + return layoutRef.current; + } + const selectedLayout = measureTable(editor); + const pointerCandidate = lastPointerRef.current + ? findTableAtPointer(editor, lastPointerRef.current, selectedLayout) + : null; + const nextLayout = pointerCandidate?.layout ?? selectedLayout; + layoutRef.current = nextLayout; + setLayout(nextLayout); + if (!nextLayout) { + setActionsOpen(false); + showProximityTarget(null); + } else if ( + actionsOpen && + !(editor.state.selection instanceof CellSelection) + ) { + setActionsOpen(false); + } else if (activeInteractionRef.current) { + showProximityTarget(activeInteractionRef.current); + } else { + showProximityTarget(pointerCandidate?.target ?? null); + } + return nextLayout; + }, [actionsOpen, editor, showProximityTarget]); + + useEffect(() => { + const evaluatePointer = () => { + pointerFrameRef.current = null; + if (activeInteractionRef.current || !lastPointerRef.current) return; + const pointerCandidate = findTableAtPointer( + editor, + lastPointerRef.current, + layoutRef.current, + ); + clearHideTimer(); + if (pointerCandidate) { + if (pointerCandidate.layout.tablePos !== layoutRef.current?.tablePos) { + layoutRef.current = pointerCandidate.layout; + setLayout(pointerCandidate.layout); + setActionsOpen(false); + } + showProximityTarget(pointerCandidate.target); + return; + } + + // Small dismissal tolerance avoids flicker at adjacent zones without + // delaying the appearance of the next valid control. + hideTimerRef.current = window.setTimeout(() => { + hideTimerRef.current = null; + if (!activeInteractionRef.current) showProximityTarget(null); + }, 50); + }; + const onPointerMove = (event: PointerEvent) => { + lastPointerRef.current = { left: event.clientX, top: event.clientY }; + if ( + activeInteractionRef.current || + pointerFrameRef.current !== null + ) { + return; + } + pointerFrameRef.current = requestAnimationFrame(evaluatePointer); + }; + const onWindowBlur = () => { + clearHideTimer(); + if (pointerFrameRef.current !== null) { + cancelAnimationFrame(pointerFrameRef.current); + pointerFrameRef.current = null; + } + lastPointerRef.current = null; + if (!activeInteractionRef.current) showProximityTarget(null); + }; + + window.addEventListener("pointermove", onPointerMove, { passive: true }); + window.addEventListener("blur", onWindowBlur); + return () => { + clearHideTimer(); + if (pointerFrameRef.current !== null) { + cancelAnimationFrame(pointerFrameRef.current); + pointerFrameRef.current = null; + } + window.removeEventListener("pointermove", onPointerMove); + window.removeEventListener("blur", onWindowBlur); + }; + }, [clearHideTimer, editor, showProximityTarget]); + + useEffect(() => { + const revealSelectedRowActions = (event: KeyboardEvent) => { + if (!TABLE_STRUCTURE_CONTROLS_VISIBLE) return; + const isTableShortcut = + event.code === "KeyT" || + (!event.code && event.key.toLocaleLowerCase() === "t"); + if ( + !isTableShortcut || + !event.altKey || + !event.shiftKey || + event.metaKey || + event.ctrlKey || + activeInteractionRef.current + ) { + return; + } + const selectedLayout = measureTable(editor); + if (!selectedLayout) return; + + event.preventDefault(); + clearHideTimer(); + layoutRef.current = selectedLayout; + setLayout(selectedLayout); + keyboardFocusRowRef.current = selectedLayout.rowIndex; + showProximityTarget({ kind: "row", index: selectedLayout.rowIndex }); + }; + + editor.view.dom.addEventListener( + "keydown", + revealSelectedRowActions, + true, + ); + return () => + editor.view.dom.removeEventListener( + "keydown", + revealSelectedRowActions, + true, + ); + }, [clearHideTimer, editor, showProximityTarget]); + + useEffect(() => { + const rowIndex = keyboardFocusRowRef.current; + if ( + rowIndex === null || + proximityTarget?.kind !== "row" || + proximityTarget.index !== rowIndex + ) { + return; + } + const handle = document.querySelector( + `.notion-table-row-handle[aria-label="Drag row ${rowIndex + 1}"]`, + ); + if (!handle) return; + keyboardFocusRowRef.current = null; + handle.focus(); + }, [layout, proximityTarget]); + + useEffect(() => { + let frame = 0; + let observedTable: HTMLTableElement | null = null; + let observedShape = ""; + let horizontalScrollElement: HTMLElement | null = null; + const scheduleUpdate = () => { + cancelAnimationFrame(frame); + frame = requestAnimationFrame(measureAndObserve); + }; + const resizeObserver = new ResizeObserver(() => { + if (!resizeCleanupRef.current) scheduleUpdate(); + }); + function measureAndObserve() { + const nextLayout = update(); + const nextTable = + nextLayout?.rowElements[0]?.closest("table") ?? null; + const nextShape = nextTable + ? `${nextTable.rows.length}:${nextTable.rows[0]?.cells.length ?? 0}` + : ""; + if (nextTable === observedTable && nextShape === observedShape) return; + + resizeObserver.disconnect(); + horizontalScrollElement?.removeEventListener("scroll", scheduleUpdate); + observedTable = nextTable; + observedShape = nextShape; + horizontalScrollElement = null; + if (!nextTable || !(nextTable instanceof HTMLTableElement)) return; + + horizontalScrollElement = nextTable.closest(".tableWrapper"); + horizontalScrollElement?.addEventListener("scroll", scheduleUpdate, { + passive: true, + }); + + const positioningContainers = new Set([ + editor.view.dom, + nextTable, + ]); + if (horizontalScrollElement) positioningContainers.add(horizontalScrollElement); + if (nextTable.offsetParent instanceof Element) { + positioningContainers.add(nextTable.offsetParent); + } + positioningContainers.forEach((element) => resizeObserver.observe(element)); + resizeObserver.observe(nextTable); + nextLayout?.rowElements.forEach((row) => resizeObserver.observe(row)); + Array.from(nextTable.rows[0]?.cells ?? []).forEach((cell) => + resizeObserver.observe(cell), + ); + } + const scrollElement = editor.view.dom.closest("[data-editor-scroll]"); + const mutationObserver = new MutationObserver((records) => { + const tableStructureChanged = records.some((record) => + [...record.addedNodes, ...record.removedNodes].some( + (node) => + node instanceof Element && + (node.matches("table, tr, td, th") || + Boolean(node.querySelector("table, tr, td, th"))), + ), + ); + if (tableStructureChanged) scheduleUpdate(); + }); + + editor.on("selectionUpdate", scheduleUpdate); + window.addEventListener("resize", scheduleUpdate); + scrollElement?.addEventListener("scroll", scheduleUpdate, { passive: true }); + mutationObserver.observe(editor.view.dom, { childList: true, subtree: true }); + scheduleUpdate(); + + return () => { + cancelAnimationFrame(frame); + editor.off("selectionUpdate", scheduleUpdate); + window.removeEventListener("resize", scheduleUpdate); + scrollElement?.removeEventListener("scroll", scheduleUpdate); + mutationObserver.disconnect(); + resizeObserver.disconnect(); + horizontalScrollElement?.removeEventListener("scroll", scheduleUpdate); + }; + }, [editor, update]); + + useEffect( + () => () => { + resizeCleanupRef.current?.(); + dragCleanupRef.current?.(); + }, + [], + ); + + useEffect(() => { + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + const { selection } = editor.state; + if (!actionsOpen && !(selection instanceof CellSelection)) return; + + setActionsOpen(false); + if (!(selection instanceof CellSelection)) return; + + const cellContentStart = selection.$anchorCell.pos + 1; + editor.view.dispatch( + editor.state.tr + .setSelection( + TextSelection.near( + editor.state.doc.resolve(cellContentStart), + 1, + ), + ) + .scrollIntoView(), + ); + }; + window.addEventListener("keydown", closeOnEscape, true); + return () => window.removeEventListener("keydown", closeOnEscape, true); + }, [actionsOpen, editor]); + + const resizeRow = ( + event: React.PointerEvent, + rowIndex: number, + ) => { + if ( + !layout || + event.button !== 0 || + !event.isPrimary || + resizeCleanupRef.current + ) { + return; + } + + const rowElement = layout.rowElements[rowIndex]; + const table = editor.state.doc.nodeAt(layout.tablePos); + if (!rowElement || !table || rowIndex >= table.childCount) return; + + const zoom = getInterfaceZoom(); + const startY = event.clientY / zoom; + const startHeight = (layout.rowRects[rowIndex]?.height ?? 28) / zoom; + const preview = createTableRowResizePreview(rowElement); + + event.preventDefault(); + event.stopPropagation(); + clearHideTimer(); + activeInteractionRef.current = { kind: "rowResize", index: rowIndex }; + showProximityTarget(activeInteractionRef.current); + const origin = event.currentTarget; + const pointerId = event.pointerId; + const ownerDocument = origin.ownerDocument; + const initialHandleTop = origin.style.top; + let previewFrame = 0; + let latestClientY = startY; + + const applyPreview = (clientY: number) => { + const height = preview.apply(getTableRowResizeHeight( + startHeight, + startY, + clientY, + )); + origin.style.top = `${ + layout.rowRects[rowIndex].bottom / zoom + height - startHeight - 8 + }px`; + }; + const cancelPreviewFrame = () => { + if (!previewFrame) return; + cancelAnimationFrame(previewFrame); + previewFrame = 0; + }; + const schedulePreview = () => { + if (previewFrame) return; + previewFrame = requestAnimationFrame(() => { + previewFrame = 0; + applyPreview(latestClientY); + }); + }; + const preventSelectionOrDrag = (interactionEvent: Event) => { + interactionEvent.preventDefault(); + }; + + const onMove = (moveEvent: PointerEvent) => { + if (moveEvent.pointerId !== pointerId) return; + moveEvent.preventDefault(); + latestClientY = moveEvent.clientY / zoom; + schedulePreview(); + }; + const finishSession = () => { + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", onUp); + window.removeEventListener("pointercancel", onCancel); + window.removeEventListener("blur", onCancel); + window.removeEventListener("keydown", onKeyDown, true); + window.removeEventListener("selectstart", preventSelectionOrDrag, true); + window.removeEventListener("dragstart", preventSelectionOrDrag, true); + cancelPreviewFrame(); + preview.restore(); + editor.view.dom.removeAttribute("data-table-row-resizing"); + ownerDocument.body.classList.remove("notion-table-row-resizing"); + origin.removeAttribute("data-resizing"); + origin.style.top = initialHandleTop; + if (origin.hasPointerCapture?.(pointerId)) { + origin.releasePointerCapture(pointerId); + } + resizeCleanupRef.current = null; + activeInteractionRef.current = null; + resolveLastPointer(); + }; + const onUp = (upEvent: PointerEvent) => { + if (upEvent.pointerId !== pointerId) return; + upEvent.preventDefault(); + upEvent.stopPropagation(); + latestClientY = upEvent.clientY / zoom; + cancelPreviewFrame(); + const height = getTableRowResizeHeight( + startHeight, + startY, + latestClientY, + ); + finishSession(); + setTableRowHeight(editor, layout.tablePos, rowIndex, height); + requestAnimationFrame(update); + }; + const onCancel = (cancelEvent?: Event) => { + if ( + cancelEvent instanceof PointerEvent && + cancelEvent.pointerId !== pointerId + ) { + return; + } + finishSession(); + requestAnimationFrame(update); + }; + const onKeyDown = (keyEvent: KeyboardEvent) => { + if (keyEvent.key !== "Escape") return; + keyEvent.preventDefault(); + keyEvent.stopPropagation(); + onCancel(); + }; + + editor.view.dom.setAttribute("data-table-row-resizing", "true"); + ownerDocument.body.classList.add("notion-table-row-resizing"); + origin.setAttribute("data-resizing", "true"); + window.addEventListener("pointermove", onMove, { passive: false }); + window.addEventListener("pointerup", onUp); + window.addEventListener("pointercancel", onCancel); + window.addEventListener("blur", onCancel); + window.addEventListener("keydown", onKeyDown, true); + window.addEventListener("selectstart", preventSelectionOrDrag, true); + window.addEventListener("dragstart", preventSelectionOrDrag, true); + resizeCleanupRef.current = () => onCancel(); + try { + origin.setPointerCapture(pointerId); + } catch { + // Window listeners still complete the resize if capture is unavailable. + } + }; + + const resizeColumn = ( + event: React.PointerEvent, + columnIndex: number, + ) => { + if ( + !layout || + event.button !== 0 || + !event.isPrimary || + resizeCleanupRef.current + ) { + return; + } + + const tableElement = layout.rowElements[0]?.closest("table"); + if (!tableElement || columnIndex >= layout.columnRects.length) return; + const zoom = getInterfaceZoom(); + const baselineWidths = layout.columnRects.map((rect) => rect.width / zoom); + const preview = createScratchTableColumnResizePreview( + tableElement, + baselineWidths, + columnIndex, + MIN_TABLE_COLUMN_WIDTH, + ); + if (!preview) return; + + event.preventDefault(); + event.stopPropagation(); + clearHideTimer(); + activeInteractionRef.current = { kind: "columnResize", index: columnIndex }; + showProximityTarget(activeInteractionRef.current); + const origin = event.currentTarget; + const pointerId = event.pointerId; + const ownerDocument = origin.ownerDocument; + const startX = event.clientX / zoom; + const startWidth = baselineWidths[columnIndex]; + const initialHandleLeft = origin.style.left; + const initialBoundary = layout.columnRects[columnIndex].right / zoom; + let latestX = startX; + let previewFrame = 0; + + const applyPreview = (clientX: number): number => { + const width = preview.apply(startWidth + clientX - startX); + origin.style.left = `${initialBoundary + width - startWidth - 8}px`; + return width; + }; + applyPreview(startX); + + const cancelPreviewFrame = () => { + if (!previewFrame) return; + cancelAnimationFrame(previewFrame); + previewFrame = 0; + }; + const schedulePreview = () => { + if (previewFrame) return; + previewFrame = requestAnimationFrame(() => { + previewFrame = 0; + applyPreview(latestX); + }); + }; + const preventSelectionOrDrag = (interactionEvent: Event) => { + interactionEvent.preventDefault(); + }; + const onMove = (moveEvent: PointerEvent) => { + if (moveEvent.pointerId !== pointerId) return; + moveEvent.preventDefault(); + latestX = moveEvent.clientX / zoom; + schedulePreview(); + }; + const finishSession = () => { + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", onUp); + window.removeEventListener("pointercancel", onCancel); + window.removeEventListener("blur", onCancel); + window.removeEventListener("keydown", onKeyDown, true); + window.removeEventListener("selectstart", preventSelectionOrDrag, true); + window.removeEventListener("dragstart", preventSelectionOrDrag, true); + cancelPreviewFrame(); + preview.restore(); + editor.view.dom.removeAttribute("data-table-column-resizing"); + ownerDocument.body.classList.remove("notion-table-column-resizing"); + origin.removeAttribute("data-resizing"); + origin.style.left = initialHandleLeft; + if (origin.hasPointerCapture?.(pointerId)) { + origin.releasePointerCapture(pointerId); + } + resizeCleanupRef.current = null; + activeInteractionRef.current = null; + resolveLastPointer(); + }; + const onUp = (upEvent: PointerEvent) => { + if (upEvent.pointerId !== pointerId) return; + upEvent.preventDefault(); + upEvent.stopPropagation(); + latestX = upEvent.clientX / zoom; + cancelPreviewFrame(); + const width = applyPreview(latestX); + const committedWidths = baselineWidths.map((baseline, index) => + index === columnIndex ? width : baseline, + ); + finishSession(); + if (setTableColumnWidths(editor, layout.tablePos, committedWidths)) { + setAnnouncement(`Column ${columnIndex + 1} resized to ${width}px`); + } + requestAnimationFrame(update); + }; + const onCancel = (cancelEvent?: Event) => { + if ( + cancelEvent instanceof PointerEvent && + cancelEvent.pointerId !== pointerId + ) { + return; + } + finishSession(); + requestAnimationFrame(update); + }; + const onKeyDown = (keyEvent: KeyboardEvent) => { + if (keyEvent.key !== "Escape") return; + keyEvent.preventDefault(); + keyEvent.stopPropagation(); + onCancel(); + }; + + editor.view.dom.setAttribute("data-table-column-resizing", "true"); + ownerDocument.body.classList.add("notion-table-column-resizing"); + origin.setAttribute("data-resizing", "true"); + window.addEventListener("pointermove", onMove, { passive: false }); + window.addEventListener("pointerup", onUp); + window.addEventListener("pointercancel", onCancel); + window.addEventListener("blur", onCancel); + window.addEventListener("keydown", onKeyDown, true); + window.addEventListener("selectstart", preventSelectionOrDrag, true); + window.addEventListener("dragstart", preventSelectionOrDrag, true); + resizeCleanupRef.current = () => onCancel(); + try { + origin.setPointerCapture(pointerId); + } catch { + // Window listeners still complete the resize if capture is unavailable. + } + }; + + const beginPointerDrag = ( + event: React.PointerEvent, + axis: TableDragAxis, + sourceIndex: number, + ) => { + if ( + !layout || + event.button !== 0 || + !event.isPrimary || + dragCleanupRef.current + ) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + clearHideTimer(); + activeInteractionRef.current = { + kind: axis, + index: sourceIndex, + }; + showProximityTarget(activeInteractionRef.current); + const origin = event.currentTarget; + const pointerId = event.pointerId; + const startPoint = { left: event.clientX, top: event.clientY }; + const initialDoc = editor.state.doc; + const tablePos = layout.tablePos; + const currentTable = editor.state.doc.nodeAt(tablePos); + const indexOffset = + axis === "row" && + currentTable && + hasPinnedTableHeaderRow(currentTable) + ? 1 + : 0; + if (sourceIndex < indexOffset) return; + const pointerSourceIndex = sourceIndex - indexOffset; + let active = false; + let lastDrop: TablePointerDrop | null = null; + let latestPoint = startPoint; + let autoScrollFrame = 0; + + const clearListeners = () => { + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", onUp); + window.removeEventListener("pointercancel", onCancel); + window.removeEventListener("blur", onCancel); + window.removeEventListener("keydown", onKeyDown, true); + editor.view.dom.removeAttribute("data-table-pointer-dragging"); + if (autoScrollFrame !== 0) { + cancelAnimationFrame(autoScrollFrame); + autoScrollFrame = 0; + } + setDragIndicator(null); + if (origin.hasPointerCapture?.(pointerId)) { + origin.releasePointerCapture(pointerId); + } + dragCleanupRef.current = null; + activeInteractionRef.current = null; + resolveLastPointer(); + }; + const resolveDropAtPoint = (point: TablePointerPoint) => { + const tableElement = findTableElement(editor, tablePos); + const currentLayout = tableElement + ? measureTableElement(editor, tableElement) + : null; + if (!currentLayout) { + lastDrop = null; + setDragIndicator(null); + return; + } + const rects = + axis === "row" + ? currentLayout.rowRects.slice(indexOffset) + : currentLayout.columnRects; + const resolvedDrop = resolveTablePointerDrop( + rects, + pointerSourceIndex, + axis, + point, + ); + lastDrop = resolvedDrop + ? { + ...resolvedDrop, + targetIndex: resolvedDrop.targetIndex + indexOffset, + } + : null; + if (!lastDrop) { + setDragIndicator(null); + return; + } + + setDragIndicator( + axis === "row" + ? { + axis, + left: currentLayout.tableRect.left, + top: lastDrop.indicatorCoordinate - 1, + width: currentLayout.tableRect.width, + height: 3, + } + : { + axis, + left: lastDrop.indicatorCoordinate - 1, + top: currentLayout.tableRect.top, + width: 3, + height: currentLayout.tableRect.height, + }, + ); + }; + const continueAutoScroll = () => { + autoScrollFrame = 0; + if (!active || !autoScrollTableDrag(editor, axis, latestPoint)) return; + resolveDropAtPoint(latestPoint); + autoScrollFrame = requestAnimationFrame(continueAutoScroll); + }; + const scheduleAutoScroll = () => { + if (autoScrollFrame !== 0) return; + if (!autoScrollTableDrag(editor, axis, latestPoint)) return; + resolveDropAtPoint(latestPoint); + autoScrollFrame = requestAnimationFrame(continueAutoScroll); + }; + const updateDrop = (moveEvent: PointerEvent) => { + if (moveEvent.pointerId !== pointerId) return; + const point = { left: moveEvent.clientX, top: moveEvent.clientY }; + latestPoint = point; + if ( + !active && + hasExceededTablePointerDragThreshold(startPoint, point) + ) { + active = true; + editor.view.dom.setAttribute("data-table-pointer-dragging", axis); + } + if (!active) return; + + moveEvent.preventDefault(); + resolveDropAtPoint(point); + scheduleAutoScroll(); + }; + const onMove = (moveEvent: PointerEvent) => updateDrop(moveEvent); + const onUp = (upEvent: PointerEvent) => { + if (upEvent.pointerId !== pointerId) return; + updateDrop(upEvent); + const drop = lastDrop; + const shouldMove = + active && drop !== null && editor.state.doc.eq(initialDoc); + clearListeners(); + if (!shouldMove) return; + + const moved = + axis === "row" + ? moveTableRow(editor, tablePos, sourceIndex, drop.targetIndex) + : moveTableColumn(editor, tablePos, sourceIndex, drop.targetIndex); + if (moved) { + setAnnouncement( + `${axis === "row" ? "Row" : "Column"} ${sourceIndex + 1} moved to position ${drop.targetIndex + 1}`, + ); + requestAnimationFrame(update); + } + }; + const onCancel = () => clearListeners(); + const onKeyDown = (keyEvent: KeyboardEvent) => { + if (keyEvent.key === "Escape") clearListeners(); + }; + + window.addEventListener("pointermove", onMove, { passive: false }); + window.addEventListener("pointerup", onUp); + window.addEventListener("pointercancel", onCancel); + window.addEventListener("blur", onCancel); + window.addEventListener("keydown", onKeyDown, true); + dragCleanupRef.current = clearListeners; + try { + origin.setPointerCapture(pointerId); + } catch { + // Window listeners still finish the gesture when capture is unavailable. + } + }; + + const beginTableEdgeDrag = ( + event: React.PointerEvent, + axis: TableEdgeDragAxis, + ) => { + if ( + !layout || + event.button !== 0 || + !event.isPrimary || + resizeCleanupRef.current + ) { + return; + } + + const table = editor.state.doc.nodeAt(layout.tablePos); + if (!table || table.type.name !== "table" || table.childCount === 0) { + return; + } + const itemCount = + axis === "row" ? table.childCount : table.firstChild?.childCount ?? 0; + if (itemCount < 1) return; + + event.preventDefault(); + event.stopPropagation(); + clearHideTimer(); + const activeTarget: TableProximityTarget = { + kind: axis === "row" ? "addRow" : "addColumn", + }; + activeInteractionRef.current = activeTarget; + showProximityTarget(activeTarget); + + const origin = event.currentTarget; + const pointerId = event.pointerId; + const zoom = getInterfaceZoom(); + const start = { + left: event.clientX / zoom, + top: event.clientY / zoom, + }; + const initialDoc = editor.state.doc; + const tablePos = layout.tablePos; + let current = start; + + const finishSession = () => { + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", onUp); + window.removeEventListener("pointercancel", onCancel); + window.removeEventListener("blur", onCancel); + window.removeEventListener("keydown", onKeyDown, true); + origin.removeAttribute("data-resizing"); + if (origin.hasPointerCapture?.(pointerId)) { + origin.releasePointerCapture(pointerId); + } + resizeCleanupRef.current = null; + activeInteractionRef.current = null; + resolveLastPointer(); + }; + const updatePoint = (pointerEvent: PointerEvent) => { + current = { + left: pointerEvent.clientX / zoom, + top: pointerEvent.clientY / zoom, + }; + }; + const onMove = (moveEvent: PointerEvent) => { + if (moveEvent.pointerId !== pointerId) return; + moveEvent.preventDefault(); + updatePoint(moveEvent); + }; + const onUp = (upEvent: PointerEvent) => { + if (upEvent.pointerId !== pointerId) return; + upEvent.preventDefault(); + upEvent.stopPropagation(); + updatePoint(upEvent); + const delta = resolveTableEdgeDragDelta({ + axis, + start, + current, + itemCount, + }); + finishSession(); + if (delta === 0 || !editor.state.doc.eq(initialDoc)) return; + + const currentLayout = layoutRef.current; + if (!currentLayout || currentLayout.tablePos !== tablePos) return; + if (!applyTableEdgeDelta(editor, currentLayout, axis, delta)) { + return; + } + + const unit = axis === "row" ? "row" : "column"; + setAnnouncement( + `${Math.abs(delta)} ${unit}${Math.abs(delta) === 1 ? "" : "s"} ${delta > 0 ? "added" : "removed"}`, + ); + requestAnimationFrame(update); + }; + const onCancel = (cancelEvent?: Event) => { + if ( + cancelEvent instanceof PointerEvent && + cancelEvent.pointerId !== pointerId + ) { + return; + } + finishSession(); + }; + const onKeyDown = (keyEvent: KeyboardEvent) => { + if (keyEvent.key !== "Escape") return; + keyEvent.preventDefault(); + keyEvent.stopPropagation(); + onCancel(); + }; + + origin.setAttribute("data-resizing", "true"); + window.addEventListener("pointermove", onMove, { passive: false }); + window.addEventListener("pointerup", onUp); + window.addEventListener("pointercancel", onCancel); + window.addEventListener("blur", onCancel); + window.addEventListener("keydown", onKeyDown, true); + resizeCleanupRef.current = () => onCancel(); + try { + origin.setPointerCapture(pointerId); + } catch { + // Window listeners still complete the gesture when capture is unavailable. + } + }; + + if (!layout || typeof document === "undefined") return null; + + const moveRow = (from: number, to: number) => { + if (moveTableRow(editor, layout.tablePos, from, to)) { + setAnnouncement(`Row ${from + 1} moved to position ${to + 1}`); + requestAnimationFrame(update); + } + }; + const moveColumn = (from: number, to: number) => { + if (moveTableColumn(editor, layout.tablePos, from, to)) { + setAnnouncement(`Column ${from + 1} moved to position ${to + 1}`); + requestAnimationFrame(update); + } + }; + const openAxisActions = (axis: TableDragAxis, index: number) => { + if (selectTableAxis(editor, layout, axis, index)) { + setAnnouncement( + `${axis === "row" ? "Row" : "Column"} ${index + 1} selected`, + ); + } + setActionsOpen(true); + }; + const tableNode = editor.state.doc.nodeAt(layout.tablePos); + const hasPinnedHeader = Boolean( + tableNode && hasPinnedTableHeaderRow(tableNode), + ); + const hasPinnedHeaderColumn = Boolean( + tableNode && hasPinnedTableHeaderColumn(tableNode), + ); + + return createPortal( +
+ {actionsOpen && ( +
+ { + setActionQuery(event.currentTarget.value); + setColorTarget(null); + }} + /> + Table + + + + + Row + + + + + + + + + + Column + + + + + + + + + {colorTarget && ( +
+ {TABLE_BACKGROUND_COLOR_OPTIONS.map((option, index) => { + const name = TABLE_BACKGROUND_COLOR_NAMES[index]; + return ( +
+ )} +
+ )} + + {layout.rowRects.map((rect, rowIndex) => ( +
+ {TABLE_STRUCTURE_CONTROLS_VISIBLE && + proximityTarget?.kind === "row" && + proximityTarget.index === rowIndex && ( + + )} + {proximityTarget?.kind === "rowResize" && + proximityTarget.index === rowIndex && ( +
+ ))} + + {TABLE_STRUCTURE_CONTROLS_VISIBLE && + proximityTarget?.kind === "column" && + layout.columnRects.map((rect, columnIndex) => + proximityTarget.index === columnIndex ? ( + + ) : null, + )} + + {proximityTarget?.kind === "columnResize" && ( + + +