1use std::path::PathBuf;
2
3use editor::{Editor, EditorEvent};
4use file_icons::FileIcons;
5use gpui::{
6 App, Context, Entity, EventEmitter, FocusHandle, Focusable, ImageSource, IntoElement,
7 ParentElement, Render, Resource, RetainAllImageCache, Styled, Subscription, WeakEntity, Window,
8 div, img,
9};
10use project::ProjectPath;
11use ui::prelude::*;
12use workspace::item::Item;
13use workspace::{Pane, Workspace};
14use worktree::Event as WorktreeEvent;
15
16use crate::{OpenFollowingPreview, OpenPreview, OpenPreviewToTheSide};
17
18pub struct SvgPreviewView {
19 focus_handle: FocusHandle,
20 svg_path: Option<PathBuf>,
21 project_path: Option<ProjectPath>,
22 image_cache: Entity<RetainAllImageCache>,
23 workspace_handle: WeakEntity<Workspace>,
24 _editor_subscription: Subscription,
25 _workspace_subscription: Option<Subscription>,
26 _project_subscription: Option<Subscription>,
27}
28
29#[derive(Clone, Copy, Debug, PartialEq)]
30pub enum SvgPreviewMode {
31 /// The preview will always show the contents of the provided editor.
32 Default,
33 /// The preview will "follow" the last active editor of an SVG file.
34 Follow,
35}
36
37impl SvgPreviewView {
38 pub fn register(workspace: &mut Workspace, _window: &mut Window, _cx: &mut Context<Workspace>) {
39 workspace.register_action(move |workspace, _: &OpenPreview, window, cx| {
40 if let Some(editor) = Self::resolve_active_item_as_svg_editor(workspace, cx)
41 && Self::is_svg_file(&editor, cx)
42 {
43 let view = Self::create_svg_view(
44 SvgPreviewMode::Default,
45 workspace,
46 editor.clone(),
47 window,
48 cx,
49 );
50 workspace.active_pane().update(cx, |pane, cx| {
51 if let Some(existing_view_idx) =
52 Self::find_existing_preview_item_idx(pane, &editor, cx)
53 {
54 pane.activate_item(existing_view_idx, true, true, window, cx);
55 } else {
56 pane.add_item(Box::new(view), true, true, None, window, cx)
57 }
58 });
59 cx.notify();
60 }
61 });
62
63 workspace.register_action(move |workspace, _: &OpenPreviewToTheSide, window, cx| {
64 if let Some(editor) = Self::resolve_active_item_as_svg_editor(workspace, cx)
65 && Self::is_svg_file(&editor, cx)
66 {
67 let editor_clone = editor.clone();
68 let view = Self::create_svg_view(
69 SvgPreviewMode::Default,
70 workspace,
71 editor_clone,
72 window,
73 cx,
74 );
75 let pane = workspace
76 .find_pane_in_direction(workspace::SplitDirection::Right, cx)
77 .unwrap_or_else(|| {
78 workspace.split_pane(
79 workspace.active_pane().clone(),
80 workspace::SplitDirection::Right,
81 window,
82 cx,
83 )
84 });
85 pane.update(cx, |pane, cx| {
86 if let Some(existing_view_idx) =
87 Self::find_existing_preview_item_idx(pane, &editor, cx)
88 {
89 pane.activate_item(existing_view_idx, true, true, window, cx);
90 } else {
91 pane.add_item(Box::new(view), false, false, None, window, cx)
92 }
93 });
94 cx.notify();
95 }
96 });
97
98 workspace.register_action(move |workspace, _: &OpenFollowingPreview, window, cx| {
99 if let Some(editor) = Self::resolve_active_item_as_svg_editor(workspace, cx)
100 && Self::is_svg_file(&editor, cx)
101 {
102 let view =
103 Self::create_svg_view(SvgPreviewMode::Follow, workspace, editor, window, cx);
104 workspace.active_pane().update(cx, |pane, cx| {
105 pane.add_item(Box::new(view), true, true, None, window, cx)
106 });
107 cx.notify();
108 }
109 });
110 }
111
112 fn find_existing_preview_item_idx(
113 pane: &Pane,
114 editor: &Entity<Editor>,
115 cx: &App,
116 ) -> Option<usize> {
117 let editor_path = Self::get_svg_path(editor, cx);
118 pane.items_of_type::<SvgPreviewView>()
119 .find(|view| {
120 let view_read = view.read(cx);
121 view_read.svg_path.is_some() && view_read.svg_path == editor_path
122 })
123 .and_then(|view| pane.index_for_item(&view))
124 }
125
126 pub fn resolve_active_item_as_svg_editor(
127 workspace: &Workspace,
128 cx: &mut Context<Workspace>,
129 ) -> Option<Entity<Editor>> {
130 let editor = workspace.active_item(cx)?.act_as::<Editor>(cx)?;
131
132 if Self::is_svg_file(&editor, cx) {
133 Some(editor)
134 } else {
135 None
136 }
137 }
138
139 fn create_svg_view(
140 mode: SvgPreviewMode,
141 workspace: &mut Workspace,
142 editor: Entity<Editor>,
143 window: &mut Window,
144 cx: &mut Context<Workspace>,
145 ) -> Entity<SvgPreviewView> {
146 let workspace_handle = workspace.weak_handle();
147 SvgPreviewView::new(mode, editor, workspace_handle, window, cx)
148 }
149
150 pub fn new(
151 mode: SvgPreviewMode,
152 active_editor: Entity<Editor>,
153 workspace_handle: WeakEntity<Workspace>,
154 window: &mut Window,
155 cx: &mut Context<Workspace>,
156 ) -> Entity<Self> {
157 cx.new(|cx| {
158 let svg_path = Self::get_svg_path(&active_editor, cx);
159 let project_path = Self::get_project_path(&active_editor, cx);
160 let image_cache = RetainAllImageCache::new(cx);
161
162 let subscription = cx.subscribe_in(
163 &active_editor,
164 window,
165 |this: &mut SvgPreviewView, _editor, event: &EditorEvent, window, cx| {
166 if event == &EditorEvent::Saved {
167 // Remove cached image to force reload
168 if let Some(svg_path) = &this.svg_path {
169 let resource = Resource::Path(svg_path.clone().into());
170 this.image_cache.update(cx, |cache, cx| {
171 cache.remove(&resource, window, cx);
172 });
173 }
174 cx.notify();
175 }
176 },
177 );
178
179 // Subscribe to workspace active item changes to follow SVG files
180 let workspace_subscription = if mode == SvgPreviewMode::Follow {
181 workspace_handle.upgrade().map(|workspace_handle| {
182 cx.subscribe_in(
183 &workspace_handle,
184 window,
185 |this: &mut SvgPreviewView,
186 workspace,
187 event: &workspace::Event,
188 _window,
189 cx| {
190 if let workspace::Event::ActiveItemChanged = event {
191 let workspace_read = workspace.read(cx);
192 if let Some(active_item) = workspace_read.active_item(cx)
193 && let Some(editor_entity) = active_item.downcast::<Editor>()
194 && Self::is_svg_file(&editor_entity, cx)
195 {
196 let new_path = Self::get_svg_path(&editor_entity, cx);
197 if this.svg_path != new_path {
198 this.svg_path = new_path;
199 cx.notify();
200 }
201 }
202 }
203 },
204 )
205 })
206 } else {
207 None
208 };
209
210 // We'll set up the project subscription after the entity is created
211 let project_subscription = None;
212
213 let view = Self {
214 focus_handle: cx.focus_handle(),
215 svg_path,
216 project_path,
217 image_cache,
218 workspace_handle,
219 _editor_subscription: subscription,
220 _workspace_subscription: workspace_subscription,
221 _project_subscription: project_subscription,
222 };
223
224 view
225 })
226 }
227
228 fn setup_project_subscription(&mut self, window: &mut Window, cx: &mut Context<Self>) {
229 if let (Some(workspace), Some(project_path)) =
230 (self.workspace_handle.upgrade(), &self.project_path)
231 {
232 let project = workspace.read(cx).project().clone();
233 let worktree_id = project_path.worktree_id;
234 let worktree = {
235 let project_read = project.read(cx);
236 project_read
237 .worktrees(cx)
238 .find(|worktree| worktree.read(cx).id() == worktree_id)
239 };
240
241 if let Some(worktree) = worktree {
242 self._project_subscription = Some(cx.subscribe_in(
243 &worktree,
244 window,
245 |this: &mut SvgPreviewView, _worktree, event: &WorktreeEvent, window, cx| {
246 if let WorktreeEvent::UpdatedEntries(changes) = event {
247 if let Some(project_path) = &this.project_path {
248 // Check if our SVG file was modified
249 for (path, _entry_id, _change) in changes.iter() {
250 if path.as_ref() == project_path.path.as_ref() {
251 // File was modified externally, clear cache and refresh
252 if let Some(svg_path) = &this.svg_path {
253 let resource = Resource::Path(svg_path.clone().into());
254 this.image_cache.update(cx, |cache, cx| {
255 cache.remove(&resource, window, cx);
256 });
257 }
258 cx.notify();
259 break;
260 }
261 }
262 }
263 }
264 },
265 ));
266 }
267 }
268 }
269
270 pub fn is_svg_file<C>(editor: &Entity<Editor>, cx: &C) -> bool
271 where
272 C: std::borrow::Borrow<App>,
273 {
274 let app = cx.borrow();
275 let buffer = editor.read(app).buffer().read(app);
276 if let Some(buffer) = buffer.as_singleton()
277 && let Some(file) = buffer.read(app).file()
278 {
279 return file
280 .path()
281 .extension()
282 .and_then(|ext| ext.to_str())
283 .map(|ext| ext.eq_ignore_ascii_case("svg"))
284 .unwrap_or(false);
285 }
286 false
287 }
288
289 fn get_svg_path<C>(editor: &Entity<Editor>, cx: &C) -> Option<PathBuf>
290 where
291 C: std::borrow::Borrow<App>,
292 {
293 let app = cx.borrow();
294 let buffer = editor.read(app).buffer().read(app).as_singleton()?;
295 let file = buffer.read(app).file()?;
296 let local_file = file.as_local()?;
297 Some(local_file.abs_path(app))
298 }
299
300 fn get_project_path<C>(editor: &Entity<Editor>, cx: &C) -> Option<ProjectPath>
301 where
302 C: std::borrow::Borrow<App>,
303 {
304 let app = cx.borrow();
305 let buffer = editor.read(app).buffer().read(app).as_singleton()?;
306 let file = buffer.read(app).file()?;
307 Some(ProjectPath {
308 worktree_id: file.worktree_id(app),
309 path: file.path().clone(),
310 })
311 }
312}
313
314impl Render for SvgPreviewView {
315 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
316 // Set up project subscription on first render if not already done
317 if self._project_subscription.is_none() {
318 self.setup_project_subscription(window, cx);
319 }
320 v_flex()
321 .id("SvgPreview")
322 .key_context("SvgPreview")
323 .track_focus(&self.focus_handle(cx))
324 .size_full()
325 .bg(cx.theme().colors().editor_background)
326 .flex()
327 .justify_center()
328 .items_center()
329 .child(if let Some(svg_path) = &self.svg_path {
330 img(ImageSource::from(svg_path.clone()))
331 .image_cache(&self.image_cache)
332 .max_w_full()
333 .max_h_full()
334 .with_fallback(|| {
335 div()
336 .p_4()
337 .child("Failed to load SVG file")
338 .into_any_element()
339 })
340 .into_any_element()
341 } else {
342 div().p_4().child("No SVG file selected").into_any_element()
343 })
344 }
345}
346
347impl Focusable for SvgPreviewView {
348 fn focus_handle(&self, _cx: &App) -> FocusHandle {
349 self.focus_handle.clone()
350 }
351}
352
353impl EventEmitter<()> for SvgPreviewView {}
354
355impl Item for SvgPreviewView {
356 type Event = ();
357
358 fn tab_icon(&self, _window: &Window, cx: &App) -> Option<Icon> {
359 // Use the same icon as SVG files in the file tree
360 self.svg_path
361 .as_ref()
362 .and_then(|svg_path| FileIcons::get_icon(svg_path, cx))
363 .map(Icon::from_path)
364 .or_else(|| Some(Icon::new(IconName::Image)))
365 }
366
367 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
368 self.svg_path
369 .as_ref()
370 .and_then(|svg_path| svg_path.file_name())
371 .map(|name| name.to_string_lossy())
372 .map(|name| format!("Preview {}", name).into())
373 .unwrap_or_else(|| "SVG Preview".into())
374 }
375
376 fn telemetry_event_text(&self) -> Option<&'static str> {
377 Some("svg preview: open")
378 }
379
380 fn to_item_events(_event: &Self::Event, _f: impl FnMut(workspace::item::ItemEvent)) {}
381}