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