diff --git a/Cargo.lock b/Cargo.lock index 3f71a7000d7..0e5326ec7ab 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,17 @@ dependencies = [ name = "svg_preview" version = "0.1.0" dependencies = [ + "anyhow", + "editor", "file_icons", "gpui", "language", "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..e1e82f9db5c 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -1320,6 +1320,7 @@ { "context": "MarkdownPreview", "bindings": { + "ctrl-shift-v": "preview::OpenSource", "pageup": "markdown::ScrollPageUp", "pagedown": "markdown::ScrollPageDown", "up": "markdown::ScrollUp", @@ -1332,6 +1333,12 @@ "ctrl-f": "buffer_search::Deploy", }, }, + { + "context": "SvgPreview", + "bindings": { + "ctrl-shift-v": "preview::OpenSource", + }, + }, { "context": "KeymapEditor", "use_key_equivalents": true, diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index de0f8bd2b46..07c0d3669db 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -1413,6 +1413,7 @@ { "context": "MarkdownPreview", "bindings": { + "cmd-shift-v": "preview::OpenSource", "pageup": "markdown::ScrollPageUp", "pagedown": "markdown::ScrollPageDown", "up": "markdown::ScrollUp", @@ -1424,6 +1425,12 @@ "cmd-f": "buffer_search::Deploy", }, }, + { + "context": "SvgPreview", + "bindings": { + "cmd-shift-v": "preview::OpenSource", + }, + }, { "context": "KeymapEditor", "use_key_equivalents": true, diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index acc2a82e18e..d53a8ffa3bb 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -1344,6 +1344,7 @@ "context": "MarkdownPreview", "use_key_equivalents": true, "bindings": { + "ctrl-shift-v": "preview::OpenSource", "pageup": "markdown::ScrollPageUp", "pagedown": "markdown::ScrollPageDown", "up": "markdown::ScrollUp", @@ -1356,6 +1357,13 @@ "ctrl-f": "buffer_search::Deploy", }, }, + { + "context": "SvgPreview", + "use_key_equivalents": true, + "bindings": { + "ctrl-shift-v": "preview::OpenSource", + }, + }, { "context": "KeymapEditor", "use_key_equivalents": true, diff --git a/assets/settings/default.json b/assets/settings/default.json index 0e42d27e980..5e0e423a959 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -182,6 +182,10 @@ // // Default: true "restore_on_file_reopen": true, + // Whether to open files in their preview instead of a text editor, when a + // preview is available for the file type (e.g. Markdown or SVG files). + // The text editor can be opened from the preview with the `preview::OpenSource` action. + "auto_preview": false, // 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..e9c1edd0ad5 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::{Item, Pane, SplitDirection, Workspace, WorkspaceSettings}; +use zed_actions::preview::OpenSource; use crate::{parser::EditorState, settings::CsvPreviewSettings, types::TableLikeContent}; @@ -54,6 +59,7 @@ pub struct CsvPreviewView { } pub fn init(cx: &mut App) { + workspace::register_project_item::(cx); cx.observe_new(|workspace: &mut Workspace, _, _| { CsvPreviewView::register(workspace); }) @@ -108,8 +114,8 @@ impl CsvPreviewView { cx.notify(); } })) - .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,6 +152,30 @@ impl CsvPreviewView { }); cx.notify(); } + }), + ) + .on_action(cx.listener( + |workspace, _: &OpenSource, window, cx| { + let Some(preview) = workspace + .active_item(cx) + .and_then(|item| item.downcast::()) + else { + cx.propagate(); + return; + }; + 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, + ); + }); + } }, )) }) @@ -153,6 +183,10 @@ impl CsvPreviewView { } fn new(editor: &Entity, cx: &mut Context) -> Entity { + cx.new(|cx| Self::build(editor.clone(), cx)) + } + + 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 +194,39 @@ impl CsvPreviewView { >()) }); - cx.new(|cx| { - let subscription = cx.subscribe( + let 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); + } + _ => {} + }; + }, + ); + + let mut view = CsvPreviewView { + 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 +292,21 @@ impl CsvPreviewView { Self::is_csv_file(&editor, cx).then_some(editor) } + fn is_csv_path(path: &ProjectPath) -> bool { + path.path + .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 +356,81 @@ 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 enabled, 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 + || !CsvPreviewView::is_csv_path(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/markdown_preview/src/markdown_preview.rs b/crates/markdown_preview/src/markdown_preview.rs index caf516b212f..7933a0f7079 100644 --- a/crates/markdown_preview/src/markdown_preview.rs +++ b/crates/markdown_preview/src/markdown_preview.rs @@ -36,6 +36,7 @@ actions!( pub fn init(cx: &mut App) { workspace::register_serializable_item::(cx); + workspace::register_project_item::(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..9be23da0156 100644 --- a/crates/markdown_preview/src/markdown_preview_view.rs +++ b/crates/markdown_preview/src/markdown_preview_view.rs @@ -14,25 +14,30 @@ 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::{ + ItemId, MultiWorkspace, Pane, Workspace, WorkspaceId, WorkspaceSettings, delete_unloaded_items, +}; +use zed_actions::preview::OpenSource; use zed_actions::{DecreaseBufferFontSize, IncreaseBufferFontSize, ResetBufferFontSize}; use crate::markdown_preview_settings::MarkdownPreviewSettings; @@ -137,6 +142,29 @@ impl MarkdownPreviewView { } }); + workspace.register_action(move |workspace, _: &OpenSource, window, cx| { + let Some(preview) = workspace + .active_item(cx) + .and_then(|item| item.downcast::()) + else { + cx.propagate(); + return; + }; + let Some(editor) = preview + .read(cx) + .active_editor + .as_ref() + .map(|state| state.editor.clone()) + else { + return; + }; + 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); + }); + } + }); + 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 @@ -254,73 +282,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,6 +421,16 @@ impl MarkdownPreviewView { .detach(); } + 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: &mut Context) -> bool { let buffer = editor.read(cx).buffer().read(cx); if let Some(buffer) = buffer.as_singleton() @@ -1125,6 +1181,7 @@ impl Item for MarkdownPreviewView { window: &mut Window, cx: &mut Context, ) { + self.workspace = workspace.weak_handle(); if self.mode != MarkdownPreviewMode::Default { return; } @@ -1208,6 +1265,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 +1292,82 @@ impl Item for MarkdownPreviewView { } } +/// A [`project::ProjectItem`] that claims markdown files when the `auto_preview` setting +/// is enabled, 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 + || !MarkdownPreviewView::is_markdown_path(path.path.as_std_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); diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index 0d2f5511430..02aadcf0e68 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(true) + }), } } diff --git a/crates/settings_content/src/workspace.rs b/crates/settings_content/src/workspace.rs index c52cda911d4..4d258df1e94 100644 --- a/crates/settings_content/src/workspace.rs +++ b/crates/settings_content/src/workspace.rs @@ -134,6 +134,12 @@ pub struct WorkspaceSettingsContent { /// Whether the focused panel follows the mouse location /// Default: false pub focus_follows_mouse: Option, + /// Whether to open files in their preview instead of a text editor, when a + /// preview is available for the file type (e.g. Markdown or SVG files). + /// The text editor can be opened from the preview with the `preview::OpenSource` action. + /// + /// Default: false + pub auto_preview: Option, } #[with_fallible_options] diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index 2f6ce0beec5..e9db6856e1e 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: "Open files in their preview instead of a text editor, when a preview is available for the 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/svg_preview/Cargo.toml b/crates/svg_preview/Cargo.toml index 9ee085ee073..25ffbf05d7f 100644 --- a/crates/svg_preview/Cargo.toml +++ b/crates/svg_preview/Cargo.toml @@ -12,10 +12,22 @@ 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"] } +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..c00a0f3f8b9 100644 --- a/crates/svg_preview/src/svg_preview.rs +++ b/crates/svg_preview/src/svg_preview.rs @@ -14,6 +14,7 @@ actions!( ); pub fn init(cx: &mut App) { + workspace::register_project_item::(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..025457a630f 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::{Pane, Workspace, WorkspaceSettings}; +use zed_actions::preview::OpenSource; use crate::{OpenFollowingPreview, OpenPreview, OpenPreviewToTheSide}; @@ -202,6 +207,12 @@ impl SvgPreviewView { }) } + pub fn is_svg_path(path: &ProjectPath) -> bool { + path.path + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("svg")) + } + 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) @@ -274,6 +285,93 @@ impl SvgPreviewView { cx.notify(); } }); + + workspace.register_action(move |workspace, _: &OpenSource, window, cx| { + let Some(preview) = workspace + .active_item(cx) + .and_then(|item| item.downcast::()) + else { + cx.propagate(); + return; + }; + let Some(buffer) = preview.read(cx).buffer.clone() else { + return; + }; + 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); + }); + } + }); + } +} + +/// A [`project::ProjectItem`] that claims SVG files when the `auto_preview` setting +/// is enabled, 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 || !SvgPreviewView::is_svg_path(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 +435,29 @@ 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._workspace_subscription.is_some() { + 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)) {} } diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index 47ae6942010..a3d3dd9e568 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(), diff --git a/crates/workspace/src/workspace_settings.rs b/crates/workspace/src/workspace_settings.rs index b450a3260ca..25f0e5aef71 100644 --- a/crates/workspace/src/workspace_settings.rs +++ b/crates/workspace/src/workspace_settings.rs @@ -39,6 +39,7 @@ pub struct WorkspaceSettings { pub zoomed_padding: bool, pub window_decorations: settings::WindowDecorations, pub focus_follows_mouse: FocusFollowsMouse, + pub auto_preview: bool, } #[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..add4dff3522 100644 --- a/crates/zed/src/zed/quick_action_bar/preview.rs +++ b/crates/zed/src/zed/quick_action_bar/preview.rs @@ -3,7 +3,7 @@ use csv_preview::{ 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, markdown_preview_view::MarkdownPreviewView, @@ -25,6 +25,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::OpenSource, cx) + }) + .on_click(move |_, window, cx| { + window.dispatch_action(zed_actions::preview::OpenSource.boxed_clone(), cx); + }); + + Some(button.into_any_element()) + } + pub fn render_preview_button( &self, workspace_handle: WeakEntity, diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index 12b34def15c..78f0800549f 100644 --- a/crates/zed_actions/src/lib.rs +++ b/crates/zed_actions/src/lib.rs @@ -856,6 +856,16 @@ 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, + ] + ); + 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..eff6e8872ca 100644 --- a/docs/src/reference/all-settings.md +++ b/docs/src/reference/all-settings.md @@ -214,6 +214,16 @@ 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: Whether to open files in their preview instead of a text editor, when a preview is available for the file type (e.g. Markdown or SVG files). The text editor can be opened from the preview with the {#action preview::OpenSource} action. +- Setting: `auto_preview` +- Default: `false` + +**Options** + +`boolean` values + ## Autosave - Description: When to automatically save edited buffers.