diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index e2e06b1e..23ca9a2b 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -375,6 +375,9 @@ impl MappableCommand { file_picker, "Open file picker", file_picker_in_current_buffer_directory, "Open file picker at current buffer's directory", file_picker_in_current_directory, "Open file picker at current working directory", + file_browser, "Open file browser in workspace root", + file_browser_in_current_buffer_directory, "Open file browser at current buffer's directory", + file_browser_in_current_directory, "Open file browser at current working directory", code_action, "Perform code action", buffer_picker, "Open buffer picker", jumplist_picker, "Open jumplist picker", @@ -2932,6 +2935,58 @@ fn file_picker_in_current_directory(cx: &mut Context) { cx.push_layer(Box::new(overlaid(picker))); } +fn file_browser(cx: &mut Context) { + let root = find_workspace().0; + if !root.exists() { + cx.editor.set_error("Workspace directory does not exist"); + return; + } + + if let Ok(picker) = ui::file_browser(root) { + cx.push_layer(Box::new(overlaid(picker))); + } +} + +fn file_browser_in_current_buffer_directory(cx: &mut Context) { + let doc_dir = doc!(cx.editor) + .path() + .and_then(|path| path.parent().map(|path| path.to_path_buf())); + + let path = match doc_dir { + Some(path) => path, + None => { + let cwd = helix_stdx::env::current_working_dir(); + if !cwd.exists() { + cx.editor.set_error( + "Current buffer has no parent and current working directory does not exist", + ); + return; + } + cx.editor.set_error( + "Current buffer has no parent, opening file browser in current working directory", + ); + cwd + } + }; + + if let Ok(picker) = ui::file_browser(path) { + cx.push_layer(Box::new(overlaid(picker))); + } +} + +fn file_browser_in_current_directory(cx: &mut Context) { + let cwd = helix_stdx::env::current_working_dir(); + if !cwd.exists() { + cx.editor + .set_error("Current working directory does not exist"); + return; + } + + if let Ok(picker) = ui::file_browser(cwd) { + cx.push_layer(Box::new(overlaid(picker))); + } +} + fn buffer_picker(cx: &mut Context) { let current = view!(cx.editor).doc; diff --git a/helix-term/src/ui/mod.rs b/helix-term/src/ui/mod.rs index ab9b5392..65223cfe 100644 --- a/helix-term/src/ui/mod.rs +++ b/helix-term/src/ui/mod.rs @@ -30,6 +30,7 @@ pub use text::Text; use helix_view::Editor; +use std::path::Path; use std::{error::Error, path::PathBuf}; pub fn prompt( @@ -265,6 +266,76 @@ pub fn file_picker(root: PathBuf, config: &helix_view::editor::Config) -> FilePi picker } +pub fn file_browser(root: PathBuf) -> Result { + let root = helix_stdx::path::canonicalize(root); + let directory_content = directory_content(&root)?; + + let columns = [PickerColumn::new( + "path", + |item: &PathBuf, root: &PathBuf| { + let name = item.strip_prefix(root).unwrap_or(item).to_string_lossy(); + if item.is_dir() { + format!("{}/", name).into() + } else { + name.into() + } + }, + )]; + let picker = Picker::new( + columns, + 0, + directory_content, + root, + move |cx, path: &PathBuf, action| { + if path.is_dir() { + let owned_path = path.clone(); + let callback = Box::pin(async move { + let call: Callback = + Callback::EditorCompositor(Box::new(move |_editor, compositor| { + if let Ok(picker) = file_browser(owned_path) { + compositor.push(Box::new(overlay::overlaid(picker))); + } + })); + Ok(call) + }); + cx.jobs.callback(callback); + } else if let Err(e) = cx.editor.open(path, action) { + let err = if let Some(err) = e.source() { + format!("{}", err) + } else { + format!("unable to open \"{}\"", path.display()) + }; + cx.editor.set_error(err); + } + }, + ) + .with_preview(|_editor, path| Some((path.as_path().into(), None))); + + Ok(picker) +} + +fn directory_content(path: &Path) -> Result, std::io::Error> { + let mut dirs = Vec::new(); + let mut files = Vec::new(); + for entry in std::fs::read_dir(path)?.flatten() { + if entry.path().is_dir() { + dirs.push(entry.path()); + } else { + files.push(entry.path()); + } + } + dirs.sort(); + files.sort(); + + let mut content = Vec::new(); + if path.parent().is_some() { + content.insert(0, path.join("..")); + } + content.extend(dirs); + content.extend(files); + Ok(content) +} + pub mod completers { use crate::ui::prompt::Completion; use helix_core::fuzzy::fuzzy_match; diff --git a/helix-term/src/ui/picker.rs b/helix-term/src/ui/picker.rs index ecf8111a..7d115221 100644 --- a/helix-term/src/ui/picker.rs +++ b/helix-term/src/ui/picker.rs @@ -85,6 +85,7 @@ pub type FileLocation<'a> = (PathOrId<'a>, Option<(usize, usize)>); pub enum CachedPreview { Document(Box), + Directory(Vec), Binary, LargeFile, NotFound, @@ -106,12 +107,20 @@ impl Preview<'_, '_> { } } + fn dir_content(&self) -> Option<&Vec> { + match self { + Preview::Cached(CachedPreview::Directory(dir_content)) => Some(dir_content), + _ => None, + } + } + /// Alternate text to show for the preview. fn placeholder(&self) -> &str { match *self { Self::EditorDocument(_) => "", Self::Cached(preview) => match preview { CachedPreview::Document(_) => "", + CachedPreview::Directory(_) => "", CachedPreview::Binary => "", CachedPreview::LargeFile => "", CachedPreview::NotFound => "", @@ -584,33 +593,58 @@ impl Picker { } let path: Arc = path.into(); - let data = std::fs::File::open(&path).and_then(|file| { - let metadata = file.metadata()?; - // Read up to 1kb to detect the content type - let n = file.take(1024).read_to_end(&mut self.read_buffer)?; - let content_type = content_inspector::inspect(&self.read_buffer[..n]); - self.read_buffer.clear(); - Ok((metadata, content_type)) - }); - let preview = data - .map( - |(metadata, content_type)| match (metadata.len(), content_type) { - (_, content_inspector::ContentType::BINARY) => CachedPreview::Binary, - (size, _) if size > MAX_FILE_SIZE_FOR_PREVIEW => { - CachedPreview::LargeFile + let preview = std::fs::metadata(&path) + .and_then(|metadata| { + if metadata.is_dir() { + let files = super::directory_content(&path)?; + let file_names: Vec<_> = files + .iter() + .filter_map(|file| { + let name = file.file_name()?.to_string_lossy(); + if file.is_dir() { + Some(format!("{}/", name)) + } else { + Some(name.into_owned()) + } + }) + .collect(); + Ok(CachedPreview::Directory(file_names)) + } else if metadata.is_file() { + if metadata.len() > MAX_FILE_SIZE_FOR_PREVIEW { + return Ok(CachedPreview::LargeFile); } - _ => Document::open(&path, None, None, editor.config.clone()) - .map(|doc| { + let content_type = std::fs::File::open(&path).and_then(|file| { + // Read up to 1kb to detect the content type + let n = file.take(1024).read_to_end(&mut self.read_buffer)?; + let content_type = + content_inspector::inspect(&self.read_buffer[..n]); + self.read_buffer.clear(); + Ok(content_type) + })?; + if content_type.is_binary() { + return Ok(CachedPreview::Binary); + } + Document::open(&path, None, None, editor.config.clone()).map_or( + Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "Cannot open document", + )), + |doc| { // Asynchronously highlight the new document helix_event::send_blocking( &self.preview_highlight_handler, path.clone(), ); - CachedPreview::Document(Box::new(doc)) - }) - .unwrap_or(CachedPreview::NotFound), - }, - ) + Ok(CachedPreview::Document(Box::new(doc))) + }, + ) + } else { + Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "Neither a dir, nor a file", + )) + } + }) .unwrap_or(CachedPreview::NotFound); self.preview_cache.insert(path.clone(), preview); Some((Preview::Cached(&self.preview_cache[&path]), range)) @@ -844,6 +878,20 @@ impl Picker { doc } _ => { + if let Some(dir_content) = preview.dir_content() { + for (i, entry) in dir_content.iter().take(inner.height as usize).enumerate() + { + surface.set_stringn( + inner.x, + inner.y + i as u16, + entry, + inner.width as usize, + text, + ); + } + return; + } + let alt_text = preview.placeholder(); let x = inner.x + inner.width.saturating_sub(alt_text.len() as u16) / 2; let y = inner.y + inner.height / 2;