Unnamed repository; edit this file 'description' to name the repository.
Add file browser (#11285)
Denys Rybalka 2025-01-24
parent 8af3310 · commit 6b044ae
-rw-r--r--book/src/generated/static-cmd.md3
-rw-r--r--helix-term/src/commands.rs55
-rw-r--r--helix-term/src/keymap/default.rs2
-rw-r--r--helix-term/src/ui/mod.rs71
-rw-r--r--helix-term/src/ui/picker.rs93
5 files changed, 203 insertions, 21 deletions
diff --git a/book/src/generated/static-cmd.md b/book/src/generated/static-cmd.md
index 08c8e702..9b2afc02 100644
--- a/book/src/generated/static-cmd.md
+++ b/book/src/generated/static-cmd.md
@@ -100,6 +100,9 @@
| `file_picker` | Open file picker | normal: `` <space>f ``, select: `` <space>f `` |
| `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 | normal: `` <space>F ``, select: `` <space>F `` |
+| `file_browser` | Open file browser in workspace root | normal: `` <space>e ``, select: `` <space>e `` |
+| `file_browser_in_current_buffer_directory` | Open file browser at current buffer's directory | normal: `` <space>E ``, select: `` <space>E `` |
+| `file_browser_in_current_directory` | Open file browser at current working directory | |
| `code_action` | Perform code action | normal: `` <space>a ``, select: `` <space>a `` |
| `buffer_picker` | Open buffer picker | normal: `` <space>b ``, select: `` <space>b `` |
| `jumplist_picker` | Open jumplist picker | normal: `` <space>j ``, select: `` <space>j `` |
diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs
index a5a210fe..9064e1cc 100644
--- a/helix-term/src/commands.rs
+++ b/helix-term/src/commands.rs
@@ -393,6 +393,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",
@@ -2992,6 +2995,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.editor) {
+ 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.editor) {
+ 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.editor) {
+ 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/keymap/default.rs b/helix-term/src/keymap/default.rs
index c6cefd92..341d6dbf 100644
--- a/helix-term/src/keymap/default.rs
+++ b/helix-term/src/keymap/default.rs
@@ -222,6 +222,8 @@ pub fn default() -> HashMap<Mode, KeyTrie> {
"space" => { "Space"
"f" => file_picker,
"F" => file_picker_in_current_directory,
+ "e" => file_browser,
+ "E" => file_browser_in_current_buffer_directory,
"b" => buffer_picker,
"j" => jumplist_picker,
"s" => symbol_picker,
diff --git a/helix-term/src/ui/mod.rs b/helix-term/src/ui/mod.rs
index 2313fb80..92fa49dd 100644
--- a/helix-term/src/ui/mod.rs
+++ b/helix-term/src/ui/mod.rs
@@ -20,6 +20,7 @@ use crate::job::{self, Callback};
pub use completion::Completion;
pub use editor::EditorView;
use helix_stdx::rope;
+use helix_view::theme::Style;
pub use markdown::Markdown;
pub use menu::Menu;
pub use picker::{Column as PickerColumn, FileLocation, Picker};
@@ -29,7 +30,9 @@ pub use spinner::{ProgressSpinners, Spinner};
pub use text::Text;
use helix_view::Editor;
+use tui::text::Span;
+use std::path::Path;
use std::{error::Error, path::PathBuf};
struct Utf8PathBuf {
@@ -276,6 +279,74 @@ pub fn file_picker(root: PathBuf, config: &helix_view::editor::Config) -> FilePi
picker
}
+type FileBrowser = Picker<(PathBuf, bool), (PathBuf, Style)>;
+
+pub fn file_browser(root: PathBuf, editor: &Editor) -> Result<FileBrowser, std::io::Error> {
+ let directory_style = editor.theme.get("ui.text.directory");
+ let directory_content = directory_content(&root)?;
+
+ let columns = [PickerColumn::new(
+ "path",
+ |(path, is_dir): &(PathBuf, bool), (root, directory_style): &(PathBuf, Style)| {
+ let name = path.strip_prefix(root).unwrap_or(path).to_string_lossy();
+ if *is_dir {
+ Span::styled(format!("{}/", name), *directory_style).into()
+ } else {
+ name.into()
+ }
+ },
+ )];
+ let picker = Picker::new(
+ columns,
+ 0,
+ directory_content,
+ (root, directory_style),
+ move |cx, (path, is_dir): &(PathBuf, bool), action| {
+ if *is_dir {
+ let new_root = helix_stdx::path::normalize(path);
+ let callback = Box::pin(async move {
+ let call: Callback =
+ Callback::EditorCompositor(Box::new(move |editor, compositor| {
+ if let Ok(picker) = file_browser(new_root, editor) {
+ 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, _is_dir)| Some((path.as_path().into(), None)));
+
+ Ok(picker)
+}
+
+fn directory_content(path: &Path) -> Result<Vec<(PathBuf, bool)>, std::io::Error> {
+ let mut content: Vec<_> = std::fs::read_dir(path)?
+ .flatten()
+ .map(|entry| {
+ (
+ entry.path(),
+ entry.file_type().is_ok_and(|file_type| file_type.is_dir()),
+ )
+ })
+ .collect();
+
+ content.sort_by(|(path1, is_dir1), (path2, is_dir2)| (!is_dir1, path1).cmp(&(!is_dir2, path2)));
+ if path.parent().is_some() {
+ content.insert(0, (path.join(".."), true));
+ }
+ Ok(content)
+}
+
pub mod completers {
use super::Utf8PathBuf;
use crate::ui::prompt::Completion;
diff --git a/helix-term/src/ui/picker.rs b/helix-term/src/ui/picker.rs
index 9ff867c1..3986ad47 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<Document>),
+ Directory(Vec<(String, bool)>),
Binary,
LargeFile,
NotFound,
@@ -106,12 +107,20 @@ impl Preview<'_, '_> {
}
}
+ fn dir_content(&self) -> Option<&Vec<(String, bool)>> {
+ 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(_) => "<Invalid file location>",
Self::Cached(preview) => match preview {
CachedPreview::Document(_) => "<Invalid file location>",
+ CachedPreview::Directory(_) => "<Invalid directory location>",
CachedPreview::Binary => "<Binary file>",
CachedPreview::LargeFile => "<File too large to preview>",
CachedPreview::NotFound => "<File not found>",
@@ -584,33 +593,58 @@ impl<T: 'static + Send + Sync, D: 'static + Send + Sync> Picker<T, D> {
}
let path: Arc<Path> = 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(|(path, is_dir)| {
+ let name = path.file_name()?.to_string_lossy();
+ if *is_dir {
+ Some((format!("{}/", name), true))
+ } else {
+ Some((name.into_owned(), false))
+ }
+ })
+ .collect();
+ Ok(CachedPreview::Directory(file_names))
+ } else if metadata.is_file() {
+ if metadata.len() > MAX_FILE_SIZE_FOR_PREVIEW {
+ return Ok(CachedPreview::LargeFile);
+ }
+ 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(|doc| {
+ 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))
@@ -826,6 +860,7 @@ impl<T: 'static + Send + Sync, D: 'static + Send + Sync> Picker<T, D> {
// clear area
let background = cx.editor.theme.get("ui.background");
let text = cx.editor.theme.get("ui.text");
+ let directory = cx.editor.theme.get("ui.text.directory");
surface.clear_with(area, background);
const BLOCK: Block<'_> = Block::bordered();
@@ -847,6 +882,22 @@ impl<T: 'static + Send + Sync, D: 'static + Send + Sync> Picker<T, D> {
doc
}
_ => {
+ if let Some(dir_content) = preview.dir_content() {
+ for (i, (path, is_dir)) in
+ dir_content.iter().take(inner.height as usize).enumerate()
+ {
+ let style = if *is_dir { directory } else { text };
+ surface.set_stringn(
+ inner.x,
+ inner.y + i as u16,
+ path,
+ inner.width as usize,
+ style,
+ );
+ }
+ 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;