Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add file browser #11285

Open
wants to merge 11 commits into
base: master
Choose a base branch
from
55 changes: 55 additions & 0 deletions helix-term/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be nice to set a default keybind of <space>. for this. Thoughts @archseer? @pascalkuthe?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems fine to me but I haven't looked at this PR much yet

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think <space>e could also work. Could have a <space>E, similar in nature to the file picker.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have created a PR that adds mappings and fixes the docs check drybalka#1

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",
Expand Down Expand Up @@ -2986,6 +2989,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(
Copy link
Contributor

@nik-rev nik-rev Jan 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wouldn't call this an error, honestly. For example I'm frequently doing hx . and then opening the file browser, since i am in a [scratch], everytime I do that it adds a red message to the statusline, so it looks like something went wrong, but really it's just fine

I think that this call to set_error can just be removed

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be fair, this function is called file_browser_in_current_buffer_directory, so this error is quite justified to inform the user that the [scratch] buffer does not have a dedicated directory. In my opinion, it would be confusing to remove this message for cases when something actually goes wrong.

What you want to call after hx . is probably file_browser_in_current_directory or simply file_browser. Now I agree, that it is more convenient to have a single key combination that covers all file browsing needs in one go, and arguably file_browser_in_current_buffer_directory already does that for the price of showing you a somewhat-irritating-but-easily-ignorable error message. In an ideal world one would probably have to wait for when programmable config will be available in helix and code the desired behavior themself.

"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;

Expand Down
71 changes: 71 additions & 0 deletions helix-term/src/ui/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down
93 changes: 72 additions & 21 deletions helix-term/src/ui/picker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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>",
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -823,6 +857,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();
Expand All @@ -844,6 +879,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;
Expand Down
Loading