ad_editor/editor/
actions.rs

1//! Editor actions in response to user input
2use crate::{
3    buffer::BufferKind,
4    config::Config,
5    config_handle,
6    dot::{Cur, Dot, Range, TextObject},
7    editor::{Editor, MiniBufferSelection},
8    exec::{Addr, Address, Program},
9    fsys::LogEvent,
10    key::{Arrow, Input},
11    mode::Mode,
12    plumb::{MatchOutcome, PlumbingMessage},
13    replace_config,
14    system::System,
15    ui::{StateChange, UserInterface},
16    update_config,
17    util::gen_help_docs,
18};
19use ad_event::Source;
20use std::{
21    env, fs,
22    path::{Path, PathBuf},
23    process::{Command, Stdio},
24    sync::mpsc::Sender,
25};
26use tracing::{debug, error, info, trace, warn};
27
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub(crate) enum Actions {
30    Single(Action),
31    Multi(Vec<Action>),
32}
33
34/// How the current viewport should be set in relation to dot.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum ViewPort {
37    /// Dot at the bottom of the viewport
38    Bottom,
39    /// Dot in the center of the viewport
40    Center,
41    /// Dot at the top of the viewport
42    Top,
43}
44
45/// Supported actions for interacting with the editor state
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub enum Action {
48    AppendToOutputBuffer { bufid: usize, content: String },
49    ChangeDirectory { path: Option<String> },
50    CommandMode,
51    Delete,
52    DeleteBuffer { force: bool },
53    DeleteColumn { force: bool },
54    DeleteWindow { force: bool },
55    DotCollapseFirst,
56    DotCollapseLast,
57    DotExtendBackward(TextObject, usize),
58    DotExtendForward(TextObject, usize),
59    DotFlip,
60    DotSet(TextObject, usize),
61    DragWindow { direction: Arrow },
62    EditCommand { cmd: String },
63    ExecuteDot,
64    ExecuteString { s: String },
65    Exit { force: bool },
66    ExpandDot,
67    FindFile { new_window: bool },
68    FindRepoFile { new_window: bool },
69    FocusBuffer { id: usize },
70    InsertChar { c: char },
71    InsertString { s: String },
72    JumpListForward,
73    JumpListBack,
74    LoadDot { new_window: bool },
75    MarkClean { bufid: usize },
76    NewEditLogTransaction,
77    NewColumn,
78    NewWindow,
79    NextBuffer,
80    NextColumn,
81    NextWindowInColumn,
82    OpenFile { path: String },
83    OpenFileInNewWindow { path: String },
84    Paste,
85    PreviousBuffer,
86    PreviousColumn,
87    PreviousWindowInColumn,
88    RawInput { i: Input },
89    Redo,
90    ReloadActiveBuffer,
91    ReloadBuffer { id: usize },
92    ReloadConfig,
93    RunMode,
94    SamMode,
95    SaveBuffer { force: bool },
96    SaveBufferAs { path: String, force: bool },
97    SearchInCurrentBuffer,
98    SelectBuffer,
99    SetViewPort(ViewPort),
100    SetMode { m: &'static str },
101    SetStatusMessage { message: String },
102    ShellPipe { cmd: String },
103    ShellReplace { cmd: String },
104    ShellRun { cmd: String },
105    ShellSend { cmd: String },
106    ShowHelp,
107    Undo,
108    UpdateConfig { input: String },
109    ViewLogs,
110    Yank,
111
112    DebugBufferContents,
113    DebugEditLog,
114}
115
116impl<S> Editor<S>
117where
118    S: System,
119{
120    pub(crate) fn change_directory(&mut self, opt_path: Option<String>) {
121        let p = match opt_path {
122            Some(p) => p,
123            None => match env::var("HOME") {
124                Ok(p) => p,
125                Err(e) => {
126                    let msg = format!("Unable to determine home directory: {e}");
127                    self.set_status_message(&msg);
128                    warn!("{msg}");
129                    return;
130                }
131            },
132        };
133
134        let new_cwd = match fs::canonicalize(p) {
135            Ok(cwd) => cwd,
136            Err(e) => {
137                self.set_status_message(&format!("Invalid path: {e}"));
138                return;
139            }
140        };
141
142        if let Err(e) = env::set_current_dir(&new_cwd) {
143            let msg = format!("Unable to set working directory: {e}");
144            self.set_status_message(&msg);
145            error!("{msg}");
146            return;
147        };
148
149        debug!(new_cwd=%new_cwd.as_os_str().to_string_lossy(), "setting working directory");
150        self.cwd = new_cwd;
151        self.set_status_message(&self.cwd.display().to_string());
152    }
153
154    /// Open a file within the editor using a path that is relative to the current working
155    /// directory
156    pub fn open_file_relative_to_cwd(&mut self, path: &str, new_window: bool) {
157        self.open_file(self.cwd.join(path), new_window);
158    }
159
160    /// Open a file within the editor
161    pub fn open_file<P: AsRef<Path>>(&mut self, path: P, new_window: bool) {
162        let path = path.as_ref();
163        debug!(?path, "opening file");
164        let was_empty_scratch = self.layout.is_empty_scratch();
165        let current_id = self.active_buffer_id();
166
167        match self.layout.open_or_focus(path, new_window) {
168            Err(e) => self.set_status_message(&format!("Error opening file: {e}")),
169
170            Ok(Some(new_id)) => {
171                if was_empty_scratch {
172                    _ = self.tx_fsys.send(LogEvent::Close(current_id));
173                }
174                _ = self.tx_fsys.send(LogEvent::Open(new_id));
175                _ = self.tx_fsys.send(LogEvent::Focus(new_id));
176            }
177
178            Ok(None) => {
179                match self.layout.active_buffer().state_changed_on_disk() {
180                    Ok(true) => {
181                        let res = self.minibuffer_prompt("File changed on disk, reload? [y/n]: ");
182                        if let Some("y" | "Y" | "yes") = res.as_deref() {
183                            let msg = self.layout.active_buffer_mut().reload_from_disk();
184                            self.set_status_message(&msg);
185                        }
186                    }
187                    Ok(false) => (),
188                    Err(e) => self.set_status_message(&e),
189                }
190                let id = self.active_buffer_id();
191                if id != current_id {
192                    _ = self.tx_fsys.send(LogEvent::Focus(id));
193                }
194            }
195        };
196    }
197
198    fn find_file_under_dir(&mut self, d: &Path, new_window: bool) {
199        let cmd = config_handle!().find_command.clone();
200
201        let selection = match cmd.split_once(' ') {
202            Some((cmd, args)) => {
203                self.minibuffer_select_from_command_output("> ", cmd, args.split_whitespace(), d)
204            }
205            None => self.minibuffer_select_from_command_output(
206                "> ",
207                &cmd,
208                std::iter::empty::<&str>(),
209                d,
210            ),
211        };
212
213        if let MiniBufferSelection::Line { line, .. } = selection {
214            self.open_file_relative_to_cwd(&format!("{}/{}", d.display(), line.trim()), new_window);
215        }
216    }
217
218    /// This shells out to the fd command line program
219    pub(crate) fn find_file(&mut self, new_window: bool) {
220        let d = self
221            .layout
222            .active_buffer()
223            .dir()
224            .unwrap_or(&self.cwd)
225            .to_owned();
226        self.find_file_under_dir(&d, new_window);
227    }
228
229    /// This shells out to the git and fd command line programs
230    pub(crate) fn find_repo_file(&mut self, new_window: bool) {
231        let d = self
232            .layout
233            .active_buffer()
234            .dir()
235            .unwrap_or(&self.cwd)
236            .to_owned();
237        let s = match self.system.run_command_blocking(
238            "git",
239            ["rev-parse", "--show-toplevel"],
240            &d,
241            self.active_buffer_id(),
242        ) {
243            Ok(s) => s,
244            Err(e) => {
245                self.set_status_message(&format!("unable to find git root: {e}"));
246                return;
247            }
248        };
249
250        let root = Path::new(s.trim());
251        self.find_file_under_dir(root, new_window);
252    }
253
254    pub(crate) fn delete_buffer(&mut self, id: usize, force: bool) {
255        match self.layout.buffer_with_id(id) {
256            Some(b) if b.dirty && !force => self.set_status_message("No write since last change"),
257            None => warn!("attempt to close unknown buffer, id={id}"),
258            _ => {
259                _ = self.tx_fsys.send(LogEvent::Close(id));
260                self.clear_input_filter(id);
261                let was_last_buffer = self.layout.close_buffer(id);
262                self.running = !was_last_buffer;
263            }
264        }
265    }
266
267    pub(crate) fn delete_active_window(&mut self, force: bool) {
268        let is_last_window = self.layout.close_active_window();
269        if is_last_window {
270            self.exit(force);
271        }
272    }
273
274    pub(crate) fn delete_active_column(&mut self, force: bool) {
275        let is_last_column = self.layout.close_active_column();
276        if is_last_column {
277            self.exit(force);
278        }
279    }
280
281    pub(crate) fn mark_clean(&mut self, bufid: usize) {
282        if let Some(b) = self.layout.buffer_with_id_mut(bufid) {
283            b.dirty = false;
284        }
285    }
286
287    pub(super) fn save_current_buffer(&mut self, fname: Option<String>, force: bool) {
288        trace!("attempting to save current buffer");
289        let p = match self.get_buffer_save_path(fname) {
290            Some(p) => p,
291            None => return,
292        };
293
294        let msg = self.layout.active_buffer_mut().save_to_disk_at(p, force);
295        self.set_status_message(&msg);
296        let id = self.active_buffer_id();
297        _ = self.tx_fsys.send(LogEvent::Save(id));
298    }
299
300    fn get_buffer_save_path(&mut self, fname: Option<String>) -> Option<PathBuf> {
301        use BufferKind as Bk;
302
303        let desired_path = match (fname, &self.layout.active_buffer().kind) {
304            // File has a known name which is either where we loaded it from or a
305            // path that has been set and verified from the Some(s) case that follows
306            (None, Bk::File(ref p)) => return Some(p.clone()),
307            // Renaming an existing file or attempting to save a new file created in
308            // the editor: both need verifying
309            (Some(s), Bk::File(_) | Bk::Unnamed) => PathBuf::from(s),
310            // Attempting to save without a name so we prompt for one and verify it
311            (None, Bk::Unnamed) => match self.minibuffer_prompt("Save As: ") {
312                Some(s) => s.into(),
313                None => return None,
314            },
315            // virtual and minibuffer buffers don't support saving and have no save path
316            (_, Bk::Directory(_) | Bk::Virtual(_) | Bk::Output(_) | Bk::MiniBuffer) => return None,
317        };
318
319        match desired_path.try_exists() {
320            Ok(false) => (),
321            Ok(true) => {
322                if !self.minibuffer_confirm("File already exists") {
323                    return None;
324                }
325            }
326            Err(e) => {
327                self.set_status_message(&format!("Unable to check path: {e}"));
328                return None;
329            }
330        }
331
332        self.layout.active_buffer_mut().kind = BufferKind::File(desired_path.clone());
333
334        Some(desired_path)
335    }
336
337    pub(super) fn reload_buffer(&mut self, id: usize) {
338        let msg = match self.layout.buffer_with_id_mut(id) {
339            Some(b) => b.reload_from_disk(),
340            // Silently ignoring attempts to reload unknown buffers
341            None => return,
342        };
343
344        self.set_status_message(&msg);
345    }
346
347    pub(super) fn reload_config(&mut self) {
348        info!("reloading config");
349        let msg = match Config::try_load() {
350            Ok(config) => {
351                replace_config(config);
352                "config reloaded".to_string()
353            }
354            Err(s) => s,
355        };
356        info!("{msg}");
357
358        self.set_status_message(&msg);
359        self.ui.state_change(StateChange::ConfigUpdated);
360    }
361
362    pub(super) fn reload_active_buffer(&mut self) {
363        let msg = self.layout.active_buffer_mut().reload_from_disk();
364        self.set_status_message(&msg);
365    }
366
367    pub(super) fn update_config(&mut self, input: &str) {
368        info!(%input, "updating config");
369        if let Err(msg) = update_config(input) {
370            self.set_status_message(&msg);
371        }
372        self.ui.state_change(StateChange::ConfigUpdated);
373    }
374
375    pub(super) fn set_mode(&mut self, name: &str) {
376        if let Some((i, _)) = self.modes.iter().enumerate().find(|(_, m)| m.name == name) {
377            self.modes.swap(0, i);
378            self.ui.set_cursor_shape(self.current_cursor_shape());
379        }
380    }
381
382    pub(super) fn exit(&mut self, force: bool) {
383        let dirty_buffers = self.layout.dirty_buffers();
384        if !dirty_buffers.is_empty() && !force {
385            self.set_status_message("No write since last change. Use ':q!' to force exit");
386            self.minibuffer_select_from("No write since last change> ", dirty_buffers);
387            return;
388        }
389
390        self.running = false;
391    }
392
393    pub(super) fn set_clipboard(&mut self, s: String) {
394        trace!("setting clipboard content");
395        match self.system.set_clipboard(&s) {
396            Ok(_) => self.set_status_message("Yanked selection to system clipboard"),
397            Err(e) => self.set_status_message(&format!("Error setting system clipboard: {e}")),
398        }
399    }
400
401    pub(super) fn paste_from_clipboard(&mut self, source: Source) {
402        trace!("pasting from clipboard");
403        match self.system.read_clipboard() {
404            Ok(s) => self.handle_action(Action::InsertString { s }, source),
405            Err(e) => self.set_status_message(&format!("Error reading system clipboard: {e}")),
406        }
407    }
408
409    pub(super) fn search_in_current_buffer(&mut self) {
410        let numbered_lines = self
411            .layout
412            .active_buffer()
413            .string_lines()
414            .into_iter()
415            .enumerate()
416            .map(|(i, line)| format!("{:>4} | {}", i + 1, line))
417            .collect();
418
419        let selection = self.minibuffer_select_from("> ", numbered_lines);
420        if let MiniBufferSelection::Line { cy, .. } = selection {
421            self.layout.active_buffer_mut().dot = Dot::Cur {
422                c: Cur::from_yx(cy, 0, self.layout.active_buffer()),
423            };
424            self.handle_action(Action::DotSet(TextObject::Line, 1), Source::Fsys);
425            self.handle_action(Action::SetViewPort(ViewPort::Center), Source::Fsys);
426        }
427    }
428
429    pub(super) fn fsys_minibuffer(
430        &mut self,
431        prompt: Option<String>,
432        lines: String,
433        tx: Sender<String>,
434    ) {
435        let lines: Vec<String> = lines.split('\n').map(|s| s.to_string()).collect();
436        let prompt: &str = prompt.as_deref().unwrap_or("> ");
437
438        let selection = self.minibuffer_select_from(prompt, lines);
439        let s = match selection {
440            MiniBufferSelection::Line { line, .. } => line,
441            MiniBufferSelection::UserInput { input } => input,
442            MiniBufferSelection::Cancelled => String::new(),
443        };
444
445        _ = tx.send(s);
446    }
447
448    pub(super) fn select_buffer(&mut self) {
449        let selection = self.minibuffer_select_from("> ", self.layout.as_buffer_list());
450        if let MiniBufferSelection::Line { line, .. } = selection {
451            // unwrap is fine here because we know the format of the buf list we are supplying
452            if let Ok(id) = line.split_once(' ').unwrap().0.parse::<usize>() {
453                self.focus_buffer(id);
454            }
455        }
456    }
457
458    pub(super) fn focus_buffer(&mut self, id: usize) {
459        self.layout.focus_id(id);
460        _ = self.tx_fsys.send(LogEvent::Focus(id));
461    }
462
463    pub(super) fn debug_buffer_contents(&mut self) {
464        self.minibuffer_select_from(
465            "<RAW BUFFER> ",
466            self.layout
467                .active_buffer()
468                .string_lines()
469                .into_iter()
470                .map(|l| format!("{:?}", l))
471                .collect(),
472        );
473    }
474
475    pub(super) fn view_logs(&mut self) {
476        self.layout
477            .open_virtual("+logs", self.log_buffer.content(), false)
478    }
479
480    pub(super) fn show_help(&mut self) {
481        self.layout.open_virtual("+help", gen_help_docs(), false)
482    }
483
484    pub(super) fn debug_edit_log(&mut self) {
485        self.minibuffer_select_from("<EDIT LOG> ", self.layout.active_buffer().debug_edit_log());
486    }
487
488    pub(super) fn expand_current_dot(&mut self) {
489        self.layout.active_buffer_mut().expand_cur_dot();
490    }
491
492    /// Default semantics for attempting to load the current dot:
493    ///   - an event filter is in place -> pass to the event filter
494    ///   - a plumbing rule matches the load -> run the plumbing rule
495    ///   - a relative path from the directory of the containing file -> open in ad
496    ///   - an absolute path -> open in ad
497    ///     - if either have a valid addr following a colon then set dot to that addr
498    ///   - search within the current buffer for the next occurance of dot and select it
499    ///
500    /// Loading and executing of dot is part of what makes ad an unsual editor. The semantics are
501    /// lifted almost directly from acme on plan9 and the curious user is encouraged to read the
502    /// materials available at http://acme.cat-v.org/ to learn more about what is possible with
503    /// such a system.
504    pub(super) fn default_load_dot(&mut self, source: Source, load_in_new_window: bool) {
505        let b = self.layout.active_buffer_mut();
506        b.expand_cur_dot();
507        if b.notify_load(source) {
508            return; // input filter in place
509        }
510
511        let s = b.dot.content(b);
512        if s.is_empty() {
513            return;
514        }
515
516        let id = b.id;
517        self.load_string_in_buffer(id, s, load_in_new_window);
518    }
519
520    pub(super) fn load_string_in_buffer(&mut self, id: usize, s: String, load_in_new_window: bool) {
521        let b = match self.layout.buffer_with_id_mut(id) {
522            Some(b) => b,
523            None => return,
524        };
525
526        let wdir = b
527            .dir()
528            .map(|p| p.display().to_string())
529            .or_else(|| Some(self.cwd.display().to_string()));
530
531        let m = PlumbingMessage {
532            src: Some("ad".to_string()),
533            dst: None,
534            wdir,
535            attrs: Default::default(),
536            data: s.clone(),
537        };
538
539        match self.plumbing_rules.plumb(m) {
540            Some(MatchOutcome::Message(m)) => self.handle_plumbing_message(m, load_in_new_window),
541
542            Some(MatchOutcome::Run(cmd)) => {
543                let mut command = Command::new("sh");
544                command
545                    .args(["-c", cmd.as_str()])
546                    .stdout(Stdio::null())
547                    .stderr(Stdio::null());
548                if let Err(e) = command.spawn() {
549                    self.set_status_message(&format!("error spawning process: {e}"));
550                };
551            }
552
553            None => self.load_explicit_string(id, s, load_in_new_window),
554        }
555    }
556
557    /// Handling of plumbing messages that are sent to ad supports several attributes
558    /// which can be set in order to configure the behaviour:
559    ///   - by default the data will be treated as a filepath and opened
560    ///   - if the attr "addr" is set it will be parsed as an Addr and applied
561    ///   - if the attr "action" is set to "showdata" then a new buffer is created to hold the data
562    ///     - if the attr "filename" is set as well then it will be used as the name for the buffer
563    ///     - otherwise the filename will be "+plumbing-message"
564    fn handle_plumbing_message(&mut self, m: PlumbingMessage, load_in_new_window: bool) {
565        let PlumbingMessage { attrs, data, .. } = m;
566        match attrs.get("action") {
567            Some(s) if s == "showdata" => {
568                let filename = attrs
569                    .get("filename")
570                    .cloned()
571                    .unwrap_or_else(|| "+plumbing-message".to_string());
572                self.layout.open_virtual(filename, data, load_in_new_window);
573            }
574
575            _ => {
576                self.open_file(data, load_in_new_window);
577                if let Some(s) = attrs.get("addr") {
578                    match Addr::parse(&mut s.chars().peekable()) {
579                        Ok(mut addr) => {
580                            let b = self.layout.active_buffer_mut();
581                            b.dot = b.map_addr(&mut addr);
582                        }
583                        Err(e) => self.set_status_message(&format!("malformed addr: {e:?}")),
584                    }
585                }
586            }
587        }
588    }
589
590    pub(super) fn load_explicit_string(
591        &mut self,
592        bufid: usize,
593        s: String,
594        load_in_new_window: bool,
595    ) {
596        if s.is_empty() {
597            return;
598        }
599
600        let b = match self.layout.buffer_with_id_mut(bufid) {
601            Some(b) => b,
602            None => return,
603        };
604
605        let (maybe_path, maybe_addr) = match s.find(':') {
606            Some(idx) => {
607                let (s, addr) = s.split_at(idx);
608                let (_, addr) = addr.split_at(1);
609                match Addr::parse(&mut addr.chars().peekable()) {
610                    Ok(expr) => (s, Some(expr)),
611                    Err(_) => (s, None),
612                }
613            }
614            None => (s.as_str(), None),
615        };
616
617        let mut path = Path::new(&maybe_path).to_path_buf();
618        let mut is_file = path.is_absolute() && path.exists();
619
620        if let (false, Some(dir)) = (is_file, b.dir()) {
621            let full_path = dir.join(&path);
622            if full_path.exists() {
623                path = full_path;
624                is_file = true;
625            }
626        }
627
628        if is_file {
629            self.open_file(path, load_in_new_window);
630            if let Some(mut addr) = maybe_addr {
631                let b = self.layout.active_buffer_mut();
632                b.dot = b.map_addr(&mut addr);
633                self.layout.clamp_scroll();
634                self.handle_action(Action::SetViewPort(ViewPort::Center), Source::Fsys);
635            }
636        } else {
637            b.find_forward(&s);
638            self.handle_action(Action::SetViewPort(ViewPort::Center), Source::Fsys);
639        }
640    }
641
642    /// Default semantics for attempting to execute the current dot:
643    ///   - an event filter is in place -> pass to the event filter
644    ///   - a valid ad command -> execute the command
645    ///   - attempt to run as a shell command with args
646    ///
647    /// Loading and executing of dot is part of what makes ad an unsual editor. The semantics are
648    /// lifted almost directly from acme on plan9 and the curious user is encouraged to read the
649    /// materials available at http://acme.cat-v.org/ to learn more about what is possible with
650    /// such a system.
651    pub(super) fn default_execute_dot(&mut self, arg: Option<(Range, String)>, source: Source) {
652        let b = self.layout.active_buffer_mut();
653        b.expand_cur_dot();
654        if b.notify_execute(source, arg.clone()) {
655            return; // input filter in place
656        }
657
658        let mut cmd = b.dot.content(b).trim().to_string();
659        if cmd.is_empty() {
660            return;
661        }
662
663        if let Some((_, arg)) = arg {
664            cmd.push(' ');
665            cmd.push_str(&arg);
666        }
667
668        match self.parse_command(&cmd) {
669            Some(actions) => self.handle_actions(actions, source),
670            None => self.run_shell_cmd(&cmd),
671        }
672    }
673
674    pub(super) fn execute_explicit_string(&mut self, bufid: usize, s: String, source: Source) {
675        let current_id = self.active_buffer_id();
676        self.layout.focus_id_silent(bufid);
677
678        match self.parse_command(s.trim()) {
679            Some(actions) => self.handle_actions(actions, source),
680            None => self.run_shell_cmd(s.trim()),
681        }
682
683        self.layout.focus_id_silent(current_id);
684    }
685
686    pub(super) fn execute_command(&mut self, cmd: &str) {
687        debug!(%cmd, "executing command");
688        if let Some(actions) = self.parse_command(cmd.trim_end()) {
689            self.handle_actions(actions, Source::Fsys);
690        }
691    }
692
693    pub(super) fn execute_edit_command(&mut self, cmd: &str) {
694        debug!(%cmd, "executing edit command");
695        let mut prog = match Program::try_parse(cmd) {
696            Ok(prog) => prog,
697            Err(error) => {
698                warn!(?error, "invalid edit command");
699                self.set_status_message(&format!("Invalid edit command: {error:?}"));
700                return;
701            }
702        };
703
704        let mut buf = Vec::new();
705        let fname = self.layout.active_buffer().full_name().to_string();
706        match prog.execute(self.layout.active_buffer_mut(), &fname, &mut buf) {
707            Ok(new_dot) => {
708                self.layout.record_jump_position();
709                self.layout.active_buffer_mut().dot = new_dot;
710            }
711
712            Err(e) => self.set_status_message(&format!("Error running edit command: {e:?}")),
713        }
714
715        if !buf.is_empty() {
716            let s = match String::from_utf8(buf) {
717                Ok(s) => s,
718                Err(e) => {
719                    error!(%e, "edit command produced invalid utf8 output");
720                    return;
721                }
722            };
723            let id = self.active_buffer_id();
724            self.layout.write_output_for_buffer(id, s, &self.cwd);
725        }
726    }
727
728    pub(super) fn command_mode(&mut self) {
729        self.modes.insert(0, Mode::ephemeral_mode("COMMAND"));
730
731        if let Some(input) = self.minibuffer_prompt(":") {
732            self.execute_command(&input);
733        }
734
735        self.modes.remove(0);
736    }
737
738    pub(super) fn run_mode(&mut self) {
739        self.modes.insert(0, Mode::ephemeral_mode("RUN"));
740
741        if let Some(input) = self.minibuffer_prompt("!") {
742            self.set_status_message(&format!("running {input:?}..."));
743            self.run_shell_cmd(&input);
744        }
745
746        self.modes.remove(0);
747    }
748
749    pub(super) fn sam_mode(&mut self) {
750        self.modes.insert(0, Mode::ephemeral_mode("EDIT"));
751
752        if let Some(input) = self.minibuffer_prompt("Edit> ") {
753            self.execute_edit_command(&input);
754        };
755
756        self.modes.remove(0);
757    }
758
759    pub(super) fn pipe_dot_through_shell_cmd(&mut self, raw_cmd_str: &str) {
760        let (s, d) = {
761            let b = self.layout.active_buffer();
762            (b.dot_contents(), b.dir().unwrap_or(&self.cwd))
763        };
764
765        let id = self.active_buffer_id();
766        let res = self
767            .system
768            .pipe_through_command("sh", ["-c", raw_cmd_str], &s, d, id);
769
770        match res {
771            Ok(s) => self.handle_action(Action::InsertString { s }, Source::Fsys),
772            Err(e) => self.set_status_message(&format!("Error running external command: {e}")),
773        }
774    }
775
776    pub(super) fn replace_dot_with_shell_cmd(&mut self, raw_cmd_str: &str) {
777        let d = self.layout.active_buffer().dir().unwrap_or(&self.cwd);
778        let id = self.active_buffer_id();
779        let res = self
780            .system
781            .run_command_blocking("sh", ["-c", raw_cmd_str], d, id);
782
783        match res {
784            Ok(s) => self.handle_action(Action::InsertString { s }, Source::Fsys),
785            Err(e) => self.set_status_message(&format!("Error running external command: {e}")),
786        }
787    }
788
789    pub(super) fn run_shell_cmd(&mut self, raw_cmd_str: &str) {
790        let d = self.layout.active_buffer().dir().unwrap_or(&self.cwd);
791        let id = self.active_buffer_id();
792        self.system
793            .run_command("sh", ["-c", raw_cmd_str], d, id, self.tx_events.clone());
794    }
795}
796
797#[cfg(test)]
798mod tests {
799    use super::*;
800    use crate::{editor::EditorMode, LogBuffer, PlumbingRules};
801    use simple_test_case::test_case;
802
803    macro_rules! assert_recv {
804        ($brx:expr, $msg:ident, $expected:expr) => {
805            match $brx.try_recv() {
806                Ok(LogEvent::$msg(id)) if id == $expected => (),
807                Ok(msg) => panic!(
808                    "expected {}({}) but got {msg:?}",
809                    stringify!($msg),
810                    $expected
811                ),
812                Err(e) => panic!(
813                    "err={e}
814recv {}({})",
815                    stringify!($msg),
816                    $expected
817                ),
818            }
819        };
820    }
821
822    #[test]
823    fn opening_a_file_sends_the_correct_fsys_messages() {
824        let mut ed = Editor::new(
825            Config::default(),
826            PlumbingRules::default(),
827            EditorMode::Headless,
828            LogBuffer::default(),
829        );
830        let brx = ed.rx_fsys.take().expect("to have fsys channels");
831
832        ed.open_file("foo", false);
833
834        // The first open should also close our scratch buffer
835        assert_recv!(brx, Close, 0);
836        assert_recv!(brx, Open, 1);
837        assert_recv!(brx, Focus, 1);
838
839        // Opening a second file should only notify for that file
840        ed.open_file("bar", false);
841        assert_recv!(brx, Open, 2);
842        assert_recv!(brx, Focus, 2);
843
844        // Opening the first file again should just notify for the current file
845        ed.open_file("foo", false);
846        assert_recv!(brx, Focus, 1);
847    }
848
849    #[test_case(&[], &[0]; "empty scratch")]
850    #[test_case(&["foo"], &[1]; "one file")]
851    #[test_case(&["foo", "bar"], &[1, 2]; "two files")]
852    #[test]
853    fn ensure_correct_fsys_state_works(files: &[&str], expected_ids: &[usize]) {
854        let mut ed = Editor::new(
855            Config::default(),
856            PlumbingRules::default(),
857            EditorMode::Headless,
858            LogBuffer::default(),
859        );
860        let brx = ed.rx_fsys.take().expect("to have fsys channels");
861
862        for file in files {
863            ed.open_file(file, false);
864        }
865
866        ed.ensure_correct_fsys_state();
867
868        if !files.is_empty() {
869            assert_recv!(brx, Close, 0);
870        }
871
872        for &expected in expected_ids {
873            assert_recv!(brx, Open, expected);
874            assert_recv!(brx, Focus, expected);
875        }
876    }
877}