From fd5cd9398db1a9d3cdd8a5e4e084b2c9c25fe114 Mon Sep 17 00:00:00 2001 From: Qiu shao <504250439@Qq.com> Date: Tue, 18 Aug 2026 09:59:35 +0000 Subject: [PATCH] Fix markdown task list marker lookup (#60646) # Objective Fix Markdown preview task list checkboxes not rendering for task items in loose or nested lists. For example, this Markdown should render all three items with checkboxes: ```markdown - [ ] test - [x] test - [x] test ``` Before this change, the items after blank lines could fall back to ordinary list bullets instead of task checkboxes. ## Solution Update Markdown list item rendering to detect task list markers in both tight and loose list event shapes emitted by pulldown-cmark. The previous renderer only handled: Item -> TaskListMarker Loose lists can emit: Item -> Paragraph -> TaskListMarker This PR adds a small helper to find task markers for both forms, then reuses the existing checkbox rendering and toggle behavior. ## Testing Tested on macOS with: rustup run 1.95.0 cargo test -p markdown test_task_marker_lookup_handles_loose_and_nested_lists rustup run 1.95.0 cargo test -p markdown test_table_checkbox The first test covers loose and nested task list items. The table checkbox tests verify that [x] and [ ] inside tables still remain text and are not treated as task list checkboxes. ## Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable ## Showcase before image after Snipaste_2026-07-09_12-27-05 --- Release Notes: - Fixed Markdown Preview for loose list item markers --------- Co-authored-by: dino --- crates/markdown/src/markdown.rs | 126 ++++++++++++++++++++++---------- 1 file changed, 88 insertions(+), 38 deletions(-) diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs index 7f556351428..43189be5fd7 100644 --- a/crates/markdown/src/markdown.rs +++ b/crates/markdown/src/markdown.rs @@ -2636,44 +2636,36 @@ impl Element for MarkdownElement { builder.push_div(div().pl_2p5(), range, markdown_end); } MarkdownTag::Item => { - let bullet = - if let Some((task_range, MarkdownEvent::TaskListMarker(checked))) = - parsed_markdown.events.get(index.saturating_add(1)) - { - let source = &parsed_markdown.source()[range.clone()]; - let checked = *checked; - let toggle_state = if checked { - ToggleState::Selected - } else { - ToggleState::Unselected - }; + let bullet = if let Some((task_range, checked)) = + task_list_marker_for_item(&parsed_markdown.events, index) + { + let source = &parsed_markdown.source()[range.clone()]; + let checkbox = Checkbox::new( + ElementId::Name(source.to_string().into()), + ToggleState::from(checked), + ) + .fill(); - let checkbox = Checkbox::new( - ElementId::Name(source.to_string().into()), - toggle_state, - ) - .fill(); - - if let Some(on_toggle) = self.on_checkbox_toggle.clone() { - let task_source_range = task_range.clone(); - checkbox - .on_click(move |_state, window, cx| { - on_toggle( - task_source_range.clone(), - !checked, - window, - cx, - ); - }) - .into_any_element() - } else { - checkbox.visualization_only(true).into_any_element() - } - } else if let Some(bullet_index) = builder.next_bullet_index() { - div().child(format!("{}.", bullet_index)).into_any_element() + if let Some(on_toggle) = self.on_checkbox_toggle.clone() { + let task_source_range = task_range.clone(); + checkbox + .on_click(move |_state, window, cx| { + on_toggle( + task_source_range.clone(), + !checked, + window, + cx, + ); + }) + .into_any_element() } else { - div().child("•").into_any_element() - }; + checkbox.visualization_only(true).into_any_element() + } + } else if let Some(bullet_index) = builder.next_bullet_index() { + div().child(format!("{}.", bullet_index)).into_any_element() + } else { + div().child("•").into_any_element() + }; self.push_markdown_list_item(&mut builder, bullet, range, markdown_end); } MarkdownTag::Emphasis => builder.push_text_style(TextStyleRefinement { @@ -3414,6 +3406,25 @@ fn alignment_to_text_align(alignment: Alignment) -> Option { } } +// The contents of loose list items are wrapped in a paragraph, so their task +// marker follows `Start(Paragraph)` rather than `Start(Item)`. +fn task_list_marker_for_item( + events: &[(Range, MarkdownEvent)], + item_index: usize, +) -> Option<(Range, bool)> { + let next_index = item_index.checked_add(1)?; + let marker_index = match &events.get(next_index)?.1 { + MarkdownEvent::Start(MarkdownTag::Paragraph) => next_index.checked_add(1)?, + MarkdownEvent::TaskListMarker(_) => next_index, + _ => return None, + }; + + match events.get(marker_index)? { + (range, MarkdownEvent::TaskListMarker(checked)) => Some((range.clone(), *checked)), + _ => None, + } +} + struct MetadataCellStyle { row_index: usize, is_key: bool, @@ -5414,10 +5425,49 @@ mod tests { assert_eq!(table.current_cell_alignment(), None); } + #[test] + fn test_task_list_marker_for_item() { + // Small helper that takes the Markdown contents and returns a vector of + // all task list marker strings as well as whether they are checked or + // not. + let task_marker_states = |markdown: &str| -> Vec<(String, bool)> { + let events = parse_markdown_with_options(markdown, false, false, false).events; + + events + .iter() + .enumerate() + .filter_map(|(index, (_, event))| { + matches!(event, MarkdownEvent::Start(MarkdownTag::Item)) + .then(|| task_list_marker_for_item(&events, index)) + .flatten() + }) + .map(|(range, checked)| (markdown[range].to_string(), checked)) + .collect::>() + }; + + assert_eq!( + task_marker_states("- [ ] task"), + vec![("[ ]".to_string(), false)] + ); + assert_eq!( + task_marker_states("- [x] first\n\n- [ ] second"), + vec![("[x]".to_string(), true), ("[ ]".to_string(), false)] + ); + assert_eq!( + task_marker_states("- [ ] top task\n\n- [x] done task\n\n - [x] nested done\n"), + vec![ + ("[ ]".to_string(), false), + ("[x]".to_string(), true), + ("[x]".to_string(), true) + ] + ); + assert_eq!(task_marker_states("- ordinary item"), vec![]); + } + #[test] fn test_table_checkbox_detection() { let md = "| Done |\n|------|\n| [x] |\n| [ ] |"; - let events = crate::parser::parse_markdown_with_options(md, false, false, false).events; + let events = parse_markdown_with_options(md, false, false, false).events; let mut in_table = false; let mut cell_texts: Vec = Vec::new(); @@ -5459,7 +5509,7 @@ mod tests { #[test] fn test_table_checkbox_marker_source_range() { let md = "| Done |\n|------|\n| [x] |\n| [ ] |"; - let events = crate::parser::parse_markdown_with_options(md, false, false, false).events; + let events = parse_markdown_with_options(md, false, false, false).events; let mut in_cell = false; let mut pending_text = String::new();