1use std::sync::Arc;
2use std::time::Duration;
3use std::{ops::Range, path::PathBuf};
4
5use anyhow::Result;
6use editor::scroll::{Autoscroll, AutoscrollStrategy};
7use editor::{Editor, EditorEvent};
8use gpui::{
9 list, AnyElement, AppContext, ClickEvent, EventEmitter, FocusHandle, FocusableView,
10 InteractiveElement, IntoElement, ListState, ParentElement, Render, Styled, Subscription, Task,
11 View, ViewContext, WeakView,
12};
13use language::LanguageRegistry;
14use ui::prelude::*;
15use workspace::item::{Item, ItemHandle, TabContentParams};
16use workspace::{Pane, Workspace};
17
18use crate::OpenPreviewToTheSide;
19use crate::{
20 markdown_elements::ParsedMarkdown,
21 markdown_parser::parse_markdown,
22 markdown_renderer::{render_markdown_block, RenderContext},
23 OpenPreview,
24};
25
26const REPARSE_DEBOUNCE: Duration = Duration::from_millis(200);
27
28pub struct MarkdownPreviewView {
29 workspace: WeakView<Workspace>,
30 active_editor: Option<EditorState>,
31 focus_handle: FocusHandle,
32 contents: Option<ParsedMarkdown>,
33 selected_block: usize,
34 list_state: ListState,
35 tab_description: Option<String>,
36 fallback_tab_description: SharedString,
37 language_registry: Arc<LanguageRegistry>,
38 parsing_markdown_task: Option<Task<Result<()>>>,
39}
40
41#[derive(Clone, Copy, Debug, PartialEq)]
42pub enum MarkdownPreviewMode {
43 /// The preview will always show the contents of the provided editor.
44 Default,
45 /// The preview will "follow" the currently active editor.
46 Follow,
47}
48
49struct EditorState {
50 editor: View<Editor>,
51 _subscription: Subscription,
52}
53
54impl MarkdownPreviewView {
55 pub fn register(workspace: &mut Workspace, _cx: &mut ViewContext<Workspace>) {
56 workspace.register_action(move |workspace, _: &OpenPreview, cx| {
57 if let Some(editor) = Self::resolve_active_item_as_markdown_editor(workspace, cx) {
58 let view = Self::create_markdown_view(workspace, editor, cx);
59 workspace.active_pane().update(cx, |pane, cx| {
60 if let Some(existing_view_idx) = Self::find_existing_preview_item_idx(pane) {
61 pane.activate_item(existing_view_idx, true, true, cx);
62 } else {
63 pane.add_item(Box::new(view.clone()), true, true, None, cx)
64 }
65 });
66 cx.notify();
67 }
68 });
69
70 workspace.register_action(move |workspace, _: &OpenPreviewToTheSide, cx| {
71 if let Some(editor) = Self::resolve_active_item_as_markdown_editor(workspace, cx) {
72 let view = Self::create_markdown_view(workspace, editor.clone(), cx);
73 let pane = workspace
74 .find_pane_in_direction(workspace::SplitDirection::Right, cx)
75 .unwrap_or_else(|| {
76 workspace.split_pane(
77 workspace.active_pane().clone(),
78 workspace::SplitDirection::Right,
79 cx,
80 )
81 });
82 pane.update(cx, |pane, cx| {
83 if let Some(existing_view_idx) = Self::find_existing_preview_item_idx(pane) {
84 pane.activate_item(existing_view_idx, true, true, cx);
85 } else {
86 pane.add_item(Box::new(view.clone()), false, false, None, cx)
87 }
88 });
89 editor.focus_handle(cx).focus(cx);
90 cx.notify();
91 }
92 });
93 }
94
95 fn find_existing_preview_item_idx(pane: &Pane) -> Option<usize> {
96 pane.items_of_type::<MarkdownPreviewView>()
97 .nth(0)
98 .and_then(|view| pane.index_for_item(&view))
99 }
100
101 fn resolve_active_item_as_markdown_editor(
102 workspace: &Workspace,
103 cx: &mut ViewContext<Workspace>,
104 ) -> Option<View<Editor>> {
105 if let Some(editor) = workspace
106 .active_item(cx)
107 .and_then(|item| item.act_as::<Editor>(cx))
108 {
109 if Self::is_markdown_file(&editor, cx) {
110 return Some(editor);
111 }
112 }
113 None
114 }
115
116 fn create_markdown_view(
117 workspace: &mut Workspace,
118 editor: View<Editor>,
119 cx: &mut ViewContext<Workspace>,
120 ) -> View<MarkdownPreviewView> {
121 let language_registry = workspace.project().read(cx).languages().clone();
122 let workspace_handle = workspace.weak_handle();
123 MarkdownPreviewView::new(
124 MarkdownPreviewMode::Follow,
125 editor,
126 workspace_handle,
127 language_registry,
128 None,
129 cx,
130 )
131 }
132
133 pub fn new(
134 mode: MarkdownPreviewMode,
135 active_editor: View<Editor>,
136 workspace: WeakView<Workspace>,
137 language_registry: Arc<LanguageRegistry>,
138 fallback_description: Option<SharedString>,
139 cx: &mut ViewContext<Workspace>,
140 ) -> View<Self> {
141 cx.new_view(|cx: &mut ViewContext<Self>| {
142 let view = cx.view().downgrade();
143
144 let list_state =
145 ListState::new(0, gpui::ListAlignment::Top, px(1000.), move |ix, cx| {
146 if let Some(view) = view.upgrade() {
147 view.update(cx, |this, cx| {
148 let Some(contents) = &this.contents else {
149 return div().into_any();
150 };
151
152 let mut render_cx =
153 RenderContext::new(Some(this.workspace.clone()), cx)
154 .with_checkbox_clicked_callback({
155 let view = view.clone();
156 move |checked, source_range, cx| {
157 view.update(cx, |view, cx| {
158 if let Some(editor) = view
159 .active_editor
160 .as_ref()
161 .map(|s| s.editor.clone())
162 {
163 editor.update(cx, |editor, cx| {
164 let task_marker =
165 if checked { "[x]" } else { "[ ]" };
166
167 editor.edit(
168 vec![(source_range, task_marker)],
169 cx,
170 );
171 });
172 view.parse_markdown_from_active_editor(
173 false, cx,
174 );
175 cx.notify();
176 }
177 })
178 }
179 });
180 let block = contents.children.get(ix).unwrap();
181 let rendered_block = render_markdown_block(block, &mut render_cx);
182
183 div()
184 .id(ix)
185 .pb_3()
186 .group("markdown-block")
187 .on_click(cx.listener(move |this, event: &ClickEvent, cx| {
188 if event.down.click_count == 2 {
189 if let Some(block) =
190 this.contents.as_ref().and_then(|c| c.children.get(ix))
191 {
192 let start = block.source_range().start;
193 this.move_cursor_to_block(cx, start..start);
194 }
195 }
196 }))
197 .map(move |container| {
198 let indicator = div()
199 .h_full()
200 .w(px(4.0))
201 .when(ix == this.selected_block, |this| {
202 this.bg(cx.theme().colors().border)
203 })
204 .group_hover("markdown-block", |s| {
205 if ix == this.selected_block {
206 s
207 } else {
208 s.bg(cx.theme().colors().border_variant)
209 }
210 })
211 .rounded_sm();
212
213 container.child(
214 div()
215 .relative()
216 .child(div().pl_4().child(rendered_block))
217 .child(indicator.absolute().left_0().top_0()),
218 )
219 })
220 .into_any()
221 })
222 } else {
223 div().into_any()
224 }
225 });
226
227 let mut this = Self {
228 selected_block: 0,
229 active_editor: None,
230 focus_handle: cx.focus_handle(),
231 workspace: workspace.clone(),
232 contents: None,
233 list_state,
234 tab_description: None,
235 language_registry,
236 fallback_tab_description: fallback_description
237 .unwrap_or_else(|| "Markdown Preview".into()),
238 parsing_markdown_task: None,
239 };
240
241 this.set_editor(active_editor, cx);
242
243 if mode == MarkdownPreviewMode::Follow {
244 if let Some(workspace) = &workspace.upgrade() {
245 cx.observe(workspace, |this, workspace, cx| {
246 let item = workspace.read(cx).active_item(cx);
247 this.workspace_updated(item, cx);
248 })
249 .detach();
250 } else {
251 log::error!("Failed to listen to workspace updates");
252 }
253 }
254
255 this
256 })
257 }
258
259 fn workspace_updated(
260 &mut self,
261 active_item: Option<Box<dyn ItemHandle>>,
262 cx: &mut ViewContext<Self>,
263 ) {
264 if let Some(item) = active_item {
265 if item.item_id() != cx.entity_id() {
266 if let Some(editor) = item.act_as::<Editor>(cx) {
267 if Self::is_markdown_file(&editor, cx) {
268 self.set_editor(editor, cx);
269 }
270 }
271 }
272 }
273 }
274
275 fn is_markdown_file<V>(editor: &View<Editor>, cx: &mut ViewContext<V>) -> bool {
276 let language = editor.read(cx).buffer().read(cx).language_at(0, cx);
277 language
278 .map(|l| l.name().as_ref() == "Markdown")
279 .unwrap_or(false)
280 }
281
282 fn set_editor(&mut self, editor: View<Editor>, cx: &mut ViewContext<Self>) {
283 if let Some(active) = &self.active_editor {
284 if active.editor == editor {
285 return;
286 }
287 }
288
289 let subscription = cx.subscribe(&editor, |this, editor, event: &EditorEvent, cx| {
290 match event {
291 EditorEvent::Edited => {
292 this.parse_markdown_from_active_editor(true, cx);
293 }
294 EditorEvent::SelectionsChanged { .. } => {
295 let editor = editor.read(cx);
296 let selection_range = editor.selections.last::<usize>(cx).range();
297 this.selected_block = this.get_block_index_under_cursor(selection_range);
298 this.list_state.scroll_to_reveal_item(this.selected_block);
299 cx.notify();
300 }
301 _ => {}
302 };
303 });
304
305 self.tab_description = editor
306 .read(cx)
307 .tab_description(0, cx)
308 .map(|tab_description| format!("Preview {}", tab_description));
309
310 self.active_editor = Some(EditorState {
311 editor,
312 _subscription: subscription,
313 });
314
315 self.parse_markdown_from_active_editor(false, cx);
316 }
317
318 fn parse_markdown_from_active_editor(
319 &mut self,
320 wait_for_debounce: bool,
321 cx: &mut ViewContext<Self>,
322 ) {
323 if let Some(state) = &self.active_editor {
324 self.parsing_markdown_task = Some(self.parse_markdown_in_background(
325 wait_for_debounce,
326 state.editor.clone(),
327 cx,
328 ));
329 }
330 }
331
332 fn parse_markdown_in_background(
333 &mut self,
334 wait_for_debounce: bool,
335 editor: View<Editor>,
336 cx: &mut ViewContext<Self>,
337 ) -> Task<Result<()>> {
338 let language_registry = self.language_registry.clone();
339
340 cx.spawn(move |view, mut cx| async move {
341 if wait_for_debounce {
342 // Wait for the user to stop typing
343 cx.background_executor().timer(REPARSE_DEBOUNCE).await;
344 }
345
346 let (contents, file_location) = view.update(&mut cx, |_, cx| {
347 let editor = editor.read(cx);
348 let contents = editor.buffer().read(cx).snapshot(cx).text();
349 let file_location = MarkdownPreviewView::get_folder_for_active_editor(editor, cx);
350 (contents, file_location)
351 })?;
352
353 let parsing_task = cx.background_executor().spawn(async move {
354 parse_markdown(&contents, file_location, Some(language_registry)).await
355 });
356 let contents = parsing_task.await;
357 view.update(&mut cx, move |view, cx| {
358 let markdown_blocks_count = contents.children.len();
359 view.contents = Some(contents);
360 let scroll_top = view.list_state.logical_scroll_top();
361 view.list_state.reset(markdown_blocks_count);
362 view.list_state.scroll_to(scroll_top);
363 cx.notify();
364 })
365 })
366 }
367
368 fn move_cursor_to_block(&self, cx: &mut ViewContext<Self>, selection: Range<usize>) {
369 if let Some(state) = &self.active_editor {
370 state.editor.update(cx, |editor, cx| {
371 editor.change_selections(
372 Some(Autoscroll::Strategy(AutoscrollStrategy::Center)),
373 cx,
374 |selections| selections.select_ranges(vec![selection]),
375 );
376 editor.focus(cx);
377 });
378 }
379 }
380
381 /// The absolute path of the file that is currently being previewed.
382 fn get_folder_for_active_editor(
383 editor: &Editor,
384 cx: &ViewContext<MarkdownPreviewView>,
385 ) -> Option<PathBuf> {
386 if let Some(file) = editor.file_at(0, cx) {
387 if let Some(file) = file.as_local() {
388 file.abs_path(cx).parent().map(|p| p.to_path_buf())
389 } else {
390 None
391 }
392 } else {
393 None
394 }
395 }
396
397 fn get_block_index_under_cursor(&self, selection_range: Range<usize>) -> usize {
398 let mut block_index = None;
399 let cursor = selection_range.start;
400
401 let mut last_end = 0;
402 if let Some(content) = &self.contents {
403 for (i, block) in content.children.iter().enumerate() {
404 let Range { start, end } = block.source_range();
405
406 // Check if the cursor is between the last block and the current block
407 if last_end > cursor && cursor < start {
408 block_index = Some(i.saturating_sub(1));
409 break;
410 }
411
412 if start <= cursor && end >= cursor {
413 block_index = Some(i);
414 break;
415 }
416 last_end = end;
417 }
418
419 if block_index.is_none() && last_end < cursor {
420 block_index = Some(content.children.len().saturating_sub(1));
421 }
422 }
423
424 block_index.unwrap_or_default()
425 }
426}
427
428impl FocusableView for MarkdownPreviewView {
429 fn focus_handle(&self, _: &AppContext) -> gpui::FocusHandle {
430 self.focus_handle.clone()
431 }
432}
433
434#[derive(Clone, Debug, PartialEq, Eq)]
435pub enum PreviewEvent {}
436
437impl EventEmitter<PreviewEvent> for MarkdownPreviewView {}
438
439impl Item for MarkdownPreviewView {
440 type Event = PreviewEvent;
441
442 fn tab_content(&self, params: TabContentParams, _cx: &WindowContext) -> AnyElement {
443 h_flex()
444 .gap_2()
445 .child(Icon::new(IconName::FileDoc).color(if params.selected {
446 Color::Default
447 } else {
448 Color::Muted
449 }))
450 .child(
451 Label::new(if let Some(description) = &self.tab_description {
452 description.clone().into()
453 } else {
454 self.fallback_tab_description.clone()
455 })
456 .color(if params.selected {
457 Color::Default
458 } else {
459 Color::Muted
460 }),
461 )
462 .into_any()
463 }
464
465 fn telemetry_event_text(&self) -> Option<&'static str> {
466 Some("markdown preview")
467 }
468
469 fn to_item_events(_event: &Self::Event, _f: impl FnMut(workspace::item::ItemEvent)) {}
470}
471
472impl Render for MarkdownPreviewView {
473 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
474 v_flex()
475 .id("MarkdownPreview")
476 .key_context("MarkdownPreview")
477 .track_focus(&self.focus_handle)
478 .size_full()
479 .bg(cx.theme().colors().editor_background)
480 .p_4()
481 .child(
482 div()
483 .flex_grow()
484 .map(|this| this.child(list(self.list_state.clone()).size_full())),
485 )
486 }
487}