9c86cf096d
New files: - muxplex/frontend/components/store.js: Reactive AppStore (EventTarget-based state management). Provides get/set/batch/on methods with automatic change event dispatch. Convenience accessors for sessions, activeView, serverSettings. - muxplex/frontend/components/view-dropdown.js: Unified <view-dropdown> component. Replaces TWO parallel dropdown implementations (header + sidebar) with a single component taking a variant prop. Eliminates ~300 lines of duplicated HTML-string-building code. app.js changes: - renderViewDropdown/renderSidebarViewDropdown: 100+ lines of HTML builders → 6-line property setters - toggleViewDropdown/toggleSidebarViewDropdown: simplified to toggle .open property - closeViewDropdown: simplified to set .open = false - switchView: removed manual sidebar label update (component handles it) - bindStaticEventListeners: replaced 80+ lines of header/sidebar dropdown event wiring + two click-outside handlers with component event listeners - showNewViewInput/showSidebarNewViewInput: updated to find menu inside component - Exposed filterVisible() globally for component use - Removed dead global _previewPopover index.html changes: - Replaced 14-line header dropdown structure with single <view-dropdown> - Replaced 8-line sidebar dropdown structure with single <view-dropdown variant="sidebar"> - Added store.js and view-dropdown.js imports All 1306 tests pass.
4386 lines
158 KiB
JavaScript
4386 lines
158 KiB
JavaScript
// Phase 2b implementation — app.js
|
||
|
||
/** Inline SVG icon strings used throughout the UI. */
|
||
var _icons = {
|
||
statusOk: '<svg viewBox="0 0 16 16" width="10" height="10" fill="currentColor"><circle cx="8" cy="8" r="4"/></svg>',
|
||
statusWarn: '<svg viewBox="0 0 16 16" width="10" height="10" fill="none" stroke="currentColor" stroke-width="2" stroke-dasharray="3 2"><circle cx="8" cy="8" r="5"/></svg>',
|
||
statusErr: '<svg viewBox="0 0 16 16" width="10" height="10" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 4l8 8M12 4l-8 8"/></svg>',
|
||
kebab: '<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor"><circle cx="8" cy="3" r="1.5"/><circle cx="8" cy="8" r="1.5"/><circle cx="8" cy="13" r="1.5"/></svg>',
|
||
chevronLeft: '<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M10 3L5 8l5 5"/></svg>',
|
||
chevronRight: '<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 3l5 5-5 5"/></svg>',
|
||
chevronUp: '<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 10l4-4 4 4"/></svg>',
|
||
chevronDown: '<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6l4 4 4-4"/></svg>',
|
||
check: '<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M3 8l4 4 6-7"/></svg>',
|
||
bell: '<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor"><path d="M8 1a1 1 0 0 1 1 1v.3A4.5 4.5 0 0 1 12.5 7v2.5l1 2H2.5l1-2V7A4.5 4.5 0 0 1 7 2.3V2a1 1 0 0 1 1-1zM6.5 13a1.5 1.5 0 0 0 3 0z"/></svg>',
|
||
close: '<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 4l8 8M12 4l-8 8"/></svg>',
|
||
};
|
||
|
||
/**
|
||
* 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 += '<div class="flyout-menu__separator" role="separator"></div>';
|
||
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 += '<button class="' + cls + '" role="menuitem" data-action="' + item.action + '"' + titleAttr + '>';
|
||
html += label;
|
||
html += '</button>';
|
||
}
|
||
|
||
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<void>}
|
||
*/
|
||
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<void>}
|
||
*/
|
||
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, '<')
|
||
.replace(/>/g, '>');
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// ANSI escape → HTML span converter (SGR codes only)
|
||
// Converts terminal color sequences to <span> 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 += '</span>'; spans--; }
|
||
} else if (style) {
|
||
out += '<span style="' + style + '">';
|
||
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 += '</span>'; 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 = `<span class="device-badge">${escapeHtml(session.deviceName)}</span>`;
|
||
}
|
||
|
||
// 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 (
|
||
`<article class="${classes}" data-session="${escapedName}" data-session-key="${escapeHtml(session.sessionKey || name)}"${remoteIdAttr} tabindex="0" role="listitem" aria-label="${escapedName}">` +
|
||
`<div class="tile-header">` +
|
||
`<span class="tile-name">${escapeHtml(name)}</span>` +
|
||
`${badgeHtml}` +
|
||
`<span class="tile-meta">${escapeHtml(timeStr)}</span>` +
|
||
`<button class="tile-options-btn" data-session="${escapedName}" aria-label="Session options" aria-haspopup="true">${_icons.kebab}</button>` +
|
||
`</div>` +
|
||
`<div class="tile-body"><pre>${ansiToHtml(lastLines)}</pre></div>` +
|
||
`</article>`
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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 = `<span class="device-badge">${escapeHtml(session.deviceName)}</span>`;
|
||
}
|
||
|
||
// 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 (
|
||
`<article class="${classes}" data-session="${escapedName}" data-session-key="${escapeHtml(session.sessionKey || name)}" data-remote-id="${escapeHtml(_sidebarEffRemoteId)}" tabindex="0" role="listitem">` +
|
||
`<div class="sidebar-item-header">` +
|
||
`<span class="sidebar-item-name">${escapedName}</span>` +
|
||
badgeHtml +
|
||
`<button class="tile-options-btn" data-session="${escapedName}" aria-label="Session options" aria-haspopup="true">${_icons.kebab}</button>` +
|
||
`</div>` +
|
||
`<div class="sidebar-item-body"><pre>${ansiToHtml(lastLines)}</pre></div>` +
|
||
`</article>`
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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 (
|
||
'<article class="source-tile source-tile--' + statusClass + '">' +
|
||
'<span class="source-tile__name">' + escapeHtml(deviceName || '') + '</span>' +
|
||
'<span class="source-tile__badge">' + escapeHtml(statusText || '') + '</span>' +
|
||
'</article>'
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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" | <user view name>
|
||
// 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 <view-dropdown> 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 = '<div class="sidebar-empty">No sessions</div>';
|
||
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 <session-grid> component.
|
||
// No-op stubs are exported at the bottom of the file for test compatibility.
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// View dropdown — render, open/close, view switching
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Update <view-dropdown> 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 <view-dropdown> 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 <session-grid> 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 <hover-preview> 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 <hover-preview> 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 = '<div class="flyout-sheet__backdrop"></div>';
|
||
html += '<div class="flyout-sheet__panel" aria-label="Session options" role="menu">';
|
||
html += '<div class="flyout-sheet__handle" aria-hidden="true"></div>';
|
||
|
||
for (var i = 0; i < items.length; i++) {
|
||
var item = items[i];
|
||
if (item.separator) {
|
||
html += '<div class="flyout-sheet__separator"></div>';
|
||
continue;
|
||
}
|
||
|
||
var label = item.label;
|
||
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-sheet__item';
|
||
if (item.className && item.className.indexOf('danger') !== -1) cls += ' flyout-sheet__item--danger';
|
||
|
||
html += '<button class="' + cls + '" role="menuitem" data-action="' + item.action + '">';
|
||
html += label;
|
||
html += '</button>';
|
||
}
|
||
|
||
html += '</div>';
|
||
|
||
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 = '<div class="flyout-sheet__backdrop"></div>';
|
||
html += '<div class="flyout-sheet__panel" aria-label="Confirm kill session" role="alertdialog">';
|
||
html += '<div class="flyout-sheet__handle" aria-hidden="true"></div>';
|
||
html += '<div class="flyout-sheet__title">Kill ' + escapeHtml(sessionName) + '?</div>';
|
||
html += '<button class="flyout-sheet__item flyout-sheet__item--danger" data-action="confirm-kill" role="button">Kill</button>';
|
||
html += '<button class="flyout-sheet__item" data-action="cancel" role="button">Cancel</button>';
|
||
html += '</div>';
|
||
|
||
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 = '<div class="flyout-sheet__backdrop"></div>';
|
||
html += '<div class="flyout-sheet__panel" aria-label="Add to View" role="menu">';
|
||
html += '<div class="flyout-sheet__handle" aria-hidden="true"></div>';
|
||
|
||
for (var i = 0; i < views.length; i++) {
|
||
var v = views[i];
|
||
var isIn = (v.sessions || []).indexOf(sessionKey) !== -1;
|
||
html += '<button class="flyout-sheet__item" role="menuitem" data-view-index="' + i + '">';
|
||
html += '<span style="margin-right:8px">' + (isIn ? _icons.check : '\u00a0\u00a0') + '</span>';
|
||
html += escapeHtml(v.name);
|
||
html += '</button>';
|
||
}
|
||
|
||
html += '<div class="flyout-sheet__separator"></div>';
|
||
html += '<button class="flyout-sheet__item" data-action="done" role="menuitem">Done</button>';
|
||
html += '</div>';
|
||
|
||
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 += '<button class="flyout-submenu__item" role="menuitem" data-view-index="' + i + '">';
|
||
html += '<span class="flyout-submenu__check">' + (isIn ? _icons.check : '') + '</span>';
|
||
html += escapeHtml(v.name);
|
||
html += '</button>';
|
||
}
|
||
// — Always show "+ New View" option at the bottom
|
||
if (views.length > 0) {
|
||
html += '<div class="flyout-menu__separator" role="separator"></div>';
|
||
}
|
||
html += '<button class="flyout-submenu__item" role="menuitem" data-action="new-view-in-flyout">+ New View</button>';
|
||
|
||
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 =
|
||
'<div class="flyout-menu__confirm">' +
|
||
'<span>Kill?</span>' +
|
||
'<button class="flyout-menu__confirm-btn flyout-menu__confirm-btn--yes" data-action="confirm-kill">Yes</button>' +
|
||
'<button class="flyout-menu__confirm-btn" data-action="cancel-kill">No</button>' +
|
||
'</div>';
|
||
|
||
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 = '<span style="color:var(--err)">Failed</span>';
|
||
setTimeout(function() {
|
||
// Revert to original kill button if menu is still open
|
||
if (_flyoutMenuEl && confirmDiv.parentNode) {
|
||
confirmDiv.outerHTML =
|
||
'<button class="flyout-menu__item flyout-menu__item--danger" role="menuitem" data-action="kill">Kill Session</button>';
|
||
}
|
||
}, 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 =
|
||
'<h2 id="manage-view-name" class="manage-view-panel__name">' + escapeHtml(_activeView) + '</h2>' +
|
||
'<button id="manage-view-delete-btn" class="manage-view-panel__delete-btn" ' +
|
||
'title="Delete this view" aria-label="Delete view">' + _icons.close + '</button>';
|
||
}
|
||
|
||
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 =
|
||
'<span class="manage-view-panel__confirm-text">Delete this view?</span>' +
|
||
'<button id="manage-view-confirm-yes" class="manage-view-panel__close-btn" ' +
|
||
'style="margin-left:8px">Yes</button>' +
|
||
'<button id="manage-view-confirm-no" class="manage-view-panel__close-btn" ' +
|
||
'style="margin-left:4px">No</button>';
|
||
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 += '<label class="manage-view-item' + (sessionIsHidden ? ' manage-view-item--hidden' : '') + '">';
|
||
html += '<input type="checkbox" class="manage-view-item__checkbox" data-session-key="' + escapeHtml(key) + '"' + (isInView ? ' checked' : '') + (sessionIsHidden ? ' data-is-hidden="1"' : '') + ' />';
|
||
html += '<span class="manage-view-item__name">' + escapedName + '</span>';
|
||
if (deviceName) html += '<span class="manage-view-item__device">' + deviceName + '</span>';
|
||
html += '</label>';
|
||
if (sessionIsHidden) {
|
||
html += '<div class="manage-view-item__disclosure">Adding will unhide this session</div>';
|
||
}
|
||
}
|
||
|
||
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<void>}
|
||
*/
|
||
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 <link> 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<void>}
|
||
*/
|
||
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<void>}
|
||
*/
|
||
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<object>}
|
||
*/
|
||
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<void>}
|
||
*/
|
||
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 = '<option value="">(none)</option>';
|
||
(_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 <li> 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 <select> for multi-device session creation.
|
||
* Returns null when multi_device_enabled is false or remote_instances is empty.
|
||
* @returns {HTMLSelectElement|null}
|
||
*/
|
||
function _createDeviceSelect() {
|
||
const ss = _serverSettings || {};
|
||
const remotes = ss.remote_instances;
|
||
if (!ss.multi_device_enabled || !remotes || remotes.length === 0) {
|
||
return null;
|
||
}
|
||
|
||
const select = document.createElement('select');
|
||
select.className = 'new-session-device-select';
|
||
|
||
// Local device option
|
||
const localOpt = document.createElement('option');
|
||
localOpt.value = '';
|
||
localOpt.textContent = ss.device_name || 'Local';
|
||
select.appendChild(localOpt);
|
||
|
||
// Remote instance options
|
||
for (var i = 0; i < remotes.length; i++) {
|
||
var opt = document.createElement('option');
|
||
opt.value = remotes[i].device_id || String(i);
|
||
opt.textContent = remotes[i].name || remotes[i].url || 'Remote ' + i;
|
||
if (_activeFilterDevice === remotes[i].name || _activeFilterDevice === remotes[i].url) {
|
||
opt.selected = true;
|
||
select.value = remotes[i].device_id || String(i);
|
||
}
|
||
select.appendChild(opt);
|
||
}
|
||
|
||
return select;
|
||
}
|
||
|
||
/**
|
||
* Replace the header + button with an inline text input (and optional device
|
||
* select) for session naming. Hides the button, inserts controls before it,
|
||
* and focuses the input.
|
||
* On Enter: if name is non-empty after trim, calls createNewSession(name, remoteId).
|
||
* On Escape: restores the button (cleanup only).
|
||
* On blur: delayed cleanup (150ms) to allow click handlers.
|
||
* @param {HTMLElement} btn - The button element to replace temporarily.
|
||
*/
|
||
function showNewSessionInput(btn) {
|
||
const select = _createDeviceSelect();
|
||
const input = _createSessionInput();
|
||
|
||
function cleanup() {
|
||
if (select && select.parentNode) select.parentNode.removeChild(select);
|
||
if (input.parentNode) input.parentNode.removeChild(input);
|
||
btn.style.display = '';
|
||
}
|
||
|
||
input.addEventListener('keydown', function (e) {
|
||
if (e.key === 'Enter') {
|
||
const name = input.value.trim();
|
||
const remoteId = select ? select.value : '';
|
||
cleanup();
|
||
if (name) createNewSession(name, remoteId);
|
||
} else if (e.key === 'Escape') {
|
||
cleanup();
|
||
}
|
||
});
|
||
|
||
input.addEventListener('blur', function() {
|
||
setTimeout(function() {
|
||
// Don't close if focus moved to the device select dropdown
|
||
if (select && document.activeElement === select) return;
|
||
cleanup();
|
||
}, 150);
|
||
});
|
||
|
||
if (select) {
|
||
select.addEventListener('blur', function() {
|
||
setTimeout(function() {
|
||
// Don't close if focus moved back to the name input
|
||
if (document.activeElement === input) return;
|
||
cleanup();
|
||
}, 150);
|
||
});
|
||
select.addEventListener('keydown', function(e) {
|
||
if (e.key === 'Escape') { cleanup(); }
|
||
});
|
||
}
|
||
|
||
btn.style.display = 'none';
|
||
if (select) btn.parentNode.insertBefore(select, btn);
|
||
btn.parentNode.insertBefore(input, btn);
|
||
input.focus();
|
||
}
|
||
|
||
/**
|
||
* Show a fixed-position input overlay for creating a new session from the mobile FAB.
|
||
* Unlike showNewSessionInput (which inserts inline into btn.parentNode), this renders
|
||
* a fixed-position overlay appended directly to document.body — ensuring it is always
|
||
* visible on mobile regardless of body/view overflow:hidden constraints.
|
||
*/
|
||
function showFabSessionInput() {
|
||
if (document.querySelector('.fab-input-overlay')) return;
|
||
|
||
const fab = $('new-session-fab');
|
||
|
||
const overlay = document.createElement('div');
|
||
overlay.className = 'fab-input-overlay';
|
||
|
||
const select = _createDeviceSelect();
|
||
const input = _createSessionInput();
|
||
|
||
if (select) overlay.appendChild(select);
|
||
overlay.appendChild(input);
|
||
|
||
function cleanup() {
|
||
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
||
if (fab) fab.style.display = '';
|
||
}
|
||
|
||
input.addEventListener('keydown', function(e) {
|
||
if (e.key === 'Enter') {
|
||
const name = input.value.trim();
|
||
const remoteId = select ? select.value : '';
|
||
cleanup();
|
||
if (name) createNewSession(name, remoteId);
|
||
} else if (e.key === 'Escape') {
|
||
cleanup();
|
||
}
|
||
});
|
||
|
||
input.addEventListener('blur', function() {
|
||
setTimeout(function() {
|
||
// Don't close if focus moved to the device select dropdown
|
||
if (select && document.activeElement === select) return;
|
||
cleanup();
|
||
}, 150);
|
||
});
|
||
|
||
if (select) {
|
||
select.addEventListener('blur', function() {
|
||
setTimeout(function() {
|
||
// Don't close if focus moved back to the name input
|
||
if (document.activeElement === input) return;
|
||
cleanup();
|
||
}, 150);
|
||
});
|
||
select.addEventListener('keydown', function(e) {
|
||
if (e.key === 'Escape') { cleanup(); }
|
||
});
|
||
}
|
||
|
||
if (fab) fab.style.display = 'none';
|
||
document.body.appendChild(overlay);
|
||
input.focus();
|
||
}
|
||
|
||
/**
|
||
* Create a new tmux session via POST /api/sessions.
|
||
* Shows a toast, then polls _currentSessions until the session name appears
|
||
* (or times out after 30s) before calling openSession — this handles commands
|
||
* that take time to create the tmux session (e.g. cloning repos, setup scripts).
|
||
* If auto_open_created is false in server settings, skips the auto-open.
|
||
* @param {string} name - The session name to create.
|
||
* @returns {Promise<void>}
|
||
*/
|
||
async function createNewSession(name, remoteId) {
|
||
var deviceId = remoteId || ''; // Accept device_id string (was integer index in old protocol)
|
||
try {
|
||
var endpoint = deviceId ? '/api/federation/' + encodeURIComponent(deviceId) + '/sessions' : '/api/sessions';
|
||
const res = await api('POST', endpoint, { name });
|
||
const data = await res.json();
|
||
const sessionName = data.name || name;
|
||
|
||
// Auto-add to active user view (not 'all' or 'hidden')
|
||
if (_activeView !== 'all' && _activeView !== 'hidden') {
|
||
var views = (_serverSettings && _serverSettings.views) || [];
|
||
var viewIdx = -1;
|
||
for (var vi = 0; vi < views.length; vi++) {
|
||
if (views[vi].name === _activeView) { viewIdx = vi; break; }
|
||
}
|
||
if (viewIdx >= 0) {
|
||
var newSessionKey = remoteId ? (remoteId + ':' + sessionName) : sessionName;
|
||
if (!remoteId && _localDeviceId) {
|
||
newSessionKey = _localDeviceId + ':' + sessionName;
|
||
}
|
||
var updatedViews = JSON.parse(JSON.stringify(views));
|
||
if (!updatedViews[viewIdx].sessions.includes(newSessionKey)) {
|
||
updatedViews[viewIdx].sessions.push(newSessionKey);
|
||
api('PATCH', '/api/settings', { views: updatedViews }).catch(function(err) {
|
||
console.warn('[createNewSession] auto-add to view failed:', err);
|
||
});
|
||
if (_serverSettings) _serverSettings.views = updatedViews;
|
||
}
|
||
}
|
||
}
|
||
|
||
showToast('Creating session \'' + sessionName + '\'…');
|
||
|
||
// Inject a loading placeholder tile so the user sees feedback immediately
|
||
var loadingTile = null;
|
||
var grid = document.getElementById('session-grid');
|
||
if (grid) {
|
||
loadingTile = document.createElement('div');
|
||
loadingTile.className = 'session-tile tile--loading';
|
||
loadingTile.id = 'loading-tile-' + sessionName;
|
||
loadingTile.innerHTML =
|
||
'<div class="tile-header"><span class="tile-name">' + escapeHtml(sessionName) + '</span>' +
|
||
'<span class="tile-meta">Creating...</span></div>' +
|
||
'<div class="tile-body"><pre class="loading-pulse"></pre></div>';
|
||
grid.appendChild(loadingTile);
|
||
}
|
||
|
||
function removeLoadingTile() {
|
||
var tile = document.getElementById('loading-tile-' + sessionName);
|
||
if (tile) tile.remove();
|
||
}
|
||
|
||
const ss = _serverSettings || {};
|
||
if (ss.auto_open_created === false) {
|
||
// Auto-open disabled — just do one refresh
|
||
await pollSessions();
|
||
removeLoadingTile();
|
||
return;
|
||
}
|
||
|
||
// Compute expectedKey: for remote sessions, use 'deviceId:sessionName' (sessionKey format)
|
||
var expectedKey = deviceId ? (deviceId + ':' + sessionName) : sessionName;
|
||
|
||
// Poll until the session appears in _currentSessions.
|
||
// Check immediately first (session may already be in cache), then retry
|
||
// at short intervals. This eliminates the old 2-second dead-wait.
|
||
var attempts = 0;
|
||
var maxAttempts = 30;
|
||
async function checkForSession() {
|
||
attempts++;
|
||
await pollSessions();
|
||
var found = _currentSessions && _currentSessions.find(function(s) {
|
||
return (s.sessionKey || s.name) === expectedKey;
|
||
});
|
||
if (found) {
|
||
removeLoadingTile();
|
||
showToast('Session \'' + sessionName + '\' ready');
|
||
openSession(sessionName, { remoteId: deviceId });
|
||
} else if (attempts >= maxAttempts) {
|
||
removeLoadingTile();
|
||
showToast('Session \'' + sessionName + '\' is taking longer than expected');
|
||
} else {
|
||
// Short retry: 500ms for first few attempts, then 2s
|
||
setTimeout(checkForSession, attempts <= 5 ? 500 : 2000);
|
||
}
|
||
}
|
||
checkForSession();
|
||
} catch (err) {
|
||
showToast(err.message || 'Failed to create session');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Kill a tmux session by name via DELETE /api/sessions/{name}.
|
||
* For remote sessions, proxies through the federation delete route.
|
||
* Shows a confirmation dialog before killing. Refreshes the session list on success.
|
||
* @param {string} name - The session name to kill.
|
||
* @param {string} [remoteId] - Remote instance index (empty or absent for local).
|
||
*/
|
||
function killSession(name, remoteId) {
|
||
var endpoint = remoteId
|
||
? '/api/federation/' + encodeURIComponent(remoteId) + '/sessions/' + encodeURIComponent(name)
|
||
: '/api/sessions/' + encodeURIComponent(name);
|
||
api('DELETE', endpoint)
|
||
.then(function() {
|
||
showToast('Session \'' + name + '\' killed');
|
||
// If we deleted the session we're currently viewing, return to dashboard
|
||
if (_viewingSession === name && (_viewingRemoteId ?? '') === (remoteId || '')) {
|
||
closeSession();
|
||
}
|
||
pollSessions();
|
||
})
|
||
.catch(function(err) {
|
||
showToast('Failed to kill session: ' + (err.message || 'unknown error'));
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Bind all static (once-only) event listeners for the app UI.
|
||
* Called once after restoreState() resolves.
|
||
*/
|
||
function bindStaticEventListeners() {
|
||
// Delegated ⋮ options button handler (tiles are re-rendered each poll)
|
||
document.addEventListener('click', function(e) {
|
||
var optionsBtn = e.target.closest && e.target.closest('.tile-options-btn');
|
||
if (!optionsBtn) return;
|
||
openFlyoutMenu(optionsBtn);
|
||
});
|
||
|
||
on($('back-btn'), 'click', closeSession);
|
||
|
||
// View dropdown — <view-dropdown> component event listeners
|
||
var headerDropdown = $('view-dropdown');
|
||
if (headerDropdown) {
|
||
headerDropdown.addEventListener('dropdown-toggle', function() { toggleViewDropdown(); });
|
||
headerDropdown.addEventListener('dropdown-close', function() { closeViewDropdown(); });
|
||
headerDropdown.addEventListener('view-switch', function(e) { switchView(e.detail.view); });
|
||
headerDropdown.addEventListener('view-new-input', function() { showNewViewInput(); });
|
||
headerDropdown.addEventListener('view-manage', function() { closeViewDropdown(); openManageViewPanel(); });
|
||
headerDropdown.addEventListener('view-manage-all', function() { closeViewDropdown(); openSettings(); switchSettingsTab('views'); });
|
||
}
|
||
|
||
// Sidebar view dropdown — <view-dropdown> component event listeners
|
||
var sidebarDropdown = $('sidebar-view-dropdown');
|
||
if (sidebarDropdown) {
|
||
sidebarDropdown.addEventListener('dropdown-toggle', function() { toggleSidebarViewDropdown(); });
|
||
sidebarDropdown.addEventListener('dropdown-close', function() { if (sidebarDropdown) sidebarDropdown.open = false; });
|
||
sidebarDropdown.addEventListener('view-switch', function(e) {
|
||
switchView(e.detail.view);
|
||
if (sidebarDropdown) sidebarDropdown.open = false;
|
||
});
|
||
sidebarDropdown.addEventListener('view-new-input', function() { showSidebarNewViewInput(); });
|
||
sidebarDropdown.addEventListener('view-manage', function() {
|
||
if (sidebarDropdown) sidebarDropdown.open = false;
|
||
openManageViewPanel();
|
||
});
|
||
sidebarDropdown.addEventListener('view-manage-all', function() {
|
||
if (sidebarDropdown) sidebarDropdown.open = false;
|
||
openSettings();
|
||
switchSettingsTab('views');
|
||
});
|
||
}
|
||
|
||
var newSessionBtn = $('new-session-btn');
|
||
if (newSessionBtn) on(newSessionBtn, 'click', function() { showNewSessionInput(newSessionBtn); });
|
||
var sidebarNewSessionBtn = $('sidebar-new-session-btn');
|
||
if (sidebarNewSessionBtn) on(sidebarNewSessionBtn, 'click', function() { showNewSessionInput(sidebarNewSessionBtn); });
|
||
var newSessionFab = $('new-session-fab');
|
||
if (newSessionFab) on(newSessionFab, 'click', showFabSessionInput);
|
||
on($('sidebar-toggle-btn'), 'click', toggleSidebar);
|
||
on($('sidebar-collapse-btn'), 'click', toggleSidebar);
|
||
bindSidebarClickAway();
|
||
document.addEventListener('keydown', handleGlobalKeydown);
|
||
var sessionPillEl = $('session-pill');
|
||
if (sessionPillEl) sessionPillEl.addEventListener('pill-click', openBottomSheet);
|
||
// Bottom sheet — component handles backdrop internally; listen for custom events
|
||
var bottomSheet = $('bottom-sheet');
|
||
if (bottomSheet) {
|
||
bottomSheet.addEventListener('sheet-select', function(e) {
|
||
closeBottomSheet();
|
||
openSession(e.detail.name, { remoteId: e.detail.remoteId });
|
||
});
|
||
bottomSheet.addEventListener('sheet-close', function() {
|
||
closeBottomSheet();
|
||
});
|
||
}
|
||
|
||
// Hover preview — component handles click internally; listen for preview-click
|
||
var hoverPreview = $('hover-preview');
|
||
if (hoverPreview) {
|
||
hoverPreview.addEventListener('preview-click', function(e) {
|
||
hidePreview();
|
||
var name = e.detail.name;
|
||
if (name) {
|
||
var session = _currentSessions && _currentSessions.find(function(s) { return s.name === name; });
|
||
openSession(name, { remoteId: (session != null && session.remoteId != null) ? session.remoteId : '' });
|
||
}
|
||
});
|
||
}
|
||
|
||
// Settings dialog bindings
|
||
on($('view-mode-btn'), 'click', cycleViewMode);
|
||
on($('settings-btn'), 'click', openSettings);
|
||
on($('settings-btn-expanded'), 'click', openSettings);
|
||
on($('settings-close-btn'), 'click', closeSettings);
|
||
on($('settings-backdrop'), 'click', closeSettings);
|
||
const settingsDialog = $('settings-dialog');
|
||
if (settingsDialog) {
|
||
settingsDialog.addEventListener('cancel', closeSettings);
|
||
// Click on the ::backdrop area (outside dialog content) dismisses settings
|
||
settingsDialog.addEventListener('click', function(e) {
|
||
if (e.target === settingsDialog) closeSettings();
|
||
});
|
||
}
|
||
document.querySelectorAll('.settings-tab').forEach(function(tab) {
|
||
on(tab, 'click', function() { switchSettingsTab(tab.dataset.tab); });
|
||
});
|
||
|
||
// Hover preview — delegated on grid container (tiles are re-rendered each poll)
|
||
var gridEl = $('session-grid');
|
||
if (gridEl && !('ontouchstart' in window)) { // desktop only
|
||
gridEl.addEventListener('mouseenter', function (e) {
|
||
var tile = e.target.closest('.session-tile');
|
||
if (!tile) return;
|
||
if (_previewTimer) { clearTimeout(_previewTimer); _previewTimer = null; }
|
||
var name = tile.dataset.session;
|
||
var delay = getDisplaySettings().hoverPreviewDelay;
|
||
if (delay > 0) _previewTimer = setTimeout(function () { showPreview(name); }, delay);
|
||
}, true); // useCapture: true for delegation with mouseenter
|
||
|
||
gridEl.addEventListener('mouseleave', function (e) {
|
||
var tile = e.target.closest('.session-tile');
|
||
if (!tile) return;
|
||
hidePreview();
|
||
}, true);
|
||
}
|
||
|
||
// Hover preview — delegated on sidebar list (items are re-rendered each poll)
|
||
var sidebarListEl = $('sidebar-list');
|
||
if (sidebarListEl && !('ontouchstart' in window)) { // desktop only
|
||
sidebarListEl.addEventListener('mouseenter', function (e) {
|
||
var item = e.target.closest('.sidebar-item');
|
||
if (!item) return;
|
||
if (_previewTimer) { clearTimeout(_previewTimer); _previewTimer = null; }
|
||
var name = item.dataset.session;
|
||
var delay = getDisplaySettings().hoverPreviewDelay;
|
||
if (delay > 0) _previewTimer = setTimeout(function () { showPreview(name); }, delay);
|
||
}, true);
|
||
|
||
sidebarListEl.addEventListener('mouseleave', function (e) {
|
||
var item = e.target.closest('.sidebar-item');
|
||
if (!item) return;
|
||
hidePreview();
|
||
}, true);
|
||
}
|
||
|
||
// Display settings — bind change events for immediate apply
|
||
on($('setting-font-size'), 'change', onDisplaySettingChange);
|
||
on($('setting-hover-delay'), 'change', onDisplaySettingChange);
|
||
on($('setting-grid-columns'), 'change', onDisplaySettingChange);
|
||
on($('setting-show-device-badges'), 'change', onDisplaySettingChange);
|
||
on($('setting-show-hover-preview'), 'change', onDisplaySettingChange);
|
||
on($('setting-activity-indicator'), 'change', onDisplaySettingChange);
|
||
on($('setting-view-mode'), 'change', function() {
|
||
var el = $('setting-view-mode');
|
||
if (el) {
|
||
saveGridViewMode(el.value);
|
||
renderGrid(_currentSessions || []);
|
||
}
|
||
});
|
||
|
||
|
||
// Sessions settings — bind change events for server-side persistence
|
||
on($('setting-default-session'), 'change', function() {
|
||
var el = $('setting-default-session');
|
||
if (el) patchServerSetting('default_session', el.value);
|
||
});
|
||
on($('setting-sort-order'), 'change', function() {
|
||
var el = $('setting-sort-order');
|
||
if (el) patchServerSetting('sort_order', el.value);
|
||
});
|
||
on($('setting-window-size-largest'), 'change', function() {
|
||
var el = $('setting-window-size-largest');
|
||
if (el) patchServerSetting('window_size_largest', el.checked);
|
||
});
|
||
on($('setting-auto-open'), 'change', function() {
|
||
var el = $('setting-auto-open');
|
||
if (el) patchServerSetting('auto_open_created', el.checked);
|
||
});
|
||
|
||
// Notifications settings — bell sound toggle persists to server settings
|
||
on($('setting-bell-sound'), 'change', function() {
|
||
if (_serverSettings) _serverSettings.bellSound = this.checked;
|
||
patchServerSetting('bellSound', this.checked);
|
||
});
|
||
|
||
// Notifications settings — permission request button
|
||
on($('notification-request-btn'), 'click', function() {
|
||
if (typeof Notification === 'undefined') return;
|
||
Notification.requestPermission().then(function(permission) {
|
||
_notificationPermission = permission;
|
||
// Update UI state
|
||
const statusEl = $('notification-status-text');
|
||
const reqBtn = $('notification-request-btn');
|
||
if (statusEl && reqBtn) {
|
||
_updateNotificationUI(statusEl, reqBtn, permission);
|
||
}
|
||
}).catch(function(err) {
|
||
console.error('Notification.requestPermission() failed:', err);
|
||
});
|
||
});
|
||
|
||
// Commands tab — create template textarea with 500ms debounce
|
||
var _templateDebounceTimer;
|
||
on($('setting-template'), 'input', function() {
|
||
clearTimeout(_templateDebounceTimer);
|
||
var val = this.value;
|
||
_templateDebounceTimer = setTimeout(function() {
|
||
patchServerSetting('new_session_template', val);
|
||
}, 500);
|
||
});
|
||
|
||
// Commands tab — create template reset button restores default
|
||
on($('setting-template-reset'), 'click', function() {
|
||
var el = $('setting-template');
|
||
if (el) el.value = NEW_SESSION_DEFAULT_TEMPLATE;
|
||
patchServerSetting('new_session_template', NEW_SESSION_DEFAULT_TEMPLATE);
|
||
});
|
||
|
||
// Multi-Device tab — enable/disable toggle
|
||
on($('setting-multi-device-enabled'), 'change', function() {
|
||
var enabled = this.checked;
|
||
_updateMultiDeviceFieldsState(enabled);
|
||
patchServerSetting('multi_device_enabled', enabled);
|
||
});
|
||
|
||
// Multi-Device tab — device name with 500ms debounce; updates document.title immediately
|
||
var _deviceNameDebounceTimer;
|
||
on($('setting-device-name'), 'input', function() {
|
||
clearTimeout(_deviceNameDebounceTimer);
|
||
var val = this.value;
|
||
// Update cached setting immediately so updatePageTitle() sees the new value
|
||
if (_serverSettings) _serverSettings.device_name = val;
|
||
updatePageTitle();
|
||
_deviceNameDebounceTimer = setTimeout(function() {
|
||
patchServerSetting('device_name', val);
|
||
}, 500);
|
||
});
|
||
|
||
// Multi-Device tab — federation generate key button
|
||
on($('federation-generate-btn'), 'click', function() {
|
||
api('POST', '/api/federation/generate-key')
|
||
.then(function(res) { return res.json(); })
|
||
.then(function(data) {
|
||
var displayEl = $('federation-key-display');
|
||
if (displayEl && data && data.key) {
|
||
displayEl.textContent = data.key;
|
||
displayEl.classList.add('settings-key-display--visible');
|
||
}
|
||
showToast('Federation key generated');
|
||
}).catch(function() {
|
||
showToast('Failed to generate federation key');
|
||
});
|
||
});
|
||
|
||
// Multi-Device tab — add remote instance button
|
||
on($('add-remote-instance-btn'), 'click', function() {
|
||
var container = $('setting-remote-instances');
|
||
if (container) {
|
||
container.appendChild(_buildRemoteInstanceRow('', '', ''));
|
||
}
|
||
});
|
||
|
||
// Multi-Device tab — delegated remove handler on remote instances container
|
||
var remoteInstancesContainer = $('setting-remote-instances');
|
||
if (remoteInstancesContainer) {
|
||
remoteInstancesContainer.addEventListener('click', function(e) {
|
||
var removeBtn = e.target.closest && e.target.closest('.settings-remote-remove');
|
||
if (!removeBtn) return;
|
||
var row = removeBtn.closest('.settings-remote-row');
|
||
if (row) {
|
||
row.remove();
|
||
_saveRemoteInstances();
|
||
}
|
||
});
|
||
|
||
// Delegated input save with debounce for remote instance URL/name fields
|
||
let _remoteDebounceTimer;
|
||
remoteInstancesContainer.addEventListener('input', function(e) {
|
||
var input = e.target.closest && e.target.closest('.settings-remote-url, .settings-remote-name, .settings-remote-key');
|
||
if (!input) return;
|
||
clearTimeout(_remoteDebounceTimer);
|
||
_remoteDebounceTimer = setTimeout(function() {
|
||
_saveRemoteInstances();
|
||
}, 500);
|
||
});
|
||
}
|
||
|
||
// Commands tab — delete template textarea with 500ms debounce
|
||
var _deleteTemplateDebounceTimer;
|
||
on($('setting-delete-template'), 'input', function() {
|
||
clearTimeout(_deleteTemplateDebounceTimer);
|
||
var val = this.value;
|
||
_deleteTemplateDebounceTimer = setTimeout(function() {
|
||
patchServerSetting('delete_session_template', val);
|
||
}, 500);
|
||
});
|
||
|
||
// Commands tab — delete template reset button restores default
|
||
on($('setting-delete-template-reset'), 'click', function() {
|
||
var el = $('setting-delete-template');
|
||
if (el) el.value = DELETE_SESSION_DEFAULT_TEMPLATE;
|
||
patchServerSetting('delete_session_template', DELETE_SESSION_DEFAULT_TEMPLATE);
|
||
});
|
||
}
|
||
|
||
// ─── Test-only helpers ────────────────────────────────────────────────────────
|
||
|
||
/** Test-only: set _currentSessions directly. */
|
||
function _setCurrentSessions(sessions) {
|
||
_currentSessions = sessions;
|
||
}
|
||
|
||
/** Test-only: set _viewMode directly. */
|
||
function _setViewMode(mode) {
|
||
_viewMode = mode;
|
||
}
|
||
|
||
/** Test-only: set _serverSettings directly. */
|
||
function _setServerSettings(settings) {
|
||
_serverSettings = settings;
|
||
}
|
||
|
||
/** Test-only: get _serverSettings. */
|
||
function _getServerSettings() {
|
||
return _serverSettings;
|
||
}
|
||
|
||
/** Test-only: get _gridViewMode. */
|
||
function _getGridViewMode() {
|
||
return _gridViewMode;
|
||
}
|
||
|
||
/** Test-only: set _gridViewMode directly. */
|
||
function _setGridViewMode(mode) {
|
||
// 'filtered' was removed in the Views feature — fall back to 'flat'
|
||
if (mode === 'filtered') mode = 'flat';
|
||
_gridViewMode = mode;
|
||
}
|
||
|
||
/** Test-only: set _activeFilterDevice directly. */
|
||
function _setActiveFilterDevice(device) {
|
||
_activeFilterDevice = device;
|
||
}
|
||
|
||
/** Test-only: get current _activeView value. */
|
||
function _getActiveView() { return _activeView; }
|
||
|
||
/** Test-only: set _activeView directly. */
|
||
function _setActiveView(view) { _activeView = view; }
|
||
|
||
// Recalculate fit layout on window resize
|
||
window.addEventListener('resize', function() {
|
||
var ds = getDisplaySettings();
|
||
if ((ds.viewMode || 'auto') === 'fit') {
|
||
var grid = document.getElementById('session-grid');
|
||
if (grid) applyFitLayout(grid);
|
||
}
|
||
});
|
||
|
||
document.addEventListener('DOMContentLoaded', async function() {
|
||
initDeviceId();
|
||
|
||
// Load ALL settings (now includes display + sidebar) before first render
|
||
await loadServerSettings();
|
||
|
||
// Cache local device_id from /api/instance-info for session key construction
|
||
api('GET', '/api/instance-info').then(function(res) {
|
||
return res.json();
|
||
}).then(function(info) {
|
||
if (info && info.device_id) _localDeviceId = info.device_id;
|
||
}).catch(function() { /* non-critical — local session key falls back to plain name */ });
|
||
|
||
var _initDs = getDisplaySettings();
|
||
applyDisplaySettings(_initDs);
|
||
_gridViewMode = loadGridViewMode();
|
||
|
||
// Initialize view mode button title
|
||
var vmBtn = document.getElementById('view-mode-btn');
|
||
if (vmBtn) vmBtn.title = 'View: ' + (_initDs.viewMode || 'auto');
|
||
|
||
document.addEventListener('keydown', trackInteraction);
|
||
document.addEventListener('click', trackInteraction);
|
||
document.addEventListener('touchstart', trackInteraction);
|
||
|
||
restoreState()
|
||
.then(function() {
|
||
startPolling();
|
||
updatePageTitle();
|
||
startHeartbeat();
|
||
bindStaticEventListeners();
|
||
renderViewDropdown();
|
||
renderSidebarViewDropdown();
|
||
})
|
||
.catch(function(err) {
|
||
console.error('[init] restoreState failed, retrying in 5s:', err);
|
||
setTimeout(function() { startPolling(); }, POLL_MS);
|
||
});
|
||
});
|
||
|
||
// Conditional CommonJS export — must remain at the very bottom of this file.
|
||
if (typeof module !== 'undefined' && module.exports) {
|
||
module.exports = {
|
||
formatTimestamp,
|
||
sessionPriority,
|
||
sortByPriority,
|
||
filterByQuery,
|
||
detectBellTransitions,
|
||
generateDeviceId,
|
||
buildHeartbeatPayload,
|
||
setConnectionStatus,
|
||
pollSessions,
|
||
startPolling,
|
||
escapeHtml,
|
||
buildTileHTML,
|
||
buildSidebarHTML,
|
||
getVisibleSessions,
|
||
renderSidebar,
|
||
initSidebar,
|
||
toggleSidebar,
|
||
bindSidebarClickAway,
|
||
renderGrid,
|
||
renderGroupedGrid: function renderGroupedGrid() {},
|
||
requestNotificationPermission,
|
||
handleBellTransitions,
|
||
sendHeartbeat,
|
||
startHeartbeat,
|
||
_resetHeartbeatTimer,
|
||
showToast,
|
||
updatePillBell,
|
||
openSession,
|
||
closeSession,
|
||
_setViewingSession,
|
||
handleGlobalKeydown,
|
||
bindStaticEventListeners,
|
||
openBottomSheet,
|
||
closeBottomSheet,
|
||
renderSheetList,
|
||
updateSessionPill,
|
||
updatePageTitle,
|
||
updateFaviconBadge,
|
||
// ANSI color rendering
|
||
ansiToHtml,
|
||
ansiParamsToStyle,
|
||
ansi256Color,
|
||
// Hover preview popover
|
||
showPreview,
|
||
hidePreview,
|
||
// Settings
|
||
getDisplaySettings,
|
||
applyDisplaySettings,
|
||
loadGridViewMode,
|
||
saveGridViewMode,
|
||
applyFitLayout,
|
||
cycleViewMode,
|
||
onDisplaySettingChange,
|
||
openSettings,
|
||
closeSettings,
|
||
switchSettingsTab,
|
||
// Server settings
|
||
loadServerSettings,
|
||
patchServerSetting,
|
||
// Fetch wrapper
|
||
api,
|
||
// Header + button with inline name input
|
||
_createDeviceSelect,
|
||
showNewSessionInput,
|
||
showFabSessionInput,
|
||
createNewSession,
|
||
// Kill session
|
||
killSession,
|
||
// Manage View panel
|
||
openManageViewPanel,
|
||
closeManageViewPanel,
|
||
renderManageViewList,
|
||
// Flyout menu
|
||
openFlyoutMenu,
|
||
closeFlyoutMenu,
|
||
// Filter bar
|
||
renderFilterBar: function renderFilterBar() {},
|
||
// View dropdown
|
||
renderViewDropdown,
|
||
toggleViewDropdown,
|
||
closeViewDropdown,
|
||
showNewViewInput,
|
||
switchView,
|
||
// Sidebar view dropdown
|
||
renderSidebarViewDropdown,
|
||
toggleSidebarViewDropdown,
|
||
showSidebarNewViewInput,
|
||
// Manage Views settings tab
|
||
renderViewsSettingsTab,
|
||
_saveViewsAndRerender,
|
||
// v2 visibility helpers (Phase 1)
|
||
isHidden,
|
||
filterVisible,
|
||
visibleCount,
|
||
// Operation layer (Phase 2) — pure data ops
|
||
_opAddMembership,
|
||
_opRemoveMembership,
|
||
_opRemoveFromAllViews,
|
||
_opHide,
|
||
_opUnhide,
|
||
_cloneOpState,
|
||
// Operation layer (Phase 2) — user-intent ops
|
||
hideSessionOp,
|
||
unhideSessionOp,
|
||
addSessionToViewOp,
|
||
removeSessionFromViewOp,
|
||
// Federation tiles
|
||
buildStatusTileHTML,
|
||
// Constants
|
||
NEW_SESSION_DEFAULT_TEMPLATE,
|
||
DELETE_SESSION_DEFAULT_TEMPLATE,
|
||
// Test-only helpers
|
||
_setCurrentSessions,
|
||
_setViewMode,
|
||
_setServerSettings,
|
||
_getServerSettings,
|
||
_getGridViewMode,
|
||
_setGridViewMode,
|
||
_setActiveFilterDevice,
|
||
_getActiveView,
|
||
_setActiveView,
|
||
};
|
||
}
|