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};
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, |view, cx| {
148 let Some(contents) = &view.contents else {
149 return div().into_any();
150 };
151 let mut render_cx =
152 RenderContext::new(Some(view.workspace.clone()), cx);
153 let block = contents.children.get(ix).unwrap();
154 let rendered_block = render_markdown_block(block, &mut render_cx);
155
156 div()
157 .id(ix)
158 .pb_3()
159 .group("markdown-block")
160 .on_click(cx.listener(move |this, event: &ClickEvent, cx| {
161 if event.down.click_count == 2 {
162 if let Some(block) =
163 this.contents.as_ref().and_then(|c| c.children.get(ix))
164 {
165 let start = block.source_range().start;
166 this.move_cursor_to_block(cx, start..start);
167 }
168 }
169 }))
170 .map(move |this| {
171 let indicator = div()
172 .h_full()
173 .w(px(4.0))
174 .when(ix == view.selected_block, |this| {
175 this.bg(cx.theme().colors().border)
176 })
177 .group_hover("markdown-block", |s| {
178 if ix != view.selected_block {
179 s.bg(cx.theme().colors().border_variant)
180 } else {
181 s
182 }
183 })
184 .rounded_sm();
185
186 this.child(
187 div()
188 .relative()
189 .child(div().pl_4().child(rendered_block))
190 .child(indicator.absolute().left_0().top_0()),
191 )
192 })
193 .into_any()
194 })
195 } else {
196 div().into_any()
197 }
198 });
199
200 let mut this = Self {
201 selected_block: 0,
202 active_editor: None,
203 focus_handle: cx.focus_handle(),
204 workspace: workspace.clone(),
205 contents: None,
206 list_state,
207 tab_description: None,
208 language_registry,
209 fallback_tab_description: fallback_description
210 .unwrap_or_else(|| "Markdown Preview".into()),
211 parsing_markdown_task: None,
212 };
213
214 this.set_editor(active_editor, cx);
215
216 if mode == MarkdownPreviewMode::Follow {
217 if let Some(workspace) = &workspace.upgrade() {
218 cx.observe(workspace, |this, workspace, cx| {
219 let item = workspace.read(cx).active_item(cx);
220 this.workspace_updated(item, cx);
221 })
222 .detach();
223 } else {
224 log::error!("Failed to listen to workspace updates");
225 }
226 }
227
228 this
229 })
230 }
231
232 fn workspace_updated(
233 &mut self,
234 active_item: Option<Box<dyn ItemHandle>>,
235 cx: &mut ViewContext<Self>,
236 ) {
237 if let Some(item) = active_item {
238 if item.item_id() != cx.entity_id() {
239 if let Some(editor) = item.act_as::<Editor>(cx) {
240 if Self::is_markdown_file(&editor, cx) {
241 self.set_editor(editor, cx);
242 }
243 }
244 }
245 }
246 }
247
248 fn is_markdown_file<V>(editor: &View<Editor>, cx: &mut ViewContext<V>) -> bool {
249 let language = editor.read(cx).buffer().read(cx).language_at(0, cx);
250 language
251 .map(|l| l.name().as_ref() == "Markdown")
252 .unwrap_or(false)
253 }
254
255 fn set_editor(&mut self, editor: View<Editor>, cx: &mut ViewContext<Self>) {
256 if let Some(active) = &self.active_editor {
257 if active.editor == editor {
258 return;
259 }
260 }
261
262 let subscription = cx.subscribe(&editor, |this, editor, event: &EditorEvent, cx| {
263 match event {
264 EditorEvent::Edited => {
265 this.on_editor_edited(cx);
266 }
267 EditorEvent::SelectionsChanged { .. } => {
268 let editor = editor.read(cx);
269 let selection_range = editor.selections.last::<usize>(cx).range();
270 this.selected_block = this.get_block_index_under_cursor(selection_range);
271 this.list_state.scroll_to_reveal_item(this.selected_block);
272 cx.notify();
273 }
274 _ => {}
275 };
276 });
277
278 self.tab_description = editor
279 .read(cx)
280 .tab_description(0, cx)
281 .map(|tab_description| format!("Preview {}", tab_description));
282
283 self.active_editor = Some(EditorState {
284 editor,
285 _subscription: subscription,
286 });
287
288 if let Some(state) = &self.active_editor {
289 self.parsing_markdown_task =
290 Some(self.parse_markdown_in_background(false, state.editor.clone(), cx));
291 }
292 }
293
294 fn on_editor_edited(&mut self, cx: &mut ViewContext<Self>) {
295 if let Some(state) = &self.active_editor {
296 self.parsing_markdown_task =
297 Some(self.parse_markdown_in_background(true, state.editor.clone(), cx));
298 }
299 }
300
301 fn parse_markdown_in_background(
302 &mut self,
303 wait_for_debounce: bool,
304 editor: View<Editor>,
305 cx: &mut ViewContext<Self>,
306 ) -> Task<Result<()>> {
307 let language_registry = self.language_registry.clone();
308
309 cx.spawn(move |view, mut cx| async move {
310 if wait_for_debounce {
311 // Wait for the user to stop typing
312 cx.background_executor().timer(REPARSE_DEBOUNCE).await;
313 }
314
315 let (contents, file_location) = view.update(&mut cx, |_, cx| {
316 let editor = editor.read(cx);
317 let contents = editor.buffer().read(cx).snapshot(cx).text();
318 let file_location = MarkdownPreviewView::get_folder_for_active_editor(editor, cx);
319 (contents, file_location)
320 })?;
321
322 let parsing_task = cx.background_executor().spawn(async move {
323 parse_markdown(&contents, file_location, Some(language_registry)).await
324 });
325 let contents = parsing_task.await;
326 view.update(&mut cx, move |view, cx| {
327 let markdown_blocks_count = contents.children.len();
328 view.contents = Some(contents);
329 let scroll_top = view.list_state.logical_scroll_top();
330 view.list_state.reset(markdown_blocks_count);
331 view.list_state.scroll_to(scroll_top);
332 cx.notify();
333 })
334 })
335 }
336
337 fn move_cursor_to_block(&self, cx: &mut ViewContext<Self>, selection: Range<usize>) {
338 if let Some(state) = &self.active_editor {
339 state.editor.update(cx, |editor, cx| {
340 editor.change_selections(
341 Some(Autoscroll::Strategy(AutoscrollStrategy::Center)),
342 cx,
343 |selections| selections.select_ranges(vec![selection]),
344 );
345 editor.focus(cx);
346 });
347 }
348 }
349
350 /// The absolute path of the file that is currently being previewed.
351 fn get_folder_for_active_editor(
352 editor: &Editor,
353 cx: &ViewContext<MarkdownPreviewView>,
354 ) -> Option<PathBuf> {
355 if let Some(file) = editor.file_at(0, cx) {
356 if let Some(file) = file.as_local() {
357 file.abs_path(cx).parent().map(|p| p.to_path_buf())
358 } else {
359 None
360 }
361 } else {
362 None
363 }
364 }
365
366 fn get_block_index_under_cursor(&self, selection_range: Range<usize>) -> usize {
367 let mut block_index = None;
368 let cursor = selection_range.start;
369
370 let mut last_end = 0;
371 if let Some(content) = &self.contents {
372 for (i, block) in content.children.iter().enumerate() {
373 let Range { start, end } = block.source_range();
374
375 // Check if the cursor is between the last block and the current block
376 if last_end > cursor && cursor < start {
377 block_index = Some(i.saturating_sub(1));
378 break;
379 }
380
381 if start <= cursor && end >= cursor {
382 block_index = Some(i);
383 break;
384 }
385 last_end = end;
386 }
387
388 if block_index.is_none() && last_end < cursor {
389 block_index = Some(content.children.len().saturating_sub(1));
390 }
391 }
392
393 block_index.unwrap_or_default()
394 }
395}
396
397impl FocusableView for MarkdownPreviewView {
398 fn focus_handle(&self, _: &AppContext) -> gpui::FocusHandle {
399 self.focus_handle.clone()
400 }
401}
402
403#[derive(Clone, Debug, PartialEq, Eq)]
404pub enum PreviewEvent {}
405
406impl EventEmitter<PreviewEvent> for MarkdownPreviewView {}
407
408impl Item for MarkdownPreviewView {
409 type Event = PreviewEvent;
410
411 fn tab_content(
412 &self,
413 _detail: Option<usize>,
414 selected: bool,
415 _cx: &WindowContext,
416 ) -> AnyElement {
417 h_flex()
418 .gap_2()
419 .child(Icon::new(IconName::FileDoc).color(if selected {
420 Color::Default
421 } else {
422 Color::Muted
423 }))
424 .child(
425 Label::new(if let Some(description) = &self.tab_description {
426 description.clone().into()
427 } else {
428 self.fallback_tab_description.clone()
429 })
430 .color(if selected {
431 Color::Default
432 } else {
433 Color::Muted
434 }),
435 )
436 .into_any()
437 }
438
439 fn telemetry_event_text(&self) -> Option<&'static str> {
440 Some("markdown preview")
441 }
442
443 fn to_item_events(_event: &Self::Event, _f: impl FnMut(workspace::item::ItemEvent)) {}
444}
445
446impl Render for MarkdownPreviewView {
447 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
448 v_flex()
449 .id("MarkdownPreview")
450 .key_context("MarkdownPreview")
451 .track_focus(&self.focus_handle)
452 .size_full()
453 .bg(cx.theme().colors().editor_background)
454 .p_4()
455 .child(
456 div()
457 .flex_grow()
458 .map(|this| this.child(list(self.list_state.clone()).size_full())),
459 )
460 }
461}