use super::{Prompt, Tree, TreeItem, TreeOp}; use crate::{ compositor::{Component, Compositor, Context, EventResult}, ctrl, key, shift, ui, }; use anyhow::{bail, ensure, Result}; use helix_core::Position; use helix_view::{ editor::Action, graphics::{CursorKind, Modifier, Rect}, input::{Event, KeyEvent}, Editor, }; use std::borrow::Cow; use std::cmp::Ordering; use std::path::{Path, PathBuf}; use tui::{ buffer::Buffer as Surface, text::{Span, Spans}, widgets::{Block, Borders, Widget}, }; macro_rules! get_theme { ($theme: expr, $s1: expr, $s2: expr) => { $theme.try_get($s1).unwrap_or_else(|| $theme.get($s2)) }; } const ICONS: &'static [&'static str] = &["", "", "", "", "", "ﰟ", "", "", "", "ﯤ", "", "ﬥ"]; const ICONS_EXT: &'static [&'static str] = &[ ".rs", ".md", ".js", ".c", ".png", ".svg", ".css", ".html", ".lua", ".ts", ".py", ".json", ]; const ICONS_COLORS: &'static [helix_view::theme::Color] = &[ helix_view::theme::Color::Rgb(227, 134, 84), helix_view::theme::Color::LightCyan, helix_view::theme::Color::Yellow, helix_view::theme::Color::Blue, helix_view::theme::Color::Yellow, helix_view::theme::Color::Yellow, helix_view::theme::Color::Green, helix_view::theme::Color::Blue, helix_view::theme::Color::Red, helix_view::theme::Color::Blue, helix_view::theme::Color::Red, ]; #[derive(Debug, Clone, Copy, PartialEq)] enum FileType { File, Dir, Exe, Placeholder, Parent, Root, } #[derive(Debug, Clone)] struct FileInfo { file_type: FileType, expanded: bool, path: PathBuf, } impl FileInfo { fn new(path: PathBuf, file_type: FileType) -> Self { Self { path, file_type, expanded: false, } } fn root(path: PathBuf) -> Self { Self { file_type: FileType::Root, path, expanded: true, } } fn parent(path: &Path) -> Self { let p = path.parent().unwrap_or_else(|| Path::new("")); Self { file_type: FileType::Parent, path: p.to_path_buf(), expanded: false, } } fn get_text(&self) -> Cow<'static, str> { match self.file_type { FileType::Parent => "..".into(), FileType::Placeholder => "---".into(), FileType::Root => { if let Some(path) = self.path.iter().last() { format!("- {} -", path.to_string_lossy()).into() } else { Cow::from("/") } } FileType::File | FileType::Exe | FileType::Dir => self .path .file_name() .map_or("/".into(), |p| p.to_string_lossy().into_owned().into()), } } } impl TreeItem for FileInfo { type Params = State; fn text(&self, cx: &mut Context, selected: bool, state: &mut State) -> Spans { let text = self.get_text(); let theme = &cx.editor.theme; let style = match self.file_type { FileType::Parent | FileType::Dir | FileType::Root => "ui.explorer.dir", FileType::File | FileType::Exe | FileType::Placeholder => "ui.explorer.file", }; let mut style = theme.try_get(style).unwrap_or_else(|| theme.get("ui.text")); if selected { let patch = match state.focus { true => "ui.explorer.focus", false => "ui.explorer.unfocus", }; if let Some(patch) = theme.try_get(patch) { style = style.patch(patch); } else { style = style.add_modifier(Modifier::REVERSED); } } Spans::from(Span::styled(text, style)) } fn is_child(&self, other: &Self) -> bool { if let FileType::Parent = other.file_type { return false; } if let FileType::Placeholder = self.file_type { self.path == other.path } else { self.path.parent().map_or(false, |p| p == other.path) } } fn cmp(&self, other: &Self) -> Ordering { use FileType::*; match (self.file_type, other.file_type) { (Parent, _) => return Ordering::Less, (_, Parent) => return Ordering::Greater, (Root, _) => return Ordering::Less, (_, Root) => return Ordering::Greater, _ => {} }; if self.path == other.path { match (self.file_type, other.file_type) { (_, Placeholder) => return Ordering::Less, (Placeholder, _) => return Ordering::Greater, _ => {} }; } if let (Some(p1), Some(p2)) = (self.path.parent(), other.path.parent()) { if p1 == p2 { match (self.file_type, other.file_type) { (Dir, File | Exe) => return Ordering::Less, (File | Exe, Dir) => return Ordering::Greater, _ => {} }; } } self.path.cmp(&other.path) } fn get_childs(&self) -> Result> { match self.file_type { FileType::Root | FileType::Dir => {} _ => return Ok(vec![]), }; let mut ret: Vec<_> = std::fs::read_dir(&self.path)? .filter_map(|entry| entry.ok()) .filter_map(|entry| { entry.metadata().ok().map(|meta| { let is_exe = false; let file_type = match (meta.is_dir(), is_exe) { (true, _) => FileType::Dir, (_, false) => FileType::File, (_, true) => FileType::Exe, }; Self { file_type, path: self.path.join(entry.file_name()), expanded: false, } }) }) .collect(); if ret.is_empty() { ret.push(Self { path: self.path.clone(), file_type: FileType::Placeholder, expanded: false, }) } Ok(ret) } fn filter(&self, _cx: &mut Context, s: &str, _params: &mut Self::Params) -> bool { if s.is_empty() { false } else { self.get_text().contains(s) } } fn icon(&self) -> Option<(&'static str, &'static helix_view::theme::Color)> { return match self.file_type { FileType::Dir => { if self.expanded { Some(("", &helix_view::theme::Color::Yellow)) } else { Some(("", &helix_view::theme::Color::Yellow)) } } FileType::File => { for (i, ext) in ICONS_EXT.iter().enumerate() { if self.get_text().ends_with(ext) { let color = ICONS_COLORS .iter() .nth(i) .unwrap_or(&helix_view::theme::Color::Blue); return ICONS.iter().nth(i).map(|c| (*c, color)); } } return Some(("", &helix_view::theme::Color::LightBlue)); } _ => None, }; } } #[derive(Clone, Copy, Debug)] enum PromptAction { Search(bool), // search next/search pre Mkdir, CreateFile, RemoveDir, RemoveFile, Filter, } #[derive(Clone, Debug)] struct State { focus: bool, current_root: PathBuf, } impl State { fn new(focus: bool, current_root: PathBuf) -> Self { Self { focus, current_root, } } } pub struct Explorer { tree: Tree, state: State, prompt: Option<(PromptAction, Prompt)>, #[allow(clippy::type_complexity)] on_next_key: Option EventResult>>, #[allow(clippy::type_complexity)] repeat_motion: Option>, } impl Explorer { pub fn new(cx: &mut Context) -> Result { let current_root = std::env::current_dir().unwrap_or_else(|_| "./".into()); let items = Self::get_items(current_root.clone(), cx)?; Ok(Self { tree: Tree::build_tree(items) .with_enter_fn(Self::toggle_current) .with_folded_fn(Self::fold_current), state: State::new(true, current_root), repeat_motion: None, prompt: None, on_next_key: None, }) } pub fn set_selection(&mut self, path: &Path) { let info = if path.is_file() { FileInfo::new(path.into(), FileType::File) } else { FileInfo::new(path.into(), FileType::Dir) }; self.tree.select(&info); self.tree.save_view(); } pub fn new_explorer_recursion() -> Result { let current_root = std::env::current_dir().unwrap_or_else(|_| "./".into()); let parent = FileInfo::parent(¤t_root); let root = FileInfo::root(current_root.clone()); let mut tree = Tree::build_from_root(root, usize::MAX / 2)? .with_enter_fn(Self::toggle_current) .with_folded_fn(Self::fold_current); tree.insert_current_level(parent); Ok(Self { tree, state: State::new(true, current_root), repeat_motion: None, prompt: None, on_next_key: None, }) } pub fn focus(&mut self) { self.state.focus = true } pub fn unfocus(&mut self) { self.state.focus = false; } pub fn is_focus(&self) -> bool { self.state.focus } fn get_items(p: PathBuf, cx: &mut Context) -> Result> { let mut items = Vec::new(); let root = FileInfo::root(p); let childs = root.get_childs()?; if cx.editor.config().explorer.is_tree() { items.push(root) } items.extend(childs); Ok(items) } fn render_preview(&mut self, area: Rect, surface: &mut Surface, editor: &Editor) { if area.height <= 2 || area.width < 60 { return; } let item = self.tree.current().item(); if item.file_type == FileType::Placeholder { return; } let head_area = render_block( area.clip_bottom(area.height - 2), surface, Borders::BOTTOM, None, ); let path_str = format!("{}", item.path.display()); surface.set_stringn( head_area.x, head_area.y, path_str, head_area.width as usize, get_theme!(editor.theme, "ui.explorer.dir", "ui.text"), ); let body_area = area.clip_top(2); let style = editor.theme.get("ui.text"); if let Ok(preview_content) = get_preview(&item.path, body_area.height as usize) { preview_content .into_iter() .enumerate() .for_each(|(row, line)| { surface.set_stringn( body_area.x, body_area.y + row as u16, line, body_area.width as usize, style, ); }) } } fn new_search_prompt(&mut self, search_next: bool) { self.tree.save_view(); self.prompt = Some(( PromptAction::Search(search_next), Prompt::new("search: ".into(), None, ui::completers::none, |_, _, _| {}), )) } fn new_filter_prompt(&mut self) { self.tree.save_view(); self.prompt = Some(( PromptAction::Filter, Prompt::new("filter: ".into(), None, ui::completers::none, |_, _, _| {}), )) } fn new_mkdir_prompt(&mut self) { self.prompt = Some(( PromptAction::Mkdir, Prompt::new("mkdir: ".into(), None, ui::completers::none, |_, _, _| {}), )); } fn new_create_file_prompt(&mut self) { self.prompt = Some(( PromptAction::CreateFile, Prompt::new( "create file: ".into(), None, ui::completers::none, |_, _, _| {}, ), )); } fn new_remove_file_prompt(&mut self, cx: &mut Context) { let item = self.tree.current_item(); let check = || { ensure!(item.file_type != FileType::Placeholder, "The path is empty"); ensure!( item.file_type != FileType::Parent, "can not remove parent dir" ); ensure!(item.path.is_file(), "The path is not a file"); let doc = cx.editor.document_by_path(&item.path); ensure!(doc.is_none(), "The file is opened"); Ok(()) }; if let Err(e) = check() { cx.editor.set_error(format!("{e}")); return; } let p = format!("remove file: {}, YES? ", item.path.display()); self.prompt = Some(( PromptAction::RemoveFile, Prompt::new(p.into(), None, ui::completers::none, |_, _, _| {}), )); } fn new_remove_dir_prompt(&mut self, cx: &mut Context) { let item = self.tree.current_item(); let check = || { ensure!(item.file_type != FileType::Placeholder, "The path is empty"); ensure!( item.file_type != FileType::Parent, "can not remove parent dir" ); ensure!(item.path.is_dir(), "The path is not a dir"); let doc = cx.editor.documents().find(|doc| { doc.path() .map(|p| p.starts_with(&item.path)) .unwrap_or(false) }); ensure!(doc.is_none(), "There are files opened under the dir"); Ok(()) }; if let Err(e) = check() { cx.editor.set_error(format!("{e}")); return; } let p = format!("remove dir: {}, YES? ", item.path.display()); self.prompt = Some(( PromptAction::RemoveDir, Prompt::new(p.into(), None, ui::completers::none, |_, _, _| {}), )); } fn fold_current(item: &mut FileInfo, _cx: &mut Context, _state: &mut State) { if item.path.is_dir() && item.file_type != FileType::Root { item.expanded = false; } } fn toggle_current( item: &mut FileInfo, cx: &mut Context, state: &mut State, ) -> TreeOp { if item.file_type == FileType::Placeholder { return TreeOp::Noop; } if item.path == Path::new("") { return TreeOp::Noop; } let meta = match std::fs::metadata(&item.path) { Ok(meta) => meta, Err(e) => { cx.editor.set_error(format!("{e}")); return TreeOp::Noop; } }; if meta.is_file() { if let Err(e) = cx.editor.open(&item.path.clone(), Action::Replace) { cx.editor.set_error(format!("{e}")); } state.focus = false; return TreeOp::Noop; } if item.path.is_dir() { item.expanded = true; if cx.editor.config().explorer.is_list() || item.file_type == FileType::Parent { match Self::get_items(item.path.clone(), cx) { Ok(items) => { state.current_root = item.path.clone(); return TreeOp::ReplaceTree(items); } Err(e) => cx.editor.set_error(format!("{e}")), } } else { return TreeOp::GetChildsAndInsert; } } cx.editor.set_error("unkonw file type"); TreeOp::Noop } fn render_float(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) { let background = cx.editor.theme.get("ui.background"); let column_width = cx.editor.config().explorer.column_width as u16; surface.clear_with(area, background); let area = render_block(area, surface, Borders::ALL, None); let mut preview_area = area.clip_left(column_width + 1); if let Some((_, prompt)) = self.prompt.as_mut() { let area = preview_area.clip_bottom(2); let promp_area = render_block( preview_area.clip_top(area.height), surface, Borders::TOP, None, ); prompt.render(promp_area, surface, cx); preview_area = area; } self.render_preview(preview_area, surface, cx.editor); let list_area = render_block( area.clip_right(preview_area.width), surface, Borders::RIGHT, None, ); self.tree.render(list_area, surface, cx, &mut self.state); } fn render_embed(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) { let config = &cx.editor.config().explorer; let side_area = area .with_width(area.width.min(config.column_width as u16 + 2)) .clip_bottom(1); let background = cx.editor.theme.get("ui.statusline"); surface.clear_with(side_area, background); let preview_area = area.clip_left(side_area.width).clip_bottom(2); let prompt_area = area.clip_top(side_area.height); let border_style = cx.editor.theme.get("ui.explorer.border"); let list_area = render_block( side_area.clip_left(1), surface, Borders::RIGHT, Some(border_style), ) .clip_bottom(1); self.tree.render(list_area, surface, cx, &mut self.state); { let statusline = if self.is_focus() { cx.editor.theme.get("ui.statusline") } else { cx.editor.theme.get("ui.statusline.inactive") }; let area = side_area.clip_top(list_area.height).clip_right(1); surface.clear_with(area, statusline); } if self.is_focus() { if preview_area.width < 30 || preview_area.height < 3 { return; } let width = preview_area.width.min(90); let mut y = self.tree.row().saturating_sub(1) as u16; let height = (preview_area.height).min(25); if (height + y) > preview_area.height { y = preview_area.height - height; } let area = Rect::new(preview_area.x, y, width, height); surface.clear_with(area, background); let area = render_block(area, surface, Borders::all(), None); self.render_preview(area, surface, cx.editor); } if let Some((_, prompt)) = self.prompt.as_mut() { prompt.render_prompt(prompt_area, surface, cx) } } fn handle_filter_event(&mut self, event: &KeyEvent, cx: &mut Context) -> EventResult { let (action, mut prompt) = self.prompt.take().unwrap(); match event { key!(Tab) | key!(Down) | ctrl!('j') => { self.tree.clean_recycle(); return self .tree .handle_event(Event::Key(event.clone()), cx, &mut self.state); } key!(Enter) => { self.tree.clean_recycle(); return self .tree .handle_event(Event::Key(event.clone()), cx, &mut self.state); } key!(Esc) | ctrl!('c') => self.tree.restore_recycle(), _ => { if let EventResult::Consumed(_) = prompt.handle_event(&Event::Key(*event), cx) { self.tree.filter(prompt.line(), cx, &mut self.state); } self.prompt = Some((action, prompt)); } }; EventResult::Consumed(None) } fn handle_search_event(&mut self, event: &KeyEvent, cx: &mut Context) -> EventResult { let (action, mut prompt) = self.prompt.take().unwrap(); let search_next = match action { PromptAction::Search(search_next) => search_next, _ => return EventResult::Ignored(None), }; match event { key!(Tab) | key!(Down) | ctrl!('j') => { return self .tree .handle_event(Event::Key(event.clone()), cx, &mut self.state) } key!(Enter) => { let search_str = prompt.line().clone(); if !search_str.is_empty() { self.repeat_motion = Some(Box::new(move |explorer, action, cx| { if let PromptAction::Search(is_next) = action { explorer.tree.save_view(); if is_next == search_next { explorer .tree .search_next(cx, &search_str, &mut explorer.state); } else { explorer .tree .search_pre(cx, &search_str, &mut explorer.state); } } })) } else { self.repeat_motion = None; } return self .tree .handle_event(Event::Key(event.clone()), cx, &mut self.state); } key!(Esc) | ctrl!('c') => self.tree.restore_view(), _ => { if let EventResult::Consumed(_) = prompt.handle_event(&Event::Key(*event), cx) { if search_next { self.tree.search_next(cx, prompt.line(), &mut self.state); } else { self.tree.search_pre(cx, prompt.line(), &mut self.state); } } self.prompt = Some((action, prompt)); } }; EventResult::Consumed(None) } fn handle_prompt_event(&mut self, event: &KeyEvent, cx: &mut Context) -> EventResult { match &self.prompt { Some((PromptAction::Search(_), _)) => return self.handle_search_event(event, cx), Some((PromptAction::Filter, _)) => return self.handle_filter_event(event, cx), _ => {} }; let (action, mut prompt) = match self.prompt.take() { Some((action, p)) => (action, p), _ => return EventResult::Ignored(None), }; let line = prompt.line(); match (action, event) { (PromptAction::Mkdir, key!(Enter)) => { if let Err(e) = self.new_path(line, true) { cx.editor.set_error(format!("{e}")) } } (PromptAction::CreateFile, key!(Enter)) => { if let Err(e) = self.new_path(line, false) { cx.editor.set_error(format!("{e}")) } } (PromptAction::RemoveDir, key!(Enter)) => { let item = self.tree.current_item(); if let Err(e) = std::fs::remove_dir_all(&item.path) { cx.editor.set_error(format!("{e}")); } else { self.tree.fold_current_child(); self.tree.remove_current(); } } (PromptAction::RemoveFile, key!(Enter)) => { if line == "YES" { let item = self.tree.current_item(); if let Err(e) = std::fs::remove_file(&item.path) { cx.editor.set_error(format!("{e}")); } else { self.tree.remove_current(); } } } (_, key!(Esc) | ctrl!('c')) => {} _ => { prompt.handle_event(&Event::Key(*event), cx); self.prompt = Some((action, prompt)); } } EventResult::Consumed(None) } fn new_path(&mut self, file_name: &str, is_dir: bool) -> Result<()> { let current = self.tree.current_item(); let current_parent = if current.file_type == FileType::Placeholder { ¤t.path } else { current .path .parent() .ok_or_else(|| anyhow::anyhow!("can not get parent dir"))? }; let p = helix_core::path::get_normalized_path(¤t_parent.join(file_name)); match p.parent() { Some(p) if p == current_parent => {} _ => bail!("The file name is not illegal"), }; let f = if is_dir { std::fs::create_dir(&p)?; FileInfo::new(p, FileType::Dir) } else { let mut fd = std::fs::OpenOptions::new(); fd.create_new(true).write(true).open(&p)?; FileInfo::new(p, FileType::File) }; if current.file_type == FileType::Placeholder { self.tree.replace_current(f); } else { self.tree.insert_current_level(f); } Ok(()) } } impl Component for Explorer { /// Process input events, return true if handled. fn handle_event(&mut self, event: &Event, cx: &mut Context) -> EventResult { let key_event = match event { Event::Key(event) => *event, Event::Resize(..) => return EventResult::Consumed(None), _ => return EventResult::Ignored(None), }; if !self.is_focus() { return EventResult::Ignored(None); } if let Some(mut on_next_key) = self.on_next_key.take() { return on_next_key(cx, self, &key_event); } if let EventResult::Consumed(c) = self.handle_prompt_event(&key_event, cx) { return EventResult::Consumed(c); } let close_fn = EventResult::Consumed(Some(Box::new(|compositor: &mut Compositor, _| { if let Some(editor) = compositor.find::() { editor.explorer = None; } }))); match key_event { key!(Esc) => self.unfocus(), ctrl!('c') => return close_fn, key!('n') => { if let Some(mut repeat_motion) = self.repeat_motion.take() { repeat_motion(self, PromptAction::Search(true), cx); self.repeat_motion = Some(repeat_motion); } } shift!('N') => { if let Some(mut repeat_motion) = self.repeat_motion.take() { repeat_motion(self, PromptAction::Search(false), cx); self.repeat_motion = Some(repeat_motion); } } key!('f') => self.new_filter_prompt(), key!('/') => self.new_search_prompt(true), key!('?') => self.new_search_prompt(false), key!('m') => { self.on_next_key = Some(Box::new(|_, explorer, event| { match event { key!('d') => explorer.new_mkdir_prompt(), key!('f') => explorer.new_create_file_prompt(), _ => return EventResult::Ignored(None), }; EventResult::Consumed(None) })); } key!('r') => { self.on_next_key = Some(Box::new(|cx, explorer, event| { match event { key!('d') => explorer.new_remove_dir_prompt(cx), key!('f') => explorer.new_remove_file_prompt(cx), _ => return EventResult::Ignored(None), }; EventResult::Consumed(None) })); } _ => { self.tree .handle_event(Event::Key(key_event.clone()), cx, &mut self.state); } } EventResult::Consumed(None) } fn render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) { if area.width < 10 || area.height < 5 { cx.editor.set_error("explorer render area is too small"); return; } let config = &cx.editor.config().explorer; if config.is_embed() { self.render_embed(area, surface, cx); } else { self.render_float(area, surface, cx); } } fn cursor(&self, area: Rect, editor: &Editor) -> (Option, CursorKind) { let prompt = match self.prompt.as_ref() { Some((_, prompt)) => prompt, None => return (None, CursorKind::Hidden), }; let config = &editor.config().explorer; let (x, y) = if config.is_overlay() { let colw = config.column_width as u16; if area.width > colw { (area.x + colw + 2, area.y + area.height - 2) } else { return (None, CursorKind::Hidden); } } else { (area.x, area.y + area.height - 1) }; prompt.cursor(Rect::new(x, y, area.width, 1), editor) } } fn get_preview(p: impl AsRef, max_line: usize) -> Result> { let p = p.as_ref(); if p.is_dir() { return Ok(p .read_dir()? .filter_map(|entry| entry.ok()) .take(max_line) .map(|entry| { if entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) { format!("{}/", entry.file_name().to_string_lossy()) } else { format!("{}", entry.file_name().to_string_lossy()) } }) .collect()); } ensure!(p.is_file(), "path: {} is not file or dir", p.display()); use std::fs::OpenOptions; use std::io::BufRead; let mut fd = OpenOptions::new(); fd.read(true); let fd = fd.open(p)?; Ok(std::io::BufReader::new(fd) .lines() .take(max_line) .filter_map(|line| line.ok()) .map(|line| line.replace('\t', " ")) .collect()) } fn render_block( area: Rect, surface: &mut Surface, borders: Borders, border_style: Option, ) -> Rect { let mut block = Block::default().borders(borders); if let Some(style) = border_style { block = block.border_style(style); } let inner = block.inner(area); block.render(area, surface); inner }