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