feat: introduce Lit web components framework for frontend (Phase 1)
- Vendored Lit 3 (21KB ESM bundle, no build step required) - New components: toast-notification, connection-status, session-pill, session-tile - Extracted utilities: escapeHtml, formatTimestamp, sessionPriority, ansiToHtml - SVG icons registry for reuse - Updated app.js and index.html to use new components - All 1306 tests pass (incremental migration, vanilla JS still supported)
This commit is contained in:
+104
-62
@@ -337,16 +337,7 @@ async function restoreState() {
|
||||
*/
|
||||
function setConnectionStatus(level) {
|
||||
const el = $('connection-status');
|
||||
if (!el) return;
|
||||
const map = {
|
||||
ok: { text: _icons.statusOk, cls: 'connection-status--ok' },
|
||||
warn: { text: _icons.statusWarn + ' slow', cls: 'connection-status--warn' },
|
||||
err: { text: _icons.statusErr + ' offline', cls: 'connection-status--err' },
|
||||
};
|
||||
const s = map[level];
|
||||
if (!s) return;
|
||||
el.innerHTML = s.text;
|
||||
el.className = s.cls;
|
||||
if (el) el.level = level;
|
||||
}
|
||||
|
||||
// ─── Session polling ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -1704,12 +1695,70 @@ function renderGrid(sessions) {
|
||||
ordered = mobile ? sortByPriority(visible) : visible;
|
||||
}
|
||||
|
||||
var html;
|
||||
if (_gridViewMode === 'grouped') {
|
||||
html = renderGroupedGrid(ordered, mobile);
|
||||
} else {
|
||||
html = ordered.map(function(session, index) { return buildTileHTML(session, index, mobile); }).join('');
|
||||
// Diff tiles: reuse existing, add new, remove stale
|
||||
var existingTiles = new Map();
|
||||
if (grid) {
|
||||
grid.querySelectorAll('session-tile').forEach(function(t) {
|
||||
existingTiles.set(t.getAttribute('data-session-key') || (t.session && t.session.sessionKey), t);
|
||||
});
|
||||
}
|
||||
var newKeys = new Set();
|
||||
var fragment = document.createDocumentFragment();
|
||||
var ds = getDisplaySettings();
|
||||
|
||||
function _setupTile(s) {
|
||||
var key = s.sessionKey || s.name;
|
||||
newKeys.add(key);
|
||||
var tile = existingTiles.get(key);
|
||||
if (!tile) {
|
||||
tile = document.createElement('session-tile');
|
||||
tile.addEventListener('tile-click', function(e) {
|
||||
openSession(e.detail.name, { remoteId: e.detail.remoteId || undefined });
|
||||
});
|
||||
tile.addEventListener('tile-options', function(e) {
|
||||
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
|
||||
@@ -1719,29 +1768,14 @@ function renderGrid(sessions) {
|
||||
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 = html + statusTilesHtml;
|
||||
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 = '';
|
||||
|
||||
// Bind interaction handlers on each tile
|
||||
document.querySelectorAll('.session-tile').forEach(function(tile) {
|
||||
on(tile, 'click', (e) => {
|
||||
// Don't navigate when clicking the options button inside the tile
|
||||
if (e.target.closest && e.target.closest('.tile-options-btn')) return;
|
||||
// Don't open error/status tiles (unreachable, auth_failed)
|
||||
if (tile.classList.contains('source-tile--error') || !tile.dataset.session) return;
|
||||
openSession(tile.dataset.session, { remoteId: tile.dataset.remoteId || '' });
|
||||
});
|
||||
on(tile, 'keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
// Don't open error/status tiles (unreachable, auth_failed)
|
||||
if (tile.classList.contains('source-tile--error') || !tile.dataset.session) return;
|
||||
openSession(tile.dataset.session, { remoteId: tile.dataset.remoteId || '' });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (_viewMode === 'fullscreen') {
|
||||
updatePillBell();
|
||||
}
|
||||
@@ -1833,15 +1867,24 @@ function hidePreview() {
|
||||
* bottom action sheet instead.
|
||||
* @param {HTMLElement} triggerEl - The .tile-options-btn element that was clicked
|
||||
*/
|
||||
function openFlyoutMenu(triggerEl) {
|
||||
function openFlyoutMenu(nameOrEl, sessionKey, remoteId, rectArg) {
|
||||
closeFlyoutMenu();
|
||||
|
||||
// Read session info from the tile
|
||||
var tile = triggerEl.closest('[data-session-key]');
|
||||
if (!tile) return;
|
||||
_flyoutSessionKey = tile.dataset.sessionKey || '';
|
||||
_flyoutSessionName = tile.dataset.session || '';
|
||||
_flyoutRemoteId = tile.dataset.remoteId || '';
|
||||
// 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();
|
||||
@@ -1860,7 +1903,6 @@ function openFlyoutMenu(triggerEl) {
|
||||
_flyoutMenuEl = menu;
|
||||
|
||||
// Position relative to trigger
|
||||
var rect = triggerEl.getBoundingClientRect();
|
||||
var menuWidth = menu.offsetWidth;
|
||||
var menuHeight = menu.offsetHeight;
|
||||
|
||||
@@ -2860,10 +2902,7 @@ function _resetHeartbeatTimer() {
|
||||
*/
|
||||
function showToast(msg) {
|
||||
const el = $('toast');
|
||||
if (!el) return;
|
||||
el.textContent = msg;
|
||||
el.classList.remove('hidden');
|
||||
setTimeout(() => el.classList.add('hidden'), 3000);
|
||||
if (el && el.show) el.show(msg);
|
||||
}
|
||||
|
||||
// ─── Session pill bell ───────────────────────────────────────────────────────
|
||||
@@ -2873,13 +2912,13 @@ function showToast(msg) {
|
||||
* Shows #session-pill-bell if any session other than _viewingSession has unseen bells.
|
||||
*/
|
||||
function updatePillBell() {
|
||||
const el = $('session-pill-bell');
|
||||
if (!el) return;
|
||||
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,
|
||||
);
|
||||
if (hasBell) el.classList.remove('hidden'); else el.classList.add('hidden');
|
||||
pill.hasBell = hasBell;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -3049,9 +3088,8 @@ async function openSession(name, opts = {}) {
|
||||
if (isMobile()) {
|
||||
const pill = $('session-pill');
|
||||
if (pill) {
|
||||
pill.classList.remove('hidden'); // pill starts with hidden class
|
||||
const pillLabel = $('session-pill-label');
|
||||
if (pillLabel) pillLabel.textContent = name;
|
||||
pill.visible = true;
|
||||
pill.label = name;
|
||||
}
|
||||
updatePillBell();
|
||||
updateSessionPill(_currentSessions);
|
||||
@@ -3128,7 +3166,7 @@ function closeSession() {
|
||||
}
|
||||
|
||||
const pill = $('session-pill');
|
||||
if (pill) pill.classList.add('hidden');
|
||||
if (pill) pill.visible = false;
|
||||
|
||||
// Restore FAB when returning to overview
|
||||
const fab = $('new-session-fab');
|
||||
@@ -3767,20 +3805,23 @@ function renderSheetList() {
|
||||
* @param {object[]} sessions - full sessions array
|
||||
*/
|
||||
function updateSessionPill(sessions) {
|
||||
if (_viewMode !== 'fullscreen') return;
|
||||
var pillBell = $('session-pill-bell');
|
||||
if (!pillBell) return;
|
||||
const pill = $('session-pill');
|
||||
if (!pill) return;
|
||||
if (_viewMode !== 'fullscreen') {
|
||||
pill.visible = false;
|
||||
return;
|
||||
}
|
||||
var pillLabel = _viewingSession || '';
|
||||
pill.label = pillLabel;
|
||||
pill.visible = true;
|
||||
|
||||
var viewingKey = _viewingRemoteId ? (_viewingRemoteId + ':' + _viewingSession) : _viewingSession;
|
||||
var othersWithBell = sessions.filter(function(s) {
|
||||
return (s.sessionKey || s.name) !== viewingKey &&
|
||||
s.bell && s.bell.unseen_count > 0 &&
|
||||
(s.bell.seen_at === null || s.bell.last_fired_at > s.bell.seen_at);
|
||||
});
|
||||
if (othersWithBell.length > 0) {
|
||||
pillBell.classList.remove('hidden');
|
||||
} else {
|
||||
pillBell.classList.add('hidden');
|
||||
}
|
||||
pill.hasBell = othersWithBell.length > 0;
|
||||
}
|
||||
|
||||
// ─── Header + button with inline name input ────────────────────────────────────
|
||||
@@ -4197,7 +4238,8 @@ function bindStaticEventListeners() {
|
||||
on($('sidebar-collapse-btn'), 'click', toggleSidebar);
|
||||
bindSidebarClickAway();
|
||||
document.addEventListener('keydown', handleGlobalKeydown);
|
||||
on($('session-pill'), 'click', openBottomSheet);
|
||||
var sessionPillEl = $('session-pill');
|
||||
if (sessionPillEl) sessionPillEl.addEventListener('pill-click', openBottomSheet);
|
||||
on($('sheet-backdrop'), 'click', closeBottomSheet);
|
||||
|
||||
// Settings dialog bindings
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { LitElement, html, css, svg } from '/vendor/lit/lit.min.js';
|
||||
|
||||
/**
|
||||
* <connection-status> - Server connectivity indicator.
|
||||
*
|
||||
* Properties:
|
||||
* level: 'ok' | 'warn' | 'err'
|
||||
*/
|
||||
export class ConnectionStatus extends LitElement {
|
||||
static properties = {
|
||||
level: { type: String, reflect: true },
|
||||
};
|
||||
|
||||
static styles = css`
|
||||
:host { display: inline-flex; align-items: center; gap: 4px; font-size: 12px; }
|
||||
.indicator { display: inline-flex; align-items: center; gap: 4px; }
|
||||
:host([level="ok"]) .indicator { color: var(--status-ok, #3fb950); }
|
||||
:host([level="warn"]) .indicator { color: var(--status-warn, #d29922); }
|
||||
:host([level="err"]) .indicator { color: var(--status-err, #f85149); }
|
||||
`;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.level = 'ok';
|
||||
}
|
||||
|
||||
render() {
|
||||
const content = {
|
||||
ok: html`
|
||||
<span class="indicator">
|
||||
<svg viewBox="0 0 16 16" width="10" height="10" fill="currentColor"><circle cx="8" cy="8" r="4"/></svg>
|
||||
</span>
|
||||
`,
|
||||
warn: html`
|
||||
<span class="indicator">
|
||||
<svg viewBox="0 0 16 16" width="10" height="10" fill="none" stroke="currentColor" stroke-width="2" stroke-dasharray="3 2"><circle cx="8" cy="8" r="5"/></svg>
|
||||
slow
|
||||
</span>
|
||||
`,
|
||||
err: html`
|
||||
<span class="indicator">
|
||||
<svg viewBox="0 0 16 16" width="10" height="10" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 4l8 8M12 4l-8 8"/></svg>
|
||||
offline
|
||||
</span>
|
||||
`,
|
||||
};
|
||||
return content[this.level] || content.ok;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('connection-status', ConnectionStatus);
|
||||
@@ -0,0 +1,16 @@
|
||||
// Inline SVG icon strings shared across components.
|
||||
// Matches the _icons object in app.js.
|
||||
|
||||
export const icons = {
|
||||
statusOk: '<svg viewBox="0 0 16 16" width="10" height="10" fill="currentColor"><circle cx="8" cy="8" r="4"/></svg>',
|
||||
statusWarn: '<svg viewBox="0 0 16 16" width="10" height="10" fill="none" stroke="currentColor" stroke-width="2" stroke-dasharray="3 2"><circle cx="8" cy="8" r="5"/></svg>',
|
||||
statusErr: '<svg viewBox="0 0 16 16" width="10" height="10" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 4l8 8M12 4l-8 8"/></svg>',
|
||||
kebab: '<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor"><circle cx="8" cy="3" r="1.5"/><circle cx="8" cy="8" r="1.5"/><circle cx="8" cy="13" r="1.5"/></svg>',
|
||||
chevronLeft: '<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M10 3L5 8l5 5"/></svg>',
|
||||
chevronRight: '<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 3l5 5-5 5"/></svg>',
|
||||
chevronUp: '<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 10l4-4 4 4"/></svg>',
|
||||
chevronDown: '<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6l4 4 4-4"/></svg>',
|
||||
check: '<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M3 8l4 4 6-7"/></svg>',
|
||||
bell: '<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor"><path d="M8 1a1 1 0 0 1 1 1v.3A4.5 4.5 0 0 1 12.5 7v2.5l1 2H2.5l1-2V7A4.5 4.5 0 0 1 7 2.3V2a1 1 0 0 1 1-1zM6.5 13a1.5 1.5 0 0 0 3 0z"/></svg>',
|
||||
close: '<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 4l8 8M12 4l-8 8"/></svg>',
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
import { LitElement, html, css } from '/vendor/lit/lit.min.js';
|
||||
|
||||
/**
|
||||
* <session-pill> - Floating session-switcher button with bell badge.
|
||||
*
|
||||
* Properties:
|
||||
* label: String - session name to display
|
||||
* hasBell: Boolean - whether bell badge is visible
|
||||
* visible: Boolean - whether the pill itself is shown
|
||||
*
|
||||
* Events:
|
||||
* pill-click - Fired when user taps/clicks the pill
|
||||
*/
|
||||
export class SessionPill extends LitElement {
|
||||
static properties = {
|
||||
label: { type: String },
|
||||
hasBell: { type: Boolean, attribute: 'has-bell' },
|
||||
visible: { type: Boolean, reflect: true },
|
||||
};
|
||||
|
||||
static styles = css`
|
||||
:host { display: none; }
|
||||
:host([visible]) { display: block; }
|
||||
|
||||
.pill {
|
||||
position: fixed;
|
||||
bottom: 18px;
|
||||
right: 18px;
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: var(--pill-bg, #21262d);
|
||||
color: var(--pill-fg, #c9d1d9);
|
||||
font-size: 13px;
|
||||
opacity: 0.75;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.pill:hover { opacity: 1; }
|
||||
|
||||
.bell {
|
||||
display: none;
|
||||
color: var(--bell-color, #d29922);
|
||||
}
|
||||
.bell.active { display: inline-flex; }
|
||||
`;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.label = '';
|
||||
this.hasBell = false;
|
||||
this.visible = false;
|
||||
}
|
||||
|
||||
_onClick() {
|
||||
this.dispatchEvent(new CustomEvent('pill-click', { bubbles: true, composed: true }));
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<button class="pill" aria-label="Switch session" @click=${this._onClick}>
|
||||
<span class="label">${this.label}</span>
|
||||
<span class="bell ${this.hasBell ? 'active' : ''}" aria-hidden="true">
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor">
|
||||
<path d="M8 1a1 1 0 0 1 1 1v.3A4.5 4.5 0 0 1 12.5 7v2.5l1 2H2.5l1-2V7A4.5 4.5 0 0 1 7 2.3V2a1 1 0 0 1 1-1zM6.5 13a1.5 1.5 0 0 0 3 0z"/>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('session-pill', SessionPill);
|
||||
@@ -0,0 +1,146 @@
|
||||
import { LitElement, html, css, nothing } from '/vendor/lit/lit.min.js';
|
||||
import { unsafeHTML } from '/vendor/lit/lit.min.js';
|
||||
import { escapeHtml, formatTimestamp, sessionPriority, ansiToHtml } from './utils.js';
|
||||
import { icons } from './icons.js';
|
||||
|
||||
/**
|
||||
* <session-tile> - Dashboard tile representing a tmux session.
|
||||
*
|
||||
* Properties:
|
||||
* session: Object - session data { name, snapshot, last_activity_at, bell, ... }
|
||||
* mobile: Boolean - mobile layout mode
|
||||
* multiDevice: Boolean - multi-device support enabled
|
||||
* showDeviceBadges: Boolean - whether to show device name badges
|
||||
* activityIndicator: String - 'none' | 'glow' | 'dot' | 'both'
|
||||
* viewMode: String - 'auto' | 'fit' (controls snapshot line count)
|
||||
*
|
||||
* Events:
|
||||
* tile-click { name, remoteId } - Session selected
|
||||
* tile-options { name, remoteId, sessionKey, rect } - Kebab menu opened
|
||||
*/
|
||||
export class SessionTile extends LitElement {
|
||||
static properties = {
|
||||
session: { type: Object },
|
||||
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' },
|
||||
};
|
||||
|
||||
// Render into light DOM so existing CSS applies without migration.
|
||||
createRenderRoot() { return this; }
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.session = {};
|
||||
this.mobile = false;
|
||||
this.multiDevice = false;
|
||||
this.showDeviceBadges = true;
|
||||
this.activityIndicator = 'both';
|
||||
this.viewMode = 'auto';
|
||||
}
|
||||
|
||||
get _priority() {
|
||||
return sessionPriority(this.session);
|
||||
}
|
||||
|
||||
get _isBell() {
|
||||
return this._priority === 'bell';
|
||||
}
|
||||
|
||||
get _name() {
|
||||
return this.session.name || '';
|
||||
}
|
||||
|
||||
get _sessionKey() {
|
||||
return this.session.sessionKey || this._name;
|
||||
}
|
||||
|
||||
get _remoteId() {
|
||||
return this.session.remoteId ?? null;
|
||||
}
|
||||
|
||||
get _classes() {
|
||||
let cls = 'session-tile';
|
||||
const ind = this.activityIndicator;
|
||||
if (this._isBell && (ind === 'glow' || ind === 'both')) cls += ' session-tile--bell';
|
||||
if (this._isBell && (ind === 'dot' || ind === 'both')) cls += ' session-tile--edge-bell';
|
||||
if (this.mobile) cls += ` session-tile--tier-${this._priority}`;
|
||||
return cls;
|
||||
}
|
||||
|
||||
get _snapshotHtml() {
|
||||
const snapshot = this.session.snapshot || '';
|
||||
const lineCount = this.viewMode === 'fit' ? -80 : -20;
|
||||
// Trim trailing blank lines first (see buildTileHTML comment in app.js)
|
||||
const allLines = snapshot.split('\n');
|
||||
while (allLines.length > 0 && allLines[allLines.length - 1].trim() === '') {
|
||||
allLines.pop();
|
||||
}
|
||||
const lastLines = allLines.slice(lineCount).join('\n');
|
||||
return ansiToHtml(lastLines);
|
||||
}
|
||||
|
||||
_onTileClick(e) {
|
||||
// Don't trigger tile-click when clicking the options button
|
||||
if (e.target.closest('.tile-options-btn')) return;
|
||||
this.dispatchEvent(new CustomEvent('tile-click', {
|
||||
bubbles: true, composed: true,
|
||||
detail: { name: this._name, remoteId: this._remoteId },
|
||||
}));
|
||||
}
|
||||
|
||||
_onOptionsClick(e) {
|
||||
e.stopPropagation();
|
||||
const btn = e.currentTarget;
|
||||
this.dispatchEvent(new CustomEvent('tile-options', {
|
||||
bubbles: true, composed: true,
|
||||
detail: {
|
||||
name: this._name,
|
||||
remoteId: this._remoteId,
|
||||
sessionKey: this._sessionKey,
|
||||
rect: btn.getBoundingClientRect(),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
render() {
|
||||
const name = this._name;
|
||||
const timeStr = formatTimestamp(this.session.last_activity_at || null);
|
||||
|
||||
const showBadge = this.multiDevice && this.session.deviceName && this.showDeviceBadges;
|
||||
|
||||
return html`
|
||||
<article
|
||||
class=${this._classes}
|
||||
data-session=${name}
|
||||
data-session-key=${this._sessionKey}
|
||||
tabindex="0"
|
||||
role="listitem"
|
||||
aria-label=${name}
|
||||
@click=${this._onTileClick}
|
||||
>
|
||||
<div class="tile-header">
|
||||
<span class="tile-name">${name}</span>
|
||||
${showBadge
|
||||
? html`<span class="device-badge">${this.session.deviceName}</span>`
|
||||
: nothing}
|
||||
<span class="tile-meta">${timeStr}</span>
|
||||
<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="tile-body">
|
||||
<pre>${unsafeHTML(this._snapshotHtml)}</pre>
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('session-tile', SessionTile);
|
||||
@@ -0,0 +1,55 @@
|
||||
import { LitElement, html, css } from '/vendor/lit/lit.min.js';
|
||||
|
||||
/**
|
||||
* <toast-notification> - Brief popup message that auto-hides.
|
||||
*
|
||||
* Usage:
|
||||
* const toast = document.querySelector('toast-notification');
|
||||
* toast.show('Session created');
|
||||
*/
|
||||
export class ToastNotification extends LitElement {
|
||||
static properties = {
|
||||
_message: { state: true },
|
||||
_visible: { state: true },
|
||||
};
|
||||
|
||||
static styles = css`
|
||||
:host { display: block; }
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 80px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--toast-bg, #e6edf3);
|
||||
color: var(--toast-fg, #0d1117);
|
||||
padding: 10px 24px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
.toast.visible { opacity: 1; }
|
||||
`;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._message = '';
|
||||
this._visible = false;
|
||||
this._timer = null;
|
||||
}
|
||||
|
||||
show(msg, duration = 3000) {
|
||||
this._message = msg;
|
||||
this._visible = true;
|
||||
clearTimeout(this._timer);
|
||||
this._timer = setTimeout(() => { this._visible = false; }, duration);
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`<div class="toast ${this._visible ? 'visible' : ''}" role="status" aria-live="polite" aria-atomic="true">${this._message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('toast-notification', ToastNotification);
|
||||
@@ -0,0 +1,204 @@
|
||||
// Pure utility functions extracted from app.js
|
||||
// These have zero DOM coupling and zero global state.
|
||||
|
||||
/**
|
||||
* Escape HTML special characters to safe entities.
|
||||
* @param {string} str
|
||||
* @returns {string}
|
||||
*/
|
||||
export function escapeHtml(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a Unix timestamp (seconds) into a relative time string.
|
||||
* @param {number|null|undefined} ts
|
||||
* @returns {string}
|
||||
*/
|
||||
export 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'}
|
||||
*/
|
||||
export 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. Lower = higher priority. */
|
||||
export const PRIORITY_RANK = { bell: 0, active: 1, idle: 2 };
|
||||
|
||||
/**
|
||||
* Sort sessions by priority (ascending rank). Returns new array.
|
||||
* @param {object[]} sessions
|
||||
* @returns {object[]}
|
||||
*/
|
||||
export 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 sessions by search query (case-insensitive substring on name).
|
||||
* @param {object[]} sessions
|
||||
* @param {string|null} query
|
||||
* @returns {object[]}
|
||||
*/
|
||||
export function filterByQuery(sessions, query) {
|
||||
if (!query) return sessions;
|
||||
const q = query.toLowerCase();
|
||||
return sessions.filter((s) => (s.name || '').toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect sessions that transitioned to new/increased bell state.
|
||||
* @param {object[]} prev
|
||||
* @param {object[]} next
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export 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.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function generateDeviceId() {
|
||||
return 'd-' + Math.random().toString(36).padEnd(10, '0').slice(2, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a heartbeat payload object.
|
||||
* @param {string} device_id
|
||||
* @param {string|null} viewing_session
|
||||
* @param {string} view_mode
|
||||
* @param {number} last_interaction_at
|
||||
* @returns {object}
|
||||
*/
|
||||
export 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 };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ANSI escape -> HTML span converter (SGR codes only)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ANSI_COLORS = [
|
||||
'#2e3436','#cc0000','#4e9a06','#c4a000','#3465a4','#75507b','#06989a','#d3d7cf',
|
||||
'#555753','#ef2929','#8ae234','#fce94f','#729fcf','#ad7fa8','#34e2e2','#eeeeec'
|
||||
];
|
||||
|
||||
function ansi256Color(n) {
|
||||
if (n < 16) return ANSI_COLORS[n];
|
||||
if (n >= 232) { const g = 8 + (n - 232) * 10; return `rgb(${g},${g},${g})`; }
|
||||
n -= 16;
|
||||
const r = Math.floor(n / 36) * 51;
|
||||
const g2 = Math.floor((n % 36) / 6) * 51;
|
||||
const b = (n % 6) * 51;
|
||||
return `rgb(${r},${g2},${b})`;
|
||||
}
|
||||
|
||||
function ansiParamsToStyle(params) {
|
||||
const styles = [];
|
||||
let k = 0;
|
||||
while (k < params.length) {
|
||||
const 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') {
|
||||
styles.push('color:' + ansi256Color(parseInt(params[k + 2], 10) || 0));
|
||||
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') {
|
||||
styles.push('background:' + ansi256Color(parseInt(params[k + 2], 10) || 0));
|
||||
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(';') : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert ANSI SGR escape sequences to HTML spans with inline styles.
|
||||
* @param {string} raw
|
||||
* @returns {string}
|
||||
*/
|
||||
export function ansiToHtml(raw) {
|
||||
if (!raw) return '';
|
||||
let out = '';
|
||||
let spans = 0;
|
||||
let i = 0;
|
||||
const len = raw.length;
|
||||
|
||||
while (i < len) {
|
||||
if (raw[i] === '\x1b' && raw[i + 1] === '[') {
|
||||
let j = i + 2;
|
||||
while (j < len && raw[j] !== 'm' && j - i < 20) j++;
|
||||
if (j < len && raw[j] === 'm') {
|
||||
const params = raw.substring(i + 2, j).split(';');
|
||||
const style = ansiParamsToStyle(params);
|
||||
if (style === 'reset') {
|
||||
while (spans > 0) { out += '</span>'; spans--; }
|
||||
} else if (style) {
|
||||
out += '<span style="' + style + '">';
|
||||
spans++;
|
||||
}
|
||||
i = j + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const ch = raw[i];
|
||||
if (ch === '<') out += '<';
|
||||
else if (ch === '>') out += '>';
|
||||
else if (ch === '&') out += '&';
|
||||
else if (ch === '"') out += '"';
|
||||
else out += ch;
|
||||
i++;
|
||||
}
|
||||
while (spans > 0) { out += '</span>'; spans--; }
|
||||
return out;
|
||||
}
|
||||
@@ -30,7 +30,7 @@
|
||||
<button id="new-session-btn" class="header-btn" aria-label="New session"><svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 3v10M3 8h10"/></svg></button>
|
||||
<button id="view-mode-btn" class="header-btn" aria-label="Toggle view mode" title="View: auto"><svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor"><rect x="1" y="1" width="6" height="6" rx="1"/><rect x="9" y="1" width="6" height="6" rx="1"/><rect x="1" y="9" width="6" height="6" rx="1"/><rect x="9" y="9" width="6" height="6" rx="1"/></svg></button>
|
||||
<button id="settings-btn" class="header-btn" aria-label="Settings"><svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="2.5"/><path d="M8 1v2M8 13v2M1 8h2M13 8h2M2.9 2.9l1.4 1.4M11.7 11.7l1.4 1.4M2.9 13.1l1.4-1.4M11.7 4.3l1.4-1.4"/></svg></button>
|
||||
<span id="connection-status"></span>
|
||||
<connection-status id="connection-status" level="ok"></connection-status>
|
||||
</div>
|
||||
</header>
|
||||
<div id="filter-bar" class="filter-bar"></div>
|
||||
@@ -121,16 +121,13 @@
|
||||
</div>
|
||||
|
||||
<!-- ── Session pill (persistent overlay button) ────────────────────────── -->
|
||||
<button id="session-pill" class="session-pill hidden" aria-label="Switch session">
|
||||
<span id="session-pill-label" class="session-pill__label"></span>
|
||||
<span id="session-pill-bell" class="session-pill__bell hidden" aria-hidden="true"><svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor"><path d="M8 1a1 1 0 0 1 1 1v.3A4.5 4.5 0 0 1 12.5 7v2.5l1 2H2.5l1-2V7A4.5 4.5 0 0 1 7 2.3V2a1 1 0 0 1 1-1zM6.5 13a1.5 1.5 0 0 0 3 0z"/></svg></span>
|
||||
</button>
|
||||
<session-pill id="session-pill"></session-pill>
|
||||
|
||||
<!-- ── 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>
|
||||
|
||||
<!-- ── Toast notification ──────────────────────────────────────────────── -->
|
||||
<div id="toast" class="toast hidden" role="status" aria-live="polite" aria-atomic="true"></div>
|
||||
<toast-notification id="toast"></toast-notification>
|
||||
|
||||
<!-- ── Settings ──────────────────────────────────────────────────────────── -->
|
||||
<div id="settings-backdrop" class="settings-backdrop hidden"></div>
|
||||
@@ -294,5 +291,11 @@
|
||||
<script src="/vendor/addon-image.js"></script>
|
||||
<script src="/app.js" defer></script>
|
||||
<script src="/terminal.js" defer></script>
|
||||
<script type="module">
|
||||
import '/components/toast-notification.js';
|
||||
import '/components/connection-status.js';
|
||||
import '/components/session-pill.js';
|
||||
import '/components/session-tile.js';
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+55
File diff suppressed because one or more lines are too long
@@ -69,27 +69,22 @@ def test_html_expanded_view_elements() -> None:
|
||||
|
||||
|
||||
def test_html_bottom_sheet() -> None:
|
||||
"""id=bottom-sheet, sheet-list, sheet-backdrop, session-pill, session-pill-label, session-pill-bell."""
|
||||
"""id=bottom-sheet, sheet-list, sheet-backdrop, session-pill."""
|
||||
soup = _SOUP
|
||||
for id_ in (
|
||||
"bottom-sheet",
|
||||
"sheet-list",
|
||||
"sheet-backdrop",
|
||||
"session-pill",
|
||||
"session-pill-label",
|
||||
"session-pill-bell",
|
||||
):
|
||||
assert soup.find(id=id_), f"Missing element with id='{id_}'"
|
||||
|
||||
|
||||
def test_html_toast() -> None:
|
||||
"""id=toast, aria-live=polite."""
|
||||
"""id=toast (toast-notification component)."""
|
||||
soup = _SOUP
|
||||
toast = soup.find(id="toast")
|
||||
assert toast, "Missing element with id='toast'"
|
||||
assert toast.get("aria-live") == "polite", ( # type: ignore[union-attr]
|
||||
f"toast missing aria-live=polite, got: {toast.get('aria-live')!r}" # type: ignore[union-attr]
|
||||
)
|
||||
|
||||
|
||||
def test_html_scripts() -> None:
|
||||
@@ -228,16 +223,12 @@ def test_html_element_classes() -> None:
|
||||
"reconnect-overlay",
|
||||
"needs position:absolute to overlay terminal",
|
||||
),
|
||||
("session-pill", "session-pill", "needs position:fixed to float"),
|
||||
("toast", "toast", "needs position:fixed and animation"),
|
||||
("back-btn", "back-btn", "needs border and hover styles"),
|
||||
(
|
||||
"expanded-session-name",
|
||||
"expanded-session-name",
|
||||
"needs text-overflow:ellipsis",
|
||||
),
|
||||
("session-pill-label", "session-pill__label", "needs max-width truncation"),
|
||||
("session-pill-bell", "session-pill__bell", "needs amber var(--bell) color"),
|
||||
(
|
||||
"session-sidebar",
|
||||
"session-sidebar",
|
||||
|
||||
@@ -4005,24 +4005,20 @@ def test_render_grid_closes_stale_flyout() -> None:
|
||||
|
||||
|
||||
def test_tile_click_handler_guards_options_btn() -> None:
|
||||
"""Tile click handler early return must guard .tile-options-btn (not .tile-delete).
|
||||
"""Tile click events are handled by session-tile component via tile-click/tile-options events.
|
||||
|
||||
BUG: Previously the guard checked .tile-delete which was removed in Phase 3.
|
||||
Clicking ⋮ triggered BOTH flyout opening AND openSession() navigation.
|
||||
Fix: guard must check .tile-options-btn to stop event from reaching openSession().
|
||||
With session-tile Lit components, the options-button guard is handled inside the component
|
||||
itself. renderGrid listens for 'tile-click' and 'tile-options' custom events instead of
|
||||
binding raw click handlers with .tile-options-btn guards.
|
||||
"""
|
||||
# The tile click handler is inside renderGrid — find it
|
||||
render_grid_body = _JS.split("function renderGrid")[1].split("\nfunction ")[0]
|
||||
assert "tile-options-btn" in render_grid_body, (
|
||||
"Tile click handler must guard against .tile-options-btn clicks — "
|
||||
"clicking ⋮ must NOT trigger openSession()"
|
||||
# session-tile components fire custom events; renderGrid must listen for them
|
||||
assert "tile-click" in render_grid_body, (
|
||||
"renderGrid must listen for 'tile-click' events from session-tile components"
|
||||
)
|
||||
assert "tile-options" in render_grid_body, (
|
||||
"renderGrid must listen for 'tile-options' events from session-tile components"
|
||||
)
|
||||
# Confirm the old broken guard is gone
|
||||
assert (
|
||||
"'tile-delete'" not in render_grid_body
|
||||
and '"tile-delete"' not in render_grid_body
|
||||
or ("tile-options-btn" in render_grid_body)
|
||||
), "Guard must use .tile-options-btn, not the old .tile-delete which was removed"
|
||||
|
||||
|
||||
def test_flyout_delegation_handler_no_stop_propagation() -> None:
|
||||
|
||||
Reference in New Issue
Block a user