use std::path::PathBuf;
use crossterm::event::{KeyCode, KeyEvent};
use crate::{ExplorerOutcome, FileExplorer, SortMode};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DualPaneActive {
#[default]
Left,
Right,
}
impl DualPaneActive {
pub fn other(self) -> Self {
match self {
Self::Left => Self::Right,
Self::Right => Self::Left,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DualPaneOutcome {
Selected(PathBuf),
Dismissed,
Pending,
Unhandled,
}
#[derive(Debug)]
pub struct DualPane {
pub left: FileExplorer,
pub right: FileExplorer,
pub active_side: DualPaneActive,
pub single_pane: bool,
}
impl DualPane {
pub fn builder(left_dir: PathBuf) -> DualPaneBuilder {
DualPaneBuilder::new(left_dir)
}
pub fn active(&self) -> &FileExplorer {
match self.active_side {
DualPaneActive::Left => &self.left,
DualPaneActive::Right => &self.right,
}
}
pub fn active_mut(&mut self) -> &mut FileExplorer {
match self.active_side {
DualPaneActive::Left => &mut self.left,
DualPaneActive::Right => &mut self.right,
}
}
pub fn inactive(&self) -> &FileExplorer {
match self.active_side {
DualPaneActive::Left => &self.right,
DualPaneActive::Right => &self.left,
}
}
pub fn handle_key(&mut self, key: KeyEvent) -> DualPaneOutcome {
match key.code {
KeyCode::Tab if key.modifiers.is_empty() => {
self.active_side = self.active_side.other();
return DualPaneOutcome::Pending;
}
KeyCode::Char('w') if key.modifiers.is_empty() => {
self.single_pane = !self.single_pane;
return DualPaneOutcome::Pending;
}
_ => {}
}
match self.active_mut().handle_key(key) {
ExplorerOutcome::Selected(p) => DualPaneOutcome::Selected(p),
ExplorerOutcome::Dismissed => DualPaneOutcome::Dismissed,
ExplorerOutcome::Pending => DualPaneOutcome::Pending,
ExplorerOutcome::Unhandled => DualPaneOutcome::Unhandled,
}
}
pub fn focus_left(&mut self) {
self.active_side = DualPaneActive::Left;
}
pub fn focus_right(&mut self) {
self.active_side = DualPaneActive::Right;
}
pub fn toggle_single_pane(&mut self) {
self.single_pane = !self.single_pane;
}
pub fn reload_both(&mut self) {
self.left.reload();
self.right.reload();
}
}
#[derive(Debug, Clone)]
pub struct DualPaneBuilder {
left_dir: PathBuf,
right_dir: Option<PathBuf>,
extensions: Vec<String>,
show_hidden: bool,
sort_mode: SortMode,
single_pane: bool,
}
impl DualPaneBuilder {
pub fn new(left_dir: PathBuf) -> Self {
Self {
left_dir,
right_dir: None,
extensions: Vec::new(),
show_hidden: false,
sort_mode: SortMode::default(),
single_pane: false,
}
}
pub fn right_dir(mut self, dir: PathBuf) -> Self {
self.right_dir = Some(dir);
self
}
pub fn extension_filter(mut self, filter: Vec<String>) -> Self {
self.extensions = filter;
self
}
pub fn allow_extension(mut self, ext: impl Into<String>) -> Self {
self.extensions.push(ext.into());
self
}
pub fn show_hidden(mut self, show: bool) -> Self {
self.show_hidden = show;
self
}
pub fn sort_mode(mut self, mode: SortMode) -> Self {
self.sort_mode = mode;
self
}
pub fn single_pane(mut self, enabled: bool) -> Self {
self.single_pane = enabled;
self
}
pub fn build(self) -> DualPane {
let right_dir = self.right_dir.unwrap_or_else(|| self.left_dir.clone());
let left = FileExplorer::builder(self.left_dir)
.extension_filter(self.extensions.clone())
.show_hidden(self.show_hidden)
.sort_mode(self.sort_mode)
.build();
let right = FileExplorer::builder(right_dir)
.extension_filter(self.extensions)
.show_hidden(self.show_hidden)
.sort_mode(self.sort_mode)
.build();
DualPane {
left,
right,
active_side: DualPaneActive::Left,
single_pane: self.single_pane,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use std::fs;
use tempfile::tempdir;
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn make_dual() -> DualPane {
let dir = tempdir().expect("tempdir");
fs::write(dir.path().join("file.txt"), b"x").unwrap();
let path = dir.keep();
DualPane::builder(path).build()
}
#[test]
fn dual_pane_active_other_left_returns_right() {
assert_eq!(DualPaneActive::Left.other(), DualPaneActive::Right);
}
#[test]
fn dual_pane_active_other_right_returns_left() {
assert_eq!(DualPaneActive::Right.other(), DualPaneActive::Left);
}
#[test]
fn builder_default_active_side_is_left() {
let dual = make_dual();
assert_eq!(dual.active_side, DualPaneActive::Left);
}
#[test]
fn builder_default_single_pane_is_false() {
let dual = make_dual();
assert!(!dual.single_pane);
}
#[test]
fn builder_single_pane_true_when_requested() {
let dir = tempdir().expect("tempdir");
let dual = DualPane::builder(dir.path().to_path_buf())
.single_pane(true)
.build();
assert!(dual.single_pane);
}
#[test]
fn builder_right_dir_sets_independent_right_pane() {
let left_dir = tempdir().expect("left tempdir");
let right_dir = tempdir().expect("right tempdir");
let dual = DualPane::builder(left_dir.path().to_path_buf())
.right_dir(right_dir.path().to_path_buf())
.build();
assert_eq!(dual.left.current_dir, left_dir.path());
assert_eq!(dual.right.current_dir, right_dir.path());
assert_ne!(dual.left.current_dir, dual.right.current_dir);
}
#[test]
fn builder_without_right_dir_mirrors_left() {
let dir = tempdir().expect("tempdir");
let dual = DualPane::builder(dir.path().to_path_buf()).build();
assert_eq!(dual.left.current_dir, dual.right.current_dir);
}
#[test]
fn builder_show_hidden_applies_to_both_panes() {
let dir = tempdir().expect("tempdir");
let dual = DualPane::builder(dir.path().to_path_buf())
.show_hidden(true)
.build();
assert!(dual.left.show_hidden);
assert!(dual.right.show_hidden);
}
#[test]
fn builder_sort_mode_applies_to_both_panes() {
let dir = tempdir().expect("tempdir");
let dual = DualPane::builder(dir.path().to_path_buf())
.sort_mode(SortMode::SizeDesc)
.build();
assert_eq!(dual.left.sort_mode, SortMode::SizeDesc);
assert_eq!(dual.right.sort_mode, SortMode::SizeDesc);
}
#[test]
fn builder_extension_filter_applies_to_both_panes() {
let dir = tempdir().expect("tempdir");
fs::write(dir.path().join("a.rs"), b"fn main(){}").unwrap();
let dual = DualPane::builder(dir.path().to_path_buf())
.allow_extension("rs")
.build();
assert_eq!(dual.left.extension_filter, vec!["rs"]);
assert_eq!(dual.right.extension_filter, vec!["rs"]);
}
#[test]
fn builder_allow_extension_accumulates() {
let dir = tempdir().expect("tempdir");
let dual = DualPane::builder(dir.path().to_path_buf())
.allow_extension("rs")
.allow_extension("toml")
.build();
assert!(dual.left.extension_filter.contains(&"rs".to_string()));
assert!(dual.left.extension_filter.contains(&"toml".to_string()));
}
#[test]
fn active_returns_left_by_default() {
let dual = make_dual();
assert_eq!(dual.active().current_dir, dual.left.current_dir);
}
#[test]
fn active_returns_right_after_focus_switch() {
let mut dual = make_dual();
dual.active_side = DualPaneActive::Right;
assert_eq!(dual.active().current_dir, dual.right.current_dir);
}
#[test]
fn active_mut_returns_left_by_default() {
let mut dual = make_dual();
let dir = dual.active_mut().current_dir.clone();
assert_eq!(dir, dual.left.current_dir);
}
#[test]
fn inactive_returns_right_when_left_is_active() {
let dual = make_dual();
assert_eq!(dual.inactive().current_dir, dual.right.current_dir);
}
#[test]
fn inactive_returns_left_when_right_is_active() {
let mut dual = make_dual();
dual.active_side = DualPaneActive::Right;
assert_eq!(dual.inactive().current_dir, dual.left.current_dir);
}
#[test]
fn focus_left_sets_active_to_left() {
let mut dual = make_dual();
dual.active_side = DualPaneActive::Right;
dual.focus_left();
assert_eq!(dual.active_side, DualPaneActive::Left);
}
#[test]
fn focus_right_sets_active_to_right() {
let mut dual = make_dual();
dual.focus_right();
assert_eq!(dual.active_side, DualPaneActive::Right);
}
#[test]
fn tab_switches_active_pane_from_left_to_right() {
let mut dual = make_dual();
assert_eq!(dual.active_side, DualPaneActive::Left);
let outcome = dual.handle_key(key(KeyCode::Tab));
assert_eq!(outcome, DualPaneOutcome::Pending);
assert_eq!(dual.active_side, DualPaneActive::Right);
}
#[test]
fn tab_switches_active_pane_from_right_to_left() {
let mut dual = make_dual();
dual.active_side = DualPaneActive::Right;
dual.handle_key(key(KeyCode::Tab));
assert_eq!(dual.active_side, DualPaneActive::Left);
}
#[test]
fn tab_returns_pending() {
let mut dual = make_dual();
assert_eq!(dual.handle_key(key(KeyCode::Tab)), DualPaneOutcome::Pending);
}
#[test]
fn w_key_toggles_single_pane_on() {
let mut dual = make_dual();
assert!(!dual.single_pane);
let outcome = dual.handle_key(key(KeyCode::Char('w')));
assert_eq!(outcome, DualPaneOutcome::Pending);
assert!(dual.single_pane);
}
#[test]
fn w_key_toggles_single_pane_off() {
let mut dual = make_dual();
dual.single_pane = true;
dual.handle_key(key(KeyCode::Char('w')));
assert!(!dual.single_pane);
}
#[test]
fn w_key_returns_pending() {
let mut dual = make_dual();
assert_eq!(
dual.handle_key(key(KeyCode::Char('w'))),
DualPaneOutcome::Pending
);
}
#[test]
fn navigation_keys_forwarded_to_active_pane() {
let mut dual = make_dual();
let before = dual.left.cursor;
dual.handle_key(key(KeyCode::Down));
let after = dual.left.cursor;
assert!(after >= before);
}
#[test]
fn esc_returns_dismissed() {
let mut dual = make_dual();
assert_eq!(
dual.handle_key(key(KeyCode::Esc)),
DualPaneOutcome::Dismissed
);
}
#[test]
fn q_key_returns_dismissed() {
let mut dual = make_dual();
assert_eq!(
dual.handle_key(key(KeyCode::Char('q'))),
DualPaneOutcome::Dismissed
);
}
#[test]
fn unrecognised_key_returns_unhandled() {
let mut dual = make_dual();
assert_eq!(
dual.handle_key(key(KeyCode::F(5))),
DualPaneOutcome::Unhandled
);
}
#[test]
fn enter_on_file_returns_selected() {
let dir = tempdir().expect("tempdir");
fs::write(dir.path().join("pick.txt"), b"hello").unwrap();
let mut dual = DualPane::builder(dir.path().to_path_buf()).build();
let outcome = dual.handle_key(key(KeyCode::Enter));
assert!(
matches!(outcome, DualPaneOutcome::Selected(_)),
"expected Selected, got {outcome:?}"
);
}
#[test]
fn tab_does_not_affect_inactive_pane_cursor() {
let dir = tempdir().expect("tempdir");
fs::write(dir.path().join("a.txt"), b"a").unwrap();
fs::write(dir.path().join("b.txt"), b"b").unwrap();
let mut dual = DualPane::builder(dir.path().to_path_buf()).build();
dual.handle_key(key(KeyCode::Down));
let left_cursor_before_tab = dual.left.cursor;
dual.handle_key(key(KeyCode::Tab));
assert_eq!(dual.left.cursor, left_cursor_before_tab);
}
#[test]
fn navigation_after_tab_affects_right_pane() {
let dir = tempdir().expect("tempdir");
fs::write(dir.path().join("a.txt"), b"a").unwrap();
fs::write(dir.path().join("b.txt"), b"b").unwrap();
let mut dual = DualPane::builder(dir.path().to_path_buf()).build();
dual.handle_key(key(KeyCode::Tab));
let right_before = dual.right.cursor;
dual.handle_key(key(KeyCode::Down));
let right_after = dual.right.cursor;
assert_eq!(dual.left.cursor, 0, "left pane cursor should not move");
assert!(
right_after >= right_before,
"right pane cursor should have advanced"
);
}
#[test]
fn toggle_single_pane_flips_state() {
let mut dual = make_dual();
assert!(!dual.single_pane);
dual.toggle_single_pane();
assert!(dual.single_pane);
dual.toggle_single_pane();
assert!(!dual.single_pane);
}
#[test]
fn reload_both_picks_up_new_files() {
let dir = tempdir().expect("tempdir");
let mut dual = DualPane::builder(dir.path().to_path_buf()).build();
assert!(dual.left.entries.is_empty());
assert!(dual.right.entries.is_empty());
fs::write(dir.path().join("new.txt"), b"hi").unwrap();
dual.reload_both();
assert!(!dual.left.entries.is_empty(), "left should see new file");
assert!(!dual.right.entries.is_empty(), "right should see new file");
}
}