diff --git a/package-lock.json b/package-lock.json index 55003d64..06ff2272 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56,9 +56,11 @@ "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^4.6.0", + "happy-dom": "20.11.1", "tailwindcss": "^4.1.18", "typescript": "~5.8.3", - "vite": "^7.0.4" + "vite": "^7.0.4", + "vitest": "4.1.10" } }, "node_modules/@babel/code-frame": { @@ -1981,6 +1983,13 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tailwindcss/node": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz", @@ -3239,6 +3248,24 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -3276,6 +3303,16 @@ "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", "license": "MIT" }, + "node_modules/@types/node": { + "version": "26.1.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz", + "integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -3305,6 +3342,23 @@ "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", "license": "MIT" }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -3326,6 +3380,119 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -3344,6 +3511,16 @@ "node": ">=10" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.0", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", @@ -3411,6 +3588,19 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer-image-size": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz", + "integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + }, + "engines": { + "node": ">=4.0" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001776", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001776.tgz", @@ -3432,6 +3622,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -3574,6 +3774,13 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", @@ -3638,6 +3845,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-equals": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", @@ -3706,6 +3933,38 @@ "dev": true, "license": "ISC" }, + "node_modules/happy-dom": { + "version": "20.11.1", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.11.1.tgz", + "integrity": "sha512-XSt8tMzbW9ymE7687xztkO1ckR7qJNQ3LywY9vlYGhGi3zXrGBHuUo2Cl1ztZaICW+1eAGdkLbj6iwVqDT33kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": ">=20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "@types/ws": "^8.18.1", + "buffer-image-size": "^0.6.4", + "entities": "^7.0.1", + "whatwg-mimetype": "^3.0.0", + "ws": "^8.21.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/happy-dom/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/highlight.js": { "version": "11.11.1", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", @@ -4151,12 +4410,33 @@ "dev": true, "license": "MIT" }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/orderedmap": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -4601,6 +4881,13 @@ "semver": "bin/semver.js" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/sonner": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", @@ -4621,6 +4908,20 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwind-merge": { "version": "3.5.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", @@ -4652,6 +4953,23 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -4669,6 +4987,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tippy.js": { "version": "6.3.7", "resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz", @@ -4704,6 +5032,13 @@ "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", "license": "MIT" }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -4869,12 +5204,151 @@ } } }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "license": "MIT" }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index fb889190..dae25d48 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,9 @@ "dev": "vite", "build": "tsc && vite build", "preview": "vite preview", - "tauri": "tauri" + "tauri": "tauri", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@dnd-kit/core": "^6.3.1", @@ -58,8 +60,10 @@ "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^4.6.0", + "happy-dom": "20.11.1", "tailwindcss": "^4.1.18", "typescript": "~5.8.3", - "vite": "^7.0.4" + "vite": "^7.0.4", + "vitest": "4.1.10" } } diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 715b6b03..36095b9a 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -2,7 +2,7 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "Capability for all app windows", - "windows": ["main", "preview-*"], + "windows": ["main", "preview-*", "workspace-*", "preferences"], "permissions": [ "core:default", "core:window:allow-close", @@ -11,7 +11,6 @@ "core:webview:allow-print", "opener:default", "opener:allow-open-url", - "opener:allow-reveal-item-in-dir", "dialog:default", "dialog:allow-open", "core:menu:default", diff --git a/src-tauri/src/draft_checkpoint.rs b/src-tauri/src/draft_checkpoint.rs new file mode 100644 index 00000000..28ba7bc8 --- /dev/null +++ b/src-tauri/src/draft_checkpoint.rs @@ -0,0 +1,464 @@ +use crate::hashing::sha256_hex; +use serde::{Deserialize, Serialize}; +use std::fmt; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +pub const CHECKPOINT_DIRECTORY_NAME: &str = "draft-checkpoints"; + +static NEXT_TEMPORARY_FILE: AtomicU64 = AtomicU64::new(0); + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DraftCheckpointKey { + pub window_label: String, + pub note_id: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DraftCheckpointMetadata { + pub source_path: String, + pub base_revision: Option, + pub updated_at: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DraftCheckpoint { + pub key: DraftCheckpointKey, + pub markdown: String, + pub metadata: DraftCheckpointMetadata, +} + +#[derive(Debug)] +pub enum DraftCheckpointError { + Io(io::Error), + Json(serde_json::Error), + IdentityMismatch { path: PathBuf }, +} + +impl fmt::Display for DraftCheckpointError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io(error) => write!(formatter, "draft checkpoint I/O failed: {error}"), + Self::Json(error) => write!(formatter, "draft checkpoint JSON is invalid: {error}"), + Self::IdentityMismatch { path } => write!( + formatter, + "draft checkpoint identity does not match its hashed file name: {}", + path.display() + ), + } + } +} + +impl std::error::Error for DraftCheckpointError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Io(error) => Some(error), + Self::Json(error) => Some(error), + Self::IdentityMismatch { .. } => None, + } + } +} + +impl From for DraftCheckpointError { + fn from(error: io::Error) -> Self { + Self::Io(error) + } +} + +impl From for DraftCheckpointError { + fn from(error: serde_json::Error) -> Self { + Self::Json(error) + } +} + +/// Atomically creates or replaces only the checkpoint identified by `checkpoint.key`. +/// All filesystem writes remain below the supplied application-data directory. +pub fn write_checkpoint( + app_data_directory: impl AsRef, + checkpoint: &DraftCheckpoint, +) -> Result<(), DraftCheckpointError> { + let checkpoint_directory = checkpoint_directory(app_data_directory.as_ref()); + fs::create_dir_all(&checkpoint_directory)?; + let path = checkpoint_path(&checkpoint_directory, &checkpoint.key); + let serialized = serde_json::to_vec(checkpoint)?; + atomic_write(&path, &serialized)?; + Ok(()) +} + +pub fn read_checkpoint( + app_data_directory: impl AsRef, + key: &DraftCheckpointKey, +) -> Result, DraftCheckpointError> { + let checkpoint_directory = checkpoint_directory(app_data_directory.as_ref()); + let path = checkpoint_path(&checkpoint_directory, key); + let bytes = match fs::read(&path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error.into()), + }; + let checkpoint: DraftCheckpoint = serde_json::from_slice(&bytes)?; + ensure_identity_matches(&path, &checkpoint)?; + Ok(Some(checkpoint)) +} + +/// Lists checkpoints newest first. Equal timestamps use identity ordering for stability. +pub fn list_checkpoints( + app_data_directory: impl AsRef, +) -> Result, DraftCheckpointError> { + let checkpoint_directory = checkpoint_directory(app_data_directory.as_ref()); + let entries = match fs::read_dir(&checkpoint_directory) { + Ok(entries) => entries, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => return Err(error.into()), + }; + let mut checkpoints = Vec::new(); + + for entry in entries { + let path = match entry { + Ok(entry) => entry.path(), + Err(_) => continue, + }; + if path.extension().and_then(|extension| extension.to_str()) != Some("json") { + continue; + } + let bytes = match fs::read(&path) { + Ok(bytes) => bytes, + Err(_) => continue, + }; + let checkpoint: DraftCheckpoint = match serde_json::from_slice(&bytes) { + Ok(checkpoint) => checkpoint, + Err(_) => continue, + }; + if ensure_identity_matches(&path, &checkpoint).is_err() { + continue; + } + checkpoints.push(checkpoint); + } + + checkpoints.sort_by(|left, right| { + right + .metadata + .updated_at + .cmp(&left.metadata.updated_at) + .then_with(|| left.key.window_label.cmp(&right.key.window_label)) + .then_with(|| left.key.note_id.cmp(&right.key.note_id)) + }); + Ok(checkpoints) +} + +/// Idempotently removes only the selected checkpoint. +pub fn clear_checkpoint( + app_data_directory: impl AsRef, + key: &DraftCheckpointKey, +) -> Result<(), DraftCheckpointError> { + let checkpoint_directory = checkpoint_directory(app_data_directory.as_ref()); + let path = checkpoint_path(&checkpoint_directory, key); + match fs::remove_file(path) { + Ok(()) => { + sync_parent_directory(&checkpoint_directory)?; + Ok(()) + } + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error.into()), + } +} + +fn checkpoint_directory(app_data_directory: &Path) -> PathBuf { + app_data_directory.join(CHECKPOINT_DIRECTORY_NAME) +} + +fn checkpoint_path(directory: &Path, key: &DraftCheckpointKey) -> PathBuf { + directory.join(checkpoint_file_name(key)) +} + +fn checkpoint_file_name(key: &DraftCheckpointKey) -> String { + let mut identity = Vec::with_capacity(key.window_label.len() + key.note_id.len() + 16); + identity.extend_from_slice(&(key.window_label.len() as u64).to_be_bytes()); + identity.extend_from_slice(key.window_label.as_bytes()); + identity.extend_from_slice(&(key.note_id.len() as u64).to_be_bytes()); + identity.extend_from_slice(key.note_id.as_bytes()); + format!("{}.json", sha256_hex(&identity)) +} + +fn ensure_identity_matches( + path: &Path, + checkpoint: &DraftCheckpoint, +) -> Result<(), DraftCheckpointError> { + let actual = path.file_name().and_then(|name| name.to_str()); + if actual == Some(&checkpoint_file_name(&checkpoint.key)) { + return Ok(()); + } + Err(DraftCheckpointError::IdentityMismatch { + path: path.to_path_buf(), + }) +} + +fn atomic_write(path: &Path, bytes: &[u8]) -> io::Result<()> { + let parent = path.parent().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "draft checkpoint target has no parent directory", + ) + })?; + let (mut temporary_file, mut temporary_path) = create_temporary_file(path, parent)?; + temporary_file.write_all(bytes)?; + temporary_file.flush()?; + temporary_file.sync_all()?; + drop(temporary_file); + + replace_file(temporary_path.path(), path)?; + temporary_path.commit(); + sync_parent_directory(parent) +} + +#[cfg(not(windows))] +fn replace_file(source: &Path, destination: &Path) -> io::Result<()> { + fs::rename(source, destination) +} + +#[cfg(windows)] +fn replace_file(source: &Path, destination: &Path) -> io::Result<()> { + use std::iter; + use std::os::windows::ffi::OsStrExt; + + const MOVEFILE_REPLACE_EXISTING: u32 = 0x1; + const MOVEFILE_WRITE_THROUGH: u32 = 0x8; + + #[link(name = "kernel32")] + extern "system" { + fn MoveFileExW( + existing_file_name: *const u16, + new_file_name: *const u16, + flags: u32, + ) -> i32; + } + + let source = source + .as_os_str() + .encode_wide() + .chain(iter::once(0)) + .collect::>(); + let destination = destination + .as_os_str() + .encode_wide() + .chain(iter::once(0)) + .collect::>(); + let result = unsafe { + MoveFileExW( + source.as_ptr(), + destination.as_ptr(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, + ) + }; + if result == 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) +} + +fn create_temporary_file(path: &Path, parent: &Path) -> io::Result<(File, TemporaryPath)> { + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("checkpoint.json"); + loop { + let sequence = NEXT_TEMPORARY_FILE.fetch_add(1, Ordering::Relaxed); + let temporary_path = parent.join(format!( + ".{file_name}.scratch-checkpoint-{}-{sequence}.tmp", + std::process::id() + )); + match OpenOptions::new() + .write(true) + .create_new(true) + .open(&temporary_path) + { + Ok(file) => return Ok((file, TemporaryPath::new(temporary_path))), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, + Err(error) => return Err(error), + } + } +} + +struct TemporaryPath { + path: PathBuf, + committed: bool, +} + +impl TemporaryPath { + fn new(path: PathBuf) -> Self { + Self { + path, + committed: false, + } + } + + fn path(&self) -> &Path { + &self.path + } + + fn commit(&mut self) { + self.committed = true; + } +} + +impl Drop for TemporaryPath { + fn drop(&mut self) { + if !self.committed { + let _ = fs::remove_file(&self.path); + } + } +} + +#[cfg(unix)] +fn sync_parent_directory(parent: &Path) -> io::Result<()> { + File::open(parent)?.sync_all() +} + +#[cfg(not(unix))] +fn sync_parent_directory(_parent: &Path) -> io::Result<()> { + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + 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, + } + + 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() + } + } + + impl Drop for TestDirectory { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } + } + + 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"); + } + + #[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"); + } + + #[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"); + } + + #[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"); + } +} 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 new file mode 100644 index 00000000..2efca487 --- /dev/null +++ b/src-tauri/src/hashing.rs @@ -0,0 +1,199 @@ +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); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 72b6d15d..39aad6a5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4,19 +4,28 @@ use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher}; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; +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::*; use tantivy::{doc, Index, IndexReader, IndexWriter, ReloadPolicy}; +use tauri::webview::{WebviewWindow, WebviewWindowBuilder}; use tauri::{AppHandle, Emitter, Manager, State, WebviewUrl}; -use tauri::webview::WebviewWindowBuilder; use tauri_plugin_clipboard_manager::ClipboardExt; use tokio::fs; use tokio::io::AsyncWriteExt; +mod draft_checkpoint; mod git; +mod hashing; +mod persistence; +mod watcher_debounce; + +use watcher_debounce::{WatcherDebounce, WATCHER_DEBOUNCE_WINDOW}; + +#[cfg(test)] +mod note_persistence_tests; // Note metadata for list display #[derive(Debug, Clone, Serialize, Deserialize)] @@ -35,17 +44,36 @@ 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)] +#[serde(rename_all = "camelCase")] +pub struct NoteConflictSnapshot { + pub content: String, + 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, @@ -60,7 +88,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" @@ -79,7 +107,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" @@ -100,10 +128,415 @@ 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 != "preferences" + && !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())); + if fallback.workspace != workspace { + fallback.selected_note_id = None; + } + 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 NativeMenuItemSpec { + parent_menu: &'static str, + id: &'static str, + label: &'static str, + accelerator: &'static str, +} + +fn native_open_folder_menu_spec() -> NativeMenuItemSpec { + NativeMenuItemSpec { + parent_menu: "File", + id: "open-folder", + label: "Open Folder…", + accelerator: "CmdOrCtrl+O", + } +} + +fn native_preferences_menu_spec() -> NativeMenuItemSpec { + NativeMenuItemSpec { + 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 build_application_menu(app_handle: &AppHandle) -> tauri::Result> { + use tauri::menu::{Menu, MenuItem, PredefinedMenuItem, Submenu}; + + let menu = Menu::default(app_handle)?; + let open_folder_spec = native_open_folder_menu_spec(); + let preferences_spec = native_preferences_menu_spec(); + 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 == open_folder_spec.parent_menu { + has_file_menu = true; + let separator = PredefinedMenuItem::separator(app_handle)?; + submenu.prepend_items(&[&open_folder, &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 open-folder entry without replacing other native default menus. + let file_menu = Submenu::with_items( + app_handle, + open_folder_spec.parent_menu, + true, + &[&open_folder], + )?; + menu.prepend(&file_menu)?; + Ok(menu) +} + +const NATIVE_OPEN_FOLDER_EVENT: &str = "open-folder"; + +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, NATIVE_OPEN_FOLDER_EVENT, ()); + } +} + +fn workspace_path_key(path: &Path) -> String { + 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 { + 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")] @@ -122,6 +555,14 @@ pub struct Settings { pub interface_zoom: Option, #[serde(rename = "customEditorWidthPx")] pub custom_editor_width_px: Option, + #[serde(rename = "editorWidthResizeEnabled")] + pub editor_width_resize_enabled: Option, + #[serde(rename = "editorToolbarVisible")] + pub editor_toolbar_visible: Option, + #[serde(rename = "titleBarModifiedDateVisible")] + pub title_bar_modified_date_visible: Option, + #[serde(rename = "titleBarFilenameVisible")] + pub title_bar_filename_visible: Option, /// Custom sidebar width in px; `None` means the default width is used. #[serde(rename = "sidebarWidthPx")] pub sidebar_width_px: Option, @@ -129,6 +570,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")] @@ -137,6 +580,207 @@ pub struct Settings { pub custom_colors_dark: Option>, } +#[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>, +} + +#[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 { @@ -162,6 +806,64 @@ pub struct FileWatcherState { 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, @@ -332,52 +1034,357 @@ impl SearchIndex { } } -// 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 struct WorkspaceSession { + pub notes_folder: String, + pub settings: RwLock, pub notes_cache: RwLock>, pub file_watcher: Mutex>, pub search_index: Mutex>, - pub debounce_map: Arc>>, + pub(crate) debounce_map: Arc>, } -impl Default for AppState { - fn default() -> Self { +impl WorkspaceSession { + #[cfg(test)] + fn empty(notes_folder: String) -> Self { Self { - app_config: RwLock::new(AppConfig::default()), - settings: RwLock::new(Settings::default()), + 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(HashMap::new())), + debounce_map: Arc::new(Mutex::new(WatcherDebounce::default())), } } -} - -// Utility: Sanitize filename from title -fn sanitize_filename(title: &str) -> String { - let sanitized: String = title - .chars() - .filter(|c| *c != '\u{00A0}' && *c != '\u{FEFF}') - .map(|c| match c { - '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '-', - _ => c, - }) - .collect(); - let trimmed = sanitized.trim(); - if trimmed.is_empty() || is_effectively_empty(trimmed) { - "Untitled".to_string() - } else { - trimmed.to_string() + 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 ordinal_suffix(day: u32) -> &'static str { - match (day % 100, day % 10) { - (11..=13, _) => "th", - (_, 1) => "st", + 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 notes_cache: RwLock>, + pub file_watcher: Mutex>, + pub search_index: Mutex>, + 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)); + } + + Err(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(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())), + 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 + .chars() + .filter(|c| *c != '\u{00A0}' && *c != '\u{FEFF}') + .map(|c| match c { + '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '-', + _ => c, + }) + .collect(); + + let trimmed = sanitized.trim(); + if trimmed.is_empty() || is_effectively_empty(trimmed) { + "Untitled".to_string() + } else { + trimmed.to_string() + } +} + +fn ordinal_suffix(day: u32) -> &'static str { + match (day % 100, day % 10) { + (11..=13, _) => "th", + (_, 1) => "st", (_, 2) => "nd", (_, 3) => "rd", _ => "th", @@ -516,7 +1523,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; } @@ -526,7 +1538,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; } @@ -534,7 +1551,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; } @@ -544,7 +1566,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; } @@ -564,7 +1591,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; } @@ -577,7 +1609,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; } @@ -625,10 +1662,11 @@ 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 { - settings.ignored_patterns.clone().unwrap_or_else(|| { - DEFAULT_IGNORED_DIRS.iter().map(|s| s.to_string()).collect() - }) +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()) } /// Filter for WalkDir: skips excluded and user-ignored directories. @@ -642,7 +1680,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. @@ -665,7 +1707,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 @@ -725,27 +1769,46 @@ 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))) +} + +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) { Ok(p) => p, - Err(_) => return AppConfig::default(), + Err(error) => { + eprintln!("app config path resolution failed: {error}"); + return AppConfig::default(); + } }; - if path.exists() { - std::fs::read_to_string(&path) - .ok() - .and_then(|content| serde_json::from_str(&content).ok()) - .unwrap_or_default() - } else { - AppConfig::default() + match std::fs::read_to_string(&path) { + Ok(content) => match serde_json::from_str(&content) { + Ok(config) => config, + Err(error) => { + eprintln!("app config deserialization failed: {error}"); + AppConfig::default() + } + }, + Err(error) => { + eprintln!("app config read failed: {error}"); + AppConfig::default() + } } } @@ -758,7 +1821,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() { @@ -772,20 +1848,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(); @@ -804,278 +1873,900 @@ 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 mut next = config.clone(); + next.records.remove(label); + save_app_config(app, &next).map_err(|error| error.to_string())?; + *config = next; + Ok(true) +} - let ignored_dirs = { - let settings = state.settings.read().expect("settings read lock"); - get_effective_ignored_dirs(&settings) +fn handle_window_destroyed(app: &AppHandle, label: &str) { + let Some(state) = app.try_state::() else { + return; }; - - 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)); + 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() - .map(|(id, title, preview, modified)| NoteMetadata { - id, - title, - preview, - modified, - }) - .collect(); +fn is_full_editor_window(label: &str) -> bool { + label == "main" || label.starts_with("workspace-") +} - // Load pinned note IDs from settings - let pinned_ids: HashSet = { - let settings = state.settings.read().expect("settings read lock"); - settings - .pinned_note_ids - .as_ref() - .map(|ids| ids.iter().cloned().collect()) - .unwrap_or_default() +fn request_full_window_closure_for_preview(app: &AppHandle) { + let Some(state) = app.try_state::() else { + return; }; - - // Sort: pinned notes first (by date), then unpinned notes (by date) - notes.sort_by(|a, b| { - let a_pinned = pinned_ids.contains(&a.id); - 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 - (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) - } - }); - - // Update cache efficiently + let windows: Vec<_> = app + .webview_windows() + .into_iter() + .filter(|(label, _)| is_full_editor_window(label)) + .collect(); { - let mut cache = state.notes_cache.write().expect("cache write lock"); - cache.clear(); - for note in ¬es { - cache.insert(note.id.clone(), note.clone()); - } + 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.close(); } +} - Ok(notes) +#[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] -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")? +fn close_window_after_save(window: WebviewWindow) -> Result<(), String> { + window + .destroy() + .map_err(|error| format!("Failed to close window after save: {error}")) +} + +#[tauri::command] +async fn set_notes_folder( + app: AppHandle, + window: WebviewWindow, + path: String, + state: State<'_, AppState>, +) -> Result<(), String> { + let path_buf = normalize_notes_folder_path(&path)?; + if window.label() == "main" { + let _normalized = initialize_notes_folder(&app, &path_buf, &state)?; + let _ = app.emit("workspaces-changed", ()); + return Ok(()); + } + + 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(); + 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", ()); + let _ = remove_workspace_search_index(&app, path); + Ok(()) +} + +#[tauri::command] +async fn switch_workspace( + app: AppHandle, + window: WebviewWindow, + path: String, + 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 (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(); + 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 folder_path = PathBuf::from(&folder); - let file_path = abs_path_from_id(&folder_path, &id)?; + 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, + preview, + modified, + }) + .collect(); + + // Load pinned note IDs from settings + let pinned_ids: HashSet = { + let settings = workspace.settings().read().expect("settings read lock"); + settings + .pinned_note_ids + .as_ref() + .map(|ids| ids.iter().cloned().collect()) + .unwrap_or_default() + }; + + // Sort: pinned notes first (by date), then unpinned notes (by date) + notes.sort_by(|a, b| { + let a_pinned = pinned_ids.contains(&a.id); + 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 + (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) + } + }); + + // Update cache efficiently + { + let mut cache = workspace.notes_cache().write().expect("cache write lock"); + cache.clear(); + for note in ¬es { + cache.insert(note.id.clone(), note.clone()); + } + } + + Ok(notes) +} + +#[tauri::command] +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(), + }) +} + +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, + 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), + }); + } + }; + + 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), + }), + } +} + +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 folder = { - let app_config = state.app_config.read().expect("app_config read lock"); - app_config - .notes_folder - .clone() - .ok_or("Notes folder not set")? - }; +) -> 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(); + let recreates_deleted_note = id.is_some() && expected_revision.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)) + if recreates_deleted_note { + let file_path = abs_path_from_id(&folder_path, &existing_id)?; + (existing_id, file_path, None) } else { - (None, sanitized_leaf.clone()) - }; + // 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)?; + let old_file_path = abs_path_from_id(&folder_path, &existing_id)?; - if existing_id != desired_id { - let mut new_id = desired_id.clone(); - let mut counter = 1; + if existing_id != desired_id { + let mut new_id = desired_id.clone(); + let mut counter = 1; - while new_id != existing_id - && abs_path_from_id(&folder_path, &new_id) - .map(|p| p.exists()) - .unwrap_or(false) - { - new_id = if let Some(ref prefix) = dir_prefix { - format!("{}/{}-{}", prefix, sanitized_leaf, counter) - } else { - format!("{}-{}", sanitized_leaf, counter) - }; - counter += 1; - } + while new_id != existing_id + && abs_path_from_id(&folder_path, &new_id) + .map(|p| p.exists()) + .unwrap_or(false) + { + new_id = if let Some(ref prefix) = dir_prefix { + format!("{}/{}-{}", prefix, sanitized_leaf, counter) + } else { + format!("{}-{}", sanitized_leaf, counter) + }; + counter += 1; + } - let new_file_path = abs_path_from_id(&folder_path, &new_id)?; - (new_id, new_file_path, Some((existing_id, old_file_path))) - } else { - (existing_id, old_file_path, None) + let new_file_path = abs_path_from_id(&folder_path, &new_id)?; + (new_id, new_file_path, Some((existing_id, old_file_path))) + } else { + (existing_id, old_file_path, None) + } } } else { // New notes go in root @@ -1094,31 +2785,94 @@ 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 || recreates_deleted_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()); + }; - // Delete old file AFTER successful write (to prevent data loss) + let mut saved_note = match initial_result { + NoteSaveResult::Conflict { current } => { + return Ok(NoteSaveResult::Conflict { current }); + } + NoteSaveResult::Saved { note } => note, + }; + + // 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_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) + .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()); + } + + 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}")) + { + 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 || { + 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); @@ -1129,31 +2883,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 @@ -1162,7 +2908,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); } @@ -1170,111 +2916,307 @@ 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); } - Ok(()) + 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, + 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 global template (default "Untitled") + let template = { + 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()) + }; + + // Expand template tags + let expanded = expand_note_name_template(&template); + + // Sanitize filename + let sanitized = sanitize_filename(&expanded); + + // Prepend folder prefix if specified + let sanitized = if let Some(ref folder_prefix) = target_folder { + if folder_prefix.is_empty() { + sanitized + } else { + format!("{}/{}", folder_prefix.trim_end_matches('/'), sanitized) + } + } else { + sanitized + }; + + // Handle {counter} tag + let has_counter = template.contains("{counter}"); + let base_id = if has_counter { + sanitized.replace("{counter}", "1") + } else { + sanitized.clone() + }; + + let mut final_id = base_id.clone(); + let mut counter = if has_counter { 2 } else { 1 }; + + // 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 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 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); - // Get template from settings (default "Untitled") - let template = { - let settings = state.settings.read().expect("settings read lock"); - settings - .default_note_name - .clone() - .unwrap_or_else(|| "Untitled".to_string()) - }; - - // Expand template tags - let expanded = expand_note_name_template(&template); - - // Sanitize filename - let sanitized = sanitize_filename(&expanded); + let original = read_note(id.clone(), window.clone(), state.clone()) + .await + .map_err(|e| format!("Failed to read original note: {e}"))?; - // Prepend folder prefix if specified - let sanitized = if let Some(ref folder_prefix) = target_folder { - if folder_prefix.is_empty() { - sanitized - } else { - format!("{}/{}", folder_prefix.trim_end_matches('/'), sanitized) - } + let base_title = extract_title(&original.content); + let copy_title = if base_title.trim().is_empty() { + "Copy" } else { - sanitized + &base_title }; + let copy_title = format!("{} (Copy)", copy_title); + let sanitized = sanitize_filename(©_title); - // Handle {counter} tag - let has_counter = template.contains("{counter}"); - let base_id = if has_counter { - sanitized.replace("{counter}", "1") + let folder_prefix = id.rfind('/').map(|pos| id[..pos].to_string()); + let sanitized = if let Some(ref prefix) = folder_prefix { + format!("{}/{}", prefix, sanitized) } else { - sanitized.clone() + sanitized }; - let mut final_id = base_id.clone(); - let mut counter = if has_counter { 2 } else { 1 }; - - // Ensure filename uniqueness + 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 = 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); } } - 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 @@ -1312,18 +3254,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) }; @@ -1354,14 +3294,12 @@ 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)?; @@ -1379,14 +3317,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)?; @@ -1402,9 +3339,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) { @@ -1416,7 +3353,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)); } @@ -1432,15 +3369,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)?; @@ -1490,7 +3423,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) { @@ -1506,7 +3439,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)) @@ -1525,10 +3458,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); @@ -1540,17 +3473,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)?; @@ -1577,7 +3507,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 @@ -1591,7 +3523,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 { @@ -1604,7 +3536,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); @@ -1613,16 +3545,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) } @@ -1630,15 +3584,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() { @@ -1678,7 +3628,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 @@ -1696,7 +3648,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) { @@ -1709,7 +3661,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)) @@ -1728,10 +3680,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); @@ -1742,27 +3694,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_global_settings( + patch: serde_json::Value, + window: WebviewWindow, + state: State, +) -> Result<(), String> { + { + 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 = 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; + } + + 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 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 (global_settings, workspace_settings) = split_settings(new_settings); { - let mut settings = state.settings.write().expect("settings write lock"); - *settings = new_settings; - } - - let settings = state.settings.read().expect("settings read lock"); - save_settings(&folder, &settings).map_err(|e| e.to_string())?; + // 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(()) } @@ -1771,27 +3835,28 @@ 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()); - } + let workspace = state.workspace_for_window(window.label())?; + let folder = workspace.notes_folder()?; + if folder != expected_folder { + return Err("Notes folder changed".to_string()); + } - folder + 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 = state.settings.write().expect("settings write lock"); - settings.git_enabled = enabled; + let mut settings = workspace.settings().write().expect("settings write lock"); + *settings = updated; } - let settings = state.settings.read().expect("settings read lock"); - save_settings(&folder, &settings).map_err(|e| e.to_string())?; - Ok(()) } @@ -1818,12 +3883,83 @@ fn preview_note_name(template: String) -> Result { } // Preview mode: file content returned by read_file_direct / save_file_direct -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct FileContent { pub path: String, pub content: String, pub title: String, pub modified: i64, + pub revision: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "status", rename_all = "camelCase")] +pub enum FileSaveResult { + Saved { + file: FileContent, + }, + Conflict { + current: Option, + }, +} + +fn read_file_content_from_path(path: &Path) -> Result { + let snapshot = persistence::read_snapshot(path) + .map_err(|error| error.to_string())? + .ok_or_else(|| "File not found".to_string())?; + let metadata = std::fs::metadata(path).map_err(|error| error.to_string())?; + let modified = metadata + .modified() + .ok() + .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|duration| duration.as_secs() as i64) + .unwrap_or(0); + + Ok(FileContent { + path: path.to_string_lossy().into_owned(), + title: extract_title(&snapshot.content), + content: snapshot.content, + modified, + revision: snapshot.revision.to_string(), + }) +} + +fn save_file_content_to_path( + path: &Path, + content: String, + expected_revision: String, +) -> Result { + let current = persistence::read_snapshot(path).map_err(|error| error.to_string())?; + let expected = match current { + Some(snapshot) if snapshot.revision.as_str() == expected_revision => snapshot.revision, + current => { + return Ok(FileSaveResult::Conflict { + current: note_conflict_snapshot(current), + }); + } + }; + + match persistence::save_if_revision(path, &content, Some(&expected)) + .map_err(|error| error.to_string())? + { + persistence::SaveResult::Saved { .. } => Ok(FileSaveResult::Saved { + file: read_file_content_from_path(path)?, + }), + persistence::SaveResult::Conflict { current } => Ok(FileSaveResult::Conflict { + current: note_conflict_snapshot(current), + }), + } +} + +fn recreate_file_content_to_path(path: &Path, content: String) -> Result { + match persistence::save_if_revision(path, &content, None).map_err(|error| error.to_string())? { + persistence::SaveResult::Saved { .. } => Ok(FileSaveResult::Saved { + file: read_file_content_from_path(path)?, + }), + persistence::SaveResult::Conflict { current } => Ok(FileSaveResult::Conflict { + current: note_conflict_snapshot(current), + }), + } } /// Validate a file path for preview mode direct file operations. @@ -1845,6 +3981,48 @@ fn validate_preview_path(path: &str) -> Result { Ok(canonical) } +/// Validate a missing direct-file target through its existing canonical parent. +/// The final component remains unresolved so create-only persistence can detect +/// a concurrent recreation without ever replacing it. +fn validate_preview_create_path(path: &str) -> Result { + let file_path = PathBuf::from(path); + if !file_path.is_absolute() { + return Err("Standalone recreation requires an absolute path".to_string()); + } + if file_path.components().any(|component| { + matches!( + component, + std::path::Component::ParentDir | std::path::Component::CurDir + ) + }) { + return Err("Path traversal is not allowed".to_string()); + } + match file_path + .extension() + .and_then(|extension| extension.to_str()) + { + Some(extension) + if extension.eq_ignore_ascii_case("md") + || extension.eq_ignore_ascii_case("markdown") => {} + _ => return Err("Only .md and .markdown files are allowed".to_string()), + } + + let file_name = file_path + .file_name() + .ok_or_else(|| "Standalone recreation target has no file name".to_string())?; + let parent = file_path + .parent() + .ok_or_else(|| "Standalone recreation target has no parent".to_string())?; + let canonical_parent = parent + .canonicalize() + .map_err(|error| format!("Cannot resolve parent directory: {error}"))?; + if !canonical_parent.is_dir() { + return Err(format!("Not a directory: {}", parent.display())); + } + + Ok(canonical_parent.join(file_name)) +} + #[tauri::command] async fn read_file_direct(path: String) -> Result { let canonical = validate_preview_path(&path)?; @@ -1853,32 +4031,17 @@ async fn read_file_direct(path: String) -> Result { return Err(format!("Not a file: {}", path)); } - let content = fs::read_to_string(&canonical) + tauri::async_runtime::spawn_blocking(move || read_file_content_from_path(&canonical)) .await - .map_err(|_| "Failed to read file".to_string())?; - let metadata = fs::metadata(&canonical) - .await - .map_err(|_| "Failed to read metadata".to_string())?; - - let modified = metadata - .modified() - .ok() - .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|d| d.as_secs() as i64) - .unwrap_or(0); - - let title = extract_title(&content); - - Ok(FileContent { - path, - content, - title, - modified, - }) + .map_err(|error| format!("Direct file read task failed: {error}"))? } #[tauri::command] -async fn save_file_direct(path: String, content: String) -> Result { +async fn save_file_direct( + path: String, + content: String, + expected_revision: String, +) -> Result { // For save, the file must already exist (we validate extension + path security) let canonical = validate_preview_path(&path)?; @@ -1886,34 +4049,29 @@ async fn save_file_direct(path: String, content: String) -> Result Result { + let create_path = validate_preview_create_path(&path)?; - Ok(FileContent { - path, - content, - title, - modified, + tauri::async_runtime::spawn_blocking(move || { + recreate_file_content_to_path(&create_path, content) }) + .await + .map_err(|error| format!("Direct file recreation task failed: {error}"))? } #[tauri::command] async fn import_file_to_folder( app: AppHandle, path: String, + window: WebviewWindow, state: State<'_, AppState>, ) -> Result { let source = validate_preview_path(&path)?; @@ -1921,13 +4079,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 @@ -1973,7 +4126,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) @@ -1982,7 +4135,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); } @@ -2005,31 +4158,37 @@ 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.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) } #[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()) + search_index + .search(&trimmed_query, 20) + .map_err(|e| e.to_string()) }) }; @@ -2037,34 +4196,32 @@ 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 + eprintln!( + "Tantivy search error, falling back to substring search: {}", + e + ); + 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| { @@ -2118,27 +4275,165 @@ async fn fallback_search(query: &str, state: &State<'_, AppState>) -> Result, + results.sort_by(|a, b| { + b.score + .partial_cmp(&a.score) + .unwrap_or(std::cmp::Ordering::Equal) + }); + results.truncate(20); + + Ok(results) +} + +// File watcher event payload +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +struct FileChangeEvent { + workspace: String, + kind: String, + path: String, + changed_ids: Vec, + 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( @@ -2147,8 +4442,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() }; @@ -2158,23 +4467,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", @@ -2183,56 +4475,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; + }); } } }, @@ -2251,26 +4523,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(()) } @@ -2283,6 +4552,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 @@ -2290,13 +4560,7 @@ 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 @@ -2342,15 +4606,10 @@ 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() { @@ -2407,21 +4666,16 @@ 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) @@ -2435,8 +4689,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, @@ -2529,53 +4781,51 @@ 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) => { - 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(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 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] -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) => { - 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, @@ -2585,20 +4835,19 @@ 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) => { - 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, @@ -2608,19 +4857,20 @@ 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) => { - 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, @@ -2631,20 +4881,19 @@ 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) => { - 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, @@ -2654,20 +4903,22 @@ 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) => { - 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, @@ -2677,20 +4928,22 @@ 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) => { - 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, @@ -2700,19 +4953,20 @@ 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) => { - 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, @@ -2723,11 +4977,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) => { @@ -2736,10 +4993,9 @@ async fn git_push_with_upstream(state: State<'_, AppState>) -> Result { - 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, @@ -2871,26 +5127,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, @@ -2929,8 +5201,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. @@ -2941,8 +5213,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))?; @@ -3226,12 +5497,10 @@ 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") { @@ -3294,12 +5563,10 @@ 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") { @@ -3361,12 +5628,10 @@ 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") { @@ -3467,7 +5732,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(), + ), }); } } @@ -3497,8 +5765,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::() { @@ -3506,23 +5774,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) { @@ -3530,7 +5806,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) }; @@ -3539,10 +5815,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 } @@ -3558,14 +5834,150 @@ fn is_markdown_extension(path: &Path) -> bool { .unwrap_or(false) } +fn should_hide_main_window_for_standalone_preview( + opened_preview: bool, + has_notes_folder: bool, +) -> bool { + opened_preview && has_notes_folder +} + +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 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) +} + +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"); + + let app_state_ready = app.try_state::().is_some(); + if !should_defer_open_request(app_state_ready) { + return Some(paths); + } + + pending.extend(paths); + None +} + +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 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, + url: WebviewUrl, +) -> tauri::utils::config::WindowConfig { + let mut runtime = template.clone(); + runtime.label = label.to_string(); + runtime.url = url; + runtime.create = false; + runtime.visible = true; + runtime +} + +fn create_preferences_window(app: &AppHandle) -> Result<(), String> { + if let Some(window) = app.get_webview_window("preferences") { + let _ = window.show(); + window.set_focus().map_err(|error| error.to_string())?; + return Ok(()); + } + + let runtime_config = runtime_window_config_from_template( + &app.config().app.windows[0], + "preferences", + WebviewUrl::App("index.html?mode=preferences".into()), + ); + let window = WebviewWindowBuilder::from_config(app, &runtime_config) + .map_err(|error| format!("Failed to configure Preferences window: {error}"))? + .title("Preferences") + .inner_size(900.0, 650.0) + .min_inner_size(720.0, 500.0) + .resizable(true) + .decorations(true) + .center() + .build() + .map_err(|error| format!("Failed to create Preferences window: {error}"))?; + let _ = window.show(); + window.set_focus().map_err(|error| error.to_string())?; + Ok(()) +} + +#[tauri::command] +fn open_preferences_window(app: AppHandle) -> Result<(), String> { + create_preferences_window(&app) +} + // 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) { @@ -3582,18 +5994,19 @@ fn create_preview_window(app: &AppHandle, file_path: &str) -> Result<(), String> let encoded_path = urlencoding::encode(file_path); let url = format!("index.html?mode=preview&file={}", encoded_path); - let builder = WebviewWindowBuilder::new(app, &label, WebviewUrl::App(url.into())) + let runtime_config = runtime_window_config_from_template( + &app.config().app.windows[0], + &label, + WebviewUrl::App(url.into()), + ); + let builder = WebviewWindowBuilder::from_config(app, &runtime_config) + .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) .resizable(true) .decorations(true); - #[cfg(target_os = "macos")] - let builder = builder - .title_bar_style(tauri::TitleBarStyle::Overlay) - .hidden_title(true); - let window = builder .build() .map_err(|e| format!("Failed to create preview window: {}", e))?; @@ -3623,6 +6036,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 { @@ -3643,11 +6063,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::(); @@ -3656,7 +6072,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) => { @@ -3714,13 +6130,29 @@ pub fn run() { let app = tauri::Builder::default() // Single-instance: forward CLI args from subsequent launches to the running instance .plugin(tauri_plugin_single_instance::init(|app, args, cwd| { - handle_cli_args(app, &args, &cwd); + 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 { + request_full_window_closure_for_preview(app); + } + hide_main_window_for_standalone_preview(app, opened_preview); + } })) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_clipboard_manager::init()) .plugin(tauri_plugin_updater::Builder::new().build()) + .menu(build_application_menu) + .on_menu_event(|app, event| { + if event.id() == native_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}"); + } + } + }) .setup(|app| { // Load app config on startup (contains notes folder path) let mut app_config = load_app_config(app.handle()); @@ -3738,7 +6170,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; @@ -3747,72 +6182,140 @@ 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); // 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); } // 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 { + request_full_window_closure_for_preview(app.handle()); + } - if let Some(main_window) = app.get_webview_window("main") { - let has_notes_folder = app - .state::() - .app_config - .read() - .expect("app_config read lock") - .notes_folder - .is_some(); - - if opened_preview && has_notes_folder { - // 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 @@ -3824,6 +6327,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(); @@ -3839,12 +6347,27 @@ pub fn run() { }) .invoke_handler(tauri::generate_handler![ get_notes_folder, + get_window_session, + close_window_after_save, + 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, + duplicate_note, list_folders, create_folder, delete_folder, @@ -3852,6 +6375,8 @@ pub fn run() { move_note, move_folder, get_settings, + update_global_settings, + update_workspace_settings, update_settings, update_git_enabled, preview_note_name, @@ -3887,6 +6412,7 @@ pub fn run() { ai_execute_ollama, read_file_direct, save_file_direct, + recreate_file_direct, import_file_to_folder, open_file_preview, install_cli, @@ -3900,17 +6426,29 @@ 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 { - for url in urls { - if let Ok(path) = url.to_file_path() { - if is_markdown_extension(&path) - && path.is_file() - && !try_select_in_notes_folder(_app_handle, &path) - { - let _ = create_preview_window(_app_handle, &path.to_string_lossy()); - } + 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 { + request_full_window_closure_for_preview(_app_handle); + } + hide_main_window_for_standalone_preview(_app_handle, opened_preview); } } }); @@ -3944,8 +6482,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| { @@ -3974,7 +6511,7 @@ mod windows_title_bar { #[tauri::command] fn set_title_bar_theme( - app: AppHandle, + window: WebviewWindow, is_dark: bool, r: u8, g: u8, @@ -3982,15 +6519,1367 @@ 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 settings_tests { + use super::{ + 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"); + + assert_eq!(settings.sidebar_sort_order.as_deref(), Some("oldest")); + + let serialized = serde_json::to_value(settings).expect("settings should serialize"); + assert_eq!(serialized["sidebarSortOrder"], "oldest"); + } + + #[test] + fn split_and_merge_keep_theme_global_and_pins_workspace_scoped() { + let settings: Settings = serde_json::from_str( + r#"{ + "theme":{"mode":"dark"}, + "editorWidth":"wide", + "gitEnabled":true, + "pinnedNoteIds":["Plans/Roadmap"], + "foldersEnabled":true + }"#, + ) + .expect("settings should deserialize"); + + 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)); + } + + #[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 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 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 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_eq!( + global, + serde_json::json!({"scope":"global","workspace":null}) + ); + assert_eq!( + workspace, + serde_json::json!({"scope":"workspace","workspace":"/notes/shared"}) + ); + } + + #[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 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, 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"); + + 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()) + ); + } + + #[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)] +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_menu_tests { + use super::{ + focused_window_target, is_full_editor_window, native_open_folder_menu_spec, + native_preferences_menu_spec, + }; + + #[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 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") + ); + } + + #[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, PathBuf}; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{Arc, Barrier, Mutex, RwLock}; + + use super::{ + 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] + 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"); + } + + #[test] + fn recreating_a_deleted_note_preserves_its_exact_nested_id() { + let root = std::env::temp_dir().join(format!( + "scratch-recreate-deleted-note-test-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(root.join("archive")).expect("create nested folder"); + + let state = AppState::default(); + state.register_workspace_session( + "workspace-recovery", + WorkspaceSession::empty(root.to_string_lossy().into_owned()), + ); + + let first = tauri::async_runtime::block_on(async { + let workspace = state.workspace_for_window("workspace-recovery").unwrap(); + save_note_in_workspace( + Some("archive/Original Name".to_string()), + "# Changed title\n\nRecovered draft\n".to_string(), + None, + &workspace, + ) + .await + .expect("recreate missing note") + }); + + let saved = match first { + super::NoteSaveResult::Saved { note } => note, + super::NoteSaveResult::Conflict { .. } => panic!("missing note should be recreated"), + }; + assert_eq!(saved.id, "archive/Original Name"); + assert_eq!( + std::fs::read_to_string(root.join("archive/Original Name.md")).unwrap(), + "# Changed title\n\nRecovered draft\n" + ); + assert!(!root.join("archive/Changed title.md").exists()); + + let second = tauri::async_runtime::block_on(async { + let workspace = state.workspace_for_window("workspace-recovery").unwrap(); + save_note_in_workspace( + Some("archive/Original Name".to_string()), + "# Hijack\n\nMust not replace\n".to_string(), + None, + &workspace, + ) + .await + .expect("return typed create-only conflict") + }); + assert!(matches!(second, super::NoteSaveResult::Conflict { .. })); + assert_eq!( + std::fs::read_to_string(root.join("archive/Original Name.md")).unwrap(), + "# Changed title\n\nRecovered draft\n" + ); + + std::fs::remove_dir_all(root).expect("cleanup recreate 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"); + } +} diff --git a/src-tauri/src/note_persistence_tests.rs b/src-tauri/src/note_persistence_tests.rs new file mode 100644 index 00000000..c264075a --- /dev/null +++ b/src-tauri/src/note_persistence_tests.rs @@ -0,0 +1,586 @@ +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/persistence.rs b/src-tauri/src/persistence.rs new file mode 100644 index 00000000..646b0806 --- /dev/null +++ b/src-tauri/src/persistence.rs @@ -0,0 +1,517 @@ +use crate::hashing::ContentRevision; +use std::collections::HashMap; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, OnceLock, Weak}; + +static NEXT_TEMPORARY_FILE: AtomicU64 = AtomicU64::new(0); +static PATH_LOCKS: OnceLock>>>> = OnceLock::new(); + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FileSnapshot { + pub revision: ContentRevision, + pub content: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum SaveResult { + Saved { revision: ContentRevision }, + Conflict { current: Option }, +} + +/// Returns a deterministic revision suitable for optimistic concurrency checks. +pub fn content_revision(content: &str) -> ContentRevision { + crate::hashing::content_revision(content) +} + +/// Saves `content` only when the file still has `expected_revision`. +/// +/// `None` means the caller expects the file not to exist. A stale or missing +/// current revision is returned as a typed conflict and never mutates `path`. +pub fn save_if_revision( + path: impl AsRef, + content: &str, + expected_revision: Option<&ContentRevision>, +) -> io::Result { + let path = path.as_ref(); + let path_lock = lock_for_path(path); + let _save_guard = path_lock + .lock() + .map_err(|_| io::Error::other("note persistence lock poisoned"))?; + + let current = read_snapshot(path)?; + let revision_matches = match (expected_revision, current.as_ref()) { + (None, None) => true, + (Some(expected), Some(snapshot)) => expected == &snapshot.revision, + _ => false, + }; + + if !revision_matches { + return Ok(SaveResult::Conflict { current }); + } + + let next_revision = content_revision(content); + if current + .as_ref() + .is_some_and(|snapshot| snapshot.revision == next_revision) + { + return Ok(SaveResult::Saved { + revision: next_revision, + }); + } + + let write_result = if expected_revision.is_none() { + atomic_create_new(path, content.as_bytes()) + } else { + atomic_write(path, content.as_bytes()) + }; + + if let Err(error) = write_result { + if expected_revision.is_none() && error.kind() == io::ErrorKind::AlreadyExists { + return Ok(SaveResult::Conflict { + current: read_snapshot(path)?, + }); + } + return Err(error); + } + + Ok(SaveResult::Saved { + revision: next_revision, + }) +} + +pub(crate) fn read_snapshot(path: &Path) -> io::Result> { + match fs::read_to_string(path) { + Ok(content) => Ok(Some(FileSnapshot { + revision: content_revision(&content), + content, + })), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error), + } +} + +fn lock_for_path(path: &Path) -> Arc> { + let key = lock_key(path); + let locks = PATH_LOCKS.get_or_init(|| Mutex::new(HashMap::new())); + let mut locks = locks + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + locks.retain(|_, lock| lock.strong_count() > 0); + + if let Some(lock) = locks.get(&key).and_then(Weak::upgrade) { + return lock; + } + + let lock = Arc::new(Mutex::new(())); + locks.insert(key, Arc::downgrade(&lock)); + lock +} + +fn lock_key(path: &Path) -> PathBuf { + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir().unwrap_or_default().join(path) + }; + + 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 +} + +fn atomic_write(path: &Path, bytes: &[u8]) -> io::Result<()> { + let parent = path.parent().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "atomic save target has no parent directory", + ) + })?; + let existing_permissions = fs::metadata(path) + .ok() + .map(|metadata| metadata.permissions()); + let (mut temporary_file, mut temporary_path) = create_temporary_file(path, parent)?; + + temporary_file.write_all(bytes)?; + temporary_file.flush()?; + if let Some(permissions) = existing_permissions { + temporary_file.set_permissions(permissions)?; + } + temporary_file.sync_all()?; + drop(temporary_file); + + fs::rename(temporary_path.path(), path)?; + temporary_path.commit(); + sync_parent_directory(parent)?; + Ok(()) +} + +/// Publishes a brand-new file without ever replacing an existing directory +/// entry. The hard-link operation is atomic within the destination directory: +/// another process either wins first or receives `AlreadyExists`. +/// Filesystems without hard-link support fall back to a direct create-only +/// publication so that create-new saves still succeed. +fn atomic_create_new(path: &Path, bytes: &[u8]) -> io::Result<()> { + let parent = path.parent().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "atomic create target has no parent directory", + ) + })?; + let (mut temporary_file, mut temporary_path) = create_temporary_file(path, parent)?; + + temporary_file.write_all(bytes)?; + temporary_file.flush()?; + temporary_file.sync_all()?; + drop(temporary_file); + + match fs::hard_link(temporary_path.path(), path) { + Ok(()) => { + fs::remove_file(temporary_path.path())?; + temporary_path.commit(); + sync_parent_directory(parent)?; + return Ok(()); + } + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => { + return Err(error); + } + Err(_) => {} + } + + 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(()) +} + +fn create_temporary_file(path: &Path, parent: &Path) -> io::Result<(File, TemporaryPath)> { + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("note"); + + loop { + let sequence = NEXT_TEMPORARY_FILE.fetch_add(1, Ordering::Relaxed); + let temporary_path = parent.join(format!( + ".{file_name}.scratch-save-{}-{sequence}.tmp", + std::process::id() + )); + + match OpenOptions::new() + .write(true) + .create_new(true) + .open(&temporary_path) + { + Ok(file) => return Ok((file, TemporaryPath::new(temporary_path))), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, + Err(error) => return Err(error), + } + } +} + +struct TemporaryPath { + path: PathBuf, + committed: bool, +} + +impl TemporaryPath { + fn new(path: PathBuf) -> Self { + Self { + path, + committed: false, + } + } + + fn path(&self) -> &Path { + &self.path + } + + fn commit(&mut self) { + self.committed = true; + } +} + +impl Drop for TemporaryPath { + fn drop(&mut self) { + if !self.committed { + let _ = fs::remove_file(&self.path); + } + } +} + +#[cfg(unix)] +fn sync_parent_directory(parent: &Path) -> io::Result<()> { + File::open(parent)?.sync_all() +} + +#[cfg(not(unix))] +fn sync_parent_directory(_parent: &Path) -> io::Result<()> { + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::path::{Path, PathBuf}; + use std::sync::atomic::{AtomicU64, Ordering}; + + static NEXT_TEST_DIRECTORY: AtomicU64 = AtomicU64::new(0); + + struct TestDirectory { + path: PathBuf, + } + + impl TestDirectory { + fn new(test_name: &str) -> Self { + let sequence = NEXT_TEST_DIRECTORY.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "scratch-persistence-{test_name}-{}-{sequence}", + std::process::id() + )); + fs::create_dir_all(&path).expect("create persistence test directory"); + Self { path } + } + + fn note_path(&self) -> PathBuf { + self.path.join("note.md") + } + } + + impl Drop for TestDirectory { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } + } + + fn temporary_artifacts(parent: &Path) -> Vec { + fs::read_dir(parent) + .expect("read test directory") + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.contains(".scratch-save-")) + }) + .collect() + } + + #[test] + fn revision_is_deterministic_and_uses_known_sha256_values() { + assert_eq!( + content_revision("").as_str(), + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + assert_eq!( + content_revision("abc").as_str(), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + assert_eq!(content_revision("same"), content_revision("same")); + assert_ne!(content_revision("same"), content_revision("changed")); + } + + #[test] + fn save_to_missing_file_is_atomic_and_returns_new_revision() { + let directory = TestDirectory::new("initial-save"); + let path = directory.note_path(); + + let result = save_if_revision(&path, "first version", None).expect("save new note"); + + assert_eq!( + result, + SaveResult::Saved { + revision: content_revision("first version") + } + ); + assert_eq!(fs::read_to_string(&path).unwrap(), "first version"); + assert!(temporary_artifacts(&directory.path).is_empty()); + } + + #[test] + fn stale_revision_returns_current_snapshot_without_modifying_final_file() { + let directory = TestDirectory::new("stale-revision"); + let path = directory.note_path(); + fs::write(&path, "version one").unwrap(); + let stale_revision = content_revision("version one"); + fs::write(&path, "version two from another window").unwrap(); + + let result = save_if_revision(&path, "stale local edit", Some(&stale_revision)) + .expect("conflict is a typed result"); + + assert_eq!( + result, + SaveResult::Conflict { + current: Some(FileSnapshot { + revision: content_revision("version two from another window"), + content: "version two from another window".to_string(), + }) + } + ); + assert_eq!( + fs::read_to_string(&path).unwrap(), + "version two from another window" + ); + assert!(temporary_artifacts(&directory.path).is_empty()); + } + + #[test] + fn expected_revision_for_deleted_file_returns_missing_conflict() { + let directory = TestDirectory::new("deleted-file"); + let path = directory.note_path(); + let deleted_revision = content_revision("deleted elsewhere"); + + let result = save_if_revision(&path, "local edit", Some(&deleted_revision)).unwrap(); + + assert_eq!(result, SaveResult::Conflict { current: None }); + assert!(!path.exists()); + } + + #[test] + fn create_only_save_refuses_to_replace_existing_file() { + let directory = TestDirectory::new("create-only"); + let path = directory.note_path(); + fs::write(&path, "already exists").unwrap(); + + let result = save_if_revision(&path, "new content", None).unwrap(); + + assert_eq!( + result, + SaveResult::Conflict { + current: Some(FileSnapshot { + revision: content_revision("already exists"), + content: "already exists".to_string(), + }) + } + ); + assert_eq!(fs::read_to_string(&path).unwrap(), "already exists"); + } + + #[test] + fn atomic_create_new_never_replaces_an_external_file() { + let directory = TestDirectory::new("external-create-race"); + let path = directory.note_path(); + fs::write(&path, "created by another process").unwrap(); + + let error = atomic_create_new(&path, b"local draft") + .expect_err("create-only publication must reject an existing destination"); + + assert_eq!(error.kind(), io::ErrorKind::AlreadyExists); + assert_eq!( + fs::read_to_string(&path).unwrap(), + "created by another process" + ); + assert!(temporary_artifacts(&directory.path).is_empty()); + } + + #[cfg(unix)] + #[test] + fn atomic_create_new_never_replaces_a_dangling_symlink() { + use std::os::unix::fs::symlink; + + let directory = TestDirectory::new("dangling-symlink-race"); + let path = directory.note_path(); + let missing_target = directory.path.join("missing.md"); + symlink(&missing_target, &path).unwrap(); + + let error = atomic_create_new(&path, b"local draft") + .expect_err("create-only publication must preserve a dangling symlink"); + + assert_eq!(error.kind(), io::ErrorKind::AlreadyExists); + assert_eq!(fs::read_link(&path).unwrap(), missing_target); + assert!(temporary_artifacts(&directory.path).is_empty()); + } + + #[test] + fn simultaneous_saves_with_same_revision_have_one_winner_and_one_conflict() { + let directory = TestDirectory::new("simultaneous-save"); + let path = directory.note_path(); + fs::write(&path, "base").unwrap(); + let expected = content_revision("base"); + + let first_path = path.clone(); + let first_expected = expected.clone(); + let first = std::thread::spawn(move || { + save_if_revision(&first_path, "edit from window one", Some(&first_expected)) + .expect("first concurrent save") + }); + + let second_path = path.clone(); + let second_expected = expected.clone(); + let second = std::thread::spawn(move || { + save_if_revision(&second_path, "edit from window two", Some(&second_expected)) + .expect("second concurrent save") + }); + + let results = [first.join().unwrap(), second.join().unwrap()]; + assert_eq!( + results + .iter() + .filter(|result| matches!(result, SaveResult::Saved { .. })) + .count(), + 1 + ); + assert_eq!( + results + .iter() + .filter(|result| matches!(result, SaveResult::Conflict { .. })) + .count(), + 1 + ); + + let final_content = fs::read_to_string(&path).unwrap(); + let saved_revision = results + .iter() + .find_map(|result| match result { + SaveResult::Saved { revision } => Some(revision), + SaveResult::Conflict { .. } => None, + }) + .unwrap(); + assert_eq!(&content_revision(&final_content), saved_revision); + assert!(temporary_artifacts(&directory.path).is_empty()); + } + + #[test] + fn atomic_write_removes_temporary_file_when_rename_fails() { + let directory = TestDirectory::new("rename-error"); + let destination_is_a_directory = directory.path.join("destination"); + fs::create_dir(&destination_is_a_directory).unwrap(); + + let error = atomic_write(&destination_is_a_directory, b"content") + .expect_err("renaming a file over a directory must fail"); + + assert_ne!(error.kind(), std::io::ErrorKind::NotFound); + assert!(destination_is_a_directory.is_dir()); + assert!(temporary_artifacts(&directory.path).is_empty()); + } +} diff --git a/src-tauri/src/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 0e5224e9..9d09df22 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -29,13 +29,21 @@ 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 { closeWindowAfterSave, requestCurrentWindowClose } from "./services/windowLifecycle"; +import { shouldSyncMainFolderLocation } from "./lib/workspace"; +import { runSafeWindowClose } from "./lib/windowClose"; +import { useWindowSessionPersistence } from "./lib/useWindowSessionPersistence"; +import { useWindowShortcuts } from "./lib/useWindowShortcuts"; // Detect preview mode from URL search params function getWindowMode(): { isPreview: boolean; + isPreferences: boolean; previewFile: string | null; } { const params = new URLSearchParams(window.location.search); @@ -43,6 +51,7 @@ function getWindowMode(): { const file = params.get("file"); return { isPreview: mode === "preview" && !!file, + isPreferences: mode === "preferences", previewFile: file, }; } @@ -63,10 +72,12 @@ function AppContent() { reloadCurrentNote, currentNote, syncNotesFolder, + flushCurrentDraft, + persistCurrentDraftRecovery, + restoredWindowSession, + isWindowSessionRestored, } = useNotes(); - const { interfaceZoom, setInterfaceZoom, reloadSettings } = useTheme(); - const interfaceZoomRef = useRef(interfaceZoom); - interfaceZoomRef.current = interfaceZoom; + const { reloadSettings } = useTheme(); const currentNoteRef = useRef(currentNote); currentNoteRef.current = currentNote; const [paletteOpen, setPaletteOpen] = useState(false); @@ -78,6 +89,60 @@ function AppContent() { const [focusMode, setFocusMode] = useState(false); const [aiProvider, setAiProvider] = useState("claude"); const editorRef = useRef(null); + const closeInProgressRef = useRef(false); + const flushWindowSession = useWindowSessionPersistence({ + notesFolder, + selectedNoteId, + sidebarVisible, + setSidebarVisible, + focusMode, + setFocusMode, + restoredSession: restoredWindowSession, + isRestored: isWindowSessionRestored, + }); + + 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: flushCurrentDraft, + persistRecovery: () => + persistCurrentDraftRecovery("window-close"), + closeWindow: async () => { + await flushWindowSession(); + await closeWindowAfterSave(); + }, + }) + .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; + }); + + return () => { + 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 @@ -85,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) => { @@ -113,14 +179,37 @@ function AppContent() { }); }, [selectedNoteId]); - const toggleSettings = useCallback(() => { - setView((prev) => (prev === "settings" ? "notes" : "settings")); - }, []); + const openSettings = useCallback(async () => { + if (view === "settings") return; + try { + await flushCurrentDraft(); + } catch (error) { + console.error("Failed to flush draft before opening Settings:", error); + toast.error("Settings could not be opened because the current draft could not be saved"); + return; + } + setView("settings"); + }, [flushCurrentDraft, view]); + + const toggleSettings = useCallback(async () => { + if (view === "notes") { + try { + await flushCurrentDraft(); + } catch (error) { + console.error("Failed to flush draft before opening Settings:", error); + toast.error("Settings could not be opened because the current draft could not be saved"); + return; + } + } + setView((previous) => (previous === "settings" ? "notes" : "settings")); + }, [flushCurrentDraft, view]); const closeSettings = useCallback(() => { setView("notes"); }, []); + useWindowShortcuts({ onOpenPreferences: openSettings }); + // Go back to command palette from AI modal const handleBackToPalette = useCallback(() => { setAiModalOpen(false); @@ -206,39 +295,6 @@ function AppContent() { const isEditorEmpty = isInEditor && currentNoteRef.current?.content.trim() === ""; - // Cmd+, - Toggle settings (always works, even in settings) - if ((e.metaKey || e.ctrlKey) && e.key === ",") { - e.preventDefault(); - toggleSettings(); - return; - } - - // Cmd+= or Cmd++ - Zoom in (works everywhere, including settings) - if ((e.metaKey || e.ctrlKey) && (e.key === "=" || e.key === "+")) { - e.preventDefault(); - setInterfaceZoom((prev) => prev + 0.05); - const newZoom = Math.round(Math.min(interfaceZoomRef.current + 0.05, 1.5) * 20) / 20; - toast(`Zoom ${Math.round(newZoom * 100)}%`, { id: "zoom", duration: 1500 }); - return; - } - - // Cmd+- - Zoom out (works everywhere, including settings) - if ((e.metaKey || e.ctrlKey) && (e.key === "-" || e.key === "_")) { - e.preventDefault(); - setInterfaceZoom((prev) => prev - 0.05); - const newZoom = Math.round(Math.max(interfaceZoomRef.current - 0.05, 0.7) * 20) / 20; - toast(`Zoom ${Math.round(newZoom * 100)}%`, { id: "zoom", duration: 1500 }); - return; - } - - // Cmd+0 - Reset zoom (works everywhere, including settings) - if ((e.metaKey || e.ctrlKey) && e.key === "0") { - e.preventDefault(); - setInterfaceZoom(1.0); - toast("Zoom 100%", { id: "zoom", duration: 1500 }); - return; - } - // Block all other shortcuts when in settings view if (view === "settings") { return; @@ -443,9 +499,43 @@ function AppContent() { toggleFocusMode, focusMode, view, - setInterfaceZoom, ]); + // 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(); @@ -635,15 +725,28 @@ 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(() => { const handleKeyDown = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === "w") { e.preventDefault(); - getCurrentWindow().close().catch(console.error); + void requestCurrentWindowClose().catch(console.error); } }; window.addEventListener("keydown", handleKeyDown); @@ -658,10 +761,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 32a8159c..c9b75b84 100644 --- a/src/components/editor/Editor.tsx +++ b/src/components/editor/Editor.tsx @@ -35,6 +35,7 @@ import tippy, { type Instance as TippyInstance } from "tippy.js"; import { open as openDialog } from "@tauri-apps/plugin-dialog"; import { openUrl } from "@tauri-apps/plugin-opener"; import { invoke, convertFileSrc } from "@tauri-apps/api/core"; +import { getCurrentWindow } from "@tauri-apps/api/window"; import { join } from "@tauri-apps/api/path"; import { toast } from "sonner"; import { mod, alt, shift, isMac, isWindows } from "../../lib/platform"; @@ -71,8 +72,17 @@ 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 { 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"; +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 { @@ -428,15 +438,30 @@ function FormatBar({ } // Data source for preview mode — bypasses NotesContext +export interface EditorPersistenceController { + flush: () => Promise; + getDraft: () => { + noteId: string | null; + content: string; + dirty: boolean; + }; +} + export interface PreviewModeData { content: string | null; title: string; filePath: string; modified: number; + revision: string; hasExternalChanges: boolean; + hasSaveConflict: boolean; reloadVersion: number; save: (content: string) => Promise; reload: () => Promise; + resolveConflict: (strategy: ConflictResolutionStrategy) => Promise; + registerPersistenceController: ( + controller: EditorPersistenceController, + ) => () => void; } interface EditorProps { @@ -447,6 +472,9 @@ interface EditorProps { onEditorReady?: (editor: TiptapEditor | null) => void; onSaveToFolder?: () => void; saveToFolderDisabled?: boolean; + onPersistenceControllerReady?: ( + controller: EditorPersistenceController | null, + ) => void; } /** @@ -510,6 +538,7 @@ export function Editor({ previewMode, onSaveToFolder, saveToFolderDisabled, + onPersistenceControllerReady, }: EditorProps) { // Always call the hook (rules of hooks), but it returns null outside NotesProvider const notesCtx = useOptionalNotes(); @@ -522,6 +551,7 @@ export function Editor({ content: previewMode.content, path: previewMode.filePath, modified: previewMode.modified, + revision: previewMode.revision, } : null : (notesCtx?.currentNote ?? null); @@ -531,27 +561,53 @@ 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 + : Boolean(notesCtx!.noteConflict); const reloadCurrentNote = previewMode ? previewMode.reload : notesCtx!.reloadCurrentNote; + const resolveNoteConflict = previewMode + ? previewMode.resolveConflict + : notesCtx!.resolveNoteConflict; const reloadVersion = previewMode ? previewMode.reloadVersion : notesCtx!.reloadVersion; const pinNote = notesCtx?.pinNote; const unpinNote = notesCtx?.unpinNote; const notes = notesCtx?.notes; - const { textDirection } = useTheme(); + const { + textDirection, + editorWidthResizeEnabled, + editorToolbarVisible, + titleBarModifiedDateVisible, + titleBarFilenameVisible, + } = useTheme(); const [isSaving, setIsSaving] = useState(false); + const [isDirty, setIsDirty] = useState(false); // Force re-render when selection changes to update toolbar active states const [, setSelectionKey] = useState(0); const [copyMenuOpen, setCopyMenuOpen] = useState(false); const [settings, setSettings] = useState(null); + const titleBarNoteInfo = currentNote + ? getTitleBarNoteInfoText( + { + modifiedDateVisible: titleBarModifiedDateVisible, + filenameVisible: titleBarFilenameVisible, + }, + currentNote, + formatDateTime, + ) + : null; // Delay transition classes until after initial mount to avoid format bar height animation on note load const [hasTransitioned, setHasTransitioned] = useState(false); useEffect(() => { @@ -568,6 +624,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; @@ -584,6 +643,25 @@ export function Editor({ const [currentMatchIndex, setCurrentMatchIndex] = useState(0); const searchInputRef = useRef(null); const saveTimeoutRef = useRef(null); + const checkpointCaptureTimerRef = useRef(null); + const checkpointCaptureStartedAtRef = useRef(null); + const queueCheckpointCaptureRef = useRef<() => void>(() => undefined); + const checkpointSchedulerRef = useRef(null); + if (!checkpointSchedulerRef.current) { + checkpointSchedulerRef.current = createDraftCheckpointScheduler( + { + write: draftCheckpointService.writeDraftCheckpoint, + clear: draftCheckpointService.clearDraftCheckpoint, + }, + { + delayMs: 250, + onError: (error) => { + console.error("Failed to persist crash checkpoint:", error); + }, + }, + ); + } + const checkpointScheduler = checkpointSchedulerRef.current; const linkPopupRef = useRef(null); const blockMathPopupRef = useRef(null); const isLoadingRef = useRef(false); @@ -592,6 +670,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; @@ -600,6 +679,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( @@ -630,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; @@ -732,8 +827,8 @@ export function Editor({ async (noteId: string, content: string) => { setIsSaving(true); try { - lastSaveRef.current = { noteId, content }; await saveNote(content, noteId); + lastSaveRef.current = { noteId, content }; } finally { setIsSaving(false); } @@ -750,11 +845,19 @@ export function Editor({ // Use loadedNoteIdRef (the note in the editor) not currentNoteIdRef (which may have changed) if (needsSaveRef.current && editorRef.current && loadedNoteIdRef.current) { - needsSaveRef.current = false; + const generation = saveGenerationRef.current; const markdown = getMarkdown(editorRef.current); await saveImmediately(loadedNoteIdRef.current, markdown); + if (saveGenerationRef.current === generation) { + needsSaveRef.current = false; + setIsDirty(sourceNeedsSaveRef.current); + await checkpointScheduler.handleSaveOutcome("saved", { + windowLabel: getCurrentWindow().label, + noteId: loadedNoteIdRef.current, + }); + } } - }, [saveImmediately, getMarkdown]); + }, [checkpointScheduler, saveImmediately, getMarkdown]); // Schedule a debounced save (markdown computed only when timer fires) const scheduleSave = useCallback(() => { @@ -766,6 +869,9 @@ export function Editor({ if (!savingNoteId) return; needsSaveRef.current = true; + setIsDirty(true); + queueCheckpointCaptureRef.current(); + const generation = ++saveGenerationRef.current; saveTimeoutRef.current = window.setTimeout(async () => { if (currentNoteIdRef.current !== savingNoteId || !needsSaveRef.current) { @@ -774,12 +880,153 @@ 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]); + }, 300); + }, [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]); + + useEffect(() => { + if (!registerWorkspaceTransitionFlush) return; + return registerWorkspaceTransitionFlush(flushAllPendingSaves); + }, [flushAllPendingSaves, registerWorkspaceTransitionFlush]); + + 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]); + + useEffect(() => { + if (!registerOpenNoteDraft) return; + return registerOpenNoteDraft(getOpenDraftSnapshot); + }, [getOpenDraftSnapshot, registerOpenNoteDraft]); + + 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]); + + const persistCurrentCrashCheckpointRef = useRef(persistCurrentCrashCheckpoint); + + useLayoutEffect(() => { + persistCurrentCrashCheckpointRef.current = persistCurrentCrashCheckpoint; + }, [persistCurrentCrashCheckpoint]); + + 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) { @@ -1579,21 +1826,31 @@ 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 () => { + 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); } - // 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 (checkpointSchedulerRef.current) { + void checkpointSchedulerRef.current.dispose().catch((error) => { + console.error("Failed to dispose checkpoint scheduler:", error); + }); } if (linkPopupRef.current) { linkPopupRef.current.destroy(); @@ -2121,15 +2378,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"); @@ -2139,7 +2410,7 @@ export function Editor({ } }, 300); }, - [currentNote, saveNote], + [checkpointScheduler, currentNote, saveNote], ); if (!currentNote) { @@ -2257,14 +2528,60 @@ export function Editor({ )} - - {formatDateTime(currentNote.modified)} - + {titleBarNoteInfo && ( + + {titleBarNoteInfo} + + )}
- {hasExternalChanges ? ( + {hasSaveConflict && resolveNoteConflict ? ( + + + + + + + + + { + void resolveNoteConflict("keepLocal").catch((error) => { + toast.error(`Conflict remains: ${error}`); + }); + }} + > + Keep My Changes + + { + void resolveNoteConflict("useRemote").catch((error) => { + toast.error(`Conflict remains: ${error}`); + }); + }} + > + Use Version on Disk + + +
+ A recovery copy is created before either action. +
+
+
+
+ ) : hasExternalChanges ? ( @@ -2282,6 +2599,15 @@ export function Editor({
+ ) : isDirty ? ( + +
+ +
+
) : (
@@ -2432,22 +2758,27 @@ export function Editor({
{/* Format Bar – transition only after initial mount to avoid height animation on note load */} -
- -
+ {editorToolbarVisible && ( +
+ +
+ )} {/* Editor content area with resize handles overlay */}
{!focusMode && !sourceMode && ( - + )}
{ + it("measures the rendered page instead of its unconstrained max-width", () => { + const container = document.createElement("div"); + const editor = document.createElement("div"); + editor.className = "ProseMirror"; + editor.style.maxWidth = "576px"; + editor.getBoundingClientRect = () => ({ + x: 37, + y: 0, + left: 37, + top: 0, + right: 563, + bottom: 800, + width: 526, + height: 800, + toJSON: () => ({}), + }); + Object.defineProperty(container, "clientWidth", { value: 600 }); + container.append(editor); + + expect(getRenderedEditorWidth(container)).toBe(526); + }); + + it("mounts no resize interaction when mouse resizing is disabled", () => { + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + + act(() => { + root.render( + ()} + />, + ); + }); + + expect(container.childElementCount).toBe(0); + + act(() => root.unmount()); + container.remove(); + }); +}); diff --git a/src/components/editor/EditorWidthHandle.tsx b/src/components/editor/EditorWidthHandle.tsx index 44ca86d6..1f532903 100644 --- a/src/components/editor/EditorWidthHandle.tsx +++ b/src/components/editor/EditorWidthHandle.tsx @@ -21,9 +21,33 @@ const SNAP_THRESHOLD = 20; interface EditorWidthHandlesProps { containerRef: RefObject; + enabled: boolean; } -export function EditorWidthHandles({ containerRef }: EditorWidthHandlesProps) { +export function getRenderedEditorWidth( + container: HTMLDivElement, +): number | null { + const proseMirror = container.querySelector(".ProseMirror"); + if (!proseMirror) return null; + + const renderedWidth = proseMirror.getBoundingClientRect().width; + if (!Number.isFinite(renderedWidth) || renderedWidth <= 0) return null; + + return Math.min(renderedWidth, container.clientWidth); +} + +export function EditorWidthHandles({ + containerRef, + enabled, +}: EditorWidthHandlesProps) { + if (!enabled) return null; + + return ; +} + +function ActiveEditorWidthHandles({ + containerRef, +}: Pick) { const { editorWidth, customEditorWidthPx, @@ -48,17 +72,10 @@ export function EditorWidthHandles({ containerRef }: EditorWidthHandlesProps) { const updateHandleOffset = useCallback(() => { if (!containerRef.current) return; const containerWidth = containerRef.current.clientWidth; - const proseMirror = - containerRef.current.querySelector(".ProseMirror"); - if (proseMirror) { - const maxWidth = getComputedStyle(proseMirror).maxWidth; - if (maxWidth && maxWidth !== "none") { - const editorPx = - maxWidth === "100%" ? containerWidth : parseFloat(maxWidth); - const clampedEditor = Math.min(editorPx, containerWidth); - setHandleOffset((containerWidth - clampedEditor) / 2); - return; - } + const renderedWidth = getRenderedEditorWidth(containerRef.current); + if (renderedWidth !== null) { + setHandleOffset((containerWidth - renderedWidth) / 2); + return; } setHandleOffset(0); }, [containerRef]); @@ -75,13 +92,8 @@ export function EditorWidthHandles({ containerRef }: EditorWidthHandlesProps) { const getCurrentEditorWidth = useCallback((): number => { if (!containerRef.current) return 768; - const proseMirror = containerRef.current.querySelector(".ProseMirror"); - if (proseMirror) { - const maxWidth = getComputedStyle(proseMirror).maxWidth; - if (maxWidth && maxWidth !== "none" && maxWidth !== "100%") { - return parseFloat(maxWidth); - } - } + const renderedWidth = getRenderedEditorWidth(containerRef.current); + if (renderedWidth !== null) return renderedWidth; if (editorWidth === "custom") return customEditorWidthPx; if (editorWidth === "full") return containerRef.current.clientWidth; const preset = PRESET_PX.find((p) => p.width === editorWidth); diff --git a/src/components/layout/Sidebar.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 2aa1d2db..58fbb6fe 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"; @@ -26,11 +29,28 @@ 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 { WorkspaceMenu } from "./WorkspaceMenu"; +import type { NoteSortOrder } from "../../types/note"; +import { SETTINGS_CHANGED_DOM_EVENT, shouldApplySettingsChange, type SettingsChangedEvent } from "../../lib/settingsScope"; 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, @@ -40,15 +60,21 @@ 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); const [folderDialogOpen, setFolderDialogOpen] = useState(false); const [folderDialogParent, setFolderDialogParent] = useState(""); 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()); @@ -58,6 +84,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(getWorkspaceSwitchErrorMessage(error)); + } + }, + [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 } }), @@ -165,16 +259,55 @@ 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", + ); }).catch((error) => { console.error("Failed to load settings:", error); setFoldersEnabled(false); }); }, []); + // Workspace settings update live in every window bound to that workspace. + useEffect(() => { + void loadWorkspaceSettings(); + const handleSettingsChanged = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (detail && !shouldApplySettingsChange(detail, notesFolder)) return; + void loadWorkspaceSettings(); + }; + window.addEventListener(SETTINGS_CHANGED_DOM_EVENT, handleSettingsChanged); + return () => + window.removeEventListener( + SETTINGS_CHANGED_DOM_EVENT, + handleSettingsChanged, + ); + }, [loadWorkspaceSettings, notesFolder]); + + const handleNoteSortOrderChange = useCallback( + async (nextSortOrder: NoteSortOrder) => { + if (nextSortOrder === noteSortOrder) return; + + const previousSortOrder = noteSortOrder; + setNoteSortOrder(nextSortOrder); + + try { + const patch: notesService.SettingsPatch = { sidebarSortOrder: nextSortOrder }; + await notesService.updateWorkspaceSettings(patch); + } 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); @@ -310,144 +443,156 @@ export function Sidebar({ onOpenSettings }: SidebarProps) { onDragEnd={handleDragEnd} onDragCancel={() => setDragLabel(null)} > -
- {/* Drag region */} - {!isWindows &&
} -
-
-
Notes
-
- {notes.length} +
+ {/* Drag region */} + {!isWindows &&
} + void handleSwitchWorkspace(path)} + onAddWorkspace={() => void handleAddWorkspace()} + onRemoveWorkspace={(path) => void handleRemoveWorkspace(path)} + /> +
+
+
Notes
+
+ {notes.length} +
-
-
- - {searchOpen ? ( - - ) : ( - - )} - - {foldersEnabled ? ( - - - - - - - - e.preventDefault()} - > - createNote()} - > - - New Note - - {mod} - {isMac ? "" : "+"}N - - - - - New Folder - - - - - ) : ( +
+ createNote()} - title={`New Note (${mod}${isMac ? "" : "+"}N)`} + onClick={toggleSearch} + title={`Search Notes (${mod}${isMac ? "" : "+"}${shift}${isMac ? "" : "+"}F)`} > - + {searchOpen ? ( + + ) : ( + + )} - )} + {foldersEnabled ? ( + + + + + + + + e.preventDefault()} + > + createNote()} + > + + New Note + + {mod} + {isMac ? "" : "+"}N + + + + + New Folder + + + + + ) : ( + createNote()} + title={`New Note (${mod}${isMac ? "" : "+"}N)`} + > + + + )} +
-
- {/* Scrollable area with search and notes */} -
- {/* Search - sticky at top */} - {searchOpen && ( -
-
- - {inputValue && ( - - )} + {/* Scrollable area with search and notes */} +
+ {/* Search - sticky at top */} + {searchOpen && ( +
+
+ + {inputValue && ( + + )} +
-
- )} + )} + + {/* Note list */} + +
- {/* Note list */} - + + {/* 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/layout/SidebarControls.test.tsx b/src/components/layout/SidebarControls.test.tsx new file mode 100644 index 00000000..8c2ea48f --- /dev/null +++ b/src/components/layout/SidebarControls.test.tsx @@ -0,0 +1,65 @@ +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { TooltipProvider } from "../ui"; +import { + NoteSortMenu, +} from "./SidebarControls"; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }) + .IS_REACT_ACT_ENVIRONMENT = true; + +afterEach(() => { + 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..27ad856d --- /dev/null +++ b/src/components/layout/SidebarControls.tsx @@ -0,0 +1,73 @@ +import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; +import type { NoteSortOrder } from "../../types/note"; +import { + ArrowDownIcon, + ArrowUpIcon, + CheckIcon, +} from "../icons"; +import { IconButton } from "../ui"; + +interface NoteSortMenuProps { + sortOrder: NoteSortOrder; + onChange: (sortOrder: NoteSortOrder) => void; +} + +const radioItemClass = + "relative flex cursor-pointer items-center gap-2 px-3 py-1.5 pr-8 text-sm text-text outline-none hover:bg-bg-muted focus:bg-bg-muted data-[state=checked]:font-medium"; + +export function NoteSortMenu({ + sortOrder, + onChange, +}: NoteSortMenuProps) { + const newestFirst = sortOrder === "newest"; + + return ( + + + + {newestFirst ? ( + + ) : ( + + )} + + + + + + Sort notes + + { + if (value === "newest" || value === "oldest") onChange(value); + }} + > + + + Newest first + + + + + + + Oldest first + + + + + + + + + ); +} diff --git a/src/components/layout/SidebarFolderSection.test.tsx b/src/components/layout/SidebarFolderSection.test.tsx new file mode 100644 index 00000000..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/layout/WorkspaceMenu.test.tsx b/src/components/layout/WorkspaceMenu.test.tsx new file mode 100644 index 00000000..13a6b644 --- /dev/null +++ b/src/components/layout/WorkspaceMenu.test.tsx @@ -0,0 +1,354 @@ +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"]', + ); + expect(document.activeElement).toBe(removeArchive); + 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("uses menu focus and arrow-key navigation", () => { + 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()); + + const currentWorkspace = container.querySelector( + 'button[role="menuitemradio"][data-workspace-path="/notes/main"]', + ); + const currentActions = container.querySelector( + 'button[aria-label="Actions for main"]', + ); + const addFolder = container.querySelector( + 'button[aria-label="Add Folder"]', + ); + expect(document.activeElement).toBe(currentWorkspace); + + act(() => { + currentWorkspace?.dispatchEvent( + new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }), + ); + }); + expect(document.activeElement).toBe(currentActions); + + act(() => { + currentActions?.dispatchEvent( + new KeyboardEvent("keydown", { key: "End", bubbles: true }), + ); + }); + expect(document.activeElement).toBe(addFolder); + + act(() => { + addFolder?.dispatchEvent( + new KeyboardEvent("keydown", { key: "Home", bubbles: true }), + ); + }); + expect(document.activeElement).toBe(currentWorkspace); + + act(() => { + currentWorkspace?.dispatchEvent( + new KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true }), + ); + }); + expect(document.activeElement).toBe(addFolder); + + 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..07347faf --- /dev/null +++ b/src/components/layout/WorkspaceMenu.tsx @@ -0,0 +1,285 @@ +import { + useEffect, + useRef, + useState, + type KeyboardEvent as ReactKeyboardEvent, +} 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 menuRef = 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 currentItem = menuRef.current?.querySelector( + '[role="menuitemradio"][aria-checked="true"]', + ); + const firstItem = menuRef.current?.querySelector( + '[role="menuitemradio"], [role="menuitem"]', + ); + (currentItem ?? firstItem)?.focus(); + }, [open]); + + useEffect(() => { + if (!actionMenuPath) return; + actionMenuRef.current + ?.querySelector('[role="menuitem"]') + ?.focus(); + }, [actionMenuPath]); + + 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]); + + const handleMenuKeyDown = (event: ReactKeyboardEvent) => { + const target = event.target as HTMLElement; + if (target.closest('[role="menu"]') !== event.currentTarget) return; + if ( + event.key !== "ArrowDown" && + event.key !== "ArrowUp" && + event.key !== "Home" && + event.key !== "End" + ) { + return; + } + + const items = Array.from( + event.currentTarget.querySelectorAll( + '[role="menuitemradio"], [role="menuitem"]', + ), + ).filter((item) => item.closest('[role="menu"]') === event.currentTarget); + if (items.length === 0) return; + + event.preventDefault(); + const currentIndex = items.indexOf(target); + let nextIndex: number; + if (event.key === "Home") nextIndex = 0; + else if (event.key === "End") nextIndex = items.length - 1; + else if (event.key === "ArrowUp") { + nextIndex = currentIndex > 0 ? currentIndex - 1 : items.length - 1; + } else { + nextIndex = currentIndex >= 0 && currentIndex < items.length - 1 + ? currentIndex + 1 + : 0; + } + items[nextIndex]?.focus(); + }; + + return ( +
+
+ +
+ + {open && ( +
+ {workspaces.length === 0 ? ( +
+ No folders in list +
+ ) : ( + workspaces.map((workspace, index) => ( +
+ + + {actionMenuPath === workspace.path && ( + + )} +
+ )) + )} +
+ +
+ )} +
+ ); +} 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..dc93c346 100644 --- a/src/components/notes/NoteList.tsx +++ b/src/components/notes/NoteList.tsx @@ -21,7 +21,9 @@ import { CopyIcon, TrashIcon, } from "../icons"; -import type { Settings } from "../../types/note"; +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"; @@ -227,6 +229,7 @@ const NoteItemWithMenu = memo(function NoteItemWithMenu({ }); interface NoteListProps { + sortOrder: NoteSortOrder; multiSelectedNoteIds: Set; setMultiSelectedNoteIds: React.Dispatch>>; lastClickedNoteId: string | null; @@ -234,6 +237,7 @@ interface NoteListProps { } export function NoteList({ + sortOrder, multiSelectedNoteIds, setMultiSelectedNoteIds, lastClickedNoteId, @@ -291,9 +295,21 @@ export function NoteList({ }, []); const refreshSettings = useCallback(() => { - notesService.getSettings().then(setSettings); + notesService.getSettings().then(setSettings).catch((error) => { + console.error("Failed to refresh settings:", error); + }); }, []); + 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()) { @@ -307,6 +323,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 +362,7 @@ export function NoteList({ ); } - if (isSearching && displayItems.length === 0) { + if (isSearching && sortedDisplayItems.length === 0) { return (
No results found @@ -349,7 +370,7 @@ export function NoteList({ ); } - if (displayItems.length === 0) { + if (sortedDisplayItems.length === 0) { return (
No notes yet @@ -362,6 +383,7 @@ export function NoteList({ return ( <> - {displayItems.map((item) => ( + {sortedDisplayItems.map((item) => ( (null); const [title, setTitle] = useState(""); const [modified, setModified] = useState(0); + const [revision, setRevision] = useState(""); const [hasExternalChanges, setHasExternalChanges] = useState(false); + const [hasSaveConflict, setHasSaveConflict] = useState(false); const [reloadVersion, setReloadVersion] = useState(0); const [focusMode, setFocusMode] = useState(false); - const recentlySavedRef = useRef(false); + const revisionRef = useRef(""); + const saveQueueRef = useRef(createSerializedTaskQueue()); + const persistenceControllerRef = useRef( + null, + ); + const closeInProgressRef = useRef(false); + + const 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(() => { + let cancelled = false; filesService .readFileDirect(filePath) - .then((result) => { - setContent(result.content); + .then(async (result) => { + if (cancelled) return; + const checkpoint = await draftCheckpointService + .getDraftCheckpoint(filePath) + .catch(() => null); + if (cancelled) return; + const recovered = + checkpoint && checkpoint.markdown !== result.content + ? checkpoint.markdown + : result.content; + if (cancelled) return; + setContent(recovered); + if (cancelled) return; setTitle(result.title); + if (cancelled) return; setModified(result.modified); + revisionRef.current = result.revision; + if (cancelled) return; + setRevision(result.revision); + if (checkpoint && checkpoint.markdown === result.content) { + await draftCheckpointService + .clearDraftCheckpoint(checkpoint.key) + .catch(() => undefined); + } else if (checkpoint) { + if (cancelled) return; + setHasExternalChanges(true); + if (cancelled) return; + setHasSaveConflict(true); + if (cancelled) return; + toast.warning("Recovered an unsaved draft from an interrupted session"); + } }) .catch((error) => { + if (cancelled) return; console.error("Failed to load file:", error); toast.error(`Failed to load file: ${error}`); }); + return () => { + cancelled = true; + }; }, [filePath]); // Listen for window focus to detect external changes 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 +167,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 +216,88 @@ 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); + }, + }, + ); + try { + await draftCheckpointService.clearDraftCheckpoint({ + windowLabel: "", + noteId: filePath, + }); + } catch { + // Best-effort cleanup; conflict is already resolved. + } + }, + [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 +389,14 @@ export function PreviewApp({ filePath }: PreviewAppProps) { title, filePath, modified, + revision, hasExternalChanges, + hasSaveConflict, reloadVersion, save, reload, + resolveConflict, + registerPersistenceController, }; return ( diff --git a/src/components/settings/EditorSettingsSection.test.tsx b/src/components/settings/EditorSettingsSection.test.tsx new file mode 100644 index 00000000..ead5d157 --- /dev/null +++ b/src/components/settings/EditorSettingsSection.test.tsx @@ -0,0 +1,159 @@ +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { describe, expect, it, vi } from "vitest"; +import { + EditorToolbarVisibilityControl, + EditorWidthResizeControl, + TitleBarNoteInfoControls, +} from "./EditorSettingsSection"; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }) + .IS_REACT_ACT_ENVIRONMENT = true; + +describe("EditorWidthResizeControl", () => { + it("exposes the current state and lets the user disable mouse resizing", () => { + const onChange = vi.fn(); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + + act(() => { + root.render( + , + ); + }); + + const group = container.querySelector( + '[role="group"][aria-label="Resize editor with mouse"]', + ); + const [offButton, onButton] = Array.from( + container.querySelectorAll("button"), + ); + + expect(group).not.toBeNull(); + expect(offButton.textContent).toBe("Off"); + expect(offButton.getAttribute("aria-pressed")).toBe("false"); + expect(onButton.textContent).toBe("On"); + expect(onButton.getAttribute("aria-pressed")).toBe("true"); + + act(() => offButton.click()); + expect(onChange).toHaveBeenCalledWith(false); + + act(() => root.unmount()); + container.remove(); + }); +}); + +describe("EditorToolbarVisibilityControl", () => { + it("exposes the hidden default and lets the user show the toolbar", () => { + const onChange = vi.fn(); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + + act(() => { + root.render( + , + ); + }); + + const group = container.querySelector( + '[role="group"][aria-label="Show formatting toolbar"]', + ); + const [offButton, onButton] = Array.from( + container.querySelectorAll("button"), + ); + + expect(group).not.toBeNull(); + expect(offButton.textContent).toBe("Off"); + expect(offButton.getAttribute("aria-pressed")).toBe("true"); + expect(onButton.textContent).toBe("On"); + expect(onButton.getAttribute("aria-pressed")).toBe("false"); + + act(() => onButton.click()); + expect(onChange).toHaveBeenCalledWith(true); + + act(() => root.unmount()); + container.remove(); + }); +}); + +describe("TitleBarNoteInfoControls", () => { + it("offers one exclusive title-bar information menu", () => { + const onModifiedDateChange = vi.fn(); + const onFilenameChange = vi.fn(); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + + act(() => { + root.render( + , + ); + }); + + const select = container.querySelector( + 'select[aria-label="Title bar information"]', + ); + expect(select).not.toBeNull(); + expect(container.querySelectorAll("select")).toHaveLength(1); + expect(Array.from(select?.options ?? []).map((option) => option.text)).toEqual( + ["Modification Date", "Filename", "None"], + ); + expect(select?.value).toBe("modifiedDate"); + expect(container.textContent).not.toContain("On"); + expect(container.textContent).not.toContain("Off"); + + act(() => { + if (!select) return; + select.value = "filename"; + select.dispatchEvent(new Event("change", { bubbles: true })); + }); + expect(onFilenameChange).toHaveBeenCalledWith(true); + expect(onModifiedDateChange).not.toHaveBeenCalled(); + + act(() => root.unmount()); + container.remove(); + }); + + it("maps None to the single active persisted setting", () => { + const onModifiedDateChange = vi.fn(); + const onFilenameChange = vi.fn(); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + + act(() => { + root.render( + , + ); + }); + + const select = container.querySelector( + 'select[aria-label="Title bar information"]', + ); + expect(select?.value).toBe("filename"); + + act(() => { + if (!select) return; + select.value = "none"; + select.dispatchEvent(new Event("change", { bubbles: true })); + }); + + expect(onFilenameChange).toHaveBeenCalledWith(false); + expect(onModifiedDateChange).not.toHaveBeenCalled(); + + act(() => root.unmount()); + container.remove(); + }); +}); diff --git a/src/components/settings/EditorSettingsSection.tsx b/src/components/settings/EditorSettingsSection.tsx index 997e9180..96702692 100644 --- a/src/components/settings/EditorSettingsSection.tsx +++ b/src/components/settings/EditorSettingsSection.tsx @@ -56,6 +56,161 @@ const boldWeightOptions = [ { value: 800, label: "Extra Bold", excludeForMonospace: false }, ]; +interface EditorWidthResizeControlProps { + enabled: boolean; + onChange: (enabled: boolean) => void; +} + +interface BinarySettingControlProps { + label: string; + description: string; + ariaLabel: string; + value: boolean; + onChange: (value: boolean) => void; +} + +function BinarySettingControl({ + label, + description, + ariaLabel, + value, + onChange, +}: BinarySettingControlProps) { + return ( +
+
+
{label}
+

{description}

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

+ Choose what appears beside the note icon +

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

Typography

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