diff --git a/package.json b/package.json
index 5cb558143..473bf8ab9 100644
--- a/package.json
+++ b/package.json
@@ -177,6 +177,7 @@
"lint-staged": "^16.4.0",
"lowlight": "^3.3.0",
"lucide-react": "^0.577.0",
+ "marked": "^17.0.1",
"mermaid": "^11.15.0",
"monaco-editor": "^0.55.1",
"oxfmt": "^0.52.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b48ad9b4f..ebb3aa401 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -263,6 +263,9 @@ importers:
lucide-react:
specifier: ^0.577.0
version: 0.577.0(react@19.2.5)
+ marked:
+ specifier: ^17.0.1
+ version: 17.0.6
mermaid:
specifier: ^11.15.0
version: 11.15.0
@@ -399,10 +402,6 @@ packages:
'@antfu/install-pkg@1.1.0':
resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==}
- '@babel/code-frame@7.29.0':
- resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
- engines: {node: '>=6.9.0'}
-
'@babel/code-frame@7.29.7':
resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
engines: {node: '>=6.9.0'}
@@ -415,10 +414,6 @@ packages:
resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==}
engines: {node: '>=6.9.0'}
- '@babel/generator@7.29.1':
- resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==}
- engines: {node: '>=6.9.0'}
-
'@babel/generator@7.29.7':
resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==}
engines: {node: '>=6.9.0'}
@@ -437,10 +432,6 @@ packages:
peerDependencies:
'@babel/core': ^7.0.0
- '@babel/helper-globals@7.28.0':
- resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==}
- engines: {node: '>=6.9.0'}
-
'@babel/helper-globals@7.29.7':
resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==}
engines: {node: '>=6.9.0'}
@@ -449,20 +440,10 @@ packages:
resolution: {integrity: sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==}
engines: {node: '>=6.9.0'}
- '@babel/helper-module-imports@7.28.6':
- resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==}
- engines: {node: '>=6.9.0'}
-
'@babel/helper-module-imports@7.29.7':
resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==}
engines: {node: '>=6.9.0'}
- '@babel/helper-module-transforms@7.28.6':
- resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==}
- engines: {node: '>=6.9.0'}
- peerDependencies:
- '@babel/core': ^7.0.0
-
'@babel/helper-module-transforms@7.29.7':
resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==}
engines: {node: '>=6.9.0'}
@@ -487,26 +468,14 @@ packages:
resolution: {integrity: sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==}
engines: {node: '>=6.9.0'}
- '@babel/helper-string-parser@7.27.1':
- resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
- engines: {node: '>=6.9.0'}
-
'@babel/helper-string-parser@7.29.7':
resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
engines: {node: '>=6.9.0'}
- '@babel/helper-validator-identifier@7.28.5':
- resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
- engines: {node: '>=6.9.0'}
-
'@babel/helper-validator-identifier@7.29.7':
resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
engines: {node: '>=6.9.0'}
- '@babel/helper-validator-option@7.27.1':
- resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==}
- engines: {node: '>=6.9.0'}
-
'@babel/helper-validator-option@7.29.7':
resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==}
engines: {node: '>=6.9.0'}
@@ -515,11 +484,6 @@ packages:
resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==}
engines: {node: '>=6.9.0'}
- '@babel/parser@7.29.3':
- resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==}
- engines: {node: '>=6.0.0'}
- hasBin: true
-
'@babel/parser@7.29.7':
resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==}
engines: {node: '>=6.0.0'}
@@ -577,26 +541,14 @@ packages:
resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
engines: {node: '>=6.9.0'}
- '@babel/template@7.28.6':
- resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==}
- engines: {node: '>=6.9.0'}
-
'@babel/template@7.29.7':
resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==}
engines: {node: '>=6.9.0'}
- '@babel/traverse@7.29.0':
- resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==}
- engines: {node: '>=6.9.0'}
-
'@babel/traverse@7.29.7':
resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==}
engines: {node: '>=6.9.0'}
- '@babel/types@7.29.0':
- resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
- engines: {node: '>=6.9.0'}
-
'@babel/types@7.29.7':
resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==}
engines: {node: '>=6.9.0'}
@@ -3457,6 +3409,7 @@ packages:
boolean@3.2.0:
resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==}
+ deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
brace-expansion@1.1.14:
resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==}
@@ -4354,14 +4307,6 @@ packages:
resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==}
engines: {node: '>=12'}
- fs-extra@11.3.4:
- resolution: {integrity: sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==}
- engines: {node: '>=14.14'}
-
- fs-extra@11.3.5:
- resolution: {integrity: sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==}
- engines: {node: '>=14.14'}
-
fs-extra@11.3.6:
resolution: {integrity: sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==}
engines: {node: '>=14.14'}
@@ -4446,6 +4391,7 @@ packages:
glob@7.2.3:
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
+ deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
global-agent@3.0.0:
resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==}
@@ -4642,6 +4588,7 @@ packages:
inflight@1.0.6:
resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==}
+ deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
@@ -4967,6 +4914,7 @@ packages:
lodash.isequal@4.5.0:
resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==}
+ deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead.
lodash@4.18.1:
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
@@ -5330,6 +5278,7 @@ packages:
node-domexception@1.0.0:
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
engines: {node: '>=10.5.0'}
+ deprecated: Use your platform's native DOMException instead
node-fetch@3.3.2:
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
@@ -6740,12 +6689,6 @@ snapshots:
package-manager-detector: 1.6.0
tinyexec: 1.1.2
- '@babel/code-frame@7.29.0':
- dependencies:
- '@babel/helper-validator-identifier': 7.28.5
- js-tokens: 4.0.0
- picocolors: 1.1.1
-
'@babel/code-frame@7.29.7':
dependencies:
'@babel/helper-validator-identifier': 7.29.7
@@ -6774,14 +6717,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@babel/generator@7.29.1':
- dependencies:
- '@babel/parser': 7.29.3
- '@babel/types': 7.29.0
- '@jridgewell/gen-mapping': 0.3.13
- '@jridgewell/trace-mapping': 0.3.31
- jsesc: 3.1.0
-
'@babel/generator@7.29.7':
dependencies:
'@babel/parser': 7.29.7
@@ -6792,7 +6727,7 @@ snapshots:
'@babel/helper-annotate-as-pure@7.27.3':
dependencies:
- '@babel/types': 7.29.0
+ '@babel/types': 7.29.7
'@babel/helper-compilation-targets@7.29.7':
dependencies:
@@ -6810,26 +6745,17 @@ snapshots:
'@babel/helper-optimise-call-expression': 7.27.1
'@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.7)
'@babel/helper-skip-transparent-expression-wrappers': 7.27.1
- '@babel/traverse': 7.29.0
+ '@babel/traverse': 7.29.7
semver: 6.3.1
transitivePeerDependencies:
- supports-color
- '@babel/helper-globals@7.28.0': {}
-
'@babel/helper-globals@7.29.7': {}
'@babel/helper-member-expression-to-functions@7.28.5':
dependencies:
- '@babel/traverse': 7.29.0
- '@babel/types': 7.29.0
- transitivePeerDependencies:
- - supports-color
-
- '@babel/helper-module-imports@7.28.6':
- dependencies:
- '@babel/traverse': 7.29.0
- '@babel/types': 7.29.0
+ '@babel/traverse': 7.29.7
+ '@babel/types': 7.29.7
transitivePeerDependencies:
- supports-color
@@ -6840,15 +6766,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.7)':
- dependencies:
- '@babel/core': 7.29.7
- '@babel/helper-module-imports': 7.28.6
- '@babel/helper-validator-identifier': 7.28.5
- '@babel/traverse': 7.29.0
- transitivePeerDependencies:
- - supports-color
-
'@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)':
dependencies:
'@babel/core': 7.29.7
@@ -6860,7 +6777,7 @@ snapshots:
'@babel/helper-optimise-call-expression@7.27.1':
dependencies:
- '@babel/types': 7.29.0
+ '@babel/types': 7.29.7
'@babel/helper-plugin-utils@7.28.6': {}
@@ -6869,27 +6786,21 @@ snapshots:
'@babel/core': 7.29.7
'@babel/helper-member-expression-to-functions': 7.28.5
'@babel/helper-optimise-call-expression': 7.27.1
- '@babel/traverse': 7.29.0
+ '@babel/traverse': 7.29.7
transitivePeerDependencies:
- supports-color
'@babel/helper-skip-transparent-expression-wrappers@7.27.1':
dependencies:
- '@babel/traverse': 7.29.0
- '@babel/types': 7.29.0
+ '@babel/traverse': 7.29.7
+ '@babel/types': 7.29.7
transitivePeerDependencies:
- supports-color
- '@babel/helper-string-parser@7.27.1': {}
-
'@babel/helper-string-parser@7.29.7': {}
- '@babel/helper-validator-identifier@7.28.5': {}
-
'@babel/helper-validator-identifier@7.29.7': {}
- '@babel/helper-validator-option@7.27.1': {}
-
'@babel/helper-validator-option@7.29.7': {}
'@babel/helpers@7.29.7':
@@ -6897,10 +6808,6 @@ snapshots:
'@babel/template': 7.29.7
'@babel/types': 7.29.7
- '@babel/parser@7.29.3':
- dependencies:
- '@babel/types': 7.29.0
-
'@babel/parser@7.29.7':
dependencies:
'@babel/types': 7.29.7
@@ -6923,7 +6830,7 @@ snapshots:
'@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.29.7)':
dependencies:
'@babel/core': 7.29.7
- '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.7)
+ '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7)
'@babel/helper-plugin-utils': 7.28.6
transitivePeerDependencies:
- supports-color
@@ -6953,7 +6860,7 @@ snapshots:
dependencies:
'@babel/core': 7.29.7
'@babel/helper-plugin-utils': 7.28.6
- '@babel/helper-validator-option': 7.27.1
+ '@babel/helper-validator-option': 7.29.7
'@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.7)
'@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.7)
'@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.7)
@@ -6962,30 +6869,12 @@ snapshots:
'@babel/runtime@7.29.7': {}
- '@babel/template@7.28.6':
- dependencies:
- '@babel/code-frame': 7.29.0
- '@babel/parser': 7.29.3
- '@babel/types': 7.29.0
-
'@babel/template@7.29.7':
dependencies:
'@babel/code-frame': 7.29.7
'@babel/parser': 7.29.7
'@babel/types': 7.29.7
- '@babel/traverse@7.29.0':
- dependencies:
- '@babel/code-frame': 7.29.0
- '@babel/generator': 7.29.1
- '@babel/helper-globals': 7.28.0
- '@babel/parser': 7.29.3
- '@babel/template': 7.28.6
- '@babel/types': 7.29.0
- debug: 4.4.3
- transitivePeerDependencies:
- - supports-color
-
'@babel/traverse@7.29.7':
dependencies:
'@babel/code-frame': 7.29.7
@@ -6998,11 +6887,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@babel/types@7.29.0':
- dependencies:
- '@babel/helper-string-parser': 7.27.1
- '@babel/helper-validator-identifier': 7.28.5
-
'@babel/types@7.29.7':
dependencies:
'@babel/helper-string-parser': 7.29.7
@@ -7127,7 +7011,7 @@ snapshots:
fs-extra: 10.1.0
isbinaryfile: 4.0.10
minimist: 1.2.8
- plist: 3.1.0
+ plist: 3.1.1
transitivePeerDependencies:
- supports-color
@@ -7148,9 +7032,9 @@ snapshots:
'@malept/cross-spawn-promise': 2.0.0
debug: 4.4.3
dir-compare: 4.2.0
- fs-extra: 11.3.4
+ fs-extra: 11.3.6
minimatch: 9.0.9
- plist: 3.1.0
+ plist: 3.1.1
transitivePeerDependencies:
- supports-color
@@ -8992,24 +8876,24 @@ snapshots:
'@types/babel__core@7.20.5':
dependencies:
- '@babel/parser': 7.29.3
- '@babel/types': 7.29.0
+ '@babel/parser': 7.29.7
+ '@babel/types': 7.29.7
'@types/babel__generator': 7.27.0
'@types/babel__template': 7.4.4
'@types/babel__traverse': 7.28.0
'@types/babel__generator@7.27.0':
dependencies:
- '@babel/types': 7.29.0
+ '@babel/types': 7.29.7
'@types/babel__template@7.4.4':
dependencies:
- '@babel/parser': 7.29.3
- '@babel/types': 7.29.0
+ '@babel/parser': 7.29.7
+ '@babel/types': 7.29.7
'@types/babel__traverse@7.28.0':
dependencies:
- '@babel/types': 7.29.0
+ '@babel/types': 7.29.7
'@types/cacheable-request@6.0.3':
dependencies:
@@ -9369,8 +9253,7 @@ snapshots:
'@xmldom/xmldom@0.8.13': {}
- '@xmldom/xmldom@0.9.10':
- optional: true
+ '@xmldom/xmldom@0.9.10': {}
'@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287)':
dependencies:
@@ -10588,24 +10471,11 @@ snapshots:
jsonfile: 6.2.1
universalify: 2.0.1
- fs-extra@11.3.4:
- dependencies:
- graceful-fs: 4.2.11
- jsonfile: 6.2.1
- universalify: 2.0.1
-
- fs-extra@11.3.5:
- dependencies:
- graceful-fs: 4.2.11
- jsonfile: 6.2.1
- universalify: 2.0.1
-
fs-extra@11.3.6:
dependencies:
graceful-fs: 4.2.11
jsonfile: 6.2.1
universalify: 2.0.1
- optional: true
fs-extra@7.0.1:
dependencies:
@@ -12019,7 +11889,7 @@ snapshots:
parse-json@5.2.0:
dependencies:
- '@babel/code-frame': 7.29.0
+ '@babel/code-frame': 7.29.7
error-ex: 1.3.4
json-parse-even-better-errors: 2.3.1
lines-and-columns: 1.2.4
@@ -12089,7 +11959,6 @@ snapshots:
'@xmldom/xmldom': 0.9.10
base64-js: 1.5.1
xmlbuilder: 15.1.1
- optional: true
pngjs@5.0.0: {}
@@ -12689,7 +12558,7 @@ snapshots:
shadcn@4.7.0(@types/node@25.6.0)(typescript@7.0.2):
dependencies:
'@babel/core': 7.29.7
- '@babel/parser': 7.29.3
+ '@babel/parser': 7.29.7
'@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.7)
'@babel/preset-typescript': 7.28.5(@babel/core@7.29.7)
'@dotenvx/dotenvx': 1.66.0
@@ -12703,7 +12572,7 @@ snapshots:
diff: 8.0.4
execa: 9.6.1
fast-glob: 3.3.3
- fs-extra: 11.3.5
+ fs-extra: 11.3.6
fuzzysort: 3.1.0
https-proxy-agent: 7.0.6
kleur: 4.1.5
@@ -12788,7 +12657,7 @@ snapshots:
simple-update-notifier@2.0.0:
dependencies:
- semver: 7.7.4
+ semver: 7.8.1
sisteransi@1.0.5: {}
diff --git a/src/renderer/src/assets/rich-markdown-editor.css b/src/renderer/src/assets/rich-markdown-editor.css
index ccf79ad36..38c625f0d 100644
--- a/src/renderer/src/assets/rich-markdown-editor.css
+++ b/src/renderer/src/assets/rich-markdown-editor.css
@@ -1026,6 +1026,27 @@
color: #58a6ff;
}
+.rich-markdown-editor sup[data-rich-markdown-html-superscript-link] {
+ line-height: 0;
+}
+
+.rich-markdown-editor .rich-markdown-html-superscript-link {
+ color: #0969da;
+ text-decoration: underline;
+ text-decoration-color: color-mix(in srgb, currentColor 40%, transparent);
+ text-underline-offset: 2px;
+}
+
+.dark .rich-markdown-editor .rich-markdown-html-superscript-link {
+ color: #58a6ff;
+}
+
+.rich-markdown-mod-held
+ .rich-markdown-editor
+ .rich-markdown-html-superscript-link[data-actionable] {
+ cursor: pointer;
+}
+
.rich-markdown-editor img {
max-width: 100%;
border-radius: 8px;
diff --git a/src/renderer/src/components/LinearIssueMarkdownDescriptionEditor.tsx b/src/renderer/src/components/LinearIssueMarkdownDescriptionEditor.tsx
index 5e45c6e71..f8f11c956 100644
--- a/src/renderer/src/components/LinearIssueMarkdownDescriptionEditor.tsx
+++ b/src/renderer/src/components/LinearIssueMarkdownDescriptionEditor.tsx
@@ -7,6 +7,10 @@ import { LoaderCircle } from 'lucide-react'
import { createRichMarkdownExtensions } from '@/components/editor/rich-markdown-extensions'
import { encodeRawMarkdownHtmlForRichEditor } from '@/components/editor/raw-markdown-html'
+import {
+ createRichMarkdownEditorCodec,
+ type RichMarkdownEditorCodec
+} from '@/components/editor/rich-markdown-source-transport'
import { LinearIssueMarkdownToolbar } from '@/components/LinearIssueMarkdownToolbar'
import { isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut'
import { cn } from '@/lib/utils'
@@ -26,8 +30,8 @@ type LinearIssueMarkdownDescriptionEditorProps = {
submitShortcutLabel: string
}
-function createLinearIssueMarkdownExtensions() {
- const extensions = createRichMarkdownExtensions()
+function createLinearIssueMarkdownExtensions(codec: RichMarkdownEditorCodec) {
+ const extensions = createRichMarkdownExtensions({ codec })
return [
...extensions,
Placeholder.configure({
@@ -54,18 +58,24 @@ export function LinearIssueMarkdownDescriptionEditor({
const richMarkdownSpellcheckEnabled = useAppStore(
(s) => s.settings?.richMarkdownSpellcheckEnabled ?? true
)
+ // Why: changing language recreates Tiptap and re-registers tokenizers, so it
+ // must also receive a fresh private Marked registry instead of growing one.
+ const codec = useMemo(() => {
+ void language
+ return createRichMarkdownEditorCodec()
+ }, [language])
const linearIssueMarkdownExtensions = useMemo(() => {
// Why: Tiptap freezes extension options when the editor is created; the
// language value is the recreation key for translated extension options.
void language
- return createLinearIssueMarkdownExtensions()
- }, [language])
+ return createLinearIssueMarkdownExtensions(codec)
+ }, [codec, language])
const editor = useEditor(
{
immediatelyRender: false,
extensions: linearIssueMarkdownExtensions,
- content: encodeRawMarkdownHtmlForRichEditor(value),
+ content: encodeRawMarkdownHtmlForRichEditor(value, codec),
contentType: 'markdown',
editable: !disabled,
editorProps: {
@@ -99,7 +109,7 @@ export function LinearIssueMarkdownDescriptionEditor({
onChange(nextValue)
}
},
- [language]
+ [codec, language]
)
useRichMarkdownSpellcheckAttribute(editor, richMarkdownSpellcheckEnabled)
@@ -124,12 +134,12 @@ export function LinearIssueMarkdownDescriptionEditor({
// Why: Linear remains the source of truth when the selected issue changes
// or an optimistic save is reverted; keep the rich view aligned with it.
- editor.commands.setContent(encodeRawMarkdownHtmlForRichEditor(value), {
+ editor.commands.setContent(encodeRawMarkdownHtmlForRichEditor(value, codec), {
contentType: 'markdown',
emitUpdate: false
})
lastEditorMarkdownRef.current = value
- }, [editor, value])
+ }, [codec, editor, value])
return (
true)
+const connectionOwner = vi.hoisted(() => ({ value: null as string | null | undefined }))
+const targetConnectionOwners = vi.hoisted(() => new Map
())
+const worktreeLookup = vi.hoisted(() => ({
+ value: [] as { id: string; path: string; diffComments: never[] }[]
+}))
+const statRuntimePathMock = vi.hoisted(() => vi.fn(async () => ({ isDirectory: false })))
// Minimal store: MarkdownPreview reads settings/worktreesByRepo plus a handful
// of action functions. None of the actions fire on the http path under test.
@@ -31,6 +39,9 @@ const storeState = {
clearDeliveredDiffComments: vi.fn(),
keybindings: {},
worktreesByRepo: {},
+ repos: [],
+ folderWorkspaces: [],
+ projectGroups: [],
openFiles: [],
activeFileIdByWorktree: {},
settings: { openLinksInApp: true },
@@ -44,14 +55,22 @@ vi.mock('@/store', () => {
)
return { useAppStore }
})
-vi.mock('@/store/slices/worktree-helpers', () => ({ findWorktreeById: () => null }))
+vi.mock('@/store/slices/worktree-helpers', () => ({
+ findWorktreeById: (_worktrees: unknown, id: string) =>
+ worktreeLookup.value.find((worktree) => worktree.id === id) ?? null
+}))
vi.mock('@/runtime/runtime-rpc-client', () => ({
settingsForRuntimeOwner: (settings: unknown) => settings
}))
vi.mock('@/runtime/runtime-file-client', () => ({
- statRuntimePath: vi.fn(async () => ({ isDirectory: false }))
+ statRuntimePath: statRuntimePathMock
+}))
+vi.mock('@/lib/connection-context', () => ({
+ getConnectionIdForFile: (worktreeId: string) => targetConnectionOwners.get(worktreeId)
+}))
+vi.mock('@/lib/connection-owner-resolution', () => ({
+ createConnectionIdForFileSelector: () => () => connectionOwner.value
}))
-vi.mock('@/lib/connection-context', () => ({ getConnectionId: () => null }))
vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
vi.mock('./useLocalImageSrc', () => ({ useLocalImageSrc: (src?: string) => src }))
vi.mock('./MermaidBlock', () => ({ default: () => null }))
@@ -77,8 +96,8 @@ describe('MarkdownPreview http link routing (Cmd vs Cmd+Shift click)', () => {
;(window as unknown as { api: unknown }).api = {
shell: {
openUrl: openUrlMock,
- openFileUri: vi.fn(),
- pathExists: vi.fn(async () => true)
+ openFileUri: openFileUriMock,
+ pathExists: pathExistsMock
},
ui: { writeClipboardText: vi.fn(async () => true) }
}
@@ -91,6 +110,14 @@ describe('MarkdownPreview http link routing (Cmd vs Cmd+Shift click)', () => {
createBrowserTabMock.mockClear()
setActiveWorktreeMock.mockClear()
openUrlMock.mockClear()
+ openFileUriMock.mockClear()
+ pathExistsMock.mockClear()
+ connectionOwner.value = null
+ targetConnectionOwners.clear()
+ worktreeLookup.value = []
+ storeState.worktreesByRepo = {}
+ storeState.openMarkdownPreview.mockClear()
+ statRuntimePathMock.mockClear()
})
afterEach(() => {
@@ -100,21 +127,28 @@ describe('MarkdownPreview http link routing (Cmd vs Cmd+Shift click)', () => {
container.remove()
})
- function render(): HTMLAnchorElement {
+ function render(
+ content = '[example](https://example.com)',
+ expectedHref = 'https://example.com',
+ sourceWorktreeId = 'wt-1',
+ filePath = '/repo/docs/README.md'
+ ): HTMLAnchorElement {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
act(() => {
root.render(
)
})
- const anchor = container.querySelector('a[href="https://example.com"]')
+ const anchor = Array.from(container.querySelectorAll('a')).find(
+ (candidate) => candidate.getAttribute('href') === expectedHref
+ )
if (!anchor) {
throw new Error('expected a rendered http anchor')
}
@@ -144,4 +178,59 @@ describe('MarkdownPreview http link routing (Cmd vs Cmd+Shift click)', () => {
expect(openUrlMock).toHaveBeenCalledWith('https://example.com/')
expect(createBrowserTabMock).not.toHaveBeenCalled()
})
+
+ it('keeps system-browser HTTP and file links inert while ownership is unknown', () => {
+ connectionOwner.value = undefined
+ const httpAnchor = render()
+ click(httpAnchor, { metaKey: true, shiftKey: true })
+ expect(openUrlMock).not.toHaveBeenCalled()
+ expect(createBrowserTabMock).not.toHaveBeenCalled()
+
+ act(() => {
+ root.unmount()
+ })
+ container.remove()
+ const fileAnchor = render('[file](file:///tmp/example.md)', 'file:///tmp/example.md')
+ click(fileAnchor, { metaKey: true, shiftKey: true })
+ expect(pathExistsMock).not.toHaveBeenCalled()
+ expect(openFileUriMock).not.toHaveBeenCalled()
+ })
+
+ it('keeps same-path file links on the explicit SSH source worktree', async () => {
+ const localWorktree = { id: 'local-wt', path: '/srv/repo', diffComments: [] as never[] }
+ const sshWorktree = { id: 'ssh-wt', path: '/srv/repo', diffComments: [] as never[] }
+ worktreeLookup.value = [localWorktree, sshWorktree]
+ storeState.worktreesByRepo = { repo: [localWorktree, sshWorktree] }
+ connectionOwner.value = 'ssh-1'
+ targetConnectionOwners.set('local-wt', null)
+ targetConnectionOwners.set('ssh-wt', 'ssh-1')
+ const anchor = render('[child](child.md)', 'child.md', 'ssh-wt', '/srv/repo/README.md')
+
+ await act(async () => {
+ anchor.dispatchEvent(new window.MouseEvent('click', { bubbles: true, cancelable: true }))
+ })
+
+ expect(statRuntimePathMock).toHaveBeenCalledWith(
+ expect.objectContaining({ worktreeId: 'ssh-wt', connectionId: 'ssh-1' }),
+ '/srv/repo/child.md'
+ )
+ expect(storeState.openMarkdownPreview).toHaveBeenCalledWith(
+ expect.objectContaining({ worktreeId: 'ssh-wt', filePath: '/srv/repo/child.md' }),
+ { anchor: null }
+ )
+ })
+
+ it('renders a raw HTML superscript citation and routes it like a Markdown link', () => {
+ const source = '[12]'
+ const anchor = render(source)
+
+ expect(anchor.parentElement?.tagName).toBe('SUP')
+ expect(anchor.textContent).toBe('[12]')
+ expect(container.textContent).not.toContain('')
+ click(anchor, { metaKey: true })
+ expect(createBrowserTabMock).toHaveBeenCalledWith('wt-1', 'https://example.com/', {
+ activate: true
+ })
+ expect(openUrlMock).not.toHaveBeenCalled()
+ })
})
diff --git a/src/renderer/src/components/editor/MarkdownPreview.toc-visibility-gate.test.tsx b/src/renderer/src/components/editor/MarkdownPreview.toc-visibility-gate.test.tsx
index 46301826f..23f66fa9e 100644
--- a/src/renderer/src/components/editor/MarkdownPreview.toc-visibility-gate.test.tsx
+++ b/src/renderer/src/components/editor/MarkdownPreview.toc-visibility-gate.test.tsx
@@ -28,6 +28,9 @@ const storeState = {
clearDeliveredDiffComments: vi.fn(),
keybindings: {},
worktreesByRepo: {},
+ repos: [],
+ folderWorkspaces: [],
+ projectGroups: [],
openFiles: [],
activeFileIdByWorktree: {},
settings: { openLinksInApp: true },
@@ -48,7 +51,10 @@ vi.mock('@/runtime/runtime-rpc-client', () => ({
vi.mock('@/runtime/runtime-file-client', () => ({
statRuntimePath: vi.fn(async () => ({ isDirectory: false }))
}))
-vi.mock('@/lib/connection-context', () => ({ getConnectionId: () => null }))
+vi.mock('@/lib/connection-context', () => ({ getConnectionIdForFile: () => null }))
+vi.mock('@/lib/connection-owner-resolution', () => ({
+ createConnectionIdForFileSelector: () => () => null
+}))
vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
vi.mock('./useLocalImageSrc', () => ({ useLocalImageSrc: (src?: string) => src }))
vi.mock('./MermaidBlock', () => ({ default: () => null }))
diff --git a/src/renderer/src/components/editor/MarkdownPreview.tsx b/src/renderer/src/components/editor/MarkdownPreview.tsx
index b77aaa33f..38d146c13 100644
--- a/src/renderer/src/components/editor/MarkdownPreview.tsx
+++ b/src/renderer/src/components/editor/MarkdownPreview.tsx
@@ -38,7 +38,8 @@ import { Input } from '@/components/ui/input'
import { useAppStore } from '@/store'
import { toast } from 'sonner'
import { computeEditorFontSize } from '@/lib/editor-font-zoom'
-import { getConnectionId } from '@/lib/connection-context'
+import { getConnectionIdForFile } from '@/lib/connection-context'
+import { createConnectionIdForFileSelector } from '@/lib/connection-owner-resolution'
import { scrollTopCache, setWithLRU } from '@/lib/scroll-cache'
import { detectLanguage } from '@/lib/language-detect'
import type { DiffComment, MarkdownDocument, Worktree } from '../../../../shared/types'
@@ -68,7 +69,7 @@ import {
setActiveMarkdownPreviewSearchMatch
} from './markdown-preview-search'
import { usePreserveSectionDuringExternalEdit } from './usePreserveSectionDuringExternalEdit'
-import { openHttpLink } from '@/lib/http-link-routing'
+import { openHttpLink, type HttpLinkSourceOwner } from '@/lib/http-link-routing'
import { getShortcutPlatform } from '@/lib/shortcut-platform'
import { isLocalPathOpenBlocked, showLocalPathOpenBlockedToast } from '@/lib/local-path-open-guard'
import { markdownPreviewUrlTransform } from './markdown-preview-url-transform'
@@ -390,14 +391,18 @@ export function deriveMarkdownPreviewSourceRoot(
function findWorktreeForMarkdownPreviewPath(
worktreesByRepo: Record,
- absolutePath: string
+ absolutePath: string,
+ acceptsWorktree: (worktree: Worktree) => boolean = () => true
): Worktree | null {
let bestMatch: Worktree | null = null
let bestMatchLength = -1
for (const worktrees of Object.values(worktreesByRepo)) {
for (const worktree of worktrees) {
- if (relativePathInsideRoot(worktree.path, absolutePath) !== null) {
+ if (
+ acceptsWorktree(worktree) &&
+ relativePathInsideRoot(worktree.path, absolutePath) !== null
+ ) {
const normalizedWorktreePathLength = normalizeMarkdownPreviewAbsolutePath(
worktree.path
).length
@@ -412,6 +417,27 @@ function findWorktreeForMarkdownPreviewPath(
return bestMatch
}
+function findMarkdownPreviewTargetWorktree(
+ worktreesByRepo: Record,
+ absolutePath: string,
+ sourceWorktree: Worktree | null,
+ sourceOwner: HttpLinkSourceOwner
+): Worktree | null {
+ if (sourceWorktree && relativePathInsideRoot(sourceWorktree.path, absolutePath) !== null) {
+ return sourceWorktree
+ }
+ return findWorktreeForMarkdownPreviewPath(worktreesByRepo, absolutePath, (worktree) => {
+ const connectionId = getConnectionIdForFile(worktree.id, absolutePath)
+ if (sourceOwner.kind === 'local') {
+ return connectionId === null
+ }
+ if (sourceOwner.kind === 'ssh') {
+ return connectionId === sourceOwner.connectionId
+ }
+ return false
+ })
+}
+
export function resolveMarkdownPreviewSourceWorktree(
worktreesByRepo: Record,
sourceWorktreeId: string | null | undefined,
@@ -498,9 +524,26 @@ export default function MarkdownPreview({
)
const allDiffComments = sourceWorktree?.diffComments
const sourceRoutingWorktreeId = sourceWorktree?.id ?? resolvedSourceWorktreeId
- const sourceConnectionId = sourceRoutingWorktreeId
- ? (getConnectionId(sourceRoutingWorktreeId) ?? null)
- : null
+ const runtimeOwnerId = resolvedSourceRuntimeEnvironmentId?.trim()
+ const sourceConnectionIdSelector = useMemo(
+ () =>
+ createConnectionIdForFileSelector(sourceRoutingWorktreeId, filePath, {
+ skip: Boolean(runtimeOwnerId)
+ }),
+ [filePath, runtimeOwnerId, sourceRoutingWorktreeId]
+ )
+ const sourceConnectionId = useAppStore(sourceConnectionIdSelector)
+ const sourceOwner = useMemo(
+ () =>
+ runtimeOwnerId
+ ? { kind: 'runtime', runtimeEnvironmentId: runtimeOwnerId }
+ : sourceConnectionId === undefined
+ ? { kind: 'unknown' }
+ : sourceConnectionId === null
+ ? { kind: 'local' }
+ : { kind: 'ssh', connectionId: sourceConnectionId },
+ [runtimeOwnerId, sourceConnectionId]
+ )
const worktreeRoot =
sourceWorktree?.path ??
(sourceRoutingWorktreeId
@@ -1254,6 +1297,9 @@ export default function MarkdownPreview({
// dangling in-worktree .md, pre-check existence so the user sees a
// toast instead of the silent no-op from shell.openFileUri.
if (isMarkdownPreviewSystemBrowserModifier(event, isMac)) {
+ if (sourceOwner.kind === 'unknown') {
+ return
+ }
const osTarget = getMarkdownPreviewLinkTarget(href, filePath)
if (!osTarget) {
return
@@ -1267,7 +1313,12 @@ export default function MarkdownPreview({
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
openHttpLink(
parsed.toString(),
- resolveMarkdownPreviewHttpOpenOptions(event, isMac, sourceRoutingWorktreeId)
+ resolveMarkdownPreviewHttpOpenOptions(
+ event,
+ isMac,
+ sourceRoutingWorktreeId,
+ sourceOwner
+ )
)
return
}
@@ -1326,7 +1377,12 @@ export default function MarkdownPreview({
// handled above; this path only sees non-escape-hatch clicks.)
openHttpLink(
target.toString(),
- resolveMarkdownPreviewHttpOpenOptions(event, isMac, sourceRoutingWorktreeId)
+ resolveMarkdownPreviewHttpOpenOptions(
+ event,
+ isMac,
+ sourceRoutingWorktreeId,
+ sourceOwner
+ )
)
return
}
@@ -1347,12 +1403,25 @@ export default function MarkdownPreview({
? { line: classifiedFileTarget.line, column: classifiedFileTarget.column }
: parseLineTarget(target.hash)
+ // Why: same-file anchors need no ownership/filesystem resolution (e.g.
+ // `./README.md#heading` when this file is README.md). Run before the
+ // unknown-ownership guard so ambiguous folder-workspace ownership still
+ // scrolls within the open document.
if (absolutePath === filePath && target.hash && !lineTarget) {
void scrollToAnchor(target.hash.slice(1))
return
}
- const targetWorktree = findWorktreeForMarkdownPreviewPath(worktreesByRepo, absolutePath)
+ if (sourceOwner.kind === 'unknown') {
+ return
+ }
+
+ const targetWorktree = findMarkdownPreviewTargetWorktree(
+ worktreesByRepo,
+ absolutePath,
+ sourceWorktree,
+ sourceOwner
+ )
if (!targetWorktree) {
if (sourceRoutingWorktreeId && worktreeRoot) {
// Why: floating markdown files are owned by a synthetic workspace,
@@ -1362,7 +1431,8 @@ export default function MarkdownPreview({
sourceFilePath: filePath,
worktreeId: sourceRoutingWorktreeId,
worktreeRoot,
- runtimeEnvironmentId: resolvedSourceRuntimeEnvironmentId
+ runtimeEnvironmentId: resolvedSourceRuntimeEnvironmentId,
+ sourceOwner
})
return
}
@@ -1384,8 +1454,15 @@ export default function MarkdownPreview({
return
}
- const relativePath = absolutePath.slice(targetWorktree.path.length + 1)
+ const relativePath = relativePathInsideRoot(targetWorktree.path, absolutePath)
+ if (relativePath === null) {
+ return
+ }
const language = detectLanguage(absolutePath)
+ const targetConnectionId = getConnectionIdForFile(targetWorktree.id, absolutePath)
+ if (targetConnectionId === undefined) {
+ return
+ }
try {
const stats = await statRuntimePath(
{
@@ -1395,7 +1472,7 @@ export default function MarkdownPreview({
),
worktreeId: targetWorktree.id,
worktreePath: targetWorktree.path,
- connectionId: getConnectionId(targetWorktree.id) ?? undefined
+ connectionId: targetConnectionId ?? undefined
},
absolutePath
)
@@ -1512,7 +1589,8 @@ export default function MarkdownPreview({
sourceFilePath: filePath,
worktreeId: sourceRoutingWorktreeId,
worktreeRoot,
- runtimeEnvironmentId: resolvedSourceRuntimeEnvironmentId
+ runtimeEnvironmentId: resolvedSourceRuntimeEnvironmentId,
+ sourceOwner
})
}
@@ -1673,6 +1751,8 @@ export default function MarkdownPreview({
setMarkdownViewMode,
setPendingEditorReveal,
sourceConnectionId,
+ sourceOwner,
+ sourceWorktree,
resolvedSourceRuntimeEnvironmentId,
sourceRoutingWorktreeId,
worktreeRoot,
diff --git a/src/renderer/src/components/editor/RichMarkdownEditor.tsx b/src/renderer/src/components/editor/RichMarkdownEditor.tsx
index c59540529..1c0cb2734 100644
--- a/src/renderer/src/components/editor/RichMarkdownEditor.tsx
+++ b/src/renderer/src/components/editor/RichMarkdownEditor.tsx
@@ -1,9 +1,8 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
-import type { Editor } from '@tiptap/react'
+import { useEditorState, type Editor } from '@tiptap/react'
import type { DiffComment, MarkdownDocument } from '../../../../shared/types'
import { useAppStore } from '@/store'
import { selectWorktreeDiffComments } from '@/store/worktree-diff-comments-selector'
-import { getIndexedWorktreeById } from '@/store/worktree-repo-index'
import { useLocalImagePick } from './useLocalImagePick'
import { useRichMarkdownSearch } from './useRichMarkdownSearch'
import type { LinkBubbleState } from './RichMarkdownLinkBubble'
@@ -25,6 +24,11 @@ import {
runRichMarkdownContextCommand
} from './rich-markdown-context-command-routing'
import { useRichMarkdownSpellcheckAttribute } from './rich-markdown-spellcheck'
+import { useRichMarkdownSuperscriptLinkSetup } from './useRichMarkdownSuperscriptLinkSetup'
+import {
+ formatSelectedHtmlSuperscriptLinkStatus,
+ getSelectedHtmlSuperscriptLinkStatus
+} from './rich-markdown-selected-link-actions'
type RichMarkdownEditorProps = {
fileId: string
@@ -86,9 +90,11 @@ export default function RichMarkdownEditor({
const allDiffComments = useAppStore((s): DiffComment[] | undefined =>
selectWorktreeDiffComments(s, worktreeId)
)
- const worktreeRoot = useAppStore(
- (s) => getIndexedWorktreeById(s.worktreesByRepo, worktreeId)?.path ?? null
- )
+ const { codec, htmlSuperscriptLinkContext, worktreeRoot } = useRichMarkdownSuperscriptLinkSetup({
+ filePath,
+ runtimeEnvironmentId,
+ worktreeId
+ })
const scrollContainerRef = useRef(null)
const menu = useRichMarkdownMenuController({ markdownDocuments })
const isMac = navigator.userAgent.includes('Mac')
@@ -196,6 +202,8 @@ export default function RichMarkdownEditor({
)
const editor = useRichMarkdownEditorInstance({
+ codec,
+ htmlSuperscriptLinkContext,
content,
filePath,
worktreeId,
@@ -240,6 +248,13 @@ export default function RichMarkdownEditor({
setSlashMenu: menu.setSlashMenu,
setDocLinkMenu: menu.setDocLinkMenu
})
+ // Why: useEditor defaults shouldRerenderOnTransaction to false, so selection-only
+ // citation NodeSelections would leave aria status stale without useEditorState.
+ const selectedCitationStatus = useEditorState({
+ editor,
+ selector: (snapshot) =>
+ getSelectedHtmlSuperscriptLinkStatus(snapshot.editor, htmlSuperscriptLinkContext)
+ })
useRichMarkdownSpellcheckAttribute(editor, richMarkdownSpellcheckEnabled)
// Why: use useLayoutEffect (synchronous cleanup) so the pending serialization
@@ -265,6 +280,7 @@ export default function RichMarkdownEditor({
})
useRichMarkdownProgrammaticSync({
+ codec,
content,
docLinkMenuSetter: menu.setDocLinkMenu,
editor,
@@ -289,12 +305,14 @@ export default function RichMarkdownEditor({
handleLinkRemove,
handleLinkEditCancel,
handleLinkOpen,
+ handleLinkCopy,
toggleLinkFromToolbar
} = useLinkBubble(editor, rootRef, linkBubble, setLinkBubble, setIsEditingLink, {
sourceFilePath: filePath,
worktreeId,
worktreeRoot,
- runtimeEnvironmentId
+ runtimeEnvironmentId,
+ htmlSuperscriptLinkContext
})
useEffect(() => {
@@ -370,6 +388,12 @@ export default function RichMarkdownEditor({
showTableOfContents={showTableOfContents}
searchState={searchState}
searchActions={searchActions}
+ citationStatus={
+ selectedCitationStatus
+ ? formatSelectedHtmlSuperscriptLinkStatus(selectedCitationStatus)
+ : ''
+ }
+ linkBubbleOwnerId={codec.transport.key}
linkBubbleActions={{
dismissLinkBubble: () => {
setLinkBubble(null)
@@ -379,6 +403,7 @@ export default function RichMarkdownEditor({
handleLinkRemove,
handleLinkEditCancel,
handleLinkOpen,
+ handleLinkCopy,
setIsEditingLink
}}
onToggleLink={toggleLinkFromToolbar}
diff --git a/src/renderer/src/components/editor/RichMarkdownEditorSurface.tsx b/src/renderer/src/components/editor/RichMarkdownEditorSurface.tsx
index f5283616a..fd619247c 100644
--- a/src/renderer/src/components/editor/RichMarkdownEditorSurface.tsx
+++ b/src/renderer/src/components/editor/RichMarkdownEditorSurface.tsx
@@ -76,6 +76,7 @@ type RichMarkdownEditorSurfaceProps = {
matchCase: boolean
matchCount: number
replaceQuery: string
+ replaceDisabled: boolean
searchQuery: string
searchInputRef: React.RefObject
wholeWord: boolean
@@ -91,12 +92,15 @@ type RichMarkdownEditorSurfaceProps = {
toggleReplaceMode: () => void
toggleWholeWord: () => void
}
+ citationStatus: string
+ linkBubbleOwnerId: string
linkBubbleActions: {
dismissLinkBubble: () => void
handleLinkSave: (href: string) => void
handleLinkRemove: () => void
handleLinkEditCancel: () => void
handleLinkOpen: () => void
+ handleLinkCopy: () => void
setIsEditingLink: (editing: boolean) => void
}
onToggleLink: () => void
@@ -155,6 +159,8 @@ export function RichMarkdownEditorSurface({
showTableOfContents,
searchState,
searchActions,
+ citationStatus,
+ linkBubbleOwnerId,
linkBubbleActions,
onToggleLink,
onImagePick,
@@ -237,6 +243,7 @@ export function RichMarkdownEditorSurface({
matchCount={searchState.matchCount}
query={searchState.searchQuery}
replaceQuery={searchState.replaceQuery}
+ replaceDisabled={searchState.replaceDisabled}
searchInputRef={searchState.searchInputRef}
wholeWord={searchState.wholeWord}
onClose={searchActions.closeSearch}
@@ -262,8 +269,13 @@ export function RichMarkdownEditorSurface({
onEditStart={() => linkBubbleActions.setIsEditingLink(true)}
onEditCancel={linkBubbleActions.handleLinkEditCancel}
onOpen={linkBubbleActions.handleLinkOpen}
+ onCopy={linkBubbleActions.handleLinkCopy}
+ ownerId={linkBubbleOwnerId}
/>
) : null}
+
+ {citationStatus}
+
{slashMenu ? (
void
onEditCancel: () => void
onOpen: () => void
+ onCopy: () => void
+ ownerId?: string
portalToDocument?: boolean
}
@@ -173,6 +181,8 @@ export function RichMarkdownLinkBubble({
onEditStart,
onEditCancel,
onOpen,
+ onCopy,
+ ownerId,
portalToDocument = false
}: RichMarkdownLinkBubbleProps): React.JSX.Element {
const bubbleRef = useRef(null)
@@ -270,6 +280,8 @@ export function RichMarkdownLinkBubble({
{
// Prevent editor blur when clicking bubble buttons, but let inputs
@@ -278,7 +290,28 @@ export function RichMarkdownLinkBubble({
e.preventDefault()
}
}}
- onKeyDown={(e) => e.stopPropagation()}
+ onKeyDown={(event) => {
+ event.stopPropagation()
+ if (event.key === 'Escape') {
+ event.preventDefault()
+ onDismiss()
+ anchorElement?.querySelector
('[contenteditable="true"]')?.focus()
+ return
+ }
+ if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') {
+ return
+ }
+ const buttons = Array.from(
+ bubbleRef.current?.querySelectorAll('button:not([disabled])') ?? []
+ )
+ const currentIndex = buttons.indexOf(document.activeElement as HTMLButtonElement)
+ if (currentIndex === -1 || buttons.length === 0) {
+ return
+ }
+ event.preventDefault()
+ const direction = event.key === 'ArrowRight' ? 1 : -1
+ buttons[(currentIndex + direction + buttons.length) % buttons.length]?.focus()
+ }}
>
{isEditing ? (
@@ -287,39 +320,45 @@ export function RichMarkdownLinkBubble({
{linkBubble.href.length > 40 ? `${linkBubble.href.slice(0, 40)}…` : linkBubble.href}
-
-
-
+
+
+ {linkBubble.kind === 'markdown' ? (
+ <>
+
+
+
+
+
+
+ >
+ ) : null}
>
)}
@@ -329,3 +368,36 @@ export function RichMarkdownLinkBubble({
// actions must portal to the app layer to remain above the right sidebar.
return portalToDocument ? createPortal(bubble, document.body) : bubble
}
+
+function LinkBubbleAction({
+ children,
+ disabled = false,
+ label,
+ onClick
+}: {
+ children: React.ReactNode
+ disabled?: boolean
+ label: string
+ onClick: () => void
+}): React.JSX.Element {
+ return (
+
+
+
+
+
+ {label}
+
+
+ )
+}
diff --git a/src/renderer/src/components/editor/RichMarkdownSearchBar.tsx b/src/renderer/src/components/editor/RichMarkdownSearchBar.tsx
index ca7fd042a..9ea8d17e0 100644
--- a/src/renderer/src/components/editor/RichMarkdownSearchBar.tsx
+++ b/src/renderer/src/components/editor/RichMarkdownSearchBar.tsx
@@ -22,6 +22,7 @@ type RichMarkdownSearchBarProps = {
matchCount: number
query: string
replaceQuery: string
+ replaceDisabled: boolean
searchInputRef: React.RefObject
wholeWord: boolean
onClose: () => void
@@ -43,6 +44,7 @@ export function RichMarkdownSearchBar({
matchCount,
query,
replaceQuery,
+ replaceDisabled,
searchInputRef,
wholeWord,
onClose,
@@ -58,6 +60,7 @@ export function RichMarkdownSearchBar({
// Why: surface the same replace shortcut the source editor uses so the toggle
// is discoverable; reads the user's effective binding, formatted per platform.
const replaceShortcut = useOptionalShortcutLabel('editor.replace')
+ const readOnlyExplanationId = React.useId()
if (!isOpen) {
return null
@@ -71,6 +74,10 @@ export function RichMarkdownSearchBar({
}
const noMatches = matchCount === 0
+ const readOnlyReplaceExplanation = translate(
+ 'auto.components.editor.RichMarkdownSearchBar.preservedRichContentReadOnly',
+ 'Preserved rich content is read-only in rich mode.'
+ )
const toggleReplaceLabel = isReplaceMode
? translate('auto.components.editor.RichMarkdownSearchBar.e8c147435f', 'Hide replace')
: translate('auto.components.editor.RichMarkdownSearchBar.9cdc38be33', 'Toggle replace')
@@ -259,6 +266,7 @@ export function RichMarkdownSearchBar({
'auto.components.editor.RichMarkdownSearchBar.44682b4159',
'Replace in rich markdown editor'
)}
+ aria-describedby={replaceDisabled ? readOnlyExplanationId : undefined}
/>
+ {replaceDisabled ? (
+
+ {readOnlyReplaceExplanation}
+
+ ) : null}
) : null}
diff --git a/src/renderer/src/components/editor/html-character-reference-decoder.ts b/src/renderer/src/components/editor/html-character-reference-decoder.ts
new file mode 100644
index 000000000..e8bd9d168
--- /dev/null
+++ b/src/renderer/src/components/editor/html-character-reference-decoder.ts
@@ -0,0 +1,22 @@
+export type HtmlAttributeQuote = '"' | "'" | null
+
+export function decodeHtmlTextCharacterReferences(value: string): string {
+ // Why: a parses markup inertly (no script/resource execution), so
+ // reading textContent safely decodes character references without XSS risk.
+ const template = document.createElement('template')
+ template.innerHTML = value
+ return template.content.textContent ?? ''
+}
+
+export function decodeHtmlAttributeCharacterReferences(
+ value: string,
+ quote: HtmlAttributeQuote
+): string {
+ // Why: inert decoding; attribute write uses the caller-supplied
+ // quote and parser-constrained value, so this is not an injection sink.
+ const template = document.createElement('template')
+ const delimiter = quote ?? ''
+ template.innerHTML = ``
+ const element = template.content.firstElementChild
+ return element?.getAttribute('data-orca-value') ?? ''
+}
diff --git a/src/renderer/src/components/editor/isolated-markdown-extension-for-tests.ts b/src/renderer/src/components/editor/isolated-markdown-extension-for-tests.ts
new file mode 100644
index 000000000..46ed021ee
--- /dev/null
+++ b/src/renderer/src/components/editor/isolated-markdown-extension-for-tests.ts
@@ -0,0 +1,9 @@
+import { Markdown } from '@tiptap/markdown'
+import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport'
+
+export function createIsolatedMarkdownExtensionForTests() {
+ return Markdown.configure({
+ marked: createRichMarkdownEditorCodec().marked,
+ markedOptions: { gfm: true }
+ })
+}
diff --git a/src/renderer/src/components/editor/markdown-dirty-state.test.ts b/src/renderer/src/components/editor/markdown-dirty-state.test.ts
index 46b786ddd..c423cbedf 100644
--- a/src/renderer/src/components/editor/markdown-dirty-state.test.ts
+++ b/src/renderer/src/components/editor/markdown-dirty-state.test.ts
@@ -7,7 +7,7 @@ import { Table } from '@tiptap/extension-table'
import { TableCell } from '@tiptap/extension-table-cell'
import { TableHeader } from '@tiptap/extension-table-header'
import { TableRow } from '@tiptap/extension-table-row'
-import { Markdown } from '@tiptap/markdown'
+import { createIsolatedMarkdownExtensionForTests } from './isolated-markdown-extension-for-tests'
import { normalizeEmptyListItems } from './rich-markdown-normalize'
const testExtensions = [
@@ -18,7 +18,7 @@ const testExtensions = [
TableRow,
TableHeader,
TableCell,
- Markdown.configure({ markedOptions: { gfm: true } })
+ createIsolatedMarkdownExtensionForTests()
]
function createEditor(markdown: string): Editor {
diff --git a/src/renderer/src/components/editor/markdown-internal-links.ts b/src/renderer/src/components/editor/markdown-internal-links.ts
index 21ca77407..b7801e378 100644
--- a/src/renderer/src/components/editor/markdown-internal-links.ts
+++ b/src/renderer/src/components/editor/markdown-internal-links.ts
@@ -3,7 +3,10 @@ import {
filesystemPathToFileUri,
fileUriToFilesystemPath
} from '../../../../shared/file-uri-path'
-import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-path'
+import {
+ isWindowsAbsolutePathLike,
+ relativePathInsideRoot
+} from '../../../../shared/cross-platform-path'
// Pure classifier for markdown link targets. Called by the link-activation
// dispatcher (activateMarkdownLink slice action) from three call sites —
@@ -31,13 +34,6 @@ export type MarkdownLinkTarget =
column?: number
}
-// Why: renderer runs with sandbox + contextIsolation, so process.platform is
-// unavailable. navigator.userAgent is the portable fallback (AGENTS.md).
-const ua = typeof navigator === 'undefined' ? '' : navigator.userAgent
-const isMacLike = ua.includes('Mac')
-const isWindowsLike = ua.includes('Windows')
-const caseInsensitiveFs = isMacLike || isWindowsLike
-
const MARKDOWN_EXTENSIONS = new Set(['.md', '.mdx', '.markdown'])
export function absolutePathToFileUri(filePath: string): string {
@@ -60,17 +56,6 @@ function normalizePathForCompare(p: string): string {
return np
}
-function isDescendantOf(childAbs: string, parentAbs: string): boolean {
- const child = normalizePathForCompare(childAbs)
- const parent = normalizePathForCompare(parentAbs)
- if (caseInsensitiveFs) {
- const lc = child.toLowerCase()
- const lp = parent.toLowerCase()
- return lc === lp || lc.startsWith(`${lp}/`)
- }
- return child === parent || child.startsWith(`${parent}/`)
-}
-
function hasMarkdownExtension(p: string): boolean {
const lastDot = p.lastIndexOf('.')
if (lastDot === -1) {
@@ -125,25 +110,7 @@ function resolveRelativeToSource(rawHref: string, sourceFilePath: string): URL |
}
function computeRelativePath(absolutePath: string, worktreeRoot: string): string {
- const parent = normalizePathForCompare(worktreeRoot)
- const child = normalizePathForCompare(absolutePath)
- const prefix = `${parent}/`
- if (caseInsensitiveFs) {
- if (child.toLowerCase() === parent.toLowerCase()) {
- return ''
- }
- if (child.toLowerCase().startsWith(prefix.toLowerCase())) {
- return child.slice(prefix.length)
- }
- } else {
- if (child === parent) {
- return ''
- }
- if (child.startsWith(prefix)) {
- return child.slice(prefix.length)
- }
- }
- return child
+ return relativePathInsideRoot(worktreeRoot, absolutePath) ?? normalizePathForCompare(absolutePath)
}
export function resolveMarkdownLinkTarget(
@@ -195,7 +162,7 @@ export function resolveMarkdownLinkTarget(
if (
worktreeRoot !== null &&
hasMarkdownExtension(pathForClassification) &&
- isDescendantOf(pathForClassification, worktreeRoot)
+ relativePathInsideRoot(worktreeRoot, pathForClassification) !== null
) {
const relativePath = computeRelativePath(pathForClassification, worktreeRoot)
return {
@@ -208,7 +175,7 @@ export function resolveMarkdownLinkTarget(
}
const relativePath =
- worktreeRoot !== null && isDescendantOf(pathForClassification, worktreeRoot)
+ worktreeRoot !== null && relativePathInsideRoot(worktreeRoot, pathForClassification) !== null
? computeRelativePath(pathForClassification, worktreeRoot)
: undefined
@@ -226,3 +193,52 @@ export function resolveMarkdownLinkTarget(
column
}
}
+
+const HTML_ATTRIBUTE_WHITESPACE = /^[\t\n\f\r ]+|[\t\n\f\r ]+$/g
+
+export function projectMarkdownHrefForClipboard(href: string): string | null {
+ const projected = href.replace(HTML_ATTRIBUTE_WHITESPACE, '')
+ if (!projected || containsAsciiControl(projected)) {
+ return null
+ }
+ if (
+ isWindowsAbsolutePathLike(projected) ||
+ projected.startsWith('\\\\') ||
+ projected.startsWith('//')
+ ) {
+ return null
+ }
+ if (projected.startsWith('#')) {
+ return projected
+ }
+
+ const scheme = /^([A-Za-z][A-Za-z\d+.-]*):/.exec(projected)?.[1]?.toLowerCase()
+ if (!scheme) {
+ return projected
+ }
+ if (scheme !== 'http' && scheme !== 'https' && scheme !== 'file') {
+ return null
+ }
+ try {
+ const parsed = new URL(projected)
+ if (parsed.protocol !== `${scheme}:`) {
+ return null
+ }
+ if (scheme === 'file' && !parsed.pathname) {
+ return null
+ }
+ return projected
+ } catch {
+ return null
+ }
+}
+
+function containsAsciiControl(value: string): boolean {
+ for (let index = 0; index < value.length; index += 1) {
+ const code = value.charCodeAt(index)
+ if (code <= 31 || code === 127) {
+ return true
+ }
+ }
+ return false
+}
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 8de5e58af..439541add 100644
--- a/src/renderer/src/components/editor/markdown-preview-links.test.ts
+++ b/src/renderer/src/components/editor/markdown-preview-links.test.ts
@@ -149,9 +149,10 @@ describe('resolveMarkdownPreviewHttpOpenOptions', () => {
resolveMarkdownPreviewHttpOpenOptions(
{ metaKey: true, ctrlKey: false, shiftKey: true },
true,
- 'wt-1'
+ 'wt-1',
+ { kind: 'local' }
)
- ).toEqual({ forceSystemBrowser: true })
+ ).toEqual({ forceSystemBrowser: true, sourceOwner: { kind: 'local' } })
})
it('forces the system browser on Ctrl+Shift-click on Linux/Windows', () => {
@@ -159,9 +160,10 @@ describe('resolveMarkdownPreviewHttpOpenOptions', () => {
resolveMarkdownPreviewHttpOpenOptions(
{ metaKey: false, ctrlKey: true, shiftKey: true },
false,
- 'wt-1'
+ 'wt-1',
+ { kind: 'local' }
)
- ).toEqual({ forceSystemBrowser: true })
+ ).toEqual({ forceSystemBrowser: true, sourceOwner: { kind: 'local' } })
})
it('routes a plain Cmd-click through the worktree so it can open in Orca', () => {
@@ -169,9 +171,10 @@ describe('resolveMarkdownPreviewHttpOpenOptions', () => {
resolveMarkdownPreviewHttpOpenOptions(
{ metaKey: true, ctrlKey: false, shiftKey: false },
true,
- 'wt-1'
+ 'wt-1',
+ { kind: 'local' }
)
- ).toEqual({ worktreeId: 'wt-1' })
+ ).toEqual({ worktreeId: 'wt-1', sourceOwner: { kind: 'local' } })
})
it('routes a plain click (no modifier) through the worktree', () => {
@@ -179,9 +182,10 @@ describe('resolveMarkdownPreviewHttpOpenOptions', () => {
resolveMarkdownPreviewHttpOpenOptions(
{ metaKey: false, ctrlKey: false, shiftKey: false },
true,
- 'wt-1'
+ 'wt-1',
+ { kind: 'local' }
)
- ).toEqual({ worktreeId: 'wt-1' })
+ ).toEqual({ worktreeId: 'wt-1', sourceOwner: { kind: 'local' } })
})
it('does not force the system browser for Shift without the platform mod key', () => {
@@ -190,9 +194,10 @@ describe('resolveMarkdownPreviewHttpOpenOptions', () => {
resolveMarkdownPreviewHttpOpenOptions(
{ metaKey: false, ctrlKey: false, shiftKey: true },
true,
- 'wt-1'
+ 'wt-1',
+ { kind: 'local' }
)
- ).toEqual({ worktreeId: 'wt-1' })
+ ).toEqual({ worktreeId: 'wt-1', sourceOwner: { kind: 'local' } })
})
it('does not treat Mac Ctrl+Shift-click as the escape hatch', () => {
@@ -201,9 +206,10 @@ describe('resolveMarkdownPreviewHttpOpenOptions', () => {
resolveMarkdownPreviewHttpOpenOptions(
{ metaKey: false, ctrlKey: true, shiftKey: true },
true,
- 'wt-1'
+ 'wt-1',
+ { kind: 'local' }
)
- ).toEqual({ worktreeId: 'wt-1' })
+ ).toEqual({ worktreeId: 'wt-1', sourceOwner: { kind: 'local' } })
})
it('passes through a null worktree (openHttpLink then falls back to system browser)', () => {
@@ -211,9 +217,10 @@ describe('resolveMarkdownPreviewHttpOpenOptions', () => {
resolveMarkdownPreviewHttpOpenOptions(
{ metaKey: false, ctrlKey: false, shiftKey: false },
true,
- null
+ null,
+ { kind: 'local' }
)
- ).toEqual({ worktreeId: null })
+ ).toEqual({ worktreeId: null, sourceOwner: { kind: 'local' } })
})
})
diff --git a/src/renderer/src/components/editor/markdown-preview-links.ts b/src/renderer/src/components/editor/markdown-preview-links.ts
index 479575e24..0541ca0ab 100644
--- a/src/renderer/src/components/editor/markdown-preview-links.ts
+++ b/src/renderer/src/components/editor/markdown-preview-links.ts
@@ -4,7 +4,7 @@ import {
fileUriToFilesystemPath
} from '../../../../shared/file-uri-path'
import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-path'
-import type { OpenHttpLinkOptions } from '@/lib/http-link-routing'
+import type { HttpLinkSourceOwner, OpenHttpLinkOptions } from '@/lib/http-link-routing'
function toFileUrl(filePath: string): string {
return filesystemPathToFileUri(filePath)
@@ -120,12 +120,13 @@ export function isMarkdownPreviewSystemBrowserModifier(
export function resolveMarkdownPreviewHttpOpenOptions(
event: Pick,
isMac: boolean,
- worktreeId: string | null
+ worktreeId: string | null,
+ sourceOwner: HttpLinkSourceOwner
): OpenHttpLinkOptions {
if (isMarkdownPreviewSystemBrowserModifier(event, isMac)) {
- return { forceSystemBrowser: true }
+ return { forceSystemBrowser: true, sourceOwner }
}
- return { worktreeId }
+ return { worktreeId, sourceOwner }
}
/**
diff --git a/src/renderer/src/components/editor/markdown-round-trip.test.ts b/src/renderer/src/components/editor/markdown-round-trip.test.ts
index a06bc883b..ef4d24d08 100644
--- a/src/renderer/src/components/editor/markdown-round-trip.test.ts
+++ b/src/renderer/src/components/editor/markdown-round-trip.test.ts
@@ -2,14 +2,16 @@ import { describe, expect, it } from 'vitest'
import { Editor } from '@tiptap/core'
import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html'
import { createRichMarkdownExtensions } from './rich-markdown-extensions'
+import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport'
import type { SlashCommandId } from './rich-markdown-slash-commands'
import { slashCommands } from './rich-markdown-slash-commands'
function roundTripMarkdown(content: string): string {
+ const codec = createRichMarkdownEditorCodec()
const editor = new Editor({
element: null,
- extensions: createRichMarkdownExtensions(),
- content: encodeRawMarkdownHtmlForRichEditor(content),
+ extensions: createRichMarkdownExtensions({ codec }),
+ content: encodeRawMarkdownHtmlForRichEditor(content, codec),
contentType: 'markdown'
})
@@ -21,10 +23,11 @@ function roundTripMarkdown(content: string): string {
}
function markdownAfterTextReplace(content: string, search: string, replacement: string): string {
+ const codec = createRichMarkdownEditorCodec()
const editor = new Editor({
element: null,
- extensions: createRichMarkdownExtensions(),
- content: encodeRawMarkdownHtmlForRichEditor(content),
+ extensions: createRichMarkdownExtensions({ codec }),
+ content: encodeRawMarkdownHtmlForRichEditor(content, codec),
contentType: 'markdown'
})
@@ -50,9 +53,10 @@ function markdownAfterTextReplace(content: string, search: string, replacement:
}
function slashCommandMarkdown(commandId: SlashCommandId): string {
+ const codec = createRichMarkdownEditorCodec()
const editor = new Editor({
element: null,
- extensions: createRichMarkdownExtensions(),
+ extensions: createRichMarkdownExtensions({ codec }),
content: '',
contentType: 'markdown'
})
@@ -71,9 +75,10 @@ function slashCommandMarkdown(commandId: SlashCommandId): string {
}
function slashCommandSelectionParent(commandId: SlashCommandId): string {
+ const codec = createRichMarkdownEditorCodec()
const editor = new Editor({
element: null,
- extensions: createRichMarkdownExtensions(),
+ extensions: createRichMarkdownExtensions({ codec }),
content: '',
contentType: 'markdown'
})
diff --git a/src/renderer/src/components/editor/markdown-round-trip.ts b/src/renderer/src/components/editor/markdown-round-trip.ts
index cacc88e79..fcfa39efd 100644
--- a/src/renderer/src/components/editor/markdown-round-trip.ts
+++ b/src/renderer/src/components/editor/markdown-round-trip.ts
@@ -1,11 +1,9 @@
import { Editor } from '@tiptap/core'
import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html'
import { createRichMarkdownExtensions } from './rich-markdown-extensions'
+import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport'
+import { createRichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-html-superscript-link-context'
-// Why: extensions are lazily created on first use to avoid eager instantiation
-// at import time. A single shared instance is safe here because only one
-// round-trip Editor is alive at a time (created and destroyed synchronously).
-let roundTripExtensions: ReturnType | null = null
const roundTripCache = new Map()
const MAX_CACHE_ENTRIES = 20
@@ -23,13 +21,23 @@ export function getRichMarkdownRoundTripOutput(content: string): string | null {
let output: string | null = null
try {
- if (!roundTripExtensions) {
- roundTripExtensions = createRichMarkdownExtensions()
- }
+ const codec = createRichMarkdownEditorCodec()
+ const context = createRichMarkdownHtmlSuperscriptLinkContext({
+ sourceFilePath: '',
+ worktreeId: '',
+ worktreeRoot: null,
+ sourceOwner: { kind: 'unknown' }
+ })
const editor = new Editor({
element: null,
- extensions: roundTripExtensions,
- content: encodeRawMarkdownHtmlForRichEditor(content),
+ extensions: createRichMarkdownExtensions({
+ codec,
+ htmlSuperscriptLinks: true,
+ htmlSuperscriptLinkContext: context
+ }),
+ content: encodeRawMarkdownHtmlForRichEditor(content, codec, {
+ htmlSuperscriptLinks: true
+ }),
contentType: 'markdown'
})
try {
diff --git a/src/renderer/src/components/editor/raw-markdown-html.ts b/src/renderer/src/components/editor/raw-markdown-html.ts
index e4403e31c..76c0285ca 100644
--- a/src/renderer/src/components/editor/raw-markdown-html.ts
+++ b/src/renderer/src/components/editor/raw-markdown-html.ts
@@ -2,53 +2,16 @@ import { Node, mergeAttributes } from '@tiptap/core'
import { isEditableDetailsHtmlBlock, matchDetailsHtmlBlock } from './details-markdown-html'
import { formatMarkdownDocLinkBody, parseMarkdownDocLink } from './markdown-doc-links'
import { normalizeMarkdownReferenceLinks } from './markdown-reference-link-normalization'
-
-const INLINE_PLACEHOLDER_PREFIX = '[[ORCA_RAW_HTML_INLINE:'
-const BLOCK_PLACEHOLDER_PREFIX = '[[ORCA_RAW_HTML_BLOCK:'
-const DOC_LINK_PLACEHOLDER_PREFIX = '[[ORCA_DOC_LINK:'
-const PLACEHOLDER_SUFFIX = ']]'
+import type {
+ RichMarkdownEditorCodec,
+ RichMarkdownSourceKind,
+ RichMarkdownSourceTransport
+} from './rich-markdown-source-transport'
+import { isReservedRichMarkdownTransportBody } from './rich-markdown-source-transport'
+import { matchHtmlSuperscriptLinkSource } from './rich-markdown-html-superscript-link-source'
const INLINE_HTML_PATTERN = /^|^<\/?[A-Za-z][\w.:-]*(?:\s[^<>]*?)?\/?>/
-function encodeHtmlPayload(raw: string): string {
- return encodeURIComponent(raw)
-}
-
-function decodeHtmlPayload(payload: string): string {
- try {
- return decodeURIComponent(payload)
- } catch {
- return ''
- }
-}
-
-function createPlaceholder(kind: 'inline' | 'block', raw: string): string {
- const prefix = kind === 'inline' ? INLINE_PLACEHOLDER_PREFIX : BLOCK_PLACEHOLDER_PREFIX
- return `${prefix}${encodeHtmlPayload(raw)}${PLACEHOLDER_SUFFIX}`
-}
-
-function matchPlaceholder(
- src: string,
- kind: 'inline' | 'block'
-): { placeholder: string; value: string } | null {
- const prefix = kind === 'inline' ? INLINE_PLACEHOLDER_PREFIX : BLOCK_PLACEHOLDER_PREFIX
- if (!src.startsWith(prefix)) {
- return null
- }
-
- const endIndex = src.indexOf(PLACEHOLDER_SUFFIX, prefix.length)
- if (endIndex === -1) {
- return null
- }
-
- const placeholder = src.slice(0, endIndex + PLACEHOLDER_SUFFIX.length)
- const payload = src.slice(prefix.length, endIndex)
- return {
- placeholder,
- value: decodeHtmlPayload(payload)
- }
-}
-
function matchInlineHtml(src: string): string | null {
const match = src.match(INLINE_HTML_PATTERN)
return match?.[0] ?? null
@@ -90,8 +53,13 @@ function matchBlockHtml(content: string, start: number): string | null {
return line
}
-export function encodeRawMarkdownHtmlForRichEditor(content: string): string {
+export function encodeRawMarkdownHtmlForRichEditor(
+ content: string,
+ codec: RichMarkdownEditorCodec,
+ { htmlSuperscriptLinks = false }: { htmlSuperscriptLinks?: boolean } = {}
+): string {
const normalizedContent = normalizeMarkdownReferenceLinks(content)
+ const { transport } = codec
let index = 0
let isLineStart = true
let activeFence: '`' | '~' | null = null
@@ -170,47 +138,65 @@ export function encodeRawMarkdownHtmlForRichEditor(content: string): string {
}
if (detailsHtml) {
- result += createPlaceholder('block', detailsHtml.raw)
+ result += transport.create('block-html', detailsHtml.raw)
index += detailsHtml.raw.length
continue
}
const blockHtml = matchBlockHtml(normalizedContent, index)
if (blockHtml) {
- result += createPlaceholder('block', blockHtml)
+ result += transport.create('block-html', blockHtml)
index += blockHtml.length
continue
}
}
+ // Why: authored text that happens to contain this editor's random envelope
+ // prefix must remain literal even in HTML-free documents and after edits.
+ if (normalizedContent.startsWith(transport.authoredPrefix, index)) {
+ const authoredEnd = normalizedContent.indexOf(']]', index + transport.authoredPrefix.length)
+ const authoredOccurrence =
+ authoredEnd === -1
+ ? transport.authoredPrefix
+ : normalizedContent.slice(index, authoredEnd + 2)
+ result += transport.create('literal', authoredOccurrence)
+ index += authoredOccurrence.length
+ continue
+ }
+
if (normalizedContent[index] === '<' && !isEscaped(normalizedContent, index)) {
+ if (htmlSuperscriptLinks) {
+ const superscriptLink = matchHtmlSuperscriptLinkSource(normalizedContent, index)
+ if (superscriptLink) {
+ result += transport.create('html-superscript-link', JSON.stringify(superscriptLink.value))
+ index = superscriptLink.end
+ continue
+ }
+ }
const inlineHtml = matchInlineHtml(normalizedContent.slice(index))
if (inlineHtml) {
- result += createPlaceholder('inline', inlineHtml)
+ result += transport.create('inline-html', inlineHtml)
index += inlineHtml.length
continue
}
}
- // Why: doc link encoding runs inside the same while loop (not a separate
- // pre-pass) so that fenced code blocks and backtick code spans are already
- // skipped by the guards above. The [[ORCA_ prefix check prevents re-encoding
- // sibling placeholders that were already emitted earlier in this pass.
+ // Why: doc link encoding runs inside this loop so fenced code and backtick
+ // spans have already been excluded from semantic preprocessing.
if (
normalizedContent[index] === '[' &&
normalizedContent[index + 1] === '[' &&
- !normalizedContent.startsWith('[[ORCA_', index) &&
!isEscaped(normalizedContent, index)
) {
const closingIndex = normalizedContent.indexOf(']]', index + 2)
if (closingIndex !== -1) {
const rawTarget = normalizedContent.slice(index + 2, closingIndex)
const link = parseMarkdownDocLink(rawTarget)
- if (link) {
- result += `${DOC_LINK_PLACEHOLDER_PREFIX}${formatMarkdownDocLinkBody(
- link.target,
- link.alias
- )}${PLACEHOLDER_SUFFIX}`
+ if (link && !isReservedRichMarkdownTransportBody(rawTarget)) {
+ result += transport.create(
+ 'document-link',
+ formatMarkdownDocLinkBody(link.target, link.alias)
+ )
index = closingIndex + 2
continue
}
@@ -226,129 +212,123 @@ export function encodeRawMarkdownHtmlForRichEditor(content: string): string {
return result
}
-export const RawMarkdownHtmlInline = Node.create({
- name: 'rawMarkdownHtmlInline',
- inline: true,
- group: 'inline',
- atom: true,
- selectable: true,
+export function createRichMarkdownLiteral(transport: RichMarkdownSourceTransport) {
+ return createRawSourceNode({
+ name: 'richMarkdownLiteral',
+ kind: 'literal',
+ inline: true,
+ transport,
+ marker: 'data-rich-markdown-literal'
+ })
+}
- addAttributes() {
- return {
- value: {
- default: ''
- }
- }
- },
-
- // Why: converting embedded HTML tags into placeholder tokens before the
- // markdown parser runs keeps marked's built-in paragraph tokenization intact
- // while still letting Orca round-trip the raw markup verbatim.
- markdownTokenName: 'rawMarkdownHtmlInline',
- markdownTokenizer: {
+export function createRawMarkdownHtmlInline(transport: RichMarkdownSourceTransport) {
+ return createRawSourceNode({
name: 'rawMarkdownHtmlInline',
- level: 'inline',
- start: INLINE_PLACEHOLDER_PREFIX,
- tokenize(src) {
- const matched = matchPlaceholder(src, 'inline')
- if (!matched) {
- return undefined
- }
+ kind: 'inline-html',
+ inline: true,
+ transport,
+ marker: 'data-raw-markdown-html-inline',
+ className: 'raw-markdown-html-inline'
+ })
+}
+function createRawSourceNode({
+ name,
+ kind,
+ inline,
+ transport,
+ marker,
+ className
+}: {
+ name: string
+ kind: RichMarkdownSourceKind
+ inline: boolean
+ transport: RichMarkdownSourceTransport
+ marker: string
+ className?: string
+}) {
+ return Node.create({
+ name,
+ inline,
+ group: inline ? 'inline' : 'block',
+ atom: true,
+ selectable: true,
+
+ addAttributes() {
return {
- type: 'rawMarkdownHtmlInline',
- raw: matched.placeholder,
- text: matched.value
+ value: {
+ default: '',
+ rendered: false
+ }
}
- }
- },
- parseMarkdown: (token, helpers) => {
- if (token.type !== 'rawMarkdownHtmlInline') {
- return []
- }
+ },
- return helpers.createNode('rawMarkdownHtmlInline', {
- value: typeof token.text === 'string' ? token.text : ''
- })
- },
- renderMarkdown: (node) => (typeof node.attrs?.value === 'string' ? node.attrs.value : ''),
+ // Why: converting embedded HTML tags into placeholder tokens before the
+ // markdown parser runs keeps marked's built-in paragraph tokenization intact
+ // while still letting Orca round-trip the raw markup verbatim.
+ markdownTokenName: name,
+ markdownTokenizer: {
+ name,
+ level: inline ? 'inline' : 'block',
+ start: transport.startFor(kind),
+ tokenize(src) {
+ const matched = transport.match(src, kind)
+ if (!matched) {
+ return undefined
+ }
- parseHTML() {
- return [{ tag: 'span[data-raw-markdown-html-inline]' }]
- },
-
- renderHTML({ HTMLAttributes, node }) {
- const value = typeof node.attrs.value === 'string' ? node.attrs.value : ''
- return [
- 'span',
- mergeAttributes(HTMLAttributes, {
- 'data-raw-markdown-html-inline': '',
- contenteditable: 'false',
- class: 'raw-markdown-html-inline'
- }),
- value
- ]
- }
-})
-
-export const RawMarkdownHtmlBlock = Node.create({
- name: 'rawMarkdownHtmlBlock',
- group: 'block',
- atom: true,
- selectable: true,
-
- addAttributes() {
- return {
- value: {
- default: ''
+ return {
+ type: name,
+ raw: matched.raw,
+ text: matched.value,
+ block: !inline
+ }
+ }
+ },
+ parseMarkdown: (token, helpers) => {
+ if (token.type !== name) {
+ return []
}
- }
- },
- markdownTokenName: 'rawMarkdownHtmlBlock',
- markdownTokenizer: {
+ return helpers.createNode(name, {
+ value: typeof token.text === 'string' ? token.text : ''
+ })
+ },
+ renderMarkdown: (node) => (typeof node.attrs?.value === 'string' ? node.attrs.value : ''),
+ renderText: ({ node }) => (typeof node.attrs.value === 'string' ? node.attrs.value : ''),
+
+ parseHTML() {
+ return [
+ {
+ tag: `${inline ? 'span' : 'div'}[${marker}]`,
+ getAttrs: (element: HTMLElement) => ({ value: element.textContent ?? '' })
+ }
+ ]
+ },
+
+ renderHTML({ HTMLAttributes, node }) {
+ const value = typeof node.attrs.value === 'string' ? node.attrs.value : ''
+ return [
+ inline ? 'span' : 'div',
+ mergeAttributes(HTMLAttributes, {
+ [marker]: '',
+ contenteditable: 'false',
+ class: className
+ }),
+ inline ? value : ['pre', value]
+ ]
+ }
+ })
+}
+
+export function createRawMarkdownHtmlBlock(transport: RichMarkdownSourceTransport) {
+ return createRawSourceNode({
name: 'rawMarkdownHtmlBlock',
- level: 'block',
- start: BLOCK_PLACEHOLDER_PREFIX,
- tokenize(src) {
- const matched = matchPlaceholder(src, 'block')
- if (!matched) {
- return undefined
- }
-
- return {
- type: 'rawMarkdownHtmlBlock',
- raw: matched.placeholder,
- text: matched.value,
- block: true
- }
- }
- },
- parseMarkdown: (token, helpers) => {
- if (token.type !== 'rawMarkdownHtmlBlock') {
- return []
- }
-
- return helpers.createNode('rawMarkdownHtmlBlock', {
- value: typeof token.text === 'string' ? token.text : ''
- })
- },
- renderMarkdown: (node) => (typeof node.attrs?.value === 'string' ? node.attrs.value : ''),
-
- parseHTML() {
- return [{ tag: 'div[data-raw-markdown-html-block]' }]
- },
-
- renderHTML({ HTMLAttributes, node }) {
- const value = typeof node.attrs.value === 'string' ? node.attrs.value : ''
- return [
- 'div',
- mergeAttributes(HTMLAttributes, {
- 'data-raw-markdown-html-block': '',
- contenteditable: 'false',
- class: 'raw-markdown-html-block'
- }),
- ['pre', value]
- ]
- }
-})
+ kind: 'block-html',
+ inline: false,
+ transport,
+ marker: 'data-raw-markdown-html-block',
+ className: 'raw-markdown-html-block'
+ })
+}
diff --git a/src/renderer/src/components/editor/rich-markdown-citation-keyboard.ts b/src/renderer/src/components/editor/rich-markdown-citation-keyboard.ts
new file mode 100644
index 000000000..309921f74
--- /dev/null
+++ b/src/renderer/src/components/editor/rich-markdown-citation-keyboard.ts
@@ -0,0 +1,40 @@
+import type { Editor } from '@tiptap/react'
+import { NodeSelection } from '@tiptap/pm/state'
+
+export function handleRichMarkdownCitationKey({
+ editor,
+ event,
+ linkBubbleOwnerId,
+ onOpen
+}: {
+ editor: Editor | null
+ event: KeyboardEvent
+ linkBubbleOwnerId: string
+ onOpen?: () => boolean
+}): boolean {
+ const selection = editor?.state?.selection
+ if (
+ !(selection instanceof NodeSelection) ||
+ selection.node.type.name !== 'richMarkdownHtmlSuperscriptLink' ||
+ event.isComposing ||
+ editor?.view.composing === true
+ ) {
+ return false
+ }
+ if (event.key === 'Enter') {
+ event.preventDefault()
+ return onOpen?.() ?? true
+ }
+ if (event.key !== 'Tab' || event.shiftKey) {
+ return false
+ }
+ const firstAction = document.querySelector(
+ `[data-rich-markdown-link-bubble-owner="${linkBubbleOwnerId}"] button:not([disabled])`
+ )
+ if (!firstAction) {
+ return false
+ }
+ event.preventDefault()
+ firstAction.focus()
+ return true
+}
diff --git a/src/renderer/src/components/editor/rich-markdown-clipboard-serialization.ts b/src/renderer/src/components/editor/rich-markdown-clipboard-serialization.ts
new file mode 100644
index 000000000..ae3cd7bad
--- /dev/null
+++ b/src/renderer/src/components/editor/rich-markdown-clipboard-serialization.ts
@@ -0,0 +1,17 @@
+import { DOMSerializer, type Slice } from '@tiptap/pm/model'
+import type { EditorView } from '@tiptap/pm/view'
+
+export function serializeRichMarkdownSliceForClipboard(
+ view: EditorView,
+ slice: Slice
+): { html: string } {
+ if (typeof view.serializeForClipboard === 'function') {
+ return { html: view.serializeForClipboard(slice).dom.innerHTML }
+ }
+ // Why: lightweight unit-test views predate ProseMirror's public clipboard
+ // serializer; production always takes the metadata-preserving branch above.
+ const fragment = DOMSerializer.fromSchema(view.state.schema).serializeFragment(slice.content)
+ const container = document.createElement('div')
+ container.appendChild(fragment)
+ return { html: container.innerHTML }
+}
diff --git a/src/renderer/src/components/editor/rich-markdown-clipboard-write.ts b/src/renderer/src/components/editor/rich-markdown-clipboard-write.ts
new file mode 100644
index 000000000..3d146c614
--- /dev/null
+++ b/src/renderer/src/components/editor/rich-markdown-clipboard-write.ts
@@ -0,0 +1,37 @@
+import type { Slice } from '@tiptap/pm/model'
+import type { EditorView } from '@tiptap/pm/view'
+import { serializeRichMarkdownSliceForClipboard } from './rich-markdown-clipboard-serialization'
+import { inspectRichMarkdownSourceOwningSlice } from './rich-markdown-source-owning-slice'
+import { showRichMarkdownSourceOwningCutLimitError } from './rich-markdown-source-owning-cut-feedback'
+
+/**
+ * Writes a rich-markdown slice to the clipboard (HTML + plain), verifying
+ * readback. Returns false when the write must not be followed by a delete
+ * (source-owning limit or clipboard rejection).
+ */
+export function writeRichMarkdownSliceToClipboard(
+ clipboardData: DataTransfer,
+ view: EditorView,
+ slice: Slice,
+ visibleText: string
+): boolean {
+ const status = inspectRichMarkdownSourceOwningSlice(slice)
+ if (status.containsSourceOwningNode && !status.canPreserve) {
+ showRichMarkdownSourceOwningCutLimitError()
+ return false
+ }
+ const serialized = serializeRichMarkdownSliceForClipboard(view, slice)
+ clipboardData.setData('text/html', serialized.html)
+ clipboardData.setData('text/plain', visibleText)
+ // Why: if the clipboard rejected the write we must not delete, and we must
+ // surface the same cut-limit feedback so the no-op is not silent.
+ if (
+ typeof clipboardData.getData === 'function' &&
+ (clipboardData.getData('text/html') !== serialized.html ||
+ clipboardData.getData('text/plain') !== visibleText)
+ ) {
+ showRichMarkdownSourceOwningCutLimitError()
+ return false
+ }
+ return true
+}
diff --git a/src/renderer/src/components/editor/rich-markdown-commands.test.ts b/src/renderer/src/components/editor/rich-markdown-commands.test.ts
index be3815b3b..a97d65cae 100644
--- a/src/renderer/src/components/editor/rich-markdown-commands.test.ts
+++ b/src/renderer/src/components/editor/rich-markdown-commands.test.ts
@@ -1,12 +1,13 @@
import { describe, expect, it } from 'vitest'
import { Editor } from '@tiptap/core'
import { createRichMarkdownExtensions } from './rich-markdown-extensions'
+import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport'
import { runSlashCommand, slashCommands, type SlashCommandId } from './rich-markdown-slash-commands'
function createEditor(content = '/'): Editor {
return new Editor({
element: null,
- extensions: createRichMarkdownExtensions(),
+ extensions: createRichMarkdownExtensions({ codec: createRichMarkdownEditorCodec() }),
content,
contentType: 'markdown'
})
diff --git a/src/renderer/src/components/editor/rich-markdown-cut-handler.ts b/src/renderer/src/components/editor/rich-markdown-cut-handler.ts
index ea1cdf8a3..b6c5e714d 100644
--- a/src/renderer/src/components/editor/rich-markdown-cut-handler.ts
+++ b/src/renderer/src/components/editor/rich-markdown-cut-handler.ts
@@ -1,7 +1,10 @@
-import { DOMSerializer } from '@tiptap/pm/model'
import { TextSelection } from '@tiptap/pm/state'
import type { EditorView } from '@tiptap/pm/view'
+import { writeRichMarkdownSliceToClipboard } from './rich-markdown-clipboard-write'
import { cutVisualLine, getVisualLineRange } from './rich-markdown-visual-line'
+import { createRichMarkdownVisibleTextMap } from './rich-markdown-visible-text-map'
+import { inspectRichMarkdownSourceOwningSlice } from './rich-markdown-source-owning-slice'
+import { showRichMarkdownSourceOwningCutLimitError } from './rich-markdown-source-owning-cut-feedback'
function deleteBlockAndRestoreSelection(view: EditorView, from: number, to: number): void {
let tr = view.state.tr.delete(from, to)
@@ -24,6 +27,12 @@ function deleteBlockAndRestoreSelection(view: EditorView, from: number, to: numb
export function handleRichMarkdownCut(view: EditorView, event: ClipboardEvent): boolean {
const { selection } = view.state
if (!selection.empty) {
+ const status = inspectRichMarkdownSourceOwningSlice(selection.content())
+ if (status.containsSourceOwningNode && !status.canPreserve) {
+ event.preventDefault()
+ showRichMarkdownSourceOwningCutLimitError()
+ return true
+ }
return false
}
@@ -54,7 +63,9 @@ export function handleRichMarkdownCut(view: EditorView, event: ClipboardEvent):
}
const cutNode = $from.node(cutDepth)
- const text = cutNode.textContent
+ const contentFrom = $from.start(cutDepth)
+ const contentTo = $from.end(cutDepth)
+ const text = createRichMarkdownVisibleTextMap(view.state.doc, contentFrom, contentTo).text
// Why: for paragraphs that word-wrap across multiple visual lines, cut
// only the visual line the cursor is on rather than the entire paragraph.
@@ -85,15 +96,10 @@ export function handleRichMarkdownCut(view: EditorView, event: ClipboardEvent):
}
event.preventDefault()
- // Why: writing both text/html and text/plain preserves inline formatting
- // (bold, italic, links) on round-trip cut-then-paste, while still giving
- // a plain-text fallback for external targets.
- const serializer = DOMSerializer.fromSchema(view.state.schema)
- const fragment = serializer.serializeFragment(cutNode.content)
- const div = document.createElement('div')
- div.appendChild(fragment)
- event.clipboardData.setData('text/html', div.innerHTML)
- event.clipboardData.setData('text/plain', text)
+ const slice = view.state.doc.slice($from.before(cutDepth), $from.after(cutDepth))
+ if (!writeRichMarkdownSliceToClipboard(event.clipboardData, view, slice, text)) {
+ return true
+ }
deleteBlockAndRestoreSelection(view, $from.before(cutDepth), $from.after(cutDepth))
diff --git a/src/renderer/src/components/editor/rich-markdown-cut.test.ts b/src/renderer/src/components/editor/rich-markdown-cut.test.ts
index 67eb9f376..13f87403f 100644
--- a/src/renderer/src/components/editor/rich-markdown-cut.test.ts
+++ b/src/renderer/src/components/editor/rich-markdown-cut.test.ts
@@ -10,10 +10,16 @@ import { Table } from '@tiptap/extension-table'
import { TableCell } from '@tiptap/extension-table-cell'
import { TableHeader } from '@tiptap/extension-table-header'
import { TableRow } from '@tiptap/extension-table-row'
-import { Markdown } from '@tiptap/markdown'
+import { createIsolatedMarkdownExtensionForTests } from './isolated-markdown-extension-for-tests'
import { handleRichMarkdownCut } from './rich-markdown-cut-handler'
import { normalizeEmptyListItems, normalizeSoftBreaks } from './rich-markdown-normalize'
+const showCutLimitErrorMock = vi.hoisted(() => vi.fn())
+
+vi.mock('./rich-markdown-source-owning-cut-feedback', () => ({
+ showRichMarkdownSourceOwningCutLimitError: showCutLimitErrorMock
+}))
+
/**
* Minimal extensions matching the rich editor schema without UI dependencies.
*/
@@ -25,7 +31,7 @@ const testExtensions = [
TableRow,
TableHeader,
TableCell,
- Markdown.configure({ markedOptions: { gfm: true } })
+ createIsolatedMarkdownExtensionForTests()
]
function createEditor(markdown: string): Editor {
@@ -38,6 +44,7 @@ function createEditor(markdown: string): Editor {
}
afterEach(() => {
+ showCutLimitErrorMock.mockReset()
vi.restoreAllMocks()
})
@@ -109,7 +116,7 @@ function countParagraphs(editor: Editor): number {
return count
}
-function createClipboardEventMock(): {
+function createClipboardEventMock(options?: { failReadback?: boolean }): {
data: Map
event: ClipboardEvent
preventDefault: ReturnType
@@ -120,7 +127,10 @@ function createClipboardEventMock(): {
clipboardData: {
setData: vi.fn((type: string, value: string) => {
data.set(type, value)
- })
+ }),
+ getData: options?.failReadback
+ ? vi.fn(() => '')
+ : vi.fn((type: string) => data.get(type) ?? '')
},
preventDefault
} as unknown as ClipboardEvent
@@ -243,6 +253,38 @@ describe('rich markdown cut handler behavior', () => {
}
})
+ it('surfaces cut-limit feedback when clipboard readback fails', () => {
+ const editor = createEditor('Body text to cut.\n')
+ try {
+ const pos = 1
+ let viewState = editor.state.apply(
+ editor.state.tr.setSelection(TextSelection.create(editor.state.doc, pos))
+ )
+ const view = {
+ get state() {
+ return viewState
+ },
+ dispatch: vi.fn((tr) => {
+ viewState = viewState.apply(tr)
+ }),
+ domAtPos: vi.fn(() => ({ node: document.createElement('p'), offset: 0 })),
+ coordsAtPos: vi.fn(() => ({ top: 0, bottom: 20, left: 0, right: 20 })),
+ posAtCoords: vi.fn(() => null)
+ } as unknown as EditorView
+
+ const clipboard = createClipboardEventMock({ failReadback: true })
+ const handled = handleRichMarkdownCut(view, clipboard.event)
+
+ expect(handled).toBe(true)
+ expect(clipboard.preventDefault).toHaveBeenCalled()
+ expect(showCutLimitErrorMock).toHaveBeenCalledTimes(1)
+ expect(view.dispatch).not.toHaveBeenCalled()
+ expect(view.state.doc.textContent).toBe('Body text to cut.')
+ } finally {
+ editor.destroy()
+ }
+ })
+
it('paragraphs separated by blank lines are separate blocks', () => {
const editor = createEditor('First paragraph.\n\nSecond paragraph.\n\nThird paragraph.\n')
diff --git a/src/renderer/src/components/editor/rich-markdown-details-keyboard.test.ts b/src/renderer/src/components/editor/rich-markdown-details-keyboard.test.ts
index 18dd0debd..d9b293c40 100644
--- a/src/renderer/src/components/editor/rich-markdown-details-keyboard.test.ts
+++ b/src/renderer/src/components/editor/rich-markdown-details-keyboard.test.ts
@@ -6,11 +6,12 @@ import {
moveFromEmptyDetailsBodyToSummary
} from './rich-markdown-details-extension'
import { createRichMarkdownExtensions } from './rich-markdown-extensions'
+import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport'
function createEditor(content: string | JSONContent) {
return new Editor({
element: null,
- extensions: createRichMarkdownExtensions(),
+ extensions: createRichMarkdownExtensions({ codec: createRichMarkdownEditorCodec() }),
content,
contentType: 'markdown'
})
diff --git a/src/renderer/src/components/editor/rich-markdown-doc-link-dom.ts b/src/renderer/src/components/editor/rich-markdown-doc-link-dom.ts
new file mode 100644
index 000000000..53e762f36
--- /dev/null
+++ b/src/renderer/src/components/editor/rich-markdown-doc-link-dom.ts
@@ -0,0 +1,20 @@
+import { mergeAttributes } from '@tiptap/core'
+import type { DOMOutputSpec } from '@tiptap/pm/model'
+
+export function renderRichMarkdownDocLinkHtml(
+ node: { attrs: Record },
+ htmlAttributes: Record
+): DOMOutputSpec {
+ const target = typeof node.attrs.target === 'string' ? node.attrs.target : ''
+ const label = typeof node.attrs.label === 'string' ? node.attrs.label : null
+ return [
+ 'span',
+ mergeAttributes(htmlAttributes, {
+ 'data-doc-link-target': target,
+ ...(label ? { 'data-doc-link-label': label } : {}),
+ contenteditable: 'false',
+ class: 'rich-markdown-doc-link'
+ }),
+ label ?? target
+ ]
+}
diff --git a/src/renderer/src/components/editor/rich-markdown-doc-link.ts b/src/renderer/src/components/editor/rich-markdown-doc-link.ts
index a6b0b02cf..a35772e4e 100644
--- a/src/renderer/src/components/editor/rich-markdown-doc-link.ts
+++ b/src/renderer/src/components/editor/rich-markdown-doc-link.ts
@@ -1,4 +1,4 @@
-import { Node, mergeAttributes } from '@tiptap/core'
+import { Node } from '@tiptap/core'
import { type EditorState, Plugin, PluginKey, TextSelection } from '@tiptap/pm/state'
import { Decoration, DecorationSet } from '@tiptap/pm/view'
import type { MarkdownDocument } from '../../../../shared/types'
@@ -10,9 +10,11 @@ import {
resolveMarkdownDocLink
} from './markdown-doc-links'
import { isDocLinkLiteralCodeTextNode } from './rich-markdown-doc-link-code-context'
-
-const DOC_LINK_PLACEHOLDER_PREFIX = '[[ORCA_DOC_LINK:'
-const DOC_LINK_PLACEHOLDER_SUFFIX = ']]'
+import {
+ isReservedRichMarkdownTransportBody,
+ type RichMarkdownSourceTransport
+} from './rich-markdown-source-transport'
+import { renderRichMarkdownDocLinkHtml } from './rich-markdown-doc-link-dom'
// Why: `.matchAll()` at each call site creates a fresh iterator so the shared
// `/g` regex never leaks `lastIndex` state across nested or concurrent scans.
@@ -55,7 +57,9 @@ function buildPreviewDecorations(state: EditorState, storage: DocLinkStorage): D
return
}
for (const match of node.text.matchAll(DOC_LINK_PATTERN)) {
- const link = parseMarkdownDocLink(match[1])
+ const link = isReservedRichMarkdownTransportBody(match[1])
+ ? null
+ : parseMarkdownDocLink(match[1])
if (!link || match.index === undefined) {
continue
}
@@ -96,262 +100,252 @@ function getDocLinkDisplayText(node: { attrs: Record }): string
return getDocLinkAlias(node) ?? getDocLinkTarget(node)
}
-export const MarkdownDocLink = Node.create({
- name: 'markdownDocLink',
- inline: true,
- group: 'inline',
- atom: true,
- selectable: true,
-
- addStorage() {
- return {
- documents: [] as MarkdownDocument[],
- _cachedDocs: null as MarkdownDocument[] | null,
- _cachedIndex: null as MarkdownDocumentIndex | null
- }
- },
-
- addAttributes() {
- return {
- target: {
- default: '',
- parseHTML: (el: HTMLElement) => el.getAttribute('data-doc-link-target') ?? ''
- },
- label: {
- default: null,
- parseHTML: (el: HTMLElement) => el.getAttribute('data-doc-link-label')
- }
- }
- },
-
- markdownTokenName: 'markdownDocLink',
- markdownTokenizer: {
+export function createMarkdownDocLink(transport: RichMarkdownSourceTransport) {
+ return Node.create({
name: 'markdownDocLink',
- level: 'inline',
- start: DOC_LINK_PLACEHOLDER_PREFIX,
- tokenize(src: string) {
- if (!src.startsWith(DOC_LINK_PLACEHOLDER_PREFIX)) {
- return undefined
- }
-
- const endIndex = src.indexOf(DOC_LINK_PLACEHOLDER_SUFFIX, DOC_LINK_PLACEHOLDER_PREFIX.length)
- if (endIndex === -1) {
- return undefined
- }
-
- const placeholder = src.slice(0, endIndex + DOC_LINK_PLACEHOLDER_SUFFIX.length)
- const link = parseMarkdownDocLink(src.slice(DOC_LINK_PLACEHOLDER_PREFIX.length, endIndex))
- if (!link) {
- return undefined
- }
+ inline: true,
+ group: 'inline',
+ atom: true,
+ selectable: true,
+ addStorage() {
return {
- type: 'markdownDocLink',
- raw: placeholder,
- text: link.target,
- label: link.alias ?? undefined
+ documents: [] as MarkdownDocument[],
+ _cachedDocs: null as MarkdownDocument[] | null,
+ _cachedIndex: null as MarkdownDocumentIndex | null
}
- }
- },
-
- parseMarkdown: (token, helpers) => {
- if (token.type !== 'markdownDocLink') {
- return []
- }
- return helpers.createNode('markdownDocLink', {
- target: typeof token.text === 'string' ? token.text : '',
- label:
- typeof (token as { label?: unknown }).label === 'string'
- ? (token as { label: string }).label
- : null
- })
- },
-
- renderMarkdown: (node) =>
- formatMarkdownDocLink(
- typeof node.attrs?.target === 'string' ? node.attrs.target : '',
- typeof node.attrs?.label === 'string' ? node.attrs.label : null
- ),
-
- addNodeView() {
- const storage = this.storage as DocLinkStorage
- return ({ node }: { node: { type: { name: string }; attrs: Record } }) => {
- const target = getDocLinkTarget(node)
- const dom = document.createElement('span')
- dom.setAttribute('data-doc-link-target', target)
- const alias = getDocLinkAlias(node)
- if (alias) {
- dom.setAttribute('data-doc-link-label', alias)
- }
- dom.setAttribute('contenteditable', 'false')
- dom.textContent = getDocLinkDisplayText(node)
-
- const applyResolutionClass = (t: string): void => {
- const resolved = resolveAgainstIndex(t, getDocIndex(storage))
- dom.className = resolved
- ? 'rich-markdown-doc-link'
- : 'rich-markdown-doc-link rich-markdown-doc-link--missing'
- }
-
- applyResolutionClass(target)
+ },
+ addAttributes() {
return {
- dom,
- // Why: this fires on every transaction, including the no-op dispatched
- // when the document list changes in storage. Re-checking resolution
- // here keeps the blue/grey styling current without a full re-render.
- update: (updatedNode: { type: { name: string }; attrs: Record }) => {
- if (updatedNode.type.name !== 'markdownDocLink') {
- return false
- }
- const newTarget = getDocLinkTarget(updatedNode)
- const newAlias = getDocLinkAlias(updatedNode)
- dom.setAttribute('data-doc-link-target', newTarget)
- if (newAlias) {
- dom.setAttribute('data-doc-link-label', newAlias)
- } else {
- dom.removeAttribute('data-doc-link-label')
- }
- dom.textContent = getDocLinkDisplayText(updatedNode)
- applyResolutionClass(newTarget)
- return true
+ target: {
+ default: '',
+ parseHTML: (el: HTMLElement) => el.getAttribute('data-doc-link-target') ?? ''
+ },
+ label: {
+ default: null,
+ parseHTML: (el: HTMLElement) => el.getAttribute('data-doc-link-label')
}
}
- }
- },
+ },
- // Why: a ProseMirror plugin (not an input rule) so that [[target]] typed in
- // any order — brackets first then target, paste, etc. — converts to a doc
- // link node. Input rules only fire on sequential append at the cursor.
- addProseMirrorPlugins() {
- const nodeType = this.type
- const storage = this.storage as DocLinkStorage
- return [
- // Why: when the cursor is adjacent to a doc link atom and the user presses
- // an arrow key toward it, dissolve the atom back to editable [[target]] text.
- // Without this, atom nodes are un-enterable — the cursor jumps over them.
- // Uses handleKeyDown (not addKeyboardShortcuts) so we can check modifier
- // keys and let Shift+Arrow extend the selection normally.
- new Plugin({
- key: docLinkDissolveKey,
- props: {
- handleKeyDown(view, event) {
- if (event.shiftKey || event.altKey || event.metaKey || event.ctrlKey) {
+ markdownTokenName: 'markdownDocLink',
+ markdownTokenizer: {
+ name: 'markdownDocLink',
+ level: 'inline',
+ start: transport.startFor('document-link'),
+ tokenize(src: string) {
+ const matched = transport.match(src, 'document-link')
+ if (!matched) {
+ return undefined
+ }
+ const link = isReservedRichMarkdownTransportBody(matched.value)
+ ? null
+ : parseMarkdownDocLink(matched.value)
+ if (!link) {
+ return undefined
+ }
+
+ return {
+ type: 'markdownDocLink',
+ raw: matched.raw,
+ text: link.target,
+ label: link.alias ?? undefined
+ }
+ }
+ },
+
+ parseMarkdown: (token, helpers) => {
+ if (token.type !== 'markdownDocLink') {
+ return []
+ }
+ return helpers.createNode('markdownDocLink', {
+ target: typeof token.text === 'string' ? token.text : '',
+ label:
+ typeof (token as { label?: unknown }).label === 'string'
+ ? (token as { label: string }).label
+ : null
+ })
+ },
+
+ renderMarkdown: (node) =>
+ formatMarkdownDocLink(
+ typeof node.attrs?.target === 'string' ? node.attrs.target : '',
+ typeof node.attrs?.label === 'string' ? node.attrs.label : null
+ ),
+ renderText: ({ node }) => getDocLinkDisplayText(node),
+
+ addNodeView() {
+ const storage = this.storage as DocLinkStorage
+ return ({ node }: { node: { type: { name: string }; attrs: Record } }) => {
+ const target = getDocLinkTarget(node)
+ const dom = document.createElement('span')
+ dom.setAttribute('data-doc-link-target', target)
+ const alias = getDocLinkAlias(node)
+ if (alias) {
+ dom.setAttribute('data-doc-link-label', alias)
+ }
+ dom.setAttribute('contenteditable', 'false')
+ dom.textContent = getDocLinkDisplayText(node)
+
+ const applyResolutionClass = (t: string): void => {
+ const resolved = resolveAgainstIndex(t, getDocIndex(storage))
+ dom.className = resolved
+ ? 'rich-markdown-doc-link'
+ : 'rich-markdown-doc-link rich-markdown-doc-link--missing'
+ }
+
+ applyResolutionClass(target)
+
+ return {
+ dom,
+ // Why: this fires on every transaction, including the no-op dispatched
+ // when the document list changes in storage. Re-checking resolution
+ // here keeps the blue/grey styling current without a full re-render.
+ update: (updatedNode: { type: { name: string }; attrs: Record }) => {
+ if (updatedNode.type.name !== 'markdownDocLink') {
return false
}
- let direction: 'left' | 'right'
- if (event.key === 'ArrowLeft') {
- direction = 'left'
- } else if (event.key === 'ArrowRight') {
- direction = 'right'
+ const newTarget = getDocLinkTarget(updatedNode)
+ const newAlias = getDocLinkAlias(updatedNode)
+ dom.setAttribute('data-doc-link-target', newTarget)
+ if (newAlias) {
+ dom.setAttribute('data-doc-link-label', newAlias)
} else {
- return false
+ dom.removeAttribute('data-doc-link-label')
}
- const { state } = view
- if (!(state.selection instanceof TextSelection)) {
- return false
- }
- const { $from } = state.selection
- const adjacent = direction === 'left' ? $from.nodeBefore : $from.nodeAfter
- if (!adjacent || adjacent.type.name !== 'markdownDocLink') {
- return false
- }
- const target = getDocLinkTarget(adjacent)
- const text = formatMarkdownDocLink(target, getDocLinkAlias(adjacent))
- const nodeStart = direction === 'left' ? $from.pos - adjacent.nodeSize : $from.pos
- const nodeEnd = nodeStart + adjacent.nodeSize
- const tr = state.tr.replaceWith(nodeStart, nodeEnd, state.schema.text(text))
- const cursorPos = direction === 'left' ? nodeStart + text.length - 2 : nodeStart + 2
- tr.setSelection(TextSelection.create(tr.doc, cursorPos))
- view.dispatch(tr)
+ dom.textContent = getDocLinkDisplayText(updatedNode)
+ applyResolutionClass(newTarget)
return true
}
}
- }),
+ }
+ },
- new Plugin({
- key: docLinkAutoConvertKey,
- appendTransaction(_transactions, _oldState, newState) {
- const { tr } = newState
- const cursor = newState.selection.from
- let modified = false
-
- newState.doc.descendants((node, pos, parent) => {
- if (node.type.name !== 'text' || !node.text) {
- return
- }
- if (isDocLinkLiteralCodeTextNode(node, parent)) {
- return
+ // Why: a ProseMirror plugin (not an input rule) so that [[target]] typed in
+ // any order — brackets first then target, paste, etc. — converts to a doc
+ // link node. Input rules only fire on sequential append at the cursor.
+ addProseMirrorPlugins() {
+ const nodeType = this.type
+ const storage = this.storage as DocLinkStorage
+ return [
+ // Why: when the cursor is adjacent to a doc link atom and the user presses
+ // an arrow key toward it, dissolve the atom back to editable [[target]] text.
+ // Without this, atom nodes are un-enterable — the cursor jumps over them.
+ // Uses handleKeyDown (not addKeyboardShortcuts) so we can check modifier
+ // keys and let Shift+Arrow extend the selection normally.
+ new Plugin({
+ key: docLinkDissolveKey,
+ props: {
+ handleKeyDown(view, event) {
+ if (event.shiftKey || event.altKey || event.metaKey || event.ctrlKey) {
+ return false
+ }
+ let direction: 'left' | 'right'
+ if (event.key === 'ArrowLeft') {
+ direction = 'left'
+ } else if (event.key === 'ArrowRight') {
+ direction = 'right'
+ } else {
+ return false
+ }
+ const { state } = view
+ if (!(state.selection instanceof TextSelection)) {
+ return false
+ }
+ const { $from } = state.selection
+ const adjacent = direction === 'left' ? $from.nodeBefore : $from.nodeAfter
+ if (!adjacent || adjacent.type.name !== 'markdownDocLink') {
+ return false
+ }
+ const target = getDocLinkTarget(adjacent)
+ const text = formatMarkdownDocLink(target, getDocLinkAlias(adjacent))
+ const nodeStart = direction === 'left' ? $from.pos - adjacent.nodeSize : $from.pos
+ const nodeEnd = nodeStart + adjacent.nodeSize
+ const tr = state.tr.replaceWith(nodeStart, nodeEnd, state.schema.text(text))
+ const cursorPos = direction === 'left' ? nodeStart + text.length - 2 : nodeStart + 2
+ tr.setSelection(TextSelection.create(tr.doc, cursorPos))
+ view.dispatch(tr)
+ return true
}
+ }
+ }),
- for (const match of node.text.matchAll(DOC_LINK_PATTERN)) {
- const link = parseMarkdownDocLink(match[1])
- if (!link || match.index === undefined) {
- continue
+ new Plugin({
+ key: docLinkAutoConvertKey,
+ appendTransaction(_transactions, _oldState, newState) {
+ const { tr } = newState
+ const cursor = newState.selection.from
+ let modified = false
+
+ newState.doc.descendants((node, pos, parent) => {
+ if (node.type.name !== 'text' || !node.text) {
+ return
+ }
+ if (isDocLinkLiteralCodeTextNode(node, parent)) {
+ return
}
- const from = pos + match.index
- const to = from + match[0].length
+ for (const match of node.text.matchAll(DOC_LINK_PATTERN)) {
+ const link = isReservedRichMarkdownTransportBody(match[1])
+ ? null
+ : parseMarkdownDocLink(match[1])
+ if (!link || match.index === undefined) {
+ continue
+ }
- // Why: skip when the cursor is anywhere from just inside [[
- // through the closing ]]. The inline preview decoration gives
- // real-time resolution feedback while the user is still editing.
- if (cursor > from && cursor <= to) {
- continue
+ const from = pos + match.index
+ const to = from + match[0].length
+
+ // Why: skip when the cursor is anywhere from just inside [[
+ // through the closing ]]. The inline preview decoration gives
+ // real-time resolution feedback while the user is still editing.
+ if (cursor > from && cursor <= to) {
+ continue
+ }
+
+ const docLinkNode = nodeType.create({ target: link.target, label: link.alias })
+ tr.replaceWith(tr.mapping.map(from), tr.mapping.map(to), docLinkNode)
+ modified = true
}
+ })
- const docLinkNode = nodeType.create({ target: link.target, label: link.alias })
- tr.replaceWith(tr.mapping.map(from), tr.mapping.map(to), docLinkNode)
- modified = true
+ return modified ? tr : null
+ }
+ }),
+
+ // Why: while the cursor is inside [[target]], the text hasn't converted
+ // to an atom node yet. This decoration gives real-time blue/grey feedback
+ // so the user knows whether the target resolves before moving the cursor out.
+ new Plugin({
+ key: docLinkInlinePreviewKey,
+ state: {
+ init(_, state) {
+ return buildPreviewDecorations(state, storage)
+ },
+ apply(tr, prev, oldState, newState) {
+ const selectionMoved = !oldState.selection.eq(newState.selection)
+ if (!tr.docChanged && !selectionMoved && !tr.getMeta('docLinksUpdated')) {
+ return prev
+ }
+ return buildPreviewDecorations(newState, storage)
}
- })
-
- return modified ? tr : null
- }
- }),
-
- // Why: while the cursor is inside [[target]], the text hasn't converted
- // to an atom node yet. This decoration gives real-time blue/grey feedback
- // so the user knows whether the target resolves before moving the cursor out.
- new Plugin({
- key: docLinkInlinePreviewKey,
- state: {
- init(_, state) {
- return buildPreviewDecorations(state, storage)
},
- apply(tr, prev, oldState, newState) {
- const selectionMoved = !oldState.selection.eq(newState.selection)
- if (!tr.docChanged && !selectionMoved && !tr.getMeta('docLinksUpdated')) {
- return prev
+ props: {
+ decorations(state) {
+ return docLinkInlinePreviewKey.getState(state)
}
- return buildPreviewDecorations(newState, storage)
}
- },
- props: {
- decorations(state) {
- return docLinkInlinePreviewKey.getState(state)
- }
- }
- })
- ]
- },
+ })
+ ]
+ },
- parseHTML() {
- return [{ tag: 'span[data-doc-link-target]' }]
- },
+ parseHTML() {
+ return [{ tag: 'span[data-doc-link-target]' }]
+ },
- renderHTML({ HTMLAttributes, node }) {
- const target = typeof node.attrs.target === 'string' ? node.attrs.target : ''
- const label = typeof node.attrs.label === 'string' ? node.attrs.label : null
- return [
- 'span',
- mergeAttributes(HTMLAttributes, {
- 'data-doc-link-target': target,
- ...(label ? { 'data-doc-link-label': label } : {}),
- contenteditable: 'false',
- class: 'rich-markdown-doc-link'
- }),
- label ?? target
- ]
- }
-})
+ renderHTML({ HTMLAttributes, node }) {
+ return renderRichMarkdownDocLinkHtml(node, HTMLAttributes)
+ }
+ })
+}
diff --git a/src/renderer/src/components/editor/rich-markdown-editor-click-routing.ts b/src/renderer/src/components/editor/rich-markdown-editor-click-routing.ts
index 4df725b8b..4c0f0ca96 100644
--- a/src/renderer/src/components/editor/rich-markdown-editor-click-routing.ts
+++ b/src/renderer/src/components/editor/rich-markdown-editor-click-routing.ts
@@ -2,8 +2,7 @@ import type { MutableRefObject } from 'react'
import type { Editor } from '@tiptap/react'
import type { EditorView } from '@tiptap/pm/view'
import { toast } from 'sonner'
-import { getConnectionId } from '@/lib/connection-context'
-import { openHttpLink } from '@/lib/http-link-routing'
+import { openHttpLink, type HttpLinkSourceOwner } from '@/lib/http-link-routing'
import { isLocalPathOpenBlocked, showLocalPathOpenBlockedToast } from '@/lib/local-path-open-guard'
import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client'
import {
@@ -14,6 +13,10 @@ import { scrollToAnchorInEditor } from './markdown-anchor-scroll'
import { getRichMarkdownCommentAtPos } from './rich-markdown-review-annotations'
import type { DiffComment } from '../../../../shared/types'
import { translate } from '@/i18n/i18n'
+import {
+ classifyHtmlSuperscriptLinkAction,
+ type RichMarkdownHtmlSuperscriptLinkContext
+} from './rich-markdown-html-superscript-link-context'
export type ActivateMarkdownLink = (
href: string,
@@ -22,6 +25,7 @@ export type ActivateMarkdownLink = (
worktreeId: string
worktreeRoot: string | null
runtimeEnvironmentId?: string | null
+ sourceOwner?: HttpLinkSourceOwner
}
) => void | Promise
@@ -33,6 +37,7 @@ type RichMarkdownEditorClickRoutingOptions = {
event: MouseEvent
filePath: string
isMac: boolean
+ htmlSuperscriptLinkContext: RichMarkdownHtmlSuperscriptLinkContext
markdownCommentsRef: MutableRefObject
markdownSourceLineOffsetRef: MutableRefObject
onOpenDocLinkRef: MutableRefObject<((target: string) => void) | undefined>
@@ -52,6 +57,7 @@ export function handleRichMarkdownEditorClick({
event,
filePath,
isMac,
+ htmlSuperscriptLinkContext,
markdownCommentsRef,
markdownSourceLineOffsetRef,
onOpenDocLinkRef,
@@ -65,6 +71,8 @@ export function handleRichMarkdownEditorClick({
worktreeRoot
}: RichMarkdownEditorClickRoutingOptions): boolean {
const editor = editorRef.current
+ const sourceSnapshot = htmlSuperscriptLinkContext.getSnapshot()
+ const sourceOwner = sourceSnapshot.sourceOwner
const modKey = isMac ? event.metaKey : event.ctrlKey
if (!editor) {
return false
@@ -88,6 +96,7 @@ export function handleRichMarkdownEditorClick({
filePath,
runtimeEnvironmentId,
src: (clickedNode.attrs.src as string | undefined) ?? '',
+ sourceOwner,
worktreeId,
worktreeRoot
})
@@ -96,7 +105,16 @@ export function handleRichMarkdownEditorClick({
onOpenDocLinkRef.current?.(clickedNode.attrs.target as string)
return true
}
- const href = getClickedLinkHref(view, pos)
+ const href =
+ clickedNode?.type.name === 'richMarkdownHtmlSuperscriptLink'
+ ? String(clickedNode.attrs.href ?? '')
+ : getClickedLinkHref(view, pos)
+ if (
+ clickedNode?.type.name === 'richMarkdownHtmlSuperscriptLink' &&
+ !classifyHtmlSuperscriptLinkAction(href, sourceSnapshot)
+ ) {
+ return true
+ }
if (!href) {
return false
}
@@ -109,8 +127,8 @@ export function handleRichMarkdownEditorClick({
href,
filePath,
runtimeEnvironmentId,
+ sourceOwner,
settings,
- worktreeId,
worktreeRoot
})
return true
@@ -119,7 +137,8 @@ export function handleRichMarkdownEditorClick({
sourceFilePath: filePath,
worktreeId,
worktreeRoot,
- runtimeEnvironmentId
+ runtimeEnvironmentId,
+ sourceOwner
})
return true
}
@@ -128,6 +147,7 @@ function activateMarkdownImageClick({
activateMarkdownLink,
filePath,
runtimeEnvironmentId,
+ sourceOwner,
src,
worktreeId,
worktreeRoot
@@ -135,6 +155,7 @@ function activateMarkdownImageClick({
activateMarkdownLink: ActivateMarkdownLink
filePath: string
runtimeEnvironmentId?: string | null
+ sourceOwner?: HttpLinkSourceOwner
src: string
worktreeId: string
worktreeRoot: string | null
@@ -146,7 +167,8 @@ function activateMarkdownImageClick({
sourceFilePath: filePath,
worktreeId,
worktreeRoot,
- runtimeEnvironmentId
+ runtimeEnvironmentId,
+ sourceOwner
})
return true
}
@@ -162,24 +184,27 @@ function getClickedLinkHref(view: EditorView, pos: number): string {
function openMarkdownLinkInClientOs({
href,
filePath,
- worktreeId,
worktreeRoot,
runtimeEnvironmentId,
+ sourceOwner,
settings
}: {
href: string
filePath: string
- worktreeId: string
worktreeRoot: string | null
runtimeEnvironmentId?: string | null
+ sourceOwner: HttpLinkSourceOwner
settings: RichMarkdownRuntimeSettings
}): void {
+ if (sourceOwner.kind === 'unknown') {
+ return
+ }
const classified = resolveMarkdownLinkTarget(href, filePath, worktreeRoot)
if (!classified) {
return
}
if (classified.kind === 'external') {
- openHttpLink(classified.url, { forceSystemBrowser: true })
+ openHttpLink(classified.url, { forceSystemBrowser: true, sourceOwner })
return
}
if (classified.kind === 'anchor') {
@@ -187,7 +212,7 @@ function openMarkdownLinkInClientOs({
}
if (
isLocalPathOpenBlocked(settingsForRuntimeOwner(settings, runtimeEnvironmentId), {
- connectionId: getConnectionId(worktreeId)
+ connectionId: sourceOwner.kind === 'ssh' ? sourceOwner.connectionId : undefined
})
) {
// Why: Shift-click opens through the client OS, which cannot safely resolve
diff --git a/src/renderer/src/components/editor/rich-markdown-editor-config.test.ts b/src/renderer/src/components/editor/rich-markdown-editor-config.test.ts
index 20d969ca8..45f43e77e 100644
--- a/src/renderer/src/components/editor/rich-markdown-editor-config.test.ts
+++ b/src/renderer/src/components/editor/rich-markdown-editor-config.test.ts
@@ -8,6 +8,8 @@ import {
type EditorConfigParams
} from './rich-markdown-editor-config'
import type { SlashMenuState } from './rich-markdown-slash-commands'
+import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport'
+import { createRichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-html-superscript-link-context'
function ref(current: T): MutableRefObject {
return { current }
@@ -25,7 +27,15 @@ function getSpellcheckAttribute(config: ReturnType = {}): EditorConfigParams {
+ const codec = createRichMarkdownEditorCodec()
return {
+ codec,
+ htmlSuperscriptLinkContext: createRichMarkdownHtmlSuperscriptLinkContext({
+ sourceFilePath: '/repo/README.md',
+ worktreeId: 'worktree-1',
+ worktreeRoot: '/repo',
+ sourceOwner: { kind: 'local' }
+ }),
content: '',
filePath: '/repo/README.md',
worktreeId: 'worktree-1',
diff --git a/src/renderer/src/components/editor/rich-markdown-editor-config.ts b/src/renderer/src/components/editor/rich-markdown-editor-config.ts
index 28536b258..12ec6dc92 100644
--- a/src/renderer/src/components/editor/rich-markdown-editor-config.ts
+++ b/src/renderer/src/components/editor/rich-markdown-editor-config.ts
@@ -15,7 +15,7 @@ import {
type DocLinkMenuState
} from './rich-markdown-commands'
import { isSingleEmptyTopLevelOrderedList } from './rich-markdown-list-continuation'
-import { getLinkBubblePosition, type LinkBubbleState } from './RichMarkdownLinkBubble'
+import type { LinkBubbleState } from './RichMarkdownLinkBubble'
import {
handleRichMarkdownEditorClick,
type ActivateMarkdownLink,
@@ -29,8 +29,16 @@ import {
import { getRichMarkdownSpellcheckAttribute } from './rich-markdown-spellcheck'
import type { MutableRefObject, Dispatch, SetStateAction } from 'react'
import type { DiffComment } from '../../../../shared/types'
+import type { RichMarkdownEditorCodec } from './rich-markdown-source-transport'
+import type { RichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-html-superscript-link-context'
+import {
+ getRichMarkdownSelectionLinkBubble,
+ openSelectedHtmlSuperscriptLink
+} from './rich-markdown-selected-link-actions'
export type EditorConfigParams = {
+ codec: RichMarkdownEditorCodec
+ htmlSuperscriptLinkContext: RichMarkdownHtmlSuperscriptLinkContext
content: string
filePath: string
worktreeId: string
@@ -79,6 +87,8 @@ export type EditorConfigParams = {
export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseEditorOptions {
const {
content,
+ codec,
+ htmlSuperscriptLinkContext,
filePath,
worktreeId,
worktreeRoot,
@@ -125,7 +135,7 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE
return {
immediatelyRender: false,
- content: encodeRawMarkdownHtmlForRichEditor(content),
+ content: encodeRawMarkdownHtmlForRichEditor(content, codec, { htmlSuperscriptLinks: true }),
contentType: 'markdown' as const,
editorProps: {
attributes: {
@@ -135,13 +145,15 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE
handleDOMEvents: {
cut: handleRichMarkdownCut
},
- handlePaste: (_view, event) =>
+ handlePaste: (view, event, slice) =>
handleRichMarkdownPaste({
editor: editorRef.current,
event,
filePath,
worktreeId,
- runtimeEnvironmentId
+ runtimeEnvironmentId,
+ slice,
+ view
}),
handleTextInput: (view, from, to, text) => {
typedEmptyOrderedListMarkerRef.current = false
@@ -172,12 +184,22 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE
typedEmptyOrderedListMarkerRef,
flushPendingSerialization,
openSearchRef,
+ linkBubbleOwnerId: codec.transport.key,
+ htmlSuperscriptLinkContext,
setIsEditingLink,
setLinkBubble,
setSelectedCommandIndex,
setSelectedDocLinkIndex,
setSlashMenu,
- setDocLinkMenu
+ setDocLinkMenu,
+ openSelectedHtmlSuperscriptLink: () =>
+ openSelectedHtmlSuperscriptLink({
+ activateMarkdownLink,
+ context: htmlSuperscriptLinkContext,
+ editor: editorRef.current,
+ root: rootRef.current,
+ runtimeEnvironmentId
+ })
}),
handleClick: (view, pos, event) => {
return handleRichMarkdownEditorClick({
@@ -185,6 +207,7 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE
editorRef,
event,
filePath,
+ htmlSuperscriptLinkContext,
isMac,
markdownCommentsRef,
markdownSourceLineOffsetRef,
@@ -259,13 +282,9 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE
syncDocLinkMenu(nextEditor, rootRef.current, setDocLinkMenu)
syncAnnotationTarget(nextEditor)
setIsEditingLink(false)
- if (nextEditor.isActive('link')) {
- const attrs = nextEditor.getAttributes('link')
- const pos = getLinkBubblePosition(nextEditor, rootRef.current)
- setLinkBubble(pos ? { href: (attrs.href as string) || '', ...pos } : null)
- } else {
- setLinkBubble(null)
- }
+ setLinkBubble(
+ getRichMarkdownSelectionLinkBubble(nextEditor, rootRef.current, htmlSuperscriptLinkContext)
+ )
}
}
}
diff --git a/src/renderer/src/components/editor/rich-markdown-empty-paragraph-delete.test.ts b/src/renderer/src/components/editor/rich-markdown-empty-paragraph-delete.test.ts
index bfdb60af4..513d90bcb 100644
--- a/src/renderer/src/components/editor/rich-markdown-empty-paragraph-delete.test.ts
+++ b/src/renderer/src/components/editor/rich-markdown-empty-paragraph-delete.test.ts
@@ -1,10 +1,10 @@
import { describe, expect, it } from 'vitest'
import { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
-import { Markdown } from '@tiptap/markdown'
+import { createIsolatedMarkdownExtensionForTests } from './isolated-markdown-extension-for-tests'
import { deleteAdjacentEmptyParagraph } from './rich-markdown-empty-paragraph-delete'
-const extensions = [StarterKit, Markdown.configure({ markedOptions: { gfm: true } })]
+const extensions = [StarterKit, createIsolatedMarkdownExtensionForTests()]
const hardWrappedMarkdown =
'Alpha owns launch-lifetime state keyed by tab id, while native\n' +
diff --git a/src/renderer/src/components/editor/rich-markdown-extensions.ts b/src/renderer/src/components/editor/rich-markdown-extensions.ts
index 3b23a9df2..a19e307b5 100644
--- a/src/renderer/src/components/editor/rich-markdown-extensions.ts
+++ b/src/renderer/src/components/editor/rich-markdown-extensions.ts
@@ -16,16 +16,23 @@ import { Markdown } from '@tiptap/markdown'
import { createLowlight, common } from 'lowlight'
import { loadLocalImageSrc, onImageCacheInvalidated } from './useLocalImageSrc'
import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client'
-import { RawMarkdownHtmlBlock, RawMarkdownHtmlInline } from './raw-markdown-html'
+import {
+ createRawMarkdownHtmlBlock,
+ createRawMarkdownHtmlInline,
+ createRichMarkdownLiteral
+} from './raw-markdown-html'
import {
createOrcaDetailsExtensions,
getRichMarkdownPlaceholder
} from './rich-markdown-details-extension'
-import { MarkdownDocLink } from './rich-markdown-doc-link'
+import { createMarkdownDocLink } from './rich-markdown-doc-link'
import { RichMarkdownCodeBlock } from './RichMarkdownCodeBlock'
import { safeReactNodeViewRenderer } from './safe-react-node-view-renderer'
import { DragSelectionGuard } from './drag-selection-guard'
import { createRichMarkdownAnnotationHighlightExtension } from './rich-markdown-annotation-highlight'
+import type { RichMarkdownEditorCodec } from './rich-markdown-source-transport'
+import { createRichMarkdownHtmlSuperscriptLink } from './rich-markdown-html-superscript-link'
+import type { RichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-html-superscript-link-context'
const lowlight = createLowlight(common)
@@ -42,10 +49,19 @@ const RichMarkdownCode = Code.extend({
})
export function createRichMarkdownExtensions({
- includePlaceholder = false
+ codec,
+ includePlaceholder = false,
+ htmlSuperscriptLinks = false,
+ htmlSuperscriptLinkContext
}: {
+ codec: RichMarkdownEditorCodec
includePlaceholder?: boolean
-} = {}): AnyExtension[] {
+ htmlSuperscriptLinks?: boolean
+ htmlSuperscriptLinkContext?: RichMarkdownHtmlSuperscriptLinkContext
+}): AnyExtension[] {
+ if (htmlSuperscriptLinks && !htmlSuperscriptLinkContext) {
+ throw new Error('HTML superscript links require a document interaction context')
+ }
const extensions: AnyExtension[] = [
// Why: rich-mode detection must use the exact same markdown extension set as
// the live editor. If these drift, Orca can claim a document is editable in
@@ -196,11 +212,16 @@ export function createRichMarkdownExtensions({
throwOnError: false
}
}),
- RawMarkdownHtmlInline,
- RawMarkdownHtmlBlock,
- MarkdownDocLink,
+ createRichMarkdownLiteral(codec.transport),
+ ...(htmlSuperscriptLinks
+ ? [createRichMarkdownHtmlSuperscriptLink(codec.transport, htmlSuperscriptLinkContext!)]
+ : []),
+ createRawMarkdownHtmlInline(codec.transport),
+ createRawMarkdownHtmlBlock(codec.transport),
+ createMarkdownDocLink(codec.transport),
DragSelectionGuard,
Markdown.configure({
+ marked: codec.marked,
markedOptions: {
gfm: true
}
diff --git a/src/renderer/src/components/editor/rich-markdown-html-superscript-link-context.ts b/src/renderer/src/components/editor/rich-markdown-html-superscript-link-context.ts
new file mode 100644
index 000000000..c4582e371
--- /dev/null
+++ b/src/renderer/src/components/editor/rich-markdown-html-superscript-link-context.ts
@@ -0,0 +1,73 @@
+import type { HttpLinkSourceOwner } from '@/lib/http-link-routing'
+import { resolveMarkdownLinkTarget } from './markdown-internal-links'
+
+export type RichMarkdownHtmlSuperscriptLinkContextSnapshot = {
+ version: number
+ sourceFilePath: string
+ worktreeId: string
+ worktreeRoot: string | null
+ sourceOwner: HttpLinkSourceOwner
+}
+
+export type RichMarkdownHtmlSuperscriptLinkContext = {
+ getSnapshot: () => RichMarkdownHtmlSuperscriptLinkContextSnapshot
+ subscribe: (listener: () => void) => () => void
+ update: (snapshot: Omit) => void
+}
+
+export function createRichMarkdownHtmlSuperscriptLinkContext(
+ initial: Omit
+): RichMarkdownHtmlSuperscriptLinkContext {
+ let snapshot = { ...initial, version: 0 }
+ const listeners = new Set<() => void>()
+ return {
+ getSnapshot: () => snapshot,
+ subscribe: (listener) => {
+ listeners.add(listener)
+ return () => listeners.delete(listener)
+ },
+ update: (next) => {
+ if (
+ next.sourceFilePath === snapshot.sourceFilePath &&
+ next.worktreeId === snapshot.worktreeId &&
+ next.worktreeRoot === snapshot.worktreeRoot &&
+ sameOwner(next.sourceOwner, snapshot.sourceOwner)
+ ) {
+ return
+ }
+ snapshot = { ...next, version: snapshot.version + 1 }
+ listeners.forEach((listener) => listener())
+ }
+ }
+}
+
+export function classifyHtmlSuperscriptLinkAction(
+ href: string,
+ snapshot: RichMarkdownHtmlSuperscriptLinkContextSnapshot
+): boolean {
+ if (snapshot.sourceOwner.kind === 'unknown' || /^[\t\n\f\r ]*$/.test(href)) {
+ return false
+ }
+ const target = resolveMarkdownLinkTarget(href, snapshot.sourceFilePath, snapshot.worktreeRoot)
+ if (!target) {
+ return false
+ }
+ return !(
+ target.kind === 'file' &&
+ target.relativePath === undefined &&
+ (snapshot.sourceOwner.kind === 'runtime' || snapshot.sourceOwner.kind === 'ssh')
+ )
+}
+
+function sameOwner(left: HttpLinkSourceOwner, right: HttpLinkSourceOwner): boolean {
+ if (left.kind !== right.kind) {
+ return false
+ }
+ if (left.kind === 'runtime' && right.kind === 'runtime') {
+ return left.runtimeEnvironmentId === right.runtimeEnvironmentId
+ }
+ if (left.kind === 'ssh' && right.kind === 'ssh') {
+ return left.connectionId === right.connectionId
+ }
+ return true
+}
diff --git a/src/renderer/src/components/editor/rich-markdown-html-superscript-link-source.ts b/src/renderer/src/components/editor/rich-markdown-html-superscript-link-source.ts
new file mode 100644
index 000000000..d0eb8c352
--- /dev/null
+++ b/src/renderer/src/components/editor/rich-markdown-html-superscript-link-source.ts
@@ -0,0 +1,293 @@
+import {
+ decodeHtmlAttributeCharacterReferences,
+ decodeHtmlTextCharacterReferences,
+ type HtmlAttributeQuote
+} from './html-character-reference-decoder'
+
+export const HTML_SUPERSCRIPT_LINK_SOURCE_LIMIT = 16 * 1024
+export const HTML_SUPERSCRIPT_LINK_HREF_LIMIT = 8 * 1024
+export const HTML_SUPERSCRIPT_LINK_TEXT_LIMIT = 2 * 1024
+
+export type HtmlSuperscriptLinkSource = {
+ source: string
+ href: string
+ label: string
+ title: string | null
+}
+
+export type HtmlSuperscriptLinkMatch = {
+ end: number
+ value: HtmlSuperscriptLinkSource
+}
+
+export type HtmlSuperscriptLinkParseStats = {
+ transitions: number
+}
+
+type ParsedAttribute = {
+ name: 'href' | 'title'
+ rawValue: string
+ quote: HtmlAttributeQuote
+}
+
+const encoder = new TextEncoder()
+
+export function matchHtmlSuperscriptLinkSource(
+ input: string,
+ start = 0,
+ stats?: HtmlSuperscriptLinkParseStats
+): HtmlSuperscriptLinkMatch | null {
+ const limit = Math.min(input.length, start + HTML_SUPERSCRIPT_LINK_SOURCE_LIMIT + 1)
+ let index = matchSimpleTag(input, start, 'sup', false, limit, stats)
+ if (index === null) {
+ return null
+ }
+
+ const anchor = matchAnchorStart(input, index, limit, stats)
+ if (!anchor) {
+ return null
+ }
+ index = anchor.end
+
+ const labelStart = index
+ while (index < limit) {
+ step(stats)
+ const code = input.charCodeAt(index)
+ if (code === 60 || code === 10 || code === 13) {
+ break
+ }
+ index += 1
+ }
+ if (index === labelStart || index >= limit || input.charCodeAt(index) !== 60) {
+ return null
+ }
+ const rawLabel = input.slice(labelStart, index)
+ index = matchSimpleTag(input, index, 'a', true, limit, stats) ?? -1
+ if (index < 0) {
+ return null
+ }
+ index = matchSimpleTag(input, index, 'sup', true, limit, stats) ?? -1
+ if (index < 0 || index - start > HTML_SUPERSCRIPT_LINK_SOURCE_LIMIT) {
+ return null
+ }
+
+ const hrefAttribute = anchor.attributes.find((attribute) => attribute.name === 'href')
+ const titleAttribute = anchor.attributes.find((attribute) => attribute.name === 'title')
+ if (!hrefAttribute) {
+ return null
+ }
+
+ const href = decodeHtmlAttributeCharacterReferences(hrefAttribute.rawValue, hrefAttribute.quote)
+ const label = decodeHtmlTextCharacterReferences(rawLabel)
+ const title = titleAttribute
+ ? decodeHtmlAttributeCharacterReferences(titleAttribute.rawValue, titleAttribute.quote)
+ : null
+ const source = input.slice(start, index)
+ if (
+ byteLength(source) > HTML_SUPERSCRIPT_LINK_SOURCE_LIMIT ||
+ byteLength(href) > HTML_SUPERSCRIPT_LINK_HREF_LIMIT ||
+ byteLength(label) > HTML_SUPERSCRIPT_LINK_TEXT_LIMIT ||
+ (title !== null && byteLength(title) > HTML_SUPERSCRIPT_LINK_TEXT_LIMIT)
+ ) {
+ return null
+ }
+
+ return { end: index, value: { source, href, label, title } }
+}
+
+export function parseHtmlSuperscriptLinkSource(source: string): HtmlSuperscriptLinkSource | null {
+ const match = matchHtmlSuperscriptLinkSource(source)
+ return match?.end === source.length ? match.value : null
+}
+
+function matchAnchorStart(
+ input: string,
+ start: number,
+ limit: number,
+ stats?: HtmlSuperscriptLinkParseStats
+): { end: number; attributes: ParsedAttribute[] } | null {
+ let index = start
+ if (input.charCodeAt(index) !== 60 || lowerCode(input.charCodeAt(index + 1)) !== 97) {
+ return null
+ }
+ step(stats, 2)
+ index += 2
+ if (!isSingleLineHtmlWhitespace(input.charCodeAt(index))) {
+ return null
+ }
+
+ const attributes: ParsedAttribute[] = []
+ while (index < limit) {
+ const whitespaceStart = index
+ while (isSingleLineHtmlWhitespace(input.charCodeAt(index))) {
+ step(stats)
+ index += 1
+ }
+ if (input.charCodeAt(index) === 62) {
+ step(stats)
+ return attributes.some((attribute) => attribute.name === 'href')
+ ? { end: index + 1, attributes }
+ : null
+ }
+ if (index === whitespaceStart) {
+ return null
+ }
+
+ const nameStart = index
+ while (isAttributeNameCode(input.charCodeAt(index))) {
+ step(stats)
+ index += 1
+ }
+ if (index === nameStart) {
+ return null
+ }
+ const normalizedName = input.slice(nameStart, index).toLowerCase()
+ if (normalizedName !== 'href' && normalizedName !== 'title') {
+ return null
+ }
+ if (attributes.some((attribute) => attribute.name === normalizedName)) {
+ return null
+ }
+ while (isSingleLineHtmlWhitespace(input.charCodeAt(index))) {
+ step(stats)
+ index += 1
+ }
+ if (input.charCodeAt(index) !== 61) {
+ return null
+ }
+ step(stats)
+ index += 1
+ while (isSingleLineHtmlWhitespace(input.charCodeAt(index))) {
+ step(stats)
+ index += 1
+ }
+
+ const parsedValue = parseAttributeValue(input, index, limit, stats)
+ if (!parsedValue) {
+ return null
+ }
+ attributes.push({
+ name: normalizedName,
+ rawValue: parsedValue.rawValue,
+ quote: parsedValue.quote
+ })
+ index = parsedValue.end
+ }
+ return null
+}
+
+function parseAttributeValue(
+ input: string,
+ start: number,
+ limit: number,
+ stats?: HtmlSuperscriptLinkParseStats
+): { end: number; rawValue: string; quote: HtmlAttributeQuote } | null {
+ const first = input[start]
+ if (first === '"' || first === "'") {
+ let index = start + 1
+ while (index < limit && input[index] !== first) {
+ step(stats)
+ const code = input.charCodeAt(index)
+ if (code === 10 || code === 13) {
+ return null
+ }
+ index += 1
+ }
+ if (index >= limit) {
+ return null
+ }
+ step(stats)
+ return {
+ end: index + 1,
+ rawValue: input.slice(start + 1, index),
+ quote: first
+ }
+ }
+
+ let index = start
+ while (index < limit) {
+ const code = input.charCodeAt(index)
+ if (isHtmlWhitespace(code) || code === 62) {
+ break
+ }
+ step(stats)
+ if (code === 34 || code === 39 || code === 60 || code === 61 || code === 96) {
+ return null
+ }
+ index += 1
+ }
+ return index === start ? null : { end: index, rawValue: input.slice(start, index), quote: null }
+}
+
+function matchSimpleTag(
+ input: string,
+ start: number,
+ name: 'a' | 'sup',
+ closing: boolean,
+ limit: number,
+ stats?: HtmlSuperscriptLinkParseStats
+): number | null {
+ let index = start
+ if (input.charCodeAt(index) !== 60) {
+ return null
+ }
+ step(stats)
+ index += 1
+ if (closing) {
+ if (input.charCodeAt(index) !== 47) {
+ return null
+ }
+ step(stats)
+ index += 1
+ }
+ for (let nameIndex = 0; nameIndex < name.length; nameIndex += 1) {
+ step(stats)
+ if (lowerCode(input.charCodeAt(index)) !== name.charCodeAt(nameIndex)) {
+ return null
+ }
+ index += 1
+ }
+ while (index < limit && isSingleLineHtmlWhitespace(input.charCodeAt(index))) {
+ step(stats)
+ index += 1
+ }
+ if (input.charCodeAt(index) !== 62) {
+ return null
+ }
+ step(stats)
+ return index + 1
+}
+
+function isAttributeNameCode(code: number): boolean {
+ return (
+ (code >= 65 && code <= 90) ||
+ (code >= 97 && code <= 122) ||
+ (code >= 48 && code <= 57) ||
+ code === 95 ||
+ code === 46 ||
+ code === 58 ||
+ code === 45
+ )
+}
+
+function isHtmlWhitespace(code: number): boolean {
+ return code === 9 || code === 10 || code === 12 || code === 13 || code === 32
+}
+
+function isSingleLineHtmlWhitespace(code: number): boolean {
+ return code === 9 || code === 12 || code === 32
+}
+
+function lowerCode(code: number): number {
+ return code >= 65 && code <= 90 ? code + 32 : code
+}
+
+function byteLength(value: string): number {
+ return encoder.encode(value).byteLength
+}
+
+function step(stats: HtmlSuperscriptLinkParseStats | undefined, count = 1): void {
+ if (stats) {
+ stats.transitions += count
+ }
+}
diff --git a/src/renderer/src/components/editor/rich-markdown-html-superscript-link.test.ts b/src/renderer/src/components/editor/rich-markdown-html-superscript-link.test.ts
new file mode 100644
index 000000000..a7dfc9a91
--- /dev/null
+++ b/src/renderer/src/components/editor/rich-markdown-html-superscript-link.test.ts
@@ -0,0 +1,593 @@
+// @vitest-environment happy-dom
+
+import { Editor } from '@tiptap/core'
+import { NodeSelection, TextSelection } from '@tiptap/pm/state'
+import { describe, expect, it, vi } from 'vitest'
+import { marked } from 'marked'
+import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html'
+import { createRichMarkdownExtensions } from './rich-markdown-extensions'
+import {
+ createRichMarkdownEditorCodec,
+ createRichMarkdownSourceTransport
+} from './rich-markdown-source-transport'
+import {
+ classifyHtmlSuperscriptLinkAction,
+ createRichMarkdownHtmlSuperscriptLinkContext
+} from './rich-markdown-html-superscript-link-context'
+import {
+ HTML_SUPERSCRIPT_LINK_SOURCE_LIMIT,
+ matchHtmlSuperscriptLinkSource,
+ parseHtmlSuperscriptLinkSource
+} from './rich-markdown-html-superscript-link-source'
+import { projectMarkdownHrefForClipboard } from './markdown-internal-links'
+import { findRichMarkdownSearchMatches } from './rich-markdown-search'
+import { findRichMarkdownSelectedTextRanges } from './rich-markdown-review-text-ranges'
+import { getRichMarkdownVisibleText } from './rich-markdown-visible-text-map'
+import { handleRichMarkdownCut } from './rich-markdown-cut-handler'
+import { getSelectedHtmlSuperscriptLinkStatus } from './rich-markdown-selected-link-actions'
+import { handleRichMarkdownCitationKey } from './rich-markdown-citation-keyboard'
+import { resolveRichMarkdownWorktreeRoot } from './useRichMarkdownSuperscriptLinkSetup'
+import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
+import type { AppState } from '@/store/types'
+import {
+ inspectRichMarkdownSourceOwningSlice,
+ RICH_MARKDOWN_SOURCE_OWNING_PASTE_LIMIT
+} from './rich-markdown-source-owning-slice'
+
+const TEST_KEY = '0123456789abcdef0123456789abcdef'
+
+function createEditor(content: string, key = TEST_KEY, element: HTMLElement | null = null): Editor {
+ const codec = createRichMarkdownEditorCodec(key)
+ const context = createRichMarkdownHtmlSuperscriptLinkContext({
+ sourceFilePath: '/repo/README.md',
+ worktreeId: 'worktree-1',
+ worktreeRoot: '/repo',
+ sourceOwner: { kind: 'local' }
+ })
+ return new Editor({
+ element,
+ extensions: createRichMarkdownExtensions({
+ codec,
+ htmlSuperscriptLinks: true,
+ htmlSuperscriptLinkContext: context
+ }),
+ content: encodeRawMarkdownHtmlForRichEditor(content, codec, {
+ htmlSuperscriptLinks: true
+ }),
+ contentType: 'markdown'
+ })
+}
+
+function nodeNames(editor: Editor): string[] {
+ const names: string[] = []
+ editor.state.doc.descendants((node) => {
+ names.push(node.type.name)
+ })
+ return names
+}
+
+describe('rich Markdown HTML superscript links', () => {
+ it('parses the reported fragment and preserves its exact source', () => {
+ const source = '[12]'
+ const editor = createEditor(`研究结果${source}。`)
+ try {
+ expect(nodeNames(editor)).toContain('richMarkdownHtmlSuperscriptLink')
+ expect(editor.getMarkdown()).toBe(`研究结果${source}。`)
+ const citation = editor.state.doc.firstChild?.child(1)
+ expect(citation?.attrs).toMatchObject({
+ source,
+ href: 'https://example.com/source',
+ label: '[12]',
+ title: null
+ })
+ } finally {
+ editor.destroy()
+ }
+ })
+
+ it('retains casing, whitespace, quote style, entities, title, and adjacency', () => {
+ const first =
+ "[¬it;]"
+ const second = '[13]'
+ const editor = createEditor(`${first}${second}`)
+ try {
+ expect(
+ nodeNames(editor).filter((name) => name === 'richMarkdownHtmlSuperscriptLink')
+ ).toHaveLength(2)
+ expect(editor.getMarkdown()).toBe(`${first}${second}`)
+ const citation = editor.state.doc.firstChild?.firstChild
+ expect(citation?.attrs.href).toBe('https://example.com/?a=1&b=2')
+ expect(citation?.attrs.title).toBe('A & B')
+ } finally {
+ editor.destroy()
+ }
+ })
+
+ it('leaves malformed and broadened HTML on the inert raw-source path', () => {
+ const rejected = [
+ '[12]',
+ '[12]',
+ '[12]',
+ '[12]',
+ '[12]\n',
+ '[12]',
+ '[12]',
+ '[12]',
+ '[12]'
+ ]
+ for (const source of rejected) {
+ const editor = createEditor(source)
+ try {
+ expect(nodeNames(editor)).not.toContain('richMarkdownHtmlSuperscriptLink')
+ if (!source.includes('\n')) {
+ expect(editor.getMarkdown()).toBe(source)
+ }
+ } finally {
+ editor.destroy()
+ }
+ }
+ })
+
+ it('does not recognize citations inside inline or fenced code', () => {
+ const source = '[12]'
+ const markdown = `\`${source}\`\n\n\`\`\`html\n${source}\n\`\`\``
+ const editor = createEditor(markdown)
+ try {
+ expect(nodeNames(editor)).not.toContain('richMarkdownHtmlSuperscriptLink')
+ expect(editor.getMarkdown()).toBe(markdown)
+ } finally {
+ editor.destroy()
+ }
+ })
+
+ it('preserves authored current-key and legacy transport-looking text', () => {
+ const transport = createRichMarkdownSourceTransport(TEST_KEY)
+ const authored = `${transport.create('inline-html', 'authored')} [[ORCA_RAW_HTML_INLINE:%3Ci%3Ex%3C%2Fi%3E]]`
+ const editor = createEditor(authored)
+ try {
+ editor.commands.insertContentAt(editor.state.doc.content.size - 1, '!')
+ expect(editor.getMarkdown()).toBe(`${authored}!`)
+ expect(nodeNames(editor)).not.toContain('markdownDocLink')
+ } finally {
+ editor.destroy()
+ }
+ })
+
+ it('keeps sequential codecs isolated and exposes the complete marked surface', () => {
+ const first = createRichMarkdownEditorCodec(TEST_KEY)
+ const second = createRichMarkdownEditorCodec('fedcba9876543210fedcba9876543210')
+ expect(Object.getOwnPropertyNames(first.marked).sort()).toEqual(
+ Object.getOwnPropertyNames(marked).sort()
+ )
+ const authoredFirstToken = first.transport.create('inline-html', 'K1')
+ const editor = createEditor(authoredFirstToken, second.transport.key)
+ try {
+ expect(editor.getMarkdown()).toBe(authoredFirstToken)
+ expect(nodeNames(editor)).not.toContain('rawMarkdownHtmlInline')
+ expect(nodeNames(editor)).not.toContain('markdownDocLink')
+ } finally {
+ editor.destroy()
+ }
+ })
+
+ it('keeps source matching linear across many rejected candidates', () => {
+ const input = ''.repeat(2_000)
+ let transitions = 0
+ for (let index = 0; index < input.length; index += 5) {
+ const stats = { transitions: 0 }
+ expect(matchHtmlSuperscriptLinkSource(input, index, stats)).toBeNull()
+ transitions += stats.transitions
+ }
+ expect(transitions).toBeLessThan(input.length * 4)
+ })
+
+ it('projects only browser-safe clipboard hrefs', () => {
+ expect(projectMarkdownHrefForClipboard(' https://example.com ')).toBe('https://example.com')
+ expect(projectMarkdownHrefForClipboard('#section')).toBe('#section')
+ expect(projectMarkdownHrefForClipboard('./guide.md')).toBe('./guide.md')
+ expect(projectMarkdownHrefForClipboard('javascript:alert(1)')).toBeNull()
+ expect(projectMarkdownHrefForClipboard('java\nscript:alert(1)')).toBeNull()
+ expect(projectMarkdownHrefForClipboard('C:\\repo\\guide.md')).toBeNull()
+ expect(projectMarkdownHrefForClipboard('\\\\server\\share\\guide.md')).toBeNull()
+ expect(projectMarkdownHrefForClipboard('//example.com/path')).toBeNull()
+ })
+
+ it('rejects duplicate attributes and parses quoted greater-than characters', () => {
+ expect(
+ parseHtmlSuperscriptLinkSource('[1]')?.href
+ ).toBe('https://example.com/?q=>')
+ expect(parseHtmlSuperscriptLinkSource('[1]')).toBeNull()
+ })
+
+ it('renders inert live DOM while exposing link semantics through Orca context', () => {
+ const host = document.createElement('div')
+ document.body.appendChild(host)
+ const source = '[12]'
+ const codec = createRichMarkdownEditorCodec(TEST_KEY)
+ const context = createRichMarkdownHtmlSuperscriptLinkContext({
+ sourceFilePath: '/repo/README.md',
+ worktreeId: 'worktree-1',
+ worktreeRoot: '/repo',
+ sourceOwner: { kind: 'local' }
+ })
+ const editor = new Editor({
+ element: host,
+ extensions: createRichMarkdownExtensions({
+ codec,
+ htmlSuperscriptLinks: true,
+ htmlSuperscriptLinkContext: context
+ }),
+ content: encodeRawMarkdownHtmlForRichEditor(source, codec, {
+ htmlSuperscriptLinks: true
+ }),
+ contentType: 'markdown'
+ })
+ try {
+ const liveLabel = host.querySelector(
+ 'sup[data-rich-markdown-html-superscript-link] > span'
+ )
+ expect(liveLabel?.textContent).toBe('[12]')
+ expect(liveLabel?.getAttribute('role')).toBe('link')
+ expect(host.querySelector('sup a')).toBeNull()
+ editor.view.dispatch(editor.state.tr.setSelection(NodeSelection.create(editor.state.doc, 1)))
+ expect(getSelectedHtmlSuperscriptLinkStatus(editor, context)).toEqual({
+ href: 'https://example.com',
+ label: '[12]',
+ openEnabled: true
+ })
+ context.update({
+ sourceFilePath: '/repo/README.md',
+ worktreeId: 'worktree-1',
+ worktreeRoot: '/repo',
+ sourceOwner: { kind: 'unknown' }
+ })
+ expect(liveLabel?.hasAttribute('role')).toBe(false)
+ expect(getSelectedHtmlSuperscriptLinkStatus(editor, context)?.openEnabled).toBe(false)
+ } finally {
+ editor.destroy()
+ host.remove()
+ }
+ })
+
+ it('keeps whitespace and remote outside-root destinations non-actionable', () => {
+ const local = {
+ version: 0,
+ sourceFilePath: '/repo/README.md',
+ worktreeId: 'worktree-1',
+ worktreeRoot: '/repo',
+ sourceOwner: { kind: 'local' as const }
+ }
+ expect(classifyHtmlSuperscriptLinkAction(' ', local)).toBe(false)
+ expect(classifyHtmlSuperscriptLinkAction('file:///etc/passwd', local)).toBe(true)
+ expect(
+ classifyHtmlSuperscriptLinkAction('file:///etc/passwd', {
+ ...local,
+ sourceOwner: { kind: 'ssh', connectionId: 'ssh-1' }
+ })
+ ).toBe(false)
+ expect(
+ classifyHtmlSuperscriptLinkAction('file:///etc/passwd', {
+ ...local,
+ sourceOwner: { kind: 'runtime', runtimeEnvironmentId: 'runtime-1' }
+ })
+ ).toBe(false)
+ })
+
+ it('scopes citation Tab focus to the selected editor bubble', () => {
+ const editor = createEditor('[12]')
+ const firstBubble = document.createElement('div')
+ firstBubble.dataset.richMarkdownLinkBubbleOwner = 'first-owner'
+ const firstButton = document.createElement('button')
+ firstBubble.appendChild(firstButton)
+ const secondBubble = document.createElement('div')
+ secondBubble.dataset.richMarkdownLinkBubbleOwner = 'second-owner'
+ const secondButton = document.createElement('button')
+ secondBubble.appendChild(secondButton)
+ document.body.append(firstBubble, secondBubble)
+ try {
+ editor.view.dispatch(editor.state.tr.setSelection(NodeSelection.create(editor.state.doc, 1)))
+ const event = {
+ key: 'Tab',
+ shiftKey: false,
+ isComposing: false,
+ preventDefault: () => {}
+ } as KeyboardEvent
+ expect(
+ handleRichMarkdownCitationKey({
+ editor,
+ event,
+ linkBubbleOwnerId: 'second-owner'
+ })
+ ).toBe(true)
+ expect(document.activeElement).toBe(secondButton)
+ } finally {
+ editor.destroy()
+ firstBubble.remove()
+ secondBubble.remove()
+ }
+ })
+
+ it('uses a folder workspace path as the citation source root', () => {
+ const state = {
+ folderWorkspaces: [{ id: 'folder-1', folderPath: '/workspace/platform' }],
+ worktreesByRepo: {}
+ } as Pick
+ expect(resolveRichMarkdownWorktreeRoot(state, folderWorkspaceKey('folder-1'))).toBe(
+ '/workspace/platform'
+ )
+ })
+
+ it('self-validates clipboard HTML and rejects a forged semantic mismatch', () => {
+ const source = '[12]'
+ const host = document.createElement('div')
+ const editor = createEditor(source, TEST_KEY, host)
+ try {
+ editor.view.dispatch(editor.state.tr.setSelection(NodeSelection.create(editor.state.doc, 1)))
+ const html = editor.view.serializeForClipboard(editor.state.selection.content()).dom.innerHTML
+ const pasted = createEditor('')
+ try {
+ pasted.commands.setContent(html, { contentType: 'html' })
+ expect(pasted.getMarkdown()).toBe(source)
+ } finally {
+ pasted.destroy()
+ }
+
+ const template = document.createElement('template')
+ template.innerHTML = html
+ const anchor = template.content.querySelector('a')
+ if (!anchor) {
+ throw new Error('expected serialized citation anchor')
+ }
+ anchor.textContent = '[99]'
+ const forged = createEditor('')
+ try {
+ forged.commands.setContent(template.innerHTML, { contentType: 'html' })
+ expect(nodeNames(forged)).not.toContain('richMarkdownHtmlSuperscriptLink')
+ expect(forged.state.doc.textContent).toContain('[99]')
+ } finally {
+ forged.destroy()
+ }
+
+ anchor.textContent = '[12]'
+ anchor.before('hidden sibling')
+ const siblingForged = createEditor('')
+ try {
+ siblingForged.commands.setContent(template.innerHTML, { contentType: 'html' })
+ expect(nodeNames(siblingForged)).not.toContain('richMarkdownHtmlSuperscriptLink')
+ expect(siblingForged.state.doc.textContent).toContain('hidden sibling')
+ } finally {
+ siblingForged.destroy()
+ }
+ } finally {
+ editor.destroy()
+ host.remove()
+ }
+ })
+
+ it('rejects oversized clipboard source before UTF-8 encoding', () => {
+ const editor = createEditor('')
+ const oversizedSource = 'x'.repeat(HTML_SUPERSCRIPT_LINK_SOURCE_LIMIT + 1)
+ const encodeSpy = vi.spyOn(TextEncoder.prototype, 'encode')
+ try {
+ editor.commands.setContent(
+ `x`,
+ { contentType: 'html' }
+ )
+ expect(nodeNames(editor)).not.toContain('richMarkdownHtmlSuperscriptLink')
+ expect(encodeSpy.mock.calls.some(([value]) => value === oversizedSource)).toBe(false)
+ } finally {
+ encodeSpy.mockRestore()
+ editor.destroy()
+ }
+ })
+
+ it('maps citation labels into search, review, and empty-selection Cut', () => {
+ const source = 'Before [12] after'
+ const editor = createEditor(source)
+ try {
+ const matches = findRichMarkdownSearchMatches(editor.state.doc, 'e [12] a')
+ expect(matches).toHaveLength(1)
+ expect(matches[0]?.touchesReadOnlyAtom).toBe(true)
+ let citationPosition = -1
+ editor.state.doc.descendants((node, pos) => {
+ if (node.type.name === 'richMarkdownHtmlSuperscriptLink') {
+ citationPosition = pos
+ }
+ })
+ const reviewRanges = findRichMarkdownSelectedTextRanges({
+ editor,
+ selectedText: 'Before [12] after'
+ })
+ expect(
+ reviewRanges.some(
+ (range) => range.from <= citationPosition && range.to >= citationPosition + 1
+ )
+ ).toBe(true)
+
+ const citationHost = document.createElement('div')
+ const citationOnly = createEditor(
+ '[12]',
+ TEST_KEY,
+ citationHost
+ )
+ try {
+ citationOnly.view.dispatch(
+ citationOnly.state.tr.setSelection(TextSelection.create(citationOnly.state.doc, 1))
+ )
+ const clipboard = new Map()
+ const event = {
+ clipboardData: {
+ setData: (type: string, value: string) => clipboard.set(type, value),
+ getData: (type: string) => clipboard.get(type) ?? ''
+ },
+ preventDefault: () => {}
+ } as unknown as ClipboardEvent
+ expect(handleRichMarkdownCut(citationOnly.view, event)).toBe(true)
+ expect(clipboard.get('text/plain')).toBe('[12]')
+ expect(clipboard.get('text/html')).toContain('data-orca-superscript-link-source')
+ expect(citationOnly.getMarkdown()).toBe('')
+ } finally {
+ citationOnly.destroy()
+ citationHost.remove()
+ }
+ } finally {
+ editor.destroy()
+ }
+ })
+
+ it('enforces aggregate UTF-8 bounds even when oversized text precedes the citation', () => {
+ const citation = '[1]'
+ const exactText = 'a'.repeat(RICH_MARKDOWN_SOURCE_OWNING_PASTE_LIMIT - 3)
+ const exact = createEditor(`${exactText}${citation}`)
+ try {
+ expect(
+ inspectRichMarkdownSourceOwningSlice(exact.state.doc.slice(0, exact.state.doc.content.size))
+ ).toEqual({ containsSourceOwningNode: true, canPreserve: true })
+ } finally {
+ exact.destroy()
+ }
+
+ const over = createEditor(`${exactText}a${citation}`)
+ try {
+ expect(
+ inspectRichMarkdownSourceOwningSlice(over.state.doc.slice(0, over.state.doc.content.size))
+ ).toEqual({ containsSourceOwningNode: true, canPreserve: false })
+ } finally {
+ over.destroy()
+ }
+
+ const astralOver = createEditor(
+ `${'😀'.repeat(Math.floor(RICH_MARKDOWN_SOURCE_OWNING_PASTE_LIMIT / 4))}${citation}`
+ )
+ try {
+ expect(
+ inspectRichMarkdownSourceOwningSlice(
+ astralOver.state.doc.slice(0, astralOver.state.doc.content.size)
+ )
+ ).toEqual({ containsSourceOwningNode: true, canPreserve: false })
+ } finally {
+ astralOver.destroy()
+ }
+ })
+
+ it('counts visible leaf serializers in source-owning slice bounds', () => {
+ const editor = createEditor('')
+ try {
+ const schema = editor.state.schema
+ const hardBreak = schema.nodes.hardBreak
+ const docLink = schema.nodes.markdownDocLink
+ const citation = schema.nodes.richMarkdownHtmlSuperscriptLink
+ if (!hardBreak || !docLink || !citation) {
+ throw new Error('Expected rich Markdown leaf node types')
+ }
+ const citationAttrs = {
+ source: '[1]',
+ href: 'x',
+ label: '[1]',
+ title: null
+ }
+ const exactDoc = schema.node('doc', null, [
+ schema.node('paragraph', null, [
+ schema.text('a'.repeat(RICH_MARKDOWN_SOURCE_OWNING_PASTE_LIMIT - 4)),
+ hardBreak.create(),
+ citation.create(citationAttrs)
+ ])
+ ])
+ expect(
+ inspectRichMarkdownSourceOwningSlice(exactDoc.slice(0, exactDoc.content.size))
+ ).toEqual({ containsSourceOwningNode: true, canPreserve: true })
+ const overHardBreakDoc = schema.node('doc', null, [
+ schema.node('paragraph', null, [
+ schema.text('a'.repeat(RICH_MARKDOWN_SOURCE_OWNING_PASTE_LIMIT - 3)),
+ hardBreak.create(),
+ citation.create(citationAttrs)
+ ])
+ ])
+ expect(
+ inspectRichMarkdownSourceOwningSlice(
+ overHardBreakDoc.slice(0, overHardBreakDoc.content.size)
+ )
+ ).toEqual({ containsSourceOwningNode: true, canPreserve: false })
+
+ const oversizedLeafDoc = schema.node('doc', null, [
+ schema.node('paragraph', null, [
+ docLink.create({ target: 'Guide', label: 'x'.repeat(300_000) }),
+ citation.create(citationAttrs)
+ ])
+ ])
+ expect(
+ inspectRichMarkdownSourceOwningSlice(
+ oversizedLeafDoc.slice(0, oversizedLeafDoc.content.size)
+ )
+ ).toEqual({ containsSourceOwningNode: true, canPreserve: false })
+ } finally {
+ editor.destroy()
+ }
+ })
+
+ it('remaps review text across adjacent citation atoms without inventing spaces', () => {
+ const editor = createEditor(
+ '[1][2]'
+ )
+ try {
+ const ranges = findRichMarkdownSelectedTextRanges({ editor, selectedText: '[1][2]' })
+ expect(ranges).toHaveLength(1)
+ expect(ranges[0]).toEqual({ from: 1, to: 3 })
+ } finally {
+ editor.destroy()
+ }
+ })
+
+ it('preserves block separators in visible text and review remapping', () => {
+ const editor = createEditor('foo\n\nbar')
+ try {
+ expect(getRichMarkdownVisibleText(editor.state.doc)).toBe('foo\nbar')
+ expect(findRichMarkdownSelectedTextRanges({ editor, selectedText: 'foo bar' })).toEqual([
+ { from: 1, to: 4 },
+ { from: 6, to: 9 }
+ ])
+ } finally {
+ editor.destroy()
+ }
+ })
+
+ it('maps dense search matches with a monotonic segment walk', () => {
+ const citation = '[1]'
+ const editor = createEditor(`x${citation}`.repeat(200))
+ try {
+ const stats = { segmentVisits: 0 }
+ expect(findRichMarkdownSearchMatches(editor.state.doc, 'x', undefined, stats)).toHaveLength(
+ 200
+ )
+ expect(stats.segmentVisits).toBeLessThan(1_000)
+ } finally {
+ editor.destroy()
+ }
+ })
+
+ it('does not let search bridge omitted atoms or block boundaries', () => {
+ const docLink = createEditor('foo[[Guide]]bar')
+ try {
+ expect(findRichMarkdownSearchMatches(docLink.state.doc, 'foobar')).toEqual([])
+ expect(
+ findRichMarkdownSearchMatches(docLink.state.doc, 'Guide')[0]?.touchesReadOnlyAtom
+ ).toBe(true)
+ } finally {
+ docLink.destroy()
+ }
+
+ const rawHtml = createEditor('foobar')
+ try {
+ expect(findRichMarkdownSearchMatches(rawHtml.state.doc, 'foobar')).toEqual([])
+ } finally {
+ rawHtml.destroy()
+ }
+
+ const blocks = createEditor('foo\n\nbar')
+ try {
+ expect(findRichMarkdownSearchMatches(blocks.state.doc, 'foobar')).toEqual([])
+ } finally {
+ blocks.destroy()
+ }
+ })
+})
diff --git a/src/renderer/src/components/editor/rich-markdown-html-superscript-link.ts b/src/renderer/src/components/editor/rich-markdown-html-superscript-link.ts
new file mode 100644
index 000000000..da3f919fd
--- /dev/null
+++ b/src/renderer/src/components/editor/rich-markdown-html-superscript-link.ts
@@ -0,0 +1,218 @@
+import { Node } from '@tiptap/core'
+import type { RichMarkdownSourceTransport } from './rich-markdown-source-transport'
+import {
+ HTML_SUPERSCRIPT_LINK_SOURCE_LIMIT,
+ parseHtmlSuperscriptLinkSource,
+ type HtmlSuperscriptLinkSource
+} from './rich-markdown-html-superscript-link-source'
+import { projectMarkdownHrefForClipboard } from './markdown-internal-links'
+import {
+ classifyHtmlSuperscriptLinkAction,
+ type RichMarkdownHtmlSuperscriptLinkContext
+} from './rich-markdown-html-superscript-link-context'
+import { translate } from '@/i18n/i18n'
+
+const CLIPBOARD_VERSION = '1'
+const MARKER_ATTRIBUTE = 'data-rich-markdown-html-superscript-link'
+const SOURCE_ATTRIBUTE = 'data-orca-superscript-link-source'
+const clipboardEncoder = new TextEncoder()
+
+export function createRichMarkdownHtmlSuperscriptLink(
+ transport: RichMarkdownSourceTransport,
+ context: RichMarkdownHtmlSuperscriptLinkContext
+) {
+ return Node.create({
+ name: 'richMarkdownHtmlSuperscriptLink',
+ inline: true,
+ group: 'inline',
+ atom: true,
+ selectable: true,
+
+ addAttributes() {
+ return {
+ source: { default: '', rendered: false },
+ href: { default: '', rendered: false },
+ label: { default: '', rendered: false },
+ title: { default: null, rendered: false }
+ }
+ },
+
+ markdownTokenName: 'richMarkdownHtmlSuperscriptLink',
+ markdownTokenizer: {
+ name: 'richMarkdownHtmlSuperscriptLink',
+ level: 'inline',
+ start: transport.startFor('html-superscript-link'),
+ tokenize(source) {
+ const matched = transport.match(source, 'html-superscript-link')
+ if (!matched) {
+ return undefined
+ }
+ const parsed = parseStructuredPayload(matched.value)
+ if (!parsed) {
+ return undefined
+ }
+ return {
+ type: 'richMarkdownHtmlSuperscriptLink',
+ raw: matched.raw,
+ citation: parsed
+ }
+ }
+ },
+ parseMarkdown: (token, helpers) => {
+ const citation = (token as { citation?: HtmlSuperscriptLinkSource }).citation
+ if (token.type !== 'richMarkdownHtmlSuperscriptLink' || !citation) {
+ return []
+ }
+ return helpers.createNode('richMarkdownHtmlSuperscriptLink', citation)
+ },
+ renderMarkdown: (node) => String(node.attrs?.source ?? ''),
+ renderText: ({ node }) => String(node.attrs.label ?? ''),
+
+ parseHTML() {
+ return [
+ {
+ tag: `sup[${MARKER_ATTRIBUTE}]`,
+ getAttrs: (element: HTMLElement) => validateClipboardElement(element)
+ }
+ ]
+ },
+
+ renderHTML({ node }) {
+ const citation = node.attrs as HtmlSuperscriptLinkSource
+ const projectedHref = projectMarkdownHrefForClipboard(citation.href)
+ const anchorAttributes: Record = {}
+ if (projectedHref !== null) {
+ anchorAttributes.href = projectedHref
+ }
+ if (citation.title !== null) {
+ anchorAttributes.title = citation.title
+ }
+ return [
+ 'sup',
+ {
+ [MARKER_ATTRIBUTE]: CLIPBOARD_VERSION,
+ [SOURCE_ATTRIBUTE]: citation.source
+ },
+ ['a', anchorAttributes, citation.label]
+ ]
+ },
+
+ addNodeView() {
+ return ({ node }) => {
+ const dom = document.createElement('sup')
+ dom.setAttribute(MARKER_ATTRIBUTE, '')
+ dom.setAttribute('contenteditable', 'false')
+ const label = document.createElement('span')
+ label.className = 'rich-markdown-html-superscript-link'
+ label.textContent = String(node.attrs.label ?? '')
+ dom.appendChild(label)
+
+ const updateActionability = (): void => {
+ const href = String(node.attrs.href ?? '')
+ const actionable = classifyHtmlSuperscriptLinkAction(href, context.getSnapshot())
+ label.setAttribute(
+ 'aria-label',
+ actionable
+ ? translate(
+ 'auto.components.editor.richMarkdownHtmlSuperscriptLink.availableAriaLabel',
+ '{{value0}}, link to {{value1}}',
+ { value0: String(node.attrs.label ?? ''), value1: href }
+ )
+ : translate(
+ 'auto.components.editor.richMarkdownHtmlSuperscriptLink.unavailableAriaLabel',
+ '{{value0}}, citation link unavailable',
+ { value0: String(node.attrs.label ?? '') }
+ )
+ )
+ if (actionable) {
+ label.setAttribute('role', 'link')
+ } else {
+ label.removeAttribute('role')
+ }
+ label.toggleAttribute('data-actionable', actionable)
+ }
+ updateActionability()
+ const unsubscribe = context.subscribe(updateActionability)
+ return { dom, destroy: unsubscribe }
+ }
+ }
+ })
+}
+
+function parseStructuredPayload(value: string): HtmlSuperscriptLinkSource | null {
+ let candidate: unknown
+ try {
+ candidate = JSON.parse(value)
+ } catch {
+ return null
+ }
+ if (!isCitationShape(candidate)) {
+ return null
+ }
+ const parsed = parseHtmlSuperscriptLinkSource(candidate.source)
+ return parsed && sameCitation(parsed, candidate) ? parsed : null
+}
+
+function validateClipboardElement(element: HTMLElement): false | Record {
+ if (
+ element.getAttribute(MARKER_ATTRIBUTE) !== CLIPBOARD_VERSION ||
+ !hasOnlyAttributes(element, [MARKER_ATTRIBUTE, SOURCE_ATTRIBUTE, 'data-pm-slice'])
+ ) {
+ return false
+ }
+ const source = element.getAttribute(SOURCE_ATTRIBUTE)
+ if (
+ !source ||
+ source.length > HTML_SUPERSCRIPT_LINK_SOURCE_LIMIT ||
+ clipboardEncoder.encode(source).byteLength > HTML_SUPERSCRIPT_LINK_SOURCE_LIMIT
+ ) {
+ return false
+ }
+ const parsed = parseHtmlSuperscriptLinkSource(source)
+ const anchor = element.firstElementChild
+ if (
+ !parsed ||
+ element.childNodes.length !== 1 ||
+ element.children.length !== 1 ||
+ !anchor ||
+ element.firstChild !== anchor ||
+ anchor.tagName !== 'A' ||
+ !hasOnlyAttributes(anchor, ['href', 'title']) ||
+ anchor.childNodes.length !== 1 ||
+ anchor.firstChild?.nodeType !== window.Node.TEXT_NODE ||
+ anchor.textContent !== parsed.label ||
+ anchor.getAttribute('title') !== parsed.title ||
+ anchor.getAttribute('href') !== projectMarkdownHrefForClipboard(parsed.href)
+ ) {
+ return false
+ }
+ return parsed
+}
+
+function hasOnlyAttributes(element: Element, allowed: string[]): boolean {
+ const allowedSet = new Set(allowed)
+ return Array.from(element.attributes).every((attribute) => allowedSet.has(attribute.name))
+}
+
+function isCitationShape(value: unknown): value is HtmlSuperscriptLinkSource {
+ if (!value || typeof value !== 'object') {
+ return false
+ }
+ const candidate = value as Record
+ return (
+ Object.keys(candidate).length === 4 &&
+ typeof candidate.source === 'string' &&
+ typeof candidate.href === 'string' &&
+ typeof candidate.label === 'string' &&
+ (typeof candidate.title === 'string' || candidate.title === null)
+ )
+}
+
+function sameCitation(left: HtmlSuperscriptLinkSource, right: HtmlSuperscriptLinkSource): boolean {
+ return (
+ left.source === right.source &&
+ left.href === right.href &&
+ left.label === right.label &&
+ left.title === right.title
+ )
+}
diff --git a/src/renderer/src/components/editor/rich-markdown-key-handler.test.ts b/src/renderer/src/components/editor/rich-markdown-key-handler.test.ts
index dc2c275aa..ba9aaea31 100644
--- a/src/renderer/src/components/editor/rich-markdown-key-handler.test.ts
+++ b/src/renderer/src/components/editor/rich-markdown-key-handler.test.ts
@@ -1,10 +1,10 @@
import { describe, expect, it, vi } from 'vitest'
import { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
-import { Markdown } from '@tiptap/markdown'
+import { createIsolatedMarkdownExtensionForTests } from './isolated-markdown-extension-for-tests'
import { createRichMarkdownKeyHandler, type KeyHandlerContext } from './rich-markdown-key-handler'
-const extensions = [StarterKit, Markdown.configure({ markedOptions: { gfm: true } })]
+const extensions = [StarterKit, createIsolatedMarkdownExtensionForTests()]
function createEditor(content: object): Editor {
return new Editor({
@@ -67,6 +67,18 @@ function createContext(editor: Editor, typedMarker: boolean): KeyHandlerContext
typedEmptyOrderedListMarkerRef: { current: typedMarker },
flushPendingSerialization: vi.fn(),
openSearchRef: { current: vi.fn() },
+ linkBubbleOwnerId: 'test-owner',
+ htmlSuperscriptLinkContext: {
+ getSnapshot: () => ({
+ sourceFilePath: '/repo/README.md',
+ worktreeId: 'worktree-1',
+ worktreeRoot: '/repo',
+ sourceOwner: { kind: 'local' as const },
+ version: 0
+ }),
+ subscribe: () => () => {},
+ update: () => {}
+ },
setIsEditingLink: vi.fn(),
setLinkBubble: vi.fn(),
setSelectedCommandIndex: vi.fn(),
diff --git a/src/renderer/src/components/editor/rich-markdown-key-handler.ts b/src/renderer/src/components/editor/rich-markdown-key-handler.ts
index b5e9ced95..2805141d1 100644
--- a/src/renderer/src/components/editor/rich-markdown-key-handler.ts
+++ b/src/renderer/src/components/editor/rich-markdown-key-handler.ts
@@ -4,7 +4,7 @@ import { getShortcutPlatform } from '@/lib/shortcut-platform'
import { useAppStore } from '@/store'
import { isMarkdownPreviewFindShortcut } from './markdown-preview-search'
import { editorShortcutMatches } from './editor-shortcuts'
-import { getLinkBubblePosition, type LinkBubbleState } from './RichMarkdownLinkBubble'
+import type { LinkBubbleState } from './RichMarkdownLinkBubble'
import { commitRow, type DocLinkMenuRow, type DocLinkMenuState } from './rich-markdown-commands'
import {
runSlashCommand,
@@ -18,6 +18,9 @@ import {
exitTrailingEmptyOrderedListItem
} from './rich-markdown-list-continuation'
import { deleteAdjacentEmptyParagraph } from './rich-markdown-empty-paragraph-delete'
+import { handleRichMarkdownCitationKey } from './rich-markdown-citation-keyboard'
+import type { RichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-html-superscript-link-context'
+import { handleRichMarkdownLinkShortcut } from './rich-markdown-link-shortcut'
export type KeyHandlerContext = {
isMac: boolean
@@ -44,6 +47,9 @@ export type KeyHandlerContext = {
setSelectedDocLinkIndex: Dispatch>
setSlashMenu: Dispatch>
setDocLinkMenu: (menu: DocLinkMenuState | null) => void
+ openSelectedHtmlSuperscriptLink?: () => boolean
+ linkBubbleOwnerId: string
+ htmlSuperscriptLinkContext: RichMarkdownHtmlSuperscriptLinkContext
}
function isComposingMarkdownInput(event: KeyboardEvent, editor: Editor | null): boolean {
@@ -100,6 +106,16 @@ export function createRichMarkdownKeyHandler(
): (_view: unknown, event: KeyboardEvent) => boolean {
return (_view, event) => {
const mod = ctx.isMac ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey
+ if (
+ handleRichMarkdownCitationKey({
+ editor: ctx.editorRef.current,
+ event,
+ linkBubbleOwnerId: ctx.linkBubbleOwnerId,
+ onOpen: ctx.openSelectedHtmlSuperscriptLink
+ })
+ ) {
+ return true
+ }
if (
isMarkdownPreviewFindShortcut(
event,
@@ -131,29 +147,18 @@ export function createRichMarkdownKeyHandler(
return true
}
- // Link: Cmd/Ctrl+K — insert or edit a hyperlink.
- if (mod && event.key.toLowerCase() === 'k') {
- event.preventDefault()
- const ed = ctx.editorRef.current
- if (!ed) {
- return true
- }
-
- if (ctx.isEditingLinkRef.current) {
- ctx.setIsEditingLink(false)
- if (!ed.isActive('link')) {
- ctx.setLinkBubble(null)
- }
- ed.commands.focus()
- return true
- }
-
- const pos = getLinkBubblePosition(ed, ctx.rootRef.current)
- if (pos) {
- const href = ed.isActive('link') ? (ed.getAttributes('link').href as string) || '' : ''
- ctx.setLinkBubble({ href, ...pos })
- ctx.setIsEditingLink(true)
- }
+ if (
+ handleRichMarkdownLinkShortcut({
+ editor: ctx.editorRef.current,
+ event,
+ htmlSuperscriptLinkContext: ctx.htmlSuperscriptLinkContext,
+ isEditing: ctx.isEditingLinkRef.current,
+ isMac: ctx.isMac,
+ root: ctx.rootRef.current,
+ setEditing: ctx.setIsEditingLink,
+ setLinkBubble: ctx.setLinkBubble
+ })
+ ) {
return true
}
diff --git a/src/renderer/src/components/editor/rich-markdown-large-text-paste.ts b/src/renderer/src/components/editor/rich-markdown-large-text-paste.ts
index 1388aee50..d4eecf1e3 100644
--- a/src/renderer/src/components/editor/rich-markdown-large-text-paste.ts
+++ b/src/renderer/src/components/editor/rich-markdown-large-text-paste.ts
@@ -17,6 +17,8 @@ type RichMarkdownLargeTextPasteOptions = {
measureYieldAfterCodeUnits?: number
yieldToEventLoop?: () => Promise
canContinue?: (editor: Editor) => boolean
+ plainTextOverride?: string
+ htmlTextOverride?: string
}
export type RichMarkdownLargeTextPasteResult =
@@ -188,8 +190,8 @@ export function handleRichMarkdownLargeTextPaste(
return false
}
- const text = readPlainText(event)
- const html = readHtmlText(event)
+ const text = options.plainTextOverride ?? readPlainText(event)
+ const html = options.htmlTextOverride ?? readHtmlText(event)
const directMaxBytes = options.directMaxBytes ?? RICH_MARKDOWN_PASTE_DIRECT_MAX_BYTES
const maxBytes = options.maxBytes ?? RICH_MARKDOWN_PASTE_MAX_BYTES
const ownershipMeasurement = measureTextControlPasteByteLength(text, {
diff --git a/src/renderer/src/components/editor/rich-markdown-link-clipboard.test.ts b/src/renderer/src/components/editor/rich-markdown-link-clipboard.test.ts
new file mode 100644
index 000000000..65cdccb61
--- /dev/null
+++ b/src/renderer/src/components/editor/rich-markdown-link-clipboard.test.ts
@@ -0,0 +1,42 @@
+// @vitest-environment happy-dom
+
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { toastError, toastSuccess } = vi.hoisted(() => ({
+ toastSuccess: vi.fn(),
+ toastError: vi.fn()
+}))
+
+vi.mock('sonner', () => ({ toast: { success: toastSuccess, error: toastError } }))
+vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
+
+import { copyRichMarkdownLink } from './rich-markdown-link-clipboard'
+
+describe('copyRichMarkdownLink', () => {
+ const writeClipboardText = vi.fn()
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ ;(window as unknown as { api: unknown }).api = { ui: { writeClipboardText } }
+ })
+
+ it('reports success only after the clipboard write resolves', async () => {
+ let resolveWrite: (() => void) | undefined
+ writeClipboardText.mockReturnValue(
+ new Promise((resolve) => {
+ resolveWrite = resolve
+ })
+ )
+ const copy = copyRichMarkdownLink('https://example.com')
+ expect(toastSuccess).not.toHaveBeenCalled()
+ resolveWrite?.()
+ await copy
+ expect(toastSuccess).toHaveBeenCalledWith('Copied link')
+ })
+
+ it('reports a rejected clipboard write without throwing', async () => {
+ writeClipboardText.mockRejectedValue(new Error('clipboard unavailable'))
+ await expect(copyRichMarkdownLink('https://example.com')).resolves.toBeUndefined()
+ expect(toastError).toHaveBeenCalledWith('Failed to copy link')
+ })
+})
diff --git a/src/renderer/src/components/editor/rich-markdown-link-clipboard.ts b/src/renderer/src/components/editor/rich-markdown-link-clipboard.ts
new file mode 100644
index 000000000..b60c4d436
--- /dev/null
+++ b/src/renderer/src/components/editor/rich-markdown-link-clipboard.ts
@@ -0,0 +1,18 @@
+import { toast } from 'sonner'
+import { translate } from '@/i18n/i18n'
+
+export async function copyRichMarkdownLink(href: string): Promise {
+ try {
+ await window.api.ui.writeClipboardText(href)
+ toast.success(
+ translate('auto.components.editor.richMarkdownLinkClipboard.copiedLink', 'Copied link')
+ )
+ } catch {
+ toast.error(
+ translate(
+ 'auto.components.editor.richMarkdownLinkClipboard.copyLinkFailed',
+ 'Failed to copy link'
+ )
+ )
+ }
+}
diff --git a/src/renderer/src/components/editor/rich-markdown-link-shortcut.test.ts b/src/renderer/src/components/editor/rich-markdown-link-shortcut.test.ts
new file mode 100644
index 000000000..ace2b9aa3
--- /dev/null
+++ b/src/renderer/src/components/editor/rich-markdown-link-shortcut.test.ts
@@ -0,0 +1,130 @@
+// @vitest-environment happy-dom
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { Editor } from '@tiptap/core'
+import { NodeSelection, TextSelection } from '@tiptap/pm/state'
+import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html'
+import { createRichMarkdownExtensions } from './rich-markdown-extensions'
+import { createRichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-html-superscript-link-context'
+import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport'
+import { handleRichMarkdownLinkShortcut } from './rich-markdown-link-shortcut'
+
+const TEST_KEY = '0123456789abcdef0123456789abcdef'
+
+function createEditor(content: string): {
+ editor: Editor
+ context: ReturnType
+ root: HTMLDivElement
+} {
+ const codec = createRichMarkdownEditorCodec(TEST_KEY)
+ const context = createRichMarkdownHtmlSuperscriptLinkContext({
+ sourceFilePath: '/repo/README.md',
+ worktreeId: 'worktree-1',
+ worktreeRoot: '/repo',
+ sourceOwner: { kind: 'local' }
+ })
+ const root = document.createElement('div')
+ document.body.appendChild(root)
+ const editor = new Editor({
+ element: root,
+ extensions: createRichMarkdownExtensions({
+ codec,
+ htmlSuperscriptLinks: true,
+ htmlSuperscriptLinkContext: context
+ }),
+ content: encodeRawMarkdownHtmlForRichEditor(content, codec, {
+ htmlSuperscriptLinks: true
+ }),
+ contentType: 'markdown'
+ })
+ return { editor, context, root }
+}
+
+function keyEvent(): KeyboardEvent {
+ return {
+ key: 'k',
+ metaKey: true,
+ ctrlKey: false,
+ preventDefault: vi.fn()
+ } as unknown as KeyboardEvent
+}
+
+afterEach(() => {
+ vi.restoreAllMocks()
+})
+
+describe('handleRichMarkdownLinkShortcut', () => {
+ it('opens the html-superscript action bubble for a citation NodeSelection', () => {
+ const source = '1'
+ const { editor, context, root } = createEditor(`See note ${source}.`)
+ try {
+ let citationPos = -1
+ editor.state.doc.descendants((node, pos) => {
+ if (node.type.name === 'richMarkdownHtmlSuperscriptLink' && citationPos < 0) {
+ citationPos = pos
+ }
+ })
+ expect(citationPos).toBeGreaterThanOrEqual(0)
+ editor.view.dispatch(
+ editor.state.tr.setSelection(NodeSelection.create(editor.state.doc, citationPos))
+ )
+
+ const setLinkBubble = vi.fn()
+ const setEditing = vi.fn()
+
+ const handled = handleRichMarkdownLinkShortcut({
+ editor,
+ event: keyEvent(),
+ htmlSuperscriptLinkContext: context,
+ isEditing: false,
+ isMac: true,
+ root,
+ setEditing,
+ setLinkBubble
+ })
+
+ expect(handled).toBe(true)
+ expect(setEditing).toHaveBeenCalledWith(false)
+ expect(setLinkBubble).toHaveBeenCalledWith(
+ expect.objectContaining({
+ kind: 'html-superscript',
+ href: 'https://example.com',
+ label: '1'
+ })
+ )
+ } finally {
+ editor.destroy()
+ root.remove()
+ }
+ })
+
+ it('opens an editable markdown bubble when there is no link selection', () => {
+ const { editor, context, root } = createEditor('plain text')
+ try {
+ editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, 1)))
+ const setLinkBubble = vi.fn()
+ const setEditing = vi.fn()
+
+ handleRichMarkdownLinkShortcut({
+ editor,
+ event: keyEvent(),
+ htmlSuperscriptLinkContext: context,
+ isEditing: false,
+ isMac: true,
+ root,
+ setEditing,
+ setLinkBubble
+ })
+
+ expect(setEditing).toHaveBeenCalledWith(true)
+ expect(setLinkBubble).toHaveBeenCalledWith(
+ expect.objectContaining({
+ kind: 'markdown',
+ href: ''
+ })
+ )
+ } finally {
+ editor.destroy()
+ root.remove()
+ }
+ })
+})
diff --git a/src/renderer/src/components/editor/rich-markdown-link-shortcut.ts b/src/renderer/src/components/editor/rich-markdown-link-shortcut.ts
new file mode 100644
index 000000000..a4feecb61
--- /dev/null
+++ b/src/renderer/src/components/editor/rich-markdown-link-shortcut.ts
@@ -0,0 +1,64 @@
+import type { Editor } from '@tiptap/react'
+import { getLinkBubblePosition } from './RichMarkdownLinkBubble'
+import type { LinkBubbleState } from './RichMarkdownLinkBubble'
+import type { RichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-html-superscript-link-context'
+import {
+ createEditableMarkdownLinkBubble,
+ getRichMarkdownSelectionLinkBubble
+} from './rich-markdown-selected-link-actions'
+
+export function handleRichMarkdownLinkShortcut({
+ editor,
+ event,
+ htmlSuperscriptLinkContext,
+ isEditing,
+ isMac,
+ root,
+ setEditing,
+ setLinkBubble
+}: {
+ editor: Editor | null
+ event: KeyboardEvent
+ htmlSuperscriptLinkContext: RichMarkdownHtmlSuperscriptLinkContext
+ isEditing: boolean
+ isMac: boolean
+ root: HTMLElement | null
+ setEditing: (editing: boolean) => void
+ setLinkBubble: (bubble: LinkBubbleState | null) => void
+}): boolean {
+ const modifier = isMac ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey
+ if (!modifier || event.key.toLowerCase() !== 'k') {
+ return false
+ }
+ event.preventDefault()
+ if (!editor) {
+ return true
+ }
+ if (isEditing) {
+ setEditing(false)
+ if (!editor.isActive('link')) {
+ setLinkBubble(null)
+ }
+ editor.commands.focus()
+ return true
+ }
+ // Why: NodeSelection on an HTML citation still has a bubble position, but
+ // markdown setLink/unsetLink cannot edit that atom — open the citation
+ // action bubble instead of the markdown edit field.
+ const selectionBubble = getRichMarkdownSelectionLinkBubble(
+ editor,
+ root,
+ htmlSuperscriptLinkContext
+ )
+ if (selectionBubble) {
+ setLinkBubble(selectionBubble)
+ setEditing(selectionBubble.kind === 'markdown')
+ return true
+ }
+ const position = getLinkBubblePosition(editor, root)
+ if (position) {
+ setLinkBubble(createEditableMarkdownLinkBubble('', position))
+ setEditing(true)
+ }
+ return true
+}
diff --git a/src/renderer/src/components/editor/rich-markdown-list-continuation.test.ts b/src/renderer/src/components/editor/rich-markdown-list-continuation.test.ts
index a96fb6718..31a3e06aa 100644
--- a/src/renderer/src/components/editor/rich-markdown-list-continuation.test.ts
+++ b/src/renderer/src/components/editor/rich-markdown-list-continuation.test.ts
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
-import { Markdown } from '@tiptap/markdown'
+import { createIsolatedMarkdownExtensionForTests } from './isolated-markdown-extension-for-tests'
import {
collapseEmptyListContinuationParagraph,
commitEmptyOrderedListMarkerAsText,
@@ -10,12 +10,12 @@ import {
isSingleEmptyTopLevelOrderedList
} from './rich-markdown-list-continuation'
-const extensions = [StarterKit, Markdown.configure({ markedOptions: { gfm: true } })]
-
function createEditor(content: object): Editor {
+ // Why: each Editor needs its own marked registry; sharing one module-scoped
+ // extension accumulates tokenizer state across tests.
return new Editor({
element: null,
- extensions,
+ extensions: [StarterKit, createIsolatedMarkdownExtensionForTests()],
content
})
}
diff --git a/src/renderer/src/components/editor/rich-markdown-local-image.test.ts b/src/renderer/src/components/editor/rich-markdown-local-image.test.ts
index 277c0a088..e343e9186 100644
--- a/src/renderer/src/components/editor/rich-markdown-local-image.test.ts
+++ b/src/renderer/src/components/editor/rich-markdown-local-image.test.ts
@@ -3,6 +3,7 @@
import { Editor } from '@tiptap/core'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createRichMarkdownExtensions } from './rich-markdown-extensions'
+import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport'
import { resetLocalImageSrcStateForTests } from './useLocalImageSrc'
import { setRichMarkdownImageResolverContext } from './rich-markdown-image-context'
@@ -40,7 +41,7 @@ describe('rich markdown local images', () => {
document.body.appendChild(host)
const editor = new Editor({
element: host,
- extensions: createRichMarkdownExtensions(),
+ extensions: createRichMarkdownExtensions({ codec: createRichMarkdownEditorCodec() }),
content: '',
contentType: 'markdown'
})
diff --git a/src/renderer/src/components/editor/rich-markdown-normalize.test.ts b/src/renderer/src/components/editor/rich-markdown-normalize.test.ts
index 01cb1d96c..29105836b 100644
--- a/src/renderer/src/components/editor/rich-markdown-normalize.test.ts
+++ b/src/renderer/src/components/editor/rich-markdown-normalize.test.ts
@@ -1,10 +1,10 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
-import { Markdown } from '@tiptap/markdown'
+import { createIsolatedMarkdownExtensionForTests } from './isolated-markdown-extension-for-tests'
import { normalizeEmptyListItems, normalizeSoftBreaks } from './rich-markdown-normalize'
-const extensions = [StarterKit, Markdown.configure({ markedOptions: { gfm: true } })]
+const extensions = [StarterKit, createIsolatedMarkdownExtensionForTests()]
afterEach(() => {
vi.restoreAllMocks()
diff --git a/src/renderer/src/components/editor/rich-markdown-paste-handler.ts b/src/renderer/src/components/editor/rich-markdown-paste-handler.ts
index b049f0d26..124d283f6 100644
--- a/src/renderer/src/components/editor/rich-markdown-paste-handler.ts
+++ b/src/renderer/src/components/editor/rich-markdown-paste-handler.ts
@@ -1,7 +1,11 @@
import type { Editor } from '@tiptap/react'
+import type { Slice } from '@tiptap/pm/model'
+import type { EditorView } from '@tiptap/pm/view'
import { handleRichMarkdownImagePaste } from './rich-markdown-paste-image'
import { handleRichMarkdownLargeTextPaste } from './rich-markdown-large-text-paste'
import { handleRichMarkdownTerminalPathPaste } from './rich-markdown-terminal-path-paste'
+import { inspectRichMarkdownSourceOwningSlice } from './rich-markdown-source-owning-slice'
+import { getRichMarkdownVisibleText } from './rich-markdown-visible-text-map'
export type RichMarkdownPasteHandlerArgs = {
editor: Editor | null
@@ -9,6 +13,8 @@ export type RichMarkdownPasteHandlerArgs = {
filePath: string
worktreeId: string
runtimeEnvironmentId?: string | null
+ slice?: Slice
+ view?: EditorView
}
export function handleRichMarkdownPaste({
@@ -16,7 +22,9 @@ export function handleRichMarkdownPaste({
event,
filePath,
worktreeId,
- runtimeEnvironmentId
+ runtimeEnvironmentId,
+ slice,
+ view
}: RichMarkdownPasteHandlerArgs): boolean {
if (
handleRichMarkdownImagePaste({
@@ -30,6 +38,36 @@ export function handleRichMarkdownPaste({
return true
}
+ const sourceOwningStatus = slice ? inspectRichMarkdownSourceOwningSlice(slice) : null
+ if (sourceOwningStatus?.containsSourceOwningNode && slice && view) {
+ if (sourceOwningStatus.canPreserve) {
+ // Why: dispatch directly so the transaction can carry paste/uiEvent
+ // metadata that editor.commands.insertContent would omit.
+ view.dispatch(
+ view.state.tr
+ .replaceSelection(slice)
+ .setMeta('paste', true)
+ .setMeta('uiEvent', 'paste')
+ .scrollIntoView()
+ )
+ return true
+ }
+ const visibleText = getRichMarkdownVisibleText(slice.content)
+ if (
+ handleRichMarkdownLargeTextPaste(editor, event, {
+ plainTextOverride: visibleText,
+ htmlTextOverride: ''
+ })
+ ) {
+ return true
+ }
+ if (visibleText && editor) {
+ event.preventDefault()
+ editor.commands.insertContent(visibleText)
+ }
+ return true
+ }
+
if (handleRichMarkdownTerminalPathPaste(editor, event)) {
return true
}
diff --git a/src/renderer/src/components/editor/rich-markdown-review-annotations.ts b/src/renderer/src/components/editor/rich-markdown-review-annotations.ts
index e78169e5e..a45c3f263 100644
--- a/src/renderer/src/components/editor/rich-markdown-review-annotations.ts
+++ b/src/renderer/src/components/editor/rich-markdown-review-annotations.ts
@@ -9,6 +9,9 @@ import {
} from './rich-markdown-range-bounds'
import type { RichMarkdownReviewNotePosition } from './rich-markdown-review-note-layout'
import { findRichMarkdownSelectedTextRanges } from './rich-markdown-review-text-ranges'
+import { getRichMarkdownSelectionVisibleText } from './rich-markdown-visible-text-map'
+import { countRichMarkdownReviewMarkdownLines } from './rich-markdown-review-line-count'
+export { countRichMarkdownReviewMarkdownLines } from './rich-markdown-review-line-count'
const RICH_MARKDOWN_ANNOTATION_BUTTON_SIZE_PX = 24
const RICH_MARKDOWN_ANNOTATION_EDGE_PADDING_PX = 8
@@ -42,27 +45,6 @@ export type RichMarkdownAnnotationTarget = RichMarkdownComposerState & {
buttonLeft: number
}
-export function countRichMarkdownReviewMarkdownLines(value: string): number {
- if (value.length === 0) {
- return 1
- }
- let lineCount = 1
- for (let index = 0; index < value.length; index += 1) {
- const charCode = value.charCodeAt(index)
- if (charCode === 13) {
- lineCount += 1
- if (value.charCodeAt(index + 1) === 10) {
- index += 1
- }
- continue
- }
- if (charCode === 10) {
- lineCount += 1
- }
- }
- return lineCount
-}
-
function serializeRichMarkdownJson(editor: Editor, content: JSONContent[]): string {
return (editor.markdown?.serialize({ type: 'doc', content }) ?? '').trimEnd()
}
@@ -293,7 +275,7 @@ export function getRichMarkdownAnnotationTarget(
if (!rect) {
return null
}
- const selectedText = window.getSelection()?.toString().trim() ?? ''
+ const selectedText = getRichMarkdownSelectionVisibleText(editor.state)
if (!selectedText) {
return null
}
diff --git a/src/renderer/src/components/editor/rich-markdown-review-line-count.ts b/src/renderer/src/components/editor/rich-markdown-review-line-count.ts
new file mode 100644
index 000000000..1102b2502
--- /dev/null
+++ b/src/renderer/src/components/editor/rich-markdown-review-line-count.ts
@@ -0,0 +1,18 @@
+export function countRichMarkdownReviewMarkdownLines(value: string): number {
+ if (value.length === 0) {
+ return 1
+ }
+ let lineCount = 1
+ for (let index = 0; index < value.length; index += 1) {
+ const charCode = value.charCodeAt(index)
+ if (charCode === 13) {
+ lineCount += 1
+ if (value.charCodeAt(index + 1) === 10) {
+ index += 1
+ }
+ } else if (charCode === 10) {
+ lineCount += 1
+ }
+ }
+ return lineCount
+}
diff --git a/src/renderer/src/components/editor/rich-markdown-review-text-ranges.test.ts b/src/renderer/src/components/editor/rich-markdown-review-text-ranges.test.ts
index cc8aaf991..0769e3531 100644
--- a/src/renderer/src/components/editor/rich-markdown-review-text-ranges.test.ts
+++ b/src/renderer/src/components/editor/rich-markdown-review-text-ranges.test.ts
@@ -40,13 +40,13 @@ function buildSelectedText(wordCount: number): string {
}
describe('findRichMarkdownSelectedTextRanges', () => {
- it('matches selected text across normalized whitespace and text nodes', () => {
+ it('matches selected text across adjacent text nodes without inventing whitespace', () => {
const editor = editorWithTextNodes([
{ isText: true, text: 'alpha' },
{ isText: true, text: 'beta' }
])
- expect(findRichMarkdownSelectedTextRanges({ editor, selectedText: 'alpha\n beta' })).toEqual([
+ expect(findRichMarkdownSelectedTextRanges({ editor, selectedText: 'alphabeta' })).toEqual([
{ from: 0, to: 9 }
])
})
diff --git a/src/renderer/src/components/editor/rich-markdown-review-text-ranges.ts b/src/renderer/src/components/editor/rich-markdown-review-text-ranges.ts
index b16b5eccf..03e6c117f 100644
--- a/src/renderer/src/components/editor/rich-markdown-review-text-ranges.ts
+++ b/src/renderer/src/components/editor/rich-markdown-review-text-ranges.ts
@@ -1,23 +1,25 @@
import type { Editor } from '@tiptap/react'
import type { RichMarkdownAnnotationHighlightRange } from './rich-markdown-annotation-highlight'
+import { forEachRichMarkdownVisibleTextSegment } from './rich-markdown-visible-text-map'
+
+type TextPosition = { from: number; to: number } | null
type NormalizedTextChar = {
value: string
- pos: number | null
+ pos: TextPosition
}
type MatchState = {
readonly needle: string
readonly prefixTable: number[]
- recentPositions: (number | null)[]
+ recentPositions: TextPosition[]
recentPositionWriteIndex: number
matchLength: number
- positions: (number | null)[] | null
+ positions: TextPosition[] | null
}
type NormalizationState = {
previousWasWhitespace: boolean
- emittedAnyText: boolean
}
export function findRichMarkdownSelectedTextRanges({
@@ -45,31 +47,32 @@ export function findRichMarkdownSelectedTextRanges({
positions: null
}
const normalizationState: NormalizationState = {
- previousWasWhitespace: false,
- emittedAnyText: false
+ previousWasWhitespace: false
}
- // Why: review text can come from large pasted selections; stream the editor
- // text instead of building per-character haystack/needle arrays.
- editor.state.doc.nodesBetween(from ?? 0, to ?? editor.state.doc.content.size, (node, pos) => {
- if (matchState.positions || !node.isText) {
- return
+ forEachRichMarkdownVisibleTextSegment(
+ editor.state.doc,
+ from ?? 0,
+ to ?? editor.state.doc.content.size,
+ (segment) => {
+ for (let index = 0; index < segment.text.length && !matchState.positions; index += 1) {
+ processRawTextChar(
+ {
+ value: segment.text.charAt(index),
+ pos:
+ segment.kind === 'separator'
+ ? null
+ : segment.kind === 'read-only-atom'
+ ? { from: segment.from, to: segment.to }
+ : { from: segment.from + index, to: segment.from + index + 1 }
+ },
+ normalizationState,
+ matchState
+ )
+ }
+ return !matchState.positions
}
- const nodeText = node.text
- if (!nodeText) {
- return
- }
- if (normalizationState.emittedAnyText) {
- processRawTextChar({ value: ' ', pos: null }, normalizationState, matchState)
- }
- for (let index = 0; index < nodeText.length && !matchState.positions; index += 1) {
- processRawTextChar(
- { value: nodeText.charAt(index), pos: pos + index },
- normalizationState,
- matchState
- )
- }
- })
+ )
return matchState.positions ? positionsToRanges(matchState.positions) : []
}
@@ -101,7 +104,6 @@ function processRawTextChar(
if (isRichMarkdownWhitespace(code)) {
if (!normalizationState.previousWasWhitespace) {
processNormalizedTextChar({ value: ' ', pos: char.pos }, matchState)
- normalizationState.emittedAnyText = true
}
normalizationState.previousWasWhitespace = true
return
@@ -109,7 +111,6 @@ function processRawTextChar(
processNormalizedTextChar(char, matchState)
normalizationState.previousWasWhitespace = false
- normalizationState.emittedAnyText = true
}
function processNormalizedTextChar(char: NormalizedTextChar, matchState: MatchState): void {
@@ -128,7 +129,7 @@ function processNormalizedTextChar(char: NormalizedTextChar, matchState: MatchSt
}
}
-function recordRecentPosition(pos: number | null, matchState: MatchState): void {
+function recordRecentPosition(pos: TextPosition, matchState: MatchState): void {
if (matchState.recentPositions.length < matchState.needle.length) {
matchState.recentPositions.push(pos)
matchState.recentPositionWriteIndex =
@@ -141,8 +142,8 @@ function recordRecentPosition(pos: number | null, matchState: MatchState): void
(matchState.recentPositionWriteIndex + 1) % matchState.needle.length
}
-function readRecentPositions(matchState: MatchState): (number | null)[] {
- const positions: (number | null)[] = []
+function readRecentPositions(matchState: MatchState): TextPosition[] {
+ const positions: TextPosition[] = []
for (let index = 0; index < matchState.needle.length; index += 1) {
const bufferIndex = (matchState.recentPositionWriteIndex + index) % matchState.needle.length
positions.push(matchState.recentPositions[bufferIndex] ?? null)
@@ -168,26 +169,26 @@ function buildPrefixTable(value: string): number[] {
return table
}
-function positionsToRanges(positions: (number | null)[]): RichMarkdownAnnotationHighlightRange[] {
+function positionsToRanges(positions: TextPosition[]): RichMarkdownAnnotationHighlightRange[] {
const ranges: RichMarkdownAnnotationHighlightRange[] = []
let rangeFrom: number | null = null
let rangeTo: number | null = null
- for (const pos of positions) {
- if (pos === null) {
+ for (const position of positions) {
+ if (position === null) {
continue
}
if (rangeFrom === null || rangeTo === null) {
- rangeFrom = pos
- rangeTo = pos + 1
+ rangeFrom = position.from
+ rangeTo = position.to
continue
}
- if (pos === rangeTo) {
- rangeTo += 1
+ if (position.from <= rangeTo) {
+ rangeTo = Math.max(rangeTo, position.to)
continue
}
ranges.push({ from: rangeFrom, to: rangeTo })
- rangeFrom = pos
- rangeTo = pos + 1
+ rangeFrom = position.from
+ rangeTo = position.to
}
if (rangeFrom !== null && rangeTo !== null) {
ranges.push({ from: rangeFrom, to: rangeTo })
diff --git a/src/renderer/src/components/editor/rich-markdown-search.test.ts b/src/renderer/src/components/editor/rich-markdown-search.test.ts
index 1e2064d2e..f345a1c22 100644
--- a/src/renderer/src/components/editor/rich-markdown-search.test.ts
+++ b/src/renderer/src/components/editor/rich-markdown-search.test.ts
@@ -6,7 +6,13 @@ import { findRichMarkdownSearchMatches } from './rich-markdown-search'
function docFromText(text: string, pos = 0): ProseMirrorNode {
return {
- descendants(callback: (node: { isText: boolean; text?: string }, pos: number) => void) {
+ nodeSize: pos + text.length + 2,
+ content: { size: pos + text.length },
+ nodesBetween(
+ _from: number,
+ _to: number,
+ callback: (node: { isText: boolean; text?: string }, pos: number) => void
+ ) {
callback({ isText: true, text }, pos)
}
} as unknown as ProseMirrorNode
diff --git a/src/renderer/src/components/editor/rich-markdown-search.ts b/src/renderer/src/components/editor/rich-markdown-search.ts
index cfd35a417..8c2c24121 100644
--- a/src/renderer/src/components/editor/rich-markdown-search.ts
+++ b/src/renderer/src/components/editor/rich-markdown-search.ts
@@ -6,12 +6,20 @@ import {
isMarkdownPreviewSearchQueryTooLarge,
type TextMatchOptions
} from './markdown-preview-search'
+import {
+ createRichMarkdownVisibleTextMap,
+ type RichMarkdownVisibleTextSegment
+} from './rich-markdown-visible-text-map'
export type RichMarkdownSearchMatch = {
from: number
to: number
+ touchesReadOnlyAtom?: boolean
+ decorationRanges?: { from: number; to: number; kind: 'inline' | 'node' }[]
}
+export type RichMarkdownSearchStats = { segmentVisits: number }
+
type RichMarkdownSearchState = {
activeIndex: number
decorations: DecorationSet
@@ -31,7 +39,8 @@ export const richMarkdownSearchPluginKey = new PluginKey {
- if (!node.isText) {
- return
+ const visibleMap = createRichMarkdownVisibleTextMap(doc)
+ const ranges = findTextMatchRanges(visibleMap.text, query, options)
+ let segmentIndex = 0
+ for (const range of ranges) {
+ while (
+ segmentIndex < visibleMap.segments.length &&
+ visibleMap.segments[segmentIndex]!.visibleTo <= range.start
+ ) {
+ if (stats) {
+ stats.segmentVisits += 1
+ }
+ segmentIndex += 1
}
-
- const text = node.text ?? ''
- if (!text.trim()) {
- return
+ const firstSegmentIndex = segmentIndex
+ let lastSegmentIndex = firstSegmentIndex - 1
+ let touchesReadOnlyAtom = false
+ let segmentsAreContiguous = true
+ let touchesSeparator = false
+ while (
+ lastSegmentIndex + 1 < visibleMap.segments.length &&
+ visibleMap.segments[lastSegmentIndex + 1]!.visibleFrom < range.end
+ ) {
+ lastSegmentIndex += 1
+ if (stats) {
+ stats.segmentVisits += 1
+ }
+ touchesReadOnlyAtom ||= visibleMap.segments[lastSegmentIndex]!.kind === 'read-only-atom'
+ touchesSeparator ||= visibleMap.segments[lastSegmentIndex]!.kind === 'separator'
+ const previous = visibleMap.segments[lastSegmentIndex - 1]
+ const current = visibleMap.segments[lastSegmentIndex]!
+ if (lastSegmentIndex > firstSegmentIndex && previous?.to !== current.from) {
+ segmentsAreContiguous = false
+ }
}
-
- const ranges = findTextMatchRanges(text, query, options)
- for (const range of ranges) {
- matches.push({
- from: pos + range.start,
- to: pos + range.end
- })
+ const first = visibleMap.segments[firstSegmentIndex]
+ const last = visibleMap.segments[lastSegmentIndex]
+ if (!first || !last || !segmentsAreContiguous || touchesSeparator) {
+ continue
}
- })
+ const from = mapSegmentStart(first, range.start)
+ const to = mapSegmentEnd(last, range.end)
+ matches.push(
+ touchesReadOnlyAtom
+ ? {
+ from,
+ to,
+ touchesReadOnlyAtom: true,
+ decorationRanges: visibleMap.segments
+ .slice(firstSegmentIndex, lastSegmentIndex + 1)
+ .map((segment) => ({
+ from: mapSegmentStart(segment, range.start),
+ to: mapSegmentEnd(segment, range.end),
+ kind: segment.kind === 'text' ? ('inline' as const) : ('node' as const)
+ }))
+ }
+ : { from, to }
+ )
+ }
return matches
}
+function mapSegmentStart(segment: RichMarkdownVisibleTextSegment, visibleFrom: number): number {
+ return segment.kind === 'text'
+ ? segment.from + Math.max(0, visibleFrom - segment.visibleFrom)
+ : segment.from
+}
+
+function mapSegmentEnd(segment: RichMarkdownVisibleTextSegment, visibleTo: number): number {
+ return segment.kind === 'text'
+ ? segment.from + Math.min(segment.text.length, visibleTo - segment.visibleFrom)
+ : segment.to
+}
+
export function createRichMarkdownSearchPlugin(): Plugin {
return new Plugin({
key: richMarkdownSearchPluginKey,
@@ -126,11 +187,18 @@ function buildSearchDecorationsFromMatches(
return DecorationSet.empty
}
- const decorations = matches.map((match, index) =>
- Decoration.inline(match.from, match.to, {
- class: 'rich-markdown-search-match',
- 'data-active': index === activeIndex ? 'true' : undefined
- })
+ const decorations = matches.flatMap((match, index) =>
+ (match.decorationRanges ?? [{ from: match.from, to: match.to, kind: 'inline' as const }]).map(
+ (range) => {
+ const attrs = {
+ class: 'rich-markdown-search-match',
+ 'data-active': index === activeIndex ? 'true' : undefined
+ }
+ return range.kind === 'node'
+ ? Decoration.node(range.from, range.to, attrs)
+ : Decoration.inline(range.from, range.to, attrs)
+ }
+ )
)
return DecorationSet.create(doc, decorations)
diff --git a/src/renderer/src/components/editor/rich-markdown-selected-link-actions.ts b/src/renderer/src/components/editor/rich-markdown-selected-link-actions.ts
new file mode 100644
index 000000000..ed4444df8
--- /dev/null
+++ b/src/renderer/src/components/editor/rich-markdown-selected-link-actions.ts
@@ -0,0 +1,162 @@
+import type { Editor } from '@tiptap/react'
+import { NodeSelection } from '@tiptap/pm/state'
+import { getLinkBubblePosition, type LinkBubbleState } from './RichMarkdownLinkBubble'
+import { scrollToAnchorInEditor } from './markdown-anchor-scroll'
+import type { ActivateMarkdownLink } from './rich-markdown-editor-click-routing'
+import {
+ classifyHtmlSuperscriptLinkAction,
+ type RichMarkdownHtmlSuperscriptLinkContext
+} from './rich-markdown-html-superscript-link-context'
+import { translate } from '@/i18n/i18n'
+
+export function createEditableMarkdownLinkBubble(
+ href: string,
+ position: Pick
+): LinkBubbleState {
+ return {
+ kind: 'markdown',
+ href,
+ openEnabled: Boolean(href),
+ copyEnabled: Boolean(href),
+ ...position
+ }
+}
+
+export function getRichMarkdownSelectionLinkBubble(
+ editor: Editor,
+ root: HTMLElement | null,
+ context: RichMarkdownHtmlSuperscriptLinkContext
+): LinkBubbleState | null {
+ const position = getLinkBubblePosition(editor, root)
+ if (!position) {
+ return null
+ }
+ if (editor.isActive('link')) {
+ return createBubble(
+ 'markdown',
+ String(editor.getAttributes('link').href ?? ''),
+ position,
+ context
+ )
+ }
+ const selection = editor.state.selection
+ if (
+ !(selection instanceof NodeSelection) ||
+ selection.node.type.name !== 'richMarkdownHtmlSuperscriptLink'
+ ) {
+ return null
+ }
+ return createBubble(
+ 'html-superscript',
+ String(selection.node.attrs.href ?? ''),
+ position,
+ context,
+ String(selection.node.attrs.label ?? '')
+ )
+}
+
+export function getSelectedHtmlSuperscriptLinkStatus(
+ editor: Editor | null,
+ context: RichMarkdownHtmlSuperscriptLinkContext
+): { href: string; label: string; openEnabled: boolean } | null {
+ const selection = editor?.state.selection
+ if (
+ !editor ||
+ !(selection instanceof NodeSelection) ||
+ selection.node.type.name !== 'richMarkdownHtmlSuperscriptLink'
+ ) {
+ return null
+ }
+ const href = String(selection.node.attrs.href ?? '')
+ return {
+ href,
+ label: String(selection.node.attrs.label ?? ''),
+ openEnabled: classifyHtmlSuperscriptLinkAction(href, context.getSnapshot())
+ }
+}
+
+export function formatSelectedHtmlSuperscriptLinkStatus(
+ status: NonNullable>
+): string {
+ const label =
+ status.label ||
+ translate('auto.components.editor.RichMarkdownEditor.citationFallbackLabel', 'Citation')
+ if (status.openEnabled) {
+ return translate(
+ 'auto.components.editor.RichMarkdownEditor.citationLinkAvailable',
+ '{{value0}}, link to {{value1}}. Press Enter to open or Tab for link actions.',
+ { value0: label, value1: status.href }
+ )
+ }
+ const actionHint = status.href
+ ? translate(
+ 'auto.components.editor.RichMarkdownEditor.tabForCitationActions',
+ 'Tab for available actions.'
+ )
+ : translate(
+ 'auto.components.editor.RichMarkdownEditor.noCitationActions',
+ 'No link actions are available.'
+ )
+ return translate(
+ 'auto.components.editor.RichMarkdownEditor.citationLinkUnavailable',
+ '{{value0}}, citation link unavailable. {{value1}}',
+ { value0: label, value1: actionHint }
+ )
+}
+
+export function openSelectedHtmlSuperscriptLink({
+ activateMarkdownLink,
+ context,
+ editor,
+ root,
+ runtimeEnvironmentId
+}: {
+ activateMarkdownLink: ActivateMarkdownLink
+ context: RichMarkdownHtmlSuperscriptLinkContext
+ editor: Editor | null
+ root: HTMLElement | null
+ runtimeEnvironmentId?: string | null
+}): boolean {
+ const selection = editor?.state.selection
+ if (
+ !editor ||
+ !(selection instanceof NodeSelection) ||
+ selection.node.type.name !== 'richMarkdownHtmlSuperscriptLink'
+ ) {
+ return false
+ }
+ const href = String(selection.node.attrs.href ?? '')
+ const snapshot = context.getSnapshot()
+ if (!classifyHtmlSuperscriptLinkAction(href, snapshot)) {
+ return true
+ }
+ if (href.startsWith('#')) {
+ scrollToAnchorInEditor(root, href.slice(1))
+ return true
+ }
+ void activateMarkdownLink(href, {
+ sourceFilePath: snapshot.sourceFilePath,
+ worktreeId: snapshot.worktreeId,
+ worktreeRoot: snapshot.worktreeRoot,
+ runtimeEnvironmentId,
+ sourceOwner: snapshot.sourceOwner
+ })
+ return true
+}
+
+function createBubble(
+ kind: LinkBubbleState['kind'],
+ href: string,
+ position: Pick,
+ context: RichMarkdownHtmlSuperscriptLinkContext,
+ label?: string
+): LinkBubbleState {
+ return {
+ kind,
+ href,
+ label,
+ openEnabled: classifyHtmlSuperscriptLinkAction(href, context.getSnapshot()),
+ copyEnabled: Boolean(href),
+ ...position
+ }
+}
diff --git a/src/renderer/src/components/editor/rich-markdown-source-owning-cut-feedback.ts b/src/renderer/src/components/editor/rich-markdown-source-owning-cut-feedback.ts
new file mode 100644
index 000000000..d3fb80018
--- /dev/null
+++ b/src/renderer/src/components/editor/rich-markdown-source-owning-cut-feedback.ts
@@ -0,0 +1,11 @@
+import { toast } from 'sonner'
+import { translate } from '@/i18n/i18n'
+
+export function showRichMarkdownSourceOwningCutLimitError(): void {
+ toast.error(
+ translate(
+ 'auto.components.editor.richMarkdownSourceOwningCutFeedback.selectLessContent',
+ 'Select less content or use code mode to cut preserved HTML citations.'
+ )
+ )
+}
diff --git a/src/renderer/src/components/editor/rich-markdown-source-owning-slice.ts b/src/renderer/src/components/editor/rich-markdown-source-owning-slice.ts
new file mode 100644
index 000000000..cbaecb4ea
--- /dev/null
+++ b/src/renderer/src/components/editor/rich-markdown-source-owning-slice.ts
@@ -0,0 +1,102 @@
+import type { Slice } from '@tiptap/pm/model'
+import {
+ getRichMarkdownLeafVisibleText,
+ isRichMarkdownVisibleBlockStart
+} from './rich-markdown-visible-text-map'
+
+export const RICH_MARKDOWN_SOURCE_OWNING_PASTE_LIMIT = 256 * 1024
+export const RICH_MARKDOWN_SOURCE_OWNING_NODE_LIMIT = 256
+
+export type RichMarkdownSourceOwningSliceStatus = {
+ containsSourceOwningNode: boolean
+ canPreserve: boolean
+}
+
+export function inspectRichMarkdownSourceOwningSlice(
+ slice: Slice
+): RichMarkdownSourceOwningSliceStatus {
+ let sourceBytes = 0
+ let visibleBytes = 0
+ let nodeCount = 0
+ let containsSourceOwningNode = false
+ let canPreserve = true
+ let sawVisibleBlock = false
+
+ slice.content.descendants((node, pos, parent, index) => {
+ const startsVisibleBlock = isRichMarkdownVisibleBlockStart(node)
+ if (canPreserve && startsVisibleBlock) {
+ if (sawVisibleBlock) {
+ const separator = addUtf8BytesWithinLimit(
+ visibleBytes,
+ '\n',
+ RICH_MARKDOWN_SOURCE_OWNING_PASTE_LIMIT
+ )
+ visibleBytes = separator.byteLength
+ canPreserve = !separator.exceeded
+ }
+ sawVisibleBlock = true
+ }
+ if (node.type.name === 'richMarkdownHtmlSuperscriptLink') {
+ containsSourceOwningNode = true
+ if (!canPreserve) {
+ return false
+ }
+ nodeCount += 1
+ const sourceMeasurement = addUtf8BytesWithinLimit(
+ sourceBytes,
+ String(node.attrs.source ?? ''),
+ RICH_MARKDOWN_SOURCE_OWNING_PASTE_LIMIT
+ )
+ sourceBytes = sourceMeasurement.byteLength
+ canPreserve =
+ !sourceMeasurement.exceeded && nodeCount <= RICH_MARKDOWN_SOURCE_OWNING_NODE_LIMIT
+ if (!canPreserve) {
+ return false
+ }
+ }
+ if (!canPreserve) {
+ // Keep walking only far enough to detect a later source-owning atom.
+ return true
+ }
+ const visible = node.isText
+ ? (node.text ?? '')
+ : node.isLeaf
+ ? getRichMarkdownLeafVisibleText(node, pos, parent, index)
+ : ''
+ if (visible) {
+ const measurement = addUtf8BytesWithinLimit(
+ visibleBytes,
+ visible,
+ RICH_MARKDOWN_SOURCE_OWNING_PASTE_LIMIT
+ )
+ visibleBytes = measurement.byteLength
+ canPreserve = !measurement.exceeded
+ }
+ return true
+ })
+
+ return { containsSourceOwningNode, canPreserve }
+}
+
+function addUtf8BytesWithinLimit(
+ current: number,
+ value: string,
+ limit: number
+): { byteLength: number; exceeded: boolean } {
+ const remaining = limit - current
+ if (value.length > remaining) {
+ return { byteLength: limit + 1, exceeded: true }
+ }
+ let byteLength = current
+ for (let index = 0; index < value.length; index += 1) {
+ const codePoint = value.codePointAt(index) ?? 0
+ byteLength += codePoint <= 0x7f ? 1 : codePoint <= 0x7ff ? 2 : codePoint <= 0xffff ? 3 : 4
+ if (byteLength > limit) {
+ return { byteLength, exceeded: true }
+ }
+ if (codePoint > 0xffff) {
+ index += 1
+ }
+ }
+ return { byteLength, exceeded: false }
+}
diff --git a/src/renderer/src/components/editor/rich-markdown-source-transport.ts b/src/renderer/src/components/editor/rich-markdown-source-transport.ts
new file mode 100644
index 000000000..94369d6e6
--- /dev/null
+++ b/src/renderer/src/components/editor/rich-markdown-source-transport.ts
@@ -0,0 +1,86 @@
+import type { marked } from 'marked'
+import { createTiptapMarkedFacade } from './tiptap-marked-facade'
+
+export type RichMarkdownSourceKind =
+ | 'literal'
+ | 'inline-html'
+ | 'block-html'
+ | 'document-link'
+ | 'html-superscript-link'
+
+const TRANSPORT_PREFIX = '[[ORCA_RICH_MD:'
+const TRANSPORT_SUFFIX = ']]'
+const KEY_PATTERN = /^[a-f0-9]{32}$/
+const TRANSPORT_BODY_PATTERN =
+ /^ORCA_RICH_MD:[a-f0-9]{32}:(?:literal|inline-html|block-html|document-link|html-superscript-link):/
+const LEGACY_PREFIXES = ['ORCA_RAW_HTML_INLINE:', 'ORCA_RAW_HTML_BLOCK:', 'ORCA_DOC_LINK:'] as const
+
+export type RichMarkdownSourceTransport = {
+ readonly key: string
+ readonly authoredPrefix: string
+ create: (kind: RichMarkdownSourceKind, value: string) => string
+ match: (source: string, kind: RichMarkdownSourceKind) => { raw: string; value: string } | null
+ startFor: (kind: RichMarkdownSourceKind) => string
+}
+
+export function isLegacyRichMarkdownTransportBody(value: string): boolean {
+ return LEGACY_PREFIXES.some((prefix) => value.startsWith(prefix))
+}
+
+export function isReservedRichMarkdownTransportBody(value: string): boolean {
+ // Why: a foreign editor's valid keyed envelope is authored text here, not
+ // a document link whose target happens to resemble transport metadata.
+ return isLegacyRichMarkdownTransportBody(value) || TRANSPORT_BODY_PATTERN.test(value)
+}
+
+export type RichMarkdownEditorCodec = {
+ transport: RichMarkdownSourceTransport
+ marked: typeof marked
+}
+
+export function createRichMarkdownEditorCodec(key = createCodecKey()): RichMarkdownEditorCodec {
+ return {
+ transport: createRichMarkdownSourceTransport(key),
+ marked: createTiptapMarkedFacade()
+ }
+}
+
+export function createRichMarkdownSourceTransport(key: string): RichMarkdownSourceTransport {
+ if (!KEY_PATTERN.test(key)) {
+ throw new Error('Rich Markdown transport keys must be 128-bit lowercase hex values')
+ }
+ const authoredPrefix = `${TRANSPORT_PREFIX}${key}:`
+ const startFor = (kind: RichMarkdownSourceKind): string => `${authoredPrefix}${kind}:`
+
+ return {
+ key,
+ authoredPrefix,
+ startFor,
+ create: (kind, value) => `${startFor(kind)}${encodeURIComponent(value)}${TRANSPORT_SUFFIX}`,
+ match: (source, kind) => {
+ const prefix = startFor(kind)
+ if (!source.startsWith(prefix)) {
+ return null
+ }
+ const endIndex = source.indexOf(TRANSPORT_SUFFIX, prefix.length)
+ if (endIndex === -1) {
+ return null
+ }
+ const raw = source.slice(0, endIndex + TRANSPORT_SUFFIX.length)
+ try {
+ return {
+ raw,
+ value: decodeURIComponent(source.slice(prefix.length, endIndex))
+ }
+ } catch {
+ return null
+ }
+ }
+ }
+}
+
+function createCodecKey(): string {
+ const bytes = new Uint8Array(16)
+ globalThis.crypto.getRandomValues(bytes)
+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')
+}
diff --git a/src/renderer/src/components/editor/rich-markdown-tab-key-handler.test.ts b/src/renderer/src/components/editor/rich-markdown-tab-key-handler.test.ts
index 41e779c8a..40822dc7b 100644
--- a/src/renderer/src/components/editor/rich-markdown-tab-key-handler.test.ts
+++ b/src/renderer/src/components/editor/rich-markdown-tab-key-handler.test.ts
@@ -3,20 +3,20 @@ import { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import TaskList from '@tiptap/extension-task-list'
import TaskItem from '@tiptap/extension-task-item'
-import { Markdown } from '@tiptap/markdown'
+import { createIsolatedMarkdownExtensionForTests } from './isolated-markdown-extension-for-tests'
import { createRichMarkdownKeyHandler, type KeyHandlerContext } from './rich-markdown-key-handler'
-const extensions = [
- StarterKit,
- TaskList,
- TaskItem.configure({ nested: true }),
- Markdown.configure({ markedOptions: { gfm: true } })
-]
-
function createEditor(content: object): Editor {
+ // Why: each Editor needs its own marked registry; sharing one module-scoped
+ // extension accumulates tokenizer state across tests.
return new Editor({
element: null,
- extensions,
+ extensions: [
+ StarterKit,
+ TaskList,
+ TaskItem.configure({ nested: true }),
+ createIsolatedMarkdownExtensionForTests()
+ ],
content
})
}
@@ -74,6 +74,18 @@ function createContext(editor: Editor): KeyHandlerContext {
typedEmptyOrderedListMarkerRef: { current: false },
flushPendingSerialization: vi.fn(),
openSearchRef: { current: vi.fn() },
+ linkBubbleOwnerId: 'test-owner',
+ htmlSuperscriptLinkContext: {
+ getSnapshot: () => ({
+ sourceFilePath: '/repo/README.md',
+ worktreeId: 'worktree-1',
+ worktreeRoot: '/repo',
+ sourceOwner: { kind: 'local' as const },
+ version: 0
+ }),
+ subscribe: () => () => {},
+ update: () => {}
+ },
setIsEditingLink: vi.fn(),
setLinkBubble: vi.fn(),
setSelectedCommandIndex: vi.fn(),
diff --git a/src/renderer/src/components/editor/rich-markdown-visible-text-map.ts b/src/renderer/src/components/editor/rich-markdown-visible-text-map.ts
new file mode 100644
index 000000000..ed3c0b2cb
--- /dev/null
+++ b/src/renderer/src/components/editor/rich-markdown-visible-text-map.ts
@@ -0,0 +1,132 @@
+import type { Fragment, Node as ProseMirrorNode } from '@tiptap/pm/model'
+
+type RichMarkdownVisibleTextContainer = ProseMirrorNode | Fragment
+
+export type RichMarkdownVisibleTextSegment = {
+ kind: 'text' | 'read-only-atom' | 'separator'
+ text: string
+ from: number
+ to: number
+ visibleFrom: number
+ visibleTo: number
+}
+
+export type RichMarkdownVisibleTextMap = {
+ text: string
+ segments: RichMarkdownVisibleTextSegment[]
+}
+
+export function createRichMarkdownVisibleTextMap(
+ doc: RichMarkdownVisibleTextContainer,
+ from = 0,
+ to = getContainerContentSize(doc)
+): RichMarkdownVisibleTextMap {
+ const segments: RichMarkdownVisibleTextSegment[] = []
+ let text = ''
+ forEachRichMarkdownVisibleTextSegment(doc, from, to, (segment) => {
+ const visibleFrom = text.length
+ text += segment.text
+ segments.push({ ...segment, visibleFrom, visibleTo: text.length })
+ return true
+ })
+ return { text, segments }
+}
+
+export function getRichMarkdownVisibleText(
+ doc: RichMarkdownVisibleTextContainer,
+ from = 0,
+ to = getContainerContentSize(doc)
+): string {
+ return createRichMarkdownVisibleTextMap(doc, from, to).text
+}
+
+export function getRichMarkdownSelectionVisibleText(state: {
+ doc: ProseMirrorNode
+ selection: { from: number; to: number }
+}): string {
+ return getRichMarkdownVisibleText(state.doc, state.selection.from, state.selection.to).trim()
+}
+
+/** Why: shared block-boundary heuristic for visible-text maps and slice byte limits. */
+export function isRichMarkdownVisibleBlockStart(node: ProseMirrorNode): boolean {
+ return node.isTextblock || (node.isBlock && node.isLeaf)
+}
+
+export function forEachRichMarkdownVisibleTextSegment(
+ doc: RichMarkdownVisibleTextContainer,
+ from: number,
+ to: number,
+ visit: (segment: Omit) => boolean
+): void {
+ let stopped = false
+ let sawVisibleBlock = false
+ const inspect = (
+ node: ProseMirrorNode,
+ pos: number,
+ parent: ProseMirrorNode | null = null,
+ index = 0
+ ) => {
+ if (stopped) {
+ return false
+ }
+ const startsVisibleBlock = isRichMarkdownVisibleBlockStart(node)
+ if (startsVisibleBlock) {
+ if (sawVisibleBlock) {
+ stopped = !visit({ kind: 'separator', text: '\n', from: pos, to: pos })
+ if (stopped) {
+ return false
+ }
+ }
+ sawVisibleBlock = true
+ if (node.isTextblock) {
+ return true
+ }
+ }
+ let visible = ''
+ let segmentFrom = pos
+ let segmentTo = pos + node.nodeSize
+ let kind: RichMarkdownVisibleTextSegment['kind'] = 'text'
+ if (node.isText) {
+ const source = node.text ?? ''
+ const startOffset = Math.max(0, from - pos)
+ const endOffset = Math.min(source.length, to - pos)
+ if (endOffset <= startOffset) {
+ return
+ }
+ visible = source.slice(startOffset, endOffset)
+ segmentFrom = pos + startOffset
+ segmentTo = pos + endOffset
+ } else if (node.isLeaf) {
+ visible = getRichMarkdownLeafVisibleText(node, pos, parent, index)
+ kind = node.isAtom ? 'read-only-atom' : 'text'
+ } else {
+ return
+ }
+ if (!visible) {
+ return
+ }
+ stopped = !visit({
+ kind,
+ text: visible,
+ from: segmentFrom,
+ to: segmentTo
+ })
+ return !stopped
+ }
+ doc.nodesBetween(from, to, inspect)
+}
+
+export function getRichMarkdownLeafVisibleText(
+ node: ProseMirrorNode,
+ pos: number,
+ parent: ProseMirrorNode | null,
+ index: number
+): string {
+ return (
+ node.type.spec.toText?.({ node, pos, parent, index }) ?? node.type.spec.leafText?.(node) ?? ''
+ )
+}
+
+function getContainerContentSize(container: RichMarkdownVisibleTextContainer): number {
+ return 'nodeSize' in container ? container.content.size : container.size
+}
diff --git a/src/renderer/src/components/editor/rich-markdown-visual-line.ts b/src/renderer/src/components/editor/rich-markdown-visual-line.ts
index c28cc4a10..bff83b2d4 100644
--- a/src/renderer/src/components/editor/rich-markdown-visual-line.ts
+++ b/src/renderer/src/components/editor/rich-markdown-visual-line.ts
@@ -1,6 +1,7 @@
-import { DOMSerializer } from '@tiptap/pm/model'
import { TextSelection } from '@tiptap/pm/state'
import type { EditorView } from '@tiptap/pm/view'
+import { writeRichMarkdownSliceToClipboard } from './rich-markdown-clipboard-write'
+import { createRichMarkdownVisibleTextMap } from './rich-markdown-visible-text-map'
/**
* Why: a paragraph that word-wraps across multiple screen lines should be cut
@@ -85,14 +86,15 @@ export function cutVisualLine(
}
event.preventDefault()
- const lineText = view.state.doc.textBetween(lineRange.from, lineRange.to, '')
+ const lineText = createRichMarkdownVisibleTextMap(
+ view.state.doc,
+ lineRange.from,
+ lineRange.to
+ ).text
const slice = view.state.doc.slice(lineRange.from, lineRange.to)
- const serializer = DOMSerializer.fromSchema(view.state.schema)
- const fragment = serializer.serializeFragment(slice.content)
- const div = document.createElement('div')
- div.appendChild(fragment)
- clipboardEvent.clipboardData.setData('text/html', div.innerHTML)
- clipboardEvent.clipboardData.setData('text/plain', lineText)
+ if (!writeRichMarkdownSliceToClipboard(clipboardEvent.clipboardData, view, slice, lineText)) {
+ return true
+ }
let tr = view.state.tr.delete(lineRange.from, lineRange.to)
const clampedPos = Math.max(0, Math.min(lineRange.from, tr.doc.content.size))
diff --git a/src/renderer/src/components/editor/tiptap-marked-facade.ts b/src/renderer/src/components/editor/tiptap-marked-facade.ts
new file mode 100644
index 000000000..e34f67555
--- /dev/null
+++ b/src/renderer/src/components/editor/tiptap-marked-facade.ts
@@ -0,0 +1,82 @@
+import {
+ Hooks,
+ Lexer,
+ Marked,
+ Parser,
+ Renderer,
+ TextRenderer,
+ Tokenizer,
+ getDefaults,
+ type MarkedOptions,
+ type Token,
+ type TokensList,
+ marked
+} from 'marked'
+
+export function createTiptapMarkedFacade(): typeof marked {
+ const registry = new Marked()
+
+ // Why: Tiptap 3.22.5 registers on the injected instance but parses with
+ // `new instance.Lexer()`, so the constructor must retain the private registry.
+ class RegistryLexer extends Lexer {
+ constructor(options?: MarkedOptions) {
+ super({
+ ...registry.defaults,
+ ...options,
+ extensions: registry.defaults.extensions
+ })
+ }
+ }
+
+ const parser = (tokens: Token[], options?: MarkedOptions) => registry.parser(tokens, options)
+ const lexer = (src: string, options?: MarkedOptions): TokensList =>
+ new RegistryLexer(options).lex(src)
+ const facade = new Proxy(marked, {
+ apply: (_target, _thisArg, args) => Reflect.apply(registry.parse, registry, args),
+ get: (target, property, receiver) => {
+ switch (property) {
+ case 'defaults':
+ return registry.defaults
+ case 'getDefaults':
+ return getDefaults
+ case 'Lexer':
+ return RegistryLexer
+ case 'Parser':
+ return Parser
+ case 'Renderer':
+ return Renderer
+ case 'TextRenderer':
+ return TextRenderer
+ case 'Tokenizer':
+ return Tokenizer
+ case 'Hooks':
+ return Hooks
+ case 'parse':
+ return facade
+ case 'parseInline':
+ return registry.parseInline
+ case 'parser':
+ return parser
+ case 'lexer':
+ return lexer
+ case 'walkTokens':
+ return registry.walkTokens.bind(registry)
+ case 'use':
+ return (...extensions: Parameters) => {
+ registry.use(...extensions)
+ return facade
+ }
+ case 'setOptions':
+ case 'options':
+ return (options: MarkedOptions) => {
+ registry.setOptions(options)
+ return facade
+ }
+ default:
+ return Reflect.get(target, property, receiver)
+ }
+ }
+ }) satisfies typeof marked
+
+ return facade
+}
diff --git a/src/renderer/src/components/editor/useLinkBubble.test.tsx b/src/renderer/src/components/editor/useLinkBubble.test.tsx
new file mode 100644
index 000000000..ac7b4521b
--- /dev/null
+++ b/src/renderer/src/components/editor/useLinkBubble.test.tsx
@@ -0,0 +1,93 @@
+// @vitest-environment happy-dom
+
+import { act, useRef, useState } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import type { HttpLinkSourceOwner } from '@/lib/http-link-routing'
+import type { LinkBubbleState } from './RichMarkdownLinkBubble'
+import { createRichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-html-superscript-link-context'
+
+const activateMarkdownLinkMock = vi.hoisted(() => vi.fn())
+
+vi.mock('@/store', () => ({
+ useAppStore: (
+ selector: (state: { activateMarkdownLink: typeof activateMarkdownLinkMock }) => unknown
+ ) => selector({ activateMarkdownLink: activateMarkdownLinkMock })
+}))
+
+import { useLinkBubble } from './useLinkBubble'
+
+describe('useLinkBubble owner hydration', () => {
+ let container: HTMLDivElement
+ let root: Root
+
+ afterEach(() => {
+ act(() => root.unmount())
+ container.remove()
+ activateMarkdownLinkMock.mockReset()
+ })
+
+ it.each([{ kind: 'local' }, { kind: 'ssh', connectionId: 'ssh-1' }])(
+ 'refreshes an open Markdown bubble when ownership becomes $kind',
+ (sourceOwner) => {
+ const context = createRichMarkdownHtmlSuperscriptLinkContext({
+ sourceFilePath: '/repo/README.md',
+ worktreeId: 'worktree-1',
+ worktreeRoot: '/repo',
+ sourceOwner: { kind: 'unknown' }
+ })
+
+ function Harness(): React.JSX.Element {
+ const rootRef = useRef(null)
+ const [bubble, setBubble] = useState({
+ kind: 'markdown',
+ href: 'https://example.com',
+ openEnabled: false,
+ copyEnabled: true,
+ left: 0,
+ top: 0
+ })
+ const { handleLinkOpen } = useLinkBubble(null, rootRef, bubble, setBubble, () => {}, {
+ sourceFilePath: '/repo/README.md',
+ worktreeId: 'worktree-1',
+ worktreeRoot: '/repo',
+ htmlSuperscriptLinkContext: context
+ })
+ return (
+
+ )
+ }
+
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+ act(() => root.render())
+ const button = container.querySelector('button')
+ if (!button) {
+ throw new Error('Expected link action')
+ }
+ expect(button.dataset.enabled).toBe('false')
+ act(() => button.click())
+ expect(activateMarkdownLinkMock).not.toHaveBeenCalled()
+
+ act(() => {
+ context.update({
+ sourceFilePath: '/repo/README.md',
+ worktreeId: 'worktree-1',
+ worktreeRoot: '/repo',
+ sourceOwner
+ })
+ })
+ expect(button.dataset.enabled).toBe('true')
+ expect(button.textContent).toBe('https://example.com')
+
+ act(() => button.click())
+ expect(activateMarkdownLinkMock).toHaveBeenCalledWith(
+ 'https://example.com',
+ expect.objectContaining({ sourceOwner })
+ )
+ }
+ )
+})
diff --git a/src/renderer/src/components/editor/useLinkBubble.ts b/src/renderer/src/components/editor/useLinkBubble.ts
index 76941e0dc..a19d89dc4 100644
--- a/src/renderer/src/components/editor/useLinkBubble.ts
+++ b/src/renderer/src/components/editor/useLinkBubble.ts
@@ -1,9 +1,14 @@
-import { useCallback } from 'react'
+import { useCallback, useEffect, useSyncExternalStore } from 'react'
import type { Editor } from '@tiptap/react'
import { getLinkBubblePosition } from './RichMarkdownLinkBubble'
import type { LinkBubbleState } from './RichMarkdownLinkBubble'
import { useAppStore } from '@/store'
import { scrollToAnchorInEditor } from './markdown-anchor-scroll'
+import {
+ classifyHtmlSuperscriptLinkAction,
+ type RichMarkdownHtmlSuperscriptLinkContext
+} from './rich-markdown-html-superscript-link-context'
+import { copyRichMarkdownLink } from './rich-markdown-link-clipboard'
/**
* Extracts link-editing action handlers from the editor component to reduce
@@ -21,14 +26,30 @@ export function useLinkBubble(
worktreeId: string
worktreeRoot: string | null
runtimeEnvironmentId?: string | null
+ htmlSuperscriptLinkContext: RichMarkdownHtmlSuperscriptLinkContext
}
): {
handleLinkSave: (href: string) => void
handleLinkRemove: () => void
handleLinkEditCancel: () => void
handleLinkOpen: () => void
+ handleLinkCopy: () => void
toggleLinkFromToolbar: () => void
} {
+ const citationContextSnapshot = useSyncExternalStore(
+ linkContext.htmlSuperscriptLinkContext.subscribe,
+ linkContext.htmlSuperscriptLinkContext.getSnapshot,
+ linkContext.htmlSuperscriptLinkContext.getSnapshot
+ )
+ useEffect(() => {
+ if (!linkBubble) {
+ return
+ }
+ const openEnabled = classifyHtmlSuperscriptLinkAction(linkBubble.href, citationContextSnapshot)
+ if (openEnabled !== linkBubble.openEnabled) {
+ setLinkBubble({ ...linkBubble, openEnabled })
+ }
+ }, [citationContextSnapshot, linkBubble, setLinkBubble])
const startLinkEdit = useCallback(() => {
if (!editor) {
return
@@ -38,7 +59,13 @@ export function useLinkBubble(
const href = editor.isActive('link')
? (editor.getAttributes('link').href as string) || ''
: ''
- setLinkBubble({ href, ...pos })
+ setLinkBubble({
+ kind: 'markdown',
+ href,
+ openEnabled: Boolean(href),
+ copyEnabled: Boolean(href),
+ ...pos
+ })
setIsEditingLink(true)
}
}, [editor, rootRef, setLinkBubble, setIsEditingLink])
@@ -99,7 +126,11 @@ export function useLinkBubble(
const activateMarkdownLink = useAppStore((s) => s.activateMarkdownLink)
const handleLinkOpen = useCallback(() => {
- if (!linkBubble?.href) {
+ if (
+ !linkBubble?.href ||
+ !linkBubble.openEnabled ||
+ !classifyHtmlSuperscriptLinkAction(linkBubble.href, citationContextSnapshot)
+ ) {
return
}
if (linkBubble.href.startsWith('#')) {
@@ -110,11 +141,14 @@ export function useLinkBubble(
sourceFilePath: linkContext.sourceFilePath,
worktreeId: linkContext.worktreeId,
worktreeRoot: linkContext.worktreeRoot,
- runtimeEnvironmentId: linkContext.runtimeEnvironmentId
+ runtimeEnvironmentId: linkContext.runtimeEnvironmentId,
+ sourceOwner: citationContextSnapshot.sourceOwner
})
}, [
activateMarkdownLink,
+ citationContextSnapshot,
linkBubble?.href,
+ linkBubble?.openEnabled,
linkContext.sourceFilePath,
linkContext.worktreeId,
linkContext.worktreeRoot,
@@ -122,6 +156,13 @@ export function useLinkBubble(
rootRef
])
+ const handleLinkCopy = useCallback(() => {
+ if (!linkBubble?.href || !linkBubble.copyEnabled) {
+ return
+ }
+ void copyRichMarkdownLink(linkBubble.href)
+ }, [linkBubble?.copyEnabled, linkBubble?.href])
+
const toggleLinkFromToolbar = useCallback(() => {
if (!editor) {
return
@@ -139,6 +180,7 @@ export function useLinkBubble(
handleLinkRemove,
handleLinkEditCancel,
handleLinkOpen,
+ handleLinkCopy,
toggleLinkFromToolbar
}
}
diff --git a/src/renderer/src/components/editor/useRichMarkdownEditorInstance.ts b/src/renderer/src/components/editor/useRichMarkdownEditorInstance.ts
index cd2cf41a1..211793f5a 100644
--- a/src/renderer/src/components/editor/useRichMarkdownEditorInstance.ts
+++ b/src/renderer/src/components/editor/useRichMarkdownEditorInstance.ts
@@ -6,13 +6,21 @@ import {
type EditorConfigParams
} from './rich-markdown-editor-config'
-const richMarkdownExtensions = createRichMarkdownExtensions({ includePlaceholder: true })
-
export function useRichMarkdownEditorInstance(params: EditorConfigParams): Editor | null {
+ const extensions = useMemo(
+ () =>
+ createRichMarkdownExtensions({
+ codec: params.codec,
+ includePlaceholder: true,
+ htmlSuperscriptLinks: true,
+ htmlSuperscriptLinkContext: params.htmlSuperscriptLinkContext
+ }),
+ [params.codec, params.htmlSuperscriptLinkContext]
+ )
const editor = useEditor(
useMemo(
() => ({
- extensions: richMarkdownExtensions,
+ extensions,
...createRichMarkdownEditorConfig(params)
}),
// Dependencies are the same as the params object keys
diff --git a/src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.ts b/src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.ts
index 2defa2504..2086a39f2 100644
--- a/src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.ts
+++ b/src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.ts
@@ -11,8 +11,10 @@ import {
setRichMarkdownImageResolverContext,
type RichMarkdownImageResolverSettings
} from './rich-markdown-image-context'
+import type { RichMarkdownEditorCodec } from './rich-markdown-source-transport'
type RichMarkdownProgrammaticSyncOptions = {
+ codec: RichMarkdownEditorCodec
content: string
docLinkMenuSetter: Dispatch>
editor: Editor | null
@@ -36,6 +38,7 @@ type RichMarkdownEditorStorage = {
}
export function useRichMarkdownProgrammaticSync({
+ codec,
content,
docLinkMenuSetter,
editor,
@@ -103,7 +106,7 @@ export function useRichMarkdownProgrammaticSync({
}
isApplyingProgrammaticUpdateRef.current = true
try {
- applyExternalRichMarkdownContent(editor, content, lastCommittedMarkdownRef)
+ applyExternalRichMarkdownContent(editor, content, lastCommittedMarkdownRef, codec)
} finally {
isApplyingProgrammaticUpdateRef.current = false
}
@@ -111,6 +114,7 @@ export function useRichMarkdownProgrammaticSync({
syncDocLinkMenu(editor, rootRef.current, docLinkMenuSetter)
}, [
content,
+ codec,
docLinkMenuSetter,
editor,
fileId,
@@ -124,15 +128,19 @@ export function useRichMarkdownProgrammaticSync({
function applyExternalRichMarkdownContent(
editor: Editor,
content: string,
- lastCommittedMarkdownRef: MutableRefObject
+ lastCommittedMarkdownRef: MutableRefObject,
+ codec: RichMarkdownEditorCodec
): void {
try {
const hadFocus = editor.isFocused
const { from: prevFrom, to: prevTo } = editor.state.selection
- editor.commands.setContent(encodeRawMarkdownHtmlForRichEditor(content), {
- contentType: 'markdown',
- emitUpdate: false
- })
+ editor.commands.setContent(
+ encodeRawMarkdownHtmlForRichEditor(content, codec, { htmlSuperscriptLinks: true }),
+ {
+ contentType: 'markdown',
+ emitUpdate: false
+ }
+ )
// Why: normalizeEmptyListItems avoids splitting hard-wrapped paragraphs from
// external content, matching onCreate's single-paragraph reflow behavior.
normalizeEmptyListItems(editor)
diff --git a/src/renderer/src/components/editor/useRichMarkdownSearch.ts b/src/renderer/src/components/editor/useRichMarkdownSearch.ts
index f45bc610b..035081665 100644
--- a/src/renderer/src/components/editor/useRichMarkdownSearch.ts
+++ b/src/renderer/src/components/editor/useRichMarkdownSearch.ts
@@ -84,6 +84,11 @@ export function useRichMarkdownSearch({
})
}, [editor, isSearchOpen, matchCase, searchQuery, wholeWord])
+ // Why: mirror the guard used by replaceCurrentMatch/replaceAllMatches so the
+ // disabled state never disagrees with what a click will actually do during the
+ // debounce window when live matches diverge from the highlight set.
+ const replaceDisabled = getLiveMatches().some((match) => match.touchesReadOnlyAtom)
+
// Clamp the user-controlled index to the valid range on every render.
// No state update needed — this is a pure derivation.
const activeMatchIndex =
@@ -160,7 +165,7 @@ export function useRichMarkdownSearch({
const liveActiveMatchIndex =
activeMatchIndex >= 0 && activeMatchIndex < liveMatches.length ? activeMatchIndex : 0
const match = liveMatches[liveActiveMatchIndex]
- if (!match) {
+ if (!match || liveMatches.some((candidate) => candidate.touchesReadOnlyAtom)) {
return
}
// Why: removing the active match shifts the next match into the same index,
@@ -173,7 +178,10 @@ export function useRichMarkdownSearch({
return
}
const liveMatches = getLiveMatches()
- if (liveMatches.length === 0) {
+ if (
+ liveMatches.length === 0 ||
+ liveMatches.some((candidate) => candidate.touchesReadOnlyAtom)
+ ) {
return
}
const tr = editor.state.tr
@@ -344,6 +352,7 @@ export function useRichMarkdownSearch({
matchCase,
matchCount,
replaceQuery,
+ replaceDisabled,
searchQuery,
searchInputRef,
wholeWord
diff --git a/src/renderer/src/components/editor/useRichMarkdownSuperscriptLinkSetup.ts b/src/renderer/src/components/editor/useRichMarkdownSuperscriptLinkSetup.ts
new file mode 100644
index 000000000..1cd89aec9
--- /dev/null
+++ b/src/renderer/src/components/editor/useRichMarkdownSuperscriptLinkSetup.ts
@@ -0,0 +1,59 @@
+import { useLayoutEffect, useMemo, useState } from 'react'
+import { useAppStore } from '@/store'
+import { getIndexedWorktreeById } from '@/store/worktree-repo-index'
+import { parseWorkspaceKey } from '../../../../shared/workspace-scope'
+import { createConnectionIdForFileSelector } from '@/lib/connection-owner-resolution'
+import type { HttpLinkSourceOwner } from '@/lib/http-link-routing'
+import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport'
+import { createRichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-html-superscript-link-context'
+import type { AppState } from '@/store/types'
+
+export function resolveRichMarkdownWorktreeRoot(
+ state: Pick,
+ worktreeId: string
+): string | null {
+ const workspaceScope = parseWorkspaceKey(worktreeId)
+ return workspaceScope?.type === 'folder'
+ ? (state.folderWorkspaces.find((workspace) => workspace.id === workspaceScope.folderWorkspaceId)
+ ?.folderPath ?? null)
+ : (getIndexedWorktreeById(state.worktreesByRepo, worktreeId)?.path ?? null)
+}
+
+export function useRichMarkdownSuperscriptLinkSetup({
+ filePath,
+ runtimeEnvironmentId,
+ worktreeId
+}: {
+ filePath: string
+ runtimeEnvironmentId?: string | null
+ worktreeId: string
+}) {
+ const worktreeRoot = useAppStore((state) => resolveRichMarkdownWorktreeRoot(state, worktreeId))
+ const runtimeId = runtimeEnvironmentId?.trim()
+ const connectionIdSelector = useMemo(() => {
+ return createConnectionIdForFileSelector(worktreeId, filePath, { skip: Boolean(runtimeId) })
+ }, [filePath, runtimeId, worktreeId])
+ const connectionId = useAppStore(connectionIdSelector)
+ const sourceOwner = useMemo(() => {
+ if (runtimeId) {
+ return { kind: 'runtime', runtimeEnvironmentId: runtimeId }
+ }
+ if (connectionId === undefined) {
+ return { kind: 'unknown' }
+ }
+ return connectionId === null ? { kind: 'local' } : { kind: 'ssh', connectionId }
+ }, [connectionId, runtimeId])
+ const [codec] = useState(createRichMarkdownEditorCodec)
+ const [context] = useState(() =>
+ createRichMarkdownHtmlSuperscriptLinkContext({
+ sourceFilePath: filePath,
+ worktreeId,
+ worktreeRoot,
+ sourceOwner
+ })
+ )
+ useLayoutEffect(() => {
+ context.update({ sourceFilePath: filePath, worktreeId, worktreeRoot, sourceOwner })
+ }, [context, filePath, sourceOwner, worktreeId, worktreeRoot])
+ return { codec, htmlSuperscriptLinkContext: context, worktreeRoot }
+}
diff --git a/src/renderer/src/components/github/GitHubMarkdownComposer.tsx b/src/renderer/src/components/github/GitHubMarkdownComposer.tsx
index 3159408be..0d7d78d1e 100644
--- a/src/renderer/src/components/github/GitHubMarkdownComposer.tsx
+++ b/src/renderer/src/components/github/GitHubMarkdownComposer.tsx
@@ -15,6 +15,9 @@ import {
type LinkBubbleState
} from '@/components/editor/RichMarkdownLinkBubble'
import { encodeRawMarkdownHtmlForRichEditor } from '@/components/editor/raw-markdown-html'
+import { createRichMarkdownEditorCodec } from '@/components/editor/rich-markdown-source-transport'
+import { createEditableMarkdownLinkBubble } from '@/components/editor/rich-markdown-selected-link-actions'
+import { copyRichMarkdownLink } from '@/components/editor/rich-markdown-link-clipboard'
import { normalizeSoftBreaks } from '@/components/editor/rich-markdown-normalize'
import { GitHubMarkdownComposerPreviewPane } from '@/components/github/github-markdown-composer-preview-pane'
import {
@@ -71,6 +74,7 @@ export function GitHubMarkdownComposer({
const [linkBubble, setLinkBubble] = useState(null)
const [isEditingLink, setIsEditingLink] = useState(false)
const isTabbed = layout === 'tabbed'
+ const codec = useMemo(() => createRichMarkdownEditorCodec(), [])
const {
imageUrl,
@@ -89,13 +93,13 @@ export function GitHubMarkdownComposer({
const extensions = useMemo(
() => [
- ...createRichMarkdownExtensions(),
+ ...createRichMarkdownExtensions({ codec }),
Placeholder.configure({
includeChildren: true,
placeholder
})
],
- [placeholder]
+ [codec, placeholder]
)
const openLinkEditor = useCallback(() => {
@@ -109,7 +113,7 @@ export function GitHubMarkdownComposer({
return
}
const href = editor.isActive('link') ? String(editor.getAttributes('link').href ?? '') : ''
- setLinkBubble({ href, ...position })
+ setLinkBubble(createEditableMarkdownLinkBubble(href, position))
setIsEditingLink(true)
}, [])
@@ -117,7 +121,7 @@ export function GitHubMarkdownComposer({
immediatelyRender: false,
extensions,
editable: !disabled,
- content: encodeRawMarkdownHtmlForRichEditor(value),
+ content: encodeRawMarkdownHtmlForRichEditor(value, codec),
contentType: 'markdown',
editorProps: {
attributes: {
@@ -177,10 +181,12 @@ export function GitHubMarkdownComposer({
if (nextEditor.isActive('link')) {
const position = getLinkBubblePosition(nextEditor, rootRef.current)
if (position) {
- setLinkBubble({
- href: String(nextEditor.getAttributes('link').href ?? ''),
- ...position
- })
+ setLinkBubble(
+ createEditableMarkdownLinkBubble(
+ String(nextEditor.getAttributes('link').href ?? ''),
+ position
+ )
+ )
return
}
}
@@ -224,7 +230,7 @@ export function GitHubMarkdownComposer({
}
applyingExternalValueRef.current = true
try {
- editor.commands.setContent(encodeRawMarkdownHtmlForRichEditor(value), {
+ editor.commands.setContent(encodeRawMarkdownHtmlForRichEditor(value, codec), {
contentType: 'markdown',
emitUpdate: false
})
@@ -233,7 +239,7 @@ export function GitHubMarkdownComposer({
} finally {
applyingExternalValueRef.current = false
}
- }, [editor, value])
+ }, [codec, editor, value])
const handleLinkSave = useCallback((href: string) => {
const editor = editorRef.current
@@ -403,6 +409,7 @@ export function GitHubMarkdownComposer({
editorRef.current?.commands.focus()
}}
onOpen={handleLinkOpen}
+ onCopy={() => void copyRichMarkdownLink(linkBubble.href)}
/>
) : null}
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index c9c058e32..3475f335e 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -11605,7 +11605,8 @@
"1c99b726e0": "Remove link",
"cdfe166f6f": "Edit link",
"bfc813e909": "Open link",
- "7b0b945fdc": "Paste or type a link…"
+ "7b0b945fdc": "Paste or type a link…",
+ "copyLink": "Copy link"
},
"RichMarkdownReviewNoteLayer": {
"f3ef92952b": "This note",
@@ -11632,7 +11633,8 @@
"68d090241d": "Match whole word",
"fd97c7e585": "Replace",
"44682b4159": "Replace in rich markdown editor",
- "c2884f5e95": "Replace all"
+ "c2884f5e95": "Replace all",
+ "preservedRichContentReadOnly": "Preserved rich content is read-only in rich mode."
},
"RichMarkdownSlashMenu": {
"82c6816ff8": "No blocks found",
@@ -11849,6 +11851,24 @@
"3fa2b8d417": "Reload from Disk",
"a95d02c644": "Keep My Edits",
"2c8f1e07b9": "Loading comparison..."
+ },
+ "RichMarkdownEditor": {
+ "citationLinkAvailable": "{{value0}}, link to {{value1}}. Press Enter to open or Tab for link actions.",
+ "citationFallbackLabel": "Citation",
+ "citationLinkUnavailable": "{{value0}}, citation link unavailable. {{value1}}",
+ "tabForCitationActions": "Tab for available actions.",
+ "noCitationActions": "No link actions are available."
+ },
+ "richMarkdownHtmlSuperscriptLink": {
+ "availableAriaLabel": "{{value0}}, link to {{value1}}",
+ "unavailableAriaLabel": "{{value0}}, citation link unavailable"
+ },
+ "richMarkdownLinkClipboard": {
+ "copiedLink": "Copied link",
+ "copyLinkFailed": "Failed to copy link"
+ },
+ "richMarkdownSourceOwningCutFeedback": {
+ "selectLessContent": "Select less content or use code mode to cut preserved HTML citations."
}
},
"diff": {
diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json
index c84e4f354..0cd02c7e9 100644
--- a/src/renderer/src/i18n/locales/es.json
+++ b/src/renderer/src/i18n/locales/es.json
@@ -11605,7 +11605,8 @@
"1c99b726e0": "Quitar enlace",
"cdfe166f6f": "Editar enlace",
"bfc813e909": "Abrir enlace",
- "7b0b945fdc": "Pega o escribe un enlace…"
+ "7b0b945fdc": "Pega o escribe un enlace…",
+ "copyLink": "Copiar enlace"
},
"RichMarkdownReviewNoteLayer": {
"f3ef92952b": "Esta nota",
@@ -11632,7 +11633,8 @@
"68d090241d": "Coincidir con toda la palabra",
"fd97c7e585": "Reemplazar",
"44682b4159": "Reemplazar en el editor de markdown enriquecido",
- "c2884f5e95": "Reemplazar todo"
+ "c2884f5e95": "Reemplazar todo",
+ "preservedRichContentReadOnly": "El contenido enriquecido conservado es de solo lectura en el modo enriquecido."
},
"RichMarkdownSlashMenu": {
"82c6816ff8": "No se encontraron bloques",
@@ -11849,6 +11851,24 @@
"3fa2b8d417": "Recargar desde disco",
"a95d02c644": "Mantener mis ediciones",
"2c8f1e07b9": "Cargando comparación..."
+ },
+ "RichMarkdownEditor": {
+ "citationLinkAvailable": "{{value0}}, enlace a {{value1}}. Pulsa Intro para abrirlo o Tab para las acciones del enlace.",
+ "citationFallbackLabel": "Cita",
+ "citationLinkUnavailable": "{{value0}}, enlace de cita no disponible. {{value1}}",
+ "tabForCitationActions": "Pulsa Tab para ver las acciones disponibles.",
+ "noCitationActions": "No hay acciones de enlace disponibles."
+ },
+ "richMarkdownHtmlSuperscriptLink": {
+ "availableAriaLabel": "{{value0}}, enlace a {{value1}}",
+ "unavailableAriaLabel": "{{value0}}, enlace de cita no disponible"
+ },
+ "richMarkdownLinkClipboard": {
+ "copiedLink": "Enlace copiado",
+ "copyLinkFailed": "No se pudo copiar el enlace"
+ },
+ "richMarkdownSourceOwningCutFeedback": {
+ "selectLessContent": "Selecciona menos contenido o usa el modo de código para cortar citas HTML conservadas."
}
},
"diff": {
diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json
index 6d481080c..37ba7f699 100644
--- a/src/renderer/src/i18n/locales/ja.json
+++ b/src/renderer/src/i18n/locales/ja.json
@@ -11605,7 +11605,8 @@
"1c99b726e0": "リンクを削除する",
"cdfe166f6f": "リンクを編集",
"bfc813e909": "リンクを開く",
- "7b0b945fdc": "リンクを貼り付けるか入力してください…"
+ "7b0b945fdc": "リンクを貼り付けるか入力してください…",
+ "copyLink": "リンクをコピー"
},
"RichMarkdownReviewNoteLayer": {
"f3ef92952b": "このメモ",
@@ -11632,7 +11633,8 @@
"68d090241d": "単語全体に一致",
"fd97c7e585": "置換",
"44682b4159": "リッチ markdown エディタで置換",
- "c2884f5e95": "すべて置換"
+ "c2884f5e95": "すべて置換",
+ "preservedRichContentReadOnly": "保持されたリッチコンテンツはリッチモードでは読み取り専用です。"
},
"RichMarkdownSlashMenu": {
"82c6816ff8": "ブロックが見つかりませんでした",
@@ -11849,6 +11851,24 @@
"3fa2b8d417": "ディスクから再読み込み",
"a95d02c644": "編集内容を保持",
"2c8f1e07b9": "比較を読み込み中..."
+ },
+ "RichMarkdownEditor": {
+ "citationLinkAvailable": "{{value0}}、{{value1}}へのリンク。Enterキーで開くか、Tabキーでリンク操作に移動します。",
+ "citationFallbackLabel": "引用",
+ "citationLinkUnavailable": "{{value0}}、引用リンクは利用できません。{{value1}}",
+ "tabForCitationActions": "Tabキーで利用可能な操作に移動します。",
+ "noCitationActions": "利用できるリンク操作はありません。"
+ },
+ "richMarkdownHtmlSuperscriptLink": {
+ "availableAriaLabel": "{{value0}}、{{value1}}へのリンク",
+ "unavailableAriaLabel": "{{value0}}、引用リンクは利用できません"
+ },
+ "richMarkdownLinkClipboard": {
+ "copiedLink": "リンクをコピーしました",
+ "copyLinkFailed": "リンクをコピーできませんでした"
+ },
+ "richMarkdownSourceOwningCutFeedback": {
+ "selectLessContent": "保持されたHTML引用を切り取るには、選択範囲を小さくするかコードモードを使用してください。"
}
},
"diff": {
diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json
index 8e5fc8a4c..ce2ed02e3 100644
--- a/src/renderer/src/i18n/locales/ko.json
+++ b/src/renderer/src/i18n/locales/ko.json
@@ -11605,7 +11605,8 @@
"1c99b726e0": "링크 삭제",
"cdfe166f6f": "링크 수정",
"bfc813e909": "링크 열기",
- "7b0b945fdc": "링크를 붙여넣거나 입력하세요…"
+ "7b0b945fdc": "링크를 붙여넣거나 입력하세요…",
+ "copyLink": "링크 복사"
},
"RichMarkdownReviewNoteLayer": {
"f3ef92952b": "이 메모",
@@ -11632,7 +11633,8 @@
"68d090241d": "전체 단어 일치",
"fd97c7e585": "바꾸기",
"44682b4159": "리치 markdown 편집기에서 바꾸기",
- "c2884f5e95": "모두 바꾸기"
+ "c2884f5e95": "모두 바꾸기",
+ "preservedRichContentReadOnly": "보존된 리치 콘텐츠는 리치 모드에서 읽기 전용입니다."
},
"RichMarkdownSlashMenu": {
"82c6816ff8": "블록을 찾을 수 없습니다.",
@@ -11849,6 +11851,24 @@
"3fa2b8d417": "디스크에서 다시 로드",
"a95d02c644": "편집 내용 유지",
"2c8f1e07b9": "비교 로딩 중..."
+ },
+ "RichMarkdownEditor": {
+ "citationLinkAvailable": "{{value0}}, {{value1}} 링크. Enter 키로 열거나 Tab 키로 링크 작업으로 이동하세요.",
+ "citationFallbackLabel": "인용",
+ "citationLinkUnavailable": "{{value0}}, 인용 링크를 사용할 수 없습니다. {{value1}}",
+ "tabForCitationActions": "Tab 키로 사용 가능한 작업으로 이동하세요.",
+ "noCitationActions": "사용 가능한 링크 작업이 없습니다."
+ },
+ "richMarkdownHtmlSuperscriptLink": {
+ "availableAriaLabel": "{{value0}}, {{value1}} 링크",
+ "unavailableAriaLabel": "{{value0}}, 인용 링크를 사용할 수 없음"
+ },
+ "richMarkdownLinkClipboard": {
+ "copiedLink": "링크를 복사했습니다",
+ "copyLinkFailed": "링크를 복사하지 못했습니다"
+ },
+ "richMarkdownSourceOwningCutFeedback": {
+ "selectLessContent": "보존된 HTML 인용을 잘라내려면 더 적은 콘텐츠를 선택하거나 코드 모드를 사용하세요."
}
},
"diff": {
diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json
index d4e2f36f9..aeb2bec07 100644
--- a/src/renderer/src/i18n/locales/zh.json
+++ b/src/renderer/src/i18n/locales/zh.json
@@ -11605,7 +11605,8 @@
"1c99b726e0": "删除链接",
"cdfe166f6f": "编辑链接",
"bfc813e909": "打开链接",
- "7b0b945fdc": "粘贴或输入链接..."
+ "7b0b945fdc": "粘贴或输入链接...",
+ "copyLink": "复制链接"
},
"RichMarkdownReviewNoteLayer": {
"f3ef92952b": "此注",
@@ -11632,7 +11633,8 @@
"68d090241d": "匹配整个单词",
"fd97c7e585": "替换",
"44682b4159": "在 Markdown 编辑器中替换",
- "c2884f5e95": "全部替换"
+ "c2884f5e95": "全部替换",
+ "preservedRichContentReadOnly": "保留的富文本内容在富文本模式下为只读。"
},
"RichMarkdownSlashMenu": {
"82c6816ff8": "没有找到块",
@@ -11849,6 +11851,24 @@
"3fa2b8d417": "从磁盘重新加载",
"a95d02c644": "保留我的编辑",
"2c8f1e07b9": "正在加载比较..."
+ },
+ "RichMarkdownEditor": {
+ "citationLinkAvailable": "{{value0}},链接到 {{value1}}。按 Enter 打开,或按 Tab 移至链接操作。",
+ "citationFallbackLabel": "引用",
+ "citationLinkUnavailable": "{{value0}},引用链接不可用。{{value1}}",
+ "tabForCitationActions": "按 Tab 移至可用操作。",
+ "noCitationActions": "没有可用的链接操作。"
+ },
+ "richMarkdownHtmlSuperscriptLink": {
+ "availableAriaLabel": "{{value0}},链接到 {{value1}}",
+ "unavailableAriaLabel": "{{value0}},引用链接不可用"
+ },
+ "richMarkdownLinkClipboard": {
+ "copiedLink": "已复制链接",
+ "copyLinkFailed": "复制链接失败"
+ },
+ "richMarkdownSourceOwningCutFeedback": {
+ "selectLessContent": "请选择较少的内容,或使用代码模式剪切保留的 HTML 引用。"
}
},
"diff": {
diff --git a/src/renderer/src/lib/connection-context.test.ts b/src/renderer/src/lib/connection-context.test.ts
index 59e80c48a..27bfbe3a7 100644
--- a/src/renderer/src/lib/connection-context.test.ts
+++ b/src/renderer/src/lib/connection-context.test.ts
@@ -10,6 +10,7 @@ import {
} from './connection-context'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants'
import { folderWorkspaceKey } from '../../../shared/workspace-scope'
+import { createConnectionIdForFileSelector } from './connection-owner-resolution'
const initialState = useAppStore.getInitialState()
@@ -581,4 +582,24 @@ describe('getConnectionIdFromState', () => {
expect(getConnectionIdFromState(state, null)).toBeNull()
})
+
+ it('recomputes a retained file-owner selector when ownership slices hydrate', () => {
+ const selector = createConnectionIdForFileSelector(
+ 'repo-ssh::/home/neil/repo-feature',
+ '/home/neil/repo-feature/README.md'
+ )
+ const unresolved: ConnectionContextState = {
+ folderWorkspaces: [],
+ projectGroups: [],
+ repos: [],
+ worktreesByRepo: {}
+ }
+ expect(selector(unresolved)).toBeUndefined()
+
+ const hydrated: ConnectionContextState = {
+ ...unresolved,
+ repos: [makeRepo({ id: 'repo-ssh', connectionId: 'ssh-hydrated' })]
+ }
+ expect(selector(hydrated)).toBe('ssh-hydrated')
+ })
})
diff --git a/src/renderer/src/lib/connection-context.ts b/src/renderer/src/lib/connection-context.ts
index 260b61499..a1cb0dc49 100644
--- a/src/renderer/src/lib/connection-context.ts
+++ b/src/renderer/src/lib/connection-context.ts
@@ -1,17 +1,11 @@
import { useAppStore } from '@/store'
-import { getIndexedRepoMap, getIndexedWorktreeMap } from '@/store/worktree-repo-index'
-import type { AppState } from '@/store/types'
-import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants'
-import { getRepoIdFromWorktreeId } from '../../../shared/worktree-id'
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
import {
- isPathInsideOrEqual,
- normalizeRuntimePathForComparison
-} from '../../../shared/cross-platform-path'
-import {
- getFolderWorkspaceCandidateRepos,
- getFolderWorkspaceConnectionId
-} from './folder-workspace-connection'
+ getConnectionIdForFileFromState,
+ getConnectionIdFromState
+} from './connection-owner-resolution'
+
+export { getConnectionIdFromState } from './connection-owner-resolution'
/**
* Resolve the SSH connectionId for a worktree. Returns null for local repos,
@@ -22,33 +16,6 @@ export function getConnectionId(worktreeId: string | null): string | null | unde
return getConnectionIdFromState(useAppStore.getState(), worktreeId)
}
-export function getConnectionIdFromState(
- state: Pick,
- worktreeId: string | null
-): string | null | undefined {
- if (!worktreeId) {
- return null
- }
- if (worktreeId === FLOATING_TERMINAL_WORKTREE_ID) {
- return null
- }
- const parsedWorkspaceKey = parseWorkspaceKey(worktreeId)
- if (parsedWorkspaceKey?.type === 'folder') {
- return getFolderWorkspaceConnectionId(state, parsedWorkspaceKey.folderWorkspaceId)
- }
- // Why: retained Zustand selectors call this on unrelated writes; reuse the
- // immutable-slice indexes instead of flattening every worktree each time.
- const worktree = getIndexedWorktreeMap(state.worktreesByRepo).get(worktreeId)
- // Why: SSH worktrees can be restored from session IDs before relay discovery
- // repopulates worktreesByRepo. The composite ID still carries the repo ID.
- const repoId = worktree?.repoId ?? getRepoIdFromWorktreeId(worktreeId)
- const repo = getIndexedRepoMap(state.repos).get(repoId)
- if (!repo) {
- return undefined
- }
- return repo.connectionId ?? null
-}
-
/**
* True when we can determine the owning host (local vs. a specific SSH target)
* for a worktree. False means the backing repo has not landed in the store yet
@@ -75,41 +42,5 @@ export function getConnectionIdForFile(
worktreeId: string | null,
filePath: string
): string | null | undefined {
- const connectionId = getConnectionId(worktreeId)
- if (connectionId !== undefined || !worktreeId) {
- return connectionId
- }
- const parsedWorkspaceKey = parseWorkspaceKey(worktreeId)
- if (parsedWorkspaceKey?.type !== 'folder') {
- return undefined
- }
- // Why: mixed local/SSH folder workspaces cannot pick one owner globally, but
- // a concrete file path can still belong unambiguously to a child repo.
- const state = useAppStore.getState()
- const candidateRepos = getFolderWorkspaceCandidateRepos(
- state,
- parsedWorkspaceKey.folderWorkspaceId
- )
- return resolveConnectionIdForRepoPath(candidateRepos, filePath)
-}
-
-function resolveConnectionIdForRepoPath(
- repos: readonly { path: string; connectionId?: string | null }[],
- filePath: string
-): string | null | undefined {
- const matchingRepos = repos
- .filter((repo) => isPathInsideOrEqual(repo.path, filePath))
- .map((repo) => ({ repo, normalizedPath: normalizeRuntimePathForComparison(repo.path) }))
- .sort((a, b) => b.normalizedPath.length - a.normalizedPath.length)
- const longestPathLength = matchingRepos[0]?.normalizedPath.length
- if (!longestPathLength) {
- return undefined
- }
- // Why: containment normalizes separators/trailing slashes; ambiguity checks
- // need the same representation or equal repo roots can be hidden.
- const bestMatches = matchingRepos.filter(
- (candidate) => candidate.normalizedPath.length === longestPathLength
- )
- const connectionIds = new Set(bestMatches.map(({ repo }) => repo.connectionId ?? null))
- return connectionIds.size === 1 ? ([...connectionIds][0] ?? null) : undefined
+ return getConnectionIdForFileFromState(useAppStore.getState(), worktreeId, filePath)
}
diff --git a/src/renderer/src/lib/connection-owner-resolution.ts b/src/renderer/src/lib/connection-owner-resolution.ts
new file mode 100644
index 000000000..3d3656215
--- /dev/null
+++ b/src/renderer/src/lib/connection-owner-resolution.ts
@@ -0,0 +1,100 @@
+import type { AppState } from '@/store/types'
+import { getIndexedRepoMap, getIndexedWorktreeMap } from '@/store/worktree-repo-index'
+import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants'
+import { getRepoIdFromWorktreeId } from '../../../shared/worktree-id'
+import { parseWorkspaceKey } from '../../../shared/workspace-scope'
+import {
+ isPathInsideOrEqual,
+ normalizeRuntimePathForComparison
+} from '../../../shared/cross-platform-path'
+import {
+ getFolderWorkspaceCandidateRepos,
+ getFolderWorkspaceConnectionId
+} from './folder-workspace-connection'
+
+type ConnectionOwnerState = Pick<
+ AppState,
+ 'folderWorkspaces' | 'projectGroups' | 'repos' | 'worktreesByRepo'
+>
+
+export function createConnectionIdForFileSelector(
+ worktreeId: string | null,
+ filePath: string,
+ { skip = false }: { skip?: boolean } = {}
+): (state: ConnectionOwnerState) => string | null | undefined {
+ let previousSlices: ConnectionOwnerState | null = null
+ let previousResult: string | null | undefined
+ return (state) => {
+ if (skip) {
+ return undefined
+ }
+ if (
+ previousSlices?.folderWorkspaces === state.folderWorkspaces &&
+ previousSlices.projectGroups === state.projectGroups &&
+ previousSlices.repos === state.repos &&
+ previousSlices.worktreesByRepo === state.worktreesByRepo
+ ) {
+ return previousResult
+ }
+ previousSlices = {
+ folderWorkspaces: state.folderWorkspaces,
+ projectGroups: state.projectGroups,
+ repos: state.repos,
+ worktreesByRepo: state.worktreesByRepo
+ }
+ previousResult = getConnectionIdForFileFromState(state, worktreeId, filePath)
+ return previousResult
+ }
+}
+
+export function getConnectionIdFromState(
+ state: ConnectionOwnerState,
+ worktreeId: string | null
+): string | null | undefined {
+ if (!worktreeId || worktreeId === FLOATING_TERMINAL_WORKTREE_ID) {
+ return null
+ }
+ const parsedWorkspaceKey = parseWorkspaceKey(worktreeId)
+ if (parsedWorkspaceKey?.type === 'folder') {
+ return getFolderWorkspaceConnectionId(state, parsedWorkspaceKey.folderWorkspaceId)
+ }
+ // Why: owner resolution runs from retained Zustand selectors, so unrelated
+ // store writes must not flatten every worktree or scan every repository.
+ const worktree = getIndexedWorktreeMap(state.worktreesByRepo).get(worktreeId)
+ const repoId = worktree?.repoId ?? getRepoIdFromWorktreeId(worktreeId)
+ const repo = getIndexedRepoMap(state.repos).get(repoId)
+ return repo ? (repo.connectionId ?? null) : undefined
+}
+
+export function getConnectionIdForFileFromState(
+ state: ConnectionOwnerState,
+ worktreeId: string | null,
+ filePath: string
+): string | null | undefined {
+ const connectionId = getConnectionIdFromState(state, worktreeId)
+ if (connectionId !== undefined || !worktreeId) {
+ return connectionId
+ }
+ const parsedWorkspaceKey = parseWorkspaceKey(worktreeId)
+ if (parsedWorkspaceKey?.type !== 'folder') {
+ return undefined
+ }
+ const candidateRepos = getFolderWorkspaceCandidateRepos(
+ state,
+ parsedWorkspaceKey.folderWorkspaceId
+ )
+ const matchingRepos = candidateRepos
+ .filter((repo) => isPathInsideOrEqual(repo.path, filePath))
+ .map((repo) => ({ repo, normalizedPath: normalizeRuntimePathForComparison(repo.path) }))
+ .sort((left, right) => right.normalizedPath.length - left.normalizedPath.length)
+ const longestPathLength = matchingRepos[0]?.normalizedPath.length
+ if (!longestPathLength) {
+ return undefined
+ }
+ const connectionIds = new Set(
+ matchingRepos
+ .filter((candidate) => candidate.normalizedPath.length === longestPathLength)
+ .map(({ repo }) => repo.connectionId ?? null)
+ )
+ return connectionIds.size === 1 ? ([...connectionIds][0] ?? null) : undefined
+}
diff --git a/src/renderer/src/lib/http-link-routing.test.ts b/src/renderer/src/lib/http-link-routing.test.ts
index 021826610..56ddc9b6e 100644
--- a/src/renderer/src/lib/http-link-routing.test.ts
+++ b/src/renderer/src/lib/http-link-routing.test.ts
@@ -32,12 +32,14 @@ const storeState = {
allWorktrees: vi.fn(
() => [] as { id: string; projectId?: string; repoId?: string; displayName?: string }[]
),
- workspacePortScan: null as { result: WorkspacePortScanResult } | null
+ workspacePortScan: null as { result: WorkspacePortScanResult } | null,
+ workspacePortScansByKey: {} as Record
}
beforeEach(() => {
vi.clearAllMocks()
storeState.settings = undefined
+ storeState.workspacePortScansByKey = {}
registerHttpLinkStoreAccessor(() => storeState)
vi.stubGlobal('window', {
api: {
@@ -110,6 +112,106 @@ describe('openHttpLink', () => {
expect(setActiveWorktreeMock).not.toHaveBeenCalled()
})
+ it('honors an explicit local document owner despite an unrelated active runtime', () => {
+ storeState.settings = { openLinksInApp: true, activeRuntimeEnvironmentId: 'env-other' }
+
+ openHttpLink('https://example.com/', {
+ worktreeId: 'wt-1',
+ sourceOwner: { kind: 'local' }
+ })
+
+ expect(createBrowserTabMock).toHaveBeenCalledWith('wt-1', 'https://example.com/', {
+ activate: true
+ })
+ expect(openUrlMock).not.toHaveBeenCalled()
+ })
+
+ it('routes explicit runtime and SSH document owners to the exact system URL', () => {
+ storeState.settings = { openLinksInApp: true, localhostWorktreeLabelsEnabled: true }
+
+ openHttpLink('http://localhost:5180/runtime', {
+ worktreeId: 'wt-1',
+ sourceOwner: { kind: 'runtime', runtimeEnvironmentId: 'env-1' }
+ })
+ openHttpLink('http://localhost:5180/ssh', {
+ worktreeId: 'wt-1',
+ sourceOwner: { kind: 'ssh', connectionId: 'ssh-1' }
+ })
+
+ expect(openUrlMock).toHaveBeenNthCalledWith(1, 'http://localhost:5180/runtime')
+ expect(openUrlMock).toHaveBeenNthCalledWith(2, 'http://localhost:5180/ssh')
+ expect(createBrowserTabMock).not.toHaveBeenCalled()
+ expect(registerLocalhostLabelMock).not.toHaveBeenCalled()
+ })
+
+ it('labels explicit local links from the local scan instead of a merged remote port', async () => {
+ storeState.settings = {
+ openLinksInApp: true,
+ activeRuntimeEnvironmentId: 'env-other',
+ localhostWorktreeLabelsEnabled: true
+ }
+ storeState.repos = [
+ { id: 'repo-local', displayName: 'Local' },
+ { id: 'repo-remote', displayName: 'Remote' }
+ ]
+ storeState.worktreesByRepo = {
+ 'repo-local': [{ id: 'wt-local', projectId: 'repo-local' }],
+ 'repo-remote': [{ id: 'wt-remote', projectId: 'repo-remote' }]
+ }
+ const port = (repoId: string, worktreeId: string, path: string) => ({
+ id: `tcp:5180:${worktreeId}`,
+ kind: 'workspace' as const,
+ port: 5180,
+ protocol: 'http' as const,
+ bindHost: '127.0.0.1',
+ connectHost: 'localhost',
+ owner: {
+ repoId,
+ worktreeId,
+ displayName: worktreeId,
+ path,
+ confidence: 'cwd' as const
+ }
+ })
+ storeState.workspacePortScan = {
+ result: {
+ platform: 'darwin',
+ scannedAt: 2,
+ ports: [port('repo-remote', 'wt-remote', '/remote')]
+ }
+ }
+ storeState.workspacePortScansByKey = {
+ 'local:all': {
+ platform: 'darwin',
+ scannedAt: 1,
+ ports: [port('repo-local', 'wt-local', '/local')]
+ }
+ }
+ registerLocalhostLabelMock.mockResolvedValue({ url: 'http://wt-local.orca.localhost:60016/' })
+
+ openHttpLink('http://localhost:5180/', {
+ worktreeId: 'wt-local',
+ sourceOwner: { kind: 'local' }
+ })
+ await Promise.resolve()
+
+ expect(registerLocalhostLabelMock).toHaveBeenCalledWith(
+ expect.objectContaining({ repoId: 'repo-local', worktreeId: 'wt-local' })
+ )
+ })
+
+ it('keeps unresolved document ownership non-actionable', () => {
+ storeState.settings = { openLinksInApp: true }
+
+ openHttpLink('https://example.com/', {
+ worktreeId: 'wt-1',
+ sourceOwner: { kind: 'unknown' }
+ })
+
+ expect(openUrlMock).not.toHaveBeenCalled()
+ expect(createBrowserTabMock).not.toHaveBeenCalled()
+ })
+
it('routes to the system browser when no worktree id is provided', () => {
storeState.settings = { openLinksInApp: true }
diff --git a/src/renderer/src/lib/http-link-routing.ts b/src/renderer/src/lib/http-link-routing.ts
index 4d678d203..e3a34faa4 100644
--- a/src/renderer/src/lib/http-link-routing.ts
+++ b/src/renderer/src/lib/http-link-routing.ts
@@ -9,8 +9,15 @@ import type { WorkspacePort, WorkspacePortScanResult } from '../../../shared/wor
export type OpenHttpLinkOptions = {
worktreeId?: string | null
forceSystemBrowser?: boolean
+ sourceOwner?: HttpLinkSourceOwner
}
+export type HttpLinkSourceOwner =
+ | { kind: 'local' }
+ | { kind: 'runtime'; runtimeEnvironmentId: string }
+ | { kind: 'ssh'; connectionId: string }
+ | { kind: 'unknown' }
+
type StoreAccessor = () => {
settings?: Partial<
Pick<
@@ -25,6 +32,7 @@ type StoreAccessor = () => {
worktreesByRepo?: Record
allWorktrees?: () => LocalhostLinkWorktree[]
workspacePortScan?: { result: WorkspacePortScanResult } | null
+ workspacePortScansByKey?: Record
}
type LocalhostLinkRepo = {
@@ -56,11 +64,15 @@ export function registerHttpLinkStoreAccessor(fn: StoreAccessor): void {
// branch). Shift+Cmd/Ctrl is the escape hatch: callers pass forceSystemBrowser
// to bypass the setting entirely.
export function openHttpLink(url: string, opts: OpenHttpLinkOptions = {}): void {
- const { worktreeId, forceSystemBrowser } = opts
+ const { worktreeId, forceSystemBrowser, sourceOwner } = opts
+ if (sourceOwner?.kind === 'unknown') {
+ return
+ }
const state = storeAccessor?.()
const remoteRuntimeActive = Boolean(state?.settings?.activeRuntimeEnvironmentId?.trim())
+ const sourceIsLocal = sourceOwner ? sourceOwner.kind === 'local' : !remoteRuntimeActive
const routeToOrca =
- !remoteRuntimeActive &&
+ sourceIsLocal &&
!forceSystemBrowser &&
Boolean(worktreeId) &&
state?.settings?.openLinksInApp === true
@@ -75,7 +87,7 @@ export function openHttpLink(url: string, opts: OpenHttpLinkOptions = {}): void
// to the global activeWorktreeId deselects the real repo workspace.
state.setActiveWorktree(worktreeId)
}
- const localhostRoute = localhostLabelRouteForTerminalLink(url, state)
+ const localhostRoute = localhostLabelRouteForHttpLink(url, state, sourceOwner)
if (!localhostRoute) {
state.createBrowserTab(worktreeId, url, { activate: true })
return
@@ -86,7 +98,7 @@ export function openHttpLink(url: string, opts: OpenHttpLinkOptions = {}): void
return
}
- const localhostRoute = state ? localhostLabelRouteForTerminalLink(url, state) : null
+ const localhostRoute = state ? localhostLabelRouteForHttpLink(url, state, sourceOwner) : null
if (!localhostRoute) {
void window.api.shell.openUrl(url)
return
@@ -96,6 +108,24 @@ export function openHttpLink(url: string, opts: OpenHttpLinkOptions = {}): void
})
}
+function localhostLabelRouteForHttpLink(
+ url: string,
+ state: ReturnType,
+ sourceOwner?: HttpLinkSourceOwner
+): LocalhostWorktreeLabelRoute | null {
+ if (sourceOwner && sourceOwner.kind !== 'local') {
+ return null
+ }
+ if (!sourceOwner && state.settings?.activeRuntimeEnvironmentId?.trim()) {
+ return null
+ }
+ const sourceScan =
+ sourceOwner?.kind === 'local'
+ ? (state.workspacePortScansByKey?.['local:all'] ?? null)
+ : undefined
+ return localhostLabelRouteForTerminalLink(url, state, sourceOwner?.kind === 'local', sourceScan)
+}
+
export async function resolveLocalhostHttpLinkDisplayUrl(url: string): Promise {
const state = storeAccessor?.()
if (!state) {
@@ -128,11 +158,13 @@ async function openLabeledLocalhostLink(
function localhostLabelRouteForTerminalLink(
rawUrl: string,
- state: ReturnType
+ state: ReturnType,
+ ignoreActiveRuntime = false,
+ sourceScan?: WorkspacePortScanResult | null
): LocalhostWorktreeLabelRoute | null {
if (
state.settings?.localhostWorktreeLabelsEnabled !== true ||
- state.settings?.activeRuntimeEnvironmentId?.trim()
+ (!ignoreActiveRuntime && state.settings?.activeRuntimeEnvironmentId?.trim())
) {
return null
}
@@ -142,7 +174,8 @@ function localhostLabelRouteForTerminalLink(
if (!parsed) {
return null
}
- const port = findWorkspacePortByNumber(state.workspacePortScan?.result, Number(parsed.port))
+ const scan = sourceScan === undefined ? state.workspacePortScan?.result : sourceScan
+ const port = findWorkspacePortByNumber(scan, Number(parsed.port))
if (!port) {
return null
}
diff --git a/src/renderer/src/store/slices/editor.test.ts b/src/renderer/src/store/slices/editor.test.ts
index cbdd62a72..0859e6756 100644
--- a/src/renderer/src/store/slices/editor.test.ts
+++ b/src/renderer/src/store/slices/editor.test.ts
@@ -49,6 +49,10 @@ function createEditorStore(): StoreApi {
browserTabsByWorktree: {},
activeBrowserTabId: null,
activeBrowserTabIdByWorktree: {},
+ repos: [{ id: 'repo-1', path: '/repo' }],
+ worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1', path: '/repo' }] },
+ folderWorkspaces: [],
+ projectGroups: [],
recordFeatureInteraction: vi.fn(),
...createEditorSlice(...(args as Parameters))
})) as unknown as StoreApi
@@ -62,6 +66,10 @@ function createEditorTabsStore(): StoreApi {
browserTabsByWorktree: {},
activeBrowserTabId: null,
activeBrowserTabIdByWorktree: {},
+ repos: [{ id: 'repo-1', path: '/repo' }],
+ worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1', path: '/repo' }] },
+ folderWorkspaces: [],
+ projectGroups: [],
recordFeatureInteraction: vi.fn(),
...createTabsSlice(...(args as Parameters)),
...createEditorSlice(...(args as Parameters))
@@ -4003,6 +4011,47 @@ describe('createEditorSlice activateMarkdownLink', () => {
])
})
+ it('rejects ambiguous same-path owner fallback and honors an explicit source owner', async () => {
+ const store = createEditorStore()
+ store.getState().openFile({
+ filePath: '/repo/docs/note.md',
+ relativePath: 'docs/note.md',
+ worktreeId: 'wt-1',
+ runtimeEnvironmentId: 'env-source',
+ language: 'markdown',
+ mode: 'edit'
+ })
+ store.getState().openFile(
+ {
+ filePath: '/repo/docs/note.md',
+ relativePath: 'docs/note.md',
+ worktreeId: 'wt-1',
+ runtimeEnvironmentId: null,
+ language: 'markdown',
+ mode: 'edit'
+ },
+ { suppressActiveRuntimeFallback: true }
+ )
+
+ await store.getState().activateMarkdownLink('https://example.com', {
+ sourceFilePath: '/repo/docs/note.md',
+ worktreeId: 'wt-1',
+ worktreeRoot: '/repo'
+ })
+ expect(openHttpLinkMock).not.toHaveBeenCalled()
+
+ await store.getState().activateMarkdownLink('https://example.com', {
+ sourceFilePath: '/repo/docs/note.md',
+ worktreeId: 'wt-1',
+ worktreeRoot: '/repo',
+ sourceOwner: { kind: 'local' }
+ })
+ expect(openHttpLinkMock).toHaveBeenCalledWith('https://example.com/', {
+ worktreeId: 'wt-1',
+ sourceOwner: { kind: 'local' }
+ })
+ })
+
it('stats SSH markdown links through the source worktree connection before opening', async () => {
const store = createEditorStore()
pathExistsMock.mockResolvedValue(true)
@@ -4268,11 +4317,38 @@ describe('createEditorSlice activateMarkdownLink', () => {
worktreeId: 'wt-1',
worktreeRoot: '/repo'
})
- expect(openHttpLinkMock).toHaveBeenCalledWith('https://example.com/', { worktreeId: 'wt-1' })
+ expect(openHttpLinkMock).toHaveBeenCalledWith('https://example.com/', {
+ worktreeId: 'wt-1',
+ sourceOwner: { kind: 'local' }
+ })
expect(openUrlMock).not.toHaveBeenCalled()
expect(store.getState().openFiles).toEqual([])
})
+ it('does not rescan legacy owner state when the source owner is explicit', async () => {
+ const store = createEditorStore()
+ for (const key of ['openFiles', 'repos', 'worktreesByRepo', 'folderWorkspaces'] as const) {
+ Object.defineProperty(store.getState(), key, {
+ configurable: true,
+ get: () => {
+ throw new Error(`explicit owner must not read ${key}`)
+ }
+ })
+ }
+
+ await store.getState().activateMarkdownLink('https://example.com', {
+ sourceFilePath: '/repo/docs/note.md',
+ worktreeId: 'wt-1',
+ worktreeRoot: '/repo',
+ sourceOwner: { kind: 'local' }
+ })
+
+ expect(openHttpLinkMock).toHaveBeenCalledWith('https://example.com/', {
+ worktreeId: 'wt-1',
+ sourceOwner: { kind: 'local' }
+ })
+ })
+
it('opens in-worktree file links in Orca', async () => {
const store = createEditorStore()
await store.getState().activateMarkdownLink('./image.png', {
diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts
index 07de857fa..8e9e86c0d 100644
--- a/src/renderer/src/store/slices/editor.ts
+++ b/src/renderer/src/store/slices/editor.ts
@@ -10,7 +10,8 @@ import {
getCheckRunDetailsTabLabel,
type OpenCheckRunDetailsState
} from '@/components/editor/check-run-details-tab'
-import { openHttpLink } from '@/lib/http-link-routing'
+import { openHttpLink, type HttpLinkSourceOwner } from '@/lib/http-link-routing'
+import { getConnectionIdForFileFromState } from '@/lib/connection-owner-resolution'
import { isLocalPathOpenBlocked, showLocalPathOpenBlockedToast } from '@/lib/local-path-open-guard'
import { detectLanguage } from '@/lib/language-detect'
import type {
@@ -500,6 +501,7 @@ export type EditorSlice = {
worktreeId: string
worktreeRoot: string | null
runtimeEnvironmentId?: string | null
+ sourceOwner?: HttpLinkSourceOwner
}
) => Promise
openMarkdownPreview: (
@@ -1335,13 +1337,6 @@ function shouldDeleteUntouchedUntitledFile(file: OpenFile | undefined, hasDraft:
)
}
-function getWorktreeConnectionId(state: AppState, worktreeId: string): string | undefined {
- const worktree = findWorktreeById(state.worktreesByRepo ?? {}, worktreeId)
- const repoId = worktree?.repoId ?? getRepoIdFromWorktreeId(worktreeId)
- const repo = (state.repos ?? []).find((candidate) => candidate.id === repoId)
- return repo?.connectionId ?? undefined
-}
-
export const createEditorSlice: StateCreator = (set, get) => ({
editorDrafts: {},
setEditorDraft: (fileId, content) =>
@@ -4170,16 +4165,48 @@ export const createEditorSlice: StateCreator = (s
activateMarkdownLink: async (rawHref, ctx) => {
const initialState = get()
+ let inferredRuntimeEnvironmentId: string | null | undefined
+ if (!ctx.sourceOwner && ctx.runtimeEnvironmentId === undefined) {
+ const inferredRuntimeOwners = new Set(
+ initialState.openFiles
+ .filter(
+ (file) => file.filePath === ctx.sourceFilePath && file.worktreeId === ctx.worktreeId
+ )
+ .map((file) => file.runtimeEnvironmentId?.trim() || null)
+ )
+ if (inferredRuntimeOwners.size > 1) {
+ return
+ }
+ inferredRuntimeEnvironmentId =
+ inferredRuntimeOwners.size === 1 ? [...inferredRuntimeOwners][0] : undefined
+ }
const sourceRuntimeEnvironmentId =
- ctx.runtimeEnvironmentId !== undefined
- ? ctx.runtimeEnvironmentId
- : initialState.openFiles.find((file) => file.filePath === ctx.sourceFilePath)
- ?.runtimeEnvironmentId
- const sourceSettings = settingsForRuntimeOwner(
- initialState.settings,
- sourceRuntimeEnvironmentId
- )
- const sourceConnectionId = getWorktreeConnectionId(initialState, ctx.worktreeId)
+ ctx.sourceOwner?.kind === 'runtime'
+ ? ctx.sourceOwner.runtimeEnvironmentId
+ : ctx.sourceOwner
+ ? null
+ : ctx.runtimeEnvironmentId !== undefined
+ ? ctx.runtimeEnvironmentId
+ : inferredRuntimeEnvironmentId
+ const runtimeOwnerId = sourceRuntimeEnvironmentId?.trim() || null
+ const sourceSettings = settingsForRuntimeOwner(initialState.settings, runtimeOwnerId)
+ const resolvedConnectionId =
+ ctx.sourceOwner || runtimeOwnerId
+ ? undefined
+ : getConnectionIdForFileFromState(initialState, ctx.worktreeId, ctx.sourceFilePath)
+ const sourceOwner: HttpLinkSourceOwner =
+ ctx.sourceOwner ??
+ (runtimeOwnerId
+ ? { kind: 'runtime', runtimeEnvironmentId: runtimeOwnerId }
+ : resolvedConnectionId === undefined
+ ? { kind: 'unknown' }
+ : resolvedConnectionId === null
+ ? { kind: 'local' }
+ : { kind: 'ssh', connectionId: resolvedConnectionId })
+ if (sourceOwner.kind === 'unknown') {
+ return
+ }
+ const sourceConnectionId = sourceOwner.kind === 'ssh' ? sourceOwner.connectionId : undefined
const fileContext = {
settings: sourceSettings,
worktreeId: ctx.worktreeId,
@@ -4194,7 +4221,7 @@ export const createEditorSlice: StateCreator = (s
return
}
if (target.kind === 'external') {
- openHttpLink(target.url, { worktreeId: ctx.worktreeId })
+ openHttpLink(target.url, { worktreeId: ctx.worktreeId, sourceOwner })
return
}
if (target.kind === 'file') {