feat: hover preview popover on dashboard tiles
Desktop only (guarded by ontouchstart check). On mouseenter of a session tile, shows a floating popover with the full tmux snapshot (all lines, not the cropped 20-line preview). Positioned right of tile if space, else left. 100ms delay prevents flicker on brush-over. Data from _currentSessions (already in memory, zero API calls). Also calls hidePreview() in openSession() so the popover is cleaned up when switching to fullscreen view.
This commit is contained in:
@@ -125,6 +125,8 @@ let _pollingTimer;
|
|||||||
let _heartbeatTimer;
|
let _heartbeatTimer;
|
||||||
let _notificationPermission = 'default';
|
let _notificationPermission = 'default';
|
||||||
let _pollFailCount = 0;
|
let _pollFailCount = 0;
|
||||||
|
let _previewPopover = null;
|
||||||
|
let _previewTimer = null;
|
||||||
|
|
||||||
// ─── DOM helpers ──────────────────────────────────────────────────────────────
|
// ─── DOM helpers ──────────────────────────────────────────────────────────────
|
||||||
function $(id) {
|
function $(id) {
|
||||||
@@ -509,6 +511,62 @@ function renderGrid(sessions) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Hover preview popover (desktop only — no hover on touch devices)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function showPreview(tile) {
|
||||||
|
var name = tile.dataset.session;
|
||||||
|
if (!name || !_currentSessions) return;
|
||||||
|
|
||||||
|
var session = _currentSessions.find(function (s) { return s.name === name; });
|
||||||
|
if (!session || !session.snapshot) return;
|
||||||
|
|
||||||
|
hidePreview();
|
||||||
|
|
||||||
|
var popover = document.createElement('div');
|
||||||
|
popover.className = 'preview-popover';
|
||||||
|
var pre = document.createElement('pre');
|
||||||
|
pre.textContent = session.snapshot;
|
||||||
|
popover.appendChild(pre);
|
||||||
|
document.body.appendChild(popover);
|
||||||
|
|
||||||
|
// Position: right of tile if room, else left
|
||||||
|
var rect = tile.getBoundingClientRect();
|
||||||
|
var popW = popover.offsetWidth;
|
||||||
|
var popH = popover.offsetHeight;
|
||||||
|
var left, top;
|
||||||
|
|
||||||
|
if (rect.right + popW + 12 < window.innerWidth) {
|
||||||
|
left = rect.right + 8;
|
||||||
|
} else {
|
||||||
|
left = rect.left - popW - 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vertically: align top with tile, clamp to viewport
|
||||||
|
top = rect.top;
|
||||||
|
if (top + popH > window.innerHeight - 16) {
|
||||||
|
top = window.innerHeight - popH - 16;
|
||||||
|
}
|
||||||
|
if (top < 8) top = 8;
|
||||||
|
|
||||||
|
popover.style.left = left + 'px';
|
||||||
|
popover.style.top = top + 'px';
|
||||||
|
|
||||||
|
_previewPopover = popover;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hidePreview() {
|
||||||
|
if (_previewTimer) {
|
||||||
|
clearTimeout(_previewTimer);
|
||||||
|
_previewTimer = null;
|
||||||
|
}
|
||||||
|
if (_previewPopover) {
|
||||||
|
_previewPopover.remove();
|
||||||
|
_previewPopover = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Notification permission ────────────────────────────────────────────────
|
// ─── Notification permission ────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -627,6 +685,7 @@ function updatePillBell() {
|
|||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
async function openSession(name, opts = {}) {
|
async function openSession(name, opts = {}) {
|
||||||
|
hidePreview();
|
||||||
_viewingSession = name;
|
_viewingSession = name;
|
||||||
_viewMode = 'fullscreen';
|
_viewMode = 'fullscreen';
|
||||||
|
|
||||||
@@ -1010,6 +1069,22 @@ function bindStaticEventListeners() {
|
|||||||
document.addEventListener('keydown', handleGlobalKeydown);
|
document.addEventListener('keydown', handleGlobalKeydown);
|
||||||
on($('session-pill'), 'click', openBottomSheet);
|
on($('session-pill'), 'click', openBottomSheet);
|
||||||
on($('sheet-backdrop'), 'click', closeBottomSheet);
|
on($('sheet-backdrop'), 'click', closeBottomSheet);
|
||||||
|
|
||||||
|
// Hover preview — delegated on grid container (tiles are re-rendered each poll)
|
||||||
|
var gridEl = $('session-grid');
|
||||||
|
if (gridEl && !('ontouchstart' in window)) { // desktop only
|
||||||
|
gridEl.addEventListener('mouseenter', function (e) {
|
||||||
|
var tile = e.target.closest('.session-tile');
|
||||||
|
if (!tile) return;
|
||||||
|
_previewTimer = setTimeout(function () { showPreview(tile); }, 100);
|
||||||
|
}, true); // useCapture: true for delegation with mouseenter
|
||||||
|
|
||||||
|
gridEl.addEventListener('mouseleave', function (e) {
|
||||||
|
var tile = e.target.closest('.session-tile');
|
||||||
|
if (!tile) return;
|
||||||
|
hidePreview();
|
||||||
|
}, true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Test-only helpers ────────────────────────────────────────────────────────
|
// ─── Test-only helpers ────────────────────────────────────────────────────────
|
||||||
@@ -1117,6 +1192,9 @@ if (typeof module !== 'undefined' && module.exports) {
|
|||||||
closeBottomSheet,
|
closeBottomSheet,
|
||||||
renderSheetList,
|
renderSheetList,
|
||||||
updateSessionPill,
|
updateSessionPill,
|
||||||
|
// Hover preview popover
|
||||||
|
showPreview,
|
||||||
|
hidePreview,
|
||||||
// Test-only helpers
|
// Test-only helpers
|
||||||
_setCurrentSessions,
|
_setCurrentSessions,
|
||||||
_setPaletteFilteredSessions,
|
_setPaletteFilteredSessions,
|
||||||
|
|||||||
@@ -959,6 +959,35 @@ body {
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Hover preview popover (desktop dashboard)
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
.preview-popover {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 500;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
min-width: 480px;
|
||||||
|
max-width: 640px;
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||||
|
pointer-events: none; /* don't interfere with mouse leaving tile */
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-popover pre {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: pre;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
Responsive overlay sidebar at <960px
|
Responsive overlay sidebar at <960px
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|||||||
@@ -2050,4 +2050,16 @@ test('openSession mounts terminal AFTER connect POST, not inside animation timer
|
|||||||
'_openTerminal must appear AFTER the /connect POST in the source');
|
'_openTerminal must appear AFTER the /connect POST in the source');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Hover preview popover ---
|
||||||
|
|
||||||
|
test('app.js has hover preview popover with desktop-only guard', () => {
|
||||||
|
const source = fs.readFileSync(
|
||||||
|
new URL('../app.js', import.meta.url), 'utf8'
|
||||||
|
);
|
||||||
|
assert.ok(source.includes('preview-popover'), 'must create popover with preview-popover class');
|
||||||
|
assert.ok(source.includes('ontouchstart'), 'must guard against touch devices (desktop only)');
|
||||||
|
assert.ok(source.includes('session.snapshot'), 'must use full snapshot text (not lastLines)');
|
||||||
|
assert.ok(source.includes('hidePreview'), 'must have cleanup on mouseleave');
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -613,3 +613,21 @@ def test_sidebar_item_has_flex_shrink_zero():
|
|||||||
assert "flex-shrink: 0" in block, (
|
assert "flex-shrink: 0" in block, (
|
||||||
".sidebar-item must have flex-shrink:0 to prevent compression"
|
".sidebar-item must have flex-shrink:0 to prevent compression"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Hover preview popover (desktop dashboard)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
|
def test_preview_popover_css_exists():
|
||||||
|
"""Preview popover must have CSS rules with position: fixed."""
|
||||||
|
css = read_css()
|
||||||
|
assert ".preview-popover" in css, "must have .preview-popover CSS class"
|
||||||
|
popover_idx = css.index(".preview-popover")
|
||||||
|
block_start = css.index("{", popover_idx)
|
||||||
|
block_end = css.index("}", block_start)
|
||||||
|
block = css[block_start:block_end]
|
||||||
|
assert "position: fixed" in block or "position:fixed" in block, (
|
||||||
|
".preview-popover must use position: fixed"
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user