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