1use crate::{
2 Project, ProjectEntryId, ProjectItem, ProjectPath,
3 worktree_store::{WorktreeStore, WorktreeStoreEvent},
4};
5use anyhow::{Context as _, Result};
6use collections::{HashMap, HashSet, hash_map};
7use futures::{StreamExt, channel::oneshot};
8use gpui::{
9 App, AsyncApp, Context, Entity, EventEmitter, Img, Subscription, Task, WeakEntity, prelude::*,
10};
11pub use image::ImageFormat;
12use image::{ExtendedColorType, GenericImageView, ImageReader};
13use language::{DiskState, File};
14use rpc::{AnyProtoClient, ErrorExt as _};
15use std::num::NonZeroU64;
16use std::path::Path;
17use std::sync::Arc;
18use std::{ffi::OsStr, path::PathBuf};
19use util::ResultExt;
20use worktree::{LoadedBinaryFile, PathChange, Worktree};
21
22#[derive(Clone, Copy, Debug, Hash, PartialEq, PartialOrd, Ord, Eq)]
23pub struct ImageId(NonZeroU64);
24
25impl std::fmt::Display for ImageId {
26 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27 write!(f, "{}", self.0)
28 }
29}
30
31impl From<NonZeroU64> for ImageId {
32 fn from(id: NonZeroU64) -> Self {
33 ImageId(id)
34 }
35}
36
37#[derive(Debug)]
38pub enum ImageItemEvent {
39 ReloadNeeded,
40 Reloaded,
41 FileHandleChanged,
42 MetadataUpdated,
43}
44
45impl EventEmitter<ImageItemEvent> for ImageItem {}
46
47pub enum ImageStoreEvent {
48 ImageAdded(Entity<ImageItem>),
49}
50
51impl EventEmitter<ImageStoreEvent> for ImageStore {}
52
53#[derive(Debug, Clone, Copy)]
54pub struct ImageMetadata {
55 pub width: u32,
56 pub height: u32,
57 pub file_size: u64,
58 pub colors: Option<ImageColorInfo>,
59 pub format: ImageFormat,
60}
61
62#[derive(Debug, Clone, Copy)]
63pub struct ImageColorInfo {
64 pub channels: u8,
65 pub bits_per_channel: u8,
66}
67
68impl ImageColorInfo {
69 pub fn from_color_type(color_type: impl Into<ExtendedColorType>) -> Option<Self> {
70 let (channels, bits_per_channel) = match color_type.into() {
71 ExtendedColorType::L8 => (1, 8),
72 ExtendedColorType::L16 => (1, 16),
73 ExtendedColorType::La8 => (2, 8),
74 ExtendedColorType::La16 => (2, 16),
75 ExtendedColorType::Rgb8 => (3, 8),
76 ExtendedColorType::Rgb16 => (3, 16),
77 ExtendedColorType::Rgba8 => (4, 8),
78 ExtendedColorType::Rgba16 => (4, 16),
79 ExtendedColorType::A8 => (1, 8),
80 ExtendedColorType::Bgr8 => (3, 8),
81 ExtendedColorType::Bgra8 => (4, 8),
82 ExtendedColorType::Cmyk8 => (4, 8),
83 _ => return None,
84 };
85
86 Some(Self {
87 channels,
88 bits_per_channel,
89 })
90 }
91
92 pub const fn bits_per_pixel(&self) -> u8 {
93 self.channels * self.bits_per_channel
94 }
95}
96
97pub struct ImageItem {
98 pub id: ImageId,
99 pub file: Arc<worktree::File>,
100 pub image: Arc<gpui::Image>,
101 reload_task: Option<Task<()>>,
102 pub image_metadata: Option<ImageMetadata>,
103}
104
105impl ImageItem {
106 pub async fn load_image_metadata(
107 image: Entity<ImageItem>,
108 project: Entity<Project>,
109 cx: &mut AsyncApp,
110 ) -> Result<ImageMetadata> {
111 let (fs, image_path) = cx.update(|cx| {
112 let fs = project.read(cx).fs().clone();
113 let image_path = image
114 .read(cx)
115 .abs_path(cx)
116 .context("absolutizing image file path")?;
117 anyhow::Ok((fs, image_path))
118 })??;
119
120 let image_bytes = fs.load_bytes(&image_path).await?;
121 let image_format = image::guess_format(&image_bytes)?;
122
123 let mut image_reader = ImageReader::new(std::io::Cursor::new(image_bytes));
124 image_reader.set_format(image_format);
125 let image = image_reader.decode()?;
126
127 let (width, height) = image.dimensions();
128 let file_metadata = fs
129 .metadata(image_path.as_path())
130 .await?
131 .context("failed to load image metadata")?;
132
133 Ok(ImageMetadata {
134 width,
135 height,
136 file_size: file_metadata.len,
137 format: image_format,
138 colors: ImageColorInfo::from_color_type(image.color()),
139 })
140 }
141
142 pub fn project_path(&self, cx: &App) -> ProjectPath {
143 ProjectPath {
144 worktree_id: self.file.worktree_id(cx),
145 path: self.file.path().clone(),
146 }
147 }
148
149 pub fn abs_path(&self, cx: &App) -> Option<PathBuf> {
150 Some(self.file.as_local()?.abs_path(cx))
151 }
152
153 fn file_updated(&mut self, new_file: Arc<worktree::File>, cx: &mut Context<Self>) {
154 let mut file_changed = false;
155
156 let old_file = &self.file;
157 if new_file.path() != old_file.path() {
158 file_changed = true;
159 }
160
161 let old_state = old_file.disk_state();
162 let new_state = new_file.disk_state();
163 if old_state != new_state {
164 file_changed = true;
165 if matches!(new_state, DiskState::Present { .. }) {
166 cx.emit(ImageItemEvent::ReloadNeeded)
167 }
168 }
169
170 self.file = new_file;
171 if file_changed {
172 cx.emit(ImageItemEvent::FileHandleChanged);
173 cx.notify();
174 }
175 }
176
177 fn reload(&mut self, cx: &mut Context<Self>) -> Option<oneshot::Receiver<()>> {
178 let local_file = self.file.as_local()?;
179 let (tx, rx) = futures::channel::oneshot::channel();
180
181 let content = local_file.load_bytes(cx);
182 self.reload_task = Some(cx.spawn(async move |this, cx| {
183 if let Some(image) = content
184 .await
185 .context("Failed to load image content")
186 .and_then(create_gpui_image)
187 .log_err()
188 {
189 this.update(cx, |this, cx| {
190 this.image = image;
191 cx.emit(ImageItemEvent::Reloaded);
192 })
193 .log_err();
194 }
195 _ = tx.send(());
196 }));
197 Some(rx)
198 }
199}
200
201pub fn is_image_file(project: &Entity<Project>, path: &ProjectPath, cx: &App) -> bool {
202 let ext = util::maybe!({
203 let worktree_abs_path = project
204 .read(cx)
205 .worktree_for_id(path.worktree_id, cx)?
206 .read(cx)
207 .abs_path();
208 path.path
209 .extension()
210 .or_else(|| worktree_abs_path.extension())
211 .and_then(OsStr::to_str)
212 .map(str::to_lowercase)
213 });
214
215 match ext {
216 Some(ext) => Img::extensions().contains(&ext.as_str()) && !ext.contains("svg"),
217 None => false,
218 }
219}
220
221impl ProjectItem for ImageItem {
222 fn try_open(
223 project: &Entity<Project>,
224 path: &ProjectPath,
225 cx: &mut App,
226 ) -> Option<Task<anyhow::Result<Entity<Self>>>> {
227 if is_image_file(&project, &path, cx) {
228 Some(cx.spawn({
229 let path = path.clone();
230 let project = project.clone();
231 async move |cx| {
232 project
233 .update(cx, |project, cx| project.open_image(path, cx))?
234 .await
235 }
236 }))
237 } else {
238 None
239 }
240 }
241
242 fn entry_id(&self, _: &App) -> Option<ProjectEntryId> {
243 self.file.entry_id
244 }
245
246 fn project_path(&self, cx: &App) -> Option<ProjectPath> {
247 Some(self.project_path(cx).clone())
248 }
249
250 fn is_dirty(&self) -> bool {
251 false
252 }
253}
254
255trait ImageStoreImpl {
256 fn open_image(
257 &self,
258 path: Arc<Path>,
259 worktree: Entity<Worktree>,
260 cx: &mut Context<ImageStore>,
261 ) -> Task<Result<Entity<ImageItem>>>;
262
263 fn reload_images(
264 &self,
265 images: HashSet<Entity<ImageItem>>,
266 cx: &mut Context<ImageStore>,
267 ) -> Task<Result<()>>;
268
269 fn as_local(&self) -> Option<Entity<LocalImageStore>>;
270}
271
272struct RemoteImageStore {}
273
274struct LocalImageStore {
275 local_image_ids_by_path: HashMap<ProjectPath, ImageId>,
276 local_image_ids_by_entry_id: HashMap<ProjectEntryId, ImageId>,
277 image_store: WeakEntity<ImageStore>,
278 _subscription: Subscription,
279}
280
281pub struct ImageStore {
282 state: Box<dyn ImageStoreImpl>,
283 opened_images: HashMap<ImageId, WeakEntity<ImageItem>>,
284 worktree_store: Entity<WorktreeStore>,
285 #[allow(clippy::type_complexity)]
286 loading_images_by_path: HashMap<
287 ProjectPath,
288 postage::watch::Receiver<Option<Result<Entity<ImageItem>, Arc<anyhow::Error>>>>,
289 >,
290}
291
292impl ImageStore {
293 pub fn local(worktree_store: Entity<WorktreeStore>, cx: &mut Context<Self>) -> Self {
294 let this = cx.weak_entity();
295 Self {
296 state: Box::new(cx.new(|cx| {
297 let subscription = cx.subscribe(
298 &worktree_store,
299 |this: &mut LocalImageStore, _, event, cx| {
300 if let WorktreeStoreEvent::WorktreeAdded(worktree) = event {
301 this.subscribe_to_worktree(worktree, cx);
302 }
303 },
304 );
305
306 LocalImageStore {
307 local_image_ids_by_path: Default::default(),
308 local_image_ids_by_entry_id: Default::default(),
309 image_store: this,
310 _subscription: subscription,
311 }
312 })),
313 opened_images: Default::default(),
314 loading_images_by_path: Default::default(),
315 worktree_store,
316 }
317 }
318
319 pub fn remote(
320 worktree_store: Entity<WorktreeStore>,
321 _upstream_client: AnyProtoClient,
322 _remote_id: u64,
323 cx: &mut Context<Self>,
324 ) -> Self {
325 Self {
326 state: Box::new(cx.new(|_| RemoteImageStore {})),
327 opened_images: Default::default(),
328 loading_images_by_path: Default::default(),
329 worktree_store,
330 }
331 }
332
333 pub fn images(&self) -> impl '_ + Iterator<Item = Entity<ImageItem>> {
334 self.opened_images
335 .values()
336 .filter_map(|image| image.upgrade())
337 }
338
339 pub fn get(&self, image_id: ImageId) -> Option<Entity<ImageItem>> {
340 self.opened_images
341 .get(&image_id)
342 .and_then(|image| image.upgrade())
343 }
344
345 pub fn get_by_path(&self, path: &ProjectPath, cx: &App) -> Option<Entity<ImageItem>> {
346 self.images()
347 .find(|image| &image.read(cx).project_path(cx) == path)
348 }
349
350 pub fn open_image(
351 &mut self,
352 project_path: ProjectPath,
353 cx: &mut Context<Self>,
354 ) -> Task<Result<Entity<ImageItem>>> {
355 let existing_image = self.get_by_path(&project_path, cx);
356 if let Some(existing_image) = existing_image {
357 return Task::ready(Ok(existing_image));
358 }
359
360 let Some(worktree) = self
361 .worktree_store
362 .read(cx)
363 .worktree_for_id(project_path.worktree_id, cx)
364 else {
365 return Task::ready(Err(anyhow::anyhow!("no such worktree")));
366 };
367
368 let loading_watch = match self.loading_images_by_path.entry(project_path.clone()) {
369 // If the given path is already being loaded, then wait for that existing
370 // task to complete and return the same image.
371 hash_map::Entry::Occupied(e) => e.get().clone(),
372
373 // Otherwise, record the fact that this path is now being loaded.
374 hash_map::Entry::Vacant(entry) => {
375 let (mut tx, rx) = postage::watch::channel();
376 entry.insert(rx.clone());
377
378 let project_path = project_path.clone();
379 let load_image = self
380 .state
381 .open_image(project_path.path.clone(), worktree, cx);
382
383 cx.spawn(async move |this, cx| {
384 let load_result = load_image.await;
385 *tx.borrow_mut() = Some(this.update(cx, |this, _cx| {
386 // Record the fact that the image is no longer loading.
387 this.loading_images_by_path.remove(&project_path);
388 let image = load_result.map_err(Arc::new)?;
389 Ok(image)
390 })?);
391 anyhow::Ok(())
392 })
393 .detach();
394 rx
395 }
396 };
397
398 cx.background_spawn(async move {
399 Self::wait_for_loading_image(loading_watch)
400 .await
401 .map_err(|e| e.cloned())
402 })
403 }
404
405 pub async fn wait_for_loading_image(
406 mut receiver: postage::watch::Receiver<
407 Option<Result<Entity<ImageItem>, Arc<anyhow::Error>>>,
408 >,
409 ) -> Result<Entity<ImageItem>, Arc<anyhow::Error>> {
410 loop {
411 if let Some(result) = receiver.borrow().as_ref() {
412 match result {
413 Ok(image) => return Ok(image.to_owned()),
414 Err(e) => return Err(e.to_owned()),
415 }
416 }
417 receiver.next().await;
418 }
419 }
420
421 pub fn reload_images(
422 &self,
423 images: HashSet<Entity<ImageItem>>,
424 cx: &mut Context<ImageStore>,
425 ) -> Task<Result<()>> {
426 if images.is_empty() {
427 return Task::ready(Ok(()));
428 }
429
430 self.state.reload_images(images, cx)
431 }
432
433 fn add_image(&mut self, image: Entity<ImageItem>, cx: &mut Context<ImageStore>) -> Result<()> {
434 let image_id = image.read(cx).id;
435
436 self.opened_images.insert(image_id, image.downgrade());
437
438 cx.subscribe(&image, Self::on_image_event).detach();
439 cx.emit(ImageStoreEvent::ImageAdded(image));
440 Ok(())
441 }
442
443 fn on_image_event(
444 &mut self,
445 image: Entity<ImageItem>,
446 event: &ImageItemEvent,
447 cx: &mut Context<Self>,
448 ) {
449 match event {
450 ImageItemEvent::FileHandleChanged => {
451 if let Some(local) = self.state.as_local() {
452 local.update(cx, |local, cx| {
453 local.image_changed_file(image, cx);
454 })
455 }
456 }
457 _ => {}
458 }
459 }
460}
461
462impl ImageStoreImpl for Entity<LocalImageStore> {
463 fn open_image(
464 &self,
465 path: Arc<Path>,
466 worktree: Entity<Worktree>,
467 cx: &mut Context<ImageStore>,
468 ) -> Task<Result<Entity<ImageItem>>> {
469 let this = self.clone();
470
471 let load_file = worktree.update(cx, |worktree, cx| {
472 worktree.load_binary_file(path.as_ref(), cx)
473 });
474 cx.spawn(async move |image_store, cx| {
475 let LoadedBinaryFile { file, content } = load_file.await?;
476 let image = create_gpui_image(content)?;
477
478 let entity = cx.new(|cx| ImageItem {
479 id: cx.entity_id().as_non_zero_u64().into(),
480 file: file.clone(),
481 image,
482 image_metadata: None,
483 reload_task: None,
484 })?;
485
486 let image_id = cx.read_entity(&entity, |model, _| model.id)?;
487
488 this.update(cx, |this, cx| {
489 image_store.update(cx, |image_store, cx| {
490 image_store.add_image(entity.clone(), cx)
491 })??;
492 this.local_image_ids_by_path.insert(
493 ProjectPath {
494 worktree_id: file.worktree_id(cx),
495 path: file.path.clone(),
496 },
497 image_id,
498 );
499
500 if let Some(entry_id) = file.entry_id {
501 this.local_image_ids_by_entry_id.insert(entry_id, image_id);
502 }
503
504 anyhow::Ok(())
505 })??;
506
507 Ok(entity)
508 })
509 }
510
511 fn reload_images(
512 &self,
513 images: HashSet<Entity<ImageItem>>,
514 cx: &mut Context<ImageStore>,
515 ) -> Task<Result<()>> {
516 cx.spawn(async move |_, cx| {
517 for image in images {
518 if let Some(rec) = image.update(cx, |image, cx| image.reload(cx))? {
519 rec.await?
520 }
521 }
522 Ok(())
523 })
524 }
525
526 fn as_local(&self) -> Option<Entity<LocalImageStore>> {
527 Some(self.clone())
528 }
529}
530
531impl LocalImageStore {
532 fn subscribe_to_worktree(&mut self, worktree: &Entity<Worktree>, cx: &mut Context<Self>) {
533 cx.subscribe(worktree, |this, worktree, event, cx| {
534 if worktree.read(cx).is_local() {
535 match event {
536 worktree::Event::UpdatedEntries(changes) => {
537 this.local_worktree_entries_changed(&worktree, changes, cx);
538 }
539 _ => {}
540 }
541 }
542 })
543 .detach();
544 }
545
546 fn local_worktree_entries_changed(
547 &mut self,
548 worktree_handle: &Entity<Worktree>,
549 changes: &[(Arc<Path>, ProjectEntryId, PathChange)],
550 cx: &mut Context<Self>,
551 ) {
552 let snapshot = worktree_handle.read(cx).snapshot();
553 for (path, entry_id, _) in changes {
554 self.local_worktree_entry_changed(*entry_id, path, worktree_handle, &snapshot, cx);
555 }
556 }
557
558 fn local_worktree_entry_changed(
559 &mut self,
560 entry_id: ProjectEntryId,
561 path: &Arc<Path>,
562 worktree: &Entity<worktree::Worktree>,
563 snapshot: &worktree::Snapshot,
564 cx: &mut Context<Self>,
565 ) -> Option<()> {
566 let project_path = ProjectPath {
567 worktree_id: snapshot.id(),
568 path: path.clone(),
569 };
570 let image_id = match self.local_image_ids_by_entry_id.get(&entry_id) {
571 Some(&image_id) => image_id,
572 None => self.local_image_ids_by_path.get(&project_path).copied()?,
573 };
574
575 let image = self
576 .image_store
577 .update(cx, |image_store, _| {
578 if let Some(image) = image_store.get(image_id) {
579 Some(image)
580 } else {
581 image_store.opened_images.remove(&image_id);
582 None
583 }
584 })
585 .ok()
586 .flatten();
587 let image = if let Some(image) = image {
588 image
589 } else {
590 self.local_image_ids_by_path.remove(&project_path);
591 self.local_image_ids_by_entry_id.remove(&entry_id);
592 return None;
593 };
594
595 image.update(cx, |image, cx| {
596 let old_file = &image.file;
597 if old_file.worktree != *worktree {
598 return;
599 }
600
601 let snapshot_entry = old_file
602 .entry_id
603 .and_then(|entry_id| snapshot.entry_for_id(entry_id))
604 .or_else(|| snapshot.entry_for_path(old_file.path.as_ref()));
605
606 let new_file = if let Some(entry) = snapshot_entry {
607 worktree::File {
608 disk_state: match entry.mtime {
609 Some(mtime) => DiskState::Present { mtime },
610 None => old_file.disk_state,
611 },
612 is_local: true,
613 entry_id: Some(entry.id),
614 path: entry.path.clone(),
615 worktree: worktree.clone(),
616 is_private: entry.is_private,
617 }
618 } else {
619 worktree::File {
620 disk_state: DiskState::Deleted,
621 is_local: true,
622 entry_id: old_file.entry_id,
623 path: old_file.path.clone(),
624 worktree: worktree.clone(),
625 is_private: old_file.is_private,
626 }
627 };
628
629 if new_file == **old_file {
630 return;
631 }
632
633 if new_file.path != old_file.path {
634 self.local_image_ids_by_path.remove(&ProjectPath {
635 path: old_file.path.clone(),
636 worktree_id: old_file.worktree_id(cx),
637 });
638 self.local_image_ids_by_path.insert(
639 ProjectPath {
640 worktree_id: new_file.worktree_id(cx),
641 path: new_file.path.clone(),
642 },
643 image_id,
644 );
645 }
646
647 if new_file.entry_id != old_file.entry_id {
648 if let Some(entry_id) = old_file.entry_id {
649 self.local_image_ids_by_entry_id.remove(&entry_id);
650 }
651 if let Some(entry_id) = new_file.entry_id {
652 self.local_image_ids_by_entry_id.insert(entry_id, image_id);
653 }
654 }
655
656 image.file_updated(Arc::new(new_file), cx);
657 });
658 None
659 }
660
661 fn image_changed_file(&mut self, image: Entity<ImageItem>, cx: &mut App) -> Option<()> {
662 let image = image.read(cx);
663 let file = &image.file;
664
665 let image_id = image.id;
666 if let Some(entry_id) = file.entry_id {
667 match self.local_image_ids_by_entry_id.get(&entry_id) {
668 Some(_) => {
669 return None;
670 }
671 None => {
672 self.local_image_ids_by_entry_id.insert(entry_id, image_id);
673 }
674 }
675 };
676 self.local_image_ids_by_path.insert(
677 ProjectPath {
678 worktree_id: file.worktree_id(cx),
679 path: file.path.clone(),
680 },
681 image_id,
682 );
683
684 Some(())
685 }
686}
687
688fn create_gpui_image(content: Vec<u8>) -> anyhow::Result<Arc<gpui::Image>> {
689 let format = image::guess_format(&content)?;
690
691 Ok(Arc::new(gpui::Image::from_bytes(
692 match format {
693 image::ImageFormat::Png => gpui::ImageFormat::Png,
694 image::ImageFormat::Jpeg => gpui::ImageFormat::Jpeg,
695 image::ImageFormat::WebP => gpui::ImageFormat::Webp,
696 image::ImageFormat::Gif => gpui::ImageFormat::Gif,
697 image::ImageFormat::Bmp => gpui::ImageFormat::Bmp,
698 image::ImageFormat::Tiff => gpui::ImageFormat::Tiff,
699 format => anyhow::bail!("Image format {format:?} not supported"),
700 },
701 content,
702 )))
703}
704
705impl ImageStoreImpl for Entity<RemoteImageStore> {
706 fn open_image(
707 &self,
708 _path: Arc<Path>,
709 _worktree: Entity<Worktree>,
710 _cx: &mut Context<ImageStore>,
711 ) -> Task<Result<Entity<ImageItem>>> {
712 Task::ready(Err(anyhow::anyhow!(
713 "Opening images from remote is not supported"
714 )))
715 }
716
717 fn reload_images(
718 &self,
719 _images: HashSet<Entity<ImageItem>>,
720 _cx: &mut Context<ImageStore>,
721 ) -> Task<Result<()>> {
722 Task::ready(Err(anyhow::anyhow!(
723 "Reloading images from remote is not supported"
724 )))
725 }
726
727 fn as_local(&self) -> Option<Entity<LocalImageStore>> {
728 None
729 }
730}
731
732#[cfg(test)]
733mod tests {
734 use super::*;
735 use fs::FakeFs;
736 use gpui::TestAppContext;
737 use serde_json::json;
738 use settings::SettingsStore;
739 use std::path::PathBuf;
740
741 pub fn init_test(cx: &mut TestAppContext) {
742 zlog::init_test();
743
744 cx.update(|cx| {
745 let settings_store = SettingsStore::test(cx);
746 cx.set_global(settings_store);
747 language::init(cx);
748 Project::init_settings(cx);
749 });
750 }
751
752 #[gpui::test]
753 async fn test_image_not_loaded_twice(cx: &mut TestAppContext) {
754 init_test(cx);
755 let fs = FakeFs::new(cx.executor());
756
757 fs.insert_tree("/root", json!({})).await;
758 // Create a png file that consists of a single white pixel
759 fs.insert_file(
760 "/root/image_1.png",
761 vec![
762 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48,
763 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00,
764 0x00, 0x1F, 0x15, 0xC4, 0x89, 0x00, 0x00, 0x00, 0x0A, 0x49, 0x44, 0x41, 0x54, 0x78,
765 0x9C, 0x63, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00,
766 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
767 ],
768 )
769 .await;
770
771 let project = Project::test(fs, ["/root".as_ref()], cx).await;
772
773 let worktree_id =
774 cx.update(|cx| project.read(cx).worktrees(cx).next().unwrap().read(cx).id());
775
776 let project_path = ProjectPath {
777 worktree_id,
778 path: PathBuf::from("image_1.png").into(),
779 };
780
781 let (task1, task2) = project.update(cx, |project, cx| {
782 (
783 project.open_image(project_path.clone(), cx),
784 project.open_image(project_path.clone(), cx),
785 )
786 });
787
788 let image1 = task1.await.unwrap();
789 let image2 = task2.await.unwrap();
790
791 assert_eq!(image1, image2);
792 }
793}