Skip to content

Commit 13f14b4

Browse files
committed
perf(ui): add virtual scrolling to file preview modal code viewer
1 parent 06c18e2 commit 13f14b4

1 file changed

Lines changed: 98 additions & 10 deletions

File tree

ui/src/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 {
@@ -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

Comments
 (0)