diff --git a/ui/src/components/file-preview-modal.ts b/ui/src/components/file-preview-modal.ts index 424ef5e95984..aaf526830796 100644 --- a/ui/src/components/file-preview-modal.ts +++ b/ui/src/components/file-preview-modal.ts @@ -1,6 +1,7 @@ // 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 { renderCopyButton } from "./copy-button.ts"; import { icons } from "./icons.ts"; export type FilePreviewModalFile = { @@ -20,6 +21,7 @@ export class OpenClawFilePreviewModal extends LitElement { @property() readOnlyLabel = "read-only"; @property() emptyTitle = "No files match"; @property() emptySubtitle = "Try another file name or content search."; + @property() copyLabel = "Copy file"; @query(".search") private searchInput?: HTMLInputElement; @query(".detail-body") private detailBody?: HTMLElement; @@ -28,9 +30,15 @@ export class OpenClawFilePreviewModal extends LitElement { @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 resetScrollAfterUpdate = true; static override styles = css` :host { @@ -260,8 +268,17 @@ export class OpenClawFilePreviewModal extends LitElement { border-bottom: 1px solid var(--border); } + .detail-title-row { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 10px; + } + .title { - margin: 0 0 10px; + flex: 1; + min-width: 0; + margin: 0; font-family: var(--mono); font-size: 22px; color: var(--text-strong); @@ -272,6 +289,84 @@ export class OpenClawFilePreviewModal extends LitElement { white-space: nowrap; } + .chat-copy-btn { + width: 32px; + height: 32px; + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + padding: 0; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--bg-elevated); + color: var(--muted); + cursor: pointer; + } + + .chat-copy-btn:hover { + border-color: var(--border-strong); + color: var(--text-strong); + } + + .chat-copy-btn:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + } + + .chat-copy-btn__icon { + display: inline-flex; + width: 16px; + height: 16px; + position: relative; + } + + .chat-copy-btn__icon-copy, + .chat-copy-btn__icon-check { + position: absolute; + inset: 0; + transition: opacity 150ms ease; + } + + .chat-copy-btn__icon-check { + opacity: 0; + } + + .chat-copy-btn[data-copied="1"] .chat-copy-btn__icon-copy { + opacity: 0; + } + + .chat-copy-btn[data-copied="1"] .chat-copy-btn__icon-check { + opacity: 1; + } + + .chat-copy-btn[data-copying="1"] { + opacity: 0; + pointer-events: none; + } + + .chat-copy-btn[data-error="1"] { + border-color: var(--danger-subtle); + background: var(--danger-subtle); + color: var(--danger); + } + + .chat-copy-btn[data-copied="1"] { + border-color: var(--ok-subtle); + background: var(--ok-subtle); + color: var(--ok); + } + + .chat-copy-btn svg { + width: 16px; + height: 16px; + stroke: currentColor; + fill: none; + stroke-width: 1.5px; + stroke-linecap: round; + stroke-linejoin: round; + } + .chips { display: flex; gap: 6px; @@ -383,9 +478,34 @@ export class OpenClawFilePreviewModal extends LitElement { } `; + protected override willUpdate(changed: PropertyValues) { + const inputsChanged = + !this.derivedInputsReady || + changed.has("activePath") || + changed.has("query") || + changed.has("files"); + if (!inputsChanged) { + return; + } + + this.derivedInputsReady = true; + this.filteredFiles = this.filterFiles(); + const nextActiveFile = this.resolveActiveFile(this.filteredFiles); + this.activeFile = nextActiveFile; + + const nextCodeSource = nextActiveFile?.contents; + if (nextCodeSource !== this.codeSource) { + this.codeSource = nextCodeSource; + this.codeLines = nextCodeSource?.split("\n") ?? []; + } + + this.resetVirtualRange(); + this.resetScrollAfterUpdate = true; + } + override render() { - const filteredFiles = this.filterFiles(); - const activeFile = this.resolveActiveFile(filteredFiles); + const filteredFiles = this.filteredFiles; + const activeFile = this.activeFile; const fileCount = filteredFiles.length === this.files.length ? `${this.files.length} files` @@ -446,22 +566,20 @@ export class OpenClawFilePreviewModal extends LitElement { } private renderFile(file: FilePreviewModalFile) { - this.codeLines = file.contents.split("\n"); const totalLines = this.codeLines.length; const totalHeight = totalLines * OpenClawFilePreviewModal.LINE_HEIGHT; - - const start = this.visibleStart; - const end = this.visibleEnd > 0 ? this.visibleEnd : Math.min(totalLines, 100); - - const visible: string[] = []; - for (let i = start; i < end && i < totalLines; i++) { - visible.push(this.codeLines[i]); - } + 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`
-

${file.path}

+
+

${file.path}

+ ${file.contents ? renderCopyButton(file.contents, this.copyLabel) : ""} +
${fileKind(file.path)} ${file.size} @@ -470,10 +588,17 @@ export class OpenClawFilePreviewModal extends LitElement {
-
-
- ${visible.map((line) => html`
${line || " "}
`)} + @@ -508,32 +633,33 @@ export class OpenClawFilePreviewModal extends LitElement { protected override firstUpdated() { this.focusModal(); - if (typeof ResizeObserver !== "undefined") { - this.resizeObserver = new ResizeObserver(() => { - this.recalcVisibleRange(); - }); - const body = this.detailBody; - if (body) { - this.resizeObserver.observe(body); - } - } } override connectedCallback() { super.connectedCallback(); - this.visibleStart = 0; - this.visibleEnd = 0; + this.resetVirtualRange(); + this.resetScrollAfterUpdate = true; + this.requestUpdate(); } override disconnectedCallback() { super.disconnectedCallback(); - cancelAnimationFrame(this.scrollRafId); + 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; + if (body) { + body.scrollTop = 0; + body.scrollLeft = 0; + } + } if (changed.has("activePath") || changed.has("query") || changed.has("files")) { - this.resetCodeScroll(); this.scrollActiveFileIntoView(); } } @@ -569,13 +695,19 @@ export class OpenClawFilePreviewModal extends LitElement { } }; - private resetCodeScroll() { - cancelAnimationFrame(this.scrollRafId); - this.scrollRafId = 0; + 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; @@ -603,6 +735,25 @@ export class OpenClawFilePreviewModal extends LitElement { } } + 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 });