Skip to content

Commit 717efa8

Browse files
ralyodioclaude
andauthored
tui: e edits the file in $EDITOR, x opens it with the system (#42)
The viewer shows what it can; for the rest the answer is the program the system already has. `e` opens the file under the cursor in $VISUAL, then $EDITOR, then vim/nvim/nano/vi — a remote file is edited on the server over `ssh -t` with the editor the server has. `x` hands a file to the desktop opener when there is a desktop, or to a player (mpv, ffplay…) or a terminal image viewer (chafa, timg…) when there is not, and says what to install when nothing fits. Under tmux the program gets a window of its own and the browser stays up beside it; without tmux the browser hands the terminal over, the way vim's :sh does, and starts again with every pane where it was and the open document re-read. Modelled on moshcode's /shell and /new. The bytes outrank the name for `x`: code.ts is TypeScript, not an MPEG transport stream, and driving the built binary in tmux is where ffplay was handed a source file. Claude-Session: https://claude.ai/code/session_01AhtFxxWvkm4izFq8Y1RQBo Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 7684176 commit 717efa8

7 files changed

Lines changed: 633 additions & 12 deletions

File tree

‎apps/cli/src/commands/tui.ts‎

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { failure, type Output } from '../output.js'
55
import { type ParsedArgv } from '../parse-argv.js'
66
import { resolveEndpoint, sshConfigHosts } from '../resolve.js'
77
import { blankPane, buildEndpointChoices, defaultLocalPath, Tui } from '../tui/app.js'
8+
import { runInherited } from '../tui/launch.js'
89

910
/**
1011
* `diskpush tui` — the two-pane browser, in a terminal.
@@ -41,6 +42,31 @@ export async function runTui(parsed: ParsedArgv, store: DiskPushStore, output: O
4142
const choices = buildEndpointChoices(await store.listConnections(), sshConfigHosts(), defaultLocalPath())
4243
const tui = new Tui(panes[0]!, panes[1]!, choices)
4344

45+
try {
46+
void tui.loadBoth()
47+
// The browser runs until it quits, or until it wants the terminal handed
48+
// to a program — `e` outside tmux. Then the program runs with the screen
49+
// to itself and the browser starts again, every pane where it was.
50+
for (;;) {
51+
await runApp(tui)
52+
const handoff = tui.takeHandoff()
53+
if (!handoff) break
54+
try {
55+
await runInherited(handoff)
56+
} catch (error) {
57+
console.error(`could not start ${handoff.argv[0]}: ${error instanceof Error ? error.message : String(error)}`)
58+
}
59+
await tui.afterHandoff()
60+
}
61+
} finally {
62+
tui.close()
63+
}
64+
65+
return EXIT.ok
66+
}
67+
68+
/** One run of the screen: from the terminal taken to the terminal restored. */
69+
async function runApp(tui: Tui): Promise<void> {
4470
// `q` is not a quit key to the app: inside the host-key prompt it has to
4571
// reach the Tui first, which is the only thing that knows a dialog is up.
4672
// 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
6187
tui.view(ui, theme, width, height)
6288
})
6389

64-
try {
65-
void tui.loadBoth()
66-
await app.start()
67-
} finally {
68-
tui.close()
69-
}
70-
71-
return EXIT.ok
90+
await app.start()
7291
}

‎apps/cli/src/tui/app.ts‎

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
endpointString,
3535
fillDocument,
3636
listLocal,
37+
looksBinary,
3738
nothingToDo,
3839
parentPath,
3940
pushChange,
@@ -46,6 +47,7 @@ import {
4647
} from './model.js'
4748
import { type Action, type Tone, type ViewState, draw, filterChoices } from './view.js'
4849
import { maxScroll, readsAsMarkdown } from './document.js'
50+
import { type Launch, type Launcher, type Target, editLaunch, openLaunch, systemLauncher } from './launch.js'
4951

5052
export {
5153
blankPane,
@@ -70,6 +72,8 @@ export class Tui {
7072
private document: Document | null = null
7173
/** What the last frame drew of it: its line count and the rows it had, so a scroll can be clamped. */
7274
private documentLayout = { total: 0, rows: 1 }
75+
/** A program waiting for the terminal, once the app has given it up. See `start`. */
76+
private handoff: Launch | null = null
7377
private filtering: Side | null = null
7478
private status: { text: string; tone: Tone } | null = null
7579
private busy = false
@@ -86,6 +90,7 @@ export class Tui {
8690
left: Pane,
8791
right: Pane,
8892
private readonly choices: readonly EndpointChoice[] = [],
93+
private readonly launcher: Launcher = systemLauncher(),
8994
) {
9095
this.panes = { left, right }
9196
}
@@ -237,6 +242,12 @@ export class Tui {
237242
case 'closeDocument':
238243
this.document = null
239244
break
245+
case 'edit':
246+
if (!this.busy) await this.editSelected()
247+
break
248+
case 'openWith':
249+
if (!this.busy) await this.openSelected()
250+
break
240251
case 'preview':
241252
if (!this.busy) await this.transferTo(true)
242253
break
@@ -458,6 +469,12 @@ export class Tui {
458469
case key.char === 'v':
459470
await this.toggleDocument()
460471
break
472+
case key.char === 'e':
473+
await this.editSelected()
474+
break
475+
case key.char === 'x':
476+
await this.openSelected()
477+
break
461478
case key.name === 'r':
462479
await this.refresh(this.active)
463480
break
@@ -635,6 +652,106 @@ export class Tui {
635652
}
636653
}
637654

655+
// --------------------------------------------------------------- launching
656+
657+
/** The row under the cursor, as something to hand to a program. */
658+
private target(): (Target & { isDirectory: boolean }) | null {
659+
const pane = this.current
660+
const row = selectedRow(pane)
661+
if (!row) return null
662+
const path = pane.connection ? posix.join(pane.path, row.rel) : join(pane.path, row.rel)
663+
return { path, name: row.entry.name, connection: pane.connection, isDirectory: row.entry.isDirectory }
664+
}
665+
666+
/** `e`: the file under the cursor, in the system editor. */
667+
private async editSelected(): Promise<void> {
668+
const target = this.target()
669+
if (!target) return
670+
if (target.isDirectory) {
671+
this.say('e edits a file; ⏎ unfolds a directory', 'warn')
672+
return
673+
}
674+
const launch = editLaunch(target, this.launcher.env, this.launcher.available)
675+
if ('error' in launch) {
676+
this.say(launch.error, 'warn')
677+
return
678+
}
679+
await this.start(launch, `Editing ${target.name}`)
680+
}
681+
682+
/** `x`: the file under the cursor, with whatever the system opens it with. */
683+
private async openSelected(): Promise<void> {
684+
const target = this.target()
685+
if (!target) return
686+
// The bytes decide text against media before the name gets a say.
687+
let text = false
688+
if (!target.connection && !target.isDirectory) {
689+
try {
690+
text = !looksBinary(readLocalHead(target.path, 8192).bytes)
691+
} catch {
692+
// Unreadable: let the opener report it.
693+
}
694+
}
695+
const launch = openLaunch(target, this.launcher.env, this.launcher.available, process.platform, { text })
696+
if ('error' in launch) {
697+
this.say(launch.error, 'warn')
698+
return
699+
}
700+
await this.start(launch, `Opened ${target.name}`)
701+
}
702+
703+
/**
704+
* Gets a program on screen.
705+
*
706+
* A desktop opener returns at once and wants no terminal. Under tmux the
707+
* program gets a window of its own and the browser stays up beside it.
708+
* Otherwise the browser hands the terminal over: it stops, the runner in
709+
* commands/tui.ts runs the program, and starts the browser again with every
710+
* pane where it was — vim's `:sh`, from the other side.
711+
*/
712+
private async start(launch: Launch, done: string): Promise<void> {
713+
if (launch.detached) {
714+
this.launcher.detach(launch)
715+
this.say(done)
716+
return
717+
}
718+
if (this.launcher.inTmux) {
719+
try {
720+
await this.launcher.tmux(launch)
721+
this.say(`${done} in a new tmux window`)
722+
} catch (error) {
723+
this.say(`tmux: ${error instanceof Error ? error.message : String(error)}`, 'error')
724+
}
725+
return
726+
}
727+
this.handoff = launch
728+
this.app?.quit()
729+
}
730+
731+
/** The program the app quit for, if it quit for one. Taken once. */
732+
takeHandoff(): Launch | null {
733+
const launch = this.handoff
734+
this.handoff = null
735+
return launch
736+
}
737+
738+
/**
739+
* Back from a hand-off. The program may have changed anything, so both
740+
* listings are re-read in place, and the open document is read again —
741+
* an edit is the most likely thing to have just happened to it.
742+
*/
743+
async afterHandoff(): Promise<void> {
744+
await this.refresh('left')
745+
await this.refresh('right')
746+
const doc = this.document
747+
if (!doc) return
748+
const pane = this.panes[doc.side]
749+
const row = visibleRows(pane).find((candidate) => endpointString(pane, candidate.rel, false) === doc.location)
750+
this.document = null
751+
if (row) await this.viewRow(doc.side, row)
752+
this.invalidate()
753+
}
754+
638755
private async readRemoteHead(pane: Pane, path: string): Promise<{ bytes: Uint8Array; size: number }> {
639756
const browser = await SftpBrowser.open(await this.session(pane.connection!))
640757
try {

‎apps/cli/src/tui/interaction.test.ts‎

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type { App, MouseEvent } from '@profullstack/hqtui'
1212
import { renderToScreen } from '@profullstack/hqtui/testing'
1313
import { key } from './keys.fixture.js'
1414
import { visibleRows, type Entry, type Pane, type Side } from './model.js'
15+
import type { Launch, Launcher } from './launch.js'
1516

1617
vi.mock('@diskpush/ssh-core', () => ({
1718
SshSession: { connect: async () => ({ close: () => {} }) },
@@ -645,3 +646,144 @@ describe('viewing a file', () => {
645646
expect(state(app).transfer).not.toBeNull()
646647
})
647648
})
649+
650+
describe('editing and opening with the system', () => {
651+
/** A launcher that records what would have run. */
652+
function fakeLauncher(over: Partial<Launcher> = {}) {
653+
const launched: { how: 'tmux' | 'detach'; launch: Launch }[] = []
654+
const launcher: Launcher = {
655+
env: { EDITOR: 'vim', PATH: '/usr/bin' },
656+
available: (bin) => ['vim', 'mpv'].includes(bin),
657+
inTmux: true,
658+
tmux: async (launch) => {
659+
launched.push({ how: 'tmux', launch })
660+
},
661+
detach: (launch) => {
662+
launched.push({ how: 'detach', launch })
663+
},
664+
...over,
665+
}
666+
return { launcher, launched }
667+
}
668+
function browser(over: Partial<Launcher> = {}) {
669+
const root = mkdtempSync(join(tmpdir(), 'diskpush-edit-'))
670+
mkdirSync(join(root, 'src'))
671+
writeFileSync(join(root, 'notes.md'), '# notes\n')
672+
writeFileSync(join(root, 'talk.mp4'), Buffer.from([0, 0, 0, 0x18, 0x66, 0x74, 0x79, 0x70]))
673+
const left = blankPane('Local', root)
674+
left.entries = listLocal(root)
675+
const { launcher, launched } = fakeLauncher(over)
676+
const app = new Tui(left, blankPane('Local', '/tmp/b'), [], launcher)
677+
return { app, root, launched }
678+
}
679+
const goto = (app: Tui, name: string) => {
680+
const p = pane(app, 'left')
681+
p.index = visibleRows(p).findIndex((row) => row.entry.name === name)
682+
expect(p.index).toBeGreaterThanOrEqual(0)
683+
}
684+
685+
it('e opens the file under the cursor in $EDITOR, in a tmux window, and says so', async () => {
686+
const { app, root, launched } = browser()
687+
goto(app, 'notes.md')
688+
await press(app, 'e')
689+
expect(launched).toEqual([
690+
{ how: 'tmux', launch: { argv: ['vim', join(root, 'notes.md')], cwd: root, title: 'notes.md' } },
691+
])
692+
expect(state(app).status?.text).toBe('Editing notes.md in a new tmux window')
693+
expect(app.takeHandoff()).toBeNull()
694+
})
695+
696+
it('e on a directory edits nothing', async () => {
697+
const { app, launched } = browser()
698+
goto(app, 'src')
699+
await press(app, 'e')
700+
expect(launched).toEqual([])
701+
expect(state(app).status?.text).toContain('e edits a file')
702+
})
703+
704+
it('without tmux, e hands the terminal over: the app quits and the runner gets the program', async () => {
705+
const { app, root, launched } = browser({ inTmux: false })
706+
const quit = vi.fn()
707+
app.attach({ quit, invalidate: () => {}, height: 30 } as unknown as App)
708+
goto(app, 'notes.md')
709+
await press(app, 'e')
710+
expect(launched).toEqual([])
711+
expect(quit).toHaveBeenCalledTimes(1)
712+
const handoff = app.takeHandoff()
713+
expect(handoff).toEqual({ argv: ['vim', join(root, 'notes.md')], cwd: root, title: 'notes.md' })
714+
// Taken once: the next run of the app is not a hand-off.
715+
expect(app.takeHandoff()).toBeNull()
716+
})
717+
718+
it('coming back from a hand-off re-reads the panes and the open file', async () => {
719+
const { app, root } = browser({ inTmux: false })
720+
app.attach({ quit: () => {}, invalidate: () => {}, height: 30 } as unknown as App)
721+
goto(app, 'notes.md')
722+
await press(app, 'v')
723+
expect(state(app).document?.text).toBe('# notes\n')
724+
await press(app, 'e')
725+
app.takeHandoff()
726+
writeFileSync(join(root, 'notes.md'), '# notes, edited\n')
727+
writeFileSync(join(root, 'new.txt'), 'x')
728+
await app.afterHandoff()
729+
expect(state(app).document?.text).toBe('# notes, edited\n')
730+
expect(pane(app, 'left').entries.some((entry) => entry.name === 'new.txt')).toBe(true)
731+
})
732+
733+
it('x sends a video to a player in a tmux window, and a file to the desktop when there is one', async () => {
734+
const { app, root, launched } = browser()
735+
goto(app, 'talk.mp4')
736+
await press(app, 'x')
737+
expect(launched).toEqual([
738+
{ how: 'tmux', launch: { argv: ['mpv', join(root, 'talk.mp4')], cwd: root, title: 'talk.mp4' } },
739+
])
740+
expect(state(app).status?.text).toBe('Opened talk.mp4 in a new tmux window')
741+
742+
const desktop = browser({ env: { DISPLAY: ':0' }, available: (bin) => bin === 'xdg-open' })
743+
goto(desktop.app, 'notes.md')
744+
await press(desktop.app, 'x')
745+
expect(desktop.launched).toEqual([
746+
{ how: 'detach', launch: { argv: ['xdg-open', join(desktop.root, 'notes.md')], title: 'notes.md', detached: true } },
747+
])
748+
})
749+
750+
it('x on a text file named like a video does not start a player', async () => {
751+
const { app, root, launched } = browser({ available: (bin) => bin === 'ffplay' })
752+
writeFileSync(join(root, 'code.ts'), 'const a = 1\n')
753+
pane(app, 'left').entries = listLocal(root)
754+
goto(app, 'code.ts')
755+
await press(app, 'x')
756+
expect(launched).toEqual([])
757+
expect(state(app).status?.text).toBe('code.ts is a text file: v views it, e edits it.')
758+
})
759+
760+
it('x says what is missing when nothing opens the file', async () => {
761+
const { app, launched } = browser({ available: () => false })
762+
goto(app, 'talk.mp4')
763+
await press(app, 'x')
764+
expect(launched).toEqual([])
765+
expect(state(app).status?.text).toBe('No video player found: install mpv or ffplay.')
766+
})
767+
768+
it('a tmux that refuses is reported, not swallowed', async () => {
769+
const { app } = browser({
770+
tmux: async () => {
771+
throw new Error('no server running')
772+
},
773+
})
774+
goto(app, 'notes.md')
775+
await press(app, 'e')
776+
expect(state(app).status).toEqual({ text: 'tmux: no server running', tone: 'error' })
777+
})
778+
779+
it('the footer caps do the same', async () => {
780+
const { app, launched } = browser()
781+
goto(app, 'notes.md')
782+
const screen = frame(app)
783+
const cap = screen.find('e edit')!
784+
screen.click(cap.x, cap.y)
785+
await settle()
786+
expect(launched).toHaveLength(1)
787+
expect(launched[0]?.launch.argv[0]).toBe('vim')
788+
})
789+
})

0 commit comments

Comments
 (0)