diff --git a/web/src/components/machine-mark.tsx b/web/src/components/machine-mark.tsx new file mode 100644 index 0000000..f99fddb --- /dev/null +++ b/web/src/components/machine-mark.tsx @@ -0,0 +1,48 @@ +import { CloudIcon, ComputerDesktopIcon } from '@heroicons/react/16/solid' + +import { cn } from '@/lib/utils' + +/** + * The two words a machine mark can say, spelled once because three screens + * say them: the sessions list on its headings, the same list on every row's + * machine badge, and the terminal's own corner chip. A pair of glyphs that + * meant one thing on one screen and another elsewhere would teach nobody + * anything. + */ +export const THIS_MACHINE_LABEL = 'This machine' +export const RELAY_MACHINE_LABEL = 'Machine reached over the relay' + +/** + * Which kind of machine this is: the one the browser is running on, or one + * reached the long way round. + * + * The question it answers is not "which machine" — the name beside it already + * does that — but "is that name the box in front of me". Machines are named + * after their hostnames by default (the join line takes `os.Hostname`), and a + * hostname is exactly the kind of string nobody has memorised. + * + * The glyphs are the two the app already uses for these facts: a desktop for + * the local machine, and the cloud that stands for Cloudflare everywhere else + * in this UI — remote access is a Worker on the reader's own account, so a + * session on another machine really is reached through it. The cloud keeps + * Cloudflare's orange, as it does on the Remote access screen. The desktop + * takes whatever colour it lands in, because the ordinary case should not + * shout and because this mark rides two very different grounds: a muted badge + * on the sessions list, and a translucent chip over a terminal. + * + * `home` is the caller's judgement, never derived here. Only the route knows + * whether this page came off the daemon's own origin — a relay tab reaches + * every machine on the fleet, including the one it rides, over the relay. + */ +export function MachineMark({ home, className }: { home: boolean; className?: string }) { + const Icon = home ? ComputerDesktopIcon : CloudIcon + const label = home ? THIS_MACHINE_LABEL : RELAY_MACHINE_LABEL + return ( + // A labelled image rather than a decorative one: the glyph carries a fact + // no text beside it repeats, so dropping it from the accessibility tree + // would drop the fact. Same treatment the pinned star gets. + + + ) +} diff --git a/web/src/components/session-table.test.tsx b/web/src/components/session-table.test.tsx index 07e047e..4c89079 100644 --- a/web/src/components/session-table.test.tsx +++ b/web/src/components/session-table.test.tsx @@ -103,6 +103,52 @@ describe('SessionTable', () => { expect(text.indexOf('MacBook Pro')).toBeLessThan(text.indexOf('devbox')) }) + it('marks a machine heading as this machine or as a relay one', async () => { + await renderTable({ + groups: [ + group('machine:m1', 'MacBook Pro', [fs({ id: 'a1' })]), + group('machine:m2', 'devbox', [fs({ id: 'b2', machineId: 'm2', machineName: 'devbox' })]), + ], + columns: ['name'], + machineMarks: { home: 'm1' }, + }) + expect(screen.getAllByLabelText('This machine').length).toBe(1) + expect(screen.getAllByLabelText('Machine reached over the relay').length).toBe(1) + }) + + it('marks a machine subheading too, wherever the second cut puts it', async () => { + const api = fs({ id: 'a1', tags: ['api'] }) + await renderTable({ + groups: [ + { + ...group('tag:api', 'api', [api]), + children: [group(`tag:api${SUBKEY_SEP}machine:m1`, 'MacBook Pro', [api])], + }, + ], + columns: ['name'], + machineMarks: { home: 'm1' }, + }) + expect(screen.getAllByLabelText('This machine').length).toBe(1) + }) + + it('leaves a heading about anything but a machine unmarked', async () => { + await renderTable({ + groups: [group('tag:api', 'api', [fs({ id: 'a1', tags: ['api'] })])], + columns: ['name'], + machineMarks: { home: 'm1' }, + }) + expect(screen.queryByLabelText('This machine')).toBeNull() + expect(screen.queryByLabelText('Machine reached over the relay')).toBeNull() + }) + + it('keeps the fold control named after its group alone', async () => { + // The mark stands beside the toggle, never inside it: the button's + // accessible name is the heading's, and a glyph swallowed into it + // would make every machine heading announce two facts as one name. + await renderTable({ machineMarks: { home: 'm1' } }) + expect(screen.getByRole('button', { name: 'MacBook Pro' })).toBeTruthy() + }) + it('folds a collapsed group down to its heading', async () => { await renderTable({ collapsed: new Set(['machine:m1']) }) @@ -314,6 +360,58 @@ describe('SessionTable', () => { expect(screen.getByText('MacBook Pro')).toBeTruthy() }) + it('marks a row on the machine this browser is running on', async () => { + // The house half of the pair: the name alone answers "which machine", + // and this answers "is that the one in front of me" — the question a + // hostname nobody has memorised cannot. + await renderTable({ + groups: [group('tag:api', 'api', [fs({ id: 'a1', tags: ['api'] })])], + machineMarks: { home: 'm1' }, + }) + expect(screen.getAllByLabelText('This machine').length).toBe(1) + expect(screen.queryByLabelText('Machine reached over the relay')).toBeNull() + }) + + it('marks a row on any other machine as reached over the relay', async () => { + await renderTable({ + groups: [ + group('tag:api', 'api', [ + fs({ id: 'a1', tags: ['api'] }), + fs({ id: 'b2', tags: ['api'], machineId: 'm2', machineName: 'devbox' }), + ]), + ], + machineMarks: { home: 'm1' }, + }) + expect(screen.getAllByLabelText('This machine').length).toBe(1) + expect(screen.getAllByLabelText('Machine reached over the relay').length).toBe(1) + }) + + it('calls every machine remote when no machine is the one in front of the reader', async () => { + // A phone on a relay origin: it reaches all of them the long way round, + // and a house on any of them would name a box in another room. + await renderTable({ + groups: [ + group('tag:api', 'api', [ + fs({ id: 'a1', tags: ['api'] }), + fs({ id: 'b2', tags: ['api'], machineId: 'm2', machineName: 'devbox' }), + ]), + ], + machineMarks: { home: null }, + }) + expect(screen.queryByLabelText('This machine')).toBeNull() + expect(screen.getAllByLabelText('Machine reached over the relay').length).toBe(2) + }) + + it('draws no marks at all when the caller offers none', async () => { + // One machine on the fleet: there is nothing to tell apart, and a badge + // wearing a glyph about it would be chrome answering a question nobody + // asked. + await renderTable({ groups: [group('tag:api', 'api', [fs({ id: 'a1', tags: ['api'] })])] }) + expect(screen.queryByLabelText('This machine')).toBeNull() + expect(screen.queryByLabelText('Machine reached over the relay')).toBeNull() + expect(screen.getByText('MacBook Pro')).toBeTruthy() + }) + it('shows the tags as badges', async () => { await renderTable({ groups: [group('machine:m1', 'MacBook Pro', [fs({ id: 'a1', tags: ['api', 'prod'] })])], diff --git a/web/src/components/session-table.tsx b/web/src/components/session-table.tsx index 8f6068c..518e310 100644 --- a/web/src/components/session-table.tsx +++ b/web/src/components/session-table.tsx @@ -21,6 +21,7 @@ import { } from '@heroicons/react/16/solid' import { GripVerticalIcon } from 'lucide-react' +import { MachineMark } from '@/components/machine-mark' import { SessionPreview } from '@/components/session-preview' import { TagBadges } from '@/components/tag-badges' import { Badge } from '@/components/ui/badge' @@ -36,11 +37,36 @@ import { import { keyOf, type FleetSession } from '@/fleet/types' import { ago } from '@/lib/time' import { cn } from '@/lib/utils' -import { COLUMN_KEYS, displayName, type ColumnKey, type Group } from '@/sessions/view' +import { + COLUMN_KEYS, + displayName, + machineOfGroup, + type ColumnKey, + type Group, +} from '@/sessions/view' /** What a row's ⋯ menu can ask of a session. */ export type RowAction = 'rename' | 'tags' | 'pin' | 'unpin' | 'close' +/** + * Whether machines wear a mark on this screen, and which of them is the one + * the browser is running on. + * + * Undefined turns the marks off entirely, which is the one-machine fleet: a + * screen where every row and every heading names the same machine has nothing + * to tell apart, and a glyph repeated down it would be chrome answering a + * question nobody asked. + * + * `home` is null when no machine is the one in front of the reader — a phone + * on a relay origin reaches every machine the long way round, its own ride + * included — and the marks then read as all-remote, which is the truth there. + * The caller decides both, because only the route knows how this page was + * served (fleet/provider.tsx, `useLoopbackTab`). + */ +export interface MachineMarks { + home: string | null +} + /** * How a dragged row is let go, when the caller offers dragging at all. * @@ -206,6 +232,7 @@ function SessionRow({ onToggleSelect, onAction, peek, + machineMarks, }: { s: FleetSession /** The heading this row renders under — what a drag is picked up from. */ @@ -221,6 +248,7 @@ function SessionRow({ onToggleSelect: (key: string) => void onAction: (action: RowAction, s: FleetSession) => void peek?: PeekFn + machineMarks?: MachineMarks }) { const key = keyOf(s) const name = displayName(s) @@ -367,7 +395,15 @@ function SessionRow({ )} {shown.includes('machine') && ( - + + {/* + The mark leads the name, because it is the coarser fact: which + kind of machine, then which one. Absent on a one-machine fleet, + where the badge is the plain name it always was. + */} + {machineMarks !== undefined && ( + + )} {s.machineName} )} @@ -495,6 +531,7 @@ export function SessionTable({ spawnLabel, drag, peek, + machineMarks, }: { groups: Group[] /** @@ -522,6 +559,8 @@ export function SessionTable({ drag?: DragToGroup /** How a row asks what it is doing, for the hover preview. See PeekFn. */ peek?: PeekFn + /** Whether machines are marked local or remote here. See MachineMarks. */ + machineMarks?: MachineMarks }) { /** * The ghost's subject, held here because only the drag layer's own events @@ -644,6 +683,7 @@ export function SessionTable({ onAction={onAction} onSpawnIn={onSpawnIn} peek={peek} + machineMarks={machineMarks} /> ))} @@ -693,6 +733,7 @@ function GroupSection({ onAction, onSpawnIn, peek, + machineMarks, }: { g: Group /** How many headings sit above this one: 0 for a group, 1 for its children. */ @@ -710,6 +751,7 @@ function GroupSection({ onAction(action: RowAction, s: FleetSession): void onSpawnIn?(group: Group): void peek?: PeekFn + machineMarks?: MachineMarks }) { /* * Registered even for a heading that refuses drops, and deliberately: the @@ -736,6 +778,10 @@ function GroupSection({ * announce it as. */ const spawn = onSpawnIn === undefined ? undefined : spawnLabel?.(g) + // Which machine this heading names, when it names one at all: a tag or a + // directory heading gathers rows from every machine there is, and a mark on + // it would be a claim about all of them at once. + const headingMachine = machineMarks === undefined ? null : machineOfGroup(g.key) return (
{/* @@ -783,6 +829,14 @@ function GroupSection({ {g.label} + {/* + Beside the toggle, never inside it: the fold control's accessible + name is the group's own, and a mark swallowed into it would make + every machine heading announce two facts as one name. + */} + {headingMachine !== null && machineMarks !== undefined && ( + + )} {tally(g.sessions)} @@ -834,6 +888,7 @@ function GroupSection({ onAction={onAction} onSpawnIn={onSpawnIn} peek={peek} + machineMarks={machineMarks} /> ))} @@ -857,6 +912,7 @@ function GroupSection({ onToggleSelect={onToggleSelect} onAction={onAction} peek={peek} + machineMarks={machineMarks} /> ))} diff --git a/web/src/components/terminal.test.tsx b/web/src/components/terminal.test.tsx index a9739d5..021e69a 100644 --- a/web/src/components/terminal.test.tsx +++ b/web/src/components/terminal.test.tsx @@ -610,8 +610,76 @@ describe('Terminal', () => { expect(document.title).toBe('vim wire.go') }) + describe('the machine chip', () => { + const corner = () => document.querySelector('[data-flue-corner]') + const chip = () => document.querySelector('[data-flue-machine]') + + it('names the machine this session runs on, above the tags', () => { + const { sock } = mountTerminal((e) => ( + + )) + act(() => sock.emitControl({ type: 'sessions', sessions: [session({ tags: ['api'] })] })) + + expect(screen.getByText('devbox')).toBeTruthy() + // Chip first, tags under it: both hang off the same right-edge column, + // and the machine is the coarser fact of the two. + const column = [...corner()!.children].map((el) => el.getAttribute('data-flue-machine')) + expect(column[0]).toBe('') + expect(corner()!.querySelector('[data-flue-tags]')).toBeTruthy() + }) + + it('says which of the two kinds of machine it is', () => { + const { show, em } = mountTerminal((e) => ( + + )) + expect(screen.getByLabelText('This machine')).toBeTruthy() + + // The same session opened from a phone: nothing on this fleet is the + // machine in front of the reader, so nothing claims to be. + act(() => + show( + , + ), + ) + expect(screen.queryByLabelText('This machine')).toBeNull() + expect(screen.getByLabelText('Machine reached over the relay')).toBeTruthy() + }) + + it('draws nothing when the fleet holds one machine', () => { + // Which machine is not a question a single-machine fleet asks, and a + // chip answering it would be permanent chrome on the common case. + mountTerminal((e) => ) + expect(chip()).toBeNull() + }) + + it('stays out of the scratch modal, like every other navigation chip', () => { + mountTerminal((e) => ( + + )) + expect(chip()).toBeNull() + }) + }) + describe('the tag strip', () => { const strip = () => document.querySelector('[data-flue-tags]') + const corner = () => document.querySelector('[data-flue-corner]') it('hangs below the control row, right-aligned over the emptiest ground', () => { const { sock } = mountTerminal((e) => ) @@ -622,11 +690,13 @@ describe('Terminal', () => { expect(screen.getByText('api')).toBeTruthy() expect(screen.getByText('prod')).toBeTruthy() // Under the chips at the right edge: terminal text is left-justified, - // so this is the quietest place a line of badges can stand. - expect(strip()!.className).toMatch(/\btop-12\b/) - expect(strip()!.className).toMatch(/\bright-3\b/) + // so this is the quietest place a line of badges can stand. The column + // that carries it is what holds that position, because the machine chip + // shares it. + expect(corner()!.className).toMatch(/\btop-12\b/) + expect(corner()!.className).toMatch(/\bright-3\b/) + expect(corner()!.className).toMatch(/\bz-10\b/) expect(strip()!.className).toMatch(/\bjustify-end\b/) - expect(strip()!.className).toMatch(/\bz-10\b/) }) it('draws nothing at all for a session without tags', () => { @@ -691,8 +761,8 @@ describe('Terminal', () => { const { sock } = mountTerminal((e) => ) act(() => sock.emitControl({ type: 'sessions', sessions: [session({ tags: ['api'] })] })) - expect(strip()!.className).toMatch(/\btop-12\b/) - expect(strip()!.className).toMatch(/\bright-3\b/) + expect(corner()!.className).toMatch(/\btop-12\b/) + expect(corner()!.className).toMatch(/\bright-3\b/) }) it('stays out of the minimal chrome, whose surface shows them elsewhere', () => { diff --git a/web/src/components/terminal.tsx b/web/src/components/terminal.tsx index 18fdc8c..507d1e6 100644 --- a/web/src/components/terminal.tsx +++ b/web/src/components/terminal.tsx @@ -12,6 +12,7 @@ import { import { useFlueClient } from '@/client/provider' import { KeyBar } from '@/components/key-bar' +import { MachineMark } from '@/components/machine-mark' import { PasteBox } from '@/components/paste-box' import { SelectionMenu, type MenuEnd } from '@/components/selection-menu' import { ShortcutsHelp } from '@/components/shortcuts-help' @@ -121,6 +122,20 @@ export interface TerminalProps { * the tab. */ ownsTitle?: boolean + /** + * The machine this session runs on, when saying so is worth the chrome. + * + * The route passes it only for a fleet of more than one reachable machine: + * on a single-machine fleet "which machine" has one answer and a chip + * spelling it out would sit over the output forever. `home` is whether that + * machine is the one this browser is running on, which is the route's + * judgement and not this component's — see MachineMark. + * + * A name and a flag rather than a machine id, for the reason the props + * above give: a terminal that knew the fleet existed could not be mounted + * without one, and the scratch modal and every test mount it without one. + */ + machine?: { name: string; home: boolean } } /** Named so the test and the markup cannot drift apart. */ @@ -218,6 +233,7 @@ export function Terminal({ fitViewport = true, viewportInset = 0, ownsTitle = true, + machine, }: TerminalProps) { const client = useFlueClient() const switcher = useSwitcher() @@ -1274,23 +1290,48 @@ export function Terminal({ /> )} {/* - The group's tags, hung below the control row at the right edge. - Terminal text is left-justified, so the right margin under the chips - is the quietest ground on the screen — a floating badge anywhere - left sits on somebody's prompt. Right-aligned and wrapping downward, - so a long set grows into that same margin. Full chrome only, which - is what makes a surface read them once (see chipsPane). The strip - takes no pointer beyond its own footprint. + What this session is, hung below the control row at the right edge: + the machine it runs on, and the group's tags under that. Terminal text + is left-justified, so the right margin under the chips is the quietest + ground on the screen — a floating badge anywhere left sits on + somebody's prompt. Right-aligned and growing downward, so a long set + grows into that same margin. Full chrome only, which is what makes a + surface read them once (see chipsPane). The column takes no pointer + beyond its own footprint. */} - {chrome === 'full' && tags.length > 0 && ( + {chrome === 'full' && (machine !== undefined || tags.length > 0) && (
- + {machine !== undefined && ( + /* + Which machine, for a fleet where that is a real question. The + route hands it over only when more than one machine is + reachable, so a single-machine terminal stays as bare as it has + always been — and this component still knows nothing about the + fleet, which is the bargain the props at the top of this file + keep. + */ + + + {machine.name} + + )} + {tags.length > 0 && ( +
+ +
+ )}
)} {/* z-10: xterm's own layers carry z-indexes, and an unindexed sibling diff --git a/web/src/fleet/provider.tsx b/web/src/fleet/provider.tsx index 34e8721..3cb7d3e 100644 --- a/web/src/fleet/provider.tsx +++ b/web/src/fleet/provider.tsx @@ -8,6 +8,27 @@ import { LOCAL_MACHINE_ID } from './types' const FleetContext = createContext(null) +/** + * Whether this page came off a daemon's own origin, for the screens that have + * to say so out loud. + * + * The flag is already threaded here for the fleet's own two loopback-only + * errands (see `loopback` below); this puts it where a component can read it, + * because "which of these machines am I sitting in front of" is a question + * only this fact answers. `LOCAL_MACHINE_ID` will not do on its own: it means + * the machine this tab *rides*, which on a relay tab is a machine in another + * room reached over the relay like every other. + * + * False by default, which is the safe way round — a tab that cannot prove it + * is on the machine says nothing rather than claiming a box it may not be. + */ +const LoopbackContext = createContext(false) + +/** Whether the daemon on this very machine served this page. */ +export function useLoopbackTab(): boolean { + return useContext(LoopbackContext) +} + export interface FleetProviderProps { children: ReactNode /** @@ -145,11 +166,18 @@ function OwnFleetProvider({ children, fleet, client, pinned, loopback }: FleetPr const ride = active.clientFor(LOCAL_MACHINE_ID) return ( - {ride ? ( - {children} - ) : ( - children - )} + {/* + Inside the fleet, so a nested pass-through provider (the case the + component above returns children for) keeps the outer tab's answer + rather than shadowing it with its own default. + */} + + {ride ? ( + {children} + ) : ( + children + )} + ) } diff --git a/web/src/routes/sessions.test.tsx b/web/src/routes/sessions.test.tsx index 512be66..06fb71b 100644 --- a/web/src/routes/sessions.test.tsx +++ b/web/src/routes/sessions.test.tsx @@ -59,7 +59,7 @@ function info(over: Partial & { id: string }): SessionInfo { * by navigating mounts into a connection that is already established, but the * very first paint of the tab does not, and both have to work. */ -async function mountSessions({ open = true, strict = false, solo = false } = {}) { +async function mountSessions({ open = true, strict = false, solo = false, loopback = false } = {}) { const local = fakeClient() const attic = fakeClient() // `solo` is a browser paired with one machine, which is what a fresh @@ -109,7 +109,13 @@ async function mountSessions({ open = true, strict = false, solo = false } = {}) await act(async () => { view = render( - + {/* + `loopback` is how the page says the daemon on this very machine + served it — the one fact that lets a machine be called this one. + False is the relay tab, which is the default here because it is the + stricter of the two: nothing on it may claim to be local. + */} + , @@ -215,6 +221,48 @@ describe('SessionsRoute', () => { expect(screen.getByText('Attic Pi', { selector: '[data-slot="badge"]' })).toBeTruthy() }) + it('marks which machine the reader is sitting in front of', async () => { + // Machines are named after their hostnames when they join a relay, and a + // hostname is not a thing people have memorised — so the list says which + // of them is the box this browser is running on, and which are reached + // through the Cloudflare relay. + const { sock, attic, welcomeLocal } = await mountSessions({ loopback: true }) + welcomeLocal() + act(() => attic.sockets[0]!.open()) + + listed(sock, [info({ id: 's1' })]) + listed(attic.sockets[0]!, [info({ id: 's2' })]) + + // Once on the heading, once on the row's machine badge, for each side. + expect(screen.getAllByLabelText('This machine').length).toBe(2) + expect(screen.getAllByLabelText('Machine reached over the relay').length).toBe(2) + }) + + it('claims no local machine on a tab the relay served', async () => { + // A phone reaches every machine on the fleet the long way round, its own + // ride included, so a house on any of them would name a box in another + // room. + const { sock, attic, welcomeLocal } = await mountSessions() + welcomeLocal() + act(() => attic.sockets[0]!.open()) + + listed(sock, [info({ id: 's1' })]) + listed(attic.sockets[0]!, [info({ id: 's2' })]) + + expect(screen.queryByLabelText('This machine')).toBeNull() + expect(screen.getAllByLabelText('Machine reached over the relay').length).toBe(4) + }) + + it('drops the marks with the chip, on a fleet of one', async () => { + // Same rule as the machine column: one machine is not a question. + const { sock, welcomeLocal } = await mountSessions({ solo: true, loopback: true }) + welcomeLocal() + listed(sock, [info({ id: 's1' })]) + + expect(screen.queryByLabelText('This machine')).toBeNull() + expect(screen.queryByLabelText('Machine reached over the relay')).toBeNull() + }) + it('keeps the chip while a second machine is merely unreachable', async () => { // Read off the fleet this browser holds, not off the machines answering // right now: a column that vanished when a laptop slept and came back diff --git a/web/src/routes/sessions.tsx b/web/src/routes/sessions.tsx index 64031bf..ab08122 100644 --- a/web/src/routes/sessions.tsx +++ b/web/src/routes/sessions.tsx @@ -22,7 +22,7 @@ import { import { useSidebar } from '@/components/ui/sidebar' import { Skeleton } from '@/components/ui/skeleton' import type { FleetGaps } from '@/fleet/fleet' -import { useFleet } from '@/fleet/provider' +import { useFleet, useLoopbackTab } from '@/fleet/provider' import { keyOf, LOCAL_MACHINE_ID, type FleetSession, type MachineState } from '@/fleet/types' import { useRefetchOnFocus } from '@/hooks/use-refetch-on-focus' import { takeCwd } from '@/lib/url' @@ -562,6 +562,26 @@ export function SessionsRoute() { [machines, view.columns], ) + /** + * Whether machines wear a local-or-remote mark here, and which one is the + * machine in front of the reader. + * + * Same trigger as the machine column, and for the same reason: a fleet of + * one has nothing to tell apart. Above one, the marks answer the question a + * hostname does not — machines are named after `os.Hostname` when they join + * a relay, and few people have theirs memorised. + * + * `home` is `null` on any tab the daemon did not serve. `local` names the + * machine this tab rides, which on a relay origin is reached over the relay + * exactly like the others — so on a phone every machine is a remote one, + * and saying otherwise would point at a box in another room. + */ + const loopback = useLoopbackTab() + const machineMarks = useMemo( + () => ((machines?.length ?? 0) > 1 ? { home: loopback ? LOCAL_MACHINE_ID : null } : undefined), + [machines, loopback], + ) + const dirty = !sameArrangement( view, active === null ? DEFAULT_VIEW : (views.find((v) => v.name === active) ?? DEFAULT_VIEW), @@ -819,6 +839,7 @@ export function SessionsRoute() { spawnLabel={spawnLabel} drag={dragToGroup} peek={peek} + machineMarks={machineMarks} /> )} diff --git a/web/src/routes/terminal.test.tsx b/web/src/routes/terminal.test.tsx new file mode 100644 index 0000000..00e1f46 --- /dev/null +++ b/web/src/routes/terminal.test.tsx @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' + +import { machineChipFor } from './terminal' + +/** + * The fleet as the terminal route reads it: the machines that are answering + * right now, named. `local` is the machine the tab rides, which is only the + * machine the reader is at when the daemon served the page itself. + */ +const MESA = { id: 'local', name: 'mesa.local' } +const ATTIC = { id: 'attic-pi', name: 'Attic Pi' } + +describe('machineChipFor', () => { + it('names the machine a session runs on, once there are two to tell apart', () => { + expect(machineChipFor([MESA, ATTIC], 'attic-pi', true)).toEqual({ + name: 'Attic Pi', + home: false, + }) + }) + + it('calls the ridden machine this one on a tab its own daemon served', () => { + expect(machineChipFor([MESA, ATTIC], 'local', true)).toEqual({ + name: 'mesa.local', + home: true, + }) + }) + + it('claims no local machine on a relay tab, ride included', () => { + // The phone case: `local` is still the machine this tab rides, and it is + // reached over the relay exactly like the other one. + expect(machineChipFor([MESA, ATTIC], 'local', false)).toEqual({ + name: 'mesa.local', + home: false, + }) + }) + + it('says nothing at all when one machine is reachable', () => { + // Which machine is not a question here, and the answer would sit over the + // shell's output for as long as the tab is open. + expect(machineChipFor([MESA], 'local', true)).toBeUndefined() + expect(machineChipFor([], 'local', true)).toBeUndefined() + }) + + it('waits for the machine to have a name', () => { + // The fleet's local source is born nameless and takes one from the + // daemon's welcome; a chip in between would be an empty box. + expect(machineChipFor([{ id: 'local', name: '' }, ATTIC], 'local', true)).toBeUndefined() + }) + + it('says nothing about a machine the reachable list does not hold', () => { + // A session opened on a machine that has since gone quiet: the terminal + // says so in its own pill, and a chip naming it would not be the fact + // that matters. + expect(machineChipFor([MESA, ATTIC], 'loft-9f9f', true)).toBeUndefined() + }) +}) diff --git a/web/src/routes/terminal.tsx b/web/src/routes/terminal.tsx index 085fca1..468de18 100644 --- a/web/src/routes/terminal.tsx +++ b/web/src/routes/terminal.tsx @@ -6,8 +6,8 @@ import { FlueClientContext } from '@/client/provider' import { NewSessionDialog } from '@/components/new-session-dialog' import { SessionGroup } from '@/components/session-group' import { Terminal } from '@/components/terminal' -import { useFleet } from '@/fleet/provider' -import type { FleetSession, MachineState } from '@/fleet/types' +import { useFleet, useLoopbackTab } from '@/fleet/provider' +import { LOCAL_MACHINE_ID, type FleetSession, type MachineState } from '@/fleet/types' import { matchNewTabChord, matchSplitChord, @@ -81,8 +81,15 @@ export function TerminalRoute() { useCallback((onChange: () => void) => fleet.onFleet(onChange), [fleet]), () => fleet.clientFor(deviceId), ) - // What the dialog needs to fill its machine picker and its tag suggestions. + // What the dialog needs to fill its machine picker and its tag suggestions — + // and, from the same list, what the corner chip says about which machine + // this session is on. See machineChipFor. const fleetForForm = useFormFleet(fleet) + const loopback = useLoopbackTab() + const machineChip = useMemo( + () => machineChipFor(fleetForForm.machines, deviceId, loopback), + [fleetForForm.machines, deviceId, loopback], + ) // This machine's rows, for the group view: which sessions share the URL // session's group, and what to call their tabs. Empty until the fleet's @@ -382,6 +389,10 @@ export function TerminalRoute() { // splits whichever pane holds the keyboard, so the chips' // placement costs a sibling nothing but the pointer route. chrome={id === chipsPane ? 'full' : 'minimal'} + // One machine chip per surface, riding the same pane as the + // control strip: every pane of a split is on the same machine, + // so N of them would be one fact printed N times. + machine={id === chipsPane ? machineChip : undefined} onClosed={() => { // Fired by the exit itself — there is no overlay any more. A // session that was already over when this view opened is being @@ -424,6 +435,41 @@ export function TerminalRoute() { ) } +/** + * What the terminal's corner chip should say about which machine this is, or + * undefined for a screen where that question has one answer. + * + * Three things have to hold before a terminal wears one: + * + * More than one machine is reachable. "Connected" is what makes which-one a + * question at all — a fleet of one would carry a chip over its output + * forever to repeat the only answer there is. + * + * The machine has a name to say. The fleet's local source is born nameless + * and takes its name from the daemon's welcome a moment later, and a chip in + * between would be an empty box floating over the shell. + * + * And `home` — whether this is the machine the reader is sitting in front of — + * needs both halves of its own. `LOCAL_MACHINE_ID` means the machine this tab + * *rides*, which on a relay origin is reached the long way round like all the + * rest; only a page the daemon itself served is standing on the machine it is + * showing. + * + * Exported for its own test: the route around it needs a fleet, two sockets + * and a router before it renders anything, and this rule is worth checking + * without them. + */ +export function machineChipFor( + machines: Array<{ id: string; name: string }>, + deviceId: string, + loopback: boolean, +): { name: string; home: boolean } | undefined { + if (machines.length <= 1) return undefined + const own = machines.find((m) => m.id === deviceId) + if (own === undefined || own.name === '') return undefined + return { name: own.name, home: loopback && deviceId === LOCAL_MACHINE_ID } +} + /** * This machine's session rows, updated only when something a pane layout * reads has genuinely changed. The fleet emits every poll tick with fresh diff --git a/web/src/sessions/view.test.ts b/web/src/sessions/view.test.ts index c7e0ae4..155dc85 100644 --- a/web/src/sessions/view.test.ts +++ b/web/src/sessions/view.test.ts @@ -17,6 +17,7 @@ import { GROUPINGS, groupSessions, hiddenExited, + machineOfGroup, ORDERING_LABELS, ORDERINGS, orderSessions, @@ -668,6 +669,31 @@ describe('spawnFromGroup', () => { }) }) +describe('machineOfGroup', () => { + it('reads the machine a heading names', () => { + expect(machineOfGroup('machine:m1')).toBe('m1') + }) + + it('says nothing for a heading about anything else', () => { + expect(machineOfGroup('tag:api')).toBeNull() + expect(machineOfGroup('dir:/Users/karn/code/flue')).toBeNull() + expect(machineOfGroup('state:running')).toBeNull() + expect(machineOfGroup('untagged')).toBeNull() + expect(machineOfGroup('all')).toBeNull() + }) + + it('finds the machine at either level of a two-cut heading', () => { + // Machine over tag is the default cut, and tag over machine is a click + // away — a subheading names a machine just as truly as a heading does. + expect(machineOfGroup(`machine:m1${SUBKEY_SEP}tag:api`)).toBe('m1') + expect(machineOfGroup(`tag:api${SUBKEY_SEP}machine:m1`)).toBe('m1') + }) + + it('keeps a colon that belongs to the id rather than to the prefix', () => { + expect(machineOfGroup('machine:a:b')).toBe('a:b') + }) +}) + describe('groupAcceptsDrop', () => { it('admits drops onto tag headings alone', () => { // Tags are the one group-defining fact a person assigns; everything else diff --git a/web/src/sessions/view.ts b/web/src/sessions/view.ts index 385344d..30259c6 100644 --- a/web/src/sessions/view.ts +++ b/web/src/sessions/view.ts @@ -419,6 +419,13 @@ interface Bucket { rank: number } +/** + * What a machine heading's key starts with, spelled once: three places read + * or write it — the bucket below, `spawnFromOne`, and `machineOfGroup` — and + * a fourth spelling would be a heading nothing could resolve back. + */ +const MACHINE_PREFIX = 'machine:' + const BUCKETS: Record Bucket[]> = { // Keyed by id and labelled by name, so two machines a user has given the // same name to stay apart. The machine this tab is riding leads: it is the @@ -427,7 +434,7 @@ const BUCKETS: Record Bucket[]> = { // single-machine screen depend on what its owner happened to call it. machine: (s) => [ { - key: `machine:${s.machineId}`, + key: `${MACHINE_PREFIX}${s.machineId}`, label: s.machineName, rank: s.machineId === LOCAL_MACHINE_ID ? 0 : 1, }, @@ -500,7 +507,7 @@ export function spawnFromGroup( function spawnFromOne(grouping: Grouping, groupKey: string): SpawnRequest | null { switch (grouping) { case 'machine': - return { machineId: after(groupKey, 'machine:') } + return { machineId: after(groupKey, MACHINE_PREFIX) } case 'directory': return { cwd: after(groupKey, 'dir:') } case 'tag': @@ -526,6 +533,25 @@ function after(key: string, prefix: string): string { return key.startsWith(prefix) ? key.slice(prefix.length) : key } +/** + * The machine a heading names, or null for a heading about anything else. + * + * The key is read rather than the label, for the reason spawnFromGroup gives: + * the label is what a person sees, and a machine somebody called `api` must + * not be able to decide what its own heading is marked with. + * + * Both levels of a two-cut key are searched because either may be the machine + * one: the default cut is machine over tag, and tag over machine is one click + * away in the display options. A key names at most one machine, so the first + * segment carrying the prefix is the answer. + */ +export function machineOfGroup(groupKey: string): string | null { + for (const part of groupKey.split(SUBKEY_SEP)) { + if (part.startsWith(MACHINE_PREFIX)) return part.slice(MACHINE_PREFIX.length) + } + return null +} + /** * What letting a dragged row go over a group's heading should do. *