From 449f3d859ba59ad50ee6f58e6cad6c6fb3460aea Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 8 Sep 2026 14:14:15 +0000 Subject: [PATCH] Rewrite the TUI on HQTUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two-pane browser was ~940 lines of hand-rolled ANSI: its own escape codes, its own key parser, its own box drawing, and a full-screen repaint on every keystroke. It is now built on @profullstack/hqtui, which owns the terminal, diffs frames and parses input, and the file is split three ways: model.ts state and the pure functions over it (no terminal, no ssh) view.ts the whole screen as one pure function of a snapshot app.ts state and effects only That split is what makes the screen testable. The old TUI wrote straight to stdout, so the only way to know what it drew was to run it in a pty and look; a frame is now a value, and view.test.ts asserts on rendered text. What is new, beyond the same two panes: - a live transfer panel: progress bar, rate, ETA, running add/update/ unchanged counts, and the files as rsync reports them. `s` used to be a status line that changed once at the end. `esc` cancels it, through an AbortSignal into runToCompletion. - `/` filters a listing as you type, `o` cycles name/size/time sort (`O` reverses), `.` toggles dotfiles, `?` lists every binding. - the endpoint picker filters as you type, over names and hostnames both. - modification times, right-aligned beside the sizes. - the mouse: click a pane to focus it, click a row to select it, wheel to scroll. Three defects found by rendering frames rather than reading the code: - Title and path share the panel's top border row and the box gives the path priority. Budgeted against the full pane width, one pane lost its title and the other lost its path — decided by a one-column rounding difference between two panes showing the very same directory. - hqtui's `truncate` keeps the head of a string, which is right for a label and wrong for a path: every worktree under one repo rendered as the identical string. Paths are shortened from the left now. - A finished transfer showed rsync's last printed figure (80% on a preview that had completed) instead of 100%. The host-key prompt keeps its behaviour and its regression test: `q` still quits from inside it, everything else is still swallowed, and a connect that fails still takes its unanswered question with it. Verified against the real binary through a pty: both panes list, the picker loads ~17 ssh_config hosts and narrows to 2 on "see", a real rsync preview and a real sync stream through the transfer panel, and both `q` and Ctrl+C exit 0 with the alternate screen and cursor restored. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AKCDDzxxcypufp9u8cjoAk --- apps/cli/package.json | 1 + apps/cli/src/commands/tui.ts | 55 +- apps/cli/src/tui/app.ts | 806 ++++++++++------------- apps/cli/src/tui/host-key-prompt.test.ts | 27 +- apps/cli/src/tui/interaction.test.ts | 191 ++++++ apps/cli/src/tui/keys.fixture.ts | 27 + apps/cli/src/tui/keys.test.ts | 62 -- apps/cli/src/tui/keys.ts | 107 --- apps/cli/src/tui/model.test.ts | 144 ++++ apps/cli/src/tui/model.ts | 234 +++++++ apps/cli/src/tui/render.test.ts | 48 -- apps/cli/src/tui/render.ts | 61 -- apps/cli/src/tui/view.test.ts | 266 ++++++++ apps/cli/src/tui/view.ts | 483 ++++++++++++++ apps/cli/tsconfig.json | 14 +- docs/cli.md | 9 +- pnpm-lock.yaml | 10 + pnpm-workspace.yaml | 1 + 18 files changed, 1756 insertions(+), 790 deletions(-) create mode 100644 apps/cli/src/tui/interaction.test.ts create mode 100644 apps/cli/src/tui/keys.fixture.ts delete mode 100644 apps/cli/src/tui/keys.test.ts delete mode 100644 apps/cli/src/tui/keys.ts create mode 100644 apps/cli/src/tui/model.test.ts create mode 100644 apps/cli/src/tui/model.ts delete mode 100644 apps/cli/src/tui/render.test.ts delete mode 100644 apps/cli/src/tui/render.ts create mode 100644 apps/cli/src/tui/view.test.ts create mode 100644 apps/cli/src/tui/view.ts diff --git a/apps/cli/package.json b/apps/cli/package.json index bbdd1ed..00d79d0 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -21,6 +21,7 @@ "@diskpush/rsync-core": "workspace:*", "@diskpush/schemas": "workspace:*", "@diskpush/ssh-core": "workspace:*", + "@profullstack/hqtui": "^0.2.0", "zod": "^3.24.1" } } diff --git a/apps/cli/src/commands/tui.ts b/apps/cli/src/commands/tui.ts index 0975dc1..065b5cd 100644 --- a/apps/cli/src/commands/tui.ts +++ b/apps/cli/src/commands/tui.ts @@ -1,11 +1,10 @@ +import { createApp } from '@profullstack/hqtui' import type { DiskPushStore } from '@diskpush/database' import { EXIT } from '../exit-codes.js' import { failure, type Output } from '../output.js' -import { flagValue, type ParsedArgv } from '../parse-argv.js' +import { type ParsedArgv } from '../parse-argv.js' import { resolveEndpoint, sshConfigHosts } from '../resolve.js' import { blankPane, buildEndpointChoices, defaultLocalPath, Tui } from '../tui/app.js' -import { parseKeys } from '../tui/keys.js' -import { ansi } from '../tui/render.js' /** * `diskpush tui` — the two-pane browser, in a terminal. @@ -42,46 +41,28 @@ export async function runTui(parsed: ParsedArgv, store: DiskPushStore, output: O const choices = buildEndpointChoices(await store.listConnections(), sshConfigHosts(), defaultLocalPath()) const tui = new Tui(panes[0]!, panes[1]!, choices) - const restore = () => { - process.stdout.write(ansi.showCursor + ansi.mainScreen) - if (process.stdin.isTTY) process.stdin.setRawMode(false) - process.stdin.pause() - } + // `q` is not a quit key to the app: inside the host-key prompt it has to + // reach the Tui first, which is the only thing that knows a dialog is up. + // Ctrl+C stays with the app so the terminal is restored however it dies. + const app = await createApp({ quitKeys: ['ctrl+c'], collapseBorders: true, title: 'DiskPush' }) + tui.attach(app) - process.stdout.write(ansi.altScreen + ansi.hideCursor) - process.stdin.setRawMode(true) - process.stdin.resume() - process.stdin.setEncoding('utf8') + app.on('key', (event) => { + void (async () => { + if (!(await tui.onKey(event))) app.quit() + else app.invalidate() + })() + }) - const onResize = () => tui.render() - process.stdout.on('resize', onResize) + app.render(({ ui, theme, width, height }) => { + tui.view(ui, theme, width, height) + }) try { - await tui.loadBoth() - tui.render() - - await new Promise((resolve) => { - const onData = (chunk: string) => { - // Several keys can arrive in one chunk, and an arrow key is three - // bytes; parseKeys turns the raw bytes into logical keys first. - void (async () => { - for (const key of parseKeys(chunk)) { - const keepGoing = await tui.onKey(key) - if (!keepGoing) { - process.stdin.off('data', onData) - resolve() - return - } - } - tui.render() - })() - } - process.stdin.on('data', onData) - }) + void tui.loadBoth() + await app.start() } finally { - process.stdout.off('resize', onResize) tui.close() - restore() } return EXIT.ok diff --git a/apps/cli/src/tui/app.ts b/apps/cli/src/tui/app.ts index 5878b48..9b4135a 100644 --- a/apps/cli/src/tui/app.ts +++ b/apps/cli/src/tui/app.ts @@ -1,115 +1,69 @@ -import { mkdtempSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs' -import { homedir, tmpdir } from 'node:os' -import { join, posix } from 'node:path' -import { knownHostsPath } from '@diskpush/database' -import { SftpBrowser, SshSession } from '@diskpush/ssh-core' -import { defaultRsyncOptions, summarizeChanges, type Connection } from '@diskpush/schemas' -import { parseEndpoint, planTransfer, runToCompletion } from '@diskpush/rsync-core' -import { isChar, type Key } from './keys.js' -import { ansi, formatSize, pad, truncate, width as width_ } from './render.js' - /** - * A two-pane browser in the terminal. + * A two-pane browser in the terminal, drawn with HQTUI. * * The same shape as the desktop app and driven by the same engine: either pane * is local or a server, and transfers run through rsync. It deliberately does * not offer Mirror — deleting files from a keystroke, with no delete list on * screen, is the accident the rest of DiskPush is built to prevent. - */ - -const CTRL_C = String.fromCharCode(3) - -export type Entry = { name: string; isDirectory: boolean; size: number } - -type Side = 'left' | 'right' - -export type Pane = { - label: string - connection: Connection | null - path: string - entries: Entry[] - index: number - offset: number - error: string | null - /** - * Entries marked with space. Empty means the whole directory, which is what - * every transfer here used to be: the pane had a cursor and no way to say - * "these two", so `s` always sent everything you were looking at. - * - * Names, not paths, and cleared whenever the pane moves, because a mark - * refers to a row in the directory currently on screen. - */ - marked: Set -} - -const HELP = - 'tab switch arrows/jk move space mark enter open left up c endpoint s sync p preview r refresh q quit' - -/** Somewhere a pane can point at: this machine, or a server. */ -export type EndpointChoice = { - label: string - detail: string - connection: Connection | null - path: string -} - -/** - * Everywhere a pane can be pointed: this machine, then saved connections, then - * `~/.ssh/config` hosts. * - * Deduplicated by name, in that order of precedence — a saved connection wins - * over an ssh_config host of the same name (it carries a port, a key and a - * default path), and ssh_config itself can list one alias more than once. + * This class owns state and effects only. The frame is `view.ts`, the state + * shape is `model.ts`, and neither of those touches a terminal — so the whole + * screen can be rendered and asserted on in a test with no pty. */ -export function buildEndpointChoices( - saved: readonly Connection[], - sshHosts: readonly Connection[], - localPath: string, -): EndpointChoice[] { - const choices: EndpointChoice[] = [ - { label: 'Local', detail: 'this machine', connection: null, path: localPath }, - ...saved.map((connection) => ({ - label: connection.name, - detail: `${connection.username}@${connection.host}`, - connection, - path: connection.defaultRemotePath ?? '.', - })), - ...sshHosts.map((connection) => ({ - label: connection.name, - detail: `${connection.username}@${connection.host} (ssh config)`, - connection, - path: '.', - })), - ] - - const seen = new Set() - return choices.filter((choice) => { - if (seen.has(choice.label)) return false - seen.add(choice.label) - return true - }) -} - -export function blankPane(label: string, path: string, connection: Connection | null = null): Pane { - return { label, connection, path, entries: [], index: 0, offset: 0, error: null, marked: new Set() } -} +import { join, posix } from 'node:path' +import type { App, Container, KeyEvent, Theme } from '@profullstack/hqtui' +import { knownHostsPath } from '@diskpush/database' +import { SftpBrowser, SshSession } from '@diskpush/ssh-core' +import { defaultRsyncOptions, type Change, type Connection } from '@diskpush/schemas' +import { parseEndpoint, planTransfer, runToCompletion } from '@diskpush/rsync-core' +import { + type EndpointChoice, + type Entry, + type Overlay, + type Pane, + type Side, + type SortKey, + SORT_KEYS, + type Transfer, + blankPane, + clampIndex, + listLocal, + pushChange, + selectedEntry, + visibleEntries, +} from './model.js' +import { type Tone, type ViewState, draw, filterChoices } from './view.js' + +export { + blankPane, + buildEndpointChoices, + defaultLocalPath, + listLocal, + type Entry, + type EndpointChoice, + type Pane, +} from './model.js' + +/** How many rows the wheel moves per notch. */ +const WHEEL_ROWS = 3 export class Tui { private readonly panes: Record private active: Side = 'left' - private status = '' + /** Whatever is on screen instead of the panes, and owns the keyboard while it is. */ + private overlay: Overlay | null = null + private transfer: Transfer | null = null + private filtering: Side | null = null + private status: { text: string; tone: Tone } | null = null private busy = false private readonly sessions = new Map() - /** Open endpoint picker, or null. It owns the keyboard while it is up. */ - private picker: { index: number } | null = null + private app: App | null = null /** - * A first connection to a host asks about its key, and the TUI owns the - * keyboard, so the question is a prompt on screen rather than readline. The - * resolver is held until a key answers it. + * 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 hostKey: { host: string; fingerprint: string; keyType: string; decide: (trust: boolean) => void } | null = null - /** The pending "transfer N files?" question, while it is on screen. */ - private confirm: { headline: string; detail: string; decide: (approved: boolean) => void } | null = null + private readonly firstVisible: Record = { left: 0, right: 0 } constructor( left: Pane, @@ -119,6 +73,69 @@ export class Tui { this.panes = { left, right } } + /** Binds the app so background work (a load, a transfer tick) can redraw. */ + attach(app: App): void { + this.app = app + } + + private invalidate(): void { + this.app?.invalidate() + } + + // ------------------------------------------------------------------ view + + snapshot(): ViewState { + return { + panes: this.panes, + active: this.active, + overlay: this.overlay, + transfer: this.transfer, + filtering: this.filtering, + status: this.status, + choices: this.choices, + now: new Date(), + } + } + + /** The render callback handed to `app.render`. */ + view(ui: Container, theme: Theme, width: number, height: number): void { + draw(ui, theme, width, height, this.snapshot(), { + onPaneFocus: (side) => { + this.active = side + this.invalidate() + }, + onSelectRow: (side, visibleRow) => { + this.active = side + const pane = this.panes[side] + pane.index = this.firstVisible[side] + visibleRow + clampIndex(pane) + this.invalidate() + }, + onScroll: (side, delta) => { + this.active = side + this.move(delta * WHEEL_ROWS) + this.invalidate() + }, + onRowDrawn: (side, index, y) => { + this.firstVisible[side] = index - y + }, + }) + } + + // ----------------------------------------------------------------- state + + private get current(): Pane { + return this.panes[this.active] + } + + private get other(): Pane { + return this.panes[this.active === 'left' ? 'right' : 'left'] + } + + private say(text: string, tone: Tone = 'info'): void { + this.status = { text, tone } + } + private async session(connection: Connection): Promise { const existing = this.sessions.get(connection.id) if (existing) return existing @@ -131,16 +148,17 @@ export class Tui { // and useless: the answer is a keystroke away. onUnknownHostKey: (details) => new Promise((resolve) => { - this.hostKey = { + this.overlay = { + kind: 'hostKey', host: details.host, fingerprint: details.fingerprint, keyType: details.keyType, decide: (trust) => { - this.hostKey = null + this.overlay = null resolve(trust) }, } - this.render() + this.invalidate() }), }) this.sessions.set(connection.id, session) @@ -154,9 +172,9 @@ export class Tui { // the connect rejects with "Timed out while waiting for handshake", and // the question outlives the asker. The pane shows an error and the app // looks frozen. Whoever asked is gone, so the question goes with them. - if (this.hostKey) { - this.hostKey = null - this.render() + if (this.overlay?.kind === 'hostKey') { + this.overlay = null + this.invalidate() } } } @@ -164,16 +182,18 @@ export class Tui { async load(side: Side): Promise { const pane = this.panes[side] pane.error = null + pane.loading = true + this.invalidate() try { pane.entries = pane.connection ? await this.listRemote(pane) : listLocal(pane.path) pane.index = 0 pane.offset = 0 - // A mark names a row in the directory that was on screen. Carrying it - // into a new listing would transfer whatever happened to share the name. - pane.marked.clear() } catch (error) { pane.entries = [] pane.error = error instanceof Error ? error.message : String(error) + } finally { + pane.loading = false + this.invalidate() } } @@ -186,311 +206,229 @@ export class Tui { const browser = await SftpBrowser.open(await this.session(pane.connection!)) try { const entries = await browser.list(pane.path) - return entries - .map((entry) => ({ name: entry.name, isDirectory: entry.type === 'directory', size: entry.size })) - .sort(compareEntries) + return entries.map((entry) => ({ + name: entry.name, + isDirectory: entry.type === 'directory', + size: entry.size, + modifiedAt: entry.modifiedAt ?? null, + })) } finally { browser.close() } } - render(): void { - // `||` not `??`: a terminal that reports 0 columns (some pty wrappers do) - // is unknown, not zero-width, and `?? 100` lets the 0 through — which made - // a box width negative and crashed on String.repeat. - const columns = Math.max(48, process.stdout.columns || 100) - const rows = Math.max(12, process.stdout.rows || 30) - const paneWidth = Math.max(20, Math.floor((columns - 3) / 2)) - const listHeight = Math.max(3, rows - 6) - - const out: string[] = [ansi.clear] - out.push(`${ansi.bold}DiskPush${ansi.reset}${ansi.dim} two-pane browser${ansi.reset}`) - - const headers = (['left', 'right'] as const).map((side) => { - const pane = this.panes[side] - this.clampScroll(side, listHeight) - const room = Math.max(8, paneWidth - width_(pane.label) - 3) - const header = `${pane.label} ${ansi.dim}${truncate(pane.path, room)}${ansi.reset}` - const marker = side === this.active ? `${ansi.blue}>${ansi.reset}` : ' ' - return `${marker}${pad(header, paneWidth)}` - }) - out.push(headers.join(' ')) - - // Drawn row by row so the two panes sit side by side. - for (let row = 0; row < listHeight; row += 1) { - const cells = (['left', 'right'] as const).map((side) => { - const pane = this.panes[side] - if (pane.error) return row === 0 ? `${ansi.red}${truncate(pane.error, paneWidth)}${ansi.reset}` : '' - const entry = pane.entries[pane.offset + row] - if (!entry) return '' - const selected = pane.offset + row === pane.index && side === this.active - const marked = pane.marked.has(entry.name) - const name = entry.isDirectory ? `${entry.name}/` : entry.name - // The mark sits in its own column so a name never shifts when it is - // toggled, and it survives the reverse-video cursor. - const size = entry.isDirectory ? '' : formatSize(entry.size) - const body = `${marked ? '*' : ' '}${pad(truncate(name, paneWidth - 9), paneWidth - 9)} ${size.padStart(6)}` - if (selected) return `${ansi.reverse}${body}${ansi.reset}` - return marked ? `${ansi.yellow}${body}${ansi.reset}` : body - }) - out.push(` ${pad(cells[0] ?? '', paneWidth)} ${pad(cells[1] ?? '', paneWidth)}`) - } - - out.push('') - out.push(this.status === '' ? `${ansi.dim}${truncate(HELP, columns - 1)}${ansi.reset}` : truncate(this.status, columns - 1)) - - let frame = out.join('\n') - if (this.picker) frame += this.renderPicker(columns, rows) - if (this.hostKey) frame += this.renderHostKey(columns, rows) - if (this.confirm) frame += this.renderConfirm(columns, rows) - process.stdout.write(frame) - } - - private clampScroll(side: Side, height: number): void { - const pane = this.panes[side] - if (pane.index < pane.offset) pane.offset = pane.index - if (pane.index >= pane.offset + height) pane.offset = pane.index - height + 1 - if (pane.offset < 0) pane.offset = 0 - } - - private get current(): Pane { - return this.panes[this.active] - } - - private get other(): Pane { - return this.panes[this.active === 'left' ? 'right' : 'left'] - } + // ------------------------------------------------------------------ keys /** Returns false when the app should exit. */ - async onKey(key: Key): Promise { - if (isChar(key, CTRL_C)) return false - - if (this.hostKey) { - // Quit stays reachable from inside the prompt. Every other key is - // deliberately swallowed here -- a fingerprint is not something to - // dismiss by mashing -- but a dialog that can trap you in the app is - // worse than one you can leave, and `q` is the quit key everywhere else. - if (isChar(key, 'q')) return false - if (isChar(key, 'y') || isChar(key, 'Y')) this.hostKey.decide(true) - else if (key === 'escape' || isChar(key, 'n') || isChar(key, 'N') || key === 'enter') this.hostKey.decide(false) - return true - } + async onKey(key: KeyEvent): Promise { + if (key.key === 'ctrl+c') return false - if (this.confirm) { - // Anything that is not an explicit yes cancels. A transfer is not the - // sort of thing to start because a key was mashed, and `q` here means - // "not this" rather than "quit", the same as it does in the picker. - const decide = this.confirm.decide - this.confirm = null - decide(isChar(key, 'y') || isChar(key, 'Y')) + if (this.overlay?.kind === 'hostKey') return this.onHostKeyKey(key, this.overlay) + if (this.overlay?.kind === 'help') { + if (key.name === 'q') return false + this.overlay = null return true } - - if (this.picker) { - // Escape closes the picker rather than the app: inside a dialog it means - // "not this", which is not the same as "quit". - if (key === 'escape' || isChar(key, 'q')) { - this.picker = null - } else if (key === 'up' || isChar(key, 'k')) { - this.picker.index = Math.max(0, this.picker.index - 1) - } else if (key === 'down' || isChar(key, 'j')) { - this.picker.index = Math.min(this.choices.length - 1, this.picker.index + 1) - } else if (key === 'enter' || key === 'right' || isChar(key, 'l')) { - await this.choose(this.picker.index) + if (this.overlay?.kind === 'picker') return this.onPickerKey(key, this.overlay) + if (this.filtering) return this.onFilterKey(key) + + // A message is about the last thing that happened; the next key starts + // something new, so it stops being the answer to anything. + this.status = null + + if (key.name === 'escape') { + // Escape belongs to the transfer while there is one: cancelling a sync in + // flight, or clearing the panel a finished one left behind. + if (this.transfer) { + this.dismissTransfer() + return true } - return true + return false } - - if (key === 'escape' || isChar(key, 'q')) return false + if (key.name === 'q') return false if (this.busy) return true - const page = Math.max(1, Math.max(12, process.stdout.rows || 30) - 8) - - if (key === 'tab') { - this.active = this.active === 'left' ? 'right' : 'left' - } else if (key === 'up' || isChar(key, 'k')) { - this.move(-1) - } else if (key === 'down' || isChar(key, 'j')) { - this.move(1) - } else if (key === 'page-up') { - this.move(-page) - } else if (key === 'page-down') { - this.move(page) - } else if (key === 'home') { - this.current.index = 0 - } else if (key === 'end') { - this.current.index = Math.max(0, this.current.entries.length - 1) - } else if (key === 'left' || isChar(key, 'h')) { - await this.goUp() - } else if (key === 'right' || key === 'enter' || isChar(key, 'l')) { - await this.enter() - } else if (isChar(key, ' ')) { - this.toggleMark() - } else if (isChar(key, 'c')) { - this.openPicker() - } else if (isChar(key, 'r')) { - await this.load(this.active) - } else if (isChar(key, 'p')) { - await this.transfer(true) - } else if (isChar(key, 's')) { - await this.transfer(false) + const pane = this.current + const page = Math.max(1, (this.app?.height ?? 30) - 10) + + switch (true) { + case key.name === 'tab': + this.active = this.active === 'left' ? 'right' : 'left' + break + case key.name === 'up' || key.name === 'k': + this.move(-1) + break + case key.name === 'down' || key.name === 'j': + this.move(1) + break + case key.name === 'pageup': + this.move(-page) + break + case key.name === 'pagedown': + this.move(page) + break + case key.name === 'home': + pane.index = 0 + break + case key.name === 'end': + pane.index = Math.max(0, visibleEntries(pane).length - 1) + break + case key.name === 'left' || key.name === 'h': + await this.goUp() + break + case key.name === 'right' || key.name === 'enter' || key.name === 'l': + await this.enter() + break + case key.name === 'c': + this.openPicker() + break + case key.name === 'r': + await this.load(this.active) + break + case key.name === '/': + this.filtering = this.active + break + // A shifted letter arrives as its own character with `shift` unset, so + // the two sort keys are told apart by case rather than by the modifier. + case key.char === 'o': + this.cycleSort() + break + case key.char === 'O': + pane.descending = !pane.descending + clampIndex(pane) + this.say(`Sorted by ${pane.sort}, ${pane.descending ? 'descending' : 'ascending'}`) + break + case key.char === '.': + pane.showHidden = !pane.showHidden + clampIndex(pane) + this.say(pane.showHidden ? 'Showing hidden files' : 'Hiding hidden files') + break + case key.name === 'p': + await this.transferTo(true) + break + case key.name === 's': + await this.transferTo(false) + break + case key.name === '?': + this.overlay = { kind: 'help' } + break + default: + break } return true } - /** - * Marks or unmarks the row under the cursor, then steps down. - * - * Stepping down is what every file manager does and what makes marking a - * run of files one key repeated rather than an alternation of two. - */ - private toggleMark(): void { - const pane = this.current - const entry = pane.entries[pane.index] - if (!entry) return - if (pane.marked.has(entry.name)) pane.marked.delete(entry.name) - else pane.marked.add(entry.name) - this.move(1) + private onHostKeyKey(key: KeyEvent, overlay: Extract): boolean { + // Quit stays reachable from inside the prompt. Every other key is + // deliberately swallowed here -- a fingerprint is not something to dismiss + // by mashing -- but a dialog that can trap you in the app is worse than one + // you can leave, and `q` is the quit key everywhere else. + if (key.name === 'q') return false + if (key.name === 'y') overlay.decide(true) + else if (key.name === 'escape' || key.name === 'n' || key.name === 'enter') overlay.decide(false) + return true + } + + private async onPickerKey(key: KeyEvent, picker: Extract): Promise { + const matches = filterChoices(this.choices, picker.query) + + // Escape closes the picker rather than the app: inside a dialog it means + // "not this", which is not the same as "quit". + if (key.name === 'escape') { + this.overlay = null + } else if (key.name === 'up') { + picker.index = Math.max(0, picker.index - 1) + } else if (key.name === 'down') { + picker.index = Math.min(Math.max(0, matches.length - 1), picker.index + 1) + } else if (key.name === 'backspace') { + picker.query = picker.query.slice(0, -1) + picker.index = 0 + } else if (key.name === 'enter') { + const choice = matches[picker.index] + this.overlay = null + if (choice) await this.choose(choice) + } else if (key.char && !key.ctrl && !key.alt) { + // Every printable key types into the query, which is why the picker binds + // no letter shortcuts of its own — j and k are host names here. + picker.query += key.char + picker.index = 0 + } + return true + } + + private onFilterKey(key: KeyEvent): boolean { + const pane = this.panes[this.filtering!] + if (key.name === 'escape') { + pane.filter = '' + this.filtering = null + } else if (key.name === 'enter') { + this.filtering = null + } else if (key.name === 'backspace') { + pane.filter = pane.filter.slice(0, -1) + } else if (key.char && !key.ctrl && !key.alt) { + pane.filter += key.char + } + clampIndex(pane) + return true } private move(delta: number): void { const pane = this.current - const last = Math.max(0, pane.entries.length - 1) + const last = Math.max(0, visibleEntries(pane).length - 1) pane.index = Math.min(last, Math.max(0, pane.index + delta)) } + private cycleSort(): void { + const pane = this.current + const next = SORT_KEYS[(SORT_KEYS.indexOf(pane.sort) + 1) % SORT_KEYS.length] as SortKey + pane.sort = next + clampIndex(pane) + this.say(`Sorted by ${next}`) + } + + // -------------------------------------------------------------- endpoints + private openPicker(): void { if (this.choices.length === 0) { - this.status = `${ansi.yellow}No servers configured. Add one with: diskpush connections add NAME user@host${ansi.reset}` + this.say('No servers configured. Add one with: diskpush connections add NAME user@host', 'warn') return } const current = this.current.connection const at = this.choices.findIndex((choice) => current ? choice.connection?.name === current.name : choice.connection === null, ) - this.picker = { index: at >= 0 ? at : 0 } + this.overlay = { kind: 'picker', query: '', index: at >= 0 ? at : 0 } } /** Points the active pane at the chosen endpoint and lists it. */ - private async choose(index: number): Promise { - const choice = this.choices[index] - this.picker = null - if (!choice) return - - const pane = this.panes[this.active] + private async choose(choice: EndpointChoice): Promise { + const pane = this.current pane.label = choice.label pane.connection = choice.connection pane.path = choice.path pane.entries = [] pane.index = 0 pane.offset = 0 + pane.filter = '' pane.error = null this.busy = true - this.status = `${ansi.dim}Connecting to ${choice.label}...${ansi.reset}` - this.render() + this.say(`Connecting to ${choice.label}…`) try { await this.load(this.active) - this.status = pane.error ? `${ansi.red}${truncate(pane.error, 200)}${ansi.reset}` : '' + this.status = pane.error ? { text: pane.error, tone: 'error' } : null } finally { this.busy = false + this.invalidate() } } - /** Draws the picker over the panes. Returns the lines it occupies. */ - private renderPicker(columns: number, rows: number): string { - const width = Math.max(28, Math.min(64, columns - 8)) - const left = Math.max(1, Math.floor((columns - width) / 2)) - const index = this.picker?.index ?? 0 - - // A machine with forty hosts in ~/.ssh/config would otherwise draw a box - // taller than the terminal, so the list scrolls with the selection. - const visible = Math.max(3, Math.min(this.choices.length, rows - 8)) - const half = Math.floor(visible / 2) - const start = Math.max(0, Math.min(this.choices.length - visible, index - half)) - const shown = this.choices.slice(start, start + visible) - - const top = Math.max(1, Math.floor((rows - visible - 4) / 2)) - const out: string[] = [] - const line = (row: number, body: string) => out.push(`${ansi.moveTo(row, left)}${body}`) - const inner = width - 2 - - const title = - this.choices.length > visible - ? `Point this pane at ${start + 1}-${start + shown.length} of ${this.choices.length}` - : 'Point this pane at' - - line(top, `${ansi.blue}+${'-'.repeat(inner)}+${ansi.reset}`) - line(top + 1, `${ansi.blue}|${ansi.reset}${ansi.bold}${pad(` ${title}`, inner)}${ansi.reset}${ansi.blue}|${ansi.reset}`) - - shown.forEach((choice, i) => { - const at = start + i - const label = pad(truncate(choice.label, 20), 20) - const detail = truncate(choice.detail, Math.max(4, inner - 24)) - const body = ` ${label} ${detail}` - const text = - at === index - ? `${ansi.reverse}${pad(body, inner)}${ansi.reset}` - : `${pad(` ${label} `, 22)}${ansi.dim}${detail}${ansi.reset}${' '.repeat(Math.max(0, inner - 22 - width_(detail)))}` - line(top + 2 + i, `${ansi.blue}|${ansi.reset}${text}${ansi.blue}|${ansi.reset}`) - }) - - line(top + 2 + shown.length, `${ansi.blue}|${ansi.reset}${ansi.dim}${pad(' enter select esc cancel', inner)}${ansi.reset}${ansi.blue}|${ansi.reset}`) - line(top + 3 + shown.length, `${ansi.blue}+${'-'.repeat(inner)}+${ansi.reset}`) - return out.join('') - } - - /** The host-key question, drawn over everything. */ - private renderConfirm(columns: number, rows: number): string { - const ask = this.confirm! - const width = Math.max(40, Math.min(72, columns - 6)) - const left = Math.max(1, Math.floor((columns - width) / 2)) - const top = Math.max(1, Math.floor(rows / 2) - 2) - const inner = width - 2 - const out: string[] = [] - const line = (row: number, body: string) => out.push(`${ansi.moveTo(row, left)}${body}`) - - line(top, `${ansi.blue}+${'-'.repeat(inner)}+${ansi.reset}`) - line( - top + 1, - `${ansi.blue}|${ansi.reset}${ansi.bold}${pad(` ${truncate(ask.headline, inner - 2)}`, inner)}${ansi.reset}${ansi.blue}|${ansi.reset}`, - ) - line( - top + 2, - `${ansi.blue}|${ansi.reset}${ansi.dim}${pad(` ${truncate(ask.detail, inner - 2)}`, inner)}${ansi.reset}${ansi.blue}|${ansi.reset}`, - ) - line(top + 3, `${ansi.blue}|${ansi.reset}${pad('', inner)}${ansi.blue}|${ansi.reset}`) - line(top + 4, `${ansi.blue}|${ansi.reset}${pad(' y transfer n cancel', inner)}${ansi.blue}|${ansi.reset}`) - line(top + 5, `${ansi.blue}+${'-'.repeat(inner)}+${ansi.reset}`) - return out.join('') - } - - private renderHostKey(columns: number, rows: number): string { - const key = this.hostKey! - const width = Math.max(40, Math.min(72, columns - 6)) - const left = Math.max(1, Math.floor((columns - width) / 2)) - const top = Math.max(1, Math.floor(rows / 2) - 4) - const inner = width - 2 - const out: string[] = [] - const line = (row: number, body: string) => out.push(`${ansi.moveTo(row, left)}${body}`) - - line(top, `${ansi.yellow}+${'-'.repeat(inner)}+${ansi.reset}`) - line(top + 1, `${ansi.yellow}|${ansi.reset}${ansi.bold}${pad(` Unknown host: ${key.host}`, inner)}${ansi.reset}${ansi.yellow}|${ansi.reset}`) - line(top + 2, `${ansi.yellow}|${ansi.reset}${pad('', inner)}${ansi.yellow}|${ansi.reset}`) - line(top + 3, `${ansi.yellow}|${ansi.reset}${pad(` ${key.keyType} key fingerprint:`, inner)}${ansi.yellow}|${ansi.reset}`) - line(top + 4, `${ansi.yellow}|${ansi.reset}${ansi.dim}${pad(` ${truncate(key.fingerprint, inner - 2)}`, inner)}${ansi.reset}${ansi.yellow}|${ansi.reset}`) - line(top + 5, `${ansi.yellow}|${ansi.reset}${pad('', inner)}${ansi.yellow}|${ansi.reset}`) - line(top + 6, `${ansi.yellow}|${ansi.reset}${ansi.dim}${pad(' Compare it with the server before trusting it.', inner)}${ansi.reset}${ansi.yellow}|${ansi.reset}`) - line(top + 7, `${ansi.yellow}|${ansi.reset}${pad(' y trust and continue n cancel', inner)}${ansi.yellow}|${ansi.reset}`) - line(top + 8, `${ansi.yellow}+${'-'.repeat(inner)}+${ansi.reset}`) - return out.join('') - } - private async enter(): Promise { const pane = this.current - const entry = pane.entries[pane.index] + const entry = selectedEntry(pane) if (!entry?.isDirectory) return pane.path = pane.connection ? posix.join(pane.path, entry.name) : join(pane.path, entry.name) + pane.filter = '' await this.load(this.active) } @@ -499,116 +437,88 @@ export class Tui { const parent = pane.connection ? posix.dirname(pane.path) : join(pane.path, '..') if (parent === pane.path) return pane.path = parent + pane.filter = '' await this.load(this.active) } - /** - * The marked entries as a file rsync can read, or null for the whole folder. - * - * NUL-separated, because a newline is legal in a filename and a - * line-separated list would split one such name into two paths that do not - * exist. - */ - private markList(pane: Pane): { path: string; cleanup: () => void } | null { - if (pane.marked.size === 0) return null - const directory = mkdtempSync(join(tmpdir(), 'diskpush-marks-')) - const path = join(directory, 'files-from') - writeFileSync(path, `${[...pane.marked].join('\0')}\0`) - return { path, cleanup: () => rmSync(directory, { recursive: true, force: true }) } + // -------------------------------------------------------------- transfers + + private dismissTransfer(): void { + if (!this.transfer) return + if (this.transfer.running) { + this.transfer.cancel() + this.say('Cancelling…', 'warn') + return + } + this.transfer = null } - /** - * Previews, then transfers what was previewed. - * - * `s` used to start an immediate transfer of the entire directory. There was - * no way to say "these two" and no moment at which anything could be - * refused: by the time a number was on screen the files were already moving. - * Now the dry run always runs first, and a real transfer waits on a yes. - */ - private async transfer(previewOnly: boolean): Promise { + private async transferTo(previewOnly: boolean): Promise { const source = this.current const destination = this.other - const scope = source.marked.size > 0 ? `${source.marked.size} marked` : 'whole folder' + const controller = new AbortController() + + const transfer: Transfer = { + mode: previewOnly ? 'preview' : 'sync', + from: endpointString(source), + to: endpointString(destination), + running: true, + progress: null, + recent: [], + summary: { add: 0, update: 0, metadata: 0, delete: 0, unchanged: 0, error: 0 }, + outcome: null, + cancel: () => controller.abort(), + } + this.transfer = transfer this.busy = true - this.status = `${ansi.yellow}Scanning ${source.path} -> ${destination.path} (${scope})${ansi.reset}` - this.render() + this.invalidate() - const list = this.markList(source) try { const remote = source.connection ?? destination.connection - const optionsFor = (dryRun: boolean) => - defaultRsyncOptions({ - dryRun, - stats: true, - ...(list ? { filesFrom: list.path, from0: true } : {}), - }) - const shell = remote ? { remoteShell: { keyPath: remote.keyPath, port: remote.port } } : {} - const endpoints = { - source: parseEndpoint(endpointString(source)), - destination: parseEndpoint(endpointString(destination)), - } - - const dry = await runToCompletion(planTransfer({ ...endpoints, options: optionsFor(true), ...shell })) - const preview = summarizeChanges(dry.changes) - const moving = preview.add + preview.update - - if (!dry.ok) { - this.status = `${ansi.red}${truncate(dry.message, 200)}${ansi.reset}` - return - } - if (previewOnly) { - this.status = `${ansi.green}Preview (${scope}): ${preview.add} to add, ${preview.update} to update, ${preview.unchanged} unchanged${ansi.reset}` - return - } - if (moving === 0) { - this.status = `${ansi.green}Nothing to transfer (${scope}). The destination already matches.${ansi.reset}` - return - } + const plan = planTransfer({ + source: parseEndpoint(transfer.from), + destination: parseEndpoint(transfer.to), + options: defaultRsyncOptions({ dryRun: previewOnly, stats: true }), + ...(remote ? { remoteShell: { keyPath: remote.keyPath, port: remote.port } } : {}), + }) - const approved = await this.ask( - `Transfer ${moving} file${moving === 1 ? '' : 's'} (${scope})?`, - `into ${destination.path}`, - ) - if (!approved) { - this.status = `${ansi.yellow}Cancelled. Nothing was transferred.${ansi.reset}` - return - } + const result = await runToCompletion(plan, { signal: controller.signal }, (event) => { + if (event.type === 'change') pushChange(transfer, event.change as Change) + else if (event.type === 'progress') transfer.progress = event.progress + this.invalidate() + }) - this.status = `${ansi.yellow}Syncing ${source.path} -> ${destination.path}${ansi.reset}` - this.render() - const result = await runToCompletion(planTransfer({ ...endpoints, options: optionsFor(false), ...shell })) - const summary = summarizeChanges(result.changes) + transfer.running = false + const moved = transfer.summary.add + transfer.summary.update if (!result.ok) { - this.status = `${ansi.red}${truncate(result.message, 200)}${ansi.reset}` + transfer.outcome = { ok: false, message: result.message } + this.say(result.message, 'error') + } else if (previewOnly) { + transfer.outcome = { ok: true, message: 'Preview complete' } + this.say( + `Preview: ${transfer.summary.add} to add, ${transfer.summary.update} to update, ${transfer.summary.unchanged} unchanged`, + 'ok', + ) } else { - this.status = `${ansi.green}Synced ${summary.add + summary.update} files${ansi.reset}` + transfer.outcome = { ok: true, message: 'Sync complete' } + this.say(`Synced ${moved} file${moved === 1 ? '' : 's'}`, 'ok') await this.load(this.active === 'left' ? 'right' : 'left') } } catch (error) { - this.status = `${ansi.red}${truncate(error instanceof Error ? error.message : String(error), 200)}${ansi.reset}` + transfer.running = false + const message = error instanceof Error ? error.message : String(error) + transfer.outcome = { ok: false, message } + this.say(message, 'error') } finally { - list?.cleanup() + transfer.running = false this.busy = false + this.invalidate() } } - /** - * A yes/no question over the panes. Resolves false on anything but y. - * - * Two lines, because `truncate` keeps the END of a string (the right choice - * for a path, the wrong one for a sentence). One combined line lost its own - * verb: "Transfer 4 files to /very/long/path?" rendered as "…r 4 files to - * /very/long/path?", which is a question about nothing. - */ - private ask(headline: string, detail: string): Promise { - return new Promise((resolve) => { - this.confirm = { headline, detail, decide: resolve } - this.render() - }) - } - close(): void { + this.transfer?.cancel() for (const session of this.sessions.values()) session.close() } } @@ -618,21 +528,3 @@ function endpointString(pane: Pane): string { if (!pane.connection) return path return `${pane.connection.username}@${pane.connection.host}:${path}` } - -function compareEntries(a: Entry, b: Entry): number { - if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1 - return a.name.localeCompare(b.name) -} - -export function listLocal(path: string): Entry[] { - return readdirSync(path) - .map((name) => { - const stats = statSync(join(path, name), { throwIfNoEntry: false }) - return { name, isDirectory: stats?.isDirectory() ?? false, size: stats?.size ?? 0 } - }) - .sort(compareEntries) -} - -export function defaultLocalPath(): string { - return process.cwd() || homedir() -} diff --git a/apps/cli/src/tui/host-key-prompt.test.ts b/apps/cli/src/tui/host-key-prompt.test.ts index f9abb9f..185d425 100644 --- a/apps/cli/src/tui/host-key-prompt.test.ts +++ b/apps/cli/src/tui/host-key-prompt.test.ts @@ -13,6 +13,7 @@ // Found by driving the real TUI over a pty against a real sshd and watching it // stop responding after a sync to a remote endpoint. import { describe, expect, it, vi } from 'vitest' +import { key } from './keys.fixture.js' type HostKeyDetails = { host: string; fingerprint: string; keyType: string } type ConnectOptions = { onUnknownHostKey: (d: HostKeyDetails) => Promise } @@ -32,9 +33,8 @@ type Tui = InstanceType /** A Tui with its private host-key prompt set, as a failed connect would leave it. */ function tuiWithPrompt(onDecide: (trust: boolean) => void = () => {}) { const tui = new Tui(blankPane('Local', '/tmp/a'), blankPane('Local', '/tmp/b')) - // Writing to stdout during a test would scribble on the reporter. - ;(tui as unknown as { render: () => void }).render = () => {} - ;(tui as unknown as { hostKey: unknown }).hostKey = { + ;(tui as unknown as { overlay: unknown }).overlay = { + kind: 'hostKey', host: 'example.test', fingerprint: 'SHA256:abc', keyType: 'ssh-ed25519', @@ -43,35 +43,35 @@ function tuiWithPrompt(onDecide: (trust: boolean) => void = () => {}) { return tui } -const prompt = (tui: InstanceType) => (tui as unknown as { hostKey: unknown }).hostKey +const overlay = (tui: Tui) => (tui as unknown as { overlay: unknown }).overlay describe('the host-key prompt', () => { it('lets q quit rather than trapping the app', async () => { const tui = tuiWithPrompt() // false means "stop the app" to the caller in commands/tui.ts. - await expect(tui.onKey({ char: 'q' })).resolves.toBe(false) + await expect(tui.onKey(key('q'))).resolves.toBe(false) }) it('still answers y and n, and keeps swallowing everything else', async () => { const answers: boolean[] = [] const yes = tuiWithPrompt((trust) => answers.push(trust)) - await expect(yes.onKey({ char: 'y' })).resolves.toBe(true) + await expect(yes.onKey(key('y'))).resolves.toBe(true) expect(answers).toEqual([true]) const no = tuiWithPrompt((trust) => answers.push(trust)) - await expect(no.onKey({ char: 'n' })).resolves.toBe(true) + await expect(no.onKey(key('n'))).resolves.toBe(true) expect(answers).toEqual([true, false]) const esc = tuiWithPrompt((trust) => answers.push(trust)) - await expect(esc.onKey('escape')).resolves.toBe(true) + await expect(esc.onKey(key('escape'))).resolves.toBe(true) expect(answers).toEqual([true, false, false]) // An arrow must not leak past the prompt into the file panes behind it. const other = tuiWithPrompt((trust) => answers.push(trust)) - await expect(other.onKey('down')).resolves.toBe(true) - await expect(other.onKey('tab')).resolves.toBe(true) + await expect(other.onKey(key('down'))).resolves.toBe(true) + await expect(other.onKey(key('tab'))).resolves.toBe(true) expect(answers).toEqual([true, false, false]) - expect(prompt(other)).not.toBeNull() + expect(overlay(other)).not.toBeNull() }) it('is cleared when the connect that raised it fails', async () => { @@ -88,7 +88,6 @@ describe('the host-key prompt', () => { } const tui = new Tui(blankPane('Local', '/tmp/a'), blankPane('Local', '/tmp/b')) - ;(tui as unknown as { render: () => void }).render = () => {} const session = (tui as unknown as { session: (c: unknown) => Promise }).session.bind(tui) @@ -98,8 +97,8 @@ describe('the host-key prompt', () => { ) // Before the fix this was still set, and the app was unusable from here on. - expect(prompt(tui)).toBeNull() + expect(overlay(tui)).toBeNull() // With the question gone, the keyboard comes back to the panes. - await expect(tui.onKey({ char: 'q' })).resolves.toBe(false) + await expect(tui.onKey(key('q'))).resolves.toBe(false) }) }) diff --git a/apps/cli/src/tui/interaction.test.ts b/apps/cli/src/tui/interaction.test.ts new file mode 100644 index 0000000..ddf5be7 --- /dev/null +++ b/apps/cli/src/tui/interaction.test.ts @@ -0,0 +1,191 @@ +/** + * The keyboard, driven through the real `Tui`. + * + * Only the network is stubbed; the panes hold entries the way a listing leaves + * them, so these are the same code paths a keystroke takes in a terminal. + */ +import { describe, expect, it, vi } from 'vitest' +import { key } from './keys.fixture.js' +import type { Entry, Pane, Side } from './model.js' + +vi.mock('@diskpush/ssh-core', () => ({ + SshSession: { connect: async () => ({ close: () => {} }) }, + SftpBrowser: { open: async () => ({ list: async () => [], close: () => {} }) }, +})) +vi.mock('@diskpush/database', () => ({ knownHostsPath: () => '/tmp/known_hosts.test' })) + +const { Tui, blankPane } = await import('./app.js') +type Tui = InstanceType + +const entry = (name: string, over: Partial = {}): Entry => ({ + name, + isDirectory: false, + size: 0, + modifiedAt: null, + ...over, +}) + +const LEFT = [entry('alpha.ts'), entry('beta.ts', { size: 900 }), entry('gamma.ts', { size: 20 }), entry('.hidden')] + +function tui(leftEntries: Entry[] = LEFT) { + const left = blankPane('Local', '/tmp/a') + const right = blankPane('Local', '/tmp/b') + left.entries = leftEntries + const app = new Tui(left, right, [ + { label: 'Local', detail: 'this machine', connection: null, path: '/tmp' }, + { label: 'prod', detail: 'deploy@prod.example', connection: null, path: '/srv' }, + { label: 'blue', detail: 'deploy@10.0.0.7', connection: null, path: '.' }, + ]) + return app +} + +const state = (app: Tui) => app.snapshot() +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)) +} + +describe('navigation', () => { + it('moves the cursor and stops at both ends', async () => { + const app = tui() + await press(app, 'down', 'down') + expect(pane(app, 'left').index).toBe(2) + await press(app, 'up', 'up', 'up', 'up') + expect(pane(app, 'left').index).toBe(0) + await press(app, 'end') + // Three visible entries: the dotfile is hidden, so end is index 2, not 3. + expect(pane(app, 'left').index).toBe(2) + }) + + it('switches panes with tab, which is what decides the sync direction', async () => { + const app = tui() + expect(state(app).active).toBe('left') + await press(app, 'tab') + expect(state(app).active).toBe('right') + }) + + it('quits on q and on ctrl+c', async () => { + await expect(tui().onKey(key('q'))).resolves.toBe(false) + await expect(tui().onKey(key('c', { ctrl: true }))).resolves.toBe(false) + }) +}) + +describe('the filter', () => { + it('narrows the listing as it is typed and keeps it on enter', async () => { + const app = tui() + await press(app, '/') + expect(state(app).filtering).toBe('left') + await press(app, 'b', 'e') + expect(pane(app, 'left').filter).toBe('be') + await press(app, 'enter') + expect(state(app).filtering).toBeNull() + expect(pane(app, 'left').filter).toBe('be') + }) + + it('is cleared by escape rather than left behind on a pane you cannot see into', async () => { + const app = tui() + await press(app, '/', 'b', 'escape') + expect(pane(app, 'left').filter).toBe('') + expect(state(app).filtering).toBeNull() + }) + + it('swallows the keys it is given: q types a q, it does not quit', async () => { + // Every printable key belongs to the prompt while it is up. Letting `q` + // through would close the app mid-word. + const app = tui() + await press(app, '/') + await expect(app.onKey(key('q'))).resolves.toBe(true) + expect(pane(app, 'left').filter).toBe('q') + }) + + it('backspaces', async () => { + const app = tui() + await press(app, '/', 'a', 'b', 'backspace') + expect(pane(app, 'left').filter).toBe('a') + }) +}) + +describe('sorting and hidden files', () => { + it('cycles the sort with o and reverses it with O', async () => { + const app = tui() + expect(pane(app, 'left').sort).toBe('name') + await press(app, 'o') + expect(pane(app, 'left').sort).toBe('size') + await press(app, 'o') + expect(pane(app, 'left').sort).toBe('time') + await press(app, 'o') + expect(pane(app, 'left').sort).toBe('name') + + // A shifted letter arrives as its own character with `shift` unset, which + // is why this is matched on case and not on the modifier. + await app.onKey(key('O')) + expect(pane(app, 'left').descending).toBe(true) + }) + + it('toggles dotfiles with .', async () => { + const app = tui() + await press(app, '.') + expect(pane(app, 'left').showHidden).toBe(true) + }) +}) + +describe('the endpoint picker', () => { + it('opens on c, types into its query and closes on escape', async () => { + const app = tui() + await press(app, 'c') + expect(state(app).overlay?.kind).toBe('picker') + await press(app, 'b', 'l') + expect(state(app).overlay).toMatchObject({ kind: 'picker', query: 'bl' }) + await press(app, 'escape') + expect(state(app).overlay).toBeNull() + }) + + it('keeps the selection inside the matches as the query narrows them', async () => { + // The index was pointing at the third of three endpoints; typing a query + // that leaves one behind must not select past the end of the list. + const app = tui() + await press(app, 'c') + await press(app, 'down', 'down') + await press(app, 'p', 'r', 'o') + await press(app, 'down') + expect(state(app).overlay).toMatchObject({ index: 0 }) + }) + + it('points the pane at the endpoint that enter selects', async () => { + const app = tui() + await press(app, 'c') + await press(app, 'p', 'r', 'o') + await press(app, 'enter') + expect(pane(app, 'left').label).toBe('prod') + expect(pane(app, 'left').path).toBe('/srv') + }) + + it('escape closes the picker rather than the app', async () => { + const app = tui() + await press(app, 'c') + await expect(app.onKey(key('escape'))).resolves.toBe(true) + }) +}) + +describe('the help overlay', () => { + it('opens on ? and closes on the next key, but q still quits', async () => { + const app = tui() + await press(app, '?') + expect(state(app).overlay?.kind).toBe('help') + await press(app, 'escape') + expect(state(app).overlay).toBeNull() + + await press(app, '?') + await expect(app.onKey(key('q'))).resolves.toBe(false) + }) +}) + +describe('the last message', () => { + it('is cleared by the next keystroke, so it never answers the wrong question', async () => { + const app = tui() + await press(app, '.') + expect(state(app).status).not.toBeNull() + await press(app, 'down') + expect(state(app).status).toBeNull() + }) +}) diff --git a/apps/cli/src/tui/keys.fixture.ts b/apps/cli/src/tui/keys.fixture.ts new file mode 100644 index 0000000..dc677f1 --- /dev/null +++ b/apps/cli/src/tui/keys.fixture.ts @@ -0,0 +1,27 @@ +/** + * Key events for tests. + * + * The TUI is driven by HQTUI's parsed `KeyEvent`, so a test that wants to press + * a key has to build one. Every field matters somewhere — `char` is what the + * filter and the endpoint picker type with, and `key` is what Ctrl+C matches — + * so they are derived here once rather than spelled out at each call site. + */ +import type { KeyEvent } from '@profullstack/hqtui' + +export function key(name: string, modifiers: { ctrl?: boolean; alt?: boolean; shift?: boolean } = {}): KeyEvent { + const ctrl = modifiers.ctrl ?? false + const alt = modifiers.alt ?? false + const shift = modifiers.shift ?? false + const printable = name.length === 1 + const prefix = `${ctrl ? 'ctrl+' : ''}${alt ? 'alt+' : ''}${shift ? 'shift+' : ''}` + return { + type: 'key', + name, + ctrl, + alt, + shift, + ...(printable && !ctrl && !alt ? { char: name } : {}), + key: `${prefix}${name}`, + raw: printable ? name : '', + } +} diff --git a/apps/cli/src/tui/keys.test.ts b/apps/cli/src/tui/keys.test.ts deleted file mode 100644 index 63ab96d..0000000 --- a/apps/cli/src/tui/keys.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { isChar, parseKeys } from './keys.js' - -const ESC = String.fromCharCode(27) - -describe('parseKeys', () => { - it('reads arrow keys as single keys, not as escape then junk', () => { - // The bug this prevents: reading byte by byte sees the escape first, and - // if escape quits, every arrow key closes the program. - expect(parseKeys(`${ESC}[A`)).toEqual(['up']) - expect(parseKeys(`${ESC}[B`)).toEqual(['down']) - expect(parseKeys(`${ESC}[C`)).toEqual(['right']) - expect(parseKeys(`${ESC}[D`)).toEqual(['left']) - }) - - it('treats an escape arriving alone as the escape key', () => { - expect(parseKeys(ESC)).toEqual(['escape']) - }) - - it('reads the application-mode form some terminals send', () => { - expect(parseKeys(`${ESC}OA`)).toEqual(['up']) - }) - - it('reads page and home keys', () => { - expect(parseKeys(`${ESC}[5~`)).toEqual(['page-up']) - expect(parseKeys(`${ESC}[6~`)).toEqual(['page-down']) - expect(parseKeys(`${ESC}[H`)).toEqual(['home']) - expect(parseKeys(`${ESC}[F`)).toEqual(['end']) - }) - - it('reads ordinary characters', () => { - expect(parseKeys('q')).toEqual([{ char: 'q' }]) - expect(parseKeys('jk')).toEqual([{ char: 'j' }, { char: 'k' }]) - }) - - it('reads enter and tab', () => { - expect(parseKeys('\r')).toEqual(['enter']) - expect(parseKeys('\n')).toEqual(['enter']) - expect(parseKeys('\t')).toEqual(['tab']) - }) - - it('handles several keys arriving in one chunk', () => { - expect(parseKeys(`${ESC}[Bj${ESC}[A`)).toEqual(['down', { char: 'j' }, 'up']) - }) - - it('ignores an unrecognised escape sequence rather than quitting', () => { - // Alt+x and friends must not be mistaken for the escape key. - expect(parseKeys(`${ESC}x`)).toEqual([]) - }) - - it('drops a truncated sequence instead of emitting a stray escape', () => { - expect(parseKeys(`${ESC}[`)).toEqual([]) - }) -}) - -describe('isChar', () => { - it('matches a character key', () => { - expect(isChar({ char: 'q' }, 'q')).toBe(true) - expect(isChar({ char: 'j' }, 'q')).toBe(false) - expect(isChar('up', 'q')).toBe(false) - }) -}) diff --git a/apps/cli/src/tui/keys.ts b/apps/cli/src/tui/keys.ts deleted file mode 100644 index fab0f1b..0000000 --- a/apps/cli/src/tui/keys.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** - * Turns raw terminal input into logical keys. - * - * A terminal sends an arrow key as three bytes: escape, `[`, then a letter. - * Reading input a character at a time therefore sees a bare escape first — and - * if escape means quit, every arrow key closes the program. That is the bug - * this file exists to prevent, so the sequences are parsed as units and only a - * escape arriving alone counts as one. - */ -const ESC = String.fromCharCode(27) - -export type Key = - | 'up' - | 'down' - | 'left' - | 'right' - | 'enter' - | 'tab' - | 'escape' - | 'page-up' - | 'page-down' - | 'home' - | 'end' - | { char: string } - -const CSI_FINAL: Record = { - A: 'up', - B: 'down', - C: 'right', - D: 'left', - H: 'home', - F: 'end', -} - -const CSI_TILDE: Record = { - '1': 'home', - '4': 'end', - '5': 'page-up', - '6': 'page-down', - '7': 'home', - '8': 'end', -} - -export function parseKeys(chunk: string): Key[] { - const keys: Key[] = [] - let i = 0 - - while (i < chunk.length) { - const char = chunk[i]! - - if (char !== ESC) { - if (char === '\r' || char === '\n') keys.push('enter') - else if (char === '\t') keys.push('tab') - else keys.push({ char }) - i += 1 - continue - } - - // Escape with nothing after it in this chunk is the key itself. - if (i + 1 >= chunk.length) { - keys.push('escape') - i += 1 - continue - } - - // CSI: ESC [ ... final - if (chunk[i + 1] === '[') { - let j = i + 2 - let parameters = '' - while (j < chunk.length && /[0-9;]/.test(chunk[j]!)) { - parameters += chunk[j] - j += 1 - } - const final = chunk[j] - if (final === undefined) { - // Truncated sequence; drop it rather than emit a spurious escape. - break - } - if (final === '~') { - const key = CSI_TILDE[parameters] - if (key) keys.push(key) - } else { - const key = CSI_FINAL[final] - if (key) keys.push(key) - } - i = j + 1 - continue - } - - // SS3: ESC O , which some terminals send for arrows in application mode. - if (chunk[i + 1] === 'O' && i + 2 < chunk.length) { - const key = CSI_FINAL[chunk[i + 2]!] - if (key) keys.push(key) - i += 3 - continue - } - - // Alt+key and anything else unrecognised: ignore rather than quit. - i += 2 - } - - return keys -} - -export function isChar(key: Key, char: string): boolean { - return typeof key === 'object' && key.char === char -} diff --git a/apps/cli/src/tui/model.test.ts b/apps/cli/src/tui/model.test.ts new file mode 100644 index 0000000..91a8c94 --- /dev/null +++ b/apps/cli/src/tui/model.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from 'vitest' +import { + type Entry, + type Pane, + blankPane, + clampIndex, + compareEntries, + formatSize, + formatWhen, + pushChange, + selectedEntry, + visibleEntries, + TRANSFER_LOG_LIMIT, +} from './model.js' +import type { Transfer } from './model.js' + +const entry = (name: string, over: Partial = {}): Entry => ({ + name, + isDirectory: false, + size: 0, + modifiedAt: null, + ...over, +}) + +function paneWith(entries: Entry[], over: Partial = {}): Pane { + return { ...blankPane('Local', '/tmp'), entries, ...over } +} + +describe('visibleEntries', () => { + it('hides dotfiles until they are asked for', () => { + const pane = paneWith([entry('.env'), entry('app.ts')]) + expect(visibleEntries(pane).map((e) => e.name)).toEqual(['app.ts']) + pane.showHidden = true + expect(visibleEntries(pane).map((e) => e.name)).toEqual(['.env', 'app.ts']) + }) + + it('filters case-insensitively on a substring', () => { + const pane = paneWith([entry('README.md'), entry('index.ts')], { filter: 'ME' }) + expect(visibleEntries(pane).map((e) => e.name)).toEqual(['README.md']) + }) + + it('keeps directories on top however the sort runs', () => { + // Reversing a size sort must not bury the directories you navigate with + // under the data, which is the whole reason they are pinned. + const pane = paneWith( + [entry('big.bin', { size: 9000 }), entry('src', { isDirectory: true }), entry('small.txt', { size: 1 })], + { sort: 'size', descending: true }, + ) + expect(visibleEntries(pane).map((e) => e.name)).toEqual(['src', 'big.bin', 'small.txt']) + }) + + it('breaks ties on name, so equal sizes are not left in listing order', () => { + const pane = paneWith([entry('b'), entry('a')], { sort: 'size' }) + expect(visibleEntries(pane).map((e) => e.name)).toEqual(['a', 'b']) + }) + + it('sorts by time when asked', () => { + const pane = paneWith( + [ + entry('old', { modifiedAt: '2020-01-01T00:00:00.000Z' }), + entry('new', { modifiedAt: '2026-01-01T00:00:00.000Z' }), + ], + { sort: 'time', descending: true }, + ) + expect(visibleEntries(pane).map((e) => e.name)).toEqual(['new', 'old']) + }) +}) + +describe('the cursor', () => { + it('indexes what is on screen, not the raw listing', () => { + // A filter that removes everything above the cursor would otherwise select + // a different file than the one under the highlight. + const pane = paneWith([entry('a.ts'), entry('b.ts'), entry('c.ts')], { filter: 'c', index: 0 }) + expect(selectedEntry(pane)?.name).toBe('c.ts') + }) + + it('is pulled back onto a row that exists when the list shrinks', () => { + const pane = paneWith([entry('a'), entry('b'), entry('c')], { index: 2 }) + pane.filter = 'a' + clampIndex(pane) + expect(pane.index).toBe(0) + }) + + it('is null on an empty listing rather than a phantom row', () => { + expect(selectedEntry(paneWith([]))).toBeNull() + }) +}) + +describe('compareEntries', () => { + it('orders names naturally when the sort is by name', () => { + expect(compareEntries(entry('a'), entry('b'), 'name', false)).toBeLessThan(0) + expect(compareEntries(entry('a'), entry('b'), 'name', true)).toBeGreaterThan(0) + }) +}) + +describe('formatSize', () => { + it('stays narrow enough for the column it lives in', () => { + expect(formatSize(999)).toBe('999B') + expect(formatSize(1500)).toBe('1.5K') + expect(formatSize(1_500_000)).toBe('1.5M') + expect(formatSize(15_000_000)).toBe('15M') + }) +}) + +describe('formatWhen', () => { + const now = new Date('2026-09-08T12:00:00.000Z') + + it('shows a clock for today and a year for anything old', () => { + const today = new Date('2026-09-08T08:30:00.000Z') + expect(formatWhen(today.toISOString(), now)).toMatch(/^\d{2}:\d{2}$/) + expect(formatWhen('2019-03-04T00:00:00.000Z', now)).toMatch(/^Mar 2019$/) + }) + + it('says nothing rather than "Invalid Date" when there is no timestamp', () => { + expect(formatWhen(null, now)).toBe('') + expect(formatWhen('not a date', now)).toBe('') + }) +}) + +describe('the transfer log', () => { + const transfer = (): Transfer => ({ + mode: 'sync', + from: '/a/', + to: '/b/', + running: true, + progress: null, + recent: [], + summary: { add: 0, update: 0, metadata: 0, delete: 0, unchanged: 0, error: 0 }, + outcome: null, + cancel: () => {}, + }) + + it('counts every change but only keeps the last screenful', () => { + // A million-file sync must not hold a million objects alive just to draw + // the last twenty of them. + const t = transfer() + for (let i = 0; i < TRANSFER_LOG_LIMIT + 50; i += 1) { + pushChange(t, { action: 'add', path: `file-${i}`, itemize: null, isDirectory: false, size: 1 }) + } + expect(t.summary.add).toBe(TRANSFER_LOG_LIMIT + 50) + expect(t.recent).toHaveLength(TRANSFER_LOG_LIMIT) + expect(t.recent.at(-1)?.path).toBe(`file-${TRANSFER_LOG_LIMIT + 49}`) + }) +}) diff --git a/apps/cli/src/tui/model.ts b/apps/cli/src/tui/model.ts new file mode 100644 index 0000000..4357e15 --- /dev/null +++ b/apps/cli/src/tui/model.ts @@ -0,0 +1,234 @@ +/** + * The state behind the two-pane browser, and the pure functions over it. + * + * Everything here is data: no terminal, no ssh, no rsync. The view renders a + * snapshot of it and the app mutates it, which is what makes both testable + * without a pty. + */ +import { readdirSync, statSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' +import type { Change, ChangeSummary, Connection, RsyncProgress } from '@diskpush/schemas' + +export type Entry = { + name: string + isDirectory: boolean + size: number + /** ISO timestamp, or null when the source did not report one. */ + modifiedAt: string | null +} + +export type Side = 'left' | 'right' + +/** Which column the listing is ordered by. `o` cycles through these. */ +export type SortKey = 'name' | 'size' | 'time' + +export const SORT_KEYS: readonly SortKey[] = ['name', 'size', 'time'] + +export type Pane = { + label: string + connection: Connection | null + path: string + entries: Entry[] + /** Index into the *visible* entries, so filtering never selects a hidden row. */ + index: number + offset: number + error: string | null + /** Live substring filter from the `/` prompt. */ + filter: string + sort: SortKey + descending: boolean + showHidden: boolean + loading: boolean +} + +export function blankPane(label: string, path: string, connection: Connection | null = null): Pane { + return { + label, + connection, + path, + entries: [], + index: 0, + offset: 0, + error: null, + filter: '', + sort: 'name', + descending: false, + showHidden: false, + loading: false, + } +} + +/** Somewhere a pane can point at: this machine, or a server. */ +export type EndpointChoice = { + label: string + detail: string + connection: Connection | null + path: string +} + +/** + * Everywhere a pane can be pointed: this machine, then saved connections, then + * `~/.ssh/config` hosts. + * + * Deduplicated by name, in that order of precedence — a saved connection wins + * over an ssh_config host of the same name (it carries a port, a key and a + * default path), and ssh_config itself can list one alias more than once. + */ +export function buildEndpointChoices( + saved: readonly Connection[], + sshHosts: readonly Connection[], + localPath: string, +): EndpointChoice[] { + const choices: EndpointChoice[] = [ + { label: 'Local', detail: 'this machine', connection: null, path: localPath }, + ...saved.map((connection) => ({ + label: connection.name, + detail: `${connection.username}@${connection.host}`, + connection, + path: connection.defaultRemotePath ?? '.', + })), + ...sshHosts.map((connection) => ({ + label: connection.name, + detail: `${connection.username}@${connection.host} (ssh config)`, + connection, + path: '.', + })), + ] + + const seen = new Set() + return choices.filter((choice) => { + if (seen.has(choice.label)) return false + seen.add(choice.label) + return true + }) +} + +/** Case-insensitive substring match, which is what a `/` filter means here. */ +export function matchesFilter(name: string, filter: string): boolean { + if (filter === '') return true + return name.toLowerCase().includes(filter.toLowerCase()) +} + +export function compareEntries(a: Entry, b: Entry, sort: SortKey, descending: boolean): number { + // Directories stay on top whichever way the sort runs: they are how you move + // around, not data, and burying them under a reversed size sort is useless. + if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1 + + let ordering: number + if (sort === 'size') ordering = a.size - b.size + else if (sort === 'time') ordering = (a.modifiedAt ?? '').localeCompare(b.modifiedAt ?? '') + else ordering = 0 + + // Name is the tiebreak for every sort, so equal sizes are not in listing order. + if (ordering === 0) ordering = a.name.localeCompare(b.name) + return descending ? -ordering : ordering +} + +/** What the pane actually shows: hidden files, the filter and the sort applied. */ +export function visibleEntries(pane: Pane): Entry[] { + return pane.entries + .filter((entry) => (pane.showHidden || !entry.name.startsWith('.')) && matchesFilter(entry.name, pane.filter)) + .sort((a, b) => compareEntries(a, b, pane.sort, pane.descending)) +} + +export function selectedEntry(pane: Pane): Entry | null { + return visibleEntries(pane)[pane.index] ?? 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) + pane.index = Math.min(last, Math.max(0, pane.index)) +} + +export function listLocal(path: string): Entry[] { + return readdirSync(path).map((name) => { + const stats = statSync(join(path, name), { throwIfNoEntry: false }) + return { + name, + isDirectory: stats?.isDirectory() ?? false, + size: stats?.size ?? 0, + modifiedAt: stats ? stats.mtime.toISOString() : null, + } + }) +} + +export function defaultLocalPath(): string { + return process.cwd() || homedir() +} + +/** Short enough for a narrow column: `4.2M`, not `4.2 MB`. */ +export function formatSize(bytes: number): string { + if (bytes < 1000) return `${bytes}B` + const units = ['K', 'M', 'G', 'T', 'P'] + let value = bytes + let unit = -1 + while (value >= 1000 && unit < units.length - 1) { + value /= 1000 + unit += 1 + } + return `${value.toFixed(value < 10 ? 1 : 0)}${units[unit]}` +} + +/** + * A file date the way a file manager shows one: a clock for today, a day and + * month for this year, a year for anything older. + */ +export function formatWhen(iso: string | null, now = new Date()): string { + if (!iso) return '' + const date = new Date(iso) + if (Number.isNaN(date.getTime())) return '' + const pad2 = (value: number) => String(value).padStart(2, '0') + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] + const sameDay = + date.getFullYear() === now.getFullYear() && + date.getMonth() === now.getMonth() && + date.getDate() === now.getDate() + if (sameDay) return `${pad2(date.getHours())}:${pad2(date.getMinutes())}` + if (date.getFullYear() === now.getFullYear()) return `${months[date.getMonth()]} ${pad2(date.getDate())}` + return `${months[date.getMonth()]} ${date.getFullYear()}` +} + +/** Whatever is on screen instead of the panes, and owns the keyboard while it is. */ +export type Overlay = + | { kind: 'picker'; query: string; index: number } + | { kind: 'help' } + | { kind: 'hostKey'; host: string; fingerprint: string; keyType: string; decide: (trust: boolean) => void } + +/** A transfer in flight, or the last one that ran. */ +export type Transfer = { + mode: 'preview' | 'sync' + from: string + to: string + running: boolean + progress: RsyncProgress | null + /** Most recent paths rsync reported, newest last. Capped by `pushChange`. */ + recent: Change[] + summary: ChangeSummary + outcome: { ok: boolean; message: string } | null + cancel: () => void +} + +export const TRANSFER_LOG_LIMIT = 200 + +export function pushChange(transfer: Transfer, change: Change): void { + transfer.summary[change.action] += 1 + transfer.recent.push(change) + // The log panel shows a screenful; a million-file sync must not also hold a + // million objects alive just to draw the last twenty of them. + if (transfer.recent.length > TRANSFER_LOG_LIMIT) transfer.recent.splice(0, transfer.recent.length - TRANSFER_LOG_LIMIT) +} + +/** Remaining seconds, derived from progress rather than rsync's own estimate. */ +export function estimateRemaining(progress: RsyncProgress | null): number | null { + if (!progress || progress.percent <= 0 || progress.percent >= 100 || progress.elapsedSeconds <= 0) return null + return (progress.elapsedSeconds / progress.percent) * (100 - progress.percent) +} + +export function formatDuration(seconds: number): string { + const total = Math.max(0, Math.round(seconds)) + const minutes = Math.floor(total / 60) + if (minutes >= 60) return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}m` + return `${minutes}:${String(total % 60).padStart(2, '0')}` +} diff --git a/apps/cli/src/tui/render.test.ts b/apps/cli/src/tui/render.test.ts deleted file mode 100644 index 67faecd..0000000 --- a/apps/cli/src/tui/render.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { formatSize, pad, truncate, width } from './render.js' - -const ESC = String.fromCharCode(27) - -describe('width', () => { - it('ignores escape sequences, which occupy no columns', () => { - expect(width(`${ESC}[7mhi${ESC}[0m`)).toBe(2) - }) -}) - -describe('truncate', () => { - it('keeps the end of a path, which is the part that identifies it', () => { - expect(truncate('a/very/long/path/to/file.txt', 12)).toBe('…to/file.txt') - }) - - it('leaves short text alone', () => { - expect(truncate('short', 12)).toBe('short') - }) - - it('does not throw on a zero or negative budget', () => { - // A terminal reporting 0 columns used to reach String.repeat with a - // negative count and crash the whole TUI. - expect(() => truncate('abc', 0)).not.toThrow() - expect(() => truncate('abc', -5)).not.toThrow() - }) -}) - -describe('pad', () => { - it('pads to the requested width', () => { - expect(pad('ab', 5)).toBe('ab ') - }) - - it('never emits a negative-length pad', () => { - expect(() => pad('abcdef', -3)).not.toThrow() - }) -}) - -describe('formatSize', () => { - it('uses bytes below a thousand', () => { - expect(formatSize(512)).toBe('512B') - }) - - it('steps up units', () => { - expect(formatSize(2048)).toBe('2.0K') - expect(formatSize(5_400_000)).toBe('5.4M') - }) -}) diff --git a/apps/cli/src/tui/render.ts b/apps/cli/src/tui/render.ts deleted file mode 100644 index 54a60d4..0000000 --- a/apps/cli/src/tui/render.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * Terminal drawing primitives. - * - * Hand-rolled rather than a TUI framework: the CLI bundle ships inside the - * desktop app, and a full-screen browser is not worth another dependency tree - * when what it needs is a box, a list and a status bar. - */ - -// Built rather than written literally: a raw escape byte in a source file is -// invisible in diffs and review, and easy to mangle. -const ESC = String.fromCharCode(27) -const CSI = `${ESC}[` - -export const ansi = { - clear: `${CSI}2J${CSI}H`, - hideCursor: `${CSI}?25l`, - showCursor: `${CSI}?25h`, - altScreen: `${CSI}?1049h`, - mainScreen: `${CSI}?1049l`, - reset: `${CSI}0m`, - dim: `${CSI}2m`, - bold: `${CSI}1m`, - reverse: `${CSI}7m`, - blue: `${CSI}38;5;39m`, - red: `${CSI}38;5;203m`, - green: `${CSI}38;5;78m`, - yellow: `${CSI}38;5;221m`, - moveTo: (row: number, column: number) => `${CSI}${row};${column}H`, -} - -const ANSI_PATTERN = new RegExp(`${ESC}\\[[0-9;?]*[A-Za-z]`, 'g') - -/** Visible width, ignoring escape sequences, which occupy no columns. */ -export function width(text: string): number { - return text.replace(ANSI_PATTERN, '').length -} - -export function truncate(text: string, to: number): string { - if (width(text) <= to) return text - // Keeps the end of a path, which is the part that identifies it. - const plain = text.replace(ANSI_PATTERN, '') - if (to <= 1) return plain.slice(0, Math.max(0, to)) - return `…${plain.slice(-(to - 1))}` -} - -export function pad(text: string, to: number): string { - const visible = width(text) - return visible >= to ? truncate(text, to) : text + ' '.repeat(to - visible) -} - -export function formatSize(bytes: number): string { - if (bytes < 1000) return `${bytes}B` - const units = ['K', 'M', 'G', 'T', 'P'] - let value = bytes - let unit = -1 - while (value >= 1000 && unit < units.length - 1) { - value /= 1000 - unit += 1 - } - return `${value.toFixed(value < 10 ? 1 : 0)}${units[unit]}` -} diff --git a/apps/cli/src/tui/view.test.ts b/apps/cli/src/tui/view.test.ts new file mode 100644 index 0000000..c8ebc9a --- /dev/null +++ b/apps/cli/src/tui/view.test.ts @@ -0,0 +1,266 @@ +/** + * The screen itself, asserted on as text. + * + * The old TUI wrote escape sequences straight to stdout, so the only way to + * know what it drew was to run it in a pty and look. HQTUI renders to an + * in-memory framebuffer, which means the frame is just a value — and defects + * like a path scrolling off the panel or a dialog that never appears become + * ordinary failing assertions. + */ +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' + +const entry = (name: string, over: Partial = {}): Entry => ({ + name, + isDirectory: false, + size: 0, + modifiedAt: null, + ...over, +}) + +function pane(label: string, path: string, entries: Entry[], over: Partial = {}): Pane { + return { ...blankPane(label, path), entries, ...over } +} + +function state(over: Partial = {}): ViewState { + return { + panes: { + left: pane('Local', '/home/me/project', [ + entry('src', { isDirectory: true }), + entry('README.md', { size: 4096, modifiedAt: '2026-09-08T09:00:00.000Z' }), + ]), + right: pane('prod', '/srv/app', [entry('bundle.js', { size: 1_500_000 })]), + }, + active: 'left', + overlay: null, + transfer: null, + filtering: null, + status: null, + choices: [ + { label: 'Local', detail: 'this machine', connection: null, path: '/home/me' }, + { label: 'prod', detail: 'deploy@prod.example', connection: null, path: '/srv/app' }, + ], + now: new Date('2026-09-08T12:00:00.000Z'), + ...over, + } +} + +const screen = (s: ViewState, width = 100, height = 30) => + renderToText(({ ui, theme }) => draw(ui, theme, width, height, s), { width, height, collapseBorders: true }) + +describe('the two panes', () => { + it('shows both endpoints, their paths and their contents', () => { + const text = screen(state()) + expect(text).toContain('Local') + expect(text).toContain('/home/me/project') + expect(text).toContain('prod') + expect(text).toContain('/srv/app') + expect(text).toContain('README.md') + expect(text).toContain('bundle.js') + }) + + it('marks directories and sizes the files', () => { + const text = screen(state()) + expect(text).toContain('▸ src') + expect(text).toContain('4.1K') + expect(text).toContain('1.5M') + }) + + it('names the sync direction in the header, so `s` is never a guess', () => { + expect(screen(state())).toContain('Local → prod') + expect(screen(state({ active: 'right' }))).toContain('Local ← prod') + }) + + it('says an empty directory is empty instead of drawing a blank box', () => { + const empty = state() + empty.panes.right = pane('prod', '/srv/app', []) + expect(screen(empty)).toContain('Empty') + }) + + it('explains an empty pane that a filter emptied', () => { + const filtered = state() + filtered.panes.left = pane('Local', '/tmp', [entry('a.ts')], { filter: 'zzz' }) + expect(screen(filtered)).toContain('Nothing matches') + }) + + it('shows the error from a listing that failed', () => { + const failed = state() + failed.panes.right = { ...pane('prod', '/srv/app', []), error: 'Permission denied' } + expect(screen(failed)).toContain('Permission denied') + }) + + it('keeps a long path inside its own pane', () => { + // The path is drawn in the panel's top border, which cannot wrap: an + // untruncated one used to run straight through the pane beside it. + const deep = state() + deep.panes.left = pane('Local', `/${'very-long-directory-name/'.repeat(12)}`, [entry('a.ts')]) + for (const line of screen(deep).split('\n')) expect(line.length).toBeLessThanOrEqual(100) + }) + + it('keeps the endpoint name AND the path on both panes when the path is long', () => { + // Both live in the same top border row and the box gives the path + // priority, so a path budgeted against the full pane width cost one pane + // its title and the other its path — decided by a one-column rounding + // difference between two panes showing the very same directory. + const long = state() + const path = '/home/me/src/profullstack/diskpush/.claude/worktrees/tui-hqtui' + long.panes.left = pane('Local', path, [entry('a.ts')]) + long.panes.right = pane('prod', path, [entry('a.ts')]) + const header = screen(long).split('\n')[1] ?? '' + expect(header).toContain('Local') + expect(header).toContain('prod') + expect(header.match(/tui-hqtui/g) ?? []).toHaveLength(2) + }) +}) + +describe('truncatePath', () => { + it('keeps the end, which is the part that names the directory', () => { + // Shortened from the head instead, every worktree under the same repo + // renders as the identical string. + expect(truncatePath('/home/me/src/profullstack/diskpush', 14)).toBe('…tack/diskpush') + }) + + it('leaves a path that already fits alone', () => { + expect(truncatePath('/srv/app', 40)).toBe('/srv/app') + }) +}) + +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, {}), { + width: 100, + height: 30, + collapseBorders: true, + }) + expect(rendered.regions.length).toBeGreaterThanOrEqual(2) + }) +}) + +describe('the key bar', () => { + it('lists the bindings that apply, and swaps them for the dialog that is up', () => { + expect(screen(state())).toContain('sync') + const picking = screen(state({ overlay: { kind: 'picker', query: '', index: 0 } })) + expect(picking).toContain('cancel') + expect(picking).not.toContain('endpoint') + }) + + it('gives way to the last message when there is one', () => { + const text = screen(state({ status: { text: 'Synced 12 files', tone: 'ok' } })) + expect(text).toContain('Synced 12 files') + }) + + it('becomes the filter prompt while a filter is being typed', () => { + const filtering = state({ filtering: 'left' }) + filtering.panes.left.filter = 'read' + expect(screen(filtering)).toContain('read') + }) +}) + +describe('the endpoint picker', () => { + it('lists the endpoints and narrows as the query is typed', () => { + expect(screen(state({ overlay: { kind: 'picker', query: '', index: 0 } }))).toContain('deploy@prod.example') + const narrowed = screen(state({ overlay: { kind: 'picker', query: 'pro', index: 0 } })) + expect(narrowed).toContain('prod') + expect(narrowed).not.toContain('this machine') + }) + + it('matches on the detail line too, so a hostname finds its alias', () => { + const choices = [ + { label: 'Local', detail: 'this machine', connection: null, path: '/' }, + { label: 'blue', detail: 'deploy@10.0.0.7', connection: null, path: '/' }, + ] + expect(filterChoices(choices, '10.0.0').map((c) => c.label)).toEqual(['blue']) + }) +}) + +describe('the host-key question', () => { + it('puts the fingerprint on screen with both answers', () => { + const text = screen( + state({ + overlay: { + kind: 'hostKey', + host: 'prod.example', + fingerprint: 'SHA256:PZm9Q0uz2pFmxYnAlY7lHi5zZ9UgUJmZAlKZLB5Fpuo', + keyType: 'ssh-ed25519', + decide: () => {}, + }, + }), + ) + expect(text).toContain('prod.example') + expect(text).toContain('SHA256:PZm9Q0uz2pFmxYnAlY7lHi5zZ9UgUJmZAlKZLB5Fpuo') + expect(text).toContain('trust') + expect(text).toContain('cancel') + }) +}) + +describe('the transfer panel', () => { + const transfer = (over: Partial = {}): Transfer => ({ + mode: 'sync', + from: '/home/me/project/', + to: 'deploy@prod:/srv/app/', + running: true, + progress: { + bytesTransferred: 12_000_000, + percent: 42, + bytesPerSecond: 3_000_000, + elapsedSeconds: 4, + filesTransferred: 18, + filesRemaining: 30, + filesTotal: 48, + }, + recent: [{ action: 'add', path: 'dist/app.js', itemize: null, isDirectory: false, size: 2048 }], + summary: { add: 7, update: 2, metadata: 0, delete: 0, unchanged: 91, error: 0 }, + outcome: null, + cancel: () => {}, + ...over, + }) + + it('shows progress, rate and the files as they go by', () => { + const text = screen(state({ transfer: transfer() })) + expect(text).toContain('Syncing') + expect(text).toContain('42%') + expect(text).toContain('3.0M/s') + expect(text).toContain('dist/app.js') + expect(text).toContain('+7') + }) + + it('offers cancel while it runs and dismiss once it is done', () => { + expect(screen(state({ transfer: transfer() }))).toContain('cancel') + const done = transfer({ running: false, outcome: { ok: true, message: 'Sync complete' } }) + expect(screen(state({ transfer: done }))).toContain('dismiss') + }) + + it('finishes the bar at 100%, not at rsync\'s last printed figure', () => { + const done = transfer({ running: false, progress: { ...transfer().progress!, percent: 80 }, outcome: { ok: true, message: 'Preview complete' } }) + const text = screen(state({ transfer: done })) + expect(text).toContain('100%') + expect(text).not.toContain('80%') + }) + + it('reports a failure instead of a finished bar', () => { + const failed = transfer({ running: false, outcome: { ok: false, message: 'rsync: connection unexpectedly closed' } }) + const text = screen(state({ transfer: failed })) + expect(text).toContain('Failed') + expect(text).toContain('connection unexpectedly closed') + }) + + it('gives its rows back to the panes on a short terminal', () => { + // Half a transfer panel is worse than none: the panes are the app. + const text = screen(state({ transfer: transfer() }), 100, 14) + expect(text).not.toContain('Syncing') + expect(text).toContain('README.md') + }) +}) + +describe('the help overlay', () => { + it('documents every binding, and why Mirror is not one', () => { + const text = screen(state({ overlay: { kind: 'help' } })) + expect(text).toContain('switch pane') + expect(text).toContain('Mirror') + }) +}) diff --git a/apps/cli/src/tui/view.ts b/apps/cli/src/tui/view.ts new file mode 100644 index 0000000..e9ffaa3 --- /dev/null +++ b/apps/cli/src/tui/view.ts @@ -0,0 +1,483 @@ +/** + * The whole screen, as one pure function of a snapshot. + * + * Nothing here touches the terminal, ssh or rsync: give it a `ViewState` and it + * describes a frame. That is what lets `view.test.ts` assert on real rendered + * text with no pty, and what keeps the app class down to state and effects. + */ +import type { Color, Container, Theme } from '@profullstack/hqtui' +import { stringWidth, truncate } from '@profullstack/hqtui' +import type { Change } from '@diskpush/schemas' +import { + type EndpointChoice, + type Overlay, + type Pane, + type Side, + type Transfer, + estimateRemaining, + formatDuration, + formatSize, + formatWhen, + visibleEntries, +} from './model.js' + +export type Tone = 'info' | 'ok' | 'warn' | 'error' + +export type ViewState = { + panes: Record + active: Side + overlay: Overlay | null + transfer: Transfer | null + /** Which pane's `/` prompt is being typed into, if any. */ + filtering: Side | null + status: { text: string; tone: Tone } | null + choices: readonly EndpointChoice[] + now: Date +} + +/** Mouse wiring. Optional so a test can render a frame without any. */ +export type ViewHandlers = { + onPaneFocus?: (side: Side) => void + onSelectRow?: (side: Side, visibleRow: 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 +} + +/** Rows the transfer panel takes when one is on screen. */ +const TRANSFER_HEIGHT = 9 + +const ACTION_LEVEL: Record = { + add: 'ADD', + update: 'UPD', + metadata: 'META', + delete: 'DEL', + unchanged: 'SAME', + error: 'ERR', +} + +export function toneColor(theme: Theme, tone: Tone): Color { + if (tone === 'ok') return theme.success + if (tone === 'warn') return theme.warning + if (tone === 'error') return theme.danger + return theme.info +} + +/** + * Shortens a path from the left. + * + * `truncate` keeps the head, which is right for a label and wrong for a path: + * `/home/anthony/src/profullstack/…` names nobody's directory, while the tail + * is exactly the part that identifies it. + */ +export function truncatePath(path: string, to: number): string { + if (to <= 0) return '' + if (stringWidth(path) <= to) return path + if (to === 1) return '…' + let out = '' + let used = 1 + const characters = [...path] + for (let i = characters.length - 1; i >= 0; i -= 1) { + const width = stringWidth(characters[i]!) + if (used + width > to) break + out = characters[i]! + out + used += width + } + return `…${out}` +} + +/** The picker's live filter: substring over both the name and the detail line. */ +export function filterChoices(choices: readonly EndpointChoice[], query: string): EndpointChoice[] { + if (query.trim() === '') return [...choices] + const needle = query.trim().toLowerCase() + return choices.filter( + (choice) => choice.label.toLowerCase().includes(needle) || choice.detail.toLowerCase().includes(needle), + ) +} + +export function draw( + ui: Container, + theme: Theme, + width: number, + height: number, + state: ViewState, + handlers: ViewHandlers = {}, +): void { + drawHeader(ui, theme, state) + + ui.row({ gap: 0, height: 'fill' }, (row) => { + drawPane(row, theme, state, 'left', Math.floor(width / 2), handlers) + drawPane(row, theme, state, 'right', Math.floor(width / 2), handlers) + }) + + // 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) + + drawFooter(ui, theme, state, width) + + 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) +} + +function drawHeader(ui: Container, theme: Theme, state: ViewState): void { + const source = state.panes[state.active] + const destination = state.panes[state.active === 'left' ? 'right' : 'left'] + const arrow = state.active === 'left' ? '→' : '←' + const direction = + state.active === 'left' ? `${source.label} ${arrow} ${destination.label}` : `${destination.label} ${arrow} ${source.label}` + + ui.statusBar({ + height: 1, + keyStyle: 'plain', + background: theme.surface, + items: [ + { label: 'DiskPush', color: theme.primary, active: true }, + { label: 'two-pane rsync browser', color: theme.muted }, + ], + right: [ + { label: direction, color: theme.accent }, + { key: '?', label: 'help', color: theme.muted }, + ], + }) +} + +function drawPane( + row: Container, + theme: Theme, + state: ViewState, + side: Side, + paneWidth: number, + handlers: ViewHandlers, +): void { + const pane = state.panes[side] + const active = side === state.active + const entries = visibleEntries(pane) + const files = entries.filter((entry) => !entry.isDirectory) + const bytes = files.reduce((total, entry) => total + entry.size, 0) + + const kind = pane.connection ? '◈' : '▪' + const title = ` ${kind} ${pane.label} ` + // Title and path share the top border row, and the box gives the path + // priority: a path budgeted against the full pane width squeezes the title + // down to an ellipsis on one side and is dropped whole on the other, so which + // half of the header you lose comes down to a one-column rounding difference. + // Budgeting it against what the title leaves keeps both. + const pathRoom = Math.max(12, paneWidth - stringWidth(title) - 8) + const sortMark = pane.descending ? '▼' : '▲' + const footerParts = [ + `${entries.length} item${entries.length === 1 ? '' : 's'}`, + files.length > 0 ? formatSize(bytes) : '', + `${pane.sort}${sortMark}`, + pane.showHidden ? 'hidden' : '', + pane.filter ? `/${pane.filter}` : '', + ].filter(Boolean) + + row.panel( + { + width: '1fr', + focused: active, + title, + titleColor: active ? theme.primary : theme.muted, + subtitle: truncatePath(pane.path, pathRoom), + subtitleColor: theme.muted, + footer: ` ${footerParts.join(' · ')} `, + }, + (panel) => { + if (pane.loading) { + panel.spacer(1) + panel.text('Connecting…', { align: 'center', fg: theme.muted }) + return + } + if (pane.error) { + panel.spacer(1) + panel.text(pane.error, { fg: theme.danger, wrap: true, align: 'center' }) + return + } + 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 { + 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%. + const percent = transfer.outcome?.ok ? 100 : (progress?.percent ?? 0) + const remaining = estimateRemaining(progress) + const preview = transfer.mode === 'preview' + + const title = transfer.running + ? ` ${preview ? 'Previewing' : 'Syncing'} ` + : transfer.outcome?.ok + ? ` ${preview ? 'Preview' : 'Sync'} complete ` + : ' Failed ' + const titleColor = transfer.running ? theme.warning : transfer.outcome?.ok ? theme.success : theme.danger + + ui.panel( + { + height: TRANSFER_HEIGHT, + title, + titleColor, + subtitle: truncatePath(`${transfer.from} → ${transfer.to}`, 60), + subtitleColor: theme.muted, + borderColor: titleColor, + footer: transfer.running ? ' esc cancel ' : ' esc dismiss ', + }, + (panel) => { + panel.meter({ + height: 1, + value: Math.max(0, Math.min(1, percent / 100)), + label: preview ? 'scan' : 'copy', + text: `${percent.toFixed(0)}%`, + heat: false, + color: transfer.outcome?.ok === false ? theme.danger : theme.primary, + }) + + panel.row({ height: 1, gap: 1 }, (row) => { + const rate = progress && progress.bytesPerSecond > 0 ? `${formatSize(progress.bytesPerSecond)}/s` : '—' + const moved = progress ? formatSize(progress.bytesTransferred) : '—' + const files = progress?.filesTransferred != null ? String(progress.filesTransferred) : '—' + row.text(` ${moved} · ${rate} · ${files} files`, { fg: theme.muted }) + row.text( + remaining != null ? `${formatDuration(remaining)} left ` : progress ? `${formatDuration(progress.elapsedSeconds)} ` : '', + { fg: theme.muted, align: 'right' }, + ) + }) + + panel.row({ height: 1, gap: 1 }, (row) => { + const { add, update, metadata, delete: removed, unchanged, error } = transfer.summary + // Along a row every child fills by default, which spreads six short + // badges across the whole panel. Each one is sized to its own text so + // they read as a group. + const badge = (text: string, color: Color, variant: 'subtle' | 'filled' = 'subtle') => + row.badge({ text, color, variant, width: text.length + 2 }) + badge(`+${add}`, theme.success) + badge(`~${update}`, theme.info) + badge(`=${unchanged}`, theme.muted) + if (metadata > 0) badge(`meta ${metadata}`, theme.muted) + if (removed > 0) badge(`-${removed}`, theme.warning) + if (error > 0) badge(`err ${error}`, theme.danger, 'filled') + row.spacer('fill') + }) + + if (transfer.outcome && !transfer.outcome.ok) { + panel.text(transfer.outcome.message, { fg: theme.danger, wrap: true }) + return + } + + panel.log({ + height: 'fill', + follow: true, + entries: transfer.recent.map((change) => ({ + level: ACTION_LEVEL[change.action], + message: change.path, + meta: change.size != null && !change.isDirectory ? formatSize(change.size) : '', + })), + levelColors: { + ADD: theme.success, + UPD: theme.info, + META: theme.muted, + DEL: theme.warning, + SAME: theme.muted, + ERR: theme.danger, + }, + }) + }, + ) +} + +function drawFooter(ui: Container, theme: Theme, state: ViewState, width: number): 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) { + ui.textInput({ + height: 1, + label: 'filter', + value: state.panes[state.filtering].filter, + placeholder: 'type to narrow, enter to keep, esc to clear', + focused: true, + width, + }) + return + } + + if (state.status) { + ui.statusBar({ + height: 1, + background: theme.surface, + items: [{ label: truncate(state.status.text, width - 2), color: toneColor(theme, state.status.tone), active: true }], + }) + return + } + + const overlay = state.overlay?.kind + const items = + overlay === 'picker' + ? [ + { key: '↑↓', label: 'move' }, + { key: '⏎', label: 'select' }, + { key: 'type', label: 'filter' }, + { key: 'esc', label: 'cancel' }, + ] + : overlay === 'hostKey' + ? [ + { key: 'y', label: 'trust' }, + { key: 'n', label: 'cancel' }, + { key: 'q', label: 'quit' }, + ] + : 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' }, + ] + + ui.statusBar({ height: 1, keyStyle: 'caps', items }) +} + +function drawPicker( + ui: Container, + theme: Theme, + state: ViewState, + overlay: Extract, + height: number, +): 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) => { + modal.textInput({ + height: 1, + value: overlay.query, + placeholder: 'type to filter servers', + focused: true, + }) + modal.divider({ height: 1, color: theme.border }) + if (matches.length === 0) { + 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 }, + ], + }) + }) +} + +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 drawHostKey( + ui: Container, + theme: Theme, + overlay: Extract, + width: number, +): void { + ui.modal( + { + title: ` Unknown host: ${overlay.host} `, + width: Math.min(72, Math.max(44, width - 8)), + height: 11, + color: theme.warning, + buttons: [ + { label: 'y trust', variant: 'warning', focused: true }, + { label: 'n cancel', variant: 'ghost' }, + ], + }, + (modal) => { + modal.text(`${overlay.keyType} key fingerprint:`) + modal.text(overlay.fingerprint, { fg: theme.warning, bold: true, wrap: true }) + modal.spacer(1) + modal.text('Compare it with the server before trusting it.', { fg: theme.muted, wrap: true }) + }, + ) +} diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index 6926b0f..3ae56f4 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -1,6 +1,14 @@ { "extends": "../../tsconfig.base.json", - "compilerOptions": { "outDir": "dist", "rootDir": "src" }, - "include": ["src/**/*.ts"], - "exclude": ["src/**/*.test.ts"] + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": [ + "src/**/*.ts" + ], + "exclude": [ + "src/**/*.test.ts", + "src/**/*.fixture.ts" + ] } diff --git a/docs/cli.md b/docs/cli.md index 07ccdca..c794cac 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -71,7 +71,14 @@ diskpush desktop # launch the desktop app In the TUI: `tab` switches pane, arrows or `j`/`k` move, Enter or `l` opens, `h` or left goes up, **`c` points the active pane somewhere else**, `s` syncs the active pane into the other, `p` previews that sync, `r` refreshes, `q` -quits. +quits. `/` filters the listing as you type, `o` cycles the sort between name, +size and time (`O` reverses it), `.` shows dotfiles, `?` lists every binding, +and `esc` cancels a transfer in flight. The mouse works too: click a pane to +focus it, click a row to select it, and scroll with the wheel. + +A transfer opens a panel below the panes with a progress bar, the rate, the +files as rsync reports them and a running count of adds, updates and unchanged +files — so `s` is no longer a status line that changes once at the end. `c` opens a picker listing Local, your saved connections, and the hosts in `~/.ssh/config` — so either pane can be a server without naming one on the diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f4bafdc..bc73911 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: '@diskpush/ssh-core': specifier: workspace:* version: link:../../packages/ssh-core + '@profullstack/hqtui': + specifier: ^0.2.0 + version: 0.2.0 zod: specifier: ^3.24.1 version: 3.25.76 @@ -1113,6 +1116,11 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@profullstack/hqtui@0.2.0': + resolution: {integrity: sha512-u6YeeYlnWgjCGsdPZIkha/UFstX+Bz0JDBHuszrBOJd9/hpnUWEI0FNSU9ElNaVCFrL3y+H5tZNCvIeReaNPHw==} + engines: {bun: '>=1.1', node: '>=22.6'} + hasBin: true + '@profullstack/x402-gateway@0.1.0': resolution: {integrity: sha512-B7tWvWk/bIEoqyec6UoyRF1pO7X/+b+wFRv2ZFIClqskmEpyxoA559ZgdTvnxqAIvuDeE9v56nVpYRQ+lmOZQQ==} engines: {node: '>=20.11'} @@ -4988,6 +4996,8 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@profullstack/hqtui@0.2.0': {} + '@profullstack/x402-gateway@0.1.0': {} '@radix-ui/primitive@1.1.7': {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f862126..ed680e7 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -15,3 +15,4 @@ allowBuilds: minimumReleaseAgeExclude: - '@profullstack/x402-gateway@0.1.0' + - '@profullstack/hqtui@0.2.0'