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>
This commit is contained in:
@@ -252,6 +252,9 @@ struct Shell {
|
|||||||
drag: Option<Drag>,
|
drag: Option<Drag>,
|
||||||
/// Estado de presentación por comando (acordeón, filtro stderr).
|
/// Estado de presentación por comando (acordeón, filtro stderr).
|
||||||
run_ui: HashMap<RunId, RunUi>,
|
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 del feed central — sigue al comando más reciente.
|
||||||
scroll: ScrollHandle,
|
scroll: ScrollHandle,
|
||||||
focus: FocusHandle,
|
focus: FocusHandle,
|
||||||
@@ -291,6 +294,7 @@ impl Shell {
|
|||||||
right_width: 188.0,
|
right_width: 188.0,
|
||||||
drag: None,
|
drag: None,
|
||||||
run_ui: HashMap::new(),
|
run_ui: HashMap::new(),
|
||||||
|
group_anchor: 0,
|
||||||
scroll: ScrollHandle::new(),
|
scroll: ScrollHandle::new(),
|
||||||
focus: cx.focus_handle(),
|
focus: cx.focus_handle(),
|
||||||
focused_once: false,
|
focused_once: false,
|
||||||
@@ -381,6 +385,19 @@ impl Shell {
|
|||||||
/// Ejecuta una línea: `cd` se maneja internamente (cambia el cwd y,
|
/// 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
|
/// con él, el aislamiento); el resto se lanza con `shuma-exec` y su
|
||||||
/// salida se transmite al panel central.
|
/// 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) {
|
fn run_command(&mut self, line: String) {
|
||||||
let line = line.trim().to_string();
|
let line = line.trim().to_string();
|
||||||
if line.is_empty() {
|
if line.is_empty() {
|
||||||
@@ -388,6 +405,12 @@ impl Shell {
|
|||||||
}
|
}
|
||||||
let now = unix_now();
|
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
|
// Los comandos anteriores que el usuario no fijó se autocolapsan
|
||||||
// al aparecer uno nuevo abajo — orden de terminal tradicional.
|
// al aparecer uno nuevo abajo — orden de terminal tradicional.
|
||||||
for ui in self.run_ui.values_mut() {
|
for ui in self.run_ui.values_mut() {
|
||||||
@@ -425,6 +448,13 @@ impl Shell {
|
|||||||
self.scroll.scroll_to_bottom();
|
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) {
|
fn refresh_completion(&mut self) {
|
||||||
let comp = self.line.complete(&self.source);
|
let comp = self.line.complete(&self.source);
|
||||||
self.show_completion =
|
self.show_completion =
|
||||||
@@ -677,13 +707,37 @@ fn render_run(
|
|||||||
None
|
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()
|
let header = div()
|
||||||
.flex()
|
.flex()
|
||||||
.flex_row()
|
.flex_row()
|
||||||
.items_center()
|
.items_center()
|
||||||
.gap(px(6.))
|
.gap(px(6.))
|
||||||
.child(header_left)
|
.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.
|
// Cuerpo: sólo con el acordeón abierto. El filtro elige el flujo.
|
||||||
let mut body: Vec<gpui::Div> = Vec::new();
|
let mut body: Vec<gpui::Div> = Vec::new();
|
||||||
@@ -839,6 +893,25 @@ impl Render for Shell {
|
|||||||
.justify_between()
|
.justify_between()
|
||||||
.items_center()
|
.items_center()
|
||||||
.child(div().text_color(dim).text_size(px(12.)).child("[RUN] grupos"))
|
.child(div().text_color(dim).text_size(px(12.)).child("[RUN] grupos"))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.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(
|
.child(
|
||||||
div()
|
div()
|
||||||
.id("collapse-left")
|
.id("collapse-left")
|
||||||
@@ -852,13 +925,14 @@ impl Render for Shell {
|
|||||||
cx.notify();
|
cx.notify();
|
||||||
})),
|
})),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.children(groups)
|
.children(groups)
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.text_size(px(10.))
|
.text_size(px(10.))
|
||||||
.text_color(dim)
|
.text_color(dim)
|
||||||
.child("clic para ejecutar el grupo"),
|
.child("clic ejecuta · + guarda lo último"),
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -17,8 +17,9 @@
|
|||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
use std::io::{BufRead, BufReader};
|
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::mpsc::{self, Receiver, TryRecvError};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
/// Qué ejecutar: una línea de comandos, en un directorio, con un shell.
|
/// Qué ejecutar: una línea de comandos, en un directorio, con un shell.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -63,9 +64,21 @@ impl RunEvent {
|
|||||||
pub struct RunHandle {
|
pub struct RunHandle {
|
||||||
rx: Receiver<RunEvent>,
|
rx: Receiver<RunEvent>,
|
||||||
finished: bool,
|
finished: bool,
|
||||||
|
/// El proceso, compartido con su hilo coordinador para poder matarlo.
|
||||||
|
child: Arc<Mutex<Option<Child>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RunHandle {
|
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.
|
/// Drena todos los eventos disponibles ahora mismo, sin bloquear.
|
||||||
/// Marca el asa como terminada al ver un evento terminal.
|
/// Marca el asa como terminada al ver un evento terminal.
|
||||||
pub fn try_events(&mut self) -> Vec<RunEvent> {
|
pub fn try_events(&mut self) -> Vec<RunEvent> {
|
||||||
@@ -114,6 +127,8 @@ impl RunHandle {
|
|||||||
pub fn run(spec: &CommandSpec) -> RunHandle {
|
pub fn run(spec: &CommandSpec) -> RunHandle {
|
||||||
let (tx, rx) = mpsc::channel();
|
let (tx, rx) = mpsc::channel();
|
||||||
let spec = spec.clone();
|
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 || {
|
std::thread::spawn(move || {
|
||||||
let spawned = Command::new(&spec.shell)
|
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.
|
// Un hilo lector por flujo: stdout y stderr fluyen en paralelo.
|
||||||
let stdout = child.stdout.take();
|
let stdout = child.stdout.take();
|
||||||
let stderr = child.stderr.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 out_reader = stdout.map(|s| {
|
||||||
let tx = tx.clone();
|
let tx = tx.clone();
|
||||||
std::thread::spawn(move || {
|
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 {
|
if let Some(h) = out_reader {
|
||||||
let _ = h.join();
|
let _ = h.join();
|
||||||
}
|
}
|
||||||
if let Some(h) = err_reader {
|
if let Some(h) = err_reader {
|
||||||
let _ = h.join();
|
let _ = h.join();
|
||||||
}
|
}
|
||||||
let code = child
|
let code = cell
|
||||||
.wait()
|
.lock()
|
||||||
.ok()
|
.ok()
|
||||||
|
.and_then(|mut g| g.as_mut().and_then(|c| c.wait().ok()))
|
||||||
.and_then(|s| s.code())
|
.and_then(|s| s.code())
|
||||||
.unwrap_or(-1);
|
.unwrap_or(-1);
|
||||||
let _ = tx.send(RunEvent::Exited(code));
|
let _ = tx.send(RunEvent::Exited(code));
|
||||||
});
|
});
|
||||||
|
|
||||||
RunHandle { rx, finished: false }
|
RunHandle { rx, finished: false, child: child_cell }
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -253,4 +275,17 @@ mod tests {
|
|||||||
assert!(RunEvent::Failed("x".into()).is_terminal());
|
assert!(RunEvent::Failed("x".into()).is_terminal());
|
||||||
assert!(!RunEvent::Stdout("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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user