feat: add markdown preview mode

This commit is contained in:
Yax Patel
2026-04-25 21:43:11 -04:00
committed by Jinwoo-H
parent c871f46ed3
commit 166b0e26c7
25 changed files with 1397 additions and 131 deletions
+5
View File
@@ -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",
+248 -15
View File
@@ -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:
+1
View File
@@ -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';
@@ -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 (
<div className="flex h-full min-h-0 flex-col">
<div className="border-b border-border/60 bg-blue-500/10 px-3 py-2 text-xs text-blue-950 dark:text-blue-100">
File is too large for rich editing. Showing source mode instead.
{richFallbackMessage}
</div>
<div className="min-h-0 flex-1 h-full">{renderMonacoEditor(fc)}</div>
</div>
@@ -198,11 +203,14 @@ export function EditorContent({
}
if (renderMode === 'preview') {
const shouldExplainRichFallback = mdViewMode === 'rich' && richModeUnsupportedMessage
return (
<div className="flex h-full min-h-0 flex-col">
<div className="border-b border-border/60 bg-amber-500/10 px-3 py-2 text-xs text-amber-950 dark:text-amber-100">
{richModeUnsupportedMessage}
</div>
{shouldExplainRichFallback ? (
<div className="border-b border-border/60 bg-amber-500/10 px-3 py-2 text-xs text-amber-950 dark:text-amber-100">
{richModeUnsupportedMessage}
</div>
) : 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 (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
Loading preview...
</div>
)
}
if (fc.isBinary) {
return (
<div className="flex h-full items-center justify-center px-6 text-center text-sm text-muted-foreground">
Markdown preview is unavailable for binary files.
</div>
)
}
const previewSourceFileId = activeFile.markdownPreviewSourceFileId ?? activeFile.filePath
const previewContent = editBuffers[previewSourceFileId] ?? fc.content
return (
<div className="min-h-0 flex-1">
<MarkdownPreview
key={viewStateScopeId}
content={previewContent}
filePath={activeFile.filePath}
scrollCacheKey={markdownPreviewViewStateKey}
initialAnchor={activeFile.markdownPreviewAnchor ?? null}
/>
</div>
)
}
if (activeFile.mode === 'edit') {
if (activeFile.conflict?.kind === 'conflict-placeholder') {
return <ConflictPlaceholderView file={activeFile} />
@@ -348,12 +387,34 @@ export function EditorContent({
</div>
)
}
const modifiedDiffContent = editBuffers[activeFile.id] ?? dc.modifiedContent
if (isMarkdown && mdViewMode === 'preview') {
return (
<div className="flex h-full min-h-0 flex-col">
<div className="border-b border-border/60 bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
{/* 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.
</div>
<div className="min-h-0 flex-1">
<MarkdownPreview
key={viewStateScopeId}
content={modifiedDiffContent}
filePath={activeFile.filePath}
scrollCacheKey={`${diffViewStateKey}:preview`}
/>
</div>
</div>
)
}
return (
<DiffViewer
key={viewStateScopeId}
modelKey={diffViewStateKey}
originalContent={dc.originalContent}
modifiedContent={editBuffers[activeFile.id] ?? dc.modifiedContent}
modifiedContent={modifiedDiffContent}
language={resolvedLanguage}
filePath={activeFile.filePath}
relativePath={activeFile.relativePath}
@@ -18,6 +18,7 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { CLOSE_ALL_CONTEXT_MENUS_EVENT } from '../tab-bar/SortableTab'
@@ -38,6 +39,13 @@ import {
} from './editor-autosave'
import { UntitledFileRenameDialog } from './UntitledFileRenameDialog'
import { exportActiveMarkdownToPdf } from './export-active-markdown'
import {
canOpenMarkdownPreview,
getDefaultMarkdownViewMode,
getMarkdownPreviewShortcutLabel,
getMarkdownViewModes,
isMarkdownPreviewShortcut
} from './markdown-preview-controls'
const isMac = navigator.userAgent.includes('Mac')
const isLinux = navigator.userAgent.includes('Linux')
@@ -48,6 +56,7 @@ const revealLabel = isMac
: isLinux
? 'Open Containing Folder'
: 'Reveal in File Explorer'
const markdownPreviewShortcutLabel = getMarkdownPreviewShortcutLabel(isMac)
type FileContent = {
content: string
@@ -132,6 +141,7 @@ function EditorPanelInner({
const markdownViewMode = useAppStore((s) => 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<Record<string, FileContent>>({})
@@ -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<HTMLDivElement>(null)
const deleteCacheEntriesByPrefix = useCallback(<T,>(cache: Map<string, T>, 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 (
<div className="flex flex-col flex-1 min-w-0 min-h-0">
<div ref={panelRef} className="flex flex-col flex-1 min-w-0 min-h-0">
{!isCombinedDiff && (
<div className="editor-header">
<div className="editor-header-text">
@@ -810,6 +949,23 @@ function EditorPanelInner({
Copy Relative Path
</DropdownMenuItem>
<DropdownMenuSeparator />
{canShowMarkdownPreview && (
<DropdownMenuItem
onSelect={() =>
openMarkdownPreview({
filePath: activeFile.filePath,
relativePath: activeFile.relativePath,
worktreeId: activeFile.worktreeId,
language: resolvedLanguage
})
}
>
<Eye className="w-3.5 h-3.5 mr-1.5" />
Open Markdown Preview
<DropdownMenuShortcut>{markdownPreviewShortcutLabel}</DropdownMenuShortcut>
</DropdownMenuItem>
)}
{canShowMarkdownPreview && <DropdownMenuSeparator />}
<DropdownMenuItem
onSelect={() => {
window.api.shell.openPath(activeFile.filePath)
@@ -884,6 +1040,7 @@ function EditorPanelInner({
{hasViewModeToggle && (
<MarkdownViewToggle
mode={mdViewMode}
modes={markdownViewModes}
onChange={(mode) => setMarkdownViewMode(activeFile.id, mode)}
/>
)}
@@ -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, number>
): 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<string, Worktree[]>,
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<HTMLDivElement>(null)
const bodyRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const matchesRef = useRef<HTMLElement[]>([])
const lastAppliedInitialAnchorRef = useRef<string | null>(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<string, number>()
// 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<HTMLElement>('[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 <CodeBlockCopyButton {...props}>{children}</CodeBlockCopyButton>
},
h1: ({ children, ...props }) => {
const id = createMarkdownPreviewHeadingId(
getMarkdownPreviewNodeText(children),
headingIdCounts
)
return (
<h1 {...props} id={id} tabIndex={-1}>
{children}
</h1>
)
},
h2: ({ children, ...props }) => {
const id = createMarkdownPreviewHeadingId(
getMarkdownPreviewNodeText(children),
headingIdCounts
)
return (
<h2 {...props} id={id} tabIndex={-1}>
{children}
</h2>
)
},
h3: ({ children, ...props }) => {
const id = createMarkdownPreviewHeadingId(
getMarkdownPreviewNodeText(children),
headingIdCounts
)
return (
<h3 {...props} id={id} tabIndex={-1}>
{children}
</h3>
)
},
h4: ({ children, ...props }) => {
const id = createMarkdownPreviewHeadingId(
getMarkdownPreviewNodeText(children),
headingIdCounts
)
return (
<h4 {...props} id={id} tabIndex={-1}>
{children}
</h4>
)
},
h5: ({ children, ...props }) => {
const id = createMarkdownPreviewHeadingId(
getMarkdownPreviewNodeText(children),
headingIdCounts
)
return (
<h5 {...props} id={id} tabIndex={-1}>
{children}
</h5>
)
},
h6: ({ children, ...props }) => {
const id = createMarkdownPreviewHeadingId(
getMarkdownPreviewNodeText(children),
headingIdCounts
)
return (
<h6 {...props} id={id} tabIndex={-1}>
{children}
</h6>
)
}
}
@@ -468,8 +714,18 @@ export default function MarkdownPreview({
)}
<Markdown
components={components}
remarkPlugins={[remarkGfm, remarkFrontmatter]}
rehypePlugins={[rehypeSlug, rehypeHighlight]}
remarkPlugins={[remarkGfm, remarkBreaks, remarkFrontmatter, remarkMath]}
// Why: raw HTML must be sanitized before any trusted renderer expands
// it into richer DOM. Running KaTeX and syntax highlighting after
// sanitize preserves VS Code-style math/code rendering without having
// to whitelist KaTeX's generated markup in the user-content schema.
rehypePlugins={[
rehypeRaw,
[rehypeSanitize, markdownPreviewSanitizeSchema],
rehypeSlug,
rehypeHighlight,
rehypeKatex
]}
>
{renderedContent}
</Markdown>
@@ -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({
}
}}
>
<ToggleGroupItem value="source" aria-label="Source" title="Source">
<Code className="h-2 w-2" />
</ToggleGroupItem>
<ToggleGroupItem value="rich" aria-label="Rich" title="Rich">
<Eye className="h-2 w-2" />
</ToggleGroupItem>
{modes.map((viewMode) => {
const metadata = VIEW_MODE_METADATA[viewMode]
const Icon = metadata.icon
return (
<ToggleGroupItem
key={viewMode}
value={viewMode}
aria-label={metadata.label}
title={metadata.label}
>
<Icon className="h-2 w-2" />
</ToggleGroupItem>
)
})}
</ToggleGroup>
)
}
@@ -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'])
})
})
@@ -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') {
@@ -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> = {}): 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)')
})
})
@@ -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)
}
@@ -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')
})
})
@@ -0,0 +1,52 @@
import type { MarkdownViewMode, OpenFile } from '@/store/slices/editor'
type MarkdownPreviewTarget = Pick<OpenFile, 'mode' | 'diffSource'> & {
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'
}
@@ -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()
})
})
@@ -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
}
@@ -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', () => {
@@ -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'
}
@@ -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
}
@@ -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)
}
@@ -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']
}
]
}
]
@@ -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({
<GitCompareArrows
className={`w-3.5 h-3.5 mr-1.5 shrink-0 ${isActive ? 'text-foreground' : 'text-muted-foreground'}`}
/>
) : isMarkdownPreviewTab ? (
<Eye
className={`w-3.5 h-3.5 mr-1.5 shrink-0 ${isActive ? 'text-foreground' : 'text-muted-foreground'}`}
/>
) : (
<FileCode
className={`w-3.5 h-3.5 mr-1.5 shrink-0 ${isActive ? 'text-foreground' : 'text-muted-foreground'}`}
@@ -337,6 +363,26 @@ export default function EditorFileTab({
Close Tabs To The Right
</DropdownMenuItem>
<DropdownMenuSeparator />
{canShowMarkdownPreview && (
<>
<DropdownMenuItem
onSelect={() => {
onActivate()
openMarkdownPreview({
filePath: file.filePath,
relativePath: file.relativePath,
worktreeId: file.worktreeId,
language: resolvedLanguage
})
}}
>
<Eye className="w-3.5 h-3.5 mr-1.5" />
Open Markdown Preview
<DropdownMenuShortcut>{markdownPreviewShortcutLabel}</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem
onSelect={() => {
void window.api.ui.writeClipboardText(file.filePath)
@@ -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<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 (!deletedIdSet.has(file.id)) {
+23 -10
View File
@@ -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)
}
@@ -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()
+90 -5
View File
@@ -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<OpenFile, 'id' | 'isDirty'>
@@ -176,6 +185,10 @@ export type EditorSlice = {
rawHref: string | undefined,
ctx: { sourceFilePath: string; worktreeId: string; worktreeRoot: string | null }
) => Promise<void>
openMarkdownPreview: (
file: Pick<OpenFile, 'filePath' | 'relativePath' | 'worktreeId' | 'language'>,
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<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (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