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
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
//! The server's own process list (`pg_stat_activity` /
//! `information_schema.processlist`), with a way to end one. Postgres and
//! MySQL only — SQLite has no server to ask, so this tab is never offered
//! for one.
//!
//! Reuses [`DataGrid`] the same way [`crate::ui::console`] does — the result
//! is a plain `QueryResult`, so a system view is shown exactly like a table's
//! rows. Ending a connection reuses the grid's own row-picking (the
//! checkboxes, `Space`, `Cmd+A`) rather than a bespoke selection of its own:
//! pick the rows, then "End Selected".
use std::sync::Arc;
use gpui_kit::component::button::{Button, ButtonVariant, ButtonVariants};
use gpui_kit::component::dialog::DialogButtonProps;
use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, h_flex, v_flex};
use gpui_kit::prelude::*;
use gpui_kit::{App, Context, Entity, FocusHandle, Focusable, SharedString, Window, div};
use crate::db::{Connection, runtime};
use crate::ui::busy::busy_label;
use crate::ui::data_grid::{DataGrid, Scope};
pub struct ProcessListView {
connection: Arc<Connection>,
grid: Entity<DataGrid>,
loading: bool,
error: Option<String>,
notice: Option<String>,
}
impl ProcessListView {
pub fn new(connection: Arc<Connection>, window: &mut Window, cx: &mut Context<Self>) -> Self {
let grid = cx.new(|cx| {
let mut grid = DataGrid::new(connection.config.engine, window, cx);
grid.set_placeholder("No process list to show", cx);
grid
});
let mut this = Self {
connection,
grid,
loading: false,
error: None,
notice: None,
};
this.refresh(cx);
this
}
/// Point this view at another connection — a database switch reopens the
/// pool, so the server it asks moves with it.
pub fn set_connection(&mut self, connection: Arc<Connection>, cx: &mut Context<Self>) {
self.connection = connection;
self.refresh(cx);
}
pub fn refresh(&mut self, cx: &mut Context<Self>) {
self.loading = true;
self.error = None;
self.notice = None;
self.grid.update(cx, |grid, cx| grid.set_loading(true, cx));
cx.notify();
let connection = self.connection.clone();
let task = runtime::spawn(async move { connection.processes().await });
cx.spawn(async move |this, cx| {
let result = task.await;
this.update(cx, |this, cx| {
this.loading = false;
this.grid.update(cx, |grid, cx| grid.set_loading(false, cx));
match result {
Ok(Ok(result)) => {
this.grid.update(cx, |grid, cx| grid.set_result(result, cx));
}
// Rows from before the failure must not stay pickable:
// "End Selected" would act on a list that is out of date.
Ok(Err(error)) => {
this.error = Some(format!("{error:#}"));
this.grid.update(cx, |grid, cx| grid.clear(cx));
}
Err(_) => this.error = Some("loading the process list was cancelled".into()),
}
cx.notify();
})
.ok();
})
.detach();
}
/// Ask before ending the picked connections, the way a destructive table
/// action already does — there is no staging for this, so it asks every
/// time rather than following the connection's safety mode.
fn kill_selected(&mut self, window: &mut Window, cx: &mut Context<Self>) {
// One question at a time; the confirm funnels into `run_kill`, which
// carries the in-flight guard.
if window.has_active_dialog(cx) {
return;
}
let picked = self.grid.read(cx).snapshot(Scope::Picked, cx);
let ids: Vec<String> = picked
.rows
.iter()
.filter_map(|row| row.first().cloned().flatten())
.collect();
if ids.is_empty() {
return;
}
let count = ids.len();
let view = cx.entity().downgrade();
window.open_alert_dialog(cx, move |alert, _, _| {
let view = view.clone();
let ids = ids.clone();
alert
.title(if count == 1 {
"End this connection?".to_string()
} else {
format!("End {count} connections?")
})
.description("Whatever it is running stops immediately; this cannot be undone.")
.button_props(
DialogButtonProps::default()
.ok_text("End")
.ok_variant(ButtonVariant::Danger)
.cancel_text("Cancel")
.show_cancel(true),
)
.on_ok(move |_, _, cx| {
if let Some(view) = view.upgrade() {
view.update(cx, |view, cx| view.run_kill(ids.clone(), cx));
}
true
})
});
}
fn run_kill(&mut self, ids: Vec<String>, cx: &mut Context<Self>) {
// The confirm dialog answers later; a kill started in the meantime
// wins. `loading` doubles as the busy flag: it disables the toolbar
// buttons, and the trailing `refresh` clears it when the list lands.
if self.loading {
return;
}
self.loading = true;
cx.notify();
let connection = self.connection.clone();
let count = ids.len();
let task = runtime::spawn(async move {
let mut failed = 0;
for id in ids {
if connection.kill_process(&id).await.is_err() {
failed += 1;
}
}
failed
});
cx.spawn(async move |this, cx| {
let failed = task.await.unwrap_or(count);
this.update(cx, |this, cx| {
// The list is re-read to show who is left, and the outcome
// set after it: a refresh starts by clearing the notice.
this.refresh(cx);
this.notice = Some(if failed == 0 {
format!(
"Ended {count} connection{}",
if count == 1 { "" } else { "s" }
)
} else {
format!("Ended {}, {failed} failed", count - failed)
});
})
.ok();
})
.detach();
}
fn render_toolbar(&self, cx: &mut Context<Self>) -> impl IntoElement {
let read_only = self.connection.config.safety.is_read_only();
h_flex()
.id("process-list-toolbar")
.flex_none()
.px_2()
.py_1()
.gap_2()
.border_b_1()
.border_color(cx.theme().border)
.bg(cx.theme().status_bar)
.child(
div()
.flex_1()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(if self.loading {
busy_label("Loading…", cx.theme().muted_foreground).into_any_element()
} else {
self.notice.clone().unwrap_or_default().into_any_element()
}),
)
.when(!read_only, |this| {
this.child(
Button::new("kill-selected-processes")
.ghost()
.xsmall()
.disabled(self.loading)
.label("End Selected")
.on_click(
cx.listener(|this, _, window, cx| this.kill_selected(window, cx)),
),
)
})
.child(
Button::new(SharedString::from("refresh-process-list"))
.ghost()
.xsmall()
.disabled(self.loading)
.label("Refresh")
.on_click(cx.listener(|this, _, _window, cx| this.refresh(cx))),
)
}
}
impl Focusable for ProcessListView {
fn focus_handle(&self, cx: &App) -> FocusHandle {
self.grid.read(cx).focus_handle(cx)
}
}
impl Render for ProcessListView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.id("process-list-view")
.size_full()
.child(self.render_toolbar(cx))
.when_some(self.error.clone(), |this, error| {
this.child(
div()
.px_2()
.py_1()
.text_xs()
.text_color(cx.theme().danger)
.child(format!("Error: {error}")),
)
})
.child(div().flex_1().min_h_0().child(self.grid.clone()))
}
}