mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
feat(terminal): add copy action to link popover (#13857)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user