From 525c36c9f6e9358df3f08f11246ea4bfbd38242b Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Wed, 8 Jul 2026 21:24:21 +0300 Subject: [PATCH] Add auto_preview setting to open previewable files in their preview --- Cargo.lock | 11 + assets/keymaps/default-linux.json | 25 +- assets/keymaps/default-macos.json | 25 +- assets/keymaps/default-windows.json | 27 +- assets/settings/default.json | 12 + crates/csv_preview/Cargo.toml | 10 +- crates/csv_preview/src/csv_preview.rs | 349 ++++- .../csv_preview/src/renderer/preview_view.rs | 1 + .../markdown_preview/src/markdown_preview.rs | 2 + .../src/markdown_preview_view.rs | 1164 +++++++++++++++-- crates/settings/src/vscode_import.rs | 9 + crates/settings_content/src/workspace.rs | 32 + crates/settings_ui/src/page_data.rs | 16 +- crates/settings_ui/src/settings_ui.rs | 1 + crates/svg_preview/Cargo.toml | 15 +- crates/svg_preview/src/svg_preview.rs | 5 + crates/svg_preview/src/svg_preview_view.rs | 713 +++++++++- crates/workspace/src/auto_preview.rs | 356 +++++ crates/workspace/src/pane.rs | 11 +- crates/workspace/src/workspace.rs | 22 +- crates/workspace/src/workspace_settings.rs | 4 +- crates/zed/src/zed/quick_action_bar.rs | 19 +- .../zed/src/zed/quick_action_bar/preview.rs | 80 +- crates/zed_actions/src/lib.rs | 12 + docs/src/migrate/vs-code.md | 18 +- docs/src/reference/all-settings.md | 34 + 26 files changed, 2748 insertions(+), 225 deletions(-) create mode 100644 crates/workspace/src/auto_preview.rs diff --git a/Cargo.lock b/Cargo.lock index 3f71a7000d7..a01714dde20 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4575,10 +4575,14 @@ dependencies = [ "editor", "feature_flags", "gpui", + "language", "log", + "project", + "settings", "text", "ui", "workspace", + "zed_actions", ] [[package]] @@ -17806,11 +17810,18 @@ dependencies = [ name = "svg_preview" version = "0.1.0" dependencies = [ + "anyhow", + "editor", "file_icons", "gpui", "language", + "markdown_preview", "multi_buffer", + "project", + "serde_json", + "settings", "ui", + "util", "workspace", "zed_actions", ] diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 8799e032bfa..39acd479537 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -614,7 +614,7 @@ "use_key_equivalents": true, "bindings": { "ctrl-k v": "markdown::OpenPreviewToTheSide", - "ctrl-shift-v": "markdown::OpenPreview", + "ctrl-shift-v": "preview::Toggle", }, }, { @@ -622,7 +622,15 @@ "use_key_equivalents": true, "bindings": { "ctrl-k v": "svg::OpenPreviewToTheSide", - "ctrl-shift-v": "svg::OpenPreview", + "ctrl-shift-v": "preview::Toggle", + }, + }, + { + "context": "Editor && extension == csv", + "use_key_equivalents": true, + "bindings": { + "ctrl-k v": "csv::OpenPreviewToTheSide", + "ctrl-shift-v": "preview::Toggle", }, }, { @@ -1320,6 +1328,7 @@ { "context": "MarkdownPreview", "bindings": { + "ctrl-shift-v": "preview::Toggle", "pageup": "markdown::ScrollPageUp", "pagedown": "markdown::ScrollPageDown", "up": "markdown::ScrollUp", @@ -1332,6 +1341,18 @@ "ctrl-f": "buffer_search::Deploy", }, }, + { + "context": "SvgPreview", + "bindings": { + "ctrl-shift-v": "preview::Toggle", + }, + }, + { + "context": "CsvPreview", + "bindings": { + "ctrl-shift-v": "preview::Toggle", + }, + }, { "context": "KeymapEditor", "use_key_equivalents": true, diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index de0f8bd2b46..0e6667b7068 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -656,7 +656,7 @@ "use_key_equivalents": true, "bindings": { "cmd-k v": "markdown::OpenPreviewToTheSide", - "cmd-shift-v": "markdown::OpenPreview", + "cmd-shift-v": "preview::Toggle", }, }, { @@ -664,7 +664,15 @@ "use_key_equivalents": true, "bindings": { "cmd-k v": "svg::OpenPreviewToTheSide", - "cmd-shift-v": "svg::OpenPreview", + "cmd-shift-v": "preview::Toggle", + }, + }, + { + "context": "Editor && extension == csv", + "use_key_equivalents": true, + "bindings": { + "cmd-k v": "csv::OpenPreviewToTheSide", + "cmd-shift-v": "preview::Toggle", }, }, { @@ -1413,6 +1421,7 @@ { "context": "MarkdownPreview", "bindings": { + "cmd-shift-v": "preview::Toggle", "pageup": "markdown::ScrollPageUp", "pagedown": "markdown::ScrollPageDown", "up": "markdown::ScrollUp", @@ -1424,6 +1433,18 @@ "cmd-f": "buffer_search::Deploy", }, }, + { + "context": "SvgPreview", + "bindings": { + "cmd-shift-v": "preview::Toggle", + }, + }, + { + "context": "CsvPreview", + "bindings": { + "cmd-shift-v": "preview::Toggle", + }, + }, { "context": "KeymapEditor", "use_key_equivalents": true, diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index acc2a82e18e..301dd073e3d 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -610,7 +610,7 @@ "use_key_equivalents": true, "bindings": { "ctrl-k v": "markdown::OpenPreviewToTheSide", - "ctrl-shift-v": "markdown::OpenPreview", + "ctrl-shift-v": "preview::Toggle", }, }, { @@ -618,7 +618,15 @@ "use_key_equivalents": true, "bindings": { "ctrl-k v": "svg::OpenPreviewToTheSide", - "ctrl-shift-v": "svg::OpenPreview", + "ctrl-shift-v": "preview::Toggle", + }, + }, + { + "context": "Editor && extension == csv", + "use_key_equivalents": true, + "bindings": { + "ctrl-k v": "csv::OpenPreviewToTheSide", + "ctrl-shift-v": "preview::Toggle", }, }, { @@ -1344,6 +1352,7 @@ "context": "MarkdownPreview", "use_key_equivalents": true, "bindings": { + "ctrl-shift-v": "preview::Toggle", "pageup": "markdown::ScrollPageUp", "pagedown": "markdown::ScrollPageDown", "up": "markdown::ScrollUp", @@ -1356,6 +1365,20 @@ "ctrl-f": "buffer_search::Deploy", }, }, + { + "context": "SvgPreview", + "use_key_equivalents": true, + "bindings": { + "ctrl-shift-v": "preview::Toggle", + }, + }, + { + "context": "CsvPreview", + "use_key_equivalents": true, + "bindings": { + "ctrl-shift-v": "preview::Toggle", + }, + }, { "context": "KeymapEditor", "use_key_equivalents": true, diff --git a/assets/settings/default.json b/assets/settings/default.json index 0e42d27e980..ef9a69477fd 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -182,6 +182,18 @@ // // Default: true "restore_on_file_reopen": true, + // How to open files that have a preview available for their file type + // (e.g. Markdown or SVG files). + // May take 3 values: + // 1. Open previewable files in a text editor, previews have to be opened manually. + // "auto_preview": "off" + // 2. Open previewable files in their preview instead of a text editor. + // The text editor can be opened from the preview with the `preview::OpenSource` action. + // "auto_preview": "in_place" + // 3. Open previewable files in a text editor and keep a preview following the + // active editor in a pane to the side. + // "auto_preview": "to_the_side" + "auto_preview": "off", // Whether to automatically close files that have been deleted on disk. "close_on_file_delete": false, // Whether toggling a panel (e.g. with its keyboard shortcut) also closes diff --git a/crates/csv_preview/Cargo.toml b/crates/csv_preview/Cargo.toml index ff4c0a61240..39a8d079aab 100644 --- a/crates/csv_preview/Cargo.toml +++ b/crates/csv_preview/Cargo.toml @@ -9,13 +9,17 @@ path = "src/csv_preview.rs" [dependencies] anyhow.workspace = true +editor.workspace = true feature_flags.workspace = true gpui.workspace = true -editor.workspace = true +language.workspace = true +log.workspace = true +project.workspace = true +settings.workspace = true +text.workspace = true ui.workspace = true workspace.workspace = true -log.workspace = true -text.workspace = true +zed_actions.workspace = true [features] dev-tools = [] diff --git a/crates/csv_preview/src/csv_preview.rs b/crates/csv_preview/src/csv_preview.rs index 8f8ba768754..d907eab33a5 100644 --- a/crates/csv_preview/src/csv_preview.rs +++ b/crates/csv_preview/src/csv_preview.rs @@ -1,8 +1,11 @@ +use ::settings::Settings as _; use editor::{Editor, EditorEvent}; use feature_flags::{FeatureFlag, FeatureFlagAppExt as _, PresenceFlag, register_feature_flag}; use gpui::{ AppContext, Entity, EventEmitter, FocusHandle, Focusable, ListAlignment, Task, actions, }; +use language::Buffer; +use project::{Project, ProjectPath}; use std::{ collections::HashMap, time::{Duration, Instant}, @@ -13,7 +16,9 @@ use ui::{ AbsoluteLength, ResizableColumnsState, SharedString, TableInteractionState, TableResizeBehavior, prelude::*, }; -use workspace::{Item, SplitDirection, Workspace}; +use workspace::item::{ItemBufferKind, ProjectItem}; +use workspace::{AutoPreview, Item, Pane, SplitDirection, Workspace, WorkspaceSettings}; +use zed_actions::preview::{OpenSource, Toggle}; use crate::{parser::EditorState, settings::CsvPreviewSettings, types::TableLikeContent}; @@ -34,6 +39,7 @@ impl FeatureFlag for TabularDataPreviewFeatureFlag { register_feature_flag!(TabularDataPreviewFeatureFlag); pub struct CsvPreviewView { + _workspace_subscription: Option, pub(crate) engine: TableDataEngine, pub(crate) focus_handle: FocusHandle, @@ -54,6 +60,8 @@ pub struct CsvPreviewView { } pub fn init(cx: &mut App) { + workspace::register_project_item::(cx); + workspace::register_auto_preview_provider(CsvPreviewView::auto_preview_provider(), cx); cx.observe_new(|workspace: &mut Workspace, _, _| { CsvPreviewView::register(workspace); }) @@ -85,31 +93,65 @@ impl CsvPreviewView { }); } + /// Opens (or reveals) a preview for the active CSV editor. + /// Returns false when the active item is not a CSV editor. + fn open_preview_for_active_editor( + workspace: &mut Workspace, + window: &mut Window, + cx: &mut Context, + ) -> bool { + let Some(editor) = workspace + .active_item(cx) + .and_then(|item| item.act_as::(cx)) + .filter(|editor| Self::is_csv_file(editor, cx)) + else { + return false; + }; + let csv_preview = Self::new(&editor, cx); + workspace.active_pane().update(cx, |pane, cx| { + let existing = pane + .items_of_type::() + .find(|view| view.read(cx).active_editor_state.editor == editor); + if let Some(idx) = existing.and_then(|e| pane.index_for_item(&e)) { + pane.activate_item(idx, true, true, window, cx); + } else { + pane.add_item(Box::new(csv_preview), true, true, None, window, cx); + } + }); + cx.notify(); + true + } + + /// Activates (or opens) a text editor for the active CSV preview. + /// Returns false when the active item is not a CSV preview. + fn open_source_for_active_preview( + workspace: &mut Workspace, + window: &mut Window, + cx: &mut Context, + ) -> bool { + let Some(preview) = workspace + .active_item(cx) + .and_then(|item| item.downcast::()) + else { + return false; + }; + let editor = preview.read(cx).active_editor_state.editor.clone(); + if !workspace.activate_item(&editor, true, true, window, cx) { + workspace.active_pane().update(cx, |pane, cx| { + pane.add_item(Box::new(editor.clone()), true, true, None, window, cx); + }); + } + true + } + pub fn register(workspace: &mut Workspace) { workspace.register_action_renderer(|div, _, _, cx| { div.when(cx.has_flag::(), |div| { div.on_action(cx.listener(|workspace, _: &OpenPreview, window, cx| { - if let Some(editor) = workspace - .active_item(cx) - .and_then(|item| item.act_as::(cx)) - .filter(|editor| Self::is_csv_file(editor, cx)) - { - let csv_preview = Self::new(&editor, cx); - workspace.active_pane().update(cx, |pane, cx| { - let existing = pane - .items_of_type::() - .find(|view| view.read(cx).active_editor_state.editor == editor); - if let Some(idx) = existing.and_then(|e| pane.index_for_item(&e)) { - pane.activate_item(idx, true, true, window, cx); - } else { - pane.add_item(Box::new(csv_preview), true, true, None, window, cx); - } - }); - cx.notify(); - } + Self::open_preview_for_active_editor(workspace, window, cx); })) - .on_action(cx.listener( - |workspace, _: &OpenPreviewToTheSide, window, cx| { + .on_action( + cx.listener(|workspace, _: &OpenPreviewToTheSide, window, cx| { if let Some(editor) = workspace .active_item(cx) .and_then(|item| item.act_as::(cx)) @@ -146,13 +188,126 @@ impl CsvPreviewView { }); cx.notify(); } - }, - )) + }), + ) + .on_action(cx.listener(|workspace, _: &OpenSource, window, cx| { + if !Self::open_source_for_active_preview(workspace, window, cx) { + cx.propagate(); + } + })) + .on_action(cx.listener(|workspace, _: &Toggle, window, cx| { + if !Self::open_source_for_active_preview(workspace, window, cx) + && !Self::open_preview_for_active_editor(workspace, window, cx) + { + cx.propagate(); + } + })) }) }); } fn new(editor: &Entity, cx: &mut Context) -> Entity { + cx.new(|cx| Self::build(editor.clone(), cx)) + } + + fn new_following( + editor: &Entity, + window: &Window, + cx: &mut Context, + ) -> Entity { + let workspace = cx.entity(); + cx.new(|cx| { + let mut this = Self::build(editor.clone(), cx); + this._workspace_subscription = Some(cx.subscribe_in( + &workspace, + window, + |this: &mut Self, workspace, event: &workspace::Event, _window, cx| { + if let workspace::Event::ActiveItemChanged = event + && let Some(editor) = workspace + .read(cx) + .active_item(cx) + .and_then(|item| item.downcast::()) + && Self::is_csv_file(&editor, cx) + && this.active_editor_state.editor != editor + { + this.set_editor(editor, cx); + } + }, + )); + this + }) + } + + fn is_following(&self) -> bool { + self._workspace_subscription.is_some() + } + + fn set_editor(&mut self, editor: Entity, cx: &mut Context) { + let subscription = Self::subscribe_to_editor(&editor, cx); + self.active_editor_state = EditorState { + editor, + _subscription: subscription, + }; + self.parse_csv_from_active_editor(false, cx); + cx.notify(); + } + + fn subscribe_to_editor(editor: &Entity, cx: &mut Context) -> gpui::Subscription { + cx.subscribe( + editor, + |this: &mut CsvPreviewView, _editor, event: &EditorEvent, cx| { + match event { + EditorEvent::Edited { .. } | EditorEvent::DirtyChanged => { + this.parse_csv_from_active_editor(true, cx); + } + _ => {} + }; + }, + ) + } + + pub(crate) fn auto_preview_provider() -> workspace::AutoPreviewProvider { + workspace::AutoPreviewProvider { + applies_to: |item, cx| { + cx.has_flag::() + && item + .downcast::() + .is_some_and(|editor| Self::is_csv_file(&editor, cx)) + }, + has_open_sources: |workspace, cx| { + workspace + .items_of_type::(cx) + .any(|editor| Self::is_csv_file(&editor, cx)) + }, + is_follow_view: |item, cx| { + item.downcast::() + .is_some_and(|view| view.read(cx).is_following()) + }, + is_preview_view: |item, cx| { + item.downcast::() + .is_some_and(|view| !view.read(cx).is_following()) + }, + build_follow_view: |workspace, window, cx| { + let editor = workspace + .active_item(cx) + .and_then(|item| item.downcast::()) + .filter(|editor| Self::is_csv_file(editor, cx))?; + Some(Box::new(Self::new_following(&editor, window, cx))) + }, + build_preview_view: |_, item, _, cx| { + let editor = item.downcast::()?; + Some(Box::new(Self::new(&editor, cx))) + }, + source_view: |_, item, _, cx| { + let preview = item.downcast::()?; + Some(Box::new( + preview.read(cx).active_editor_state.editor.clone(), + )) + }, + } + } + + fn build(editor: Entity, cx: &mut Context) -> Self { let contents = TableLikeContent::default(); let table_interaction_state = cx.new(|cx| { TableInteractionState::new(cx).with_custom_scrollbar(ui::Scrollbars::for_settings::< @@ -160,41 +315,30 @@ impl CsvPreviewView { >()) }); - cx.new(|cx| { - let subscription = cx.subscribe( + let subscription = Self::subscribe_to_editor(&editor, cx); + + let mut view = CsvPreviewView { + _workspace_subscription: None, + focus_handle: cx.focus_handle(), + active_editor_state: EditorState { editor, - |this: &mut CsvPreviewView, _editor, event: &EditorEvent, cx| { - match event { - EditorEvent::Edited { .. } | EditorEvent::DirtyChanged => { - this.parse_csv_from_active_editor(true, cx); - } - _ => {} - }; - }, - ); + _subscription: subscription, + }, + table_interaction_state, + column_widths: ColumnWidths::new(cx, 1), + parsing_task: None, + is_parsing: false, + filter_sort_task: None, + performance_metrics: PerformanceMetrics::default(), + list_state: gpui::ListState::new(contents.rows.len(), ListAlignment::Top, px(1.)) + .with_uniform_item_height(px(24.)), + settings: CsvPreviewSettings::default(), + last_parse_end_time: None, + engine: TableDataEngine::default(), + }; - let mut view = CsvPreviewView { - focus_handle: cx.focus_handle(), - active_editor_state: EditorState { - editor: editor.clone(), - _subscription: subscription, - }, - table_interaction_state, - column_widths: ColumnWidths::new(cx, 1), - parsing_task: None, - is_parsing: false, - filter_sort_task: None, - performance_metrics: PerformanceMetrics::default(), - list_state: gpui::ListState::new(contents.rows.len(), ListAlignment::Top, px(1.)) - .with_uniform_item_height(px(24.)), - settings: CsvPreviewSettings::default(), - last_parse_end_time: None, - engine: TableDataEngine::default(), - }; - - view.parse_csv_from_active_editor(false, cx); - view - }) + view.parse_csv_from_active_editor(false, cx); + view } pub(crate) fn editor_state(&self) -> &EditorState { @@ -260,6 +404,21 @@ impl CsvPreviewView { Self::is_csv_file(&editor, cx).then_some(editor) } + fn is_csv_path(path: impl AsRef) -> bool { + path.as_ref() + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("csv")) + } + + fn source_buffer(&self, cx: &App) -> Option> { + self.active_editor_state + .editor + .read(cx) + .buffer() + .read(cx) + .as_singleton() + } + fn is_csv_file(editor: &Entity, cx: &App) -> bool { editor .read(cx) @@ -309,6 +468,84 @@ impl Item for CsvPreviewView { }) .unwrap_or_else(|| SharedString::from("CSV Preview")) } + + fn buffer_kind(&self, _cx: &App) -> ItemBufferKind { + ItemBufferKind::Singleton + } + + fn is_dirty(&self, cx: &App) -> bool { + self.source_buffer(cx) + .is_some_and(|buffer| buffer.read(cx).is_dirty()) + } + + fn for_each_project_item( + &self, + cx: &App, + f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem), + ) { + if let Some(buffer) = self.source_buffer(cx) { + f(buffer.entity_id(), buffer.read(cx)) + } + } +} + +/// A [`project::ProjectItem`] that claims CSV files when the `auto_preview` setting +/// is set to `in_place`, so that opening such files shows their rendered preview instead of an editor. +pub struct CsvPreviewItem { + buffer: Entity, +} + +impl project::ProjectItem for CsvPreviewItem { + fn try_open( + project: &Entity, + path: &ProjectPath, + cx: &mut App, + ) -> Option>>> { + if !cx.has_flag::() + || WorkspaceSettings::get_global(cx).auto_preview != AutoPreview::InPlace + || !project + .read(cx) + .absolute_path(path, cx) + .is_some_and(CsvPreviewView::is_csv_path) + { + return None; + } + let buffer = project.update(cx, |project, cx| project.open_buffer(path.clone(), cx)); + Some(cx.spawn(async move |cx| { + let buffer = buffer.await?; + Ok(cx.new(|_| CsvPreviewItem { buffer })) + })) + } + + fn entry_id(&self, cx: &App) -> Option { + project::ProjectItem::entry_id(self.buffer.read(cx), cx) + } + + fn project_path(&self, cx: &App) -> Option { + project::ProjectItem::project_path(self.buffer.read(cx), cx) + } + + fn is_dirty(&self) -> bool { + // This item is only a carrier between `try_open` and `for_project_item`: the + // preview reports its dirty state through the buffer it renders. + false + } +} + +impl ProjectItem for CsvPreviewView { + type Item = CsvPreviewItem; + + fn for_project_item( + project: Entity, + _pane: Option<&Pane>, + item: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let buffer = item.read(cx).buffer.clone(); + let editor = cx.new(|cx| Editor::for_buffer(buffer, Some(project), window, cx)); + Self::build(editor, cx) + } } #[derive(Debug, Default)] diff --git a/crates/csv_preview/src/renderer/preview_view.rs b/crates/csv_preview/src/renderer/preview_view.rs index 335633656af..d45c77cea28 100644 --- a/crates/csv_preview/src/renderer/preview_view.rs +++ b/crates/csv_preview/src/renderer/preview_view.rs @@ -10,6 +10,7 @@ impl Render for CsvPreviewView { let render_prep_start = Instant::now(); let table_with_settings = v_flex() + .key_context("CsvPreview") .size_full() .bg(theme.colors().editor_background) .track_focus(&self.focus_handle) diff --git a/crates/markdown_preview/src/markdown_preview.rs b/crates/markdown_preview/src/markdown_preview.rs index caf516b212f..83d59c46038 100644 --- a/crates/markdown_preview/src/markdown_preview.rs +++ b/crates/markdown_preview/src/markdown_preview.rs @@ -36,6 +36,8 @@ actions!( pub fn init(cx: &mut App) { workspace::register_serializable_item::(cx); + workspace::register_project_item::(cx); + workspace::register_auto_preview_provider(MarkdownPreviewView::auto_preview_provider(), cx); cx.observe_new(|workspace: &mut Workspace, window, cx| { let Some(window) = window else { diff --git a/crates/markdown_preview/src/markdown_preview_view.rs b/crates/markdown_preview/src/markdown_preview_view.rs index db6c1a80786..e2de8fdbf55 100644 --- a/crates/markdown_preview/src/markdown_preview_view.rs +++ b/crates/markdown_preview/src/markdown_preview_view.rs @@ -14,25 +14,31 @@ use gpui::{ InteractiveElement, IntoElement, IsZero, Pixels, Render, Resource, RetainAllImageCache, ScrollHandle, SharedString, SharedUri, Subscription, Task, WeakEntity, Window, point, px, }; -use language::LanguageRegistry; +use language::{Buffer, LanguageRegistry}; use markdown::{ CodeBlockRenderer, CopyButtonVisibility, Markdown, MarkdownElement, MarkdownFont, MarkdownOptions, MarkdownStyle, }; use project::search::SearchQuery; -use project::{Project, ProjectPath}; +use project::{Project, ProjectEntryId, ProjectPath}; use settings::{SeedQuerySetting, Settings, update_settings_file}; use theme::{SystemAppearance, Theme, ThemeRegistry}; use theme_settings::ThemeSettings; use ui::utils::WithRemSize; use ui::{ContextMenu, WithScrollbar, prelude::*, right_click_menu}; use util::markdown::split_local_url_fragment; -use workspace::item::{Item, ItemBufferKind, ItemHandle, SaveOptions, SerializableItem}; +use workspace::item::{ + Item, ItemBufferKind, ItemHandle, ProjectItem, SaveOptions, SerializableItem, +}; use workspace::notifications::NotifyResultExt; use workspace::searchable::{ Direction, SearchEvent, SearchOptions, SearchToken, SearchableItem, SearchableItemHandle, }; -use workspace::{ItemId, Pane, Workspace, WorkspaceId, delete_unloaded_items}; +use workspace::{ + AutoPreview, ItemId, MultiWorkspace, Pane, Workspace, WorkspaceId, WorkspaceSettings, + delete_unloaded_items, +}; +use zed_actions::preview::{OpenSource, Toggle}; use zed_actions::{DecreaseBufferFontSize, IncreaseBufferFontSize, ResetBufferFontSize}; use crate::markdown_preview_settings::MarkdownPreviewSettings; @@ -95,19 +101,7 @@ pub enum MarkdownPreviewEvent { impl MarkdownPreviewView { pub fn register(workspace: &mut Workspace, _window: &mut Window, _cx: &mut Context) { workspace.register_action(move |workspace, _: &OpenPreview, window, cx| { - if let Some(editor) = Self::resolve_active_item_as_markdown_editor(workspace, cx) { - let view = Self::create_markdown_view(workspace, editor.clone(), window, cx); - workspace.active_pane().update(cx, |pane, cx| { - if let Some(existing_view_idx) = - Self::find_existing_independent_preview_item_idx(pane, &editor, cx) - { - pane.activate_item(existing_view_idx, true, true, window, cx); - } else { - pane.add_item(Box::new(view.clone()), true, true, None, window, cx) - } - }); - cx.notify(); - } + Self::open_preview_for_active_editor(workspace, window, cx); }); workspace.register_action(move |workspace, _: &OpenPreviewToTheSide, window, cx| { @@ -137,6 +131,20 @@ impl MarkdownPreviewView { } }); + workspace.register_action(move |workspace, _: &OpenSource, window, cx| { + if !Self::open_source_for_active_preview(workspace, window, cx) { + cx.propagate(); + } + }); + + workspace.register_action(move |workspace, _: &Toggle, window, cx| { + if !Self::open_source_for_active_preview(workspace, window, cx) + && !Self::open_preview_for_active_editor(workspace, window, cx) + { + cx.propagate(); + } + }); + workspace.register_action(move |workspace, _: &OpenFollowingPreview, window, cx| { if let Some(editor) = Self::resolve_active_item_as_markdown_editor(workspace, cx) { // Check if there's already a following preview @@ -163,6 +171,98 @@ impl MarkdownPreviewView { }); } + pub(crate) fn auto_preview_provider() -> workspace::AutoPreviewProvider { + workspace::AutoPreviewProvider { + applies_to: |item, cx| { + item.downcast::() + .is_some_and(|editor| Self::is_markdown_file(&editor, cx)) + }, + has_open_sources: |workspace, cx| { + workspace + .items_of_type::(cx) + .any(|editor| Self::is_markdown_file(&editor, cx)) + }, + is_follow_view: |item, cx| { + item.downcast::() + .is_some_and(|view| view.read(cx).mode == MarkdownPreviewMode::Follow) + }, + is_preview_view: |item, cx| { + item.downcast::() + .is_some_and(|view| view.read(cx).mode == MarkdownPreviewMode::Default) + }, + build_follow_view: |workspace, window, cx| { + let editor = Self::resolve_active_item_as_markdown_editor(workspace, cx)?; + Some(Box::new(Self::create_following_markdown_view( + workspace, editor, window, cx, + ))) + }, + build_preview_view: |workspace, item, window, cx| { + let editor = item.downcast::()?; + Some(Box::new(Self::create_markdown_view( + workspace, editor, window, cx, + ))) + }, + source_view: |_, item, _, cx| { + let preview = item.downcast::()?; + let editor = preview.read(cx).active_editor.as_ref()?.editor.clone(); + Some(Box::new(editor)) + }, + } + } + + /// Opens (or reveals) a preview for the active markdown editor. + /// Returns false when the active item is not a markdown editor. + fn open_preview_for_active_editor( + workspace: &mut Workspace, + window: &mut Window, + cx: &mut Context, + ) -> bool { + let Some(editor) = Self::resolve_active_item_as_markdown_editor(workspace, cx) else { + return false; + }; + let view = Self::create_markdown_view(workspace, editor.clone(), window, cx); + workspace.active_pane().update(cx, |pane, cx| { + if let Some(existing_view_idx) = + Self::find_existing_independent_preview_item_idx(pane, &editor, cx) + { + pane.activate_item(existing_view_idx, true, true, window, cx); + } else { + pane.add_item(Box::new(view), true, true, None, window, cx) + } + }); + cx.notify(); + true + } + + /// Activates (or opens) a text editor for the active markdown preview. + /// Returns false when the active item is not a markdown preview. + fn open_source_for_active_preview( + workspace: &mut Workspace, + window: &mut Window, + cx: &mut Context, + ) -> bool { + let Some(preview) = workspace + .active_item(cx) + .and_then(|item| item.downcast::()) + else { + return false; + }; + let Some(editor) = preview + .read(cx) + .active_editor + .as_ref() + .map(|state| state.editor.clone()) + else { + return true; + }; + if !workspace.activate_item(&editor, true, true, window, cx) { + workspace.active_pane().update(cx, |pane, cx| { + pane.add_item(Box::new(editor.clone()), true, true, None, window, cx); + }); + } + true + } + fn find_existing_independent_preview_item_idx( pane: &Pane, editor: &Entity, @@ -254,73 +354,91 @@ impl MarkdownPreviewView { cx: &mut App, ) -> Entity { cx.new(|cx| { - let markdown = cx.new(|cx| { - Markdown::new_with_options( - SharedString::default(), - Some(language_registry), - None, - MarkdownOptions { - parse_html: true, - render_mermaid_diagrams: true, - parse_heading_slugs: true, - render_metadata_blocks: true, - ..Default::default() - }, - cx, - ) - }); - let mut this = Self { - active_editor: None, - focus_handle: cx.focus_handle(), - workspace: workspace.clone(), - _markdown_subscription: cx.observe( - &markdown, - |this: &mut Self, _: Entity, cx| { - this.sync_active_root_block(cx); - }, - ), - markdown, - active_source_index: None, - scroll_handle: ScrollHandle::new(), - image_cache: RetainAllImageCache::new(cx), - base_directory: None, - pending_update_task: None, + Self::build( mode, - }; + active_editor, + workspace, + language_registry, + window, + cx, + ) + }) + } - this.set_editor(active_editor, window, cx); + fn build( + mode: MarkdownPreviewMode, + active_editor: Entity, + workspace: WeakEntity, + language_registry: Arc, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let markdown = cx.new(|cx| { + Markdown::new_with_options( + SharedString::default(), + Some(language_registry), + None, + MarkdownOptions { + parse_html: true, + render_mermaid_diagrams: true, + parse_heading_slugs: true, + render_metadata_blocks: true, + ..Default::default() + }, + cx, + ) + }); + let mut this = Self { + active_editor: None, + focus_handle: cx.focus_handle(), + workspace: workspace.clone(), + _markdown_subscription: cx.observe( + &markdown, + |this: &mut Self, _: Entity, cx| { + this.sync_active_root_block(cx); + }, + ), + markdown, + active_source_index: None, + scroll_handle: ScrollHandle::new(), + image_cache: RetainAllImageCache::new(cx), + base_directory: None, + pending_update_task: None, + mode, + }; - match mode { - MarkdownPreviewMode::Follow => { - if let Some(workspace) = &workspace.upgrade() { - cx.observe_in(workspace, window, |this, workspace, window, cx| { - let item = workspace.read(cx).active_item(cx); - this.workspace_updated(item, window, cx); - }) - .detach(); - } else { - log::error!("Failed to listen to workspace updates"); - } - } - MarkdownPreviewMode::Default => { - // After workspace restoration the bound editor may be an orphan that - // wraps the right buffer but isn't the canonical Editor instance in - // any pane. Re-binding to the workspace's editor for our buffer is - // what restores cursor-driven scroll sync — `SelectionsChanged` only - // fires from the editor the user actually interacts with. - // - // Subscribing to `workspace::Event` (rather than `observe`) keeps the - // rebind check off the cursor-move hot path; `observe` would fire on - // every workspace `cx.notify`. - if let Some(workspace) = &workspace.upgrade() { - cx.subscribe_in(workspace, window, Self::on_workspace_event) - .detach(); - } + this.set_editor(active_editor, window, cx); + + match mode { + MarkdownPreviewMode::Follow => { + if let Some(workspace) = &workspace.upgrade() { + cx.observe_in(workspace, window, |this, workspace, window, cx| { + let item = workspace.read(cx).active_item(cx); + this.workspace_updated(item, window, cx); + }) + .detach(); + } else { + log::error!("Failed to listen to workspace updates"); } } + MarkdownPreviewMode::Default => { + // After workspace restoration the bound editor may be an orphan that + // wraps the right buffer but isn't the canonical Editor instance in + // any pane. Re-binding to the workspace's editor for our buffer is + // what restores cursor-driven scroll sync — `SelectionsChanged` only + // fires from the editor the user actually interacts with. + // + // Subscribing to `workspace::Event` (rather than `observe`) keeps the + // rebind check off the cursor-move hot path; `observe` would fire on + // every workspace `cx.notify`. + if let Some(workspace) = &workspace.upgrade() { + cx.subscribe_in(workspace, window, Self::on_workspace_event) + .detach(); + } + } + } - this - }) + this } fn workspace_updated( @@ -375,14 +493,29 @@ impl MarkdownPreviewView { .detach(); } - pub fn is_markdown_file(editor: &Entity, cx: &mut Context) -> bool { - let buffer = editor.read(cx).buffer().read(cx); - if let Some(buffer) = buffer.as_singleton() - && let Some(language) = buffer.read(cx).language() - { + fn source_buffer(&self, cx: &App) -> Option> { + self.active_editor + .as_ref()? + .editor + .read(cx) + .buffer() + .read(cx) + .as_singleton() + } + + pub fn is_markdown_file(editor: &Entity, cx: &App) -> bool { + let Some(buffer) = editor.read(cx).buffer().read(cx).as_singleton() else { + return false; + }; + let buffer = buffer.read(cx); + if let Some(language) = buffer.language() { return language.name() == "Markdown"; } - false + // Language may not be assigned (yet), e.g. when the language is not + // loaded: fall back to checking the file extension. + buffer + .file() + .is_some_and(|file| Self::is_markdown_path(Path::new(file.file_name(cx)))) } fn set_editor(&mut self, editor: Entity, window: &mut Window, cx: &mut Context) { @@ -1125,6 +1258,7 @@ impl Item for MarkdownPreviewView { window: &mut Window, cx: &mut Context, ) { + self.workspace = workspace.weak_handle(); if self.mode != MarkdownPreviewMode::Default { return; } @@ -1208,6 +1342,24 @@ impl Item for MarkdownPreviewView { ItemBufferKind::Singleton } + fn is_dirty(&self, cx: &App) -> bool { + self.source_buffer(cx) + .is_some_and(|buffer| buffer.read(cx).is_dirty()) + } + + fn for_each_project_item( + &self, + cx: &App, + f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem), + ) { + if self.mode != MarkdownPreviewMode::Default { + return; + } + if let Some(buffer) = self.source_buffer(cx) { + f(buffer.entity_id(), buffer.read(cx)) + } + } + fn as_searchable( &self, handle: &Entity, @@ -1217,6 +1369,85 @@ impl Item for MarkdownPreviewView { } } +/// A [`project::ProjectItem`] that claims markdown files when the `auto_preview` setting +/// is set to `in_place`, so that opening such files shows their rendered preview instead of an editor. +pub struct MarkdownPreviewItem { + buffer: Entity, +} + +impl project::ProjectItem for MarkdownPreviewItem { + fn try_open( + project: &Entity, + path: &ProjectPath, + cx: &mut App, + ) -> Option>>> { + if WorkspaceSettings::get_global(cx).auto_preview != AutoPreview::InPlace + || !project + .read(cx) + .absolute_path(path, cx) + .is_some_and(MarkdownPreviewView::is_markdown_path) + { + return None; + } + let buffer = project.update(cx, |project, cx| project.open_buffer(path.clone(), cx)); + Some(cx.spawn(async move |cx| { + let buffer = buffer.await?; + Ok(cx.new(|_| MarkdownPreviewItem { buffer })) + })) + } + + fn entry_id(&self, cx: &App) -> Option { + project::ProjectItem::entry_id(self.buffer.read(cx), cx) + } + + fn project_path(&self, cx: &App) -> Option { + project::ProjectItem::project_path(self.buffer.read(cx), cx) + } + + fn is_dirty(&self) -> bool { + // This item is only a carrier between `try_open` and `for_project_item`: the + // preview reports its dirty state through the buffer it renders. + false + } +} + +impl ProjectItem for MarkdownPreviewView { + type Item = MarkdownPreviewItem; + + fn for_project_item( + project: Entity, + pane: Option<&Pane>, + item: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let buffer = item.read(cx).buffer.clone(); + let language_registry = project.read(cx).languages().clone(); + let editor = cx.new(|cx| Editor::for_buffer(buffer, Some(project), window, cx)); + let workspace = pane + .map(|pane| pane.workspace().clone()) + .or_else(|| { + Some( + window + .root::() + .flatten()? + .read(cx) + .workspace() + .downgrade(), + ) + }) + .unwrap_or_else(WeakEntity::new_invalid); + Self::build( + MarkdownPreviewMode::Default, + editor, + workspace, + language_registry, + window, + cx, + ) + } +} + impl Render for MarkdownPreviewView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let preview_theme = self.resolve_preview_theme(cx); @@ -1619,8 +1850,9 @@ mod tests { use crate::markdown_preview_view::resolve_preview_image; use buffer_diff::BufferDiff; use editor::Editor; - use gpui::{AppContext as _, Entity, TestAppContext}; + use gpui::{AppContext as _, BorrowAppContext as _, Entity, Focusable as _, TestAppContext}; use serde_json::json; + use settings::SettingsStore; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; @@ -1629,10 +1861,12 @@ mod tests { use util::test::TempTree; use workspace::item::SerializableItem; use workspace::{ - AppState, ItemId, MultiWorkspace, SaveIntent, Workspace, WorkspaceId, open_paths, + AppState, AutoPreview, ItemId, MultiWorkspace, SaveIntent, Workspace, WorkspaceId, + open_paths, }; + use zed_actions::preview::{OpenSource, Toggle}; - use super::MarkdownPreviewView; + use super::{MarkdownPreviewMode, MarkdownPreviewView}; #[test] fn resolves_workspace_absolute_preview_image_path_and_rejects_missing() { @@ -2248,6 +2482,762 @@ mod tests { ); } + #[gpui::test] + async fn auto_preview_opens_markdown_files_as_preview(cx: &mut TestAppContext) { + let app_state = init_test(cx); + set_auto_preview(cx, AutoPreview::InPlace); + app_state + .fs + .as_fake() + .insert_tree( + path!("/dir"), + json!({ + "a.md": "# a", + "b.md": "# b", + "c.txt": "plain text", + }), + ) + .await; + + cx.update(|cx| { + open_paths( + &[PathBuf::from(path!("/dir"))], + app_state.clone(), + workspace::OpenOptions::default(), + cx, + ) + }) + .await + .unwrap(); + cx.run_until_parked(); + + let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::().unwrap()); + open_workspace_path(&multi_workspace, "a.md", cx).await; + let first_preview = multi_workspace + .update(cx, |multi_workspace, window, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| { + assert_eq!(workspace.active_pane().read(cx).items_len(), 1); + let preview = workspace + .active_item(cx) + .and_then(|item| item.downcast::()) + .expect("markdown file should have been opened as a preview"); + assert!( + preview.read(cx).focus_handle.contains_focused(window, cx), + "the opened preview should be focused" + ); + preview + }) + }) + .unwrap(); + assert_eq!( + preview_source_path(cx, &first_preview).as_ref(), + rel_path("a.md") + ); + + open_workspace_path(&multi_workspace, "b.md", cx).await; + let second_preview = multi_workspace + .update(cx, |multi_workspace, _, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| { + assert_eq!( + workspace.active_pane().read(cx).items_len(), + 2, + "a new preview should have been added for the second file" + ); + workspace + .active_item(cx) + .and_then(|item| item.downcast::()) + .expect("second markdown file should have been opened as a preview") + }) + }) + .unwrap(); + assert_eq!( + preview_source_path(cx, &second_preview).as_ref(), + rel_path("b.md") + ); + assert_ne!(first_preview, second_preview); + + open_workspace_path(&multi_workspace, "a.md", cx).await; + multi_workspace + .update(cx, |multi_workspace, _, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| { + assert_eq!( + workspace.active_pane().read(cx).items_len(), + 2, + "reopening a previewed file should reuse its preview" + ); + let active_preview = workspace + .active_item(cx) + .and_then(|item| item.downcast::()) + .expect("reopened markdown file should show its preview"); + assert_eq!(active_preview, first_preview); + }) + }) + .unwrap(); + + open_workspace_path(&multi_workspace, "c.txt", cx).await; + let editor = multi_workspace + .update(cx, |multi_workspace, _, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| { + assert_eq!(workspace.active_pane().read(cx).items_len(), 3); + workspace + .active_item(cx) + .and_then(|item| item.downcast::()) + .expect("non-previewable files should still open in an editor") + }) + }) + .unwrap(); + assert_eq!(editor_source_path(cx, &editor).as_ref(), rel_path("c.txt")); + } + + #[gpui::test] + async fn open_source_opens_an_editor_for_the_previewed_file(cx: &mut TestAppContext) { + let app_state = init_test(cx); + set_auto_preview(cx, AutoPreview::InPlace); + app_state + .fs + .as_fake() + .insert_tree(path!("/dir"), json!({ "a.md": "# a" })) + .await; + + cx.update(|cx| { + open_paths( + &[PathBuf::from(path!("/dir"))], + app_state.clone(), + workspace::OpenOptions::default(), + cx, + ) + }) + .await + .unwrap(); + cx.run_until_parked(); + + let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::().unwrap()); + open_workspace_path(&multi_workspace, "a.md", cx).await; + let preview = multi_workspace + .update(cx, |multi_workspace, _, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace + .read(cx) + .active_item(cx) + .and_then(|item| item.downcast::()) + .expect("markdown file should have been opened as a preview") + }) + .unwrap(); + + multi_workspace + .update(cx, |_, window, cx| { + window.dispatch_action(Box::new(OpenSource), cx); + }) + .unwrap(); + cx.run_until_parked(); + + let editor = multi_workspace + .update(cx, |multi_workspace, window, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| { + assert_eq!( + workspace.active_pane().read(cx).items_len(), + 2, + "an editor should have been added next to the preview" + ); + let editor = workspace + .active_item(cx) + .and_then(|item| item.downcast::()) + .expect("the editor for the previewed file should be active"); + assert!( + editor + .read(cx) + .focus_handle(cx) + .contains_focused(window, cx) + ); + editor + }) + }) + .unwrap(); + assert_eq!(editor_source_path(cx, &editor).as_ref(), rel_path("a.md")); + + // Opening the source again should activate the existing editor instead of adding a new one. + multi_workspace + .update(cx, |multi_workspace, window, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| { + workspace.activate_item(&preview, true, true, window, cx); + }); + }) + .unwrap(); + cx.run_until_parked(); + multi_workspace + .update(cx, |_, window, cx| { + window.dispatch_action(Box::new(OpenSource), cx); + }) + .unwrap(); + cx.run_until_parked(); + + multi_workspace + .update(cx, |multi_workspace, _, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| { + assert_eq!(workspace.active_pane().read(cx).items_len(), 2); + assert!( + workspace + .active_item(cx) + .and_then(|item| item.downcast::()) + .is_some() + ); + }) + }) + .unwrap(); + } + + #[gpui::test] + async fn closed_side_preview_stays_closed_until_the_next_activation(cx: &mut TestAppContext) { + let app_state = init_test(cx); + set_auto_preview(cx, AutoPreview::ToTheSide); + app_state + .fs + .as_fake() + .insert_tree( + path!("/dir"), + json!({ + "a.md": "# a", + "b.md": "# b", + }), + ) + .await; + + cx.update(|cx| { + open_paths( + &[PathBuf::from(path!("/dir"))], + app_state.clone(), + workspace::OpenOptions::default(), + cx, + ) + }) + .await + .unwrap(); + cx.run_until_parked(); + + let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::().unwrap()); + open_workspace_path(&multi_workspace, "a.md", cx).await; + let preview = multi_workspace + .update(cx, |multi_workspace, _, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| { + workspace + .items_of_type::(cx) + .next() + .expect("a preview should have been opened to the side") + }) + }) + .unwrap(); + + // The user closes the preview tab: it should not come back while the same + // item stays active. + multi_workspace + .update(cx, |multi_workspace, window, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| { + let pane = workspace.pane_for(&preview).unwrap(); + pane.update(cx, |pane, cx| { + pane.remove_item(preview.entity_id(), false, true, window, cx) + }); + }) + }) + .unwrap(); + cx.run_until_parked(); + multi_workspace + .update(cx, |multi_workspace, _, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| { + assert_eq!( + workspace.items_of_type::(cx).count(), + 0, + "a closed side preview should stay closed" + ); + assert_eq!(workspace.panes().len(), 1); + }) + }) + .unwrap(); + + // Activating another previewable item reopens the preview. + open_workspace_path(&multi_workspace, "b.md", cx).await; + let preview = multi_workspace + .update(cx, |multi_workspace, _, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| { + assert_eq!(workspace.panes().len(), 2); + workspace + .items_of_type::(cx) + .next() + .expect("the side preview should reopen on the next item activation") + }) + }) + .unwrap(); + assert_eq!(preview_source_path(cx, &preview).as_ref(), rel_path("b.md")); + } + + #[gpui::test] + async fn changing_auto_preview_updates_open_tabs(cx: &mut TestAppContext) { + let app_state = init_test(cx); + app_state + .fs + .as_fake() + .insert_tree(path!("/dir"), json!({ "a.md": "# a" })) + .await; + + cx.update(|cx| { + open_paths( + &[PathBuf::from(path!("/dir"))], + app_state.clone(), + workspace::OpenOptions::default(), + cx, + ) + }) + .await + .unwrap(); + cx.run_until_parked(); + + let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::().unwrap()); + open_workspace_path(&multi_workspace, "a.md", cx).await; + multi_workspace + .update(cx, |multi_workspace, _, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace + .read(cx) + .active_item(cx) + .and_then(|item| item.downcast::()) + .expect("with auto_preview off, markdown files should open in an editor") + }) + .unwrap(); + + set_auto_preview(cx, AutoPreview::InPlace); + cx.run_until_parked(); + let preview = multi_workspace + .update(cx, |multi_workspace, _, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| { + assert_eq!( + workspace.active_pane().read(cx).items_len(), + 1, + "the editor should have been converted in place" + ); + workspace + .active_item(cx) + .and_then(|item| item.downcast::()) + .expect("switching to in_place should convert open editors to previews") + }) + }) + .unwrap(); + assert_eq!(preview_source_path(cx, &preview).as_ref(), rel_path("a.md")); + + set_auto_preview(cx, AutoPreview::ToTheSide); + cx.run_until_parked(); + multi_workspace + .update(cx, |multi_workspace, _, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| { + workspace + .active_item(cx) + .and_then(|item| item.downcast::()) + .expect("switching to to_the_side should convert previews back to editors"); + assert_eq!( + workspace.panes().len(), + 2, + "a side preview should have been opened" + ); + let side_preview = workspace + .items_of_type::(cx) + .next() + .expect("a follow preview should exist"); + assert_eq!(side_preview.read(cx).mode, MarkdownPreviewMode::Follow); + }) + }) + .unwrap(); + + set_auto_preview(cx, AutoPreview::InPlace); + cx.run_until_parked(); + multi_workspace + .update(cx, |multi_workspace, _, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| { + assert_eq!( + workspace.panes().len(), + 1, + "switching away from to_the_side should remove the side preview" + ); + assert_eq!(workspace.active_pane().read(cx).items_len(), 1); + workspace + .active_item(cx) + .and_then(|item| item.downcast::()) + .expect("switching to in_place should convert open editors to previews"); + }) + }) + .unwrap(); + + set_auto_preview(cx, AutoPreview::Off); + cx.run_until_parked(); + multi_workspace + .update(cx, |multi_workspace, _, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| { + assert_eq!(workspace.panes().len(), 1); + assert_eq!(workspace.active_pane().read(cx).items_len(), 1); + workspace + .active_item(cx) + .and_then(|item| item.downcast::()) + .expect("switching auto_preview off should convert previews to editors"); + }) + }) + .unwrap(); + } + + #[gpui::test] + async fn in_place_auto_previews_open_as_ephemeral_tabs(cx: &mut TestAppContext) { + let app_state = init_test(cx); + set_auto_preview(cx, AutoPreview::InPlace); + app_state + .fs + .as_fake() + .insert_tree( + path!("/dir"), + json!({ + "a.md": "# a", + "b.md": "# b", + }), + ) + .await; + + cx.update(|cx| { + open_paths( + &[PathBuf::from(path!("/dir"))], + app_state.clone(), + workspace::OpenOptions::default(), + cx, + ) + }) + .await + .unwrap(); + cx.run_until_parked(); + + let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::().unwrap()); + open_workspace_path_as_preview(&multi_workspace, "a.md", cx).await; + multi_workspace + .update(cx, |multi_workspace, _, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| { + let preview = workspace + .active_item(cx) + .and_then(|item| item.downcast::()) + .expect("markdown file should have been opened as a preview"); + let pane = workspace.active_pane().read(cx); + assert_eq!(pane.items_len(), 1); + assert_eq!( + pane.preview_item_id(), + Some(preview.entity_id()), + "an auto preview opened as a preview tab should be ephemeral" + ); + }) + }) + .unwrap(); + + open_workspace_path_as_preview(&multi_workspace, "b.md", cx).await; + let second_preview = multi_workspace + .update(cx, |multi_workspace, _, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| { + let preview = workspace + .active_item(cx) + .and_then(|item| item.downcast::()) + .expect("second markdown file should have been opened as a preview"); + let pane = workspace.active_pane().read(cx); + assert_eq!( + pane.items_len(), + 1, + "an ephemeral preview tab should be replaced by the next one" + ); + assert_eq!(pane.preview_item_id(), Some(preview.entity_id())); + preview + }) + }) + .unwrap(); + assert_eq!( + preview_source_path(cx, &second_preview).as_ref(), + rel_path("b.md") + ); + } + + #[gpui::test] + async fn to_the_side_auto_preview_follows_editors_and_closes_with_them( + cx: &mut TestAppContext, + ) { + let app_state = init_test(cx); + set_auto_preview(cx, AutoPreview::ToTheSide); + app_state + .fs + .as_fake() + .insert_tree( + path!("/dir"), + json!({ + "a.md": "# a", + "b.md": "# b", + }), + ) + .await; + + cx.update(|cx| { + open_paths( + &[PathBuf::from(path!("/dir"))], + app_state.clone(), + workspace::OpenOptions::default(), + cx, + ) + }) + .await + .unwrap(); + cx.run_until_parked(); + + let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::().unwrap()); + open_workspace_path(&multi_workspace, "a.md", cx).await; + let preview = multi_workspace + .update(cx, |multi_workspace, window, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| { + let editor = workspace + .active_item(cx) + .and_then(|item| item.downcast::()) + .expect("markdown files should still open in an editor"); + assert!( + editor + .read(cx) + .focus_handle(cx) + .contains_focused(window, cx), + "the editor should keep the focus" + ); + assert_eq!( + workspace.panes().len(), + 2, + "a pane should have been split for the preview" + ); + let preview = workspace + .items_of_type::(cx) + .next() + .expect("a preview should have been opened to the side"); + assert_eq!(preview.read(cx).mode, MarkdownPreviewMode::Follow); + assert_ne!( + workspace.pane_for(&preview), + Some(workspace.active_pane().clone()), + "the preview should live in the other pane" + ); + preview + }) + }) + .unwrap(); + assert_eq!(preview_source_path(cx, &preview).as_ref(), rel_path("a.md")); + + open_workspace_path(&multi_workspace, "b.md", cx).await; + multi_workspace + .update(cx, |multi_workspace, _, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| { + assert_eq!( + workspace.items_of_type::(cx).count(), + 1, + "the same preview should be reused for other markdown files" + ); + }) + }) + .unwrap(); + assert_eq!(preview_source_path(cx, &preview).as_ref(), rel_path("b.md")); + + multi_workspace + .update(cx, |multi_workspace, window, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| { + let editors = workspace.items_of_type::(cx).collect::>(); + for editor in editors { + let pane = workspace.pane_for(&editor).unwrap(); + pane.update(cx, |pane, cx| { + pane.remove_item(editor.entity_id(), false, true, window, cx) + }); + } + }) + }) + .unwrap(); + cx.run_until_parked(); + + multi_workspace + .update(cx, |multi_workspace, _, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| { + assert_eq!( + workspace.items_of_type::(cx).count(), + 0, + "the preview should close when no markdown editors remain" + ); + assert_eq!( + workspace.panes().len(), + 1, + "the preview pane should be removed with the preview" + ); + }) + }) + .unwrap(); + } + + #[gpui::test] + async fn toggle_switches_between_source_and_preview(cx: &mut TestAppContext) { + let app_state = init_test(cx); + set_auto_preview(cx, AutoPreview::InPlace); + app_state + .fs + .as_fake() + .insert_tree(path!("/dir"), json!({ "a.md": "# a" })) + .await; + + cx.update(|cx| { + open_paths( + &[PathBuf::from(path!("/dir"))], + app_state.clone(), + workspace::OpenOptions::default(), + cx, + ) + }) + .await + .unwrap(); + cx.run_until_parked(); + + let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::().unwrap()); + open_workspace_path(&multi_workspace, "a.md", cx).await; + let preview = multi_workspace + .update(cx, |multi_workspace, _, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace + .read(cx) + .active_item(cx) + .and_then(|item| item.downcast::()) + .expect("markdown file should have been opened as a preview") + }) + .unwrap(); + + multi_workspace + .update(cx, |_, window, cx| { + window.dispatch_action(Box::new(Toggle), cx); + }) + .unwrap(); + cx.run_until_parked(); + let editor = multi_workspace + .update(cx, |multi_workspace, _, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| { + assert_eq!(workspace.active_pane().read(cx).items_len(), 2); + workspace + .active_item(cx) + .and_then(|item| item.downcast::()) + .expect("toggling a preview should open its source editor") + }) + }) + .unwrap(); + assert_eq!(editor_source_path(cx, &editor).as_ref(), rel_path("a.md")); + + multi_workspace + .update(cx, |_, window, cx| { + window.dispatch_action(Box::new(Toggle), cx); + }) + .unwrap(); + cx.run_until_parked(); + multi_workspace + .update(cx, |multi_workspace, _, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| { + assert_eq!( + workspace.active_pane().read(cx).items_len(), + 2, + "toggling back should reuse the existing preview" + ); + assert_eq!( + workspace + .active_item(cx) + .and_then(|item| item.downcast::()), + Some(preview), + "toggling an editor should reveal its preview" + ); + }) + }) + .unwrap(); + } + + fn set_auto_preview(cx: &mut TestAppContext, auto_preview: AutoPreview) { + cx.update(|cx| { + cx.update_global::(|settings, cx| { + settings.update_user_settings(cx, |settings| { + settings.workspace.auto_preview = Some(auto_preview); + }); + }); + }); + } + + async fn open_workspace_path( + multi_workspace: &gpui::WindowHandle, + file: &str, + cx: &mut TestAppContext, + ) { + let open_task = multi_workspace + .update(cx, |multi_workspace, window, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| { + let worktree_id = workspace + .project() + .read(cx) + .worktrees(cx) + .next() + .unwrap() + .read(cx) + .id(); + workspace.open_path((worktree_id, rel_path(file)), None, true, window, cx) + }) + }) + .unwrap(); + open_task.await.unwrap(); + cx.run_until_parked(); + } + + async fn open_workspace_path_as_preview( + multi_workspace: &gpui::WindowHandle, + file: &str, + cx: &mut TestAppContext, + ) { + let open_task = multi_workspace + .update(cx, |multi_workspace, window, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| { + let worktree_id = workspace + .project() + .read(cx) + .worktrees(cx) + .next() + .unwrap() + .read(cx) + .id(); + workspace.open_path_preview( + (worktree_id, rel_path(file)), + None, + true, + true, + true, + window, + cx, + ) + }) + }) + .unwrap(); + open_task.await.unwrap(); + cx.run_until_parked(); + } + fn init_test(cx: &mut TestAppContext) -> Arc { cx.update(|cx| { let state = AppState::test(cx); diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index 0d2f5511430..a50235f3d92 100644 --- a/crates/settings/src/vscode_import.rs +++ b/crates/settings/src/vscode_import.rs @@ -1058,6 +1058,15 @@ impl VsCodeSettings { }), zoomed_padding: None, focus_follows_mouse: None, + auto_preview: self + .read_value("workbench.editorAssociations") + .and_then(|value| { + let associations = value.as_object()?; + associations + .values() + .any(|editor| editor.as_str() == Some("vscode.markdown.preview.editor")) + .then_some(AutoPreview::InPlace) + }), } } diff --git a/crates/settings_content/src/workspace.rs b/crates/settings_content/src/workspace.rs index c52cda911d4..e4862c0c54d 100644 --- a/crates/settings_content/src/workspace.rs +++ b/crates/settings_content/src/workspace.rs @@ -134,6 +134,38 @@ pub struct WorkspaceSettingsContent { /// Whether the focused panel follows the mouse location /// Default: false pub focus_follows_mouse: Option, + /// How to open files that have a preview available for their file type + /// (e.g. Markdown or SVG files). + /// + /// Default: off + pub auto_preview: Option, +} + +#[derive( + Copy, + Clone, + PartialEq, + Eq, + Default, + Serialize, + Deserialize, + JsonSchema, + MergeFrom, + Debug, + strum::VariantArray, + strum::VariantNames, +)] +#[serde(rename_all = "snake_case")] +pub enum AutoPreview { + /// Open previewable files in a text editor, previews have to be opened manually. + #[default] + Off, + /// Open previewable files in their preview instead of a text editor. + /// The text editor can be opened from the preview with the `preview::OpenSource` action. + InPlace, + /// Open previewable files in a text editor and keep a preview following the + /// active editor in a pane to the side. + ToTheSide, } #[with_fallible_options] diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index 2f6ce0beec5..62ff227579c 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -3652,7 +3652,7 @@ fn search_and_files_page() -> SettingsPage { ] } - fn file_scan_section() -> [SettingsPageItem; 6] { + fn file_scan_section() -> [SettingsPageItem; 7] { [ SettingsPageItem::SectionHeader("File Scan"), SettingsPageItem::SettingItem(SettingItem { @@ -3749,6 +3749,20 @@ fn search_and_files_page() -> SettingsPage { metadata: None, files: USER, }), + SettingsPageItem::SettingItem(SettingItem { + title: "Auto Preview", + description: "How to open files that have a preview available for their file type (e.g. Markdown or SVG files).", + field: Box::new(SettingField { + organization_override: None, + json_path: Some("auto_preview"), + pick: |settings_content| settings_content.workspace.auto_preview.as_ref(), + write: |settings_content, value, _| { + settings_content.workspace.auto_preview = value; + }, + }), + metadata: None, + files: USER, + }), ] } diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs index 35f816d649b..26dffc4b87d 100644 --- a/crates/settings_ui/src/settings_ui.rs +++ b/crates/settings_ui/src/settings_ui.rs @@ -530,6 +530,7 @@ fn init_renderers(cx: &mut App) { .add_basic_renderer::(render_toggle_button) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) + .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) diff --git a/crates/svg_preview/Cargo.toml b/crates/svg_preview/Cargo.toml index 9ee085ee073..f991e2a32fc 100644 --- a/crates/svg_preview/Cargo.toml +++ b/crates/svg_preview/Cargo.toml @@ -12,10 +12,23 @@ workspace = true path = "src/svg_preview.rs" [dependencies] -multi_buffer.workspace = true +anyhow.workspace = true +editor.workspace = true file_icons.workspace = true gpui.workspace = true language.workspace = true +multi_buffer.workspace = true +project.workspace = true +settings.workspace = true ui.workspace = true workspace.workspace = true zed_actions.workspace = true + +[dev-dependencies] +editor = { workspace = true, features = ["test-support"] } +gpui = { workspace = true, features = ["test-support"] } +markdown_preview.workspace = true +project = { workspace = true, features = ["test-support"] } +serde_json.workspace = true +util.workspace = true +workspace = { workspace = true, features = ["test-support"] } diff --git a/crates/svg_preview/src/svg_preview.rs b/crates/svg_preview/src/svg_preview.rs index 060639db5fa..4ce409970df 100644 --- a/crates/svg_preview/src/svg_preview.rs +++ b/crates/svg_preview/src/svg_preview.rs @@ -14,6 +14,11 @@ actions!( ); pub fn init(cx: &mut App) { + workspace::register_project_item::(cx); + workspace::register_auto_preview_provider( + svg_preview_view::SvgPreviewView::auto_preview_provider(), + cx, + ); cx.observe_new(|workspace: &mut Workspace, window, cx| { let Some(window) = window else { return; diff --git a/crates/svg_preview/src/svg_preview_view.rs b/crates/svg_preview/src/svg_preview_view.rs index 259243b8ac7..a4413ac29dc 100644 --- a/crates/svg_preview/src/svg_preview_view.rs +++ b/crates/svg_preview/src/svg_preview_view.rs @@ -1,6 +1,8 @@ use std::mem; use std::sync::Arc; +use anyhow::Result; +use editor::Editor; use file_icons::FileIcons; use gpui::{ App, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement, ParentElement, Render, @@ -8,9 +10,12 @@ use gpui::{ }; use language::{Buffer, BufferEvent}; use multi_buffer::MultiBuffer; +use project::{Project, ProjectEntryId, ProjectPath}; +use settings::Settings as _; use ui::prelude::*; -use workspace::item::Item; -use workspace::{Pane, Workspace}; +use workspace::item::{Item, ItemBufferKind, ProjectItem}; +use workspace::{AutoPreview, Pane, Workspace, WorkspaceSettings}; +use zed_actions::preview::{OpenSource, Toggle}; use crate::{OpenFollowingPreview, OpenPreview, OpenPreviewToTheSide}; @@ -79,6 +84,8 @@ impl SvgPreviewView { move |this: &mut SvgPreviewView, workspace, event: &workspace::Event, window, cx| { if let workspace::Event::ActiveItemChanged = event { let workspace = workspace.read(cx); + // When the active item is not an SVG buffer, keep showing the last + // previewed file instead of blanking the view. if let Some(active_item) = workspace.active_item(cx) && let Some(buffer) = active_item.downcast::() && Self::is_svg_file(&buffer, cx) @@ -93,8 +100,6 @@ impl SvgPreviewView { this.render_image(window, cx); cx.notify(); } - } else { - this.set_current(None, window, cx); } } }, @@ -202,30 +207,135 @@ impl SvgPreviewView { }) } - pub fn register(workspace: &mut Workspace, _window: &mut Window, _cx: &mut Context) { - workspace.register_action(move |workspace, _: &OpenPreview, window, cx| { - if let Some(buffer) = Self::resolve_active_item_as_svg_buffer(workspace, cx) - && Self::is_svg_file(&buffer, cx) - { - let view = Self::create_svg_view( - SvgPreviewMode::Default, + pub fn is_svg_path(path: impl AsRef) -> bool { + path.as_ref() + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("svg")) + } + + fn is_following(&self) -> bool { + self._workspace_subscription.is_some() + } + + pub(crate) fn auto_preview_provider() -> workspace::AutoPreviewProvider { + workspace::AutoPreviewProvider { + applies_to: |item, cx| { + item.downcast::() + .is_some_and(|editor| Self::is_svg_file(editor.read(cx).buffer(), cx)) + }, + has_open_sources: |workspace, cx| { + workspace + .items_of_type::(cx) + .any(|editor| Self::is_svg_file(editor.read(cx).buffer(), cx)) + }, + is_follow_view: |item, cx| { + item.downcast::() + .is_some_and(|view| view.read(cx).is_following()) + }, + is_preview_view: |item, cx| { + item.downcast::() + .is_some_and(|view| !view.read(cx).is_following()) + }, + build_follow_view: |workspace, window, cx| { + let buffer = Self::resolve_active_item_as_svg_buffer(workspace, cx)?; + Some(Box::new(Self::create_svg_view( + SvgPreviewMode::Follow, workspace, - buffer.clone(), + buffer, window, cx, - ); - workspace.active_pane().update(cx, |pane, cx| { - if let Some(existing_view_idx) = - Self::find_existing_preview_item_idx(pane, &buffer, cx) - { - pane.activate_item(existing_view_idx, true, true, window, cx); - } else { - pane.add_item(Box::new(view), true, true, None, window, cx) - } + ))) + }, + build_preview_view: |workspace, item, window, cx| { + let editor = item.downcast::()?; + let buffer = editor.read(cx).buffer().clone(); + Some(Box::new(Self::create_svg_view( + SvgPreviewMode::Default, + workspace, + buffer, + window, + cx, + ))) + }, + source_view: |workspace, item, window, cx| { + let preview = item.downcast::()?; + let buffer = preview.read(cx).buffer.clone()?; + let existing_editor = workspace.items_of_type::(cx).find(|editor| { + editor.read(cx).buffer().read(cx).as_singleton().as_ref() == Some(&buffer) }); - cx.notify(); + let editor = existing_editor.unwrap_or_else(|| { + let project = workspace.project().clone(); + cx.new(|cx| Editor::for_buffer(buffer, Some(project), window, cx)) + }); + Some(Box::new(editor)) + }, + } + } + + /// Opens (or reveals) a preview for the active SVG editor. + /// Returns false when the active item is not an SVG editor. + fn open_preview_for_active_editor( + workspace: &mut Workspace, + window: &mut Window, + cx: &mut Context, + ) -> bool { + let Some(buffer) = Self::resolve_active_item_as_svg_buffer(workspace, cx) else { + return false; + }; + let view = Self::create_svg_view( + SvgPreviewMode::Default, + workspace, + buffer.clone(), + window, + cx, + ); + workspace.active_pane().update(cx, |pane, cx| { + if let Some(existing_view_idx) = Self::find_existing_preview_item_idx(pane, &buffer, cx) + { + pane.activate_item(existing_view_idx, true, true, window, cx); + } else { + pane.add_item(Box::new(view), true, true, None, window, cx) } }); + cx.notify(); + true + } + + /// Activates (or opens) a text editor for the active SVG preview. + /// Returns false when the active item is not an SVG preview. + fn open_source_for_active_preview( + workspace: &mut Workspace, + window: &mut Window, + cx: &mut Context, + ) -> bool { + let Some(preview) = workspace + .active_item(cx) + .and_then(|item| item.downcast::()) + else { + return false; + }; + let Some(buffer) = preview.read(cx).buffer.clone() else { + return true; + }; + let existing_editor = workspace.items_of_type::(cx).find(|editor| { + editor.read(cx).buffer().read(cx).as_singleton().as_ref() == Some(&buffer) + }); + if let Some(editor) = existing_editor { + workspace.activate_item(&editor, true, true, window, cx); + } else { + let project = workspace.project().clone(); + let editor = cx.new(|cx| Editor::for_buffer(buffer, Some(project), window, cx)); + workspace.active_pane().update(cx, |pane, cx| { + pane.add_item(Box::new(editor), true, true, None, window, cx); + }); + } + true + } + + pub fn register(workspace: &mut Workspace, _window: &mut Window, _cx: &mut Context) { + workspace.register_action(move |workspace, _: &OpenPreview, window, cx| { + Self::open_preview_for_active_editor(workspace, window, cx); + }); workspace.register_action(move |workspace, _: &OpenPreviewToTheSide, window, cx| { if let Some(editor) = Self::resolve_active_item_as_svg_buffer(workspace, cx) @@ -274,6 +384,87 @@ impl SvgPreviewView { cx.notify(); } }); + + workspace.register_action(move |workspace, _: &OpenSource, window, cx| { + if !Self::open_source_for_active_preview(workspace, window, cx) { + cx.propagate(); + } + }); + + workspace.register_action(move |workspace, _: &Toggle, window, cx| { + if !Self::open_source_for_active_preview(workspace, window, cx) + && !Self::open_preview_for_active_editor(workspace, window, cx) + { + cx.propagate(); + } + }); + } +} + +/// A [`project::ProjectItem`] that claims SVG files when the `auto_preview` setting +/// is set to `in_place`, so that opening such files shows their rendered preview instead of an editor. +pub struct SvgPreviewItem { + buffer: Entity, +} + +impl project::ProjectItem for SvgPreviewItem { + fn try_open( + project: &Entity, + path: &ProjectPath, + cx: &mut App, + ) -> Option>>> { + if WorkspaceSettings::get_global(cx).auto_preview != AutoPreview::InPlace + || !project + .read(cx) + .absolute_path(path, cx) + .is_some_and(SvgPreviewView::is_svg_path) + { + return None; + } + let buffer = project.update(cx, |project, cx| project.open_buffer(path.clone(), cx)); + Some(cx.spawn(async move |cx| { + let buffer = buffer.await?; + Ok(cx.new(|_| SvgPreviewItem { buffer })) + })) + } + + fn entry_id(&self, cx: &App) -> Option { + project::ProjectItem::entry_id(self.buffer.read(cx), cx) + } + + fn project_path(&self, cx: &App) -> Option { + project::ProjectItem::project_path(self.buffer.read(cx), cx) + } + + fn is_dirty(&self) -> bool { + // This item is only a carrier between `try_open` and `for_project_item`: the + // preview reports its dirty state through the buffer it renders. + false + } +} + +impl ProjectItem for SvgPreviewView { + type Item = SvgPreviewItem; + + fn for_project_item( + _project: Entity, + _pane: Option<&Pane>, + item: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let buffer = item.read(cx).buffer.clone(); + let subscription = Self::create_buffer_subscription(&buffer, window, cx); + let mut this = Self { + focus_handle: cx.focus_handle(), + buffer: Some(buffer), + current_svg: None, + _buffer_subscription: Some(subscription), + _workspace_subscription: None, + _refresh: Task::ready(()), + }; + this.render_image(window, cx); + this } } @@ -337,5 +528,483 @@ impl Item for SvgPreviewView { Some("svg preview: open") } + fn buffer_kind(&self, _cx: &App) -> ItemBufferKind { + ItemBufferKind::Singleton + } + + fn is_dirty(&self, cx: &App) -> bool { + self.buffer + .as_ref() + .is_some_and(|buffer| buffer.read(cx).is_dirty()) + } + + fn for_each_project_item( + &self, + cx: &App, + f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem), + ) { + // Previews that follow the active editor are not bound to a single file. + if self.is_following() { + return; + } + if let Some(buffer) = &self.buffer { + f(buffer.entity_id(), buffer.read(cx)) + } + } + fn to_item_events(_event: &Self::Event, _f: &mut dyn FnMut(workspace::item::ItemEvent)) {} } + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + use std::sync::Arc; + + use editor::Editor; + use gpui::{BorrowAppContext as _, Focusable as _, TestAppContext, WindowHandle}; + use serde_json::json; + use settings::SettingsStore; + use util::path; + use util::rel_path::rel_path; + use workspace::{AppState, AutoPreview, MultiWorkspace, open_paths}; + use zed_actions::preview::OpenSource; + + use super::SvgPreviewView; + + const SVG_CONTENTS: &str = r#""#; + + #[gpui::test] + async fn auto_preview_opens_svg_files_as_preview(cx: &mut TestAppContext) { + let app_state = init_test(cx); + set_auto_preview(cx, AutoPreview::InPlace); + app_state + .fs + .as_fake() + .insert_tree( + path!("/dir"), + json!({ + "a.svg": SVG_CONTENTS, + "b.txt": "plain text", + }), + ) + .await; + + cx.update(|cx| { + open_paths( + &[PathBuf::from(path!("/dir"))], + app_state.clone(), + workspace::OpenOptions::default(), + cx, + ) + }) + .await + .unwrap(); + cx.run_until_parked(); + + let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::().unwrap()); + open_workspace_path(&multi_workspace, "a.svg", cx).await; + let preview = multi_workspace + .update(cx, |multi_workspace, window, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| { + assert_eq!(workspace.active_pane().read(cx).items_len(), 1); + let preview = workspace + .active_item(cx) + .and_then(|item| item.downcast::()) + .expect("SVG file should have been opened as a preview"); + assert!( + preview.read(cx).focus_handle.contains_focused(window, cx), + "the opened preview should be focused" + ); + preview + }) + }) + .unwrap(); + assert_eq!( + preview + .read_with(cx, |preview, cx| preview + .buffer + .as_ref() + .unwrap() + .read(cx) + .file() + .unwrap() + .path() + .clone()) + .as_ref(), + rel_path("a.svg") + ); + + // Reopening the file should reuse the existing preview. + open_workspace_path(&multi_workspace, "a.svg", cx).await; + multi_workspace + .update(cx, |multi_workspace, _, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| { + assert_eq!(workspace.active_pane().read(cx).items_len(), 1); + assert_eq!( + workspace + .active_item(cx) + .and_then(|item| item.downcast::()), + Some(preview.clone()) + ); + }) + }) + .unwrap(); + + open_workspace_path(&multi_workspace, "b.txt", cx).await; + multi_workspace + .update(cx, |multi_workspace, _, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| { + assert_eq!(workspace.active_pane().read(cx).items_len(), 2); + assert!( + workspace + .active_item(cx) + .and_then(|item| item.downcast::()) + .is_some(), + "non-previewable files should still open in an editor" + ); + }) + }) + .unwrap(); + } + + #[gpui::test] + async fn open_source_opens_an_editor_for_the_previewed_file(cx: &mut TestAppContext) { + let app_state = init_test(cx); + set_auto_preview(cx, AutoPreview::InPlace); + app_state + .fs + .as_fake() + .insert_tree(path!("/dir"), json!({ "a.svg": SVG_CONTENTS })) + .await; + + cx.update(|cx| { + open_paths( + &[PathBuf::from(path!("/dir"))], + app_state.clone(), + workspace::OpenOptions::default(), + cx, + ) + }) + .await + .unwrap(); + cx.run_until_parked(); + + let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::().unwrap()); + open_workspace_path(&multi_workspace, "a.svg", cx).await; + multi_workspace + .update(cx, |_, window, cx| { + window.dispatch_action(Box::new(OpenSource), cx); + }) + .unwrap(); + cx.run_until_parked(); + + let editor = multi_workspace + .update(cx, |multi_workspace, _, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| { + assert_eq!( + workspace.active_pane().read(cx).items_len(), + 2, + "an editor should have been added next to the preview" + ); + workspace + .active_item(cx) + .and_then(|item| item.downcast::()) + .expect("the editor for the previewed file should be active") + }) + }) + .unwrap(); + let editor_path = editor.read_with(cx, |editor, cx| { + editor + .buffer() + .read(cx) + .as_singleton() + .unwrap() + .read(cx) + .file() + .unwrap() + .path() + .clone() + }); + assert_eq!(editor_path.as_ref(), rel_path("a.svg")); + } + + #[gpui::test] + async fn svg_files_open_in_an_editor_by_default(cx: &mut TestAppContext) { + let app_state = init_test(cx); + app_state + .fs + .as_fake() + .insert_tree(path!("/dir"), json!({ "a.svg": SVG_CONTENTS })) + .await; + + cx.update(|cx| { + open_paths( + &[PathBuf::from(path!("/dir"))], + app_state.clone(), + workspace::OpenOptions::default(), + cx, + ) + }) + .await + .unwrap(); + cx.run_until_parked(); + + let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::().unwrap()); + open_workspace_path(&multi_workspace, "a.svg", cx).await; + multi_workspace + .update(cx, |multi_workspace, _, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| { + assert!( + workspace + .active_item(cx) + .and_then(|item| item.downcast::()) + .is_some(), + "with auto_preview off, SVG files should open in an editor" + ); + }) + }) + .unwrap(); + } + + #[gpui::test] + async fn to_the_side_auto_preview_follows_editors_and_closes_with_them( + cx: &mut TestAppContext, + ) { + let app_state = init_test(cx); + set_auto_preview(cx, AutoPreview::ToTheSide); + app_state + .fs + .as_fake() + .insert_tree(path!("/dir"), json!({ "a.svg": SVG_CONTENTS })) + .await; + + cx.update(|cx| { + open_paths( + &[PathBuf::from(path!("/dir"))], + app_state.clone(), + workspace::OpenOptions::default(), + cx, + ) + }) + .await + .unwrap(); + cx.run_until_parked(); + + let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::().unwrap()); + open_workspace_path(&multi_workspace, "a.svg", cx).await; + multi_workspace + .update(cx, |multi_workspace, window, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| { + let editor = workspace + .active_item(cx) + .and_then(|item| item.downcast::()) + .expect("SVG files should still open in an editor"); + assert!( + editor + .read(cx) + .focus_handle(cx) + .contains_focused(window, cx), + "the editor should keep the focus" + ); + assert_eq!( + workspace.panes().len(), + 2, + "a pane should have been split for the preview" + ); + let preview = workspace + .items_of_type::(cx) + .next() + .expect("a preview should have been opened to the side"); + assert!(preview.read(cx).is_following()); + assert_ne!( + workspace.pane_for(&preview), + Some(workspace.active_pane().clone()), + "the preview should live in the other pane" + ); + }) + }) + .unwrap(); + + multi_workspace + .update(cx, |multi_workspace, window, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| { + let editors = workspace.items_of_type::(cx).collect::>(); + for editor in editors { + let pane = workspace.pane_for(&editor).unwrap(); + pane.update(cx, |pane, cx| { + pane.remove_item(editor.entity_id(), false, true, window, cx) + }); + } + }) + }) + .unwrap(); + cx.run_until_parked(); + + multi_workspace + .update(cx, |multi_workspace, _, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| { + assert_eq!( + workspace.items_of_type::(cx).count(), + 0, + "the preview should close when no SVG editors remain" + ); + assert_eq!( + workspace.panes().len(), + 1, + "the preview pane should be removed with the preview" + ); + }) + }) + .unwrap(); + } + + #[gpui::test] + async fn single_side_preview_is_shared_between_preview_kinds(cx: &mut TestAppContext) { + let app_state = init_test(cx); + cx.update(markdown_preview::init); + set_auto_preview(cx, AutoPreview::ToTheSide); + app_state + .fs + .as_fake() + .insert_tree( + path!("/dir"), + json!({ + "a.md": "# a", + "b.svg": SVG_CONTENTS, + }), + ) + .await; + + cx.update(|cx| { + open_paths( + &[PathBuf::from(path!("/dir"))], + app_state.clone(), + workspace::OpenOptions::default(), + cx, + ) + }) + .await + .unwrap(); + cx.run_until_parked(); + + let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::().unwrap()); + open_workspace_path(&multi_workspace, "a.md", cx).await; + let markdown_preview_pane = multi_workspace + .update(cx, |multi_workspace, _, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| { + assert_eq!(workspace.panes().len(), 2); + let preview = workspace + .items_of_type::(cx) + .next() + .expect("a markdown preview should have been opened to the side"); + let pane = workspace.pane_for(&preview).unwrap(); + assert_eq!(pane.read(cx).items_len(), 1); + pane + }) + }) + .unwrap(); + + open_workspace_path(&multi_workspace, "b.svg", cx).await; + multi_workspace + .update(cx, |multi_workspace, _, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| { + assert_eq!( + workspace.panes().len(), + 2, + "the preview pane should be reused for the other preview kind" + ); + assert_eq!( + workspace + .items_of_type::(cx) + .count(), + 0, + "the markdown preview should have been replaced" + ); + let preview = workspace + .items_of_type::(cx) + .next() + .expect("an SVG preview should have taken the preview tab slot"); + assert!(preview.read(cx).is_following()); + let pane = workspace.pane_for(&preview).unwrap(); + assert_eq!(pane, markdown_preview_pane); + assert_eq!( + pane.read(cx).items_len(), + 1, + "a single dynamic preview tab should be kept to the side" + ); + }) + }) + .unwrap(); + + open_workspace_path(&multi_workspace, "a.md", cx).await; + multi_workspace + .update(cx, |multi_workspace, _, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| { + assert_eq!(workspace.panes().len(), 2); + assert_eq!(workspace.items_of_type::(cx).count(), 0); + let preview = workspace + .items_of_type::(cx) + .next() + .expect("the markdown preview should be back in the preview tab slot"); + let pane = workspace.pane_for(&preview).unwrap(); + assert_eq!(pane.read(cx).items_len(), 1); + }) + }) + .unwrap(); + } + + fn set_auto_preview(cx: &mut TestAppContext, auto_preview: AutoPreview) { + cx.update(|cx| { + cx.update_global::(|settings, cx| { + settings.update_user_settings(cx, |settings| { + settings.workspace.auto_preview = Some(auto_preview); + }); + }); + }); + } + + async fn open_workspace_path( + multi_workspace: &WindowHandle, + file: &str, + cx: &mut TestAppContext, + ) { + let open_task = multi_workspace + .update(cx, |multi_workspace, window, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| { + let worktree_id = workspace + .project() + .read(cx) + .worktrees(cx) + .next() + .unwrap() + .read(cx) + .id(); + workspace.open_path((worktree_id, rel_path(file)), None, true, window, cx) + }) + }) + .unwrap(); + open_task.await.unwrap(); + cx.run_until_parked(); + } + + fn init_test(cx: &mut TestAppContext) -> Arc { + cx.update(|cx| { + let state = AppState::test(cx); + editor::init(cx); + crate::init(cx); + state + }) + } +} diff --git a/crates/workspace/src/auto_preview.rs b/crates/workspace/src/auto_preview.rs new file mode 100644 index 00000000000..4436ce5aa6e --- /dev/null +++ b/crates/workspace/src/auto_preview.rs @@ -0,0 +1,356 @@ +use gpui::{App, Context, EntityId, Focusable as _, Global, Window}; +use settings::Settings as _; + +use crate::{AutoPreview, ItemHandle, SplitDirection, Workspace, WorkspaceSettings}; + +/// Integration point for preview views (e.g. Markdown or SVG previews) that can +/// automatically accompany or replace text editors, driven by the `auto_preview` setting. +#[derive(Clone, Copy)] +pub struct AutoPreviewProvider { + /// Whether this provider can preview the file shown by the given item. + pub applies_to: fn(&dyn ItemHandle, &App) -> bool, + /// Whether any items this provider applies to are open in the workspace. + pub has_open_sources: fn(&Workspace, &App) -> bool, + /// Whether the given item is this provider's preview that follows the active editor. + pub is_follow_view: fn(&dyn ItemHandle, &App) -> bool, + /// Whether the given item is this provider's preview of a single file. + pub is_preview_view: fn(&dyn ItemHandle, &App) -> bool, + /// Builds a preview that follows the workspace's active editor. + pub build_follow_view: + fn(&mut Workspace, &mut Window, &mut Context) -> Option>, + /// Builds a preview of the file shown by the given item. + pub build_preview_view: fn( + &mut Workspace, + &dyn ItemHandle, + &mut Window, + &mut Context, + ) -> Option>, + /// Returns an editor for the file shown by the given preview item. + pub source_view: fn( + &mut Workspace, + &dyn ItemHandle, + &mut Window, + &mut Context, + ) -> Option>, +} + +/// Per-workspace state of the side preview managed by [`sync_side_preview`]. +#[derive(Default)] +pub(crate) struct AutoPreviewState { + /// The follow preview currently managed in this workspace. + follow_view_id: Option, + /// The item that was active when the user closed the side preview: the preview is + /// not reopened until another item gets activated. + suppressed_for_item: Option, +} + +#[derive(Default)] +struct GlobalAutoPreviewProviders(Vec); + +impl Global for GlobalAutoPreviewProviders {} + +pub fn register_auto_preview_provider(provider: AutoPreviewProvider, cx: &mut App) { + cx.default_global::() + .0 + .push(provider); +} + +fn providers(cx: &App) -> Vec { + cx.try_global::() + .map(|providers| providers.0.clone()) + .unwrap_or_default() +} + +/// Keeps a single follow-mode preview in a pane to the side of previewable editors +/// when the `auto_preview` setting is set to `to_the_side`: the preview is created when +/// a previewable editor becomes active, switches its kind together with the active +/// editor's file type, and is removed when no previewable editors remain open. +pub(crate) fn sync_side_preview( + workspace: &mut Workspace, + window: &mut Window, + cx: &mut Context, +) { + if WorkspaceSettings::get_global(cx).auto_preview != AutoPreview::ToTheSide { + return; + } + let providers = providers(cx); + if providers.is_empty() { + return; + } + let follow_views = collect_follow_views(workspace, &providers, cx); + let active_item = workspace.active_item(cx); + let active_item_id = active_item.as_ref().map(|item| item.item_id()); + + // The managed preview disappeared without the sync removing it: the user has + // closed its tab. Pause reopening until another item gets activated. + if let Some(managed_id) = workspace.auto_preview_state.follow_view_id + && !follow_views + .iter() + .any(|(_, view)| view.item_id() == managed_id) + { + workspace.auto_preview_state.follow_view_id = None; + workspace.auto_preview_state.suppressed_for_item = active_item_id; + } + if workspace.auto_preview_state.suppressed_for_item.is_some() + && workspace.auto_preview_state.suppressed_for_item != active_item_id + { + workspace.auto_preview_state.suppressed_for_item = None; + } + + let active_provider = active_item.as_ref().and_then(|item| { + providers + .iter() + .position(|provider| (provider.applies_to)(item.as_ref(), cx)) + }); + let Some(provider_index) = active_provider else { + for (index, view) in follow_views { + if !(providers[index].has_open_sources)(workspace, cx) + && let Some(pane) = workspace.pane_for(view.as_ref()) + { + pane.update(cx, |pane, cx| { + pane.remove_item(view.item_id(), false, true, window, cx); + }); + if workspace.auto_preview_state.follow_view_id == Some(view.item_id()) { + workspace.auto_preview_state.follow_view_id = None; + } + } + } + return; + }; + + if workspace.auto_preview_state.suppressed_for_item.is_some() { + return; + } + + let existing = follow_views + .iter() + .find(|(index, _)| *index == provider_index) + .map(|(_, view)| view.boxed_clone()); + + // Remove follow previews of other types: a single, dynamic preview is kept to the + // side, and the new preview takes the tab slot vacated by the previous one. + let mut vacated_slot = None; + for (index, view) in &follow_views { + if *index == provider_index { + continue; + } + let Some(pane) = workspace.pane_for(view.as_ref()) else { + continue; + }; + let reuse_slot = existing.is_none() && vacated_slot.is_none(); + if reuse_slot && let Some(item_index) = pane.read(cx).index_for_item(view.as_ref()) { + vacated_slot = Some((pane.clone(), item_index)); + } + pane.update(cx, |pane, cx| { + pane.remove_item(view.item_id(), false, !reuse_slot, window, cx); + }); + if workspace.auto_preview_state.follow_view_id == Some(view.item_id()) { + workspace.auto_preview_state.follow_view_id = None; + } + } + + if let Some(view) = existing { + workspace.auto_preview_state.follow_view_id = Some(view.item_id()); + if let Some(pane) = workspace.pane_for(view.as_ref()) { + pane.update(cx, |pane, cx| { + if let Some(index) = pane.index_for_item(view.as_ref()) + && pane + .active_item() + .is_none_or(|item| item.item_id() != view.item_id()) + { + pane.activate_item(index, false, false, window, cx); + } + }); + } + } else { + let Some(view) = (providers[provider_index].build_follow_view)(workspace, window, cx) + else { + return; + }; + workspace.auto_preview_state.follow_view_id = Some(view.item_id()); + let (pane, destination_index) = match vacated_slot { + Some((pane, index)) => (pane, Some(index)), + None => { + let pane = workspace + .find_pane_in_direction(SplitDirection::Right, cx) + .unwrap_or_else(|| { + workspace.split_pane( + workspace.active_pane().clone(), + SplitDirection::Right, + window, + cx, + ) + }); + (pane, None) + } + }; + pane.update(cx, |pane, cx| { + pane.add_item(view, false, false, destination_index, window, cx); + }); + // Splitting a pane moves the focus into it: return the focus to the source item. + if let Some(item) = active_item { + item.item_focus_handle(cx).focus(window, cx); + } + } +} + +/// Applies a change of the `auto_preview` setting to the already open items: +/// previewable editors and their previews are converted into each other in place, +/// and the side preview is created or removed. +pub(crate) fn auto_preview_setting_changed( + workspace: &mut Workspace, + window: &mut Window, + cx: &mut Context, +) { + let providers = providers(cx); + if providers.is_empty() { + return; + } + workspace.auto_preview_state = AutoPreviewState::default(); + match WorkspaceSettings::get_global(cx).auto_preview { + AutoPreview::Off => { + remove_follow_views(workspace, &providers, window, cx); + convert_previews_to_sources(workspace, &providers, window, cx); + } + AutoPreview::InPlace => { + remove_follow_views(workspace, &providers, window, cx); + convert_items(workspace, window, cx, |workspace, item, window, cx| { + let provider = providers + .iter() + .find(|provider| (provider.applies_to)(item, cx))?; + (provider.build_preview_view)(workspace, item, window, cx) + }); + } + AutoPreview::ToTheSide => { + convert_previews_to_sources(workspace, &providers, window, cx); + sync_side_preview(workspace, window, cx); + } + } +} + +fn collect_follow_views( + workspace: &Workspace, + providers: &[AutoPreviewProvider], + cx: &App, +) -> Vec<(usize, Box)> { + let mut views = Vec::new(); + for pane in workspace.panes() { + for item in pane.read(cx).items() { + if let Some(index) = providers + .iter() + .position(|provider| (provider.is_follow_view)(item.as_ref(), cx)) + { + views.push((index, item.boxed_clone())); + } + } + } + views +} + +fn remove_follow_views( + workspace: &mut Workspace, + providers: &[AutoPreviewProvider], + window: &mut Window, + cx: &mut Context, +) { + for (_, view) in collect_follow_views(workspace, providers, cx) { + if let Some(pane) = workspace.pane_for(view.as_ref()) { + pane.update(cx, |pane, cx| { + pane.remove_item(view.item_id(), false, true, window, cx); + }); + } + } +} + +fn convert_previews_to_sources( + workspace: &mut Workspace, + providers: &[AutoPreviewProvider], + window: &mut Window, + cx: &mut Context, +) { + convert_items(workspace, window, cx, |workspace, item, window, cx| { + let provider = providers + .iter() + .find(|provider| (provider.is_preview_view)(item, cx))?; + (provider.source_view)(workspace, item, window, cx) + }); +} + +/// Replaces items in their tab slots with the converted counterparts, keeping the +/// panes' active tabs, ephemeral (preview tab) statuses and the focus in place. +fn convert_items( + workspace: &mut Workspace, + window: &mut Window, + cx: &mut Context, + mut convert: impl FnMut( + &mut Workspace, + &dyn ItemHandle, + &mut Window, + &mut Context, + ) -> Option>, +) { + let panes = workspace.panes().to_vec(); + for pane in panes { + let pane_had_focus = pane.read(cx).focus_handle(cx).contains_focused(window, cx); + let original_active_id = pane.read(cx).active_item().map(|item| item.item_id()); + let items = pane + .read(cx) + .items() + .map(|item| item.boxed_clone()) + .collect::>(); + let mut converted = false; + let mut active_replacement = None; + for item in items { + let Some(new_item) = convert(workspace, item.as_ref(), window, cx) else { + continue; + }; + // The replacement may already be open elsewhere (e.g. an editor opened via + // `preview::OpenSource`): drop the converted item and let that tab stand. + let already_open = workspace.pane_for(new_item.as_ref()).is_some(); + let was_active = original_active_id == Some(item.item_id()); + pane.update(cx, |pane, cx| { + let Some(index) = pane.index_for_item(item.as_ref()) else { + return; + }; + let was_ephemeral = pane.is_active_preview_item(item.item_id()); + if !already_open { + pane.add_item( + new_item.boxed_clone(), + false, + false, + Some(index), + window, + cx, + ); + } + pane.remove_item(item.item_id(), false, already_open, window, cx); + if was_ephemeral && !already_open { + pane.set_preview_item_id(Some(new_item.item_id()), cx); + } + converted = true; + }); + if was_active && !already_open { + active_replacement = Some(new_item); + } + } + if !converted || !workspace.panes().contains(&pane) { + continue; + } + // Adding items disturbs the pane's active tab: restore it (or its replacement). + let target = active_replacement.or_else(|| { + let pane = pane.read(cx); + original_active_id.and_then(|id| { + pane.items() + .find(|item| item.item_id() == id) + .map(|item| item.boxed_clone()) + }) + }); + if let Some(target) = target { + pane.update(cx, |pane, cx| { + if let Some(index) = pane.index_for_item(target.as_ref()) { + pane.activate_item(index, pane_had_focus, pane_had_focus, window, cx); + } + }); + } + } +} diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index 47ae6942010..1daa7b33dff 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -885,6 +885,10 @@ impl Pane { cx.notify(); } + pub fn workspace(&self) -> &WeakEntity { + &self.workspace + } + pub fn nav_history_for_item(&self, item: &Entity) -> ItemNavHistory { ItemNavHistory { history: self.nav_history.clone(), @@ -1279,10 +1283,15 @@ impl Pane { None }; + let item_type = item.to_any_view().entity_type(); let existing_item_index = self.items.iter().position(|existing_item| { if existing_item.item_id() == item.item_id() { true - } else if existing_item.buffer_kind(cx) == ItemBufferKind::Singleton { + } else if existing_item.buffer_kind(cx) == ItemBufferKind::Singleton + // Different view types may represent the same project entry (e.g. an editor + // and a preview of the same file) and should get separate tabs. + && existing_item.to_any_view().entity_type() == item_type + { existing_item .project_entry_ids(cx) .first() diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 0fc345b1b7c..f664298cc4f 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -1,4 +1,5 @@ pub mod active_file_name; +mod auto_preview; pub mod dock; pub mod history_manager; pub mod invalid_item_view; @@ -115,6 +116,7 @@ use settings::{ update_settings_file, }; +pub use auto_preview::{AutoPreviewProvider, register_auto_preview_provider}; use sqlez::{ bindable::{Bind, Column, StaticColumnCount}, statement::Statement, @@ -155,7 +157,7 @@ use util::{ }; use uuid::Uuid; pub use workspace_settings::{ - AutosaveSetting, BottomDockLayout, EncodingDisplayOptions, FocusFollowsMouse, + AutoPreview, AutosaveSetting, BottomDockLayout, EncodingDisplayOptions, FocusFollowsMouse, RestoreOnStartupBehavior, StatusBarSettings, TabBarSettings, WorkspaceSettings, }; use zed_actions::{Spawn, feedback::FileBugReport, theme::ToggleMode}; @@ -1423,6 +1425,7 @@ pub struct Workspace { active_workspace_id: Option>>, active_worktree_creation: ActiveWorktreeCreation, deferred_save_items: Vec>, + auto_preview_state: auto_preview::AutoPreviewState, } impl EventEmitter for Workspace {} @@ -1762,6 +1765,22 @@ impl Workspace { Self::serialize_items(&this, serializable_items_rx, cx).await }); + cx.subscribe_in(&cx.entity(), window, |this, _, event, window, cx| { + if matches!(event, Event::ActiveItemChanged | Event::ItemRemoved { .. }) { + auto_preview::sync_side_preview(this, window, cx); + } + }) + .detach(); + + let mut auto_preview_setting = WorkspaceSettings::get_global(cx).auto_preview; + cx.observe_global_in::(window, move |this, window, cx| { + let new_setting = WorkspaceSettings::get_global(cx).auto_preview; + if std::mem::replace(&mut auto_preview_setting, new_setting) != new_setting { + auto_preview::auto_preview_setting_changed(this, window, cx); + } + }) + .detach(); + let subscriptions = vec![ cx.observe_window_activation(window, Self::on_window_activation_changed), cx.observe_window_bounds(window, move |this, window, cx| { @@ -1870,6 +1889,7 @@ impl Workspace { open_in_dev_container: false, _dev_container_task: None, deferred_save_items: Vec::new(), + auto_preview_state: auto_preview::AutoPreviewState::default(), } } diff --git a/crates/workspace/src/workspace_settings.rs b/crates/workspace/src/workspace_settings.rs index b450a3260ca..60b6345519c 100644 --- a/crates/workspace/src/workspace_settings.rs +++ b/crates/workspace/src/workspace_settings.rs @@ -5,7 +5,7 @@ use collections::HashMap; use serde::Deserialize; use settings::CommandAliasTarget; pub use settings::{ - AutosaveSetting, BottomDockLayout, EncodingDisplayOptions, InactiveOpacity, + AutoPreview, AutosaveSetting, BottomDockLayout, EncodingDisplayOptions, InactiveOpacity, PaneSplitDirectionHorizontal, PaneSplitDirectionVertical, RegisterSetting, RestoreOnStartupBehavior, Settings, }; @@ -39,6 +39,7 @@ pub struct WorkspaceSettings { pub zoomed_padding: bool, pub window_decorations: settings::WindowDecorations, pub focus_follows_mouse: FocusFollowsMouse, + pub auto_preview: AutoPreview, } #[derive(Copy, Clone, Deserialize)] @@ -139,6 +140,7 @@ impl Settings for WorkspaceSettings { .unwrap_or(250), ), }, + auto_preview: workspace.auto_preview.unwrap(), } } } diff --git a/crates/zed/src/zed/quick_action_bar.rs b/crates/zed/src/zed/quick_action_bar.rs index d1ffb58b792..37eda7e9b6d 100644 --- a/crates/zed/src/zed/quick_action_bar.rs +++ b/crates/zed/src/zed/quick_action_bar.rs @@ -88,14 +88,15 @@ impl QuickActionBar { let new_show = EditorSettings::get_global(cx).toolbar.quick_actions; if new_show != self.show { self.show = new_show; - cx.emit(ToolbarItemEvent::ChangeLocation( - self.get_toolbar_item_location(), - )); + let new_location = self.get_toolbar_item_location(cx); + cx.emit(ToolbarItemEvent::ChangeLocation(new_location)); } } - fn get_toolbar_item_location(&self) -> ToolbarItemLocation { - if self.show && self.active_editor().is_some() { + fn get_toolbar_item_location(&self, cx: &mut Context) -> ToolbarItemLocation { + if self.show + && (self.active_editor().is_some() || self.render_open_source_button(cx).is_some()) + { ToolbarItemLocation::PrimaryRight } else { ToolbarItemLocation::Hidden @@ -106,6 +107,12 @@ impl QuickActionBar { impl Render for QuickActionBar { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let Some(editor) = self.active_editor() else { + if let Some(open_source_button) = self.render_open_source_button(cx) { + return h_flex() + .id("quick action bar") + .gap(DynamicSpacing::Base01.rems(cx)) + .child(open_source_button); + } return div().id("empty quick action bar"); }; @@ -788,6 +795,6 @@ impl ToolbarItemView for QuickActionBar { })); } } - self.get_toolbar_item_location() + self.get_toolbar_item_location(cx) } } diff --git a/crates/zed/src/zed/quick_action_bar/preview.rs b/crates/zed/src/zed/quick_action_bar/preview.rs index 01e2d164d7d..94e27cbbc36 100644 --- a/crates/zed/src/zed/quick_action_bar/preview.rs +++ b/crates/zed/src/zed/quick_action_bar/preview.rs @@ -1,16 +1,14 @@ use csv_preview::{ - CsvPreviewView, OpenPreview as CsvOpenPreview, OpenPreviewToTheSide as CsvOpenPreviewToTheSide, - TabularDataPreviewFeatureFlag, + CsvPreviewView, OpenPreviewToTheSide as CsvOpenPreviewToTheSide, TabularDataPreviewFeatureFlag, }; use feature_flags::FeatureFlagAppExt as _; -use gpui::{AnyElement, Modifiers, WeakEntity}; +use gpui::{Action as _, AnyElement, Modifiers, WeakEntity}; use markdown_preview::{ - OpenPreview as MarkdownOpenPreview, OpenPreviewToTheSide as MarkdownOpenPreviewToTheSide, + OpenPreviewToTheSide as MarkdownOpenPreviewToTheSide, markdown_preview_view::MarkdownPreviewView, }; use svg_preview::{ - OpenPreview as SvgOpenPreview, OpenPreviewToTheSide as SvgOpenPreviewToTheSide, - svg_preview_view::SvgPreviewView, + OpenPreviewToTheSide as SvgOpenPreviewToTheSide, svg_preview_view::SvgPreviewView, }; use ui::{Tooltip, prelude::*, text_for_keystroke}; use workspace::Workspace; @@ -25,6 +23,31 @@ enum PreviewType { } impl QuickActionBar { + pub fn render_open_source_button(&self, _cx: &mut Context) -> Option { + let item = self.active_item.as_ref()?; + let (button_id, tooltip_text) = if item.downcast::().is_some() { + ("edit-markdown-source", "Edit Markdown") + } else if item.downcast::().is_some() { + ("edit-svg-source", "Edit SVG") + } else if item.downcast::().is_some() { + ("edit-csv-source", "Edit CSV") + } else { + return None; + }; + + let button = IconButton::new(button_id, IconName::Pencil) + .icon_size(IconSize::Small) + .style(ButtonStyle::Subtle) + .tooltip(move |_window, cx| { + Tooltip::for_action(tooltip_text, &zed_actions::preview::Toggle, cx) + }) + .on_click(move |_, window, cx| { + window.dispatch_action(zed_actions::preview::Toggle.boxed_clone(), cx); + }); + + Some(button.into_any_element()) + } + pub fn render_preview_button( &self, workspace_handle: WeakEntity, @@ -51,30 +74,23 @@ impl QuickActionBar { let preview_type = preview_type?; - let (button_id, tooltip_text, open_action, open_to_side_action, open_action_for_tooltip) = - match preview_type { - PreviewType::Markdown => ( - "toggle-markdown-preview", - "Preview Markdown", - Box::new(MarkdownOpenPreview) as Box, - Box::new(MarkdownOpenPreviewToTheSide) as Box, - &markdown_preview::OpenPreview as &dyn gpui::Action, - ), - PreviewType::Svg => ( - "toggle-svg-preview", - "Preview SVG", - Box::new(SvgOpenPreview) as Box, - Box::new(SvgOpenPreviewToTheSide) as Box, - &svg_preview::OpenPreview as &dyn gpui::Action, - ), - PreviewType::Csv => ( - "toggle-csv-preview", - "Preview CSV", - Box::new(CsvOpenPreview) as Box, - Box::new(CsvOpenPreviewToTheSide) as Box, - &csv_preview::OpenPreview as &dyn gpui::Action, - ), - }; + let (button_id, tooltip_text, open_to_side_action) = match preview_type { + PreviewType::Markdown => ( + "toggle-markdown-preview", + "Preview Markdown", + Box::new(MarkdownOpenPreviewToTheSide) as Box, + ), + PreviewType::Svg => ( + "toggle-svg-preview", + "Preview SVG", + Box::new(SvgOpenPreviewToTheSide) as Box, + ), + PreviewType::Csv => ( + "toggle-csv-preview", + "Preview CSV", + Box::new(CsvOpenPreviewToTheSide) as Box, + ), + }; let alt_click = gpui::Keystroke { key: "click".into(), @@ -88,7 +104,7 @@ impl QuickActionBar { .tooltip(move |_window, cx| { Tooltip::with_meta( tooltip_text, - Some(open_action_for_tooltip), + Some(&zed_actions::preview::Toggle), format!( "{} to open in a split", text_for_keystroke(&alt_click.modifiers, &alt_click.key, cx) @@ -102,7 +118,7 @@ impl QuickActionBar { if window.modifiers().alt { window.dispatch_action(open_to_side_action.boxed_clone(), cx); } else { - window.dispatch_action(open_action.boxed_clone(), cx); + window.dispatch_action(zed_actions::preview::Toggle.boxed_clone(), cx); } }); } diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index 12b34def15c..d1209915eb6 100644 --- a/crates/zed_actions/src/lib.rs +++ b/crates/zed_actions/src/lib.rs @@ -856,6 +856,18 @@ pub mod wsl_actions { } pub mod preview { + use gpui::actions; + + actions!( + preview, + [ + /// Opens a text editor for the file shown in the current preview. + OpenSource, + /// Toggles between a previewable file's text editor and its preview. + Toggle, + ] + ); + pub mod markdown { use gpui::actions; diff --git a/docs/src/migrate/vs-code.md b/docs/src/migrate/vs-code.md index 5bc8db48a2f..6fa01e5ea8f 100644 --- a/docs/src/migrate/vs-code.md +++ b/docs/src/migrate/vs-code.md @@ -123,6 +123,7 @@ The following VS Code settings are automatically imported when you use **Import | `workbench.editor.editorActionsLocation` | `tab_bar.show_tab_bar_buttons` | | `workbench.editor.limit.enabled` / `value` | `max_tabs` | | `workbench.editor.restoreViewState` | `restore_on_file_reopen` | +| `workbench.editorAssociations` | `auto_preview` | | `workbench.statusBar.visible` | `status_bar.show` | **Project Panel (File Explorer)** @@ -173,14 +174,15 @@ Zed doesn’t import extensions or keybindings, but this import gets core editor You can configure most settings in the Settings Editor ({#kb zed::OpenSettings}). For advanced settings, run {#action zed::OpenSettingsFile} from the Command Palette to edit your settings file directly. Here’s how common VS Code settings translate: -| VS Code | Zed | Notes | -| --- | --- | --- | -| editor.fontFamily | buffer_font_family | Zed uses Zed Mono by default | -| editor.fontSize | buffer_font_size | Set in pixels | -| editor.tabSize | tab_size | Can override per language | -| editor.insertSpaces | insert_spaces | Boolean | -| editor.formatOnSave | format_on_save | Works with formatter enabled | -| editor.wordWrap | soft_wrap | Supports optional wrap column | + +| VS Code | Zed | Notes | +| ------------------- | ------------------ | ----------------------------- | +| editor.fontFamily | buffer_font_family | Zed uses Zed Mono by default | +| editor.fontSize | buffer_font_size | Set in pixels | +| editor.tabSize | tab_size | Can override per language | +| editor.insertSpaces | insert_spaces | Boolean | +| editor.formatOnSave | format_on_save | Works with formatter enabled | +| editor.wordWrap | soft_wrap | Supports optional wrap column | Zed also supports per-project settings. You can find these in the Settings Editor as well. diff --git a/docs/src/reference/all-settings.md b/docs/src/reference/all-settings.md index 2ded300ce5d..009a908724a 100644 --- a/docs/src/reference/all-settings.md +++ b/docs/src/reference/all-settings.md @@ -214,6 +214,40 @@ Add an extension here with `false` to pin it to its currently installed version. Selecting **Install Another Version…** from an extension's `⋯` menu on the Extensions page ({#action zed::Extensions}) does this automatically. +## Auto Preview + +- Description: How to open files that have a preview available for their file type (e.g. Markdown or SVG files). +- Setting: `auto_preview` +- Default: `off` + +**Options** + +1. Open previewable files in a text editor, previews have to be opened manually: + +```json [settings] +{ + "auto_preview": "off" +} +``` + +2. Open previewable files in their preview instead of a text editor. The text editor can be opened from the preview with the {#action preview::OpenSource} action: + +```json [settings] +{ + "auto_preview": "in_place" +} +``` + +3. Open previewable files in a text editor and keep a preview following the active editor in a pane to the side: + +```json [settings] +{ + "auto_preview": "to_the_side" +} +``` + +The {#action preview::Toggle} action switches between a previewable file's text editor and its preview. + ## Autosave - Description: When to automatically save edited buffers.