feat: add copy remote URL to line number context menu (#253)

* feat: add copy remote URL to line number context menu

Add 'Copy Remote URL' option to the line number gutter right-click menu,
allowing users to quickly copy a shareable link to a file and line on
GitHub, GitLab, Bitbucket, or other hosted git providers.

- Install hosted-git-info to parse remote URLs and generate browse URLs
- Add getRemoteFileUrl() to resolve file URLs using default branch
- Wire up IPC handler, preload bridge, and renderer UI
- Use ExternalLink icon to distinguish from path copy options

* refactor: use @types/hosted-git-info instead of custom type declarations

Replace the custom hosted-git-info.d.ts with the @types package from
DefinitelyTyped. Use a targeted cast for browseFile() which the @types
package doesn't include yet (stuck on v3 API).

* refactor: revert to local type declarations for hosted-git-info

The @types/hosted-git-info package is stuck on the v3 API and lacks
browseFile(). A local .d.ts is cleaner than casting around stale types.
This commit is contained in:
Matheus Nogueira 2026-04-01 20:02:05 -03:00 committed by GitHub
parent e8e7d2ea8f
commit 2ec433f6df
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 143 additions and 2 deletions

View File

@ -45,6 +45,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"electron-updater": "^6.8.3",
"hosted-git-info": "^9.0.2",
"lucide-react": "^0.577.0",
"monaco-editor": "^0.55.1",
"node-pty": "^1.1.0",

View File

@ -59,6 +59,9 @@ importers:
electron-updater:
specifier: ^6.8.3
version: 6.8.3
hosted-git-info:
specifier: ^9.0.2
version: 9.0.2
lucide-react:
specifier: ^0.577.0
version: 0.577.0(react@19.2.4)
@ -931,48 +934,56 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-arm64-musl@0.41.0':
resolution: {integrity: sha512-VfVZxL0+6RU86T8F8vKiDBa+iHsr8PAjQmKGBzSCAX70b6x+UOMFl+2dNihmKmUwqkCazCPfYjt6SuAPOeQJ3g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-ppc64-gnu@0.41.0':
resolution: {integrity: sha512-bwzokz2eGvdfJbc0i+zXMJ4BBjQPqg13jyWpEEZDOrBCQ91r8KeY2Mi2kUeuMTZNFXju+jcAbAbpyJxRGla0eg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-gnu@0.41.0':
resolution: {integrity: sha512-POLM//PCH9uqDeNDwWL3b3DkMmI3oI2cU6hwc2lnztD1o7dzrQs3R9nq555BZ6wI7t2lyhT9CS+CRaz5X0XqLA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-musl@0.41.0':
resolution: {integrity: sha512-NNK7PzhFqLUwx/G12Xtm6scGv7UITvyGdAR5Y+TlqsG+essnuRWR4jRNODWRjzLZod0T3SayRbnkSIWMBov33w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-s390x-gnu@0.41.0':
resolution: {integrity: sha512-qVf/zDC5cN9eKe4qI/O/m445er1IRl6swsSl7jHkqmOSVfknwCe5JXitYjZca+V/cNJSU/xPlC5EFMabMMFDpw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-gnu@0.41.0':
resolution: {integrity: sha512-ojxYWu7vUb6ysYqVCPHuAPVZHAI40gfZ0PDtZAMwVmh2f0V8ExpPIKoAKr7/8sNbAXJBBpZhs2coypIo2jJX4w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-musl@0.41.0':
resolution: {integrity: sha512-O2exZLBxoCMIv2vlvcbkdedazJPTdG0VSup+0QUCfYQtx751zCZNboX2ZUOiQ/gDTdhtXvSiot0h6GEGkOyalA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxfmt/binding-openharmony-arm64@0.41.0':
resolution: {integrity: sha512-N+31/VoL+z+NNBt8viy3I4NaIdPbiYeOnB884LKqvXldaE2dRztdPv3q5ipfZYv0RwFp7JfqS4I27K/DSHCakg==}
@ -1045,48 +1056,56 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-arm64-musl@1.56.0':
resolution: {integrity: sha512-rkTZkBfJ4TYLjansjSzL6mgZOdN5IvUnSq3oNJSLwBcNvy3dlgQtpHPrRxrCEbbcp7oQ6If0tkNaqfOsphYZ9g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-ppc64-gnu@1.56.0':
resolution: {integrity: sha512-uqL1kMH3u69/e1CH2EJhP3CP28jw2ExLsku4o8RVAZ7fySo9zOyI2fy9pVlTAp4voBLVgzndXi3SgtdyCTa2aA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-gnu@1.56.0':
resolution: {integrity: sha512-j0CcMBOgV6KsRaBdsebIeiy7hCjEvq2KdEsiULf2LZqAq0v1M1lWjelhCV57LxsqaIGChXFuFJ0RiFrSRHPhSg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-musl@1.56.0':
resolution: {integrity: sha512-7VDOiL8cDG3DQ/CY3yKjbV1c4YPvc4vH8qW09Vv+5ukq3l/Kcyr6XGCd5NvxUmxqDb2vjMpM+eW/4JrEEsUetA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-s390x-gnu@1.56.0':
resolution: {integrity: sha512-JGRpX0M+ikD3WpwJ7vKcHKV6Kg0dT52BW2Eu2BupXotYeqGXBrbY+QPkAyKO6MNgKozyTNaRh3r7g+VWgyAQYQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-gnu@1.56.0':
resolution: {integrity: sha512-dNaICPvtmuxFP/VbqdofrLqdS3bM/AKJN3LMJD52si44ea7Be1cBk6NpfIahaysG9Uo+L98QKddU9CD5L8UHnQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-musl@1.56.0':
resolution: {integrity: sha512-pF1vOtM+GuXmbklM1hV8WMsn6tCNPvkUzklj/Ej98JhlanbmA2RB1BILgOpwSuCTRTIYx2MXssmEyQQ90QF5aA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxlint/binding-openharmony-arm64@1.56.0':
resolution: {integrity: sha512-bp8NQ4RE6fDIFLa4bdBiOA+TAvkNkg+rslR+AvvjlLTYXLy9/uKAYLQudaQouWihLD/hgkrXIKKzXi5IXOewwg==}
@ -1847,66 +1866,79 @@ packages:
resolution: {integrity: sha512-KUizqxpwaR2AZdAUsMWfL/C94pUu7TKpoPd88c8yFVixJ+l9hejkrwoK5Zj3wiNh65UeyryKnJyxL1b7yNqFQA==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.59.1':
resolution: {integrity: sha512-MZoQ/am77ckJtZGFAtPucgUuJWiop3m2R3lw7tC0QCcbfl4DRhQUBUkHWCkcrT3pqy5Mzv5QQgY6Dmlba6iTWg==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.59.1':
resolution: {integrity: sha512-Sez95TP6xGjkWB1608EfhCX1gdGrO5wzyN99VqzRtC17x/1bhw5VU1V0GfKUwbW/Xr1J8mSasoFoJa6Y7aGGSA==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.59.1':
resolution: {integrity: sha512-9Cs2Seq98LWNOJzR89EGTZoiP8EkZ9UbQhBlDgfAkM6asVna1xJ04W2CLYWDN/RpUgOjtQvcv8wQVi1t5oQazA==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.59.1':
resolution: {integrity: sha512-n9yqttftgFy7IrNEnHy1bOp6B4OSe8mJDiPkT7EqlM9FnKOwUMnCK62ixW0Kd9Clw0/wgvh8+SqaDXMFvw3KqQ==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.59.1':
resolution: {integrity: sha512-SfpNXDzVTqs/riak4xXcLpq5gIQWsqGWMhN1AGRQKB4qGSs4r0sEs3ervXPcE1O9RsQ5bm8Muz6zmQpQnPss1g==}
cpu: [loong64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.59.1':
resolution: {integrity: sha512-LjaChED0wQnjKZU+tsmGbN+9nN1XhaWUkAlSbTdhpEseCS4a15f/Q8xC2BN4GDKRzhhLZpYtJBZr2NZhR0jvNw==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.59.1':
resolution: {integrity: sha512-ojW7iTJSIs4pwB2xV6QXGwNyDctvXOivYllttuPbXguuKDX5vwpqYJsHc6D2LZzjDGHML414Tuj3LvVPe1CT1A==}
cpu: [ppc64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.59.1':
resolution: {integrity: sha512-FP+Q6WTcxxvsr0wQczhSE+tOZvFPV8A/mUE6mhZYFW9/eea/y/XqAgRoLLMuE9Cz0hfX5bi7p116IWoB+P237A==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.59.1':
resolution: {integrity: sha512-L1uD9b/Ig8Z+rn1KttCJjwhN1FgjRMBKsPaBsDKkfUl7GfFq71pU4vWCnpOsGljycFEbkHWARZLf4lMYg3WOLw==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.59.1':
resolution: {integrity: sha512-EZc9NGTk/oSUzzOD4nYY4gIjteo2M3CiozX6t1IXGCOdgxJTlVu/7EdPeiqeHPSIrxkLhavqpBAUCfvC6vBOug==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.59.1':
resolution: {integrity: sha512-NQ9KyU1Anuy59L8+HHOKM++CoUxrQWrZWXRik4BJFm+7i5NP6q/SW43xIBr80zzt+PDBJ7LeNmloQGfa0JGk0w==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.59.1':
resolution: {integrity: sha512-GZkLk2t6naywsveSFBsEb0PLU+JC9ggVjbndsbG20VPhar6D1gkMfCx4NfP9owpovBXTN+eRdqGSkDGIxPHhmQ==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openbsd-x64@4.59.1':
resolution: {integrity: sha512-1hjG9Jpl2KDOetr64iQd8AZAEjkDUUK5RbDkYWsViYLC1op1oNzdjMJeFiofcGhqbNTaY2kfgqowE7DILifsrA==}
@ -1994,24 +2026,28 @@ packages:
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.2.2':
resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.2.2':
resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.2.2':
resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.2.2':
resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==}
@ -3157,6 +3193,10 @@ packages:
resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==}
engines: {node: '>=10'}
hosted-git-info@9.0.2:
resolution: {integrity: sha512-M422h7o/BR3rmCQ8UHi7cyyMqKltdP9Uo+J2fXK+RSAY+wTcKOIRyhTuKv4qn+DJf3g+PL890AzId5KZpX+CBg==}
engines: {node: ^20.17.0 || >=22.9.0}
html-url-attributes@3.0.1:
resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
@ -3464,24 +3504,28 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.32.0:
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.32.0:
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.32.0:
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.32.0:
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
@ -3546,6 +3590,10 @@ packages:
lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
lru-cache@11.2.7:
resolution: {integrity: sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==}
engines: {node: 20 || >=22}
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
@ -8053,6 +8101,10 @@ snapshots:
dependencies:
lru-cache: 6.0.0
hosted-git-info@9.0.2:
dependencies:
lru-cache: 11.2.7
html-url-attributes@3.0.1: {}
http-cache-semantics@4.2.0: {}
@ -8367,6 +8419,8 @@ snapshots:
lru-cache@10.4.3: {}
lru-cache@11.2.7: {}
lru-cache@5.1.1:
dependencies:
yallist: 3.1.1

View File

@ -2,6 +2,7 @@ import { execFile, execSync } from 'child_process'
import { existsSync, statSync } from 'fs'
import { join, basename } from 'path'
import { promisify } from 'util'
import hostedGitInfo from 'hosted-git-info'
const execFileAsync = promisify(execFile)
@ -321,3 +322,36 @@ export async function getBranchConflictKind(
return null
}
}
/**
* 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,
relativePath: string,
line: number
): string | null {
const remoteUrl = getRemoteUrl(repoPath)
if (!remoteUrl) {
return null
}
const info = hostedGitInfo.fromUrl(remoteUrl)
if (!info) {
return null
}
const defaultBranch = getDefaultBaseRef(repoPath).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}`
}

View File

@ -26,6 +26,7 @@ import {
getBranchCompare,
getBranchDiff
} from '../git/status'
import { getRemoteFileUrl } from '../git/repo'
import {
resolveAuthorizedPath,
resolveRegisteredWorktreePath,
@ -432,4 +433,15 @@ export function registerFilesystemHandlers(store: Store): void {
await discardChanges(worktreePath, filePath)
}
)
ipcMain.handle(
'git:remoteFileUrl',
async (
_event,
args: { worktreePath: string; relativePath: string; line: number }
): Promise<string | null> => {
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
return getRemoteFileUrl(worktreePath, args.relativePath, args.line)
}
)
}

13
src/main/types/hosted-git-info.d.ts vendored Normal file
View File

@ -0,0 +1,13 @@
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
}

View File

@ -172,6 +172,11 @@ type GitApi = {
stage: (args: { worktreePath: string; filePath: string }) => Promise<void>
unstage: (args: { worktreePath: string; filePath: string }) => Promise<void>
discard: (args: { worktreePath: string; filePath: string }) => Promise<void>
remoteFileUrl: (args: {
worktreePath: string
relativePath: string
line: number
}) => Promise<string | null>
}
type Api = {

View File

@ -283,7 +283,12 @@ const api = {
unstage: (args: { worktreePath: string; filePath: string }): Promise<void> =>
ipcRenderer.invoke('git:unstage', args),
discard: (args: { worktreePath: string; filePath: string }): Promise<void> =>
ipcRenderer.invoke('git:discard', args)
ipcRenderer.invoke('git:discard', args),
remoteFileUrl: (args: {
worktreePath: string
relativePath: string
line: number
}): Promise<string | null> => ipcRenderer.invoke('git:remoteFileUrl', args)
},
ui: {

View File

@ -1,7 +1,7 @@
import React, { useRef, useCallback, useEffect, useState } from 'react'
import Editor, { type OnMount } from '@monaco-editor/react'
import type { editor } from 'monaco-editor'
import { Copy } from 'lucide-react'
import { Copy, ExternalLink } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuContent,
@ -206,6 +206,23 @@ export default function MonacoEditor({
<Copy className="w-3.5 h-3.5 mr-1.5" />
Copy Rel. Path to Line
</DropdownMenuItem>
<DropdownMenuItem
onSelect={async () => {
// Derive worktree root from the absolute and relative paths
const worktreePath = filePath.slice(0, -(relativePath.length + 1))
const url = await window.api.git.remoteFileUrl({
worktreePath,
relativePath,
line: gutterMenuLine
})
if (url) {
window.api.ui.writeClipboardText(url)
}
}}
>
<ExternalLink className="w-3.5 h-3.5 mr-1.5" />
Copy Remote URL
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>