Archived
359 lines
17 KiB
TypeScript
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)}/<name>/</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("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
}
|
|
|
|
function escapeAttr(value: unknown): string {
|
|
return escapeHtml(value).replaceAll('"', """);
|
|
}
|