Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions web/src/lib/clipboard.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
64 changes: 60 additions & 4 deletions web/src/lib/clipboard.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 4 additions & 0 deletions web/src/lib/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export default {
'button.close': 'Close',
'button.dismiss': 'Dismiss',
'button.copy': 'Copy',
'button.paste': 'Paste',

// New session form
'newSession.machine': 'Machine',
Expand Down Expand Up @@ -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',
Expand Down
4 changes: 4 additions & 0 deletions web/src/lib/locales/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ export default {
'button.close': '关闭',
'button.dismiss': '忽略',
'button.copy': '复制',
'button.paste': '粘贴',

// New session form
'newSession.machine': '机器',
Expand Down Expand Up @@ -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': '复制',
Expand Down
39 changes: 33 additions & 6 deletions web/src/routes/sessions/file.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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' })
Expand Down Expand Up @@ -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')

Expand Down Expand Up @@ -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>
Expand Down Expand Up @@ -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>
)
Expand Down
100 changes: 100 additions & 0 deletions web/src/routes/sessions/terminal.test.tsx
Original file line number Diff line number Diff line change
@@ -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()
})
})
Loading