use std::path::{Path, PathBuf};
use ratatui::crossterm::event::{KeyCode, KeyEvent};
use crate::app::{
App, DockKind, FileMenu, FileMenuItem, FilePrompt, FilePromptKind, Mode, Tab, ViewKind,
FILE_NAME_MAX,
};
use crate::event::AppEvent;
use crate::files::FileView;
use crate::ids::PaneId;
use crate::layout::{Axis, TileLayout};
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum OpenTarget {
Preview,
Pane,
Tab,
}
impl App {
pub fn ensure_file_tree(&mut self) {
if self.sidebars.side_of(&DockKind::Files).is_none() {
return;
}
let cwd = self.ws().cwd.clone();
self.file_tree.set_root(cwd);
self.load_pending_dirs();
self.rescan_file_tree();
self.refresh_git_status();
}
fn rescan_file_tree(&mut self) {
if std::time::Instant::now().duration_since(self.last_file_scan_at)
< std::time::Duration::from_millis(1500)
{
return;
}
self.last_file_scan_at = std::time::Instant::now();
let dirs = self.file_tree.loaded_visible_dirs();
if dirs.is_empty() {
return;
}
let tx = self.app_tx.clone();
std::thread::spawn(move || {
for path in dirs {
let entries = crate::files::read_dir_entries(&path);
let _ = tx.send(AppEvent::DirRead { path, entries });
}
});
}
fn refresh_git_status(&mut self) {
if self.git_status_inflight
|| std::time::Instant::now().duration_since(self.last_git_status_at)
< std::time::Duration::from_secs(2)
{
return;
}
self.last_git_status_at = std::time::Instant::now();
self.git_status_inflight = true;
let root = self.file_tree.root().to_path_buf();
let tx = self.app_tx.clone();
std::thread::spawn(move || {
let map = crate::git::local::tree_status(&root);
let _ = tx.send(AppEvent::FileGitStatus(map));
});
}
pub fn resolve_file_path(&self, raw: &str) -> PathBuf {
let p = PathBuf::from(raw);
if p.is_absolute() {
p
} else {
self.ws().cwd.join(p)
}
}
pub fn ensure_file_views(&mut self) {
if self.views.is_empty() {
return;
}
let mut stale = Vec::new();
for (id, ViewKind::File(v)) in self.views.iter() {
let disk = std::fs::metadata(&v.path).and_then(|m| m.modified()).ok();
if disk.is_some() && disk != v.mtime {
stale.push((*id, v.path.clone(), disk));
}
}
for (id, path, mtime) in stale {
if let Some(ViewKind::File(v)) = self.views.get_mut(&id) {
v.mtime = mtime; }
self.schedule_file_read(id, path);
}
}
pub fn toggle_files_dock(&mut self) {
if self.sidebars.side_of(&DockKind::Files).is_some() {
self.unmount_dock(&DockKind::Files);
} else {
self.sidebars.left.docks.push(DockKind::Files);
self.sidebars.left.visible = true;
self.save_sidebars();
}
}
pub fn file_row_activate(&mut self, index: usize, target: OpenTarget) {
let Some(row) = self.file_tree.visible_rows().get(index).cloned() else {
return;
};
if row.is_dir {
self.file_tree.toggle(&row.path);
self.load_pending_dirs();
} else {
self.open_file_view(row.path, target);
}
}
fn load_pending_dirs(&mut self) {
for path in self.file_tree.needs_load() {
self.file_tree.mark_pending(path.clone());
let tx = self.app_tx.clone();
std::thread::spawn(move || {
let entries = crate::files::read_dir_entries(&path);
let _ = tx.send(AppEvent::DirRead { path, entries });
});
}
}
pub fn open_file_menu(&mut self, index: usize, col: u16, row: u16) {
if let Some(r) = self.file_tree.visible_rows().get(index).cloned() {
self.file_menu = Some(FileMenu {
path: r.path,
is_dir: r.is_dir,
anchor: (col, row),
items: Vec::new(),
});
}
}
pub fn file_menu_click(&mut self, col: u16, row: u16) {
let hit = self.file_menu.as_ref().and_then(|m| {
m.items
.iter()
.find(|(_, r)| col >= r.x && col < r.right() && row >= r.y && row < r.bottom())
.map(|(it, _)| *it)
});
match hit {
Some(FileMenuItem::Divider) => {}
Some(it) => self.file_menu_action(it),
None => self.file_menu = None,
}
}
#[cfg(test)]
pub fn file_menu_action_pub(&mut self, item: FileMenuItem) {
self.file_menu_action(item);
}
fn file_menu_action(&mut self, item: FileMenuItem) {
let Some(menu) = self.file_menu.take() else {
return;
};
let dir = if menu.is_dir {
menu.path.clone()
} else {
menu.path
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| menu.path.clone())
};
let prompt = |kind, dir, target, buffer| {
Some(FilePrompt {
kind,
dir,
target,
buffer,
error: None,
})
};
match item {
FileMenuItem::NewFile => {
self.file_prompt = prompt(FilePromptKind::NewFile, dir, None, String::new())
}
FileMenuItem::NewFolder => {
self.file_prompt = prompt(FilePromptKind::NewFolder, dir, None, String::new())
}
FileMenuItem::Rename => {
let parent = menu
.path
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| menu.path.clone());
let name = menu
.path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
self.file_prompt = prompt(
FilePromptKind::Rename,
parent,
Some(menu.path.clone()),
name,
)
}
FileMenuItem::CopyPath => {
self.pending_clipboard = Some(menu.path.to_string_lossy().into_owned());
self.show_toast("copied path");
}
FileMenuItem::Delete => self.file_delete = Some(menu.path),
FileMenuItem::Divider => {}
}
}
pub fn file_prompt_key(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Esc => self.file_prompt = None,
KeyCode::Enter => self.commit_file_prompt(),
KeyCode::Backspace => {
if let Some(p) = self.file_prompt.as_mut() {
p.buffer.pop();
p.error = None;
}
}
KeyCode::Char(c) => {
if let Some(p) = self.file_prompt.as_mut() {
if p.buffer.chars().count() < FILE_NAME_MAX {
p.buffer.push(c);
p.error = None;
}
}
}
_ => {}
}
}
fn commit_file_prompt(&mut self) {
let Some(p) = self.file_prompt.as_ref() else {
return;
};
let name = p.buffer.trim().to_string();
if name.is_empty() {
return;
}
if name.contains(['/', '\\']) || name == ".." || name == "." {
if let Some(pr) = self.file_prompt.as_mut() {
pr.error = Some("name can't contain a path".into());
}
return;
}
let dest = p.dir.join(&name);
let (kind, target) = (p.kind, p.target.clone());
let result = match kind {
FilePromptKind::NewFile => {
if dest.exists() {
Err(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
"already exists",
))
} else {
std::fs::write(&dest, b"")
}
}
FilePromptKind::NewFolder => std::fs::create_dir(&dest),
FilePromptKind::Rename => std::fs::rename(target.as_ref().unwrap(), &dest),
};
match result {
Ok(()) => {
self.file_prompt = None;
self.after_fs_change(&dest);
self.show_toast(match kind {
FilePromptKind::Rename => "renamed",
_ => "created",
});
}
Err(e) => {
if let Some(pr) = self.file_prompt.as_mut() {
pr.error = Some(e.to_string());
}
}
}
}
pub fn file_delete_key(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => self.confirm_delete(),
_ => self.file_delete = None,
}
}
fn confirm_delete(&mut self) {
let Some(path) = self.file_delete.take() else {
return;
};
let result = if path.is_dir() {
std::fs::remove_dir_all(&path)
} else {
std::fs::remove_file(&path)
};
match result {
Ok(()) => {
self.after_fs_change(&path);
self.show_toast("deleted");
}
Err(e) => self.show_toast(format!("delete failed: {e}")),
}
}
fn after_fs_change(&mut self, path: &Path) {
self.file_tree.invalidate();
self.load_pending_dirs();
self.file_tree.reveal(path);
self.last_git_status_at = std::time::Instant::now()
.checked_sub(std::time::Duration::from_secs(10))
.unwrap_or_else(std::time::Instant::now);
self.refresh_git_status();
}
fn view_showing(&self, path: &std::path::Path) -> Option<PaneId> {
self.views
.iter()
.find_map(|(id, ViewKind::File(v))| (v.path == path).then_some(*id))
}
pub fn open_file_view(&mut self, path: PathBuf, target: OpenTarget) {
if let Some(id) = self.view_showing(&path) {
self.focus_pane_global(id);
return;
}
if target == OpenTarget::Preview {
if let Some(id) = self.preview_view.filter(|id| self.views.contains_key(id)) {
self.set_view_file(id, path);
self.focus_pane_global(id);
return;
}
}
let id = PaneId::alloc();
self.views
.insert(id, ViewKind::File(FileView::new(path.clone())));
match target {
OpenTarget::Tab => {
let ws = &mut self.workspaces[self.active_ws];
ws.tabs.push(Tab::panes(TileLayout::new(id)));
ws.active_tab = ws.tabs.len() - 1;
}
OpenTarget::Preview | OpenTarget::Pane => {
self.layout_mut().split_focused(Axis::Col, id);
self.layout_mut().focus = id;
}
}
if target == OpenTarget::Preview {
self.preview_view = Some(id);
}
self.schedule_file_read(id, path);
self.mode = Mode::Normal;
}
fn set_view_file(&mut self, id: PaneId, path: PathBuf) {
if let Some(ViewKind::File(v)) = self.views.get_mut(&id) {
*v = FileView::new(path.clone());
}
self.schedule_file_read(id, path);
}
fn schedule_file_read(&mut self, id: PaneId, path: PathBuf) {
if let Some(ViewKind::File(v)) = self.views.get_mut(&id) {
v.mtime = std::fs::metadata(&path).and_then(|m| m.modified()).ok();
}
let tx = self.app_tx.clone();
std::thread::spawn(move || {
let load = crate::files::read_file(&path);
let _ = tx.send(AppEvent::FileRead { id, load });
});
}
pub fn copy_file_view(&mut self, id: PaneId) {
let text = match self.views.get(&id) {
Some(ViewKind::File(v)) => match &v.load {
crate::files::FileLoad::Text(lines) => Some(lines.join("\n")),
_ => None,
},
None => return,
};
match text {
Some(t) => {
self.pending_clipboard = Some(t);
let msg = self.catalog.copied;
self.show_toast(msg);
}
None => self.show_toast("nothing to copy"),
}
}
pub fn handle_file_key(&mut self, id: PaneId, key: KeyEvent) -> bool {
let viewport = self
.pane_content_rects
.iter()
.find(|(pid, _)| *pid == id)
.map(|(_, r)| r.height.saturating_sub(1) as usize)
.unwrap_or(20);
let Some(ViewKind::File(v)) = self.views.get_mut(&id) else {
return false;
};
if v.search.as_ref().is_some_and(|s| s.editing) {
match key.code {
KeyCode::Char(c) => v.search_push(c),
KeyCode::Backspace => v.search_backspace(),
KeyCode::Enter => {
v.search_commit();
v.search_step(true, viewport); }
KeyCode::Esc => v.search_cancel(),
_ => return false,
}
return true;
}
match key.code {
KeyCode::Char('j') | KeyCode::Down => v.scroll_by(1, viewport),
KeyCode::Char('k') | KeyCode::Up => v.scroll_by(-1, viewport),
KeyCode::Char('d') => v.scroll_by(viewport as i32 / 2, viewport),
KeyCode::Char('u') => v.scroll_by(-(viewport as i32) / 2, viewport),
KeyCode::PageDown | KeyCode::Char(' ') => v.scroll_by(viewport as i32, viewport),
KeyCode::PageUp => v.scroll_by(-(viewport as i32), viewport),
KeyCode::Char('g') | KeyCode::Home => v.goto_top(),
KeyCode::Char('G') | KeyCode::End => v.goto_bottom(viewport),
KeyCode::Char('h') | KeyCode::Left => v.scroll_right(-8),
KeyCode::Char('l') | KeyCode::Right => v.scroll_right(8),
KeyCode::Char('w') => v.wrap = !v.wrap,
KeyCode::Char('/') => v.search_begin(),
KeyCode::Char('n') => v.search_step(true, viewport),
KeyCode::Char('N') => v.search_step(false, viewport),
KeyCode::Char('y') | KeyCode::Char('c') => {
self.copy_file_view(id);
return true;
}
KeyCode::Char('q') => self.close_pane(id),
KeyCode::Esc => {
if v.search.is_some() {
v.search_cancel();
} else {
self.close_pane(id);
}
}
_ => return false,
}
true
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::DockKind;
use ratatui::{backend::TestBackend, Terminal};
fn buffer_text(term: &Terminal<TestBackend>) -> String {
let buf = term.backend().buffer();
(0..buf.area.height)
.map(|r| {
(0..buf.area.width)
.map(|c| buf.cell((c, r)).map(|x| x.symbol()).unwrap_or(" "))
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn files_dock_renders_and_a_click_expands() {
let _env = crate::persist::test_env("files-dock-render");
let root = std::env::temp_dir().join(format!("bohay-ft-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::write(root.join("src/mod.rs"), b"// hi").unwrap();
std::fs::write(root.join("README.md"), b"# hi").unwrap();
let (tx, _rx) = std::sync::mpsc::channel();
let mut app = App::new(120, 40, tx).unwrap();
app.workspaces[app.active_ws].cwd = root.clone();
app.sidebars.left.docks.push(DockKind::Files);
app.ensure_file_tree();
app.file_tree
.apply_dir(root.clone(), crate::files::read_dir_entries(&root));
let mut term = Terminal::new(TestBackend::new(120, 40)).unwrap();
term.draw(|f| crate::ui::render(f, &mut app)).unwrap();
let text = buffer_text(&term);
assert!(text.contains("FILES"), "header drawn");
assert!(text.contains("src"), "a folder row drawn");
assert!(text.contains("README.md"), "a file row drawn");
assert!(!text.contains("mod.rs"), "child hidden while collapsed");
let (idx, rect) = app
.file_tree_rects
.iter()
.find(|(i, _)| app.file_tree.visible_rows()[*i].name == "src")
.cloned()
.expect("src row has a rect");
assert!(app.file_tree.visible_rows()[idx].is_dir);
app.file_row_activate(idx, OpenTarget::Preview);
app.file_tree.apply_dir(
root.join("src"),
crate::files::read_dir_entries(&root.join("src")),
);
let _ = rect;
term.draw(|f| crate::ui::render(f, &mut app)).unwrap();
let text = buffer_text(&term);
assert!(text.contains("mod.rs"), "child visible after expanding src");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn file_view_pane_renders_scrolls_and_closes() {
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let _env = crate::persist::test_env("file-view-pane");
let dir = std::env::temp_dir().join(format!("bohay-fvp-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("code.rs");
let body: String = (1..=80).map(|i| format!("line number {i}\n")).collect();
std::fs::write(&file, body).unwrap();
let (tx, _rx) = std::sync::mpsc::channel();
let mut app = App::new(120, 40, tx).unwrap();
app.open_file_view(file.clone(), OpenTarget::Pane);
let vid = app.layout().focus;
assert!(
app.views.contains_key(&vid),
"a view leaf exists and is focused"
);
if let Some(ViewKind::File(v)) = app.views.get_mut(&vid) {
v.apply(crate::files::read_file(&file));
}
let mut term = Terminal::new(TestBackend::new(120, 40)).unwrap();
term.draw(|f| crate::ui::render(f, &mut app)).unwrap();
let text = buffer_text(&term);
assert!(text.contains("code.rs"), "the pane title shows the file");
assert!(text.contains("line number 1"), "first line rendered");
assert!(text.contains("80 lines"), "footer line count");
assert!(!text.contains("line number 80"), "bottom not visible yet");
app.handle_file_key(vid, KeyEvent::new(KeyCode::Char('G'), KeyModifiers::NONE));
term.draw(|f| crate::ui::render(f, &mut app)).unwrap();
assert!(
buffer_text(&term).contains("line number 80"),
"scrolled to end"
);
app.handle_file_key(vid, KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE));
assert!(!app.views.contains_key(&vid), "view leaf closed");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn open_view_live_refreshes_on_disk_change() {
let _env = crate::persist::test_env("file-live-refresh");
let dir = std::env::temp_dir().join(format!("bohay-lr-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("live.txt");
std::fs::write(&file, b"before\n").unwrap();
let (tx, rx) = std::sync::mpsc::channel();
let mut app = App::new(120, 40, tx).unwrap();
app.open_file_view(file.clone(), OpenTarget::Pane);
let vid = app.layout().focus;
let ev = rx
.recv_timeout(std::time::Duration::from_secs(2))
.expect("initial read");
app.handle_event(ev);
assert_eq!(
app.views.get(&vid).map(|ViewKind::File(v)| v.line_count()),
Some(1),
"initial content is one line"
);
std::fs::write(&file, b"after edit\nsecond line\n").unwrap();
filetime_set(&file, std::time::SystemTime::now());
app.ensure_file_views();
let ev = rx.recv_timeout(std::time::Duration::from_secs(3)).unwrap();
app.handle_event(ev);
if let Some(ViewKind::File(v)) = app.views.get(&vid) {
assert_eq!(v.line_count(), 2, "the view reloaded the edited file");
} else {
panic!("view gone");
}
let _ = std::fs::remove_dir_all(&dir);
}
fn filetime_set(path: &std::path::Path, _when: std::time::SystemTime) {
let cur = std::fs::metadata(path).and_then(|m| m.modified()).ok();
let data = std::fs::read(path).unwrap();
std::fs::write(path, &data).unwrap();
if std::fs::metadata(path).and_then(|m| m.modified()).ok() == cur {
std::thread::sleep(std::time::Duration::from_millis(1100));
std::fs::write(path, &data).unwrap();
}
}
#[test]
fn reopening_focuses_existing_and_copy_yanks_content() {
let _env = crate::persist::test_env("file-dedup-copy");
let dir = std::env::temp_dir().join(format!("bohay-dc-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("a.txt");
std::fs::write(&file, b"line one\nline two\n").unwrap();
let (tx, rx) = std::sync::mpsc::channel();
let mut app = App::new(120, 40, tx).unwrap();
app.open_file_view(file.clone(), OpenTarget::Tab);
let first = app.layout().focus;
let ev = rx.recv_timeout(std::time::Duration::from_secs(2)).unwrap();
app.handle_event(ev);
let tabs_before = app.workspaces[app.active_ws].tabs.len();
let views_before = app.views.len();
app.open_file_view(file.clone(), OpenTarget::Tab);
assert_eq!(
app.workspaces[app.active_ws].tabs.len(),
tabs_before,
"no duplicate tab"
);
assert_eq!(app.views.len(), views_before, "no duplicate view");
assert_eq!(app.layout().focus, first, "the existing view is focused");
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
app.handle_file_key(first, KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
assert_eq!(
app.pending_clipboard.as_deref(),
Some("line one\nline two"),
"the file content is queued to the clipboard"
);
assert!(app.toast.is_some(), "a copy toast is shown");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn mouse_drag_selects_and_copies_file_text() {
use crate::event::AppEvent;
use ratatui::crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
let _env = crate::persist::test_env("file-drag-copy");
let dir = std::env::temp_dir().join(format!("bohay-md-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("s.txt");
std::fs::write(&file, b"hello world\nsecond line\n").unwrap();
let (tx, rx) = std::sync::mpsc::channel();
let mut app = App::new(120, 40, tx).unwrap();
app.open_file_view(file.clone(), OpenTarget::Tab);
let vid = app.layout().focus;
let ev = rx.recv_timeout(std::time::Duration::from_secs(2)).unwrap();
app.handle_event(ev);
let mut term = ratatui::Terminal::new(ratatui::backend::TestBackend::new(120, 40)).unwrap();
term.draw(|f| crate::ui::render(f, &mut app)).unwrap();
let content = app
.pane_content_rects
.iter()
.find(|(id, _)| *id == vid)
.map(|(_, r)| *r)
.expect("the view has a content rect");
let gutter = crate::files::gutter_width(2);
let x0 = content.x + gutter + 1; let y = content.y; let down = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: x0,
row: y,
modifiers: KeyModifiers::NONE,
};
let drag = MouseEvent {
kind: MouseEventKind::Drag(MouseButton::Left),
column: x0 + 4, row: y,
modifiers: KeyModifiers::NONE,
};
let up = MouseEvent {
kind: MouseEventKind::Up(MouseButton::Left),
column: x0 + 4,
row: y,
modifiers: KeyModifiers::NONE,
};
app.handle_event(AppEvent::Mouse(down));
app.handle_event(AppEvent::Mouse(drag));
assert!(
app.selection.is_some(),
"a selection is built over the view"
);
app.handle_event(AppEvent::Mouse(up));
assert_eq!(
app.pending_clipboard.as_deref(),
Some("hello"),
"the dragged text was copied to the clipboard"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn file_tab_survives_restore() {
let _env = crate::persist::test_env("file-tab-restore");
let dir = std::env::temp_dir().join(format!("bohay-fvr-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("keep.txt");
std::fs::write(&file, b"persisted body\n").unwrap();
let (tx, _rx) = std::sync::mpsc::channel();
let mut app = App::new(120, 40, tx).unwrap();
app.open_file_view(file.clone(), OpenTarget::Tab);
let snap = crate::persist::snapshot(&app);
let (tx2, _rx2) = std::sync::mpsc::channel();
let restored = App::from_snapshot(snap, tx2).expect("restore");
let paths: Vec<_> = restored
.views
.values()
.map(|ViewKind::File(v)| v.path.clone())
.collect();
assert_eq!(paths, vec![file], "the file view was rebuilt on restore");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn file_view_frees_content_on_close() {
let _env = crate::persist::test_env("file-mem-free");
let dir = std::env::temp_dir().join(format!("bohay-mem-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("big.txt");
let body: String = (0..50_000).map(|i| format!("line {i}\n")).collect();
std::fs::write(&file, body).unwrap();
let (tx, _rx) = std::sync::mpsc::channel();
let mut app = App::new(80, 24, tx).unwrap();
app.open_file_view(file.clone(), OpenTarget::Tab);
let vid = app.layout().focus;
if let Some(ViewKind::File(v)) = app.views.get_mut(&vid) {
v.apply(crate::files::read_file(&file));
assert_eq!(v.line_count(), 50_000, "content held while open");
}
app.close_pane(vid);
assert!(
!app.views.contains_key(&vid),
"view (and its 50k lines) freed on close"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn set_line_dock_no_stale_tail_when_row_shortens() {
use ratatui::{backend::TestBackend, Terminal};
let _env = crate::persist::test_env("stale-tail");
let root = std::env::temp_dir().join(format!("bohay-st-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("VERYLONGFILENAME_abcdefghij.rs"), b"x").unwrap();
std::fs::write(root.join("z_short.rs"), b"x").unwrap();
let (tx, _rx) = std::sync::mpsc::channel();
let mut app = App::new(60, 20, tx).unwrap();
app.workspaces[app.active_ws].cwd = root.clone();
app.sidebars.left.docks.push(crate::app::DockKind::Files);
app.ensure_file_tree();
app.file_tree
.apply_dir(root.clone(), crate::files::read_dir_entries(&root));
let mut term = Terminal::new(TestBackend::new(60, 20)).unwrap();
term.draw(|f| crate::ui::render(f, &mut app)).unwrap();
let empty = root.join("sub");
std::fs::create_dir_all(&empty).unwrap();
std::fs::write(empty.join("z.rs"), b"x").unwrap();
app.workspaces[app.active_ws].cwd = empty.clone();
app.file_tree.set_root(empty.clone());
app.file_tree
.apply_dir(empty.clone(), crate::files::read_dir_entries(&empty));
term.draw(|f| crate::ui::render(f, &mut app)).unwrap();
let buf = term.backend().buffer();
let full: String = (0..buf.area.height)
.map(|r| {
(0..buf.area.width)
.map(|c| buf.cell((c, r)).map(|x| x.symbol()).unwrap_or(" "))
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
assert!(
!full.contains("VERYLONGFILENAME"),
"stale tail from the previous longer row leaked:\n{full}"
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn expanding_a_folder_loads_it_immediately() {
let _env = crate::persist::test_env("file-expand-now");
let root = std::env::temp_dir().join(format!("bohay-ex-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("sub")).unwrap();
std::fs::write(root.join("sub/inner.rs"), b"x").unwrap();
let (tx, rx) = std::sync::mpsc::channel();
let mut app = App::new(80, 24, tx).unwrap();
app.workspaces[app.active_ws].cwd = root.clone();
app.sidebars.left.docks.push(DockKind::Files);
app.ensure_file_tree();
let ev = rx.recv_timeout(std::time::Duration::from_secs(2)).unwrap();
app.handle_event(ev);
let idx = app
.file_tree
.visible_rows()
.iter()
.position(|r| r.name == "sub")
.expect("sub row");
app.file_row_activate(idx, OpenTarget::Tab);
let ev = rx
.recv_timeout(std::time::Duration::from_secs(2))
.expect("expand scheduled a read immediately");
app.handle_event(ev);
assert!(
app.file_tree
.visible_rows()
.iter()
.any(|r| r.name == "inner.rs"),
"the folder's contents loaded right after the click"
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn closing_a_file_tab_lets_it_reopen() {
let _env = crate::persist::test_env("file-reopen");
let dir = std::env::temp_dir().join(format!("bohay-reopen-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("r.txt");
std::fs::write(&file, b"body\n").unwrap();
let (tx, _rx) = std::sync::mpsc::channel();
let mut app = App::new(120, 40, tx).unwrap();
app.open_file_view(file.clone(), OpenTarget::Tab);
let vid = app.layout().focus;
assert!(app.views.contains_key(&vid), "the file view is open");
let file_tab = app.workspaces[app.active_ws].active_tab;
app.close_tab(file_tab);
assert!(
!app.views.contains_key(&vid),
"the view is forgotten when its tab is closed (no orphan)"
);
app.open_file_view(file.clone(), OpenTarget::Tab);
let vid2 = app.layout().focus;
assert!(app.views.contains_key(&vid2), "reopened into a fresh view");
assert_ne!(vid, vid2, "a new leaf, not the stale id");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn clicking_a_file_reopens_after_its_tab_is_closed() {
use crate::event::AppEvent;
use ratatui::crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
let _env = crate::persist::test_env("file-click-reopen");
let root = std::env::temp_dir().join(format!("bohay-cr-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("hello.txt"), b"hi\n").unwrap();
let (tx, rx) = std::sync::mpsc::channel();
let mut app = App::new(120, 40, tx).unwrap();
app.workspaces[app.active_ws].cwd = root.clone();
app.sidebars.left.docks.push(DockKind::Files);
app.ensure_file_tree();
while let Ok(ev) = rx.recv_timeout(std::time::Duration::from_millis(300)) {
app.handle_event(ev);
}
let mut term = Terminal::new(TestBackend::new(120, 40)).unwrap();
let click_file = |app: &mut App, term: &mut Terminal<TestBackend>| {
term.draw(|f| crate::ui::render(f, app)).unwrap();
let (_, rect) = app
.file_tree_rects
.iter()
.find(|(i, _)| app.file_tree.visible_rows()[*i].name == "hello.txt")
.cloned()
.expect("hello.txt has a clickable rect");
let down = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: rect.x + 3,
row: rect.y,
modifiers: KeyModifiers::NONE,
};
app.handle_event(AppEvent::Mouse(down));
};
click_file(&mut app, &mut term);
assert_eq!(app.views.len(), 1, "first click opened the file");
let first = app.layout().focus;
let file_tab = app.workspaces[app.active_ws].active_tab;
app.close_tab(file_tab);
assert_eq!(app.views.len(), 0, "closing the tab forgot the view");
click_file(&mut app, &mut term);
assert_eq!(
app.views.len(),
1,
"clicking the file again reopened it (the reported bug)"
);
assert_ne!(app.layout().focus, first, "reopened into a new leaf");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn long_line_wraps_and_shows_its_tail() {
let _env = crate::persist::test_env("file-wrap");
let dir = std::env::temp_dir().join(format!("bohay-wrap-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("long.md");
let body = "HEADWORD ".to_string() + &"filler ".repeat(20) + "TAILWORD";
std::fs::write(&file, &body).unwrap();
let (tx, rx) = std::sync::mpsc::channel();
let mut app = App::new(40, 20, tx).unwrap();
app.open_file_view(file.clone(), OpenTarget::Tab);
let vid = app.layout().focus;
let ev = rx.recv_timeout(std::time::Duration::from_secs(2)).unwrap();
app.handle_event(ev);
assert!(
matches!(
app.views.get(&vid),
Some(ViewKind::File(v)) if v.wrap
),
"the view opened wrapped"
);
let mut term = Terminal::new(TestBackend::new(40, 20)).unwrap();
term.draw(|f| crate::ui::render(f, &mut app)).unwrap();
let text = buffer_text(&term);
assert!(text.contains("HEADWORD"), "the head of the line renders");
assert!(
text.contains("TAILWORD"),
"the tail wraps onto a later row and is visible, not clipped:\n{text}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn external_new_file_is_picked_up_by_rescan() {
let _env = crate::persist::test_env("file-external-add");
let root = std::env::temp_dir().join(format!("bohay-ext-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("existing.rs"), b"x").unwrap();
let (tx, rx) = std::sync::mpsc::channel();
let mut app = App::new(80, 30, tx).unwrap();
app.workspaces[app.active_ws].cwd = root.clone();
app.sidebars.left.docks.push(DockKind::Files);
app.ensure_file_tree();
while let Ok(ev) = rx.recv_timeout(std::time::Duration::from_millis(300)) {
app.handle_event(ev);
}
assert!(
app.file_tree
.visible_rows()
.iter()
.any(|r| r.name == "existing.rs"),
"the initial file is in the tree"
);
app.last_file_scan_at = std::time::Instant::now()
.checked_sub(std::time::Duration::from_secs(10))
.unwrap();
app.ensure_file_tree();
while let Ok(ev) = rx.recv_timeout(std::time::Duration::from_millis(300)) {
app.handle_event(ev);
}
std::fs::write(root.join("dropped.rs"), b"y").unwrap();
app.last_file_scan_at = std::time::Instant::now()
.checked_sub(std::time::Duration::from_secs(10))
.unwrap();
app.ensure_file_tree();
while let Ok(ev) = rx.recv_timeout(std::time::Duration::from_millis(300)) {
app.handle_event(ev);
}
assert!(
app.file_tree
.visible_rows()
.iter()
.any(|r| r.name == "dropped.rs"),
"the externally-created file showed up after a rescan"
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn file_menu_crud_creates_renames_deletes() {
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let _env = crate::persist::test_env("file-crud");
let root = std::env::temp_dir().join(format!("bohay-crud-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::write(root.join("src/old.rs"), b"x").unwrap();
let (tx, rx) = std::sync::mpsc::channel();
let mut app = App::new(80, 30, tx).unwrap();
app.workspaces[app.active_ws].cwd = root.clone();
app.sidebars.left.docks.push(DockKind::Files);
app.ensure_file_tree();
while let Ok(ev) = rx.recv_timeout(std::time::Duration::from_millis(300)) {
app.handle_event(ev);
}
let src_idx = app
.file_tree
.visible_rows()
.iter()
.position(|r| r.name == "src")
.unwrap();
app.file_row_activate(src_idx, OpenTarget::Tab); while let Ok(ev) = rx.recv_timeout(std::time::Duration::from_millis(300)) {
app.handle_event(ev);
}
let typ = |app: &mut App, s: &str| {
for c in s.chars() {
app.file_prompt_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
}
};
let src_idx = app
.file_tree
.visible_rows()
.iter()
.position(|r| r.name == "src")
.unwrap();
app.open_file_menu(src_idx, 5, 5);
app.file_menu_action_pub(crate::app::FileMenuItem::NewFile);
typ(&mut app, "created.rs");
app.file_prompt_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(
root.join("src/created.rs").exists(),
"new file created on disk"
);
while let Ok(ev) = rx.recv_timeout(std::time::Duration::from_millis(300)) {
app.handle_event(ev);
}
let old_idx = app
.file_tree
.visible_rows()
.iter()
.position(|r| r.name == "old.rs")
.unwrap();
app.open_file_menu(old_idx, 5, 6);
app.file_menu_action_pub(crate::app::FileMenuItem::Rename);
for _ in 0..20 {
app.file_prompt_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
}
typ(&mut app, "new.rs");
app.file_prompt_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(
root.join("src/new.rs").exists() && !root.join("src/old.rs").exists(),
"renamed"
);
while let Ok(ev) = rx.recv_timeout(std::time::Duration::from_millis(300)) {
app.handle_event(ev);
}
let c_idx = app
.file_tree
.visible_rows()
.iter()
.position(|r| r.name == "created.rs")
.unwrap();
app.open_file_menu(c_idx, 5, 7);
app.file_menu_action_pub(crate::app::FileMenuItem::Delete);
app.file_delete_key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
assert!(!root.join("src/created.rs").exists(), "deleted");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn delete_needs_confirmation() {
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let _env = crate::persist::test_env("file-del-guard");
let root = std::env::temp_dir().join(format!("bohay-dg-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
let file = root.join("keep.rs");
std::fs::write(&file, b"x").unwrap();
let (tx, rx) = std::sync::mpsc::channel();
let mut app = App::new(80, 24, tx).unwrap();
app.workspaces[app.active_ws].cwd = root.clone();
app.sidebars.left.docks.push(DockKind::Files);
app.ensure_file_tree();
while let Ok(ev) = rx.recv_timeout(std::time::Duration::from_millis(300)) {
app.handle_event(ev);
}
let idx = app
.file_tree
.visible_rows()
.iter()
.position(|r| r.name == "keep.rs")
.unwrap();
app.open_file_menu(idx, 5, 5);
app.file_menu_action_pub(crate::app::FileMenuItem::Delete);
assert!(app.file_delete.is_some(), "the confirm modal is armed");
assert!(
file.exists(),
"nothing deleted yet — waiting on confirmation"
);
app.file_delete_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(
app.file_delete.is_none() && file.exists(),
"cancel keeps the file"
);
app.open_file_menu(idx, 5, 5);
app.file_menu_action_pub(crate::app::FileMenuItem::Delete);
app.file_delete_key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
assert!(!file.exists(), "confirmed delete removes it");
let _ = std::fs::remove_dir_all(&root);
}
}