This repository has been archived on 2026-08-23. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
pi-web/pi-web-plugins/relays/relaysPanelElement.ts
T

359 lines
17 KiB
TypeScript

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<void> {
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<void> {
const token = ++this.scanToken;
this.selectedRelayPath = relayPath;
await this.loadDocuments(context, token, relayPath, undefined);
}
private async openDocument(context: WorkspacePanelContext, documentPath: string): Promise<void> {
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<void> {
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<void> {
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()}<section class="empty">Select a workspace.</section>`;
return;
}
this.root.innerHTML = `
${relaysStyles()}
<section class="toolbar">
<strong>Relays</strong>
<span class="toolbar-actions">
${this.renderRelayPicker()}
<button class="icon-button" data-refresh aria-label="Refresh" title="Refresh">${refreshIconSvg()}</button>
</span>
</section>
${this.renderDocumentTabs()}
<section class="viewer">${this.renderViewer()}</section>
`;
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 ? "" : `<span class="relay-name" title="${escapeAttr(relay.path)}">${escapeHtml(relay.name)}</span>`;
}
const options = listing.relays.map((relay) => {
const selected = relay.path === this.selectedRelayPath ? " selected" : "";
return `<option value="${escapeAttr(relay.path)}"${selected}>${escapeHtml(relay.name)}</option>`;
}).join("");
return `<select data-relay-picker aria-label="Relay">${options}</select>`;
}
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 `<button class="document-tab${active ? " active" : ""}" data-document-path="${escapeAttr(document.path)}"${active ? ' aria-current="true"' : ""}>${escapeHtml(document.name)}</button>`;
}).join("");
return `<nav class="document-tabs" aria-label="Relay documents">${tabs}</nav>`;
}
private renderViewer(): string {
const listing = this.listing;
if (listing === undefined) return `<p class="muted">Scanning ${escapeHtml(RELAYS_ROOT)}…</p>`;
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 `<p class="muted">Loading relay documents…</p>`;
if (documents.kind === "unavailable") return renderErrorState("Could not list this relay's documents.", documents.detail);
if (documents.kind === "missing") {
return `<div class="empty-state"><strong>This relay no longer exists.</strong><p>Click Refresh to rescan ${escapeHtml(RELAYS_ROOT)}.</p></div>`;
}
if (documents.documents.length === 0) {
return `
<div class="empty-state">
<strong>This relay has no documents yet.</strong>
<p>Relay packets usually contain <code>status.md</code>, <code>charter.md</code>, and <code>log.md</code>.</p>
</div>
`;
}
return this.renderSelectedDocument();
}
private renderSelectedDocument(): string {
const documentPath = this.selectedDocumentPath;
const content = this.documentContent;
if (documentPath === undefined) return `<p class="muted">Select a document.</p>`;
if (content === undefined) return `<p class="muted">Loading ${escapeHtml(documentName(documentPath))}…</p>`;
if (content.kind === "unavailable") return renderErrorState("Could not read this document.", content.detail);
if (content.kind === "missing") {
return `<div class="empty-state"><strong>This document no longer exists.</strong><p>Click Refresh to rescan the relay.</p></div>`;
}
if (content.binary) {
return `<div class="empty-state"><strong>Binary file: ${escapeHtml(documentName(documentPath))}</strong><p>Binary documents have no text preview.</p></div>`;
}
const truncation = content.truncated
? `<div class="status info">This document is truncated — only the beginning is shown.</div>`
: "";
if (isMarkdownDocumentPath(documentPath)) {
return `${truncation}<div class="document markdown">${renderRelayDocumentHtml(content.content)}</div>`;
}
return `${truncation}<pre class="document">${escapeHtml(content.content)}</pre>`;
}
}
/** Reload glyph matching the app's own refresh control (AppRefreshControl). */
function refreshIconSvg(): string {
return `
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M20 6v5h-5"></path>
<path d="M4 18v-5h5"></path>
<path d="M18.2 9A7 7 0 0 0 6.1 6.8L4 9"></path>
<path d="M5.8 15a7 7 0 0 0 12.1 2.2L20 15"></path>
</svg>
`;
}
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 `
<div class="empty-state">
<strong>No relays in this workspace.</strong>
<p>Relay packets live in <code>${escapeHtml(RELAYS_ROOT)}/&lt;name&gt;/</code>. This workspace has none yet.</p>
</div>
`;
}
function renderErrorState(message: string, detail: string): string {
return `<div class="status error"><strong>${escapeHtml(message)}</strong><pre>${escapeHtml(detail)}</pre></div>`;
}
function relaysStyles(): string {
return `
<style>
:host { display: contents; }
/* Toolbar and tab strip are panel chrome: they must never flex-shrink
(the app container is a fixed-height flex column; with shrink enabled
the viewer's huge content basis starves them down to a sliver once a
tall document renders). The viewer absorbs all shrinking instead. */
.toolbar { flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--pi-border-muted); }
.toolbar-actions { display: inline-flex; align-items: center; flex-wrap: nowrap; justify-content: flex-end; gap: 8px; min-width: 0; }
.relay-name { min-width: 0; color: var(--pi-text-secondary); overflow-wrap: anywhere; }
/* Bottom padding (not viewer margin) so the gap below the tabs persists
when the viewer's content scrolls up against its top edge. */
.document-tabs { flex: 0 0 auto; display: flex; flex-wrap: nowrap; gap: 6px; padding: 8px 12px; overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; scrollbar-width: thin; }
.viewer { flex: 1 1 auto; box-sizing: border-box; display: grid; align-content: start; gap: 12px; min-height: 0; overflow: auto; padding: 12px; }
/* Grid children default to min-width: auto; without these caps a wide code
block or table would silently stretch the whole viewer track. */
.viewer > * { box-sizing: border-box; min-width: 0; max-width: 100%; }
button, select { border: 1px solid var(--pi-border); border-radius: 7px; background: var(--pi-surface); color: var(--pi-text); font: inherit; }
button { cursor: pointer; padding: 6px 10px; }
button.icon-button { flex: 0 0 auto; display: inline-grid; place-items: center; width: 30px; height: 30px; padding: 0; }
button.icon-button svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; pointer-events: none; }
select { min-width: 0; max-width: 240px; padding: 5px 6px; }
.document-tab { flex: 0 0 auto; white-space: nowrap; font-size: 12px; padding: 4px 10px; }
.document-tab.active { border-color: var(--pi-accent-border); background: var(--pi-accent); color: var(--pi-bg); }
code, pre { border: 1px solid var(--pi-border-muted); border-radius: 6px; background: var(--pi-bg); color: var(--pi-text-secondary); font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
code { padding: 2px 5px; }
pre { margin: 0; overflow: auto; padding: 8px; white-space: pre-wrap; overflow-wrap: anywhere; }
.document.markdown { line-height: 1.5; overflow-wrap: anywhere; }
.document.markdown p, .document.markdown ul, .document.markdown ol, .document.markdown pre, .document.markdown blockquote, .document.markdown .table-scroll { margin: 0 0 10px; }
.document.markdown > :last-child { margin-bottom: 0; }
.document.markdown h1, .document.markdown h2, .document.markdown h3, .document.markdown h4 { line-height: 1.25; margin: 14px 0 8px; }
.document.markdown h1:first-child, .document.markdown h2:first-child, .document.markdown h3:first-child, .document.markdown h4:first-child { margin-top: 0; }
.document.markdown h1 { font-size: 18px; }
.document.markdown h2 { font-size: 16px; }
.document.markdown h3 { font-size: 14px; }
.document.markdown h4 { font-size: 13px; }
.document.markdown ul, .document.markdown ol { padding-left: 22px; }
.document.markdown li + li { margin-top: 3px; }
.document.markdown pre { white-space: pre; overflow-wrap: normal; }
.document.markdown pre code { border: 0; background: transparent; padding: 0; }
.document.markdown img { box-sizing: border-box; max-width: 100%; }
.document.markdown blockquote { border-left: 3px solid var(--pi-border-muted); color: var(--pi-muted); padding-left: 10px; }
.document.markdown a { color: var(--pi-accent); }
.document.markdown .table-scroll { max-width: 100%; overflow-x: auto; }
/* Cells wrap at word boundaries only: a wide table keeps its natural width
and scrolls inside .table-scroll instead of being squeezed unreadably. */
.document.markdown table { border-collapse: collapse; overflow-wrap: normal; }
.document.markdown th, .document.markdown td { border: 1px solid var(--pi-border-muted); padding: 4px 8px; }
.status pre { margin-top: 8px; }
.muted { color: var(--pi-muted); }
.empty-state { border: 1px dashed var(--pi-border-muted); border-radius: 8px; color: var(--pi-muted); padding: 12px; }
.empty-state p { margin: 6px 0 0; }
.status { border: 1px solid var(--pi-border); border-radius: 8px; padding: 10px; }
.status.info { border-color: var(--pi-accent-border); background: var(--pi-bg-overlay-soft); }
.status.error { border-color: var(--pi-danger); color: var(--pi-danger); }
.empty { padding: 16px; color: var(--pi-muted); }
</style>
`;
}
function escapeHtml(value: unknown): string {
return String(value).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
}
function escapeAttr(value: unknown): string {
return escapeHtml(value).replaceAll('"', "&quot;");
}