diff --git a/web/package.json b/web/package.json index 4a7ddd7..3a2f489 100644 --- a/web/package.json +++ b/web/package.json @@ -16,6 +16,7 @@ "lint": "tsc --noEmit" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", "@heroicons/react": "^2.2.0", "@noble/ciphers": "^2.2.0", "@noble/curves": "^2.2.0", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index e15d366..05709e5 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@dnd-kit/core': + specifier: ^6.3.1 + version: 6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@heroicons/react': specifier: ^2.2.0 version: 2.2.0(react@19.2.8) @@ -307,6 +310,22 @@ packages: resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} engines: {node: '>=20.19.0'} + '@dnd-kit/accessibility@3.1.1': + resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} + peerDependencies: + react: '>=16.8.0' + + '@dnd-kit/core@6.3.1': + resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@dnd-kit/utilities@3.2.2': + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} + peerDependencies: + react: '>=16.8.0' + '@dotenvx/dotenvx@1.75.1': resolution: {integrity: sha512-/BITOC9dmS/edY2zQwZNicQ059O6RKabtQfyEafV0nGtfYRNHYy1DIPiYVcov40+tob9hfmBnbR963dS+EQ1DQ==} hasBin: true @@ -3463,6 +3482,24 @@ snapshots: '@csstools/css-tokenizer@4.0.0': {} + '@dnd-kit/accessibility@3.1.1(react@19.2.8)': + dependencies: + react: 19.2.8 + tslib: 2.8.1 + + '@dnd-kit/core@6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@dnd-kit/accessibility': 3.1.1(react@19.2.8) + '@dnd-kit/utilities': 3.2.2(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + tslib: 2.8.1 + + '@dnd-kit/utilities@3.2.2(react@19.2.8)': + dependencies: + react: 19.2.8 + tslib: 2.8.1 + '@dotenvx/dotenvx@1.75.1': dependencies: '@dotenvx/primitives': 0.8.0 diff --git a/web/src/components/session-table.test.tsx b/web/src/components/session-table.test.tsx index 7790c5b..dfa29fe 100644 --- a/web/src/components/session-table.test.tsx +++ b/web/src/components/session-table.test.tsx @@ -536,6 +536,65 @@ describe('SessionTable', () => { }) }) + describe('dragging', () => { + // What a drag *does* is dropOnGroup's, pinned in sessions/view.test.ts; + // jsdom draws no layout, so the gesture itself — sensors, collision, + // release — cannot be honestly simulated here. What this component owes + // and can prove is the wiring around the gesture. + + it('leaves the native anchor drag alone until a drag contract arrives', async () => { + const bare = await renderTable() + expect( + bare.getByRole('link', { name: 'Open zsh in a new tab' }).getAttribute('draggable'), + ).toBeNull() + bare.unmount() + + // With one: the browser's own href-drag would race the sensor for + // every gesture starting on the link's stretched overlay, which is + // most of the row — so it stands down. + await renderTable({ drag: { droppable: () => true, onDrop: vi.fn() } }) + const link = screen.getByRole('link', { name: 'Open zsh in a new tab' }) + expect(link.getAttribute('draggable')).toBe('false') + // And the row is still, above all, the link it always was. + expect(link.getAttribute('href')).toBe('/d/m1/s/a1') + }) + + it('wears a grip only where a drop could actually land', async () => { + // The grip is an advertisement, not the handle it looks like — the + // gesture works from the whole row. It renders when some heading on + // screen would take the drop, and stays away both from lists that + // cannot drag and from groupings whose every heading refuses, where it + // would promise a move that only ever says no. + const bare = await renderTable() + expect(bare.queryByTitle('Drag to move to another group')).toBeNull() + bare.unmount() + + const refused = await renderTable({ + drag: { droppable: () => false, onDrop: vi.fn() }, + }) + expect(refused.queryByTitle('Drag to move to another group')).toBeNull() + refused.unmount() + + await renderTable({ drag: { droppable: () => true, onDrop: vi.fn() } }) + expect(screen.getByTitle('Drag to move to another group')).toBeTruthy() + }) + + it('claims the long press only while dragging is on offer', async () => { + // select-none and the callout suppression are what let a finger hold a + // row without iOS answering with selection or the link preview — and + // they cost real behaviour (copying a path), so they must not leak + // into lists that cannot drag. + const bare = await renderTable() + expect(bare.container.querySelector('li')!.className).not.toMatch(/select-none/) + bare.unmount() + + const { container } = await renderTable({ + drag: { droppable: () => true, onDrop: vi.fn() }, + }) + expect(container.querySelector('li')!.className).toMatch(/select-none/) + }) + }) + describe('empty state', () => { it('shows the terminal card when there are no groups at all', async () => { const { container } = await renderTable({ groups: [] }) diff --git a/web/src/components/session-table.tsx b/web/src/components/session-table.tsx index c1f8438..7ab04d3 100644 --- a/web/src/components/session-table.tsx +++ b/web/src/components/session-table.tsx @@ -1,4 +1,17 @@ -import { Fragment } from 'react' +import { Fragment, useState } from 'react' +import { + DndContext, + DragOverlay, + MouseSensor, + TouchSensor, + useDraggable, + useDroppable, + useSensor, + useSensors, + type DragEndEvent, + type DragOverEvent, + type DragStartEvent, +} from '@dnd-kit/core' import { Link } from '@tanstack/react-router' import { ChevronDownIcon, @@ -6,6 +19,7 @@ import { PlusIcon, StarIcon, } from '@heroicons/react/16/solid' +import { GripVerticalIcon } from 'lucide-react' import { SessionPreview } from '@/components/session-preview' import { Badge } from '@/components/ui/badge' @@ -26,6 +40,32 @@ import { COLUMN_KEYS, displayName, type ColumnKey, type Group } from '@/sessions /** What a row's ⋯ menu can ask of a session. */ export type RowAction = 'rename' | 'tags' | 'pin' | 'unpin' | 'close' +/** + * How a dragged row is let go, when the caller offers dragging at all. + * + * Both halves belong to the caller for the same reason `spawnLabel` does: this + * component is handed labelled runs of rows and cannot tell a tag's heading + * from a machine's, so what a drop *means* — a retag, a refusal said out loud + * — is decided above it (see `dropOnGroup`). `droppable` is what the drag + * layer draws while the row is in the air: a group it answers true for + * highlights under the pointer, any other answers with a no-drop cursor, and + * the drop itself is reported either way so the caller can say why it + * refused. Omitting the whole object turns dragging off, which is what the + * ungrouped view and every test that is not about dragging do. + */ +export interface DragToGroup { + /** Whether this group's heading can take a drop. */ + droppable(group: Group): boolean + /** A row let go over a group — its band, or its run of rows. */ + onDrop(s: FleetSession, fromKey: string, group: Group): void +} + +/** What rides on a draggable row: the session, and the heading it left. */ +interface DragCargo { + s: FleetSession + fromKey: string +} + /** * How a row asks the daemon what it is doing, for the hover preview. * @@ -164,6 +204,9 @@ const TAG_CAP = 3 */ function SessionRow({ s, + groupKey, + drag, + handle, paneCount, shown, selected, @@ -172,6 +215,12 @@ function SessionRow({ peek, }: { s: FleetSession + /** The heading this row renders under — what a drag is picked up from. */ + groupKey: string + /** The drag layer's contract, when the caller offers one. See DragToGroup. */ + drag?: DragToGroup + /** Whether to show the grip — only when a drop could land somewhere. */ + handle: boolean /** Panes folded into this row, when its group has more than one. */ paneCount?: number shown: ColumnKey[] @@ -183,6 +232,20 @@ function SessionRow({ const key = keyOf(s) const name = displayName(s) const ended = s.state === 'exited' + /* + * One draggable per rendered row rather than per session: a session tagged + * twice renders under two headings, and the id has to say which heading the + * drag left, or a retag could not know which tag to take off. The newline + * can appear in neither half, so the pair cannot collide with another + * row's. The listeners land on the whole `li` — the activation distance is + * what keeps a plain click a click, so the checkbox, the ⋯ trigger and the + * link all still answer their own presses. + */ + const { setNodeRef, listeners, isDragging } = useDraggable({ + id: `${groupKey}\n${key}`, + data: { s, fromKey: groupKey } satisfies DragCargo, + disabled: drag === undefined, + }) const link = ( @@ -218,7 +285,45 @@ function SessionRow({ ) return ( -
  • +
  • + {/* + The grip is how the drag says it exists before anyone has tried it — + the gesture works from anywhere on the row, so this is an + advertisement rather than the handle it looks like. Quiet until the + row is hovered, exactly as the checkbox and the ⋯ trigger are, and at + full strength for a coarse pointer, which has no hover and no grab + cursor to learn from. Rendered only when a drop could land somewhere + (see SessionTable): a grip on a row whose every target refuses would + advertise a gesture that only ever says no. z-10 so it takes its own + pointer — the grab cursor is half the signal, and under the link's + overlay it would read as one more place to click. aria-hidden because + it is not operable on its own: the keyboard path is the tag editor, + and a focusable handle no key can lift would be a lie told to exactly + the people who rely on it. + */} + {handle && ( + + )} onToggleSelect(key)} @@ -408,6 +513,7 @@ export function SessionTable({ onAction, onSpawnIn, spawnLabel, + drag, peek, }: { groups: Group[] @@ -432,9 +538,29 @@ export function SessionTable({ * of rows and could not tell a machine's heading from a tag's. */ spawnLabel?(group: Group): string | undefined + /** Drag a row onto a group's heading, when that means anything. */ + drag?: DragToGroup /** How a row asks what it is doing, for the hover preview. See PeekFn. */ peek?: PeekFn }) { + /** + * The ghost's subject, held here because only the drag layer's own events + * know it. Ahead of the empty-state return, as every hook must be. + * + * The sensors are two rather than one pointer sensor, because a mouse and + * a finger disagree about what starting a drag should cost. A mouse pays + * five pixels of travel, which is what keeps a plain click a click on a row + * that is mostly one big link. A finger pays a hold — scrolling is also a + * touch sliding up a row, and a distance rule would grab every scroll — + * and the tolerance is what lets a hold that drifts slightly still lift + * rather than demanding a surgeon's stillness. + */ + const [dragging, setDragging] = useState(null) + const sensors = useSensors( + useSensor(MouseSensor, { activationConstraint: { distance: 5 } }), + useSensor(TouchSensor, { activationConstraint: { delay: 300, tolerance: 8 } }), + ) + if (groups.length === 0) { /* * The empty state quotes the landing page's terminal figure: a dark card @@ -471,101 +597,244 @@ export function SessionTable({ // The pinned rule only makes sense where there are no headings to do the // separating: the single 'all' group of the ungrouped view. const ungrouped = groups.length === 1 && groups[0]!.key === 'all' + // Whether the rows wear a grip: only while some heading on screen would + // actually take the drop. The gesture itself stays live either way — the + // 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)) + + /* + * The drag layer's own bookkeeping, all of it about saying things: who the + * ghost shows, what the cursor promises, and the one report a drop makes. + * The cursor rides document.body because during a drag the pointer is + * captured — no element under it is hovered, so no element's cursor rule + * can speak. `no-drop` over a heading the caller refuses is the drop + * rejected *before* it happens; the caller then says why in words when the + * reader insists (see DragToGroup). + */ + const settle = () => { + setDragging(null) + document.body.style.cursor = '' + } + const dragStart = (e: DragStartEvent) => { + setDragging((e.active.data.current as DragCargo).s) + document.body.style.cursor = 'grabbing' + } + const dragOver = (e: DragOverEvent) => { + if (drag === undefined) return + const group = (e.over?.data.current as { group: Group } | undefined)?.group + document.body.style.cursor = + group !== undefined && !drag.droppable(group) ? 'no-drop' : 'grabbing' + } + const dragEnd = (e: DragEndEvent) => { + settle() + const group = (e.over?.data.current as { group: Group } | undefined)?.group + if (drag === undefined || group === undefined) return + const cargo = e.active.data.current as DragCargo + drag.onDrop(cargo.s, cargo.fromKey, group) + } return ( -
    - {groups.map((g) => { - const open = !collapsed.has(g.key) - // Where the pinned prefix ends; 0 and -1 both mean "no rule". - const boundary = ungrouped ? g.sessions.findIndex((s) => !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. - */ - const spawn = onSpawnIn === undefined ? undefined : spawnLabel?.(g) - return ( -
    - {/* - Two real controls in the band, and they are kept apart on - purpose: folding a group and starting a session in it must never - be the same click. The toggle owns the chevron and the label so - its accessible name is the group's; the tally is read-along - text; and the `+` is ranged right, where a new-thing control - belongs and where a pointer aimed at the fold cannot reach it. - It appears only when the caller can honour it — see - `spawnFromGroup`, which is what decides that a heading like - "Exited" has nothing to offer. - */} -
    - - - {tally(g.sessions)} - - {spawn !== undefined && onSpawnIn !== undefined && ( - - )} -
    - {open && ( -
      - {g.sessions.map((s, at) => ( - // Keyed by the same composite the selection uses: unique - // within a group, even when the session itself repeats - // across tag groups in their own lists. - - {at === boundary && at > 0 && } - - - ))} -
    + +
    + {groups.map((g) => ( + !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)} + drag={drag} + handle={liftable} + panes={panes} + shown={shown} + selected={selected} + onToggleSelect={onToggleSelect} + onToggleGroup={onToggleGroup} + onAction={onAction} + onSpawnIn={onSpawnIn} + peek={peek} + /> + ))} +
    + {/* + The dragged row as a ghost: not the row itself — a full-width row + under a fingertip would hide the headings it is aimed at — but the + one thing that identifies it, in a card the elevation tokens already + know how to draw. No drop animation: the row's real move is the next + list re-grouping, and a ghost gliding back to a place the row is + about to leave would animate a lie. + */} + + {dragging === null ? null : ( +
    +

    + {displayName(dragging)} +

    +
    + )} +
    +
    + ) +} + +/** + * One group: its heading band, and its run of rows while unfolded. + * + * A component rather than a map body so the droppable hook has somewhere + * legal to live — one droppable per group, covering the section whole, + * because the issue a drop answers is "put this session *there*", and there + * is the group as a place: heading and rows alike. The highlight lands on + * the band alone, where the reader is aiming. + */ +function GroupSection({ + g, + open, + boundary, + spawn, + drag, + handle, + panes, + shown, + selected, + onToggleSelect, + onToggleGroup, + onAction, + onSpawnIn, + peek, +}: { + g: Group + open: boolean + boundary: number + spawn?: string + drag?: DragToGroup + handle: boolean + panes?: ReadonlyMap + shown: ColumnKey[] + selected: ReadonlySet + onToggleSelect(key: string): void + onToggleGroup(groupKey: string): void + onAction(action: RowAction, s: FleetSession): void + onSpawnIn?(group: Group): void + peek?: PeekFn +}) { + /* + * Registered even for a heading that refuses drops, and deliberately: the + * refusal has an affordance — the no-drop cursor, the notice on release — + * and both need the drag layer to know the pointer is over this group at + * all. Only `droppable` decides whether anything lights up. + */ + const { setNodeRef, isOver } = useDroppable({ + id: g.key, + data: { group: g }, + disabled: drag === undefined, + }) + const droppable = drag !== undefined && drag.droppable(g) + return ( +
    + {/* + Two real controls in the band, and they are kept apart on + purpose: folding a group and starting a session in it must never + be the same click. The toggle owns the chevron and the label so + its accessible name is the group's; the tally is read-along + text; and the `+` is ranged right, where a new-thing control + belongs and where a pointer aimed at the fold cannot reach it. + It appears only when the caller can honour it — see + `spawnFromGroup`, which is what decides that a heading like + "Exited" has nothing to offer. + */} +
    +
    + /> + {g.label} + + + {tally(g.sessions)} + + {spawn !== undefined && onSpawnIn !== undefined && ( + + )} +
    + {open && ( +
      + {g.sessions.map((s, at) => ( + // Keyed by the same composite the selection uses: unique + // within a group, even when the session itself repeats + // across tag groups in their own lists. + + {at === boundary && at > 0 && } + + + ))} +
    + )} + ) } diff --git a/web/src/routes/sessions.test.tsx b/web/src/routes/sessions.test.tsx index 2ab5b0c..3e06a4a 100644 --- a/web/src/routes/sessions.test.tsx +++ b/web/src/routes/sessions.test.tsx @@ -139,6 +139,19 @@ function listed(sock: FakeSocket, sessions: SessionInfo[]) { const newSession = () => screen.getByRole('button', { name: 'New session' }) +/** + * The route's own status line. By role alone it stopped being unique: the + * drag layer (dnd-kit's DndContext) portals a live region onto document.body + * that is also role=status. The route's is the one and only `p` — the drag + * layer's is a div — and pinning the tag keeps every assertion about notices + * reading the element the reader actually sees. + */ +function notice(): HTMLElement { + const line = screen.getAllByRole('status').find((el) => el.tagName === 'P') + if (line === undefined) throw new Error('the notice line is not on screen') + return line +} + /** Open one of the display-options selects and take the option reading `label`. */ async function pick(user: ReturnType, of: string, label: string) { const trigger = screen.getByRole('combobox', { name: of }) @@ -388,7 +401,7 @@ describe('SessionsRoute', () => { await user.click(screen.getByRole('menuitem', { name: 'Close' })) expect(sock.ofType('close')).toEqual([{ type: 'close', id: 's1' }]) - expect(screen.getByRole('status').textContent).toContain('Closing alpha') + expect(notice().textContent).toContain('Closing alpha') }) it('announces a refusal that answers an act nobody can correlate', async () => { @@ -404,7 +417,7 @@ describe('SessionsRoute', () => { await user.click(screen.getByRole('menuitem', { name: 'Close' })) act(() => sock.emitControl({ type: 'error', code: 'not_found', msg: 'no such session' })) - expect(screen.getByRole('status').textContent).toBe('That session is gone.') + expect(notice().textContent).toBe('That session is gone.') }) it('announces one from a remote machine too, not only the ridden one', async () => { @@ -415,7 +428,7 @@ describe('SessionsRoute', () => { attic.sockets[0]!.emitControl({ type: 'error', code: 'not_found', msg: 'no such session' }), ) - expect(screen.getByRole('status').textContent).toBe('That session is gone.') + expect(notice().textContent).toBe('That session is gone.') }) it('leaves a correlated refusal to whoever holds its request', async () => { @@ -429,7 +442,7 @@ describe('SessionsRoute', () => { sock.emitControl({ type: 'error', code: 'not_found', msg: 'no such session', reqId: 9 }), ) - expect(screen.getByRole('status').textContent).toBe('') + expect(notice().textContent).toBe('') }) it('says nothing about an error that is not a missing session', async () => { @@ -437,7 +450,7 @@ describe('SessionsRoute', () => { act(() => sock.emitControl({ type: 'error', code: 'lagged', msg: 'too far behind' })) - expect(screen.getByRole('status').textContent).toBe('') + expect(notice().textContent).toBe('') }) }) @@ -466,7 +479,7 @@ describe('SessionsRoute', () => { expect(attic.sockets[0]!.ofType('close')).toEqual([{ type: 'close', id: 's2' }]) // The act consumes the selection: the bar leaves with it. expect(screen.queryByRole('toolbar', { name: 'Bulk actions' })).toBeNull() - expect(screen.getByRole('status').textContent).toContain('Closing 2 sessions') + expect(notice().textContent).toContain('Closing 2 sessions') }) it('pins the whole selection', async () => { @@ -672,7 +685,7 @@ describe('SessionsRoute', () => { act(() => sock.close()) - expect(screen.getByRole('status').textContent).toMatch(/reconnecting/i) + expect(notice().textContent).toMatch(/reconnecting/i) }) it('shows a revoked machine as a final band, with no retry to press', async () => { @@ -717,8 +730,8 @@ describe('SessionsRoute', () => { // The live region announces the fact; the reconnect line would be a // promise nothing is keeping. - expect(screen.getByRole('status').textContent).toMatch(/access was revoked/i) - expect(screen.getByRole('status').textContent).not.toMatch(/reconnecting/i) + expect(notice().textContent).toMatch(/access was revoked/i) + expect(notice().textContent).not.toMatch(/reconnecting/i) // And the band names the ridden machine as its welcome named it. expect(screen.getByText('mesa.local')).toBeTruthy() expect(screen.getByText(/revoked by another device/)).toBeTruthy() @@ -729,11 +742,11 @@ describe('SessionsRoute', () => { // already in the accessibility tree, so one that arrives together with // its first message is a message nobody hears. const { sock } = await mountSessions() - expect(screen.getByRole('status').textContent).toBe('') + expect(notice().textContent).toBe('') act(() => sock.close()) - expect(screen.getByRole('status').textContent).toMatch(/reconnecting/i) + expect(notice().textContent).toMatch(/reconnecting/i) }) }) @@ -1071,7 +1084,7 @@ describe('SessionsRoute', () => { }) await saveAs(user, 'Ops') - expect(screen.getByRole('status').textContent).toContain('Could not save the view') + expect(notice().textContent).toContain('Could not save the view') expect(screen.queryByRole('button', { name: 'Ops' })).toBeNull() }) }) diff --git a/web/src/routes/sessions.tsx b/web/src/routes/sessions.tsx index 071a324..8c7c7c0 100644 --- a/web/src/routes/sessions.tsx +++ b/web/src/routes/sessions.tsx @@ -9,7 +9,7 @@ import { NewSessionDialog } from '@/components/new-session-dialog' import { PageHeader } from '@/components/page-header' import { RenameDialog } from '@/components/rename-dialog' import { SessionSearch } from '@/components/session-search' -import { SessionTable, type RowAction } from '@/components/session-table' +import { SessionTable, type DragToGroup, type RowAction } from '@/components/session-table' import { TagEditor } from '@/components/tag-editor' import { ViewTabs } from '@/components/view-tabs' import { Button } from '@/components/ui/button' @@ -33,6 +33,8 @@ import { applyView, DEFAULT_VIEW, displayName, + dropOnGroup, + groupAcceptsDrop, hiddenExited, spawnFromGroup, type Group, @@ -483,6 +485,42 @@ export function SessionsRoute() { return 'New session' } + /** + * Drag a row, drop it on a heading: give the session that heading's fact. + * + * The meaning of a drop is `dropOnGroup`'s, pure and unit-tested, exactly + * as the heading's `+` defers to `spawnFromGroup`; this is only the glue + * that carries a verdict out. A retag is the same `update` the tag editor + * sends — drag and drop is a shortcut for an existing edit, never a new + * power — followed by an immediate `list`, because the row only moves when + * the next delivery re-groups, and a poll's worth of nothing after a drop + * reads as the drop not working. The notice says what happened either way: + * a successful move in words, since the rows reshuffle under the pointer, + * and a refusal in the verdict's own reason. Ungrouped there is nowhere to + * drop, so the whole layer is switched off rather than offered and refused. + */ + const dragToGroup = useMemo(() => { + if (view.grouping === 'none') return undefined + const grouping = view.grouping + return { + droppable: () => groupAcceptsDrop(grouping), + onDrop: (s, fromKey, group) => { + const verdict = dropOnGroup(grouping, s, fromKey, group.key) + if (verdict.kind === 'retag') { + fleet.update(s.machineId, { id: s.id, tags: verdict.tags }) + fleet.list() + setNotice( + verdict.tags.length === 0 + ? `Removed the tags from ${displayName(s)}.` + : `Moved ${displayName(s)} to ${group.label}.`, + ) + } else if (verdict.kind === 'reject') { + setNotice(verdict.reason) + } + }, + } + }, [view.grouping, fleet]) + /** * How a row asks what it is doing, for the hover preview. * @@ -775,6 +813,7 @@ export function SessionsRoute() { onAction={onAction} onSpawnIn={spawnInGroup} spawnLabel={spawnLabel} + drag={dragToGroup} peek={peek} /> )} diff --git a/web/src/sessions/view.test.ts b/web/src/sessions/view.test.ts index e265a72..2d6bc5a 100644 --- a/web/src/sessions/view.test.ts +++ b/web/src/sessions/view.test.ts @@ -7,7 +7,9 @@ import { COLUMN_LABELS, DEFAULT_VIEW, displayName, + dropOnGroup, filterSessions, + groupAcceptsDrop, GROUPING_LABELS, GROUPINGS, groupSessions, @@ -569,3 +571,107 @@ describe('spawnFromGroup', () => { } }) }) + +describe('groupAcceptsDrop', () => { + it('admits drops onto tag headings alone', () => { + // Tags are the one group-defining fact a person assigns; everything else + // is derived from the session or pinned to a daemon, and a heading that + // highlighted for a drop it must refuse would be an offer made in bad + // faith. + for (const grouping of GROUPINGS) { + expect(groupAcceptsDrop(grouping), grouping).toBe(grouping === 'tag') + } + }) +}) + +describe('dropOnGroup', () => { + it('moves a session between tags: the source tag off, the target on', () => { + // "Put this there" — a session that kept the tag it was dragged out of + // would still sit under the heading the reader just removed it from, + // which reads as a drop that did not work. + const row = s({ tags: ['api'] }) + expect(dropOnGroup('tag', row, 'tag:api', 'tag:ops')).toEqual({ + kind: 'retag', + tags: ['ops'], + }) + }) + + it('leaves the tags the gesture never named alone', () => { + const row = s({ tags: ['api', 'db', 'edge'] }) + expect(dropOnGroup('tag', row, 'tag:api', 'tag:ops')).toEqual({ + kind: 'retag', + tags: ['db', 'edge', 'ops'], + }) + }) + + it('does not double a tag the session already carries', () => { + // Dragged out of `api` onto `ops` while already tagged both: the move is + // still a move — api comes off — and ops must appear once, not twice. + const row = s({ tags: ['api', 'ops'] }) + expect(dropOnGroup('tag', row, 'tag:api', 'tag:ops')).toEqual({ + kind: 'retag', + tags: ['ops'], + }) + }) + + it('tags an untagged session dropped onto a tag', () => { + const row = s({ tags: [] }) + expect(dropOnGroup('tag', row, 'untagged', 'tag:api')).toEqual({ + kind: 'retag', + tags: ['api'], + }) + }) + + it('clears every tag on a drop onto the untagged remainder', () => { + // "No tag" is not a tag to swap in but the absence being pointed at, and + // half-clearing would leave the row under headings the reader just + // dragged it away from. + const row = s({ tags: ['api', 'ops'] }) + expect(dropOnGroup('tag', row, 'tag:api', 'untagged')).toEqual({ kind: 'retag', tags: [] }) + }) + + it('keeps a colon that belongs to the tag rather than to the prefix', () => { + const row = s({ tags: ['a:b'] }) + expect(dropOnGroup('tag', row, 'tag:a:b', 'tag:c:d')).toEqual({ + kind: 'retag', + tags: ['c:d'], + }) + }) + + it('has nothing to say about a drop back onto its own heading', () => { + const row = s({ tags: ['api'] }) + expect(dropOnGroup('tag', row, 'tag:api', 'tag:api')).toEqual({ kind: 'none' }) + expect(dropOnGroup('machine', row, 'machine:m1', 'machine:m1')).toEqual({ kind: 'none' }) + expect(dropOnGroup('tag', s(), 'untagged', 'untagged')).toEqual({ kind: 'none' }) + }) + + it('refuses a machine heading, with the reason said in words', () => { + // A live shell cannot cross daemons; the pointer was allowed to make the + // offer, so the refusal has to be answerable out loud. + const verdict = dropOnGroup('machine', s(), 'machine:m1', 'machine:m2') + expect(verdict.kind).toBe('reject') + if (verdict.kind === 'reject') expect(verdict.reason).toMatch(/machine/) + }) + + it('refuses the derived groupings, state and directory', () => { + for (const [grouping, from, to] of [ + ['state', 'state:running', 'state:exited'], + ['directory', 'dir:/a', 'dir:/b'], + ] as const) { + const verdict = dropOnGroup(grouping, s(), from, to) + expect(verdict.kind, grouping).toBe('reject') + } + }) + + it('shrugs at the ungrouped view, where there is nothing to drop onto', () => { + expect(dropOnGroup('none', s(), 'all', 'all')).toEqual({ kind: 'none' }) + expect(dropOnGroup('none', s(), 'all', 'other')).toEqual({ kind: 'none' }) + }) + + it('leaves the session it was asked about untouched', () => { + const row = s({ tags: ['api', 'ops'] }) + dropOnGroup('tag', row, 'tag:api', 'untagged') + dropOnGroup('tag', row, 'tag:api', 'tag:edge') + expect(row.tags).toEqual(['api', 'ops']) + }) +}) diff --git a/web/src/sessions/view.ts b/web/src/sessions/view.ts index bb7db9e..4ef5232 100644 --- a/web/src/sessions/view.ts +++ b/web/src/sessions/view.ts @@ -410,6 +410,86 @@ function after(key: string, prefix: string): string { return key.startsWith(prefix) ? key.slice(prefix.length) : key } +/** + * What letting a dragged row go over a group's heading should do. + * + * `retag` is the one thing a drop can actually change: a session's tags are + * the only group-defining fact a person assigns, so a drag between tag + * headings is a plain metadata edit — the same one the tag editor performs, + * which is why drag and drop can never become the only path to it. `reject` + * is a drop the screen must answer out loud, in the reason given, because the + * pointer was allowed to make an offer the data cannot honour. `none` is a + * drop with nothing to say — the row let go where it already was. + */ +export type DropVerdict = + | { kind: 'retag'; tags: string[] } + | { kind: 'reject'; reason: string } + | { kind: 'none' } + +/** + * Whether this grouping's headings can take a drop at all. + * + * Only tags: a machine heading names a daemon a live shell cannot cross to, + * and state and directory are read off the session rather than assigned to + * it. The distinction is what the drag layer draws — valid targets highlight, + * 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' +} + +/** + * The verdict on one drop: this session, picked up under `fromKey`, let go + * over `toKey`. + * + * A tag drop *moves* rather than merely adds — the tag it was picked up + * under comes off and the tag it landed on goes on — because the gesture is + * "put this there", and a session that stayed under the heading it was + * dragged out of would read as a drop that did not work. The rest of the + * session's tags are none of the gesture's business and survive untouched. + * Landing on the untagged remainder clears every tag, since "no tag" is not + * a tag to swap in but the absence being pointed at. + * + * The refusals name their reason in a full sentence, because the reason is + * the answer to the question the reader just asked with their pointer. + */ +export function dropOnGroup( + grouping: Grouping, + s: FleetSession, + fromKey: string, + toKey: string, +): DropVerdict { + if (fromKey === toKey) return { kind: 'none' } + switch (grouping) { + case 'tag': { + if (toKey === UNTAGGED_KEY) return { kind: 'retag', tags: [] } + const fromTag = fromKey === UNTAGGED_KEY ? null : after(fromKey, 'tag:') + const toTag = after(toKey, 'tag:') + const tags = s.tags.filter((t) => t !== fromTag) + if (!tags.includes(toTag)) tags.push(toTag) + return { kind: 'retag', tags } + } + case 'machine': + return { + kind: 'reject', + reason: 'A session cannot move to another machine. Its shell runs where it started.', + } + case 'state': + return { + kind: 'reject', + reason: 'State cannot be assigned. It reports what the session is doing.', + } + case 'directory': + return { + kind: 'reject', + reason: 'Directory cannot be assigned. It is where the session runs.', + } + case 'none': + return { kind: 'none' } + } +} + /** Gather the rows into their buckets, then put the buckets in reading order. */ function collect(list: FleetSession[], bucketsOf: (s: FleetSession) => Bucket[]): Group[] { const found = new Map()