1pub mod file_associations;
2mod project_panel_settings;
3use settings::{Settings, SettingsStore};
4
5use db::kvp::KEY_VALUE_STORE;
6use editor::{scroll::autoscroll::Autoscroll, Cancel, Editor};
7use file_associations::FileAssociations;
8
9use anyhow::{anyhow, Result};
10use gpui::{
11 actions, div, px, uniform_list, Action, AppContext, AssetSource, AsyncWindowContext,
12 ClipboardItem, Div, EventEmitter, FocusHandle, Focusable, FocusableView, InteractiveElement,
13 Model, MouseDownEvent, ParentElement, Pixels, Point, PromptLevel, Render, Stateful, Styled,
14 Task, UniformListScrollHandle, View, ViewContext, VisualContext as _, WeakView, WindowContext,
15};
16use menu::{Confirm, SelectNext, SelectPrev};
17use project::{
18 repository::GitFileStatus, Entry, EntryKind, Fs, Project, ProjectEntryId, ProjectPath,
19 Worktree, WorktreeId,
20};
21use project_panel_settings::{ProjectPanelDockPosition, ProjectPanelSettings};
22use serde::{Deserialize, Serialize};
23use std::{
24 cmp::Ordering,
25 collections::{hash_map, HashMap},
26 ffi::OsStr,
27 ops::Range,
28 path::Path,
29 sync::Arc,
30};
31use theme::ActiveTheme as _;
32use ui::{v_stack, IconElement, Label, ListItem};
33use unicase::UniCase;
34use util::{maybe, ResultExt, TryFutureExt};
35use workspace::{
36 dock::{DockPosition, Panel, PanelEvent},
37 Workspace,
38};
39
40const PROJECT_PANEL_KEY: &'static str = "ProjectPanel";
41const NEW_ENTRY_ID: ProjectEntryId = ProjectEntryId::MAX;
42
43pub struct ProjectPanel {
44 project: Model<Project>,
45 fs: Arc<dyn Fs>,
46 list: UniformListScrollHandle,
47 focus_handle: FocusHandle,
48 visible_entries: Vec<(WorktreeId, Vec<Entry>)>,
49 last_worktree_root_id: Option<ProjectEntryId>,
50 expanded_dir_ids: HashMap<WorktreeId, Vec<ProjectEntryId>>,
51 selection: Option<Selection>,
52 edit_state: Option<EditState>,
53 filename_editor: View<Editor>,
54 clipboard_entry: Option<ClipboardEntry>,
55 _dragged_entry_destination: Option<Arc<Path>>,
56 _workspace: WeakView<Workspace>,
57 has_focus: bool,
58 width: Option<f32>,
59 pending_serialization: Task<Option<()>>,
60}
61
62#[derive(Copy, Clone, Debug)]
63struct Selection {
64 worktree_id: WorktreeId,
65 entry_id: ProjectEntryId,
66}
67
68#[derive(Clone, Debug)]
69struct EditState {
70 worktree_id: WorktreeId,
71 entry_id: ProjectEntryId,
72 is_new_entry: bool,
73 is_dir: bool,
74 processing_filename: Option<String>,
75}
76
77#[derive(Copy, Clone)]
78pub enum ClipboardEntry {
79 Copied {
80 worktree_id: WorktreeId,
81 entry_id: ProjectEntryId,
82 },
83 Cut {
84 worktree_id: WorktreeId,
85 entry_id: ProjectEntryId,
86 },
87}
88
89#[derive(Debug, PartialEq, Eq)]
90pub struct EntryDetails {
91 filename: String,
92 icon: Option<Arc<str>>,
93 path: Arc<Path>,
94 depth: usize,
95 kind: EntryKind,
96 is_ignored: bool,
97 is_expanded: bool,
98 is_selected: bool,
99 is_editing: bool,
100 is_processing: bool,
101 is_cut: bool,
102 git_status: Option<GitFileStatus>,
103}
104
105actions!(
106 ExpandSelectedEntry,
107 CollapseSelectedEntry,
108 CollapseAllEntries,
109 NewDirectory,
110 NewFile,
111 Copy,
112 CopyPath,
113 CopyRelativePath,
114 RevealInFinder,
115 OpenInTerminal,
116 Cut,
117 Paste,
118 Delete,
119 Rename,
120 Open,
121 ToggleFocus,
122 NewSearchInDirectory,
123);
124
125pub fn init_settings(cx: &mut AppContext) {
126 ProjectPanelSettings::register(cx);
127}
128
129pub fn init(assets: impl AssetSource, cx: &mut AppContext) {
130 init_settings(cx);
131 file_associations::init(assets, cx);
132
133 cx.observe_new_views(|workspace: &mut Workspace, _| {
134 workspace.register_action(|workspace, _: &ToggleFocus, cx| {
135 workspace.toggle_panel_focus::<ProjectPanel>(cx);
136 });
137 })
138 .detach();
139}
140
141#[derive(Debug)]
142pub enum Event {
143 OpenedEntry {
144 entry_id: ProjectEntryId,
145 focus_opened_item: bool,
146 },
147 SplitEntry {
148 entry_id: ProjectEntryId,
149 },
150 Focus,
151 NewSearchInDirectory {
152 dir_entry: Entry,
153 },
154 ActivatePanel,
155}
156
157#[derive(Serialize, Deserialize)]
158struct SerializedProjectPanel {
159 width: Option<f32>,
160}
161
162impl ProjectPanel {
163 fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> View<Self> {
164 let project = workspace.project().clone();
165 let project_panel = cx.build_view(|cx: &mut ViewContext<Self>| {
166 cx.observe(&project, |this, _, cx| {
167 this.update_visible_entries(None, cx);
168 cx.notify();
169 })
170 .detach();
171 let focus_handle = cx.focus_handle();
172
173 cx.on_focus(&focus_handle, Self::focus_in).detach();
174 cx.on_blur(&focus_handle, Self::focus_out).detach();
175
176 cx.subscribe(&project, |this, project, event, cx| match event {
177 project::Event::ActiveEntryChanged(Some(entry_id)) => {
178 if let Some(worktree_id) = project.read(cx).worktree_id_for_entry(*entry_id, cx)
179 {
180 this.expand_entry(worktree_id, *entry_id, cx);
181 this.update_visible_entries(Some((worktree_id, *entry_id)), cx);
182 this.autoscroll(cx);
183 cx.notify();
184 }
185 }
186 project::Event::ActivateProjectPanel => {
187 cx.emit(Event::ActivatePanel);
188 }
189 project::Event::WorktreeRemoved(id) => {
190 this.expanded_dir_ids.remove(id);
191 this.update_visible_entries(None, cx);
192 cx.notify();
193 }
194 _ => {}
195 })
196 .detach();
197
198 let filename_editor = cx.build_view(|cx| Editor::single_line(cx));
199
200 cx.subscribe(&filename_editor, |this, _, event, cx| match event {
201 editor::EditorEvent::BufferEdited
202 | editor::EditorEvent::SelectionsChanged { .. } => {
203 this.autoscroll(cx);
204 }
205 editor::EditorEvent::Blurred => {
206 if this
207 .edit_state
208 .as_ref()
209 .map_or(false, |state| state.processing_filename.is_none())
210 {
211 this.edit_state = None;
212 this.update_visible_entries(None, cx);
213 }
214 }
215 _ => {}
216 })
217 .detach();
218
219 // cx.observe_global::<FileAssociations, _>(|_, cx| {
220 // cx.notify();
221 // })
222 // .detach();
223
224 let mut this = Self {
225 project: project.clone(),
226 fs: workspace.app_state().fs.clone(),
227 list: UniformListScrollHandle::new(),
228 focus_handle,
229 visible_entries: Default::default(),
230 last_worktree_root_id: Default::default(),
231 expanded_dir_ids: Default::default(),
232 selection: None,
233 edit_state: None,
234 filename_editor,
235 clipboard_entry: None,
236 // context_menu: cx.add_view(|cx| ContextMenu::new(view_id, cx)),
237 _dragged_entry_destination: None,
238 _workspace: workspace.weak_handle(),
239 has_focus: false,
240 width: None,
241 pending_serialization: Task::ready(None),
242 };
243 this.update_visible_entries(None, cx);
244
245 // Update the dock position when the setting changes.
246 let mut old_dock_position = this.position(cx);
247 ProjectPanelSettings::register(cx);
248 cx.observe_global::<SettingsStore>(move |this, cx| {
249 let new_dock_position = this.position(cx);
250 if new_dock_position != old_dock_position {
251 old_dock_position = new_dock_position;
252 cx.emit(PanelEvent::ChangePosition);
253 }
254 })
255 .detach();
256
257 this
258 });
259
260 cx.subscribe(&project_panel, {
261 let project_panel = project_panel.downgrade();
262 move |workspace, _, event, cx| match event {
263 &Event::OpenedEntry {
264 entry_id,
265 focus_opened_item,
266 } => {
267 if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
268 if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
269 workspace
270 .open_path(
271 ProjectPath {
272 worktree_id: worktree.read(cx).id(),
273 path: entry.path.clone(),
274 },
275 None,
276 focus_opened_item,
277 cx,
278 )
279 .detach_and_log_err(cx);
280 if !focus_opened_item {
281 if let Some(project_panel) = project_panel.upgrade() {
282 let focus_handle = project_panel.read(cx).focus_handle.clone();
283 cx.focus(&focus_handle);
284 }
285 }
286 }
287 }
288 }
289 &Event::SplitEntry { entry_id } => {
290 if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
291 if let Some(_entry) = worktree.read(cx).entry_for_id(entry_id) {
292 // workspace
293 // .split_path(
294 // ProjectPath {
295 // worktree_id: worktree.read(cx).id(),
296 // path: entry.path.clone(),
297 // },
298 // cx,
299 // )
300 // .detach_and_log_err(cx);
301 }
302 }
303 }
304 _ => {}
305 }
306 })
307 .detach();
308
309 project_panel
310 }
311
312 pub async fn load(
313 workspace: WeakView<Workspace>,
314 mut cx: AsyncWindowContext,
315 ) -> Result<View<Self>> {
316 let serialized_panel = cx
317 .background_executor()
318 .spawn(async move { KEY_VALUE_STORE.read_kvp(PROJECT_PANEL_KEY) })
319 .await
320 .map_err(|e| anyhow!("Failed to load project panel: {}", e))
321 .log_err()
322 .flatten()
323 .map(|panel| serde_json::from_str::<SerializedProjectPanel>(&panel))
324 .transpose()
325 .log_err()
326 .flatten();
327
328 workspace.update(&mut cx, |workspace, cx| {
329 let panel = ProjectPanel::new(workspace, cx);
330 if let Some(serialized_panel) = serialized_panel {
331 panel.update(cx, |panel, cx| {
332 panel.width = serialized_panel.width;
333 cx.notify();
334 });
335 }
336 panel
337 })
338 }
339
340 fn serialize(&mut self, cx: &mut ViewContext<Self>) {
341 let width = self.width;
342 self.pending_serialization = cx.background_executor().spawn(
343 async move {
344 KEY_VALUE_STORE
345 .write_kvp(
346 PROJECT_PANEL_KEY.into(),
347 serde_json::to_string(&SerializedProjectPanel { width })?,
348 )
349 .await?;
350 anyhow::Ok(())
351 }
352 .log_err(),
353 );
354 }
355
356 fn focus_in(&mut self, cx: &mut ViewContext<Self>) {
357 if !self.has_focus {
358 self.has_focus = true;
359 cx.emit(Event::Focus);
360 }
361 }
362
363 fn focus_out(&mut self, _: &mut ViewContext<Self>) {
364 self.has_focus = false;
365 }
366
367 fn deploy_context_menu(
368 &mut self,
369 _position: Point<Pixels>,
370 _entry_id: ProjectEntryId,
371 _cx: &mut ViewContext<Self>,
372 ) {
373 // todo!()
374 // let project = self.project.read(cx);
375
376 // let worktree_id = if let Some(id) = project.worktree_id_for_entry(entry_id, cx) {
377 // id
378 // } else {
379 // return;
380 // };
381
382 // self.selection = Some(Selection {
383 // worktree_id,
384 // entry_id,
385 // });
386
387 // let mut menu_entries = Vec::new();
388 // if let Some((worktree, entry)) = self.selected_entry(cx) {
389 // let is_root = Some(entry) == worktree.root_entry();
390 // if !project.is_remote() {
391 // menu_entries.push(ContextMenuItem::action(
392 // "Add Folder to Project",
393 // workspace::AddFolderToProject,
394 // ));
395 // if is_root {
396 // let project = self.project.clone();
397 // menu_entries.push(ContextMenuItem::handler("Remove from Project", move |cx| {
398 // project.update(cx, |project, cx| project.remove_worktree(worktree_id, cx));
399 // }));
400 // }
401 // }
402 // menu_entries.push(ContextMenuItem::action("New File", NewFile));
403 // menu_entries.push(ContextMenuItem::action("New Folder", NewDirectory));
404 // menu_entries.push(ContextMenuItem::Separator);
405 // menu_entries.push(ContextMenuItem::action("Cut", Cut));
406 // menu_entries.push(ContextMenuItem::action("Copy", Copy));
407 // if let Some(clipboard_entry) = self.clipboard_entry {
408 // if clipboard_entry.worktree_id() == worktree.id() {
409 // menu_entries.push(ContextMenuItem::action("Paste", Paste));
410 // }
411 // }
412 // menu_entries.push(ContextMenuItem::Separator);
413 // menu_entries.push(ContextMenuItem::action("Copy Path", CopyPath));
414 // menu_entries.push(ContextMenuItem::action(
415 // "Copy Relative Path",
416 // CopyRelativePath,
417 // ));
418
419 // if entry.is_dir() {
420 // menu_entries.push(ContextMenuItem::Separator);
421 // }
422 // menu_entries.push(ContextMenuItem::action("Reveal in Finder", RevealInFinder));
423 // if entry.is_dir() {
424 // menu_entries.push(ContextMenuItem::action("Open in Terminal", OpenInTerminal));
425 // menu_entries.push(ContextMenuItem::action(
426 // "Search Inside",
427 // NewSearchInDirectory,
428 // ));
429 // }
430
431 // menu_entries.push(ContextMenuItem::Separator);
432 // menu_entries.push(ContextMenuItem::action("Rename", Rename));
433 // if !is_root {
434 // menu_entries.push(ContextMenuItem::action("Delete", Delete));
435 // }
436 // }
437
438 // // self.context_menu.update(cx, |menu, cx| {
439 // // menu.show(position, AnchorCorner::TopLeft, menu_entries, cx);
440 // // });
441
442 // cx.notify();
443 }
444
445 fn expand_selected_entry(&mut self, _: &ExpandSelectedEntry, cx: &mut ViewContext<Self>) {
446 if let Some((worktree, entry)) = self.selected_entry(cx) {
447 if entry.is_dir() {
448 let worktree_id = worktree.id();
449 let entry_id = entry.id;
450 let expanded_dir_ids =
451 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
452 expanded_dir_ids
453 } else {
454 return;
455 };
456
457 match expanded_dir_ids.binary_search(&entry_id) {
458 Ok(_) => self.select_next(&SelectNext, cx),
459 Err(ix) => {
460 self.project.update(cx, |project, cx| {
461 project.expand_entry(worktree_id, entry_id, cx);
462 });
463
464 expanded_dir_ids.insert(ix, entry_id);
465 self.update_visible_entries(None, cx);
466 cx.notify();
467 }
468 }
469 }
470 }
471 }
472
473 fn collapse_selected_entry(&mut self, _: &CollapseSelectedEntry, cx: &mut ViewContext<Self>) {
474 if let Some((worktree, mut entry)) = self.selected_entry(cx) {
475 let worktree_id = worktree.id();
476 let expanded_dir_ids =
477 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
478 expanded_dir_ids
479 } else {
480 return;
481 };
482
483 loop {
484 let entry_id = entry.id;
485 match expanded_dir_ids.binary_search(&entry_id) {
486 Ok(ix) => {
487 expanded_dir_ids.remove(ix);
488 self.update_visible_entries(Some((worktree_id, entry_id)), cx);
489 cx.notify();
490 break;
491 }
492 Err(_) => {
493 if let Some(parent_entry) =
494 entry.path.parent().and_then(|p| worktree.entry_for_path(p))
495 {
496 entry = parent_entry;
497 } else {
498 break;
499 }
500 }
501 }
502 }
503 }
504 }
505
506 pub fn collapse_all_entries(&mut self, _: &CollapseAllEntries, cx: &mut ViewContext<Self>) {
507 self.expanded_dir_ids.clear();
508 self.update_visible_entries(None, cx);
509 cx.notify();
510 }
511
512 fn toggle_expanded(&mut self, entry_id: ProjectEntryId, cx: &mut ViewContext<Self>) {
513 if let Some(worktree_id) = self.project.read(cx).worktree_id_for_entry(entry_id, cx) {
514 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
515 self.project.update(cx, |project, cx| {
516 match expanded_dir_ids.binary_search(&entry_id) {
517 Ok(ix) => {
518 expanded_dir_ids.remove(ix);
519 }
520 Err(ix) => {
521 project.expand_entry(worktree_id, entry_id, cx);
522 expanded_dir_ids.insert(ix, entry_id);
523 }
524 }
525 });
526 self.update_visible_entries(Some((worktree_id, entry_id)), cx);
527 cx.focus(&self.focus_handle);
528 cx.notify();
529 }
530 }
531 }
532
533 fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
534 if let Some(selection) = self.selection {
535 let (mut worktree_ix, mut entry_ix, _) =
536 self.index_for_selection(selection).unwrap_or_default();
537 if entry_ix > 0 {
538 entry_ix -= 1;
539 } else if worktree_ix > 0 {
540 worktree_ix -= 1;
541 entry_ix = self.visible_entries[worktree_ix].1.len() - 1;
542 } else {
543 return;
544 }
545
546 let (worktree_id, worktree_entries) = &self.visible_entries[worktree_ix];
547 self.selection = Some(Selection {
548 worktree_id: *worktree_id,
549 entry_id: worktree_entries[entry_ix].id,
550 });
551 self.autoscroll(cx);
552 cx.notify();
553 } else {
554 self.select_first(cx);
555 }
556 }
557
558 fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
559 if let Some(task) = self.confirm_edit(cx) {
560 task.detach_and_log_err(cx);
561 }
562 }
563
564 fn open_file(&mut self, _: &Open, cx: &mut ViewContext<Self>) {
565 if let Some((_, entry)) = self.selected_entry(cx) {
566 if entry.is_file() {
567 self.open_entry(entry.id, true, cx);
568 }
569 }
570 }
571
572 fn confirm_edit(&mut self, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
573 let edit_state = self.edit_state.as_mut()?;
574 cx.focus(&self.focus_handle);
575
576 let worktree_id = edit_state.worktree_id;
577 let is_new_entry = edit_state.is_new_entry;
578 let is_dir = edit_state.is_dir;
579 let worktree = self.project.read(cx).worktree_for_id(worktree_id, cx)?;
580 let entry = worktree.read(cx).entry_for_id(edit_state.entry_id)?.clone();
581 let filename = self.filename_editor.read(cx).text(cx);
582
583 let path_already_exists = |path| worktree.read(cx).entry_for_path(path).is_some();
584 let edit_task;
585 let edited_entry_id;
586 if is_new_entry {
587 self.selection = Some(Selection {
588 worktree_id,
589 entry_id: NEW_ENTRY_ID,
590 });
591 let new_path = entry.path.join(&filename.trim_start_matches("/"));
592 if path_already_exists(new_path.as_path()) {
593 return None;
594 }
595
596 edited_entry_id = NEW_ENTRY_ID;
597 edit_task = self.project.update(cx, |project, cx| {
598 project.create_entry((worktree_id, &new_path), is_dir, cx)
599 })?;
600 } else {
601 let new_path = if let Some(parent) = entry.path.clone().parent() {
602 parent.join(&filename)
603 } else {
604 filename.clone().into()
605 };
606 if path_already_exists(new_path.as_path()) {
607 return None;
608 }
609
610 edited_entry_id = entry.id;
611 edit_task = self.project.update(cx, |project, cx| {
612 project.rename_entry(entry.id, new_path.as_path(), cx)
613 })?;
614 };
615
616 edit_state.processing_filename = Some(filename);
617 cx.notify();
618
619 Some(cx.spawn(|this, mut cx| async move {
620 let new_entry = edit_task.await;
621 this.update(&mut cx, |this, cx| {
622 this.edit_state.take();
623 cx.notify();
624 })?;
625
626 let new_entry = new_entry?;
627 this.update(&mut cx, |this, cx| {
628 if let Some(selection) = &mut this.selection {
629 if selection.entry_id == edited_entry_id {
630 selection.worktree_id = worktree_id;
631 selection.entry_id = new_entry.id;
632 this.expand_to_selection(cx);
633 }
634 }
635 this.update_visible_entries(None, cx);
636 if is_new_entry && !is_dir {
637 this.open_entry(new_entry.id, true, cx);
638 }
639 cx.notify();
640 })?;
641 Ok(())
642 }))
643 }
644
645 fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
646 dbg!("odd");
647 self.edit_state = None;
648 self.update_visible_entries(None, cx);
649 cx.focus(&self.focus_handle);
650 cx.notify();
651 }
652
653 fn open_entry(
654 &mut self,
655 entry_id: ProjectEntryId,
656 focus_opened_item: bool,
657 cx: &mut ViewContext<Self>,
658 ) {
659 cx.emit(Event::OpenedEntry {
660 entry_id,
661 focus_opened_item,
662 });
663 }
664
665 fn split_entry(&mut self, entry_id: ProjectEntryId, cx: &mut ViewContext<Self>) {
666 cx.emit(Event::SplitEntry { entry_id });
667 }
668
669 fn new_file(&mut self, _: &NewFile, cx: &mut ViewContext<Self>) {
670 self.add_entry(false, cx)
671 }
672
673 fn new_directory(&mut self, _: &NewDirectory, cx: &mut ViewContext<Self>) {
674 self.add_entry(true, cx)
675 }
676
677 fn add_entry(&mut self, is_dir: bool, cx: &mut ViewContext<Self>) {
678 if let Some(Selection {
679 worktree_id,
680 entry_id,
681 }) = self.selection
682 {
683 let directory_id;
684 if let Some((worktree, expanded_dir_ids)) = self
685 .project
686 .read(cx)
687 .worktree_for_id(worktree_id, cx)
688 .zip(self.expanded_dir_ids.get_mut(&worktree_id))
689 {
690 let worktree = worktree.read(cx);
691 if let Some(mut entry) = worktree.entry_for_id(entry_id) {
692 loop {
693 if entry.is_dir() {
694 if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
695 expanded_dir_ids.insert(ix, entry.id);
696 }
697 directory_id = entry.id;
698 break;
699 } else {
700 if let Some(parent_path) = entry.path.parent() {
701 if let Some(parent_entry) = worktree.entry_for_path(parent_path) {
702 entry = parent_entry;
703 continue;
704 }
705 }
706 return;
707 }
708 }
709 } else {
710 return;
711 };
712 } else {
713 return;
714 };
715
716 self.edit_state = Some(EditState {
717 worktree_id,
718 entry_id: directory_id,
719 is_new_entry: true,
720 is_dir,
721 processing_filename: None,
722 });
723 self.filename_editor.update(cx, |editor, cx| {
724 editor.clear(cx);
725 editor.focus(cx);
726 });
727 self.update_visible_entries(Some((worktree_id, NEW_ENTRY_ID)), cx);
728 self.autoscroll(cx);
729 cx.notify();
730 }
731 }
732
733 fn rename(&mut self, _: &Rename, cx: &mut ViewContext<Self>) {
734 if let Some(Selection {
735 worktree_id,
736 entry_id,
737 }) = self.selection
738 {
739 if let Some(worktree) = self.project.read(cx).worktree_for_id(worktree_id, cx) {
740 if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
741 self.edit_state = Some(EditState {
742 worktree_id,
743 entry_id,
744 is_new_entry: false,
745 is_dir: entry.is_dir(),
746 processing_filename: None,
747 });
748 let file_name = entry
749 .path
750 .file_name()
751 .map(|s| s.to_string_lossy())
752 .unwrap_or_default()
753 .to_string();
754 let file_stem = entry.path.file_stem().map(|s| s.to_string_lossy());
755 let selection_end =
756 file_stem.map_or(file_name.len(), |file_stem| file_stem.len());
757 self.filename_editor.update(cx, |editor, cx| {
758 editor.set_text(file_name, cx);
759 editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
760 s.select_ranges([0..selection_end])
761 });
762 editor.focus(cx);
763 });
764 self.update_visible_entries(None, cx);
765 self.autoscroll(cx);
766 cx.notify();
767 }
768 }
769
770 // cx.update_global(|drag_and_drop: &mut DragAndDrop<Workspace>, cx| {
771 // drag_and_drop.cancel_dragging::<ProjectEntryId>(cx);
772 // })
773 }
774 }
775
776 fn delete(&mut self, _: &Delete, cx: &mut ViewContext<Self>) {
777 maybe!({
778 let Selection { entry_id, .. } = self.selection?;
779 let path = self.project.read(cx).path_for_entry(entry_id, cx)?.path;
780 let file_name = path.file_name()?;
781
782 let answer = cx.prompt(
783 PromptLevel::Info,
784 &format!("Delete {file_name:?}?"),
785 &["Delete", "Cancel"],
786 );
787
788 cx.spawn(|this, mut cx| async move {
789 if answer.await != Ok(0) {
790 return Ok(());
791 }
792 this.update(&mut cx, |this, cx| {
793 this.project
794 .update(cx, |project, cx| project.delete_entry(entry_id, cx))
795 .ok_or_else(|| anyhow!("no such entry"))
796 })??
797 .await
798 })
799 .detach_and_log_err(cx);
800 Some(())
801 });
802 }
803
804 fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
805 if let Some(selection) = self.selection {
806 let (mut worktree_ix, mut entry_ix, _) =
807 self.index_for_selection(selection).unwrap_or_default();
808 if let Some((_, worktree_entries)) = self.visible_entries.get(worktree_ix) {
809 if entry_ix + 1 < worktree_entries.len() {
810 entry_ix += 1;
811 } else {
812 worktree_ix += 1;
813 entry_ix = 0;
814 }
815 }
816
817 if let Some((worktree_id, worktree_entries)) = self.visible_entries.get(worktree_ix) {
818 if let Some(entry) = worktree_entries.get(entry_ix) {
819 self.selection = Some(Selection {
820 worktree_id: *worktree_id,
821 entry_id: entry.id,
822 });
823 self.autoscroll(cx);
824 cx.notify();
825 }
826 }
827 } else {
828 self.select_first(cx);
829 }
830 }
831
832 fn select_first(&mut self, cx: &mut ViewContext<Self>) {
833 let worktree = self
834 .visible_entries
835 .first()
836 .and_then(|(worktree_id, _)| self.project.read(cx).worktree_for_id(*worktree_id, cx));
837 if let Some(worktree) = worktree {
838 let worktree = worktree.read(cx);
839 let worktree_id = worktree.id();
840 if let Some(root_entry) = worktree.root_entry() {
841 self.selection = Some(Selection {
842 worktree_id,
843 entry_id: root_entry.id,
844 });
845 self.autoscroll(cx);
846 cx.notify();
847 }
848 }
849 }
850
851 fn autoscroll(&mut self, cx: &mut ViewContext<Self>) {
852 if let Some((_, _, index)) = self.selection.and_then(|s| self.index_for_selection(s)) {
853 self.list.scroll_to_item(index);
854 cx.notify();
855 }
856 }
857
858 fn cut(&mut self, _: &Cut, cx: &mut ViewContext<Self>) {
859 if let Some((worktree, entry)) = self.selected_entry(cx) {
860 self.clipboard_entry = Some(ClipboardEntry::Cut {
861 worktree_id: worktree.id(),
862 entry_id: entry.id,
863 });
864 cx.notify();
865 }
866 }
867
868 fn copy(&mut self, _: &Copy, cx: &mut ViewContext<Self>) {
869 if let Some((worktree, entry)) = self.selected_entry(cx) {
870 self.clipboard_entry = Some(ClipboardEntry::Copied {
871 worktree_id: worktree.id(),
872 entry_id: entry.id,
873 });
874 cx.notify();
875 }
876 }
877
878 fn paste(&mut self, _: &Paste, cx: &mut ViewContext<Self>) {
879 maybe!({
880 let (worktree, entry) = self.selected_entry(cx)?;
881 let clipboard_entry = self.clipboard_entry?;
882 if clipboard_entry.worktree_id() != worktree.id() {
883 return None;
884 }
885
886 let clipboard_entry_file_name = self
887 .project
888 .read(cx)
889 .path_for_entry(clipboard_entry.entry_id(), cx)?
890 .path
891 .file_name()?
892 .to_os_string();
893
894 let mut new_path = entry.path.to_path_buf();
895 if entry.is_file() {
896 new_path.pop();
897 }
898
899 new_path.push(&clipboard_entry_file_name);
900 let extension = new_path.extension().map(|e| e.to_os_string());
901 let file_name_without_extension = Path::new(&clipboard_entry_file_name).file_stem()?;
902 let mut ix = 0;
903 while worktree.entry_for_path(&new_path).is_some() {
904 new_path.pop();
905
906 let mut new_file_name = file_name_without_extension.to_os_string();
907 new_file_name.push(" copy");
908 if ix > 0 {
909 new_file_name.push(format!(" {}", ix));
910 }
911 if let Some(extension) = extension.as_ref() {
912 new_file_name.push(".");
913 new_file_name.push(extension);
914 }
915
916 new_path.push(new_file_name);
917 ix += 1;
918 }
919
920 if clipboard_entry.is_cut() {
921 if let Some(task) = self.project.update(cx, |project, cx| {
922 project.rename_entry(clipboard_entry.entry_id(), new_path, cx)
923 }) {
924 task.detach_and_log_err(cx);
925 }
926 } else if let Some(task) = self.project.update(cx, |project, cx| {
927 project.copy_entry(clipboard_entry.entry_id(), new_path, cx)
928 }) {
929 task.detach_and_log_err(cx);
930 }
931
932 Some(())
933 });
934 }
935
936 fn copy_path(&mut self, _: &CopyPath, cx: &mut ViewContext<Self>) {
937 if let Some((worktree, entry)) = self.selected_entry(cx) {
938 cx.write_to_clipboard(ClipboardItem::new(
939 worktree
940 .abs_path()
941 .join(&entry.path)
942 .to_string_lossy()
943 .to_string(),
944 ));
945 }
946 }
947
948 fn copy_relative_path(&mut self, _: &CopyRelativePath, cx: &mut ViewContext<Self>) {
949 if let Some((_, entry)) = self.selected_entry(cx) {
950 cx.write_to_clipboard(ClipboardItem::new(entry.path.to_string_lossy().to_string()));
951 }
952 }
953
954 fn reveal_in_finder(&mut self, _: &RevealInFinder, cx: &mut ViewContext<Self>) {
955 if let Some((worktree, entry)) = self.selected_entry(cx) {
956 cx.reveal_path(&worktree.abs_path().join(&entry.path));
957 }
958 }
959
960 fn open_in_terminal(&mut self, _: &OpenInTerminal, _cx: &mut ViewContext<Self>) {
961 todo!()
962 // if let Some((worktree, entry)) = self.selected_entry(cx) {
963 // let window = cx.window();
964 // let view_id = cx.view_id();
965 // let path = worktree.abs_path().join(&entry.path);
966
967 // cx.app_context()
968 // .spawn(|mut cx| async move {
969 // window.dispatch_action(
970 // view_id,
971 // &workspace::OpenTerminal {
972 // working_directory: path,
973 // },
974 // &mut cx,
975 // );
976 // })
977 // .detach();
978 // }
979 }
980
981 pub fn new_search_in_directory(
982 &mut self,
983 _: &NewSearchInDirectory,
984 cx: &mut ViewContext<Self>,
985 ) {
986 if let Some((_, entry)) = self.selected_entry(cx) {
987 if entry.is_dir() {
988 cx.emit(Event::NewSearchInDirectory {
989 dir_entry: entry.clone(),
990 });
991 }
992 }
993 }
994
995 // todo!()
996 // fn move_entry(
997 // &mut self,
998 // entry_to_move: ProjectEntryId,
999 // destination: ProjectEntryId,
1000 // destination_is_file: bool,
1001 // cx: &mut ViewContext<Self>,
1002 // ) {
1003 // let destination_worktree = self.project.update(cx, |project, cx| {
1004 // let entry_path = project.path_for_entry(entry_to_move, cx)?;
1005 // let destination_entry_path = project.path_for_entry(destination, cx)?.path.clone();
1006
1007 // let mut destination_path = destination_entry_path.as_ref();
1008 // if destination_is_file {
1009 // destination_path = destination_path.parent()?;
1010 // }
1011
1012 // let mut new_path = destination_path.to_path_buf();
1013 // new_path.push(entry_path.path.file_name()?);
1014 // if new_path != entry_path.path.as_ref() {
1015 // let task = project.rename_entry(entry_to_move, new_path, cx)?;
1016 // cx.foreground_executor().spawn(task).detach_and_log_err(cx);
1017 // }
1018
1019 // Some(project.worktree_id_for_entry(destination, cx)?)
1020 // });
1021
1022 // if let Some(destination_worktree) = destination_worktree {
1023 // self.expand_entry(destination_worktree, destination, cx);
1024 // }
1025 // }
1026
1027 fn index_for_selection(&self, selection: Selection) -> Option<(usize, usize, usize)> {
1028 let mut entry_index = 0;
1029 let mut visible_entries_index = 0;
1030 for (worktree_index, (worktree_id, worktree_entries)) in
1031 self.visible_entries.iter().enumerate()
1032 {
1033 if *worktree_id == selection.worktree_id {
1034 for entry in worktree_entries {
1035 if entry.id == selection.entry_id {
1036 return Some((worktree_index, entry_index, visible_entries_index));
1037 } else {
1038 visible_entries_index += 1;
1039 entry_index += 1;
1040 }
1041 }
1042 break;
1043 } else {
1044 visible_entries_index += worktree_entries.len();
1045 }
1046 }
1047 None
1048 }
1049
1050 pub fn selected_entry<'a>(
1051 &self,
1052 cx: &'a AppContext,
1053 ) -> Option<(&'a Worktree, &'a project::Entry)> {
1054 let (worktree, entry) = self.selected_entry_handle(cx)?;
1055 Some((worktree.read(cx), entry))
1056 }
1057
1058 fn selected_entry_handle<'a>(
1059 &self,
1060 cx: &'a AppContext,
1061 ) -> Option<(Model<Worktree>, &'a project::Entry)> {
1062 let selection = self.selection?;
1063 let project = self.project.read(cx);
1064 let worktree = project.worktree_for_id(selection.worktree_id, cx)?;
1065 let entry = worktree.read(cx).entry_for_id(selection.entry_id)?;
1066 Some((worktree, entry))
1067 }
1068
1069 fn expand_to_selection(&mut self, cx: &mut ViewContext<Self>) -> Option<()> {
1070 let (worktree, entry) = self.selected_entry(cx)?;
1071 let expanded_dir_ids = self.expanded_dir_ids.entry(worktree.id()).or_default();
1072
1073 for path in entry.path.ancestors() {
1074 let Some(entry) = worktree.entry_for_path(path) else {
1075 continue;
1076 };
1077 if entry.is_dir() {
1078 if let Err(idx) = expanded_dir_ids.binary_search(&entry.id) {
1079 expanded_dir_ids.insert(idx, entry.id);
1080 }
1081 }
1082 }
1083
1084 Some(())
1085 }
1086
1087 fn update_visible_entries(
1088 &mut self,
1089 new_selected_entry: Option<(WorktreeId, ProjectEntryId)>,
1090 cx: &mut ViewContext<Self>,
1091 ) {
1092 let project = self.project.read(cx);
1093 self.last_worktree_root_id = project
1094 .visible_worktrees(cx)
1095 .rev()
1096 .next()
1097 .and_then(|worktree| worktree.read(cx).root_entry())
1098 .map(|entry| entry.id);
1099
1100 self.visible_entries.clear();
1101 for worktree in project.visible_worktrees(cx) {
1102 let snapshot = worktree.read(cx).snapshot();
1103 let worktree_id = snapshot.id();
1104
1105 let expanded_dir_ids = match self.expanded_dir_ids.entry(worktree_id) {
1106 hash_map::Entry::Occupied(e) => e.into_mut(),
1107 hash_map::Entry::Vacant(e) => {
1108 // The first time a worktree's root entry becomes available,
1109 // mark that root entry as expanded.
1110 if let Some(entry) = snapshot.root_entry() {
1111 e.insert(vec![entry.id]).as_slice()
1112 } else {
1113 &[]
1114 }
1115 }
1116 };
1117
1118 let mut new_entry_parent_id = None;
1119 let mut new_entry_kind = EntryKind::Dir;
1120 if let Some(edit_state) = &self.edit_state {
1121 if edit_state.worktree_id == worktree_id && edit_state.is_new_entry {
1122 new_entry_parent_id = Some(edit_state.entry_id);
1123 new_entry_kind = if edit_state.is_dir {
1124 EntryKind::Dir
1125 } else {
1126 EntryKind::File(Default::default())
1127 };
1128 }
1129 }
1130
1131 let mut visible_worktree_entries = Vec::new();
1132 let mut entry_iter = snapshot.entries(true);
1133
1134 while let Some(entry) = entry_iter.entry() {
1135 visible_worktree_entries.push(entry.clone());
1136 if Some(entry.id) == new_entry_parent_id {
1137 visible_worktree_entries.push(Entry {
1138 id: NEW_ENTRY_ID,
1139 kind: new_entry_kind,
1140 path: entry.path.join("\0").into(),
1141 inode: 0,
1142 mtime: entry.mtime,
1143 is_symlink: false,
1144 is_ignored: false,
1145 is_external: false,
1146 git_status: entry.git_status,
1147 });
1148 }
1149 if expanded_dir_ids.binary_search(&entry.id).is_err()
1150 && entry_iter.advance_to_sibling()
1151 {
1152 continue;
1153 }
1154 entry_iter.advance();
1155 }
1156
1157 snapshot.propagate_git_statuses(&mut visible_worktree_entries);
1158
1159 visible_worktree_entries.sort_by(|entry_a, entry_b| {
1160 let mut components_a = entry_a.path.components().peekable();
1161 let mut components_b = entry_b.path.components().peekable();
1162 loop {
1163 match (components_a.next(), components_b.next()) {
1164 (Some(component_a), Some(component_b)) => {
1165 let a_is_file = components_a.peek().is_none() && entry_a.is_file();
1166 let b_is_file = components_b.peek().is_none() && entry_b.is_file();
1167 let ordering = a_is_file.cmp(&b_is_file).then_with(|| {
1168 let name_a =
1169 UniCase::new(component_a.as_os_str().to_string_lossy());
1170 let name_b =
1171 UniCase::new(component_b.as_os_str().to_string_lossy());
1172 name_a.cmp(&name_b)
1173 });
1174 if !ordering.is_eq() {
1175 return ordering;
1176 }
1177 }
1178 (Some(_), None) => break Ordering::Greater,
1179 (None, Some(_)) => break Ordering::Less,
1180 (None, None) => break Ordering::Equal,
1181 }
1182 }
1183 });
1184 self.visible_entries
1185 .push((worktree_id, visible_worktree_entries));
1186 }
1187
1188 if let Some((worktree_id, entry_id)) = new_selected_entry {
1189 self.selection = Some(Selection {
1190 worktree_id,
1191 entry_id,
1192 });
1193 }
1194 }
1195
1196 fn expand_entry(
1197 &mut self,
1198 worktree_id: WorktreeId,
1199 entry_id: ProjectEntryId,
1200 cx: &mut ViewContext<Self>,
1201 ) {
1202 self.project.update(cx, |project, cx| {
1203 if let Some((worktree, expanded_dir_ids)) = project
1204 .worktree_for_id(worktree_id, cx)
1205 .zip(self.expanded_dir_ids.get_mut(&worktree_id))
1206 {
1207 project.expand_entry(worktree_id, entry_id, cx);
1208 let worktree = worktree.read(cx);
1209
1210 if let Some(mut entry) = worktree.entry_for_id(entry_id) {
1211 loop {
1212 if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
1213 expanded_dir_ids.insert(ix, entry.id);
1214 }
1215
1216 if let Some(parent_entry) =
1217 entry.path.parent().and_then(|p| worktree.entry_for_path(p))
1218 {
1219 entry = parent_entry;
1220 } else {
1221 break;
1222 }
1223 }
1224 }
1225 }
1226 });
1227 }
1228
1229 fn for_each_visible_entry(
1230 &self,
1231 range: Range<usize>,
1232 cx: &mut ViewContext<ProjectPanel>,
1233 mut callback: impl FnMut(ProjectEntryId, EntryDetails, &mut ViewContext<ProjectPanel>),
1234 ) {
1235 let mut ix = 0;
1236 for (worktree_id, visible_worktree_entries) in &self.visible_entries {
1237 if ix >= range.end {
1238 return;
1239 }
1240
1241 if ix + visible_worktree_entries.len() <= range.start {
1242 ix += visible_worktree_entries.len();
1243 continue;
1244 }
1245
1246 let end_ix = range.end.min(ix + visible_worktree_entries.len());
1247 let (git_status_setting, show_file_icons, show_folder_icons) = {
1248 let settings = ProjectPanelSettings::get_global(cx);
1249 (
1250 settings.git_status,
1251 settings.file_icons,
1252 settings.folder_icons,
1253 )
1254 };
1255 if let Some(worktree) = self.project.read(cx).worktree_for_id(*worktree_id, cx) {
1256 let snapshot = worktree.read(cx).snapshot();
1257 let root_name = OsStr::new(snapshot.root_name());
1258 let expanded_entry_ids = self
1259 .expanded_dir_ids
1260 .get(&snapshot.id())
1261 .map(Vec::as_slice)
1262 .unwrap_or(&[]);
1263
1264 let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
1265 for entry in visible_worktree_entries[entry_range].iter() {
1266 let status = git_status_setting.then(|| entry.git_status).flatten();
1267 let is_expanded = expanded_entry_ids.binary_search(&entry.id).is_ok();
1268 let icon = match entry.kind {
1269 EntryKind::File(_) => {
1270 if show_file_icons {
1271 FileAssociations::get_icon(&entry.path, cx)
1272 } else {
1273 None
1274 }
1275 }
1276 _ => {
1277 if show_folder_icons {
1278 FileAssociations::get_folder_icon(is_expanded, cx)
1279 } else {
1280 FileAssociations::get_chevron_icon(is_expanded, cx)
1281 }
1282 }
1283 };
1284
1285 let mut details = EntryDetails {
1286 filename: entry
1287 .path
1288 .file_name()
1289 .unwrap_or(root_name)
1290 .to_string_lossy()
1291 .to_string(),
1292 icon,
1293 path: entry.path.clone(),
1294 depth: entry.path.components().count(),
1295 kind: entry.kind,
1296 is_ignored: entry.is_ignored,
1297 is_expanded,
1298 is_selected: self.selection.map_or(false, |e| {
1299 e.worktree_id == snapshot.id() && e.entry_id == entry.id
1300 }),
1301 is_editing: false,
1302 is_processing: false,
1303 is_cut: self
1304 .clipboard_entry
1305 .map_or(false, |e| e.is_cut() && e.entry_id() == entry.id),
1306 git_status: status,
1307 };
1308
1309 if let Some(edit_state) = &self.edit_state {
1310 let is_edited_entry = if edit_state.is_new_entry {
1311 entry.id == NEW_ENTRY_ID
1312 } else {
1313 entry.id == edit_state.entry_id
1314 };
1315
1316 if is_edited_entry {
1317 if let Some(processing_filename) = &edit_state.processing_filename {
1318 details.is_processing = true;
1319 details.filename.clear();
1320 details.filename.push_str(processing_filename);
1321 } else {
1322 if edit_state.is_new_entry {
1323 details.filename.clear();
1324 }
1325 details.is_editing = true;
1326 }
1327 }
1328 }
1329
1330 callback(entry.id, details, cx);
1331 }
1332 }
1333 ix = end_ix;
1334 }
1335 }
1336
1337 fn render_entry(
1338 &self,
1339 entry_id: ProjectEntryId,
1340 details: EntryDetails,
1341 // dragged_entry_destination: &mut Option<Arc<Path>>,
1342 cx: &mut ViewContext<Self>,
1343 ) -> ListItem {
1344 let kind = details.kind;
1345 let settings = ProjectPanelSettings::get_global(cx);
1346 let show_editor = details.is_editing && !details.is_processing;
1347 let is_selected = self
1348 .selection
1349 .map_or(false, |selection| selection.entry_id == entry_id);
1350
1351 let theme = cx.theme();
1352 let filename_text_color = details
1353 .git_status
1354 .as_ref()
1355 .map(|status| match status {
1356 GitFileStatus::Added => theme.status().created,
1357 GitFileStatus::Modified => theme.status().modified,
1358 GitFileStatus::Conflict => theme.status().conflict,
1359 })
1360 .unwrap_or(theme.status().info);
1361
1362 ListItem::new(entry_id.to_proto() as usize)
1363 .indent_level(details.depth)
1364 .indent_step_size(px(settings.indent_size))
1365 .selected(is_selected)
1366 .child(if let Some(icon) = &details.icon {
1367 div().child(IconElement::from_path(icon.to_string()))
1368 } else {
1369 div()
1370 })
1371 .child(
1372 if let (Some(editor), true) = (Some(&self.filename_editor), show_editor) {
1373 div().w_full().child(editor.clone())
1374 } else {
1375 div()
1376 .text_color(filename_text_color)
1377 .child(Label::new(details.filename.clone()))
1378 }
1379 .ml_1(),
1380 )
1381 .on_click(cx.listener(move |this, event: &gpui::ClickEvent, cx| {
1382 if !show_editor {
1383 if kind.is_dir() {
1384 this.toggle_expanded(entry_id, cx);
1385 } else {
1386 if event.down.modifiers.command {
1387 this.split_entry(entry_id, cx);
1388 } else {
1389 this.open_entry(entry_id, event.up.click_count > 1, cx);
1390 }
1391 }
1392 }
1393 }))
1394 .on_secondary_mouse_down(cx.listener(move |this, event: &MouseDownEvent, cx| {
1395 this.deploy_context_menu(event.position, entry_id, cx);
1396 }))
1397 // .on_drop::<ProjectEntryId>(|this, event, cx| {
1398 // this.move_entry(
1399 // *dragged_entry,
1400 // entry_id,
1401 // matches!(details.kind, EntryKind::File(_)),
1402 // cx,
1403 // );
1404 // })
1405 }
1406}
1407
1408impl Render for ProjectPanel {
1409 type Element = Focusable<Stateful<Div>>;
1410
1411 fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> Self::Element {
1412 let has_worktree = self.visible_entries.len() != 0;
1413
1414 if has_worktree {
1415 div()
1416 .id("project-panel")
1417 .size_full()
1418 .key_context("ProjectPanel")
1419 .on_action(cx.listener(Self::select_next))
1420 .on_action(cx.listener(Self::select_prev))
1421 .on_action(cx.listener(Self::expand_selected_entry))
1422 .on_action(cx.listener(Self::collapse_selected_entry))
1423 .on_action(cx.listener(Self::collapse_all_entries))
1424 .on_action(cx.listener(Self::new_file))
1425 .on_action(cx.listener(Self::new_directory))
1426 .on_action(cx.listener(Self::rename))
1427 .on_action(cx.listener(Self::delete))
1428 .on_action(cx.listener(Self::confirm))
1429 .on_action(cx.listener(Self::open_file))
1430 .on_action(cx.listener(Self::cancel))
1431 .on_action(cx.listener(Self::cut))
1432 .on_action(cx.listener(Self::copy))
1433 .on_action(cx.listener(Self::copy_path))
1434 .on_action(cx.listener(Self::copy_relative_path))
1435 .on_action(cx.listener(Self::paste))
1436 .on_action(cx.listener(Self::reveal_in_finder))
1437 .on_action(cx.listener(Self::open_in_terminal))
1438 .on_action(cx.listener(Self::new_search_in_directory))
1439 .track_focus(&self.focus_handle)
1440 .child(
1441 uniform_list(
1442 cx.view().clone(),
1443 "entries",
1444 self.visible_entries
1445 .iter()
1446 .map(|(_, worktree_entries)| worktree_entries.len())
1447 .sum(),
1448 {
1449 |this, range, cx| {
1450 let mut items = Vec::new();
1451 this.for_each_visible_entry(range, cx, |id, details, cx| {
1452 items.push(this.render_entry(id, details, cx));
1453 });
1454 items
1455 }
1456 },
1457 )
1458 .size_full()
1459 .track_scroll(self.list.clone()),
1460 )
1461 } else {
1462 v_stack()
1463 .id("empty-project_panel")
1464 .track_focus(&self.focus_handle)
1465 }
1466 }
1467}
1468
1469impl EventEmitter<Event> for ProjectPanel {}
1470
1471impl EventEmitter<PanelEvent> for ProjectPanel {}
1472
1473impl Panel for ProjectPanel {
1474 fn position(&self, cx: &WindowContext) -> DockPosition {
1475 match ProjectPanelSettings::get_global(cx).dock {
1476 ProjectPanelDockPosition::Left => DockPosition::Left,
1477 ProjectPanelDockPosition::Right => DockPosition::Right,
1478 }
1479 }
1480
1481 fn position_is_valid(&self, position: DockPosition) -> bool {
1482 matches!(position, DockPosition::Left | DockPosition::Right)
1483 }
1484
1485 fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
1486 settings::update_settings_file::<ProjectPanelSettings>(
1487 self.fs.clone(),
1488 cx,
1489 move |settings| {
1490 let dock = match position {
1491 DockPosition::Left | DockPosition::Bottom => ProjectPanelDockPosition::Left,
1492 DockPosition::Right => ProjectPanelDockPosition::Right,
1493 };
1494 settings.dock = Some(dock);
1495 },
1496 );
1497 }
1498
1499 fn size(&self, cx: &WindowContext) -> f32 {
1500 self.width
1501 .unwrap_or_else(|| ProjectPanelSettings::get_global(cx).default_width)
1502 }
1503
1504 fn set_size(&mut self, size: Option<f32>, cx: &mut ViewContext<Self>) {
1505 self.width = size;
1506 self.serialize(cx);
1507 cx.notify();
1508 }
1509
1510 fn icon(&self, _: &WindowContext) -> Option<ui::Icon> {
1511 Some(ui::Icon::FileTree)
1512 }
1513
1514 fn toggle_action(&self) -> Box<dyn Action> {
1515 Box::new(ToggleFocus)
1516 }
1517
1518 fn has_focus(&self, _: &WindowContext) -> bool {
1519 self.has_focus
1520 }
1521
1522 fn persistent_name() -> &'static str {
1523 "Project Panel"
1524 }
1525}
1526
1527impl FocusableView for ProjectPanel {
1528 fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
1529 self.focus_handle.clone()
1530 }
1531}
1532
1533impl ClipboardEntry {
1534 fn is_cut(&self) -> bool {
1535 matches!(self, Self::Cut { .. })
1536 }
1537
1538 fn entry_id(&self) -> ProjectEntryId {
1539 match self {
1540 ClipboardEntry::Copied { entry_id, .. } | ClipboardEntry::Cut { entry_id, .. } => {
1541 *entry_id
1542 }
1543 }
1544 }
1545
1546 fn worktree_id(&self) -> WorktreeId {
1547 match self {
1548 ClipboardEntry::Copied { worktree_id, .. }
1549 | ClipboardEntry::Cut { worktree_id, .. } => *worktree_id,
1550 }
1551 }
1552}
1553
1554#[cfg(test)]
1555mod tests {
1556 use super::*;
1557 use gpui::{TestAppContext, View, VisualTestContext, WindowHandle};
1558 use pretty_assertions::assert_eq;
1559 use project::{project_settings::ProjectSettings, FakeFs};
1560 use serde_json::json;
1561 use settings::SettingsStore;
1562 use std::{
1563 collections::HashSet,
1564 path::{Path, PathBuf},
1565 sync::atomic::{self, AtomicUsize},
1566 };
1567 use workspace::AppState;
1568
1569 #[gpui::test]
1570 async fn test_visible_list(cx: &mut gpui::TestAppContext) {
1571 init_test(cx);
1572
1573 let fs = FakeFs::new(cx.executor().clone());
1574 fs.insert_tree(
1575 "/root1",
1576 json!({
1577 ".dockerignore": "",
1578 ".git": {
1579 "HEAD": "",
1580 },
1581 "a": {
1582 "0": { "q": "", "r": "", "s": "" },
1583 "1": { "t": "", "u": "" },
1584 "2": { "v": "", "w": "", "x": "", "y": "" },
1585 },
1586 "b": {
1587 "3": { "Q": "" },
1588 "4": { "R": "", "S": "", "T": "", "U": "" },
1589 },
1590 "C": {
1591 "5": {},
1592 "6": { "V": "", "W": "" },
1593 "7": { "X": "" },
1594 "8": { "Y": {}, "Z": "" }
1595 }
1596 }),
1597 )
1598 .await;
1599 fs.insert_tree(
1600 "/root2",
1601 json!({
1602 "d": {
1603 "9": ""
1604 },
1605 "e": {}
1606 }),
1607 )
1608 .await;
1609
1610 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1611 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1612 let cx = &mut VisualTestContext::from_window(*workspace, cx);
1613 let panel = workspace
1614 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
1615 .unwrap();
1616 assert_eq!(
1617 visible_entries_as_strings(&panel, 0..50, cx),
1618 &[
1619 "v root1",
1620 " > .git",
1621 " > a",
1622 " > b",
1623 " > C",
1624 " .dockerignore",
1625 "v root2",
1626 " > d",
1627 " > e",
1628 ]
1629 );
1630
1631 toggle_expand_dir(&panel, "root1/b", cx);
1632 assert_eq!(
1633 visible_entries_as_strings(&panel, 0..50, cx),
1634 &[
1635 "v root1",
1636 " > .git",
1637 " > a",
1638 " v b <== selected",
1639 " > 3",
1640 " > 4",
1641 " > C",
1642 " .dockerignore",
1643 "v root2",
1644 " > d",
1645 " > e",
1646 ]
1647 );
1648
1649 assert_eq!(
1650 visible_entries_as_strings(&panel, 6..9, cx),
1651 &[
1652 //
1653 " > C",
1654 " .dockerignore",
1655 "v root2",
1656 ]
1657 );
1658 }
1659
1660 #[gpui::test]
1661 async fn test_exclusions_in_visible_list(cx: &mut gpui::TestAppContext) {
1662 init_test(cx);
1663 cx.update(|cx| {
1664 cx.update_global::<SettingsStore, _>(|store, cx| {
1665 store.update_user_settings::<ProjectSettings>(cx, |project_settings| {
1666 project_settings.file_scan_exclusions =
1667 Some(vec!["**/.git".to_string(), "**/4/**".to_string()]);
1668 });
1669 });
1670 });
1671
1672 let fs = FakeFs::new(cx.background_executor.clone());
1673 fs.insert_tree(
1674 "/root1",
1675 json!({
1676 ".dockerignore": "",
1677 ".git": {
1678 "HEAD": "",
1679 },
1680 "a": {
1681 "0": { "q": "", "r": "", "s": "" },
1682 "1": { "t": "", "u": "" },
1683 "2": { "v": "", "w": "", "x": "", "y": "" },
1684 },
1685 "b": {
1686 "3": { "Q": "" },
1687 "4": { "R": "", "S": "", "T": "", "U": "" },
1688 },
1689 "C": {
1690 "5": {},
1691 "6": { "V": "", "W": "" },
1692 "7": { "X": "" },
1693 "8": { "Y": {}, "Z": "" }
1694 }
1695 }),
1696 )
1697 .await;
1698 fs.insert_tree(
1699 "/root2",
1700 json!({
1701 "d": {
1702 "4": ""
1703 },
1704 "e": {}
1705 }),
1706 )
1707 .await;
1708
1709 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1710 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1711 let cx = &mut VisualTestContext::from_window(*workspace, cx);
1712 let panel = workspace
1713 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
1714 .unwrap();
1715 assert_eq!(
1716 visible_entries_as_strings(&panel, 0..50, cx),
1717 &[
1718 "v root1",
1719 " > a",
1720 " > b",
1721 " > C",
1722 " .dockerignore",
1723 "v root2",
1724 " > d",
1725 " > e",
1726 ]
1727 );
1728
1729 toggle_expand_dir(&panel, "root1/b", cx);
1730 assert_eq!(
1731 visible_entries_as_strings(&panel, 0..50, cx),
1732 &[
1733 "v root1",
1734 " > a",
1735 " v b <== selected",
1736 " > 3",
1737 " > C",
1738 " .dockerignore",
1739 "v root2",
1740 " > d",
1741 " > e",
1742 ]
1743 );
1744
1745 toggle_expand_dir(&panel, "root2/d", cx);
1746 assert_eq!(
1747 visible_entries_as_strings(&panel, 0..50, cx),
1748 &[
1749 "v root1",
1750 " > a",
1751 " v b",
1752 " > 3",
1753 " > C",
1754 " .dockerignore",
1755 "v root2",
1756 " v d <== selected",
1757 " > e",
1758 ]
1759 );
1760
1761 toggle_expand_dir(&panel, "root2/e", cx);
1762 assert_eq!(
1763 visible_entries_as_strings(&panel, 0..50, cx),
1764 &[
1765 "v root1",
1766 " > a",
1767 " v b",
1768 " > 3",
1769 " > C",
1770 " .dockerignore",
1771 "v root2",
1772 " v d",
1773 " v e <== selected",
1774 ]
1775 );
1776 }
1777
1778 #[gpui::test(iterations = 30)]
1779 async fn test_editing_files(cx: &mut gpui::TestAppContext) {
1780 init_test(cx);
1781
1782 let fs = FakeFs::new(cx.executor().clone());
1783 fs.insert_tree(
1784 "/root1",
1785 json!({
1786 ".dockerignore": "",
1787 ".git": {
1788 "HEAD": "",
1789 },
1790 "a": {
1791 "0": { "q": "", "r": "", "s": "" },
1792 "1": { "t": "", "u": "" },
1793 "2": { "v": "", "w": "", "x": "", "y": "" },
1794 },
1795 "b": {
1796 "3": { "Q": "" },
1797 "4": { "R": "", "S": "", "T": "", "U": "" },
1798 },
1799 "C": {
1800 "5": {},
1801 "6": { "V": "", "W": "" },
1802 "7": { "X": "" },
1803 "8": { "Y": {}, "Z": "" }
1804 }
1805 }),
1806 )
1807 .await;
1808 fs.insert_tree(
1809 "/root2",
1810 json!({
1811 "d": {
1812 "9": ""
1813 },
1814 "e": {}
1815 }),
1816 )
1817 .await;
1818
1819 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1820 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1821 let cx = &mut VisualTestContext::from_window(*workspace, cx);
1822 let panel = workspace
1823 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
1824 .unwrap();
1825
1826 select_path(&panel, "root1", cx);
1827 assert_eq!(
1828 visible_entries_as_strings(&panel, 0..10, cx),
1829 &[
1830 "v root1 <== selected",
1831 " > .git",
1832 " > a",
1833 " > b",
1834 " > C",
1835 " .dockerignore",
1836 "v root2",
1837 " > d",
1838 " > e",
1839 ]
1840 );
1841
1842 // Add a file with the root folder selected. The filename editor is placed
1843 // before the first file in the root folder.
1844 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
1845 panel.update(cx, |panel, cx| {
1846 assert!(panel.filename_editor.read(cx).is_focused(cx));
1847 });
1848 assert_eq!(
1849 visible_entries_as_strings(&panel, 0..10, cx),
1850 &[
1851 "v root1",
1852 " > .git",
1853 " > a",
1854 " > b",
1855 " > C",
1856 " [EDITOR: ''] <== selected",
1857 " .dockerignore",
1858 "v root2",
1859 " > d",
1860 " > e",
1861 ]
1862 );
1863
1864 let confirm = panel.update(cx, |panel, cx| {
1865 panel
1866 .filename_editor
1867 .update(cx, |editor, cx| editor.set_text("the-new-filename", cx));
1868 panel.confirm_edit(cx).unwrap()
1869 });
1870 assert_eq!(
1871 visible_entries_as_strings(&panel, 0..10, cx),
1872 &[
1873 "v root1",
1874 " > .git",
1875 " > a",
1876 " > b",
1877 " > C",
1878 " [PROCESSING: 'the-new-filename'] <== selected",
1879 " .dockerignore",
1880 "v root2",
1881 " > d",
1882 " > e",
1883 ]
1884 );
1885
1886 confirm.await.unwrap();
1887 assert_eq!(
1888 visible_entries_as_strings(&panel, 0..10, cx),
1889 &[
1890 "v root1",
1891 " > .git",
1892 " > a",
1893 " > b",
1894 " > C",
1895 " .dockerignore",
1896 " the-new-filename <== selected",
1897 "v root2",
1898 " > d",
1899 " > e",
1900 ]
1901 );
1902
1903 select_path(&panel, "root1/b", cx);
1904 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
1905 assert_eq!(
1906 visible_entries_as_strings(&panel, 0..10, cx),
1907 &[
1908 "v root1",
1909 " > .git",
1910 " > a",
1911 " v b",
1912 " > 3",
1913 " > 4",
1914 " [EDITOR: ''] <== selected",
1915 " > C",
1916 " .dockerignore",
1917 " the-new-filename",
1918 ]
1919 );
1920
1921 panel
1922 .update(cx, |panel, cx| {
1923 panel
1924 .filename_editor
1925 .update(cx, |editor, cx| editor.set_text("another-filename.txt", cx));
1926 panel.confirm_edit(cx).unwrap()
1927 })
1928 .await
1929 .unwrap();
1930 assert_eq!(
1931 visible_entries_as_strings(&panel, 0..10, cx),
1932 &[
1933 "v root1",
1934 " > .git",
1935 " > a",
1936 " v b",
1937 " > 3",
1938 " > 4",
1939 " another-filename.txt <== selected",
1940 " > C",
1941 " .dockerignore",
1942 " the-new-filename",
1943 ]
1944 );
1945
1946 select_path(&panel, "root1/b/another-filename.txt", cx);
1947 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
1948 assert_eq!(
1949 visible_entries_as_strings(&panel, 0..10, cx),
1950 &[
1951 "v root1",
1952 " > .git",
1953 " > a",
1954 " v b",
1955 " > 3",
1956 " > 4",
1957 " [EDITOR: 'another-filename.txt'] <== selected",
1958 " > C",
1959 " .dockerignore",
1960 " the-new-filename",
1961 ]
1962 );
1963
1964 let confirm = panel.update(cx, |panel, cx| {
1965 panel.filename_editor.update(cx, |editor, cx| {
1966 let file_name_selections = editor.selections.all::<usize>(cx);
1967 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
1968 let file_name_selection = &file_name_selections[0];
1969 assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
1970 assert_eq!(file_name_selection.end, "another-filename".len(), "Should not select file extension");
1971
1972 editor.set_text("a-different-filename.tar.gz", cx)
1973 });
1974 panel.confirm_edit(cx).unwrap()
1975 });
1976 assert_eq!(
1977 visible_entries_as_strings(&panel, 0..10, cx),
1978 &[
1979 "v root1",
1980 " > .git",
1981 " > a",
1982 " v b",
1983 " > 3",
1984 " > 4",
1985 " [PROCESSING: 'a-different-filename.tar.gz'] <== selected",
1986 " > C",
1987 " .dockerignore",
1988 " the-new-filename",
1989 ]
1990 );
1991
1992 confirm.await.unwrap();
1993 assert_eq!(
1994 visible_entries_as_strings(&panel, 0..10, cx),
1995 &[
1996 "v root1",
1997 " > .git",
1998 " > a",
1999 " v b",
2000 " > 3",
2001 " > 4",
2002 " a-different-filename.tar.gz <== selected",
2003 " > C",
2004 " .dockerignore",
2005 " the-new-filename",
2006 ]
2007 );
2008
2009 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2010 assert_eq!(
2011 visible_entries_as_strings(&panel, 0..10, cx),
2012 &[
2013 "v root1",
2014 " > .git",
2015 " > a",
2016 " v b",
2017 " > 3",
2018 " > 4",
2019 " [EDITOR: 'a-different-filename.tar.gz'] <== selected",
2020 " > C",
2021 " .dockerignore",
2022 " the-new-filename",
2023 ]
2024 );
2025
2026 panel.update(cx, |panel, cx| {
2027 panel.filename_editor.update(cx, |editor, cx| {
2028 let file_name_selections = editor.selections.all::<usize>(cx);
2029 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
2030 let file_name_selection = &file_name_selections[0];
2031 assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
2032 assert_eq!(file_name_selection.end, "a-different-filename.tar".len(), "Should not select file extension, but still may select anything up to the last dot..");
2033
2034 });
2035 panel.cancel(&Cancel, cx)
2036 });
2037
2038 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2039 assert_eq!(
2040 visible_entries_as_strings(&panel, 0..10, cx),
2041 &[
2042 "v root1",
2043 " > .git",
2044 " > a",
2045 " v b",
2046 " > [EDITOR: ''] <== selected",
2047 " > 3",
2048 " > 4",
2049 " a-different-filename.tar.gz",
2050 " > C",
2051 " .dockerignore",
2052 ]
2053 );
2054
2055 let confirm = panel.update(cx, |panel, cx| {
2056 panel
2057 .filename_editor
2058 .update(cx, |editor, cx| editor.set_text("new-dir", cx));
2059 panel.confirm_edit(cx).unwrap()
2060 });
2061 panel.update(cx, |panel, cx| panel.select_next(&Default::default(), cx));
2062 assert_eq!(
2063 visible_entries_as_strings(&panel, 0..10, cx),
2064 &[
2065 "v root1",
2066 " > .git",
2067 " > a",
2068 " v b",
2069 " > [PROCESSING: 'new-dir']",
2070 " > 3 <== selected",
2071 " > 4",
2072 " a-different-filename.tar.gz",
2073 " > C",
2074 " .dockerignore",
2075 ]
2076 );
2077
2078 confirm.await.unwrap();
2079 assert_eq!(
2080 visible_entries_as_strings(&panel, 0..10, cx),
2081 &[
2082 "v root1",
2083 " > .git",
2084 " > a",
2085 " v b",
2086 " > 3 <== selected",
2087 " > 4",
2088 " > new-dir",
2089 " a-different-filename.tar.gz",
2090 " > C",
2091 " .dockerignore",
2092 ]
2093 );
2094
2095 panel.update(cx, |panel, cx| panel.rename(&Default::default(), cx));
2096 assert_eq!(
2097 visible_entries_as_strings(&panel, 0..10, cx),
2098 &[
2099 "v root1",
2100 " > .git",
2101 " > a",
2102 " v b",
2103 " > [EDITOR: '3'] <== selected",
2104 " > 4",
2105 " > new-dir",
2106 " a-different-filename.tar.gz",
2107 " > C",
2108 " .dockerignore",
2109 ]
2110 );
2111
2112 // Dismiss the rename editor when it loses focus.
2113 workspace.update(cx, |_, cx| cx.blur()).unwrap();
2114 assert_eq!(
2115 visible_entries_as_strings(&panel, 0..10, cx),
2116 &[
2117 "v root1",
2118 " > .git",
2119 " > a",
2120 " v b",
2121 " > 3 <== selected",
2122 " > 4",
2123 " > new-dir",
2124 " a-different-filename.tar.gz",
2125 " > C",
2126 " .dockerignore",
2127 ]
2128 );
2129 }
2130
2131 #[gpui::test(iterations = 10)]
2132 async fn test_adding_directories_via_file(cx: &mut gpui::TestAppContext) {
2133 init_test(cx);
2134
2135 let fs = FakeFs::new(cx.executor().clone());
2136 fs.insert_tree(
2137 "/root1",
2138 json!({
2139 ".dockerignore": "",
2140 ".git": {
2141 "HEAD": "",
2142 },
2143 "a": {
2144 "0": { "q": "", "r": "", "s": "" },
2145 "1": { "t": "", "u": "" },
2146 "2": { "v": "", "w": "", "x": "", "y": "" },
2147 },
2148 "b": {
2149 "3": { "Q": "" },
2150 "4": { "R": "", "S": "", "T": "", "U": "" },
2151 },
2152 "C": {
2153 "5": {},
2154 "6": { "V": "", "W": "" },
2155 "7": { "X": "" },
2156 "8": { "Y": {}, "Z": "" }
2157 }
2158 }),
2159 )
2160 .await;
2161 fs.insert_tree(
2162 "/root2",
2163 json!({
2164 "d": {
2165 "9": ""
2166 },
2167 "e": {}
2168 }),
2169 )
2170 .await;
2171
2172 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
2173 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2174 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2175 let panel = workspace
2176 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2177 .unwrap();
2178
2179 select_path(&panel, "root1", cx);
2180 assert_eq!(
2181 visible_entries_as_strings(&panel, 0..10, cx),
2182 &[
2183 "v root1 <== selected",
2184 " > .git",
2185 " > a",
2186 " > b",
2187 " > C",
2188 " .dockerignore",
2189 "v root2",
2190 " > d",
2191 " > e",
2192 ]
2193 );
2194
2195 // Add a file with the root folder selected. The filename editor is placed
2196 // before the first file in the root folder.
2197 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2198 panel.update(cx, |panel, cx| {
2199 assert!(panel.filename_editor.read(cx).is_focused(cx));
2200 });
2201 assert_eq!(
2202 visible_entries_as_strings(&panel, 0..10, cx),
2203 &[
2204 "v root1",
2205 " > .git",
2206 " > a",
2207 " > b",
2208 " > C",
2209 " [EDITOR: ''] <== selected",
2210 " .dockerignore",
2211 "v root2",
2212 " > d",
2213 " > e",
2214 ]
2215 );
2216
2217 let confirm = panel.update(cx, |panel, cx| {
2218 panel.filename_editor.update(cx, |editor, cx| {
2219 editor.set_text("/bdir1/dir2/the-new-filename", cx)
2220 });
2221 panel.confirm_edit(cx).unwrap()
2222 });
2223
2224 assert_eq!(
2225 visible_entries_as_strings(&panel, 0..10, cx),
2226 &[
2227 "v root1",
2228 " > .git",
2229 " > a",
2230 " > b",
2231 " > C",
2232 " [PROCESSING: '/bdir1/dir2/the-new-filename'] <== selected",
2233 " .dockerignore",
2234 "v root2",
2235 " > d",
2236 " > e",
2237 ]
2238 );
2239
2240 confirm.await.unwrap();
2241 assert_eq!(
2242 visible_entries_as_strings(&panel, 0..13, cx),
2243 &[
2244 "v root1",
2245 " > .git",
2246 " > a",
2247 " > b",
2248 " v bdir1",
2249 " v dir2",
2250 " the-new-filename <== selected",
2251 " > C",
2252 " .dockerignore",
2253 "v root2",
2254 " > d",
2255 " > e",
2256 ]
2257 );
2258 }
2259
2260 #[gpui::test]
2261 async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
2262 init_test(cx);
2263
2264 let fs = FakeFs::new(cx.executor().clone());
2265 fs.insert_tree(
2266 "/root1",
2267 json!({
2268 "one.two.txt": "",
2269 "one.txt": ""
2270 }),
2271 )
2272 .await;
2273
2274 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
2275 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2276 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2277 let panel = workspace
2278 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2279 .unwrap();
2280
2281 panel.update(cx, |panel, cx| {
2282 panel.select_next(&Default::default(), cx);
2283 panel.select_next(&Default::default(), cx);
2284 });
2285
2286 assert_eq!(
2287 visible_entries_as_strings(&panel, 0..50, cx),
2288 &[
2289 //
2290 "v root1",
2291 " one.two.txt <== selected",
2292 " one.txt",
2293 ]
2294 );
2295
2296 // Regression test - file name is created correctly when
2297 // the copied file's name contains multiple dots.
2298 panel.update(cx, |panel, cx| {
2299 panel.copy(&Default::default(), cx);
2300 panel.paste(&Default::default(), cx);
2301 });
2302 cx.executor().run_until_parked();
2303
2304 assert_eq!(
2305 visible_entries_as_strings(&panel, 0..50, cx),
2306 &[
2307 //
2308 "v root1",
2309 " one.two copy.txt",
2310 " one.two.txt <== selected",
2311 " one.txt",
2312 ]
2313 );
2314
2315 panel.update(cx, |panel, cx| {
2316 panel.paste(&Default::default(), cx);
2317 });
2318 cx.executor().run_until_parked();
2319
2320 assert_eq!(
2321 visible_entries_as_strings(&panel, 0..50, cx),
2322 &[
2323 //
2324 "v root1",
2325 " one.two copy 1.txt",
2326 " one.two copy.txt",
2327 " one.two.txt <== selected",
2328 " one.txt",
2329 ]
2330 );
2331 }
2332
2333 #[gpui::test]
2334 async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
2335 init_test_with_editor(cx);
2336
2337 let fs = FakeFs::new(cx.executor().clone());
2338 fs.insert_tree(
2339 "/src",
2340 json!({
2341 "test": {
2342 "first.rs": "// First Rust file",
2343 "second.rs": "// Second Rust file",
2344 "third.rs": "// Third Rust file",
2345 }
2346 }),
2347 )
2348 .await;
2349
2350 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2351 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2352 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2353 let panel = workspace
2354 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2355 .unwrap();
2356
2357 toggle_expand_dir(&panel, "src/test", cx);
2358 select_path(&panel, "src/test/first.rs", cx);
2359 panel.update(cx, |panel, cx| panel.open_file(&Open, cx));
2360 cx.executor().run_until_parked();
2361 assert_eq!(
2362 visible_entries_as_strings(&panel, 0..10, cx),
2363 &[
2364 "v src",
2365 " v test",
2366 " first.rs <== selected",
2367 " second.rs",
2368 " third.rs"
2369 ]
2370 );
2371 ensure_single_file_is_opened(&workspace, "test/first.rs", cx);
2372
2373 submit_deletion(&panel, cx);
2374 assert_eq!(
2375 visible_entries_as_strings(&panel, 0..10, cx),
2376 &[
2377 "v src",
2378 " v test",
2379 " second.rs",
2380 " third.rs"
2381 ],
2382 "Project panel should have no deleted file, no other file is selected in it"
2383 );
2384 ensure_no_open_items_and_panes(&workspace, cx);
2385
2386 select_path(&panel, "src/test/second.rs", cx);
2387 panel.update(cx, |panel, cx| panel.open_file(&Open, cx));
2388 cx.executor().run_until_parked();
2389 assert_eq!(
2390 visible_entries_as_strings(&panel, 0..10, cx),
2391 &[
2392 "v src",
2393 " v test",
2394 " second.rs <== selected",
2395 " third.rs"
2396 ]
2397 );
2398 ensure_single_file_is_opened(&workspace, "test/second.rs", cx);
2399
2400 workspace
2401 .update(cx, |workspace, cx| {
2402 let active_items = workspace
2403 .panes()
2404 .iter()
2405 .filter_map(|pane| pane.read(cx).active_item())
2406 .collect::<Vec<_>>();
2407 assert_eq!(active_items.len(), 1);
2408 let open_editor = active_items
2409 .into_iter()
2410 .next()
2411 .unwrap()
2412 .downcast::<Editor>()
2413 .expect("Open item should be an editor");
2414 open_editor.update(cx, |editor, cx| editor.set_text("Another text!", cx));
2415 })
2416 .unwrap();
2417 submit_deletion(&panel, cx);
2418 assert_eq!(
2419 visible_entries_as_strings(&panel, 0..10, cx),
2420 &["v src", " v test", " third.rs"],
2421 "Project panel should have no deleted file, with one last file remaining"
2422 );
2423 ensure_no_open_items_and_panes(&workspace, cx);
2424 }
2425
2426 #[gpui::test]
2427 async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
2428 init_test_with_editor(cx);
2429
2430 let fs = FakeFs::new(cx.executor().clone());
2431 fs.insert_tree(
2432 "/src",
2433 json!({
2434 "test": {
2435 "first.rs": "// First Rust file",
2436 "second.rs": "// Second Rust file",
2437 "third.rs": "// Third Rust file",
2438 }
2439 }),
2440 )
2441 .await;
2442
2443 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2444 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2445 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2446 let panel = workspace
2447 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2448 .unwrap();
2449
2450 select_path(&panel, "src/", cx);
2451 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2452 cx.executor().run_until_parked();
2453 assert_eq!(
2454 visible_entries_as_strings(&panel, 0..10, cx),
2455 &[
2456 //
2457 "v src <== selected",
2458 " > test"
2459 ]
2460 );
2461 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2462 panel.update(cx, |panel, cx| {
2463 assert!(panel.filename_editor.read(cx).is_focused(cx));
2464 });
2465 assert_eq!(
2466 visible_entries_as_strings(&panel, 0..10, cx),
2467 &[
2468 //
2469 "v src",
2470 " > [EDITOR: ''] <== selected",
2471 " > test"
2472 ]
2473 );
2474 panel.update(cx, |panel, cx| {
2475 panel
2476 .filename_editor
2477 .update(cx, |editor, cx| editor.set_text("test", cx));
2478 assert!(
2479 panel.confirm_edit(cx).is_none(),
2480 "Should not allow to confirm on conflicting new directory name"
2481 )
2482 });
2483 assert_eq!(
2484 visible_entries_as_strings(&panel, 0..10, cx),
2485 &[
2486 //
2487 "v src",
2488 " > test"
2489 ],
2490 "File list should be unchanged after failed folder create confirmation"
2491 );
2492
2493 select_path(&panel, "src/test/", cx);
2494 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2495 cx.executor().run_until_parked();
2496 assert_eq!(
2497 visible_entries_as_strings(&panel, 0..10, cx),
2498 &[
2499 //
2500 "v src",
2501 " > test <== selected"
2502 ]
2503 );
2504 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2505 panel.update(cx, |panel, cx| {
2506 assert!(panel.filename_editor.read(cx).is_focused(cx));
2507 });
2508 assert_eq!(
2509 visible_entries_as_strings(&panel, 0..10, cx),
2510 &[
2511 "v src",
2512 " v test",
2513 " [EDITOR: ''] <== selected",
2514 " first.rs",
2515 " second.rs",
2516 " third.rs"
2517 ]
2518 );
2519 panel.update(cx, |panel, cx| {
2520 panel
2521 .filename_editor
2522 .update(cx, |editor, cx| editor.set_text("first.rs", cx));
2523 assert!(
2524 panel.confirm_edit(cx).is_none(),
2525 "Should not allow to confirm on conflicting new file name"
2526 )
2527 });
2528 assert_eq!(
2529 visible_entries_as_strings(&panel, 0..10, cx),
2530 &[
2531 "v src",
2532 " v test",
2533 " first.rs",
2534 " second.rs",
2535 " third.rs"
2536 ],
2537 "File list should be unchanged after failed file create confirmation"
2538 );
2539
2540 select_path(&panel, "src/test/first.rs", cx);
2541 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2542 cx.executor().run_until_parked();
2543 assert_eq!(
2544 visible_entries_as_strings(&panel, 0..10, cx),
2545 &[
2546 "v src",
2547 " v test",
2548 " first.rs <== selected",
2549 " second.rs",
2550 " third.rs"
2551 ],
2552 );
2553 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2554 panel.update(cx, |panel, cx| {
2555 assert!(panel.filename_editor.read(cx).is_focused(cx));
2556 });
2557 assert_eq!(
2558 visible_entries_as_strings(&panel, 0..10, cx),
2559 &[
2560 "v src",
2561 " v test",
2562 " [EDITOR: 'first.rs'] <== selected",
2563 " second.rs",
2564 " third.rs"
2565 ]
2566 );
2567 panel.update(cx, |panel, cx| {
2568 panel
2569 .filename_editor
2570 .update(cx, |editor, cx| editor.set_text("second.rs", cx));
2571 assert!(
2572 panel.confirm_edit(cx).is_none(),
2573 "Should not allow to confirm on conflicting file rename"
2574 )
2575 });
2576 assert_eq!(
2577 visible_entries_as_strings(&panel, 0..10, cx),
2578 &[
2579 "v src",
2580 " v test",
2581 " first.rs <== selected",
2582 " second.rs",
2583 " third.rs"
2584 ],
2585 "File list should be unchanged after failed rename confirmation"
2586 );
2587 }
2588
2589 #[gpui::test]
2590 async fn test_new_search_in_directory_trigger(cx: &mut gpui::TestAppContext) {
2591 init_test_with_editor(cx);
2592
2593 let fs = FakeFs::new(cx.executor().clone());
2594 fs.insert_tree(
2595 "/src",
2596 json!({
2597 "test": {
2598 "first.rs": "// First Rust file",
2599 "second.rs": "// Second Rust file",
2600 "third.rs": "// Third Rust file",
2601 }
2602 }),
2603 )
2604 .await;
2605
2606 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2607 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2608 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2609 let panel = workspace
2610 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2611 .unwrap();
2612
2613 let new_search_events_count = Arc::new(AtomicUsize::new(0));
2614 let _subscription = panel.update(cx, |_, cx| {
2615 let subcription_count = Arc::clone(&new_search_events_count);
2616 let view = cx.view().clone();
2617 cx.subscribe(&view, move |_, _, event, _| {
2618 if matches!(event, Event::NewSearchInDirectory { .. }) {
2619 subcription_count.fetch_add(1, atomic::Ordering::SeqCst);
2620 }
2621 })
2622 });
2623
2624 toggle_expand_dir(&panel, "src/test", cx);
2625 select_path(&panel, "src/test/first.rs", cx);
2626 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2627 cx.executor().run_until_parked();
2628 assert_eq!(
2629 visible_entries_as_strings(&panel, 0..10, cx),
2630 &[
2631 "v src",
2632 " v test",
2633 " first.rs <== selected",
2634 " second.rs",
2635 " third.rs"
2636 ]
2637 );
2638 panel.update(cx, |panel, cx| {
2639 panel.new_search_in_directory(&NewSearchInDirectory, cx)
2640 });
2641 assert_eq!(
2642 new_search_events_count.load(atomic::Ordering::SeqCst),
2643 0,
2644 "Should not trigger new search in directory when called on a file"
2645 );
2646
2647 select_path(&panel, "src/test", cx);
2648 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2649 cx.executor().run_until_parked();
2650 assert_eq!(
2651 visible_entries_as_strings(&panel, 0..10, cx),
2652 &[
2653 "v src",
2654 " v test <== selected",
2655 " first.rs",
2656 " second.rs",
2657 " third.rs"
2658 ]
2659 );
2660 panel.update(cx, |panel, cx| {
2661 panel.new_search_in_directory(&NewSearchInDirectory, cx)
2662 });
2663 assert_eq!(
2664 new_search_events_count.load(atomic::Ordering::SeqCst),
2665 1,
2666 "Should trigger new search in directory when called on a directory"
2667 );
2668 }
2669
2670 #[gpui::test]
2671 async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) {
2672 init_test_with_editor(cx);
2673
2674 let fs = FakeFs::new(cx.executor().clone());
2675 fs.insert_tree(
2676 "/project_root",
2677 json!({
2678 "dir_1": {
2679 "nested_dir": {
2680 "file_a.py": "# File contents",
2681 "file_b.py": "# File contents",
2682 "file_c.py": "# File contents",
2683 },
2684 "file_1.py": "# File contents",
2685 "file_2.py": "# File contents",
2686 "file_3.py": "# File contents",
2687 },
2688 "dir_2": {
2689 "file_1.py": "# File contents",
2690 "file_2.py": "# File contents",
2691 "file_3.py": "# File contents",
2692 }
2693 }),
2694 )
2695 .await;
2696
2697 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
2698 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2699 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2700 let panel = workspace
2701 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2702 .unwrap();
2703
2704 panel.update(cx, |panel, cx| {
2705 panel.collapse_all_entries(&CollapseAllEntries, cx)
2706 });
2707 cx.executor().run_until_parked();
2708 assert_eq!(
2709 visible_entries_as_strings(&panel, 0..10, cx),
2710 &["v project_root", " > dir_1", " > dir_2",]
2711 );
2712
2713 // Open dir_1 and make sure nested_dir was collapsed when running collapse_all_entries
2714 toggle_expand_dir(&panel, "project_root/dir_1", cx);
2715 cx.executor().run_until_parked();
2716 assert_eq!(
2717 visible_entries_as_strings(&panel, 0..10, cx),
2718 &[
2719 "v project_root",
2720 " v dir_1 <== selected",
2721 " > nested_dir",
2722 " file_1.py",
2723 " file_2.py",
2724 " file_3.py",
2725 " > dir_2",
2726 ]
2727 );
2728 }
2729
2730 #[gpui::test]
2731 async fn test_new_file_move(cx: &mut gpui::TestAppContext) {
2732 init_test(cx);
2733
2734 let fs = FakeFs::new(cx.executor().clone());
2735 fs.as_fake().insert_tree("/root", json!({})).await;
2736 let project = Project::test(fs, ["/root".as_ref()], cx).await;
2737 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2738 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2739 let panel = workspace
2740 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2741 .unwrap();
2742
2743 // Make a new buffer with no backing file
2744 workspace
2745 .update(cx, |workspace, cx| {
2746 Editor::new_file(workspace, &Default::default(), cx)
2747 })
2748 .unwrap();
2749
2750 // "Save as"" the buffer, creating a new backing file for it
2751 let save_task = workspace
2752 .update(cx, |workspace, cx| {
2753 workspace.save_active_item(workspace::SaveIntent::Save, cx)
2754 })
2755 .unwrap();
2756
2757 cx.executor().run_until_parked();
2758 cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/new")));
2759 save_task.await.unwrap();
2760
2761 // Rename the file
2762 select_path(&panel, "root/new", cx);
2763 assert_eq!(
2764 visible_entries_as_strings(&panel, 0..10, cx),
2765 &["v root", " new <== selected"]
2766 );
2767 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2768 panel.update(cx, |panel, cx| {
2769 panel
2770 .filename_editor
2771 .update(cx, |editor, cx| editor.set_text("newer", cx));
2772 });
2773 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2774
2775 cx.executor().run_until_parked();
2776 assert_eq!(
2777 visible_entries_as_strings(&panel, 0..10, cx),
2778 &["v root", " newer <== selected"]
2779 );
2780
2781 workspace
2782 .update(cx, |workspace, cx| {
2783 workspace.save_active_item(workspace::SaveIntent::Save, cx)
2784 })
2785 .unwrap()
2786 .await
2787 .unwrap();
2788
2789 cx.executor().run_until_parked();
2790 // assert that saving the file doesn't restore "new"
2791 assert_eq!(
2792 visible_entries_as_strings(&panel, 0..10, cx),
2793 &["v root", " newer <== selected"]
2794 );
2795 }
2796
2797 fn toggle_expand_dir(
2798 panel: &View<ProjectPanel>,
2799 path: impl AsRef<Path>,
2800 cx: &mut VisualTestContext,
2801 ) {
2802 let path = path.as_ref();
2803 panel.update(cx, |panel, cx| {
2804 for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
2805 let worktree = worktree.read(cx);
2806 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
2807 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
2808 panel.toggle_expanded(entry_id, cx);
2809 return;
2810 }
2811 }
2812 panic!("no worktree for path {:?}", path);
2813 });
2814 }
2815
2816 fn select_path(panel: &View<ProjectPanel>, path: impl AsRef<Path>, cx: &mut VisualTestContext) {
2817 let path = path.as_ref();
2818 panel.update(cx, |panel, cx| {
2819 for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
2820 let worktree = worktree.read(cx);
2821 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
2822 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
2823 panel.selection = Some(Selection {
2824 worktree_id: worktree.id(),
2825 entry_id,
2826 });
2827 return;
2828 }
2829 }
2830 panic!("no worktree for path {:?}", path);
2831 });
2832 }
2833
2834 fn visible_entries_as_strings(
2835 panel: &View<ProjectPanel>,
2836 range: Range<usize>,
2837 cx: &mut VisualTestContext,
2838 ) -> Vec<String> {
2839 let mut result = Vec::new();
2840 let mut project_entries = HashSet::new();
2841 let mut has_editor = false;
2842
2843 panel.update(cx, |panel, cx| {
2844 panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
2845 if details.is_editing {
2846 assert!(!has_editor, "duplicate editor entry");
2847 has_editor = true;
2848 } else {
2849 assert!(
2850 project_entries.insert(project_entry),
2851 "duplicate project entry {:?} {:?}",
2852 project_entry,
2853 details
2854 );
2855 }
2856
2857 let indent = " ".repeat(details.depth);
2858 let icon = if details.kind.is_dir() {
2859 if details.is_expanded {
2860 "v "
2861 } else {
2862 "> "
2863 }
2864 } else {
2865 " "
2866 };
2867 let name = if details.is_editing {
2868 format!("[EDITOR: '{}']", details.filename)
2869 } else if details.is_processing {
2870 format!("[PROCESSING: '{}']", details.filename)
2871 } else {
2872 details.filename.clone()
2873 };
2874 let selected = if details.is_selected {
2875 " <== selected"
2876 } else {
2877 ""
2878 };
2879 result.push(format!("{indent}{icon}{name}{selected}"));
2880 });
2881 });
2882
2883 result
2884 }
2885
2886 fn init_test(cx: &mut TestAppContext) {
2887 cx.update(|cx| {
2888 let settings_store = SettingsStore::test(cx);
2889 cx.set_global(settings_store);
2890 init_settings(cx);
2891 theme::init(theme::LoadThemes::JustBase, cx);
2892 language::init(cx);
2893 editor::init_settings(cx);
2894 crate::init((), cx);
2895 workspace::init_settings(cx);
2896 client::init_settings(cx);
2897 Project::init_settings(cx);
2898
2899 cx.update_global::<SettingsStore, _>(|store, cx| {
2900 store.update_user_settings::<ProjectSettings>(cx, |project_settings| {
2901 project_settings.file_scan_exclusions = Some(Vec::new());
2902 });
2903 });
2904 });
2905 }
2906
2907 fn init_test_with_editor(cx: &mut TestAppContext) {
2908 cx.update(|cx| {
2909 let app_state = AppState::test(cx);
2910 theme::init(theme::LoadThemes::JustBase, cx);
2911 init_settings(cx);
2912 language::init(cx);
2913 editor::init(cx);
2914 crate::init((), cx);
2915 workspace::init(app_state.clone(), cx);
2916 Project::init_settings(cx);
2917 });
2918 }
2919
2920 fn ensure_single_file_is_opened(
2921 window: &WindowHandle<Workspace>,
2922 expected_path: &str,
2923 cx: &mut TestAppContext,
2924 ) {
2925 window
2926 .update(cx, |workspace, cx| {
2927 let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
2928 assert_eq!(worktrees.len(), 1);
2929 let worktree_id = worktrees[0].read(cx).id();
2930
2931 let open_project_paths = workspace
2932 .panes()
2933 .iter()
2934 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
2935 .collect::<Vec<_>>();
2936 assert_eq!(
2937 open_project_paths,
2938 vec![ProjectPath {
2939 worktree_id,
2940 path: Arc::from(Path::new(expected_path))
2941 }],
2942 "Should have opened file, selected in project panel"
2943 );
2944 })
2945 .unwrap();
2946 }
2947
2948 fn submit_deletion(panel: &View<ProjectPanel>, cx: &mut VisualTestContext) {
2949 assert!(
2950 !cx.has_pending_prompt(),
2951 "Should have no prompts before the deletion"
2952 );
2953 panel.update(cx, |panel, cx| panel.delete(&Delete, cx));
2954 assert!(
2955 cx.has_pending_prompt(),
2956 "Should have a prompt after the deletion"
2957 );
2958 cx.simulate_prompt_answer(0);
2959 assert!(
2960 !cx.has_pending_prompt(),
2961 "Should have no prompts after prompt was replied to"
2962 );
2963 cx.executor().run_until_parked();
2964 }
2965
2966 fn ensure_no_open_items_and_panes(
2967 workspace: &WindowHandle<Workspace>,
2968 cx: &mut VisualTestContext,
2969 ) {
2970 assert!(
2971 !cx.has_pending_prompt(),
2972 "Should have no prompts after deletion operation closes the file"
2973 );
2974 workspace
2975 .read_with(cx, |workspace, cx| {
2976 let open_project_paths = workspace
2977 .panes()
2978 .iter()
2979 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
2980 .collect::<Vec<_>>();
2981 assert!(
2982 open_project_paths.is_empty(),
2983 "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
2984 );
2985 })
2986 .unwrap();
2987 }
2988}