feat(markdown): support HTML superscript links (#8307)

* feat(markdown): support HTML superscript links

* Add HTML superscript citation links to the rich markdown editor

- Introduce marked-based tokenizer support for `<sup><a>` citation
  links, with a shared editor context so key/link handlers can resolve
  selection status and open an action bubble for citation atoms
  (which markdown setLink/unsetLink can't edit).
- Extract clipboard-write logic shared by cut and cut-visual-line into
  rich-markdown-clipboard-write.ts, and surface a cut-limit error when
  clipboard readback fails.
- Fix MarkdownPreview same-file anchor scrolling to run before the
  unknown-ownership guard so ambiguous folder-workspace ownership
  still scrolls within the open document.
- Keep search's replace-disabled state derived from live matches
  instead of a stale snapshot.

* Update lockfile to dedupe stale package versions after dependency resolu

- Removes duplicate/superseded resolutions (older @babel/* versions, fs-extra 11.3.4/11.3.5, plist 3.1.0, semver 7.7.4) left over from a prior install
- Aligns transitive deps to the single resolved versions already in use elsewhere in the tree
This commit is contained in:
Jinjing 2026-07-11 14:51:52 -07:00 committed by GitHub
parent 6be8687c34
commit ac8879c789
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
82 changed files with 4301 additions and 1048 deletions

View File

@ -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",

View File

@ -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: {}

View File

@ -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;

View File

@ -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 (
<div

View File

@ -15,6 +15,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const createBrowserTabMock = vi.fn()
const setActiveWorktreeMock = vi.fn()
const openUrlMock = vi.fn()
const openFileUriMock = vi.fn()
const pathExistsMock = vi.fn(async () => true)
const connectionOwner = vi.hoisted(() => ({ value: null as string | null | undefined }))
const targetConnectionOwners = vi.hoisted(() => new Map<string, string | null | undefined>())
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(
<MarkdownPreview
content="[example](https://example.com)"
filePath="/repo/docs/README.md"
sourceWorktreeId="wt-1"
content={content}
filePath={filePath}
sourceWorktreeId={sourceWorktreeId}
scrollCacheKey="test-key"
/>
)
})
const anchor = container.querySelector<HTMLAnchorElement>('a[href="https://example.com"]')
const anchor = Array.from(container.querySelectorAll<HTMLAnchorElement>('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 = '<sup><a href="https://example.com">[12]</a></sup>'
const anchor = render(source)
expect(anchor.parentElement?.tagName).toBe('SUP')
expect(anchor.textContent).toBe('[12]')
expect(container.textContent).not.toContain('<sup>')
click(anchor, { metaKey: true })
expect(createBrowserTabMock).toHaveBeenCalledWith('wt-1', 'https://example.com/', {
activate: true
})
expect(openUrlMock).not.toHaveBeenCalled()
})
})

View File

@ -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 }))

View File

@ -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<string, Worktree[]>,
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<string, Worktree[]>,
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<string, Worktree[]>,
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<HttpLinkSourceOwner>(
() =>
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,

View File

@ -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<HTMLDivElement | null>(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}

View File

@ -76,6 +76,7 @@ type RichMarkdownEditorSurfaceProps = {
matchCase: boolean
matchCount: number
replaceQuery: string
replaceDisabled: boolean
searchQuery: string
searchInputRef: React.RefObject<HTMLInputElement | null>
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}
<span className="sr-only" role="status" aria-live="polite">
{citationStatus}
</span>
{slashMenu ? (
<RichMarkdownSlashMenu
editor={editor}

View File

@ -1,13 +1,19 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import type { Editor } from '@tiptap/react'
import { ExternalLink, Pencil, Unlink } from 'lucide-react'
import { Copy, ExternalLink, Pencil, Unlink } from 'lucide-react'
import { translate } from '@/i18n/i18n'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
export type LinkBubbleState = {
kind: 'markdown' | 'html-superscript'
href: string
left: number
top: number
openEnabled: boolean
copyEnabled: boolean
label?: string
}
const LINK_BUBBLE_VIEWPORT_MARGIN = 8
@ -160,6 +166,8 @@ type RichMarkdownLinkBubbleProps = {
onEditStart: () => 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<HTMLDivElement | null>(null)
@ -270,6 +280,8 @@ export function RichMarkdownLinkBubble({
<div
ref={bubbleRef}
className="rich-markdown-link-bubble"
data-rich-markdown-link-bubble=""
data-rich-markdown-link-bubble-owner={ownerId}
style={positionStyle}
onMouseDown={(e) => {
// 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<HTMLElement>('[contenteditable="true"]')?.focus()
return
}
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') {
return
}
const buttons = Array.from(
bubbleRef.current?.querySelectorAll<HTMLButtonElement>('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 ? (
<LinkEditInput initialHref={linkBubble.href} onSave={onSave} onCancel={onEditCancel} />
@ -287,39 +320,45 @@ export function RichMarkdownLinkBubble({
<span className="rich-markdown-link-url" title={linkBubble.href}>
{linkBubble.href.length > 40 ? `${linkBubble.href.slice(0, 40)}` : linkBubble.href}
</span>
<button
type="button"
className="rich-markdown-link-button"
onClick={onOpen}
title={translate(
<LinkBubbleAction
label={translate(
'auto.components.editor.RichMarkdownLinkBubble.bfc813e909',
'Open link'
)}
disabled={!linkBubble.openEnabled}
onClick={onOpen}
>
<ExternalLink size={14} />
</button>
<button
type="button"
className="rich-markdown-link-button"
onClick={onEditStart}
title={translate(
'auto.components.editor.RichMarkdownLinkBubble.cdfe166f6f',
'Edit link'
)}
</LinkBubbleAction>
<LinkBubbleAction
label={translate('auto.components.editor.RichMarkdownLinkBubble.copyLink', 'Copy link')}
disabled={!linkBubble.copyEnabled}
onClick={onCopy}
>
<Pencil size={14} />
</button>
<button
type="button"
className="rich-markdown-link-button"
onClick={onRemove}
title={translate(
'auto.components.editor.RichMarkdownLinkBubble.1c99b726e0',
'Remove link'
)}
>
<Unlink size={14} />
</button>
<Copy size={14} />
</LinkBubbleAction>
{linkBubble.kind === 'markdown' ? (
<>
<LinkBubbleAction
label={translate(
'auto.components.editor.RichMarkdownLinkBubble.cdfe166f6f',
'Edit link'
)}
onClick={onEditStart}
>
<Pencil size={14} />
</LinkBubbleAction>
<LinkBubbleAction
label={translate(
'auto.components.editor.RichMarkdownLinkBubble.1c99b726e0',
'Remove link'
)}
onClick={onRemove}
>
<Unlink size={14} />
</LinkBubbleAction>
</>
) : null}
</>
)}
</div>
@ -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 (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="rich-markdown-link-button"
aria-label={label}
disabled={disabled}
onClick={onClick}
>
{children}
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{label}
</TooltipContent>
</Tooltip>
)
}

View File

@ -22,6 +22,7 @@ type RichMarkdownSearchBarProps = {
matchCount: number
query: string
replaceQuery: string
replaceDisabled: boolean
searchInputRef: React.RefObject<HTMLInputElement | null>
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}
/>
</div>
<Button
@ -267,11 +275,12 @@ export function RichMarkdownSearchBar({
size="icon-xs"
onMouseDown={keepSearchFocus}
onClick={onReplaceCurrent}
disabled={noMatches}
title={translate(
'auto.components.editor.RichMarkdownSearchBar.fd97c7e585',
'Replace'
)}
disabled={noMatches || replaceDisabled}
title={
replaceDisabled
? readOnlyReplaceExplanation
: translate('auto.components.editor.RichMarkdownSearchBar.fd97c7e585', 'Replace')
}
aria-label={translate(
'auto.components.editor.RichMarkdownSearchBar.fd97c7e585',
'Replace'
@ -286,11 +295,15 @@ export function RichMarkdownSearchBar({
size="icon-xs"
onMouseDown={keepSearchFocus}
onClick={onReplaceAll}
disabled={noMatches}
title={translate(
'auto.components.editor.RichMarkdownSearchBar.c2884f5e95',
'Replace all'
)}
disabled={noMatches || replaceDisabled}
title={
replaceDisabled
? readOnlyReplaceExplanation
: translate(
'auto.components.editor.RichMarkdownSearchBar.c2884f5e95',
'Replace all'
)
}
aria-label={translate(
'auto.components.editor.RichMarkdownSearchBar.c2884f5e95',
'Replace all'
@ -299,6 +312,11 @@ export function RichMarkdownSearchBar({
>
<ReplaceAll size={14} />
</Button>
{replaceDisabled ? (
<span id={readOnlyExplanationId} className="sr-only" role="status">
{readOnlyReplaceExplanation}
</span>
) : null}
</div>
) : null}
</div>

View File

@ -0,0 +1,22 @@
export type HtmlAttributeQuote = '"' | "'" | null
export function decodeHtmlTextCharacterReferences(value: string): string {
// Why: a <template> 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 <template> 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 = `<span data-orca-value=${delimiter}${value}${delimiter}></span>`
const element = template.content.firstElementChild
return element?.getAttribute('data-orca-value') ?? ''
}

View File

@ -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 }
})
}

View File

@ -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 {

View File

@ -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
}

View File

@ -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' } })
})
})

View File

@ -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<MouseEvent, 'metaKey' | 'ctrlKey' | 'shiftKey'>,
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 }
}
/**

View File

@ -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'
})

View File

@ -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<typeof createRichMarkdownExtensions> | null = null
const roundTripCache = new Map<string, string | null>()
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 {

View File

@ -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 = /^<!--[\s\S]*?-->|^<\/?[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'
})
}

View File

@ -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<HTMLButtonElement>(
`[data-rich-markdown-link-bubble-owner="${linkBubbleOwnerId}"] button:not([disabled])`
)
if (!firstAction) {
return false
}
event.preventDefault()
firstAction.focus()
return true
}

View File

@ -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 }
}

View File

@ -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
}

View File

@ -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'
})

View File

@ -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))

View File

@ -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<string, string>
event: ClipboardEvent
preventDefault: ReturnType<typeof vi.fn>
@ -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')

View File

@ -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'
})

View File

@ -0,0 +1,20 @@
import { mergeAttributes } from '@tiptap/core'
import type { DOMOutputSpec } from '@tiptap/pm/model'
export function renderRichMarkdownDocLinkHtml(
node: { attrs: Record<string, unknown> },
htmlAttributes: Record<string, unknown>
): 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
]
}

View File

@ -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, unknown> }): 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<string, unknown> } }) => {
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<string, unknown> }) => {
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<string, unknown> } }) => {
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<string, unknown> }) => {
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)
}
})
}

View File

@ -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<unknown>
@ -33,6 +37,7 @@ type RichMarkdownEditorClickRoutingOptions = {
event: MouseEvent
filePath: string
isMac: boolean
htmlSuperscriptLinkContext: RichMarkdownHtmlSuperscriptLinkContext
markdownCommentsRef: MutableRefObject<DiffComment[]>
markdownSourceLineOffsetRef: MutableRefObject<number>
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

View File

@ -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<T>(current: T): MutableRefObject<T> {
return { current }
@ -25,7 +27,15 @@ function getSpellcheckAttribute(config: ReturnType<typeof createRichMarkdownEdit
}
function createConfigParams(overrides: Partial<EditorConfigParams> = {}): 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',

View File

@ -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)
)
}
}
}

View File

@ -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' +

View File

@ -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
}

View File

@ -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<RichMarkdownHtmlSuperscriptLinkContextSnapshot, 'version'>) => void
}
export function createRichMarkdownHtmlSuperscriptLinkContext(
initial: Omit<RichMarkdownHtmlSuperscriptLinkContextSnapshot, 'version'>
): 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
}

View File

@ -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
}
}

View File

@ -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 = '<sup><a href="https://example.com/source">[12]</a></sup>'
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 =
"<SUP ><A title='A &amp; B' href='https://example.com/?a=1&amp;b=2'>[&notit;]</A ></SUP >"
const second = '<sup><a href=#section>[13]</a></sup>'
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 = [
'<sup><a href="">[12]<a></sup>',
'<sup><a href="x" onclick="go()">[12]</a></sup>',
'<sup><a href="x"><b>[12]</b></a></sup>',
'<sup class="x"><a href="x">[12]</a></sup>',
'<sup><a href="x">[12]\n</a></sup>',
'<sup\n><a href="x">[12]</a></sup>',
'<sup><a\n href="x">[12]</a></sup>',
'<sup><a href =\n "x">[12]</a></sup>',
'<sup><a href="x">[12]</a\n></sup>'
]
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 = '<sup><a href="https://example.com">[12]</a></sup>'
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', '<b>authored</b>')} [[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', '<b>K1</b>')
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 = '<sup>'.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('<sup><a href="https://example.com/?q=>">[1]</a></sup>')?.href
).toBe('https://example.com/?q=>')
expect(parseHtmlSuperscriptLinkSource('<sup><a href="a" href="b">[1]</a></sup>')).toBeNull()
})
it('renders inert live DOM while exposing link semantics through Orca context', () => {
const host = document.createElement('div')
document.body.appendChild(host)
const source = '<sup><a href="https://example.com">[12]</a></sup>'
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<HTMLElement>(
'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('<sup><a href="https://example.com">[12]</a></sup>')
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<AppState, 'folderWorkspaces' | 'worktreesByRepo'>
expect(resolveRichMarkdownWorktreeRoot(state, folderWorkspaceKey('folder-1'))).toBe(
'/workspace/platform'
)
})
it('self-validates clipboard HTML and rejects a forged semantic mismatch', () => {
const source = '<sup><a title="Source" href="./guide.md">[12]</a></sup>'
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(
`<sup data-rich-markdown-html-superscript-link="1" data-orca-superscript-link-source="${oversizedSource}"><a>x</a></sup>`,
{ 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 <sup><a href="https://example.com">[12]</a></sup> 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(
'<sup><a href="https://example.com">[12]</a></sup>',
TEST_KEY,
citationHost
)
try {
citationOnly.view.dispatch(
citationOnly.state.tr.setSelection(TextSelection.create(citationOnly.state.doc, 1))
)
const clipboard = new Map<string, string>()
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 = '<sup><a href="https://example.com">[1]</a></sup>'
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: '<sup><a href="x">[1]</a></sup>',
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(
'<sup><a href="https://one.example">[1]</a></sup><sup><a href="https://two.example">[2]</a></sup>'
)
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 = '<sup><a href="https://example.com">[1]</a></sup>'
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('foo<kbd>bar')
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()
}
})
})

View File

@ -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<string, string> = {}
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<string, unknown> {
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<string, unknown>
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
)
}

View File

@ -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(),

View File

@ -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<SetStateAction<number>>
setSlashMenu: Dispatch<SetStateAction<SlashMenuState | null>>
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
}

View File

@ -17,6 +17,8 @@ type RichMarkdownLargeTextPasteOptions = {
measureYieldAfterCodeUnits?: number
yieldToEventLoop?: () => Promise<void>
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, {

View File

@ -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<void>((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')
})
})

View File

@ -0,0 +1,18 @@
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
export async function copyRichMarkdownLink(href: string): Promise<void> {
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'
)
)
}
}

View File

@ -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<typeof createRichMarkdownHtmlSuperscriptLinkContext>
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 = '<sup><a href="https://example.com">1</a></sup>'
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()
}
})
})

View File

@ -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
}

View File

@ -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
})
}

View File

@ -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: '![](diagram.png)',
contentType: 'markdown'
})

View File

@ -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()

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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 }
])
})

View File

@ -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 })

View File

@ -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

View File

@ -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<RichMarkdownSearchState
export function findRichMarkdownSearchMatches(
doc: ProseMirrorNode,
query: string,
options?: TextMatchOptions
options?: TextMatchOptions,
stats?: RichMarkdownSearchStats
): RichMarkdownSearchMatch[] {
if (!query) {
return []
@ -41,28 +50,80 @@ export function findRichMarkdownSearchMatches(
}
const matches: RichMarkdownSearchMatch[] = []
doc.descendants((node, pos) => {
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<RichMarkdownSearchState> {
return new Plugin<RichMarkdownSearchState>({
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)

View File

@ -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, 'left' | 'top'>
): 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<ReturnType<typeof getSelectedHtmlSuperscriptLinkStatus>>
): 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<LinkBubbleState, 'left' | 'top'>,
context: RichMarkdownHtmlSuperscriptLinkContext,
label?: string
): LinkBubbleState {
return {
kind,
href,
label,
openEnabled: classifyHtmlSuperscriptLinkAction(href, context.getSnapshot()),
copyEnabled: Boolean(href),
...position
}
}

View File

@ -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.'
)
)
}

View File

@ -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 }
}

View File

@ -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('')
}

View File

@ -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(),

View File

@ -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<RichMarkdownVisibleTextSegment, 'visibleFrom' | 'visibleTo'>) => 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
}

View File

@ -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))

View File

@ -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<typeof registry.use>) => {
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
}

View File

@ -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<HttpLinkSourceOwner>([{ 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<HTMLElement>(null)
const [bubble, setBubble] = useState<LinkBubbleState | null>({
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 (
<button data-enabled={String(bubble?.openEnabled)} onClick={handleLinkOpen}>
{bubble?.href}
</button>
)
}
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
act(() => root.render(<Harness />))
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 })
)
}
)
})

View File

@ -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
}
}

View File

@ -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

View File

@ -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<SetStateAction<DocLinkMenuState | null>>
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<string>
lastCommittedMarkdownRef: MutableRefObject<string>,
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)

View File

@ -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

View File

@ -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<AppState, 'folderWorkspaces' | 'worktreesByRepo'>,
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<HttpLinkSourceOwner>(() => {
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 }
}

View File

@ -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<LinkBubbleState | null>(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}
</div>

View File

@ -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": {

View File

@ -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": {

View File

@ -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": {

View File

@ -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": {

View File

@ -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": {

View File

@ -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')
})
})

View File

@ -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<AppState, 'folderWorkspaces' | 'projectGroups' | 'repos' | 'worktreesByRepo'>,
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)
}

View File

@ -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
}

View File

@ -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<string, WorkspacePortScanResult>
}
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 }

View File

@ -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<string, LocalhostLinkWorktree[]>
allWorktrees?: () => LocalhostLinkWorktree[]
workspacePortScan?: { result: WorkspacePortScanResult } | null
workspacePortScansByKey?: Record<string, WorkspacePortScanResult>
}
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<StoreAccessor>,
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<string | null> {
const state = storeAccessor?.()
if (!state) {
@ -128,11 +158,13 @@ async function openLabeledLocalhostLink(
function localhostLabelRouteForTerminalLink(
rawUrl: string,
state: ReturnType<StoreAccessor>
state: ReturnType<StoreAccessor>,
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
}

View File

@ -49,6 +49,10 @@ function createEditorStore(): StoreApi<AppState> {
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<typeof createEditorSlice>))
})) as unknown as StoreApi<AppState>
@ -62,6 +66,10 @@ function createEditorTabsStore(): StoreApi<AppState> {
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<typeof createTabsSlice>)),
...createEditorSlice(...(args as Parameters<typeof createEditorSlice>))
@ -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', {

View File

@ -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<void>
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<AppState, [], [], EditorSlice> = (set, get) => ({
editorDrafts: {},
setEditorDraft: (fileId, content) =>
@ -4170,16 +4165,48 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (s
return
}
if (target.kind === 'external') {
openHttpLink(target.url, { worktreeId: ctx.worktreeId })
openHttpLink(target.url, { worktreeId: ctx.worktreeId, sourceOwner })
return
}
if (target.kind === 'file') {