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
@@ -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; }