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}