From 54aabe116feb76a907369392c5f02e372b1c77f9 Mon Sep 17 00:00:00 2001 From: Karn Date: Wed, 19 Aug 2026 22:11:18 +0530 Subject: [PATCH 1/3] Extract the file peek body into a reusable FileContents component The streaming read, the cache handshake and the body rendering move from FileViewer into files/contents.tsx, together with the shared FileHeader title row. The viewer keeps only its dialog chrome and renders FileContents inside, so the modal behaves exactly as before while the body becomes mountable outside a dialog. Co-Authored-By: Claude Fable 5 --- web/src/files/contents.test.tsx | 101 +++++ web/src/files/contents.tsx | 670 ++++++++++++++++++++++++++++++++ web/src/files/viewer.tsx | 622 ++--------------------------- 3 files changed, 795 insertions(+), 598 deletions(-) create mode 100644 web/src/files/contents.test.tsx create mode 100644 web/src/files/contents.tsx 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.tsx b/web/src/files/viewer.tsx index 59d8a28..2b550cd 100644 --- a/web/src/files/viewer.tsx +++ b/web/src/files/viewer.tsx @@ -1,24 +1,10 @@ -import { useVirtualizer } from '@tanstack/react-virtual' -import { Check, Copy, FileText, WrapText, X } from 'lucide-react' +import { X } from 'lucide-react' import { Dialog } from 'radix-ui' -import { lazy, Suspense, useEffect, useMemo, useRef, useState, type CSSProperties } 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' +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 +12,12 @@ 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, 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) - 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]}` -} From bac1bb55eb5632e44e5ee185e40a9368f314f293 Mon Sep 17 00:00:00 2001 From: Karn Date: Wed, 19 Aug 2026 22:14:16 +0530 Subject: [PATCH 2/3] Serve a session's file as a full-bleed page of its own /d/$deviceId/s/$sessionId/file renders FileContents full-page, beside the terminal route and outside the shell, with the file named by a required path search param the route validates. The client is resolved through the fleet the way the terminal resolves its own, the body waits for the first socket open because a window.open tab starts cold, and the browser tab is titled with the file's basename while the page is up. Co-Authored-By: Claude Fable 5 --- web/src/router.tsx | 18 ++++ web/src/routes/file-peek.test.tsx | 149 ++++++++++++++++++++++++++++++ web/src/routes/file-peek.tsx | 110 ++++++++++++++++++++++ web/src/routes/terminal.tsx | 5 +- 4 files changed, 280 insertions(+), 2 deletions(-) create mode 100644 web/src/routes/file-peek.test.tsx create mode 100644 web/src/routes/file-peek.tsx 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 (
From b943bedd77c80d1f0ee2db9752199be0555494c0 Mon Sep 17 00:00:00 2001 From: Karn Date: Wed, 19 Aug 2026 22:16:28 +0530 Subject: [PATCH 3/3] Offer the peeked file in a browser tab of its own An open-in-new-tab button in the viewer's title row builds the file page's address through the router's buildLocation, so the path rides the route's own search serialisation, and opens it with window.open while the dialog stays up. The machine segment comes off the tab's address the way the scratch terminal reads it, with local as the fallback. Co-Authored-By: Claude Fable 5 --- web/src/files/viewer.test.tsx | 41 ++++++++++++++++++++++++++++-- web/src/files/viewer.tsx | 47 +++++++++++++++++++++++++++++++++-- 2 files changed, 84 insertions(+), 4 deletions(-) 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 2b550cd..71bfb38 100644 --- a/web/src/files/viewer.tsx +++ b/web/src/files/viewer.tsx @@ -1,7 +1,9 @@ -import { X } from 'lucide-react' +import { ExternalLink, X } from 'lucide-react' import { Dialog } from 'radix-ui' +import { useRouter, type RegisteredRouter } from '@tanstack/react-router' import { Button } from '@/components/ui/button' +import { LOCAL_MACHINE_ID } from '@/fleet/types' import { FileContents, FileHeader, type FileTarget } from './contents' export type { FileTarget } @@ -15,7 +17,8 @@ export interface FileViewerProps { /** * 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, and the close affordance. + * 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) { return ( @@ -51,6 +54,7 @@ export function FileViewer({ sessionId, target, onClose }: FileViewerProps) { } > + + ) +}