diff --git a/apps/cli/package.json b/apps/cli/package.json index aea3147..c9a24fe 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -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" } } diff --git a/apps/cli/src/tui/app.ts b/apps/cli/src/tui/app.ts index 79e5daa..decf97e 100644 --- a/apps/cli/src/tui/app.ts +++ b/apps/cli/src/tui/app.ts @@ -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, @@ -26,12 +27,17 @@ import { type SortKey, SORT_KEYS, type Transfer, + DOCUMENT_LIMIT, + blankDocument, blankPane, clampIndex, + endpointString, + fillDocument, listLocal, nothingToDo, parentPath, pushChange, + readLocalHead, resetTree, scannedFrom, scopeTransfer, @@ -39,6 +45,7 @@ import { visibleRows, } from './model.js' import { type Action, type Tone, type ViewState, draw, filterChoices } from './view.js' +import { maxScroll, readsAsMarkdown } from './document.js' export { blankPane, @@ -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 @@ -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, @@ -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) => { @@ -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 } + }, }) } @@ -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 @@ -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) @@ -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 @@ -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 { + 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 { + 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 @@ -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 { 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. */ @@ -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 diff --git a/apps/cli/src/tui/document.test.ts b/apps/cli/src/tui/document.test.ts new file mode 100644 index 0000000..84398cb --- /dev/null +++ b/apps/cli/src/tui/document.test.ts @@ -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) + }) +}) diff --git a/apps/cli/src/tui/document.ts b/apps/cli/src/tui/document.ts new file mode 100644 index 0000000..5601238 --- /dev/null +++ b/apps/cli/src/tui/document.ts @@ -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() + +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)) +} diff --git a/apps/cli/src/tui/interaction.test.ts b/apps/cli/src/tui/interaction.test.ts index 97e461d..c04b3f5 100644 --- a/apps/cli/src/tui/interaction.test.ts +++ b/apps/cli/src/tui/interaction.test.ts @@ -11,11 +11,20 @@ import { describe, expect, it, vi } from 'vitest' import type { App, MouseEvent } from '@profullstack/hqtui' import { renderToScreen } from '@profullstack/hqtui/testing' import { key } from './keys.fixture.js' -import type { Entry, Pane, Side } from './model.js' +import { visibleRows, type Entry, type Pane, type Side } from './model.js' vi.mock('@diskpush/ssh-core', () => ({ SshSession: { connect: async () => ({ close: () => {} }) }, - SftpBrowser: { open: async () => ({ list: async () => [], close: () => {} }) }, + SftpBrowser: { + open: async () => ({ + list: async () => [], + readHead: async (path: string) => { + if (path.endsWith('gone.md')) throw new Error('No such file') + return { bytes: Buffer.from('# Remote\n\nover sftp\n'), size: 4096 } + }, + close: () => {}, + }), + }, })) vi.mock('@diskpush/database', () => ({ knownHostsPath: () => '/tmp/known_hosts.test' })) @@ -473,3 +482,166 @@ describe('the last message', () => { expect(state(app).status).toBeNull() }) }) + +describe('viewing a file', () => { + /** A directory with one of everything the viewer tells apart. */ + function docs() { + const root = mkdtempSync(join(tmpdir(), 'diskpush-view-')) + mkdirSync(join(root, 'src')) + writeFileSync(join(root, 'README.md'), '# Title\n\nHello **world**\n') + writeFileSync(join(root, 'NOTES'), '# Notes\n\n- one\n- two\n\nSee [the docs](https://example.com).\n') + writeFileSync(join(root, 'code.ts'), 'const a = 1\nconst b = 2\n') + writeFileSync(join(root, 'blob.bin'), Buffer.from([0x89, 0x50, 0x4e, 0x47, 0, 1, 2])) + writeFileSync(join(root, 'long.txt'), Array.from({ length: 200 }, (_, i) => `line ${i + 1}`).join('\n') + '\n') + const left = blankPane('Local', root) + left.entries = listLocal(root) + const right = blankPane('Local', '/tmp/b') + const app = new Tui(left, right, []) + return { app, root } + } + /** Puts the cursor on a row by name. */ + const goto = (app: Tui, name: string, side: Side = 'left') => { + const p = pane(app, side) + p.index = visibleRows(p).findIndex((row) => row.entry.name === name) + expect(p.index).toBeGreaterThanOrEqual(0) + } + const document = (app: Tui) => state(app).document + + it('v opens the file under the cursor, rendered under the panes, and v closes it', async () => { + const { app, root } = docs() + goto(app, 'README.md') + await press(app, 'v') + expect(document(app)?.kind).toBe('markdown') + expect(document(app)?.location).toBe(join(root, 'README.md')) + expect(document(app)?.loading).toBe(false) + const screen = frame(app) + expect(screen.contains('Hello world')).toBe(true) + expect(screen.contains('# Title')).toBe(false) + expect(screen.contains('esc close')).toBe(true) + await press(app, 'v') + expect(document(app)).toBeNull() + }) + + it('⏎ on a file opens it too, and esc closes it before it would quit', async () => { + const { app } = docs() + goto(app, 'code.ts') + await press(app, 'enter') + expect(document(app)?.kind).toBe('text') + expect(frame(app).contains('2 │ const b = 2')).toBe(true) + expect(await app.onKey(key('escape'))).toBe(true) + expect(document(app)).toBeNull() + expect(await app.onKey(key('escape'))).toBe(false) + }) + + it('v on a directory opens nothing and says why', async () => { + const { app } = docs() + goto(app, 'src') + await press(app, 'v') + expect(document(app)).toBeNull() + expect(state(app).status?.text).toContain('v views a file') + }) + + it('tells a binary from text, and reads an extensionless README as markdown', async () => { + const { app } = docs() + goto(app, 'blob.bin') + await press(app, 'v') + expect(document(app)?.kind).toBe('binary') + expect(frame(app).contains('00000000 89 50 4e 47 00 01 02')).toBe(true) + goto(app, 'NOTES') + await press(app, 'v', 'v') + expect(document(app)?.kind).toBe('markdown') + expect(frame(app).contains('• one')).toBe(true) + }) + + it('follows the cursor from file to file, and stays put over a directory', async () => { + const { app } = docs() + goto(app, 'code.ts') + await press(app, 'v') + expect(document(app)?.name).toBe('code.ts') + // Files sort by name after the one directory: src, blob.bin, code.ts, long.txt, NOTES, README.md. + await press(app, 'down') + await settle() + expect(document(app)?.name).toBe('long.txt') + await press(app, 'up', 'up') + await settle() + expect(document(app)?.name).toBe('blob.bin') + // `src` sorts first: the cursor is on a directory and the viewer keeps the last file. + await press(app, 'up') + await settle() + expect(pane(app, 'left').index).toBe(0) + expect(document(app)?.name).toBe('blob.bin') + }) + + it('a click on another file switches the viewer to it', async () => { + const { app } = docs() + goto(app, 'code.ts') + await press(app, 'v') + const screen = frame(app) + const readme = screen.find('README.md')! + screen.click(readme.x, readme.y) + await settle() + expect(document(app)?.name).toBe('README.md') + }) + + it('pages with pgdn, pgup, g and G while the arrows still move the cursor', async () => { + const { app } = docs() + goto(app, 'long.txt') + await press(app, 'v') + // A frame tells the app how many rows the document has. + frame(app) + await press(app, 'pagedown') + expect(document(app)?.scroll).toBe(16) + await press(app, 'G') + expect(document(app)?.scroll).toBe(200 - 16) + await press(app, 'pageup') + expect(document(app)?.scroll).toBe(200 - 32) + await press(app, 'g') + expect(document(app)?.scroll).toBe(0) + const before = pane(app, 'left').index + await press(app, 'up') + expect(pane(app, 'left').index).toBe(before - 1) + }) + + it('scrolls with the wheel over the document, and clamps at the end', async () => { + const { app } = docs() + goto(app, 'long.txt') + await press(app, 'v') + let screen = frame(app) + const line = screen.find('line 3')! + expect(screen.scroll(line.x, line.y, 1)).toBe(true) + expect(document(app)?.scroll).toBe(3) + for (let i = 0; i < 200; i += 1) screen.scroll(line.x, line.y, 1) + expect(document(app)?.scroll).toBe(200 - 16) + screen = frame(app) + expect(screen.contains('200 │ line 200')).toBe(true) + }) + + it('reads a remote file over sftp, and shows the error when it cannot', async () => { + const connection = { id: 'prod', name: 'prod', host: 'prod.example', port: 22, username: 'deploy' } + const left = blankPane('prod', '/srv/app', connection as never) + left.entries = [ + { name: 'README.md', isDirectory: false, size: 4096, modifiedAt: null }, + { name: 'gone.md', isDirectory: false, size: 1, modifiedAt: null }, + ] + const app = new Tui(left, blankPane('Local', '/tmp/b'), []) + goto(app, 'README.md') + await press(app, 'v') + expect(document(app)?.kind).toBe('markdown') + expect(document(app)?.location).toBe('deploy@prod.example:/srv/app/README.md') + expect(frame(app).contains('over sftp')).toBe(true) + // `gone.md` sorts before it. + await press(app, 'up') + await settle() + expect(document(app)?.error).toBe('No such file') + expect(frame(app).contains('No such file')).toBe(true) + }) + + it('a preview takes the rows back', async () => { + const { app } = docs() + goto(app, 'code.ts') + await press(app, 'v') + await press(app, 'p') + expect(document(app)).toBeNull() + expect(state(app).transfer).not.toBeNull() + }) +}) diff --git a/apps/cli/src/tui/model.test.ts b/apps/cli/src/tui/model.test.ts index 91a8c94..09a9a12 100644 --- a/apps/cli/src/tui/model.test.ts +++ b/apps/cli/src/tui/model.test.ts @@ -1,3 +1,6 @@ +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { type Entry, @@ -10,8 +13,7 @@ import { pushChange, selectedEntry, visibleEntries, - TRANSFER_LOG_LIMIT, -} from './model.js' + TRANSFER_LOG_LIMIT, DOCUMENT_LIMIT, blankDocument, decodeText, fillDocument, hexDump, isMarkdownName, isTruncated, looksBinary, readLocalHead } from './model.js' import type { Transfer } from './model.js' const entry = (name: string, over: Partial = {}): Entry => ({ @@ -142,3 +144,68 @@ describe('the transfer log', () => { expect(t.recent.at(-1)?.path).toBe(`file-${TRANSFER_LOG_LIMIT + 49}`) }) }) + +describe('documents', () => { + const doc = (name: string, bytes: Uint8Array | string, size?: number, byContent = () => false) => { + const d = blankDocument('left', name, `/x/${name}`) + const raw = typeof bytes === 'string' ? Buffer.from(bytes) : bytes + fillDocument(d, { bytes: raw, size: size ?? raw.length }, byContent) + return d + } + + it('knows markdown by its extension, the way readm3 does', () => { + for (const name of ['README.md', 'notes.markdown', 'a.MD', 'page.mdx']) expect(isMarkdownName(name)).toBe(true) + for (const name of ['README', 'a.ts', 'md', 'a.md.bak']) expect(isMarkdownName(name)).toBe(false) + }) + + it('calls a head with a NUL in it binary, and text otherwise', () => { + expect(looksBinary(Buffer.from('hello\n'))).toBe(false) + expect(looksBinary(Buffer.from('hel\0lo'))).toBe(true) + expect(looksBinary(new Uint8Array())).toBe(false) + // Tabs, newlines and colour escapes are text; a head of other control bytes is not. + expect(looksBinary(Buffer.from('\t\x1b[31mred\x1b[0m\r\n'))).toBe(false) + expect(looksBinary(Buffer.from([1, 2, 3, 4, 5, 6, 7, 8, 65, 66]))).toBe(true) + // Noise without a NUL is still not text, and Latin-1 prose still is. + expect(looksBinary(Uint8Array.from({ length: 300 }, (_, i) => 0x80 + ((i * 37) % 0x7f)))).toBe(true) + expect(looksBinary(Buffer.from('Le caf\xe9 est pr\xeat, dit-elle, et la journ\xe9e commence.', 'latin1'))).toBe(false) + expect(looksBinary(Buffer.from('Ünïcödé is fine, and so is 日本語 and emoji 🎉.'))).toBe(false) + }) + + it('decodes text, dropping a BOM and folding Windows line ends', () => { + expect(decodeText(Buffer.from('a\r\nb\rc\n'))).toBe('a\nb\nc\n') + }) + + it('dumps hex sixteen bytes to a row, gapped after eight, printable bytes alongside', () => { + const rows = hexDump(Buffer.from('Hello, world!\n\0\xff!')) + expect(rows).toEqual([ + '00000000 48 65 6c 6c 6f 2c 20 77 6f 72 6c 64 21 0a 00 c3 |Hello, world!...|', + '00000010 bf 21 |.!|', + ]) + expect(hexDump(new Uint8Array(10_000)).length).toBe(4096 / 16) + }) + + it('decides the kind by name first, and by content only for a file with no extension', () => { + expect(doc('README.md', 'plain words').kind).toBe('markdown') + expect(doc('a.ts', '# not a heading').kind).toBe('text') + expect(doc('README', '# Notes', undefined, () => true).kind).toBe('markdown') + expect(doc('a.txt', '# Notes', undefined, () => true).kind).toBe('text') + expect(doc('a.md', 'x\0y').kind).toBe('binary') + }) + + it('knows when it holds only the head of a file', () => { + expect(isTruncated(doc('big.log', 'first bytes', 5_000_000))).toBe(true) + expect(isTruncated(doc('small.log', 'all of it'))).toBe(false) + }) + + it('reads only the head of a local file, and reports the whole size', () => { + const dir = mkdtempSync(join(tmpdir(), 'diskpush-doc-')) + const path = join(dir, 'big.txt') + writeFileSync(path, 'x'.repeat(5000)) + const head = readLocalHead(path, 100) + expect(head.bytes.length).toBe(100) + expect(head.size).toBe(5000) + const whole = readLocalHead(path, DOCUMENT_LIMIT) + expect(whole.bytes.length).toBe(5000) + expect(DOCUMENT_LIMIT).toBe(1024 * 1024) + }) +}) diff --git a/apps/cli/src/tui/model.ts b/apps/cli/src/tui/model.ts index b6fc8d2..33ebe93 100644 --- a/apps/cli/src/tui/model.ts +++ b/apps/cli/src/tui/model.ts @@ -5,7 +5,7 @@ * snapshot of it and the app mutates it, which is what makes both testable * without a pty. */ -import { readdirSync, statSync } from 'node:fs' +import { closeSync, fstatSync, openSync, readSync, readdirSync, statSync } from 'node:fs' import { homedir } from 'node:os' import { dirname, join, posix } from 'node:path' import type { Change, ChangeSummary, Connection, RsyncProgress } from '@diskpush/schemas' @@ -372,3 +372,168 @@ export function formatDuration(seconds: number): string { if (minutes >= 60) return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}m` return `${minutes}:${String(total % 60).padStart(2, '0')}` } + +// ---------------------------------------------------------------- documents + +/** + * How much of a file the viewer reads: the first megabyte. + * + * Every document anyone reads in a terminal fits; the cap is for what else is + * under a cursor in a file manager — an archive, a database, a video — where + * reading the whole thing would take minutes and show nothing anyway. + */ +export const DOCUMENT_LIMIT = 1024 * 1024 + +/** How a document is drawn: rendered markdown, plain lines, or a hex dump. */ +export type DocumentKind = 'markdown' | 'text' | 'binary' + +/** A file open under the panes. */ +export type Document = { + /** The pane it was opened from. */ + side: Side + name: string + /** Where it is, spelled the way a transfer would: `user@host:/srv/app/README.md` or `/home/me/README.md`. */ + location: string + /** The head is still on its way. */ + loading: boolean + error: string | null + kind: DocumentKind + /** Decoded text for markdown and text; empty for a binary. */ + text: string + /** The raw head, kept for the hex view. */ + bytes: Uint8Array + /** The whole file's size, which can be far more than was read. */ + size: number + /** First rendered line on screen. */ + scroll: number +} + +export function blankDocument(side: Side, name: string, location: string): Document { + return { + side, + name, + location, + loading: true, + error: null, + kind: 'text', + text: '', + bytes: new Uint8Array(), + size: 0, + scroll: 0, + } +} + +/** True when more of the file exists than the viewer read. */ +export function isTruncated(doc: Document): boolean { + return doc.size > doc.bytes.length +} + +/** What readm3 opens: the markdown extensions, matched the way it matches them. */ +const MARKDOWN_NAME = /\.(?:md|markdown|mdown|mkd|mkdn|mdwn|mdx)$/i + +export function isMarkdownName(name: string): boolean { + return MARKDOWN_NAME.test(name) +} + +/** How many leading bytes decide text against binary. */ +const SNIFF = 8192 + +const utf8 = new TextDecoder('utf-8', { fatal: false }) + +/** + * A NUL in the head is the test `grep` and `git` use, and it is right far more + * often than any cleverer one: text encodings do not emit NUL, and nearly every + * binary format does within its first few kilobytes. + * + * Not every one, though. Three hundred random bytes have a one-in-three chance + * of holding no NUL at all, and a small compressed or encrypted file shown as + * "text" is a panel of garbage. So beyond NUL, a head is binary when a tenth + * of it is either a control byte that is not whitespace or a byte that is not + * UTF-8 — the decoder's replacement characters count those. A Latin-1 file + * with an accent every few words stays text; noise does not. + */ +export function looksBinary(bytes: Uint8Array): boolean { + const head = bytes.subarray(0, SNIFF) + if (head.length === 0) return false + let suspect = 0 + for (const byte of head) { + if (byte === 0) return true + // Tab, newline, carriage return, form feed and escape are text. + if (byte < 0x20 && byte !== 0x09 && byte !== 0x0a && byte !== 0x0d && byte !== 0x0c && byte !== 0x1b) suspect += 1 + } + for (const char of utf8.decode(head)) if (char === '\ufffd') suspect += 1 + return suspect / head.length > 0.1 +} + +/** Bytes as text: UTF-8, a BOM dropped, Windows line ends folded. */ +export function decodeText(bytes: Uint8Array): string { + const text = utf8.decode(bytes) + return (text.charCodeAt(0) === 0xfeff ? text.slice(1) : text).replace(/\r\n?/g, '\n') +} + +/** Bytes per row of the hex view. */ +export const HEX_ROW = 16 + +/** + * A hex dump of the head, one row per sixteen bytes: offset, the bytes, and + * the printable ones as characters. `xxd` without the `xxd`. + */ +export function hexDump(bytes: Uint8Array, limit = 4096): string[] { + const rows: string[] = [] + const end = Math.min(bytes.length, limit) + for (let at = 0; at < end; at += HEX_ROW) { + const chunk = bytes.subarray(at, Math.min(at + HEX_ROW, end)) + const hex = [...chunk].map((byte) => byte.toString(16).padStart(2, '0')) + // A gap after the eighth byte, as every hex dump since `od` has drawn it. + const left = hex.slice(0, 8).join(' ') + const right = hex.slice(8).join(' ') + const ascii = [...chunk].map((byte) => (byte >= 0x20 && byte < 0x7f ? String.fromCharCode(byte) : '.')).join('') + rows.push(`${at.toString(16).padStart(8, '0')} ${(left + ' ' + right).padEnd(HEX_ROW * 3 + 1)} |${ascii}|`) + } + return rows +} + +/** + * Fills a document in from the head that was read for it. + * + * `markdownByContent` is a second opinion for a file whose name says nothing + * — `README`, `NOTES`, a file with no extension — so the kind is decided by + * the name first and by the text only when the name is silent. + */ +export function fillDocument( + doc: Document, + head: { bytes: Uint8Array; size: number }, + markdownByContent: (text: string) => boolean = () => false, +): void { + doc.bytes = head.bytes + doc.size = head.size + doc.loading = false + doc.error = null + doc.scroll = 0 + if (looksBinary(head.bytes)) { + doc.kind = 'binary' + doc.text = '' + return + } + doc.text = decodeText(head.bytes) + doc.kind = + isMarkdownName(doc.name) || (!doc.name.includes('.') && markdownByContent(doc.text)) ? 'markdown' : 'text' +} + +/** The first `limit` bytes of a local file, and how big the whole file is. */ +export function readLocalHead(path: string, limit: number): { bytes: Uint8Array; size: number } { + const fd = openSync(path, 'r') + try { + const size = fstatSync(fd).size + const bytes = Buffer.alloc(Math.max(0, Math.min(size, limit))) + let read = 0 + while (read < bytes.length) { + const count = readSync(fd, bytes, read, bytes.length - read, read) + if (count === 0) break + read += count + } + return { bytes: bytes.subarray(0, read), size } + } finally { + closeSync(fd) + } +} diff --git a/apps/cli/src/tui/text-type-detection.d.ts b/apps/cli/src/tui/text-type-detection.d.ts new file mode 100644 index 0000000..af99c7a --- /dev/null +++ b/apps/cli/src/tui/text-type-detection.d.ts @@ -0,0 +1,12 @@ +/** + * `@profullstack/text-type-detection` ships plain JavaScript with no types. + * The one function the viewer uses, declared here so the import checks. + */ +declare module '@profullstack/text-type-detection' { + export type TextFormat = 'markdown' | 'plain' | 'ascii' | 'code' | 'html' | 'json' | 'xml' + export function detectTextFormat(text: string): { + text_format: TextFormat + reasons: string[] + stats: Record + } +} diff --git a/apps/cli/src/tui/view.test.ts b/apps/cli/src/tui/view.test.ts index 6c04840..e0982c0 100644 --- a/apps/cli/src/tui/view.test.ts +++ b/apps/cli/src/tui/view.test.ts @@ -9,8 +9,8 @@ */ import { describe, expect, it } from 'vitest' import { renderToScreen, renderToText } from '@profullstack/hqtui/testing' -import { blankPane, type Entry, type Pane, type Transfer } from './model.js' -import { type Action, type ViewHandlers, type ViewState, draw, filterChoices, truncatePath } from './view.js' +import { blankDocument, blankPane, fillDocument, type Document, type Entry, type Pane, type Transfer } from './model.js' +import { type Action, type ViewHandlers, type ViewState, draw, filterChoices, paneRowsBeside, truncatePath } from './view.js' const entry = (name: string, over: Partial = {}): Entry => ({ name, @@ -36,6 +36,7 @@ function state(over: Partial = {}): ViewState { active: 'left', overlay: null, transfer: null, + document: null, filtering: null, status: null, choices: [ @@ -503,3 +504,130 @@ describe('the help overlay', () => { expect(text).toContain('Mirror') }) }) + +describe('a file open under the panes', () => { + const render = (s: ViewState, handlers: ViewHandlers = {}) => + renderToScreen(({ ui, theme }) => draw(ui, theme, 100, 30, s, handlers), { + width: 100, + height: 30, + collapseBorders: true, + }) + + function document(name: string, text: string, over: Partial = {}): Document { + const doc = blankDocument('left', name, `/home/me/project/${name}`) + fillDocument(doc, { bytes: Buffer.from(text), size: Buffer.byteLength(text) }) + return { ...doc, ...over } + } + + it('offers v in the footer', () => { + expect(screen(state())).toContain('v view') + }) + + it('draws the document under both panes, rendered, with its name and where it lives', () => { + const text = screen(state({ document: document('README.md', '# DiskPush\n\nPush files fast.\n') })) + const lines = text.split('\n') + expect(text).toContain('README.md') + expect(text).toContain('/home/me/project/README.md') + // The panes are still there, above it, and the heading is rendered rather than shown as `# DiskPush`. + expect(text).toContain('/srv/app') + expect(text).toContain('DiskPush') + expect(text).not.toContain('# DiskPush') + expect(text).toContain('Push files fast.') + expect(text).toContain('markdown') + // The panes keep a fixed share and the document takes the rest. + const paneBottom = lines.findIndex((line) => line.includes('items')) + expect(paneBottom).toBe(paneRowsBeside(30)) + expect(lines[paneBottom + 1]).toContain('README.md') + }) + + it('leaves the panes a listing on a short terminal, and the document more than a title', () => { + expect(paneRowsBeside(30)).toBe(10) + expect(paneRowsBeside(18)).toBe(6) + expect(paneRowsBeside(12)).toBe(4) + const text = screen(state({ document: document('a.ts', 'const a = 1\n') }), 80, 14) + expect(text).toContain('1 │ const a = 1') + expect(text).toContain('a.ts') + }) + + it('numbers text, and dumps a binary as hex', () => { + expect(screen(state({ document: document('a.ts', 'const a = 1\nconst b = 2\n') }))).toContain('2 │ const b = 2') + const text = screen(state({ document: document('a.bin', 'AB\0C') })) + expect(text).toContain('00000000 41 42 00 43') + expect(text).toContain('|AB.C|') + expect(text).toContain('binary') + }) + + it('says where in the file you are, and how much of a big file was read', () => { + const long = Array.from({ length: 100 }, (_, i) => `line ${i + 1}`).join('\n') + '\n' + let text = screen(state({ document: document('big.log', long) })) + expect(text).toContain('lines 1–16 of 100') + expect(text).toContain('1 │ line 1') + text = screen(state({ document: document('big.log', long, { scroll: 50 }) })) + expect(text).toContain('lines 51–66 of 100') + expect(text).toContain('51 │ line 51') + expect(text).not.toContain('1 │ line 1 ') + text = screen(state({ document: document('big.log', long, { size: 50 * 1024 * 1024 }) })) + expect(text).toContain('first 792B of 52M') + }) + + it('reports the layout it drew, so the app can clamp a scroll', () => { + const layouts: [number, number][] = [] + const long = Array.from({ length: 100 }, (_, i) => `line ${i + 1}`).join('\n') + '\n' + render(state({ document: document('big.log', long) }), { onDocumentLayout: (total, rows) => layouts.push([total, rows]) }) + expect(layouts).toEqual([[100, 16]]) + }) + + it('scrolls with the wheel over the text', () => { + const scrolled: number[] = [] + const long = Array.from({ length: 100 }, (_, i) => `line ${i + 1}`).join('\n') + '\n' + const s = render(state({ document: document('big.log', long) }), { onDocumentScroll: (delta) => scrolled.push(delta) }) + const line = s.find('line 5')! + expect(s.scroll(line.x, line.y, 1)).toBe(true) + expect(scrolled).toEqual([1]) + }) + + it('says Reading… while the head is on its way, and shows the error when it fails', () => { + const loading = blankDocument('left', 'slow.md', 'deploy@prod:/srv/slow.md') + expect(screen(state({ document: loading }))).toContain('Reading…') + expect(screen(state({ document: { ...loading, loading: false, error: 'Permission denied' } }))).toContain('Permission denied') + }) + + it('swaps the footer for the viewer keys, and the caps still act', () => { + const actions: Action[] = [] + const s = render(state({ document: document('a.ts', 'x\n') }), { onAction: (action) => actions.push(action) }) + const text = s.text() + expect(text).toContain('esc close') + expect(text).toContain('pgdn pgup page') + expect(text).not.toContain('v view') + const close = s.find('esc close')! + s.click(close.x, close.y) + expect(actions).toEqual(['closeDocument']) + }) + + it('takes the rows a finished transfer panel would have had', () => { + const transfer: Transfer = { + mode: 'preview', + from: '/a/', + to: '/b/', + what: 'everything', + running: false, + startedAt: 0, + endedAt: 1000, + progress: null, + scanned: null, + recent: [], + summary: { add: 0, update: 0, metadata: 0, delete: 0, unchanged: 0, error: 0 }, + outcome: { ok: true, message: 'Preview complete' }, + cancel: () => {}, + } + const text = screen(state({ transfer, document: document('a.ts', 'x\n') })) + expect(text).toContain('1 │ x') + expect(text).not.toContain('Preview complete') + }) + + it('lists v in the help', () => { + const text = screen(state({ overlay: { kind: 'help' } })) + expect(text).toContain('view it under the panes') + expect(text).toContain('page the open file') + }) +}) diff --git a/apps/cli/src/tui/view.ts b/apps/cli/src/tui/view.ts index 1e082ae..7c1d607 100644 --- a/apps/cli/src/tui/view.ts +++ b/apps/cli/src/tui/view.ts @@ -6,9 +6,10 @@ * text with no pty, and what keeps the app class down to state and effects. */ import type { Color, Container, Theme } from '@profullstack/hqtui' -import { stringWidth, truncate } from '@profullstack/hqtui' +import { stringWidth, truncate, widgets } from '@profullstack/hqtui' import type { Change } from '@diskpush/schemas' import { + type Document, type EndpointChoice, type Overlay, type Pane, @@ -19,11 +20,13 @@ import { formatDuration, formatSize, formatWhen, + isTruncated, nothingToDo, parentPath, rowTree, visibleEntries, } from './model.js' +import { documentLines, maxScroll } from './document.js' /** What hqtui's tree draws: the model's `Row`, spelled the widget's way. */ type TreeNode = { @@ -41,6 +44,8 @@ export type ViewState = { active: Side overlay: Overlay | null transfer: Transfer | null + /** The file open under the panes, if any. */ + document: Document | null /** Which pane's `/` prompt is being typed into, if any. */ filtering: Side | null status: { text: string; tone: Tone } | null @@ -57,6 +62,8 @@ export type Action = | 'pane' | 'open' | 'endpoint' + | 'view' + | 'closeDocument' | 'preview' | 'sync' | 'filter' @@ -91,11 +98,37 @@ export type ViewHandlers = { onDismissOverlay?: () => void /** A click on one of the host-key question's answers. */ onHostKeyDecide?: (trust: boolean) => void + /** The wheel over the open document, or a click on its scrollbar. */ + onDocumentScroll?: (delta: number) => void + /** + * What the frame drew of the document: how many lines it has and how many + * of them fit. Only the frame knows either, and the app needs both to clamp + * a scroll before the next frame rather than after it. + */ + onDocumentLayout?: (total: number, rows: number) => void } /** Rows the transfer panel takes when one is on screen. */ const TRANSFER_HEIGHT = 9 +/** Rows of the document panel that are border rather than document. */ +const DOCUMENT_CHROME = 2 + +/** + * Rows the panes keep while a document is open under them. + * + * The panes are how you got to the file and how you pick the next one, so + * they stay; the document is what you asked to look at, so it gets the larger + * share. A short terminal still leaves the panes enough rows to be a listing, + * and the document enough to be more than a title. + */ +export function paneRowsBeside(height: number): number { + // The header and the footer take a row each. + const usable = height - 2 + const panes = Math.max(6, Math.floor(usable * 0.38)) + return Math.min(panes, Math.max(4, usable - DOCUMENT_CHROME - 4)) +} + const ACTION_LEVEL: Record = { add: 'ADD', update: 'UPD', @@ -154,14 +187,17 @@ export function draw( ): void { drawHeader(ui, theme, state, handlers) - ui.row({ gap: 0, height: 'fill' }, (row) => { + // With a document open the panes give up most of the screen to it, which + // is the ask: the listing stays where it was, and the file opens under it. + ui.row({ gap: 0, height: state.document ? paneRowsBeside(height) : 'fill' }, (row) => { drawPane(row, theme, state, 'left', Math.floor(width / 2), handlers) drawPane(row, theme, state, 'right', Math.floor(width / 2), handlers) }) + if (state.document) drawDocument(ui, theme, state.document, width, height, handlers) // A short terminal gives its rows to the panes; the transfer is still // readable from the status line, and half a panel is worse than none. - if (state.transfer && height >= TRANSFER_HEIGHT + 8) drawTransfer(ui, theme, state, state.transfer, handlers) + else if (state.transfer && height >= TRANSFER_HEIGHT + 8) drawTransfer(ui, theme, state, state.transfer, handlers) drawFooter(ui, theme, state, width, handlers) @@ -313,6 +349,93 @@ function drawPane( ) } +/** + * The open file, under the panes. + * + * Markdown is drawn rendered, text as numbered lines, a binary as a hex dump + * of its head; `documentLines` decides which. The panel's footer says where + * in the file you are and how much of it was read, because the viewer reads a + * head and not the file, and a listing that ends is not the same as a file + * that does. + */ +function drawDocument( + ui: Container, + theme: Theme, + doc: Document, + width: number, + height: number, + handlers: ViewHandlers, +): void { + const title = ` ${doc.name} ` + // 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) + const scroll = Math.min(doc.scroll, maxScroll(lines.length, rows)) + const scrolls = lines.length > rows + handlers.onDocumentLayout?.(lines.length, rows) + + const last = Math.min(lines.length, scroll + rows) + const footerParts = [ + lines.length > 0 ? `lines ${scroll + 1}–${last} of ${lines.length}` : '', + doc.loading || doc.error ? '' : doc.kind, + doc.loading + ? '' + : isTruncated(doc) + ? `first ${formatSize(doc.bytes.length)} of ${formatSize(doc.size)}` + : formatSize(doc.size), + ].filter(Boolean) + + ui.panel( + { + height: 'fill', + title, + titleColor: theme.primary, + subtitle: truncatePath(doc.location, Math.max(12, width - stringWidth(title) - 8)), + subtitleColor: theme.muted, + footer: footerParts.length > 0 ? ` ${footerParts.join(' · ')} ` : undefined, + }, + (panel) => { + if (doc.loading) { + panel.spacer(1) + panel.text('Reading…', { align: 'center', fg: theme.muted }) + return + } + if (doc.error) { + panel.spacer(1) + panel.text(doc.error, { fg: theme.danger, wrap: true, align: 'center' }) + return + } + if (lines.length === 0) { + panel.spacer(1) + panel.text('Empty file', { align: 'center', fg: theme.muted }) + return + } + panel.row({ gap: 0, height: 'fill' }, (row) => { + row.draw( + (surface) => { + widgets.drawText(surface, lines, { scroll }) + // `text` claims no region of its own, so the wheel over the + // document is wired here: the same scroll the bar beside it does. + row.ctx.hit({ rect: surface.hitRect(), onScroll: (delta) => handlers.onDocumentScroll?.(delta) }) + }, + { width: '1fr' }, + ) + // A bar whose thumb fills the track says nothing; a file that fits gets none. + if (scrolls) { + row.scrollbar({ + width: 1, + total: lines.length, + viewport: rows, + offset: scroll, + onScroll: (delta) => handlers.onDocumentScroll?.(delta), + }) + } + }) + }, + ) +} + function drawTransfer(ui: Container, theme: Theme, state: ViewState, transfer: Transfer, handlers: ViewHandlers): void { const progress = transfer.progress const preview = transfer.mode === 'preview' @@ -508,9 +631,21 @@ function drawFooter(ui: Container, theme: Theme, state: ViewState, width: number { key: 'esc', label: 'cancel', onPress: act('cancelTransfer') }, { key: 'q', label: 'quit', onPress: act('quit') }, ] - : [ + : state.document + ? [ + // The panes keep the arrows, so the document follows the + // cursor from file to file; paging is what the viewer owns. + { key: 'tab', label: 'pane', onPress: act('pane') }, + { key: '↑↓', label: 'file' }, + { key: 'pgdn pgup', label: 'page' }, + { key: 'g G', label: 'top end' }, + { key: 'esc', label: 'close', onPress: act('closeDocument') }, + { key: 'q', label: 'quit', onPress: act('quit') }, + ] + : [ { key: 'tab', label: 'pane', onPress: act('pane') }, { key: '⏎', label: 'open', onPress: act('open') }, + { key: 'v', label: 'view', onPress: act('view') }, { key: 'c', label: 'endpoint', onPress: act('endpoint') }, { key: 'p', label: 'preview', onPress: act('preview') }, { key: 's', label: 'sync', onPress: act('sync') }, @@ -580,8 +715,8 @@ function drawHelp(ui: Container, theme: Theme, handlers: ViewHandlers): void { ui.modal( { title: ' Keys ', - width: 58, - height: 24, + width: 66, + height: 26, buttons: [{ label: 'esc close', variant: 'ghost', onPress: close }], onDismiss: close, }, @@ -595,6 +730,8 @@ function drawHelp(ui: Container, theme: Theme, handlers: ViewHandlers): void { { label: 'pgup pgdn home end', value: 'jump' }, { label: 'click', value: 'select; fold or unfold a directory' }, { 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: 'c', value: 'point this pane somewhere else' }, { label: '/', value: 'filter this listing' }, { label: 'o / O', value: 'cycle sort / reverse it' }, @@ -602,7 +739,7 @@ function drawHelp(ui: Container, theme: Theme, handlers: ViewHandlers): void { { label: 'r', value: 'reload' }, { label: 'p', value: 'preview syncing this to the other pane' }, { label: 's', value: 'sync it, into the same place over there' }, - { label: 'esc', value: 'cancel a transfer, or close this' }, + { label: 'esc', value: 'cancel a transfer, close a file or this' }, { label: 'q', value: 'quit' }, ], { labelColor: theme.accent }, diff --git a/packages/ssh-core/src/browser.ts b/packages/ssh-core/src/browser.ts index 218e524..842ae0a 100644 --- a/packages/ssh-core/src/browser.ts +++ b/packages/ssh-core/src/browser.ts @@ -200,6 +200,40 @@ export class SftpBrowser { }) } + /** + * The first `limit` bytes of a file, and how big the whole file is. + * + * Not ssh2's `readFile`: that pulls the entire file into memory, and the + * viewer is pointed at whatever sits under the cursor, which is as likely + * to be a 40 GB backup as a README. A short read is normal here — the + * server answers one request with however much it feels like, so the loop + * runs until it has what it asked for or the file ran out. + */ + async readHead(path: string, limit: number): Promise<{ bytes: Buffer; size: number }> { + const handle = await new Promise((resolve, reject) => { + this.sftp.open(path, 'r', (error, opened) => (error ? reject(error) : resolve(opened))) + }) + try { + const size = await new Promise((resolve, reject) => { + this.sftp.fstat(handle, (error, stats) => (error ? reject(error) : resolve(stats.size))) + }) + const bytes = Buffer.alloc(Math.max(0, Math.min(size, limit))) + let read = 0 + while (read < bytes.length) { + const count = await new Promise((resolve, reject) => { + this.sftp.read(handle, bytes, read, bytes.length - read, read, (error, got) => + error ? reject(error) : resolve(got), + ) + }) + if (count === 0) break + read += count + } + return { bytes: bytes.subarray(0, read), size } + } finally { + await new Promise((resolve) => this.sftp.close(handle, () => resolve())) + } + } + /** * Removes a directory and everything under it. * diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ba3ac62..442123f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,12 @@ importers: '@profullstack/hqtui': specifier: ^0.5.1 version: 0.5.1 + '@profullstack/readm3': + specifier: ^0.3.0 + version: 0.3.0 + '@profullstack/text-type-detection': + specifier: ^1.0.0 + version: 1.0.0 zod: specifier: ^3.24.1 version: 3.25.76 @@ -1116,11 +1122,25 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@profullstack/hqtui@0.1.12': + resolution: {integrity: sha512-BAmrR93G3hyDj2oPsePn4UcTvvYYBQsIuHzZ1VL7L9ezqSQx1vzAbf9pXvQgRHWaBkH+R1Uxw2XjcHNjZ1Ixlw==} + engines: {bun: '>=1.1', node: '>=22.6'} + hasBin: true + '@profullstack/hqtui@0.5.1': resolution: {integrity: sha512-TRj2CKCUhpXhhmsQ1DMIZb9OcU+XLm90BqhPPo0zk2OwyXNcQhlqBeNdxLqwTY/BlIWZpcTyQto458AQ7iCTFg==} engines: {bun: '>=1.1', node: '>=22.6'} hasBin: true + '@profullstack/readm3@0.3.0': + resolution: {integrity: sha512-WBCr6/aekifSQCue6E0jv+6SgNP0HI3cWOnoKQRZUt9ohrRbzvSnZ/ae4WDgCtc+vKXsiJPOuIDFNnMCcQxGRw==} + engines: {bun: '>=1.1', node: '>=22.6'} + hasBin: true + + '@profullstack/text-type-detection@1.0.0': + resolution: {integrity: sha512-3tG0Icz2gwLfwbOwdYxgCTTcjCyAAhEnU82ReIrbOQiN8f3R84WrPLWfE4Pqg/ce57VFVPpNkSmd5doaXKV+dw==} + engines: {node: '>=20.0.0'} + '@profullstack/x402-gateway@0.1.0': resolution: {integrity: sha512-B7tWvWk/bIEoqyec6UoyRF1pO7X/+b+wFRv2ZFIClqskmEpyxoA559ZgdTvnxqAIvuDeE9v56nVpYRQ+lmOZQQ==} engines: {node: '>=20.11'} @@ -3044,6 +3064,11 @@ packages: engines: {node: '>= 18'} hasBin: true + marked@18.0.12: + resolution: {integrity: sha512-LEm4ga2YeI2T3GVHj9b0BaDPPk93LLTHMFeMyQbNIzPxc8vCI0y/scy0ZA6z6lXKyT9j9Nhl/OC6ZYKYGuFScA==} + engines: {node: '>= 20'} + hasBin: true + matcher@3.0.0: resolution: {integrity: sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==} engines: {node: '>=10'} @@ -4996,8 +5021,17 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@profullstack/hqtui@0.1.12': {} + '@profullstack/hqtui@0.5.1': {} + '@profullstack/readm3@0.3.0': + dependencies: + '@profullstack/hqtui': 0.1.12 + marked: 18.0.12 + + '@profullstack/text-type-detection@1.0.0': {} + '@profullstack/x402-gateway@0.1.0': {} '@radix-ui/primitive@1.1.7': {} @@ -6979,6 +7013,8 @@ snapshots: marked@15.0.12: {} + marked@18.0.12: {} + matcher@3.0.0: dependencies: escape-string-regexp: 4.0.0