Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions web/src/components/session-switcher.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,25 @@ describe('SessionSwitcher', () => {
expect(props.onPick).toHaveBeenCalledWith({ machineId: 'local', sessionId: 'p2' })
})

it('leaves exited sessions out until a search names one, then says it ended', async () => {
const user = userEvent.setup()
mount({
sessions: [
s({ id: 'a', name: 'pnpm build' }),
s({ id: 'over', name: 'old deploy', state: 'exited' }),
],
})
// At rest the palette is a "take me there" control, and a dead session is
// not a there.
expect(rowNames().join(' ')).not.toContain('old deploy')

await user.type(field(), 'deploy')

const row = screen.getAllByRole('option')[0]!
expect(row.textContent).toContain('old deploy')
expect(row.textContent).toContain('exited')
})

it('shows a remembered session whose machine has gone, and still opens it', async () => {
const user = userEvent.setup()
const { props } = mount({
Expand Down
8 changes: 8 additions & 0 deletions web/src/components/session-switcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,12 @@ function Hint({ keys, what }: { keys: string; what: string }) {
* a machine this browser cannot reach right now can say nothing about what is
* happening inside it, and a confident green dot over an unreachable machine
* would be the list inventing news.
*
* An ended row also says so in a word — "exited", the sessions screen's own
* word for it — as a ghost says "unreachable". It can afford the space: ended
* rows only appear as search results (switcher/order.ts keeps them out of the
* resting palette), where the reader is choosing between matches and "this
* one is over" is the fact that decides.
*/
function Row({
row,
Expand Down Expand Up @@ -425,6 +431,8 @@ function Row({
<span className="font-mono text-xs text-muted-foreground">{rowMachine(row)}</span>
{ghost ? (
<span className="text-xs text-muted-foreground">unreachable</span>
) : ended ? (
<span className="text-xs text-muted-foreground">exited</span>
) : row.badge !== null ? (
<kbd className="font-mono text-xs text-muted-foreground">⌃⇧{row.badge}</kbd>
) : null}
Expand Down
62 changes: 56 additions & 6 deletions web/src/routes/sessions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -257,13 +257,58 @@ describe('SessionsRoute', () => {
const { sock } = await mountSessions()
listed(sock, [info({ id: 's1' }), info({ id: 's2', state: 'exited' })])

// The ended session has to be on show before State can have two headings:
// the default folds it away.
await user.click(screen.getByRole('button', { name: 'Show exited sessions' }))
await user.click(screen.getByRole('button', { name: 'Display options' }))
await pick(user, 'Grouping', 'State')

expect(screen.getByRole('button', { name: 'Running' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'Exited' })).toBeTruthy()
})

describe('the ended fold', () => {
it('folds ended sessions away by default and says how many', async () => {
const { sock } = await mountSessions()
listed(sock, [
info({ id: 's1', cwd: '/live' }),
info({ id: 's2', cwd: '/dead', state: 'exited' }),
info({ id: 's3', cwd: '/dead-too', state: 'exited' }),
])

expect(screen.getByText('/live')).toBeTruthy()
expect(screen.queryByText('/dead')).toBeNull()
expect(screen.getByText('exited sessions')).toBeTruthy()
expect(screen.getByText('2')).toBeTruthy()

await userEvent.click(screen.getByRole('button', { name: 'Show exited sessions' }))

expect(screen.getByText('/dead')).toBeTruthy()
expect(screen.getByText('/dead-too')).toBeTruthy()
// An open fold hides nothing, so the count would be a claim about
// nothing; the display options own the way back.
expect(screen.queryByRole('button', { name: 'Show exited sessions' })).toBeNull()
})

it('does not claim an empty fleet when everything on it has ended', async () => {
// "No sessions yet" is a claim about the fleet, and a fleet whose every
// session has ended is not one that has none — the fold line stands in
// for the rows it hides.
const { sock, welcomeLocal, attic } = await mountSessions()
welcomeLocal()
act(() => attic.sockets[0]!.open())
listed(sock, [info({ id: 's1', cwd: '/dead', state: 'exited' })])
listed(attic.sockets[0]!, [])

expect(screen.queryByText(/No sessions yet/i)).toBeNull()
expect(screen.getByText('exited session')).toBeTruthy()

await userEvent.click(screen.getByRole('button', { name: 'Show exited sessions' }))

expect(screen.getByText('/dead')).toBeTruthy()
})
})

it('opens a session on the machine that owns it, from the row itself', async () => {
const { sock, attic, router } = await mountSessions()
act(() => attic.sockets[0]!.open())
Expand Down Expand Up @@ -886,6 +931,9 @@ describe('SessionsRoute', () => {
info({ id: 's2', state: 'exited', exitCode: 1 }),
])
await user.click(screen.getByRole('button', { name: 'Display options' }))
// The default folds the ended session away, and a heading that is not
// there can prove nothing about its controls.
await user.click(screen.getByRole('checkbox', { name: 'Show exited sessions' }))
await pick(user, 'Grouping', 'State')
await user.keyboard('{Escape}')

Expand Down Expand Up @@ -952,29 +1000,30 @@ describe('SessionsRoute', () => {
const { sock } = await mountSessions()
listed(sock, [info({ id: 's1', cwd: '/live' }), info({ id: 's2', cwd: '/dead', state: 'exited' })])

// Arrange: ended sessions out, then keep that under a name.
// Arrange: ended sessions in — the default folds them away — then keep
// that under a name.
await user.click(screen.getByRole('button', { name: 'Display options' }))
await user.click(screen.getByRole('checkbox', { name: 'Show exited sessions' }))
await user.keyboard('{Escape}')
expect(screen.queryByText('/dead')).toBeNull()
expect(screen.getByText('/dead')).toBeTruthy()

await saveAs(user, 'Ops')
expect(screen.getByRole('button', { name: 'Ops' }).getAttribute('aria-pressed')).toBe('true')

// All is the built-in default: everything comes back.
// All is the built-in default: the ended session folds away again.
await user.click(screen.getByRole('button', { name: 'All' }))
expect(screen.getByText('/dead')).toBeTruthy()
expect(screen.queryByText('/dead')).toBeNull()

// And the tab re-applies what it kept.
await user.click(screen.getByRole('button', { name: 'Ops' }))
expect(screen.queryByText('/dead')).toBeNull()
expect(screen.getByText('/dead')).toBeTruthy()

const kept = JSON.parse(localStorage.getItem('flue.views')!) as Array<{
name: string
showExited: boolean
}>
expect(kept).toHaveLength(1)
expect(kept[0]).toMatchObject({ name: 'Ops', showExited: false })
expect(kept[0]).toMatchObject({ name: 'Ops', showExited: true })
})

it('marks dirty by value, so an edit undone is no edit at all', async () => {
Expand Down Expand Up @@ -1036,6 +1085,7 @@ describe('SessionsRoute', () => {
const first = await mountSessions()
listed(first.sock, [info({ id: 's1' }), info({ id: 's2', state: 'exited' })])
await user.click(screen.getByRole('button', { name: 'Display options' }))
await user.click(screen.getByRole('checkbox', { name: 'Show exited sessions' }))
await pick(user, 'Grouping', 'State')
await user.keyboard('{Escape}')
first.unmount()
Expand Down
47 changes: 46 additions & 1 deletion web/src/routes/sessions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
applyView,
DEFAULT_VIEW,
displayName,
hiddenExited,
spawnFromGroup,
type Group,
type ViewConfig,
Expand Down Expand Up @@ -391,6 +392,8 @@ export function SessionsRoute() {
const machines = fleetState?.machines ?? null

const groups = useMemo(() => applyView(sessions, view), [sessions, view])
/** How many rows the view folded away for having ended. See hiddenExited. */
const hiddenEnded = useMemo(() => hiddenExited(sessions, view), [sessions, view])
const byKey = useMemo(() => new Map(sessions.map((s) => [keyOf(s), s])), [sessions])
const knownTags = useMemo(
() => [...new Set(sessions.flatMap((s) => s.tags))].sort(),
Expand Down Expand Up @@ -624,10 +627,13 @@ export function SessionsRoute() {
* machine still dialling, and at least one machine actually online — "No
* sessions yet" from a screen whose machines are all down would be a lie
* the placeholders and the unreachable bands are there to avoid telling.
* A list that is only empty because it folded its ended sessions away is
* not empty either; the fold line below stands in for the rows it hides.
*/
const showTable =
fleetState !== null &&
(groups.length > 0 || (settled && connecting.length === 0 && online.length > 0))
(groups.length > 0 ||
(settled && connecting.length === 0 && online.length > 0 && hiddenEnded === 0))

/** Placeholder rows, for machines that have not had the chance to answer. */
const showSkeleton =
Expand Down Expand Up @@ -758,6 +764,13 @@ export function SessionsRoute() {
/>
)}

{hiddenEnded > 0 && (
<ExitedFold
count={hiddenEnded}
onShow={() => setView((v) => ({ ...v, showExited: true }))}
/>
)}

{showSkeleton && <LoadingRows />}

{view.grouping === 'machine' &&
Expand Down Expand Up @@ -949,6 +962,38 @@ function FleetGapBand({ gaps }: { gaps: FleetGaps }) {
)
}

/**
* The ended sessions the view folded away, counted where their rows would
* have ended up — under the list, since every ordering reads the living
* before the dead.
*
* A line and not a band: the bands above it report problems, and a fold is
* an arrangement working as designed. It exists because the default hides
* ended sessions, and a default that hid them without saying so would make
* an emptied list read as an empty fleet — the count keeps them discoverable
* from the screen itself rather than from a checkbox in the display options.
* Show sets the same `showExited` the checkbox owns, so the reveal is a view
* edit like any other: the dirty flag lights, the choice persists, and the
* checkbox is already ticked for whoever goes looking for the way back.
*
* "Exited", not a softer word, because it is the word this screen already
* uses everywhere it counts these sessions — the state heading, the group
* headcounts, the checkbox — and the fold naming them anything else would
* read as a third kind of session.
*/
function ExitedFold({ count, onShow }: { count: number; onShow(): void }) {
return (
<p className="flex items-center gap-x-1.5 text-base/6 text-zinc-500 sm:text-sm/6 dark:text-zinc-400">
<span className="font-medium text-zinc-950 dark:text-white">{count}</span>
{count === 1 ? 'exited session' : 'exited sessions'}
<span aria-hidden="true">·</span>
<Button variant="ghost" size="sm" aria-label="Show exited sessions" onClick={onShow}>
Show
</Button>
</p>
)
}

/**
* A machine the fleet cannot reach right now, said in a muted band where its
* rows would have been. Retry redials that one machine's client, and does it
Expand Down
29 changes: 28 additions & 1 deletion web/src/sessions/view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
GROUPING_LABELS,
GROUPINGS,
groupSessions,
hiddenExited,
ORDERING_LABELS,
ORDERINGS,
orderSessions,
Expand Down Expand Up @@ -395,7 +396,12 @@ describe('DEFAULT_VIEW', () => {
expect(DEFAULT_VIEW.grouping).toBe('machine')
expect(DEFAULT_VIEW.ordering).toBe('lastActive')
expect(DEFAULT_VIEW.search).toBe('')
expect(DEFAULT_VIEW.showExited).toBe(true)
})

it('folds the ended sessions away', () => {
// An exited session is history, not somewhere to go; the list opens on
// what is running, and `hiddenExited` is what keeps the fold honest.
expect(DEFAULT_VIEW.showExited).toBe(false)
})

it('shows every column but the creation time', () => {
Expand Down Expand Up @@ -494,6 +500,27 @@ describe('applyView', () => {
})
})

describe('hiddenExited', () => {
const rows = [
s({ id: 'live', cwd: '/code/flue' }),
s({ id: 'gone', cwd: '/srv/db', state: 'exited' }),
s({ id: 'gone-too', cwd: '/srv/cache', state: 'exited' }),
]

it('counts what the fold is hiding', () => {
expect(hiddenExited(rows, { ...DEFAULT_VIEW, showExited: false })).toBe(2)
})

it('is zero when the view shows them, since an open fold hides nothing', () => {
expect(hiddenExited(rows, { ...DEFAULT_VIEW, showExited: true })).toBe(0)
})

it('counts inside the search, so the sentence matches the list it sits under', () => {
expect(hiddenExited(rows, { ...DEFAULT_VIEW, showExited: false, search: 'srv/db' })).toBe(1)
expect(hiddenExited(rows, { ...DEFAULT_VIEW, showExited: false, search: 'flue' })).toBe(0)
})
})

describe('spawnFromGroup', () => {
it('hands a machine heading its own machine', () => {
expect(spawnFromGroup('machine', 'machine:m1')).toEqual({ machineId: 'm1' })
Expand Down
32 changes: 26 additions & 6 deletions web/src/sessions/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,11 +107,14 @@ export interface ViewConfig {
*
* Grouped by machine because the fleet is the reason this screen was rebuilt —
* "what is running where" is the first question, and a heading per machine
* answers it before a single row is read. Ended sessions stay in, because a
* session that exited three minutes ago with a non-zero code is exactly what
* someone comes here to find; hiding it by default would make the screen
* quietly lie about what happened. Every column but the creation time, which
* is the one people ask for rarely and can turn on.
* answers it before a single row is read. Ended sessions start hidden: an
* exited session is history rather than somewhere to go, and a list that
* opens on last week's dead shells buries the rows someone came to act on.
* They are folded away rather than gone — `hiddenExited` counts them, the
* screen says the count out loud, and one press brings them back — so the
* session that exited three minutes ago with a non-zero code is still one
* click from being found. Every column but the creation time, which is the
* one people ask for rarely and can turn on.
*
* Frozen, and its column list with it, because this is one object shared by
* every browser tab that has never saved an arrangement. `ViewConfig` is a
Expand All @@ -126,7 +129,7 @@ export const DEFAULT_VIEW: ViewConfig = Object.freeze<ViewConfig>({
ordering: 'lastActive',
search: '',
columns: frozen(['name', 'directory', 'machine', 'tags', 'state', 'lastActive']),
showExited: true,
showExited: false,
})

/**
Expand Down Expand Up @@ -439,3 +442,20 @@ export function applyView(list: FleetSession[], v: ViewConfig): Group[] {
const wanted = v.showExited ? matched : matched.filter((s) => s.state !== 'exited')
return groupSessions(orderSessions(wanted, v.ordering), v.grouping)
}

/**
* How many sessions the view folded away for having ended.
*
* The number the screen says out loud under the list: a default that hides
* ended sessions silently would make an emptied list read as an empty fleet,
* and the way back — the display options' checkbox — is set somewhere the
* reader has no reason to look. Counted after the search for the same reason
* `applyView` filters after it: the sentence sits under a searched list, and
* "3 ended" must mean three the reader would see, not three somewhere in the
* fleet. Zero whenever the view shows them, because a fold that is open hides
* nothing.
*/
export function hiddenExited(list: FleetSession[], v: ViewConfig): number {
if (v.showExited) return 0
return filterSessions(list, v.search).filter((s) => s.state === 'exited').length
}
Loading