11// Control UI component implements the file preview modal element.
22import { LitElement , css , html , type PropertyValues } from "lit" ;
3- import { property , query } from "lit/decorators.js" ;
3+ import { property , query , state } from "lit/decorators.js" ;
44import { icons } from "../icons.ts" ;
55
66export type FilePreviewModalFile = {
@@ -21,6 +21,16 @@ export class OpenClawFilePreviewModal extends LitElement {
2121 @property ( ) emptyTitle = "No files match" ;
2222 @property ( ) emptySubtitle = "Try another file name or content search." ;
2323 @query ( ".search" ) private searchInput ?: HTMLInputElement ;
24+ @query ( ".detail-body" ) private detailBody ?: HTMLElement ;
25+
26+ private static readonly LINE_HEIGHT = 22 ;
27+ private static readonly OVERSCAN = 30 ;
28+
29+ @state ( ) private visibleStart = 0 ;
30+ @state ( ) private visibleEnd = 0 ;
31+ private codeLines : string [ ] = [ ] ;
32+ private scrollRafId = 0 ;
33+ private resizeObserver ?: ResizeObserver ;
2434
2535 static override styles = css `
2636 :host {
@@ -305,16 +315,20 @@ export class OpenClawFilePreviewModal extends LitElement {
305315 padding: 20px 24px 24px;
306316 }
307317
308- .pre {
309- margin: 0;
318+ .code-vscroll {
319+ min-width: 100%;
320+ width: max-content;
321+ }
322+
323+ .code-line {
324+ height: 22px;
325+ line-height: 22px;
310326 font-family: var(--mono);
311327 font-size: 13px;
312- line-height: 1.7;
313328 color: var(--text);
314- background: transparent;
315- border: none;
316- white-space: pre-wrap;
317- word-break: break-word;
329+ white-space: pre;
330+ overflow: hidden;
331+ text-overflow: ellipsis;
318332 }
319333
320334 .foot {
@@ -440,6 +454,18 @@ export class OpenClawFilePreviewModal extends LitElement {
440454 }
441455
442456 private renderFile ( file : FilePreviewModalFile ) {
457+ this . codeLines = file . contents . split ( "\n" ) ;
458+ const totalLines = this . codeLines . length ;
459+ const totalHeight = totalLines * OpenClawFilePreviewModal . LINE_HEIGHT ;
460+
461+ const start = this . visibleStart ;
462+ const end = this . visibleEnd > 0 ? this . visibleEnd : Math . min ( totalLines , 100 ) ;
463+
464+ const visible : string [ ] = [ ] ;
465+ for ( let i = start ; i < end && i < totalLines ; i ++ ) {
466+ visible . push ( this . codeLines [ i ] ) ;
467+ }
468+
443469 return html `
444470 < section class ="detail ">
445471 < div class ="detail-head ">
@@ -451,8 +477,14 @@ export class OpenClawFilePreviewModal extends LitElement {
451477 ${ this . contextLabel ? html `< span class ="chip ok "> ${ this . contextLabel } </ span > ` : "" }
452478 </ div >
453479 </ div >
454- < div class ="detail-body ">
455- < pre class ="pre "> ${ file . contents } </ pre >
480+ < div class ="detail-body " @scroll =${ this . handleCodeScroll } >
481+ < div class ="code-vscroll " style ="height:${ totalHeight } px; ">
482+ < div style ="height:${ start * OpenClawFilePreviewModal . LINE_HEIGHT } px; "> </ div >
483+ ${ visible . map ( ( line ) => html `< div class ="code-line "> ${ line || " " } </ div > ` ) }
484+ < div
485+ style ="height:${ ( totalLines - end ) * OpenClawFilePreviewModal . LINE_HEIGHT } px; "
486+ > </ div >
487+ </ div >
456488 </ div >
457489 </ section >
458490 ` ;
@@ -482,16 +514,45 @@ export class OpenClawFilePreviewModal extends LitElement {
482514 return files . find ( ( file ) => file . path === this . activePath ) ?? files [ 0 ] ;
483515 }
484516
517+ override connectedCallback ( ) {
518+ super . connectedCallback ( ) ;
519+ this . visibleStart = 0 ;
520+ this . visibleEnd = 0 ;
521+ }
522+
523+ override disconnectedCallback ( ) {
524+ super . disconnectedCallback ( ) ;
525+ cancelAnimationFrame ( this . scrollRafId ) ;
526+ this . resizeObserver ?. disconnect ( ) ;
527+ }
528+
485529 protected override firstUpdated ( ) {
486530 this . focusModal ( ) ;
531+ if ( typeof ResizeObserver !== "undefined" ) {
532+ this . resizeObserver = new ResizeObserver ( ( ) => {
533+ this . recalcVisibleRange ( ) ;
534+ } ) ;
535+ const body = this . detailBody ;
536+ if ( body ) {
537+ this . resizeObserver . observe ( body ) ;
538+ }
539+ }
487540 }
488541
489542 protected override updated ( changed : PropertyValues < this> ) {
490543 if ( changed . has ( "activePath" ) || changed . has ( "query" ) || changed . has ( "files" ) ) {
544+ this . resetCodeScroll ( ) ;
491545 this . scrollActiveFileIntoView ( ) ;
492546 }
493547 }
494548
549+ private resetCodeScroll ( ) {
550+ cancelAnimationFrame ( this . scrollRafId ) ;
551+ this . scrollRafId = 0 ;
552+ this . visibleStart = 0 ;
553+ this . visibleEnd = 0 ;
554+ }
555+
495556 private handleQueryInput = ( event : Event ) => {
496557 const nextQuery = ( event . target as HTMLInputElement ) . value ?? "" ;
497558 this . dispatchEvent (
@@ -507,6 +568,33 @@ export class OpenClawFilePreviewModal extends LitElement {
507568 event . preventDefault ( ) ;
508569 } ;
509570
571+ private handleCodeScroll = ( ) => {
572+ if ( this . scrollRafId ) {
573+ return ;
574+ }
575+ this . scrollRafId = requestAnimationFrame ( ( ) => {
576+ this . scrollRafId = 0 ;
577+ this . recalcVisibleRange ( ) ;
578+ } ) ;
579+ } ;
580+
581+ private recalcVisibleRange ( ) {
582+ const container = this . detailBody ;
583+ if ( ! container || this . codeLines . length === 0 ) {
584+ return ;
585+ }
586+
587+ const { LINE_HEIGHT , OVERSCAN } = OpenClawFilePreviewModal ;
588+ const start = Math . max ( 0 , Math . floor ( container . scrollTop / LINE_HEIGHT ) - OVERSCAN ) ;
589+ const visibleCount = Math . ceil ( container . clientHeight / LINE_HEIGHT ) ;
590+ const end = Math . min ( this . codeLines . length , start + visibleCount + OVERSCAN * 2 ) ;
591+
592+ if ( start !== this . visibleStart || end !== this . visibleEnd ) {
593+ this . visibleStart = start ;
594+ this . visibleEnd = end ;
595+ }
596+ }
597+
510598 private handleKeydown = ( event : KeyboardEvent ) => {
511599 switch ( event . key ) {
512600 case "Escape" :
0 commit comments