From 4a5150309dbcf6ffbdd9bba1a1c782705dd88d3b Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Mon, 27 Jul 2026 19:31:15 +0200 Subject: [PATCH] feat(ui): add copy buttons to workspace menu details --- .changeset/workspace-menu-copy-details.md | 5 ++ .../src/components/WorkspaceList.test.ts | 90 ++++++++++++++++++- src/client/src/components/WorkspaceList.ts | 26 +++++- src/client/src/components/shared.ts | 2 + 4 files changed, 120 insertions(+), 3 deletions(-) create mode 100644 .changeset/workspace-menu-copy-details.md diff --git a/.changeset/workspace-menu-copy-details.md b/.changeset/workspace-menu-copy-details.md new file mode 100644 index 0000000..e72b98f --- /dev/null +++ b/.changeset/workspace-menu-copy-details.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Add copy buttons to the workspace menu details so the workspace path and branch can be copied to the clipboard with one click, matching the copy affordances already available in chats. diff --git a/src/client/src/components/WorkspaceList.test.ts b/src/client/src/components/WorkspaceList.test.ts index 0649e91..6e6a642 100644 --- a/src/client/src/components/WorkspaceList.test.ts +++ b/src/client/src/components/WorkspaceList.test.ts @@ -1,10 +1,14 @@ // @vitest-environment happy-dom -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi, type Mock } from "vitest"; import type { Workspace, WorkspaceActivity } from "../api"; import { WorkspaceList } from "./WorkspaceList"; +let restoreClipboardStub: () => void = () => undefined; + afterEach(() => { + restoreClipboardStub(); + restoreClipboardStub = () => undefined; document.body.replaceChildren(); }); @@ -41,6 +45,90 @@ describe("workspace unread indicator", () => { }); }); +describe("workspace detail copy buttons", () => { + it("copies the workspace path from the menu details and keeps the menu open", async () => { + const writeText = stubClipboardWriteText(() => Promise.resolve()); + const list = await mountWorkspaceList([workspace("ws-a")], new Set()); + openMenu(list, "ws-a"); + await list.updateComplete; + + detailCopyButton(list, "Copy path").click(); + await vi.waitFor(() => { expect(writeText).toHaveBeenCalledWith("/repo/ws-a"); }); + await vi.waitFor(() => { expect(detailCopyButton(list, "Copied").textContent).toContain("✓"); }); + + expect(list.shadowRoot?.querySelector(".workspace-menu-panel")).not.toBeNull(); + }); + + it("copies the bare branch name without the main suffix", async () => { + const writeText = stubClipboardWriteText(() => Promise.resolve()); + const list = await mountWorkspaceList([{ ...workspace("ws-a"), branch: "feature-x" }], new Set()); + openMenu(list, "feature-x"); + await list.updateComplete; + + detailCopyButton(list, "Copy branch").click(); + await vi.waitFor(() => { expect(writeText).toHaveBeenCalledWith("feature-x"); }); + }); + + it("offers to copy the workspace label when there is no branch", async () => { + const writeText = stubClipboardWriteText(() => Promise.resolve()); + const list = await mountWorkspaceList([workspace("ws-a")], new Set()); + openMenu(list, "ws-a"); + await list.updateComplete; + + detailCopyButton(list, "Copy workspace label").click(); + await vi.waitFor(() => { expect(writeText).toHaveBeenCalledWith("ws-a"); }); + }); + + it("keeps the copy action unchanged when the clipboard write fails", async () => { + const writeText = stubClipboardWriteText(() => Promise.reject(new Error("denied"))); + const list = await mountWorkspaceList([workspace("ws-a")], new Set()); + openMenu(list, "ws-a"); + await list.updateComplete; + + detailCopyButton(list, "Copy path").click(); + await vi.waitFor(() => { expect(writeText).toHaveBeenCalled(); }); + await new Promise((resolve) => { setTimeout(resolve, 0); }); + await list.updateComplete; + + expect(detailCopyButton(list, "Copy path")).toBeDefined(); + expect(list.shadowRoot?.querySelector(".workspace-menu-panel .detail-copy[aria-label='Copied']")).toBeNull(); + }); +}); + +function openMenu(list: WorkspaceList, workspaceLabel: string): void { + const toggle = rowFor(list, workspaceLabel).querySelector(".action-menu-toggle"); + if (toggle === null) throw new Error(`Expected a menu toggle for ${workspaceLabel}`); + toggle.click(); +} + +function detailCopyButton(list: WorkspaceList, label: string): HTMLButtonElement { + const buttons = [...(list.shadowRoot?.querySelectorAll(".workspace-menu-panel .detail-copy") ?? [])]; + const button = buttons.find((candidate) => candidate.getAttribute("aria-label") === label); + if (button === undefined) throw new Error(`Expected a detail copy button labeled ${label}`); + return button; +} + +function stubClipboardWriteText(writeText: (text: string) => Promise): Mock<(text: string) => Promise> { + const mock = vi.fn<(text: string) => Promise>(writeText); + const secureContext = Object.getOwnPropertyDescriptor(window, "isSecureContext"); + const clipboard = Object.getOwnPropertyDescriptor(window.navigator, "clipboard"); + Object.defineProperty(window, "isSecureContext", { value: true, configurable: true }); + Object.defineProperty(window.navigator, "clipboard", { value: { writeText: mock }, configurable: true }); + restoreClipboardStub = () => { + restoreStubbedProperty(window, "isSecureContext", secureContext); + restoreStubbedProperty(window.navigator, "clipboard", clipboard); + }; + return mock; +} + +function restoreStubbedProperty(target: object, key: string, descriptor: PropertyDescriptor | undefined): void { + if (descriptor === undefined) { + Reflect.deleteProperty(target, key); + return; + } + Object.defineProperty(target, key, descriptor); +} + async function mountWorkspaceList(workspaces: Workspace[], unreadWorkspaceIds: ReadonlySet): Promise { const list = new WorkspaceList(); list.workspaces = workspaces; diff --git a/src/client/src/components/WorkspaceList.ts b/src/client/src/components/WorkspaceList.ts index c3b4179..a96edd9 100644 --- a/src/client/src/components/WorkspaceList.ts +++ b/src/client/src/components/WorkspaceList.ts @@ -1,6 +1,7 @@ import { LitElement, html, type PropertyValues, type TemplateResult } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import type { Workspace, WorkspaceActivity } from "../api"; +import { writeClipboardText } from "../clipboard"; import type { WorkspaceLabelItem } from "../plugins/types"; import { workspaceActivityFor, workspaceActivityIndicator } from "../workspaceActivity"; import { actionMenuPanelStyle } from "./actionMenu"; @@ -28,6 +29,7 @@ export class WorkspaceList extends LitElement implements KeyboardNavigableSectio @property({ attribute: false }) onCancelKeyboardNavigation?: () => void | Promise; @state() private openMenuWorkspaceId: string | undefined; @state() private menuStyle = ""; + @state() private copiedDetailKey: string | undefined; private readonly onDocumentClick = (event: MouseEvent) => { if (event.composedPath().includes(this)) return; @@ -159,15 +161,16 @@ export class WorkspaceList extends LitElement implements KeyboardNavigableSectio } private renderWorkspaceDetails(label: string, items: WorkspaceLabelItem[], workspace: Workspace): TemplateResult { + const branchCopyAction = workspace.branch === undefined ? "Copy workspace label" : "Copy branch"; return html`
${workspace.branch === undefined ? "Workspace" : "Branch"}
-
${label}
+
${label}${this.renderDetailCopyButton(`${workspace.id}:branch`, workspace.branch ?? workspace.label, branchCopyAction)}
Path
-
${workspace.path}
+
${workspace.path}${this.renderDetailCopyButton(`${workspace.id}:path`, workspace.path, "Copy path")}
${items.length === 0 ? null : html`
@@ -179,6 +182,25 @@ export class WorkspaceList extends LitElement implements KeyboardNavigableSectio `; } + private renderDetailCopyButton(key: string, value: string, action: string): TemplateResult { + const copied = this.copiedDetailKey === key; + const label = copied ? "Copied" : action; + return html` + + `; + } + + private async copyDetail(key: string, value: string): Promise { + const copied = await writeClipboardText(value); + if (!copied) return; + this.copiedDetailKey = key; + window.setTimeout(() => { + if (this.copiedDetailKey === key) this.copiedDetailKey = undefined; + }, 1200); + } + private delete(workspace: Workspace): void { if (this.isDeleting(workspace)) return; this.openMenuWorkspaceId = undefined; diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index ea132fb..bd4d75d 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -273,6 +273,8 @@ export const listStyles = css` .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; } + .action-menu-panel .detail-copy { box-sizing: border-box; display: inline-grid; place-items: center; width: 18px; height: 18px; margin-left: 6px; padding: 0; border: 1px solid var(--pi-border); border-radius: 5px; background: transparent; color: var(--pi-muted); font-size: 11px; line-height: 1; cursor: pointer; vertical-align: middle; } + .action-menu-panel .detail-copy:hover, .action-menu-panel .detail-copy:focus { color: var(--pi-text); border-color: var(--pi-accent); background: var(--pi-surface-hover); } .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; } .action-activity { position: absolute; top: 5px; right: 6px; z-index: 1; display: grid; place-items: center; width: 10px; height: 10px; }