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