From 3bfb42f1cc10338aabc61511d1ce2472bb92004f Mon Sep 17 00:00:00 2001 From: sergio Date: Wed, 20 May 2026 19:09:09 +0000 Subject: [PATCH] feat(shuma): matar procesos + guardar grupos desde la UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit shuma-exec: RunHandle::kill() — el proceso se comparte con su hilo coordinador (Arc>) para poder terminarlo; los lectores cierran al cerrarse los pipes. 8 tests (incluye kill de un sleep). shuma-shell: - Cada tarjeta de comando en curso (▷) muestra un botón «✕ matar». - Meta-comando `:save ` guarda como grupo los comandos ejecutados desde el último guardado. El botón «+» del panel [RUN] precarga «:save » en el input para nombrarlo. Co-Authored-By: Claude Opus 4.7 --- crates/apps/shuma-shell/src/main.rs | 98 +++++++++++++++++++--- crates/modules/shuma/shuma-exec/src/lib.rs | 43 +++++++++- 2 files changed, 125 insertions(+), 16 deletions(-) diff --git a/crates/apps/shuma-shell/src/main.rs b/crates/apps/shuma-shell/src/main.rs index de0399e..6f66bd1 100644 --- a/crates/apps/shuma-shell/src/main.rs +++ b/crates/apps/shuma-shell/src/main.rs @@ -252,6 +252,9 @@ struct Shell { drag: Option, /// Estado de presentación por comando (acordeón, filtro stderr). run_ui: HashMap, + /// Largo del historial en el último `:save` — define qué comandos + /// entran al próximo grupo guardado. + group_anchor: usize, /// Scroll del feed central — sigue al comando más reciente. scroll: ScrollHandle, focus: FocusHandle, @@ -291,6 +294,7 @@ impl Shell { right_width: 188.0, drag: None, run_ui: HashMap::new(), + group_anchor: 0, scroll: ScrollHandle::new(), focus: cx.focus_handle(), focused_once: false, @@ -381,6 +385,19 @@ impl Shell { /// Ejecuta una línea: `cd` se maneja internamente (cambia el cwd y, /// con él, el aislamiento); el resto se lanza con `shuma-exec` y su /// salida se transmite al panel central. + /// Guarda como grupo los comandos ejecutados desde el último `:save`. + fn save_group(&mut self, name: &str) { + let name = name.trim(); + if name.is_empty() { + return; + } + let n = self.session.history().len().saturating_sub(self.group_anchor); + if n > 0 { + self.session.save_recent_as_group(name, n); + self.group_anchor = self.session.history().len(); + } + } + fn run_command(&mut self, line: String) { let line = line.trim().to_string(); if line.is_empty() { @@ -388,6 +405,12 @@ impl Shell { } let now = unix_now(); + // Meta-comando `:save ` — guarda un grupo, no se ejecuta. + if let Some(name) = line.strip_prefix(":save ") { + self.save_group(name); + return; + } + // Los comandos anteriores que el usuario no fijó se autocolapsan // al aparecer uno nuevo abajo — orden de terminal tradicional. for ui in self.run_ui.values_mut() { @@ -425,6 +448,13 @@ impl Shell { self.scroll.scroll_to_bottom(); } + /// Mata el proceso de un comando en curso. + fn kill_run(&self, id: RunId) { + if let Some((_, handle)) = self.active.iter().find(|(rid, _)| *rid == id) { + handle.kill(); + } + } + fn refresh_completion(&mut self) { let comp = self.line.complete(&self.source); self.show_completion = @@ -677,13 +707,37 @@ fn render_run( None }; + // Botón de matar — sólo mientras el comando sigue corriendo. + let kill_chip = if r.status == RunStatus::Running { + Some( + div() + .id(SharedString::from(format!("kill-{id}"))) + .flex_none() + .px(px(6.)) + .py(px(1.)) + .rounded(px(3.)) + .text_size(px(11.)) + .text_color(gpui::hsla(2.0 / 360.0, 0.66, 0.64, 1.0)) + .cursor_pointer() + .hover(|s| s.bg(gpui::hsla(2.0 / 360.0, 0.55, 0.28, 1.0))) + .child("✕ matar") + .on_click(cx.listener(move |shell, _, _, cx| { + shell.kill_run(id); + cx.notify(); + })), + ) + } else { + None + }; + let header = div() .flex() .flex_row() .items_center() .gap(px(6.)) .child(header_left) - .children(stderr_chip); + .children(stderr_chip) + .children(kill_chip); // Cuerpo: sólo con el acordeón abierto. El filtro elige el flujo. let mut body: Vec = Vec::new(); @@ -841,16 +895,36 @@ impl Render for Shell { .child(div().text_color(dim).text_size(px(12.)).child("[RUN] grupos")) .child( div() - .id("collapse-left") - .px(px(5.)) - .text_color(dim) - .cursor_pointer() - .hover(|s| s.text_color(accent)) - .child("«") - .on_click(cx.listener(|s, _, _, cx| { - s.left_collapsed = true; - cx.notify(); - })), + .flex() + .flex_row() + .gap(px(4.)) + .items_center() + .child( + div() + .id("save-group") + .px(px(5.)) + .text_color(dim) + .cursor_pointer() + .hover(|s| s.text_color(accent)) + .child("+") + .on_click(cx.listener(|s, _, _, cx| { + s.line.set_text(":save "); + cx.notify(); + })), + ) + .child( + div() + .id("collapse-left") + .px(px(5.)) + .text_color(dim) + .cursor_pointer() + .hover(|s| s.text_color(accent)) + .child("«") + .on_click(cx.listener(|s, _, _, cx| { + s.left_collapsed = true; + cx.notify(); + })), + ), ), ) .children(groups) @@ -858,7 +932,7 @@ impl Render for Shell { div() .text_size(px(10.)) .text_color(dim) - .child("clic para ejecutar el grupo"), + .child("clic ejecuta · + guarda lo último"), ) }; diff --git a/crates/modules/shuma/shuma-exec/src/lib.rs b/crates/modules/shuma/shuma-exec/src/lib.rs index 504d8b8..07fbc48 100644 --- a/crates/modules/shuma/shuma-exec/src/lib.rs +++ b/crates/modules/shuma/shuma-exec/src/lib.rs @@ -17,8 +17,9 @@ #![forbid(unsafe_code)] use std::io::{BufRead, BufReader}; -use std::process::{Command, Stdio}; +use std::process::{Child, Command, Stdio}; use std::sync::mpsc::{self, Receiver, TryRecvError}; +use std::sync::{Arc, Mutex}; /// Qué ejecutar: una línea de comandos, en un directorio, con un shell. #[derive(Debug, Clone)] @@ -63,9 +64,21 @@ impl RunEvent { pub struct RunHandle { rx: Receiver, finished: bool, + /// El proceso, compartido con su hilo coordinador para poder matarlo. + child: Arc>>, } impl RunHandle { + /// Mata el proceso (envía la señal de terminación). No hace nada si + /// el proceso ya terminó o nunca llegó a lanzarse. + pub fn kill(&self) { + if let Ok(mut guard) = self.child.lock() { + if let Some(c) = guard.as_mut() { + let _ = c.kill(); + } + } + } + /// Drena todos los eventos disponibles ahora mismo, sin bloquear. /// Marca el asa como terminada al ver un evento terminal. pub fn try_events(&mut self) -> Vec { @@ -114,6 +127,8 @@ impl RunHandle { pub fn run(spec: &CommandSpec) -> RunHandle { let (tx, rx) = mpsc::channel(); let spec = spec.clone(); + let child_cell: Arc>> = Arc::new(Mutex::new(None)); + let cell = Arc::clone(&child_cell); std::thread::spawn(move || { let spawned = Command::new(&spec.shell) @@ -136,6 +151,10 @@ pub fn run(spec: &CommandSpec) -> RunHandle { // Un hilo lector por flujo: stdout y stderr fluyen en paralelo. let stdout = child.stdout.take(); let stderr = child.stderr.take(); + // Comparte el proceso para que `RunHandle::kill` pueda alcanzarlo. + if let Ok(mut g) = cell.lock() { + *g = Some(child); + } let out_reader = stdout.map(|s| { let tx = tx.clone(); std::thread::spawn(move || { @@ -157,21 +176,24 @@ pub fn run(spec: &CommandSpec) -> RunHandle { }) }); + // Los lectores terminan cuando el proceso cierra sus pipes —sea + // por fin natural o por `kill`—; recién entonces se cosecha. if let Some(h) = out_reader { let _ = h.join(); } if let Some(h) = err_reader { let _ = h.join(); } - let code = child - .wait() + let code = cell + .lock() .ok() + .and_then(|mut g| g.as_mut().and_then(|c| c.wait().ok())) .and_then(|s| s.code()) .unwrap_or(-1); let _ = tx.send(RunEvent::Exited(code)); }); - RunHandle { rx, finished: false } + RunHandle { rx, finished: false, child: child_cell } } #[cfg(test)] @@ -253,4 +275,17 @@ mod tests { assert!(RunEvent::Failed("x".into()).is_terminal()); assert!(!RunEvent::Stdout("x".into()).is_terminal()); } + + #[test] + fn kill_stops_a_long_running_process() { + let mut h = run(&sh("sleep 30")); + // Espera breve para que el proceso se haya lanzado. + std::thread::sleep(std::time::Duration::from_millis(250)); + h.kill(); + // wait_all retorna pronto (no espera los 30s) y cierra con un + // evento terminal. + let events = h.wait_all(); + assert!(events.last().map(|e| e.is_terminal()).unwrap_or(false)); + assert!(h.is_finished()); + } }