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. * * Rendering is region-scoped: the toolbar, tab strip, and viewer are * persistent elements built once, and each async stage re-renders only its * own region. Clicking a tab never rebuilds the strip — it toggles the * active marker in place and re-renders the viewer — so the strip's * horizontal scroll position and keyboard focus survive document switches. */ 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; private readonly toolbar: HTMLElement; private readonly tabStrip: HTMLElement; private readonly viewer: HTMLElement; constructor() { super(); this.root = this.attachShadow({ mode: "open" }); this.root.innerHTML = ` ${relaysStyles()}
Select a workspace.
`; this.toolbar = requiredRegion(this.root, ".toolbar"); this.tabStrip = requiredRegion(this.root, "nav.document-tabs"); this.viewer = requiredRegion(this.root, ".viewer"); // Listeners bind once against the persistent regions and delegate to // whichever controls the latest region render produced. this.toolbar.addEventListener("click", (event) => { const button = event.target instanceof Element ? event.target.closest("button[data-refresh]") : null; if (button !== null) this.refresh(); }); this.toolbar.addEventListener("change", (event) => { const picker = event.target; if (!(picker instanceof HTMLSelectElement) || !picker.matches("select[data-relay-picker]")) return; const context = this.contextValue; if (context !== undefined) void this.openRelay(context, picker.value); }); this.tabStrip.addEventListener("click", (event) => { const tab = event.target instanceof Element ? event.target.closest("button[data-document-path]") : null; if (tab === null) return; const documentPath = tab.getAttribute("data-document-path"); const context = this.contextValue; if (context !== undefined && documentPath !== null) void this.openDocument(context, documentPath); }); } 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 re-render this panel for the // same workspace (mirrors the workspace-tasks panel). if (previousKey === nextKey) return; if (value === undefined) { this.resetScanState(); this.renderAll(); return; } void this.scan(value, {}); } /** 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.renderAll(); 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; this.renderToolbar(); if (relay === undefined) { this.renderViewer(); 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; // The tab set is unchanged: toggle the active marker on the mounted // buttons instead of rebuilding the strip, so its scroll position and // focus stay put. A different document starts reading from the top. this.updateActiveTab(); this.viewer.scrollTop = 0; 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.renderTabs(); this.renderViewer(); 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; this.renderTabs(); if (document === undefined) { this.renderViewer(); return; } await this.loadDocumentContent(context, token, document.path); } private async loadDocumentContent(context: WorkspacePanelContext, token: number, documentPath: string): Promise { this.documentContent = undefined; this.renderViewer(); const content = await readRelayDocument(context.files, documentPath); if (!this.isCurrentScan(context, token)) return; this.documentContent = content; this.renderViewer(); } 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 renderAll(): void { this.renderToolbar(); this.renderTabs(); this.renderViewer(); } private renderToolbar(): void { if (this.contextValue === undefined) { this.toolbar.hidden = true; this.toolbar.replaceChildren(); return; } this.toolbar.hidden = false; this.toolbar.innerHTML = ` Relays ${this.renderRelayPicker()} `; } 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 renderTabs(): void { const documents = this.documents; if (documents?.kind !== "loaded" || documents.documents.length === 0) { this.tabStrip.hidden = true; // A new tab set starts at the left edge, not at the previous set's offset. this.tabStrip.replaceChildren(); this.tabStrip.scrollLeft = 0; return; } this.tabStrip.hidden = false; // The strip element itself persists across re-renders, so replacing its // buttons keeps the container's horizontal scroll position. this.tabStrip.innerHTML = documents.documents.map((document) => { const active = document.path === this.selectedDocumentPath; return ``; }).join(""); } /** Move the active marker between the mounted tab buttons without rebuilding them. */ private updateActiveTab(): void { for (const tab of this.tabStrip.querySelectorAll("button[data-document-path]")) { const active = tab.getAttribute("data-document-path") === this.selectedDocumentPath; tab.classList.toggle("active", active); if (active) tab.setAttribute("aria-current", "true"); else tab.removeAttribute("aria-current"); } } private renderViewer(): void { if (this.contextValue === undefined) { this.viewer.innerHTML = `
Select a workspace.
`; return; } this.viewer.innerHTML = this.renderViewerContent(); } private renderViewerContent(): 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)}
`; } } /** Shell regions come from a literal template; absence means the template broke. */ function requiredRegion(root: ShadowRoot, selector: string): HTMLElement { const element = root.querySelector(selector); if (!(element instanceof HTMLElement)) throw new Error(`relays panel shell is missing ${selector}`); return element; } /** 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('"', """); }