From cf1b0ed34555ce9da1aa64cc6b028caaa19127dd Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Thu, 21 May 2026 13:47:56 +0200 Subject: [PATCH] fix: replace workspace hover lens with details menu --- .changeset/scroll-workspace-hover-lens.md | 5 + src/client/src/components/ProjectList.ts | 6 +- src/client/src/components/SessionList.ts | 6 +- src/client/src/components/WorkspaceList.ts | 127 ++++++++++++++----- src/client/src/components/actionMenu.ts | 43 +++++++ src/client/src/components/rowOverflowLens.ts | 52 -------- src/client/src/components/shared.ts | 22 ++-- src/client/src/components/workspaceLabel.ts | 4 + 8 files changed, 162 insertions(+), 103 deletions(-) create mode 100644 .changeset/scroll-workspace-hover-lens.md create mode 100644 src/client/src/components/actionMenu.ts delete mode 100644 src/client/src/components/rowOverflowLens.ts diff --git a/.changeset/scroll-workspace-hover-lens.md b/.changeset/scroll-workspace-hover-lens.md new file mode 100644 index 0000000..75af6d3 --- /dev/null +++ b/.changeset/scroll-workspace-hover-lens.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Replace the workspace hover lens with a workspace actions/details menu so metadata remains accessible without blocking list scrolling or shifting rows. diff --git a/src/client/src/components/ProjectList.ts b/src/client/src/components/ProjectList.ts index a20c02e..0636c71 100644 --- a/src/client/src/components/ProjectList.ts +++ b/src/client/src/components/ProjectList.ts @@ -2,6 +2,7 @@ import { LitElement, html, type PropertyValues } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import type { Project, Workspace, WorkspaceActivity } from "../api"; import { projectActivityIndicator } from "../workspaceActivity"; +import { actionMenuPanelStyle } from "./actionMenu"; import { renderActivityIndicator } from "./activityBadge"; import { activateSelectableRow, activateSelectableRowFromKeyboard } from "./selectableRow"; import { listStyles } from "./shared"; @@ -85,10 +86,7 @@ export class ProjectList extends LitElement { this.openMenuProjectId = undefined; return; } - if (target instanceof HTMLElement) { - const rect = target.getBoundingClientRect(); - this.menuStyle = `top: ${String(rect.bottom + 4)}px; right: ${String(window.innerWidth - rect.right)}px;`; - } + this.menuStyle = actionMenuPanelStyle(target); this.openMenuProjectId = projectId; } diff --git a/src/client/src/components/SessionList.ts b/src/client/src/components/SessionList.ts index f2bb2a8..a12393d 100644 --- a/src/client/src/components/SessionList.ts +++ b/src/client/src/components/SessionList.ts @@ -3,6 +3,7 @@ import { customElement, property, state } from "lit/decorators.js"; import type { SessionActivity, SessionInfo, SessionStatus } from "../api"; import { isCachedNewSessionInfo } from "../cachedNewSessions"; import { isSessionActive } from "../../../shared/activity"; +import { actionMenuPanelStyle } from "./actionMenu"; import { renderActivityIndicator } from "./activityBadge"; import { activateSelectableRow, activateSelectableRowFromKeyboard } from "./selectableRow"; import { listStyles } from "./shared"; @@ -131,10 +132,7 @@ export class SessionList extends LitElement { this.openMenuSessionId = undefined; return; } - if (target instanceof HTMLElement) { - const rect = target.getBoundingClientRect(); - this.menuStyle = `top: ${String(rect.bottom + 4)}px; right: ${String(window.innerWidth - rect.right)}px;`; - } + this.menuStyle = actionMenuPanelStyle(target); this.openMenuSessionId = sessionId; } diff --git a/src/client/src/components/WorkspaceList.ts b/src/client/src/components/WorkspaceList.ts index 6a86e67..f54ca06 100644 --- a/src/client/src/components/WorkspaceList.ts +++ b/src/client/src/components/WorkspaceList.ts @@ -1,13 +1,13 @@ -import { LitElement, html, type PropertyValues } from "lit"; +import { LitElement, html, type PropertyValues, type TemplateResult } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import type { Workspace, WorkspaceActivity } from "../api"; import type { WorkspaceLabelItem } from "../plugins/types"; import { workspaceActivityFor, workspaceActivityIndicator } from "../workspaceActivity"; +import { actionMenuPanelStyle } from "./actionMenu"; import { renderActivityIndicator } from "./activityBadge"; -import { focusMovedWithinCurrentTarget, rowOverflowLensStyle, shouldOpenOverflowLensFromFocus, shouldOpenOverflowLensFromPointer } from "./rowOverflowLens"; import { activateSelectableRow, activateSelectableRowFromKeyboard } from "./selectableRow"; import { listStyles } from "./shared"; -import { renderWorkspaceLabelItems } from "./workspaceLabel"; +import { renderWorkspaceLabelInlineItems } from "./workspaceLabel"; @customElement("workspace-list") export class WorkspaceList extends LitElement { @@ -19,12 +19,27 @@ export class WorkspaceList extends LitElement { @property({ attribute: false }) activities: Record = {}; @property({ attribute: false }) onSelect?: (workspace: Workspace) => void; @property({ attribute: false }) onToggleCollapsed?: () => void; - @state() private overflowLensWorkspaceId: string | undefined; - @state() private overflowLensStyle = ""; + @state() private openMenuWorkspaceId: string | undefined; + @state() private menuStyle = ""; + + private readonly onDocumentClick = (event: MouseEvent) => { + if (event.composedPath().includes(this)) return; + this.openMenuWorkspaceId = undefined; + }; + + override connectedCallback(): void { + super.connectedCallback(); + document.addEventListener("click", this.onDocumentClick); + } + + override disconnectedCallback(): void { + document.removeEventListener("click", this.onDocumentClick); + super.disconnectedCallback(); + } protected override updated(changed: PropertyValues): void { - if (changed.has("workspaces") && this.overflowLensWorkspaceId !== undefined && !this.workspaces.some((workspace) => workspace.id === this.overflowLensWorkspaceId)) this.overflowLensWorkspaceId = undefined; - if (changed.has("collapsed") && this.collapsed) this.overflowLensWorkspaceId = undefined; + if (changed.has("workspaces") && this.openMenuWorkspaceId !== undefined && !this.workspaces.some((workspace) => workspace.id === this.openMenuWorkspaceId)) this.openMenuWorkspaceId = undefined; + if (changed.has("collapsed") && this.collapsed) this.openMenuWorkspaceId = undefined; if ((changed.has("selected") || changed.has("workspaces") || changed.has("collapsed")) && !this.collapsed) this.scrollSelectedIntoView(); } @@ -33,28 +48,20 @@ export class WorkspaceList extends LitElement {

${this.renderHeading()}

${this.collapsed ? null : this.workspaces.map((workspace) => { - const label = `${workspace.label}${workspace.isMain ? " · main" : ""}`; + const label = workspacePrimaryLabel(workspace); const items = this.workspaceLabelItems(workspace); return html`
{ if (shouldOpenOverflowLensFromPointer(event)) this.openOverflowLens(workspace.id, event.currentTarget); }} - @pointerleave=${() => { this.closeOverflowLens(workspace.id); }} - @focusin=${(event: FocusEvent) => { if (shouldOpenOverflowLensFromFocus(event)) this.openOverflowLens(workspace.id, event.currentTarget); }} - @focusout=${(event: FocusEvent) => { this.closeOverflowLensOnFocusOut(workspace.id, event); }} + title=${label} @click=${(event: MouseEvent) => { activateSelectableRow(event, () => this.onSelect?.(workspace)); }} @keydown=${(event: KeyboardEvent) => { this.handleWorkspaceKeydown(event, workspace); }} >
${this.renderWorkspaceMain(label, items, workspace)}
- ${this.overflowLensWorkspaceId === workspace.id ? html` -
- ${this.renderWorkspaceMain(label, items, workspace)} -
- ` : null} + ${this.renderWorkspaceMenu(label, items, workspace)}
`; })} @@ -69,40 +76,82 @@ export class WorkspaceList extends LitElement { return html``; } - private renderActivity(workspace: Workspace) { + private renderActivity(workspace: Workspace): TemplateResult | undefined { const kind = workspaceActivityIndicator(workspaceActivityFor(workspace, this.activities)); - return renderActivityIndicator(kind, kind === "terminal" ? "Workspace terminal active" : "Workspace active") ?? ""; + return renderActivityIndicator(kind, kind === "terminal" ? "Workspace terminal active" : "Workspace active"); } - private renderWorkspaceMain(label: string, items: WorkspaceLabelItem[], workspace: Workspace) { + private renderWorkspaceMain(label: string, items: WorkspaceLabelItem[], workspace: Workspace): TemplateResult { return html` - - ${label} - ${renderWorkspaceLabelItems(items)} + + ${this.renderActivity(workspace)} + ${label} - ${this.renderActivity(workspace)}${workspace.path} + ${items.length === 0 ? null : html` + + ${renderWorkspaceLabelInlineItems(items)} + + `} `; } - private openOverflowLens(workspaceId: string, target: EventTarget | null): void { - this.overflowLensWorkspaceId = workspaceId; - this.overflowLensStyle = rowOverflowLensStyle(target); + private renderWorkspaceMenu(label: string, items: WorkspaceLabelItem[], workspace: Workspace): TemplateResult { + const open = this.openMenuWorkspaceId === workspace.id; + const menuId = workspaceMenuId(workspace.id); + return html` +
+ + ${open ? html` +
{ event.stopPropagation(); }}> + ${this.renderWorkspaceDetails(label, items, workspace)} +
+ ` : null} +
+ `; } - private closeOverflowLens(workspaceId: string): void { - if (this.overflowLensWorkspaceId === workspaceId) this.overflowLensWorkspaceId = undefined; + private renderWorkspaceDetails(label: string, items: WorkspaceLabelItem[], workspace: Workspace): TemplateResult { + return html` +
+
+
${workspace.branch === undefined ? "Workspace" : "Branch"}
+
${label}
+
+
+
Path
+
${workspace.path}
+
+ ${items.length === 0 ? null : html` +
+
Details
+
${renderWorkspaceLabelInlineItems(items)}
+
+ `} +
+ `; } - private closeOverflowLensOnFocusOut(workspaceId: string, event: FocusEvent): void { - if (focusMovedWithinCurrentTarget(event)) return; - this.closeOverflowLens(workspaceId); + private toggleMenu(workspaceId: string, target: EventTarget | null): void { + if (this.openMenuWorkspaceId === workspaceId) { + this.openMenuWorkspaceId = undefined; + return; + } + this.menuStyle = actionMenuPanelStyle(target); + this.openMenuWorkspaceId = workspaceId; } private handleWorkspaceKeydown(event: KeyboardEvent, workspace: Workspace): void { - if (event.key === "Escape" && this.overflowLensWorkspaceId === workspace.id) { + if (event.key === "Escape" && this.openMenuWorkspaceId === workspace.id) { event.preventDefault(); event.stopPropagation(); - this.overflowLensWorkspaceId = undefined; + this.openMenuWorkspaceId = undefined; return; } activateSelectableRowFromKeyboard(event, () => this.onSelect?.(workspace)); @@ -114,3 +163,11 @@ export class WorkspaceList extends LitElement { static override styles = listStyles; } + +function workspacePrimaryLabel(workspace: Workspace): string { + return `${workspace.branch ?? workspace.label}${workspace.isMain ? " · main" : ""}`; +} + +function workspaceMenuId(workspaceId: string): string { + return `workspace-menu-${workspaceId.replace(/[^a-zA-Z0-9_-]/g, "-")}`; +} diff --git a/src/client/src/components/actionMenu.ts b/src/client/src/components/actionMenu.ts new file mode 100644 index 0000000..dac8a8c --- /dev/null +++ b/src/client/src/components/actionMenu.ts @@ -0,0 +1,43 @@ +const ACTION_MENU_GAP_PX = 0; +const ACTION_MENU_MIN_USEFUL_HEIGHT_PX = 120; + +interface ActionMenuRect { + top: number; + right: number; + bottom: number; + left: number; +} + +export function actionMenuPanelStyle(target: EventTarget | null): string { + if (typeof HTMLElement === "undefined" || typeof window === "undefined" || !(target instanceof HTMLElement)) return ""; + const trigger = target.getBoundingClientRect(); + const bounds = actionMenuBounds(target); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const leftBound = Math.max(0, bounds.left); + const rightBound = Math.min(viewportWidth, bounds.right); + const topBound = Math.max(0, bounds.top); + const bottomBound = Math.min(viewportHeight, bounds.bottom); + const triggerRight = Math.min(trigger.right, rightBound); + const availableBelow = bottomBound - trigger.bottom - ACTION_MENU_GAP_PX; + const availableAbove = trigger.top - topBound - ACTION_MENU_GAP_PX; + const placement = availableBelow < ACTION_MENU_MIN_USEFUL_HEIGHT_PX && availableAbove > availableBelow + ? [`bottom: ${px(viewportHeight - trigger.top + ACTION_MENU_GAP_PX)};`, `max-height: ${px(Math.max(0, availableAbove))};`] + : [`top: ${px(trigger.bottom + ACTION_MENU_GAP_PX)};`, `max-height: ${px(Math.max(0, availableBelow))};`]; + + return [ + ...placement, + `right: ${px(Math.max(0, viewportWidth - triggerRight))};`, + `max-width: ${px(Math.max(0, triggerRight - leftBound))};`, + ].join(" "); +} + +function actionMenuBounds(target: HTMLElement): ActionMenuRect { + const root = target.getRootNode(); + if (typeof ShadowRoot !== "undefined" && root instanceof ShadowRoot && root.host instanceof HTMLElement) return root.host.getBoundingClientRect(); + return { top: 0, right: window.innerWidth, bottom: window.innerHeight, left: 0 }; +} + +function px(value: number): string { + return `${String(Math.round(value))}px`; +} diff --git a/src/client/src/components/rowOverflowLens.ts b/src/client/src/components/rowOverflowLens.ts deleted file mode 100644 index f8109c5..0000000 --- a/src/client/src/components/rowOverflowLens.ts +++ /dev/null @@ -1,52 +0,0 @@ -const LENS_MARGIN_PX = 8; -const MAX_LENS_WIDTH_PX = 720; - -interface RowOverflowLensRect { - top: number; - left: number; - width: number; - height: number; -} - -interface RowOverflowLensViewport { - width: number; -} - -export function rowOverflowLensStyle(target: EventTarget | null): string { - if (typeof HTMLElement === "undefined" || !(target instanceof HTMLElement)) return ""; - const anchor = target.querySelector(".action-main") ?? target; - return rowOverflowLensStyleForRect(anchor.getBoundingClientRect(), { width: window.innerWidth }); -} - -export function rowOverflowLensStyleForRect(rect: RowOverflowLensRect, viewport: RowOverflowLensViewport): string { - const left = Math.max(LENS_MARGIN_PX, rect.left); - const availableWidth = Math.max(0, viewport.width - left - LENS_MARGIN_PX); - const maxWidth = Math.min(MAX_LENS_WIDTH_PX, availableWidth); - const minWidth = Math.min(rect.width, maxWidth); - - return [ - `top: ${px(rect.top)};`, - `left: ${px(left)};`, - `height: ${px(rect.height)};`, - `min-width: ${px(minWidth)};`, - `max-width: ${px(maxWidth)};`, - ].join(" "); -} - -export function shouldOpenOverflowLensFromPointer(event: PointerEvent): boolean { - return event.pointerType === "mouse" || event.pointerType === "pen"; -} - -export function shouldOpenOverflowLensFromFocus(event: FocusEvent): boolean { - if (typeof HTMLElement === "undefined" || !(event.currentTarget instanceof HTMLElement)) return false; - return event.currentTarget.matches(":focus-visible"); -} - -export function focusMovedWithinCurrentTarget(event: FocusEvent): boolean { - if (typeof HTMLElement === "undefined" || typeof Node === "undefined") return false; - return event.currentTarget instanceof HTMLElement && event.relatedTarget instanceof Node && event.currentTarget.contains(event.relatedTarget); -} - -function px(value: number): string { - return `${String(Math.round(value))}px`; -} diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index b42037c..194456b 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -182,7 +182,16 @@ export const listStyles = css` .action-main { box-sizing: border-box; min-width: 0; width: 100%; border: 1px solid var(--pi-border); border-top-right-radius: 0; border-bottom-right-radius: 0; border-top-left-radius: 8px; border-bottom-left-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px 7px calc(9px + var(--depth, 0) * 16px); text-align: left; } .action-name { display: -webkit-box; max-height: 2.5em; overflow: hidden; overflow-wrap: anywhere; line-height: 1.25; -webkit-box-orient: vertical; -webkit-line-clamp: 2; } .action-row:not(.selected):hover .action-main { background: var(--pi-surface-hover); } - .workspace-row .action-main { border-radius: 8px; } + .workspace-row .action-main { border-radius: 8px 0 0 8px; } + .workspace-primary { min-width: 0; display: flex; align-items: baseline; gap: 6px; } + .workspace-primary .activity-indicator { flex: 0 0 auto; margin-right: 0; } + .workspace-primary-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .workspace-secondary { margin-top: 3px; } + .workspace-menu-panel { width: max-content; min-width: min(120px, calc(100vw - 16px)); padding: 8px; } + .workspace-menu-details { display: grid; gap: 6px; margin: 0; } + .workspace-detail-row { display: grid; grid-template-columns: minmax(58px, max-content) minmax(0, 1fr); gap: 8px; align-items: baseline; } + .workspace-detail-row dt { color: var(--pi-muted); font-size: 12px; white-space: normal; } + .workspace-detail-row dd { min-width: 0; margin: 0; overflow-wrap: anywhere; white-space: normal; } .tree-marker { color: var(--pi-dim); margin-right: 5px; } .badge { display: inline-block; margin-left: 5px; border: 1px solid var(--pi-border); border-radius: 999px; color: var(--pi-muted); padding: 0 5px; font-size: 11px; font-weight: 400; } .activity-indicator { display: inline-block; width: 7px; height: 7px; margin-right: 6px; background: var(--pi-success); animation: pulse 1s ease-in-out infinite; vertical-align: 1px; } @@ -191,8 +200,8 @@ export const listStyles = css` .action-menu { position: relative; align-self: stretch; } .action-menu-toggle { display: grid; place-items: center; height: 100%; min-width: 32px; padding: 0; color: var(--pi-muted); border-left: 0; border-top-left-radius: 0; border-bottom-left-radius: 0; } .action-menu-toggle:hover { color: var(--pi-text); background: var(--pi-surface-hover); } - .action-menu-panel { position: fixed; z-index: 50; min-width: 120px; padding: 4px; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); box-shadow: 0 8px 24px var(--pi-shadow); } - .action-menu-panel button { display: block; width: 100%; text-align: left; border: 0; background: transparent; color: var(--pi-text); } + .action-menu-panel { position: fixed; z-index: 50; box-sizing: border-box; min-width: min(120px, calc(100vw - 16px)); overflow: auto; padding: 4px; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); box-shadow: 0 8px 24px var(--pi-shadow); overflow-wrap: anywhere; } + .action-menu-panel button { display: block; width: 100%; text-align: left; white-space: normal; overflow-wrap: anywhere; border: 0; background: transparent; color: var(--pi-text); } .action-menu-panel button:hover { background: var(--pi-selection-bg); } button.selected { border-color: var(--pi-accent); background: var(--pi-selection-bg); } button:disabled { opacity: .5; cursor: not-allowed; } @@ -202,11 +211,8 @@ export const listStyles = css` .workspace-label-item, .workspace-label-render, .workspace-label-separator { color: var(--pi-muted); } .workspace-label-link { color: var(--pi-accent); text-decoration: none; } .workspace-label-link:hover, .workspace-label-link:focus { text-decoration: underline; } - .row-overflow-lens { position: fixed; z-index: 40; box-sizing: border-box; width: max-content; overflow: hidden; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface-hover); color: var(--pi-text); padding: 7px 9px 7px calc(9px + var(--depth, 0) * 16px); cursor: pointer; } - .action-row.selected > .row-overflow-lens { border-color: var(--pi-accent); background: var(--pi-selection-bg); } - .row-overflow-lens small { overflow: visible; text-overflow: clip; white-space: nowrap; } - .row-overflow-lens .workspace-label { display: inline-flex; max-width: none; overflow: visible; white-space: nowrap; flex-wrap: nowrap; } - .row-overflow-lens .workspace-label-base, .row-overflow-lens .workspace-label-item, .row-overflow-lens .workspace-label-render { overflow: visible; text-overflow: clip; white-space: nowrap; } + .workspace-detail-row .workspace-label { overflow: visible; white-space: normal; flex-wrap: wrap; } + .workspace-detail-row .workspace-label-base, .workspace-detail-row .workspace-label-item, .workspace-detail-row .workspace-label-render { overflow: visible; text-overflow: clip; overflow-wrap: anywhere; white-space: normal; } @keyframes pulse { 0%, 100% { transform: scale(.75); opacity: .55; } 50% { transform: scale(1.2); opacity: 1; } } `; diff --git a/src/client/src/components/workspaceLabel.ts b/src/client/src/components/workspaceLabel.ts index 2eec0ba..94be9c5 100644 --- a/src/client/src/components/workspaceLabel.ts +++ b/src/client/src/components/workspaceLabel.ts @@ -14,6 +14,10 @@ export function renderWorkspaceLabelItems(items: WorkspaceLabelItem[] = []): Tem return items.map((item) => html`·${renderWorkspaceLabelItem(item)}`); } +export function renderWorkspaceLabelInlineItems(items: WorkspaceLabelItem[] = []): TemplateResult[] { + return items.map((item, index) => html`${index === 0 ? null : html`·`}${renderWorkspaceLabelItem(item)}`); +} + function renderWorkspaceLabelItem(item: WorkspaceLabelItem): TemplateResult { if (item.type === "render") return html`${item.render()}`; if (item.type === "link" && isSafeHref(item.href)) {