aboutsummaryrefslogtreecommitdiff
path: root/helix-term/src/commands.rs
diff options
context:
space:
mode:
Diffstat (limited to 'helix-term/src/commands.rs')
-rw-r--r--helix-term/src/commands.rs829
1 files changed, 697 insertions, 132 deletions
diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs
index 12dd2460..56b31b67 100644
--- a/helix-term/src/commands.rs
+++ b/helix-term/src/commands.rs
@@ -3,21 +3,25 @@ pub(crate) mod dap;
pub use dap::*;
use helix_core::{
- comment, coords_at_pos, find_first_non_whitespace_char, find_root, graphemes, indent,
+ comment, coords_at_pos, find_first_non_whitespace_char, find_root, graphemes,
+ history::UndoKind,
+ indent,
indent::IndentStyle,
line_ending::{get_line_ending_of_str, line_end_char_index, str_is_line_ending},
match_brackets,
movement::{self, Direction},
+ numbers::NumberIncrementor,
object, pos_at_coords,
regex::{self, Regex, RegexBuilder},
register::Register,
- search, selection, surround, textobject, LineEnding, Position, Range, Rope, RopeGraphemes,
- RopeSlice, Selection, SmallVec, Tendril, Transaction,
+ search, selection, surround, textobject,
+ unicode::width::UnicodeWidthChar,
+ LineEnding, Position, Range, Rope, RopeGraphemes, RopeSlice, Selection, SmallVec, Tendril,
+ Transaction,
};
-
use helix_view::{
clipboard::ClipboardType,
- document::Mode,
+ document::{Mode, SCRATCH_BUFFER_NAME},
editor::{Action, Motion},
input::KeyEvent,
keyboard::KeyCode,
@@ -27,7 +31,7 @@ use helix_view::{
use anyhow::{anyhow, bail, Context as _};
use helix_lsp::{
- lsp,
+ block_on, lsp,
util::{lsp_pos_to_pos, lsp_range_to_range, pos_to_lsp_pos, range_to_lsp_range},
OffsetEncoding,
};
@@ -189,6 +193,7 @@ impl Command {
copy_selection_on_prev_line, "Copy selection on previous line",
move_next_word_start, "Move to beginning of next word",
move_prev_word_start, "Move to beginning of previous word",
+ move_prev_word_end, "Move to end of previous word",
move_next_word_end, "Move to end of next word",
move_next_long_word_start, "Move to beginning of next long word",
move_prev_long_word_start, "Move to beginning of previous long word",
@@ -241,6 +246,7 @@ impl Command {
code_action, "Perform code action",
buffer_picker, "Open buffer picker",
symbol_picker, "Open symbol picker",
+ workspace_symbol_picker, "Open workspace symbol picker",
last_picker, "Open last picker",
prepend_to_line, "Insert at start of line",
append_to_line, "Insert at end of line",
@@ -261,6 +267,7 @@ impl Command {
goto_window_middle, "Goto window middle",
goto_window_bottom, "Goto window bottom",
goto_last_accessed_file, "Goto last accessed file",
+ goto_last_modification, "Goto last modification",
goto_line, "Goto line",
goto_last_line, "Goto last line",
goto_first_diag, "Goto first diagnostic",
@@ -274,6 +281,7 @@ impl Command {
// TODO: different description ?
goto_line_end_newline, "Goto line end",
goto_first_nonwhitespace, "Goto first non-blank in line",
+ trim_selections, "Trim whitespace from selections",
extend_to_line_start, "Extend to line start",
extend_to_line_end, "Extend to line end",
extend_to_line_end_newline, "Extend to line end",
@@ -283,8 +291,13 @@ impl Command {
delete_char_backward, "Delete previous char",
delete_char_forward, "Delete next char",
delete_word_backward, "Delete previous word",
+ delete_word_forward, "Delete next word",
+ kill_to_line_start, "Delete content till the start of the line",
+ kill_to_line_end, "Delete content till the end of the line",
undo, "Undo change",
redo, "Redo change",
+ earlier, "Move backward in history",
+ later, "Move forward in history",
yank, "Yank selection",
yank_joined_to_clipboard, "Join and yank selections to clipboard",
yank_main_selection_to_clipboard, "Yank main selection to clipboard",
@@ -304,6 +317,7 @@ impl Command {
format_selections, "Format selection",
join_selections, "Join lines inside selection",
keep_selections, "Keep selections matching regex",
+ remove_selections, "Remove selections matching regex",
keep_primary_selection, "Keep primary selection",
remove_primary_selection, "Remove primary selection",
completion, "Invoke completion popup",
@@ -324,7 +338,9 @@ impl Command {
hsplit, "Horizontal bottom split",
vsplit, "Vertical right split",
wclose, "Close window",
+ wonly, "Current window only",
select_register, "Select register",
+ insert_register, "Insert register",
align_view_middle, "Align view middle",
align_view_top, "Align view top",
align_view_center, "Align view center",
@@ -358,6 +374,9 @@ impl Command {
shell_append_output, "Append output of shell command after each selection",
shell_keep_pipe, "Filter selections with shell predicate",
suspend, "Suspend",
+ rename_symbol, "Rename symbol",
+ increment, "Increment",
+ decrement, "Decrement",
);
}
@@ -581,6 +600,29 @@ fn extend_to_line_start(cx: &mut Context) {
goto_line_start_impl(view, doc, Movement::Extend)
}
+fn kill_to_line_start(cx: &mut Context) {
+ let (view, doc) = current!(cx.editor);
+ let text = doc.text().slice(..);
+
+ let selection = doc.selection(view.id).clone().transform(|range| {
+ let line = range.cursor_line(text);
+ range.put_cursor(text, text.line_to_char(line), true)
+ });
+ delete_selection_insert_mode(doc, view, &selection);
+}
+
+fn kill_to_line_end(cx: &mut Context) {
+ let (view, doc) = current!(cx.editor);
+ let text = doc.text().slice(..);
+
+ let selection = doc.selection(view.id).clone().transform(|range| {
+ let line = range.cursor_line(text);
+ let pos = line_end_char_index(&text, line);
+ range.put_cursor(text, pos, true)
+ });
+ delete_selection_insert_mode(doc, view, &selection);
+}
+
fn goto_first_nonwhitespace(cx: &mut Context) {
let (view, doc) = current!(cx.editor);
let text = doc.text().slice(..);
@@ -598,6 +640,42 @@ fn goto_first_nonwhitespace(cx: &mut Context) {
doc.set_selection(view.id, selection);
}
+fn trim_selections(cx: &mut Context) {
+ let (view, doc) = current!(cx.editor);
+ let text = doc.text().slice(..);
+
+ let ranges: SmallVec<[Range; 1]> = doc
+ .selection(view.id)
+ .iter()
+ .filter_map(|range| {
+ if range.is_empty() || range.fragment(text).chars().all(|ch| ch.is_whitespace()) {
+ return None;
+ }
+ let mut start = range.from();
+ let mut end = range.to();
+ start = movement::skip_while(text, start, |x| x.is_whitespace()).unwrap_or(start);
+ end = movement::backwards_skip_while(text, end, |x| x.is_whitespace()).unwrap_or(end);
+ if range.anchor < range.head {
+ Some(Range::new(start, end))
+ } else {
+ Some(Range::new(end, start))
+ }
+ })
+ .collect();
+
+ if !ranges.is_empty() {
+ let primary = doc.selection(view.id).primary();
+ let idx = ranges
+ .iter()
+ .position(|range| range.overlaps(&primary))
+ .unwrap_or(ranges.len() - 1);
+ doc.set_selection(view.id, Selection::new(ranges, idx));
+ } else {
+ collapse_selection(cx);
+ keep_primary_selection(cx);
+ };
+}
+
fn goto_window(cx: &mut Context, align: Align) {
let (view, doc) = current!(cx.editor);
@@ -657,6 +735,10 @@ fn move_prev_word_start(cx: &mut Context) {
move_word_impl(cx, movement::move_prev_word_start)
}
+fn move_prev_word_end(cx: &mut Context) {
+ move_word_impl(cx, movement::move_prev_word_end)
+}
+
fn move_next_word_end(cx: &mut Context) {
move_word_impl(cx, movement::move_next_word_end)
}
@@ -1193,6 +1275,7 @@ fn search_impl(
regex: &Regex,
movement: Movement,
direction: Direction,
+ scrolloff: usize,
) {
let text = doc.text().slice(..);
let selection = doc.selection(view.id);
@@ -1251,7 +1334,11 @@ fn search_impl(
};
doc.set_selection(view.id, selection);
- align_view(doc, view, Align::Center);
+ if view.is_cursor_in_view(doc, 0) {
+ view.ensure_cursor_in_view(doc, scrolloff);
+ } else {
+ align_view(doc, view, Align::Center)
+ }
};
}
@@ -1275,6 +1362,8 @@ fn rsearch(cx: &mut Context) {
// TODO: use one function for search vs extend
fn searcher(cx: &mut Context, direction: Direction) {
let reg = cx.register.unwrap_or('/');
+ let scrolloff = cx.editor.config.scrolloff;
+
let (_, doc) = current!(cx.editor);
// TODO: could probably share with select_on_matches?
@@ -1299,7 +1388,15 @@ fn searcher(cx: &mut Context, direction: Direction) {
if event != PromptEvent::Update {
return;
}
- search_impl(doc, view, &contents, &regex, Movement::Move, direction);
+ search_impl(
+ doc,
+ view,
+ &contents,
+ &regex,
+ Movement::Move,
+ direction,
+ scrolloff,
+ );
},
);
@@ -1307,6 +1404,7 @@ fn searcher(cx: &mut Context, direction: Direction) {
}
fn search_next_or_prev_impl(cx: &mut Context, movement: Movement, direction: Direction) {
+ let scrolloff = cx.editor.config.scrolloff;
let (view, doc) = current!(cx.editor);
let registers = &cx.editor.registers;
if let Some(query) = registers.read('/') {
@@ -1321,7 +1419,7 @@ fn search_next_or_prev_impl(cx: &mut Context, movement: Movement, direction: Dir
.case_insensitive(case_insensitive)
.build()
{
- search_impl(doc, view, &contents, &regex, movement, direction);
+ search_impl(doc, view, &contents, &regex, movement, direction, scrolloff);
} else {
// get around warning `mutable_borrow_reservation_conflict`
// which will be a hard error in the future
@@ -1365,7 +1463,7 @@ fn global_search(cx: &mut Context) {
let completions = search_completions(cx, None);
let prompt = ui::regex_prompt(
cx,
- "global search:".into(),
+ "global-search:".into(),
None,
move |input: &str| {
completions
@@ -1543,6 +1641,17 @@ fn delete_selection_impl(reg: &mut Register, doc: &mut Document, view_id: ViewId
doc.apply(&transaction, view_id);
}
+#[inline]
+fn delete_selection_insert_mode(doc: &mut Document, view: &View, selection: &Selection) {
+ let view_id = view.id;
+
+ // then delete
+ let transaction = Transaction::change_by_selection(doc.text(), selection, |range| {
+ (range.from(), range.to(), None)
+ });
+ doc.apply(&transaction, view_id);
+}
+
fn delete_selection(cx: &mut Context) {
let reg_name = cx.register.unwrap_or('"');
let (view, doc) = current!(cx.editor);
@@ -1657,8 +1766,7 @@ mod cmd {
buffers_remaining_impl(cx.editor)?
}
- cx.editor
- .close(view!(cx.editor).id, /* close_buffer */ false);
+ cx.editor.close(view!(cx.editor).id);
Ok(())
}
@@ -1668,8 +1776,7 @@ mod cmd {
_args: &[&str],
_event: PromptEvent,
) -> anyhow::Result<()> {
- cx.editor
- .close(view!(cx.editor).id, /* close_buffer */ false);
+ cx.editor.close(view!(cx.editor).id);
Ok(())
}
@@ -1679,11 +1786,30 @@ mod cmd {
args: &[&str],
_event: PromptEvent,
) -> anyhow::Result<()> {
- use helix_core::path::expand_tilde;
let path = args.get(0).context("wrong argument count")?;
- let _ = cx
- .editor
- .open(expand_tilde(Path::new(path)), Action::Replace)?;
+ let _ = cx.editor.open(path.into(), Action::Replace)?;
+ Ok(())
+ }
+
+ fn buffer_close(
+ cx: &mut compositor::Context,
+ _args: &[&str],
+ _event: PromptEvent,
+ ) -> anyhow::Result<()> {
+ let view = view!(cx.editor);
+ let doc_id = view.doc;
+ cx.editor.close_document(doc_id, false)?;
+ Ok(())
+ }
+
+ fn force_buffer_close(
+ cx: &mut compositor::Context,
+ _args: &[&str],
+ _event: PromptEvent,
+ ) -> anyhow::Result<()> {
+ let view = view!(cx.editor);
+ let doc_id = view.doc;
+ cx.editor.close_document(doc_id, true)?;
Ok(())
}
@@ -1838,13 +1964,13 @@ mod cmd {
args: &[&str],
_event: PromptEvent,
) -> anyhow::Result<()> {
- let uk = args
- .join(" ")
- .parse::<helix_core::history::UndoKind>()
- .map_err(|s| anyhow!(s))?;
+ let uk = args.join(" ").parse::<UndoKind>().map_err(|s| anyhow!(s))?;
let (view, doc) = current!(cx.editor);
- doc.earlier(view.id, uk);
+ let success = doc.earlier(view.id, uk);
+ if !success {
+ cx.editor.set_status("Already at oldest change".to_owned());
+ }
Ok(())
}
@@ -1854,12 +1980,12 @@ mod cmd {
args: &[&str],
_event: PromptEvent,
) -> anyhow::Result<()> {
- let uk = args
- .join(" ")
- .parse::<helix_core::history::UndoKind>()
- .map_err(|s| anyhow!(s))?;
+ let uk = args.join(" ").parse::<UndoKind>().map_err(|s| anyhow!(s))?;
let (view, doc) = current!(cx.editor);
- doc.later(view.id, uk);
+ let success = doc.later(view.id, uk);
+ if !success {
+ cx.editor.set_status("Already at newest change".to_owned());
+ }
Ok(())
}
@@ -1891,7 +2017,7 @@ mod cmd {
.map(|doc| {
doc.relative_path()
.map(|path| path.to_string_lossy().to_string())
- .unwrap_or_else(|| "[scratch]".into())
+ .unwrap_or_else(|| SCRATCH_BUFFER_NAME.into())
})
.collect();
if !modified.is_empty() {
@@ -1933,7 +2059,7 @@ mod cmd {
// close all views
let views: Vec<_> = cx.editor.tree.views().map(|(view, _)| view.id).collect();
for view_id in views {
- cx.editor.close(view_id, false);
+ cx.editor.close(view_id);
}
}
@@ -1977,7 +2103,7 @@ mod cmd {
// close all views
let views: Vec<_> = editor.tree.views().map(|(view, _)| view.id).collect();
for view_id in views {
- editor.close(view_id, false);
+ editor.close(view_id);
}
Ok(())
@@ -1999,6 +2125,25 @@ mod cmd {
quit_all_impl(&mut cx.editor, args, event, true)
}
+ fn cquit(
+ cx: &mut compositor::Context,
+ args: &[&str],
+ _event: PromptEvent,
+ ) -> anyhow::Result<()> {
+ let exit_code = args
+ .first()
+ .and_then(|code| code.parse::<i32>().ok())
+ .unwrap_or(1);
+ cx.editor.exit_code = exit_code;
+
+ let views: Vec<_> = cx.editor.tree.views().map(|(view, _)| view.id).collect();
+ for view_id in views {
+ cx.editor.close(view_id);
+ }
+
+ Ok(())
+ }
+
fn theme(
cx: &mut compositor::Context,
args: &[&str],
@@ -2241,7 +2386,6 @@ mod cmd {
args: &[&str],
_event: PromptEvent,
) -> anyhow::Result<()> {
- use helix_lsp::block_on;
if let Some(debugger) = cx.editor.debugger.as_mut() {
let (frame, thread_id) = match (debugger.active_frame, debugger.thread_id) {
(Some(frame), Some(thread_id)) => (frame, thread_id),
@@ -2345,6 +2489,20 @@ mod cmd {
completer: Some(completers::filename),
},
TypableCommand {
+ name: "buffer-close",
+ aliases: &["bc", "bclose"],
+ doc: "Close the current buffer.",
+ fun: buffer_close,
+ completer: None, // FIXME: buffer completer
+ },
+ TypableCommand {
+ name: "buffer-close!",
+ aliases: &["bc!", "bclose!"],
+ doc: "Close the current buffer forcefully (ignoring unsaved changes).",
+ fun: force_buffer_close,
+ completer: None, // FIXME: buffer completer
+ },
+ TypableCommand {
name: "write",
aliases: &["w"],
doc: "Write changes to disk. Accepts an optional path (:write some/path.txt)",
@@ -2443,6 +2601,13 @@ mod cmd {
completer: None,
},
TypableCommand {
+ name: "cquit",
+ aliases: &["cq"],
+ doc: "Quit with exit code (default 1). Accepts an optional integer exit code (:cq 2).",
+ fun: cquit,
+ completer: None,
+ },
+ TypableCommand {
name: "theme",
aliases: &[],
doc: "Change the theme of current view. Requires theme name as argument (:theme <name>)",
@@ -2698,36 +2863,66 @@ fn file_picker(cx: &mut Context) {
fn buffer_picker(cx: &mut Context) {
let current = view!(cx.editor).doc;
+ struct BufferMeta {
+ id: DocumentId,
+ path: Option<PathBuf>,
+ is_modified: bool,
+ is_current: bool,
+ }
+
+ impl BufferMeta {
+ fn format(&self) -> Cow<str> {
+ let path = self
+ .path
+ .as_deref()
+ .map(helix_core::path::get_relative_path);
+ let path = match path.as_deref().and_then(Path::to_str) {
+ Some(path) => path,
+ None => return Cow::Borrowed(SCRATCH_BUFFER_NAME),
+ };
+
+ let mut flags = Vec::new();
+ if self.is_modified {
+ flags.push("+");
+ }
+ if self.is_current {
+ flags.push("*");
+ }
+
+ let flag = if flags.is_empty() {
+ "".into()
+ } else {
+ format!(" ({})", flags.join(""))
+ };
+ Cow::Owned(format!("{}{}", path, flag))
+ }
+ }
+
+ let new_meta = |doc: &Document| BufferMeta {
+ id: doc.id(),
+ path: doc.path().cloned(),
+ is_modified: doc.is_modified(),
+ is_current: doc.id() == current,
+ };
+
let picker = FilePicker::new(
cx.editor
.documents
.iter()
- .map(|(id, doc)| (*id, doc.path().cloned()))
+ .map(|(_, doc)| new_meta(doc))
.collect(),
- move |(id, path): &(DocumentId, Option<PathBuf>)| {
- let path = path.as_deref().map(helix_core::path::get_relative_path);
- match path.as_ref().and_then(|path| path.to_str()) {
- Some(path) => {
- if *id == current {
- format!("{} (*)", &path).into()
- } else {
- path.to_owned().into()
- }
- }
- None => "[scratch buffer]".into(),
- }
- },
- |cx, (id, _path): &(DocumentId, Option<PathBuf>), _action| {
- cx.editor.switch(*id, Action::Replace);
+ BufferMeta::format,
+ |cx, meta, _action| {
+ cx.editor.switch(meta.id, Action::Replace);
},
- |editor, (id, path)| {
- let doc = &editor.documents.get(id)?;
+ |editor, meta| {
+ let doc = &editor.documents.get(&meta.id)?;
let &view_id = doc.selections().keys().next()?;
let line = doc
.selection(view_id)
.primary()
.cursor_line(doc.text().slice(..));
- Some((path.clone()?, Some((line, line))))
+ Some((meta.path.clone()?, Some((line, line))))
},
);
cx.push_layer(Box::new(picker));
@@ -2782,7 +2977,7 @@ fn symbol_picker(cx: &mut Context) {
}
};
- let picker = FilePicker::new(
+ let mut picker = FilePicker::new(
symbols,
|symbol| (&symbol.name).into(),
move |cx, symbol, _action| {
@@ -2807,6 +3002,69 @@ fn symbol_picker(cx: &mut Context) {
Some((path, line))
},
);
+ picker.truncate_start = false;
+ compositor.push(Box::new(picker))
+ }
+ },
+ )
+}
+
+fn workspace_symbol_picker(cx: &mut Context) {
+ let (_, doc) = current!(cx.editor);
+
+ let language_server = match doc.language_server() {
+ Some(language_server) => language_server,
+ None => return,
+ };
+ let offset_encoding = language_server.offset_encoding();
+
+ let future = language_server.workspace_symbols("".to_string());
+
+ let current_path = doc_mut!(cx.editor).path().cloned();
+ cx.callback(
+ future,
+ move |_editor: &mut Editor,
+ compositor: &mut Compositor,
+ response: Option<Vec<lsp::SymbolInformation>>| {
+ if let Some(symbols) = response {
+ let mut picker = FilePicker::new(
+ symbols,
+ move |symbol| {
+ let path = symbol.location.uri.to_file_path().unwrap();
+ if current_path.as_ref().map(|p| p == &path).unwrap_or(false) {
+ (&symbol.name).into()
+ } else {
+ let relative_path = helix_core::path::get_relative_path(path.as_path())
+ .to_str()
+ .unwrap()
+ .to_owned();
+ format!("{} ({})", &symbol.name, relative_path).into()
+ }
+ },
+ move |cx, symbol, action| {
+ let path = symbol.location.uri.to_file_path().unwrap();
+ cx.editor.open(path, action).expect("editor.open failed");
+ let (view, doc) = current!(cx.editor);
+
+ if let Some(range) =
+ lsp_range_to_range(doc.text(), symbol.location.range, offset_encoding)
+ {
+ // we flip the range so that the cursor sits on the start of the symbol
+ // (for example start of the function).
+ doc.set_selection(view.id, Selection::single(range.head, range.anchor));
+ align_view(doc, view, Align::Center);
+ }
+ },
+ move |_editor, symbol| {
+ let path = symbol.location.uri.to_file_path().unwrap();
+ let line = Some((
+ symbol.location.range.start.line as usize,
+ symbol.location.range.end.line as usize,
+ ));
+ Some((path, line))
+ },
+ );
+ picker.truncate_start = false;
compositor.push(Box::new(picker))
}
},
@@ -2864,14 +3122,104 @@ pub fn code_action(cx: &mut Context) {
)
}
+pub fn apply_document_resource_op(op: &lsp::ResourceOp) -> std::io::Result<()> {
+ use lsp::ResourceOp;
+ use std::fs;
+ match op {
+ ResourceOp::Create(op) => {
+ let path = op.uri.to_file_path().unwrap();
+ let ignore_if_exists = if let Some(options) = &op.options {
+ !options.overwrite.unwrap_or(false) && options.ignore_if_exists.unwrap_or(false)
+ } else {
+ false
+ };
+ if ignore_if_exists && path.exists() {
+ Ok(())
+ } else {
+ fs::write(&path, [])
+ }
+ }
+ ResourceOp::Delete(op) => {
+ let path = op.uri.to_file_path().unwrap();
+ if path.is_dir() {
+ let recursive = if let Some(options) = &op.options {
+ options.recursive.unwrap_or(false)
+ } else {
+ false
+ };
+ if recursive {
+ fs::remove_dir_all(&path)
+ } else {
+ fs::remove_dir(&path)
+ }
+ } else if path.is_file() {
+ fs::remove_file(&path)
+ } else {
+ Ok(())
+ }
+ }
+ ResourceOp::Rename(op) => {
+ let from = op.old_uri.to_file_path().unwrap();
+ let to = op.new_uri.to_file_path().unwrap();
+ let ignore_if_exists = if let Some(options) = &op.options {
+ !options.overwrite.unwrap_or(false) && options.ignore_if_exists.unwrap_or(false)
+ } else {
+ false
+ };
+ if ignore_if_exists && to.exists() {
+ Ok(())
+ } else {
+ fs::rename(&from, &to)
+ }
+ }
+ }
+}
+
fn apply_workspace_edit(
editor: &mut Editor,
offset_encoding: OffsetEncoding,
workspace_edit: &lsp::WorkspaceEdit,
) {
+ let mut apply_edits = |uri: &helix_lsp::Url, text_edits: Vec<lsp::TextEdit>| {
+ let path = uri
+ .to_file_path()
+ .expect("unable to convert URI to filepath");
+
+ let current_view_id = view!(editor).id;
+ let doc_id = editor.open(path, Action::Load).unwrap();
+ let doc = editor
+ .document_mut(doc_id)
+ .expect("Document for document_changes not found");
+
+ // Need to determine a view for apply/append_changes_to_history
+ let selections = doc.selections();
+ let view_id = if selections.contains_key(&current_view_id) {
+ // use current if possible
+ current_view_id
+ } else {
+ // Hack: we take the first available view_id
+ selections
+ .keys()
+ .next()
+ .copied()
+ .expect("No view_id available")
+ };
+
+ let transaction = helix_lsp::util::generate_transaction_from_edits(
+ doc.text(),
+ text_edits,
+ offset_encoding,
+ );
+ doc.apply(&transaction, view_id);
+ doc.append_changes_to_history(view_id);
+ };
+
if let Some(ref changes) = workspace_edit.changes {
log::debug!("workspace changes: {:?}", changes);
- editor.set_error(String::from("Handling workspace_edit.changes is not implemented yet, see https://github.com/helix-editor/helix/issues/183"));
+ for (uri, text_edits) in changes {
+ let text_edits = text_edits.to_vec();
+ apply_edits(uri, text_edits);
+ }
return;
// Not sure if it works properly, it'll be safer to just panic here to avoid breaking some parts of code on which code actions will be used
// TODO: find some example that uses workspace changes, and test it
@@ -2889,30 +3237,6 @@ fn apply_workspace_edit(
match document_changes {
lsp::DocumentChanges::Edits(document_edits) => {
for document_edit in document_edits {
- let path = document_edit
- .text_document
- .uri
- .to_file_path()
- .expect("unable to convert URI to filepath");
- let current_view_id = view!(editor).id;
- let doc = editor
- .document_by_path_mut(path)
- .expect("Document for document_changes not found");
-
- // Need to determine a view for apply/append_changes_to_history
- let selections = doc.selections();
- let view_id = if selections.contains_key(&current_view_id) {
- // use current if possible
- current_view_id
- } else {
- // Hack: we take the first available view_id
- selections
- .keys()
- .next()
- .copied()
- .expect("No view_id available")
- };
-
let edits = document_edit
.edits
.iter()
@@ -2924,19 +3248,33 @@ fn apply_workspace_edit(
})
.cloned()
.collect();
-
- let transaction = helix_lsp::util::generate_transaction_from_edits(
- doc.text(),
- edits,
- offset_encoding,
- );
- doc.apply(&transaction, view_id);
- doc.append_changes_to_history(view_id);
+ apply_edits(&document_edit.text_document.uri, edits);
}
}
lsp::DocumentChanges::Operations(operations) => {
log::debug!("document changes - operations: {:?}", operations);
- editor.set_error(String::from("Handling document operations is not implemented yet, see https://github.com/helix-editor/helix/issues/183"));
+ for operateion in operations {
+ match operateion {
+ lsp::DocumentChangeOperation::Op(op) => {
+ apply_document_resource_op(op).unwrap();
+ }
+
+ lsp::DocumentChangeOperation::Edit(document_edit) => {
+ let edits = document_edit
+ .edits
+ .iter()
+ .map(|edit| match edit {
+ lsp::OneOf::Left(text_edit) => text_edit,
+ lsp::OneOf::Right(annotated_text_edit) => {
+ &annotated_text_edit.text_edit
+ }
+ })
+ .cloned()
+ .collect();
+ apply_edits(&document_edit.text_document.uri, edits);
+ }
+ }
+ }
}
}
}
@@ -3175,6 +3513,19 @@ fn goto_last_accessed_file(cx: &mut Context) {
}
}
+fn goto_last_modification(cx: &mut Context) {
+ let (view, doc) = current!(cx.editor);
+ let pos = doc.history.get_mut().last_edit_pos();
+ let text = doc.text().slice(..);
+ if let Some(pos) = pos {
+ let selection = doc
+ .selection(view.id)
+ .clone()
+ .transform(|range| range.put_cursor(text, pos, doc.mode == Mode::Select));
+ doc.set_selection(view.id, selection);
+ }
+}
+
fn select_mode(cx: &mut Context) {
let (view, doc) = current!(cx.editor);
let text = doc.text().slice(..);
@@ -3773,19 +4124,70 @@ pub mod insert {
doc.apply(&transaction, view.id);
}
- // TODO: handle indent-aware delete
pub fn delete_char_backward(cx: &mut Context) {
let count = cx.count();
let (view, doc) = current!(cx.editor);
let text = doc.text().slice(..);
+ let indent_unit = doc.indent_unit();
+ let tab_size = doc.tab_width();
+
let transaction =
Transaction::change_by_selection(doc.text(), doc.selection(view.id), |range| {
let pos = range.cursor(text);
- (
- graphemes::nth_prev_grapheme_boundary(text, pos, count),
- pos,
- None,
- )
+ let line_start_pos = text.line_to_char(range.cursor_line(text));
+ // considier to delete by indent level if all characters before `pos` are indent units.
+ let fragment = Cow::from(text.slice(line_start_pos..pos));
+ if !fragment.is_empty() && fragment.chars().all(|ch| ch.is_whitespace()) {
+ if text.get_char(pos.saturating_sub(1)) == Some('\t') {
+ // fast path, delete one char
+ (
+ graphemes::nth_prev_grapheme_boundary(text, pos, 1),
+ pos,
+ None,
+ )
+ } else {
+ let unit_len = indent_unit.chars().count();
+ // NOTE: indent_unit always contains 'only spaces' or 'only tab' according to `IndentStyle` definition.
+ let unit_size = if indent_unit.starts_with('\t') {
+ tab_size * unit_len
+ } else {
+ unit_len
+ };
+ let width: usize = fragment
+ .chars()
+ .map(|ch| {
+ if ch == '\t' {
+ tab_size
+ } else {
+ // it can be none if it still meet control characters other than '\t'
+ // here just set the width to 1 (or some value better?).
+ ch.width().unwrap_or(1)
+ }
+ })
+ .sum();
+ let mut drop = width % unit_size; // round down to nearest unit
+ if drop == 0 {
+ drop = unit_size
+ }; // if it's already at a unit, consume a whole unit
+ let mut chars = fragment.chars().rev();
+ let mut start = pos;
+ for _ in 0..drop {
+ // delete up to `drop` spaces
+ match chars.next() {
+ Some(' ') => start -= 1,
+ _ => break,
+ }
+ }
+ (start, pos, None) // delete!
+ }
+ } else {
+ // delete char
+ (
+ graphemes::nth_prev_grapheme_boundary(text, pos, count),
+ pos,
+ None,
+ )
+ }
});
doc.apply(&transaction, view.id);
}
@@ -3815,8 +4217,19 @@ pub mod insert {
.selection(view.id)
.clone()
.transform(|range| movement::move_prev_word_start(text, range, count));
- doc.set_selection(view.id, selection);
- delete_selection(cx)
+ delete_selection_insert_mode(doc, view, &selection);
+ }
+
+ pub fn delete_word_forward(cx: &mut Context) {
+ let count = cx.count();
+ let (view, doc) = current!(cx.editor);
+ let text = doc.text().slice(..);
+
+ let selection = doc
+ .selection(view.id)
+ .clone()
+ .transform(|range| movement::move_next_word_start(text, range, count));
+ delete_selection_insert_mode(doc, view, &selection);
}
}
@@ -3826,20 +4239,48 @@ pub mod insert {
// storing it?
fn undo(cx: &mut Context) {
+ let count = cx.count();
let (view, doc) = current!(cx.editor);
- let view_id = view.id;
- let success = doc.undo(view_id);
- if !success {
- cx.editor.set_status("Already at oldest change".to_owned());
+ for _ in 0..count {
+ if !doc.undo(view.id) {
+ cx.editor.set_status("Already at oldest change".to_owned());
+ break;
+ }
}
}
fn redo(cx: &mut Context) {
+ let count = cx.count();
let (view, doc) = current!(cx.editor);
- let view_id = view.id;
- let success = doc.redo(view_id);
- if !success {
- cx.editor.set_status("Already at newest change".to_owned());
+ for _ in 0..count {
+ if !doc.redo(view.id) {
+ cx.editor.set_status("Already at newest change".to_owned());
+ break;
+ }
+ }
+}
+
+fn earlier(cx: &mut Context) {
+ let count = cx.count();
+ let (view, doc) = current!(cx.editor);
+ for _ in 0..count {
+ // rather than doing in batch we do this so get error halfway
+ if !doc.earlier(view.id, UndoKind::Steps(1)) {
+ cx.editor.set_status("Already at oldest change".to_owned());
+ break;
+ }
+ }
+}
+
+fn later(cx: &mut Context) {
+ let count = cx.count();
+ let (view, doc) = current!(cx.editor);
+ for _ in 0..count {
+ // rather than doing in batch we do this so get error halfway
+ if !doc.later(view.id, UndoKind::Steps(1)) {
+ cx.editor.set_status("Already at newest change".to_owned());
+ break;
+ }
}
}
@@ -4297,12 +4738,12 @@ fn join_selections(cx: &mut Context) {
doc.append_changes_to_history(view.id);
}
-fn keep_selections(cx: &mut Context) {
- // keep selections matching regex
+fn keep_or_remove_selections_impl(cx: &mut Context, remove: bool) {
+ // keep or remove selections matching regex
let reg = cx.register.unwrap_or('/');
let prompt = ui::regex_prompt(
cx,
- "keep:".into(),
+ if !remove { "keep:" } else { "remove:" }.into(),
Some(reg),
|_input: &str| Vec::new(),
move |view, doc, regex, event| {
@@ -4311,7 +4752,9 @@ fn keep_selections(cx: &mut Context) {
}
let text = doc.text().slice(..);
- if let Some(selection) = selection::keep_matches(text, doc.selection(view.id), &regex) {
+ if let Some(selection) =
+ selection::keep_or_remove_matches(text, doc.selection(view.id), &regex, remove)
+ {
doc.set_selection(view.id, selection);
}
},
@@ -4320,6 +4763,14 @@ fn keep_selections(cx: &mut Context) {
cx.push_layer(Box::new(prompt));
}
+fn keep_selections(cx: &mut Context) {
+ keep_or_remove_selections_impl(cx, false)
+}
+
+fn remove_selections(cx: &mut Context) {
+ keep_or_remove_selections_impl(cx, true)
+}
+
fn keep_primary_selection(cx: &mut Context) {
let (view, doc) = current!(cx.editor);
// TODO: handle count
@@ -4445,19 +4896,15 @@ pub fn completion(cx: &mut Context) {
return;
}
let size = compositor.size();
- let ui = compositor
- .find(std::any::type_name::<ui::EditorView>())
- .unwrap();
- if let Some(ui) = ui.as_any_mut().downcast_mut::<ui::EditorView>() {
- ui.set_completion(
- editor,
- items,
- offset_encoding,
- start_offset,
- trigger_offset,
- size,
- );
- };
+ let ui = compositor.find::<ui::EditorView>().unwrap();
+ ui.set_completion(
+ editor,
+ items,
+ offset_encoding,
+ start_offset,
+ trigger_offset,
+ size,
+ );
},
);
}
@@ -4487,18 +4934,27 @@ fn hover(cx: &mut Context) {
move |editor: &mut Editor, compositor: &mut Compositor, response: Option<lsp::Hover>| {
if let Some(hover) = response {
// hover.contents / .range <- used for visualizing
- let contents = match hover.contents {
- lsp::HoverContents::Scalar(contents) => {
- // markedstring(string/languagestring to be highlighted)
- // TODO
- log::error!("hover contents {:?}", contents);
- return;
- }
- lsp::HoverContents::Array(contents) => {
- log::error!("hover contents {:?}", contents);
- return;
+
+ fn marked_string_to_markdown(contents: lsp::MarkedString) -> String {
+ match contents {
+ lsp::MarkedString::String(contents) => contents,
+ lsp::MarkedString::LanguageString(string) => {
+ if string.language == "markdown" {
+ string.value
+ } else {
+ format!("```{}\n{}\n```", string.language, string.value)
+ }
+ }
}
- // TODO: render markdown
+ }
+
+ let contents = match hover.contents {
+ lsp::HoverContents::Scalar(contents) => marked_string_to_markdown(contents),
+ lsp::HoverContents::Array(contents) => contents
+ .into_iter()
+ .map(marked_string_to_markdown)
+ .collect::<Vec<_>>()
+ .join("\n\n"),
lsp::HoverContents::Markup(contents) => contents.value,
};
@@ -4706,7 +5162,21 @@ fn wclose(cx: &mut Context) {
}
let view_id = view!(cx.editor).id;
// close current split
- cx.editor.close(view_id, /* close_buffer */ false);
+ cx.editor.close(view_id);
+}
+
+fn wonly(cx: &mut Context) {
+ let views = cx
+ .editor
+ .tree
+ .views()
+ .map(|(v, focus)| (v.id, focus))
+ .collect::<Vec<_>>();
+ for (view_id, focus) in views {
+ if !focus {
+ cx.editor.close(view_id);
+ }
+ }
}
fn select_register(cx: &mut Context) {
@@ -4717,6 +5187,15 @@ fn select_register(cx: &mut Context) {
})
}
+fn insert_register(cx: &mut Context) {
+ cx.on_next_key(move |cx, event| {
+ if let Some(ch) = event.char() {
+ cx.editor.selected_register = Some(ch);
+ paste_before(cx);
+ }
+ })
+}
+
fn align_view_top(cx: &mut Context) {
let (view, doc) = current!(cx.editor);
align_view(doc, view, Align::Top);
@@ -4785,10 +5264,19 @@ fn select_textobject(cx: &mut Context, objtype: textobject::TextObject) {
let selection = doc.selection(view.id).clone().transform(|range| {
match ch {
- 'w' => textobject::textobject_word(text, range, objtype, count),
+ 'w' => textobject::textobject_word(text, range, objtype, count, false),
+ 'W' => textobject::textobject_word(text, range, objtype, count, true),
'c' => textobject_treesitter("class", range),
'f' => textobject_treesitter("function", range),
'p' => textobject_treesitter("parameter", range),
+ 'm' => {
+ let ch = text.char(range.cursor(text));
+ if !ch.is_ascii_alphanumeric() {
+ textobject::textobject_surround(text, range, objtype, ch, count)
+ } else {
+ range
+ }
+ }
// TODO: cancel new ranges if inconsistent surround matches across lines
ch if !ch.is_ascii_alphanumeric() => {
textobject::textobject_surround(text, range, objtype, ch, count)
@@ -5051,6 +5539,10 @@ fn shell(cx: &mut Context, prompt: Cow<'static, str>, behavior: ShellBehavior) {
doc.apply(&transaction, view.id);
doc.append_changes_to_history(view.id);
}
+
+ // after replace cursor may be out of bounds, do this to
+ // make sure cursor is in view and update scroll as well
+ view.ensure_cursor_in_view(doc, cx.editor.config.scrolloff);
},
None,
);
@@ -5096,3 +5588,76 @@ fn add_newline_impl(cx: &mut Context, open: Open) {
doc.apply(&transaction, view.id);
doc.append_changes_to_history(view.id);
}
+
+fn rename_symbol(cx: &mut Context) {
+ let prompt = Prompt::new(
+ "rename-to:".into(),
+ None,
+ |_input: &str| Vec::new(),
+ move |cx: &mut compositor::Context, input: &str, event: PromptEvent| {
+ if event != PromptEvent::Validate {
+ return;
+ }
+
+ log::debug!("renaming to: {:?}", input);
+
+ let (view, doc) = current!(cx.editor);
+ let language_server = match doc.language_server() {
+ Some(language_server) => language_server,
+ None => return,
+ };
+
+ let offset_encoding = language_server.offset_encoding();
+
+ let pos = pos_to_lsp_pos(
+ doc.text(),
+ doc.selection(view.id)
+ .primary()
+ .cursor(doc.text().slice(..)),
+ offset_encoding,
+ );
+
+ let task = language_server.rename_symbol(doc.identifier(), pos, input.to_string());
+ let edits = block_on(task).unwrap_or_default();
+ log::debug!("Edits from LSP: {:?}", edits);
+ apply_workspace_edit(&mut cx.editor, offset_encoding, &edits);
+ },
+ None,
+ );
+ cx.push_layer(Box::new(prompt));
+}
+
+/// Increment object under cursor by count.
+fn increment(cx: &mut Context) {
+ increment_impl(cx, cx.count() as i64);
+}
+
+/// Decrement object under cursor by count.
+fn decrement(cx: &mut Context) {
+ increment_impl(cx, -(cx.count() as i64));
+}
+
+/// Decrement object under cursor by `amount`.
+fn increment_impl(cx: &mut Context, amount: i64) {
+ let (view, doc) = current!(cx.editor);
+ let selection = doc.selection(view.id);
+ let text = doc.text();
+
+ let changes = selection.ranges().iter().filter_map(|range| {
+ let incrementor = NumberIncrementor::from_range(text.slice(..), *range)?;
+ let new_text = incrementor.incremented_text(amount);
+ Some((
+ incrementor.range.from(),
+ incrementor.range.to(),
+ Some(new_text),
+ ))
+ });
+
+ if changes.clone().count() > 0 {
+ let transaction = Transaction::change(doc.text(), changes);
+ let transaction = transaction.with_selection(selection.clone());
+
+ doc.apply(&transaction, view.id);
+ doc.append_changes_to_history(view.id);
+ }
+}