From 05635f422e8586252a1e932aeedb6cc6d7e65f1b Mon Sep 17 00:00:00 2001 From: Paho Lurie-Gregg Date: Sun, 26 Feb 2023 21:36:38 -0800 Subject: [PATCH] Provide file-picker navigation We provide the following new keybindings while in a picker: * `C-e` - Change picker root to one directory in, based on selection * `C-a` - Change picker root to one directory out These can be especially useful when combined with the command `file_picker_in_current_buffer_directory` when navigating library files. That is, with this change the following flow is enabled: 1. Perform a `goto_defition` on a symbol defined in an external library. 2. Perform `file_picker_in_current_buffer_directory`, opening a picker in the external library. 3. Browse the external library with `C-e` and `C-a`, which is not possible without this change. To accomplish this, we need access to a `FilePickerConfig` not just when building the picker, but also later, so we add an associated `Item::Config` type, which is simply `()` for everything except `PathBuf`. To make it easier to follow what is going on when navigating the picker, we set the status bar to the picker's root directory on change. --- book/src/keymap.md | 2 + helix-term/src/application.rs | 2 +- helix-term/src/commands.rs | 21 ++++++--- helix-term/src/commands/dap.rs | 8 +++- helix-term/src/commands/lsp.rs | 10 +++- helix-term/src/commands/typed.rs | 5 +- helix-term/src/ui/completion.rs | 1 + helix-term/src/ui/menu.rs | 19 ++++++-- helix-term/src/ui/mod.rs | 78 ++++++++++++++++++++------------ helix-term/src/ui/picker.rs | 65 ++++++++++++++++++++++++-- helix-view/src/editor.rs | 2 +- 11 files changed, 162 insertions(+), 51 deletions(-) diff --git a/book/src/keymap.md b/book/src/keymap.md index 3a5ccca53311..9e1df3b930f0 100644 --- a/book/src/keymap.md +++ b/book/src/keymap.md @@ -430,6 +430,8 @@ Keys to use within picker. Remapping currently not supported. | `Ctrl-v` | Open vertically | | `Ctrl-t` | Toggle preview | | `Escape`, `Ctrl-c` | Close picker | +| `Ctrl-e` | Change picker root to one directory in, based on selection | +| `Ctrl-a` | Change picker root to one directory out | ## Prompt diff --git a/helix-term/src/application.rs b/helix-term/src/application.rs index 809393c7fd7b..e21a31a37561 100644 --- a/helix-term/src/application.rs +++ b/helix-term/src/application.rs @@ -161,7 +161,7 @@ impl Application { // If the first file is a directory, skip it and open a picker if let Some((first, _)) = files_it.next_if(|(p, _)| p.is_dir()) { - let picker = ui::file_picker(first, &config.load().editor); + let picker = ui::file_picker(first, editor.config().file_picker); compositor.push(Box::new(overlaid(picker))); } diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index d0b9047c8ad6..984289a2ed96 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -1229,7 +1229,7 @@ fn goto_file_impl(cx: &mut Context, action: Action) { let path = &rel_path.join(p); if path.is_dir() { - let picker = ui::file_picker(path.into(), &cx.editor.config()); + let picker = ui::file_picker(path.into(), cx.editor.config().file_picker); cx.push_layer(Box::new(overlaid(picker))); } else if let Err(e) = cx.editor.open(path, action) { cx.editor.set_error(format!("Open file failed: {:?}", e)); @@ -1266,7 +1266,7 @@ fn open_url(cx: &mut Context, url: Url, action: Action) { Ok(_) | Err(_) => { let path = &rel_path.join(url.path()); if path.is_dir() { - let picker = ui::file_picker(path.into(), &cx.editor.config()); + let picker = ui::file_picker(path.into(), cx.editor.config().file_picker); cx.push_layer(Box::new(overlaid(picker))); } else if let Err(e) = cx.editor.open(path, action) { cx.editor.set_error(format!("Open file failed: {:?}", e)); @@ -2221,6 +2221,7 @@ fn global_search(cx: &mut Context) { impl ui::menu::Item for FileResult { type Data = Option; + type Config = (); fn format(&self, current_path: &Self::Data) -> Row { let relative_path = helix_stdx::path::get_relative_path(&self.path) @@ -2810,7 +2811,7 @@ fn file_picker(cx: &mut Context) { cx.editor.set_error("Workspace directory does not exist"); return; } - let picker = ui::file_picker(root, &cx.editor.config()); + let picker = ui::file_picker(root, cx.editor.config().file_picker); cx.push_layer(Box::new(overlaid(picker))); } @@ -2827,7 +2828,7 @@ fn file_picker_in_current_buffer_directory(cx: &mut Context) { } }; - let picker = ui::file_picker(path, &cx.editor.config()); + let picker = ui::file_picker(path, cx.editor.config().file_picker); cx.push_layer(Box::new(overlaid(picker))); } @@ -2838,7 +2839,7 @@ fn file_picker_in_current_directory(cx: &mut Context) { .set_error("Current working directory does not exist"); return; } - let picker = ui::file_picker(cwd, &cx.editor.config()); + let picker = ui::file_picker(cwd, cx.editor.config().file_picker); cx.push_layer(Box::new(overlaid(picker))); } @@ -2855,6 +2856,7 @@ fn buffer_picker(cx: &mut Context) { impl ui::menu::Item for BufferMeta { type Data = (); + type Config = (); fn format(&self, _data: &Self::Data) -> Row { let path = self @@ -2896,7 +2898,7 @@ fn buffer_picker(cx: &mut Context) { // mru items.sort_unstable_by_key(|item| std::cmp::Reverse(item.focused_at)); - let picker = Picker::new(items, (), |cx, meta, action| { + let picker = Picker::new((), items, (), |cx, meta, action| { cx.editor.switch(meta.id, action); }) .with_preview(|editor, meta| { @@ -2922,6 +2924,7 @@ fn jumplist_picker(cx: &mut Context) { impl ui::menu::Item for JumpMeta { type Data = (); + type Config = (); fn format(&self, _data: &Self::Data) -> Row { let path = self @@ -2974,6 +2977,7 @@ fn jumplist_picker(cx: &mut Context) { }; let picker = Picker::new( + (), cx.editor .tree .views() @@ -3014,6 +3018,7 @@ fn changed_file_picker(cx: &mut Context) { impl Item for FileChange { type Data = FileChangeData; + type Config = (); fn format(&self, data: &Self::Data) -> Row { let process_path = |path: &PathBuf| { @@ -3053,6 +3058,7 @@ fn changed_file_picker(cx: &mut Context) { let renamed = cx.editor.theme.get("diff.delta.moved"); let picker = Picker::new( + (), Vec::new(), FileChangeData { cwd: cwd.clone(), @@ -3092,6 +3098,7 @@ fn changed_file_picker(cx: &mut Context) { impl ui::menu::Item for MappableCommand { type Data = ReverseKeymap; + type Config = (); fn format(&self, keymap: &Self::Data) -> Row { let fmt_binding = |bindings: &Vec>| -> String { @@ -3138,7 +3145,7 @@ pub fn command_palette(cx: &mut Context) { } })); - let picker = Picker::new(commands, keymap, move |cx, command, _action| { + let picker = Picker::new((), commands, keymap, move |cx, command, _action| { let mut ctx = Context { register, count, diff --git a/helix-term/src/commands/dap.rs b/helix-term/src/commands/dap.rs index d62b0a4e5b4e..5265e0312bdd 100644 --- a/helix-term/src/commands/dap.rs +++ b/helix-term/src/commands/dap.rs @@ -24,6 +24,7 @@ use helix_view::handlers::dap::{breakpoints_changed, jump_to_stack_frame, select impl ui::menu::Item for StackFrame { type Data = (); + type Config = (); fn format(&self, _data: &Self::Data) -> Row { self.name.as_str().into() // TODO: include thread_states in the label @@ -32,6 +33,7 @@ impl ui::menu::Item for StackFrame { impl ui::menu::Item for DebugTemplate { type Data = (); + type Config = (); fn format(&self, _data: &Self::Data) -> Row { self.name.as_str().into() @@ -40,6 +42,7 @@ impl ui::menu::Item for DebugTemplate { impl ui::menu::Item for Thread { type Data = ThreadStates; + type Config = (); fn format(&self, thread_states: &Self::Data) -> Row { format!( @@ -73,7 +76,7 @@ fn thread_picker( let debugger = debugger!(editor); let thread_states = debugger.thread_states.clone(); - let picker = Picker::new(threads, thread_states, move |cx, thread, _action| { + let picker = Picker::new((), threads, thread_states, move |cx, thread, _action| { callback_fn(cx.editor, thread) }) .with_preview(move |editor, thread| { @@ -269,6 +272,7 @@ pub fn dap_launch(cx: &mut Context) { let templates = config.templates.clone(); cx.push_layer(Box::new(overlaid(Picker::new( + (), templates, (), |cx, template, _action| { @@ -735,7 +739,7 @@ pub fn dap_switch_stack_frame(cx: &mut Context) { let frames = debugger.stack_frames[&thread_id].clone(); - let picker = Picker::new(frames, (), move |cx, frame, _action| { + let picker = Picker::new((), frames, (), move |cx, frame, _action| { let debugger = debugger!(cx.editor); // TODO: this should be simpler to find let pos = debugger.stack_frames[&thread_id] diff --git a/helix-term/src/commands/lsp.rs b/helix-term/src/commands/lsp.rs index 63d1608f928c..84d8e78990ce 100644 --- a/helix-term/src/commands/lsp.rs +++ b/helix-term/src/commands/lsp.rs @@ -66,6 +66,7 @@ macro_rules! language_server_with_feature { impl ui::menu::Item for lsp::Location { /// Current working directory. type Data = PathBuf; + type Config = (); fn format(&self, cwdir: &Self::Data) -> Row { // The preallocation here will overallocate a few characters since it will account for the @@ -105,6 +106,7 @@ struct SymbolInformationItem { impl ui::menu::Item for SymbolInformationItem { /// Path to currently focussed document type Data = Option; + type Config = (); fn format(&self, current_doc_path: &Self::Data) -> Row { if current_doc_path.as_ref() == Some(&self.symbol.location.uri) { @@ -141,6 +143,7 @@ struct PickerDiagnostic { impl ui::menu::Item for PickerDiagnostic { type Data = (DiagnosticStyles, DiagnosticsFormat); + type Config = (); fn format(&self, (styles, format): &Self::Data) -> Row { let mut style = self @@ -246,7 +249,7 @@ type SymbolPicker = Picker; fn sym_picker(symbols: Vec, current_path: Option) -> SymbolPicker { // TODO: drop current_path comparison and instead use workspace: bool flag? - Picker::new(symbols, current_path, move |cx, item, action| { + Picker::new((), symbols, current_path, move |cx, item, action| { jump_to_location( cx.editor, &item.symbol.location, @@ -295,6 +298,7 @@ fn diag_picker( }; Picker::new( + (), flat_diag, (styles, format), move |cx, @@ -502,6 +506,7 @@ struct CodeActionOrCommandItem { impl ui::menu::Item for CodeActionOrCommandItem { type Data = (); + type Config = (); fn format(&self, _data: &Self::Data) -> Row { match &self.lsp_item { lsp::CodeActionOrCommand::CodeAction(action) => action.title.as_str().into(), @@ -752,6 +757,7 @@ pub fn code_action(cx: &mut Context) { impl ui::menu::Item for lsp::Command { type Data = (); + type Config = (); fn format(&self, _data: &Self::Data) -> Row { self.title.as_str().into() } @@ -822,7 +828,7 @@ fn goto_impl( } [] => unreachable!("`locations` should be non-empty for `goto_impl`"), _locations => { - let picker = Picker::new(locations, cwdir, move |cx, location, action| { + let picker = Picker::new((), locations, cwdir, move |cx, location, action| { jump_to_location(cx.editor, location, offset_encoding, action) }) .with_preview(move |_editor, location| Some(location_to_file_location(location))); diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index 0dfcdb6e9bee..e69bc2b2ef88 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -117,7 +117,8 @@ fn open(cx: &mut compositor::Context, args: &[Cow], event: PromptEvent) -> let callback = async move { let call: job::Callback = job::Callback::EditorCompositor(Box::new( move |editor: &mut Editor, compositor: &mut Compositor| { - let picker = ui::file_picker(path.into_owned(), &editor.config()); + let picker = + ui::file_picker(path.into_owned(), editor.config().file_picker); compositor.push(Box::new(overlaid(picker))); }, )); @@ -1395,7 +1396,7 @@ fn lsp_workspace_command( let callback = async move { let call: job::Callback = Callback::EditorCompositor(Box::new( move |_editor: &mut Editor, compositor: &mut Compositor| { - let picker = ui::Picker::new(commands, (), move |cx, command, _action| { + let picker = ui::Picker::new((), commands, (), move |cx, command, _action| { execute_lsp_command(cx.editor, language_server_id, command.clone()); }); compositor.push(Box::new(overlaid(picker))) diff --git a/helix-term/src/ui/completion.rs b/helix-term/src/ui/completion.rs index 6cbb5b1095f4..c1090c24691c 100644 --- a/helix-term/src/ui/completion.rs +++ b/helix-term/src/ui/completion.rs @@ -26,6 +26,7 @@ use helix_lsp::{lsp, util, OffsetEncoding}; impl menu::Item for CompletionItem { type Data = (); + type Config = (); fn sort_text(&self, data: &Self::Data) -> Cow { self.filter_text(data) } diff --git a/helix-term/src/ui/menu.rs b/helix-term/src/ui/menu.rs index c0e60b33e344..85bf94f4f82a 100644 --- a/helix-term/src/ui/menu.rs +++ b/helix-term/src/ui/menu.rs @@ -15,15 +15,16 @@ use tui::{ pub use tui::widgets::{Cell, Row}; use helix_view::{ - editor::SmartTabConfig, + editor::{FilePickerConfig, SmartTabConfig}, graphics::{Margin, Rect}, Editor, }; use tui::layout::Constraint; -pub trait Item: Sync + Send + 'static { +pub trait Item: Sync + Send + Sized + 'static { /// Additional editor state that is used for label calculation. type Data: Sync + Send + 'static; + type Config; fn format(&self, data: &Self::Data) -> Row; @@ -36,11 +37,15 @@ pub trait Item: Sync + Send + 'static { let label: String = self.format(data).cell_text().collect(); label.into() } + + fn goto_parent(_picker: &mut Picker, _cx: &mut Context) {} + fn goto_child(_picker: &mut Picker, _cx: &mut Context) {} } impl Item for PathBuf { /// Root prefix to strip. type Data = PathBuf; + type Config = FilePickerConfig; fn format(&self, root_path: &Self::Data) -> Row { self.strip_prefix(root_path) @@ -48,6 +53,14 @@ impl Item for PathBuf { .to_string_lossy() .into() } + + fn goto_parent(picker: &mut Picker, cx: &mut Context) { + picker.goto_parent(cx); + } + + fn goto_child(picker: &mut Picker, cx: &mut Context) { + picker.goto_child(cx); + } } pub type MenuCallback = Box, MenuEvent)>; @@ -251,7 +264,7 @@ impl Menu { } } -use super::PromptEvent as MenuEvent; +use super::{Picker, PromptEvent as MenuEvent}; impl Component for Menu { fn handle_event(&mut self, event: &Event, cx: &mut Context) -> EventResult { diff --git a/helix-term/src/ui/mod.rs b/helix-term/src/ui/mod.rs index b5969818cf56..5d49b38b7378 100644 --- a/helix-term/src/ui/mod.rs +++ b/helix-term/src/ui/mod.rs @@ -19,6 +19,7 @@ use crate::job::{self, Callback}; pub use completion::{Completion, CompletionItem}; pub use editor::EditorView; use helix_stdx::rope; +use helix_view::editor::FilePickerConfig; pub use markdown::Markdown; pub use menu::Menu; pub use picker::{DynamicPicker, FileLocation, Picker}; @@ -29,7 +30,7 @@ pub use text::Text; use helix_view::Editor; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; pub fn prompt( cx: &mut crate::commands::Context, @@ -172,26 +173,22 @@ pub fn raw_regex_prompt( cx.push_layer(Box::new(prompt)); } -pub fn file_picker(root: PathBuf, config: &helix_view::editor::Config) -> Picker { +pub fn walk_dir(root: &Path, config: FilePickerConfig) -> impl Iterator { use ignore::{types::TypesBuilder, WalkBuilder}; - use std::time::Instant; - - let now = Instant::now(); - - let dedup_symlinks = config.file_picker.deduplicate_links; - let absolute_root = root.canonicalize().unwrap_or_else(|_| root.clone()); + let dedup_symlinks = config.deduplicate_links; + let absolute_root = root.canonicalize().unwrap_or_else(|_| root.to_owned()); let mut walk_builder = WalkBuilder::new(&root); walk_builder - .hidden(config.file_picker.hidden) - .parents(config.file_picker.parents) - .ignore(config.file_picker.ignore) - .follow_links(config.file_picker.follow_symlinks) - .git_ignore(config.file_picker.git_ignore) - .git_global(config.file_picker.git_global) - .git_exclude(config.file_picker.git_exclude) + .hidden(config.hidden) + .parents(config.parents) + .ignore(config.ignore) + .follow_links(config.follow_symlinks) + .git_ignore(config.git_ignore) + .git_global(config.git_global) + .git_exclude(config.git_exclude) .sort_by_file_name(|name1, name2| name1.cmp(name2)) - .max_depth(config.file_picker.max_depth) + .max_depth(config.max_depth) .filter_entry(move |entry| filter_picker_entry(entry, &absolute_root, dedup_symlinks)); walk_builder.add_custom_ignore_filename(helix_loader::config_dir().join("ignore")); @@ -210,26 +207,21 @@ pub fn file_picker(root: PathBuf, config: &helix_view::editor::Config) -> Picker .build() .expect("failed to build excluded_types"); walk_builder.types(excluded_types); - let mut files = walk_builder.build().filter_map(|entry| { + let files = walk_builder.build().filter_map(|entry| { let entry = entry.ok()?; if !entry.file_type()?.is_file() { return None; } Some(entry.into_path()) }); - log::debug!("file_picker init {:?}", Instant::now().duration_since(now)); - let picker = Picker::new(Vec::new(), root, move |cx, path: &PathBuf, action| { - 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.clone().into(), None))); + files +} + +pub fn inject_files( + picker: &Picker, + mut files: impl Iterator + Send + 'static, +) { let injector = picker.injector(); let timeout = std::time::Instant::now() + std::time::Duration::from_millis(30); @@ -252,6 +244,34 @@ pub fn file_picker(root: PathBuf, config: &helix_view::editor::Config) -> Picker } }); } +} + +pub fn file_picker(root: PathBuf, config: FilePickerConfig) -> Picker { + use std::time::Instant; + + let now = Instant::now(); + + let files = walk_dir(&root, config); + log::debug!("file_picker init {:?}", Instant::now().duration_since(now)); + + let picker = Picker::new( + config, + Vec::new(), + root, + move |cx, path: &PathBuf, action| { + 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.clone().into(), None))); + + inject_files(&picker, files); picker } diff --git a/helix-term/src/ui/picker.rs b/helix-term/src/ui/picker.rs index c2728888a1c4..c6ac39e28c2c 100644 --- a/helix-term/src/ui/picker.rs +++ b/helix-term/src/ui/picker.rs @@ -25,7 +25,8 @@ use tui::widgets::Widget; use std::{ collections::HashMap, io::Read, - path::PathBuf, + ops::Deref, + path::{Path, PathBuf}, sync::{ atomic::{self, AtomicBool}, Arc, @@ -47,7 +48,7 @@ use helix_view::{ }; pub const ID: &str = "picker"; -use super::{menu::Item, overlay::Overlay}; +use super::{inject_files, menu::Item, overlay::Overlay, walk_dir}; pub const MIN_AREA_WIDTH_FOR_PREVIEW: u16 = 72; /// Biggest file size to preview in bytes @@ -201,6 +202,8 @@ pub struct Picker { read_buffer: Vec, /// Given an item in the picker, return the file path and line number to display. file_fn: Option>, + + config: T::Config, } impl Picker { @@ -220,6 +223,7 @@ impl Picker { } pub fn new( + config: T::Config, options: Vec, editor_data: T::Data, callback_fn: impl Fn(&mut Context, &T, Action) + 'static, @@ -237,6 +241,7 @@ impl Picker { } } Self::with( + config, matcher, Arc::new(editor_data), Arc::new(AtomicBool::new(false)), @@ -248,11 +253,21 @@ impl Picker { matcher: Nucleo, injector: Injector, callback_fn: impl Fn(&mut Context, &T, Action) + 'static, - ) -> Self { - Self::with(matcher, injector.editor_data, injector.shutown, callback_fn) + ) -> Self + where + T::Config: Default, + { + Self::with( + T::Config::default(), + matcher, + injector.editor_data, + injector.shutown, + callback_fn, + ) } fn with( + config: T::Config, matcher: Nucleo, editor_data: Arc, shutdown: Arc, @@ -280,6 +295,7 @@ impl Picker { preview_cache: HashMap::new(), read_buffer: Vec::with_capacity(1024), file_fn: None, + config, } } @@ -800,6 +816,41 @@ impl Picker { } } +impl Picker { + fn change_root(&mut self, root: Arc, cx: &mut Context) { + cx.editor.set_status(root.display().to_string()); + self.editor_data = root; + let files = walk_dir(&self.editor_data, self.config); + self.matcher.restart(true); + inject_files(&self, files); + self.cursor = 0; + } + + pub fn goto_parent(&mut self, cx: &mut Context) { + if let Some(parent) = &self.editor_data.parent() { + self.change_root(Arc::new(parent.to_path_buf()), cx); + } + } + + pub fn goto_child(&mut self, cx: &mut Context) { + if let Some(selection) = self.selection() { + let component = selection + .strip_prefix(&*self.editor_data) + .ok() + .map(Path::components) + .and_then(|mut iter| iter.next()); + if let Some(comp) = component { + let mut child = self.editor_data.deref().clone(); + child.push(comp); + if child.is_dir() { + self.prompt.clear(cx.editor); + self.change_root(Arc::new(child), cx); + } + } + } + } +} + impl Component for Picker { fn render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) { // +---------+ +---------+ @@ -912,6 +963,12 @@ impl Component for Picker { ctrl!('t') => { self.toggle_preview(); } + ctrl!('a') => { + T::goto_parent(self, ctx); + } + ctrl!('e') => { + T::goto_child(self, ctx); + } _ => { self.prompt_handle_event(event, ctx); } diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index dd360a78ebdd..6e0dfb856539 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -165,7 +165,7 @@ impl Default for GutterLineNumbersConfig { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case", default, deny_unknown_fields)] pub struct FilePickerConfig { /// IgnoreOptions