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
20 changes: 18 additions & 2 deletions web/src/components/key-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,15 @@ export function KeyBar(props: {
ctrl: boolean
onCtrl: () => void
onKey: (key: BarKey) => void
onPaste: () => void
}) {
const chip = 'rounded-md px-2.5 py-1.5 font-mono text-sm/4 transition-colors select-none'
const chip =
'min-h-12 shrink-0 rounded-md px-3 py-1.5 font-mono text-sm/4 transition-colors select-none'
return (
<div
data-flue-keybar=""
className={cn(
'absolute bottom-3 left-1/2 z-10 flex -translate-x-1/2 items-center gap-x-1',
'absolute bottom-3 left-1/2 z-10 flex max-w-[calc(100%-1.5rem)] -translate-x-1/2 items-center gap-x-1 overflow-x-auto overscroll-x-contain',
'rounded-lg bg-(--chip-bg) p-1 shadow-lg ring-1 ring-(--chip-ring) backdrop-blur-sm',
)}
>
Expand All @@ -62,6 +64,20 @@ export function KeyBar(props: {
>
ctrl
</button>
<button
type="button"
title="Paste from clipboard"
onPointerDown={(e) => {
e.preventDefault()
props.onPaste()
}}
onClick={(e) => {
if (e.detail === 0) props.onPaste()
}}
className={cn(chip, 'text-(--chip-dim) hover:text-(--chip-fg) active:bg-(--chip-wash)')}
>
paste
</button>
{KEYS.map((k) => (
<button
key={k.key}
Expand Down
20 changes: 19 additions & 1 deletion web/src/components/session-switcher.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

import type { FleetSession } from '@/fleet/types'
import type { RecentVisit } from '@/switcher/recents'
Expand Down Expand Up @@ -88,6 +88,10 @@ describe('SessionSwitcher', () => {
)
})

afterEach(() => {
vi.unstubAllGlobals()
})

it('shows nothing at all while shut', () => {
mount({ open: false })
expect(screen.queryByRole('combobox')).toBeNull()
Expand All @@ -105,6 +109,20 @@ describe('SessionSwitcher', () => {
expect(screen.queryByText(/loading/i)).toBeNull()
})

it('does not summon the keyboard when opened on mobile', async () => {
vi.stubGlobal('innerWidth', 390)
mount()

const dialog = screen.getByRole('dialog')
await waitFor(() => expect(document.activeElement).toBe(dialog))
expect(dialog.className).toContain('max-md:max-w-none')
expect(screen.getByRole('listbox').parentElement!.className).toContain('min-w-0')

// Search remains available by explicit intent.
await userEvent.setup().click(field())
expect(document.activeElement).toBe(field())
})

it('reads pinned first, under its own heading, with the chord on the row', () => {
mount({ sessions: [s({ id: 'a', name: 'loose' }), s({ id: 'p', name: 'kept', pinned: true })] })
expect(screen.getByText('Pinned')).toBeTruthy()
Expand Down
29 changes: 20 additions & 9 deletions web/src/components/session-switcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ export function SessionSwitcher({
}: SessionSwitcherProps) {
const [search, setSearch] = useState('')
const [highlight, setHighlight] = useState<string | null>(null)
const dialogRef = useRef<HTMLDivElement>(null)
const mobile = useIsMobile()
const apple = useMemo(() => isApplePlatform(), [])

Expand Down Expand Up @@ -225,23 +226,33 @@ export function SessionSwitcher({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
ref={dialogRef}
showCloseButton={false}
aria-describedby={undefined}
onOpenAutoFocus={(e) => {
// Opening the switcher from its touch control is for choosing a row,
// not an implicit request to search. Keep the software keyboard down
// until the person explicitly taps the field.
if (mobile) {
e.preventDefault()
dialogRef.current?.focus({ preventScroll: true })
}
}}
// Anchored high rather than centred: a palette grows downwards as it
// fills, and one pinned to the middle of the window would slide its own
// search field up the screen while somebody types into it.
className={cn(
'top-[12vh] w-[56rem] translate-y-0 gap-0 overflow-hidden p-0 sm:max-w-[calc(100vw-2rem)]',
'max-h-[76vh] max-md:top-auto max-md:bottom-0 max-md:h-[85vh] max-md:max-h-none',
'max-md:w-full max-md:rounded-b-none',
'top-[12vh] w-[56rem] grid-rows-[auto_minmax(0,1fr)_auto] translate-y-0 gap-0 overflow-hidden p-0 sm:max-w-[calc(100vw-2rem)]',
'max-h-[76vh] max-md:top-auto max-md:bottom-0 max-md:h-[85dvh] max-md:max-h-none',
'max-md:w-full max-md:max-w-none max-md:rounded-b-none',
)}
>
<DialogTitle className="sr-only">Switch session</DialogTitle>

<div className="flex items-center gap-x-2.5 border-b border-hairline px-3.5">
<MagnifyingGlassIcon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
<input
autoFocus
autoFocus={!mobile}
type="text"
role="combobox"
aria-expanded="true"
Expand All @@ -258,10 +269,10 @@ export function SessionSwitcher({
/>
</div>

<div className="flex min-h-0 flex-1">
<div className="flex min-h-0 min-w-0 flex-1">
<div
className={cn(
'min-h-0 flex-1 overflow-y-auto py-1.5',
'min-h-0 min-w-0 flex-1 overflow-y-auto overscroll-contain py-1.5',
// The pane beside it only exists where there is width for one.
'md:w-[24rem] md:flex-none md:border-r md:border-hairline',
)}
Expand Down Expand Up @@ -328,7 +339,7 @@ export function SessionSwitcher({
</div>
</div>

<div className="flex flex-wrap items-center gap-x-4 gap-y-1 border-t border-hairline bg-muted/40 px-3.5 py-2 text-[0.6875rem] text-muted-foreground">
<div className="hidden flex-wrap items-center gap-x-4 gap-y-1 border-t border-hairline bg-muted/40 px-3.5 py-2 text-[0.6875rem] text-muted-foreground md:flex">
<Hint keys="↑↓" what="move" />
<Hint keys="↵" what="open" />
<Hint keys={apple ? '⌃⇧1-9' : 'Ctrl+Shift+1-9'} what="pinned" />
Expand Down Expand Up @@ -386,7 +397,7 @@ function Row({
onPointerMove={onHighlight}
onClick={onPick}
className={cn(
'relative flex h-8 cursor-pointer items-center gap-x-2.5 px-3.5 text-control',
'relative flex h-12 cursor-pointer items-center gap-x-2.5 px-3.5 text-base md:h-8 md:text-control',
active && 'bg-row-hover',
)}
>
Expand All @@ -408,7 +419,7 @@ function Row({
>
{rowLabel(row)}
</span>
<span className="truncate font-mono text-xs text-muted-foreground">{rowCwd(row)}</span>
<span className="hidden truncate font-mono text-xs text-muted-foreground sm:inline">{rowCwd(row)}</span>
<span className="ml-auto flex shrink-0 items-center gap-x-2 pl-2">
{row.current && <span className="text-xs text-muted-foreground">current</span>}
<span className="font-mono text-xs text-muted-foreground">{rowMachine(row)}</span>
Expand Down
88 changes: 87 additions & 1 deletion web/src/components/terminal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { createFakeEmulator, type FakeEmulator } from '@/testing/emulator'
import { attached, fakeClient, sizeChanged, type FakeSocket } from '@/testing/socket'
import { RESIZE_SETTLE_MS, Terminal, TERMINAL_SHORTCUT_HINT } from './terminal'

const REAL_CLIPBOARD = Object.getOwnPropertyDescriptor(navigator, 'clipboard')

/**
* One emulator per mount, all of them kept.
*
Expand Down Expand Up @@ -117,6 +119,8 @@ afterEach(() => {
document.documentElement.style.backgroundColor = ''
document.body.style.backgroundColor = ''
localStorage.clear()
if (REAL_CLIPBOARD) Object.defineProperty(navigator, 'clipboard', REAL_CLIPBOARD)
else Reflect.deleteProperty(navigator, 'clipboard')
})

/**
Expand Down Expand Up @@ -636,6 +640,33 @@ describe('Terminal', () => {
expect(move.defaultPrevented).toBe(true)
})

it('scrolls when the gesture starts in usable pane space outside xterm', () => {
// A cross-device screen can be scaled smaller than its inset, and the
// inset itself has breathing room around the cells. The whole terminal
// area must scroll; whether the first pixel hit xterm cannot decide it.
const { em } = mountDraggable()

act(() => void inset().dispatchEvent(touch('touchstart', [200])))
const move = touch('touchmove', [166])
act(() => void inset().dispatchEvent(move))

expect(em.live().scrolled).toBe(2)
expect(move.defaultPrevented).toBe(true)
})

it('does not resume a one-finger drag after a second finger interrupts it', () => {
const { em } = mountDraggable()
const surface = surfaceEl()

act(() => {
surface.dispatchEvent(touch('touchstart', [200]))
surface.dispatchEvent(touch('touchmove', [166, 240]))
surface.dispatchEvent(touch('touchmove', [140]))
})

expect(em.live().scrolled).toBe(0)
})

it('leaves the gesture to the browser while the page is pinch-zoomed', () => {
// Releasing touch-action cannot deliver this on its own. It only tells
// the browser it *may* pan; the preventDefault() above cancels that pan
Expand Down Expand Up @@ -1430,6 +1461,61 @@ describe('Terminal', () => {
expect(key('ctrl').getAttribute('aria-pressed')).toBe('false')
})

it('pastes through the emulator from the mobile clipboard', async () => {
coarsePointer()
const readText = vi.fn().mockResolvedValue('git status\n')
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { readText },
})
const { sock, em } = mountTerminal((e) => (
<Terminal sessionId="s1" createEmulator={e.create} />
))
act(() => sock.emitControl(attached({ ref: 1, id: 's1' })))

fireEvent.pointerDown(key('paste'))

await waitFor(() => expect(em.live().pasted).toEqual(['git status\n']))
expect(readText).toHaveBeenCalledTimes(1)
expect(sock.input()).toEqual([{ ref: 1, text: 'git status\n' }])
})

it('does not apply the latched Ctrl modifier to a one-character paste', async () => {
coarsePointer()
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { readText: vi.fn().mockResolvedValue('c') },
})
const { sock } = mountTerminal((e) => (
<Terminal sessionId="s1" createEmulator={e.create} />
))
act(() => sock.emitControl(attached({ ref: 1, id: 's1' })))

fireEvent.pointerDown(key('ctrl'))
fireEvent.pointerDown(key('paste'))

await waitFor(() => expect(sock.input()).toEqual([{ ref: 1, text: 'c' }]))
expect(key('ctrl').getAttribute('aria-pressed')).toBe('false')
})

it('explains when the browser refuses the clipboard', async () => {
coarsePointer()
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { readText: vi.fn().mockRejectedValue(new Error('denied')) },
})
const { sock } = mountTerminal((e) => (
<Terminal sessionId="s1" createEmulator={e.create} />
))
act(() => sock.emitControl(attached({ ref: 1, id: 's1' })))

fireEvent.pointerDown(key('paste'))

expect((await screen.findByRole('alert')).textContent).toBe(
'This browser would not hand flue the clipboard.',
)
})

it('drops bar keys pressed before the attach comes back', () => {
coarsePointer()
const { sock } = mountTerminal((e) => <Terminal sessionId="s1" createEmulator={e.create} />)
Expand All @@ -1440,7 +1526,7 @@ describe('Terminal', () => {
it('reserves bottom room in the inset so the bar covers no rows', () => {
coarsePointer()
mountTerminal((e) => <Terminal sessionId="s1" createEmulator={e.create} />)
expect(inset().className).toContain('bottom-16')
expect(inset().className).toContain('bottom-20')
})
})
})
Expand Down
Loading