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 525c36c9f6
26 changed files with 2748 additions and 225 deletions

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

View file

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

View file

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

View file

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

View file

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

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::{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<gpui::Subscription>,
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::<CsvPreviewView>(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<Workspace>,
) -> bool {
let Some(editor) = workspace
.active_item(cx)
.and_then(|item| item.act_as::<Editor>(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::<CsvPreviewView>()
.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<Workspace>,
) -> bool {
let Some(preview) = workspace
.active_item(cx)
.and_then(|item| item.downcast::<CsvPreviewView>())
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::<TabularDataPreviewFeatureFlag>(), |div| {
div.on_action(cx.listener(|workspace, _: &OpenPreview, window, cx| {
if let Some(editor) = workspace
.active_item(cx)
.and_then(|item| item.act_as::<Editor>(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::<CsvPreviewView>()
.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::<Editor>(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<Editor>, cx: &mut Context<Workspace>) -> Entity<Self> {
cx.new(|cx| Self::build(editor.clone(), cx))
}
fn new_following(
editor: &Entity<Editor>,
window: &Window,
cx: &mut Context<Workspace>,
) -> Entity<Self> {
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::<Editor>())
&& 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<Editor>, cx: &mut Context<Self>) {
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<Editor>, cx: &mut Context<Self>) -> 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::<TabularDataPreviewFeatureFlag>()
&& item
.downcast::<Editor>()
.is_some_and(|editor| Self::is_csv_file(&editor, cx))
},
has_open_sources: |workspace, cx| {
workspace
.items_of_type::<Editor>(cx)
.any(|editor| Self::is_csv_file(&editor, cx))
},
is_follow_view: |item, cx| {
item.downcast::<CsvPreviewView>()
.is_some_and(|view| view.read(cx).is_following())
},
is_preview_view: |item, cx| {
item.downcast::<CsvPreviewView>()
.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::<Editor>())
.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::<Editor>()?;
Some(Box::new(Self::new(&editor, cx)))
},
source_view: |_, item, _, cx| {
let preview = item.downcast::<CsvPreviewView>()?;
Some(Box::new(
preview.read(cx).active_editor_state.editor.clone(),
))
},
}
}
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 +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<std::path::Path>) -> bool {
path.as_ref()
.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 +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<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 != 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::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

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

View file

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

File diff suppressed because it is too large Load diff

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(AutoPreview::InPlace)
}),
}
}

View file

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

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

View file

@ -530,6 +530,7 @@ fn init_renderers(cx: &mut App) {
.add_basic_renderer::<settings::SaturatingBool>(render_toggle_button)
.add_basic_renderer::<settings::CursorShape>(render_dropdown)
.add_basic_renderer::<settings::RestoreOnStartupBehavior>(render_dropdown)
.add_basic_renderer::<settings::AutoPreview>(render_dropdown)
.add_basic_renderer::<settings::BottomDockLayout>(render_dropdown)
.add_basic_renderer::<settings::OnLastWindowClosed>(render_dropdown)
.add_basic_renderer::<settings::CliDefaultOpenBehavior>(render_dropdown)

View file

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

View file

@ -14,6 +14,11 @@ actions!(
);
pub fn init(cx: &mut App) {
workspace::register_project_item::<svg_preview_view::SvgPreviewView>(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;

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::{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::<MultiBuffer>()
&& 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>) {
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<std::path::Path>) -> 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::<Editor>()
.is_some_and(|editor| Self::is_svg_file(editor.read(cx).buffer(), cx))
},
has_open_sources: |workspace, cx| {
workspace
.items_of_type::<Editor>(cx)
.any(|editor| Self::is_svg_file(editor.read(cx).buffer(), cx))
},
is_follow_view: |item, cx| {
item.downcast::<SvgPreviewView>()
.is_some_and(|view| view.read(cx).is_following())
},
is_preview_view: |item, cx| {
item.downcast::<SvgPreviewView>()
.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::<Editor>()?;
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::<SvgPreviewView>()?;
let buffer = preview.read(cx).buffer.clone()?;
let existing_editor = workspace.items_of_type::<Editor>(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<Workspace>,
) -> 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<Workspace>,
) -> bool {
let Some(preview) = workspace
.active_item(cx)
.and_then(|item| item.downcast::<SvgPreviewView>())
else {
return false;
};
let Some(buffer) = preview.read(cx).buffer.clone() else {
return true;
};
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);
});
}
true
}
pub fn register(workspace: &mut Workspace, _window: &mut Window, _cx: &mut Context<Workspace>) {
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<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 != 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<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 +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#"<svg xmlns="http://www.w3.org/2000/svg"></svg>"#;
#[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::<MultiWorkspace>().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::<SvgPreviewView>())
.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::<SvgPreviewView>()),
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::<Editor>())
.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::<MultiWorkspace>().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::<Editor>())
.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::<MultiWorkspace>().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::<Editor>())
.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::<MultiWorkspace>().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::<Editor>())
.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::<SvgPreviewView>(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::<Editor>(cx).collect::<Vec<_>>();
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::<SvgPreviewView>(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::<MultiWorkspace>().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::<markdown_preview::markdown_preview_view::MarkdownPreviewView>(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::<markdown_preview::markdown_preview_view::MarkdownPreviewView>(cx)
.count(),
0,
"the markdown preview should have been replaced"
);
let preview = workspace
.items_of_type::<SvgPreviewView>(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::<SvgPreviewView>(cx).count(), 0);
let preview = workspace
.items_of_type::<markdown_preview::markdown_preview_view::MarkdownPreviewView>(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::<SettingsStore, _>(|settings, cx| {
settings.update_user_settings(cx, |settings| {
settings.workspace.auto_preview = Some(auto_preview);
});
});
});
}
async fn open_workspace_path(
multi_workspace: &WindowHandle<MultiWorkspace>,
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<AppState> {
cx.update(|cx| {
let state = AppState::test(cx);
editor::init(cx);
crate::init(cx);
state
})
}
}

View file

@ -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<Workspace>) -> Option<Box<dyn ItemHandle>>,
/// Builds a preview of the file shown by the given item.
pub build_preview_view: fn(
&mut Workspace,
&dyn ItemHandle,
&mut Window,
&mut Context<Workspace>,
) -> Option<Box<dyn ItemHandle>>,
/// Returns an editor for the file shown by the given preview item.
pub source_view: fn(
&mut Workspace,
&dyn ItemHandle,
&mut Window,
&mut Context<Workspace>,
) -> Option<Box<dyn ItemHandle>>,
}
/// 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<EntityId>,
/// 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<EntityId>,
}
#[derive(Default)]
struct GlobalAutoPreviewProviders(Vec<AutoPreviewProvider>);
impl Global for GlobalAutoPreviewProviders {}
pub fn register_auto_preview_provider(provider: AutoPreviewProvider, cx: &mut App) {
cx.default_global::<GlobalAutoPreviewProviders>()
.0
.push(provider);
}
fn providers(cx: &App) -> Vec<AutoPreviewProvider> {
cx.try_global::<GlobalAutoPreviewProviders>()
.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<Workspace>,
) {
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<Workspace>,
) {
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<dyn ItemHandle>)> {
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<Workspace>,
) {
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<Workspace>,
) {
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<Workspace>,
mut convert: impl FnMut(
&mut Workspace,
&dyn ItemHandle,
&mut Window,
&mut Context<Workspace>,
) -> Option<Box<dyn ItemHandle>>,
) {
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::<Vec<_>>();
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);
}
});
}
}
}

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

View file

@ -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<Rc<Cell<EntityId>>>,
active_worktree_creation: ActiveWorktreeCreation,
deferred_save_items: Vec<Box<dyn WeakItemHandle>>,
auto_preview_state: auto_preview::AutoPreviewState,
}
impl EventEmitter<Event> 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::<SettingsStore>(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(),
}
}

View file

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

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

@ -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<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::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<Workspace>,
@ -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<dyn gpui::Action>,
Box::new(MarkdownOpenPreviewToTheSide) as Box<dyn gpui::Action>,
&markdown_preview::OpenPreview as &dyn gpui::Action,
),
PreviewType::Svg => (
"toggle-svg-preview",
"Preview SVG",
Box::new(SvgOpenPreview) as Box<dyn gpui::Action>,
Box::new(SvgOpenPreviewToTheSide) as Box<dyn gpui::Action>,
&svg_preview::OpenPreview as &dyn gpui::Action,
),
PreviewType::Csv => (
"toggle-csv-preview",
"Preview CSV",
Box::new(CsvOpenPreview) as Box<dyn gpui::Action>,
Box::new(CsvOpenPreviewToTheSide) as Box<dyn gpui::Action>,
&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<dyn gpui::Action>,
),
PreviewType::Svg => (
"toggle-svg-preview",
"Preview SVG",
Box::new(SvgOpenPreviewToTheSide) as Box<dyn gpui::Action>,
),
PreviewType::Csv => (
"toggle-csv-preview",
"Preview CSV",
Box::new(CsvOpenPreviewToTheSide) as Box<dyn gpui::Action>,
),
};
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);
}
});
}

View file

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

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