Archived
fix(relays): keep document tab strip scroll position on tab switch
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Keep the relays panel document tab strip's horizontal scroll position when switching documents, instead of jumping back to the left edge on every tab click.
|
||||
@@ -109,7 +109,7 @@ describe("multiple relays", () => {
|
||||
const select = picker(panel);
|
||||
if (select === null) throw new Error("relay picker missing");
|
||||
select.value = `${RELAYS_ROOT}/older`;
|
||||
select.dispatchEvent(new Event("change"));
|
||||
select.dispatchEvent(new Event("change", { bubbles: true })); // Real change events bubble; the panel listens at the region container.
|
||||
await flushAsync();
|
||||
|
||||
expect(fake.listFiles).toHaveBeenCalledWith(`${RELAYS_ROOT}/older`);
|
||||
@@ -167,6 +167,77 @@ describe("document tabs", () => {
|
||||
expect(children.map((child) => child.textContent)).toEqual(["status.md", "log.md", "notes.md"]);
|
||||
});
|
||||
|
||||
it("keeps the tab strip mounted with its scroll and focus when switching documents", 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", "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));
|
||||
const strip = tabStrip(panel);
|
||||
strip.scrollLeft = 120;
|
||||
const logTab = tabNamed(panel, "log.md");
|
||||
|
||||
logTab.click();
|
||||
await flushAsync();
|
||||
|
||||
// Switching documents only re-renders the viewer: the strip element and
|
||||
// its buttons stay mounted, so scroll position and button identity survive.
|
||||
expect(documentText(panel)).toBe("log body");
|
||||
expect(tabStrip(panel)).toBe(strip);
|
||||
expect(tabStrip(panel).scrollLeft).toBe(120);
|
||||
expect(tabNamed(panel, "log.md")).toBe(logTab);
|
||||
expect(activeTab(panel)?.textContent).toBe("log.md");
|
||||
});
|
||||
|
||||
it("scrolls the viewer back to the top when switching documents", async () => {
|
||||
const fake = workspaceFilesFake();
|
||||
fake.addDirectory(RELAYS_ROOT, [relayDirectory("relay")]);
|
||||
fake.addDirectory(`${RELAYS_ROOT}/relay`, [relayDocument("relay", "log.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));
|
||||
const viewer = shadow(panel).querySelector("section.viewer");
|
||||
if (!(viewer instanceof HTMLElement)) throw new Error("viewer missing");
|
||||
viewer.scrollTop = 80;
|
||||
|
||||
tabNamed(panel, "log.md").click();
|
||||
await flushAsync();
|
||||
|
||||
expect(documentText(panel)).toBe("log body");
|
||||
expect(viewer.scrollTop).toBe(0);
|
||||
});
|
||||
|
||||
it("starts a different relay's tab strip at the left edge", 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")]);
|
||||
fake.addDirectory(`${RELAYS_ROOT}/beta`, [relayDocument("beta", "status.md"), relayDocument("beta", "log.md")]);
|
||||
fake.addDocument(`${RELAYS_ROOT}/beta/status.md`, "beta status");
|
||||
fake.addDocument(`${RELAYS_ROOT}/alpha/status.md`, "alpha status");
|
||||
|
||||
const panel = await mountPanel(panelContext(fake));
|
||||
tabStrip(panel).scrollLeft = 120;
|
||||
|
||||
const select = picker(panel);
|
||||
if (select === null) throw new Error("relay picker missing");
|
||||
select.value = `${RELAYS_ROOT}/alpha`;
|
||||
select.dispatchEvent(new Event("change", { bubbles: true })); // Real change events bubble; the panel listens at the region container.
|
||||
await flushAsync();
|
||||
|
||||
expect(documentText(panel)).toBe("alpha status");
|
||||
expect(tabStrip(panel).scrollLeft).toBe(0);
|
||||
});
|
||||
|
||||
it("shows a truncation notice when the open document is truncated", async () => {
|
||||
const fake = workspaceFilesFake();
|
||||
fake.addDirectory(RELAYS_ROOT, [relayDirectory("relay")]);
|
||||
@@ -308,7 +379,7 @@ describe("refresh and context changes", () => {
|
||||
const select = picker(panel);
|
||||
if (select === null) throw new Error("relay picker missing");
|
||||
select.value = `${RELAYS_ROOT}/alpha`;
|
||||
select.dispatchEvent(new Event("change"));
|
||||
select.dispatchEvent(new Event("change", { bubbles: true })); // Real change events bubble; the panel listens at the region container.
|
||||
await flushAsync();
|
||||
tabNamed(panel, "log.md").click();
|
||||
await flushAsync();
|
||||
@@ -463,6 +534,12 @@ function refreshButton(panel: RelaysPanelTestElement): HTMLElement {
|
||||
return button;
|
||||
}
|
||||
|
||||
function tabStrip(panel: RelaysPanelTestElement): HTMLElement {
|
||||
const strip = shadow(panel).querySelector("nav.document-tabs");
|
||||
if (!(strip instanceof HTMLElement)) throw new Error("tab strip missing");
|
||||
return strip;
|
||||
}
|
||||
|
||||
function tabNames(panel: RelaysPanelTestElement): string[] {
|
||||
return [...shadow(panel).querySelectorAll("button[data-document-path]")].map((tab) => tab.textContent);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,12 @@ interface RelaySelection {
|
||||
* sanitized HTML and everything else as preformatted text. All async loads
|
||||
* flow through scanToken so stale responses for a previous workspace or
|
||||
* selection never overwrite newer state.
|
||||
*
|
||||
* Rendering is region-scoped: the toolbar, tab strip, and viewer are
|
||||
* persistent elements built once, and each async stage re-renders only its
|
||||
* own region. Clicking a tab never rebuilds the strip — it toggles the
|
||||
* active marker in place and re-renders the viewer — so the strip's
|
||||
* horizontal scroll position and keyboard focus survive document switches.
|
||||
*/
|
||||
class PiWebRelaysPanel extends HTMLElement {
|
||||
private contextValue: WorkspacePanelContext | undefined;
|
||||
@@ -39,36 +45,64 @@ class PiWebRelaysPanel extends HTMLElement {
|
||||
private documentContent: RelayDocumentContent | undefined;
|
||||
private scanToken = 0;
|
||||
private readonly root: ShadowRoot;
|
||||
private readonly toolbar: HTMLElement;
|
||||
private readonly tabStrip: HTMLElement;
|
||||
private readonly viewer: HTMLElement;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.root = this.attachShadow({ mode: "open" });
|
||||
this.root.innerHTML = `
|
||||
${relaysStyles()}
|
||||
<section class="toolbar" hidden></section>
|
||||
<nav class="document-tabs" aria-label="Relay documents" hidden></nav>
|
||||
<section class="viewer"><div class="empty">Select a workspace.</div></section>
|
||||
`;
|
||||
this.toolbar = requiredRegion(this.root, ".toolbar");
|
||||
this.tabStrip = requiredRegion(this.root, "nav.document-tabs");
|
||||
this.viewer = requiredRegion(this.root, ".viewer");
|
||||
|
||||
// Listeners bind once against the persistent regions and delegate to
|
||||
// whichever controls the latest region render produced.
|
||||
this.toolbar.addEventListener("click", (event) => {
|
||||
const button = event.target instanceof Element ? event.target.closest("button[data-refresh]") : null;
|
||||
if (button !== null) this.refresh();
|
||||
});
|
||||
this.toolbar.addEventListener("change", (event) => {
|
||||
const picker = event.target;
|
||||
if (!(picker instanceof HTMLSelectElement) || !picker.matches("select[data-relay-picker]")) return;
|
||||
const context = this.contextValue;
|
||||
if (context !== undefined) void this.openRelay(context, picker.value);
|
||||
});
|
||||
this.tabStrip.addEventListener("click", (event) => {
|
||||
const tab = event.target instanceof Element ? event.target.closest("button[data-document-path]") : null;
|
||||
if (tab === null) return;
|
||||
const documentPath = tab.getAttribute("data-document-path");
|
||||
const context = this.contextValue;
|
||||
if (context !== undefined && documentPath !== null) void this.openDocument(context, documentPath);
|
||||
});
|
||||
}
|
||||
|
||||
set context(value: WorkspacePanelContext | undefined) {
|
||||
const previousKey = this.contextValue === undefined ? undefined : contextKey(this.contextValue);
|
||||
const nextKey = value === undefined ? undefined : contextKey(value);
|
||||
this.contextValue = value;
|
||||
// Parent app updates should not rescan or rebuild this shadow DOM for the
|
||||
// Parent app updates should not rescan or re-render this panel for the
|
||||
// same workspace (mirrors the workspace-tasks panel).
|
||||
if (previousKey === nextKey) return;
|
||||
if (value === undefined) {
|
||||
this.resetScanState();
|
||||
this.render();
|
||||
this.renderAll();
|
||||
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();
|
||||
this.renderAll();
|
||||
|
||||
const listing = await listWorkspaceRelays(context.files);
|
||||
if (!this.isCurrentScan(context, token)) return;
|
||||
@@ -80,8 +114,9 @@ class PiWebRelaysPanel extends HTMLElement {
|
||||
? listing.relays.find((candidate) => candidate.path === selection.relayPath) ?? listing.relays[0]
|
||||
: undefined;
|
||||
this.selectedRelayPath = relay?.path;
|
||||
this.renderToolbar();
|
||||
if (relay === undefined) {
|
||||
this.render();
|
||||
this.renderViewer();
|
||||
return;
|
||||
}
|
||||
await this.loadDocuments(context, token, relay.path, selection.documentPath);
|
||||
@@ -96,6 +131,11 @@ class PiWebRelaysPanel extends HTMLElement {
|
||||
private async openDocument(context: WorkspacePanelContext, documentPath: string): Promise<void> {
|
||||
const token = ++this.scanToken;
|
||||
this.selectedDocumentPath = documentPath;
|
||||
// The tab set is unchanged: toggle the active marker on the mounted
|
||||
// buttons instead of rebuilding the strip, so its scroll position and
|
||||
// focus stay put. A different document starts reading from the top.
|
||||
this.updateActiveTab();
|
||||
this.viewer.scrollTop = 0;
|
||||
await this.loadDocumentContent(context, token, documentPath);
|
||||
}
|
||||
|
||||
@@ -103,7 +143,8 @@ class PiWebRelaysPanel extends HTMLElement {
|
||||
this.documents = undefined;
|
||||
this.selectedDocumentPath = undefined;
|
||||
this.documentContent = undefined;
|
||||
this.render();
|
||||
this.renderTabs();
|
||||
this.renderViewer();
|
||||
|
||||
const documents = await listRelayDocuments(context.files, relayPath);
|
||||
if (!this.isCurrentScan(context, token)) return;
|
||||
@@ -113,8 +154,9 @@ class PiWebRelaysPanel extends HTMLElement {
|
||||
? documents.documents.find((candidate) => candidate.path === preferredDocumentPath) ?? defaultRelayDocument(documents.documents)
|
||||
: undefined;
|
||||
this.selectedDocumentPath = document?.path;
|
||||
this.renderTabs();
|
||||
if (document === undefined) {
|
||||
this.render();
|
||||
this.renderViewer();
|
||||
return;
|
||||
}
|
||||
await this.loadDocumentContent(context, token, document.path);
|
||||
@@ -122,12 +164,12 @@ class PiWebRelaysPanel extends HTMLElement {
|
||||
|
||||
private async loadDocumentContent(context: WorkspacePanelContext, token: number, documentPath: string): Promise<void> {
|
||||
this.documentContent = undefined;
|
||||
this.render();
|
||||
this.renderViewer();
|
||||
|
||||
const content = await readRelayDocument(context.files, documentPath);
|
||||
if (!this.isCurrentScan(context, token)) return;
|
||||
this.documentContent = content;
|
||||
this.render();
|
||||
this.renderViewer();
|
||||
}
|
||||
|
||||
private refresh(): void {
|
||||
@@ -148,38 +190,26 @@ class PiWebRelaysPanel extends HTMLElement {
|
||||
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>`;
|
||||
private renderAll(): void {
|
||||
this.renderToolbar();
|
||||
this.renderTabs();
|
||||
this.renderViewer();
|
||||
}
|
||||
|
||||
private renderToolbar(): void {
|
||||
if (this.contextValue === undefined) {
|
||||
this.toolbar.hidden = true;
|
||||
this.toolbar.replaceChildren();
|
||||
return;
|
||||
}
|
||||
this.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.toolbar.hidden = false;
|
||||
this.toolbar.innerHTML = `
|
||||
<strong>Relays</strong>
|
||||
<span class="toolbar-actions">
|
||||
${this.renderRelayPicker()}
|
||||
<button class="icon-button" data-refresh aria-label="Refresh" title="Refresh">${refreshIconSvg()}</button>
|
||||
</span>
|
||||
`;
|
||||
|
||||
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 {
|
||||
@@ -197,17 +227,43 @@ class PiWebRelaysPanel extends HTMLElement {
|
||||
return `<select data-relay-picker aria-label="Relay">${options}</select>`;
|
||||
}
|
||||
|
||||
private renderDocumentTabs(): string {
|
||||
private renderTabs(): void {
|
||||
const documents = this.documents;
|
||||
if (documents?.kind !== "loaded" || documents.documents.length === 0) return "";
|
||||
const tabs = documents.documents.map((document) => {
|
||||
if (documents?.kind !== "loaded" || documents.documents.length === 0) {
|
||||
this.tabStrip.hidden = true;
|
||||
// A new tab set starts at the left edge, not at the previous set's offset.
|
||||
this.tabStrip.replaceChildren();
|
||||
this.tabStrip.scrollLeft = 0;
|
||||
return;
|
||||
}
|
||||
this.tabStrip.hidden = false;
|
||||
// The strip element itself persists across re-renders, so replacing its
|
||||
// buttons keeps the container's horizontal scroll position.
|
||||
this.tabStrip.innerHTML = documents.documents.map((document) => {
|
||||
const active = document.path === this.selectedDocumentPath;
|
||||
return `<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 {
|
||||
/** Move the active marker between the mounted tab buttons without rebuilding them. */
|
||||
private updateActiveTab(): void {
|
||||
for (const tab of this.tabStrip.querySelectorAll("button[data-document-path]")) {
|
||||
const active = tab.getAttribute("data-document-path") === this.selectedDocumentPath;
|
||||
tab.classList.toggle("active", active);
|
||||
if (active) tab.setAttribute("aria-current", "true");
|
||||
else tab.removeAttribute("aria-current");
|
||||
}
|
||||
}
|
||||
|
||||
private renderViewer(): void {
|
||||
if (this.contextValue === undefined) {
|
||||
this.viewer.innerHTML = `<div class="empty">Select a workspace.</div>`;
|
||||
return;
|
||||
}
|
||||
this.viewer.innerHTML = this.renderViewerContent();
|
||||
}
|
||||
|
||||
private renderViewerContent(): 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);
|
||||
@@ -255,6 +311,13 @@ class PiWebRelaysPanel extends HTMLElement {
|
||||
}
|
||||
}
|
||||
|
||||
/** Shell regions come from a literal template; absence means the template broke. */
|
||||
function requiredRegion(root: ShadowRoot, selector: string): HTMLElement {
|
||||
const element = root.querySelector(selector);
|
||||
if (!(element instanceof HTMLElement)) throw new Error(`relays panel shell is missing ${selector}`);
|
||||
return element;
|
||||
}
|
||||
|
||||
/** Reload glyph matching the app's own refresh control (AppRefreshControl). */
|
||||
function refreshIconSvg(): string {
|
||||
return `
|
||||
@@ -297,6 +360,7 @@ function relaysStyles(): string {
|
||||
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[hidden], .document-tabs[hidden] { display: none; }
|
||||
.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
|
||||
|
||||
Reference in New Issue
Block a user