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