Detailed changes
@@ -1178,6 +1178,10 @@
"remove_trailing_whitespace_on_save": true,
// Whether to start a new line with a comment when a previous line is a comment as well.
"extend_comment_on_newline": true,
+ // Whether to continue markdown lists when pressing enter.
+ "extend_list_on_newline": true,
+ // Whether to indent list items when pressing tab after a list marker.
+ "indent_list_on_tab": true,
// Removes any lines containing only whitespace at the end of the file and
// ensures just one newline at the end.
"ensure_final_newline_on_save": true,
@@ -163,6 +163,7 @@ use project::{
project_settings::{DiagnosticSeverity, GoToDiagnosticSeverityFilter, ProjectSettings},
};
use rand::seq::SliceRandom;
+use regex::Regex;
use rpc::{ErrorCode, ErrorExt, proto::PeerId};
use scroll::{Autoscroll, OngoingScroll, ScrollAnchor, ScrollManager};
use selections_collection::{MutableSelectionsCollection, SelectionsCollection};
@@ -4787,82 +4788,146 @@ impl Editor {
let end = selection.end;
let selection_is_empty = start == end;
let language_scope = buffer.language_scope_at(start);
- let (comment_delimiter, doc_delimiter, newline_formatting) =
- if let Some(language) = &language_scope {
- let mut newline_formatting =
- NewlineFormatting::new(&buffer, start..end, language);
-
- // Comment extension on newline is allowed only for cursor selections
- let comment_delimiter = maybe!({
- if !selection_is_empty {
- return None;
- }
+ let (delimiter, newline_config) = if let Some(language) = &language_scope {
+ let needs_extra_newline = NewlineConfig::insert_extra_newline_brackets(
+ &buffer,
+ start..end,
+ language,
+ )
+ || NewlineConfig::insert_extra_newline_tree_sitter(
+ &buffer,
+ start..end,
+ );
- if !multi_buffer.language_settings(cx).extend_comment_on_newline
- {
- return None;
- }
+ let mut newline_config = NewlineConfig::Newline {
+ additional_indent: IndentSize::spaces(0),
+ extra_line_additional_indent: if needs_extra_newline {
+ Some(IndentSize::spaces(0))
+ } else {
+ None
+ },
+ prevent_auto_indent: false,
+ };
- return comment_delimiter_for_newline(
- &start_point,
- &buffer,
- language,
- );
- });
+ let comment_delimiter = maybe!({
+ if !selection_is_empty {
+ return None;
+ }
- let doc_delimiter = maybe!({
- if !selection_is_empty {
- return None;
- }
+ if !multi_buffer.language_settings(cx).extend_comment_on_newline {
+ return None;
+ }
- if !multi_buffer.language_settings(cx).extend_comment_on_newline
- {
- return None;
- }
+ return comment_delimiter_for_newline(
+ &start_point,
+ &buffer,
+ language,
+ );
+ });
- return documentation_delimiter_for_newline(
- &start_point,
- &buffer,
- language,
- &mut newline_formatting,
- );
- });
+ let doc_delimiter = maybe!({
+ if !selection_is_empty {
+ return None;
+ }
- (comment_delimiter, doc_delimiter, newline_formatting)
- } else {
- (None, None, NewlineFormatting::default())
- };
+ if !multi_buffer.language_settings(cx).extend_comment_on_newline {
+ return None;
+ }
- let prevent_auto_indent = doc_delimiter.is_some();
- let delimiter = comment_delimiter.or(doc_delimiter);
+ return documentation_delimiter_for_newline(
+ &start_point,
+ &buffer,
+ language,
+ &mut newline_config,
+ );
+ });
- let capacity_for_delimiter =
- delimiter.as_deref().map(str::len).unwrap_or_default();
- let mut new_text = String::with_capacity(
- 1 + capacity_for_delimiter
- + existing_indent.len as usize
- + newline_formatting.indent_on_newline.len as usize
- + newline_formatting.indent_on_extra_newline.len as usize,
- );
- new_text.push('\n');
- new_text.extend(existing_indent.chars());
- new_text.extend(newline_formatting.indent_on_newline.chars());
+ let list_delimiter = maybe!({
+ if !selection_is_empty {
+ return None;
+ }
- if let Some(delimiter) = &delimiter {
- new_text.push_str(delimiter);
- }
+ if !multi_buffer.language_settings(cx).extend_list_on_newline {
+ return None;
+ }
- if newline_formatting.insert_extra_newline {
- new_text.push('\n');
- new_text.extend(existing_indent.chars());
- new_text.extend(newline_formatting.indent_on_extra_newline.chars());
- }
+ return list_delimiter_for_newline(
+ &start_point,
+ &buffer,
+ language,
+ &mut newline_config,
+ );
+ });
+
+ (
+ comment_delimiter.or(doc_delimiter).or(list_delimiter),
+ newline_config,
+ )
+ } else {
+ (
+ None,
+ NewlineConfig::Newline {
+ additional_indent: IndentSize::spaces(0),
+ extra_line_additional_indent: None,
+ prevent_auto_indent: false,
+ },
+ )
+ };
+
+ let (edit_start, new_text, prevent_auto_indent) = match &newline_config {
+ NewlineConfig::ClearCurrentLine => {
+ let row_start =
+ buffer.point_to_offset(Point::new(start_point.row, 0));
+ (row_start, String::new(), false)
+ }
+ NewlineConfig::UnindentCurrentLine { continuation } => {
+ let row_start =
+ buffer.point_to_offset(Point::new(start_point.row, 0));
+ let tab_size = buffer.language_settings_at(start, cx).tab_size;
+ let tab_size_indent = IndentSize::spaces(tab_size.get());
+ let reduced_indent =
+ existing_indent.with_delta(Ordering::Less, tab_size_indent);
+ let mut new_text = String::new();
+ new_text.extend(reduced_indent.chars());
+ new_text.push_str(continuation);
+ (row_start, new_text, true)
+ }
+ NewlineConfig::Newline {
+ additional_indent,
+ extra_line_additional_indent,
+ prevent_auto_indent,
+ } => {
+ let capacity_for_delimiter =
+ delimiter.as_deref().map(str::len).unwrap_or_default();
+ let extra_line_len = extra_line_additional_indent
+ .map(|i| 1 + existing_indent.len as usize + i.len as usize)
+ .unwrap_or(0);
+ let mut new_text = String::with_capacity(
+ 1 + capacity_for_delimiter
+ + existing_indent.len as usize
+ + additional_indent.len as usize
+ + extra_line_len,
+ );
+ new_text.push('\n');
+ new_text.extend(existing_indent.chars());
+ new_text.extend(additional_indent.chars());
+ if let Some(delimiter) = &delimiter {
+ new_text.push_str(delimiter);
+ }
+ if let Some(extra_indent) = extra_line_additional_indent {
+ new_text.push('\n');
+ new_text.extend(existing_indent.chars());
+ new_text.extend(extra_indent.chars());
+ }
+ (start, new_text, *prevent_auto_indent)
+ }
+ };
let anchor = buffer.anchor_after(end);
let new_selection = selection.map(|_| anchor);
(
- ((start..end, new_text), prevent_auto_indent),
- (newline_formatting.insert_extra_newline, new_selection),
+ ((edit_start..end, new_text), prevent_auto_indent),
+ (newline_config.has_extra_line(), new_selection),
)
})
.unzip()
@@ -10387,6 +10452,22 @@ impl Editor {
}
prev_edited_row = selection.end.row;
+ // If cursor is after a list prefix, make selection non-empty to trigger line indent
+ if selection.is_empty() {
+ let cursor = selection.head();
+ let settings = buffer.language_settings_at(cursor, cx);
+ if settings.indent_list_on_tab {
+ if let Some(language) = snapshot.language_scope_at(Point::new(cursor.row, 0)) {
+ if is_list_prefix_row(MultiBufferRow(cursor.row), &snapshot, &language) {
+ row_delta = Self::indent_selection(
+ buffer, &snapshot, selection, &mut edits, row_delta, cx,
+ );
+ continue;
+ }
+ }
+ }
+ }
+
// If the selection is non-empty, then increase the indentation of the selected lines.
if !selection.is_empty() {
row_delta =
@@ -23355,7 +23436,7 @@ fn documentation_delimiter_for_newline(
start_point: &Point,
buffer: &MultiBufferSnapshot,
language: &LanguageScope,
- newline_formatting: &mut NewlineFormatting,
+ newline_config: &mut NewlineConfig,
) -> Option<Arc<str>> {
let BlockCommentConfig {
start: start_tag,
@@ -23407,6 +23488,9 @@ fn documentation_delimiter_for_newline(
}
};
+ let mut needs_extra_line = false;
+ let mut extra_line_additional_indent = IndentSize::spaces(0);
+
let cursor_is_before_end_tag_if_exists = {
let mut char_position = 0u32;
let mut end_tag_offset = None;
@@ -23424,11 +23508,11 @@ fn documentation_delimiter_for_newline(
let cursor_is_before_end_tag = column <= end_tag_offset;
if cursor_is_after_start_tag {
if cursor_is_before_end_tag {
- newline_formatting.insert_extra_newline = true;
+ needs_extra_line = true;
}
let cursor_is_at_start_of_end_tag = column == end_tag_offset;
if cursor_is_at_start_of_end_tag {
- newline_formatting.indent_on_extra_newline.len = *len;
+ extra_line_additional_indent.len = *len;
}
}
cursor_is_before_end_tag
@@ -23440,39 +23524,240 @@ fn documentation_delimiter_for_newline(
if (cursor_is_after_start_tag || cursor_is_after_delimiter)
&& cursor_is_before_end_tag_if_exists
{
- if cursor_is_after_start_tag {
- newline_formatting.indent_on_newline.len = *len;
- }
+ let additional_indent = if cursor_is_after_start_tag {
+ IndentSize::spaces(*len)
+ } else {
+ IndentSize::spaces(0)
+ };
+
+ *newline_config = NewlineConfig::Newline {
+ additional_indent,
+ extra_line_additional_indent: if needs_extra_line {
+ Some(extra_line_additional_indent)
+ } else {
+ None
+ },
+ prevent_auto_indent: true,
+ };
Some(delimiter.clone())
} else {
None
}
}
-#[derive(Debug, Default)]
-struct NewlineFormatting {
- insert_extra_newline: bool,
- indent_on_newline: IndentSize,
- indent_on_extra_newline: IndentSize,
+const ORDERED_LIST_MAX_MARKER_LEN: usize = 16;
+
+fn list_delimiter_for_newline(
+ start_point: &Point,
+ buffer: &MultiBufferSnapshot,
+ language: &LanguageScope,
+ newline_config: &mut NewlineConfig,
+) -> Option<Arc<str>> {
+ let (snapshot, range) = buffer.buffer_line_for_row(MultiBufferRow(start_point.row))?;
+
+ let num_of_whitespaces = snapshot
+ .chars_for_range(range.clone())
+ .take_while(|c| c.is_whitespace())
+ .count();
+
+ let task_list_entries: Vec<_> = language
+ .task_list()
+ .into_iter()
+ .flat_map(|config| {
+ config
+ .prefixes
+ .iter()
+ .map(|prefix| (prefix.as_ref(), config.continuation.as_ref()))
+ })
+ .collect();
+ let unordered_list_entries: Vec<_> = language
+ .unordered_list()
+ .iter()
+ .map(|marker| (marker.as_ref(), marker.as_ref()))
+ .collect();
+
+ let all_entries: Vec<_> = task_list_entries
+ .into_iter()
+ .chain(unordered_list_entries)
+ .collect();
+
+ if let Some(max_prefix_len) = all_entries.iter().map(|(p, _)| p.len()).max() {
+ let candidate: String = snapshot
+ .chars_for_range(range.clone())
+ .skip(num_of_whitespaces)
+ .take(max_prefix_len)
+ .collect();
+
+ if let Some((prefix, continuation)) = all_entries
+ .iter()
+ .filter(|(prefix, _)| candidate.starts_with(*prefix))
+ .max_by_key(|(prefix, _)| prefix.len())
+ {
+ let end_of_prefix = num_of_whitespaces + prefix.len();
+ let cursor_is_after_prefix = end_of_prefix <= start_point.column as usize;
+ let has_content_after_marker = snapshot
+ .chars_for_range(range)
+ .skip(end_of_prefix)
+ .any(|c| !c.is_whitespace());
+
+ if has_content_after_marker && cursor_is_after_prefix {
+ return Some((*continuation).into());
+ }
+
+ if start_point.column as usize == end_of_prefix {
+ if num_of_whitespaces == 0 {
+ *newline_config = NewlineConfig::ClearCurrentLine;
+ } else {
+ *newline_config = NewlineConfig::UnindentCurrentLine {
+ continuation: (*continuation).into(),
+ };
+ }
+ }
+
+ return None;
+ }
+ }
+
+ let candidate: String = snapshot
+ .chars_for_range(range.clone())
+ .skip(num_of_whitespaces)
+ .take(ORDERED_LIST_MAX_MARKER_LEN)
+ .collect();
+
+ for ordered_config in language.ordered_list() {
+ let regex = match Regex::new(&ordered_config.pattern) {
+ Ok(r) => r,
+ Err(_) => continue,
+ };
+
+ if let Some(captures) = regex.captures(&candidate) {
+ let full_match = captures.get(0)?;
+ let marker_len = full_match.len();
+ let end_of_prefix = num_of_whitespaces + marker_len;
+ let cursor_is_after_prefix = end_of_prefix <= start_point.column as usize;
+
+ let has_content_after_marker = snapshot
+ .chars_for_range(range)
+ .skip(end_of_prefix)
+ .any(|c| !c.is_whitespace());
+
+ if has_content_after_marker && cursor_is_after_prefix {
+ let number: u32 = captures.get(1)?.as_str().parse().ok()?;
+ let continuation = ordered_config
+ .format
+ .replace("{1}", &(number + 1).to_string());
+ return Some(continuation.into());
+ }
+
+ if start_point.column as usize == end_of_prefix {
+ let continuation = ordered_config.format.replace("{1}", "1");
+ if num_of_whitespaces == 0 {
+ *newline_config = NewlineConfig::ClearCurrentLine;
+ } else {
+ *newline_config = NewlineConfig::UnindentCurrentLine {
+ continuation: continuation.into(),
+ };
+ }
+ }
+
+ return None;
+ }
+ }
+
+ None
}
-impl NewlineFormatting {
- fn new(
- buffer: &MultiBufferSnapshot,
- range: Range<MultiBufferOffset>,
- language: &LanguageScope,
- ) -> Self {
- Self {
- insert_extra_newline: Self::insert_extra_newline_brackets(
- buffer,
- range.clone(),
- language,
- ) || Self::insert_extra_newline_tree_sitter(buffer, range),
- indent_on_newline: IndentSize::spaces(0),
- indent_on_extra_newline: IndentSize::spaces(0),
+fn is_list_prefix_row(
+ row: MultiBufferRow,
+ buffer: &MultiBufferSnapshot,
+ language: &LanguageScope,
+) -> bool {
+ let Some((snapshot, range)) = buffer.buffer_line_for_row(row) else {
+ return false;
+ };
+
+ let num_of_whitespaces = snapshot
+ .chars_for_range(range.clone())
+ .take_while(|c| c.is_whitespace())
+ .count();
+
+ let task_list_prefixes: Vec<_> = language
+ .task_list()
+ .into_iter()
+ .flat_map(|config| {
+ config
+ .prefixes
+ .iter()
+ .map(|p| p.as_ref())
+ .collect::<Vec<_>>()
+ })
+ .collect();
+ let unordered_list_markers: Vec<_> = language
+ .unordered_list()
+ .iter()
+ .map(|marker| marker.as_ref())
+ .collect();
+ let all_prefixes: Vec<_> = task_list_prefixes
+ .into_iter()
+ .chain(unordered_list_markers)
+ .collect();
+ if let Some(max_prefix_len) = all_prefixes.iter().map(|p| p.len()).max() {
+ let candidate: String = snapshot
+ .chars_for_range(range.clone())
+ .skip(num_of_whitespaces)
+ .take(max_prefix_len)
+ .collect();
+ if all_prefixes
+ .iter()
+ .any(|prefix| candidate.starts_with(*prefix))
+ {
+ return true;
}
}
+ let ordered_list_candidate: String = snapshot
+ .chars_for_range(range)
+ .skip(num_of_whitespaces)
+ .take(ORDERED_LIST_MAX_MARKER_LEN)
+ .collect();
+ for ordered_config in language.ordered_list() {
+ let regex = match Regex::new(&ordered_config.pattern) {
+ Ok(r) => r,
+ Err(_) => continue,
+ };
+ if let Some(captures) = regex.captures(&ordered_list_candidate) {
+ return captures.get(0).is_some();
+ }
+ }
+
+ false
+}
+
+#[derive(Debug)]
+enum NewlineConfig {
+ /// Insert newline with optional additional indent and optional extra blank line
+ Newline {
+ additional_indent: IndentSize,
+ extra_line_additional_indent: Option<IndentSize>,
+ prevent_auto_indent: bool,
+ },
+ /// Clear the current line
+ ClearCurrentLine,
+ /// Unindent the current line and add continuation
+ UnindentCurrentLine { continuation: Arc<str> },
+}
+
+impl NewlineConfig {
+ fn has_extra_line(&self) -> bool {
+ matches!(
+ self,
+ Self::Newline {
+ extra_line_additional_indent: Some(_),
+ ..
+ }
+ )
+ }
+
fn insert_extra_newline_brackets(
buffer: &MultiBufferSnapshot,
range: Range<MultiBufferOffset>,
@@ -28021,7 +28021,7 @@ async fn test_markdown_indents(cx: &mut gpui::TestAppContext) {
"
});
- // Case 2: Test adding new line after nested list preserves indent of previous line
+ // Case 2: Test adding new line after nested list continues the list with unchecked task
cx.set_state(&indoc! {"
- [ ] Item 1
- [ ] Item 1.a
@@ -28038,32 +28038,12 @@ async fn test_markdown_indents(cx: &mut gpui::TestAppContext) {
- [x] Item 2
- [x] Item 2.a
- [x] Item 2.b
- ˇ"
+ - [ ] ˇ"
});
- // Case 3: Test adding a new nested list item preserves indent
- cx.set_state(&indoc! {"
- - [ ] Item 1
- - [ ] Item 1.a
- - [x] Item 2
- - [x] Item 2.a
- - [x] Item 2.b
- ˇ"
- });
- cx.update_editor(|editor, window, cx| {
- editor.handle_input("-", window, cx);
- });
- cx.run_until_parked();
- cx.assert_editor_state(indoc! {"
- - [ ] Item 1
- - [ ] Item 1.a
- - [x] Item 2
- - [x] Item 2.a
- - [x] Item 2.b
- -ˇ"
- });
+ // Case 3: Test adding content to continued list item
cx.update_editor(|editor, window, cx| {
- editor.handle_input(" [x] Item 2.c", window, cx);
+ editor.handle_input("Item 2.c", window, cx);
});
cx.run_until_parked();
cx.assert_editor_state(indoc! {"
@@ -28072,10 +28052,10 @@ async fn test_markdown_indents(cx: &mut gpui::TestAppContext) {
- [x] Item 2
- [x] Item 2.a
- [x] Item 2.b
- - [x] Item 2.cˇ"
+ - [ ] Item 2.cˇ"
});
- // Case 4: Test adding new line after nested ordered list preserves indent of previous line
+ // Case 4: Test adding new line after nested ordered list continues with next number
cx.set_state(indoc! {"
1. Item 1
1. Item 1.a
@@ -28092,44 +28072,12 @@ async fn test_markdown_indents(cx: &mut gpui::TestAppContext) {
2. Item 2
1. Item 2.a
2. Item 2.b
- ˇ"
+ 3. ˇ"
});
- // Case 5: Adding new ordered list item preserves indent
- cx.set_state(indoc! {"
- 1. Item 1
- 1. Item 1.a
- 2. Item 2
- 1. Item 2.a
- 2. Item 2.b
- ˇ"
- });
- cx.update_editor(|editor, window, cx| {
- editor.handle_input("3", window, cx);
- });
- cx.run_until_parked();
- cx.assert_editor_state(indoc! {"
- 1. Item 1
- 1. Item 1.a
- 2. Item 2
- 1. Item 2.a
- 2. Item 2.b
- 3ˇ"
- });
- cx.update_editor(|editor, window, cx| {
- editor.handle_input(".", window, cx);
- });
- cx.run_until_parked();
- cx.assert_editor_state(indoc! {"
- 1. Item 1
- 1. Item 1.a
- 2. Item 2
- 1. Item 2.a
- 2. Item 2.b
- 3.ˇ"
- });
+ // Case 5: Adding content to continued ordered list item
cx.update_editor(|editor, window, cx| {
- editor.handle_input(" Item 2.c", window, cx);
+ editor.handle_input("Item 2.c", window, cx);
});
cx.run_until_parked();
cx.assert_editor_state(indoc! {"
@@ -29497,6 +29445,524 @@ async fn test_find_references_single_case(cx: &mut TestAppContext) {
cx.assert_editor_state(after);
}
+#[gpui::test]
+async fn test_newline_task_list_continuation(cx: &mut TestAppContext) {
+ init_test(cx, |settings| {
+ settings.defaults.tab_size = Some(2.try_into().unwrap());
+ });
+
+ let markdown_language = languages::language("markdown", tree_sitter_md::LANGUAGE.into());
+ let mut cx = EditorTestContext::new(cx).await;
+ cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx));
+
+ // Case 1: Adding newline after (whitespace + prefix + any non-whitespace) adds marker
+ cx.set_state(indoc! {"
+ - [ ] taskˇ
+ "});
+ cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ cx.assert_editor_state(indoc! {"
+ - [ ] task
+ - [ ] ˇ
+ "});
+
+ // Case 2: Works with checked task items too
+ cx.set_state(indoc! {"
+ - [x] completed taskˇ
+ "});
+ cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ cx.assert_editor_state(indoc! {"
+ - [x] completed task
+ - [ ] ˇ
+ "});
+
+ // Case 3: Cursor position doesn't matter - content after marker is what counts
+ cx.set_state(indoc! {"
+ - [ ] taˇsk
+ "});
+ cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ cx.assert_editor_state(indoc! {"
+ - [ ] ta
+ - [ ] ˇsk
+ "});
+
+ // Case 4: Adding newline after (whitespace + prefix + some whitespace) does NOT add marker
+ cx.set_state(indoc! {"
+ - [ ] ˇ
+ "});
+ cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ cx.assert_editor_state(
+ indoc! {"
+ - [ ]$$
+ ˇ
+ "}
+ .replace("$", " ")
+ .as_str(),
+ );
+
+ // Case 5: Adding newline with content adds marker preserving indentation
+ cx.set_state(indoc! {"
+ - [ ] task
+ - [ ] indentedˇ
+ "});
+ cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ cx.assert_editor_state(indoc! {"
+ - [ ] task
+ - [ ] indented
+ - [ ] ˇ
+ "});
+
+ // Case 6: Adding newline with cursor right after prefix, unindents
+ cx.set_state(indoc! {"
+ - [ ] task
+ - [ ] sub task
+ - [ ] ˇ
+ "});
+ cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ cx.assert_editor_state(indoc! {"
+ - [ ] task
+ - [ ] sub task
+ - [ ] ˇ
+ "});
+ cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+ cx.wait_for_autoindent_applied().await;
+
+ // Case 7: Adding newline with cursor right after prefix, removes marker
+ cx.assert_editor_state(indoc! {"
+ - [ ] task
+ - [ ] sub task
+ - [ ] ˇ
+ "});
+ cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ cx.assert_editor_state(indoc! {"
+ - [ ] task
+ - [ ] sub task
+ ˇ
+ "});
+
+ // Case 8: Cursor before or inside prefix does not add marker
+ cx.set_state(indoc! {"
+ ˇ- [ ] task
+ "});
+ cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ cx.assert_editor_state(indoc! {"
+
+ ˇ- [ ] task
+ "});
+
+ cx.set_state(indoc! {"
+ - [ˇ ] task
+ "});
+ cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ cx.assert_editor_state(indoc! {"
+ - [
+ ˇ
+ ] task
+ "});
+}
+
+#[gpui::test]
+async fn test_newline_unordered_list_continuation(cx: &mut TestAppContext) {
+ init_test(cx, |settings| {
+ settings.defaults.tab_size = Some(2.try_into().unwrap());
+ });
+
+ let markdown_language = languages::language("markdown", tree_sitter_md::LANGUAGE.into());
+ let mut cx = EditorTestContext::new(cx).await;
+ cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx));
+
+ // Case 1: Adding newline after (whitespace + marker + any non-whitespace) adds marker
+ cx.set_state(indoc! {"
+ - itemˇ
+ "});
+ cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ cx.assert_editor_state(indoc! {"
+ - item
+ - ˇ
+ "});
+
+ // Case 2: Works with different markers
+ cx.set_state(indoc! {"
+ * starred itemˇ
+ "});
+ cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ cx.assert_editor_state(indoc! {"
+ * starred item
+ * ˇ
+ "});
+
+ cx.set_state(indoc! {"
+ + plus itemˇ
+ "});
+ cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ cx.assert_editor_state(indoc! {"
+ + plus item
+ + ˇ
+ "});
+
+ // Case 3: Cursor position doesn't matter - content after marker is what counts
+ cx.set_state(indoc! {"
+ - itˇem
+ "});
+ cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ cx.assert_editor_state(indoc! {"
+ - it
+ - ˇem
+ "});
+
+ // Case 4: Adding newline after (whitespace + marker + some whitespace) does NOT add marker
+ cx.set_state(indoc! {"
+ - ˇ
+ "});
+ cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ cx.assert_editor_state(
+ indoc! {"
+ - $
+ ˇ
+ "}
+ .replace("$", " ")
+ .as_str(),
+ );
+
+ // Case 5: Adding newline with content adds marker preserving indentation
+ cx.set_state(indoc! {"
+ - item
+ - indentedˇ
+ "});
+ cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ cx.assert_editor_state(indoc! {"
+ - item
+ - indented
+ - ˇ
+ "});
+
+ // Case 6: Adding newline with cursor right after marker, unindents
+ cx.set_state(indoc! {"
+ - item
+ - sub item
+ - ˇ
+ "});
+ cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ cx.assert_editor_state(indoc! {"
+ - item
+ - sub item
+ - ˇ
+ "});
+ cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+ cx.wait_for_autoindent_applied().await;
+
+ // Case 7: Adding newline with cursor right after marker, removes marker
+ cx.assert_editor_state(indoc! {"
+ - item
+ - sub item
+ - ˇ
+ "});
+ cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ cx.assert_editor_state(indoc! {"
+ - item
+ - sub item
+ ˇ
+ "});
+
+ // Case 8: Cursor before or inside prefix does not add marker
+ cx.set_state(indoc! {"
+ ˇ- item
+ "});
+ cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ cx.assert_editor_state(indoc! {"
+
+ ˇ- item
+ "});
+
+ cx.set_state(indoc! {"
+ -ˇ item
+ "});
+ cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ cx.assert_editor_state(indoc! {"
+ -
+ ˇitem
+ "});
+}
+
+#[gpui::test]
+async fn test_newline_ordered_list_continuation(cx: &mut TestAppContext) {
+ init_test(cx, |settings| {
+ settings.defaults.tab_size = Some(2.try_into().unwrap());
+ });
+
+ let markdown_language = languages::language("markdown", tree_sitter_md::LANGUAGE.into());
+ let mut cx = EditorTestContext::new(cx).await;
+ cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx));
+
+ // Case 1: Adding newline after (whitespace + marker + any non-whitespace) increments number
+ cx.set_state(indoc! {"
+ 1. first itemˇ
+ "});
+ cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ cx.assert_editor_state(indoc! {"
+ 1. first item
+ 2. ˇ
+ "});
+
+ // Case 2: Works with larger numbers
+ cx.set_state(indoc! {"
+ 10. tenth itemˇ
+ "});
+ cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ cx.assert_editor_state(indoc! {"
+ 10. tenth item
+ 11. ˇ
+ "});
+
+ // Case 3: Cursor position doesn't matter - content after marker is what counts
+ cx.set_state(indoc! {"
+ 1. itˇem
+ "});
+ cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ cx.assert_editor_state(indoc! {"
+ 1. it
+ 2. ˇem
+ "});
+
+ // Case 4: Adding newline after (whitespace + marker + some whitespace) does NOT add marker
+ cx.set_state(indoc! {"
+ 1. ˇ
+ "});
+ cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ cx.assert_editor_state(
+ indoc! {"
+ 1. $
+ ˇ
+ "}
+ .replace("$", " ")
+ .as_str(),
+ );
+
+ // Case 5: Adding newline with content adds marker preserving indentation
+ cx.set_state(indoc! {"
+ 1. item
+ 2. indentedˇ
+ "});
+ cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ cx.assert_editor_state(indoc! {"
+ 1. item
+ 2. indented
+ 3. ˇ
+ "});
+
+ // Case 6: Adding newline with cursor right after marker, unindents
+ cx.set_state(indoc! {"
+ 1. item
+ 2. sub item
+ 3. ˇ
+ "});
+ cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ cx.assert_editor_state(indoc! {"
+ 1. item
+ 2. sub item
+ 1. ˇ
+ "});
+ cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+ cx.wait_for_autoindent_applied().await;
+
+ // Case 7: Adding newline with cursor right after marker, removes marker
+ cx.assert_editor_state(indoc! {"
+ 1. item
+ 2. sub item
+ 1. ˇ
+ "});
+ cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ cx.assert_editor_state(indoc! {"
+ 1. item
+ 2. sub item
+ ˇ
+ "});
+
+ // Case 8: Cursor before or inside prefix does not add marker
+ cx.set_state(indoc! {"
+ ˇ1. item
+ "});
+ cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ cx.assert_editor_state(indoc! {"
+
+ ˇ1. item
+ "});
+
+ cx.set_state(indoc! {"
+ 1ˇ. item
+ "});
+ cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ cx.assert_editor_state(indoc! {"
+ 1
+ ˇ. item
+ "});
+}
+
+#[gpui::test]
+async fn test_newline_should_not_autoindent_ordered_list(cx: &mut TestAppContext) {
+ init_test(cx, |settings| {
+ settings.defaults.tab_size = Some(2.try_into().unwrap());
+ });
+
+ let markdown_language = languages::language("markdown", tree_sitter_md::LANGUAGE.into());
+ let mut cx = EditorTestContext::new(cx).await;
+ cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx));
+
+ // Case 1: Adding newline after (whitespace + marker + any non-whitespace) increments number
+ cx.set_state(indoc! {"
+ 1. first item
+ 1. sub first item
+ 2. sub second item
+ 3. ˇ
+ "});
+ cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ cx.assert_editor_state(indoc! {"
+ 1. first item
+ 1. sub first item
+ 2. sub second item
+ 1. ˇ
+ "});
+}
+
+#[gpui::test]
+async fn test_tab_list_indent(cx: &mut TestAppContext) {
+ init_test(cx, |settings| {
+ settings.defaults.tab_size = Some(2.try_into().unwrap());
+ });
+
+ let markdown_language = languages::language("markdown", tree_sitter_md::LANGUAGE.into());
+ let mut cx = EditorTestContext::new(cx).await;
+ cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx));
+
+ // Case 1: Unordered list - cursor after prefix, adds indent before prefix
+ cx.set_state(indoc! {"
+ - ˇitem
+ "});
+ cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ let expected = indoc! {"
+ $$- ˇitem
+ "};
+ cx.assert_editor_state(expected.replace("$", " ").as_str());
+
+ // Case 2: Task list - cursor after prefix
+ cx.set_state(indoc! {"
+ - [ ] ˇtask
+ "});
+ cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ let expected = indoc! {"
+ $$- [ ] ˇtask
+ "};
+ cx.assert_editor_state(expected.replace("$", " ").as_str());
+
+ // Case 3: Ordered list - cursor after prefix
+ cx.set_state(indoc! {"
+ 1. ˇfirst
+ "});
+ cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ let expected = indoc! {"
+ $$1. ˇfirst
+ "};
+ cx.assert_editor_state(expected.replace("$", " ").as_str());
+
+ // Case 4: With existing indentation - adds more indent
+ let initial = indoc! {"
+ $$- ˇitem
+ "};
+ cx.set_state(initial.replace("$", " ").as_str());
+ cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ let expected = indoc! {"
+ $$$$- ˇitem
+ "};
+ cx.assert_editor_state(expected.replace("$", " ").as_str());
+
+ // Case 5: Empty list item
+ cx.set_state(indoc! {"
+ - ˇ
+ "});
+ cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ let expected = indoc! {"
+ $$- ˇ
+ "};
+ cx.assert_editor_state(expected.replace("$", " ").as_str());
+
+ // Case 6: Cursor at end of line with content
+ cx.set_state(indoc! {"
+ - itemˇ
+ "});
+ cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ let expected = indoc! {"
+ $$- itemˇ
+ "};
+ cx.assert_editor_state(expected.replace("$", " ").as_str());
+
+ // Case 7: Cursor at start of list item, indents it
+ cx.set_state(indoc! {"
+ - item
+ ˇ - sub item
+ "});
+ cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ let expected = indoc! {"
+ - item
+ ˇ - sub item
+ "};
+ cx.assert_editor_state(expected);
+
+ // Case 8: Cursor at start of list item, moves the cursor when "indent_list_on_tab" is false
+ cx.update_editor(|_, _, cx| {
+ SettingsStore::update_global(cx, |store, cx| {
+ store.update_user_settings(cx, |settings| {
+ settings.project.all_languages.defaults.indent_list_on_tab = Some(false);
+ });
+ });
+ });
+ cx.set_state(indoc! {"
+ - item
+ ˇ - sub item
+ "});
+ cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ let expected = indoc! {"
+ - item
+ ˇ- sub item
+ "};
+ cx.assert_editor_state(expected);
+}
+
#[gpui::test]
async fn test_local_worktree_trust(cx: &mut TestAppContext) {
init_test(cx, |_| {});
@@ -827,6 +827,15 @@ pub struct LanguageConfig {
/// Delimiters and configuration for recognizing and formatting documentation comments.
#[serde(default, alias = "documentation")]
pub documentation_comment: Option<BlockCommentConfig>,
+ /// List markers that are inserted unchanged on newline (e.g., `- `, `* `, `+ `).
+ #[serde(default)]
+ pub unordered_list: Vec<Arc<str>>,
+ /// Configuration for ordered lists with auto-incrementing numbers on newline (e.g., `1. ` becomes `2. `).
+ #[serde(default)]
+ pub ordered_list: Vec<OrderedListConfig>,
+ /// Configuration for task lists where multiple markers map to a single continuation prefix (e.g., `- [x] ` continues as `- [ ] `).
+ #[serde(default)]
+ pub task_list: Option<TaskListConfig>,
/// A list of additional regex patterns that should be treated as prefixes
/// for creating boundaries during rewrapping, ensuring content from one
/// prefixed section doesn't merge with another (e.g., markdown list items).
@@ -898,6 +907,24 @@ pub struct DecreaseIndentConfig {
pub valid_after: Vec<String>,
}
+/// Configuration for continuing ordered lists with auto-incrementing numbers.
+#[derive(Clone, Debug, Deserialize, JsonSchema)]
+pub struct OrderedListConfig {
+ /// A regex pattern with a capture group for the number portion (e.g., `(\\d+)\\. `).
+ pub pattern: String,
+ /// A format string where `{1}` is replaced with the incremented number (e.g., `{1}. `).
+ pub format: String,
+}
+
+/// Configuration for continuing task lists on newline.
+#[derive(Clone, Debug, Deserialize, JsonSchema)]
+pub struct TaskListConfig {
+ /// The list markers to match (e.g., `- [ ] `, `- [x] `).
+ pub prefixes: Vec<Arc<str>>,
+ /// The marker to insert when continuing the list on a new line (e.g., `- [ ] `).
+ pub continuation: Arc<str>,
+}
+
#[derive(Clone, Debug, Serialize, Deserialize, Default, JsonSchema)]
pub struct LanguageMatcher {
/// Given a list of `LanguageConfig`'s, the language of a file can be determined based on the path extension matching any of the `path_suffixes`.
@@ -1068,6 +1095,9 @@ impl Default for LanguageConfig {
line_comments: Default::default(),
block_comment: Default::default(),
documentation_comment: Default::default(),
+ unordered_list: Default::default(),
+ ordered_list: Default::default(),
+ task_list: Default::default(),
rewrap_prefixes: Default::default(),
scope_opt_in_language_servers: Default::default(),
overrides: Default::default(),
@@ -2153,6 +2183,21 @@ impl LanguageScope {
self.language.config.documentation_comment.as_ref()
}
+ /// Returns list markers that are inserted unchanged on newline (e.g., `- `, `* `, `+ `).
+ pub fn unordered_list(&self) -> &[Arc<str>] {
+ &self.language.config.unordered_list
+ }
+
+ /// Returns configuration for ordered lists with auto-incrementing numbers (e.g., `1. ` becomes `2. `).
+ pub fn ordered_list(&self) -> &[OrderedListConfig] {
+ &self.language.config.ordered_list
+ }
+
+ /// Returns configuration for task list continuation, if any (e.g., `- [x] ` continues as `- [ ] `).
+ pub fn task_list(&self) -> Option<&TaskListConfig> {
+ self.language.config.task_list.as_ref()
+ }
+
/// Returns additional regex patterns that act as prefix markers for creating
/// boundaries during rewrapping.
///
@@ -122,6 +122,10 @@ pub struct LanguageSettings {
pub whitespace_map: WhitespaceMap,
/// Whether to start a new line with a comment when a previous line is a comment as well.
pub extend_comment_on_newline: bool,
+ /// Whether to continue markdown lists when pressing enter.
+ pub extend_list_on_newline: bool,
+ /// Whether to indent list items when pressing tab after a list marker.
+ pub indent_list_on_tab: bool,
/// Inlay hint related settings.
pub inlay_hints: InlayHintSettings,
/// Whether to automatically close brackets.
@@ -567,6 +571,8 @@ impl settings::Settings for AllLanguageSettings {
tab: SharedString::new(whitespace_map.tab.unwrap().to_string()),
},
extend_comment_on_newline: settings.extend_comment_on_newline.unwrap(),
+ extend_list_on_newline: settings.extend_list_on_newline.unwrap(),
+ indent_list_on_tab: settings.indent_list_on_tab.unwrap(),
inlay_hints: InlayHintSettings {
enabled: inlay_hints.enabled.unwrap(),
show_value_hints: inlay_hints.show_value_hints.unwrap(),
@@ -20,6 +20,9 @@ rewrap_prefixes = [
">\\s*",
"[-*+]\\s+\\[[\\sx]\\]\\s+"
]
+unordered_list = ["- ", "* ", "+ "]
+ordered_list = [{ pattern = "(\\d+)\\. ", format = "{1}. " }]
+task_list = { prefixes = ["- [ ] ", "- [x] "], continuation = "- [ ] " }
auto_indent_on_paste = false
auto_indent_using_last_non_empty_line = false
@@ -363,6 +363,14 @@ pub struct LanguageSettingsContent {
///
/// Default: true
pub extend_comment_on_newline: Option<bool>,
+ /// Whether to continue markdown lists when pressing enter.
+ ///
+ /// Default: true
+ pub extend_list_on_newline: Option<bool>,
+ /// Whether to indent list items when pressing tab after a list marker.
+ ///
+ /// Default: true
+ pub indent_list_on_tab: Option<bool>,
/// Inlay hint related settings.
pub inlay_hints: Option<InlayHintSettingsContent>,
/// Whether to automatically type closing characters for you. For example,
@@ -430,6 +430,8 @@ impl VsCodeSettings {
enable_language_server: None,
ensure_final_newline_on_save: self.read_bool("files.insertFinalNewline"),
extend_comment_on_newline: None,
+ extend_list_on_newline: None,
+ indent_list_on_tab: None,
format_on_save: self.read_bool("editor.guides.formatOnSave").map(|b| {
if b {
FormatOnSave::On
@@ -1585,6 +1585,26 @@ Positive `integer` value between 1 and 32. Values outside of this range will be
`boolean` values
+## Extend List On Newline
+
+- Description: Whether to continue lists when pressing Enter at the end of a list item. Supports unordered, ordered, and task lists. Pressing Enter on an empty list item removes the marker and exits the list.
+- Setting: `extend_list_on_newline`
+- Default: `true`
+
+**Options**
+
+`boolean` values
+
+## Indent List On Tab
+
+- Description: Whether to indent list items when pressing Tab on a line containing only a list marker. This enables quick creation of nested lists.
+- Setting: `indent_list_on_tab`
+- Default: `true`
+
+**Options**
+
+`boolean` values
+
## Status Bar
- Description: Control various elements in the status bar. Note that some items in the status bar have their own settings set elsewhere.
@@ -33,6 +33,40 @@ Zed supports using Prettier to automatically re-format Markdown documents. You c
},
```
+### List Continuation
+
+Zed automatically continues lists when you press Enter at the end of a list item. Supported list types:
+
+- Unordered lists (`-`, `*`, or `+` markers)
+- Ordered lists (numbers are auto-incremented)
+- Task lists (`- [ ]` and `- [x]`)
+
+Pressing Enter on an empty list item removes the marker and exits the list.
+
+To disable this behavior:
+
+```json [settings]
+ "languages": {
+ "Markdown": {
+ "extend_list_on_newline": false
+ }
+ },
+```
+
+### List Indentation
+
+Zed indents list items when you press Tab while the cursor is on a line containing only a list marker. This allows you to quickly create nested lists.
+
+To disable this behavior:
+
+```json [settings]
+ "languages": {
+ "Markdown": {
+ "indent_list_on_tab": false
+ }
+ },
+```
+
### Trailing Whitespace
By default Zed will remove trailing whitespace on save. If you rely on invisible trailing whitespace being converted to `<br />` in Markdown files you can disable this behavior with: