Archived
feat(ui): add copy buttons to workspace menu details
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user