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