feat(dashboard): debounced filter for linked devices table on Sync page
CI / Build & Test (push) Successful in 4m25s

Add search input (name, ID, type) with 250ms debounce; mirror in extension
and renderer dashboards. Update parity docs note in Browser repo only.
This commit is contained in:
Raven Scott
2026-03-27 01:42:50 -04:00
parent 64d8eb4d7c
commit bd48847bf0
5 changed files with 296 additions and 56 deletions
+58
View File
@@ -1042,6 +1042,64 @@
border-color: color-mix(in srgb, var(--amber) 35%, transparent);
color: var(--amber);
}
.sync-runtime-badge--neutral {
opacity: 0.75;
}
.sync-you-badge {
display: inline-block;
margin-left: 6px;
padding: 2px 7px;
border-radius: 99px;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.03em;
background: var(--green-dim);
color: var(--green);
}
.sync-linked-toolbar {
padding: 0 16px 12px 16px;
display: flex;
flex-direction: column;
gap: 6px;
}
.sync-linked-filter-label {
font-size: 12px;
font-weight: 500;
color: var(--text3);
}
.sync-linked-filter-input {
max-width: 320px;
}
.sync-linked-filter-empty {
font-style: italic;
color: var(--text4);
}
.sync-linked-table tbody tr.sync-linked-row--current td:first-child {
border-left: 3px solid var(--green);
padding-left: calc(12px - 3px);
}
.sync-linked-row--meta td {
padding-top: 10px;
font-size: 12px;
color: var(--text3);
}
.sync-table-meta-label {
margin-right: 6px;
}
.sync-table-meta-id {
font-size: 12px;
}
.sync-linked-type-cell {
vertical-align: middle;
white-space: nowrap;
}
.sync-device-name-counter {
font-size: 11px;
font-variant-numeric: tabular-nums;
color: var(--text4);
min-width: 3.5em;
}
/* ── Sync: this device (setup vs named hero) ───────────────────────── */
.sync-device-intro {
+15 -8
View File
@@ -602,20 +602,22 @@
<!-- ── Sync page ─────────────────────────────── -->
<div class="page" id="page-sync">
<div class="card sync-device-card" id="syncDeviceCard" style="margin-bottom:16px;">
<div class="card-body">
<div class="section-heading">This device</div>
<div class="card-body" aria-labelledby="syncDeviceSectionHeading">
<div class="section-heading" id="syncDeviceSectionHeading">This device</div>
<p id="syncDeviceIntro" class="sync-device-intro">Custom name is shown to other linked devices and replaces your computer host name when set. Stored only on this machine; the chosen name still syncs in the group like before.</p>
<div id="syncDeviceHero" class="sync-device-hero" hidden>
<div class="sync-device-hero__main">
<div id="syncDeviceNameDisplay" class="sync-device-hero-name"></div>
<p class="sync-device-hero-line">This is how this device appears in your sync group.</p>
<p id="syncDeviceHeroLine" class="sync-device-hero-line">This is how this device appears in your sync group.</p>
</div>
<span class="sync-runtime-badge" id="syncRuntimeBadgeHero" title="How this app runs"></span>
</div>
<div id="syncDeviceEditWrap" class="sync-device-edit-wrap">
<div id="syncDeviceEditWrap" class="sync-device-edit-wrap" aria-label="Edit device display name">
<div class="sync-device-edit-row">
<input type="text" id="syncDeviceNameInput" class="input sync-device-input" placeholder="Use computer host name if empty" maxlength="64" autocomplete="off" />
<span id="syncDeviceNameCounter" class="sync-device-name-counter" aria-live="polite">0 / 64</span>
<button type="button" class="btn btn-primary" id="btnSaveDeviceName">Save name</button>
<button type="button" class="btn btn-secondary" id="btnClearDeviceName" hidden>Clear custom name</button>
<span class="sync-runtime-badge" id="syncRuntimeBadgeEdit" title="How this app runs"></span>
</div>
<p id="syncDeviceNameHint" class="sync-device-hint"></p>
@@ -627,8 +629,8 @@
<div class="card-body">
<div class="section-heading">Device sync</div>
<p style="margin:0 0 14px 0;font-size:13px;color:var(--text3);">Sync your Holesail state (virtual hosts, servers, tunnels, SSH/RDP connections, settings) to another device. CA certificates are not synced; install the root CA on the other device if needed.</p>
<div id="syncStatus" style="display:flex;align-items:center;gap:8px;margin-bottom:14px;">
<div class="status-dot" id="syncStatusDot"></div>
<div id="syncStatus" class="sync-status-live" role="status" aria-live="polite" aria-atomic="true" style="display:flex;align-items:center;gap:8px;margin-bottom:14px;">
<div class="status-dot" id="syncStatusDot" aria-hidden="true"></div>
<span id="syncStatusText">Checking…</span>
<span id="syncLastSynced" style="font-size:11px;color:var(--text4);"></span>
</div>
@@ -650,15 +652,20 @@
<div class="card" id="syncLinkedDevicesCard" style="display:none;margin-bottom:16px;">
<div class="card-body flush">
<div class="section-heading" style="padding:0 16px 10px 16px;">Linked devices</div>
<table class="table">
<div class="sync-linked-toolbar">
<label class="sync-linked-filter-label" for="syncLinkedDevicesFilter">Filter devices</label>
<input type="search" id="syncLinkedDevicesFilter" class="input sync-linked-filter-input" placeholder="Search by name, ID, or type" autocomplete="off" />
</div>
<table class="table sync-linked-table">
<thead>
<tr>
<th>Device</th>
<th>ID</th>
<th>Type</th>
</tr>
</thead>
<tbody id="syncLinkedDevicesTable">
<tr><td colspan="2" class="empty-cell">Not linked</td></tr>
<tr><td colspan="3" class="empty-cell">Not linked</td></tr>
</tbody>
</table>
<div style="padding:12px 16px;border-top:1px solid var(--border);display:flex;gap:10px;flex-wrap:wrap;">
+182 -43
View File
@@ -16,6 +16,11 @@ function _closeSyncDeviceNameEdit() { _syncDeviceNameEditOpen = false; }
/** Last successful getSyncStatus payload (for instant layout when opening "Change name"). */
let _lastSyncStatusForDevicePanel = null;
function getDisplayNameMaxLen(response) {
const n = response && typeof response.displayNameMaxLength === 'number' ? response.displayNameMaxLength : 64;
return n > 0 ? n : 64;
}
function syncDevicePanelIsNamed(response) {
return (response.localDisplayName || '').trim().length > 0;
}
@@ -34,10 +39,115 @@ function applySyncRuntimeBadges(runtimeKind) {
}
}
function syncRuntimeBadgeHtml(runtime) {
if (runtime === 'desktop') return ' <span class="sync-runtime-badge sync-runtime-badge--desktop">Desktop app</span>';
if (runtime === 'browser-extension') return ' <span class="sync-runtime-badge sync-runtime-badge--browser">Browser extension</span>';
return '';
function syncRuntimeBadgeCellHtml(runtime) {
if (runtime === 'desktop') {
return '<span class="sync-runtime-badge sync-runtime-badge--desktop">Desktop app</span>';
}
if (runtime === 'browser-extension') {
return '<span class="sync-runtime-badge sync-runtime-badge--browser">Browser extension</span>';
}
return '<span class="sync-runtime-badge sync-runtime-badge--neutral">—</span>';
}
function sortLinkedDevices(devices) {
const arr = devices.slice();
arr.sort((a, b) => {
if (!!a.isCurrent !== !!b.isCurrent) return a.isCurrent ? -1 : 1;
if (!!a.isMaster !== !!b.isMaster) return a.isMaster ? -1 : 1;
const na = (a.name || a.id || '').toString();
const nb = (b.name || b.id || '').toString();
const c = na.localeCompare(nb);
if (c !== 0) return c;
return (a.id || '').localeCompare(b.id || '');
});
return arr;
}
const LINKED_DEVICES_FILTER_DEBOUNCE_MS = 250;
let _linkedDevicesFilterDebounceTimer = null;
/** Last successful getSyncStatus payload when linked (for debounced filter re-renders). */
let _lastLinkedDevicesContext = null;
function getLinkedDeviceFilterQuery() {
const el = $('syncLinkedDevicesFilter');
return el && typeof el.value === 'string' ? el.value : '';
}
function computeLinkedDeviceLabel(d, response, index) {
return d.name || (d.isCurrent ? (response.deviceName || 'This device') : ('Device ' + (index + 1)));
}
function linkedDeviceMatchesFilter(d, response, queryLower, index) {
if (!queryLower) return true;
const id = (d.id || '').toLowerCase();
const label = computeLinkedDeviceLabel(d, response, index).toLowerCase();
const rt = d.runtime === 'desktop' ? 'desktop app' : d.runtime === 'browser-extension' ? 'browser extension' : '';
return label.includes(queryLower) || id.includes(queryLower) || rt.includes(queryLower);
}
function buildLinkedDevicesTableBodyHtml(response) {
const q = getLinkedDeviceFilterQuery().trim().toLowerCase();
const syncGroupId = response.syncGroupId || '—';
const rawDevices = Array.isArray(response.linkedDevices) ? response.linkedDevices : [];
let rows = '';
if (rawDevices.length > 0) {
const sorted = sortLinkedDevices(rawDevices);
const withIndex = sorted.map((d, i) => ({ d, i }));
const filtered = q ? withIndex.filter(({ d, i }) => linkedDeviceMatchesFilter(d, response, q, i)) : withIndex;
if (filtered.length === 0 && q) {
rows = '<tr><td colspan="3" class="empty-cell sync-linked-filter-empty">No devices match your search</td></tr>';
} else {
filtered.forEach(({ d, i }) => {
const label = computeLinkedDeviceLabel(d, response, i);
const masterBadge = d.isMaster ? ' <span class="sync-master-badge">MASTER</span>' : '';
const youBadge = d.isCurrent ? ' <span class="sync-you-badge">This device</span>' : '';
const rowClass = d.isCurrent ? ' class="sync-linked-row sync-linked-row--current"' : ' class="sync-linked-row"';
const typeCell = syncRuntimeBadgeCellHtml(d.runtime);
rows += '<tr' + rowClass + '><td>' + escapeHtml(label) + masterBadge + youBadge + '</td><td class="mono" style="font-size:12px;">' + escapeHtml(d.id || '—') + '</td><td class="sync-linked-type-cell">' + typeCell + '</td></tr>';
});
}
} else {
const d = {
id: response.deviceId,
name: response.deviceName,
isCurrent: true,
isMaster: response.isMaster,
runtime: response.runtimeKind
};
if (q && !linkedDeviceMatchesFilter(d, response, q, 0)) {
rows = '<tr><td colspan="3" class="empty-cell sync-linked-filter-empty">No devices match your search</td></tr>';
} else {
const label = computeLinkedDeviceLabel(d, response, 0);
const masterBadge = d.isMaster ? ' <span class="sync-master-badge">MASTER</span>' : '';
const youBadge = ' <span class="sync-you-badge">This device</span>';
const rt = syncRuntimeBadgeCellHtml(response.runtimeKind);
rows = '<tr class="sync-linked-row sync-linked-row--current"><td>' + escapeHtml(label) + masterBadge + youBadge + '</td><td class="mono" style="font-size:12px;">' + escapeHtml(response.deviceId || '—') + '</td><td class="sync-linked-type-cell">' + rt + '</td></tr>';
}
}
rows += '<tr class="sync-linked-row sync-linked-row--meta"><td colspan="3"><span class="sync-table-meta-label">Sync group</span> <span class="mono sync-table-meta-id">' + escapeHtml(syncGroupId) + '</span></td></tr>';
return rows;
}
function renderLinkedDevicesTableBody() {
const tbody = $('syncLinkedDevicesTable');
if (!tbody || !_lastLinkedDevicesContext) return;
tbody.innerHTML = buildLinkedDevicesTableBodyHtml(_lastLinkedDevicesContext);
}
function scheduleLinkedDevicesFilterRender() {
if (_linkedDevicesFilterDebounceTimer) clearTimeout(_linkedDevicesFilterDebounceTimer);
_linkedDevicesFilterDebounceTimer = setTimeout(() => {
_linkedDevicesFilterDebounceTimer = null;
renderLinkedDevicesTableBody();
}, LINKED_DEVICES_FILTER_DEBOUNCE_MS);
}
function updateSyncDeviceNameCounter() {
const input = $('syncDeviceNameInput');
const el = $('syncDeviceNameCounter');
if (!input || !el) return;
const max = parseInt(input.getAttribute('maxlength'), 10) || 64;
el.textContent = input.value.length + ' / ' + max;
}
function applySyncDeviceNamePanel(response) {
@@ -51,14 +161,21 @@ function applySyncDeviceNamePanel(response) {
const intro = $('syncDeviceIntro');
const display = $('syncDeviceNameDisplay');
const btnChange = $('btnChangeDeviceName');
const btnClear = $('btnClearDeviceName');
const card = $('syncDeviceCard');
const skipDeviceNameApply =
_syncDeviceNameDirty || !!(input && document.activeElement === input);
const maxLen = getDisplayNameMaxLen(response);
if (input) {
input.setAttribute('maxlength', String(maxLen));
}
if (input && response.localDisplayName !== undefined && !skipDeviceNameApply) {
input.value = response.localDisplayName;
}
updateSyncDeviceNameCounter();
if (response.runtimeKind) {
applySyncRuntimeBadges(response.runtimeKind);
@@ -80,6 +197,7 @@ function applySyncDeviceNamePanel(response) {
if (hero) hero.hidden = !showHero;
if (editWrap) editWrap.hidden = !showEdit;
if (btnChange) btnChange.hidden = !isNamed || _syncDeviceNameEditOpen;
if (btnClear) btnClear.hidden = !(showEdit && isNamed);
if (display && typeof response.effectiveDeviceName === 'string' && !skipDeviceNameApply) {
display.textContent = response.effectiveDeviceName || '';
@@ -93,6 +211,38 @@ function applySyncDeviceNamePanel(response) {
if (hint) hint.hidden = !showEdit;
}
function submitDeviceDisplayName() {
const input = $('syncDeviceNameInput');
const name = input && typeof input.value === 'string' ? input.value.trim() : '';
chrome.runtime.sendMessage(
{ target: 'holesail-native', action: 'send', payload: { type: 'setDeviceDisplayName', payload: { displayName: name } } },
(res) => {
if (chrome.runtime.lastError) {
showToast('Failed: ' + (chrome.runtime.lastError.message || 'unknown'), 'error');
return;
}
if (res && res.ok) {
showToast('Device name saved', 'success');
_clearSyncDeviceNameDirty();
_closeSyncDeviceNameEdit();
updateSyncStatus();
} else {
showToast(res && res.error ? res.error : 'Could not save name', 'error');
}
}
);
}
function syncDeviceNameEscapeHandler() {
if (!_syncDeviceNameEditOpen) return;
_closeSyncDeviceNameEdit();
if (_lastSyncStatusForDevicePanel) {
applySyncDeviceNamePanel(_lastSyncStatusForDevicePanel);
} else {
updateSyncStatus();
}
}
/**
* Update the sync status UI from the native host.
*/
@@ -143,25 +293,8 @@ function updateSyncStatus() {
if (btnDelink) btnDelink.style.display = response.isMaster ? 'none' : '';
if (btnDisband) btnDisband.style.display = response.isMaster ? '' : 'none';
if (tbody) {
const syncGroupId = response.syncGroupId || '—';
const linkedDevices = Array.isArray(response.linkedDevices) ? response.linkedDevices : [];
if (linkedDevices.length > 0) linkedDevices.sort((a, b) => (a.id || '').localeCompare(b.id || ''));
let rows = '';
if (linkedDevices.length > 0) {
linkedDevices.forEach((d, i) => {
const label = d.name || (d.isCurrent ? (response.deviceName || 'This device') : ('Device ' + (i + 1)));
const masterBadge = d.isMaster ? ' <span class="sync-master-badge">MASTER</span>' : '';
const rt = syncRuntimeBadgeHtml(d.runtime);
rows += '<tr><td>' + escapeHtml(label) + masterBadge + rt + '</td><td class="mono" style="font-size:12px;">' + escapeHtml(d.id || '—') + '</td></tr>';
});
} else {
const curName = response.deviceName || 'This device';
const masterBadge = response.isMaster ? ' <span class="sync-master-badge">MASTER</span>' : '';
const rt = syncRuntimeBadgeHtml(response.runtimeKind);
rows = '<tr><td>' + escapeHtml(curName) + masterBadge + rt + '</td><td class="mono" style="font-size:12px;">' + escapeHtml(response.deviceId || '—') + '</td></tr>';
}
rows += '<tr><td>Sync group</td><td class="mono" style="font-size:12px;">' + escapeHtml(syncGroupId) + '</td></tr>';
tbody.innerHTML = rows;
_lastLinkedDevicesContext = response;
tbody.innerHTML = buildLinkedDevicesTableBodyHtml(response);
}
} else {
if (dot) dot.style.background = 'var(--text4)';
@@ -172,8 +305,9 @@ function updateSyncStatus() {
if (linkDeviceForm) linkDeviceForm.style.display = 'flex';
const card = $('syncLinkedDevicesCard');
const tbody = $('syncLinkedDevicesTable');
_lastLinkedDevicesContext = null;
if (card) card.style.display = 'none';
if (tbody) tbody.innerHTML = '<tr><td colspan="2" class="empty-cell">Not linked</td></tr>';
if (tbody) tbody.innerHTML = '<tr><td colspan="3" class="empty-cell">Not linked</td></tr>';
}
}
);
@@ -189,7 +323,23 @@ function setupSyncEvents() {
const inviteInput = $('syncInviteInput');
const inviteDisplay = $('syncInviteDisplay');
$('syncDeviceNameInput')?.addEventListener('input', _markSyncDeviceNameDirty);
$('syncLinkedDevicesFilter')?.addEventListener('input', () => {
scheduleLinkedDevicesFilterRender();
});
$('syncDeviceNameInput')?.addEventListener('input', () => {
_markSyncDeviceNameDirty();
updateSyncDeviceNameCounter();
});
$('syncDeviceNameInput')?.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
submitDeviceDisplayName();
} else if (e.key === 'Escape') {
syncDeviceNameEscapeHandler();
}
});
$('btnChangeDeviceName')?.addEventListener('click', () => {
_openSyncDeviceNameEdit();
@@ -200,25 +350,14 @@ function setupSyncEvents() {
});
$('btnSaveDeviceName')?.addEventListener('click', () => {
submitDeviceDisplayName();
});
$('btnClearDeviceName')?.addEventListener('click', () => {
const input = $('syncDeviceNameInput');
const name = input && typeof input.value === 'string' ? input.value.trim() : '';
chrome.runtime.sendMessage(
{ target: 'holesail-native', action: 'send', payload: { type: 'setDeviceDisplayName', payload: { displayName: name } } },
(res) => {
if (chrome.runtime.lastError) {
showToast('Failed: ' + (chrome.runtime.lastError.message || 'unknown'), 'error');
return;
}
if (res && res.ok) {
showToast('Device name saved', 'success');
_clearSyncDeviceNameDirty();
_closeSyncDeviceNameEdit();
updateSyncStatus();
} else {
showToast(res && res.error ? res.error : 'Could not save name', 'error');
}
}
);
if (input) input.value = '';
updateSyncDeviceNameCounter();
submitDeviceDisplayName();
});
btnCreate?.addEventListener('click', () => {
+12 -5
View File
@@ -14,6 +14,8 @@ const IDENTITY_FILENAME = 'autopass-identity.json';
const STATE_FILENAME = 'state.json';
const AUTOPASS_DIRNAME = 'autopass';
const REMOTE_UPDATE_DEBOUNCE_MS = 600;
/** Max length for custom device display name (local file + synced label). */
const DEVICE_DISPLAY_NAME_MAX_LENGTH = 64;
let storageDir = null;
let holesailManager = null;
@@ -72,7 +74,7 @@ function readLocalDeviceDisplayName() {
const raw = fs.readFileSync(p, 'utf8');
const data = JSON.parse(raw);
if (data && typeof data.displayName === 'string') {
return data.displayName.trim().slice(0, 64);
return data.displayName.trim().slice(0, DEVICE_DISPLAY_NAME_MAX_LENGTH);
}
} catch (_) {}
return '';
@@ -81,7 +83,7 @@ function readLocalDeviceDisplayName() {
function writeLocalDeviceDisplayName(name) {
const p = getLocalDeviceDisplayNamePath();
if (!p) return { ok: false, error: 'Storage path not set' };
const trimmed = typeof name === 'string' ? name.trim().slice(0, 64) : '';
const trimmed = typeof name === 'string' ? name.trim().slice(0, DEVICE_DISPLAY_NAME_MAX_LENGTH) : '';
try {
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, JSON.stringify({ displayName: trimmed }, null, 2), 'utf8');
@@ -579,13 +581,16 @@ function readSyncMasterDeviceIdFromState() {
async function getSyncStatus() {
await ensureInitialized();
const effName = getEffectiveDeviceName();
const localDisp = readLocalDeviceDisplayName();
const out = {
linked: !!pass,
invite: currentInvite || null,
lastSyncedAt: lastSyncedAt || null,
localDisplayName: readLocalDeviceDisplayName(),
localDisplayName: localDisp,
runtimeKind: getLocalRuntimeKind(),
effectiveDeviceName: effName
effectiveDeviceName: effName,
displayNameMaxLength: DEVICE_DISPLAY_NAME_MAX_LENGTH,
usesCustomDisplayName: localDisp.length > 0
};
if (pass) {
try {
@@ -916,7 +921,9 @@ function hasSyncIdentity() {
function setDeviceDisplayName(payload) {
if (!storageDir || !holesailManager) return { ok: false, error: 'Storage path or holesail manager not set' };
const raw = payload && typeof payload.displayName === 'string' ? payload.displayName : '';
if (raw.length > 64) return { ok: false, error: 'displayName must be at most 64 characters' };
if (raw.length > DEVICE_DISPLAY_NAME_MAX_LENGTH) {
return { ok: false, error: 'displayName must be at most ' + DEVICE_DISPLAY_NAME_MAX_LENGTH + ' characters' };
}
const w = writeLocalDeviceDisplayName(raw);
if (!w.ok) return w;
if (typeof holesailManager.saveState === 'function') {
+29
View File
@@ -124,6 +124,35 @@ describe('handler registry', () => {
assert.deepStrictEqual(reply.calls[0].settings, settings);
});
test('getSyncStatus handler merges ok with status fields', async () => {
const status = {
linked: false,
displayNameMaxLength: 64,
usesCustomDisplayName: false,
localDisplayName: '',
effectiveDeviceName: 'myhost',
runtimeKind: 'browser-extension'
};
const mockDeps = makeMockDeps({
syncManager: {
getSyncStatus: async () => status,
createSyncInvite: async () => ({}),
pairWithInvite: async () => ({}),
warmupSyncAtStartup: async () => {},
hasSyncIdentity: () => false,
setDeviceDisplayName: () => ({ ok: true })
}
});
const handlers = buildHandlers(mockDeps);
const reply = mockReply();
await handlers.get('getSyncStatus')({}, reply);
assert.strictEqual(reply.calls.length, 1);
assert.strictEqual(reply.calls[0].ok, true);
assert.strictEqual(reply.calls[0].displayNameMaxLength, 64);
assert.strictEqual(reply.calls[0].usesCustomDisplayName, false);
assert.strictEqual(reply.calls[0].effectiveDeviceName, 'myhost');
});
test('getSshConnections handler calls reply with ok and sshConnections', async () => {
const list = [{ id: 'ssh-1', label: 'My Server' }];
const mockDeps = makeMockDeps({