1pub mod file_associations;
2mod project_panel_settings;
3use client::{ErrorCode, ErrorExt};
4use settings::Settings;
5
6use db::kvp::KEY_VALUE_STORE;
7use editor::{actions::Cancel, scroll::Autoscroll, Editor};
8use file_associations::FileAssociations;
9
10use anyhow::{anyhow, Result};
11use collections::{hash_map, HashMap};
12use gpui::{
13 actions, div, overlay, px, uniform_list, Action, AppContext, AssetSource, AsyncWindowContext,
14 ClipboardItem, DismissEvent, Div, EventEmitter, FocusHandle, FocusableView, InteractiveElement,
15 KeyContext, Model, MouseButton, MouseDownEvent, ParentElement, Pixels, Point, PromptLevel,
16 Render, Stateful, Styled, Subscription, Task, UniformListScrollHandle, View, ViewContext,
17 VisualContext as _, WeakView, WindowContext,
18};
19use menu::{Confirm, SelectNext, SelectPrev};
20use project::{
21 repository::GitFileStatus, Entry, EntryKind, Fs, Project, ProjectEntryId, ProjectPath,
22 Worktree, WorktreeId,
23};
24use project_panel_settings::{ProjectPanelDockPosition, ProjectPanelSettings};
25use serde::{Deserialize, Serialize};
26use std::{
27 cmp::Ordering,
28 ffi::OsStr,
29 ops::Range,
30 path::{Path, PathBuf},
31 sync::Arc,
32};
33use theme::ThemeSettings;
34use ui::{prelude::*, v_flex, ContextMenu, Icon, KeyBinding, Label, ListItem};
35use unicase::UniCase;
36use util::{maybe, NumericPrefixWithSuffix, ResultExt, TryFutureExt};
37use workspace::{
38 dock::{DockPosition, Panel, PanelEvent},
39 notifications::DetachAndPromptErr,
40 Workspace,
41};
42
43const PROJECT_PANEL_KEY: &str = "ProjectPanel";
44const NEW_ENTRY_ID: ProjectEntryId = ProjectEntryId::MAX;
45
46pub struct ProjectPanel {
47 project: Model<Project>,
48 fs: Arc<dyn Fs>,
49 scroll_handle: UniformListScrollHandle,
50 focus_handle: FocusHandle,
51 visible_entries: Vec<(WorktreeId, Vec<Entry>)>,
52 last_worktree_root_id: Option<ProjectEntryId>,
53 expanded_dir_ids: HashMap<WorktreeId, Vec<ProjectEntryId>>,
54 selection: Option<Selection>,
55 context_menu: Option<(View<ContextMenu>, Point<Pixels>, Subscription)>,
56 edit_state: Option<EditState>,
57 filename_editor: View<Editor>,
58 clipboard_entry: Option<ClipboardEntry>,
59 _dragged_entry_destination: Option<Arc<Path>>,
60 workspace: WeakView<Workspace>,
61 width: Option<Pixels>,
62 pending_serialization: Task<Option<()>>,
63}
64
65#[derive(Copy, Clone, Debug)]
66struct Selection {
67 worktree_id: WorktreeId,
68 entry_id: ProjectEntryId,
69}
70
71#[derive(Clone, Debug)]
72struct EditState {
73 worktree_id: WorktreeId,
74 entry_id: ProjectEntryId,
75 is_new_entry: bool,
76 is_dir: bool,
77 processing_filename: Option<String>,
78}
79
80#[derive(Copy, Clone)]
81pub enum ClipboardEntry {
82 Copied {
83 worktree_id: WorktreeId,
84 entry_id: ProjectEntryId,
85 },
86 Cut {
87 worktree_id: WorktreeId,
88 entry_id: ProjectEntryId,
89 },
90}
91
92#[derive(Debug, PartialEq, Eq, Clone)]
93pub struct EntryDetails {
94 filename: String,
95 icon: Option<Arc<str>>,
96 path: Arc<Path>,
97 depth: usize,
98 kind: EntryKind,
99 is_ignored: bool,
100 is_expanded: bool,
101 is_selected: bool,
102 is_editing: bool,
103 is_processing: bool,
104 is_cut: bool,
105 git_status: Option<GitFileStatus>,
106 is_dotenv: bool,
107}
108
109actions!(
110 project_panel,
111 [
112 ExpandSelectedEntry,
113 CollapseSelectedEntry,
114 CollapseAllEntries,
115 NewDirectory,
116 NewFile,
117 Copy,
118 CopyPath,
119 CopyRelativePath,
120 RevealInFinder,
121 OpenInTerminal,
122 Cut,
123 Paste,
124 Delete,
125 Rename,
126 Open,
127 ToggleFocus,
128 NewSearchInDirectory,
129 ]
130);
131
132pub fn init_settings(cx: &mut AppContext) {
133 ProjectPanelSettings::register(cx);
134}
135
136pub fn init(assets: impl AssetSource, cx: &mut AppContext) {
137 init_settings(cx);
138 file_associations::init(assets, cx);
139
140 cx.observe_new_views(|workspace: &mut Workspace, _| {
141 workspace.register_action(|workspace, _: &ToggleFocus, cx| {
142 workspace.toggle_panel_focus::<ProjectPanel>(cx);
143 });
144 })
145 .detach();
146}
147
148#[derive(Debug)]
149pub enum Event {
150 OpenedEntry {
151 entry_id: ProjectEntryId,
152 focus_opened_item: bool,
153 },
154 SplitEntry {
155 entry_id: ProjectEntryId,
156 },
157 Focus,
158}
159
160#[derive(Serialize, Deserialize)]
161struct SerializedProjectPanel {
162 width: Option<Pixels>,
163}
164
165struct DraggedProjectEntryView {
166 entry_id: ProjectEntryId,
167 details: EntryDetails,
168 width: Pixels,
169}
170
171impl ProjectPanel {
172 fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> View<Self> {
173 let project = workspace.project().clone();
174 let project_panel = cx.new_view(|cx: &mut ViewContext<Self>| {
175 let focus_handle = cx.focus_handle();
176 cx.on_focus(&focus_handle, Self::focus_in).detach();
177
178 cx.subscribe(&project, |this, project, event, cx| match event {
179 project::Event::ActiveEntryChanged(Some(entry_id)) => {
180 if ProjectPanelSettings::get_global(cx).auto_reveal_entries {
181 this.reveal_entry(project, *entry_id, true, cx);
182 }
183 }
184 project::Event::RevealInProjectPanel(entry_id) => {
185 this.reveal_entry(project, *entry_id, false, cx);
186 cx.emit(PanelEvent::Activate);
187 }
188 project::Event::ActivateProjectPanel => {
189 cx.emit(PanelEvent::Activate);
190 }
191 project::Event::WorktreeRemoved(id) => {
192 this.expanded_dir_ids.remove(id);
193 this.update_visible_entries(None, cx);
194 cx.notify();
195 }
196 project::Event::WorktreeUpdatedEntries(_, _) | project::Event::WorktreeAdded => {
197 this.update_visible_entries(None, cx);
198 cx.notify();
199 }
200 _ => {}
201 })
202 .detach();
203
204 let filename_editor = cx.new_view(|cx| Editor::single_line(cx));
205
206 cx.subscribe(&filename_editor, |this, _, event, cx| match event {
207 editor::EditorEvent::BufferEdited
208 | editor::EditorEvent::SelectionsChanged { .. } => {
209 this.autoscroll(cx);
210 }
211 editor::EditorEvent::Blurred => {
212 if this
213 .edit_state
214 .as_ref()
215 .map_or(false, |state| state.processing_filename.is_none())
216 {
217 this.edit_state = None;
218 this.update_visible_entries(None, cx);
219 }
220 }
221 _ => {}
222 })
223 .detach();
224
225 cx.observe_global::<FileAssociations>(|_, cx| {
226 cx.notify();
227 })
228 .detach();
229
230 let mut this = Self {
231 project: project.clone(),
232 fs: workspace.app_state().fs.clone(),
233 scroll_handle: UniformListScrollHandle::new(),
234 focus_handle,
235 visible_entries: Default::default(),
236 last_worktree_root_id: Default::default(),
237 expanded_dir_ids: Default::default(),
238 selection: None,
239 edit_state: None,
240 context_menu: None,
241 filename_editor,
242 clipboard_entry: None,
243 _dragged_entry_destination: None,
244 workspace: workspace.weak_handle(),
245 width: None,
246 pending_serialization: Task::ready(None),
247 };
248 this.update_visible_entries(None, cx);
249
250 this
251 });
252
253 cx.subscribe(&project_panel, {
254 let project_panel = project_panel.downgrade();
255 move |workspace, _, event, cx| match event {
256 &Event::OpenedEntry {
257 entry_id,
258 focus_opened_item,
259 } => {
260 if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
261 if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
262 let file_path = entry.path.clone();
263 let worktree_id = worktree.read(cx).id();
264 let entry_id = entry.id;
265
266 workspace
267 .open_path(
268 ProjectPath {
269 worktree_id,
270 path: file_path.clone(),
271 },
272 None,
273 focus_opened_item,
274 cx,
275 )
276 .detach_and_prompt_err("Failed to open file", cx, move |e, _| {
277 match e.error_code() {
278 ErrorCode::UnsharedItem => Some(format!(
279 "{} is not shared by the host. This could be because it has been marked as `private`",
280 file_path.display()
281 )),
282 _ => None,
283 }
284 });
285
286 if let Some(project_panel) = project_panel.upgrade() {
287 // Always select the entry, regardless of whether it is opened or not.
288 project_panel.update(cx, |project_panel, _| {
289 project_panel.selection = Some(Selection {
290 worktree_id,
291 entry_id
292 });
293 });
294 if !focus_opened_item {
295 let focus_handle = project_panel.read(cx).focus_handle.clone();
296 cx.focus(&focus_handle);
297 }
298 }
299 }
300 }
301 }
302 &Event::SplitEntry { entry_id } => {
303 if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
304 if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
305 workspace
306 .split_path(
307 ProjectPath {
308 worktree_id: worktree.read(cx).id(),
309 path: entry.path.clone(),
310 },
311 cx,
312 )
313 .detach_and_log_err(cx);
314 }
315 }
316 }
317 _ => {}
318 }
319 })
320 .detach();
321
322 project_panel
323 }
324
325 pub async fn load(
326 workspace: WeakView<Workspace>,
327 mut cx: AsyncWindowContext,
328 ) -> Result<View<Self>> {
329 let serialized_panel = cx
330 .background_executor()
331 .spawn(async move { KEY_VALUE_STORE.read_kvp(PROJECT_PANEL_KEY) })
332 .await
333 .map_err(|e| anyhow!("Failed to load project panel: {}", e))
334 .log_err()
335 .flatten()
336 .map(|panel| serde_json::from_str::<SerializedProjectPanel>(&panel))
337 .transpose()
338 .log_err()
339 .flatten();
340
341 workspace.update(&mut cx, |workspace, cx| {
342 let panel = ProjectPanel::new(workspace, cx);
343 if let Some(serialized_panel) = serialized_panel {
344 panel.update(cx, |panel, cx| {
345 panel.width = serialized_panel.width.map(|px| px.round());
346 cx.notify();
347 });
348 }
349 panel
350 })
351 }
352
353 fn serialize(&mut self, cx: &mut ViewContext<Self>) {
354 let width = self.width;
355 self.pending_serialization = cx.background_executor().spawn(
356 async move {
357 KEY_VALUE_STORE
358 .write_kvp(
359 PROJECT_PANEL_KEY.into(),
360 serde_json::to_string(&SerializedProjectPanel { width })?,
361 )
362 .await?;
363 anyhow::Ok(())
364 }
365 .log_err(),
366 );
367 }
368
369 fn focus_in(&mut self, cx: &mut ViewContext<Self>) {
370 if !self.focus_handle.contains_focused(cx) {
371 cx.emit(Event::Focus);
372 }
373 }
374
375 fn deploy_context_menu(
376 &mut self,
377 position: Point<Pixels>,
378 entry_id: ProjectEntryId,
379 cx: &mut ViewContext<Self>,
380 ) {
381 let this = cx.view().clone();
382 let project = self.project.read(cx);
383
384 let worktree_id = if let Some(id) = project.worktree_id_for_entry(entry_id, cx) {
385 id
386 } else {
387 return;
388 };
389
390 self.selection = Some(Selection {
391 worktree_id,
392 entry_id,
393 });
394
395 if let Some((worktree, entry)) = self.selected_entry(cx) {
396 let is_root = Some(entry) == worktree.root_entry();
397 let is_dir = entry.is_dir();
398 let worktree_id = worktree.id();
399 let is_local = project.is_local();
400 let is_read_only = project.is_read_only();
401
402 let context_menu = ContextMenu::build(cx, |menu, cx| {
403 menu.context(self.focus_handle.clone()).when_else(
404 is_read_only,
405 |menu| {
406 menu.action("Copy Relative Path", Box::new(CopyRelativePath))
407 .when(is_dir, |menu| {
408 menu.action("Search Inside", Box::new(NewSearchInDirectory))
409 })
410 },
411 |menu| {
412 menu.when(is_local, |menu| {
413 menu.action(
414 "Add Folder to Project",
415 Box::new(workspace::AddFolderToProject),
416 )
417 .when(is_root, |menu| {
418 menu.entry(
419 "Remove from Project",
420 None,
421 cx.handler_for(&this, move |this, cx| {
422 this.project.update(cx, |project, cx| {
423 project.remove_worktree(worktree_id, cx)
424 });
425 }),
426 )
427 .action("Collapse All", Box::new(CollapseAllEntries))
428 })
429 })
430 .action("New File", Box::new(NewFile))
431 .action("New Folder", Box::new(NewDirectory))
432 .separator()
433 .action("Cut", Box::new(Cut))
434 .action("Copy", Box::new(Copy))
435 .when_some(self.clipboard_entry, |menu, entry| {
436 menu.when(entry.worktree_id() == worktree_id, |menu| {
437 menu.action("Paste", Box::new(Paste))
438 })
439 })
440 .separator()
441 .action("Copy Path", Box::new(CopyPath))
442 .action("Copy Relative Path", Box::new(CopyRelativePath))
443 .separator()
444 .action("Reveal in Finder", Box::new(RevealInFinder))
445 .when(is_dir, |menu| {
446 menu.action("Open in Terminal", Box::new(OpenInTerminal))
447 .action("Search Inside", Box::new(NewSearchInDirectory))
448 })
449 .separator()
450 .action("Rename", Box::new(Rename))
451 .when(!is_root, |menu| menu.action("Delete", Box::new(Delete)))
452 },
453 )
454 });
455
456 cx.focus_view(&context_menu);
457 let subscription = cx.subscribe(&context_menu, |this, _, _: &DismissEvent, cx| {
458 this.context_menu.take();
459 cx.notify();
460 });
461 self.context_menu = Some((context_menu, position, subscription));
462 }
463
464 cx.notify();
465 }
466
467 fn expand_selected_entry(&mut self, _: &ExpandSelectedEntry, cx: &mut ViewContext<Self>) {
468 if let Some((worktree, entry)) = self.selected_entry(cx) {
469 if entry.is_dir() {
470 let worktree_id = worktree.id();
471 let entry_id = entry.id;
472 let expanded_dir_ids =
473 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
474 expanded_dir_ids
475 } else {
476 return;
477 };
478
479 match expanded_dir_ids.binary_search(&entry_id) {
480 Ok(_) => self.select_next(&SelectNext, cx),
481 Err(ix) => {
482 self.project.update(cx, |project, cx| {
483 project.expand_entry(worktree_id, entry_id, cx);
484 });
485
486 expanded_dir_ids.insert(ix, entry_id);
487 self.update_visible_entries(None, cx);
488 cx.notify();
489 }
490 }
491 }
492 }
493 }
494
495 fn collapse_selected_entry(&mut self, _: &CollapseSelectedEntry, cx: &mut ViewContext<Self>) {
496 if let Some((worktree, mut entry)) = self.selected_entry(cx) {
497 let worktree_id = worktree.id();
498 let expanded_dir_ids =
499 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
500 expanded_dir_ids
501 } else {
502 return;
503 };
504
505 loop {
506 let entry_id = entry.id;
507 match expanded_dir_ids.binary_search(&entry_id) {
508 Ok(ix) => {
509 expanded_dir_ids.remove(ix);
510 self.update_visible_entries(Some((worktree_id, entry_id)), cx);
511 cx.notify();
512 break;
513 }
514 Err(_) => {
515 if let Some(parent_entry) =
516 entry.path.parent().and_then(|p| worktree.entry_for_path(p))
517 {
518 entry = parent_entry;
519 } else {
520 break;
521 }
522 }
523 }
524 }
525 }
526 }
527
528 pub fn collapse_all_entries(&mut self, _: &CollapseAllEntries, cx: &mut ViewContext<Self>) {
529 self.expanded_dir_ids.clear();
530 self.update_visible_entries(None, cx);
531 cx.notify();
532 }
533
534 fn toggle_expanded(&mut self, entry_id: ProjectEntryId, cx: &mut ViewContext<Self>) {
535 if let Some(worktree_id) = self.project.read(cx).worktree_id_for_entry(entry_id, cx) {
536 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
537 self.project.update(cx, |project, cx| {
538 match expanded_dir_ids.binary_search(&entry_id) {
539 Ok(ix) => {
540 expanded_dir_ids.remove(ix);
541 }
542 Err(ix) => {
543 project.expand_entry(worktree_id, entry_id, cx);
544 expanded_dir_ids.insert(ix, entry_id);
545 }
546 }
547 });
548 self.update_visible_entries(Some((worktree_id, entry_id)), cx);
549 cx.focus(&self.focus_handle);
550 cx.notify();
551 }
552 }
553 }
554
555 fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
556 if let Some(selection) = self.selection {
557 let (mut worktree_ix, mut entry_ix, _) =
558 self.index_for_selection(selection).unwrap_or_default();
559 if entry_ix > 0 {
560 entry_ix -= 1;
561 } else if worktree_ix > 0 {
562 worktree_ix -= 1;
563 entry_ix = self.visible_entries[worktree_ix].1.len() - 1;
564 } else {
565 return;
566 }
567
568 let (worktree_id, worktree_entries) = &self.visible_entries[worktree_ix];
569 self.selection = Some(Selection {
570 worktree_id: *worktree_id,
571 entry_id: worktree_entries[entry_ix].id,
572 });
573 self.autoscroll(cx);
574 cx.notify();
575 } else {
576 self.select_first(cx);
577 }
578 }
579
580 fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
581 if let Some(task) = self.confirm_edit(cx) {
582 task.detach_and_log_err(cx);
583 }
584 }
585
586 fn open(&mut self, _: &Open, cx: &mut ViewContext<Self>) {
587 if let Some((_, entry)) = self.selected_entry(cx) {
588 if entry.is_file() {
589 self.open_entry(entry.id, true, cx);
590 } else {
591 self.toggle_expanded(entry.id, cx);
592 }
593 }
594 }
595
596 fn confirm_edit(&mut self, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
597 let edit_state = self.edit_state.as_mut()?;
598 cx.focus(&self.focus_handle);
599
600 let worktree_id = edit_state.worktree_id;
601 let is_new_entry = edit_state.is_new_entry;
602 let is_dir = edit_state.is_dir;
603 let worktree = self.project.read(cx).worktree_for_id(worktree_id, cx)?;
604 let entry = worktree.read(cx).entry_for_id(edit_state.entry_id)?.clone();
605 let filename = self.filename_editor.read(cx).text(cx);
606
607 let path_already_exists = |path| worktree.read(cx).entry_for_path(path).is_some();
608 let edit_task;
609 let edited_entry_id;
610 if is_new_entry {
611 self.selection = Some(Selection {
612 worktree_id,
613 entry_id: NEW_ENTRY_ID,
614 });
615 let new_path = entry.path.join(&filename.trim_start_matches('/'));
616 if path_already_exists(new_path.as_path()) {
617 return None;
618 }
619
620 edited_entry_id = NEW_ENTRY_ID;
621 edit_task = self.project.update(cx, |project, cx| {
622 project.create_entry((worktree_id, &new_path), is_dir, cx)
623 });
624 } else {
625 let new_path = if let Some(parent) = entry.path.clone().parent() {
626 parent.join(&filename)
627 } else {
628 filename.clone().into()
629 };
630 if path_already_exists(new_path.as_path()) {
631 return None;
632 }
633
634 edited_entry_id = entry.id;
635 edit_task = self.project.update(cx, |project, cx| {
636 project.rename_entry(entry.id, new_path.as_path(), cx)
637 });
638 };
639
640 edit_state.processing_filename = Some(filename);
641 cx.notify();
642
643 Some(cx.spawn(|this, mut cx| async move {
644 let new_entry = edit_task.await;
645 this.update(&mut cx, |this, cx| {
646 this.edit_state.take();
647 cx.notify();
648 })?;
649
650 if let Some(new_entry) = new_entry? {
651 this.update(&mut cx, |this, cx| {
652 if let Some(selection) = &mut this.selection {
653 if selection.entry_id == edited_entry_id {
654 selection.worktree_id = worktree_id;
655 selection.entry_id = new_entry.id;
656 this.expand_to_selection(cx);
657 }
658 }
659 this.update_visible_entries(None, cx);
660 if is_new_entry && !is_dir {
661 this.open_entry(new_entry.id, true, cx);
662 }
663 cx.notify();
664 })?;
665 }
666 Ok(())
667 }))
668 }
669
670 fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
671 self.edit_state = None;
672 self.update_visible_entries(None, cx);
673 cx.focus(&self.focus_handle);
674 cx.notify();
675 }
676
677 fn open_entry(
678 &mut self,
679 entry_id: ProjectEntryId,
680 focus_opened_item: bool,
681 cx: &mut ViewContext<Self>,
682 ) {
683 cx.emit(Event::OpenedEntry {
684 entry_id,
685 focus_opened_item,
686 });
687 }
688
689 fn split_entry(&mut self, entry_id: ProjectEntryId, cx: &mut ViewContext<Self>) {
690 cx.emit(Event::SplitEntry { entry_id });
691 }
692
693 fn new_file(&mut self, _: &NewFile, cx: &mut ViewContext<Self>) {
694 self.add_entry(false, cx)
695 }
696
697 fn new_directory(&mut self, _: &NewDirectory, cx: &mut ViewContext<Self>) {
698 self.add_entry(true, cx)
699 }
700
701 fn add_entry(&mut self, is_dir: bool, cx: &mut ViewContext<Self>) {
702 if let Some(Selection {
703 worktree_id,
704 entry_id,
705 }) = self.selection
706 {
707 let directory_id;
708 if let Some((worktree, expanded_dir_ids)) = self
709 .project
710 .read(cx)
711 .worktree_for_id(worktree_id, cx)
712 .zip(self.expanded_dir_ids.get_mut(&worktree_id))
713 {
714 let worktree = worktree.read(cx);
715 if let Some(mut entry) = worktree.entry_for_id(entry_id) {
716 loop {
717 if entry.is_dir() {
718 if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
719 expanded_dir_ids.insert(ix, entry.id);
720 }
721 directory_id = entry.id;
722 break;
723 } else {
724 if let Some(parent_path) = entry.path.parent() {
725 if let Some(parent_entry) = worktree.entry_for_path(parent_path) {
726 entry = parent_entry;
727 continue;
728 }
729 }
730 return;
731 }
732 }
733 } else {
734 return;
735 };
736 } else {
737 return;
738 };
739
740 self.edit_state = Some(EditState {
741 worktree_id,
742 entry_id: directory_id,
743 is_new_entry: true,
744 is_dir,
745 processing_filename: None,
746 });
747 self.filename_editor.update(cx, |editor, cx| {
748 editor.clear(cx);
749 editor.focus(cx);
750 });
751 self.update_visible_entries(Some((worktree_id, NEW_ENTRY_ID)), cx);
752 self.autoscroll(cx);
753 cx.notify();
754 }
755 }
756
757 fn rename(&mut self, _: &Rename, cx: &mut ViewContext<Self>) {
758 if let Some(Selection {
759 worktree_id,
760 entry_id,
761 }) = self.selection
762 {
763 if let Some(worktree) = self.project.read(cx).worktree_for_id(worktree_id, cx) {
764 if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
765 self.edit_state = Some(EditState {
766 worktree_id,
767 entry_id,
768 is_new_entry: false,
769 is_dir: entry.is_dir(),
770 processing_filename: None,
771 });
772 let file_name = entry
773 .path
774 .file_name()
775 .map(|s| s.to_string_lossy())
776 .unwrap_or_default()
777 .to_string();
778 let file_stem = entry.path.file_stem().map(|s| s.to_string_lossy());
779 let selection_end =
780 file_stem.map_or(file_name.len(), |file_stem| file_stem.len());
781 self.filename_editor.update(cx, |editor, cx| {
782 editor.set_text(file_name, cx);
783 editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
784 s.select_ranges([0..selection_end])
785 });
786 editor.focus(cx);
787 });
788 self.update_visible_entries(None, cx);
789 self.autoscroll(cx);
790 cx.notify();
791 }
792 }
793 }
794 }
795
796 fn delete(&mut self, _: &Delete, cx: &mut ViewContext<Self>) {
797 maybe!({
798 let Selection { entry_id, .. } = self.selection?;
799 let path = self.project.read(cx).path_for_entry(entry_id, cx)?.path;
800 let file_name = path.file_name()?;
801
802 let answer = cx.prompt(
803 PromptLevel::Info,
804 &format!("Delete {file_name:?}?"),
805 None,
806 &["Delete", "Cancel"],
807 );
808
809 cx.spawn(|this, mut cx| async move {
810 if answer.await != Ok(0) {
811 return Ok(());
812 }
813 this.update(&mut cx, |this, cx| {
814 this.project
815 .update(cx, |project, cx| project.delete_entry(entry_id, cx))
816 .ok_or_else(|| anyhow!("no such entry"))
817 })??
818 .await
819 })
820 .detach_and_log_err(cx);
821 Some(())
822 });
823 }
824
825 fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
826 if let Some(selection) = self.selection {
827 let (mut worktree_ix, mut entry_ix, _) =
828 self.index_for_selection(selection).unwrap_or_default();
829 if let Some((_, worktree_entries)) = self.visible_entries.get(worktree_ix) {
830 if entry_ix + 1 < worktree_entries.len() {
831 entry_ix += 1;
832 } else {
833 worktree_ix += 1;
834 entry_ix = 0;
835 }
836 }
837
838 if let Some((worktree_id, worktree_entries)) = self.visible_entries.get(worktree_ix) {
839 if let Some(entry) = worktree_entries.get(entry_ix) {
840 self.selection = Some(Selection {
841 worktree_id: *worktree_id,
842 entry_id: entry.id,
843 });
844 self.autoscroll(cx);
845 cx.notify();
846 }
847 }
848 } else {
849 self.select_first(cx);
850 }
851 }
852
853 fn select_first(&mut self, cx: &mut ViewContext<Self>) {
854 let worktree = self
855 .visible_entries
856 .first()
857 .and_then(|(worktree_id, _)| self.project.read(cx).worktree_for_id(*worktree_id, cx));
858 if let Some(worktree) = worktree {
859 let worktree = worktree.read(cx);
860 let worktree_id = worktree.id();
861 if let Some(root_entry) = worktree.root_entry() {
862 self.selection = Some(Selection {
863 worktree_id,
864 entry_id: root_entry.id,
865 });
866 self.autoscroll(cx);
867 cx.notify();
868 }
869 }
870 }
871
872 fn autoscroll(&mut self, cx: &mut ViewContext<Self>) {
873 if let Some((_, _, index)) = self.selection.and_then(|s| self.index_for_selection(s)) {
874 self.scroll_handle.scroll_to_item(index);
875 cx.notify();
876 }
877 }
878
879 fn cut(&mut self, _: &Cut, cx: &mut ViewContext<Self>) {
880 if let Some((worktree, entry)) = self.selected_entry(cx) {
881 self.clipboard_entry = Some(ClipboardEntry::Cut {
882 worktree_id: worktree.id(),
883 entry_id: entry.id,
884 });
885 cx.notify();
886 }
887 }
888
889 fn copy(&mut self, _: &Copy, cx: &mut ViewContext<Self>) {
890 if let Some((worktree, entry)) = self.selected_entry(cx) {
891 self.clipboard_entry = Some(ClipboardEntry::Copied {
892 worktree_id: worktree.id(),
893 entry_id: entry.id,
894 });
895 cx.notify();
896 }
897 }
898
899 fn paste(&mut self, _: &Paste, cx: &mut ViewContext<Self>) {
900 maybe!({
901 let (worktree, entry) = self.selected_entry(cx)?;
902 let clipboard_entry = self.clipboard_entry?;
903 if clipboard_entry.worktree_id() != worktree.id() {
904 return None;
905 }
906
907 let clipboard_entry_file_name = self
908 .project
909 .read(cx)
910 .path_for_entry(clipboard_entry.entry_id(), cx)?
911 .path
912 .file_name()?
913 .to_os_string();
914
915 let mut new_path = entry.path.to_path_buf();
916 // If we're pasting into a file, or a directory into itself, go up one level.
917 if entry.is_file() || (entry.is_dir() && entry.id == clipboard_entry.entry_id()) {
918 new_path.pop();
919 }
920
921 new_path.push(&clipboard_entry_file_name);
922 let extension = new_path.extension().map(|e| e.to_os_string());
923 let file_name_without_extension = Path::new(&clipboard_entry_file_name).file_stem()?;
924 let mut ix = 0;
925 while worktree.entry_for_path(&new_path).is_some() {
926 new_path.pop();
927
928 let mut new_file_name = file_name_without_extension.to_os_string();
929 new_file_name.push(" copy");
930 if ix > 0 {
931 new_file_name.push(format!(" {}", ix));
932 }
933 if let Some(extension) = extension.as_ref() {
934 new_file_name.push(".");
935 new_file_name.push(extension);
936 }
937
938 new_path.push(new_file_name);
939 ix += 1;
940 }
941
942 if clipboard_entry.is_cut() {
943 self.project
944 .update(cx, |project, cx| {
945 project.rename_entry(clipboard_entry.entry_id(), new_path, cx)
946 })
947 .detach_and_log_err(cx)
948 } else {
949 self.project
950 .update(cx, |project, cx| {
951 project.copy_entry(clipboard_entry.entry_id(), new_path, cx)
952 })
953 .detach_and_log_err(cx)
954 }
955
956 Some(())
957 });
958 }
959
960 fn copy_path(&mut self, _: &CopyPath, cx: &mut ViewContext<Self>) {
961 if let Some((worktree, entry)) = self.selected_entry(cx) {
962 cx.write_to_clipboard(ClipboardItem::new(
963 worktree
964 .abs_path()
965 .join(&entry.path)
966 .to_string_lossy()
967 .to_string(),
968 ));
969 }
970 }
971
972 fn copy_relative_path(&mut self, _: &CopyRelativePath, cx: &mut ViewContext<Self>) {
973 if let Some((_, entry)) = self.selected_entry(cx) {
974 cx.write_to_clipboard(ClipboardItem::new(entry.path.to_string_lossy().to_string()));
975 }
976 }
977
978 fn reveal_in_finder(&mut self, _: &RevealInFinder, cx: &mut ViewContext<Self>) {
979 if let Some((worktree, entry)) = self.selected_entry(cx) {
980 cx.reveal_path(&worktree.abs_path().join(&entry.path));
981 }
982 }
983
984 fn open_in_terminal(&mut self, _: &OpenInTerminal, cx: &mut ViewContext<Self>) {
985 if let Some((worktree, entry)) = self.selected_entry(cx) {
986 let path = worktree.abs_path().join(&entry.path);
987 cx.dispatch_action(
988 workspace::OpenTerminal {
989 working_directory: path,
990 }
991 .boxed_clone(),
992 )
993 }
994 }
995
996 pub fn new_search_in_directory(
997 &mut self,
998 _: &NewSearchInDirectory,
999 cx: &mut ViewContext<Self>,
1000 ) {
1001 if let Some((worktree, entry)) = self.selected_entry(cx) {
1002 if entry.is_dir() {
1003 let include_root = self.project.read(cx).visible_worktrees(cx).count() > 1;
1004 let dir_path = if include_root {
1005 let mut full_path = PathBuf::from(worktree.root_name());
1006 full_path.push(&entry.path);
1007 Arc::from(full_path)
1008 } else {
1009 entry.path.clone()
1010 };
1011
1012 self.workspace
1013 .update(cx, |workspace, cx| {
1014 search::ProjectSearchView::new_search_in_directory(
1015 workspace, &dir_path, cx,
1016 );
1017 })
1018 .ok();
1019 }
1020 }
1021 }
1022
1023 fn move_entry(
1024 &mut self,
1025 entry_to_move: ProjectEntryId,
1026 destination: ProjectEntryId,
1027 destination_is_file: bool,
1028 cx: &mut ViewContext<Self>,
1029 ) {
1030 let destination_worktree = self.project.update(cx, |project, cx| {
1031 let entry_path = project.path_for_entry(entry_to_move, cx)?;
1032 let destination_entry_path = project.path_for_entry(destination, cx)?.path.clone();
1033
1034 let mut destination_path = destination_entry_path.as_ref();
1035 if destination_is_file {
1036 destination_path = destination_path.parent()?;
1037 }
1038
1039 let mut new_path = destination_path.to_path_buf();
1040 new_path.push(entry_path.path.file_name()?);
1041 if new_path != entry_path.path.as_ref() {
1042 let task = project.rename_entry(entry_to_move, new_path, cx);
1043 cx.foreground_executor().spawn(task).detach_and_log_err(cx);
1044 }
1045
1046 project.worktree_id_for_entry(destination, cx)
1047 });
1048
1049 if let Some(destination_worktree) = destination_worktree {
1050 self.expand_entry(destination_worktree, destination, cx);
1051 }
1052 }
1053
1054 fn index_for_selection(&self, selection: Selection) -> Option<(usize, usize, usize)> {
1055 let mut entry_index = 0;
1056 let mut visible_entries_index = 0;
1057 for (worktree_index, (worktree_id, worktree_entries)) in
1058 self.visible_entries.iter().enumerate()
1059 {
1060 if *worktree_id == selection.worktree_id {
1061 for entry in worktree_entries {
1062 if entry.id == selection.entry_id {
1063 return Some((worktree_index, entry_index, visible_entries_index));
1064 } else {
1065 visible_entries_index += 1;
1066 entry_index += 1;
1067 }
1068 }
1069 break;
1070 } else {
1071 visible_entries_index += worktree_entries.len();
1072 }
1073 }
1074 None
1075 }
1076
1077 pub fn selected_entry<'a>(
1078 &self,
1079 cx: &'a AppContext,
1080 ) -> Option<(&'a Worktree, &'a project::Entry)> {
1081 let (worktree, entry) = self.selected_entry_handle(cx)?;
1082 Some((worktree.read(cx), entry))
1083 }
1084
1085 fn selected_entry_handle<'a>(
1086 &self,
1087 cx: &'a AppContext,
1088 ) -> Option<(Model<Worktree>, &'a project::Entry)> {
1089 let selection = self.selection?;
1090 let project = self.project.read(cx);
1091 let worktree = project.worktree_for_id(selection.worktree_id, cx)?;
1092 let entry = worktree.read(cx).entry_for_id(selection.entry_id)?;
1093 Some((worktree, entry))
1094 }
1095
1096 fn expand_to_selection(&mut self, cx: &mut ViewContext<Self>) -> Option<()> {
1097 let (worktree, entry) = self.selected_entry(cx)?;
1098 let expanded_dir_ids = self.expanded_dir_ids.entry(worktree.id()).or_default();
1099
1100 for path in entry.path.ancestors() {
1101 let Some(entry) = worktree.entry_for_path(path) else {
1102 continue;
1103 };
1104 if entry.is_dir() {
1105 if let Err(idx) = expanded_dir_ids.binary_search(&entry.id) {
1106 expanded_dir_ids.insert(idx, entry.id);
1107 }
1108 }
1109 }
1110
1111 Some(())
1112 }
1113
1114 fn update_visible_entries(
1115 &mut self,
1116 new_selected_entry: Option<(WorktreeId, ProjectEntryId)>,
1117 cx: &mut ViewContext<Self>,
1118 ) {
1119 let project = self.project.read(cx);
1120 self.last_worktree_root_id = project
1121 .visible_worktrees(cx)
1122 .rev()
1123 .next()
1124 .and_then(|worktree| worktree.read(cx).root_entry())
1125 .map(|entry| entry.id);
1126
1127 self.visible_entries.clear();
1128 for worktree in project.visible_worktrees(cx) {
1129 let snapshot = worktree.read(cx).snapshot();
1130 let worktree_id = snapshot.id();
1131
1132 let expanded_dir_ids = match self.expanded_dir_ids.entry(worktree_id) {
1133 hash_map::Entry::Occupied(e) => e.into_mut(),
1134 hash_map::Entry::Vacant(e) => {
1135 // The first time a worktree's root entry becomes available,
1136 // mark that root entry as expanded.
1137 if let Some(entry) = snapshot.root_entry() {
1138 e.insert(vec![entry.id]).as_slice()
1139 } else {
1140 &[]
1141 }
1142 }
1143 };
1144
1145 let mut new_entry_parent_id = None;
1146 let mut new_entry_kind = EntryKind::Dir;
1147 if let Some(edit_state) = &self.edit_state {
1148 if edit_state.worktree_id == worktree_id && edit_state.is_new_entry {
1149 new_entry_parent_id = Some(edit_state.entry_id);
1150 new_entry_kind = if edit_state.is_dir {
1151 EntryKind::Dir
1152 } else {
1153 EntryKind::File(Default::default())
1154 };
1155 }
1156 }
1157
1158 let mut visible_worktree_entries = Vec::new();
1159 let mut entry_iter = snapshot.entries(true);
1160
1161 while let Some(entry) = entry_iter.entry() {
1162 visible_worktree_entries.push(entry.clone());
1163 if Some(entry.id) == new_entry_parent_id {
1164 visible_worktree_entries.push(Entry {
1165 id: NEW_ENTRY_ID,
1166 kind: new_entry_kind,
1167 path: entry.path.join("\0").into(),
1168 inode: 0,
1169 mtime: entry.mtime,
1170 is_symlink: false,
1171 is_ignored: false,
1172 is_external: false,
1173 is_private: false,
1174 git_status: entry.git_status,
1175 });
1176 }
1177 if expanded_dir_ids.binary_search(&entry.id).is_err()
1178 && entry_iter.advance_to_sibling()
1179 {
1180 continue;
1181 }
1182 entry_iter.advance();
1183 }
1184
1185 snapshot.propagate_git_statuses(&mut visible_worktree_entries);
1186
1187 visible_worktree_entries.sort_by(|entry_a, entry_b| {
1188 let mut components_a = entry_a.path.components().peekable();
1189 let mut components_b = entry_b.path.components().peekable();
1190 loop {
1191 match (components_a.next(), components_b.next()) {
1192 (Some(component_a), Some(component_b)) => {
1193 let a_is_file = components_a.peek().is_none() && entry_a.is_file();
1194 let b_is_file = components_b.peek().is_none() && entry_b.is_file();
1195 let ordering = a_is_file.cmp(&b_is_file).then_with(|| {
1196 let maybe_numeric_ordering = maybe!({
1197 let num_and_remainder_a = Path::new(component_a.as_os_str())
1198 .file_stem()
1199 .and_then(|s| s.to_str())
1200 .and_then(
1201 NumericPrefixWithSuffix::from_numeric_prefixed_str,
1202 )?;
1203 let num_and_remainder_b = Path::new(component_b.as_os_str())
1204 .file_stem()
1205 .and_then(|s| s.to_str())
1206 .and_then(
1207 NumericPrefixWithSuffix::from_numeric_prefixed_str,
1208 )?;
1209
1210 num_and_remainder_a.partial_cmp(&num_and_remainder_b)
1211 });
1212
1213 maybe_numeric_ordering.unwrap_or_else(|| {
1214 let name_a =
1215 UniCase::new(component_a.as_os_str().to_string_lossy());
1216 let name_b =
1217 UniCase::new(component_b.as_os_str().to_string_lossy());
1218
1219 name_a.cmp(&name_b)
1220 })
1221 });
1222 if !ordering.is_eq() {
1223 return ordering;
1224 }
1225 }
1226 (Some(_), None) => break Ordering::Greater,
1227 (None, Some(_)) => break Ordering::Less,
1228 (None, None) => break Ordering::Equal,
1229 }
1230 }
1231 });
1232 self.visible_entries
1233 .push((worktree_id, visible_worktree_entries));
1234 }
1235
1236 if let Some((worktree_id, entry_id)) = new_selected_entry {
1237 self.selection = Some(Selection {
1238 worktree_id,
1239 entry_id,
1240 });
1241 }
1242 }
1243
1244 fn expand_entry(
1245 &mut self,
1246 worktree_id: WorktreeId,
1247 entry_id: ProjectEntryId,
1248 cx: &mut ViewContext<Self>,
1249 ) {
1250 self.project.update(cx, |project, cx| {
1251 if let Some((worktree, expanded_dir_ids)) = project
1252 .worktree_for_id(worktree_id, cx)
1253 .zip(self.expanded_dir_ids.get_mut(&worktree_id))
1254 {
1255 project.expand_entry(worktree_id, entry_id, cx);
1256 let worktree = worktree.read(cx);
1257
1258 if let Some(mut entry) = worktree.entry_for_id(entry_id) {
1259 loop {
1260 if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
1261 expanded_dir_ids.insert(ix, entry.id);
1262 }
1263
1264 if let Some(parent_entry) =
1265 entry.path.parent().and_then(|p| worktree.entry_for_path(p))
1266 {
1267 entry = parent_entry;
1268 } else {
1269 break;
1270 }
1271 }
1272 }
1273 }
1274 });
1275 }
1276
1277 fn for_each_visible_entry(
1278 &self,
1279 range: Range<usize>,
1280 cx: &mut ViewContext<ProjectPanel>,
1281 mut callback: impl FnMut(ProjectEntryId, EntryDetails, &mut ViewContext<ProjectPanel>),
1282 ) {
1283 let mut ix = 0;
1284 for (worktree_id, visible_worktree_entries) in &self.visible_entries {
1285 if ix >= range.end {
1286 return;
1287 }
1288
1289 if ix + visible_worktree_entries.len() <= range.start {
1290 ix += visible_worktree_entries.len();
1291 continue;
1292 }
1293
1294 let end_ix = range.end.min(ix + visible_worktree_entries.len());
1295 let (git_status_setting, show_file_icons, show_folder_icons) = {
1296 let settings = ProjectPanelSettings::get_global(cx);
1297 (
1298 settings.git_status,
1299 settings.file_icons,
1300 settings.folder_icons,
1301 )
1302 };
1303 if let Some(worktree) = self.project.read(cx).worktree_for_id(*worktree_id, cx) {
1304 let snapshot = worktree.read(cx).snapshot();
1305 let root_name = OsStr::new(snapshot.root_name());
1306 let expanded_entry_ids = self
1307 .expanded_dir_ids
1308 .get(&snapshot.id())
1309 .map(Vec::as_slice)
1310 .unwrap_or(&[]);
1311
1312 let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
1313 for entry in visible_worktree_entries[entry_range].iter() {
1314 let status = git_status_setting.then(|| entry.git_status).flatten();
1315 let is_expanded = expanded_entry_ids.binary_search(&entry.id).is_ok();
1316 let icon = match entry.kind {
1317 EntryKind::File(_) => {
1318 if show_file_icons {
1319 FileAssociations::get_icon(&entry.path, cx)
1320 } else {
1321 None
1322 }
1323 }
1324 _ => {
1325 if show_folder_icons {
1326 FileAssociations::get_folder_icon(is_expanded, cx)
1327 } else {
1328 FileAssociations::get_chevron_icon(is_expanded, cx)
1329 }
1330 }
1331 };
1332
1333 let mut details = EntryDetails {
1334 filename: entry
1335 .path
1336 .file_name()
1337 .unwrap_or(root_name)
1338 .to_string_lossy()
1339 .to_string(),
1340 icon,
1341 path: entry.path.clone(),
1342 depth: entry.path.components().count(),
1343 kind: entry.kind,
1344 is_ignored: entry.is_ignored,
1345 is_expanded,
1346 is_selected: self.selection.map_or(false, |e| {
1347 e.worktree_id == snapshot.id() && e.entry_id == entry.id
1348 }),
1349 is_editing: false,
1350 is_processing: false,
1351 is_cut: self
1352 .clipboard_entry
1353 .map_or(false, |e| e.is_cut() && e.entry_id() == entry.id),
1354 git_status: status,
1355 is_dotenv: entry.is_private,
1356 };
1357
1358 if let Some(edit_state) = &self.edit_state {
1359 let is_edited_entry = if edit_state.is_new_entry {
1360 entry.id == NEW_ENTRY_ID
1361 } else {
1362 entry.id == edit_state.entry_id
1363 };
1364
1365 if is_edited_entry {
1366 if let Some(processing_filename) = &edit_state.processing_filename {
1367 details.is_processing = true;
1368 details.filename.clear();
1369 details.filename.push_str(processing_filename);
1370 } else {
1371 if edit_state.is_new_entry {
1372 details.filename.clear();
1373 }
1374 details.is_editing = true;
1375 }
1376 }
1377 }
1378
1379 callback(entry.id, details, cx);
1380 }
1381 }
1382 ix = end_ix;
1383 }
1384 }
1385
1386 fn render_entry(
1387 &self,
1388 entry_id: ProjectEntryId,
1389 details: EntryDetails,
1390 cx: &mut ViewContext<Self>,
1391 ) -> Stateful<Div> {
1392 let kind = details.kind;
1393 let settings = ProjectPanelSettings::get_global(cx);
1394 let show_editor = details.is_editing && !details.is_processing;
1395 let is_selected = self
1396 .selection
1397 .map_or(false, |selection| selection.entry_id == entry_id);
1398 let width = self.size(cx);
1399
1400 let filename_text_color = details
1401 .git_status
1402 .as_ref()
1403 .map(|status| match status {
1404 GitFileStatus::Added => Color::Created,
1405 GitFileStatus::Modified => Color::Modified,
1406 GitFileStatus::Conflict => Color::Conflict,
1407 })
1408 .unwrap_or(if is_selected {
1409 Color::Default
1410 } else if details.is_ignored {
1411 Color::Disabled
1412 } else {
1413 Color::Muted
1414 });
1415
1416 let file_name = details.filename.clone();
1417 let icon = details.icon.clone();
1418 let depth = details.depth;
1419 div()
1420 .id(entry_id.to_proto() as usize)
1421 .on_drag(entry_id, move |entry_id, cx| {
1422 cx.new_view(|_| DraggedProjectEntryView {
1423 details: details.clone(),
1424 width,
1425 entry_id: *entry_id,
1426 })
1427 })
1428 .drag_over::<ProjectEntryId>(|style, _, cx| {
1429 style.bg(cx.theme().colors().drop_target_background)
1430 })
1431 .on_drop(cx.listener(move |this, dragged_id: &ProjectEntryId, cx| {
1432 this.move_entry(*dragged_id, entry_id, kind.is_file(), cx);
1433 }))
1434 .child(
1435 ListItem::new(entry_id.to_proto() as usize)
1436 .indent_level(depth)
1437 .indent_step_size(px(settings.indent_size))
1438 .selected(is_selected)
1439 .child(if let Some(icon) = &icon {
1440 div().child(Icon::from_path(icon.to_string()).color(filename_text_color))
1441 } else {
1442 div().size(IconSize::default().rems()).invisible()
1443 })
1444 .child(
1445 if let (Some(editor), true) = (Some(&self.filename_editor), show_editor) {
1446 h_flex().h_6().w_full().child(editor.clone())
1447 } else {
1448 div()
1449 .h_6()
1450 .child(Label::new(file_name).color(filename_text_color))
1451 }
1452 .ml_1(),
1453 )
1454 .on_click(cx.listener(move |this, event: &gpui::ClickEvent, cx| {
1455 if event.down.button == MouseButton::Right {
1456 return;
1457 }
1458 if !show_editor {
1459 if kind.is_dir() {
1460 this.toggle_expanded(entry_id, cx);
1461 } else {
1462 if event.down.modifiers.command {
1463 this.split_entry(entry_id, cx);
1464 } else {
1465 this.open_entry(entry_id, event.up.click_count > 1, cx);
1466 }
1467 }
1468 }
1469 }))
1470 .on_secondary_mouse_down(cx.listener(
1471 move |this, event: &MouseDownEvent, cx| {
1472 // Stop propagation to prevent the catch-all context menu for the project
1473 // panel from being deployed.
1474 cx.stop_propagation();
1475 this.deploy_context_menu(event.position, entry_id, cx);
1476 },
1477 )),
1478 )
1479 }
1480
1481 fn dispatch_context(&self, cx: &ViewContext<Self>) -> KeyContext {
1482 let mut dispatch_context = KeyContext::default();
1483 dispatch_context.add("ProjectPanel");
1484 dispatch_context.add("menu");
1485
1486 let identifier = if self.filename_editor.focus_handle(cx).is_focused(cx) {
1487 "editing"
1488 } else {
1489 "not_editing"
1490 };
1491
1492 dispatch_context.add(identifier);
1493 dispatch_context
1494 }
1495
1496 fn reveal_entry(
1497 &mut self,
1498 project: Model<Project>,
1499 entry_id: ProjectEntryId,
1500 skip_ignored: bool,
1501 cx: &mut ViewContext<'_, ProjectPanel>,
1502 ) {
1503 if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
1504 let worktree = worktree.read(cx);
1505 if skip_ignored
1506 && worktree
1507 .entry_for_id(entry_id)
1508 .map_or(true, |entry| entry.is_ignored)
1509 {
1510 return;
1511 }
1512
1513 let worktree_id = worktree.id();
1514 self.expand_entry(worktree_id, entry_id, cx);
1515 self.update_visible_entries(Some((worktree_id, entry_id)), cx);
1516 self.autoscroll(cx);
1517 cx.notify();
1518 }
1519 }
1520}
1521
1522impl Render for ProjectPanel {
1523 fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> impl IntoElement {
1524 let has_worktree = self.visible_entries.len() != 0;
1525 let project = self.project.read(cx);
1526
1527 if has_worktree {
1528 div()
1529 .id("project-panel")
1530 .size_full()
1531 .relative()
1532 .key_context(self.dispatch_context(cx))
1533 .on_action(cx.listener(Self::select_next))
1534 .on_action(cx.listener(Self::select_prev))
1535 .on_action(cx.listener(Self::expand_selected_entry))
1536 .on_action(cx.listener(Self::collapse_selected_entry))
1537 .on_action(cx.listener(Self::collapse_all_entries))
1538 .on_action(cx.listener(Self::open))
1539 .on_action(cx.listener(Self::confirm))
1540 .on_action(cx.listener(Self::cancel))
1541 .on_action(cx.listener(Self::copy_path))
1542 .on_action(cx.listener(Self::copy_relative_path))
1543 .on_action(cx.listener(Self::new_search_in_directory))
1544 .when(!project.is_read_only(), |el| {
1545 el.on_action(cx.listener(Self::new_file))
1546 .on_action(cx.listener(Self::new_directory))
1547 .on_action(cx.listener(Self::rename))
1548 .on_action(cx.listener(Self::delete))
1549 .on_action(cx.listener(Self::cut))
1550 .on_action(cx.listener(Self::copy))
1551 .on_action(cx.listener(Self::paste))
1552 })
1553 .when(project.is_local(), |el| {
1554 el.on_action(cx.listener(Self::reveal_in_finder))
1555 .on_action(cx.listener(Self::open_in_terminal))
1556 })
1557 .on_mouse_down(
1558 MouseButton::Right,
1559 cx.listener(move |this, event: &MouseDownEvent, cx| {
1560 // When deploying the context menu anywhere below the last project entry,
1561 // act as if the user clicked the root of the last worktree.
1562 if let Some(entry_id) = this.last_worktree_root_id {
1563 this.deploy_context_menu(event.position, entry_id, cx);
1564 }
1565 }),
1566 )
1567 .track_focus(&self.focus_handle)
1568 .child(
1569 uniform_list(
1570 cx.view().clone(),
1571 "entries",
1572 self.visible_entries
1573 .iter()
1574 .map(|(_, worktree_entries)| worktree_entries.len())
1575 .sum(),
1576 {
1577 |this, range, cx| {
1578 let mut items = Vec::new();
1579 this.for_each_visible_entry(range, cx, |id, details, cx| {
1580 items.push(this.render_entry(id, details, cx));
1581 });
1582 items
1583 }
1584 },
1585 )
1586 .size_full()
1587 .track_scroll(self.scroll_handle.clone()),
1588 )
1589 .children(self.context_menu.as_ref().map(|(menu, position, _)| {
1590 overlay()
1591 .position(*position)
1592 .anchor(gpui::AnchorCorner::TopLeft)
1593 .child(menu.clone())
1594 }))
1595 } else {
1596 v_flex()
1597 .id("empty-project_panel")
1598 .size_full()
1599 .p_4()
1600 .track_focus(&self.focus_handle)
1601 .child(
1602 Button::new("open_project", "Open a project")
1603 .style(ButtonStyle::Filled)
1604 .full_width()
1605 .key_binding(KeyBinding::for_action(&workspace::Open, cx))
1606 .on_click(cx.listener(|this, _, cx| {
1607 this.workspace
1608 .update(cx, |workspace, cx| workspace.open(&workspace::Open, cx))
1609 .log_err();
1610 })),
1611 )
1612 }
1613 }
1614}
1615
1616impl Render for DraggedProjectEntryView {
1617 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl Element {
1618 let settings = ProjectPanelSettings::get_global(cx);
1619 let ui_font = ThemeSettings::get_global(cx).ui_font.family.clone();
1620 h_flex()
1621 .font(ui_font)
1622 .bg(cx.theme().colors().background)
1623 .w(self.width)
1624 .child(
1625 ListItem::new(self.entry_id.to_proto() as usize)
1626 .indent_level(self.details.depth)
1627 .indent_step_size(px(settings.indent_size))
1628 .child(if let Some(icon) = &self.details.icon {
1629 div().child(Icon::from_path(icon.to_string()))
1630 } else {
1631 div()
1632 })
1633 .child(Label::new(self.details.filename.clone())),
1634 )
1635 }
1636}
1637
1638impl EventEmitter<Event> for ProjectPanel {}
1639
1640impl EventEmitter<PanelEvent> for ProjectPanel {}
1641
1642impl Panel for ProjectPanel {
1643 fn position(&self, cx: &WindowContext) -> DockPosition {
1644 match ProjectPanelSettings::get_global(cx).dock {
1645 ProjectPanelDockPosition::Left => DockPosition::Left,
1646 ProjectPanelDockPosition::Right => DockPosition::Right,
1647 }
1648 }
1649
1650 fn position_is_valid(&self, position: DockPosition) -> bool {
1651 matches!(position, DockPosition::Left | DockPosition::Right)
1652 }
1653
1654 fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
1655 settings::update_settings_file::<ProjectPanelSettings>(
1656 self.fs.clone(),
1657 cx,
1658 move |settings| {
1659 let dock = match position {
1660 DockPosition::Left | DockPosition::Bottom => ProjectPanelDockPosition::Left,
1661 DockPosition::Right => ProjectPanelDockPosition::Right,
1662 };
1663 settings.dock = Some(dock);
1664 },
1665 );
1666 }
1667
1668 fn size(&self, cx: &WindowContext) -> Pixels {
1669 self.width
1670 .unwrap_or_else(|| ProjectPanelSettings::get_global(cx).default_width)
1671 }
1672
1673 fn set_size(&mut self, size: Option<Pixels>, cx: &mut ViewContext<Self>) {
1674 self.width = size;
1675 self.serialize(cx);
1676 cx.notify();
1677 }
1678
1679 fn icon(&self, _: &WindowContext) -> Option<ui::IconName> {
1680 Some(ui::IconName::FileTree)
1681 }
1682
1683 fn icon_tooltip(&self, _cx: &WindowContext) -> Option<&'static str> {
1684 Some("Project Panel")
1685 }
1686
1687 fn toggle_action(&self) -> Box<dyn Action> {
1688 Box::new(ToggleFocus)
1689 }
1690
1691 fn persistent_name() -> &'static str {
1692 "Project Panel"
1693 }
1694
1695 fn starts_open(&self, cx: &WindowContext) -> bool {
1696 self.project.read(cx).visible_worktrees(cx).any(|tree| {
1697 tree.read(cx)
1698 .root_entry()
1699 .map_or(false, |entry| entry.is_dir())
1700 })
1701 }
1702}
1703
1704impl FocusableView for ProjectPanel {
1705 fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
1706 self.focus_handle.clone()
1707 }
1708}
1709
1710impl ClipboardEntry {
1711 fn is_cut(&self) -> bool {
1712 matches!(self, Self::Cut { .. })
1713 }
1714
1715 fn entry_id(&self) -> ProjectEntryId {
1716 match self {
1717 ClipboardEntry::Copied { entry_id, .. } | ClipboardEntry::Cut { entry_id, .. } => {
1718 *entry_id
1719 }
1720 }
1721 }
1722
1723 fn worktree_id(&self) -> WorktreeId {
1724 match self {
1725 ClipboardEntry::Copied { worktree_id, .. }
1726 | ClipboardEntry::Cut { worktree_id, .. } => *worktree_id,
1727 }
1728 }
1729}
1730
1731#[cfg(test)]
1732mod tests {
1733 use super::*;
1734 use collections::HashSet;
1735 use gpui::{TestAppContext, View, VisualTestContext, WindowHandle};
1736 use pretty_assertions::assert_eq;
1737 use project::{FakeFs, WorktreeSettings};
1738 use serde_json::json;
1739 use settings::SettingsStore;
1740 use std::path::{Path, PathBuf};
1741 use workspace::AppState;
1742
1743 #[gpui::test]
1744 async fn test_visible_list(cx: &mut gpui::TestAppContext) {
1745 init_test(cx);
1746
1747 let fs = FakeFs::new(cx.executor().clone());
1748 fs.insert_tree(
1749 "/root1",
1750 json!({
1751 ".dockerignore": "",
1752 ".git": {
1753 "HEAD": "",
1754 },
1755 "a": {
1756 "0": { "q": "", "r": "", "s": "" },
1757 "1": { "t": "", "u": "" },
1758 "2": { "v": "", "w": "", "x": "", "y": "" },
1759 },
1760 "b": {
1761 "3": { "Q": "" },
1762 "4": { "R": "", "S": "", "T": "", "U": "" },
1763 },
1764 "C": {
1765 "5": {},
1766 "6": { "V": "", "W": "" },
1767 "7": { "X": "" },
1768 "8": { "Y": {}, "Z": "" }
1769 }
1770 }),
1771 )
1772 .await;
1773 fs.insert_tree(
1774 "/root2",
1775 json!({
1776 "d": {
1777 "9": ""
1778 },
1779 "e": {}
1780 }),
1781 )
1782 .await;
1783
1784 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1785 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1786 let cx = &mut VisualTestContext::from_window(*workspace, cx);
1787 let panel = workspace
1788 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
1789 .unwrap();
1790 assert_eq!(
1791 visible_entries_as_strings(&panel, 0..50, cx),
1792 &[
1793 "v root1",
1794 " > .git",
1795 " > a",
1796 " > b",
1797 " > C",
1798 " .dockerignore",
1799 "v root2",
1800 " > d",
1801 " > e",
1802 ]
1803 );
1804
1805 toggle_expand_dir(&panel, "root1/b", cx);
1806 assert_eq!(
1807 visible_entries_as_strings(&panel, 0..50, cx),
1808 &[
1809 "v root1",
1810 " > .git",
1811 " > a",
1812 " v b <== selected",
1813 " > 3",
1814 " > 4",
1815 " > C",
1816 " .dockerignore",
1817 "v root2",
1818 " > d",
1819 " > e",
1820 ]
1821 );
1822
1823 assert_eq!(
1824 visible_entries_as_strings(&panel, 6..9, cx),
1825 &[
1826 //
1827 " > C",
1828 " .dockerignore",
1829 "v root2",
1830 ]
1831 );
1832 }
1833
1834 #[gpui::test]
1835 async fn test_exclusions_in_visible_list(cx: &mut gpui::TestAppContext) {
1836 init_test(cx);
1837 cx.update(|cx| {
1838 cx.update_global::<SettingsStore, _>(|store, cx| {
1839 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
1840 worktree_settings.file_scan_exclusions =
1841 Some(vec!["**/.git".to_string(), "**/4/**".to_string()]);
1842 });
1843 });
1844 });
1845
1846 let fs = FakeFs::new(cx.background_executor.clone());
1847 fs.insert_tree(
1848 "/root1",
1849 json!({
1850 ".dockerignore": "",
1851 ".git": {
1852 "HEAD": "",
1853 },
1854 "a": {
1855 "0": { "q": "", "r": "", "s": "" },
1856 "1": { "t": "", "u": "" },
1857 "2": { "v": "", "w": "", "x": "", "y": "" },
1858 },
1859 "b": {
1860 "3": { "Q": "" },
1861 "4": { "R": "", "S": "", "T": "", "U": "" },
1862 },
1863 "C": {
1864 "5": {},
1865 "6": { "V": "", "W": "" },
1866 "7": { "X": "" },
1867 "8": { "Y": {}, "Z": "" }
1868 }
1869 }),
1870 )
1871 .await;
1872 fs.insert_tree(
1873 "/root2",
1874 json!({
1875 "d": {
1876 "4": ""
1877 },
1878 "e": {}
1879 }),
1880 )
1881 .await;
1882
1883 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1884 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1885 let cx = &mut VisualTestContext::from_window(*workspace, cx);
1886 let panel = workspace
1887 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
1888 .unwrap();
1889 assert_eq!(
1890 visible_entries_as_strings(&panel, 0..50, cx),
1891 &[
1892 "v root1",
1893 " > a",
1894 " > b",
1895 " > C",
1896 " .dockerignore",
1897 "v root2",
1898 " > d",
1899 " > e",
1900 ]
1901 );
1902
1903 toggle_expand_dir(&panel, "root1/b", cx);
1904 assert_eq!(
1905 visible_entries_as_strings(&panel, 0..50, cx),
1906 &[
1907 "v root1",
1908 " > a",
1909 " v b <== selected",
1910 " > 3",
1911 " > C",
1912 " .dockerignore",
1913 "v root2",
1914 " > d",
1915 " > e",
1916 ]
1917 );
1918
1919 toggle_expand_dir(&panel, "root2/d", cx);
1920 assert_eq!(
1921 visible_entries_as_strings(&panel, 0..50, cx),
1922 &[
1923 "v root1",
1924 " > a",
1925 " v b",
1926 " > 3",
1927 " > C",
1928 " .dockerignore",
1929 "v root2",
1930 " v d <== selected",
1931 " > e",
1932 ]
1933 );
1934
1935 toggle_expand_dir(&panel, "root2/e", cx);
1936 assert_eq!(
1937 visible_entries_as_strings(&panel, 0..50, cx),
1938 &[
1939 "v root1",
1940 " > a",
1941 " v b",
1942 " > 3",
1943 " > C",
1944 " .dockerignore",
1945 "v root2",
1946 " v d",
1947 " v e <== selected",
1948 ]
1949 );
1950 }
1951
1952 #[gpui::test(iterations = 30)]
1953 async fn test_editing_files(cx: &mut gpui::TestAppContext) {
1954 init_test(cx);
1955
1956 let fs = FakeFs::new(cx.executor().clone());
1957 fs.insert_tree(
1958 "/root1",
1959 json!({
1960 ".dockerignore": "",
1961 ".git": {
1962 "HEAD": "",
1963 },
1964 "a": {
1965 "0": { "q": "", "r": "", "s": "" },
1966 "1": { "t": "", "u": "" },
1967 "2": { "v": "", "w": "", "x": "", "y": "" },
1968 },
1969 "b": {
1970 "3": { "Q": "" },
1971 "4": { "R": "", "S": "", "T": "", "U": "" },
1972 },
1973 "C": {
1974 "5": {},
1975 "6": { "V": "", "W": "" },
1976 "7": { "X": "" },
1977 "8": { "Y": {}, "Z": "" }
1978 }
1979 }),
1980 )
1981 .await;
1982 fs.insert_tree(
1983 "/root2",
1984 json!({
1985 "d": {
1986 "9": ""
1987 },
1988 "e": {}
1989 }),
1990 )
1991 .await;
1992
1993 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1994 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1995 let cx = &mut VisualTestContext::from_window(*workspace, cx);
1996 let panel = workspace
1997 .update(cx, |workspace, cx| {
1998 let panel = ProjectPanel::new(workspace, cx);
1999 workspace.add_panel(panel.clone(), cx);
2000 panel
2001 })
2002 .unwrap();
2003
2004 select_path(&panel, "root1", cx);
2005 assert_eq!(
2006 visible_entries_as_strings(&panel, 0..10, cx),
2007 &[
2008 "v root1 <== selected",
2009 " > .git",
2010 " > a",
2011 " > b",
2012 " > C",
2013 " .dockerignore",
2014 "v root2",
2015 " > d",
2016 " > e",
2017 ]
2018 );
2019
2020 // Add a file with the root folder selected. The filename editor is placed
2021 // before the first file in the root folder.
2022 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2023 panel.update(cx, |panel, cx| {
2024 assert!(panel.filename_editor.read(cx).is_focused(cx));
2025 });
2026 assert_eq!(
2027 visible_entries_as_strings(&panel, 0..10, cx),
2028 &[
2029 "v root1",
2030 " > .git",
2031 " > a",
2032 " > b",
2033 " > C",
2034 " [EDITOR: ''] <== selected",
2035 " .dockerignore",
2036 "v root2",
2037 " > d",
2038 " > e",
2039 ]
2040 );
2041
2042 let confirm = panel.update(cx, |panel, cx| {
2043 panel
2044 .filename_editor
2045 .update(cx, |editor, cx| editor.set_text("the-new-filename", cx));
2046 panel.confirm_edit(cx).unwrap()
2047 });
2048 assert_eq!(
2049 visible_entries_as_strings(&panel, 0..10, cx),
2050 &[
2051 "v root1",
2052 " > .git",
2053 " > a",
2054 " > b",
2055 " > C",
2056 " [PROCESSING: 'the-new-filename'] <== selected",
2057 " .dockerignore",
2058 "v root2",
2059 " > d",
2060 " > e",
2061 ]
2062 );
2063
2064 confirm.await.unwrap();
2065 assert_eq!(
2066 visible_entries_as_strings(&panel, 0..10, cx),
2067 &[
2068 "v root1",
2069 " > .git",
2070 " > a",
2071 " > b",
2072 " > C",
2073 " .dockerignore",
2074 " the-new-filename <== selected",
2075 "v root2",
2076 " > d",
2077 " > e",
2078 ]
2079 );
2080
2081 select_path(&panel, "root1/b", cx);
2082 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2083 assert_eq!(
2084 visible_entries_as_strings(&panel, 0..10, cx),
2085 &[
2086 "v root1",
2087 " > .git",
2088 " > a",
2089 " v b",
2090 " > 3",
2091 " > 4",
2092 " [EDITOR: ''] <== selected",
2093 " > C",
2094 " .dockerignore",
2095 " the-new-filename",
2096 ]
2097 );
2098
2099 panel
2100 .update(cx, |panel, cx| {
2101 panel
2102 .filename_editor
2103 .update(cx, |editor, cx| editor.set_text("another-filename.txt", cx));
2104 panel.confirm_edit(cx).unwrap()
2105 })
2106 .await
2107 .unwrap();
2108 assert_eq!(
2109 visible_entries_as_strings(&panel, 0..10, cx),
2110 &[
2111 "v root1",
2112 " > .git",
2113 " > a",
2114 " v b",
2115 " > 3",
2116 " > 4",
2117 " another-filename.txt <== selected",
2118 " > C",
2119 " .dockerignore",
2120 " the-new-filename",
2121 ]
2122 );
2123
2124 select_path(&panel, "root1/b/another-filename.txt", cx);
2125 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2126 assert_eq!(
2127 visible_entries_as_strings(&panel, 0..10, cx),
2128 &[
2129 "v root1",
2130 " > .git",
2131 " > a",
2132 " v b",
2133 " > 3",
2134 " > 4",
2135 " [EDITOR: 'another-filename.txt'] <== selected",
2136 " > C",
2137 " .dockerignore",
2138 " the-new-filename",
2139 ]
2140 );
2141
2142 let confirm = panel.update(cx, |panel, cx| {
2143 panel.filename_editor.update(cx, |editor, cx| {
2144 let file_name_selections = editor.selections.all::<usize>(cx);
2145 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
2146 let file_name_selection = &file_name_selections[0];
2147 assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
2148 assert_eq!(file_name_selection.end, "another-filename".len(), "Should not select file extension");
2149
2150 editor.set_text("a-different-filename.tar.gz", cx)
2151 });
2152 panel.confirm_edit(cx).unwrap()
2153 });
2154 assert_eq!(
2155 visible_entries_as_strings(&panel, 0..10, cx),
2156 &[
2157 "v root1",
2158 " > .git",
2159 " > a",
2160 " v b",
2161 " > 3",
2162 " > 4",
2163 " [PROCESSING: 'a-different-filename.tar.gz'] <== selected",
2164 " > C",
2165 " .dockerignore",
2166 " the-new-filename",
2167 ]
2168 );
2169
2170 confirm.await.unwrap();
2171 assert_eq!(
2172 visible_entries_as_strings(&panel, 0..10, cx),
2173 &[
2174 "v root1",
2175 " > .git",
2176 " > a",
2177 " v b",
2178 " > 3",
2179 " > 4",
2180 " a-different-filename.tar.gz <== selected",
2181 " > C",
2182 " .dockerignore",
2183 " the-new-filename",
2184 ]
2185 );
2186
2187 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2188 assert_eq!(
2189 visible_entries_as_strings(&panel, 0..10, cx),
2190 &[
2191 "v root1",
2192 " > .git",
2193 " > a",
2194 " v b",
2195 " > 3",
2196 " > 4",
2197 " [EDITOR: 'a-different-filename.tar.gz'] <== selected",
2198 " > C",
2199 " .dockerignore",
2200 " the-new-filename",
2201 ]
2202 );
2203
2204 panel.update(cx, |panel, cx| {
2205 panel.filename_editor.update(cx, |editor, cx| {
2206 let file_name_selections = editor.selections.all::<usize>(cx);
2207 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
2208 let file_name_selection = &file_name_selections[0];
2209 assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
2210 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..");
2211
2212 });
2213 panel.cancel(&Cancel, cx)
2214 });
2215
2216 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2217 assert_eq!(
2218 visible_entries_as_strings(&panel, 0..10, cx),
2219 &[
2220 "v root1",
2221 " > .git",
2222 " > a",
2223 " v b",
2224 " > [EDITOR: ''] <== selected",
2225 " > 3",
2226 " > 4",
2227 " a-different-filename.tar.gz",
2228 " > C",
2229 " .dockerignore",
2230 ]
2231 );
2232
2233 let confirm = panel.update(cx, |panel, cx| {
2234 panel
2235 .filename_editor
2236 .update(cx, |editor, cx| editor.set_text("new-dir", cx));
2237 panel.confirm_edit(cx).unwrap()
2238 });
2239 panel.update(cx, |panel, cx| panel.select_next(&Default::default(), cx));
2240 assert_eq!(
2241 visible_entries_as_strings(&panel, 0..10, cx),
2242 &[
2243 "v root1",
2244 " > .git",
2245 " > a",
2246 " v b",
2247 " > [PROCESSING: 'new-dir']",
2248 " > 3 <== selected",
2249 " > 4",
2250 " a-different-filename.tar.gz",
2251 " > C",
2252 " .dockerignore",
2253 ]
2254 );
2255
2256 confirm.await.unwrap();
2257 assert_eq!(
2258 visible_entries_as_strings(&panel, 0..10, cx),
2259 &[
2260 "v root1",
2261 " > .git",
2262 " > a",
2263 " v b",
2264 " > 3 <== selected",
2265 " > 4",
2266 " > new-dir",
2267 " a-different-filename.tar.gz",
2268 " > C",
2269 " .dockerignore",
2270 ]
2271 );
2272
2273 panel.update(cx, |panel, cx| panel.rename(&Default::default(), cx));
2274 assert_eq!(
2275 visible_entries_as_strings(&panel, 0..10, cx),
2276 &[
2277 "v root1",
2278 " > .git",
2279 " > a",
2280 " v b",
2281 " > [EDITOR: '3'] <== selected",
2282 " > 4",
2283 " > new-dir",
2284 " a-different-filename.tar.gz",
2285 " > C",
2286 " .dockerignore",
2287 ]
2288 );
2289
2290 // Dismiss the rename editor when it loses focus.
2291 workspace.update(cx, |_, cx| cx.blur()).unwrap();
2292 assert_eq!(
2293 visible_entries_as_strings(&panel, 0..10, cx),
2294 &[
2295 "v root1",
2296 " > .git",
2297 " > a",
2298 " v b",
2299 " > 3 <== selected",
2300 " > 4",
2301 " > new-dir",
2302 " a-different-filename.tar.gz",
2303 " > C",
2304 " .dockerignore",
2305 ]
2306 );
2307 }
2308
2309 #[gpui::test(iterations = 10)]
2310 async fn test_adding_directories_via_file(cx: &mut gpui::TestAppContext) {
2311 init_test(cx);
2312
2313 let fs = FakeFs::new(cx.executor().clone());
2314 fs.insert_tree(
2315 "/root1",
2316 json!({
2317 ".dockerignore": "",
2318 ".git": {
2319 "HEAD": "",
2320 },
2321 "a": {
2322 "0": { "q": "", "r": "", "s": "" },
2323 "1": { "t": "", "u": "" },
2324 "2": { "v": "", "w": "", "x": "", "y": "" },
2325 },
2326 "b": {
2327 "3": { "Q": "" },
2328 "4": { "R": "", "S": "", "T": "", "U": "" },
2329 },
2330 "C": {
2331 "5": {},
2332 "6": { "V": "", "W": "" },
2333 "7": { "X": "" },
2334 "8": { "Y": {}, "Z": "" }
2335 }
2336 }),
2337 )
2338 .await;
2339 fs.insert_tree(
2340 "/root2",
2341 json!({
2342 "d": {
2343 "9": ""
2344 },
2345 "e": {}
2346 }),
2347 )
2348 .await;
2349
2350 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".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| {
2355 let panel = ProjectPanel::new(workspace, cx);
2356 workspace.add_panel(panel.clone(), cx);
2357 panel
2358 })
2359 .unwrap();
2360
2361 select_path(&panel, "root1", cx);
2362 assert_eq!(
2363 visible_entries_as_strings(&panel, 0..10, cx),
2364 &[
2365 "v root1 <== selected",
2366 " > .git",
2367 " > a",
2368 " > b",
2369 " > C",
2370 " .dockerignore",
2371 "v root2",
2372 " > d",
2373 " > e",
2374 ]
2375 );
2376
2377 // Add a file with the root folder selected. The filename editor is placed
2378 // before the first file in the root folder.
2379 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2380 panel.update(cx, |panel, cx| {
2381 assert!(panel.filename_editor.read(cx).is_focused(cx));
2382 });
2383 assert_eq!(
2384 visible_entries_as_strings(&panel, 0..10, cx),
2385 &[
2386 "v root1",
2387 " > .git",
2388 " > a",
2389 " > b",
2390 " > C",
2391 " [EDITOR: ''] <== selected",
2392 " .dockerignore",
2393 "v root2",
2394 " > d",
2395 " > e",
2396 ]
2397 );
2398
2399 let confirm = panel.update(cx, |panel, cx| {
2400 panel.filename_editor.update(cx, |editor, cx| {
2401 editor.set_text("/bdir1/dir2/the-new-filename", cx)
2402 });
2403 panel.confirm_edit(cx).unwrap()
2404 });
2405
2406 assert_eq!(
2407 visible_entries_as_strings(&panel, 0..10, cx),
2408 &[
2409 "v root1",
2410 " > .git",
2411 " > a",
2412 " > b",
2413 " > C",
2414 " [PROCESSING: '/bdir1/dir2/the-new-filename'] <== selected",
2415 " .dockerignore",
2416 "v root2",
2417 " > d",
2418 " > e",
2419 ]
2420 );
2421
2422 confirm.await.unwrap();
2423 assert_eq!(
2424 visible_entries_as_strings(&panel, 0..13, cx),
2425 &[
2426 "v root1",
2427 " > .git",
2428 " > a",
2429 " > b",
2430 " v bdir1",
2431 " v dir2",
2432 " the-new-filename <== selected",
2433 " > C",
2434 " .dockerignore",
2435 "v root2",
2436 " > d",
2437 " > e",
2438 ]
2439 );
2440 }
2441
2442 #[gpui::test]
2443 async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
2444 init_test(cx);
2445
2446 let fs = FakeFs::new(cx.executor().clone());
2447 fs.insert_tree(
2448 "/root1",
2449 json!({
2450 "one.two.txt": "",
2451 "one.txt": ""
2452 }),
2453 )
2454 .await;
2455
2456 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
2457 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2458 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2459 let panel = workspace
2460 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2461 .unwrap();
2462
2463 panel.update(cx, |panel, cx| {
2464 panel.select_next(&Default::default(), cx);
2465 panel.select_next(&Default::default(), cx);
2466 });
2467
2468 assert_eq!(
2469 visible_entries_as_strings(&panel, 0..50, cx),
2470 &[
2471 //
2472 "v root1",
2473 " one.two.txt <== selected",
2474 " one.txt",
2475 ]
2476 );
2477
2478 // Regression test - file name is created correctly when
2479 // the copied file's name contains multiple dots.
2480 panel.update(cx, |panel, cx| {
2481 panel.copy(&Default::default(), cx);
2482 panel.paste(&Default::default(), cx);
2483 });
2484 cx.executor().run_until_parked();
2485
2486 assert_eq!(
2487 visible_entries_as_strings(&panel, 0..50, cx),
2488 &[
2489 //
2490 "v root1",
2491 " one.two copy.txt",
2492 " one.two.txt <== selected",
2493 " one.txt",
2494 ]
2495 );
2496
2497 panel.update(cx, |panel, cx| {
2498 panel.paste(&Default::default(), cx);
2499 });
2500 cx.executor().run_until_parked();
2501
2502 assert_eq!(
2503 visible_entries_as_strings(&panel, 0..50, cx),
2504 &[
2505 //
2506 "v root1",
2507 " one.two copy 1.txt",
2508 " one.two copy.txt",
2509 " one.two.txt <== selected",
2510 " one.txt",
2511 ]
2512 );
2513 }
2514
2515 #[gpui::test]
2516 async fn test_copy_paste_directory(cx: &mut gpui::TestAppContext) {
2517 init_test(cx);
2518
2519 let fs = FakeFs::new(cx.executor().clone());
2520 fs.insert_tree(
2521 "/root",
2522 json!({
2523 "a": {
2524 "one.txt": "",
2525 "two.txt": "",
2526 "inner_dir": {
2527 "three.txt": "",
2528 "four.txt": "",
2529 }
2530 },
2531 "b": {}
2532 }),
2533 )
2534 .await;
2535
2536 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
2537 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2538 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2539 let panel = workspace
2540 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2541 .unwrap();
2542
2543 select_path(&panel, "root/a", cx);
2544 panel.update(cx, |panel, cx| {
2545 panel.copy(&Default::default(), cx);
2546 panel.select_next(&Default::default(), cx);
2547 panel.paste(&Default::default(), cx);
2548 });
2549 cx.executor().run_until_parked();
2550
2551 let pasted_dir = find_project_entry(&panel, "root/b/a", cx);
2552 assert_ne!(pasted_dir, None, "Pasted directory should have an entry");
2553
2554 let pasted_dir_file = find_project_entry(&panel, "root/b/a/one.txt", cx);
2555 assert_ne!(
2556 pasted_dir_file, None,
2557 "Pasted directory file should have an entry"
2558 );
2559
2560 let pasted_dir_inner_dir = find_project_entry(&panel, "root/b/a/inner_dir", cx);
2561 assert_ne!(
2562 pasted_dir_inner_dir, None,
2563 "Directories inside pasted directory should have an entry"
2564 );
2565
2566 toggle_expand_dir(&panel, "root/b", cx);
2567 toggle_expand_dir(&panel, "root/b/a", cx);
2568 toggle_expand_dir(&panel, "root/b/a/inner_dir", cx);
2569
2570 assert_eq!(
2571 visible_entries_as_strings(&panel, 0..50, cx),
2572 &[
2573 //
2574 "v root",
2575 " > a",
2576 " v b",
2577 " v a",
2578 " v inner_dir <== selected",
2579 " four.txt",
2580 " three.txt",
2581 " one.txt",
2582 " two.txt",
2583 ]
2584 );
2585
2586 select_path(&panel, "root", cx);
2587 panel.update(cx, |panel, cx| panel.paste(&Default::default(), cx));
2588 cx.executor().run_until_parked();
2589 panel.update(cx, |panel, cx| panel.paste(&Default::default(), cx));
2590 cx.executor().run_until_parked();
2591 assert_eq!(
2592 visible_entries_as_strings(&panel, 0..50, cx),
2593 &[
2594 //
2595 "v root <== selected",
2596 " > a",
2597 " > a copy",
2598 " > a copy 1",
2599 " v b",
2600 " v a",
2601 " v inner_dir",
2602 " four.txt",
2603 " three.txt",
2604 " one.txt",
2605 " two.txt"
2606 ]
2607 );
2608 }
2609
2610 #[gpui::test]
2611 async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
2612 init_test_with_editor(cx);
2613
2614 let fs = FakeFs::new(cx.executor().clone());
2615 fs.insert_tree(
2616 "/src",
2617 json!({
2618 "test": {
2619 "first.rs": "// First Rust file",
2620 "second.rs": "// Second Rust file",
2621 "third.rs": "// Third Rust file",
2622 }
2623 }),
2624 )
2625 .await;
2626
2627 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2628 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2629 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2630 let panel = workspace
2631 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2632 .unwrap();
2633
2634 toggle_expand_dir(&panel, "src/test", cx);
2635 select_path(&panel, "src/test/first.rs", cx);
2636 panel.update(cx, |panel, cx| panel.open(&Open, cx));
2637 cx.executor().run_until_parked();
2638 assert_eq!(
2639 visible_entries_as_strings(&panel, 0..10, cx),
2640 &[
2641 "v src",
2642 " v test",
2643 " first.rs <== selected",
2644 " second.rs",
2645 " third.rs"
2646 ]
2647 );
2648 ensure_single_file_is_opened(&workspace, "test/first.rs", cx);
2649
2650 submit_deletion(&panel, cx);
2651 assert_eq!(
2652 visible_entries_as_strings(&panel, 0..10, cx),
2653 &[
2654 "v src",
2655 " v test",
2656 " second.rs",
2657 " third.rs"
2658 ],
2659 "Project panel should have no deleted file, no other file is selected in it"
2660 );
2661 ensure_no_open_items_and_panes(&workspace, cx);
2662
2663 select_path(&panel, "src/test/second.rs", cx);
2664 panel.update(cx, |panel, cx| panel.open(&Open, cx));
2665 cx.executor().run_until_parked();
2666 assert_eq!(
2667 visible_entries_as_strings(&panel, 0..10, cx),
2668 &[
2669 "v src",
2670 " v test",
2671 " second.rs <== selected",
2672 " third.rs"
2673 ]
2674 );
2675 ensure_single_file_is_opened(&workspace, "test/second.rs", cx);
2676
2677 workspace
2678 .update(cx, |workspace, cx| {
2679 let active_items = workspace
2680 .panes()
2681 .iter()
2682 .filter_map(|pane| pane.read(cx).active_item())
2683 .collect::<Vec<_>>();
2684 assert_eq!(active_items.len(), 1);
2685 let open_editor = active_items
2686 .into_iter()
2687 .next()
2688 .unwrap()
2689 .downcast::<Editor>()
2690 .expect("Open item should be an editor");
2691 open_editor.update(cx, |editor, cx| editor.set_text("Another text!", cx));
2692 })
2693 .unwrap();
2694 submit_deletion(&panel, cx);
2695 assert_eq!(
2696 visible_entries_as_strings(&panel, 0..10, cx),
2697 &["v src", " v test", " third.rs"],
2698 "Project panel should have no deleted file, with one last file remaining"
2699 );
2700 ensure_no_open_items_and_panes(&workspace, cx);
2701 }
2702
2703 #[gpui::test]
2704 async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
2705 init_test_with_editor(cx);
2706
2707 let fs = FakeFs::new(cx.executor().clone());
2708 fs.insert_tree(
2709 "/src",
2710 json!({
2711 "test": {
2712 "first.rs": "// First Rust file",
2713 "second.rs": "// Second Rust file",
2714 "third.rs": "// Third Rust file",
2715 }
2716 }),
2717 )
2718 .await;
2719
2720 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2721 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2722 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2723 let panel = workspace
2724 .update(cx, |workspace, cx| {
2725 let panel = ProjectPanel::new(workspace, cx);
2726 workspace.add_panel(panel.clone(), cx);
2727 panel
2728 })
2729 .unwrap();
2730
2731 select_path(&panel, "src/", cx);
2732 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2733 cx.executor().run_until_parked();
2734 assert_eq!(
2735 visible_entries_as_strings(&panel, 0..10, cx),
2736 &[
2737 //
2738 "v src <== selected",
2739 " > test"
2740 ]
2741 );
2742 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2743 panel.update(cx, |panel, cx| {
2744 assert!(panel.filename_editor.read(cx).is_focused(cx));
2745 });
2746 assert_eq!(
2747 visible_entries_as_strings(&panel, 0..10, cx),
2748 &[
2749 //
2750 "v src",
2751 " > [EDITOR: ''] <== selected",
2752 " > test"
2753 ]
2754 );
2755 panel.update(cx, |panel, cx| {
2756 panel
2757 .filename_editor
2758 .update(cx, |editor, cx| editor.set_text("test", cx));
2759 assert!(
2760 panel.confirm_edit(cx).is_none(),
2761 "Should not allow to confirm on conflicting new directory name"
2762 )
2763 });
2764 assert_eq!(
2765 visible_entries_as_strings(&panel, 0..10, cx),
2766 &[
2767 //
2768 "v src",
2769 " > test"
2770 ],
2771 "File list should be unchanged after failed folder create confirmation"
2772 );
2773
2774 select_path(&panel, "src/test/", cx);
2775 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2776 cx.executor().run_until_parked();
2777 assert_eq!(
2778 visible_entries_as_strings(&panel, 0..10, cx),
2779 &[
2780 //
2781 "v src",
2782 " > test <== selected"
2783 ]
2784 );
2785 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2786 panel.update(cx, |panel, cx| {
2787 assert!(panel.filename_editor.read(cx).is_focused(cx));
2788 });
2789 assert_eq!(
2790 visible_entries_as_strings(&panel, 0..10, cx),
2791 &[
2792 "v src",
2793 " v test",
2794 " [EDITOR: ''] <== selected",
2795 " first.rs",
2796 " second.rs",
2797 " third.rs"
2798 ]
2799 );
2800 panel.update(cx, |panel, cx| {
2801 panel
2802 .filename_editor
2803 .update(cx, |editor, cx| editor.set_text("first.rs", cx));
2804 assert!(
2805 panel.confirm_edit(cx).is_none(),
2806 "Should not allow to confirm on conflicting new file name"
2807 )
2808 });
2809 assert_eq!(
2810 visible_entries_as_strings(&panel, 0..10, cx),
2811 &[
2812 "v src",
2813 " v test",
2814 " first.rs",
2815 " second.rs",
2816 " third.rs"
2817 ],
2818 "File list should be unchanged after failed file create confirmation"
2819 );
2820
2821 select_path(&panel, "src/test/first.rs", cx);
2822 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2823 cx.executor().run_until_parked();
2824 assert_eq!(
2825 visible_entries_as_strings(&panel, 0..10, cx),
2826 &[
2827 "v src",
2828 " v test",
2829 " first.rs <== selected",
2830 " second.rs",
2831 " third.rs"
2832 ],
2833 );
2834 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2835 panel.update(cx, |panel, cx| {
2836 assert!(panel.filename_editor.read(cx).is_focused(cx));
2837 });
2838 assert_eq!(
2839 visible_entries_as_strings(&panel, 0..10, cx),
2840 &[
2841 "v src",
2842 " v test",
2843 " [EDITOR: 'first.rs'] <== selected",
2844 " second.rs",
2845 " third.rs"
2846 ]
2847 );
2848 panel.update(cx, |panel, cx| {
2849 panel
2850 .filename_editor
2851 .update(cx, |editor, cx| editor.set_text("second.rs", cx));
2852 assert!(
2853 panel.confirm_edit(cx).is_none(),
2854 "Should not allow to confirm on conflicting file rename"
2855 )
2856 });
2857 assert_eq!(
2858 visible_entries_as_strings(&panel, 0..10, cx),
2859 &[
2860 "v src",
2861 " v test",
2862 " first.rs <== selected",
2863 " second.rs",
2864 " third.rs"
2865 ],
2866 "File list should be unchanged after failed rename confirmation"
2867 );
2868 }
2869
2870 #[gpui::test]
2871 async fn test_dir_toggle_collapse(cx: &mut gpui::TestAppContext) {
2872 init_test_with_editor(cx);
2873
2874 let fs = FakeFs::new(cx.executor().clone());
2875 fs.insert_tree(
2876 "/project_root",
2877 json!({
2878 "dir_1": {
2879 "nested_dir": {
2880 "file_a.py": "# File contents",
2881 }
2882 },
2883 "file_1.py": "# File contents",
2884 }),
2885 )
2886 .await;
2887
2888 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
2889 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2890 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2891 let panel = workspace
2892 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2893 .unwrap();
2894
2895 panel.update(cx, |panel, cx| panel.open(&Open, cx));
2896 cx.executor().run_until_parked();
2897 select_path(&panel, "project_root/dir_1", cx);
2898 panel.update(cx, |panel, cx| panel.open(&Open, cx));
2899 select_path(&panel, "project_root/dir_1/nested_dir", cx);
2900 panel.update(cx, |panel, cx| panel.open(&Open, cx));
2901 panel.update(cx, |panel, cx| panel.open(&Open, cx));
2902 cx.executor().run_until_parked();
2903 assert_eq!(
2904 visible_entries_as_strings(&panel, 0..10, cx),
2905 &[
2906 "v project_root",
2907 " v dir_1",
2908 " > nested_dir <== selected",
2909 " file_1.py",
2910 ]
2911 );
2912 }
2913
2914 #[gpui::test]
2915 async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) {
2916 init_test_with_editor(cx);
2917
2918 let fs = FakeFs::new(cx.executor().clone());
2919 fs.insert_tree(
2920 "/project_root",
2921 json!({
2922 "dir_1": {
2923 "nested_dir": {
2924 "file_a.py": "# File contents",
2925 "file_b.py": "# File contents",
2926 "file_c.py": "# File contents",
2927 },
2928 "file_1.py": "# File contents",
2929 "file_2.py": "# File contents",
2930 "file_3.py": "# File contents",
2931 },
2932 "dir_2": {
2933 "file_1.py": "# File contents",
2934 "file_2.py": "# File contents",
2935 "file_3.py": "# File contents",
2936 }
2937 }),
2938 )
2939 .await;
2940
2941 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
2942 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2943 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2944 let panel = workspace
2945 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2946 .unwrap();
2947
2948 panel.update(cx, |panel, cx| {
2949 panel.collapse_all_entries(&CollapseAllEntries, cx)
2950 });
2951 cx.executor().run_until_parked();
2952 assert_eq!(
2953 visible_entries_as_strings(&panel, 0..10, cx),
2954 &["v project_root", " > dir_1", " > dir_2",]
2955 );
2956
2957 // Open dir_1 and make sure nested_dir was collapsed when running collapse_all_entries
2958 toggle_expand_dir(&panel, "project_root/dir_1", cx);
2959 cx.executor().run_until_parked();
2960 assert_eq!(
2961 visible_entries_as_strings(&panel, 0..10, cx),
2962 &[
2963 "v project_root",
2964 " v dir_1 <== selected",
2965 " > nested_dir",
2966 " file_1.py",
2967 " file_2.py",
2968 " file_3.py",
2969 " > dir_2",
2970 ]
2971 );
2972 }
2973
2974 #[gpui::test]
2975 async fn test_new_file_move(cx: &mut gpui::TestAppContext) {
2976 init_test(cx);
2977
2978 let fs = FakeFs::new(cx.executor().clone());
2979 fs.as_fake().insert_tree("/root", json!({})).await;
2980 let project = Project::test(fs, ["/root".as_ref()], cx).await;
2981 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2982 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2983 let panel = workspace
2984 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2985 .unwrap();
2986
2987 // Make a new buffer with no backing file
2988 workspace
2989 .update(cx, |workspace, cx| {
2990 Editor::new_file(workspace, &Default::default(), cx)
2991 })
2992 .unwrap();
2993
2994 // "Save as"" the buffer, creating a new backing file for it
2995 let save_task = workspace
2996 .update(cx, |workspace, cx| {
2997 workspace.save_active_item(workspace::SaveIntent::Save, cx)
2998 })
2999 .unwrap();
3000
3001 cx.executor().run_until_parked();
3002 cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/new")));
3003 save_task.await.unwrap();
3004
3005 // Rename the file
3006 select_path(&panel, "root/new", cx);
3007 assert_eq!(
3008 visible_entries_as_strings(&panel, 0..10, cx),
3009 &["v root", " new <== selected"]
3010 );
3011 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
3012 panel.update(cx, |panel, cx| {
3013 panel
3014 .filename_editor
3015 .update(cx, |editor, cx| editor.set_text("newer", cx));
3016 });
3017 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
3018
3019 cx.executor().run_until_parked();
3020 assert_eq!(
3021 visible_entries_as_strings(&panel, 0..10, cx),
3022 &["v root", " newer <== selected"]
3023 );
3024
3025 workspace
3026 .update(cx, |workspace, cx| {
3027 workspace.save_active_item(workspace::SaveIntent::Save, cx)
3028 })
3029 .unwrap()
3030 .await
3031 .unwrap();
3032
3033 cx.executor().run_until_parked();
3034 // assert that saving the file doesn't restore "new"
3035 assert_eq!(
3036 visible_entries_as_strings(&panel, 0..10, cx),
3037 &["v root", " newer <== selected"]
3038 );
3039 }
3040
3041 #[gpui::test]
3042 async fn test_autoreveal_and_gitignored_files(cx: &mut gpui::TestAppContext) {
3043 init_test_with_editor(cx);
3044 cx.update(|cx| {
3045 cx.update_global::<SettingsStore, _>(|store, cx| {
3046 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
3047 worktree_settings.file_scan_exclusions = Some(Vec::new());
3048 });
3049 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
3050 project_panel_settings.auto_reveal_entries = Some(false)
3051 });
3052 })
3053 });
3054
3055 let fs = FakeFs::new(cx.background_executor.clone());
3056 fs.insert_tree(
3057 "/project_root",
3058 json!({
3059 ".git": {},
3060 ".gitignore": "**/gitignored_dir",
3061 "dir_1": {
3062 "file_1.py": "# File 1_1 contents",
3063 "file_2.py": "# File 1_2 contents",
3064 "file_3.py": "# File 1_3 contents",
3065 "gitignored_dir": {
3066 "file_a.py": "# File contents",
3067 "file_b.py": "# File contents",
3068 "file_c.py": "# File contents",
3069 },
3070 },
3071 "dir_2": {
3072 "file_1.py": "# File 2_1 contents",
3073 "file_2.py": "# File 2_2 contents",
3074 "file_3.py": "# File 2_3 contents",
3075 }
3076 }),
3077 )
3078 .await;
3079
3080 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3081 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3082 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3083 let panel = workspace
3084 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
3085 .unwrap();
3086
3087 assert_eq!(
3088 visible_entries_as_strings(&panel, 0..20, cx),
3089 &[
3090 "v project_root",
3091 " > .git",
3092 " > dir_1",
3093 " > dir_2",
3094 " .gitignore",
3095 ]
3096 );
3097
3098 let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
3099 .expect("dir 1 file is not ignored and should have an entry");
3100 let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
3101 .expect("dir 2 file is not ignored and should have an entry");
3102 let gitignored_dir_file =
3103 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
3104 assert_eq!(
3105 gitignored_dir_file, None,
3106 "File in the gitignored dir should not have an entry before its dir is toggled"
3107 );
3108
3109 toggle_expand_dir(&panel, "project_root/dir_1", cx);
3110 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3111 cx.executor().run_until_parked();
3112 assert_eq!(
3113 visible_entries_as_strings(&panel, 0..20, cx),
3114 &[
3115 "v project_root",
3116 " > .git",
3117 " v dir_1",
3118 " v gitignored_dir <== selected",
3119 " file_a.py",
3120 " file_b.py",
3121 " file_c.py",
3122 " file_1.py",
3123 " file_2.py",
3124 " file_3.py",
3125 " > dir_2",
3126 " .gitignore",
3127 ],
3128 "Should show gitignored dir file list in the project panel"
3129 );
3130 let gitignored_dir_file =
3131 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
3132 .expect("after gitignored dir got opened, a file entry should be present");
3133
3134 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3135 toggle_expand_dir(&panel, "project_root/dir_1", cx);
3136 assert_eq!(
3137 visible_entries_as_strings(&panel, 0..20, cx),
3138 &[
3139 "v project_root",
3140 " > .git",
3141 " > dir_1 <== selected",
3142 " > dir_2",
3143 " .gitignore",
3144 ],
3145 "Should hide all dir contents again and prepare for the auto reveal test"
3146 );
3147
3148 for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
3149 panel.update(cx, |panel, cx| {
3150 panel.project.update(cx, |_, cx| {
3151 cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
3152 })
3153 });
3154 cx.run_until_parked();
3155 assert_eq!(
3156 visible_entries_as_strings(&panel, 0..20, cx),
3157 &[
3158 "v project_root",
3159 " > .git",
3160 " > dir_1 <== selected",
3161 " > dir_2",
3162 " .gitignore",
3163 ],
3164 "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
3165 );
3166 }
3167
3168 cx.update(|cx| {
3169 cx.update_global::<SettingsStore, _>(|store, cx| {
3170 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
3171 project_panel_settings.auto_reveal_entries = Some(true)
3172 });
3173 })
3174 });
3175
3176 panel.update(cx, |panel, cx| {
3177 panel.project.update(cx, |_, cx| {
3178 cx.emit(project::Event::ActiveEntryChanged(Some(dir_1_file)))
3179 })
3180 });
3181 cx.run_until_parked();
3182 assert_eq!(
3183 visible_entries_as_strings(&panel, 0..20, cx),
3184 &[
3185 "v project_root",
3186 " > .git",
3187 " v dir_1",
3188 " > gitignored_dir",
3189 " file_1.py <== selected",
3190 " file_2.py",
3191 " file_3.py",
3192 " > dir_2",
3193 " .gitignore",
3194 ],
3195 "When auto reveal is enabled, not ignored dir_1 entry should be revealed"
3196 );
3197
3198 panel.update(cx, |panel, cx| {
3199 panel.project.update(cx, |_, cx| {
3200 cx.emit(project::Event::ActiveEntryChanged(Some(dir_2_file)))
3201 })
3202 });
3203 cx.run_until_parked();
3204 assert_eq!(
3205 visible_entries_as_strings(&panel, 0..20, cx),
3206 &[
3207 "v project_root",
3208 " > .git",
3209 " v dir_1",
3210 " > gitignored_dir",
3211 " file_1.py",
3212 " file_2.py",
3213 " file_3.py",
3214 " v dir_2",
3215 " file_1.py <== selected",
3216 " file_2.py",
3217 " file_3.py",
3218 " .gitignore",
3219 ],
3220 "When auto reveal is enabled, not ignored dir_2 entry should be revealed"
3221 );
3222
3223 panel.update(cx, |panel, cx| {
3224 panel.project.update(cx, |_, cx| {
3225 cx.emit(project::Event::ActiveEntryChanged(Some(
3226 gitignored_dir_file,
3227 )))
3228 })
3229 });
3230 cx.run_until_parked();
3231 assert_eq!(
3232 visible_entries_as_strings(&panel, 0..20, cx),
3233 &[
3234 "v project_root",
3235 " > .git",
3236 " v dir_1",
3237 " > gitignored_dir",
3238 " file_1.py",
3239 " file_2.py",
3240 " file_3.py",
3241 " v dir_2",
3242 " file_1.py <== selected",
3243 " file_2.py",
3244 " file_3.py",
3245 " .gitignore",
3246 ],
3247 "When auto reveal is enabled, a gitignored selected entry should not be revealed in the project panel"
3248 );
3249
3250 panel.update(cx, |panel, cx| {
3251 panel.project.update(cx, |_, cx| {
3252 cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
3253 })
3254 });
3255 cx.run_until_parked();
3256 assert_eq!(
3257 visible_entries_as_strings(&panel, 0..20, cx),
3258 &[
3259 "v project_root",
3260 " > .git",
3261 " v dir_1",
3262 " v gitignored_dir",
3263 " file_a.py <== selected",
3264 " file_b.py",
3265 " file_c.py",
3266 " file_1.py",
3267 " file_2.py",
3268 " file_3.py",
3269 " v dir_2",
3270 " file_1.py",
3271 " file_2.py",
3272 " file_3.py",
3273 " .gitignore",
3274 ],
3275 "When a gitignored entry is explicitly revealed, it should be shown in the project tree"
3276 );
3277 }
3278
3279 #[gpui::test]
3280 async fn test_explicit_reveal(cx: &mut gpui::TestAppContext) {
3281 init_test_with_editor(cx);
3282 cx.update(|cx| {
3283 cx.update_global::<SettingsStore, _>(|store, cx| {
3284 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
3285 worktree_settings.file_scan_exclusions = Some(Vec::new());
3286 });
3287 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
3288 project_panel_settings.auto_reveal_entries = Some(false)
3289 });
3290 })
3291 });
3292
3293 let fs = FakeFs::new(cx.background_executor.clone());
3294 fs.insert_tree(
3295 "/project_root",
3296 json!({
3297 ".git": {},
3298 ".gitignore": "**/gitignored_dir",
3299 "dir_1": {
3300 "file_1.py": "# File 1_1 contents",
3301 "file_2.py": "# File 1_2 contents",
3302 "file_3.py": "# File 1_3 contents",
3303 "gitignored_dir": {
3304 "file_a.py": "# File contents",
3305 "file_b.py": "# File contents",
3306 "file_c.py": "# File contents",
3307 },
3308 },
3309 "dir_2": {
3310 "file_1.py": "# File 2_1 contents",
3311 "file_2.py": "# File 2_2 contents",
3312 "file_3.py": "# File 2_3 contents",
3313 }
3314 }),
3315 )
3316 .await;
3317
3318 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3319 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3320 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3321 let panel = workspace
3322 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
3323 .unwrap();
3324
3325 assert_eq!(
3326 visible_entries_as_strings(&panel, 0..20, cx),
3327 &[
3328 "v project_root",
3329 " > .git",
3330 " > dir_1",
3331 " > dir_2",
3332 " .gitignore",
3333 ]
3334 );
3335
3336 let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
3337 .expect("dir 1 file is not ignored and should have an entry");
3338 let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
3339 .expect("dir 2 file is not ignored and should have an entry");
3340 let gitignored_dir_file =
3341 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
3342 assert_eq!(
3343 gitignored_dir_file, None,
3344 "File in the gitignored dir should not have an entry before its dir is toggled"
3345 );
3346
3347 toggle_expand_dir(&panel, "project_root/dir_1", cx);
3348 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3349 cx.run_until_parked();
3350 assert_eq!(
3351 visible_entries_as_strings(&panel, 0..20, cx),
3352 &[
3353 "v project_root",
3354 " > .git",
3355 " v dir_1",
3356 " v gitignored_dir <== selected",
3357 " file_a.py",
3358 " file_b.py",
3359 " file_c.py",
3360 " file_1.py",
3361 " file_2.py",
3362 " file_3.py",
3363 " > dir_2",
3364 " .gitignore",
3365 ],
3366 "Should show gitignored dir file list in the project panel"
3367 );
3368 let gitignored_dir_file =
3369 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
3370 .expect("after gitignored dir got opened, a file entry should be present");
3371
3372 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3373 toggle_expand_dir(&panel, "project_root/dir_1", cx);
3374 assert_eq!(
3375 visible_entries_as_strings(&panel, 0..20, cx),
3376 &[
3377 "v project_root",
3378 " > .git",
3379 " > dir_1 <== selected",
3380 " > dir_2",
3381 " .gitignore",
3382 ],
3383 "Should hide all dir contents again and prepare for the explicit reveal test"
3384 );
3385
3386 for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
3387 panel.update(cx, |panel, cx| {
3388 panel.project.update(cx, |_, cx| {
3389 cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
3390 })
3391 });
3392 cx.run_until_parked();
3393 assert_eq!(
3394 visible_entries_as_strings(&panel, 0..20, cx),
3395 &[
3396 "v project_root",
3397 " > .git",
3398 " > dir_1 <== selected",
3399 " > dir_2",
3400 " .gitignore",
3401 ],
3402 "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
3403 );
3404 }
3405
3406 panel.update(cx, |panel, cx| {
3407 panel.project.update(cx, |_, cx| {
3408 cx.emit(project::Event::RevealInProjectPanel(dir_1_file))
3409 })
3410 });
3411 cx.run_until_parked();
3412 assert_eq!(
3413 visible_entries_as_strings(&panel, 0..20, cx),
3414 &[
3415 "v project_root",
3416 " > .git",
3417 " v dir_1",
3418 " > gitignored_dir",
3419 " file_1.py <== selected",
3420 " file_2.py",
3421 " file_3.py",
3422 " > dir_2",
3423 " .gitignore",
3424 ],
3425 "With no auto reveal, explicit reveal should show the dir_1 entry in the project panel"
3426 );
3427
3428 panel.update(cx, |panel, cx| {
3429 panel.project.update(cx, |_, cx| {
3430 cx.emit(project::Event::RevealInProjectPanel(dir_2_file))
3431 })
3432 });
3433 cx.run_until_parked();
3434 assert_eq!(
3435 visible_entries_as_strings(&panel, 0..20, cx),
3436 &[
3437 "v project_root",
3438 " > .git",
3439 " v dir_1",
3440 " > gitignored_dir",
3441 " file_1.py",
3442 " file_2.py",
3443 " file_3.py",
3444 " v dir_2",
3445 " file_1.py <== selected",
3446 " file_2.py",
3447 " file_3.py",
3448 " .gitignore",
3449 ],
3450 "With no auto reveal, explicit reveal should show the dir_2 entry in the project panel"
3451 );
3452
3453 panel.update(cx, |panel, cx| {
3454 panel.project.update(cx, |_, cx| {
3455 cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
3456 })
3457 });
3458 cx.run_until_parked();
3459 assert_eq!(
3460 visible_entries_as_strings(&panel, 0..20, cx),
3461 &[
3462 "v project_root",
3463 " > .git",
3464 " v dir_1",
3465 " v gitignored_dir",
3466 " file_a.py <== selected",
3467 " file_b.py",
3468 " file_c.py",
3469 " file_1.py",
3470 " file_2.py",
3471 " file_3.py",
3472 " v dir_2",
3473 " file_1.py",
3474 " file_2.py",
3475 " file_3.py",
3476 " .gitignore",
3477 ],
3478 "With no auto reveal, explicit reveal should show the gitignored entry in the project panel"
3479 );
3480 }
3481
3482 fn toggle_expand_dir(
3483 panel: &View<ProjectPanel>,
3484 path: impl AsRef<Path>,
3485 cx: &mut VisualTestContext,
3486 ) {
3487 let path = path.as_ref();
3488 panel.update(cx, |panel, cx| {
3489 for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
3490 let worktree = worktree.read(cx);
3491 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
3492 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
3493 panel.toggle_expanded(entry_id, cx);
3494 return;
3495 }
3496 }
3497 panic!("no worktree for path {:?}", path);
3498 });
3499 }
3500
3501 fn select_path(panel: &View<ProjectPanel>, path: impl AsRef<Path>, cx: &mut VisualTestContext) {
3502 let path = path.as_ref();
3503 panel.update(cx, |panel, cx| {
3504 for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
3505 let worktree = worktree.read(cx);
3506 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
3507 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
3508 panel.selection = Some(crate::Selection {
3509 worktree_id: worktree.id(),
3510 entry_id,
3511 });
3512 return;
3513 }
3514 }
3515 panic!("no worktree for path {:?}", path);
3516 });
3517 }
3518
3519 fn find_project_entry(
3520 panel: &View<ProjectPanel>,
3521 path: impl AsRef<Path>,
3522 cx: &mut VisualTestContext,
3523 ) -> Option<ProjectEntryId> {
3524 let path = path.as_ref();
3525 panel.update(cx, |panel, cx| {
3526 for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
3527 let worktree = worktree.read(cx);
3528 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
3529 return worktree.entry_for_path(relative_path).map(|entry| entry.id);
3530 }
3531 }
3532 panic!("no worktree for path {path:?}");
3533 })
3534 }
3535
3536 fn visible_entries_as_strings(
3537 panel: &View<ProjectPanel>,
3538 range: Range<usize>,
3539 cx: &mut VisualTestContext,
3540 ) -> Vec<String> {
3541 let mut result = Vec::new();
3542 let mut project_entries = HashSet::default();
3543 let mut has_editor = false;
3544
3545 panel.update(cx, |panel, cx| {
3546 panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
3547 if details.is_editing {
3548 assert!(!has_editor, "duplicate editor entry");
3549 has_editor = true;
3550 } else {
3551 assert!(
3552 project_entries.insert(project_entry),
3553 "duplicate project entry {:?} {:?}",
3554 project_entry,
3555 details
3556 );
3557 }
3558
3559 let indent = " ".repeat(details.depth);
3560 let icon = if details.kind.is_dir() {
3561 if details.is_expanded {
3562 "v "
3563 } else {
3564 "> "
3565 }
3566 } else {
3567 " "
3568 };
3569 let name = if details.is_editing {
3570 format!("[EDITOR: '{}']", details.filename)
3571 } else if details.is_processing {
3572 format!("[PROCESSING: '{}']", details.filename)
3573 } else {
3574 details.filename.clone()
3575 };
3576 let selected = if details.is_selected {
3577 " <== selected"
3578 } else {
3579 ""
3580 };
3581 result.push(format!("{indent}{icon}{name}{selected}"));
3582 });
3583 });
3584
3585 result
3586 }
3587
3588 fn init_test(cx: &mut TestAppContext) {
3589 cx.update(|cx| {
3590 let settings_store = SettingsStore::test(cx);
3591 cx.set_global(settings_store);
3592 init_settings(cx);
3593 theme::init(theme::LoadThemes::JustBase, cx);
3594 language::init(cx);
3595 editor::init_settings(cx);
3596 crate::init((), cx);
3597 workspace::init_settings(cx);
3598 client::init_settings(cx);
3599 Project::init_settings(cx);
3600
3601 cx.update_global::<SettingsStore, _>(|store, cx| {
3602 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
3603 worktree_settings.file_scan_exclusions = Some(Vec::new());
3604 });
3605 });
3606 });
3607 }
3608
3609 fn init_test_with_editor(cx: &mut TestAppContext) {
3610 cx.update(|cx| {
3611 let app_state = AppState::test(cx);
3612 theme::init(theme::LoadThemes::JustBase, cx);
3613 init_settings(cx);
3614 language::init(cx);
3615 editor::init(cx);
3616 crate::init((), cx);
3617 workspace::init(app_state.clone(), cx);
3618 Project::init_settings(cx);
3619 });
3620 }
3621
3622 fn ensure_single_file_is_opened(
3623 window: &WindowHandle<Workspace>,
3624 expected_path: &str,
3625 cx: &mut TestAppContext,
3626 ) {
3627 window
3628 .update(cx, |workspace, cx| {
3629 let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
3630 assert_eq!(worktrees.len(), 1);
3631 let worktree_id = worktrees[0].read(cx).id();
3632
3633 let open_project_paths = workspace
3634 .panes()
3635 .iter()
3636 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
3637 .collect::<Vec<_>>();
3638 assert_eq!(
3639 open_project_paths,
3640 vec![ProjectPath {
3641 worktree_id,
3642 path: Arc::from(Path::new(expected_path))
3643 }],
3644 "Should have opened file, selected in project panel"
3645 );
3646 })
3647 .unwrap();
3648 }
3649
3650 fn submit_deletion(panel: &View<ProjectPanel>, cx: &mut VisualTestContext) {
3651 assert!(
3652 !cx.has_pending_prompt(),
3653 "Should have no prompts before the deletion"
3654 );
3655 panel.update(cx, |panel, cx| panel.delete(&Delete, cx));
3656 assert!(
3657 cx.has_pending_prompt(),
3658 "Should have a prompt after the deletion"
3659 );
3660 cx.simulate_prompt_answer(0);
3661 assert!(
3662 !cx.has_pending_prompt(),
3663 "Should have no prompts after prompt was replied to"
3664 );
3665 cx.executor().run_until_parked();
3666 }
3667
3668 fn ensure_no_open_items_and_panes(
3669 workspace: &WindowHandle<Workspace>,
3670 cx: &mut VisualTestContext,
3671 ) {
3672 assert!(
3673 !cx.has_pending_prompt(),
3674 "Should have no prompts after deletion operation closes the file"
3675 );
3676 workspace
3677 .read_with(cx, |workspace, cx| {
3678 let open_project_paths = workspace
3679 .panes()
3680 .iter()
3681 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
3682 .collect::<Vec<_>>();
3683 assert!(
3684 open_project_paths.is_empty(),
3685 "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
3686 );
3687 })
3688 .unwrap();
3689 }
3690}