1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
//! Marked rows, and the actions that run over all of them at once.
//!
//! A batch is not a loop over the single-row action: it has to be refused as a
//! whole before anything is destroyed — one running session blocks a batch
//! delete — and the marks have to outlive the worker's replies, so a row stays
//! on screen until the deletion it was marked for is confirmed. That bookkeeping
//! is what earns this its own file.
use super::*;
impl App {
/// Open the terminate confirmation for the selected session, explaining
/// itself when there is nothing this cctop can signal.
pub(super) fn confirm_terminate(&mut self) {
// Kept here rather than at the key, because this is now the only way in:
// `k` moves the cursor, and Ctrl+K is what asks. A subagent has no
// process of its own to signal — stopping it means stopping its parent,
// which is not what the cursor is pointing at.
if self.on_subagent() {
self.set_status("A subagent cannot be stopped on its own");
return;
}
if let Some(why) = self.selected_session().and_then(App::remote_refusal) {
self.set_status(why);
return;
}
match self.selected_session() {
Some(s) if s.root_pid().is_some() => self.mode = Mode::KillConfirm,
Some(s) if s.is_running() => self.mode = Mode::KillBlocked,
Some(_) => self.set_status("Selected session is not running"),
None => {}
}
}
/// Toggle whether the selected session is marked for a batch action.
pub(super) fn toggle_mark(&mut self) {
let Some(s) = self.selected_session() else {
return;
};
let key = s.key();
if !self.marked.remove(&key) {
self.marked.insert(key);
}
self.needs_redraw = true;
}
/// The session keys currently marked, in table order for a stable listing.
pub(super) fn marked_sessions(&self) -> Vec<&Session> {
self.visible
.iter()
.filter_map(|row| match row {
// Child rows would list their parent a second time.
Row::Session(i) => self.sessions.get(*i),
Row::Subagent { .. } => None,
})
.filter(|s| self.marked.contains(&s.key()))
.collect()
}
/// True when every marked session is ready for the given batch action.
pub(super) fn batch_ok(&self, kind: BatchKind) -> bool {
self.marked_sessions().iter().all(|s| match kind {
BatchKind::Delete => !s.is_running(),
BatchKind::Kill => s.root_pid().is_some(),
})
}
pub(super) fn unmark_all(&mut self) {
if self.marked.is_empty() {
return;
}
self.marked.clear();
self.needs_redraw = true;
}
/// Enter the batch-confirm modal if there's anything to do.
pub(super) fn batch(&mut self, kind: BatchKind) {
if self.marked_sessions().is_empty() {
self.set_status("No sessions marked — press Space to mark");
return;
}
self.batch = kind;
self.mode = if self.batch_ok(kind) {
Mode::BatchConfirm
} else {
match kind {
BatchKind::Delete => Mode::BatchDeleteBlocked,
BatchKind::Kill => Mode::BatchKillBlocked,
}
};
self.needs_redraw = true;
}
/// Confirm and run the pending batch action over all marked sessions.
pub(super) fn batch_execute(&mut self) {
let kind = self.batch;
let marked: Vec<Session> = self.marked_sessions().into_iter().cloned().collect();
let mut requested = 0;
let mut acted_on: Vec<String> = Vec::new();
let mut failed = 0;
for s in &marked {
let key = s.key();
// Marking spans machines because the table does; acting does not.
if s.remote.is_some() {
failed += 1;
continue;
}
match kind {
BatchKind::Delete => {
if self.tx.send(Request::Delete(Box::new(s.clone()))).is_ok() {
self.deleting.insert(key.clone());
requested += 1;
acted_on.push(key);
} else {
failed += 1;
}
}
BatchKind::Kill => match s.root_pid() {
Some(pid) => {
self.tx
.send(Request::Terminate {
session_key: key.clone(),
pid,
})
.ok();
acted_on.push(key);
}
None => failed += 1,
},
}
}
for key in &acted_on {
self.marked.remove(key);
}
self.set_status(match kind {
BatchKind::Delete => {
if failed == 0 {
format!("Deleting {requested} session(s)…")
} else {
format!("Deleting {requested} session(s), {failed} failed to start")
}
}
BatchKind::Kill => format!(
"Kill sent to {} session(s){}",
acted_on.len(),
if failed > 0 {
format!(" ({} skipped)", failed)
} else {
String::new()
}
),
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ui::tests::{session, test_app};
#[test]
fn batch_delete_keeps_marked_sessions_visible_until_worker_confirms() {
let mut app = test_app();
app.sessions = vec![
session("a", false, "/x"),
session("b", false, "/x"),
session("c", false, "/x"),
];
app.refilter();
// Mark a and c by identity, not by row: the three fixtures are created in
// the same instant, so which row each lands on depends on how the clock
// happened to tick. Selecting by index made this assert the sort order,
// and it failed on hosts where those timestamps came out equal or out of
// creation order.
let row = |app: &App, id: &str| {
app.visible
.iter()
.position(|&r| app.sessions[r.session()].session_id == id)
.expect("fixture is visible")
};
app.selected = row(&app, "a");
app.toggle_mark();
app.selected = row(&app, "c");
app.toggle_mark();
assert_eq!(app.marked.len(), 2);
assert_eq!(app.marked_sessions().len(), 2);
app.batch(BatchKind::Delete);
assert_eq!(app.mode, Mode::BatchConfirm);
app.batch_execute();
assert_eq!(app.sessions.len(), 3);
assert_eq!(app.deleting.len(), 2);
assert!(app.marked.is_empty());
}
#[test]
fn batch_delete_refuses_when_marked_session_is_running() {
let mut app = test_app();
app.sessions = vec![session("a", false, "/x"), session("b", true, "/x")];
app.refilter();
app.selected = 0;
app.toggle_mark();
app.selected = 1;
app.toggle_mark();
app.batch(BatchKind::Delete);
assert_eq!(app.mode, Mode::BatchDeleteBlocked);
}
}