diff --git a/book/src/generated/static-cmd.md b/book/src/generated/static-cmd.md index 0fed616ad..7eb98485e 100644 --- a/book/src/generated/static-cmd.md +++ b/book/src/generated/static-cmd.md @@ -80,6 +80,7 @@ | `search_selection_detect_word_boundaries` | Use current selection as the search pattern, automatically wrapping with `\b` on word boundaries | normal: `` * ``, select: `` * `` | | `make_search_word_bounded` | Modify current search to make it word bounded | | | `global_search` | Global search in workspace folder | normal: `` / ``, select: `` / `` | +| `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 | normal: `` x ``, select: `` x `` | | `extend_line_above` | Select current line, if already selected, extend to previous line | | diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index a3417ea1b..ff71ba3d6 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -44,7 +44,7 @@ use helix_core::{ Selection, SmallVec, Syntax, Tendril, Transaction, }; use helix_view::{ - document::{FormatterError, Mode, SCRATCH_BUFFER_NAME}, + document::{FormatterError, Mode, REFACTOR_BUFFER_NAME, SCRATCH_BUFFER_NAME}, editor::Action, info::Info, input::KeyEvent, @@ -380,6 +380,7 @@ impl MappableCommand { search_selection_detect_word_boundaries, "Use current selection as the search pattern, automatically wrapping with `\\b` on word boundaries", make_search_word_bounded, "Modify current search to make it word bounded", 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", @@ -2452,13 +2453,15 @@ fn global_search(cx: &mut Context) { path: PathBuf, /// 0 indexed lines line_num: usize, + line_content: String, } impl FileResult { - fn new(path: &Path, line_num: usize) -> Self { + fn new(path: &Path, line_num: usize, line_content: String) -> Self { Self { path: path.to_path_buf(), line_num, + line_content, } } } @@ -2583,9 +2586,13 @@ fn global_search(cx: &mut Context) { }; let mut stop = false; - let sink = sinks::UTF8(|line_num, _line_content| { + let sink = sinks::UTF8(|line_num, line_content| { stop = injector - .push(FileResult::new(entry.path(), line_num as usize - 1)) + .push(FileResult::new( + entry.path(), + line_num as usize - 1, + line_content.into(), + )) .is_err(); Ok(!stop) @@ -2670,12 +2677,127 @@ fn global_search(cx: &mut Context) { .with_preview(|_editor, FileResult { path, line_num, .. }| { Some((path.as_path().into(), Some((*line_num, *line_num)))) }) + .with_refactor(move |cx, results: Vec<&FileResult>| { + if results.is_empty() { + cx.editor.set_status("No matches found"); + return; + } + + let mut matches: HashMap> = HashMap::new(); + let mut lines: Vec<(PathBuf, usize)> = vec![]; + + for result in results { + let path = result.path.clone(); + let line = result.line_num; + let text = result.line_content.clone(); + + lines.push((path.clone(), line)); + matches.entry(path).or_default().push((line, text)); + } + + let mut doc_text = Rope::new(); + let mut line_map = HashMap::new(); + let language_id = doc!(cx.editor).language_id().map(String::from); + + let mut count = 0; + for (key, value) in &matches { + for (line, text) in value { + doc_text.insert(doc_text.len_chars(), text); + line_map.insert((key.clone(), *line), count); + count += 1; + } + } + doc_text.split_off(doc_text.len_chars().saturating_sub(1)); + let mut doc = Document::refactor( + doc_text, + matches, + line_map, + lines, + // TODO: actually learn how to detect encoding + None, + cx.editor.config.clone(), + cx.editor.syn_loader.clone(), + ); + if let Some(language_id) = language_id { + doc.set_language_by_language_id(&language_id, &cx.editor.syn_loader.load()) + .ok(); + }; + cx.editor.new_file_from_document(Action::Replace, doc); + }) .with_history_register(Some(reg)) .with_dynamic_query(get_files, Some(275)); cx.push_layer(Box::new(overlaid(picker))); } +fn global_refactor(cx: &mut Context) { + let document_type = doc!(cx.editor).document_type.clone(); + + match &document_type { + helix_view::document::DocumentType::File => (), + helix_view::document::DocumentType::Refactor { + matches, line_map, .. + } => { + let line_ending: LineEnding = cx.editor.config.load().default_line_ending.into(); + let refactor_id = doc!(cx.editor).id(); + let replace_text = doc!(cx.editor).text().clone(); + let view = view!(cx.editor).clone(); + let mut documents: usize = 0; + let mut count: usize = 0; + for (key, value) in matches { + let mut changes = Vec::<(usize, usize, String)>::new(); + for (line, text) in value { + if let Some(re_line) = line_map.get(&(key.clone(), *line)) { + let mut replace = replace_text + .get_line(*re_line) + .unwrap_or_else(|| "".into()) + .to_string() + .clone(); + + replace = replace + .strip_suffix(line_ending.as_str()) + .unwrap_or(&replace) + .to_string(); + replace.push_str(line_ending.as_str()); + if text != &replace { + changes.push((*line, text.chars().count(), replace)); + } + } + } + if !changes.is_empty() { + if let Some(doc) = cx + .editor + .open(key, Action::Load) + .ok() + .and_then(|id| cx.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()); + doc.apply(&transaction, view.id); + } + } + } + cx.editor.set_status(format!( + "Refactored {} documents, {} lines changed.", + documents, count + )); + cx.editor.close_document(refactor_id, true).ok(); + } + } +} + enum Extend { Above, Below, @@ -3153,6 +3275,7 @@ fn buffer_picker(cx: &mut Context) { is_modified: bool, is_current: bool, focused_at: std::time::Instant, + is_refactor: bool, } let new_meta = |doc: &Document| BufferMeta { @@ -3161,6 +3284,14 @@ fn buffer_picker(cx: &mut Context) { is_modified: doc.is_modified(), is_current: doc.id() == current, focused_at: doc.focused_at, + is_refactor: matches!( + &doc.document_type, + helix_view::document::DocumentType::Refactor { + matches: _, + line_map: _, + lines: _, + } + ), }; let mut items = cx @@ -3186,6 +3317,9 @@ fn buffer_picker(cx: &mut Context) { flags.into() }), PickerColumn::new("path", |meta: &BufferMeta, _| { + if meta.is_refactor { + return REFACTOR_BUFFER_NAME.into(); + } let path = meta .path .as_deref() diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs index 9343d55d4..8bc3d8a19 100644 --- a/helix-term/src/ui/editor.rs +++ b/helix-term/src/ui/editor.rs @@ -583,13 +583,20 @@ impl EditorView { let current_doc = view!(editor).doc; for doc in editor.documents() { - let fname = doc - .path() - .unwrap_or(&scratch) - .file_name() - .unwrap_or_default() - .to_str() - .unwrap_or_default(); + let fname = match &doc.document_type { + helix_view::document::DocumentType::File => doc + .path() + .unwrap_or(&scratch) + .file_name() + .unwrap_or_default() + .to_str() + .unwrap_or_default(), + helix_view::document::DocumentType::Refactor { + matches: _, + line_map: _, + lines: _, + } => helix_view::document::REFACTOR_BUFFER_NAME, + }; let style = if current_doc == doc.id() { bufferline_active diff --git a/helix-term/src/ui/picker.rs b/helix-term/src/ui/picker.rs index 3f3aaba2b..85465a861 100644 --- a/helix-term/src/ui/picker.rs +++ b/helix-term/src/ui/picker.rs @@ -266,6 +266,7 @@ pub struct Picker { /// Given an item in the picker, return the file path and line number to display. file_fn: Option>, /// An event handler for syntax highlighting the currently previewed file. + refactor_fn: RefactorCallback, preview_highlight_handler: Sender>, dynamic_query_handler: Option>, } @@ -382,6 +383,7 @@ impl Picker { truncate_start: true, show_preview: true, callback_fn: Box::new(callback_fn), + refactor_fn: None, completion_height: 0, widths, preview_cache: HashMap::new(), @@ -419,6 +421,11 @@ impl Picker { self } + pub fn with_refactor(mut self, quickfix_fn: impl Fn(&mut Context, Vec<&T>) + 'static) -> Self { + self.refactor_fn = Some(Box::new(quickfix_fn)); + self + } + pub fn with_history_register(mut self, history_register: Option) -> Self { self.prompt.with_history_register(history_register); self @@ -490,6 +497,15 @@ impl Picker { .map(|item| item.data) } + pub fn get_list(&self) -> Vec<&T> { + let matcher = self.matcher.snapshot(); + let total = matcher.matched_item_count(); + matcher + .matched_items(0..total) + .map(|item| item.data) + .collect() + } + fn primary_query(&self) -> Arc { self.query .get(&self.columns[self.primary_column].name) @@ -1124,6 +1140,15 @@ impl Component for Picker { self.toggle_preview(); } + ctrl!('q') => { + if self.selection().is_some() { + if let Some(quickfix) = &self.refactor_fn { + let items = self.get_list(); + (quickfix)(ctx, items); + } + } + return close_fn(self); + } _ => { self.prompt_handle_event(event, ctx); } @@ -1168,3 +1193,4 @@ impl Drop for Picker { } type PickerCallback = Box; +type RefactorCallback = Option)>>; diff --git a/helix-term/src/ui/statusline.rs b/helix-term/src/ui/statusline.rs index ea3d27bd6..48ee2cdcc 100644 --- a/helix-term/src/ui/statusline.rs +++ b/helix-term/src/ui/statusline.rs @@ -5,7 +5,7 @@ use helix_core::{coords_at_pos, encoding, Position}; use helix_lsp::lsp::DiagnosticSeverity; use helix_view::document::DEFAULT_LANGUAGE_NAME; use helix_view::{ - document::{Mode, SCRATCH_BUFFER_NAME}, + document::{Mode, REFACTOR_BUFFER_NAME, SCRATCH_BUFFER_NAME}, graphics::Rect, theme::Style, Document, Editor, View, @@ -450,12 +450,21 @@ where F: Fn(&mut RenderContext<'a>, Span<'a>) + Copy, { let title = { - let rel_path = context.doc.relative_path(); - let path = rel_path - .as_ref() - .map(|p| p.to_string_lossy()) - .unwrap_or_else(|| SCRATCH_BUFFER_NAME.into()); - format!(" {} ", path) + match &context.doc.document_type { + helix_view::document::DocumentType::File => { + let rel_path = context.doc.relative_path(); + let path = rel_path + .as_ref() + .map(|p| p.to_string_lossy()) + .unwrap_or_else(|| SCRATCH_BUFFER_NAME.into()); + format!(" {} ", path) + } + helix_view::document::DocumentType::Refactor { + matches: _, + line_map: _, + lines: _, + } => REFACTOR_BUFFER_NAME.into(), + } }; write(context, title.into()); diff --git a/helix-view/src/document.rs b/helix-view/src/document.rs index 04b7703c5..78f7123d2 100644 --- a/helix-view/src/document.rs +++ b/helix-view/src/document.rs @@ -61,6 +61,8 @@ pub const DEFAULT_LANGUAGE_NAME: &str = "text"; pub const SCRATCH_BUFFER_NAME: &str = "[scratch]"; +pub const REFACTOR_BUFFER_NAME: &str = "[refactor]"; + #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] pub enum Mode { Normal = 0, @@ -138,6 +140,19 @@ pub enum DocumentOpenError { IoError(#[from] io::Error), } +#[derive(Debug, Clone)] +pub enum DocumentType { + File, + Refactor { + // Filepath: list of (line_num, text) + matches: HashMap>, + // (Filepath, line_num): line_num (in buffer) + line_map: HashMap<(PathBuf, usize), usize>, + // List of (line_num, Filepath) in buffer order + lines: Vec<(PathBuf, usize)>, + }, +} + pub struct Document { pub(crate) id: DocumentId, text: Rope, @@ -210,6 +225,7 @@ pub struct Document { // large refactor that would make `&mut Editor` available on the `DocumentDidChange` event. pub color_swatch_controller: TaskController, + pub document_type: DocumentType, // NOTE: this field should eventually go away - we should use the Editor's syn_loader instead // of storing a copy on every doc. Then we can remove the surrounding `Arc` and use the // `ArcSwap` directly. @@ -728,6 +744,66 @@ impl Document { color_swatches: None, color_swatch_controller: TaskController::new(), syn_loader, + document_type: DocumentType::File, + } + } + + pub fn refactor( + text: Rope, + matches: HashMap>, + line_map: HashMap<(PathBuf, usize), usize>, + lines: Vec<(PathBuf, usize)>, + encoding_with_bom_info: Option<(&'static Encoding, bool)>, + config: Arc>, + syn_loader: Arc>, + ) -> Self { + let (encoding, has_bom) = encoding_with_bom_info.unwrap_or((encoding::UTF_8, false)); + let line_ending = config.load().default_line_ending.into(); + let changes = ChangeSet::new(text.slice(..)); + let old_state = None; + + Self { + id: DocumentId::default(), + active_snippet: None, + path: None, + relative_path: OnceCell::new(), + encoding, + has_bom, + text, + selections: HashMap::default(), + inlay_hints: HashMap::default(), + inlay_hints_oudated: false, + view_data: Default::default(), + indent_style: DEFAULT_INDENT, + editor_config: EditorConfig::default(), + line_ending, + restore_cursor: false, + syntax: None, + language: None, + changes, + old_state, + diagnostics: Vec::new(), + version: 0, + history: Cell::new(History::default()), + savepoints: Vec::new(), + last_saved_time: SystemTime::now(), + last_saved_revision: 0, + modified_since_accessed: false, + language_servers: HashMap::new(), + diff_handle: None, + config, + version_control_head: None, + focused_at: std::time::Instant::now(), + readonly: false, + jump_labels: HashMap::new(), + color_swatches: None, + color_swatch_controller: TaskController::new(), + syn_loader, + document_type: DocumentType::Refactor { + matches, + line_map, + lines, + }, } } @@ -1731,16 +1807,25 @@ impl Document { /// If there are unsaved modifications. pub fn is_modified(&self) -> bool { - let history = self.history.take(); - let current_revision = history.current_revision(); - self.history.set(history); - log::debug!( - "id {} modified - last saved: {}, current: {}", - self.id, - self.last_saved_revision, - current_revision - ); - current_revision != self.last_saved_revision || !self.changes.is_empty() + match self.document_type { + DocumentType::File => { + let history = self.history.take(); + let current_revision = history.current_revision(); + self.history.set(history); + log::debug!( + "id {} modified - last saved: {}, current: {}", + self.id, + self.last_saved_revision, + current_revision + ); + current_revision != self.last_saved_revision || !self.changes.is_empty() + } + DocumentType::Refactor { + matches: _, + line_map: _, + lines: _, + } => false, + } } /// Save modifications to history, and so [`Self::is_modified`] will return false. diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index 57e130881..6f9ff9b17 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -1771,7 +1771,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 diff --git a/helix-view/src/gutter.rs b/helix-view/src/gutter.rs index c2cbc0da5..e44e767b4 100644 --- a/helix-view/src/gutter.rs +++ b/helix-view/src/gutter.rs @@ -2,6 +2,7 @@ use std::fmt::Write; use helix_core::syntax::config::LanguageServerFeature; +use crate::document::DocumentType; use crate::{ editor::GutterType, graphics::{Style, UnderlineStyle}, @@ -16,6 +17,8 @@ pub type GutterFn<'doc> = Box Optio pub type Gutter = for<'doc> fn(&'doc Editor, &'doc Document, &View, &Theme, bool, usize) -> GutterFn<'doc>; +const REFACTOR_GUTTER_WIDTH: usize = 20; + impl GutterType { pub fn style<'doc>( self, @@ -151,10 +154,6 @@ pub fn line_numbers<'doc>( let last_line_in_view = view.estimate_last_doc_line(doc); - // Whether to draw the line number for the last line of the - // document or not. We only draw it if it's not an empty line. - let draw_last = text.line_to_byte(last_line_in_view) < text.len_bytes(); - let linenr = theme.get("ui.linenr"); let linenr_select = theme.get("ui.linenr.selected"); @@ -163,43 +162,93 @@ pub fn line_numbers<'doc>( .char_to_line(doc.selection(view.id).primary().cursor(text)); let line_number = editor.config().line_number; - let mode = editor.mode; + let draw_last = text.line_to_byte(last_line_in_view) < text.len_bytes(); - Box::new( - move |line: usize, selected: bool, first_visual_line: bool, out: &mut String| { - if line == last_line_in_view && !draw_last { - write!(out, "{:>1$}", '~', width).unwrap(); - Some(linenr) - } else { - use crate::{document::Mode, editor::LineNumber}; - - let relative = line_number == LineNumber::Relative - && mode != Mode::Insert - && is_focused - && current_line != line; - - let display_num = if relative { - current_line.abs_diff(line) + match &doc.document_type { + DocumentType::File => Box::new( + move |line: usize, selected: bool, first_visual_line: bool, out: &mut String| { + // Whether to draw the line number for the last line of the + // document or not. We only draw it if it's not an empty line. + let mode = editor.mode; + if line == last_line_in_view && !draw_last { + write!(out, "{:>1$}", '~', width).unwrap(); + Some(linenr) } else { - line + 1 - }; + use crate::{document::Mode, editor::LineNumber}; + let relative = line_number == LineNumber::Relative + && mode != Mode::Insert + && is_focused + && current_line != line; + + let display_num = if relative { + current_line.abs_diff(line) + } else { + line + 1 + }; + + let style = if selected && is_focused { + linenr_select + } else { + linenr + }; + + if first_visual_line { + write!(out, "{:>1$}", display_num, width).unwrap(); + } else { + write!(out, "{:>1$}", " ", width).unwrap(); + } + + first_visual_line.then_some(style) + } + }, + ), + DocumentType::Refactor { + matches: _, + line_map: _, + lines, + } => Box::new( + move |line: usize, selected: bool, first_visual_line: bool, out: &mut String| { + if let Some((file_path, line_num)) = lines.get(line) { + let gutter = format_path_line( + file_path.to_str().unwrap_or(""), + line_num + 1, + REFACTOR_GUTTER_WIDTH, + ); + write!(out, "{}", gutter).unwrap(); + } else { + write!(out, "{:>1$}", '~', REFACTOR_GUTTER_WIDTH).unwrap(); + } let style = if selected && is_focused { linenr_select } else { linenr }; - - if first_visual_line { - write!(out, "{:>1$}", display_num, width).unwrap(); - } else { - write!(out, "{:>1$}", " ", width).unwrap(); - } - + // Some(style) first_visual_line.then_some(style) - } - }, - ) + }, + ), + } +} + +fn format_path_line(path: &str, line: usize, max_width: usize) -> String { + let raw = format!("{path}:{line}"); + + if raw.len() <= max_width { + format!("{:>width$}", raw, width = max_width) + } else { + let ellipsis = "..."; + let keep = max_width.saturating_sub(ellipsis.len()); + let tail = raw + .chars() + .rev() + .take(keep) + .collect::() + .chars() + .rev() + .collect::(); + format!("{:>width$}", format!("{ellipsis}{tail}"), width = max_width) + } } /// The width of a "line-numbers" gutter @@ -208,6 +257,9 @@ pub fn line_numbers<'doc>( /// whether there is content on the last line (the `~` line), and the /// `editor.gutters.line-numbers.min-width` settings. fn line_numbers_width(view: &View, doc: &Document) -> usize { + if matches!(doc.document_type, DocumentType::Refactor { .. }) { + return REFACTOR_GUTTER_WIDTH; + } let text = doc.text(); let last_line = text.len_lines().saturating_sub(1); let draw_last = text.line_to_byte(last_line) < text.len_bytes();