From f9c7cc9c6e6cf49592ce21f8b9d7e0584daf61af Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Wed, 15 Apr 2026 21:18:50 -0700 Subject: [PATCH] feat: add Add Sessions panel JS logic with immediate-commit checkboxes --- muxplex/frontend/app.js | 258 ++++++++++++++++++++++++++++++ muxplex/tests/test_frontend_js.py | 42 +++++ 2 files changed, 300 insertions(+) diff --git a/muxplex/frontend/app.js b/muxplex/frontend/app.js index 73289ad..4cad1de 100644 --- a/muxplex/frontend/app.js +++ b/muxplex/frontend/app.js @@ -1969,6 +1969,260 @@ function _executeKill(name, remoteId) { }); } +// ─── Add Sessions Panel ────────────────────────────────────────────────────────────────────────── + +/** + * Open the Add Sessions panel for the active user view. + * Only available for user views (not "All" or "Hidden"). + */ +function openAddSessionsPanel() { + if (_activeView === 'all' || _activeView === 'hidden') return; + + var panel = $('add-sessions-panel'); + if (!panel) return; + + // Update title + var titleEl = $('add-sessions-title'); + if (titleEl) titleEl.textContent = 'Add Sessions to “' + _activeView + '”'; + + renderAddSessionsList(); + panel.classList.remove('hidden'); + + // Close on backdrop click + var backdrop = $('add-sessions-backdrop'); + if (backdrop) { + backdrop.onclick = closeAddSessionsPanel; + } + + // Close button + var closeBtn = $('add-sessions-close'); + if (closeBtn) { + closeBtn.onclick = closeAddSessionsPanel; + } +} + +/** + * Close the Add Sessions panel. + */ +function closeAddSessionsPanel() { + var panel = $('add-sessions-panel'); + if (panel) panel.classList.add('hidden'); +} + +/** + * Render the session list inside the Add Sessions panel. + * Shows all sessions NOT currently in the active view. + * Hidden sessions are shown dimmed with a "hidden" badge and disclosure text. + * Grouped by device, alphabetical within each group. + * Immediate-commit checkboxes — each change fires a PATCH immediately. + */ +function renderAddSessionsList() { + var listEl = $('add-sessions-list'); + var emptyEl = $('add-sessions-empty'); + if (!listEl) return; + + var views = (_serverSettings && _serverSettings.views) || []; + var hidden = (_serverSettings && _serverSettings.hidden_sessions) || []; + + // Find the active view's session list + var activeViewObj = null; + for (var i = 0; i < views.length; i++) { + if (views[i].name === _activeView) { + activeViewObj = views[i]; + break; + } + } + if (!activeViewObj) { listEl.innerHTML = ''; return; } + var viewSessions = activeViewObj.sessions || []; + + // Get all real sessions (not status entries), excluding those already in the view + var allSessions = (_currentSessions || []).filter(function(s) { + return !s.status; + }); + + var notInView = allSessions.filter(function(s) { + var key = s.sessionKey || s.name; + return viewSessions.indexOf(key) === -1; + }); + + if (notInView.length === 0) { + listEl.innerHTML = ''; + if (emptyEl) emptyEl.style.display = ''; + return; + } + if (emptyEl) emptyEl.style.display = 'none'; + + // Sort: group by deviceName, alphabetical within each group + notInView.sort(function(a, b) { + var da = (_getDeviceDisplayName(a) || '').toLowerCase(); + var db = (_getDeviceDisplayName(b) || '').toLowerCase(); + if (da !== db) return da < db ? -1 : 1; + var na = (a.name || '').toLowerCase(); + var nb = (b.name || '').toLowerCase(); + return na < nb ? -1 : na > nb ? 1 : 0; + }); + + var html = ''; + for (var j = 0; j < notInView.length; j++) { + var s = notInView[j]; + var key = s.sessionKey || s.name; + var isHidden = hidden.indexOf(key) !== -1 || hidden.indexOf(s.name) !== -1; + var escapedName = escapeHtml(s.name || ''); + var deviceName = escapeHtml(_getDeviceDisplayName(s) || ''); + + html += ''; + if (isHidden) { + html += '
This will make it visible again.
'; + } + } + + listEl.innerHTML = html; + + // Delegated change handler for immediate-commit checkboxes + // Each checkbox change fires api('PATCH', '/api/settings') immediately (no batch Done). + listEl.onchange = function(e) { + var cb = e.target.closest('.add-sessions-item__checkbox'); + if (!cb) return; + var sessionKey = cb.dataset.sessionKey; + var isHiddenSession = cb.dataset.isHidden === '1'; + + if (cb.checked) { + var views = (_serverSettings && _serverSettings.views) || []; + var updatedViews = JSON.parse(JSON.stringify(views)); + for (var vi = 0; vi < updatedViews.length; vi++) { + if (updatedViews[vi].name === _activeView) { + var vs = updatedViews[vi].sessions || []; + if (vs.indexOf(sessionKey) === -1) vs.push(sessionKey); + updatedViews[vi].sessions = vs; + break; + } + } + var patch = { views: updatedViews }; + if (isHiddenSession) { + var hiddenList = (_serverSettings && _serverSettings.hidden_sessions) || []; + var hi = hiddenList.indexOf(sessionKey); + if (hi !== -1) { + var updatedHidden = hiddenList.slice(); + updatedHidden.splice(hi, 1); + patch.hidden_sessions = updatedHidden; + } + } + api('PATCH', '/api/settings', patch) + .then(function() { + if (_serverSettings) { + _serverSettings.views = updatedViews; + if (patch.hidden_sessions) _serverSettings.hidden_sessions = patch.hidden_sessions; + } + renderAddSessionsList(); + renderGrid(_currentSessions || []); + }) + .catch(function(err) { + showToast('Couldn’t save — try again'); + if (cb) cb.checked = false; + console.warn('[renderAddSessionsList] PATCH failed:', err); + }); + } else { + // Should not normally happen (unchecking means removing), but handle gracefully + cb.checked = false; + } + }; + + // Show/hide disclosure on hover for hidden items + listEl.onmouseover = function(e) { + var item = e.target.closest('.add-sessions-item--hidden'); + if (item) { + var disc = item.nextElementSibling; + if (disc && disc.classList.contains('add-sessions-item__disclosure')) { + disc.style.display = ''; + } + } + }; + listEl.onmouseout = function(e) { + var item = e.target.closest('.add-sessions-item--hidden'); + if (item) { + var disc = item.nextElementSibling; + if (disc && disc.classList.contains('add-sessions-item__disclosure')) { + disc.style.display = 'none'; + } + } + }; +} + +/** + * Get a human-readable display name for the device a session belongs to. + * Priority: friendly name → hostname → truncated device_id → empty string. + * @param {object} session + * @returns {string} + */ +function _getDeviceDisplayName(session) { + if (!session) return ''; + if (session.device_name) return session.device_name; + if (session.deviceName) return session.deviceName; + if (session.hostname) return session.hostname; + if (session.device_id) return session.device_id.slice(0, 8); + return ''; +} + +/** + * Add a session to the active user view via PATCH. + * If the session is hidden, also unhide it. + * On error: show toast, revert checkbox. + * @param {string} sessionKey + * @param {boolean} unhideFirst + * @param {HTMLInputElement} checkbox + */ +function _addSessionToActiveView(sessionKey, unhideFirst, checkbox) { + var views = (_serverSettings && _serverSettings.views) || []; + var updatedViews = JSON.parse(JSON.stringify(views)); + + // Find active view and add session + for (var i = 0; i < updatedViews.length; i++) { + if (updatedViews[i].name === _activeView) { + var sessions = updatedViews[i].sessions || []; + if (sessions.indexOf(sessionKey) === -1) { + sessions.push(sessionKey); + } + updatedViews[i].sessions = sessions; + break; + } + } + + var patch = { views: updatedViews }; + + if (unhideFirst) { + var hidden = (_serverSettings && _serverSettings.hidden_sessions) || []; + var hiddenIdx = hidden.indexOf(sessionKey); + if (hiddenIdx !== -1) { + var updatedHidden = hidden.slice(); + updatedHidden.splice(hiddenIdx, 1); + patch.hidden_sessions = updatedHidden; + } + } + + api('PATCH', '/api/settings', patch) + .then(function() { + if (_serverSettings) { + _serverSettings.views = updatedViews; + if (patch.hidden_sessions) _serverSettings.hidden_sessions = patch.hidden_sessions; + } + // Re-render the list (session is now in the view, so it disappears from the list) + renderAddSessionsList(); + // Refresh the grid behind the panel + renderGrid(_currentSessions || []); + }) + .catch(function(err) { + showToast('Couldn’t save — try again'); + if (checkbox) checkbox.checked = false; + console.warn('[_addSessionToActiveView] PATCH failed:', err); + }); +} + + // ─── Notification permission ──────────────────────────────────────────────── /** @@ -3758,6 +4012,10 @@ if (typeof module !== 'undefined' && module.exports) { createNewSession, // Kill session killSession, + // Add Sessions panel + openAddSessionsPanel, + closeAddSessionsPanel, + renderAddSessionsList, // Flyout menu openFlyoutMenu, closeFlyoutMenu, diff --git a/muxplex/tests/test_frontend_js.py b/muxplex/tests/test_frontend_js.py index 22921ab..f5dedbd 100644 --- a/muxplex/tests/test_frontend_js.py +++ b/muxplex/tests/test_frontend_js.py @@ -3882,3 +3882,45 @@ def test_do_kill_inline_shows_confirmation_buttons() -> None: assert "Yes" in fn_body and "No" in fn_body, ( "_doKillSessionInline must show 'Kill? [Yes] [No]' inline" ) + + +# ============================================================ +# Add Sessions panel JS logic (task-9) +# ============================================================ + + +def test_open_add_sessions_panel_function_exists() -> None: + """app.js must define an openAddSessionsPanel function.""" + assert "function openAddSessionsPanel" in _JS, ( + "app.js must contain an openAddSessionsPanel function" + ) + + +def test_close_add_sessions_panel_function_exists() -> None: + """app.js must define a closeAddSessionsPanel function.""" + assert "function closeAddSessionsPanel" in _JS, ( + "app.js must contain a closeAddSessionsPanel function" + ) + + +def test_render_add_sessions_list_function_exists() -> None: + """app.js must define a renderAddSessionsList function.""" + assert "function renderAddSessionsList" in _JS, ( + "app.js must contain a renderAddSessionsList function" + ) + + +def test_add_sessions_uses_immediate_commit() -> None: + """renderAddSessionsList must PATCH immediately on checkbox change (no batch Done).""" + fn_body = _JS.split("function renderAddSessionsList")[1].split("\nfunction ")[0] + assert "PATCH" in fn_body or "api(" in fn_body, ( + "renderAddSessionsList must fire PATCH on each checkbox change (immediate commit)" + ) + + +def test_add_sessions_shows_device_name() -> None: + """renderAddSessionsList must show device name next to each session.""" + fn_body = _JS.split("function renderAddSessionsList")[1].split("\nfunction ")[0] + assert "deviceName" in fn_body or "device" in fn_body, ( + "renderAddSessionsList must show device name for disambiguation" + )