fm84 0.44.1

A synthwave dual-pane TUI file manager
//! Keys reaching what is on top, and the dialogs they drive.

use super::*;

/// Keys go to whatever is on top - an error, then a dialog, then the
/// screen - and to nothing underneath it.
#[test]
fn a_key_reaches_only_what_is_on_top() {
    let dir = std::env::temp_dir().join(format!("fm84-keys-{}", std::process::id()));
    let _ = std::fs::remove_dir_all(&dir);
    std::fs::create_dir_all(&dir).unwrap();
    std::fs::write(dir.join("a.txt"), "text").unwrap();
    let mut app_state = AppState::new();
    app_state.active_side = Side::Left;
    app_state.open_dir(Side::Left, dir.clone(), Some("a.txt".as_ref()));

    // Space behind an error selects nothing, and Enter puts the error away.
    app_state.display_error("in the way".to_string());
    assert!(press(&mut app_state, KeyCode::Char(' ')));
    assert!(app_state.left.selected.is_empty() && app_state.error.is_some());
    press(&mut app_state, KeyCode::Enter);
    assert!(app_state.error.is_none());

    // F1 opens help over the panels; typing there searches nothing.
    press(&mut app_state, KeyCode::F(1));
    assert!(matches!(app_state.dialog, Some(Dialog::Help)));
    press(&mut app_state, KeyCode::Char('a'));
    assert!(app_state.search_input.is_empty());
    // Too long for the panels of a short terminal: it scrolls, as far as
    // its last line and no further.
    let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(100, 30)).unwrap();
    crate::ui::render_ui(&mut terminal, &mut app_state);
    let last = crate::ui::help_lines().len() - app_state.drawn.help_room;
    press(&mut app_state, KeyCode::End);
    assert_eq!(app_state.help_scroll, last);
    press(&mut app_state, KeyCode::Down);
    assert_eq!(app_state.help_scroll, last);
    press(&mut app_state, KeyCode::Home);
    press(&mut app_state, KeyCode::Up);
    assert_eq!(app_state.help_scroll, 0);
    press(&mut app_state, KeyCode::F(1));
    assert!(app_state.dialog.is_none());

    // F2 types into the rename field, and Esc leaves the name alone.
    press(&mut app_state, KeyCode::F(2));
    press(&mut app_state, KeyCode::Char('x'));
    assert_eq!(app_state.dialog_input().unwrap().text, "a.txtx");
    press(&mut app_state, KeyCode::Esc);
    assert!(app_state.dialog.is_none() && dir.join("a.txt").exists());

    // In the editor, Esc over edits asks; Esc again goes back to them.
    press(&mut app_state, KeyCode::F(4));
    assert!(matches!(app_state.screen, Screen::Editor(_)));
    press(&mut app_state, KeyCode::Char('!'));
    press(&mut app_state, KeyCode::Esc);
    assert!(matches!(app_state.dialog, Some(Dialog::SaveChanges { then_quit: false })));
    press(&mut app_state, KeyCode::Esc);
    assert!(app_state.dialog.is_none() && app_state.is_editing());
    // F10 asks too, and Discard then leaves.
    assert!(press(&mut app_state, KeyCode::F(10)));
    assert!(!press(&mut app_state, KeyCode::Char('n')));
    assert_eq!(std::fs::read_to_string(dir.join("a.txt")).unwrap(), "text");

    std::fs::remove_dir_all(&dir).unwrap();
}

/// Moving about the Options dialog and typing into a text row, put
/// back with Esc. Nothing here changes a value, so nothing is saved over
/// the real config.
#[test]
fn the_options_dialog_moves_and_edits_from_the_keys() {
    use crate::options::{OPTION_ROWS, OptionRow};
    let mut app_state = AppState::new();
    press(&mut app_state, KeyCode::F(11));
    assert!(matches!(app_state.dialog, Some(Dialog::Options { editing: None })));

    press(&mut app_state, KeyCode::Up);
    assert_eq!(app_state.options_cursor, 0);
    press(&mut app_state, KeyCode::Down);
    press(&mut app_state, KeyCode::Down);
    assert_eq!(app_state.options_cursor, 2);
    for _ in 0..OPTION_ROWS.len() {
        press(&mut app_state, KeyCode::Down);
    }
    assert_eq!(app_state.options_cursor, OPTION_ROWS.len() - 1);

    // A text row opens for typing, with what it holds; Esc puts it back.
    app_state.options_cursor = OPTION_ROWS.iter().position(|&row| row == OptionRow::Terminal).unwrap();
    let before = app_state.options.text(OptionRow::Terminal).to_string();
    press(&mut app_state, KeyCode::Enter);
    let Some(Dialog::Options { editing: Some(input) }) = &app_state.dialog else { panic!("not editing") };
    assert_eq!(input.text, before);
    press(&mut app_state, KeyCode::Char('x'));
    // Up and Down belong to the text while it is being typed into.
    let row = app_state.options_cursor;
    press(&mut app_state, KeyCode::Down);
    assert_eq!(app_state.options_cursor, row);
    press(&mut app_state, KeyCode::Esc);
    assert!(matches!(app_state.dialog, Some(Dialog::Options { editing: None })));
    assert_eq!(app_state.options.text(OptionRow::Terminal), before);

    press(&mut app_state, KeyCode::F(11));
    assert!(app_state.dialog.is_none());
    press(&mut app_state, KeyCode::F(11));
    press(&mut app_state, KeyCode::Esc);
    assert!(app_state.dialog.is_none());
}

/// Ctrl+D's list: A keeps the panel's directory once, Shift+arrows move
/// one, D forgets it, and Enter or a number goes there.
#[test]
fn bookmarks_are_kept_moved_forgotten_and_gone_to() {
    let dir = std::env::temp_dir().join(format!("fm84-bookmarks-{}", std::process::id()));
    let _ = std::fs::remove_dir_all(&dir);
    std::fs::create_dir_all(dir.join("one")).unwrap();
    std::fs::create_dir_all(dir.join("two")).unwrap();
    let mut app_state = AppState::new();
    app_state.active_side = Side::Left;
    app_state.open_dir(Side::Left, dir.join("one"), None);
    // Opened on an empty list rather than the bookmarks of whoever runs this.
    app_state.dialog = Some(Dialog::Bookmarks { list: Vec::new(), index: 0 });
    let list = |app_state: &AppState| match &app_state.dialog {
        Some(Dialog::Bookmarks { list, index }) => (list.clone(), *index),
        _ => panic!("not open"),
    };

    press(&mut app_state, KeyCode::Char('a'));
    press(&mut app_state, KeyCode::Char('a'));
    assert_eq!(list(&app_state), (vec![dir.join("one")], 0));
    app_state.dialog = None;
    app_state.open_dir(Side::Left, dir.join("two"), None);
    app_state.dialog = Some(Dialog::Bookmarks { list: vec![dir.join("one")], index: 0 });
    press(&mut app_state, KeyCode::Insert);
    assert_eq!(list(&app_state), (vec![dir.join("one"), dir.join("two")], 1));
    handle_key(&mut app_state, KeyEvent::new(KeyCode::Up, KeyModifiers::SHIFT));
    assert_eq!(list(&app_state), (vec![dir.join("two"), dir.join("one")], 0));

    // 2 goes to the second, and the dialog closes.
    press(&mut app_state, KeyCode::Char('2'));
    assert!(app_state.dialog.is_none());
    assert_eq!(app_state.left.dir, dir.join("one"));

    // Gone since: said so, and the panel stays put.
    app_state.dialog = Some(Dialog::Bookmarks {
        list: vec![dir.join("gone"), dir.join("two")],
        index: 0,
    });
    press(&mut app_state, KeyCode::Enter);
    assert!(app_state.error.is_some());
    assert_eq!(app_state.left.dir, dir.join("one"));
    press(&mut app_state, KeyCode::Esc);

    app_state.dialog = Some(Dialog::Bookmarks {
        list: vec![dir.join("gone"), dir.join("one")],
        index: 1,
    });
    // Drawn numbered, the panel's own directory marked.
    let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(100, 30)).unwrap();
    crate::ui::render_ui(&mut terminal, &mut app_state);
    let screen: String = terminal.backend().buffer().content().iter().map(|cell| cell.symbol()).collect();
    assert!(screen.contains("1   ") && screen.contains("2 • "), "{screen}");
    press(&mut app_state, KeyCode::Delete);
    assert_eq!(list(&app_state), (vec![dir.join("gone")], 0));
    handle_key(&mut app_state, KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL));
    assert!(app_state.dialog.is_none());

    std::fs::remove_dir_all(&dir).unwrap();
}

/// Alt+F7 asks what to look for, offering the last search; Enter looks,
/// and Enter on a result takes the panel to it, cursor and all.
#[test]
fn find_files_finds_and_goes_there() {
    let dir = std::env::temp_dir().join(format!("fm84-find-files-{}", std::process::id()));
    let _ = std::fs::remove_dir_all(&dir);
    std::fs::create_dir_all(dir.join("a").join("deep")).unwrap();
    std::fs::write(dir.join("a").join("deep").join("notes.md"), "the needle is here").unwrap();
    std::fs::write(dir.join("a").join("other.md"), "nothing").unwrap();
    std::fs::write(dir.join("top.txt"), "needle").unwrap();

    let mut app_state = AppState::new();
    app_state.active_side = Side::Left;
    app_state.open_dir(Side::Left, dir.clone(), None);
    let alt_f7 = KeyEvent::new(KeyCode::F(7), KeyModifiers::ALT);
    handle_key(&mut app_state, alt_f7);
    assert_eq!(app_state.dialog_input().unwrap().text, "*", "everything, the first time");

    // *.md, then Tab to the text.
    app_state.dialog_input().unwrap().set("*.md".to_string());
    press(&mut app_state, KeyCode::Tab);
    for c in "needle".chars() {
        press(&mut app_state, KeyCode::Char(c));
    }
    press(&mut app_state, KeyCode::Enter);
    let started = std::time::Instant::now();
    let found = loop {
        app_state.poll_find_files();
        if let Some(Dialog::FindFiles(find)) = &app_state.dialog
            && let FindFiles::Found(search) = find.as_ref()
            && search.done
        {
            break search.results.iter().map(|(path, _)| path.clone()).collect::<Vec<_>>();
        }
        assert!(started.elapsed() < std::time::Duration::from_secs(10), "the search never finished");
        std::thread::sleep(std::time::Duration::from_millis(5));
    };
    assert_eq!(found, [dir.join("a").join("deep").join("notes.md")]);

    // Enter goes there: its directory, with the cursor on it.
    press(&mut app_state, KeyCode::Enter);
    assert!(app_state.dialog.is_none());
    assert_eq!(app_state.left.dir, dir.join("a").join("deep"));
    let on = app_state.left.state.selected().and_then(|index| app_state.left.children.get(index)).map(|item| item.name_full.clone());
    assert_eq!(on.as_deref(), Some("notes.md"));

    // The next time, the last search is offered again.
    handle_key(&mut app_state, KeyEvent::new(KeyCode::F(7), KeyModifiers::CONTROL));
    assert_eq!(app_state.dialog_input().unwrap().text, "*.md");
    press(&mut app_state, KeyCode::Esc);
    assert!(app_state.dialog.is_none());

    std::fs::remove_dir_all(&dir).unwrap();
}

/// The form's other rows - a regular expression, a size, a time - and the
/// results fed to the panel, to work on together.
#[test]
fn find_files_filters_and_feeds_the_panel() {
    let dir = std::env::temp_dir().join(format!("fm84-find-feed-{}", std::process::id()));
    let _ = std::fs::remove_dir_all(&dir);
    std::fs::create_dir_all(dir.join("src").join("deep")).unwrap();
    std::fs::write(dir.join("src").join("main.rs"), "fn main() {}\n").unwrap();
    std::fs::write(dir.join("src").join("deep").join("lib.rs"), "pub fn run() {}\n").unwrap();
    std::fs::write(dir.join("src").join("big.rs"), vec![b'x'; 4096]).unwrap();
    std::fs::write(dir.join("notes.txt"), "fn in prose\n").unwrap();

    let mut app_state = AppState::new();
    app_state.active_side = Side::Left;
    app_state.open_dir(Side::Left, dir.clone(), None);
    let alt_f7 = KeyEvent::new(KeyCode::F(7), KeyModifiers::ALT);
    let form = |app_state: &mut AppState, rows: [&str; 4], regex: bool| {
        handle_key(app_state, alt_f7);
        for (row, text) in rows.iter().enumerate() {
            app_state.dialog_input().unwrap().set(text.to_string());
            press(app_state, KeyCode::Tab);
            assert!(row < 3 || app_state.dialog_input().is_none(), "the switch is not typed into");
        }
        // On the regex row now: Space turns it on.
        if regex {
            press(app_state, KeyCode::Char(' '));
        }
        press(app_state, KeyCode::Enter);
    };
    let results = |app_state: &mut AppState| {
        let started = std::time::Instant::now();
        loop {
            app_state.poll_find_files();
            if let Some(Dialog::FindFiles(find)) = &app_state.dialog
                && let FindFiles::Found(search) = find.as_ref()
                && search.done
            {
                break search.results.iter().map(|(path, _)| path.strip_prefix(&dir).unwrap().to_string_lossy().into_owned()).collect::<Vec<_>>();
            }
            assert!(started.elapsed() < std::time::Duration::from_secs(10), "the search never finished");
            std::thread::sleep(std::time::Duration::from_millis(5));
        }
    };
    let sep = std::path::MAIN_SEPARATOR;

    // A regular expression, with a word boundary.
    form(&mut app_state, ["*.rs", r"^(pub )?fn \w+\(", "", ""], true);
    assert_eq!(results(&mut app_state), [format!("src{sep}deep{sep}lib.rs"), format!("src{sep}main.rs")]);
    press(&mut app_state, KeyCode::Esc);

    // A size, which leaves the small ones out.
    form(&mut app_state, ["*", "", ">1K", ""], false);
    assert_eq!(results(&mut app_state), [format!("src{sep}big.rs")]);
    press(&mut app_state, KeyCode::Esc);

    // What cannot be read is said so, and the form stays to put it right.
    form(&mut app_state, ["*", "", "big", ""], false);
    assert!(app_state.error.as_deref().is_some_and(|error| error.starts_with("Size as")), "{:?}", app_state.error);
    assert!(matches!(&app_state.dialog, Some(Dialog::FindFiles(find)) if matches!(**find, FindFiles::Asking(_))));
    app_state.error = None;
    press(&mut app_state, KeyCode::Esc);

    // Modified in the last day: every file, fed to the panel.
    form(&mut app_state, ["*.rs", "", "", "<1d"], false);
    assert_eq!(results(&mut app_state).len(), 3);
    press(&mut app_state, KeyCode::Char('f'));
    assert!(app_state.dialog.is_none());
    assert!(app_state.left.flat.as_ref().is_some_and(|flat| flat.dir == dir && flat.feed.is_some()));
    let mut names: Vec<String> = app_state.left.children.iter().filter(|item| !item.is_parent()).map(|item| item.name_full.clone()).collect();
    names.sort();
    assert_eq!(names, [format!("src{sep}big.rs"), format!("src{sep}deep{sep}lib.rs"), format!("src{sep}main.rs")]);

    // One deleted since is gone from the list at the reread.
    std::fs::remove_file(dir.join("src").join("big.rs")).unwrap();
    app_state.reload_panel(Side::Left, None);
    assert_eq!(app_state.left.children.iter().filter(|item| !item.is_parent()).count(), 2);
    assert!(app_state.left.flat.as_ref().is_some_and(|flat| flat.feed.is_some()), "a reread keeps it");

    // One renamed stays in the list, under its new name.
    let at = app_state.left.children.iter().position(|item| item.name_full == format!("src{sep}main.rs")).unwrap();
    app_state.left.state.select(Some(at));
    press(&mut app_state, KeyCode::F(2));
    app_state.dialog_input().unwrap().set("app.rs".to_string());
    press(&mut app_state, KeyCode::Enter);
    let mut names: Vec<String> = app_state.left.children.iter().filter(|item| !item.is_parent()).map(|item| item.name_full.clone()).collect();
    names.sort();
    assert_eq!(names, [format!("src{sep}app.rs"), format!("src{sep}deep{sep}lib.rs")]);

    // Ctrl+B goes back to the directory as it is.
    handle_key(&mut app_state, KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL));
    assert!(app_state.left.flat.is_none());
    assert!(app_state.left.children.iter().any(|item| item.name_full == "notes.txt"));
    std::fs::remove_dir_all(&dir).unwrap();
}

/// Shift+F10 quits like F10 but says to leave the shell where it was, and
/// the last F10 decides: one with Shift, then a job's plain second F10, is a
/// normal quit.
#[test]
fn shift_f10_quits_without_moving_the_shell() {
    let mut app_state = AppState::new();
    app_state.options = crate::options::Options::default();
    assert!(!handle_key(&mut app_state, KeyEvent::new(KeyCode::F(10), KeyModifiers::SHIFT)));
    assert!(app_state.stay_put);
    assert!(!press(&mut app_state, KeyCode::F(10)));
    assert!(!app_state.stay_put);
}

/// F10 leaves at once, unless the editor holds edits nobody has saved:
/// then the save prompt comes up first, over anything else in the way.
#[test]
fn quitting_asks_about_unsaved_edits_first() {
    let path = std::env::temp_dir().join(format!("fm84-quit-{}.txt", std::process::id()));
    std::fs::write(&path, "text").unwrap();
    let mut app_state = AppState::new();
    app_state.options = crate::options::Options::default();
    assert!(request_quit(&mut app_state));

    app_state.open_editor(path.clone()).unwrap();
    assert!(request_quit(&mut app_state), "nothing to lose yet");

    app_state.editor_insert_char('x');
    app_state.open_prompt(crate::app::PromptKind::Find);
    app_state.display_error("in the way".to_string());
    assert!(!request_quit(&mut app_state));
    // The save prompt takes the find prompt's place, and the error goes.
    assert!(matches!(app_state.dialog, Some(Dialog::SaveChanges { then_quit: true })));
    assert!(app_state.error.is_none());
    std::fs::remove_file(path).unwrap();
}