T17 review fixes: hidden numbers draw, empty edits and read-only commands change nothing, hints stay in a ghost-cut row, the newest cursor leads, a composition undoes in one step, call keys carry their place, references count matches, trailing-space hits and **/ globs, name-only switcher marks, pin and remove stay on their button, settings show their places; decimals moves to typography::format

This commit is contained in:
ZacharyZhang-NY committed 2026-09-26 01:07:37 -04:00
1 parent 5545ce6458
commit 8ea952c6dc
20 files changed
+383 -85

No files matched your search

+14 -2
View File
@@ -37,9 +37,12 @@ impl Selection {
}
}
/// Sorted by where they start, with overlapping selections joined into one.
/// Sorted by where they start, overlapping selections joined into one; the last given, the primary, stays last.
pub(crate) fn merged(mut all: Vec<Selection>) -> Vec<Selection> {
assert!(!all.is_empty(), "an editor keeps at least one cursor");
let primary = all
.last()
.expect("an editor keeps at least one cursor")
.range();
all.sort_by_key(|selection| selection.range().start);
let mut out: Vec<Selection> = Vec::with_capacity(all.len());
for next in all {
@@ -59,6 +62,15 @@ pub(crate) fn merged(mut all: Vec<Selection>) -> Vec<Selection> {
_ => out.push(next),
}
}
let lead = out
.iter()
.position(|selection| {
let range = selection.range();
range.start <= primary.start && primary.end <= range.end
})
.expect("a merge keeps every cursor inside one");
let lead = out.remove(lead);
out.push(lead);
out
}
+54 -3
View File
@@ -33,8 +33,19 @@ impl CodeEditor {
log::debug!("code editor: read only, edit dropped");
return;
}
if edits.is_empty() {
return;
}
let before = self.snapshot();
let carets = replace_each(&mut self.buffer, joined(edits));
let primary = self.primary().range().start;
let edits = joined(edits);
let lead = edits
.iter()
.rposition(|(range, _)| range.start <= primary)
.unwrap_or(0);
let mut carets = replace_each(&mut self.buffer, edits);
let lead = carets.remove(lead);
carets.push(lead);
self.selections = merged(carets);
self.marked = None;
self.commit(before, typing, cx);
@@ -43,6 +54,9 @@ impl CodeEditor {
/// 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>) {
if !self.editable() {
return;
}
let mut chars = text.chars();
let (Some(ch), None) = (chars.next(), chars.next()) else {
return self.insert(text, true, cx);
@@ -87,6 +101,9 @@ impl CodeEditor {
reach: fn(&Buffer, usize) -> Range<usize>,
cx: &mut Context<Self>,
) {
if !self.editable() {
return;
}
let edits = self
.selections
.iter()
@@ -104,6 +121,9 @@ impl CodeEditor {
/// 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>) {
if !self.editable() {
return;
}
let tab = self.options.tab;
let edits = self
.selections
@@ -166,12 +186,16 @@ impl CodeEditor {
self.buffer.line_of(range.start)..=self.buffer.line_of(last)
})
.collect();
lines.sort_unstable();
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 !self.editable() {
return;
}
if let Some(ghost) = self.marks.ghost.take() {
log::info!("code editor: ghost text taken");
self.set_selections(vec![Selection::caret(ghost.offset)], cx);
@@ -208,6 +232,9 @@ impl CodeEditor {
/// Shift-Tab: takes one tab stop of indent off each touched line.
pub(crate) fn outdent(&mut self, cx: &mut Context<Self>) {
if !self.editable() {
return;
}
let tab = self.options.tab;
let kept = self.selections.clone();
let edits = self
@@ -260,6 +287,9 @@ impl CodeEditor {
/// 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>) {
if !self.editable() {
return;
}
let lines: Vec<usize> = self
.touched()
.into_iter()
@@ -295,12 +325,18 @@ impl CodeEditor {
}
pub(crate) fn undo(&mut self, cx: &mut Context<Self>) {
if !self.editable() {
return;
}
if let Some(previous) = self.history.undo(self.snapshot()) {
self.restore(previous, cx);
}
}
pub(crate) fn redo(&mut self, cx: &mut Context<Self>) {
if !self.editable() {
return;
}
if let Some(next) = self.history.redo(self.snapshot()) {
self.restore(next, cx);
}
@@ -308,7 +344,7 @@ impl CodeEditor {
/// Each selection's text, a line apiece; the whole line for a bare caret.
fn copied(&self) -> Vec<(Range<usize>, String)> {
self.selections
self.in_order()
.iter()
.map(|selection| {
let range = if selection.is_empty() {
@@ -329,6 +365,9 @@ impl CodeEditor {
}
pub(crate) fn cut(&mut self, cx: &mut Context<Self>) {
if !self.editable() {
return;
}
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")));
@@ -344,12 +383,15 @@ impl CodeEditor {
/// 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>) {
if !self.editable() {
return;
}
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
self.in_order()
.iter()
.zip(lines)
.map(|(selection, line)| (selection.range(), line.to_string()))
@@ -364,6 +406,15 @@ impl CodeEditor {
}
}
impl CodeEditor {
/// The selections from the top of the text down; the primary one is otherwise last.
fn in_order(&self) -> Vec<Selection> {
let mut all = self.selections.clone();
all.sort_by_key(|selection| selection.range().start);
all
}
}
/// 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);
+9 -1
View File
@@ -22,7 +22,11 @@ pub fn passes(path: &str, include: &str, exclude: &str) -> bool {
match ch {
'*' if chars.peek() == Some(&'*') => {
chars.next();
pattern.push_str(".*");
if chars.next_if_eq(&'/').is_some() {
pattern.push_str("(?:.*/)?");
} else {
pattern.push_str(".*");
}
}
'*' => pattern.push_str("[^/]*"),
'?' => pattern.push_str("[^/]"),
@@ -129,6 +133,10 @@ mod tests {
#[test]
fn globs_take_includes_then_drop_excludes() {
assert!(passes("src/editor/view.rs", "src/**/*.rs", ""));
assert!(
passes("src/main.rs", "src/**/*.rs", ""),
"** takes no folders too"
);
assert!(!passes("examples/main.rs", "src/**", ""));
assert!(!passes(
"src/editor/tests.rs",
+10 -3
View File
@@ -23,10 +23,17 @@ pub(crate) enum Cell {
impl CodeEditor {
/// Columns the gutter spans: a slot for a breakpoint or a problem, the number, a fold chevron and the git bar.
pub(crate) fn gutter_columns(&self) -> usize {
let digits = self.buffer.lines().to_string().len().max(2);
match self.options.numbers {
LineNumbers::Hidden => 5,
_ => digits + 6,
_ => self.digits() + 6,
}
}
/// Columns the numbers take: none when hidden.
fn digits(&self) -> usize {
match self.options.numbers {
LineNumbers::Hidden => 0,
_ => self.buffer.lines().to_string().len().max(2),
}
}
@@ -83,7 +90,7 @@ impl CodeEditor {
let theme = cx.theme();
let colors = theme.colors.clone();
let advance = self.frame.advance;
let digits = self.gutter_columns() - 6;
let digits = self.digits();
let frame = || {
div()
.flex_none()
+15 -1
View File
@@ -19,7 +19,7 @@ impl CodeEditor {
/// Notes set into a line: inlay hints, and the ghost text's first line.
pub(crate) fn notes(&self, line: usize, cx: &App) -> Vec<(usize, String, HighlightStyle)> {
let colors = &cx.theme().colors;
let range = self.buffer.line_range(line);
let range = self.row_range(line);
let hint = HighlightStyle {
color: Some(colors.fg_subtle),
background_color: Some(colors.hover),
@@ -61,6 +61,15 @@ impl CodeEditor {
&self.buffer.text()[self.row_range(line)]
}
/// Ends a composition: all of it, from its first mark, is one undo step.
fn end_composition(&mut self) {
if let Some(start) = self.composing.take()
&& start.text != self.buffer.text()
{
self.history.record(start, false);
}
}
/// The offset under a window point, when a row of code is there.
pub(crate) fn offset_at(&self, position: Point<Pixels>, cx: &App) -> Option<usize> {
let metrics = self.metrics;
@@ -129,6 +138,7 @@ impl EntityInputHandler for CodeEditor {
fn unmark_text(&mut self, _: &mut Window, cx: &mut Context<Self>) {
self.marked = None;
self.end_composition();
cx.notify();
}
@@ -149,6 +159,7 @@ impl EntityInputHandler for CodeEditor {
}
None => self.type_text(text, cx),
}
self.end_composition();
}
fn replace_and_mark_text_in_range(
@@ -166,6 +177,9 @@ impl EntityInputHandler for CodeEditor {
.map(|range| self.byte_range(&range))
.or(self.marked.clone())
.unwrap_or(self.primary().range());
if self.composing.is_none() {
self.composing = Some(self.snapshot());
}
self.apply(vec![(range.clone(), text.to_string())], true, cx);
self.marked = (!text.is_empty()).then(|| range.start..range.start + text.len());
let caret = match selected_utf16 {
+1 -1
View File
@@ -27,7 +27,7 @@ mod status;
mod symbols;
mod syntax;
mod tasks;
#[cfg(test)]
#[cfg(all(test, feature = "test-support"))]
mod tests;
mod view;
mod welcome;
+6 -7
View File
@@ -9,7 +9,7 @@ use crate::{
buttons::{ToggleButton, ToggleItem},
primitives::{Icon, IconName, Severity},
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
typography::Ellipsis,
typography::{Ellipsis, format},
};
/// A problem the tools found: where, how bad, what, and which tool said so.
@@ -116,14 +116,13 @@ impl RenderOnce for ProblemsPanel {
.count()
};
let filters = [
(Severity::Danger, "errors", "Error"),
(Severity::Warning, "warnings", "Warning"),
(Severity::Info, "notes", "Note"),
(Severity::Danger, "errors", ("Error", "Errors")),
(Severity::Warning, "warnings", ("Warning", "Warnings")),
(Severity::Info, "notes", ("Note", "Notes")),
]
.map(|(severity, key, words)| {
.map(|(severity, key, (one, other))| {
let (shown, on_shown) = (self.shown.clone(), self.on_shown.clone());
let found = count(severity);
let label = format!("{found} {words}{}", if found == 1 { "" } else { "s" });
let label = format::plural(count(severity) as u64, one, other);
ToggleButton::new(
(self.id.clone(), key),
ToggleItem::new(key).icon(severity.icon()).label(label),
+39 -3
View File
@@ -9,6 +9,7 @@ use gpui::{
use crate::{
primitives::{Disclosure, Icon, IconName},
theme::{ActiveTheme, IconSize, Radius, TextSize},
typography::format,
};
/// A line that matched: its number, its text, and where in it the matches lie.
@@ -33,7 +34,8 @@ const LEAD: usize = 24;
fn trimmed(hit: &Hit) -> (String, Vec<Range<usize>>) {
let first = hit.ranges.first().map_or(0, |range| range.start);
let before = hit.text[..first].chars().count();
let text = hit.text.trim_end();
let last = hit.ranges.iter().map(|range| range.end).max().unwrap_or(0);
let text = &hit.text[..hit.text.trim_end().len().max(last)];
if before <= LEAD {
let start = text.len() - text.trim_start().len();
let start = start.min(first);
@@ -59,6 +61,15 @@ fn trimmed(hit: &Hit) -> (String, Vec<Range<usize>>) {
(shown, ranges)
}
/// How many places the files name the symbol: every match, two on one line counting twice.
fn references(files: &[FileHits]) -> usize {
files
.iter()
.flat_map(|file| &file.hits)
.map(|hit| hit.ranges.len())
.sum()
}
type OnOpen = Rc<dyn Fn(&SharedString, usize, &mut Window, &mut App)>;
type OnPath = Rc<dyn Fn(&SharedString, &mut Window, &mut App)>;
@@ -397,7 +408,7 @@ impl RenderOnce for ReferencesPanel {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = theme.colors.clone();
let count: usize = self.files.iter().map(|file| file.hits.len()).sum();
let count = references(&self.files);
let rows = results(&self.id, &self.files, &[], None, self.on_open, None, cx);
div()
.flex()
@@ -420,7 +431,7 @@ impl RenderOnce for ReferencesPanel {
div()
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.fg_subtle)
.child(format!("{count} references")),
.child(format::plural(count as u64, "reference", "references")),
),
)
.children(rows)
@@ -431,6 +442,31 @@ impl RenderOnce for ReferencesPanel {
mod tests {
use super::*;
#[test]
fn a_match_on_trailing_spaces_keeps_them() {
let spaces = 6..8;
let hit = Hit {
line: 0,
text: "let a; ".into(),
ranges: vec![spaces],
};
let (text, ranges) = trimmed(&hit);
assert_eq!(&text[ranges[0].clone()], " ");
}
#[test]
fn references_count_every_match_on_a_line() {
let both = FileHits {
path: "a.rs".into(),
hits: vec![Hit {
line: 0,
text: "f(f(1))".into(),
ranges: vec![0..1, 2..3],
}],
};
assert_eq!(references(&[both]), 2);
}
#[test]
fn long_lines_start_near_their_first_match() {
let needle = 40..46;
+20
View File
@@ -10,6 +10,7 @@ use crate::{
forms::{Choice, JsonInput, NumberInput, Select, Switch, TextInput},
primitives::IconName,
theme::{ActiveTheme, ControlSize, Radius, TextSize},
typography::format::decimals,
};
/// A setting's value and the form it takes.
@@ -241,6 +242,7 @@ impl RenderOnce for SettingsEditor {
NumberInput::new(id.clone(), *value)
.range(min, max)
.step(step)
.precision(places(*value, step))
.size(ControlSize::Sm)
.on_change(move |value, window, cx| {
tell(
@@ -371,10 +373,28 @@ impl RenderOnce for SettingsEditor {
}
}
/// Places a number setting shows: enough for its step and its value.
fn places(value: f64, step: f64) -> usize {
let own = if value == 0.0 {
0
} else {
decimals(value.abs())
};
decimals(step).max(own)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn numbers_show_the_places_their_value_and_step_need() {
assert_eq!(places(0.5, 0.1), 1);
assert_eq!(places(14.0, 1.0), 0);
assert_eq!(places(0.25, 0.05), 2);
assert_eq!(places(0.0, 0.5), 1);
}
#[test]
fn json_holds_only_what_changed() {
let groups = vec![(
+7 -1
View File
@@ -107,6 +107,8 @@ pub struct CodeEditor {
pub(crate) options: Options,
pub(crate) marks: Marks,
pub(crate) marked: Option<Range<usize>>,
/// The text before an input method's composition began, while it runs.
pub(crate) composing: Option<Snapshot>,
pub(crate) metrics: Metrics,
pub(crate) dragging: bool,
pub(crate) caret_on: bool,
@@ -172,6 +174,7 @@ impl CodeEditor {
},
marks: Marks::default(),
marked: None,
composing: None,
metrics: Metrics::default(),
dragging: false,
caret_on: true,
@@ -435,6 +438,7 @@ impl CodeEditor {
self.buffer = Buffer::new(snapshot.text);
self.selections = snapshot.selections;
self.marked = None;
self.composing = None;
cx.emit(EditorEvent::Changed);
self.restart_blink(cx);
}
@@ -442,7 +446,9 @@ impl CodeEditor {
/// Makes `before` one undo step when the text moved on from it.
pub(crate) fn commit(&mut self, before: Snapshot, typing: bool, cx: &mut Context<Self>) {
if before.text != self.buffer.text() {
self.history.record(before, typing);
if self.composing.is_none() {
self.history.record(before, typing);
}
self.marks.ghost = None;
self.folded.retain(|line| *line < self.buffer.lines());
cx.emit(EditorEvent::Changed);
+27 -2
View File
@@ -362,8 +362,13 @@ impl CallHierarchy {
}
}
/// A call's node key: its callers' keys, its name and its place, so two calls of one name stay apart.
fn call_key(call: &Call, path: &str) -> String {
format!("{path}/{}@{}", call.name, call.place)
}
fn call_node(call: &Call, path: &str) -> TreeNode {
let key = format!("{path}/{}", call.name);
let key = call_key(call, path);
let node = TreeNode::new(key.clone(), call.name.clone())
.icon(call.kind.icon())
.note(call.place.clone());
@@ -374,7 +379,7 @@ fn call_node(call: &Call, path: &str) -> TreeNode {
}
fn keys(call: &Call, path: &str, out: &mut Vec<SharedString>) {
let key = format!("{path}/{}", call.name);
let key = call_key(call, path);
for inner in &call.calls {
keys(inner, &key, out);
}
@@ -420,6 +425,26 @@ impl RenderOnce for CallHierarchy {
mod tests {
use super::*;
#[test]
fn calls_of_one_name_from_two_places_keep_their_own_keys() {
let call = |name: &str, place: &str| Call {
name: name.to_string().into(),
kind: SymbolKind::Function,
place: place.to_string().into(),
calls: Vec::new(),
};
let root = Call {
calls: vec![call("draw", "a.rs:1"), call("draw", "b.rs:9")],
..call("main", "main.rs:1")
};
let mut all = Vec::new();
keys(&root, "", &mut all);
let mut unique = all.clone();
unique.sort();
unique.dedup();
assert_eq!(unique.len(), all.len());
}
#[test]
fn the_innermost_symbol_above_a_line_holds_it() {
let symbols = vec![
+95 -2
View File
@@ -1,6 +1,8 @@
use gpui::{Entity, TestAppContext, VisualTestContext};
use std::time::Duration;
use super::{CodeEditor, GhostText, layout::Row};
use gpui::{Entity, EntityInputHandler, TestAppContext, VisualTestContext};
use super::{CodeEditor, GhostText, InlayHint, LineNumbers, layout::Row};
use crate::theme::Theme;
fn editor<'a>(
@@ -101,6 +103,97 @@ fn a_selection_inside_a_fold_opens_it(cx: &mut TestAppContext) {
});
}
#[gpui::test]
fn hidden_numbers_draw(cx: &mut TestAppContext) {
let (editor, cx) = editor("a\nb", cx);
editor.update(cx, |editor, cx| {
editor.set_line_numbers(LineNumbers::Hidden, cx)
});
cx.run_until_parked();
assert_eq!(editor.read_with(cx, |editor, _| editor.gutter_columns()), 5);
}
#[gpui::test]
fn edits_with_nothing_to_change_leave_the_text(cx: &mut TestAppContext) {
let (editor, cx) = editor("a\n\nb", cx);
editor.update(cx, |editor, cx| {
editor.outdent(cx);
let blank = 2..2;
editor.select([blank], cx);
editor.toggle_comment(cx);
assert_eq!(editor.text(), "a\n\nb");
});
}
#[gpui::test]
fn a_read_only_editor_ignores_typing_and_undo(cx: &mut TestAppContext) {
let (editor, cx) = editor("x", cx);
editor.update(cx, |editor, cx| {
let end = 1..1;
editor.select([end], cx);
editor.type_text("y", cx);
editor.set_read_only(true, cx);
editor.type_text("(", cx);
assert_eq!(editor.primary().head, 2, "the caret stays");
editor.undo(cx);
assert_eq!(editor.text(), "xy");
});
}
#[gpui::test]
fn a_hint_past_a_multi_line_ghost_stays_in_its_row(cx: &mut TestAppContext) {
let (editor, cx) = editor("call(x)\nnext", cx);
editor.update(cx, |editor, cx| {
editor.set_inlay_hints(
vec![InlayHint {
offset: 6,
text: ": i32".into(),
}],
cx,
);
editor.set_ghost_text(
Some(GhostText {
offset: 5,
text: "a,\n".into(),
}),
cx,
);
let row = editor.row_text(0).len();
assert!(editor.notes(0, cx).iter().all(|(at, ..)| *at <= row));
});
cx.run_until_parked();
}
#[gpui::test]
fn cursors_added_above_keep_climbing(cx: &mut TestAppContext) {
let (editor, cx) = editor("a\nb\nc\nd", cx);
editor.update(cx, |editor, cx| {
let last = 6..6;
editor.select([last], cx);
editor.add_cursor(false, cx);
editor.add_cursor(false, cx);
assert_eq!(editor.selections().len(), 3);
assert_eq!(editor.position().0, 2, "the newest cursor leads");
});
}
#[gpui::test]
fn a_slow_composition_undoes_in_one_step(cx: &mut TestAppContext) {
let (editor, cx) = editor("", cx);
for pinyin in ["n", "ni"] {
editor.update_in(cx, |editor, window, cx| {
editor.replace_and_mark_text_in_range(None, pinyin, None, window, cx)
});
std::thread::sleep(Duration::from_millis(950));
}
editor.update_in(cx, |editor, window, cx| {
editor.replace_text_in_range(None, "你", window, cx);
assert_eq!(editor.text(), "你");
editor.undo(cx);
assert_eq!(editor.text(), "", "the pinyin was never a step");
});
}
impl CodeEditor {
/// Whether line `line` has a row now.
fn frame_shows(&self, line: usize) -> bool {
+28 -10
View File
@@ -90,7 +90,10 @@ impl RenderOnce for RecentProjects {
.size(ControlSize::Sm)
.tooltip(words)
.when_some(action, move |button, action| {
button.on_click(move |_, window, cx| action(ix, window, cx))
button.on_click(move |_, window, cx| {
cx.stop_propagation();
action(ix, window, cx)
})
})
};
div()
@@ -215,16 +218,10 @@ impl RenderOnce for ProjectSwitcher {
let mut found: Vec<(i32, Row)> = ordered(&self.projects)
.into_iter()
.filter_map(|(ix, project)| {
let fit = if query.is_empty() {
None
let (score, hits) = if query.is_empty() {
(0, Vec::new())
} else {
Some(fuzzy(&query, &project.name).or_else(|| fuzzy(&query, &project.path))?)
};
let (score, hits) = fit.map_or((0, Vec::new()), |fit| (fit.score, fit.hits));
let hits = if hits.iter().all(|hit| hit.end <= project.name.len()) {
hits
} else {
Vec::new()
fit(&query, project)?
};
Some((
score,
@@ -282,6 +279,14 @@ impl RenderOnce for ProjectSwitcher {
}
}
/// How well `query` fits a project, by name or else by path, and the name's letters it marks; a path match marks none.
fn fit(query: &str, project: &Project) -> Option<(i32, Vec<std::ops::Range<usize>>)> {
if let Some(fit) = fuzzy(query, &project.name) {
return Some((fit.score, fit.hits));
}
fuzzy(query, &project.path).map(|fit| (fit.score, Vec::new()))
}
/// A way to start from the welcome page: its icon, words and keys.
#[derive(Clone, Debug, PartialEq)]
pub struct StartAction {
@@ -454,6 +459,19 @@ impl RenderOnce for WelcomePage {
mod tests {
use super::*;
#[test]
fn only_a_name_match_marks_the_name() {
let project = Project {
name: "中文".into(),
path: "foo".into(),
branch: None,
opened: "now".into(),
pinned: false,
};
assert_eq!(fit("oo", &project).map(|(_, hits)| hits), Some(Vec::new()));
assert!(fit("zz", &project).is_none());
}
#[test]
fn pinned_projects_lead_and_keep_their_places() {
let project = |name: &str, pinned: bool| Project {