diff --git a/ui/src/components/file-preview-modal.test.ts b/ui/src/components/file-preview-modal.test.ts index ebd2647a3486..761b705ff872 100644 --- a/ui/src/components/file-preview-modal.test.ts +++ b/ui/src/components/file-preview-modal.test.ts @@ -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(".detail-body"); expect(body).toBeInstanceOf(HTMLElement); - Object.defineProperty(body, "clientHeight", { configurable: true, value: 220 }); + const firstChunks = [...(modal.shadowRoot?.querySelectorAll(".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(".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(".detail-body"); - const updatedLines = updatedModal.shadowRoot?.querySelectorAll(".code-line") ?? []; + const secondChunks = [ + ...(updatedModal.shadowRoot?.querySelectorAll(".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(".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(".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(".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(() => { diff --git a/ui/src/components/file-preview-modal.ts b/ui/src/components/file-preview-modal.ts index 5d4df9d8c216..0edaade354f0 100644 --- a/ui/src/components/file-preview-modal.ts +++ b/ui/src/components/file-preview-modal.ts @@ -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`
@@ -586,20 +572,11 @@ export class OpenClawFilePreviewModal extends LitElement { ${this.contextLabel ? html`${this.contextLabel}` : ""}
-
-
@@ -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.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(".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 = {