feat(ui): add copy buttons to workspace menu details

This commit is contained in:
Federico Jaramillo Martinez
2026-07-27 19:31:15 +02:00
parent 8517800a24
commit 4a5150309d
4 changed files with 120 additions and 3 deletions
@@ -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.
@@ -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<HTMLButtonElement>(".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<HTMLButtonElement>(".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<void>): Mock<(text: string) => Promise<void>> {
const mock = vi.fn<(text: string) => Promise<void>>(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<string>): Promise<WorkspaceList> {
const list = new WorkspaceList();
list.workspaces = workspaces;
+24 -2
View File
@@ -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<void>;
@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`
<dl class="workspace-menu-details">
<div class="workspace-detail-row">
<dt>${workspace.branch === undefined ? "Workspace" : "Branch"}</dt>
<dd>${label}</dd>
<dd>${label}${this.renderDetailCopyButton(`${workspace.id}:branch`, workspace.branch ?? workspace.label, branchCopyAction)}</dd>
</div>
<div class="workspace-detail-row">
<dt>Path</dt>
<dd title=${workspace.path}>${workspace.path}</dd>
<dd title=${workspace.path}>${workspace.path}${this.renderDetailCopyButton(`${workspace.id}:path`, workspace.path, "Copy path")}</dd>
</div>
${items.length === 0 ? null : html`
<div class="workspace-detail-row">
@@ -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`
<button type="button" class="detail-copy" title=${label} aria-label=${label} @click=${() => { void this.copyDetail(key, value); }}>
<span aria-hidden="true">${copied ? "✓" : "⧉"}</span>
</button>
`;
}
private async copyDetail(key: string, value: string): Promise<void> {
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;
+2
View File
@@ -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; }