From 166b0e26c70fcaab4c2fae20727ebb9abece8a85 Mon Sep 17 00:00:00 2001 From: Yax Patel Date: Sun, 19 Apr 2026 18:28:14 -0400 Subject: [PATCH] feat: add markdown preview mode --- package.json | 5 + pnpm-lock.yaml | 263 ++++++++++++++- src/renderer/src/assets/main.css | 1 + .../src/components/editor/EditorContent.tsx | 77 ++++- .../src/components/editor/EditorPanel.tsx | 209 ++++++++++-- .../src/components/editor/MarkdownPreview.tsx | 316 ++++++++++++++++-- .../components/editor/MarkdownViewToggle.tsx | 39 ++- .../components/editor/editor-autosave.test.ts | 10 +- .../src/components/editor/editor-autosave.ts | 2 +- .../components/editor/editor-labels.test.ts | 41 +++ .../src/components/editor/editor-labels.ts | 4 + .../editor/markdown-preview-controls.test.ts | 119 +++++++ .../editor/markdown-preview-controls.ts | 52 +++ .../editor/markdown-preview-links.test.ts | 28 ++ .../editor/markdown-preview-links.ts | 21 +- .../editor/markdown-render-mode.test.ts | 14 +- .../components/editor/markdown-render-mode.ts | 17 +- .../useFileExplorerAutoReveal.ts | 10 +- .../right-sidebar/useFileExplorerDragDrop.ts | 37 +- .../src/components/settings/ShortcutsPane.tsx | 10 + .../src/components/tab-bar/EditorFileTab.tsx | 46 +++ .../src/hooks/useEditorExternalWatch.ts | 18 +- src/renderer/src/lib/rename-file.ts | 33 +- src/renderer/src/store/slices/editor.test.ts | 61 ++++ src/renderer/src/store/slices/editor.ts | 95 +++++- 25 files changed, 1397 insertions(+), 131 deletions(-) create mode 100644 src/renderer/src/components/editor/editor-labels.test.ts create mode 100644 src/renderer/src/components/editor/markdown-preview-controls.test.ts create mode 100644 src/renderer/src/components/editor/markdown-preview-controls.ts diff --git a/package.json b/package.json index e85f4de01ee..b33a7b76001 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ "github-slugger": "^2.0.0", "hosted-git-info": "^9.0.2", "html-to-image": "^1.11.13", + "katex": "^0.16.45", "lowlight": "^3.3.0", "lucide-react": "^0.577.0", "mermaid": "^11.14.0", @@ -88,10 +89,14 @@ "radix-ui": "^1.4.3", "react-markdown": "^10.1.0", "rehype-highlight": "^7.0.2", + "rehype-katex": "^7.0.1", + "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", "rehype-slug": "^6.0.0", "remark-breaks": "^4.0.0", "remark-frontmatter": "^5.0.0", "remark-gfm": "^4.0.1", + "remark-math": "^6.0.0", "shadcn": "^4.1.0", "simple-git": "^3.33.0", "sonner": "^2.0.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8571d198062..7f8af4b3a41 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,10 +24,10 @@ importers: version: 3.2.2(react@19.2.4) '@electron-toolkit/preload': specifier: ^3.0.2 - version: 3.0.2(electron@41.2.2) + version: 3.0.2(electron@41.3.0) '@electron-toolkit/utils': specifier: ^4.0.0 - version: 4.0.0(electron@41.2.2) + version: 4.0.0(electron@41.3.0) '@linear/sdk': specifier: ^82.1.0 version: 82.1.0(graphql@16.13.1) @@ -120,7 +120,7 @@ importers: version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) dompurify: specifier: ^3.4.0 - version: 3.4.1 + version: 3.4.0 electron-updater: specifier: ^6.8.3 version: 6.8.3 @@ -133,6 +133,9 @@ importers: html-to-image: specifier: ^1.11.13 version: 1.11.13 + katex: + specifier: ^0.16.45 + version: 0.16.45 lowlight: specifier: ^3.3.0 version: 3.3.0 @@ -160,6 +163,15 @@ importers: rehype-highlight: specifier: ^7.0.2 version: 7.0.2 + rehype-katex: + specifier: ^7.0.1 + version: 7.0.1 + rehype-raw: + specifier: ^7.0.0 + version: 7.0.0 + rehype-sanitize: + specifier: ^6.0.0 + version: 6.0.0 rehype-slug: specifier: ^6.0.0 version: 6.0.0 @@ -172,6 +184,9 @@ importers: remark-gfm: specifier: ^4.0.1 version: 4.0.1 + remark-math: + specifier: ^6.0.0 + version: 6.0.0 shadcn: specifier: ^4.1.0 version: 4.1.0(@types/node@25.5.0)(typescript@5.9.3) @@ -235,7 +250,7 @@ importers: version: 5.2.0(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)) electron: specifier: ^41.2.2 - version: 41.2.2 + version: 41.3.0 electron-builder: specifier: ^26.8.1 version: 26.8.1(electron-builder-squirrel-windows@26.8.1) @@ -2771,6 +2786,9 @@ packages: '@types/http-cache-semantics@4.2.0': resolution: {integrity: sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==} + '@types/katex@0.16.8': + resolution: {integrity: sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==} + '@types/keyv@3.1.4': resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==} @@ -3672,8 +3690,8 @@ packages: dompurify@3.2.7: resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==} - dompurify@3.4.1: - resolution: {integrity: sha512-JahakDAIg1gyOm7dlgWSDjV4n7Ip2PKR55NIT6jrMfIgLFgWo81vdr1/QGqWtFNRqXP9UV71oVePtjqS2ebnPw==} + dompurify@3.4.0: + resolution: {integrity: sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==} dotenv-expand@11.0.7: resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==} @@ -3738,8 +3756,8 @@ packages: resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==} engines: {node: '>=8.0.0'} - electron@41.2.2: - resolution: {integrity: sha512-3rzz/hVIpF726W9g7nleQzyF2IOEZbzZnUTUYGhMaEfsoab8fDyOYAWbdBdo4+DczS1Ifz11rdYo8IAAGcRx/g==} + electron@41.3.0: + resolution: {integrity: sha512-2Q5aeocmFdeheZGDUTrAvSR3t+n0c3d104AJWWEnt7syJU0tE4VdibMYaPtQ47QuXSoUf0/xSsfUUvu/uSXIfg==} engines: {node: '>= 12.20.55'} hasBin: true @@ -3766,6 +3784,10 @@ packages: resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} engines: {node: '>=10.13.0'} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -4112,15 +4134,39 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hast-util-from-dom@5.0.1: + resolution: {integrity: sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==} + + hast-util-from-html-isomorphic@2.0.0: + resolution: {integrity: sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw==} + + hast-util-from-html@2.0.3: + resolution: {integrity: sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==} + + hast-util-from-parse5@8.0.3: + resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==} + hast-util-heading-rank@3.0.0: resolution: {integrity: sha512-EJKb8oMUXVHcWZTDepnr+WNbfnXKFNf9duMesmr4S8SXTJBJ9M4Yok08pu9vxdJwdlGRhVumk9mEhkEvKGifwA==} hast-util-is-element@3.0.0: resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==} + hast-util-parse-selector@4.0.0: + resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} + + hast-util-raw@9.1.0: + resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==} + + hast-util-sanitize@5.0.2: + resolution: {integrity: sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==} + hast-util-to-jsx-runtime@2.3.6: resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} + hast-util-to-parse5@8.0.1: + resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==} + hast-util-to-string@3.0.1: resolution: {integrity: sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==} @@ -4130,6 +4176,9 @@ packages: hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + hastscript@9.0.1: + resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} + headers-polyfill@4.0.3: resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==} @@ -4155,6 +4204,9 @@ packages: html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + http-cache-semantics@4.2.0: resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} @@ -4657,6 +4709,9 @@ packages: mdast-util-gfm@3.1.0: resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + mdast-util-math@3.0.0: + resolution: {integrity: sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==} + mdast-util-mdx-expression@2.0.1: resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} @@ -4726,6 +4781,9 @@ packages: micromark-extension-gfm@3.0.0: resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + micromark-extension-math@3.1.0: + resolution: {integrity: sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==} + micromark-factory-destination@2.0.1: resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} @@ -5073,6 +5131,9 @@ packages: resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} engines: {node: '>=18'} + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -5356,6 +5417,15 @@ packages: rehype-highlight@7.0.2: resolution: {integrity: sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA==} + rehype-katex@7.0.1: + resolution: {integrity: sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==} + + rehype-raw@7.0.0: + resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==} + + rehype-sanitize@6.0.0: + resolution: {integrity: sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==} + rehype-slug@6.0.0: resolution: {integrity: sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A==} @@ -5368,6 +5438,9 @@ packages: remark-gfm@4.0.1: resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} + remark-math@6.0.0: + resolution: {integrity: sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==} + remark-parse@11.0.0: resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} @@ -5881,6 +5954,9 @@ packages: unist-util-position@5.0.0: resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + unist-util-remove-position@5.0.0: + resolution: {integrity: sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==} + unist-util-stringify-position@4.0.0: resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} @@ -5961,6 +6037,9 @@ packages: resolution: {integrity: sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==} engines: {node: '>=0.6.0'} + vfile-location@5.0.3: + resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} + vfile-message@4.0.3: resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} @@ -6068,6 +6147,9 @@ packages: wcwidth@1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + web-namespaces@2.0.1: + resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + web-streams-polyfill@3.3.3: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} @@ -6480,17 +6562,17 @@ snapshots: dependencies: '@noble/ciphers': 1.3.0 - '@electron-toolkit/preload@3.0.2(electron@41.2.2)': + '@electron-toolkit/preload@3.0.2(electron@41.3.0)': dependencies: - electron: 41.2.2 + electron: 41.3.0 '@electron-toolkit/tsconfig@2.0.0(@types/node@25.5.0)': dependencies: '@types/node': 25.5.0 - '@electron-toolkit/utils@4.0.0(electron@41.2.2)': + '@electron-toolkit/utils@4.0.0(electron@41.3.0)': dependencies: - electron: 41.2.2 + electron: 41.3.0 '@electron/asar@3.4.1': dependencies: @@ -8533,6 +8615,8 @@ snapshots: '@types/http-cache-semantics@4.2.0': {} + '@types/katex@0.16.8': {} + '@types/keyv@3.1.4': dependencies: '@types/node': 25.5.0 @@ -9491,7 +9575,7 @@ snapshots: optionalDependencies: '@types/trusted-types': 2.0.7 - dompurify@3.4.1: + dompurify@3.4.0: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -9601,7 +9685,7 @@ snapshots: transitivePeerDependencies: - supports-color - electron@41.2.2: + electron@41.3.0: dependencies: '@electron/get': 2.0.3 '@types/node': 24.12.2 @@ -9631,6 +9715,8 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.0 + entities@6.0.1: {} + env-paths@2.2.1: {} environment@1.1.0: {} @@ -10078,6 +10164,39 @@ snapshots: dependencies: function-bind: 1.1.2 + hast-util-from-dom@5.0.1: + dependencies: + '@types/hast': 3.0.4 + hastscript: 9.0.1 + web-namespaces: 2.0.1 + + hast-util-from-html-isomorphic@2.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-from-dom: 5.0.1 + hast-util-from-html: 2.0.3 + unist-util-remove-position: 5.0.0 + + hast-util-from-html@2.0.3: + dependencies: + '@types/hast': 3.0.4 + devlop: 1.1.0 + hast-util-from-parse5: 8.0.3 + parse5: 7.3.0 + vfile: 6.0.3 + vfile-message: 4.0.3 + + hast-util-from-parse5@8.0.3: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + devlop: 1.1.0 + hastscript: 9.0.1 + property-information: 7.1.0 + vfile: 6.0.3 + vfile-location: 5.0.3 + web-namespaces: 2.0.1 + hast-util-heading-rank@3.0.0: dependencies: '@types/hast': 3.0.4 @@ -10086,6 +10205,32 @@ snapshots: dependencies: '@types/hast': 3.0.4 + hast-util-parse-selector@4.0.0: + dependencies: + '@types/hast': 3.0.4 + + hast-util-raw@9.1.0: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + '@ungap/structured-clone': 1.3.0 + hast-util-from-parse5: 8.0.3 + hast-util-to-parse5: 8.0.1 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + parse5: 7.3.0 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + + hast-util-sanitize@5.0.2: + dependencies: + '@types/hast': 3.0.4 + '@ungap/structured-clone': 1.3.0 + unist-util-position: 5.0.0 + hast-util-to-jsx-runtime@2.3.6: dependencies: '@types/estree': 1.0.8 @@ -10106,6 +10251,16 @@ snapshots: transitivePeerDependencies: - supports-color + hast-util-to-parse5@8.0.1: + dependencies: + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + hast-util-to-string@3.0.1: dependencies: '@types/hast': 3.0.4 @@ -10121,6 +10276,14 @@ snapshots: dependencies: '@types/hast': 3.0.4 + hastscript@9.0.1: + dependencies: + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + hast-util-parse-selector: 4.0.0 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + headers-polyfill@4.0.3: {} highlight.js@11.11.1: {} @@ -10139,6 +10302,8 @@ snapshots: html-url-attributes@3.0.1: {} + html-void-elements@3.0.0: {} + http-cache-semantics@4.2.0: {} http-errors@2.0.1: @@ -10623,6 +10788,18 @@ snapshots: transitivePeerDependencies: - supports-color + mdast-util-math@3.0.0: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + longest-streak: 3.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + unist-util-remove-position: 5.0.0 + transitivePeerDependencies: + - supports-color + mdast-util-mdx-expression@2.0.1: dependencies: '@types/estree-jsx': 1.0.5 @@ -10722,7 +10899,7 @@ snapshots: d3-sankey: 0.12.3 dagre-d3-es: 7.0.14 dayjs: 1.11.20 - dompurify: 3.4.1 + dompurify: 3.4.0 katex: 0.16.45 khroma: 2.1.0 lodash-es: 4.18.1 @@ -10816,6 +10993,16 @@ snapshots: micromark-util-combine-extensions: 2.0.1 micromark-util-types: 2.0.2 + micromark-extension-math@3.1.0: + dependencies: + '@types/katex': 0.16.8 + devlop: 1.1.0 + katex: 0.16.45 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + micromark-factory-destination@2.0.1: dependencies: micromark-util-character: 2.1.1 @@ -11271,6 +11458,10 @@ snapshots: parse-ms@4.0.0: {} + parse5@7.3.0: + dependencies: + entities: 6.0.1 + parseurl@1.3.3: {} path-browserify@1.0.1: {} @@ -11638,6 +11829,27 @@ snapshots: unist-util-visit: 5.1.0 vfile: 6.0.3 + rehype-katex@7.0.1: + dependencies: + '@types/hast': 3.0.4 + '@types/katex': 0.16.8 + hast-util-from-html-isomorphic: 2.0.0 + hast-util-to-text: 4.0.2 + katex: 0.16.45 + unist-util-visit-parents: 6.0.2 + vfile: 6.0.3 + + rehype-raw@7.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-raw: 9.1.0 + vfile: 6.0.3 + + rehype-sanitize@6.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-sanitize: 5.0.2 + rehype-slug@6.0.0: dependencies: '@types/hast': 3.0.4 @@ -11672,6 +11884,15 @@ snapshots: transitivePeerDependencies: - supports-color + remark-math@6.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-math: 3.0.0 + micromark-extension-math: 3.1.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + remark-parse@11.0.0: dependencies: '@types/mdast': 4.0.4 @@ -12272,6 +12493,11 @@ snapshots: dependencies: '@types/unist': 3.0.3 + unist-util-remove-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-visit: 5.1.0 + unist-util-stringify-position@4.0.0: dependencies: '@types/unist': 3.0.3 @@ -12341,6 +12567,11 @@ snapshots: extsprintf: 1.4.1 optional: true + vfile-location@5.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile: 6.0.3 + vfile-message@4.0.3: dependencies: '@types/unist': 3.0.3 @@ -12416,6 +12647,8 @@ snapshots: dependencies: defaults: 1.0.4 + web-namespaces@2.0.1: {} + web-streams-polyfill@3.3.3: {} which@2.0.2: diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 52edf54fb3c..52695f1c0ab 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -1,6 +1,7 @@ @import 'tailwindcss'; @import 'tw-animate-css'; @import '@xterm/xterm/css/xterm.css'; +@import 'katex/dist/katex.min.css'; @import './rich-markdown-editor.css'; @import './markdown-preview.css'; @import './terminal.css'; diff --git a/src/renderer/src/components/editor/EditorContent.tsx b/src/renderer/src/components/editor/EditorContent.tsx index 1df6969ee73..179d4ed1df3 100644 --- a/src/renderer/src/components/editor/EditorContent.tsx +++ b/src/renderer/src/components/editor/EditorContent.tsx @@ -2,7 +2,7 @@ import React, { lazy } from 'react' import { detectLanguage } from '@/lib/language-detect' import { useAppStore } from '@/store' import { ConflictBanner, ConflictPlaceholderView, ConflictReviewPanel } from './ConflictComponents' -import type { OpenFile } from '@/store/slices/editor' +import type { MarkdownViewMode, OpenFile } from '@/store/slices/editor' import type { GitStatusEntry, GitDiffResult } from '../../../../shared/types' import { RICH_MARKDOWN_MAX_SIZE_BYTES } from '../../../../shared/constants' import { getMarkdownRenderMode } from './markdown-render-mode' @@ -31,8 +31,6 @@ type FileContent = { mimeType?: string } -type MarkdownViewMode = 'source' | 'rich' - export function EditorContent({ activeFile, viewStateScopeId, @@ -77,6 +75,10 @@ export function EditorContent({ : `${activeFile.filePath}::${viewStateScopeId}` const diffViewStateKey = viewStateScopeId === activeFile.id ? activeFile.id : `${activeFile.id}::${viewStateScopeId}` + const markdownPreviewViewStateKey = + viewStateScopeId === activeFile.id + ? `${activeFile.id}:preview` + : `${activeFile.id}::${viewStateScopeId}:preview` const openConflictFile = useAppStore((s) => s.openConflictFile) const openConflictReview = useAppStore((s) => s.openConflictReview) @@ -140,10 +142,13 @@ export function EditorContent({ // Keep the explanatory banner here so the user understands why "rich" view // currently shows Monaco instead. if (renderMode === 'source' && mdViewMode === 'rich') { + const richFallbackMessage = + richModeUnsupportedMessage ?? + 'File is too large for rich editing. Showing source mode instead.' return (
- File is too large for rich editing. Showing source mode instead. + {richFallbackMessage}
{renderMonacoEditor(fc)}
@@ -198,11 +203,14 @@ export function EditorContent({ } if (renderMode === 'preview') { + const shouldExplainRichFallback = mdViewMode === 'rich' && richModeUnsupportedMessage return (
-
- {richModeUnsupportedMessage} -
+ {shouldExplainRichFallback ? ( +
+ {richModeUnsupportedMessage} +
+ ) : null} {/* Why: before rich editing shipped, Orca already had a stable markdown preview surface. If Tiptap cannot safely own a document, falling back to that renderer preserves readable preview mode instead of forcing the @@ -269,6 +277,37 @@ export function EditorContent({ ) } + if (activeFile.mode === 'markdown-preview') { + const fc = fileContents[activeFile.id] + if (!fc) { + return ( +
+ Loading preview... +
+ ) + } + if (fc.isBinary) { + return ( +
+ Markdown preview is unavailable for binary files. +
+ ) + } + const previewSourceFileId = activeFile.markdownPreviewSourceFileId ?? activeFile.filePath + const previewContent = editBuffers[previewSourceFileId] ?? fc.content + return ( +
+ +
+ ) + } + if (activeFile.mode === 'edit') { if (activeFile.conflict?.kind === 'conflict-placeholder') { return @@ -348,12 +387,34 @@ export function EditorContent({
) } + const modifiedDiffContent = editBuffers[activeFile.id] ?? dc.modifiedContent + if (isMarkdown && mdViewMode === 'preview') { + return ( +
+
+ {/* Why: a rendered markdown preview cannot express additions and + deletions simultaneously, so preview mode intentionally shows the + modified side of the diff. Source mode remains available for the + actual line-by-line comparison. */} + Previewing the modified version of this diff. Switch to source mode to inspect changes. +
+
+ +
+
+ ) + } return ( s.markdownViewMode) const setMarkdownViewMode = useAppStore((s) => s.setMarkdownViewMode) const openFile = useAppStore((s) => s.openFile) + const openMarkdownPreview = useAppStore((s) => s.openMarkdownPreview) const closeFile = useAppStore((s) => s.closeFile) const clearUntitled = useAppStore((s) => s.clearUntitled) const editorDrafts = useAppStore((s) => s.editorDrafts) @@ -139,6 +149,11 @@ function EditorPanelInner({ const settings = useAppStore((s) => s.settings) const activeFile = openFiles.find((f) => f.id === activeFileId) ?? null + const activeFilePath = activeFile?.filePath ?? null + const activeFileRelativePath = activeFile?.relativePath ?? null + const activeFileWorktreeId = activeFile?.worktreeId ?? null + const activeFileMode = activeFile?.mode ?? null + const activeFileDiffSource = activeFile?.diffSource const activeViewStateId = activeViewStateIdProp ?? activeFileId const [fileContents, setFileContents] = useState>({}) @@ -154,6 +169,7 @@ function EditorPanelInner({ const [prevDiffView, setPrevDiffView] = useState(settings?.diffDefaultView) const [pathMenuOpen, setPathMenuOpen] = useState(false) const [pathMenuPoint, setPathMenuPoint] = useState({ x: 0, y: 0 }) + const panelRef = useRef(null) const deleteCacheEntriesByPrefix = useCallback((cache: Map, prefix: string) => { for (const key of cache.keys()) { @@ -216,10 +232,10 @@ function EditorPanelInner({ monaco.editor.getModel(monaco.Uri.parse(prevFile.filePath))?.dispose() scrollTopCache.delete(prevFile.filePath) deleteCacheEntriesByPrefix(scrollTopCache, `${prevFile.filePath}::`) - // Why: markdown edit tabs cycle through three view modes (source, rich, - // preview), each caching scroll under a mode-scoped key. All must be - // evicted so a reopened file starts fresh regardless of which mode was - // last active. + // Why: markdown edit tabs keep separate source/rich scroll caches, + // and older sessions may still have the legacy in-place preview key. + // Clear all of them so reopened files never inherit stale viewport + // state from a prior tab incarnation. scrollTopCache.delete(`${prevFile.filePath}:rich`) scrollTopCache.delete(`${prevFile.filePath}:preview`) // Why: mermaid files use a mode-scoped cache key just like markdown. @@ -229,6 +245,13 @@ function EditorPanelInner({ cursorPositionCache.delete(prevFile.filePath) deleteCacheEntriesByPrefix(cursorPositionCache, `${prevFile.filePath}::`) break + case 'markdown-preview': + // Why: preview tabs have no retained Monaco models, but they do + // own pane-scoped preview scroll cache entries that should be + // dropped on close so reopening the preview starts fresh. + scrollTopCache.delete(`${prevFile.id}:preview`) + deleteCacheEntriesByPrefix(scrollTopCache, `${prevFile.id}::`) + break case 'diff': // Why: kept diff models are keyed by tab id, not file path, because the // same file can appear in multiple diff tabs with different contents. @@ -236,6 +259,12 @@ function EditorPanelInner({ monaco.editor.getModel(monaco.Uri.parse(`diff:modified:${prevId}`))?.dispose() diffViewStateCache.delete(prevId) deleteCacheEntriesByPrefix(diffViewStateCache, `${prevId}::`) + // Why: single-file markdown diffs now have a rendered preview mode + // whose scroll position is keyed off the diff tab identity rather + // than a Monaco view-state cache entry. Clear those mode-scoped + // keys alongside the diff models so reopened diff tabs start fresh. + scrollTopCache.delete(`${prevId}:preview`) + deleteCacheEntriesByPrefix(scrollTopCache, `${prevId}::`) break case 'conflict-review': break @@ -253,7 +282,7 @@ function EditorPanelInner({ if (activeFile.mode === 'conflict-review') { return } - if (activeFile.mode === 'edit') { + if (activeFile.mode === 'edit' || activeFile.mode === 'markdown-preview') { if (activeFile.conflict?.kind === 'conflict-placeholder') { return } @@ -430,19 +459,29 @@ function EditorPanelInner({ if (!activeFile) { return } + const saveTargetFile = + activeFile.mode === 'markdown-preview' + ? (openFiles.find( + (openFile) => + openFile.id === activeFile.markdownPreviewSourceFileId && openFile.mode === 'edit' + ) ?? null) + : activeFile + if (!saveTargetFile) { + return + } // Why: for untitled files, Cmd+S should prompt for a name before // writing anything. Saving first would make Cancel misleading since // the write already happened. Show the dialog and let the confirm // handler do the save + rename atomically. - if (activeFile.isUntitled) { - setRenameDialogFileId(activeFile.id) + if (saveTargetFile.isUntitled) { + setRenameDialogFileId(saveTargetFile.id) return } try { - await requestEditorFileSave({ fileId: activeFile.id, fallbackContent: content }) + await requestEditorFileSave({ fileId: saveTargetFile.id, fallbackContent: content }) } catch {} }, - [activeFile] + [activeFile, openFiles] ) // Why: global Cmd+S (from Terminal.tsx) dispatches this event when @@ -453,19 +492,32 @@ function EditorPanelInner({ if (!activeFile) { return } - // Why: untitled files need the dialog even when there's no draft yet. - // For regular files, skip the save if there's no draft — the file on - // disk is already up-to-date, and passing an empty fallback would - // overwrite it with nothing. - const draft = useAppStore.getState().editorDrafts[activeFile.id] - if (!draft && !activeFile.isUntitled) { + const saveTargetFile = + activeFile.mode === 'markdown-preview' + ? (openFilesRef.current.find( + (openFile) => + openFile.id === activeFile.markdownPreviewSourceFileId && openFile.mode === 'edit' + ) ?? null) + : activeFile + if (!saveTargetFile) { return } - void handleSave(draft ?? '') + // Why: a markdown preview tab is read-only but still fronts the same + // underlying document. Cmd/Ctrl+S should save that source editor's draft + // instead of no-oping just because the preview tab currently has focus. + const state = useAppStore.getState() + const draft = state.editorDrafts[saveTargetFile.id] + if (!draft && !saveTargetFile.isUntitled && !saveTargetFile.isDirty) { + return + } + const fallbackContent = + draft ?? + (activeFile.mode === 'markdown-preview' ? fileContents[activeFile.id]?.content : '') + void handleSave(fallbackContent ?? '') } window.addEventListener(ORCA_EDITOR_REQUEST_CMD_SAVE_EVENT, handler) return () => window.removeEventListener(ORCA_EDITOR_REQUEST_CMD_SAVE_EVENT, handler) - }, [activeFile, handleSave]) + }, [activeFile, fileContents, handleSave]) useEffect(() => { const handler = (event: Event): void => { @@ -487,7 +539,7 @@ function EditorPanelInner({ // loadFileContent / loadDiffContent overwrite the entry atomically once // the fresh read returns, which is what Monaco's value-sync can observe. for (const file of matchingFiles) { - if (file.mode === 'edit') { + if (file.mode === 'edit' || file.mode === 'markdown-preview') { void loadFileContent(file.filePath, file.id, file.worktreeId) } else if ( file.mode === 'diff' && @@ -538,11 +590,29 @@ function EditorPanelInner({ return } - if (file.mode === 'edit') { + if (file.mode === 'edit' || file.mode === 'markdown-preview') { setFileContents((prev) => ({ ...prev, [file.id]: { content: detail.content, isBinary: false } })) + } + + const previewTabs = openFilesRef.current.filter( + (openFile) => + openFile.mode === 'markdown-preview' && + openFile.markdownPreviewSourceFileId === detail.fileId + ) + if (previewTabs.length > 0) { + setFileContents((prev) => { + const next = { ...prev } + for (const previewTab of previewTabs) { + next[previewTab.id] = { content: detail.content, isBinary: false } + } + return next + }) + } + + if (file.mode === 'edit' || file.mode === 'markdown-preview') { return } @@ -664,6 +734,58 @@ function EditorPanelInner({ } }, [activeFile]) + useEffect(() => { + if (!activeFilePath || !activeFileRelativePath || !activeFileWorktreeId || !activeFileMode) { + return + } + + const shortcutLanguage = + activeFileMode === 'diff' + ? detectLanguage(activeFileRelativePath) + : detectLanguage(activeFilePath) + const canShowMarkdownPreview = canOpenMarkdownPreview({ + language: shortcutLanguage, + mode: activeFileMode, + diffSource: activeFileDiffSource + }) + if (!canShowMarkdownPreview) { + return + } + + const handleKeyDown = (event: KeyboardEvent): void => { + if (event.defaultPrevented || !isMarkdownPreviewShortcut(event, isMac)) { + return + } + const root = panelRef.current + if (!root) { + return + } + const target = event.target + const targetInsidePanel = target instanceof Node && root.contains(target) + if (!targetInsidePanel) { + return + } + event.preventDefault() + event.stopPropagation() + openMarkdownPreview({ + filePath: activeFilePath, + relativePath: activeFileRelativePath, + worktreeId: activeFileWorktreeId, + language: shortcutLanguage + }) + } + + window.addEventListener('keydown', handleKeyDown, { capture: true }) + return () => window.removeEventListener('keydown', handleKeyDown, { capture: true }) + }, [ + activeFileDiffSource, + activeFileMode, + activeFilePath, + activeFileRelativePath, + activeFileWorktreeId, + openMarkdownPreview + ]) + if (!activeFile) { return null } @@ -728,12 +850,29 @@ function EditorPanelInner({ sourceGroupId }) } - // Why: mermaid files reuse the same per-file view mode store as markdown. - // Both default to 'rich' (rendered view) and fall back to 'source' (Monaco). - const hasViewModeToggle = (isMarkdown || isMermaid) && activeFile.mode === 'edit' - const mdViewMode: MarkdownViewMode = hasViewModeToggle - ? (markdownViewMode[activeFile.id] ?? 'rich') - : 'source' + const markdownViewModes = getMarkdownViewModes({ + language: resolvedLanguage, + mode: activeFile.mode, + diffSource: activeFile.diffSource + }) + const hasViewModeToggle = markdownViewModes.length > 0 + const defaultMarkdownViewMode = getDefaultMarkdownViewMode({ + language: resolvedLanguage, + mode: activeFile.mode, + diffSource: activeFile.diffSource + }) + const storedMarkdownViewMode = markdownViewMode[activeFile.id] + const mdViewMode: MarkdownViewMode = + hasViewModeToggle && + storedMarkdownViewMode !== undefined && + markdownViewModes.includes(storedMarkdownViewMode) + ? storedMarkdownViewMode + : defaultMarkdownViewMode + const canShowMarkdownPreview = canOpenMarkdownPreview({ + language: resolvedLanguage, + mode: activeFile.mode, + diffSource: activeFile.diffSource + }) const handleOpenDiffTargetFile = (): void => { if (!openFileState.canOpen) { @@ -755,7 +894,7 @@ function EditorPanelInner({ ) return ( -
+
{!isCombinedDiff && (
@@ -810,6 +949,23 @@ function EditorPanelInner({ Copy Relative Path + {canShowMarkdownPreview && ( + + openMarkdownPreview({ + filePath: activeFile.filePath, + relativePath: activeFile.relativePath, + worktreeId: activeFile.worktreeId, + language: resolvedLanguage + }) + } + > + + Open Markdown Preview + {markdownPreviewShortcutLabel} + + )} + {canShowMarkdownPreview && } { window.api.shell.openPath(activeFile.filePath) @@ -884,6 +1040,7 @@ function EditorPanelInner({ {hasViewModeToggle && ( setMarkdownViewMode(activeFile.id, mode)} /> )} diff --git a/src/renderer/src/components/editor/MarkdownPreview.tsx b/src/renderer/src/components/editor/MarkdownPreview.tsx index 2ecb5e124e8..292c259e81d 100644 --- a/src/renderer/src/components/editor/MarkdownPreview.tsx +++ b/src/renderer/src/components/editor/MarkdownPreview.tsx @@ -1,8 +1,16 @@ +/* eslint-disable max-lines -- Why: MarkdownPreview owns rendering, link interception, +search, and viewport state for the preview surface in one place so markdown +behavior stays coherent across split panes and preview tabs. */ import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import Markdown from 'react-markdown' import remarkGfm from 'remark-gfm' +import remarkBreaks from 'remark-breaks' import remarkFrontmatter from 'remark-frontmatter' +import remarkMath from 'remark-math' import rehypeHighlight from 'rehype-highlight' +import rehypeKatex from 'rehype-katex' +import rehypeRaw from 'rehype-raw' +import rehypeSanitize, { defaultSchema } from 'rehype-sanitize' import rehypeSlug from 'rehype-slug' import { extractFrontMatter } from './markdown-frontmatter' import { ChevronDown, ChevronUp, X } from 'lucide-react' @@ -10,11 +18,11 @@ import type { Components } from 'react-markdown' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { useAppStore } from '@/store' -import { toast } from 'sonner' import { computeEditorFontSize } from '@/lib/editor-font-zoom' import { scrollTopCache, setWithLRU } from '@/lib/scroll-cache' -import { getMarkdownPreviewLinkTarget } from './markdown-preview-links' -import { absolutePathToFileUri, resolveMarkdownLinkTarget } from './markdown-internal-links' +import { detectLanguage } from '@/lib/language-detect' +import type { Worktree } from '../../../../shared/types' +import { fileUrlToAbsolutePath, resolveMarkdownPreviewHref } from './markdown-preview-links' import { useLocalImageSrc } from './useLocalImageSrc' import CodeBlockCopyButton from './CodeBlockCopyButton' import MermaidBlock from './MermaidBlock' @@ -32,15 +40,102 @@ type MarkdownPreviewProps = { filePath: string worktreeId: string scrollCacheKey: string + initialAnchor?: string | null +} + +const markdownPreviewSanitizeSchema = { + ...defaultSchema, + tagNames: [...(defaultSchema.tagNames ?? []), 'details', 'summary', 'kbd', 'sub', 'sup', 'ins'], + attributes: { + ...defaultSchema.attributes, + '*': [...(defaultSchema.attributes?.['*'] ?? []), 'id'], + a: [...(defaultSchema.attributes?.a ?? []), 'href', 'title'], + code: [ + ...(defaultSchema.attributes?.code ?? []), + ['className', /^language-[\w-]+$/, 'math-inline', 'math-display'] + ], + div: [...(defaultSchema.attributes?.div ?? []), ['className', /^language-[\w-]+$/], 'align'], + details: [...(defaultSchema.attributes?.details ?? []), 'open'], + h1: [...(defaultSchema.attributes?.h1 ?? []), 'id'], + h2: [...(defaultSchema.attributes?.h2 ?? []), 'id'], + h3: [...(defaultSchema.attributes?.h3 ?? []), 'id'], + h4: [...(defaultSchema.attributes?.h4 ?? []), 'id'], + h5: [...(defaultSchema.attributes?.h5 ?? []), 'id'], + h6: [...(defaultSchema.attributes?.h6 ?? []), 'id'], + img: [...(defaultSchema.attributes?.img ?? []), 'src', 'alt', 'title', 'width', 'height'], + input: [...(defaultSchema.attributes?.input ?? []), 'type', 'checked', 'disabled'], + pre: [...(defaultSchema.attributes?.pre ?? []), ['className', /^language-[\w-]+$/]], + span: [...(defaultSchema.attributes?.span ?? []), ['className', /^hljs(?:-[\w-]+)?$/]], + td: [...(defaultSchema.attributes?.td ?? []), 'align'], + th: [...(defaultSchema.attributes?.th ?? []), 'align'] + } +} + +function getMarkdownPreviewNodeText(node: React.ReactNode): string { + if (typeof node === 'string' || typeof node === 'number') { + return String(node) + } + if (Array.isArray(node)) { + return node.map((child) => getMarkdownPreviewNodeText(child)).join('') + } + if (React.isValidElement<{ children?: React.ReactNode }>(node)) { + return getMarkdownPreviewNodeText(node.props.children) + } + return '' +} + +function createMarkdownPreviewHeadingId( + headingText: string, + headingIdCounts: Map +): string { + const baseId = + headingText + .trim() + .toLowerCase() + .replace(/[`~!@#$%^&*()=+[\]{}|\\:;"'<>,.?/]+/g, '') + .replace(/\s+/g, '-') || 'section' + const duplicateCount = headingIdCounts.get(baseId) ?? 0 + headingIdCounts.set(baseId, duplicateCount + 1) + return duplicateCount === 0 ? baseId : `${baseId}-${duplicateCount}` +} + +function normalizeMarkdownPreviewAbsolutePath(absolutePath: string): string { + return absolutePath.replaceAll('\\', '/') +} + +function findWorktreeForMarkdownPreviewPath( + worktreesByRepo: Record, + absolutePath: string +): Worktree | null { + const normalizedAbsolutePath = normalizeMarkdownPreviewAbsolutePath(absolutePath) + let bestMatch: Worktree | null = null + let bestMatchLength = -1 + + for (const worktrees of Object.values(worktreesByRepo)) { + for (const worktree of worktrees) { + const normalizedWorktreePath = normalizeMarkdownPreviewAbsolutePath(worktree.path) + if ( + normalizedAbsolutePath === normalizedWorktreePath || + normalizedAbsolutePath.startsWith(`${normalizedWorktreePath}/`) + ) { + if (normalizedWorktreePath.length > bestMatchLength) { + bestMatch = worktree + bestMatchLength = normalizedWorktreePath.length + } + } + } + } + + return bestMatch } export default function MarkdownPreview({ content, filePath, worktreeId, - scrollCacheKey + scrollCacheKey, + initialAnchor = null }: MarkdownPreviewProps): React.JSX.Element { - const activateMarkdownLink = useAppStore((s) => s.activateMarkdownLink) const worktreeRoot = useAppStore((s) => { for (const list of Object.values(s.worktreesByRepo)) { const wt = list.find((w) => w.id === worktreeId) @@ -50,15 +145,18 @@ export default function MarkdownPreview({ } return null }) - const isMac = navigator.userAgent.includes('Mac') const rootRef = useRef(null) const bodyRef = useRef(null) const inputRef = useRef(null) const matchesRef = useRef([]) + const lastAppliedInitialAnchorRef = useRef(null) const [isSearchOpen, setIsSearchOpen] = useState(false) const [query, setQuery] = useState('') const [matchCount, setMatchCount] = useState(0) const [activeMatchIndex, setActiveMatchIndex] = useState(-1) + const openFile = useAppStore((s) => s.openFile) + const openMarkdownPreview = useAppStore((s) => s.openMarkdownPreview) + const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) const settings = useAppStore((s) => s.settings) const editorFontZoomLevel = useAppStore((s) => s.editorFontZoomLevel) const editorFontSize = computeEditorFontSize(14, editorFontZoomLevel) @@ -78,6 +176,7 @@ export default function MarkdownPreview({ .replace(/\r?\n(?:---|\+\+\+)\r?\n?$/, '') .trim() }, [frontMatter]) + const headingIdCounts = new Map() // Why: each split pane needs its own markdown preview viewport even when the // underlying file is shared. The caller passes a pane-scoped cache key so @@ -180,6 +279,31 @@ export default function MarkdownPreview({ setActiveMatchIndex(-1) }, []) + const scrollToAnchor = useCallback((rawAnchor: string): boolean => { + const container = rootRef.current + const body = bodyRef.current + if (!container || !body) { + return false + } + + const decodedAnchor = decodeURIComponent(rawAnchor) + let target: HTMLElement | null = null + for (const candidate of body.querySelectorAll('[id]')) { + if (candidate.id === decodedAnchor) { + target = candidate + break + } + } + if (!target) { + return false + } + + const targetTop = target.offsetTop + container.scrollTo({ top: Math.max(0, targetTop - 12) }) + target.focus({ preventScroll: true }) + return true + }, []) + useEffect(() => { if (isSearchOpen) { inputRef.current?.focus() @@ -217,6 +341,30 @@ export default function MarkdownPreview({ setActiveMarkdownPreviewSearchMatch(matchesRef.current, activeMatchIndex) }, [activeMatchIndex, matchCount]) + useLayoutEffect(() => { + if (!initialAnchor || initialAnchor === lastAppliedInitialAnchorRef.current) { + return + } + + let frameId = 0 + let attempts = 0 + + const tryRevealAnchor = (): void => { + if (scrollToAnchor(initialAnchor)) { + lastAppliedInitialAnchorRef.current = initialAnchor + return + } + + attempts += 1 + if (attempts < 30) { + frameId = window.requestAnimationFrame(tryRevealAnchor) + } + } + + tryRevealAnchor() + return () => window.cancelAnimationFrame(frameId) + }, [content, initialAnchor, scrollToAnchor]) + useEffect(() => { const handleKeyDown = (event: KeyboardEvent): void => { const root = rootRef.current @@ -260,28 +408,13 @@ export default function MarkdownPreview({ return } - // Why: anchor links target headings within the same preview. rehype-slug - // adds matching id attributes to headings so querySelector can find them. - // No modifier key required — same-page scroll is non-destructive. + event.preventDefault() + if (href.startsWith('#')) { - event.preventDefault() - // Why: anchors in markdown are often URL-encoded (e.g. `#%C3%A9-foo`) - // while rehype-slug produces unicode ids, so decode before matching. - let id = href.slice(1) - try { - id = decodeURIComponent(id) - } catch { - // Malformed %-escapes: fall back to the raw fragment. - } - const el = rootRef.current?.querySelector(`[id="${CSS.escape(id)}"]`) - if (el) { - el.scrollIntoView({ behavior: 'smooth', block: 'start' }) - } + void scrollToAnchor(href.slice(1)) return } - event.preventDefault() - // Why: Cmd/Ctrl+Shift-click is the OS escape hatch — always hand the // link to the system default handler, bypassing the classifier. For a // dangling in-worktree .md, pre-check existence so the user sees a @@ -322,10 +455,57 @@ export default function MarkdownPreview({ return } - void activateMarkdownLink(href, { - sourceFilePath: filePath, - worktreeId, - worktreeRoot + const target = resolveMarkdownPreviewHref(href, filePath) + if (!target) { + return + } + + if (target.protocol === 'http:' || target.protocol === 'https:') { + void window.api.shell.openUrl(target.toString()) + return + } + + if (target.protocol !== 'file:') { + return + } + + const absolutePath = fileUrlToAbsolutePath(target) + if (!absolutePath) { + return + } + + if (absolutePath === filePath && target.hash) { + void scrollToAnchor(target.hash.slice(1)) + return + } + + const targetWorktree = findWorktreeForMarkdownPreviewPath(worktreesByRepo, absolutePath) + if (!targetWorktree) { + void window.api.shell.openFileUri(target.toString()) + return + } + + const relativePath = absolutePath.slice(targetWorktree.path.length + 1) + const language = detectLanguage(absolutePath) + if (language === 'markdown') { + openMarkdownPreview( + { + filePath: absolutePath, + relativePath, + worktreeId: targetWorktree.id, + language + }, + { anchor: target.hash ? target.hash.slice(1) : null } + ) + return + } + + openFile({ + filePath: absolutePath, + relativePath, + worktreeId: targetWorktree.id, + language, + mode: 'edit' }) } @@ -370,6 +550,72 @@ export default function MarkdownPreview({ return <>{children} } return {children} + }, + h1: ({ children, ...props }) => { + const id = createMarkdownPreviewHeadingId( + getMarkdownPreviewNodeText(children), + headingIdCounts + ) + return ( +

+ {children} +

+ ) + }, + h2: ({ children, ...props }) => { + const id = createMarkdownPreviewHeadingId( + getMarkdownPreviewNodeText(children), + headingIdCounts + ) + return ( +

+ {children} +

+ ) + }, + h3: ({ children, ...props }) => { + const id = createMarkdownPreviewHeadingId( + getMarkdownPreviewNodeText(children), + headingIdCounts + ) + return ( +

+ {children} +

+ ) + }, + h4: ({ children, ...props }) => { + const id = createMarkdownPreviewHeadingId( + getMarkdownPreviewNodeText(children), + headingIdCounts + ) + return ( +

+ {children} +

+ ) + }, + h5: ({ children, ...props }) => { + const id = createMarkdownPreviewHeadingId( + getMarkdownPreviewNodeText(children), + headingIdCounts + ) + return ( +
+ {children} +
+ ) + }, + h6: ({ children, ...props }) => { + const id = createMarkdownPreviewHeadingId( + getMarkdownPreviewNodeText(children), + headingIdCounts + ) + return ( +
+ {children} +
+ ) } } @@ -468,8 +714,18 @@ export default function MarkdownPreview({ )} {renderedContent} diff --git a/src/renderer/src/components/editor/MarkdownViewToggle.tsx b/src/renderer/src/components/editor/MarkdownViewToggle.tsx index 8bd53acbaef..26653d69ea8 100644 --- a/src/renderer/src/components/editor/MarkdownViewToggle.tsx +++ b/src/renderer/src/components/editor/MarkdownViewToggle.tsx @@ -1,15 +1,32 @@ import React from 'react' -import { Code, Eye } from 'lucide-react' +import { Code, Eye, Pencil } from 'lucide-react' import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group' import type { MarkdownViewMode } from '@/store/slices/editor' +const VIEW_MODE_METADATA = { + source: { + label: 'Source', + icon: Code + }, + rich: { + label: 'Rich Editor', + icon: Pencil + }, + preview: { + label: 'Preview', + icon: Eye + } +} as const + type MarkdownViewToggleProps = { mode: MarkdownViewMode + modes: readonly MarkdownViewMode[] onChange: (mode: MarkdownViewMode) => void } export default function MarkdownViewToggle({ mode, + modes, onChange }: MarkdownViewToggleProps): React.JSX.Element { return ( @@ -25,12 +42,20 @@ export default function MarkdownViewToggle({ } }} > - - - - - - + {modes.map((viewMode) => { + const metadata = VIEW_MODE_METADATA[viewMode] + const Icon = metadata.icon + return ( + + + + ) + })} ) } diff --git a/src/renderer/src/components/editor/editor-autosave.test.ts b/src/renderer/src/components/editor/editor-autosave.test.ts index a1b7fda3b9c..9484e7206c3 100644 --- a/src/renderer/src/components/editor/editor-autosave.test.ts +++ b/src/renderer/src/components/editor/editor-autosave.test.ts @@ -143,6 +143,12 @@ describe('requestEditorFileSave', () => { describe('getOpenFilesForExternalFileChange', () => { it('matches edit tabs and unstaged diff tabs for the same worktree file', () => { const matchingEdit = makeOpenFile() + const matchingPreview = makeOpenFile({ + id: 'markdown-preview::/repo/file.ts', + mode: 'markdown-preview', + language: 'markdown', + markdownPreviewSourceFileId: '/repo/file.ts' + }) const matchingUnstagedDiff = makeOpenFile({ id: 'wt-1::diff::unstaged::file.ts', mode: 'diff', @@ -161,13 +167,13 @@ describe('getOpenFilesForExternalFileChange', () => { expect( getOpenFilesForExternalFileChange( - [matchingEdit, matchingUnstagedDiff, stagedDiff, otherWorktree], + [matchingEdit, matchingPreview, matchingUnstagedDiff, stagedDiff, otherWorktree], { worktreeId: 'wt-1', worktreePath: '/repo', relativePath: 'file.ts' } ).map((file) => file.id) - ).toEqual(['/repo/file.ts', 'wt-1::diff::unstaged::file.ts']) + ).toEqual(['/repo/file.ts', 'markdown-preview::/repo/file.ts', 'wt-1::diff::unstaged::file.ts']) }) }) diff --git a/src/renderer/src/components/editor/editor-autosave.ts b/src/renderer/src/components/editor/editor-autosave.ts index 4ac5016704e..9550de9679f 100644 --- a/src/renderer/src/components/editor/editor-autosave.ts +++ b/src/renderer/src/components/editor/editor-autosave.ts @@ -73,7 +73,7 @@ export function getOpenFilesForExternalFileChange( if (file.worktreeId !== target.worktreeId) { return false } - if (file.mode === 'edit') { + if (file.mode === 'edit' || file.mode === 'markdown-preview') { return file.filePath === absolutePath } if (file.mode === 'diff') { diff --git a/src/renderer/src/components/editor/editor-labels.test.ts b/src/renderer/src/components/editor/editor-labels.test.ts new file mode 100644 index 00000000000..a22e874384d --- /dev/null +++ b/src/renderer/src/components/editor/editor-labels.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { getEditorDisplayLabel } from './editor-labels' +import type { OpenFile } from '@/store/slices/editor' + +function makeOpenFile(overrides: Partial = {}): OpenFile { + return { + id: '/repo/docs/README.md', + filePath: '/repo/docs/README.md', + relativePath: 'docs/README.md', + worktreeId: 'wt-1', + language: 'markdown', + isDirty: false, + mode: 'edit', + ...overrides + } +} + +describe('getEditorDisplayLabel', () => { + it('adds a preview suffix for markdown preview tabs', () => { + expect( + getEditorDisplayLabel( + makeOpenFile({ + id: 'markdown-preview::/repo/docs/README.md', + mode: 'markdown-preview' + }) + ) + ).toBe('README.md (preview)') + }) + + it('uses the requested label variant for markdown preview tabs', () => { + expect( + getEditorDisplayLabel( + makeOpenFile({ + id: 'markdown-preview::/repo/docs/README.md', + mode: 'markdown-preview' + }), + 'relativePath' + ) + ).toBe('docs/README.md (preview)') + }) +}) diff --git a/src/renderer/src/components/editor/editor-labels.ts b/src/renderer/src/components/editor/editor-labels.ts index 6f23759fd44..188af3d218f 100644 --- a/src/renderer/src/components/editor/editor-labels.ts +++ b/src/renderer/src/components/editor/editor-labels.ts @@ -28,6 +28,10 @@ export function getEditorDisplayLabel( return 'Conflict Review' } + if (file.mode === 'markdown-preview') { + return `${getBaseLabel(file, variant)} (preview)` + } + if (file.mode !== 'diff') { return getBaseLabel(file, variant) } diff --git a/src/renderer/src/components/editor/markdown-preview-controls.test.ts b/src/renderer/src/components/editor/markdown-preview-controls.test.ts new file mode 100644 index 00000000000..bad3a637f94 --- /dev/null +++ b/src/renderer/src/components/editor/markdown-preview-controls.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest' +import { + canOpenMarkdownPreview, + getDefaultMarkdownViewMode, + getMarkdownPreviewShortcutLabel, + getMarkdownViewModes, + isMarkdownPreviewShortcut +} from './markdown-preview-controls' + +describe('getMarkdownViewModes', () => { + it('offers source and rich for markdown edit tabs', () => { + expect( + getMarkdownViewModes({ + language: 'markdown', + mode: 'edit' + }) + ).toEqual(['source', 'rich']) + }) + + it('offers source and preview for single-file markdown diffs', () => { + expect( + getMarkdownViewModes({ + language: 'markdown', + mode: 'diff', + diffSource: 'unstaged' + }) + ).toEqual(['source', 'preview']) + }) + + it('does not offer preview for mermaid edit tabs', () => { + expect( + getMarkdownViewModes({ + language: 'mermaid', + mode: 'edit' + }) + ).toEqual(['source', 'rich']) + }) +}) + +describe('markdown preview helpers', () => { + it('defaults markdown edit tabs to rich mode', () => { + expect( + getDefaultMarkdownViewMode({ + language: 'markdown', + mode: 'edit' + }) + ).toBe('rich') + }) + + it('defaults markdown diffs to source mode', () => { + expect( + getDefaultMarkdownViewMode({ + language: 'markdown', + mode: 'diff', + diffSource: 'unstaged' + }) + ).toBe('source') + }) + + it('opens dedicated preview tabs only for markdown edit tabs', () => { + expect( + canOpenMarkdownPreview({ + language: 'markdown', + mode: 'edit' + }) + ).toBe(true) + expect( + canOpenMarkdownPreview({ + language: 'markdown', + mode: 'diff', + diffSource: 'unstaged' + }) + ).toBe(false) + }) + + it('matches the VS Code-style shortcut on macOS and Windows/Linux', () => { + expect( + isMarkdownPreviewShortcut( + { + key: 'V', + metaKey: true, + ctrlKey: false, + shiftKey: true, + altKey: false + } as KeyboardEvent, + true + ) + ).toBe(true) + expect( + isMarkdownPreviewShortcut( + { + key: 'v', + metaKey: false, + ctrlKey: true, + shiftKey: true, + altKey: false + } as KeyboardEvent, + false + ) + ).toBe(true) + expect( + isMarkdownPreviewShortcut( + { + key: 'v', + metaKey: false, + ctrlKey: true, + shiftKey: false, + altKey: false + } as KeyboardEvent, + false + ) + ).toBe(false) + }) + + it('formats the shortcut label per platform', () => { + expect(getMarkdownPreviewShortcutLabel(true)).toBe('⌘⇧V') + expect(getMarkdownPreviewShortcutLabel(false)).toBe('Ctrl+Shift+V') + }) +}) diff --git a/src/renderer/src/components/editor/markdown-preview-controls.ts b/src/renderer/src/components/editor/markdown-preview-controls.ts new file mode 100644 index 00000000000..f0d57ac10ac --- /dev/null +++ b/src/renderer/src/components/editor/markdown-preview-controls.ts @@ -0,0 +1,52 @@ +import type { MarkdownViewMode, OpenFile } from '@/store/slices/editor' + +type MarkdownPreviewTarget = Pick & { + language: string +} + +const MARKDOWN_EDIT_VIEW_MODES = ['source', 'rich'] as const satisfies readonly MarkdownViewMode[] +const MARKDOWN_DIFF_VIEW_MODES = [ + 'source', + 'preview' +] as const satisfies readonly MarkdownViewMode[] +const MERMAID_VIEW_MODES = ['source', 'rich'] as const satisfies readonly MarkdownViewMode[] +const NO_VIEW_MODES = [] as const satisfies readonly MarkdownViewMode[] + +export function getMarkdownViewModes(target: MarkdownPreviewTarget): readonly MarkdownViewMode[] { + if (target.language === 'markdown') { + if (target.mode === 'edit') { + return MARKDOWN_EDIT_VIEW_MODES + } + if ( + target.mode === 'diff' && + target.diffSource !== 'combined-uncommitted' && + target.diffSource !== 'combined-branch' + ) { + return MARKDOWN_DIFF_VIEW_MODES + } + } + + if (target.language === 'mermaid' && target.mode === 'edit') { + return MERMAID_VIEW_MODES + } + + return NO_VIEW_MODES +} + +export function getDefaultMarkdownViewMode(target: MarkdownPreviewTarget): MarkdownViewMode { + const modes = getMarkdownViewModes(target) + return modes.includes('rich') ? 'rich' : 'source' +} + +export function canOpenMarkdownPreview(target: MarkdownPreviewTarget): boolean { + return target.language === 'markdown' && target.mode === 'edit' +} + +export function isMarkdownPreviewShortcut(event: KeyboardEvent, isMac: boolean): boolean { + const modifierPressed = isMac ? event.metaKey : event.ctrlKey + return modifierPressed && event.shiftKey && !event.altKey && event.key.toLowerCase() === 'v' +} + +export function getMarkdownPreviewShortcutLabel(isMac: boolean): string { + return isMac ? '⌘⇧V' : 'Ctrl+Shift+V' +} diff --git a/src/renderer/src/components/editor/markdown-preview-links.test.ts b/src/renderer/src/components/editor/markdown-preview-links.test.ts index 17e5c2765d9..a12eec9c15a 100644 --- a/src/renderer/src/components/editor/markdown-preview-links.test.ts +++ b/src/renderer/src/components/editor/markdown-preview-links.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from 'vitest' import { + fileUrlToAbsolutePath, getMarkdownPreviewImageSrc, getMarkdownPreviewLinkTarget, + resolveMarkdownPreviewHref, resolveImageAbsolutePath } from './markdown-preview-links' @@ -23,6 +25,14 @@ describe('getMarkdownPreviewLinkTarget', () => { }) }) +describe('resolveMarkdownPreviewHref', () => { + it('resolves markdown fragments against the current file path', () => { + expect( + resolveMarkdownPreviewHref('./guide/setup.md#install', '/repo/docs/README.md')?.toString() + ).toBe('file:///repo/docs/guide/setup.md#install') + }) +}) + describe('getMarkdownPreviewImageSrc', () => { it('resolves relative image paths against the current file', () => { expect(getMarkdownPreviewImageSrc('../assets/diagram.png', '/repo/docs/guides/README.md')).toBe( @@ -74,3 +84,21 @@ describe('resolveImageAbsolutePath', () => { expect(resolveImageAbsolutePath(undefined, '/repo/README.md')).toBeNull() }) }) + +describe('fileUrlToAbsolutePath', () => { + it('converts unix file URLs to absolute paths', () => { + expect(fileUrlToAbsolutePath(new URL('file:///repo/docs/README.md'))).toBe( + '/repo/docs/README.md' + ) + }) + + it('converts windows file URLs to absolute paths', () => { + expect(fileUrlToAbsolutePath(new URL('file:///C:/repo/docs/README.md'))).toBe( + 'C:/repo/docs/README.md' + ) + }) + + it('returns null for non-file URLs', () => { + expect(fileUrlToAbsolutePath(new URL('https://example.com/readme.md'))).toBeNull() + }) +}) diff --git a/src/renderer/src/components/editor/markdown-preview-links.ts b/src/renderer/src/components/editor/markdown-preview-links.ts index 6cd5595b9e9..e1077d78b29 100644 --- a/src/renderer/src/components/editor/markdown-preview-links.ts +++ b/src/renderer/src/components/editor/markdown-preview-links.ts @@ -14,7 +14,7 @@ function toFileUrl(filePath: string): string { return `file:///${segments.join('/')}` } -function resolveMarkdownUrl(rawUrl: string, filePath: string): URL | null { +export function resolveMarkdownPreviewHref(rawUrl: string, filePath: string): URL | null { if (!rawUrl || rawUrl.startsWith('#')) { return null } @@ -34,7 +34,7 @@ export function getMarkdownPreviewLinkTarget( return null } - const resolved = resolveMarkdownUrl(rawHref, filePath) + const resolved = resolveMarkdownPreviewHref(rawHref, filePath) if (!resolved) { return null } @@ -58,7 +58,7 @@ export function getMarkdownPreviewImageSrc( return rawSrc } - const resolved = resolveMarkdownUrl(rawSrc, filePath) + const resolved = resolveMarkdownPreviewHref(rawSrc, filePath) if (!resolved) { return rawSrc } @@ -87,7 +87,7 @@ export function resolveImageAbsolutePath( return null } - const resolved = resolveMarkdownUrl(rawSrc, filePath) + const resolved = resolveMarkdownPreviewHref(rawSrc, filePath) if (!resolved || resolved.protocol !== 'file:') { return null } @@ -101,3 +101,16 @@ export function resolveImageAbsolutePath( return absolutePath } + +export function fileUrlToAbsolutePath(fileUrl: URL): string | null { + if (fileUrl.protocol !== 'file:') { + return null + } + + let absolutePath = decodeURIComponent(fileUrl.pathname) + if (/^\/[A-Za-z]:\//.test(absolutePath)) { + absolutePath = absolutePath.slice(1) + } + + return absolutePath +} diff --git a/src/renderer/src/components/editor/markdown-render-mode.test.ts b/src/renderer/src/components/editor/markdown-render-mode.test.ts index 1c9df7e0cbf..f5ee3477c0a 100644 --- a/src/renderer/src/components/editor/markdown-render-mode.test.ts +++ b/src/renderer/src/components/editor/markdown-render-mode.test.ts @@ -22,14 +22,24 @@ describe('getMarkdownRenderMode', () => { ).toBe('rich-editor') }) - it('falls back to plain markdown preview when rich editing is unsupported', () => { + it('keeps explicit preview mode in the rendered preview', () => { + expect( + getMarkdownRenderMode({ + exceedsRichModeSizeLimit: true, + hasRichModeUnsupportedContent: true, + viewMode: 'preview' + }) + ).toBe('preview') + }) + + it('falls back to source mode when rich editing is unsupported', () => { expect( getMarkdownRenderMode({ exceedsRichModeSizeLimit: false, hasRichModeUnsupportedContent: true, viewMode: 'rich' }) - ).toBe('preview') + ).toBe('source') }) it('falls back to source mode when the markdown is too large for rich editing', () => { diff --git a/src/renderer/src/components/editor/markdown-render-mode.ts b/src/renderer/src/components/editor/markdown-render-mode.ts index 038b808b197..b8cbc499986 100644 --- a/src/renderer/src/components/editor/markdown-render-mode.ts +++ b/src/renderer/src/components/editor/markdown-render-mode.ts @@ -1,4 +1,4 @@ -type MarkdownViewMode = 'source' | 'rich' +import type { MarkdownViewMode } from '@/store/slices/editor' export type MarkdownRenderMode = 'source' | 'rich-editor' | 'preview' @@ -15,6 +15,13 @@ export function getMarkdownRenderMode({ return 'source' } + // Why: an explicit preview choice should stay in the rendered markdown view + // even when the rich editor would otherwise fall back for size or syntax. + // The user asked for read-only preview, not for Tiptap ownership. + if (viewMode === 'preview') { + return 'preview' + } + // Why: large markdown files stay editable, but ProseMirror's full-document // tree and serialization path make rich mode noticeably laggy there. Treat // "too large" as another safety condition that routes the user to Monaco @@ -24,8 +31,8 @@ export function getMarkdownRenderMode({ } // Why: rich view is the user's "formatted markdown" choice, not a promise - // that Tiptap owns the document. When rich editing is unsafe, we must fall - // back to the pre-#264 markdown preview instead of silently flipping to raw - // source mode and making preview appear broken. - return hasRichModeUnsupportedContent ? 'preview' : 'rich-editor' + // that Tiptap owns the document. Now that Orca has a dedicated preview tab, + // unsafe rich documents should stay editable in source mode here instead of + // silently turning the current editor tab into a read-only preview surface. + return hasRichModeUnsupportedContent ? 'source' : 'rich-editor' } diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerAutoReveal.ts b/src/renderer/src/components/right-sidebar/useFileExplorerAutoReveal.ts index a4b2ecc46d5..f72298b2c31 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerAutoReveal.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerAutoReveal.ts @@ -53,9 +53,15 @@ export function useFileExplorerAutoReveal({ return } - // Only auto-reveal regular edit-mode files, not diffs or conflict reviews + // Why: markdown preview tabs are separate UI surfaces, but they still map + // to one concrete file on disk and should keep Explorer selection in sync + // just like a normal edit tab. Diffs and conflict-review tabs do not. const activeFile = openFiles.find((f) => f.id === activeFileId) - if (!activeFile || activeFile.worktreeId !== activeWorktreeId || activeFile.mode !== 'edit') { + if ( + !activeFile || + activeFile.worktreeId !== activeWorktreeId || + (activeFile.mode !== 'edit' && activeFile.mode !== 'markdown-preview') + ) { return } diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts b/src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts index b806518302b..7fb36136564 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts @@ -1,3 +1,6 @@ +/* eslint-disable max-lines -- Why: explorer drag/drop keeps move, native-file +drop, auto-expand, and undo/redo coordination in one hook because splitting the +DnD state machine across files makes those interactions harder to reason about. */ import type { RefObject } from 'react' import { useCallback, useEffect, useRef, useState } from 'react' import { toast } from 'sonner' @@ -7,6 +10,7 @@ import { detectLanguage } from '@/lib/language-detect' import { getConnectionId } from '@/lib/connection-context' import { requestEditorSaveQuiesce } from '@/components/editor/editor-autosave' import { commitFileExplorerOp } from './fileExplorerUndoRedo' + function extractIpcErrorMessage(err: unknown, fallback: string): string { if (!(err instanceof Error)) { return fallback @@ -161,20 +165,33 @@ export function useFileExplorerDragDrop({ const draft = state.editorDrafts[file.id] const wasDirty = file.isDirty - state.closeFile(oldFilePath) + // Why: markdown preview tabs use a synthetic tab id rather than the + // file path, so move remaps must close the actual tab id before + // reopening the file at its new path. + state.closeFile(file.id) - if (file.mode !== 'edit') { + if (file.mode === 'edit') { + state.openFile({ + filePath: updatedPath, + relativePath: updatedRelative, + worktreeId: file.worktreeId, + language: detectLanguage(basename(updatedPath)), + mode: 'edit' + }) + } else if (file.mode === 'markdown-preview') { + state.openMarkdownPreview( + { + filePath: updatedPath, + relativePath: updatedRelative, + worktreeId: file.worktreeId, + language: 'markdown' + }, + { anchor: file.markdownPreviewAnchor ?? null } + ) + } else { continue } - state.openFile({ - filePath: updatedPath, - relativePath: updatedRelative, - worktreeId: file.worktreeId, - language: detectLanguage(basename(updatedPath)), - mode: 'edit' - }) - if (draft !== undefined) { state.setEditorDraft(updatedPath, draft) } diff --git a/src/renderer/src/components/settings/ShortcutsPane.tsx b/src/renderer/src/components/settings/ShortcutsPane.tsx index 0103dceac6f..bca7ab8efdb 100644 --- a/src/renderer/src/components/settings/ShortcutsPane.tsx +++ b/src/renderer/src/components/settings/ShortcutsPane.tsx @@ -184,6 +184,16 @@ const SHORTCUT_GROUP_DEFINITIONS: ShortcutGroupDefinition[] = [ keys: ({ mod, shift, enter }) => [mod, shift, enter] } ] + }, + { + title: 'Editors', + items: [ + { + action: 'Show Markdown Preview', + searchKeywords: ['shortcut', 'editor', 'markdown', 'preview'], + keys: ({ mod, shift }) => [mod, shift, 'V'] + } + ] } ] diff --git a/src/renderer/src/components/tab-bar/EditorFileTab.tsx b/src/renderer/src/components/tab-bar/EditorFileTab.tsx index 5cef1dc886e..ff450aa5c4e 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTab.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTab.tsx @@ -5,6 +5,7 @@ import { FileCode, GitCompareArrows, Copy, + Eye, ShieldAlert, ExternalLink, Columns2, @@ -15,12 +16,15 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, + DropdownMenuShortcut, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { basename, normalizeRelativePath } from '@/lib/path' import { getEditorDisplayLabel } from '@/components/editor/editor-labels' import { renameFileOnDisk } from '@/lib/rename-file' +import { detectLanguage } from '@/lib/language-detect' import { useWorktreeById } from '@/store/selectors' +import { useAppStore } from '@/store' import { STATUS_COLORS, STATUS_LABELS } from '../right-sidebar/status-display' import type { GitFileStatus } from '../../../../shared/types' import type { OpenFile } from '../../store/slices/editor' @@ -31,6 +35,10 @@ import { getDropIndicatorClasses, type DropIndicator } from './drop-indicator' +import { + canOpenMarkdownPreview, + getMarkdownPreviewShortcutLabel +} from '@/components/editor/markdown-preview-controls' const isMac = navigator.userAgent.includes('Mac') const isLinux = navigator.userAgent.includes('Linux') @@ -41,6 +49,7 @@ const revealLabel = isMac : isLinux ? 'Open Containing Folder' : 'Reveal in File Explorer' +const markdownPreviewShortcutLabel = getMarkdownPreviewShortcutLabel(isMac) export default function EditorFileTab({ file, @@ -82,6 +91,19 @@ export default function EditorFileTab({ const isDiff = file.mode === 'diff' const isConflictReview = file.mode === 'conflict-review' + const isMarkdownPreviewTab = file.mode === 'markdown-preview' + const resolvedLanguage = + file.mode === 'diff' + ? detectLanguage(file.relativePath) + : isConflictReview + ? 'plaintext' + : file.language + const canShowMarkdownPreview = canOpenMarkdownPreview({ + language: resolvedLanguage, + mode: file.mode, + diffSource: file.diffSource + }) + const openMarkdownPreview = useAppStore((s) => s.openMarkdownPreview) const [menuOpen, setMenuOpen] = useState(false) const [menuPoint, setMenuPoint] = useState({ x: 0, y: 0 }) const [isRenaming, setIsRenaming] = useState(false) @@ -214,6 +236,10 @@ export default function EditorFileTab({ + ) : isMarkdownPreviewTab ? ( + ) : ( + {canShowMarkdownPreview && ( + <> + { + onActivate() + openMarkdownPreview({ + filePath: file.filePath, + relativePath: file.relativePath, + worktreeId: file.worktreeId, + language: resolvedLanguage + }) + }} + > + + Open Markdown Preview + {markdownPreviewShortcutLabel} + + + + )} { void window.api.ui.writeClipboardText(file.filePath) diff --git a/src/renderer/src/hooks/useEditorExternalWatch.ts b/src/renderer/src/hooks/useEditorExternalWatch.ts index 18243d87e51..5a2bc9fa416 100644 --- a/src/renderer/src/hooks/useEditorExternalWatch.ts +++ b/src/renderer/src/hooks/useEditorExternalWatch.ts @@ -319,7 +319,7 @@ export function createExternalWatchEventHandler( for (const file of state.openFiles) { if ( file.worktreeId === target.worktreeId && - file.mode === 'edit' && + (file.mode === 'edit' || file.mode === 'markdown-preview') && file.externalMutation && createOrUpdatePaths.has(normalizeAbsolutePath(file.filePath)) ) { @@ -422,7 +422,11 @@ export function getOverflowExternalReloadTargets( const notifications: ExternalWatchNotification[] = [] for (const file of state.openFiles) { - if (file.worktreeId !== target.worktreeId || file.mode !== 'edit' || file.isDirty) { + if ( + file.worktreeId !== target.worktreeId || + (file.mode !== 'edit' && file.mode !== 'markdown-preview') || + file.isDirty + ) { continue } if (file.externalMutation) { @@ -488,7 +492,10 @@ function collectDeletedOpenEditorIds( } const result: string[] = [] for (const file of openFiles) { - if (file.worktreeId !== worktreeId || file.mode !== 'edit') { + if ( + file.worktreeId !== worktreeId || + (file.mode !== 'edit' && file.mode !== 'markdown-preview') + ) { continue } if (deletePaths.has(normalizeAbsolutePath(file.filePath))) { @@ -523,7 +530,10 @@ function hasRenameCorrelatedCreate( const deletedIdSet = new Set(deletedOpenEditorIds) const deletedBasenames = new Set() for (const file of openFiles) { - if (file.worktreeId !== worktreeId || file.mode !== 'edit') { + if ( + file.worktreeId !== worktreeId || + (file.mode !== 'edit' && file.mode !== 'markdown-preview') + ) { continue } if (!deletedIdSet.has(file.id)) { diff --git a/src/renderer/src/lib/rename-file.ts b/src/renderer/src/lib/rename-file.ts index be7286b82af..eef158e65e1 100644 --- a/src/renderer/src/lib/rename-file.ts +++ b/src/renderer/src/lib/rename-file.ts @@ -42,19 +42,32 @@ function remapOpenTabsForRenamedPath(fromPath: string, toPath: string, worktreeP const draft = state.editorDrafts[file.id] const wasDirty = file.isDirty - state.closeFile(oldFilePath) - if (file.mode !== 'edit') { + // Why: preview tabs use a synthetic tab id (`markdown-preview::...`) that + // does not equal filePath. Closing by the real tab id keeps rename/move + // remaps correct for both editable and read-only markdown preview tabs. + state.closeFile(file.id) + if (file.mode === 'edit') { + state.openFile({ + filePath: updatedPath, + relativePath: updatedRelative, + worktreeId: file.worktreeId, + language: detectLanguage(basename(updatedPath)), + mode: 'edit' + }) + } else if (file.mode === 'markdown-preview') { + state.openMarkdownPreview( + { + filePath: updatedPath, + relativePath: updatedRelative, + worktreeId: file.worktreeId, + language: 'markdown' + }, + { anchor: file.markdownPreviewAnchor ?? null } + ) + } else { continue } - state.openFile({ - filePath: updatedPath, - relativePath: updatedRelative, - worktreeId: file.worktreeId, - language: detectLanguage(basename(updatedPath)), - mode: 'edit' - }) - if (draft !== undefined) { state.setEditorDraft(updatedPath, draft) } diff --git a/src/renderer/src/store/slices/editor.test.ts b/src/renderer/src/store/slices/editor.test.ts index 158f5d20e05..de06ea87fb8 100644 --- a/src/renderer/src/store/slices/editor.test.ts +++ b/src/renderer/src/store/slices/editor.test.ts @@ -144,6 +144,67 @@ describe('createEditorSlice markdown view state', () => { }) }) +describe('createEditorSlice openMarkdownPreview', () => { + it('opens markdown preview as a separate read-only tab', () => { + const store = createEditorStore() + + store.getState().openFile({ + filePath: '/repo/docs/README.md', + relativePath: 'docs/README.md', + worktreeId: 'wt-1', + language: 'markdown', + mode: 'edit' + }) + store.getState().openMarkdownPreview({ + filePath: '/repo/docs/README.md', + relativePath: 'docs/README.md', + worktreeId: 'wt-1', + language: 'markdown' + }) + + expect(store.getState().openFiles).toEqual([ + expect.objectContaining({ + id: '/repo/docs/README.md', + mode: 'edit' + }), + expect.objectContaining({ + id: 'markdown-preview::/repo/docs/README.md', + mode: 'markdown-preview', + markdownPreviewSourceFileId: '/repo/docs/README.md' + }) + ]) + expect(store.getState().activeFileId).toBe('markdown-preview::/repo/docs/README.md') + }) + + it('retargets an existing preview tab instead of duplicating it', () => { + const store = createEditorStore() + + store.getState().openMarkdownPreview({ + filePath: '/repo/docs/README.md', + relativePath: 'docs/README.md', + worktreeId: 'wt-1', + language: 'markdown' + }) + store.getState().openMarkdownPreview( + { + filePath: '/repo/docs/README.md', + relativePath: 'docs/README.md', + worktreeId: 'wt-1', + language: 'markdown' + }, + { anchor: 'install' } + ) + + expect(store.getState().openFiles).toEqual([ + expect.objectContaining({ + id: 'markdown-preview::/repo/docs/README.md', + mode: 'markdown-preview', + markdownPreviewAnchor: 'install' + }) + ]) + }) +}) + describe('createEditorSlice pending editor reveal', () => { it('stores the destination file path with the reveal payload', () => { const store = createEditorStore() diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index 555f1d2d6af..e7ce09e7191 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -86,6 +86,15 @@ export type OpenFile = { worktreeId: string language: string isDirty: boolean + /** Why: markdown preview tabs are separate editor tabs that mirror a source + * markdown file's live draft. Storing the source file ID lets the preview + * follow unsaved edits from the normal editor without becoming editable + * itself or conflating the preview tab's identity with the source tab. */ + markdownPreviewSourceFileId?: string + /** Optional hash fragment to reveal when a preview tab is opened from a + * markdown link such as `./guide.md#setup`. Kept on tab state so repeated + * "open preview" actions can retarget an already-open preview tab. */ + markdownPreviewAnchor?: string diffSource?: DiffSource branchCompare?: BranchCompareSnapshot branchOldPath?: string @@ -107,13 +116,13 @@ export type OpenFile = { // a strikethrough label plus a "deleted"/"renamed" suffix. Cleared if the // file reappears on disk at its original path. externalMutation?: 'deleted' | 'renamed' - mode: 'edit' | 'diff' | 'conflict-review' + mode: 'edit' | 'diff' | 'conflict-review' | 'markdown-preview' } export type RightSidebarTab = 'explorer' | 'search' | 'source-control' | 'checks' export type ActivityBarPosition = 'top' | 'side' -export type MarkdownViewMode = 'source' | 'rich' +export type MarkdownViewMode = 'source' | 'rich' | 'preview' /** Enough state to restore a tab via `openFile` after `closeFile` (id is always filePath). */ export type ClosedEditorTabSnapshot = Omit @@ -176,6 +185,10 @@ export type EditorSlice = { rawHref: string | undefined, ctx: { sourceFilePath: string; worktreeId: string; worktreeRoot: string | null } ) => Promise + openMarkdownPreview: ( + file: Pick, + options?: { anchor?: string | null; targetGroupId?: string } + ) => void pinFile: (fileId: string, tabId?: string) => void closeFile: (fileId: string) => void closeAllFiles: () => void @@ -610,6 +623,76 @@ export const createEditorSlice: StateCreator = (s ) }, + openMarkdownPreview: (file, options) => { + const id = `markdown-preview::${file.filePath}` + const anchor = options?.anchor || undefined + set((s) => { + const existing = s.openFiles.find((openFile) => openFile.id === id) + const worktreeId = file.worktreeId + const activeResult = { + activeFileId: id, + activeTabType: 'editor' as const, + activeFileIdByWorktree: { ...s.activeFileIdByWorktree, [worktreeId]: id }, + activeTabTypeByWorktree: { ...s.activeTabTypeByWorktree, [worktreeId]: 'editor' as const } + } + + if (existing) { + const needsUpdate = + existing.relativePath !== file.relativePath || + existing.filePath !== file.filePath || + existing.language !== file.language || + existing.markdownPreviewSourceFileId !== file.filePath || + existing.markdownPreviewAnchor !== anchor || + existing.mode !== 'markdown-preview' + return needsUpdate + ? { + openFiles: s.openFiles.map((openFile) => + openFile.id === id + ? { + ...openFile, + filePath: file.filePath, + relativePath: file.relativePath, + worktreeId: file.worktreeId, + language: file.language, + markdownPreviewSourceFileId: file.filePath, + markdownPreviewAnchor: anchor, + mode: 'markdown-preview' as const + } + : openFile + ), + ...activeResult + } + : activeResult + } + + const newFile: OpenFile = { + id, + filePath: file.filePath, + relativePath: file.relativePath, + worktreeId: file.worktreeId, + language: file.language, + isDirty: false, + markdownPreviewSourceFileId: file.filePath, + markdownPreviewAnchor: anchor, + mode: 'markdown-preview' + } + + return { + openFiles: [...s.openFiles, newFile], + ...activeResult + } + }) + void openWorkspaceEditorItem( + get(), + id, + file.worktreeId, + `${file.relativePath} (preview)`, + 'editor', + false, + options?.targetGroupId + ) + }, + pinFile: (fileId, tabId) => { set((s) => { const file = s.openFiles.find((f) => f.id === fileId) @@ -742,8 +825,9 @@ export const createEditorSlice: StateCreator = (s const wtRecent = closedFile?.worktreeId // Why: untitled files that were never edited will be deleted from disk // after close. Adding them to the reopen stack would let Cmd+Shift+T - // try to reopen a path that no longer exists. - if (closedFile && wtRecent && !shouldDeleteFromDisk) { + // try to reopen a path that no longer exists. Preview tabs are also + // excluded — they are ephemeral views, not user-opened files. + if (closedFile && wtRecent && !shouldDeleteFromDisk && closedFile.mode !== 'markdown-preview') { const { id: _id, isDirty: _dirty, ...snap } = closedFile const stack = s.recentlyClosedEditorTabsByWorktree[wtRecent] ?? [] nextRecentlyClosed = { @@ -904,7 +988,8 @@ export const createEditorSlice: StateCreator = (s for (const f of [...closingFiles].reverse()) { // Why: untitled non-dirty files are deleted from disk after close — // skip them so the reopen stack doesn't reference vanished paths. - if (f.isUntitled && !f.isDirty) { + // Preview tabs are ephemeral views that shouldn't pollute the stack. + if ((f.isUntitled && !f.isDirty) || f.mode === 'markdown-preview') { continue } const { id: _id, isDirty: _dirty, ...snap } = f