Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/Client/CanvasPane.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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
]
Expand Down
62 changes: 61 additions & 1 deletion src/Server/BeadspaceTemplate.html
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -407,6 +435,8 @@
margin-bottom: 0.125rem;
}

.copy-id-btn { opacity: 1; }

.issues-table .col-title {
white-space: normal;
max-width: none;
Expand Down Expand Up @@ -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 = '<svg class="copy-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>';
var CHECK_ICON_SVG = '<svg class="check-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></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();
Expand Down Expand Up @@ -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');
Expand Down
54 changes: 54 additions & 0 deletions src/Tests/BeadspaceCanvasTests.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────────────────────────────────

[<Test>]
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")
}

[<Test>]
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<string>("() => navigator.clipboard.readText()")
Assert.That(clip, Is.EqualTo(expectedId), "Clipboard should contain the copied issue ID")
}

[<Test>]
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")
}
Loading