Archived
feat(plugins): add bundled relays workspace panel plugin
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Add a built-in Relays plugin: a read-only workspace tab (and **Open Workspace Relays** action) that browses `.pi-web/relays/` packets, with a most-recent relay picker, ordered document tabs, sanitized markdown rendering, and truncation notices.
|
||||
@@ -280,6 +280,25 @@ Task fields:
|
||||
|
||||
Review task configs before running them, especially in shared projects. Workspace Tasks runs trusted shell commands from your repositories.
|
||||
|
||||
### Relays
|
||||
|
||||
**Plugin id:** `relays`
|
||||
**What it does:** adds a read-only **Relays** workspace tab for browsing the workspace's relays, plus an **Open Workspace Relays** action for the selected workspace that opens the same tab.
|
||||
|
||||
A relay is a directory of markdown notes under `.pi-web/relays/<name>/` in the workspace root — the convention used by the Relay method for chaining agent sessions. The tab lists each relay's documents with `status.md`, `charter.md`, and `log.md` first (in that order), followed by any other files alphabetically, and opens `status.md` by default. Markdown documents render as sanitized HTML; other files render as preformatted text, and binary files have no preview. Truncated documents show a notice, and **Refresh** re-scans the workspace and reloads the open document.
|
||||
|
||||
With several relays, a picker pre-selects the most recently modified one; a single relay opens directly. A workspace without `.pi-web/relays/` shows an empty state explaining the convention. The tab never creates, edits, or deletes relay files.
|
||||
|
||||
Relays is enabled by default. To hide it, disable `relays` in **Settings → PI WEB plugins** or set:
|
||||
|
||||
```json
|
||||
{
|
||||
"plugins": {
|
||||
"relays": { "enabled": false }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Discovery and packaging
|
||||
|
||||
PI WEB builds the gateway `/pi-web-plugins/manifest.json` from these sources:
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isMarkdownDocumentPath, renderRelayDocumentHtml } from "./markdownDocument";
|
||||
|
||||
describe("isMarkdownDocumentPath", () => {
|
||||
it("matches .md documents case-insensitively", () => {
|
||||
expect(isMarkdownDocumentPath(".pi-web/relays/r/status.md")).toBe(true);
|
||||
expect(isMarkdownDocumentPath("NOTES.MD")).toBe(true);
|
||||
expect(isMarkdownDocumentPath(".pi-web/relays/r/data.json")).toBe(false);
|
||||
expect(isMarkdownDocumentPath(".pi-web/relays/r/markdown.txt")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderRelayDocumentHtml", () => {
|
||||
it("renders GFM markdown: headings, emphasis, lists, and code fences", () => {
|
||||
const fragment = fragmentOf(renderRelayDocumentHtml([
|
||||
"# Status",
|
||||
"",
|
||||
"All **good**.",
|
||||
"",
|
||||
"- one",
|
||||
"- two",
|
||||
"",
|
||||
"```sh",
|
||||
"echo hi",
|
||||
"```",
|
||||
].join("\n")));
|
||||
|
||||
expect(fragment.querySelector("h1")?.textContent).toBe("Status");
|
||||
expect(fragment.querySelector("strong")?.textContent).toBe("good");
|
||||
expect(fragment.querySelectorAll("li")).toHaveLength(2);
|
||||
expect(fragment.querySelector("pre code")?.textContent).toContain("echo hi");
|
||||
});
|
||||
|
||||
it("renders soft line breaks as <br> so plain-wrapped relay docs stay readable", () => {
|
||||
const fragment = fragmentOf(renderRelayDocumentHtml("one\ntwo"));
|
||||
|
||||
expect(fragment.querySelector("p br")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("escapes raw HTML instead of embedding it", () => {
|
||||
const fragment = fragmentOf(renderRelayDocumentHtml("before\n\n<script>alert('xss')</script>\n\n<em>after</em>"));
|
||||
|
||||
expect(fragment.querySelector("script")).toBeNull();
|
||||
expect(fragment.querySelector("em")).toBeNull();
|
||||
expect(fragment.textContent).toContain("<script>alert('xss')</script>");
|
||||
});
|
||||
|
||||
it("strips javascript: URLs from links and images while keeping safe protocols", () => {
|
||||
const fragment = fragmentOf(renderRelayDocumentHtml([
|
||||
"[bad](javascript:alert('xss'))",
|
||||
"",
|
||||
"[web](https://example.com/docs)",
|
||||
"",
|
||||
"[mail](mailto:[email protected])",
|
||||
"",
|
||||
"[anchor](#details)",
|
||||
"",
|
||||
")",
|
||||
].join("\n")));
|
||||
|
||||
const links = [...fragment.querySelectorAll("a")];
|
||||
expect(links.find((link) => link.textContent === "bad")?.hasAttribute("href")).toBe(false);
|
||||
expect(links.find((link) => link.textContent === "web")?.getAttribute("href")).toBe("https://example.com/docs");
|
||||
expect(links.find((link) => link.textContent === "mail")?.getAttribute("href")).toBe("mailto:[email protected]");
|
||||
expect(links.find((link) => link.textContent === "anchor")?.getAttribute("href")).toBe("#details");
|
||||
expect(fragment.querySelector("img")?.hasAttribute("src")).toBe(false);
|
||||
});
|
||||
|
||||
it("forces rendered links to open in a new tab without opener access", () => {
|
||||
const fragment = fragmentOf(renderRelayDocumentHtml("[docs](https://example.com)"));
|
||||
|
||||
const link = fragment.querySelector("a");
|
||||
expect(link?.getAttribute("target")).toBe("_blank");
|
||||
expect(link?.getAttribute("rel")).toBe("noreferrer noopener");
|
||||
});
|
||||
|
||||
it("wraps tables in a labeled scroll region", () => {
|
||||
const fragment = fragmentOf(renderRelayDocumentHtml("| a | b |\n| - | - |\n| 1 | 2 |"));
|
||||
|
||||
const wrapper = fragment.querySelector(".table-scroll");
|
||||
expect(wrapper?.getAttribute("role")).toBe("region");
|
||||
expect(wrapper?.getAttribute("aria-label")).toBe("Table");
|
||||
expect(wrapper?.querySelector("table")).not.toBeNull();
|
||||
expect(wrapper?.querySelectorAll("td")).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
/** Parse rendered HTML back into a fragment for assertions. */
|
||||
function fragmentOf(html: string): DocumentFragment {
|
||||
const template = document.createElement("template");
|
||||
template.innerHTML = html;
|
||||
return template.content;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { marked } from "./vendor/marked.esm.js";
|
||||
|
||||
// Raw HTML inside relay documents is escaped before sanitizing, so the
|
||||
// sanitizer only ever sees marked-generated markup. This mirrors the safety
|
||||
// rules of the app's renderer in src/client/src/formatting/markdown.ts; the
|
||||
// plugin cannot import that module, so the rules are duplicated here.
|
||||
const renderer = new marked.Renderer();
|
||||
renderer.html = ({ text }) => escapeHtml(text);
|
||||
|
||||
const MAX_MARKDOWN_CACHE_ENTRIES = 300;
|
||||
const markdownHtmlCache = new Map<string, string>();
|
||||
|
||||
/** Relay documents ending in .md render as markdown; everything else stays preformatted text. */
|
||||
export function isMarkdownDocumentPath(path: string): boolean {
|
||||
return path.toLowerCase().endsWith(".md");
|
||||
}
|
||||
|
||||
/** Render one relay markdown document into sanitized HTML safe to interpolate into innerHTML. */
|
||||
export function renderRelayDocumentHtml(source: string): string {
|
||||
const cached = markdownHtmlCache.get(source);
|
||||
if (cached !== undefined) return cached;
|
||||
const html = marked.parse(source, { async: false, breaks: true, gfm: true, renderer });
|
||||
const safeHtml = sanitizeHtml(html);
|
||||
markdownHtmlCache.set(source, safeHtml);
|
||||
if (markdownHtmlCache.size > MAX_MARKDOWN_CACHE_ENTRIES) {
|
||||
const oldest = markdownHtmlCache.keys().next().value;
|
||||
if (oldest !== undefined) markdownHtmlCache.delete(oldest);
|
||||
}
|
||||
return safeHtml;
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">");
|
||||
}
|
||||
|
||||
const TABLE_SCROLL_CLASS = "table-scroll";
|
||||
|
||||
function sanitizeHtml(html: string): string {
|
||||
const template = document.createElement("template");
|
||||
template.innerHTML = html;
|
||||
template.content.querySelectorAll("script, style, iframe, object, embed").forEach((node) => { node.remove(); });
|
||||
template.content.querySelectorAll("*").forEach((element) => {
|
||||
for (const attribute of [...element.attributes]) {
|
||||
const name = attribute.name.toLowerCase();
|
||||
if (name.startsWith("on")) element.removeAttribute(attribute.name);
|
||||
if ((name === "href" || name === "src") && !isSafeUrl(attribute.value)) element.removeAttribute(attribute.name);
|
||||
}
|
||||
if (element.tagName === "A") {
|
||||
element.setAttribute("target", "_blank");
|
||||
element.setAttribute("rel", "noreferrer noopener");
|
||||
}
|
||||
});
|
||||
wrapTablesInScrollRegions(template.content);
|
||||
return template.innerHTML;
|
||||
}
|
||||
|
||||
// Tables keep their natural width and scroll horizontally instead of being
|
||||
// squeezed into the panel, which is unreadable on narrow screens.
|
||||
function wrapTablesInScrollRegions(root: DocumentFragment): void {
|
||||
root.querySelectorAll("table").forEach((table) => {
|
||||
if (table.parentElement?.classList.contains(TABLE_SCROLL_CLASS) === true) return;
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = TABLE_SCROLL_CLASS;
|
||||
wrapper.setAttribute("role", "region");
|
||||
wrapper.setAttribute("aria-label", "Table");
|
||||
wrapper.setAttribute("tabindex", "0");
|
||||
table.before(wrapper);
|
||||
wrapper.append(table);
|
||||
});
|
||||
}
|
||||
|
||||
function isSafeUrl(url: string): boolean {
|
||||
if (url.startsWith("#") || url.startsWith("/")) return true;
|
||||
try {
|
||||
return ["http:", "https:", "mailto:"].includes(new URL(url).protocol);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "@pi-web/relays-plugin",
|
||||
"private": true,
|
||||
"piWeb": {
|
||||
"plugins": [
|
||||
{ "id": "relays", "module": "pi-web-plugin.js" }
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { PiWebPlugin } from "@jmfederico/pi-web/plugin-api";
|
||||
import { RELAYS_ROOT } from "./relayDiscovery.js";
|
||||
import { defineRelaysPanelElement } from "./relaysPanelElement.js";
|
||||
|
||||
const plugin: PiWebPlugin = {
|
||||
apiVersion: 1,
|
||||
name: "Relays",
|
||||
activate: ({ pluginId, html, svg }) => {
|
||||
defineRelaysPanelElement();
|
||||
|
||||
return {
|
||||
contributions: {
|
||||
actions: [
|
||||
{
|
||||
id: "workspace.open-relays",
|
||||
title: "Open Workspace Relays",
|
||||
description: `Open the workspace Relays tab. Relays live in ${RELAYS_ROOT}.`,
|
||||
group: "Workspace",
|
||||
enabled: (context) => context.state.selectedWorkspace !== undefined,
|
||||
run: (context) => {
|
||||
if (context.state.selectedWorkspace === undefined) return;
|
||||
context.selectWorkspaceTool(`${pluginId}:workspace.relays`);
|
||||
},
|
||||
},
|
||||
],
|
||||
workspacePanels: [
|
||||
{
|
||||
id: "workspace.relays",
|
||||
title: "Relays",
|
||||
icon: svg`
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 17H7A5 5 0 0 1 7 7h2"></path>
|
||||
<path d="M15 7h2a5 5 0 1 1 0 10h-2"></path>
|
||||
<line x1="8" y1="12" x2="16" y2="12"></line>
|
||||
</svg>
|
||||
`,
|
||||
order: 50,
|
||||
render: (context) => html`<pi-web-relays-panel .context=${context}></pi-web-relays-panel>`,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default plugin;
|
||||
@@ -0,0 +1,231 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { FileContentResponse, FileTreeEntry, FileTreeResponse } from "@jmfederico/pi-web/plugin-api";
|
||||
import {
|
||||
defaultRelayDocument,
|
||||
listRelayDocuments,
|
||||
listWorkspaceRelays,
|
||||
orderRelayDocuments,
|
||||
readRelayDocument,
|
||||
RELAYS_ROOT,
|
||||
sortRelaysByRecency,
|
||||
type RelayDiscoveryFiles,
|
||||
type RelayDocumentEntry,
|
||||
type RelaySummary,
|
||||
} from "./relayDiscovery";
|
||||
|
||||
describe("listWorkspaceRelays", () => {
|
||||
it("lists relay directories through the workspace files helper, most recently modified first", async () => {
|
||||
const listFiles = vi.fn<RelayDiscoveryFiles["listFiles"]>(() => Promise.resolve(tree([
|
||||
directoryEntry("older", "2026-01-01T00:00:00.000Z"),
|
||||
fileEntry("stray-file.md", "2026-03-01T00:00:00.000Z"),
|
||||
directoryEntry("newer", "2026-02-01T00:00:00.000Z"),
|
||||
symlinkEntry("linked", "2026-04-01T00:00:00.000Z"),
|
||||
])));
|
||||
const files = filesWith({ listFiles });
|
||||
|
||||
const result = await listWorkspaceRelays(files);
|
||||
|
||||
expect(listFiles).toHaveBeenCalledWith(RELAYS_ROOT);
|
||||
expect(result).toEqual({
|
||||
kind: "loaded",
|
||||
relays: [
|
||||
{ name: "newer", path: `${RELAYS_ROOT}/newer`, modifiedAt: "2026-02-01T00:00:00.000Z" },
|
||||
{ name: "older", path: `${RELAYS_ROOT}/older`, modifiedAt: "2026-01-01T00:00:00.000Z" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("reports an empty relays directory as zero relays", async () => {
|
||||
const files = filesWith({ listFiles: () => Promise.resolve(tree([])) });
|
||||
|
||||
await expect(listWorkspaceRelays(files)).resolves.toEqual({ kind: "loaded", relays: [] });
|
||||
});
|
||||
|
||||
it("treats a missing relays directory as missing rather than a failure", async () => {
|
||||
const files = filesWith({ listFiles: () => Promise.reject(new Error("Path does not exist")) });
|
||||
|
||||
await expect(listWorkspaceRelays(files)).resolves.toEqual({ kind: "missing" });
|
||||
});
|
||||
|
||||
it("treats a relays root that is not a directory as missing rather than a failure", async () => {
|
||||
const files = filesWith({ listFiles: () => Promise.reject(new Error("Path is not a directory")) });
|
||||
|
||||
await expect(listWorkspaceRelays(files)).resolves.toEqual({ kind: "missing" });
|
||||
});
|
||||
|
||||
it("surfaces other listing failures as unavailable with the error detail", async () => {
|
||||
const files = filesWith({ listFiles: () => Promise.reject(new Error("connection lost")) });
|
||||
|
||||
await expect(listWorkspaceRelays(files)).resolves.toEqual({ kind: "unavailable", detail: "connection lost" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("sortRelaysByRecency", () => {
|
||||
it("sorts undated and invalid-dated relays after dated ones, alphabetically", () => {
|
||||
const relays: RelaySummary[] = [
|
||||
{ name: "zulu", path: "zulu" },
|
||||
{ name: "middle", path: "middle", modifiedAt: "2026-02-01T00:00:00.000Z" },
|
||||
{ name: "alpha", path: "alpha" },
|
||||
{ name: "broken", path: "broken", modifiedAt: "not-a-date" },
|
||||
{ name: "newest", path: "newest", modifiedAt: "2026-03-01T00:00:00.000Z" },
|
||||
];
|
||||
|
||||
expect(sortRelaysByRecency(relays).map((relay) => relay.name)).toEqual(["newest", "middle", "alpha", "broken", "zulu"]);
|
||||
});
|
||||
|
||||
it("does not mutate the input array", () => {
|
||||
const relays: RelaySummary[] = [
|
||||
{ name: "b", path: "b", modifiedAt: "2026-01-01T00:00:00.000Z" },
|
||||
{ name: "a", path: "a", modifiedAt: "2026-02-01T00:00:00.000Z" },
|
||||
];
|
||||
|
||||
sortRelaysByRecency(relays);
|
||||
|
||||
expect(relays.map((relay) => relay.name)).toEqual(["b", "a"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listRelayDocuments", () => {
|
||||
it("lists relay files with anchor documents first, then alphabetical", async () => {
|
||||
const relayPath = `${RELAYS_ROOT}/my-relay`;
|
||||
const relayFile = (name: string): FileTreeEntry => ({ name, path: `${relayPath}/${name}`, type: "file" });
|
||||
const listFiles = vi.fn<RelayDiscoveryFiles["listFiles"]>(() => Promise.resolve(tree([
|
||||
relayFile("notes.md"),
|
||||
relayFile("log.md"),
|
||||
directoryEntry("subdir"),
|
||||
relayFile("status.md"),
|
||||
relayFile("charter.md"),
|
||||
relayFile("data.json"),
|
||||
])));
|
||||
const files = filesWith({ listFiles });
|
||||
|
||||
const result = await listRelayDocuments(files, relayPath);
|
||||
|
||||
expect(listFiles).toHaveBeenCalledWith(relayPath);
|
||||
expect(result).toEqual({
|
||||
kind: "loaded",
|
||||
documents: [
|
||||
{ name: "status.md", path: `${relayPath}/status.md`, modifiedAt: undefined },
|
||||
{ name: "charter.md", path: `${relayPath}/charter.md`, modifiedAt: undefined },
|
||||
{ name: "log.md", path: `${relayPath}/log.md`, modifiedAt: undefined },
|
||||
{ name: "data.json", path: `${relayPath}/data.json`, modifiedAt: undefined },
|
||||
{ name: "notes.md", path: `${relayPath}/notes.md`, modifiedAt: undefined },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("treats a vanished relay directory as missing", async () => {
|
||||
const files = filesWith({ listFiles: () => Promise.reject(new Error("Path does not exist")) });
|
||||
|
||||
await expect(listRelayDocuments(files, `${RELAYS_ROOT}/gone`)).resolves.toEqual({ kind: "missing" });
|
||||
});
|
||||
|
||||
it("surfaces other listing failures as unavailable", async () => {
|
||||
const files = filesWith({ listFiles: () => Promise.reject(new Error("boom")) });
|
||||
|
||||
await expect(listRelayDocuments(files, `${RELAYS_ROOT}/x`)).resolves.toEqual({ kind: "unavailable", detail: "boom" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("orderRelayDocuments", () => {
|
||||
it("puts anchor documents first in fixed order without mutating the input", () => {
|
||||
const documents: RelayDocumentEntry[] = [
|
||||
{ name: "zebra.md", path: "zebra.md" },
|
||||
{ name: "log.md", path: "log.md" },
|
||||
{ name: "status.md", path: "status.md" },
|
||||
];
|
||||
|
||||
const ordered = orderRelayDocuments(documents);
|
||||
|
||||
expect(ordered.map((document) => document.name)).toEqual(["status.md", "log.md", "zebra.md"]);
|
||||
expect(documents.map((document) => document.name)).toEqual(["zebra.md", "log.md", "status.md"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaultRelayDocument", () => {
|
||||
it("picks status.md when present", () => {
|
||||
const documents: RelayDocumentEntry[] = [
|
||||
{ name: "charter.md", path: "charter.md" },
|
||||
{ name: "status.md", path: "status.md" },
|
||||
];
|
||||
|
||||
expect(defaultRelayDocument(documents)?.name).toBe("status.md");
|
||||
});
|
||||
|
||||
it("falls back to the first ordered document when status.md is absent", () => {
|
||||
const documents: RelayDocumentEntry[] = [
|
||||
{ name: "alpha.md", path: "alpha.md" },
|
||||
{ name: "charter.md", path: "charter.md" },
|
||||
];
|
||||
|
||||
expect(defaultRelayDocument(documents)?.name).toBe("charter.md");
|
||||
});
|
||||
|
||||
it("returns undefined for a relay without documents", () => {
|
||||
expect(defaultRelayDocument([])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("readRelayDocument", () => {
|
||||
it("returns document content with its truncation and binary flags", async () => {
|
||||
const files = filesWith({
|
||||
readFile: () => Promise.resolve(fileContent({ content: "# Status", truncated: true, binary: false })),
|
||||
});
|
||||
|
||||
await expect(readRelayDocument(files, `${RELAYS_ROOT}/r/log.md`)).resolves.toEqual({
|
||||
kind: "loaded",
|
||||
content: "# Status",
|
||||
truncated: true,
|
||||
binary: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("treats a vanished document as missing", async () => {
|
||||
const files = filesWith({ readFile: () => Promise.reject(new Error("Path does not exist")) });
|
||||
|
||||
await expect(readRelayDocument(files, `${RELAYS_ROOT}/r/gone.md`)).resolves.toEqual({ kind: "missing" });
|
||||
});
|
||||
|
||||
it("surfaces other read failures as unavailable", async () => {
|
||||
const files = filesWith({ readFile: () => Promise.reject(new Error("boom")) });
|
||||
|
||||
await expect(readRelayDocument(files, `${RELAYS_ROOT}/r/x.md`)).resolves.toEqual({ kind: "unavailable", detail: "boom" });
|
||||
});
|
||||
});
|
||||
|
||||
function filesWith(overrides: Partial<RelayDiscoveryFiles>): RelayDiscoveryFiles {
|
||||
return {
|
||||
listFiles: () => Promise.reject(new Error("listFiles not expected")),
|
||||
readFile: () => Promise.reject(new Error("readFile not expected")),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function tree(entries: FileTreeEntry[]): FileTreeResponse {
|
||||
return { path: RELAYS_ROOT, entries, scannedAt: "2026-01-01T00:00:00.000Z", truncated: false };
|
||||
}
|
||||
|
||||
function directoryEntry(name: string, modifiedAt?: string): FileTreeEntry {
|
||||
return { name, path: `${RELAYS_ROOT}/${name}`, type: "directory", ...(modifiedAt === undefined ? {} : { modifiedAt }) };
|
||||
}
|
||||
|
||||
function fileEntry(name: string, modifiedAt?: string): FileTreeEntry {
|
||||
return { name, path: `${RELAYS_ROOT}/${name}`, type: "file", ...(modifiedAt === undefined ? {} : { modifiedAt }) };
|
||||
}
|
||||
|
||||
function symlinkEntry(name: string, modifiedAt?: string): FileTreeEntry {
|
||||
return { name, path: `${RELAYS_ROOT}/${name}`, type: "symlink", ...(modifiedAt === undefined ? {} : { modifiedAt }) };
|
||||
}
|
||||
|
||||
function fileContent(overrides: Partial<FileContentResponse>): FileContentResponse {
|
||||
return {
|
||||
path: `${RELAYS_ROOT}/r/log.md`,
|
||||
encoding: "utf8",
|
||||
size: 8,
|
||||
modifiedAt: "2026-01-01T00:00:00.000Z",
|
||||
content: "",
|
||||
truncated: false,
|
||||
binary: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { FileContentResponse, FileTreeEntry, FileTreeResponse } from "@jmfederico/pi-web/plugin-api";
|
||||
|
||||
export const RELAYS_ROOT = ".pi-web/relays";
|
||||
|
||||
/** Documents that anchor a relay packet, in display order. Any other files follow alphabetically. */
|
||||
export const RELAY_ANCHOR_DOCUMENTS: readonly string[] = ["status.md", "charter.md", "log.md"];
|
||||
|
||||
/** Structural subset of the plugin WorkspaceFiles helper this module needs. */
|
||||
export interface RelayDiscoveryFiles {
|
||||
listFiles(path: string): Promise<FileTreeResponse>;
|
||||
readFile(path: string): Promise<FileContentResponse>;
|
||||
}
|
||||
|
||||
export interface RelaySummary {
|
||||
name: string;
|
||||
path: string;
|
||||
modifiedAt?: string | undefined;
|
||||
}
|
||||
|
||||
export interface RelayDocumentEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
modifiedAt?: string | undefined;
|
||||
}
|
||||
|
||||
export type RelaysListing =
|
||||
| { kind: "loaded"; relays: RelaySummary[] }
|
||||
| { kind: "missing" }
|
||||
| { kind: "unavailable"; detail: string };
|
||||
|
||||
export type RelayDocumentsListing =
|
||||
| { kind: "loaded"; documents: RelayDocumentEntry[] }
|
||||
| { kind: "missing" }
|
||||
| { kind: "unavailable"; detail: string };
|
||||
|
||||
export type RelayDocumentContent =
|
||||
| { kind: "loaded"; content: string; truncated: boolean; binary: boolean }
|
||||
| { kind: "missing" }
|
||||
| { kind: "unavailable"; detail: string };
|
||||
|
||||
// The workspace file API rejects with these messages when a path is absent or
|
||||
// is not a directory. For the relays root both mean "zero relays", not a failure.
|
||||
const missingListingErrorMessages = new Set(["Path does not exist", "Path is not a directory"]);
|
||||
|
||||
/** List the workspace's relays, most recently modified first. Never rejects. */
|
||||
export async function listWorkspaceRelays(files: RelayDiscoveryFiles): Promise<RelaysListing> {
|
||||
let listing: FileTreeResponse;
|
||||
try {
|
||||
listing = await files.listFiles(RELAYS_ROOT);
|
||||
} catch (error) {
|
||||
return fileAccessFailure(error);
|
||||
}
|
||||
const relays = sortRelaysByRecency(
|
||||
listing.entries
|
||||
.filter((entry) => entry.type === "directory")
|
||||
.map((entry) => toRelaySummary(entry)),
|
||||
);
|
||||
return { kind: "loaded", relays };
|
||||
}
|
||||
|
||||
/** List one relay's documents: status.md, charter.md, log.md first, then alphabetical. Never rejects. */
|
||||
export async function listRelayDocuments(files: RelayDiscoveryFiles, relayPath: string): Promise<RelayDocumentsListing> {
|
||||
let listing: FileTreeResponse;
|
||||
try {
|
||||
listing = await files.listFiles(relayPath);
|
||||
} catch (error) {
|
||||
return fileAccessFailure(error);
|
||||
}
|
||||
const documents = orderRelayDocuments(
|
||||
listing.entries
|
||||
.filter((entry) => entry.type === "file")
|
||||
.map((entry) => toRelayDocumentEntry(entry)),
|
||||
);
|
||||
return { kind: "loaded", documents };
|
||||
}
|
||||
|
||||
/** Read one relay document. Never rejects. */
|
||||
export async function readRelayDocument(files: RelayDiscoveryFiles, documentPath: string): Promise<RelayDocumentContent> {
|
||||
try {
|
||||
const file = await files.readFile(documentPath);
|
||||
return { kind: "loaded", content: file.content, truncated: file.truncated, binary: file.binary };
|
||||
} catch (error) {
|
||||
return fileAccessFailure(error);
|
||||
}
|
||||
}
|
||||
|
||||
/** Newest first; relays without a usable modifiedAt sort last, alphabetically. */
|
||||
export function sortRelaysByRecency(relays: RelaySummary[]): RelaySummary[] {
|
||||
return [...relays].sort(compareRelaysByRecency);
|
||||
}
|
||||
|
||||
/** Anchor documents first in fixed order, then everything else alphabetically. */
|
||||
export function orderRelayDocuments(documents: RelayDocumentEntry[]): RelayDocumentEntry[] {
|
||||
return [...documents].sort(compareRelayDocuments);
|
||||
}
|
||||
|
||||
/** The document a relay opens on: status.md when present, otherwise the first ordered document. */
|
||||
export function defaultRelayDocument(documents: RelayDocumentEntry[]): RelayDocumentEntry | undefined {
|
||||
// Anchor ordering guarantees status.md is first whenever it exists.
|
||||
return orderRelayDocuments(documents)[0];
|
||||
}
|
||||
|
||||
function toRelaySummary(entry: FileTreeEntry): RelaySummary {
|
||||
return { name: entry.name, path: entry.path, modifiedAt: entry.modifiedAt };
|
||||
}
|
||||
|
||||
function toRelayDocumentEntry(entry: FileTreeEntry): RelayDocumentEntry {
|
||||
return { name: entry.name, path: entry.path, modifiedAt: entry.modifiedAt };
|
||||
}
|
||||
|
||||
function compareRelaysByRecency(left: RelaySummary, right: RelaySummary): number {
|
||||
const leftTime = timestampOf(left.modifiedAt);
|
||||
const rightTime = timestampOf(right.modifiedAt);
|
||||
if (leftTime !== undefined && rightTime !== undefined && leftTime !== rightTime) return rightTime - leftTime;
|
||||
if (leftTime !== undefined) return -1;
|
||||
if (rightTime !== undefined) return 1;
|
||||
return left.name.localeCompare(right.name);
|
||||
}
|
||||
|
||||
function timestampOf(modifiedAt: string | undefined): number | undefined {
|
||||
if (modifiedAt === undefined) return undefined;
|
||||
const time = Date.parse(modifiedAt);
|
||||
return Number.isNaN(time) ? undefined : time;
|
||||
}
|
||||
|
||||
function compareRelayDocuments(left: RelayDocumentEntry, right: RelayDocumentEntry): number {
|
||||
const anchorOrder = anchorIndexOf(left.name) - anchorIndexOf(right.name);
|
||||
if (anchorOrder !== 0) return anchorOrder;
|
||||
return left.name.localeCompare(right.name);
|
||||
}
|
||||
|
||||
function anchorIndexOf(name: string): number {
|
||||
const index = RELAY_ANCHOR_DOCUMENTS.indexOf(name);
|
||||
return index === -1 ? RELAY_ANCHOR_DOCUMENTS.length : index;
|
||||
}
|
||||
|
||||
function fileAccessFailure(error: unknown): { kind: "missing" } | { kind: "unavailable"; detail: string } {
|
||||
if (error instanceof Error && missingListingErrorMessages.has(error.message)) return { kind: "missing" };
|
||||
return { kind: "unavailable", detail: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { afterEach, describe, expect, it, vi, type Mock } from "vitest";
|
||||
import type { FileContentResponse, FileTreeEntry, WorkspaceFiles, WorkspacePanelContext } from "@jmfederico/pi-web/plugin-api";
|
||||
import { RELAYS_ROOT, type RelayDiscoveryFiles } from "./relayDiscovery";
|
||||
import { defineRelaysPanelElement, relaysPanelTagName } from "./relaysPanelElement";
|
||||
|
||||
interface RelaysPanelTestElement extends HTMLElement {
|
||||
context: WorkspacePanelContext | undefined;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"pi-web-relays-panel": RelaysPanelTestElement;
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
describe("workspace states", () => {
|
||||
it("asks for a workspace when no context is set", async () => {
|
||||
const panel = await mountPanel();
|
||||
|
||||
expect(shadow(panel).textContent).toContain("Select a workspace.");
|
||||
});
|
||||
|
||||
it("explains the relays convention when the workspace has no relays root", async () => {
|
||||
// The fake rejects unknown paths with "Path does not exist".
|
||||
const panel = await mountPanel(panelContext(workspaceFilesFake()));
|
||||
|
||||
expect(viewerText(panel)).toContain("No relays in this workspace.");
|
||||
expect(viewerText(panel)).toContain(`${RELAYS_ROOT}/<name>/`);
|
||||
});
|
||||
|
||||
it("shows the same empty state when the relays root exists but is empty", async () => {
|
||||
const fake = workspaceFilesFake();
|
||||
fake.addDirectory(RELAYS_ROOT, []);
|
||||
|
||||
const panel = await mountPanel(panelContext(fake));
|
||||
|
||||
expect(viewerText(panel)).toContain("No relays in this workspace.");
|
||||
});
|
||||
|
||||
it("surfaces a scan failure with its detail", async () => {
|
||||
const fake = workspaceFilesFake();
|
||||
fake.failWith(RELAYS_ROOT, new Error("connection lost"));
|
||||
|
||||
const panel = await mountPanel(panelContext(fake));
|
||||
|
||||
const error = shadow(panel).querySelector(".status.error");
|
||||
expect(error?.textContent).toContain("Could not scan workspace relays.");
|
||||
expect(error?.textContent).toContain("connection lost");
|
||||
});
|
||||
});
|
||||
|
||||
describe("single relay", () => {
|
||||
it("auto-opens the relay without a picker and renders its default document", async () => {
|
||||
const fake = workspaceFilesFake();
|
||||
fake.addDirectory(RELAYS_ROOT, [relayDirectory("only-relay", "2026-02-01T00:00:00.000Z")]);
|
||||
fake.addDirectory(`${RELAYS_ROOT}/only-relay`, [
|
||||
relayDocument("only-relay", "charter.md"),
|
||||
relayDocument("only-relay", "status.md"),
|
||||
]);
|
||||
fake.addDocument(`${RELAYS_ROOT}/only-relay/status.md`, "# Status\nAll good.");
|
||||
fake.addDocument(`${RELAYS_ROOT}/only-relay/charter.md`, "# Charter");
|
||||
|
||||
const panel = await mountPanel(panelContext(fake));
|
||||
|
||||
expect(picker(panel)).toBeNull();
|
||||
expect(shadow(panel).querySelector(".relay-name")?.textContent).toBe("only-relay");
|
||||
expect(tabNames(panel)).toEqual(["status.md", "charter.md"]);
|
||||
expect(activeTab(panel)?.textContent).toBe("status.md");
|
||||
expect(documentText(panel)).toContain("All good.");
|
||||
expect(fake.readFile).toHaveBeenCalledWith(`${RELAYS_ROOT}/only-relay/status.md`);
|
||||
});
|
||||
|
||||
it("escapes filesystem-derived names and paths", async () => {
|
||||
const fake = workspaceFilesFake();
|
||||
fake.addDirectory(RELAYS_ROOT, [relayDirectory("<img src=x>")]);
|
||||
fake.addDirectory(`${RELAYS_ROOT}/<img src=x>`, [relayDocument("<img src=x>", "status.md")]);
|
||||
fake.addDocument(`${RELAYS_ROOT}/<img src=x>/status.md`, "safe");
|
||||
|
||||
const panel = await mountPanel(panelContext(fake));
|
||||
|
||||
expect(shadow(panel).querySelector("img")).toBeNull();
|
||||
expect(shadow(panel).querySelector(".relay-name")?.textContent).toBe("<img src=x>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("multiple relays", () => {
|
||||
it("pre-selects the most recently modified relay and opens another on picker change", async () => {
|
||||
const fake = workspaceFilesFake();
|
||||
fake.addDirectory(RELAYS_ROOT, [
|
||||
relayDirectory("older", "2026-01-01T00:00:00.000Z"),
|
||||
relayDirectory("newer", "2026-03-01T00:00:00.000Z"),
|
||||
]);
|
||||
fake.addDirectory(`${RELAYS_ROOT}/newer`, [relayDocument("newer", "status.md")]);
|
||||
fake.addDirectory(`${RELAYS_ROOT}/older`, [relayDocument("older", "status.md")]);
|
||||
fake.addDocument(`${RELAYS_ROOT}/newer/status.md`, "newer status");
|
||||
fake.addDocument(`${RELAYS_ROOT}/older/status.md`, "older status");
|
||||
|
||||
const panel = await mountPanel(panelContext(fake));
|
||||
|
||||
expect(picker(panel)?.value).toBe(`${RELAYS_ROOT}/newer`);
|
||||
expect(documentText(panel)).toBe("newer status");
|
||||
|
||||
const select = picker(panel);
|
||||
if (select === null) throw new Error("relay picker missing");
|
||||
select.value = `${RELAYS_ROOT}/older`;
|
||||
select.dispatchEvent(new Event("change"));
|
||||
await flushAsync();
|
||||
|
||||
expect(fake.listFiles).toHaveBeenCalledWith(`${RELAYS_ROOT}/older`);
|
||||
expect(documentText(panel)).toBe("older status");
|
||||
});
|
||||
});
|
||||
|
||||
describe("document tabs", () => {
|
||||
it("orders tabs with anchor documents first and loads the clicked tab's document", async () => {
|
||||
const fake = workspaceFilesFake();
|
||||
fake.addDirectory(RELAYS_ROOT, [relayDirectory("relay")]);
|
||||
fake.addDirectory(`${RELAYS_ROOT}/relay`, [
|
||||
relayDocument("relay", "notes.md"),
|
||||
relayDocument("relay", "log.md"),
|
||||
relayDocument("relay", "data.json"),
|
||||
relayDocument("relay", "charter.md"),
|
||||
relayDocument("relay", "status.md"),
|
||||
]);
|
||||
fake.addDocument(`${RELAYS_ROOT}/relay/status.md`, "status body");
|
||||
fake.addDocument(`${RELAYS_ROOT}/relay/log.md`, "log body");
|
||||
|
||||
const panel = await mountPanel(panelContext(fake));
|
||||
|
||||
expect(tabNames(panel)).toEqual(["status.md", "charter.md", "log.md", "data.json", "notes.md"]);
|
||||
expect(activeTab(panel)?.textContent).toBe("status.md");
|
||||
|
||||
tabNamed(panel, "log.md").click();
|
||||
await flushAsync();
|
||||
|
||||
expect(fake.readFile).toHaveBeenCalledWith(`${RELAYS_ROOT}/relay/log.md`);
|
||||
expect(activeTab(panel)?.textContent).toBe("log.md");
|
||||
expect(documentText(panel)).toBe("log body");
|
||||
});
|
||||
|
||||
it("renders all document tabs as direct children of one tab strip", async () => {
|
||||
// The strip scrolls horizontally instead of wrapping, so every tab must be
|
||||
// a direct child of the single nav.document-tabs row.
|
||||
const fake = workspaceFilesFake();
|
||||
fake.addDirectory(RELAYS_ROOT, [relayDirectory("relay")]);
|
||||
fake.addDirectory(`${RELAYS_ROOT}/relay`, [
|
||||
relayDocument("relay", "notes.md"),
|
||||
relayDocument("relay", "log.md"),
|
||||
relayDocument("relay", "status.md"),
|
||||
]);
|
||||
fake.addDocument(`${RELAYS_ROOT}/relay/status.md`, "status body");
|
||||
|
||||
const panel = await mountPanel(panelContext(fake));
|
||||
|
||||
const strips = shadow(panel).querySelectorAll("nav.document-tabs");
|
||||
expect(strips.length).toBe(1);
|
||||
const strip = strips[0];
|
||||
expect(strip?.getAttribute("aria-label")).toBe("Relay documents");
|
||||
const children = [...(strip?.children ?? [])];
|
||||
expect(children.every((child) => child instanceof HTMLButtonElement)).toBe(true);
|
||||
expect(children.map((child) => child.textContent)).toEqual(["status.md", "log.md", "notes.md"]);
|
||||
});
|
||||
|
||||
it("shows a truncation notice when the open document is truncated", async () => {
|
||||
const fake = workspaceFilesFake();
|
||||
fake.addDirectory(RELAYS_ROOT, [relayDirectory("relay")]);
|
||||
fake.addDirectory(`${RELAYS_ROOT}/relay`, [relayDocument("relay", "log.md")]);
|
||||
fake.addDocument(`${RELAYS_ROOT}/relay/log.md`, "partial log", { truncated: true });
|
||||
|
||||
const panel = await mountPanel(panelContext(fake));
|
||||
|
||||
expect(shadow(panel).querySelector(".status.info")?.textContent).toContain("truncated");
|
||||
expect(documentText(panel)).toBe("partial log");
|
||||
});
|
||||
|
||||
it("explains when a document has no text preview", async () => {
|
||||
const fake = workspaceFilesFake();
|
||||
fake.addDirectory(RELAYS_ROOT, [relayDirectory("relay")]);
|
||||
fake.addDirectory(`${RELAYS_ROOT}/relay`, [relayDocument("relay", "status.md")]);
|
||||
fake.addDocument(`${RELAYS_ROOT}/relay/status.md`, "AAAA", { binary: true });
|
||||
|
||||
const panel = await mountPanel(panelContext(fake));
|
||||
|
||||
expect(viewerText(panel)).toContain("Binary file: status.md");
|
||||
expect(shadow(panel).querySelector("pre.document")).toBeNull();
|
||||
});
|
||||
|
||||
it("explains when the open document vanishes between listing and read", async () => {
|
||||
const fake = workspaceFilesFake();
|
||||
fake.addDirectory(RELAYS_ROOT, [relayDirectory("relay")]);
|
||||
fake.addDirectory(`${RELAYS_ROOT}/relay`, [relayDocument("relay", "status.md")]);
|
||||
// No addDocument: the fake readFile rejects with "Path does not exist".
|
||||
|
||||
const panel = await mountPanel(panelContext(fake));
|
||||
|
||||
expect(viewerText(panel)).toContain("This document no longer exists.");
|
||||
});
|
||||
|
||||
it("explains when a relay has no documents", async () => {
|
||||
const fake = workspaceFilesFake();
|
||||
fake.addDirectory(RELAYS_ROOT, [relayDirectory("relay")]);
|
||||
fake.addDirectory(`${RELAYS_ROOT}/relay`, []);
|
||||
|
||||
const panel = await mountPanel(panelContext(fake));
|
||||
|
||||
expect(viewerText(panel)).toContain("This relay has no documents yet.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("markdown rendering", () => {
|
||||
it("renders .md documents as sanitized markdown HTML", async () => {
|
||||
const fake = workspaceFilesFake();
|
||||
fake.addDirectory(RELAYS_ROOT, [relayDirectory("relay")]);
|
||||
fake.addDirectory(`${RELAYS_ROOT}/relay`, [relayDocument("relay", "status.md")]);
|
||||
fake.addDocument(`${RELAYS_ROOT}/relay/status.md`, [
|
||||
"# Status",
|
||||
"",
|
||||
"See [the docs](https://example.com/docs).",
|
||||
"",
|
||||
"<script>alert('xss')</script>",
|
||||
"",
|
||||
"[click](javascript:alert('xss'))",
|
||||
].join("\n"));
|
||||
|
||||
const panel = await mountPanel(panelContext(fake));
|
||||
|
||||
const rendered = shadow(panel).querySelector(".document.markdown");
|
||||
expect(rendered).not.toBeNull();
|
||||
expect(shadow(panel).querySelector("pre.document")).toBeNull();
|
||||
expect(rendered?.querySelector("h1")?.textContent).toBe("Status");
|
||||
expect(rendered?.querySelector("script")).toBeNull();
|
||||
const docsLink = rendered?.querySelector('a[href="https://example.com/docs"]');
|
||||
expect(docsLink?.getAttribute("target")).toBe("_blank");
|
||||
expect(docsLink?.getAttribute("rel")).toBe("noreferrer noopener");
|
||||
const unsafeLink = [...rendered?.querySelectorAll("a") ?? []].find((link) => link.textContent === "click");
|
||||
expect(unsafeLink?.hasAttribute("href")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps non-markdown documents as escaped preformatted text", async () => {
|
||||
const fake = workspaceFilesFake();
|
||||
fake.addDirectory(RELAYS_ROOT, [relayDirectory("relay")]);
|
||||
fake.addDirectory(`${RELAYS_ROOT}/relay`, [relayDocument("relay", "notes.txt")]);
|
||||
fake.addDocument(`${RELAYS_ROOT}/relay/notes.txt`, "# not a heading\n<script>alert('xss')</script>");
|
||||
|
||||
const panel = await mountPanel(panelContext(fake));
|
||||
|
||||
expect(shadow(panel).querySelector(".document.markdown")).toBeNull();
|
||||
const pre = shadow(panel).querySelector("pre.document");
|
||||
expect(pre?.textContent).toContain("# not a heading");
|
||||
expect(shadow(panel).querySelector(".viewer h1")).toBeNull();
|
||||
expect(shadow(panel).querySelector(".viewer script")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the truncation notice ahead of rendered markdown", async () => {
|
||||
const fake = workspaceFilesFake();
|
||||
fake.addDirectory(RELAYS_ROOT, [relayDirectory("relay")]);
|
||||
fake.addDirectory(`${RELAYS_ROOT}/relay`, [relayDocument("relay", "log.md")]);
|
||||
fake.addDocument(`${RELAYS_ROOT}/relay/log.md`, "# Partial", { truncated: true });
|
||||
|
||||
const panel = await mountPanel(panelContext(fake));
|
||||
|
||||
const viewer = shadow(panel).querySelector(".viewer");
|
||||
expect(viewer?.querySelector(".status.info")?.textContent).toContain("truncated");
|
||||
expect(viewer?.querySelector(".document.markdown h1")?.textContent).toBe("Partial");
|
||||
});
|
||||
});
|
||||
|
||||
describe("refresh and context changes", () => {
|
||||
it("renders Refresh as an icon-only button with an accessible name", async () => {
|
||||
const fake = workspaceFilesFake();
|
||||
fake.addDirectory(RELAYS_ROOT, []);
|
||||
|
||||
const panel = await mountPanel(panelContext(fake));
|
||||
|
||||
const button = refreshButton(panel);
|
||||
expect(button.getAttribute("aria-label")).toBe("Refresh");
|
||||
expect(button.getAttribute("title")).toBe("Refresh");
|
||||
expect(button.textContent.trim()).toBe("");
|
||||
const icon = button.querySelector("svg");
|
||||
expect(icon).not.toBeNull();
|
||||
expect(icon?.getAttribute("aria-hidden")).toBe("true");
|
||||
|
||||
fake.listFiles.mockClear();
|
||||
button.click();
|
||||
await flushAsync();
|
||||
expect(fake.listFiles).toHaveBeenCalledWith(RELAYS_ROOT);
|
||||
});
|
||||
|
||||
it("re-scans on Refresh while keeping the open relay and document", async () => {
|
||||
const fake = workspaceFilesFake();
|
||||
fake.addDirectory(RELAYS_ROOT, [
|
||||
relayDirectory("alpha", "2026-01-01T00:00:00.000Z"),
|
||||
relayDirectory("beta", "2026-02-01T00:00:00.000Z"),
|
||||
]);
|
||||
fake.addDirectory(`${RELAYS_ROOT}/alpha`, [relayDocument("alpha", "status.md"), relayDocument("alpha", "log.md")]);
|
||||
fake.addDirectory(`${RELAYS_ROOT}/beta`, [relayDocument("beta", "status.md")]);
|
||||
fake.addDocument(`${RELAYS_ROOT}/alpha/status.md`, "alpha status");
|
||||
fake.addDocument(`${RELAYS_ROOT}/alpha/log.md`, "alpha log");
|
||||
fake.addDocument(`${RELAYS_ROOT}/beta/status.md`, "beta status");
|
||||
|
||||
const panel = await mountPanel(panelContext(fake));
|
||||
const select = picker(panel);
|
||||
if (select === null) throw new Error("relay picker missing");
|
||||
select.value = `${RELAYS_ROOT}/alpha`;
|
||||
select.dispatchEvent(new Event("change"));
|
||||
await flushAsync();
|
||||
tabNamed(panel, "log.md").click();
|
||||
await flushAsync();
|
||||
expect(documentText(panel)).toBe("alpha log");
|
||||
|
||||
fake.listFiles.mockClear();
|
||||
fake.readFile.mockClear();
|
||||
refreshButton(panel).click();
|
||||
await flushAsync();
|
||||
|
||||
expect(fake.listFiles).toHaveBeenCalledWith(RELAYS_ROOT);
|
||||
expect(fake.listFiles).toHaveBeenCalledWith(`${RELAYS_ROOT}/alpha`);
|
||||
expect(fake.readFile).toHaveBeenCalledWith(`${RELAYS_ROOT}/alpha/log.md`);
|
||||
expect(picker(panel)?.value).toBe(`${RELAYS_ROOT}/alpha`);
|
||||
expect(activeTab(panel)?.textContent).toBe("log.md");
|
||||
expect(documentText(panel)).toBe("alpha log");
|
||||
});
|
||||
|
||||
it("does not rescan when the same workspace context is set again, but rescans for a new workspace", async () => {
|
||||
const fake = workspaceFilesFake();
|
||||
fake.addDirectory(RELAYS_ROOT, [relayDirectory("relay")]);
|
||||
fake.addDirectory(`${RELAYS_ROOT}/relay`, [relayDocument("relay", "status.md")]);
|
||||
fake.addDocument(`${RELAYS_ROOT}/relay/status.md`, "status");
|
||||
|
||||
const panel = await mountPanel(panelContext(fake));
|
||||
const callsAfterMount = fake.listFiles.mock.calls.length;
|
||||
|
||||
panel.context = panelContext(fake);
|
||||
await flushAsync();
|
||||
expect(fake.listFiles.mock.calls.length).toBe(callsAfterMount);
|
||||
|
||||
panel.context = panelContext(fake, "ws-2");
|
||||
await flushAsync();
|
||||
expect(fake.listFiles.mock.calls.length).toBeGreaterThan(callsAfterMount);
|
||||
});
|
||||
});
|
||||
|
||||
interface WorkspaceFilesFake {
|
||||
files: WorkspaceFiles;
|
||||
listFiles: Mock<RelayDiscoveryFiles["listFiles"]>;
|
||||
readFile: Mock<RelayDiscoveryFiles["readFile"]>;
|
||||
addDirectory(path: string, entries: FileTreeEntry[]): void;
|
||||
addDocument(path: string, content: string, overrides?: Partial<FileContentResponse>): void;
|
||||
failWith(path: string, error: Error): void;
|
||||
}
|
||||
|
||||
/** In-memory WorkspaceFiles fake: unknown paths reject with "Path does not exist", like the real helper. */
|
||||
function workspaceFilesFake(): WorkspaceFilesFake {
|
||||
const directories = new Map<string, FileTreeEntry[]>();
|
||||
const documents = new Map<string, FileContentResponse>();
|
||||
const failures = new Map<string, Error>();
|
||||
const listFiles = vi.fn<RelayDiscoveryFiles["listFiles"]>((path) => {
|
||||
const failure = failures.get(path);
|
||||
if (failure !== undefined) return Promise.reject(failure);
|
||||
const entries = directories.get(path);
|
||||
if (entries === undefined) return Promise.reject(new Error("Path does not exist"));
|
||||
return Promise.resolve({ path, entries, scannedAt: "2026-01-01T00:00:00.000Z", truncated: false });
|
||||
});
|
||||
const readFile = vi.fn<RelayDiscoveryFiles["readFile"]>((path) => {
|
||||
const failure = failures.get(path);
|
||||
if (failure !== undefined) return Promise.reject(failure);
|
||||
const file = documents.get(path);
|
||||
if (file === undefined) return Promise.reject(new Error("Path does not exist"));
|
||||
return Promise.resolve(file);
|
||||
});
|
||||
return {
|
||||
files: {
|
||||
listFiles,
|
||||
readFile,
|
||||
// The panel is read-only; the mutating helpers exist only to satisfy WorkspaceFiles.
|
||||
writeFile: () => Promise.reject(new Error("writeFile not used")),
|
||||
deleteFile: () => Promise.reject(new Error("deleteFile not used")),
|
||||
moveFile: () => Promise.reject(new Error("moveFile not used")),
|
||||
},
|
||||
listFiles,
|
||||
readFile,
|
||||
addDirectory: (path, entries) => { directories.set(path, entries); },
|
||||
addDocument: (path, content, overrides = {}) => {
|
||||
documents.set(path, {
|
||||
path,
|
||||
encoding: "utf8",
|
||||
size: content.length,
|
||||
modifiedAt: "2026-01-01T00:00:00.000Z",
|
||||
content,
|
||||
truncated: false,
|
||||
binary: false,
|
||||
...overrides,
|
||||
});
|
||||
},
|
||||
failWith: (path, error) => { failures.set(path, error); },
|
||||
};
|
||||
}
|
||||
|
||||
function panelContext(fake: WorkspaceFilesFake, workspaceId = "ws-1"): WorkspacePanelContext {
|
||||
return {
|
||||
machine: { id: "machine-1", name: "Local", kind: "local" },
|
||||
workspace: { id: workspaceId, projectId: "project-1", path: "/repo", label: "repo", isMain: true, isGitRepo: true, isGitWorktree: false },
|
||||
files: fake.files,
|
||||
host: { requestRender: () => undefined },
|
||||
prompt: { insertText: () => undefined, getText: () => "", getSelection: () => null },
|
||||
terminal: { open: () => undefined, runCommand: () => Promise.reject(new Error("terminal not used")) },
|
||||
};
|
||||
}
|
||||
|
||||
async function mountPanel(context?: WorkspacePanelContext): Promise<RelaysPanelTestElement> {
|
||||
defineRelaysPanelElement();
|
||||
const panel = document.createElement(relaysPanelTagName);
|
||||
document.body.append(panel);
|
||||
if (context !== undefined) panel.context = context;
|
||||
await flushAsync();
|
||||
return panel;
|
||||
}
|
||||
|
||||
/** One macrotask drains the full microtask chain of the panel's immediate-fake scans. */
|
||||
function flushAsync(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
}
|
||||
|
||||
function relayDirectory(name: string, modifiedAt?: string): FileTreeEntry {
|
||||
return { name, path: `${RELAYS_ROOT}/${name}`, type: "directory", ...(modifiedAt === undefined ? {} : { modifiedAt }) };
|
||||
}
|
||||
|
||||
function relayDocument(relayName: string, name: string, modifiedAt?: string): FileTreeEntry {
|
||||
return { name, path: `${RELAYS_ROOT}/${relayName}/${name}`, type: "file", ...(modifiedAt === undefined ? {} : { modifiedAt }) };
|
||||
}
|
||||
|
||||
function shadow(panel: RelaysPanelTestElement): ShadowRoot {
|
||||
const root = panel.shadowRoot;
|
||||
if (root === null) throw new Error("panel has no shadow root");
|
||||
return root;
|
||||
}
|
||||
|
||||
function viewerText(panel: RelaysPanelTestElement): string {
|
||||
return shadow(panel).querySelector(".viewer")?.textContent ?? "";
|
||||
}
|
||||
|
||||
/** Text of the open document viewer, whether it rendered markdown or a <pre> block. */
|
||||
function documentText(panel: RelaysPanelTestElement): string | null {
|
||||
return shadow(panel).querySelector(".viewer .document")?.textContent.trim() ?? null;
|
||||
}
|
||||
|
||||
function picker(panel: RelaysPanelTestElement): HTMLSelectElement | null {
|
||||
const select = shadow(panel).querySelector("select[data-relay-picker]");
|
||||
return select instanceof HTMLSelectElement ? select : null;
|
||||
}
|
||||
|
||||
function refreshButton(panel: RelaysPanelTestElement): HTMLElement {
|
||||
const button = shadow(panel).querySelector("button[data-refresh]");
|
||||
if (!(button instanceof HTMLElement)) throw new Error("refresh button missing");
|
||||
return button;
|
||||
}
|
||||
|
||||
function tabNames(panel: RelaysPanelTestElement): string[] {
|
||||
return [...shadow(panel).querySelectorAll("button[data-document-path]")].map((tab) => tab.textContent);
|
||||
}
|
||||
|
||||
function activeTab(panel: RelaysPanelTestElement): Element | null {
|
||||
return shadow(panel).querySelector("button[data-document-path].active");
|
||||
}
|
||||
|
||||
function tabNamed(panel: RelaysPanelTestElement, name: string): HTMLElement {
|
||||
const tab = [...shadow(panel).querySelectorAll("button[data-document-path]")].find((candidate) => candidate.textContent === name);
|
||||
if (!(tab instanceof HTMLElement)) throw new Error(`tab "${name}" not found`);
|
||||
return tab;
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
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('"', """);
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
# Vendored dependencies
|
||||
|
||||
## marked (`marked.esm.js`)
|
||||
|
||||
- **Source:** `node_modules/marked/lib/marked.esm.js` — marked v18.0.6, the
|
||||
version the repo pins (`"marked": "^18.0.6"` in the root `package.json`).
|
||||
- **License:** MIT — copyright (c) 2018-2026 MarkedJS, (c) 2011-2018
|
||||
Christopher Jeffrey. The attribution header at the top of `marked.esm.js` is
|
||||
preserved; see also `node_modules/marked/LICENSE.md`.
|
||||
- **Why vendored:** bundled plugins load in the browser as standalone ES
|
||||
modules and cannot resolve bare package specifiers. The plugin therefore
|
||||
ships the exact marked build the repo already depends on, rather than
|
||||
hand-rolling a markdown subset or adding a markdown helper to the plugin API.
|
||||
- **Local modification:** the trailing `//# sourceMappingURL=marked.esm.js.map`
|
||||
comment was removed because the (much larger) source map is not vendored.
|
||||
Everything else is byte-identical to the published file.
|
||||
- **`marked.esm.d.ts`** is a hand-written minimal declaration covering only the
|
||||
surface `markdownDocument.ts` uses. The plugin build
|
||||
(`scripts/build-plugins.mjs`) skips `.d.ts` files and copies `.js` assets
|
||||
verbatim, so the vendored module ships as-is.
|
||||
|
||||
**Updating:** after a `marked` upgrade in the root `package.json`, copy the new
|
||||
`node_modules/marked/lib/marked.esm.js` here, re-apply the sourceMappingURL
|
||||
removal, and update this note.
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Minimal declarations for the vendored marked ESM build (./marked.esm.js).
|
||||
* Covers only the surface markdownDocument.ts uses; widen it if usage grows.
|
||||
* The plugin build copies the .js verbatim and skips this file, so these
|
||||
* declarations exist for tsc and editor tooling only.
|
||||
*/
|
||||
|
||||
export interface MarkedHtmlToken {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface MarkedRenderer {
|
||||
html: (token: MarkedHtmlToken) => string;
|
||||
}
|
||||
|
||||
export interface MarkedParseOptions {
|
||||
async?: false;
|
||||
breaks?: boolean;
|
||||
gfm?: boolean;
|
||||
renderer?: MarkedRenderer;
|
||||
}
|
||||
|
||||
export interface Marked {
|
||||
Renderer: new () => MarkedRenderer;
|
||||
parse(source: string, options?: MarkedParseOptions): string;
|
||||
}
|
||||
|
||||
export const marked: Marked;
|
||||
+76
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user