diff --git a/apps/cli/package.json b/apps/cli/package.json index d8f8314..1ebc545 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -21,7 +21,7 @@ "@diskpush/rsync-core": "workspace:*", "@diskpush/schemas": "workspace:*", "@diskpush/ssh-core": "workspace:*", - "@profullstack/hqtui": "^0.2.0", + "@profullstack/hqtui": "^0.5.0", "zod": "^3.24.1" } } diff --git a/apps/cli/src/tui/app.ts b/apps/cli/src/tui/app.ts index 9b4135a..fc09b30 100644 --- a/apps/cli/src/tui/app.ts +++ b/apps/cli/src/tui/app.ts @@ -28,11 +28,12 @@ import { blankPane, clampIndex, listLocal, + parentPath, pushChange, selectedEntry, visibleEntries, } from './model.js' -import { type Tone, type ViewState, draw, filterChoices } from './view.js' +import { type Action, type Tone, type ViewState, draw, filterChoices } from './view.js' export { blankPane, @@ -58,12 +59,6 @@ export class Tui { private busy = false private readonly sessions = new Map() private app: App | null = null - /** - * The first row index each pane's table actually drew, recorded during - * render. A click reports the row it landed on, counted from the top of the - * visible window — and only the table knows where that window starts. - */ - private readonly firstVisible: Record = { left: 0, right: 0 } constructor( left: Pane, @@ -104,24 +99,104 @@ export class Tui { this.active = side this.invalidate() }, - onSelectRow: (side, visibleRow) => { + onSelectRow: (side, index) => { + this.status = null this.active = side const pane = this.panes[side] - pane.index = this.firstVisible[side] + visibleRow + pane.index = index clampIndex(pane) this.invalidate() }, + onOpenRow: (side, index) => { + if (this.busy) return + this.status = null + this.active = side + const pane = this.panes[side] + if (index < 0) { + void this.goUp() + return + } + pane.index = index + clampIndex(pane) + void this.enter() + }, onScroll: (side, delta) => { this.active = side this.move(delta * WHEEL_ROWS) this.invalidate() }, - onRowDrawn: (side, index, y) => { - this.firstVisible[side] = index - y + onAction: (action) => void this.run(action), + onPickChoice: (choice) => { + if (this.overlay?.kind !== 'picker') return + this.overlay = null + void this.choose(choice) + }, + onDismissOverlay: () => { + // The host-key question is not on this list on purpose: it is only + // ever answered, never waved away. + if (this.overlay?.kind === 'picker' || this.overlay?.kind === 'help') this.overlay = null + this.invalidate() + }, + onHostKeyDecide: (trust) => { + if (this.overlay?.kind === 'hostKey') this.overlay.decide(trust) }, }) } + /** + * A key cap clicked in the footer or the header. Each one does what the key + * does, under the same rules: a dialog owns the input while it is up, and a + * transfer in flight takes nothing but cancel and quit. + */ + private async run(action: Action): Promise { + if (action === 'quit') { + this.app?.quit() + return + } + if (action === 'closeOverlay') { + if (this.overlay?.kind === 'picker' || this.overlay?.kind === 'help') this.overlay = null + this.invalidate() + return + } + if (action === 'cancelTransfer' || action === 'dismissTransfer') { + this.dismissTransfer() + this.invalidate() + return + } + if (this.overlay || this.filtering) return + + this.status = null + switch (action) { + case 'pane': + this.active = this.active === 'left' ? 'right' : 'left' + break + case 'help': + this.overlay = { kind: 'help' } + break + case 'open': + if (!this.busy) await this.enter() + break + case 'endpoint': + if (!this.busy) this.openPicker() + break + case 'preview': + if (!this.busy) await this.transferTo(true) + break + case 'sync': + if (!this.busy) await this.transferTo(false) + break + case 'filter': + if (!this.busy) this.filtering = this.active + break + case 'sort': + if (!this.busy) this.cycleSort() + break + default: + break + } + this.invalidate() + } + // ----------------------------------------------------------------- state private get current(): Pane { @@ -434,8 +509,8 @@ export class Tui { private async goUp(): Promise { const pane = this.current - const parent = pane.connection ? posix.dirname(pane.path) : join(pane.path, '..') - if (parent === pane.path) return + const parent = parentPath(pane) + if (parent === null) return pane.path = parent pane.filter = '' await this.load(this.active) diff --git a/apps/cli/src/tui/interaction.test.ts b/apps/cli/src/tui/interaction.test.ts index ddf5be7..9286ef4 100644 --- a/apps/cli/src/tui/interaction.test.ts +++ b/apps/cli/src/tui/interaction.test.ts @@ -5,6 +5,8 @@ * them, so these are the same code paths a keystroke takes in a terminal. */ import { describe, expect, it, vi } from 'vitest' +import type { App } from '@profullstack/hqtui' +import { renderToScreen } from '@profullstack/hqtui/testing' import { key } from './keys.fixture.js' import type { Entry, Pane, Side } from './model.js' @@ -44,6 +46,14 @@ const pane = (app: Tui, side: Side): Pane => state(app).panes[side] const press = async (app: Tui, ...names: string[]) => { for (const name of names) await app.onKey(key(name)) } +/** Draws the real frame, so a click lands where the user would see it. */ +const frame = (app: Tui) => + renderToScreen(({ ui, theme, width, height }) => app.view(ui, theme, width, height), { + width: 100, + height: 30, + collapseBorders: true, + }) +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)) describe('navigation', () => { it('moves the cursor and stops at both ends', async () => { @@ -180,6 +190,116 @@ describe('the help overlay', () => { }) }) +describe('the mouse', () => { + it('selects a row with a click, and focuses whichever pane the click is in', () => { + const app = tui() + const screen = frame(app) + const gamma = screen.find('gamma.ts')! + expect(screen.click(gamma.x, gamma.y)).toBe(true) + expect(pane(app, 'left').index).toBe(2) + expect(state(app).active).toBe('left') + // Anywhere in the right pane, including its empty space. + expect(screen.click(75, 10)).toBe(true) + expect(state(app).active).toBe('right') + }) + + it('opens a directory on a double-click, and .. takes it back up', async () => { + const app = tui([entry('src', { isDirectory: true }), entry('a.ts')]) + let screen = frame(app) + const src = screen.find('src')! + screen.click(src.x, src.y, { clicks: 2 }) + await settle() + expect(pane(app, 'left').path).toBe('/tmp/a/src') + + // The listing failed (there is no such directory) and `..` is still there. + screen = frame(app) + const up = screen.find('..')! + screen.click(up.x, up.y, { clicks: 2 }) + await settle() + expect(pane(app, 'left').path).toBe('/tmp/a') + }) + + it('a file does not open on a double-click', async () => { + const app = tui() + const screen = frame(app) + const beta = screen.find('beta.ts')! + screen.click(beta.x, beta.y, { clicks: 2 }) + await settle() + expect(pane(app, 'left').path).toBe('/tmp/a') + expect(pane(app, 'left').index).toBe(1) + }) + + it('drives the key bar: endpoint opens the picker, a server points the pane, outside closes it', async () => { + const app = tui() + let screen = frame(app) + const endpoint = screen.find('c endpoint')! + screen.click(endpoint.x, endpoint.y) + await settle() + expect(state(app).overlay?.kind).toBe('picker') + + screen = frame(app) + screen.click(0, 0) + expect(state(app).overlay).toBeNull() + + screen = frame(app) + screen.click(endpoint.x, endpoint.y) + await settle() + screen = frame(app) + const blue = screen.find('deploy@10.0.0.7')! + screen.click(blue.x, blue.y) + await settle() + expect(state(app).overlay).toBeNull() + expect(pane(app, 'left').label).toBe('blue') + }) + + it('opens the help from the header and closes it from its button', async () => { + const app = tui() + let screen = frame(app) + const help = screen.find('? help')! + screen.click(help.x, help.y) + await settle() + expect(state(app).overlay?.kind).toBe('help') + screen = frame(app) + const close = screen.find('esc close')! + screen.click(close.x, close.y) + expect(state(app).overlay).toBeNull() + }) + + it('quits from the key bar through the app it is attached to', async () => { + const app = tui() + const quit = vi.fn() + app.attach({ quit, invalidate: () => {} } as unknown as App) + const screen = frame(app) + const q = screen.find('q quit')! + screen.click(q.x, q.y) + await settle() + expect(quit).toHaveBeenCalledTimes(1) + }) + + it('gives a dialog the whole screen: the key bar under it is not clickable', async () => { + const app = tui() + const quit = vi.fn() + app.attach({ quit, invalidate: () => {} } as unknown as App) + await press(app, '?') + const screen = frame(app) + // Bottom row, where the key bar is: the click is taken by the backdrop, + // which closes the help, and nothing underneath acts on it. + expect(screen.click(2, 29)).toBe(true) + expect(state(app).overlay).toBeNull() + expect(quit).not.toHaveBeenCalled() + }) + + it('clears the last message, like a key does', async () => { + const app = tui() + await press(app, '.') + expect(state(app).status).not.toBeNull() + const screen = frame(app) + const alpha = screen.find('alpha.ts')! + screen.click(alpha.x, alpha.y) + expect(state(app).status).toBeNull() + }) +}) + describe('the last message', () => { it('is cleared by the next keystroke, so it never answers the wrong question', async () => { const app = tui() diff --git a/apps/cli/src/tui/model.ts b/apps/cli/src/tui/model.ts index 4357e15..1d8e5ad 100644 --- a/apps/cli/src/tui/model.ts +++ b/apps/cli/src/tui/model.ts @@ -7,7 +7,7 @@ */ import { readdirSync, statSync } from 'node:fs' import { homedir } from 'node:os' -import { join } from 'node:path' +import { join, posix } from 'node:path' import type { Change, ChangeSummary, Connection, RsyncProgress } from '@diskpush/schemas' export type Entry = { @@ -136,6 +136,21 @@ export function selectedEntry(pane: Pane): Entry | null { return visibleEntries(pane)[pane.index] ?? null } +/** The directory above this pane's, or null when there is nowhere up to go. */ +export function parentPath(pane: Pane): string | null { + const parent = pane.connection ? posix.dirname(pane.path) : join(pane.path, '..') + return parent === pane.path ? null : parent +} + +/** + * The `..` row at the top of a listing. The keyboard leaves a directory with + * ←; a mouse needs something to click on, and every file manager since the + * first one has spelled it this way. It is one shared object so the view can + * tell it from a real entry by identity, and it never enters a pane's + * `entries`, so sorting, filtering and the cursor index never see it. + */ +export const PARENT_ENTRY: Entry = Object.freeze({ name: '..', isDirectory: true, size: 0, modifiedAt: null }) + /** Keeps the cursor on a row that exists, which filtering and reloading can break. */ export function clampIndex(pane: Pane): void { const last = Math.max(0, visibleEntries(pane).length - 1) diff --git a/apps/cli/src/tui/view.test.ts b/apps/cli/src/tui/view.test.ts index c8ebc9a..1bbe119 100644 --- a/apps/cli/src/tui/view.test.ts +++ b/apps/cli/src/tui/view.test.ts @@ -10,7 +10,7 @@ import { describe, expect, it } from 'vitest' import { renderToScreen, renderToText } from '@profullstack/hqtui/testing' import { blankPane, type Entry, type Pane, type Transfer } from './model.js' -import { type ViewState, draw, filterChoices, truncatePath } from './view.js' +import { type Action, type ViewHandlers, type ViewState, draw, filterChoices, truncatePath } from './view.js' const entry = (name: string, over: Partial = {}): Entry => ({ name, @@ -128,16 +128,161 @@ describe('truncatePath', () => { }) describe('the mouse', () => { - it('registers a clickable, scrollable region for each pane', () => { - // Wheel and click handlers are otherwise only observable by running a real - // terminal and moving the mouse. - const s = state() - const rendered = renderToScreen(({ ui, theme }) => draw(ui, theme, 100, 30, s, {}), { + // Clicks are driven through the same hit-test the app runs, so these prove + // that the cell showing a thing is the cell that does it — which is + // otherwise only observable by running a real terminal and moving the mouse. + const frame = (s: ViewState, handlers: ViewHandlers = {}) => + renderToScreen(({ ui, theme }) => draw(ui, theme, 100, 30, s, handlers), { width: 100, height: 30, collapseBorders: true, }) - expect(rendered.regions.length).toBeGreaterThanOrEqual(2) + + const runningTransfer = (running: boolean): Transfer => ({ + mode: 'sync', + from: '/home/me/project/', + to: 'deploy@prod:/srv/app/', + running, + progress: null, + recent: [], + summary: { add: 0, update: 0, metadata: 0, delete: 0, unchanged: 0, error: 0 }, + outcome: running ? null : { ok: true, message: 'Sync complete' }, + cancel: () => {}, + }) + + it('registers a clickable, scrollable region for each pane', () => { + expect(frame(state()).regions.length).toBeGreaterThanOrEqual(2) + }) + + it('selects a row on a click and opens it on a double-click', () => { + const log: unknown[] = [] + const rendered = frame(state(), { + onSelectRow: (side, index) => log.push(['select', side, index]), + onOpenRow: (side, index) => log.push(['open', side, index]), + }) + const readme = rendered.find('README.md')! + expect(rendered.click(readme.x, readme.y)).toBe(true) + const src = rendered.find('src')! + rendered.click(src.x, src.y, { clicks: 2 }) + // The second press of a double-click selects and then opens. + expect(log).toEqual([ + ['select', 'left', 1], + ['select', 'left', 0], + ['open', 'left', 0], + ]) + }) + + it('puts .. above a listing that has a parent; a double-click on it goes up', () => { + const log: unknown[] = [] + const rendered = frame(state(), { + onSelectRow: (side, index) => log.push(['select', side, index]), + onOpenRow: (side, index) => log.push(['open', side, index]), + }) + const up = rendered.find('..')! + // A click does not select it: the cursor has nowhere to rest on `..`. + expect(rendered.click(up.x, up.y)).toBe(true) + expect(log).toEqual([]) + rendered.click(up.x, up.y, { clicks: 2 }) + expect(log).toEqual([['open', 'left', -1]]) + }) + + it('has no .. at the root, and keeps it on a listing that failed', () => { + const root = state() + root.panes.left.path = '/' + root.panes.right.path = '/' + expect(screen(root)).not.toContain('..') + + const broken = state() + broken.panes.left.error = 'Permission denied' + const text = screen(broken) + expect(text).toContain('Permission denied') + expect(text).toContain('..') + }) + + it('makes every key in the bar a button for its action', () => { + const actions: Action[] = [] + const rendered = frame(state(), { onAction: (action) => actions.push(action) }) + for (const cap of ['tab pane', '⏎ open', 'c endpoint', 'p preview', 's sync', '/ filter', 'o sort', 'q quit']) { + const at = rendered.find(cap)! + expect(at, cap).not.toBeNull() + expect(rendered.click(at.x, at.y), cap).toBe(true) + } + expect(actions).toEqual(['pane', 'open', 'endpoint', 'preview', 'sync', 'filter', 'sort', 'quit']) + }) + + it('switches panes from the direction in the header, and opens help from ?', () => { + const actions: Action[] = [] + const rendered = frame(state(), { onAction: (action) => actions.push(action) }) + const direction = rendered.find('Local → prod')! + rendered.click(direction.x, direction.y) + const help = rendered.find('? help')! + rendered.click(help.x, help.y) + expect(actions).toEqual(['pane', 'help']) + }) + + it('focuses a pane from a click on its chrome', () => { + const focused: string[] = [] + const rendered = frame(state(), { onPaneFocus: (side) => focused.push(side) }) + const right = rendered.find('▪ prod')! + rendered.click(right.x, right.y) + const left = rendered.find('▪ Local')! + rendered.click(left.x, left.y) + expect(focused).toEqual(['right', 'left']) + }) + + it('chooses a server from the picker with one click, and closes it from outside', () => { + const log: unknown[] = [] + const rendered = frame(state({ overlay: { kind: 'picker', query: '', index: 0 } }), { + onPickChoice: (choice) => log.push(['pick', choice.label]), + onDismissOverlay: () => log.push('dismiss'), + }) + const prod = rendered.find('deploy@prod.example')! + rendered.click(prod.x, prod.y) + rendered.click(0, 0) + expect(log).toEqual([['pick', 'prod'], 'dismiss']) + }) + + it('answers the host-key question only from its buttons', () => { + const log: unknown[] = [] + const rendered = frame( + state({ + overlay: { kind: 'hostKey', host: 'prod', fingerprint: 'SHA256:abc', keyType: 'ed25519', decide: () => {} }, + }), + { onHostKeyDecide: (trust) => log.push(trust), onDismissOverlay: () => log.push('dismiss') }, + ) + const trust = rendered.find('y trust')! + rendered.click(trust.x, trust.y) + const cancel = rendered.find('n cancel')! + rendered.click(cancel.x, cancel.y) + // A stray click outside the dialog is taken, and answers nothing. + expect(rendered.click(0, 0)).toBe(true) + expect(log).toEqual([true, false]) + }) + + it('closes the help from its button or from outside', () => { + let closed = 0 + const rendered = frame(state({ overlay: { kind: 'help' } }), { onDismissOverlay: () => closed++ }) + const close = rendered.find('esc close')! + rendered.click(close.x, close.y) + rendered.click(0, 0) + expect(closed).toBe(2) + }) + + it('dismisses a finished transfer with a click, and never cancels a running one that way', () => { + const actions: Action[] = [] + const done = frame(state({ transfer: runningTransfer(false) }), { onAction: (action) => actions.push(action) }) + const complete = done.find('Sync complete')! + done.click(complete.x, complete.y) + expect(actions).toEqual(['dismissTransfer']) + + const running = frame(state({ transfer: runningTransfer(true) }), { onAction: (action) => actions.push(action) }) + const syncing = running.find('Syncing')! + running.click(syncing.x, syncing.y) + expect(actions).toEqual(['dismissTransfer']) + // Cancel lives on the key bar, spelled out. + const cancel = running.find('esc cancel')! + running.click(cancel.x, cancel.y) + expect(actions).toEqual(['dismissTransfer', 'cancelTransfer']) }) }) @@ -258,9 +403,10 @@ describe('the transfer panel', () => { }) describe('the help overlay', () => { - it('documents every binding, and why Mirror is not one', () => { + it('documents every binding, the mouse, and why Mirror is not one', () => { const text = screen(state({ overlay: { kind: 'help' } })) expect(text).toContain('switch pane') + expect(text).toContain('double-click') expect(text).toContain('Mirror') }) }) diff --git a/apps/cli/src/tui/view.ts b/apps/cli/src/tui/view.ts index e9ffaa3..388e92f 100644 --- a/apps/cli/src/tui/view.ts +++ b/apps/cli/src/tui/view.ts @@ -10,14 +10,17 @@ import { stringWidth, truncate } from '@profullstack/hqtui' import type { Change } from '@diskpush/schemas' import { type EndpointChoice, + type Entry, type Overlay, type Pane, type Side, type Transfer, + PARENT_ENTRY, estimateRemaining, formatDuration, formatSize, formatWhen, + parentPath, visibleEntries, } from './model.js' @@ -35,17 +38,47 @@ export type ViewState = { now: Date } -/** Mouse wiring. Optional so a test can render a frame without any. */ +/** + * Something a key in the footer, or in the header, stands for. A click on the + * key cap does what pressing the key does, through the same code, so the two + * can never disagree. + */ +export type Action = + | 'pane' + | 'open' + | 'endpoint' + | 'preview' + | 'sync' + | 'filter' + | 'sort' + | 'help' + | 'quit' + | 'cancelTransfer' + | 'dismissTransfer' + | 'closeOverlay' + +/** + * Mouse wiring. Optional so a test can render a frame without any. + * + * Rows are reported as an index into the pane's *visible* entries, never as a + * screen row: the table scrolls, and only the frame that drew it knows where + * its window started, so the frame does the arithmetic. `-1` is the `..` row. + */ export type ViewHandlers = { onPaneFocus?: (side: Side) => void - onSelectRow?: (side: Side, visibleRow: number) => void + /** A click on a row: select it. */ + onSelectRow?: (side: Side, index: number) => void + /** A double-click on a row: open the directory, or go up for `..`. */ + onOpenRow?: (side: Side, index: number) => void onScroll?: (side: Side, delta: number) => void - /** - * Every body row the table drew, with its screen row. A click reports the row - * it landed on counted from the top of the visible window, and only the table - * knows where that window starts — this is how the app finds out. - */ - onRowDrawn?: (side: Side, index: number, y: number) => void + /** A click on a key cap in the footer or the header. */ + onAction?: (action: Action) => void + /** A click on a row of the endpoint picker: point the pane there. */ + onPickChoice?: (choice: EndpointChoice) => void + /** A click on a dialog's backdrop, or its close button. */ + onDismissOverlay?: () => void + /** A click on one of the host-key question's answers. */ + onHostKeyDecide?: (trust: boolean) => void } /** Rows the transfer panel takes when one is on screen. */ @@ -107,7 +140,7 @@ export function draw( state: ViewState, handlers: ViewHandlers = {}, ): void { - drawHeader(ui, theme, state) + drawHeader(ui, theme, state, handlers) ui.row({ gap: 0, height: 'fill' }, (row) => { drawPane(row, theme, state, 'left', Math.floor(width / 2), handlers) @@ -116,16 +149,16 @@ export function draw( // A short terminal gives its rows to the panes; the transfer is still // readable from the status line, and half a panel is worse than none. - if (state.transfer && height >= TRANSFER_HEIGHT + 8) drawTransfer(ui, theme, state.transfer) + if (state.transfer && height >= TRANSFER_HEIGHT + 8) drawTransfer(ui, theme, state.transfer, handlers) - drawFooter(ui, theme, state, width) + drawFooter(ui, theme, state, width, handlers) - if (state.overlay?.kind === 'picker') drawPicker(ui, theme, state, state.overlay, height) - if (state.overlay?.kind === 'help') drawHelp(ui, theme) - if (state.overlay?.kind === 'hostKey') drawHostKey(ui, theme, state.overlay, width) + if (state.overlay?.kind === 'picker') drawPicker(ui, theme, state, state.overlay, height, handlers) + if (state.overlay?.kind === 'help') drawHelp(ui, theme, handlers) + if (state.overlay?.kind === 'hostKey') drawHostKey(ui, theme, state.overlay, width, handlers) } -function drawHeader(ui: Container, theme: Theme, state: ViewState): void { +function drawHeader(ui: Container, theme: Theme, state: ViewState, handlers: ViewHandlers): void { const source = state.panes[state.active] const destination = state.panes[state.active === 'left' ? 'right' : 'left'] const arrow = state.active === 'left' ? '→' : '←' @@ -141,8 +174,10 @@ function drawHeader(ui: Container, theme: Theme, state: ViewState): void { { label: 'two-pane rsync browser', color: theme.muted }, ], right: [ - { label: direction, color: theme.accent }, - { key: '?', label: 'help', color: theme.muted }, + // The direction is the active pane spelled out, so clicking it does what + // tab does: it is the one place the sync direction is visible. + { label: direction, color: theme.accent, onPress: () => handlers.onAction?.('pane') }, + { key: '?', label: 'help', color: theme.muted, onPress: () => handlers.onAction?.('help') }, ], }) } @@ -160,6 +195,14 @@ function drawPane( const entries = visibleEntries(pane) const files = entries.filter((entry) => !entry.isDirectory) const bytes = files.reduce((total, entry) => total + entry.size, 0) + // `..` sits above the listing whenever there is somewhere to go. It is not + // one of the entries, so the cursor index counts from the first real row. + // A listing that failed keeps it too: the way out of a directory that + // cannot be read must not be keyboard-only. + const parent = parentPath(pane) !== null + const listed = pane.error ? [] : entries + const rows: Entry[] = parent ? [PARENT_ENTRY, ...listed] : listed + const shift = parent ? 1 : 0 const kind = pane.connection ? '◈' : '▪' const title = ` ${kind} ${pane.label} ` @@ -187,6 +230,9 @@ function drawPane( subtitle: truncatePath(pane.path, pathRoom), subtitleColor: theme.muted, footer: ` ${footerParts.join(' · ')} `, + // The border, the title, the space under a short listing: a click on any + // of it lands in this pane, so this pane becomes the one the keys act on. + onClick: () => handlers.onPaneFocus?.(side), }, (panel) => { if (pane.loading) { @@ -194,58 +240,73 @@ function drawPane( panel.text('Connecting…', { align: 'center', fg: theme.muted }) return } + // Where the table's window started, as of this frame. A click reports + // the row it landed on counted from the top of that window. + let first = 0 + const indexOf = (visibleRow: number) => first + visibleRow - shift + + if (rows.length > 0) { + panel.table({ + rows, + selected: pane.index + shift, + offset: pane.offset, + followSelection: true, + scrollbar: true, + header: false, + // An empty or unreadable directory still shows its `..`, on one + // row, with the explanation underneath rather than a screen of nothing. + ...(listed.length === 0 ? { size: rows.length } : {}), + onFocus: () => handlers.onPaneFocus?.(side), + onSelectRow: (visibleRow) => { + const index = indexOf(visibleRow) + // `..` is not a place the cursor can rest; a click on it only + // brings the pane to the front, and a double-click leaves. + if (index >= 0) handlers.onSelectRow?.(side, index) + }, + onActivateRow: (visibleRow) => handlers.onOpenRow?.(side, indexOf(visibleRow)), + onScroll: (delta) => handlers.onScroll?.(side, delta), + onRow: (_entry, index, y) => { + first = index - y + }, + columns: [ + { + // Fills: the size and date belong against the right edge, not + // floating in the middle of a wide pane behind a column of air. + key: 'name', + width: '1fr', + render: (entry) => `${entry.isDirectory ? '▸' : ' '} ${entry.name}`, + color: (entry) => (entry === PARENT_ENTRY ? theme.muted : entry.isDirectory ? theme.primary : theme.foreground), + }, + { + key: 'size', + width: 7, + align: 'right', + render: (entry) => (entry === PARENT_ENTRY ? '' : entry.isDirectory ? '—' : formatSize(entry.size)), + color: theme.muted, + }, + { + key: 'modifiedAt', + width: 8, + align: 'right', + render: (entry) => formatWhen(entry.modifiedAt, state.now), + color: theme.muted, + }, + ], + }) + } + if (pane.error) { panel.spacer(1) panel.text(pane.error, { fg: theme.danger, wrap: true, align: 'center' }) - return - } - if (entries.length === 0) { + } else if (entries.length === 0) { panel.spacer(1) panel.text(pane.filter ? `Nothing matches “${pane.filter}”` : 'Empty', { align: 'center', fg: theme.muted }) - return } - - panel.table({ - rows: entries, - selected: pane.index, - offset: pane.offset, - followSelection: true, - scrollbar: true, - header: false, - onFocus: () => handlers.onPaneFocus?.(side), - onSelectRow: (visibleRow) => handlers.onSelectRow?.(side, visibleRow), - onScroll: (delta) => handlers.onScroll?.(side, delta), - onRow: (_entry, index, y) => handlers.onRowDrawn?.(side, index, y), - columns: [ - { - // Fills: the size and date belong against the right edge, not - // floating in the middle of a wide pane behind a column of air. - key: 'name', - width: '1fr', - render: (entry) => `${entry.isDirectory ? '▸' : ' '} ${entry.name}`, - color: (entry) => (entry.isDirectory ? theme.primary : theme.foreground), - }, - { - key: 'size', - width: 7, - align: 'right', - render: (entry) => (entry.isDirectory ? '—' : formatSize(entry.size)), - color: theme.muted, - }, - { - key: 'modifiedAt', - width: 8, - align: 'right', - render: (entry) => formatWhen(entry.modifiedAt, state.now), - color: theme.muted, - }, - ], - }) }, ) } -function drawTransfer(ui: Container, theme: Theme, transfer: Transfer): void { +function drawTransfer(ui: Container, theme: Theme, transfer: Transfer, handlers: ViewHandlers): void { const progress = transfer.progress // rsync's last progress line is whatever it happened to print before it // exited — 80% on a preview that finished. A completed transfer is 100%. @@ -269,6 +330,11 @@ function drawTransfer(ui: Container, theme: Theme, transfer: Transfer): void { subtitleColor: theme.muted, borderColor: titleColor, footer: transfer.running ? ' esc cancel ' : ' esc dismiss ', + // A finished transfer is dismissed by clicking it. A running one is not + // cancelled that way: cancelling a sync from a stray click is exactly + // the kind of accident this program exists to avoid, so that stays on + // the key bar, where it is spelled out. + ...(transfer.running ? {} : { onClick: () => handlers.onAction?.('dismissTransfer') }), }, (panel) => { panel.meter({ @@ -333,7 +399,7 @@ function drawTransfer(ui: Container, theme: Theme, transfer: Transfer): void { ) } -function drawFooter(ui: Container, theme: Theme, state: ViewState, width: number): void { +function drawFooter(ui: Container, theme: Theme, state: ViewState, width: number, handlers: ViewHandlers): void { // The filter prompt replaces the key bar while it is being typed: the keys it // would list are all characters going into the filter. if (state.filtering) { @@ -357,6 +423,10 @@ function drawFooter(ui: Container, theme: Theme, state: ViewState, width: number return } + // Each key cap is also a button for the same action. While a dialog is up + // its backdrop takes every click, so the bar under it only needs to read + // right; the dialog's own buttons are the ones that answer. + const act = (action: Action) => () => handlers.onAction?.(action) const overlay = state.overlay?.kind const items = overlay === 'picker' @@ -374,16 +444,23 @@ function drawFooter(ui: Container, theme: Theme, state: ViewState, width: number ] : overlay === 'help' ? [{ key: 'esc', label: 'close' }] - : [ - { key: 'tab', label: 'pane' }, - { key: '⏎', label: 'open' }, - { key: 'c', label: 'endpoint' }, - { key: 'p', label: 'preview' }, - { key: 's', label: 'sync' }, - { key: '/', label: 'filter' }, - { key: 'o', label: 'sort' }, - { key: 'q', label: 'quit' }, - ] + : state.transfer?.running + ? [ + // Every other key is ignored while a transfer runs, so the bar + // says so instead of listing keys that would do nothing. + { key: 'esc', label: 'cancel', onPress: act('cancelTransfer') }, + { key: 'q', label: 'quit', onPress: act('quit') }, + ] + : [ + { key: 'tab', label: 'pane', onPress: act('pane') }, + { key: '⏎', label: 'open', onPress: act('open') }, + { key: 'c', label: 'endpoint', onPress: act('endpoint') }, + { key: 'p', label: 'preview', onPress: act('preview') }, + { key: 's', label: 'sync', onPress: act('sync') }, + { key: '/', label: 'filter', onPress: act('filter') }, + { key: 'o', label: 'sort', onPress: act('sort') }, + { key: 'q', label: 'quit', onPress: act('quit') }, + ] ui.statusBar({ height: 1, keyStyle: 'caps', items }) } @@ -394,13 +471,17 @@ function drawPicker( state: ViewState, overlay: Extract, height: number, + handlers: ViewHandlers, ): void { const matches = filterChoices(state.choices, overlay.query) // Built from a modal rather than `commandPalette`, whose title is fixed at // "Command Palette" — this is a list of your servers, and saying so is the // whole point of the dialog. const rows = Math.max(3, Math.min(matches.length, height - 12)) - ui.modal({ title: ' Point this pane at ', width: 62, height: rows + 6 }, (modal) => { + let first = 0 + ui.modal( + { title: ' Point this pane at ', width: 62, height: rows + 6, onDismiss: () => handlers.onDismissOverlay?.() }, + (modal) => { modal.textInput({ height: 1, value: overlay.query, @@ -412,48 +493,70 @@ function drawPicker( modal.text('No server matches that.', { fg: theme.muted, align: 'center' }) return } - modal.table({ - rows: matches, - selected: overlay.index, - followSelection: true, - scrollbar: true, - header: false, - height: 'fill', - columns: [ - { key: 'label', width: '1fr', color: theme.foreground }, - { key: 'detail', align: 'right', color: theme.muted }, - ], - }) - }) + modal.table({ + rows: matches, + selected: overlay.index, + followSelection: true, + scrollbar: true, + header: false, + height: 'fill', + // This is a menu: one click chooses. Selecting and then confirming is + // what the keyboard does because it has to type a filter first. + onSelectRow: (visibleRow) => { + const choice = matches[first + visibleRow] + if (choice) handlers.onPickChoice?.(choice) + }, + onRow: (_choice, index, y) => { + first = index - y + }, + columns: [ + { key: 'label', width: '1fr', color: theme.foreground }, + { key: 'detail', align: 'right', color: theme.muted }, + ], + }) + }, + ) } -function drawHelp(ui: Container, theme: Theme): void { - ui.modal({ title: ' Keys ', width: 58, height: 22 }, (modal) => { - modal.keyValues( - [ - { label: 'tab', value: 'switch pane' }, - { label: '↑ ↓ / j k', value: 'move' }, - { label: '⏎ / → / l', value: 'open directory' }, - { label: '← / h', value: 'go up' }, - { label: 'pgup pgdn home end', value: 'jump' }, - { label: 'c', value: 'point this pane somewhere else' }, - { label: '/', value: 'filter this listing' }, - { label: 'o / O', value: 'cycle sort / reverse it' }, - { label: '.', value: 'show hidden files' }, - { label: 'r', value: 'reload' }, - { label: 'p', value: 'preview a sync to the other pane' }, - { label: 's', value: 'sync to the other pane' }, - { label: 'esc', value: 'cancel a transfer, or close this' }, - { label: 'q', value: 'quit' }, - ], - { labelColor: theme.accent }, - ) - modal.spacer('fill') - modal.text('No Mirror: deleting files from a keystroke, with no delete list on screen, is the accident DiskPush exists to prevent.', { - fg: theme.muted, - wrap: true, - }) - }) +function drawHelp(ui: Container, theme: Theme, handlers: ViewHandlers): void { + const close = () => handlers.onDismissOverlay?.() + ui.modal( + { + title: ' Keys ', + width: 58, + height: 24, + buttons: [{ label: 'esc close', variant: 'ghost', onPress: close }], + onDismiss: close, + }, + (modal) => { + modal.keyValues( + [ + { label: 'tab', value: 'switch pane' }, + { label: '↑ ↓ / j k', value: 'move' }, + { label: '⏎ / → / l', value: 'open directory' }, + { label: '← / h', value: 'go up' }, + { label: 'pgup pgdn home end', value: 'jump' }, + { label: 'click', value: 'select a row, or a key in the bar' }, + { label: 'double-click', value: 'open a directory, or .. to go up' }, + { label: 'c', value: 'point this pane somewhere else' }, + { label: '/', value: 'filter this listing' }, + { label: 'o / O', value: 'cycle sort / reverse it' }, + { label: '.', value: 'show hidden files' }, + { label: 'r', value: 'reload' }, + { label: 'p', value: 'preview a sync to the other pane' }, + { label: 's', value: 'sync to the other pane' }, + { label: 'esc', value: 'cancel a transfer, or close this' }, + { label: 'q', value: 'quit' }, + ], + { labelColor: theme.accent }, + ) + modal.text('No Mirror: deleting files from a keystroke, with no delete list on screen, is the accident DiskPush exists to prevent.', { + fg: theme.muted, + wrap: true, + }) + modal.spacer('fill') + }, + ) } function drawHostKey( @@ -461,6 +564,7 @@ function drawHostKey( theme: Theme, overlay: Extract, width: number, + handlers: ViewHandlers, ): void { ui.modal( { @@ -468,9 +572,10 @@ function drawHostKey( width: Math.min(72, Math.max(44, width - 8)), height: 11, color: theme.warning, + // No onDismiss: a fingerprint is not something a stray click answers. buttons: [ - { label: 'y trust', variant: 'warning', focused: true }, - { label: 'n cancel', variant: 'ghost' }, + { label: 'y trust', variant: 'warning', focused: true, onPress: () => handlers.onHostKeyDecide?.(true) }, + { label: 'n cancel', variant: 'ghost', onPress: () => handlers.onHostKeyDecide?.(false) }, ], }, (modal) => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bc73911..67b3f52 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,8 +42,8 @@ importers: specifier: workspace:* version: link:../../packages/ssh-core '@profullstack/hqtui': - specifier: ^0.2.0 - version: 0.2.0 + specifier: ^0.5.0 + version: 0.5.0 zod: specifier: ^3.24.1 version: 3.25.76 @@ -1116,8 +1116,8 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} - '@profullstack/hqtui@0.2.0': - resolution: {integrity: sha512-u6YeeYlnWgjCGsdPZIkha/UFstX+Bz0JDBHuszrBOJd9/hpnUWEI0FNSU9ElNaVCFrL3y+H5tZNCvIeReaNPHw==} + '@profullstack/hqtui@0.5.0': + resolution: {integrity: sha512-jDwILBmdQx8pQA0ao/tzzty8u5nFNS4XhvU9xXhZvdIaMeiF3HBJIVtP6cS6kigS4lXmHR7sztbb9QJpaWwhig==} engines: {bun: '>=1.1', node: '>=22.6'} hasBin: true @@ -4996,7 +4996,7 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true - '@profullstack/hqtui@0.2.0': {} + '@profullstack/hqtui@0.5.0': {} '@profullstack/x402-gateway@0.1.0': {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ed680e7..15f6681 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -15,4 +15,4 @@ allowBuilds: minimumReleaseAgeExclude: - '@profullstack/x402-gateway@0.1.0' - - '@profullstack/hqtui@0.2.0' + - '@profullstack/hqtui@0.2.0 || 0.5.0'