fast-fs 0.2.1

High-speed async file system traversal library with batteries-included file browser component
Documentation
// <FILE>crates/fast-fs/src/nav/action_result.rs</FILE> - <DESC>Action result types for browser operations</DESC>
// <VERS>VERSION: 0.2.0</VERS>
// <WCTX>Adding clipboard support</WCTX>
// <CLOG>Added ClipboardOp and ClipboardState for cut/copy tracking</CLOG>

//! Action result types
//!
//! These types represent the outcomes of browser actions and drive the
//! consumer's UI flow (confirmations, inputs, etc.).

use std::path::{Path, PathBuf};

/// Clipboard operation type
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClipboardOp {
    /// Cut (move) operation
    Cut,
    /// Copy operation
    Copy,
}

/// Clipboard state tracking cut/copy intent
///
/// The library tracks which files are in the clipboard and whether they
/// were cut or copied. The caller is responsible for:
/// - Persisting/syncing with system clipboard if desired
/// - Executing the actual paste operation
/// - Showing visual feedback for cut items (e.g., dimmed)
///
/// # Example
///
/// ```no_run
/// use fast_fs::nav::{Browser, BrowserConfig, KeyInput, ActionResult, ClipboardState};
///
/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
/// let config = BrowserConfig::project_explorer();
/// let mut browser = Browser::new(config).await?;
/// let mut app_clipboard: Option<ClipboardState> = None;
///
/// // User presses Ctrl+C
/// if let ActionResult::Clipboard(state) = browser.handle_key(KeyInput::Ctrl('c')).await {
///     // Store state for later paste
///     app_clipboard = Some(state);
/// }
///
/// // Later, check conflicts before pasting
/// if let Some(ref clipboard) = app_clipboard {
///     let conflicts = clipboard.would_conflict(browser.current_path());
///     // Caller handles actual file copy/move operations
/// }
/// # Ok(())
/// # }
/// ```
#[derive(Debug, Clone)]
pub struct ClipboardState {
    /// Whether this is a cut or copy operation
    pub operation: ClipboardOp,
    /// Paths that were cut/copied
    pub paths: Vec<PathBuf>,
    /// Directory where the cut/copy originated
    pub source_dir: PathBuf,
}

impl ClipboardState {
    /// Create a new clipboard state
    pub fn new(operation: ClipboardOp, paths: Vec<PathBuf>, source_dir: PathBuf) -> Self {
        Self {
            operation,
            paths,
            source_dir,
        }
    }

    /// Human-readable message (e.g., "3 items to paste")
    pub fn message(&self) -> String {
        let op = match self.operation {
            ClipboardOp::Cut => "move",
            ClipboardOp::Copy => "copy",
        };
        if self.paths.len() == 1 {
            format!(
                "{} '{}'",
                op,
                self.paths[0]
                    .file_name()
                    .map(|n| n.to_string_lossy())
                    .unwrap_or_default()
            )
        } else {
            format!("{} {} items", op, self.paths.len())
        }
    }

    /// Check if pasting to destination would cause conflicts
    pub fn would_conflict(&self, dest: &Path) -> Vec<PathBuf> {
        self.paths
            .iter()
            .filter_map(|p| {
                let name = p.file_name()?;
                let dest_path = dest.join(name);
                if dest_path.exists() {
                    Some(dest_path)
                } else {
                    None
                }
            })
            .collect()
    }

    /// Check if destination is the same as source
    pub fn is_same_directory(&self, dest: &Path) -> bool {
        self.source_dir == dest
    }

    /// Get the number of items in clipboard
    pub fn len(&self) -> usize {
        self.paths.len()
    }

    /// Check if clipboard is empty
    pub fn is_empty(&self) -> bool {
        self.paths.is_empty()
    }
}

/// Result of executing an action in the browser
///
/// The consumer processes these results to update UI state:
/// - `Done`: No further action needed, re-render may be required
/// - `DirectoryChanged`: Directory has changed, full re-render needed
/// - `NeedsConfirmation`: Show confirmation dialog, then call `resolve_confirmation`
/// - `NeedsInput`: Show input UI, then call `complete_input`
/// - `FileSelected`: User has selected a file (for save/open dialogs)
/// - `Clipboard`: Cut/copy performed, consumer should store state
/// - `Unhandled`: Key was not bound to an action
#[derive(Debug, Clone)]
pub enum ActionResult {
    /// Action completed, state may have changed
    Done,

    /// Directory changed — consumer should re-render
    DirectoryChanged,

    /// Operation pending confirmation (delete, overwrite)
    NeedsConfirmation(PendingOp),

    /// File activated (Enter on a file) — for save/open dialogs
    FileSelected(PathBuf),

    /// Consumer should show input UI
    NeedsInput(InputRequest),

    /// Clipboard operation performed — consumer should store state
    Clipboard(ClipboardState),

    /// Key not bound — consumer may handle (e.g., for jump_to_char)
    Unhandled,
}

impl ActionResult {
    /// Check if this result indicates the directory changed
    pub fn is_directory_changed(&self) -> bool {
        matches!(self, ActionResult::DirectoryChanged)
    }

    /// Check if this result requires user interaction
    pub fn needs_interaction(&self) -> bool {
        matches!(
            self,
            ActionResult::NeedsConfirmation(_) | ActionResult::NeedsInput(_)
        )
    }
}

/// Request for user input
///
/// The consumer should display an appropriate input UI based on the variant,
/// then call `browser.complete_input()` with the user's input.
#[derive(Debug, Clone)]
pub enum InputRequest {
    /// Filter input request
    Filter {
        /// Current filter pattern, if any
        current: Option<String>,
    },

    /// Path navigation input
    Path {
        /// Current path for pre-fill
        current: PathBuf,
    },

    /// Rename item input
    Rename {
        /// Current name for pre-fill
        current_name: String,
    },

    /// Create new directory
    NewDirectory,

    /// Create new file
    NewFile,
}

impl InputRequest {
    /// Get prompt text for this input request
    pub fn prompt(&self) -> &'static str {
        match self {
            InputRequest::Filter { .. } => "Filter:",
            InputRequest::Path { .. } => "Go to:",
            InputRequest::Rename { .. } => "New name:",
            InputRequest::NewDirectory => "Directory name:",
            InputRequest::NewFile => "File name:",
        }
    }

    /// Get current value for pre-fill, if any (as lossy UTF-8)
    pub fn current(&self) -> Option<String> {
        match self {
            InputRequest::Filter { current } => current.clone(),
            InputRequest::Path { current } => Some(current.to_string_lossy().into_owned()),
            InputRequest::Rename { current_name } => Some(current_name.clone()),
            InputRequest::NewDirectory | InputRequest::NewFile => None,
        }
    }

    /// For Path input: get original PathBuf (preserves non-UTF8)
    pub fn current_path(&self) -> Option<&Path> {
        match self {
            InputRequest::Path { current } => Some(current.as_path()),
            _ => None,
        }
    }

    /// Check if this input creates a new item
    pub fn is_create(&self) -> bool {
        matches!(self, InputRequest::NewDirectory | InputRequest::NewFile)
    }
}

/// Pending operation awaiting confirmation
///
/// The consumer should display a confirmation dialog, then call
/// `browser.resolve_confirmation(confirmed)` with the user's response.
#[derive(Debug, Clone)]
pub enum PendingOp {
    /// Delete one or more paths
    Delete {
        /// Paths to delete
        paths: Vec<PathBuf>,
    },

    /// Rename a file or directory
    Rename {
        /// Original path
        from: PathBuf,
        /// New path
        to: PathBuf,
    },

    /// Overwrite an existing file
    Overwrite {
        /// Path that will be overwritten
        path: PathBuf,
    },
}

impl PendingOp {
    /// Human-readable confirmation message (uses lossy UTF-8)
    pub fn message(&self) -> String {
        match self {
            PendingOp::Delete { paths } => {
                if paths.len() == 1 {
                    format!("Delete '{}'?", paths[0].display())
                } else {
                    format!("Delete {} items?", paths.len())
                }
            }
            PendingOp::Rename { from, to } => {
                format!(
                    "Rename '{}' to '{}'?",
                    from.file_name()
                        .map(|n| n.to_string_lossy())
                        .unwrap_or_default(),
                    to.file_name()
                        .map(|n| n.to_string_lossy())
                        .unwrap_or_default()
                )
            }
            PendingOp::Overwrite { path } => {
                format!("'{}' already exists. Overwrite?", path.display())
            }
        }
    }

    /// Paths affected by this operation
    pub fn paths(&self) -> impl Iterator<Item = &Path> {
        let paths: Vec<&Path> = match self {
            PendingOp::Delete { paths } => paths.iter().map(|p| p.as_path()).collect(),
            PendingOp::Rename { from, to } => vec![from.as_path(), to.as_path()],
            PendingOp::Overwrite { path } => vec![path.as_path()],
        };
        paths.into_iter()
    }

    /// Get the operation type as a string
    pub fn operation_type(&self) -> &'static str {
        match self {
            PendingOp::Delete { .. } => "delete",
            PendingOp::Rename { .. } => "rename",
            PendingOp::Overwrite { .. } => "overwrite",
        }
    }
}

// <FILE>crates/fast-fs/src/nav/action_result.rs</FILE>
// <VERS>END OF VERSION: 0.2.0</VERS>