@@ -17,6 +17,7 @@ import { SftpBrowser, SshSession } from '@diskpush/ssh-core'
1717import { defaultRsyncOptions , type Change , type Connection } from '@diskpush/schemas'
1818import { parseEndpoint , planTransfer , runToCompletion } from '@diskpush/rsync-core'
1919import {
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'
4147import { type Action , type Tone , type ViewState , draw , filterChoices } from './view.js'
48+ import { maxScroll , readsAsMarkdown } from './document.js'
4249
4350export {
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
0 commit comments