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
3 changes: 2 additions & 1 deletion daemon/internal/api/web_dist/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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':
Expand Down
12 changes: 7 additions & 5 deletions daemon/internal/api/web_dist/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down Expand Up @@ -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; }
Expand All @@ -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; }
Expand Down Expand Up @@ -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); }
Expand Down
15 changes: 15 additions & 0 deletions daemon/internal/api/web_dist/tab-worktrees.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
8 changes: 4 additions & 4 deletions daemon/internal/api/web_dist/worktree-controller.js
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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) {
Expand All @@ -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() {
Expand Down
3 changes: 2 additions & 1 deletion packaging/test/console_dom/cases.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
106 changes: 106 additions & 0 deletions packaging/test/console_dom/cases_worktree_layout.js
Original file line number Diff line number Diff line change
@@ -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}, '*');
});
})();
21 changes: 21 additions & 0 deletions packaging/test/console_dom/cases_worktree_layout.py
Original file line number Diff line number Diff line change
@@ -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),)
Loading