kraal 0.2.0

A terminal workspace manager for SSH fleets and coding agents
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
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
//! The two-panel file manager pane: opening it, and acting on what it asks for (copy, move,
//! delete, preview, edit, shell, choosing a server for a panel).

use std::sync::Arc;

use anyhow::Result;

use super::edits::EditTarget;
use super::ui::{ConfirmAction, Mode, PickAction, PickItem, Picker, PickerKind};
use super::{ClientId, Event, FloatRect, Server};
use crate::files::{FileKey, FilesAction, FilesPane, Location, Remote, Side};
use crate::input::{Key, KeyCode};
use crate::layout::PaneId;
use crate::pane::{Content, PaneSpec};
use crate::paths;
use crate::protocol::Placement;

impl Server {
    /// `/path`, `~/x` or `server:path` as a panel location.
    pub(super) fn files_location(&self, spec: &str) -> Location {
        if let Some((head, path)) = crate::transfer::split_remote(spec)
            && let Some(s) = self.known_servers.iter().find(|k| k.server.name == head).map(|k| k.server.clone())
        {
            let mut ssh_args = self.ssh_options(&s);
            ssh_args.push(s.destination());
            let path = if path.is_empty() { "~".to_string() } else { path.to_string() };
            return Location::Remote(Remote { server: s.name.clone(), ssh_args, path });
        }
        Location::Local(paths::expand_tilde(spec))
    }

    pub(super) fn file_manager(&self, left: &str, right: &str) -> FilesPane {
        let tx = self.tx.clone();
        // Called from the listing's own thread: waiting for room in the queue costs nothing there,
        // while a wake-up dropped would leave the panel loading for good.
        let wake: Arc<dyn Fn() + Send + Sync> = Arc::new(move || {
            let _ = tx.send(Event::Wake);
        });
        FilesPane::new(self.files_location(left), self.files_location(right), wake)
    }

    /// Open a file manager as a large floating pane.
    pub fn open_files(&mut self, ws: u32, left: String, right: String) -> Result<PaneId> {
        let spec = PaneSpec { files: Some([left, right]), ..Default::default() };
        let id = self.spawn_pane_in(ws, None, spec, Placement::Floating, None)?;
        if let Some((wi, ti)) = self.workspace_of(id)
            && let Some(f) = self.workspaces[wi].tabs[ti].floating.iter_mut().find(|f| f.pane == id)
        {
            f.rect = FloatRect { x: 0.03, y: 0.04, w: 0.94, h: 0.92 };
        }
        self.relayout();
        Ok(id)
    }

    /// Default panels for a client: the focused pane's directory, and its server if it has one.
    pub(super) fn files_defaults(&self, client: ClientId) -> (String, String) {
        let focus = self.client_focus(client).and_then(|p| self.panes.get(&p));
        let local = focus
            .filter(|p| p.ssh.is_none())
            .and_then(|p| p.current_cwd())
            .unwrap_or_else(|| dirs::home_dir().unwrap_or_default());
        let right = match focus.and_then(|p| p.ssh.as_ref()) {
            Some(s) if self.known_servers.iter().any(|k| k.server.name == s.name) => format!("{}:~", s.name),
            _ => dirs::home_dir().unwrap_or_default().display().to_string(),
        };
        (local.display().to_string(), right)
    }

    /// The size this client sees the file manager at: a smaller terminal shows less of it, and
    /// clicks, pages and the wheel are counted in what it shows. As the pane is drawn: its frame
    /// clipped to the client's screen, then the content inside it.
    pub(super) fn files_view(&self, client: ClientId, pane: PaneId) -> Option<(u16, u16)> {
        let c = self.clients.get(&client)?;
        let wi = self.ws_index(c.workspace)?;
        let (_, outer, floating) =
            self.tab_rects(self.workspaces[wi].tab(), self.layout_area).into_iter().find(|(p, _, _)| *p == pane)?;
        let (_, inner) = self.placed(outer, floating, ratatui::layout::Rect::new(0, 0, c.cols, c.rows));
        let p = self.panes.get(&pane)?;
        Some((inner.width.min(p.cols), inner.height.min(p.rows)))
    }

    /// A key typed in a file manager pane.
    pub(super) fn files_key(&mut self, client: ClientId, pane: PaneId, k: &Key) -> Mode {
        let fk = match k.code {
            _ if k.mods.ctrl || k.mods.alt => return Mode::Normal,
            KeyCode::Up => FileKey::Up,
            KeyCode::Down => FileKey::Down,
            KeyCode::PageUp => FileKey::PageUp,
            KeyCode::PageDown => FileKey::PageDown,
            KeyCode::Home => FileKey::Home,
            KeyCode::End => FileKey::End,
            KeyCode::Enter => FileKey::Enter,
            KeyCode::Backspace => FileKey::Backspace,
            KeyCode::Tab | KeyCode::BackTab => FileKey::Tab,
            KeyCode::Esc => FileKey::Esc,
            KeyCode::Delete => FileKey::Delete,
            KeyCode::Insert => FileKey::Insert,
            KeyCode::F(n) => FileKey::F(n),
            KeyCode::Char(c) => FileKey::Char(c),
            KeyCode::Left | KeyCode::Right => FileKey::Tab,
        };
        let view = self.files_view(client, pane);
        let Some(Content::Files(f)) = self.panes.get_mut(&pane).map(|p| &mut p.content) else { return Mode::Normal };
        if let Some((w, h)) = view {
            f.set_view(client, w, h);
        }
        let action = f.key(fk);
        self.mark_dirty();
        self.files_action(client, pane, action)
    }

    /// Mouse click in a file manager pane, at pane-relative cell (col, row).
    pub(super) fn files_click(&mut self, client: ClientId, pane: PaneId, col: u16, row: u16, double: bool) -> Mode {
        let view = self.files_view(client, pane);
        let Some(Content::Files(f)) = self.panes.get_mut(&pane).map(|p| &mut p.content) else { return Mode::Normal };
        if let Some((w, h)) = view {
            f.set_view(client, w, h);
        }
        let action = f.click(col, row, double);
        self.mark_dirty();
        self.files_action(client, pane, action)
    }

    pub(super) fn files_action(&mut self, client: ClientId, pane: PaneId, action: FilesAction) -> Mode {
        // The places a job acts on, as the CLI reads them; a local path that is not UTF-8 cannot be
        // passed exactly, and a server whose settings changed since its panel was listed would be
        // another machine than the one shown.
        let places: Vec<&Location> = match &action {
            FilesAction::Copy { sources, dest, .. } | FilesAction::Move { sources, dest, .. } => {
                sources.iter().chain([dest]).collect()
            }
            FilesAction::Delete(targets) => targets.iter().collect(),
            FilesAction::Shell(loc) | FilesAction::Edit(loc) => vec![loc],
            _ => Vec::new(),
        };
        if places.iter().any(|l| l.exact_display().is_none()) {
            self.toast(client, "a path here is not valid UTF-8: kraal cannot point at it safely; use a shell");
            return Mode::Normal;
        }
        if let Some(server) = self.stale_server(&places) {
            self.reconnect_panels(pane);
            self.toast(
                client,
                format!("{server} is not reached as when its panel was listed: check the panel and retry"),
            );
            return Mode::Normal;
        }
        let expect: Vec<String> = places
            .iter()
            .filter_map(|l| match l {
                Location::Remote(r) => Some(format!("--expect={}={}", r.server, crate::shell::join(&r.ssh_args))),
                Location::Local(_) => None,
            })
            .collect();
        let dir_arg = |l: &Location| {
            let shown = l.display();
            if shown.ends_with('/') { shown } else { format!("{shown}/") }
        };
        match action {
            FilesAction::None => Mode::Normal,
            FilesAction::Copy { sources, dest, replaces } => {
                // What the panel shows can be out of date: the copy itself checks again, and
                // replaces only what the user agreed to.
                let mut args = vec!["cp".to_string(), "--no-replace".to_string()];
                for name in &replaces {
                    args.extend(["--allow".to_string(), name.clone()]);
                }
                args.extend(expect);
                args.push("--".into());
                args.extend(sources.iter().map(|s| s.display()));
                args.push(dir_arg(&dest));
                if replaces.is_empty() {
                    self.start_job(client, "copy", args);
                    return Mode::Normal;
                }
                // Copying over files is a decision, not a side effect.
                // Every name, one per line: the question scrolls.
                let message = format!(
                    "Copy {} to {}?\nIt replaces what is there:\n  {}",
                    describe(&sources),
                    dest.display(),
                    replaces.join("\n  ")
                );
                Mode::confirm(message, ConfirmAction::Job { label: "copy and replace".into(), args })
            }
            FilesAction::Move { sources, dest, replaces } => {
                let mut args = vec!["cp".to_string(), "--move".to_string(), "--no-replace".to_string()];
                for name in &replaces {
                    args.extend(["--allow".to_string(), name.clone()]);
                }
                args.extend(expect);
                args.push("--".into());
                args.extend(sources.iter().map(|s| s.display()));
                args.push(dir_arg(&dest));
                let mut message = format!("Move {} to {}?", describe(&sources), dest.display());
                if !replaces.is_empty() {
                    message.push_str(&format!("\nIt replaces what is there:\n  {}", replaces.join("\n  ")));
                }
                Mode::confirm(message, ConfirmAction::Job { label: "move".into(), args })
            }
            FilesAction::Delete(targets) => {
                let mut args = vec!["rm".to_string()];
                args.extend(expect);
                args.push("--".into());
                args.extend(targets.iter().map(|s| s.display()));
                let list: Vec<String> = targets.iter().map(|t| t.display()).collect();
                let what = if list.len() == 1 { list[0].clone() } else { format!("these {} items", list.len()) };
                let mut message = format!("Delete {what} for good?");
                if list.len() > 1 {
                    message.push_str(&format!("\n  {}", list.join("\n  ")));
                }
                Mode::confirm(message, ConfirmAction::Job { label: "delete".into(), args })
            }
            FilesAction::Preview(path) => {
                let Some(ws) = self.clients.get(&client).map(|c| c.workspace) else { return Mode::Normal };
                let spec = PaneSpec { preview: Some(path), ..Default::default() };
                if let Err(e) = self.spawn_pane_in(ws, Some(pane), spec, Placement::Floating, None) {
                    self.toast(client, format!("{e:#}"));
                }
                Mode::Normal
            }
            FilesAction::Shell(loc) => {
                let Some(ws) = self.clients.get(&client).map(|c| c.workspace) else { return Mode::Normal };
                let result = match &loc {
                    Location::Local(p) => {
                        let spec = PaneSpec { cwd: Some(p.clone()), ..Default::default() };
                        self.spawn_pane_in(ws, None, spec, Placement::Tab, None)
                    }
                    Location::Remote(r) => match self.resolve_server(&r.server) {
                        Some(s) => {
                            let mut command = self.ssh_command(&s);
                            command.insert(1, "-t".into());
                            let cd = if r.path == "~" || r.path.is_empty() {
                                "cd".to_string()
                            } else {
                                format!("cd {}", crate::shell::quote(&r.path))
                            };
                            // A POSIX script, whatever the login shell there (fish, csh…).
                            let script = format!("{cd} && exec \"${{SHELL:-sh}}\" -l");
                            command.push(format!("sh -c {}", crate::shell::quote_always(&script)));
                            let spec = PaneSpec { command, server: Some(s.name.clone()), ..Default::default() };
                            self.spawn_pane_in(ws, None, spec, Placement::Tab, Some(s.name.clone()))
                        }
                        None => Err(anyhow::anyhow!("unknown server {}", r.server)),
                    },
                };
                if let Err(e) = result {
                    self.toast(client, format!("{e:#}"));
                }
                Mode::Normal
            }
            FilesAction::Edit(Location::Local(path)) => {
                let Some(ws) = self.clients.get(&client).map(|c| c.workspace) else { return Mode::Normal };
                let command = super::edits::editor_command(&path, None);
                let name = path.file_name().map(|n| n.to_string_lossy().into_owned());
                let spec = PaneSpec { command, close_on_exit: true, keep_on_failure: true, ..Default::default() };
                if let Err(e) = self.spawn_pane_in(ws, Some(pane), spec, Placement::Floating, name) {
                    self.toast(client, format!("{e:#}"));
                }
                Mode::Normal
            }
            FilesAction::Edit(Location::Remote(r)) => {
                let Some(ws) = self.clients.get(&client).map(|c| c.workspace) else { return Mode::Normal };
                let production = self
                    .known_servers
                    .iter()
                    .find(|k| k.server.name == r.server)
                    .and_then(|k| self.server_production(&k.server));
                let target = EditTarget {
                    server: r.server,
                    ssh: r.ssh_args,
                    path: r.path,
                    line: None,
                    ws: Some(ws),
                    near: Some(pane),
                };
                self.edit_request(client, target, production)
            }
            FilesAction::PickLocation(side) => Mode::Picker(self.location_picker(pane, side)),
            FilesAction::Refused(why) => {
                self.toast(client, why);
                Mode::Normal
            }
            FilesAction::Close => {
                self.close_pane(pane);
                Mode::Normal
            }
        }
    }

    fn location_picker(&self, pane: PaneId, side: Side) -> Picker {
        let mut items = vec![PickItem {
            label: "local".into(),
            detail: format!("this machine · {}", paths::shorten_home(&dirs::home_dir().unwrap_or_default())),
            action: PickAction::FilesSet { pane, side, target: "~".into() },
            state: None,
            dim: false,
        }];
        for k in &self.known_servers {
            items.push(PickItem {
                label: k.server.name.clone(),
                detail: format!("{} · {}", k.server.address(), k.server.groups.join(", ")),
                action: PickAction::FilesSet { pane, side, target: format!("{}:~", k.server.name) },
                state: None,
                dim: false,
            });
        }
        let which = if side == Side::Left { "left" } else { "right" };
        Picker::new(PickerKind::Pane, &format!("Show in the {which} panel"), items)
    }

    /// The ssh arguments that reach `server` now, destination last (what a panel lists with).
    fn panel_ssh_args(&self, server: &str) -> Option<Vec<String>> {
        let s = self.known_servers.iter().find(|k| k.server.name == server)?.server.clone();
        let mut args = self.ssh_options(&s);
        args.push(s.destination());
        Some(args)
    }

    /// A server among `places` that is not reached as its panel listed it any more.
    fn stale_server(&self, places: &[&Location]) -> Option<String> {
        places.iter().find_map(|l| match l {
            Location::Remote(r) if self.panel_ssh_args(&r.server).as_ref() != Some(&r.ssh_args) => {
                Some(r.server.clone())
            }
            _ => None,
        })
    }

    /// List the remote panels of a file manager again with their servers' current settings.
    fn reconnect_panels(&mut self, pane: PaneId) {
        for side in [Side::Left, Side::Right] {
            let Some(Content::Files(f)) = self.panes.get(&pane).map(|p| &p.content) else { return };
            let Location::Remote(r) = f.location(side) else { continue };
            // A server gone from the inventory stays as it was: its listing fails, and so would a job.
            if self.panel_ssh_args(&r.server).is_none_or(|now| now == r.ssh_args) {
                continue;
            }
            let loc = self.files_location(&format!("{}:{}", r.server, r.path));
            if let Some(Content::Files(f)) = self.panes.get_mut(&pane).map(|p| &mut p.content) {
                f.set_location(side, loc);
            }
        }
        self.mark_dirty();
    }

    pub(super) fn files_set(&mut self, pane: PaneId, side: Side, target: &str) {
        let loc = self.files_location(target);
        if let Some(Content::Files(f)) = self.panes.get_mut(&pane).map(|p| &mut p.content) {
            f.set_location(side, loc);
        }
        self.mark_persist();
        self.mark_dirty();
    }

    /// Reload every file manager (after a transfer finished).
    pub(super) fn refresh_file_managers(&mut self) {
        for p in self.panes.values_mut() {
            if let Content::Files(f) = &mut p.content {
                f.refresh();
            }
        }
    }

    /// Run `kraal <args>` in a floating pane that closes itself a few seconds after success.
    pub(super) fn start_job(&mut self, client: ClientId, label: &str, args: Vec<String>) {
        let Some(ws) = self.clients.get(&client).map(|c| c.workspace) else { return };
        let exe = std::env::current_exe().map(|p| p.display().to_string()).unwrap_or_else(|_| "kraal".into());
        let mut command = vec![exe];
        command.extend(args);
        let spec = PaneSpec { command, close_on_success: true, ..Default::default() };
        let previous = self.client_focus(client);
        match self.spawn_pane_in(ws, None, spec, Placement::Floating, Some(format!("⇅ {label}"))) {
            Ok(id) => {
                // A small window in the bottom-right corner, on top, that does not take the focus.
                if let Some((wi, ti)) = self.workspace_of(id) {
                    let tab = &mut self.workspaces[wi].tabs[ti];
                    if let Some(f) = tab.floating.iter_mut().find(|f| f.pane == id) {
                        f.rect = FloatRect { x: 0.5, y: 0.58, w: 0.48, h: 0.4 };
                    }
                    if let Some(prev) = previous.filter(|p| tab.contains(*p)) {
                        tab.focus = Some(prev);
                        tab.recent.retain(|p| *p != prev);
                        tab.recent.insert(0, prev);
                    }
                }
                self.relayout();
            }
            Err(e) => self.toast(client, format!("{e:#}")),
        }
    }
}

/// What a file operation acts on: the place, or the items by name.
fn describe(locs: &[Location]) -> String {
    match locs {
        [one] => one.display(),
        many => {
            let shown: Vec<String> = many.iter().filter_map(Location::name).collect();
            format!("{} items ({})", many.len(), names(&shown))
        }
    }
}

/// Up to five names, then how many more.
fn names(list: &[String]) -> String {
    let mut out = list.iter().take(5).cloned().collect::<Vec<_>>().join(", ");
    if list.len() > 5 {
        out.push_str(&format!(" and {} more", list.len() - 5));
    }
    out
}