mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 12:26:38 -06:00
refactor: preserve wrapped file preview performance
This commit is contained in:
@@ -157,14 +157,7 @@ describe("openclaw-file-preview-modal", () => {
|
||||
expect(onDocumentKeydown).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps large-file rendering bounded and resets the real scroller on file changes", async () => {
|
||||
let frameCallback: FrameRequestCallback | undefined;
|
||||
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
|
||||
frameCallback = callback;
|
||||
return 1;
|
||||
});
|
||||
vi.stubGlobal("cancelAnimationFrame", vi.fn());
|
||||
|
||||
it("chunks large files without changing their text and resets the scroller on file changes", async () => {
|
||||
const firstContents = Array.from({ length: 500 }, (_, index) => `first-${index}`).join("\n");
|
||||
const secondContents = Array.from({ length: 500 }, (_, index) => `second-${index}`).join("\n");
|
||||
const previewFiles = [
|
||||
@@ -174,56 +167,20 @@ describe("openclaw-file-preview-modal", () => {
|
||||
const modal = await renderPreview({ activePath: "first.ts", previewFiles });
|
||||
const body = modal.shadowRoot?.querySelector<HTMLElement>(".detail-body");
|
||||
expect(body).toBeInstanceOf(HTMLElement);
|
||||
Object.defineProperty(body, "clientHeight", { configurable: true, value: 220 });
|
||||
const firstChunks = [...(modal.shadowRoot?.querySelectorAll<HTMLElement>(".code-chunk") ?? [])];
|
||||
expect(firstChunks).toHaveLength(8);
|
||||
expect(firstChunks.map((chunk) => chunk.textContent ?? "").join("\n")).toBe(firstContents);
|
||||
|
||||
body!.scrollTop = 2200;
|
||||
body!.dispatchEvent(new Event("scroll"));
|
||||
frameCallback?.(0);
|
||||
await modal.updateComplete;
|
||||
|
||||
const scrolledLines = modal.shadowRoot?.querySelectorAll<HTMLElement>(".code-line") ?? [];
|
||||
expect(scrolledLines.length).toBeLessThanOrEqual(70);
|
||||
expect(scrolledLines[0]?.dataset.line).toBe("70");
|
||||
expect(scrolledLines[0]?.textContent).toBe("first-70");
|
||||
|
||||
const updatedModal = await renderPreview({ activePath: "second.ts", previewFiles });
|
||||
const updatedBody = updatedModal.shadowRoot?.querySelector<HTMLElement>(".detail-body");
|
||||
const updatedLines = updatedModal.shadowRoot?.querySelectorAll<HTMLElement>(".code-line") ?? [];
|
||||
const secondChunks = [
|
||||
...(updatedModal.shadowRoot?.querySelectorAll<HTMLElement>(".code-chunk") ?? []),
|
||||
];
|
||||
|
||||
expect(updatedBody?.scrollTop).toBe(0);
|
||||
expect(updatedLines[0]?.dataset.line).toBe("0");
|
||||
expect(updatedLines[0]?.textContent).toBe("second-0");
|
||||
});
|
||||
|
||||
it("observes the current scroller after an empty filter replaces it", async () => {
|
||||
const observedTargets: Element[] = [];
|
||||
const disconnect = vi.fn();
|
||||
class TestResizeObserver {
|
||||
observe(target: Element) {
|
||||
observedTargets.push(target);
|
||||
}
|
||||
unobserve() {}
|
||||
disconnect = disconnect;
|
||||
takeRecords(): ResizeObserverEntry[] {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
vi.stubGlobal("ResizeObserver", TestResizeObserver);
|
||||
|
||||
const modal = await renderPreview();
|
||||
const initialBody = modal.shadowRoot?.querySelector<HTMLElement>(".detail-body");
|
||||
expect(initialBody).toBeInstanceOf(HTMLElement);
|
||||
expect(observedTargets).toContain(initialBody);
|
||||
|
||||
await renderPreview({ query: "missing" });
|
||||
expect(modal.shadowRoot?.querySelector(".detail-body")).toBeNull();
|
||||
expect(disconnect).toHaveBeenCalled();
|
||||
|
||||
const restoredModal = await renderPreview();
|
||||
const restoredBody = restoredModal.shadowRoot?.querySelector<HTMLElement>(".detail-body");
|
||||
expect(restoredBody).toBeInstanceOf(HTMLElement);
|
||||
expect(restoredBody).not.toBe(initialBody);
|
||||
expect(observedTargets).toContain(restoredBody);
|
||||
expect(secondChunks.map((chunk) => chunk.textContent ?? "").join("\n")).toBe(secondContents);
|
||||
});
|
||||
|
||||
it("copies the complete active file while only a virtual window is rendered", async () => {
|
||||
@@ -235,7 +192,7 @@ describe("openclaw-file-preview-modal", () => {
|
||||
const copyButton = modal.shadowRoot?.querySelector<HTMLButtonElement>(".chat-copy-btn");
|
||||
|
||||
expect(copyButton).toBeInstanceOf(HTMLButtonElement);
|
||||
expect(modal.shadowRoot?.querySelectorAll(".code-line").length).toBeLessThan(500);
|
||||
expect(modal.shadowRoot?.querySelectorAll(".code-chunk").length).toBe(8);
|
||||
copyButton!.click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Control UI component implements the file preview modal element.
|
||||
import { LitElement, css, html, type PropertyValues } from "lit";
|
||||
import { property, query, state } from "lit/decorators.js";
|
||||
import { property, query } from "lit/decorators.js";
|
||||
import { renderCopyButton } from "./copy-button.ts";
|
||||
import { icons } from "./icons.ts";
|
||||
|
||||
@@ -25,19 +25,11 @@ export class OpenClawFilePreviewModal extends LitElement {
|
||||
@query(".search") private searchInput?: HTMLInputElement;
|
||||
@query(".detail-body") private detailBody?: HTMLElement;
|
||||
|
||||
private static readonly LINE_HEIGHT = 22;
|
||||
private static readonly OVERSCAN = 30;
|
||||
|
||||
@state() private visibleStart = 0;
|
||||
@state() private visibleEnd = 0;
|
||||
private filteredFiles: FilePreviewModalFile[] = [];
|
||||
private activeFile?: FilePreviewModalFile;
|
||||
private derivedInputsReady = false;
|
||||
private codeSource?: string;
|
||||
private codeLines: string[] = [];
|
||||
private scrollRafId = 0;
|
||||
private resizeObserver?: ResizeObserver;
|
||||
private resizeObserverTarget?: HTMLElement;
|
||||
private codeChunks: string[] = [];
|
||||
private resetScrollAfterUpdate = true;
|
||||
|
||||
static override styles = css`
|
||||
@@ -398,24 +390,26 @@ export class OpenClawFilePreviewModal extends LitElement {
|
||||
|
||||
.detail-body {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
padding: 20px 24px 24px;
|
||||
}
|
||||
|
||||
.code-vscroll {
|
||||
min-width: 100%;
|
||||
width: max-content;
|
||||
.code-content {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.code-line {
|
||||
height: 22px;
|
||||
line-height: 22px;
|
||||
.code-chunk {
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: var(--text);
|
||||
white-space: pre;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-block-size: auto 1414px;
|
||||
}
|
||||
|
||||
.foot {
|
||||
@@ -496,10 +490,9 @@ export class OpenClawFilePreviewModal extends LitElement {
|
||||
const nextCodeSource = nextActiveFile?.contents;
|
||||
if (nextCodeSource !== this.codeSource) {
|
||||
this.codeSource = nextCodeSource;
|
||||
this.codeLines = nextCodeSource?.split("\n") ?? [];
|
||||
this.codeChunks = nextCodeSource === undefined ? [] : chunkFileContents(nextCodeSource);
|
||||
}
|
||||
|
||||
this.resetVirtualRange();
|
||||
this.resetScrollAfterUpdate = true;
|
||||
}
|
||||
|
||||
@@ -565,13 +558,6 @@ export class OpenClawFilePreviewModal extends LitElement {
|
||||
}
|
||||
|
||||
private renderFile(file: FilePreviewModalFile) {
|
||||
const totalLines = this.codeLines.length;
|
||||
const totalHeight = totalLines * OpenClawFilePreviewModal.LINE_HEIGHT;
|
||||
const start = Math.min(this.visibleStart, totalLines);
|
||||
const fallbackEnd = Math.min(totalLines, 100);
|
||||
const end = Math.max(start, Math.min(this.visibleEnd || fallbackEnd, totalLines));
|
||||
const visible = this.codeLines.slice(start, end);
|
||||
|
||||
return html`
|
||||
<section class="detail">
|
||||
<div class="detail-head">
|
||||
@@ -586,20 +572,11 @@ export class OpenClawFilePreviewModal extends LitElement {
|
||||
${this.contextLabel ? html`<span class="chip ok">${this.contextLabel}</span>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-body" @scroll=${this.handleCodeScroll}>
|
||||
<div class="code-vscroll" role="presentation" style="height:${totalHeight}px;">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style="height:${start * OpenClawFilePreviewModal.LINE_HEIGHT}px;"
|
||||
></div>
|
||||
${visible.map(
|
||||
(line, index) =>
|
||||
html`<div class="code-line" data-line=${start + index}>${line || " "}</div>`,
|
||||
<div class="detail-body">
|
||||
<div class="code-content">
|
||||
${this.codeChunks.map(
|
||||
(chunk, index) => html`<pre class="code-chunk" data-chunk=${index}>${chunk}</pre>`,
|
||||
)}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style="height:${(totalLines - end) * OpenClawFilePreviewModal.LINE_HEIGHT}px;"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -636,20 +613,11 @@ export class OpenClawFilePreviewModal extends LitElement {
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.resetVirtualRange();
|
||||
this.resetScrollAfterUpdate = true;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.cancelScrollFrame();
|
||||
this.resizeObserver?.disconnect();
|
||||
this.resizeObserverTarget = undefined;
|
||||
}
|
||||
|
||||
protected override updated(changed: PropertyValues<this>) {
|
||||
this.syncResizeObserver();
|
||||
if (this.resetScrollAfterUpdate) {
|
||||
this.resetScrollAfterUpdate = false;
|
||||
const body = this.detailBody;
|
||||
@@ -694,65 +662,6 @@ export class OpenClawFilePreviewModal extends LitElement {
|
||||
}
|
||||
};
|
||||
|
||||
private resetVirtualRange() {
|
||||
this.cancelScrollFrame();
|
||||
this.visibleStart = 0;
|
||||
this.visibleEnd = 0;
|
||||
}
|
||||
|
||||
private cancelScrollFrame() {
|
||||
if (this.scrollRafId) {
|
||||
cancelAnimationFrame(this.scrollRafId);
|
||||
this.scrollRafId = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private handleCodeScroll = () => {
|
||||
if (this.scrollRafId) {
|
||||
return;
|
||||
}
|
||||
this.scrollRafId = requestAnimationFrame(() => {
|
||||
this.scrollRafId = 0;
|
||||
this.recalcVisibleRange();
|
||||
});
|
||||
};
|
||||
|
||||
private recalcVisibleRange() {
|
||||
const container = this.detailBody;
|
||||
if (!container || this.codeLines.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { LINE_HEIGHT, OVERSCAN } = OpenClawFilePreviewModal;
|
||||
const start = Math.max(0, Math.floor(container.scrollTop / LINE_HEIGHT) - OVERSCAN);
|
||||
const visibleCount = Math.ceil(container.clientHeight / LINE_HEIGHT);
|
||||
const end = Math.min(this.codeLines.length, start + visibleCount + OVERSCAN * 2);
|
||||
|
||||
if (start !== this.visibleStart || end !== this.visibleEnd) {
|
||||
this.visibleStart = start;
|
||||
this.visibleEnd = end;
|
||||
}
|
||||
}
|
||||
|
||||
private syncResizeObserver() {
|
||||
const target = this.detailBody;
|
||||
if (target === this.resizeObserverTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.resizeObserver?.disconnect();
|
||||
this.resizeObserverTarget = undefined;
|
||||
if (!target || typeof ResizeObserver !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
this.resizeObserver ??= new ResizeObserver(() => {
|
||||
this.recalcVisibleRange();
|
||||
});
|
||||
this.resizeObserver.observe(target);
|
||||
this.resizeObserverTarget = target;
|
||||
}
|
||||
|
||||
private focusModal() {
|
||||
const target = this.searchInput ?? this.shadowRoot?.querySelector<HTMLElement>(".modal");
|
||||
target?.focus({ preventScroll: true });
|
||||
@@ -808,6 +717,17 @@ export class OpenClawFilePreviewModal extends LitElement {
|
||||
};
|
||||
}
|
||||
|
||||
const FILE_PREVIEW_CHUNK_LINES = 64;
|
||||
|
||||
function chunkFileContents(contents: string): string[] {
|
||||
const lines = contents.split("\n");
|
||||
const chunks: string[] = [];
|
||||
for (let index = 0; index < lines.length; index += FILE_PREVIEW_CHUNK_LINES) {
|
||||
chunks.push(lines.slice(index, index + FILE_PREVIEW_CHUNK_LINES).join("\n"));
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function fileKind(path: string): string {
|
||||
const ext = path.split(".").pop()?.toLowerCase() ?? "";
|
||||
const map: Record<string, string> = {
|
||||
|
||||
Reference in New Issue
Block a user