diff --git a/web/src/lib/clipboard.test.ts b/web/src/lib/clipboard.test.ts new file mode 100644 index 00000000..2a28276c --- /dev/null +++ b/web/src/lib/clipboard.test.ts @@ -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') + }) +}) diff --git a/web/src/lib/clipboard.ts b/web/src/lib/clipboard.ts index 79dc74b7..38a29e87 100644 --- a/web/src/lib/clipboard.ts +++ b/web/src/lib/clipboard.ts @@ -1,6 +1,62 @@ -export function safeCopyToClipboard(text: string): Promise { - 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 { + 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') } diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 83275612..342ac5c4 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -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', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 48c75d51..33e5b1f3 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -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': '复制', diff --git a/web/src/routes/sessions/file.tsx b/web/src/routes/sessions/file.tsx index 179a29b1..dd3a96bb 100644 --- a/web/src/routes/sessions/file.tsx +++ b/web/src/routes/sessions/file.tsx @@ -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() { {filePath} @@ -257,9 +272,21 @@ export default function FilePage() {
{diffError}
) : displayMode === 'file' ? ( decodedContent ? ( -
-                                {highlighted ?? decodedContent}
-                            
+
+ {canCopyContent ? ( + + ) : null} +
+                                    {highlighted ?? decodedContent}
+                                
+
) : (
File is empty.
) diff --git a/web/src/routes/sessions/terminal.test.tsx b/web/src/routes/sessions/terminal.test.tsx new file mode 100644 index 00000000..2f294342 --- /dev/null +++ b/web/src/routes/sessions/terminal.test.tsx @@ -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: () =>
+})) + +function renderWithProviders() { + return render( + + + + ) +} + +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() + }) +}) diff --git a/web/src/routes/sessions/terminal.tsx b/web/src/routes/sessions/terminal.tsx index 5ccc9fe4..0aba34b6 100644 --- a/web/src/routes/sessions/terminal.tsx +++ b/web/src/routes/sessions/terminal.tsx @@ -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 ( (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() {
+ {QUICK_INPUT_ROWS.map((row, rowIndex) => (
+ + { + setPasteDialogOpen(open) + if (!open) { + setManualPasteText('') + } + }} + > + + + {t('terminal.paste.fallbackTitle')} + + {t('terminal.paste.fallbackDescription')} + + +