diff --git a/muxplex/auth.py b/muxplex/auth.py index 2afdb39..06ea5bd 100644 --- a/muxplex/auth.py +++ b/muxplex/auth.py @@ -136,6 +136,20 @@ def authenticate_pam(username: str, password: str) -> bool: # Paths that bypass auth (login page itself, static assets it needs) _AUTH_EXEMPT_PATHS = {"/login", "/auth/mode", "/auth/logout", "/api/instance-info"} +# File extensions that are always served without auth — the login page needs +# its own CSS, JS, images, and fonts before the user has a session cookie. +_STATIC_EXTENSIONS = { + ".css", + ".js", + ".svg", + ".png", + ".ico", + ".woff", + ".woff2", + ".ttf", + ".map", +} + # Socket-level localhost addresses — cannot be forged via HTTP headers _LOCALHOST_ADDRS = {"127.0.0.1", "::1"} @@ -168,12 +182,17 @@ class AuthMiddleware(BaseHTTPMiddleware): if request.url.path in _AUTH_EXEMPT_PATHS: return await call_next(request) - # 3. Valid session cookie + # 3. Static assets — login page needs its CSS/JS/images before auth + path = request.url.path + if any(path.endswith(ext) for ext in _STATIC_EXTENSIONS): + return await call_next(request) + + # 4. Valid session cookie cookie = request.cookies.get("muxplex_session") if cookie and verify_session_cookie(self.secret, cookie, self.ttl_seconds): return await call_next(request) - # 4. Authorization: Basic header + # 5. Authorization: Basic header auth_header = request.headers.get("authorization", "") if auth_header.lower().startswith("basic "): try: @@ -186,7 +205,7 @@ class AuthMiddleware(BaseHTTPMiddleware): pass return JSONResponse({"detail": "Invalid credentials"}, status_code=401) - # 5. No auth — redirect browsers, 401 for API clients + # 6. No auth — redirect browsers, 401 for API clients accept = request.headers.get("accept", "") if "application/json" in accept: return JSONResponse({"detail": "Authentication required"}, status_code=401) diff --git a/muxplex/cli.py b/muxplex/cli.py index c7b8292..140703a 100644 --- a/muxplex/cli.py +++ b/muxplex/cli.py @@ -21,6 +21,110 @@ from muxplex.auth import ( _system_service_path = Path("/etc/systemd/system/muxplex.service") +def _get_install_info() -> dict: + """Detect how muxplex was installed using PEP 610 direct_url.json. + + Returns dict with keys: + source: 'git' | 'editable' | 'pypi' | 'unknown' + version: installed version string + commit: installed commit sha (git only) + url: git repo URL (git only) + """ + import json + from importlib.metadata import PackageNotFoundError, distribution + + info: dict = { + "source": "unknown", + "version": "0.0.0", + "commit": None, + "url": None, + } + + try: + dist = distribution("muxplex") + info["version"] = dist.metadata["Version"] + + du_text = dist.read_text("direct_url.json") + if du_text: + du = json.loads(du_text) + + if "vcs_info" in du: + info["source"] = "git" + info["commit"] = du["vcs_info"].get("commit_id", "") + info["url"] = du.get("url", "") + elif "dir_info" in du and du["dir_info"].get("editable"): + info["source"] = "editable" + else: + info["source"] = "unknown" + else: + # No direct_url.json → probably PyPI + info["source"] = "pypi" + except PackageNotFoundError: + pass + + return info + + +def _check_for_update(info: dict) -> tuple[bool, str]: + """Check if an update is available. Returns (update_available, message). + + For git: compares installed commit_id against remote HEAD sha. + For pypi: compares installed version against latest PyPI version. + For editable: always returns (False, "editable install"). + For unknown: always returns (True, "unknown install source"). + """ + import json + import urllib.request + + if info["source"] == "editable": + return False, "editable install — manage updates manually" + + if info["source"] == "git": + try: + result = subprocess.run( + ["git", "ls-remote", info["url"], "HEAD"], + capture_output=True, + text=True, + timeout=10, + ) + if result.returncode != 0: + return True, "could not check remote — upgrading to be safe" + + remote_sha = ( + result.stdout.strip().split()[0] if result.stdout.strip() else "" + ) + local_sha = info["commit"] or "" + + if not remote_sha: + return True, "could not read remote sha — upgrading to be safe" + + if local_sha == remote_sha: + return False, f"up to date (commit {local_sha[:8]})" + else: + return True, f"update available ({local_sha[:8]} → {remote_sha[:8]})" + except Exception: + return True, "check failed — upgrading to be safe" + + if info["source"] == "pypi": + try: + req = urllib.request.Request( + "https://pypi.org/pypi/muxplex/json", + headers={"Accept": "application/json"}, + ) + with urllib.request.urlopen(req, timeout=10) as resp: + data = json.loads(resp.read()) + latest = data["info"]["version"] + if latest == info["version"]: + return False, f"up to date (v{info['version']})" + else: + return True, f"update available (v{info['version']} → v{latest})" + except Exception: + return True, "could not check PyPI — upgrading to be safe" + + # Unknown source + return True, "unknown install source — upgrading to be safe" + + def reset_secret() -> None: """Regenerate the signing secret and warn that all sessions are now invalid.""" path = get_secret_path() @@ -116,14 +220,26 @@ def doctor() -> None: else: print(" Install: sudo apt install ttyd") - # muxplex version + # muxplex version + install source + update check try: from importlib.metadata import version as pkg_version # noqa: PLC0415 muxplex_version = pkg_version("muxplex") except Exception: muxplex_version = "dev" - print(f" {ok_mark} muxplex {muxplex_version}") + + info = _get_install_info() + source_label = info["source"] + if info["commit"]: + source_label += f" @ {info['commit'][:8]}" + print(f" {ok_mark} muxplex {muxplex_version} (installed via {source_label})") + + update_available, update_msg = _check_for_update(info) + if update_available: + print(f" {warn_mark} Update: {update_msg}") + print(" Run: muxplex upgrade") + else: + print(f" {ok_mark} {update_msg}") # Settings file from muxplex.settings import SETTINGS_PATH # noqa: PLC0415 @@ -171,7 +287,18 @@ def doctor() -> None: if sys.platform == "darwin": plist = Path.home() / "Library" / "LaunchAgents" / "com.muxplex.plist" if plist.exists(): - print(f" {ok_mark} Service: launchd agent installed ({plist})") + uid = os.getuid() + result = subprocess.run( + ["launchctl", "print", f"gui/{uid}/com.muxplex"], + capture_output=True, + text=True, + ) + if result.returncode == 0: + print(f" {ok_mark} Service: launchd agent running") + else: + print( + f" {warn_mark} Service: launchd agent installed but not running ({plist})" + ) else: print( f" {warn_mark} Service: not installed (run: muxplex install-service)" @@ -220,21 +347,39 @@ def _install_launchd(executable: str) -> None: import shutil # Prefer the entry point script ('muxplex') so macOS shows the correct - # process name in Activity Monitor, launchctl list, and login items. - # Fall back to 'python -m muxplex' if the entry point isn't on PATH. + # process name in Activity Monitor and launchctl list. muxplex_bin = shutil.which("muxplex") if muxplex_bin: program_args = f""" {muxplex_bin} + --host + 0.0.0.0 """ else: program_args = f""" {executable} -m muxplex + --host + 0.0.0.0 """ + # Build PATH that includes Homebrew and common tool locations. + # launchd agents run with a minimal PATH (/usr/bin:/bin:/usr/sbin:/sbin). + # Homebrew binaries (tmux, ttyd, uv, git) won't be found without this. + _homebrew_paths = [ + "/opt/homebrew/bin", # Apple Silicon Homebrew + "/usr/local/bin", # Intel Homebrew + system extras + "/opt/homebrew/sbin", + "/usr/local/sbin", + ] + _user_local = str(Path.home() / ".local" / "bin") # uv/pip tool installs + _extra = [_user_local] + _homebrew_paths + _base = "/usr/bin:/bin:/usr/sbin:/sbin" + _service_path = ":".join(_extra) + ":" + _base + label = "com.muxplex" + uid = os.getuid() plist = f""" @@ -243,6 +388,11 @@ def _install_launchd(executable: str) -> None: {label} ProgramArguments {program_args} + EnvironmentVariables + + PATH + {_service_path} + RunAtLoad KeepAlive @@ -256,12 +406,44 @@ def _install_launchd(executable: str) -> None: """ path = Path.home() / "Library" / "LaunchAgents" / f"{label}.plist" path.parent.mkdir(parents=True, exist_ok=True) + + # Unload existing service first (ignore errors if not loaded) + subprocess.run( + ["launchctl", "bootout", f"gui/{uid}/{label}"], + capture_output=True, + ) + path.write_text(plist) print(f"Launch agent written to {path}") - print("Enable with:") - print(f" launchctl load {path}") - print("Disable with:") - print(f" launchctl unload {path}") + print() + + # Load the service using the modern bootstrap API + result = subprocess.run( + ["launchctl", "bootstrap", f"gui/{uid}", str(path)], + capture_output=True, + text=True, + ) + if result.returncode == 0: + print(f" Service started (launchctl bootstrap gui/{uid})") + else: + # Fallback to legacy load for older macOS + result2 = subprocess.run( + ["launchctl", "load", str(path)], + capture_output=True, + text=True, + ) + if result2.returncode == 0: + print(" Service started (launchctl load)") + else: + print(" Could not auto-start. Try manually:") + print(f" launchctl bootstrap gui/{uid} {path}") + + print() + print("Management commands:") + print(f" Stop: launchctl bootout gui/{uid}/{label}") + print(f" Start: launchctl bootstrap gui/{uid} {path}") + print(f" Status: launchctl print gui/{uid}/{label}") + print(" Logs: tail -f /tmp/muxplex.log") def _install_systemd(executable: str, *, system: bool = False) -> None: @@ -313,6 +495,143 @@ def install_service(*, system: bool = False) -> None: _install_systemd(executable, system=system) +def upgrade(*, force: bool = False) -> None: + """Upgrade muxplex to the latest version and restart the service.""" + print("\nmuxplex upgrade\n") + + # Show current install info + info = _get_install_info() + commit_suffix = f" (commit {info['commit'][:8]})" if info["commit"] else "" + print(f" Installed: v{info['version']}{commit_suffix} via {info['source']}") + + if not force: + update_available, message = _check_for_update(info) + print(f" Status: {message}") + + if not update_available: + print( + "\n Already up to date." + " Use 'muxplex upgrade --force' to reinstall anyway.\n" + ) + return + else: + print(" Status: --force specified — skipping version check") + + # 1. Detect platform and stop service + if sys.platform == "darwin": + label = "com.muxplex" + uid = os.getuid() + plist = Path.home() / "Library" / "LaunchAgents" / f"{label}.plist" + if plist.exists(): + print(" Stopping launchd service...") + subprocess.run( + ["launchctl", "bootout", f"gui/{uid}/{label}"], capture_output=True + ) + else: + print(" No launchd service found (skipping stop)") + else: + # Linux/WSL — check systemd + result = subprocess.run( + ["systemctl", "--user", "is-active", "muxplex"], + capture_output=True, + text=True, + ) + if result.returncode == 0: + print(" Stopping systemd service...") + subprocess.run( + ["systemctl", "--user", "stop", "muxplex"], capture_output=True + ) + else: + print(" No active systemd service found (skipping stop)") + + # 2. Reinstall via uv tool install + print(" Installing latest version...") + uv_path = shutil.which("uv") + if uv_path: + result = subprocess.run( + [ + uv_path, + "tool", + "install", + "git+https://github.com/bkrabach/muxplex", + "--force", + ], + capture_output=True, + text=True, + ) + if result.returncode != 0: + print(f" ERROR: uv tool install failed:\n{result.stderr}") + return + print(" Installed successfully") + else: + # Fallback: pip + pip_path = shutil.which("pip") or shutil.which("pip3") + if pip_path: + result = subprocess.run( + [ + pip_path, + "install", + "--upgrade", + "git+https://github.com/bkrabach/muxplex", + ], + capture_output=True, + text=True, + ) + if result.returncode != 0: + print(f" ERROR: pip install failed:\n{result.stderr}") + return + print(" Installed successfully") + else: + print(" ERROR: neither uv nor pip found — cannot upgrade") + return + + # 3. Regenerate service file (picks up any plist/unit changes) + print(" Regenerating service file...") + install_service(system=False) + + # 4. Restart service + if sys.platform == "darwin": + label = "com.muxplex" + uid = os.getuid() + plist = Path.home() / "Library" / "LaunchAgents" / f"{label}.plist" + if plist.exists(): + print(" Starting launchd service...") + result = subprocess.run( + ["launchctl", "bootstrap", f"gui/{uid}", str(plist)], + capture_output=True, + text=True, + ) + if result.returncode == 0: + print(" Service started") + else: + # Fallback to legacy load for older macOS + subprocess.run(["launchctl", "load", str(plist)], capture_output=True) + print(" Service started (legacy)") + else: + print(" Service file not found — run: muxplex install-service") + else: + result = subprocess.run( + ["systemctl", "--user", "is-enabled", "muxplex"], + capture_output=True, + text=True, + ) + if result.returncode == 0: + print(" Restarting systemd service...") + subprocess.run( + ["systemctl", "--user", "daemon-reload"], capture_output=True + ) + subprocess.run( + ["systemctl", "--user", "start", "muxplex"], capture_output=True + ) + print(" Service started") + else: + print(" Service not enabled — run: muxplex install-service") + + # 5. Doctor check + print("\n Verifying...") + doctor() + + def main() -> None: """CLI entry point.""" parser = argparse.ArgumentParser( @@ -356,6 +675,21 @@ def main() -> None: sub.add_parser("doctor", help="Check dependencies and system status") + upgrade_parser = sub.add_parser( + "upgrade", help="Upgrade muxplex to latest version and restart service" + ) + upgrade_parser.add_argument( + "--force", + action="store_true", + help="Force reinstall even if already up to date", + ) + update_parser = sub.add_parser("update", help="Alias for upgrade") + update_parser.add_argument( + "--force", + action="store_true", + help="Force reinstall even if already up to date", + ) + args = parser.parse_args() if args.command == "install-service": @@ -366,6 +700,8 @@ def main() -> None: reset_secret() elif args.command == "doctor": doctor() + elif args.command in ("upgrade", "update"): + upgrade(force=getattr(args, "force", False)) else: _check_dependencies() serve( diff --git a/muxplex/frontend/app.js b/muxplex/frontend/app.js index 60b3c6a..65dbc8f 100644 --- a/muxplex/frontend/app.js +++ b/muxplex/frontend/app.js @@ -145,8 +145,12 @@ const DISPLAY_DEFAULTS = { gridColumns: 'auto', bellSound: false, notificationPermission: 'default', + viewMode: 'auto', }; + +var VIEW_MODES = ['auto', 'fit']; const NEW_SESSION_DEFAULT_TEMPLATE = 'tmux new-session -d -s {name}'; +const DELETE_SESSION_DEFAULT_TEMPLATE = 'tmux kill-session -t {name}'; // ─── DOM helpers ────────────────────────────────────────────────────────────── function $(id) { @@ -207,8 +211,8 @@ function trackInteraction() { // ─── State restoration ─────────────────────────────────────────────────────── /** * Restore application state from the server on page load. - * Calls GET /api/state and, if an active session exists, re-opens it - * without POSTing to /connect (ttyd is already running). + * Calls GET /api/state and, if an active session exists, re-opens it, + * skipping only the zoom animation (ttyd is re-spawned to handle service restarts). * Always resolves — errors are logged as warnings so the app can start normally. * @returns {Promise} */ @@ -217,7 +221,7 @@ async function restoreState() { const res = await api('GET', '/api/state'); const state = await res.json(); if (state.active_session) { - await openSession(state.active_session, { skipConnect: true }); + await openSession(state.active_session, { skipAnimation: true }); } } catch (err) { console.warn('[restoreState] could not restore previous session:', err); @@ -322,6 +326,7 @@ async function pollSessions() { renderSidebar(merged, _viewingSession); handleBellTransitions(prev, merged); updateSessionPill(merged); + updateFaviconBadge(); } /** @@ -470,9 +475,11 @@ function buildTileHTML(session, index, mobile) { ? `${escapeHtml(session.deviceName)}` : ''; - // Last 20 lines of snapshot + // Last N lines of snapshot — show more in fit mode so tall tiles fill const snapshot = session.snapshot || ''; - const lastLines = snapshot.split('\n').slice(-20).join('\n'); + var _tileDs = loadDisplaySettings(); + var _lineCount = (_tileDs.viewMode === 'fit') ? -80 : -20; + const lastLines = snapshot.split('\n').slice(_lineCount).join('\n'); const sourceUrlAttr = session.sourceUrl ? ` data-source-url="${escapeHtml(session.sourceUrl)}"` : ''; return ( @@ -920,6 +927,20 @@ function renderGrid(sessions) { updatePillBell(); } + // Reapply view mode layout after grid HTML is rebuilt + var currentDs = loadDisplaySettings(); + var currentMode = currentDs.viewMode || 'auto'; + if (currentMode === 'fit' && grid) { + grid.classList.add('session-grid--fit'); + requestAnimationFrame(function() { + applyFitLayout(grid); + // Scroll each tile's pre to the bottom so content anchors at the bottom (like a real terminal) + grid.querySelectorAll('.tile-body pre').forEach(function(pre) { + pre.scrollTop = pre.scrollHeight; + }); + }); + } + } // --------------------------------------------------------------------------- @@ -1043,7 +1064,14 @@ function handleBellTransitions(prevSessions, nextSessions) { */ async function sendHeartbeat() { try { - const payload = buildHeartbeatPayload(_deviceId, _viewingSession, _viewMode, _lastInteractionAt); + // When the browser tab is hidden (user switched tabs or minimized), report + // viewing_session as null. This prevents the server from clearing bells on + // the session — the user isn't actually looking at it, so activity should + // accumulate and show in the favicon badge / tab indicators. + var effectiveSession = (typeof document !== 'undefined' && document.hidden) + ? null + : _viewingSession; + const payload = buildHeartbeatPayload(_deviceId, effectiveSession, _viewMode, _lastInteractionAt); await api('POST', '/api/heartbeat', payload); } catch (err) { console.warn('[sendHeartbeat] heartbeat failed:', err); @@ -1096,13 +1124,68 @@ function updatePillBell() { if (hasBell) el.classList.remove('hidden'); else el.classList.add('hidden'); } +// --------------------------------------------------------------------------- +// Dynamic favicon — activity dot overlay +// --------------------------------------------------------------------------- + +var _originalFavicon = null; // cached original favicon href + +/** + * Update the favicon with an activity dot if any session has unseen bells. + * Uses a 32x32 canvas to draw the original favicon + a colored circle overlay. + * Restores the original favicon when there are no unseen bells. + */ +function updateFaviconBadge() { + var hasActivity = _currentSessions && _currentSessions.some(function (s) { + return s.bell && s.bell.unseen_count > 0; + }); + + var link = document.querySelector('link[rel="icon"][sizes="32x32"]') || + document.querySelector('link[rel="icon"]'); + if (!link) return; + + // Cache the original favicon on first call + if (!_originalFavicon) _originalFavicon = link.href; + + if (!hasActivity) { + // Restore original favicon when no activity + if (link.href !== _originalFavicon) link.href = _originalFavicon; + return; + } + + // Draw favicon + activity dot on canvas + var canvas = document.createElement('canvas'); + canvas.width = 32; + canvas.height = 32; + var ctx = canvas.getContext('2d'); + if (!ctx) return; + + var img = new Image(); + img.crossOrigin = 'anonymous'; + img.onload = function () { + ctx.drawImage(img, 0, 0, 32, 32); + + // Activity dot — brand amber (same as bell indicator) + ctx.beginPath(); + ctx.arc(24, 8, 7, 0, 2 * Math.PI); // top-right area + ctx.fillStyle = '#F1A640'; // var(--bell-color) + ctx.fill(); + ctx.strokeStyle = '#0D1117'; // var(--bg) — border for contrast + ctx.lineWidth = 2; + ctx.stroke(); + + link.href = canvas.toDataURL('image/png'); + }; + img.src = _originalFavicon; +} + // ─── Session open / close ──────────────────────────────────────────────────── /** * Open a session in fullscreen view with a zoom transition. * @param {string} name - session name * @param {object} [opts] - * @param {boolean} [opts.skipConnect] - if true, skip the /connect POST (ttyd already running) + * @param {boolean} [opts.skipAnimation] - if true, skip the zoom animation (e.g. on page restore) * @returns {Promise} */ async function openSession(name, opts = {}) { @@ -1120,7 +1203,8 @@ async function openSession(name, opts = {}) { if (nameEl) nameEl.textContent = name; // Zoom animation: pin tile at current position, then animate to full viewport - const tile = document.querySelector(`[data-session="${name}"]`); + // Skipped on restore (skipAnimation:true) — no tile DOM element to zoom from + const tile = opts.skipAnimation ? null : document.querySelector(`[data-session="${name}"]`); if (tile) { const rect = tile.getBoundingClientRect(); tile.style.position = 'fixed'; @@ -1152,7 +1236,7 @@ async function openSession(name, opts = {}) { initSidebar(); renderSidebar(_currentSessions, name); resolve(); - }, 260); + }, opts.skipAnimation ? 0 : 260); // If setTimeout is stubbed (e.g. in test env), resolve immediately so we don't hang if (timerId == null) resolve(); }); @@ -1173,7 +1257,7 @@ async function openSession(name, opts = {}) { const fab = $('new-session-fab'); if (fab) fab.classList.add('hidden'); - // Connect to session (kill old ttyd, spawn new one for this session) + // Always spawn ttyd for this session — ensures correct session after service restart or page restore var _sourceUrl = opts.sourceUrl || ''; try { if (!opts.skipConnect) { @@ -1220,6 +1304,16 @@ function closeSession() { } if (overview) overview.style.display = ''; // overview uses view--active (no !important), style.display clears fine + // Reapply fit layout after overview becomes visible again + var _closDs = loadDisplaySettings(); + if ((_closDs.viewMode || 'auto') === 'fit') { + var _closGrid = document.getElementById('session-grid'); + if (_closGrid) { + _closGrid.classList.add('session-grid--fit'); + requestAnimationFrame(function() { applyFitLayout(_closGrid); }); + } + } + const pill = $('session-pill'); if (pill) pill.classList.add('hidden'); @@ -1383,24 +1477,116 @@ function saveDisplaySettings(settings) { } catch (_) { /* blocked — ok */ } } +/** + * Calculate and apply grid layout to fill the viewport exactly (Fit mode). + * Determines optimal cols × rows based on tile count and available space. + * @param {Element} grid - The session grid element + */ +function applyFitLayout(grid) { + var count = grid.querySelectorAll('.session-tile').length; + if (count === 0) return; + + // Available space — use grid's parent container + var parent = grid.parentElement; + var availH = parent ? parent.clientHeight : window.innerHeight; + var availW = grid.clientWidth; + + // Subtract padding and gap + var style = getComputedStyle(grid); + var padT = parseFloat(style.paddingTop) || 0; + var padB = parseFloat(style.paddingBottom) || 0; + var padL = parseFloat(style.paddingLeft) || 0; + var padR = parseFloat(style.paddingRight) || 0; + var gap = parseFloat(style.gap) || 8; + + var innerW = availW - padL - padR; + var innerH = availH - padT - padB; + + // Calculate optimal cols/rows — start with square root + var cols = Math.ceil(Math.sqrt(count)); + var rows = Math.ceil(count / cols); + + // Prefer wider layouts (more cols, fewer rows) since tiles are landscape + if (rows > 1 && cols < count) { + var altCols = cols + 1; + var altRows = Math.ceil(count / altCols); + if (altRows < rows) { + cols = altCols; + rows = altRows; + } + } + + // Tile height from available space + var tileH = (innerH - gap * (rows - 1)) / rows; + + grid.style.gridTemplateColumns = 'repeat(' + cols + ', 1fr)'; + grid.style.gridTemplateRows = 'repeat(' + rows + ', 1fr)'; + + // Override tile height so tiles fill the grid rows + grid.querySelectorAll('.session-tile').forEach(function(t) { + t.style.height = tileH + 'px'; + }); +} + +/** + * Cycle the dashboard view mode: auto → fit → auto. + * Persists to localStorage and reapplies display settings. + */ +function cycleViewMode() { + var ds = loadDisplaySettings(); + var idx = VIEW_MODES.indexOf(ds.viewMode || 'auto'); + ds.viewMode = VIEW_MODES[(idx + 1) % VIEW_MODES.length]; + saveDisplaySettings(ds); + applyDisplaySettings(ds); + + // Update button label + var btn = document.getElementById('view-mode-btn'); + if (btn) btn.title = 'View: ' + ds.viewMode; +} + /** * Apply display settings to the live DOM. * Sets --preview-font-size CSS custom property and updates #session-grid - * grid-template-columns based on the gridColumns setting. + * grid-template-columns based on the gridColumns setting and viewMode. * @param {object} ds - display settings object */ function applyDisplaySettings(ds) { - // Apply font size as CSS custom property - document.documentElement.style.setProperty('--preview-font-size', ds.fontSize + 'px'); + // Apply font size as CSS custom property (tile previews) + if (document.documentElement) { + document.documentElement.style.setProperty('--preview-font-size', ds.fontSize + 'px'); + } - // Apply grid columns + // Apply font size to the live xterm.js terminal without reconnecting + if (window._setTerminalFontSize) { + window._setTerminalFontSize(ds.fontSize); + } + + // Apply view mode to grid var grid = document.getElementById('session-grid'); - if (grid) { - if (ds.gridColumns === 'auto') { + if (!grid) return; + + var mode = ds.viewMode || 'auto'; + + // Remove all mode classes + grid.classList.remove('session-grid--fit'); + + // Reset any inline styles from previous fit calculation + grid.style.removeProperty('grid-template-rows'); + grid.querySelectorAll('.session-tile').forEach(function(t) { + t.style.removeProperty('height'); + }); + + if (mode === 'auto') { + // Restore grid columns setting + if (ds.gridColumns === 'auto' || !ds.gridColumns) { grid.style.removeProperty('grid-template-columns'); } else { grid.style.gridTemplateColumns = 'repeat(' + ds.gridColumns + ', 1fr)'; } + + } else if (mode === 'fit') { + grid.classList.add('session-grid--fit'); + requestAnimationFrame(function() { applyFitLayout(grid); }); } } @@ -1590,11 +1776,17 @@ function openSettings() { }); } - // New Session tab - populate template textarea + // Commands tab - populate create template textarea const templateEl = $('setting-template'); if (templateEl) { templateEl.value = (ss && ss.new_session_template) || NEW_SESSION_DEFAULT_TEMPLATE; } + + // Commands tab - populate delete template textarea + const deleteTemplateEl = $('setting-delete-template'); + if (deleteTemplateEl) { + deleteTemplateEl.value = (ss && ss.delete_session_template) || DELETE_SESSION_DEFAULT_TEMPLATE; + } }); } @@ -1847,10 +2039,30 @@ async function createNewSession(name) { const sessionName = data.name || name; showToast('Creating session \'' + sessionName + '\'…'); + // Inject a loading placeholder tile so the user sees feedback immediately + var loadingTile = null; + var grid = document.getElementById('session-grid'); + if (grid) { + loadingTile = document.createElement('div'); + loadingTile.className = 'session-tile tile--loading'; + loadingTile.id = 'loading-tile-' + sessionName; + loadingTile.innerHTML = + '
' + escapeHtml(sessionName) + '' + + 'Creating...
' + + '
'; + grid.appendChild(loadingTile); + } + + function removeLoadingTile() { + var tile = document.getElementById('loading-tile-' + sessionName); + if (tile) tile.remove(); + } + const ss = _serverSettings || {}; if (ss.auto_open_created === false) { // Auto-open disabled — just do one refresh await pollSessions(); + removeLoadingTile(); return; } @@ -1865,10 +2077,12 @@ async function createNewSession(name) { }); if (found) { clearInterval(pollForSession); + removeLoadingTile(); showToast('Session \'' + sessionName + '\' ready'); openSession(sessionName); } else if (attempts >= maxAttempts) { clearInterval(pollForSession); + removeLoadingTile(); showToast('Session \'' + sessionName + '\' is taking longer than expected'); } }, 2000); @@ -1931,12 +2145,19 @@ function bindStaticEventListeners() { on($('sheet-backdrop'), 'click', closeBottomSheet); // Settings dialog bindings + on($('view-mode-btn'), 'click', cycleViewMode); on($('settings-btn'), 'click', openSettings); on($('settings-btn-expanded'), 'click', openSettings); on($('settings-close-btn'), 'click', closeSettings); on($('settings-backdrop'), 'click', closeSettings); const settingsDialog = $('settings-dialog'); - if (settingsDialog) settingsDialog.addEventListener('cancel', closeSettings); + if (settingsDialog) { + settingsDialog.addEventListener('cancel', closeSettings); + // Click on the ::backdrop area (outside dialog content) dismisses settings + settingsDialog.addEventListener('click', function(e) { + if (e.target === settingsDialog) closeSettings(); + }); + } document.querySelectorAll('.settings-tab').forEach(function(tab) { on(tab, 'click', function() { switchSettingsTab(tab.dataset.tab); }); }); @@ -2060,7 +2281,7 @@ function bindStaticEventListeners() { }); }); - // New Session tab — template textarea with 500ms debounce + // Commands tab — create template textarea with 500ms debounce var _templateDebounceTimer; on($('setting-template'), 'input', function() { clearTimeout(_templateDebounceTimer); @@ -2070,7 +2291,7 @@ function bindStaticEventListeners() { }, 500); }); - // New Session tab — reset button restores default template + // Commands tab — create template reset button restores default on($('setting-template-reset'), 'click', function() { var el = $('setting-template'); if (el) el.value = NEW_SESSION_DEFAULT_TEMPLATE; @@ -2132,6 +2353,23 @@ function bindStaticEventListeners() { renderGrid(_currentSessions || []); }); } + + // Commands tab — delete template textarea with 500ms debounce + var _deleteTemplateDebounceTimer; + on($('setting-delete-template'), 'input', function() { + clearTimeout(_deleteTemplateDebounceTimer); + var val = this.value; + _deleteTemplateDebounceTimer = setTimeout(function() { + patchServerSetting('delete_session_template', val); + }, 500); + }); + + // Commands tab — delete template reset button restores default + on($('setting-delete-template-reset'), 'click', function() { + var el = $('setting-delete-template'); + if (el) el.value = DELETE_SESSION_DEFAULT_TEMPLATE; + patchServerSetting('delete_session_template', DELETE_SESSION_DEFAULT_TEMPLATE); + }); } // ─── Test-only helpers ──────────────────────────────────────────────────────── @@ -2206,10 +2444,25 @@ function _setActiveFilterDevice(device) { _activeFilterDevice = device; } +// Recalculate fit layout on window resize +window.addEventListener('resize', function() { + var ds = loadDisplaySettings(); + if ((ds.viewMode || 'auto') === 'fit') { + var grid = document.getElementById('session-grid'); + if (grid) requestAnimationFrame(function() { applyFitLayout(grid); }); + } +}); + document.addEventListener('DOMContentLoaded', () => { initDeviceId(); - applyDisplaySettings(loadDisplaySettings()); + var _initDs = loadDisplaySettings(); + applyDisplaySettings(_initDs); _gridViewMode = loadGridViewMode(); + + // Initialize view mode button title + var vmBtn = document.getElementById('view-mode-btn'); + if (vmBtn) vmBtn.title = 'View: ' + (_initDs.viewMode || 'auto'); + document.addEventListener('keydown', trackInteraction); document.addEventListener('click', trackInteraction); document.addEventListener('touchstart', trackInteraction); @@ -2219,7 +2472,6 @@ document.addEventListener('DOMContentLoaded', () => { startPolling(); loadServerSettings(); startHeartbeat(); - requestNotificationPermission(); bindStaticEventListeners(); }) .catch((err) => { @@ -2280,6 +2532,8 @@ if (typeof module !== 'undefined' && module.exports) { applyDisplaySettings, loadGridViewMode, saveGridViewMode, + applyFitLayout, + cycleViewMode, onDisplaySettingChange, openSettings, closeSettings, @@ -2306,6 +2560,9 @@ if (typeof module !== 'undefined' && module.exports) { buildOfflineTileHTML, openLoginPopup, formatLastSeen, + // Constants + NEW_SESSION_DEFAULT_TEMPLATE, + DELETE_SESSION_DEFAULT_TEMPLATE, // Test-only helpers _setCurrentSessions, _setViewMode, diff --git a/muxplex/frontend/index.html b/muxplex/frontend/index.html index b4a3906..8791bc2 100644 --- a/muxplex/frontend/index.html +++ b/muxplex/frontend/index.html @@ -22,6 +22,7 @@

muxplex

+
@@ -88,7 +89,7 @@ - +
@@ -100,6 +101,7 @@ +
@@ -194,6 +196,12 @@ {name} is replaced with the session name
+
+ + + {name} is replaced with the session name + +
diff --git a/muxplex/frontend/style.css b/muxplex/frontend/style.css index f901df7..ce7b852 100644 --- a/muxplex/frontend/style.css +++ b/muxplex/frontend/style.css @@ -239,6 +239,30 @@ body { margin: 0; } +/* Loading placeholder tile — shown while a new session is being created */ +.tile--loading { + opacity: 0.6; + pointer-events: none; +} + +.tile--loading .tile-body pre { + background: repeating-linear-gradient( + 90deg, + var(--border) 0px, + var(--bg-surface) 30px, + var(--border) 60px + ); + background-size: 200% 100%; + animation: shimmer 1.5s ease-in-out infinite; + min-height: 60px; + border-radius: 2px; +} + +@keyframes shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + .tile-pre { position: absolute; inset: 0; @@ -1492,6 +1516,27 @@ body { padding: 8px 12px 4px; } +/* ============================================================ + Dashboard view modes — Fit + ============================================================ */ + +/* Fit view — tiles fill viewport, no scroll */ +.session-grid--fit { + overflow: hidden; +} + +/* In fit mode, stretch pre to fill the full tile body height and anchor content to bottom */ +.session-grid--fit .tile-body pre { + top: 0; /* stretch to fill full tile body height in fit mode */ + overflow-y: scroll; /* enable scrollTop positioning (content anchored via JS scrollTop=scrollHeight) */ + scrollbar-width: none; /* Firefox: hide scrollbar */ + -ms-overflow-style: none; /* IE/Edge: hide scrollbar */ +} + +.session-grid--fit .tile-body pre::-webkit-scrollbar { + display: none; /* Chrome/Safari: hide scrollbar */ +} + /* ============================================================ Responsive overlay sidebar at <960px ============================================================ */ diff --git a/muxplex/frontend/terminal.js b/muxplex/frontend/terminal.js index b07a91f..4d55bf7 100644 --- a/muxplex/frontend/terminal.js +++ b/muxplex/frontend/terminal.js @@ -268,6 +268,26 @@ function closeTerminal() { window._openTerminal = openTerminal; window._closeTerminal = closeTerminal; +// --------------------------------------------------------------------------- +// setTerminalFontSize — live font-size update without reconnecting +// --------------------------------------------------------------------------- + +/** + * Update the terminal font size at runtime without reconnecting. + * Modifies _term.options.fontSize and refits the terminal to recalculate dimensions. + * No-op when no terminal is open. + * @param {number} size - font size in pixels + */ +function setTerminalFontSize(size) { + if (!_term) return; + _term.options.fontSize = size; + if (_fitAddon) { + try { _fitAddon.fit(); } catch (_) {} + } +} + +window._setTerminalFontSize = setTerminalFontSize; + // --------------------------------------------------------------------------- // Android touch scroll — rAF-batched WheelEvent dispatch // Android batches touchmove events irregularly; dispatching one WheelEvent diff --git a/muxplex/frontend/tests/test_app.mjs b/muxplex/frontend/tests/test_app.mjs index 76e8086..26e043e 100644 --- a/muxplex/frontend/tests/test_app.mjs +++ b/muxplex/frontend/tests/test_app.mjs @@ -1049,20 +1049,24 @@ test('openSession returns a Promise', () => { globalThis.setTimeout = origSetTimeout; }); -test('openSession with skipConnect calls window._openTerminal inside setTimeout callback', async () => { +test('openSession with skipAnimation calls window._openTerminal after connect POST', async () => { + // After the fix: skipAnimation only skips the zoom animation, connect always fires. let openTerminalCalledWith = null; + const origFetch = globalThis.fetch; const origGetById = globalThis.document.getElementById; const origQS = globalThis.document.querySelector; const origSetTimeout = globalThis.setTimeout; + globalThis.fetch = async (url, opts) => ({ ok: true }); globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} } }); globalThis.document.querySelector = () => null; - // Use a synchronous mock so setTimeout callbacks run immediately — _openTerminal is now called inside setTimeout + // Use a synchronous mock so setTimeout callbacks run immediately globalThis.setTimeout = (fn) => { fn(); }; globalThis.window._openTerminal = (name) => { openTerminalCalledWith = name; }; - await app.openSession('my-session', { skipConnect: true }); + await app.openSession('my-session', { skipAnimation: true }); assert.strictEqual(openTerminalCalledWith, 'my-session', '_openTerminal should be called with session name'); + globalThis.fetch = origFetch; globalThis.document.getElementById = origGetById; globalThis.document.querySelector = origQS; globalThis.setTimeout = origSetTimeout; @@ -3273,4 +3277,288 @@ test('formatLastSeen returns Never for null', () => { test('formatLastSeen returns Never for undefined', () => { assert.strictEqual(app.formatLastSeen(undefined), 'Never'); }); +// --- Issue 1: Loading placeholder tile --- +test('createNewSession injects tile--loading placeholder after POST succeeds', () => { + const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8'); + const start = source.indexOf('async function createNewSession('); + assert.ok(start !== -1, 'createNewSession must exist'); + const snippet = source.slice(start, start + 2500); + assert.ok(snippet.includes('tile--loading'), 'createNewSession must inject tile--loading placeholder class'); + assert.ok(snippet.includes('loading-tile-'), 'createNewSession must use loading-tile- id prefix for the placeholder'); +}); + +test('createNewSession removes loading placeholder when session is found', () => { + const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8'); + const start = source.indexOf('async function createNewSession('); + const snippet = source.slice(start, start + 2500); + assert.ok( + snippet.includes('loadingTile') && snippet.includes('.remove()'), + 'createNewSession must remove the loading tile (loadingTile.remove()) when session is found' + ); +}); + +test('CSS style.css has tile--loading and shimmer animation', () => { + const source = fs.readFileSync(new URL('../style.css', import.meta.url), 'utf8'); + assert.ok(source.includes('tile--loading'), 'style.css must have .tile--loading rule'); + assert.ok(source.includes('shimmer'), 'style.css must have shimmer animation'); +}); + +// --- Issue 2: Always call connect on restore --- + +test('openSession always POSTs to connect even when skipConnect option is passed', async () => { + // Before the fix, skipConnect:true skipped the connect POST entirely. + // After the fix, connect is always called; the option is renamed to skipAnimation. + const fetchCalls = []; + const origFetch = globalThis.fetch; + const origGetById = globalThis.document.getElementById; + const origQS = globalThis.document.querySelector; + const origSetTimeout = globalThis.setTimeout; + globalThis.fetch = async (url, opts) => { fetchCalls.push({ url, opts }); return { ok: true }; }; + globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} } }); + globalThis.document.querySelector = () => null; + globalThis.setTimeout = () => {}; + globalThis.window._openTerminal = () => {}; + + await app.openSession('work', { skipConnect: true }); + + const connectCall = fetchCalls.find((c) => c.url === '/api/sessions/work/connect'); + assert.ok(connectCall, 'skipConnect:true must NOT prevent connect POST — connect always fires after fix'); + assert.strictEqual(connectCall.opts.method, 'POST'); + globalThis.fetch = origFetch; + globalThis.document.getElementById = origGetById; + globalThis.document.querySelector = origQS; + globalThis.setTimeout = origSetTimeout; +}); + +// --- Issue 3: Notification permission on user click only --- + +test('DOMContentLoaded handler does NOT call requestNotificationPermission at startup', () => { + // Browsers require notification permission to be requested in response to a user gesture. + // Auto-calling at startup is silently blocked, leaving the permission in a broken state. + const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8'); + const domContentLoadedIdx = source.indexOf("document.addEventListener('DOMContentLoaded'"); + assert.ok(domContentLoadedIdx !== -1, 'DOMContentLoaded handler must exist'); + // Extract the DOMContentLoaded handler body (next ~600 chars covers the entire handler) + const handlerBody = source.substring(domContentLoadedIdx, domContentLoadedIdx + 600); + assert.ok( + !handlerBody.includes('requestNotificationPermission'), + 'requestNotificationPermission must NOT be called automatically in DOMContentLoaded — only on user click' + ); +}); + +// --- Issue 4: Apply font size to live terminal without reconnecting --- + +test('applyDisplaySettings calls window._setTerminalFontSize when available', () => { + const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8'); + const fnStart = source.indexOf('function applyDisplaySettings('); + assert.ok(fnStart !== -1, 'applyDisplaySettings must exist'); + const fnBody = source.substring(fnStart, fnStart + 600); + assert.ok( + fnBody.includes('_setTerminalFontSize'), + 'applyDisplaySettings must call window._setTerminalFontSize to update live terminal font size' + ); +}); + + + +// --- Issue: Dynamic favicon badge with activity dot --- + +test('updateFaviconBadge function exists in app.js', () => { + const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8'); + assert.ok( + source.includes('function updateFaviconBadge'), + 'app.js must define updateFaviconBadge function' + ); +}); + +test('pollSessions calls updateFaviconBadge', () => { + const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8'); + const pollStart = source.indexOf('async function pollSessions()'); + assert.ok(pollStart !== -1, 'pollSessions must exist'); + // Find the closing brace of pollSessions (next line starting with "}") + const pollEnd = source.indexOf('\n}', pollStart); + const pollBody = source.substring(pollStart, pollEnd + 2); + assert.ok( + pollBody.includes('updateFaviconBadge'), + 'pollSessions must call updateFaviconBadge — update favicon on every poll cycle' + ); +}); + + +// --- Delete session template (task: customizable delete command) --- + +test('app.js defines DELETE_SESSION_DEFAULT_TEMPLATE constant', () => { + const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8'); + assert.ok( + source.includes('DELETE_SESSION_DEFAULT_TEMPLATE'), + 'app.js must define DELETE_SESSION_DEFAULT_TEMPLATE constant' + ); +}); + +test('DELETE_SESSION_DEFAULT_TEMPLATE value is tmux kill-session -t {name}', () => { + const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8'); + assert.ok( + source.includes("'tmux kill-session -t {name}'") || source.includes('"tmux kill-session -t {name}"'), + "DELETE_SESSION_DEFAULT_TEMPLATE must be set to 'tmux kill-session -t {name}'" + ); +}); + +test('openSettings loads delete_session_template from server settings', () => { + const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8'); + const fnStart = source.indexOf('function openSettings('); + assert.ok(fnStart !== -1, 'openSettings must exist'); + const fnEnd = source.indexOf('\nfunction ', fnStart + 1); + const fnBody = source.substring(fnStart, fnEnd > fnStart ? fnEnd : fnStart + 3000); + assert.ok( + fnBody.includes('setting-delete-template'), + 'openSettings must populate #setting-delete-template from server settings' + ); +}); + +test('bindStaticEventListeners wires delete template input to save', () => { + const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8'); + const fnStart = source.indexOf('function bindStaticEventListeners('); + assert.ok(fnStart !== -1, 'bindStaticEventListeners must exist'); + const fnEnd = source.indexOf('\nfunction ', fnStart + 1); + const fnBody = source.substring(fnStart, fnEnd > fnStart ? fnEnd : fnStart + 6000); + assert.ok( + fnBody.includes('setting-delete-template'), + 'bindStaticEventListeners must wire #setting-delete-template input event to save' + ); +}); + +test('bindStaticEventListeners wires delete template reset button', () => { + const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8'); + const fnStart = source.indexOf('function bindStaticEventListeners('); + assert.ok(fnStart !== -1, 'bindStaticEventListeners must exist'); + const fnEnd = source.indexOf('\nfunction ', fnStart + 1); + const fnBody = source.substring(fnStart, fnEnd > fnStart ? fnEnd : fnStart + 6000); + assert.ok( + fnBody.includes('setting-delete-template-reset'), + 'bindStaticEventListeners must wire #setting-delete-template-reset click handler' + ); +}); + +// --- View mode cycling (Auto / Fit / Compact) --- + +test('app.js has VIEW_MODES array with auto and fit only (no compact)', () => { + const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8'); + assert.ok(source.includes("'auto'"), "must include 'auto' mode"); + assert.ok(source.includes("'fit'"), "must include 'fit' mode"); + assert.ok(source.includes('VIEW_MODES'), 'must define VIEW_MODES'); + // Compact mode was removed — VIEW_MODES must only have two entries + const viewModesMatch = source.match(/var VIEW_MODES\s*=\s*\[([^\]]+)\]/); + assert.ok(viewModesMatch, 'VIEW_MODES array must be defined'); + assert.ok(!viewModesMatch[1].includes("'compact'"), "VIEW_MODES must NOT include 'compact'"); +}); + +test('app.js exports cycleViewMode function', () => { + assert.ok('cycleViewMode' in app, 'app.js must export cycleViewMode'); + assert.strictEqual(typeof app.cycleViewMode, 'function', 'cycleViewMode must be a function'); +}); + +test('app.js exports applyFitLayout function', () => { + assert.ok('applyFitLayout' in app, 'app.js must export applyFitLayout'); + assert.strictEqual(typeof app.applyFitLayout, 'function', 'applyFitLayout must be a function'); +}); + +test('DISPLAY_DEFAULTS includes viewMode: auto', () => { + const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8'); + // DISPLAY_DEFAULTS should define viewMode + const defaultsStart = source.indexOf('DISPLAY_DEFAULTS'); + assert.ok(defaultsStart !== -1, 'DISPLAY_DEFAULTS must exist'); + const defaultsEnd = source.indexOf('};', defaultsStart); + const defaultsBody = source.substring(defaultsStart, defaultsEnd + 2); + assert.ok(defaultsBody.includes('viewMode'), 'DISPLAY_DEFAULTS must include viewMode'); +}); + +test('applyDisplaySettings handles fit mode by adding session-grid--fit class', () => { + const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8'); + const fnStart = source.indexOf('function applyDisplaySettings('); + assert.ok(fnStart !== -1, 'applyDisplaySettings must exist'); + const fnEnd = source.indexOf('\nfunction ', fnStart + 1); + const fnBody = source.substring(fnStart, fnEnd > fnStart ? fnEnd : fnStart + 2000); + assert.ok( + fnBody.includes('session-grid--fit'), + 'applyDisplaySettings must apply session-grid--fit class for fit mode' + ); +}); + +test('applyDisplaySettings does NOT handle compact mode (compact was removed)', () => { + const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8'); + const fnStart = source.indexOf('function applyDisplaySettings('); + assert.ok(fnStart !== -1, 'applyDisplaySettings must exist'); + const fnEnd = source.indexOf('\nfunction ', fnStart + 1); + const fnBody = source.substring(fnStart, fnEnd > fnStart ? fnEnd : fnStart + 2000); + assert.ok( + !fnBody.includes("'compact'"), + "applyDisplaySettings must NOT reference 'compact' mode — compact was removed" + ); +}); + +test('cycleViewMode cycles through auto -> fit -> auto (two modes, compact removed)', () => { + // Reset display settings to auto + const ds = app.loadDisplaySettings(); + ds.viewMode = 'auto'; + app.saveDisplaySettings(ds); + + // First cycle: auto -> fit + app.cycleViewMode(); + const ds1 = app.loadDisplaySettings(); + assert.strictEqual(ds1.viewMode, 'fit', 'first cycle should go auto -> fit'); + + // Second cycle: fit -> auto (wraps, compact is gone) + app.cycleViewMode(); + const ds2 = app.loadDisplaySettings(); + assert.strictEqual(ds2.viewMode, 'auto', 'second cycle should wrap fit -> auto (only two modes)'); +}); + +test('bindStaticEventListeners wires view-mode-btn click to cycleViewMode', () => { + const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8'); + const fnStart = source.indexOf('function bindStaticEventListeners('); + assert.ok(fnStart !== -1, 'bindStaticEventListeners must exist'); + const fnEnd = source.indexOf('\nfunction ', fnStart + 1); + const fnBody = source.substring(fnStart, fnEnd > fnStart ? fnEnd : fnStart + 6000); + assert.ok( + fnBody.includes('view-mode-btn'), + 'bindStaticEventListeners must wire #view-mode-btn click handler' + ); +}); + +test('applyFitLayout is called via requestAnimationFrame for correct timing', () => { + const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8'); + assert.ok( + source.includes('requestAnimationFrame') && source.includes('applyFitLayout'), + 'applyFitLayout must be deferred via requestAnimationFrame' + ); +}); + +// --- Fit view bug fixes: closeSession reapply, more lines, bottom-anchor --- + +test('closeSession reapplies fit layout when returning to dashboard', () => { + const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8'); + const fnStart = source.indexOf('function closeSession'); + assert.ok(fnStart !== -1, 'closeSession function must exist'); + const fnBody = source.substring(fnStart, fnStart + 1500); + assert.ok( + fnBody.includes('applyFitLayout'), + 'closeSession must call applyFitLayout for fit mode when returning to dashboard' + ); +}); + +test('buildTileHTML shows up to 80 lines in fit mode', () => { + const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8'); + assert.ok( + source.includes('-80') || source.includes('(-80)'), + 'app.js must use -80 slice for fit mode to show up to 80 lines' + ); +}); + +test('CSS style.css has scrollbar-width none for fit mode pre to hide scrollbar', () => { + const source = fs.readFileSync(new URL('../style.css', import.meta.url), 'utf8'); + assert.ok( + source.includes('scrollbar-width: none'), + 'style.css must have scrollbar-width: none for hidden scrollbar in fit mode pre' + ); +}); diff --git a/muxplex/frontend/tests/test_terminal.mjs b/muxplex/frontend/tests/test_terminal.mjs index 0e96a3e..8dd6af9 100644 --- a/muxplex/frontend/tests/test_terminal.mjs +++ b/muxplex/frontend/tests/test_terminal.mjs @@ -807,4 +807,28 @@ test('terminal.js Android touch scroll is UA-gated', () => { assert.ok(!source.includes('scrollLines'), 'must NOT use scrollLines (scrolls local buffer not PTY)'); }); +// --- Issue 4: setTerminalFontSize --- + +test('terminal.js exposes window._setTerminalFontSize function', () => { + const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8'); + assert.ok( + source.includes('window._setTerminalFontSize'), + 'terminal.js must expose window._setTerminalFontSize for live font size updates' + ); +}); + +test('_setTerminalFontSize sets _term.options.fontSize and calls _fitAddon.fit()', () => { + const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8'); + // The function body must update _term.options.fontSize + assert.ok( + source.includes('_term.options.fontSize = size'), + '_setTerminalFontSize must assign _term.options.fontSize = size' + ); + // And call _fitAddon.fit() + assert.ok( + source.includes('_fitAddon.fit()'), + '_setTerminalFontSize must call _fitAddon.fit() to reflow the terminal' + ); +}); + diff --git a/muxplex/main.py b/muxplex/main.py index 4548f15..4f2eb36 100644 --- a/muxplex/main.py +++ b/muxplex/main.py @@ -14,6 +14,7 @@ import logging import os import pathlib import pwd +import socket import subprocess import sys import time @@ -296,11 +297,15 @@ class CreateSessionPayload(BaseModel): # --------------------------------------------------------------------------- -# Frontend directory +# Frontend directory + hostname # --------------------------------------------------------------------------- _FRONTEND_DIR = pathlib.Path(__file__).parent / "frontend" +# Short hostname (no domain) injected into page titles so browser tabs show +# which machine each muxplex instance is running on. +_HOSTNAME = socket.gethostname().split(".")[0] + # --------------------------------------------------------------------------- # Routes @@ -420,9 +425,13 @@ async def delete_current_session() -> dict: @app.delete("/api/sessions/{name}") async def delete_session(name: str) -> dict: - """Kill a tmux session by name. + """Kill/destroy a tmux session using the delete_session_template from settings. - Runs `tmux kill-session -t {name}`. Returns {ok: True, name: name}. + Reads delete_session_template, substitutes {name}, and runs it synchronously + (30s timeout) so the caller can rely on the session being gone on return. + + Returns {ok: True, name: name}. Errors are logged as warnings — the endpoint + always returns 200 so the UI can refresh and reflect the gone session. 404 if session is not in the known session list (when non-empty). Must be declared after DELETE /api/sessions/current so "current" routes correctly. """ @@ -430,10 +439,29 @@ async def delete_session(name: str) -> dict: if known and name not in known: raise HTTPException(status_code=404, detail=f"Session '{name}' not found") + settings = load_settings() + command = settings.get( + "delete_session_template", "tmux kill-session -t {name}" + ).replace("{name}", name) + try: - await run_tmux("kill-session", "-t", name) - except RuntimeError: - raise HTTPException(status_code=500, detail=f"Failed to kill session '{name}'") + result = subprocess.run( + command, + shell=True, + capture_output=True, + text=True, + timeout=30, + ) + if result.returncode != 0: + _log.warning( + "Delete command failed (rc=%d): %s", + result.returncode, + result.stderr.strip(), + ) + except subprocess.TimeoutExpired: + _log.warning("Delete command timed out after 30s: %r", command) + except Exception: + _log.warning("Delete command failed: %r", command) return {"ok": True, "name": name} @@ -589,6 +617,18 @@ async def terminal_ws_proxy(websocket: WebSocket) -> None: # --------------------------------------------------------------------------- +@app.get("/", response_class=HTMLResponse) +@app.get("/index.html", response_class=HTMLResponse) +async def index_page(): + """Serve index.html with hostname injected into the page title.""" + html = (_FRONTEND_DIR / "index.html").read_text() + html = html.replace( + "muxplex", + f"{_HOSTNAME} \u2014 muxplex", + ) + return HTMLResponse(html) + + @app.get("/login", response_class=HTMLResponse) async def login_page(): """Serve branded login.html with injected window.MUXPLEX_AUTH containing auth mode and username.""" @@ -598,6 +638,10 @@ async def login_page(): html = html.replace( "", f"" ) + html = html.replace( + "Sign in \u2014 muxplex", + f"Sign in \u2014 {_HOSTNAME} \u2014 muxplex", + ) return HTMLResponse(html) diff --git a/muxplex/settings.py b/muxplex/settings.py index 32c327f..d5103f3 100644 --- a/muxplex/settings.py +++ b/muxplex/settings.py @@ -20,6 +20,7 @@ DEFAULT_SETTINGS: dict = { "new_session_template": "tmux new-session -d -s {name}", "remote_instances": [], "device_name": "", + "delete_session_template": "tmux kill-session -t {name}", } diff --git a/muxplex/tests/test_api.py b/muxplex/tests/test_api.py index 169f76b..c40bc9a 100644 --- a/muxplex/tests/test_api.py +++ b/muxplex/tests/test_api.py @@ -1159,20 +1159,34 @@ def test_delete_session_success(client, monkeypatch): def test_delete_session_calls_kill_session(client, monkeypatch): - """DELETE /api/sessions/{name} calls tmux kill-session -t {name}.""" - from unittest.mock import AsyncMock + """DELETE /api/sessions/{name} runs 'tmux kill-session -t {name}' via subprocess (default template).""" + from unittest.mock import MagicMock, patch monkeypatch.setattr("muxplex.main.get_session_list", lambda: ["my-session"]) - mock_run_tmux = AsyncMock(return_value="") - monkeypatch.setattr("muxplex.main.run_tmux", mock_run_tmux) - client.delete("/api/sessions/my-session") + captured = [] - assert mock_run_tmux.called - args = mock_run_tmux.call_args[0] - assert args[0] == "kill-session" - assert "-t" in args - assert "my-session" in args + def mock_run(cmd, **kwargs): + captured.append(cmd) + result = MagicMock() + result.returncode = 0 + result.stderr = "" + return result + + with patch("muxplex.main.subprocess.run", side_effect=mock_run): + client.delete("/api/sessions/my-session") + + assert len(captured) == 1, "subprocess.run must be called exactly once" + executed_cmd = captured[0] + assert "kill-session" in executed_cmd, ( + f"Default command must include 'kill-session', got: {executed_cmd!r}" + ) + assert "-t" in executed_cmd, ( + f"Default command must include '-t', got: {executed_cmd!r}" + ) + assert "my-session" in executed_cmd, ( + f"Command must include session name 'my-session', got: {executed_cmd!r}" + ) def test_delete_session_not_found(client, monkeypatch): @@ -1181,3 +1195,167 @@ def test_delete_session_not_found(client, monkeypatch): response = client.delete("/api/sessions/nonexistent") assert response.status_code == 404 + + +# --------------------------------------------------------------------------- +# Issue 1: Static assets exempt from auth middleware +# --------------------------------------------------------------------------- + + +def test_static_asset_accessible_from_non_localhost_without_auth(monkeypatch): + """Static assets (.svg, .css, .js etc.) are served without auth from non-localhost. + + The login page needs its own CSS/JS/images to render before the user has + authenticated. The auth middleware must exempt static file extensions. + """ + monkeypatch.setenv("MUXPLEX_PASSWORD", "test-pw") + with TestClient(app, base_url="http://192.168.1.1", follow_redirects=False) as c: + response = c.get("/wordmark-on-dark.svg") + assert response.status_code == 200, ( + f"Expected 200 for static asset from non-localhost, got {response.status_code}" + ) + + +def test_css_asset_accessible_from_non_localhost_without_auth(monkeypatch): + """CSS files are served without auth from non-localhost.""" + monkeypatch.setenv("MUXPLEX_PASSWORD", "test-pw") + with TestClient(app, base_url="http://192.168.1.1", follow_redirects=False) as c: + response = c.get("/style.css") + assert response.status_code == 200, ( + f"Expected 200 for CSS from non-localhost, got {response.status_code}" + ) + + +# --------------------------------------------------------------------------- +# Issue 2: Hostname in page title +# --------------------------------------------------------------------------- + + +def test_index_page_title_contains_hostname(client): + """GET / returns HTML with hostname in page title (e.g. 'myhost — muxplex').""" + import socket + + hostname = socket.gethostname().split(".")[0] + response = client.get("/") + assert response.status_code == 200 + assert hostname in response.text, ( + f"Expected hostname '{hostname}' in title of index page" + ) + assert "muxplex" in response.text + + +def test_login_page_title_contains_hostname(client): + """GET /login returns HTML with hostname in page title (e.g. 'Sign in — myhost — muxplex').""" + import socket + + hostname = socket.gethostname().split(".")[0] + response = client.get("/login") + assert response.status_code == 200 + assert hostname in response.text, ( + f"Expected hostname '{hostname}' in title of login page" + ) + + +# --------------------------------------------------------------------------- +# DELETE /api/sessions/{name} — custom template (task: customizable delete command) +# --------------------------------------------------------------------------- + + +def test_delete_session_uses_template_command(client, monkeypatch, tmp_path): + """DELETE /api/sessions/{name} must execute the delete_session_template from settings. + + The template {name} placeholder must be substituted with the session name. + The command must be run synchronously via subprocess.run (not run_tmux). + """ + from unittest.mock import MagicMock, patch + + # Make the session appear to exist so the 404 guard passes + monkeypatch.setattr("muxplex.main.get_session_list", lambda: ["myworkspace"]) + + # Redirect settings to a temp path so we can write a custom template + import muxplex.settings as settings_mod + + fake_settings_path = tmp_path / "settings.json" + monkeypatch.setattr(settings_mod, "SETTINGS_PATH", fake_settings_path) + + # Write a custom template + import json + + fake_settings_path.write_text( + json.dumps( + { + "delete_session_template": "echo destroy {name}", + } + ) + ) + + # Capture subprocess.run calls + captured_commands = [] + + def mock_run(cmd, **kwargs): + captured_commands.append(cmd) + result = MagicMock() + result.returncode = 0 + result.stderr = "" + return result + + with patch("muxplex.main.subprocess.run", side_effect=mock_run): + response = client.delete("/api/sessions/myworkspace") + + assert response.status_code == 200, ( + f"DELETE /api/sessions/myworkspace must return 200, got {response.status_code}" + ) + data = response.json() + assert data.get("ok") is True, f"Response must have ok=True, got: {data}" + assert data.get("name") == "myworkspace", ( + f"Response must have name='myworkspace', got: {data}" + ) + + # Verify template substitution happened + assert len(captured_commands) == 1, ( + f"subprocess.run must be called exactly once, called {len(captured_commands)} times" + ) + executed_cmd = captured_commands[0] + assert "myworkspace" in executed_cmd, ( + f"Executed command must contain session name 'myworkspace', got: {executed_cmd!r}" + ) + assert "echo destroy" in executed_cmd, ( + f"Executed command must use the custom template, got: {executed_cmd!r}" + ) + + +def test_delete_session_default_template_is_tmux_kill(client, monkeypatch, tmp_path): + """DELETE /api/sessions/{name} uses 'tmux kill-session -t {name}' when no custom template is set.""" + from unittest.mock import MagicMock, patch + + monkeypatch.setattr("muxplex.main.get_session_list", lambda: ["mysession"]) + + # Redirect settings to empty temp file (no settings file = use defaults) + import muxplex.settings as settings_mod + + fake_settings_path = tmp_path / "settings.json" + monkeypatch.setattr(settings_mod, "SETTINGS_PATH", fake_settings_path) + # Don't write any settings — defaults should be used + + captured_commands = [] + + def mock_run(cmd, **kwargs): + captured_commands.append(cmd) + result = MagicMock() + result.returncode = 0 + result.stderr = "" + return result + + with patch("muxplex.main.subprocess.run", side_effect=mock_run): + response = client.delete("/api/sessions/mysession") + + assert response.status_code == 200 + assert len(captured_commands) == 1 + executed_cmd = captured_commands[0] + # Default template substituted + assert "mysession" in executed_cmd, ( + f"Default template must substitute session name, got: {executed_cmd!r}" + ) + assert "kill-session" in executed_cmd, ( + f"Default template must contain 'kill-session', got: {executed_cmd!r}" + ) diff --git a/muxplex/tests/test_cli.py b/muxplex/tests/test_cli.py index 8b620f3..3131fde 100644 --- a/muxplex/tests/test_cli.py +++ b/muxplex/tests/test_cli.py @@ -263,12 +263,18 @@ def test_reset_secret_prints_warning(tmp_path, monkeypatch, capsys): def test_install_service_writes_launchd_plist_on_macos(tmp_path, monkeypatch): """install_service() on macOS writes a launchd plist to ~/Library/LaunchAgents/.""" + import subprocess from muxplex.cli import install_service fake_home = tmp_path / "home" fake_home.mkdir() monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home)) monkeypatch.setattr("sys.platform", "darwin") + monkeypatch.setattr( + subprocess, + "run", + lambda *a, **kw: type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})(), + ) install_service(system=False) @@ -283,12 +289,18 @@ def test_install_service_writes_launchd_plist_on_macos(tmp_path, monkeypatch): def test_install_service_does_not_write_systemd_on_macos(tmp_path, monkeypatch): """install_service() on macOS must NOT write a systemd unit file.""" + import subprocess from muxplex.cli import install_service fake_home = tmp_path / "home" fake_home.mkdir() monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home)) monkeypatch.setattr("sys.platform", "darwin") + monkeypatch.setattr( + subprocess, + "run", + lambda *a, **kw: type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})(), + ) install_service(system=False) @@ -296,6 +308,64 @@ def test_install_service_does_not_write_systemd_on_macos(tmp_path, monkeypatch): assert not systemd_path.exists(), "No systemd unit file should be written on macOS" +def test_install_service_uses_modern_launchctl_on_macos(tmp_path, monkeypatch, capsys): + """install-service on macOS must use launchctl bootstrap (not load).""" + import subprocess + from muxplex.cli import install_service + + fake_home = tmp_path / "home" + fake_home.mkdir() + monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home)) + monkeypatch.setattr("sys.platform", "darwin") + monkeypatch.setattr(shutil, "which", lambda name: f"/usr/local/bin/{name}") + + calls = [] + + def mock_run(cmd, **kwargs): + calls.append(cmd) + return type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})() + + monkeypatch.setattr(subprocess, "run", mock_run) + + install_service(system=False) + + launchctl_cmds = [c for c in calls if isinstance(c, list) and "launchctl" in str(c)] + assert any( + "bootout" in str(c) for c in launchctl_cmds + ), "must bootout old service before loading" + assert any( + "bootstrap" in str(c) for c in launchctl_cmds + ), "must use launchctl bootstrap (modern API)" + # Must NOT use deprecated load + assert not any( + c == ["launchctl", "load"] or (isinstance(c, list) and c[:2] == ["launchctl", "load"]) + for c in launchctl_cmds + ), "must NOT use deprecated launchctl load" + + +def test_install_service_plist_includes_host_flag(tmp_path, monkeypatch): + """The generated plist must include --host 0.0.0.0 for network access.""" + import subprocess + from muxplex.cli import install_service + + fake_home = tmp_path / "home" + fake_home.mkdir() + monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home)) + monkeypatch.setattr("sys.platform", "darwin") + monkeypatch.setattr(shutil, "which", lambda name: f"/usr/local/bin/{name}") + monkeypatch.setattr( + subprocess, + "run", + lambda *a, **kw: type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})(), + ) + + install_service(system=False) + + plist_path = fake_home / "Library" / "LaunchAgents" / "com.muxplex.plist" + content = plist_path.read_text() + assert "0.0.0.0" in content, "plist must bind to 0.0.0.0 for network access" + + def test_install_service_writes_systemd_on_linux(tmp_path, monkeypatch): """install_service() on Linux writes a systemd unit to ~/.config/systemd/user/.""" from muxplex.cli import install_service @@ -516,3 +586,209 @@ def test_main_dispatches_to_doctor(monkeypatch): assert len(calls) == 1, ( "doctor() must be called once when 'doctor' subcommand is used" ) + + +# --------------------------------------------------------------------------- +# upgrade / update subcommand tests +# --------------------------------------------------------------------------- + + +def test_upgrade_subcommand_registered(): + """upgrade must be a valid subcommand.""" + import io + + from muxplex.cli import main + + buf = io.StringIO() + with patch("sys.argv", ["muxplex", "--help"]): + try: + with patch("sys.stdout", buf): + main() + except SystemExit: + pass + + help_text = buf.getvalue().lower() + assert "upgrade" in help_text + + +def test_update_alias_registered(): + """update must be a valid subcommand (alias for upgrade).""" + import io + + from muxplex.cli import main + + buf = io.StringIO() + with patch("sys.argv", ["muxplex", "--help"]): + try: + with patch("sys.stdout", buf): + main() + except SystemExit: + pass + + help_text = buf.getvalue().lower() + assert "update" in help_text + + +def test_upgrade_calls_uv_tool_install(monkeypatch, capsys): + """upgrade must attempt uv tool install when update is available.""" + import subprocess + + import muxplex.cli as cli_mod + + calls = [] + + def mock_run(cmd, **kwargs): + calls.append(cmd) + return type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})() + + monkeypatch.setattr(subprocess, "run", mock_run) + monkeypatch.setattr(shutil, "which", lambda name: f"/usr/bin/{name}") + monkeypatch.setattr(cli_mod, "install_service", lambda system=False: None) + monkeypatch.setattr(cli_mod, "doctor", lambda: None) + # Mock version check so upgrade proceeds regardless of local install type + monkeypatch.setattr( + cli_mod, + "_check_for_update", + lambda info: (True, "update available (abc12345 → def67890)"), + ) + + cli_mod.upgrade() + + # Should have called uv tool install + uv_calls = [c for c in calls if isinstance(c, list) and "uv" in str(c)] + assert len(uv_calls) > 0, "upgrade must call uv tool install" + + +def test_main_dispatches_to_upgrade(monkeypatch): + """main() with 'upgrade' subcommand must invoke upgrade().""" + from muxplex.cli import main + + calls = [] + monkeypatch.setattr("muxplex.cli.upgrade", lambda force=False: calls.append(True)) + + with patch("sys.argv", ["muxplex", "upgrade"]): + main() + + assert len(calls) == 1, "upgrade() must be called once for 'upgrade' subcommand" + + +def test_main_dispatches_update_to_upgrade(monkeypatch): + """main() with 'update' subcommand must also invoke upgrade().""" + from muxplex.cli import main + + calls = [] + monkeypatch.setattr("muxplex.cli.upgrade", lambda force=False: calls.append(True)) + + with patch("sys.argv", ["muxplex", "update"]): + main() + + assert len(calls) == 1, "upgrade() must be called once for 'update' subcommand" + + +# --------------------------------------------------------------------------- +# Smart version-check tests (_get_install_info / _check_for_update) +# --------------------------------------------------------------------------- + + +def test_get_install_info_returns_dict(): + """_get_install_info must return a dict with all required keys.""" + from muxplex.cli import _get_install_info + + info = _get_install_info() + assert "source" in info + assert "version" in info + assert "commit" in info + assert "url" in info + assert info["source"] in ("git", "editable", "pypi", "unknown") + + +def test_check_for_update_editable_returns_false(): + """Editable installs must never suggest an update.""" + from muxplex.cli import _check_for_update + + info = {"source": "editable", "version": "0.1.0", "commit": None, "url": None} + available, msg = _check_for_update(info) + assert available is False + assert "editable" in msg + + +def test_upgrade_force_skips_version_check(monkeypatch, capsys): + """upgrade(force=True) must skip the version check and proceed to install.""" + import subprocess + + import muxplex.cli as cli_mod + + calls = [] + + def mock_run(cmd, **kwargs): + calls.append(cmd) + return type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})() + + monkeypatch.setattr(subprocess, "run", mock_run) + monkeypatch.setattr(shutil, "which", lambda name: f"/usr/bin/{name}") + monkeypatch.setattr(cli_mod, "install_service", lambda system=False: None) + monkeypatch.setattr(cli_mod, "doctor", lambda: None) + # With force=True the version check must be bypassed entirely + check_calls = [] + monkeypatch.setattr( + cli_mod, + "_check_for_update", + lambda info: check_calls.append(info) or (True, "should not be reached"), + ) + + cli_mod.upgrade(force=True) + + # _check_for_update must NOT have been called when force=True + assert len(check_calls) == 0, "Version check must be skipped when force=True" + # uv install must still be attempted + uv_calls = [c for c in calls if isinstance(c, list) and "uv" in str(c)] + assert len(uv_calls) > 0, "upgrade(force=True) must still call uv tool install" + + +def test_upgrade_already_up_to_date_skips_install(monkeypatch, capsys): + """upgrade() must print 'up to date' and NOT call uv when version check says current.""" + import subprocess + + import muxplex.cli as cli_mod + + calls = [] + + def mock_run(cmd, **kwargs): + calls.append(cmd) + return type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})() + + monkeypatch.setattr(subprocess, "run", mock_run) + monkeypatch.setattr(shutil, "which", lambda name: f"/usr/bin/{name}") + monkeypatch.setattr(cli_mod, "install_service", lambda system=False: None) + monkeypatch.setattr(cli_mod, "doctor", lambda: None) + monkeypatch.setattr( + cli_mod, + "_check_for_update", + lambda info: (False, "up to date (commit abcd1234)"), + ) + + cli_mod.upgrade() + + out = capsys.readouterr().out + assert "up to date" in out.lower() or "already" in out.lower() + # uv install must NOT have been called + uv_calls = [c for c in calls if isinstance(c, list) and "uv" in str(c)] + assert len(uv_calls) == 0, "uv must NOT be called when already up to date" + + +def test_upgrade_force_flag_registered(): + """upgrade --force must be accepted by argparse without error.""" + import io + + from muxplex.cli import main + + buf = io.StringIO() + with patch("sys.argv", ["muxplex", "upgrade", "--help"]): + try: + with patch("sys.stdout", buf): + main() + except SystemExit: + pass + + help_text = buf.getvalue() + assert "--force" in help_text diff --git a/muxplex/tests/test_frontend_css.py b/muxplex/tests/test_frontend_css.py index 95ea095..b5544a8 100644 --- a/muxplex/tests/test_frontend_css.py +++ b/muxplex/tests/test_frontend_css.py @@ -1940,3 +1940,50 @@ def test_css_no_unclosed_braces() -> None: assert open_count == close_count, ( f"CSS file has unbalanced braces: {open_count} open vs {close_count} close" ) + + +def test_css_no_compact_view() -> None: + """.session-grid--compact CSS modifier must NOT exist — compact view was removed.""" + css = read_css() + assert ".session-grid--compact" not in css, ( + ".session-grid--compact must be removed — compact view mode was removed, only Auto and Fit remain" + ) + + +def test_css_fit_view_exists() -> None: + """.session-grid--fit CSS modifier must exist for fit view mode.""" + css = read_css() + assert ".session-grid--fit" in css, ( + "Missing .session-grid--fit CSS selector for fit view mode" + ) + + +def test_css_no_compact_tile_height() -> None: + """.session-grid--compact .session-tile must NOT exist — compact view was removed.""" + css = read_css() + assert ".session-grid--compact .session-tile" not in css, ( + ".session-grid--compact .session-tile must be removed — compact view mode was removed" + ) + + +# ============================================================ +# Fit view bug fixes +# ============================================================ + + +def test_fit_view_pre_has_top_zero() -> None: + """In fit mode, .tile-body pre must have top:0 to fill the full tile height. + + Bug: .tile-body pre uses position:absolute with bottom:0 but no top:0. + In fit mode where tiles are taller than auto mode, the pre is anchored + to the bottom but only takes natural content height, leaving a black gap above. + Fix: .session-grid--fit .tile-body pre { top: 0 } stretches it to fill the tile. + """ + css = read_css() + assert ".session-grid--fit .tile-body pre" in css, ( + "Missing .session-grid--fit .tile-body pre rule — needed to fix pre height in fit mode" + ) + block = _extract_rule_block(css, ".session-grid--fit .tile-body pre {") + assert "top: 0" in block or "top:0" in block, ( + ".session-grid--fit .tile-body pre must have top: 0 to fill full tile body height" + ) diff --git a/muxplex/tests/test_frontend_html.py b/muxplex/tests/test_frontend_html.py index b04e937..eb998ab 100644 --- a/muxplex/tests/test_frontend_html.py +++ b/muxplex/tests/test_frontend_html.py @@ -1109,3 +1109,107 @@ def test_html_sessions_tab_add_remote_instance_btn() -> None: assert sessions_panel.find(id="add-remote-instance-btn") is not None, ( "#add-remote-instance-btn must be inside the sessions settings panel" ) + + +# ============================================================ +# Delete session template (task: customizable delete command) +# ============================================================ + + +def test_html_delete_template_textarea_exists() -> None: + """New Session (Commands) panel must contain #setting-delete-template textarea.""" + soup = _SOUP + dialog = soup.find(id="settings-dialog") + assert dialog is not None, "Missing #settings-dialog" + new_session_panel = dialog.find( + class_="settings-panel", attrs={"data-tab": "new-session"} + ) + assert new_session_panel is not None, "Missing new-session settings-panel" + textarea = new_session_panel.find("textarea", id="setting-delete-template") + assert textarea is not None, ( + "Missing