diff --git a/CLAUDE.md b/Scratch - Development Guide.md similarity index 98% rename from CLAUDE.md rename to Scratch - Development Guide.md index cff841bd..b43344ac 100644 --- a/CLAUDE.md +++ b/Scratch - Development Guide.md @@ -42,4 +42,4 @@ Runs on every push to `main` and on PRs. Validates frontend build (`tsc` + Vite) 2. Commit to `main`, then tag and push: `git tag v0.5.0 && git push origin v0.5.0` 3. The release workflow builds all platforms and creates a draft GitHub release 4. Update the description in `latest.json` from GitHub after the action finishes -5. Review, edit notes, and publish +5. Review, edit notes, and publish \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 55003d64..5b68dd9f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,23 +20,28 @@ "@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-drag-handle-react": "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 +61,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 +1625,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 +1982,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 +2602,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 +2655,152 @@ "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-collaboration": { + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-collaboration/-/extension-collaboration-3.29.2.tgz", + "integrity": "sha512-KVKwIofMzraf0MxCXUkYd6fNmz0oGKaHDkjhNT6HGHrVzTFDJlZTwHHwSndQS2xKGmZbhWe8C32hvUrFsZXTPw==", + "license": "MIT", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.29.2", + "@tiptap/pm": "3.29.2", + "@tiptap/y-tiptap": "^3.0.7", + "yjs": "^13" + } + }, "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-drag-handle": { + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-drag-handle/-/extension-drag-handle-3.29.2.tgz", + "integrity": "sha512-5Y5ElfRnrWIr9IRODzGkqLgIIbdUXNstbs7hWskXQWTg532TJfMpZmQbQRv7th39IcH0uusT7Vs/QlFKXzKVkA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@floating-ui/dom": "^1.6.13" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.29.2", + "@tiptap/extension-collaboration": "3.29.2", + "@tiptap/extension-node-range": "3.29.2", + "@tiptap/pm": "3.29.2", + "@tiptap/y-tiptap": "^3.0.7" + } + }, + "node_modules/@tiptap/extension-drag-handle-react": { + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-drag-handle-react/-/extension-drag-handle-react-3.29.2.tgz", + "integrity": "sha512-/zGdSAaIP57d0kp0KzA9yLXWM/4p1krAFwaJgSRpQEdFLsgitUZk16gqzxOyZ8xC2dZ2inBPvSc2jDWjrw66FQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-drag-handle": "3.29.2", + "@tiptap/pm": "3.29.2", + "@tiptap/react": "3.29.2", + "react": "^16.8 || ^17 || ^18 || ^19", + "react-dom": "^16.8 || ^17 || ^18 || ^19" } }, "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 +2809,405 @@ }, "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-node-range": { + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-node-range/-/extension-node-range-3.29.2.tgz", + "integrity": "sha512-7ipYCrGZvnOL0vR4E69m8PSKDg49hH5n8nVFRej3cRn/xAdAl+ytJmrLSE+SLPvw6TN44NWk3iug23IjOu3Nbw==", + "license": "MIT", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.29.2", + "@tiptap/pm": "3.29.2" } }, "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.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.20.0" + "@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 +3217,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==", - "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" + "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.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 +3247,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 +3261,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 +3274,35 @@ } }, "node_modules/@tiptap/starter-kit": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.20.0.tgz", - "integrity": "sha512-W4+1re35pDNY/7rpXVg+OKo/Fa4Gfrn08Bq3E3fzlJw6gjE3tYU8dY9x9vC2rK9pd9NOp7Af11qCFDaWpohXkw==", - "license": "MIT", - "dependencies": { - "@tiptap/core": "^3.20.0", - "@tiptap/extension-blockquote": "^3.20.0", - "@tiptap/extension-bold": "^3.20.0", - "@tiptap/extension-bullet-list": "^3.20.0", - "@tiptap/extension-code": "^3.20.0", - "@tiptap/extension-code-block": "^3.20.0", - "@tiptap/extension-document": "^3.20.0", - "@tiptap/extension-dropcursor": "^3.20.0", - "@tiptap/extension-gapcursor": "^3.20.0", - "@tiptap/extension-hard-break": "^3.20.0", - "@tiptap/extension-heading": "^3.20.0", - "@tiptap/extension-horizontal-rule": "^3.20.0", - "@tiptap/extension-italic": "^3.20.0", - "@tiptap/extension-link": "^3.20.0", - "@tiptap/extension-list": "^3.20.0", - "@tiptap/extension-list-item": "^3.20.0", - "@tiptap/extension-list-keymap": "^3.20.0", - "@tiptap/extension-ordered-list": "^3.20.0", - "@tiptap/extension-paragraph": "^3.20.0", - "@tiptap/extension-strike": "^3.20.0", - "@tiptap/extension-text": "^3.20.0", - "@tiptap/extension-underline": "^3.20.0", - "@tiptap/extensions": "^3.20.0", - "@tiptap/pm": "^3.20.0" + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.29.2.tgz", + "integrity": "sha512-oTu0tysiqk4zgjEtxRHjAQgxUKaAevZwueOWwSWubHdokqp7SpcbE5n9USJv89HKuTUDm3GjnQH6q8HNn/2DsA==", + "license": "MIT", + "dependencies": { + "@tiptap/core": "^3.29.2", + "@tiptap/extension-blockquote": "^3.29.2", + "@tiptap/extension-bold": "^3.29.2", + "@tiptap/extension-bullet-list": "^3.29.2", + "@tiptap/extension-code": "^3.29.2", + "@tiptap/extension-code-block": "^3.29.2", + "@tiptap/extension-document": "^3.29.2", + "@tiptap/extension-dropcursor": "^3.29.2", + "@tiptap/extension-gapcursor": "^3.29.2", + "@tiptap/extension-hard-break": "^3.29.2", + "@tiptap/extension-heading": "^3.29.2", + "@tiptap/extension-horizontal-rule": "^3.29.2", + "@tiptap/extension-italic": "^3.29.2", + "@tiptap/extension-link": "^3.29.2", + "@tiptap/extension-list": "^3.29.2", + "@tiptap/extension-list-item": "^3.29.2", + "@tiptap/extension-list-keymap": "^3.29.2", + "@tiptap/extension-ordered-list": "^3.29.2", + "@tiptap/extension-paragraph": "^3.29.2", + "@tiptap/extension-strike": "^3.29.2", + "@tiptap/extension-text": "^3.29.2", + "@tiptap/extension-underline": "^3.29.2", + "@tiptap/extensions": "^3.29.2", + "@tiptap/pm": "^3.29.2" }, "funding": { "type": "github", @@ -3181,17 +3310,39 @@ } }, "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/@tiptap/y-tiptap": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@tiptap/y-tiptap/-/y-tiptap-3.0.8.tgz", + "integrity": "sha512-+kndlSuoUK9sKVRuxbAHXNrbDvyydnG/Y0NFU9XXqaSkI9IRcrjpOf1RGd7NrC9jJXquDqZS96wOQf6eUpP9Dg==", + "license": "MIT", + "peer": true, + "dependencies": { + "lib0": "^0.2.100" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "peerDependencies": { + "prosemirror-model": "^1.7.1", + "prosemirror-state": "^1.2.3", + "prosemirror-view": "^1.9.10", + "y-protocols": "^1.0.1", + "yjs": "^13.5.38" } }, "node_modules/@types/babel__core": { @@ -3239,6 +3390,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 +3423,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 +3462,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 +3500,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 +3625,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 +3702,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 +3736,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 +3771,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 +3870,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 +3929,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 +4017,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", @@ -3714,6 +4057,17 @@ "node": ">=12.0.0" } }, + "node_modules/isomorphic.js": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", + "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", + "license": "MIT", + "peer": true, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -3773,6 +4127,28 @@ "katex": "cli.js" } }, + "node_modules/lib0": { + "version": "0.2.117", + "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.117.tgz", + "integrity": "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==", + "license": "MIT", + "peer": true, + "dependencies": { + "isomorphic.js": "^0.2.4" + }, + "bin": { + "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js", + "0gentesthtml": "bin/gentesthtml.js", + "0serve": "bin/0serve.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/lightningcss": { "version": "1.31.1", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", @@ -4034,19 +4410,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 +4450,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 +4462,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 +4495,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 +4586,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 +4617,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 +4660,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 +4704,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 +4901,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 +4928,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 +4973,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 +5007,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 +5046,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,18 +5218,196 @@ } } }, + "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/y-protocols": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.7.tgz", + "integrity": "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==", + "license": "MIT", + "peer": true, + "dependencies": { + "lib0": "^0.2.85" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + }, + "peerDependencies": { + "yjs": "^13.0.0" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, "license": "ISC" + }, + "node_modules/yjs": { + "version": "13.6.31", + "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.31.tgz", + "integrity": "sha512-Eq+5BRfbeGyqGVrTJL3bEcr8gKkxPuyuoHmAwpk52fDb8kOVMrfVSTRPd6yiGgX5Fskb96qCRjzjbRjrL4YEnw==", + "license": "MIT", + "peer": true, + "dependencies": { + "lib0": "^0.2.99" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } } } } diff --git a/package.json b/package.json index fb889190..478c65b1 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,28 @@ "@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-drag-handle-react": "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 +65,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..70873dd0 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -2,7 +2,7 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "Capability for all app windows", - "windows": ["main", "preview-*"], + "windows": ["main", "preview-*", "workspace-*", "preferences"], "permissions": [ "core:default", "core:window:allow-close", diff --git a/src-tauri/src/draft_checkpoint.rs b/src-tauri/src/draft_checkpoint.rs new file mode 100644 index 00000000..06f719ae --- /dev/null +++ b/src-tauri/src/draft_checkpoint.rs @@ -0,0 +1,395 @@ +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 = entry?.path(); + if path.extension().and_then(|extension| extension.to_str()) != Some("json") { + continue; + } + let Ok(bytes) = fs::read(&path) else { + continue; + }; + let Ok(checkpoint) = serde_json::from_slice::(&bytes) else { + 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", crate::sha256::hex_digest(&identity)) +} + +fn ensure_identity_matches( + path: &Path, + checkpoint: &DraftCheckpoint, +) -> Result<(), DraftCheckpointError> { + let actual = path.file_name().and_then(|name| name.to_str()); + if actual == Some(&checkpoint_file_name(&checkpoint.key)) { + return Ok(()); + } + Err(DraftCheckpointError::IdentityMismatch { + path: path.to_path_buf(), + }) +} + +fn atomic_write(path: &Path, bytes: &[u8]) -> io::Result<()> { + let parent = path.parent().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "draft checkpoint target has no parent directory", + ) + })?; + let (mut temporary_file, mut temporary_path) = create_temporary_file(path, parent)?; + temporary_file.write_all(bytes)?; + temporary_file.flush()?; + temporary_file.sync_all()?; + drop(temporary_file); + + replace_file(temporary_path.path(), path)?; + temporary_path.commit(); + sync_parent_directory(parent) +} + +#[cfg(not(windows))] +fn replace_file(source: &Path, destination: &Path) -> io::Result<()> { + fs::rename(source, destination) +} + +#[cfg(windows)] +fn replace_file(source: &Path, destination: &Path) -> io::Result<()> { + use std::iter; + use std::os::windows::ffi::OsStrExt; + + const MOVEFILE_REPLACE_EXISTING: u32 = 0x1; + const MOVEFILE_WRITE_THROUGH: u32 = 0x8; + + #[link(name = "kernel32")] + extern "system" { + fn MoveFileExW( + existing_file_name: *const u16, + new_file_name: *const u16, + flags: u32, + ) -> i32; + } + + let source = source + .as_os_str() + .encode_wide() + .chain(iter::once(0)) + .collect::>(); + let destination = destination + .as_os_str() + .encode_wide() + .chain(iter::once(0)) + .collect::>(); + let result = unsafe { + MoveFileExW( + source.as_ptr(), + destination.as_ptr(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, + ) + }; + if result == 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) +} + +fn create_temporary_file(path: &Path, parent: &Path) -> io::Result<(File, TemporaryPath)> { + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("checkpoint.json"); + loop { + let sequence = NEXT_TEMPORARY_FILE.fetch_add(1, Ordering::Relaxed); + let temporary_path = parent.join(format!( + ".{file_name}.scratch-checkpoint-{}-{sequence}.tmp", + std::process::id() + )); + match OpenOptions::new() + .write(true) + .create_new(true) + .open(&temporary_path) + { + Ok(file) => return Ok((file, TemporaryPath::new(temporary_path))), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, + Err(error) => return Err(error), + } + } +} + +struct TemporaryPath { + path: PathBuf, + committed: bool, +} + +impl TemporaryPath { + fn new(path: PathBuf) -> Self { + Self { + path, + committed: false, + } + } + + fn path(&self) -> &Path { + &self.path + } + + fn commit(&mut self) { + self.committed = true; + } +} + +impl Drop for TemporaryPath { + fn drop(&mut self) { + if !self.committed { + let _ = fs::remove_file(&self.path); + } + } +} + +#[cfg(unix)] +fn sync_parent_directory(parent: &Path) -> io::Result<()> { + File::open(parent)?.sync_all() +} + +#[cfg(not(unix))] +fn sync_parent_directory(_parent: &Path) -> io::Result<()> { + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{ + checkpoint_directory, list_checkpoints, write_checkpoint, DraftCheckpoint, + DraftCheckpointKey, DraftCheckpointMetadata, + }; + use std::fs; + use std::path::PathBuf; + use std::sync::atomic::{AtomicU64, Ordering}; + + static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0); + + struct TestDirectory { + path: PathBuf, + } + + impl TestDirectory { + fn new() -> Self { + let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "scratch-draft-checkpoint-list-{}-{sequence}", + std::process::id(), + )); + fs::create_dir_all(&path).expect("create checkpoint test directory"); + Self { path } + } + } + + impl Drop for TestDirectory { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } + } + + fn checkpoint(note_id: &str) -> DraftCheckpoint { + DraftCheckpoint { + key: DraftCheckpointKey { + window_label: "main".to_string(), + note_id: note_id.to_string(), + }, + markdown: "# Recoverable".to_string(), + metadata: DraftCheckpointMetadata { + source_path: "/notes/recoverable.md".to_string(), + base_revision: Some("revision".to_string()), + updated_at: "2026-08-04T08:00:00Z".to_string(), + }, + } + } + + #[test] + fn list_skips_invalid_entries_and_returns_valid_checkpoints() { + let directory = TestDirectory::new(); + let valid = checkpoint("recoverable.md"); + write_checkpoint(&directory.path, &valid).expect("write valid checkpoint"); + + let checkpoint_directory = checkpoint_directory(&directory.path); + fs::write(checkpoint_directory.join("truncated.json"), b"{").expect("write truncated JSON"); + fs::write(checkpoint_directory.join("unrelated.json"), b"{}") + .expect("write unrelated JSON"); + fs::write( + checkpoint_directory.join("wrong-identity.json"), + serde_json::to_vec(&checkpoint("different.md")).expect("serialize checkpoint"), + ) + .expect("write identity mismatch"); + fs::create_dir(checkpoint_directory.join("unreadable.json")) + .expect("create unreadable JSON entry"); + fs::write(checkpoint_directory.join("ignore.txt"), b"not JSON") + .expect("write non-JSON file"); + + assert_eq!( + list_checkpoints(&directory.path).expect("list valid checkpoints"), + vec![valid], + ); + } +} diff --git a/src-tauri/src/editor_image_open.rs b/src-tauri/src/editor_image_open.rs new file mode 100644 index 00000000..e900f3be --- /dev/null +++ b/src-tauri/src/editor_image_open.rs @@ -0,0 +1,384 @@ +use std::fs::{self, File, OpenOptions}; +use std::io; +use std::path::{Component, Path, PathBuf}; + +const SUPPORTED_IMAGE_EXTENSIONS: &[&str] = &[ + "jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "tiff", "tif", "ico", "avif", +]; +const MAX_IMAGE_NAME_ATTEMPTS: usize = 1_000; + +pub(crate) fn copy_image_to_assets_create_only( + source: &Path, + assets_directory: &Path, + sanitized_name: &str, + extension: &str, +) -> Result { + for attempt in 0..MAX_IMAGE_NAME_ATTEMPTS { + let target_name = if attempt == 0 { + format!("{sanitized_name}.{extension}") + } else { + format!("{sanitized_name}-{attempt}.{extension}") + }; + let target_path = assets_directory.join(target_name); + let mut target = match OpenOptions::new() + .write(true) + .create_new(true) + .open(&target_path) + { + Ok(target) => target, + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, + Err(error) => return Err(format!("Failed to create image asset: {error}")), + }; + + let copy_result = (|| -> io::Result<()> { + let mut source_file = File::open(source)?; + io::copy(&mut source_file, &mut target)?; + target.sync_all() + })(); + if let Err(error) = copy_result { + drop(target); + let _ = fs::remove_file(&target_path); + return Err(format!("Failed to copy image: {error}")); + } + + return Ok(target_path); + } + + Err(format!( + "Could not create a unique image asset after {MAX_IMAGE_NAME_ATTEMPTS} attempts", + )) +} + +pub(crate) fn editor_window_can_open_local_images(window_label: &str) -> bool { + window_label == "main" || window_label.starts_with("workspace-") +} + +pub(crate) fn canonical_workspace_assets(workspace: &Path) -> Result { + let canonical_workspace = workspace + .canonicalize() + .map_err(|_| "Workspace folder is unavailable".to_string())?; + let canonical_assets = workspace + .join("assets") + .canonicalize() + .map_err(|_| "Workspace assets folder is unavailable".to_string())?; + if !canonical_assets.starts_with(&canonical_workspace) { + return Err("Workspace assets folder escapes the workspace".to_string()); + } + Ok(canonical_assets) +} + +pub(crate) fn validate_editor_image_source( + source: &str, + workspace: &Path, +) -> Result { + let url = url::Url::parse(source).map_err(|_| "Invalid image source".to_string())?; + let is_asset_scheme = + url.scheme() == "asset" && matches!(url.host_str(), None | Some("localhost")); + let is_asset_host = + matches!(url.scheme(), "http" | "https") && url.host_str() == Some("asset.localhost"); + + if !is_asset_scheme && !is_asset_host { + return Err("Only local Tauri asset URLs are supported".to_string()); + } + if url.query().is_some() || url.fragment().is_some() { + return Err("Local image source must not contain a query or fragment".to_string()); + } + + let encoded_path = url.path().strip_prefix('/').unwrap_or(url.path()); + let decoded_path = urlencoding::decode(encoded_path) + .map_err(|_| "Invalid percent encoding in image source".to_string())?; + if decoded_path.contains('\0') { + return Err("Image path contains a NUL byte".to_string()); + } + + let requested_path = PathBuf::from(decoded_path.as_ref()); + if !requested_path.is_absolute() { + return Err("Image path must be absolute".to_string()); + } + if requested_path + .components() + .any(|component| component == Component::ParentDir) + { + return Err("Image path traversal is not allowed".to_string()); + } + + let canonical_assets = canonical_workspace_assets(workspace)?; + + let canonical_requested = requested_path + .canonicalize() + .map_err(|_| "Image file is unavailable".to_string())?; + if !canonical_requested.starts_with(&canonical_assets) { + return Err("Image path is outside the workspace assets folder".to_string()); + } + + let metadata = fs::metadata(&canonical_requested) + .map_err(|_| "Image file metadata is unavailable".to_string())?; + if !metadata.is_file() { + return Err("Image path is not a regular file".to_string()); + } + + let extension = canonical_requested + .extension() + .and_then(|value| value.to_str()) + .map(str::to_ascii_lowercase) + .ok_or_else(|| "Image file has no supported extension".to_string())?; + if !SUPPORTED_IMAGE_EXTENSIONS.contains(&extension.as_str()) { + return Err("Image file extension is not supported".to_string()); + } + + Ok(canonical_requested) +} + +#[cfg(test)] +mod tests { + use super::{ + copy_image_to_assets_create_only, editor_window_can_open_local_images, + validate_editor_image_source, + }; + use std::fs; + use std::path::{Path, PathBuf}; + use std::sync::atomic::{AtomicU64, Ordering}; + + static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0); + + struct TestWorkspace { + root: PathBuf, + } + + impl TestWorkspace { + fn new() -> Self { + let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let root = std::env::temp_dir().join(format!( + "scratch-editor-image-open-{}-{sequence}", + std::process::id(), + )); + fs::create_dir_all(root.join("assets")).expect("create test assets directory"); + Self { root } + } + + fn asset(&self, name: &str, contents: &[u8]) -> PathBuf { + let path = self.root.join("assets").join(name); + fs::write(&path, contents).expect("write test asset"); + path + } + + fn source(path: &Path) -> String { + format!( + "asset://localhost/{}", + urlencoding::encode(path.to_string_lossy().as_ref()), + ) + } + } + + impl Drop for TestWorkspace { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.root); + } + } + + #[test] + fn allows_only_normal_editor_windows_to_open_local_images() { + assert!(editor_window_can_open_local_images("main")); + assert!(editor_window_can_open_local_images("workspace-client")); + assert!(!editor_window_can_open_local_images("preview-note")); + assert!(!editor_window_can_open_local_images("preferences")); + assert!(!editor_window_can_open_local_images("unknown")); + } + + #[test] + fn accepts_a_regular_supported_image_inside_workspace_assets() { + let workspace = TestWorkspace::new(); + let image = workspace.asset("photo.PNG", b"image"); + + assert_eq!( + validate_editor_image_source(&TestWorkspace::source(&image), &workspace.root) + .expect("valid workspace image"), + image.canonicalize().expect("canonical image"), + ); + } + + #[test] + fn rejects_paths_outside_workspace_assets() { + let workspace = TestWorkspace::new(); + let outside = workspace.root.join("outside.png"); + fs::write(&outside, b"image").expect("write outside image"); + + assert!( + validate_editor_image_source(&TestWorkspace::source(&outside), &workspace.root) + .is_err(), + ); + } + + #[test] + fn rejects_encoded_system_and_windows_paths() { + let workspace = TestWorkspace::new(); + + assert!( + validate_editor_image_source("asset://localhost/%2Fetc%2Fpasswd", &workspace.root,) + .is_err() + ); + assert!(validate_editor_image_source( + "asset://localhost/C%3A%5CWindows%5CSystem32%5Cdrivers%5Cetc%5Chosts", + &workspace.root, + ) + .is_err()); + } + + #[test] + fn rejects_unsupported_schemes_and_malformed_sources() { + let workspace = TestWorkspace::new(); + + for source in [ + "file:///etc/passwd", + "javascript:alert(1)", + "asset://localhost/%ZZ", + "asset://localhost/assets/photo.png", + "asset://localhost/%00tmp%2Fphoto.png", + "asset://localhost/%2Ftmp%2Fphoto.png?download=1", + "asset://localhost/%2Ftmp%2Fphoto.png#fragment", + ] { + assert!( + validate_editor_image_source(source, &workspace.root).is_err(), + "source must be rejected: {source}", + ); + } + } + + #[test] + fn rejects_parent_directory_components_before_canonicalization() { + let workspace = TestWorkspace::new(); + let escaped = format!( + "{}%2Fassets%2F..%2Foutside.png", + urlencoding::encode(workspace.root.to_string_lossy().as_ref()), + ); + + assert!(validate_editor_image_source( + &format!("asset://localhost/{escaped}"), + &workspace.root, + ) + .is_err()); + } + + #[test] + fn rejects_non_image_files_inside_assets() { + let workspace = TestWorkspace::new(); + let text = workspace.asset("notes.txt", b"not an image"); + + assert!( + validate_editor_image_source(&TestWorkspace::source(&text), &workspace.root).is_err(), + ); + } + + #[test] + fn atomically_uses_a_suffix_without_replacing_an_existing_asset() { + let workspace = TestWorkspace::new(); + let source = workspace.root.join("source.png"); + fs::write(&source, b"new image").expect("write source image"); + let existing = workspace.asset("photo.png", b"existing image"); + + let copied = copy_image_to_assets_create_only( + &source, + &workspace.root.join("assets"), + "photo", + "png", + ) + .expect("copy to unique asset path"); + + assert_eq!( + copied.file_name().and_then(|name| name.to_str()), + Some("photo-1.png") + ); + assert_eq!( + fs::read(existing).expect("read existing asset"), + b"existing image" + ); + assert_eq!(fs::read(copied).expect("read copied asset"), b"new image"); + } + + #[test] + fn concurrent_copies_receive_distinct_asset_names() { + let workspace = TestWorkspace::new(); + let source = workspace.root.join("source.png"); + fs::write(&source, b"shared image").expect("write source image"); + let assets = workspace.root.join("assets"); + + let first_source = source.clone(); + let first_assets = assets.clone(); + let first = std::thread::spawn(move || { + copy_image_to_assets_create_only(&first_source, &first_assets, "photo", "png") + }); + let second = std::thread::spawn(move || { + copy_image_to_assets_create_only(&source, &assets, "photo", "png") + }); + + let first_path = first + .join() + .expect("first copy thread") + .expect("first copy"); + let second_path = second + .join() + .expect("second copy thread") + .expect("second copy"); + assert_ne!(first_path, second_path); + assert_eq!( + fs::read(first_path).expect("read first copy"), + b"shared image" + ); + assert_eq!( + fs::read(second_path).expect("read second copy"), + b"shared image" + ); + } + + #[test] + fn failed_copy_removes_the_partial_destination() { + let workspace = TestWorkspace::new(); + let missing_source = workspace.root.join("missing.png"); + let assets = workspace.root.join("assets"); + + assert!( + copy_image_to_assets_create_only(&missing_source, &assets, "photo", "png",).is_err() + ); + assert!(!assets.join("photo.png").exists()); + } + + #[cfg(unix)] + #[test] + fn rejects_symlinks_that_escape_workspace_assets() { + use std::os::unix::fs::symlink; + + let workspace = TestWorkspace::new(); + let outside = workspace.root.join("outside.png"); + fs::write(&outside, b"image").expect("write outside image"); + let link = workspace.root.join("assets").join("escape.png"); + symlink(&outside, &link).expect("create escaping symlink"); + + assert!( + validate_editor_image_source(&TestWorkspace::source(&link), &workspace.root).is_err(), + ); + } + + #[cfg(unix)] + #[test] + fn rejects_an_assets_directory_symlinked_outside_the_workspace() { + use std::os::unix::fs::symlink; + + let workspace = TestWorkspace::new(); + fs::remove_dir(workspace.root.join("assets")).expect("remove original assets directory"); + let outside = std::env::temp_dir().join(format!( + "scratch-editor-image-open-outside-{}-{}", + std::process::id(), + TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed), + )); + fs::create_dir_all(&outside).expect("create outside assets directory"); + let image = outside.join("escape.png"); + fs::write(&image, b"image").expect("write outside image"); + symlink(&outside, workspace.root.join("assets")).expect("symlink assets outside"); + + assert!( + validate_editor_image_source(&TestWorkspace::source(&image), &workspace.root).is_err(), + ); + + fs::remove_dir_all(outside).expect("remove outside assets directory"); + } +} 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/lib.rs b/src-tauri/src/lib.rs index 72b6d15d..244236ce 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4,19 +4,29 @@ 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 editor_image_open; mod git; +mod persistence; +mod sha256; +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 +45,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 +89,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 +108,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 +129,413 @@ pub enum TextDirection { #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct AppConfig { pub notes_folder: Option, + #[serde(default)] + pub workspaces: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub global_settings: Option, + #[serde(default, rename = "windowSessions", alias = "records")] + pub records: HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WindowGeometry { + pub x: i32, + pub y: i32, + pub width: u32, + pub height: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WindowSession { + pub workspace: String, + pub selected_note_id: Option, + pub sidebar_visible: bool, + pub focus_mode: bool, + pub geometry: Option, +} + +impl WindowSession { + fn for_workspace(workspace: String) -> Self { + Self { + workspace, + selected_note_id: None, + sidebar_visible: true, + focus_mode: false, + geometry: None, + } + } +} + +fn upsert_window_session_workspace(config: &mut AppConfig, label: &str, workspace: String) { + config + .records + .entry(label.to_string()) + .and_modify(|record| record.workspace = workspace.clone()) + .or_insert_with(|| WindowSession::for_workspace(workspace)); +} + +fn remove_window_session_on_destroy( + config: &mut AppConfig, + label: &str, + is_quitting: bool, + preserve_for_preview: bool, +) -> bool { + !is_quitting && !preserve_for_preview && config.records.remove(label).is_some() +} + +fn restorable_window_sessions( + config: &AppConfig, + standalone_preview: bool, + workspace_is_available: impl Fn(&str) -> bool, +) -> Vec<(String, WindowSession)> { + if standalone_preview { + return Vec::new(); + } + + let mut records: Vec<_> = config + .records + .iter() + .filter(|(label, record)| { + !label.starts_with("preview-") && workspace_is_available(&record.workspace) + }) + .map(|(label, record)| (label.clone(), record.clone())) + .collect(); + records.sort_by(|(left, _), (right, _)| (left != "main", left).cmp(&(right != "main", right))); + records +} + +fn fallback_main_session( + config: &AppConfig, + default_workspace: Option<&str>, + standalone_preview: bool, + main_restored: bool, + workspace_is_available: impl Fn(&str) -> bool, +) -> Option { + if standalone_preview || main_restored { + return None; + } + + let workspace = default_workspace?.to_string(); + if !workspace_is_available(&workspace) { + return None; + } + + let mut fallback = config + .records + .get("main") + .cloned() + .unwrap_or_else(|| WindowSession::for_workspace(workspace.clone())); + 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); +pub(crate) const MAX_RECOVERY_NAME_ATTEMPTS: usize = 100; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct NativeMenuItemSpec { + parent_menu: &'static str, + id: &'static str, + label: &'static str, + accelerator: &'static str, +} + +fn native_open_folder_menu_spec() -> NativeMenuItemSpec { + NativeMenuItemSpec { + parent_menu: "File", + id: "open-folder", + label: "Open Folder…", + accelerator: "CmdOrCtrl+O", + } +} + +fn native_preferences_menu_spec() -> NativeMenuItemSpec { + NativeMenuItemSpec { + parent_menu: "Application", + id: "preferences", + label: "Preferences…", + accelerator: "CmdOrCtrl+,", + } +} + +fn focused_window_target<'a>( + windows: impl IntoIterator, +) -> Option<&'a str> { + let mut main = None; + let mut first = None; + + for (label, is_focused) in windows { + if is_focused { + return Some(label); + } + if label == "main" { + main = Some(label); + } + first.get_or_insert(label); + } + + main.or(first) +} + +fn build_application_menu(app_handle: &AppHandle) -> tauri::Result> { + use tauri::menu::{Menu, MenuItem, PredefinedMenuItem, Submenu}; + + let menu = Menu::default(app_handle)?; + let open_folder_spec = native_open_folder_menu_spec(); + let preferences_spec = native_preferences_menu_spec(); + let open_folder = MenuItem::with_id( + app_handle, + open_folder_spec.id, + open_folder_spec.label, + true, + Some(open_folder_spec.accelerator), + )?; + let preferences = MenuItem::with_id( + app_handle, + preferences_spec.id, + preferences_spec.label, + true, + Some(preferences_spec.accelerator), + )?; + + let application_menu_name = app_handle + .config() + .product_name + .clone() + .unwrap_or_else(|| app_handle.package_info().name.clone()); + let mut has_file_menu = false; + + for item in menu.items()? { + let Some(submenu) = item.as_submenu() else { + continue; + }; + let text = submenu.text()?; + if text == open_folder_spec.parent_menu { + has_file_menu = true; + let separator = PredefinedMenuItem::separator(app_handle)?; + submenu.prepend_items(&[&open_folder, &separator])?; + } else if text == application_menu_name { + let separator = PredefinedMenuItem::separator(app_handle)?; + submenu.insert_items(&[&preferences, &separator], 1)?; + } + } + + if has_file_menu { + return Ok(menu); + } + + // Tauri omits a default File menu on a few Unix desktop targets. Keep the + // explicit open-folder entry without replacing other native default menus. + let file_menu = Submenu::with_items( + app_handle, + open_folder_spec.parent_menu, + true, + &[&open_folder], + )?; + menu.prepend(&file_menu)?; + Ok(menu) +} + +fn emit_native_open_folder_request(app: &AppHandle) { + let window_states = app + .webview_windows() + .into_iter() + .map(|(label, window)| (label, window.is_focused().unwrap_or(false))) + .collect::>(); + let target = focused_window_target( + window_states + .iter() + .map(|(label, is_focused)| (label.as_str(), *is_focused)), + ); + + if let Some(label) = target { + let _ = app.emit_to(label, "open-folder-in-new-window", ()); + } +} + +fn workspace_path_key(path: &Path) -> String { + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + + let mut hasher = DefaultHasher::new(); + path.hash(&mut hasher); + format!("workspace-{:x}", hasher.finish()) +} + +fn workspace_window_label(path: &Path, sequence: u64) -> String { + format!("{}-{}", workspace_path_key(path), sequence) +} + +fn next_workspace_window_label(path: &Path) -> String { + workspace_window_label( + path, + WORKSPACE_WINDOW_SEQUENCE.fetch_add(1, Ordering::Relaxed), + ) +} + +#[derive(Debug, Default)] +struct WorkspaceBindings { + by_window: HashMap, +} + +impl WorkspaceBindings { + fn bind(&mut self, window_label: impl Into, path: impl Into) -> Option { + let window_label = window_label.into(); + let path = path.into(); + + self.by_window.insert(window_label, path) + } + + fn unbind(&mut self, window_label: &str) -> Option { + self.by_window.remove(window_label) + } + + fn path_for_window(&self, window_label: &str) -> Option<&str> { + self.by_window.get(window_label).map(String::as_str) + } + + fn window_for_path(&self, path: &str) -> Option<&str> { + self.by_window + .iter() + .find_map(|(label, workspace)| (workspace == path).then_some(label.as_str())) + } + + fn windows_for_path(&self, path: &str) -> Vec { + let mut labels: Vec = self + .by_window + .iter() + .filter_map(|(label, workspace)| (workspace == path).then_some(label.clone())) + .collect(); + labels.sort(); + labels + } + + fn window_for_note_path(&self, note_path: &Path) -> Option<&str> { + self.by_window + .iter() + .filter(|(_, workspace)| note_path.starts_with(Path::new(workspace))) + .max_by_key(|(_, workspace)| Path::new(workspace).components().count()) + .map(|(label, _)| label.as_str()) + } + + fn windows_for_note_path(&self, note_path: &Path) -> Vec { + let max_depth = self + .by_window + .values() + .filter(|workspace| note_path.starts_with(Path::new(workspace))) + .map(|workspace| Path::new(workspace).components().count()) + .max(); + + let Some(max_depth) = max_depth else { + return Vec::new(); + }; + + let mut labels: Vec = self + .by_window + .iter() + .filter(|(_, workspace)| { + note_path.starts_with(Path::new(workspace)) + && Path::new(workspace).components().count() == max_depth + }) + .map(|(label, _)| label.clone()) + .collect(); + labels.sort(); + labels + } +} + +fn note_change_targets( + bindings: &WorkspaceBindings, + note_path: &Path, + origin_window: Option<&str>, +) -> Vec { + bindings + .windows_for_note_path(note_path) + .into_iter() + .filter(|label| origin_window != Some(label.as_str())) + .collect() +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceInfo { + pub path: String, + pub name: String, + pub is_default: bool, + pub is_current: bool, + pub is_open: bool, +} + +fn build_workspace_infos( + config: &AppConfig, + bindings: &WorkspaceBindings, + current_window_label: &str, +) -> Vec { + let mut paths = Vec::new(); + if let Some(default_path) = config.notes_folder.clone() { + paths.push(default_path); + } + for workspace in &config.workspaces { + if !paths.iter().any(|path| path == workspace) { + paths.push(workspace.clone()); + } + } + + let current_path = bindings.path_for_window(current_window_label); + paths + .into_iter() + .map(|path| { + let name = Path::new(&path) + .file_name() + .and_then(|name| name.to_str()) + .filter(|name| !name.is_empty()) + .unwrap_or(&path) + .to_string(); + WorkspaceInfo { + is_default: config.notes_folder.as_deref() == Some(path.as_str()), + is_current: current_path == Some(path.as_str()), + is_open: bindings.window_for_path(&path).is_some(), + path, + name, + } + }) + .collect() } // Per-folder settings (stored in .scratch/settings.json within notes folder) -#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] pub struct Settings { pub theme: ThemeSettings, #[serde(rename = "editorFont")] @@ -122,6 +554,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 +569,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 +579,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 +805,66 @@ 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() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .get(&key) + .cloned() + { + return Ok(existing); + } + + let initializer = initializers + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .entry(key.clone()) + .or_insert_with(|| Arc::new(Mutex::new(()))) + .clone(); + let _initializing = initializer + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + + if let Some(existing) = registry + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .get(&key) + .cloned() + { + return Ok(existing); + } + + let created = Arc::new(create()?); + registry + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .insert(key, Arc::clone(&created)); + Ok(created) +} + // Tantivy search index state pub struct SearchIndex { index: Index, @@ -332,47 +1035,352 @@ impl SearchIndex { } } -// App state with improved structure -pub struct AppState { - pub app_config: RwLock, // notes_folder path (stored in app data) - pub settings: RwLock, // per-folder settings (stored in .scratch/) +pub struct WorkspaceSession { + pub notes_folder: String, + pub settings: RwLock, pub notes_cache: RwLock>, pub file_watcher: Mutex>, pub search_index: Mutex>, - pub debounce_map: Arc>>, + pub(crate) debounce_map: Arc>, } -impl Default for AppState { - fn default() -> Self { +impl WorkspaceSession { + #[cfg(test)] + fn empty(notes_folder: String) -> Self { Self { - app_config: RwLock::new(AppConfig::default()), - settings: RwLock::new(Settings::default()), + notes_folder, + settings: RwLock::new(WorkspaceSettings::default()), notes_cache: RwLock::new(HashMap::new()), file_watcher: Mutex::new(None), search_index: Mutex::new(None), - debounce_map: Arc::new(Mutex::new(HashMap::new())), + debounce_map: Arc::new(Mutex::new(WatcherDebounce::default())), } } -} - -// Utility: Sanitize filename from title -fn sanitize_filename(title: &str) -> String { - let sanitized: String = title - .chars() - .filter(|c| *c != '\u{00A0}' && *c != '\u{FEFF}') - .map(|c| match c { - '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '-', - _ => c, - }) - .collect(); - let trimmed = sanitized.trim(); - if trimmed.is_empty() || is_effectively_empty(trimmed) { - "Untitled".to_string() - } else { - trimmed.to_string() + fn initialize(app: &AppHandle, path: &Path) -> Result { + let (canonical_path, notes_folder) = prepare_notes_folder(path)?; + Self::initialize_prepared(app, canonical_path, notes_folder) } -} + + fn initialize_prepared( + app: &AppHandle, + canonical_path: PathBuf, + notes_folder: String, + ) -> Result { + let settings = load_settings(¬es_folder); + let ignored_dirs = get_effective_ignored_dirs(&settings); + let search_index = get_workspace_search_index_path(app, &canonical_path) + .ok() + .and_then(|index_path| SearchIndex::new(&index_path).ok()) + .inspect(|index| { + let _ = index.rebuild_index(&canonical_path, &ignored_dirs); + }); + + let _ = app + .asset_protocol_scope() + .allow_directory(&canonical_path, true); + + Ok(Self { + notes_folder, + settings: RwLock::new(settings), + notes_cache: RwLock::new(HashMap::new()), + file_watcher: Mutex::new(None), + search_index: Mutex::new(search_index), + debounce_map: Arc::new(Mutex::new(WatcherDebounce::default())), + }) + } +} + +// App state with improved structure +pub struct AppState { + pub app_config: RwLock, // notes_folder path (stored in app data) + pub settings: RwLock, // per-folder settings (stored in .scratch/) + pub notes_cache: RwLock>, + pub file_watcher: Mutex>, + pub search_index: Mutex>, + pub(crate) debounce_map: Arc>, + workspace_sessions: RwLock>>, + workspace_initializers: Mutex>>>, + workspace_bindings: RwLock, + is_quitting: AtomicBool, + preserve_session_labels: Mutex>, +} + +enum WorkspaceRuntime<'a> { + Main(&'a AppState), + Session(Arc), +} + +impl WorkspaceRuntime<'_> { + fn notes_folder(&self) -> Result { + match self { + Self::Main(state) => state + .workspace_bindings + .read() + .expect("workspace bindings read lock") + .path_for_window("main") + .map(str::to_string) + .or_else(|| { + state + .app_config + .read() + .expect("app_config read lock") + .notes_folder + .clone() + }) + .ok_or_else(|| "Notes folder not set".to_string()), + Self::Session(session) => Ok(session.notes_folder.clone()), + } + } + + fn settings(&self) -> &RwLock { + match self { + Self::Main(state) => &state.settings, + Self::Session(session) => &session.settings, + } + } + + fn note_path(&self, id: &str) -> Result { + abs_path_from_id(&PathBuf::from(self.notes_folder()?), id) + } + + fn notes_cache(&self) -> &RwLock> { + match self { + Self::Main(state) => &state.notes_cache, + Self::Session(session) => &session.notes_cache, + } + } + + fn file_watcher(&self) -> &Mutex> { + match self { + Self::Main(state) => &state.file_watcher, + Self::Session(session) => &session.file_watcher, + } + } + + fn search_index(&self) -> &Mutex> { + match self { + Self::Main(state) => &state.search_index, + Self::Session(session) => &session.search_index, + } + } + + fn debounce_map(&self) -> &Arc> { + match self { + Self::Main(state) => &state.debounce_map, + Self::Session(session) => &session.debounce_map, + } + } +} + +fn uses_default_workspace_fallback(window_label: &str) -> bool { + window_label == "main" || window_label == "preferences" || window_label.starts_with("preview-") +} + +impl AppState { + fn get_or_initialize_workspace_session( + &self, + app: &AppHandle, + path: &Path, + ) -> Result, String> { + let (canonical_path, notes_folder) = prepare_notes_folder(path)?; + get_or_initialize_shared( + &self.workspace_sessions, + &self.workspace_initializers, + notes_folder.clone(), + || WorkspaceSession::initialize_prepared(app, canonical_path, notes_folder), + ) + } + + fn workspace_for_window(&self, window_label: &str) -> Result, String> { + let session = if uses_default_workspace_fallback(window_label) { + self.workspace_session("main") + } else { + self.workspace_session(window_label) + }; + if let Some(session) = session { + return Ok(WorkspaceRuntime::Session(session)); + } + + if uses_default_workspace_fallback(window_label) { + return Ok(WorkspaceRuntime::Main(self)); + } + + Err(format!( + "Workspace session not found for window: {}", + window_label + )) + } + + fn register_workspace_session( + &self, + window_label: impl Into, + session: WorkspaceSession, + ) -> Arc { + let window_label = window_label.into(); + let notes_folder = session.notes_folder.clone(); + let shared_session = { + let mut sessions = self + .workspace_sessions + .write() + .expect("workspace sessions write lock"); + sessions + .entry(notes_folder.clone()) + .or_insert_with(|| Arc::new(session)) + .clone() + }; + + let previous_folder = self + .workspace_bindings + .write() + .expect("workspace bindings write lock") + .bind(window_label.clone(), notes_folder.clone()); + if let Some(previous_folder) = previous_folder { + if previous_folder != notes_folder { + let has_remaining_windows = !self + .workspace_bindings + .read() + .expect("workspace bindings read lock") + .windows_for_path(&previous_folder) + .is_empty(); + if !has_remaining_windows { + self.workspace_sessions + .write() + .expect("workspace sessions write lock") + .remove(&previous_folder); + self.workspace_initializers + .lock() + .expect("workspace initializers lock") + .remove(&previous_folder); + } + } + } + shared_session + } + + fn workspace_session(&self, window_label: &str) -> Option> { + let notes_folder = self + .workspace_bindings + .read() + .expect("workspace bindings read lock") + .path_for_window(window_label)? + .to_string(); + self.workspace_sessions + .read() + .expect("workspace sessions read lock") + .get(¬es_folder) + .cloned() + } + + fn workspace_session_for_path(&self, notes_folder: &str) -> Option> { + self.workspace_sessions + .read() + .expect("workspace sessions read lock") + .get(notes_folder) + .cloned() + } + + fn bind_existing_workspace_session( + &self, + window_label: impl Into, + notes_folder: impl Into, + ) -> Result, String> { + let window_label = window_label.into(); + let notes_folder = notes_folder.into(); + let session = self + .workspace_session_for_path(¬es_folder) + .ok_or_else(|| format!("Workspace session not found for path: {}", notes_folder))?; + self.workspace_bindings + .write() + .expect("workspace bindings write lock") + .bind(window_label, notes_folder); + Ok(session) + } + + fn remove_workspace_session(&self, window_label: &str) -> Option> { + let (notes_folder, has_remaining_windows) = { + let mut bindings = self + .workspace_bindings + .write() + .expect("workspace bindings write lock"); + let notes_folder = bindings.unbind(window_label)?; + let has_remaining_windows = !bindings.windows_for_path(¬es_folder).is_empty(); + (notes_folder, has_remaining_windows) + }; + + if has_remaining_windows { + return self + .workspace_sessions + .read() + .expect("workspace sessions read lock") + .get(¬es_folder) + .cloned(); + } + + let removed = self + .workspace_sessions + .write() + .expect("workspace sessions write lock") + .remove(¬es_folder); + self.workspace_initializers + .lock() + .expect("workspace initializers lock") + .remove(¬es_folder); + removed + } + + #[cfg(test)] + fn workspace_session_count(&self) -> usize { + self.workspace_sessions + .read() + .expect("workspace sessions read lock") + .len() + } +} + +impl Default for AppState { + fn default() -> Self { + Self { + app_config: RwLock::new(AppConfig::default()), + settings: RwLock::new(WorkspaceSettings::default()), + notes_cache: RwLock::new(HashMap::new()), + file_watcher: Mutex::new(None), + search_index: Mutex::new(None), + debounce_map: Arc::new(Mutex::new(WatcherDebounce::default())), + workspace_sessions: RwLock::new(HashMap::new()), + workspace_initializers: Mutex::new(HashMap::new()), + workspace_bindings: RwLock::new(WorkspaceBindings::default()), + is_quitting: AtomicBool::new(false), + preserve_session_labels: Mutex::new(HashSet::new()), + } + } +} + +type PendingOpenRequest = (Vec, String); + +// macOS open-file and single-instance callbacks may arrive before `setup` +// manages AppState. Keep those requests until the notes configuration is ready. +static PENDING_SINGLE_INSTANCE_OPENS: Mutex> = Mutex::new(Vec::new()); +static PENDING_OPENED_MARKDOWN_PATHS: Mutex> = Mutex::new(Vec::new()); + +// Utility: Sanitize filename from title +fn sanitize_filename(title: &str) -> String { + let sanitized: String = title + .chars() + .filter(|c| *c != '\u{00A0}' && *c != '\u{FEFF}') + .map(|c| match c { + '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '-', + _ => c, + }) + .collect(); + + let trimmed = sanitized.trim(); + if trimmed.is_empty() || is_effectively_empty(trimmed) { + "Untitled".to_string() + } else { + trimmed.to_string() + } +} fn ordinal_suffix(day: u32) -> &'static str { match (day % 100, day % 10) { @@ -516,7 +1524,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 +1539,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 +1552,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 +1567,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 +1592,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 +1610,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 +1663,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 +1681,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 +1708,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 +1770,11 @@ fn get_settings_path(notes_folder: &str) -> PathBuf { scratch_dir.join("settings.json") } -// Get search index path -fn get_search_index_path(app: &AppHandle) -> Result { +fn get_workspace_search_index_path(app: &AppHandle, notes_folder: &Path) -> Result { let app_data = app.path().app_data_dir()?; - std::fs::create_dir_all(&app_data)?; - Ok(app_data.join("search_index")) + let indexes = app_data.join("workspace_search_indexes"); + std::fs::create_dir_all(&indexes)?; + Ok(indexes.join(workspace_path_key(notes_folder))) } // Load app config from disk (notes folder path) @@ -758,7 +1803,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 +1830,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,251 +1855,893 @@ fn normalize_notes_folder_path(path: &str) -> Result { Ok(PathBuf::from(trimmed)) } -/// Shared initialization logic for setting a notes folder. -/// Creates required directories, verifies write access, updates config/settings, -/// adds asset protocol scope, and rebuilds the search index. -fn initialize_notes_folder(app: &AppHandle, path_buf: &PathBuf, state: &AppState) -> Result { - let normalized_path = path_buf.to_string_lossy().into_owned(); - - // Verify it's a valid directory +fn prepare_notes_folder(path_buf: &Path) -> Result<(PathBuf, String), String> { if !path_buf.exists() { std::fs::create_dir_all(path_buf).map_err(|e| e.to_string())?; } - // Create assets folder - let assets = path_buf.join("assets"); - std::fs::create_dir_all(&assets).map_err(|e| e.to_string())?; + let canonical = std::fs::canonicalize(path_buf).map_err(|e| e.to_string())?; + std::fs::create_dir_all(canonical.join("assets")).map_err(|e| e.to_string())?; - // Create .scratch config folder - let scratch_dir = path_buf.join(".scratch"); + let scratch_dir = canonical.join(".scratch"); std::fs::create_dir_all(&scratch_dir).map_err(|e| e.to_string())?; - // Verify write access early to avoid later silent failures let write_test_path = scratch_dir.join(".write-test"); std::fs::write(&write_test_path, b"ok") .map_err(|e| format!("Notes folder is not writable: {}", e))?; let _ = std::fs::remove_file(&write_test_path); - // Load per-folder settings (starts fresh with defaults if none exist) - let settings = load_settings(&normalized_path); - - // Update app config - { - let mut app_config = state.app_config.write().expect("app_config write lock"); - app_config.notes_folder = Some(normalized_path.clone()); - } + let normalized = canonical.to_string_lossy().into_owned(); + Ok((canonical, normalized)) +} - // Update settings in memory - { - let mut current_settings = state.settings.write().expect("settings write lock"); - *current_settings = settings; - } +fn configure_main_workspace( + app: &AppHandle, + path_buf: &Path, + state: &AppState, + make_default: bool, +) -> Result { + let session = WorkspaceSession::initialize(app, path_buf)?; + let normalized_path = session.notes_folder.clone(); - // Save app config to disk { - let app_config = state.app_config.read().expect("app_config read lock"); - save_app_config(app, &app_config).map_err(|e| e.to_string())?; - } - - // Add notes folder to asset protocol scope so images can be served - let _ = app.asset_protocol_scope().allow_directory(path_buf, true); - - // Initialize search index - if let Ok(index_path) = get_search_index_path(app) { - if let Ok(search_index) = SearchIndex::new(&index_path) { - let ignored_dirs = { - let settings = state.settings.read().expect("settings read lock"); - get_effective_ignored_dirs(&settings) - }; - let _ = search_index.rebuild_index(path_buf, &ignored_dirs); - let mut index = state.search_index.lock().expect("search index mutex"); - *index = Some(search_index); + let mut config = state.app_config.write().expect("app_config write lock"); + let mut next = config.clone(); + if make_default { + next.notes_folder = Some(normalized_path.clone()); } + remember_workspace(&mut next, normalized_path.clone()); + upsert_window_session_workspace(&mut next, "main", normalized_path.clone()); + save_app_config(app, &next).map_err(|error| error.to_string())?; + *config = next; } + state.register_workspace_session("main", session); Ok(normalized_path) } +/// Sets and persists the main/default notes folder. +fn initialize_notes_folder( + app: &AppHandle, + path_buf: &Path, + state: &AppState, +) -> Result { + configure_main_workspace(app, path_buf, state, true) +} + +/// Rebinds the main window while preserving the configured default folder. +fn switch_main_workspace( + app: &AppHandle, + path_buf: &Path, + state: &AppState, +) -> Result { + configure_main_workspace(app, path_buf, state, false) +} + // TAURI COMMANDS #[tauri::command] -fn get_notes_folder(state: State) -> Option { +fn get_notes_folder(window: WebviewWindow, state: State) -> Option { state + .workspace_for_window(window.label()) + .ok()? + .notes_folder() + .ok() +} + +#[tauri::command] +fn get_window_session(window: WebviewWindow, state: State) -> Option { + let mut record = state .app_config .read() - .expect("app_config read lock") - .notes_folder - .clone() + .expect("app config read lock") + .records + .get(window.label()) + .cloned()?; + if let Some(workspace) = state + .workspace_bindings + .read() + .expect("workspace bindings read lock") + .path_for_window(window.label()) + { + record.workspace = workspace.to_string(); + } + Some(record) } #[tauri::command] -fn set_notes_folder(app: AppHandle, path: String, state: State) -> Result<(), String> { - let path_buf = normalize_notes_folder_path(&path)?; - initialize_notes_folder(&app, &path_buf, &state)?; +fn update_window_session( + patch: serde_json::Value, + window: WebviewWindow, + state: State, +) -> Result<(), String> { + let workspace = state.workspace_for_window(window.label())?.notes_folder()?; + let mut patch = patch; + let patch_object = patch + .as_object_mut() + .ok_or_else(|| "Window session patch must be an object".to_string())?; + patch_object.remove("workspace"); + + let mut config = state.app_config.write().expect("app config write lock"); + if !workspace_is_remembered(&config, &workspace) { + return Ok(()); + } + let current = config + .records + .get(window.label()) + .cloned() + .unwrap_or_else(|| WindowSession::for_workspace(workspace.clone())); + let mut updated = apply_settings_patch(¤t, patch)?; + updated.workspace = workspace; + + let mut next = config.clone(); + next.records.insert(window.label().to_string(), updated); + save_app_config(window.app_handle(), &next).map_err(|error| error.to_string())?; + *config = next; Ok(()) } -#[tauri::command] -async fn list_notes(state: State<'_, AppState>) -> Result, String> { - let folder = { - let app_config = state.app_config.read().expect("app_config read lock"); - app_config - .notes_folder - .clone() - .ok_or("Notes folder not set")? - }; - - let path = PathBuf::from(&folder); - if !path.exists() { - return Ok(vec![]); - } +fn clear_window_session_record( + app: &AppHandle, + state: &AppState, + label: &str, +) -> Result { + let mut config = state.app_config.write().expect("app config write lock"); + if !config.records.contains_key(label) { + return Ok(false); + } + + let mut next = config.clone(); + next.records.remove(label); + save_app_config(app, &next).map_err(|error| error.to_string())?; + *config = next; + Ok(true) +} - let ignored_dirs = { - let settings = state.settings.read().expect("settings read lock"); - get_effective_ignored_dirs(&settings) +fn handle_window_destroyed(app: &AppHandle, label: &str) { + let Some(state) = app.try_state::() else { + return; }; - - let path_clone = path.clone(); - let discovered = tokio::task::spawn_blocking(move || { - use walkdir::WalkDir; - let mut results: Vec<(String, String, String, i64)> = Vec::new(); - for entry in WalkDir::new(&path_clone) - .max_depth(10) - .into_iter() - .filter_entry(|e| is_visible_notes_entry(e, &ignored_dirs)) - .flatten() - { - let file_path = entry.path(); - if !file_path.is_file() { - continue; - } - if let Some(id) = id_from_abs_path(&path_clone, file_path, &ignored_dirs) { - if let Ok(content) = std::fs::read_to_string(file_path) { - let modified = entry - .metadata() - .ok() - .and_then(|m| m.modified().ok()) - .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|d| d.as_secs() as i64) - .unwrap_or(0); - let title = extract_title(&content); - let preview = generate_preview(&content); - results.push((id, title, preview, modified)); + let is_quitting = state.is_quitting.load(Ordering::SeqCst); + let preserve_for_preview = state + .preserve_session_labels + .lock() + .expect("preserved session labels mutex") + .remove(label); + { + let mut config = state.app_config.write().expect("app config write lock"); + let mut next = config.clone(); + if remove_window_session_on_destroy(&mut next, label, is_quitting, preserve_for_preview) { + match save_app_config(app, &next) { + Ok(()) => *config = next, + Err(error) => { + eprintln!("Failed to clear closed window session {label}: {error}") } } } - results - }) - .await - .map_err(|e| e.to_string())?; + } + state.remove_workspace_session(label); + let _ = app.emit("workspaces-changed", ()); +} - let mut notes: Vec = discovered +fn suppress_full_windows_for_preview(app: &AppHandle) { + let Some(state) = app.try_state::() else { + return; + }; + let windows: Vec<_> = app + .webview_windows() .into_iter() - .map(|(id, title, preview, modified)| NoteMetadata { - id, - title, - preview, - modified, - }) + .filter(|(label, _)| !label.starts_with("preview-")) .collect(); - - // Load pinned note IDs from settings - let pinned_ids: HashSet = { - let settings = state.settings.read().expect("settings read lock"); - settings - .pinned_note_ids - .as_ref() - .map(|ids| ids.iter().cloned().collect()) - .unwrap_or_default() - }; - - // Sort: pinned notes first (by date), then unpinned notes (by date) - notes.sort_by(|a, b| { - let a_pinned = pinned_ids.contains(&a.id); - let b_pinned = pinned_ids.contains(&b.id); - - match (a_pinned, b_pinned) { - (true, false) => std::cmp::Ordering::Less, // a pinned, b not -> a first - (false, true) => std::cmp::Ordering::Greater, // b pinned, a not -> b first - _ => b.modified.cmp(&a.modified), // both same status -> sort by date (newest first) - } - }); - - // Update cache efficiently { - let mut cache = state.notes_cache.write().expect("cache write lock"); - cache.clear(); - for note in ¬es { - cache.insert(note.id.clone(), note.clone()); - } + let mut preserved = state + .preserve_session_labels + .lock() + .expect("preserved session labels mutex"); + preserved.extend(windows.iter().map(|(label, _)| label.clone())); } + for (_, window) in windows { + let _ = window.destroy(); + } +} - Ok(notes) +#[tauri::command] +fn clear_window_session(window: WebviewWindow, state: State) -> Result<(), String> { + clear_window_session_record(window.app_handle(), &state, window.label())?; + Ok(()) } +/// Finalize a close request only after the WebView has flushed the draft or +/// persisted a recovery snapshot. Keeping force-destroy in trusted Rust avoids +/// exposing `plugin:window|destroy` to editor WebViews. #[tauri::command] -async fn read_note(id: String, state: State<'_, AppState>) -> Result { - let folder = { - let app_config = state.app_config.read().expect("app_config read lock"); - app_config - .notes_folder - .clone() - .ok_or("Notes folder not set")? - }; +fn close_window_after_save(window: WebviewWindow) -> Result<(), String> { + window + .destroy() + .map_err(|error| format!("Failed to close window after save: {error}")) +} - let folder_path = PathBuf::from(&folder); - let file_path = abs_path_from_id(&folder_path, &id)?; - if !file_path.exists() { - return Err("Note not found".to_string()); +#[tauri::command] +fn set_notes_folder( + app: AppHandle, + window: WebviewWindow, + path: String, + state: State, +) -> Result<(), String> { + let path_buf = normalize_notes_folder_path(&path)?; + if window.label() == "main" { + initialize_notes_folder(&app, &path_buf, &state)?; + let _ = app.emit("workspaces-changed", ()); + return Ok(()); } - let content = fs::read_to_string(&file_path) - .await - .map_err(|e| e.to_string())?; - let metadata = fs::metadata(&file_path) - .await - .map_err(|e| e.to_string())?; + let session = WorkspaceSession::initialize(&app, &path_buf)?; + let notes_folder = session.notes_folder.clone(); + { + let mut config = state.app_config.write().expect("app_config write lock"); + let mut next = config.clone(); + remember_workspace(&mut next, notes_folder.clone()); + upsert_window_session_workspace(&mut next, window.label(), notes_folder.clone()); + save_app_config(&app, &next).map_err(|e| e.to_string())?; + *config = next; + } + state.register_workspace_session(window.label(), session); + let _ = app.emit("workspaces-changed", ()); + Ok(()) +} - let modified = metadata - .modified() +#[tauri::command] +fn list_workspaces(window: WebviewWindow, state: State) -> Vec { + let config = state + .app_config + .read() + .expect("app_config read lock") + .clone(); + let bindings = state + .workspace_bindings + .read() + .expect("workspace bindings read lock"); + build_workspace_infos(&config, &bindings, window.label()) +} + +#[tauri::command] +fn remove_workspace_from_list( + app: AppHandle, + path: String, + state: State, +) -> Result<(), String> { + let path = path.trim(); + if path.is_empty() { + return Err("Workspace path is empty".to_string()); + } + + { + let mut config = state.app_config.write().expect("app config write lock"); + let mut next = config.clone(); + if !forget_workspace(&mut next, path) { + return Ok(()); + } + save_app_config(&app, &next).map_err(|error| error.to_string())?; + *config = next; + } + + let _ = app.emit("workspaces-changed", ()); + Ok(()) +} + +#[tauri::command] +fn switch_workspace( + app: AppHandle, + window: WebviewWindow, + path: String, + state: State, +) -> Result { + let path_buf = normalize_notes_folder_path(&path)?; + let normalized_path = if window.label() == "main" { + switch_main_workspace(&app, &path_buf, &state)? + } else { + let session = WorkspaceSession::initialize(&app, &path_buf)?; + let notes_folder = session.notes_folder.clone(); + { + let mut config = state.app_config.write().expect("app_config write lock"); + let mut next = config.clone(); + remember_workspace(&mut next, notes_folder.clone()); + upsert_window_session_workspace(&mut next, window.label(), notes_folder.clone()); + save_app_config(&app, &next).map_err(|error| error.to_string())?; + *config = next; + } + state.register_workspace_session(window.label(), session); + notes_folder + }; + + let _ = app.emit("workspaces-changed", ()); + Ok(normalized_path) +} + +fn restore_window_session( + app: &AppHandle, + state: &AppState, + label: &str, + record: &WindowSession, +) -> Result<(), String> { + let workspace_path = Path::new(&record.workspace); + let existing_session = state.workspace_session_for_path(&record.workspace); + if let Some(session) = existing_session { + state + .workspace_bindings + .write() + .expect("workspace bindings write lock") + .bind(label, session.notes_folder.clone()); + } else { + let session = WorkspaceSession::initialize(app, workspace_path)?; + state.register_workspace_session(label, session); + } + + if label == "main" { + let window = app + .get_webview_window("main") + .ok_or_else(|| "Main window not found".to_string())?; + if let Some(geometry) = &record.geometry { + let _ = window.set_position(tauri::PhysicalPosition::new(geometry.x, geometry.y)); + let _ = window.set_size(tauri::PhysicalSize::new(geometry.width, geometry.height)); + } + return Ok(()); + } + + if app.get_webview_window(label).is_some() { + return Ok(()); + } + + let title = workspace_path + .file_name() + .and_then(|name| name.to_str()) + .filter(|name| !name.is_empty()) + .unwrap_or("Scratch"); + let runtime_config = runtime_window_config_from_template( + &app.config().app.windows[0], + label, + WebviewUrl::App("index.html?mode=workspace".into()), + ); + let mut builder = WebviewWindowBuilder::from_config(app, &runtime_config) + .map_err(|error| format!("Failed to configure workspace window {label}: {error}"))? + .title(format!("{} — Scratch", title)) + .inner_size(1080.0, 720.0) + .min_inner_size(600.0, 400.0) + .resizable(true) + .decorations(true); + if let Some(geometry) = &record.geometry { + builder = builder + .position(geometry.x as f64, geometry.y as f64) + .inner_size(geometry.width as f64, geometry.height as f64); + } + + let window = builder.build().map_err(|error| { + state.remove_workspace_session(label); + format!("Failed to restore workspace window {label}: {error}") + })?; + let _ = window.show(); + Ok(()) +} + +#[tauri::command] +async fn open_workspace_window( + app: AppHandle, + path: String, + state: State<'_, AppState>, +) -> Result { + let path_buf = normalize_notes_folder_path(&path)?; + let init_app = app.clone(); + let session = tauri::async_runtime::spawn_blocking(move || { + let state = init_app.state::(); + state.get_or_initialize_workspace_session(&init_app, &path_buf) + }) + .await + .map_err(|error| format!("Workspace initialization failed: {}", error))??; + let notes_folder = session.notes_folder.clone(); + + let label = loop { + let candidate = next_workspace_window_label(Path::new(¬es_folder)); + let record_exists = state + .app_config + .read() + .expect("app config read lock") + .records + .contains_key(&candidate); + if app.get_webview_window(&candidate).is_none() && !record_exists { + break candidate; + } + }; + let title = Path::new(¬es_folder) + .file_name() + .and_then(|name| name.to_str()) + .filter(|name| !name.is_empty()) + .unwrap_or("Scratch") + .to_string(); + + { + let mut config = state.app_config.write().expect("app_config write lock"); + let mut next = config.clone(); + remember_workspace(&mut next, notes_folder.clone()); + upsert_window_session_workspace(&mut next, &label, notes_folder.clone()); + save_app_config(&app, &next).map_err(|error| error.to_string())?; + *config = next; + } + let binding_result = state + .bind_existing_workspace_session(&label, notes_folder.clone()) + .map(|_| ()); + if let Err(error) = binding_result { + let _ = clear_window_session_record(&app, &state, &label); + return Err(error); + } + + let runtime_config = runtime_window_config_from_template( + &app.config().app.windows[0], + &label, + WebviewUrl::App("index.html?mode=workspace".into()), + ); + let builder = WebviewWindowBuilder::from_config(&app, &runtime_config) + .map_err(|error| format!("Failed to configure workspace window: {error}"))? + .title(format!("{} — Scratch", title)) + .inner_size(1080.0, 720.0) + .min_inner_size(600.0, 400.0) + .resizable(true) + .decorations(true); + + let window = builder.build().map_err(|error| { + state.remove_workspace_session(&label); + let _ = clear_window_session_record(&app, &state, &label); + format!("Failed to create workspace window: {}", error) + })?; + let _ = window.show(); + window.set_focus().map_err(|error| error.to_string())?; + let _ = app.emit("workspaces-changed", ()); + + Ok(label) +} + +#[tauri::command] +async fn list_notes( + window: WebviewWindow, + state: State<'_, AppState>, +) -> Result, String> { + let workspace = state.workspace_for_window(window.label())?; + let folder = workspace.notes_folder()?; + + let path = PathBuf::from(&folder); + if !path.exists() { + return Ok(vec![]); + } + + let ignored_dirs = { + let settings = workspace.settings().read().expect("settings read lock"); + get_effective_ignored_dirs(&settings) + }; + + let path_clone = path.clone(); + let discovered = tokio::task::spawn_blocking(move || { + use walkdir::WalkDir; + let mut results: Vec<(String, String, String, i64)> = Vec::new(); + for entry in WalkDir::new(&path_clone) + .max_depth(10) + .into_iter() + .filter_entry(|e| is_visible_notes_entry(e, &ignored_dirs)) + .flatten() + { + let file_path = entry.path(); + if !file_path.is_file() { + continue; + } + if let Some(id) = id_from_abs_path(&path_clone, file_path, &ignored_dirs) { + if let Ok(content) = std::fs::read_to_string(file_path) { + let modified = entry + .metadata() + .ok() + .and_then(|m| m.modified().ok()) + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + let title = extract_title(&content); + let preview = generate_preview(&content); + results.push((id, title, preview, modified)); + } + } + } + results + }) + .await + .map_err(|e| e.to_string())?; + + let mut notes: Vec = discovered + .into_iter() + .map(|(id, title, preview, modified)| NoteMetadata { + id, + title, + preview, + modified, + }) + .collect(); + + // Load pinned note IDs from settings + let pinned_ids: HashSet = { + let settings = workspace.settings().read().expect("settings read lock"); + settings + .pinned_note_ids + .as_ref() + .map(|ids| ids.iter().cloned().collect()) + .unwrap_or_default() + }; + + // Sort: pinned notes first (by date), then unpinned notes (by date) + notes.sort_by(|a, b| { + let a_pinned = pinned_ids.contains(&a.id); + let b_pinned = pinned_ids.contains(&b.id); + + match (a_pinned, b_pinned) { + (true, false) => std::cmp::Ordering::Less, // a pinned, b not -> a first + (false, true) => std::cmp::Ordering::Greater, // b pinned, a not -> b first + _ => b.modified.cmp(&a.modified), // both same status -> sort by date (newest first) + } + }); + + // Update cache efficiently + { + let mut cache = workspace.notes_cache().write().expect("cache write lock"); + cache.clear(); + for note in ¬es { + cache.insert(note.id.clone(), note.clone()); + } + } + + Ok(notes) +} + +#[tauri::command] +async fn read_note( + id: String, + window: WebviewWindow, + state: State<'_, AppState>, +) -> Result { + let workspace = state.workspace_for_window(window.label())?; + let file_path = workspace.note_path(&id)?; + if !file_path.exists() { + return Err("Note not found".to_string()); + } + + tauri::async_runtime::spawn_blocking(move || read_note_from_path(id, &file_path)) + .await + .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), + }), + } +} + +pub(crate) struct RecoverySnapshotRequest<'a> { + pub recovery_root: &'a Path, + pub note_id: &'a str, + pub source_path: &'a str, + pub content: &'a str, + pub reason: &'a str, + pub timestamp: u128, + pub process_id: u32, +} + +fn write_recovery_snapshot_with( + request: RecoverySnapshotRequest<'_>, + mut next_sequence: NextSequence, + mut save: Save, +) -> Result +where + NextSequence: FnMut() -> u64, + Save: FnMut(&Path, &str) -> Result, +{ + let RecoverySnapshotRequest { + recovery_root, + note_id, + source_path, + content, + reason, + timestamp, + process_id, + } = request; + let recovery_directory = recovery_root.join("recovery"); + std::fs::create_dir_all(&recovery_directory).map_err(|error| error.to_string())?; + let safe_note_id = sanitize_filename(¬e_id.replace('/', "-")); + let safe_reason = sanitize_filename(reason); + + #[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, + }; + + for _ in 0..MAX_RECOVERY_NAME_ATTEMPTS { + let sequence = next_sequence(); + let stem = format!("{timestamp}-{process_id}-{sequence}-{safe_reason}-{safe_note_id}"); + let recovery_path = recovery_directory.join(format!("{stem}.md")); + + match save(&recovery_path, content)? { + persistence::SaveResult::Conflict { .. } => continue, + persistence::SaveResult::Saved { .. } => { + if let Ok(metadata_content) = serde_json::to_string_pretty(&metadata) { + let metadata_path = recovery_directory.join(format!("{stem}.json")); + let _ = save(&metadata_path, &metadata_content); + } + return Ok(recovery_path); + } + } + } + + Err(format!( + "Recovery snapshot filename collision limit reached after {MAX_RECOVERY_NAME_ATTEMPTS} attempts" + )) +} + +fn write_recovery_snapshot( + recovery_root: &Path, + note_id: &str, + source_path: &str, + content: &str, + reason: &str, +) -> Result { + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_millis()) + .unwrap_or(0); + + write_recovery_snapshot_with( + RecoverySnapshotRequest { + recovery_root, + note_id, + source_path, + content, + reason, + timestamp, + process_id: std::process::id(), + }, + || RECOVERY_SNAPSHOT_SEQUENCE.fetch_add(1, Ordering::Relaxed), + |path, value| { + persistence::save_if_revision(path, value, None).map_err(|error| error.to_string()) + }, + ) +} + +#[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 +2787,89 @@ 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_content = content.clone(); + let target_result = tauri::async_runtime::spawn_blocking(move || { + persistence::save_if_revision(&target_path, &target_content, None) + .map_err(|error| error.to_string()) + }) + .await + .map_err(|error| format!("Note rename task failed: {error}"))??; + + if matches!(target_result, persistence::SaveResult::Conflict { .. }) { + return Err("Rename target changed while saving; source note was preserved".into()); + } + + fs::remove_file(old_file_path).await.map_err(|error| { + format!("Saved renamed note but could not remove source: {error}") + })?; + let renamed_id = final_id.clone(); + let renamed_path = file_path.clone(); + saved_note = tauri::async_runtime::spawn_blocking(move || { + read_note_from_path(renamed_id, &renamed_path) + }) + .await + .map_err(|error| format!("Renamed note read task failed: {error}"))??; } } - let metadata = fs::metadata(&file_path) - .await - .map_err(|e| e.to_string())?; - let modified = metadata - .modified() - .ok() - .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|d| d.as_secs() as i64) - .unwrap_or(0); + let modified = saved_note.modified; // Update search index (delete old entry if renamed, then add new) { - let index = state.search_index.lock().expect("search index mutex"); + let index = workspace.search_index().lock().expect("search index mutex"); if let Some(ref search_index) = *index { if let Some((ref old_id_str, _)) = old_id { let _ = search_index.delete_note(old_id_str); @@ -1129,31 +2880,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 +2905,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 +2913,83 @@ 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(()) } +pub(crate) const MAX_CREATE_NOTE_ATTEMPTS: usize = 1_000; + +pub(crate) fn create_note_create_only( + folder_path: &Path, + sanitized_template: &str, + has_counter: bool, +) -> Result { + for attempt in 0..MAX_CREATE_NOTE_ATTEMPTS { + let final_id = if has_counter { + sanitized_template.replace("{counter}", &(attempt + 1).to_string()) + } else if attempt == 0 { + sanitized_template.to_string() + } else { + format!("{sanitized_template}-{attempt}") + }; + let display_title = extract_title_from_id(&final_id); + let content = format!("# {display_title}\n\n"); + let file_path = abs_path_from_id(folder_path, &final_id)?; + + if let Some(parent) = file_path.parent() { + std::fs::create_dir_all(parent).map_err(|error| error.to_string())?; + } + + match persistence::save_if_revision(&file_path, &content, None) + .map_err(|error| error.to_string())? + { + persistence::SaveResult::Saved { .. } => { + return read_note_from_path(final_id, &file_path); + } + persistence::SaveResult::Conflict { .. } => continue, + } + } + + Err(format!( + "Could not create a unique note after {MAX_CREATE_NOTE_ATTEMPTS} attempts", + )) +} + #[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( + app: AppHandle, + 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()) }; @@ -1214,67 +3010,50 @@ async fn create_note(target_folder: Option, state: State<'_, AppState>) sanitized }; - // Handle {counter} tag + // Handle {counter} tag. The final path is chosen atomically below. let has_counter = template.contains("{counter}"); - let base_id = if has_counter { - sanitized.replace("{counter}", "1") - } else { - sanitized.clone() - }; - - let mut final_id = base_id.clone(); - let mut counter = if has_counter { 2 } else { 1 }; - - // Ensure filename uniqueness - 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()); - } else { - final_id = format!("{}-{}", base_id, counter); - } - counter += 1; - } - - // Extract display title from filename - let display_title = extract_title_from_id(&final_id); - - let content = format!("# {}\n\n", display_title); - let file_path = abs_path_from_id(&folder_path, &final_id)?; - - // Create parent directories (for templates like {year}/{month}/{day}) - if let Some(parent) = file_path.parent() { - fs::create_dir_all(parent) - .await - .map_err(|e| e.to_string())?; - } - - fs::write(&file_path, &content) - .await - .map_err(|e| e.to_string())?; - - let modified = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs() as i64) - .unwrap_or(0); + let create_folder = folder_path.clone(); + let create_template = sanitized.clone(); + let note = tauri::async_runtime::spawn_blocking(move || { + create_note_create_only(&create_folder, &create_template, has_counter) + }) + .await + .map_err(|error| format!("Note create task failed: {error}"))??; // 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); + let _ = search_index.index_note(¬e.id, ¬e.title, ¬e.content, note.modified); } } - Ok(Note { - id: final_id, - title: display_title, - content, - path: file_path.to_string_lossy().into_owned(), - modified, - }) + let metadata = NoteMetadata { + id: note.id.clone(), + title: note.title.clone(), + preview: generate_preview(¬e.content), + modified: note.modified, + }; + { + let mut cache = workspace.notes_cache().write().expect("cache write lock"); + cache.insert(metadata.id.clone(), metadata); + } + + emit_workspace_file_change( + &app, + &state, + Path::new(¬e.path), + semantic_file_change_event( + folder, + "created", + None, + Some(¬e), + Some(window.label().to_string()), + ), + Some(window.label()), + ); + + Ok(note) } /// Validate a relative folder path against traversal attacks @@ -1312,18 +3091,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 +3131,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 +3154,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 +3176,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 +3190,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 +3206,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 +3260,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 +3276,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 +3295,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 +3310,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 +3344,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 +3360,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 +3373,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 +3382,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 +3421,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 +3465,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 +3485,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 +3498,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 +3517,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 +3531,143 @@ 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 = match state.workspace_for_window(window.label()) { + Ok(workspace) => workspace + .settings() + .read() + .expect("settings read lock") + .clone(), + Err(error) => { + eprintln!( + "Failed to route settings request for window '{}': {error}", + window.label() + ); + WorkspaceSettings::default() + } + }; + let global_settings = state + .app_config + .read() + .expect("app config read lock") + .global_settings + .clone() + .unwrap_or_default(); + + merge_settings(&global_settings, &workspace_settings) +} + +fn emit_settings_changed( + app: &AppHandle, + state: &AppState, + scope: SettingsChangeScope, + workspace: Option, +) { + let payload = SettingsChangedPayload { scope, workspace }; + let targets = { + let bindings = state + .workspace_bindings + .read() + .expect("workspace bindings read lock"); + settings_change_targets(&bindings, scope, payload.workspace.as_deref()) + }; + + match targets { + SettingsChangeTargets::All => { + let _ = app.emit("settings-changed", payload); + } + SettingsChangeTargets::Windows(labels) => { + for label in labels { + let _ = app.emit_to(&label, "settings-changed", payload.clone()); + } + } + } +} + +#[tauri::command] +fn update_global_settings( + patch: serde_json::Value, + window: WebviewWindow, + state: State, +) -> Result<(), String> { + { + let mut config = state.app_config.write().expect("app config write lock"); + let current = config.global_settings.clone().unwrap_or_default(); + let updated = apply_settings_patch(¤t, patch)?; + let mut next_config = config.clone(); + next_config.global_settings = Some(updated); + save_app_config(window.app_handle(), &next_config).map_err(|error| error.to_string())?; + *config = next_config; + } + + emit_settings_changed( + window.app_handle(), + &state, + SettingsChangeScope::Global, + None, + ); + Ok(()) +} + +#[tauri::command] +fn update_workspace_settings( + patch: serde_json::Value, + window: WebviewWindow, + state: State, +) -> Result<(), String> { + let workspace = state.workspace_for_window(window.label())?; + let folder = workspace.notes_folder()?; + { + let mut settings = workspace.settings().write().expect("settings write lock"); + let updated = apply_settings_patch(&*settings, patch)?; + save_settings(&folder, &updated).map_err(|error| error.to_string())?; + *settings = updated; + } + + emit_settings_changed( + window.app_handle(), + &state, + SettingsChangeScope::Workspace, + Some(folder), + ); + Ok(()) } #[tauri::command] fn update_settings( new_settings: Settings, + window: WebviewWindow, state: State, ) -> Result<(), String> { - let folder = { - let app_config = state.app_config.read().expect("app_config read lock"); - app_config.notes_folder.clone().ok_or("Notes folder not set")? - }; + let workspace = state.workspace_for_window(window.label())?; + let folder = workspace.notes_folder()?; + let (global_settings, workspace_settings) = split_settings(new_settings); { - let mut settings = state.settings.write().expect("settings write lock"); - *settings = new_settings; - } - - let settings = state.settings.read().expect("settings read lock"); - save_settings(&folder, &settings).map_err(|e| e.to_string())?; + // Compatibility full-replacement path. Keep both scope locks through + // persistence so it cannot interleave with the atomic patch commands. + let mut config = state.app_config.write().expect("app config write lock"); + let mut settings = workspace.settings().write().expect("settings write lock"); + let mut next = config.clone(); + next.global_settings = Some(global_settings); + save_app_config(window.app_handle(), &next).map_err(|error| error.to_string())?; + save_settings(&folder, &workspace_settings).map_err(|error| error.to_string())?; + *config = next; + *settings = workspace_settings; + } + + emit_settings_changed( + window.app_handle(), + &state, + SettingsChangeScope::Global, + None, + ); + emit_settings_changed( + window.app_handle(), + &state, + SettingsChangeScope::Workspace, + Some(folder), + ); Ok(()) } @@ -1771,27 +3676,23 @@ fn update_settings( fn update_git_enabled( enabled: Option, expected_folder: String, + window: WebviewWindow, state: State, ) -> Result<(), String> { - let folder = { - let app_config = state.app_config.read().expect("app_config read lock"); - let folder = app_config.notes_folder.clone().ok_or("Notes folder not set")?; - - if folder != expected_folder { - return Err("Notes folder changed".to_string()); - } - - folder - }; + let workspace = state.workspace_for_window(window.label())?; + let folder = workspace.notes_folder()?; + if folder != expected_folder { + return Err("Notes folder changed".to_string()); + } { - let mut settings = state.settings.write().expect("settings write lock"); - settings.git_enabled = enabled; + let mut settings = workspace.settings().write().expect("settings write lock"); + let mut updated = settings.clone(); + updated.git_enabled = enabled; + 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())?; - Ok(()) } @@ -1818,12 +3719,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 +3817,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 +3867,17 @@ async fn read_file_direct(path: String) -> Result { return Err(format!("Not a file: {}", path)); } - let content = fs::read_to_string(&canonical) + tauri::async_runtime::spawn_blocking(move || read_file_content_from_path(&canonical)) .await - .map_err(|_| "Failed to read file".to_string())?; - let metadata = fs::metadata(&canonical) - .await - .map_err(|_| "Failed to read metadata".to_string())?; - - let modified = metadata - .modified() - .ok() - .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|d| d.as_secs() as i64) - .unwrap_or(0); - - let title = extract_title(&content); - - Ok(FileContent { - path, - content, - title, - modified, - }) + .map_err(|error| format!("Direct file read task failed: {error}"))? } #[tauri::command] -async fn save_file_direct(path: String, content: String) -> Result { +async fn save_file_direct( + path: String, + content: String, + expected_revision: String, +) -> Result { // For save, the file must already exist (we validate extension + path security) let canonical = validate_preview_path(&path)?; @@ -1886,34 +3885,33 @@ 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}"))? +} + +fn import_selection_target(window_label: &str) -> &str { + window_label } #[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 +3919,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 +3966,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 +3975,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 +3998,41 @@ 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(); - } + // Keep selection and focus in the workspace window that initiated import. + let _ = app.emit_to( + import_selection_target(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 +4040,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 +4119,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 +4286,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 +4311,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 +4319,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 +4367,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 +4396,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 +4404,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 +4450,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() { @@ -2380,48 +4483,51 @@ async fn copy_image_to_assets( // Sanitize the filename let sanitized_name = sanitize_filename(original_name); - // Create assets folder path - let assets_dir = PathBuf::from(&folder).join("assets"); + // Keep imported files inside the canonical workspace even if an attacker + // replaces the assets directory with a symlink. + let canonical_workspace = fs::canonicalize(&folder) + .await + .map_err(|_| "Workspace folder is unavailable".to_string())?; + let assets_dir = canonical_workspace.join("assets"); fs::create_dir_all(&assets_dir) .await .map_err(|e| e.to_string())?; - // Generate unique filename - let mut target_name = format!("{}.{}", sanitized_name, extension); - let mut counter = 1; - let mut target_path = assets_dir.join(&target_name); - - while target_path.exists() { - target_name = format!("{}-{}.{}", sanitized_name, counter, extension); - target_path = assets_dir.join(&target_name); - counter += 1; - } - - // Copy the file - fs::copy(&source, &target_path) - .await - .map_err(|_| "Failed to copy image".to_string())?; + let copy_source = source.clone(); + let copy_name = sanitized_name.clone(); + let copy_extension = ext_lower.clone(); + let copy_workspace = canonical_workspace.clone(); + let target_path = tauri::async_runtime::spawn_blocking(move || { + let canonical_assets = editor_image_open::canonical_workspace_assets(©_workspace)?; + editor_image_open::copy_image_to_assets_create_only( + ©_source, + &canonical_assets, + ©_name, + ©_extension, + ) + }) + .await + .map_err(|error| format!("Image copy task failed: {error}"))??; + let target_name = target_path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| "Copied image filename is invalid".to_string())?; // Return both relative path and filename for frontend to construct the URL Ok(format!("assets/{}", target_name)) } #[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 +4541,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, @@ -2500,6 +4604,23 @@ async fn open_in_file_manager(path: String) -> Result<(), String> { Ok(()) } +#[tauri::command] +fn open_editor_image( + window: WebviewWindow, + source: String, + state: State<'_, AppState>, +) -> Result<(), String> { + if !editor_image_open::editor_window_can_open_local_images(window.label()) { + return Err("Local images can only be opened from an editor window".to_string()); + } + + let workspace = state.workspace_for_window(window.label())?; + let workspace_path = PathBuf::from(workspace.notes_folder()?); + let image_path = editor_image_open::validate_editor_image_source(&source, &workspace_path)?; + + open::that(&image_path).map_err(|error| format!("Failed to open image: {error}")) +} + #[tauri::command] async fn open_url_safe(url: String) -> Result<(), String> { // Validate URL scheme - only allow http, https, mailto @@ -2529,53 +4650,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 +4704,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 +4726,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 +4750,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 +4772,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 +4797,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 +4822,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 +4846,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 +4862,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 +4996,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 +5070,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 +5082,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 +5366,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 +5432,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 +5497,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 +5601,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 +5634,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 +5643,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 +5675,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 +5684,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,6 +5703,144 @@ 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; @@ -3582,18 +5865,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 +5907,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 +5934,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 +5943,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 +6001,29 @@ pub fn run() { let app = tauri::Builder::default() // Single-instance: forward CLI args from subsequent launches to the running instance .plugin(tauri_plugin_single_instance::init(|app, args, cwd| { - handle_cli_args(app, &args, &cwd); + if let Some((args, cwd)) = defer_single_instance_open_if_needed(app, args, cwd) { + let opened_preview = handle_cli_args(app, &args, &cwd); + if opened_preview { + suppress_full_windows_for_preview(app); + } + hide_main_window_for_standalone_preview(app, opened_preview); + } })) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_clipboard_manager::init()) .plugin(tauri_plugin_updater::Builder::new().build()) + .menu(build_application_menu) + .on_menu_event(|app, event| { + if event.id() == native_open_folder_menu_spec().id { + emit_native_open_folder_request(app); + } else if event.id() == native_preferences_menu_spec().id { + if let Err(error) = create_preferences_window(app) { + eprintln!("Failed to open Preferences: {error}"); + } + } + }) .setup(|app| { // Load app config on startup (contains notes folder path) let mut app_config = load_app_config(app.handle()); @@ -3738,7 +6041,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 +6053,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 - // 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 args: Vec = std::env::args().collect(); - let opened_preview = 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 - }; + // 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 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(); + if args.len() > 1 { + let cwd = std::env::current_dir() + .unwrap_or_default() + .to_string_lossy() + .into_owned(); + opened_preview |= handle_cli_args(app.handle(), &args, &cwd); + } + if opened_preview { + suppress_full_windows_for_preview(app.handle()); + } + + 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 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 { + 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 +6198,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,10 +6218,24 @@ 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, list_folders, @@ -3852,6 +6245,8 @@ pub fn run() { move_note, move_folder, get_settings, + update_global_settings, + update_workspace_settings, update_settings, update_git_enabled, preview_note_name, @@ -3865,6 +6260,7 @@ pub fn run() { save_clipboard_image, open_folder_dialog, open_in_file_manager, + open_editor_image, open_url_safe, git_is_available, git_get_status, @@ -3887,6 +6283,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 +6297,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 { + suppress_full_windows_for_preview(_app_handle); } + hide_main_window_for_standalone_preview(_app_handle, opened_preview); } } }); @@ -3944,8 +6353,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 +6382,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 +6390,1282 @@ 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 main_fallback_drops_selection_from_a_different_workspace() { + let mut config = AppConfig::default(); + let mut previous = WindowSession::for_workspace("/notes/previous".to_string()); + previous.selected_note_id = Some("Previous note".to_string()); + config.records.insert("main".to_string(), previous); + + let fallback = + fallback_main_session(&config, Some("/notes/current"), false, false, |_| true) + .expect("valid current workspace"); + + assert_eq!(fallback.workspace, "/notes/current"); + assert_eq!(fallback.selected_note_id, None); + } + + #[test] + fn individual_close_removes_record_but_quit_preserves_open_records() { + let mut config = AppConfig::default(); + upsert_window_session_workspace(&mut config, "main", "/notes/main".to_string()); + upsert_window_session_workspace( + &mut config, + "workspace-client", + "/notes/client".to_string(), + ); + + assert!(remove_window_session_on_destroy( + &mut config, + "workspace-client", + false, + false, + )); + assert!(!config.records.contains_key("workspace-client")); + assert!(!remove_window_session_on_destroy( + &mut config, + "main", + true, + false, + )); + assert!(config.records.contains_key("main")); + + assert!(!remove_window_session_on_destroy( + &mut config, + "main", + false, + true, + )); + assert!(config.records.contains_key("main")); + } + + #[test] + fn workspace_rebind_updates_record_without_losing_ui_state() { + let mut config = AppConfig::default(); + config.records.insert( + "main".to_string(), + WindowSession { + workspace: "/notes/old".to_string(), + selected_note_id: Some("Roadmap".to_string()), + sidebar_visible: false, + focus_mode: true, + geometry: None, + }, + ); + + upsert_window_session_workspace(&mut config, "main", "/notes/new".to_string()); + + let session = config.records.get("main").unwrap(); + assert_eq!(session.workspace, "/notes/new"); + assert_eq!(session.selected_note_id.as_deref(), Some("Roadmap")); + assert!(!session.sidebar_visible); + assert!(session.focus_mode); + } + + #[test] + fn opening_workspace_window_creates_initial_record() { + let mut config = AppConfig::default(); + + upsert_window_session_workspace( + &mut config, + "workspace-client", + "/notes/client".to_string(), + ); + + assert_eq!( + config.records["workspace-client"], + WindowSession::for_workspace("/notes/client".to_string()) + ); + } +} + +#[cfg(test)] +mod standalone_window_tests { + use super::{ + should_defer_open_request, should_hide_main_window_for_standalone_preview, + should_show_main_window, + }; + + #[test] + fn hides_main_window_for_a_standalone_preview_when_notes_are_configured() { + assert!(should_hide_main_window_for_standalone_preview(true, true)); + } + + #[test] + fn keeps_main_window_for_onboarding_without_a_notes_folder() { + assert!(!should_hide_main_window_for_standalone_preview(true, false)); + } + + #[test] + fn keeps_main_window_for_a_normal_launch() { + assert!(!should_hide_main_window_for_standalone_preview(false, true)); + } + + #[test] + fn defers_file_open_requests_until_app_state_is_ready() { + assert!(should_defer_open_request(false)); + assert!(!should_defer_open_request(true)); + } + + #[test] + fn normal_launch_shows_main_even_when_no_workspace_can_be_restored() { + assert!(should_show_main_window(false)); + assert!(!should_show_main_window(true)); + } +} + +#[cfg(test)] +mod window_chrome_tests { + use super::{runtime_window_config_from_template, WebviewUrl}; + + #[test] + fn release_config_uses_the_canonical_scratch_identity() { + let config: serde_json::Value = serde_json::from_str(include_str!("../tauri.conf.json")) + .expect("tauri config should deserialize"); + + assert_eq!(config["productName"].as_str(), Some("Scratch")); + assert_eq!(config["identifier"].as_str(), Some("com.scratch.app")); + } + + #[test] + fn every_macos_window_uses_the_requested_traffic_light_geometry() { + let config: serde_json::Value = serde_json::from_str(include_str!("../tauri.conf.json")) + .expect("tauri config should deserialize"); + let main_window = &config["app"]["windows"][0]; + + let configured_position = &main_window["trafficLightPosition"]; + assert_eq!(configured_position["x"].as_f64(), Some(16.0)); + assert_eq!(configured_position["y"].as_f64(), Some(24.0)); + let template: tauri::utils::config::WindowConfig = + serde_json::from_value(main_window.clone()).expect("main window config should parse"); + let runtime = runtime_window_config_from_template( + &template, + "preview-alignment-test", + WebviewUrl::App("index.html?mode=preview".into()), + ); + + assert_eq!( + runtime.traffic_light_position, + template.traffic_light_position + ); + assert_eq!(runtime.title_bar_style, template.title_bar_style); + assert_eq!(runtime.hidden_title, template.hidden_title); + assert!(runtime.visible, "runtime windows must still be shown"); + } +} + +#[cfg(test)] +mod native_menu_tests { + use super::{ + focused_window_target, native_open_folder_menu_spec, native_preferences_menu_spec, + }; + + #[test] + fn native_menus_expose_open_folder_and_preferences() { + let open_folder = native_open_folder_menu_spec(); + assert_eq!(open_folder.parent_menu, "File"); + assert_eq!(open_folder.id, "open-folder"); + assert_eq!(open_folder.label, "Open Folder…"); + assert_eq!(open_folder.accelerator, "CmdOrCtrl+O"); + + let preferences = native_preferences_menu_spec(); + assert_eq!(preferences.id, "preferences"); + assert_eq!(preferences.label, "Preferences…"); + assert_eq!(preferences.accelerator, "CmdOrCtrl+,"); + } + + #[test] + fn open_folder_targets_the_focused_window_including_standalone_preview() { + let windows = [("main", false), ("preview-note", true)]; + assert_eq!( + focused_window_target(windows.iter().copied()), + Some("preview-note") + ); + } +} + +#[cfg(test)] +mod workspace_registry_tests { + use std::collections::HashMap; + use std::path::Path; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{Arc, Barrier, Mutex, RwLock}; + + use super::{ + build_workspace_infos, ensure_shared_resource, forget_workspace, get_or_initialize_shared, + import_selection_target, note_change_targets, 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 imported_note_selection_targets_only_the_invoking_workspace_window() { + assert_eq!( + import_selection_target("workspace-client-a"), + "workspace-client-a", + ); + assert_ne!(import_selection_target("workspace-client-a"), "main"); + } + + #[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_created_event_carries_the_new_id_and_revision() { + let note = super::Note { + id: "Untitled".to_string(), + title: "Untitled".to_string(), + content: "# Untitled\n\n".to_string(), + path: "/notes/Untitled.md".to_string(), + modified: 1, + revision: "created-revision".to_string(), + }; + + let event = semantic_file_change_event( + "/notes".to_string(), + "created", + None, + Some(¬e), + Some("main".to_string()), + ); + + assert_eq!(event.kind, "created"); + assert_eq!(event.changed_ids, vec!["Untitled"]); + assert_eq!(event.previous_id, None); + assert_eq!(event.current_id.as_deref(), Some("Untitled")); + assert_eq!(event.revision.as_deref(), Some("created-revision")); + } + + #[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 shared_workspace_acquisition_recovers_poisoned_registry_locks() { + let key = "workspace-poisoned".to_string(); + let registry = Arc::new(RwLock::new(HashMap::>::new())); + let initializer = Arc::new(Mutex::new(())); + let initializers = Arc::new(Mutex::new(HashMap::from([( + key.clone(), + Arc::clone(&initializer), + )]))); + + let poisoned_registry = Arc::clone(®istry); + assert!(std::thread::spawn(move || { + let _guard = poisoned_registry.write().unwrap(); + panic!("poison shared registry"); + }) + .join() + .is_err()); + + let poisoned_initializers = Arc::clone(&initializers); + assert!(std::thread::spawn(move || { + let _guard = poisoned_initializers.lock().unwrap(); + panic!("poison initializer registry"); + }) + .join() + .is_err()); + + let poisoned_initializer = Arc::clone(&initializer); + assert!(std::thread::spawn(move || { + let _guard = poisoned_initializer.lock().unwrap(); + panic!("poison per-workspace initializer"); + }) + .join() + .is_err()); + + let resource = + get_or_initialize_shared(®istry, &initializers, key, || Ok::(42)) + .expect("poisoned locks should not disable every workspace"); + + assert_eq!(*resource, 42); + } + + #[test] + fn window_bindings_keep_identical_note_ids_in_separate_workspaces() { + let mut bindings = WorkspaceBindings::default(); + bindings.bind("main", "/notes/main"); + bindings.bind("workspace-client", "/notes/client"); + + assert_eq!(bindings.path_for_window("main"), Some("/notes/main")); + assert_eq!( + bindings.path_for_window("workspace-client"), + Some("/notes/client") + ); + assert_eq!(bindings.window_for_path("/notes/main"), Some("main")); + assert_eq!( + bindings.window_for_path("/notes/client"), + Some("workspace-client") + ); + } + + #[test] + fn note_paths_resolve_to_the_most_specific_open_workspace() { + let mut bindings = WorkspaceBindings::default(); + bindings.bind("main", "/notes"); + bindings.bind("workspace-client", "/notes/client"); + + assert_eq!( + bindings.window_for_note_path(Path::new("/notes/client/shared.md")), + Some("workspace-client") + ); + assert_eq!( + bindings.window_for_note_path(Path::new("/notes/general.md")), + Some("main") + ); + assert_eq!( + bindings.window_for_note_path(Path::new("/outside/note.md")), + None + ); + } + + #[test] + fn multiple_windows_bound_to_one_workspace_are_not_a_conflict() { + let mut bindings = WorkspaceBindings::default(); + bindings.bind("main", "/notes/main"); + bindings.bind("workspace-client", "/notes/client"); + bindings.bind("workspace-client-copy", "/notes/client"); + + assert_eq!( + bindings.windows_for_path("/notes/client"), + vec![ + "workspace-client".to_string(), + "workspace-client-copy".to_string(), + ] + ); + } + + #[test] + fn removing_one_window_binding_preserves_other_workspaces() { + let mut bindings = WorkspaceBindings::default(); + bindings.bind("main", "/notes/main"); + bindings.bind("workspace-client", "/notes/client"); + + assert_eq!( + bindings.unbind("workspace-client"), + Some("/notes/client".to_string()) + ); + assert_eq!(bindings.path_for_window("workspace-client"), None); + assert_eq!(bindings.path_for_window("main"), Some("/notes/main")); + } + + #[test] + fn workspace_sessions_keep_settings_and_cache_isolated() { + let first = WorkspaceSession::empty("/notes/client-a".to_string()); + let second = WorkspaceSession::empty("/notes/client-b".to_string()); + + first + .settings + .write() + .expect("first settings write lock") + .git_enabled = Some(true); + first + .notes_cache + .write() + .expect("first cache write lock") + .insert( + "shared-id".to_string(), + super::NoteMetadata { + id: "shared-id".to_string(), + title: "Client A".to_string(), + preview: String::new(), + modified: 0, + }, + ); + + assert_eq!(first.notes_folder, "/notes/client-a"); + assert_eq!(second.notes_folder, "/notes/client-b"); + assert_eq!( + second + .settings + .read() + .expect("second settings read lock") + .git_enabled, + None + ); + assert!(second + .notes_cache + .read() + .expect("second cache read lock") + .is_empty()); + } + + #[test] + fn app_state_resolves_sessions_by_window_without_cross_talk() { + let state = AppState::default(); + state.register_workspace_session( + "workspace-a", + WorkspaceSession::empty("/notes/client-a".to_string()), + ); + state.register_workspace_session( + "workspace-b", + WorkspaceSession::empty("/notes/client-b".to_string()), + ); + + assert_eq!( + state + .workspace_session("workspace-a") + .expect("workspace A session") + .notes_folder, + "/notes/client-a" + ); + assert_eq!( + state + .workspace_session("workspace-b") + .expect("workspace B session") + .notes_folder, + "/notes/client-b" + ); + + state.remove_workspace_session("workspace-a"); + + assert!(state.workspace_session("workspace-a").is_none()); + assert!(state.workspace_session("workspace-b").is_some()); + } + + #[test] + fn windows_on_the_same_workspace_share_one_runtime_until_the_last_close() { + let state = AppState::default(); + state.register_workspace_session( + "workspace-a", + WorkspaceSession::empty("/notes/shared".to_string()), + ); + state.register_workspace_session( + "workspace-b", + WorkspaceSession::empty("/notes/shared".to_string()), + ); + + let first = state + .workspace_session("workspace-a") + .expect("first window session"); + let second = state + .workspace_session("workspace-b") + .expect("second window session"); + assert!(std::sync::Arc::ptr_eq(&first, &second)); + assert_eq!(state.workspace_session_count(), 1); + + state.remove_workspace_session("workspace-a"); + assert!(state.workspace_session("workspace-b").is_some()); + assert_eq!(state.workspace_session_count(), 1); + + state.remove_workspace_session("workspace-b"); + assert_eq!(state.workspace_session_count(), 0); + } + + #[test] + fn twenty_windows_share_one_runtime_and_release_it_after_the_last_close() { + let state = AppState::default(); + for index in 0..20 { + state.register_workspace_session( + format!("workspace-{index}"), + WorkspaceSession::empty("/notes/shared".to_string()), + ); + } + + assert_eq!(state.workspace_session_count(), 1); + for index in 0..19 { + state.remove_workspace_session(&format!("workspace-{index}")); + assert_eq!(state.workspace_session_count(), 1); + } + state.remove_workspace_session("workspace-19"); + assert_eq!(state.workspace_session_count(), 0); + } + + #[test] + fn main_and_secondary_window_share_the_same_canonical_runtime() { + let state = AppState::default(); + let main_session = state.register_workspace_session( + "main", + WorkspaceSession::empty("/notes/shared".to_string()), + ); + let copy_session = state + .bind_existing_workspace_session("workspace-copy", "/notes/shared") + .expect("bind second window to main workspace"); + + let main_runtime = state.workspace_for_window("main").expect("main runtime"); + let copy_runtime = state + .workspace_for_window("workspace-copy") + .expect("copy runtime"); + let ( + WorkspaceRuntime::Session(main_from_router), + WorkspaceRuntime::Session(copy_from_router), + ) = (main_runtime, copy_runtime) + else { + panic!("all full editor windows must route through canonical sessions"); + }; + + assert!(std::sync::Arc::ptr_eq(&main_session, ©_session)); + assert!(std::sync::Arc::ptr_eq(&main_from_router, ©_from_router,)); + assert_eq!(state.workspace_session_count(), 1); + } + + #[test] + fn rebinding_the_last_window_releases_its_previous_runtime() { + let state = AppState::default(); + state.register_workspace_session( + "workspace-window", + WorkspaceSession::empty("/notes/old".to_string()), + ); + + state.register_workspace_session( + "workspace-window", + WorkspaceSession::empty("/notes/new".to_string()), + ); + + assert!(state.workspace_session_for_path("/notes/old").is_none()); + assert!(state.workspace_session_for_path("/notes/new").is_some()); + assert_eq!(state.workspace_session_count(), 1); + } + + #[test] + fn workspace_resolution_uses_main_default_and_additional_session_roots() { + let state = AppState::default(); + state + .app_config + .write() + .expect("app config write lock") + .notes_folder = Some("/notes/main".to_string()); + state.register_workspace_session( + "workspace-client", + WorkspaceSession::empty("/notes/client".to_string()), + ); + + let main = state + .workspace_for_window("main") + .expect("main workspace should resolve"); + let client = state + .workspace_for_window("workspace-client") + .expect("client workspace should resolve"); + + assert_eq!(main.notes_folder().unwrap(), "/notes/main"); + assert_eq!(client.notes_folder().unwrap(), "/notes/client"); + assert!(state.workspace_for_window("missing-window").is_err()); + } + + #[test] + fn main_window_binding_can_change_without_mutating_the_default_workspace() { + let state = AppState::default(); + state + .app_config + .write() + .expect("app config write lock") + .notes_folder = Some("/notes/default".to_string()); + state + .workspace_bindings + .write() + .expect("workspace bindings write lock") + .bind("main", "/notes/client"); + + let main = state + .workspace_for_window("main") + .expect("main workspace should resolve from its current binding"); + + assert_eq!(main.notes_folder().unwrap(), "/notes/client"); + assert_eq!( + state + .app_config + .read() + .expect("app config read lock") + .notes_folder + .as_deref(), + Some("/notes/default") + ); + } + + #[test] + fn workspace_resolution_isolates_same_relative_note_path() { + let state = AppState::default(); + state + .app_config + .write() + .expect("app config write lock") + .notes_folder = Some("/notes/main".to_string()); + state.register_workspace_session( + "workspace-client", + WorkspaceSession::empty("/notes/client".to_string()), + ); + + let main_root = state + .workspace_for_window("main") + .unwrap() + .note_path("shared") + .unwrap(); + let client_root = state + .workspace_for_window("workspace-client") + .unwrap() + .note_path("shared") + .unwrap(); + + assert_eq!(main_root, Path::new("/notes/main/shared.md")); + assert_eq!(client_root, Path::new("/notes/client/shared.md")); + assert_ne!(main_root, client_root); + } + + #[test] + fn workspace_infos_identify_current_default_and_open_windows() { + let config = AppConfig { + notes_folder: Some("/notes/main".to_string()), + workspaces: vec![ + "/notes/main".to_string(), + "/notes/client".to_string(), + "/notes/archive".to_string(), + ], + global_settings: None, + records: Default::default(), + }; + let mut bindings = WorkspaceBindings::default(); + bindings.bind("main", "/notes/main"); + bindings.bind("workspace-client", "/notes/client"); + + let infos = build_workspace_infos(&config, &bindings, "workspace-client"); + + assert_eq!(infos.len(), 3); + assert_eq!(infos[0].name, "main"); + assert!(infos[0].is_default); + assert!(infos[0].is_open); + assert!(!infos[0].is_current); + assert_eq!(infos[1].name, "client"); + assert!(!infos[1].is_default); + assert!(infos[1].is_open); + assert!(infos[1].is_current); + assert_eq!(infos[2].name, "archive"); + assert!(!infos[2].is_open); + assert!(!infos[2].is_current); + } + + #[test] + fn desktop_capability_uses_interceptable_close_without_force_destroy() { + let capability: serde_json::Value = + serde_json::from_str(include_str!("../capabilities/default.json")) + .expect("desktop capability should deserialize"); + let windows = capability["windows"] + .as_array() + .expect("desktop capability windows"); + + assert!(windows.iter().any(|window| window == "workspace-*")); + + let permissions = capability["permissions"] + .as_array() + .expect("desktop capability permissions"); + assert!(permissions + .iter() + .any(|permission| permission == "core:window:allow-close")); + assert!(!permissions + .iter() + .any(|permission| permission == "core:window:allow-destroy")); + } + + #[test] + fn preferences_window_has_titlebar_dragging_capability() { + let capability: serde_json::Value = + serde_json::from_str(include_str!("../capabilities/default.json")) + .expect("desktop capability should deserialize"); + let windows = capability["windows"] + .as_array() + .expect("desktop capability windows"); + let permissions = capability["permissions"] + .as_array() + .expect("desktop capability permissions"); + + assert!( + windows.iter().any(|window| window == "preferences"), + "the dedicated Preferences window must receive the drag permission" + ); + assert!(permissions + .iter() + .any(|permission| permission == "core:window:allow-start-dragging")); + } + + #[test] + fn same_note_id_saves_to_each_workspace_without_cross_talk() { + let root = std::env::temp_dir().join(format!( + "scratch-workspace-save-test-{}", + std::process::id() + )); + let alpha_root = root.join("alpha"); + let beta_root = root.join("beta"); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&alpha_root).expect("create alpha workspace"); + std::fs::create_dir_all(&beta_root).expect("create beta workspace"); + let initial = "# shared\n\nInitial.\n"; + std::fs::write(alpha_root.join("shared.md"), initial).expect("seed alpha note"); + std::fs::write(beta_root.join("shared.md"), initial).expect("seed beta note"); + let initial_revision = super::persistence::content_revision(initial).to_string(); + + let state = AppState::default(); + state.register_workspace_session( + "workspace-alpha", + WorkspaceSession::empty(alpha_root.to_string_lossy().into_owned()), + ); + state.register_workspace_session( + "workspace-beta", + WorkspaceSession::empty(beta_root.to_string_lossy().into_owned()), + ); + + tauri::async_runtime::block_on(async { + let alpha = state.workspace_for_window("workspace-alpha").unwrap(); + save_note_in_workspace( + Some("shared".to_string()), + "# shared\n\nAlpha window edit.\n".to_string(), + Some(initial_revision.clone()), + &alpha, + ) + .await + .expect("save alpha note"); + + let beta = state.workspace_for_window("workspace-beta").unwrap(); + save_note_in_workspace( + Some("shared".to_string()), + "# shared\n\nBeta window edit.\n".to_string(), + Some(initial_revision), + &beta, + ) + .await + .expect("save beta note"); + }); + + assert_eq!( + std::fs::read_to_string(alpha_root.join("shared.md")).unwrap(), + "# shared\n\nAlpha window edit.\n" + ); + assert_eq!( + std::fs::read_to_string(beta_root.join("shared.md")).unwrap(), + "# shared\n\nBeta window edit.\n" + ); + + std::fs::remove_dir_all(root).expect("remove workspace save test"); + } +} diff --git a/src-tauri/src/note_persistence_tests.rs b/src-tauri/src/note_persistence_tests.rs new file mode 100644 index 00000000..7afcebbf --- /dev/null +++ b/src-tauri/src/note_persistence_tests.rs @@ -0,0 +1,727 @@ +use crate::persistence::{ + content_revision, read_snapshot, save_if_revision, FileSnapshot, SaveResult, +}; +use crate::{ + create_note_create_only, 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, write_recovery_snapshot_with, + FileSaveResult, NoteConflictSnapshot, NoteSaveResult, RecoverySnapshotRequest, + MAX_RECOVERY_NAME_ATTEMPTS, +}; +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 simultaneous_note_creators_retry_the_same_initial_id_without_overwriting() { + let directory = TestDirectory::new("simultaneous-creators"); + let start = Arc::new(Barrier::new(3)); + + let first_folder = directory.path.clone(); + let first_start = Arc::clone(&start); + let first = std::thread::spawn(move || { + first_start.wait(); + create_note_create_only(&first_folder, "Untitled", false).expect("first creator succeeds") + }); + + let second_folder = directory.path.clone(); + let second_start = Arc::clone(&start); + let second = std::thread::spawn(move || { + second_start.wait(); + create_note_create_only(&second_folder, "Untitled", false).expect("second creator retries") + }); + + start.wait(); + let first_note = first.join().expect("first creator completes"); + let second_note = second.join().expect("second creator completes"); + let mut ids = vec![first_note.id, second_note.id]; + ids.sort(); + + assert_eq!(ids, vec!["Untitled", "Untitled-1"]); + assert_eq!( + fs::read_to_string(directory.note_path("Untitled.md")).unwrap(), + "# Untitled\n\n", + ); + assert_eq!( + fs::read_to_string(directory.note_path("Untitled-1.md")).unwrap(), + "# Untitled 1\n\n", + ); +} + +#[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"); +} + +#[test] +fn recovery_snapshot_retries_collisions_and_uses_the_successful_stem_for_metadata() { + let directory = TestDirectory::new("recovery-collision-retry"); + let mut sequence = 40_u64; + let mut markdown_attempts = 0_usize; + let mut written_paths = Vec::new(); + + let recovery_path = write_recovery_snapshot_with( + RecoverySnapshotRequest { + recovery_root: &directory.path, + note_id: "Plan", + source_path: "/notes/Plan.md", + content: "dirty draft", + reason: "window-close", + timestamp: 123_456, + process_id: 987, + }, + || { + let current = sequence; + sequence += 1; + current + }, + |path, content| { + written_paths.push(path.to_path_buf()); + if path.extension().and_then(|value| value.to_str()) == Some("md") { + markdown_attempts += 1; + if markdown_attempts <= 3 { + return Ok(SaveResult::Conflict { current: None }); + } + } + Ok(SaveResult::Saved { + revision: content_revision(content), + }) + }, + ) + .expect("retry recovery collision"); + + assert_eq!(markdown_attempts, 4); + assert!(recovery_path + .file_stem() + .and_then(|value| value.to_str()) + .is_some_and(|stem| stem.contains("123456-987-43-window-close-Plan"))); + let metadata_path = written_paths + .iter() + .find(|path| path.extension().and_then(|value| value.to_str()) == Some("json")) + .expect("metadata write path"); + assert_eq!(metadata_path.file_stem(), recovery_path.file_stem()); +} + +#[test] +fn recovery_snapshot_reports_collision_retry_exhaustion() { + let directory = TestDirectory::new("recovery-collision-exhaustion"); + let mut attempts = 0_usize; + + let error = write_recovery_snapshot_with( + RecoverySnapshotRequest { + recovery_root: &directory.path, + note_id: "Plan", + source_path: "/notes/Plan.md", + content: "dirty draft", + reason: "window-close", + timestamp: 123_456, + process_id: 987, + }, + || 0, + |_path, _content| { + attempts += 1; + Ok(SaveResult::Conflict { current: None }) + }, + ) + .expect_err("collision exhaustion must fail"); + + assert_eq!(attempts, MAX_RECOVERY_NAME_ATTEMPTS); + assert!(error.contains("collision")); +} + +#[test] +fn recovery_snapshot_returns_non_conflict_persistence_errors_immediately() { + let directory = TestDirectory::new("recovery-persistence-error"); + let mut attempts = 0_usize; + + let error = write_recovery_snapshot_with( + RecoverySnapshotRequest { + recovery_root: &directory.path, + note_id: "Plan", + source_path: "/notes/Plan.md", + content: "dirty draft", + reason: "window-close", + timestamp: 123_456, + process_id: 987, + }, + || 0, + |_path, _content| { + attempts += 1; + Err("disk failure".to_string()) + }, + ) + .expect_err("persistence failure must surface"); + + assert_eq!(attempts, 1); + assert_eq!(error, "disk failure"); +} diff --git a/src-tauri/src/persistence.rs b/src-tauri/src/persistence.rs new file mode 100644 index 00000000..c41556d5 --- /dev/null +++ b/src-tauri/src/persistence.rs @@ -0,0 +1,549 @@ +use std::collections::HashMap; +use std::fmt; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, OnceLock, Weak}; + +static NEXT_TEMPORARY_FILE: AtomicU64 = AtomicU64::new(0); +static PATH_LOCKS: OnceLock>>>> = OnceLock::new(); + +/// Stable SHA-256 identifier for one exact UTF-8 note content. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct ContentRevision(String); + +impl ContentRevision { + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for ContentRevision { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FileSnapshot { + pub revision: ContentRevision, + 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 { + let digest = crate::sha256::digest(content.as_bytes()); + let mut hex = String::with_capacity(digest.len() * 2); + const DIGITS: &[u8; 16] = b"0123456789abcdef"; + + for byte in digest { + hex.push(DIGITS[(byte >> 4) as usize] as char); + hex.push(DIGITS[(byte & 0x0f) as usize] as char); + } + + ContentRevision(hex) +} + +/// 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`. +/// The per-path lock is process-local; external editors can still race, so the +/// revision check and atomic filesystem operation remain authoritative. +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 Some(file_name) = absolute.file_name() else { + return absolute; + }; + absolute + .parent() + .and_then(|parent| parent.canonicalize().ok()) + .map(|parent| parent.join(file_name)) + .unwrap_or(absolute) +} + +fn atomic_write(path: &Path, bytes: &[u8]) -> io::Result<()> { + let parent = path.parent().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "atomic save target has no parent directory", + ) + })?; + let existing_permissions = fs::metadata(path) + .ok() + .map(|metadata| metadata.permissions()); + let (mut temporary_file, mut temporary_path) = create_temporary_file(path, parent)?; + + temporary_file.write_all(bytes)?; + temporary_file.flush()?; + if let Some(permissions) = existing_permissions { + temporary_file.set_permissions(permissions)?; + } + temporary_file.sync_all()?; + drop(temporary_file); + + fs::rename(temporary_path.path(), path)?; + temporary_path.commit(); + sync_parent_directory(parent)?; + Ok(()) +} + +/// Publishes a brand-new file without ever replacing an existing directory +/// entry. The hard-link operation is atomic within the destination directory: +/// another process either wins first or receives `AlreadyExists`. +fn atomic_create_new(path: &Path, bytes: &[u8]) -> io::Result<()> { + atomic_create_new_with_hard_link(path, bytes, |source, destination| { + fs::hard_link(source, destination) + }) +} + +fn atomic_create_new_with_hard_link(path: &Path, bytes: &[u8], hard_link: F) -> io::Result<()> +where + F: FnOnce(&Path, &Path) -> 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 hard_link(temporary_path.path(), path) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::Unsupported => { + let mut source = File::open(temporary_path.path())?; + let mut destination = OpenOptions::new().write(true).create_new(true).open(path)?; + let publish_result = io::copy(&mut source, &mut destination) + .and_then(|_| destination.flush()) + .and_then(|_| destination.sync_all()); + drop(destination); + if let Err(error) = publish_result { + let _ = fs::remove_file(path); + return Err(error); + } + } + Err(error) => return Err(error), + } + + fs::remove_file(temporary_path.path())?; + temporary_path.commit(); + sync_parent_directory(parent)?; + 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()); + } + + #[test] + fn atomic_create_new_falls_back_when_hard_links_are_unsupported() { + let directory = TestDirectory::new("unsupported-hard-link"); + let path = directory.note_path(); + + atomic_create_new_with_hard_link(&path, b"local draft", |_, _| { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "hard links are unavailable", + )) + }) + .expect("create-only fallback should publish the prepared content"); + + assert_eq!(fs::read_to_string(&path).unwrap(), "local draft"); + 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/sha256.rs b/src-tauri/src/sha256.rs new file mode 100644 index 00000000..8ddcc1c6 --- /dev/null +++ b/src-tauri/src/sha256.rs @@ -0,0 +1,121 @@ +pub(crate) fn digest(input: &[u8]) -> [u8; 32] { + const INITIAL: [u32; 8] = [ + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, + 0x5be0cd19, + ]; + const ROUND_CONSTANTS: [u32; 64] = [ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, + 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, + 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, + 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, + 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, + 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, + 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, + 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, + 0xc67178f2, + ]; + + let mut state = INITIAL; + let mut chunks = input.chunks_exact(64); + for chunk in &mut chunks { + compress(&mut state, chunk, &ROUND_CONSTANTS); + } + + let remainder = chunks.remainder(); + let mut tail = [0_u8; 128]; + tail[..remainder.len()].copy_from_slice(remainder); + tail[remainder.len()] = 0x80; + let tail_length = if remainder.len() < 56 { 64 } else { 128 }; + tail[tail_length - 8..tail_length] + .copy_from_slice(&(input.len() as u64).wrapping_mul(8).to_be_bytes()); + + for chunk in tail[..tail_length].chunks_exact(64) { + compress(&mut state, chunk, &ROUND_CONSTANTS); + } + + let mut digest = [0_u8; 32]; + for (output, word) in digest.chunks_exact_mut(4).zip(state) { + output.copy_from_slice(&word.to_be_bytes()); + } + digest +} + +pub(crate) fn hex_digest(input: &[u8]) -> String { + let digest = digest(input); + let mut hex = String::with_capacity(64); + const DIGITS: &[u8; 16] = b"0123456789abcdef"; + for byte in digest { + hex.push(DIGITS[(byte >> 4) as usize] as char); + hex.push(DIGITS[(byte & 0x0f) as usize] as char); + } + hex +} + +fn compress(state: &mut [u32; 8], chunk: &[u8], constants: &[u32; 64]) { + let mut schedule = [0_u32; 64]; + for (index, word) in chunk.chunks_exact(4).enumerate() { + schedule[index] = u32::from_be_bytes([word[0], word[1], word[2], word[3]]); + } + for index in 16..64 { + let s0 = schedule[index - 15].rotate_right(7) + ^ schedule[index - 15].rotate_right(18) + ^ (schedule[index - 15] >> 3); + let s1 = schedule[index - 2].rotate_right(17) + ^ schedule[index - 2].rotate_right(19) + ^ (schedule[index - 2] >> 10); + schedule[index] = schedule[index - 16] + .wrapping_add(s0) + .wrapping_add(schedule[index - 7]) + .wrapping_add(s1); + } + + let [mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut h] = *state; + for index in 0..64 { + let big_s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25); + let choice = (e & f) ^ ((!e) & g); + let temp1 = h + .wrapping_add(big_s1) + .wrapping_add(choice) + .wrapping_add(constants[index]) + .wrapping_add(schedule[index]); + let big_s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22); + let majority = (a & b) ^ (a & c) ^ (b & c); + let temp2 = big_s0.wrapping_add(majority); + + h = g; + g = f; + f = e; + e = d.wrapping_add(temp1); + d = c; + c = b; + b = a; + a = temp1.wrapping_add(temp2); + } + + state[0] = state[0].wrapping_add(a); + state[1] = state[1].wrapping_add(b); + state[2] = state[2].wrapping_add(c); + state[3] = state[3].wrapping_add(d); + state[4] = state[4].wrapping_add(e); + state[5] = state[5].wrapping_add(f); + state[6] = state[6].wrapping_add(g); + state[7] = state[7].wrapping_add(h); +} + +#[cfg(test)] +mod tests { + use super::hex_digest; + + #[test] + fn matches_standard_lowercase_sha256_vectors() { + assert_eq!( + hex_digest(b""), + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + ); + assert_eq!( + hex_digest(b"abc"), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + ); + } +} 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..4b196e89 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 */ @@ -790,7 +851,7 @@ ul[data-type="taskList"] li > div > p { margin: 0; } -ul[data-type="taskList"] li[data-checked="true"] > div { +ul[data-type="taskList"] li[data-checked="true"] > div > p { text-decoration: line-through; opacity: 0.6; } @@ -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,601 @@ 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); +} + +/* Editor block drag controls */ +.notion-block-drag-handle { + --notion-block-handle-offset: -8px; + + z-index: 42; + display: flex; + align-items: center; + gap: 1px; + height: 26px; + padding: 1px; + color: var(--color-text-muted); + border-radius: 5px; + background: color-mix(in srgb, var(--color-bg) 94%, transparent); + opacity: 0; + transform: translateX(var(--notion-block-handle-offset)); + transition: opacity 120ms ease; +} + +/* Keep the 53px Tauri handle plus its 8px visual gap outside block content. + ProseMirror already contributes 24px of inline padding, so the shell only + reserves the remaining 37px on each side. Symmetric padding preserves the + centered Narrow/Normal/Wide layouts and gives Full width a stable gutter. */ +.notion-editor-shell { + padding-inline: 37px; +} + +.notion-block-drag-handle.is-block-hovered, +.notion-block-drag-handle:hover, +.notion-block-drag-handle[data-dragging="true"] { + opacity: 1; +} + +.notion-block-drag-handle:not(.is-block-hovered):not(:hover):not( + [data-dragging="true"] + ) { + pointer-events: none; +} + +.notion-block-add, +.notion-block-grip { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + color: inherit; + border: 0; + border-radius: 4px; + background: transparent; + cursor: pointer; + user-select: none; +} + +.notion-block-control-icon { + display: block; + width: 16px; + height: 16px; + flex: 0 0 16px; +} + +.notion-block-add:hover, +.notion-block-grip:hover { + color: var(--color-text); + background: var(--color-bg-emphasis); +} + +.notion-block-grip { + cursor: grab; +} + +.notion-block-drag-handle.uses-pointer-drag .notion-block-grip { + width: 28px; + height: 24px; +} + +.ProseMirror.notion-pointer-block-drag-enabled img { + cursor: grab; + -webkit-user-drag: none; +} + +.ProseMirror.notion-pointer-block-drag-enabled[data-block-pointer-dragging="true"] + img.ProseMirror-selectednode { + cursor: grabbing; + opacity: 0.72; +} + +.notion-block-drop-indicator { + position: fixed; + z-index: 70; + height: 3px; + 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; + transform: translateY(-1px); +} + +.notion-block-drag-handle[data-dragging="true"] .notion-block-grip { + cursor: grabbing; +} + + +.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-block-drag-handle, + .notion-block-drop-indicator, + .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..000aac95 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -29,13 +29,24 @@ 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, + requestWindowClose, +} 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 +54,7 @@ function getWindowMode(): { const file = params.get("file"); return { isPreview: mode === "preview" && !!file, + isPreferences: mode === "preferences", previewFile: file, }; } @@ -63,10 +75,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 +92,65 @@ 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 && !disposed) { + toast.warning(`Draft recovered to ${result.recoveredTo}`); + } + }) + .catch((error) => { + closeInProgressRef.current = false; + console.error("Failed to close window safely:", error); + if (!disposed) { + toast.error( + `Window kept open because the draft could not be saved: ${error}`, + ); + } + }); + }) + .then((removeListener) => { + if (disposed) removeListener(); + else unlisten = removeListener; + }) + .catch((error) => { + console.error("Failed to register close handler:", error); + }); + + 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 +158,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 +187,40 @@ 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 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 were not opened because the draft could not be saved.", + ); + return; + } + + setView("settings"); + }, [flushCurrentDraft, view]); const closeSettings = useCallback(() => { setView("notes"); }, []); + useWindowShortcuts({ onOpenPreferences: openSettings }); + // Go back to command palette from AI modal const handleBackToPalette = useCallback(() => { setAiModalOpen(false); @@ -206,39 +306,6 @@ function AppContent() { const isEditorEmpty = isInEditor && currentNoteRef.current?.content.trim() === ""; - // Cmd+, - Toggle settings (always works, even in settings) - if ((e.metaKey || e.ctrlKey) && e.key === ",") { - e.preventDefault(); - toggleSettings(); - return; - } - - // Cmd+= or Cmd++ - Zoom in (works everywhere, including settings) - if ((e.metaKey || e.ctrlKey) && (e.key === "=" || e.key === "+")) { - e.preventDefault(); - setInterfaceZoom((prev) => prev + 0.05); - const newZoom = Math.round(Math.min(interfaceZoomRef.current + 0.05, 1.5) * 20) / 20; - toast(`Zoom ${Math.round(newZoom * 100)}%`, { id: "zoom", duration: 1500 }); - return; - } - - // Cmd+- - Zoom out (works everywhere, including settings) - if ((e.metaKey || e.ctrlKey) && (e.key === "-" || e.key === "_")) { - e.preventDefault(); - setInterfaceZoom((prev) => prev - 0.05); - const newZoom = Math.round(Math.max(interfaceZoomRef.current - 0.05, 0.7) * 20) / 20; - toast(`Zoom ${Math.round(newZoom * 100)}%`, { id: "zoom", duration: 1500 }); - return; - } - - // Cmd+0 - Reset zoom (works everywhere, including settings) - if ((e.metaKey || e.ctrlKey) && e.key === "0") { - e.preventDefault(); - setInterfaceZoom(1.0); - toast("Zoom 100%", { id: "zoom", duration: 1500 }); - return; - } - // Block all other shortcuts when in settings view if (view === "settings") { return; @@ -443,7 +510,6 @@ function AppContent() { toggleFocusMode, focusMode, view, - setInterfaceZoom, ]); const handleClosePalette = useCallback(() => { @@ -635,21 +701,63 @@ 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); + requestWindowClose().catch(console.error); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, []); + // Native File > Open Folder… works from any focused window, including a + // standalone Markdown editor or Preferences. + useEffect(() => { + let disposed = false; + let unlisten: (() => void) | undefined; + void listen("open-folder-in-new-window", async () => { + try { + const selected = await open({ + directory: true, + multiple: false, + title: "Open Folder", + }); + if (!disposed && typeof selected === "string") { + await notesService.openWorkspaceWindow(selected); + } + } catch (error) { + console.error("Failed to open folder:", error); + if (!disposed) toast.error("Failed to open folder"); + } + }).then((cleanup) => { + if (disposed) cleanup(); + else unlisten = cleanup; + }); + return () => { + disposed = true; + unlisten?.(); + }; + }, []); + // Add platform class for OS-specific styling (e.g., keyboard shortcuts) useEffect(() => { const os = isMac ? "mac" : isWindows ? "windows" : "linux"; @@ -658,10 +766,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..43778820 100644 --- a/src/components/editor/Editor.tsx +++ b/src/components/editor/Editor.tsx @@ -35,6 +35,8 @@ 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 { getCurrentWebview } from "@tauri-apps/api/webview"; +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 +71,60 @@ 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 { SCRATCH_TRAILING_NODE_OPTIONS } from "./notion/editorBehavior"; +import { + BlockDragControls, + SelectionMenu, +} from "./notion/NotionMenus"; +import { TableControls } from "./notion/TableControls"; +import { + getImageOpenTarget, + handleImageDoubleClick, +} from "./notion/imageInteractions"; +import { + createImageDragScaleFactorController, + filterSupportedImagePaths, + importDroppedImagePaths, + physicalToLogicalPoint, + resolveEditorBlockDropTarget, + type ImageDropBlock, +} from "./imageDrop"; import { cn } from "../../lib/utils"; import { plainTextFromMarkdown } from "../../lib/plainText"; +import { getTitleBarNoteInfoText } from "../../lib/titleBarNoteInfo"; +import { persistCheckpointBeforeEditorDisposal } from "../../lib/editorCheckpointCleanup"; +import { flushPendingEditorSaves } from "../../lib/pendingEditorSaves"; +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 +480,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 +514,9 @@ interface EditorProps { onEditorReady?: (editor: TiptapEditor | null) => void; onSaveToFolder?: () => void; saveToFolderDisabled?: boolean; + onPersistenceControllerReady?: ( + controller: EditorPersistenceController | null, + ) => void; } /** @@ -502,6 +572,34 @@ function blockIndexToPos( return pos; } +function getTopLevelImageDropBlocks( + editor: TiptapEditor, +): ImageDropBlock[] { + const blocks: ImageDropBlock[] = []; + let position = 0; + + for (let index = 0; index < editor.state.doc.childCount; index++) { + const node = editor.state.doc.child(index); + const domNode = editor.view.nodeDOM(position); + const element = + domNode instanceof HTMLElement ? domNode : domNode?.parentElement; + + if (element) { + const rect = element.getBoundingClientRect(); + blocks.push({ + before: position, + after: position + node.nodeSize, + top: rect.top, + bottom: rect.bottom, + }); + } + + position += node.nodeSize; + } + + return blocks; +} + export function Editor({ onToggleSidebar, sidebarVisible, @@ -510,6 +608,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 +621,7 @@ export function Editor({ content: previewMode.content, path: previewMode.filePath, modified: previewMode.modified, + revision: previewMode.revision, } : null : (notesCtx?.currentNote ?? null); @@ -531,27 +631,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 +694,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,14 +713,46 @@ 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 persistCrashCheckpointRef = useRef<() => Promise>( + async () => undefined, + ); + const checkpointSchedulerRef = useRef(null); + if (!checkpointSchedulerRef.current) { + checkpointSchedulerRef.current = createDraftCheckpointScheduler( + { + write: draftCheckpointService.writeDraftCheckpoint, + clear: (key) => + draftCheckpointService.clearDraftCheckpoint(key.noteId), + }, + { + 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); const scrollContainerRef = useRef(null); + const imageDropSurfaceRef = useRef(null); + const imageDragActiveRef = useRef(false); + const imageDragScaleFactorRef = useRef(1); + const [imageDropIndicator, setImageDropIndicator] = useState<{ + top: number; + left: number; + width: number; + } | null>(null); const editorRef = useRef(null); 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 +761,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 +769,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 +795,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 +912,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 +930,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 +954,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 +965,155 @@ export function Editor({ // Compute markdown only now, when we actually save if (editorRef.current) { - needsSaveRef.current = false; const markdown = getMarkdown(editorRef.current); - await saveImmediately(savingNoteId, markdown); + try { + await saveImmediately(savingNoteId, markdown); + if (saveGenerationRef.current === generation) { + needsSaveRef.current = false; + setIsDirty(sourceNeedsSaveRef.current); + await checkpointScheduler.handleSaveOutcome("saved", { + windowLabel: getCurrentWindow().label, + noteId: savingNoteId, + }); + } + } catch (error) { + needsSaveRef.current = true; + console.error("Failed to save note:", error); + toast.error("Failed to save note"); + } } }, 500); - }, [saveImmediately, getMarkdown, currentNote?.id]); + }, [checkpointScheduler, saveImmediately, getMarkdown, currentNote?.id]); + + const flushSourceSave = useCallback(async () => { + if (sourceTimeoutRef.current) { + clearTimeout(sourceTimeoutRef.current); + sourceTimeoutRef.current = null; + } + const noteId = loadedNoteIdRef.current ?? currentNoteIdRef.current; + if (!sourceNeedsSaveRef.current || !noteId) return; + + const generation = sourceSaveGenerationRef.current; + await saveImmediately(noteId, sourceContentRef.current); + if (sourceSaveGenerationRef.current === generation) { + sourceNeedsSaveRef.current = false; + setIsDirty(needsSaveRef.current); + await checkpointScheduler.handleSaveOutcome("saved", { + windowLabel: getCurrentWindow().label, + noteId, + }); + } + }, [checkpointScheduler, saveImmediately]); + + const flushAllPendingSaves = useCallback(async () => { + await flushPendingEditorSaves({ + flushSource: flushSourceSave, + flushFormatted: 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]); + + useEffect(() => { + persistCrashCheckpointRef.current = persistCurrentCrashCheckpoint; + const queueCheckpointCapture = () => { + 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 persistCrashCheckpointRef.current(); + }, delay); + }; + queueCheckpointCaptureRef.current = queueCheckpointCapture; + return () => { + if (queueCheckpointCaptureRef.current === queueCheckpointCapture) { + queueCheckpointCaptureRef.current = () => undefined; + } + }; + }, [persistCurrentCrashCheckpoint]); + + 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 +1387,8 @@ export function Editor({ levels: [1, 2, 3, 4, 5, 6], }, codeBlock: false, + link: false, + trailingNode: SCRATCH_TRAILING_NODE_OPTIONS, }), CodeBlockLowlight.extend({ addNodeView() { @@ -1103,15 +1439,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, @@ -1138,14 +1487,22 @@ export function Editor({ autocorrect: "on", autocapitalize: "sentences", }, + handleDOMEvents: { + dblclick: handleImageDoubleClick, + }, // 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 +1510,7 @@ export function Editor({ null, slice.content, ); - return manager.serialize(doc.toJSON()); + return serializeMarkdownDocument(manager, doc.toJSON()); } catch { return fallback; } @@ -1218,9 +1575,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 cannot be pasted inside another table."); + 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 +1613,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 cannot be pasted inside another table."); + return true; + } currentEditor.commands.insertContent(parsed); return true; } @@ -1253,8 +1644,9 @@ export function Editor({ onCreate: ({ editor: editorInstance }) => { editorRef.current = editorInstance; }, - onUpdate: () => { + onUpdate: ({ transaction }) => { if (isLoadingRef.current) return; + if (transaction.getMeta("scratchTableRowResizePreview")) return; scheduleSave(); }, onSelectionUpdate: () => { @@ -1274,6 +1666,195 @@ export function Editor({ // Track reloadVersion to detect manual refreshes const lastReloadVersionRef = useRef(0); + // Tauri handles operating-system file drops before they reach HTML5 or + // ProseMirror. Bridge native image paths into Scratch's existing asset + // importer, then insert the image at the physical drop location. + useEffect(() => { + if ( + !editor || + !currentNote || + typeof window === "undefined" || + !("__TAURI_INTERNALS__" in window) + ) { + return; + } + + let disposed = false; + let unlisten: (() => void) | undefined; + + const registerImageDrop = async () => { + try { + const currentWebview = getCurrentWebview(); + const currentWindow = getCurrentWindow(); + const scaleFactorController = createImageDragScaleFactorController( + imageDragScaleFactorRef, + () => currentWindow.scaleFactor(), + ); + const removeListener = await currentWebview.onDragDropEvent( + async (event) => { + if (editor.isDestroyed || !editor.isEditable) { + return; + } + + if (event.payload.type === "leave") { + imageDragActiveRef.current = false; + scaleFactorController.reset(); + setImageDropIndicator(null); + return; + } + + if (event.payload.type === "enter") { + imageDragActiveRef.current = + filterSupportedImagePaths(event.payload.paths).length > 0; + if (!imageDragActiveRef.current) { + scaleFactorController.reset(); + setImageDropIndicator(null); + return; + } + } + + if ( + event.payload.type === "over" && + !imageDragActiveRef.current + ) { + return; + } + + try { + const scaleFactor = await scaleFactorController.enter(); + if (scaleFactor === null) return; + if ( + event.payload.type !== "drop" && + !imageDragActiveRef.current + ) { + return; + } + const point = physicalToLogicalPoint( + event.payload.position, + scaleFactor, + ); + const firstBlock = editor.state.doc.firstChild; + const titleBoundary = + firstBlock?.type.name === "heading" && + firstBlock.attrs.level === 1 + ? firstBlock.nodeSize + : 0; + const editorRect = editor.view.dom.getBoundingClientRect(); + const dropSurfaceRect = + imageDropSurfaceRef.current?.getBoundingClientRect() ?? null; + const dropTarget = resolveEditorBlockDropTarget( + point, + editorRect, + dropSurfaceRect, + getTopLevelImageDropBlocks(editor), + editor.state.selection.from, + titleBoundary, + ); + + if (event.payload.type !== "drop") { + if (dropTarget && dropSurfaceRect) { + setImageDropIndicator({ + top: dropTarget.top - dropSurfaceRect.top, + left: editorRect.left - dropSurfaceRect.left, + width: editorRect.width, + }); + } else { + setImageDropIndicator(null); + } + return; + } + + imageDragActiveRef.current = false; + scaleFactorController.reset(); + setImageDropIndicator(null); + + const imagePaths = filterSupportedImagePaths( + event.payload.paths, + ); + if (imagePaths.length === 0 || dropTarget === null) return; + + const notesFolder = await invoke("get_notes_folder"); + const result = await importDroppedImagePaths( + imagePaths, + dropTarget.position, + { + copyImageToAssets: (sourcePath) => + invoke("copy_image_to_assets", { sourcePath }), + resolveAssetUrl: async (relativePath) => { + const absolutePath = await join(notesFolder, relativePath); + return convertFileSrc(absolutePath); + }, + insertImage: (src, position) => { + if (editor.isDestroyed) { + throw new Error("Editor was destroyed during image drop"); + } + const safePosition = Math.max( + 0, + Math.min(position, editor.state.doc.content.size), + ); + const inserted = editor + .chain() + .focus() + .insertContentAt(safePosition, { + type: "image", + attrs: { src }, + }) + .run(); + if (!inserted) { + throw new Error("Tiptap rejected the dropped image"); + } + }, + onError: (sourcePath, error) => { + console.error( + `Failed to import dropped image: ${sourcePath}`, + error, + ); + }, + }, + ); + + if (result.imported > 0) { + toast.success( + result.imported === 1 + ? "Image inserted" + : `${result.imported} images inserted`, + ); + } + if (result.failed > 0) { + toast.error( + result.failed === 1 + ? "Failed to insert one image" + : `Failed to insert ${result.failed} images`, + ); + } + } catch (error) { + console.error("Failed to handle dropped images:", error); + toast.error("Failed to insert dropped image"); + } + }, + ); + + if (disposed) { + removeListener(); + } else { + unlisten = removeListener; + } + } catch (error) { + if (!disposed) { + console.error("Failed to register native image drop:", error); + } + } + }; + + void registerImageDrop(); + + return () => { + disposed = true; + imageDragScaleFactorRef.current = 1; + unlisten?.(); + }; + }, [editor, currentNote?.id]); + // Notify parent component when editor is ready useEffect(() => { onEditorReady?.(editor); @@ -1485,17 +2066,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 +2095,9 @@ export function Editor({ loadedNoteIdRef.current = loadingNoteId; loadedModifiedRef.current = currentNote.modified; + needsSaveRef.current = false; + sourceNeedsSaveRef.current = false; + setIsDirty(false); isLoadingRef.current = true; @@ -1521,14 +2108,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 +2166,26 @@ 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 () => { + persistCheckpointBeforeEditorDisposal( + () => persistCrashCheckpointRef.current(), + () => checkpointScheduler.dispose(), + (stage, error) => { + console.error(`Failed to ${stage} editor crash checkpoint:`, 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 (linkPopupRef.current) { linkPopupRef.current.destroy(); @@ -2027,13 +2619,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 +2713,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 +2745,7 @@ export function Editor({ } }, 300); }, - [currentNote, saveNote], + [checkpointScheduler, currentNote, saveNote], ); if (!currentNote) { @@ -2257,14 +2863,61 @@ export function Editor({ )} - - {formatDateTime(currentNote.modified)} - + {titleBarNoteInfo && ( + + {titleBarNoteInfo} + + )}
- {hasExternalChanges ? ( + {hasSaveConflict && resolveNoteConflict ? ( + + + + + + + + + { + void resolveNoteConflict("keepLocal").catch((error) => { + toast.error(`Conflict remains: ${error}`); + }); + }} + > + Keep My Changes + + { + void resolveNoteConflict("useRemote").catch((error) => { + toast.error(`Conflict remains: ${error}`); + }); + }} + > + Use Version on Disk + + +
+ A recovery copy is created before either action. +
+
+
+
+ ) : hasExternalChanges ? ( @@ -2282,6 +2935,15 @@ export function Editor({
+ ) : isDirty ? ( + +
+ +
+
) : (
@@ -2432,22 +3094,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 && ( - + )}
)}
{ if (!editor) return; + const eventTarget = e.target; + const image = + eventTarget instanceof Element + ? eventTarget.closest("img") + : null; + if (image && editor.view.dom.contains(image)) { + e.preventDefault(); + e.stopPropagation(); + + const openTarget = getImageOpenTarget( + image.currentSrc || image.getAttribute("src") || "", + ); + if (!openTarget) { + toast.error("This image cannot be opened"); + return; + } + + try { + const menu = await Menu.new({ + items: [ + await MenuItem.new({ + text: "Open Image", + action: () => { + if (openTarget.kind === "local-asset") { + void invoke("open_editor_image", { + source: openTarget.source, + }).catch((error) => { + console.error("Failed to open image:", error); + toast.error("Failed to open image"); + }); + } else { + void openUrl(openTarget.value).catch((error) => { + console.error("Failed to open image:", error); + toast.error("Failed to open image"); + }); + } + }, + }), + ], + }); + await menu.popup(); + } catch (error) { + console.error("Image context menu error:", error); + toast.error("Failed to open image menu"); + } + return; + } + // Get the position at the click coordinates const clickPos = editor.view.posAtCoords({ left: e.clientX, @@ -2619,13 +3335,15 @@ export function Editor({ editor.chain().focus().addColumnAfter().run(), }), ); - menuItems.push( - await MenuItem.new({ - text: "Delete Column", - action: () => - editor.chain().focus().deleteColumn().run(), - }), - ); + if (rowNode.childCount > 1) { + menuItems.push( + await MenuItem.new({ + text: "Delete Column", + action: () => + editor.chain().focus().deleteColumn().run(), + }), + ); + } menuItems.push( await PredefinedMenuItem.new({ item: "Separator" }), ); @@ -2647,12 +3365,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 +3410,28 @@ export function Editor({ } }} > - + + {imageDropIndicator && ( + )} diff --git a/src/components/editor/EditorWidthHandle.test.tsx b/src/components/editor/EditorWidthHandle.test.tsx new file mode 100644 index 00000000..0547cca9 --- /dev/null +++ b/src/components/editor/EditorWidthHandle.test.tsx @@ -0,0 +1,136 @@ +import { act, createRef } from "react"; +import { createRoot } from "react-dom/client"; +import { describe, expect, it, vi } from "vitest"; +import { + EditorWidthHandles, + getRenderedEditorWidth, +} from "./EditorWidthHandle"; + +vi.mock("../../context/ThemeContext", () => ({ + useTheme: () => ({ + editorWidth: "normal", + customEditorWidthPx: 768, + setEditorWidth: vi.fn(), + setCustomEditorWidthPx: vi.fn(), + setEditorMaxWidthLive: vi.fn(), + }), +})); + +(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("caps the measured width at the container width", () => { + const container = document.createElement("div"); + const editor = document.createElement("div"); + editor.className = "ProseMirror"; + editor.getBoundingClientRect = () => ({ + x: 0, + y: 0, + left: 0, + top: 0, + right: 700, + bottom: 800, + width: 700, + height: 800, + toJSON: () => ({}), + }); + Object.defineProperty(container, "clientWidth", { value: 600 }); + container.append(editor); + + expect(getRenderedEditorWidth(container)).toBe(600); + }); + + 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(); + }); + + it("mounts both resize handles when mouse resizing is enabled", () => { + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + disconnect() {} + }, + ); + const editorContainer = document.createElement("div"); + const editor = document.createElement("div"); + editor.className = "ProseMirror"; + editor.getBoundingClientRect = () => ({ + x: 216, + y: 0, + left: 216, + top: 0, + right: 984, + bottom: 800, + width: 768, + height: 800, + toJSON: () => ({}), + }); + Object.defineProperty(editorContainer, "clientWidth", { value: 1200 }); + editorContainer.append(editor); + + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + + act(() => { + root.render( + , + ); + }); + + expect(container.querySelectorAll('[role="separator"]')).toHaveLength(2); + expect( + container.querySelector('[aria-label="Resize editor width (left)"]'), + ).not.toBeNull(); + expect( + container.querySelector('[aria-label="Resize editor width (right)"]'), + ).not.toBeNull(); + + act(() => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); + }); +}); 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..5a0853ae --- /dev/null +++ b/src/components/editor/editorHistory.test.ts @@ -0,0 +1,148 @@ +import { Editor, type JSONContent } from "@tiptap/core"; +import { TableKit } from "@tiptap/extension-table"; +import StarterKit from "@tiptap/starter-kit"; +import { describe, expect, it, vi } from "vitest"; +import { replaceEditorContentWithoutHistory } from "./editorHistory"; + +describe("editor note history", () => { + it("never lets undo erase the content loaded from the note", () => { + const editor = new Editor({ + extensions: [StarterKit], + content: "

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

${loadedContent}

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

", + }); + + const loadedContent: JSONContent = { + type: "doc", + content: [ + { + type: "table", + content: [ + { + type: "tableRow", + content: [ + { + type: "tableCell", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Parent cell" }], + }, + { + type: "table", + content: [ + { + type: "tableRow", + content: [ + { + type: "tableCell", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "Nested cell" }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }; + + try { + replaceEditorContentWithoutHistory(editor, loadedContent); + + const cellsContainingNestedTables: string[] = []; + editor.state.doc.descendants((node) => { + if (node.type.name !== "tableCell" && node.type.name !== "tableHeader") { + return; + } + + let containsTable = false; + node.descendants((descendant) => { + if (descendant.type.name === "table") { + containsTable = true; + return false; + } + }); + + if (containsTable) { + cellsContainingNestedTables.push(node.textContent); + } + }); + + expect(cellsContainingNestedTables).toEqual([]); + expect(editor.state.doc.textContent).toContain("Parent cell"); + expect(editor.state.doc.textContent).toContain("Nested cell"); + } finally { + editor.destroy(); + } + }); + + it("parses loaded HTML in an inert document without running attributes", () => { + const editor = new Editor({ + extensions: [StarterKit], + content: "

Before

", + }); + const parseFromString = vi.spyOn(DOMParser.prototype, "parseFromString"); + const loadedContent = + '

Safe

'; + let eventHandlerRan = false; + (globalThis as typeof globalThis & { markLoaded?: () => void }).markLoaded = + () => { + eventHandlerRan = true; + }; + + try { + replaceEditorContentWithoutHistory( + editor, + loadedContent, + ); + + expect(parseFromString).toHaveBeenCalledWith(loadedContent, "text/html"); + expect(eventHandlerRan).toBe(false); + expect(editor.getText()).toBe("Safe"); + } finally { + delete (globalThis as typeof globalThis & { markLoaded?: () => void }) + .markLoaded; + parseFromString.mockRestore(); + editor.destroy(); + } + }); +}); diff --git a/src/components/editor/editorHistory.ts b/src/components/editor/editorHistory.ts new file mode 100644 index 00000000..0fdf4496 --- /dev/null +++ b/src/components/editor/editorHistory.ts @@ -0,0 +1,47 @@ +import type { Content, Editor } from "@tiptap/core"; +import { DOMParser as ProseMirrorDOMParser } from "@tiptap/pm/model"; +import { EditorState } from "@tiptap/pm/state"; +import { normalizeNestedTablesInJson } from "./notion/tableIntegrity"; + +function normalizeLoadedContent(editor: Editor, content: Content): Content { + if (typeof content === "string") { + const inertDocument = new DOMParser().parseFromString(content, "text/html"); + const parsed = ProseMirrorDOMParser.fromSchema(editor.schema).parse( + inertDocument.body, + ); + 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; + // Recreating state intentionally resets transient plugin state for the newly loaded note. + editor.view.updateState( + EditorState.create({ + schema: loadedState.schema, + doc: loadedState.doc, + selection: loadedState.selection, + plugins: loadedState.plugins, + }), + ); +} diff --git a/src/components/editor/imageDrop.test.ts b/src/components/editor/imageDrop.test.ts new file mode 100644 index 00000000..55dd7111 --- /dev/null +++ b/src/components/editor/imageDrop.test.ts @@ -0,0 +1,432 @@ +import { describe, expect, it, vi } from "vitest"; +import { Editor } from "@tiptap/core"; +import Image from "@tiptap/extension-image"; +import StarterKit from "@tiptap/starter-kit"; +import { + createImageDragScaleFactorController, + filterSupportedImagePaths, + importDroppedImagePaths, + isPointInsideRect, + physicalToLogicalPoint, + resolveBlockDropTarget, + resolveEditorBlockDropTarget, + resolveImageDropPosition, +} from "./imageDrop"; + +describe("image drag and drop adapter", () => { + it("keeps only image paths accepted by the Scratch backend", () => { + expect( + filterSupportedImagePaths([ + "/tmp/photo.PNG", + "/tmp/diagram.svg", + "/tmp/archive.png.zip", + "/tmp/note.md", + "C:\\Temp\\scan.TIFF", + "/tmp/no-extension", + ]), + ).toEqual([ + "/tmp/photo.PNG", + "/tmp/diagram.svg", + "C:\\Temp\\scan.TIFF", + ]); + }); + + it("converts Tauri physical drop coordinates to browser logical pixels", () => { + expect(physicalToLogicalPoint({ x: 840, y: 520 }, 2)).toEqual({ + x: 420, + y: 260, + }); + expect(physicalToLogicalPoint({ x: 105, y: 51 }, 1.5)).toEqual({ + x: 70, + y: 34, + }); + }); + + it("caches one scale-factor lookup for each native image drag", async () => { + const scaleFactorRef = { current: 1 }; + const loadScaleFactor = vi + .fn<() => Promise>() + .mockResolvedValueOnce(2) + .mockResolvedValueOnce(1.5); + const controller = createImageDragScaleFactorController( + scaleFactorRef, + loadScaleFactor, + ); + + await expect(controller.enter()).resolves.toBe(2); + expect(controller.current()).toBe(2); + expect(controller.current()).toBe(2); + expect(loadScaleFactor).toHaveBeenCalledTimes(1); + + controller.reset(); + expect(controller.current()).toBe(1); + + await expect(controller.enter()).resolves.toBe(1.5); + expect(loadScaleFactor).toHaveBeenCalledTimes(2); + }); + + it("shares an in-flight scale-factor lookup across overlapping drag events", async () => { + let resolveScaleFactor: ((value: number) => void) | undefined; + const loadScaleFactor = vi.fn( + () => + new Promise((resolve) => { + resolveScaleFactor = resolve; + }), + ); + const controller = createImageDragScaleFactorController( + { current: 1 }, + loadScaleFactor, + ); + + const entered = controller.enter(); + const moved = controller.enter(); + + expect(loadScaleFactor).toHaveBeenCalledTimes(1); + resolveScaleFactor?.(2); + await expect(entered).resolves.toBe(2); + await expect(moved).resolves.toBe(2); + }); + + it("does not restore a stale scale factor after the drag is reset", async () => { + let resolveScaleFactor: ((value: number) => void) | undefined; + const scaleFactorRef = { current: 1 }; + const controller = createImageDragScaleFactorController( + scaleFactorRef, + () => + new Promise((resolve) => { + resolveScaleFactor = resolve; + }), + ); + + const pending = controller.enter(); + controller.reset(); + resolveScaleFactor?.(2); + + await expect(pending).resolves.toBeNull(); + expect(controller.current()).toBe(1); + }); + + it("falls back to scale factor 1 when native scale resolution is invalid", async () => { + for (const invalidScaleFactor of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) { + const scaleFactorRef = { current: 2 }; + const controller = createImageDragScaleFactorController( + scaleFactorRef, + async () => invalidScaleFactor, + ); + + await expect(controller.enter()).resolves.toBe(1); + expect(controller.current()).toBe(1); + } + + const rejectedScaleFactorRef = { current: 2 }; + const rejectedController = createImageDragScaleFactorController( + rejectedScaleFactorRef, + async () => Promise.reject(new Error("native scale unavailable")), + ); + await expect(rejectedController.enter()).resolves.toBe(1); + expect(rejectedController.current()).toBe(1); + }); + + it("inserts only when the native drop point is inside the editor", () => { + const editorRect = { left: 200, top: 100, right: 900, bottom: 700 }; + const posAtCoords = vi.fn(() => ({ pos: 27 })); + + expect( + resolveImageDropPosition( + { x: 450, y: 240 }, + editorRect, + posAtCoords, + 8, + ), + ).toBe(27); + expect(posAtCoords).toHaveBeenCalledWith({ left: 450, top: 240 }); + + expect( + resolveImageDropPosition( + { x: 120, y: 240 }, + editorRect, + posAtCoords, + 8, + ), + ).toBeNull(); + }); + + it("treats every rectangle edge as inside and one pixel beyond as outside", () => { + const rect = { left: 10, top: 20, right: 110, bottom: 220 }; + + for (const point of [ + { x: 50, y: 80 }, + { x: 10, y: 80 }, + { x: 110, y: 80 }, + { x: 50, y: 20 }, + { x: 50, y: 220 }, + ]) { + expect(isPointInsideRect(point, rect)).toBe(true); + } + + for (const point of [ + { x: 9, y: 80 }, + { x: 111, y: 80 }, + { x: 50, y: 19 }, + { x: 50, y: 221 }, + ]) { + expect(isPointInsideRect(point, rect)).toBe(false); + } + }); + + it("uses the current selection when posAtCoords cannot resolve an editor gap", () => { + expect( + resolveImageDropPosition( + { x: 300, y: 200 }, + { left: 0, top: 0, right: 600, bottom: 500 }, + () => null, + 19, + ), + ).toBe(19); + }); + + it("keeps dropped images after the leading note title", () => { + expect( + resolveImageDropPosition( + { x: 300, y: 120 }, + { left: 0, top: 0, right: 600, bottom: 500 }, + () => ({ pos: 0 }), + 0, + 14, + ), + ).toBe(14); + + expect( + resolveImageDropPosition( + { x: 300, y: 450 }, + { left: 0, top: 0, right: 600, bottom: 500 }, + () => ({ pos: 42 }), + 0, + 14, + ), + ).toBe(42); + }); + + it("snaps an image drop to the closest boundary between text blocks", () => { + const blocks = [ + { before: 0, after: 12, top: 100, bottom: 140 }, + { before: 12, after: 30, top: 160, bottom: 190 }, + { before: 30, after: 50, top: 210, bottom: 240 }, + ]; + const editorRect = { left: 200, top: 80, right: 900, bottom: 500 }; + + expect( + resolveBlockDropTarget( + { x: 450, y: 151 }, + editorRect, + blocks, + 18, + 12, + ), + ).toEqual({ position: 12, top: 150 }); + expect( + resolveBlockDropTarget( + { x: 450, y: 202 }, + editorRect, + blocks, + 18, + 12, + ), + ).toEqual({ position: 30, top: 200 }); + expect( + resolveBlockDropTarget( + { x: 450, y: 238 }, + editorRect, + blocks, + 18, + 12, + ), + ).toEqual({ position: 50, top: 240 }); + }); + + it("accepts a drop after the last paragraph anywhere in the document body", () => { + expect( + resolveEditorBlockDropTarget( + { x: 1080, y: 620 }, + { left: 240, top: 80, right: 960, bottom: 260 }, + { left: 0, top: 80, right: 1200, bottom: 800 }, + [ + { before: 0, after: 12, top: 100, bottom: 140 }, + { before: 12, after: 36, top: 165, bottom: 220 }, + ], + 20, + 12, + ), + ).toEqual({ position: 36, top: 220 }); + }); + + it("accepts a drop below the last paragraph after the document is scrolled", () => { + expect( + resolveEditorBlockDropTarget( + { x: 428, y: 744 }, + { left: 384, top: -1592, right: 1152, bottom: 800 }, + { left: 256, top: -1592, right: 1280, bottom: -873 }, + [ + { before: 0, after: 12, top: -1560, bottom: -840 }, + { before: 12, after: 36, top: 680, bottom: 704 }, + ], + 20, + 12, + ), + ).toEqual({ position: 36, top: 704 }); + }); + + it("copies supported images sequentially and inserts them in drop order", async () => { + const copyImageToAssets = vi + .fn<(path: string) => Promise>() + .mockImplementation(async (path) => `assets/${path.split("/").pop()}`); + const resolveAssetUrl = vi + .fn<(relativePath: string) => Promise>() + .mockImplementation(async (relativePath) => `asset://${relativePath}`); + const insertImage = vi.fn<(src: string, position: number) => void>(); + + const result = await importDroppedImagePaths( + ["/tmp/first.png", "/tmp/ignore.txt", "/tmp/second.webp"], + 12, + { copyImageToAssets, resolveAssetUrl, insertImage }, + ); + + expect(result).toEqual({ imported: 2, failed: 0 }); + expect(copyImageToAssets.mock.calls).toEqual([ + ["/tmp/first.png"], + ["/tmp/second.webp"], + ]); + expect(insertImage.mock.calls).toEqual([ + ["asset://assets/first.png", 12], + ["asset://assets/second.webp", 13], + ]); + }); + + it("creates a rendered Tiptap image node from the imported asset URL", async () => { + const editor = new Editor({ + extensions: [StarterKit, Image.configure({ allowBase64: false })], + content: "

Drop target

", + }); + + const result = await importDroppedImagePaths( + ["/tmp/rendered.png"], + 0, + { + copyImageToAssets: async () => "assets/rendered.png", + resolveAssetUrl: async () => + "asset://localhost/notes/assets/rendered.png", + insertImage: (src, position) => { + const inserted = editor.commands.insertContentAt(position, { + type: "image", + attrs: { src }, + }); + if (!inserted) throw new Error("image insertion failed"); + }, + }, + ); + + expect(result).toEqual({ imported: 1, failed: 0 }); + expect(editor.getJSON().content?.[0]).toMatchObject({ + type: "image", + attrs: { src: "asset://localhost/notes/assets/rendered.png" }, + }); + expect(editor.view.dom.querySelector("img")?.getAttribute("src")).toBe( + "asset://localhost/notes/assets/rendered.png", + ); + + editor.destroy(); + }); + + it("inserts an image block after a body paragraph", async () => { + const editor = new Editor({ + extensions: [StarterKit, Image.configure({ allowBase64: false })], + content: "

Title

First paragraph

Second paragraph

", + }); + const titleSize = editor.state.doc.child(0).nodeSize; + const firstParagraphSize = editor.state.doc.child(1).nodeSize; + const afterFirstParagraph = titleSize + firstParagraphSize; + + const result = await importDroppedImagePaths( + ["/tmp/after-paragraph.png"], + afterFirstParagraph, + { + copyImageToAssets: async () => "assets/after-paragraph.png", + resolveAssetUrl: async (relativePath) => `asset://${relativePath}`, + insertImage: (src, position) => { + const inserted = editor.commands.insertContentAt(position, { + type: "image", + attrs: { src }, + }); + if (!inserted) throw new Error("image insertion failed"); + }, + }, + ); + + expect(result).toEqual({ imported: 1, failed: 0 }); + expect(editor.getJSON().content?.map((node) => node.type)).toEqual([ + "heading", + "paragraph", + "image", + "paragraph", + ]); + + editor.destroy(); + }); + + it("inserts an image after the last body paragraph", async () => { + const editor = new Editor({ + extensions: [StarterKit, Image.configure({ allowBase64: false })], + content: "

Title

Last paragraph

", + }); + + const result = await importDroppedImagePaths( + ["/tmp/after-last-paragraph.png"], + editor.state.doc.content.size, + { + copyImageToAssets: async () => "assets/after-last-paragraph.png", + resolveAssetUrl: async (relativePath) => `asset://${relativePath}`, + insertImage: (src, position) => { + const inserted = editor.commands.insertContentAt(position, { + type: "image", + attrs: { src }, + }); + if (!inserted) throw new Error("image insertion failed"); + }, + }, + ); + + expect(result).toEqual({ imported: 1, failed: 0 }); + expect(editor.getJSON().content?.map((node) => node.type)).toEqual([ + "heading", + "paragraph", + "image", + "paragraph", + ]); + + editor.destroy(); + }); + + it("continues importing later images when one copy fails", async () => { + const onError = vi.fn(); + const insertImage = vi.fn<(src: string, position: number) => void>(); + + const result = await importDroppedImagePaths( + ["/tmp/broken.png", "/tmp/good.jpg"], + 4, + { + copyImageToAssets: async (path) => { + if (path.endsWith("broken.png")) throw new Error("copy failed"); + return "assets/good.jpg"; + }, + resolveAssetUrl: async (relativePath) => `asset://${relativePath}`, + insertImage, + onError, + }, + ); + + expect(result).toEqual({ imported: 1, failed: 1 }); + expect(insertImage).toHaveBeenCalledWith("asset://assets/good.jpg", 4); + expect(onError).toHaveBeenCalledOnce(); + expect(onError.mock.calls[0]?.[0]).toBe("/tmp/broken.png"); + }); +}); diff --git a/src/components/editor/imageDrop.ts b/src/components/editor/imageDrop.ts new file mode 100644 index 00000000..a7eeefcd --- /dev/null +++ b/src/components/editor/imageDrop.ts @@ -0,0 +1,236 @@ +const SUPPORTED_IMAGE_EXTENSIONS = new Set([ + "jpg", + "jpeg", + "png", + "gif", + "webp", + "svg", + "bmp", + "tiff", + "tif", + "ico", + "avif", +]); + +export interface DropPoint { + x: number; + y: number; +} + +export interface DropRect { + left: number; + top: number; + right: number; + bottom: number; +} + +export function isPointInsideRect( + point: DropPoint, + rect: DropRect, +): boolean { + return ( + point.x >= rect.left && + point.x <= rect.right && + point.y >= rect.top && + point.y <= rect.bottom + ); +} + +export interface ImageDropBlock { + before: number; + after: number; + top: number; + bottom: number; +} + +export interface ImageDropTarget { + position: number; + top: number; +} + +interface ImageDropAdapters { + copyImageToAssets: (sourcePath: string) => Promise; + resolveAssetUrl: (relativePath: string) => Promise; + insertImage: (src: string, position: number) => void; + onError?: (sourcePath: string, error: unknown) => void; +} + +interface ScaleFactorRef { + current: number; +} + +function normalizeScaleFactor(scaleFactor: number): number { + return Number.isFinite(scaleFactor) && scaleFactor > 0 ? scaleFactor : 1; +} + +export function createImageDragScaleFactorController( + scaleFactorRef: ScaleFactorRef, + loadScaleFactor: () => Promise, +) { + let generation = 0; + let pendingLookup: Promise | null = null; + + return { + enter(): Promise { + if (pendingLookup) return pendingLookup; + const lookupGeneration = generation; + let loadedScaleFactor: Promise; + try { + loadedScaleFactor = loadScaleFactor(); + } catch { + loadedScaleFactor = Promise.resolve(1); + } + pendingLookup = loadedScaleFactor + .then(normalizeScaleFactor) + .catch(() => 1) + .then((scaleFactor) => { + if (generation !== lookupGeneration) { + return null; + } + scaleFactorRef.current = scaleFactor; + return scaleFactor; + }); + return pendingLookup; + }, + current(): number { + return normalizeScaleFactor(scaleFactorRef.current); + }, + reset(): void { + generation += 1; + pendingLookup = null; + scaleFactorRef.current = 1; + }, + }; +} + +export function filterSupportedImagePaths(paths: string[]): string[] { + return paths.filter((path) => { + const filename = path.split(/[\\/]/).pop() ?? ""; + const extension = filename.match(/\.([^.]+)$/)?.[1]?.toLowerCase(); + return Boolean(extension && SUPPORTED_IMAGE_EXTENSIONS.has(extension)); + }); +} + +export function physicalToLogicalPoint( + position: DropPoint, + scaleFactor: number, +): DropPoint { + const safeScaleFactor = normalizeScaleFactor(scaleFactor); + return { + x: position.x / safeScaleFactor, + y: position.y / safeScaleFactor, + }; +} + +export function resolveImageDropPosition( + point: DropPoint, + editorRect: DropRect, + posAtCoords: (coords: { left: number; top: number }) => + | { pos: number } + | null, + fallbackPosition: number, + minimumPosition = 0, +): number | null { + if (!isPointInsideRect(point, editorRect)) return null; + const resolvedPosition = + posAtCoords({ left: point.x, top: point.y })?.pos ?? fallbackPosition; + return Math.max(minimumPosition, resolvedPosition); +} + +export function resolveBlockDropTarget( + point: DropPoint, + editorRect: DropRect, + blocks: ImageDropBlock[], + fallbackPosition: number, + minimumPosition = 0, +): ImageDropTarget | null { + if (!isPointInsideRect(point, editorRect)) return null; + + const candidates: ImageDropTarget[] = []; + blocks.forEach((block, index) => { + if (index === 0) { + candidates.push({ position: block.before, top: block.top }); + } + + const nextBlock = blocks[index + 1]; + if (nextBlock) { + candidates.push({ + position: nextBlock.before, + top: (block.bottom + nextBlock.top) / 2, + }); + } else { + candidates.push({ position: block.after, top: block.bottom }); + } + }); + + const validCandidates = candidates.filter( + (candidate) => candidate.position >= minimumPosition, + ); + const closestCandidate = validCandidates.reduce( + (closest, candidate) => { + if (!closest) return candidate; + return Math.abs(candidate.top - point.y) < + Math.abs(closest.top - point.y) + ? candidate + : closest; + }, + null, + ); + + return ( + closestCandidate ?? { + position: Math.max(minimumPosition, fallbackPosition), + top: point.y, + } + ); +} + +export function resolveEditorBlockDropTarget( + point: DropPoint, + editorRect: DropRect, + dropSurfaceRect: DropRect | null, + blocks: ImageDropBlock[], + fallbackPosition: number, + minimumPosition = 0, +): ImageDropTarget | null { + const surfaceRect = dropSurfaceRect ?? editorRect; + + return resolveBlockDropTarget( + point, + { + left: surfaceRect.left, + right: surfaceRect.right, + top: Math.min(surfaceRect.top, editorRect.top), + bottom: Math.max(surfaceRect.bottom, editorRect.bottom), + }, + blocks, + fallbackPosition, + minimumPosition, + ); +} + +export async function importDroppedImagePaths( + paths: string[], + insertionPosition: number, + adapters: ImageDropAdapters, +): Promise<{ imported: number; failed: number }> { + const imagePaths = filterSupportedImagePaths(paths); + let nextPosition = insertionPosition; + let imported = 0; + let failed = 0; + + for (const sourcePath of imagePaths) { + try { + const relativePath = await adapters.copyImageToAssets(sourcePath); + const assetUrl = await adapters.resolveAssetUrl(relativePath); + adapters.insertImage(assetUrl, nextPosition); + nextPosition += 1; + imported += 1; + } catch (error) { + failed += 1; + adapters.onError?.(sourcePath, error); + } + } + + return { imported, failed }; +} diff --git a/src/components/editor/notion/NotionMenus.test.tsx b/src/components/editor/notion/NotionMenus.test.tsx new file mode 100644 index 00000000..c0cb1109 --- /dev/null +++ b/src/components/editor/notion/NotionMenus.test.tsx @@ -0,0 +1,1925 @@ +import { Editor } from "@tiptap/core"; +import Image from "@tiptap/extension-image"; +import { TableKit } from "@tiptap/extension-table"; +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 { + BlockDragControls, + getBlockDragHandleReferenceRect, + SelectionMenu, + shouldShowSelectionMenu, +} from "./NotionMenus"; +import { + ScratchColor, + ScratchHighlight, + ScratchTextStyle, +} from "./markdownMarks"; +import { SCRATCH_TRAILING_NODE_OPTIONS } from "./editorBehavior"; + +(globalThis as unknown as Record).IS_REACT_ACT_ENVIRONMENT = + true; + +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 flushAnimationFrames(): Promise { + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + await new Promise((resolve) => requestAnimationFrame(() => resolve())); +} + +describe("block drag handle layout", () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + document.documentElement.style.removeProperty("zoom"); + document.body.replaceChildren(); + }); + + it("keeps hidden block controls out of tab order and exposes a button grip on hover", async () => { + const editor = new Editor({ + extensions: [StarterKit], + content: "

Keyboard movable block

", + }); + const controls = document.createElement("div"); + document.body.append(editor.view.dom, controls); + const root = createRoot(controls); + const block = editor.view.dom.querySelector("p"); + if (!block) throw new Error("Missing keyboard block fixture"); + block.getBoundingClientRect = () => createRect(100, 80, 600, 112); + editor.view.dom.getBoundingClientRect = () => createRect(0, 0, 800, 400); + + try { + await act(async () => { + root.render(); + }); + const hiddenAdd = document.querySelector( + '.notion-block-add[aria-label="Add block"]', + ); + const hiddenGrip = document.querySelector( + '.notion-block-grip[aria-label="Move block with Arrow Up or Arrow Down"]', + ); + expect(hiddenAdd?.tabIndex).toBe(-1); + expect(hiddenGrip?.tagName).toBe("BUTTON"); + expect(hiddenGrip?.tabIndex).toBe(-1); + + await act(async () => { + block.dispatchEvent( + new MouseEvent("mousemove", { + bubbles: true, + clientX: 120, + clientY: 96, + }), + ); + await flushAnimationFrames(); + }); + + expect(hiddenAdd?.tabIndex).toBe(0); + expect(hiddenGrip?.tabIndex).toBe(0); + } finally { + await act(async () => root.unmount()); + editor.destroy(); + } + }); + + it("moves the hovered block with Arrow Down while preserving the title", async () => { + const editor = new Editor({ + extensions: [StarterKit], + content: + "

Protected title

Alpha

Beta

Gamma

", + }); + const controls = document.createElement("div"); + document.body.append(editor.view.dom, controls); + const root = createRoot(controls); + const paragraphs = editor.view.dom.querySelectorAll(":scope > p"); + const beta = paragraphs[1]; + if (!beta) throw new Error("Missing keyboard grip target"); + + let betaTextPosition = -1; + editor.state.doc.descendants((node, pos) => { + if (node.type.name === "paragraph" && node.textContent === "Beta") { + betaTextPosition = pos + 1; + } + }); + if (betaTextPosition < 0) throw new Error("Missing Beta text position"); + + editor.view.dom.getBoundingClientRect = () => + createRect(100, 40, 700, 280); + beta.getBoundingClientRect = () => createRect(100, 120, 700, 152); + vi.spyOn(editor.view, "posAtCoords").mockReturnValue({ + pos: betaTextPosition, + inside: betaTextPosition - 1, + }); + + const topLevelTexts = () => + Array.from( + { length: editor.state.doc.childCount }, + (_, index) => editor.state.doc.child(index).textContent, + ); + + try { + await act(async () => { + root.render(); + }); + await act(async () => { + beta.dispatchEvent( + new MouseEvent("mousemove", { + bubbles: true, + clientX: 140, + clientY: 136, + }), + ); + await flushAnimationFrames(); + }); + + const grip = document.querySelector( + '.notion-block-grip[aria-label="Move block with Arrow Up or Arrow Down"]', + ); + if (!grip) throw new Error("Missing keyboard block grip"); + const keyEvent = new KeyboardEvent("keydown", { + key: "ArrowDown", + bubbles: true, + cancelable: true, + }); + + await act(async () => { + grip.dispatchEvent(keyEvent); + }); + + expect(keyEvent.defaultPrevented).toBe(true); + expect(topLevelTexts()).toEqual([ + "Protected title", + "Alpha", + "Gamma", + "Beta", + ]); + + expect(editor.commands.undo()).toBe(true); + expect(topLevelTexts()).toEqual([ + "Protected title", + "Alpha", + "Beta", + "Gamma", + ]); + } finally { + await act(async () => root.unmount()); + editor.destroy(); + } + }); + + it("keeps block controls unavailable on the protected leading H1", async () => { + const editor = new Editor({ + extensions: [StarterKit], + content: "

Protected title

Body

", + }); + const initialDocument = editor.getJSON(); + const controls = document.createElement("div"); + document.body.append(editor.view.dom, controls); + const root = createRoot(controls); + const title = editor.view.dom.querySelector("h1"); + if (!title) throw new Error("Missing protected title fixture"); + title.getBoundingClientRect = () => createRect(100, 40, 700, 80); + editor.view.dom.getBoundingClientRect = () => + createRect(100, 20, 700, 240); + vi.spyOn(editor.view, "posAtCoords").mockReturnValue({ + pos: 1, + inside: 0, + }); + + try { + await act(async () => { + root.render(); + }); + await act(async () => { + title.dispatchEvent( + new MouseEvent("mousemove", { + bubbles: true, + clientX: 140, + clientY: 60, + }), + ); + await flushAnimationFrames(); + }); + + const addButton = document.querySelector( + '.notion-block-add[aria-label="Add block"]', + ); + const grip = document.querySelector( + '.notion-block-grip[aria-label="Move block with Arrow Up or Arrow Down"]', + ); + expect(addButton?.tabIndex).toBe(-1); + expect(grip?.tabIndex).toBe(-1); + + await act(async () => { + addButton?.click(); + }); + expect(editor.getJSON()).toEqual(initialDocument); + } finally { + await act(async () => root.unmount()); + editor.destroy(); + } + }); + + it("moves the same block repeatedly with Arrow Down while preserving grip focus", async () => { + const editor = new Editor({ + extensions: [StarterKit], + content: + "

Protected title

Alpha

Beta

Gamma

Delta

", + }); + const controls = document.createElement("div"); + document.body.append(editor.view.dom, controls); + const root = createRoot(controls); + const paragraphs = editor.view.dom.querySelectorAll(":scope > p"); + const beta = paragraphs[1]; + if (!beta) throw new Error("Missing keyboard grip target"); + + let betaTextPosition = -1; + editor.state.doc.descendants((node, pos) => { + if (node.type.name === "paragraph" && node.textContent === "Beta") { + betaTextPosition = pos + 1; + } + }); + if (betaTextPosition < 0) throw new Error("Missing Beta text position"); + + editor.view.dom.getBoundingClientRect = () => + createRect(100, 40, 700, 320); + beta.getBoundingClientRect = () => createRect(100, 120, 700, 152); + vi.spyOn(editor.view, "posAtCoords").mockReturnValue({ + pos: betaTextPosition, + inside: betaTextPosition - 1, + }); + + const topLevelTexts = () => + Array.from( + { length: editor.state.doc.childCount }, + (_, index) => editor.state.doc.child(index).textContent, + ); + + let currentTime = Date.now(); + vi.spyOn(Date, "now").mockImplementation(() => currentTime); + + const advanceTime = () => { + currentTime += 600; + }; + + try { + await act(async () => { + root.render(); + }); + await act(async () => { + beta.dispatchEvent( + new MouseEvent("mousemove", { + bubbles: true, + clientX: 140, + clientY: 136, + }), + ); + await flushAnimationFrames(); + }); + + const grip = document.querySelector( + '.notion-block-grip[aria-label="Move block with Arrow Up or Arrow Down"]', + ); + if (!grip) throw new Error("Missing keyboard block grip"); + + // First ArrowDown: Beta moves below Gamma + await act(async () => { + const keyEvent = new KeyboardEvent("keydown", { + key: "ArrowDown", + bubbles: true, + cancelable: true, + }); + grip.dispatchEvent(keyEvent); + expect(keyEvent.defaultPrevented).toBe(true); + }); + advanceTime(); + + expect(topLevelTexts()).toEqual([ + "Protected title", + "Alpha", + "Gamma", + "Beta", + "Delta", + ]); + expect(document.activeElement).toBe(grip); + + // Second ArrowDown: Beta moves below Delta + await act(async () => { + const keyEvent = new KeyboardEvent("keydown", { + key: "ArrowDown", + bubbles: true, + cancelable: true, + }); + grip.dispatchEvent(keyEvent); + expect(keyEvent.defaultPrevented).toBe(true); + }); + advanceTime(); + + expect(topLevelTexts()).toEqual([ + "Protected title", + "Alpha", + "Gamma", + "Delta", + "Beta", + ]); + expect(document.activeElement).toBe(grip); + + // Verify H1 remains first + expect(editor.state.doc.child(0).type.name).toBe("heading"); + expect(editor.state.doc.child(0).attrs.level).toBe(1); + + // Test repeated ArrowUp sequence + await act(async () => { + const keyEvent = new KeyboardEvent("keydown", { + key: "ArrowUp", + bubbles: true, + cancelable: true, + }); + grip.dispatchEvent(keyEvent); + expect(keyEvent.defaultPrevented).toBe(true); + }); + advanceTime(); + + expect(topLevelTexts()).toEqual([ + "Protected title", + "Alpha", + "Gamma", + "Beta", + "Delta", + ]); + expect(document.activeElement).toBe(grip); + + await act(async () => { + const keyEvent = new KeyboardEvent("keydown", { + key: "ArrowUp", + bubbles: true, + cancelable: true, + }); + grip.dispatchEvent(keyEvent); + expect(keyEvent.defaultPrevented).toBe(true); + }); + advanceTime(); + + expect(topLevelTexts()).toEqual([ + "Protected title", + "Alpha", + "Beta", + "Gamma", + "Delta", + ]); + expect(document.activeElement).toBe(grip); + + // Verify undo behavior: each undo reverses one keyboard move + expect(editor.commands.undo()).toBe(true); + expect(topLevelTexts()).toEqual([ + "Protected title", + "Alpha", + "Gamma", + "Beta", + "Delta", + ]); + + // Second undo reverses the previous move + expect(editor.commands.undo()).toBe(true); + expect(topLevelTexts()).toEqual([ + "Protected title", + "Alpha", + "Gamma", + "Delta", + "Beta", + ]); + + // Third undo + expect(editor.commands.undo()).toBe(true); + expect(topLevelTexts()).toEqual([ + "Protected title", + "Alpha", + "Gamma", + "Beta", + "Delta", + ]); + + // Fourth undo returns to original + expect(editor.commands.undo()).toBe(true); + expect(topLevelTexts()).toEqual([ + "Protected title", + "Alpha", + "Beta", + "Gamma", + "Delta", + ]); + } finally { + await act(async () => root.unmount()); + editor.destroy(); + } + }); + + it("uses centered vector icons for both block controls", async () => { + const editor = new Editor({ + extensions: [StarterKit], + content: "

Aligned block controls

", + }); + const controls = document.createElement("div"); + document.body.append(editor.view.dom, controls); + const root = createRoot(controls); + + try { + await act(async () => { + root.render(); + }); + + const add = document.querySelector( + '.notion-block-add[aria-label="Add block"]', + ); + const grip = document.querySelector( + '.notion-block-grip[aria-label="Move block with Arrow Up or Arrow Down"]', + ); + + expect(add?.querySelector("svg.notion-block-control-icon")).not.toBeNull(); + expect( + grip?.querySelector("svg.notion-block-control-icon"), + ).not.toBeNull(); + expect(add?.textContent?.trim()).toBe(""); + expect(grip?.textContent?.trim()).toBe(""); + } finally { + await act(async () => root.unmount()); + editor.destroy(); + } + }); + + it("anchors the shared block grip to the top-left of an image", () => { + const editor = new Editor({ + extensions: [StarterKit, Image.configure({ inline: false })], + content: '', + }); + const image = editor.view.dom.querySelector("img"); + const imageNode = editor.state.doc.child(0); + if (!image) throw new Error("Missing image drag-handle fixture"); + image.getBoundingClientRect = () => createRect(120, 80, 620, 380); + + try { + const referenceRect = getBlockDragHandleReferenceRect(editor, { + node: imageNode, + pos: 0, + }); + + expect(referenceRect?.left).toBe(120); + expect(referenceRect?.top).toBe(80); + expect(referenceRect?.bottom).toBeLessThanOrEqual(108); + expect(referenceRect?.height).toBeLessThan(image.getBoundingClientRect().height); + } finally { + editor.destroy(); + } + }); + + it("shows the same block grip when an image is hovered", async () => { + const editor = new Editor({ + extensions: [StarterKit, Image.configure({ inline: false })], + content: '', + }); + const controls = document.createElement("div"); + document.body.append(editor.view.dom, controls); + const root = createRoot(controls); + const image = editor.view.dom.querySelector("img"); + if (!image) throw new Error("Missing hovered image fixture"); + editor.view.dom.getBoundingClientRect = () => createRect(0, 0, 800, 500); + image.getBoundingClientRect = () => createRect(120, 80, 620, 380); + + try { + await act(async () => { + root.render(); + }); + await act(async () => { + image.dispatchEvent( + new MouseEvent("mousemove", { + bubbles: true, + clientX: 140, + clientY: 94, + }), + ); + await flushAnimationFrames(); + }); + + const handle = document.querySelector( + ".notion-block-drag-handle.is-block-hovered", + ); + expect(handle).not.toBeNull(); + expect( + handle?.querySelector(".notion-block-grip svg.notion-block-control-icon"), + ).not.toBeNull(); + } finally { + await act(async () => root.unmount()); + editor.destroy(); + } + }); + + it.each([0.7, 1.2, 1.25, 1.5])( + "normalizes block coordinates around the positioned shell at CSS zoom %s", + async (zoom) => { + document.documentElement.style.zoom = String(zoom); + + const editor = new Editor({ + extensions: [StarterKit], + content: "

Zoomed text block

", + }); + const host = document.createElement("div"); + host.className = "notion-editor-shell"; + const controls = document.createElement("div"); + host.append(editor.view.dom, controls); + document.body.append(host); + const root = createRoot(controls); + const block = editor.view.dom.querySelector("p"); + if (!block) throw new Error("Missing zoomed paragraph block"); + + Object.defineProperties(editor.view.dom, { + offsetWidth: { configurable: true, value: 900 }, + offsetHeight: { configurable: true, value: 400 }, + }); + const shellLeft = 80 * zoom; + host.getBoundingClientRect = () => + createRect(shellLeft, 0, shellLeft + 900 * zoom, 400 * zoom); + editor.view.dom.getBoundingClientRect = () => + createRect(0, 0, 900 * zoom, 400 * zoom); + block.getBoundingClientRect = () => + createRect(200 * zoom, 80 * zoom, 600 * zoom, 112 * zoom); + + try { + await act(async () => { + root.render(); + }); + + await act(async () => { + block.dispatchEvent( + new MouseEvent("mousemove", { + bubbles: true, + clientX: 220 * zoom, + clientY: 96 * zoom, + }), + ); + await flushAnimationFrames(); + }); + + const handle = document.querySelector( + ".notion-block-drag-handle", + ); + expect(handle).not.toBeNull(); + expect(handle?.style.visibility).not.toBe("hidden"); + const expectedLeft = shellLeft + (200 * zoom - shellLeft) / zoom; + expect(Number.parseFloat(handle?.style.left ?? "NaN")).toBeCloseTo( + expectedLeft, + 3, + ); + } finally { + await act(async () => { + root.unmount(); + }); + editor.destroy(); + } + }, + ); + + it("shows the drag handle as soon as its block is hovered at 120 percent zoom", async () => { + document.documentElement.style.zoom = "1.2"; + + const editor = new Editor({ + extensions: [StarterKit], + content: "

Reachable drag handle

", + }); + const host = document.createElement("div"); + host.className = "notion-editor-shell"; + const controls = document.createElement("div"); + host.append(editor.view.dom, controls); + document.body.append(host); + const root = createRoot(controls); + const block = editor.view.dom.querySelector("p"); + if (!block) throw new Error("Missing drag-handle paragraph block"); + + editor.view.dom.getBoundingClientRect = () => + createRect(100, 0, 800, 300); + host.getBoundingClientRect = () => createRect(100, 0, 800, 300); + block.getBoundingClientRect = () => createRect(180, 80, 700, 112); + + try { + await act(async () => { + root.render(); + }); + + await act(async () => { + block.dispatchEvent( + new MouseEvent("mousemove", { + bubbles: true, + clientX: 220, + clientY: 96, + }), + ); + await flushAnimationFrames(); + }); + + const handle = document.querySelector( + ".notion-block-drag-handle", + ); + expect(handle).not.toBeNull(); + expect(handle?.style.visibility).not.toBe("hidden"); + expect(handle?.classList.contains("is-block-hovered")).toBe(true); + } finally { + await act(async () => { + root.unmount(); + }); + editor.destroy(); + } + }); + + it("remeasures the same block after its editor layout changes", async () => { + let resizeObserverCallback: ResizeObserverCallback | null = null; + + class ResizeObserverMock implements ResizeObserver { + constructor(callback: ResizeObserverCallback) { + resizeObserverCallback = callback; + } + + observe(): void {} + unobserve(): void {} + disconnect(): void {} + } + + vi.stubGlobal("ResizeObserver", ResizeObserverMock); + + const editor = new Editor({ + extensions: [StarterKit], + content: "

Resizable text block

", + }); + const host = document.createElement("div"); + const controls = document.createElement("div"); + host.append(editor.view.dom, controls); + document.body.append(host); + const root = createRoot(controls); + const block = editor.view.dom.querySelector("p"); + if (!block) throw new Error("Missing paragraph block"); + + let blockLeft = 400; + editor.view.dom.getBoundingClientRect = () => + createRect(0, 0, 900, 500); + block.getBoundingClientRect = () => + createRect(blockLeft, 80, blockLeft + 420, 112); + + try { + await act(async () => { + root.render(); + }); + + await act(async () => { + block.dispatchEvent( + new MouseEvent("mousemove", { + bubbles: true, + clientX: blockLeft + 12, + clientY: 92, + }), + ); + await flushAnimationFrames(); + }); + + const handle = document.querySelector( + ".notion-block-drag-handle", + ); + expect(handle).not.toBeNull(); + expect(handle?.style.visibility).not.toBe("hidden"); + const initialLeft = handle?.style.left; + + blockLeft = 40; + expect(resizeObserverCallback).not.toBeNull(); + await act(async () => { + resizeObserverCallback?.([], {} as ResizeObserver); + await flushAnimationFrames(); + }); + + expect(handle?.style.visibility).toBe("hidden"); + + await act(async () => { + block.dispatchEvent( + new MouseEvent("mousemove", { + bubbles: true, + clientX: blockLeft + 12, + clientY: 92, + }), + ); + await flushAnimationFrames(); + }); + + expect(handle?.style.visibility).not.toBe("hidden"); + expect(handle?.style.left).not.toBe(initialLeft); + } finally { + await act(async () => { + root.unmount(); + }); + editor.destroy(); + } + }); + + it.each([ + ["bullet list", "
  • List item

"], + ["numbered list", "
  1. List item

"], + ])( + "positions a %s handle before its marker while keeping the item row", + (_label, content) => { + const editor = new Editor({ extensions: [StarterKit], content }); + const list = editor.view.dom.querySelector("ul, ol"); + const listItem = editor.view.dom.querySelector("li"); + const node = editor.state.doc.nodeAt(1); + if (!list || !listItem || !node) { + throw new Error("Missing list fixture nodes"); + } + + list.getBoundingClientRect = () => createRect(100, 180, 700, 280); + listItem.getBoundingClientRect = () => createRect(122, 220, 700, 252); + + try { + expect( + getBlockDragHandleReferenceRect(editor, { node, pos: 1 }), + ).toMatchObject({ + left: 100, + top: 220, + right: 700, + bottom: 252, + width: 600, + height: 32, + }); + } finally { + editor.destroy(); + } + }, + ); + + it.each([ + ["paragraph", "

Wrapped paragraph

", "p", 0, 32], + ["heading 1", "

Wrapped heading

", "h1", 0, 44], + ["heading 2", "

Wrapped heading

", "h2", 0, 36], + ["heading 3", "

Wrapped heading

", "h3", 0, 32], + ["heading 4", "

Wrapped heading

", "h4", 0, 28], + [ + "bullet list", + "
  • Wrapped list item

", + "li", + 1, + 32, + ], + [ + "numbered list", + "
  1. Wrapped list item

", + "li", + 1, + 32, + ], + ])( + "anchors a multi-line %s handle to the first line, not the block center, without shifting it horizontally", + (_label, content, selector, position, firstLineHeight) => { + const editor = new Editor({ extensions: [StarterKit], content }); + const element = editor.view.dom.querySelector(selector); + const node = editor.state.doc.nodeAt(position); + if (!element || !node) { + throw new Error("Missing multi-line block fixture nodes"); + } + + element.style.lineHeight = `${firstLineHeight}px`; + element.getBoundingClientRect = () => createRect(100, 180, 700, 276); + const list = element.closest("ul, ol"); + if (list) { + list.getBoundingClientRect = () => createRect(100, 160, 700, 276); + } + + try { + const referenceRect = getBlockDragHandleReferenceRect(editor, { + node, + pos: position, + }); + if (!referenceRect) { + throw new Error("Missing multi-line handle reference rectangle"); + } + + expect(referenceRect).toMatchObject({ + left: 100, + top: 180, + right: 700, + bottom: 180 + firstLineHeight, + width: 600, + height: firstLineHeight, + }); + expect(referenceRect.top + referenceRect.height / 2).not.toBe(228); + } finally { + editor.destroy(); + } + }, + ); + + it.each([ + ["bullet list", "

Before

  • List item

"], + ["numbered list", "

Before

  1. List item

"], + ])( + "keeps a one-line %s draggable across its full row height", + async (_label, content) => { + const editor = new Editor({ extensions: [StarterKit], content }); + const topBlock = editor.view.dom.querySelector(":scope > p"); + const list = editor.view.dom.querySelector("ul, ol"); + const listItem = editor.view.dom.querySelector("li"); + const listParagraph = listItem?.querySelector("p"); + if (!topBlock || !list || !listItem || !listParagraph) { + throw new Error("Missing list hover fixture nodes"); + } + + let listTextPosition = -1; + editor.state.doc.descendants((node, pos) => { + if (node.type.name === "paragraph" && node.textContent === "List item") { + listTextPosition = pos + 1; + } + }); + if (listTextPosition < 0) throw new Error("Missing list text position"); + + editor.view.dom.getBoundingClientRect = () => + createRect(100, 80, 700, 300); + topBlock.getBoundingClientRect = () => createRect(100, 100, 700, 132); + list.getBoundingClientRect = () => createRect(100, 180, 700, 204); + listItem.getBoundingClientRect = () => createRect(122, 180, 700, 204); + listParagraph.getBoundingClientRect = () => + createRect(144, 180, 700, 204); + vi.spyOn(editor.view, "posAtCoords").mockReturnValue({ + pos: listTextPosition, + inside: listTextPosition - 1, + }); + + const host = document.createElement("div"); + const controls = document.createElement("div"); + host.append(editor.view.dom, controls); + document.body.append(host); + const root = createRoot(controls); + + try { + await act(async () => { + root.render(); + }); + + await act(async () => { + listParagraph.dispatchEvent( + new MouseEvent("mousemove", { + bubbles: true, + clientX: 174, + clientY: 185, + }), + ); + await flushAnimationFrames(); + }); + + const handle = document.querySelector( + ".notion-block-drag-handle", + ); + expect(handle?.style.visibility).not.toBe("hidden"); + } finally { + await act(async () => { + root.unmount(); + }); + editor.destroy(); + } + }, + ); + + it("adds one empty item with the same bullet style and line rhythm", async () => { + const editor = new Editor({ + extensions: [ + StarterKit.configure({ + trailingNode: SCRATCH_TRAILING_NODE_OPTIONS, + }), + ], + content: "
  • List item

", + }); + const host = document.createElement("div"); + const controls = document.createElement("div"); + host.append(editor.view.dom, controls); + document.body.append(host); + const root = createRoot(controls); + const list = editor.view.dom.querySelector("ul"); + const listItem = list?.querySelector("li"); + const listParagraph = listItem?.querySelector("p"); + if (!list || !listItem || !listParagraph) { + throw new Error("Missing bulleted-list add fixture nodes"); + } + + let listTextPosition = -1; + editor.state.doc.descendants((node, pos) => { + if (node.type.name === "paragraph" && node.textContent === "List item") { + listTextPosition = pos + 1; + } + }); + if (listTextPosition < 0) throw new Error("Missing list text position"); + + editor.view.dom.getBoundingClientRect = () => + createRect(100, 0, 700, 240); + list.getBoundingClientRect = () => createRect(100, 80, 700, 120); + listItem.getBoundingClientRect = () => createRect(122, 80, 700, 120); + listParagraph.getBoundingClientRect = () => + createRect(144, 80, 700, 120); + vi.spyOn(editor.view, "posAtCoords").mockReturnValue({ + pos: listTextPosition, + inside: listTextPosition - 1, + }); + + try { + await act(async () => { + root.render(); + }); + + await act(async () => { + listParagraph.dispatchEvent( + new MouseEvent("mousemove", { + bubbles: true, + clientX: 174, + clientY: 96, + }), + ); + await flushAnimationFrames(); + }); + + const addButton = document.querySelector( + '.notion-block-add[aria-label="Add block"]', + ); + expect(addButton).not.toBeNull(); + expect( + editor.getJSON().content?.map((node) => node.type), + ).toEqual(["bulletList"]); + + await act(async () => { + addButton?.click(); + }); + + const content = editor.getJSON().content ?? []; + expect(content.map((node) => node.type)).toEqual(["bulletList"]); + const bulletList = editor.state.doc.child(0); + expect(bulletList.childCount).toBe(2); + expect(bulletList.child(0).type.name).toBe("listItem"); + expect(bulletList.child(0).child(0).type.name).toBe("paragraph"); + expect(bulletList.child(0).textContent).toBe(""); + expect(bulletList.child(1).textContent).toBe("List item"); + expect(editor.state.selection.$from.parent.type.name).toBe("paragraph"); + expect(editor.state.selection.$from.parent.textContent).toBe(""); + const listItems = editor.view.dom.querySelectorAll("ul > li"); + expect(listItems).toHaveLength(2); + expect( + listItems[0]?.querySelector("p > br.ProseMirror-trailingBreak"), + ).not.toBeNull(); + } finally { + await act(async () => { + root.unmount(); + }); + editor.destroy(); + } + }); + + it("adds an empty paragraph before a normal text block", async () => { + const editor = new Editor({ + extensions: [StarterKit], + content: "

Existing body

", + }); + const host = document.createElement("div"); + const controls = document.createElement("div"); + host.append(editor.view.dom, controls); + document.body.append(host); + const root = createRoot(controls); + const paragraph = editor.view.dom.querySelector("p"); + if (!paragraph) throw new Error("Missing paragraph add fixture"); + paragraph.getBoundingClientRect = () => createRect(100, 80, 700, 120); + editor.view.dom.getBoundingClientRect = () => + createRect(100, 20, 700, 240); + vi.spyOn(editor.view, "posAtCoords").mockReturnValue({ pos: 1, inside: 0 }); + + try { + await act(async () => { + root.render(); + }); + await act(async () => { + paragraph.dispatchEvent( + new MouseEvent("mousemove", { + bubbles: true, + clientX: 140, + clientY: 100, + }), + ); + await flushAnimationFrames(); + }); + + const addButton = document.querySelector( + '.notion-block-add[aria-label="Add block"]', + ); + await act(async () => { + addButton?.click(); + }); + + expect( + Array.from( + { length: editor.state.doc.childCount }, + (_, index) => editor.state.doc.child(index).textContent, + ), + ).toEqual(["", "Existing body"]); + expect(editor.state.selection.$from.parent.type.name).toBe("paragraph"); + expect(editor.state.selection.$from.parent.textContent).toBe(""); + } finally { + await act(async () => root.unmount()); + editor.destroy(); + } + }); +}); + +describe("macOS image block pointer drag", () => { + afterEach(() => { + delete (window as unknown as Record).__TAURI_INTERNALS__; + document.documentElement.style.removeProperty("zoom"); + document.body.replaceChildren(); + }); + + it.each([0.7, 1.25, 1.5])( + "keeps the pointer drop indicator aligned at CSS zoom %s", + async (zoom) => { + document.documentElement.style.zoom = String(zoom); + (window as unknown as Record).__TAURI_INTERNALS__ = {}; + const editor = new Editor({ + extensions: [StarterKit, Image.configure({ inline: false })], + content: + '

Before

After

', + }); + const host = document.createElement("div"); + const controls = document.createElement("div"); + host.append(editor.view.dom, controls); + document.body.append(host); + const root = createRoot(controls); + + const [before, after] = editor.view.dom.querySelectorAll("p"); + const image = editor.view.dom.querySelector("img"); + if (!image) throw new Error("Missing zoomed image fixture"); + editor.view.dom.getBoundingClientRect = () => + createRect(100 * zoom, 0, 700 * zoom, 400 * zoom); + before.getBoundingClientRect = () => + createRect(100 * zoom, 20 * zoom, 700 * zoom, 60 * zoom); + image.getBoundingClientRect = () => + createRect(100 * zoom, 80 * zoom, 500 * zoom, 120 * zoom); + after.getBoundingClientRect = () => + createRect(100 * zoom, 140 * zoom, 700 * zoom, 180 * zoom); + + try { + await act(async () => { + root.render(); + }); + + await act(async () => { + image.dispatchEvent( + new PointerEvent("pointerdown", { + bubbles: true, + button: 0, + clientX: 300 * zoom, + clientY: 100 * zoom, + isPrimary: true, + pointerId: 1, + }), + ); + document.dispatchEvent( + new PointerEvent("pointermove", { + bubbles: true, + clientX: 300 * zoom, + clientY: 170 * zoom, + isPrimary: true, + pointerId: 1, + }), + ); + }); + + const indicator = document.querySelector( + ".notion-block-drop-indicator", + ); + expect(indicator).not.toBeNull(); + expect(indicator?.style.left).toBe("100px"); + expect(indicator?.style.top).toBe("180px"); + expect(indicator?.style.width).toBe("600px"); + } finally { + await act(async () => { + root.unmount(); + }); + editor.destroy(); + } + }, + ); + + it("leaves the document unchanged when pointer movement stays below the drag threshold", async () => { + (window as unknown as Record).__TAURI_INTERNALS__ = {}; + const editor = new Editor({ + extensions: [StarterKit, Image.configure({ inline: false })], + content: + '

Before

After

', + }); + const initialDocument = editor.getJSON(); + const host = document.createElement("div"); + const controls = document.createElement("div"); + host.append(editor.view.dom, controls); + document.body.append(host); + const root = createRoot(controls); + const image = editor.view.dom.querySelector("img"); + if (!image) throw new Error("Missing pointer click image fixture"); + + try { + await act(async () => { + root.render(); + }); + await act(async () => { + image.dispatchEvent( + new PointerEvent("pointerdown", { + bubbles: true, + button: 0, + clientX: 200, + clientY: 100, + isPrimary: true, + pointerId: 1, + }), + ); + document.dispatchEvent( + new PointerEvent("pointermove", { + bubbles: true, + clientX: 202, + clientY: 101, + isPrimary: true, + pointerId: 1, + }), + ); + document.dispatchEvent( + new PointerEvent("pointerup", { + bubbles: true, + clientX: 202, + clientY: 101, + isPrimary: true, + pointerId: 1, + }), + ); + }); + + expect(editor.getJSON()).toEqual(initialDocument); + expect(document.querySelector(".notion-block-drop-indicator")).toBeNull(); + } finally { + await act(async () => root.unmount()); + editor.destroy(); + } + }); + + it("moves an image directly without relying on an HTML drop event", async () => { + (window as unknown as Record).__TAURI_INTERNALS__ = {}; + const editor = new Editor({ + extensions: [StarterKit, Image.configure({ inline: false })], + content: + '

Before

After

', + }); + const host = document.createElement("div"); + const controls = document.createElement("div"); + host.append(editor.view.dom, controls); + document.body.append(host); + const root = createRoot(controls); + + const [before, after] = editor.view.dom.querySelectorAll("p"); + const image = editor.view.dom.querySelector("img"); + if (!image) throw new Error("Missing image element"); + editor.view.dom.getBoundingClientRect = () => createRect(100, 0, 700, 400); + before.getBoundingClientRect = () => createRect(100, 20, 700, 60); + image.getBoundingClientRect = () => createRect(100, 80, 500, 120); + after.getBoundingClientRect = () => createRect(100, 140, 700, 180); + + try { + await act(async () => { + root.render(); + }); + + await act(async () => { + image.dispatchEvent( + new PointerEvent("pointerdown", { + bubbles: true, + button: 0, + clientX: 300, + clientY: 100, + isPrimary: true, + pointerId: 1, + }), + ); + document.dispatchEvent( + new PointerEvent("pointermove", { + bubbles: true, + clientX: 300, + clientY: 170, + isPrimary: true, + pointerId: 1, + }), + ); + }); + + expect(document.querySelector(".notion-block-drop-indicator")).not.toBeNull(); + + await act(async () => { + document.dispatchEvent( + new PointerEvent("pointerup", { + bubbles: true, + clientX: 300, + clientY: 170, + isPrimary: true, + pointerId: 1, + }), + ); + }); + + expect(editor.getJSON().content?.map((node) => node.type)).toEqual([ + "paragraph", + "paragraph", + "image", + "paragraph", + ]); + expect(editor.getJSON().content?.[2]).toMatchObject({ + type: "image", + attrs: { src: "asset://localhost/assets/photo.png" }, + }); + expect(document.querySelector(".notion-block-drop-indicator")).toBeNull(); + } finally { + await act(async () => { + root.unmount(); + }); + editor.destroy(); + } + }); + + it("moves a bullet past an atomic table at 120 percent zoom and honors Escape", async () => { + const zoom = 1.2; + document.documentElement.style.zoom = String(zoom); + (window as unknown as Record).__TAURI_INTERNALS__ = {}; + const editor = new Editor({ + extensions: [StarterKit, TableKit], + content: + "
  • Move me

  • Keep me

" + + "

Cell

" + + "

After

", + }); + const initialDocument = editor.getJSON(); + const sourceList = editor.state.doc.child(0); + const tablePosition = sourceList.nodeSize; + const tableNode = editor.state.doc.child(1); + const paragraphPosition = tablePosition + tableNode.nodeSize; + const host = document.createElement("div"); + const controls = document.createElement("div"); + host.append(editor.view.dom, controls); + document.body.append(host); + const root = createRoot(controls); + const list = editor.view.nodeDOM(0); + const table = editor.view.nodeDOM(tablePosition); + const after = editor.view.nodeDOM(paragraphPosition); + const firstListParagraph = editor.view.dom.querySelector("li p"); + let listTextPosition = -1; + editor.state.doc.descendants((node, position) => { + if (node.type.name === "paragraph" && node.textContent === "Move me") { + listTextPosition = position + 1; + return false; + } + return true; + }); + if ( + !(list instanceof HTMLElement) || + !(table instanceof HTMLElement) || + !(after instanceof HTMLElement) || + !firstListParagraph || + listTextPosition < 0 + ) { + throw new Error("Missing 120 percent list/table fixture"); + } + editor.view.dom.getBoundingClientRect = () => + createRect(100 * zoom, 0, 700 * zoom, 440 * zoom); + host.getBoundingClientRect = () => + createRect(100 * zoom, 0, 700 * zoom, 440 * zoom); + list.getBoundingClientRect = () => + createRect(100 * zoom, 20 * zoom, 700 * zoom, 100 * zoom); + table.getBoundingClientRect = () => + createRect(100 * zoom, 120 * zoom, 700 * zoom, 260 * zoom); + after.getBoundingClientRect = () => + createRect(100 * zoom, 280 * zoom, 700 * zoom, 320 * zoom); + firstListParagraph.getBoundingClientRect = () => + createRect(144 * zoom, 20 * zoom, 700 * zoom, 56 * zoom); + vi.spyOn(editor.view, "posAtCoords").mockReturnValue({ + pos: listTextPosition, + inside: listTextPosition - 1, + }); + + const startDrag = async (pointerId: number) => { + firstListParagraph.dispatchEvent( + new MouseEvent("mousemove", { + bubbles: true, + clientX: 174 * zoom, + clientY: 38 * zoom, + }), + ); + await flushAnimationFrames(); + const grip = document.querySelector(".notion-block-grip"); + grip?.dispatchEvent( + new PointerEvent("pointerdown", { + bubbles: true, + button: 0, + clientX: 90 * zoom, + clientY: 38 * zoom, + isPrimary: true, + pointerId, + }), + ); + document.dispatchEvent( + new PointerEvent("pointermove", { + bubbles: true, + clientX: 300 * zoom, + clientY: 252 * zoom, + isPrimary: true, + pointerId, + }), + ); + }; + + try { + await act(async () => { + root.render(); + }); + await act(async () => startDrag(1)); + expect( + document.querySelector(".notion-block-drop-indicator") + ?.style.top, + ).toBe("280px"); + + await act(async () => { + document.dispatchEvent( + new KeyboardEvent("keydown", { bubbles: true, key: "Escape" }), + ); + document.dispatchEvent( + new PointerEvent("pointerup", { + bubbles: true, + isPrimary: true, + pointerId: 1, + }), + ); + }); + expect(document.querySelector(".notion-block-drop-indicator")).toBeNull(); + expect(editor.getJSON()).toEqual(initialDocument); + + await act(async () => startDrag(2)); + await act(async () => { + document.dispatchEvent( + new PointerEvent("pointerup", { + bubbles: true, + clientX: 300 * zoom, + clientY: 252 * zoom, + isPrimary: true, + pointerId: 2, + }), + ); + }); + expect(editor.getJSON().content?.map((node) => node.type)).toEqual([ + "bulletList", + "table", + "bulletList", + "paragraph", + ]); + expect(editor.state.doc.child(1).toJSON()).toEqual(tableNode.toJSON()); + expect(editor.state.doc.child(2).textContent).toBe("Move me"); + } finally { + await act(async () => root.unmount()); + editor.destroy(); + } + }); + + it.each([ + [ + "bullet list", + "ul", + "bulletList", + "

Before

  • First wrapped list line

  • Second item

After

", + ], + [ + "numbered list", + "ol", + "orderedList", + "

Before

  1. First wrapped list line

  2. Second item

After

", + ], + ])( + "reorders the hovered %s item through the macOS pointer fallback", + async (_label, selector, listType, content) => { + (window as unknown as Record).__TAURI_INTERNALS__ = {}; + const editor = new Editor({ extensions: [StarterKit], content }); + const host = document.createElement("div"); + const controls = document.createElement("div"); + host.append(editor.view.dom, controls); + document.body.append(host); + const root = createRoot(controls); + + const [before, after] = editor.view.dom.querySelectorAll(":scope > p"); + const list = editor.view.dom.querySelector(selector); + const listItems = list?.querySelectorAll("li"); + const firstListParagraph = listItems?.[0]?.querySelector("p"); + if ( + !before || + !after || + !list || + !listItems || + listItems.length !== 2 || + !firstListParagraph + ) { + throw new Error("Missing pointer list drag fixture nodes"); + } + + let listTextPosition = -1; + editor.state.doc.descendants((node, pos) => { + if ( + node.type.name === "paragraph" && + node.textContent === "First wrapped list line" + ) { + listTextPosition = pos + 1; + } + }); + if (listTextPosition < 0) throw new Error("Missing list text position"); + + editor.view.dom.getBoundingClientRect = () => + createRect(100, 0, 700, 400); + before.getBoundingClientRect = () => createRect(100, 20, 700, 60); + list.getBoundingClientRect = () => createRect(100, 80, 700, 180); + listItems[0].getBoundingClientRect = () => + createRect(122, 80, 700, 128); + listItems[1].getBoundingClientRect = () => + createRect(122, 132, 700, 180); + firstListParagraph.getBoundingClientRect = () => + createRect(144, 80, 700, 128); + after.getBoundingClientRect = () => createRect(100, 220, 700, 260); + vi.spyOn(editor.view, "posAtCoords").mockReturnValue({ + pos: listTextPosition, + inside: listTextPosition - 1, + }); + try { + await act(async () => { + root.render(); + }); + + await act(async () => { + firstListParagraph.dispatchEvent( + new MouseEvent("mousemove", { + bubbles: true, + clientX: 174, + clientY: 96, + }), + ); + await flushAnimationFrames(); + }); + + const grip = document.querySelector( + ".notion-block-grip", + ); + expect(grip).not.toBeNull(); + + await act(async () => { + grip?.dispatchEvent( + new PointerEvent("pointerdown", { + bubbles: true, + button: 0, + clientX: 90, + clientY: 96, + isPrimary: true, + pointerId: 1, + }), + ); + document.dispatchEvent( + new PointerEvent("pointermove", { + bubbles: true, + clientX: 300, + clientY: 176, + isPrimary: true, + pointerId: 1, + }), + ); + }); + + expect( + document.querySelector(".notion-block-drop-indicator"), + ).not.toBeNull(); + + await act(async () => { + document.dispatchEvent( + new PointerEvent("pointerup", { + bubbles: true, + clientX: 300, + clientY: 176, + isPrimary: true, + pointerId: 1, + }), + ); + }); + + expect(editor.getJSON().content?.map((node) => node.type)).toEqual([ + "paragraph", + listType, + "paragraph", + ]); + const movedList = editor.state.doc.child(1); + expect(movedList.child(0).textContent).toBe("Second item"); + expect(movedList.child(1).textContent).toBe( + "First wrapped list line", + ); + } finally { + await act(async () => { + root.unmount(); + }); + editor.destroy(); + } + }, + ); +}); + +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..1625c465 --- /dev/null +++ b/src/components/editor/notion/NotionMenus.tsx @@ -0,0 +1,1111 @@ +import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; +import DragHandle from "@tiptap/extension-drag-handle-react"; +import { NodeSelection, type EditorState } from "@tiptap/pm/state"; +import { useEditorState, type Editor } from "@tiptap/react"; +import { BubbleMenu } from "@tiptap/react/menus"; +import { + Fragment, + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; +import { createPortal } from "react-dom"; +import { + BoldIcon, + CheckIcon, + CheckSquareIcon, + ChevronDownIcon, + GripVerticalIcon, + Heading1Icon, + Heading2Icon, + Heading3Icon, + Heading4Icon, + InlineCodeIcon, + ItalicIcon, + LinkIcon, + ListIcon, + ListOrderedIcon, + PilcrowIcon, + PlusIcon, + QuoteIcon, + StrikethroughIcon, +} from "../../icons"; +import { + HIGHLIGHT_COLOR_OPTIONS, + TEXT_COLOR_OPTIONS, +} from "./markdownMarks"; +import { + getTableBlockHandleReferenceRect, + getTopLevelBlockDragTargetFromDom, + hasExceededBlockPointerDragThreshold, + isCurrentMovableBlockDragTarget, + isTableBlockHandleProximity, + moveBlockByKeyboard, + moveBlockAtPoint, + resolveBlockDropTarget, + type BlockDragTarget, +} from "./blockDrag"; +import { getInterfaceZoom } from "./interfaceGeometry"; + +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"]; + +const BLOCK_DRAG_HANDLE_POSITION_CONFIG = { + placement: "left", + strategy: "absolute", +} satisfies NonNullable< + React.ComponentProps["computePositionConfig"] +>; + +const BLOCK_DRAG_NESTED_OPTIONS: Exclude< + React.ComponentProps["nested"], + boolean | undefined +> = { + edgeDetection: { + edges: ["left", "top"], + threshold: 12, + strength: 200, + }, +}; + +const LIST_ITEM_NODE_TYPES = new Set(["listItem", "taskItem"]); + +function getFirstTextRect(element: HTMLElement): DOMRect | null { + const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); + let textNode = walker.nextNode(); + + while (textNode) { + const value = textNode.textContent ?? ""; + const firstCharacter = value.search(/\S/); + if (firstCharacter >= 0) { + try { + const range = document.createRange(); + range.setStart(textNode, firstCharacter); + range.setEnd(textNode, firstCharacter + 1); + const rect = range.getBoundingClientRect(); + if ( + Number.isFinite(rect.top) && + Number.isFinite(rect.bottom) && + rect.height > 0 + ) { + return rect; + } + } catch { + return null; + } + } + textNode = walker.nextNode(); + } + + return null; +} + +function getFirstLineVerticalBounds( + element: HTMLElement, + targetRect: DOMRect, +): { top: number; bottom: number } { + const firstTextRect = getFirstTextRect(element); + if (firstTextRect) { + return { + top: Math.max(targetRect.top, firstTextRect.top), + bottom: Math.min(targetRect.bottom, firstTextRect.bottom), + }; + } + + const lineElements = [ + element, + element.querySelector("p, h1, h2, h3, h4, h5, h6"), + ]; + + for (const lineElement of lineElements) { + if (!lineElement) continue; + const lineHeights = [ + lineElement.style.lineHeight, + window.getComputedStyle(lineElement).lineHeight, + ]; + for (const value of lineHeights) { + const lineHeight = Number.parseFloat(value); + if (Number.isFinite(lineHeight) && lineHeight > 0) { + return { + top: targetRect.top, + bottom: targetRect.top + Math.min(lineHeight, targetRect.height), + }; + } + } + } + + for (const lineElement of lineElements) { + if (!lineElement) continue; + const fontSize = Number.parseFloat( + window.getComputedStyle(lineElement).fontSize, + ); + if (Number.isFinite(fontSize) && fontSize > 0) { + return { + top: targetRect.top, + bottom: + targetRect.top + Math.min(fontSize * 1.2, targetRect.height), + }; + } + } + + return { top: targetRect.top, bottom: targetRect.bottom }; +} + +function createReferenceRect( + 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: () => ({}), + }; +} + +function viewportRectToPositioningRootRect( + rect: DOMRect, + positioningRootRect: DOMRect, +): DOMRect { + const zoom = getInterfaceZoom(); + if (zoom === 1) return rect; + + return createReferenceRect( + positioningRootRect.left + (rect.left - positioningRootRect.left) / zoom, + positioningRootRect.top + (rect.top - positioningRootRect.top) / zoom, + positioningRootRect.left + (rect.right - positioningRootRect.left) / zoom, + positioningRootRect.top + (rect.bottom - positioningRootRect.top) / zoom, + ); +} + +export function getBlockDragHandleReferenceRect( + editor: Editor, + target: BlockDragTarget, +): DOMRect | null { + const domNode = editor.view.nodeDOM(target.pos); + const element = + domNode instanceof HTMLElement ? domNode : domNode?.parentElement; + if (!element) return null; + + const targetRect = element.getBoundingClientRect(); + if (target.node.type.name === "image") { + return createReferenceRect( + targetRect.left, + targetRect.top, + targetRect.right, + targetRect.top + Math.min(28, targetRect.height), + ); + } + const usesFirstLine = + target.node.isTextblock || LIST_ITEM_NODE_TYPES.has(target.node.type.name); + const verticalBounds = usesFirstLine + ? getFirstLineVerticalBounds(element, targetRect) + : { top: targetRect.top, bottom: targetRect.bottom }; + if (!LIST_ITEM_NODE_TYPES.has(target.node.type.name)) { + return createReferenceRect( + targetRect.left, + verticalBounds.top, + targetRect.right, + verticalBounds.bottom, + ); + } + + const list = element.closest("ul, ol"); + if (!list) { + return createReferenceRect( + targetRect.left, + verticalBounds.top, + targetRect.right, + verticalBounds.bottom, + ); + } + + const listRect = list.getBoundingClientRect(); + return createReferenceRect( + listRect.left, + verticalBounds.top, + listRect.right, + verticalBounds.bottom, + ); +} + +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 + + + + +
+
+ ); +} + +export function BlockDragControls({ editor }: { editor: Editor }) { + const [nodePos, setNodePos] = useState(null); + const [tableBlockHandleProximate, setTableBlockHandleProximate] = + useState(false); + const [pointerDropIndicator, setPointerDropIndicator] = useState<{ + top: number; + left: number; + width: number; + } | null>(null); + const targetRef = useRef(null); + const dragSessionRef = useRef<{ + target: BlockDragTarget; + initialDoc: Editor["state"]["doc"]; + lastPoint: { left: number; top: number } | null; + cancelled: boolean; + } | null>(null); + const pointerDragSessionRef = useRef<{ + pointerId: number; + target: BlockDragTarget; + initialDoc: Editor["state"]["doc"]; + startPoint: { left: number; top: number }; + lastPoint: { left: number; top: number }; + origin: HTMLElement; + active: boolean; + } | null>(null); + const usePointerBlockDrag = + typeof window !== "undefined" && "__TAURI_INTERNALS__" in window; + const handleNodeChange = useCallback( + ({ node, pos }: Parameters["onNodeChange"]>>[0]) => { + const target = node ? { node, pos } : null; + const movableTarget = + target && isCurrentMovableBlockDragTarget(editor, target) + ? target + : null; + targetRef.current = movableTarget; + setNodePos(movableTarget?.pos ?? null); + setTableBlockHandleProximate( + Boolean(movableTarget && movableTarget.node.type.name !== "table"), + ); + }, + [editor], + ); + const getReferencedVirtualElement = useCallback(() => { + const target = targetRef.current; + if (!target) return null; + + return { + getBoundingClientRect: () => { + const blockRect = + getBlockDragHandleReferenceRect(editor, target) ?? + editor.view.dom.getBoundingClientRect(); + const rect = + target.node.type.name === "table" + ? (() => { + const tableReference = + getTableBlockHandleReferenceRect(blockRect); + return createReferenceRect( + tableReference.left, + tableReference.top, + tableReference.right, + tableReference.bottom, + ); + })() + : blockRect; + const positioningRoot = + editor.view.dom.closest(".notion-editor-shell") ?? + document.documentElement; + return viewportRectToPositioningRootRect( + rect, + positioningRoot.getBoundingClientRect(), + ); + }, + }; + }, [editor]); + + useEffect(() => { + const updateTableBlockHandleProximity = (event: PointerEvent) => { + const target = targetRef.current; + if (!target || target.node.type.name !== "table") return; + const nodeDom = editor.view.nodeDOM(target.pos); + const element = + nodeDom instanceof HTMLElement ? nodeDom : nodeDom?.parentElement; + const nextVisible = Boolean( + element && + isTableBlockHandleProximity(element.getBoundingClientRect(), { + left: event.clientX, + top: event.clientY, + }), + ); + setTableBlockHandleProximate((current) => + current === nextVisible ? current : nextVisible, + ); + }; + + document.addEventListener("pointermove", updateTableBlockHandleProximity, { + capture: true, + passive: true, + }); + return () => { + document.removeEventListener( + "pointermove", + updateTableBlockHandleProximity, + true, + ); + }; + }, [editor]); + + useEffect(() => { + let animationFrame: number | null = null; + + const invalidateHandlePosition = () => { + if (animationFrame !== null) return; + + animationFrame = requestAnimationFrame(() => { + animationFrame = null; + if (editor.isDestroyed) return; + + editor.view.dispatch( + editor.state.tr + .setMeta("hideDragHandle", true) + .setMeta("addToHistory", false), + ); + }); + }; + + const resizeObserver = + typeof ResizeObserver === "undefined" + ? null + : new ResizeObserver(invalidateHandlePosition); + const observedElements = [ + editor.view.dom, + editor.view.dom.parentElement, + editor.view.dom.closest("[data-editor-scroll]"), + ]; + + for (const element of new Set(observedElements)) { + if (element) resizeObserver?.observe(element); + } + window.addEventListener("resize", invalidateHandlePosition); + + return () => { + window.removeEventListener("resize", invalidateHandlePosition); + resizeObserver?.disconnect(); + if (animationFrame !== null) cancelAnimationFrame(animationFrame); + }; + }, [editor]); + + const resetPointerDrag = useCallback(() => { + const session = pointerDragSessionRef.current; + pointerDragSessionRef.current = null; + setPointerDropIndicator(null); + editor.view.dom.removeAttribute("data-block-pointer-dragging"); + + if (session?.origin.hasPointerCapture?.(session.pointerId)) { + session.origin.releasePointerCapture(session.pointerId); + } + }, [editor]); + + const beginPointerDrag = useCallback( + ( + event: PointerEvent, + target: BlockDragTarget, + origin: HTMLElement, + preventSelection: boolean, + ) => { + if ( + !usePointerBlockDrag || + event.button !== 0 || + !event.isPrimary || + !isCurrentMovableBlockDragTarget(editor, target) + ) { + return; + } + + if (preventSelection) { + event.preventDefault(); + event.stopPropagation(); + } + + const point = { left: event.clientX, top: event.clientY }; + pointerDragSessionRef.current = { + pointerId: event.pointerId, + target, + initialDoc: editor.state.doc, + startPoint: point, + lastPoint: point, + origin, + active: false, + }; + try { + origin.setPointerCapture(event.pointerId); + } catch { + // Document-level listeners still complete the gesture if capture fails. + } + }, + [editor, usePointerBlockDrag], + ); + + useEffect(() => { + if (!usePointerBlockDrag) return; + + const editorDom = editor.view.dom; + editorDom.classList.add("notion-pointer-block-drag-enabled"); + + const beginImagePointerDrag = (event: PointerEvent) => { + const eventTarget = event.target; + if (!(eventTarget instanceof Element)) return; + const image = eventTarget.closest("img"); + if (!(image instanceof HTMLElement) || !editorDom.contains(image)) return; + + const target = getTopLevelBlockDragTargetFromDom(editor, image); + if (!target || target.node.type.name !== "image") return; + beginPointerDrag(event, target, image, false); + }; + const preventNativeImageDrag = (event: DragEvent) => { + const eventTarget = event.target; + if ( + eventTarget instanceof Element && + eventTarget.closest("img") && + editorDom.contains(eventTarget) + ) { + event.preventDefault(); + } + }; + const updatePointerDrag = (event: PointerEvent) => { + const session = pointerDragSessionRef.current; + if (!session || event.pointerId !== session.pointerId) return; + + session.lastPoint = { left: event.clientX, top: event.clientY }; + if ( + !session.active && + hasExceededBlockPointerDragThreshold( + session.startPoint, + session.lastPoint, + ) + ) { + session.active = true; + editorDom.setAttribute("data-block-pointer-dragging", "true"); + } + if (!session.active) return; + + event.preventDefault(); + const dropTarget = resolveBlockDropTarget( + editor, + session.target, + session.lastPoint, + ); + setPointerDropIndicator( + dropTarget + ? { + top: dropTarget.indicatorTop, + left: dropTarget.indicatorLeft, + width: dropTarget.indicatorWidth, + } + : null, + ); + }; + const finishPointerDrag = (event: PointerEvent) => { + const session = pointerDragSessionRef.current; + if (!session || event.pointerId !== session.pointerId) return; + + if (session.active) { + event.preventDefault(); + event.stopPropagation(); + if (editor.state.doc.eq(session.initialDoc)) { + moveBlockAtPoint(editor, session.target, session.lastPoint); + } + } + resetPointerDrag(); + }; + const cancelPointerDrag = (event: PointerEvent) => { + const session = pointerDragSessionRef.current; + if (!session || event.pointerId !== session.pointerId) return; + resetPointerDrag(); + }; + const cancelPointerDragWithKeyboard = (event: KeyboardEvent) => { + if (event.key === "Escape" && pointerDragSessionRef.current) { + resetPointerDrag(); + } + }; + + editorDom.addEventListener("pointerdown", beginImagePointerDrag); + editorDom.addEventListener("dragstart", preventNativeImageDrag, true); + document.addEventListener("pointermove", updatePointerDrag, { + capture: true, + passive: false, + }); + document.addEventListener("pointerup", finishPointerDrag, true); + document.addEventListener("pointercancel", cancelPointerDrag, true); + document.addEventListener("keydown", cancelPointerDragWithKeyboard, true); + return () => { + editorDom.classList.remove("notion-pointer-block-drag-enabled"); + editorDom.removeEventListener("pointerdown", beginImagePointerDrag); + editorDom.removeEventListener("dragstart", preventNativeImageDrag, true); + document.removeEventListener("pointermove", updatePointerDrag, true); + document.removeEventListener("pointerup", finishPointerDrag, true); + document.removeEventListener("pointercancel", cancelPointerDrag, true); + document.removeEventListener( + "keydown", + cancelPointerDragWithKeyboard, + true, + ); + resetPointerDrag(); + }; + }, [beginPointerDrag, editor, resetPointerDrag, usePointerBlockDrag]); + + useEffect(() => { + const rememberDragPoint = (event: DragEvent) => { + const session = dragSessionRef.current; + if (!session) return; + session.lastPoint = { left: event.clientX, top: event.clientY }; + }; + const cancelFallback = (event: KeyboardEvent) => { + if (event.key === "Escape" && dragSessionRef.current) { + dragSessionRef.current.cancelled = true; + } + }; + + document.addEventListener("dragover", rememberDragPoint, true); + document.addEventListener("keydown", cancelFallback, true); + return () => { + document.removeEventListener("dragover", rememberDragPoint, true); + document.removeEventListener("keydown", cancelFallback, true); + }; + }, []); + + const handleDragStart = useCallback(() => { + const target = targetRef.current; + if (!target || !isCurrentMovableBlockDragTarget(editor, target)) { + dragSessionRef.current = null; + return; + } + + dragSessionRef.current = { + target, + initialDoc: editor.state.doc, + lastPoint: null, + cancelled: false, + }; + }, [editor]); + + const handleDragEnd = useCallback(() => { + const session = dragSessionRef.current; + dragSessionRef.current = null; + if ( + session?.lastPoint && + !session.cancelled && + editor.state.doc.eq(session.initialDoc) + ) { + moveBlockAtPoint(editor, session.target, session.lastPoint); + } + }, [editor]); + + const addBlock = () => { + const target = targetRef.current; + if ( + nodePos === null || + !target || + !isCurrentMovableBlockDragTarget(editor, target) + ) { + return; + } + + const isListItem = LIST_ITEM_NODE_TYPES.has(target.node.type.name); + const content = isListItem + ? { + type: target.node.type.name, + attrs: target.node.type.name === "taskItem" ? { checked: false } : {}, + content: [{ type: "paragraph" }], + } + : { type: "paragraph" }; + const selectionPosition = target.pos + (isListItem ? 2 : 1); + const inserted = editor + .chain() + .focus() + .insertContentAt(target.pos, content) + .setTextSelection(selectionPosition) + .run(); + + if (!inserted) editor.chain().focus().createParagraphNear().run(); + }; + + const isTableTarget = targetRef.current?.node.type.name === "table"; + const isHandleProximate = + nodePos !== null && (!isTableTarget || tableBlockHandleProximate); + + return ( + <> + + + + + {pointerDropIndicator && + createPortal( + - {/* Footer with git status, commit, and settings */} -
- - {/* Folder name dialog */} - -
- - {/* Drag overlay — floating label while dragging */} - - {dragLabel && ( -
- - {dragLabel} - {dragCount > 1 && ( - - +{dragCount - 1} - - )} -
- )} -
+ {/* Drag overlay — floating label while dragging */} + + {dragLabel && ( +
+ + {dragLabel} + {dragCount > 1 && ( + + +{dragCount - 1} + + )} +
+ )} +
); } diff --git a/src/components/layout/SidebarControls.test.tsx b/src/components/layout/SidebarControls.test.tsx new file mode 100644 index 00000000..b17b951f --- /dev/null +++ b/src/components/layout/SidebarControls.test.tsx @@ -0,0 +1,82 @@ +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { TooltipProvider } from "../ui"; +import { + NoteSortMenu, +} from "./SidebarControls"; + +afterEach(() => { + document.body.replaceChildren(); +}); + +describe("NoteSortMenu", () => { + it("offers newest and oldest ordering and reports the selected option", () => { + const onChange = vi.fn(); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + + act(() => { + root.render( + + + , + ); + }); + + const trigger = container.querySelector( + 'button[aria-label="Sort notes: Newest first"]', + ); + expect(trigger).not.toBeNull(); + expect(trigger?.tabIndex).toBe(0); + + act(() => { + trigger?.dispatchEvent( + new PointerEvent("pointerdown", { + bubbles: true, + button: 0, + pointerType: "mouse", + }), + ); + }); + + const options = Array.from( + document.body.querySelectorAll('[role="menuitemradio"]'), + ); + expect(options.map((option) => option.textContent?.trim())).toEqual([ + "Newest first", + "Oldest first", + ]); + expect(options[0]?.getAttribute("aria-checked")).toBe("true"); + + act(() => { + options[1]?.click(); + }); + + expect(onChange).toHaveBeenCalledOnce(); + expect(onChange).toHaveBeenCalledWith("oldest"); + + act(() => root.unmount()); + }); + + it("does not prevent Radix focus restoration on the sort trigger", () => { + const onChange = vi.fn(); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + + act(() => { + root.render( + + + , + ); + }); + + const source = container.innerHTML; + expect(source).not.toContain("onCloseAutoFocus"); + + act(() => root.unmount()); + }); +}); diff --git a/src/components/layout/SidebarControls.tsx b/src/components/layout/SidebarControls.tsx new file mode 100644 index 00000000..27ad856d --- /dev/null +++ b/src/components/layout/SidebarControls.tsx @@ -0,0 +1,73 @@ +import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; +import type { NoteSortOrder } from "../../types/note"; +import { + ArrowDownIcon, + ArrowUpIcon, + CheckIcon, +} from "../icons"; +import { IconButton } from "../ui"; + +interface NoteSortMenuProps { + sortOrder: NoteSortOrder; + onChange: (sortOrder: NoteSortOrder) => void; +} + +const radioItemClass = + "relative flex cursor-pointer items-center gap-2 px-3 py-1.5 pr-8 text-sm text-text outline-none hover:bg-bg-muted focus:bg-bg-muted data-[state=checked]:font-medium"; + +export function NoteSortMenu({ + sortOrder, + onChange, +}: NoteSortMenuProps) { + const newestFirst = sortOrder === "newest"; + + return ( + + + + {newestFirst ? ( + + ) : ( + + )} + + + + + + Sort notes + + { + if (value === "newest" || value === "oldest") onChange(value); + }} + > + + + Newest first + + + + + + + Oldest first + + + + + + + + + ); +} diff --git a/src/components/layout/SidebarFolderSection.test.tsx b/src/components/layout/SidebarFolderSection.test.tsx new file mode 100644 index 00000000..97d67e3f --- /dev/null +++ b/src/components/layout/SidebarFolderSection.test.tsx @@ -0,0 +1,87 @@ +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + SidebarFolderSection, + loadFolderSectionCollapsed, + saveFolderSectionCollapsed, +} from "./SidebarFolderSection"; + +afterEach(() => { + document.body.replaceChildren(); +}); + +describe("folder section persistence", () => { + it("loads only an explicitly collapsed section and saves the next state", () => { + const values = new Map(); + const storage = { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + }; + + expect(loadFolderSectionCollapsed(storage)).toBe(false); + values.set("scratch:foldersSectionCollapsed", "true"); + expect(loadFolderSectionCollapsed(storage)).toBe(true); + + saveFolderSectionCollapsed(false, storage); + expect(values.get("scratch:foldersSectionCollapsed")).toBe("false"); + }); +}); + +describe("SidebarFolderSection", () => { + it("uses one disclosure control to hide and reveal the complete folder group", () => { + const onCollapsedChange = vi.fn(); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + + act(() => { + root.render( + +
Folder tree
+
, + ); + }); + + const collapseButton = container.querySelector( + 'button[aria-label="Collapse Folders"]', + ); + expect(collapseButton?.getAttribute("aria-expanded")).toBe("true"); + const contentId = collapseButton?.getAttribute("aria-controls"); + expect(contentId).toBeTruthy(); + expect(container.querySelector(`#${contentId}`)).not.toBeNull(); + expect(container.textContent).toContain("Folders"); + expect(container.querySelector('[data-testid="folder-group"]')).not.toBeNull(); + + act(() => collapseButton?.click()); + expect(onCollapsedChange).toHaveBeenCalledWith(true); + + act(() => { + root.render( + +
Folder tree
+
, + ); + }); + + const expandButton = container.querySelector( + 'button[aria-label="Expand Folders"]', + ); + expect(expandButton?.getAttribute("aria-expanded")).toBe("false"); + expect(expandButton?.getAttribute("aria-controls")).toBe(contentId); + const collapsedContent = container.querySelector(`#${contentId}`); + expect(collapsedContent).not.toBeNull(); + expect(collapsedContent?.hidden).toBe(true); + + act(() => expandButton?.click()); + expect(onCollapsedChange).toHaveBeenLastCalledWith(false); + + act(() => root.unmount()); + }); +}); diff --git a/src/components/layout/SidebarFolderSection.tsx b/src/components/layout/SidebarFolderSection.tsx new file mode 100644 index 00000000..02214401 --- /dev/null +++ b/src/components/layout/SidebarFolderSection.tsx @@ -0,0 +1,74 @@ +import { useId, type ReactNode } from "react"; +import { ChevronRightIcon } from "../icons"; + +const STORAGE_KEY = "scratch:foldersSectionCollapsed"; + +type SidebarStorage = Pick; + +function defaultStorage(): SidebarStorage | undefined { + try { + return globalThis.localStorage; + } catch { + return undefined; + } +} + +export function loadFolderSectionCollapsed( + storage: SidebarStorage | undefined = defaultStorage(), +): boolean { + try { + return storage?.getItem(STORAGE_KEY) === "true"; + } catch { + return false; + } +} + +export function saveFolderSectionCollapsed( + collapsed: boolean, + storage: SidebarStorage | undefined = defaultStorage(), +): void { + try { + storage?.setItem(STORAGE_KEY, String(collapsed)); + } catch { + // Keep the disclosure usable when storage is unavailable. + } +} + +interface SidebarFolderSectionProps { + collapsed: boolean; + onCollapsedChange: (collapsed: boolean) => void; + children: ReactNode; +} + +export function SidebarFolderSection({ + collapsed, + onCollapsedChange, + children, +}: SidebarFolderSectionProps) { + const contentId = useId(); + const expanded = !collapsed; + + return ( +
+ + +
+ ); +} diff --git a/src/components/layout/WorkspaceMenu.test.tsx b/src/components/layout/WorkspaceMenu.test.tsx new file mode 100644 index 00000000..5b09d7b5 --- /dev/null +++ b/src/components/layout/WorkspaceMenu.test.tsx @@ -0,0 +1,289 @@ +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { WorkspaceInfo } from "../../services/notes"; +import { WorkspaceMenu } from "./WorkspaceMenu"; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }) + .IS_REACT_ACT_ENVIRONMENT = true; + +afterEach(() => { + document.body.replaceChildren(); +}); + +const workspaces: WorkspaceInfo[] = [ + { + path: "/notes/main", + name: "main", + isDefault: true, + isCurrent: true, + isOpen: true, + }, + { + path: "/notes/client", + name: "client", + isDefault: false, + isCurrent: false, + isOpen: true, + }, + { + path: "/notes/archive", + name: "archive", + isDefault: false, + isCurrent: false, + isOpen: false, + }, +]; + +describe("WorkspaceMenu", () => { + it("shows the current folder and switches space inside the same window", () => { + const onSwitchWorkspace = vi.fn(); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + + act(() => { + root.render( + , + ); + }); + + const trigger = container.querySelector( + 'button[aria-label="Switch workspace"]', + ); + expect(trigger?.textContent).toContain("main"); + expect(trigger?.textContent).not.toContain("Spaces"); + expect(trigger?.getAttribute("aria-expanded")).toBe("false"); + expect(container.querySelector('[role="menu"]')).toBeNull(); + + act(() => trigger?.click()); + + expect(trigger?.getAttribute("aria-expanded")).toBe("true"); + expect(container.querySelector('[role="menu"]')).not.toBeNull(); + const current = container.querySelector( + 'button[role="menuitemradio"][data-workspace-path="/notes/main"]', + ); + expect(current?.getAttribute("aria-checked")).toBe("true"); + + const client = container.querySelector( + 'button[role="menuitemradio"][data-workspace-path="/notes/client"]', + ); + act(() => client?.click()); + expect(onSwitchWorkspace).toHaveBeenCalledWith("/notes/client"); + + act(() => root.unmount()); + }); + + it("opens a per-folder actions dropdown before removing a remembered folder", () => { + const onAddWorkspace = vi.fn(); + const onRemoveWorkspace = vi.fn(); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + + act(() => { + root.render( + , + ); + }); + + expect( + container.querySelector('button[aria-label="Open Folder in New Window"]'), + ).toBeNull(); + expect( + container.querySelector('button[aria-label="Add Folder"]'), + ).toBeNull(); + + const trigger = container.querySelector( + 'button[aria-label="Switch workspace"]', + ); + act(() => trigger?.click()); + + const addFolder = container.querySelector( + 'button[role="menuitem"][aria-label="Add Folder"]', + ); + const archiveActions = container.querySelector( + 'button[aria-label="Actions for archive"]', + ); + expect(addFolder).not.toBeNull(); + expect(archiveActions).not.toBeNull(); + expect(archiveActions?.getAttribute("aria-haspopup")).toBe("menu"); + expect(archiveActions?.getAttribute("aria-expanded")).toBe("false"); + expect( + container.querySelector( + 'button[role="menuitem"][aria-label="Remove archive from List"]', + ), + ).toBeNull(); + expect( + container.querySelector( + 'button[role="menuitem"][aria-label="Open Folder in New Window"]', + ), + ).toBeNull(); + + act(() => archiveActions?.click()); + expect(onRemoveWorkspace).not.toHaveBeenCalled(); + expect(archiveActions?.getAttribute("aria-expanded")).toBe("true"); + expect( + container.querySelector('[role="menu"][aria-label="Actions for archive"]'), + ).not.toBeNull(); + + const removeArchive = container.querySelector( + 'button[role="menuitem"][aria-label="Remove archive from List"]', + ); + act(() => removeArchive?.click()); + expect(onRemoveWorkspace).toHaveBeenCalledWith("/notes/archive"); + expect( + container.querySelector('[role="menu"][aria-label="Actions for archive"]'), + ).toBeNull(); + expect(container.querySelector('[role="menu"][aria-label="Workspaces"]')).not.toBeNull(); + + act(() => addFolder?.click()); + expect(onAddWorkspace).toHaveBeenCalledOnce(); + expect(container.querySelector('[role="menu"]')).toBeNull(); + + act(() => root.unmount()); + }); + + it("closes the nested folder actions menu before the workspace switcher", () => { + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + + act(() => { + root.render( + , + ); + }); + + const workspaceTrigger = container.querySelector( + 'button[aria-label="Switch workspace"]', + ); + act(() => workspaceTrigger?.click()); + const archiveActions = container.querySelector( + 'button[aria-label="Actions for archive"]', + ); + act(() => archiveActions?.click()); + + act(() => { + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + }); + + expect( + container.querySelector('[role="menu"][aria-label="Actions for archive"]'), + ).toBeNull(); + expect(container.querySelector('[role="menu"][aria-label="Workspaces"]')).not.toBeNull(); + expect(document.activeElement).toBe(archiveActions); + + act(() => { + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + }); + expect(container.querySelector('[role="menu"][aria-label="Workspaces"]')).toBeNull(); + expect(document.activeElement).toBe(workspaceTrigger); + + act(() => root.unmount()); + }); + + it("closes the workspace menu without removing the compact trigger", () => { + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + + act(() => { + root.render( + , + ); + }); + + const trigger = container.querySelector( + 'button[aria-label="Switch workspace"]', + ); + act(() => trigger?.click()); + expect(container.querySelector('[role="menu"]')).not.toBeNull(); + + act(() => trigger?.click()); + expect(container.querySelector('[role="menu"]')).toBeNull(); + expect(trigger).not.toBeNull(); + + act(() => root.unmount()); + }); + + it("keeps the active folder label while the remembered list is empty", () => { + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + + act(() => { + root.render( + , + ); + }); + + const trigger = container.querySelector( + 'button[aria-label="Switch workspace"]', + ); + expect(trigger?.textContent).toContain("Professional"); + act(() => trigger?.click()); + expect(container.textContent).toContain("No folders in list"); + + act(() => root.unmount()); + }); + + it("does not replace a forgotten active folder label with another remembered folder", () => { + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + + act(() => { + root.render( + ({ + ...workspace, + isCurrent: false, + }))} + currentWorkspacePath="/notes/Professional" + onSwitchWorkspace={vi.fn()} + onAddWorkspace={vi.fn()} + onRemoveWorkspace={vi.fn()} + />, + ); + }); + + const trigger = container.querySelector( + 'button[aria-label="Switch workspace"]', + ); + expect(trigger?.textContent).toContain("Professional"); + expect(trigger?.textContent).not.toContain("main"); + + act(() => root.unmount()); + }); +}); diff --git a/src/components/layout/WorkspaceMenu.tsx b/src/components/layout/WorkspaceMenu.tsx new file mode 100644 index 00000000..5f3218a4 --- /dev/null +++ b/src/components/layout/WorkspaceMenu.tsx @@ -0,0 +1,224 @@ +import { useEffect, useRef, useState } from "react"; +import type { WorkspaceInfo } from "../../services/notes"; +import { + CheckIcon, + ChevronDownIcon, + ExternalLinkIcon, + FolderPlusIcon, + MinusIcon, + MoreVerticalIcon, +} from "../icons"; + +interface WorkspaceMenuProps { + workspaces: WorkspaceInfo[]; + currentWorkspacePath: string | null; + onSwitchWorkspace: (path: string) => void; + onAddWorkspace: () => void; + onRemoveWorkspace: (path: string) => void; +} + +export function WorkspaceMenu({ + workspaces, + currentWorkspacePath, + onSwitchWorkspace, + onAddWorkspace, + onRemoveWorkspace, +}: WorkspaceMenuProps) { + const [open, setOpen] = useState(false); + const [actionMenuPath, setActionMenuPath] = useState(null); + const rootRef = useRef(null); + const triggerRef = useRef(null); + const actionMenuRef = useRef(null); + const actionTriggerRefs = useRef(new Map()); + const currentWorkspace = workspaces.find((workspace) => workspace.isCurrent); + const currentPathSegments = + currentWorkspacePath?.split(/[\\/]/).filter(Boolean) ?? []; + const currentWorkspaceName = currentWorkspace?.name ?? + currentPathSegments[currentPathSegments.length - 1] ?? + "Select Space"; + + useEffect(() => { + if (!open) return; + + const handlePointerDown = (event: PointerEvent) => { + const target = event.target as Node; + if (!rootRef.current?.contains(target)) { + setActionMenuPath(null); + setOpen(false); + return; + } + + if ( + actionMenuPath && + !actionMenuRef.current?.contains(target) && + !actionTriggerRefs.current.get(actionMenuPath)?.contains(target) + ) { + setActionMenuPath(null); + } + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + event.preventDefault(); + if (actionMenuPath) { + const actionTrigger = actionTriggerRefs.current.get(actionMenuPath); + setActionMenuPath(null); + actionTrigger?.focus(); + return; + } + setOpen(false); + triggerRef.current?.focus(); + }; + + document.addEventListener("pointerdown", handlePointerDown); + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("pointerdown", handlePointerDown); + document.removeEventListener("keydown", handleKeyDown); + }; + }, [actionMenuPath, open]); + + return ( +
+
+ +
+ + {open && ( +
+ {workspaces.length === 0 ? ( +
+ No folders in list +
+ ) : ( + workspaces.map((workspace, index) => ( +
+ + + {actionMenuPath === workspace.path && ( + + )} +
+ )) + )} +
+ +
+ )} +
+ ); +} diff --git a/src/components/notes/FolderTreeView.test.tsx b/src/components/notes/FolderTreeView.test.tsx new file mode 100644 index 00000000..768907bf --- /dev/null +++ b/src/components/notes/FolderTreeView.test.tsx @@ -0,0 +1,286 @@ +import { DndContext } from "@dnd-kit/core"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + buildFolderTree, + getVisibleItemsForFolderSection, +} from "../../lib/folderTree"; +import type { FolderNode, NoteMetadata } from "../../types/note"; +import { FolderItemComponent, FolderTreeView } from "./FolderTreeView"; + +// Hoisted mocks for FolderTreeView integration test +const { mockUseNotes, mockListFolders } = vi.hoisted(() => ({ + mockUseNotes: vi.fn(), + mockListFolders: vi.fn(), +})); + +vi.mock("../../context/NotesContext", () => ({ + useNotes: mockUseNotes, +})); + +vi.mock("../../services/notes", () => ({ + listFolders: mockListFolders, + getNotesFolder: vi.fn().mockResolvedValue("/tmp/notes"), + getSettings: vi.fn(), + updateSettings: vi.fn(), +})); + +afterEach(() => { + document.body.replaceChildren(); +}); + +function note(id: string, modified: number): NoteMetadata { + return { id, title: id, preview: "", modified }; +} + +describe("FolderItemComponent", () => { + it("does not expose a per-folder descendant-collapse action", () => { + const child: FolderNode = { + name: "docs", + path: "Point/docs", + children: [], + notes: [], + }; + const parent: FolderNode = { + name: "Point", + path: "Point", + children: [child], + notes: [], + }; + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + + act(() => { + root.render( + + undefined)} + onUnpinNote={vi.fn(async () => undefined)} + onDuplicateNote={vi.fn(async () => undefined)} + onDeleteNote={vi.fn()} + onMoveNoteToParent={vi.fn()} + onMoveFolderToParent={vi.fn()} + /> + , + ); + }); + + expect( + container.querySelector( + 'button[aria-label="Collapse all subfolders in Point"]', + ), + ).toBeNull(); + expect(container.querySelectorAll("button")).toHaveLength(0); + + act(() => root.unmount()); + container.remove(); + }); +}); + +describe("getVisibleItemsForFolderSection keyboard navigation", () => { + it("excludes folder items from keyboard navigation while section is collapsed and includes them when expanded", () => { + const tree = buildFolderTree( + [note("pinned", 30), note("docs/inside", 20), note("recent", 10)], + new Set(["pinned"]), + ["docs"], + ); + + // When collapsed: folder items are excluded from keyboard navigation + const collapsed = getVisibleItemsForFolderSection( + tree, + new Set(["pinned"]), + new Set(), + true, + ); + expect(collapsed).toEqual([ + { type: "note", id: "pinned" }, + { type: "note", id: "recent" }, + ]); + expect(collapsed.some((item) => item.type === "folder")).toBe(false); + + // When expanded: folder items are included in keyboard navigation + const expanded = getVisibleItemsForFolderSection( + tree, + new Set(["pinned"]), + new Set(), + false, + ); + expect( + expanded.some((item) => item.type === "folder" && item.path === "docs"), + ).toBe(true); + expect( + expanded.some( + (item) => item.type === "note" && item.id === "docs/inside", + ), + ).toBe(true); + }); +}); + +describe("FolderTreeView folder section collapse and reopening", () => { + beforeEach(() => { + if (typeof localStorage !== "undefined" && localStorage !== null) { + localStorage.clear(); + } + vi.clearAllMocks(); + mockListFolders.mockResolvedValue(["docs"]); + mockUseNotes.mockReturnValue({ + notes: [note("note1", 1000), note("docs/note2", 2000)], + selectedNoteId: null, + currentNote: null, + notesFolder: null, + isLoading: false, + error: null, + searchQuery: "", + searchResults: [], + isSearching: false, + hasExternalChanges: false, + reloadVersion: 0, + selectNote: vi.fn(), + createNote: vi.fn(), + consumePendingNewNote: vi.fn(), + saveNote: vi.fn(), + deleteNote: vi.fn(), + duplicateNote: vi.fn(), + refreshNotes: vi.fn(), + reloadCurrentNote: vi.fn(), + setNotesFolder: vi.fn(), + syncNotesFolder: vi.fn(), + search: vi.fn(), + clearSearch: vi.fn(), + pinNote: vi.fn(), + unpinNote: vi.fn(), + createNoteInFolder: vi.fn(), + createFolder: vi.fn(), + deleteFolder: vi.fn(), + renameFolder: vi.fn(), + moveNote: vi.fn(), + moveFolder: vi.fn(), + }); + }); + + it("reopens a collapsed Folders section when an expand-folder event fires", async () => { + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + , + ); + }); + + // Wait for async listFolders to resolve and tree to build + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + + // Initially expanded (default localStorage state is false/expanded) + expect( + container.querySelector('button[aria-label="Collapse Folders"]'), + ).not.toBeNull(); + expect( + container.querySelector('button[aria-label="Expand Folders"]'), + ).toBeNull(); + + // Collapse the section + act(() => { + ( + container.querySelector( + 'button[aria-label="Collapse Folders"]', + ) as HTMLButtonElement | null + )?.click(); + }); + + // Section is now collapsed + expect( + container.querySelector('button[aria-label="Expand Folders"]'), + ).not.toBeNull(); + expect( + container.querySelector('button[aria-label="Collapse Folders"]'), + ).toBeNull(); + + // Dispatch expand-folder event to reopen + act(() => { + window.dispatchEvent(new CustomEvent("expand-folder", { detail: "docs" })); + }); + + // Section is now expanded again + expect( + container.querySelector('button[aria-label="Collapse Folders"]'), + ).not.toBeNull(); + expect( + container.querySelector('button[aria-label="Expand Folders"]'), + ).toBeNull(); + + act(() => root.unmount()); + container.remove(); + }); + + it("does not block native Enter activation on the Folders disclosure", async () => { + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + , + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + const disclosure = container.querySelector( + 'button[aria-label="Collapse Folders"]', + ); + expect(disclosure).not.toBeNull(); + disclosure?.focus(); + + const enter = new KeyboardEvent("keydown", { + key: "Enter", + bubbles: true, + cancelable: true, + }); + act(() => { + disclosure?.dispatchEvent(enter); + }); + + expect(enter.defaultPrevented).toBe(false); + + act(() => root.unmount()); + container.remove(); + }); +}); diff --git a/src/components/notes/FolderTreeView.tsx b/src/components/notes/FolderTreeView.tsx index 3f7998a3..a7f00c98 100644 --- a/src/components/notes/FolderTreeView.tsx +++ b/src/components/notes/FolderTreeView.tsx @@ -5,7 +5,7 @@ import { useNotes } from "../../context/NotesContext"; import { buildFolderTree, countNotesInFolder, - getVisibleItems, + getVisibleItemsForFolderSection, type TreeItem, } from "../../lib/folderTree"; import { FolderNameDialog } from "./FolderNameDialog"; @@ -35,7 +35,17 @@ import { ArrowUpIcon, } from "../icons"; import * as notesService from "../../services/notes"; -import type { FolderNode, NoteMetadata, Settings } from "../../types/note"; +import { + SidebarFolderSection, + loadFolderSectionCollapsed, + saveFolderSectionCollapsed, +} from "../layout/SidebarFolderSection"; +import type { + FolderNode, + NoteMetadata, + NoteSortOrder, + Settings, +} from "../../types/note"; const STORAGE_KEY = "scratch:collapsedFolders"; @@ -269,7 +279,7 @@ interface FolderItemProps { onMoveFolderToParent: (path: string, targetParent: string) => void; } -const FolderItemComponent = memo(function FolderItem({ +export const FolderItemComponent = memo(function FolderItem({ folder, depth, collapsedFolders, @@ -474,6 +484,7 @@ const FolderItemComponent = memo(function FolderItem({ }); interface FolderTreeViewProps { + sortOrder: NoteSortOrder; pinnedIds: Set; settings: Settings | null; multiSelectedNoteIds: Set; @@ -483,6 +494,7 @@ interface FolderTreeViewProps { } export function FolderTreeView({ + sortOrder, pinnedIds, settings: _settings, multiSelectedNoteIds, @@ -508,6 +520,9 @@ export function FolderTreeView({ const [collapsedFolders, setCollapsedFolders] = useState>(loadCollapsedFolders); + const [foldersSectionCollapsed, setFoldersSectionCollapsed] = useState( + loadFolderSectionCollapsed, + ); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [folderToDelete, setFolderToDelete] = useState(null); const [renameDialogOpen, setRenameDialogOpen] = useState(false); @@ -534,9 +549,13 @@ export function FolderTreeView({ saveCollapsedFolders(collapsedFolders); }, [collapsedFolders]); + useEffect(() => { + saveFolderSectionCollapsed(foldersSectionCollapsed); + }, [foldersSectionCollapsed]); + const tree = useMemo( - () => buildFolderTree(notes, pinnedIds, knownFolders), - [notes, pinnedIds, knownFolders], + () => buildFolderTree(notes, pinnedIds, knownFolders, sortOrder), + [notes, pinnedIds, knownFolders, sortOrder], ); const handleToggleCollapse = useCallback((path: string) => { @@ -554,6 +573,7 @@ export function FolderTreeView({ // Expand a folder and all its ancestors const expandFolder = useCallback((folderPath: string) => { if (!folderPath) return; + setFoldersSectionCollapsed(false); setCollapsedFolders((prev) => { const next = new Set(prev); // Expand this folder and every ancestor @@ -663,8 +683,14 @@ export function FolderTreeView({ // Flat list of visible items for keyboard navigation const visibleItems = useMemo( - () => getVisibleItems(tree, pinnedIds, collapsedFolders), - [tree, pinnedIds, collapsedFolders], + () => + getVisibleItemsForFolderSection( + tree, + pinnedIds, + collapsedFolders, + foldersSectionCollapsed, + ), + [tree, pinnedIds, collapsedFolders, foldersSectionCollapsed], ); // Visible note IDs in order (for Shift+Click range computation) @@ -774,12 +800,13 @@ export function FolderTreeView({ } if (visibleItems.length === 0) return; - e.preventDefault(); - e.stopPropagation(); - const currentIndex = visibleItems.findIndex( (item) => itemKey(item) === focusedItemKey, ); + if (e.key === "Enter" && currentIndex < 0) return; + + e.preventDefault(); + e.stopPropagation(); if (e.key === "ArrowDown" || e.key === "ArrowUp") { let newIndex: number; @@ -796,7 +823,6 @@ export function FolderTreeView({ selectNote(item.id); } } else if (e.key === "Enter") { - if (currentIndex < 0) return; const item = visibleItems[currentIndex]; if (item.type === "folder") { handleToggleCollapse(item.path); @@ -875,30 +901,37 @@ export function FolderTreeView({ ))} {/* Folders */} - {tree.folders.map((folder) => ( - - ))} + {tree.folders.length > 0 && ( + + {tree.folders.map((folder) => ( + + ))} + + )} {/* Unpinned root notes */} {unpinnedRootNotes.map((note) => ( diff --git a/src/components/notes/NoteList.tsx b/src/components/notes/NoteList.tsx index 3e761f39..b7dcdc78 100644 --- a/src/components/notes/NoteList.tsx +++ b/src/components/notes/NoteList.tsx @@ -21,7 +21,13 @@ import { CopyIcon, TrashIcon, } from "../icons"; -import type { Settings } from "../../types/note"; +import type { NoteSortOrder, Settings } from "../../types/note"; +import { sortNotesByModified } from "../../lib/folderTree"; +import { + SETTINGS_CHANGED_DOM_EVENT, + shouldApplySettingsChange, + type SettingsChangedEvent, +} from "../../lib/settingsScope"; const menuItemClass = "px-3 py-1.5 text-sm text-text cursor-pointer outline-none hover:bg-bg-muted focus:bg-bg-muted flex items-center gap-2 rounded-sm"; @@ -227,6 +233,7 @@ const NoteItemWithMenu = memo(function NoteItemWithMenu({ }); interface NoteListProps { + sortOrder: NoteSortOrder; multiSelectedNoteIds: Set; setMultiSelectedNoteIds: React.Dispatch>>; lastClickedNoteId: string | null; @@ -234,6 +241,7 @@ interface NoteListProps { } export function NoteList({ + sortOrder, multiSelectedNoteIds, setMultiSelectedNoteIds, lastClickedNoteId, @@ -250,6 +258,7 @@ export function NoteList({ isLoading, searchQuery, searchResults, + notesFolder, } = useNotes(); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); @@ -291,9 +300,29 @@ export function NoteList({ }, []); const refreshSettings = useCallback(() => { - notesService.getSettings().then(setSettings); + notesService + .getSettings() + .then(setSettings) + .catch((error) => { + console.error("Failed to refresh settings:", error); + }); }, []); + useEffect(() => { + const handleSettingsChanged = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (detail && shouldApplySettingsChange(detail, notesFolder)) { + refreshSettings(); + } + }; + window.addEventListener(SETTINGS_CHANGED_DOM_EVENT, handleSettingsChanged); + return () => + window.removeEventListener( + SETTINGS_CHANGED_DOM_EVENT, + handleSettingsChanged, + ); + }, [notesFolder, refreshSettings]); + // Memoize display items to prevent recalculation on every render const displayItems = useMemo(() => { if (searchQuery.trim()) { @@ -307,6 +336,11 @@ export function NoteList({ return notes; }, [searchQuery, searchResults, notes]); + const sortedDisplayItems = useMemo( + () => sortNotesByModified(displayItems, sortOrder), + [displayItems, sortOrder], + ); + // Listen for focus request from editor (when Escape is pressed) useEffect(() => { const handleFocusNoteList = () => { @@ -341,7 +375,7 @@ export function NoteList({ ); } - if (isSearching && displayItems.length === 0) { + if (isSearching && sortedDisplayItems.length === 0) { return (
No results found @@ -349,7 +383,7 @@ export function NoteList({ ); } - if (displayItems.length === 0) { + if (sortedDisplayItems.length === 0) { return (
No notes yet @@ -362,6 +396,7 @@ export function NoteList({ return ( <> - {displayItems.map((item) => ( + {sortedDisplayItems.map((item) => ( (null); const [title, setTitle] = useState(""); const [modified, setModified] = useState(0); + const [revision, setRevision] = useState(""); + const recreationConflictRef = useRef< + { content: string; revision: string } | null + >(null); const [hasExternalChanges, setHasExternalChanges] = useState(false); + const [hasSaveConflict, setHasSaveConflict] = useState(false); const [reloadVersion, setReloadVersion] = useState(0); const [focusMode, setFocusMode] = useState(false); - const recentlySavedRef = useRef(false); + const revisionRef = useRef(""); + const saveQueueRef = useRef(createSerializedTaskQueue()); + const persistenceControllerRef = useRef( + null, + ); + const closeInProgressRef = useRef(false); + + const registerPersistenceController = useCallback( + (controller: EditorPersistenceController) => { + persistenceControllerRef.current = controller; + return () => { + if (persistenceControllerRef.current === controller) { + persistenceControllerRef.current = null; + } + }; + }, + [], + ); + + useEffect(() => { + let disposed = false; + let unlisten: (() => void) | undefined; + const appWindow = getCurrentWindow(); + + appWindow.onCloseRequested((event) => { + if (closeInProgressRef.current) return; + event.preventDefault(); + closeInProgressRef.current = true; + + void runSafeWindowClose({ + flushDraft: () => + persistenceControllerRef.current?.flush() ?? Promise.resolve(), + persistRecovery: async () => { + const draft = persistenceControllerRef.current?.getDraft(); + if (!draft?.dirty) return undefined; + return notesService.persistRecoverySnapshot({ + noteId: filePath, + sourcePath: filePath, + content: draft.content, + reason: "standalone-window-close", + }); + }, + closeWindow: closeWindowAfterSave, + }) + .then((result) => { + if (result.recoveredTo && !disposed) { + toast.warning(`Draft recovered to ${result.recoveredTo}`); + } + }) + .catch((error) => { + closeInProgressRef.current = false; + console.error("Failed to close standalone window safely:", error); + if (!disposed) { + toast.error( + `Window kept open because the draft could not be saved: ${error}`, + ); + } + }); + }) + .then((removeListener) => { + if (disposed) removeListener(); + else unlisten = removeListener; + }) + .catch((error) => { + console.error("Failed to register close handler:", error); + }); + + return () => { + disposed = true; + unlisten?.(); + }; + }, [filePath]); // Load file on mount useEffect(() => { - filesService - .readFileDirect(filePath) - .then((result) => { - setContent(result.content); + let cancelled = false; + + const loadFile = async () => { + try { + const loaded = await loadStandalonePreviewState( + filePath, + filesService.readFileDirect, + draftCheckpointService.getDraftCheckpoint, + () => cancelled, + ); + if (!loaded) return; + const { file: result, checkpoint } = loaded; + const recovered = + checkpoint && checkpoint.markdown !== result.content + ? checkpoint.markdown + : result.content; + setContent(recovered); setTitle(result.title); setModified(result.modified); - }) - .catch((error) => { + revisionRef.current = result.revision; + setRevision(result.revision); + if (checkpoint && checkpoint.markdown === result.content) { + await draftCheckpointService + .clearDraftCheckpoint(checkpoint.key.noteId) + .catch(() => undefined); + } else if (checkpoint) { + setHasExternalChanges(true); + setHasSaveConflict(true); + toast.warning("Recovered an unsaved draft from an interrupted session"); + } + } catch (error) { + if (cancelled) return; console.error("Failed to load file:", error); toast.error(`Failed to load file: ${error}`); - }); + } + }; + + void loadFile(); + return () => { + cancelled = true; + }; }, [filePath]); // Listen for window focus to detect external changes useEffect(() => { const handleFocus = async () => { - if (recentlySavedRef.current) { - recentlySavedRef.current = false; - return; - } try { const result = await filesService.readFileDirect(filePath); - if (result.modified !== modified && content !== null) { + if (result.revision !== revisionRef.current && content !== null) { setHasExternalChanges(true); + if (persistenceControllerRef.current?.getDraft().dirty) { + setHasSaveConflict(true); + } } } catch { // File may have been deleted @@ -55,28 +186,48 @@ export function PreviewApp({ filePath }: PreviewAppProps) { }, [filePath, modified, content]); const save = useCallback( - async (newContent: string) => { - try { - const result = await filesService.saveFileDirect(filePath, newContent); - recentlySavedRef.current = true; - setModified(result.modified); - setTitle(result.title); - setHasExternalChanges(false); - } catch (error) { - console.error("Failed to save file:", error); - toast.error(`Failed to save: ${error}`); - } - }, + (newContent: string) => + saveQueueRef.current(async () => { + try { + if (!revisionRef.current) { + throw new Error("Missing base revision for standalone note"); + } + const result = await filesService.saveFileDirect( + filePath, + newContent, + revisionRef.current, + ); + if (result.status === "conflict") { + setHasExternalChanges(true); + setHasSaveConflict(true); + throw new Error("Save conflict: local draft was preserved"); + } + revisionRef.current = result.file.revision; + setRevision(result.file.revision); + setModified(result.file.modified); + setTitle(result.file.title); + setHasExternalChanges(false); + setHasSaveConflict(false); + } catch (error) { + console.error("Failed to save file:", error); + toast.error(`Failed to save: ${error}`); + throw error; + } + }), [filePath], ); const reload = useCallback(async () => { try { + await flushDirtyDraftBeforeReload(persistenceControllerRef.current); const result = await filesService.readFileDirect(filePath); setContent(result.content); setTitle(result.title); setModified(result.modified); + revisionRef.current = result.revision; + setRevision(result.revision); setHasExternalChanges(false); + setHasSaveConflict(false); setReloadVersion((v) => v + 1); } catch (error) { console.error("Failed to reload file:", error); @@ -84,10 +235,100 @@ export function PreviewApp({ filePath }: PreviewAppProps) { } }, [filePath]); + const resolveConflict = useCallback( + async (strategy: ConflictResolutionStrategy) => { + const draft = persistenceControllerRef.current?.getDraft(); + if (!draft) throw new Error("No open draft to resolve"); + + let remote: filesService.FileContent | null = null; + try { + remote = await filesService.readFileDirect(filePath); + } catch { + const concurrent = recreationConflictRef.current; + remote = concurrent + ? { + path: filePath, + content: concurrent.content, + title, + modified, + revision: concurrent.revision, + } + : null; + } + + const applyFile = (file: filesService.FileContent) => { + setContent(file.content); + setTitle(file.title); + setModified(file.modified); + revisionRef.current = file.revision; + setRevision(file.revision); + setHasExternalChanges(false); + setHasSaveConflict(false); + recreationConflictRef.current = null; + setReloadVersion((version) => version + 1); + }; + + await runConflictResolution( + strategy, + { draft, remote }, + { + persistRecovery: () => + notesService.persistRecoverySnapshot({ + noteId: filePath, + sourcePath: filePath, + content: draft.content, + reason: `standalone-conflict-${strategy}`, + }), + overwriteRemote: async (localDraft, current) => { + const result = await filesService.saveFileDirect( + filePath, + localDraft.content, + current.revision, + ); + if (result.status === "conflict") { + throw new Error("The disk version changed again; conflict preserved"); + } + applyFile(result.file); + }, + recreateDeleted: async (localDraft) => { + try { + const recreated = await recreateDeletedStandaloneDraft( + filePath, + localDraft.content, + filesService.recreateFileDirect, + ); + applyFile(recreated); + } catch (error) { + if (error instanceof StandaloneRecreationConflictError) { + recreationConflictRef.current = error.current; + setHasExternalChanges(true); + setHasSaveConflict(true); + } + throw error; + } + }, + acceptRemote: async (current) => { + if (!current) { + throw new Error( + "Source file was deleted; local changes are safe in recovery storage", + ); + } + applyFile(current); + }, + }, + ); + await draftCheckpointService.clearDraftCheckpoint(filePath); + }, + [filePath, modified, title], + ); + // Listen for preview-file-change events useEffect(() => { const unlisten = listen("preview-file-change", () => { setHasExternalChanges(true); + if (persistenceControllerRef.current?.getDraft().dirty) { + setHasSaveConflict(true); + } }); return () => { unlisten.then((fn) => fn()); @@ -164,7 +405,7 @@ export function PreviewApp({ filePath }: PreviewAppProps) { try { await filesService.importFileToFolder(filePath); // Backend emits select-note + focuses main window; close this preview - await getCurrentWindow().close(); + await requestWindowClose(); } catch (error) { console.error("Failed to save to folder:", error); toast.error(`Failed to save to folder: ${error}`); @@ -179,10 +420,14 @@ export function PreviewApp({ filePath }: PreviewAppProps) { title, filePath, modified, + revision, hasExternalChanges, + hasSaveConflict, reloadVersion, save, reload, + resolveConflict, + registerPersistenceController, }; return ( diff --git a/src/components/settings/EditorSettingsSection.test.tsx b/src/components/settings/EditorSettingsSection.test.tsx new file mode 100644 index 00000000..02d29eab --- /dev/null +++ b/src/components/settings/EditorSettingsSection.test.tsx @@ -0,0 +1,200 @@ +import { act, useState } from "react"; +import { createRoot } from "react-dom/client"; +import { describe, expect, it, vi } from "vitest"; +import { + EditorToolbarVisibilityControl, + EditorWidthResizeControl, + TitleBarNoteInfoControls, +} from "./EditorSettingsSection"; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }) + .IS_REACT_ACT_ENVIRONMENT = true; + +describe("EditorWidthResizeControl", () => { + it("exposes the current state and lets the user disable mouse resizing", () => { + const onChange = vi.fn(); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + + act(() => { + root.render( + , + ); + }); + + const group = container.querySelector( + '[role="group"][aria-label="Resize editor with mouse"]', + ); + const [offButton, onButton] = Array.from( + container.querySelectorAll("button"), + ); + + expect(group).not.toBeNull(); + expect(offButton.textContent).toBe("Off"); + expect(offButton.getAttribute("aria-pressed")).toBe("false"); + expect(onButton.textContent).toBe("On"); + expect(onButton.getAttribute("aria-pressed")).toBe("true"); + + act(() => offButton.click()); + expect(onChange).toHaveBeenCalledWith(false); + + act(() => root.unmount()); + container.remove(); + }); +}); + +describe("EditorToolbarVisibilityControl", () => { + it("exposes the hidden default and lets the user show the toolbar", () => { + const onChange = vi.fn(); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + + act(() => { + root.render( + , + ); + }); + + const group = container.querySelector( + '[role="group"][aria-label="Show formatting toolbar"]', + ); + const [offButton, onButton] = Array.from( + container.querySelectorAll("button"), + ); + + expect(group).not.toBeNull(); + expect(offButton.textContent).toBe("Off"); + expect(offButton.getAttribute("aria-pressed")).toBe("true"); + expect(onButton.textContent).toBe("On"); + expect(onButton.getAttribute("aria-pressed")).toBe("false"); + + act(() => onButton.click()); + expect(onChange).toHaveBeenCalledWith(true); + + act(() => root.unmount()); + container.remove(); + }); +}); + +describe("TitleBarNoteInfoControls", () => { + it("offers one exclusive title-bar information menu", () => { + const onModifiedDateChange = vi.fn(); + const onFilenameChange = vi.fn(); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + + act(() => { + root.render( + , + ); + }); + + const select = container.querySelector( + 'select[aria-label="Title bar information"]', + ); + expect(select).not.toBeNull(); + expect(container.querySelectorAll("select")).toHaveLength(1); + expect(Array.from(select?.options ?? []).map((option) => option.text)).toEqual( + ["Modification Date", "Filename", "None"], + ); + expect(select?.value).toBe("modifiedDate"); + expect(container.textContent).not.toContain("On"); + expect(container.textContent).not.toContain("Off"); + + act(() => { + if (!select) return; + select.value = "filename"; + select.dispatchEvent(new Event("change", { bubbles: true })); + }); + expect(onFilenameChange).toHaveBeenCalledWith(true); + expect(onModifiedDateChange).toHaveBeenCalledWith(false); + + act(() => root.unmount()); + container.remove(); + }); + + it("maps None to the single active persisted setting", () => { + const onModifiedDateChange = vi.fn(); + const onFilenameChange = vi.fn(); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + + act(() => { + root.render( + , + ); + }); + + const select = container.querySelector( + 'select[aria-label="Title bar information"]', + ); + expect(select?.value).toBe("filename"); + + act(() => { + if (!select) return; + select.value = "none"; + select.dispatchEvent(new Event("change", { bubbles: true })); + }); + + expect(onFilenameChange).toHaveBeenCalledWith(false); + expect(onModifiedDateChange).toHaveBeenCalledWith(false); + + act(() => root.unmount()); + container.remove(); + }); + + it("can switch from modified date to filename and then to None", () => { + function StatefulTitleBarControl() { + const [modifiedDateVisible, setModifiedDateVisible] = useState(true); + const [filenameVisible, setFilenameVisible] = useState(false); + return ( + + ); + } + + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + + act(() => root.render()); + const select = container.querySelector( + 'select[aria-label="Title bar information"]', + ); + + act(() => { + if (!select) return; + select.value = "filename"; + select.dispatchEvent(new Event("change", { bubbles: true })); + }); + expect(select?.value).toBe("filename"); + + act(() => { + if (!select) return; + select.value = "none"; + select.dispatchEvent(new Event("change", { bubbles: true })); + }); + expect(select?.value).toBe("none"); + + act(() => root.unmount()); + container.remove(); + }); +}); diff --git a/src/components/settings/EditorSettingsSection.tsx b/src/components/settings/EditorSettingsSection.tsx index 997e9180..83d6a713 100644 --- a/src/components/settings/EditorSettingsSection.tsx +++ b/src/components/settings/EditorSettingsSection.tsx @@ -56,6 +56,148 @@ const boldWeightOptions = [ { value: 800, label: "Extra Bold", excludeForMonospace: false }, ]; +interface EditorWidthResizeControlProps { + enabled: boolean; + onChange: (enabled: boolean) => void; +} + +interface BinarySettingControlProps { + label: string; + description: string; + ariaLabel: string; + value: boolean; + onChange: (value: boolean) => void; +} + +function BinarySettingControl({ + label, + description, + ariaLabel, + value, + onChange, +}: BinarySettingControlProps) { + return ( +
+
+
{label}
+

{description}

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

+ Choose what appears beside the note icon +

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

Typography

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