diff --git a/apps/cli/src/commands/tui.ts b/apps/cli/src/commands/tui.ts index b1955de..d1766ad 100644 --- a/apps/cli/src/commands/tui.ts +++ b/apps/cli/src/commands/tui.ts @@ -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. @@ -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 { // `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. @@ -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() } diff --git a/apps/cli/src/tui/app.ts b/apps/cli/src/tui/app.ts index decf97e..427bbab 100644 --- a/apps/cli/src/tui/app.ts +++ b/apps/cli/src/tui/app.ts @@ -34,6 +34,7 @@ import { endpointString, fillDocument, listLocal, + looksBinary, nothingToDo, parentPath, pushChange, @@ -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, @@ -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 @@ -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 } } @@ -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 @@ -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 @@ -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 { + 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 { + 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 { + 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 { + 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 { diff --git a/apps/cli/src/tui/interaction.test.ts b/apps/cli/src/tui/interaction.test.ts index c04b3f5..8e4b8cc 100644 --- a/apps/cli/src/tui/interaction.test.ts +++ b/apps/cli/src/tui/interaction.test.ts @@ -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: () => {} }) }, @@ -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 = {}) { + 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 = {}) { + 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') + }) +}) diff --git a/apps/cli/src/tui/launch.test.ts b/apps/cli/src/tui/launch.test.ts new file mode 100644 index 0000000..e3dffa8 --- /dev/null +++ b/apps/cli/src/tui/launch.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest' +import type { Connection } from '@diskpush/schemas' +import { editLaunch, editorArgv, hasDesktop, mediaKind, openLaunch, type Target } from './launch.js' + +const has = (...bins: string[]) => (bin: string) => bins.includes(bin) +const local = (name: string): Target => ({ path: `/home/me/${name}`, name, connection: null }) +const prod: Connection = { + id: 'prod', + name: 'prod', + host: 'prod.example', + port: 22, + username: 'deploy', + authType: 'agent', + keyPath: null, + jumpHost: null, +} as Connection + +describe('the editor', () => { + it('is $VISUAL, then $EDITOR, then whatever is installed', () => { + expect(editorArgv({ VISUAL: 'code -w', EDITOR: 'nano' }, has('vim'))).toEqual(['code', '-w']) + expect(editorArgv({ EDITOR: 'nano' }, has('vim'))).toEqual(['nano']) + expect(editorArgv({}, has('nano', 'vi'))).toEqual(['nano']) + expect(editorArgv({}, has())).toBeNull() + }) + + it('opens a local file in it, from its own directory', () => { + expect(editLaunch(local('notes.md'), { EDITOR: 'vim' }, has())).toEqual({ + argv: ['vim', '/home/me/notes.md'], + cwd: '/home/me', + title: 'notes.md', + }) + expect(editLaunch(local('notes.md'), {}, has())).toEqual({ error: 'No editor: set $EDITOR, or install vim or nano.' }) + }) + + it('edits a remote file on the server, over ssh -t, with the editor the server has', () => { + const target: Target = { path: '/srv/app/config.yml', name: 'config.yml', connection: prod } + expect(editLaunch(target, { EDITOR: 'vim' }, has())).toEqual({ + argv: ['ssh', '-t', 'deploy@prod.example', '${VISUAL:-${EDITOR:-vi}} /srv/app/config.yml'], + title: 'config.yml', + }) + const odd: Target = { + path: "/srv/app/it's here.yml", + name: "it's here.yml", + connection: { ...prod, port: 2222, keyPath: '/home/me/.ssh/prod', jumpHost: 'bastion' }, + } + expect(editLaunch(odd, {}, has()).argv).toEqual([ + 'ssh', + '-t', + '-p', + '2222', + '-i', + '/home/me/.ssh/prod', + '-J', + 'bastion', + 'deploy@prod.example', + "${VISUAL:-${EDITOR:-vi}} '/srv/app/it'\\''s here.yml'", + ]) + }) +}) + +describe('opening with the system', () => { + it('knows a video, a song and a picture by name', () => { + expect(mediaKind('talk.MP4')).toBe('video') + expect(mediaKind('song.flac')).toBe('audio') + expect(mediaKind('photo.jpeg')).toBe('image') + expect(mediaKind('archive.tar.gz')).toBe('other') + }) + + it('hands everything to the desktop opener when there is a desktop', () => { + expect(hasDesktop({ DISPLAY: ':0' }, 'linux')).toBe(true) + expect(hasDesktop({}, 'linux')).toBe(false) + expect(hasDesktop({}, 'darwin')).toBe(true) + expect(openLaunch(local('report.pdf'), { DISPLAY: ':0' }, has('xdg-open', 'mpv'), 'linux')).toEqual({ + argv: ['xdg-open', '/home/me/report.pdf'], + title: 'report.pdf', + detached: true, + }) + expect(openLaunch(local('talk.mp4'), {}, has('open'), 'darwin')).toMatchObject({ argv: ['open', '/home/me/talk.mp4'] }) + }) + + it('finds a terminal player or viewer when there is no desktop, in order of preference', () => { + expect(openLaunch(local('talk.mp4'), {}, has('ffplay', 'mpv'), 'linux')).toMatchObject({ argv: ['mpv', '/home/me/talk.mp4'] }) + expect(openLaunch(local('talk.mp4'), {}, has('ffplay'), 'linux')).toMatchObject({ argv: ['ffplay', '/home/me/talk.mp4'] }) + // ffplay would open a window it cannot have: sound only. + expect(openLaunch(local('song.mp3'), {}, has('ffplay'), 'linux')).toMatchObject({ + argv: ['ffplay', '-nodisp', '-autoexit', '/home/me/song.mp3'], + }) + expect(openLaunch(local('photo.png'), {}, has('chafa'), 'linux')).toMatchObject({ argv: ['chafa', '/home/me/photo.png'], cwd: '/home/me' }) + }) + + it('trusts the bytes over the name: a text file is never sent to a player', () => { + expect(openLaunch(local('code.ts'), {}, has('ffplay'), 'linux', { text: true })).toEqual({ + error: 'code.ts is a text file: v views it, e edits it.', + }) + // The desktop still gets it, because the desktop reads the bytes too. + expect(openLaunch(local('code.ts'), { DISPLAY: ':0' }, has('xdg-open'), 'linux', { text: true })).toMatchObject({ + argv: ['xdg-open', '/home/me/code.ts'], + }) + }) + + it('says what is missing, and what would work instead', () => { + expect(openLaunch(local('talk.mp4'), {}, has(), 'linux')).toEqual({ error: 'No video player found: install mpv or ffplay.' }) + expect(openLaunch(local('photo.png'), {}, has(), 'linux')).toEqual({ error: 'No image viewer found: install chafa or timg.' }) + expect(openLaunch(local('data.bin'), {}, has('mpv'), 'linux')).toEqual({ + error: 'Nothing here opens data.bin. Press e to open it in $EDITOR.', + }) + expect(openLaunch({ path: '/srv/a.mp4', name: 'a.mp4', connection: prod }, { DISPLAY: ':0' }, has('xdg-open'), 'linux')).toEqual({ + error: 'x opens files on this machine. Sync a.mp4 here first, or press e to edit it over ssh.', + }) + }) +}) diff --git a/apps/cli/src/tui/launch.ts b/apps/cli/src/tui/launch.ts new file mode 100644 index 0000000..b9ca82a --- /dev/null +++ b/apps/cli/src/tui/launch.ts @@ -0,0 +1,210 @@ +/** + * Handing a file to something that is not the viewer. + * + * The viewer shows what it can. For the rest — a file you want to change, a + * video, an image, an archive — the answer is the program the system already + * has for it: `$EDITOR` for editing, and a player or the desktop opener for + * the media types. Under tmux that program gets a window of its own and the + * browser keeps running beside it; without tmux the browser hands the + * terminal over and comes back when the program exits, the way vim's `:sh` + * does. Modelled on moshcode's `/shell` and `/new`. + * + * The planning is pure and tested; the two effects at the bottom are thin. + */ +import { spawn } from 'node:child_process' +import { existsSync } from 'node:fs' +import { delimiter, dirname, extname, join } from 'node:path' +import { shellJoin, shellQuote } from '@diskpush/rsync-core' +import type { Connection } from '@diskpush/schemas' + +/** A program to run, and where. */ +export type Launch = { + argv: string[] + cwd?: string + /** What to call the tmux window. */ + title: string + /** + * Returns at once and owns no terminal: a desktop opener. It is spawned + * detached rather than given a window that would close immediately. + */ + detached?: boolean +} + +/** Whether a program is on PATH. */ +export type Available = (bin: string) => boolean + +export function onPath(env: NodeJS.ProcessEnv = process.env): Available { + const dirs = (env.PATH ?? '').split(delimiter).filter(Boolean) + return (bin) => bin.includes('/') ? existsSync(bin) : dirs.some((dir) => existsSync(join(dir, bin))) +} + +/** Editors tried, in order, when neither $VISUAL nor $EDITOR is set. */ +const EDITORS = ['vim', 'nvim', 'nano', 'vi'] + +/** + * The editor as argv: `$VISUAL`, then `$EDITOR`, then the first of the usual + * suspects on PATH. Split on whitespace because `EDITOR="code -w"` is how a + * GUI editor is told to wait, and that flag is part of the editor. + */ +export function editorArgv(env: NodeJS.ProcessEnv, available: Available): string[] | null { + for (const name of ['VISUAL', 'EDITOR']) { + const value = env[name]?.trim() + if (value) return value.split(/\s+/) + } + const found = EDITORS.find(available) + return found ? [found] : null +} + +/** Where a file to edit lives: on this machine, or on a server. */ +export type Target = { path: string; name: string; connection: Connection | null } + +/** + * Editing a file. + * + * A local file goes straight to the editor. A remote one is edited *on the + * server*, over `ssh -t`, with whatever `$VISUAL`/`$EDITOR` is there and `vi` + * failing that — the variables are left for the remote shell to expand, which + * is why the command is one string rather than argv. Nothing is downloaded and + * nothing has to be uploaded back, and the file the editor saves is the file. + */ +export function editLaunch(target: Target, env: NodeJS.ProcessEnv, available: Available): Launch | { error: string } { + if (target.connection) { + const c = target.connection + const argv = [ + 'ssh', + '-t', + ...(c.port !== 22 ? ['-p', String(c.port)] : []), + ...(c.keyPath ? ['-i', c.keyPath] : []), + ...(c.jumpHost ? ['-J', c.jumpHost] : []), + `${c.username}@${c.host}`, + `\${VISUAL:-\${EDITOR:-vi}} ${shellQuote(target.path)}`, + ] + return { argv, title: target.name } + } + const editor = editorArgv(env, available) + if (!editor) return { error: 'No editor: set $EDITOR, or install vim or nano.' } + return { argv: [...editor, target.path], cwd: dirname(target.path), title: target.name } +} + +const VIDEO = new Set(['.mp4', '.mkv', '.webm', '.mov', '.avi', '.m4v', '.ts', '.m2ts', '.mpg', '.mpeg', '.wmv', '.flv']) +const AUDIO = new Set(['.mp3', '.flac', '.wav', '.m4a', '.aac', '.ogg', '.opus', '.wma', '.aiff']) +const IMAGE = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg', '.avif', '.heic', '.tiff', '.tif']) + +export type MediaKind = 'video' | 'audio' | 'image' | 'other' + +export function mediaKind(name: string): MediaKind { + const ext = extname(name).toLowerCase() + if (VIDEO.has(ext)) return 'video' + if (AUDIO.has(ext)) return 'audio' + if (IMAGE.has(ext)) return 'image' + return 'other' +} + +/** Terminal programs for each kind, in order of preference. */ +const PLAYERS = ['mpv', 'ffplay', 'vlc', 'mplayer'] +const AUDIO_PLAYERS = ['mpv', 'ffplay', 'play', 'mplayer'] +const IMAGE_VIEWERS = ['chafa', 'timg', 'viu', 'feh'] + +/** True when there is a desktop to open things on. */ +export function hasDesktop(env: NodeJS.ProcessEnv, platform = process.platform): boolean { + if (platform === 'darwin') return true + return Boolean(env.DISPLAY || env.WAYLAND_DISPLAY) +} + +/** + * Opening a file with "whatever opens this". + * + * With a desktop in reach the desktop opener decides, exactly as a double + * click would. On a server, or in an ssh session, there is no desktop, so a + * video or a song goes to a player and an image to a viewer that draws in the + * terminal — each in its own tmux window. What is left has no program to go + * to, and the message says what would. + * + * `text` is what the file's own bytes say, and it outranks the name: `.ts` is + * TypeScript a thousand times for every MPEG transport stream, and a player + * handed a source file is a window that opens and closes with an error. + */ +export function openLaunch( + target: Target, + env: NodeJS.ProcessEnv, + available: Available, + platform = process.platform, + { text = false }: { text?: boolean } = {}, +): Launch | { error: string } { + if (target.connection) { + return { error: `x opens files on this machine. Sync ${target.name} here first, or press e to edit it over ssh.` } + } + const kind = text ? 'other' : mediaKind(target.name) + if (hasDesktop(env, platform)) { + const opener = platform === 'darwin' ? 'open' : 'xdg-open' + if (available(opener)) return { argv: [opener, target.path], title: target.name, detached: true } + } + if (text) return { error: `${target.name} is a text file: v views it, e edits it.` } + const candidates = kind === 'video' ? PLAYERS : kind === 'audio' ? AUDIO_PLAYERS : kind === 'image' ? IMAGE_VIEWERS : [] + const found = candidates.find(available) + if (found) { + // ffplay draws a window it cannot have here; ask it to play sound only. + const argv = found === 'ffplay' && kind === 'audio' ? ['ffplay', '-nodisp', '-autoexit', target.path] : [found, target.path] + return { argv, cwd: dirname(target.path), title: target.name } + } + if (kind === 'other') return { error: `Nothing here opens ${target.name}. Press e to open it in $EDITOR.` } + return { error: `No ${kind} ${kind === 'image' ? 'viewer' : 'player'} found: install ${candidates.slice(0, 2).join(' or ')}.` } +} + +/** Runs `launch` in a new tmux window beside this one. Rejects when tmux refuses. */ +export function openTmuxWindow(launch: Launch, env: NodeJS.ProcessEnv = process.env): Promise { + const args = ['new-window', '-n', launch.title, ...(launch.cwd ? ['-c', launch.cwd] : []), shellJoin(launch.argv)] + return new Promise((resolve, reject) => { + const child = spawn('tmux', args, { env, stdio: ['ignore', 'ignore', 'pipe'] }) + let stderr = '' + child.stderr?.on('data', (chunk: Buffer) => (stderr += chunk.toString())) + child.on('error', reject) + child.on('exit', (code) => (code === 0 ? resolve() : reject(new Error(stderr.trim() || `tmux exited ${code}`)))) + }) +} + +/** Starts a desktop opener and forgets it: it returns at once and owns no terminal. */ +export function spawnDetached(launch: Launch, env: NodeJS.ProcessEnv = process.env): void { + const [bin, ...args] = launch.argv + const child = spawn(bin!, args, { env, detached: true, stdio: 'ignore' }) + child.on('error', () => {}) + child.unref() +} + +/** + * Runs `launch` with the terminal handed over, and resolves when it exits. + * + * Only for a terminal the TUI has already given up: the app must be stopped + * before this, and started again after, or the two fight over the screen. + */ +export function runInherited(launch: Launch, env: NodeJS.ProcessEnv = process.env): Promise { + const [bin, ...args] = launch.argv + return new Promise((resolve, reject) => { + const child = spawn(bin!, args, { env, stdio: 'inherit', ...(launch.cwd ? { cwd: launch.cwd } : {}) }) + child.on('error', reject) + child.on('exit', (code) => resolve(code)) + }) +} + +/** + * What the app uses to get a program on screen. Injected so a test can watch + * what would have been launched instead of launching it. + */ +export type Launcher = { + env: NodeJS.ProcessEnv + available: Available + /** True under tmux, where a program can have a window of its own. */ + inTmux: boolean + tmux: (launch: Launch) => Promise + detach: (launch: Launch) => void +} + +export function systemLauncher(env: NodeJS.ProcessEnv = process.env): Launcher { + return { + env, + available: onPath(env), + inTmux: Boolean(env.TMUX), + tmux: (launch) => openTmuxWindow(launch, env), + detach: (launch) => spawnDetached(launch, env), + } +} diff --git a/apps/cli/src/tui/view.test.ts b/apps/cli/src/tui/view.test.ts index e0982c0..8eb78e9 100644 --- a/apps/cli/src/tui/view.test.ts +++ b/apps/cli/src/tui/view.test.ts @@ -519,8 +519,17 @@ describe('a file open under the panes', () => { return { ...doc, ...over } } - it('offers v in the footer', () => { - expect(screen(state())).toContain('v view') + it('offers v, e and x in the footer', () => { + const text = screen(state()) + expect(text).toContain('v view') + expect(text).toContain('e edit') + expect(text).toContain('x open…') + }) + + it('tells you, over a hex dump, what would open the file', () => { + const text = screen(state({ document: document('a.bin', 'AB\0C') })) + expect(text).toContain('Not a text file. e edits it; x opens it') + expect(text).toContain('00000000 41 42 00 43') }) it('draws the document under both panes, rendered, with its name and where it lives', () => { @@ -629,5 +638,7 @@ describe('a file open under the panes', () => { const text = screen(state({ overlay: { kind: 'help' } })) expect(text).toContain('view it under the panes') expect(text).toContain('page the open file') + expect(text).toContain('edit it in $EDITOR') + expect(text).toContain('open it with a player') }) }) diff --git a/apps/cli/src/tui/view.ts b/apps/cli/src/tui/view.ts index 7c1d607..d4d20cc 100644 --- a/apps/cli/src/tui/view.ts +++ b/apps/cli/src/tui/view.ts @@ -64,6 +64,8 @@ export type Action = | 'endpoint' | 'view' | 'closeDocument' + | 'edit' + | 'openWith' | 'preview' | 'sync' | 'filter' @@ -370,7 +372,9 @@ function drawDocument( // The border and its padding take two columns a side; the scrollbar one more. const textWidth = Math.max(10, width - 5) const lines = doc.loading || doc.error ? [] : documentLines(doc, textWidth, theme) - const rows = Math.max(1, height - 2 - paneRowsBeside(height) - DOCUMENT_CHROME) + // A hex dump is the viewer admitting defeat, so it says what would work. + const hint = doc.kind === 'binary' ? 'Not a text file. e edits it; x opens it with a player, a viewer or the desktop.' : null + const rows = Math.max(1, height - 2 - paneRowsBeside(height) - DOCUMENT_CHROME - (hint ? 1 : 0)) const scroll = Math.min(doc.scroll, maxScroll(lines.length, rows)) const scrolls = lines.length > rows handlers.onDocumentLayout?.(lines.length, rows) @@ -411,6 +415,7 @@ function drawDocument( panel.text('Empty file', { align: 'center', fg: theme.muted }) return } + if (hint) panel.text(hint, { height: 1, fg: theme.muted }) panel.row({ gap: 0, height: 'fill' }, (row) => { row.draw( (surface) => { @@ -639,6 +644,8 @@ function drawFooter(ui: Container, theme: Theme, state: ViewState, width: number { key: '↑↓', label: 'file' }, { key: 'pgdn pgup', label: 'page' }, { key: 'g G', label: 'top end' }, + { key: 'e', label: 'edit', onPress: act('edit') }, + { key: 'x', label: 'open…', onPress: act('openWith') }, { key: 'esc', label: 'close', onPress: act('closeDocument') }, { key: 'q', label: 'quit', onPress: act('quit') }, ] @@ -646,6 +653,8 @@ function drawFooter(ui: Container, theme: Theme, state: ViewState, width: number { key: 'tab', label: 'pane', onPress: act('pane') }, { key: '⏎', label: 'open', onPress: act('open') }, { key: 'v', label: 'view', onPress: act('view') }, + { key: 'e', label: 'edit', onPress: act('edit') }, + { key: 'x', label: 'open…', onPress: act('openWith') }, { key: 'c', label: 'endpoint', onPress: act('endpoint') }, { key: 'p', label: 'preview', onPress: act('preview') }, { key: 's', label: 'sync', onPress: act('sync') }, @@ -716,7 +725,7 @@ function drawHelp(ui: Container, theme: Theme, handlers: ViewHandlers): void { { title: ' Keys ', width: 66, - height: 26, + height: 28, buttons: [{ label: 'esc close', variant: 'ghost', onPress: close }], onDismiss: close, }, @@ -732,6 +741,8 @@ function drawHelp(ui: Container, theme: Theme, handlers: ViewHandlers): void { { label: 'click ..', value: 'go up' }, { label: 'v, or ⏎ on a file', value: 'view it under the panes; ↑ ↓ then follow' }, { label: 'pgdn pgup g G', value: 'page the open file, jump to its ends' }, + { label: 'e', value: 'edit it in $EDITOR: a tmux window, or here' }, + { label: 'x', value: 'open it with a player, a viewer or the desktop' }, { label: 'c', value: 'point this pane somewhere else' }, { label: '/', value: 'filter this listing' }, { label: 'o / O', value: 'cycle sort / reverse it' },