// Phase 2b implementation — app.js /** Inline SVG icon strings used throughout the UI. */ var _icons = { statusOk: '', statusWarn: '', statusErr: '', kebab: '', chevronLeft: '', chevronRight: '', chevronUp: '', chevronDown: '', check: '', bell: '', close: '', }; /** * Format a Unix timestamp (seconds) into a relative time string. * @param {number|null|undefined} ts - Unix timestamp in seconds * @returns {string} */ function formatTimestamp(ts) { if (ts == null) return ''; const diff = Math.floor(Date.now() / 1000 - ts); if (diff < 60) return `${diff}s ago`; if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; return `${Math.floor(diff / 3600)}h ago`; } /** * Return the priority label for a session object. * @param {object} session * @returns {'bell'|'idle'} */ function sessionPriority(session) { const bell = session && session.bell; if (bell && bell.unseen_count > 0 && (bell.seen_at === null || bell.last_fired_at > bell.seen_at)) { return 'bell'; } return 'idle'; } /** Priority rank map used by sortByPriority. Lower rank = higher priority. */ const PRIORITY_RANK = { bell: 0, active: 1, idle: 2 }; /** * Sort an array of sessions by priority (ascending rank). * Returns a new array; does not mutate the original. * @param {object[]} sessions * @returns {object[]} */ function sortByPriority(sessions) { return sessions.slice().sort((a, b) => { const rankA = PRIORITY_RANK[sessionPriority(a)] ?? 2; const rankB = PRIORITY_RANK[sessionPriority(b)] ?? 2; return rankA - rankB; }); } /** * Filter an array of sessions by a search query string. * Matches against session.name (case-insensitive substring match). * Returns all sessions when query is empty or null. * @param {object[]} sessions * @param {string|null} query * @returns {object[]} */ function filterByQuery(sessions, query) { if (!query) return sessions; const q = query.toLowerCase(); return sessions.filter((s) => (s.name || '').toLowerCase().includes(q)); } /** * Detect which sessions have transitioned to a new or increased bell/alert state. * Builds a Map of previous session keys (sessionKey || name) to their unseen_count, then returns * the names of next sessions whose bell.unseen_count > 0 AND > the previous count. * @param {object[]} prev - previous sessions array * @param {object[]} next - updated sessions array * @returns {string[]} names of sessions that newly have or increased bell count */ function detectBellTransitions(prev, next) { const prevMap = new Map( (prev || []).map((s) => [s.sessionKey || s.name, (s.bell && s.bell.unseen_count) || 0]), ); return (next || []) .filter((s) => { const unseen = s.bell && s.bell.unseen_count; if (!unseen || unseen <= 0) return false; const key = s.sessionKey || s.name; const prevCount = prevMap.has(key) ? prevMap.get(key) : 0; return unseen > prevCount; }) .map((s) => s.name); } /** * Generate a pseudo-random device ID string. * Format: 'd-' followed by 8 alphanumeric characters. * @returns {string} */ function generateDeviceId() { return 'd-' + Math.random().toString(36).padEnd(10, '0').slice(2, 10); } /** * Build a heartbeat payload object for the current device/view state. * @param {string} device_id - The generated device identifier * @param {string|null} viewing_session - The session currently being viewed, or null * @param {string} view_mode - Current view mode (e.g. 'split', 'full') * @param {number} last_interaction_at - Unix timestamp of last user interaction * @returns {object} */ function buildHeartbeatPayload(device_id, viewing_session, view_mode, last_interaction_at) { const label = typeof navigator !== 'undefined' && navigator.userAgent ? navigator.userAgent.slice(0, 50) : 'unknown'; return { device_id, label, viewing_session, view_mode, last_interaction_at, }; } // ─── Runtime constants ──────────────────────────────────────────────────────── const POLL_MS = 2000; const HEARTBEAT_MS = 5000; const MOBILE_THRESHOLD = 600; // ─── App state ──────────────────────────────────────────────────────────────── let _deviceId = ''; let _currentSessions = []; let _viewingSession = null; let _viewingRemoteId = ''; let _viewMode = 'grid'; let _lastInteractionAt = Date.now() / 1000; let _pollingTimer; let _heartbeatTimer; let _notificationPermission = 'default'; let _pollFailCount = 0; let _previewTimer = null; var _previewSessionName = null; // track by NAME, not DOM element // Flyout menu state let _flyoutMenuEl = null; let _flyoutSubmenuEl = null; let _flyoutSessionKey = null; let _flyoutSessionName = null; let _flyoutRemoteId = null; /** * Data map of menu item definitions keyed by view type. * Each entry is an array of item config objects with: * { label, action, className?, separator? } * The 'user' view type uses a unified Views submenu (no separate Remove item). */ const FLYOUT_MENU_MAP = { 'all': [ { label: 'Add to View\u2026', action: 'add-to-view', className: 'flyout-menu__item--has-submenu' }, { label: 'Hide', action: 'hide' }, { separator: true }, { label: 'Kill Session', action: 'kill', className: 'flyout-menu__item--danger' }, ], 'user': [ { label: 'Add to View\u2026', action: 'add-to-view', className: 'flyout-menu__item--has-submenu' }, { label: 'Hide', action: 'hide' }, { separator: true }, { label: 'Kill Session', action: 'kill', className: 'flyout-menu__item--danger' }, ], 'hidden': [ { label: 'Unhide', action: 'unhide' }, { label: 'Unhide & Add to View\u2026', action: 'unhide-add-to-view', className: 'flyout-menu__item--has-submenu' }, { separator: true }, { label: 'Kill Session', action: 'kill', className: 'flyout-menu__item--danger' }, ], }; /** * Build the flyout menu HTML string based on the active view type. * Uses FLYOUT_MENU_MAP to generate items — no if/else chains. * @returns {string} HTML for the menu items */ function _buildFlyoutMenuItems() { // Determine view type: 'all', 'hidden', or 'user' var viewType = _activeView; if (viewType !== 'all' && viewType !== 'hidden') { viewType = 'user'; } var items = FLYOUT_MENU_MAP[viewType] || FLYOUT_MENU_MAP['all']; var html = ''; for (var i = 0; i < items.length; i++) { var item = items[i]; if (item.separator) { html += ''; continue; } var label = item.label; // Inject view name for "Remove from {viewName}" if (label.indexOf('{viewName}') !== -1) { var displayName = _activeView; if (displayName.length > 20) { displayName = displayName.substring(0, 20) + '\u2026'; } label = label.replace('{viewName}', escapeHtml(displayName)); } var cls = 'flyout-menu__item'; if (item.className) cls += ' ' + item.className; var titleAttr = ''; if (item.action === 'remove-from-view' && _activeView && _activeView.length > 20) { titleAttr = ' title="Remove from ' + escapeHtml(_activeView) + '"'; } html += ''; } return html; } // ─── Settings state ─────────────────────────────────────────────────────────── let _settingsOpen = false; let _serverSettings = null; let _gridViewMode = 'flat'; let _activeFilterDevice = 'all'; let _activeView = 'all'; let _localDeviceId = null; const DISPLAY_DEFAULTS = { fontSize: 14, hoverPreviewDelay: 1500, gridColumns: 'auto', bellSound: false, viewMode: 'auto', showDeviceBadges: true, // show device name labels on tiles/sidebar showHoverPreview: true, // show hover preview popover on tile hover activityIndicator: 'both', // 'none' | 'glow' | 'dot' | 'both' gridViewMode: 'flat', // 'flat' | 'grouped' }; var VIEW_MODES = ['auto', 'fit']; const NEW_SESSION_DEFAULT_TEMPLATE = 'tmux new-session -d -s {name}'; const DELETE_SESSION_DEFAULT_TEMPLATE = 'tmux kill-session -t {name}'; // ─── DOM helpers ────────────────────────────────────────────────────────────── function $(id) { return document.getElementById(id); } function on(el, ev, fn) { if (el) el.addEventListener(ev, fn); } function isMobile() { return window.innerWidth < MOBILE_THRESHOLD; } // ─── Fetch wrapper ──────────────────────────────────────────────────────────── async function api(method, path, body) { const opts = { method, headers: {} }; if (body !== undefined) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body); } const res = await fetch(path, opts); if (!res.ok) { const err = new Error(`HTTP ${res.status}: ${res.statusText}`); err.status = res.status; throw err; } return res; } // ─── Device ID ──────────────────────────────────────────────────────────────── function initDeviceId() { const STORAGE_KEY = 'tmux-web-device-id'; try { let id = localStorage.getItem(STORAGE_KEY); if (!id) { id = generateDeviceId(); try { localStorage.setItem(STORAGE_KEY, id); } catch (_) { /* blocked — ok */ } } _deviceId = id; } catch (_) { // localStorage blocked (Tracking Prevention, private browsing, etc.) // Fall back to a session-only device ID — not persisted but functional if (!_deviceId) _deviceId = generateDeviceId(); } } // ─── Interaction tracking ───────────────────────────────────────────────────── function trackInteraction() { _lastInteractionAt = Math.floor(Date.now() / 1000); } // ─── State restoration ─────────────────────────────────────────────────────── /** * Restore application state from the server on page load. * Calls GET /api/state and, if an active session exists, re-opens it, * skipping only the zoom animation (ttyd is re-spawned to handle service restarts). * Always resolves — errors are logged as warnings so the app can start normally. * @returns {Promise} */ async function restoreState() { try { const res = await api('GET', '/api/state'); const state = await res.json(); if (state.active_view) { _activeView = state.active_view; } if (state.active_session) { await openSession(state.active_session, { skipAnimation: true, remoteId: state.active_remote_id || '', }); } } catch (err) { console.warn('[restoreState] could not restore previous session:', err); } } // ─── Connection status ────────────────────────────────────────────────────────────────────────── /** * Update the #connection-status indicator element. * @param {'ok'|'warn'|'err'} level */ function setConnectionStatus(level) { const el = $('connection-status'); if (el) el.level = level; } // ─── Session polling ───────────────────────────────────────────────────────────────────────────── /** * Fetch sessions from the appropriate endpoint and update the UI. * Uses /api/federation/sessions when multi_device_enabled is true, * /api/sessions otherwise. * Called by startPolling. * @returns {Promise} */ async function pollSessions() { try { var endpoint = (_serverSettings && _serverSettings.multi_device_enabled) ? '/api/federation/sessions' : '/api/sessions'; const res = await api('GET', endpoint); const sessions = await res.json(); const prev = _currentSessions; _currentSessions = sessions; _pollFailCount = 0; setConnectionStatus('ok'); renderGrid(sessions); renderSidebar(sessions, _viewingSession, _viewingRemoteId); handleBellTransitions(prev, sessions); updateSessionPill(sessions); updateFaviconBadge(); updatePageTitle(); } catch (err) { _pollFailCount++; setConnectionStatus(_pollFailCount <= 2 ? 'warn' : 'err'); } } /** * Start the session polling loop. Guards against double-start. * Uses self-scheduling setTimeout so at most one poll is in-flight at a time. * If a poll takes longer than POLL_MS, the next poll starts POLL_MS after it * finishes — never while it is still running. */ function startPolling() { if (_pollingTimer) return; _pollingTimer = true; // sentinel: prevents double-start before first setTimeout fires async function pollLoop() { await pollSessions(); _pollingTimer = setTimeout(pollLoop, POLL_MS); } pollLoop(); } // ─── Grid rendering ────────────────────────────────────────────────────────── /** * Escape HTML special characters to safe entities. * @param {string} str * @returns {string} */ function escapeHtml(str) { return String(str) .replace(/&/g, '&') .replace(//g, '>'); } // --------------------------------------------------------------------------- // ANSI escape → HTML span converter (SGR codes only) // Converts terminal color sequences to tags with inline styles. // --------------------------------------------------------------------------- var ANSI_COLORS = [ '#2e3436','#cc0000','#4e9a06','#c4a000','#3465a4','#75507b','#06989a','#d3d7cf', '#555753','#ef2929','#8ae234','#fce94f','#729fcf','#ad7fa8','#34e2e2','#eeeeec' ]; function ansiToHtml(raw) { if (!raw) return ''; var out = ''; var spans = 0; var i = 0; var len = raw.length; while (i < len) { // Look for ESC [ ... m (SGR sequence) if (raw[i] === '\x1b' && raw[i + 1] === '[') { var j = i + 2; while (j < len && raw[j] !== 'm' && j - i < 20) j++; if (j < len && raw[j] === 'm') { var params = raw.substring(i + 2, j).split(';'); var style = ansiParamsToStyle(params); if (style === 'reset') { // Close all open spans while (spans > 0) { out += ''; spans--; } } else if (style) { out += ''; spans++; } i = j + 1; continue; } } // Escape HTML characters var ch = raw[i]; if (ch === '<') out += '<'; else if (ch === '>') out += '>'; else if (ch === '&') out += '&'; else if (ch === '"') out += '"'; else out += ch; i++; } while (spans > 0) { out += ''; spans--; } return out; } function ansiParamsToStyle(params) { var styles = []; var k = 0; while (k < params.length) { var p = parseInt(params[k], 10) || 0; if (p === 0) return 'reset'; if (p === 1) styles.push('font-weight:bold'); else if (p === 2) styles.push('opacity:0.7'); else if (p === 3) styles.push('font-style:italic'); else if (p === 4) styles.push('text-decoration:underline'); else if (p === 7) styles.push('filter:invert(1)'); else if (p === 9) styles.push('text-decoration:line-through'); else if (p >= 30 && p <= 37) styles.push('color:' + ANSI_COLORS[p - 30]); else if (p === 38 && params[k + 1] === '5') { var c = parseInt(params[k + 2], 10) || 0; styles.push('color:' + ansi256Color(c)); k += 2; } else if (p === 39) styles.push('color:inherit'); else if (p >= 40 && p <= 47) styles.push('background:' + ANSI_COLORS[p - 40]); else if (p === 48 && params[k + 1] === '5') { var c2 = parseInt(params[k + 2], 10) || 0; styles.push('background:' + ansi256Color(c2)); k += 2; } else if (p === 49) styles.push('background:inherit'); else if (p >= 90 && p <= 97) styles.push('color:' + ANSI_COLORS[p - 90 + 8]); else if (p >= 100 && p <= 107) styles.push('background:' + ANSI_COLORS[p - 100 + 8]); k++; } return styles.length ? styles.join(';') : ''; } function ansi256Color(n) { if (n < 16) return ANSI_COLORS[n]; if (n >= 232) { var g = 8 + (n - 232) * 10; return 'rgb(' + g + ',' + g + ',' + g + ')'; } n -= 16; var r = Math.floor(n / 36) * 51; var g2 = Math.floor((n % 36) / 6) * 51; var b = (n % 6) * 51; return 'rgb(' + r + ',' + g2 + ',' + b + ')'; } /** * Build the HTML string for a single session tile. * @param {object} session * @param {number} index * @param {boolean} mobile * @returns {string} */ function buildTileHTML(session, index, mobile) { const priority = sessionPriority(session); const isBell = priority === 'bell'; var ds = getDisplaySettings(); var actIndicator = ds.activityIndicator !== undefined ? ds.activityIndicator : 'both'; let classes = 'session-tile'; // Glow (full border + inner glow): applied when actIndicator is 'glow' or 'both' if (isBell && (actIndicator === 'glow' || actIndicator === 'both')) classes += ' session-tile--bell'; // Edge bar only (left border amber, no glow): applied when actIndicator is 'dot' or 'both' if (isBell && (actIndicator === 'dot' || actIndicator === 'both')) classes += ' session-tile--edge-bell'; if (mobile) classes += ` session-tile--tier-${priority}`; const name = session.name || ''; const escapedName = escapeHtml(name); const timeStr = formatTimestamp(session.last_activity_at || null); // Device badge — shown inside tile-meta, before timestamp with · separator // Shown when multiple sources configured AND session has a device name let badgeHtml = ''; if (_serverSettings && _serverSettings.multi_device_enabled && session.deviceName && ds.showDeviceBadges !== false) { badgeHtml = `${escapeHtml(session.deviceName)}`; } // Last N lines of snapshot — show more in fit mode so tall tiles fill const snapshot = session.snapshot || ''; var _lineCount = (ds.viewMode === 'fit') ? -80 : -20; // Trim trailing blank lines from the FULL snapshot FIRST — sessions with the cursor // near the top (e.g. fresh tunnel/ssh session) have content at rows 1-2 and rows 3-40 // blank. slice(-20) would grab the last 20 rows (all blank); trimming after slice // then removes everything → empty preview. Trim first so slice sees only content rows. var allLines = snapshot.split('\n'); while (allLines.length > 0 && allLines[allLines.length - 1].trim() === '') { allLines.pop(); } const lastLines = allLines.slice(_lineCount).join('\n'); // Use remoteId (null for local sessions, device_id string for remote sessions) so // openSession() can correctly distinguish local vs federation routing. // deviceId is the local device's own UUID for local sessions — using it here would // cause openSession() to route local sessions through /api/federation/{deviceId}/… // which returns 404 because the local device is not a registered remote instance. const remoteIdAttr = session.remoteId != null ? ` data-remote-id="${escapeHtml(String(session.remoteId))}"` : ''; return ( `
` + `
` + `${escapeHtml(name)}` + `${badgeHtml}` + `${escapeHtml(timeStr)}` + `` + `
` + `
${ansiToHtml(lastLines)}
` + `
` ); } /** * Build the HTML string for a single session sidebar card. * @param {object} session * @param {string} currentSession - name of the currently active session * @param {string} currentRemoteId - remoteId of the currently active session * @returns {string} */ function buildSidebarHTML(session, currentSession, currentRemoteId) { const name = session.name || ''; const escapedName = escapeHtml(name); const isActive = name === currentSession && (session.remoteId ?? '') === (currentRemoteId ?? ''); var ds = getDisplaySettings(); var actIndicator = ds.activityIndicator !== undefined ? ds.activityIndicator : 'both'; const unseen = session.bell && session.bell.unseen_count; const isBell = unseen && unseen > 0; let classes = 'sidebar-item'; if (isActive) classes += ' sidebar-item--active'; // Glow (full border + inner glow): applied when actIndicator is 'glow' or 'both' if (isBell && (actIndicator === 'glow' || actIndicator === 'both')) classes += ' sidebar-item--bell'; // Edge bar only (left border amber, no glow): applied when actIndicator is 'dot' or 'both' if (isBell && (actIndicator === 'dot' || actIndicator === 'both')) classes += ' sidebar-item--edge-bell'; // Device badge — shown in header line when multi_device_enabled let badgeHtml = ''; if (_serverSettings && _serverSettings.multi_device_enabled && session.deviceName && ds.showDeviceBadges !== false) { badgeHtml = `${escapeHtml(session.deviceName)}`; } // Last 20 lines of snapshot — trim trailing blanks from the FULL snapshot FIRST, // then slice. Sessions with the cursor near the top have content at rows 1-2 and // rows 3-40 blank; slice(-20) would return only blank rows, then trim-after-slice // removes everything → empty preview. Trim first to keep meaningful content. const snapshot = session.snapshot || ''; var allLines = snapshot.split('\n'); while (allLines.length > 0 && allLines[allLines.length - 1].trim() === '') { allLines.pop(); } const lastLines = allLines.slice(-20).join('\n'); // Use remoteId (null for local sessions, device_id string for remote sessions). // Do NOT use deviceId here: for local sessions deviceId is the local machine's UUID // which is not a registered remote_instance, so routing through federation would 404. var _sidebarEffRemoteId = session.remoteId != null ? String(session.remoteId) : ''; return ( `
` + `` + `` + `
` ); } /** * Build the HTML string for a generic status tile (auth_failed or unreachable). * @param {string} deviceName * @param {string} statusText * @param {string} statusClass * @returns {string} */ function buildStatusTileHTML(deviceName, statusText, statusClass) { return ( '
' + '' + escapeHtml(deviceName || '') + '' + '' + escapeHtml(statusText || '') + '' + '
' ); } // --------------------------------------------------------------------------- // v2 visibility helpers — single source of truth for session filtering. // See docs/plans/2026-05-17-hidden-state-redesign-design.md // --------------------------------------------------------------------------- // Returns true if the session key is in settings.hidden_sessions. function isHidden(key, settings) { var hidden = (settings && settings.hidden_sessions) || []; return hidden.indexOf(key) !== -1; } // Canonical session-list filter. Single source of truth for "what is in this // view right now". See docs/plans/2026-05-17-hidden-state-redesign-design.md. // view: "all" | "hidden" | // options.includeHidden: when true, hidden sessions are NOT filtered out of // "all" or user views. Ignored for "hidden" (always shows only hidden). function filterVisible(sessions, settings, view, options) { options = options || {}; var includeHidden = options.includeHidden === true; var hiddenList = (settings && settings.hidden_sessions) || []; var live = (sessions || []).filter(function (s) { return !s.status; }); function keyOf(s) { return s.sessionKey || s.name; } function isSessionHidden(s) { return hiddenList.indexOf(keyOf(s)) !== -1 || hiddenList.indexOf(s.name) !== -1; } if (view === "hidden") { return live.filter(isSessionHidden); } if (view === "all") { if (includeHidden) return live.slice(); return live.filter(function (s) { return !isSessionHidden(s); }); } var views = (settings && settings.views) || []; var userView = null; for (var i = 0; i < views.length; i++) { if (views[i].name === view) { userView = views[i]; break; } } if (!userView) return []; var members = userView.sessions || []; function inView(s) { return members.indexOf(keyOf(s)) !== -1 || members.indexOf(s.name) !== -1; } if (includeHidden) { return live.filter(inView); } return live.filter(function (s) { return inView(s) && !isSessionHidden(s); }); } // Expose for component window.filterVisible = filterVisible; function visibleCount(sessions, settings, view, options) { return filterVisible(sessions, settings, view, options).length; } /** * Returns sessions filtered by the active view. * * Thin wrapper around filterVisible() — the canonical filter that is the * single source of truth for "what is in this view right now". * See docs/plans/2026-05-17-hidden-state-redesign-design.md. * * @param {object[]} sessions * @returns {object[]} */ function getVisibleSessions(sessions) { return filterVisible(sessions, _serverSettings, _activeView); } // ============================================================================= // Operation layer (Phase 2) // // Two layers: // 1. Pure data ops: _opAddMembership/_opRemoveMembership/_opHide/_opUnhide — // narrow, composable, no side effects beyond their name. Operate on a // local settings object (typically a clone of _serverSettings) so callers // can compose multiple operations before PATCHing. // 2. User-intent ops: hideSessionOp/unhideSessionOp/addSessionToViewOp/ // removeSessionFromViewOp — express what the *user* meant. Some compose // multiple pure ops: // - hideSessionOp = hide + removeFromAllViews (asymmetric: v1 // federation-safe; matches current UX) // - addSessionToViewOp = unhide + addMembership (auto-unhide on add, // explicit composition) // - unhideSessionOp = unhide (orthogonal — does NOT touch membership) // - removeSessionFromViewOp = removeMembership (orthogonal — does NOT // hide) // // The asymmetry between hideSessionOp (which removes from views) and // addSessionToViewOp (which unhides) is intentional. See // docs/plans/2026-05-17-hidden-state-redesign-design.md. // ============================================================================= // --- Pure data ops --- // Add `key` to view's session list if absent. No-op if view doesn't exist. function _opAddMembership(state, viewName, key) { var views = state.views || []; for (var i = 0; i < views.length; i++) { if (views[i].name === viewName) { var sessions = views[i].sessions || []; if (sessions.indexOf(key) === -1) { sessions.push(key); views[i].sessions = sessions; } break; } } } // Remove `key` from view's session list. No-op if view or key absent. function _opRemoveMembership(state, viewName, key) { var views = state.views || []; for (var i = 0; i < views.length; i++) { if (views[i].name === viewName) { var sessions = views[i].sessions || []; var pos = sessions.indexOf(key); if (pos !== -1) { sessions.splice(pos, 1); views[i].sessions = sessions; } break; } } } // Remove `key` from every view's session list. function _opRemoveFromAllViews(state, key) { var views = state.views || []; for (var i = 0; i < views.length; i++) { var sessions = views[i].sessions || []; var pos = sessions.indexOf(key); if (pos !== -1) { sessions.splice(pos, 1); views[i].sessions = sessions; } } } // Append `key` to hidden_sessions if absent. function _opHide(state, key) { if (state.hidden_sessions.indexOf(key) === -1) { state.hidden_sessions.push(key); } } // Remove `key` from hidden_sessions. No-op if absent. function _opUnhide(state, key) { var pos = state.hidden_sessions.indexOf(key); if (pos !== -1) { state.hidden_sessions.splice(pos, 1); } } // Helper: deep-clone the bits we mutate (avoid touching the cached // _serverSettings before the PATCH confirms). function _cloneOpState(settings) { return JSON.parse(JSON.stringify({ hidden_sessions: (settings && settings.hidden_sessions) || [], views: (settings && settings.views) || [] })); } // --- User-intent ops --- // Each returns the patch body for /api/settings. // hideSessionOp: hide(k) + removeFromAllViews(k). // Asymmetric — removes from all views. This is the v1 federation-safe // behaviour; matching the current UX. Returns { hidden_sessions, views }. function hideSessionOp(settings, key) { var state = _cloneOpState(settings); _opHide(state, key); _opRemoveFromAllViews(state, key); return { hidden_sessions: state.hidden_sessions, views: state.views }; } // unhideSessionOp: unhide(k) only. // Orthogonal — does NOT touch view membership. Returns { hidden_sessions }. function unhideSessionOp(settings, key) { var state = _cloneOpState(settings); _opUnhide(state, key); return { hidden_sessions: state.hidden_sessions }; } // addSessionToViewOp: unhide(k) + addMembership(k, viewName). // Auto-unhide on add is explicit composition here, not an invariant // enforced elsewhere. Returns { hidden_sessions, views }. function addSessionToViewOp(settings, viewName, key) { var state = _cloneOpState(settings); _opUnhide(state, key); _opAddMembership(state, viewName, key); return { hidden_sessions: state.hidden_sessions, views: state.views }; } // removeSessionFromViewOp: removeMembership(k, viewName) only. // Orthogonal — does NOT hide the session. Returns { views }. function removeSessionFromViewOp(settings, viewName, key) { var state = _cloneOpState(settings); _opRemoveMembership(state, viewName, key); return { views: state.views }; } /** * Resolve the active view name against the known views list. * * If active_view is "all" or "hidden" it is always valid and returned as-is. * If active_view matches a view name in the views list it is returned as-is. * Otherwise (e.g. the view was deleted while this device was offline) fall back * to "all" so the user always sees sessions rather than an empty/broken state. * * @param {string} activeView - The stored active_view value from state. * @param {object[]} views - The views array from settings (each has a .name field). * @returns {string} Resolved view name — always "all", "hidden", or a known view name. */ function _resolveActiveView(activeView, views) { if (activeView === 'all' || activeView === 'hidden') return activeView; var list = views || []; for (var i = 0; i < list.length; i++) { if (list[i].name === activeView) return activeView; } return 'all'; } /** * Render the session sidebar list. Only renders in fullscreen view. * Shows empty state when no sessions exist. * Binds click handlers on each sidebar-item to switch sessions. * @param {object[]} sessions * @param {string|null} currentSession - name of the currently active session * @param {string} [currentRemoteId] - remoteId of the currently active session */ function renderSidebar(sessions, currentSession, currentRemoteId) { if (_viewMode !== 'fullscreen') return; const list = $('sidebar-list'); if (!list) return; const visible = getVisibleSessions(sessions); var ds = getDisplaySettings(); if (visible.length === 0) { list.innerHTML = ''; return; } // Build a map of existing sidebar-item elements keyed by session key var existingItems = new Map(); list.querySelectorAll('sidebar-item').forEach(function(el) { var key = el.session && (el.session.sessionKey || el.session.name); if (key) existingItems.set(key, el); }); var newKeys = new Set(); var fragment = document.createDocumentFragment(); function _setupSidebarItem(session) { var key = session.sessionKey || session.name; newKeys.add(key); var item = existingItems.get(key); if (!item) { item = document.createElement('sidebar-item'); // sidebar-select navigates; sidebar-options opens the tile-options-btn flyout item.addEventListener('sidebar-select', function(e) { openSession(e.detail.name, { remoteId: e.detail.remoteId }); }); item.addEventListener('sidebar-options', function(e) { openFlyoutMenu(e.detail.name, e.detail.sessionKey, e.detail.remoteId, e.detail.rect); }); } item.session = session; item.active = (session.name === currentSession && (session.remoteId ?? '') === (currentRemoteId ?? '')); item.multiDevice = !!(_serverSettings && _serverSettings.multi_device_enabled); item.showDeviceBadges = ds.showDeviceBadges !== false; item.activityIndicator = ds.activityIndicator || 'both'; return item; } if (_serverSettings && _serverSettings.multi_device_enabled) { // Group sessions by deviceName when multi_device_enabled const groups = new Map(); for (const session of visible) { const deviceName = session.deviceName || 'Unknown'; if (!groups.has(deviceName)) groups.set(deviceName, []); groups.get(deviceName).push(session); } for (const [deviceName, deviceSessions] of groups) { var header = document.createElement('h4'); header.className = 'sidebar-device-header'; header.textContent = deviceName; fragment.appendChild(header); for (var j = 0; j < deviceSessions.length; j++) { fragment.appendChild(_setupSidebarItem(deviceSessions[j])); } } } else { for (var i = 0; i < visible.length; i++) { fragment.appendChild(_setupSidebarItem(visible[i])); } } // Remove stale items existingItems.forEach(function(item, key) { if (!newKeys.has(key)) item.remove(); }); // Clear and rebuild list.innerHTML = ''; list.appendChild(fragment); } const SIDEBAR_NARROW_THRESHOLD = 960; /** * Initialise sidebar open/closed state on page load. * Reads sidebarOpen from _serverSettings cache. * Defaults to open on wide screens (innerWidth >= 960) when no stored value. * Applies sidebar--collapsed class accordingly and persists the initial state. */ function initSidebar() { var stored = _serverSettings ? _serverSettings.sidebarOpen : null; var isOpen; if (stored !== null && stored !== undefined) { isOpen = !!stored; } else { isOpen = window.innerWidth >= SIDEBAR_NARROW_THRESHOLD; // Persist the auto-detected value (fire-and-forget) if (_serverSettings) _serverSettings.sidebarOpen = isOpen; patchServerSetting('sidebarOpen', isOpen); } var sidebar = $('session-sidebar'); if (sidebar) { if (isOpen) { sidebar.classList.remove('sidebar--collapsed'); } else { sidebar.classList.add('sidebar--collapsed'); } } } /** * Toggle the sidebar open/closed state. * Derives current state from DOM class, inverts it, persists to server, * applies sidebar--collapsed class, and updates the collapse button text. * Button shows ‹ when open, › when closed. */ function toggleSidebar() { var sidebar = $('session-sidebar'); if (!sidebar) return; var isOpen = !sidebar.classList.contains('sidebar--collapsed'); isOpen = !isOpen; if (isOpen) { sidebar.classList.remove('sidebar--collapsed'); } else { sidebar.classList.add('sidebar--collapsed'); } if (_serverSettings) _serverSettings.sidebarOpen = isOpen; patchServerSetting('sidebarOpen', isOpen); var collapseBtn = $('sidebar-collapse-btn'); if (collapseBtn) collapseBtn.innerHTML = isOpen ? _icons.chevronLeft : _icons.chevronRight; } /** * Bind a click-away handler on #terminal-container that collapses the sidebar * when the user taps outside of it in overlay mode (window.innerWidth < 960). * Returns early without collapsing if: * - the screen is wide enough that the sidebar is not in overlay mode (>= 960px) * - the sidebar element is missing * - the sidebar is already collapsed */ function bindSidebarClickAway() { var container = $('terminal-container'); if (!container) return; container.addEventListener('click', function() { if (window.innerWidth >= SIDEBAR_NARROW_THRESHOLD) return; var sidebar = $('session-sidebar'); if (!sidebar) return; if (sidebar.classList.contains('sidebar--collapsed')) return; sidebar.classList.add('sidebar--collapsed'); if (_serverSettings) _serverSettings.sidebarOpen = false; patchServerSetting('sidebarOpen', false); }); } /** * Render the session grid. Shows empty state when no sessions exist. * On mobile, sorts sessions by priority before rendering. * Binds click and keydown handlers on each tile. * @param {object[]} sessions */ // renderGroupedGrid and renderFilterBar removed — logic moved into component. // No-op stubs are exported at the bottom of the file for test compatibility. // --------------------------------------------------------------------------- // View dropdown — render, open/close, view switching // --------------------------------------------------------------------------- /** * Update component properties for the header dropdown. * Called on open and after a view switch. */ function renderViewDropdown() { var dropdown = $('view-dropdown'); if (!dropdown) return; dropdown.views = (_serverSettings && _serverSettings.views) || []; dropdown.activeView = _activeView; dropdown.sessions = _currentSessions || []; dropdown.settings = _serverSettings; } /** * Toggle the header view dropdown open/closed. */ function toggleViewDropdown() { var dropdown = $('view-dropdown'); if (!dropdown) return; dropdown.open = !dropdown.open; if (dropdown.open) renderViewDropdown(); } /** * Close the header view dropdown. */ function closeViewDropdown() { var dropdown = $('view-dropdown'); if (dropdown) dropdown.open = false; } /** * Update component properties for the sidebar dropdown. */ function renderSidebarViewDropdown() { var dropdown = $('sidebar-view-dropdown'); if (!dropdown) return; dropdown.views = (_serverSettings && _serverSettings.views) || []; dropdown.activeView = _activeView; dropdown.sessions = _currentSessions || []; dropdown.settings = _serverSettings; } /** * Toggle the sidebar view dropdown open/closed. */ function toggleSidebarViewDropdown() { var dropdown = $('sidebar-view-dropdown'); if (!dropdown) return; dropdown.open = !dropdown.open; if (dropdown.open) renderSidebarViewDropdown(); } /** * Show an inline text input inside the view dropdown for creating a new view. * Replaces the '+ New View' button with a text input inside the dropdown menu. * - Removes any existing input and re-focuses it if already present. * - On Enter: validates name (not empty, not reserved, not duplicate), * then PATCHes /api/settings with the new view appended to views, * updates _serverSettings.views on success, and calls switchView(name). * - On Escape: closes the dropdown. * - On blur: closes the dropdown after 150ms if input is no longer focused. */ function showNewViewInput() { var dropdown = $('view-dropdown'); var menu = dropdown && dropdown.querySelector('.view-dropdown__menu'); if (!menu) return; // Re-focus existing input instead of creating a duplicate var existing = menu.querySelector('.view-dropdown__new-input'); if (existing) { existing.focus(); return; } // Find the '+ New View' button to replace var newViewBtn = menu.querySelector('[data-action="new-view"]'); if (!newViewBtn) return; // Create the inline text input var input = document.createElement('input'); input.type = 'text'; input.className = 'view-dropdown__new-input'; input.placeholder = 'View name'; input.maxLength = 30; input.setAttribute('aria-label', 'New view name'); // Replace the '+ New View' button with the input newViewBtn.parentNode.replaceChild(input, newViewBtn); input.focus(); input.addEventListener('keydown', function(e) { if (e.key === 'Enter') { var name = input.value.trim(); // Validate: not empty if (!name) return; // Validate: not reserved (case-insensitive) if (name.toLowerCase() === 'all' || name.toLowerCase() === 'hidden') { showToast('Cannot use reserved name \'' + name + '\''); return; } // Validate: not duplicate var views = (_serverSettings && _serverSettings.views) || []; if (views.find(function(v) { return v.name === name; })) { showToast('View \'' + name + '\' already exists'); return; } // Create view and PATCH /api/settings var updatedViews = views.concat([{ name: name, sessions: [] }]); api('PATCH', '/api/settings', { views: updatedViews }) .then(function() { if (_serverSettings) _serverSettings.views = updatedViews; switchView(name); openManageViewPanel(); }) .catch(function() { showToast('Failed to create view'); }); } else if (e.key === 'Escape') { closeViewDropdown(); } }); input.addEventListener('blur', function() { setTimeout(function() { if (document.activeElement !== input) { closeViewDropdown(); } }, 150); }); } /** * Show an inline text input inside the SIDEBAR view dropdown for creating a new view. * Targets #sidebar-view-dropdown-menu instead of #view-dropdown-menu. * - On Enter: validates, PATCHes /api/settings, calls switchView + openManageViewPanel. * - On Escape / blur: closes the sidebar dropdown. */ function showSidebarNewViewInput() { var dropdown = $('sidebar-view-dropdown'); var menu = dropdown && dropdown.querySelector('.view-dropdown__menu'); if (!menu) return; // Re-focus existing input instead of creating a duplicate var existing = menu.querySelector('.view-dropdown__new-input'); if (existing) { existing.focus(); return; } // Find the '+ New View' button to replace var newViewBtn = menu.querySelector('[data-action="new-view"]'); if (!newViewBtn) return; // Create the inline text input var input = document.createElement('input'); input.type = 'text'; input.className = 'view-dropdown__new-input'; input.placeholder = 'View name'; input.maxLength = 30; input.setAttribute('aria-label', 'New view name'); // Replace the '+ New View' button with the input newViewBtn.parentNode.replaceChild(input, newViewBtn); input.focus(); function closeSidebarDropdown() { menu.classList.add('hidden'); var trigger = $('sidebar-view-dropdown-trigger'); if (trigger) trigger.setAttribute('aria-expanded', 'false'); } input.addEventListener('keydown', function(e) { if (e.key === 'Enter') { var name = input.value.trim(); // Validate: not empty if (!name) return; // Validate: not reserved (case-insensitive) if (name.toLowerCase() === 'all' || name.toLowerCase() === 'hidden') { showToast('Cannot use reserved name \'' + name + '\''); return; } // Validate: not duplicate var views = (_serverSettings && _serverSettings.views) || []; if (views.find(function(v) { return v.name === name; })) { showToast('View \'' + name + '\' already exists'); return; } // Create view and PATCH /api/settings var updatedViews = views.concat([{ name: name, sessions: [] }]); api('PATCH', '/api/settings', { views: updatedViews }) .then(function() { if (_serverSettings) _serverSettings.views = updatedViews; closeSidebarDropdown(); switchView(name); openManageViewPanel(); }) .catch(function() { showToast('Failed to create view'); }); } else if (e.key === 'Escape') { closeSidebarDropdown(); } }); input.addEventListener('blur', function() { setTimeout(function() { if (document.activeElement !== input) { closeSidebarDropdown(); } }, 150); }); } /** * Save updated views array via PATCH /api/settings, update _serverSettings, * re-render the views settings tab, and re-render the view dropdown. * @param {Array} updatedViews - New views array to save. */ function _saveViewsAndRerender(updatedViews) { return api('PATCH', '/api/settings', { views: updatedViews }) .then(function() { if (_serverSettings) _serverSettings.views = updatedViews; renderViewsSettingsTab(); renderViewDropdown(); }) .catch(function() { showToast('Failed to save views'); }); } /** * Render the Views settings tab content. * Reads views from _serverSettings and builds an interactive list * with inline rename, up/down reorder, and delete with confirmation. */ function renderViewsSettingsTab() { var listEl = $('views-settings-list'); var emptyEl = $('views-settings-empty'); if (!listEl) return; var views = (_serverSettings && _serverSettings.views) || []; if (views.length === 0) { listEl.innerHTML = ''; if (emptyEl) emptyEl.style.display = ''; return; } if (emptyEl) emptyEl.style.display = 'none'; // Build the list of view rows (no inline rename — rename is in Manage View panel) listEl.innerHTML = ''; views.forEach(function(view, idx) { var sessionCount = visibleCount(_currentSessions, _serverSettings, view.name); var inclHidden = visibleCount(_currentSessions, _serverSettings, view.name, { includeHidden: true }); var hiddenInViewCount = inclHidden - sessionCount; var row = document.createElement('div'); row.className = 'views-settings-row'; row.setAttribute('data-view-idx', String(idx)); // Name span (not clickable for rename — rename is in Manage View panel) var nameSpan = document.createElement('span'); nameSpan.className = 'views-settings-name'; nameSpan.textContent = view.name; // Session count — show "(M hidden)" suffix when M > 0 var countSpan = document.createElement('span'); countSpan.className = 'views-settings-count'; countSpan.textContent = sessionCount + (sessionCount === 1 ? ' session' : ' sessions') + (hiddenInViewCount > 0 ? ' (' + hiddenInViewCount + ' hidden)' : ''); // Actions container var actionsDiv = document.createElement('div'); actionsDiv.className = 'views-settings-actions'; // Up button var upBtn = document.createElement('button'); upBtn.className = 'views-settings-btn'; upBtn.innerHTML = _icons.chevronUp; upBtn.title = 'Move up'; upBtn.setAttribute('data-action', 'move-up'); upBtn.setAttribute('data-idx', String(idx)); if (idx === 0) upBtn.disabled = true; // Down button var downBtn = document.createElement('button'); downBtn.className = 'views-settings-btn'; downBtn.innerHTML = _icons.chevronDown; downBtn.title = 'Move down'; downBtn.setAttribute('data-action', 'move-down'); downBtn.setAttribute('data-idx', String(idx)); if (idx === views.length - 1) downBtn.disabled = true; // Manage button (opens Manage View panel — close settings first) var manageBtn = document.createElement('button'); manageBtn.className = 'views-settings-btn views-settings-btn--manage'; manageBtn.textContent = 'Manage'; manageBtn.setAttribute('data-action', 'manage'); manageBtn.setAttribute('data-idx', String(idx)); // Delete button var deleteBtn = document.createElement('button'); deleteBtn.className = 'views-settings-btn views-settings-btn--danger'; deleteBtn.textContent = 'Delete'; deleteBtn.setAttribute('data-action', 'delete'); deleteBtn.setAttribute('data-idx', String(idx)); actionsDiv.appendChild(upBtn); actionsDiv.appendChild(downBtn); actionsDiv.appendChild(manageBtn); actionsDiv.appendChild(deleteBtn); row.appendChild(nameSpan); row.appendChild(countSpan); row.appendChild(actionsDiv); listEl.appendChild(row); }); // Add "+ New View" button at the bottom var newViewRow = document.createElement('div'); newViewRow.className = 'views-settings-new-row'; var newViewBtn = document.createElement('button'); newViewBtn.className = 'views-settings-btn views-settings-btn--new'; newViewBtn.textContent = '+ New View'; newViewBtn.setAttribute('data-action', 'new-view-in-settings'); newViewRow.appendChild(newViewBtn); listEl.appendChild(newViewRow); // Delegated click handler on the list listEl.onclick = function(e) { var views = (_serverSettings && _serverSettings.views) || []; var target = e.target; // Move up if (target.getAttribute('data-action') === 'move-up') { var idx = parseInt(target.getAttribute('data-idx'), 10); if (idx > 0) { var updated = views.slice(); var tmp = updated[idx - 1]; updated[idx - 1] = updated[idx]; updated[idx] = tmp; _saveViewsAndRerender(updated); } return; } // Move down if (target.getAttribute('data-action') === 'move-down') { var idx = parseInt(target.getAttribute('data-idx'), 10); if (idx < views.length - 1) { var updated = views.slice(); var tmp = updated[idx + 1]; updated[idx + 1] = updated[idx]; updated[idx] = tmp; _saveViewsAndRerender(updated); } return; } // Manage: close settings, switch to that view, open Manage View panel if (target.getAttribute('data-action') === 'manage') { var idx = parseInt(target.getAttribute('data-idx'), 10); var viewName = views[idx] && views[idx].name; if (!viewName) return; closeSettings(); switchView(viewName); openManageViewPanel(); return; } // Delete: show inline confirm if (target.getAttribute('data-action') === 'delete') { var idx = parseInt(target.getAttribute('data-idx'), 10); var row = listEl.querySelector('[data-view-idx="' + idx + '"]'); if (!row) return; // Replace delete button with "Sure? [Yes] [No]" var actionsDiv = row.querySelector('.views-settings-actions'); if (!actionsDiv) return; actionsDiv.innerHTML = ''; var confirmSpan = document.createElement('span'); confirmSpan.className = 'views-settings-confirm'; confirmSpan.textContent = 'Sure? '; var yesBtn = document.createElement('button'); yesBtn.className = 'views-settings-btn views-settings-btn--danger'; yesBtn.textContent = 'Yes'; yesBtn.setAttribute('data-action', 'confirm-delete'); yesBtn.setAttribute('data-idx', String(idx)); var noBtn = document.createElement('button'); noBtn.className = 'views-settings-btn'; noBtn.textContent = 'No'; noBtn.setAttribute('data-action', 'cancel-delete'); confirmSpan.appendChild(yesBtn); confirmSpan.appendChild(document.createTextNode(' ')); confirmSpan.appendChild(noBtn); actionsDiv.appendChild(confirmSpan); return; } // Confirm delete if (target.getAttribute('data-action') === 'confirm-delete') { var idx = parseInt(target.getAttribute('data-idx'), 10); var updated = views.slice(); updated.splice(idx, 1); // If deleting the active view, fall back to 'all' if (_activeView === views[idx].name) { _activeView = 'all'; api('PATCH', '/api/state', { active_view: _activeView }).catch(function() {}); } _saveViewsAndRerender(updated); return; } // Cancel delete: re-render if (target.getAttribute('data-action') === 'cancel-delete') { renderViewsSettingsTab(); return; } // + New View: create a new view and open Manage View panel if (target.getAttribute('data-action') === 'new-view-in-settings') { var newName = prompt('View name:'); if (!newName || !newName.trim()) return; newName = newName.trim(); if (newName.toLowerCase() === 'all' || newName.toLowerCase() === 'hidden') { showToast('Cannot use reserved name \'' + newName + '\''); return; } if (views.find(function(v) { return v.name === newName; })) { showToast('View \'' + newName + '\' already exists'); return; } var updatedViews = views.concat([{ name: newName, sessions: [] }]); api('PATCH', '/api/settings', { views: updatedViews }) .then(function() { if (_serverSettings) _serverSettings.views = updatedViews; renderViewsSettingsTab(); renderViewDropdown(); // Close settings and open Manage View panel for the new view closeSettings(); switchView(newName); openManageViewPanel(); }) .catch(function() { showToast('Failed to create view'); }); return; } }; } /** * Switch to a named view. Updates _activeView, re-renders the grid and sidebar, * updates the dropdown label, and persists the change via PATCH /api/state. * @param {string} viewName - 'all', 'hidden', or a user view name. */ function switchView(viewName) { _activeView = viewName; closeViewDropdown(); renderGrid(_currentSessions || []); renderSidebar(_currentSessions || [], _viewingSession, _viewingRemoteId); renderViewDropdown(); renderSidebarViewDropdown(); // Persist active view — fire and forget api('PATCH', '/api/state', { active_view: viewName }).catch(function() {}); } function renderGrid(sessions) { // Close flyout if the targeted session no longer exists if (_flyoutSessionKey) { var flyoutStillExists = (sessions || []).some(function(s) { return (s.sessionKey || s.name) === _flyoutSessionKey; }); if (!flyoutStillExists) { closeFlyoutMenu(); } } var visible = getVisibleSessions(sessions); var emptyState = $('empty-state'); if (visible.length === 0) { // Check if there are status sessions (auth_failed / unreachable) the grid will render var hasStatusSessions = (sessions || []).some(function(s) { return s.status === 'auth_failed' || s.status === 'unreachable'; }); if (emptyState) { if (hasStatusSessions) emptyState.classList.add('hidden'); else emptyState.classList.remove('hidden'); } } else { if (emptyState) emptyState.classList.add('hidden'); } // Set properties on the element — sorting, grouping, and tile // diffing (including alphabetical / localeCompare ordering) are handled inside // the component based on sortOrder and groupMode. var grid = $('session-grid'); if (!grid) return; var ds = getDisplaySettings(); grid.sessions = sessions; grid.visibleSessions = visible; grid.sortOrder = (_serverSettings && _serverSettings.sort_order) || 'manual'; grid.groupMode = _gridViewMode; grid.mobile = isMobile(); grid.multiDevice = !!(_serverSettings && _serverSettings.multi_device_enabled); grid.showDeviceBadges = ds.showDeviceBadges !== false; grid.activityIndicator = ds.activityIndicator || 'both'; grid.viewMode = ds.viewMode || 'auto'; // Bind session-open / session-options event listeners only once if (!grid._eventsbound) { grid._eventsbound = true; grid.addEventListener('session-open', function(e) { openSession(e.detail.name, { remoteId: e.detail.remoteId || undefined }); }); grid.addEventListener('session-options', function(e) { openFlyoutMenu(e.detail.name, e.detail.sessionKey, e.detail.remoteId, e.detail.rect); }); } if (_viewMode === 'fullscreen') { updatePillBell(); } // Reapply view mode layout after grid properties are set var currentMode = ds.viewMode || 'auto'; if (currentMode === 'fit' && grid) { grid.classList.add('session-grid--fit'); applyFitLayout(grid); } } // --------------------------------------------------------------------------- // Hover preview popover (desktop only — no hover on touch devices) // --------------------------------------------------------------------------- // _previewClickHandler removed — preview-click event is handled via component // listener in bindStaticEventListeners(). function showPreview(name) { if (!name || !_currentSessions) return; var ds = getDisplaySettings(); if (ds.showHoverPreview === false) return; var session = _currentSessions.find(function(s) { return s.name === name; }); if (!session || !session.snapshot) return; var preview = $('hover-preview'); if (!preview) return; preview.sessionName = name; preview.snapshot = session.snapshot; preview.open = true; } // hidePreviewDOM: hides the component function hidePreviewDOM() { var preview = $('hover-preview'); if (preview) preview.open = false; } // hidePreview: full cleanup including timer and session name function hidePreview() { if (_previewTimer) { clearTimeout(_previewTimer); _previewTimer = null; } hidePreviewDOM(); _previewSessionName = null; } // ── Tile Flyout Menu ────────────────────────────────────────────────────────── /** * Open the flyout menu for a session tile's ⋮ button. * Creates a floating menu appended to document.body, positioned relative to * the trigger button via getBoundingClientRect. On mobile, renders as a * bottom action sheet instead. * @param {HTMLElement} triggerEl - The .tile-options-btn element that was clicked */ function openFlyoutMenu(nameOrEl, sessionKey, remoteId, rectArg) { closeFlyoutMenu(); // Support both legacy (trigger element) and new (explicit params) call styles var rect; if (nameOrEl instanceof HTMLElement) { var tile = nameOrEl.closest('[data-session-key]'); if (!tile) return; _flyoutSessionKey = tile.dataset.sessionKey || ''; _flyoutSessionName = tile.dataset.session || ''; _flyoutRemoteId = tile.dataset.remoteId || ''; rect = nameOrEl.getBoundingClientRect(); } else { _flyoutSessionName = nameOrEl || ''; _flyoutSessionKey = sessionKey || ''; _flyoutRemoteId = remoteId || ''; rect = rectArg; } if (isMobile()) { _openFlyoutSheet(); return; } // Build menu items based on active view type var menuHtml = _buildFlyoutMenuItems(); var menu = document.createElement('div'); menu.className = 'flyout-menu'; menu.setAttribute('role', 'menu'); menu.setAttribute('aria-label', 'Session options'); menu.innerHTML = menuHtml; document.body.appendChild(menu); _flyoutMenuEl = menu; // Position relative to trigger var menuWidth = menu.offsetWidth; var menuHeight = menu.offsetHeight; // Default: below and to the left of the trigger var top = rect.bottom + 4; var left = rect.right - menuWidth; // Keep within viewport if (left < 8) left = 8; if (top + menuHeight > window.innerHeight - 8) { top = rect.top - menuHeight - 4; } if (top < 8) top = 8; menu.style.top = top + 'px'; menu.style.left = left + 'px'; // Delegated click handler on the flyout menu.addEventListener('click', _handleFlyoutClick); // Close on click-outside (next tick to avoid the opening click) setTimeout(function() { document.addEventListener('click', _flyoutOutsideClickHandler, true); }, 0); } /** * Close the flyout menu and any open submenu. */ function closeFlyoutMenu() { if (_flyoutSubmenuEl) { _flyoutSubmenuEl.remove(); _flyoutSubmenuEl = null; } if (_flyoutMenuEl) { _flyoutMenuEl.removeEventListener('click', _handleFlyoutClick); _flyoutMenuEl.remove(); _flyoutMenuEl = null; } // Remove mobile sheet if open var sheet = document.querySelector('.flyout-sheet'); if (sheet) sheet.remove(); document.removeEventListener('click', _flyoutOutsideClickHandler, true); _flyoutSessionKey = null; _flyoutSessionName = null; _flyoutRemoteId = null; } /** * Open a bottom action sheet for the flyout menu (mobile). * Same actions as the desktop flyout, but renders as a full-width bottom sheet. */ function _openFlyoutSheet() { var viewType = _activeView; if (viewType !== 'all' && viewType !== 'hidden') viewType = 'user'; var items = FLYOUT_MENU_MAP[viewType] || FLYOUT_MENU_MAP['all']; var html = '
'; html += ''; var sheet = document.createElement('div'); sheet.className = 'flyout-sheet'; sheet.setAttribute('role', 'dialog'); sheet.setAttribute('aria-modal', 'true'); sheet.innerHTML = html; document.body.appendChild(sheet); // Backdrop closes var backdrop = sheet.querySelector('.flyout-sheet__backdrop'); if (backdrop) { backdrop.addEventListener('click', closeFlyoutMenu); } // Delegated action handler var panel = sheet.querySelector('.flyout-sheet__panel'); if (panel) { panel.addEventListener('click', function(e) { var btn = e.target.closest('[data-action]'); if (!btn) return; var action = btn.dataset.action; if (action === 'add-to-view' || action === 'unhide-add-to-view') { // On mobile, show a view picker sheet (not the Add Sessions panel which is for the active view) var sessionKey = _flyoutSessionKey; var sessionName = _flyoutSessionName; var unhideFirst = action === 'unhide-add-to-view'; closeFlyoutMenu(); _openMobileViewPicker(sessionKey, sessionName, unhideFirst); } else if (action === 'kill') { // Show a confirmation sheet — consistent with the existing sheet pattern var killName = _flyoutSessionName; var killRemoteId = _flyoutRemoteId; closeFlyoutMenu(); _openMobileKillConfirm(killName, killRemoteId); } else { // Dispatch directly _handleFlyoutClick(e); } }); } } /** * Show a confirmation bottom sheet before killing a session on mobile. * Shows "Kill [sessionName]?" with Kill and Cancel buttons. * @param {string} sessionName * @param {string} remoteId */ function _openMobileKillConfirm(sessionName, remoteId) { var sheet = document.createElement('div'); sheet.className = 'flyout-sheet'; var html = '
'; html += '
'; html += ''; html += '
Kill ' + escapeHtml(sessionName) + '?
'; html += ''; html += ''; html += '
'; sheet.innerHTML = html; document.body.appendChild(sheet); var backdrop = sheet.querySelector('.flyout-sheet__backdrop'); if (backdrop) backdrop.addEventListener('click', function() { sheet.remove(); }); var panel = sheet.querySelector('.flyout-sheet__panel'); if (panel) { panel.addEventListener('click', function(e) { var btn = e.target.closest('[data-action]'); if (!btn) return; sheet.remove(); if (btn.dataset.action === 'confirm-kill') { killSession(sessionName, remoteId); } }); } } /** * Open a bottom sheet listing all user views for the session (mobile view picker). * Same toggle behaviour as the desktop submenu — each tap fires a PATCH immediately. * The sheet has a "Done" button that closes it. * @param {string} sessionKey * @param {string} sessionName * @param {boolean} unhideFirst - If true, also unhide the session on first add */ function _openMobileViewPicker(sessionKey, sessionName, unhideFirst) { var views = (_serverSettings && _serverSettings.views) || []; if (views.length === 0) { showToast('No user views. Create one from the header dropdown.'); return; } var sheet = document.createElement('div'); sheet.className = 'flyout-sheet'; var html = '
'; html += ''; sheet.innerHTML = html; document.body.appendChild(sheet); var backdrop = sheet.querySelector('.flyout-sheet__backdrop'); if (backdrop) backdrop.addEventListener('click', function() { sheet.remove(); }); var panel = sheet.querySelector('.flyout-sheet__panel'); if (panel) { panel.addEventListener('click', function(e) { var btn = e.target.closest('[data-action="done"]'); if (btn) { sheet.remove(); return; } var viewBtn = e.target.closest('[data-view-index]'); if (!viewBtn) return; var idx = parseInt(viewBtn.dataset.viewIndex, 10); var views = (_serverSettings && _serverSettings.views) || []; var view = views[idx]; if (!view) return; var sessions = view.sessions || []; var isAlreadyInView = sessions.indexOf(sessionKey) !== -1; var patch; var nowIn; if (isAlreadyInView) { patch = removeSessionFromViewOp(_serverSettings, view.name, sessionKey); nowIn = false; } else { patch = addSessionToViewOp(_serverSettings, view.name, sessionKey); nowIn = true; } // Update checkmark immediately for responsiveness var checkEl = viewBtn.querySelector('span'); if (checkEl) checkEl.innerHTML = nowIn ? _icons.check : '\u00a0\u00a0'; api('PATCH', '/api/settings', patch) .then(function() { if (_serverSettings) { _serverSettings.views = patch.views; if (patch.hidden_sessions) _serverSettings.hidden_sessions = patch.hidden_sessions; } if (nowIn && patch.hidden_sessions) renderGrid(_currentSessions || []); }) .catch(function(err) { showToast('Couldn\u2019t save \u2014 try again'); // Revert checkmark if (checkEl) checkEl.innerHTML = nowIn ? '\u00a0\u00a0' : _icons.check; console.warn('[_openMobileViewPicker] PATCH failed:', err); }); }); } } /** * Click-outside handler for the flyout menu. * @param {MouseEvent} e */ function _flyoutOutsideClickHandler(e) { if (_flyoutMenuEl && !_flyoutMenuEl.contains(e.target) && (!_flyoutSubmenuEl || !_flyoutSubmenuEl.contains(e.target))) { closeFlyoutMenu(); } } /** * Delegated click handler for the flyout menu. * Dispatches based on data-action attribute. * @param {MouseEvent} e */ function _handleFlyoutClick(e) { var item = e.target.closest('[data-action]'); if (!item) return; var action = item.dataset.action; switch (action) { case 'add-to-view': case 'unhide-add-to-view': _openFlyoutSubmenu(item, action === 'unhide-add-to-view'); break; case 'remove-from-view': _doRemoveFromView(); break; case 'hide': _doHideSession(); break; case 'unhide': _doUnhideSession(); break; case 'kill': _doKillSessionInline(item); break; default: break; } } /** * Open the "Add to View" submenu next to a flyout menu item. * Lists all user-created views with checkmarks for views the session is already in. * Clicking a view toggles membership immediately via PATCH /api/settings. * The flyout stays open after submenu actions. * @param {HTMLElement} triggerItem - The menu item that triggered the submenu * @param {boolean} unhideFirst - If true, also unhide the session (for "Unhide & Add to View") */ function _openFlyoutSubmenu(triggerItem, unhideFirst) { // Close existing submenu if (_flyoutSubmenuEl) { _flyoutSubmenuEl.remove(); _flyoutSubmenuEl = null; } var views = (_serverSettings && _serverSettings.views) || []; var sessionKey = _flyoutSessionKey; // Show ALL user views with checkmarks — unified Views submenu var html = ''; for (var i = 0; i < views.length; i++) { var v = views[i]; var isIn = (v.sessions || []).indexOf(sessionKey) !== -1; html += ''; } // — Always show "+ New View" option at the bottom if (views.length > 0) { html += ''; } html += ''; var submenu = document.createElement('div'); submenu.className = 'flyout-submenu'; submenu.setAttribute('role', 'menu'); submenu.innerHTML = html; document.body.appendChild(submenu); _flyoutSubmenuEl = submenu; // Position to the right of the trigger item (or left if no space) if (_flyoutMenuEl) { var menuRect = _flyoutMenuEl.getBoundingClientRect(); var subWidth = submenu.offsetWidth; var subHeight = submenu.offsetHeight; var itemRect = triggerItem.getBoundingClientRect(); var left = menuRect.right + 4; if (left + subWidth > window.innerWidth - 8) { left = menuRect.left - subWidth - 4; } var top = itemRect.top; if (top + subHeight > window.innerHeight - 8) { top = window.innerHeight - subHeight - 8; } if (top < 8) top = 8; submenu.style.top = top + 'px'; submenu.style.left = left + 'px'; } // Click handler — toggle view membership via PATCH /api/settings submenu.addEventListener('click', function(e) { // Handle '+ New View' action var newViewAction = e.target.closest('[data-action="new-view-in-flyout"]'); if (newViewAction) { var capturedKey = sessionKey; closeFlyoutMenu(); var newName = prompt('View name:'); if (!newName || !newName.trim()) return; newName = newName.trim(); if (newName.toLowerCase() === 'all' || newName.toLowerCase() === 'hidden') { showToast('Cannot use reserved name \'' + newName + '\''); return; } var existViews = (_serverSettings && _serverSettings.views) || []; if (existViews.find(function(v) { return v.name === newName; })) { showToast('View \'' + newName + '\' already exists'); return; } // New-view creation: addSessionToViewOp doesn't model view creation, but // we use it on a temp settings (with the new view already appended) so // that the hidden_sessions update is expressed via the op layer. var newView = { name: newName, sessions: [capturedKey] }; var newViews = existViews.concat([newView]); var tempSettings = { hidden_sessions: (_serverSettings && _serverSettings.hidden_sessions) || [], views: newViews }; var flyoutPatch = addSessionToViewOp(tempSettings, newName, capturedKey); api('PATCH', '/api/settings', flyoutPatch) .then(function() { if (_serverSettings) { _serverSettings.views = flyoutPatch.views; _serverSettings.hidden_sessions = flyoutPatch.hidden_sessions; } switchView(newName); }) .catch(function() { showToast('Failed to create view'); }); return; } var btn = e.target.closest('[data-view-index]'); if (!btn) return; var idx = parseInt(btn.dataset.viewIndex, 10); var views = (_serverSettings && _serverSettings.views) || []; var view = views[idx]; if (!view) return; var sessions = view.sessions || []; var isAlreadyInView = sessions.indexOf(sessionKey) !== -1; var patch; if (isAlreadyInView) { patch = removeSessionFromViewOp(_serverSettings, view.name, sessionKey); } else { patch = addSessionToViewOp(_serverSettings, view.name, sessionKey); } api('PATCH', '/api/settings', patch) .then(function() { if (_serverSettings) { _serverSettings.views = patch.views; if (patch.hidden_sessions) _serverSettings.hidden_sessions = patch.hidden_sessions; } // Update checkmarks in submenu if (_flyoutSubmenuEl) { var checkItems = _flyoutSubmenuEl.querySelectorAll('[data-view-index]'); for (var ci = 0; ci < checkItems.length; ci++) { var vi = parseInt(checkItems[ci].dataset.viewIndex, 10); var checkEl = checkItems[ci].querySelector('.flyout-submenu__check'); var updViews = (_serverSettings && _serverSettings.views) || []; if (checkEl && updViews[vi]) { checkEl.innerHTML = (updViews[vi].sessions || []).indexOf(sessionKey) !== -1 ? _icons.check : ''; } } } if (!isAlreadyInView && patch.hidden_sessions) { renderGrid(_currentSessions || []); } }) .catch(function(err) { showToast('Couldn\u2019t save \u2014 try again'); console.warn('[_openFlyoutSubmenu] PATCH failed:', err); }); }); } /** * Hide a session: add to hidden_sessions and remove from ALL views. * Closes the flyout and re-renders the grid. */ function _doHideSession() { var sessionKey = _flyoutSessionKey; if (!sessionKey) return; var patch = hideSessionOp(_serverSettings, sessionKey); closeFlyoutMenu(); api('PATCH', '/api/settings', patch) .then(function() { if (_serverSettings) { _serverSettings.hidden_sessions = patch.hidden_sessions; _serverSettings.views = patch.views; } renderGrid(_currentSessions || []); renderViewDropdown(); }) .catch(function(err) { showToast('Couldn\u2019t save \u2014 try again'); console.warn('[_doHideSession] PATCH failed:', err); }); } /** * Unhide a session: remove from hidden_sessions. * Closes the flyout and re-renders the grid. */ function _doUnhideSession() { var sessionKey = _flyoutSessionKey; if (!sessionKey) return; // Early-exit if session is not hidden (no PATCH needed). var hidden = (_serverSettings && _serverSettings.hidden_sessions) || []; if (hidden.indexOf(sessionKey) === -1) { closeFlyoutMenu(); return; } var patch = unhideSessionOp(_serverSettings, sessionKey); closeFlyoutMenu(); api('PATCH', '/api/settings', patch) .then(function() { if (_serverSettings) _serverSettings.hidden_sessions = patch.hidden_sessions; renderGrid(_currentSessions || []); renderViewDropdown(); }) .catch(function(err) { showToast('Couldn\u2019t save \u2014 try again'); console.warn('[_doUnhideSession] PATCH failed:', err); }); } /** * Remove a session from the currently active user view. * Closes the flyout and re-renders the grid. */ function _doRemoveFromView() { var sessionKey = _flyoutSessionKey; if (!sessionKey || _activeView === 'all' || _activeView === 'hidden') return; var patch = removeSessionFromViewOp(_serverSettings, _activeView, sessionKey); closeFlyoutMenu(); api('PATCH', '/api/settings', patch) .then(function() { if (_serverSettings) _serverSettings.views = patch.views; renderGrid(_currentSessions || []); }) .catch(function(err) { showToast('Couldn\u2019t save \u2014 try again'); console.warn('[_doRemoveFromView] PATCH failed:', err); }); } /** * Show inline kill confirmation inside the flyout menu. * Replaces the "Kill Session" item with "Kill? [Yes] [No]". * No timeout — stays until click-outside closes the menu. * On error: "Failed" for 2 seconds then reverts. * @param {HTMLElement} killItem - The "Kill Session" menu item element */ function _doKillSessionInline(killItem) { var sessionName = _flyoutSessionName; var remoteId = _flyoutRemoteId; // Replace the kill item with confirmation UI var confirmHtml = '
' + 'Kill?' + '' + '' + '
'; killItem.outerHTML = confirmHtml; // Re-attach handlers on the confirm/cancel buttons if (!_flyoutMenuEl) return; var confirmBtn = _flyoutMenuEl.querySelector('[data-action="confirm-kill"]'); var cancelBtn = _flyoutMenuEl.querySelector('[data-action="cancel-kill"]'); if (confirmBtn) { confirmBtn.addEventListener('click', function(e) { e.stopPropagation(); _executeKill(sessionName, remoteId); }); } if (cancelBtn) { cancelBtn.addEventListener('click', function(e) { e.stopPropagation(); closeFlyoutMenu(); }); } } /** * Execute the kill session API call from the flyout inline confirmation. * On success: closes flyout, shows toast, refreshes sessions. * On error: shows "Failed" for 2s in the confirm area, then reverts. * @param {string} name * @param {string} remoteId */ function _executeKill(name, remoteId) { var endpoint = remoteId ? '/api/federation/' + encodeURIComponent(remoteId) + '/sessions/' + encodeURIComponent(name) : '/api/sessions/' + encodeURIComponent(name); api('DELETE', endpoint) .then(function() { closeFlyoutMenu(); showToast('Session \'' + name + '\' killed'); if (_viewingSession === name && (_viewingRemoteId ?? '') === (remoteId || '')) { closeSession(); } pollSessions(); }) .catch(function(err) { // Show "Failed" for 2 seconds var confirmDiv = _flyoutMenuEl && _flyoutMenuEl.querySelector('.flyout-menu__confirm'); if (confirmDiv) { confirmDiv.innerHTML = 'Failed'; setTimeout(function() { // Revert to original kill button if menu is still open if (_flyoutMenuEl && confirmDiv.parentNode) { confirmDiv.outerHTML = ''; } }, 2000); } }); } // ─── Manage View Panel ────────────────────────────────────────────────────────────────────────── /** * Open the Manage View panel for the active user view. * Only available for user views (not "All" or "Hidden"). * Renders the view name (clickable to rename) and a delete button in the header. */ function openManageViewPanel() { if (_activeView === 'all' || _activeView === 'hidden') return; var panel = $('manage-view-panel'); if (!panel) return; // Rebuild the name-row with view name + delete button var nameRow = panel.querySelector('.manage-view-panel__name-row'); if (nameRow) { nameRow.innerHTML = '

' + escapeHtml(_activeView) + '

' + ''; } renderManageViewList(); panel.classList.remove('hidden'); // Close on backdrop click var backdrop = $('manage-view-backdrop'); if (backdrop) backdrop.onclick = closeManageViewPanel; // Close button at bottom var closeBtn = $('manage-view-close'); if (closeBtn) closeBtn.onclick = closeManageViewPanel; // — Rename click handler on the view name — var nameEl = $('manage-view-name'); if (nameEl) { nameEl.onclick = function() { var currentName = _activeView; // Replace h2 with an inline input for rename var input = document.createElement('input'); input.type = 'text'; input.className = 'manage-view-panel__name-input'; input.value = currentName; input.maxLength = 30; input.setAttribute('aria-label', 'View name'); if (nameEl.parentNode) nameEl.parentNode.replaceChild(input, nameEl); input.focus(); input.select(); var _committed = false; function commitRename() { if (_committed) return; var newName = input.value.trim(); if (!newName || newName === currentName) { revertRename(); return; } if (newName.toLowerCase() === 'all' || newName.toLowerCase() === 'hidden') { showToast('Cannot use reserved name \'' + newName + '\''); input.focus(); return; } var views = (_serverSettings && _serverSettings.views) || []; if (views.find(function(v) { return v.name === newName; })) { showToast('View \'' + newName + '\' already exists'); input.focus(); return; } _committed = true; var updatedViews = views.map(function(v) { return v.name === currentName ? { name: newName, sessions: v.sessions || [] } : v; }); api('PATCH', '/api/settings', { views: updatedViews }) .then(function() { if (_serverSettings) _serverSettings.views = updatedViews; _activeView = newName; api('PATCH', '/api/state', { active_view: newName }).catch(function() {}); renderViewDropdown(); openManageViewPanel(); }) .catch(function() { showToast('Failed to rename view'); _committed = false; revertRename(); }); } function revertRename() { if (_committed) return; openManageViewPanel(); } input.addEventListener('keydown', function(e) { if (e.key === 'Enter') { e.preventDefault(); commitRename(); } else if (e.key === 'Escape') { revertRename(); } }); input.addEventListener('blur', function() { setTimeout(function() { if (document.activeElement !== input) { var newName = input.value.trim(); if (newName && newName !== currentName) commitRename(); else revertRename(); } }, 150); }); }; } // — Delete button handler — var deleteBtn = $('manage-view-delete-btn'); if (deleteBtn) { deleteBtn.onclick = function(e) { e.stopPropagation(); // Show inline confirmation in the name-row var nameRow2 = panel.querySelector('.manage-view-panel__name-row'); if (!nameRow2) return; nameRow2.innerHTML = 'Delete this view?' + '' + ''; var yesBtn = $('manage-view-confirm-yes'); var noBtn = $('manage-view-confirm-no'); if (yesBtn) { yesBtn.onclick = function() { var viewToDelete = _activeView; var views = (_serverSettings && _serverSettings.views) || []; var updatedViews = views.filter(function(v) { return v.name !== viewToDelete; }); api('PATCH', '/api/settings', { views: updatedViews }) .then(function() { if (_serverSettings) _serverSettings.views = updatedViews; closeManageViewPanel(); switchView('all'); showToast('View \'' + viewToDelete + '\' deleted'); renderViewDropdown(); }) .catch(function() { showToast('Failed to delete view'); }); }; } if (noBtn) { noBtn.onclick = function() { openManageViewPanel(); }; } }; } } /** * Close the Manage View panel. */ function closeManageViewPanel() { var panel = $('manage-view-panel'); if (panel) panel.classList.add('hidden'); } /** * Render the session list inside the Manage View panel. * Shows ALL sessions: checked = in this view, unchecked = not in this view. * Checked items sorted first, then unchecked. Within each group, alphabetical by device. * Immediate-commit: each checkbox toggle fires PATCH /api/settings immediately. * Hidden sessions: dimmed with "hidden" badge. Static note below for hidden items. */ function renderManageViewList() { var listEl = $('manage-view-list'); var summaryEl = $('manage-view-summary'); if (!listEl) return; var views = (_serverSettings && _serverSettings.views) || []; // 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) var allSessions = (_currentSessions || []).filter(function(s) { return !s.status; }); // Update summary line if (summaryEl) { summaryEl.textContent = allSessions.length + ' sessions · ' + visibleCount(_currentSessions, _serverSettings, _activeView, { includeHidden: true }) + ' in this view'; } // Partition into inView (checked first) and notInView var inView = allSessions.filter(function(s) { var key = s.sessionKey || s.name; return viewSessions.indexOf(key) !== -1 || viewSessions.indexOf(s.name) !== -1; }); var notInView = allSessions.filter(function(s) { var key = s.sessionKey || s.name; return viewSessions.indexOf(key) === -1 && viewSessions.indexOf(s.name) === -1; }); // Sort each group: alphabetical, grouped by device function sortByDeviceAlpha(arr) { return arr.slice().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 sorted = sortByDeviceAlpha(inView).concat(sortByDeviceAlpha(notInView)); var html = ''; for (var j = 0; j < sorted.length; j++) { var s = sorted[j]; var key = s.sessionKey || s.name; var isInView = viewSessions.indexOf(key) !== -1 || viewSessions.indexOf(s.name) !== -1; // Phase 5: use the isHidden() helper (Phase 1) — do not inline a hidden check here. // The manage-view-item--hidden class triggers opacity + CSS ::after "(hidden)" badge. var sessionIsHidden = isHidden(key, _serverSettings); var escapedName = escapeHtml(s.name || ''); var deviceName = escapeHtml(_getDeviceDisplayName(s) || ''); html += ''; if (sessionIsHidden) { html += '
Adding will unhide this session
'; } } listEl.innerHTML = html; // Delegated change handler for immediate-commit checkboxes listEl.onchange = function(e) { var cb = e.target.closest('.manage-view-item__checkbox'); if (!cb) return; var sessionKey = cb.dataset.sessionKey; var isChecked = cb.checked; var patch; if (isChecked) { patch = addSessionToViewOp(_serverSettings, _activeView, sessionKey); } else { patch = removeSessionFromViewOp(_serverSettings, _activeView, sessionKey); } api('PATCH', '/api/settings', patch) .then(function() { if (_serverSettings) { _serverSettings.views = patch.views; if (patch.hidden_sessions) _serverSettings.hidden_sessions = patch.hidden_sessions; } // Update summary count in-place — do NOT re-render the full list (avoids layout thrash) var summaryEl = $('manage-view-summary'); if (summaryEl) { var latestViews = (_serverSettings && _serverSettings.views) || []; var latestViewObj = null; for (var si = 0; si < latestViews.length; si++) { if (latestViews[si].name === _activeView) { latestViewObj = latestViews[si]; break; } } var latestViewSessions = (latestViewObj && latestViewObj.sessions) || []; var latestAllSessions = (_currentSessions || []).filter(function(s) { return !s.status; }); summaryEl.textContent = latestAllSessions.length + ' sessions \u00b7 ' + latestViewSessions.length + ' in this view'; } renderGrid(_currentSessions || []); }) .catch(function(err) { showToast('Couldn’t save — try again'); if (cb) cb.checked = !isChecked; console.warn('[renderManageViewList] PATCH failed:', err); }); }; } /** * 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 ''; } // ─── Notification permission ──────────────────────────────────────────────── /** * Request browser notification permission on first load. * - If the Notification API is not available, returns immediately. * - If already granted, records the state synchronously. * - If default (not yet asked), calls requestPermission() and stores the result. * - Otherwise (e.g. denied), stores the current permission value. */ function requestNotificationPermission() { if (typeof Notification === 'undefined') return; if (Notification.permission === 'granted') { _notificationPermission = 'granted'; } else if (Notification.permission === 'default') { Notification.requestPermission().then((permission) => { _notificationPermission = permission; }); } else { _notificationPermission = Notification.permission; } } // ─── Bell transition notifications ───────────────────────────────────────── /** * Fire OS notifications for sessions that have newly received a bell event. * Only fires when the Notification permission is granted AND the browser tab * is currently hidden (document.hidden === true). * Uses a per-session tag so the OS deduplicates multiple bells into one * notification per session. * @param {object[]} prevSessions - sessions array from the previous poll * @param {object[]} nextSessions - sessions array from the current poll */ function handleBellTransitions(prevSessions, nextSessions) { const transitions = detectBellTransitions(prevSessions, nextSessions); for (const name of transitions) { if (_notificationPermission === 'granted' && document.hidden) { // eslint-disable-next-line no-new new Notification('Activity in: ' + name, { body: 'tmux session needs attention', tag: 'tmux-bell-' + name, }); } } } // ─── Heartbeat ────────────────────────────────────────────────────────────────── /** * Send a single heartbeat POST to /api/heartbeat. * Catches errors and logs them as warnings — never throws. * @returns {Promise} */ async function sendHeartbeat() { try { // When the browser tab is hidden (user switched tabs or minimized), report // viewing_session as null. This prevents the server from clearing bells on // the session — the user isn't actually looking at it, so activity should // accumulate and show in the favicon badge / tab indicators. var effectiveSession = (typeof document !== 'undefined' && document.hidden) ? null : _viewingSession; const payload = buildHeartbeatPayload(_deviceId, effectiveSession, _viewMode, _lastInteractionAt); await api('POST', '/api/heartbeat', payload); } catch (err) { console.warn('[sendHeartbeat] heartbeat failed:', err); } } /** * Start the heartbeat loop. Guards against double-start. * Uses self-scheduling setTimeout so at most one heartbeat is in-flight at a time. * Calls sendHeartbeat() immediately, then HEARTBEAT_MS after each completion. */ function startHeartbeat() { if (_heartbeatTimer) return; _heartbeatTimer = true; // sentinel: prevents double-start before first setTimeout fires async function heartbeatLoop() { await sendHeartbeat(); _heartbeatTimer = setTimeout(heartbeatLoop, HEARTBEAT_MS); } heartbeatLoop(); } /** Test-only helper: reset heartbeat timer state so tests can exercise startHeartbeat cleanly. */ function _resetHeartbeatTimer() { if (_heartbeatTimer) clearTimeout(_heartbeatTimer); _heartbeatTimer = undefined; } // ─── Toast notification ───────────────────────────────────────────────────── /** * Show a brief toast message. * Removes the 'hidden' class immediately, then restores it after 3000ms. * @param {string} msg */ function showToast(msg) { const el = $('toast'); if (el && el.show) el.show(msg); } // ─── Session pill bell ─────────────────────────────────────────────────────── /** * Update the floating session-pill bell indicator. * Shows #session-pill-bell if any session other than _viewingSession has unseen bells. */ function updatePillBell() { const pill = $('session-pill'); if (!pill) return; const viewingKey = _viewingRemoteId ? (_viewingRemoteId + ':' + _viewingSession) : _viewingSession; const hasBell = _currentSessions.some( (s) => (s.sessionKey || s.name) !== viewingKey && s.bell && s.bell.unseen_count > 0, ); pill.hasBell = hasBell; } // --------------------------------------------------------------------------- // Dynamic favicon — activity dot overlay // --------------------------------------------------------------------------- var _originalFavicon = null; // cached original favicon href var _faviconImage = null; // cached Image object for favicon badge compositing — avoids re-fetching every poll /** * Draw the favicon activity badge onto the element. * Owns the _faviconImage lifecycle: lazily creates it once (caching it in the module-level * variable) and reuses it on all subsequent calls. This avoids re-fetching favicon-32.png * on every poll cycle (previously new Image() was created inside updateFaviconBadge every 2s). * If the image is not yet loaded, registers an onload callback to retry automatically. */ function _drawFaviconBadge() { // Lazy-init: create the Image object once and cache it — subsequent calls reuse it if (!_faviconImage) { _faviconImage = new Image(); // No crossOrigin: favicon is same-origin; crossOrigin on same-origin images can // cause cache misses when the browser has the asset cached without CORS headers. _faviconImage.src = _originalFavicon; } // If image is not yet loaded, wait for it (onload will call us back) if (!_faviconImage.complete || _faviconImage.naturalWidth === 0) { _faviconImage.onload = function() { _drawFaviconBadge(); }; return; } var link = document.querySelector('link[rel="icon"][sizes="32x32"]') || document.querySelector('link[rel="icon"]'); if (!link) return; var canvas = document.createElement('canvas'); canvas.width = 32; canvas.height = 32; var ctx = canvas.getContext('2d'); if (!ctx) return; ctx.drawImage(_faviconImage, 0, 0, 32, 32); // Activity dot — brand amber (same as bell indicator) ctx.beginPath(); ctx.arc(24, 8, 7, 0, 2 * Math.PI); // top-right area ctx.fillStyle = '#F1A640'; // var(--bell-color) ctx.fill(); ctx.strokeStyle = '#0D1117'; // var(--bg) — border for contrast ctx.lineWidth = 2; ctx.stroke(); link.href = canvas.toDataURL('image/png'); } /** * Update the favicon with an activity dot if any session has unseen bells. * Uses a 32x32 canvas to draw the original favicon + a colored circle overlay. * Restores the original favicon when there are no unseen bells. * Delegates drawing to _drawFaviconBadge which manages the cached Image object. */ function updateFaviconBadge() { var visible = getVisibleSessions(_currentSessions); var hasActivity = visible.length > 0 && visible.some(function (s) { return s.bell && s.bell.unseen_count > 0; }); var link = document.querySelector('link[rel="icon"][sizes="32x32"]') || document.querySelector('link[rel="icon"]'); if (!link) return; // Cache the original favicon href on first call if (!_originalFavicon) _originalFavicon = link.href; if (!hasActivity) { // Restore original favicon when no activity if (link.href !== _originalFavicon) link.href = _originalFavicon; return; } _drawFaviconBadge(); } /** * Update the page title with an optional activity count prefix and the hostname. * Format: "(N) hostname - muxplex" when N sessions have unseen bells, otherwise * "hostname - muxplex". Hostname is device_name from server settings, falling back * to location.hostname so even unconfigured installs show something useful. * Call from pollSessions() on every tick, and whenever server settings change. */ function updatePageTitle() { var hostname = (_serverSettings && _serverSettings.device_name) || (typeof location !== 'undefined' ? location.hostname : null) || 'muxplex'; var visible = getVisibleSessions(_currentSessions); var count = visible.filter(function(s) { return s.bell && s.bell.unseen_count > 0; }).length; var prefix = count > 0 ? '(' + count + ') ' : ''; document.title = prefix + hostname + ' - muxplex'; } // ─── Session open / close ──────────────────────────────────────────────────── /** * Open a session in fullscreen view with a zoom transition. * @param {string} name - session name * @param {object} [opts] * @param {boolean} [opts.skipAnimation] - if true, skip the zoom animation (e.g. on page restore) * @returns {Promise} */ async function openSession(name, opts = {}) { if (!name || !name.trim()) return; hidePreview(); _viewingSession = name; _viewingRemoteId = opts.remoteId != null ? opts.remoteId : ''; _viewMode = 'fullscreen'; // Pre-render sidebar with current sessions before first poll tick initSidebar(); renderSidebar(_currentSessions, name, _viewingRemoteId); // Update expanded header const nameEl = $('expanded-session-name'); if (nameEl) nameEl.textContent = name; // Zoom animation: pin tile at current position, then animate to full viewport // Skipped on restore (skipAnimation:true) — no tile DOM element to zoom from const tile = opts.skipAnimation ? null : document.querySelector(`[data-session="${name}"]`); if (tile) { const rect = tile.getBoundingClientRect(); tile.style.position = 'fixed'; tile.style.top = rect.top + 'px'; tile.style.left = rect.left + 'px'; tile.style.width = rect.width + 'px'; tile.style.height = rect.height + 'px'; tile.style.transition = 'none'; // Force reflow void tile.offsetWidth; tile.style.transition = 'all 250ms ease'; tile.style.top = '0'; tile.style.left = '0'; tile.style.width = '100vw'; tile.style.height = '100vh'; } // Start animation concurrently with /connect POST — resolve when view is ready var animDone = new Promise(function (resolve) { var timerId = setTimeout(function () { var overview = $('view-overview'); var expanded = $('view-expanded'); if (overview) overview.style.display = 'none'; if (expanded) { expanded.classList.remove('hidden'); // must remove class — !important wins over style.display expanded.classList.add('view--active'); // makes it display:flex } // Re-render sidebar after DOM is visible and dimensions are correct initSidebar(); renderSidebar(_currentSessions, name, _viewingRemoteId); resolve(); }, opts.skipAnimation ? 0 : 260); // If setTimeout is stubbed (e.g. in test env), resolve immediately so we don't hang if (timerId == null) resolve(); }); // Mobile pill if (isMobile()) { const pill = $('session-pill'); if (pill) { pill.visible = true; pill.label = name; } updatePillBell(); updateSessionPill(_currentSessions); } // Hide FAB during fullscreen session view const fab = $('new-session-fab'); if (fab) fab.classList.add('hidden'); // Always spawn ttyd for this session — ensures correct session after service restart or page restore // _deviceId holds the device_id string (was integer remoteId index in old protocol) var _deviceId = opts.remoteId != null ? opts.remoteId : ''; try { if (_deviceId !== '') { // Remote session: route connect POST through same-origin federation proxy await api('POST', '/api/federation/' + encodeURIComponent(_deviceId) + '/connect/' + encodeURIComponent(name)); } else { await api('POST', '/api/sessions/' + encodeURIComponent(name) + '/connect'); } } catch (err) { showToast(err.message || 'Connection failed'); return closeSession(); } // Persist active_remote_id so restoreState() can reopen remote sessions after page refresh api('PATCH', '/api/state', { active_session: name, active_remote_id: _deviceId || null }).catch(function() {}); // Fire-and-forget bell-clear for remote sessions — acknowledge bells on the remote server if (_deviceId !== '') { api('POST', '/api/federation/' + encodeURIComponent(_deviceId) + '/sessions/' + encodeURIComponent(name) + '/bell/clear').catch(function() {}); } // Wait for animation to finish (may already be done if /connect was slow) await animDone; // Mount terminal NOW — /connect has completed, new ttyd is serving the correct session if (window._openTerminal) window._openTerminal(name, _deviceId, getDisplaySettings().fontSize); } /** * Close the current session and return to the grid view. * @returns {Promise} */ function closeSession() { _viewMode = 'grid'; _viewingSession = null; if (window._closeTerminal) window._closeTerminal(); // Fire-and-forget DELETE — skip for remote sessions (they don't need to know we stopped watching) if (_viewingRemoteId === '') { api('DELETE', '/api/sessions/current').catch(function() {}); } // Clear active_remote_id so a page refresh does not attempt to reopen the remote session api('PATCH', '/api/state', { active_session: null, active_remote_id: null }).catch(function() {}); _viewingRemoteId = ''; const expanded = $('view-expanded'); const overview = $('view-overview'); if (expanded) { expanded.classList.add('hidden'); expanded.classList.remove('view--active'); } if (overview) overview.style.display = ''; // overview uses view--active (no !important), style.display clears fine // Reapply fit layout after overview becomes visible again var _closDs = getDisplaySettings(); if ((_closDs.viewMode || 'auto') === 'fit') { var _closGrid = document.getElementById('session-grid'); if (_closGrid) { _closGrid.classList.add('session-grid--fit'); applyFitLayout(_closGrid); } } const pill = $('session-pill'); if (pill) pill.visible = false; // Restore FAB when returning to overview const fab = $('new-session-fab'); if (fab) fab.classList.remove('hidden'); return Promise.resolve(); } /** Test-only helper: set _viewingSession directly. */ function _setViewingSession(name) { _viewingSession = name; } // ─── Server settings ───────────────────────────────────────────────────────── /** * Load server settings from GET /api/settings and cache in _serverSettings. * Always resolves — errors are logged as warnings. * @returns {Promise} */ async function loadServerSettings() { try { const res = await api('GET', '/api/settings'); _serverSettings = await res.json(); } catch (err) { console.warn('[loadServerSettings] failed:', err); if (!_serverSettings) _serverSettings = {}; } return _serverSettings; } /** * Send a PATCH to /api/settings with a single key/value update. * Shows a toast on success or failure. * @param {string} key * @param {*} value * @returns {Promise} */ async function patchServerSetting(key, value) { try { await api('PATCH', '/api/settings', { [key]: value }); _serverSettings = Object.assign({}, _serverSettings, { [key]: value }); showToast('Setting saved'); } catch (err) { showToast('Failed to save setting'); console.warn('[patchServerSetting] failed:', err); } } /** * Build a single remote instance row element with URL input, name input, key input, and remove button. * @param {string} url - remote instance URL * @param {string} name - remote instance display name * @param {string} key - federation key for the remote instance * @returns {HTMLDivElement} */ function _buildRemoteInstanceRow(url, name, key) { var row = document.createElement('div'); row.className = 'settings-remote-row'; var urlInput = document.createElement('input'); urlInput.type = 'text'; urlInput.className = 'settings-remote-url'; urlInput.placeholder = 'http://192.168.1.x:8000'; urlInput.value = url || ''; urlInput.setAttribute('aria-label', 'Remote instance URL'); var nameInput = document.createElement('input'); nameInput.type = 'text'; nameInput.className = 'settings-remote-name'; nameInput.placeholder = 'Device name'; nameInput.value = name || ''; nameInput.setAttribute('aria-label', 'Remote instance display name'); var keyInput = document.createElement('input'); keyInput.type = 'password'; keyInput.className = 'settings-remote-key'; keyInput.placeholder = 'Federation key'; keyInput.value = key || ''; keyInput.setAttribute('aria-label', 'Federation key for remote instance'); var removeBtn = document.createElement('button'); removeBtn.className = 'settings-remote-remove'; removeBtn.innerHTML = _icons.close; removeBtn.setAttribute('aria-label', 'Remove remote instance'); row.appendChild(urlInput); row.appendChild(nameInput); row.appendChild(keyInput); row.appendChild(removeBtn); return row; } /** * Read remote instance rows from the DOM and save to server settings. */ function _saveRemoteInstances() { var container = $('setting-remote-instances'); if (!container) return; var instances = []; container.querySelectorAll('.settings-remote-row').forEach(function(row) { var urlEl = row.querySelector('.settings-remote-url'); var nameEl = row.querySelector('.settings-remote-name'); var keyEl = row.querySelector('.settings-remote-key'); var url = (urlEl && urlEl.value) ? urlEl.value.trim() : ''; var name = (nameEl && nameEl.value) ? nameEl.value.trim() : ''; var key = (keyEl && keyEl.value) ? keyEl.value.trim() : ''; if (url) { instances.push({ url: url, name: name, key: key }); } }); patchServerSetting('remote_instances', instances); } // ─── Multi-Device helper ────────────────────────────────────────────────────────── /** * Enable or disable all Multi-Device tab fields (except the enable toggle itself). * When disabled, the fields container gets opacity: 0.5 and inputs/selects/buttons * are disabled so users cannot interact with them. * @param {boolean} enabled */ function _updateMultiDeviceFieldsState(enabled) { var fieldsContainer = $('multi-device-fields'); if (!fieldsContainer) return; var controls = fieldsContainer.querySelectorAll('input, select, button'); controls.forEach(function(ctrl) { ctrl.disabled = !enabled; }); fieldsContainer.style.opacity = enabled ? '' : '0.5'; } // ─── Settings dialog ────────────────────────────────────────────────────────── /** * Get display settings from the server-settings cache (_serverSettings), * falling back to DISPLAY_DEFAULTS for any missing keys. * Only includes keys defined in DISPLAY_DEFAULTS. * @returns {object} */ function getDisplaySettings() { const result = Object.assign({}, DISPLAY_DEFAULTS); const ss = _serverSettings || {}; for (const key of Object.keys(DISPLAY_DEFAULTS)) { if (Object.prototype.hasOwnProperty.call(ss, key)) { result[key] = ss[key]; } } return result; } /** * Set grid template for fit mode based on tile count. * Pure arithmetic — no DOM measurement, no getComputedStyle, no clientHeight. * Safe to call at any time regardless of display state or layout phase. * * The grid already has a definite height from CSS (flex: 1 inside height: 100dvh). * Setting grid-template-rows: repeat(rows, 1fr) lets the browser divide that height * equally without JS needing to know the pixel dimensions. Tiles use height: auto * (set in CSS) so they fill their grid cells without inline style overrides. * * @param {Element} grid - The session grid element */ function applyFitLayout(grid) { var count = grid.querySelectorAll('.session-tile').length; if (count === 0) { grid.style.removeProperty('grid-template-columns'); grid.style.removeProperty('grid-template-rows'); return; } // Calculate optimal cols/rows — start with square root var cols = Math.ceil(Math.sqrt(count)); var rows = Math.ceil(count / cols); // Prefer wider layouts (more cols, fewer rows) since tiles are landscape if (rows > 1 && cols < count) { var altCols = cols + 1; var altRows = Math.ceil(count / altCols); if (altRows < rows) { cols = altCols; rows = altRows; } } grid.style.gridTemplateColumns = 'repeat(' + cols + ', 1fr)'; grid.style.gridTemplateRows = 'repeat(' + rows + ', 1fr)'; } /** * Cycle the dashboard view mode: auto → fit → auto. * Persists to server settings and reapplies display settings. */ function cycleViewMode() { var ds = getDisplaySettings(); var idx = VIEW_MODES.indexOf(ds.viewMode || 'auto'); ds.viewMode = VIEW_MODES[(idx + 1) % VIEW_MODES.length]; if (_serverSettings) _serverSettings.viewMode = ds.viewMode; patchServerSetting('viewMode', ds.viewMode); applyDisplaySettings(ds); // Update button label var btn = document.getElementById('view-mode-btn'); if (btn) btn.title = 'View: ' + ds.viewMode; } /** * Apply display settings to the live DOM. * Sets --preview-font-size CSS custom property and updates #session-grid * grid-template-columns based on the gridColumns setting and viewMode. * @param {object} ds - display settings object */ function applyDisplaySettings(ds) { // Apply font size as CSS custom property (tile previews) if (document.documentElement) { document.documentElement.style.setProperty('--preview-font-size', ds.fontSize + 'px'); } // Apply font size to the live xterm.js terminal without reconnecting if (window._setTerminalFontSize) { window._setTerminalFontSize(ds.fontSize); } // Apply view mode to grid var grid = document.getElementById('session-grid'); if (!grid) return; var mode = ds.viewMode || 'auto'; // Remove all mode classes grid.classList.remove('session-grid--fit'); // Reset any inline styles from previous fit calculation grid.style.removeProperty('grid-template-rows'); grid.style.removeProperty('grid-template-columns'); if (mode === 'auto') { // Restore grid columns setting if (ds.gridColumns === 'auto' || !ds.gridColumns) { grid.style.removeProperty('grid-template-columns'); } else { grid.style.gridTemplateColumns = 'repeat(' + ds.gridColumns + ', 1fr)'; } } else if (mode === 'fit') { grid.classList.add('session-grid--fit'); applyFitLayout(grid); } } /** * Load grid view mode preference from display settings (server). * Returns 'flat' as default. * @returns {string} */ function loadGridViewMode() { var ds = getDisplaySettings(); var mode = ds.gridViewMode || 'flat'; // 'filtered' was removed in the Views feature — fall back to 'flat' if (mode === 'filtered') mode = 'flat'; return mode; } /** * Save grid view mode preference to server settings and update _gridViewMode. * @param {string} mode - The grid view mode to save. */ function saveGridViewMode(mode) { if (_serverSettings) _serverSettings.gridViewMode = mode; patchServerSetting('gridViewMode', mode); _gridViewMode = mode; } /** * Handle a change event on any Display settings control. * Reads current values from form elements, saves via server settings PATCH, * and applies via applyDisplaySettings immediately. */ function onDisplaySettingChange() { var ds = getDisplaySettings(); var fontSizeEl = document.getElementById('setting-font-size'); if (fontSizeEl) ds.fontSize = parseInt(fontSizeEl.value, 10) || ds.fontSize; var hoverDelayEl = document.getElementById('setting-hover-delay'); if (hoverDelayEl) ds.hoverPreviewDelay = parseInt(hoverDelayEl.value, 10); var gridColumnsEl = document.getElementById('setting-grid-columns'); if (gridColumnsEl) { var raw = gridColumnsEl.value; ds.gridColumns = raw === 'auto' ? 'auto' : parseInt(raw, 10); } var showDeviceBadgesEl = document.getElementById('setting-show-device-badges'); if (showDeviceBadgesEl) ds.showDeviceBadges = showDeviceBadgesEl.checked; var showHoverPreviewEl = document.getElementById('setting-show-hover-preview'); if (showHoverPreviewEl) ds.showHoverPreview = showHoverPreviewEl.checked; var activityIndicatorEl = document.getElementById('setting-activity-indicator'); if (activityIndicatorEl) ds.activityIndicator = activityIndicatorEl.value; var patch = { fontSize: ds.fontSize, hoverPreviewDelay: ds.hoverPreviewDelay, gridColumns: ds.gridColumns, showDeviceBadges: ds.showDeviceBadges, showHoverPreview: ds.showHoverPreview, activityIndicator: ds.activityIndicator, }; Object.assign(_serverSettings, patch); api('PATCH', '/api/settings', patch) .then(function() { showToast('Settings saved'); }) .catch(function(err) { console.warn('[onDisplaySettingChange] failed:', err); }); applyDisplaySettings(ds); } /** * Update notification UI controls to reflect the current permission state. * @param {Element} statusEl - The status text element. * @param {Element} reqBtn - The request-permission button. * @param {string} permission - Notification.permission value, or 'unsupported'. */ function _updateNotificationUI(statusEl, reqBtn, permission) { if (!statusEl || !reqBtn) return; if (permission === 'granted') { statusEl.textContent = 'Granted'; reqBtn.disabled = true; } else if (permission === 'denied') { statusEl.textContent = 'Denied (check browser settings)'; reqBtn.disabled = true; } else if (permission === 'unsupported') { statusEl.textContent = 'Not supported'; reqBtn.disabled = true; } else { statusEl.textContent = 'Not requested'; reqBtn.disabled = false; } } /** * Open the settings dialog. * Sets _settingsOpen, calls dialog.showModal(), removes hidden from backdrop, * and loads current display settings into form controls. */ function openSettings() { _settingsOpen = true; const dialog = $('settings-dialog'); if (dialog) dialog.showModal(); const backdrop = $('settings-backdrop'); if (backdrop) backdrop.classList.remove('hidden'); const settings = getDisplaySettings(); const fontSizeEl = $('setting-font-size'); if (fontSizeEl) fontSizeEl.value = String(settings.fontSize); const hoverDelayEl = $('setting-hover-delay'); if (hoverDelayEl) hoverDelayEl.value = String(settings.hoverPreviewDelay); const gridColumnsEl = $('setting-grid-columns'); if (gridColumnsEl) gridColumnsEl.value = String(settings.gridColumns); const viewModeEl = $('setting-view-mode'); if (viewModeEl) viewModeEl.value = loadGridViewMode(); // Populate display toggle controls const showDeviceBadgesEl = $('setting-show-device-badges'); if (showDeviceBadgesEl) showDeviceBadgesEl.checked = settings.showDeviceBadges !== false; const showHoverPreviewEl = $('setting-show-hover-preview'); if (showHoverPreviewEl) showHoverPreviewEl.checked = settings.showHoverPreview !== false; const activityIndicatorEl = $('setting-activity-indicator'); if (activityIndicatorEl) activityIndicatorEl.value = settings.activityIndicator || 'both'; // Populate Sessions tab / bell sound from display settings const bellSoundEl = $('setting-bell-sound'); if (bellSoundEl) bellSoundEl.checked = !!settings.bellSound; // Update notification permission status text/button const statusEl = $('notification-status-text'); const reqBtn = $('notification-request-btn'); if (statusEl && reqBtn) { const permission = typeof Notification === 'undefined' ? 'unsupported' : Notification.permission; _updateNotificationUI(statusEl, reqBtn, permission); } // Populate Sessions tab from server settings loadServerSettings().then(function(ss) { // Default session dropdown const defaultSessionEl = $('setting-default-session'); if (defaultSessionEl) { // Rebuild options from current sessions defaultSessionEl.innerHTML = ''; (_currentSessions || []).forEach(function(s) { const opt = document.createElement('option'); opt.value = s.name || ''; opt.textContent = s.name || ''; if (ss && ss.default_session === s.name) opt.selected = true; defaultSessionEl.appendChild(opt); }); } // Sort order const sortOrderEl = $('setting-sort-order'); if (sortOrderEl && ss && ss.sort_order) { sortOrderEl.value = ss.sort_order; } // Window size largest const windowSizeEl = $('setting-window-size-largest'); if (windowSizeEl) { windowSizeEl.checked = !!(ss && ss.window_size_largest); } // Auto-open const autoOpenEl = $('setting-auto-open'); if (autoOpenEl) { autoOpenEl.checked = ss && ss.auto_open_created !== undefined ? !!ss.auto_open_created : true; } // Device name const deviceNameEl = $('setting-device-name'); if (deviceNameEl) { deviceNameEl.value = (ss && ss.device_name) || ''; } // Update document.title from device_name setting updatePageTitle(); // Multi-device enabled checkbox (with smart default: checked if remote_instances non-empty) const multiDeviceEnabledEl = $('setting-multi-device-enabled'); if (multiDeviceEnabledEl) { var remoteList = (ss && ss.remote_instances) || []; multiDeviceEnabledEl.checked = !!(ss && ss.multi_device_enabled) || remoteList.length > 0; _updateMultiDeviceFieldsState(multiDeviceEnabledEl.checked); } // Remote instances const remoteInstancesEl = $('setting-remote-instances'); if (remoteInstancesEl) { remoteInstancesEl.innerHTML = ''; var remotes = (ss && ss.remote_instances) || []; remotes.forEach(function(r) { remoteInstancesEl.appendChild(_buildRemoteInstanceRow(r.url || '', r.name || '', r.key || '')); }); } // Commands tab - populate create template textarea const templateEl = $('setting-template'); if (templateEl) { templateEl.value = (ss && ss.new_session_template) || NEW_SESSION_DEFAULT_TEMPLATE; } // Commands tab - populate delete template textarea const deleteTemplateEl = $('setting-delete-template'); if (deleteTemplateEl) { deleteTemplateEl.value = (ss && ss.delete_session_template) || DELETE_SESSION_DEFAULT_TEMPLATE; } // Views tab renderViewsSettingsTab(); }); } /** * Close the settings dialog. * Sets _settingsOpen to false, calls dialog.close(), adds hidden to backdrop. */ function closeSettings() { _settingsOpen = false; const dialog = $('settings-dialog'); if (dialog) dialog.close(); const backdrop = $('settings-backdrop'); if (backdrop) backdrop.classList.add('hidden'); } /** * Switch the active settings tab. * Toggles settings-tab--active class and aria-selected on tab buttons, * toggles hidden class on settings-panel elements by matching data-tab. * @param {string} tabName */ function switchSettingsTab(tabName) { document.querySelectorAll('.settings-tab').forEach(function(tab) { const isActive = tab.dataset.tab === tabName; if (isActive) { tab.classList.add('settings-tab--active'); tab.setAttribute('aria-selected', 'true'); } else { tab.classList.remove('settings-tab--active'); tab.setAttribute('aria-selected', 'false'); } }); document.querySelectorAll('.settings-panel').forEach(function(panel) { const panelTab = panel.dataset.tab; if (panelTab === tabName) { panel.classList.remove('hidden'); } else { panel.classList.add('hidden'); } }); } /** * Global keydown handler. * Settings open: Escape closes settings, return. * Ignore shortcuts when typing in INPUT/TEXTAREA/SELECT. * Comma key (not in inputs, no ctrl/meta) opens settings. * Grid overview only: backtick toggles dropdown, number keys 1-9 switch views. * Arrow keys + Enter navigate within open dropdown. * Escape closes open dropdown. * Fullscreen: Escape calls closeSession(). * @param {KeyboardEvent} e */ function handleGlobalKeydown(e) { // Settings open: only Escape closes it, then bail if (_settingsOpen) { if (e.key === 'Escape') { closeSettings(); } return; } // Determine if focus is inside a text input const tag = document.activeElement && document.activeElement.tagName; const inInput = (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'); // Comma key (not in inputs, no ctrl/meta) opens settings if (e.key === ',' && !e.ctrlKey && !e.metaKey && !inInput) { openSettings(); return; } // View dropdown shortcuts — grid overview only, not in input, no ctrl/meta if (_viewMode === 'grid' && !inInput && !e.ctrlKey && !e.metaKey) { // Backtick toggles the view dropdown if (e.code === 'Backquote') { e.preventDefault(); toggleViewDropdown(); return; } // Number keys 1-9: 1=all, 9=hidden, 2-8=user views by index (num-2) if (e.code && e.code.startsWith('Digit')) { const num = parseInt(e.key, 10); if (num >= 1 && num <= 9) { const views = (_serverSettings && _serverSettings.views) || []; if (num === 1) { switchView('all'); } else if (num === 9) { switchView('hidden'); } else { const vIdx = num - 2; if (vIdx < views.length) { switchView(views[vIdx].name); } } return; } } } // Arrow key navigation within open dropdown const menu = $('view-dropdown-menu'); if (menu && !menu.classList.contains('hidden')) { if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); const items = Array.from(menu.querySelectorAll('[role="menuitem"]')); if (items.length > 0) { const focusedEl = document.activeElement; let itemIdx = items.indexOf(focusedEl); if (e.key === 'ArrowDown') { itemIdx = (itemIdx + 1) % items.length; } else { itemIdx = (itemIdx - 1 + items.length) % items.length; } items[itemIdx].focus(); } return; } if (e.key === 'Enter') { const focusedEl = document.activeElement; if (menu.contains(focusedEl)) { focusedEl.click(); return; } } if (e.key === 'Escape') { closeViewDropdown(); return; } } // Fullscreen: Escape calls closeSession if (_viewMode === 'fullscreen' && e.key === 'Escape') { e.preventDefault(); closeSession(); } } /** * Open the bottom sheet (mobile session switcher). * Renders the current session list and removes the 'hidden' class. */ function openBottomSheet() { var sheet = $('bottom-sheet'); if (!sheet) return; sheet.sessions = getVisibleSessions(_currentSessions); sheet.viewingSession = _viewingSession || ''; sheet.viewingRemoteId = _viewingRemoteId || ''; sheet.open = true; sheet.classList.remove('hidden'); } /** * Close the bottom sheet. * Adds the 'hidden' class and removes the dynamic backdrop listener. */ function closeBottomSheet() { var sheet = $('bottom-sheet'); if (sheet) { sheet.open = false; sheet.classList.add('hidden'); } } /** * Render the session list inside #sheet-list for the mobile bottom sheet. * Sorts sessions by priority, builds
  • elements with bell indicator and timestamp, * and binds click handlers to switch sessions. */ function renderSheetList() { var sheet = $('bottom-sheet'); if (!sheet) return; sheet.sessions = getVisibleSessions(_currentSessions); sheet.viewingSession = _viewingSession || ''; sheet.viewingRemoteId = _viewingRemoteId || ''; } /** * Update the session pill bell badge when in fullscreen view. * Shows #session-pill-bell if any other session (not currently viewed) has unseen bells. * @param {object[]} sessions - full sessions array */ function updateSessionPill(sessions) { const pill = $('session-pill'); if (!pill) return; if (_viewMode !== 'fullscreen') { pill.visible = false; return; } var pillLabel = _viewingSession || ''; pill.label = pillLabel; pill.visible = true; var viewingKey = _viewingRemoteId ? (_viewingRemoteId + ':' + _viewingSession) : _viewingSession; var othersWithBell = sessions.filter(function(s) { return (s.sessionKey || s.name) !== viewingKey && s.bell && s.bell.unseen_count > 0 && (s.bell.seen_at === null || s.bell.last_fired_at > s.bell.seen_at); }); pill.hasBell = othersWithBell.length > 0; } // ─── Header + button with inline name input ──────────────────────────────────── /** * Create a new session name input element with shared base configuration. * Used by both showNewSessionInput (inline) and showFabSessionInput (overlay) * to avoid duplicating the five setup properties. * @returns {HTMLInputElement} */ function _createSessionInput() { const input = document.createElement('input'); input.type = 'text'; input.className = 'new-session-input'; input.placeholder = 'Session name\u2026'; input.autocomplete = 'off'; input.spellcheck = false; return input; } /** * Create an optional device