Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 102 additions & 12 deletions src/client/src/components/WorkspacePanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ export interface WorkspacePanelEmptyState {

type WorkspacePanelBadge = string | number | TemplateResult | undefined;

const visiblePanelTabLimit = 5;
const pinnedWorkspaceTabsStorageKey = "pi-web.workspacePanel.pinnedTabs";

@customElement("workspace-panel")
export class WorkspacePanel extends LitElement {
@property({ attribute: false }) workspace: Workspace | undefined;
Expand All @@ -23,6 +26,7 @@ export class WorkspacePanel extends LitElement {
@query(".workspace-header-strip") private workspaceHeaderStrip?: HTMLElement | null;
@state() private workspaceHeaderCanScrollLeft = false;
@state() private workspaceHeaderCanScrollRight = false;
@state() private pinnedPanelIds: readonly QualifiedContributionId[] = loadPinnedPanelIds();

private observedWorkspaceHeaderStrip: HTMLElement | undefined;
private workspaceHeaderResizeObserver: ResizeObserver | undefined;
Expand Down Expand Up @@ -60,23 +64,30 @@ export class WorkspacePanel extends LitElement {
});
const visiblePanels = this.panels;
const selectedPanel = visiblePanels.find((panel) => panel.id === this.tool) ?? visiblePanels[0];
const visibleTabPanels = this.visibleTabPanels(visiblePanels);
const visibleTabPanelIds = new Set(visibleTabPanels.map((panel) => panel.id));
const overflowPanels = visiblePanels.filter((panel) => !visibleTabPanelIds.has(panel.id));
const selectedOverflowPanel = overflowPanels.some((panel) => panel.id === selectedPanel?.id) ? selectedPanel : undefined;
return html`
${this.hideToolTabs ? null : html`
<header>
<div class=${this.workspaceHeaderFrameClass()}>
<div class="workspace-header-strip" @scroll=${this.onWorkspaceHeaderScroll}>
<div class="tabs">
${visiblePanels.map((panel) => {
const selected = selectedPanel?.id === panel.id;
const badge = panel.badge?.(context);
const ariaLabel = this.panelTabAriaLabel(panel, badge);
return html`
<button class=${this.panelTabClass(panel, selected)} title=${ariaLabel} aria-label=${ariaLabel} aria-pressed=${String(selected)} @click=${() => { this.onSelectTool(panel.id); }}>
${this.renderPanelTabContent(panel, badge)}
</button>
`;
})}
</div>
<nav class="tabs workspace-tabs" aria-label="Workspace tabs">
${visibleTabPanels.map((panel) => this.renderPanelTab(panel, context, selectedPanel?.id === panel.id))}
</nav>
${overflowPanels.length === 0 ? null : html`
<label class="overflow-tab-select">
<span>More workspace tabs</span>
<select aria-label="More workspace tabs" .value=${selectedOverflowPanel?.id ?? ""} @change=${this.onOverflowToolSelectChange}>
<option value="" disabled>${selectedOverflowPanel === undefined ? `More (${String(overflowPanels.length)})` : this.panelOptionLabel(selectedOverflowPanel, selectedOverflowPanel.badge?.(context))}</option>
${overflowPanels.map((panel) => {
const badge = panel.badge?.(context);
return html`<option value=${panel.id}>${this.panelOptionLabel(panel, badge)}</option>`;
})}
</select>
</label>
`}
</div>
</div>
</header>
Expand All @@ -92,6 +103,49 @@ export class WorkspacePanel extends LitElement {
`;
}

private onOverflowToolSelectChange = (event: Event): void => {
const target = event.currentTarget;
if (!(target instanceof HTMLSelectElement)) return;
const tool = qualifiedContributionId(target.value);
if (tool === undefined) return;
this.pinPanel(tool);
this.onSelectTool(tool);
};

private renderPanelTab(panel: QualifiedWorkspacePanelContribution, context: WorkspacePanelContext, selected: boolean): TemplateResult {
const badge = panel.badge?.(context);
const ariaLabel = this.panelTabAriaLabel(panel, badge);
return html`
<button class=${this.panelTabClass(panel, selected)} title=${ariaLabel} aria-label=${ariaLabel} aria-pressed=${String(selected)} @click=${() => { this.onSelectTool(panel.id); }}>
${this.renderPanelTabContent(panel, badge)}
</button>
`;
}

private visibleTabPanels(panels: QualifiedWorkspacePanelContribution[]): QualifiedWorkspacePanelContribution[] {
const byId = new Map(panels.map((panel) => [panel.id, panel]));
const chosen: QualifiedWorkspacePanelContribution[] = [];
const seen = new Set<QualifiedContributionId>();
const add = (panel: QualifiedWorkspacePanelContribution | undefined): void => {
if (panel === undefined || seen.has(panel.id) || chosen.length >= visiblePanelTabLimit) return;
chosen.push(panel);
seen.add(panel.id);
};
for (const id of this.pinnedPanelIds) add(byId.get(id));
for (const panel of panels) add(panel);
return chosen;
}

private pinPanel(panelId: QualifiedContributionId): void {
const currentPinned = this.visibleTabPanels(this.panels).map((panel) => panel.id);
if (currentPinned.includes(panelId)) return;
const nextPinned = currentPinned.length < visiblePanelTabLimit
? [...currentPinned, panelId]
: [...currentPinned.slice(0, visiblePanelTabLimit - 1), panelId];
this.pinnedPanelIds = nextPinned;
savePinnedPanelIds(nextPinned);
}

private panelTabClass(panel: QualifiedWorkspacePanelContribution, selected: boolean): string {
return [
...(panel.icon === undefined ? [] : ["icon-tab"]),
Expand All @@ -113,6 +167,12 @@ export class WorkspacePanel extends LitElement {
`;
}

private panelOptionLabel(panel: QualifiedWorkspacePanelContribution, badge: WorkspacePanelBadge): string {
if (typeof badge !== "string" && typeof badge !== "number") return panel.title;
const trimmedBadge = String(badge).trim();
return trimmedBadge === "" ? panel.title : `${panel.title} (${trimmedBadge})`;
}

private isEmptyBadge(badge: WorkspacePanelBadge): boolean {
return badge === undefined || badge === "";
}
Expand Down Expand Up @@ -159,3 +219,33 @@ export class WorkspacePanel extends LitElement {

static override styles = workspacePanelStyles;
}

function qualifiedContributionId(value: string): QualifiedContributionId | undefined {
const separator = value.indexOf(":");
if (separator <= 0 || separator >= value.length - 1) return undefined;
const pluginId = value.slice(0, separator);
const localId = value.slice(separator + 1);
return `${pluginId}:${localId}`;
}

function loadPinnedPanelIds(): QualifiedContributionId[] {
if (typeof window === "undefined") return [];
try {
const raw = window.localStorage.getItem(pinnedWorkspaceTabsStorageKey);
if (raw === null) return [];
const value: unknown = JSON.parse(raw);
if (!Array.isArray(value)) return [];
return value.flatMap((item) => typeof item === "string" ? qualifiedContributionId(item) ?? [] : []);
} catch {
return [];
}
}

function savePinnedPanelIds(panelIds: readonly QualifiedContributionId[]): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(pinnedWorkspaceTabsStorageKey, JSON.stringify(panelIds));
} catch {
// Ignore storage failures; tabs still update for this render cycle.
}
}
7 changes: 6 additions & 1 deletion src/client/src/components/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,12 @@ export const workspacePanelStyles = css`
.workspace-header-scroll-frame::before { left: 0; background: linear-gradient(90deg, color-mix(in srgb, var(--pi-shadow-strong) 55%, transparent) 0%, transparent 100%); }
.workspace-header-scroll-frame::after { right: 0; background: linear-gradient(270deg, color-mix(in srgb, var(--pi-shadow-strong) 55%, transparent) 0%, transparent 100%); }
.workspace-header-scroll-frame.can-scroll-left::before, .workspace-header-scroll-frame.can-scroll-right::after { opacity: 1; }
.workspace-header-strip { display: flex; justify-content: space-between; align-items: center; gap: 8px; min-width: 0; padding: 8px; overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; scrollbar-width: thin; }
.workspace-header-strip { display: flex; align-items: center; gap: 8px; min-width: 0; padding: 8px; overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; scrollbar-width: thin; }
.workspace-tabs { flex: 0 0 auto; }
.overflow-tab-select { flex: 0 0 auto; min-width: 0; }
.overflow-tab-select span { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; border: 0; }
.overflow-tab-select select { max-width: 180px; border: 1px solid var(--pi-border); border-radius: 7px; background: var(--pi-surface); color: var(--pi-text); padding: 5px 26px 5px 8px; font: inherit; cursor: pointer; }
.overflow-tab-select select:focus { outline: 2px solid color-mix(in srgb, var(--pi-accent) 45%, transparent); outline-offset: 1px; }
.tabs { flex: 0 0 auto; display: flex; gap: 6px; align-items: center; }
.tabs button { flex: 0 0 auto; white-space: nowrap; }
.tabs button.icon-tab { min-width: 34px; }
Expand Down