import type { WorkspacePanelContext } from "@jmfederico/pi-web/plugin-api"; import { isMarkdownDocumentPath, renderRelayDocumentHtml } from "./markdownDocument.js"; import { defaultRelayDocument, listRelayDocuments, listWorkspaceRelays, readRelayDocument, RELAYS_ROOT, type RelayDocumentContent, type RelayDocumentsListing, type RelaysListing, } from "./relayDiscovery.js"; export const relaysPanelTagName = "pi-web-relays-panel"; export function defineRelaysPanelElement(): void { if (!customElements.get(relaysPanelTagName)) customElements.define(relaysPanelTagName, PiWebRelaysPanel); } /** Selection a scan should restore after reloading, when the entries still exist. */ interface RelaySelection { relayPath?: string | undefined; documentPath?: string | undefined; } /** * Read-only relay browser: relay picker (auto-opens a single relay), one tab * per relay document, and a document viewer rendering markdown documents as * sanitized HTML and everything else as preformatted text. All async loads * flow through scanToken so stale responses for a previous workspace or * selection never overwrite newer state. */ class PiWebRelaysPanel extends HTMLElement { private contextValue: WorkspacePanelContext | undefined; private listing: RelaysListing | undefined; private selectedRelayPath: string | undefined; private documents: RelayDocumentsListing | undefined; private selectedDocumentPath: string | undefined; private documentContent: RelayDocumentContent | undefined; private scanToken = 0; private readonly root: ShadowRoot; constructor() { super(); this.root = this.attachShadow({ mode: "open" }); } set context(value: WorkspacePanelContext | undefined) { const previousKey = this.contextValue === undefined ? undefined : contextKey(this.contextValue); const nextKey = value === undefined ? undefined : contextKey(value); this.contextValue = value; // Parent app updates should not rescan or rebuild this shadow DOM for the // same workspace (mirrors the workspace-tasks panel). if (previousKey === nextKey) return; if (value === undefined) { this.resetScanState(); this.render(); return; } void this.scan(value, {}); } connectedCallback(): void { this.render(); } /** Rescan relays, then reload the selected relay's documents and the open document. */ private async scan(context: WorkspacePanelContext, selection: RelaySelection): Promise { const token = ++this.scanToken; this.resetScanState(); this.render(); const listing = await listWorkspaceRelays(context.files); if (!this.isCurrentScan(context, token)) return; this.listing = listing; // listWorkspaceRelays returns most recently modified first, so the first // relay is the default pre-selection. const relay = listing.kind === "loaded" ? listing.relays.find((candidate) => candidate.path === selection.relayPath) ?? listing.relays[0] : undefined; this.selectedRelayPath = relay?.path; if (relay === undefined) { this.render(); return; } await this.loadDocuments(context, token, relay.path, selection.documentPath); } private async openRelay(context: WorkspacePanelContext, relayPath: string): Promise { const token = ++this.scanToken; this.selectedRelayPath = relayPath; await this.loadDocuments(context, token, relayPath, undefined); } private async openDocument(context: WorkspacePanelContext, documentPath: string): Promise { const token = ++this.scanToken; this.selectedDocumentPath = documentPath; await this.loadDocumentContent(context, token, documentPath); } private async loadDocuments(context: WorkspacePanelContext, token: number, relayPath: string, preferredDocumentPath: string | undefined): Promise { this.documents = undefined; this.selectedDocumentPath = undefined; this.documentContent = undefined; this.render(); const documents = await listRelayDocuments(context.files, relayPath); if (!this.isCurrentScan(context, token)) return; this.documents = documents; const document = documents.kind === "loaded" ? documents.documents.find((candidate) => candidate.path === preferredDocumentPath) ?? defaultRelayDocument(documents.documents) : undefined; this.selectedDocumentPath = document?.path; if (document === undefined) { this.render(); return; } await this.loadDocumentContent(context, token, document.path); } private async loadDocumentContent(context: WorkspacePanelContext, token: number, documentPath: string): Promise { this.documentContent = undefined; this.render(); const content = await readRelayDocument(context.files, documentPath); if (!this.isCurrentScan(context, token)) return; this.documentContent = content; this.render(); } private refresh(): void { const context = this.contextValue; if (context === undefined) return; void this.scan(context, { relayPath: this.selectedRelayPath, documentPath: this.selectedDocumentPath }); } private resetScanState(): void { this.listing = undefined; this.selectedRelayPath = undefined; this.documents = undefined; this.selectedDocumentPath = undefined; this.documentContent = undefined; } private isCurrentScan(context: WorkspacePanelContext, token: number): boolean { return token === this.scanToken && this.contextValue !== undefined && contextKey(this.contextValue) === contextKey(context); } private render(): void { const context = this.contextValue; if (context === undefined) { this.root.innerHTML = `${relaysStyles()}
Select a workspace.
`; return; } this.root.innerHTML = ` ${relaysStyles()}
Relays ${this.renderRelayPicker()}
${this.renderDocumentTabs()}
${this.renderViewer()}
`; this.root.querySelector("button[data-refresh]")?.addEventListener("click", () => { this.refresh(); }); this.root.querySelector("select[data-relay-picker]")?.addEventListener("change", (event) => { const picker = event.target; if (picker instanceof HTMLSelectElement) void this.openRelay(context, picker.value); }); for (const tab of this.root.querySelectorAll("button[data-document-path]")) { tab.addEventListener("click", () => { const documentPath = tab.getAttribute("data-document-path"); if (documentPath !== null) void this.openDocument(context, documentPath); }); } } private renderRelayPicker(): string { const listing = this.listing; if (listing?.kind !== "loaded" || listing.relays.length === 0) return ""; // A single relay opens immediately; a one-option picker would be noise. if (listing.relays.length === 1) { const relay = listing.relays[0]; return relay === undefined ? "" : `${escapeHtml(relay.name)}`; } const options = listing.relays.map((relay) => { const selected = relay.path === this.selectedRelayPath ? " selected" : ""; return ``; }).join(""); return ``; } private renderDocumentTabs(): string { const documents = this.documents; if (documents?.kind !== "loaded" || documents.documents.length === 0) return ""; const tabs = documents.documents.map((document) => { const active = document.path === this.selectedDocumentPath; return ``; }).join(""); return ``; } private renderViewer(): string { const listing = this.listing; if (listing === undefined) return `

Scanning ${escapeHtml(RELAYS_ROOT)}…

`; if (listing.kind === "unavailable") return renderErrorState("Could not scan workspace relays.", listing.detail); if (listing.kind === "missing" || listing.relays.length === 0) return renderEmptyState(); return this.renderSelectedRelay(); } private renderSelectedRelay(): string { const documents = this.documents; if (documents === undefined) return `

Loading relay documents…

`; if (documents.kind === "unavailable") return renderErrorState("Could not list this relay's documents.", documents.detail); if (documents.kind === "missing") { return `
This relay no longer exists.

Click Refresh to rescan ${escapeHtml(RELAYS_ROOT)}.

`; } if (documents.documents.length === 0) { return `
This relay has no documents yet.

Relay packets usually contain status.md, charter.md, and log.md.

`; } return this.renderSelectedDocument(); } private renderSelectedDocument(): string { const documentPath = this.selectedDocumentPath; const content = this.documentContent; if (documentPath === undefined) return `

Select a document.

`; if (content === undefined) return `

Loading ${escapeHtml(documentName(documentPath))}…

`; if (content.kind === "unavailable") return renderErrorState("Could not read this document.", content.detail); if (content.kind === "missing") { return `
This document no longer exists.

Click Refresh to rescan the relay.

`; } if (content.binary) { return `
Binary file: ${escapeHtml(documentName(documentPath))}

Binary documents have no text preview.

`; } const truncation = content.truncated ? `
This document is truncated — only the beginning is shown.
` : ""; if (isMarkdownDocumentPath(documentPath)) { return `${truncation}
${renderRelayDocumentHtml(content.content)}
`; } return `${truncation}
${escapeHtml(content.content)}
`; } } /** Reload glyph matching the app's own refresh control (AppRefreshControl). */ function refreshIconSvg(): string { return ` `; } function contextKey(context: WorkspacePanelContext): string { return `${context.machine.id}:${context.workspace.projectId}:${context.workspace.id}`; } function documentName(path: string): string { return path.slice(path.lastIndexOf("/") + 1); } function renderEmptyState(): string { return `
No relays in this workspace.

Relay packets live in ${escapeHtml(RELAYS_ROOT)}/<name>/. This workspace has none yet.

`; } function renderErrorState(message: string, detail: string): string { return `
${escapeHtml(message)}
${escapeHtml(detail)}
`; } function relaysStyles(): string { return ` `; } function escapeHtml(value: unknown): string { return String(value).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); } function escapeAttr(value: unknown): string { return escapeHtml(value).replaceAll('"', """); }