From 810dc46010ac9ba8e9eea2871f19f14b6bc70e32 Mon Sep 17 00:00:00 2001 From: pathwave Date: Thu, 20 Oct 2022 12:37:14 +0200 Subject: [PATCH 1/8] Impl refactoring view --- helix-term/src/commands.rs | 315 +++++++++++++++++++++++++++++++- helix-term/src/ui/editor.rs | 20 +- helix-term/src/ui/statusline.rs | 22 ++- helix-view/src/document.rs | 82 ++++++++- helix-view/src/editor.rs | 2 +- 5 files changed, 415 insertions(+), 26 deletions(-) diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 2cbdeb451..a5b50c3ac 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -42,7 +42,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, @@ -378,6 +378,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", @@ -2668,6 +2669,275 @@ fn global_search(cx: &mut Context) { 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 => { + 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 encoding = Some(doc!(cx.editor).encoding()); + + 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_else(|| { + 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_else(|| 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 = Callback::Editor(Box::new(move |editor: &mut Editor| { + if all_matches.is_empty() { + editor.set_status("No matches found"); + return; + } + let mut matches: HashMap> = HashMap::new(); + for (path, line, text) in all_matches { + if let Some(vec) = matches.get_mut(&path) { + vec.push((line, text)); + } else { + let v = Vec::from([(line, text)]); + matches.insert(path, v); + } + } + + let language_id = doc!(editor).language_id().map(String::from); + + let mut doc_text = Rope::new(); + let mut line_map = HashMap::new(); + + let mut count = 0; + for (key, value) in &matches { + for (line, text) in value { + doc_text.insert(doc_text.len_chars(), text); + doc_text.insert(doc_text.len_chars(), "\n"); + 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, + encoding, + editor.config.clone(), + ); + 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); + })); + Ok(call) + }; + cx.jobs.callback(show_refactor); + } + helix_view::document::DocumentType::Refactor { matches, line_map } => { + 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(|| "\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) = 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, @@ -3144,7 +3414,40 @@ fn buffer_picker(cx: &mut Context) { path: Option, is_modified: bool, is_current: bool, +<<<<<<< HEAD focused_at: std::time::Instant, +======= + is_refactor: bool, + } + + impl ui::menu::Item for BufferMeta { + type Data = (); + + fn format(&self, _data: &Self::Data) -> Row { + let path = self + .path + .as_deref() + .map(helix_core::path::get_relative_path); + let path = if self.is_refactor { + REFACTOR_BUFFER_NAME + } else { + match path.as_deref().and_then(Path::to_str) { + Some(path) => path, + None => SCRATCH_BUFFER_NAME, + } + }; + + let mut flags = String::new(); + if self.is_modified { + flags.push('+'); + } + if self.is_current { + flags.push('*'); + } + + Row::new([self.id.to_string(), flags, path.to_string()]) + } +>>>>>>> f55507e4 (Impl refactoring view) } let new_meta = |doc: &Document| BufferMeta { @@ -3152,7 +3455,17 @@ fn buffer_picker(cx: &mut Context) { path: doc.path().cloned(), is_modified: doc.is_modified(), is_current: doc.id() == current, +<<<<<<< HEAD focused_at: doc.focused_at, +======= + is_refactor: matches!( + &doc.document_type, + helix_view::document::DocumentType::Refactor { + matches: _, + line_map: _ + } + ), +>>>>>>> f55507e4 (Impl refactoring view) }; let mut items = cx diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs index 9343d55d4..03ac69ca8 100644 --- a/helix-term/src/ui/editor.rs +++ b/helix-term/src/ui/editor.rs @@ -583,13 +583,19 @@ 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: _, + } => helix_view::document::REFACTOR_BUFFER_NAME, + }; let style = if current_doc == doc.id() { bufferline_active diff --git a/helix-term/src/ui/statusline.rs b/helix-term/src/ui/statusline.rs index ea3d27bd6..82f9fddd0 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,20 @@ 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: _, + } => REFACTOR_BUFFER_NAME.into(), + } }; write(context, title.into()); diff --git a/helix-view/src/document.rs b/helix-view/src/document.rs index fb89e2e0c..097d456d5 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,15 @@ pub enum DocumentOpenError { IoError(#[from] io::Error), } +#[derive(Clone)] +pub enum DocumentType { + File, + Refactor { + matches: HashMap>, + line_map: HashMap<(PathBuf, usize), usize>, + }, +} + pub struct Document { pub(crate) id: DocumentId, text: Rope, @@ -221,6 +232,7 @@ pub struct DocumentColorSwatches { pub color_swatches: Vec, pub colors: Vec, pub color_swatches_padding: Vec, + pub document_type: DocumentType, } /// Inlay hints for a single `(Document, View)` combo. @@ -728,6 +740,48 @@ 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>, + encoding: Option<&'static encoding::Encoding>, + config: Arc>, + ) -> Self { + let encoding = encoding.unwrap_or(encoding::UTF_8); + let changes = ChangeSet::new(&text); + let old_state = None; + + Self { + id: DocumentId::default(), + path: None, + encoding, + text, + selections: HashMap::default(), + inlay_hints: HashMap::default(), + inlay_hints_oudated: false, + indent_style: DEFAULT_INDENT, + line_ending: 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_server: None, + diff_handle: None, + config, + version_control_head: None, + document_type: DocumentType::Refactor { matches, line_map }, } } @@ -1730,16 +1784,24 @@ 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: _, + } => 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 2e5c60cfa..6cae86cc6 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -1746,7 +1746,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 From 194a00fbfccbb4404b35598c26474498e42bcfbb Mon Sep 17 00:00:00 2001 From: Gareth Widlansky <101901964+gerblesh@users.noreply.github.com> Date: Mon, 30 Jun 2025 08:59:56 -0700 Subject: [PATCH 2/8] feat: WIP quickfix keybind --- helix-term/src/commands.rs | 563 ++++++++++++++++++------------------ helix-term/src/ui/picker.rs | 32 +- helix-view/src/document.rs | 27 +- 3 files changed, 324 insertions(+), 298 deletions(-) diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index a5b50c3ac..f50c9ca1c 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -52,6 +52,7 @@ use helix_view::{ view::View, Document, DocumentId, Editor, ViewId, }; +use tokio_stream::wrappers::UnboundedReceiverStream; use anyhow::{anyhow, bail, ensure, Context as _}; use insert::*; @@ -378,7 +379,6 @@ 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", @@ -2663,280 +2663,296 @@ fn global_search(cx: &mut Context) { .with_preview(|_editor, FileResult { path, line_num, .. }| { Some((path.as_path().into(), Some((*line_num, *line_num)))) }) + .with_quickfix(move |cx, results: Vec<&FileResult>| { + let quickfix_line = results + .iter() + .map(|FileResult { path, line_num, .. }| format!("{}:{}", path.display(), line_num)) + .collect::>() + .join(" "); + + log::info!("Quickfix entries: {}", quickfix_line); + cx.editor + .set_status(format!("Quickfix entries: {}", quickfix_line)); + // cx.editor + // .set_error(format!("Quickfix entries: {}", quickfix_line)); + }) .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(); +// TODO make this worky again +// fn global_refactor(cx: &mut Context) { +// let document_type = doc!(cx.editor).document_type.clone(); - match &document_type { - helix_view::document::DocumentType::File => { - 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(); +// match &document_type { +// helix_view::document::DocumentType::File => { +// 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('/'); +// 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 - }; +// // 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 encoding = Some(doc!(cx.editor).encoding()); +// let encoding = Some(doc!(cx.editor).encoding()); - 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; - } +// 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.., comp.clone().into())) +// .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(); +// if let Ok(matcher) = RegexMatcherBuilder::new() +// .case_smart(smart_case) +// .build(regex.into()) +// { +// let searcher = SearcherBuilder::new() +// .binary_detection(BinaryDetection::quit(b'\x00')) +// .build(query); - 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_else(|| { - matched - .strip_suffix('\n') - }) - .unwrap_or(matched), - ), - )) - .unwrap(); - } - } - } - // Exclude from file search - checked.insert(path.clone()); - } - Ok(true) - }), - ) - .ok(); - } +// 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_else(|| { +// 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, - }; +// 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, - }; +// 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_else(|| matched.strip_suffix('\n')) - .unwrap_or(matched), - ), - )) - .unwrap(); - } - Ok(true) - }), - ); +// 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_else(|| 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 - }) - }); - } - }, - ); +// 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 = Callback::Editor(Box::new(move |editor: &mut Editor| { - if all_matches.is_empty() { - editor.set_status("No matches found"); - return; - } - let mut matches: HashMap> = HashMap::new(); - for (path, line, text) in all_matches { - if let Some(vec) = matches.get_mut(&path) { - vec.push((line, text)); - } else { - let v = Vec::from([(line, text)]); - matches.insert(path, v); - } - } +// let show_refactor = async move { +// let all_matches: Vec<(PathBuf, usize, String)> = +// UnboundedReceiverStream::new(all_matches_rx).collect().await; +// let call: job::Callback = Callback::Editor(Box::new(move |editor: &mut Editor| { +// if all_matches.is_empty() { +// editor.set_status("No matches found"); +// return; +// } +// let mut matches: HashMap> = HashMap::new(); +// for (path, line, text) in all_matches { +// if let Some(vec) = matches.get_mut(&path) { +// vec.push((line, text)); +// } else { +// let v = Vec::from([(line, text)]); +// matches.insert(path, v); +// } +// } - let language_id = doc!(editor).language_id().map(String::from); +// let language_id = doc!(editor).language_id().map(String::from); - let mut doc_text = Rope::new(); - let mut line_map = HashMap::new(); +// let mut doc_text = Rope::new(); +// let mut line_map = HashMap::new(); - let mut count = 0; - for (key, value) in &matches { - for (line, text) in value { - doc_text.insert(doc_text.len_chars(), text); - doc_text.insert(doc_text.len_chars(), "\n"); - 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, - encoding, - editor.config.clone(), - ); - 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); - })); - Ok(call) - }; - cx.jobs.callback(show_refactor); - } - helix_view::document::DocumentType::Refactor { matches, line_map } => { - 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(|| "\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) = 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(); - } - } -} +// let mut count = 0; +// for (key, value) in &matches { +// for (line, text) in value { +// doc_text.insert(doc_text.len_chars(), text); +// doc_text.insert(doc_text.len_chars(), "\n"); +// 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, +// // TODO: actually learn how to detect encoding +// None, +// editor.config.clone(), +// editor.syn_loader.clone(), +// ); +// // 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); +// })); +// Ok(call) +// }; +// cx.jobs.callback(show_refactor); +// } +// helix_view::document::DocumentType::Refactor { matches, line_map } => { +// 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(|| "\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) = 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, @@ -3414,50 +3430,16 @@ fn buffer_picker(cx: &mut Context) { path: Option, is_modified: bool, is_current: bool, -<<<<<<< HEAD focused_at: std::time::Instant, -======= is_refactor: bool, } - impl ui::menu::Item for BufferMeta { - type Data = (); - - fn format(&self, _data: &Self::Data) -> Row { - let path = self - .path - .as_deref() - .map(helix_core::path::get_relative_path); - let path = if self.is_refactor { - REFACTOR_BUFFER_NAME - } else { - match path.as_deref().and_then(Path::to_str) { - Some(path) => path, - None => SCRATCH_BUFFER_NAME, - } - }; - - let mut flags = String::new(); - if self.is_modified { - flags.push('+'); - } - if self.is_current { - flags.push('*'); - } - - Row::new([self.id.to_string(), flags, path.to_string()]) - } ->>>>>>> f55507e4 (Impl refactoring view) - } - let new_meta = |doc: &Document| BufferMeta { id: doc.id(), path: doc.path().cloned(), is_modified: doc.is_modified(), is_current: doc.id() == current, -<<<<<<< HEAD focused_at: doc.focused_at, -======= is_refactor: matches!( &doc.document_type, helix_view::document::DocumentType::Refactor { @@ -3465,7 +3447,6 @@ fn buffer_picker(cx: &mut Context) { line_map: _ } ), ->>>>>>> f55507e4 (Impl refactoring view) }; let mut items = cx @@ -3491,6 +3472,10 @@ fn buffer_picker(cx: &mut Context) { flags.into() }), PickerColumn::new("path", |meta: &BufferMeta, _| { + // TODO: make this rust look like actual rust + if meta.is_refactor { + return helix_view::document::REFACTOR_BUFFER_NAME.into(); + } let path = meta .path .as_deref() diff --git a/helix-term/src/ui/picker.rs b/helix-term/src/ui/picker.rs index 3f3aaba2b..9685348ae 100644 --- a/helix-term/src/ui/picker.rs +++ b/helix-term/src/ui/picker.rs @@ -13,6 +13,7 @@ use crate::{ EditorView, }, }; +use crossterm::event::KeyEvent; use futures_util::future::BoxFuture; use helix_event::AsyncHook; use nucleo::pattern::{CaseMatching, Normalization}; @@ -29,10 +30,10 @@ use tui::{ use tui::widgets::Widget; use std::{ - borrow::Cow, + borrow::{BorrowMut, Cow}, collections::HashMap, io::Read, - path::Path, + path::{Path, PathBuf}, sync::{ atomic::{self, AtomicUsize}, Arc, @@ -266,6 +267,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. + quickfix_fn: QuickfixCallback, preview_highlight_handler: Sender>, dynamic_query_handler: Option>, } @@ -335,6 +337,7 @@ impl Picker { primary_column: usize, injector: Injector, callback_fn: impl Fn(&mut Context, &T, Action) + 'static, + quickfix_fn: Option) + 'static>>, ) -> Self { Self::with( matcher, @@ -382,6 +385,7 @@ impl Picker { truncate_start: true, show_preview: true, callback_fn: Box::new(callback_fn), + quickfix_fn: None, completion_height: 0, widths, preview_cache: HashMap::new(), @@ -419,6 +423,11 @@ impl Picker { self } + pub fn with_quickfix(mut self, quickfix_fn: impl Fn(&mut Context, Vec<&T>) + 'static) -> Self { + self.quickfix_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 +499,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 +1142,15 @@ impl Component for Picker { self.toggle_preview(); } + ctrl!('q') => { + if let Some(_) = self.selection() { + if let Some(quickfix) = &self.quickfix_fn { + let items = self.get_list(); + (quickfix)(ctx, items); + } + } + return close_fn(self); + } _ => { self.prompt_handle_event(event, ctx); } @@ -1168,3 +1195,4 @@ impl Drop for Picker { } type PickerCallback = Box; +type QuickfixCallback = Option)>>; diff --git a/helix-view/src/document.rs b/helix-view/src/document.rs index 097d456d5..2e1904187 100644 --- a/helix-view/src/document.rs +++ b/helix-view/src/document.rs @@ -140,7 +140,7 @@ pub enum DocumentOpenError { IoError(#[from] io::Error), } -#[derive(Clone)] +#[derive(Debug, Clone)] pub enum DocumentType { File, Refactor { @@ -221,6 +221,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. @@ -232,7 +233,6 @@ pub struct DocumentColorSwatches { pub color_swatches: Vec, pub colors: Vec, pub color_swatches_padding: Vec, - pub document_type: DocumentType, } /// Inlay hints for a single `(Document, View)` combo. @@ -748,23 +748,30 @@ impl Document { text: Rope, matches: HashMap>, line_map: HashMap<(PathBuf, usize), usize>, - encoding: Option<&'static encoding::Encoding>, + encoding_with_bom_info: Option<(&'static Encoding, bool)>, config: Arc>, + syn_loader: Arc>, ) -> Self { - let encoding = encoding.unwrap_or(encoding::UTF_8); - let changes = ChangeSet::new(&text); + 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, - line_ending: DEFAULT_LINE_ENDING, + editor_config: EditorConfig::default(), + line_ending, restore_cursor: false, syntax: None, language: None, @@ -777,10 +784,16 @@ impl Document { last_saved_time: SystemTime::now(), last_saved_revision: 0, modified_since_accessed: false, - language_server: None, + 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 }, } } From 4ad4ff13845e10a8e304f3d14df82accf5bd890a Mon Sep 17 00:00:00 2001 From: Gareth Widlansky <101901964+gerblesh@users.noreply.github.com> Date: Mon, 30 Jun 2025 10:45:25 -0700 Subject: [PATCH 3/8] it works! (ish) --- helix-term/src/commands.rs | 387 ++++++++++--------------------------- 1 file changed, 104 insertions(+), 283 deletions(-) diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index f50c9ca1c..d6e739585 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -379,6 +379,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", @@ -2445,13 +2446,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: line_content.into(), } } } @@ -2576,9 +2579,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) @@ -2664,17 +2671,43 @@ fn global_search(cx: &mut Context) { Some((path.as_path().into(), Some((*line_num, *line_num)))) }) .with_quickfix(move |cx, results: Vec<&FileResult>| { - let quickfix_line = results - .iter() - .map(|FileResult { path, line_num, .. }| format!("{}:{}", path.display(), line_num)) - .collect::>() - .join(" "); + if results.is_empty() { + cx.editor.set_status("No matches found"); + return; + } - log::info!("Quickfix entries: {}", quickfix_line); - cx.editor - .set_status(format!("Quickfix entries: {}", quickfix_line)); - // cx.editor - // .set_error(format!("Quickfix entries: {}", quickfix_line)); + let mut matches: HashMap> = HashMap::new(); + + for result in results { + let path = result.path.clone(); + let line = result.line_num; + let text = result.line_content.clone(); + + matches.entry(path).or_default().push((line, text)); + } + + let mut doc_text = Rope::new(); + let mut line_map = HashMap::new(); + + 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 doc = Document::refactor( + doc_text, + matches, + line_map, + // TODO: actually learn how to detect encoding + None, + cx.editor.config.clone(), + cx.editor.syn_loader.clone(), + ); + cx.editor.new_file_from_document(Action::Replace, doc); }) .with_history_register(Some(reg)) .with_dynamic_query(get_files, Some(275)); @@ -2682,277 +2715,65 @@ fn global_search(cx: &mut Context) { cx.push_layer(Box::new(overlaid(picker))); } -// TODO make this worky again -// fn global_refactor(cx: &mut Context) { -// let document_type = doc!(cx.editor).document_type.clone(); +fn global_refactor(cx: &mut Context) { + let document_type = doc!(cx.editor).document_type.clone(); -// match &document_type { -// helix_view::document::DocumentType::File => { -// 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 encoding = Some(doc!(cx.editor).encoding()); - -// 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.., comp.clone().into())) -// .collect() -// }, -// move |editor, regex, event| { -// if event != PromptEvent::Validate { -// return; -// } - -// if let Ok(matcher) = RegexMatcherBuilder::new() -// .case_smart(smart_case) -// .build(regex.into()) -// { -// let searcher = SearcherBuilder::new() -// .binary_detection(BinaryDetection::quit(b'\x00')) -// .build(query); - -// 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_else(|| { -// 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_else(|| 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 = Callback::Editor(Box::new(move |editor: &mut Editor| { -// if all_matches.is_empty() { -// editor.set_status("No matches found"); -// return; -// } -// let mut matches: HashMap> = HashMap::new(); -// for (path, line, text) in all_matches { -// if let Some(vec) = matches.get_mut(&path) { -// vec.push((line, text)); -// } else { -// let v = Vec::from([(line, text)]); -// matches.insert(path, v); -// } -// } - -// let language_id = doc!(editor).language_id().map(String::from); - -// let mut doc_text = Rope::new(); -// let mut line_map = HashMap::new(); - -// let mut count = 0; -// for (key, value) in &matches { -// for (line, text) in value { -// doc_text.insert(doc_text.len_chars(), text); -// doc_text.insert(doc_text.len_chars(), "\n"); -// 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, -// // TODO: actually learn how to detect encoding -// None, -// editor.config.clone(), -// editor.syn_loader.clone(), -// ); -// // 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); -// })); -// Ok(call) -// }; -// cx.jobs.callback(show_refactor); -// } -// helix_view::document::DocumentType::Refactor { matches, line_map } => { -// 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(|| "\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) = 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(); -// } -// } -// } + match &document_type { + helix_view::document::DocumentType::File => return, + helix_view::document::DocumentType::Refactor { matches, line_map } => { + 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(|| "\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) = 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, From 491137995c62a7771e7f554f8c181b53b0670a49 Mon Sep 17 00:00:00 2001 From: Gareth Widlansky <101901964+gerblesh@users.noreply.github.com> Date: Mon, 30 Jun 2025 11:33:11 -0700 Subject: [PATCH 4/8] fix newlines --- helix-term/src/commands.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index d6e739585..81a5c38e4 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -2721,6 +2721,7 @@ fn global_refactor(cx: &mut Context) { match &document_type { helix_view::document::DocumentType::File => return, 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(); @@ -2732,10 +2733,15 @@ fn global_refactor(cx: &mut Context) { if let Some(re_line) = line_map.get(&(key.clone(), *line)) { let mut replace = replace_text .get_line(*re_line) - .unwrap_or_else(|| "\n".into()) + .unwrap_or_else(|| "".into()) .to_string() .clone(); - replace = replace.strip_suffix('\n').unwrap_or(&replace).to_string(); + + 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)); } From fdd36d6261e5d6ac10c3648fad462e61e24c6e1d Mon Sep 17 00:00:00 2001 From: Gareth Widlansky <101901964+gerblesh@users.noreply.github.com> Date: Mon, 30 Jun 2025 12:15:35 -0700 Subject: [PATCH 5/8] fix syntax highlighting --- helix-term/src/commands.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 81a5c38e4..2b36e73d2 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -2688,6 +2688,7 @@ fn global_search(cx: &mut Context) { 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 { @@ -2698,7 +2699,7 @@ fn global_search(cx: &mut Context) { } } doc_text.split_off(doc_text.len_chars().saturating_sub(1)); - let doc = Document::refactor( + let mut doc = Document::refactor( doc_text, matches, line_map, @@ -2707,6 +2708,10 @@ fn global_search(cx: &mut Context) { 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)) From 627885ddeb625d13e2dcc5275998c48f3e284852 Mon Sep 17 00:00:00 2001 From: Gareth Widlansky <101901964+gerblesh@users.noreply.github.com> Date: Mon, 30 Jun 2025 22:57:20 -0700 Subject: [PATCH 6/8] feat: functional gutter --- helix-term/src/commands.rs | 10 ++- helix-term/src/ui/editor.rs | 1 + helix-term/src/ui/statusline.rs | 1 + helix-view/src/document.rs | 12 +++- helix-view/src/gutter.rs | 116 +++++++++++++++++++++++--------- 5 files changed, 105 insertions(+), 35 deletions(-) diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 2b36e73d2..dffd336d8 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -2677,12 +2677,14 @@ fn global_search(cx: &mut Context) { } 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)); } @@ -2703,6 +2705,7 @@ fn global_search(cx: &mut Context) { doc_text, matches, line_map, + lines, // TODO: actually learn how to detect encoding None, cx.editor.config.clone(), @@ -2725,7 +2728,9 @@ fn global_refactor(cx: &mut Context) { match &document_type { helix_view::document::DocumentType::File => return, - helix_view::document::DocumentType::Refactor { matches, line_map } => { + 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(); @@ -3276,7 +3281,8 @@ fn buffer_picker(cx: &mut Context) { &doc.document_type, helix_view::document::DocumentType::Refactor { matches: _, - line_map: _ + line_map: _, + lines: _, } ), }; diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs index 03ac69ca8..8bc3d8a19 100644 --- a/helix-term/src/ui/editor.rs +++ b/helix-term/src/ui/editor.rs @@ -594,6 +594,7 @@ impl EditorView { helix_view::document::DocumentType::Refactor { matches: _, line_map: _, + lines: _, } => helix_view::document::REFACTOR_BUFFER_NAME, }; diff --git a/helix-term/src/ui/statusline.rs b/helix-term/src/ui/statusline.rs index 82f9fddd0..48ee2cdcc 100644 --- a/helix-term/src/ui/statusline.rs +++ b/helix-term/src/ui/statusline.rs @@ -462,6 +462,7 @@ where helix_view::document::DocumentType::Refactor { matches: _, line_map: _, + lines: _, } => REFACTOR_BUFFER_NAME.into(), } }; diff --git a/helix-view/src/document.rs b/helix-view/src/document.rs index 2e1904187..0ba1d400a 100644 --- a/helix-view/src/document.rs +++ b/helix-view/src/document.rs @@ -144,8 +144,12 @@ pub enum DocumentOpenError { 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)>, }, } @@ -748,6 +752,7 @@ impl Document { 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>, @@ -794,7 +799,11 @@ impl Document { color_swatches: None, color_swatch_controller: TaskController::new(), syn_loader, - document_type: DocumentType::Refactor { matches, line_map }, + document_type: DocumentType::Refactor { + matches, + line_map, + lines, + }, } } @@ -1813,6 +1822,7 @@ impl Document { DocumentType::Refactor { matches: _, line_map: _, + lines: _, } => false, } } 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(); From a6105af2824efc2d2c243f632a8a5012e37b5772 Mon Sep 17 00:00:00 2001 From: Gareth Widlansky <101901964+gerblesh@users.noreply.github.com> Date: Mon, 30 Jun 2025 23:00:43 -0700 Subject: [PATCH 7/8] style: misc nits --- book/src/generated/static-cmd.md | 1 + helix-term/src/commands.rs | 3 +-- helix-term/src/ui/picker.rs | 5 ++--- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/book/src/generated/static-cmd.md b/book/src/generated/static-cmd.md index 7ecb7f4e8..b971482dd 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 dffd336d8..e1308231f 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -52,7 +52,6 @@ use helix_view::{ view::View, Document, DocumentId, Editor, ViewId, }; -use tokio_stream::wrappers::UnboundedReceiverStream; use anyhow::{anyhow, bail, ensure, Context as _}; use insert::*; @@ -3312,7 +3311,7 @@ fn buffer_picker(cx: &mut Context) { PickerColumn::new("path", |meta: &BufferMeta, _| { // TODO: make this rust look like actual rust if meta.is_refactor { - return helix_view::document::REFACTOR_BUFFER_NAME.into(); + return REFACTOR_BUFFER_NAME.into(); } let path = meta .path diff --git a/helix-term/src/ui/picker.rs b/helix-term/src/ui/picker.rs index 9685348ae..5801fbea6 100644 --- a/helix-term/src/ui/picker.rs +++ b/helix-term/src/ui/picker.rs @@ -13,7 +13,6 @@ use crate::{ EditorView, }, }; -use crossterm::event::KeyEvent; use futures_util::future::BoxFuture; use helix_event::AsyncHook; use nucleo::pattern::{CaseMatching, Normalization}; @@ -30,10 +29,10 @@ use tui::{ use tui::widgets::Widget; use std::{ - borrow::{BorrowMut, Cow}, + borrow::Cow, collections::HashMap, io::Read, - path::{Path, PathBuf}, + path::Path, sync::{ atomic::{self, AtomicUsize}, Arc, From 2108ec3132120f696be824c864cf9586c1dbb8b1 Mon Sep 17 00:00:00 2001 From: Gareth Widlansky <101901964+gerblesh@users.noreply.github.com> Date: Mon, 30 Jun 2025 23:08:55 -0700 Subject: [PATCH 8/8] fix: more nits --- helix-term/src/commands.rs | 7 +++---- helix-term/src/ui/picker.rs | 15 +++++++-------- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index e1308231f..d84ec62e1 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -2453,7 +2453,7 @@ fn global_search(cx: &mut Context) { Self { path: path.to_path_buf(), line_num, - line_content: line_content.into(), + line_content, } } } @@ -2669,7 +2669,7 @@ fn global_search(cx: &mut Context) { .with_preview(|_editor, FileResult { path, line_num, .. }| { Some((path.as_path().into(), Some((*line_num, *line_num)))) }) - .with_quickfix(move |cx, results: Vec<&FileResult>| { + .with_refactor(move |cx, results: Vec<&FileResult>| { if results.is_empty() { cx.editor.set_status("No matches found"); return; @@ -2726,7 +2726,7 @@ fn global_refactor(cx: &mut Context) { let document_type = doc!(cx.editor).document_type.clone(); match &document_type { - helix_view::document::DocumentType::File => return, + helix_view::document::DocumentType::File => (), helix_view::document::DocumentType::Refactor { matches, line_map, .. } => { @@ -3309,7 +3309,6 @@ fn buffer_picker(cx: &mut Context) { flags.into() }), PickerColumn::new("path", |meta: &BufferMeta, _| { - // TODO: make this rust look like actual rust if meta.is_refactor { return REFACTOR_BUFFER_NAME.into(); } diff --git a/helix-term/src/ui/picker.rs b/helix-term/src/ui/picker.rs index 5801fbea6..85465a861 100644 --- a/helix-term/src/ui/picker.rs +++ b/helix-term/src/ui/picker.rs @@ -266,7 +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. - quickfix_fn: QuickfixCallback, + refactor_fn: RefactorCallback, preview_highlight_handler: Sender>, dynamic_query_handler: Option>, } @@ -336,7 +336,6 @@ impl Picker { primary_column: usize, injector: Injector, callback_fn: impl Fn(&mut Context, &T, Action) + 'static, - quickfix_fn: Option) + 'static>>, ) -> Self { Self::with( matcher, @@ -384,7 +383,7 @@ impl Picker { truncate_start: true, show_preview: true, callback_fn: Box::new(callback_fn), - quickfix_fn: None, + refactor_fn: None, completion_height: 0, widths, preview_cache: HashMap::new(), @@ -422,8 +421,8 @@ impl Picker { self } - pub fn with_quickfix(mut self, quickfix_fn: impl Fn(&mut Context, Vec<&T>) + 'static) -> Self { - self.quickfix_fn = Some(Box::new(quickfix_fn)); + 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 } @@ -1142,8 +1141,8 @@ impl Component for Picker { - if let Some(_) = self.selection() { - if let Some(quickfix) = &self.quickfix_fn { + if self.selection().is_some() { + if let Some(quickfix) = &self.refactor_fn { let items = self.get_list(); (quickfix)(ctx, items); } @@ -1194,4 +1193,4 @@ impl Drop for Picker { } type PickerCallback = Box; -type QuickfixCallback = Option)>>; +type RefactorCallback = Option)>>;