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