diff --git a/book/src/keymap.md b/book/src/keymap.md index 10257378c..b2c7c17a5 100644 --- a/book/src/keymap.md +++ b/book/src/keymap.md @@ -286,6 +286,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` | @@ -464,6 +466,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. diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 2cbdeb451..483cb3498 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -3091,7 +3091,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))); } } @@ -3118,7 +3118,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))); } } @@ -3131,7 +3131,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))); } } diff --git a/helix-term/src/ui/file_explorer.rs b/helix-term/src/ui/file_explorer.rs new file mode 100644 index 000000000..5253c9ac5 --- /dev/null +++ b/helix-term/src/ui/file_explorer.rs @@ -0,0 +1,422 @@ +use std::error::Error as _; +use std::{ + fs, + path::{Path, PathBuf}, +}; + +use helix_core::hashmap; +use helix_view::{theme::Style, Editor}; +use tui::text::Span; + +use crate::{alt, compositor::Context, job::Callback}; + +use super::prompt::Movement; +use super::{ + directory_content, overlay, picker::PickerKeyHandler, Picker, PickerColumn, Prompt, PromptEvent, +}; + +/// for each path: (path to item, is the path a directory?) +type ExplorerItem = (PathBuf, bool); +/// (file explorer root, directory style) +type ExplorerData = (PathBuf, Style); + +type FileExplorer = Picker; + +type KeyHandler = PickerKeyHandler; + +/// Create a prompt that asks for the user's confirmation before overwriting a path +fn confirm_before_overwriting( + // Path that we are overwriting + overwriting: PathBuf, + // Overwrite this path with + overwrite_with: PathBuf, + cx: &mut Context, + picker_root: PathBuf, + overwrite: F, +) -> Option> +where + F: Fn(&mut Context, PathBuf, &Path) -> Option> + Send + 'static, +{ + // No need for confirmation, as the path does not exist. We can freely write to it + if !overwriting.exists() { + return overwrite(cx, picker_root, &overwrite_with); + } + let callback = Box::pin(async move { + let call: Callback = Callback::EditorCompositor(Box::new(move |_editor, compositor| { + let prompt = Prompt::new( + format!( + "Path {} already exists. Ovewrite? (y/n):", + overwriting.display() + ) + .into(), + None, + crate::ui::completers::none, + move |cx, input: &str, event: PromptEvent| { + if event != PromptEvent::Validate || input != "y" { + return; + }; + + if let Some(result) = overwrite(cx, picker_root.clone(), &overwrite_with) { + cx.editor.set_result(result); + }; + }, + ); + + compositor.push(Box::new(prompt)); + })); + Ok(call) + }); + cx.jobs.callback(callback); + + None +} + +fn create_file_operation_prompt( + cx: &mut Context, + // Currently selected path of the picker + path: &Path, + // Text value of the prompt + prompt: fn(&Path) -> String, + // How to move the cursor + movement: Option, + // What to fill user's input with + prefill: fn(&Path) -> String, + // Action to take when the operation runs + file_op: F, +) where + F: Fn(&mut Context, &PathBuf, String) -> Option> + Send + 'static, +{ + let selected_path = path.to_path_buf(); + let callback = Box::pin(async move { + let call: Callback = Callback::EditorCompositor(Box::new(move |editor, compositor| { + // to be able to move selected_path + let path = selected_path.clone(); + let mut prompt = Prompt::new( + prompt(&path).into(), + None, + crate::ui::completers::none, + move |cx, input: &str, event: PromptEvent| { + if event != PromptEvent::Validate { + return; + }; + + if let Some(result) = file_op(cx, &path, input.to_owned()) { + cx.editor.set_result(result); + } else { + cx.editor.clear_status(); + }; + }, + ); + + prompt.set_line(prefill(&selected_path), editor); + + if let Some(movement) = movement { + log::error!("{movement:?}"); + prompt.move_cursor(movement); + } + + 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, + root: PathBuf, + editor: &Editor, +) -> Result { + 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( + cx, + path, + |_| "Create: ".into(), + None, + |path| { + path.parent() + .map(|p| format!("{}{}", p.display(), std::path::MAIN_SEPARATOR)) + .unwrap_or_default() + }, + move |cx, _, to_create_string| { + let root = data.0.clone(); + let to_create = helix_stdx::path::expand_tilde(PathBuf::from(&to_create_string)); + + confirm_before_overwriting( + to_create.to_path_buf(), + to_create.to_path_buf(), + cx, + root, + move |cx: &mut Context, root: PathBuf, to_create: &Path| { + if to_create_string.ends_with(std::path::MAIN_SEPARATOR) { + if let Err(err_create_dir) = + fs::create_dir_all(to_create).map_err(|err| { + format!( + "Unable to create directory {}: {err}", + to_create.display() + ) + }) + { + return Some(Err(err_create_dir)); + } + refresh_file_explorer(cursor, cx, root); + + return Some(Ok(format!("Created directory: {}", to_create.display()))); + } + + // allows to create a path like /path/to/somewhere.txt even if "to" does not exist. Creates intermediate directories + let Some(to_create_parent) = to_create.parent() else { + return Some(Err(format!( + "Failed to get parent directory of {}", + to_create.display() + ))); + }; + + if let Err(err_create_parent) = fs::create_dir_all(to_create_parent) { + return Some(Err(format!( + "Could not create intermediate directories: {err_create_parent}" + ))); + } + + if let Err(err_create_file) = fs::File::create(to_create).map_err(|err| { + format!("Unable to create file {}: {err}", to_create.display()) + }) { + return Some(Err(err_create_file)); + }; + + refresh_file_explorer(cursor, cx, root); + + Some(Ok(format!("Created file: {}", to_create.display()))) + }, + ) + }, + ) + }); + + let move_: KeyHandler = Box::new(|cx, (path, _), data, cursor| { + create_file_operation_prompt( + cx, + path, + |path| format!("Move {} -> ", path.display()), + // move cursor before the extension + // Yazi does this and it leads to good user experience + // Most of the time when we would like to rename a file we + // don't want to change its file extension + path.extension() + .inspect(|a| log::error!("{a:?}")) + // +1 to account for the dot in the extension (`.`) + .map(|ext| Movement::BackwardChar(ext.len() + 1)), + |path| path.display().to_string(), + move |cx, move_from, move_to_string| { + let root = data.0.clone(); + let move_to = helix_stdx::path::expand_tilde(PathBuf::from(&move_to_string)); + + confirm_before_overwriting( + move_to.to_path_buf(), + move_from.to_path_buf(), + cx, + root, + move |cx: &mut Context, root: PathBuf, move_from: &Path| { + let move_to = + helix_stdx::path::expand_tilde(PathBuf::from(&move_to_string)); + + if let Err(err) = cx.editor.move_path(move_from, &move_to).map_err(|err| { + format!( + "Unable to move {} {} -> {}: {err}", + if move_to_string.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 + }, + ) + }, + ) + }); + + let delete: KeyHandler = Box::new(|cx, (path, _), data, cursor| { + create_file_operation_prompt( + cx, + path, + |path| format!("Delete {}? (y/n): ", path.display()), + None, + |_| "".to_string(), + move |cx, to_delete, confirmation| { + let root = data.0.clone(); + 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( + cx, + path, + |path| format!("Copy {} -> ", path.display()), + None, + |path| { + path.parent() + .map(|p| format!("{}{}", p.display(), std::path::MAIN_SEPARATOR)) + .unwrap_or_default() + }, + move |cx, copy_from, copy_to_string| { + let root = data.0.clone(); + let copy_to = helix_stdx::path::expand_tilde(PathBuf::from(©_to_string)); + + if copy_from.is_dir() || copy_to_string.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() + ))); + } + + let copy_to_str = copy_to_string.to_string(); + + confirm_before_overwriting( + copy_to.to_path_buf(), + copy_from.to_path_buf(), + cx, + root, + move |cx: &mut Context, picker_root: PathBuf, copy_from: &Path| { + let copy_to = helix_stdx::path::expand_tilde(PathBuf::from(©_to_str)); + if let Err(err) = std::fs::copy(copy_from, ©_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, picker_root); + + Some(Ok(format!( + "Copied contents of file {} to {}", + copy_from.display(), + copy_to.display() + ))) + }, + ) + }, + ) + }); + + let columns = [PickerColumn::new( + "path", + |(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() + } else { + name.into() + } + }, + )]; + + let picker = Picker::new( + columns, + 0, + directory_content, + (root, directory_style), + 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(None, 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_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) +} diff --git a/helix-term/src/ui/mod.rs b/helix-term/src/ui/mod.rs index 106bfbfb8..bf3d48a78 100644 --- a/helix-term/src/ui/mod.rs +++ b/helix-term/src/ui/mod.rs @@ -1,6 +1,7 @@ mod completion; mod document; pub(crate) mod editor; +mod file_explorer; mod info; pub mod lsp; mod markdown; @@ -19,6 +20,7 @@ use crate::filter_picker_entry; use crate::job::{self, Callback}; pub use completion::Completion; pub use editor::EditorView; +pub use file_explorer::file_explorer; use helix_stdx::rope; use helix_view::theme::Style; pub use markdown::Markdown; @@ -300,56 +302,6 @@ pub fn file_picker(editor: &Editor, root: PathBuf) -> FilePicker { picker } -type FileExplorer = Picker<(PathBuf, bool), (PathBuf, Style)>; - -pub fn file_explorer(root: PathBuf, editor: &Editor) -> Result { - 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_explorer(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, std::io::Error> { let mut content: Vec<_> = std::fs::read_dir(path)? .flatten() diff --git a/helix-term/src/ui/picker.rs b/helix-term/src/ui/picker.rs index 3f3aaba2b..55a9130f2 100644 --- a/helix-term/src/ui/picker.rs +++ b/helix-term/src/ui/picker.rs @@ -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 { widths: Vec, callback_fn: PickerCallback, + custom_key_handlers: PickerKeyHandlers, pub truncate_start: bool, /// Caches paths to documents @@ -385,6 +387,7 @@ impl Picker { 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::::default().spawn(), @@ -392,6 +395,11 @@ impl Picker { } } + pub fn with_key_handlers(mut self, handlers: PickerKeyHandlers) -> Self { + self.custom_key_handlers = handlers; + self + } + pub fn injector(&self) -> Injector { Injector { dst: self.matcher.injector(), @@ -483,6 +491,11 @@ impl Picker { .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 Picker { self.show_preview = !self.show_preview; } + fn custom_key_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(_))); @@ -1049,6 +1073,11 @@ impl Component for Picker { self.move_by(1, Direction::Backward); @@ -1168,3 +1197,5 @@ impl Drop for Picker { } type PickerCallback = Box; +pub type PickerKeyHandler = Box, u32) + 'static>; +pub type PickerKeyHandlers = HashMap>; diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index 89f053741..658e39af4 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -1331,6 +1331,14 @@ impl Editor { self.status_msg = Some((error, Severity::Error)); } + #[inline] + pub fn set_result>>(&mut self, result: Result) { + match result { + Ok(ok) => self.set_status(ok), + Err(err) => self.set_error(err), + } + } + #[inline] pub fn set_warning>>(&mut self, warning: T) { let warning = warning.into();