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