feat(terminal): add copy action to link popover (#13857)

This commit is contained in:
Jinwoo Hong
2026-08-11 12:58:07 -07:00
committed by GitHub
parent 17c57b7d20
commit 92f928cf89
4 changed files with 169 additions and 5 deletions
+4
View File
@@ -2,6 +2,10 @@
All UI work — layout, color, typography, spacing, component selection, UX behavior — must follow [`docs/STYLEGUIDE.md`](./docs/STYLEGUIDE.md). Use the tokens defined in `src/renderer/src/assets/main.css` (the canonical source) and the shadcn primitives in `src/renderer/src/components/ui/`. Don't invent new color values, font sizes, or shadow tiers when a documented one already covers the role. When STYLEGUIDE.md is silent, follow the resolution order in its final section.
## Electron UI Validation
Use the `$electron` skill and Playwright CDP for rendered Orca UI checks. Do not use computer-use for Orca UI validation.
# Style
## Concise/Brief Non-obviosu comments ONLY
* DO NOT: be verbose, explain the obvious, walk through the code ("WHY not HOW")
@@ -1,13 +1,20 @@
// @vitest-environment happy-dom
import type { ReactNode } from 'react'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { BROWSER_TERMINAL_LINK_ACTIONS_SETTINGS_TARGET_ID } from '@/lib/settings-navigation-types'
import type { TerminalLinkActionRequest } from './terminal-link-action-request'
const mocks = vi.hoisted(() => ({
openSettingsPage: vi.fn(),
openSettingsTarget: vi.fn()
openSettingsTarget: vi.fn(),
toastError: vi.fn(),
toastSuccess: vi.fn(),
writeClipboardText: vi.fn()
}))
vi.mock('sonner', () => ({
toast: { error: mocks.toastError, success: mocks.toastSuccess }
}))
vi.mock('@/store', () => ({
@@ -141,6 +148,102 @@ describe('TerminalLinkActionPopover', () => {
).toBeTruthy()
})
it('copies the resolved URL 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 focusTerminal = vi.fn()
const request: TerminalLinkActionRequest = {
paneId: 1,
anchorX: 100,
anchorY: 200,
destination: 'https://example.com/hidden-destination',
kind: 'url',
primary: { label: 'Open link', run: vi.fn() },
focusTerminal
}
render(<TerminalLinkActionPopover request={request} onClose={onClose} />)
fireEvent.click(screen.getByRole('button', { name: 'Copy link' }))
await waitFor(() => expect(mocks.writeClipboardText).toHaveBeenCalledWith(request.destination))
await waitFor(() => expect(screen.getByRole('button', { name: 'Copied' })).toBeTruthy())
expect(mocks.toastSuccess).toHaveBeenCalledWith('Copied link')
expect(onClose).not.toHaveBeenCalled()
expect(focusTerminal).not.toHaveBeenCalled()
})
it('ignores duplicate copy clicks while the clipboard write is in flight', async () => {
vi.stubGlobal('navigator', { userAgent: 'Macintosh' })
Object.assign(window, { api: { ui: { writeClipboardText: mocks.writeClipboardText } } })
let resolveWrite: (() => void) | undefined
mocks.writeClipboardText.mockReturnValue(
new Promise<void>((resolve) => {
resolveWrite = resolve
})
)
const request: TerminalLinkActionRequest = {
paneId: 1,
anchorX: 100,
anchorY: 200,
destination: 'https://example.com/hidden-destination',
kind: 'url',
primary: { label: 'Open link', run: vi.fn() },
focusTerminal: vi.fn()
}
render(<TerminalLinkActionPopover request={request} onClose={vi.fn()} />)
const copyButton = screen.getByRole('button', { name: 'Copy link' })
fireEvent.click(copyButton)
fireEvent.click(copyButton)
expect(mocks.writeClipboardText).toHaveBeenCalledOnce()
resolveWrite?.()
await waitFor(() => expect(mocks.toastSuccess).toHaveBeenCalledOnce())
fireEvent.click(copyButton)
await waitFor(() => expect(mocks.writeClipboardText).toHaveBeenCalledTimes(2))
})
it('shows a failure toast when copying fails', async () => {
vi.stubGlobal('navigator', { userAgent: 'Macintosh' })
Object.assign(window, { api: { ui: { writeClipboardText: mocks.writeClipboardText } } })
mocks.writeClipboardText.mockRejectedValue(new Error('denied'))
const request: TerminalLinkActionRequest = {
paneId: 1,
anchorX: 100,
anchorY: 200,
destination: 'https://example.com/hidden-destination',
kind: 'url',
primary: { label: 'Open link', run: vi.fn() },
focusTerminal: vi.fn()
}
render(<TerminalLinkActionPopover request={request} onClose={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: 'Copy link' }))
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('Failed to copy link'))
fireEvent.click(screen.getByRole('button', { name: 'Copy link' }))
await waitFor(() => expect(mocks.writeClipboardText).toHaveBeenCalledTimes(2))
})
it('does not offer copy link for non-URL destinations', () => {
vi.stubGlobal('navigator', { userAgent: 'Macintosh' })
const request: TerminalLinkActionRequest = {
paneId: 1,
anchorX: 100,
anchorY: 200,
destination: '/tmp/example.ts',
kind: 'file',
primary: { label: 'Open file', run: vi.fn() },
focusTerminal: vi.fn()
}
render(<TerminalLinkActionPopover request={request} onClose={vi.fn()} />)
expect(screen.queryByRole('button', { name: 'Copy link' })).toBeNull()
})
it('opens the terminal link setting from the compact settings button', () => {
vi.stubGlobal('navigator', { userAgent: 'Macintosh' })
const onClose = vi.fn()
@@ -1,9 +1,11 @@
import { useMemo } from 'react'
import { ExternalLink, Globe, Settings } from 'lucide-react'
import { useMemo, useRef } from 'react'
import { Check, Copy, ExternalLink, Globe, Settings } from 'lucide-react'
import { toast } from 'sonner'
import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo'
import { Button } from '@/components/ui/button'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useClipboardTextCopyFeedback } from '@/hooks/use-clipboard-text-copy-feedback'
import { translate } from '@/i18n/i18n'
import { BROWSER_TERMINAL_LINK_ACTIONS_SETTINGS_TARGET_ID } from '@/lib/settings-navigation-types'
import { useAppStore } from '@/store'
@@ -48,6 +50,9 @@ export function TerminalLinkActionPopover({
}: TerminalLinkActionPopoverProps): React.JSX.Element {
const openSettingsPage = useAppStore((state) => state.openSettingsPage)
const openSettingsTarget = useAppStore((state) => state.openSettingsTarget)
const copyableDestination = request?.kind === 'url' ? request.destination : ''
const { copyText, status: copyStatus } = useClipboardTextCopyFeedback(copyableDestination)
const copyInFlightRef = useRef(false)
const virtualRef = useMemo(
() => ({
current: {
@@ -67,6 +72,36 @@ export function TerminalLinkActionPopover({
'auto.components.terminal.pane.TerminalLinkActionPopover.terminalLinkSettings',
'Terminal link settings'
)
const copyLabel =
copyStatus === 'copied'
? translate('auto.components.terminal.pane.TerminalLinkActionPopover.copied', 'Copied')
: translate('auto.components.terminal.pane.TerminalLinkActionPopover.copyLink', 'Copy link')
const copyDestination = async (): Promise<void> => {
if (copyInFlightRef.current) {
return
}
copyInFlightRef.current = true
try {
if (await copyText()) {
toast.success(
translate(
'auto.components.terminal.pane.TerminalLinkActionPopover.copiedLink',
'Copied link'
)
)
return
}
toast.error(
translate(
'auto.components.terminal.pane.TerminalLinkActionPopover.copyLinkFailed',
'Failed to copy link'
)
)
} finally {
copyInFlightRef.current = false
}
}
const openTerminalLinkSettings = (): void => {
onClose()
@@ -104,6 +139,24 @@ export function TerminalLinkActionPopover({
>
{request.destination}
</span>
{request.kind === 'url' ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label={copyLabel}
className="text-muted-foreground"
size="icon-xs"
variant="ghost"
onClick={() => void copyDestination()}
>
{copyStatus === 'copied' ? <Check /> : <Copy />}
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{copyLabel}
</TooltipContent>
</Tooltip>
) : null}
<Tooltip>
<TooltipTrigger asChild>
<Button
+5 -1
View File
@@ -2856,7 +2856,11 @@
"openTaskTerminal": "Open task terminal",
"systemBrowser": "System Browser",
"orcaBrowser": "Orca Browser",
"terminalLinkSettings": "Terminal link settings"
"terminalLinkSettings": "Terminal link settings",
"copyLink": "Copy link",
"copied": "Copied",
"copiedLink": "Copied link",
"copyLinkFailed": "Failed to copy link"
},
"TerminalSessionStateSaveFailureDialog": {
"6bee0c8f17": "Open Disk Space Analyzer",