diff --git a/src/Client/CanvasPane.fs b/src/Client/CanvasPane.fs index eaf4aa3..f818cc0 100644 --- a/src/Client/CanvasPane.fs +++ b/src/Client/CanvasPane.fs @@ -330,6 +330,7 @@ let view (isOpen: bool) (position: CanvasPosition) (focusedDoc: (WorktreeStatus prop.className (if isActive then "canvas-iframe canvas-iframe-active" else "canvas-iframe") prop.src (iframeSrc wt d) prop.custom ("sandbox", "allow-scripts allow-same-origin allow-forms allow-popups") + prop.custom ("allow", "clipboard-write") prop.style [ if not isActive then style.display.none ] diff --git a/src/Server/BeadspaceTemplate.html b/src/Server/BeadspaceTemplate.html index 0038a4d..9b85fb5 100644 --- a/src/Server/BeadspaceTemplate.html +++ b/src/Server/BeadspaceTemplate.html @@ -183,8 +183,36 @@ font-size: 0.6875rem; color: var(--text-muted); white-space: nowrap; + display: flex; + align-items: center; + gap: 0.25rem; } +.copy-id-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.25rem; + height: 1.25rem; + padding: 0; + background: transparent; + border: none; + border-radius: 4px; + color: var(--text-muted); + cursor: pointer; + opacity: 0; + flex-shrink: 0; + transition: opacity 0.12s ease, color 0.12s ease, background 0.12s ease; +} +.issues-table tr.issue-table-row:hover .copy-id-btn { opacity: 1; } +.copy-id-btn:hover { color: var(--text-primary); background: var(--bg-elevated); } +.copy-id-btn:focus-visible { opacity: 1; outline: 1px solid var(--accent); outline-offset: 1px; } +.copy-id-btn svg { width: 0.875rem; height: 0.875rem; display: block; } +.copy-id-btn .check-icon { display: none; } +.copy-id-btn.copied { color: var(--status-closed); opacity: 1; } +.copy-id-btn.copied .copy-icon { display: none; } +.copy-id-btn.copied .check-icon { display: block; } + .issues-table .col-title { font-weight: 500; max-width: 400px; @@ -407,6 +435,8 @@ margin-bottom: 0.125rem; } + .copy-id-btn { opacity: 1; } + .issues-table .col-title { white-space: normal; max-width: none; @@ -439,6 +469,32 @@ return el.innerHTML; } +function copyTextToClipboard(text) { + var safe = String(text).replace(/[\u0000-\u001F\u007F]/g, ''); + return navigator.clipboard.writeText(safe); +} + +var COPY_ICON_SVG = ''; +var CHECK_ICON_SVG = ''; + +function createCopyIdButton(id) { + var btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'copy-id-btn'; + btn.title = 'Copy ID'; + btn.setAttribute('aria-label', 'Copy issue ID'); + btn.innerHTML = COPY_ICON_SVG + CHECK_ICON_SVG; + btn.addEventListener('click', function(e) { + e.stopPropagation(); + copyTextToClipboard(id).then(function() { + btn.classList.add('copied'); + btn.title = 'Copied!'; + setTimeout(function() { btn.classList.remove('copied'); btn.title = 'Copy ID'; }, 1200); + }); + }); + return btn; +} + function daysAgo(dateStr) { if (!dateStr) return 0; var diff = Date.now() - new Date(dateStr).getTime(); @@ -628,7 +684,11 @@ var tdId = document.createElement('td'); tdId.className = 'col-id'; - tdId.textContent = i.id; + var idText = document.createElement('span'); + idText.className = 'id-text'; + idText.textContent = i.id; + tdId.appendChild(idText); + tdId.appendChild(createCopyIdButton(i.id)); tr.appendChild(tdId); var tdPri = document.createElement('td'); diff --git a/src/Tests/BeadspaceCanvasTests.fs b/src/Tests/BeadspaceCanvasTests.fs index 5686256..49d4783 100644 --- a/src/Tests/BeadspaceCanvasTests.fs +++ b/src/Tests/BeadspaceCanvasTests.fs @@ -307,3 +307,57 @@ type BeadspaceCanvasTests() = let! tableCount = this.Page.Locator(".issues-table").CountAsync() Assert.That(tableCount, Is.EqualTo(1), "Issues table should still be rendered after refresh") } + + // ── Copy ID Button ─────────────────────────────────────────────────── + + [] + member this.``Each issue row has a copy-ID button``() = + task { + let allChip = this.Page.Locator(".filter-chip", PageLocatorOptions(HasText = "All")) + do! allChip.ClickAsync() + let! rowCount = this.Page.Locator(".issue-table-row").CountAsync() + let! btnCount = this.Page.Locator(".issue-table-row .copy-id-btn").CountAsync() + Assert.That(rowCount, Is.GreaterThan(0), "There should be issue rows to test") + Assert.That(btnCount, Is.EqualTo(rowCount), "Every issue row should have exactly one copy-ID button") + } + + [] + member this.``Clicking copy button copies ID without expanding detail panel``() = + task { + do! this.Context.GrantPermissionsAsync([| "clipboard-read"; "clipboard-write" |]) + + let allChip = this.Page.Locator(".filter-chip", PageLocatorOptions(HasText = "All")) + do! allChip.ClickAsync() + + let firstRow = this.Page.Locator(".issue-table-row").First + let! expectedId = firstRow.GetAttributeAsync("data-issue-id") + + let copyBtn = firstRow.Locator(".copy-id-btn") + do! copyBtn.ClickAsync() + + // Detail panel must NOT expand — the button stops propagation + let! expandedCount = this.Page.Locator(".detail-panel.expanded").CountAsync() + Assert.That(expandedCount, Is.EqualTo(0), "Clicking the copy button should not expand the detail panel") + + // Web-first assertion: auto-retries and passes on first match, so the page's + // 1200ms timer clearing the .copied class can't fail an already-satisfied check. + let copiedBtn = firstRow.Locator(".copy-id-btn.copied") + do! Assertions.Expect(copiedBtn).ToBeVisibleAsync(LocatorAssertionsToBeVisibleOptions(Timeout = 3000.0f)) + + // Best-effort: clipboard content matches the row id + let! clip = this.Page.EvaluateAsync("() => navigator.clipboard.readText()") + Assert.That(clip, Is.EqualTo(expectedId), "Clipboard should contain the copied issue ID") + } + + [] + member this.``Clicking row body still expands detail panel``() = + task { + let allChip = this.Page.Locator(".filter-chip", PageLocatorOptions(HasText = "All")) + do! allChip.ClickAsync() + let firstRow = this.Page.Locator(".issue-table-row").First + do! firstRow.Locator(".col-title").ClickAsync() + let detailPanel = this.Page.Locator(".detail-panel.expanded") + do! detailPanel.WaitForAsync(LocatorWaitForOptions(Timeout = 5000.0f)) + let! count = detailPanel.CountAsync() + Assert.That(count, Is.EqualTo(1), "Clicking the row body should still expand the detail panel") + }