diff --git a/web/src/files/contents.test.tsx b/web/src/files/contents.test.tsx new file mode 100644 index 0000000..a9dbe89 --- /dev/null +++ b/web/src/files/contents.test.tsx @@ -0,0 +1,101 @@ +import { act, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { FlueClientProvider } from '@/client/provider' +import { fakeClient, type FakeSocket } from '@/testing/socket' +import { FileContents, type FileTarget } from './contents' + +const openContents = (target: FileTarget, header?: Parameters[0]['header']) => { + // jsdom lays nothing out, and the virtualizer windows on measured boxes — + // the same pretend geometry viewer.test.tsx uses. + vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(480) + vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(800) + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({ + width: 800, + height: 480, + top: 0, + left: 0, + right: 800, + bottom: 480, + x: 0, + y: 0, + toJSON: () => ({}), + } as DOMRect) + const { client, last } = fakeClient() + client.connect() + const sock = last() + act(() => sock.open()) + const view = render( + + + , + ) + const sent = sock.control().find((m) => m.type === 'read') + return { sock, sent, view, client } +} + +const served = (sock: FakeSocket, reqId: unknown, over: Record = {}) => + act(() => { + sock.emitControl({ + type: 'file', + ref: 7, + path: '/home/k/proj/a.go', + size: 22, + mime: 'text/plain; charset=utf-8', + kind: 'text', + reqId: reqId as number, + ...over, + }) + }) + +const flowed = async (sock: FakeSocket, body: string) => { + await act(async () => { + sock.emitFile(7, body) + await new Promise((frame) => requestAnimationFrame(frame)) + }) +} + +afterEach(() => vi.restoreAllMocks()) + +describe('FileContents', () => { + it('asks for the file and says so while opening', () => { + const { sent } = openContents({ path: 'a.go' }) + expect(sent).toMatchObject({ type: 'read', id: 's1', path: 'a.go' }) + expect(screen.getByRole('status').textContent).toMatch(/Opening/) + }) + + it('paints streamed chunks as they arrive, then finishes on eof', async () => { + const { sock, sent } = openContents({ path: 'notes.txt' }) + served(sock, sent!.reqId, { path: '/home/k/notes.txt' }) + await flowed(sock, 'package main\n\nfunc main()') + expect(screen.getByText('package main')).toBeTruthy() + await act(async () => { + sock.emitControl({ type: 'eof', ref: 7 }) + await new Promise((frame) => requestAnimationFrame(frame)) + }) + expect(screen.getByText('func main()')).toBeTruthy() + }) + + it('turns a refusal into words', () => { + const { sock, sent } = openContents({ path: 'web/src/' }) + act(() => + sock.emitControl({ + type: 'error', + code: 'is_dir', + msg: 'that is a directory', + reqId: sent!.reqId as number, + }), + ) + expect(screen.getByRole('alert').textContent).toMatch(/directory/) + }) + + it('hands the header slot the resolved name, directory and size', () => { + const { sock, sent } = openContents({ path: 'a.go' }, (view) => ( +
+ {view.base} in {view.dir} at {view.meta === null ? '' : String(view.meta.size)} +
+ )) + served(sock, sent!.reqId) + expect(screen.getByRole('banner').textContent).toBe('a.go in /home/k/proj at 22') + }) +}) diff --git a/web/src/files/contents.tsx b/web/src/files/contents.tsx new file mode 100644 index 0000000..2c4c801 --- /dev/null +++ b/web/src/files/contents.tsx @@ -0,0 +1,670 @@ +import { useVirtualizer } from '@tanstack/react-virtual' +import { Check, Copy, FileText, WrapText } from 'lucide-react' +import { + lazy, + Suspense, + useEffect, + useMemo, + useRef, + useState, + type CSSProperties, + type Dispatch, + type ReactNode, + type SetStateAction, +} from 'react' + +import { useFlueClient } from '@/client/provider' +import type { FileMsg, PathEntry } from '@/client/protocol' +import type { ReadHandle } from '@/client/client' +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' +import { cachedFile, rememberFile, CACHE_ENTRY_MAX } from './cache' +import { HIGHLIGHT_MAX_BYTES, HIGHLIGHT_MAX_LINES } from './caps' +import { highlight } from './highlight' +import { languageFor } from './lang' +import type { PeekToken } from './tokenize' + +export interface FileTarget { + path: string + line?: number + col?: number +} + +/** An unwrapped monospace row's height (leading-5), the size estimate the + * virtualizer starts from; a wrapped row measures taller and is measured. */ +const LINE_PX = 20 + +/** The daemon's ceiling on text; past it only the head was sent. */ +const TEXT_CAP = 8 << 20 + +/** + * The most characters one rendered row may carry. File content is untrusted, + * and without this an 8 MiB single-line file is one eight-million-character + * DOM row — and a stream with no newline in it would paint nothing at all + * until eof. A longer line continues on the next row; for a file holding + * lines that size, the row numbering is presentation, not truth. + */ +const ROW_CAP = 8192 + +const REFUSALS: Record = { + not_found: 'Nothing at this path under the session.', + is_dir: 'That path is a directory.', + too_large: 'This image is too large to send.', + denied: 'The machine may not read this file.', + busy: 'Two files are already streaming from this machine. Close one first.', + unsupported: 'Neither text nor an image, so nothing sensible to show.', + bad_path: 'Not a usable path.', + timeout: 'The machine did not answer in time.', + lost: 'The connection dropped before the file arrived whole.', +} + +type Phase = + | { at: 'opening' } + | { at: 'text'; meta: FileMsg; done: boolean } + | { at: 'image'; meta: FileMsg } + | { at: 'refused'; code: string } + +/** How the body of a markdown file shows: as a page, or as its lines. */ +type BodyMode = 'rendered' | 'raw' + +/** Loaded on the first markdown file, beside shiki, never in the bundle. */ +const MarkdownLazy = lazy(() => + import('./markdown').then((m) => ({ default: m.MarkdownView })), +) + +/** + * Above this size a markdown file opens raw, because rendering parses the + * whole document on the main thread in one go. Content is untrusted and one + * click must not buy a multi-second freeze; the Rendered press stays + * offered, so a reader who wants the page still gets it — knowingly. + */ +const RENDER_DEFAULT_MAX = 512 << 10 + +/** + * What the surrounding chrome may read and steer: the resolved identity of + * the file for a title row, and the two presentation switches whose buttons + * live beside it rather than in the body. + */ +export interface FileContentsView { + meta: FileMsg | null + shownPath: string + base: string + dir: string + renderable: boolean + mode: BodyMode + setMode: Dispatch> + showsLines: boolean + wrap: boolean + setWrap: Dispatch> +} + +export interface FileContentsProps { + sessionId: string + target: FileTarget + /** A title row rendered above the body, handed the view so its controls + * and the body agree. The dialog puts its own close affordance here; the + * full-page route takes the plain FileHeader. */ + header?: (view: FileContentsView) => ReactNode +} + +/** + * A file over the session: the answer to clicking a path the session named. + * + * Content paints as it arrives — the head of a large file is on screen before + * the tail has left the daemon, which over a relay is the difference between + * reading now and watching a spinner. Painting is paced to one state change + * per animation frame so a fast stream cannot outrun the renderer. A complete + * file colours afterwards, off the main thread, and is remembered in memory + * so reopening it costs one stat and no read. + * + * Chrome-free on purpose: the modal viewer wraps it in a dialog and the + * full-page route in a page, and both hand their title rows in through + * `header`. The parent supplies the flex column the body's flex-1 fills. + */ +export function FileContents({ sessionId, target, header }: FileContentsProps) { + const client = useFlueClient() + const [phase, setPhase] = useState({ at: 'opening' }) + const [tokens, setTokens] = useState(null) + const [imageUrl, setImageUrl] = useState(null) + const [wrap, setWrap] = useState(true) + const [mode, setMode] = useState('raw') + const [, setPainted] = useState(0) + const linesRef = useRef([]) + const frame = useRef(0) + + useEffect(() => { + let gone = false + linesRef.current = [] + setPhase({ at: 'opening' }) + setTokens(null) + setImageUrl(null) + const decoder = new TextDecoder() + const parts: Uint8Array[] = [] + let tail = '' + let meta: FileMsg | null = null + let handle: ReadHandle | null = null + + const repaint = () => { + if (frame.current !== 0) return + frame.current = requestAnimationFrame(() => { + frame.current = 0 + setPainted((n) => n + 1) + }) + } + const emit = (row: string) => { + const clean = row.endsWith('\r') ? row.slice(0, -1) : row + if (clean.length <= ROW_CAP) { + linesRef.current.push(clean) + return + } + for (let at = 0; at < clean.length; at += ROW_CAP) { + linesRef.current.push(clean.slice(at, at + ROW_CAP)) + } + } + const push = (piece: string) => { + if (piece === '') return + const rows = (tail + piece).split('\n') + tail = rows.pop() ?? '' + for (const r of rows) emit(r) + // A newline may never come. Overflow leaves the tail as finished rows, + // which both bounds the tail and lets a newline-less stream paint. + while (tail.length >= ROW_CAP) { + linesRef.current.push(tail.slice(0, ROW_CAP)) + tail = tail.slice(ROW_CAP) + } + } + + const deliverMeta = (m: FileMsg) => { + meta = m + if (gone) return + setPhase(m.kind === 'image' ? { at: 'image', meta: m } : { at: 'text', meta: m, done: false }) + // A whole markdown file opens as a page; a click that named a line + // opens on the lines the number means, with the page one press away. + setMode( + m.kind === 'text' && + m.truncated !== true && + m.size <= RENDER_DEFAULT_MAX && + target.line === undefined && + languageFor(m.path) === 'markdown' + ? 'rendered' + : 'raw', + ) + } + const deliverChunk = (bytes: Uint8Array) => { + parts.push(bytes) + if (meta?.kind !== 'image') { + push(decoder.decode(bytes, { stream: true })) + repaint() + } + } + // Keyed per session as well as per clicked text: a relative path + // resolves against a session's live working directory, so the same + // spelling in another session may name a different file entirely. + const cacheKey = `${sessionId}\u0000${target.path}` + const statOne = () => client.stat(sessionId, [target.path]).then(([e]) => e) + const deliverEof = (before: Promise | null) => { + if (meta?.kind === 'image') { + if (!gone) setImageUrl(assembleDataUrl(meta.mime, parts)) + } else { + push(decoder.decode()) + if (tail !== '') { + linesRef.current.push(tail) + tail = '' + } + setPhase((p) => (p.at === 'text' ? { ...p, done: true } : p)) + repaint() + } + if (before === null || meta === null || meta.truncated === true) return + const total = parts.reduce((n, p) => n + p.length, 0) + if (total > CACHE_ENTRY_MAX) return + // Remembered only when the stat taken as the read began and the stat + // taken after it agree on size and stamp, and both agree with the + // stream's own size. mtime is unix seconds, so a same-second edit can + // still slip this — but an edit during the stream cannot, which is + // what an after-only check got wrong: it blessed post-edit bytes with + // a post-edit stamp and served them stale on every reopen. + const settled = meta + void Promise.all([before, statOne()]) + .then(([pre, post]) => { + if (pre?.exists !== true || pre.kind !== 'file') return + if (post?.exists !== true || post.kind !== 'file') return + if ((pre.size ?? 0) !== settled.size || (post.size ?? 0) !== settled.size) return + if ((pre.mtime ?? 0) !== (post.mtime ?? 0)) return + rememberFile(client, cacheKey, { + meta: settled, + mtime: post.mtime ?? 0, + bytes: joinBytes(parts, total), + }) + }) + .catch(() => {}) + } + + const readFromWire = (before: Promise | null) => { + if (gone) return + handle = client.read(sessionId, target.path, { + file: deliverMeta, + chunk: deliverChunk, + eof: () => deliverEof(before), + fail: (f) => { + if (!gone) setPhase({ at: 'refused', code: f.code }) + }, + }) + } + + const start = async () => { + const held = cachedFile(client, cacheKey) + if (held === null) { + // The before-stat rides in parallel with the read, so the cache's + // integrity costs the open no latency at all. + readFromWire(statOne().catch(() => undefined)) + return + } + let confirmed: PathEntry | undefined + try { + confirmed = await statOne() + if (gone) return + if ( + confirmed?.exists === true && + confirmed.kind === 'file' && + (confirmed.size ?? 0) === held.meta.size && + (confirmed.mtime ?? 0) === held.mtime + ) { + deliverMeta(held.meta) + deliverChunk(held.bytes) + deliverEof(null) + return + } + } catch { + // The stat could not confirm the memory; the wire is the truth, and + // with nothing to agree with, this read is not remembered either. + } + if (gone) return + readFromWire(confirmed === undefined ? null : Promise.resolve(confirmed)) + } + void start() + + return () => { + gone = true + handle?.cancel() + if (frame.current !== 0) cancelAnimationFrame(frame.current) + frame.current = 0 + } + }, [client, sessionId, target.path]) + + // Colouring waits for the view that wants it: a complete raw text body. + // A markdown file that opened rendered pays for tokens only when Raw first + // shows, a cached replay pays the same way, and the caps hold before any + // join or worker round trip so an oversized file never builds the string. + useEffect(() => { + if (phase.at !== 'text' || !phase.done || phase.meta.truncated === true) return + if (tokens !== null) return + const lang = languageFor(phase.meta.path) + if (lang === null) return + if (lang === 'markdown' && mode === 'rendered') return + if (phase.meta.size > HIGHLIGHT_MAX_BYTES || linesRef.current.length > HIGHLIGHT_MAX_LINES) + return + let stale = false + void highlight(linesRef.current.join('\n'), lang).then((rows) => { + if (!stale && rows !== null) setTokens(rows) + }) + return () => { + stale = true + } + }, [phase, mode, tokens]) + + const meta = phase.at === 'text' || phase.at === 'image' ? phase.meta : null + const shownPath = meta?.path ?? target.path + const slash = shownPath.lastIndexOf('/') + const base = slash >= 0 ? shownPath.slice(slash + 1) : shownPath + const dir = slash > 0 ? shownPath.slice(0, slash) : slash === 0 ? '/' : '' + const renderable = + phase.at === 'text' && + phase.meta.truncated !== true && + languageFor(phase.meta.path) === 'markdown' + const showsPage = renderable && mode === 'rendered' + const showsLines = phase.at === 'text' && !showsPage + + // The page is one memoized element: react-markdown re-parses the whole + // document on every render it takes part in, so unrelated state — tokens + // arriving, a wrap toggle — must not hand it a fresh chance to. + const pageDone = phase.at === 'text' && phase.done + const pageText = useMemo( + () => (showsPage && pageDone ? linesRef.current.join('\n') : null), + [showsPage, pageDone], + ) + const page = useMemo( + () => (pageText === null ? null : ), + [pageText], + ) + + return ( + <> + {header?.({ meta, shownPath, base, dir, renderable, mode, setMode, showsLines, wrap, setWrap })} + {phase.at === 'text' && phase.meta.truncated === true && ( +

+ Showing the first {fmtBytes(TEXT_CAP)} of {fmtBytes(phase.meta.size)}. The rest stayed + on the machine. +

+ )} + {phase.at === 'opening' && ( +

+ Opening… +

+ )} + {phase.at === 'refused' && ( +

+ {REFUSALS[phase.code] ?? 'The machine refused this read.'} +

+ )} + {phase.at === 'image' && + (imageUrl !== null ? ( +
+ {base} +
+ ) : ( +

+ Receiving the image… +

+ ))} + {showsPage && phase.at === 'text' && ( + // data-file-body and the tab stop, exactly as the text window + // carries them: the dialog's opening focus lands here, so arrow + // and page keys scroll the page from the first keystroke. +
+
+ {page !== null ? ( + + Rendering… +

+ } + > + {page} +
+ ) : ( +

+ Receiving… +

+ )} +
+
+ )} + {showsLines && ( + // Keyed by path, so a body whose target changes under it — a later + // phase reuses the mounted surface — starts its scroll and its + // one-shot jump over rather than inheriting the old file's. + + )} + + ) +} + +/** + * The title row both hosts share: the file's name and directory, its size, + * and the presentation controls. `title` lets the dialog put its Dialog.Title + * in the name's place; `children` is the trailing corner, where the dialog + * adds its own buttons. + */ +export function FileHeader({ + view, + title, + children, +}: { + view: FileContentsView + title?: ReactNode + children?: ReactNode +}) { + return ( +
+ + {title ?? {view.base}} + {view.dir} + {view.meta !== null && ( + + {fmtBytes(view.meta.size)} + + )} + {view.renderable && ( +
+ + +
+ )} + {view.showsLines && ( + + )} + + {children} +
+ ) +} + +function TextWindow({ + lines, + tokens, + mark, + wrap, +}: { + lines: string[] + tokens: PeekToken[][] | null + mark?: number + wrap: boolean +}) { + const boxRef = useRef(null) + const jumped = useRef(false) + + // Measured rather than arithmetic, because a wrapped line is as tall as + // the width made it; the estimate only has to be right for the unwrapped + // common case and the virtualizer corrects the rest as rows mount. + const virtualizer = useVirtualizer({ + count: lines.length, + getScrollElement: () => boxRef.current, + estimateSize: () => LINE_PX, + overscan: 24, + getItemKey: (index) => index, + // Both fallbacks are for an unlaid-out environment (jsdom): a rect so + // the first render windows instead of rendering nothing, and the + // estimate standing in for a measured height of zero, which would + // otherwise put every row of the file inside a zero-height viewport. + initialRect: { width: 800, height: 480 }, + measureElement: (el) => { + const measured = el.getBoundingClientRect().height + return measured > 0 ? measured : LINE_PX + }, + }) + + // One jump to the named line, as soon as enough of the file has arrived — + // re-issued for a few frames, because the first jump lands on estimated + // heights and wrapped rows above the mark measure taller after they mount, + // which the virtualizer does not re-anchor for on its own. + useEffect(() => { + if (mark === undefined || jumped.current || lines.length < mark) return + jumped.current = true + let tries = 0 + const land = () => { + virtualizer.scrollToIndex(mark - 1, { align: 'center' }) + tries++ + if (tries >= 8) return + const shown = virtualizer.getVirtualItems().some((i) => i.index === mark - 1) + if (!shown) requestAnimationFrame(land) + } + land() + }, [lines.length, mark, virtualizer]) + + // Flipping the wrap invalidates every height the virtualizer has learned. + useEffect(() => { + virtualizer.measure() + }, [wrap, virtualizer]) + + // Wide enough for the last line's number, so the column never jumps as + // scrolling reveals longer numbers. + const gutterCh = String(Math.max(lines.length, 1)).length + + return ( +
+
+ {virtualizer.getVirtualItems().map((item) => { + const n = item.index + 1 + return ( +
+ + {n} + +
+ {tokens?.[item.index] !== undefined ? ( + + ) : ( + lines[item.index] + )} +
+
+ ) + })} +
+
+ ) +} + +function TokenRow({ row }: { row: PeekToken[] }) { + return ( + <> + {row.map((t, i) => ( + + {t.text} + + ))} + + ) +} + +function CopyPath({ path }: { path: string }) { + const [held, setHeld] = useState(false) + return ( + + ) +} + +function joinBytes(parts: Uint8Array[], total: number): Uint8Array { + const out = new Uint8Array(total) + let at = 0 + for (const p of parts) { + out.set(p, at) + at += p.length + } + return out +} + +function assembleDataUrl(mime: string, parts: Uint8Array[]): string { + let bin = '' + for (const part of parts) { + for (let at = 0; at < part.length; at += 0x8000) { + bin += String.fromCharCode(...part.subarray(at, at + 0x8000)) + } + } + return `data:${mime};base64,${btoa(bin)}` +} + +function fmtBytes(n: number): string { + if (n < 1024) return `${n} B` + const units = ['KiB', 'MiB', 'GiB'] + let v = n + let u = -1 + do { + v /= 1024 + u++ + } while (v >= 1024 && u < units.length - 1) + const shown = v >= 10 ? Math.round(v) : Math.round(v * 10) / 10 + return `${shown} ${units[u]}` +} diff --git a/web/src/files/viewer.test.tsx b/web/src/files/viewer.test.tsx index 14376ff..6bd6fb5 100644 --- a/web/src/files/viewer.test.tsx +++ b/web/src/files/viewer.test.tsx @@ -1,12 +1,15 @@ import { act, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import { RouterContextProvider } from '@tanstack/react-router' +import type { ReactNode } from 'react' import { afterEach, describe, expect, it, vi } from 'vitest' import { FlueClientProvider } from '@/client/provider' +import { createFlueRouter } from '@/router' import { fakeClient, type FakeSocket } from '@/testing/socket' import { FileViewer, type FileTarget } from './viewer' -const openViewer = (target: FileTarget) => { +const openViewer = (target: FileTarget, router?: ReturnType) => { // jsdom lays nothing out, and the virtualizer windows on measured boxes — // offsetWidth/offsetHeight for the scroll box, getBoundingClientRect for // row measurement. Every element pretending to be 800x480 is what @@ -29,10 +32,20 @@ const openViewer = (target: FileTarget) => { client.connect() const sock = last() act(() => sock.open()) + // The optional router rides in as context only — RouterContextProvider + // renders no matches — so the viewer mounts exactly as the terminal mounts + // it, with the tab's address in reach of its open-in-new-tab button. + const wrapper = + router === undefined + ? undefined + : ({ children }: { children: ReactNode }) => ( + {children} + ) const view = render( , + { wrapper }, ) const sent = sock.control().find((m) => m.type === 'read') // Answer every stat not yet answered with one entry (or nothing for null). @@ -73,7 +86,10 @@ const flowed = async (sock: FakeSocket, body: string) => { }) } -afterEach(() => vi.restoreAllMocks()) +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) describe('FileViewer', () => { it('asks for the file and says so while opening', () => { @@ -415,6 +431,27 @@ describe('FileViewer', () => { expect(body().className).toContain('whitespace-pre-wrap') }) + it('opens the file page for this machine and session in a new tab, staying open itself', async () => { + const open = vi.fn().mockReturnValue(null) + vi.stubGlobal('open', open) + window.history.replaceState(null, '', '/d/attic-pi/s/abc123') + const router = createFlueRouter() + await router.load() + const { sock, sent, onClose } = openViewer({ path: 'a.go' }, router) + served(sock, sent!.reqId) + + await userEvent.click(screen.getByRole('button', { name: 'Open in new tab' })) + + // The address is built by the router, so the search serialisation is the + // route's own — the path survives whatever characters it carries. + const url = new URL(open.mock.calls[0]![0] as string, 'http://localhost') + expect(url.pathname).toBe('/d/attic-pi/s/s1/file') + expect(url.searchParams.get('path')).toBe('/home/k/proj/a.go') + expect(open.mock.calls[0]![1]).toBe('_blank') + expect(screen.getByRole('dialog', { name: 'a.go' })).toBeTruthy() + expect(onClose).not.toHaveBeenCalled() + }) + it('offers the resolved path to the clipboard', async () => { const wrote: string[] = [] Object.assign(navigator, { diff --git a/web/src/files/viewer.tsx b/web/src/files/viewer.tsx index 59d8a28..71bfb38 100644 --- a/web/src/files/viewer.tsx +++ b/web/src/files/viewer.tsx @@ -1,24 +1,12 @@ -import { useVirtualizer } from '@tanstack/react-virtual' -import { Check, Copy, FileText, WrapText, X } from 'lucide-react' +import { ExternalLink, X } from 'lucide-react' import { Dialog } from 'radix-ui' -import { lazy, Suspense, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react' +import { useRouter, type RegisteredRouter } from '@tanstack/react-router' -import { useFlueClient } from '@/client/provider' -import type { FileMsg, PathEntry } from '@/client/protocol' -import type { ReadHandle } from '@/client/client' import { Button } from '@/components/ui/button' -import { cn } from '@/lib/utils' -import { cachedFile, rememberFile, CACHE_ENTRY_MAX } from './cache' -import { HIGHLIGHT_MAX_BYTES, HIGHLIGHT_MAX_LINES } from './caps' -import { highlight } from './highlight' -import { languageFor } from './lang' -import type { PeekToken } from './tokenize' +import { LOCAL_MACHINE_ID } from '@/fleet/types' +import { FileContents, FileHeader, type FileTarget } from './contents' -export interface FileTarget { - path: string - line?: number - col?: number -} +export type { FileTarget } export interface FileViewerProps { sessionId: string @@ -26,282 +14,13 @@ export interface FileViewerProps { onClose: () => void } -/** An unwrapped monospace row's height (leading-5), the size estimate the - * virtualizer starts from; a wrapped row measures taller and is measured. */ -const LINE_PX = 20 - -/** The daemon's ceiling on text; past it only the head was sent. */ -const TEXT_CAP = 8 << 20 - -/** - * The most characters one rendered row may carry. File content is untrusted, - * and without this an 8 MiB single-line file is one eight-million-character - * DOM row — and a stream with no newline in it would paint nothing at all - * until eof. A longer line continues on the next row; for a file holding - * lines that size, the row numbering is presentation, not truth. - */ -const ROW_CAP = 8192 - -const REFUSALS: Record = { - not_found: 'Nothing at this path under the session.', - is_dir: 'That path is a directory.', - too_large: 'This image is too large to send.', - denied: 'The machine may not read this file.', - busy: 'Two files are already streaming from this machine. Close one first.', - unsupported: 'Neither text nor an image, so nothing sensible to show.', - bad_path: 'Not a usable path.', - timeout: 'The machine did not answer in time.', - lost: 'The connection dropped before the file arrived whole.', -} - -type Phase = - | { at: 'opening' } - | { at: 'text'; meta: FileMsg; done: boolean } - | { at: 'image'; meta: FileMsg } - | { at: 'refused'; code: string } - -/** How the body of a markdown file shows: as a page, or as its lines. */ -type BodyMode = 'rendered' | 'raw' - -/** Loaded on the first markdown file, beside shiki, never in the bundle. */ -const MarkdownLazy = lazy(() => - import('./markdown').then((m) => ({ default: m.MarkdownView })), -) - -/** - * Above this size a markdown file opens raw, because rendering parses the - * whole document on the main thread in one go. Content is untrusted and one - * click must not buy a multi-second freeze; the Rendered press stays - * offered, so a reader who wants the page still gets it — knowingly. - */ -const RENDER_DEFAULT_MAX = 512 << 10 - /** - * A file over the session: the answer to clicking a path the session named. - * - * Content paints as it arrives — the head of a large file is on screen before - * the tail has left the daemon, which over a relay is the difference between - * reading now and watching a spinner. Painting is paced to one state change - * per animation frame so a fast stream cannot outrun the renderer. A complete - * file colours afterwards, off the main thread, and is remembered in memory - * so reopening it costs one stat and no read. + * The modal file peek: FileContents inside a dialog. The streaming, the + * cache and the body all live in ./contents; what belongs here is the + * overlay, the dialog's focus handling, the way out to a tab of the file's + * own, and the close affordance. */ export function FileViewer({ sessionId, target, onClose }: FileViewerProps) { - const client = useFlueClient() - const [phase, setPhase] = useState({ at: 'opening' }) - const [tokens, setTokens] = useState(null) - const [imageUrl, setImageUrl] = useState(null) - const [wrap, setWrap] = useState(true) - const [mode, setMode] = useState('raw') - const [, setPainted] = useState(0) - const linesRef = useRef([]) - const frame = useRef(0) - - useEffect(() => { - let gone = false - linesRef.current = [] - setPhase({ at: 'opening' }) - setTokens(null) - setImageUrl(null) - const decoder = new TextDecoder() - const parts: Uint8Array[] = [] - let tail = '' - let meta: FileMsg | null = null - let handle: ReadHandle | null = null - - const repaint = () => { - if (frame.current !== 0) return - frame.current = requestAnimationFrame(() => { - frame.current = 0 - setPainted((n) => n + 1) - }) - } - const emit = (row: string) => { - const clean = row.endsWith('\r') ? row.slice(0, -1) : row - if (clean.length <= ROW_CAP) { - linesRef.current.push(clean) - return - } - for (let at = 0; at < clean.length; at += ROW_CAP) { - linesRef.current.push(clean.slice(at, at + ROW_CAP)) - } - } - const push = (piece: string) => { - if (piece === '') return - const rows = (tail + piece).split('\n') - tail = rows.pop() ?? '' - for (const r of rows) emit(r) - // A newline may never come. Overflow leaves the tail as finished rows, - // which both bounds the tail and lets a newline-less stream paint. - while (tail.length >= ROW_CAP) { - linesRef.current.push(tail.slice(0, ROW_CAP)) - tail = tail.slice(ROW_CAP) - } - } - - const deliverMeta = (m: FileMsg) => { - meta = m - if (gone) return - setPhase(m.kind === 'image' ? { at: 'image', meta: m } : { at: 'text', meta: m, done: false }) - // A whole markdown file opens as a page; a click that named a line - // opens on the lines the number means, with the page one press away. - setMode( - m.kind === 'text' && - m.truncated !== true && - m.size <= RENDER_DEFAULT_MAX && - target.line === undefined && - languageFor(m.path) === 'markdown' - ? 'rendered' - : 'raw', - ) - } - const deliverChunk = (bytes: Uint8Array) => { - parts.push(bytes) - if (meta?.kind !== 'image') { - push(decoder.decode(bytes, { stream: true })) - repaint() - } - } - // Keyed per session as well as per clicked text: a relative path - // resolves against a session's live working directory, so the same - // spelling in another session may name a different file entirely. - const cacheKey = `${sessionId}\u0000${target.path}` - const statOne = () => client.stat(sessionId, [target.path]).then(([e]) => e) - const deliverEof = (before: Promise | null) => { - if (meta?.kind === 'image') { - if (!gone) setImageUrl(assembleDataUrl(meta.mime, parts)) - } else { - push(decoder.decode()) - if (tail !== '') { - linesRef.current.push(tail) - tail = '' - } - setPhase((p) => (p.at === 'text' ? { ...p, done: true } : p)) - repaint() - } - if (before === null || meta === null || meta.truncated === true) return - const total = parts.reduce((n, p) => n + p.length, 0) - if (total > CACHE_ENTRY_MAX) return - // Remembered only when the stat taken as the read began and the stat - // taken after it agree on size and stamp, and both agree with the - // stream's own size. mtime is unix seconds, so a same-second edit can - // still slip this — but an edit during the stream cannot, which is - // what an after-only check got wrong: it blessed post-edit bytes with - // a post-edit stamp and served them stale on every reopen. - const settled = meta - void Promise.all([before, statOne()]) - .then(([pre, post]) => { - if (pre?.exists !== true || pre.kind !== 'file') return - if (post?.exists !== true || post.kind !== 'file') return - if ((pre.size ?? 0) !== settled.size || (post.size ?? 0) !== settled.size) return - if ((pre.mtime ?? 0) !== (post.mtime ?? 0)) return - rememberFile(client, cacheKey, { - meta: settled, - mtime: post.mtime ?? 0, - bytes: joinBytes(parts, total), - }) - }) - .catch(() => {}) - } - - const readFromWire = (before: Promise | null) => { - if (gone) return - handle = client.read(sessionId, target.path, { - file: deliverMeta, - chunk: deliverChunk, - eof: () => deliverEof(before), - fail: (f) => { - if (!gone) setPhase({ at: 'refused', code: f.code }) - }, - }) - } - - const start = async () => { - const held = cachedFile(client, cacheKey) - if (held === null) { - // The before-stat rides in parallel with the read, so the cache's - // integrity costs the open no latency at all. - readFromWire(statOne().catch(() => undefined)) - return - } - let confirmed: PathEntry | undefined - try { - confirmed = await statOne() - if (gone) return - if ( - confirmed?.exists === true && - confirmed.kind === 'file' && - (confirmed.size ?? 0) === held.meta.size && - (confirmed.mtime ?? 0) === held.mtime - ) { - deliverMeta(held.meta) - deliverChunk(held.bytes) - deliverEof(null) - return - } - } catch { - // The stat could not confirm the memory; the wire is the truth, and - // with nothing to agree with, this read is not remembered either. - } - if (gone) return - readFromWire(confirmed === undefined ? null : Promise.resolve(confirmed)) - } - void start() - - return () => { - gone = true - handle?.cancel() - if (frame.current !== 0) cancelAnimationFrame(frame.current) - frame.current = 0 - } - }, [client, sessionId, target.path]) - - // Colouring waits for the view that wants it: a complete raw text body. - // A markdown file that opened rendered pays for tokens only when Raw first - // shows, a cached replay pays the same way, and the caps hold before any - // join or worker round trip so an oversized file never builds the string. - useEffect(() => { - if (phase.at !== 'text' || !phase.done || phase.meta.truncated === true) return - if (tokens !== null) return - const lang = languageFor(phase.meta.path) - if (lang === null) return - if (lang === 'markdown' && mode === 'rendered') return - if (phase.meta.size > HIGHLIGHT_MAX_BYTES || linesRef.current.length > HIGHLIGHT_MAX_LINES) - return - let stale = false - void highlight(linesRef.current.join('\n'), lang).then((rows) => { - if (!stale && rows !== null) setTokens(rows) - }) - return () => { - stale = true - } - }, [phase, mode, tokens]) - - const meta = phase.at === 'text' || phase.at === 'image' ? phase.meta : null - const shownPath = meta?.path ?? target.path - const slash = shownPath.lastIndexOf('/') - const base = slash >= 0 ? shownPath.slice(slash + 1) : shownPath - const dir = slash > 0 ? shownPath.slice(0, slash) : slash === 0 ? '/' : '' - const renderable = - phase.at === 'text' && - phase.meta.truncated !== true && - languageFor(phase.meta.path) === 'markdown' - const showsPage = renderable && mode === 'rendered' - const showsLines = phase.at === 'text' && !showsPage - - // The page is one memoized element: react-markdown re-parses the whole - // document on every render it takes part in, so unrelated state — tokens - // arriving, a wrap toggle — must not hand it a fresh chance to. - const pageDone = phase.at === 'text' && phase.done - const pageText = useMemo( - () => (showsPage && pageDone ? linesRef.current.join('\n') : null), - [showsPage, pageDone], - ) - const page = useMemo( - () => (pageText === null ? null : ), - [pageText], - ) - return ( -
- - {base} - {dir} - {meta !== null && ( - - {fmtBytes(meta.size)} - - )} - {renderable && ( -
- - -
- )} - {showsLines && ( - + + + + + )} - - - - -
- {phase.at === 'text' && phase.meta.truncated === true && ( -

- Showing the first {fmtBytes(TEXT_CAP)} of {fmtBytes(phase.meta.size)}. The rest stayed - on the machine. -

- )} - {phase.at === 'opening' && ( -

- Opening… -

- )} - {phase.at === 'refused' && ( -

- {REFUSALS[phase.code] ?? 'The machine refused this read.'} -

- )} - {phase.at === 'image' && - (imageUrl !== null ? ( -
- {base} -
- ) : ( -

- Receiving the image… -

- ))} - {showsPage && phase.at === 'text' && ( - // data-file-body and the tab stop, exactly as the text window - // carries them: the dialog's opening focus lands here, so arrow - // and page keys scroll the page from the first keystroke. -
-
- {page !== null ? ( - - Rendering… -

- } - > - {page} -
- ) : ( -

- Receiving… -

- )} -
-
- )} - {showsLines && ( - // Keyed by path, so a viewer whose target changes under it — a - // later phase reuses the mounted dialog — starts its scroll and - // its one-shot jump over rather than inheriting the old file's. - - )} + />
) } -function TextWindow({ - lines, - tokens, - mark, - wrap, -}: { - lines: string[] - tokens: PeekToken[][] | null - mark?: number - wrap: boolean -}) { - const boxRef = useRef(null) - const jumped = useRef(false) - - // Measured rather than arithmetic, because a wrapped line is as tall as - // the width made it; the estimate only has to be right for the unwrapped - // common case and the virtualizer corrects the rest as rows mount. - const virtualizer = useVirtualizer({ - count: lines.length, - getScrollElement: () => boxRef.current, - estimateSize: () => LINE_PX, - overscan: 24, - getItemKey: (index) => index, - // Both fallbacks are for an unlaid-out environment (jsdom): a rect so - // the first render windows instead of rendering nothing, and the - // estimate standing in for a measured height of zero, which would - // otherwise put every row of the file inside a zero-height viewport. - initialRect: { width: 800, height: 480 }, - measureElement: (el) => { - const measured = el.getBoundingClientRect().height - return measured > 0 ? measured : LINE_PX - }, - }) - - // One jump to the named line, as soon as enough of the file has arrived — - // re-issued for a few frames, because the first jump lands on estimated - // heights and wrapped rows above the mark measure taller after they mount, - // which the virtualizer does not re-anchor for on its own. - useEffect(() => { - if (mark === undefined || jumped.current || lines.length < mark) return - jumped.current = true - let tries = 0 - const land = () => { - virtualizer.scrollToIndex(mark - 1, { align: 'center' }) - tries++ - if (tries >= 8) return - const shown = virtualizer.getVirtualItems().some((i) => i.index === mark - 1) - if (!shown) requestAnimationFrame(land) - } - land() - }, [lines.length, mark, virtualizer]) - - // Flipping the wrap invalidates every height the virtualizer has learned. - useEffect(() => { - virtualizer.measure() - }, [wrap, virtualizer]) - - // Wide enough for the last line's number, so the column never jumps as - // scrolling reveals longer numbers. - const gutterCh = String(Math.max(lines.length, 1)).length - - return ( -
-
- {virtualizer.getVirtualItems().map((item) => { - const n = item.index + 1 - return ( -
- - {n} - -
- {tokens?.[item.index] !== undefined ? ( - - ) : ( - lines[item.index] - )} -
-
- ) - })} -
-
- ) -} - -function TokenRow({ row }: { row: PeekToken[] }) { - return ( - <> - {row.map((t, i) => ( - - {t.text} - - ))} - - ) -} - -function CopyPath({ path }: { path: string }) { - const [held, setHeld] = useState(false) +/** + * The same file in a tab of its own, with the dialog left standing. + * + * The address is built by the router rather than by hand, for the reason + * useOpenNewSession gives: the file page narrows what arrives with its own + * schema, the router's serialiser writes the query, and buildLocation is what + * makes the two agree by construction. Which machine's page comes off the + * tab's own address — the deepest match's deviceId, exactly the segment the + * scratch terminal reads — because the viewer's client context rides that + * same param. Read at the click, not subscribed: it is only ever used here. + * + * The hook tolerates the router's absence — some tests mount the viewer + * bare — by offering nothing, since without an address there is no machine + * to name. + */ +function OpenInTab({ sessionId, path }: { sessionId: string; path: string }) { + const router: RegisteredRouter | undefined = useRouter({ warn: false }) + if (router === undefined) return null return ( ) } - -function joinBytes(parts: Uint8Array[], total: number): Uint8Array { - const out = new Uint8Array(total) - let at = 0 - for (const p of parts) { - out.set(p, at) - at += p.length - } - return out -} - -function assembleDataUrl(mime: string, parts: Uint8Array[]): string { - let bin = '' - for (const part of parts) { - for (let at = 0; at < part.length; at += 0x8000) { - bin += String.fromCharCode(...part.subarray(at, at + 0x8000)) - } - } - return `data:${mime};base64,${btoa(bin)}` -} - -function fmtBytes(n: number): string { - if (n < 1024) return `${n} B` - const units = ['KiB', 'MiB', 'GiB'] - let v = n - let u = -1 - do { - v /= 1024 - u++ - } while (v >= 1024 && u < units.length - 1) - const shown = v >= 10 ? Math.round(v) : Math.round(v * 10) / 10 - return `${shown} ${units[u]}` -} diff --git a/web/src/router.tsx b/web/src/router.tsx index baf4bfd..c54a985 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -11,6 +11,7 @@ import { AppShell } from '@/components/app-shell' import { AgentViewerRoute } from '@/routes/agent-viewer' import { AgentsRoute } from '@/routes/agents' import { DevicesRoute } from '@/routes/devices' +import { FilePeekRoute, validateFilePeekSearch } from '@/routes/file-peek' import { MachinesRoute } from '@/routes/machines' import { NewSessionRoute } from '@/routes/new-session' import { PairRoute } from '@/routes/pair' @@ -259,6 +260,22 @@ const terminalRoute = createRoute({ component: TerminalRoute, }) +/** + * A session's file as a page: the address the viewer's open-in-new-tab + * builds. Beside the terminal and outside the shell for the terminal's + * reason — the tab is the file, and this page rides the same machine-scoped + * client the terminal does. The path, like its route id, is exported for the + * tests that key on it. + */ +export const FILE_ROUTE_ID = '/d/$deviceId/s/$sessionId/file' + +const fileRoute = createRoute({ + getParentRoute: () => rootRoute, + path: FILE_ROUTE_ID, + validateSearch: validateFilePeekSearch, + component: FilePeekRoute, +}) + /** * The page that starts a session, beside the terminal and outside the shell on * purpose: it is the terminal a moment before there is one, it replaces itself @@ -343,6 +360,7 @@ const routeTree = rootRoute.addChildren([ settingsRoute, ]), terminalRoute, + fileRoute, newSessionRoute, machinesRoute, pairRoute, diff --git a/web/src/routes/file-peek.test.tsx b/web/src/routes/file-peek.test.tsx new file mode 100644 index 0000000..fb2e701 --- /dev/null +++ b/web/src/routes/file-peek.test.tsx @@ -0,0 +1,149 @@ +import { act, render, screen } from '@testing-library/react' +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + Outlet, + RouterProvider, +} from '@tanstack/react-router' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { FleetClient } from '@/fleet/fleet' +import { FleetProvider } from '@/fleet/provider' +import { createFlueRouter, FILE_ROUTE_ID } from '@/router' +import { fakeClient, type FakeSocket } from '@/testing/socket' +import { FilePeekRoute, validateFilePeekSearch } from './file-peek' + +/** + * Mount the page at an address, over a scripted fleet of two machines — the + * mountNew arrangement from new-session.test.tsx, for the same kind of route: + * the address is the whole input, and the socket opens after the first render + * because a window.open tab always starts cold. + */ +async function mountFile(url: string) { + // jsdom lays nothing out, and the virtualizer windows on measured boxes — + // the same pretend geometry viewer.test.tsx uses. + vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(480) + vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(800) + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({ + width: 800, + height: 480, + top: 0, + left: 0, + right: 800, + bottom: 480, + x: 0, + y: 0, + toJSON: () => ({}), + } as DOMRect) + const local = fakeClient() + const attic = fakeClient() + const fleet = new FleetClient([ + { id: 'local', name: '', client: local.client, pinned: false }, + { id: 'attic-pi', name: 'Attic Pi', client: attic.client, pinned: false }, + ]) + + const rootRoute = createRootRoute({ component: () => }) + const routeTree = rootRoute.addChildren([ + createRoute({ + getParentRoute: () => rootRoute, + path: '/d/$deviceId/s/$sessionId/file', + validateSearch: validateFilePeekSearch, + component: FilePeekRoute, + }), + ]) + const router = createRouter({ + routeTree, + history: createMemoryHistory({ initialEntries: [url] }), + }) + await router.load() + + let view!: ReturnType + await act(async () => { + view = render( + + + , + ) + }) + return { ...view, router, local, attic } +} + +const served = (sock: FakeSocket, reqId: unknown, over: Record = {}) => + act(() => { + sock.emitControl({ + type: 'file', + ref: 7, + path: '/home/k/notes.txt', + size: 22, + mime: 'text/plain; charset=utf-8', + kind: 'text', + reqId: reqId as number, + ...over, + }) + }) + +const flowed = async (sock: FakeSocket, body: string) => { + await act(async () => { + sock.emitFile(7, body) + await new Promise((frame) => requestAnimationFrame(frame)) + }) +} + +afterEach(() => vi.restoreAllMocks()) + +describe('FilePeekRoute', () => { + it('reads the named file over the session once the socket opens, and paints it', async () => { + const { local } = await mountFile('/d/local/s/s1/file?path=%2Fhome%2Fk%2Fnotes.txt') + const sock = local.sockets[0]! + + // A cold tab: nothing can be asked before the socket is up. + expect(sock.ofType('read')).toEqual([]) + + act(() => sock.open()) + const sent = sock.control().find((m) => m.type === 'read') + expect(sent).toMatchObject({ type: 'read', id: 's1', path: '/home/k/notes.txt' }) + + served(sock, sent!.reqId) + await flowed(sock, 'plain words\nsecond line') + expect(screen.getByText('plain words')).toBeTruthy() + await act(async () => { + sock.emitControl({ type: 'eof', ref: 7 }) + await new Promise((frame) => requestAnimationFrame(frame)) + }) + expect(screen.getByText('second line')).toBeTruthy() + }) + + it('sets the tab title to the file basename while mounted', async () => { + document.title = 'flue' + const view = await mountFile('/d/local/s/s1/file?path=%2Fhome%2Fk%2Fnotes.txt') + expect(document.title).toBe('notes.txt') + view.unmount() + expect(document.title).toBe('flue') + }) + + it('reads over the machine the address names', async () => { + const { local, attic } = await mountFile('/d/attic-pi/s/s1/file?path=a.go') + act(() => attic.sockets[0]!.open()) + expect(attic.sockets[0]!.ofType('read')).toMatchObject([{ type: 'read', id: 's1', path: 'a.go' }]) + expect(local.sockets[0]!.ofType('read')).toEqual([]) + }) + + it('refuses an address that names no path', async () => { + document.title = 'flue' + const { local } = await mountFile('/d/local/s/s1/file') + act(() => local.sockets[0]!.open()) + expect(screen.getByRole('alert').textContent).toMatch(/Not a usable path/) + expect(local.sockets[0]!.ofType('read')).toEqual([]) + expect(document.title).toBe('flue') + }) + + it('is registered in the app router, outside the shell like the terminal', () => { + const ids = createFlueRouter() + .matchRoutes('/d/local/s/abc123/file', {}) + .map((m) => m.routeId) + expect(ids).toContain(FILE_ROUTE_ID) + expect(ids.some((id) => id.includes('shell'))).toBe(false) + }) +}) diff --git a/web/src/routes/file-peek.tsx b/web/src/routes/file-peek.tsx new file mode 100644 index 0000000..0f4d50f --- /dev/null +++ b/web/src/routes/file-peek.tsx @@ -0,0 +1,110 @@ +import { useCallback, useEffect, useState, useSyncExternalStore } from 'react' +import { useParams, useSearch } from '@tanstack/react-router' + +import { FlueClientContext } from '@/client/provider' +import type { FlueClient } from '@/client/client' +import { FileContents, FileHeader } from '@/files/contents' +import { useFleet } from '@/fleet/provider' +import { MachineNotPaired } from '@/routes/terminal' + +/** + * This page's path, written out rather than imported from src/router.tsx for + * the terminal route's reason: the router imports this component, so the + * import would close a cycle. The literal is typed against the registered + * route tree, so a path that drifts is a compile error. + */ +const FILE_PATH = '/d/$deviceId/s/$sessionId/file' as const + +/** + * What the address may carry: the one file it names. Narrowed to a string or + * emptied, so a mangled link — `path` repeated parses to an array — lands on + * the page's refusal rather than going out on the wire. The router's own + * search serialisation is what carries the percent-encoding both ways. + */ +export function validateFilePeekSearch(search: Record): { path: string } { + return { path: typeof search.path === 'string' ? search.path : '' } +} + +/** + * A file over a session as a page of its own: where the viewer's + * open-in-new-tab lands. Full-bleed beside the terminal rather than under + * the shell, because the tab is the file and app chrome around it would + * answer a question nobody asked. + * + * The client is resolved the way the terminal route resolves its own — + * through a fleet subscription, because a window.open tab renders before the + * fleet has adopted a remote machine's sources, and the moment of adoption + * has to reach this page as a re-render. + */ +export function FilePeekRoute() { + const { deviceId, sessionId } = useParams({ from: FILE_PATH }) + const { path } = useSearch({ from: FILE_PATH }) + const fleet = useFleet() + const client = useSyncExternalStore( + useCallback((onChange: () => void) => fleet.onFleet(onChange), [fleet]), + () => fleet.clientFor(deviceId), + ) + const ready = useSocketOpen(client) + + // The browser tab is named for the file, and gives the name back on the + // way out — this page can be navigated away from within a running app. + const base = path.slice(path.lastIndexOf('/') + 1) + useEffect(() => { + if (base === '') return + const prior = document.title + document.title = base + return () => { + document.title = prior + } + }, [base]) + + if (path === '') { + return ( +

+ Not a usable path. +

+ ) + } + if (client === null) return + return ( + +
+ {ready ? ( + } + /> + ) : ( +

+ Opening… +

+ )} +
+
+ ) +} + +/** + * Whether the client has carried its socket up, latched once per client. + * + * A cold tab is what window.open makes, and `read` refuses on a socket that + * is not open yet — so the body waits for the first open rather than asking + * into the void. Latched rather than tracked, because a blip mid-stream is + * the read's own story to tell: the body answers it the way the modal + * viewer always has. + */ +function useSocketOpen(client: FlueClient | null): boolean { + const [ready, setReady] = useState(() => client !== null && client.status === 'open') + useEffect(() => { + if (client === null) { + setReady(false) + return + } + setReady(client.status === 'open') + return client.onStatus((s) => { + if (s === 'open') setReady(true) + }) + }, [client]) + return ready +} diff --git a/web/src/routes/terminal.tsx b/web/src/routes/terminal.tsx index e4300e1..085fca1 100644 --- a/web/src/routes/terminal.tsx +++ b/web/src/routes/terminal.tsx @@ -538,9 +538,10 @@ function useFormFleet(fleet: ReturnType): FormFleet { * The missing-machine treatment, matched to the missing-session one: the same * full-bleed pane the terminal renders, the same pill in the same corner, a * dot that holds still because the state is final. Dark in both themes, as - * the pill is when it floats over a terminal. + * the pill is when it floats over a terminal. Exported for the file page, + * which answers an unpaired machine the same way. */ -function MachineNotPaired() { +export function MachineNotPaired() { return (