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: 2 additions & 0 deletions apps/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
"@diskpush/schemas": "workspace:*",
"@diskpush/ssh-core": "workspace:*",
"@profullstack/hqtui": "^0.5.1",
"@profullstack/readm3": "^0.3.0",
"@profullstack/text-type-detection": "^1.0.0",
"zod": "^3.24.1"
}
}
134 changes: 133 additions & 1 deletion apps/cli/src/tui/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { SftpBrowser, SshSession } from '@diskpush/ssh-core'
import { defaultRsyncOptions, type Change, type Connection } from '@diskpush/schemas'
import { parseEndpoint, planTransfer, runToCompletion } from '@diskpush/rsync-core'
import {
type Document,
type EndpointChoice,
type Entry,
type Overlay,
Expand All @@ -26,19 +27,25 @@ import {
type SortKey,
SORT_KEYS,
type Transfer,
DOCUMENT_LIMIT,
blankDocument,
blankPane,
clampIndex,
endpointString,
fillDocument,
listLocal,
nothingToDo,
parentPath,
pushChange,
readLocalHead,
resetTree,
scannedFrom,
scopeTransfer,
selectedRow,
visibleRows,
} from './model.js'
import { type Action, type Tone, type ViewState, draw, filterChoices } from './view.js'
import { maxScroll, readsAsMarkdown } from './document.js'

export {
blankPane,
Expand All @@ -59,6 +66,10 @@ export class Tui {
/** Whatever is on screen instead of the panes, and owns the keyboard while it is. */
private overlay: Overlay | null = null
private transfer: Transfer | null = null
/** The file open under the panes, if any. */
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 }
private filtering: Side | null = null
private status: { text: string; tone: Tone } | null = null
private busy = false
Expand Down Expand Up @@ -96,6 +107,7 @@ export class Tui {
active: this.active,
overlay: this.overlay,
transfer: this.transfer,
document: this.document,
filtering: this.filtering,
status: this.status,
choices: this.choices,
Expand All @@ -118,6 +130,8 @@ export class Tui {
clampIndex(pane)
const row = selectedRow(pane)
if (row?.entry.isDirectory && !this.busy) void this.toggle(side, row)
// With the viewer open, a click on a file is a request to see it.
else if (row && this.document) void this.viewRow(side, row)
this.invalidate()
},
onGoUp: (side) => {
Expand Down Expand Up @@ -155,6 +169,13 @@ export class Tui {
onHostKeyDecide: (trust) => {
if (this.overlay?.kind === 'hostKey') this.overlay.decide(trust)
},
onDocumentScroll: (delta) => {
this.scrollDocument(delta * WHEEL_ROWS)
this.invalidate()
},
onDocumentLayout: (total, rows) => {
this.documentLayout = { total, rows }
},
})
}

Expand Down Expand Up @@ -210,6 +231,12 @@ export class Tui {
case 'endpoint':
if (!this.busy) this.openPicker()
break
case 'view':
if (!this.busy) await this.toggleDocument()
break
case 'closeDocument':
this.document = null
break
case 'preview':
if (!this.busy) await this.transferTo(true)
break
Expand Down Expand Up @@ -379,10 +406,17 @@ export class Tui {
this.dismissTransfer()
return true
}
// Then to the open file: it is the thing on top, and closing it is
// what escape means while it is there.
if (this.document) {
this.document = null
return true
}
return false
}
if (key.name === 'q') return false
if (this.busy) return true
if (this.document && this.onDocumentKey(key)) return true

const pane = this.current
const page = Math.max(1, (this.app?.height ?? 30) - 10)
Expand Down Expand Up @@ -421,6 +455,9 @@ export class Tui {
case key.name === 'c':
this.openPicker()
break
case key.char === 'v':
await this.toggleDocument()
break
case key.name === 'r':
await this.refresh(this.active)
break
Expand Down Expand Up @@ -515,6 +552,96 @@ export class Tui {
const pane = this.current
const last = Math.max(0, visibleRows(pane).length - 1)
pane.index = Math.min(last, Math.max(0, pane.index + delta))
this.followCursor()
}

// ------------------------------------------------------------- documents

/**
* The keys the open file takes: paging and jumping.
*
* Not the arrows. Those stay with the panes so the viewer can follow the
* cursor from one file to the next, which is what makes it a preview rather
* than a detour. Returns false for a key that is not the viewer's.
*/
private onDocumentKey(key: KeyEvent): boolean {
const rows = this.documentLayout.rows
if (key.name === 'pagedown' || key.name === 'space' || key.char === ' ') this.scrollDocument(rows)
else if (key.name === 'pageup' || key.char === 'b') this.scrollDocument(-rows)
else if (key.name === 'home' || key.char === 'g') this.scrollDocument(-Infinity)
else if (key.name === 'end' || key.char === 'G') this.scrollDocument(Infinity)
else return false
return true
}

private scrollDocument(delta: number): void {
const doc = this.document
if (!doc) return
const furthest = maxScroll(this.documentLayout.total, this.documentLayout.rows)
doc.scroll = Math.min(furthest, Math.max(0, doc.scroll + delta))
}

/** With a file open, the cursor landing on another file shows that one instead. */
private followCursor(): void {
if (!this.document) return
const row = selectedRow(this.current)
if (row && !row.entry.isDirectory) void this.viewRow(this.active, row)
}

/** `v`: the file under the cursor opens under the panes, or the open one closes. */
private async toggleDocument(): Promise<void> {
if (this.document) {
this.document = null
return
}
const row = selectedRow(this.current)
if (!row) return
if (row.entry.isDirectory) {
this.say('v views a file; ⏎ unfolds a directory', 'warn')
return
}
await this.viewRow(this.active, row)
}

/**
* Reads the head of a file and shows it under the panes.
*
* Only the head — see DOCUMENT_LIMIT. Nobody waits on the read: the cursor
* keeps moving, and a head that lands after the viewer has moved on to
* another file, or has been closed, is dropped rather than shown.
*/
private async viewRow(side: Side, row: Row): Promise<void> {
const pane = this.panes[side]
const location = endpointString(pane, row.rel, false)
if (this.document?.location === location && !this.document.error) return

const doc = blankDocument(side, row.entry.name, location)
this.document = doc
// A finished transfer's panel and the document want the same rows.
if (this.transfer && !this.transfer.running) this.transfer = null
this.invalidate()
try {
const head = pane.connection
? await this.readRemoteHead(pane, posix.join(pane.path, row.rel))
: readLocalHead(join(pane.path, row.rel), DOCUMENT_LIMIT)
if (this.document !== doc) return
fillDocument(doc, head, readsAsMarkdown)
} catch (error) {
if (this.document !== doc) return
doc.loading = false
doc.error = error instanceof Error ? error.message : String(error)
} finally {
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 {
return await browser.readHead(path, DOCUMENT_LIMIT)
} finally {
browser.close()
}
}

// ------------------------------------------------------------------ tree
Expand Down Expand Up @@ -552,9 +679,12 @@ export class Tui {
return Promise.resolve(listLocal(join(pane.path, rel)))
}

/** ⏎ unfolds a directory, and opens a file under the panes. */
private async toggleSelected(): Promise<void> {
const row = selectedRow(this.current)
if (row) await this.toggle(this.active, row)
if (!row) return
if (row.entry.isDirectory) await this.toggle(this.active, row)
else await this.viewRow(this.active, row)
}

/** → on a folded directory unfolds it; on an unfolded one it steps onto the first child. */
Expand Down Expand Up @@ -677,6 +807,8 @@ export class Tui {
cancel: () => controller.abort(),
}
this.transfer = transfer
// The transfer panel takes the rows the document had.
this.document = null
this.busy = true
this.invalidate()
// rsync can be silent for a long time while it walks a tree; the clock in
Expand Down
60 changes: 60 additions & 0 deletions apps/cli/src/tui/document.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest'
import { themes } from '@profullstack/hqtui'
import { spanText } from '@profullstack/hqtui'
import { documentLines, maxScroll, readsAsMarkdown } from './document.js'
import { blankDocument, fillDocument, type Document } from './model.js'

const theme = themes.dark

function doc(name: string, text: string, size = Buffer.byteLength(text)): Document {
const d = blankDocument('left', name, `/x/${name}`)
fillDocument(d, { bytes: Buffer.from(text), size }, readsAsMarkdown)
return d
}

describe('rendering a document', () => {
it('renders markdown through readm3, with roles turned into theme colours', () => {
const lines = documentLines(doc('README.md', '# Title\n\nSome **bold** text.\n'), 60, theme)
const text = lines.map(spanText)
expect(text[0]).toBe('Title')
expect(lines[0]?.[0]?.fg).toEqual(theme.title)
expect(text.some((line) => line.includes('Some bold text.'))).toBe(true)
const bold = lines.flat().find((span) => span.text === 'bold')
expect(bold?.bold).toBe(true)
})

it('shows text as numbered lines, tabs widened, and no phantom last line', () => {
const lines = documentLines(doc('a.ts', 'const a = 1\n\tconst b = 2\n'), 60, theme).map(spanText)
expect(lines).toEqual(['1 │ const a = 1', '2 │ const b = 2'])
})

it('shows a binary as a hex dump', () => {
const lines = documentLines(doc('a.bin', 'AB\0C'), 60, theme).map(spanText)
expect(lines).toEqual(['00000000 41 42 00 43 |AB.C|'])
})

it('caches by width, and re-renders when the width changes', () => {
const d = doc('README.md', '# Title\n\n' + 'word '.repeat(40) + '\n')
const narrow = documentLines(d, 40, theme)
expect(documentLines(d, 40, theme)).toBe(narrow)
const wide = documentLines(d, 120, theme)
expect(wide).not.toBe(narrow)
expect(wide.length).toBeLessThan(narrow.length)
})

it('never scrolls past the last screenful', () => {
expect(maxScroll(100, 20)).toBe(80)
expect(maxScroll(10, 20)).toBe(0)
expect(maxScroll(5, 0)).toBe(4)
})
})

describe('what an extensionless file is', () => {
it('takes a README that reads as markdown for markdown', () => {
expect(readsAsMarkdown('# Notes\n\n- one\n- two\n\nSee [the docs](https://example.com).\n')).toBe(true)
})

it('leaves a shell script alone', () => {
expect(readsAsMarkdown('#!/bin/sh\nset -e\nfor f in *; do echo "$f"; done\n')).toBe(false)
})
})
82 changes: 82 additions & 0 deletions apps/cli/src/tui/document.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* A document as lines the frame can draw.
*
* Markdown is rendered by readm3 — the same renderer readm3.com and the
* `readm3` reader use — which hands back lines of spans carrying a semantic
* role (`h1`, `code`, `link`…) rather than a colour, and its own `colorOf`
* turns each role into a colour of the theme in use. Anything else that is
* text is shown as numbered lines, and a binary as a hex dump of its head.
*
* Pure: a document and a width in, span lines out. Nothing here touches a
* terminal, so a rendered document is assertable the way a frame is.
*/
import type { SpanLine, Theme } from '@profullstack/hqtui'
import { colorOf, renderMarkdown, type Span as MarkdownSpan } from '@profullstack/readm3'
import { detectTextFormat } from '@profullstack/text-type-detection'
import { type Document, hexDump } from './model.js'

/** Spaces a tab stands for. Code shows in terminals at eight; four reads better in a pane. */
const TAB = ' '

/**
* A second opinion on an extensionless file: does the text read as markdown?
*
* `README`, `NOTES`, `TODO` — files people write in markdown and never name
* that way. The detector is the house one, and only its markdown answer is
* trusted; everything else it might say (`code`, `json`) is drawn as text anyway.
*/
export function readsAsMarkdown(text: string): boolean {
return detectTextFormat(text).text_format === 'markdown'
}

function fromMarkdown(span: MarkdownSpan, theme: Theme): SpanLine[number] {
return {
text: span.text,
fg: colorOf(span, theme),
...(span.bold ? { bold: true } : {}),
...(span.italic ? { italic: true } : {}),
...(span.underline ? { underline: true } : {}),
...(span.dim ? { dim: true } : {}),
}
}

function render(doc: Document, width: number, theme: Theme): SpanLine[] {
if (doc.kind === 'markdown') {
return renderMarkdown(doc.text, Math.max(20, width)).map((line) => line.spans.map((span) => fromMarkdown(span, theme)))
}
if (doc.kind === 'binary') {
return hexDump(doc.bytes).map((row) => [
{ text: row.slice(0, 8), fg: theme.muted },
{ text: row.slice(8), fg: theme.foreground },
])
}
const lines = doc.text.split('\n')
// A trailing newline is how a text file ends, not an empty last line.
if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop()
const gutter = String(lines.length).length
return lines.map((line, index) => [
{ text: `${String(index + 1).padStart(gutter)} `, fg: theme.muted },
{ text: '│ ', fg: theme.border },
{ text: line.replaceAll('\t', TAB), fg: theme.foreground },
])
}

/**
* Rendered lines are cached per document and width. A megabyte of markdown
* must not be parsed again on every frame, and a frame is drawn on every
* mouse move.
*/
const rendered = new WeakMap<Document, { width: number; theme: string; lines: SpanLine[] }>()

export function documentLines(doc: Document, width: number, theme: Theme): SpanLine[] {
const hit = rendered.get(doc)
if (hit && hit.width === width && hit.theme === theme.name) return hit.lines
const lines = render(doc, width, theme)
rendered.set(doc, { width, theme: theme.name, lines })
return lines
}

/** The furthest a document of `total` lines can scroll in `rows` rows. */
export function maxScroll(total: number, rows: number): number {
return Math.max(0, total - Math.max(1, rows))
}
Loading
Loading