fix(web): harden clipboard copy and terminal paste flows (#187)

This commit is contained in:
gaius-codius
2026-02-19 08:08:52 +08:00
committed by GitHub
parent c8682047bf
commit 77cfa715f9
7 changed files with 367 additions and 10 deletions
+56
View File
@@ -0,0 +1,56 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { safeCopyToClipboard } from './clipboard'
describe('safeCopyToClipboard', () => {
beforeEach(() => {
vi.restoreAllMocks()
Object.defineProperty(document, 'execCommand', {
configurable: true,
writable: true,
value: vi.fn(() => false)
})
})
it('uses navigator clipboard writeText when available', async () => {
const writeText = vi.fn(async () => {})
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText }
})
const execCommand = vi.mocked(document.execCommand)
execCommand.mockReturnValue(true)
await safeCopyToClipboard('hello')
expect(writeText).toHaveBeenCalledWith('hello')
expect(execCommand).not.toHaveBeenCalled()
})
it('falls back to execCommand when clipboard api write fails', async () => {
const writeText = vi.fn(async () => {
throw new Error('clipboard denied')
})
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText }
})
const execCommand = vi.mocked(document.execCommand)
execCommand.mockReturnValue(true)
await safeCopyToClipboard('fallback')
expect(writeText).toHaveBeenCalledWith('fallback')
expect(execCommand).toHaveBeenCalledWith('copy')
})
it('throws when both modern and legacy copy strategies fail', async () => {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: undefined
})
const execCommand = vi.mocked(document.execCommand)
execCommand.mockReturnValue(false)
await expect(safeCopyToClipboard('x')).rejects.toThrow('Copy to clipboard failed')
})
})
+60 -4
View File
@@ -1,6 +1,62 @@
export function safeCopyToClipboard(text: string): Promise<void> {
if (navigator.clipboard?.writeText) {
return navigator.clipboard.writeText(text)
function copyWithExecCommand(text: string): boolean {
if (typeof document === 'undefined' || !document.body) {
return false
}
return Promise.reject(new Error('Clipboard API not available'))
const textarea = document.createElement('textarea')
textarea.value = text
textarea.setAttribute('readonly', 'true')
textarea.style.position = 'fixed'
textarea.style.top = '0'
textarea.style.left = '0'
textarea.style.width = '1px'
textarea.style.height = '1px'
textarea.style.padding = '0'
textarea.style.border = '0'
textarea.style.opacity = '0'
textarea.style.pointerEvents = 'none'
const activeElement = document.activeElement instanceof HTMLElement ? document.activeElement : null
const selection = document.getSelection()
const previousRange = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null
document.body.appendChild(textarea)
textarea.focus()
textarea.select()
textarea.setSelectionRange(0, textarea.value.length)
let copied = false
try {
copied = document.execCommand('copy')
} catch {
copied = false
} finally {
document.body.removeChild(textarea)
if (selection) {
selection.removeAllRanges()
if (previousRange) {
selection.addRange(previousRange)
}
}
activeElement?.focus()
}
return copied
}
export async function safeCopyToClipboard(text: string): Promise<void> {
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text)
return
} catch {
// Fall through to legacy copy strategy.
}
}
if (copyWithExecCommand(text)) {
return
}
throw new Error('Copy to clipboard failed')
}
+4
View File
@@ -88,6 +88,7 @@ export default {
'button.close': 'Close',
'button.dismiss': 'Dismiss',
'button.copy': 'Copy',
'button.paste': 'Paste',
// New session form
'newSession.machine': 'Machine',
@@ -136,6 +137,9 @@ export default {
'terminal.commandArgs': 'Command args',
'terminal.stdout': 'Stdout',
'terminal.stderr': 'Stderr',
'terminal.paste.fallbackTitle': 'Paste input',
'terminal.paste.fallbackDescription': 'Clipboard read is unavailable. Paste your text below.',
'terminal.paste.placeholder': 'Paste terminal input here…',
// Code block
'code.copy': 'Copy',
+4
View File
@@ -90,6 +90,7 @@ export default {
'button.close': '关闭',
'button.dismiss': '忽略',
'button.copy': '复制',
'button.paste': '粘贴',
// New session form
'newSession.machine': '机器',
@@ -138,6 +139,9 @@ export default {
'terminal.commandArgs': '命令参数',
'terminal.stdout': '标准输出',
'terminal.stderr': '标准错误',
'terminal.paste.fallbackTitle': '粘贴输入',
'terminal.paste.fallbackDescription': '无法读取剪贴板,请在下方粘贴文本。',
'terminal.paste.placeholder': '在此粘贴终端输入…',
// Code block
'code.copy': '复制',
+33 -6
View File
@@ -11,6 +11,8 @@ import { queryKeys } from '@/lib/query-keys'
import { langAlias, useShikiHighlighter } from '@/lib/shiki'
import { decodeBase64 } from '@/lib/utils'
const MAX_COPYABLE_FILE_BYTES = 1_000_000
function decodePath(value: string): string {
if (!value) return ''
const decoded = decodeBase64(value)
@@ -94,6 +96,10 @@ function resolveLanguage(path: string): string | undefined {
return langAlias[ext] ?? ext
}
function getUtf8ByteLength(value: string): number {
return new TextEncoder().encode(value).length
}
function isBinaryContent(content: string): boolean {
if (!content) return false
if (content.includes('\0')) return true
@@ -112,7 +118,8 @@ function extractCommandError(result: GitCommandResponse | undefined): string | n
export default function FilePage() {
const { api } = useAppContext()
const { copied, copy } = useCopyToClipboard()
const { copied: pathCopied, copy: copyPath } = useCopyToClipboard()
const { copied: contentCopied, copy: copyContent } = useCopyToClipboard()
const goBack = useAppGoBack()
const { sessionId } = useParams({ from: '/sessions/$sessionId/file' })
const search = useSearch({ from: '/sessions/$sessionId/file' })
@@ -160,6 +167,14 @@ export default function FilePage() {
const language = useMemo(() => resolveLanguage(filePath), [filePath])
const highlighted = useShikiHighlighter(decodedContent, language)
const contentSizeBytes = useMemo(
() => (decodedContent ? getUtf8ByteLength(decodedContent) : 0),
[decodedContent]
)
const canCopyContent = fileContentResult?.success === true
&& !binaryFile
&& decodedContent.length > 0
&& contentSizeBytes <= MAX_COPYABLE_FILE_BYTES
const [displayMode, setDisplayMode] = useState<'diff' | 'file'>('diff')
@@ -204,11 +219,11 @@ export default function FilePage() {
<span className="min-w-0 flex-1 truncate text-xs text-[var(--app-hint)]">{filePath}</span>
<button
type="button"
onClick={() => copy(filePath)}
onClick={() => copyPath(filePath)}
className="shrink-0 rounded p-1 text-[var(--app-hint)] hover:bg-[var(--app-subtle-bg)] hover:text-[var(--app-fg)] transition-colors"
title="Copy path"
>
{copied ? <CheckIcon className="h-3.5 w-3.5" /> : <CopyIcon className="h-3.5 w-3.5" />}
{pathCopied ? <CheckIcon className="h-3.5 w-3.5" /> : <CopyIcon className="h-3.5 w-3.5" />}
</button>
</div>
</div>
@@ -257,9 +272,21 @@ export default function FilePage() {
<div className="text-sm text-[var(--app-hint)]">{diffError}</div>
) : displayMode === 'file' ? (
decodedContent ? (
<pre className="shiki overflow-auto rounded-md bg-[var(--app-code-bg)] p-3 text-xs font-mono">
<code>{highlighted ?? decodedContent}</code>
</pre>
<div className="relative">
{canCopyContent ? (
<button
type="button"
onClick={() => copyContent(decodedContent)}
className="absolute right-2 top-2 z-10 rounded p-1 text-[var(--app-hint)] hover:bg-[var(--app-subtle-bg)] hover:text-[var(--app-fg)] transition-colors"
title="Copy file content"
>
{contentCopied ? <CheckIcon className="h-3.5 w-3.5" /> : <CopyIcon className="h-3.5 w-3.5" />}
</button>
) : null}
<pre className="shiki overflow-auto rounded-md bg-[var(--app-code-bg)] p-3 pr-8 text-xs font-mono">
<code>{highlighted ?? decodedContent}</code>
</pre>
</div>
) : (
<div className="text-sm text-[var(--app-hint)]">File is empty.</div>
)
+100
View File
@@ -0,0 +1,100 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { I18nProvider } from '@/lib/i18n-context'
import TerminalPage from './terminal'
const writeMock = vi.fn()
vi.mock('@tanstack/react-router', () => ({
useParams: () => ({ sessionId: 'session-1' })
}))
vi.mock('@/lib/app-context', () => ({
useAppContext: () => ({
api: null,
token: 'test-token',
baseUrl: 'http://localhost:3000'
})
}))
vi.mock('@/hooks/useAppGoBack', () => ({
useAppGoBack: () => vi.fn()
}))
vi.mock('@/hooks/queries/useSession', () => ({
useSession: () => ({
session: {
id: 'session-1',
active: true,
metadata: { path: '/tmp/project' }
}
})
}))
vi.mock('@/hooks/useTerminalSocket', () => ({
useTerminalSocket: () => ({
state: { status: 'connected' as const },
connect: vi.fn(),
write: writeMock,
resize: vi.fn(),
disconnect: vi.fn(),
onOutput: vi.fn(),
onExit: vi.fn()
})
}))
vi.mock('@/hooks/useLongPress', () => ({
useLongPress: ({ onClick }: { onClick: () => void }) => ({
onClick
})
}))
vi.mock('@/components/Terminal/TerminalView', () => ({
TerminalView: () => <div data-testid="terminal-view" />
}))
function renderWithProviders() {
return render(
<I18nProvider>
<TerminalPage />
</I18nProvider>
)
}
describe('TerminalPage paste behavior', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('does not open manual paste dialog when clipboard text is empty', async () => {
const readText = vi.fn(async () => '')
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { readText }
})
renderWithProviders()
fireEvent.click(screen.getAllByRole('button', { name: 'Paste' })[0])
await waitFor(() => {
expect(readText).toHaveBeenCalledTimes(1)
})
expect(writeMock).not.toHaveBeenCalled()
expect(screen.queryByText('Paste input')).not.toBeInTheDocument()
})
it('opens manual paste dialog when clipboard read fails', async () => {
const readText = vi.fn(async () => {
throw new Error('blocked')
})
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { readText }
})
renderWithProviders()
fireEvent.click(screen.getAllByRole('button', { name: 'Paste' })[0])
expect(await screen.findByText('Paste input')).toBeInTheDocument()
})
})
+110
View File
@@ -7,8 +7,17 @@ import { useAppGoBack } from '@/hooks/useAppGoBack'
import { useSession } from '@/hooks/queries/useSession'
import { useTerminalSocket } from '@/hooks/useTerminalSocket'
import { useLongPress } from '@/hooks/useLongPress'
import { useTranslation } from '@/lib/use-translation'
import { TerminalView } from '@/components/Terminal/TerminalView'
import { LoadingState } from '@/components/LoadingState'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
function BackIcon() {
return (
<svg
@@ -170,6 +179,7 @@ function QuickKeyButton(props: {
}
export default function TerminalPage() {
const { t } = useTranslation()
const { sessionId } = useParams({ from: '/sessions/$sessionId/terminal' })
const { api, token, baseUrl } = useAppContext()
const goBack = useAppGoBack()
@@ -188,6 +198,8 @@ export default function TerminalPage() {
const [exitInfo, setExitInfo] = useState<{ code: number | null; signal: string | null } | null>(null)
const [ctrlActive, setCtrlActive] = useState(false)
const [altActive, setAltActive] = useState(false)
const [pasteDialogOpen, setPasteDialogOpen] = useState(false)
const [manualPasteText, setManualPasteText] = useState('')
const {
state: terminalState,
@@ -312,6 +324,48 @@ export default function TerminalPage() {
}, [terminalState.status])
const quickInputDisabled = !session?.active || terminalState.status !== 'connected'
const writePlainInput = useCallback((text: string) => {
if (!text || quickInputDisabled) {
return false
}
write(text)
resetModifiers()
terminalRef.current?.focus()
return true
}, [quickInputDisabled, write, resetModifiers])
const handlePasteAction = useCallback(async () => {
if (quickInputDisabled) {
return
}
const readClipboard = navigator.clipboard?.readText
if (readClipboard) {
try {
const clipboardText = await readClipboard.call(navigator.clipboard)
if (!clipboardText) {
return
}
if (writePlainInput(clipboardText)) {
return
}
} catch {
// Fall through to manual paste modal.
}
}
setManualPasteText('')
setPasteDialogOpen(true)
}, [quickInputDisabled, writePlainInput])
const handleManualPasteSubmit = useCallback(() => {
if (!manualPasteText.trim()) {
return
}
if (writePlainInput(manualPasteText)) {
setPasteDialogOpen(false)
setManualPasteText('')
}
}, [manualPasteText, writePlainInput])
const handleQuickInput = useCallback(
(sequence: string) => {
if (quickInputDisabled) {
@@ -406,6 +460,16 @@ export default function TerminalPage() {
<div className="bg-[var(--app-bg)] border-t border-[var(--app-border)] pb-[env(safe-area-inset-bottom)]">
<div className="mx-auto w-full max-w-content px-3">
<div className="flex flex-col gap-2 py-2">
<button
type="button"
onClick={() => {
void handlePasteAction()
}}
disabled={quickInputDisabled}
className="w-full rounded-md border border-[var(--app-border)] bg-[var(--app-secondary-bg)] px-3 py-2 text-sm font-medium text-[var(--app-fg)] transition-colors hover:bg-[var(--app-subtle-bg)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--app-button)] disabled:cursor-not-allowed disabled:opacity-50"
>
{t('button.paste')}
</button>
{QUICK_INPUT_ROWS.map((row, rowIndex) => (
<div
key={`terminal-quick-row-${rowIndex}`}
@@ -432,6 +496,52 @@ export default function TerminalPage() {
</div>
</div>
</div>
<Dialog
open={pasteDialogOpen}
onOpenChange={(open) => {
setPasteDialogOpen(open)
if (!open) {
setManualPasteText('')
}
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t('terminal.paste.fallbackTitle')}</DialogTitle>
<DialogDescription>
{t('terminal.paste.fallbackDescription')}
</DialogDescription>
</DialogHeader>
<textarea
value={manualPasteText}
onChange={(event) => setManualPasteText(event.target.value)}
placeholder={t('terminal.paste.placeholder')}
className="mt-2 min-h-32 w-full resize-y rounded-md border border-[var(--app-border)] bg-[var(--app-bg)] p-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--app-link)]"
autoCapitalize="none"
autoCorrect="off"
/>
<div className="mt-3 flex justify-end gap-2">
<Button
type="button"
variant="secondary"
onClick={() => {
setPasteDialogOpen(false)
setManualPasteText('')
}}
>
{t('button.cancel')}
</Button>
<Button
type="button"
onClick={handleManualPasteSubmit}
disabled={!manualPasteText.trim()}
>
{t('button.paste')}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
)
}