mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:02:50 +00:00
refactor: extract file preview modal component
This commit is contained in:
@@ -525,297 +525,6 @@
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
/* ── File preview modal (Spotlight / Option D vibe) ────────────────── */
|
||||
.sw-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(6px);
|
||||
z-index: 50;
|
||||
animation: sw-fade 140ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes sw-fade {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sw-pop {
|
||||
from {
|
||||
transform: translate(-50%, -48%) scale(0.97);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.sw-modal {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: min(1100px, 92vw);
|
||||
height: min(780px, 86vh);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.6);
|
||||
z-index: 51;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: sw-pop 160ms ease-out;
|
||||
}
|
||||
|
||||
.sw-modal__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.sw-modal__search-icon {
|
||||
color: var(--muted);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.sw-modal__search {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--text-strong);
|
||||
font: inherit;
|
||||
font-size: 18px;
|
||||
font-weight: 400;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.sw-modal__search:focus,
|
||||
.sw-modal__search:focus-visible {
|
||||
outline: none;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.sw-modal__search::placeholder {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.sw-modal__state {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
padding: 5px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
.sw-modal__esc {
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.sw-modal__body {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 360px 1fr;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sw-modal__list {
|
||||
border-right: 1px solid var(--border);
|
||||
padding: 14px 10px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.sw-modal__list-section {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted);
|
||||
padding: 4px 12px 8px;
|
||||
}
|
||||
|
||||
.sw-modal__item {
|
||||
display: grid;
|
||||
grid-template-columns: 10px 1fr auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 12px 14px;
|
||||
border-radius: var(--radius-md);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.sw-modal__item:hover {
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
.sw-modal__item.is-active {
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
|
||||
.sw-modal__item.is-active .sw-modal__item-name {
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.sw-modal__item-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 6px color-mix(in srgb, var(--accent) 40%, transparent);
|
||||
}
|
||||
|
||||
.sw-modal__item-name {
|
||||
font-family: var(--mono);
|
||||
font-size: 14px;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sw-modal__item-meta {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.sw-modal__empty {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.sw-modal__detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sw-modal__detail--empty {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.sw-modal__detail-head {
|
||||
padding: 20px 24px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.sw-modal__detail-title {
|
||||
margin: 0 0 10px;
|
||||
font-family: var(--mono);
|
||||
font-size: 22px;
|
||||
color: var(--text-strong);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sw-modal__chips {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.sw-modal__chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 11.5px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.sw-modal__chip--accent {
|
||||
background: var(--accent-subtle);
|
||||
border-color: color-mix(in srgb, var(--accent) 30%, transparent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.sw-modal__chip--ok {
|
||||
background: color-mix(in srgb, var(--ok) 12%, transparent);
|
||||
border-color: color-mix(in srgb, var(--ok) 30%, transparent);
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
.sw-modal__detail-body {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 20px 24px 24px;
|
||||
}
|
||||
|
||||
.sw-modal__pre {
|
||||
margin: 0;
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: var(--text);
|
||||
background: transparent;
|
||||
border: none;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.sw-modal__foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
padding: 12px 20px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.sw-modal__foot-group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.sw-modal__kbd {
|
||||
font-family: var(--mono);
|
||||
font-size: 10.5px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.sw-detail__meta-link {
|
||||
background: none;
|
||||
border: none;
|
||||
|
||||
96
ui/src/ui/components/file-preview-modal.test.ts
Normal file
96
ui/src/ui/components/file-preview-modal.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { html, nothing, render } from "lit";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawFilePreviewModal } from "./file-preview-modal.ts";
|
||||
import "./file-preview-modal.ts";
|
||||
|
||||
let container: HTMLDivElement;
|
||||
|
||||
const files = [
|
||||
{
|
||||
path: "templates/digest.md",
|
||||
size: "2.1 KB",
|
||||
contents: "Morning digest template",
|
||||
},
|
||||
{
|
||||
path: "filters/auto-senders.txt",
|
||||
size: "418 B",
|
||||
contents: "noreply@example.com",
|
||||
},
|
||||
];
|
||||
|
||||
async function renderPreview(query = "") {
|
||||
render(
|
||||
html`
|
||||
<openclaw-file-preview-modal
|
||||
.files=${files}
|
||||
.activePath=${"templates/digest.md"}
|
||||
.query=${query}
|
||||
.contextLabel=${"in morning-catchup"}
|
||||
></openclaw-file-preview-modal>
|
||||
`,
|
||||
container,
|
||||
);
|
||||
|
||||
const modal = container.querySelector<OpenClawFilePreviewModal>("openclaw-file-preview-modal");
|
||||
expect(modal).toBeInstanceOf(HTMLElement);
|
||||
if (!modal) {
|
||||
throw new Error("expected file preview modal");
|
||||
}
|
||||
await modal.updateComplete;
|
||||
return modal;
|
||||
}
|
||||
|
||||
function shadowText(modal: OpenClawFilePreviewModal): string {
|
||||
return modal.shadowRoot?.textContent ?? "";
|
||||
}
|
||||
|
||||
describe("openclaw-file-preview-modal", () => {
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
render(nothing, container);
|
||||
container.remove();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("filters files by path or contents", async () => {
|
||||
const modal = await renderPreview("sender");
|
||||
|
||||
expect(shadowText(modal)).toContain("1/2 files");
|
||||
expect(shadowText(modal)).toContain("filters/auto-senders.txt");
|
||||
expect(shadowText(modal)).not.toContain("templates/digest.md");
|
||||
expect(shadowText(modal)).toContain("noreply@example.com");
|
||||
});
|
||||
|
||||
it("emits controlled query, select, and close events", async () => {
|
||||
const modal = await renderPreview();
|
||||
const onQuery = vi.fn();
|
||||
const onSelect = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
modal.addEventListener("file-preview-query-change", onQuery);
|
||||
modal.addEventListener("file-preview-select", onSelect);
|
||||
modal.addEventListener("file-preview-close", onClose);
|
||||
|
||||
const input = modal.shadowRoot?.querySelector<HTMLInputElement>(".search");
|
||||
expect(input).toBeInstanceOf(HTMLInputElement);
|
||||
input!.value = "digest";
|
||||
input!.dispatchEvent(new InputEvent("input", { bubbles: true, composed: true }));
|
||||
|
||||
const secondFile = modal.shadowRoot?.querySelectorAll<HTMLButtonElement>(".item")[1];
|
||||
expect(secondFile).toBeInstanceOf(HTMLButtonElement);
|
||||
secondFile!.click();
|
||||
|
||||
modal.shadowRoot
|
||||
?.querySelector<HTMLElement>(".modal")
|
||||
?.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
||||
|
||||
expect(onQuery.mock.lastCall?.[0].detail).toBe("digest");
|
||||
expect(onSelect.mock.lastCall?.[0].detail).toBe("filters/auto-senders.txt");
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
524
ui/src/ui/components/file-preview-modal.ts
Normal file
524
ui/src/ui/components/file-preview-modal.ts
Normal file
@@ -0,0 +1,524 @@
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { property } from "lit/decorators.js";
|
||||
|
||||
export type FilePreviewModalFile = {
|
||||
path: string;
|
||||
size: string;
|
||||
contents: string;
|
||||
};
|
||||
|
||||
export class OpenClawFilePreviewModal extends LitElement {
|
||||
@property({ attribute: false }) files: FilePreviewModalFile[] = [];
|
||||
@property() activePath = "";
|
||||
@property() query = "";
|
||||
@property() label = "Support files";
|
||||
@property() listLabel = "Files";
|
||||
@property() searchPlaceholder = "Search files...";
|
||||
@property() contextLabel = "";
|
||||
@property() readOnlyLabel = "read-only";
|
||||
@property() emptyTitle = "No files match";
|
||||
@property() emptySubtitle = "Try another file name or content search.";
|
||||
|
||||
static override styles = css`
|
||||
:host {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(6px);
|
||||
animation: fade 140ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes fade {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pop {
|
||||
from {
|
||||
transform: translate(-50%, -48%) scale(0.97);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: min(1100px, 92vw);
|
||||
height: min(780px, 86vh);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: pop 160ms ease-out;
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
color: var(--muted);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.search {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--text-strong);
|
||||
font: inherit;
|
||||
font-size: 18px;
|
||||
font-weight: 400;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.search:focus,
|
||||
.search:focus-visible {
|
||||
outline: none;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.search::placeholder {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.state {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
padding: 5px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
.esc,
|
||||
.kbd {
|
||||
font-family: var(--mono);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.esc {
|
||||
font-size: 10px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 360px 1fr;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.list {
|
||||
border-right: 1px solid var(--border);
|
||||
padding: 14px 10px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.list-section {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted);
|
||||
padding: 4px 12px 8px;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: grid;
|
||||
grid-template-columns: 10px 1fr auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 12px 14px;
|
||||
border-radius: var(--radius-md);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.item:hover {
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
.item.is-active {
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
|
||||
.item.is-active .item-name {
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.item-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 6px color-mix(in srgb, var(--accent) 40%, transparent);
|
||||
}
|
||||
|
||||
.item-name {
|
||||
font-family: var(--mono);
|
||||
font-size: 14px;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.item-meta {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.empty-list {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.detail.empty {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.detail-head {
|
||||
padding: 20px 24px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0 0 10px;
|
||||
font-family: var(--mono);
|
||||
font-size: 22px;
|
||||
color: var(--text-strong);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 11.5px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.chip.accent {
|
||||
background: var(--accent-subtle);
|
||||
border-color: color-mix(in srgb, var(--accent) 30%, transparent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.chip.ok {
|
||||
background: color-mix(in srgb, var(--ok) 12%, transparent);
|
||||
border-color: color-mix(in srgb, var(--ok) 30%, transparent);
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
.detail-body {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 20px 24px 24px;
|
||||
}
|
||||
|
||||
.pre {
|
||||
margin: 0;
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: var(--text);
|
||||
background: transparent;
|
||||
border: none;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
padding: 12px 20px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.foot-group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.kbd {
|
||||
font-size: 10.5px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.button {
|
||||
height: 36px;
|
||||
padding: 0 14px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-strong);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.empty-subtitle {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
max-width: 380px;
|
||||
}
|
||||
`;
|
||||
|
||||
override render() {
|
||||
const filteredFiles = this.filterFiles();
|
||||
const activeFile = this.resolveActiveFile(filteredFiles);
|
||||
const fileCount =
|
||||
filteredFiles.length === this.files.length
|
||||
? `${this.files.length} files`
|
||||
: `${filteredFiles.length}/${this.files.length} files`;
|
||||
|
||||
return html`
|
||||
<div class="backdrop" @click=${this.emitClose}></div>
|
||||
<div
|
||||
class="modal"
|
||||
role="dialog"
|
||||
aria-label=${this.label}
|
||||
aria-modal="true"
|
||||
@keydown=${this.handleKeydown}
|
||||
>
|
||||
<header class="head">
|
||||
<span class="search-icon">⌕</span>
|
||||
<input
|
||||
class="search"
|
||||
placeholder=${this.searchPlaceholder}
|
||||
.value=${this.query}
|
||||
@input=${this.handleQueryInput}
|
||||
autofocus
|
||||
/>
|
||||
<span class="state">${fileCount} <span class="esc">esc</span></span>
|
||||
</header>
|
||||
<div class="body">
|
||||
<aside class="list">
|
||||
<div class="list-section">${this.listLabel} · ${filteredFiles.length}</div>
|
||||
${filteredFiles.length === 0
|
||||
? html`<div class="empty-list">No files match.</div>`
|
||||
: filteredFiles.map(
|
||||
(file) => html`
|
||||
<button
|
||||
class="item ${file.path === activeFile?.path ? "is-active" : ""}"
|
||||
@click=${() => this.emitSelect(file.path)}
|
||||
>
|
||||
<span class="item-dot"></span>
|
||||
<span class="item-name">${file.path}</span>
|
||||
<span class="item-meta">${file.size}</span>
|
||||
</button>
|
||||
`,
|
||||
)}
|
||||
</aside>
|
||||
${activeFile ? this.renderFile(activeFile) : this.renderEmpty()}
|
||||
</div>
|
||||
<footer class="foot">
|
||||
<span class="foot-group"><span class="kbd">↑↓</span> navigate</span>
|
||||
<span class="foot-group"><span class="kbd">↵</span> open</span>
|
||||
<span class="foot-group"><span class="kbd">⌘C</span> copy</span>
|
||||
<span class="foot-group"><span class="kbd">⌘E</span> edit</span>
|
||||
<span class="spacer"></span>
|
||||
<button class="button" @click=${this.emitClose}>
|
||||
Close <span class="kbd">esc</span>
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderFile(file: FilePreviewModalFile) {
|
||||
return html`
|
||||
<section class="detail">
|
||||
<div class="detail-head">
|
||||
<h2 class="title">${file.path}</h2>
|
||||
<div class="chips">
|
||||
<span class="chip accent">${fileKind(file.path)}</span>
|
||||
<span class="chip">${file.size}</span>
|
||||
<span class="chip">${this.readOnlyLabel}</span>
|
||||
${this.contextLabel ? html`<span class="chip ok">${this.contextLabel}</span>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-body">
|
||||
<pre class="pre">${file.contents}</pre>
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderEmpty() {
|
||||
return html`
|
||||
<section class="detail empty">
|
||||
<p class="empty-title">${this.emptyTitle}</p>
|
||||
<p class="empty-subtitle">${this.emptySubtitle}</p>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
private filterFiles(): FilePreviewModalFile[] {
|
||||
const query = this.query.trim().toLowerCase();
|
||||
if (!query) {
|
||||
return this.files;
|
||||
}
|
||||
return this.files.filter((file) => {
|
||||
const haystack = `${file.path}\n${file.contents}`.toLowerCase();
|
||||
return haystack.includes(query);
|
||||
});
|
||||
}
|
||||
|
||||
private resolveActiveFile(files: FilePreviewModalFile[]): FilePreviewModalFile | undefined {
|
||||
return files.find((file) => file.path === this.activePath) ?? files[0];
|
||||
}
|
||||
|
||||
private handleQueryInput = (event: Event) => {
|
||||
const query = (event.target as HTMLInputElement).value ?? "";
|
||||
this.dispatchEvent(
|
||||
new CustomEvent<string>("file-preview-query-change", {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
detail: query,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
private handleKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.emitClose();
|
||||
};
|
||||
|
||||
private emitSelect(path: string) {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent<string>("file-preview-select", {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
detail: path,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private emitClose = () => {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("file-preview-close", {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function fileKind(path: string): string {
|
||||
const ext = path.split(".").pop()?.toLowerCase() ?? "";
|
||||
const map: Record<string, string> = {
|
||||
md: "Markdown",
|
||||
txt: "Text",
|
||||
json: "JSON",
|
||||
yaml: "YAML",
|
||||
yml: "YAML",
|
||||
ts: "TypeScript",
|
||||
js: "JavaScript",
|
||||
py: "Python",
|
||||
sh: "Shell",
|
||||
};
|
||||
return map[ext] ?? (ext ? ext.toUpperCase() : "File");
|
||||
}
|
||||
|
||||
if (!customElements.get("openclaw-file-preview-modal")) {
|
||||
customElements.define("openclaw-file-preview-modal", OpenClawFilePreviewModal);
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"openclaw-file-preview-modal": OpenClawFilePreviewModal;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { html, nothing } from "lit";
|
||||
import "../components/file-preview-modal.ts";
|
||||
|
||||
export type SkillWorkshopProposalStatus =
|
||||
| "pending"
|
||||
@@ -96,14 +97,19 @@ export function renderSkillWorkshop(props: SkillWorkshopProps) {
|
||||
</div>
|
||||
</section>
|
||||
${preview && selected
|
||||
? renderFilePreview(
|
||||
selected,
|
||||
preview,
|
||||
props.filePreviewQuery,
|
||||
props.onFilePreviewQueryChange,
|
||||
props.onClosePreview,
|
||||
(path) => props.onPreviewFile(selected.key, path),
|
||||
)
|
||||
? html`
|
||||
<openclaw-file-preview-modal
|
||||
.files=${selected.supportFiles}
|
||||
.activePath=${preview.path}
|
||||
.query=${props.filePreviewQuery}
|
||||
.contextLabel=${`in ${selected.slug}`}
|
||||
@file-preview-query-change=${(event: CustomEvent<string>) =>
|
||||
props.onFilePreviewQueryChange(event.detail)}
|
||||
@file-preview-select=${(event: CustomEvent<string>) =>
|
||||
props.onPreviewFile(selected.key, event.detail)}
|
||||
@file-preview-close=${props.onClosePreview}
|
||||
></openclaw-file-preview-modal>
|
||||
`
|
||||
: nothing}
|
||||
`;
|
||||
}
|
||||
@@ -284,122 +290,6 @@ function renderEmpty() {
|
||||
`;
|
||||
}
|
||||
|
||||
function renderFilePreview(
|
||||
proposal: SkillWorkshopProposal,
|
||||
file: SkillWorkshopFile,
|
||||
query: string,
|
||||
onQueryChange: (query: string) => void,
|
||||
onClose: () => void,
|
||||
onSelect: (path: string) => void,
|
||||
) {
|
||||
const filteredFiles = filterSupportFiles(proposal.supportFiles, query);
|
||||
const activeFile = filteredFiles.some((f) => f.path === file.path) ? file : filteredFiles[0];
|
||||
|
||||
return html`
|
||||
<div class="sw-modal-backdrop" @click=${onClose}></div>
|
||||
<div class="sw-modal" role="dialog" aria-label="Support files">
|
||||
<header class="sw-modal__head">
|
||||
<span class="sw-modal__search-icon">⌕</span>
|
||||
<input
|
||||
class="sw-modal__search"
|
||||
placeholder="Search files…"
|
||||
.value=${query}
|
||||
@input=${(event: Event) => onQueryChange((event.target as HTMLInputElement).value ?? "")}
|
||||
autofocus
|
||||
/>
|
||||
<span class="sw-modal__state">
|
||||
${filteredFiles.length === proposal.supportFiles.length
|
||||
? `${proposal.supportFiles.length} files`
|
||||
: `${filteredFiles.length}/${proposal.supportFiles.length} files`}
|
||||
<span class="sw-modal__esc">esc</span>
|
||||
</span>
|
||||
</header>
|
||||
<div class="sw-modal__body">
|
||||
<aside class="sw-modal__list">
|
||||
<div class="sw-modal__list-section">FILES · ${filteredFiles.length}</div>
|
||||
${filteredFiles.length === 0
|
||||
? html`<div class="sw-modal__empty">No files match.</div>`
|
||||
: filteredFiles.map((f) => {
|
||||
const isActive = f.path === activeFile?.path;
|
||||
return html`
|
||||
<button
|
||||
class="sw-modal__item ${isActive ? "is-active" : ""}"
|
||||
@click=${() => onSelect(f.path)}
|
||||
>
|
||||
<span class="sw-modal__item-dot"></span>
|
||||
<span class="sw-modal__item-name">${f.path}</span>
|
||||
<span class="sw-modal__item-meta">${f.size}</span>
|
||||
</button>
|
||||
`;
|
||||
})}
|
||||
</aside>
|
||||
${activeFile
|
||||
? html`
|
||||
<section class="sw-modal__detail">
|
||||
<div class="sw-modal__detail-head">
|
||||
<h2 class="sw-modal__detail-title">${activeFile.path}</h2>
|
||||
<div class="sw-modal__chips">
|
||||
<span class="sw-modal__chip sw-modal__chip--accent"
|
||||
>${fileKind(activeFile.path)}</span
|
||||
>
|
||||
<span class="sw-modal__chip">${activeFile.size}</span>
|
||||
<span class="sw-modal__chip">read-only</span>
|
||||
<span class="sw-modal__chip sw-modal__chip--ok">in ${proposal.slug}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sw-modal__detail-body">
|
||||
<pre class="sw-modal__pre">${activeFile.contents}</pre>
|
||||
</div>
|
||||
</section>
|
||||
`
|
||||
: html`
|
||||
<section class="sw-modal__detail sw-modal__detail--empty">
|
||||
<p class="sw-empty__title">No files match</p>
|
||||
<p class="sw-empty__sub">Try another file name or content search.</p>
|
||||
</section>
|
||||
`}
|
||||
</div>
|
||||
<footer class="sw-modal__foot">
|
||||
<span class="sw-modal__foot-group"><span class="sw-modal__kbd">↑↓</span> navigate</span>
|
||||
<span class="sw-modal__foot-group"><span class="sw-modal__kbd">↵</span> open</span>
|
||||
<span class="sw-modal__foot-group"><span class="sw-modal__kbd">⌘C</span> copy</span>
|
||||
<span class="sw-modal__foot-group"><span class="sw-modal__kbd">⌘E</span> edit</span>
|
||||
<span class="sw-action-bar__spacer"></span>
|
||||
<button class="sw-btn sw-btn--ghost" @click=${onClose}>
|
||||
Close <span class="sw-kbd">esc</span>
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function filterSupportFiles(files: SkillWorkshopFile[], query: string): SkillWorkshopFile[] {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) {
|
||||
return files;
|
||||
}
|
||||
return files.filter((file) => {
|
||||
const haystack = `${file.path}\n${file.contents}`.toLowerCase();
|
||||
return haystack.includes(q);
|
||||
});
|
||||
}
|
||||
|
||||
function fileKind(path: string): string {
|
||||
const ext = path.split(".").pop()?.toLowerCase() ?? "";
|
||||
const map: Record<string, string> = {
|
||||
md: "Markdown",
|
||||
txt: "Text",
|
||||
json: "JSON",
|
||||
yaml: "YAML",
|
||||
yml: "YAML",
|
||||
ts: "TypeScript",
|
||||
js: "JavaScript",
|
||||
py: "Python",
|
||||
sh: "Shell",
|
||||
};
|
||||
return map[ext] ?? (ext ? ext.toUpperCase() : "File");
|
||||
}
|
||||
|
||||
function renderProposalBody(body: string) {
|
||||
const lines = body.split("\n");
|
||||
const out: unknown[] = [];
|
||||
|
||||
Reference in New Issue
Block a user