From 002189af611876cb91aca5abb34610d7a64a1396 Mon Sep 17 00:00:00 2001 From: pathwave Date: Thu, 20 Oct 2022 12:37:14 +0200 Subject: [PATCH] Impl refactoring view --- helix-term/src/commands.rs | 183 +++++++++++++++++ helix-term/src/ui/mod.rs | 2 + helix-term/src/ui/refactor.rs | 372 ++++++++++++++++++++++++++++++++++ helix-view/src/editor.rs | 2 +- 4 files changed, 558 insertions(+), 1 deletion(-) create mode 100644 helix-term/src/ui/refactor.rs diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index adb4802d8f456..56a3e3d0b7ca8 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -250,6 +250,7 @@ impl MappableCommand { extend_search_prev, "Add previous search match to selection", search_selection, "Use current selection as search pattern", global_search, "Global search in workspace folder", + global_refactor, "Global refactoring in workspace folder", extend_line, "Select current line, if already selected, extend to another line based on the anchor", extend_line_below, "Select current line, if already selected, extend to next line", extend_line_above, "Select current line, if already selected, extend to previous line", @@ -1981,6 +1982,188 @@ fn global_search(cx: &mut Context) { }; cx.jobs.callback(show_picker); } +fn global_refactor(cx: &mut Context) { + let (all_matches_sx, all_matches_rx) = + tokio::sync::mpsc::unbounded_channel::<(PathBuf, usize, String)>(); + let config = cx.editor.config(); + let smart_case = config.search.smart_case; + let file_picker_config = config.file_picker.clone(); + + let reg = cx.register.unwrap_or('/'); + + // Restrict to current file type if possible + let file_extension = doc!(cx.editor).path().and_then(|f| f.extension()); + let file_glob = if let Some(file_glob) = file_extension.and_then(|f| f.to_str()) { + let mut tb = ignore::types::TypesBuilder::new(); + tb.add("p", &(String::from("*.") + file_glob)) + .ok() + .and_then(|_| { + tb.select("all"); + tb.build().ok() + }) + } else { + None + }; + + let completions = search_completions(cx, Some(reg)); + ui::regex_prompt( + cx, + "global-refactor:".into(), + Some(reg), + move |_editor: &Editor, input: &str| { + completions + .iter() + .filter(|comp| comp.starts_with(input)) + .map(|comp| (0.., std::borrow::Cow::Owned(comp.clone()))) + .collect() + }, + move |editor, regex, event| { + if event != PromptEvent::Validate { + return; + } + + if let Ok(matcher) = RegexMatcherBuilder::new() + .case_smart(smart_case) + .build(regex.as_str()) + { + let searcher = SearcherBuilder::new() + .binary_detection(BinaryDetection::quit(b'\x00')) + .build(); + + let mut checked = HashSet::::new(); + let file_extension = editor.documents[&editor.tree.get(editor.tree.focus).doc] + .path() + .and_then(|f| f.extension()); + for doc in editor.documents() { + searcher + .clone() + .search_slice( + matcher.clone(), + doc.text().to_string().as_bytes(), + sinks::UTF8(|line_num, matched| { + if let Some(path) = doc.path() { + if let Some(extension) = path.extension() { + if let Some(file_extension) = file_extension { + if file_extension == extension { + all_matches_sx + .send(( + path.clone(), + line_num as usize - 1, + String::from( + matched + .strip_suffix("\r\n") + .or(matched.strip_suffix("\n")) + .unwrap_or(matched), + ), + )) + .unwrap(); + } + } + } + // Exclude from file search + checked.insert(path.clone()); + } + Ok(true) + }), + ) + .ok(); + } + + let search_root = std::env::current_dir() + .expect("Global search error: Failed to get current dir"); + let mut wb = WalkBuilder::new(search_root); + wb.hidden(file_picker_config.hidden) + .parents(file_picker_config.parents) + .ignore(file_picker_config.ignore) + .git_ignore(file_picker_config.git_ignore) + .git_global(file_picker_config.git_global) + .git_exclude(file_picker_config.git_exclude) + .max_depth(file_picker_config.max_depth); + if let Some(file_glob) = &file_glob { + wb.types(file_glob.clone()); + } + wb.build_parallel().run(|| { + let mut searcher = searcher.clone(); + let matcher = matcher.clone(); + let all_matches_sx = all_matches_sx.clone(); + let checked = checked.clone(); + Box::new(move |entry: Result| -> WalkState { + let entry = match entry { + Ok(entry) => entry, + Err(_) => return WalkState::Continue, + }; + + match entry.file_type() { + Some(entry) if entry.is_file() => {} + // skip everything else + _ => return WalkState::Continue, + }; + + let result = searcher.search_path( + &matcher, + entry.path(), + sinks::UTF8(|line_num, matched| { + let path = entry.clone().into_path(); + if !checked.contains(&path) { + all_matches_sx + .send(( + path, + line_num as usize - 1, + String::from( + matched + .strip_suffix("\r\n") + .or(matched.strip_suffix("\n")) + .unwrap_or(matched), + ), + )) + .unwrap(); + } + Ok(true) + }), + ); + + if let Err(err) = result { + log::error!("Global search error: {}, {}", entry.path().display(), err); + } + WalkState::Continue + }) + }); + } + }, + ); + + let show_refactor = async move { + let all_matches: Vec<(PathBuf, usize, String)> = + UnboundedReceiverStream::new(all_matches_rx).collect().await; + let call: job::Callback = + Box::new(move |editor: &mut Editor, compositor: &mut Compositor| { + if all_matches.is_empty() { + editor.set_status("No matches found"); + return; + } + let mut document_data: HashMap> = HashMap::new(); + for (path, line, text) in all_matches { + if let Some(vec) = document_data.get_mut(&path) { + vec.push((line, text)); + } else { + let v = Vec::from([(line, text)]); + document_data.insert(path, v); + } + } + + let editor_view = compositor.find::().unwrap(); + let language_id = doc!(editor) + .language_id() + .and_then(|language_id| Some(String::from(language_id))); + + let re_view = + ui::RefactorView::new(document_data, editor, editor_view, language_id); + compositor.push(Box::new(re_view)); + }); + Ok(call) + }; + cx.jobs.callback(show_refactor); +} enum Extend { Above, diff --git a/helix-term/src/ui/mod.rs b/helix-term/src/ui/mod.rs index f99dea0b8dc3c..9cfef691bd0aa 100644 --- a/helix-term/src/ui/mod.rs +++ b/helix-term/src/ui/mod.rs @@ -9,6 +9,7 @@ pub mod overlay; mod picker; pub mod popup; mod prompt; +mod refactor; mod spinner; mod statusline; mod text; @@ -22,6 +23,7 @@ pub use menu::Menu; pub use picker::{FileLocation, FilePicker, Picker}; pub use popup::Popup; pub use prompt::{Prompt, PromptEvent}; +pub use refactor::RefactorView; pub use spinner::{ProgressSpinners, Spinner}; pub use text::Text; diff --git a/helix-term/src/ui/refactor.rs b/helix-term/src/ui/refactor.rs new file mode 100644 index 0000000000000..50ac1044558ad --- /dev/null +++ b/helix-term/src/ui/refactor.rs @@ -0,0 +1,372 @@ +use crate::{ + compositor::{Component, Compositor, Context, Event, EventResult}, + keymap::{KeyTrie, KeyTrieNode, Keymap}, +}; + +use arc_swap::access::DynGuard; +use helix_core::{ + syntax::{self, HighlightEvent}, + Rope, Tendril, Transaction, +}; +use helix_view::{ + apply_transaction, document::Mode, editor::Action, graphics::Rect, keyboard::KeyCode, + theme::Style, Document, Editor, View, +}; +use once_cell::sync::Lazy; +use std::{ + collections::{HashMap, HashSet}, + path::PathBuf, +}; + +use tui::buffer::Buffer as Surface; + +use super::EditorView; + +const UNSUPPORTED_COMMANDS: Lazy> = Lazy::new(|| { + HashSet::from([ + "global_search", + "global_refactor", + // "command_mode", + "file_picker", + "file_picker_in_current_directory", + "code_action", + "buffer_picker", + "jumplist_picker", + "symbol_picker", + "select_references_to_symbol_under_cursor", + "workspace_symbol_picker", + "diagnostics_picker", + "workspace_diagnostics_picker", + "last_picker", + "goto_definition", + "goto_type_definition", + "goto_implementation", + "goto_file", + "goto_file_hsplit", + "goto_file_vsplit", + "goto_reference", + "goto_window_top", + "goto_window_center", + "goto_window_bottom", + "goto_last_accessed_file", + "goto_last_modified_file", + "goto_last_modification", + "goto_line", + "goto_last_line", + "goto_first_diag", + "goto_last_diag", + "goto_next_diag", + "goto_prev_diag", + "goto_line_start", + "goto_line_end", + "goto_next_buffer", + "goto_previous_buffer", + "signature_help", + "completion", + "hover", + "select_next_sibling", + "select_prev_sibling", + "jump_view_right", + "jump_view_left", + "jump_view_up", + "jump_view_down", + "swap_view_right", + "swap_view_left", + "swap_view_up", + "swap_view_down", + "transpose_view", + "rotate_view", + "hsplit", + "hsplit_new", + "vsplit", + "vsplit_new", + "wonly", + "select_textobject_around", + "select_textobject_inner", + "goto_next_function", + "goto_prev_function", + "goto_next_class", + "goto_prev_class", + "goto_next_parameter", + "goto_prev_parameter", + "goto_next_comment", + "goto_prev_comment", + "goto_next_test", + "goto_prev_test", + "goto_next_paragraph", + "goto_prev_paragraph", + "dap_launch", + "dap_toggle_breakpoint", + "dap_continue", + "dap_pause", + "dap_step_in", + "dap_step_out", + "dap_next", + "dap_variables", + "dap_terminate", + "dap_edit_condition", + "dap_edit_log", + "dap_switch_thread", + "dap_switch_stack_frame", + "dap_enable_exceptions", + "dap_disable_exceptions", + "shell_pipe", + "shell_pipe_to", + "shell_insert_output", + "shell_append_output", + "shell_keep_pipe", + "suspend", + "rename_symbol", + "record_macro", + "replay_macro", + "command_palette", + ]) +}); + +pub struct RefactorView { + matches: HashMap>, + line_map: HashMap<(PathBuf, usize), usize>, + keymap: DynGuard>, + sticky: Option, + apply_prompt: bool, +} + +impl RefactorView { + pub fn new( + matches: HashMap>, + editor: &mut Editor, + editor_view: &mut EditorView, + language_id: Option, + ) -> Self { + let keymap = editor_view.keymaps.map(); + let mut review = RefactorView { + matches, + keymap, + sticky: None, + line_map: HashMap::new(), + apply_prompt: false, + }; + let mut doc_text = Rope::new(); + + let mut count = 0; + for (key, value) in &review.matches { + for (line, text) in value { + doc_text.insert(doc_text.len_chars(), &text); + doc_text.insert(doc_text.len_chars(), "\n"); + review.line_map.insert((key.clone(), *line), count); + count += 1; + } + } + doc_text.split_off(doc_text.len_chars().saturating_sub(1)); + let mut doc = Document::from(doc_text, None); + if let Some(language_id) = language_id { + doc.set_language_by_language_id(&language_id, editor.syn_loader.clone()) + .ok(); + }; + editor.new_file_from_document(Action::Replace, doc); + let doc = doc_mut!(editor); + let viewid = editor.tree.insert(View::new(doc.id(), vec![])); + editor.tree.focus = viewid; + doc.ensure_view_init(viewid); + doc.reset_selection(viewid); + + review + } + + fn apply_refactor(&self, editor: &mut Editor) -> (usize, usize) { + let replace_text = doc!(editor).text().clone(); + let mut view = view!(editor).clone(); + let mut documents: usize = 0; + let mut count: usize = 0; + for (key, value) in &self.matches { + let mut changes = Vec::<(usize, usize, String)>::new(); + for (line, text) in value { + if let Some(re_line) = self.line_map.get(&(key.clone(), *line)) { + let mut replace = replace_text + .get_line(*re_line) + .unwrap_or("\n".into()) + .to_string() + .clone(); + replace = replace.strip_suffix("\n").unwrap_or(&replace).to_string(); + if text != &replace { + changes.push((*line, text.chars().count(), replace)); + } + } + } + if !changes.is_empty() { + if let Some(doc) = editor + .open(&key, Action::Load) + .ok() + .and_then(|id| editor.document_mut(id)) + { + documents += 1; + let mut applychanges = Vec::<(usize, usize, Option)>::new(); + for (line, length, text) in changes { + if doc.text().len_lines() > line { + let start = doc.text().line_to_char(line); + applychanges.push(( + start, + start + length, + Some(Tendril::from(text.to_string())), + )); + count += 1; + } + } + let transaction = Transaction::change(doc.text(), applychanges.into_iter()); + apply_transaction(&transaction, doc, &mut view); + } + } + } + (documents, count) + } + + fn render_view(&self, editor: &Editor, surface: &mut Surface) { + let doc = doc!(editor); + let view = view!(editor); + let offset = view.offset; + let mut area = view.area; + + self.render_doc_name(surface, &mut area, offset); + let highlights = + EditorView::doc_syntax_highlights(&doc, offset, area.height, &editor.theme); + let highlights: Box> = Box::new(syntax::merge( + highlights, + EditorView::doc_selection_highlights( + editor.mode(), + &doc, + &view, + &editor.theme, + &editor.config().cursor_shape, + ), + )); + + EditorView::render_text_highlights( + &doc, + offset, + area, + surface, + &editor.theme, + highlights, + &editor.config(), + ); + } + + fn render_doc_name( + &self, + surface: &mut Surface, + area: &mut Rect, + offset: helix_core::Position, + ) { + let mut start = 0; + for (key, value) in &self.matches { + for (line, _) in value { + if start >= offset.row { + let text = key.display().to_string() + ":" + line.to_string().as_str(); + surface.set_string_truncated( + area.x as u16, + area.y + start as u16, + &text, + 15, + |_| Style::default().fg(helix_view::theme::Color::Magenta), + true, + true, + ); + } + start += 1; + } + } + area.x = 15; + } + + #[inline] + fn close(&self, editor: &mut Editor) -> EventResult { + editor.close_document(doc!(editor).id(), true).ok(); + editor.autoinfo = None; + EventResult::Consumed(Some(Box::new(|compositor: &mut Compositor, _cx| { + compositor.pop(); + }))) + } +} + +impl Component for RefactorView { + fn handle_event(&mut self, event: &Event, cx: &mut Context) -> EventResult { + let config = cx.editor.config(); + let (view, doc) = current!(cx.editor); + view.ensure_cursor_in_view(&doc, config.scrolloff); + match event { + Event::Key(event) => match event.code { + KeyCode::Esc => { + self.sticky = None; + } + _ => { + // Temp solution + if self.apply_prompt { + if let Some(char) = event.char() { + if char == 'y' || char == 'Y' { + let (documents, count) = self.apply_refactor(cx.editor); + let result = format!( + "Refactored {} documents, {} lines changed.", + documents, count + ); + cx.editor.set_status(result); + return self.close(cx.editor); + } + } + cx.editor.set_status("Aborted"); + self.apply_prompt = false; + return EventResult::Consumed(None); + } + let sticky = self.sticky.clone(); + if let Some(key) = sticky.as_ref().and_then(|sticky| sticky.get(event)).or(self + .keymap + .get(&cx.editor.mode) + .and_then(|map| map.get(event))) + { + match key { + KeyTrie::Leaf(command) => { + if UNSUPPORTED_COMMANDS.contains(command.name()) { + cx.editor + .set_status("Command not supported in refactor view"); + return EventResult::Consumed(None); + } else if command.name() == "wclose" { + return self.close(cx.editor); + // TODO: custom command mode + } else if command.name() == "command_mode" { + cx.editor.set_status("Apply changes to documents? (y/n): "); + self.apply_prompt = true; + return EventResult::Consumed(None); + } + self.sticky = None; + cx.editor.autoinfo = None; + } + KeyTrie::Sequence(_) => (), + KeyTrie::Node(node) => { + self.sticky = Some(node.clone()); + cx.editor.autoinfo = Some(node.infobox()); + return EventResult::Consumed(None); + } + } + } + } + }, + _ => (), + } + + EventResult::Ignored(None) + } + + fn render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) { + let view = view_mut!(cx.editor); + let area = area.clip_bottom(1); + view.area = area; + surface.clear_with(area, cx.editor.theme.get("ui.background")); + + self.render_view(&cx.editor, surface); + if cx.editor.config().auto_info { + if let Some(mut info) = cx.editor.autoinfo.take() { + info.render(area, surface, cx); + cx.editor.autoinfo = Some(info) + } + } + } +} diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index af69ceeae19a6..676418b76d84d 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -1070,7 +1070,7 @@ impl Editor { id } - fn new_file_from_document(&mut self, action: Action, doc: Document) -> DocumentId { + pub fn new_file_from_document(&mut self, action: Action, doc: Document) -> DocumentId { let id = self.new_document(doc); self.switch(id, action); id