From d4e3eb19ba554c5fc71bcb3f326aaaf26303e8f6 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 12 Sep 2026 20:55:20 +0000 Subject: [PATCH 1/3] tui: the panes are a folding tree, driven by one click MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first pass at the mouse copied a file manager from 1990: a click moved the cursor and a double-click walked into a directory, replacing the listing with a new one. That is not what a mouse user expects from a pane of files today. They expect the directory to open in place, under its own name, on one click, and to close the same way, with the rest of the listing still on screen around it. And they expect the row under the pointer to say so. Each pane is now that tree: - One click on a directory unfolds it: its listing is fetched (locally, or over the same SFTP session for a server) and drawn indented beneath it, nested as deep as you like. The marker on the row carries the state: ▸ folded, ▾ unfolded, … while the listing is on its way. The next click folds it again, and the listing is kept so unfolding is instant. A directory that cannot be read says so on the status line and stays folded. - One click on a file selects it. One click on `..` goes up. - The row under the pointer is lit, a shade lighter than the background, and goes out when the pointer leaves the rows. The frame's own tree hears the pointer only while it is inside, so the app's mouse listener notices the move that no row claimed. - The keyboard walks the same tree: → unfolds a folded directory and steps onto the first child of an unfolded one, ← folds the directory under the cursor, else climbs to its parent row, else leaves the root. ⏎ toggles. Double-click is gone; nothing needs it. The pane's state grew a `children` map (listing by relative path), an `unfolded` set, a `listing` set and a `hover` index; `visibleRows` flattens the tree in draw order, which is what the cursor indexes and what a click lands on. The view draws it with hqtui 0.5.1's tree, whose new `onRow` callback and `hovered` option are what made this possible. The interaction tests now unfold a real directory made on disk under `tmpdir`, click into its nested directory, fold the parent, walk it with the arrow keys, and prove the hover lights and goes out. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DWtLsmAescX4Nb8QiBJd37 --- apps/cli/src/commands/tui.ts | 3 + apps/cli/src/tui/app.ts | 153 ++++++++++++++++++++++----- apps/cli/src/tui/interaction.test.ts | 99 +++++++++++++++-- apps/cli/src/tui/model.ts | 101 +++++++++++++++--- apps/cli/src/tui/view.test.ts | 45 +++++--- apps/cli/src/tui/view.ts | 116 ++++++++++---------- 6 files changed, 395 insertions(+), 122 deletions(-) diff --git a/apps/cli/src/commands/tui.ts b/apps/cli/src/commands/tui.ts index 065b5cd..b1955de 100644 --- a/apps/cli/src/commands/tui.ts +++ b/apps/cli/src/commands/tui.ts @@ -53,6 +53,9 @@ export async function runTui(parsed: ParsedArgv, store: DiskPushStore, output: O else app.invalidate() })() }) + // The frame's own regions have already had the event by the time this + // runs; what is left is noticing the pointer leave the rows. + app.on('mouse', (event) => tui.onMouse(event)) app.render(({ ui, theme, width, height }) => { tui.view(ui, theme, width, height) diff --git a/apps/cli/src/tui/app.ts b/apps/cli/src/tui/app.ts index fc09b30..f4a6611 100644 --- a/apps/cli/src/tui/app.ts +++ b/apps/cli/src/tui/app.ts @@ -11,7 +11,7 @@ * screen can be rendered and asserted on in a test with no pty. */ import { join, posix } from 'node:path' -import type { App, Container, KeyEvent, Theme } from '@profullstack/hqtui' +import type { App, Container, KeyEvent, MouseEvent, 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' @@ -21,6 +21,7 @@ import { type Entry, type Overlay, type Pane, + type Row, type Side, type SortKey, SORT_KEYS, @@ -30,8 +31,9 @@ import { listLocal, parentPath, pushChange, - selectedEntry, - visibleEntries, + resetTree, + selectedRow, + visibleRows, } from './model.js' import { type Action, type Tone, type ViewState, draw, filterChoices } from './view.js' @@ -59,6 +61,12 @@ export class Tui { private busy = false private readonly sessions = new Map() private app: App | null = null + /** + * Whether a row claimed the pointer during the mouse event being handled. + * A tree only hears the pointer while it is inside, so a move that no row + * claimed is the pointer leaving, and the hover goes with it. + */ + private hoverSeen = false constructor( left: Pane, @@ -105,20 +113,24 @@ export class Tui { const pane = this.panes[side] pane.index = index clampIndex(pane) + const row = selectedRow(pane) + if (row?.entry.isDirectory && !this.busy) void this.toggle(side, row) this.invalidate() }, - onOpenRow: (side, index) => { + onGoUp: (side) => { if (this.busy) return this.status = null this.active = side + void this.goUp() + }, + onHoverRow: (side, index) => { + this.hoverSeen = true const pane = this.panes[side] - if (index < 0) { - void this.goUp() - return - } - pane.index = index - clampIndex(pane) - void this.enter() + const other = this.panes[side === 'left' ? 'right' : 'left'] + if (pane.hover === index && other.hover === null) return + pane.hover = index + other.hover = null + this.invalidate() }, onScroll: (side, delta) => { this.active = side @@ -143,6 +155,22 @@ export class Tui { }) } + /** + * Every mouse event, after the frame's regions have had it. The only thing + * left to learn here is a move that no row claimed: the pointer has left the + * rows, so nothing should stay lit. + */ + onMouse(event: MouseEvent): void { + if (event.action === 'move' && !this.hoverSeen) { + if (this.panes.left.hover !== null || this.panes.right.hover !== null) { + this.panes.left.hover = null + this.panes.right.hover = null + this.invalidate() + } + } + this.hoverSeen = false + } + /** * A key cap clicked in the footer or the header. Each one does what the key * does, under the same rules: a dialog owns the input while it is up, and a @@ -174,7 +202,7 @@ export class Tui { this.overlay = { kind: 'help' } break case 'open': - if (!this.busy) await this.enter() + if (!this.busy) await this.toggleSelected() break case 'endpoint': if (!this.busy) this.openPicker() @@ -259,8 +287,9 @@ export class Tui { pane.error = null pane.loading = true this.invalidate() + resetTree(pane) try { - pane.entries = pane.connection ? await this.listRemote(pane) : listLocal(pane.path) + pane.entries = pane.connection ? await this.listRemote(pane, pane.path) : listLocal(pane.path) pane.index = 0 pane.offset = 0 } catch (error) { @@ -277,10 +306,10 @@ export class Tui { await this.load('right') } - private async listRemote(pane: Pane): Promise { + private async listRemote(pane: Pane, path: string): Promise { const browser = await SftpBrowser.open(await this.session(pane.connection!)) try { - const entries = await browser.list(pane.path) + const entries = await browser.list(path) return entries.map((entry) => ({ name: entry.name, isDirectory: entry.type === 'directory', @@ -346,13 +375,16 @@ export class Tui { pane.index = 0 break case key.name === 'end': - pane.index = Math.max(0, visibleEntries(pane).length - 1) + pane.index = Math.max(0, visibleRows(pane).length - 1) break case key.name === 'left' || key.name === 'h': - await this.goUp() + await this.foldOrGoUp() break - case key.name === 'right' || key.name === 'enter' || key.name === 'l': - await this.enter() + case key.name === 'enter': + await this.toggleSelected() + break + case key.name === 'right' || key.name === 'l': + await this.unfoldOrStepIn() break case key.name === 'c': this.openPicker() @@ -449,10 +481,82 @@ export class Tui { private move(delta: number): void { const pane = this.current - const last = Math.max(0, visibleEntries(pane).length - 1) + const last = Math.max(0, visibleRows(pane).length - 1) pane.index = Math.min(last, Math.max(0, pane.index + delta)) } + // ------------------------------------------------------------------ tree + + /** Folds or unfolds a directory row, listing it first if it never has been. */ + private async toggle(side: Side, row: Row): Promise { + const pane = this.panes[side] + if (!row.entry.isDirectory) return + if (row.unfolded) { + pane.unfolded.delete(row.rel) + clampIndex(pane) + this.invalidate() + return + } + if (!pane.children.has(row.rel)) { + if (pane.listing.has(row.rel)) return + pane.listing.add(row.rel) + this.invalidate() + try { + pane.children.set(row.rel, await this.listBelow(pane, row.rel)) + } catch (error) { + this.say(`${row.rel}: ${error instanceof Error ? error.message : String(error)}`, 'error') + return + } finally { + pane.listing.delete(row.rel) + this.invalidate() + } + } + pane.unfolded.add(row.rel) + this.invalidate() + } + + private listBelow(pane: Pane, rel: string): Promise { + if (pane.connection) return this.listRemote(pane, posix.join(pane.path, rel)) + return Promise.resolve(listLocal(join(pane.path, rel))) + } + + private async toggleSelected(): Promise { + const row = selectedRow(this.current) + if (row) await this.toggle(this.active, row) + } + + /** → on a folded directory unfolds it; on an unfolded one it steps onto the first child. */ + private async unfoldOrStepIn(): Promise { + const pane = this.current + const row = selectedRow(pane) + if (!row?.entry.isDirectory) return + if (!row.unfolded) { + await this.toggle(this.active, row) + return + } + if (row.children.length > 0) pane.index += 1 + } + + /** ← folds the directory under the cursor, else climbs to its parent row, else leaves the root. */ + private async foldOrGoUp(): Promise { + const pane = this.current + const row = selectedRow(pane) + if (row?.entry.isDirectory && row.unfolded) { + await this.toggle(this.active, row) + return + } + if (row && row.depth > 0) { + const rows = visibleRows(pane) + for (let i = pane.index - 1; i >= 0; i -= 1) { + if (rows[i]!.depth < row.depth) { + pane.index = i + return + } + } + } + await this.goUp() + } + private cycleSort(): void { const pane = this.current const next = SORT_KEYS[(SORT_KEYS.indexOf(pane.sort) + 1) % SORT_KEYS.length] as SortKey @@ -498,15 +602,6 @@ export class Tui { } } - private async enter(): Promise { - const pane = this.current - 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) - } - private async goUp(): Promise { const pane = this.current const parent = parentPath(pane) diff --git a/apps/cli/src/tui/interaction.test.ts b/apps/cli/src/tui/interaction.test.ts index 9286ef4..77fb0ce 100644 --- a/apps/cli/src/tui/interaction.test.ts +++ b/apps/cli/src/tui/interaction.test.ts @@ -4,8 +4,11 @@ * 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 { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' import { describe, expect, it, vi } from 'vitest' -import type { App } from '@profullstack/hqtui' +import type { App, MouseEvent } from '@profullstack/hqtui' import { renderToScreen } from '@profullstack/hqtui/testing' import { key } from './keys.fixture.js' import type { Entry, Pane, Side } from './model.js' @@ -16,7 +19,7 @@ vi.mock('@diskpush/ssh-core', () => ({ })) vi.mock('@diskpush/database', () => ({ knownHostsPath: () => '/tmp/known_hosts.test' })) -const { Tui, blankPane } = await import('./app.js') +const { Tui, blankPane, listLocal } = await import('./app.js') type Tui = InstanceType const entry = (name: string, over: Partial = {}): Entry => ({ @@ -54,6 +57,32 @@ const frame = (app: Tui) => collapseBorders: true, }) const settle = () => new Promise((resolve) => setTimeout(resolve, 0)) +const move = (x: number, y: number): MouseEvent => ({ + type: 'mouse', + action: 'move', + button: 'none', + x, + y, + scroll: 0, + clicks: 1, + ctrl: false, + alt: false, + shift: false, +}) + +/** A real directory on disk, because unfolding lists it for real. */ +function realTree() { + const root = mkdtempSync(join(tmpdir(), 'diskpush-tui-')) + mkdirSync(join(root, 'src', 'lib'), { recursive: true }) + writeFileSync(join(root, 'src', 'index.ts'), 'export {}\n') + writeFileSync(join(root, 'src', 'lib', 'deep.ts'), 'export {}\n') + writeFileSync(join(root, 'a.ts'), '') + const left = blankPane('Local', root) + left.entries = listLocal(root) + const right = blankPane('Local', '/tmp/b') + const app = new Tui(left, right, []) + return { app, root } +} describe('navigation', () => { it('moves the cursor and stops at both ends', async () => { @@ -203,32 +232,80 @@ describe('the mouse', () => { expect(state(app).active).toBe('right') }) - it('opens a directory on a double-click, and .. takes it back up', async () => { - const app = tui([entry('src', { isDirectory: true }), entry('a.ts')]) + it('unfolds a directory on one click, shows its listing beneath it, and folds it on the next', async () => { + const { app } = realTree() let screen = frame(app) const src = screen.find('src')! - screen.click(src.x, src.y, { clicks: 2 }) + screen.click(src.x, src.y) + await settle() + expect(pane(app, 'left').unfolded.has('src')).toBe(true) + expect(pane(app, 'left').index).toBe(0) + + screen = frame(app) + expect(screen.contains('index.ts')).toBe(true) + expect(screen.contains('▾ src')).toBe(true) + // Nested directories unfold the same way, one level at a time. + const lib = screen.find('lib')! + screen.click(lib.x, lib.y) await settle() - expect(pane(app, 'left').path).toBe('/tmp/a/src') + screen = frame(app) + expect(screen.contains('deep.ts')).toBe(true) + expect(pane(app, 'left').unfolded.has('src/lib')).toBe(true) - // The listing failed (there is no such directory) and `..` is still there. + // Folding the parent hides everything under it, and remembers it. + screen.click(src.x, src.y) + await settle() screen = frame(app) + expect(screen.contains('index.ts')).toBe(false) + expect(pane(app, 'left').children.has('src')).toBe(true) + }) + + it('one click on .. goes up', async () => { + const { app, root } = realTree() + const screen = frame(app) const up = screen.find('..')! - screen.click(up.x, up.y, { clicks: 2 }) + screen.click(up.x, up.y) await settle() - expect(pane(app, 'left').path).toBe('/tmp/a') + expect(pane(app, 'left').path).toBe(dirname(root)) }) - it('a file does not open on a double-click', async () => { + it('a click on a file only selects it', async () => { const app = tui() const screen = frame(app) const beta = screen.find('beta.ts')! - screen.click(beta.x, beta.y, { clicks: 2 }) + screen.click(beta.x, beta.y) await settle() expect(pane(app, 'left').path).toBe('/tmp/a') expect(pane(app, 'left').index).toBe(1) }) + it('lights the row under the pointer, and puts it out when the pointer leaves the rows', () => { + const app = tui() + const screen = frame(app) + const beta = screen.find('beta.ts')! + expect(screen.hover(beta.x, beta.y)).toBe(true) + app.onMouse(move(beta.x, beta.y)) + expect(pane(app, 'left').hover).toBe(1) + // A move the frame's rows did not claim: the header, say. + expect(screen.hover(2, 0)).toBe(false) + app.onMouse(move(2, 0)) + expect(pane(app, 'left').hover).toBeNull() + }) + + it('walks the tree from the keyboard: → unfolds and steps in, ← folds and climbs, then leaves', async () => { + const { app, root } = realTree() + await press(app, 'right') + expect(pane(app, 'left').unfolded.has('src')).toBe(true) + await press(app, 'right') + expect(pane(app, 'left').index).toBe(1) + await press(app, 'left') + expect(pane(app, 'left').index).toBe(0) + await press(app, 'left') + expect(pane(app, 'left').unfolded.has('src')).toBe(false) + await press(app, 'left') + expect(pane(app, 'left').path).toBe(dirname(root)) + }) + it('drives the key bar: endpoint opens the picker, a server points the pane, outside closes it', async () => { const app = tui() let screen = frame(app) diff --git a/apps/cli/src/tui/model.ts b/apps/cli/src/tui/model.ts index 1d8e5ad..f0b8a03 100644 --- a/apps/cli/src/tui/model.ts +++ b/apps/cli/src/tui/model.ts @@ -40,6 +40,18 @@ export type Pane = { descending: boolean showHidden: boolean loading: boolean + /** + * The listing of every directory that has been unfolded below the root, by + * path relative to `path` (`src/lib`). Kept across a fold so unfolding again + * is instant; dropped when the root changes. + */ + children: Map + /** Relative paths of the directories currently unfolded. */ + unfolded: Set + /** Relative paths whose listing is on its way. */ + listing: Set + /** The row under the mouse, as an index into `visibleRows`; -1 is `..`. */ + hover: number | null } export function blankPane(label: string, path: string, connection: Connection | null = null): Pane { @@ -56,9 +68,21 @@ export function blankPane(label: string, path: string, connection: Connection | descending: false, showHidden: false, loading: false, + children: new Map(), + unfolded: new Set(), + listing: new Set(), + hover: null, } } +/** Forgets everything below the root: the root is about to change. */ +export function resetTree(pane: Pane): void { + pane.children.clear() + pane.unfolded.clear() + pane.listing.clear() + pane.hover = null +} + /** Somewhere a pane can point at: this machine, or a server. */ export type EndpointChoice = { label: string @@ -125,35 +149,82 @@ export function compareEntries(a: Entry, b: Entry, sort: SortKey, descending: bo 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 +/** A listing with the pane's hidden-file rule, filter and sort applied. */ +export function orderEntries(entries: readonly Entry[], pane: Pane): Entry[] { + return entries .filter((entry) => (pane.showHidden || !entry.name.startsWith('.')) && matchesFilter(entry.name, pane.filter)) .sort((a, b) => compareEntries(a, b, pane.sort, pane.descending)) } +/** The root listing as the pane shows it. */ +export function visibleEntries(pane: Pane): Entry[] { + return orderEntries(pane.entries, pane) +} + +/** One line of a pane: an entry at some depth of the unfolded tree. */ +export type Row = { + entry: Entry + /** Path relative to the pane root, e.g. `src/lib`. */ + rel: string + depth: number + unfolded: boolean + /** The listing that would fill this directory is still on its way. */ + listing: boolean + children: Row[] +} + +/** The pane as a tree: the root listing, with each unfolded directory's listing nested under it. */ +export function rowTree(pane: Pane): Row[] { + const build = (entries: readonly Entry[], prefix: string, depth: number): Row[] => + orderEntries(entries, pane).map((entry) => { + const rel = prefix ? `${prefix}/${entry.name}` : entry.name + const unfolded = entry.isDirectory && pane.unfolded.has(rel) + return { + entry, + rel, + depth, + unfolded, + listing: pane.listing.has(rel), + children: unfolded ? build(pane.children.get(rel) ?? [], rel, depth + 1) : [], + } + }) + return build(pane.entries, '', 0) +} + +/** + * The tree flattened in the order it is drawn, which is what the cursor + * indexes and what a click lands on. + */ +export function visibleRows(pane: Pane): Row[] { + const out: Row[] = [] + const walk = (rows: Row[]): void => { + for (const row of rows) { + out.push(row) + walk(row.children) + } + } + walk(rowTree(pane)) + return out +} + +export function selectedRow(pane: Pane): Row | null { + return visibleRows(pane)[pane.index] ?? null +} + export function selectedEntry(pane: Pane): Entry | null { - return visibleEntries(pane)[pane.index] ?? null + return selectedRow(pane)?.entry ?? null } + /** The directory above this pane's, or null when there is nowhere up to go. */ export function parentPath(pane: Pane): string | null { const parent = pane.connection ? posix.dirname(pane.path) : join(pane.path, '..') return parent === pane.path ? null : parent } -/** - * The `..` row at the top of a listing. The keyboard leaves a directory with - * ←; a mouse needs something to click on, and every file manager since the - * first one has spelled it this way. It is one shared object so the view can - * tell it from a real entry by identity, and it never enters a pane's - * `entries`, so sorting, filtering and the cursor index never see it. - */ -export const PARENT_ENTRY: Entry = Object.freeze({ name: '..', isDirectory: true, size: 0, modifiedAt: null }) - -/** Keeps the cursor on a row that exists, which filtering and reloading can break. */ +/** Keeps the cursor on a row that exists, which filtering, folding and reloading can break. */ export function clampIndex(pane: Pane): void { - const last = Math.max(0, visibleEntries(pane).length - 1) + const last = Math.max(0, visibleRows(pane).length - 1) pane.index = Math.min(last, Math.max(0, pane.index)) } diff --git a/apps/cli/src/tui/view.test.ts b/apps/cli/src/tui/view.test.ts index 1bbe119..4b5fc24 100644 --- a/apps/cli/src/tui/view.test.ts +++ b/apps/cli/src/tui/view.test.ts @@ -154,36 +154,57 @@ describe('the mouse', () => { expect(frame(state()).regions.length).toBeGreaterThanOrEqual(2) }) - it('selects a row on a click and opens it on a double-click', () => { + it('selects a row on one click, and reports the row under the pointer', () => { const log: unknown[] = [] const rendered = frame(state(), { onSelectRow: (side, index) => log.push(['select', side, index]), - onOpenRow: (side, index) => log.push(['open', side, index]), + onHoverRow: (side, index) => log.push(['hover', side, index]), }) const readme = rendered.find('README.md')! expect(rendered.click(readme.x, readme.y)).toBe(true) const src = rendered.find('src')! - rendered.click(src.x, src.y, { clicks: 2 }) - // The second press of a double-click selects and then opens. + rendered.click(src.x, src.y) + rendered.hover(readme.x, readme.y) expect(log).toEqual([ ['select', 'left', 1], ['select', 'left', 0], - ['open', 'left', 0], + ['hover', 'left', 1], ]) }) - it('puts .. above a listing that has a parent; a double-click on it goes up', () => { + it('puts .. above a listing that has a parent, and one click on it goes up', () => { const log: unknown[] = [] const rendered = frame(state(), { onSelectRow: (side, index) => log.push(['select', side, index]), - onOpenRow: (side, index) => log.push(['open', side, index]), + onGoUp: (side) => log.push(['up', side]), }) const up = rendered.find('..')! - // A click does not select it: the cursor has nowhere to rest on `..`. expect(rendered.click(up.x, up.y)).toBe(true) - expect(log).toEqual([]) - rendered.click(up.x, up.y, { clicks: 2 }) - expect(log).toEqual([['open', 'left', -1]]) + expect(log).toEqual([['up', 'left']]) + }) + + it('draws an unfolded directory as a tree under its parent, with the fold state on the marker', () => { + const s = state() + s.panes.left.children.set('src', [entry('index.ts', { size: 120 }), entry('lib', { isDirectory: true })]) + s.panes.left.unfolded.add('src') + const text = screen(s) + expect(text).toContain('▾ src') + expect(text).toContain('index.ts') + expect(text).toContain('▸ lib') + // A folded sibling stays folded, and a listing on its way says so. + s.panes.left.unfolded.delete('src') + s.panes.left.listing.add('src') + expect(screen(s)).toContain('… src') + expect(screen(s)).not.toContain('index.ts') + }) + + it('lights the row under the pointer', () => { + const lit = state() + lit.panes.left.hover = 1 + const plain = frame(state()) + const hovered = frame(lit) + const readme = plain.find('README.md')! + expect(hovered.cell(readme.x, readme.y).bg).not.toBe(plain.cell(readme.x, readme.y).bg) }) it('has no .. at the root, and keeps it on a listing that failed', () => { @@ -406,7 +427,7 @@ describe('the help overlay', () => { it('documents every binding, the mouse, and why Mirror is not one', () => { const text = screen(state({ overlay: { kind: 'help' } })) expect(text).toContain('switch pane') - expect(text).toContain('double-click') + expect(text).toContain('fold or unfold') expect(text).toContain('Mirror') }) }) diff --git a/apps/cli/src/tui/view.ts b/apps/cli/src/tui/view.ts index 388e92f..764c280 100644 --- a/apps/cli/src/tui/view.ts +++ b/apps/cli/src/tui/view.ts @@ -10,20 +10,29 @@ import { stringWidth, truncate } from '@profullstack/hqtui' import type { Change } from '@diskpush/schemas' import { type EndpointChoice, - type Entry, type Overlay, type Pane, + type Row, type Side, type Transfer, - PARENT_ENTRY, estimateRemaining, formatDuration, formatSize, formatWhen, parentPath, + rowTree, visibleEntries, } from './model.js' +/** What hqtui's tree draws: the model's `Row`, spelled the widget's way. */ +type TreeNode = { + label: string + color?: Color + values?: { text: string; width: number; color?: Color; align?: 'left' | 'right' | 'center' }[] + children?: TreeNode[] + expanded?: boolean +} + export type Tone = 'info' | 'ok' | 'warn' | 'error' export type ViewState = { @@ -60,16 +69,18 @@ export type Action = /** * Mouse wiring. Optional so a test can render a frame without any. * - * Rows are reported as an index into the pane's *visible* entries, never as a - * screen row: the table scrolls, and only the frame that drew it knows where - * its window started, so the frame does the arithmetic. `-1` is the `..` row. + * Rows are reported as an index into the pane's *visible rows*, never as a + * screen row: the tree scrolls, and only the frame that drew it knows where + * its window started, so the frame does the arithmetic. */ export type ViewHandlers = { onPaneFocus?: (side: Side) => void - /** A click on a row: select it. */ + /** A click on a row: select it, and fold or unfold it if it is a directory. */ onSelectRow?: (side: Side, index: number) => void - /** A double-click on a row: open the directory, or go up for `..`. */ - onOpenRow?: (side: Side, index: number) => void + /** A click on the `..` row. */ + onGoUp?: (side: Side) => void + /** The pointer is over a row (-1 is `..`), or left the rows (null). */ + onHoverRow?: (side: Side, index: number | null) => void onScroll?: (side: Side, delta: number) => void /** A click on a key cap in the footer or the header. */ onAction?: (action: Action) => void @@ -195,14 +206,30 @@ function drawPane( const entries = visibleEntries(pane) const files = entries.filter((entry) => !entry.isDirectory) const bytes = files.reduce((total, entry) => total + entry.size, 0) - // `..` sits above the listing whenever there is somewhere to go. It is not - // one of the entries, so the cursor index counts from the first real row. - // A listing that failed keeps it too: the way out of a directory that - // cannot be read must not be keyboard-only. + // `..` sits above the tree whenever there is somewhere to go. It is not one + // of the rows, so the cursor index counts from the first real row. A + // listing that failed keeps it too: the way out of a directory that cannot + // be read must not be keyboard-only. const parent = parentPath(pane) !== null - const listed = pane.error ? [] : entries - const rows: Entry[] = parent ? [PARENT_ENTRY, ...listed] : listed + const tree = pane.error ? [] : rowTree(pane) const shift = parent ? 1 : 0 + const column = (text: string, width: number) => ({ text, width, color: theme.muted }) + const toNode = (row: Row): TreeNode => ({ + // The marker is the fold state, and it changes under the click: ▸ folded, + // ▾ unfolded, … while the listing is on its way. + label: `${row.entry.isDirectory ? (row.listing ? '…' : row.unfolded ? '▾' : '▸') : ' '} ${row.entry.name}`, + color: row.entry.isDirectory ? theme.primary : theme.foreground, + expanded: row.unfolded, + ...(row.unfolded ? { children: row.children.map(toNode) } : {}), + values: [ + column(row.entry.isDirectory ? '—' : formatSize(row.entry.size), 7), + column(formatWhen(row.entry.modifiedAt, state.now), 8), + ], + }) + const nodes: TreeNode[] = [ + ...(parent ? [{ label: '↑ ..', color: theme.muted, values: [column('', 7), column('', 8)] }] : []), + ...tree.map(toNode), + ] const kind = pane.connection ? '◈' : '▪' const title = ` ${kind} ${pane.label} ` @@ -240,65 +267,44 @@ function drawPane( panel.text('Connecting…', { align: 'center', fg: theme.muted }) return } - // Where the table's window started, as of this frame. A click reports + // Where the tree's window started, as of this frame. A click reports // the row it landed on counted from the top of that window. let first = 0 const indexOf = (visibleRow: number) => first + visibleRow - shift - if (rows.length > 0) { - panel.table({ - rows, + if (nodes.length > 0) { + panel.tree({ + nodes, selected: pane.index + shift, - offset: pane.offset, + hovered: pane.hover === null ? undefined : pane.hover + shift, followSelection: true, scrollbar: true, - header: false, + // Indent alone shows the nesting; the ▸ ▾ markers carry the fold + // state, and connector lines would make `..` look like a sibling. + guides: false, // An empty or unreadable directory still shows its `..`, on one // row, with the explanation underneath rather than a screen of nothing. - ...(listed.length === 0 ? { size: rows.length } : {}), + ...(tree.length === 0 ? { size: nodes.length } : {}), onFocus: () => handlers.onPaneFocus?.(side), + // One click does the thing: a directory folds or unfolds, a file is + // selected, `..` goes up. onSelectRow: (visibleRow) => { const index = indexOf(visibleRow) - // `..` is not a place the cursor can rest; a click on it only - // brings the pane to the front, and a double-click leaves. - if (index >= 0) handlers.onSelectRow?.(side, index) + if (index < 0) handlers.onGoUp?.(side) + else handlers.onSelectRow?.(side, index) }, - onActivateRow: (visibleRow) => handlers.onOpenRow?.(side, indexOf(visibleRow)), + onHoverRow: (visibleRow) => handlers.onHoverRow?.(side, visibleRow === null ? null : indexOf(visibleRow)), onScroll: (delta) => handlers.onScroll?.(side, delta), - onRow: (_entry, index, y) => { + onRow: (_node, index, y) => { first = index - y }, - columns: [ - { - // Fills: the size and date belong against the right edge, not - // floating in the middle of a wide pane behind a column of air. - key: 'name', - width: '1fr', - render: (entry) => `${entry.isDirectory ? '▸' : ' '} ${entry.name}`, - color: (entry) => (entry === PARENT_ENTRY ? theme.muted : entry.isDirectory ? theme.primary : theme.foreground), - }, - { - key: 'size', - width: 7, - align: 'right', - render: (entry) => (entry === PARENT_ENTRY ? '' : entry.isDirectory ? '—' : formatSize(entry.size)), - color: theme.muted, - }, - { - key: 'modifiedAt', - width: 8, - align: 'right', - render: (entry) => formatWhen(entry.modifiedAt, state.now), - color: theme.muted, - }, - ], }) } if (pane.error) { panel.spacer(1) panel.text(pane.error, { fg: theme.danger, wrap: true, align: 'center' }) - } else if (entries.length === 0) { + } else if (tree.length === 0) { panel.spacer(1) panel.text(pane.filter ? `Nothing matches “${pane.filter}”` : 'Empty', { align: 'center', fg: theme.muted }) } @@ -533,11 +539,11 @@ function drawHelp(ui: Container, theme: Theme, handlers: ViewHandlers): void { [ { label: 'tab', value: 'switch pane' }, { label: '↑ ↓ / j k', value: 'move' }, - { label: '⏎ / → / l', value: 'open directory' }, - { label: '← / h', value: 'go up' }, + { label: '⏎ / → / l', value: 'unfold a directory' }, + { label: '← / h', value: 'fold it, or go up' }, { label: 'pgup pgdn home end', value: 'jump' }, - { label: 'click', value: 'select a row, or a key in the bar' }, - { label: 'double-click', value: 'open a directory, or .. to go up' }, + { label: 'click', value: 'select; fold or unfold a directory' }, + { label: 'click ..', value: 'go up' }, { label: 'c', value: 'point this pane somewhere else' }, { label: '/', value: 'filter this listing' }, { label: 'o / O', value: 'cycle sort / reverse it' }, From 989d86d53040b19e52e1d05c0f4408d35a7f5182 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 12 Sep 2026 20:56:19 +0000 Subject: [PATCH 2/3] chore(cli): hqtui ^0.5.1, the release with the tree's onRow and hover Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DWtLsmAescX4Nb8QiBJd37 --- apps/cli/package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/cli/package.json b/apps/cli/package.json index dbcd7d9..9657333 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -21,7 +21,7 @@ "@diskpush/rsync-core": "workspace:*", "@diskpush/schemas": "workspace:*", "@diskpush/ssh-core": "workspace:*", - "@profullstack/hqtui": "^0.5.0", + "@profullstack/hqtui": "^0.5.1", "zod": "^3.24.1" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 67b3f52..ba3ac62 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,8 +42,8 @@ importers: specifier: workspace:* version: link:../../packages/ssh-core '@profullstack/hqtui': - specifier: ^0.5.0 - version: 0.5.0 + specifier: ^0.5.1 + version: 0.5.1 zod: specifier: ^3.24.1 version: 3.25.76 @@ -1116,8 +1116,8 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} - '@profullstack/hqtui@0.5.0': - resolution: {integrity: sha512-jDwILBmdQx8pQA0ao/tzzty8u5nFNS4XhvU9xXhZvdIaMeiF3HBJIVtP6cS6kigS4lXmHR7sztbb9QJpaWwhig==} + '@profullstack/hqtui@0.5.1': + resolution: {integrity: sha512-TRj2CKCUhpXhhmsQ1DMIZb9OcU+XLm90BqhPPo0zk2OwyXNcQhlqBeNdxLqwTY/BlIWZpcTyQto458AQ7iCTFg==} engines: {bun: '>=1.1', node: '>=22.6'} hasBin: true @@ -4996,7 +4996,7 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true - '@profullstack/hqtui@0.5.0': {} + '@profullstack/hqtui@0.5.1': {} '@profullstack/x402-gateway@0.1.0': {} From b71352e35747eaf35b3e5f8429e1508e4547c023 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 12 Sep 2026 20:58:36 +0000 Subject: [PATCH 3/3] chore: let CI install the hour-old hqtui 0.5.1 pnpm's minimumReleaseAge policy holds new packages for a day, and the local install had already written the exclusion for our own hqtui into the workspace file; this commits it so the frozen install in CI agrees. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DWtLsmAescX4Nb8QiBJd37 --- pnpm-workspace.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 15f6681..6eb89cf 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -15,4 +15,4 @@ allowBuilds: minimumReleaseAgeExclude: - '@profullstack/x402-gateway@0.1.0' - - '@profullstack/hqtui@0.2.0 || 0.5.0' + - '@profullstack/hqtui@0.2.0 || 0.5.0 || 0.5.1'