dear-file-browser 0.14.0

File dialogs and in-UI file browser for dear-imgui-rs
Documentation
use std::path::PathBuf;

use crate::dialog_core::EntryId;
use crate::dialog_state::{
    ClipboardOp, FileClipboard, FileDialogState, PasteConflictAction, PasteConflictPrompt,
    PendingPasteJob,
};
use crate::fs::FileSystem;
use crate::fs_ops::{
    ExistingTargetDecision, ExistingTargetPolicy, apply_existing_target_policy, copy_tree,
    move_tree,
};

pub(super) fn selected_entry_paths_from_ids(state: &FileDialogState) -> Vec<PathBuf> {
    state.core.selected_entry_paths()
}

pub(super) fn selected_entry_counts_from_ids(state: &FileDialogState) -> (usize, usize) {
    state.core.selected_entry_counts()
}

pub(super) fn open_rename_modal_from_selection(state: &mut FileDialogState) {
    if state.core.selected_len() != 1 {
        return;
    }
    let Some(rename_target_id) = state.core.selected_entry_ids().into_iter().next() else {
        return;
    };
    let Some(rename_to) = state
        .core
        .entry_path_by_id(rename_target_id)
        .and_then(|path| path.file_name())
        .and_then(|name| name.to_str())
        .filter(|name| !name.is_empty())
        .map(ToOwned::to_owned)
    else {
        return;
    };
    state.ui.operations.rename.target_id = Some(rename_target_id);
    state.ui.operations.rename.to = rename_to;
    state.ui.operations.rename.error = None;
    state.ui.operations.rename.open_next = true;
    state.ui.operations.rename.focus_next = true;
}

pub(super) fn open_delete_modal_from_selection(state: &mut FileDialogState) {
    let delete_target_ids = state.core.selected_entry_ids();
    if delete_target_ids.is_empty() {
        return;
    }
    state.ui.operations.delete.target_ids = delete_target_ids;
    state.ui.operations.delete.error = None;
    state.ui.operations.delete.open_next = true;
}
pub(super) fn clipboard_set_from_selection(state: &mut FileDialogState, op: ClipboardOp) {
    if !state.core.has_selection() {
        return;
    }

    let sources = selected_entry_paths_from_ids(state);
    if sources.is_empty() {
        return;
    }
    state.ui.operations.paste.clipboard = Some(FileClipboard { op, sources });
}

pub(super) fn start_paste_into_cwd(state: &mut FileDialogState) {
    let Some(clipboard) = state.ui.operations.paste.clipboard.clone() else {
        return;
    };
    if clipboard.sources.is_empty() {
        return;
    }

    state.ui.operations.paste.job = Some(PendingPasteJob {
        clipboard,
        dest_dir: state.core.cwd.clone(),
        next_index: 0,
        created: Vec::new(),
        apply_all_conflicts: None,
        pending_conflict_action: None,
        conflict: None,
    });
}

fn try_complete_paste_job(state: &mut FileDialogState) {
    let Some(job) = state.ui.operations.paste.job.take() else {
        return;
    };
    if job.created.is_empty() {
        return;
    }

    state.core.invalidate_dir_cache();

    let selected_ids = job
        .created
        .iter()
        .map(|name| EntryId::from_path(&state.core.cwd.join(name)))
        .collect::<Vec<_>>();
    let reveal_id = selected_ids.first().copied();
    state.core.replace_selection_by_ids(selected_ids);
    state.ui.operations.reveal_id_next = reveal_id;

    if matches!(job.clipboard.op, ClipboardOp::Cut) {
        state.ui.operations.paste.clipboard = None;
    }
}

fn step_paste_job(state: &mut FileDialogState, fs: &dyn FileSystem) -> Result<bool, String> {
    let Some(job) = state.ui.operations.paste.job.as_mut() else {
        return Ok(false);
    };

    if job.conflict.is_some() {
        return Ok(false);
    }

    while job.next_index < job.clipboard.sources.len() {
        let src = job.clipboard.sources[job.next_index].clone();
        let name = src
            .file_name()
            .ok_or_else(|| format!("Invalid source path: {}", src.display()))?
            .to_string_lossy()
            .to_string();

        let mut dest = job.dest_dir.join(&name);
        if dest == src {
            job.next_index += 1;
            continue;
        }
        if dest.starts_with(&src) {
            return Err(format!("Refusing to paste '{name}' into itself"));
        }

        let exists = fs.metadata(&dest).is_ok();
        if exists {
            if let Some(action) = job
                .pending_conflict_action
                .take()
                .or(job.apply_all_conflicts)
            {
                let policy = match action {
                    PasteConflictAction::Overwrite => ExistingTargetPolicy::Overwrite,
                    PasteConflictAction::Skip => ExistingTargetPolicy::Skip,
                    PasteConflictAction::KeepBoth => ExistingTargetPolicy::KeepBoth,
                };
                match apply_existing_target_policy(fs, &job.dest_dir, &name, policy)
                    .map_err(|e| format!("Failed to resolve target conflict for '{name}': {e}"))?
                {
                    ExistingTargetDecision::Skip => {
                        job.next_index += 1;
                        continue;
                    }
                    ExistingTargetDecision::Continue(p) => dest = p,
                }
            } else {
                job.conflict = Some(PasteConflictPrompt {
                    source: src,
                    dest,
                    apply_to_all: false,
                });
                state.ui.operations.paste.conflict_open_next = true;
                return Ok(false);
            }
        }

        let r = match job.clipboard.op {
            ClipboardOp::Copy => copy_tree(fs, &src, &dest),
            ClipboardOp::Cut => move_tree(fs, &src, &dest),
        };
        if let Err(e) = r {
            return Err(format!("Failed to paste '{name}': {e}"));
        }

        let created_name = dest
            .file_name()
            .map(|v| v.to_string_lossy().to_string())
            .unwrap_or(name);
        job.created.push(created_name);
        job.next_index += 1;
    }

    Ok(true)
}

pub(super) fn run_paste_job_until_wait_or_done(
    state: &mut FileDialogState,
    fs: &dyn FileSystem,
) -> Result<(), String> {
    if step_paste_job(state, fs)? {
        try_complete_paste_job(state);
    }

    Ok(())
}