From 67092af29b83213393221c75e2063d85ebe029ae Mon Sep 17 00:00:00 2001 From: falkoro <39274208+falkoro@users.noreply.github.com> Date: Mon, 1 Jun 2026 11:13:35 +0200 Subject: [PATCH] Add tmux session stop control --- frontend/core.ts | 3 +++ frontend/events.ts | 7 +++++++ frontend/render.ts | 5 ++++- public/app.css | 7 ++++--- public/core.js | 3 +++ public/events.js | 8 ++++++++ public/render.js | 5 ++++- src/pages.rs | 2 +- src/routes.rs | 16 ++++++++++++++++ src/tmux.rs | 12 ++++++++++++ 10 files changed, 62 insertions(+), 6 deletions(-) diff --git a/frontend/core.ts b/frontend/core.ts index 6827dd8..cb42325 100644 --- a/frontend/core.ts +++ b/frontend/core.ts @@ -534,6 +534,9 @@ function updateUnlockState(): void { document.querySelectorAll('[data-key]').forEach((button) => { button.disabled = !targetReady(button.dataset.shell || ''); }); + document.querySelectorAll('[data-stop]').forEach((button) => { + button.disabled = !targetReady(button.dataset.stop || ''); + }); document.querySelectorAll('[data-command]').forEach((input) => { input.disabled = !targetReady(input.dataset.command || ''); }); diff --git a/frontend/events.ts b/frontend/events.ts index ea50568..969e118 100644 --- a/frontend/events.ts +++ b/frontend/events.ts @@ -3,6 +3,7 @@ document.addEventListener('click', async (event: MouseEvent) => { if (!target) return; const copyButton = target.closest('[data-copy]'); const startButton = target.closest('[data-start]'); + const stopButton = target.closest('[data-stop]'); const restartButton = target.closest('[data-restart]'); const keyButton = target.closest('[data-key]'); const sendButton = target.closest('[data-send-shell]'); @@ -91,6 +92,12 @@ document.addEventListener('click', async (event: MouseEvent) => { await sessionAction('/api/start', startButton.dataset.start || ''); return selectSession(startButton.dataset.start); } + if (stopButton && !stopButton.disabled) { + const stopped = stopButton.dataset.stop || ''; + await sessionAction('/api/stop', stopped); + if (sessionByName(stopped)) selectSession(stopped); + return; + } if (restartButton && !restartButton.disabled) { await sessionAction('/api/restart', restartButton.dataset.restart || ''); return selectSession(restartButton.dataset.restart); diff --git a/frontend/render.ts b/frontend/render.ts index 763efab..73143e1 100644 --- a/frontend/render.ts +++ b/frontend/render.ts @@ -25,6 +25,7 @@ function createShellCard(shell: ShellPreview): HTMLElement { + @@ -51,6 +52,7 @@ function createShellCard(shell: ShellPreview): HTMLElement { article.querySelector('[data-reset-preview]')!.dataset.resetPreview = shell.name; article.querySelector('[data-shellin]')!.dataset.shellin = shell.name; article.querySelector('[data-resume]')!.dataset.resume = shell.name; + article.querySelector('[data-stop]')!.dataset.stop = shell.name; article.querySelectorAll('[data-key]').forEach((button) => { button.dataset.shell = shell.name; }); @@ -238,13 +240,14 @@ function renderSelectedSessionActions(): void { const state = sessionRuntime(selected); const startDisabled = selected.running || selected.family === 'custom' || !shellUnlocked ? 'disabled' : ''; const restartDisabled = selected.family === 'custom' || !shellUnlocked ? 'disabled' : ''; + const stopDisabled = !selected.running || !shellUnlocked ? 'disabled' : ''; const attached = selected.attached > 0 ? `${selected.attached} attached` : ''; const displayLabel = shellDisplayLabel(selected.name, selected.label); const sshButton = selected.sshCommand ? `` : ''; el.hidden = false; - el.innerHTML = `
${escapeHtml(selected.badge)}
${escapeHtml(displayLabel)}${escapeHtml(state.label)} · ${escapeHtml(fmtTime(selected.activity))}${attached}
${sshButton}
`; + el.innerHTML = `
${escapeHtml(selected.badge)}
${escapeHtml(displayLabel)}${escapeHtml(state.label)} · ${escapeHtml(fmtTime(selected.activity))}${attached}
${sshButton}
`; } let shellTabsSignature = ''; diff --git a/public/app.css b/public/app.css index 389c7ec..a3ccfc4 100644 --- a/public/app.css +++ b/public/app.css @@ -499,11 +499,12 @@ body.shells-locked .container-actions{display:none} /* Compact (remote) rows: float the action buttons at the right, revealed on hover/focus, so they never crowd the line or wrap at narrow sidebar widths. */ .container-item.compact{position:relative} -.container-item.compact .container-actions{position:absolute;top:50%;right:6px;transform:translateY(-50%);margin:0;opacity:0;pointer-events:none;transition:opacity .12s ease;background:rgba(5,11,17,.92);border:1px solid rgba(139,246,255,.18);border-radius:7px;padding:2px;gap:4px} +.container-item.compact .container-actions{position:absolute;top:6px;right:6px;transform:none;margin:0;opacity:0;pointer-events:none;transition:opacity .12s ease;background:rgba(5,11,17,.92);border:1px solid rgba(139,246,255,.18);border-radius:7px;padding:2px;gap:4px} .container-item.compact:hover .container-actions,.container-item.compact:focus-within .container-actions{opacity:1;pointer-events:auto} +.container-item.compact:hover .ci-cpu,.container-item.compact:focus-within .ci-cpu,.container-item.compact:hover .container-age,.container-item.compact:focus-within .container-age{opacity:0} .container-item.compact .container-action{padding:2px 7px} -/* No hover on touch — keep the buttons in flow on their own line there. */ -@media (hover:none){.container-item.compact .container-actions{position:static;transform:none;opacity:1;pointer-events:auto;background:none;border:0;justify-content:flex-end;margin-top:3px}} +/* No hover on touch — keep the buttons visible in the same reserved top-right space. */ +@media (hover:none){.container-item.compact .container-actions{top:6px;right:6px;transform:none;opacity:1;pointer-events:auto}.container-item.compact .ci-cpu,.container-item.compact .container-age{opacity:0}} .container-item .ci-row1{align-items:flex-start} /* Condensed 2-line container row for remote host cards. */ .container-item.compact{padding:6px 9px;gap:2px} diff --git a/public/core.js b/public/core.js index c2e9274..a1355e5 100644 --- a/public/core.js +++ b/public/core.js @@ -436,6 +436,9 @@ function updateUnlockState() { document.querySelectorAll('[data-key]').forEach((button) => { button.disabled = !targetReady(button.dataset.shell || ''); }); + document.querySelectorAll('[data-stop]').forEach((button) => { + button.disabled = !targetReady(button.dataset.stop || ''); + }); document.querySelectorAll('[data-command]').forEach((input) => { input.disabled = !targetReady(input.dataset.command || ''); }); diff --git a/public/events.js b/public/events.js index 64c36aa..e05a1a5 100644 --- a/public/events.js +++ b/public/events.js @@ -5,6 +5,7 @@ document.addEventListener('click', async (event) => { return; const copyButton = target.closest('[data-copy]'); const startButton = target.closest('[data-start]'); + const stopButton = target.closest('[data-stop]'); const restartButton = target.closest('[data-restart]'); const keyButton = target.closest('[data-key]'); const sendButton = target.closest('[data-send-shell]'); @@ -103,6 +104,13 @@ document.addEventListener('click', async (event) => { await sessionAction('/api/start', startButton.dataset.start || ''); return selectSession(startButton.dataset.start); } + if (stopButton && !stopButton.disabled) { + const stopped = stopButton.dataset.stop || ''; + await sessionAction('/api/stop', stopped); + if (sessionByName(stopped)) + selectSession(stopped); + return; + } if (restartButton && !restartButton.disabled) { await sessionAction('/api/restart', restartButton.dataset.restart || ''); return selectSession(restartButton.dataset.restart); diff --git a/public/render.js b/public/render.js index f3ca403..96987ef 100644 --- a/public/render.js +++ b/public/render.js @@ -25,6 +25,7 @@ function createShellCard(shell) { + @@ -51,6 +52,7 @@ function createShellCard(shell) { article.querySelector('[data-reset-preview]').dataset.resetPreview = shell.name; article.querySelector('[data-shellin]').dataset.shellin = shell.name; article.querySelector('[data-resume]').dataset.resume = shell.name; + article.querySelector('[data-stop]').dataset.stop = shell.name; article.querySelectorAll('[data-key]').forEach((button) => { button.dataset.shell = shell.name; }); @@ -245,13 +247,14 @@ function renderSelectedSessionActions() { const state = sessionRuntime(selected); const startDisabled = selected.running || selected.family === 'custom' || !shellUnlocked ? 'disabled' : ''; const restartDisabled = selected.family === 'custom' || !shellUnlocked ? 'disabled' : ''; + const stopDisabled = !selected.running || !shellUnlocked ? 'disabled' : ''; const attached = selected.attached > 0 ? `${selected.attached} attached` : ''; const displayLabel = shellDisplayLabel(selected.name, selected.label); const sshButton = selected.sshCommand ? `` : ''; el.hidden = false; - el.innerHTML = `
${escapeHtml(selected.badge)}
${escapeHtml(displayLabel)}${escapeHtml(state.label)} · ${escapeHtml(fmtTime(selected.activity))}${attached}
${sshButton}
`; + el.innerHTML = `
${escapeHtml(selected.badge)}
${escapeHtml(displayLabel)}${escapeHtml(state.label)} · ${escapeHtml(fmtTime(selected.activity))}${attached}
${sshButton}
`; } let shellTabsSignature = ''; function shellbarSummaryMoving(text) { diff --git a/src/pages.rs b/src/pages.rs index 381198c..d0c467a 100644 --- a/src/pages.rs +++ b/src/pages.rs @@ -43,7 +43,7 @@ fn links_panel_html(config: &Config) -> String { fn workspace(links_panel: &str) -> String { format!( - r#"

Shells

All panes side-by-side. Type into any shell.

stream idle
"#, + r#"

Shells

All panes side-by-side. Type into any shell.

stream idle
"#, links_panel ) } diff --git a/src/routes.rs b/src/routes.rs index bcf24ea..792e063 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -116,6 +116,7 @@ pub fn router(state: AppState) -> Router { ) .route("/api/start", post(api_start)) .route("/api/restart", post(api_restart)) + .route("/api/stop", post(api_stop)) .route("/api/container-action", post(api_container_action)) .with_state(state) } @@ -596,6 +597,21 @@ async fn api_restart( session_result(tmux::restart_session(state.config.clone(), &body.name).await) } +async fn api_stop( + State(state): State, + headers: HeaderMap, + connect: ConnectInfo, + axum::Json(body): axum::Json, +) -> Response { + if let Some(response) = guard(&state, &headers, &connect) { + return response; + } + if let Err(response) = require_unlock(&state, &headers).and_then(|_| require_action(&headers)) { + return response; + } + session_result(tmux::stop_session(&body.name).await) +} + // Restart / pull-latest a Docker/Podman container, locally or on a configured remote host. // Mutating: login + shell-unlock + action-header gated, same as the tmux session controls. async fn api_container_action( diff --git a/src/tmux.rs b/src/tmux.rs index 2932ebf..c04ba64 100644 --- a/src/tmux.rs +++ b/src/tmux.rs @@ -374,6 +374,18 @@ pub async fn restart_session(config: Arc, name: &str) -> Result Result { + if name.trim().is_empty() { + return Err("Session name is required".to_string()); + } + let sessions = list_tmux_sessions().await; + if !sessions.iter().any(|s| s.name == name) { + return Err(format!("{name} is not running")); + } + tmux_output(&["kill-session", "-t", name]).await?; + Ok(format!("{name} stopped")) +} + pub async fn paste_text( config: Arc, name: &str,