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 {
@@ -297,16 +307,20 @@ export class OpenClawFilePreviewModal extends LitElement {
297307 padding: 20px 24px 24px;
298308 }
299309
300- .pre {
301- margin: 0;
310+ .code-vscroll {
311+ min-width: 100%;
312+ width: max-content;
313+ }
314+
315+ .code-line {
316+ height: 22px;
317+ line-height: 22px;
302318 font-family: var(--mono);
303319 font-size: 13px;
304- line-height: 1.7;
305320 color: var(--text);
306- background: transparent;
307- border: none;
308- white-space: pre-wrap;
309- word-break: break-word;
321+ white-space: pre;
322+ overflow: hidden;
323+ text-overflow: ellipsis;
310324 }
311325
312326 .foot {
@@ -432,6 +446,18 @@ export class OpenClawFilePreviewModal extends LitElement {
432446 }
433447
434448 private renderFile ( file : FilePreviewModalFile ) {
449+ this . codeLines = file . contents . split ( "\n" ) ;
450+ const totalLines = this . codeLines . length ;
451+ const totalHeight = totalLines * OpenClawFilePreviewModal . LINE_HEIGHT ;
452+
453+ const start = this . visibleStart ;
454+ const end = this . visibleEnd > 0 ? this . visibleEnd : Math . min ( totalLines , 100 ) ;
455+
456+ const visible : string [ ] = [ ] ;
457+ for ( let i = start ; i < end && i < totalLines ; i ++ ) {
458+ visible . push ( this . codeLines [ i ] ) ;
459+ }
460+
435461 return html `
436462 < section class ="detail ">
437463 < div class ="detail-head ">
@@ -443,8 +469,14 @@ export class OpenClawFilePreviewModal extends LitElement {
443469 ${ this . contextLabel ? html `< span class ="chip ok "> ${ this . contextLabel } </ span > ` : "" }
444470 </ div >
445471 </ div >
446- < div class ="detail-body ">
447- < pre class ="pre "> ${ file . contents } </ pre >
472+ < div class ="detail-body " @scroll =${ this . handleCodeScroll } >
473+ < div class ="code-vscroll " style ="height:${ totalHeight } px; ">
474+ < div style ="height:${ start * OpenClawFilePreviewModal . LINE_HEIGHT } px; "> </ div >
475+ ${ visible . map ( ( line ) => html `< div class ="code-line "> ${ line || " " } </ div > ` ) }
476+ < div
477+ style ="height:${ ( totalLines - end ) * OpenClawFilePreviewModal . LINE_HEIGHT } px; "
478+ > </ div >
479+ </ div >
448480 </ div >
449481 </ section >
450482 ` ;
@@ -476,10 +508,32 @@ export class OpenClawFilePreviewModal extends LitElement {
476508
477509 protected override firstUpdated ( ) {
478510 this . focusModal ( ) ;
511+ if ( typeof ResizeObserver !== "undefined" ) {
512+ this . resizeObserver = new ResizeObserver ( ( ) => {
513+ this . recalcVisibleRange ( ) ;
514+ } ) ;
515+ const body = this . detailBody ;
516+ if ( body ) {
517+ this . resizeObserver . observe ( body ) ;
518+ }
519+ }
520+ }
521+
522+ override connectedCallback ( ) {
523+ super . connectedCallback ( ) ;
524+ this . visibleStart = 0 ;
525+ this . visibleEnd = 0 ;
526+ }
527+
528+ override disconnectedCallback ( ) {
529+ super . disconnectedCallback ( ) ;
530+ cancelAnimationFrame ( this . scrollRafId ) ;
531+ this . resizeObserver ?. disconnect ( ) ;
479532 }
480533
481534 protected override updated ( changed : PropertyValues < this> ) {
482535 if ( changed . has ( "activePath" ) || changed . has ( "query" ) || changed . has ( "files" ) ) {
536+ this . resetCodeScroll ( ) ;
483537 this . scrollActiveFileIntoView ( ) ;
484538 }
485539 }
@@ -515,6 +569,40 @@ export class OpenClawFilePreviewModal extends LitElement {
515569 }
516570 } ;
517571
572+ private resetCodeScroll ( ) {
573+ cancelAnimationFrame ( this . scrollRafId ) ;
574+ this . scrollRafId = 0 ;
575+ this . visibleStart = 0 ;
576+ this . visibleEnd = 0 ;
577+ }
578+
579+ private handleCodeScroll = ( ) => {
580+ if ( this . scrollRafId ) {
581+ return ;
582+ }
583+ this . scrollRafId = requestAnimationFrame ( ( ) => {
584+ this . scrollRafId = 0 ;
585+ this . recalcVisibleRange ( ) ;
586+ } ) ;
587+ } ;
588+
589+ private recalcVisibleRange ( ) {
590+ const container = this . detailBody ;
591+ if ( ! container || this . codeLines . length === 0 ) {
592+ return ;
593+ }
594+
595+ const { LINE_HEIGHT , OVERSCAN } = OpenClawFilePreviewModal ;
596+ const start = Math . max ( 0 , Math . floor ( container . scrollTop / LINE_HEIGHT ) - OVERSCAN ) ;
597+ const visibleCount = Math . ceil ( container . clientHeight / LINE_HEIGHT ) ;
598+ const end = Math . min ( this . codeLines . length , start + visibleCount + OVERSCAN * 2 ) ;
599+
600+ if ( start !== this . visibleStart || end !== this . visibleEnd ) {
601+ this . visibleStart = start ;
602+ this . visibleEnd = end ;
603+ }
604+ }
605+
518606 private focusModal ( ) {
519607 const target = this . searchInput ?? this . shadowRoot ?. querySelector < HTMLElement > ( ".modal" ) ;
520608 target ?. focus ( { preventScroll : true } ) ;
0 commit comments