Skip to content

Commit f937ad6

Browse files
committed
perf(ui): add virtual scrolling to file preview modal code viewer
Replace single <pre> element with virtual scrolling to improve rendering performance for large files. Also fixes lint issues with no-underscore-dangle and curly rules.
1 parent 04e4c37 commit f937ad6

1 file changed

Lines changed: 98 additions & 10 deletions

File tree

ui/src/ui/components/file-preview-modal.ts

Lines changed: 98 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Control UI component implements the file preview modal element.
22
import { LitElement, css, html, type PropertyValues } from "lit";
3-
import { property, query } from "lit/decorators.js";
3+
import { property, query, state } from "lit/decorators.js";
44
import { icons } from "../icons.ts";
55

66
export 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

Comments
 (0)