feat(dashboard): debounced filter for linked devices table on Sync page
CI / Build & Test (push) Successful in 4m25s
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:
@@ -1042,6 +1042,64 @@
|
|||||||
border-color: color-mix(in srgb, var(--amber) 35%, transparent);
|
border-color: color-mix(in srgb, var(--amber) 35%, transparent);
|
||||||
color: var(--amber);
|
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: this device (setup vs named hero) ───────────────────────── */
|
||||||
.sync-device-intro {
|
.sync-device-intro {
|
||||||
|
|||||||
@@ -602,20 +602,22 @@
|
|||||||
<!-- ── Sync page ─────────────────────────────── -->
|
<!-- ── Sync page ─────────────────────────────── -->
|
||||||
<div class="page" id="page-sync">
|
<div class="page" id="page-sync">
|
||||||
<div class="card sync-device-card" id="syncDeviceCard" style="margin-bottom:16px;">
|
<div class="card sync-device-card" id="syncDeviceCard" style="margin-bottom:16px;">
|
||||||
<div class="card-body">
|
<div class="card-body" aria-labelledby="syncDeviceSectionHeading">
|
||||||
<div class="section-heading">This device</div>
|
<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>
|
<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 id="syncDeviceHero" class="sync-device-hero" hidden>
|
||||||
<div class="sync-device-hero__main">
|
<div class="sync-device-hero__main">
|
||||||
<div id="syncDeviceNameDisplay" class="sync-device-hero-name"></div>
|
<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>
|
</div>
|
||||||
<span class="sync-runtime-badge" id="syncRuntimeBadgeHero" title="How this app runs">—</span>
|
<span class="sync-runtime-badge" id="syncRuntimeBadgeHero" title="How this app runs">—</span>
|
||||||
</div>
|
</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">
|
<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" />
|
<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-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>
|
<span class="sync-runtime-badge" id="syncRuntimeBadgeEdit" title="How this app runs">—</span>
|
||||||
</div>
|
</div>
|
||||||
<p id="syncDeviceNameHint" class="sync-device-hint"></p>
|
<p id="syncDeviceNameHint" class="sync-device-hint"></p>
|
||||||
@@ -627,8 +629,8 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="section-heading">Device sync</div>
|
<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>
|
<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 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"></div>
|
<div class="status-dot" id="syncStatusDot" aria-hidden="true"></div>
|
||||||
<span id="syncStatusText">Checking…</span>
|
<span id="syncStatusText">Checking…</span>
|
||||||
<span id="syncLastSynced" style="font-size:11px;color:var(--text4);"></span>
|
<span id="syncLastSynced" style="font-size:11px;color:var(--text4);"></span>
|
||||||
</div>
|
</div>
|
||||||
@@ -650,15 +652,20 @@
|
|||||||
<div class="card" id="syncLinkedDevicesCard" style="display:none;margin-bottom:16px;">
|
<div class="card" id="syncLinkedDevicesCard" style="display:none;margin-bottom:16px;">
|
||||||
<div class="card-body flush">
|
<div class="card-body flush">
|
||||||
<div class="section-heading" style="padding:0 16px 10px 16px;">Linked devices</div>
|
<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>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Device</th>
|
<th>Device</th>
|
||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
|
<th>Type</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="syncLinkedDevicesTable">
|
<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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div style="padding:12px 16px;border-top:1px solid var(--border);display:flex;gap:10px;flex-wrap:wrap;">
|
<div style="padding:12px 16px;border-top:1px solid var(--border);display:flex;gap:10px;flex-wrap:wrap;">
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ function _closeSyncDeviceNameEdit() { _syncDeviceNameEditOpen = false; }
|
|||||||
/** Last successful getSyncStatus payload (for instant layout when opening "Change name"). */
|
/** Last successful getSyncStatus payload (for instant layout when opening "Change name"). */
|
||||||
let _lastSyncStatusForDevicePanel = null;
|
let _lastSyncStatusForDevicePanel = null;
|
||||||
|
|
||||||
|
function getDisplayNameMaxLen(response) {
|
||||||
|
const n = response && typeof response.displayNameMaxLength === 'number' ? response.displayNameMaxLength : 64;
|
||||||
|
return n > 0 ? n : 64;
|
||||||
|
}
|
||||||
|
|
||||||
function syncDevicePanelIsNamed(response) {
|
function syncDevicePanelIsNamed(response) {
|
||||||
return (response.localDisplayName || '').trim().length > 0;
|
return (response.localDisplayName || '').trim().length > 0;
|
||||||
}
|
}
|
||||||
@@ -34,10 +39,115 @@ function applySyncRuntimeBadges(runtimeKind) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncRuntimeBadgeHtml(runtime) {
|
function syncRuntimeBadgeCellHtml(runtime) {
|
||||||
if (runtime === 'desktop') return ' <span class="sync-runtime-badge sync-runtime-badge--desktop">Desktop app</span>';
|
if (runtime === 'desktop') {
|
||||||
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--desktop">Desktop app</span>';
|
||||||
return '';
|
}
|
||||||
|
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) {
|
function applySyncDeviceNamePanel(response) {
|
||||||
@@ -51,14 +161,21 @@ function applySyncDeviceNamePanel(response) {
|
|||||||
const intro = $('syncDeviceIntro');
|
const intro = $('syncDeviceIntro');
|
||||||
const display = $('syncDeviceNameDisplay');
|
const display = $('syncDeviceNameDisplay');
|
||||||
const btnChange = $('btnChangeDeviceName');
|
const btnChange = $('btnChangeDeviceName');
|
||||||
|
const btnClear = $('btnClearDeviceName');
|
||||||
const card = $('syncDeviceCard');
|
const card = $('syncDeviceCard');
|
||||||
|
|
||||||
const skipDeviceNameApply =
|
const skipDeviceNameApply =
|
||||||
_syncDeviceNameDirty || !!(input && document.activeElement === input);
|
_syncDeviceNameDirty || !!(input && document.activeElement === input);
|
||||||
|
|
||||||
|
const maxLen = getDisplayNameMaxLen(response);
|
||||||
|
if (input) {
|
||||||
|
input.setAttribute('maxlength', String(maxLen));
|
||||||
|
}
|
||||||
|
|
||||||
if (input && response.localDisplayName !== undefined && !skipDeviceNameApply) {
|
if (input && response.localDisplayName !== undefined && !skipDeviceNameApply) {
|
||||||
input.value = response.localDisplayName;
|
input.value = response.localDisplayName;
|
||||||
}
|
}
|
||||||
|
updateSyncDeviceNameCounter();
|
||||||
|
|
||||||
if (response.runtimeKind) {
|
if (response.runtimeKind) {
|
||||||
applySyncRuntimeBadges(response.runtimeKind);
|
applySyncRuntimeBadges(response.runtimeKind);
|
||||||
@@ -80,6 +197,7 @@ function applySyncDeviceNamePanel(response) {
|
|||||||
if (hero) hero.hidden = !showHero;
|
if (hero) hero.hidden = !showHero;
|
||||||
if (editWrap) editWrap.hidden = !showEdit;
|
if (editWrap) editWrap.hidden = !showEdit;
|
||||||
if (btnChange) btnChange.hidden = !isNamed || _syncDeviceNameEditOpen;
|
if (btnChange) btnChange.hidden = !isNamed || _syncDeviceNameEditOpen;
|
||||||
|
if (btnClear) btnClear.hidden = !(showEdit && isNamed);
|
||||||
|
|
||||||
if (display && typeof response.effectiveDeviceName === 'string' && !skipDeviceNameApply) {
|
if (display && typeof response.effectiveDeviceName === 'string' && !skipDeviceNameApply) {
|
||||||
display.textContent = response.effectiveDeviceName || '';
|
display.textContent = response.effectiveDeviceName || '';
|
||||||
@@ -93,6 +211,38 @@ function applySyncDeviceNamePanel(response) {
|
|||||||
if (hint) hint.hidden = !showEdit;
|
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.
|
* Update the sync status UI from the native host.
|
||||||
*/
|
*/
|
||||||
@@ -143,25 +293,8 @@ function updateSyncStatus() {
|
|||||||
if (btnDelink) btnDelink.style.display = response.isMaster ? 'none' : '';
|
if (btnDelink) btnDelink.style.display = response.isMaster ? 'none' : '';
|
||||||
if (btnDisband) btnDisband.style.display = response.isMaster ? '' : 'none';
|
if (btnDisband) btnDisband.style.display = response.isMaster ? '' : 'none';
|
||||||
if (tbody) {
|
if (tbody) {
|
||||||
const syncGroupId = response.syncGroupId || '—';
|
_lastLinkedDevicesContext = response;
|
||||||
const linkedDevices = Array.isArray(response.linkedDevices) ? response.linkedDevices : [];
|
tbody.innerHTML = buildLinkedDevicesTableBodyHtml(response);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (dot) dot.style.background = 'var(--text4)';
|
if (dot) dot.style.background = 'var(--text4)';
|
||||||
@@ -172,8 +305,9 @@ function updateSyncStatus() {
|
|||||||
if (linkDeviceForm) linkDeviceForm.style.display = 'flex';
|
if (linkDeviceForm) linkDeviceForm.style.display = 'flex';
|
||||||
const card = $('syncLinkedDevicesCard');
|
const card = $('syncLinkedDevicesCard');
|
||||||
const tbody = $('syncLinkedDevicesTable');
|
const tbody = $('syncLinkedDevicesTable');
|
||||||
|
_lastLinkedDevicesContext = null;
|
||||||
if (card) card.style.display = 'none';
|
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 inviteInput = $('syncInviteInput');
|
||||||
const inviteDisplay = $('syncInviteDisplay');
|
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', () => {
|
$('btnChangeDeviceName')?.addEventListener('click', () => {
|
||||||
_openSyncDeviceNameEdit();
|
_openSyncDeviceNameEdit();
|
||||||
@@ -200,25 +350,14 @@ function setupSyncEvents() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
$('btnSaveDeviceName')?.addEventListener('click', () => {
|
$('btnSaveDeviceName')?.addEventListener('click', () => {
|
||||||
|
submitDeviceDisplayName();
|
||||||
|
});
|
||||||
|
|
||||||
|
$('btnClearDeviceName')?.addEventListener('click', () => {
|
||||||
const input = $('syncDeviceNameInput');
|
const input = $('syncDeviceNameInput');
|
||||||
const name = input && typeof input.value === 'string' ? input.value.trim() : '';
|
if (input) input.value = '';
|
||||||
chrome.runtime.sendMessage(
|
updateSyncDeviceNameCounter();
|
||||||
{ target: 'holesail-native', action: 'send', payload: { type: 'setDeviceDisplayName', payload: { displayName: name } } },
|
submitDeviceDisplayName();
|
||||||
(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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
btnCreate?.addEventListener('click', () => {
|
btnCreate?.addEventListener('click', () => {
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ const IDENTITY_FILENAME = 'autopass-identity.json';
|
|||||||
const STATE_FILENAME = 'state.json';
|
const STATE_FILENAME = 'state.json';
|
||||||
const AUTOPASS_DIRNAME = 'autopass';
|
const AUTOPASS_DIRNAME = 'autopass';
|
||||||
const REMOTE_UPDATE_DEBOUNCE_MS = 600;
|
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 storageDir = null;
|
||||||
let holesailManager = null;
|
let holesailManager = null;
|
||||||
@@ -72,7 +74,7 @@ function readLocalDeviceDisplayName() {
|
|||||||
const raw = fs.readFileSync(p, 'utf8');
|
const raw = fs.readFileSync(p, 'utf8');
|
||||||
const data = JSON.parse(raw);
|
const data = JSON.parse(raw);
|
||||||
if (data && typeof data.displayName === 'string') {
|
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 (_) {}
|
} catch (_) {}
|
||||||
return '';
|
return '';
|
||||||
@@ -81,7 +83,7 @@ function readLocalDeviceDisplayName() {
|
|||||||
function writeLocalDeviceDisplayName(name) {
|
function writeLocalDeviceDisplayName(name) {
|
||||||
const p = getLocalDeviceDisplayNamePath();
|
const p = getLocalDeviceDisplayNamePath();
|
||||||
if (!p) return { ok: false, error: 'Storage path not set' };
|
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 {
|
try {
|
||||||
fs.mkdirSync(path.dirname(p), { recursive: true });
|
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||||
fs.writeFileSync(p, JSON.stringify({ displayName: trimmed }, null, 2), 'utf8');
|
fs.writeFileSync(p, JSON.stringify({ displayName: trimmed }, null, 2), 'utf8');
|
||||||
@@ -579,13 +581,16 @@ function readSyncMasterDeviceIdFromState() {
|
|||||||
async function getSyncStatus() {
|
async function getSyncStatus() {
|
||||||
await ensureInitialized();
|
await ensureInitialized();
|
||||||
const effName = getEffectiveDeviceName();
|
const effName = getEffectiveDeviceName();
|
||||||
|
const localDisp = readLocalDeviceDisplayName();
|
||||||
const out = {
|
const out = {
|
||||||
linked: !!pass,
|
linked: !!pass,
|
||||||
invite: currentInvite || null,
|
invite: currentInvite || null,
|
||||||
lastSyncedAt: lastSyncedAt || null,
|
lastSyncedAt: lastSyncedAt || null,
|
||||||
localDisplayName: readLocalDeviceDisplayName(),
|
localDisplayName: localDisp,
|
||||||
runtimeKind: getLocalRuntimeKind(),
|
runtimeKind: getLocalRuntimeKind(),
|
||||||
effectiveDeviceName: effName
|
effectiveDeviceName: effName,
|
||||||
|
displayNameMaxLength: DEVICE_DISPLAY_NAME_MAX_LENGTH,
|
||||||
|
usesCustomDisplayName: localDisp.length > 0
|
||||||
};
|
};
|
||||||
if (pass) {
|
if (pass) {
|
||||||
try {
|
try {
|
||||||
@@ -916,7 +921,9 @@ function hasSyncIdentity() {
|
|||||||
function setDeviceDisplayName(payload) {
|
function setDeviceDisplayName(payload) {
|
||||||
if (!storageDir || !holesailManager) return { ok: false, error: 'Storage path or holesail manager not set' };
|
if (!storageDir || !holesailManager) return { ok: false, error: 'Storage path or holesail manager not set' };
|
||||||
const raw = payload && typeof payload.displayName === 'string' ? payload.displayName : '';
|
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);
|
const w = writeLocalDeviceDisplayName(raw);
|
||||||
if (!w.ok) return w;
|
if (!w.ok) return w;
|
||||||
if (typeof holesailManager.saveState === 'function') {
|
if (typeof holesailManager.saveState === 'function') {
|
||||||
|
|||||||
@@ -124,6 +124,35 @@ describe('handler registry', () => {
|
|||||||
assert.deepStrictEqual(reply.calls[0].settings, settings);
|
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 () => {
|
test('getSshConnections handler calls reply with ok and sshConnections', async () => {
|
||||||
const list = [{ id: 'ssh-1', label: 'My Server' }];
|
const list = [{ id: 'ssh-1', label: 'My Server' }];
|
||||||
const mockDeps = makeMockDeps({
|
const mockDeps = makeMockDeps({
|
||||||
|
|||||||
Reference in New Issue
Block a user