diff --git a/muxplex/frontend/app.js b/muxplex/frontend/app.js index 9acb4ca..ca51984 100644 --- a/muxplex/frontend/app.js +++ b/muxplex/frontend/app.js @@ -142,6 +142,7 @@ const DISPLAY_DEFAULTS = { notificationPermission: 'default', }; 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) { @@ -1221,11 +1222,17 @@ function openSettings() { autoOpenEl.checked = ss && ss.auto_open !== undefined ? !!ss.auto_open : true; } - // 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; + } }); } @@ -1687,7 +1694,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); @@ -1697,12 +1704,29 @@ 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; patchServerSetting('new_session_template', NEW_SESSION_DEFAULT_TEMPLATE); }); + + // 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 ──────────────────────────────────────────────────────── @@ -1798,6 +1822,9 @@ if (typeof module !== 'undefined' && module.exports) { createNewSession, // Kill session killSession, + // 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 56ca3fb..b96b453 100644 --- a/muxplex/frontend/index.html +++ b/muxplex/frontend/index.html @@ -87,7 +87,7 @@ - +
@@ -169,6 +169,12 @@ {name} is replaced with the session name
+
+ + + {name} is replaced with the session name + +
diff --git a/muxplex/frontend/tests/test_app.mjs b/muxplex/frontend/tests/test_app.mjs index 11918b5..b1b8ead 100644 --- a/muxplex/frontend/tests/test_app.mjs +++ b/muxplex/frontend/tests/test_app.mjs @@ -2063,3 +2063,58 @@ test('pollSessions calls 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' + ); +}); diff --git a/muxplex/main.py b/muxplex/main.py index 505e27e..61b36da 100644 --- a/muxplex/main.py +++ b/muxplex/main.py @@ -411,9 +411,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. """ @@ -421,10 +425,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} diff --git a/muxplex/settings.py b/muxplex/settings.py index 27ee91d..9336334 100644 --- a/muxplex/settings.py +++ b/muxplex/settings.py @@ -17,6 +17,7 @@ DEFAULT_SETTINGS: dict = { "window_size_largest": False, "auto_open_created": True, "new_session_template": "tmux new-session -d -s {name}", + "delete_session_template": "tmux kill-session -t {name}", } diff --git a/muxplex/tests/test_api.py b/muxplex/tests/test_api.py index c12f67c..3aaade0 100644 --- a/muxplex/tests/test_api.py +++ b/muxplex/tests/test_api.py @@ -1044,20 +1044,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): @@ -1125,3 +1139,108 @@ def test_login_page_title_contains_hostname(client): 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_frontend_html.py b/muxplex/tests/test_frontend_html.py index 007b045..ef0320a 100644 --- a/muxplex/tests/test_frontend_html.py +++ b/muxplex/tests/test_frontend_html.py @@ -1062,3 +1062,94 @@ def test_html_settings_close_btn_exists() -> None: assert dialog.find(id="settings-close-btn") is not None, ( "#settings-close-btn must be a descendant of #settings-dialog" ) + + +# ============================================================ +# 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