Merge remote-tracking branch 'nikitarevenco/powerful-file-explorer'

This commit is contained in:
Kalle Carlbark 2025-02-25 22:16:29 +01:00
commit 9796951648
6 changed files with 442 additions and 11 deletions

View file

@ -285,6 +285,8 @@ This layer is a kludge of mappings, mostly pickers.
| ----- | ----------- | ------- |
| `f` | Open file picker at LSP workspace root | `file_picker` |
| `F` | Open file picker at current working directory | `file_picker_in_current_directory` |
| `e` | Open file explorer at LSP workspace root | `file_explorer` |
| `E` | Open file explorer at the opened file's directory | `file_explorer_in_current_buffer_directory`|
| `b` | Open buffer picker | `buffer_picker` |
| `j` | Open jumplist picker | `jumplist_picker` |
| `g` | Open changed file picker | `changed_file_picker` |
@ -463,6 +465,18 @@ See the documentation page on [pickers](./pickers.md) for more info.
| `Ctrl-t` | Toggle preview |
| `Escape`, `Ctrl-c` | Close picker |
### File Explorer
There are additional keys accessible when using the File Explorer (`Space-e` and `Space-E`).
| Key | Description |
| ----- | ------------- |
| `Alt-m` | Move selected file or directory |
| `Alt-n` | Create a new file or directory |
| `Alt-d` | Delete the selected file or directory |
| `Alt-c` | Copy the selected file |
| `Alt-y` | Yank the path to the selected file or directory |
## Prompt
Keys to use within prompt, Remapping currently not supported.

View file

@ -3017,7 +3017,7 @@ fn file_explorer(cx: &mut Context) {
return;
}
if let Ok(picker) = ui::file_explorer(root, cx.editor) {
if let Ok(picker) = ui::file_explorer(None, root, cx.editor) {
cx.push_layer(Box::new(overlaid(picker)));
}
}
@ -3044,7 +3044,7 @@ fn file_explorer_in_current_buffer_directory(cx: &mut Context) {
}
};
if let Ok(picker) = ui::file_explorer(path, cx.editor) {
if let Ok(picker) = ui::file_explorer(None, path, cx.editor) {
cx.push_layer(Box::new(overlaid(picker)));
}
}
@ -3057,7 +3057,7 @@ fn file_explorer_in_current_directory(cx: &mut Context) {
return;
}
if let Ok(picker) = ui::file_explorer(cwd, cx.editor) {
if let Ok(picker) = ui::file_explorer(None, cwd, cx.editor) {
cx.push_layer(Box::new(overlaid(picker)));
}
}

View file

@ -14,11 +14,12 @@ mod statusline;
mod text;
mod text_decorations;
use crate::compositor::Compositor;
use crate::filter_picker_entry;
use crate::compositor::{Compositor, Context};
use crate::job::{self, Callback};
use crate::{alt, filter_picker_entry};
pub use completion::Completion;
pub use editor::EditorView;
use helix_core::hashmap;
use helix_stdx::rope;
use helix_view::theme::Style;
pub use markdown::Markdown;
@ -32,9 +33,13 @@ pub use text::Text;
use helix_view::Editor;
use tui::text::Span;
use std::fs;
use std::path::Path;
use std::sync::Arc;
use std::{error::Error, path::PathBuf};
use self::picker::PickerKeyHandler;
struct Utf8PathBuf {
path: String,
is_dir: bool,
@ -279,15 +284,379 @@ pub fn file_picker(root: PathBuf, config: &helix_view::editor::Config) -> FilePi
picker
}
type FileExplorer = Picker<(PathBuf, bool), (PathBuf, Style)>;
/// for each path: (path to item, is the path a directory?)
type ExplorerItem = (PathBuf, bool);
/// (file explorer root, directory style)
type ExplorerData = (PathBuf, Style);
pub fn file_explorer(root: PathBuf, editor: &Editor) -> Result<FileExplorer, std::io::Error> {
type FileExplorer = Picker<ExplorerItem, ExplorerData>;
type KeyHandler = PickerKeyHandler<ExplorerItem, ExplorerData>;
type OnConfirm = fn(
cursor: u32,
cx: &mut Context,
picker_root: PathBuf,
&str,
&Path,
) -> Option<Result<String, String>>;
fn create_confirmation_prompt(
cursor: u32,
input: String,
cx: &mut Context,
operation_input_str: String,
operation_input: PathBuf,
picker_root: PathBuf,
on_confirm: OnConfirm,
) {
let callback = Box::pin(async move {
let call: Callback = Callback::EditorCompositor(Box::new(move |_editor, compositor| {
let prompt = Prompt::new(
input.into(),
None,
crate::ui::completers::none,
move |cx, input: &str, event: PromptEvent| {
if event != PromptEvent::Validate || input != "y" {
return;
};
match on_confirm(
cursor,
cx,
picker_root.clone(),
&operation_input_str,
&operation_input,
) {
Some(Ok(msg)) => cx.editor.set_status(msg),
Some(Err(msg)) => cx.editor.set_error(msg),
None => (),
};
},
);
compositor.push(Box::new(prompt));
}));
Ok(call)
});
cx.jobs.callback(callback);
}
type FileOperation = fn(PathBuf, u32, &mut Context, &Path, &str) -> Option<Result<String, String>>;
fn create_file_operation_prompt(
cursor: u32,
prompt: &'static str,
cx: &mut Context,
path: &Path,
data: Arc<ExplorerData>,
compute_initial_line: fn(&Path) -> String,
file_op: FileOperation,
) {
cx.editor.file_explorer_selected_path = Some(path.to_path_buf());
let callback = Box::pin(async move {
let call: Callback = Callback::EditorCompositor(Box::new(move |editor, compositor| {
let mut prompt = Prompt::new(
prompt.into(),
None,
crate::ui::completers::none,
move |cx, input: &str, event: PromptEvent| {
if event != PromptEvent::Validate {
return;
};
let path = cx.editor.file_explorer_selected_path.clone();
if let Some(path) = path {
match file_op(data.0.clone(), cursor, cx, &path, input) {
Some(Ok(msg)) => cx.editor.set_status(msg),
Some(Err(msg)) => cx.editor.set_error(msg),
None => (),
};
} else {
cx.editor
.set_error("Unable to determine path of selected file")
}
},
);
if let Some(path_editing) = &editor.file_explorer_selected_path {
prompt.set_line_no_recalculate(compute_initial_line(path_editing));
}
compositor.push(Box::new(prompt));
}));
Ok(call)
});
cx.jobs.callback(callback);
}
fn refresh_file_explorer(cursor: u32, cx: &mut Context, root: PathBuf) {
let callback = Box::pin(async move {
let call: Callback = Callback::EditorCompositor(Box::new(move |editor, compositor| {
// replace the old file explorer with the new one
compositor.pop();
if let Ok(picker) = file_explorer(Some(cursor), root, editor) {
compositor.push(Box::new(overlay::overlaid(picker)));
}
}));
Ok(call)
});
cx.jobs.callback(callback);
}
pub fn file_explorer(
cursor: Option<u32>,
root: PathBuf,
editor: &Editor,
) -> Result<FileExplorer, std::io::Error> {
let directory_style = editor.theme.get("ui.text.directory");
let directory_content = directory_content(&root)?;
let yank_path: KeyHandler = Box::new(|cx, (path, _), _, _| {
let register = cx
.editor
.selected_register
.unwrap_or(cx.editor.config().default_yank_register);
let path = helix_stdx::path::get_relative_path(path);
let path = path.to_string_lossy().to_string();
let message = format!("Yanked path {} to register {register}", path);
match cx.editor.registers.write(register, vec![path]) {
Ok(()) => cx.editor.set_status(message),
Err(err) => cx.editor.set_error(err.to_string()),
};
});
let create: KeyHandler = Box::new(|cx, (path, _), data, cursor| {
create_file_operation_prompt(
cursor,
"create:",
cx,
path,
data,
|path| {
path.parent()
.map(|p| format!("{}{}", p.display(), std::path::MAIN_SEPARATOR))
.unwrap_or_default()
},
|root, cursor, cx, _, to_create_str| {
let to_create = helix_stdx::path::expand_tilde(PathBuf::from(to_create_str));
let do_create = |cursor: u32,
cx: &mut Context,
root: PathBuf,
to_create_str: &str,
to_create: &Path| {
if to_create_str.ends_with(std::path::MAIN_SEPARATOR) {
if let Err(err) = fs::create_dir_all(to_create).map_err(|err| {
format!("Unable to create directory {}: {err}", to_create.display())
}) {
return Some(Err(err));
}
refresh_file_explorer(cursor, cx, root);
return Some(Ok(format!("Created directory: {}", to_create.display())));
}
if let Err(err) = fs::File::create(to_create).map_err(|err| {
format!("Unable to create file {}: {err}", to_create.display())
}) {
return Some(Err(err));
};
refresh_file_explorer(cursor, cx, root);
Some(Ok(format!("Created file: {}", to_create.display())))
};
if to_create.exists() {
create_confirmation_prompt(
cursor,
format!(
"Path {} already exists. Overwrite? (y/n):",
to_create.display()
),
cx,
to_create_str.to_string(),
to_create.to_path_buf(),
root,
do_create,
);
return None;
};
do_create(cursor, cx, root, to_create_str, &to_create)
},
)
});
let move_: KeyHandler = Box::new(|cx, (path, _), data, cursor| {
create_file_operation_prompt(
cursor,
"move:",
cx,
path,
data,
|path| path.display().to_string(),
|root, cursor, cx, move_from, move_to_str| {
let move_to = helix_stdx::path::expand_tilde(PathBuf::from(move_to_str));
let do_move = |cursor: u32,
cx: &mut Context,
root: PathBuf,
move_to_str: &str,
move_from: &Path| {
let move_to = helix_stdx::path::expand_tilde(PathBuf::from(move_to_str));
if let Err(err) = fs::rename(move_from, &move_to).map_err(|err| {
format!(
"Unable to move {} {} -> {}: {err}",
if move_to_str.ends_with(std::path::MAIN_SEPARATOR) {
"directory"
} else {
"file"
},
move_from.display(),
move_to.display()
)
}) {
return Some(Err(err));
};
refresh_file_explorer(cursor, cx, root);
None
};
if move_to.exists() {
create_confirmation_prompt(
cursor,
format!(
"Path {} already exists. Overwrite? (y/n):",
move_to.display()
),
cx,
move_to_str.to_string(),
move_from.to_path_buf(),
root,
do_move,
);
return None;
};
do_move(cursor, cx, root, move_to_str, move_from)
},
)
});
let delete: KeyHandler = Box::new(|cx, (path, _), data, cursor| {
create_file_operation_prompt(
cursor,
"delete? (y/n):",
cx,
path,
data,
|_| "".to_string(),
|root, cursor, cx, to_delete, confirmation| {
if confirmation != "y" {
return None;
}
if !to_delete.exists() {
return Some(Err(format!("Path {} does not exist", to_delete.display())));
};
if to_delete.is_dir() {
if let Err(err) = fs::remove_dir_all(to_delete).map_err(|err| {
format!("Unable to delete directory {}: {err}", to_delete.display())
}) {
return Some(Err(err));
};
refresh_file_explorer(cursor, cx, root);
return Some(Ok(format!("Deleted directory: {}", to_delete.display())));
}
if let Err(err) = fs::remove_file(to_delete)
.map_err(|err| format!("Unable to delete file {}: {err}", to_delete.display()))
{
return Some(Err(err));
};
refresh_file_explorer(cursor, cx, root);
Some(Ok(format!("Deleted file: {}", to_delete.display())))
},
)
});
let copy: KeyHandler = Box::new(|cx, (path, _), data, cursor| {
create_file_operation_prompt(
cursor,
"copy-to:",
cx,
path,
data,
|path| {
path.parent()
.map(|p| format!("{}{}", p.display(), std::path::MAIN_SEPARATOR))
.unwrap_or_default()
},
|root, cursor, cx, copy_from, copy_to_str| {
let copy_to = helix_stdx::path::expand_tilde(PathBuf::from(copy_to_str));
let do_copy = |cursor: u32,
cx: &mut Context,
root: PathBuf,
copy_to_str: &str,
copy_from: &Path| {
let copy_to = helix_stdx::path::expand_tilde(PathBuf::from(copy_to_str));
if let Err(err) = std::fs::copy(copy_from, &copy_to).map_err(|err| {
format!(
"Unable to copy from file {} to {}: {err}",
copy_from.display(),
copy_to.display()
)
}) {
return Some(Err(err));
};
refresh_file_explorer(cursor, cx, root);
Some(Ok(format!(
"Copied contents of file {} to {}",
copy_from.display(),
copy_to.display()
)))
};
if copy_from.is_dir() || copy_to_str.ends_with(std::path::MAIN_SEPARATOR) {
// TODO: support copying directories (recursively)?. This isn't built-in to the standard library
return Some(Err(format!(
"Copying directories is not supported: {} is a directory",
copy_from.display()
)));
}
if copy_to.exists() {
create_confirmation_prompt(
cursor,
format!(
"Path {} already exists. Overwrite? (y/n):",
copy_to.display()
),
cx,
copy_to_str.to_string(),
copy_from.to_path_buf(),
root,
do_copy,
);
return None;
}
do_copy(cursor, cx, root, copy_to_str, copy_from)
},
)
});
let columns = [PickerColumn::new(
"path",
|(path, is_dir): &(PathBuf, bool), (root, directory_style): &(PathBuf, Style)| {
|(path, is_dir): &ExplorerItem, (root, directory_style): &ExplorerData| {
let name = path.strip_prefix(root).unwrap_or(path).to_string_lossy();
if *is_dir {
Span::styled(format!("{}/", name), *directory_style).into()
@ -296,18 +665,19 @@ pub fn file_explorer(root: PathBuf, editor: &Editor) -> Result<FileExplorer, std
}
},
)];
let picker = Picker::new(
columns,
0,
directory_content,
(root, directory_style),
move |cx, (path, is_dir): &(PathBuf, bool), action| {
move |cx, (path, is_dir): &ExplorerItem, 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_explorer(new_root, editor) {
if let Ok(picker) = file_explorer(None, new_root, editor) {
compositor.push(Box::new(overlay::overlaid(picker)));
}
}));
@ -324,7 +694,15 @@ pub fn file_explorer(root: PathBuf, editor: &Editor) -> Result<FileExplorer, std
}
},
)
.with_preview(|_editor, (path, _is_dir)| Some((path.as_path().into(), None)));
.with_cursor(cursor.unwrap_or_default())
.with_preview(|_editor, (path, _is_dir)| Some((path.as_path().into(), None)))
.with_key_handlers(hashmap! {
alt!('n') => create,
alt!('m') => move_,
alt!('d') => delete,
alt!('c') => copy,
alt!('y') => yank_path,
});
Ok(picker)
}

View file

@ -47,6 +47,7 @@ use helix_core::{
use helix_view::{
editor::Action,
graphics::{CursorKind, Margin, Modifier, Rect},
input::KeyEvent,
theme::Style,
view::ViewPosition,
Document, DocumentId, Editor,
@ -258,6 +259,7 @@ pub struct Picker<T: 'static + Send + Sync, D: 'static> {
widths: Vec<Constraint>,
callback_fn: PickerCallback<T>,
custom_key_handlers: PickerKeyHandlers<T, D>,
pub truncate_start: bool,
/// Caches paths to documents
@ -385,6 +387,7 @@ impl<T: 'static + Send + Sync, D: 'static + Send + Sync> Picker<T, D> {
completion_height: 0,
widths,
preview_cache: HashMap::new(),
custom_key_handlers: HashMap::new(),
read_buffer: Vec::with_capacity(1024),
file_fn: None,
preview_highlight_handler: PreviewHighlightHandler::<T, D>::default().spawn(),
@ -392,6 +395,11 @@ impl<T: 'static + Send + Sync, D: 'static + Send + Sync> Picker<T, D> {
}
}
pub fn with_key_handlers(mut self, handlers: PickerKeyHandlers<T, D>) -> Self {
self.custom_key_handlers = handlers;
self
}
pub fn injector(&self) -> Injector<T, D> {
Injector {
dst: self.matcher.injector(),
@ -483,6 +491,11 @@ impl<T: 'static + Send + Sync, D: 'static + Send + Sync> Picker<T, D> {
.saturating_sub(1);
}
pub fn with_cursor(mut self, cursor: u32) -> Self {
self.cursor = cursor;
self
}
pub fn selection(&self) -> Option<&T> {
self.matcher
.snapshot()
@ -509,6 +522,17 @@ impl<T: 'static + Send + Sync, D: 'static + Send + Sync> Picker<T, D> {
self.show_preview = !self.show_preview;
}
fn custom_event_handler(&mut self, event: &KeyEvent, cx: &mut Context) -> EventResult {
if let (Some(callback), Some(selected)) =
(self.custom_key_handlers.get(event), self.selection())
{
callback(cx, selected, Arc::clone(&self.editor_data), self.cursor);
EventResult::Consumed(None)
} else {
EventResult::Ignored(None)
}
}
fn prompt_handle_event(&mut self, event: &Event, cx: &mut Context) -> EventResult {
if let EventResult::Consumed(_) = self.prompt.handle_event(event, cx) {
self.handle_prompt_change(matches!(event, Event::Paste(_)));
@ -1046,6 +1070,10 @@ impl<I: 'static + Send + Sync, D: 'static + Send + Sync> Component for Picker<I,
EventResult::Consumed(Some(callback))
};
if let EventResult::Consumed(_) = self.custom_event_handler(&key_event, ctx) {
return EventResult::Consumed(None);
}
match key_event {
shift!(Tab) | key!(Up) | ctrl!('p') => {
self.move_by(1, Direction::Backward);
@ -1157,3 +1185,5 @@ impl<T: 'static + Send + Sync, D> Drop for Picker<T, D> {
}
type PickerCallback<T> = Box<dyn Fn(&mut Context, &T, Action)>;
pub type PickerKeyHandler<T, D> = Box<dyn Fn(&mut Context, &T, Arc<D>, u32) + 'static>;
pub type PickerKeyHandlers<T, D> = HashMap<KeyEvent, PickerKeyHandler<T, D>>;

View file

@ -122,6 +122,13 @@ impl Prompt {
self.recalculate_completion(editor);
}
pub fn set_line_no_recalculate(&mut self, line: String) {
debug_assert!(self.completion.is_empty());
let cursor = line.len();
self.line = line;
self.cursor = cursor;
}
pub fn with_language(
mut self,
language: &'static str,

View file

@ -1107,6 +1107,7 @@ pub struct Editor {
pub mouse_down_range: Option<Range>,
pub cursor_cache: CursorCache,
pub file_explorer_selected_path: Option<PathBuf>,
}
pub type Motion = Box<dyn Fn(&mut Editor)>;
@ -1229,6 +1230,7 @@ impl Editor {
handlers,
mouse_down_range: None,
cursor_cache: CursorCache::default(),
file_explorer_selected_path: None,
}
}