mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-07 06:52:28 +00:00
fix(web): harden clipboard copy and terminal paste flows (#187)
This commit is contained in:
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '复制',
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user