diff --git a/muxplex/frontend/app.js b/muxplex/frontend/app.js index 65dbc8f..838f966 100644 --- a/muxplex/frontend/app.js +++ b/muxplex/frontend/app.js @@ -1380,17 +1380,23 @@ function buildSources(settings) { var sources = [ { url: '', name: localName, type: 'local', status: 'authenticated', backoffMs: 2000 }, ]; + // Only add remote sources when multi-device is enabled. + // Smart default: treat as enabled if remote_instances is non-empty (backward compat). var remotes = (settings && settings.remote_instances) || []; - for (var i = 0; i < remotes.length; i++) { - var r = remotes[i]; - if (r && r.url) { - sources.push({ - url: r.url.replace(/\/+$/, ''), - name: r.name || r.url, - type: 'remote', - status: 'authenticated', - backoffMs: 2000, - }); + var multiDeviceEnabled = (settings && settings.multi_device_enabled) || + (remotes.length > 0); + if (multiDeviceEnabled) { + for (var i = 0; i < remotes.length; i++) { + var r = remotes[i]; + if (r && r.url) { + sources.push({ + url: r.url.replace(/\/+$/, ''), + name: r.name || r.url, + type: 'remote', + status: 'authenticated', + backoffMs: 2000, + }); + } } } return sources; @@ -1449,6 +1455,25 @@ function _saveRemoteInstances() { }); } +// ─── Multi-Device helper ────────────────────────────────────────────────────────── + +/** + * Enable or disable all Multi-Device tab fields (except the enable toggle itself). + * When disabled, the fields container gets opacity: 0.5 and inputs/selects/buttons + * are disabled so users cannot interact with them. + * @param {boolean} enabled + */ +function _updateMultiDeviceFieldsState(enabled) { + var fieldsContainer = $('multi-device-fields'); + if (!fieldsContainer) return; + var controls = fieldsContainer.querySelectorAll('input, select, button'); + controls.forEach(function(ctrl) { + ctrl.disabled = !enabled; + }); + fieldsContainer.style.opacity = enabled ? '' : '0.5'; +} + + // ─── Settings dialog ────────────────────────────────────────────────────────── /** @@ -1766,6 +1791,20 @@ function openSettings() { deviceNameEl.value = (ss && ss.device_name) || ''; } + // Update document.title from device_name setting + if (ss && ss.device_name) { + document.title = ss.device_name; + } + + // Multi-device enabled checkbox (with smart default: checked if remote_instances non-empty) + const multiDeviceEnabledEl = $('setting-multi-device-enabled'); + if (multiDeviceEnabledEl) { + var remoteList = (ss && ss.remote_instances) || []; + multiDeviceEnabledEl.checked = !!(ss && ss.multi_device_enabled) || + remoteList.length > 0; + _updateMultiDeviceFieldsState(multiDeviceEnabledEl.checked); + } + // Remote instances const remoteInstancesEl = $('setting-remote-instances'); if (remoteInstancesEl) { @@ -2298,11 +2337,21 @@ function bindStaticEventListeners() { patchServerSetting('new_session_template', NEW_SESSION_DEFAULT_TEMPLATE); }); - // Sessions tab — device name with 500ms debounce + // Multi-Device tab — enable/disable toggle + on($('setting-multi-device-enabled'), 'change', function() { + var enabled = this.checked; + _updateMultiDeviceFieldsState(enabled); + patchServerSetting('multi_device_enabled', enabled).then(function() { + _sources = buildSources(_serverSettings); + }); + }); + + // Multi-Device tab — device name with 500ms debounce; updates document.title immediately var _deviceNameDebounceTimer; on($('setting-device-name'), 'input', function() { clearTimeout(_deviceNameDebounceTimer); var val = this.value; + if (val) document.title = val; _deviceNameDebounceTimer = setTimeout(function() { patchServerSetting('device_name', val).then(function() { _sources = buildSources(_serverSettings); diff --git a/muxplex/frontend/index.html b/muxplex/frontend/index.html index 8791bc2..41f04c8 100644 --- a/muxplex/frontend/index.html +++ b/muxplex/frontend/index.html @@ -90,6 +90,7 @@ +
@@ -123,21 +124,6 @@
-
- - -
-
- - -
+ diff --git a/muxplex/frontend/style.css b/muxplex/frontend/style.css index ce7b852..c3503a4 100644 --- a/muxplex/frontend/style.css +++ b/muxplex/frontend/style.css @@ -1653,3 +1653,8 @@ body { color: #ef4444; background: rgba(239, 68, 68, 0.1); } + +/* Multi-Device tab — smooth enable/disable opacity transition */ +#multi-device-fields { + transition: opacity 0.2s ease; +} diff --git a/muxplex/settings.py b/muxplex/settings.py index d5103f3..717d3c3 100644 --- a/muxplex/settings.py +++ b/muxplex/settings.py @@ -21,6 +21,7 @@ DEFAULT_SETTINGS: dict = { "remote_instances": [], "device_name": "", "delete_session_template": "tmux kill-session -t {name}", + "multi_device_enabled": False, } diff --git a/muxplex/tests/test_frontend_css.py b/muxplex/tests/test_frontend_css.py index b5544a8..25911c7 100644 --- a/muxplex/tests/test_frontend_css.py +++ b/muxplex/tests/test_frontend_css.py @@ -1987,3 +1987,23 @@ def test_fit_view_pre_has_top_zero() -> None: 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" ) + + +# ============================================================ +# Multi-Device tab CSS (settings UI reorganization) +# ============================================================ + + +def test_css_multi_device_fields_transition() -> None: + """#multi-device-fields must have a CSS transition for smooth enable/disable animation.""" + css = read_css() + assert "#multi-device-fields" in css, ( + "Missing #multi-device-fields CSS rule — needed for smooth enable/disable opacity transition" + ) + match = re.search(r"#multi-device-fields\s*\{([^}]*)\}", css, re.DOTALL) + assert match, "#multi-device-fields CSS rule not found" + body = match.group(1) + assert "transition" in body, ( + "#multi-device-fields must have a transition property for smooth enable/disable animation" + ) + assert "opacity" in body, "#multi-device-fields transition must include opacity" diff --git a/muxplex/tests/test_frontend_html.py b/muxplex/tests/test_frontend_html.py index eb998ab..3377f47 100644 --- a/muxplex/tests/test_frontend_html.py +++ b/muxplex/tests/test_frontend_html.py @@ -434,7 +434,7 @@ def test_html_settings_dialog() -> None: def test_html_settings_tabs() -> None: - """settings-dialog must contain 4 tab buttons with correct data-tab values.""" + """settings-dialog must contain 5 tab buttons with correct data-tab values.""" soup = _SOUP dialog = soup.find(id="settings-dialog") assert dialog is not None, "Missing #settings-dialog" @@ -442,7 +442,7 @@ def test_html_settings_tabs() -> None: assert tabs_container is not None, ( "Missing nav.settings-tabs inside #settings-dialog" ) - expected_tabs = ["display", "sessions", "notifications", "new-session"] + expected_tabs = ["display", "sessions", "notifications", "new-session", "devices"] for tab_value in expected_tabs: tab = tabs_container.find("button", attrs={"data-tab": tab_value}) assert tab is not None, ( @@ -540,8 +540,8 @@ def test_html_settings_panels_use_data_tab() -> None: dialog = soup.find(id="settings-dialog") assert dialog is not None, "Missing #settings-dialog" panels = dialog.find_all(class_="settings-panel") - assert len(panels) == 4, ( - f"Expected 4 .settings-panel elements, found: {len(panels)}" + assert len(panels) == 5, ( + f"Expected 5 .settings-panel elements, found: {len(panels)}" ) for panel in panels: assert panel.get("data-tab") is not None, ( @@ -1070,44 +1070,44 @@ def test_html_settings_close_btn_exists() -> None: def test_html_sessions_tab_device_name_input() -> None: - """Sessions tab must contain a #setting-device-name text input for the device name.""" + """Multi-Device tab must contain a #setting-device-name text input for the device name.""" soup = _SOUP el = soup.find(id="setting-device-name") assert el is not None, "Missing element with id='setting-device-name'" - # Must be inside the sessions panel - sessions_panel = soup.find("div", attrs={"data-tab": "sessions"}) - assert sessions_panel is not None, "Missing sessions panel (data-tab='sessions')" - assert sessions_panel.find(id="setting-device-name") is not None, ( - "#setting-device-name must be inside the sessions settings panel" + # Must be inside the devices panel (not sessions) + devices_panel = soup.find("div", attrs={"data-tab": "devices"}) + assert devices_panel is not None, "Missing devices panel (data-tab='devices')" + assert devices_panel.find(id="setting-device-name") is not None, ( + "#setting-device-name must be inside the devices (Multi-Device) settings panel" ) def test_html_sessions_tab_remote_instances_container() -> None: - """Sessions tab must contain a #setting-remote-instances container for remote instance rows.""" + """Multi-Device tab must contain a #setting-remote-instances container for remote instance rows.""" soup = _SOUP el = soup.find(id="setting-remote-instances") assert el is not None, "Missing element with id='setting-remote-instances'" - # Must be inside the sessions panel - sessions_panel = soup.find("div", attrs={"data-tab": "sessions"}) - assert sessions_panel is not None, "Missing sessions panel (data-tab='sessions')" - assert sessions_panel.find(id="setting-remote-instances") is not None, ( - "#setting-remote-instances must be inside the sessions settings panel" + # Must be inside the devices panel + devices_panel = soup.find("div", attrs={"data-tab": "devices"}) + assert devices_panel is not None, "Missing devices panel (data-tab='devices')" + assert devices_panel.find(id="setting-remote-instances") is not None, ( + "#setting-remote-instances must be inside the devices (Multi-Device) settings panel" ) def test_html_sessions_tab_add_remote_instance_btn() -> None: - """Sessions tab must contain an #add-remote-instance-btn button to add remote instances.""" + """Multi-Device tab must contain an #add-remote-instance-btn button to add remote instances.""" soup = _SOUP el = soup.find(id="add-remote-instance-btn") assert el is not None, "Missing element with id='add-remote-instance-btn'" assert el.name == "button", ( f"#add-remote-instance-btn must be a