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}