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