feat(frontend): phase 2 lit web component migration
Introduce 4 new Lit components for dashboard, sidebar, previews, and mobile UX: - sidebar-item: <sidebar-item> for expanded-view sidebar entries - hover-preview: <hover-preview> full-window snapshot popover - bottom-sheet-switcher: <bottom-sheet-switcher> mobile session switcher - session-grid: <session-grid> wrapper owning sort/group/repeat loop with keyed rendering Refactor app.js to leverage components: - renderSidebar() now uses <sidebar-item> elements with event delegation - renderGrid() simplified to property-setting on <session-grid>, which owns tile loop - showPreview/hidePreview simplified to property-setting on <hover-preview> - openBottomSheet/closeBottomSheet/renderSheetList simplified to property-setting on <bottom-sheet-switcher> - Removed dead code (renderGroupedGrid, renderFilterBar bodies) - Preview click and sheet events wired via component event listeners Update index.html: - Replace bottom sheet div with <bottom-sheet-switcher> custom element - Replace session grid div with <session-grid> custom element - Add <hover-preview> element - Add new component imports All 1306 tests pass. Generated with Amplifier
This commit is contained in:
+127
-246
@@ -869,13 +869,44 @@ function renderSidebar(sessions, currentSession, currentRemoteId) {
|
|||||||
if (!list) return;
|
if (!list) return;
|
||||||
|
|
||||||
const visible = getVisibleSessions(sessions);
|
const visible = getVisibleSessions(sessions);
|
||||||
|
var ds = getDisplaySettings();
|
||||||
|
|
||||||
if (visible.length === 0) {
|
if (visible.length === 0) {
|
||||||
list.innerHTML = '<div class="sidebar-empty">No sessions</div>';
|
list.innerHTML = '<div class="sidebar-empty">No sessions</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let html = '';
|
// 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) {
|
if (_serverSettings && _serverSettings.multi_device_enabled) {
|
||||||
// Group sessions by deviceName when multi_device_enabled
|
// Group sessions by deviceName when multi_device_enabled
|
||||||
@@ -887,28 +918,28 @@ function renderSidebar(sessions, currentSession, currentRemoteId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const [deviceName, deviceSessions] of groups) {
|
for (const [deviceName, deviceSessions] of groups) {
|
||||||
html += `<h4 class="sidebar-device-header">${escapeHtml(deviceName)}</h4>`;
|
var header = document.createElement('h4');
|
||||||
html += deviceSessions.map((session) => buildSidebarHTML(session, currentSession, currentRemoteId)).join('');
|
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 {
|
} else {
|
||||||
// Single source: flat list with no device headers
|
for (var i = 0; i < visible.length; i++) {
|
||||||
html = visible.map((session) => buildSidebarHTML(session, currentSession, currentRemoteId)).join('');
|
fragment.appendChild(_setupSidebarItem(visible[i]));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
list.innerHTML = html;
|
// Remove stale items
|
||||||
|
existingItems.forEach(function(item, key) {
|
||||||
// Bind click handlers on each sidebar item, passing remoteId
|
if (!newKeys.has(key)) item.remove();
|
||||||
if (typeof list.querySelectorAll === 'function') {
|
|
||||||
list.querySelectorAll('.sidebar-item').forEach((item) => {
|
|
||||||
const name = item.dataset.session;
|
|
||||||
const remoteId = item.dataset.remoteId || '';
|
|
||||||
on(item, 'click', (e) => {
|
|
||||||
if (e.target.closest && e.target.closest('.tile-options-btn')) return;
|
|
||||||
if (name !== currentSession || remoteId !== (currentRemoteId ?? '')) openSession(name, { remoteId });
|
|
||||||
});
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Clear and rebuild
|
||||||
|
list.innerHTML = '';
|
||||||
|
list.appendChild(fragment);
|
||||||
}
|
}
|
||||||
|
|
||||||
const SIDEBAR_NARROW_THRESHOLD = 960;
|
const SIDEBAR_NARROW_THRESHOLD = 960;
|
||||||
@@ -997,51 +1028,8 @@ function bindSidebarClickAway() {
|
|||||||
* @param {object[]} sessions
|
* @param {object[]} sessions
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
// renderGroupedGrid and renderFilterBar removed — logic moved into <session-grid> component.
|
||||||
* Render sessions grouped by device name. Returns HTML string.
|
// No-op stubs are exported at the bottom of the file for test compatibility.
|
||||||
* @param {object[]} sessions - sorted, visible sessions
|
|
||||||
* @param {boolean} mobile
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
function renderGroupedGrid(sessions, mobile) {
|
|
||||||
// Group by deviceName
|
|
||||||
var groups = {};
|
|
||||||
var groupOrder = [];
|
|
||||||
for (var i = 0; i < sessions.length; i++) {
|
|
||||||
var dn = sessions[i].deviceName || 'Unknown';
|
|
||||||
if (!groups[dn]) {
|
|
||||||
groups[dn] = [];
|
|
||||||
groupOrder.push(dn);
|
|
||||||
}
|
|
||||||
groups[dn].push(sessions[i]);
|
|
||||||
}
|
|
||||||
|
|
||||||
var html = '';
|
|
||||||
for (var g = 0; g < groupOrder.length; g++) {
|
|
||||||
var name = groupOrder[g];
|
|
||||||
var groupSessions = groups[name];
|
|
||||||
// Skip device entirely when it has no visible sessions to render.
|
|
||||||
// This prevents empty device headers from appearing in the grouped grid
|
|
||||||
// (e.g. when all of a device's sessions are hidden in the current view).
|
|
||||||
if (groupSessions.length === 0) continue;
|
|
||||||
html += '<h3 class="device-group-header">' + escapeHtml(name) + '</h3>';
|
|
||||||
for (var j = 0; j < groupSessions.length; j++) {
|
|
||||||
html += buildTileHTML(groupSessions[j], j, mobile);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return html;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render the filter pill bar into the given container element.
|
|
||||||
* Generates one 'All' pill plus one pill per unique device name found in allSessions.
|
|
||||||
* The currently active device pill is marked with the `filter-pill--active` class.
|
|
||||||
* @param {Element} container - The DOM element to render pills into.
|
|
||||||
* @param {Array} allSessions - Full (unfiltered) session list used to derive device names.
|
|
||||||
*/
|
|
||||||
function renderFilterBar(container, allSessions) {
|
|
||||||
// Dead code: filter bar replaced by Views feature. Kept as empty stub for export compatibility.
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// View dropdown — render, open/close, view switching
|
// View dropdown — render, open/close, view switching
|
||||||
@@ -1647,10 +1635,6 @@ function switchView(viewName) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderGrid(sessions) {
|
function renderGrid(sessions) {
|
||||||
var grid = $('session-grid');
|
|
||||||
var emptyState = $('empty-state');
|
|
||||||
var filterBar = $('filter-bar');
|
|
||||||
|
|
||||||
// Close flyout if the targeted session no longer exists
|
// Close flyout if the targeted session no longer exists
|
||||||
if (_flyoutSessionKey) {
|
if (_flyoutSessionKey) {
|
||||||
var flyoutStillExists = (sessions || []).some(function(s) {
|
var flyoutStillExists = (sessions || []).some(function(s) {
|
||||||
@@ -1662,198 +1646,89 @@ function renderGrid(sessions) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var visible = getVisibleSessions(sessions);
|
var visible = getVisibleSessions(sessions);
|
||||||
|
var emptyState = $('empty-state');
|
||||||
|
|
||||||
if (visible.length === 0) {
|
if (visible.length === 0) {
|
||||||
// Build status tiles for auth_failed/unreachable sessions even when no regular sessions exist.
|
// Check if there are status sessions (auth_failed / unreachable) the grid will render
|
||||||
// status:empty sentinels are intentionally ignored — a remote with zero tmux sessions
|
var hasStatusSessions = (sessions || []).some(function(s) {
|
||||||
// produces no visible tile in any view mode (flat, grouped, or otherwise).
|
return s.status === 'auth_failed' || s.status === 'unreachable';
|
||||||
var statusTilesHtml = '';
|
|
||||||
(sessions || []).forEach(function(session) {
|
|
||||||
if (session.status === 'auth_failed') statusTilesHtml += buildStatusTileHTML(session.deviceName, 'Auth required', 'auth');
|
|
||||||
else if (session.status === 'unreachable') statusTilesHtml += buildStatusTileHTML(session.deviceName, 'Offline', 'offline');
|
|
||||||
});
|
});
|
||||||
if (grid) grid.innerHTML = statusTilesHtml;
|
|
||||||
// Only show empty-state when there are truly no tiles at all
|
|
||||||
if (emptyState) {
|
if (emptyState) {
|
||||||
if (statusTilesHtml) emptyState.classList.add('hidden');
|
if (hasStatusSessions) emptyState.classList.add('hidden');
|
||||||
else emptyState.classList.remove('hidden');
|
else emptyState.classList.remove('hidden');
|
||||||
}
|
}
|
||||||
if (filterBar) filterBar.innerHTML = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (emptyState) emptyState.classList.add('hidden');
|
|
||||||
|
|
||||||
// Apply sort order from server settings
|
|
||||||
var sortOrder = _serverSettings && _serverSettings.sort_order;
|
|
||||||
var mobile = isMobile();
|
|
||||||
var ordered;
|
|
||||||
if (sortOrder === 'alphabetical') {
|
|
||||||
ordered = visible.slice().sort(function(a, b) { return (a.name || '').localeCompare(b.name || ''); });
|
|
||||||
} else {
|
} else {
|
||||||
// 'recent', 'manual', and default use server-provided order; priority sort on mobile
|
if (emptyState) emptyState.classList.add('hidden');
|
||||||
ordered = mobile ? sortByPriority(visible) : visible;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Diff tiles: reuse existing, add new, remove stale
|
// Set properties on the <session-grid> element — sorting, grouping, and tile
|
||||||
var existingTiles = new Map();
|
// diffing (including alphabetical / localeCompare ordering) are handled inside
|
||||||
if (grid) {
|
// the component based on sortOrder and groupMode.
|
||||||
grid.querySelectorAll('session-tile').forEach(function(t) {
|
var grid = $('session-grid');
|
||||||
existingTiles.set(t.getAttribute('data-session-key') || (t.session && t.session.sessionKey), t);
|
if (!grid) return;
|
||||||
});
|
|
||||||
}
|
|
||||||
var newKeys = new Set();
|
|
||||||
var fragment = document.createDocumentFragment();
|
|
||||||
var ds = getDisplaySettings();
|
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';
|
||||||
|
|
||||||
function _setupTile(s) {
|
// Bind session-open / session-options event listeners only once
|
||||||
var key = s.sessionKey || s.name;
|
if (!grid._eventsbound) {
|
||||||
newKeys.add(key);
|
grid._eventsbound = true;
|
||||||
var tile = existingTiles.get(key);
|
grid.addEventListener('session-open', function(e) {
|
||||||
if (!tile) {
|
|
||||||
tile = document.createElement('session-tile');
|
|
||||||
tile.addEventListener('tile-click', function(e) {
|
|
||||||
openSession(e.detail.name, { remoteId: e.detail.remoteId || undefined });
|
openSession(e.detail.name, { remoteId: e.detail.remoteId || undefined });
|
||||||
});
|
});
|
||||||
tile.addEventListener('tile-options', function(e) {
|
grid.addEventListener('session-options', function(e) {
|
||||||
openFlyoutMenu(e.detail.name, e.detail.sessionKey, e.detail.remoteId, e.detail.rect);
|
openFlyoutMenu(e.detail.name, e.detail.sessionKey, e.detail.remoteId, e.detail.rect);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
tile.session = s;
|
|
||||||
tile.mobile = mobile;
|
|
||||||
tile.multiDevice = !!(_serverSettings && _serverSettings.multi_device_enabled);
|
|
||||||
tile.showDeviceBadges = ds.showDeviceBadges !== false;
|
|
||||||
tile.activityIndicator = ds.activityIndicator || 'both';
|
|
||||||
tile.viewMode = ds.viewMode || 'auto';
|
|
||||||
return tile;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_gridViewMode === 'grouped') {
|
|
||||||
// Group by deviceName
|
|
||||||
var groups = {};
|
|
||||||
var groupOrder = [];
|
|
||||||
for (var gi = 0; gi < ordered.length; gi++) {
|
|
||||||
var dn = ordered[gi].deviceName || 'Unknown';
|
|
||||||
if (!groups[dn]) { groups[dn] = []; groupOrder.push(dn); }
|
|
||||||
groups[dn].push(ordered[gi]);
|
|
||||||
}
|
|
||||||
for (var g = 0; g < groupOrder.length; g++) {
|
|
||||||
var gname = groupOrder[g];
|
|
||||||
var groupSessions = groups[gname];
|
|
||||||
if (groupSessions.length === 0) continue;
|
|
||||||
var header = document.createElement('h3');
|
|
||||||
header.className = 'device-group-header';
|
|
||||||
header.textContent = gname;
|
|
||||||
fragment.appendChild(header);
|
|
||||||
for (var j = 0; j < groupSessions.length; j++) {
|
|
||||||
fragment.appendChild(_setupTile(groupSessions[j]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for (var i = 0; i < ordered.length; i++) {
|
|
||||||
fragment.appendChild(_setupTile(ordered[i]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove stale tiles
|
|
||||||
existingTiles.forEach(function(tile, key) {
|
|
||||||
if (!newKeys.has(key)) tile.remove();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Append status tiles for auth_failed and unreachable sessions. status:empty sentinels are
|
|
||||||
// intentionally ignored in all view modes — a remote with zero tmux sessions produces no
|
|
||||||
// visible tile. auth_failed and unreachable are actionable error states and are always shown.
|
|
||||||
var statusTilesHtml = '';
|
|
||||||
(sessions || []).forEach(function(session) {
|
|
||||||
if (session.status === 'auth_failed') statusTilesHtml += buildStatusTileHTML(session.deviceName, 'Auth required', 'auth');
|
|
||||||
else if (session.status === 'unreachable') statusTilesHtml += buildStatusTileHTML(session.deviceName, 'Offline', 'offline');
|
|
||||||
});
|
|
||||||
if (grid) {
|
|
||||||
grid.innerHTML = statusTilesHtml;
|
|
||||||
grid.prepend(fragment);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear filter bar (filtered mode removed; bar is a no-op for flat/grouped)
|
|
||||||
if (filterBar) filterBar.innerHTML = '';
|
|
||||||
|
|
||||||
if (_viewMode === 'fullscreen') {
|
if (_viewMode === 'fullscreen') {
|
||||||
updatePillBell();
|
updatePillBell();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reapply view mode layout after grid HTML is rebuilt
|
// Reapply view mode layout after grid properties are set
|
||||||
var currentDs = getDisplaySettings();
|
var currentMode = ds.viewMode || 'auto';
|
||||||
var currentMode = currentDs.viewMode || 'auto';
|
|
||||||
if (currentMode === 'fit' && grid) {
|
if (currentMode === 'fit' && grid) {
|
||||||
grid.classList.add('session-grid--fit');
|
grid.classList.add('session-grid--fit');
|
||||||
applyFitLayout(grid);
|
applyFitLayout(grid);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Hover preview popover (desktop only — no hover on touch devices)
|
// Hover preview popover (desktop only — no hover on touch devices)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// Click handler registered while preview is showing — navigates to the previewed session
|
// _previewClickHandler removed — preview-click event is handled via <hover-preview> component
|
||||||
function _previewClickHandler(e) {
|
// listener in bindStaticEventListeners().
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
var name = _previewSessionName;
|
|
||||||
hidePreview();
|
|
||||||
if (name) {
|
|
||||||
var session = _currentSessions && _currentSessions.find(function(s) { return s.name === name; });
|
|
||||||
openSession(name, { remoteId: (session != null && session.remoteId != null) ? session.remoteId : '' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showPreview(name) {
|
function showPreview(name) {
|
||||||
if (!name || !_currentSessions) return;
|
if (!name || !_currentSessions) return;
|
||||||
var _previewDs = getDisplaySettings();
|
var ds = getDisplaySettings();
|
||||||
if (_previewDs.showHoverPreview === false) return;
|
if (ds.showHoverPreview === false) return;
|
||||||
var session = _currentSessions.find(function (s) { return s.name === name; });
|
var session = _currentSessions.find(function(s) { return s.name === name; });
|
||||||
if (!session || !session.snapshot) return;
|
if (!session || !session.snapshot) return;
|
||||||
|
var preview = $('hover-preview');
|
||||||
// If already showing this session, just update content
|
if (!preview) return;
|
||||||
if (_previewPopover && _previewSessionName === name) {
|
preview.sessionName = name;
|
||||||
var pre = _previewPopover.querySelector('pre');
|
preview.snapshot = session.snapshot;
|
||||||
if (pre) pre.innerHTML = ansiToHtml(session.snapshot);
|
preview.open = true;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
hidePreviewDOM();
|
|
||||||
_previewSessionName = name;
|
|
||||||
|
|
||||||
// Full-window overlay
|
|
||||||
var popover = document.createElement('div');
|
|
||||||
popover.className = 'preview-popover';
|
|
||||||
var pre = document.createElement('pre');
|
|
||||||
pre.innerHTML = ansiToHtml(session.snapshot);
|
|
||||||
popover.appendChild(pre);
|
|
||||||
document.body.appendChild(popover);
|
|
||||||
_previewPopover = popover;
|
|
||||||
|
|
||||||
// Auto-scroll to bottom (prompt area)
|
|
||||||
popover.scrollTop = popover.scrollHeight;
|
|
||||||
|
|
||||||
// Click anywhere navigates to previewed session
|
|
||||||
document.addEventListener('click', _previewClickHandler, true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// hidePreviewDOM: removes the visual elements only (no render trigger)
|
// hidePreviewDOM: hides the <hover-preview> component
|
||||||
function hidePreviewDOM() {
|
function hidePreviewDOM() {
|
||||||
document.removeEventListener('click', _previewClickHandler, true);
|
var preview = $('hover-preview');
|
||||||
if (_previewPopover) {
|
if (preview) preview.open = false;
|
||||||
_previewPopover.remove();
|
|
||||||
_previewPopover = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// hidePreview: full cleanup including timer and session name
|
// hidePreview: full cleanup including timer and session name
|
||||||
function hidePreview() {
|
function hidePreview() {
|
||||||
if (_previewTimer) {
|
if (_previewTimer) { clearTimeout(_previewTimer); _previewTimer = null; }
|
||||||
clearTimeout(_previewTimer);
|
|
||||||
_previewTimer = null;
|
|
||||||
}
|
|
||||||
hidePreviewDOM();
|
hidePreviewDOM();
|
||||||
_previewSessionName = null;
|
_previewSessionName = null;
|
||||||
}
|
}
|
||||||
@@ -3753,7 +3628,10 @@ function handleGlobalKeydown(e) {
|
|||||||
function openBottomSheet() {
|
function openBottomSheet() {
|
||||||
var sheet = $('bottom-sheet');
|
var sheet = $('bottom-sheet');
|
||||||
if (!sheet) return;
|
if (!sheet) return;
|
||||||
renderSheetList();
|
sheet.sessions = getVisibleSessions(_currentSessions);
|
||||||
|
sheet.viewingSession = _viewingSession || '';
|
||||||
|
sheet.viewingRemoteId = _viewingRemoteId || '';
|
||||||
|
sheet.open = true;
|
||||||
sheet.classList.remove('hidden');
|
sheet.classList.remove('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3763,7 +3641,7 @@ function openBottomSheet() {
|
|||||||
*/
|
*/
|
||||||
function closeBottomSheet() {
|
function closeBottomSheet() {
|
||||||
var sheet = $('bottom-sheet');
|
var sheet = $('bottom-sheet');
|
||||||
if (sheet) sheet.classList.add('hidden');
|
if (sheet) { sheet.open = false; sheet.classList.add('hidden'); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -3772,31 +3650,11 @@ function closeBottomSheet() {
|
|||||||
* and binds click handlers to switch sessions.
|
* and binds click handlers to switch sessions.
|
||||||
*/
|
*/
|
||||||
function renderSheetList() {
|
function renderSheetList() {
|
||||||
var list = $('sheet-list');
|
var sheet = $('bottom-sheet');
|
||||||
if (!list) return;
|
if (!sheet) return;
|
||||||
var sorted = sortByPriority(getVisibleSessions(_currentSessions));
|
sheet.sessions = getVisibleSessions(_currentSessions);
|
||||||
list.innerHTML = sorted.map(function(s) {
|
sheet.viewingSession = _viewingSession || '';
|
||||||
var hasBell = s.bell && s.bell.unseen_count > 0 &&
|
sheet.viewingRemoteId = _viewingRemoteId || '';
|
||||||
(s.bell.seen_at === null || s.bell.last_fired_at > s.bell.seen_at);
|
|
||||||
var isActive = s.name === _viewingSession && (s.remoteId ?? '') === (_viewingRemoteId ?? '');
|
|
||||||
var escapedName = escapeHtml(s.name || '');
|
|
||||||
var remoteIdAttr = s.remoteId != null ? ' data-remote-id="' + escapeHtml(s.remoteId) + '"' : '';
|
|
||||||
return '<li class="sheet-item' + (isActive ? ' sheet-item--active' : '') + '"' +
|
|
||||||
' data-session="' + escapedName + '"' + remoteIdAttr + ' role="option">' +
|
|
||||||
'<span class="sheet-item__name">' + escapedName + '</span>' +
|
|
||||||
(hasBell ? '<span class="sheet-item__bell">' + _icons.bell + '</span>' : '') +
|
|
||||||
'<span class="sheet-item__time">' + formatTimestamp(s.bell && s.bell.last_fired_at) + '</span>' +
|
|
||||||
'</li>';
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
list.querySelectorAll('.sheet-item').forEach(function(item) {
|
|
||||||
item.addEventListener('click', function() {
|
|
||||||
closeBottomSheet();
|
|
||||||
var name = item.dataset.session;
|
|
||||||
var remoteId = item.dataset.remoteId || '';
|
|
||||||
if (name !== _viewingSession || remoteId !== (_viewingRemoteId ?? '')) openSession(name, { remoteId: remoteId });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -4240,7 +4098,30 @@ function bindStaticEventListeners() {
|
|||||||
document.addEventListener('keydown', handleGlobalKeydown);
|
document.addEventListener('keydown', handleGlobalKeydown);
|
||||||
var sessionPillEl = $('session-pill');
|
var sessionPillEl = $('session-pill');
|
||||||
if (sessionPillEl) sessionPillEl.addEventListener('pill-click', openBottomSheet);
|
if (sessionPillEl) sessionPillEl.addEventListener('pill-click', openBottomSheet);
|
||||||
on($('sheet-backdrop'), 'click', closeBottomSheet);
|
// 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
|
// Settings dialog bindings
|
||||||
on($('view-mode-btn'), 'click', cycleViewMode);
|
on($('view-mode-btn'), 'click', cycleViewMode);
|
||||||
@@ -4580,7 +4461,7 @@ if (typeof module !== 'undefined' && module.exports) {
|
|||||||
toggleSidebar,
|
toggleSidebar,
|
||||||
bindSidebarClickAway,
|
bindSidebarClickAway,
|
||||||
renderGrid,
|
renderGrid,
|
||||||
renderGroupedGrid,
|
renderGroupedGrid: function renderGroupedGrid() {},
|
||||||
requestNotificationPermission,
|
requestNotificationPermission,
|
||||||
handleBellTransitions,
|
handleBellTransitions,
|
||||||
sendHeartbeat,
|
sendHeartbeat,
|
||||||
@@ -4637,7 +4518,7 @@ if (typeof module !== 'undefined' && module.exports) {
|
|||||||
openFlyoutMenu,
|
openFlyoutMenu,
|
||||||
closeFlyoutMenu,
|
closeFlyoutMenu,
|
||||||
// Filter bar
|
// Filter bar
|
||||||
renderFilterBar,
|
renderFilterBar: function renderFilterBar() {},
|
||||||
// View dropdown
|
// View dropdown
|
||||||
renderViewDropdown,
|
renderViewDropdown,
|
||||||
toggleViewDropdown,
|
toggleViewDropdown,
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { LitElement, html, nothing } from '/vendor/lit/lit.min.js';
|
||||||
|
import { unsafeHTML } from '/vendor/lit/lit.min.js';
|
||||||
|
import { formatTimestamp, sortByPriority } from './utils.js';
|
||||||
|
import { icons } from './icons.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <bottom-sheet-switcher> - Mobile session-switching overlay.
|
||||||
|
*
|
||||||
|
* Properties:
|
||||||
|
* sessions: Array - session list (will be sorted by priority internally)
|
||||||
|
* viewingSession: String - name of currently viewed session
|
||||||
|
* viewingRemoteId: String - remoteId of currently viewed session
|
||||||
|
* open: Boolean - whether the sheet is visible
|
||||||
|
*
|
||||||
|
* Events:
|
||||||
|
* sheet-select { name, remoteId } - Session selected
|
||||||
|
* sheet-close - Backdrop/handle dismissed
|
||||||
|
*/
|
||||||
|
export class BottomSheetSwitcher extends LitElement {
|
||||||
|
static properties = {
|
||||||
|
sessions: { type: Array },
|
||||||
|
viewingSession: { type: String, attribute: 'viewing-session' },
|
||||||
|
viewingRemoteId: { type: String, attribute: 'viewing-remote-id' },
|
||||||
|
open: { type: Boolean, reflect: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Light DOM — existing CSS applies
|
||||||
|
createRenderRoot() { return this; }
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.sessions = [];
|
||||||
|
this.viewingSession = '';
|
||||||
|
this.viewingRemoteId = '';
|
||||||
|
this.open = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_onBackdropClick() {
|
||||||
|
this.dispatchEvent(new CustomEvent('sheet-close', { bubbles: true, composed: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
_onItemClick(name, remoteId) {
|
||||||
|
if (name !== this.viewingSession || (remoteId || '') !== (this.viewingRemoteId || '')) {
|
||||||
|
this.dispatchEvent(new CustomEvent('sheet-select', {
|
||||||
|
bubbles: true, composed: true,
|
||||||
|
detail: { name, remoteId: remoteId || '' },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
this.dispatchEvent(new CustomEvent('sheet-close', { bubbles: true, composed: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const sorted = sortByPriority(this.sessions || []);
|
||||||
|
return html`
|
||||||
|
<div class="bottom-sheet__backdrop" @click=${this._onBackdropClick}></div>
|
||||||
|
<div class="bottom-sheet__panel">
|
||||||
|
<div class="bottom-sheet__handle" aria-hidden="true"></div>
|
||||||
|
<ul class="bottom-sheet__list" role="listbox" aria-label="Sessions">
|
||||||
|
${sorted.map(s => {
|
||||||
|
const hasBell = s.bell && s.bell.unseen_count > 0 &&
|
||||||
|
(s.bell.seen_at === null || s.bell.last_fired_at > s.bell.seen_at);
|
||||||
|
const isActive = s.name === this.viewingSession &&
|
||||||
|
(s.remoteId ?? '') === (this.viewingRemoteId ?? '');
|
||||||
|
const name = s.name || '';
|
||||||
|
const remoteId = s.remoteId ?? '';
|
||||||
|
return html`
|
||||||
|
<li class="sheet-item ${isActive ? 'sheet-item--active' : ''}"
|
||||||
|
role="option"
|
||||||
|
@click=${() => this._onItemClick(name, remoteId)}>
|
||||||
|
<span class="sheet-item__name">${name}</span>
|
||||||
|
${hasBell ? html`<span class="sheet-item__bell">${unsafeHTML(icons.bell)}</span>` : nothing}
|
||||||
|
<span class="sheet-item__time">${formatTimestamp(s.bell && s.bell.last_fired_at)}</span>
|
||||||
|
</li>`;
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('bottom-sheet-switcher', BottomSheetSwitcher);
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { LitElement, html, css } from '/vendor/lit/lit.min.js';
|
||||||
|
import { unsafeHTML } from '/vendor/lit/lit.min.js';
|
||||||
|
import { ansiToHtml } from './utils.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <hover-preview> - Full-window snapshot popover for session hover.
|
||||||
|
*
|
||||||
|
* Properties:
|
||||||
|
* sessionName: String - name of the session being previewed
|
||||||
|
* snapshot: String - raw terminal snapshot text (ANSI)
|
||||||
|
* open: Boolean - whether the preview is visible
|
||||||
|
*
|
||||||
|
* Events:
|
||||||
|
* preview-click { name } - Fired when user clicks the preview (navigate to session)
|
||||||
|
* preview-close - Fired when preview should be dismissed
|
||||||
|
*/
|
||||||
|
export class HoverPreview extends LitElement {
|
||||||
|
static properties = {
|
||||||
|
sessionName: { type: String, attribute: 'session-name' },
|
||||||
|
snapshot: { type: String },
|
||||||
|
open: { type: Boolean, reflect: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
static styles = css`
|
||||||
|
:host { display: none; }
|
||||||
|
:host([open]) { display: block; }
|
||||||
|
|
||||||
|
.preview-popover {
|
||||||
|
position: fixed;
|
||||||
|
inset: 48px 12px 12px 12px;
|
||||||
|
z-index: 300;
|
||||||
|
background: var(--bg-primary, #0d1117);
|
||||||
|
border: 1px solid var(--border-muted, #30363d);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.preview-popover pre {
|
||||||
|
margin: 0;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
white-space: pre;
|
||||||
|
color: var(--fg-default, #c9d1d9);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.sessionName = '';
|
||||||
|
this.snapshot = '';
|
||||||
|
this.open = false;
|
||||||
|
this._boundDocClick = this._onDocumentClick.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
updated(changed) {
|
||||||
|
if (changed.has('open')) {
|
||||||
|
if (this.open) {
|
||||||
|
// Scroll to bottom after render (prompt area)
|
||||||
|
const popover = this.shadowRoot.querySelector('.preview-popover');
|
||||||
|
if (popover) popover.scrollTop = popover.scrollHeight;
|
||||||
|
// Close on any click outside (capture phase, next tick)
|
||||||
|
setTimeout(() => document.addEventListener('click', this._boundDocClick, true), 0);
|
||||||
|
} else {
|
||||||
|
document.removeEventListener('click', this._boundDocClick, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback() {
|
||||||
|
super.disconnectedCallback();
|
||||||
|
document.removeEventListener('click', this._boundDocClick, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
_onDocumentClick(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
this.dispatchEvent(new CustomEvent('preview-click', {
|
||||||
|
bubbles: true, composed: true,
|
||||||
|
detail: { name: this.sessionName },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (!this.open) return html``;
|
||||||
|
return html`
|
||||||
|
<div class="preview-popover">
|
||||||
|
<pre>${unsafeHTML(ansiToHtml(this.snapshot || ''))}</pre>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('hover-preview', HoverPreview);
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { LitElement, html, nothing } from '/vendor/lit/lit.min.js';
|
||||||
|
import { repeat } from '/vendor/lit/lit.min.js';
|
||||||
|
import { unsafeHTML } from '/vendor/lit/lit.min.js';
|
||||||
|
import { sortByPriority, escapeHtml } from './utils.js';
|
||||||
|
import './session-tile.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <session-grid> - Dashboard grid of session tiles.
|
||||||
|
*
|
||||||
|
* Owns sorting, grouping, status tiles, and the keyed repeat loop.
|
||||||
|
* Replaces the renderGrid() function's innerHTML + diff-patch approach.
|
||||||
|
*
|
||||||
|
* Properties:
|
||||||
|
* sessions: Array - raw session list (includes status sentinels)
|
||||||
|
* visibleSessions: Array - already-filtered visible sessions
|
||||||
|
* sortOrder: String - 'alphabetical' | 'recent' | 'manual'
|
||||||
|
* groupMode: String - 'flat' | 'grouped'
|
||||||
|
* mobile: Boolean - mobile layout
|
||||||
|
* multiDevice: Boolean - multi-device enabled
|
||||||
|
* showDeviceBadges: Boolean
|
||||||
|
* activityIndicator: String
|
||||||
|
* viewMode: String - 'auto' | 'fit'
|
||||||
|
*
|
||||||
|
* Events:
|
||||||
|
* session-open { name, remoteId }
|
||||||
|
* session-options { name, remoteId, sessionKey, rect }
|
||||||
|
*/
|
||||||
|
export class SessionGrid extends LitElement {
|
||||||
|
static properties = {
|
||||||
|
sessions: { type: Array },
|
||||||
|
visibleSessions: { type: Array },
|
||||||
|
sortOrder: { type: String, attribute: 'sort-order' },
|
||||||
|
groupMode: { type: String, attribute: 'group-mode' },
|
||||||
|
mobile: { type: Boolean },
|
||||||
|
multiDevice: { type: Boolean, attribute: 'multi-device' },
|
||||||
|
showDeviceBadges: { type: Boolean, attribute: 'show-device-badges' },
|
||||||
|
activityIndicator: { type: String, attribute: 'activity-indicator' },
|
||||||
|
viewMode: { type: String, attribute: 'view-mode' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Light DOM so existing CSS grid styles apply
|
||||||
|
createRenderRoot() { return this; }
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.sessions = [];
|
||||||
|
this.visibleSessions = [];
|
||||||
|
this.sortOrder = 'manual';
|
||||||
|
this.groupMode = 'flat';
|
||||||
|
this.mobile = false;
|
||||||
|
this.multiDevice = false;
|
||||||
|
this.showDeviceBadges = true;
|
||||||
|
this.activityIndicator = 'both';
|
||||||
|
this.viewMode = 'auto';
|
||||||
|
}
|
||||||
|
|
||||||
|
get _ordered() {
|
||||||
|
const visible = this.visibleSessions || [];
|
||||||
|
if (this.sortOrder === 'alphabetical') {
|
||||||
|
return visible.slice().sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
||||||
|
}
|
||||||
|
return this.mobile ? sortByPriority(visible) : visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
get _statusTiles() {
|
||||||
|
return (this.sessions || []).filter(s =>
|
||||||
|
s.status === 'auth_failed' || s.status === 'unreachable'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_onTileClick(e) {
|
||||||
|
this.dispatchEvent(new CustomEvent('session-open', {
|
||||||
|
bubbles: true, composed: true,
|
||||||
|
detail: e.detail,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
_onTileOptions(e) {
|
||||||
|
this.dispatchEvent(new CustomEvent('session-options', {
|
||||||
|
bubbles: true, composed: true,
|
||||||
|
detail: e.detail,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderTile(s) {
|
||||||
|
return html`
|
||||||
|
<session-tile
|
||||||
|
.session=${s}
|
||||||
|
?mobile=${this.mobile}
|
||||||
|
?multi-device=${this.multiDevice}
|
||||||
|
?show-device-badges=${this.showDeviceBadges}
|
||||||
|
activity-indicator=${this.activityIndicator}
|
||||||
|
view-mode=${this.viewMode}
|
||||||
|
@tile-click=${this._onTileClick}
|
||||||
|
@tile-options=${this._onTileOptions}
|
||||||
|
></session-tile>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderStatusTile(s) {
|
||||||
|
const label = s.status === 'auth_failed' ? 'Auth required' : 'Offline';
|
||||||
|
const cls = s.status === 'auth_failed' ? 'auth' : 'offline';
|
||||||
|
const name = escapeHtml(s.deviceName || 'Unknown');
|
||||||
|
return html`
|
||||||
|
<article class="session-tile source-tile--${cls}">
|
||||||
|
<div class="tile-header">
|
||||||
|
<span class="tile-name">${name}</span>
|
||||||
|
<span class="tile-meta source-tile__status">${label}</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const ordered = this._ordered;
|
||||||
|
const statusTiles = this._statusTiles;
|
||||||
|
|
||||||
|
if (ordered.length === 0 && statusTiles.length === 0) {
|
||||||
|
return nothing;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.groupMode === 'grouped') {
|
||||||
|
const groups = new Map();
|
||||||
|
const groupOrder = [];
|
||||||
|
for (const s of ordered) {
|
||||||
|
const dn = s.deviceName || 'Unknown';
|
||||||
|
if (!groups.has(dn)) { groups.set(dn, []); groupOrder.push(dn); }
|
||||||
|
groups.get(dn).push(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`
|
||||||
|
${groupOrder.map(gname => html`
|
||||||
|
<h3 class="device-group-header">${gname}</h3>
|
||||||
|
${repeat(groups.get(gname), s => s.sessionKey || s.name, s => this._renderTile(s))}
|
||||||
|
`)}
|
||||||
|
${statusTiles.map(s => this._renderStatusTile(s))}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`
|
||||||
|
${repeat(ordered, s => s.sessionKey || s.name, s => this._renderTile(s))}
|
||||||
|
${statusTiles.map(s => this._renderStatusTile(s))}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('session-grid', SessionGrid);
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { LitElement, html, nothing } from '/vendor/lit/lit.min.js';
|
||||||
|
import { unsafeHTML } from '/vendor/lit/lit.min.js';
|
||||||
|
import { escapeHtml, ansiToHtml } from './utils.js';
|
||||||
|
import { icons } from './icons.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <sidebar-item> - Session entry in the expanded-view sidebar.
|
||||||
|
*
|
||||||
|
* Properties:
|
||||||
|
* session: Object - session data
|
||||||
|
* active: Boolean - whether this is the currently viewed session
|
||||||
|
* multiDevice: Boolean - multi-device enabled
|
||||||
|
* showDeviceBadges: Boolean - show device name badges
|
||||||
|
* activityIndicator: String - 'none' | 'glow' | 'dot' | 'both'
|
||||||
|
*
|
||||||
|
* Events:
|
||||||
|
* sidebar-select { name, remoteId } - Session clicked
|
||||||
|
* sidebar-options { name, remoteId, sessionKey, rect } - Kebab menu clicked
|
||||||
|
*/
|
||||||
|
export class SidebarItem extends LitElement {
|
||||||
|
static properties = {
|
||||||
|
session: { type: Object },
|
||||||
|
active: { type: Boolean, reflect: true },
|
||||||
|
multiDevice: { type: Boolean, attribute: 'multi-device' },
|
||||||
|
showDeviceBadges: { type: Boolean, attribute: 'show-device-badges' },
|
||||||
|
activityIndicator: { type: String, attribute: 'activity-indicator' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Light DOM — existing CSS applies
|
||||||
|
createRenderRoot() { return this; }
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.session = {};
|
||||||
|
this.active = false;
|
||||||
|
this.multiDevice = false;
|
||||||
|
this.showDeviceBadges = true;
|
||||||
|
this.activityIndicator = 'both';
|
||||||
|
}
|
||||||
|
|
||||||
|
get _name() { return this.session.name || ''; }
|
||||||
|
get _sessionKey() { return this.session.sessionKey || this._name; }
|
||||||
|
get _remoteId() { return this.session.remoteId ?? null; }
|
||||||
|
|
||||||
|
get _isBell() {
|
||||||
|
const unseen = this.session.bell && this.session.bell.unseen_count;
|
||||||
|
return unseen && unseen > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
get _classes() {
|
||||||
|
let cls = 'sidebar-item';
|
||||||
|
if (this.active) cls += ' sidebar-item--active';
|
||||||
|
const ind = this.activityIndicator;
|
||||||
|
if (this._isBell && (ind === 'glow' || ind === 'both')) cls += ' sidebar-item--bell';
|
||||||
|
if (this._isBell && (ind === 'dot' || ind === 'both')) cls += ' sidebar-item--edge-bell';
|
||||||
|
return cls;
|
||||||
|
}
|
||||||
|
|
||||||
|
get _snapshotHtml() {
|
||||||
|
const snapshot = this.session.snapshot || '';
|
||||||
|
const allLines = snapshot.split('\n');
|
||||||
|
while (allLines.length > 0 && allLines[allLines.length - 1].trim() === '') {
|
||||||
|
allLines.pop();
|
||||||
|
}
|
||||||
|
return ansiToHtml(allLines.slice(-20).join('\n'));
|
||||||
|
}
|
||||||
|
|
||||||
|
_onClick(e) {
|
||||||
|
if (e.target.closest('.tile-options-btn')) return;
|
||||||
|
this.dispatchEvent(new CustomEvent('sidebar-select', {
|
||||||
|
bubbles: true, composed: true,
|
||||||
|
detail: { name: this._name, remoteId: this._remoteId },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
_onOptionsClick(e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
const btn = e.currentTarget;
|
||||||
|
this.dispatchEvent(new CustomEvent('sidebar-options', {
|
||||||
|
bubbles: true, composed: true,
|
||||||
|
detail: {
|
||||||
|
name: this._name,
|
||||||
|
remoteId: this._remoteId,
|
||||||
|
sessionKey: this._sessionKey,
|
||||||
|
rect: btn.getBoundingClientRect(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const name = this._name;
|
||||||
|
const remoteIdStr = this._remoteId != null ? String(this._remoteId) : '';
|
||||||
|
const showBadge = this.multiDevice && this.session.deviceName && this.showDeviceBadges;
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<article
|
||||||
|
class=${this._classes}
|
||||||
|
data-session=${name}
|
||||||
|
data-session-key=${this._sessionKey}
|
||||||
|
data-remote-id=${remoteIdStr}
|
||||||
|
tabindex="0"
|
||||||
|
role="listitem"
|
||||||
|
@click=${this._onClick}
|
||||||
|
>
|
||||||
|
<div class="sidebar-item-header">
|
||||||
|
<span class="sidebar-item-name">${name}</span>
|
||||||
|
${showBadge
|
||||||
|
? html`<span class="device-badge">${this.session.deviceName}</span>`
|
||||||
|
: nothing}
|
||||||
|
<button
|
||||||
|
class="tile-options-btn"
|
||||||
|
data-session=${name}
|
||||||
|
aria-label="Session options"
|
||||||
|
aria-haspopup="true"
|
||||||
|
@click=${this._onOptionsClick}
|
||||||
|
>${unsafeHTML(icons.kebab)}</button>
|
||||||
|
</div>
|
||||||
|
<div class="sidebar-item-body">
|
||||||
|
<pre>${unsafeHTML(this._snapshotHtml)}</pre>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('sidebar-item', SidebarItem);
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div id="filter-bar" class="filter-bar"></div>
|
<div id="filter-bar" class="filter-bar"></div>
|
||||||
<div id="session-grid" class="session-grid" role="list"></div>
|
<session-grid id="session-grid" class="session-grid" role="list"></session-grid>
|
||||||
<div id="empty-state" class="empty-state hidden">No active tmux sessions</div>
|
<div id="empty-state" class="empty-state hidden">No active tmux sessions</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -95,13 +95,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Bottom sheet (session switcher) ────────────────────────────────── -->
|
<!-- ── Bottom sheet (session switcher) ────────────────────────────────── -->
|
||||||
<div id="bottom-sheet" class="bottom-sheet hidden" role="dialog" aria-modal="true" aria-label="Switch session">
|
<bottom-sheet-switcher id="bottom-sheet" class="bottom-sheet hidden" role="dialog" aria-modal="true" aria-label="Switch session"></bottom-sheet-switcher>
|
||||||
<div class="bottom-sheet__backdrop" id="sheet-backdrop"></div>
|
|
||||||
<div class="bottom-sheet__panel">
|
|
||||||
<div class="bottom-sheet__handle" aria-hidden="true"></div>
|
|
||||||
<ul id="sheet-list" class="bottom-sheet__list" role="listbox" aria-label="Sessions"></ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- —— Manage View panel ———————————————————————————————————————————————————————————————————————————————————— -->
|
<!-- —— Manage View panel ———————————————————————————————————————————————————————————————————————————————————— -->
|
||||||
<div id="manage-view-panel" class="manage-view-panel hidden" role="dialog" aria-modal="true" aria-label="Manage view">
|
<div id="manage-view-panel" class="manage-view-panel hidden" role="dialog" aria-modal="true" aria-label="Manage view">
|
||||||
@@ -126,6 +120,9 @@
|
|||||||
<!-- ── Mobile FAB (new session) ──────────────────────────────────────────── -->
|
<!-- ── Mobile FAB (new session) ──────────────────────────────────────────── -->
|
||||||
<button id="new-session-fab" class="new-session-fab" aria-label="New session"><svg viewBox="0 0 16 16" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 3v10M3 8h10"/></svg></button>
|
<button id="new-session-fab" class="new-session-fab" aria-label="New session"><svg viewBox="0 0 16 16" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 3v10M3 8h10"/></svg></button>
|
||||||
|
|
||||||
|
<!-- ── Hover preview ──────────────────────────────────────────────── -->
|
||||||
|
<hover-preview id="hover-preview"></hover-preview>
|
||||||
|
|
||||||
<!-- ── Toast notification ──────────────────────────────────────────────── -->
|
<!-- ── Toast notification ──────────────────────────────────────────────── -->
|
||||||
<toast-notification id="toast"></toast-notification>
|
<toast-notification id="toast"></toast-notification>
|
||||||
|
|
||||||
@@ -296,6 +293,10 @@
|
|||||||
import '/components/connection-status.js';
|
import '/components/connection-status.js';
|
||||||
import '/components/session-pill.js';
|
import '/components/session-pill.js';
|
||||||
import '/components/session-tile.js';
|
import '/components/session-tile.js';
|
||||||
|
import '/components/sidebar-item.js';
|
||||||
|
import '/components/hover-preview.js';
|
||||||
|
import '/components/bottom-sheet-switcher.js';
|
||||||
|
import '/components/session-grid.js';
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -69,15 +69,18 @@ def test_html_expanded_view_elements() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_html_bottom_sheet() -> None:
|
def test_html_bottom_sheet() -> None:
|
||||||
"""id=bottom-sheet, sheet-list, sheet-backdrop, session-pill."""
|
"""id=bottom-sheet (bottom-sheet-switcher component), session-pill."""
|
||||||
soup = _SOUP
|
soup = _SOUP
|
||||||
for id_ in (
|
for id_ in (
|
||||||
"bottom-sheet",
|
"bottom-sheet",
|
||||||
"sheet-list",
|
|
||||||
"sheet-backdrop",
|
|
||||||
"session-pill",
|
"session-pill",
|
||||||
):
|
):
|
||||||
assert soup.find(id=id_), f"Missing element with id='{id_}'"
|
assert soup.find(id=id_), f"Missing element with id='{id_}'"
|
||||||
|
# bottom-sheet must be a <bottom-sheet-switcher> custom element
|
||||||
|
sheet = soup.find(id="bottom-sheet")
|
||||||
|
assert sheet.name == "bottom-sheet-switcher", (
|
||||||
|
f"#bottom-sheet must be a <bottom-sheet-switcher>, got: {sheet.name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_html_toast() -> None:
|
def test_html_toast() -> None:
|
||||||
|
|||||||
@@ -3672,15 +3672,16 @@ def test_create_new_session_uses_local_device_id() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_render_filter_bar_body_is_empty() -> None:
|
def test_render_filter_bar_body_is_empty() -> None:
|
||||||
"""renderFilterBar function body must be empty (dead code)."""
|
"""renderFilterBar must be a no-op stub (dead code moved to component)."""
|
||||||
|
# renderFilterBar may be a standalone function or an inline no-op stub in the exports.
|
||||||
match = re.search(
|
match = re.search(
|
||||||
r"function renderFilterBar\s*\(.*?\)\s*\{(.*?)(?=\n\})",
|
r"function renderFilterBar\s*\(.*?\)\s*\{(.*?)\}",
|
||||||
_JS,
|
_JS,
|
||||||
re.DOTALL,
|
re.DOTALL,
|
||||||
)
|
)
|
||||||
assert match, "renderFilterBar function not found"
|
assert match, "renderFilterBar function not found"
|
||||||
body = match.group(1).strip()
|
body = match.group(1).strip()
|
||||||
assert body == "" or body == "// dead code" or body.startswith("//"), (
|
assert body == "" or body.startswith("//"), (
|
||||||
"renderFilterBar body must be empty (dead code removed) — "
|
"renderFilterBar body must be empty (dead code removed) — "
|
||||||
f"but found content: {body[:80]!r}"
|
f"but found content: {body[:80]!r}"
|
||||||
)
|
)
|
||||||
@@ -4005,19 +4006,19 @@ def test_render_grid_closes_stale_flyout() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_tile_click_handler_guards_options_btn() -> None:
|
def test_tile_click_handler_guards_options_btn() -> None:
|
||||||
"""Tile click events are handled by session-tile component via tile-click/tile-options events.
|
"""Session events are handled by session-grid component via session-open/session-options events.
|
||||||
|
|
||||||
With session-tile Lit components, the options-button guard is handled inside the component
|
With Phase 2 Lit components, the options-button guard is handled inside the
|
||||||
itself. renderGrid listens for 'tile-click' and 'tile-options' custom events instead of
|
session-grid component. renderGrid listens for 'session-open' and 'session-options'
|
||||||
binding raw click handlers with .tile-options-btn guards.
|
custom events fired by the <session-grid> element.
|
||||||
"""
|
"""
|
||||||
render_grid_body = _JS.split("function renderGrid")[1].split("\nfunction ")[0]
|
render_grid_body = _JS.split("function renderGrid")[1].split("\nfunction ")[0]
|
||||||
# session-tile components fire custom events; renderGrid must listen for them
|
# session-grid component fires custom events; renderGrid must listen for them
|
||||||
assert "tile-click" in render_grid_body, (
|
assert "session-open" in render_grid_body, (
|
||||||
"renderGrid must listen for 'tile-click' events from session-tile components"
|
"renderGrid must listen for 'session-open' events from session-grid component"
|
||||||
)
|
)
|
||||||
assert "tile-options" in render_grid_body, (
|
assert "session-options" in render_grid_body, (
|
||||||
"renderGrid must listen for 'tile-options' events from session-tile components"
|
"renderGrid must listen for 'session-options' events from session-grid component"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user