feat(links): add copy file path action to the link popover (#21257)

File links in the terminal link popover now offer a "Copy file path"
button alongside the existing "Copy link" for URLs. The copied value is
the resolved filesystem path, so OSC 8 file:// links show the raw URI but
copy the decoded local path.

Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
This commit is contained in:
Wooseong Kim
2026-10-28 01:51:24 -07:00
committed by GitHub
co-authored by Neil
parent 1a0ce3a55c
commit 5e345ffa75
7 changed files with 82 additions and 19 deletions
@@ -254,13 +254,13 @@ describe('LinkActionPopover', () => {
await waitFor(() => expect(mocks.writeClipboardText).toHaveBeenCalledTimes(2))
})
it('does not offer copy link for non-URL destinations', () => {
it('does not offer copy actions for non-copyable destinations', () => {
vi.stubGlobal('navigator', { userAgent: 'Macintosh' })
const request: LinkActionRequest = {
anchorX: 100,
anchorY: 200,
destination: '/tmp/example.ts',
kind: 'file',
kind: 'workspace',
primary: { label: 'Open file', run: vi.fn() },
restoreFocus: vi.fn()
}
@@ -268,6 +268,31 @@ describe('LinkActionPopover', () => {
render(<LinkActionPopover request={request} onClose={vi.fn()} />)
expect(screen.queryByRole('button', { name: 'Copy link' })).toBeNull()
expect(screen.queryByRole('button', { name: 'Copy file path' })).toBeNull()
})
it('copies the resolved file path without closing the popover', async () => {
vi.stubGlobal('navigator', { userAgent: 'Macintosh' })
Object.assign(window, { api: { ui: { writeClipboardText: mocks.writeClipboardText } } })
mocks.writeClipboardText.mockResolvedValue(undefined)
const onClose = vi.fn()
const request: LinkActionRequest = {
anchorX: 100,
anchorY: 200,
destination: 'file:///repo/src/main.ts',
copyValue: '/repo/src/main.ts',
kind: 'file',
primary: { label: 'Open file', run: vi.fn() },
restoreFocus: vi.fn()
}
render(<LinkActionPopover request={request} onClose={onClose} />)
fireEvent.click(screen.getByRole('button', { name: 'Copy file path' }))
await waitFor(() => expect(mocks.writeClipboardText).toHaveBeenCalledWith('/repo/src/main.ts'))
await waitFor(() => expect(screen.getByRole('button', { name: 'Copied' })).toBeTruthy())
expect(mocks.toastSuccess).toHaveBeenCalledWith('Copied file path')
expect(onClose).not.toHaveBeenCalled()
})
it('opens the terminal link setting from the compact settings button', () => {
@@ -61,7 +61,11 @@ export function LinkActionPopover<TRequest extends LinkActionRequest>({
}: LinkActionPopoverProps<TRequest>): React.JSX.Element {
const openSettingsPage = useAppStore((state) => state.openSettingsPage)
const openSettingsTarget = useAppStore((state) => state.openSettingsTarget)
const copyableDestination = request?.kind === 'url' ? request.destination : ''
const isFilePath = request?.kind === 'file'
const canCopyDestination = isFilePath || request?.kind === 'url'
const copyableDestination = canCopyDestination
? (request?.copyValue ?? request?.destination ?? '')
: ''
const { copyText, status: copyStatus } = useClipboardTextCopyFeedback(copyableDestination)
const copyInFlightRef = useRef(false)
const contentRef = useRef<HTMLDivElement>(null)
@@ -103,7 +107,28 @@ export function LinkActionPopover<TRequest extends LinkActionRequest>({
const copyLabel =
copyStatus === 'copied'
? translate('auto.components.terminal.pane.TerminalLinkActionPopover.copied', 'Copied')
: translate('auto.components.terminal.pane.TerminalLinkActionPopover.copyLink', 'Copy link')
: isFilePath
? translate(
'auto.components.terminal.pane.TerminalLinkActionPopover.copyFilePath',
'Copy file path'
)
: translate('auto.components.terminal.pane.TerminalLinkActionPopover.copyLink', 'Copy link')
const copiedMessage = isFilePath
? translate(
'auto.components.terminal.pane.TerminalLinkActionPopover.copiedFilePath',
'Copied file path'
)
: translate('auto.components.terminal.pane.TerminalLinkActionPopover.copiedLink', 'Copied link')
const copyFailedMessage = isFilePath
? translate(
'auto.components.terminal.pane.TerminalLinkActionPopover.copyFilePathFailed',
'Failed to copy file path'
)
: translate(
'auto.components.terminal.pane.TerminalLinkActionPopover.copyLinkFailed',
'Failed to copy link'
)
const copyDestination = async (): Promise<void> => {
if (copyInFlightRef.current) {
@@ -112,20 +137,10 @@ export function LinkActionPopover<TRequest extends LinkActionRequest>({
copyInFlightRef.current = true
try {
if (await copyText()) {
toast.success(
translate(
'auto.components.terminal.pane.TerminalLinkActionPopover.copiedLink',
'Copied link'
)
)
toast.success(copiedMessage)
return
}
toast.error(
translate(
'auto.components.terminal.pane.TerminalLinkActionPopover.copyLinkFailed',
'Failed to copy link'
)
)
toast.error(copyFailedMessage)
} finally {
copyInFlightRef.current = false
}
@@ -170,7 +185,7 @@ export function LinkActionPopover<TRequest extends LinkActionRequest>({
>
{request.destination}
</span>
{request.kind === 'url' ? (
{canCopyDestination ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -9,6 +9,8 @@ export type LinkActionRequest = {
anchorX: number
anchorY: number
destination: string
/** Copied instead of destination when the shown target differs from the resolved path. */
copyValue?: string
kind: LinkActionKind
primary: LinkAction
alternate?: LinkAction
@@ -59,7 +59,8 @@ export function handleTerminalFileLink(
const actions = buildFileLinkActions(filePath, line, column, deps, actionContext?.sourceOwner)
return requestTerminalLinkAction(event, actionContext, {
...actions,
destination: actionDestination ?? actions.destination
destination: actionDestination ?? actions.destination,
copyValue: actions.destination
})
}
@@ -41,7 +41,7 @@ export function closeTerminalLinkActionRequest(
type LinkActionDetails = Pick<
TerminalLinkActionRequest,
'destination' | 'kind' | 'primary' | 'alternate' | 'secondaryActions'
'destination' | 'copyValue' | 'kind' | 'primary' | 'alternate' | 'secondaryActions'
>
export function requestTerminalLinkAction(
@@ -355,4 +355,21 @@ describe('terminal link action routing', () => {
).toBe(true)
expect(request.mock.calls[0][0].destination).toBe(destination)
})
it('shows the raw OSC 8 file URI but copies its resolved path', () => {
const request = vi.fn()
handleOscLink('file:///repo/my%20file.ts#L4', plainEvent(), {
worktreeId: 'wt-1',
worktreePath: '/repo',
startupCwd: '/repo',
linkActionContext: actionContext(request)
})
expect(request.mock.calls[0][0]).toMatchObject({
destination: 'file:///repo/my%20file.ts#L4',
copyValue: '/repo/my file.ts',
kind: 'file'
})
})
})
+3
View File
@@ -3559,9 +3559,12 @@
"orcaBrowser": "Orca Browser",
"terminalLinkSettings": "Terminal link settings",
"copyLink": "Copy link",
"copyFilePath": "Copy file path",
"copied": "Copied",
"copiedLink": "Copied link",
"copiedFilePath": "Copied file path",
"copyLinkFailed": "Failed to copy link",
"copyFilePathFailed": "Failed to copy file path",
"downloadOpenWithDefaultApp": "Download & open with default app",
"downloadOpenFailed": "Failed to download '{{value0}}'.",
"openInThisGroup": "Open in this group",