Add auto_preview setting to open previewable files in their preview

This commit is contained in:
Kirill Bulatov 2026-07-08 21:24:21 +03:00
parent 6b733d1058
commit ce6a5f34d5
22 changed files with 661 additions and 123 deletions

10
Cargo.lock generated
View file

@ -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",
]

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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

View file

@ -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 = []

View file

@ -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::<CsvPreviewView>(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::<Editor>(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::<CsvPreviewView>())
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<Editor>, cx: &mut Context<Workspace>) -> Entity<Self> {
cx.new(|cx| Self::build(editor.clone(), cx))
}
fn build(editor: Entity<Editor>, cx: &mut Context<Self>) -> 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<Entity<Buffer>> {
self.active_editor_state
.editor
.read(cx)
.buffer()
.read(cx)
.as_singleton()
}
fn is_csv_file(editor: &Entity<Editor>, 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<Buffer>,
}
impl project::ProjectItem for CsvPreviewItem {
fn try_open(
project: &Entity<Project>,
path: &ProjectPath,
cx: &mut App,
) -> Option<Task<anyhow::Result<Entity<Self>>>> {
if !cx.has_flag::<TabularDataPreviewFeatureFlag>()
|| !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::ProjectEntryId> {
project::ProjectItem::entry_id(self.buffer.read(cx), cx)
}
fn project_path(&self, cx: &App) -> Option<ProjectPath> {
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<Project>,
_pane: Option<&Pane>,
item: Entity<Self::Item>,
window: &mut Window,
cx: &mut Context<Self>,
) -> 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)]

View file

@ -36,6 +36,7 @@ actions!(
pub fn init(cx: &mut App) {
workspace::register_serializable_item::<MarkdownPreviewView>(cx);
workspace::register_project_item::<MarkdownPreviewView>(cx);
cx.observe_new(|workspace: &mut Workspace, window, cx| {
let Some(window) = window else {

View file

@ -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::<MarkdownPreviewView>())
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<Self> {
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<Markdown>, 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<Editor>,
workspace: WeakEntity<Workspace>,
language_registry: Arc<LanguageRegistry>,
window: &mut Window,
cx: &mut Context<Self>,
) -> 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<Markdown>, 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<Entity<Buffer>> {
self.active_editor
.as_ref()?
.editor
.read(cx)
.buffer()
.read(cx)
.as_singleton()
}
pub fn is_markdown_file<V>(editor: &Entity<Editor>, cx: &mut Context<V>) -> 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>,
) {
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<Self>,
@ -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<Buffer>,
}
impl project::ProjectItem for MarkdownPreviewItem {
fn try_open(
project: &Entity<Project>,
path: &ProjectPath,
cx: &mut App,
) -> Option<Task<Result<Entity<Self>>>> {
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<ProjectEntryId> {
project::ProjectItem::entry_id(self.buffer.read(cx), cx)
}
fn project_path(&self, cx: &App) -> Option<ProjectPath> {
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<Project>,
pane: Option<&Pane>,
item: Entity<Self::Item>,
window: &mut Window,
cx: &mut Context<Self>,
) -> 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::<MultiWorkspace>()
.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<Self>) -> impl IntoElement {
let preview_theme = self.resolve_preview_theme(cx);

View file

@ -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)
}),
}
}

View file

@ -134,6 +134,12 @@ pub struct WorkspaceSettingsContent {
/// Whether the focused panel follows the mouse location
/// Default: false
pub focus_follows_mouse: Option<FocusFollowsMouse>,
/// 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<bool>,
}
#[with_fallible_options]

View file

@ -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,
}),
]
}

View file

@ -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"] }

View file

@ -14,6 +14,7 @@ actions!(
);
pub fn init(cx: &mut App) {
workspace::register_project_item::<svg_preview_view::SvgPreviewView>(cx);
cx.observe_new(|workspace: &mut Workspace, window, cx| {
let Some(window) = window else {
return;

View file

@ -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>) {
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::<SvgPreviewView>())
else {
cx.propagate();
return;
};
let Some(buffer) = preview.read(cx).buffer.clone() else {
return;
};
let existing_editor = workspace.items_of_type::<Editor>(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<Buffer>,
}
impl project::ProjectItem for SvgPreviewItem {
fn try_open(
project: &Entity<Project>,
path: &ProjectPath,
cx: &mut App,
) -> Option<Task<Result<Entity<Self>>>> {
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<ProjectEntryId> {
project::ProjectItem::entry_id(self.buffer.read(cx), cx)
}
fn project_path(&self, cx: &App) -> Option<ProjectPath> {
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<Project>,
_pane: Option<&Pane>,
item: Entity<Self::Item>,
window: &mut Window,
cx: &mut Context<Self>,
) -> 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)) {}
}

View file

@ -885,6 +885,10 @@ impl Pane {
cx.notify();
}
pub fn workspace(&self) -> &WeakEntity<Workspace> {
&self.workspace
}
pub fn nav_history_for_item<T: Item>(&self, item: &Entity<T>) -> ItemNavHistory {
ItemNavHistory {
history: self.nav_history.clone(),

View file

@ -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(),
}
}
}

View file

@ -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<Self>) -> 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<Self>) -> 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)
}
}

View file

@ -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<Self>) -> Option<AnyElement> {
let item = self.active_item.as_ref()?;
let (button_id, tooltip_text) = if item.downcast::<MarkdownPreviewView>().is_some() {
("edit-markdown-source", "Edit Markdown")
} else if item.downcast::<SvgPreviewView>().is_some() {
("edit-svg-source", "Edit SVG")
} else if item.downcast::<CsvPreviewView>().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<Workspace>,

View file

@ -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;

View file

@ -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 doesnt 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.
Heres 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.

View file

@ -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.