diff --git a/package.json b/package.json index c841e6b09..f7965c6c1 100644 --- a/package.json +++ b/package.json @@ -98,14 +98,10 @@ "dompurify": "^3.4.2", "electron-updater": "^6.8.3", "emoji-picker-react": "^4.19.1", - "entities": "^6.0.1", - "github-slugger": "^2.0.0", - "hosted-git-info": "^9.0.3", "html-to-image": "^1.11.13", "katex": "^0.16.45", "lowlight": "^3.3.0", "lucide-react": "^0.577.0", - "mdast-util-to-string": "^4.0.0", "mermaid": "^11.15.0", "monaco-editor": "^0.55.1", "node-pty": "^1.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 90c5718c4..5989c5861 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -142,15 +142,6 @@ importers: emoji-picker-react: specifier: ^4.19.1 version: 4.19.1(react@19.2.5) - entities: - specifier: ^6.0.1 - version: 6.0.1 - github-slugger: - specifier: ^2.0.0 - version: 2.0.0 - hosted-git-info: - specifier: ^9.0.3 - version: 9.0.3 html-to-image: specifier: ^1.11.13 version: 1.11.13 @@ -163,9 +154,6 @@ importers: lucide-react: specifier: ^0.577.0 version: 0.577.0(react@19.2.5) - mdast-util-to-string: - specifier: ^4.0.0 - version: 4.0.0 mermaid: specifier: ^11.15.0 version: 11.15.0 @@ -331,7 +319,7 @@ importers: version: 19.2.5(react@19.2.5) react-grab: specifier: ^0.1.33 - version: 0.1.33(react@19.2.5) + version: 0.1.34(react@19.2.5) tailwindcss: specifier: ^4.2.4 version: 4.2.4 @@ -369,11 +357,6 @@ packages: '@antfu/install-pkg@1.1.0': resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} - '@antfu/ni@30.1.0': - resolution: {integrity: sha512-3VuAbPjgY52rQNn4wABaXMhBU2Oq91uy6L8nX49eJ35OLI68CyckGU+HZxcaHix4ymuGM2nFL1D6sLpgODK5xw==} - engines: {node: '>=20.19.0'} - hasBin: true - '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} @@ -553,8 +536,8 @@ packages: peerDependencies: react: '>=16.8.0' - '@dotenvx/dotenvx@1.65.0': - resolution: {integrity: sha512-v4FA/Lw3pTEloLxBqTOaYDX6MNo0Jo7lGBsPZhwnJBqRJp0AzQg1ZZNxrFsh6HVC6QWeWrfIKLn0y2eyIXaVDg==} + '@dotenvx/dotenvx@1.66.0': + resolution: {integrity: sha512-qlQFhHUjhRDybrinqLAD0MClVZDOrsq80O8eD5iSjz3Qa/4f3Jg7SQrOaSobrRyP1QaWIYLGtGpj2c7H0D8NUw==} hasBin: true '@ecies/ciphers@0.2.6': @@ -2228,8 +2211,8 @@ packages: '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} - '@react-grab/cli@0.1.33': - resolution: {integrity: sha512-UOc3PwN11Osw0NzaxRLK8trP4X+5iW1Dst3gvHRCafe3wXHyadzHYH8H1hdkcXdlIx3gsoD9ASJ+G/JH+A/jqA==} + '@react-grab/cli@0.1.34': + resolution: {integrity: sha512-L2eAxN46Vq2Ss3nDegrH7wQVMeWH03ahawp+OdzUtQWqL3cq6Bt149q9XhY3cWc9fJsxuWjLfCn+3T9uApIlBA==} hasBin: true '@rolldown/pluginutils@1.0.0-rc.3': @@ -3215,8 +3198,8 @@ packages: bindings@1.5.0: resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} - bippy@0.5.39: - resolution: {integrity: sha512-8hE8rKSl8JWyeaY+JjpnmceWAZPpLEyzOZQpWXM5Rc7861c5WotMJHy2aRZKZrGA8nMpvLNF01t4yQQ+HcZG3w==} + bippy@0.5.40: + resolution: {integrity: sha512-3QPSDG5tgd7FCIkcKsUqmbGdlHxBxP7II5drXPNp1I0rpA9+4+/VjQOigDTbzeqTi6Xkaf1Dq7x4E8vbOuwOkA==} peerDependencies: react: '>=17.0.1' @@ -3463,6 +3446,10 @@ packages: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} engines: {node: '>= 0.6'} + content-type@2.0.0: + resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} + engines: {node: '>=18'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -3999,8 +3986,8 @@ packages: exponential-backoff@3.1.3: resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} - express-rate-limit@8.5.0: - resolution: {integrity: sha512-XKhFohWaSBdVJNTi5TaHziqnPkv04I9UQV6q1Wy7Ui6GGQZVW12ojDFwqer14EvCXxjvPG0CyWXx7cAXpALB4Q==} + express-rate-limit@8.5.2: + resolution: {integrity: sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==} engines: {node: '>= 16'} peerDependencies: express: '>= 4.11' @@ -4160,9 +4147,6 @@ packages: fuzzysort@3.1.0: resolution: {integrity: sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ==} - fzf@0.5.2: - resolution: {integrity: sha512-Tt4kuxLXFKHy8KT40zwsUPUkg1CrsgY25FxA2U/j/0WgEDCk3ddc/zLTCCcbSHX9FcKtLuVaDGtGE/STWC+j3Q==} - gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -4314,18 +4298,14 @@ packages: resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} engines: {node: '>=12.0.0'} - hono@4.12.17: - resolution: {integrity: sha512-FbJJNb/XgX7YW0hX/V8w5oYLztKEsRLykCMZWt1WdLtsfjzMvmoqWBA4H4t5norinq8/rh20oiZYr+WSl4UzAQ==} + hono@4.12.19: + resolution: {integrity: sha512-xa3eYXYXx68XTT4hZ7dRzsXBhaq85ToSrlUJNoR0gwz/1Ap/CNwX47wfvV7pc/xWhjKVVkLT7zBJy8chhNguqQ==} engines: {node: '>=16.9.0'} hosted-git-info@4.1.0: resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==} engines: {node: '>=10'} - hosted-git-info@9.0.3: - resolution: {integrity: sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==} - engines: {node: ^20.17.0 || >=22.9.0} - html-to-image@1.11.13: resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==} @@ -4415,8 +4395,8 @@ packages: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} - ip-address@10.1.0: - resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} + ip-address@10.2.0: + resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} engines: {node: '>= 12'} ipaddr.js@1.9.1: @@ -4747,10 +4727,6 @@ packages: lowlight@3.3.0: resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==} - lru-cache@11.3.6: - resolution: {integrity: sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==} - engines: {node: 20 || >=22} - lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -5442,8 +5418,8 @@ packages: engines: {node: '>=10.13.0'} hasBin: true - qs@6.15.1: - resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==} + qs@6.15.2: + resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} engines: {node: '>=0.6'} queue-microtask@1.2.3: @@ -5483,8 +5459,8 @@ packages: peerDependencies: react: ^19.2.5 - react-grab@0.1.33: - resolution: {integrity: sha512-ER919JMsE4TTrb2CpEivqsIjNMSycD4HtS8v7mS3pq67U7WL1K3+C8m9AYOwW4dpuYh+EanC2eJBmfuczHJZ0A==} + react-grab@0.1.34: + resolution: {integrity: sha512-jtdOdv0kb90oqL+pMszSh9DOLgVRaX4ZE6XN4GkDEpNNUqveQfZT014+EeJraljpqQfuWKW+96NrrRqUD93D2g==} hasBin: true peerDependencies: react: '>=17.0.0' @@ -6077,9 +6053,9 @@ packages: resolution: {integrity: sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==} engines: {node: '>=20'} - type-is@2.0.1: - resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} - engines: {node: '>= 0.6'} + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} @@ -6458,13 +6434,6 @@ snapshots: package-manager-detector: 1.6.0 tinyexec: 1.1.2 - '@antfu/ni@30.1.0': - dependencies: - fzf: 0.5.2 - package-manager-detector: 1.6.0 - tinyexec: 1.1.2 - tinyglobby: 0.2.16 - '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -6700,7 +6669,7 @@ snapshots: react: 19.2.5 tslib: 2.8.1 - '@dotenvx/dotenvx@1.65.0': + '@dotenvx/dotenvx@1.66.0': dependencies: commander: 11.1.0 dotenv: 17.4.2 @@ -6999,9 +6968,9 @@ snapshots: dependencies: graphql: 16.13.2 - '@hono/node-server@1.19.14(hono@4.12.17)': + '@hono/node-server@1.19.14(hono@4.12.19)': dependencies: - hono: 4.12.17 + hono: 4.12.19 '@iconify/types@2.0.0': {} @@ -7094,7 +7063,7 @@ snapshots: '@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)': dependencies: - '@hono/node-server': 1.19.14(hono@4.12.17) + '@hono/node-server': 1.19.14(hono@4.12.19) ajv: 8.20.0 ajv-formats: 3.0.1(ajv@8.20.0) content-type: 1.0.5 @@ -7103,8 +7072,8 @@ snapshots: eventsource: 3.0.7 eventsource-parser: 3.0.8 express: 5.2.1 - express-rate-limit: 8.5.0(express@5.2.1) - hono: 4.12.17 + express-rate-limit: 8.5.2(express@5.2.1) + hono: 4.12.19 jose: 6.2.3 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 @@ -8160,16 +8129,17 @@ snapshots: '@radix-ui/rect@1.1.1': {} - '@react-grab/cli@0.1.33': + '@react-grab/cli@0.1.34': dependencies: - '@antfu/ni': 30.1.0 commander: 14.0.3 ignore: 7.0.5 jsonc-parser: 3.3.1 ora: 9.4.0 + package-manager-detector: 1.6.0 picocolors: 1.1.1 prompts: 2.4.2 smol-toml: 1.6.1 + tinyexec: 1.1.2 '@rolldown/pluginutils@1.0.0-rc.3': {} @@ -9133,7 +9103,7 @@ snapshots: dependencies: file-uri-to-path: 1.0.0 - bippy@0.5.39(react@19.2.5): + bippy@0.5.40(react@19.2.5): dependencies: react: 19.2.5 @@ -9151,9 +9121,9 @@ snapshots: http-errors: 2.0.1 iconv-lite: 0.7.2 on-finished: 2.4.1 - qs: 6.15.1 + qs: 6.15.2 raw-body: 3.0.2 - type-is: 2.0.1 + type-is: 2.1.0 transitivePeerDependencies: - supports-color @@ -9384,6 +9354,8 @@ snapshots: content-type@1.0.5: {} + content-type@2.0.0: {} + convert-source-map@2.0.0: {} cookie-signature@1.2.2: {} @@ -10021,10 +9993,10 @@ snapshots: exponential-backoff@3.1.3: {} - express-rate-limit@8.5.0(express@5.2.1): + express-rate-limit@8.5.2(express@5.2.1): dependencies: express: 5.2.1 - ip-address: 10.1.0 + ip-address: 10.2.0 express@5.2.1: dependencies: @@ -10048,13 +10020,13 @@ snapshots: once: 1.4.0 parseurl: 1.3.3 proxy-addr: 2.0.7 - qs: 6.15.1 + qs: 6.15.2 range-parser: 1.2.1 router: 2.2.0 send: 1.2.1 serve-static: 2.2.1 statuses: 2.0.2 - type-is: 2.0.1 + type-is: 2.1.0 vary: 1.1.2 transitivePeerDependencies: - supports-color @@ -10190,7 +10162,6 @@ snapshots: graceful-fs: 4.2.11 jsonfile: 6.2.1 universalify: 2.0.1 - optional: true fs-extra@7.0.1: dependencies: @@ -10223,8 +10194,6 @@ snapshots: fuzzysort@3.1.0: {} - fzf@0.5.2: {} - gensync@1.0.0-beta.2: {} get-caller-file@2.0.5: {} @@ -10463,16 +10432,12 @@ snapshots: highlight.js@11.11.1: {} - hono@4.12.17: {} + hono@4.12.19: {} hosted-git-info@4.1.0: dependencies: lru-cache: 6.0.0 - hosted-git-info@9.0.3: - dependencies: - lru-cache: 11.3.6 - html-to-image@1.11.13: {} html-url-attributes@3.0.1: {} @@ -10554,7 +10519,7 @@ snapshots: internmap@2.0.3: {} - ip-address@10.1.0: {} + ip-address@10.2.0: {} ipaddr.js@1.9.1: {} @@ -10805,8 +10770,6 @@ snapshots: devlop: 1.1.0 highlight.js: 11.11.1 - lru-cache@11.3.6: {} - lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -11812,7 +11775,7 @@ snapshots: pngjs: 5.0.0 yargs: 15.4.1 - qs@6.15.1: + qs@6.15.2: dependencies: side-channel: 1.1.0 @@ -11904,10 +11867,10 @@ snapshots: react: 19.2.5 scheduler: 0.27.0 - react-grab@0.1.33(react@19.2.5): + react-grab@0.1.34(react@19.2.5): dependencies: - '@react-grab/cli': 0.1.33 - bippy: 0.5.39(react@19.2.5) + '@react-grab/cli': 0.1.34 + bippy: 0.5.40(react@19.2.5) optionalDependencies: react: 19.2.5 @@ -12245,7 +12208,7 @@ snapshots: '@babel/parser': 7.29.3 '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0) - '@dotenvx/dotenvx': 1.65.0 + '@dotenvx/dotenvx': 1.66.0 '@modelcontextprotocol/sdk': 1.29.0(zod@3.25.76) '@types/validate-npm-package-name': 4.0.2 browserslist: 4.28.2 @@ -12256,7 +12219,7 @@ snapshots: diff: 8.0.4 execa: 9.6.1 fast-glob: 3.3.3 - fs-extra: 11.3.4 + fs-extra: 11.3.5 fuzzysort: 3.1.0 https-proxy-agent: 7.0.6 kleur: 4.1.5 @@ -12624,9 +12587,9 @@ snapshots: dependencies: tagged-tag: 1.0.0 - type-is@2.0.1: + type-is@2.1.0: dependencies: - content-type: 1.0.5 + content-type: 2.0.0 media-typer: 1.1.0 mime-types: 3.0.2 diff --git a/src/main/git/hosted-remote-url.test.ts b/src/main/git/hosted-remote-url.test.ts new file mode 100644 index 000000000..f4158d6cc --- /dev/null +++ b/src/main/git/hosted-remote-url.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import { buildHostedRemoteFileUrl, parseHostedRemote } from './hosted-remote-url' + +describe('hosted remote URLs', () => { + it('parses common GitHub remote formats', () => { + expect(parseHostedRemote('https://github.com/Org/Repo.git')).toEqual({ + host: 'github.com', + path: 'Org/Repo', + provider: 'github' + }) + expect(parseHostedRemote('git@github.com:Org/Repo.git')).toEqual({ + host: 'github.com', + path: 'Org/Repo', + provider: 'github' + }) + expect(parseHostedRemote('ssh://git@github.com/Org/Repo.git')).toEqual({ + host: 'github.com', + path: 'Org/Repo', + provider: 'github' + }) + expect(parseHostedRemote('github:Org/Repo')).toEqual({ + host: 'github.com', + path: 'Org/Repo', + provider: 'github' + }) + }) + + it('parses nested GitLab and Bitbucket remotes', () => { + expect(parseHostedRemote('git@gitlab.com:group/sub/repo.git')).toEqual({ + host: 'gitlab.com', + path: 'group/sub/repo', + provider: 'gitlab' + }) + expect(parseHostedRemote('https://bitbucket.org/team/repo.git')).toEqual({ + host: 'bitbucket.org', + path: 'team/repo', + provider: 'bitbucket' + }) + }) + + it('builds file URLs with encoded branches and paths', () => { + expect( + buildHostedRemoteFileUrl('git@github.com:Org/Repo.git', 'src/a file.ts', 'feature/x', 42) + ).toBe('https://github.com/Org/Repo/blob/feature%2Fx/src/a%20file.ts#L42') + + expect( + buildHostedRemoteFileUrl('git@gitlab.com:group/sub/repo.git', 'src/a.ts', 'feature/x', 9) + ).toBe('https://gitlab.com/group/sub/repo/tree/feature%2Fx/src/a.ts#L9') + + expect( + buildHostedRemoteFileUrl('git@bitbucket.org:team/repo.git', 'src/a.ts', 'feature/x', 7) + ).toBe('https://bitbucket.org/team/repo/src/feature%2Fx/src/a.ts#L7') + }) + + it('rejects unsupported hosts and incomplete repo paths', () => { + expect(parseHostedRemote('git@example.com:team/repo.git')).toBeNull() + expect(parseHostedRemote('git@github.com:repo.git')).toBeNull() + expect(parseHostedRemote('ftp://github.com/Org/Repo.git')).toBeNull() + }) +}) diff --git a/src/main/git/hosted-remote-url.ts b/src/main/git/hosted-remote-url.ts new file mode 100644 index 000000000..4046d2901 --- /dev/null +++ b/src/main/git/hosted-remote-url.ts @@ -0,0 +1,116 @@ +// Scoped local fork of the hosted-git-info@9.0.3 behavior Orca used. +// Why: source links only need GitHub/GitLab/Bitbucket remote parsing and file +// URL construction, so we keep that small surface local and tested. +type HostedRemoteProvider = 'github' | 'gitlab' | 'bitbucket' + +type HostedRemote = { + host: string + path: string + provider: HostedRemoteProvider +} + +const shorthandHosts: Record = { + bitbucket: { host: 'bitbucket.org', provider: 'bitbucket' }, + github: { host: 'github.com', provider: 'github' }, + gitlab: { host: 'gitlab.com', provider: 'gitlab' } +} + +function providerForHost(host: string): HostedRemoteProvider | null { + const normalized = host.toLowerCase() + if (normalized === 'github.com') { + return 'github' + } + if (normalized === 'gitlab.com') { + return 'gitlab' + } + if (normalized === 'bitbucket.org') { + return 'bitbucket' + } + return null +} + +function trimGitSuffix(path: string): string { + return path.replace(/\.git$/i, '') +} + +function decodeRemotePathPart(pathPart: string): string { + try { + return decodeURIComponent(pathPart) + } catch { + return pathPart + } +} + +function cleanRemotePath(path: string): string | null { + const normalized = trimGitSuffix(path.replace(/^\/+/, '').replace(/\/+$/, '')) + const parts = normalized.split('/').filter(Boolean) + if (parts.length < 2) { + return null + } + return parts.map(decodeRemotePathPart).join('/') +} + +export function parseHostedRemote(remoteUrl: string): HostedRemote | null { + const trimmed = remoteUrl.trim().replace(/^git\+/, '') + const shorthand = trimmed.match(/^([a-z]+):([^/].+)$/i) + if (shorthand) { + const host = shorthandHosts[shorthand[1].toLowerCase()] + const path = cleanRemotePath(shorthand[2]) + return host && path ? { ...host, path } : null + } + + if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) { + const scpLike = trimmed.match(/^(?:[^@/:]+@)?([^:\s/]+):([^\s]+)$/) + if (scpLike) { + const provider = providerForHost(scpLike[1]) + const path = cleanRemotePath(scpLike[2]) + return provider && path ? { host: scpLike[1].toLowerCase(), path, provider } : null + } + } + + try { + const url = new URL(trimmed) + if (!['git:', 'http:', 'https:', 'ssh:'].includes(url.protocol.toLowerCase())) { + return null + } + const provider = providerForHost(url.hostname) + const path = cleanRemotePath(url.pathname) + return provider && path ? { host: url.hostname.toLowerCase(), path, provider } : null + } catch { + return null + } +} + +function encodeRemotePath(path: string): string { + return path.split('/').map(encodeURIComponent).join('/') +} + +function encodeRelativePath(path: string): string { + return path.replaceAll('\\', '/').split('/').filter(Boolean).map(encodeURIComponent).join('/') +} + +export function buildHostedRemoteFileUrl( + remoteUrl: string, + relativePath: string, + branch: string, + line: number +): string | null { + const remote = parseHostedRemote(remoteUrl) + if (!remote) { + return null + } + + const encodedRepoPath = encodeRemotePath(remote.path) + const encodedBranch = encodeURIComponent(branch) + const encodedFilePath = encodeRelativePath(relativePath) + const filePathSuffix = encodedFilePath ? `/${encodedFilePath}` : '' + const baseUrl = `https://${remote.host}/${encodedRepoPath}` + + if (remote.provider === 'github') { + return `${baseUrl}/blob/${encodedBranch}${filePathSuffix}#L${line}` + } + if (remote.provider === 'gitlab') { + return `${baseUrl}/tree/${encodedBranch}${filePathSuffix}#L${line}` + } + return `${baseUrl}/src/${encodedBranch}${filePathSuffix}#L${line}` +} diff --git a/src/main/git/repo.ts b/src/main/git/repo.ts index dc4c29fdd..b12dc3922 100644 --- a/src/main/git/repo.ts +++ b/src/main/git/repo.ts @@ -2,9 +2,9 @@ import { execSync } from 'child_process' import { existsSync, statSync } from 'fs' import { join, basename } from 'path' -import hostedGitInfo from 'hosted-git-info' import { gitExecFileSync, gitExecFileAsync } from './runner' import type { BaseRefSearchResult } from '../../shared/types' +import { buildHostedRemoteFileUrl } from './hosted-remote-url' const GH_LOGIN_TIMEOUT_MS = 2500 @@ -672,9 +672,6 @@ function isAllowedRemoteBaseRef(refName: string, allowedBaseRef: string | undefi /** * Build a hosted URL (e.g. GitHub, GitLab, Bitbucket) for a specific file * and line in the repo. Returns null when the remote isn't a recognized host. - * - * Why hosted-git-info: it handles SSH, HTTPS, and shorthand remote URLs - * across multiple providers, so we don't have to maintain our own URL parser. */ export function getRemoteFileUrl( repoPath: string, @@ -686,22 +683,11 @@ export function getRemoteFileUrl( return null } - const info = hostedGitInfo.fromUrl(remoteUrl) - if (!info) { - return null - } - const defaultBaseRef = getDefaultBaseRef(repoPath) if (!defaultBaseRef) { return null } const defaultBranch = defaultBaseRef.replace(/^origin\//, '') - const browseUrl = info.browseFile(relativePath, { committish: defaultBranch }) - if (!browseUrl) { - return null - } - // Why: hosted-git-info lowercases the fragment, but GitHub convention - // uses uppercase L for line links (e.g. #L42). Append manually. - return `${browseUrl}#L${line}` + return buildHostedRemoteFileUrl(remoteUrl, relativePath, defaultBranch, line) } diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index ba3edf25f..0fc09dc71 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -1183,9 +1183,8 @@ export function registerFilesystemHandlers( _event, args: { worktreePath: string; relativePath: string; line: number; connectionId?: string } ): Promise => { - // Why: remote repos can't use the local hosted-git-info approach because - // the .git/config lives on the remote. Route through the relay's git.exec - // to fetch the remote URL and build the file link server-side. + // Why: remote repos can't read relay-side .git/config locally. Delegate + // URL construction to the SSH provider, which can fetch remote metadata. if (args.connectionId) { const provider = getSshGitProvider(args.connectionId) if (!provider) { diff --git a/src/main/providers/ssh-git-provider.ts b/src/main/providers/ssh-git-provider.ts index f1c3b0708..513ae5312 100644 --- a/src/main/providers/ssh-git-provider.ts +++ b/src/main/providers/ssh-git-provider.ts @@ -4,7 +4,6 @@ small amount of param plumbing. */ import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' import type { IGitProvider } from './types' -import hostedGitInfo from 'hosted-git-info' import type { GitStatusResult, GitDiffResult, @@ -16,6 +15,7 @@ import type { GitWorktreeInfo } from '../../shared/types' import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history' +import { buildHostedRemoteFileUrl } from '../git/hosted-remote-url' import type { CommitMessageDraftContext } from '../../shared/commit-message-generation' import type { CommitMessagePlan } from '../../shared/commit-message-plan' import type { RemoteCommitMessageExecResult } from '../text-generation/commit-message-text-generation' @@ -274,9 +274,8 @@ export class SshGitProvider implements IGitProvider { return true } - // Why: the local getRemoteFileUrl uses hosted-git-info which requires the - // remote URL from .git/config. For SSH connections we must fetch the remote - // URL from the relay, then apply the same hosted-git-info logic locally. + // Why: SSH worktrees need the remote URL from the relay-side .git/config + // before local code can map it to a hosted source link. async getRemoteFileUrl( worktreePath: string, relativePath: string, @@ -293,11 +292,6 @@ export class SshGitProvider implements IGitProvider { return null } - const info = hostedGitInfo.fromUrl(remoteUrl) - if (!info) { - return null - } - let defaultBranch = 'main' try { const refResult = await this.exec( @@ -312,13 +306,6 @@ export class SshGitProvider implements IGitProvider { // Fall back to 'main' } - const browseUrl = info.browseFile(relativePath, { committish: defaultBranch }) - if (!browseUrl) { - return null - } - - // Why: hosted-git-info lowercases the fragment, but GitHub convention - // uses uppercase L for line links (e.g. #L42). Append manually. - return `${browseUrl}#L${line}` + return buildHostedRemoteFileUrl(remoteUrl, relativePath, defaultBranch, line) } } diff --git a/src/main/types/hosted-git-info.d.ts b/src/main/types/hosted-git-info.d.ts deleted file mode 100644 index ea1254c62..000000000 --- a/src/main/types/hosted-git-info.d.ts +++ /dev/null @@ -1,13 +0,0 @@ -declare module 'hosted-git-info' { - type HostedGitInfo = { - browseFile(path: string, opts?: { committish?: string; fragment?: string }): string | undefined - browse(path?: string, opts?: { committish?: string; fragment?: string }): string | undefined - } - - type HostedGitInfoStatic = { - fromUrl(url: string): HostedGitInfo | undefined - } - - const hostedGitInfo: HostedGitInfoStatic - export default hostedGitInfo -} diff --git a/src/renderer/src/components/editor/MarkdownPreview.tsx b/src/renderer/src/components/editor/MarkdownPreview.tsx index ecf77b332..31c4f5bc3 100644 --- a/src/renderer/src/components/editor/MarkdownPreview.tsx +++ b/src/renderer/src/components/editor/MarkdownPreview.tsx @@ -12,7 +12,6 @@ import rehypeKatex from 'rehype-katex' import rehypeRaw from 'rehype-raw' import rehypeSanitize, { defaultSchema } from 'rehype-sanitize' import rehypeSlug from 'rehype-slug' -import GithubSlugger from 'github-slugger' import { extractFrontMatter } from './markdown-frontmatter' import { Check, @@ -230,27 +229,6 @@ const markdownPreviewSanitizeSchema = { } } -function getMarkdownPreviewNodeText(node: React.ReactNode): string { - if (typeof node === 'string' || typeof node === 'number') { - return String(node) - } - if (Array.isArray(node)) { - return node.map((child) => getMarkdownPreviewNodeText(child)).join('') - } - if (React.isValidElement<{ children?: React.ReactNode }>(node)) { - return getMarkdownPreviewNodeText(node.props.children) - } - return '' -} - -// Why: use the same GithubSlugger that rehype-slug uses internally so -// heading IDs match standard GitHub/VS Code anchor links. The custom -// slugger previously stripped punctuation differently, breaking links -// like `#a--b` for headings containing `A & B`. -function createMarkdownPreviewHeadingId(headingText: string, slugger: GithubSlugger): string { - return slugger.slug(headingText) -} - function parseLineTarget(hash: string): { line: number; column?: number } | null { if (!hash) { return null @@ -471,7 +449,6 @@ export default function MarkdownPreview({ .replace(/\r?\n(?:---|\+\+\+)\r?\n?$/, '') .trim() }, [frontMatter]) - const sluggerRef = useRef(new GithubSlugger()) const [activeAnnotationBlockKey, setActiveAnnotationBlockKey] = useState(null) const [reviewPanelOpen, setReviewPanelOpen] = useState(false) const [reviewNotesCopied, setReviewNotesCopied] = useState(false) @@ -884,8 +861,6 @@ export default function MarkdownPreview({ ) const components: Components = useMemo(() => { - sluggerRef.current.reset() - const slugger = sluggerRef.current return { a: ({ href, children, className, ...props }) => { const docLinkTarget = parseMarkdownDocLinkHref(href) @@ -1239,61 +1214,55 @@ export default function MarkdownPreview({ ) }, h1: ({ node, children, ...props }) => { - const id = createMarkdownPreviewHeadingId(getMarkdownPreviewNodeText(children), slugger) return wrapAnnotatedBlock( 'h1', node as MarkdownPreviewPositionNode, -

+

{children}

) }, h2: ({ node, children, ...props }) => { - const id = createMarkdownPreviewHeadingId(getMarkdownPreviewNodeText(children), slugger) return wrapAnnotatedBlock( 'h2', node as MarkdownPreviewPositionNode, -

+

{children}

) }, h3: ({ node, children, ...props }) => { - const id = createMarkdownPreviewHeadingId(getMarkdownPreviewNodeText(children), slugger) return wrapAnnotatedBlock( 'h3', node as MarkdownPreviewPositionNode, -

+

{children}

) }, h4: ({ node, children, ...props }) => { - const id = createMarkdownPreviewHeadingId(getMarkdownPreviewNodeText(children), slugger) return wrapAnnotatedBlock( 'h4', node as MarkdownPreviewPositionNode, -

+

{children}

) }, h5: ({ node, children, ...props }) => { - const id = createMarkdownPreviewHeadingId(getMarkdownPreviewNodeText(children), slugger) return wrapAnnotatedBlock( 'h5', node as MarkdownPreviewPositionNode, -
+
{children}
) }, h6: ({ node, children, ...props }) => { - const id = createMarkdownPreviewHeadingId(getMarkdownPreviewNodeText(children), slugger) return wrapAnnotatedBlock( 'h6', node as MarkdownPreviewPositionNode, -
+
{children}
) diff --git a/src/renderer/src/components/editor/markdown-anchor-scroll.ts b/src/renderer/src/components/editor/markdown-anchor-scroll.ts index edca0fde5..daf31bc2b 100644 --- a/src/renderer/src/components/editor/markdown-anchor-scroll.ts +++ b/src/renderer/src/components/editor/markdown-anchor-scroll.ts @@ -1,10 +1,7 @@ -import GithubSlugger from 'github-slugger' +import { MarkdownHeadingSlugger } from './markdown-heading-slug' -// Why: rehype-slug generates heading ids using a stateful GithubSlugger that -// appends numeric suffixes to duplicate headings (foo, foo-1, foo-2). To keep -// the editor's anchor matching in parity with the preview, we must use the -// same stateful slugger — the stateless `slug()` helper would miss suffixes -// and silently land on the wrong heading. +// Why: duplicate headings need the same stateful suffixes as the preview +// renderer (foo, foo-1, foo-2) or anchor links can jump to the wrong heading. export function scrollToAnchorInEditor(root: HTMLElement | null, anchor: string): void { if (!root || !anchor) { return @@ -16,7 +13,7 @@ export function scrollToAnchorInEditor(root: HTMLElement | null, anchor: string) // Malformed %-escapes: fall back to the raw fragment. } const headings = root.querySelectorAll('h1, h2, h3, h4, h5, h6') - const slugger = new GithubSlugger() + const slugger = new MarkdownHeadingSlugger() for (const heading of headings) { if (slugger.slug(heading.textContent ?? '') === decoded) { heading.scrollIntoView({ behavior: 'smooth', block: 'start' }) diff --git a/src/renderer/src/components/editor/markdown-heading-slug.test.ts b/src/renderer/src/components/editor/markdown-heading-slug.test.ts new file mode 100644 index 000000000..18050f2c7 --- /dev/null +++ b/src/renderer/src/components/editor/markdown-heading-slug.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' +import { MarkdownHeadingSlugger, slugMarkdownHeading } from './markdown-heading-slug' + +describe('markdown heading slugger', () => { + it('matches GitHub-style punctuation and space handling used by markdown anchors', () => { + expect(slugMarkdownHeading('A & B')).toBe('a--b') + expect(slugMarkdownHeading('https://example.com')).toBe('httpsexamplecom') + expect(slugMarkdownHeading('Keep_under-score')).toBe('keep_under-score') + }) + + it('adds stable duplicate suffixes', () => { + const slugger = new MarkdownHeadingSlugger() + + expect([slugger.slug('Repeat'), slugger.slug('Repeat'), slugger.slug('Repeat')]).toEqual([ + 'repeat', + 'repeat-1', + 'repeat-2' + ]) + }) + + it('can reset duplicate state between render passes', () => { + const slugger = new MarkdownHeadingSlugger() + + expect(slugger.slug('Repeat')).toBe('repeat') + expect(slugger.slug('Repeat')).toBe('repeat-1') + slugger.reset() + expect(slugger.slug('Repeat')).toBe('repeat') + }) +}) diff --git a/src/renderer/src/components/editor/markdown-heading-slug.ts b/src/renderer/src/components/editor/markdown-heading-slug.ts new file mode 100644 index 000000000..20755100b --- /dev/null +++ b/src/renderer/src/components/editor/markdown-heading-slug.ts @@ -0,0 +1,30 @@ +// Scoped local fork of github-slugger@2.0.0 behavior. +// Why: TOC/editor anchors need to match rehype-slug@6.0.0 preview IDs without +// adding a direct github-slugger runtime dependency. +const markdownSlugPunctuationPattern = /[^\p{L}\p{M}\p{N} _-]/gu + +export class MarkdownHeadingSlugger { + private readonly occurrences = new Map() + + reset(): void { + this.occurrences.clear() + } + + slug(value: string): string { + const baseSlug = slugMarkdownHeading(value) + let nextSlug = baseSlug + + while (this.occurrences.has(nextSlug)) { + const nextCount = (this.occurrences.get(baseSlug) ?? 0) + 1 + this.occurrences.set(baseSlug, nextCount) + nextSlug = `${baseSlug}-${nextCount}` + } + + this.occurrences.set(nextSlug, 0) + return nextSlug + } +} + +export function slugMarkdownHeading(value: string): string { + return value.toLowerCase().replace(markdownSlugPunctuationPattern, '').replace(/ /g, '-') +} diff --git a/src/renderer/src/components/editor/markdown-table-of-contents.ts b/src/renderer/src/components/editor/markdown-table-of-contents.ts index 2d40cb4b9..0268a1d43 100644 --- a/src/renderer/src/components/editor/markdown-table-of-contents.ts +++ b/src/renderer/src/components/editor/markdown-table-of-contents.ts @@ -1,10 +1,8 @@ -import GithubSlugger from 'github-slugger' -import { decodeHTML } from 'entities' -import { toString } from 'mdast-util-to-string' import remarkFrontmatter from 'remark-frontmatter' import remarkGfm from 'remark-gfm' import remarkParse from 'remark-parse' import { unified } from 'unified' +import { MarkdownHeadingSlugger } from './markdown-heading-slug' export type MarkdownTocLevel = 1 | 2 | 3 @@ -15,12 +13,43 @@ export type MarkdownTocItem = { title: string } +const htmlEntitiesForToc = new Map([ + ['amp', '&'], + ['apos', "'"], + ['gt', '>'], + ['lt', '<'], + ['nbsp', ' '], + ['quot', '"'] +]) + function isMarkdownTocLevel(value: number): value is MarkdownTocLevel { return value === 1 || value === 2 || value === 3 } +// Scoped local fork of the tiny entities@6.0.1 surface Orca used here. +// Why: TOC labels only need common/numeric entity decoding before inline +// Markdown stripping, not the full entity database. +function decodeTocHtmlEntities(text: string): string { + return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z][a-z0-9]+);/gi, (match, entity: string) => { + const normalized = entity.toLowerCase() + if (normalized.startsWith('#x')) { + const codePoint = Number.parseInt(normalized.slice(2), 16) + return Number.isInteger(codePoint) && codePoint >= 0 && codePoint <= 0x10ffff + ? String.fromCodePoint(codePoint) + : match + } + if (normalized.startsWith('#')) { + const codePoint = Number.parseInt(normalized.slice(1), 10) + return Number.isInteger(codePoint) && codePoint >= 0 && codePoint <= 0x10ffff + ? String.fromCodePoint(codePoint) + : match + } + return htmlEntitiesForToc.get(normalized) ?? match + }) +} + export function stripInlineMarkdownForToc(text: string): string { - return decodeHTML(text) + return decodeTocHtmlEntities(text) .replace(/!\[([^\]]*)\]\([^)]+\)/g, '$1') .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') .replace(/\[\[[^|\]]+\|([^\]]+)\]\]/g, '$1') @@ -48,18 +77,33 @@ function appendTocItem(stack: MarkdownTocItem[], item: MarkdownTocItem): void { } type MarkdownAstNode = { + alt?: string children?: MarkdownAstNode[] depth?: number type?: string + value?: string +} + +// Scoped local fork of mdast-util-to-string@4.0.0 for heading nodes. +// Why: TOC generation only needs text/alt/child concatenation from parsed +// Markdown headings, so a local walker keeps the dependency boundary smaller. +function markdownAstNodeToText(node: MarkdownAstNode): string { + if (typeof node.value === 'string') { + return node.value + } + if (typeof node.alt === 'string') { + return node.alt + } + return (node.children ?? []).map(markdownAstNodeToText).join('') } export function buildMarkdownTableOfContents(markdown: string): MarkdownTocItem[] { - const slugger = new GithubSlugger() + const slugger = new MarkdownHeadingSlugger() const root: MarkdownTocItem = { id: 'toc-root', level: 1, title: '', children: [] } const stack: MarkdownTocItem[] = [root] - // Why: the TOC must produce the same heading text/ids as react-markdown plus - // rehype-slug; parsing Markdown avoids drift on setext, GFM, and entities. + // Why: parsing Markdown keeps the TOC aligned with rendered setext/GFM/entity + // headings without carrying separate mdast stringifier/entity packages. const tree = unified() .use(remarkParse) .use(remarkGfm) @@ -72,7 +116,7 @@ export function buildMarkdownTableOfContents(markdown: string): MarkdownTocItem[ typeof node.depth === 'number' && isMarkdownTocLevel(node.depth) ) { - const title = toString(node).replace(/\s+/g, ' ').trim() + const title = markdownAstNodeToText(node).replace(/\s+/g, ' ').trim() if (title) { appendTocItem(stack, { children: [], diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 52f96613e..91cba9efc 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -5,9 +5,9 @@ import { createRoot } from 'react-dom/client' import App from './App' import { applyDocumentTheme } from './lib/document-theme' -if (import.meta.env.DEV) { - import('react-grab').then(({ init }) => init()) - import('react-grab/styles.css') +if (import.meta.env.DEV && import.meta.env.VITE_ENABLE_REACT_GRAB === 'true') { + void import('react-grab').then(({ init }) => init()) + void import('react-grab/styles.css') } applyDocumentTheme('system', { disableTransitions: false })