pub mod clipboard;
pub mod fs_ops;
pub mod shell_exec;
pub mod transfer;
use std::path::{Path, PathBuf};
use crate::app::mode::Mode;
use crate::app::state::{AppState, StateError};
use crate::input::command_parser::ParsedCommand;
use transfer::{TransferItem, TransferKind};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Action {
MoveDown,
MoveUp,
JumpTop,
JumpBottom,
EnterOrOpen,
GoParent,
HistoryBack,
HistoryForward,
ReturnFromJump,
Refresh,
ToggleHidden,
SetSortBy(crate::app::sort::SortBy),
ToggleSortReverse,
ToggleDirsFirst,
CycleEntryDetails,
PreviewScrollDown,
PreviewScrollUp,
PreviewPageDown,
PreviewPageUp,
PreviewScrollTop,
PreviewScrollBottom,
EnterSearch,
EnterCommand,
ExitMode,
SearchAppendChar(char),
SearchDeleteChar,
SearchMoveDown,
SearchMoveUp,
SearchConfirm,
CommandKey(crossterm::event::KeyEvent),
RepeatLastCommand,
ExecuteCommand(ParsedCommand),
CopyAbsPath,
CopyRelPath,
CopyFilename,
CopyContent,
BeginDelete,
ConfirmDelete,
CancelDelete,
ToggleMark,
ClearMarks,
PasteMarks(TransferKind),
EnterVisual,
CommitVisual(Option<Box<Action>>),
SetPendingNavKey(char),
RunExternal {
argv: Vec<String>,
cwd: std::path::PathBuf,
pause: shell_exec::ShellPause,
},
Quit,
Cancel,
OpenWithOs,
NewTab,
CloseTab,
SwitchTabNext,
SwitchTabPrev,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Repeatable {
CommandLine(String),
Key(Action),
}
pub fn apply(action: Action, state: &mut AppState) -> Result<(), StateError> {
match action {
Action::MoveDown => state.move_down(),
Action::MoveUp => state.move_up(),
Action::JumpTop => state.jump_top(),
Action::JumpBottom => state.jump_bottom(),
Action::PreviewScrollDown => state.scroll_preview_lines(1),
Action::PreviewScrollUp => state.scroll_preview_lines(-1),
Action::PreviewPageDown => state.scroll_preview_pages(1),
Action::PreviewPageUp => state.scroll_preview_pages(-1),
Action::PreviewScrollTop => state.scroll_preview_to_edge(false),
Action::PreviewScrollBottom => state.scroll_preview_to_edge(true),
Action::EnterOrOpen => {
if let Some(entry) = state.selected_entry().cloned() {
use crate::app::state::EntryKind;
match entry.kind {
EntryKind::Dir => {
state.enter_dir(entry.path)?;
}
EntryKind::File | EntryKind::Symlink => {
let editor = state.config.general.editor.clone();
state.pending_external = Some(Action::RunExternal {
argv: vec![editor, entry.path.display().to_string()],
cwd: state.cwd.clone(),
pause: shell_exec::ShellPause::Never,
});
state.dirty = true;
}
}
}
}
Action::GoParent => {
state.go_parent()?;
}
Action::HistoryBack => {
if state.history.can_go_back() {
state.history_back()?;
} else {
state.notify("no earlier directory — u goes back once you have moved");
}
}
Action::HistoryForward => {
if state.history.can_go_forward() {
state.history_forward()?;
} else {
state.notify("no later directory — Ctrl-r goes forward after u");
}
}
Action::ReturnFromJump => {
let before = state.cwd.clone();
match state.return_from_jump()? {
None => state.notify("no jump to return from — :jump makes one"),
Some(0) => {}
Some(skipped) if state.cwd == before => state.notify(format!(
"no jump to return from — {skipped} led to directories that are gone"
)),
Some(skipped) => state.notify(format!(
"skipped {skipped} jump(s) to directories that are gone"
)),
}
}
Action::Refresh => {
state.refresh()?;
}
Action::ToggleHidden => {
state.toggle_hidden()?;
}
Action::SetSortBy(by) => {
apply_sort(state, |s| s.by = by);
}
Action::ToggleSortReverse => {
apply_sort(state, |s| s.reverse = !s.reverse);
}
Action::ToggleDirsFirst => {
apply_sort(state, |s| s.dirs_first = !s.dirs_first);
}
Action::CycleEntryDetails => {
let next = state.config.navigation.details().next();
state.config.navigation.entry_details = next.as_str().to_owned();
state.notify(format!("details: {}", next.as_str()));
state.dirty = true;
}
Action::EnterSearch => {
state.mode = Mode::Search {
query: String::new(),
matches: Vec::new(),
};
state.apply_filter(String::new());
state.dirty = true;
}
Action::EnterCommand => {
state.mode = Mode::Command {
buffer: String::new(),
cursor: 0,
history_index: None,
};
state.clear_notice();
state.dirty = true;
}
Action::ExitMode => {
if state.mode != Mode::Navigation {
state.mode = Mode::Navigation;
state.clear_filter();
state.pending_delete = false;
state.clear_notice();
state.pending_nav_key = None;
state.dirty = true;
}
}
Action::SearchAppendChar(ch) => {
let new_query = if let Mode::Search { query, .. } = &state.mode {
let mut q = query.clone();
q.push(ch);
q
} else {
return Ok(());
};
state.apply_filter(new_query);
}
Action::SearchDeleteChar => {
let new_query = if let Mode::Search { query, .. } = &state.mode {
let mut q = query.clone();
q.pop();
q
} else {
return Ok(());
};
state.apply_filter(new_query);
}
Action::SearchMoveDown => {
state.move_down();
}
Action::SearchMoveUp => {
state.move_up();
}
Action::SearchConfirm => {
if let Some(entry) = state.selected_entry().cloned() {
use crate::app::state::EntryKind;
match entry.kind {
EntryKind::Dir => {
state.mode = Mode::Navigation;
state.clear_filter();
state.enter_dir(entry.path)?;
}
EntryKind::File | EntryKind::Symlink => {
state.mode = Mode::Navigation;
state.clear_filter();
let editor = state.config.general.editor.clone();
state.pending_external = Some(Action::RunExternal {
argv: vec![editor, entry.path.display().to_string()],
cwd: state.cwd.clone(),
pause: shell_exec::ShellPause::Never,
});
state.dirty = true;
}
}
}
}
Action::CommandKey(key) => {
use crate::input::command_parser::{feed_with_sources, CompletionSources, FeedResult};
let (buffer, cursor, history_index, is_shell) = if let Mode::Command {
buffer,
cursor,
history_index,
} = &mut state.mode
{
let is_shell = buffer.starts_with('!') || {
false
};
(buffer, cursor, history_index, is_shell)
} else {
return Ok(());
};
let mut buf_owned = buffer.clone();
let mut cur_owned = *cursor;
let mut hist_owned = *history_index;
let cwd = state.cwd.clone();
let plugin_actions: Vec<String> = state
.plugin_engine
.as_ref()
.map(|e| e.action_names().map(|s| s.to_string()).collect())
.unwrap_or_default();
let plugin_action_refs: Vec<&str> = plugin_actions.iter().map(|s| s.as_str()).collect();
let bookmarks: Vec<(String, PathBuf)> = state
.bookmark_store
.as_ref()
.map(|store| {
store
.list()
.map(|(name, path)| (name.to_owned(), path.to_owned()))
.collect()
})
.unwrap_or_default();
let result = {
let hist = std::mem::take(&mut state.command_history);
let mut tab = std::mem::take(&mut state.tab_state);
let res = feed_with_sources(
key,
&mut buf_owned,
&mut cur_owned,
&mut hist_owned,
&mut tab,
&hist,
&cwd,
is_shell,
&CompletionSources {
plugin_actions: &plugin_action_refs,
bookmarks: &bookmarks,
},
);
state.command_history = hist;
state.tab_state = tab;
res
};
if let Mode::Command {
buffer,
cursor,
history_index,
} = &mut state.mode
{
*buffer = buf_owned;
*cursor = cur_owned;
*history_index = hist_owned;
}
match result {
FeedResult::Updated | FeedResult::Completion { .. } => {
state.dirty = true;
}
FeedResult::Cancel => {
state.mode = Mode::Navigation;
state.clear_notice();
state.dirty = true;
}
FeedResult::Submit(submitted_buf) => {
if !submitted_buf.trim().is_empty() {
state.command_history.push(submitted_buf.clone());
}
state.mode = Mode::Navigation;
state.dirty = true;
if !submitted_buf.trim().is_empty() {
state.last_repeatable =
Some(Repeatable::CommandLine(submitted_buf.clone()));
}
run_command_line(state, &submitted_buf)?;
}
}
}
Action::RepeatLastCommand => match state.last_repeatable.clone() {
Some(Repeatable::CommandLine(line)) => {
state.clear_notice();
run_command_line(state, &line)?;
}
Some(Repeatable::Key(action)) => apply(action, state)?,
None => state.notify("nothing to repeat yet — @ repeats a command, dd, pp or pm"),
},
Action::ExecuteCommand(cmd) => {
execute_parsed_command(cmd, state)?;
}
Action::CopyAbsPath => {
if let Some(entry) = state.selected_entry().cloned() {
record_yank(state, clipboard::absolute_path_text(&entry.path));
}
}
Action::CopyRelPath => {
if let Some(entry) = state.selected_entry().cloned() {
let base = state.launch_dir.clone();
record_yank(state, clipboard::relative_path_text(&entry.path, &base));
}
}
Action::CopyFilename => {
if let Some(entry) = state.selected_entry().cloned() {
record_yank(state, clipboard::filename_text(&entry.path));
}
}
Action::CopyContent => {
if let Some(entry) = state.selected_entry().cloned() {
let show_hidden = state.show_hidden;
record_yank(state, clipboard::content_text(&entry.path, show_hidden));
}
}
Action::BeginDelete => {
let has_target = state.selected_entry().is_some()
|| !state.marks.is_empty()
|| matches!(state.mode, Mode::Visual { .. });
if has_target {
state.last_repeatable = Some(Repeatable::Key(Action::BeginDelete));
state.pending_delete = true;
state.clear_notice();
state.dirty = true;
}
}
Action::ConfirmDelete => {
if !state.pending_delete {
return Ok(());
}
state.pending_delete = false;
if matches!(state.mode, Mode::Visual { .. }) {
let range = crate::app::marks::visual_paths(state);
state.mode = Mode::Navigation;
delete_paths(state, range)?;
} else if !state.marks.is_empty() {
let marked = state.marks.paths().cloned().collect();
delete_paths(state, marked)?;
} else if let Some(entry) = state.selected_entry().cloned() {
let mode = configured_delete_mode(state);
match fs_ops::delete(&entry.path, mode) {
Ok(()) => {
state.notify(match mode {
fs_ops::DeleteMode::Trash => {
format!("moved to the recycle bin: {}", entry.file_name)
}
fs_ops::DeleteMode::Permanent => {
format!("deleted: {}", entry.file_name)
}
});
state.refresh()?;
}
Err(e) => {
state.set_error(format!("delete: {e}"));
}
}
}
}
Action::CancelDelete => {
state.pending_delete = false;
state.clear_notice();
state.dirty = true;
}
Action::ToggleMark => {
if let Some(entry) = state.selected_entry().cloned() {
state.marks.toggle(&entry.path);
state.dirty = true;
}
}
Action::ClearMarks => clear_marks(state),
Action::EnterVisual => {
if state.mode == Mode::Navigation {
if let Some(entry) = state.selected_entry() {
state.mode = Mode::Visual {
anchor: entry.path.clone(),
};
state.dirty = true;
}
}
}
Action::CommitVisual(then) => {
if matches!(state.mode, Mode::Visual { .. }) {
let range = crate::app::marks::visual_paths(state);
state.marks.extend(range);
state.mode = Mode::Navigation;
state.dirty = true;
}
if let Some(next) = then {
apply(*next, state)?;
}
}
Action::PasteMarks(kind) => {
state.last_repeatable = Some(Repeatable::Key(Action::PasteMarks(kind)));
paste_marks(state, kind);
}
Action::SetPendingNavKey(ch) => {
state.pending_nav_key = Some(ch);
state.dirty = true;
}
Action::RunExternal { .. } => {
tracing::debug!("RunExternal reached apply() — should be handled by event loop");
}
Action::Quit | Action::Cancel => {
}
Action::OpenWithOs => {
if let Some(entry) = state.selected_entry().cloned() {
#[cfg(target_os = "macos")]
let argv = vec!["open".to_owned(), entry.path.display().to_string()];
#[cfg(target_os = "linux")]
let argv = vec!["xdg-open".to_owned(), entry.path.display().to_string()];
#[cfg(target_os = "windows")]
let argv = vec![
"cmd.exe".to_owned(),
"/C".to_owned(),
"start".to_owned(),
String::new(), entry.path.display().to_string(),
];
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
let argv = vec!["xdg-open".to_owned(), entry.path.display().to_string()];
state.pending_external = Some(Action::RunExternal {
argv,
cwd: state.cwd.clone(),
pause: shell_exec::ShellPause::Never,
});
state.dirty = true;
}
}
Action::NewTab => {
state.open_tab(None)?;
state.dirty = true;
}
Action::CloseTab => {
if !state.close_tab()? {
state.notify(LAST_TAB_HINT);
}
state.dirty = true;
}
Action::SwitchTabNext => {
if state.tab_manager.is_single() {
state.notify(SINGLE_TAB_HINT);
} else {
state.switch_tab_next()?;
}
state.dirty = true;
}
Action::SwitchTabPrev => {
if state.tab_manager.is_single() {
state.notify(SINGLE_TAB_HINT);
} else {
state.switch_tab_prev()?;
}
state.dirty = true;
}
}
Ok(())
}
fn run_command_line(state: &mut AppState, line: &str) -> Result<(), StateError> {
let (text, is_shell) = match line.strip_prefix('!') {
Some(rest) => (rest, true),
None => (line, false),
};
match crate::input::command_parser::parse(text, is_shell) {
Ok(cmd) => {
state.clear_notice();
apply(Action::ExecuteCommand(cmd), state)
}
Err(e) => {
state.set_error(e.to_string());
Ok(())
}
}
}
fn expand_destination(state: &mut AppState, kind: TransferKind, dest: &str) -> Option<PathBuf> {
let Some(after_at) = dest.strip_prefix('@') else {
return Some(PathBuf::from(dest));
};
let (name, route) = match after_at.find(std::path::is_separator) {
Some(split) => (&after_at[..split], &after_at[split + 1..]),
None => (after_at, ""),
};
let base = state
.bookmark_store
.as_ref()
.and_then(|store| store.get(name))
.map(Path::to_path_buf);
match base {
Some(base) if route.is_empty() => Some(base),
Some(base) => Some(base.join(route)),
None => {
state.set_error(format!(
"{}: no bookmark named '{name}' — :bookmark {name} makes one",
transfer_verb(kind)
));
None
}
}
}
fn write_config(state: &mut AppState) {
use crate::config::persist;
if state.settings.changes().is_empty() {
state.notify("nothing to write — no :set changes this session");
return;
}
let config_dir = crate::paths::config_dir();
let Some(path) = persist::target_file(state.settings.file.as_deref(), config_dir.as_deref())
else {
state.set_error("config: no config file loaded, and no config directory to create one in");
return;
};
if let Err(e) = persist::write_settings(&path, state.settings.changes()) {
state.set_error(format!("config: {e}"));
return;
}
let count = state.settings.changes().len();
state.settings.clear();
let shown = crate::pathfmt::display(&path);
if state.settings.file.is_none() {
if let Some(state_file) = state.settings.remember_in.clone() {
if let Err(e) = crate::config::last_used::remember(&state_file, &path) {
state.set_error(format!(
"config: wrote {shown}, but could not remember it for next time: {e}"
));
return;
}
}
state.settings.file = Some(path);
}
state.notify(format!("wrote {count} setting(s) to {shown}"));
}
fn clear_marks(state: &mut AppState) {
let count = state.marks.clear();
if count > 0 {
state.notify(format!("{count} unmarked"));
}
}
fn paste_marks(state: &mut AppState, kind: TransferKind) {
if state.marks.is_empty() {
state.notify(NOTHING_MARKED_HINT);
return;
}
let cwd = state.cwd.clone();
let items = state
.marks
.paths()
.map(|source| TransferItem {
source: source.clone(),
dest: cwd.join(source.file_name().unwrap_or_default()),
})
.collect();
transfer::start(state, kind, "paste", items);
}
fn delete_paths(state: &mut AppState, paths: Vec<PathBuf>) -> Result<(), StateError> {
let mode = configured_delete_mode(state);
let mut deleted = 0usize;
let mut gone = 0usize;
let mut first_failure = None;
let mut failed = 0usize;
for path in paths {
if path.symlink_metadata().is_err() {
gone += 1;
state.marks.remove(&path);
continue;
}
match fs_ops::delete(&path, mode) {
Ok(()) => {
deleted += 1;
state.marks.remove(&path);
}
Err(e) => {
failed += 1;
first_failure.get_or_insert(e.to_string());
}
}
}
let verb = match mode {
fs_ops::DeleteMode::Trash => "moved to the recycle bin",
fs_ops::DeleteMode::Permanent => "deleted",
};
let mut report = format!("{deleted} {verb}");
if gone > 0 {
report.push_str(&format!(", {gone} gone"));
}
match first_failure {
Some(reason) => state.set_error(format!("{report}, {failed} failed ({reason})")),
None => state.notify(report),
}
state.refresh()
}
const NOTHING_MARKED_HINT: &str = "nothing is marked — Space marks the selection";
fn transfer_verb(kind: TransferKind) -> &'static str {
match kind {
TransferKind::Copy => "cp",
TransferKind::Move => "mv",
}
}
fn transfer_selection(state: &mut AppState, kind: TransferKind, dest: &str) {
let Some(dest) = expand_destination(state, kind, dest) else {
return;
};
if !state.marks.is_empty() {
transfer_marks(state, kind, &dest);
return;
}
let Some(entry) = state.selected_entry().cloned() else {
return;
};
let target = fs_ops::resolve_target(&entry.path, &dest, &state.cwd);
transfer::start(
state,
kind,
transfer_verb(kind),
vec![TransferItem {
source: entry.path,
dest: target,
}],
);
}
fn transfer_marks(state: &mut AppState, kind: TransferKind, dest: &Path) {
let verb = transfer_verb(kind);
let marked: Vec<PathBuf> = state.marks.paths().cloned().collect();
if marked.len() > 1 && !fs_ops::resolve_dest(dest, &state.cwd).is_dir() {
state.set_error(format!(
"{verb}: '{}' is not a directory, and {} entries are marked",
crate::pathfmt::display(dest),
marked.len()
));
return;
}
let items = marked
.into_iter()
.map(|source| TransferItem {
dest: fs_ops::resolve_target(&source, dest, &state.cwd),
source,
})
.collect();
transfer::start(state, kind, verb, items);
}
fn transfer_matches(state: &mut AppState, kind: TransferKind, pattern: &str, dest: &str) {
let verb = transfer_verb(kind);
let cwd = state.cwd.clone();
let Some(dest_expanded) = expand_destination(state, kind, dest) else {
return;
};
let matches = match fs_ops::matching_entries(&cwd, pattern) {
Ok(matches) => matches,
Err(e) => {
state.set_error(format!("{verb}: {e}"));
return;
}
};
if matches.is_empty() {
state.set_error(format!("{verb}: nothing matches '{pattern}'"));
return;
}
let dest_path = fs_ops::resolve_dest(&dest_expanded, &cwd);
if !dest_path.is_dir() {
state.set_error(format!(
"{verb}: '{dest}' is not a directory, and '{pattern}' matches {} entries",
matches.len()
));
return;
}
let items = matches
.into_iter()
.map(|source| TransferItem {
dest: dest_path.join(source.file_name().unwrap_or_default()),
source,
})
.collect();
transfer::start(state, kind, verb, items);
}
fn apply_sort(state: &mut AppState, change: impl FnOnce(&mut crate::app::sort::SortSettings)) {
let mut settings = state.sort;
change(&mut settings);
state.set_sort(settings);
state.notify(settings.describe());
}
fn configured_delete_mode(state: &AppState) -> fs_ops::DeleteMode {
fs_ops::DeleteMode::parse(&state.config.general.delete_mode)
.unwrap_or(fs_ops::DeleteMode::Trash)
}
fn configured_pause(state: &AppState) -> shell_exec::ShellPause {
shell_exec::ShellPause::parse(&state.config.general.shell_pause)
.unwrap_or(shell_exec::ShellPause::Always)
}
const SINGLE_TAB_HINT: &str = "only one tab open — Ctrl-t opens another";
const LAST_TAB_HINT: &str = "the last tab stays open — q quits Trail";
fn record_yank(state: &mut AppState, text: Result<String, clipboard::ClipboardError>) {
match text {
Ok(s) => {
if let Err(e) = clipboard::set_clipboard(&s) {
state.set_error(format!("clipboard unavailable: {e}"));
} else {
state.notify(format!("yanked: {}", clipboard::summarize(&s)));
}
state.last_yank = Some(s);
}
Err(e) => {
state.set_error(format!("yank: {e}"));
}
}
state.dirty = true;
}
fn execute_parsed_command(cmd: ParsedCommand, state: &mut AppState) -> Result<(), StateError> {
let cwd = state.cwd.clone();
match cmd {
ParsedCommand::Mkdir(name) => match fs_ops::mkdir(&cwd, &name) {
Ok(_) => {
state.clear_notice();
state.refresh()?;
}
Err(e) => {
state.set_error(format!("mkdir: {e}"));
}
},
ParsedCommand::Touch(name) => match fs_ops::touch(&cwd, &name) {
Ok(_) => {
state.clear_notice();
state.refresh()?;
}
Err(e) => {
state.set_error(format!("touch: {e}"));
}
},
ParsedCommand::Rename(new_name) => {
if let Some(entry) = state.selected_entry().cloned() {
match fs_ops::rename(&entry.path, &new_name) {
Ok(_) => {
state.clear_notice();
state.refresh()?;
}
Err(e) => {
state.set_error(format!("rename: {e}"));
}
}
}
}
ParsedCommand::Mv(dest) => transfer_selection(state, TransferKind::Move, &dest),
ParsedCommand::Cp(dest) => transfer_selection(state, TransferKind::Copy, &dest),
ParsedCommand::MvMatching { pattern, dest } => {
transfer_matches(state, TransferKind::Move, &pattern, &dest);
}
ParsedCommand::CpMatching { pattern, dest } => {
transfer_matches(state, TransferKind::Copy, &pattern, &dest);
}
ParsedCommand::Git(subcmd) => {
state.pending_external = Some(Action::RunExternal {
argv: shell_exec::shell_argv(&state.config.general.shell, &format!("git {subcmd}")),
cwd: state.cwd.clone(),
pause: configured_pause(state),
});
state.dirty = true;
}
ParsedCommand::Sort { by, reverse } => {
apply_sort(state, |s| {
if let Some(by) = by {
s.by = by;
}
if reverse {
s.reverse = !s.reverse;
}
});
}
ParsedCommand::Set { key, value } => match state.config.set_value(&key, &value) {
Ok(()) => {
state.settings.record(&key, &value);
if key.contains("image_") {
crate::preview::graphics::configure(
&state.config.preview.image_protocol,
(
state.config.preview.image_cell_width,
state.config.preview.image_cell_height,
),
);
}
if matches!(
key.as_str(),
"navigation.sort_by"
| "sort_by"
| "navigation.sort_reverse"
| "sort_reverse"
| "navigation.dirs_first"
| "dirs_first"
) {
state.set_sort(state.config.navigation.sort_settings());
}
state.clear_notice();
state.dirty = true;
}
Err(e) => {
state.set_error(format!("set: {e}"));
}
},
ParsedCommand::Shell(cmd_str) => {
if cmd_str.trim().is_empty() {
state.set_error("!: empty command".to_owned());
} else {
state.pending_external = Some(Action::RunExternal {
argv: shell_exec::shell_argv(&state.config.general.shell, &cmd_str),
cwd: state.cwd.clone(),
pause: configured_pause(state),
});
state.dirty = true;
}
}
ParsedCommand::Bookmark(name) => {
let bookmark_name = if name.is_empty() {
state
.cwd
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("bookmark")
.to_owned()
} else {
name
};
match state.bookmark_add(bookmark_name.clone()) {
Ok(()) => {
state.notify(format!("bookmark added: {bookmark_name}"));
}
Err(e) => {
state.set_error(format!("bookmark: {e}"));
}
}
}
ParsedCommand::Jump(name) => match state.bookmark_jump(&name) {
Ok(true) => {
state.clear_notice();
}
Ok(false) => {
state.set_error(format!("bookmark '{name}' not found"));
}
Err(e) => {
state.set_error(format!("jump: {e}"));
}
},
ParsedCommand::Term(command) => crate::terminal::run_command(state, command),
ParsedCommand::ConfigWrite => write_config(state),
ParsedCommand::Unmark => {
if state.marks.is_empty() {
state.notify(NOTHING_MARKED_HINT);
} else {
clear_marks(state);
}
}
ParsedCommand::Plugin { name, arg } => {
if let Some(engine) = &state.plugin_engine {
if engine.fire_action(&name, &arg) {
state.clear_notice();
} else {
state.set_error(format!("plugin action '{name}' not found"));
}
} else {
state.set_error(format!("plugin action '{name}' failed: no plugins loaded"));
}
}
}
Ok(())
}