diff --git a/crates/ely_app/src/shell/internal_pages.rs b/crates/ely_app/src/shell/internal_pages.rs index f23a870..7c7b41a 100644 --- a/crates/ely_app/src/shell/internal_pages.rs +++ b/crates/ely_app/src/shell/internal_pages.rs @@ -7,6 +7,7 @@ mod downloads; mod general; mod history; mod notes; +mod notes_markdown; mod plugin_catalog; mod plugin_details; mod plugins; diff --git a/crates/ely_app/src/shell/internal_pages/notes.rs b/crates/ely_app/src/shell/internal_pages/notes.rs index 51c9554..5fb0d17 100644 --- a/crates/ely_app/src/shell/internal_pages/notes.rs +++ b/crates/ely_app/src/shell/internal_pages/notes.rs @@ -14,7 +14,7 @@ use gpui_component::{ scroll::ScrollableElement, }; -use super::{ElyShell, render_canvas_surface}; +use super::{ElyShell, notes_markdown::render_markdown_preview, render_canvas_surface}; impl ElyShell { pub(super) fn render_notes_page( @@ -127,13 +127,7 @@ impl ElyShell { .text_color(rgb(colors::MUTED)) .child(note.display_url()), ) - .child( - div() - .text_xs() - .truncate() - .text_color(rgb(colors::MUTED_SOFT)) - .child(markdown_preview(note.body())), - ), + .child(render_markdown_preview(note.body())), ), ) .child( @@ -226,24 +220,6 @@ fn render_remove_note_action( .into_any_element() } -fn markdown_preview(body: &str) -> String { - let preview = body - .lines() - .find_map(|line| { - let trimmed = line.trim(); - (!trimmed.is_empty()).then_some(trimmed) - }) - .unwrap_or(body.trim()); - - truncate_chars(preview, 120) -} - -fn truncate_chars(value: &str, limit: usize) -> String { - let mut chars = value.chars(); - let truncated: String = chars.by_ref().take(limit).collect(); - if chars.next().is_some() { format!("{truncated}...") } else { truncated } -} - fn updated_at_label(updated_at: SystemTime) -> String { updated_at_label_for(updated_at, SystemTime::now()) } @@ -269,20 +245,7 @@ fn updated_at_label_for(updated_at: SystemTime, now: SystemTime) -> String { mod tests { use std::time::{Duration, SystemTime}; - use super::{markdown_preview, updated_at_label_for}; - - #[test] - fn markdown_preview_uses_first_non_empty_line() { - assert_eq!(markdown_preview("\n # Heading\n- detail"), "# Heading"); - } - - #[test] - fn markdown_preview_truncates_long_lines() { - let preview = markdown_preview(&"a".repeat(130)); - - assert_eq!(preview.len(), 123); - assert!(preview.ends_with("...")); - } + use super::updated_at_label_for; #[test] fn updated_at_label_formats_recent_entries() { diff --git a/crates/ely_app/src/shell/internal_pages/notes_markdown.rs b/crates/ely_app/src/shell/internal_pages/notes_markdown.rs new file mode 100644 index 0000000..6c88439 --- /dev/null +++ b/crates/ely_app/src/shell/internal_pages/notes_markdown.rs @@ -0,0 +1,245 @@ +use ely_design_system::colors; +use gpui::{AnyElement, IntoElement, ParentElement, Styled, div, px, rgb}; +use gpui_component::StyledExt; + +const PREVIEW_BLOCK_LIMIT: usize = 4; +const CODE_LINE_LIMIT: usize = 3; +const TEXT_LIMIT: usize = 120; + +#[derive(Clone, Debug, Eq, PartialEq)] +enum MarkdownPreviewBlock { + Heading { level: u8, text: String }, + Bullet(String), + Quote(String), + Code(String), + Paragraph(String), +} + +pub(super) fn render_markdown_preview(body: &str) -> AnyElement { + div() + .max_h(px(96.0)) + .overflow_hidden() + .flex() + .flex_col() + .gap_1() + .children(markdown_preview_blocks(body).into_iter().map(render_markdown_block)) + .into_any_element() +} + +fn render_markdown_block(block: MarkdownPreviewBlock) -> AnyElement { + match block { + MarkdownPreviewBlock::Heading { level, text } => render_heading(level, text), + MarkdownPreviewBlock::Bullet(text) => render_bullet(text), + MarkdownPreviewBlock::Quote(text) => render_quote(text), + MarkdownPreviewBlock::Code(text) => render_code(text), + MarkdownPreviewBlock::Paragraph(text) => render_paragraph(text), + } +} + +fn render_heading(level: u8, text: String) -> AnyElement { + let size = match level { + 1 => 14.0, + 2 => 13.0, + _ => 12.0, + }; + + div() + .text_size(px(size)) + .font_semibold() + .truncate() + .text_color(rgb(colors::INK)) + .child(text) + .into_any_element() +} + +fn render_bullet(text: String) -> AnyElement { + div() + .min_w_0() + .flex() + .items_start() + .gap_2() + .text_xs() + .text_color(rgb(colors::MUTED_SOFT)) + .child(div().flex_none().child("-")) + .child(div().min_w_0().truncate().child(text)) + .into_any_element() +} + +fn render_quote(text: String) -> AnyElement { + div() + .min_w_0() + .border_l_2() + .border_color(rgb(colors::HAIRLINE_STRONG)) + .pl_2() + .text_xs() + .truncate() + .text_color(rgb(colors::MUTED)) + .child(text) + .into_any_element() +} + +fn render_code(text: String) -> AnyElement { + div() + .min_w_0() + .rounded_md() + .border_1() + .border_color(rgb(colors::HAIRLINE)) + .bg(rgb(colors::CANVAS_SOFT)) + .px_2() + .py_1() + .text_xs() + .text_color(rgb(colors::BODY)) + .child(truncate_text(&text, TEXT_LIMIT)) + .into_any_element() +} + +fn render_paragraph(text: String) -> AnyElement { + div().text_xs().truncate().text_color(rgb(colors::MUTED_SOFT)).child(text).into_any_element() +} + +fn markdown_preview_blocks(body: &str) -> Vec { + let mut blocks = Vec::new(); + let mut code_lines = Vec::new(); + let mut in_code_block = false; + + for line in body.lines() { + let trimmed = line.trim(); + if trimmed.starts_with("```") { + if in_code_block { + push_code_block(&mut blocks, &mut code_lines); + if blocks.len() >= PREVIEW_BLOCK_LIMIT { + break; + } + } + in_code_block = !in_code_block; + continue; + } + + if in_code_block { + if !trimmed.is_empty() && code_lines.len() < CODE_LINE_LIMIT { + code_lines.push(trimmed.to_string()); + } + continue; + } + + let Some(block) = parse_markdown_line(trimmed) else { + continue; + }; + + blocks.push(block); + if blocks.len() >= PREVIEW_BLOCK_LIMIT { + break; + } + } + + if in_code_block && blocks.len() < PREVIEW_BLOCK_LIMIT { + push_code_block(&mut blocks, &mut code_lines); + } + + blocks +} + +fn parse_markdown_line(line: &str) -> Option { + if line.is_empty() { + return None; + } + + if let Some((level, text)) = heading(line) { + return Some(MarkdownPreviewBlock::Heading { + level, + text: truncate_text(text, TEXT_LIMIT), + }); + } + + if let Some(text) = line.strip_prefix("- ").or_else(|| line.strip_prefix("* ")) { + return non_empty_block(text, MarkdownPreviewBlock::Bullet); + } + + if let Some(text) = line.strip_prefix("> ") { + return non_empty_block(text, MarkdownPreviewBlock::Quote); + } + + Some(MarkdownPreviewBlock::Paragraph(truncate_text(line, TEXT_LIMIT))) +} + +fn heading(line: &str) -> Option<(u8, &str)> { + let level = line.chars().take_while(|character| *character == '#').count(); + if !(1..=3).contains(&level) { + return None; + } + + let text = line[level..].strip_prefix(' ')?; + (!text.trim().is_empty()).then_some((level as u8, text.trim())) +} + +fn non_empty_block( + value: &str, + block: impl FnOnce(String) -> MarkdownPreviewBlock, +) -> Option { + let trimmed = value.trim(); + (!trimmed.is_empty()).then(|| block(truncate_text(trimmed, TEXT_LIMIT))) +} + +fn push_code_block(blocks: &mut Vec, code_lines: &mut Vec) { + if code_lines.is_empty() { + return; + } + + blocks.push(MarkdownPreviewBlock::Code(code_lines.join(" "))); + code_lines.clear(); +} + +fn truncate_text(value: &str, limit: usize) -> String { + let mut chars = value.chars(); + let truncated: String = chars.by_ref().take(limit).collect(); + if chars.next().is_some() { format!("{truncated}...") } else { truncated } +} + +#[cfg(test)] +mod tests { + use super::{MarkdownPreviewBlock, markdown_preview_blocks, truncate_text}; + + #[test] + fn markdown_preview_blocks_parse_headings_bullets_and_quotes() { + let blocks = markdown_preview_blocks("# Heading\n- first\n> quoted\nplain"); + + assert_eq!( + blocks, + vec![ + MarkdownPreviewBlock::Heading { level: 1, text: "Heading".to_string() }, + MarkdownPreviewBlock::Bullet("first".to_string()), + MarkdownPreviewBlock::Quote("quoted".to_string()), + MarkdownPreviewBlock::Paragraph("plain".to_string()), + ] + ); + } + + #[test] + fn markdown_preview_blocks_parse_fenced_code() { + let blocks = markdown_preview_blocks("```rust\nlet value = 1;\nvalue + 1\n```\nnext"); + + assert_eq!( + blocks, + vec![ + MarkdownPreviewBlock::Code("let value = 1; value + 1".to_string()), + MarkdownPreviewBlock::Paragraph("next".to_string()), + ] + ); + } + + #[test] + fn markdown_preview_blocks_limit_visible_blocks() { + let blocks = markdown_preview_blocks("one\ntwo\nthree\nfour\nfive"); + + assert_eq!(blocks.len(), 4); + assert_eq!(blocks[3], MarkdownPreviewBlock::Paragraph("four".to_string())); + } + + #[test] + fn truncate_text_appends_marker_for_long_text() { + let truncated = truncate_text(&"a".repeat(130), 120); + + assert_eq!(truncated.len(), 123); + assert!(truncated.ends_with("...")); + } +}