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
35 changes: 27 additions & 8 deletions apps/cli/src/commands/tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { failure, type Output } from '../output.js'
import { type ParsedArgv } from '../parse-argv.js'
import { resolveEndpoint, sshConfigHosts } from '../resolve.js'
import { blankPane, buildEndpointChoices, defaultLocalPath, Tui } from '../tui/app.js'
import { runInherited } from '../tui/launch.js'

/**
* `diskpush tui` — the two-pane browser, in a terminal.
Expand Down Expand Up @@ -41,6 +42,31 @@ 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)

try {
void tui.loadBoth()
// The browser runs until it quits, or until it wants the terminal handed
// to a program — `e` outside tmux. Then the program runs with the screen
// to itself and the browser starts again, every pane where it was.
for (;;) {
await runApp(tui)
const handoff = tui.takeHandoff()
if (!handoff) break
try {
await runInherited(handoff)
} catch (error) {
console.error(`could not start ${handoff.argv[0]}: ${error instanceof Error ? error.message : String(error)}`)
}
await tui.afterHandoff()
}
} finally {
tui.close()
}

return EXIT.ok
}

/** One run of the screen: from the terminal taken to the terminal restored. */
async function runApp(tui: Tui): Promise<void> {
// `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.
Expand All @@ -61,12 +87,5 @@ export async function runTui(parsed: ParsedArgv, store: DiskPushStore, output: O
tui.view(ui, theme, width, height)
})

try {
void tui.loadBoth()
await app.start()
} finally {
tui.close()
}

return EXIT.ok
await app.start()
}
117 changes: 117 additions & 0 deletions apps/cli/src/tui/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
endpointString,
fillDocument,
listLocal,
looksBinary,
nothingToDo,
parentPath,
pushChange,
Expand All @@ -46,6 +47,7 @@ import {
} from './model.js'
import { type Action, type Tone, type ViewState, draw, filterChoices } from './view.js'
import { maxScroll, readsAsMarkdown } from './document.js'
import { type Launch, type Launcher, type Target, editLaunch, openLaunch, systemLauncher } from './launch.js'

export {
blankPane,
Expand All @@ -70,6 +72,8 @@ export class Tui {
private document: Document | null = null
/** What the last frame drew of it: its line count and the rows it had, so a scroll can be clamped. */
private documentLayout = { total: 0, rows: 1 }
/** A program waiting for the terminal, once the app has given it up. See `start`. */
private handoff: Launch | null = null
private filtering: Side | null = null
private status: { text: string; tone: Tone } | null = null
private busy = false
Expand All @@ -86,6 +90,7 @@ export class Tui {
left: Pane,
right: Pane,
private readonly choices: readonly EndpointChoice[] = [],
private readonly launcher: Launcher = systemLauncher(),
) {
this.panes = { left, right }
}
Expand Down Expand Up @@ -237,6 +242,12 @@ export class Tui {
case 'closeDocument':
this.document = null
break
case 'edit':
if (!this.busy) await this.editSelected()
break
case 'openWith':
if (!this.busy) await this.openSelected()
break
case 'preview':
if (!this.busy) await this.transferTo(true)
break
Expand Down Expand Up @@ -458,6 +469,12 @@ export class Tui {
case key.char === 'v':
await this.toggleDocument()
break
case key.char === 'e':
await this.editSelected()
break
case key.char === 'x':
await this.openSelected()
break
case key.name === 'r':
await this.refresh(this.active)
break
Expand Down Expand Up @@ -635,6 +652,106 @@ export class Tui {
}
}

// --------------------------------------------------------------- launching

/** The row under the cursor, as something to hand to a program. */
private target(): (Target & { isDirectory: boolean }) | null {
const pane = this.current
const row = selectedRow(pane)
if (!row) return null
const path = pane.connection ? posix.join(pane.path, row.rel) : join(pane.path, row.rel)
return { path, name: row.entry.name, connection: pane.connection, isDirectory: row.entry.isDirectory }
}

/** `e`: the file under the cursor, in the system editor. */
private async editSelected(): Promise<void> {
const target = this.target()
if (!target) return
if (target.isDirectory) {
this.say('e edits a file; ⏎ unfolds a directory', 'warn')
return
}
const launch = editLaunch(target, this.launcher.env, this.launcher.available)
if ('error' in launch) {
this.say(launch.error, 'warn')
return
}
await this.start(launch, `Editing ${target.name}`)
}

/** `x`: the file under the cursor, with whatever the system opens it with. */
private async openSelected(): Promise<void> {
const target = this.target()
if (!target) return
// The bytes decide text against media before the name gets a say.
let text = false
if (!target.connection && !target.isDirectory) {
try {
text = !looksBinary(readLocalHead(target.path, 8192).bytes)
} catch {
// Unreadable: let the opener report it.
}
}
const launch = openLaunch(target, this.launcher.env, this.launcher.available, process.platform, { text })
if ('error' in launch) {
this.say(launch.error, 'warn')
return
}
await this.start(launch, `Opened ${target.name}`)
}

/**
* Gets a program on screen.
*
* A desktop opener returns at once and wants no terminal. Under tmux the
* program gets a window of its own and the browser stays up beside it.
* Otherwise the browser hands the terminal over: it stops, the runner in
* commands/tui.ts runs the program, and starts the browser again with every
* pane where it was — vim's `:sh`, from the other side.
*/
private async start(launch: Launch, done: string): Promise<void> {
if (launch.detached) {
this.launcher.detach(launch)
this.say(done)
return
}
if (this.launcher.inTmux) {
try {
await this.launcher.tmux(launch)
this.say(`${done} in a new tmux window`)
} catch (error) {
this.say(`tmux: ${error instanceof Error ? error.message : String(error)}`, 'error')
}
return
}
this.handoff = launch
this.app?.quit()
}

/** The program the app quit for, if it quit for one. Taken once. */
takeHandoff(): Launch | null {
const launch = this.handoff
this.handoff = null
return launch
}

/**
* Back from a hand-off. The program may have changed anything, so both
* listings are re-read in place, and the open document is read again —
* an edit is the most likely thing to have just happened to it.
*/
async afterHandoff(): Promise<void> {
await this.refresh('left')
await this.refresh('right')
const doc = this.document
if (!doc) return
const pane = this.panes[doc.side]
const row = visibleRows(pane).find((candidate) => endpointString(pane, candidate.rel, false) === doc.location)
this.document = null
if (row) await this.viewRow(doc.side, row)
this.invalidate()
}

private async readRemoteHead(pane: Pane, path: string): Promise<{ bytes: Uint8Array; size: number }> {
const browser = await SftpBrowser.open(await this.session(pane.connection!))
try {
Expand Down
142 changes: 142 additions & 0 deletions apps/cli/src/tui/interaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type { App, MouseEvent } from '@profullstack/hqtui'
import { renderToScreen } from '@profullstack/hqtui/testing'
import { key } from './keys.fixture.js'
import { visibleRows, type Entry, type Pane, type Side } from './model.js'
import type { Launch, Launcher } from './launch.js'

vi.mock('@diskpush/ssh-core', () => ({
SshSession: { connect: async () => ({ close: () => {} }) },
Expand Down Expand Up @@ -645,3 +646,144 @@ describe('viewing a file', () => {
expect(state(app).transfer).not.toBeNull()
})
})

describe('editing and opening with the system', () => {
/** A launcher that records what would have run. */
function fakeLauncher(over: Partial<Launcher> = {}) {
const launched: { how: 'tmux' | 'detach'; launch: Launch }[] = []
const launcher: Launcher = {
env: { EDITOR: 'vim', PATH: '/usr/bin' },
available: (bin) => ['vim', 'mpv'].includes(bin),
inTmux: true,
tmux: async (launch) => {
launched.push({ how: 'tmux', launch })
},
detach: (launch) => {
launched.push({ how: 'detach', launch })
},
...over,
}
return { launcher, launched }
}
function browser(over: Partial<Launcher> = {}) {
const root = mkdtempSync(join(tmpdir(), 'diskpush-edit-'))
mkdirSync(join(root, 'src'))
writeFileSync(join(root, 'notes.md'), '# notes\n')
writeFileSync(join(root, 'talk.mp4'), Buffer.from([0, 0, 0, 0x18, 0x66, 0x74, 0x79, 0x70]))
const left = blankPane('Local', root)
left.entries = listLocal(root)
const { launcher, launched } = fakeLauncher(over)
const app = new Tui(left, blankPane('Local', '/tmp/b'), [], launcher)
return { app, root, launched }
}
const goto = (app: Tui, name: string) => {
const p = pane(app, 'left')
p.index = visibleRows(p).findIndex((row) => row.entry.name === name)
expect(p.index).toBeGreaterThanOrEqual(0)
}

it('e opens the file under the cursor in $EDITOR, in a tmux window, and says so', async () => {
const { app, root, launched } = browser()
goto(app, 'notes.md')
await press(app, 'e')
expect(launched).toEqual([
{ how: 'tmux', launch: { argv: ['vim', join(root, 'notes.md')], cwd: root, title: 'notes.md' } },
])
expect(state(app).status?.text).toBe('Editing notes.md in a new tmux window')
expect(app.takeHandoff()).toBeNull()
})

it('e on a directory edits nothing', async () => {
const { app, launched } = browser()
goto(app, 'src')
await press(app, 'e')
expect(launched).toEqual([])
expect(state(app).status?.text).toContain('e edits a file')
})

it('without tmux, e hands the terminal over: the app quits and the runner gets the program', async () => {
const { app, root, launched } = browser({ inTmux: false })
const quit = vi.fn()
app.attach({ quit, invalidate: () => {}, height: 30 } as unknown as App)
goto(app, 'notes.md')
await press(app, 'e')
expect(launched).toEqual([])
expect(quit).toHaveBeenCalledTimes(1)
const handoff = app.takeHandoff()
expect(handoff).toEqual({ argv: ['vim', join(root, 'notes.md')], cwd: root, title: 'notes.md' })
// Taken once: the next run of the app is not a hand-off.
expect(app.takeHandoff()).toBeNull()
})

it('coming back from a hand-off re-reads the panes and the open file', async () => {
const { app, root } = browser({ inTmux: false })
app.attach({ quit: () => {}, invalidate: () => {}, height: 30 } as unknown as App)
goto(app, 'notes.md')
await press(app, 'v')
expect(state(app).document?.text).toBe('# notes\n')
await press(app, 'e')
app.takeHandoff()
writeFileSync(join(root, 'notes.md'), '# notes, edited\n')
writeFileSync(join(root, 'new.txt'), 'x')
await app.afterHandoff()
expect(state(app).document?.text).toBe('# notes, edited\n')
expect(pane(app, 'left').entries.some((entry) => entry.name === 'new.txt')).toBe(true)
})

it('x sends a video to a player in a tmux window, and a file to the desktop when there is one', async () => {
const { app, root, launched } = browser()
goto(app, 'talk.mp4')
await press(app, 'x')
expect(launched).toEqual([
{ how: 'tmux', launch: { argv: ['mpv', join(root, 'talk.mp4')], cwd: root, title: 'talk.mp4' } },
])
expect(state(app).status?.text).toBe('Opened talk.mp4 in a new tmux window')

const desktop = browser({ env: { DISPLAY: ':0' }, available: (bin) => bin === 'xdg-open' })
goto(desktop.app, 'notes.md')
await press(desktop.app, 'x')
expect(desktop.launched).toEqual([
{ how: 'detach', launch: { argv: ['xdg-open', join(desktop.root, 'notes.md')], title: 'notes.md', detached: true } },
])
})

it('x on a text file named like a video does not start a player', async () => {
const { app, root, launched } = browser({ available: (bin) => bin === 'ffplay' })
writeFileSync(join(root, 'code.ts'), 'const a = 1\n')
pane(app, 'left').entries = listLocal(root)
goto(app, 'code.ts')
await press(app, 'x')
expect(launched).toEqual([])
expect(state(app).status?.text).toBe('code.ts is a text file: v views it, e edits it.')
})

it('x says what is missing when nothing opens the file', async () => {
const { app, launched } = browser({ available: () => false })
goto(app, 'talk.mp4')
await press(app, 'x')
expect(launched).toEqual([])
expect(state(app).status?.text).toBe('No video player found: install mpv or ffplay.')
})

it('a tmux that refuses is reported, not swallowed', async () => {
const { app } = browser({
tmux: async () => {
throw new Error('no server running')
},
})
goto(app, 'notes.md')
await press(app, 'e')
expect(state(app).status).toEqual({ text: 'tmux: no server running', tone: 'error' })
})

it('the footer caps do the same', async () => {
const { app, launched } = browser()
goto(app, 'notes.md')
const screen = frame(app)
const cap = screen.find('e edit')!
screen.click(cap.x, cap.y)
await settle()
expect(launched).toHaveLength(1)
expect(launched[0]?.launch.argv[0]).toBe('vim')
})
})
Loading
Loading