Skip to content

Commit 6478781

Browse files
ralyodioclaude
andauthored
tui: v views the file under the cursor, under the panes (#41)
`v`, or ⏎ on a file, opens it beneath the two panes: the panes keep a fixed share of the screen and the document takes the rest. The arrows stay with the panes so the viewer follows the cursor from file to file; pgdn/pgup/space/b page the document, g/G jump, the wheel scrolls, and esc or v closes it. Remote files are read over the same SFTP session the listing came from. Markdown is rendered by @profullstack/readm3 (the readm3.com renderer) with its roles mapped onto the theme; an extensionless file that @profullstack/text-type-detection calls markdown is rendered too; other text shows as numbered lines; a binary as a hex dump of its head. Only the first megabyte is read, via a new SftpBrowser.readHead and a local equivalent, and the footer says so when that is not the whole file. Binary is a NUL in the head, or a tenth of it control bytes or invalid UTF-8: the second rule came from driving the built binary in a pty, where a 300-byte random file with no NUL rendered as garbage text. Claude-Session: https://claude.ai/code/session_01AhtFxxWvkm4izFq8Y1RQBo Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 9fad772 commit 6478781

12 files changed

Lines changed: 1042 additions & 15 deletions

File tree

‎apps/cli/package.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
"@diskpush/schemas": "workspace:*",
2323
"@diskpush/ssh-core": "workspace:*",
2424
"@profullstack/hqtui": "^0.5.1",
25+
"@profullstack/readm3": "^0.3.0",
26+
"@profullstack/text-type-detection": "^1.0.0",
2527
"zod": "^3.24.1"
2628
}
2729
}

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

Lines changed: 133 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { SftpBrowser, SshSession } from '@diskpush/ssh-core'
1717
import { defaultRsyncOptions, type Change, type Connection } from '@diskpush/schemas'
1818
import { parseEndpoint, planTransfer, runToCompletion } from '@diskpush/rsync-core'
1919
import {
20+
type Document,
2021
type EndpointChoice,
2122
type Entry,
2223
type Overlay,
@@ -26,19 +27,25 @@ import {
2627
type SortKey,
2728
SORT_KEYS,
2829
type Transfer,
30+
DOCUMENT_LIMIT,
31+
blankDocument,
2932
blankPane,
3033
clampIndex,
34+
endpointString,
35+
fillDocument,
3136
listLocal,
3237
nothingToDo,
3338
parentPath,
3439
pushChange,
40+
readLocalHead,
3541
resetTree,
3642
scannedFrom,
3743
scopeTransfer,
3844
selectedRow,
3945
visibleRows,
4046
} from './model.js'
4147
import { type Action, type Tone, type ViewState, draw, filterChoices } from './view.js'
48+
import { maxScroll, readsAsMarkdown } from './document.js'
4249

4350
export {
4451
blankPane,
@@ -59,6 +66,10 @@ export class Tui {
5966
/** Whatever is on screen instead of the panes, and owns the keyboard while it is. */
6067
private overlay: Overlay | null = null
6168
private transfer: Transfer | null = null
69+
/** The file open under the panes, if any. */
70+
private document: Document | null = null
71+
/** What the last frame drew of it: its line count and the rows it had, so a scroll can be clamped. */
72+
private documentLayout = { total: 0, rows: 1 }
6273
private filtering: Side | null = null
6374
private status: { text: string; tone: Tone } | null = null
6475
private busy = false
@@ -96,6 +107,7 @@ export class Tui {
96107
active: this.active,
97108
overlay: this.overlay,
98109
transfer: this.transfer,
110+
document: this.document,
99111
filtering: this.filtering,
100112
status: this.status,
101113
choices: this.choices,
@@ -118,6 +130,8 @@ export class Tui {
118130
clampIndex(pane)
119131
const row = selectedRow(pane)
120132
if (row?.entry.isDirectory && !this.busy) void this.toggle(side, row)
133+
// With the viewer open, a click on a file is a request to see it.
134+
else if (row && this.document) void this.viewRow(side, row)
121135
this.invalidate()
122136
},
123137
onGoUp: (side) => {
@@ -155,6 +169,13 @@ export class Tui {
155169
onHostKeyDecide: (trust) => {
156170
if (this.overlay?.kind === 'hostKey') this.overlay.decide(trust)
157171
},
172+
onDocumentScroll: (delta) => {
173+
this.scrollDocument(delta * WHEEL_ROWS)
174+
this.invalidate()
175+
},
176+
onDocumentLayout: (total, rows) => {
177+
this.documentLayout = { total, rows }
178+
},
158179
})
159180
}
160181

@@ -210,6 +231,12 @@ export class Tui {
210231
case 'endpoint':
211232
if (!this.busy) this.openPicker()
212233
break
234+
case 'view':
235+
if (!this.busy) await this.toggleDocument()
236+
break
237+
case 'closeDocument':
238+
this.document = null
239+
break
213240
case 'preview':
214241
if (!this.busy) await this.transferTo(true)
215242
break
@@ -379,10 +406,17 @@ export class Tui {
379406
this.dismissTransfer()
380407
return true
381408
}
409+
// Then to the open file: it is the thing on top, and closing it is
410+
// what escape means while it is there.
411+
if (this.document) {
412+
this.document = null
413+
return true
414+
}
382415
return false
383416
}
384417
if (key.name === 'q') return false
385418
if (this.busy) return true
419+
if (this.document && this.onDocumentKey(key)) return true
386420

387421
const pane = this.current
388422
const page = Math.max(1, (this.app?.height ?? 30) - 10)
@@ -421,6 +455,9 @@ export class Tui {
421455
case key.name === 'c':
422456
this.openPicker()
423457
break
458+
case key.char === 'v':
459+
await this.toggleDocument()
460+
break
424461
case key.name === 'r':
425462
await this.refresh(this.active)
426463
break
@@ -515,6 +552,96 @@ export class Tui {
515552
const pane = this.current
516553
const last = Math.max(0, visibleRows(pane).length - 1)
517554
pane.index = Math.min(last, Math.max(0, pane.index + delta))
555+
this.followCursor()
556+
}
557+
558+
// ------------------------------------------------------------- documents
559+
560+
/**
561+
* The keys the open file takes: paging and jumping.
562+
*
563+
* Not the arrows. Those stay with the panes so the viewer can follow the
564+
* cursor from one file to the next, which is what makes it a preview rather
565+
* than a detour. Returns false for a key that is not the viewer's.
566+
*/
567+
private onDocumentKey(key: KeyEvent): boolean {
568+
const rows = this.documentLayout.rows
569+
if (key.name === 'pagedown' || key.name === 'space' || key.char === ' ') this.scrollDocument(rows)
570+
else if (key.name === 'pageup' || key.char === 'b') this.scrollDocument(-rows)
571+
else if (key.name === 'home' || key.char === 'g') this.scrollDocument(-Infinity)
572+
else if (key.name === 'end' || key.char === 'G') this.scrollDocument(Infinity)
573+
else return false
574+
return true
575+
}
576+
577+
private scrollDocument(delta: number): void {
578+
const doc = this.document
579+
if (!doc) return
580+
const furthest = maxScroll(this.documentLayout.total, this.documentLayout.rows)
581+
doc.scroll = Math.min(furthest, Math.max(0, doc.scroll + delta))
582+
}
583+
584+
/** With a file open, the cursor landing on another file shows that one instead. */
585+
private followCursor(): void {
586+
if (!this.document) return
587+
const row = selectedRow(this.current)
588+
if (row && !row.entry.isDirectory) void this.viewRow(this.active, row)
589+
}
590+
591+
/** `v`: the file under the cursor opens under the panes, or the open one closes. */
592+
private async toggleDocument(): Promise<void> {
593+
if (this.document) {
594+
this.document = null
595+
return
596+
}
597+
const row = selectedRow(this.current)
598+
if (!row) return
599+
if (row.entry.isDirectory) {
600+
this.say('v views a file; ⏎ unfolds a directory', 'warn')
601+
return
602+
}
603+
await this.viewRow(this.active, row)
604+
}
605+
606+
/**
607+
* Reads the head of a file and shows it under the panes.
608+
*
609+
* Only the head — see DOCUMENT_LIMIT. Nobody waits on the read: the cursor
610+
* keeps moving, and a head that lands after the viewer has moved on to
611+
* another file, or has been closed, is dropped rather than shown.
612+
*/
613+
private async viewRow(side: Side, row: Row): Promise<void> {
614+
const pane = this.panes[side]
615+
const location = endpointString(pane, row.rel, false)
616+
if (this.document?.location === location && !this.document.error) return
617+
618+
const doc = blankDocument(side, row.entry.name, location)
619+
this.document = doc
620+
// A finished transfer's panel and the document want the same rows.
621+
if (this.transfer && !this.transfer.running) this.transfer = null
622+
this.invalidate()
623+
try {
624+
const head = pane.connection
625+
? await this.readRemoteHead(pane, posix.join(pane.path, row.rel))
626+
: readLocalHead(join(pane.path, row.rel), DOCUMENT_LIMIT)
627+
if (this.document !== doc) return
628+
fillDocument(doc, head, readsAsMarkdown)
629+
} catch (error) {
630+
if (this.document !== doc) return
631+
doc.loading = false
632+
doc.error = error instanceof Error ? error.message : String(error)
633+
} finally {
634+
this.invalidate()
635+
}
636+
}
637+
638+
private async readRemoteHead(pane: Pane, path: string): Promise<{ bytes: Uint8Array; size: number }> {
639+
const browser = await SftpBrowser.open(await this.session(pane.connection!))
640+
try {
641+
return await browser.readHead(path, DOCUMENT_LIMIT)
642+
} finally {
643+
browser.close()
644+
}
518645
}
519646

520647
// ------------------------------------------------------------------ tree
@@ -552,9 +679,12 @@ export class Tui {
552679
return Promise.resolve(listLocal(join(pane.path, rel)))
553680
}
554681

682+
/** ⏎ unfolds a directory, and opens a file under the panes. */
555683
private async toggleSelected(): Promise<void> {
556684
const row = selectedRow(this.current)
557-
if (row) await this.toggle(this.active, row)
685+
if (!row) return
686+
if (row.entry.isDirectory) await this.toggle(this.active, row)
687+
else await this.viewRow(this.active, row)
558688
}
559689

560690
/** → on a folded directory unfolds it; on an unfolded one it steps onto the first child. */
@@ -677,6 +807,8 @@ export class Tui {
677807
cancel: () => controller.abort(),
678808
}
679809
this.transfer = transfer
810+
// The transfer panel takes the rows the document had.
811+
this.document = null
680812
this.busy = true
681813
this.invalidate()
682814
// rsync can be silent for a long time while it walks a tree; the clock in

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

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { themes } from '@profullstack/hqtui'
3+
import { spanText } from '@profullstack/hqtui'
4+
import { documentLines, maxScroll, readsAsMarkdown } from './document.js'
5+
import { blankDocument, fillDocument, type Document } from './model.js'
6+
7+
const theme = themes.dark
8+
9+
function doc(name: string, text: string, size = Buffer.byteLength(text)): Document {
10+
const d = blankDocument('left', name, `/x/${name}`)
11+
fillDocument(d, { bytes: Buffer.from(text), size }, readsAsMarkdown)
12+
return d
13+
}
14+
15+
describe('rendering a document', () => {
16+
it('renders markdown through readm3, with roles turned into theme colours', () => {
17+
const lines = documentLines(doc('README.md', '# Title\n\nSome **bold** text.\n'), 60, theme)
18+
const text = lines.map(spanText)
19+
expect(text[0]).toBe('Title')
20+
expect(lines[0]?.[0]?.fg).toEqual(theme.title)
21+
expect(text.some((line) => line.includes('Some bold text.'))).toBe(true)
22+
const bold = lines.flat().find((span) => span.text === 'bold')
23+
expect(bold?.bold).toBe(true)
24+
})
25+
26+
it('shows text as numbered lines, tabs widened, and no phantom last line', () => {
27+
const lines = documentLines(doc('a.ts', 'const a = 1\n\tconst b = 2\n'), 60, theme).map(spanText)
28+
expect(lines).toEqual(['1 │ const a = 1', '2 │ const b = 2'])
29+
})
30+
31+
it('shows a binary as a hex dump', () => {
32+
const lines = documentLines(doc('a.bin', 'AB\0C'), 60, theme).map(spanText)
33+
expect(lines).toEqual(['00000000 41 42 00 43 |AB.C|'])
34+
})
35+
36+
it('caches by width, and re-renders when the width changes', () => {
37+
const d = doc('README.md', '# Title\n\n' + 'word '.repeat(40) + '\n')
38+
const narrow = documentLines(d, 40, theme)
39+
expect(documentLines(d, 40, theme)).toBe(narrow)
40+
const wide = documentLines(d, 120, theme)
41+
expect(wide).not.toBe(narrow)
42+
expect(wide.length).toBeLessThan(narrow.length)
43+
})
44+
45+
it('never scrolls past the last screenful', () => {
46+
expect(maxScroll(100, 20)).toBe(80)
47+
expect(maxScroll(10, 20)).toBe(0)
48+
expect(maxScroll(5, 0)).toBe(4)
49+
})
50+
})
51+
52+
describe('what an extensionless file is', () => {
53+
it('takes a README that reads as markdown for markdown', () => {
54+
expect(readsAsMarkdown('# Notes\n\n- one\n- two\n\nSee [the docs](https://example.com).\n')).toBe(true)
55+
})
56+
57+
it('leaves a shell script alone', () => {
58+
expect(readsAsMarkdown('#!/bin/sh\nset -e\nfor f in *; do echo "$f"; done\n')).toBe(false)
59+
})
60+
})

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

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/**
2+
* A document as lines the frame can draw.
3+
*
4+
* Markdown is rendered by readm3 — the same renderer readm3.com and the
5+
* `readm3` reader use — which hands back lines of spans carrying a semantic
6+
* role (`h1`, `code`, `link`…) rather than a colour, and its own `colorOf`
7+
* turns each role into a colour of the theme in use. Anything else that is
8+
* text is shown as numbered lines, and a binary as a hex dump of its head.
9+
*
10+
* Pure: a document and a width in, span lines out. Nothing here touches a
11+
* terminal, so a rendered document is assertable the way a frame is.
12+
*/
13+
import type { SpanLine, Theme } from '@profullstack/hqtui'
14+
import { colorOf, renderMarkdown, type Span as MarkdownSpan } from '@profullstack/readm3'
15+
import { detectTextFormat } from '@profullstack/text-type-detection'
16+
import { type Document, hexDump } from './model.js'
17+
18+
/** Spaces a tab stands for. Code shows in terminals at eight; four reads better in a pane. */
19+
const TAB = ' '
20+
21+
/**
22+
* A second opinion on an extensionless file: does the text read as markdown?
23+
*
24+
* `README`, `NOTES`, `TODO` — files people write in markdown and never name
25+
* that way. The detector is the house one, and only its markdown answer is
26+
* trusted; everything else it might say (`code`, `json`) is drawn as text anyway.
27+
*/
28+
export function readsAsMarkdown(text: string): boolean {
29+
return detectTextFormat(text).text_format === 'markdown'
30+
}
31+
32+
function fromMarkdown(span: MarkdownSpan, theme: Theme): SpanLine[number] {
33+
return {
34+
text: span.text,
35+
fg: colorOf(span, theme),
36+
...(span.bold ? { bold: true } : {}),
37+
...(span.italic ? { italic: true } : {}),
38+
...(span.underline ? { underline: true } : {}),
39+
...(span.dim ? { dim: true } : {}),
40+
}
41+
}
42+
43+
function render(doc: Document, width: number, theme: Theme): SpanLine[] {
44+
if (doc.kind === 'markdown') {
45+
return renderMarkdown(doc.text, Math.max(20, width)).map((line) => line.spans.map((span) => fromMarkdown(span, theme)))
46+
}
47+
if (doc.kind === 'binary') {
48+
return hexDump(doc.bytes).map((row) => [
49+
{ text: row.slice(0, 8), fg: theme.muted },
50+
{ text: row.slice(8), fg: theme.foreground },
51+
])
52+
}
53+
const lines = doc.text.split('\n')
54+
// A trailing newline is how a text file ends, not an empty last line.
55+
if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop()
56+
const gutter = String(lines.length).length
57+
return lines.map((line, index) => [
58+
{ text: `${String(index + 1).padStart(gutter)} `, fg: theme.muted },
59+
{ text: '│ ', fg: theme.border },
60+
{ text: line.replaceAll('\t', TAB), fg: theme.foreground },
61+
])
62+
}
63+
64+
/**
65+
* Rendered lines are cached per document and width. A megabyte of markdown
66+
* must not be parsed again on every frame, and a frame is drawn on every
67+
* mouse move.
68+
*/
69+
const rendered = new WeakMap<Document, { width: number; theme: string; lines: SpanLine[] }>()
70+
71+
export function documentLines(doc: Document, width: number, theme: Theme): SpanLine[] {
72+
const hit = rendered.get(doc)
73+
if (hit && hit.width === width && hit.theme === theme.name) return hit.lines
74+
const lines = render(doc, width, theme)
75+
rendered.set(doc, { width, theme: theme.name, lines })
76+
return lines
77+
}
78+
79+
/** The furthest a document of `total` lines can scroll in `rows` rows. */
80+
export function maxScroll(total: number, rows: number): number {
81+
return Math.max(0, total - Math.max(1, rows))
82+
}

0 commit comments

Comments
 (0)