Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"@diskpush/rsync-core": "workspace:*",
"@diskpush/schemas": "workspace:*",
"@diskpush/ssh-core": "workspace:*",
"@profullstack/hqtui": "^0.2.0",
"@profullstack/hqtui": "^0.5.0",
"zod": "^3.24.1"
}
}
101 changes: 88 additions & 13 deletions apps/cli/src/tui/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,12 @@ import {
blankPane,
clampIndex,
listLocal,
parentPath,
pushChange,
selectedEntry,
visibleEntries,
} from './model.js'
import { type Tone, type ViewState, draw, filterChoices } from './view.js'
import { type Action, type Tone, type ViewState, draw, filterChoices } from './view.js'

export {
blankPane,
Expand All @@ -58,12 +59,6 @@ export class Tui {
private busy = false
private readonly sessions = new Map<string, SshSession>()
private app: App | null = null
/**
* The first row index each pane's table actually drew, recorded during
* render. A click reports the row it landed on, counted from the top of the
* visible window — and only the table knows where that window starts.
*/
private readonly firstVisible: Record<Side, number> = { left: 0, right: 0 }

constructor(
left: Pane,
Expand Down Expand Up @@ -104,24 +99,104 @@ export class Tui {
this.active = side
this.invalidate()
},
onSelectRow: (side, visibleRow) => {
onSelectRow: (side, index) => {
this.status = null
this.active = side
const pane = this.panes[side]
pane.index = this.firstVisible[side] + visibleRow
pane.index = index
clampIndex(pane)
this.invalidate()
},
onOpenRow: (side, index) => {
if (this.busy) return
this.status = null
this.active = side
const pane = this.panes[side]
if (index < 0) {
void this.goUp()
return
}
pane.index = index
clampIndex(pane)
void this.enter()
},
onScroll: (side, delta) => {
this.active = side
this.move(delta * WHEEL_ROWS)
this.invalidate()
},
onRowDrawn: (side, index, y) => {
this.firstVisible[side] = index - y
onAction: (action) => void this.run(action),
onPickChoice: (choice) => {
if (this.overlay?.kind !== 'picker') return
this.overlay = null
void this.choose(choice)
},
onDismissOverlay: () => {
// The host-key question is not on this list on purpose: it is only
// ever answered, never waved away.
if (this.overlay?.kind === 'picker' || this.overlay?.kind === 'help') this.overlay = null
this.invalidate()
},
onHostKeyDecide: (trust) => {
if (this.overlay?.kind === 'hostKey') this.overlay.decide(trust)
},
})
}

/**
* A key cap clicked in the footer or the header. Each one does what the key
* does, under the same rules: a dialog owns the input while it is up, and a
* transfer in flight takes nothing but cancel and quit.
*/
private async run(action: Action): Promise<void> {
if (action === 'quit') {
this.app?.quit()
return
}
if (action === 'closeOverlay') {
if (this.overlay?.kind === 'picker' || this.overlay?.kind === 'help') this.overlay = null
this.invalidate()
return
}
if (action === 'cancelTransfer' || action === 'dismissTransfer') {
this.dismissTransfer()
this.invalidate()
return
}
if (this.overlay || this.filtering) return

this.status = null
switch (action) {
case 'pane':
this.active = this.active === 'left' ? 'right' : 'left'
break
case 'help':
this.overlay = { kind: 'help' }
break
case 'open':
if (!this.busy) await this.enter()
break
case 'endpoint':
if (!this.busy) this.openPicker()
break
case 'preview':
if (!this.busy) await this.transferTo(true)
break
case 'sync':
if (!this.busy) await this.transferTo(false)
break
case 'filter':
if (!this.busy) this.filtering = this.active
break
case 'sort':
if (!this.busy) this.cycleSort()
break
default:
break
}
this.invalidate()
}

// ----------------------------------------------------------------- state

private get current(): Pane {
Expand Down Expand Up @@ -434,8 +509,8 @@ export class Tui {

private async goUp(): Promise<void> {
const pane = this.current
const parent = pane.connection ? posix.dirname(pane.path) : join(pane.path, '..')
if (parent === pane.path) return
const parent = parentPath(pane)
if (parent === null) return
pane.path = parent
pane.filter = ''
await this.load(this.active)
Expand Down
120 changes: 120 additions & 0 deletions apps/cli/src/tui/interaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
* them, so these are the same code paths a keystroke takes in a terminal.
*/
import { describe, expect, it, vi } from 'vitest'
import type { App } from '@profullstack/hqtui'
import { renderToScreen } from '@profullstack/hqtui/testing'
import { key } from './keys.fixture.js'
import type { Entry, Pane, Side } from './model.js'

Expand Down Expand Up @@ -44,6 +46,14 @@ const pane = (app: Tui, side: Side): Pane => state(app).panes[side]
const press = async (app: Tui, ...names: string[]) => {
for (const name of names) await app.onKey(key(name))
}
/** Draws the real frame, so a click lands where the user would see it. */
const frame = (app: Tui) =>
renderToScreen(({ ui, theme, width, height }) => app.view(ui, theme, width, height), {
width: 100,
height: 30,
collapseBorders: true,
})
const settle = () => new Promise((resolve) => setTimeout(resolve, 0))

describe('navigation', () => {
it('moves the cursor and stops at both ends', async () => {
Expand Down Expand Up @@ -180,6 +190,116 @@ describe('the help overlay', () => {
})
})

describe('the mouse', () => {
it('selects a row with a click, and focuses whichever pane the click is in', () => {
const app = tui()
const screen = frame(app)
const gamma = screen.find('gamma.ts')!
expect(screen.click(gamma.x, gamma.y)).toBe(true)
expect(pane(app, 'left').index).toBe(2)
expect(state(app).active).toBe('left')
// Anywhere in the right pane, including its empty space.
expect(screen.click(75, 10)).toBe(true)
expect(state(app).active).toBe('right')
})

it('opens a directory on a double-click, and .. takes it back up', async () => {
const app = tui([entry('src', { isDirectory: true }), entry('a.ts')])
let screen = frame(app)
const src = screen.find('src')!
screen.click(src.x, src.y, { clicks: 2 })
await settle()
expect(pane(app, 'left').path).toBe('/tmp/a/src')

// The listing failed (there is no such directory) and `..` is still there.
screen = frame(app)
const up = screen.find('..')!
screen.click(up.x, up.y, { clicks: 2 })
await settle()
expect(pane(app, 'left').path).toBe('/tmp/a')
})

it('a file does not open on a double-click', async () => {
const app = tui()
const screen = frame(app)
const beta = screen.find('beta.ts')!
screen.click(beta.x, beta.y, { clicks: 2 })
await settle()
expect(pane(app, 'left').path).toBe('/tmp/a')
expect(pane(app, 'left').index).toBe(1)
})

it('drives the key bar: endpoint opens the picker, a server points the pane, outside closes it', async () => {
const app = tui()
let screen = frame(app)
const endpoint = screen.find('c endpoint')!
screen.click(endpoint.x, endpoint.y)
await settle()
expect(state(app).overlay?.kind).toBe('picker')

screen = frame(app)
screen.click(0, 0)
expect(state(app).overlay).toBeNull()

screen = frame(app)
screen.click(endpoint.x, endpoint.y)
await settle()
screen = frame(app)
const blue = screen.find('deploy@10.0.0.7')!
screen.click(blue.x, blue.y)
await settle()
expect(state(app).overlay).toBeNull()
expect(pane(app, 'left').label).toBe('blue')
})

it('opens the help from the header and closes it from its button', async () => {
const app = tui()
let screen = frame(app)
const help = screen.find('? help')!
screen.click(help.x, help.y)
await settle()
expect(state(app).overlay?.kind).toBe('help')
screen = frame(app)
const close = screen.find('esc close')!
screen.click(close.x, close.y)
expect(state(app).overlay).toBeNull()
})

it('quits from the key bar through the app it is attached to', async () => {
const app = tui()
const quit = vi.fn()
app.attach({ quit, invalidate: () => {} } as unknown as App)
const screen = frame(app)
const q = screen.find('q quit')!
screen.click(q.x, q.y)
await settle()
expect(quit).toHaveBeenCalledTimes(1)
})

it('gives a dialog the whole screen: the key bar under it is not clickable', async () => {
const app = tui()
const quit = vi.fn()
app.attach({ quit, invalidate: () => {} } as unknown as App)
await press(app, '?')
const screen = frame(app)
// Bottom row, where the key bar is: the click is taken by the backdrop,
// which closes the help, and nothing underneath acts on it.
expect(screen.click(2, 29)).toBe(true)
expect(state(app).overlay).toBeNull()
expect(quit).not.toHaveBeenCalled()
})

it('clears the last message, like a key does', async () => {
const app = tui()
await press(app, '.')
expect(state(app).status).not.toBeNull()
const screen = frame(app)
const alpha = screen.find('alpha.ts')!
screen.click(alpha.x, alpha.y)
expect(state(app).status).toBeNull()
})
})

describe('the last message', () => {
it('is cleared by the next keystroke, so it never answers the wrong question', async () => {
const app = tui()
Expand Down
17 changes: 16 additions & 1 deletion apps/cli/src/tui/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
*/
import { readdirSync, statSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { join, posix } from 'node:path'
import type { Change, ChangeSummary, Connection, RsyncProgress } from '@diskpush/schemas'

export type Entry = {
Expand Down Expand Up @@ -136,6 +136,21 @@ export function selectedEntry(pane: Pane): Entry | null {
return visibleEntries(pane)[pane.index] ?? null
}

/** The directory above this pane's, or null when there is nowhere up to go. */
export function parentPath(pane: Pane): string | null {
const parent = pane.connection ? posix.dirname(pane.path) : join(pane.path, '..')
return parent === pane.path ? null : parent
}

/**
* The `..` row at the top of a listing. The keyboard leaves a directory with
* ←; a mouse needs something to click on, and every file manager since the
* first one has spelled it this way. It is one shared object so the view can
* tell it from a real entry by identity, and it never enters a pane's
* `entries`, so sorting, filtering and the cursor index never see it.
*/
export const PARENT_ENTRY: Entry = Object.freeze({ name: '..', isDirectory: true, size: 0, modifiedAt: null })

/** Keeps the cursor on a row that exists, which filtering and reloading can break. */
export function clampIndex(pane: Pane): void {
const last = Math.max(0, visibleEntries(pane).length - 1)
Expand Down
Loading
Loading