diff --git a/package-lock.json b/package-lock.json index 55003d64..2268787d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,23 +20,27 @@ "@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-updater": "^2.10.0", - "@tiptap/core": "^3.19.0", - "@tiptap/extension-code-block-lowlight": "^3.20.0", - "@tiptap/extension-image": "^3.18.0", - "@tiptap/extension-link": "^3.18.0", - "@tiptap/extension-mathematics": "^3.18.0", - "@tiptap/extension-placeholder": "^3.18.0", - "@tiptap/extension-table": "^3.19.0", - "@tiptap/extension-table-cell": "^3.19.0", - "@tiptap/extension-table-header": "^3.19.0", - "@tiptap/extension-table-row": "^3.19.0", - "@tiptap/extension-task-item": "^3.18.0", - "@tiptap/extension-task-list": "^3.18.0", - "@tiptap/markdown": "^3.18.0", - "@tiptap/pm": "^3.18.0", - "@tiptap/react": "^3.18.0", - "@tiptap/starter-kit": "^3.18.0", - "@tiptap/suggestion": "^3.18.0", + "@tiptap/core": "3.29.2", + "@tiptap/extension-code-block-lowlight": "3.29.2", + "@tiptap/extension-highlight": "3.29.2", + "@tiptap/extension-image": "3.29.2", + "@tiptap/extension-link": "3.29.2", + "@tiptap/extension-mathematics": "3.29.2", + "@tiptap/extension-placeholder": "3.29.2", + "@tiptap/extension-subscript": "3.29.2", + "@tiptap/extension-superscript": "3.29.2", + "@tiptap/extension-table": "3.29.2", + "@tiptap/extension-table-cell": "3.29.2", + "@tiptap/extension-table-header": "3.29.2", + "@tiptap/extension-table-row": "3.29.2", + "@tiptap/extension-task-item": "3.29.2", + "@tiptap/extension-task-list": "3.29.2", + "@tiptap/extension-text-style": "3.29.2", + "@tiptap/markdown": "3.29.2", + "@tiptap/pm": "3.29.2", + "@tiptap/react": "3.29.2", + "@tiptap/starter-kit": "3.29.2", + "@tiptap/suggestion": "3.29.2", "beautiful-mermaid": "^1.1.3", "clsx": "^2.1.1", "highlight.js": "^11.11.1", @@ -56,9 +60,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": { @@ -1618,12 +1624,6 @@ "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", "license": "MIT" }, - "node_modules/@remirror/core-constants": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz", - "integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==", - "license": "MIT" - }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -1981,6 +1981,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", @@ -2594,48 +2601,49 @@ } }, "node_modules/@tiptap/core": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.20.0.tgz", - "integrity": "sha512-aC9aROgia/SpJqhsXFiX9TsligL8d+oeoI8W3u00WI45s0VfsqjgeKQLDLF7Tu7hC+7F02teC84SAHuup003VQ==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.29.2.tgz", + "integrity": "sha512-oKUkiPUB7noilVYxI9lNzUD4rX17sHub+PYjMfHMWHG9A3nvIy+FdePIVIIhThKWF7ijhr3eIqHY51Bn+GAFtw==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/pm": "^3.20.0" + "@tiptap/pm": "3.29.2" } }, "node_modules/@tiptap/extension-blockquote": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.20.0.tgz", - "integrity": "sha512-LQzn6aGtL4WXz2+rYshl/7/VnP2qJTpD7fWL96GXAzhqviPEY1bJES7poqJb3MU/gzl8VJUVzVzU1VoVfUKlbA==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.29.2.tgz", + "integrity": "sha512-ca4OzKDh0yaxg2+Z56bC2QnWsNsFp2YMRfVig1PDXyMVFMNJpLcnhxgq/9btn+xYAlYrj8RymOeCTYREOR6Zjg==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.20.0" + "@tiptap/core": "3.29.2", + "@tiptap/pm": "3.29.2" } }, "node_modules/@tiptap/extension-bold": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.20.0.tgz", - "integrity": "sha512-sQklEWiyf58yDjiHtm5vmkVjfIc/cBuSusmCsQ0q9vGYnEF1iOHKhGpvnCeEXNeqF3fiJQRlquzt/6ymle3Iwg==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.29.2.tgz", + "integrity": "sha512-elYbGxJsYnBb4leqrcjdIJuiG380BcOgN+UUzvOv+qEjfGVzHodFOMBl3qnmD6urYHNu5/qQK2S0qSSRXKCLNQ==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.20.0" + "@tiptap/core": "3.29.2" } }, "node_modules/@tiptap/extension-bubble-menu": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.20.0.tgz", - "integrity": "sha512-MDosUfs8Tj+nwg8RC+wTMWGkLJORXmbR6YZgbiX4hrc7G90Gopdd6kj6ht5/T8t7dLLaX7N0+DEHdUEPGED7dw==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.29.2.tgz", + "integrity": "sha512-kzcWarcpr031rZu+R3Hzut5uxb3Qj/xxMDEYZIQ3uiq1yb5vEMXj8/SIyWautk6Nu8Rr1leV3rGdR4NzhzyFAA==", "license": "MIT", "optional": true, "dependencies": { @@ -2646,96 +2654,97 @@ "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.20.0", - "@tiptap/pm": "^3.20.0" + "@tiptap/core": "3.29.2", + "@tiptap/pm": "3.29.2" } }, "node_modules/@tiptap/extension-bullet-list": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.20.0.tgz", - "integrity": "sha512-OcKMeopBbqWzhSi6o8nNz0aayogg1sfOAhto3NxJu3Ya32dwBFqmHXSYM6uW4jOphNvVPyjiq9aNRh3qTdd1dw==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.29.2.tgz", + "integrity": "sha512-3bWcCUPbCHv0XttlMdnAtXLNYWx2pblByMgxmGsaP9FU0QnslGXty6A6gHCqI33ygRg1vrA6U5Wtpwbi5aKu5g==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/extension-list": "^3.20.0" + "@tiptap/extension-list": "3.29.2" } }, "node_modules/@tiptap/extension-code": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.20.0.tgz", - "integrity": "sha512-TYDWFeSQ9umiyrqsT6VecbuhL8XIHkUhO+gEk0sVvH67ZLwjFDhAIIgWIr1/dbIGPcvMZM19E7xUUhAdIaXaOQ==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.29.2.tgz", + "integrity": "sha512-c6W5UGuB7WNLpYocsgRzpO2OOTI4QjaI9jjHRMuty9z+s9DtaYM/HrRLNwVh6MopkHb+i/89Wkv8gCS34fftig==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.20.0" + "@tiptap/core": "3.29.2" } }, "node_modules/@tiptap/extension-code-block": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.20.0.tgz", - "integrity": "sha512-lBbmNek14aCjrHcBcq3PRqWfNLvC6bcRa2Osc6e/LtmXlcpype4f6n+Yx+WZ+f2uUh0UmDRCz7BEyUETEsDmlQ==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.29.2.tgz", + "integrity": "sha512-w153ct8g6dLiPTdXQ6SOIMxX4SEo5Q50AmjdEEEcJ7ZcYUcde/ScSskLHfOYmyt5ZFAiyEwr121+pux+p3/oAQ==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.20.0", - "@tiptap/pm": "^3.20.0" + "@tiptap/core": "3.29.2", + "@tiptap/pm": "3.29.2" } }, "node_modules/@tiptap/extension-code-block-lowlight": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block-lowlight/-/extension-code-block-lowlight-3.20.0.tgz", - "integrity": "sha512-9lN9rn07lOWkLnByT5C1axtq56MHpOI7MpLaCmX3p+x1bDl6Uvixm6AoBdTLfZUmUYeEFBsf7t5cR+QepMbkiA==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block-lowlight/-/extension-code-block-lowlight-3.29.2.tgz", + "integrity": "sha512-iUtg33uSlbnU0y6c3nUSWdw8ILmbuc+HeAouqZ7Vl4leYddLf7wLP4s+RJ8PKDJe8m1r6GTK6VVpCOUK5eIFzQ==", + "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.20.0", - "@tiptap/extension-code-block": "^3.20.0", - "@tiptap/pm": "^3.20.0", + "@tiptap/core": "3.29.2", + "@tiptap/extension-code-block": "3.29.2", + "@tiptap/pm": "3.29.2", "highlight.js": "^11", "lowlight": "^2 || ^3" } }, "node_modules/@tiptap/extension-document": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.20.0.tgz", - "integrity": "sha512-oJfLIG3vAtZo/wg29WiBcyWt22KUgddpP8wqtCE+kY5Dw8znLR9ehNmVWlSWJA5OJUMO0ntAHx4bBT+I2MBd5w==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.29.2.tgz", + "integrity": "sha512-YUamvefLnsqu6124GavVTI7nqcFlQJ12ROB0oSwG69eSBZYNjg1tIs05LFrBxkwf4Xgqd6YzfJ9+FeG428RvzQ==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.20.0" + "@tiptap/core": "3.29.2" } }, "node_modules/@tiptap/extension-dropcursor": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.20.0.tgz", - "integrity": "sha512-d+cxplRlktVgZPwatnc34IArlppM0IFKS1J5wLk+ba1jidizsbMVh45tP/BTK2flhyfRqcNoB5R0TArhUpbkNQ==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.29.2.tgz", + "integrity": "sha512-KKno7cU9r1HdR48CRrsDu69/1UjZdoslq/UcE+Kx+tdhAv/aljXMkRSNzGMrBNOBDmHRgS1+58zm21WQWdQzwA==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/extensions": "^3.20.0" + "@tiptap/extensions": "3.29.2" } }, "node_modules/@tiptap/extension-floating-menu": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.20.0.tgz", - "integrity": "sha512-rYs4Bv5pVjqZ/2vvR6oe7ammZapkAwN51As/WDbemvYDjfOGRqK58qGauUjYZiDzPOEIzI2mxGwsZ4eJhPW4Ig==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.29.2.tgz", + "integrity": "sha512-CBTq4Xs5aGWr65uFCIkKBms796OhmirWf/ax//iJ4fl9IfwqjwFuc2vQs9PmuwpKn9ctDHo2sMTtvyeCvIt5LQ==", "license": "MIT", "optional": true, "funding": { @@ -2744,336 +2753,390 @@ }, "peerDependencies": { "@floating-ui/dom": "^1.0.0", - "@tiptap/core": "^3.20.0", - "@tiptap/pm": "^3.20.0" + "@tiptap/core": "3.29.2", + "@tiptap/pm": "3.29.2" } }, "node_modules/@tiptap/extension-gapcursor": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.20.0.tgz", - "integrity": "sha512-P/LasfvG9/qFq43ZAlNbAnPnXC+/RJf49buTrhtFvI9Zg0+Lbpjx1oh6oMHB19T88Y28KtrckfFZ8aTSUWDq6w==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.29.2.tgz", + "integrity": "sha512-8Q39UR4/Tit759IeW9xZIe3NMwN11GsuA3FLheDyyGn7RrW02HD3HhUDlazE54Ki4HoosjFmChPlN4Ik2ubdRQ==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/extensions": "^3.20.0" + "@tiptap/extensions": "3.29.2" } }, "node_modules/@tiptap/extension-hard-break": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.20.0.tgz", - "integrity": "sha512-rqvhMOw4f+XQmEthncbvDjgLH6fz8L9splnKZC7OeS0eX8b0qd7+xI1u5kyxF3KA2Z0BnigES++jjWuecqV6mA==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.29.2.tgz", + "integrity": "sha512-eUW3LN3fq8rXnjEUeI3D2QONYdLsU3yYQm4jxlErs2h4cfwrFjgf19VSUFVmm6LrFbbQ0OnDVPeVLL6iOwDw2w==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.20.0" + "@tiptap/core": "3.29.2" } }, "node_modules/@tiptap/extension-heading": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.20.0.tgz", - "integrity": "sha512-JgJhurnCe3eN6a0lEsNQM/46R1bcwzwWWZEFDSb1P9dR8+t1/5v7cMZWsSInpD7R4/74iJn0+M5hcXLwCmBmYA==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.29.2.tgz", + "integrity": "sha512-6W4aIy70Mh7BNlbG9zZ5FBLhJhU2UUEzgZJ/jwYSCcB30o8McLxJSEjhtoHiX8R78Ah2/JzBGvIe5olZlbeE4A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.29.2" + } + }, + "node_modules/@tiptap/extension-highlight": { + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-highlight/-/extension-highlight-3.29.2.tgz", + "integrity": "sha512-bSeZ1C8OlthyK7BzgtqOGfQymdap9V38W2hbr9KGlhVOIAmTqneOwjm27iFcu7jKqkMArVu6WBYN18Lom6umHQ==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.20.0" + "@tiptap/core": "3.29.2" } }, "node_modules/@tiptap/extension-horizontal-rule": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.20.0.tgz", - "integrity": "sha512-6uvcutFMv+9wPZgptDkbRDjAm3YVxlibmkhWD5GuaWwS9L/yUtobpI3GycujRSUZ8D3q6Q9J7LqpmQtQRTalWA==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.29.2.tgz", + "integrity": "sha512-8/ZPzbB9X85Mc9/7xVLZupQKBr2UVcQTGr512xtqMW+XkCQRHCph46tRo828YE13IMWI5fWn/FaNCqXG9cULSw==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.20.0", - "@tiptap/pm": "^3.20.0" + "@tiptap/core": "3.29.2", + "@tiptap/pm": "3.29.2" } }, "node_modules/@tiptap/extension-image": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-3.20.0.tgz", - "integrity": "sha512-0t7HYncV0kYEQS79NFczxdlZoZ8zu8X4VavDqt+mbSAUKRq3gCvgtZ5Zyd778sNmtmbz3arxkEYMIVou2swD0g==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-3.29.2.tgz", + "integrity": "sha512-F+S4iru247mNVZdkNwNDZHeb281DkjsBJinaOGsOyJTvXY+KU5Uq7vY1LQTn493dTfU48Z0yMBUXwJffCT92Mg==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.20.0" + "@tiptap/core": "3.29.2" } }, "node_modules/@tiptap/extension-italic": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.20.0.tgz", - "integrity": "sha512-/DhnKQF8yN8RxtuL8abZ28wd5281EaGoE2Oha35zXSOF1vNYnbyt8Ymkv/7u1BcWEWTvRPgaju0YCGXisPRLYw==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.29.2.tgz", + "integrity": "sha512-iH63V/5wsaMnY4Jz0+meaAGhaec4AiOzOduzl6ZZr5IyGhZ1kthyW84ELt0dyLI3hNceAUhaNWc+I7+vX0aoXA==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.20.0" + "@tiptap/core": "3.29.2" } }, "node_modules/@tiptap/extension-link": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.20.0.tgz", - "integrity": "sha512-qI/5A+R0ZWBxo/8HxSn1uOyr7odr3xHBZ/gzOR1GUJaZqjlJxkWFX0RtXMbLKEGEvT25o345cF7b0wFznEh8qA==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.29.2.tgz", + "integrity": "sha512-DcVer5SqrexKCEP6Ip1UPxJUMvcRCCItSv0wxoGytanrimBh2smvcg6X0DWnjlsi5H0updhyl+atYCmmQXUIXA==", "license": "MIT", "dependencies": { - "linkifyjs": "^4.3.2" + "linkifyjs": "^4.3.3" }, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.20.0", - "@tiptap/pm": "^3.20.0" + "@tiptap/core": "3.29.2", + "@tiptap/pm": "3.29.2" } }, "node_modules/@tiptap/extension-list": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.20.0.tgz", - "integrity": "sha512-+V0/gsVWAv+7vcY0MAe6D52LYTIicMSHw00wz3ISZgprSb2yQhJ4+4gurOnUrQ4Du3AnRQvxPROaofwxIQ66WQ==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.29.2.tgz", + "integrity": "sha512-WPZ9BHAPT6QeIm1vdVkuoOWvy9a8/EZeJwV2VhU8LXyTAttvzyj4rsbbHyJWvYWlUSTt/QF2AZ2zhKo7u1w3/A==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.20.0", - "@tiptap/pm": "^3.20.0" + "@tiptap/core": "3.29.2", + "@tiptap/pm": "3.29.2" } }, "node_modules/@tiptap/extension-list-item": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.20.0.tgz", - "integrity": "sha512-qEtjaaGPuqaFB4VpLrGDoIe9RHnckxPfu6d3rc22ap6TAHCDyRv05CEyJogqccnFceG/v5WN4znUBER8RWnWHA==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.29.2.tgz", + "integrity": "sha512-s8vBVHHFT0Qpu7CzAZ7S1kYmSiVaDvUNvMNcZUWnxj6VPfiwmx0eXd9FsjePrRCMoM5tFmPnhFTHDxY3D/eZeQ==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/extension-list": "^3.20.0" + "@tiptap/extension-list": "3.29.2" } }, "node_modules/@tiptap/extension-list-keymap": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.20.0.tgz", - "integrity": "sha512-Z4GvKy04Ms4cLFN+CY6wXswd36xYsT2p/YL0V89LYFMZTerOeTjFYlndzn6svqL8NV1PRT5Diw4WTTxJSmcJPA==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.29.2.tgz", + "integrity": "sha512-R+3k8OLnxdCH7Xy9ieOwUt5m2Je74u8mikothGmsYVO2Zyq48fIbmZ+X6RBPCu7DBOI2FIUhHEFbKQeDWvDNmA==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/extension-list": "^3.20.0" + "@tiptap/extension-list": "3.29.2" } }, "node_modules/@tiptap/extension-mathematics": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-mathematics/-/extension-mathematics-3.20.0.tgz", - "integrity": "sha512-tVdAqew37SVLlymYGqraz1huWUlkuMj986hvrYOwsOtre2Zbvd8ULxN92TnbuxJSQ6Nt4zrElRbiPrFL2dG8nQ==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-mathematics/-/extension-mathematics-3.29.2.tgz", + "integrity": "sha512-v2qFI635nsOi+IiMg9nWw6t1SUunU53qFj2lceDIiIeEX26bW2YjMYoju0T6WpySCtbTunkKGcVerGn0DtYC9Q==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.20.0", - "@tiptap/pm": "^3.20.0", - "katex": "^0.16.4" + "@tiptap/core": "3.29.2", + "@tiptap/pm": "3.29.2", + "katex": "^0.16.4 || ^0.17.0" } }, "node_modules/@tiptap/extension-ordered-list": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.20.0.tgz", - "integrity": "sha512-jVKnJvrizLk7etwBMfyoj6H2GE4M+PD4k7Bwp6Bh1ohBWtfIA1TlngdS842Mx5i1VB2e3UWIwr8ZH46gl6cwMA==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.29.2.tgz", + "integrity": "sha512-ndCunC+UsYOpkOtL7vGnDz21UNa45WUlcO9wMT1fbuYow2QnRhsuMlCWENXI52YPPARWuQ0RDgN7q6TaxPERBg==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/extension-list": "^3.20.0" + "@tiptap/extension-list": "3.29.2" } }, "node_modules/@tiptap/extension-paragraph": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.20.0.tgz", - "integrity": "sha512-mM99zK4+RnEXIMCv6akfNATAs0Iija6FgyFA9J9NZ6N4o8y9QiNLLa6HjLpAC+W+VoCgQIekyoF/Q9ftxmAYDQ==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.29.2.tgz", + "integrity": "sha512-7qJj5YTr11vvjNgjDN1ypOfwTovc0QOCYcit/rskeuVgnmQZOZQzC/BbyKLLG7UGnpRLemU/mEGbW9pAqjAXkQ==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.20.0" + "@tiptap/core": "3.29.2" } }, "node_modules/@tiptap/extension-placeholder": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-3.20.0.tgz", - "integrity": "sha512-ZhYD3L5m16ydSe2z8vqz+RdtAG/iOQaFHHedFct70tKRoLqi2ajF5kgpemu8DwpaRTcyiCN4G99J/+MqehKNjQ==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-3.29.2.tgz", + "integrity": "sha512-/BlpPIq2JZk6zLaeYVOXazi6dmd0iRriTymNEnFn1doManN1y5HED9LsMeb/AhNhauL5AgmcHgpvAjbsN9k4SA==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/extensions": "^3.20.0" + "@tiptap/extensions": "3.29.2" } }, "node_modules/@tiptap/extension-strike": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.20.0.tgz", - "integrity": "sha512-0vcTZRRAiDfon3VM1mHBr9EFmTkkUXMhm0Xtdtn0bGe+sIqufyi+hUYTEw93EQOD9XNsPkrud6jzQNYpX2H3AQ==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.29.2.tgz", + "integrity": "sha512-aEvLAbddUQZ+FukCreV3q4G2HfNI+odE7E9U+wbq6XsSWKyo8/pDu1muz+TFKNre4blSMOQ3JQmw5UeHDKy+fg==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.20.0" + "@tiptap/core": "3.29.2" + } + }, + "node_modules/@tiptap/extension-subscript": { + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-subscript/-/extension-subscript-3.29.2.tgz", + "integrity": "sha512-3fpp1B2kZWBfmVYZ1ak3yFWiKR3eV9GR/vYGPDMrkZoTdG9LKylRAJs3Fk2gDgTtOPmbrtilGQjl2o55RuoGxw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.29.2", + "@tiptap/pm": "3.29.2" + } + }, + "node_modules/@tiptap/extension-superscript": { + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-superscript/-/extension-superscript-3.29.2.tgz", + "integrity": "sha512-/bPgdY7zUHtvToHizvzR6Yf4uIWO35cKj9EZsl/vyFb2mXyJ3a2mysCZgZq+5uvhZS/16eNNKOcezlkayhhkMA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.29.2", + "@tiptap/pm": "3.29.2" } }, "node_modules/@tiptap/extension-table": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-3.20.0.tgz", - "integrity": "sha512-vaaMtQ2KnSSr8WVwgWf7NYNzPwrHx/6T0ekA5CxV8qNUEpXIaLXa5+tE7tJHWEdNR2KY3gUJ46D3lfOkxyFrBQ==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-3.29.2.tgz", + "integrity": "sha512-HY3JWD8i/F8W1oWw56DMqEMqxScIAZFRrhL3rUwiuowHG8LLDqDdQjBV3w1DY8lUZOtNY9grpuMtGlPUFrOVAA==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.20.0", - "@tiptap/pm": "^3.20.0" + "@tiptap/core": "3.29.2", + "@tiptap/pm": "3.29.2" } }, "node_modules/@tiptap/extension-table-cell": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-table-cell/-/extension-table-cell-3.20.0.tgz", - "integrity": "sha512-9Dg4zda3UWwtpBwSG7b9BeQy5oT27a/yEIBeARuxe19bloMLZgqpPRtnSrOK0OAITtVnjA+NZdKPcVLRMS2E8A==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-table-cell/-/extension-table-cell-3.29.2.tgz", + "integrity": "sha512-SAdjUM9AdtEI7Uyd9wEKpYao7xJ/WVjkLoRm07uWB/J71JE9ewSilV9887C9JPPdFNhzMEL+OcO0be8d3z4qrA==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/extension-table": "^3.20.0" + "@tiptap/extension-table": "3.29.2" } }, "node_modules/@tiptap/extension-table-header": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-table-header/-/extension-table-header-3.20.0.tgz", - "integrity": "sha512-2tVHHlihpeHO/gh2uU46gAX3NTGdKR+yDmfLlO2l0QAvx2TXNfNzX2pOM4MmyostW5Ko9TCWV4x0D9h3IQDhPw==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-table-header/-/extension-table-header-3.29.2.tgz", + "integrity": "sha512-5xtCctg9hT2YFg8Rrn8K43nJdPbVw1XtD/mnXW+vTtKgHnPnRhSsQn6TCRp+cVmcGU0MN1GK3busKFVhQ+T1zQ==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/extension-table": "^3.20.0" + "@tiptap/extension-table": "3.29.2" } }, "node_modules/@tiptap/extension-table-row": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-table-row/-/extension-table-row-3.20.0.tgz", - "integrity": "sha512-clkfQahkYW/U48QBh1rPZv3AWWSC9AqGKp2DLTH/SGIorM/NwI0jpPtBETMlvowyQu0ivlH9B896smEph+Do2A==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-table-row/-/extension-table-row-3.29.2.tgz", + "integrity": "sha512-37FCBkwf1+FrJKyac75GCeNks0xWE/kBB9sHZ24f9WSZy1aqNhiuchqzGwdeOQGY9AywrCQgVseGDhI9wEoGtQ==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/extension-table": "^3.20.0" + "@tiptap/extension-table": "3.29.2" } }, "node_modules/@tiptap/extension-task-item": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-task-item/-/extension-task-item-3.20.0.tgz", - "integrity": "sha512-UArl5ffRNKyiOGJYNtjF50+A0qgYF2DXIEFGyMgJsbp1KYQpDj+UrezWvnGOHZfX64eBWDIO6Bum5jS7o32dYQ==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-task-item/-/extension-task-item-3.29.2.tgz", + "integrity": "sha512-mi5p2/FbUO9LFl7qPmRBThME+bFpd0vyeOD6IiWnD32hYZ720/N5VDkDypPmz+IaxTFaiLeGVmGC1SxGtxksKA==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/extension-list": "^3.20.0" + "@tiptap/extension-list": "3.29.2" } }, "node_modules/@tiptap/extension-task-list": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-task-list/-/extension-task-list-3.20.0.tgz", - "integrity": "sha512-Ab2hfun+5xMG6kQhRjL7s16zTe3XaQRXe3/deGo9hnZrAzTQpWVwDN7H36BX8MemrWnAw3PiuwBH5iOrwNRGjA==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-task-list/-/extension-task-list-3.29.2.tgz", + "integrity": "sha512-7ZkVoMcM3FAuvilZs3XliRRowPVMrVoKhfklfbNkTj9nVOvVToM+xcXKCcsZnf1mjMAyjw65m8doXJQKGCH7DA==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/extension-list": "^3.20.0" + "@tiptap/extension-list": "3.29.2" } }, "node_modules/@tiptap/extension-text": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.20.0.tgz", - "integrity": "sha512-tf8bE8tSaOEWabCzPm71xwiUhyMFKqY9jkP5af3Kr1/F45jzZFIQAYZooHI/+zCHRrgJ99MQHKHe1ZNvODrKHQ==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.29.2.tgz", + "integrity": "sha512-Ubko45JWWHe8glBt2PiGNF8hcbys/JNalFhiR7Y1X4iOOtAxAKJJxh3+eq+//NTlGuBPdWGp7zw8EEUp7anjKA==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.20.0" + "@tiptap/core": "3.29.2" + } + }, + "node_modules/@tiptap/extension-text-style": { + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-3.29.2.tgz", + "integrity": "sha512-aQad9V9ROaEi3hE4g5z9wQ6lv5FSnzlnWFMVJxRjNlwXgm7H0fymxb4rGfca+pAwYkiRwpKYh1LatpJw2ECQAA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.29.2" } }, "node_modules/@tiptap/extension-underline": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.20.0.tgz", - "integrity": "sha512-LzNXuy2jwR/y+ymoUqC72TiGzbOCjioIjsDu0MNYpHuHqTWPK5aV9Mh0nbZcYFy/7fPlV1q0W139EbJeYBZEAQ==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.29.2.tgz", + "integrity": "sha512-K7XwH/xS/5AIREWQ00VTEf/W5U0olp7j6wwit7cdd/8nHv6h6AGr1+iEApHKoLXWQZLfGQKzJlT9W61LAl+fHA==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.20.0" + "@tiptap/core": "3.29.2" } }, "node_modules/@tiptap/extensions": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.20.0.tgz", - "integrity": "sha512-HIsXX942w3nbxEQBlMAAR/aa6qiMBEP7CsSMxaxmTIVAmW35p6yUASw6GdV1u0o3lCZjXq2OSRMTskzIqi5uLg==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.29.2.tgz", + "integrity": "sha512-BCz+FCAChSYtUe4BFj97HEO+nSK+J7GxbJgZG4Hg7DT/gI+hRyeNndU8efiQAx3WGdzsFi3UxRpcF1tTQM7iMQ==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.20.0", - "@tiptap/pm": "^3.20.0" + "@tiptap/core": "3.29.2", + "@tiptap/pm": "3.29.2" } }, "node_modules/@tiptap/markdown": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/markdown/-/markdown-3.20.0.tgz", - "integrity": "sha512-3vUxs8tsVIf/KWKLWjFsTqrjuaTYJY9rawDL5sio9NwlqFWDuWpHEVJcqbQXJUrgQSh12AZoTKyfgiEqkAGI3Q==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/markdown/-/markdown-3.29.2.tgz", + "integrity": "sha512-Knf9YqG3fxOyFuS3pe3eapDz0ya9eqPGzmyMSd5PuHNmR9/Ir6i5qk5+Md6bCPgDNjYskztNm7DoBXPfbXbO/w==", "license": "MIT", "dependencies": { "marked": "^17.0.1" @@ -3083,34 +3146,29 @@ "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.20.0", - "@tiptap/pm": "^3.20.0" + "@tiptap/core": "3.29.2", + "@tiptap/pm": "3.29.2" } }, "node_modules/@tiptap/pm": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.20.0.tgz", - "integrity": "sha512-jn+2KnQZn+b+VXr8EFOJKsnjVNaA4diAEr6FOazupMt8W8ro1hfpYtZ25JL87Kao/WbMze55sd8M8BDXLUKu1A==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.29.2.tgz", + "integrity": "sha512-GCOme7xHaS+DSoaA4CDcAD3l6JyBlvZhvCyfsy2Vp6j8tEoBkZWio7soYVosmlyn7zq8/64VeFZP5s47yfG7fQ==", "license": "MIT", "dependencies": { - "prosemirror-changeset": "^2.3.0", - "prosemirror-collab": "^1.3.1", - "prosemirror-commands": "^1.6.2", - "prosemirror-dropcursor": "^1.8.1", - "prosemirror-gapcursor": "^1.3.2", - "prosemirror-history": "^1.4.1", - "prosemirror-inputrules": "^1.4.0", - "prosemirror-keymap": "^1.2.2", - "prosemirror-markdown": "^1.13.1", - "prosemirror-menu": "^1.2.4", - "prosemirror-model": "^1.24.1", - "prosemirror-schema-basic": "^1.2.3", - "prosemirror-schema-list": "^1.5.0", - "prosemirror-state": "^1.4.3", - "prosemirror-tables": "^1.6.4", - "prosemirror-trailing-node": "^3.0.0", - "prosemirror-transform": "^1.10.2", - "prosemirror-view": "^1.38.1" + "prosemirror-changeset": "^2.4.1", + "prosemirror-commands": "^1.7.1", + "prosemirror-dropcursor": "^1.8.2", + "prosemirror-gapcursor": "^1.4.1", + "prosemirror-history": "^1.5.0", + "prosemirror-inputrules": "^1.5.1", + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.11", + "prosemirror-schema-list": "^1.5.1", + "prosemirror-state": "^1.4.4", + "prosemirror-tables": "^1.8.5", + "prosemirror-transform": "^1.12.0", + "prosemirror-view": "^1.41.9" }, "funding": { "type": "github", @@ -3118,9 +3176,9 @@ } }, "node_modules/@tiptap/react": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.20.0.tgz", - "integrity": "sha512-jFLNzkmn18zqefJwPje0PPd9VhZ7Oy28YHiSvSc7YpBnQIbuN/HIxZ2lrOsKyEHta0WjRZjfU5X1pGxlbcGwOA==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.29.2.tgz", + "integrity": "sha512-ZMKgteYmZXnq7C22U9fbCTC6jAF8XlQM5n2K2FLWCdYAlP4FNV3XeQ9hY2a13KSQ0Q3yltWXZlcWBpt5ClxScg==", "license": "MIT", "dependencies": { "@types/use-sync-external-store": "^0.0.6", @@ -3132,12 +3190,12 @@ "url": "https://github.com/sponsors/ueberdosis" }, "optionalDependencies": { - "@tiptap/extension-bubble-menu": "^3.20.0", - "@tiptap/extension-floating-menu": "^3.20.0" + "@tiptap/extension-bubble-menu": "^3.29.2", + "@tiptap/extension-floating-menu": "^3.29.2" }, "peerDependencies": { - "@tiptap/core": "^3.20.0", - "@tiptap/pm": "^3.20.0", + "@tiptap/core": "3.29.2", + "@tiptap/pm": "3.29.2", "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", @@ -3145,35 +3203,35 @@ } }, "node_modules/@tiptap/starter-kit": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.20.0.tgz", - "integrity": "sha512-W4+1re35pDNY/7rpXVg+OKo/Fa4Gfrn08Bq3E3fzlJw6gjE3tYU8dY9x9vC2rK9pd9NOp7Af11qCFDaWpohXkw==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.29.2.tgz", + "integrity": "sha512-oTu0tysiqk4zgjEtxRHjAQgxUKaAevZwueOWwSWubHdokqp7SpcbE5n9USJv89HKuTUDm3GjnQH6q8HNn/2DsA==", "license": "MIT", "dependencies": { - "@tiptap/core": "^3.20.0", - "@tiptap/extension-blockquote": "^3.20.0", - "@tiptap/extension-bold": "^3.20.0", - "@tiptap/extension-bullet-list": "^3.20.0", - "@tiptap/extension-code": "^3.20.0", - "@tiptap/extension-code-block": "^3.20.0", - "@tiptap/extension-document": "^3.20.0", - "@tiptap/extension-dropcursor": "^3.20.0", - "@tiptap/extension-gapcursor": "^3.20.0", - "@tiptap/extension-hard-break": "^3.20.0", - "@tiptap/extension-heading": "^3.20.0", - "@tiptap/extension-horizontal-rule": "^3.20.0", - "@tiptap/extension-italic": "^3.20.0", - "@tiptap/extension-link": "^3.20.0", - "@tiptap/extension-list": "^3.20.0", - "@tiptap/extension-list-item": "^3.20.0", - "@tiptap/extension-list-keymap": "^3.20.0", - "@tiptap/extension-ordered-list": "^3.20.0", - "@tiptap/extension-paragraph": "^3.20.0", - "@tiptap/extension-strike": "^3.20.0", - "@tiptap/extension-text": "^3.20.0", - "@tiptap/extension-underline": "^3.20.0", - "@tiptap/extensions": "^3.20.0", - "@tiptap/pm": "^3.20.0" + "@tiptap/core": "^3.29.2", + "@tiptap/extension-blockquote": "^3.29.2", + "@tiptap/extension-bold": "^3.29.2", + "@tiptap/extension-bullet-list": "^3.29.2", + "@tiptap/extension-code": "^3.29.2", + "@tiptap/extension-code-block": "^3.29.2", + "@tiptap/extension-document": "^3.29.2", + "@tiptap/extension-dropcursor": "^3.29.2", + "@tiptap/extension-gapcursor": "^3.29.2", + "@tiptap/extension-hard-break": "^3.29.2", + "@tiptap/extension-heading": "^3.29.2", + "@tiptap/extension-horizontal-rule": "^3.29.2", + "@tiptap/extension-italic": "^3.29.2", + "@tiptap/extension-link": "^3.29.2", + "@tiptap/extension-list": "^3.29.2", + "@tiptap/extension-list-item": "^3.29.2", + "@tiptap/extension-list-keymap": "^3.29.2", + "@tiptap/extension-ordered-list": "^3.29.2", + "@tiptap/extension-paragraph": "^3.29.2", + "@tiptap/extension-strike": "^3.29.2", + "@tiptap/extension-text": "^3.29.2", + "@tiptap/extension-underline": "^3.29.2", + "@tiptap/extensions": "^3.29.2", + "@tiptap/pm": "^3.29.2" }, "funding": { "type": "github", @@ -3181,17 +3239,18 @@ } }, "node_modules/@tiptap/suggestion": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/suggestion/-/suggestion-3.20.0.tgz", - "integrity": "sha512-OA9Fe+1Q/Ex0ivTcpRcVFiLnNsVdIBmiEoctt/gu4H2ayCYmZ906veioXNdc1m/3MtVVUIuEnvwwsrOZXlfDEw==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/suggestion/-/suggestion-3.29.2.tgz", + "integrity": "sha512-ZEhRm0gnRCwCScR9IrnIBhm9sr6U8vpR9oeznYk3hiedZ48zsgohqvgoIYpWcwYWrT2Pb0NrfhCT8IuSzdJHzQ==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.20.0", - "@tiptap/pm": "^3.20.0" + "@floating-ui/dom": "^1.0.0", + "@tiptap/core": "3.29.2", + "@tiptap/pm": "3.29.2" } }, "node_modules/@types/babel__core": { @@ -3239,6 +3298,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", @@ -3254,28 +3331,16 @@ "@types/unist": "*" } }, - "node_modules/@types/linkify-it": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", - "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", - "license": "MIT" - }, - "node_modules/@types/markdown-it": { - "version": "14.1.2", - "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", - "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", + "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": { - "@types/linkify-it": "^5", - "@types/mdurl": "^2" + "undici-types": "~8.3.0" } }, - "node_modules/@types/mdurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", - "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", - "license": "MIT" - }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -3305,6 +3370,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,11 +3408,118 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "license": "Python-2.0" + "node_modules/@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/aria-hidden": { "version": "1.2.6", @@ -3344,6 +3533,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 +3610,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 +3644,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", @@ -3457,12 +3679,6 @@ "dev": true, "license": "MIT" }, - "node_modules/crelt": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", - "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", - "license": "MIT" - }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -3562,17 +3778,12 @@ "node": ">=10.13.0" } }, - "node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" }, "node_modules/esbuild": { "version": "0.27.3", @@ -3626,16 +3837,24 @@ "node": ">=6" } }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "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": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=12.0.0" } }, "node_modules/fast-equals": { @@ -3706,6 +3925,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", @@ -4034,19 +4285,10 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/linkify-it": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", - "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", - "license": "MIT", - "dependencies": { - "uc.micro": "^2.0.0" - } - }, "node_modules/linkifyjs": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz", - "integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz", + "integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==", "license": "MIT" }, "node_modules/lowlight": { @@ -4083,23 +4325,6 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/markdown-it": { - "version": "14.1.1", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", - "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1", - "entities": "^4.4.0", - "linkify-it": "^5.0.0", - "mdurl": "^2.0.0", - "punycode.js": "^2.3.1", - "uc.micro": "^2.1.0" - }, - "bin": { - "markdown-it": "bin/markdown-it.mjs" - } - }, "node_modules/marked": { "version": "17.0.3", "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.3.tgz", @@ -4112,12 +4337,6 @@ "node": ">= 20" } }, - "node_modules/mdurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", - "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", - "license": "MIT" - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -4151,12 +4370,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", @@ -4221,23 +4461,14 @@ } }, "node_modules/prosemirror-changeset": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.0.tgz", - "integrity": "sha512-LvqH2v7Q2SF6yxatuPP2e8vSUKS/L+xAU7dPDC4RMyHMhZoGDfBC74mYuyYF4gLqOEG758wajtyhNnsTkuhvng==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz", + "integrity": "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==", "license": "MIT", "dependencies": { "prosemirror-transform": "^1.0.0" } }, - "node_modules/prosemirror-collab": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz", - "integrity": "sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==", - "license": "MIT", - "dependencies": { - "prosemirror-state": "^1.0.0" - } - }, "node_modules/prosemirror-commands": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz", @@ -4261,9 +4492,9 @@ } }, "node_modules/prosemirror-gapcursor": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.0.tgz", - "integrity": "sha512-z00qvurSdCEWUIulij/isHaqu4uLS8r/Fi61IbjdIPJEonQgggbJsLnstW7Lgdk4zQ68/yr6B6bf7sJXowIgdQ==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz", + "integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==", "license": "MIT", "dependencies": { "prosemirror-keymap": "^1.0.0", @@ -4304,47 +4535,15 @@ "w3c-keyname": "^2.2.0" } }, - "node_modules/prosemirror-markdown": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.4.tgz", - "integrity": "sha512-D98dm4cQ3Hs6EmjK500TdAOew4Z03EV71ajEFiWra3Upr7diytJsjF4mPV2dW+eK5uNectiRj0xFxYI9NLXDbw==", - "license": "MIT", - "dependencies": { - "@types/markdown-it": "^14.0.0", - "markdown-it": "^14.0.0", - "prosemirror-model": "^1.25.0" - } - }, - "node_modules/prosemirror-menu": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.3.0.tgz", - "integrity": "sha512-TImyPXCHPcDsSka2/lwJ6WjTASr4re/qWq1yoTTuLOqfXucwF6VcRa2LWCkM/EyTD1UO3CUwiH8qURJoWJRxwg==", - "license": "MIT", - "dependencies": { - "crelt": "^1.0.0", - "prosemirror-commands": "^1.0.0", - "prosemirror-history": "^1.0.0", - "prosemirror-state": "^1.0.0" - } - }, "node_modules/prosemirror-model": { - "version": "1.25.4", - "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz", - "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==", + "version": "1.25.11", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.11.tgz", + "integrity": "sha512-QWg9RhnpLlogAmp3p96uEFrE5txQpFynd4vhBAELkwgOCWQs/X0yCzB3/hrHqiPwf91RG5KyWq6553zs9JqIOQ==", "license": "MIT", "dependencies": { "orderedmap": "^2.0.0" } }, - "node_modules/prosemirror-schema-basic": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz", - "integrity": "sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==", - "license": "MIT", - "dependencies": { - "prosemirror-model": "^1.25.0" - } - }, "node_modules/prosemirror-schema-list": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz", @@ -4380,50 +4579,26 @@ "prosemirror-view": "^1.41.4" } }, - "node_modules/prosemirror-trailing-node": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz", - "integrity": "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==", - "license": "MIT", - "dependencies": { - "@remirror/core-constants": "3.0.0", - "escape-string-regexp": "^4.0.0" - }, - "peerDependencies": { - "prosemirror-model": "^1.22.1", - "prosemirror-state": "^1.4.2", - "prosemirror-view": "^1.33.8" - } - }, "node_modules/prosemirror-transform": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.11.0.tgz", - "integrity": "sha512-4I7Ce4KpygXb9bkiPS3hTEk4dSHorfRw8uI0pE8IhxlK2GXsqv5tIA7JUSxtSu7u8APVOTtbUBxTmnHIxVkIJw==", + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz", + "integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==", "license": "MIT", "dependencies": { "prosemirror-model": "^1.21.0" } }, "node_modules/prosemirror-view": { - "version": "1.41.6", - "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.6.tgz", - "integrity": "sha512-mxpcDG4hNQa/CPtzxjdlir5bJFDlm0/x5nGBbStB2BWX+XOQ9M8ekEG+ojqB5BcVu2Rc80/jssCMZzSstJuSYg==", + "version": "1.42.2", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.2.tgz", + "integrity": "sha512-Pdg0l5kXm8aLDquFAnQFTCITg0q44sLqBlHlpsVLD9segdOao8TOfQdAhCrCXyVgPSRr6UDDROOIWA3bIrN9YQ==", "license": "MIT", "dependencies": { - "prosemirror-model": "^1.20.0", + "prosemirror-model": "^1.25.8", "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.1.0" } }, - "node_modules/punycode.js": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", - "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/react": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", @@ -4601,6 +4776,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 +4803,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 +4848,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 +4882,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", @@ -4698,10 +4921,11 @@ "node": ">=14.17" } }, - "node_modules/uc.micro": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", - "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "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": { @@ -4869,12 +5093,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..cc036c5d 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", @@ -22,23 +24,27 @@ "@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-updater": "^2.10.0", - "@tiptap/core": "^3.19.0", - "@tiptap/extension-code-block-lowlight": "^3.20.0", - "@tiptap/extension-image": "^3.18.0", - "@tiptap/extension-link": "^3.18.0", - "@tiptap/extension-mathematics": "^3.18.0", - "@tiptap/extension-placeholder": "^3.18.0", - "@tiptap/extension-table": "^3.19.0", - "@tiptap/extension-table-cell": "^3.19.0", - "@tiptap/extension-table-header": "^3.19.0", - "@tiptap/extension-table-row": "^3.19.0", - "@tiptap/extension-task-item": "^3.18.0", - "@tiptap/extension-task-list": "^3.18.0", - "@tiptap/markdown": "^3.18.0", - "@tiptap/pm": "^3.18.0", - "@tiptap/react": "^3.18.0", - "@tiptap/starter-kit": "^3.18.0", - "@tiptap/suggestion": "^3.18.0", + "@tiptap/core": "3.29.2", + "@tiptap/extension-code-block-lowlight": "3.29.2", + "@tiptap/extension-highlight": "3.29.2", + "@tiptap/extension-image": "3.29.2", + "@tiptap/extension-link": "3.29.2", + "@tiptap/extension-mathematics": "3.29.2", + "@tiptap/extension-placeholder": "3.29.2", + "@tiptap/extension-subscript": "3.29.2", + "@tiptap/extension-superscript": "3.29.2", + "@tiptap/extension-table": "3.29.2", + "@tiptap/extension-table-cell": "3.29.2", + "@tiptap/extension-table-header": "3.29.2", + "@tiptap/extension-table-row": "3.29.2", + "@tiptap/extension-task-item": "3.29.2", + "@tiptap/extension-task-list": "3.29.2", + "@tiptap/extension-text-style": "3.29.2", + "@tiptap/markdown": "3.29.2", + "@tiptap/pm": "3.29.2", + "@tiptap/react": "3.29.2", + "@tiptap/starter-kit": "3.29.2", + "@tiptap/suggestion": "3.29.2", "beautiful-mermaid": "^1.1.3", "clsx": "^2.1.1", "highlight.js": "^11.11.1", @@ -58,8 +64,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..e1d68b80 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -2,16 +2,14 @@ "$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", "core:window:allow-start-dragging", "core:window:allow-set-focus", "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..7ffe2d47 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,476 @@ 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 NativeNewWindowMenuSpec { + parent_menu: &'static str, + id: &'static str, + label: &'static str, + accelerator: &'static str, +} + +fn native_new_window_menu_spec() -> NativeNewWindowMenuSpec { + NativeNewWindowMenuSpec { + parent_menu: "File", + id: "new-window", + label: "New Window", + accelerator: "CmdOrCtrl+Shift+N", + } +} + +fn native_open_folder_menu_spec() -> NativeNewWindowMenuSpec { + NativeNewWindowMenuSpec { + parent_menu: "File", + id: "open-folder", + label: "Open Folder…", + accelerator: "CmdOrCtrl+O", + } +} + +fn native_preferences_menu_spec() -> NativeNewWindowMenuSpec { + NativeNewWindowMenuSpec { + parent_menu: "Application", + id: "preferences", + label: "Preferences…", + accelerator: "CmdOrCtrl+,", + } +} + +fn focused_window_target<'a>( + windows: impl IntoIterator, +) -> Option<&'a str> { + let mut main = None; + let mut first = None; + + for (label, is_focused) in windows { + if is_focused { + return Some(label); + } + if label == "main" { + main = Some(label); + } + first.get_or_insert(label); + } + + main.or(first) +} + +fn new_window_event_target<'a>( + windows: impl IntoIterator, +) -> Option<&'a str> { + let mut main = None; + let mut first_full_editor = None; + + for (label, is_focused) in windows { + if label.starts_with("preview-") || label == "preferences" { + continue; + } + if is_focused { + return Some(label); + } + if label == "main" { + main = Some(label); + } + first_full_editor.get_or_insert(label); + } + + main.or(first_full_editor) +} + +fn build_application_menu(app_handle: &AppHandle) -> tauri::Result> { + use tauri::menu::{Menu, MenuItem, PredefinedMenuItem, Submenu}; + + let menu = Menu::default(app_handle)?; + let spec = native_new_window_menu_spec(); + let open_folder_spec = native_open_folder_menu_spec(); + let preferences_spec = native_preferences_menu_spec(); + let new_window = MenuItem::with_id( + app_handle, + spec.id, + spec.label, + true, + Some(spec.accelerator), + )?; + let open_folder = MenuItem::with_id( + app_handle, + open_folder_spec.id, + open_folder_spec.label, + true, + Some(open_folder_spec.accelerator), + )?; + let preferences = MenuItem::with_id( + app_handle, + preferences_spec.id, + preferences_spec.label, + true, + Some(preferences_spec.accelerator), + )?; + + let application_menu_name = app_handle + .config() + .product_name + .clone() + .unwrap_or_else(|| app_handle.package_info().name.clone()); + let mut has_file_menu = false; + + for item in menu.items()? { + let Some(submenu) = item.as_submenu() else { + continue; + }; + let text = submenu.text()?; + if text == spec.parent_menu { + has_file_menu = true; + let separator = PredefinedMenuItem::separator(app_handle)?; + submenu.prepend_items(&[&open_folder, &new_window, &separator])?; + } else if text == application_menu_name { + let separator = PredefinedMenuItem::separator(app_handle)?; + submenu.insert_items(&[&preferences, &separator], 1)?; + } + } + + if has_file_menu { + return Ok(menu); + } + + // Tauri omits a default File menu on a few Unix desktop targets. + // Keep the explicit new-window entry available there without replacing + // any of the other platform-native default menus. + let file_menu = Submenu::with_items( + app_handle, + spec.parent_menu, + true, + &[&open_folder, &new_window], + )?; + menu.prepend(&file_menu)?; + Ok(menu) +} + +const NATIVE_NEW_WINDOW_EVENT: &str = "new-window"; +const NATIVE_OPEN_FOLDER_EVENT: &str = "open-folder"; + +fn emit_native_new_window_request(app: &AppHandle) { + let window_states = app + .webview_windows() + .into_iter() + .map(|(label, window)| { + let is_focused = window.is_focused().unwrap_or(false); + (label, is_focused) + }) + .collect::>(); + let target = new_window_event_target( + window_states + .iter() + .map(|(label, is_focused)| (label.as_str(), *is_focused)), + ); + + if let Some(label) = target { + let _ = app.emit_to(label, NATIVE_NEW_WINDOW_EVENT, ()); + } +} + +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 +616,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 +631,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 +641,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 +867,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,30 +1095,335 @@ 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 initialize(app: &AppHandle, path: &Path) -> Result { + let (canonical_path, notes_folder) = prepare_notes_folder(path)?; + Self::initialize_prepared(app, canonical_path, notes_folder) + } + + fn initialize_prepared( + app: &AppHandle, + canonical_path: PathBuf, + notes_folder: String, + ) -> Result { + let settings = load_settings(¬es_folder); + let ignored_dirs = get_effective_ignored_dirs(&settings); + let search_index = get_workspace_search_index_path(app, &canonical_path) + .ok() + .and_then(|index_path| SearchIndex::new(&index_path).ok()) + .inspect(|index| { + let _ = index.rebuild_index(&canonical_path, &ignored_dirs); + }); + + let _ = app + .asset_protocol_scope() + .allow_directory(&canonical_path, true); + + Ok(Self { + notes_folder, + settings: RwLock::new(settings), + notes_cache: RwLock::new(HashMap::new()), + file_watcher: Mutex::new(None), + search_index: Mutex::new(search_index), + debounce_map: Arc::new(Mutex::new(WatcherDebounce::default())), + }) + } +} + +// App state with improved structure +pub struct AppState { + pub app_config: RwLock, // notes_folder path (stored in app data) + pub settings: RwLock, // per-folder settings (stored in .scratch/) + pub 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() @@ -516,7 +1584,12 @@ fn strip_markdown(text: &str) -> String { while let Some(start) = result.find("~~") { if let Some(end) = result[start + 2..].find("~~") { let inner = &result[start + 2..start + 2 + end]; - result = format!("{}{}{}", &result[..start], inner, &result[start + 4 + end..]); + result = format!( + "{}{}{}", + &result[..start], + inner, + &result[start + 4 + end..] + ); } else { break; } @@ -526,7 +1599,12 @@ fn strip_markdown(text: &str) -> String { while let Some(start) = result.find("**") { if let Some(end) = result[start + 2..].find("**") { let inner = &result[start + 2..start + 2 + end]; - result = format!("{}{}{}", &result[..start], inner, &result[start + 4 + end..]); + result = format!( + "{}{}{}", + &result[..start], + inner, + &result[start + 4 + end..] + ); } else { break; } @@ -534,7 +1612,12 @@ fn strip_markdown(text: &str) -> String { while let Some(start) = result.find("__") { if let Some(end) = result[start + 2..].find("__") { let inner = &result[start + 2..start + 2 + end]; - result = format!("{}{}{}", &result[..start], inner, &result[start + 4 + end..]); + result = format!( + "{}{}{}", + &result[..start], + inner, + &result[start + 4 + end..] + ); } else { break; } @@ -544,7 +1627,12 @@ fn strip_markdown(text: &str) -> String { while let Some(start) = result.find('`') { if let Some(end) = result[start + 1..].find('`') { let inner = &result[start + 1..start + 1 + end]; - result = format!("{}{}{}", &result[..start], inner, &result[start + 2 + end..]); + result = format!( + "{}{}{}", + &result[..start], + inner, + &result[start + 2 + end..] + ); } else { break; } @@ -564,7 +1652,12 @@ fn strip_markdown(text: &str) -> String { if let Some(end) = result[start + 1..].find('*') { if end > 0 { let inner = &result[start + 1..start + 1 + end]; - result = format!("{}{}{}", &result[..start], inner, &result[start + 2 + end..]); + result = format!( + "{}{}{}", + &result[..start], + inner, + &result[start + 2 + end..] + ); } else { break; } @@ -577,7 +1670,12 @@ fn strip_markdown(text: &str) -> String { if let Some(end) = result[start + 1..].find('_') { if end > 0 { let inner = &result[start + 1..start + 1 + end]; - result = format!("{}{}{}", &result[..start], inner, &result[start + 2 + end..]); + result = format!( + "{}{}{}", + &result[..start], + inner, + &result[start + 2 + end..] + ); } else { break; } @@ -625,10 +1723,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 +1741,11 @@ fn is_visible_notes_entry(entry: &walkdir::DirEntry, ignored_dirs: &[String]) -> /// Convert an absolute file path to a note ID (relative path from notes root, no .md extension, POSIX separators). /// Returns None if the path is outside the root, not a .md file, or in an excluded/ignored directory. -fn id_from_abs_path(notes_root: &Path, file_path: &Path, ignored_dirs: &[String]) -> Option { +fn id_from_abs_path( + notes_root: &Path, + file_path: &Path, + ignored_dirs: &[String], +) -> Option { let rel = file_path.strip_prefix(notes_root).ok()?; // Skip files inside excluded or ignored directories. @@ -665,7 +1768,9 @@ fn id_from_abs_path(notes_root: &Path, file_path: &Path, ignored_dirs: &[String] // Strip .md by converting to string and trimming (avoids with_extension // which breaks on stems containing dots like "meeting.2024-01-15.md"). let rel_str = rel.to_str()?; - let id = rel_str.strip_suffix(".md")?.replace(std::path::MAIN_SEPARATOR, "/"); + let id = rel_str + .strip_suffix(".md")? + .replace(std::path::MAIN_SEPARATOR, "/"); if id.is_empty() { None @@ -725,11 +1830,22 @@ 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) @@ -758,7 +1874,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 +1901,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,99 +1926,480 @@ 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); + let normalized = canonical.to_string_lossy().into_owned(); + Ok((canonical, normalized)) +} + +fn configure_main_workspace( + app: &AppHandle, + path_buf: &Path, + state: &AppState, + make_default: bool, +) -> Result { + let session = WorkspaceSession::initialize(app, path_buf)?; + let normalized_path = session.notes_folder.clone(); - // 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 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) +} - // Update settings in memory +// TAURI COMMANDS + +#[tauri::command] +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") + .records + .get(window.label()) + .cloned()?; + if let Some(workspace) = state + .workspace_bindings + .read() + .expect("workspace bindings read lock") + .path_for_window(window.label()) { - let mut current_settings = state.settings.write().expect("settings write lock"); - *current_settings = settings; + record.workspace = workspace.to_string(); } + Some(record) +} - // 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())?; +#[tauri::command] +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(()) +} - // Add notes folder to asset protocol scope so images can be served - let _ = app.asset_protocol_scope().allow_directory(path_buf, true); +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) +} - // 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); +fn handle_window_destroyed(app: &AppHandle, label: &str) { + let Some(state) = app.try_state::() else { + return; + }; + let is_quitting = state.is_quitting.load(Ordering::SeqCst); + let preserve_for_preview = state + .preserve_session_labels + .lock() + .expect("preserved session labels mutex") + .remove(label); + { + let mut config = state.app_config.write().expect("app config write lock"); + let mut next = config.clone(); + if remove_window_session_on_destroy(&mut next, label, is_quitting, preserve_for_preview) { + match save_app_config(app, &next) { + Ok(()) => *config = next, + Err(error) => { + eprintln!("Failed to clear closed window session {label}: {error}") + } + } } } + state.remove_workspace_session(label); + let _ = app.emit("workspaces-changed", ()); +} - Ok(normalized_path) +fn is_full_editor_window(label: &str) -> bool { + label == "main" || label.starts_with("workspace-") } -// TAURI COMMANDS +fn request_full_window_closure_for_preview(app: &AppHandle) { + let Some(state) = app.try_state::() else { + return; + }; + let windows: Vec<_> = app + .webview_windows() + .into_iter() + .filter(|(label, _)| is_full_editor_window(label)) + .collect(); + { + let mut preserved = state + .preserve_session_labels + .lock() + .expect("preserved session labels mutex"); + preserved.extend(windows.iter().map(|(label, _)| label.clone())); + } + for (_, window) in windows { + let _ = window.close(); + } +} #[tauri::command] -fn get_notes_folder(state: State) -> Option { - state +fn clear_window_session(window: WebviewWindow, state: State) -> Result<(), String> { + clear_window_session_record(window.app_handle(), &state, window.label())?; + Ok(()) +} + +/// Finalize a close request only after the WebView has flushed the draft or +/// persisted a recovery snapshot. Keeping force-destroy in trusted Rust avoids +/// exposing `plugin:window|destroy` to editor WebViews. +#[tauri::command] +fn close_window_after_save(window: WebviewWindow) -> Result<(), String> { + window + .destroy() + .map_err(|error| format!("Failed to close window after save: {error}")) +} + +#[tauri::command] +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") - .notes_folder - .clone() + .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] -fn set_notes_folder(app: AppHandle, path: String, state: State) -> Result<(), String> { +async fn switch_workspace( + app: AppHandle, + window: WebviewWindow, + path: String, + state: State<'_, AppState>, +) -> Result { let path_buf = normalize_notes_folder_path(&path)?; - initialize_notes_folder(&app, &path_buf, &state)?; + 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 _ = 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 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")? +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() { @@ -904,7 +2407,7 @@ async fn list_notes(state: State<'_, AppState>) -> Result, Str } 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) }; @@ -954,7 +2457,7 @@ async fn list_notes(state: State<'_, AppState>) -> Result, Str // Load pinned note IDs from settings let pinned_ids: HashSet = { - let settings = state.settings.read().expect("settings read lock"); + let settings = workspace.settings().read().expect("settings read lock"); settings .pinned_note_ids .as_ref() @@ -968,15 +2471,15 @@ async fn list_notes(state: State<'_, AppState>) -> Result, Str let b_pinned = pinned_ids.contains(&b.id); match (a_pinned, b_pinned) { - (true, false) => std::cmp::Ordering::Less, // a pinned, b not -> a first + (true, false) => std::cmp::Ordering::Less, // a pinned, b not -> a first (false, true) => std::cmp::Ordering::Greater, // b pinned, a not -> b first - _ => b.modified.cmp(&a.modified), // both same status -> sort by date (newest first) + _ => b.modified.cmp(&a.modified), // both same status -> sort by date (newest first) } }); // Update cache efficiently { - let mut cache = state.notes_cache.write().expect("cache write lock"); + let mut cache = workspace.notes_cache().write().expect("cache write lock"); cache.clear(); for note in ¬es { cache.insert(note.id.clone(), note.clone()); @@ -987,68 +2490,303 @@ async fn list_notes(state: State<'_, AppState>) -> Result, Str } #[tauri::command] -async fn read_note(id: String, state: State<'_, AppState>) -> Result { - let folder = { - let app_config = state.app_config.read().expect("app_config read lock"); - app_config - .notes_folder - .clone() - .ok_or("Notes folder not set")? - }; - - let folder_path = PathBuf::from(&folder); - let file_path = abs_path_from_id(&folder_path, &id)?; +async fn read_note( + id: String, + window: WebviewWindow, + state: State<'_, AppState>, +) -> Result { + let workspace = state.workspace_for_window(window.label())?; + let file_path = workspace.note_path(&id)?; if !file_path.exists() { return Err("Note not found".to_string()); } - let content = fs::read_to_string(&file_path) - .await - .map_err(|e| e.to_string())?; - let metadata = fs::metadata(&file_path) + tauri::async_runtime::spawn_blocking(move || read_note_from_path(id, &file_path)) .await - .map_err(|e| e.to_string())?; + .map_err(|error| format!("Note read task failed: {error}"))? +} +fn read_note_from_path(id: String, file_path: &Path) -> Result { + let snapshot = persistence::read_snapshot(file_path) + .map_err(|error| error.to_string())? + .ok_or_else(|| "Note not found".to_string())?; + let metadata = std::fs::metadata(file_path).map_err(|error| error.to_string())?; let modified = metadata .modified() .ok() - .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|d| d.as_secs() as i64) + .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|duration| duration.as_secs() as i64) .unwrap_or(0); Ok(Note { id, - title: extract_title(&content), - content, + title: extract_title(&snapshot.content), + content: snapshot.content, path: file_path.to_string_lossy().into_owned(), modified, + revision: snapshot.revision.to_string(), + }) +} + +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(); // 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)) + ( + Some(prefix.to_string()), + format!("{}/{}", prefix, sanitized_leaf), + ) } else { (None, sanitized_leaf.clone()) }; @@ -1094,31 +2832,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 { + 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 +2930,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 +2955,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,30 +2963,44 @@ async fn delete_note(id: String, state: State<'_, AppState>) -> Result<(), Strin // Remove from cache { - let mut cache = state.notes_cache.write().expect("cache write lock"); + let mut cache = workspace.notes_cache().write().expect("cache write lock"); cache.remove(&id); } + emit_workspace_file_change( + &app, + &state, + &file_path, + semantic_file_change_event( + workspace_path, + "deleted", + Some(id), + None, + Some(window.label().to_string()), + ), + Some(window.label()), + ); + Ok(()) } #[tauri::command] -async fn create_note(target_folder: Option, state: State<'_, AppState>) -> Result { - let folder = { - let app_config = state.app_config.read().expect("app_config read lock"); - app_config - .notes_folder - .clone() - .ok_or("Notes folder not set")? - }; +async fn create_note( + target_folder: Option, + window: WebviewWindow, + state: State<'_, AppState>, +) -> Result { + let workspace = state.workspace_for_window(window.label())?; + let folder = workspace.notes_folder()?; let folder_path = PathBuf::from(&folder); - // Get template from settings (default "Untitled") + // Get global template (default "Untitled") let template = { - let settings = state.settings.read().expect("settings read lock"); - settings - .default_note_name - .clone() + let config = state.app_config.read().expect("app config read lock"); + config + .global_settings + .as_ref() + .and_then(|settings| settings.default_note_name.clone()) .unwrap_or_else(|| "Untitled".to_string()) }; @@ -1225,56 +3032,238 @@ async fn create_note(target_folder: Option, state: State<'_, AppState>) let mut final_id = base_id.clone(); let mut counter = if has_counter { 2 } else { 1 }; - // Ensure filename uniqueness + // Extract display title from filename + let display_title = extract_title_from_id(&final_id); + let content = format!("# {}\n\n", display_title); + let file_path = abs_path_from_id(&folder_path, &final_id)?; + + // Create parent directories (for templates like {year}/{month}/{day}) + if let Some(parent) = file_path.parent() { + fs::create_dir_all(parent) + .await + .map_err(|e| e.to_string())?; + } + + loop { + let create_path = file_path.clone(); + let create_content = content.clone(); + let _create_id = final_id.clone(); + let result = tauri::async_runtime::spawn_blocking(move || { + persistence::save_if_revision(&create_path, &create_content, None) + }) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string())?; + + match result { + persistence::SaveResult::Saved { .. } => break, + persistence::SaveResult::Conflict { current } => { + if current.is_none() { + return Err("Note file unexpectedly already exists".to_string()); + } + final_id = if has_counter { + sanitized.replace("{counter}", &counter.to_string()) + } else { + format!("{}-{}", base_id, counter) + }; + counter += 1; + } + } + } + + let modified = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + + // Update search index + { + let index = workspace.search_index().lock().expect("search index mutex"); + if let Some(ref search_index) = *index { + let _ = search_index.index_note(&final_id, &display_title, &content, modified); + } + } + + let created_note = Note { + id: final_id, + title: display_title, + revision: persistence::content_revision(&content).to_string(), + content, + path: file_path.to_string_lossy().into_owned(), + modified, + }; + + { + let mut cache = workspace.notes_cache().write().expect("cache write lock"); + cache.insert( + created_note.id.clone(), + NoteMetadata { + id: created_note.id.clone(), + title: created_note.title.clone(), + preview: generate_preview(&created_note.content), + modified: created_note.modified, + }, + ); + } + + emit_workspace_file_change( + window.app_handle(), + &state, + Path::new(&created_note.path), + semantic_file_change_event( + folder, + "created", + None, + Some(&created_note), + Some(window.label().to_string()), + ), + Some(window.label()), + ); + + Ok(created_note) +} + +#[tauri::command] +async fn duplicate_note( + id: String, + window: WebviewWindow, + state: State<'_, AppState>, +) -> Result { + let workspace = state.workspace_for_window(window.label())?; + let folder = workspace.notes_folder()?; + let folder_path = PathBuf::from(&folder); + + let original = read_note(id.clone(), window.clone(), state.clone()) + .await + .map_err(|e| format!("Failed to read original note: {e}"))?; + + let base_title = extract_title(&original.content); + let copy_title = if base_title.trim().is_empty() { + "Copy" + } else { + &base_title + }; + let copy_title = format!("{} (Copy)", copy_title); + let sanitized = sanitize_filename(©_title); + + let folder_prefix = id.rfind('/').map(|pos| id[..pos].to_string()); + let sanitized = if let Some(ref prefix) = folder_prefix { + format!("{}/{}", prefix, sanitized) + } else { + sanitized + }; + + let mut final_id = sanitized.clone(); + let mut counter = 1; while abs_path_from_id(&folder_path, &final_id) .map(|p| p.exists()) .unwrap_or(false) { - if has_counter { - final_id = sanitized.replace("{counter}", &counter.to_string()); + final_id = if let Some(ref prefix) = folder_prefix { + format!("{}/{}-{}", prefix, copy_title, counter) } else { - final_id = format!("{}-{}", base_id, counter); - } + format!("{}-{}", copy_title, counter) + }; counter += 1; } - // Extract display title from filename + let duplicated_content = if original.content.starts_with("# ") { + if let Some(rest) = original.content.strip_prefix("# ") { + let first_line_end = rest.find('\n').map_or(rest.len(), |i| i); + let title = &rest[..first_line_end]; + format!("# {} (Copy){}", title, &rest[first_line_end..]) + } else { + format!("# {} (Copy)", base_title) + } + } else { + original.content.clone() + }; let display_title = extract_title_from_id(&final_id); - - let content = format!("# {}\n\n", display_title); + let content = duplicated_content; let file_path = abs_path_from_id(&folder_path, &final_id)?; - // Create parent directories (for templates like {year}/{month}/{day}) if let Some(parent) = file_path.parent() { fs::create_dir_all(parent) .await .map_err(|e| e.to_string())?; } - fs::write(&file_path, &content) + loop { + let create_path = file_path.clone(); + let create_content = content.clone(); + let _create_id = final_id.clone(); + let result = tauri::async_runtime::spawn_blocking(move || { + persistence::save_if_revision(&create_path, &create_content, None) + }) .await + .map_err(|e| e.to_string())? .map_err(|e| e.to_string())?; + match result { + persistence::SaveResult::Saved { .. } => break, + persistence::SaveResult::Conflict { current } => { + if current.is_none() { + return Err("Duplicate note file unexpectedly already exists".to_string()); + } + final_id = if let Some(ref prefix) = folder_prefix { + format!("{}/{}-{}", prefix, copy_title, counter) + } else { + format!("{}-{}", copy_title, counter) + }; + counter += 1; + } + } + } + let modified = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs() as i64) .unwrap_or(0); - // Update search index { - let index = 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 +3301,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 +3341,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 +3364,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 +3386,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 +3400,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 +3416,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 +3470,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 +3486,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 +3505,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 +3520,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 +3554,9 @@ async fn move_note( // Ensure target directory exists if let Some(parent) = dest_path.parent() { - fs::create_dir_all(parent).await.map_err(|e| e.to_string())?; + fs::create_dir_all(parent) + .await + .map_err(|e| e.to_string())?; } // Handle collision @@ -1591,7 +3570,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 +3583,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 +3592,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 +3631,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 +3675,9 @@ async fn move_folder( // Ensure target parent exists if let Some(parent) = dest.parent() { - fs::create_dir_all(parent).await.map_err(|e| e.to_string())?; + fs::create_dir_all(parent) + .await + .map_err(|e| e.to_string())?; } // Compute old and new path prefixes for updating IDs @@ -1696,7 +3695,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 +3708,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 +3727,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 +3741,139 @@ async fn move_folder( } #[tauri::command] -fn get_settings(state: State) -> Settings { - state.settings.read().expect("settings read lock").clone() +fn get_settings(window: WebviewWindow, state: State) -> Settings { + let workspace_settings = state + .workspace_for_window(window.label()) + .map(|workspace| { + workspace + .settings() + .read() + .expect("settings read lock") + .clone() + }) + .unwrap_or_default(); + let global_settings = state + .app_config + .read() + .expect("app config read lock") + .global_settings + .clone() + .unwrap_or_default(); + + merge_settings(&global_settings, &workspace_settings) +} + +fn emit_settings_changed( + app: &AppHandle, + state: &AppState, + scope: SettingsChangeScope, + workspace: Option, +) { + let payload = SettingsChangedPayload { scope, workspace }; + let targets = { + let bindings = state + .workspace_bindings + .read() + .expect("workspace bindings read lock"); + settings_change_targets(&bindings, scope, payload.workspace.as_deref()) + }; + + match targets { + SettingsChangeTargets::All => { + let _ = app.emit("settings-changed", payload); + } + SettingsChangeTargets::Windows(labels) => { + for label in labels { + let _ = app.emit_to(&label, "settings-changed", payload.clone()); + } + } + } } #[tauri::command] -fn update_settings( - new_settings: Settings, +fn update_global_settings( + patch: serde_json::Value, + window: WebviewWindow, state: State, ) -> Result<(), String> { - let folder = { - let app_config = state.app_config.read().expect("app_config read lock"); - app_config.notes_folder.clone().ok_or("Notes folder not set")? - }; + { + let mut config = state.app_config.write().expect("app config write lock"); + let current = config.global_settings.clone().unwrap_or_default(); + let updated = apply_settings_patch(¤t, patch)?; + let mut next_config = config.clone(); + next_config.global_settings = Some(updated); + save_app_config(window.app_handle(), &next_config).map_err(|error| error.to_string())?; + *config = next_config; + } + + emit_settings_changed( + window.app_handle(), + &state, + SettingsChangeScope::Global, + None, + ); + Ok(()) +} +#[tauri::command] +fn update_workspace_settings( + patch: serde_json::Value, + window: WebviewWindow, + state: State, +) -> Result<(), String> { + let workspace = state.workspace_for_window(window.label())?; + let folder = workspace.notes_folder()?; { - let mut settings = state.settings.write().expect("settings write lock"); - *settings = new_settings; + let mut settings = workspace.settings().write().expect("settings write lock"); + let updated = apply_settings_patch(&*settings, patch)?; + save_settings(&folder, &updated).map_err(|error| error.to_string())?; + *settings = updated; } - let settings = state.settings.read().expect("settings read lock"); - save_settings(&folder, &settings).map_err(|e| e.to_string())?; + emit_settings_changed( + window.app_handle(), + &state, + SettingsChangeScope::Workspace, + Some(folder), + ); + Ok(()) +} + +#[tauri::command] +fn update_settings( + new_settings: Settings, + window: WebviewWindow, + state: State, +) -> Result<(), String> { + let workspace = state.workspace_for_window(window.label())?; + let folder = workspace.notes_folder()?; + let (global_settings, workspace_settings) = split_settings(new_settings); + + { + // Compatibility full-replacement path. Keep both scope locks through + // persistence so it cannot interleave with the atomic patch commands. + let mut config = state.app_config.write().expect("app config write lock"); + let mut settings = workspace.settings().write().expect("settings write lock"); + let mut next = config.clone(); + next.global_settings = Some(global_settings); + save_app_config(window.app_handle(), &next).map_err(|error| error.to_string())?; + save_settings(&folder, &workspace_settings).map_err(|error| error.to_string())?; + *config = next; + *settings = workspace_settings; + } + + emit_settings_changed( + window.app_handle(), + &state, + SettingsChangeScope::Global, + None, + ); + emit_settings_changed( + window.app_handle(), + &state, + SettingsChangeScope::Workspace, + Some(folder), + ); Ok(()) } @@ -1771,27 +3882,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 +3930,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 +4028,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 +4078,17 @@ async fn read_file_direct(path: String) -> Result { return Err(format!("Not a file: {}", path)); } - let content = fs::read_to_string(&canonical) - .await - .map_err(|_| "Failed to read file".to_string())?; - let metadata = fs::metadata(&canonical) + tauri::async_runtime::spawn_blocking(move || read_file_content_from_path(&canonical)) .await - .map_err(|_| "Failed to read metadata".to_string())?; - - let modified = metadata - .modified() - .ok() - .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|d| d.as_secs() as i64) - .unwrap_or(0); - - let title = extract_title(&content); - - Ok(FileContent { - path, - content, - title, - modified, - }) + .map_err(|error| format!("Direct file read task failed: {error}"))? } #[tauri::command] -async fn save_file_direct(path: String, content: String) -> Result { +async fn save_file_direct( + path: String, + content: String, + expected_revision: String, +) -> Result { // For save, the file must already exist (we validate extension + path security) let canonical = validate_preview_path(&path)?; @@ -1886,34 +4096,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 +4126,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 +4173,7 @@ async fn import_file_to_folder( } Err(_) => return Err("Failed to create file".to_string()), } - }; + } let modified = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -1982,7 +4182,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 +4205,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 +4243,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 +4322,165 @@ async fn fallback_search(query: &str, state: &State<'_, AppState>) -> Result, + revision: Option, + origin_window: Option, + previous_id: Option, + current_id: Option, +} + +fn semantic_file_change_event( + workspace: String, + kind: &str, + previous_id: Option, + current_note: Option<&Note>, + origin_window: Option, +) -> FileChangeEvent { + let current_id = current_note.map(|note| note.id.clone()); + let mut changed_ids = Vec::new(); + if let Some(id) = previous_id.as_ref() { + changed_ids.push(id.clone()); + } + if let Some(id) = current_id.as_ref() { + if !changed_ids.contains(id) { + changed_ids.push(id.clone()); + } + } + + let path = current_note + .map(|note| note.path.clone()) + .unwrap_or_else(|| { + let id = previous_id.as_deref().unwrap_or_default(); + PathBuf::from(&workspace) + .join(format!("{id}.md")) + .to_string_lossy() + .into_owned() + }); + + FileChangeEvent { + workspace, + kind: kind.to_string(), + path, + changed_ids, + revision: current_note.map(|note| note.revision.clone()), + origin_window, + previous_id, + current_id, + } +} + +fn emit_workspace_file_change( + app: &AppHandle, + state: &AppState, + note_path: &Path, + payload: FileChangeEvent, + origin_window: Option<&str>, +) { + let labels = { + let bindings = state + .workspace_bindings + .read() + .expect("workspace bindings read lock"); + note_change_targets(&bindings, note_path, origin_window) + }; + for label in labels { + let _ = app.emit_to(&label, "file-change", payload.clone()); + } +} + +fn process_external_note_change( + app_handle: AppHandle, + workspace_path: String, + legacy_main_runtime: bool, + path: PathBuf, + note_id: String, + kind: String, +) { + if let Some(state) = app_handle.try_state::() { + let workspace = if legacy_main_runtime { + Some(WorkspaceRuntime::Main(state.inner())) + } else { + state + .workspace_session_for_path(&workspace_path) + .map(WorkspaceRuntime::Session) + }; + if let Some(workspace) = workspace { + let index = workspace.search_index().lock().expect("search index mutex"); + if let Some(ref search_index) = *index { + match kind.as_str() { + "created" | "modified" => match std::fs::read_to_string(&path) { + Ok(content) => { + let title = extract_title(&content); + let modified = std::fs::metadata(&path) + .ok() + .and_then(|metadata| metadata.modified().ok()) + .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|duration| duration.as_secs() as i64) + .unwrap_or(0); + let _ = search_index.index_note(¬e_id, &title, &content, modified); + } + Err(_) if !path.exists() => { + let _ = search_index.delete_note(¬e_id); + } + Err(_) => {} + }, + "deleted" => { + let _ = search_index.delete_note(¬e_id); + } + _ => {} + } + } + } + } + + let effective_kind = if kind == "modified" && !path.exists() { + "deleted" + } else { + kind.as_str() + }; + let revision = std::fs::read_to_string(&path) + .ok() + .map(|content| persistence::content_revision(&content).to_string()); + let payload = FileChangeEvent { + workspace: workspace_path, + kind: effective_kind.to_string(), + path: path.to_string_lossy().into_owned(), + changed_ids: vec![note_id.clone()], + revision, + origin_window: None, + previous_id: (effective_kind == "deleted").then_some(note_id.clone()), + current_id: (effective_kind != "deleted").then_some(note_id), + }; + if let Some(state) = app_handle.try_state::() { + emit_workspace_file_change(&app_handle, &state, &path, payload, None); + } } fn setup_file_watcher( app: AppHandle, notes_folder: &str, - debounce_map: Arc>>, + legacy_main_runtime: bool, + debounce_map: Arc>, ) -> Result { let folder_path = PathBuf::from(notes_folder); let notes_root = folder_path.clone(); + let workspace_path = notes_folder.to_string(); let app_handle = app.clone(); let watcher = RecommendedWatcher::new( @@ -2147,8 +4489,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 +4514,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 +4522,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 +4570,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 +4599,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 +4607,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 +4653,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 +4713,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 +4736,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 +4828,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 +4882,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 +4904,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 +4928,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 +4950,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 +4975,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 +5000,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 +5024,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 +5040,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 +5174,42 @@ fn cli_target_path() -> PathBuf { #[tauri::command] fn get_cli_status() -> Result { #[cfg(not(target_os = "macos"))] - return Ok(CliStatus { supported: false, installed: false, path: None }); + return Ok(CliStatus { + supported: false, + installed: false, + path: None, + }); #[cfg(target_os = "macos")] { let target = cli_target_path(); if !target.exists() && target.symlink_metadata().is_err() { - return Ok(CliStatus { supported: true, installed: false, path: None }); + return Ok(CliStatus { + supported: true, + installed: false, + path: None, + }); } // Verify this is our wrapper (has marker) and points to the current binary let content = std::fs::read_to_string(&target).unwrap_or_default(); if !content.contains(SCRATCH_CLI_MARKER) { // Foreign binary at this path — don't claim it as ours - return Ok(CliStatus { supported: true, installed: false, path: None }); + return Ok(CliStatus { + supported: true, + installed: false, + path: None, + }); } let current_exe = std::env::current_exe() .map(|p| p.to_string_lossy().into_owned()) .unwrap_or_default(); if !current_exe.is_empty() && !content.contains(¤t_exe) { // Our wrapper but points to a moved/deleted binary — needs reinstall - return Ok(CliStatus { supported: true, installed: false, path: None }); + return Ok(CliStatus { + supported: true, + installed: false, + path: None, + }); } Ok(CliStatus { supported: true, @@ -2929,8 +5248,8 @@ fn install_cli() -> Result { .map_err(|e| format!("Failed to remove existing file: {}", e))?; } - let exe_path = std::env::current_exe() - .map_err(|e| format!("Cannot find exe path: {}", e))?; + let exe_path = + std::env::current_exe().map_err(|e| format!("Cannot find exe path: {}", e))?; // Shell-escape the exe path using single quotes to prevent // interpretation of $, `, ", and other metacharacters. @@ -2941,8 +5260,7 @@ fn install_cli() -> Result { // the terminal is not blocked waiting for the GUI app to exit. let script = format!( "#!/bin/sh\n{}\nnohup {} \"$@\" >/dev/null 2>&1 &\n", - SCRATCH_CLI_MARKER, - escaped_exe + SCRATCH_CLI_MARKER, escaped_exe ); std::fs::write(&target, script.as_bytes()) .map_err(|e| format!("Failed to write CLI script: {}", e))?; @@ -3226,12 +5544,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 +5610,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 +5675,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 +5779,10 @@ async fn ai_execute_ollama( return Ok(AiExecutionResult { success: false, output: String::new(), - error: Some("Authentication required. Run `ollama login` in your terminal to sign in.".to_string()), + error: Some( + "Authentication required. Run `ollama login` in your terminal to sign in." + .to_string(), + ), }); } } @@ -3497,8 +5812,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 +5821,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 +5853,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 +5862,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 +5881,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 +6041,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 +6083,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 +6110,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 +6119,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 +6177,31 @@ 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_new_window_menu_spec().id { + emit_native_new_window_request(app); + } else if event.id() == native_open_folder_menu_spec().id { + emit_native_open_folder_request(app); + } else if event.id() == native_preferences_menu_spec().id { + if let Err(error) = create_preferences_window(app) { + eprintln!("Failed to open Preferences: {error}"); + } + } + }) .setup(|app| { // Load app config on startup (contains notes folder path) let mut app_config = load_app_config(app.handle()); @@ -3738,7 +6219,10 @@ pub fn run() { Ok(normalized) => { // Path is structurally valid but not currently a directory // (e.g., unmounted drive). Preserve the user's preference. - eprintln!("Notes folder not found (may be temporarily unavailable): {:?}", normalized); + eprintln!( + "Notes folder not found (may be temporarily unavailable): {:?}", + normalized + ); } Err(_) => { app_config.notes_folder = None; @@ -3747,72 +6231,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 +6376,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 +6396,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 +6424,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 +6461,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 +6475,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 +6531,7 @@ mod windows_title_bar { // Windows COLORREF is little-endian 0x00BBGGRR let (r, g, b) = rgb; - let caption_color: u32 = - ((b as u32) << 16) | ((g as u32) << 8) | (r as u32); + let caption_color: u32 = ((b as u32) << 16) | ((g as u32) << 8) | (r as u32); unsafe { let set_attr = |attr: u32, value: *const std::ffi::c_void, size: u32| { @@ -3974,7 +6560,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 +6568,1338 @@ 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_new_window_menu_tests { + use super::{ + focused_window_target, is_full_editor_window, native_new_window_menu_spec, + native_open_folder_menu_spec, native_preferences_menu_spec, new_window_event_target, + }; + + #[test] + fn file_menu_exposes_new_window_with_the_requested_shortcut() { + let spec = native_new_window_menu_spec(); + + assert_eq!(spec.parent_menu, "File"); + assert_eq!(spec.id, "new-window"); + assert_eq!(spec.label, "New Window"); + assert_eq!(spec.accelerator, "CmdOrCtrl+Shift+N"); + } + + #[test] + fn native_menus_expose_open_folder_and_preferences() { + let open_folder = native_open_folder_menu_spec(); + assert_eq!(open_folder.parent_menu, "File"); + assert_eq!(open_folder.id, "open-folder"); + assert_eq!(open_folder.label, "Open Folder…"); + assert_eq!(open_folder.accelerator, "CmdOrCtrl+O"); + + let preferences = native_preferences_menu_spec(); + assert_eq!(preferences.id, "preferences"); + assert_eq!(preferences.label, "Preferences…"); + assert_eq!(preferences.accelerator, "CmdOrCtrl+,"); + } + + #[test] + fn native_new_window_action_targets_one_full_editor_window() { + let windows = [ + ("main", false), + ("workspace-client", true), + ("preview-note", false), + ]; + assert_eq!( + new_window_event_target(windows.iter().copied()), + Some("workspace-client") + ); + + let preview_focused = [("main", false), ("preview-note", true)]; + assert_eq!( + new_window_event_target(preview_focused.iter().copied()), + Some("main") + ); + } + + #[test] + fn open_folder_targets_the_focused_window_including_standalone_preview() { + let windows = [("main", false), ("preview-note", true)]; + assert_eq!( + focused_window_target(windows.iter().copied()), + Some("preview-note") + ); + } + + #[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 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..50f9514f --- /dev/null +++ b/src-tauri/src/persistence.rs @@ -0,0 +1,515 @@ +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`. +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.css b/src/App.css index d094bedf..63040025 100644 --- a/src/App.css +++ b/src/App.css @@ -16,6 +16,8 @@ --color-border: rgba(28, 25, 23, 0.08); --color-accent: #1c1917; --color-selection: rgba(250, 204, 21, 0.4); /* Tailwind yellow-400 */ + --editor-selection-bg: #fef9c3; + --editor-selection-text: var(--color-text); /* Editor font settings - simplified (overridden by ThemeContext) */ --editor-font-family: @@ -61,6 +63,8 @@ --color-border: rgba(250, 249, 249, 0.07); --color-accent: #fafaf9; --color-selection: rgba(253, 224, 71, 0.35); /* Tailwind yellow-300 */ + --editor-selection-bg: #33301c; + --editor-selection-text: var(--color-text); /* Syntax highlighting colors - Dark (GitHub) */ --color-syntax-keyword: #ea4a5a; @@ -154,6 +158,63 @@ body { background-color: var(--color-selection); } +/* Keep native selection paint. This decoration remains visible when editor + focus moves to the contextual formatting controls. */ +.scratch-text-selection { + color: inherit; + background-color: var(--editor-selection-bg); + border-radius: 2px; + box-shadow: 0 0 0 1px var(--editor-selection-bg); + -webkit-box-decoration-break: clone; + box-decoration-break: clone; +} + +.ProseMirror [data-text-color] { + color: var(--scratch-text-color-light); +} + +.dark .ProseMirror [data-text-color] { + color: var(--scratch-text-color-dark); +} + +.ProseMirror mark { + --scratch-highlight-surface: var( + --scratch-highlight-color-light, + #fef9c3 + ); + + color: inherit; + background-color: var(--scratch-highlight-surface); + border-radius: 2px; + box-shadow: 0 0 0 1px var(--scratch-highlight-surface); + -webkit-box-decoration-break: clone; + box-decoration-break: clone; +} + +.dark .ProseMirror mark { + --scratch-highlight-surface: var( + --scratch-highlight-color-dark, + #33301c + ); +} + +@media (prefers-contrast: more) { + .scratch-text-selection, + .ProseMirror mark { + box-shadow: 0 0 0 1px var(--editor-selection-text); + } +} + +@media (forced-colors: active) { + .scratch-text-selection, + .ProseMirror mark { + color: HighlightText; + background-color: Highlight; + box-shadow: 0 0 0 1px Highlight; + forced-color-adjust: none; + } +} + /* Hide scrollbar while allowing scroll */ .scrollbar-none { scrollbar-width: none; /* Firefox */ @@ -842,7 +903,8 @@ table.not-prose td { padding: 0.5em 0.75em; text-align: left; vertical-align: top; - min-width: 120px; + /* Match TableKit cellMinWidth and persisted width normalization. */ + min-width: 80px; } /* Avoid double lines with separate border model */ @@ -887,6 +949,17 @@ table.not-prose th > * { pointer-events: none; } +.scratch-table-cell-focused { + box-shadow: inset 0 0 0 2px + color-mix(in srgb, var(--color-accent) 68%, transparent); +} + +@media (prefers-contrast: more) { + .scratch-table-cell-focused { + box-shadow: inset 0 0 0 3px var(--color-accent); + } +} + /* stylelint-disable selector-class-pattern */ /* Table wrapper - scrollable container with border (class name generated by TipTap) */ @@ -909,9 +982,26 @@ table.not-prose th > * { .tableWrapper table { margin: 0; - width: max-content !important; + width: max-content; + max-width: none; + min-width: 0; + table-layout: fixed; +} + +.tableWrapper table[data-fit-to-width="true"] { + width: 100% !important; max-width: 100% !important; - min-width: 0 !important; + table-layout: fixed; +} + +.ProseMirror td[data-table-background-color], +.ProseMirror th[data-table-background-color] { + background-color: var(--scratch-table-background-light); +} + +.dark .ProseMirror td[data-table-background-color], +.dark .ProseMirror th[data-table-background-color] { + background-color: var(--scratch-table-background-dark); } /* stylelint-enable selector-class-pattern */ @@ -923,6 +1013,496 @@ table.not-prose th { position: relative; } +/* Selection formatting and table controls */ +/* Notion-like contextual editor controls */ +.notion-selection-toolbar, +.notion-table-actionbar { + display: flex; + align-items: center; + gap: 2px; + padding: 4px; + color: var(--color-text); + background: color-mix(in srgb, var(--color-bg) 96%, transparent); + border: 1px solid color-mix(in srgb, var(--color-text) 12%, transparent); + border-radius: 8px; + box-shadow: + 0 10px 30px rgba(0, 0, 0, 0.12), + 0 2px 8px rgba(0, 0, 0, 0.08); + backdrop-filter: blur(14px); +} + +.notion-selection-menu { + z-index: 70; + overflow: visible; +} + +.notion-selection-toolbar { + max-width: min(94vw, 760px); + overflow-x: auto; + scrollbar-width: none; +} + +.notion-selection-toolbar::-webkit-scrollbar { + display: none; +} + +.notion-menu-button, +.notion-table-action { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 29px; + height: 29px; + padding: 0 7px; + color: var(--color-text-muted); + font-size: 12px; + line-height: 1; + white-space: nowrap; + border: 0; + border-radius: 5px; + background: transparent; + cursor: pointer; +} + +.notion-menu-button:hover, +.notion-table-action:hover:not(:disabled), +.notion-menu-button.is-active { + color: var(--color-text); + background: var(--color-bg-emphasis); +} + +.notion-block-style-trigger { + min-width: 72px; + gap: 5px; + justify-content: flex-start; + color: var(--color-text); + font-weight: 500; +} + +.notion-block-style-chevron { + margin-left: auto; + color: var(--color-text-muted); +} + +.notion-block-style-menu { + z-index: 90; + width: 190px; + max-height: min(360px, calc(100vh - 24px)); + padding: 5px; + color: var(--color-text); + background: var(--color-bg); + border: 1px solid var(--color-border); + border-radius: 8px; + box-shadow: 0 12px 34px rgba(0, 0, 0, 0.16); + outline: none; + overflow-y: auto; +} + +.notion-block-style-separator { + height: 1px; + margin: 4px 6px; + background: var(--color-border); +} + +.notion-block-style-item { + display: grid; + grid-template-columns: 20px 1fr 16px; + align-items: center; + gap: 8px; + min-height: 32px; + padding: 4px 7px; + color: var(--color-text-muted); + font-size: 12px; + border-radius: 5px; + outline: none; + cursor: pointer; +} + +.notion-block-style-item:hover, +.notion-block-style-item:focus, +.notion-block-style-item[data-highlighted], +.notion-block-style-item.is-active { + color: var(--color-text); + background: var(--color-bg-emphasis); +} + +.notion-table-action:disabled { + opacity: 0.3; + cursor: default; +} + +.notion-menu-separator, +.notion-table-action-separator { + width: 1px; + height: 20px; + margin: 0 3px; + background: var(--color-border); +} + +.notion-color-trigger { + position: relative; + flex-direction: column; + gap: 1px; +} + +.notion-color-indicator { + width: 15px; + height: 2px; + border-radius: 999px; +} + +.notion-color-indicator[data-theme-color="true"], +.notion-color-swatch { + background-color: var(--notion-color-light); +} + +.dark .notion-color-indicator[data-theme-color="true"], +.dark .notion-color-swatch { + background-color: var(--notion-color-dark); +} + +.notion-color-menu { + z-index: 90; + width: 160px; + padding: 7px; + color: var(--color-text); + background: var(--color-bg); + border: 1px solid var(--color-border); + border-radius: 8px; + box-shadow: 0 12px 34px rgba(0, 0, 0, 0.16); +} + +.notion-color-clear { + padding: 6px 7px; + margin-bottom: 5px; + font-size: 12px; + border-radius: 5px; + outline: none; + cursor: pointer; +} + +.notion-color-clear:hover, +.notion-color-clear:focus { + background: var(--color-bg-emphasis); +} + +.notion-color-grid { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 5px; +} + +.notion-color-swatch { + width: 24px; + height: 24px; + border: 2px solid var(--color-bg); + border-radius: 5px; + outline: 1px solid color-mix(in srgb, var(--color-text) 12%, transparent); + cursor: pointer; +} + +.notion-color-swatch.is-active { + outline: 2px solid var(--color-accent); +} + +.notion-table-controls { + position: fixed; + inset: 0; + z-index: 55; + pointer-events: none; +} + +.notion-table-actionbar { + position: fixed; + z-index: 58; + max-width: calc(100vw - 16px); + overflow-x: auto; + scrollbar-width: none; + pointer-events: auto; +} + +.notion-table-actionbar::-webkit-scrollbar { + display: none; +} + +.notion-table-action-search { + width: 116px; + min-width: 96px; + height: 29px; + padding: 0 8px; + color: var(--color-text); + font: inherit; + font-size: 12px; + border: 1px solid var(--color-border); + border-radius: 5px; + outline: none; + background: var(--color-bg); +} + +.notion-table-action-search:focus-visible { + border-color: color-mix(in srgb, var(--color-accent) 65%, var(--color-border)); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-accent) 22%, transparent); +} + +.notion-table-color-chip { + width: 14px; + height: 14px; + border: 1px solid color-mix(in srgb, var(--color-text) 22%, transparent); + border-radius: 3px; + background: var(--color-accent); +} + +.notion-table-color-palette { + display: inline-flex; + align-items: center; + gap: 3px; + padding-left: 4px; + border-left: 1px solid var(--color-border); +} + +.notion-table-color-swatch { + width: 28px; + min-width: 28px; + height: 28px; + padding: 0; + border: 1px solid color-mix(in srgb, var(--color-text) 16%, transparent); + border-radius: 5px; + outline: none; + background: var(--scratch-table-swatch-light); + cursor: pointer; +} + +.dark .notion-table-color-swatch { + background: var(--scratch-table-swatch-dark); +} + +.notion-table-color-swatch:hover, +.notion-table-color-swatch:focus-visible { + border-color: color-mix(in srgb, var(--color-text) 48%, transparent); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-accent) 24%, transparent); +} + +.notion-table-action:focus-visible { + color: var(--color-text); + outline: 2px solid color-mix(in srgb, var(--color-accent) 65%, transparent); + outline-offset: 1px; + background: var(--color-bg-emphasis); +} + +.notion-table-action-label { + padding: 0 3px; + color: var(--color-text-muted); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.notion-table-row-handle, +.notion-table-column-handle, +.notion-table-add-control, +.notion-table-edge-resize { + position: fixed; + z-index: 57; + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + color: var(--color-text-muted); + font-size: 14px; + border: 0; + border-radius: 5px; + background: color-mix(in srgb, var(--color-bg) 94%, transparent); + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08); + cursor: grab; + pointer-events: auto; + touch-action: none; + user-select: none; + -webkit-user-drag: none; +} + +.notion-table-row-handle:hover, +.notion-table-column-handle:hover, +.notion-table-row-handle.is-active, +.notion-table-column-handle.is-active { + color: var(--color-text); + background: var(--color-bg-emphasis); +} + +.notion-table-row-handle.is-disabled { + opacity: 0.35; + cursor: not-allowed; +} + +.notion-table-row-handle.is-active, +.notion-table-column-handle.is-active { + color: var(--color-accent); + background: color-mix(in srgb, var(--color-accent) 16%, var(--color-bg)); + box-shadow: inset 0 0 0 1px + color-mix(in srgb, var(--color-accent) 42%, transparent); +} + +.notion-table-add-control { + cursor: pointer; +} + +.notion-table-add-control:hover { + color: var(--color-text); + background: var(--color-bg-emphasis); +} + +.notion-table-edge-resize { + cursor: ns-resize; +} + +.notion-table-edge-resize.is-column { + cursor: ew-resize; +} + +.notion-table-edge-resize::before { + content: ""; + width: 12px; + height: 4px; + border-block: 1px solid currentcolor; +} + +.notion-table-edge-resize.is-column::before { + width: 4px; + height: 12px; + border-block: 0; + border-inline: 1px solid currentcolor; +} + +.notion-table-edge-resize:hover, +.notion-table-edge-resize[data-resizing="true"] { + color: var(--color-accent); + background: color-mix(in srgb, var(--color-accent) 12%, var(--color-bg)); +} + +.notion-table-row-resize { + position: fixed; + z-index: 56; + height: 16px; + padding: 0; + border: 0; + background: transparent; + cursor: row-resize; + pointer-events: auto; + touch-action: none; + user-select: none; +} + +.notion-table-column-resize { + position: fixed; + z-index: 57; + width: 16px; + padding: 0; + border: 0; + background: transparent; + cursor: col-resize; + pointer-events: auto; + touch-action: none; + user-select: none; +} + +.notion-table-column-resize::after { + content: ""; + position: absolute; + top: 0; + right: 7px; + bottom: 0; + width: 2px; + border-radius: 999px; + background: transparent; + pointer-events: none; +} + +.notion-table-column-resize:hover::after, +.notion-table-column-resize[data-resizing="true"]::after { + background: color-mix(in srgb, var(--color-accent) 55%, transparent); +} + +.notion-table-row-resize::after { + content: ""; + position: absolute; + top: 7px; + right: 0; + left: 0; + height: 2px; + border-radius: 999px; + background: transparent; + pointer-events: none; +} + +.notion-table-row-resize:hover::after, +.notion-table-row-resize[data-resizing="true"]::after { + background: color-mix(in srgb, var(--color-accent) 55%, transparent); +} + +body.notion-table-row-resizing, +body.notion-table-row-resizing * { + cursor: row-resize !important; + user-select: none !important; +} + +body.notion-table-column-resizing, +body.notion-table-column-resizing * { + cursor: col-resize !important; + user-select: none !important; +} + +.ProseMirror[data-table-pointer-dragging] { + cursor: grabbing; + user-select: none; +} + +.notion-table-drop-indicator { + position: fixed; + z-index: 60; + border-radius: 999px; + background: var(--color-accent); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-bg) 80%, transparent); + pointer-events: none; +} + +.notion-table-announcement { + position: fixed; + width: 1px; + height: 1px; + padding: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} + +.ProseMirror.resize-cursor, +.ProseMirror.resize-cursor * { + cursor: col-resize !important; +} + +.column-resize-handle { + position: absolute; + top: 0; + right: -3px; + bottom: -1px; + z-index: 5; + width: 6px; + background: color-mix(in srgb, var(--color-accent) 45%, transparent); + pointer-events: none; +} + +@media print { + .notion-selection-menu, + .notion-table-controls, + .notion-block-style-menu, + .notion-color-menu { + display: none !important; + } +} + /* Radix UI tooltip animations */ @keyframes tooltip-fade-in { from { diff --git a/src/App.tsx b/src/App.tsx index 0e5224e9..b51855dd 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 { shouldSyncMainFolderLocation } from "./lib/workspace"; +import { runSafeWindowClose } from "./lib/windowClose"; +import { useWindowSessionPersistence } from "./lib/useWindowSessionPersistence"; +import { closeWindowAfterSave, requestCurrentWindowClose } from "./services/windowLifecycle"; +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,36 @@ function AppContent() { }); }, [selectedNoteId]); - const toggleSettings = useCallback(() => { - setView((prev) => (prev === "settings" ? "notes" : "settings")); - }, []); + const toggleSettings = useCallback(async () => { + if (view === "notes") { + try { + await flushCurrentDraft(); + } catch (error) { + toast.error(`Settings not opened: ${error}`); + return; + } + } + setView((previous) => (previous === "settings" ? "notes" : "settings")); + }, [flushCurrentDraft, view]); const closeSettings = useCallback(() => { setView("notes"); }, []); + 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]); + + useWindowShortcuts({ onOpenPreferences: openSettings }); + // Go back to command palette from AI modal const handleBackToPalette = useCallback(() => { setAiModalOpen(false); @@ -206,39 +294,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 +498,65 @@ function AppContent() { toggleFocusMode, focusMode, view, - setInterfaceZoom, ]); + // Native File > New Window opens a new window for the current workspace. + useEffect(() => { + let disposed = false; + let unlisten: (() => void) | undefined; + void listen("new-window", async () => { + if (disposed || !notesFolder) return; + try { + await notesService.openWorkspaceWindow(notesFolder); + } catch (error) { + console.error("Failed to open new window:", error); + if (!disposed) toast.error("Failed to open new window"); + } + }).then((cleanup) => { + if (disposed) cleanup(); + else unlisten = cleanup; + }); + return () => { + disposed = true; + unlisten?.(); + }; + }, [notesFolder]); + + // Native File > Open Folder… works from any focused window, including a + // standalone Markdown editor or Preferences. + const openFolderInWorkspaceWindow = useCallback(async () => { + const selected = await open({ + directory: true, + multiple: false, + title: "Open Folder", + }); + + if (typeof selected === "string") { + await notesService.openWorkspaceWindow(selected); + } + }, []); + + useEffect(() => { + let disposed = false; + let unlisten: (() => void) | undefined; + void listen("open-folder", async () => { + if (disposed) return; + try { + await openFolderInWorkspaceWindow(); + } catch (error) { + console.error("Failed to open folder:", error); + if (!disposed) toast.error("Failed to open folder"); + } + }).then((cleanup) => { + if (disposed) cleanup(); + else unlisten = cleanup; + }); + return () => { + disposed = true; + unlisten?.(); + }; + }, [openFolderInWorkspaceWindow]); + const handleClosePalette = useCallback(() => { setPaletteOpen(false); editorRef.current?.commands.focus(); @@ -635,15 +746,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 +782,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..90fd82c6 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"; @@ -69,10 +70,42 @@ import { Wikilink, type WikilinkStorage } from "./Wikilink"; import { WikilinkSuggestion } from "./WikilinkSuggestion"; import { EditorWidthHandles } from "./EditorWidthHandle"; import { ScratchBlockMath, normalizeBlockMath } from "./MathExtensions"; +import { + ScratchColor, + ScratchHighlight, + ScratchSubscript, + ScratchSuperscript, + ScratchTextStyle, +} from "./notion/markdownMarks"; +import { ScratchTextSelection } from "./notion/selectionDecoration"; +import { + parseMarkdownDocument, + serializeMarkdownDocument, +} from "./notion/markdownDocument"; +import { ScratchTableRow } from "./notion/tableExtensions"; +import { ScratchTableMetadata } from "./notion/tableMetadata"; +import { ScratchTableView } from "./notion/tableView"; +import { replaceEditorContentWithoutHistory } from "./editorHistory"; +import { + pasteTableTsv, + serializeTableCellSelectionToTsv, + shouldRejectTablePaste, +} from "./notion/tableClipboard"; +import { SelectionMenu } from "./notion/NotionMenus"; +import { TableControls } from "./notion/TableControls"; import { cn } from "../../lib/utils"; import { plainTextFromMarkdown } from "../../lib/plainText"; +import { getTitleBarNoteInfoText } from "../../lib/titleBarNoteInfo"; +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 +461,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 +495,9 @@ interface EditorProps { onEditorReady?: (editor: TiptapEditor | null) => void; onSaveToFolder?: () => void; saveToFolderDisabled?: boolean; + onPersistenceControllerReady?: ( + controller: EditorPersistenceController | null, + ) => void; } /** @@ -510,6 +561,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 +574,7 @@ export function Editor({ content: previewMode.content, path: previewMode.filePath, modified: previewMode.modified, + revision: previewMode.revision, } : null : (notesCtx?.currentNote ?? null); @@ -531,27 +584,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 +647,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 +666,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 +693,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 +702,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( @@ -607,7 +710,10 @@ export function Editor({ if (!editorInstance) return ""; const manager = editorInstance.storage.markdown?.manager; if (manager) { - let markdown = manager.serialize(editorInstance.getJSON()); + let markdown = serializeMarkdownDocument( + manager, + editorInstance.getJSON(), + ); // Clean up nbsp entities that TipTap inserts (especially in table cells) markdown = markdown.replace(/ | /g, " "); return markdown; @@ -630,6 +736,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 +853,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 +871,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 +895,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 +906,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) { @@ -1053,6 +1326,7 @@ export function Editor({ levels: [1, 2, 3, 4, 5, 6], }, codeBlock: false, + link: false, }), CodeBlockLowlight.extend({ addNodeView() { @@ -1103,15 +1377,28 @@ export function Editor({ nested: true, }), TableKit.configure({ + tableRow: false, table: { - resizable: false, + resizable: true, + handleWidth: 6, + cellMinWidth: 80, + lastColumnResizable: true, + View: ScratchTableView, HTMLAttributes: { class: "not-prose", }, }, }), + ScratchTableRow, + ScratchTableMetadata, + ScratchTextStyle, + ScratchColor, + ScratchHighlight.configure({ multicolor: true }), + ScratchSubscript, + ScratchSuperscript, Frontmatter, Markdown.configure({}), + ScratchTextSelection, SearchHighlight.configure({ matches: [], currentIndex: 0, @@ -1140,12 +1427,17 @@ export function Editor({ }, // Serialize copied text as markdown instead of plain text clipboardTextSerializer: (slice) => { + const currentEditor = editorRef.current; + const tableTsv = currentEditor + ? serializeTableCellSelectionToTsv(currentEditor) + : null; + if (tableTsv !== null) return tableTsv; + const fallback = slice.content.textBetween( 0, slice.content.size, "\n\n", ); - const currentEditor = editorRef.current; const manager = currentEditor?.storage.markdown?.manager; if (!currentEditor || !manager) return fallback; try { @@ -1153,7 +1445,7 @@ export function Editor({ null, slice.content, ); - return manager.serialize(doc.toJSON()); + return serializeMarkdownDocument(manager, doc.toJSON()); } catch { return fallback; } @@ -1218,9 +1510,34 @@ export function Editor({ } } + const currentEditor = editorRef.current; + const isInsideTableCell = Boolean( + currentEditor && + (currentEditor.isActive("tableCell") || + currentEditor.isActive("tableHeader")), + ); + const clipboardHtml = clipboardData.getData("text/html"); + if ( + shouldRejectTablePaste({ + isInsideTableCell, + html: clipboardHtml, + parsedContent: null, + }) + ) { + toast.error("A table can’t be pasted inside a table cell"); + return true; + } + // Handle markdown text paste const text = clipboardData.getData("text/plain"); if (!text) return false; + if ( + isInsideTableCell && + currentEditor && + pasteTableTsv(currentEditor, text) + ) { + return true; + } // Check if text looks like markdown (has common markdown patterns) const markdownPatterns = @@ -1231,14 +1548,23 @@ export function Editor({ } // Parse markdown and insert using editor ref - const currentEditor = editorRef.current; if (!currentEditor) return false; const manager = currentEditor.storage.markdown?.manager; if (manager && typeof manager.parse === "function") { try { - const parsed = manager.parse(text); + const parsed = parseMarkdownDocument(manager, text); if (parsed) { + if ( + shouldRejectTablePaste({ + isInsideTableCell, + html: clipboardHtml, + parsedContent: parsed, + }) + ) { + toast.error("A table can’t be pasted inside a table cell"); + return true; + } currentEditor.commands.insertContent(parsed); return true; } @@ -1253,8 +1579,9 @@ export function Editor({ onCreate: ({ editor: editorInstance }) => { editorRef.current = editorInstance; }, - onUpdate: () => { + onUpdate: ({ transaction }) => { if (isLoadingRef.current) return; + if (transaction.getMeta("scratchTableRowResizePreview")) return; scheduleSave(); }, onSelectionUpdate: () => { @@ -1485,17 +1812,20 @@ export function Editor({ // Manual reload - update the editor content lastReloadVersionRef.current = reloadVersion; loadedModifiedRef.current = currentNote.modified; + needsSaveRef.current = false; + sourceNeedsSaveRef.current = false; + setIsDirty(false); isLoadingRef.current = true; const manager = editor.storage.markdown?.manager; if (manager) { try { - const parsed = manager.parse(currentNote.content); - editor.commands.setContent(parsed); + const parsed = parseMarkdownDocument(manager, currentNote.content); + replaceEditorContentWithoutHistory(editor, parsed); } catch { - editor.commands.setContent(currentNote.content); + replaceEditorContentWithoutHistory(editor, currentNote.content); } } else { - editor.commands.setContent(currentNote.content); + replaceEditorContentWithoutHistory(editor, currentNote.content); } isLoadingRef.current = false; return; @@ -1511,6 +1841,9 @@ export function Editor({ loadedNoteIdRef.current = loadingNoteId; loadedModifiedRef.current = currentNote.modified; + needsSaveRef.current = false; + sourceNeedsSaveRef.current = false; + setIsDirty(false); isLoadingRef.current = true; @@ -1521,14 +1854,14 @@ export function Editor({ const manager = editor.storage.markdown?.manager; if (manager) { try { - const parsed = manager.parse(currentNote.content); - editor.commands.setContent(parsed); + const parsed = parseMarkdownDocument(manager, currentNote.content); + replaceEditorContentWithoutHistory(editor, parsed); } catch { // Fallback to plain text if parsing fails - editor.commands.setContent(currentNote.content); + replaceEditorContentWithoutHistory(editor, currentNote.content); } } else { - editor.commands.setContent(currentNote.content); + replaceEditorContentWithoutHistory(editor, currentNote.content); } // Scroll to top after content is set (must be after setContent to work reliably) @@ -1579,21 +1912,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(); @@ -2027,13 +2370,13 @@ export function Editor({ const manager = editor.storage.markdown?.manager; if (manager) { try { - const parsed = manager.parse(sourceContent); - editor.commands.setContent(parsed); + const parsed = parseMarkdownDocument(manager, sourceContent); + replaceEditorContentWithoutHistory(editor, parsed); } catch { - editor.commands.setContent(sourceContent); + replaceEditorContentWithoutHistory(editor, sourceContent); } } else { - editor.commands.setContent(sourceContent); + replaceEditorContentWithoutHistory(editor, sourceContent); } setSourceMode(false); } @@ -2121,15 +2464,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 +2496,7 @@ export function Editor({ } }, 300); }, - [currentNote, saveNote], + [checkpointScheduler, currentNote, saveNote], ); if (!currentNote) { @@ -2257,14 +2614,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 +2685,15 @@ export function Editor({
+ ) : isDirty ? ( + +
+ +
+
) : (
@@ -2432,22 +2844,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 && ( - + )}
- editor.chain().focus().deleteColumn().run(), - }), - ); + if (rowNode.childCount > 1) { + menuItems.push( + await MenuItem.new({ + text: "Delete Column", + action: () => + editor.chain().focus().deleteColumn().run(), + }), + ); + } menuItems.push( await PredefinedMenuItem.new({ item: "Separator" }), ); @@ -2647,12 +3066,15 @@ export function Editor({ editor.chain().focus().addRowAfter().run(), }), ); - menuItems.push( - await MenuItem.new({ - text: "Delete Row", - action: () => editor.chain().focus().deleteRow().run(), - }), - ); + if (tableNode.childCount > 1) { + menuItems.push( + await MenuItem.new({ + text: "Delete Row", + action: () => + editor.chain().focus().deleteRow().run(), + }), + ); + } menuItems.push( await PredefinedMenuItem.new({ item: "Separator" }), ); @@ -2689,7 +3111,19 @@ export function Editor({ } }} > - + + {editor && ( + <> + + + + )}
)} diff --git a/src/components/editor/EditorWidthHandle.test.tsx b/src/components/editor/EditorWidthHandle.test.tsx new file mode 100644 index 00000000..982eba74 --- /dev/null +++ b/src/components/editor/EditorWidthHandle.test.tsx @@ -0,0 +1,54 @@ +import { act, createRef } from "react"; +import { createRoot } from "react-dom/client"; +import { describe, expect, it } from "vitest"; +import { + EditorWidthHandles, + getRenderedEditorWidth, +} from "./EditorWidthHandle"; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }) + .IS_REACT_ACT_ENVIRONMENT = true; + +describe("EditorWidthHandles", () => { + 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/editor/editorHistory.test.ts b/src/components/editor/editorHistory.test.ts new file mode 100644 index 00000000..b5bbd360 --- /dev/null +++ b/src/components/editor/editorHistory.test.ts @@ -0,0 +1,158 @@ +import { Editor, Extension, type JSONContent } from "@tiptap/core"; +import { TableKit } from "@tiptap/extension-table"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; +import StarterKit from "@tiptap/starter-kit"; +import { describe, expect, it } from "vitest"; +import { replaceEditorContentWithoutHistory } from "./editorHistory"; + +describe("editor note history", () => { + it("never lets undo erase the content loaded from the note", () => { + const editor = new Editor({ + extensions: [StarterKit], + content: "

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

${loadedContent}

`, + ); + + const paragraphEnd = editor.state.doc.content.size - 1; + editor.commands.insertContentAt(paragraphEnd, " + saisie"); + expect(editor.getText()).toBe(`${loadedContent} + saisie`); + + expect(editor.commands.undo()).toBe(true); + expect(editor.getText()).toBe(loadedContent); + + expect(editor.commands.undo()).toBe(false); + expect(editor.getText()).toBe(loadedContent); + } finally { + editor.destroy(); + } + }); + + it("resets undo history without resetting unrelated plugin state", () => { + const stateKey = new PluginKey("preservedAcrossNoteLoad"); + const PreservedState = Extension.create({ + name: "preservedState", + addProseMirrorPlugins() { + return [ + new Plugin({ + key: stateKey, + state: { + init: () => 0, + apply: (transaction, value) => + transaction.getMeta("increment-preserved-state") + ? value + 1 + : value, + }, + }), + ]; + }, + }); + const editor = new Editor({ + extensions: [StarterKit, PreservedState], + content: "

Before

", + }); + + try { + editor.view.dispatch( + editor.state.tr.setMeta("increment-preserved-state", true), + ); + expect(stateKey.getState(editor.state)).toBe(1); + + replaceEditorContentWithoutHistory(editor, "

Loaded

"); + + expect(stateKey.getState(editor.state)).toBe(1); + expect(editor.commands.undo()).toBe(false); + expect(editor.getText()).toBe("Loaded"); + } finally { + editor.destroy(); + } + }); + + it("flattens nested tables while preserving parent and nested cell text", () => { + const editor = new Editor({ + extensions: [StarterKit, TableKit], + content: "

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

Selected text

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

Selected text

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

Selected text

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

Selected text

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

Selected text

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

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

Before

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

A1

B1

A2

B2

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