MarkdownDocument: a Markdown file in one CodeEditor, shown visual, source, split or preview
CI / site (push) Successful in 34s
CI / platforms (windows-latest) (push) Successful in 5m7s
CI / crate (push) Failing after 26m45s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s

Visual draws the top-level blocks in a gpui list; a pressed block opens as
its source and its typing is the text at once, written back as one undo
step. CodeEditor takes the owner's line colors, resets with a fresh
history and keeps at most 64 MiB of undo text. MarkdownMode gains Preview.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-12 00:44:17 -04:00
1 parent 9036c92442
commit 2b6f5513aa
19 files changed
+1267 -86

No files matched your search

+301
View File
@@ -0,0 +1,301 @@
mod parse;
#[cfg(all(test, feature = "test-support"))]
mod tests;
mod visual;
pub(crate) use visual::bind_keys;
use std::{ops::Range, path::Path, rc::Rc, sync::Arc};
use gpui::{
App, AppContext as _, Context, Entity, EventEmitter, FocusHandle, Focusable, ListAlignment,
ListState, Subscription, Task, Window,
};
pub use parse::DocumentHeading;
use parse::{Parsed, alike, parsed};
use super::{MarkdownMode, markdown::markdown_highlights, modes::joint, render::OnLink};
use crate::{
editor::{CodeEditor, EditorEvent, LineNumbers},
forms::TextInput,
};
/// Texts this long parse at once; longer ones elsewhere.
const AT_ONCE: usize = 128 << 10;
/// What the document tells its owner.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DocumentEvent {
/// The text changed, typed in any mode or replaced.
Changed,
/// A new read of the text: blocks and headings.
Parsed,
}
/// The block open as its source, in its own field.
struct Open {
range: Range<usize>,
input: Entity<TextInput>,
/// Opened past the end, to add a block.
append: bool,
_subscriptions: Vec<Subscription>,
}
/// A Markdown file: one text, one history, three views.
pub struct MarkdownDocument {
editor: Entity<CodeEditor>,
mode: MarkdownMode,
base: Option<Arc<Path>>,
parsed: Arc<Parsed>,
/// Text changes counted; `read` is the one parsed.
edits: u64,
read: u64,
_parsing: Option<Task<()>>,
list: ListState,
open: Option<Open>,
/// Where the caret rests while no block is open.
rest: usize,
focus: FocusHandle,
on_link: Option<OnLink>,
_subscriptions: Vec<Subscription>,
}
impl EventEmitter<DocumentEvent> for MarkdownDocument {}
impl Focusable for MarkdownDocument {
fn focus_handle(&self, cx: &App) -> FocusHandle {
match self.mode {
MarkdownMode::Source | MarkdownMode::Split => self.editor.focus_handle(cx),
MarkdownMode::Visual | MarkdownMode::Preview => self.focus.clone(),
}
}
}
impl MarkdownDocument {
pub fn new(
text: impl Into<String>,
mode: MarkdownMode,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let editor = cx.new(|cx| {
CodeEditor::new(text, window, cx)
.language("Markdown")
.line_numbers(LineNumbers::Hidden)
.hide_indent_guides()
.highlighter(markdown_highlights)
});
let subscriptions = vec![cx.subscribe(&editor, |document, _, event, cx| {
if *event == EditorEvent::Changed {
document.changed(cx);
}
})];
let overdraw = window.rem_size() * 40.0;
let mut document = Self {
editor,
mode,
base: None,
parsed: Arc::default(),
edits: 0,
read: u64::MAX,
_parsing: None,
list: ListState::new(1, ListAlignment::Top, overdraw),
open: None,
rest: 0,
focus: cx.focus_handle(),
on_link: None,
_subscriptions: subscriptions,
};
document.reparse(cx);
document
}
/// The folder relative pictures load from.
pub fn set_base(&mut self, folder: impl Into<Arc<Path>>, cx: &mut Context<Self>) {
self.base = Some(folder.into());
cx.notify();
}
/// Takes link presses; without it the system opens them.
pub fn on_link(&mut self, handler: impl Fn(&str, &mut Window, &mut App) + 'static) {
self.on_link = Some(Rc::new(handler));
}
pub fn editor(&self) -> &Entity<CodeEditor> {
&self.editor
}
pub fn mode(&self) -> MarkdownMode {
self.mode
}
/// Shows the text as `mode`; an open block writes first.
pub fn set_mode(&mut self, mode: MarkdownMode, window: &mut Window, cx: &mut Context<Self>) {
self.commit(cx);
log::info!("markdown document: {:?} to {mode:?}", self.mode);
self.mode = mode;
let focus = self.focus_handle(cx);
window.focus(&focus, cx);
cx.notify();
}
/// The text now, an open block's typing included.
pub fn text(&self, cx: &App) -> String {
let text = self.editor.read(cx).text();
let Some(open) = &self.open else {
return text.to_string();
};
let typed = open.input.read(cx).text();
let written = match open.append {
true if typed.is_empty() => String::new(),
true => format!("{}{typed}", joint(text)),
false => typed.to_string(),
};
[&text[..open.range.start], &written, &text[open.range.end..]].concat()
}
/// Replaces the text in one step; open typing drops.
pub fn set_text(&mut self, text: impl Into<String>, cx: &mut Context<Self>) {
self.drop_open(cx);
self.editor
.update(cx, |editor, cx| editor.set_text(text, cx));
}
/// Replaces the text, undo afresh; open typing drops.
pub fn reset(&mut self, text: impl Into<String>, cx: &mut Context<Self>) {
self.drop_open(cx);
self.rest = 0;
self.editor.update(cx, |editor, cx| editor.reset(text, cx));
}
/// The headings as last read, with lines from zero.
pub fn outline(&self, cx: &App) -> Vec<(DocumentHeading, usize)> {
let editor = self.editor.read(cx);
self.parsed
.headings
.iter()
.filter(|heading| heading.at <= editor.text().len())
.map(|heading| (heading.clone(), editor.buffer.line_of(heading.at)))
.collect()
}
/// Whether the blocks shown read the text now.
pub fn read_now(&self) -> bool {
self.read == self.edits
}
/// The caret's line and column, from zero.
pub fn caret(&self, cx: &App) -> (usize, usize) {
let editor = self.editor.read(cx);
let at = match (&self.open, self.mode) {
(_, MarkdownMode::Source | MarkdownMode::Split) => editor.primary().head,
(Some(open), _) => {
let input = open.input.read(cx);
let line = editor
.buffer
.line_of(open.range.start.min(editor.text().len()));
let typed = &input.text()[..input.cursor()];
let column = match typed.rfind('\n') {
Some(newline) => typed[newline + 1..].chars().count(),
None => {
let start = editor.buffer.line_range(line).start;
editor.text()[start..open.range.start].chars().count()
+ typed.chars().count()
}
};
return (line + typed.matches('\n').count(), column);
}
(None, _) => self.rest,
};
editor.buffer.point(at.min(editor.text().len()))
}
/// The first line in view.
pub fn top_line(&self, cx: &App) -> usize {
let editor = self.editor.read(cx);
match self.mode {
MarkdownMode::Source | MarkdownMode::Split => editor.top_line().unwrap_or(0),
MarkdownMode::Visual | MarkdownMode::Preview => {
let ix = self.list.logical_scroll_top().item_ix;
self.parsed.blocks.get(ix).map_or(0, |block| {
editor
.buffer
.line_of(block.range.start.min(editor.text().len()))
})
}
}
}
/// Brings line `line` into view and focus, caret at start.
pub fn reveal(&mut self, line: usize, window: &mut Window, cx: &mut Context<Self>) {
self.commit(cx);
let lines = self.editor.read(cx).buffer.lines();
let at = self
.editor
.read(cx)
.buffer
.line_range(line.min(lines - 1))
.start;
self.rest = at;
match self.mode {
MarkdownMode::Source | MarkdownMode::Split => self
.editor
.update(cx, |editor, cx| editor.select(std::iter::once(at..at), cx)),
MarkdownMode::Visual | MarkdownMode::Preview => {
let blocks = &self.parsed.blocks;
let ix = blocks
.partition_point(|block| block.range.start <= at)
.saturating_sub(1);
self.list.scroll_to_reveal_item(ix);
}
}
let focus = self.focus_handle(cx);
window.focus(&focus, cx);
cx.notify();
}
fn changed(&mut self, cx: &mut Context<Self>) {
self.edits += 1;
cx.emit(DocumentEvent::Changed);
self.reparse(cx);
}
/// Reads the text again, at once when short.
fn reparse(&mut self, cx: &mut Context<Self>) {
let (text, edits) = (self.editor.read(cx).text().to_string(), self.edits);
if text.len() <= AT_ONCE {
self._parsing = None;
return self.adopt(parsed(&text), edits, cx);
}
self._parsing = Some(cx.spawn(async move |document, cx| {
let read = cx.background_spawn(async move { parsed(&text) }).await;
document
.update(cx, |document, cx| document.adopt(read, edits, cx))
.ok();
}));
}
/// Takes the read of edit `edits`; older reads drop.
fn adopt(&mut self, read: Parsed, edits: u64, cx: &mut Context<Self>) {
if edits != self.edits {
return log::debug!("markdown document: read {edits} lapsed");
}
let (head, tail) = alike(&self.parsed, &read);
let old = self.parsed.blocks.len();
self.list
.splice(head..old - tail, read.blocks.len() - head - tail);
self.parsed = Arc::new(read);
self.read = edits;
cx.emit(DocumentEvent::Parsed);
cx.notify();
}
/// Closes the open block without writing it.
fn drop_open(&mut self, cx: &mut Context<Self>) {
if self.open.take().is_some() {
log::info!("markdown document: the open block closed unwritten");
cx.notify();
}
}
}