diff --git a/src/client/src/components/WorkspacePanel.ts b/src/client/src/components/WorkspacePanel.ts index 43f30292..454196dd 100644 --- a/src/client/src/components/WorkspacePanel.ts +++ b/src/client/src/components/WorkspacePanel.ts @@ -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; @@ -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; @@ -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`
-
- ${visiblePanels.map((panel) => { - const selected = selectedPanel?.id === panel.id; - const badge = panel.badge?.(context); - const ariaLabel = this.panelTabAriaLabel(panel, badge); - return html` - - `; - })} -
+ + ${overflowPanels.length === 0 ? null : html` + + `}
@@ -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` + + `; + } + + private visibleTabPanels(panels: QualifiedWorkspacePanelContribution[]): QualifiedWorkspacePanelContribution[] { + const byId = new Map(panels.map((panel) => [panel.id, panel])); + const chosen: QualifiedWorkspacePanelContribution[] = []; + const seen = new Set(); + 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"]), @@ -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 === ""; } @@ -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. + } +} diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index df2e812e..3df8d7ea 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -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; }