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:
@@ -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