diff --git a/package.json b/package.json index 5cb558143..473bf8ab9 100644 --- a/package.json +++ b/package.json @@ -177,6 +177,7 @@ "lint-staged": "^16.4.0", "lowlight": "^3.3.0", "lucide-react": "^0.577.0", + "marked": "^17.0.1", "mermaid": "^11.15.0", "monaco-editor": "^0.55.1", "oxfmt": "^0.52.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b48ad9b4f..ebb3aa401 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -263,6 +263,9 @@ importers: lucide-react: specifier: ^0.577.0 version: 0.577.0(react@19.2.5) + marked: + specifier: ^17.0.1 + version: 17.0.6 mermaid: specifier: ^11.15.0 version: 11.15.0 @@ -399,10 +402,6 @@ packages: '@antfu/install-pkg@1.1.0': resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} - '@babel/code-frame@7.29.0': - resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} - engines: {node: '>=6.9.0'} - '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -415,10 +414,6 @@ packages: resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} engines: {node: '>=6.9.0'} - '@babel/generator@7.29.1': - resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} - engines: {node: '>=6.9.0'} - '@babel/generator@7.29.7': resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} engines: {node: '>=6.9.0'} @@ -437,10 +432,6 @@ packages: peerDependencies: '@babel/core': ^7.0.0 - '@babel/helper-globals@7.28.0': - resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} - engines: {node: '>=6.9.0'} - '@babel/helper-globals@7.29.7': resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} engines: {node: '>=6.9.0'} @@ -449,20 +440,10 @@ packages: resolution: {integrity: sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==} engines: {node: '>=6.9.0'} - '@babel/helper-module-imports@7.28.6': - resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} - engines: {node: '>=6.9.0'} - '@babel/helper-module-imports@7.29.7': resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} engines: {node: '>=6.9.0'} - '@babel/helper-module-transforms@7.28.6': - resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - '@babel/helper-module-transforms@7.29.7': resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} engines: {node: '>=6.9.0'} @@ -487,26 +468,14 @@ packages: resolution: {integrity: sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==} engines: {node: '>=6.9.0'} - '@babel/helper-string-parser@7.27.1': - resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} - engines: {node: '>=6.9.0'} - '@babel/helper-string-parser@7.29.7': resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@7.28.5': - resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} - engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@7.29.7': resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-option@7.27.1': - resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} - engines: {node: '>=6.9.0'} - '@babel/helper-validator-option@7.29.7': resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} engines: {node: '>=6.9.0'} @@ -515,11 +484,6 @@ packages: resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} engines: {node: '>=6.9.0'} - '@babel/parser@7.29.3': - resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==} - engines: {node: '>=6.0.0'} - hasBin: true - '@babel/parser@7.29.7': resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} engines: {node: '>=6.0.0'} @@ -577,26 +541,14 @@ packages: resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} engines: {node: '>=6.9.0'} - '@babel/template@7.28.6': - resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} - engines: {node: '>=6.9.0'} - '@babel/template@7.29.7': resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} - '@babel/traverse@7.29.0': - resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} - engines: {node: '>=6.9.0'} - '@babel/traverse@7.29.7': resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} engines: {node: '>=6.9.0'} - '@babel/types@7.29.0': - resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} - engines: {node: '>=6.9.0'} - '@babel/types@7.29.7': resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} @@ -3457,6 +3409,7 @@ packages: boolean@3.2.0: resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. brace-expansion@1.1.14: resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} @@ -4354,14 +4307,6 @@ packages: resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} engines: {node: '>=12'} - fs-extra@11.3.4: - resolution: {integrity: sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==} - engines: {node: '>=14.14'} - - fs-extra@11.3.5: - resolution: {integrity: sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==} - engines: {node: '>=14.14'} - fs-extra@11.3.6: resolution: {integrity: sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==} engines: {node: '>=14.14'} @@ -4446,6 +4391,7 @@ packages: glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me global-agent@3.0.0: resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==} @@ -4642,6 +4588,7 @@ packages: inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -4967,6 +4914,7 @@ packages: lodash.isequal@4.5.0: resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} + deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. lodash@4.18.1: resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} @@ -5330,6 +5278,7 @@ packages: node-domexception@1.0.0: resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead node-fetch@3.3.2: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} @@ -6740,12 +6689,6 @@ snapshots: package-manager-detector: 1.6.0 tinyexec: 1.1.2 - '@babel/code-frame@7.29.0': - dependencies: - '@babel/helper-validator-identifier': 7.28.5 - js-tokens: 4.0.0 - picocolors: 1.1.1 - '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -6774,14 +6717,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/generator@7.29.1': - dependencies: - '@babel/parser': 7.29.3 - '@babel/types': 7.29.0 - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - jsesc: 3.1.0 - '@babel/generator@7.29.7': dependencies: '@babel/parser': 7.29.7 @@ -6792,7 +6727,7 @@ snapshots: '@babel/helper-annotate-as-pure@7.27.3': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@babel/helper-compilation-targets@7.29.7': dependencies: @@ -6810,26 +6745,17 @@ snapshots: '@babel/helper-optimise-call-expression': 7.27.1 '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.7) '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.7 semver: 6.3.1 transitivePeerDependencies: - supports-color - '@babel/helper-globals@7.28.0': {} - '@babel/helper-globals@7.29.7': {} '@babel/helper-member-expression-to-functions@7.28.5': dependencies: - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 - transitivePeerDependencies: - - supports-color - - '@babel/helper-module-imports@7.28.6': - dependencies: - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -6840,15 +6766,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.7)': - dependencies: - '@babel/core': 7.29.7 - '@babel/helper-module-imports': 7.28.6 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.29.0 - transitivePeerDependencies: - - supports-color - '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 @@ -6860,7 +6777,7 @@ snapshots: '@babel/helper-optimise-call-expression@7.27.1': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@babel/helper-plugin-utils@7.28.6': {} @@ -6869,27 +6786,21 @@ snapshots: '@babel/core': 7.29.7 '@babel/helper-member-expression-to-functions': 7.28.5 '@babel/helper-optimise-call-expression': 7.27.1 - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color '@babel/helper-skip-transparent-expression-wrappers@7.27.1': dependencies: - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/helper-string-parser@7.27.1': {} - '@babel/helper-string-parser@7.29.7': {} - '@babel/helper-validator-identifier@7.28.5': {} - '@babel/helper-validator-identifier@7.29.7': {} - '@babel/helper-validator-option@7.27.1': {} - '@babel/helper-validator-option@7.29.7': {} '@babel/helpers@7.29.7': @@ -6897,10 +6808,6 @@ snapshots: '@babel/template': 7.29.7 '@babel/types': 7.29.7 - '@babel/parser@7.29.3': - dependencies: - '@babel/types': 7.29.0 - '@babel/parser@7.29.7': dependencies: '@babel/types': 7.29.7 @@ -6923,7 +6830,7 @@ snapshots: '@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.7) + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color @@ -6953,7 +6860,7 @@ snapshots: dependencies: '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 - '@babel/helper-validator-option': 7.27.1 + '@babel/helper-validator-option': 7.29.7 '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.7) '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.7) '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.7) @@ -6962,30 +6869,12 @@ snapshots: '@babel/runtime@7.29.7': {} - '@babel/template@7.28.6': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/parser': 7.29.3 - '@babel/types': 7.29.0 - '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 '@babel/parser': 7.29.7 '@babel/types': 7.29.7 - '@babel/traverse@7.29.0': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.29.3 - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - '@babel/traverse@7.29.7': dependencies: '@babel/code-frame': 7.29.7 @@ -6998,11 +6887,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/types@7.29.0': - dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/types@7.29.7': dependencies: '@babel/helper-string-parser': 7.29.7 @@ -7127,7 +7011,7 @@ snapshots: fs-extra: 10.1.0 isbinaryfile: 4.0.10 minimist: 1.2.8 - plist: 3.1.0 + plist: 3.1.1 transitivePeerDependencies: - supports-color @@ -7148,9 +7032,9 @@ snapshots: '@malept/cross-spawn-promise': 2.0.0 debug: 4.4.3 dir-compare: 4.2.0 - fs-extra: 11.3.4 + fs-extra: 11.3.6 minimatch: 9.0.9 - plist: 3.1.0 + plist: 3.1.1 transitivePeerDependencies: - supports-color @@ -8992,24 +8876,24 @@ snapshots: '@types/babel__core@7.20.5': dependencies: - '@babel/parser': 7.29.3 - '@babel/types': 7.29.0 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 '@types/babel__generator': 7.27.0 '@types/babel__template': 7.4.4 '@types/babel__traverse': 7.28.0 '@types/babel__generator@7.27.0': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@types/babel__template@7.4.4': dependencies: - '@babel/parser': 7.29.3 - '@babel/types': 7.29.0 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 '@types/babel__traverse@7.28.0': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@types/cacheable-request@6.0.3': dependencies: @@ -9369,8 +9253,7 @@ snapshots: '@xmldom/xmldom@0.8.13': {} - '@xmldom/xmldom@0.9.10': - optional: true + '@xmldom/xmldom@0.9.10': {} '@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287)': dependencies: @@ -10588,24 +10471,11 @@ snapshots: jsonfile: 6.2.1 universalify: 2.0.1 - fs-extra@11.3.4: - dependencies: - graceful-fs: 4.2.11 - jsonfile: 6.2.1 - universalify: 2.0.1 - - fs-extra@11.3.5: - dependencies: - graceful-fs: 4.2.11 - jsonfile: 6.2.1 - universalify: 2.0.1 - fs-extra@11.3.6: dependencies: graceful-fs: 4.2.11 jsonfile: 6.2.1 universalify: 2.0.1 - optional: true fs-extra@7.0.1: dependencies: @@ -12019,7 +11889,7 @@ snapshots: parse-json@5.2.0: dependencies: - '@babel/code-frame': 7.29.0 + '@babel/code-frame': 7.29.7 error-ex: 1.3.4 json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 @@ -12089,7 +11959,6 @@ snapshots: '@xmldom/xmldom': 0.9.10 base64-js: 1.5.1 xmlbuilder: 15.1.1 - optional: true pngjs@5.0.0: {} @@ -12689,7 +12558,7 @@ snapshots: shadcn@4.7.0(@types/node@25.6.0)(typescript@7.0.2): dependencies: '@babel/core': 7.29.7 - '@babel/parser': 7.29.3 + '@babel/parser': 7.29.7 '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.7) '@babel/preset-typescript': 7.28.5(@babel/core@7.29.7) '@dotenvx/dotenvx': 1.66.0 @@ -12703,7 +12572,7 @@ snapshots: diff: 8.0.4 execa: 9.6.1 fast-glob: 3.3.3 - fs-extra: 11.3.5 + fs-extra: 11.3.6 fuzzysort: 3.1.0 https-proxy-agent: 7.0.6 kleur: 4.1.5 @@ -12788,7 +12657,7 @@ snapshots: simple-update-notifier@2.0.0: dependencies: - semver: 7.7.4 + semver: 7.8.1 sisteransi@1.0.5: {} diff --git a/src/renderer/src/assets/rich-markdown-editor.css b/src/renderer/src/assets/rich-markdown-editor.css index ccf79ad36..38c625f0d 100644 --- a/src/renderer/src/assets/rich-markdown-editor.css +++ b/src/renderer/src/assets/rich-markdown-editor.css @@ -1026,6 +1026,27 @@ color: #58a6ff; } +.rich-markdown-editor sup[data-rich-markdown-html-superscript-link] { + line-height: 0; +} + +.rich-markdown-editor .rich-markdown-html-superscript-link { + color: #0969da; + text-decoration: underline; + text-decoration-color: color-mix(in srgb, currentColor 40%, transparent); + text-underline-offset: 2px; +} + +.dark .rich-markdown-editor .rich-markdown-html-superscript-link { + color: #58a6ff; +} + +.rich-markdown-mod-held + .rich-markdown-editor + .rich-markdown-html-superscript-link[data-actionable] { + cursor: pointer; +} + .rich-markdown-editor img { max-width: 100%; border-radius: 8px; diff --git a/src/renderer/src/components/LinearIssueMarkdownDescriptionEditor.tsx b/src/renderer/src/components/LinearIssueMarkdownDescriptionEditor.tsx index 5e45c6e71..f8f11c956 100644 --- a/src/renderer/src/components/LinearIssueMarkdownDescriptionEditor.tsx +++ b/src/renderer/src/components/LinearIssueMarkdownDescriptionEditor.tsx @@ -7,6 +7,10 @@ import { LoaderCircle } from 'lucide-react' import { createRichMarkdownExtensions } from '@/components/editor/rich-markdown-extensions' import { encodeRawMarkdownHtmlForRichEditor } from '@/components/editor/raw-markdown-html' +import { + createRichMarkdownEditorCodec, + type RichMarkdownEditorCodec +} from '@/components/editor/rich-markdown-source-transport' import { LinearIssueMarkdownToolbar } from '@/components/LinearIssueMarkdownToolbar' import { isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut' import { cn } from '@/lib/utils' @@ -26,8 +30,8 @@ type LinearIssueMarkdownDescriptionEditorProps = { submitShortcutLabel: string } -function createLinearIssueMarkdownExtensions() { - const extensions = createRichMarkdownExtensions() +function createLinearIssueMarkdownExtensions(codec: RichMarkdownEditorCodec) { + const extensions = createRichMarkdownExtensions({ codec }) return [ ...extensions, Placeholder.configure({ @@ -54,18 +58,24 @@ export function LinearIssueMarkdownDescriptionEditor({ const richMarkdownSpellcheckEnabled = useAppStore( (s) => s.settings?.richMarkdownSpellcheckEnabled ?? true ) + // Why: changing language recreates Tiptap and re-registers tokenizers, so it + // must also receive a fresh private Marked registry instead of growing one. + const codec = useMemo(() => { + void language + return createRichMarkdownEditorCodec() + }, [language]) const linearIssueMarkdownExtensions = useMemo(() => { // Why: Tiptap freezes extension options when the editor is created; the // language value is the recreation key for translated extension options. void language - return createLinearIssueMarkdownExtensions() - }, [language]) + return createLinearIssueMarkdownExtensions(codec) + }, [codec, language]) const editor = useEditor( { immediatelyRender: false, extensions: linearIssueMarkdownExtensions, - content: encodeRawMarkdownHtmlForRichEditor(value), + content: encodeRawMarkdownHtmlForRichEditor(value, codec), contentType: 'markdown', editable: !disabled, editorProps: { @@ -99,7 +109,7 @@ export function LinearIssueMarkdownDescriptionEditor({ onChange(nextValue) } }, - [language] + [codec, language] ) useRichMarkdownSpellcheckAttribute(editor, richMarkdownSpellcheckEnabled) @@ -124,12 +134,12 @@ export function LinearIssueMarkdownDescriptionEditor({ // Why: Linear remains the source of truth when the selected issue changes // or an optimistic save is reverted; keep the rich view aligned with it. - editor.commands.setContent(encodeRawMarkdownHtmlForRichEditor(value), { + editor.commands.setContent(encodeRawMarkdownHtmlForRichEditor(value, codec), { contentType: 'markdown', emitUpdate: false }) lastEditorMarkdownRef.current = value - }, [editor, value]) + }, [codec, editor, value]) return (
true) +const connectionOwner = vi.hoisted(() => ({ value: null as string | null | undefined })) +const targetConnectionOwners = vi.hoisted(() => new Map()) +const worktreeLookup = vi.hoisted(() => ({ + value: [] as { id: string; path: string; diffComments: never[] }[] +})) +const statRuntimePathMock = vi.hoisted(() => vi.fn(async () => ({ isDirectory: false }))) // Minimal store: MarkdownPreview reads settings/worktreesByRepo plus a handful // of action functions. None of the actions fire on the http path under test. @@ -31,6 +39,9 @@ const storeState = { clearDeliveredDiffComments: vi.fn(), keybindings: {}, worktreesByRepo: {}, + repos: [], + folderWorkspaces: [], + projectGroups: [], openFiles: [], activeFileIdByWorktree: {}, settings: { openLinksInApp: true }, @@ -44,14 +55,22 @@ vi.mock('@/store', () => { ) return { useAppStore } }) -vi.mock('@/store/slices/worktree-helpers', () => ({ findWorktreeById: () => null })) +vi.mock('@/store/slices/worktree-helpers', () => ({ + findWorktreeById: (_worktrees: unknown, id: string) => + worktreeLookup.value.find((worktree) => worktree.id === id) ?? null +})) vi.mock('@/runtime/runtime-rpc-client', () => ({ settingsForRuntimeOwner: (settings: unknown) => settings })) vi.mock('@/runtime/runtime-file-client', () => ({ - statRuntimePath: vi.fn(async () => ({ isDirectory: false })) + statRuntimePath: statRuntimePathMock +})) +vi.mock('@/lib/connection-context', () => ({ + getConnectionIdForFile: (worktreeId: string) => targetConnectionOwners.get(worktreeId) +})) +vi.mock('@/lib/connection-owner-resolution', () => ({ + createConnectionIdForFileSelector: () => () => connectionOwner.value })) -vi.mock('@/lib/connection-context', () => ({ getConnectionId: () => null })) vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) vi.mock('./useLocalImageSrc', () => ({ useLocalImageSrc: (src?: string) => src })) vi.mock('./MermaidBlock', () => ({ default: () => null })) @@ -77,8 +96,8 @@ describe('MarkdownPreview http link routing (Cmd vs Cmd+Shift click)', () => { ;(window as unknown as { api: unknown }).api = { shell: { openUrl: openUrlMock, - openFileUri: vi.fn(), - pathExists: vi.fn(async () => true) + openFileUri: openFileUriMock, + pathExists: pathExistsMock }, ui: { writeClipboardText: vi.fn(async () => true) } } @@ -91,6 +110,14 @@ describe('MarkdownPreview http link routing (Cmd vs Cmd+Shift click)', () => { createBrowserTabMock.mockClear() setActiveWorktreeMock.mockClear() openUrlMock.mockClear() + openFileUriMock.mockClear() + pathExistsMock.mockClear() + connectionOwner.value = null + targetConnectionOwners.clear() + worktreeLookup.value = [] + storeState.worktreesByRepo = {} + storeState.openMarkdownPreview.mockClear() + statRuntimePathMock.mockClear() }) afterEach(() => { @@ -100,21 +127,28 @@ describe('MarkdownPreview http link routing (Cmd vs Cmd+Shift click)', () => { container.remove() }) - function render(): HTMLAnchorElement { + function render( + content = '[example](https://example.com)', + expectedHref = 'https://example.com', + sourceWorktreeId = 'wt-1', + filePath = '/repo/docs/README.md' + ): HTMLAnchorElement { container = document.createElement('div') document.body.appendChild(container) root = createRoot(container) act(() => { root.render( ) }) - const anchor = container.querySelector('a[href="https://example.com"]') + const anchor = Array.from(container.querySelectorAll('a')).find( + (candidate) => candidate.getAttribute('href') === expectedHref + ) if (!anchor) { throw new Error('expected a rendered http anchor') } @@ -144,4 +178,59 @@ describe('MarkdownPreview http link routing (Cmd vs Cmd+Shift click)', () => { expect(openUrlMock).toHaveBeenCalledWith('https://example.com/') expect(createBrowserTabMock).not.toHaveBeenCalled() }) + + it('keeps system-browser HTTP and file links inert while ownership is unknown', () => { + connectionOwner.value = undefined + const httpAnchor = render() + click(httpAnchor, { metaKey: true, shiftKey: true }) + expect(openUrlMock).not.toHaveBeenCalled() + expect(createBrowserTabMock).not.toHaveBeenCalled() + + act(() => { + root.unmount() + }) + container.remove() + const fileAnchor = render('[file](file:///tmp/example.md)', 'file:///tmp/example.md') + click(fileAnchor, { metaKey: true, shiftKey: true }) + expect(pathExistsMock).not.toHaveBeenCalled() + expect(openFileUriMock).not.toHaveBeenCalled() + }) + + it('keeps same-path file links on the explicit SSH source worktree', async () => { + const localWorktree = { id: 'local-wt', path: '/srv/repo', diffComments: [] as never[] } + const sshWorktree = { id: 'ssh-wt', path: '/srv/repo', diffComments: [] as never[] } + worktreeLookup.value = [localWorktree, sshWorktree] + storeState.worktreesByRepo = { repo: [localWorktree, sshWorktree] } + connectionOwner.value = 'ssh-1' + targetConnectionOwners.set('local-wt', null) + targetConnectionOwners.set('ssh-wt', 'ssh-1') + const anchor = render('[child](child.md)', 'child.md', 'ssh-wt', '/srv/repo/README.md') + + await act(async () => { + anchor.dispatchEvent(new window.MouseEvent('click', { bubbles: true, cancelable: true })) + }) + + expect(statRuntimePathMock).toHaveBeenCalledWith( + expect.objectContaining({ worktreeId: 'ssh-wt', connectionId: 'ssh-1' }), + '/srv/repo/child.md' + ) + expect(storeState.openMarkdownPreview).toHaveBeenCalledWith( + expect.objectContaining({ worktreeId: 'ssh-wt', filePath: '/srv/repo/child.md' }), + { anchor: null } + ) + }) + + it('renders a raw HTML superscript citation and routes it like a Markdown link', () => { + const source = '[12]' + const anchor = render(source) + + expect(anchor.parentElement?.tagName).toBe('SUP') + expect(anchor.textContent).toBe('[12]') + expect(container.textContent).not.toContain('') + click(anchor, { metaKey: true }) + expect(createBrowserTabMock).toHaveBeenCalledWith('wt-1', 'https://example.com/', { + activate: true + }) + expect(openUrlMock).not.toHaveBeenCalled() + }) }) diff --git a/src/renderer/src/components/editor/MarkdownPreview.toc-visibility-gate.test.tsx b/src/renderer/src/components/editor/MarkdownPreview.toc-visibility-gate.test.tsx index 46301826f..23f66fa9e 100644 --- a/src/renderer/src/components/editor/MarkdownPreview.toc-visibility-gate.test.tsx +++ b/src/renderer/src/components/editor/MarkdownPreview.toc-visibility-gate.test.tsx @@ -28,6 +28,9 @@ const storeState = { clearDeliveredDiffComments: vi.fn(), keybindings: {}, worktreesByRepo: {}, + repos: [], + folderWorkspaces: [], + projectGroups: [], openFiles: [], activeFileIdByWorktree: {}, settings: { openLinksInApp: true }, @@ -48,7 +51,10 @@ vi.mock('@/runtime/runtime-rpc-client', () => ({ vi.mock('@/runtime/runtime-file-client', () => ({ statRuntimePath: vi.fn(async () => ({ isDirectory: false })) })) -vi.mock('@/lib/connection-context', () => ({ getConnectionId: () => null })) +vi.mock('@/lib/connection-context', () => ({ getConnectionIdForFile: () => null })) +vi.mock('@/lib/connection-owner-resolution', () => ({ + createConnectionIdForFileSelector: () => () => null +})) vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) vi.mock('./useLocalImageSrc', () => ({ useLocalImageSrc: (src?: string) => src })) vi.mock('./MermaidBlock', () => ({ default: () => null })) diff --git a/src/renderer/src/components/editor/MarkdownPreview.tsx b/src/renderer/src/components/editor/MarkdownPreview.tsx index b77aaa33f..38d146c13 100644 --- a/src/renderer/src/components/editor/MarkdownPreview.tsx +++ b/src/renderer/src/components/editor/MarkdownPreview.tsx @@ -38,7 +38,8 @@ import { Input } from '@/components/ui/input' import { useAppStore } from '@/store' import { toast } from 'sonner' import { computeEditorFontSize } from '@/lib/editor-font-zoom' -import { getConnectionId } from '@/lib/connection-context' +import { getConnectionIdForFile } from '@/lib/connection-context' +import { createConnectionIdForFileSelector } from '@/lib/connection-owner-resolution' import { scrollTopCache, setWithLRU } from '@/lib/scroll-cache' import { detectLanguage } from '@/lib/language-detect' import type { DiffComment, MarkdownDocument, Worktree } from '../../../../shared/types' @@ -68,7 +69,7 @@ import { setActiveMarkdownPreviewSearchMatch } from './markdown-preview-search' import { usePreserveSectionDuringExternalEdit } from './usePreserveSectionDuringExternalEdit' -import { openHttpLink } from '@/lib/http-link-routing' +import { openHttpLink, type HttpLinkSourceOwner } from '@/lib/http-link-routing' import { getShortcutPlatform } from '@/lib/shortcut-platform' import { isLocalPathOpenBlocked, showLocalPathOpenBlockedToast } from '@/lib/local-path-open-guard' import { markdownPreviewUrlTransform } from './markdown-preview-url-transform' @@ -390,14 +391,18 @@ export function deriveMarkdownPreviewSourceRoot( function findWorktreeForMarkdownPreviewPath( worktreesByRepo: Record, - absolutePath: string + absolutePath: string, + acceptsWorktree: (worktree: Worktree) => boolean = () => true ): Worktree | null { let bestMatch: Worktree | null = null let bestMatchLength = -1 for (const worktrees of Object.values(worktreesByRepo)) { for (const worktree of worktrees) { - if (relativePathInsideRoot(worktree.path, absolutePath) !== null) { + if ( + acceptsWorktree(worktree) && + relativePathInsideRoot(worktree.path, absolutePath) !== null + ) { const normalizedWorktreePathLength = normalizeMarkdownPreviewAbsolutePath( worktree.path ).length @@ -412,6 +417,27 @@ function findWorktreeForMarkdownPreviewPath( return bestMatch } +function findMarkdownPreviewTargetWorktree( + worktreesByRepo: Record, + absolutePath: string, + sourceWorktree: Worktree | null, + sourceOwner: HttpLinkSourceOwner +): Worktree | null { + if (sourceWorktree && relativePathInsideRoot(sourceWorktree.path, absolutePath) !== null) { + return sourceWorktree + } + return findWorktreeForMarkdownPreviewPath(worktreesByRepo, absolutePath, (worktree) => { + const connectionId = getConnectionIdForFile(worktree.id, absolutePath) + if (sourceOwner.kind === 'local') { + return connectionId === null + } + if (sourceOwner.kind === 'ssh') { + return connectionId === sourceOwner.connectionId + } + return false + }) +} + export function resolveMarkdownPreviewSourceWorktree( worktreesByRepo: Record, sourceWorktreeId: string | null | undefined, @@ -498,9 +524,26 @@ export default function MarkdownPreview({ ) const allDiffComments = sourceWorktree?.diffComments const sourceRoutingWorktreeId = sourceWorktree?.id ?? resolvedSourceWorktreeId - const sourceConnectionId = sourceRoutingWorktreeId - ? (getConnectionId(sourceRoutingWorktreeId) ?? null) - : null + const runtimeOwnerId = resolvedSourceRuntimeEnvironmentId?.trim() + const sourceConnectionIdSelector = useMemo( + () => + createConnectionIdForFileSelector(sourceRoutingWorktreeId, filePath, { + skip: Boolean(runtimeOwnerId) + }), + [filePath, runtimeOwnerId, sourceRoutingWorktreeId] + ) + const sourceConnectionId = useAppStore(sourceConnectionIdSelector) + const sourceOwner = useMemo( + () => + runtimeOwnerId + ? { kind: 'runtime', runtimeEnvironmentId: runtimeOwnerId } + : sourceConnectionId === undefined + ? { kind: 'unknown' } + : sourceConnectionId === null + ? { kind: 'local' } + : { kind: 'ssh', connectionId: sourceConnectionId }, + [runtimeOwnerId, sourceConnectionId] + ) const worktreeRoot = sourceWorktree?.path ?? (sourceRoutingWorktreeId @@ -1254,6 +1297,9 @@ export default function MarkdownPreview({ // dangling in-worktree .md, pre-check existence so the user sees a // toast instead of the silent no-op from shell.openFileUri. if (isMarkdownPreviewSystemBrowserModifier(event, isMac)) { + if (sourceOwner.kind === 'unknown') { + return + } const osTarget = getMarkdownPreviewLinkTarget(href, filePath) if (!osTarget) { return @@ -1267,7 +1313,12 @@ export default function MarkdownPreview({ if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { openHttpLink( parsed.toString(), - resolveMarkdownPreviewHttpOpenOptions(event, isMac, sourceRoutingWorktreeId) + resolveMarkdownPreviewHttpOpenOptions( + event, + isMac, + sourceRoutingWorktreeId, + sourceOwner + ) ) return } @@ -1326,7 +1377,12 @@ export default function MarkdownPreview({ // handled above; this path only sees non-escape-hatch clicks.) openHttpLink( target.toString(), - resolveMarkdownPreviewHttpOpenOptions(event, isMac, sourceRoutingWorktreeId) + resolveMarkdownPreviewHttpOpenOptions( + event, + isMac, + sourceRoutingWorktreeId, + sourceOwner + ) ) return } @@ -1347,12 +1403,25 @@ export default function MarkdownPreview({ ? { line: classifiedFileTarget.line, column: classifiedFileTarget.column } : parseLineTarget(target.hash) + // Why: same-file anchors need no ownership/filesystem resolution (e.g. + // `./README.md#heading` when this file is README.md). Run before the + // unknown-ownership guard so ambiguous folder-workspace ownership still + // scrolls within the open document. if (absolutePath === filePath && target.hash && !lineTarget) { void scrollToAnchor(target.hash.slice(1)) return } - const targetWorktree = findWorktreeForMarkdownPreviewPath(worktreesByRepo, absolutePath) + if (sourceOwner.kind === 'unknown') { + return + } + + const targetWorktree = findMarkdownPreviewTargetWorktree( + worktreesByRepo, + absolutePath, + sourceWorktree, + sourceOwner + ) if (!targetWorktree) { if (sourceRoutingWorktreeId && worktreeRoot) { // Why: floating markdown files are owned by a synthetic workspace, @@ -1362,7 +1431,8 @@ export default function MarkdownPreview({ sourceFilePath: filePath, worktreeId: sourceRoutingWorktreeId, worktreeRoot, - runtimeEnvironmentId: resolvedSourceRuntimeEnvironmentId + runtimeEnvironmentId: resolvedSourceRuntimeEnvironmentId, + sourceOwner }) return } @@ -1384,8 +1454,15 @@ export default function MarkdownPreview({ return } - const relativePath = absolutePath.slice(targetWorktree.path.length + 1) + const relativePath = relativePathInsideRoot(targetWorktree.path, absolutePath) + if (relativePath === null) { + return + } const language = detectLanguage(absolutePath) + const targetConnectionId = getConnectionIdForFile(targetWorktree.id, absolutePath) + if (targetConnectionId === undefined) { + return + } try { const stats = await statRuntimePath( { @@ -1395,7 +1472,7 @@ export default function MarkdownPreview({ ), worktreeId: targetWorktree.id, worktreePath: targetWorktree.path, - connectionId: getConnectionId(targetWorktree.id) ?? undefined + connectionId: targetConnectionId ?? undefined }, absolutePath ) @@ -1512,7 +1589,8 @@ export default function MarkdownPreview({ sourceFilePath: filePath, worktreeId: sourceRoutingWorktreeId, worktreeRoot, - runtimeEnvironmentId: resolvedSourceRuntimeEnvironmentId + runtimeEnvironmentId: resolvedSourceRuntimeEnvironmentId, + sourceOwner }) } @@ -1673,6 +1751,8 @@ export default function MarkdownPreview({ setMarkdownViewMode, setPendingEditorReveal, sourceConnectionId, + sourceOwner, + sourceWorktree, resolvedSourceRuntimeEnvironmentId, sourceRoutingWorktreeId, worktreeRoot, diff --git a/src/renderer/src/components/editor/RichMarkdownEditor.tsx b/src/renderer/src/components/editor/RichMarkdownEditor.tsx index c59540529..1c0cb2734 100644 --- a/src/renderer/src/components/editor/RichMarkdownEditor.tsx +++ b/src/renderer/src/components/editor/RichMarkdownEditor.tsx @@ -1,9 +1,8 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import type { Editor } from '@tiptap/react' +import { useEditorState, type Editor } from '@tiptap/react' import type { DiffComment, MarkdownDocument } from '../../../../shared/types' import { useAppStore } from '@/store' import { selectWorktreeDiffComments } from '@/store/worktree-diff-comments-selector' -import { getIndexedWorktreeById } from '@/store/worktree-repo-index' import { useLocalImagePick } from './useLocalImagePick' import { useRichMarkdownSearch } from './useRichMarkdownSearch' import type { LinkBubbleState } from './RichMarkdownLinkBubble' @@ -25,6 +24,11 @@ import { runRichMarkdownContextCommand } from './rich-markdown-context-command-routing' import { useRichMarkdownSpellcheckAttribute } from './rich-markdown-spellcheck' +import { useRichMarkdownSuperscriptLinkSetup } from './useRichMarkdownSuperscriptLinkSetup' +import { + formatSelectedHtmlSuperscriptLinkStatus, + getSelectedHtmlSuperscriptLinkStatus +} from './rich-markdown-selected-link-actions' type RichMarkdownEditorProps = { fileId: string @@ -86,9 +90,11 @@ export default function RichMarkdownEditor({ const allDiffComments = useAppStore((s): DiffComment[] | undefined => selectWorktreeDiffComments(s, worktreeId) ) - const worktreeRoot = useAppStore( - (s) => getIndexedWorktreeById(s.worktreesByRepo, worktreeId)?.path ?? null - ) + const { codec, htmlSuperscriptLinkContext, worktreeRoot } = useRichMarkdownSuperscriptLinkSetup({ + filePath, + runtimeEnvironmentId, + worktreeId + }) const scrollContainerRef = useRef(null) const menu = useRichMarkdownMenuController({ markdownDocuments }) const isMac = navigator.userAgent.includes('Mac') @@ -196,6 +202,8 @@ export default function RichMarkdownEditor({ ) const editor = useRichMarkdownEditorInstance({ + codec, + htmlSuperscriptLinkContext, content, filePath, worktreeId, @@ -240,6 +248,13 @@ export default function RichMarkdownEditor({ setSlashMenu: menu.setSlashMenu, setDocLinkMenu: menu.setDocLinkMenu }) + // Why: useEditor defaults shouldRerenderOnTransaction to false, so selection-only + // citation NodeSelections would leave aria status stale without useEditorState. + const selectedCitationStatus = useEditorState({ + editor, + selector: (snapshot) => + getSelectedHtmlSuperscriptLinkStatus(snapshot.editor, htmlSuperscriptLinkContext) + }) useRichMarkdownSpellcheckAttribute(editor, richMarkdownSpellcheckEnabled) // Why: use useLayoutEffect (synchronous cleanup) so the pending serialization @@ -265,6 +280,7 @@ export default function RichMarkdownEditor({ }) useRichMarkdownProgrammaticSync({ + codec, content, docLinkMenuSetter: menu.setDocLinkMenu, editor, @@ -289,12 +305,14 @@ export default function RichMarkdownEditor({ handleLinkRemove, handleLinkEditCancel, handleLinkOpen, + handleLinkCopy, toggleLinkFromToolbar } = useLinkBubble(editor, rootRef, linkBubble, setLinkBubble, setIsEditingLink, { sourceFilePath: filePath, worktreeId, worktreeRoot, - runtimeEnvironmentId + runtimeEnvironmentId, + htmlSuperscriptLinkContext }) useEffect(() => { @@ -370,6 +388,12 @@ export default function RichMarkdownEditor({ showTableOfContents={showTableOfContents} searchState={searchState} searchActions={searchActions} + citationStatus={ + selectedCitationStatus + ? formatSelectedHtmlSuperscriptLinkStatus(selectedCitationStatus) + : '' + } + linkBubbleOwnerId={codec.transport.key} linkBubbleActions={{ dismissLinkBubble: () => { setLinkBubble(null) @@ -379,6 +403,7 @@ export default function RichMarkdownEditor({ handleLinkRemove, handleLinkEditCancel, handleLinkOpen, + handleLinkCopy, setIsEditingLink }} onToggleLink={toggleLinkFromToolbar} diff --git a/src/renderer/src/components/editor/RichMarkdownEditorSurface.tsx b/src/renderer/src/components/editor/RichMarkdownEditorSurface.tsx index f5283616a..fd619247c 100644 --- a/src/renderer/src/components/editor/RichMarkdownEditorSurface.tsx +++ b/src/renderer/src/components/editor/RichMarkdownEditorSurface.tsx @@ -76,6 +76,7 @@ type RichMarkdownEditorSurfaceProps = { matchCase: boolean matchCount: number replaceQuery: string + replaceDisabled: boolean searchQuery: string searchInputRef: React.RefObject wholeWord: boolean @@ -91,12 +92,15 @@ type RichMarkdownEditorSurfaceProps = { toggleReplaceMode: () => void toggleWholeWord: () => void } + citationStatus: string + linkBubbleOwnerId: string linkBubbleActions: { dismissLinkBubble: () => void handleLinkSave: (href: string) => void handleLinkRemove: () => void handleLinkEditCancel: () => void handleLinkOpen: () => void + handleLinkCopy: () => void setIsEditingLink: (editing: boolean) => void } onToggleLink: () => void @@ -155,6 +159,8 @@ export function RichMarkdownEditorSurface({ showTableOfContents, searchState, searchActions, + citationStatus, + linkBubbleOwnerId, linkBubbleActions, onToggleLink, onImagePick, @@ -237,6 +243,7 @@ export function RichMarkdownEditorSurface({ matchCount={searchState.matchCount} query={searchState.searchQuery} replaceQuery={searchState.replaceQuery} + replaceDisabled={searchState.replaceDisabled} searchInputRef={searchState.searchInputRef} wholeWord={searchState.wholeWord} onClose={searchActions.closeSearch} @@ -262,8 +269,13 @@ export function RichMarkdownEditorSurface({ onEditStart={() => linkBubbleActions.setIsEditingLink(true)} onEditCancel={linkBubbleActions.handleLinkEditCancel} onOpen={linkBubbleActions.handleLinkOpen} + onCopy={linkBubbleActions.handleLinkCopy} + ownerId={linkBubbleOwnerId} /> ) : null} + + {citationStatus} + {slashMenu ? ( void onEditCancel: () => void onOpen: () => void + onCopy: () => void + ownerId?: string portalToDocument?: boolean } @@ -173,6 +181,8 @@ export function RichMarkdownLinkBubble({ onEditStart, onEditCancel, onOpen, + onCopy, + ownerId, portalToDocument = false }: RichMarkdownLinkBubbleProps): React.JSX.Element { const bubbleRef = useRef(null) @@ -270,6 +280,8 @@ export function RichMarkdownLinkBubble({
{ // Prevent editor blur when clicking bubble buttons, but let inputs @@ -278,7 +290,28 @@ export function RichMarkdownLinkBubble({ e.preventDefault() } }} - onKeyDown={(e) => e.stopPropagation()} + onKeyDown={(event) => { + event.stopPropagation() + if (event.key === 'Escape') { + event.preventDefault() + onDismiss() + anchorElement?.querySelector('[contenteditable="true"]')?.focus() + return + } + if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') { + return + } + const buttons = Array.from( + bubbleRef.current?.querySelectorAll('button:not([disabled])') ?? [] + ) + const currentIndex = buttons.indexOf(document.activeElement as HTMLButtonElement) + if (currentIndex === -1 || buttons.length === 0) { + return + } + event.preventDefault() + const direction = event.key === 'ArrowRight' ? 1 : -1 + buttons[(currentIndex + direction + buttons.length) % buttons.length]?.focus() + }} > {isEditing ? ( @@ -287,39 +320,45 @@ export function RichMarkdownLinkBubble({ {linkBubble.href.length > 40 ? `${linkBubble.href.slice(0, 40)}…` : linkBubble.href} - - - + + + {linkBubble.kind === 'markdown' ? ( + <> + + + + + + + + ) : null} )}
@@ -329,3 +368,36 @@ export function RichMarkdownLinkBubble({ // actions must portal to the app layer to remain above the right sidebar. return portalToDocument ? createPortal(bubble, document.body) : bubble } + +function LinkBubbleAction({ + children, + disabled = false, + label, + onClick +}: { + children: React.ReactNode + disabled?: boolean + label: string + onClick: () => void +}): React.JSX.Element { + return ( + + + + + + {label} + + + ) +} diff --git a/src/renderer/src/components/editor/RichMarkdownSearchBar.tsx b/src/renderer/src/components/editor/RichMarkdownSearchBar.tsx index ca7fd042a..9ea8d17e0 100644 --- a/src/renderer/src/components/editor/RichMarkdownSearchBar.tsx +++ b/src/renderer/src/components/editor/RichMarkdownSearchBar.tsx @@ -22,6 +22,7 @@ type RichMarkdownSearchBarProps = { matchCount: number query: string replaceQuery: string + replaceDisabled: boolean searchInputRef: React.RefObject wholeWord: boolean onClose: () => void @@ -43,6 +44,7 @@ export function RichMarkdownSearchBar({ matchCount, query, replaceQuery, + replaceDisabled, searchInputRef, wholeWord, onClose, @@ -58,6 +60,7 @@ export function RichMarkdownSearchBar({ // Why: surface the same replace shortcut the source editor uses so the toggle // is discoverable; reads the user's effective binding, formatted per platform. const replaceShortcut = useOptionalShortcutLabel('editor.replace') + const readOnlyExplanationId = React.useId() if (!isOpen) { return null @@ -71,6 +74,10 @@ export function RichMarkdownSearchBar({ } const noMatches = matchCount === 0 + const readOnlyReplaceExplanation = translate( + 'auto.components.editor.RichMarkdownSearchBar.preservedRichContentReadOnly', + 'Preserved rich content is read-only in rich mode.' + ) const toggleReplaceLabel = isReplaceMode ? translate('auto.components.editor.RichMarkdownSearchBar.e8c147435f', 'Hide replace') : translate('auto.components.editor.RichMarkdownSearchBar.9cdc38be33', 'Toggle replace') @@ -259,6 +266,7 @@ export function RichMarkdownSearchBar({ 'auto.components.editor.RichMarkdownSearchBar.44682b4159', 'Replace in rich markdown editor' )} + aria-describedby={replaceDisabled ? readOnlyExplanationId : undefined} />
+ {replaceDisabled ? ( + + {readOnlyReplaceExplanation} + + ) : null} ) : null} diff --git a/src/renderer/src/components/editor/html-character-reference-decoder.ts b/src/renderer/src/components/editor/html-character-reference-decoder.ts new file mode 100644 index 000000000..e8bd9d168 --- /dev/null +++ b/src/renderer/src/components/editor/html-character-reference-decoder.ts @@ -0,0 +1,22 @@ +export type HtmlAttributeQuote = '"' | "'" | null + +export function decodeHtmlTextCharacterReferences(value: string): string { + // Why: a