T17 Editor: CodeEditor with gutter, folds, minimap, rulers, guides, brackets, whitespace, cursors, multi-cursor, sticky scroll, inlay hints, lenses, decorations, ghost text, inline chat and diff (T17a); completions, signatures, hover, code actions, rename, references, outline, go to symbol, call and type hierarchy (T17b); find and replace, search panel and filters (T17c); problems, output, debug console, extensions, settings with JSON, keybindings, theme previews, welcome, recent projects, switcher, task runner (T17d); status items (T17e)
Shared: generic undo History, OverflowMenu icon and tooltip, StatusBarItem leading, six Lucide icons.
This commit is contained in:
1 parent
a894e5f973
commit
5545ce6458
59 files changed
+10820
-71
No files matched your search
@@ -0,0 +1,378 @@
|
||||
use std::ops::Range;
|
||||
|
||||
use gpui::{ClipboardItem, Context};
|
||||
|
||||
use super::{
|
||||
buffer::Buffer,
|
||||
cursor::{Motion, Selection, merged, replace_each},
|
||||
state::CodeEditor,
|
||||
};
|
||||
|
||||
/// Pairs typing an opener closes at once.
|
||||
const PAIRS: [(char, char); 5] = [('(', ')'), ('[', ']'), ('{', '}'), ('"', '"'), ('\'', '\'')];
|
||||
|
||||
impl CodeEditor {
|
||||
/// Replaces each selection with `text`; typing in one burst makes one undo step.
|
||||
pub(crate) fn insert(&mut self, text: &str, typing: bool, cx: &mut Context<Self>) {
|
||||
let edits = self
|
||||
.selections
|
||||
.iter()
|
||||
.map(|selection| (selection.range(), text.to_string()))
|
||||
.collect();
|
||||
self.apply(edits, typing, cx);
|
||||
}
|
||||
|
||||
/// Replaces ranges all at once and leaves a caret after each insert.
|
||||
pub(crate) fn apply(
|
||||
&mut self,
|
||||
edits: Vec<(Range<usize>, String)>,
|
||||
typing: bool,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if !self.editable() {
|
||||
log::debug!("code editor: read only, edit dropped");
|
||||
return;
|
||||
}
|
||||
let before = self.snapshot();
|
||||
let carets = replace_each(&mut self.buffer, joined(edits));
|
||||
self.selections = merged(carets);
|
||||
self.marked = None;
|
||||
self.commit(before, typing, cx);
|
||||
self.reveal(true, cx);
|
||||
}
|
||||
|
||||
/// Types text at each cursor; an opener brings its closer, and a closer already there is stepped over.
|
||||
pub(crate) fn type_text(&mut self, text: &str, cx: &mut Context<Self>) {
|
||||
let mut chars = text.chars();
|
||||
let (Some(ch), None) = (chars.next(), chars.next()) else {
|
||||
return self.insert(text, true, cx);
|
||||
};
|
||||
let next = |caret: usize| self.buffer.text()[caret..].chars().next();
|
||||
let all_empty = self.selections.iter().all(Selection::is_empty);
|
||||
if all_empty
|
||||
&& PAIRS.iter().any(|(_, close)| *close == ch)
|
||||
&& self
|
||||
.selections
|
||||
.iter()
|
||||
.all(|selection| next(selection.head) == Some(ch))
|
||||
{
|
||||
return self.motion(Motion::Right, false, cx);
|
||||
}
|
||||
let Some((open, close)) = PAIRS.iter().find(|(open, _)| *open == ch) else {
|
||||
return self.insert(text, true, cx);
|
||||
};
|
||||
let free = |caret: usize| {
|
||||
next(caret).is_none_or(|after| after.is_whitespace() || ")]}".contains(after))
|
||||
};
|
||||
if !all_empty || !self.selections.iter().all(|selection| free(selection.head)) {
|
||||
return self.insert(text, true, cx);
|
||||
}
|
||||
let edits = self
|
||||
.selections
|
||||
.iter()
|
||||
.map(|selection| (selection.range(), format!("{open}{close}")))
|
||||
.collect();
|
||||
self.apply(edits, true, cx);
|
||||
let back: Vec<Selection> = self
|
||||
.selections
|
||||
.iter()
|
||||
.map(|caret| Selection::caret(caret.head - close.len_utf8()))
|
||||
.collect();
|
||||
self.set_selections(back, cx);
|
||||
}
|
||||
|
||||
/// Deletes each selection, or what `reach` finds from each bare caret.
|
||||
pub(crate) fn delete_by(
|
||||
&mut self,
|
||||
reach: fn(&Buffer, usize) -> Range<usize>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let edits = self
|
||||
.selections
|
||||
.iter()
|
||||
.map(|selection| {
|
||||
let range = if selection.is_empty() {
|
||||
reach(&self.buffer, selection.head)
|
||||
} else {
|
||||
selection.range()
|
||||
};
|
||||
(range, String::new())
|
||||
})
|
||||
.collect();
|
||||
self.apply(edits, false, cx);
|
||||
}
|
||||
|
||||
/// A new line at each cursor, indented like its own; one step deeper after an opener, with the closer on a line of its own.
|
||||
pub(crate) fn newline(&mut self, cx: &mut Context<Self>) {
|
||||
let tab = self.options.tab;
|
||||
let edits = self
|
||||
.selections
|
||||
.iter()
|
||||
.map(|selection| {
|
||||
let at = selection.range().start;
|
||||
let line = self.buffer.line_of(at);
|
||||
let indent: String = self
|
||||
.buffer
|
||||
.line(line)
|
||||
.chars()
|
||||
.take(self.buffer.indent(line))
|
||||
.collect();
|
||||
let before = self.buffer.text()[..at].trim_end().chars().next_back();
|
||||
let after = self.buffer.text()[selection.range().end..].chars().next();
|
||||
let opens = before.is_some_and(|ch| "{[(:".contains(ch));
|
||||
let text = match (opens, after) {
|
||||
(true, Some(close)) if "}])".contains(close) => {
|
||||
format!("\n{indent}{}\n{indent}", " ".repeat(tab))
|
||||
}
|
||||
(true, _) => format!("\n{indent}{}", " ".repeat(tab)),
|
||||
_ => format!("\n{indent}"),
|
||||
};
|
||||
(selection.range(), text)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let closers: Vec<bool> = edits
|
||||
.iter()
|
||||
.map(|(_, text)| text.matches('\n').count() == 2)
|
||||
.collect();
|
||||
self.apply(edits, false, cx);
|
||||
let placed: Vec<Selection> = self
|
||||
.selections
|
||||
.iter()
|
||||
.zip(closers)
|
||||
.map(|(caret, split)| {
|
||||
if split {
|
||||
let line = self.buffer.line_of(caret.head);
|
||||
Selection::caret(self.buffer.line_range(line - 1).end)
|
||||
} else {
|
||||
*caret
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
self.set_selections(placed, cx);
|
||||
}
|
||||
|
||||
/// The lines the selections touch, each once.
|
||||
fn touched(&self) -> Vec<usize> {
|
||||
let mut lines: Vec<usize> = self
|
||||
.selections
|
||||
.iter()
|
||||
.flat_map(|selection| {
|
||||
let range = selection.range();
|
||||
let last = if range.end > range.start && self.buffer.point(range.end).1 == 0 {
|
||||
range.end - 1
|
||||
} else {
|
||||
range.end
|
||||
};
|
||||
self.buffer.line_of(range.start)..=self.buffer.line_of(last)
|
||||
})
|
||||
.collect();
|
||||
lines.dedup();
|
||||
lines
|
||||
}
|
||||
|
||||
/// Tab: takes ghost text when there is some, indents touched lines when a selection spans lines, or fills to the next tab stop.
|
||||
pub(crate) fn indent(&mut self, cx: &mut Context<Self>) {
|
||||
if let Some(ghost) = self.marks.ghost.take() {
|
||||
log::info!("code editor: ghost text taken");
|
||||
self.set_selections(vec![Selection::caret(ghost.offset)], cx);
|
||||
return self.insert(&ghost.text, false, cx);
|
||||
}
|
||||
let tab = self.options.tab;
|
||||
let spans_lines = self.selections.iter().any(|selection| {
|
||||
self.buffer.line_of(selection.range().start)
|
||||
!= self.buffer.line_of(selection.range().end)
|
||||
});
|
||||
if !spans_lines {
|
||||
let edits = self
|
||||
.selections
|
||||
.iter()
|
||||
.map(|selection| {
|
||||
let column = self.buffer.point(selection.range().start).1;
|
||||
(selection.range(), " ".repeat(tab - column % tab))
|
||||
})
|
||||
.collect();
|
||||
return self.apply(edits, false, cx);
|
||||
}
|
||||
let kept = self.selections.clone();
|
||||
let edits = self
|
||||
.touched()
|
||||
.into_iter()
|
||||
.filter(|line| !self.buffer.line(*line).trim().is_empty())
|
||||
.map(|line| {
|
||||
let start = self.buffer.line_range(line).start;
|
||||
(start..start, " ".repeat(tab))
|
||||
})
|
||||
.collect();
|
||||
self.shift_lines(edits, kept, cx);
|
||||
}
|
||||
|
||||
/// Shift-Tab: takes one tab stop of indent off each touched line.
|
||||
pub(crate) fn outdent(&mut self, cx: &mut Context<Self>) {
|
||||
let tab = self.options.tab;
|
||||
let kept = self.selections.clone();
|
||||
let edits = self
|
||||
.touched()
|
||||
.into_iter()
|
||||
.filter_map(|line| {
|
||||
let start = self.buffer.line_range(line).start;
|
||||
let cut = self.buffer.indent(line).min(tab);
|
||||
(cut > 0).then(|| (start..start + cut, String::new()))
|
||||
})
|
||||
.collect();
|
||||
self.shift_lines(edits, kept, cx);
|
||||
}
|
||||
|
||||
/// Applies line-start edits and keeps each selection over the same text.
|
||||
fn shift_lines(
|
||||
&mut self,
|
||||
edits: Vec<(Range<usize>, String)>,
|
||||
kept: Vec<Selection>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let moves: Vec<(std::ops::Range<usize>, isize)> = edits
|
||||
.iter()
|
||||
.map(|(range, text)| (range.clone(), text.len() as isize - range.len() as isize))
|
||||
.collect();
|
||||
let shift = |offset: usize| -> usize {
|
||||
let mut moved = offset as isize;
|
||||
for (range, delta) in &moves {
|
||||
if range.start < offset {
|
||||
if offset < range.end {
|
||||
moved -= (offset - range.start) as isize;
|
||||
} else {
|
||||
moved += delta;
|
||||
}
|
||||
}
|
||||
}
|
||||
moved.max(0) as usize
|
||||
};
|
||||
let kept: Vec<Selection> = kept
|
||||
.into_iter()
|
||||
.map(|selection| Selection {
|
||||
anchor: shift(selection.anchor),
|
||||
head: shift(selection.head),
|
||||
goal: None,
|
||||
})
|
||||
.collect();
|
||||
self.apply(edits, false, cx);
|
||||
self.set_selections(kept, cx);
|
||||
}
|
||||
|
||||
/// Comments the touched lines with `//`, or takes the comment off when every one has it.
|
||||
pub(crate) fn toggle_comment(&mut self, cx: &mut Context<Self>) {
|
||||
let lines: Vec<usize> = self
|
||||
.touched()
|
||||
.into_iter()
|
||||
.filter(|line| !self.buffer.line(*line).trim().is_empty())
|
||||
.collect();
|
||||
let commented = |line: usize| self.buffer.line(line).trim_start().starts_with("//");
|
||||
let kept = self.selections.clone();
|
||||
let edits = if !lines.is_empty() && lines.iter().all(|line| commented(*line)) {
|
||||
lines
|
||||
.iter()
|
||||
.map(|line| {
|
||||
let start = self.buffer.line_range(*line).start + self.buffer.indent(*line);
|
||||
let text = &self.buffer.text()[start..];
|
||||
let cut = if text.starts_with("// ") { 3 } else { 2 };
|
||||
(start..start + cut, String::new())
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
let column = lines
|
||||
.iter()
|
||||
.map(|line| self.buffer.indent(*line))
|
||||
.min()
|
||||
.unwrap_or(0);
|
||||
lines
|
||||
.iter()
|
||||
.map(|line| {
|
||||
let at = self.buffer.offset(*line, column);
|
||||
(at..at, "// ".to_string())
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
self.shift_lines(edits, kept, cx);
|
||||
}
|
||||
|
||||
pub(crate) fn undo(&mut self, cx: &mut Context<Self>) {
|
||||
if let Some(previous) = self.history.undo(self.snapshot()) {
|
||||
self.restore(previous, cx);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn redo(&mut self, cx: &mut Context<Self>) {
|
||||
if let Some(next) = self.history.redo(self.snapshot()) {
|
||||
self.restore(next, cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Each selection's text, a line apiece; the whole line for a bare caret.
|
||||
fn copied(&self) -> Vec<(Range<usize>, String)> {
|
||||
self.selections
|
||||
.iter()
|
||||
.map(|selection| {
|
||||
let range = if selection.is_empty() {
|
||||
let line = self.buffer.line_of(selection.head);
|
||||
let range = self.buffer.line_range(line);
|
||||
range.start..self.buffer.next(range.end)
|
||||
} else {
|
||||
selection.range()
|
||||
};
|
||||
(range.clone(), self.buffer.text()[range].to_string())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn copy(&mut self, cx: &mut Context<Self>) {
|
||||
let text: Vec<String> = self.copied().into_iter().map(|(_, text)| text).collect();
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(text.join("\n")));
|
||||
}
|
||||
|
||||
pub(crate) fn cut(&mut self, cx: &mut Context<Self>) {
|
||||
let copied = self.copied();
|
||||
let text: Vec<String> = copied.iter().map(|(_, text)| text.clone()).collect();
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(text.join("\n")));
|
||||
self.apply(
|
||||
copied
|
||||
.into_iter()
|
||||
.map(|(range, _)| (range, String::new()))
|
||||
.collect(),
|
||||
false,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
/// Pastes one line at each cursor when the counts match, else the whole text at every one.
|
||||
pub(crate) fn paste(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) else {
|
||||
return;
|
||||
};
|
||||
let lines: Vec<&str> = text.split('\n').collect();
|
||||
let edits = if lines.len() == self.selections.len() && lines.len() > 1 {
|
||||
self.selections
|
||||
.iter()
|
||||
.zip(lines)
|
||||
.map(|(selection, line)| (selection.range(), line.to_string()))
|
||||
.collect()
|
||||
} else {
|
||||
self.selections
|
||||
.iter()
|
||||
.map(|selection| (selection.range(), text.clone()))
|
||||
.collect()
|
||||
};
|
||||
self.apply(edits, false, cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Edits sorted, with overlapping ranges joined; the first text of a joined run is kept.
|
||||
fn joined(mut edits: Vec<(Range<usize>, String)>) -> Vec<(Range<usize>, String)> {
|
||||
edits.sort_by_key(|(range, _)| range.start);
|
||||
let mut out: Vec<(Range<usize>, String)> = Vec::with_capacity(edits.len());
|
||||
for (range, text) in edits {
|
||||
match out.last_mut() {
|
||||
Some((last, _)) if range.start < last.end => last.end = last.end.max(range.end),
|
||||
_ => out.push((range, text)),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
Reference in new issue
Block a user