From 8d256cd4ab4de1273afe396bec0a23692a431db8 Mon Sep 17 00:00:00 2001 From: PageZ948 Date: Thu, 20 Aug 2026 01:15:01 +0000 Subject: [PATCH] feat: render scalable Alta site and group hierarchy --- README.md | 7 +- device-tree.js | 4 +- index.html | 17 +- package.json | 3 + renderer.js | 329 +++++++++++++++++--------------- scripts/verify-kit.js | 5 +- sidebar-controller.js | 13 +- sidebar-view.js | 142 ++++++++++++++ styles.css | 172 ++++++++++++++++- test/device-tree.test.js | 5 +- test/renderer-hierarchy.test.js | 76 ++++++++ test/sidebar-controller.test.js | 5 + 12 files changed, 614 insertions(+), 164 deletions(-) create mode 100644 sidebar-view.js create mode 100644 test/renderer-hierarchy.test.js diff --git a/README.md b/README.md index 974b6c2..48a4dc6 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,10 @@ npm start Use **Generate / Rotate** if a pairing may have been exposed, then update the extension. Use **Revoke** to immediately disable bridge authentication. +## Camera hierarchy and large deployments + +The sidebar presents collapsed Alta sites, then device groups, then cameras, including explicit fallback nodes for ungrouped, orphaned, unknown, or conflicting metadata. Search opens only matching ancestors and supports full tree keyboard navigation. A fixed-height virtual window keeps the mounted DOM bounded even when thousands of cameras are visible; selection and active-proxy state survive expansion, search, and scrolling. If site or group metadata cannot be loaded, every valid camera remains selectable in the fallback hierarchy and APT shows a warning. + ## Development and verification ```bash @@ -54,7 +58,8 @@ Core files: - `src/proxy-launch.js` — fixed shell-free helper process management - `src/update-policy.js` — exact GitPeji check-only release policy - `preload.js` — narrow context bridge -- `renderer.js`, `index.html`, `styles.css` — non-secret UI +- `device-tree.js`, `sidebar-controller.js`, `sidebar-view.js` — pure hierarchy, interaction state, and bounded DOM adapter +- `renderer.js`, `index.html`, `styles.css` — non-secret UI integration and Alta styling - `chrome-extension/` — stable-ID paired cookie sender - `test/` — pure and end-to-end contract tests diff --git a/device-tree.js b/device-tree.js index aa3f26e..05abce2 100644 --- a/device-tree.js +++ b/device-tree.js @@ -75,7 +75,7 @@ const identity = createIdentity('site', safe, ordinal, siteOccurrences, diagnostics); const site = makeSite(identity.uniqueId, text(safe.name) || identity.canonicalId, ordinal, { canonicalId: identity.canonicalId, - pendingDeletion: safe.pendingDeletion === true, + pendingDeletion: safe.pendingDeletion === true || Boolean(text(safe.pendingDeletionStart)), source: safe, }); sites.push(site); @@ -105,7 +105,7 @@ const group = makeGroup(site, identity.uniqueId, text(safe.name) || identity.canonicalId, ordinal, { canonicalId: identity.canonicalId, rawParentId: parentId, - pendingDeletion: safe.pendingDeletion === true, + pendingDeletion: safe.pendingDeletion === true || Boolean(text(safe.pendingDeletionStart)), source: safe, }); site.groups.push(group); diff --git a/index.html b/index.html index f97b005..66d89d1 100644 --- a/index.html +++ b/index.html @@ -12,13 +12,19 @@
@@ -103,6 +109,9 @@
+ + + diff --git a/package.json b/package.json index fec0d8e..d222b54 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,9 @@ "preload.js", "renderer.js", "renderer-controller.js", + "device-tree.js", + "sidebar-controller.js", + "sidebar-view.js", "index.html", "styles.css", "src/**/*", diff --git a/renderer.js b/renderer.js index a0ed42e..70bd3f8 100644 --- a/renderer.js +++ b/renderer.js @@ -3,6 +3,9 @@ const connectionStatus = document.getElementById('connectionStatus'); const deviceStatus = document.getElementById('deviceStatus'); const deviceList = document.getElementById('deviceList'); +const treeSpacer = document.getElementById('treeSpacer'); +const treeWindow = document.getElementById('treeWindow'); +const deviceResults = document.getElementById('deviceResults'); const statusIndicator = document.getElementById('statusIndicator'); const deviceSearch = document.getElementById('deviceSearch'); const disconnectBtn = document.getElementById('disconnectBtn'); @@ -21,12 +24,14 @@ const pairingSecretRow = document.getElementById('pairingSecretRow'); const rotatePairingBtn = document.getElementById('rotatePairingBtn'); const revokePairingBtn = document.getElementById('revokePairingBtn'); +const ROW_HEIGHT = window.AptSidebarView.ROW_HEIGHT; let connection = { connected: false, origin: null, activeProxies: [] }; let selectedDevice = null; -let allDevices = []; -let allSites = {}; -let collapsedSites = new Set(); -let loadingDevices = false; +let hierarchyModel = null; +let sidebarController = null; +let loadGeneration = 0; +let searchTimer = null; +let scrollFrame = null; function showStatus(element, message, type) { element.textContent = message; @@ -35,7 +40,20 @@ function showStatus(element, message, type) { } function activeDeviceIds() { - return new Set((connection.activeProxies || []).map((proxy) => proxy.deviceId)); + return new Set((connection.activeProxies || []).map((proxy) => String(proxy.deviceId || '').toLowerCase())); +} + +function deviceStatusFor(device) { + const value = String(device && (device.displayStatus ?? device.status ?? device.online) || '').toLowerCase(); + return ['green', 'online', 'live', 'connected', 'true'].includes(value); +} + +function updateProxyButtons() { + const id = selectedDevice && selectedDevice.id; + const active = id ? activeDeviceIds().has(id.toLowerCase()) : false; + const hasUsername = altaUsername.value.trim().length > 0; + startProxyBtn.disabled = !connection.connected || !id || !hasUsername || active; + stopProxyBtn.disabled = !id || !active; } function renderConnectionState(state) { @@ -50,171 +68,141 @@ function renderConnectionState(state) { text.textContent = connection.connected ? 'Connected' : 'Disconnected'; disconnectBtn.disabled = !connection.connected; updateProxyButtons(); + if (sidebarController) sidebarController.refresh(); } -function updateProxyButtons() { - const id = selectedDevice && (selectedDevice.guid || selectedDevice.id); - const active = id ? activeDeviceIds().has(id.toLowerCase()) : false; - const hasUsername = altaUsername.value.trim().length > 0; - startProxyBtn.disabled = !connection.connected || !id || !hasUsername || active; - stopProxyBtn.disabled = !id || !active; -} - -function clearDeviceList() { - deviceList.textContent = ''; +function showTreePlaceholder(message) { + treeSpacer.style.height = '0px'; + treeWindow.style.transform = ''; const placeholder = document.createElement('p'); placeholder.className = 'placeholder-text'; - placeholder.textContent = connection.connected ? 'Loading devices...' : 'Connect through the paired Chrome extension to load devices'; - deviceList.appendChild(placeholder); + placeholder.textContent = message; + treeWindow.replaceChildren(placeholder); + deviceList.removeAttribute('aria-activedescendant'); +} + +function clearHierarchy() { + loadGeneration += 1; + if (searchTimer) clearTimeout(searchTimer); + searchTimer = null; + if (sidebarController) sidebarController.destroy(); + sidebarController = null; + hierarchyModel = null; selectedDevice = null; selectedDeviceId.value = ''; + deviceSearch.value = ''; + deviceResults.textContent = connection.connected ? 'Loading cameras' : 'Connect to load cameras'; + showTreePlaceholder(connection.connected + ? 'Loading devices...' + : 'Connect through the paired Chrome extension to load devices'); updateProxyButtons(); } -function deviceStatusFor(device) { - const raw = device && device.live && device.live.display_status !== undefined - ? device.live.display_status - : device && device.status !== undefined - ? device.status - : device && device.online; - const value = String(raw ?? '').toLowerCase(); - return ['green', 'online', 'live', 'connected', 'true'].includes(value); -} - -function selectDevice(device, item) { - const previous = deviceList.querySelector('.device-item.selected'); - if (previous) previous.classList.remove('selected'); - item.classList.add('selected'); - selectedDevice = device; - const id = device.guid || device.id || ''; - selectedDeviceId.value = id; - showStatus(connectionStatus, `Selected device: ${device.name || 'Unnamed Device'}`, 'info'); +function syncSelection(snapshot) { + const selectedKey = snapshot && snapshot.state.selectedKey; + const camera = selectedKey && hierarchyModel ? hierarchyModel.cameraByKey.get(selectedKey) : null; + if (!camera) return; + if (selectedDevice && selectedDevice.id === camera.source.id) return; + selectedDevice = camera.source; + selectedDeviceId.value = selectedDevice.id; + showStatus(connectionStatus, `Selected device: ${selectedDevice.name || 'Unnamed Device'}`, 'info'); updateProxyButtons(); } -function createDeviceItem(device) { - const item = document.createElement('div'); - const id = device.guid || device.id || ''; - item.className = 'device-item'; - item.dataset.deviceId = id; - if (activeDeviceIds().has(String(id).toLowerCase())) item.classList.add('proxy-active'); - const name = document.createElement('div'); - name.className = 'device-name'; - name.textContent = device.name || 'Unnamed Device'; - const dot = document.createElement('div'); - dot.className = `device-status-dot ${deviceStatusFor(device) ? 'online' : 'offline'}`; - item.append(name, dot); - item.addEventListener('click', () => selectDevice(device, item)); - return item; +function syncTreeScroll(snapshot) { + if (!snapshot) return; + const target = snapshot.state.scrollTop; + if (Math.abs(deviceList.scrollTop - target) >= ROW_HEIGHT) deviceList.scrollTop = target; } -function groupDevicesBySite(devices) { - const groups = new Map(); - for (const device of devices) { - const siteId = device.server_group_id; - const siteName = siteId && allSites[siteId]; - const key = siteName ? siteId : '__ungrouped'; - if (!groups.has(key)) groups.set(key, { name: siteName || 'Ungrouped', devices: [] }); - groups.get(key).devices.push(device); - } - return [...groups.entries()].sort((left, right) => left[1].name.localeCompare(right[1].name)); +function describeHierarchyWarning(hierarchy, model) { + const diagnostics = hierarchy && hierarchy.diagnostics && typeof hierarchy.diagnostics === 'object' + ? hierarchy.diagnostics + : {}; + const metadataErrors = hierarchy && hierarchy.metadataErrors && typeof hierarchy.metadataErrors === 'object' + ? hierarchy.metadataErrors + : {}; + const unavailable = ['sites', 'groups'].filter((kind) => metadataErrors[kind] || (diagnostics[kind] && diagnostics[kind].error)); + const issueCount = model.diagnostics.length; + if (!unavailable.length && issueCount === 0) return ''; + const parts = []; + if (unavailable.length) parts.push(`${unavailable.join(' and ')} metadata unavailable`); + if (issueCount) parts.push(`${issueCount} hierarchy warning${issueCount === 1 ? '' : 's'}`); + return parts.join('; '); } -function displayDevices(devices) { - deviceList.textContent = ''; - if (!devices.length) { - const empty = document.createElement('p'); - empty.className = 'no-devices'; - empty.textContent = deviceSearch.value.trim() ? 'No devices match your search' : 'No local cameras found'; - deviceList.appendChild(empty); - return; - } - - const grouped = Object.keys(allSites).length > 0; - if (!grouped) { - for (const device of devices) deviceList.appendChild(createDeviceItem(device)); - return; - } - - for (const [siteId, group] of groupDevicesBySite(devices)) { - const header = document.createElement('div'); - const collapsed = collapsedSites.has(siteId); - header.className = `site-group-header${collapsed ? ' collapsed' : ''}`; - const arrow = document.createElement('span'); - arrow.className = 'site-group-arrow'; - arrow.textContent = collapsed ? '\u25B6' : '\u25BC'; - const name = document.createElement('span'); - name.className = 'site-group-name'; - name.textContent = group.name; - const count = document.createElement('span'); - count.className = 'site-group-count'; - count.textContent = String(group.devices.length); - header.append(arrow, name, count); - header.addEventListener('click', () => { - if (collapsedSites.has(siteId)) collapsedSites.delete(siteId); - else collapsedSites.add(siteId); - filterDevices(); - }); - deviceList.appendChild(header); - if (!collapsed) for (const device of group.devices) deviceList.appendChild(createDeviceItem(device)); - } -} - -function filterDevices() { - const query = deviceSearch.value.toLowerCase().trim(); - if (!query) { - displayDevices(allDevices); - return; - } - displayDevices(allDevices.filter((device) => { - const values = [ - device.name, device.guid, device.id, device.type, device.model, device.ipAddress, - device.server_group_id && allSites[device.server_group_id], - ]; - return values.some((value) => String(value || '').toLowerCase().includes(query)); - })); +function installHierarchy(hierarchy) { + hierarchyModel = window.AptDeviceTree.buildDeviceTreeModel(hierarchy); + const view = window.AptSidebarView.createSidebarView({ + document, + tree: deviceList, + spacer: treeSpacer, + windowElement: treeWindow, + model: hierarchyModel, + getActiveDeviceIds: activeDeviceIds, + isOnline: deviceStatusFor, + onCommit: (snapshot) => { + const count = snapshot.state.searchQuery && snapshot.state.searchCameraKeys + ? snapshot.state.searchCameraKeys.size + : hierarchyModel.cameraCount; + const hidden = snapshot.rows.hiddenSelected ? ' Selected camera is hidden by the current view.' : ''; + deviceResults.textContent = snapshot.state.searchQuery + ? `${count} exact search result${count === 1 ? '' : 's'}.${hidden}` + : `${count} camera${count === 1 ? '' : 's'}.${hidden}`; + }, + }); + sidebarController = window.AptSidebarController.createSidebarController({ + model: hierarchyModel, + view, + rowHeight: ROW_HEIGHT, + overscan: 16, + initialState: { viewportHeight: deviceList.clientHeight }, + }); } async function loadDevices() { - if (!connection.connected || loadingDevices) return; - loadingDevices = true; - showStatus(deviceStatus, 'Fetching devices...', 'info'); + if (!connection.connected) return; + const generation = ++loadGeneration; + const origin = connection.origin; + showStatus(deviceStatus, 'Fetching camera hierarchy...', 'info'); try { - const [devicesResult, sitesResult] = await Promise.all([ - window.electronAPI.getDevices(), - window.electronAPI.getDeviceSites(), - ]); - allSites = {}; - if (sitesResult.success && Array.isArray(sitesResult.sites)) { - for (const site of sitesResult.sites) if (site.id) allSites[site.id] = site.name || 'Unnamed Site'; - } - if (!devicesResult.success) { - allDevices = []; - showStatus(deviceStatus, devicesResult.message || 'Failed to load devices', 'error'); - clearDeviceList(); + const result = await window.electronAPI.getDeviceHierarchy(); + if (generation !== loadGeneration || !connection.connected || connection.origin !== origin) return; + if (!result.success) { + showStatus(deviceStatus, result.message || 'Failed to load cameras', 'error'); + showTreePlaceholder('Could not load cameras'); return; } - allDevices = devicesResult.devices.filter((device) => - !device.capabilities || device.capabilities.localStorage === undefined || device.capabilities.localStorage === false + const hierarchy = result.hierarchy || { devices: [], sites: [], groups: [] }; + installHierarchy(hierarchy); + const warning = describeHierarchyWarning(hierarchy, hierarchyModel); + const count = hierarchyModel.cameraCount; + showStatus( + deviceStatus, + warning ? `Found ${count} camera${count === 1 ? '' : 's'}; ${warning}. Cameras remain available in fallback hierarchy.` : `Found ${count} camera${count === 1 ? '' : 's'}`, + warning ? 'warning' : 'success', ); - showStatus(deviceStatus, `Found ${allDevices.length} local camera${allDevices.length === 1 ? '' : 's'}`, 'success'); - filterDevices(); } catch { - showStatus(deviceStatus, 'Could not load devices.', 'error'); - } finally { - loadingDevices = false; + if (generation !== loadGeneration) return; + showStatus(deviceStatus, 'Could not load cameras.', 'error'); + showTreePlaceholder('Could not load cameras'); } } +function dispatchTree(action) { + if (!sidebarController) return null; + const snapshot = sidebarController.dispatch(action); + syncSelection(snapshot); + syncTreeScroll(snapshot); + return snapshot; +} + const rendererController = window.AptRendererController.createRendererController({ disconnect: () => window.electronAPI.disconnect(), renderConnectionState, clearDisconnectedState: () => { - allDevices = []; - allSites = {}; - collapsedSites.clear(); altaUsername.value = ''; - clearDeviceList(); + clearHierarchy(); }, showConnectionStatus: (message, type) => showStatus(connectionStatus, message, type), }); @@ -224,15 +212,13 @@ async function handleDisconnect() { } async function handleStartProxy() { - const id = selectedDevice && (selectedDevice.guid || selectedDevice.id); + const id = selectedDevice && selectedDevice.id; if (!id) return; startProxyBtn.disabled = true; showStatus(connectionStatus, `Starting proxy for ${selectedDevice.name || 'selected device'}...`, 'info'); const result = await window.electronAPI.launchProxy(id, altaUsername.value); if (result.success) { - connection = await window.electronAPI.getConnectionState(); - renderConnectionState(connection); - filterDevices(); + renderConnectionState(await window.electronAPI.getConnectionState()); showStatus(connectionStatus, 'Camera proxy started.', 'success'); } else { showStatus(connectionStatus, result.message || 'Failed to start camera proxy.', 'error'); @@ -241,13 +227,11 @@ async function handleStartProxy() { } async function handleStopProxy() { - const id = selectedDevice && (selectedDevice.guid || selectedDevice.id); + const id = selectedDevice && selectedDevice.id; if (!id) return; stopProxyBtn.disabled = true; const result = await window.electronAPI.stopProxy(id); - connection = await window.electronAPI.getConnectionState(); - renderConnectionState(connection); - filterDevices(); + renderConnectionState(await window.electronAPI.getConnectionState()); showStatus(connectionStatus, result.success ? 'Camera proxy stopped.' : result.message || 'Failed to stop camera proxy.', result.success ? 'success' : 'error'); } @@ -288,6 +272,17 @@ function renderPairingStatus(result) { } } +async function runSearch(query) { + if (!sidebarController) return; + const result = await sidebarController.search(query); + if (result && !result.cancelled) { + const count = result.count; + deviceResults.textContent = query.trim() + ? `${count} exact search result${count === 1 ? '' : 's'}.` + : `${hierarchyModel.cameraCount} camera${hierarchyModel.cameraCount === 1 ? '' : 's'}.`; + } +} + async function initialize() { renderConnectionState(await window.electronAPI.getConnectionState()); renderPairingStatus(await window.electronAPI.getPairingStatus()); @@ -296,10 +291,12 @@ async function initialize() { const wasConnected = connection.connected; const previousOrigin = connection.origin; renderConnectionState(state); + if (!state.connected) { + clearHierarchy(); + return; + } if (state.connected && (!wasConnected || state.origin !== previousOrigin)) { - allDevices = []; - allSites = {}; - clearDeviceList(); + clearHierarchy(); showStatus(connectionStatus, `Connected to ${state.origin}`, 'success'); await loadDevices(); } @@ -307,10 +304,42 @@ async function initialize() { setTimeout(() => checkForUpdates(false), 2000); } +deviceList.addEventListener('click', (event) => { + const row = event.target.closest('.tree-row[data-row-key]'); + if (!row || !deviceList.contains(row)) return; + dispatchTree({ type: 'CLICK', rowKey: row.dataset.rowKey }); + deviceList.focus(); +}); +deviceList.addEventListener('keydown', (event) => { + if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End', 'Enter', ' ', 'Spacebar', 'Escape'].includes(event.key)) return; + event.preventDefault(); + dispatchTree({ type: 'KEY', key: event.key }); + if (event.key === 'Escape') { + deviceSearch.value = ''; + runSearch(''); + } +}); +deviceList.addEventListener('scroll', () => { + if (scrollFrame || !sidebarController) return; + scrollFrame = requestAnimationFrame(() => { + scrollFrame = null; + sidebarController.dispatch({ type: 'SET_VIEWPORT', scrollTop: deviceList.scrollTop, viewportHeight: deviceList.clientHeight }); + }); +}); +deviceSearch.addEventListener('input', () => { + if (searchTimer) clearTimeout(searchTimer); + searchTimer = setTimeout(() => runSearch(deviceSearch.value), 125); +}); +deviceSearch.addEventListener('keydown', (event) => { + if (event.key !== 'Escape') return; + event.preventDefault(); + if (searchTimer) clearTimeout(searchTimer); + deviceSearch.value = ''; + runSearch(''); +}); disconnectBtn.addEventListener('click', handleDisconnect); startProxyBtn.addEventListener('click', handleStartProxy); stopProxyBtn.addEventListener('click', handleStopProxy); -deviceSearch.addEventListener('input', filterDevices); altaUsername.addEventListener('input', updateProxyButtons); checkUpdateBtn.addEventListener('click', () => checkForUpdates(true)); openReleasesBtn.addEventListener('click', () => window.electronAPI.openFixedReleasesPage()); diff --git a/scripts/verify-kit.js b/scripts/verify-kit.js index b7168b4..fc863ec 100644 --- a/scripts/verify-kit.js +++ b/scripts/verify-kit.js @@ -171,7 +171,10 @@ function verifySource() { for (const [label, version] of [['lockfile', lock.version], ['lockfile root package', lock.packages?.['']?.version]]) { if (version !== pkg.version) fail(`${label} version ${version} does not match application version ${pkg.version}`); } - const productionRoots = ['main.js', 'preload.js', 'renderer.js', 'index.html', 'src/', 'chrome-extension/']; + const productionRoots = [ + 'main.js', 'preload.js', 'renderer.js', 'renderer-controller.js', 'device-tree.js', + 'sidebar-controller.js', 'sidebar-view.js', 'index.html', 'src/', 'chrome-extension/', + ]; const productionFiles = new Map([...files].filter(([name]) => productionRoots.some((root) => name === root || name.startsWith(root)) )); diff --git a/sidebar-controller.js b/sidebar-controller.js index fb1ec83..6219a3b 100644 --- a/sidebar-controller.js +++ b/sidebar-controller.js @@ -133,7 +133,7 @@ } const model = options.model; const view = options.view; - const rowHeight = Number.isFinite(options.rowHeight) ? options.rowHeight : 28; + const rowHeight = Number.isFinite(options.rowHeight) ? options.rowHeight : 30; const overscan = Number.isInteger(options.overscan) ? options.overscan : 16; const searchRunner = options.searchRunner || treeApi.createSearchRunner({ scheduler: options.scheduler, chunkSize: options.chunkSize }); let state = createInitialState(options.initialState); @@ -149,6 +149,15 @@ selectedKey: state.selectedKey, matchCameraKeys: state.searchQuery ? state.searchCameraKeys : null, }); + const activeIndex = rows.findIndex((row) => row.key === state.activeKey); + if (activeIndex >= 0 && state.viewportHeight > 0) { + const activeTop = activeIndex * rowHeight; + const activeBottom = activeTop + rowHeight; + let scrollTop = state.scrollTop; + if (activeTop < scrollTop) scrollTop = activeTop; + else if (activeBottom > scrollTop + state.viewportHeight) scrollTop = activeBottom - state.viewportHeight; + if (scrollTop !== state.scrollTop) state = Object.assign({}, state, { scrollTop }); + } const window = treeApi.calculateVirtualWindow(rows, { scrollTop: state.scrollTop, viewportHeight: state.viewportHeight, rowHeight, overscan, }); @@ -188,7 +197,7 @@ commit(); return Object.freeze({ - dispatch, search, getState: () => state, getSnapshot: () => lastSnapshot, + dispatch, search, refresh: commit, getState: () => state, getSnapshot: () => lastSnapshot, destroy() { searchRunner.cancel(); }, }); } diff --git a/sidebar-view.js b/sidebar-view.js new file mode 100644 index 0000000..e45b955 --- /dev/null +++ b/sidebar-view.js @@ -0,0 +1,142 @@ +'use strict'; + +(function exposeSidebarView(root, factory) { + const api = factory(); + if (typeof module !== 'undefined' && module.exports) module.exports = api; + if (root) root.AptSidebarView = api; +}(typeof window !== 'undefined' ? window : undefined, function sidebarViewFactory() { + const ROW_HEIGHT = 30; + + function stableDomId(key) { + return `apt-tree-row-${Array.from(String(key || '')).map((character) => character.codePointAt(0).toString(16)).join('-')}`; + } + + function describeVirtualRows(snapshot, options = {}) { + const activeIds = options.activeDeviceIds instanceof Set ? options.activeDeviceIds : new Set(); + const model = options.model; + const isOnline = typeof options.isOnline === 'function' ? options.isOnline : () => false; + const windowRows = snapshot && snapshot.window && Array.isArray(snapshot.window.rows) + ? snapshot.window.rows + : []; + return windowRows.map((row, windowIndex) => { + const node = model && model.rowByKey ? model.rowByKey.get(row.key) : null; + const device = node && node.kind === 'camera' ? node.source : null; + const deviceId = device ? device.id : row.id; + const classes = ['tree-row', `tree-${row.kind}`]; + if (row.ariaSelected) classes.push('selected'); + if (snapshot.state && snapshot.state.activeKey === row.key) classes.push('active'); + if (row.pendingDeletion) classes.push('pending-deletion'); + if (row.hierarchyStatus && !['assigned', 'inferred-site'].includes(row.hierarchyStatus)) classes.push('hierarchy-warning'); + if (row.kind === 'camera') { + classes.push(isOnline(device || row) ? 'online' : 'offline'); + if (activeIds.has(String(deviceId || '').toLowerCase())) classes.push('proxy-active'); + } + return Object.freeze({ + key: row.key, + kind: row.kind, + name: row.name, + device, + deviceId, + role: 'treeitem', + domId: stableDomId(row.key), + top: windowIndex * ROW_HEIGHT, + classes: classes.join(' '), + pendingDeletion: row.pendingDeletion === true, + hierarchyStatus: row.hierarchyStatus, + address: device && device.address ? device.address : '', + aria: { + level: row.ariaLevel, + expanded: row.ariaExpanded, + selected: row.ariaSelected, + posinset: row.ariaPosInSet, + setsize: row.ariaSetSize, + }, + }); + }); + } + + function setAria(element, name, value) { + if (value === undefined || value === null) element.removeAttribute(`aria-${name}`); + else element.setAttribute(`aria-${name}`, String(value)); + } + + function createSidebarView(options = {}) { + const documentRef = options.document; + const tree = options.tree; + const spacer = options.spacer; + const windowElement = options.windowElement; + if (!documentRef || !tree || !spacer || !windowElement) { + throw new TypeError('Sidebar view requires document, tree, spacer, and windowElement.'); + } + + function createRow(descriptor) { + const element = documentRef.createElement('div'); + element.id = descriptor.domId; + element.className = descriptor.classes; + element.dataset.rowKey = descriptor.key; + element.setAttribute('role', descriptor.role); + element.style.top = `${descriptor.top}px`; + setAria(element, 'level', descriptor.aria.level); + setAria(element, 'expanded', descriptor.aria.expanded); + setAria(element, 'selected', descriptor.aria.selected); + setAria(element, 'posinset', descriptor.aria.posinset); + setAria(element, 'setsize', descriptor.aria.setsize); + + const disclosure = documentRef.createElement('span'); + disclosure.className = 'tree-disclosure'; + disclosure.setAttribute('aria-hidden', 'true'); + disclosure.textContent = descriptor.kind === 'camera' ? '' : (descriptor.aria.expanded ? '\u25bc' : '\u25b6'); + const label = documentRef.createElement('span'); + label.className = 'tree-label'; + label.textContent = descriptor.name; + element.append(disclosure, label); + + if (descriptor.address) { + const address = documentRef.createElement('span'); + address.className = 'tree-address'; + address.textContent = descriptor.address; + element.appendChild(address); + } + if (descriptor.pendingDeletion) { + const pending = documentRef.createElement('span'); + pending.className = 'tree-badge pending'; + pending.textContent = 'Pending deletion'; + element.appendChild(pending); + } + if (descriptor.hierarchyStatus && !['assigned', 'inferred-site'].includes(descriptor.hierarchyStatus)) { + const warning = documentRef.createElement('span'); + warning.className = 'tree-warning'; + warning.title = `Hierarchy: ${descriptor.hierarchyStatus}`; + warning.setAttribute('aria-label', `Hierarchy warning: ${descriptor.hierarchyStatus}`); + warning.textContent = '!'; + element.appendChild(warning); + } + return element; + } + + function commit(snapshot) { + const descriptors = describeVirtualRows(snapshot, { + model: options.model, + activeDeviceIds: typeof options.getActiveDeviceIds === 'function' ? options.getActiveDeviceIds() : new Set(), + isOnline: options.isOnline, + }); + spacer.style.height = `${snapshot.window.totalHeight}px`; + windowElement.style.transform = `translateY(${snapshot.window.offsetTop}px)`; + windowElement.style.height = `${descriptors.length * ROW_HEIGHT}px`; + const fragment = documentRef.createDocumentFragment(); + for (const descriptor of descriptors) fragment.appendChild(createRow(descriptor)); + windowElement.replaceChildren(fragment); + + const active = descriptors.find((row) => snapshot.state.activeKey === row.key); + if (active) tree.setAttribute('aria-activedescendant', active.domId); + else tree.removeAttribute('aria-activedescendant'); + tree.classList.toggle('has-hidden-selection', snapshot.rows.hiddenSelected === true); + if (typeof options.onCommit === 'function') options.onCommit(snapshot, descriptors); + return descriptors; + } + + return Object.freeze({ commit }); + } + + return Object.freeze({ ROW_HEIGHT, stableDomId, describeVirtualRows, createSidebarView }); +})); diff --git a/styles.css b/styles.css index 83958c4..aff017a 100644 --- a/styles.css +++ b/styles.css @@ -51,7 +51,7 @@ body { /* Left Sidebar - Available Devices */ .devices-sidebar { - width: 220px; + width: 300px; background: var(--card-bg); border-right: 1px solid var(--border); display: flex; @@ -113,7 +113,175 @@ body { .device-list { flex: 1; overflow-y: auto; - padding: 8px; + padding: 0; +} + +/* Accessible, bounded virtual hierarchy */ +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.tree-results { + min-height: 16px; + margin-top: 6px; + color: var(--text-secondary); + font-size: 11px; +} + +.virtual-tree { + position: relative; + outline: none; +} + +.virtual-tree:focus-visible { + box-shadow: inset 0 0 0 2px var(--accent-primary); +} + +.virtual-tree.has-hidden-selection::after { + content: 'Selected camera hidden'; + position: sticky; + bottom: 4px; + left: 8px; + z-index: 3; + display: inline-block; + padding: 2px 6px; + border-radius: 3px; + background: var(--bg-primary); + color: var(--warning); + font-size: 10px; + pointer-events: none; +} + +.virtual-tree-spacer { + position: relative; + min-height: 100%; +} + +.virtual-tree-window { + position: absolute; + top: 0; + right: 0; + left: 0; + will-change: transform; +} + +.tree-row { + position: absolute; + right: 0; + left: 0; + height: 30px; + display: flex; + align-items: center; + gap: 5px; + padding-right: 8px; + border-left: 2px solid transparent; + color: var(--text-primary); + cursor: default; + user-select: none; +} + +.tree-row:hover, +.tree-row.active { + background: var(--hover-bg); +} + +.tree-row.active { + border-left-color: var(--accent-primary); + box-shadow: inset 0 0 0 1px rgba(14, 122, 254, 0.35); +} + +.tree-row.selected { + background: rgba(14, 122, 254, 0.28); +} + +.tree-site { + padding-left: 8px; + color: var(--accent-primary); + font-weight: 700; +} + +.tree-group { + padding-left: 24px; + color: #b8cbea; + font-weight: 600; +} + +.tree-camera { + padding-left: 42px; +} + +.tree-camera::before { + content: ''; + width: 7px; + height: 7px; + flex: 0 0 7px; + border-radius: 50%; + background: var(--error); +} + +.tree-camera.online::before { + background: var(--success); +} + +.tree-camera.proxy-active { + border-right: 3px solid var(--success); +} + +.tree-disclosure { + width: 10px; + flex: 0 0 10px; + color: var(--text-secondary); + font-size: 9px; + text-align: center; +} + +.tree-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tree-address { + margin-left: auto; + overflow: hidden; + color: var(--text-secondary); + font-family: Consolas, monospace; + font-size: 10px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tree-badge { + flex: 0 0 auto; + padding: 1px 4px; + border-radius: 3px; + font-size: 9px; +} + +.tree-badge.pending, +.tree-row.pending-deletion { + color: var(--warning); +} + +.tree-warning { + width: 14px; + height: 14px; + flex: 0 0 14px; + border: 1px solid var(--warning); + border-radius: 50%; + color: var(--warning); + font-size: 9px; + line-height: 12px; + text-align: center; } .device-item { diff --git a/test/device-tree.test.js b/test/device-tree.test.js index 81d14ed..f850d1f 100644 --- a/test/device-tree.test.js +++ b/test/device-tree.test.js @@ -13,11 +13,11 @@ function baseFixture() { return { sites: [ { id: 's2', name: 'Zulu' }, - { id: 's1', name: 'Alpha', pendingDeletion: true }, + { id: 's1', name: 'Alpha', pendingDeletionStart: '2026-08-19T12:00:00Z' }, ], groups: [ { id: 'g2', name: 'Doors', parentId: 's2' }, - { id: 'g1', name: 'Lobby', parentId: 's1', pendingDeletion: true }, + { id: 'g1', name: 'Lobby', parentId: 's1', pendingDeletionStart: '2026-08-19T12:00:00Z' }, ], devices: [ { id: 'c4', name: 'No Group', siteId: 's1', type: 'camera' }, @@ -36,6 +36,7 @@ test('builds deterministic hierarchy with explicit exceptional buckets and diagn assert.deepEqual(model.sites.map((site) => site.name), ['Alpha', 'Orphaned cameras', 'Unknown site: missing-s', 'Zulu']); assert.equal(model.sites[0].pendingDeletion, true); assert.deepEqual(model.sites[0].groups.map((group) => group.name), ['Doors', 'Lobby', 'Ungrouped', 'Unknown group: missing-g']); + assert.equal(model.sites[0].groups.find((group) => group.id === 'g1').pendingDeletion, true); assert.deepEqual(model.sites[3].groups[0].cameras.map((camera) => camera.name), ['Inferred']); const conflict = model.cameraByKey.get('camera:c3'); diff --git a/test/renderer-hierarchy.test.js b/test/renderer-hierarchy.test.js new file mode 100644 index 0000000..0eebc29 --- /dev/null +++ b/test/renderer-hierarchy.test.js @@ -0,0 +1,76 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { describeVirtualRows } = require('../sidebar-view'); + +const ROOT = path.resolve(__dirname, '..'); +const read = (name) => fs.readFileSync(path.join(ROOT, name), 'utf8'); + +function syntheticSnapshot(count) { + const rows = Array.from({ length: count }, (_, index) => ({ + key: `camera:00000000-0000-4000-8000-${String(index).padStart(12, '0')}`, + id: `00000000-0000-4000-8000-${String(index).padStart(12, '0')}`, + kind: 'camera', name: `Camera ${index}`, ariaLevel: 3, ariaSelected: index === 4, + ariaPosInSet: index + 1, ariaSetSize: count, + })); + const startIndex = Math.max(0, Math.floor(count / 2) - 20); + return { + state: { activeKey: rows[startIndex].key, selectedKey: rows[4].key }, + rows: Object.assign(rows, { hiddenSelected: true }), + window: { rows: rows.slice(startIndex, startIndex + 60), startIndex, offsetTop: startIndex * 30, totalHeight: count * 30 }, + }; +} + +test('renderer loads hierarchy foundations in order and exposes an accessible virtual tree', () => { + const html = read('index.html'); + const treeIndex = html.indexOf('device-tree.js'); + const controllerIndex = html.indexOf('sidebar-controller.js'); + const viewIndex = html.indexOf('sidebar-view.js'); + const rendererIndex = html.indexOf('renderer.js'); + assert.ok(treeIndex >= 0 && treeIndex < controllerIndex && controllerIndex < viewIndex && viewIndex < rendererIndex); + assert.match(html, /id="deviceList"[^>]*role="tree"[^>]*tabindex="0"[^>]*aria-activedescendant/); + assert.match(html, /id="deviceResults"[^>]*role="status"[^>]*aria-live="polite"/); +}); + +test('renderer uses one hierarchy request and no parallel flat discovery', () => { + const renderer = read('renderer.js'); + assert.match(renderer, /electronAPI\.getDeviceHierarchy\(\)/); + assert.doesNotMatch(renderer, /electronAPI\.getDevices\(|electronAPI\.getDeviceSites\(|Promise\.all\(\s*\[\s*window\.electronAPI\.get/); + assert.doesNotMatch(renderer, /\ballDevices\b|\ballSites\b|\bcollapsedSites\b|groupDevicesBySite/); + assert.match(renderer, /loadGeneration/); + assert.match(renderer, /generation\s*!==\s*loadGeneration/); +}); + +test('renderer uses delegated tree events, throttled scroll, and debounced search', () => { + const renderer = read('renderer.js'); + assert.equal((renderer.match(/deviceList\.addEventListener\('click'/g) || []).length, 1); + assert.equal((renderer.match(/deviceList\.addEventListener\('keydown'/g) || []).length, 1); + assert.equal((renderer.match(/deviceList\.addEventListener\('scroll'/g) || []).length, 1); + assert.match(renderer, /requestAnimationFrame/); + assert.match(renderer, /setTimeout\([^,]+,\s*125\)/s); +}); + +test('virtual row descriptors stay bounded for 1,500 and 5,000 visible rows', () => { + for (const count of [1_500, 5_000]) { + const descriptors = describeVirtualRows(syntheticSnapshot(count), { activeDeviceIds: new Set() }); + assert.ok(descriptors.length <= 80, `${count} rows mounted ${descriptors.length} descriptors`); + assert.equal(descriptors[0].role, 'treeitem'); + assert.equal(descriptors[0].aria.level, 3); + assert.match(descriptors[0].domId, /^apt-tree-row-/); + } +}); + +test('view descriptors preserve selection and refresh proxy/status classes without rebuilding rows', () => { + const snapshot = syntheticSnapshot(1_500); + const selectedId = snapshot.rows[4].id; + const before = describeVirtualRows(snapshot, { activeDeviceIds: new Set() }); + const camera = before.find((row) => row.kind === 'camera'); + const after = describeVirtualRows(snapshot, { activeDeviceIds: new Set([camera.deviceId]), isOnline: () => true }); + assert.ok(after.find((row) => row.key === camera.key).classes.includes('proxy-active')); + assert.ok(after.find((row) => row.key === camera.key).classes.includes('online')); + assert.equal(snapshot.rows[4].id, selectedId); + assert.equal(snapshot.rows.hiddenSelected, true); +}); diff --git a/test/sidebar-controller.test.js b/test/sidebar-controller.test.js index 1fcda70..893d2cc 100644 --- a/test/sidebar-controller.test.js +++ b/test/sidebar-controller.test.js @@ -81,6 +81,11 @@ test('controller commits exactly once per dispatch through view adapter', () => assert.equal(commits.length, 2); assert.equal(commits[1].state.expandedKeys.has('site:s'), true); assert.ok(Array.isArray(commits[1].window.rows)); + const modelReference = controller.getSnapshot().rows; + controller.refresh(); + assert.equal(commits.length, 3); + assert.notEqual(controller.getSnapshot().rows, modelReference); + assert.equal(controller.getSnapshot().rows.length, modelReference.length); }); test('active and selected state survive virtualization and selection is indicated when hidden', () => {