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'