From bb769b937a4d3d10fcbcaf20abaf77ad4fdfa3a6 Mon Sep 17 00:00:00 2001 From: iam-liam <117163129+iam-liam@users.noreply.github.com> Date: Thu, 19 Mar 2026 15:39:03 +0000 Subject: [PATCH] markdown: Render checkboxes in markdown table cells (#50595) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Render `[x]` and `[ ]` as checkbox widgets when they appear as the sole content of a markdown table cell. Previously these were displayed as raw text. List-item checkboxes were already rendered correctly; this extends the same treatment to table cells. Fixes #50045. ## What this does - Table cells containing only `[x]`, `[X]`, or `[ ]` now render as visual checkboxes instead of plain text - Both markdown rendering paths are covered: the `markdown` crate (agent panel, chat) and the `markdown_preview` crate (file preview) - Checkboxes are display-only, matching the existing list-item checkbox behavior ## How it works pulldown-cmark splits `[x]` in table cells into three separate `Text` events (`[`, `x`, `]`) rather than emitting a `TaskListMarker` event (which only fires for list items per the GFM spec). The fix operates at each crate's natural interception point: - **`markdown` crate**: After all text events for a table cell have been buffered, `replace_pending_checkbox()` checks the accumulated text before the cell div is finalized. If it matches the checkbox pattern, the pending text is replaced with a `Checkbox` widget. - **`markdown_preview` crate**: In `render_markdown_text()`, text chunks whose trimmed content matches the checkbox pattern are rendered as `MarkdownCheckbox` widgets instead of `InteractiveText`. ## Scope Three files, purely additive: - `crates/markdown/src/markdown.rs` — `replace_pending_checkbox()` on builder, called at `TableCell` end - `crates/markdown_preview/src/markdown_renderer.rs` — checkbox detection in `render_markdown_text()` - `crates/markdown_preview/src/markdown_parser.rs` — test only No changes to parser data models, GPUI, or any shared infrastructure. ## What's not in scope - **HTML ``** — pulldown-cmark strips these as raw HTML. Supporting them requires HTML tag parsing, which is a separate concern. - **Interactive (click-to-toggle) checkboxes in tables** — table checkboxes are display-only. List-item checkboxes in Zed support Cmd+click toggling, but extending that to table cells would require tracking source ranges across the split parser events, which is a separate enhancement. ## Follow-up Table checkbox interactivity (Cmd+click toggle) is straightforward to add as a follow-up — the source ranges are already available in `markdown_preview`, and the `markdown` crate would need minor callback plumbing. ## Screenshots **Markdown checkbox before** md-checkbox-before-1 md-checkbox-before-2 **Markdown checkbox after** md-checkbox-after-1 md-checkbox-after-2 ## Test plan **Unit tests** (2 new): - `test_table_with_checkboxes` (markdown_preview) — parser delivers `[x]`/`[ ]` text into table cell structures - `test_table_checkbox_detection` (markdown) — parser events accumulate checkbox text in table cells, confirming `replace_pending_checkbox` detection logic **Automated**: - [x] `cargo test -p markdown` — 27 tests pass (26 existing + 1 new) - [x] `cargo test -p markdown_preview` — 61 tests pass (60 existing + 1 new) **Manual** (verified against `test-checkbox-table.md`): - [x] Basic `[x]`/`[ ]` in a status column - [x] Checkbox-only column alongside text - [x] Multiple checkbox columns in one table - [x] Left, center, and right column alignments - [x] Uppercase `[X]` variant - [x] Leading/trailing whitespace in cell - [x] Checkboxes alongside other inline elements (links, bold text) - [x] Single-column and minimal two-column tables - [x] Normal table text unaffected by detection - [x] List checkboxes still render correctly (regression) - [x] Agent panel: asked agent to output table with checkbox columns Release Notes: - Fixed `[x]` and `[ ]` checkboxes not rendering in markdown table cells (#50045) --- crates/markdown/src/markdown.rs | 65 +++++++++++++++++++ .../markdown_preview/src/markdown_parser.rs | 29 +++++++++ .../markdown_preview/src/markdown_renderer.rs | 18 +++++ 3 files changed, 112 insertions(+) diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs index 087b7153704c215ec27eae653879ffe9f11ebf09..7605c53c788363b4eec42a2151a258a137ce84a6 100644 --- a/crates/markdown/src/markdown.rs +++ b/crates/markdown/src/markdown.rs @@ -1441,6 +1441,7 @@ impl Element for MarkdownElement { builder.table.end_row(); } MarkdownTagEnd::TableCell => { + builder.replace_pending_checkbox(range); builder.pop_div(); builder.table.end_cell(); } @@ -1926,6 +1927,28 @@ impl MarkdownElementBuilder { } } + fn replace_pending_checkbox(&mut self, source_range: &Range) { + let trimmed = self.pending_line.text.trim(); + if trimmed == "[x]" || trimmed == "[X]" || trimmed == "[ ]" { + let checked = trimmed != "[ ]"; + self.pending_line = PendingLine::default(); + let checkbox = Checkbox::new( + ElementId::Name( + format!("table_checkbox_{}_{}", source_range.start, source_range.end).into(), + ), + if checked { + ToggleState::Selected + } else { + ToggleState::Unselected + }, + ) + .fill() + .visualization_only(true) + .into_any_element(); + self.div_stack.last_mut().unwrap().extend([checkbox]); + } + } + fn flush_text(&mut self) { let line = mem::take(&mut self.pending_line); if line.text.is_empty() { @@ -2493,6 +2516,48 @@ mod tests { assert_eq!(second_word, "b"); } + #[test] + fn test_table_checkbox_detection() { + let md = "| Done |\n|------|\n| [x] |\n| [ ] |"; + let (events, _, _) = crate::parser::parse_markdown(md); + + let mut in_table = false; + let mut cell_texts: Vec = Vec::new(); + let mut current_cell = String::new(); + + for (range, event) in &events { + match event { + MarkdownEvent::Start(MarkdownTag::Table(_)) => in_table = true, + MarkdownEvent::End(MarkdownTagEnd::Table) => in_table = false, + MarkdownEvent::Start(MarkdownTag::TableCell) => current_cell.clear(), + MarkdownEvent::End(MarkdownTagEnd::TableCell) => { + if in_table { + cell_texts.push(current_cell.clone()); + } + } + MarkdownEvent::Text if in_table => { + current_cell.push_str(&md[range.clone()]); + } + _ => {} + } + } + + let checkbox_cells: Vec<&String> = cell_texts + .iter() + .filter(|t| { + let trimmed = t.trim(); + trimmed == "[x]" || trimmed == "[X]" || trimmed == "[ ]" + }) + .collect(); + assert_eq!( + checkbox_cells.len(), + 2, + "Expected 2 checkbox cells, got: {cell_texts:?}" + ); + assert_eq!(checkbox_cells[0].trim(), "[x]"); + assert_eq!(checkbox_cells[1].trim(), "[ ]"); + } + #[gpui::test] fn test_inline_code_word_selection_excludes_backticks(cx: &mut TestAppContext) { // Test that double-clicking on inline code selects just the code content, diff --git a/crates/markdown_preview/src/markdown_parser.rs b/crates/markdown_preview/src/markdown_parser.rs index 29ea273f49578bd6ad408a8d57b891f572705c07..40a1ed804f750a7e3173a76643ad1f6b1a362bd3 100644 --- a/crates/markdown_preview/src/markdown_parser.rs +++ b/crates/markdown_preview/src/markdown_parser.rs @@ -2776,6 +2776,35 @@ Some other content ); } + #[gpui::test] + async fn test_table_with_checkboxes() { + let markdown = "\ +| Done | Task | +|------|---------| +| [x] | Fix bug | +| [ ] | Add feature |"; + + let parsed = parse(markdown).await; + let table = match &parsed.children[0] { + ParsedMarkdownElement::Table(table) => table, + other => panic!("Expected table, got: {:?}", other), + }; + + let first_cell = &table.body[0].columns[0]; + let first_cell_text = match &first_cell.children[0] { + MarkdownParagraphChunk::Text(t) => t.contents.to_string(), + other => panic!("Expected text chunk, got: {:?}", other), + }; + assert_eq!(first_cell_text.trim(), "[x]"); + + let second_cell = &table.body[1].columns[0]; + let second_cell_text = match &second_cell.children[0] { + MarkdownParagraphChunk::Text(t) => t.contents.to_string(), + other => panic!("Expected text chunk, got: {:?}", other), + }; + assert_eq!(second_cell_text.trim(), "[ ]"); + } + #[gpui::test] async fn test_list_basic() { let parsed = parse( diff --git a/crates/markdown_preview/src/markdown_renderer.rs b/crates/markdown_preview/src/markdown_renderer.rs index a6666fd97dccf2adef7e70ac374314cd55ff5821..96adc670d91f51b343c388403fa5aa7ebad678ee 100644 --- a/crates/markdown_preview/src/markdown_renderer.rs +++ b/crates/markdown_preview/src/markdown_renderer.rs @@ -891,6 +891,24 @@ fn render_markdown_text(parsed_new: &MarkdownParagraph, cx: &mut RenderContext) for parsed_region in parsed_new { match parsed_region { MarkdownParagraphChunk::Text(parsed) => { + let trimmed = parsed.contents.trim(); + if trimmed == "[x]" || trimmed == "[X]" || trimmed == "[ ]" { + let checked = trimmed != "[ ]"; + let element = div() + .child(MarkdownCheckbox::new( + cx.next_id(&parsed.source_range), + if checked { + ToggleState::Selected + } else { + ToggleState::Unselected + }, + cx.clone(), + )) + .into_any(); + any_element.push(element); + continue; + } + let element_id = cx.next_id(&parsed.source_range); let highlights = gpui::combine_highlights(