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/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' }, 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': {} 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'