diff --git a/daemon/internal/api/web_dist/app.js b/daemon/internal/api/web_dist/app.js index 6796ca0d..02d9ff89 100644 --- a/daemon/internal/api/web_dist/app.js +++ b/daemon/internal/api/web_dist/app.js @@ -1098,6 +1098,7 @@ document.addEventListener('DOMContentLoaded', () => { toast: showToast, confirm: (...args) => window.saConfirm(...args), render: renderNow, + renderSelection: renderWorktreeSelection, markDirty, visible: view => activeTab === 'system' && activeSystemSub === view, now: Date.now, @@ -3705,8 +3706,8 @@ document.addEventListener('DOMContentLoaded', () => { window.removeWorktree(d.path, d.branch); break; case 'worktree-select': - e.preventDefault(); worktreeController.toggleWorktreeSelection(d.path); + if (el.checked !== worktreeController.worktreesState.selected.has(d.path)) e.preventDefault(); Array.from(document.querySelectorAll('[data-action="worktree-select"]')).find(button => button.dataset.path === d.path)?.focus({ preventScroll: true }); break; case 'worktrees-select-visible': diff --git a/daemon/internal/api/web_dist/style.css b/daemon/internal/api/web_dist/style.css index 72beeb44..93710ef2 100644 --- a/daemon/internal/api/web_dist/style.css +++ b/daemon/internal/api/web_dist/style.css @@ -2205,6 +2205,7 @@ button.spend-day:focus-visible { outline: 2px solid var(--brand); outline-offset .wt-repo-error { margin-left: auto; font-size: 0.72rem; color: var(--warn); } /* Worktree identity and controls; Clutter retains its existing row layout. */ #system-worktrees .wt-repo-head, #system-worktrees .wt-main { flex-wrap: wrap; align-items: center; gap: 8px 12px; } +#system-worktrees .wt-repo-head { align-items: flex-start; } #system-worktrees .wt-repo-identity { flex: 1 1 220px; min-width: 0; } #system-worktrees .wt-repo-name { margin: 0 0 4px; font: 650 14px/1.4 var(--font); color: var(--tx-1); overflow-wrap: anywhere; } #system-worktrees .wt-repo-path { display: block; font: 12px/1.5 var(--mono); color: var(--tx-2); overflow: visible; text-overflow: clip; white-space: normal; overflow-wrap: anywhere; } @@ -2241,7 +2242,7 @@ button.spend-day:focus-visible { outline: 2px solid var(--brand); outline-offset #system-worktrees .wt-filters { gap: 10px; } #system-worktrees .wt-search { min-width: 0; } #system-worktrees .wt-add { min-width: 0; margin-left: 0; padding: 6px; border: 1px solid var(--line-2); border-radius: var(--r-sm); } -#system-worktrees { max-width: 1120px; min-width: 0; margin-inline: auto; } +#system-worktrees { width: 100%; max-width: 1120px; min-width: 0; margin-inline: auto; } #system-worktrees .wt-workspace { border: 0; border-radius: 0; background: transparent; overflow: visible; } #system-worktrees .wt-workspace > .panel-head { padding: 0 0 24px; align-items: flex-start; border: 0; background: transparent; } .wt-page-heading { min-width: 0; flex: 1 1 320px; } @@ -2257,7 +2258,7 @@ button.spend-day:focus-visible { outline: 2px solid var(--brand); outline-offset #system-worktrees #worktrees-container { display: block; padding: 0; } #system-worktrees .wt-repo-name { font-size: 15px; margin: 0; } #system-worktrees .wt-repo-path { font-size: 12px; margin-top: 4px; } -.wt-repo-options { margin-left: auto; max-width: 100%; color: var(--tx-2); font-size: 13px; } +.wt-repo-options { flex: 0 0 18rem; width: 18rem; margin-left: auto; max-width: 100%; color: var(--tx-2); font-size: 13px; } .wt-repo-options .wt-repo-actions { padding: 10px 0; } #system-worktrees .wt-lane-pills { padding: 0 0 8px; } .wt-lane { margin-top: 32px; min-width: 0; } @@ -2287,9 +2288,10 @@ button.spend-day:focus-visible { outline: 2px solid var(--brand); outline-offset #system-worktrees .wt-more { margin-left: 4px; color: var(--tx-2); font-size: 13px; } #system-worktrees .wt-main .wt-idle { color: var(--tx-2); font-size: 13px; } /* A lane's rows share its accent at the left edge. */ -#system-worktrees .wt-row.wt-in-work, #system-worktrees .wt-row.wt-in-check { box-shadow: inset 3px 0 0 var(--warn); } -#system-worktrees .wt-row.wt-in-safe { box-shadow: inset 3px 0 0 var(--ok); } -#system-worktrees .wt-row.wt-in-active { box-shadow: inset 3px 0 0 var(--brand); } +#system-worktrees .wt-row.wt-in-work, #system-worktrees .wt-row.wt-in-check, +#system-worktrees .wt-row.wt-in-work > .wt-main, #system-worktrees .wt-row.wt-in-check > .wt-main { box-shadow: inset 3px 0 0 var(--warn); } +#system-worktrees .wt-row.wt-in-safe, #system-worktrees .wt-row.wt-in-safe > .wt-main { box-shadow: inset 3px 0 0 var(--ok); } +#system-worktrees .wt-row.wt-in-active, #system-worktrees .wt-row.wt-in-active > .wt-main { box-shadow: inset 3px 0 0 var(--brand); } #system-worktrees .wt-size { font: 500 15px/1.5 var(--font); color: var(--tx-1); text-align: right; } .wt-disclosure { width: 16px; height: 16px; flex-shrink: 0; color: var(--tx-2); transform: rotate(90deg); } .wt-row[open] > .wt-main .wt-disclosure { transform: rotate(-90deg); } diff --git a/daemon/internal/api/web_dist/tab-worktrees.js b/daemon/internal/api/web_dist/tab-worktrees.js index 56cf7149..47f26280 100644 --- a/daemon/internal/api/web_dist/tab-worktrees.js +++ b/daemon/internal/api/web_dist/tab-worktrees.js @@ -840,6 +840,21 @@ function worktreesSummaryText(rep, nowMs, shown) { + (rep.refreshing ? ' · refreshing…' : ''); } +// Selection changes retain the list, disclosure state, and focused checkbox. +function renderWorktreeSelection() { + const state = window.SA.worktrees; + const container = document.getElementById('worktrees-container'); + if (!container || !state.report) return; + for (const checkbox of container.querySelectorAll('input.wt-select')) { + checkbox.checked = state.selected.has(checkbox.dataset.path); + } + const groups = worktreeGroups(state.report, state.filter, window.SA.globalSearchTerm?.() || ''); + const html = worktreeSelectionHTML(state.report, state, groups); + const bar = container.querySelector('.wt-selection'); + if (bar) bar.outerHTML = html; + else container.insertAdjacentHTML('beforeend', html); +} + function renderWorktrees() { const SA = window.SA; const container = document.getElementById('worktrees-container'); diff --git a/daemon/internal/api/web_dist/worktree-controller.js b/daemon/internal/api/web_dist/worktree-controller.js index 42558dff..f98c10b2 100644 --- a/daemon/internal/api/web_dist/worktree-controller.js +++ b/daemon/internal/api/web_dist/worktree-controller.js @@ -1,7 +1,7 @@ // Worktree Hunter and clutter: the two reports, their loaders and size // polling, the removal progress toast and the actions on both tabs. const WORKTREE_STALE_MS = 60000; -function createWorktreeController({ request, toast, confirm, render, markDirty, visible, now, history, discuss, toastHost }) { +function createWorktreeController({ request, toast, confirm, render, renderSelection = () => render(['worktrees']), markDirty, visible, now, history, discuss, toastHost }) { // Worktrees are not telemetry: the tab fetches its report when opened // (the daemon caches a scan for 10 minutes) and on Rescan, never on the // refresh cycle. @@ -303,7 +303,7 @@ function createWorktreeController({ request, toast, confirm, render, markDirty, if (!current || !current.approval) return; worktreesState.selected.set(path, current.approval); } - render(['worktrees']); + renderSelection(); } function selectVisibleWorktrees(globalSearchTerm) { @@ -315,14 +315,14 @@ function createWorktreeController({ request, toast, confirm, render, markDirty, if (approval) worktreesState.selected.set(w.path, approval); } } - render(['worktrees']); + renderSelection(); } function clearWorktreeSelection() { if (worktreesState.selectionBusy) return; worktreesState.selected.clear(); worktreesState.pendingRemovalSelections.clear(); - render(['worktrees']); + renderSelection(); } async function removeSelectedWorktrees() { diff --git a/packaging/test/console_dom/cases.py b/packaging/test/console_dom/cases.py index 7643b719..087c7b02 100644 --- a/packaging/test/console_dom/cases.py +++ b/packaging/test/console_dom/cases.py @@ -14,6 +14,7 @@ from cases_monitoring import CASES as monitoring from cases_flag_recovery import CASES as flag_recovery from cases_attention_stability import CASES as attention_stability +from cases_worktree_layout import CASES as worktree_layout CASES = (*reports, *sessions, *spend, *agent, *policy, *worktrees, *review_results, *expected_removal, *policy_health, - *protection_context, *worktree_selection, *monitoring, *flag_recovery, *attention_stability) + *protection_context, *worktree_selection, *monitoring, *flag_recovery, *attention_stability, *worktree_layout) diff --git a/packaging/test/console_dom/cases_worktree_layout.js b/packaging/test/console_dom/cases_worktree_layout.js new file mode 100644 index 00000000..4b7abf8a --- /dev/null +++ b/packaging/test/console_dom/cases_worktree_layout.js @@ -0,0 +1,106 @@ +(() => { + const {data, ready, idle, wait, openTab, iso} = window.DomTest; + const params = new URLSearchParams(location.search); + const width = Number(window.frameElement?.width || params.get('layoutwidth')); + // An iframe exercises exact CSS viewport widths below Chrome's minimum window size. + if (window.parent === window && !params.has('layoutchild')) { + const frame = document.createElement('iframe'); + frame.width = String(width); + frame.height = '900'; + frame.frameBorder = '0'; + frame.src = `harness.html?worktreelayout&layoutchild&layoutwidth=${width}`; + window.addEventListener('message', event => { + if (event.source === frame.contentWindow && event.data?.worktreeLayoutProbe) { + document.body.dataset.worktreeLayoutProbe = JSON.stringify(event.data.worktreeLayoutProbe); + } + }); + document.addEventListener('DOMContentLoaded', () => document.body.prepend(frame), {once:true}); + return; + } + const repo = '/synthetic/project-with-a-complete-repository-name'; + const rows = Array.from({length:8}, (_, index) => ({ + path: `${repo}/.worktrees/branch-${index}`, branch: `fix/branch-${index}`, state: 'remove', + head: 'a'.repeat(40), size_bytes: 24 * 1024 * 1024, last_activity: iso(86400000), idle_days: 1, + reasons: ['merged into origin/main (squash)'], reason_kinds: ['safe'], + })); + data['/worktrees'] = { + generated_at: iso(1000), duration_ms: 10, stale_days: 14, + repos: [{path:repo, source:'session', default_branch:'origin/main', size_bytes:201326592, worktrees:rows}], + summary: {repos:1, worktrees:8, remove:8}, advice:{}, asks:{}, removals:{}, + }; + const rect = element => { + const r = element.getBoundingClientRect(); + return [r.left, r.top, r.width, r.height]; + }; + const same = (before, after) => before.every((value, index) => Math.abs(value - after[index]) < 1); + ready().then(async () => { + const probe = {}; + try { + openTab('worktrees'); + await wait(() => document.querySelector('.wt-repo-options')); + await idle(); + const container = document.getElementById('worktrees-container'); + const options = container.querySelector('.wt-repo-options'); + const summary = options.querySelector('summary'); + const identity = container.querySelector('.wt-repo-identity'); + const facts = container.querySelector('.wt-repo-facts'); + const before = [rect(identity), rect(facts), rect(summary)]; + summary.click(); + await idle(); + probe.viewport = innerWidth === width; + probe.geometry = {width, actualWidth:innerWidth, before, after:[rect(identity), rect(facts), rect(summary)]}; + probe.headerStable = probe.geometry.after.every((value, index) => same(before[index], value)); + probe.fits = document.documentElement.scrollWidth <= innerWidth; + const row = container.querySelector('.wt-row'); + const checkbox = row.querySelector('.wt-select'); + row.querySelector('.wt-main').click(); + await idle(); + row.scrollIntoView({block:'center'}); + await idle(); + checkbox.focus({preventScroll:true}); + const geometry = rect(row); + const scrollBefore = scrollY; + checkbox.click(); + // Check synchronously and after a frame: replacement can flash for just one frame. + probe.selectionNodes = row.isConnected && checkbox.isConnected && options.isConnected; + probe.selectionOpen = row.open && options.open; + probe.selectionFocus = document.activeElement === checkbox; + await idle(); + probe.selectionChecked = checkbox.checked && window.SA.worktrees.selected.has(rows[0].path); + probe.selectionGeometry = same(geometry, rect(row)); + probe.geometry.selection = {before:geometry, after:rect(row), scrollBefore, scrollAfter:scrollY}; + probe.laneAccent = getComputedStyle(row.querySelector('.wt-main')).boxShadow !== 'none'; + probe.selectionOpen = probe.selectionOpen && row.open && options.open; + // toggle events are asynchronous: selection immediately after opening must retain it. + const next = container.querySelectorAll('.wt-row')[1]; + next.querySelector('.wt-main').click(); + next.querySelector('.wt-select').click(); + await idle(); + probe.rapidDisclosure = next.isConnected && next.open && window.SA.worktrees.expanded.has(rows[1].path); + container.querySelector('[data-action="worktrees-select-visible"]').click(); + await idle(); + probe.bulkSelection = row.isConnected && options.open && row.open + && container.querySelectorAll('.wt-select:checked').length === rows.length; + container.querySelector('[data-action="worktrees-clear-selection"]').click(); + await idle(); + probe.clearSelection = row.isConnected && options.open && row.open && !checkbox.checked + && !window.SA.worktrees.selected.size && !container.querySelector('.wt-select:checked'); + checkbox.closest('label').click(); + await idle(); + probe.labelSelection = row.isConnected && row.open && checkbox.checked + && window.SA.worktrees.selected.has(rows[0].path); + // Model a report arriving while focused controls hold a full render. + const stale = container.querySelectorAll('.wt-row')[2].querySelector('.wt-select'); + window.SA.worktrees.report.repos[0].worktrees[2].in_use = true; + stale.click(); + probe.rejectedSelection = !stale.checked && !window.SA.worktrees.selected.has(rows[2].path); + window.SA.worktrees.selectionBusy = true; + checkbox.click(); + probe.busySelection = checkbox.checked && window.SA.worktrees.selected.has(rows[0].path); + window.SA.worktrees.selectionBusy = false; + probe.fits = probe.fits && document.documentElement.scrollWidth <= innerWidth; + } catch (error) {probe.error = error.message;} + document.body.dataset.worktreeLayoutProbe = JSON.stringify(probe); + window.parent.postMessage({worktreeLayoutProbe:probe}, '*'); + }); +})(); diff --git a/packaging/test/console_dom/cases_worktree_layout.py b/packaging/test/console_dom/cases_worktree_layout.py new file mode 100644 index 00000000..6ec8a435 --- /dev/null +++ b/packaging/test/console_dom/cases_worktree_layout.py @@ -0,0 +1,21 @@ +"""Worktree disclosure and selection geometry through the real console.""" +from case_support import html, json, re +from case_types import Case + + +def run_worktree_layout(page): + for width in (320, 390, 559, 560, 561, 719, 720, 721, 1024, 1440, 1280): + served = width == 1280 + query = f'?worktreelayout&layoutwidth={width}' + ('&layoutchild' if served else '') + dom = page.dom(query, served=served, window_size=(width if served else 1600, 1000), + fixture={'script': 'cases_worktree_layout.js'}) + receipt = re.search(r'data-worktree-layout-probe="([^"]+)"', dom) + state = json.loads(html.unescape(receipt.group(1))) if receipt else {} + for name in ('viewport', 'headerStable', 'fits', 'selectionNodes', 'selectionOpen', + 'selectionFocus', 'selectionChecked', 'selectionGeometry', 'laneAccent', + 'rapidDisclosure', 'bulkSelection', 'clearSelection', 'labelSelection', + 'rejectedSelection', 'busySelection'): + page.check(f'worktree layout ({width}px): {name}', state.get(name) is True, str(state)) + + +CASES = (Case('worktree_layout', run_worktree_layout),)