Skip to main content

trail/actions/
mod.rs

1//! Action system: the `Action` enum and `apply(action, state)`.
2//!
3//! Every user-initiated mutation flows through an `Action` value, keeping
4//! the state machine testable independently of input handling.
5
6pub mod clipboard;
7pub mod fs_ops;
8pub mod run_report;
9pub mod shell_exec;
10pub mod transfer;
11
12use std::path::{Path, PathBuf};
13
14use crate::app::mode::Mode;
15use crate::app::state::{AppState, StateError};
16use crate::input::command_parser::ParsedCommand;
17use transfer::{TransferItem, TransferKind};
18
19/// Every user-initiated state change is represented as one of these variants.
20///
21/// Phase 1 implements the navigation actions; Phase 2 the search actions;
22/// Phase 3 adds filesystem mutations, clipboard, and command execution.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum Action {
25    // ── Navigation ──────────────────────────────────────────────────────────
26    /// Move the selection cursor down one row.
27    MoveDown,
28    /// Move the selection cursor up one row.
29    MoveUp,
30    /// Jump the selection to the first entry.
31    JumpTop,
32    /// Jump the selection to the last entry.
33    JumpBottom,
34    /// Enter the selected directory, or open the selected file in the configured
35    /// editor. For directories this is handled synchronously in `apply`; for
36    /// files it returns [`Action::RunExternal`] via the `apply_enter_or_open`
37    /// helper so the event loop can call `shell_exec::run_external` on the
38    /// async path.
39    EnterOrOpen,
40    /// Navigate to the parent directory.
41    GoParent,
42    /// Navigate back in the directory history (`u`).
43    HistoryBack,
44    /// Navigate forward in the directory history (`Ctrl-r`).
45    HistoryForward,
46    /// Return to where the newest jump (`:jump`) was made from (`U`),
47    /// skipping the directories visited since.
48    ReturnFromJump,
49    /// Reload the current directory listing.
50    Refresh,
51    /// Toggle visibility of hidden files.
52    ToggleHidden,
53
54    // ── Listing order ───────────────────────────────────────────────────────
55    /// Re-order the active tab's listing by the given key.
56    SetSortBy(crate::app::sort::SortBy),
57    /// Flip the active tab's listing order.
58    ToggleSortReverse,
59    /// Toggle whether directories are grouped ahead of files in the active tab.
60    ToggleDirsFirst,
61    /// Step the details column through `none → size → modified → both → none`.
62    CycleEntryDetails,
63
64    // ── Preview scrolling ───────────────────────────────────────────────────
65    /// Scroll the preview pane down one line, leaving the selection alone.
66    PreviewScrollDown,
67    /// Scroll the preview pane up one line, leaving the selection alone.
68    PreviewScrollUp,
69    /// Scroll the preview pane down one screenful (less a two-line overlap).
70    PreviewPageDown,
71    /// Scroll the preview pane up one screenful (less a two-line overlap).
72    PreviewPageUp,
73    /// Scroll the preview pane back to its first line.
74    PreviewScrollTop,
75    /// Scroll the preview pane to the last loaded line, which is the end of the
76    /// file only when the preview is not truncated.
77    PreviewScrollBottom,
78
79    // ── Mode transitions ──────────────────────────────────────────────────
80    /// Enter Search Mode (Phase 2 wires the actual filter logic).
81    EnterSearch,
82    /// Enter Command Mode (Phase 3 wires the actual command parser).
83    EnterCommand,
84    /// Exit the current mode, returning to Navigation.
85    ExitMode,
86
87    // ── Search Mode ───────────────────────────────────────────────────────
88    /// Append `char` to the Search Mode query and re-run the fuzzy filter.
89    SearchAppendChar(char),
90    /// Delete the last character from the Search Mode query and re-run the
91    /// fuzzy filter. No-op if the query is already empty.
92    SearchDeleteChar,
93    /// Move the filtered-list selection down by one row.
94    SearchMoveDown,
95    /// Move the filtered-list selection up by one row.
96    SearchMoveUp,
97    /// Confirm the current filtered selection: enter a directory or leave
98    /// Search Mode if the selected entry is a file (file open is Phase 6).
99    SearchConfirm,
100
101    // ── Command Mode ──────────────────────────────────────────────────────
102    /// Feed a single key event into the Command Mode buffer.
103    CommandKey(crossterm::event::KeyEvent),
104    /// Do the last file operation again (`@`): the newest `:`/`!` command, or
105    /// `dd`, `pp` or `pm`, whichever came last — see [`Repeatable`]. Like
106    /// Vim's `.`, it acts on whatever is selected — or marked — now.
107    RepeatLastCommand,
108
109    // ── Filesystem mutations (Phase 3) ────────────────────────────────────
110    /// Execute a validated, parsed command (dispatched after Command Mode submit).
111    ExecuteCommand(ParsedCommand),
112    /// Copy the absolute path of the selected entry to the yank buffer.
113    CopyAbsPath,
114    /// Copy the relative path of the selected entry to the yank buffer.
115    CopyRelPath,
116    /// Copy the filename of the selected entry to the yank buffer.
117    CopyFilename,
118    /// Copy the content of the selected entry to the yank buffer: the text of
119    /// a file, or the listing of a directory.
120    CopyContent,
121    /// Begin the `dd` delete flow — sets `pending_delete = true`.
122    BeginDelete,
123    /// Confirm and execute the pending delete.
124    ConfirmDelete,
125    /// Cancel the pending delete confirmation.
126    CancelDelete,
127    /// Mark or unmark the selected entry (`Space`). The selection stays put;
128    /// a run of entries is marked with visual mode (`v`).
129    ToggleMark,
130    /// Unmark everything (`Esc` in Navigation Mode, `:unmark`).
131    ClearMarks,
132    /// Copy (`pp`) or move (`pm`) every marked entry into `cwd`.
133    PasteMarks(TransferKind),
134    /// Enter visual mode (`v`), anchoring a range at the selection.
135    EnterVisual,
136    /// Add the visual range to the marks and return to Navigation Mode, then
137    /// apply the follow-up action if there is one — so `h`/`l` in visual mode
138    /// mark the range and navigate in one key.
139    CommitVisual(Option<Box<Action>>),
140    /// Set `state.pending_nav_key` to begin a multi-key Navigation Mode
141    /// sequence (`y` for clipboard, `d` for delete). The following key
142    /// resolves the sequence in `keymap::navigation`.
143    SetPendingNavKey(char),
144
145    // ── Shell integration (Phase 6) ───────────────────────────────────────
146    /// Run an external process using the terminal suspend/resume sequence.
147    ///
148    /// Produced by `apply` for file-open and `!<shell command>` actions;
149    /// handled by the event loop in `main.rs` which calls
150    /// `shell_exec::run_external` and forces a full redraw on return.
151    RunExternal {
152        /// The argument vector: `argv[0]` is the program, `argv[1..]` are args.
153        argv: Vec<String>,
154        /// Working directory for the subprocess. Typically `state.cwd`.
155        cwd: std::path::PathBuf,
156        /// Whether to hold the screen after the command finishes so its output
157        /// can be read. Resolved from `[general] shell_pause` for `!` and
158        /// `:git`, and always [`shell_exec::ShellPause::Never`] for the editor
159        /// and OS-handler opens, which draw their own screens.
160        pause: shell_exec::ShellPause,
161        /// The command as typed, `!cargo test` or `:git status`, for the result
162        /// block and the status bar. `None` for the editor and OS-handler opens,
163        /// which report only when they fail.
164        label: Option<String>,
165    },
166
167    // ── Quit ─────────────────────────────────────────────────────────────
168    /// Quit the application normally (writes `--cwd-file` in Phase 6).
169    Quit,
170    /// Cancel the application without writing `--cwd-file`.
171    ///
172    /// Mapped to `Ctrl-C`. The shell wrapper does nothing and the parent
173    /// shell stays in its original directory.
174    Cancel,
175
176    // ── OS open ─────────────────────────────────────────────────────────
177    /// Open the selected entry with the OS default handler.
178    ///
179    /// On macOS uses `open`, on Linux `xdg-open`, on Windows `explorer`.
180    /// Bound to `o` in Navigation Mode.
181    OpenWithOs,
182
183    // ── Tab management (Phase 8) ──────────────────────────────────────
184    /// Open a new tab rooted at the current working directory.
185    NewTab,
186    /// Close the currently active tab. No-op if only one tab is open.
187    CloseTab,
188    /// Switch focus to the next tab, wrapping from the last to the first.
189    SwitchTabNext,
190    /// Switch focus to the previous tab, wrapping from the first to the last.
191    SwitchTabPrev,
192}
193
194/// What `@` repeats: the last file operation, typed or pressed.
195///
196/// Only operations are recorded — commands and the keys that change files
197/// (`dd`, `pp`, `pm`). Moving, marking and yanking are not, so `j` between a
198/// `dd` and an `@` does not replace the `dd`; it is what makes `dd`, `j`, `@`
199/// work the way Vim's `.` does.
200#[derive(Debug, Clone, PartialEq, Eq)]
201pub enum Repeatable {
202    /// A `:` command without its `:`, or a `!` command with its `!`.
203    CommandLine(String),
204    /// A key's action, replayed as if the key were pressed again. A repeated
205    /// `dd` therefore still asks for confirmation.
206    Key(Action),
207}
208
209/// Reports the last run of an external command in the status bar: a failure in
210/// the error colour, a success as an ordinary notice.
211///
212/// Whatever `[general] shell_pause` says: the result block is out of sight as
213/// soon as the alternate screen returns, and under `never` this is the only
214/// report there is. The editor and the OS file handler report only a failure.
215pub fn report_run(state: &mut AppState, report: &run_report::RunReport) {
216    if let Some(line) = report.status_line() {
217        if report.succeeded() {
218            state.notify(line);
219        } else {
220            state.set_error(line);
221        }
222    }
223}
224
225/// Applies `action` to `state`, returning an error if a filesystem operation
226/// fails.
227///
228/// This is the single entry point for all state mutations from the UI thread.
229/// Callers should call this rather than mutating `AppState` directly, so that
230/// tests can drive state through `Action` values without a running terminal.
231///
232/// # Errors
233///
234/// Returns [`StateError`] if a navigation or directory-loading action fails.
235pub fn apply(action: Action, state: &mut AppState) -> Result<(), StateError> {
236    match action {
237        Action::MoveDown => state.move_down(),
238        Action::MoveUp => state.move_up(),
239        Action::JumpTop => state.jump_top(),
240        Action::JumpBottom => state.jump_bottom(),
241
242        Action::PreviewScrollDown => state.scroll_preview_lines(1),
243        Action::PreviewScrollUp => state.scroll_preview_lines(-1),
244        Action::PreviewPageDown => state.scroll_preview_pages(1),
245        Action::PreviewPageUp => state.scroll_preview_pages(-1),
246        Action::PreviewScrollTop => state.scroll_preview_to_edge(false),
247        Action::PreviewScrollBottom => state.scroll_preview_to_edge(true),
248
249        Action::EnterOrOpen => {
250            if let Some(entry) = state.selected_entry().cloned() {
251                use crate::app::state::EntryKind;
252                match entry.kind {
253                    EntryKind::Dir => {
254                        state.enter_dir(entry.path)?;
255                    }
256                    EntryKind::File | EntryKind::Symlink => {
257                        // Phase 6: open the file in the configured editor.
258                        // We cannot call shell_exec::run_external here because
259                        // apply() is synchronous and run_external manipulates
260                        // the terminal. Instead we store the RunExternal action
261                        // in state so the event loop can execute it.
262                        let editor = state.config.general.editor.clone();
263                        state.pending_external = Some(Action::RunExternal {
264                            argv: vec![editor, entry.path.display().to_string()],
265                            cwd: state.cwd.clone(),
266                            // An editor owns the screen while it runs and leaves
267                            // nothing to read behind it.
268                            pause: shell_exec::ShellPause::Never,
269                            label: None,
270                        });
271                        state.dirty = true;
272                    }
273                }
274            }
275        }
276
277        Action::GoParent => {
278            state.go_parent()?;
279        }
280
281        // At either end of the history these used to do nothing at all, which
282        // reads as a key that is not working — the same reason a tab switch
283        // with one tab says so.
284        Action::HistoryBack => {
285            if state.history.can_go_back() {
286                state.history_back()?;
287            } else {
288                state.notify("no earlier directory — u goes back once you have moved");
289            }
290        }
291
292        Action::HistoryForward => {
293            if state.history.can_go_forward() {
294                state.history_forward()?;
295            } else {
296                state.notify("no later directory — Ctrl-r goes forward after u");
297            }
298        }
299
300        Action::ReturnFromJump => {
301            let before = state.cwd.clone();
302            match state.return_from_jump()? {
303                None => state.notify("no jump to return from — :jump makes one"),
304                Some(0) => {}
305                Some(skipped) if state.cwd == before => state.notify(format!(
306                    "no jump to return from — {skipped} led to directories that are gone"
307                )),
308                Some(skipped) => state.notify(format!(
309                    "skipped {skipped} jump(s) to directories that are gone"
310                )),
311            }
312        }
313
314        Action::Refresh => {
315            state.refresh()?;
316        }
317
318        Action::ToggleHidden => {
319            state.toggle_hidden()?;
320        }
321
322        Action::SetSortBy(by) => {
323            apply_sort(state, |s| s.by = by);
324        }
325
326        Action::ToggleSortReverse => {
327            apply_sort(state, |s| s.reverse = !s.reverse);
328        }
329
330        Action::ToggleDirsFirst => {
331            apply_sort(state, |s| s.dirs_first = !s.dirs_first);
332        }
333
334        Action::CycleEntryDetails => {
335            // Read at render time, so unlike the sort keys this needs nothing
336            // rebuilt — the next frame simply draws the new column.
337            let next = state.config.navigation.details().next();
338            state.config.navigation.entry_details = next.as_str().to_owned();
339            state.notify(format!("details: {}", next.as_str()));
340            state.dirty = true;
341        }
342
343        // Mode transitions.
344        Action::EnterSearch => {
345            state.mode = Mode::Search {
346                query: String::new(),
347                matches: Vec::new(),
348            };
349            // Entering Search Mode with an empty query shows all entries.
350            state.apply_filter(String::new());
351            state.dirty = true;
352        }
353
354        Action::EnterCommand => {
355            state.mode = Mode::Command {
356                buffer: String::new(),
357                cursor: 0,
358                history_index: None,
359            };
360            state.clear_notice();
361            state.dirty = true;
362        }
363
364        Action::ExitMode => {
365            if state.mode != Mode::Navigation {
366                state.mode = Mode::Navigation;
367                state.clear_filter();
368                state.pending_delete = false;
369                state.clear_notice();
370                state.pending_nav_key = None;
371                state.dirty = true;
372            }
373        }
374
375        // Search Mode actions.
376        Action::SearchAppendChar(ch) => {
377            let new_query = if let Mode::Search { query, .. } = &state.mode {
378                let mut q = query.clone();
379                q.push(ch);
380                q
381            } else {
382                return Ok(());
383            };
384            state.apply_filter(new_query);
385        }
386
387        Action::SearchDeleteChar => {
388            let new_query = if let Mode::Search { query, .. } = &state.mode {
389                let mut q = query.clone();
390                // Remove the last Unicode scalar (pop handles multi-byte chars).
391                q.pop();
392                q
393            } else {
394                return Ok(());
395            };
396            state.apply_filter(new_query);
397        }
398
399        Action::SearchMoveDown => {
400            state.move_down();
401        }
402
403        Action::SearchMoveUp => {
404            state.move_up();
405        }
406
407        Action::SearchConfirm => {
408            if let Some(entry) = state.selected_entry().cloned() {
409                use crate::app::state::EntryKind;
410                match entry.kind {
411                    EntryKind::Dir => {
412                        // Exit Search Mode first, then enter the directory —
413                        // with the selection still on it, so that is what the
414                        // parent remembers and `h` comes back to.
415                        state.mode = Mode::Navigation;
416                        state.clear_filter();
417                        state.enter_dir(entry.path)?;
418                    }
419                    EntryKind::File | EntryKind::Symlink => {
420                        // Phase 6: open in editor via the RunExternal mechanism.
421                        state.mode = Mode::Navigation;
422                        state.clear_filter();
423                        let editor = state.config.general.editor.clone();
424                        state.pending_external = Some(Action::RunExternal {
425                            argv: vec![editor, entry.path.display().to_string()],
426                            cwd: state.cwd.clone(),
427                            pause: shell_exec::ShellPause::Never,
428                            label: None,
429                        });
430                        state.dirty = true;
431                    }
432                }
433            }
434        }
435
436        // ── Command Mode ─────────────────────────────────────────────────────
437        Action::CommandKey(key) => {
438            use crate::input::command_parser::{feed_with_sources, CompletionSources, FeedResult};
439
440            // Extract buffer/cursor/history_index from the mode.
441            let (buffer, cursor, history_index, is_shell) = if let Mode::Command {
442                buffer,
443                cursor,
444                history_index,
445            } = &mut state.mode
446            {
447                // Determine shell mode from the buffer's leading character.
448                let is_shell = buffer.starts_with('!') || {
449                    // Check if the raw key was '!' during initial entry.
450                    // The mode's buffer is always the text after the sentinel,
451                    // so we check the stored sentinel flag via the buffer prefix.
452                    false
453                };
454                (buffer, cursor, history_index, is_shell)
455            } else {
456                return Ok(());
457            };
458
459            // We need owned copies to avoid borrow-checker issues when also
460            // needing state for history/tab.
461            let mut buf_owned = buffer.clone();
462            let mut cur_owned = *cursor;
463            let mut hist_owned = *history_index;
464            let cwd = state.cwd.clone();
465
466            let plugin_actions: Vec<String> = state
467                .plugin_engine
468                .as_ref()
469                .map(|e| e.action_names().map(|s| s.to_string()).collect())
470                .unwrap_or_default();
471            let plugin_action_refs: Vec<&str> = plugin_actions.iter().map(|s| s.as_str()).collect();
472            let bookmarks: Vec<(String, PathBuf)> = state
473                .bookmark_store
474                .as_ref()
475                .map(|store| {
476                    store
477                        .list()
478                        .map(|(name, path)| (name.to_owned(), path.to_owned()))
479                        .collect()
480                })
481                .unwrap_or_default();
482
483            let result = {
484                // Temporarily move command_history and tab_state out of state.
485                // They are put back below.
486                let hist = std::mem::take(&mut state.command_history);
487                let mut tab = std::mem::take(&mut state.tab_state);
488
489                let res = feed_with_sources(
490                    key,
491                    &mut buf_owned,
492                    &mut cur_owned,
493                    &mut hist_owned,
494                    &mut tab,
495                    &hist,
496                    &cwd,
497                    is_shell,
498                    &CompletionSources {
499                        plugin_actions: &plugin_action_refs,
500                        bookmarks: &bookmarks,
501                    },
502                );
503
504                state.command_history = hist;
505                state.tab_state = tab;
506                res
507            };
508
509            // Write the possibly-mutated buffer back into the mode.
510            if let Mode::Command {
511                buffer,
512                cursor,
513                history_index,
514            } = &mut state.mode
515            {
516                *buffer = buf_owned;
517                *cursor = cur_owned;
518                *history_index = hist_owned;
519            }
520
521            match result {
522                FeedResult::Updated | FeedResult::Completion { .. } => {
523                    state.dirty = true;
524                }
525                FeedResult::Cancel => {
526                    state.mode = Mode::Navigation;
527                    state.clear_notice();
528                    state.dirty = true;
529                }
530                FeedResult::Submit(submitted_buf) => {
531                    // Push to history regardless of validity (so the user can
532                    // edit and re-submit — but only non-empty strings).
533                    if !submitted_buf.trim().is_empty() {
534                        state.command_history.push(submitted_buf.clone());
535                    }
536
537                    // Exit Command Mode regardless.
538                    state.mode = Mode::Navigation;
539                    state.dirty = true;
540
541                    if !submitted_buf.trim().is_empty() {
542                        state.last_repeatable =
543                            Some(Repeatable::CommandLine(submitted_buf.clone()));
544                    }
545                    run_command_line(state, &submitted_buf)?;
546                }
547            }
548        }
549
550        Action::RepeatLastCommand => match state.last_repeatable.clone() {
551            Some(Repeatable::CommandLine(line)) => {
552                state.clear_notice();
553                run_command_line(state, &line)?;
554            }
555            Some(Repeatable::Key(action)) => apply(action, state)?,
556            None => state.notify("nothing to repeat yet — @ repeats a command, dd, pp or pm"),
557        },
558
559        // ── Filesystem command execution ──────────────────────────────────────
560        Action::ExecuteCommand(cmd) => {
561            execute_parsed_command(cmd, state)?;
562        }
563
564        // ── Clipboard ─────────────────────────────────────────────────────────
565        Action::CopyAbsPath => {
566            if let Some(entry) = state.selected_entry().cloned() {
567                record_yank(state, clipboard::absolute_path_text(&entry.path));
568            }
569        }
570
571        Action::CopyRelPath => {
572            if let Some(entry) = state.selected_entry().cloned() {
573                // Relative to where Trail was launched, not to the directory
574                // being browsed — see `AppState::launch_dir`. Against `cwd`
575                // this yanked the bare file name, duplicating `yn`.
576                let base = state.launch_dir.clone();
577                record_yank(state, clipboard::relative_path_text(&entry.path, &base));
578            }
579        }
580
581        Action::CopyFilename => {
582            if let Some(entry) = state.selected_entry().cloned() {
583                record_yank(state, clipboard::filename_text(&entry.path));
584            }
585        }
586
587        Action::CopyContent => {
588            if let Some(entry) = state.selected_entry().cloned() {
589                let show_hidden = state.show_hidden;
590                record_yank(state, clipboard::content_text(&entry.path, show_hidden));
591            }
592        }
593
594        // ── Delete with confirmation ───────────────────────────────────────────
595        Action::BeginDelete => {
596            let has_target = state.selected_entry().is_some()
597                || !state.marks.is_empty()
598                || matches!(state.mode, Mode::Visual { .. });
599            if has_target {
600                state.last_repeatable = Some(Repeatable::Key(Action::BeginDelete));
601                state.pending_delete = true;
602                state.clear_notice();
603                state.dirty = true;
604            }
605        }
606
607        Action::ConfirmDelete => {
608            if !state.pending_delete {
609                return Ok(());
610            }
611            state.pending_delete = false;
612            // What `dd` deletes, most specific first: a visual range is what the
613            // user is looking at right now, then the marks, then the selection.
614            if matches!(state.mode, Mode::Visual { .. }) {
615                let range = crate::app::marks::visual_paths(state);
616                state.mode = Mode::Navigation;
617                delete_paths(state, range)?;
618            } else if !state.marks.is_empty() {
619                let marked = state.marks.paths().cloned().collect();
620                delete_paths(state, marked)?;
621            } else if let Some(entry) = state.selected_entry().cloned() {
622                let mode = configured_delete_mode(state);
623                match fs_ops::delete(&entry.path, mode) {
624                    Ok(()) => {
625                        // Say where it went: with a recycle bin in play, "gone"
626                        // and "recoverable" are different outcomes and the user
627                        // is entitled to know which one they got.
628                        state.notify(match mode {
629                            fs_ops::DeleteMode::Trash => {
630                                format!("moved to the recycle bin: {}", entry.file_name)
631                            }
632                            fs_ops::DeleteMode::Permanent => {
633                                format!("deleted: {}", entry.file_name)
634                            }
635                        });
636                        // Refresh to reflect the deletion.
637                        state.refresh()?;
638                    }
639                    Err(e) => {
640                        state.set_error(format!("delete: {e}"));
641                    }
642                }
643            }
644        }
645
646        Action::CancelDelete => {
647            state.pending_delete = false;
648            state.clear_notice();
649            state.dirty = true;
650        }
651
652        Action::ToggleMark => {
653            if let Some(entry) = state.selected_entry().cloned() {
654                state.marks.toggle(&entry.path);
655                state.dirty = true;
656            }
657        }
658
659        Action::ClearMarks => clear_marks(state),
660
661        Action::EnterVisual => {
662            if state.mode == Mode::Navigation {
663                if let Some(entry) = state.selected_entry() {
664                    state.mode = Mode::Visual {
665                        anchor: entry.path.clone(),
666                    };
667                    state.dirty = true;
668                }
669            }
670        }
671
672        Action::CommitVisual(then) => {
673            if matches!(state.mode, Mode::Visual { .. }) {
674                let range = crate::app::marks::visual_paths(state);
675                state.marks.extend(range);
676                state.mode = Mode::Navigation;
677                state.dirty = true;
678            }
679            if let Some(next) = then {
680                apply(*next, state)?;
681            }
682        }
683
684        Action::PasteMarks(kind) => {
685            state.last_repeatable = Some(Repeatable::Key(Action::PasteMarks(kind)));
686            paste_marks(state, kind);
687        }
688
689        Action::SetPendingNavKey(ch) => {
690            state.pending_nav_key = Some(ch);
691            state.dirty = true;
692        }
693
694        Action::RunExternal { .. } => {
695            // RunExternal is never dispatched through apply() — the event loop
696            // in main.rs intercepts it and calls shell_exec::run_external
697            // directly. If it reaches here, it's a caller bug; log and ignore.
698            tracing::debug!("RunExternal reached apply() — should be handled by event loop");
699        }
700
701        Action::Quit | Action::Cancel => {
702            // Handled by the event loop; nothing to do at the state level.
703        }
704
705        // ── OS open ───────────────────────────────────────────────────────────
706        Action::OpenWithOs => {
707            if let Some(entry) = state.selected_entry().cloned() {
708                #[cfg(target_os = "macos")]
709                let argv = vec!["open".to_owned(), entry.path.display().to_string()];
710                #[cfg(target_os = "linux")]
711                let argv = vec!["xdg-open".to_owned(), entry.path.display().to_string()];
712                #[cfg(target_os = "windows")]
713                let argv = vec![
714                    "cmd.exe".to_owned(),
715                    "/C".to_owned(),
716                    "start".to_owned(),
717                    String::new(), // window title (required by start)
718                    entry.path.display().to_string(),
719                ];
720                #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
721                let argv = vec!["xdg-open".to_owned(), entry.path.display().to_string()];
722
723                state.pending_external = Some(Action::RunExternal {
724                    argv,
725                    cwd: state.cwd.clone(),
726                    // The OS handler opens its own window; there is no output
727                    // here to hold the screen for.
728                    pause: shell_exec::ShellPause::Never,
729                    label: None,
730                });
731                state.dirty = true;
732            }
733        }
734
735        // ── Tab management (Phase 8) ──────────────────────────────────────────
736        Action::NewTab => {
737            state.open_tab(None)?;
738            state.dirty = true;
739        }
740
741        Action::CloseTab => {
742            if !state.close_tab()? {
743                state.notify(LAST_TAB_HINT);
744            }
745            state.dirty = true;
746        }
747
748        Action::SwitchTabNext => {
749            if state.tab_manager.is_single() {
750                state.notify(SINGLE_TAB_HINT);
751            } else {
752                state.switch_tab_next()?;
753            }
754            state.dirty = true;
755        }
756
757        Action::SwitchTabPrev => {
758            if state.tab_manager.is_single() {
759                state.notify(SINGLE_TAB_HINT);
760            } else {
761                state.switch_tab_prev()?;
762            }
763            state.dirty = true;
764        }
765    }
766    Ok(())
767}
768
769/// Parses and runs one command line exactly as typed — `:` commands without
770/// their `:`, shell commands with their `!`. Shared by `Enter` in Command Mode
771/// and by `@`, so a repeated command cannot behave differently from the
772/// original.
773///
774/// # Errors
775///
776/// Returns [`StateError`] if the command's own action fails that way; a parse
777/// error is reported in the status bar instead.
778fn run_command_line(state: &mut AppState, line: &str) -> Result<(), StateError> {
779    let (text, is_shell) = match line.strip_prefix('!') {
780        Some(rest) => (rest, true),
781        None => (line, false),
782    };
783    match crate::input::command_parser::parse(text, is_shell) {
784        Ok(cmd) => {
785            state.clear_notice();
786            apply(Action::ExecuteCommand(cmd), state)
787        }
788        Err(e) => {
789            state.set_error(e.to_string());
790            Ok(())
791        }
792    }
793}
794
795/// Expands an `@name` destination through the bookmark store, or returns
796/// `dest` unchanged when it does not start with `@`.
797///
798/// `@docs` is the bookmark's directory and `@docs\next\route` a route under it.
799/// An unknown name is an error, reported here — never a folder literally named
800/// `@docs`, which is reached as `.\@docs` instead. Returns `None` after
801/// reporting.
802fn expand_destination(state: &mut AppState, kind: TransferKind, dest: &str) -> Option<PathBuf> {
803    let Some(after_at) = dest.strip_prefix('@') else {
804        return Some(PathBuf::from(dest));
805    };
806    let (name, route) = match after_at.find(std::path::is_separator) {
807        Some(split) => (&after_at[..split], &after_at[split + 1..]),
808        None => (after_at, ""),
809    };
810    let base = state
811        .bookmark_store
812        .as_ref()
813        .and_then(|store| store.get(name))
814        .map(Path::to_path_buf);
815    match base {
816        Some(base) if route.is_empty() => Some(base),
817        Some(base) => Some(base.join(route)),
818        None => {
819            state.set_error(format!(
820                "{}: no bookmark named '{name}' — :bookmark {name} makes one",
821                transfer_verb(kind)
822            ));
823            None
824        }
825    }
826}
827
828/// Saves this session's `:set` changes into the config file (`:config write`).
829///
830/// Into the file this session loaded; with none, into `trail.toml` in the
831/// config directory, which is then remembered so the next start loads it —
832/// a written setting that the next start ignored would be worse than none.
833fn write_config(state: &mut AppState) {
834    use crate::config::persist;
835
836    if state.settings.changes().is_empty() {
837        state.notify("nothing to write — no :set changes this session");
838        return;
839    }
840    let config_dir = crate::paths::config_dir();
841    let Some(path) = persist::target_file(state.settings.file.as_deref(), config_dir.as_deref())
842    else {
843        state.set_error("config: no config file loaded, and no config directory to create one in");
844        return;
845    };
846    if let Err(e) = persist::write_settings(&path, state.settings.changes()) {
847        state.set_error(format!("config: {e}"));
848        return;
849    }
850
851    let count = state.settings.changes().len();
852    state.settings.clear();
853    let shown = crate::pathfmt::display(&path);
854    if state.settings.file.is_none() {
855        if let Some(state_file) = state.settings.remember_in.clone() {
856            if let Err(e) = crate::config::last_used::remember(&state_file, &path) {
857                state.set_error(format!(
858                    "config: wrote {shown}, but could not remember it for next time: {e}"
859                ));
860                return;
861            }
862        }
863        state.settings.file = Some(path);
864    }
865    state.notify(format!("wrote {count} setting(s) to {shown}"));
866}
867
868/// Unmarks everything, saying how many marks went. Silent when there were
869/// none, because `Esc` reaches here on every press in Navigation Mode.
870fn clear_marks(state: &mut AppState) {
871    let count = state.marks.clear();
872    if count > 0 {
873        state.notify(format!("{count} unmarked"));
874    }
875}
876
877/// Copies or moves every marked entry into `cwd`, under its own name.
878///
879/// Marks that have vanished since they were made go into the job anyway, so
880/// the report counts them and [`transfer::finish`] drops them from the set.
881fn paste_marks(state: &mut AppState, kind: TransferKind) {
882    if state.marks.is_empty() {
883        state.notify(NOTHING_MARKED_HINT);
884        return;
885    }
886    let cwd = state.cwd.clone();
887    let items = state
888        .marks
889        .paths()
890        .map(|source| TransferItem {
891            source: source.clone(),
892            dest: cwd.join(source.file_name().unwrap_or_default()),
893        })
894        .collect();
895    transfer::start(state, kind, "paste", items);
896}
897
898/// Deletes `paths` — the marks, or a visual range — after the single
899/// confirmation `dd` asked for.
900///
901/// Each entry goes the way `[general] delete_mode` says, the same as a `dd` of
902/// one. Deleted and vanished entries leave the mark set; one that could not be
903/// deleted stays marked, so the user can see which and try again.
904///
905/// # Errors
906///
907/// Returns [`StateError`] only if re-listing the directory afterwards fails.
908fn delete_paths(state: &mut AppState, paths: Vec<PathBuf>) -> Result<(), StateError> {
909    let mode = configured_delete_mode(state);
910    let mut deleted = 0usize;
911    let mut gone = 0usize;
912    let mut first_failure = None;
913    let mut failed = 0usize;
914
915    for path in paths {
916        if path.symlink_metadata().is_err() {
917            gone += 1;
918            state.marks.remove(&path);
919            continue;
920        }
921        match fs_ops::delete(&path, mode) {
922            Ok(()) => {
923                deleted += 1;
924                state.marks.remove(&path);
925            }
926            Err(e) => {
927                failed += 1;
928                first_failure.get_or_insert(e.to_string());
929            }
930        }
931    }
932
933    let verb = match mode {
934        fs_ops::DeleteMode::Trash => "moved to the recycle bin",
935        fs_ops::DeleteMode::Permanent => "deleted",
936    };
937    let mut report = format!("{deleted} {verb}");
938    if gone > 0 {
939        report.push_str(&format!(", {gone} gone"));
940    }
941    match first_failure {
942        Some(reason) => state.set_error(format!("{report}, {failed} failed ({reason})")),
943        None => state.notify(report),
944    }
945    state.refresh()
946}
947
948/// Shown when a paste is asked for with nothing marked.
949const NOTHING_MARKED_HINT: &str = "nothing is marked — Space marks the selection";
950
951/// The verb a transfer is reported under: `cp` or `mv`.
952fn transfer_verb(kind: TransferKind) -> &'static str {
953    match kind {
954        TransferKind::Copy => "cp",
955        TransferKind::Move => "mv",
956    }
957}
958
959/// Starts a `:cp`/`:mv` of the selected entry to `dest`.
960///
961/// Only the destination is resolved here; the copy or move itself runs in the
962/// background (see [`transfer`]) and reports when it is done.
963fn transfer_selection(state: &mut AppState, kind: TransferKind, dest: &str) {
964    let Some(dest) = expand_destination(state, kind, dest) else {
965        return;
966    };
967    if !state.marks.is_empty() {
968        transfer_marks(state, kind, &dest);
969        return;
970    }
971    let Some(entry) = state.selected_entry().cloned() else {
972        return;
973    };
974    let target = fs_ops::resolve_target(&entry.path, &dest, &state.cwd);
975    transfer::start(
976        state,
977        kind,
978        transfer_verb(kind),
979        vec![TransferItem {
980            source: entry.path,
981            dest: target,
982        }],
983    );
984}
985
986/// Starts a `:cp`/`:mv` of every marked entry to `dest`.
987///
988/// One mark behaves exactly like the selection would: `dest` may be a new
989/// name. Several need `dest` to be an existing directory, for the same reason
990/// a pattern does — several entries cannot all become one path.
991fn transfer_marks(state: &mut AppState, kind: TransferKind, dest: &Path) {
992    let verb = transfer_verb(kind);
993    let marked: Vec<PathBuf> = state.marks.paths().cloned().collect();
994    if marked.len() > 1 && !fs_ops::resolve_dest(dest, &state.cwd).is_dir() {
995        state.set_error(format!(
996            "{verb}: '{}' is not a directory, and {} entries are marked",
997            crate::pathfmt::display(dest),
998            marked.len()
999        ));
1000        return;
1001    }
1002    let items = marked
1003        .into_iter()
1004        .map(|source| TransferItem {
1005            dest: fs_ops::resolve_target(&source, dest, &state.cwd),
1006            source,
1007        })
1008        .collect();
1009    transfer::start(state, kind, verb, items);
1010}
1011
1012/// Starts a `:cp`/`:mv` of every entry in `cwd` matching `pattern`.
1013///
1014/// `dest` must be an existing directory: a pattern names several files, and
1015/// several files cannot be moved onto one path. Checking it first is what stops
1016/// `:mv *.md notes.md` from copying each match over the last.
1017///
1018/// A failure part-way through does not undo what already succeeded -- there is
1019/// no transaction to roll back to -- so the report says what became of each.
1020fn transfer_matches(state: &mut AppState, kind: TransferKind, pattern: &str, dest: &str) {
1021    let verb = transfer_verb(kind);
1022    let cwd = state.cwd.clone();
1023    let Some(dest_expanded) = expand_destination(state, kind, dest) else {
1024        return;
1025    };
1026
1027    let matches = match fs_ops::matching_entries(&cwd, pattern) {
1028        Ok(matches) => matches,
1029        Err(e) => {
1030            state.set_error(format!("{verb}: {e}"));
1031            return;
1032        }
1033    };
1034    if matches.is_empty() {
1035        state.set_error(format!("{verb}: nothing matches '{pattern}'"));
1036        return;
1037    }
1038
1039    let dest_path = fs_ops::resolve_dest(&dest_expanded, &cwd);
1040    if !dest_path.is_dir() {
1041        state.set_error(format!(
1042            "{verb}: '{dest}' is not a directory, and '{pattern}' matches {} entries",
1043            matches.len()
1044        ));
1045        return;
1046    }
1047
1048    let items = matches
1049        .into_iter()
1050        .map(|source| TransferItem {
1051            dest: dest_path.join(source.file_name().unwrap_or_default()),
1052            source,
1053        })
1054        .collect();
1055    transfer::start(state, kind, verb, items);
1056}
1057
1058/// Where `dd` sends an entry, from `[general] delete_mode`.
1059///
1060/// An unparseable value cannot normally reach here -- it is rejected at load and
1061/// by `:set` -- so the fallback is the recoverable option: a config Trail cannot
1062/// read must not be the reason a file cannot be got back.
1063/// Applies `change` to the active tab's sort settings, re-orders the listing,
1064/// and says what the new order is.
1065///
1066/// Every sort binding and `:sort` go through here so that the notice, the
1067/// re-sort and the per-tab bookkeeping cannot be done by one caller and
1068/// forgotten by the next. The settings are read back from `state.sort` rather
1069/// than from the config, because they are per tab: the config only ever seeded
1070/// the first one.
1071fn apply_sort(state: &mut AppState, change: impl FnOnce(&mut crate::app::sort::SortSettings)) {
1072    let mut settings = state.sort;
1073    change(&mut settings);
1074    state.set_sort(settings);
1075    state.notify(settings.describe());
1076}
1077
1078fn configured_delete_mode(state: &AppState) -> fs_ops::DeleteMode {
1079    fs_ops::DeleteMode::parse(&state.config.general.delete_mode)
1080        .unwrap_or(fs_ops::DeleteMode::Trash)
1081}
1082
1083/// The pause policy for a command whose output the user is expecting to read.
1084///
1085/// An unparseable value cannot normally reach here -- `TrailConfig::validate`
1086/// rejects one at load and `:set` rejects one at runtime -- so falling back to
1087/// waiting is the conservative answer for a config that somehow got past both:
1088/// output kept is recoverable, output erased is not.
1089fn configured_pause(state: &AppState) -> shell_exec::ShellPause {
1090    shell_exec::ShellPause::parse(&state.config.general.shell_pause)
1091        .unwrap_or(shell_exec::ShellPause::Always)
1092}
1093
1094/// Shown when a tab switch is asked for and there is nothing to switch to.
1095///
1096/// Switching used to be a silent no-op here, which is indistinguishable from a
1097/// binding that is not working — and was reported as exactly that.
1098const SINGLE_TAB_HINT: &str = "only one tab open — Ctrl-t opens another";
1099
1100/// Shown when the last remaining tab is asked to close.
1101const LAST_TAB_HINT: &str = "the last tab stays open — q quits Trail";
1102
1103/// Records the outcome of a yank operation in `state`.
1104///
1105/// `text` is the already-computed string to yank — a path from one of the
1106/// `*_text` functions in [`clipboard`], or an entry's content from
1107/// `clipboard::content_text`. On success the string is stored in
1108/// `AppState::last_yank` **whether or not** the OS clipboard accepted it: the
1109/// text was computed correctly, and only the hand-off to the OS can fail. That
1110/// keeps the status bar honest on machines with no reachable clipboard
1111/// (headless servers, bare TTYs) while still reporting the failure, rather
1112/// than silently doing nothing.
1113fn record_yank(state: &mut AppState, text: Result<String, clipboard::ClipboardError>) {
1114    match text {
1115        Ok(s) => {
1116            if let Err(e) = clipboard::set_clipboard(&s) {
1117                // Non-fatal: the yank still happened as far as Trail is
1118                // concerned, the OS just would not take it.
1119                state.set_error(format!("clipboard unavailable: {e}"));
1120            } else {
1121                // Summarized here rather than in the status bar: a `yc` of a
1122                // whole file must not put the file in a one-line widget, and
1123                // the notice is the thing that gets logged.
1124                state.notify(format!("yanked: {}", clipboard::summarize(&s)));
1125            }
1126            state.last_yank = Some(s);
1127        }
1128        Err(e) => {
1129            state.set_error(format!("yank: {e}"));
1130        }
1131    }
1132    state.dirty = true;
1133}
1134
1135/// Executes a [`ParsedCommand`] against `state`, performing the corresponding
1136/// filesystem mutation (or surfacing a stub message for Phase-4+ commands).
1137fn execute_parsed_command(cmd: ParsedCommand, state: &mut AppState) -> Result<(), StateError> {
1138    let cwd = state.cwd.clone();
1139
1140    match cmd {
1141        ParsedCommand::Mkdir(name) => match fs_ops::mkdir(&cwd, &name) {
1142            Ok(_) => {
1143                state.clear_notice();
1144                state.refresh()?;
1145            }
1146            Err(e) => {
1147                state.set_error(format!("mkdir: {e}"));
1148            }
1149        },
1150
1151        ParsedCommand::Touch(name) => match fs_ops::touch(&cwd, &name) {
1152            Ok(_) => {
1153                state.clear_notice();
1154                state.refresh()?;
1155            }
1156            Err(e) => {
1157                state.set_error(format!("touch: {e}"));
1158            }
1159        },
1160
1161        ParsedCommand::Rename(new_name) => {
1162            if let Some(entry) = state.selected_entry().cloned() {
1163                match fs_ops::rename(&entry.path, &new_name) {
1164                    Ok(_) => {
1165                        state.clear_notice();
1166                        state.refresh()?;
1167                    }
1168                    Err(e) => {
1169                        state.set_error(format!("rename: {e}"));
1170                    }
1171                }
1172            }
1173        }
1174
1175        ParsedCommand::Mv(dest) => transfer_selection(state, TransferKind::Move, &dest),
1176
1177        ParsedCommand::Cp(dest) => transfer_selection(state, TransferKind::Copy, &dest),
1178
1179        ParsedCommand::MvMatching { pattern, dest } => {
1180            transfer_matches(state, TransferKind::Move, &pattern, &dest);
1181        }
1182
1183        ParsedCommand::CpMatching { pattern, dest } => {
1184            transfer_matches(state, TransferKind::Copy, &pattern, &dest);
1185        }
1186
1187        ParsedCommand::Git(subcmd) => {
1188            state.pending_external = Some(Action::RunExternal {
1189                argv: shell_exec::shell_argv(&state.config.general.shell, &format!("git {subcmd}")),
1190                cwd: state.cwd.clone(),
1191                pause: configured_pause(state),
1192                label: Some(format!(":git {subcmd}")),
1193            });
1194            state.dirty = true;
1195        }
1196
1197        ParsedCommand::Sort { by, reverse } => {
1198            apply_sort(state, |s| {
1199                if let Some(by) = by {
1200                    s.by = by;
1201                }
1202                if reverse {
1203                    s.reverse = !s.reverse;
1204                }
1205            });
1206        }
1207
1208        ParsedCommand::Set { key, value } => match state.config.set_value(&key, &value) {
1209            Ok(()) => {
1210                state.settings.record(&key, &value);
1211                // `preview.*` keys feed process-wide graphics state rather than
1212                // being read out of the config at render time, so it has to be
1213                // rebuilt here. Cheap, and applies to the next preview.
1214                if key.contains("image_") {
1215                    crate::preview::graphics::configure(
1216                        &state.config.preview.image_protocol,
1217                        (
1218                            state.config.preview.image_cell_width,
1219                            state.config.preview.image_cell_height,
1220                        ),
1221                    );
1222                }
1223                // The sort keys decide the order the listing was *built* in, so
1224                // setting one has no effect until something re-sorts — without
1225                // this, `:set sort_by size` would appear to do nothing until the
1226                // next navigation. `entry_details` needs no such rebuild: the
1227                // renderer reads it every frame.
1228                if matches!(
1229                    key.as_str(),
1230                    "navigation.sort_by"
1231                        | "sort_by"
1232                        | "navigation.sort_reverse"
1233                        | "sort_reverse"
1234                        | "navigation.dirs_first"
1235                        | "dirs_first"
1236                ) {
1237                    state.set_sort(state.config.navigation.sort_settings());
1238                }
1239                state.clear_notice();
1240                state.dirty = true;
1241            }
1242            Err(e) => {
1243                state.set_error(format!("set: {e}"));
1244            }
1245        },
1246
1247        ParsedCommand::Shell(cmd_str) => {
1248            // Phase 6: run via shell_exec::run_external through the event loop.
1249            // Route through a shell interpreter so that builtins, pipelines,
1250            // quoted arguments, and variable expansions all work correctly.
1251            // Direct argv-split would fail for any non-trivial shell command.
1252            // Which interpreter is `[general] shell`'s call, not ours.
1253            if cmd_str.trim().is_empty() {
1254                state.set_error("!: empty command".to_owned());
1255            } else {
1256                state.pending_external = Some(Action::RunExternal {
1257                    argv: shell_exec::shell_argv(&state.config.general.shell, &cmd_str),
1258                    cwd: state.cwd.clone(),
1259                    pause: configured_pause(state),
1260                    label: Some(format!("!{cmd_str}")),
1261                });
1262                state.dirty = true;
1263            }
1264        }
1265
1266        ParsedCommand::Bookmark(name) => {
1267            // Use the supplied name, or fall back to the cwd base-name.
1268            let bookmark_name = if name.is_empty() {
1269                state
1270                    .cwd
1271                    .file_name()
1272                    .and_then(|n| n.to_str())
1273                    .unwrap_or("bookmark")
1274                    .to_owned()
1275            } else {
1276                name
1277            };
1278            match state.bookmark_add(bookmark_name.clone()) {
1279                Ok(()) => {
1280                    state.notify(format!("bookmark added: {bookmark_name}"));
1281                }
1282                Err(e) => {
1283                    state.set_error(format!("bookmark: {e}"));
1284                }
1285            }
1286        }
1287
1288        ParsedCommand::Jump(name) => match state.bookmark_jump(&name) {
1289            Ok(true) => {
1290                state.clear_notice();
1291            }
1292            Ok(false) => {
1293                state.set_error(format!("bookmark '{name}' not found"));
1294            }
1295            Err(e) => {
1296                state.set_error(format!("jump: {e}"));
1297            }
1298        },
1299
1300        ParsedCommand::Term(command) => crate::terminal::run_command(state, command),
1301
1302        ParsedCommand::ConfigWrite => write_config(state),
1303
1304        ParsedCommand::Unmark => {
1305            if state.marks.is_empty() {
1306                state.notify(NOTHING_MARKED_HINT);
1307            } else {
1308                clear_marks(state);
1309            }
1310        }
1311
1312        ParsedCommand::Plugin { name, arg } => {
1313            if let Some(engine) = &state.plugin_engine {
1314                if engine.fire_action(&name, &arg) {
1315                    state.clear_notice();
1316                } else {
1317                    state.set_error(format!("plugin action '{name}' not found"));
1318                }
1319            } else {
1320                state.set_error(format!("plugin action '{name}' failed: no plugins loaded"));
1321            }
1322        }
1323    }
1324
1325    Ok(())
1326}