From 382be21ca519068c2e72af1918fe0f88512c7fe8 Mon Sep 17 00:00:00 2001 From: Karn Date: Fri, 21 Aug 2026 01:05:04 +0530 Subject: [PATCH] feat(web): cut the sessions list twice, machine then tag by default Display options gain a "Then by" select. Groups carry optional children, keyed parent plus child so one tag on two machines folds and drops apart, and a second cut that yields a single subheading flattens away. Stored arrangements missing the field read as cut once, so saved tabs look as they did before. Co-Authored-By: Claude Fable 5 --- web/src/components/display-options.test.tsx | 66 +++++++- web/src/components/display-options.tsx | 29 +++- web/src/components/session-table.test.tsx | 84 +++++++++- web/src/components/session-table.tsx | 87 +++++++--- web/src/routes/sessions.test.tsx | 4 +- web/src/routes/sessions.tsx | 14 +- web/src/sessions/view.test.ts | 168 ++++++++++++++++++++ web/src/sessions/view.ts | 115 +++++++++++++- web/src/sessions/views-store.test.ts | 23 +++ web/src/sessions/views-store.ts | 16 ++ 10 files changed, 569 insertions(+), 37 deletions(-) diff --git a/web/src/components/display-options.test.tsx b/web/src/components/display-options.test.tsx index 9609e12..485400c 100644 --- a/web/src/components/display-options.test.tsx +++ b/web/src/components/display-options.test.tsx @@ -11,6 +11,7 @@ import { GROUPINGS, ORDERING_LABELS, ORDERINGS, + SUBGROUPING_LABELS, type ViewConfig, } from '@/sessions/view' import { DisplayOptions } from './display-options' @@ -112,7 +113,7 @@ describe('DisplayOptions', () => { }) it('edits the grouping and nothing else', async () => { - const { user, onChange, view } = await open() + const { user, onChange, view } = await open({ subgrouping: 'none' }) await pick(user, 'Grouping', 'Tag') @@ -120,6 +121,69 @@ describe('DisplayOptions', () => { expect(onChange).toHaveBeenCalledWith({ ...view, grouping: 'tag' }) }) + describe('the second cut', () => { + it('offers every grouping but the first, and calls the way out "Nothing"', async () => { + const { user } = await open({ grouping: 'machine' }) + screen.getByRole('combobox', { name: 'Then by' }).focus() + await user.keyboard('{Enter}') + + // The first cut again would be one subheading under every heading — a + // choice that can only ever draw nothing. + expect(screen.queryByRole('option', { name: 'Machine' })).toBeNull() + for (const grouping of GROUPINGS) { + if (grouping === 'machine') continue + expect( + screen.getByRole('option', { name: SUBGROUPING_LABELS[grouping] }), + grouping, + ).toBeTruthy() + } + expect(screen.getByRole('option', { name: 'Nothing' })).toBeTruthy() + }) + + it('reads back the second cut it was handed', async () => { + await open({ grouping: 'machine', subgrouping: 'tag' }) + expect(screen.getByRole('combobox', { name: 'Then by' }).textContent).toContain('Tag') + }) + + it('edits the second cut and nothing else', async () => { + const { user, onChange, view } = await open({ grouping: 'machine', subgrouping: 'none' }) + + await pick(user, 'Then by', 'Tag') + + expect(onChange).toHaveBeenCalledTimes(1) + expect(onChange).toHaveBeenCalledWith({ ...view, subgrouping: 'tag' }) + }) + + it('turns the second cut off when the first cut takes its key', async () => { + // Machine then tag, and the reader picks Tag for the first cut: a view + // cut by tag then tag would draw exactly as tag alone, and a select + // still reading "Tag" under it would claim a second cut that is not + // there. + const { user, onChange, view } = await open({ grouping: 'machine', subgrouping: 'tag' }) + + await pick(user, 'Grouping', 'Tag') + + expect(onChange).toHaveBeenCalledWith({ ...view, grouping: 'tag', subgrouping: 'none' }) + }) + + it('turns the second cut off, and the control with it, when grouping is off', async () => { + // Nothing cut once cannot be cut twice. + const { user, onChange, view } = await open({ grouping: 'machine', subgrouping: 'tag' }) + + await pick(user, 'Grouping', 'No grouping') + + expect(onChange).toHaveBeenCalledWith({ ...view, grouping: 'none', subgrouping: 'none' }) + }) + + it('refuses the second cut while there is no first', async () => { + await open({ grouping: 'none', subgrouping: 'none' }) + const then = screen.getByRole('combobox', { name: 'Then by' }) + expect(then.hasAttribute('disabled') || then.getAttribute('aria-disabled') === 'true').toBe( + true, + ) + }) + }) + it('edits the ordering, and turns the direction back to that key’s own', async () => { // The default view reads by last active, newest first. Directory is a // textual key that naturally reads a to z — a direction remembered from diff --git a/web/src/components/display-options.tsx b/web/src/components/display-options.tsx index 4d5d6de..867b1e7 100644 --- a/web/src/components/display-options.tsx +++ b/web/src/components/display-options.tsx @@ -22,6 +22,7 @@ import { GROUPINGS, ORDERING_LABELS, ORDERINGS, + SUBGROUPING_LABELS, type ColumnKey, type ViewConfig, } from '@/sessions/view' @@ -80,7 +81,28 @@ export function DisplayOptions({ view, onChange }: DisplayOptionsProps) { value={view.grouping} options={GROUPINGS} labels={GROUPING_LABELS} - onPick={(grouping) => onChange({ ...view, grouping })} + // The first cut taking the second's key, or going away entirely, + // takes the second cut with it: tag then tag draws as tag alone, + // and nothing cut once cannot be cut twice. A select left reading + // "Tag" underneath would claim a second cut that is not there. + onPick={(grouping) => + onChange({ + ...view, + grouping, + subgrouping: + grouping === 'none' || grouping === view.subgrouping ? 'none' : view.subgrouping, + }) + } + /> + g !== view.grouping)} + labels={SUBGROUPING_LABELS} + disabled={view.grouping === 'none'} + onPick={(subgrouping) => onChange({ ...view, subgrouping })} /> ({ value, options, labels, + disabled = false, onPick, }: { label: string value: T options: readonly T[] labels: Record + /** A choice with nothing to choose right now; it keeps its place and its word. */ + disabled?: boolean onPick(value: T): void }) { return ( @@ -224,7 +249,7 @@ function Choice({ `onValueChange` as a bare string; the only values it can emit are the ones rendered below, which are `T`. */} - onPick(next as T)}> diff --git a/web/src/components/session-table.test.tsx b/web/src/components/session-table.test.tsx index dfa29fe..07e047e 100644 --- a/web/src/components/session-table.test.tsx +++ b/web/src/components/session-table.test.tsx @@ -4,7 +4,7 @@ import { describe, expect, it, vi } from 'vitest' import type { FleetSession } from '@/fleet/types' import { renderWithRouter } from '@/testing/render' -import { COLUMN_KEYS, type Group } from '@/sessions/view' +import { COLUMN_KEYS, SUBKEY_SEP, type Group } from '@/sessions/view' import { SessionTable } from './session-table' /** @@ -150,6 +150,88 @@ describe('SessionTable', () => { }) }) + describe('groups cut twice', () => { + const api = fs({ id: 'a1', title: 'api-shell', tags: ['api'] }) + const ops = fs({ id: 'b2', title: 'ops-shell', tags: ['ops'] }) + const nested: Group = { + ...group('machine:m1', 'MacBook Pro', [api, ops]), + children: [ + group(`machine:m1${SUBKEY_SEP}tag:api`, 'api', [api]), + group(`machine:m1${SUBKEY_SEP}tag:ops`, 'ops', [ops]), + ], + } + + it('draws a subheading per child, with its own tally, under the parent', async () => { + const { container } = await renderTable({ groups: [nested] }) + + expect(screen.getByRole('button', { name: 'api' })).toBeTruthy() + expect(screen.getByRole('button', { name: 'ops' })).toBeTruthy() + const text = container.textContent! + expect(text.indexOf('MacBook Pro')).toBeLessThan(text.indexOf('api')) + expect(text.indexOf('api')).toBeLessThan(text.indexOf('ops')) + }) + + it('draws every row once, under its child and not again under the parent', async () => { + // The parent carries the whole run for its tally; the table must not + // print that run a second time above the subheadings. + await renderTable({ groups: [nested] }) + expect(screen.getAllByRole('link', { name: /api-shell/ })).toHaveLength(1) + expect(screen.getAllByRole('link', { name: /ops-shell/ })).toHaveLength(1) + }) + + it('tallies the parent over every row and the child over its own', async () => { + await renderTable({ groups: [nested] }) + expect(screen.getByText('2 running')).toBeTruthy() + expect(screen.getAllByText('1 running')).toHaveLength(2) + }) + + it('folds a child on its own key, leaving its siblings open', async () => { + await renderTable({ + groups: [nested], + collapsed: new Set([`machine:m1${SUBKEY_SEP}tag:api`]), + }) + + expect(screen.getByRole('button', { name: 'api' }).getAttribute('aria-expanded')).toBe( + 'false', + ) + expect(screen.queryByRole('link', { name: /api-shell/ })).toBeNull() + expect(screen.getByRole('link', { name: /ops-shell/ })).toBeTruthy() + }) + + it('folds the parent over every child', async () => { + await renderTable({ groups: [nested], collapsed: new Set(['machine:m1']) }) + expect(screen.queryByRole('button', { name: 'api' })).toBeNull() + expect(screen.queryByRole('link')).toBeNull() + }) + + it('reports a child toggle by the child’s key', async () => { + const { props } = await renderTable({ groups: [nested] }) + await userEvent.click(screen.getByRole('button', { name: 'api' })) + expect(props.onToggleGroup).toHaveBeenCalledWith(`machine:m1${SUBKEY_SEP}tag:api`) + }) + + it('offers a spawn control on a child, named for the child', async () => { + const onSpawnIn = vi.fn() + await renderTable({ + groups: [nested], + onSpawnIn, + spawnLabel: (g) => (g.children === undefined ? `New session tagged ${g.label}` : undefined), + }) + + expect(screen.queryByRole('button', { name: /on MacBook Pro/ })).toBeNull() + await userEvent.click(screen.getByRole('button', { name: 'New session tagged api' })) + expect(onSpawnIn).toHaveBeenCalledWith(nested.children![0]) + }) + + it('wears a grip when only the children could take a drop', async () => { + await renderTable({ + groups: [nested], + drag: { droppable: (g) => g.children === undefined, onDrop: vi.fn() }, + }) + expect(screen.getAllByTitle('Drag to move to another group').length).toBeGreaterThan(0) + }) + }) + describe('the fields a row carries', () => { it('draws no header row of field names at all', async () => { // The rows are one list, not a spreadsheet: what each piece is, its diff --git a/web/src/components/session-table.tsx b/web/src/components/session-table.tsx index b0b739a..8f6068c 100644 --- a/web/src/components/session-table.tsx +++ b/web/src/components/session-table.tsx @@ -582,7 +582,8 @@ export function SessionTable({ // refusals are part of what it says — but an advertisement is a promise, // and a grip down a list with nowhere to go promises a move that every // release would refuse. - const liftable = drag !== undefined && groups.some((g) => drag.droppable(g)) + const liftable = + drag !== undefined && groups.some((g) => drag.droppable(g) || g.children?.some(drag.droppable)) /* * The drag layer's own bookkeeping, all of it about saying things: who the @@ -628,21 +629,11 @@ export function SessionTable({ !s.pinned) : 0} - /* - * What this heading's `+` would be called, and therefore whether - * it exists at all. - * - * Resolved once, per group, and checked — not merely passed to - * aria-label. A group that refuses one answers undefined (see - * `spawnFromGroup`, and "Exited" for the case that motivates it), - * and an unchecked answer rendered a button with no accessible - * name: a `+` a pointer can press, a click the caller then - * refuses, and nothing for a screen reader to announce it as. - */ - spawn={onSpawnIn === undefined ? undefined : spawnLabel?.(g)} + collapsed={collapsed} + spawnLabel={spawnLabel} drag={drag} handle={liftable} panes={panes} @@ -688,9 +679,10 @@ export function SessionTable({ */ function GroupSection({ g, - open, + depth, boundary, - spawn, + collapsed, + spawnLabel, drag, handle, panes, @@ -703,9 +695,11 @@ function GroupSection({ peek, }: { g: Group - open: boolean + /** How many headings sit above this one: 0 for a group, 1 for its children. */ + depth: number boundary: number - spawn?: string + collapsed: ReadonlySet + spawnLabel?(group: Group): string | undefined drag?: DragToGroup handle: boolean panes?: ReadonlyMap @@ -729,6 +723,19 @@ function GroupSection({ disabled: drag === undefined, }) const droppable = drag !== undefined && drag.droppable(g) + const open = !collapsed.has(g.key) + /* + * What this heading's `+` would be called, and therefore whether it exists + * at all. + * + * Resolved once, per group, and checked — not merely passed to aria-label. + * A group that refuses one answers undefined (see `spawnFromGroup`, and + * "Exited" for the case that motivates it), and an unchecked answer + * rendered a button with no accessible name: a `+` a pointer can press, a + * click the caller then refuses, and nothing for a screen reader to + * announce it as. + */ + const spawn = onSpawnIn === undefined ? undefined : spawnLabel?.(g) return (
{/* @@ -766,7 +773,15 @@ function GroupSection({ !open && '-rotate-90', )} /> - {g.label} + {/* + A subheading reads a step quieter than the heading over it, so the + two levels tell apart by colour and indent alone — the heading's + weight would make every tag under a machine as loud as the + machine. + */} + 0 && 'text-zinc-600 dark:text-zinc-300')}> + {g.label} + {tally(g.sessions)} @@ -791,7 +806,39 @@ function GroupSection({ )} - {open && ( + {open && g.children !== undefined && ( + /* + * The second cut, when there is one: the same rows again under + * subheadings, each a section of its own with its own fold, `+` and + * drop target. Indented by the width of the chevron and its gap, so + * a child's chevron sits under its parent's label and the tree reads + * as one. The parent's own `sessions` are not drawn — every one of + * them is under some child — but they are what its tally counted. + */ +
+ {g.children.map((c) => ( + + ))} +
+ )} + {open && g.children === undefined && (
    {g.sessions.map((s, at) => ( // Keyed by the same composite the selection uses: unique diff --git a/web/src/routes/sessions.test.tsx b/web/src/routes/sessions.test.tsx index 3e06a4a..512be66 100644 --- a/web/src/routes/sessions.test.tsx +++ b/web/src/routes/sessions.test.tsx @@ -462,7 +462,9 @@ describe('SessionsRoute', () => { listed(sock, [info({ id: 's1', name: 'alpha', tags: ['api', 'prod'] })]) listed(attic.sockets[0]!, [info({ id: 's2', name: 'beta', tags: ['api'] })]) - await userEvent.click(screen.getByRole('checkbox', { name: 'Select alpha' })) + // The default view cuts each machine by tag, and alpha carries two — so + // it has a row under each, and either row's box selects the session. + await userEvent.click(screen.getAllByRole('checkbox', { name: 'Select alpha' })[0]!) await userEvent.click(screen.getByRole('checkbox', { name: 'Select beta' })) expect(screen.getByText('2 selected')).toBeTruthy() return mounted diff --git a/web/src/routes/sessions.tsx b/web/src/routes/sessions.tsx index eb9f871..64031bf 100644 --- a/web/src/routes/sessions.tsx +++ b/web/src/routes/sessions.tsx @@ -90,6 +90,7 @@ function defaultView(): ViewConfig { function configOf(v: SavedView): ViewConfig { return { grouping: v.grouping, + subgrouping: v.subgrouping, ordering: v.ordering, direction: v.direction, search: v.search, @@ -109,6 +110,7 @@ function configOf(v: SavedView): ViewConfig { function sameArrangement(a: ViewConfig, b: ViewConfig): boolean { return ( a.grouping === b.grouping && + a.subgrouping === b.subgrouping && a.ordering === b.ordering && a.direction === b.direction && a.search === b.search && @@ -462,7 +464,7 @@ export function SessionsRoute() { * can still start something tagged `api` and `staging`. */ const spawnInGroup = (group: Group) => { - const want = spawnFromGroup(view.grouping, group.key) + const want = spawnFromGroup(view.grouping, group.key, view.subgrouping) if (want === null) return setCreating({ machineId: want.machineId ?? primaryTarget, @@ -479,7 +481,7 @@ export function SessionsRoute() { * offered for a group the click would refuse. */ const spawnLabel = (group: Group): string | undefined => { - const want = spawnFromGroup(view.grouping, group.key) + const want = spawnFromGroup(view.grouping, group.key, view.subgrouping) if (want === null) return undefined if (want.tag !== undefined) return `New session tagged ${want.tag}` if (want.cwd !== undefined) return `New session in ${want.cwd}` @@ -503,11 +505,11 @@ export function SessionsRoute() { */ const dragToGroup = useMemo(() => { if (view.grouping === 'none') return undefined - const grouping = view.grouping + const { grouping, subgrouping } = view return { - droppable: () => groupAcceptsDrop(grouping), + droppable: () => groupAcceptsDrop(grouping, subgrouping), onDrop: (s, fromKey, group) => { - const verdict = dropOnGroup(grouping, s, fromKey, group.key) + const verdict = dropOnGroup(grouping, s, fromKey, group.key, subgrouping) if (verdict.kind === 'retag') { fleet.update(s.machineId, { id: s.id, tags: verdict.tags }) fleet.list() @@ -521,7 +523,7 @@ export function SessionsRoute() { } }, } - }, [view.grouping, fleet]) + }, [view.grouping, view.subgrouping, fleet]) /** * How a row asks what it is doing, for the hover preview. diff --git a/web/src/sessions/view.test.ts b/web/src/sessions/view.test.ts index 2168bde..c7e0ae4 100644 --- a/web/src/sessions/view.test.ts +++ b/web/src/sessions/view.test.ts @@ -21,6 +21,9 @@ import { ORDERINGS, orderSessions, spawnFromGroup, + SUBGROUPING_LABELS, + SUBKEY_SEP, + type Group, } from './view' /** @@ -768,3 +771,168 @@ describe('dropOnGroup', () => { expect(row.tags).toEqual(['api', 'ops']) }) }) + +describe('groupSessions, cut twice', () => { + const rows = [ + s({ id: 'a', tags: ['api'] }), + s({ id: 'b', tags: ['ops', 'api'] }), + s({ id: 'c' }), + s({ id: 'z', machineId: 'zeta', machineName: 'zeta box', tags: ['api'] }), + s({ id: 'y', machineId: 'zeta', machineName: 'zeta box' }), + s({ id: 'p', machineId: 'pi', machineName: 'attic pi' }), + ] + + it('cuts each group again by the second key, remainder last', () => { + const [local] = groupSessions(rows, 'machine', 'tag') + expect(local!.key).toBe('machine:local') + expect(local!.children!.map((g) => g.label)).toEqual(['api', 'ops', 'No tag']) + expect(local!.children!.map((g) => ids(g.sessions))).toEqual([['a', 'b'], ['b'], ['c']]) + }) + + it('keeps the whole run on the parent, so a tally counts every row once', () => { + // A session under two tags is still one session on the machine. + const [local] = groupSessions(rows, 'machine', 'tag') + expect(ids(local!.sessions)).toEqual(['a', 'b', 'c']) + }) + + it('keys a child under its parent, so one tag on two machines stays apart', () => { + const [local, , zeta] = groupSessions(rows, 'machine', 'tag') + const api = (g: Group) => g.children!.find((c) => c.label === 'api')!.key + expect(api(local!)).toBe(`machine:local${SUBKEY_SEP}tag:api`) + expect(api(zeta!)).toBe(`machine:zeta${SUBKEY_SEP}tag:api`) + }) + + it('flattens a group whose second cut is one heading', () => { + // One subheading over every row of a group says nothing the group's own + // heading did not, and "No tag" alone under a machine is a heading over + // the remainder of nothing. + const groups = groupSessions(rows, 'machine', 'tag') + const pi = groups.find((g) => g.key === 'machine:pi')! + expect(pi.children).toBeUndefined() + expect(ids(pi.sessions)).toEqual(['p']) + }) + + it('has no children at all when the second key is off', () => { + for (const g of groupSessions(rows, 'machine', 'none')) expect(g.children).toBeUndefined() + for (const g of groupSessions(rows, 'machine')) expect(g.children).toBeUndefined() + }) + + it('gives every heading, parent or child, a key no other can claim', () => { + const keys = groupSessions(rows, 'tag', 'machine').flatMap((g) => [ + g.key, + ...(g.children ?? []).map((c) => c.key), + ]) + expect(new Set(keys).size).toBe(keys.length) + }) +}) + +describe('DEFAULT_VIEW, cut twice', () => { + it('groups by machine, then by tag', () => { + expect(DEFAULT_VIEW.grouping).toBe('machine') + expect(DEFAULT_VIEW.subgrouping).toBe('tag') + }) +}) + +describe('the words for the second key', () => { + it('labels every grouping, and calls the way out of it "Nothing"', () => { + // "Then by: No grouping" reads as a contradiction; "Then by: Nothing" is + // the sentence a person would say. + for (const g of GROUPINGS) expect(SUBGROUPING_LABELS[g], g).toMatch(/^[A-Z][^A-Z]*$/) + expect(SUBGROUPING_LABELS.none).toBe('Nothing') + expect(SUBGROUPING_LABELS.tag).toBe(GROUPING_LABELS.tag) + }) +}) + +describe('applyView, cut twice', () => { + it('cuts by the second key the view names', () => { + const rows = [s({ id: 'a', tags: ['api'] }), s({ id: 'b', tags: ['ops'] })] + const once = applyView(rows, { ...DEFAULT_VIEW, subgrouping: 'none' }) + const twice = applyView(rows, { ...DEFAULT_VIEW, subgrouping: 'tag' }) + expect(once[0]!.children).toBeUndefined() + expect(twice[0]!.children!.map((g) => g.label)).toEqual(['api', 'ops']) + }) +}) + +describe('spawnFromGroup, cut twice', () => { + it('merges what both headings imply', () => { + expect(spawnFromGroup('machine', `machine:m1${SUBKEY_SEP}tag:api`, 'tag')).toEqual({ + machineId: 'm1', + tag: 'api', + }) + }) + + it('hands a parent heading only its own fact', () => { + expect(spawnFromGroup('machine', 'machine:m1', 'tag')).toEqual({ machineId: 'm1' }) + }) + + it('asks for no tag under a machine’s untagged remainder', () => { + expect(spawnFromGroup('machine', `machine:m1${SUBKEY_SEP}untagged`, 'tag')).toEqual({ + machineId: 'm1', + }) + }) + + it('offers nothing when either heading refuses', () => { + expect(spawnFromGroup('tag', `tag:api${SUBKEY_SEP}state:exited`, 'state')).toBeNull() + expect(spawnFromGroup('state', `state:exited${SUBKEY_SEP}tag:api`, 'tag')).toBeNull() + }) +}) + +describe('groupAcceptsDrop, cut twice', () => { + it('admits a drop when either heading is a tag', () => { + expect(groupAcceptsDrop('machine', 'tag')).toBe(true) + expect(groupAcceptsDrop('tag', 'machine')).toBe(true) + expect(groupAcceptsDrop('machine', 'none')).toBe(false) + expect(groupAcceptsDrop('machine')).toBe(false) + }) +}) + +describe('dropOnGroup, cut twice', () => { + const row = s({ tags: ['api'] }) + + it('moves a session between tags under the same machine', () => { + const verdict = dropOnGroup( + 'machine', + row, + `machine:m1${SUBKEY_SEP}tag:api`, + `machine:m1${SUBKEY_SEP}tag:ops`, + 'tag', + ) + expect(verdict).toEqual({ kind: 'retag', tags: ['ops'] }) + }) + + it('refuses a tag under another machine, for the machine’s reason', () => { + // The tag could be given; the machine cannot. A drop that did half of + // what the pointer asked would leave the row under neither heading. + const verdict = dropOnGroup( + 'machine', + row, + `machine:m1${SUBKEY_SEP}tag:api`, + `machine:m2${SUBKEY_SEP}tag:api`, + 'tag', + ) + expect(verdict.kind).toBe('reject') + if (verdict.kind === 'reject') expect(verdict.reason).toMatch(/machine/) + }) + + it('has nothing to say about a drop onto its own machine’s heading', () => { + const verdict = dropOnGroup( + 'machine', + row, + `machine:m1${SUBKEY_SEP}tag:api`, + 'machine:m1', + 'tag', + ) + expect(verdict).toEqual({ kind: 'none' }) + }) + + it('reads the tag from whichever level carries it', () => { + const verdict = dropOnGroup( + 'tag', + row, + `tag:api${SUBKEY_SEP}machine:m1`, + `tag:ops${SUBKEY_SEP}machine:m1`, + 'machine', + ) + expect(verdict).toEqual({ kind: 'retag', tags: ['ops'] }) + }) +}) diff --git a/web/src/sessions/view.ts b/web/src/sessions/view.ts index 1fce83c..385344d 100644 --- a/web/src/sessions/view.ts +++ b/web/src/sessions/view.ts @@ -94,6 +94,16 @@ export const GROUPING_LABELS: Record = { none: 'No grouping', } +/** + * The same words, for the second cut. One differs: after "Then by", the way + * out is "Nothing" — "Then by: No grouping" reads as a contradiction rather + * than as a choice. + */ +export const SUBGROUPING_LABELS: Record = { + ...GROUPING_LABELS, + none: 'Nothing', +} + /** @see GROUPING_LABELS */ export const ORDERING_LABELS: Record = { lastActive: 'Last active', @@ -122,6 +132,12 @@ export const COLUMN_LABELS: Record = { /** One arrangement of the sessions list: what is shown, and how it reads. */ export interface ViewConfig { grouping: Grouping + /** + * The second cut: how each group's rows are cut again into subheadings. + * `none` is a first-class answer — one cut — and `grouping` set to `none` + * makes this moot, since there is nothing to cut again. + */ + subgrouping: Grouping ordering: Ordering direction: Direction search: string @@ -153,6 +169,11 @@ export interface ViewConfig { */ export const DEFAULT_VIEW: ViewConfig = Object.freeze({ grouping: 'machine', + // Then by tag: a fleet with tagged work reads "what is running where, and + // what it is for" from the headings alone. A machine with no tags on it + // shows no subheadings at all (see groupSessions), so a fresh install with + // one untagged shell looks exactly as it did before this existed. + subgrouping: 'tag', ordering: 'lastActive', direction: DEFAULT_DIRECTIONS.lastActive, search: '', @@ -185,9 +206,27 @@ function frozen(list: T[]): T[] { export interface Group { key: string label: string + /** Every row under this heading, subheadings or not — what a tally counts. */ sessions: FleetSession[] + /** + * The second cut, when the view asked for one and it said something: the + * same rows again, under subheadings. Absent when the view cuts once, and + * absent when the cut would have made one subheading over every row — a + * heading that repeats the heading above it. The list draws children + * where they exist and `sessions` where they do not. + */ + children?: Group[] } +/** + * What joins a parent's key to a child's own, so a child's key names both. + * The ASCII unit separator: not a character a tag, a path or a machine id + * carries, which is what lets the key be split back into its two halves + * without a grammar. The two halves are what `spawnFromGroup` and + * `dropOnGroup` read a child heading through. + */ +export const SUBKEY_SEP = '\u001f' + /** The heading the sessions with no tags at all end up under. */ const UNTAGGED_KEY = 'untagged' @@ -346,8 +385,27 @@ function activeBucket(s: FleetSession): number { * counted twice. Sessions with no tags gather under one heading at the very * end, where they read as the remainder rather than as a tag someone invented. */ -export function groupSessions(list: FleetSession[], grouping: Grouping): Group[] { - return collect(list, BUCKETS[grouping]) +export function groupSessions( + list: FleetSession[], + grouping: Grouping, + subgrouping: Grouping = 'none', +): Group[] { + const groups = collect(list, BUCKETS[grouping]) + if (subgrouping === 'none') return groups + return groups.map((g) => { + // Cut again, and prefix each child's key with its parent's: `api` on two + // machines is two headings, folded and dropped on apart. The parent keeps + // its whole run — a session under two tags is still one session on the + // machine, and the tally over the parent heading should say so. + const children = collect(g.sessions, BUCKETS[subgrouping]).map((c) => ({ + ...c, + key: `${g.key}${SUBKEY_SEP}${c.key}`, + })) + // One subheading over every row says nothing the parent's heading did + // not, and "No tag" alone under a machine is a heading over the remainder + // of nothing. Flat, then, and the list draws it as a plain group. + return children.length > 1 ? { ...g, children } : g + }) } /** @@ -423,7 +481,23 @@ export interface SpawnRequest { tag?: string } -export function spawnFromGroup(grouping: Grouping, groupKey: string): SpawnRequest | null { +export function spawnFromGroup( + grouping: Grouping, + groupKey: string, + subgrouping: Grouping = 'none', +): SpawnRequest | null { + // A child heading names two facts, one per level, and a session made under + // it should carry both: `+` under `api` under `attic pi` makes a session on + // attic pi tagged api. Either level refusing refuses the whole — a `+` + // under an exited subheading has no more to offer than one under Exited. + const [own, sub] = groupKey.split(SUBKEY_SEP) as [string, string?] + const parent = spawnFromOne(grouping, own) + if (parent === null || sub === undefined) return parent + const child = spawnFromOne(subgrouping, sub) + return child === null ? null : { ...parent, ...child } +} + +function spawnFromOne(grouping: Grouping, groupKey: string): SpawnRequest | null { switch (grouping) { case 'machine': return { machineId: after(groupKey, 'machine:') } @@ -477,8 +551,8 @@ export type DropVerdict = * the rest answer the pointer with a no-drop cursor — so it is decided here, * beside the verdicts it must always agree with. */ -export function groupAcceptsDrop(grouping: Grouping): boolean { - return grouping === 'tag' +export function groupAcceptsDrop(grouping: Grouping, subgrouping: Grouping = 'none'): boolean { + return grouping === 'tag' || subgrouping === 'tag' } /** @@ -501,6 +575,31 @@ export function dropOnGroup( s: FleetSession, fromKey: string, toKey: string, + subgrouping: Grouping = 'none', +): DropVerdict { + if (fromKey === toKey) return { kind: 'none' } + // One verdict per level, read in parent-then-child order. A refusal at + // either level is the whole answer — a tag the session could take under a + // machine it cannot cross to is still a drop that cannot be honoured, and + // doing half of it would leave the row under neither heading. Otherwise + // the one level with something to change speaks, and the rest is silence. + const [fromOwn, fromSub] = fromKey.split(SUBKEY_SEP) as [string, string?] + const [toOwn, toSub] = toKey.split(SUBKEY_SEP) as [string, string?] + const verdicts = [dropOnOne(grouping, s, fromOwn, toOwn)] + if (fromSub !== undefined && toSub !== undefined) { + verdicts.push(dropOnOne(subgrouping, s, fromSub, toSub)) + } + return ( + verdicts.find((v) => v.kind === 'reject') ?? + verdicts.find((v) => v.kind === 'retag') ?? { kind: 'none' } + ) +} + +function dropOnOne( + grouping: Grouping, + s: FleetSession, + fromKey: string, + toKey: string, ): DropVerdict { if (fromKey === toKey) return { kind: 'none' } switch (grouping) { @@ -562,7 +661,11 @@ function collect(list: FleetSession[], bucketsOf: (s: FleetSession) => Bucket[]) export function applyView(list: FleetSession[], v: ViewConfig): Group[] { const matched = filterSessions(list, v.search) const wanted = v.showExited ? matched : matched.filter((s) => s.state !== 'exited') - return groupSessions(orderSessions(wanted, v.ordering, v.direction), v.grouping) + return groupSessions( + orderSessions(wanted, v.ordering, v.direction), + v.grouping, + v.subgrouping, + ) } /** diff --git a/web/src/sessions/views-store.test.ts b/web/src/sessions/views-store.test.ts index 02a59de..4be0373 100644 --- a/web/src/sessions/views-store.test.ts +++ b/web/src/sessions/views-store.test.ts @@ -123,6 +123,7 @@ describe('listViews on a store it cannot believe', () => { { what: 'a row with an empty name', row: { ...DEFAULT_VIEW, name: '' } }, { what: 'a row named with nothing but blanks', row: { ...DEFAULT_VIEW, name: ' ' } }, { what: 'a grouping nothing groups by', row: { ...WORK, grouping: 'folder' } }, + { what: 'a second grouping nothing groups by', row: { ...WORK, subgrouping: 'folder' } }, { what: 'an ordering nothing orders by', row: { ...WORK, ordering: 'size' } }, { what: 'a direction nothing reads in', row: { ...WORK, direction: 'sideways' } }, { what: 'a search that is not text', row: { ...WORK, search: 3 } }, @@ -157,6 +158,21 @@ describe('listViews on a store it cannot believe', () => { { ...OPS, direction: 'asc' }, ]) }) + + it('reads a view saved before second groupings existed as cut once', () => { + // Same bargain as the direction: what that build showed was one cut, and + // a saved tab someone arranged must not grow subheadings on the day this + // ships. The default for a fresh browser is another matter (DEFAULT_VIEW). + const { subgrouping: _, ...old } = OPS + stored([old]) + expect(listViews()).toEqual([{ ...OPS, subgrouping: 'none' }]) + }) + + it('round-trips a view cut twice', () => { + const nested: SavedView = { ...WORK, grouping: 'machine', subgrouping: 'tag' } + saveView(nested) + expect(listViews()).toEqual([nested]) + }) }) describe('deleteView', () => { @@ -213,6 +229,7 @@ describe('the current arrangement', () => { 'null', JSON.stringify({ active: 'Ops' }), JSON.stringify({ view: { ...DEFAULT_VIEW, grouping: 'folder' }, active: null }), + JSON.stringify({ view: { ...DEFAULT_VIEW, subgrouping: 'folder' }, active: null }), JSON.stringify({ view: { ...DEFAULT_VIEW, direction: 'sideways' }, active: null }), JSON.stringify({ view: { ...DEFAULT_VIEW, columns: ['name', 'colour'] }, active: null }), JSON.stringify({ view: { ...DEFAULT_VIEW, showExited: 'yes' }, active: null }), @@ -236,6 +253,12 @@ describe('the current arrangement', () => { expect(loadCurrent().view).toEqual({ ...DEFAULT_VIEW, ordering: 'name', direction: 'asc' }) }) + it('reads an arrangement saved before second groupings existed as cut once', () => { + const { subgrouping: _, ...old } = DEFAULT_VIEW + localStorage.setItem(CURRENT_KEY, JSON.stringify({ view: old, active: null })) + expect(loadCurrent().view).toEqual({ ...DEFAULT_VIEW, subgrouping: 'none' }) + }) + it('drops a pressed tab whose view is gone, and keeps the arrangement', () => { // A view deleted in another browser tab can still be named here; the // strip cannot press a tab that is not there, but what the reader was diff --git a/web/src/sessions/views-store.ts b/web/src/sessions/views-store.ts index 7f76beb..fb4f1fe 100644 --- a/web/src/sessions/views-store.ts +++ b/web/src/sessions/views-store.ts @@ -79,6 +79,7 @@ function isViewConfig(value: unknown): value is ViewConfig { const v = value as ViewConfig | null return ( isMember(GROUPINGS, v?.grouping) && + (v.subgrouping === undefined || isMember(GROUPINGS, v.subgrouping)) && isMember(ORDERINGS, v.ordering) && (v.direction === undefined || isMember(DIRECTIONS, v.direction)) && typeof v.search === 'string' && @@ -93,6 +94,19 @@ function direction(v: ViewConfig): ViewConfig['direction'] { return v.direction ?? DEFAULT_DIRECTIONS[v.ordering] } +/** + * The second cut a stored view meant, written down or not. + * + * The same bargain as the direction, with a different answer: a view written + * before second cuts existed showed one cut, and comes back showing one cut. + * Not `DEFAULT_VIEW.subgrouping` — that is what a browser with nothing + * arranged opens on, and an arrangement someone kept must not sprout + * subheadings on the day this ships. + */ +function subgrouping(v: ViewConfig): ViewConfig['subgrouping'] { + return v.subgrouping ?? 'none' +} + /** Whether a stored word is still one of the words this build knows. */ function isMember(allowed: readonly T[], value: unknown): value is T { return typeof value === 'string' && (allowed as readonly string[]).includes(value) @@ -129,6 +143,7 @@ export function listViews(): SavedView[] { return parsed.filter(isSavedView).map((v) => ({ name: v.name, grouping: v.grouping, + subgrouping: subgrouping(v), ordering: v.ordering, direction: direction(v), search: v.search, @@ -218,6 +233,7 @@ export function loadCurrent(): { view: ViewConfig; active: string | null } { if (record === null || !isViewConfig(record.view)) return fallback() const view: ViewConfig = { grouping: record.view.grouping, + subgrouping: subgrouping(record.view), ordering: record.view.ordering, direction: direction(record.view), search: record.view.search,