feat(shuma): matar procesos + guardar grupos desde la UI
shuma-exec: RunHandle::kill() — el proceso se comparte con su hilo
coordinador (Arc<Mutex<Child>>) 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 <nombre>` 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 <noreply@anthropic.com>
3bfb42f1cc10338aabc61511d1ce2472bb92004f · por sergio · 2026-05-20T19:09:09Z (hace 5 meses) · padre 0fde2aa27 · ver el árbol en este commit
Delta semántico · 11
crates/apps/shuma-shell/src/main.rs
crates/modules/shuma/shuma-exec/src/lib.rs
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<Drag>,
/// Estado de presentación por comando (acordeón, filtro stderr).
run_ui: HashMap<RunId, RunUi>,
+ /// 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 <nombre>` — 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<gpui::Div> = 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<RunEvent>,
finished: bool,
+ /// El proceso, compartido con su hilo coordinador para poder matarlo.
+ child: Arc<Mutex<Option<Child>>>,
}
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<RunEvent> {
@@ -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<Mutex<Option<Child>>> = 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());
+ }
}