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 entry.is_file() {
912 new_path.pop();
913 }
914
915 new_path.push(&clipboard_entry_file_name);
916 let extension = new_path.extension().map(|e| e.to_os_string());
917 let file_name_without_extension = Path::new(&clipboard_entry_file_name).file_stem()?;
918 let mut ix = 0;
919 while worktree.entry_for_path(&new_path).is_some() {
920 new_path.pop();
921
922 let mut new_file_name = file_name_without_extension.to_os_string();
923 new_file_name.push(" copy");
924 if ix > 0 {
925 new_file_name.push(format!(" {}", ix));
926 }
927 if let Some(extension) = extension.as_ref() {
928 new_file_name.push(".");
929 new_file_name.push(extension);
930 }
931
932 new_path.push(new_file_name);
933 ix += 1;
934 }
935
936 if clipboard_entry.is_cut() {
937 self.project
938 .update(cx, |project, cx| {
939 project.rename_entry(clipboard_entry.entry_id(), new_path, cx)
940 })
941 .detach_and_log_err(cx)
942 } else {
943 self.project
944 .update(cx, |project, cx| {
945 project.copy_entry(clipboard_entry.entry_id(), new_path, cx)
946 })
947 .detach_and_log_err(cx)
948 }
949
950 Some(())
951 });
952 }
953
954 fn copy_path(&mut self, _: &CopyPath, cx: &mut ViewContext<Self>) {
955 if let Some((worktree, entry)) = self.selected_entry(cx) {
956 cx.write_to_clipboard(ClipboardItem::new(
957 worktree
958 .abs_path()
959 .join(&entry.path)
960 .to_string_lossy()
961 .to_string(),
962 ));
963 }
964 }
965
966 fn copy_relative_path(&mut self, _: &CopyRelativePath, cx: &mut ViewContext<Self>) {
967 if let Some((_, entry)) = self.selected_entry(cx) {
968 cx.write_to_clipboard(ClipboardItem::new(entry.path.to_string_lossy().to_string()));
969 }
970 }
971
972 fn reveal_in_finder(&mut self, _: &RevealInFinder, cx: &mut ViewContext<Self>) {
973 if let Some((worktree, entry)) = self.selected_entry(cx) {
974 cx.reveal_path(&worktree.abs_path().join(&entry.path));
975 }
976 }
977
978 fn open_in_terminal(&mut self, _: &OpenInTerminal, cx: &mut ViewContext<Self>) {
979 if let Some((worktree, entry)) = self.selected_entry(cx) {
980 let path = worktree.abs_path().join(&entry.path);
981 cx.dispatch_action(
982 workspace::OpenTerminal {
983 working_directory: path,
984 }
985 .boxed_clone(),
986 )
987 }
988 }
989
990 pub fn new_search_in_directory(
991 &mut self,
992 _: &NewSearchInDirectory,
993 cx: &mut ViewContext<Self>,
994 ) {
995 if let Some((_, entry)) = self.selected_entry(cx) {
996 if entry.is_dir() {
997 let entry = entry.clone();
998 self.workspace
999 .update(cx, |workspace, cx| {
1000 search::ProjectSearchView::new_search_in_directory(workspace, &entry, cx);
1001 })
1002 .ok();
1003 }
1004 }
1005 }
1006
1007 fn move_entry(
1008 &mut self,
1009 entry_to_move: ProjectEntryId,
1010 destination: ProjectEntryId,
1011 destination_is_file: bool,
1012 cx: &mut ViewContext<Self>,
1013 ) {
1014 let destination_worktree = self.project.update(cx, |project, cx| {
1015 let entry_path = project.path_for_entry(entry_to_move, cx)?;
1016 let destination_entry_path = project.path_for_entry(destination, cx)?.path.clone();
1017
1018 let mut destination_path = destination_entry_path.as_ref();
1019 if destination_is_file {
1020 destination_path = destination_path.parent()?;
1021 }
1022
1023 let mut new_path = destination_path.to_path_buf();
1024 new_path.push(entry_path.path.file_name()?);
1025 if new_path != entry_path.path.as_ref() {
1026 let task = project.rename_entry(entry_to_move, new_path, cx);
1027 cx.foreground_executor().spawn(task).detach_and_log_err(cx);
1028 }
1029
1030 Some(project.worktree_id_for_entry(destination, cx)?)
1031 });
1032
1033 if let Some(destination_worktree) = destination_worktree {
1034 self.expand_entry(destination_worktree, destination, cx);
1035 }
1036 }
1037
1038 fn index_for_selection(&self, selection: Selection) -> Option<(usize, usize, usize)> {
1039 let mut entry_index = 0;
1040 let mut visible_entries_index = 0;
1041 for (worktree_index, (worktree_id, worktree_entries)) in
1042 self.visible_entries.iter().enumerate()
1043 {
1044 if *worktree_id == selection.worktree_id {
1045 for entry in worktree_entries {
1046 if entry.id == selection.entry_id {
1047 return Some((worktree_index, entry_index, visible_entries_index));
1048 } else {
1049 visible_entries_index += 1;
1050 entry_index += 1;
1051 }
1052 }
1053 break;
1054 } else {
1055 visible_entries_index += worktree_entries.len();
1056 }
1057 }
1058 None
1059 }
1060
1061 pub fn selected_entry<'a>(
1062 &self,
1063 cx: &'a AppContext,
1064 ) -> Option<(&'a Worktree, &'a project::Entry)> {
1065 let (worktree, entry) = self.selected_entry_handle(cx)?;
1066 Some((worktree.read(cx), entry))
1067 }
1068
1069 fn selected_entry_handle<'a>(
1070 &self,
1071 cx: &'a AppContext,
1072 ) -> Option<(Model<Worktree>, &'a project::Entry)> {
1073 let selection = self.selection?;
1074 let project = self.project.read(cx);
1075 let worktree = project.worktree_for_id(selection.worktree_id, cx)?;
1076 let entry = worktree.read(cx).entry_for_id(selection.entry_id)?;
1077 Some((worktree, entry))
1078 }
1079
1080 fn expand_to_selection(&mut self, cx: &mut ViewContext<Self>) -> Option<()> {
1081 let (worktree, entry) = self.selected_entry(cx)?;
1082 let expanded_dir_ids = self.expanded_dir_ids.entry(worktree.id()).or_default();
1083
1084 for path in entry.path.ancestors() {
1085 let Some(entry) = worktree.entry_for_path(path) else {
1086 continue;
1087 };
1088 if entry.is_dir() {
1089 if let Err(idx) = expanded_dir_ids.binary_search(&entry.id) {
1090 expanded_dir_ids.insert(idx, entry.id);
1091 }
1092 }
1093 }
1094
1095 Some(())
1096 }
1097
1098 fn update_visible_entries(
1099 &mut self,
1100 new_selected_entry: Option<(WorktreeId, ProjectEntryId)>,
1101 cx: &mut ViewContext<Self>,
1102 ) {
1103 let project = self.project.read(cx);
1104 self.last_worktree_root_id = project
1105 .visible_worktrees(cx)
1106 .rev()
1107 .next()
1108 .and_then(|worktree| worktree.read(cx).root_entry())
1109 .map(|entry| entry.id);
1110
1111 self.visible_entries.clear();
1112 for worktree in project.visible_worktrees(cx) {
1113 let snapshot = worktree.read(cx).snapshot();
1114 let worktree_id = snapshot.id();
1115
1116 let expanded_dir_ids = match self.expanded_dir_ids.entry(worktree_id) {
1117 hash_map::Entry::Occupied(e) => e.into_mut(),
1118 hash_map::Entry::Vacant(e) => {
1119 // The first time a worktree's root entry becomes available,
1120 // mark that root entry as expanded.
1121 if let Some(entry) = snapshot.root_entry() {
1122 e.insert(vec![entry.id]).as_slice()
1123 } else {
1124 &[]
1125 }
1126 }
1127 };
1128
1129 let mut new_entry_parent_id = None;
1130 let mut new_entry_kind = EntryKind::Dir;
1131 if let Some(edit_state) = &self.edit_state {
1132 if edit_state.worktree_id == worktree_id && edit_state.is_new_entry {
1133 new_entry_parent_id = Some(edit_state.entry_id);
1134 new_entry_kind = if edit_state.is_dir {
1135 EntryKind::Dir
1136 } else {
1137 EntryKind::File(Default::default())
1138 };
1139 }
1140 }
1141
1142 let mut visible_worktree_entries = Vec::new();
1143 let mut entry_iter = snapshot.entries(true);
1144
1145 while let Some(entry) = entry_iter.entry() {
1146 visible_worktree_entries.push(entry.clone());
1147 if Some(entry.id) == new_entry_parent_id {
1148 visible_worktree_entries.push(Entry {
1149 id: NEW_ENTRY_ID,
1150 kind: new_entry_kind,
1151 path: entry.path.join("\0").into(),
1152 inode: 0,
1153 mtime: entry.mtime,
1154 is_symlink: false,
1155 is_ignored: false,
1156 is_external: false,
1157 is_private: false,
1158 git_status: entry.git_status,
1159 });
1160 }
1161 if expanded_dir_ids.binary_search(&entry.id).is_err()
1162 && entry_iter.advance_to_sibling()
1163 {
1164 continue;
1165 }
1166 entry_iter.advance();
1167 }
1168
1169 snapshot.propagate_git_statuses(&mut visible_worktree_entries);
1170
1171 visible_worktree_entries.sort_by(|entry_a, entry_b| {
1172 let mut components_a = entry_a.path.components().peekable();
1173 let mut components_b = entry_b.path.components().peekable();
1174 loop {
1175 match (components_a.next(), components_b.next()) {
1176 (Some(component_a), Some(component_b)) => {
1177 let a_is_file = components_a.peek().is_none() && entry_a.is_file();
1178 let b_is_file = components_b.peek().is_none() && entry_b.is_file();
1179 let ordering = a_is_file.cmp(&b_is_file).then_with(|| {
1180 let name_a =
1181 UniCase::new(component_a.as_os_str().to_string_lossy());
1182 let name_b =
1183 UniCase::new(component_b.as_os_str().to_string_lossy());
1184 name_a.cmp(&name_b)
1185 });
1186 if !ordering.is_eq() {
1187 return ordering;
1188 }
1189 }
1190 (Some(_), None) => break Ordering::Greater,
1191 (None, Some(_)) => break Ordering::Less,
1192 (None, None) => break Ordering::Equal,
1193 }
1194 }
1195 });
1196 self.visible_entries
1197 .push((worktree_id, visible_worktree_entries));
1198 }
1199
1200 if let Some((worktree_id, entry_id)) = new_selected_entry {
1201 self.selection = Some(Selection {
1202 worktree_id,
1203 entry_id,
1204 });
1205 }
1206 }
1207
1208 fn expand_entry(
1209 &mut self,
1210 worktree_id: WorktreeId,
1211 entry_id: ProjectEntryId,
1212 cx: &mut ViewContext<Self>,
1213 ) {
1214 self.project.update(cx, |project, cx| {
1215 if let Some((worktree, expanded_dir_ids)) = project
1216 .worktree_for_id(worktree_id, cx)
1217 .zip(self.expanded_dir_ids.get_mut(&worktree_id))
1218 {
1219 project.expand_entry(worktree_id, entry_id, cx);
1220 let worktree = worktree.read(cx);
1221
1222 if let Some(mut entry) = worktree.entry_for_id(entry_id) {
1223 loop {
1224 if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
1225 expanded_dir_ids.insert(ix, entry.id);
1226 }
1227
1228 if let Some(parent_entry) =
1229 entry.path.parent().and_then(|p| worktree.entry_for_path(p))
1230 {
1231 entry = parent_entry;
1232 } else {
1233 break;
1234 }
1235 }
1236 }
1237 }
1238 });
1239 }
1240
1241 fn for_each_visible_entry(
1242 &self,
1243 range: Range<usize>,
1244 cx: &mut ViewContext<ProjectPanel>,
1245 mut callback: impl FnMut(ProjectEntryId, EntryDetails, &mut ViewContext<ProjectPanel>),
1246 ) {
1247 let mut ix = 0;
1248 for (worktree_id, visible_worktree_entries) in &self.visible_entries {
1249 if ix >= range.end {
1250 return;
1251 }
1252
1253 if ix + visible_worktree_entries.len() <= range.start {
1254 ix += visible_worktree_entries.len();
1255 continue;
1256 }
1257
1258 let end_ix = range.end.min(ix + visible_worktree_entries.len());
1259 let (git_status_setting, show_file_icons, show_folder_icons) = {
1260 let settings = ProjectPanelSettings::get_global(cx);
1261 (
1262 settings.git_status,
1263 settings.file_icons,
1264 settings.folder_icons,
1265 )
1266 };
1267 if let Some(worktree) = self.project.read(cx).worktree_for_id(*worktree_id, cx) {
1268 let snapshot = worktree.read(cx).snapshot();
1269 let root_name = OsStr::new(snapshot.root_name());
1270 let expanded_entry_ids = self
1271 .expanded_dir_ids
1272 .get(&snapshot.id())
1273 .map(Vec::as_slice)
1274 .unwrap_or(&[]);
1275
1276 let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
1277 for entry in visible_worktree_entries[entry_range].iter() {
1278 let status = git_status_setting.then(|| entry.git_status).flatten();
1279 let is_expanded = expanded_entry_ids.binary_search(&entry.id).is_ok();
1280 let icon = match entry.kind {
1281 EntryKind::File(_) => {
1282 if show_file_icons {
1283 FileAssociations::get_icon(&entry.path, cx)
1284 } else {
1285 None
1286 }
1287 }
1288 _ => {
1289 if show_folder_icons {
1290 FileAssociations::get_folder_icon(is_expanded, cx)
1291 } else {
1292 FileAssociations::get_chevron_icon(is_expanded, cx)
1293 }
1294 }
1295 };
1296
1297 let mut details = EntryDetails {
1298 filename: entry
1299 .path
1300 .file_name()
1301 .unwrap_or(root_name)
1302 .to_string_lossy()
1303 .to_string(),
1304 icon,
1305 path: entry.path.clone(),
1306 depth: entry.path.components().count(),
1307 kind: entry.kind,
1308 is_ignored: entry.is_ignored,
1309 is_expanded,
1310 is_selected: self.selection.map_or(false, |e| {
1311 e.worktree_id == snapshot.id() && e.entry_id == entry.id
1312 }),
1313 is_editing: false,
1314 is_processing: false,
1315 is_cut: self
1316 .clipboard_entry
1317 .map_or(false, |e| e.is_cut() && e.entry_id() == entry.id),
1318 git_status: status,
1319 is_dotenv: entry.is_private,
1320 };
1321
1322 if let Some(edit_state) = &self.edit_state {
1323 let is_edited_entry = if edit_state.is_new_entry {
1324 entry.id == NEW_ENTRY_ID
1325 } else {
1326 entry.id == edit_state.entry_id
1327 };
1328
1329 if is_edited_entry {
1330 if let Some(processing_filename) = &edit_state.processing_filename {
1331 details.is_processing = true;
1332 details.filename.clear();
1333 details.filename.push_str(processing_filename);
1334 } else {
1335 if edit_state.is_new_entry {
1336 details.filename.clear();
1337 }
1338 details.is_editing = true;
1339 }
1340 }
1341 }
1342
1343 callback(entry.id, details, cx);
1344 }
1345 }
1346 ix = end_ix;
1347 }
1348 }
1349
1350 fn render_entry(
1351 &self,
1352 entry_id: ProjectEntryId,
1353 details: EntryDetails,
1354 cx: &mut ViewContext<Self>,
1355 ) -> Stateful<Div> {
1356 let kind = details.kind;
1357 let settings = ProjectPanelSettings::get_global(cx);
1358 let show_editor = details.is_editing && !details.is_processing;
1359 let is_selected = self
1360 .selection
1361 .map_or(false, |selection| selection.entry_id == entry_id);
1362 let width = self.width.unwrap_or(px(0.));
1363
1364 let filename_text_color = details
1365 .git_status
1366 .as_ref()
1367 .map(|status| match status {
1368 GitFileStatus::Added => Color::Created,
1369 GitFileStatus::Modified => Color::Modified,
1370 GitFileStatus::Conflict => Color::Conflict,
1371 })
1372 .unwrap_or(if is_selected {
1373 Color::Default
1374 } else if details.is_ignored {
1375 Color::Disabled
1376 } else {
1377 Color::Muted
1378 });
1379
1380 let file_name = details.filename.clone();
1381 let icon = details.icon.clone();
1382 let depth = details.depth;
1383 div()
1384 .id(entry_id.to_proto() as usize)
1385 .on_drag(entry_id, move |entry_id, cx| {
1386 cx.new_view(|_| DraggedProjectEntryView {
1387 details: details.clone(),
1388 width,
1389 entry_id: *entry_id,
1390 })
1391 })
1392 .drag_over::<ProjectEntryId>(|style, _, cx| {
1393 style.bg(cx.theme().colors().drop_target_background)
1394 })
1395 .on_drop(cx.listener(move |this, dragged_id: &ProjectEntryId, cx| {
1396 this.move_entry(*dragged_id, entry_id, kind.is_file(), cx);
1397 }))
1398 .child(
1399 ListItem::new(entry_id.to_proto() as usize)
1400 .indent_level(depth)
1401 .indent_step_size(px(settings.indent_size))
1402 .selected(is_selected)
1403 .child(if let Some(icon) = &icon {
1404 div().child(Icon::from_path(icon.to_string()).color(filename_text_color))
1405 } else {
1406 div().size(IconSize::default().rems()).invisible()
1407 })
1408 .child(
1409 if let (Some(editor), true) = (Some(&self.filename_editor), show_editor) {
1410 div().h_full().w_full().child(editor.clone())
1411 } else {
1412 div().child(Label::new(file_name).color(filename_text_color))
1413 }
1414 .ml_1(),
1415 )
1416 .on_click(cx.listener(move |this, event: &gpui::ClickEvent, cx| {
1417 if event.down.button == MouseButton::Right {
1418 return;
1419 }
1420 if !show_editor {
1421 if kind.is_dir() {
1422 this.toggle_expanded(entry_id, cx);
1423 } else {
1424 if event.down.modifiers.command {
1425 this.split_entry(entry_id, cx);
1426 } else {
1427 this.open_entry(entry_id, event.up.click_count > 1, cx);
1428 }
1429 }
1430 }
1431 }))
1432 .on_secondary_mouse_down(cx.listener(
1433 move |this, event: &MouseDownEvent, cx| {
1434 // Stop propagation to prevent the catch-all context menu for the project
1435 // panel from being deployed.
1436 cx.stop_propagation();
1437 this.deploy_context_menu(event.position, entry_id, cx);
1438 },
1439 )),
1440 )
1441 }
1442
1443 fn dispatch_context(&self, cx: &ViewContext<Self>) -> KeyContext {
1444 let mut dispatch_context = KeyContext::default();
1445 dispatch_context.add("ProjectPanel");
1446 dispatch_context.add("menu");
1447
1448 let identifier = if self.filename_editor.focus_handle(cx).is_focused(cx) {
1449 "editing"
1450 } else {
1451 "not_editing"
1452 };
1453
1454 dispatch_context.add(identifier);
1455 dispatch_context
1456 }
1457
1458 fn reveal_entry(
1459 &mut self,
1460 project: Model<Project>,
1461 entry_id: ProjectEntryId,
1462 skip_ignored: bool,
1463 cx: &mut ViewContext<'_, ProjectPanel>,
1464 ) {
1465 if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
1466 let worktree = worktree.read(cx);
1467 if skip_ignored
1468 && worktree
1469 .entry_for_id(entry_id)
1470 .map_or(true, |entry| entry.is_ignored)
1471 {
1472 return;
1473 }
1474
1475 let worktree_id = worktree.id();
1476 self.expand_entry(worktree_id, entry_id, cx);
1477 self.update_visible_entries(Some((worktree_id, entry_id)), cx);
1478 self.autoscroll(cx);
1479 cx.notify();
1480 }
1481 }
1482}
1483
1484impl Render for ProjectPanel {
1485 fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> impl IntoElement {
1486 let has_worktree = self.visible_entries.len() != 0;
1487 let project = self.project.read(cx);
1488
1489 if has_worktree {
1490 div()
1491 .id("project-panel")
1492 .size_full()
1493 .relative()
1494 .key_context(self.dispatch_context(cx))
1495 .on_action(cx.listener(Self::select_next))
1496 .on_action(cx.listener(Self::select_prev))
1497 .on_action(cx.listener(Self::expand_selected_entry))
1498 .on_action(cx.listener(Self::collapse_selected_entry))
1499 .on_action(cx.listener(Self::collapse_all_entries))
1500 .on_action(cx.listener(Self::open))
1501 .on_action(cx.listener(Self::confirm))
1502 .on_action(cx.listener(Self::cancel))
1503 .on_action(cx.listener(Self::copy_path))
1504 .on_action(cx.listener(Self::copy_relative_path))
1505 .on_action(cx.listener(Self::new_search_in_directory))
1506 .when(!project.is_read_only(), |el| {
1507 el.on_action(cx.listener(Self::new_file))
1508 .on_action(cx.listener(Self::new_directory))
1509 .on_action(cx.listener(Self::rename))
1510 .on_action(cx.listener(Self::delete))
1511 .on_action(cx.listener(Self::cut))
1512 .on_action(cx.listener(Self::copy))
1513 .on_action(cx.listener(Self::paste))
1514 })
1515 .when(project.is_local(), |el| {
1516 el.on_action(cx.listener(Self::reveal_in_finder))
1517 .on_action(cx.listener(Self::open_in_terminal))
1518 })
1519 .on_mouse_down(
1520 MouseButton::Right,
1521 cx.listener(move |this, event: &MouseDownEvent, cx| {
1522 // When deploying the context menu anywhere below the last project entry,
1523 // act as if the user clicked the root of the last worktree.
1524 if let Some(entry_id) = this.last_worktree_root_id {
1525 this.deploy_context_menu(event.position, entry_id, cx);
1526 }
1527 }),
1528 )
1529 .track_focus(&self.focus_handle)
1530 .child(
1531 uniform_list(
1532 cx.view().clone(),
1533 "entries",
1534 self.visible_entries
1535 .iter()
1536 .map(|(_, worktree_entries)| worktree_entries.len())
1537 .sum(),
1538 {
1539 |this, range, cx| {
1540 let mut items = Vec::new();
1541 this.for_each_visible_entry(range, cx, |id, details, cx| {
1542 items.push(this.render_entry(id, details, cx));
1543 });
1544 items
1545 }
1546 },
1547 )
1548 .size_full()
1549 .track_scroll(self.list.clone()),
1550 )
1551 .children(self.context_menu.as_ref().map(|(menu, position, _)| {
1552 overlay()
1553 .position(*position)
1554 .anchor(gpui::AnchorCorner::TopLeft)
1555 .child(menu.clone())
1556 }))
1557 } else {
1558 v_flex()
1559 .id("empty-project_panel")
1560 .size_full()
1561 .p_4()
1562 .track_focus(&self.focus_handle)
1563 .child(
1564 Button::new("open_project", "Open a project")
1565 .style(ButtonStyle::Filled)
1566 .full_width()
1567 .key_binding(KeyBinding::for_action(&workspace::Open, cx))
1568 .on_click(cx.listener(|this, _, cx| {
1569 this.workspace
1570 .update(cx, |workspace, cx| workspace.open(&workspace::Open, cx))
1571 .log_err();
1572 })),
1573 )
1574 }
1575 }
1576}
1577
1578impl Render for DraggedProjectEntryView {
1579 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl Element {
1580 let settings = ProjectPanelSettings::get_global(cx);
1581 let ui_font = ThemeSettings::get_global(cx).ui_font.family.clone();
1582 h_flex()
1583 .font(ui_font)
1584 .bg(cx.theme().colors().background)
1585 .w(self.width)
1586 .child(
1587 ListItem::new(self.entry_id.to_proto() as usize)
1588 .indent_level(self.details.depth)
1589 .indent_step_size(px(settings.indent_size))
1590 .child(if let Some(icon) = &self.details.icon {
1591 div().child(Icon::from_path(icon.to_string()))
1592 } else {
1593 div()
1594 })
1595 .child(Label::new(self.details.filename.clone())),
1596 )
1597 }
1598}
1599
1600impl EventEmitter<Event> for ProjectPanel {}
1601
1602impl EventEmitter<PanelEvent> for ProjectPanel {}
1603
1604impl Panel for ProjectPanel {
1605 fn position(&self, cx: &WindowContext) -> DockPosition {
1606 match ProjectPanelSettings::get_global(cx).dock {
1607 ProjectPanelDockPosition::Left => DockPosition::Left,
1608 ProjectPanelDockPosition::Right => DockPosition::Right,
1609 }
1610 }
1611
1612 fn position_is_valid(&self, position: DockPosition) -> bool {
1613 matches!(position, DockPosition::Left | DockPosition::Right)
1614 }
1615
1616 fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
1617 settings::update_settings_file::<ProjectPanelSettings>(
1618 self.fs.clone(),
1619 cx,
1620 move |settings| {
1621 let dock = match position {
1622 DockPosition::Left | DockPosition::Bottom => ProjectPanelDockPosition::Left,
1623 DockPosition::Right => ProjectPanelDockPosition::Right,
1624 };
1625 settings.dock = Some(dock);
1626 },
1627 );
1628 }
1629
1630 fn size(&self, cx: &WindowContext) -> Pixels {
1631 self.width
1632 .unwrap_or_else(|| ProjectPanelSettings::get_global(cx).default_width)
1633 }
1634
1635 fn set_size(&mut self, size: Option<Pixels>, cx: &mut ViewContext<Self>) {
1636 self.width = size;
1637 self.serialize(cx);
1638 cx.notify();
1639 }
1640
1641 fn icon(&self, _: &WindowContext) -> Option<ui::IconName> {
1642 Some(ui::IconName::FileTree)
1643 }
1644
1645 fn icon_tooltip(&self, _cx: &WindowContext) -> Option<&'static str> {
1646 Some("Project Panel")
1647 }
1648
1649 fn toggle_action(&self) -> Box<dyn Action> {
1650 Box::new(ToggleFocus)
1651 }
1652
1653 fn persistent_name() -> &'static str {
1654 "Project Panel"
1655 }
1656
1657 fn starts_open(&self, cx: &WindowContext) -> bool {
1658 self.project.read(cx).visible_worktrees(cx).any(|tree| {
1659 tree.read(cx)
1660 .root_entry()
1661 .map_or(false, |entry| entry.is_dir())
1662 })
1663 }
1664}
1665
1666impl FocusableView for ProjectPanel {
1667 fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
1668 self.focus_handle.clone()
1669 }
1670}
1671
1672impl ClipboardEntry {
1673 fn is_cut(&self) -> bool {
1674 matches!(self, Self::Cut { .. })
1675 }
1676
1677 fn entry_id(&self) -> ProjectEntryId {
1678 match self {
1679 ClipboardEntry::Copied { entry_id, .. } | ClipboardEntry::Cut { entry_id, .. } => {
1680 *entry_id
1681 }
1682 }
1683 }
1684
1685 fn worktree_id(&self) -> WorktreeId {
1686 match self {
1687 ClipboardEntry::Copied { worktree_id, .. }
1688 | ClipboardEntry::Cut { worktree_id, .. } => *worktree_id,
1689 }
1690 }
1691}
1692
1693#[cfg(test)]
1694mod tests {
1695 use super::*;
1696 use collections::HashSet;
1697 use gpui::{TestAppContext, View, VisualTestContext, WindowHandle};
1698 use pretty_assertions::assert_eq;
1699 use project::{project_settings::ProjectSettings, FakeFs};
1700 use serde_json::json;
1701 use settings::SettingsStore;
1702 use std::path::{Path, PathBuf};
1703 use workspace::AppState;
1704
1705 #[gpui::test]
1706 async fn test_visible_list(cx: &mut gpui::TestAppContext) {
1707 init_test(cx);
1708
1709 let fs = FakeFs::new(cx.executor().clone());
1710 fs.insert_tree(
1711 "/root1",
1712 json!({
1713 ".dockerignore": "",
1714 ".git": {
1715 "HEAD": "",
1716 },
1717 "a": {
1718 "0": { "q": "", "r": "", "s": "" },
1719 "1": { "t": "", "u": "" },
1720 "2": { "v": "", "w": "", "x": "", "y": "" },
1721 },
1722 "b": {
1723 "3": { "Q": "" },
1724 "4": { "R": "", "S": "", "T": "", "U": "" },
1725 },
1726 "C": {
1727 "5": {},
1728 "6": { "V": "", "W": "" },
1729 "7": { "X": "" },
1730 "8": { "Y": {}, "Z": "" }
1731 }
1732 }),
1733 )
1734 .await;
1735 fs.insert_tree(
1736 "/root2",
1737 json!({
1738 "d": {
1739 "9": ""
1740 },
1741 "e": {}
1742 }),
1743 )
1744 .await;
1745
1746 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1747 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1748 let cx = &mut VisualTestContext::from_window(*workspace, cx);
1749 let panel = workspace
1750 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
1751 .unwrap();
1752 assert_eq!(
1753 visible_entries_as_strings(&panel, 0..50, cx),
1754 &[
1755 "v root1",
1756 " > .git",
1757 " > a",
1758 " > b",
1759 " > C",
1760 " .dockerignore",
1761 "v root2",
1762 " > d",
1763 " > e",
1764 ]
1765 );
1766
1767 toggle_expand_dir(&panel, "root1/b", cx);
1768 assert_eq!(
1769 visible_entries_as_strings(&panel, 0..50, cx),
1770 &[
1771 "v root1",
1772 " > .git",
1773 " > a",
1774 " v b <== selected",
1775 " > 3",
1776 " > 4",
1777 " > C",
1778 " .dockerignore",
1779 "v root2",
1780 " > d",
1781 " > e",
1782 ]
1783 );
1784
1785 assert_eq!(
1786 visible_entries_as_strings(&panel, 6..9, cx),
1787 &[
1788 //
1789 " > C",
1790 " .dockerignore",
1791 "v root2",
1792 ]
1793 );
1794 }
1795
1796 #[gpui::test]
1797 async fn test_exclusions_in_visible_list(cx: &mut gpui::TestAppContext) {
1798 init_test(cx);
1799 cx.update(|cx| {
1800 cx.update_global::<SettingsStore, _>(|store, cx| {
1801 store.update_user_settings::<ProjectSettings>(cx, |project_settings| {
1802 project_settings.file_scan_exclusions =
1803 Some(vec!["**/.git".to_string(), "**/4/**".to_string()]);
1804 });
1805 });
1806 });
1807
1808 let fs = FakeFs::new(cx.background_executor.clone());
1809 fs.insert_tree(
1810 "/root1",
1811 json!({
1812 ".dockerignore": "",
1813 ".git": {
1814 "HEAD": "",
1815 },
1816 "a": {
1817 "0": { "q": "", "r": "", "s": "" },
1818 "1": { "t": "", "u": "" },
1819 "2": { "v": "", "w": "", "x": "", "y": "" },
1820 },
1821 "b": {
1822 "3": { "Q": "" },
1823 "4": { "R": "", "S": "", "T": "", "U": "" },
1824 },
1825 "C": {
1826 "5": {},
1827 "6": { "V": "", "W": "" },
1828 "7": { "X": "" },
1829 "8": { "Y": {}, "Z": "" }
1830 }
1831 }),
1832 )
1833 .await;
1834 fs.insert_tree(
1835 "/root2",
1836 json!({
1837 "d": {
1838 "4": ""
1839 },
1840 "e": {}
1841 }),
1842 )
1843 .await;
1844
1845 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1846 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1847 let cx = &mut VisualTestContext::from_window(*workspace, cx);
1848 let panel = workspace
1849 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
1850 .unwrap();
1851 assert_eq!(
1852 visible_entries_as_strings(&panel, 0..50, cx),
1853 &[
1854 "v root1",
1855 " > a",
1856 " > b",
1857 " > C",
1858 " .dockerignore",
1859 "v root2",
1860 " > d",
1861 " > e",
1862 ]
1863 );
1864
1865 toggle_expand_dir(&panel, "root1/b", cx);
1866 assert_eq!(
1867 visible_entries_as_strings(&panel, 0..50, cx),
1868 &[
1869 "v root1",
1870 " > a",
1871 " v b <== selected",
1872 " > 3",
1873 " > C",
1874 " .dockerignore",
1875 "v root2",
1876 " > d",
1877 " > e",
1878 ]
1879 );
1880
1881 toggle_expand_dir(&panel, "root2/d", cx);
1882 assert_eq!(
1883 visible_entries_as_strings(&panel, 0..50, cx),
1884 &[
1885 "v root1",
1886 " > a",
1887 " v b",
1888 " > 3",
1889 " > C",
1890 " .dockerignore",
1891 "v root2",
1892 " v d <== selected",
1893 " > e",
1894 ]
1895 );
1896
1897 toggle_expand_dir(&panel, "root2/e", cx);
1898 assert_eq!(
1899 visible_entries_as_strings(&panel, 0..50, cx),
1900 &[
1901 "v root1",
1902 " > a",
1903 " v b",
1904 " > 3",
1905 " > C",
1906 " .dockerignore",
1907 "v root2",
1908 " v d",
1909 " v e <== selected",
1910 ]
1911 );
1912 }
1913
1914 #[gpui::test(iterations = 30)]
1915 async fn test_editing_files(cx: &mut gpui::TestAppContext) {
1916 init_test(cx);
1917
1918 let fs = FakeFs::new(cx.executor().clone());
1919 fs.insert_tree(
1920 "/root1",
1921 json!({
1922 ".dockerignore": "",
1923 ".git": {
1924 "HEAD": "",
1925 },
1926 "a": {
1927 "0": { "q": "", "r": "", "s": "" },
1928 "1": { "t": "", "u": "" },
1929 "2": { "v": "", "w": "", "x": "", "y": "" },
1930 },
1931 "b": {
1932 "3": { "Q": "" },
1933 "4": { "R": "", "S": "", "T": "", "U": "" },
1934 },
1935 "C": {
1936 "5": {},
1937 "6": { "V": "", "W": "" },
1938 "7": { "X": "" },
1939 "8": { "Y": {}, "Z": "" }
1940 }
1941 }),
1942 )
1943 .await;
1944 fs.insert_tree(
1945 "/root2",
1946 json!({
1947 "d": {
1948 "9": ""
1949 },
1950 "e": {}
1951 }),
1952 )
1953 .await;
1954
1955 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1956 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1957 let cx = &mut VisualTestContext::from_window(*workspace, cx);
1958 let panel = workspace
1959 .update(cx, |workspace, cx| {
1960 let panel = ProjectPanel::new(workspace, cx);
1961 workspace.add_panel(panel.clone(), cx);
1962 panel
1963 })
1964 .unwrap();
1965
1966 select_path(&panel, "root1", cx);
1967 assert_eq!(
1968 visible_entries_as_strings(&panel, 0..10, cx),
1969 &[
1970 "v root1 <== selected",
1971 " > .git",
1972 " > a",
1973 " > b",
1974 " > C",
1975 " .dockerignore",
1976 "v root2",
1977 " > d",
1978 " > e",
1979 ]
1980 );
1981
1982 // Add a file with the root folder selected. The filename editor is placed
1983 // before the first file in the root folder.
1984 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
1985 panel.update(cx, |panel, cx| {
1986 assert!(panel.filename_editor.read(cx).is_focused(cx));
1987 });
1988 assert_eq!(
1989 visible_entries_as_strings(&panel, 0..10, cx),
1990 &[
1991 "v root1",
1992 " > .git",
1993 " > a",
1994 " > b",
1995 " > C",
1996 " [EDITOR: ''] <== selected",
1997 " .dockerignore",
1998 "v root2",
1999 " > d",
2000 " > e",
2001 ]
2002 );
2003
2004 let confirm = panel.update(cx, |panel, cx| {
2005 panel
2006 .filename_editor
2007 .update(cx, |editor, cx| editor.set_text("the-new-filename", cx));
2008 panel.confirm_edit(cx).unwrap()
2009 });
2010 assert_eq!(
2011 visible_entries_as_strings(&panel, 0..10, cx),
2012 &[
2013 "v root1",
2014 " > .git",
2015 " > a",
2016 " > b",
2017 " > C",
2018 " [PROCESSING: 'the-new-filename'] <== selected",
2019 " .dockerignore",
2020 "v root2",
2021 " > d",
2022 " > e",
2023 ]
2024 );
2025
2026 confirm.await.unwrap();
2027 assert_eq!(
2028 visible_entries_as_strings(&panel, 0..10, cx),
2029 &[
2030 "v root1",
2031 " > .git",
2032 " > a",
2033 " > b",
2034 " > C",
2035 " .dockerignore",
2036 " the-new-filename <== selected",
2037 "v root2",
2038 " > d",
2039 " > e",
2040 ]
2041 );
2042
2043 select_path(&panel, "root1/b", cx);
2044 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2045 assert_eq!(
2046 visible_entries_as_strings(&panel, 0..10, cx),
2047 &[
2048 "v root1",
2049 " > .git",
2050 " > a",
2051 " v b",
2052 " > 3",
2053 " > 4",
2054 " [EDITOR: ''] <== selected",
2055 " > C",
2056 " .dockerignore",
2057 " the-new-filename",
2058 ]
2059 );
2060
2061 panel
2062 .update(cx, |panel, cx| {
2063 panel
2064 .filename_editor
2065 .update(cx, |editor, cx| editor.set_text("another-filename.txt", cx));
2066 panel.confirm_edit(cx).unwrap()
2067 })
2068 .await
2069 .unwrap();
2070 assert_eq!(
2071 visible_entries_as_strings(&panel, 0..10, cx),
2072 &[
2073 "v root1",
2074 " > .git",
2075 " > a",
2076 " v b",
2077 " > 3",
2078 " > 4",
2079 " another-filename.txt <== selected",
2080 " > C",
2081 " .dockerignore",
2082 " the-new-filename",
2083 ]
2084 );
2085
2086 select_path(&panel, "root1/b/another-filename.txt", cx);
2087 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2088 assert_eq!(
2089 visible_entries_as_strings(&panel, 0..10, cx),
2090 &[
2091 "v root1",
2092 " > .git",
2093 " > a",
2094 " v b",
2095 " > 3",
2096 " > 4",
2097 " [EDITOR: 'another-filename.txt'] <== selected",
2098 " > C",
2099 " .dockerignore",
2100 " the-new-filename",
2101 ]
2102 );
2103
2104 let confirm = panel.update(cx, |panel, cx| {
2105 panel.filename_editor.update(cx, |editor, cx| {
2106 let file_name_selections = editor.selections.all::<usize>(cx);
2107 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
2108 let file_name_selection = &file_name_selections[0];
2109 assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
2110 assert_eq!(file_name_selection.end, "another-filename".len(), "Should not select file extension");
2111
2112 editor.set_text("a-different-filename.tar.gz", cx)
2113 });
2114 panel.confirm_edit(cx).unwrap()
2115 });
2116 assert_eq!(
2117 visible_entries_as_strings(&panel, 0..10, cx),
2118 &[
2119 "v root1",
2120 " > .git",
2121 " > a",
2122 " v b",
2123 " > 3",
2124 " > 4",
2125 " [PROCESSING: 'a-different-filename.tar.gz'] <== selected",
2126 " > C",
2127 " .dockerignore",
2128 " the-new-filename",
2129 ]
2130 );
2131
2132 confirm.await.unwrap();
2133 assert_eq!(
2134 visible_entries_as_strings(&panel, 0..10, cx),
2135 &[
2136 "v root1",
2137 " > .git",
2138 " > a",
2139 " v b",
2140 " > 3",
2141 " > 4",
2142 " a-different-filename.tar.gz <== selected",
2143 " > C",
2144 " .dockerignore",
2145 " the-new-filename",
2146 ]
2147 );
2148
2149 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2150 assert_eq!(
2151 visible_entries_as_strings(&panel, 0..10, cx),
2152 &[
2153 "v root1",
2154 " > .git",
2155 " > a",
2156 " v b",
2157 " > 3",
2158 " > 4",
2159 " [EDITOR: 'a-different-filename.tar.gz'] <== selected",
2160 " > C",
2161 " .dockerignore",
2162 " the-new-filename",
2163 ]
2164 );
2165
2166 panel.update(cx, |panel, cx| {
2167 panel.filename_editor.update(cx, |editor, cx| {
2168 let file_name_selections = editor.selections.all::<usize>(cx);
2169 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
2170 let file_name_selection = &file_name_selections[0];
2171 assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
2172 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..");
2173
2174 });
2175 panel.cancel(&Cancel, cx)
2176 });
2177
2178 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2179 assert_eq!(
2180 visible_entries_as_strings(&panel, 0..10, cx),
2181 &[
2182 "v root1",
2183 " > .git",
2184 " > a",
2185 " v b",
2186 " > [EDITOR: ''] <== selected",
2187 " > 3",
2188 " > 4",
2189 " a-different-filename.tar.gz",
2190 " > C",
2191 " .dockerignore",
2192 ]
2193 );
2194
2195 let confirm = panel.update(cx, |panel, cx| {
2196 panel
2197 .filename_editor
2198 .update(cx, |editor, cx| editor.set_text("new-dir", cx));
2199 panel.confirm_edit(cx).unwrap()
2200 });
2201 panel.update(cx, |panel, cx| panel.select_next(&Default::default(), cx));
2202 assert_eq!(
2203 visible_entries_as_strings(&panel, 0..10, cx),
2204 &[
2205 "v root1",
2206 " > .git",
2207 " > a",
2208 " v b",
2209 " > [PROCESSING: 'new-dir']",
2210 " > 3 <== selected",
2211 " > 4",
2212 " a-different-filename.tar.gz",
2213 " > C",
2214 " .dockerignore",
2215 ]
2216 );
2217
2218 confirm.await.unwrap();
2219 assert_eq!(
2220 visible_entries_as_strings(&panel, 0..10, cx),
2221 &[
2222 "v root1",
2223 " > .git",
2224 " > a",
2225 " v b",
2226 " > 3 <== selected",
2227 " > 4",
2228 " > new-dir",
2229 " a-different-filename.tar.gz",
2230 " > C",
2231 " .dockerignore",
2232 ]
2233 );
2234
2235 panel.update(cx, |panel, cx| panel.rename(&Default::default(), cx));
2236 assert_eq!(
2237 visible_entries_as_strings(&panel, 0..10, cx),
2238 &[
2239 "v root1",
2240 " > .git",
2241 " > a",
2242 " v b",
2243 " > [EDITOR: '3'] <== selected",
2244 " > 4",
2245 " > new-dir",
2246 " a-different-filename.tar.gz",
2247 " > C",
2248 " .dockerignore",
2249 ]
2250 );
2251
2252 // Dismiss the rename editor when it loses focus.
2253 workspace.update(cx, |_, cx| cx.blur()).unwrap();
2254 assert_eq!(
2255 visible_entries_as_strings(&panel, 0..10, cx),
2256 &[
2257 "v root1",
2258 " > .git",
2259 " > a",
2260 " v b",
2261 " > 3 <== selected",
2262 " > 4",
2263 " > new-dir",
2264 " a-different-filename.tar.gz",
2265 " > C",
2266 " .dockerignore",
2267 ]
2268 );
2269 }
2270
2271 #[gpui::test(iterations = 10)]
2272 async fn test_adding_directories_via_file(cx: &mut gpui::TestAppContext) {
2273 init_test(cx);
2274
2275 let fs = FakeFs::new(cx.executor().clone());
2276 fs.insert_tree(
2277 "/root1",
2278 json!({
2279 ".dockerignore": "",
2280 ".git": {
2281 "HEAD": "",
2282 },
2283 "a": {
2284 "0": { "q": "", "r": "", "s": "" },
2285 "1": { "t": "", "u": "" },
2286 "2": { "v": "", "w": "", "x": "", "y": "" },
2287 },
2288 "b": {
2289 "3": { "Q": "" },
2290 "4": { "R": "", "S": "", "T": "", "U": "" },
2291 },
2292 "C": {
2293 "5": {},
2294 "6": { "V": "", "W": "" },
2295 "7": { "X": "" },
2296 "8": { "Y": {}, "Z": "" }
2297 }
2298 }),
2299 )
2300 .await;
2301 fs.insert_tree(
2302 "/root2",
2303 json!({
2304 "d": {
2305 "9": ""
2306 },
2307 "e": {}
2308 }),
2309 )
2310 .await;
2311
2312 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
2313 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2314 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2315 let panel = workspace
2316 .update(cx, |workspace, cx| {
2317 let panel = ProjectPanel::new(workspace, cx);
2318 workspace.add_panel(panel.clone(), cx);
2319 panel
2320 })
2321 .unwrap();
2322
2323 select_path(&panel, "root1", cx);
2324 assert_eq!(
2325 visible_entries_as_strings(&panel, 0..10, cx),
2326 &[
2327 "v root1 <== selected",
2328 " > .git",
2329 " > a",
2330 " > b",
2331 " > C",
2332 " .dockerignore",
2333 "v root2",
2334 " > d",
2335 " > e",
2336 ]
2337 );
2338
2339 // Add a file with the root folder selected. The filename editor is placed
2340 // before the first file in the root folder.
2341 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2342 panel.update(cx, |panel, cx| {
2343 assert!(panel.filename_editor.read(cx).is_focused(cx));
2344 });
2345 assert_eq!(
2346 visible_entries_as_strings(&panel, 0..10, cx),
2347 &[
2348 "v root1",
2349 " > .git",
2350 " > a",
2351 " > b",
2352 " > C",
2353 " [EDITOR: ''] <== selected",
2354 " .dockerignore",
2355 "v root2",
2356 " > d",
2357 " > e",
2358 ]
2359 );
2360
2361 let confirm = panel.update(cx, |panel, cx| {
2362 panel.filename_editor.update(cx, |editor, cx| {
2363 editor.set_text("/bdir1/dir2/the-new-filename", cx)
2364 });
2365 panel.confirm_edit(cx).unwrap()
2366 });
2367
2368 assert_eq!(
2369 visible_entries_as_strings(&panel, 0..10, cx),
2370 &[
2371 "v root1",
2372 " > .git",
2373 " > a",
2374 " > b",
2375 " > C",
2376 " [PROCESSING: '/bdir1/dir2/the-new-filename'] <== selected",
2377 " .dockerignore",
2378 "v root2",
2379 " > d",
2380 " > e",
2381 ]
2382 );
2383
2384 confirm.await.unwrap();
2385 assert_eq!(
2386 visible_entries_as_strings(&panel, 0..13, cx),
2387 &[
2388 "v root1",
2389 " > .git",
2390 " > a",
2391 " > b",
2392 " v bdir1",
2393 " v dir2",
2394 " the-new-filename <== selected",
2395 " > C",
2396 " .dockerignore",
2397 "v root2",
2398 " > d",
2399 " > e",
2400 ]
2401 );
2402 }
2403
2404 #[gpui::test]
2405 async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
2406 init_test(cx);
2407
2408 let fs = FakeFs::new(cx.executor().clone());
2409 fs.insert_tree(
2410 "/root1",
2411 json!({
2412 "one.two.txt": "",
2413 "one.txt": ""
2414 }),
2415 )
2416 .await;
2417
2418 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
2419 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2420 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2421 let panel = workspace
2422 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2423 .unwrap();
2424
2425 panel.update(cx, |panel, cx| {
2426 panel.select_next(&Default::default(), cx);
2427 panel.select_next(&Default::default(), cx);
2428 });
2429
2430 assert_eq!(
2431 visible_entries_as_strings(&panel, 0..50, cx),
2432 &[
2433 //
2434 "v root1",
2435 " one.two.txt <== selected",
2436 " one.txt",
2437 ]
2438 );
2439
2440 // Regression test - file name is created correctly when
2441 // the copied file's name contains multiple dots.
2442 panel.update(cx, |panel, cx| {
2443 panel.copy(&Default::default(), cx);
2444 panel.paste(&Default::default(), cx);
2445 });
2446 cx.executor().run_until_parked();
2447
2448 assert_eq!(
2449 visible_entries_as_strings(&panel, 0..50, cx),
2450 &[
2451 //
2452 "v root1",
2453 " one.two copy.txt",
2454 " one.two.txt <== selected",
2455 " one.txt",
2456 ]
2457 );
2458
2459 panel.update(cx, |panel, cx| {
2460 panel.paste(&Default::default(), cx);
2461 });
2462 cx.executor().run_until_parked();
2463
2464 assert_eq!(
2465 visible_entries_as_strings(&panel, 0..50, cx),
2466 &[
2467 //
2468 "v root1",
2469 " one.two copy 1.txt",
2470 " one.two copy.txt",
2471 " one.two.txt <== selected",
2472 " one.txt",
2473 ]
2474 );
2475 }
2476
2477 #[gpui::test]
2478 async fn test_copy_paste_directory(cx: &mut gpui::TestAppContext) {
2479 init_test(cx);
2480
2481 let fs = FakeFs::new(cx.executor().clone());
2482 fs.insert_tree(
2483 "/root",
2484 json!({
2485 "a": {
2486 "one.txt": "",
2487 "two.txt": "",
2488 "inner_dir": {
2489 "three.txt": "",
2490 "four.txt": "",
2491 }
2492 },
2493 "b": {}
2494 }),
2495 )
2496 .await;
2497
2498 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
2499 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2500 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2501 let panel = workspace
2502 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2503 .unwrap();
2504
2505 select_path(&panel, "root/a", cx);
2506 panel.update(cx, |panel, cx| {
2507 panel.copy(&Default::default(), cx);
2508 panel.select_next(&Default::default(), cx);
2509 panel.paste(&Default::default(), cx);
2510 });
2511 cx.executor().run_until_parked();
2512
2513 let pasted_dir = find_project_entry(&panel, "root/b/a", cx);
2514 assert_ne!(pasted_dir, None, "Pasted directory should have an entry");
2515
2516 let pasted_dir_file = find_project_entry(&panel, "root/b/a/one.txt", cx);
2517 assert_ne!(
2518 pasted_dir_file, None,
2519 "Pasted directory file should have an entry"
2520 );
2521
2522 let pasted_dir_inner_dir = find_project_entry(&panel, "root/b/a/inner_dir", cx);
2523 assert_ne!(
2524 pasted_dir_inner_dir, None,
2525 "Directories inside pasted directory should have an entry"
2526 );
2527
2528 toggle_expand_dir(&panel, "root/b", cx);
2529 toggle_expand_dir(&panel, "root/b/a", cx);
2530 toggle_expand_dir(&panel, "root/b/a/inner_dir", cx);
2531
2532 assert_eq!(
2533 visible_entries_as_strings(&panel, 0..50, cx),
2534 &[
2535 //
2536 "v root",
2537 " > a",
2538 " v b",
2539 " v a",
2540 " v inner_dir <== selected",
2541 " four.txt",
2542 " three.txt",
2543 " one.txt",
2544 " two.txt",
2545 ]
2546 );
2547
2548 select_path(&panel, "root", cx);
2549 panel.update(cx, |panel, cx| panel.paste(&Default::default(), cx));
2550 cx.executor().run_until_parked();
2551 panel.update(cx, |panel, cx| panel.paste(&Default::default(), cx));
2552 cx.executor().run_until_parked();
2553 assert_eq!(
2554 visible_entries_as_strings(&panel, 0..50, cx),
2555 &[
2556 //
2557 "v root <== selected",
2558 " > a",
2559 " > a copy",
2560 " > a copy 1",
2561 " v b",
2562 " v a",
2563 " v inner_dir",
2564 " four.txt",
2565 " three.txt",
2566 " one.txt",
2567 " two.txt"
2568 ]
2569 );
2570 }
2571
2572 #[gpui::test]
2573 async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
2574 init_test_with_editor(cx);
2575
2576 let fs = FakeFs::new(cx.executor().clone());
2577 fs.insert_tree(
2578 "/src",
2579 json!({
2580 "test": {
2581 "first.rs": "// First Rust file",
2582 "second.rs": "// Second Rust file",
2583 "third.rs": "// Third Rust file",
2584 }
2585 }),
2586 )
2587 .await;
2588
2589 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2590 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2591 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2592 let panel = workspace
2593 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2594 .unwrap();
2595
2596 toggle_expand_dir(&panel, "src/test", cx);
2597 select_path(&panel, "src/test/first.rs", cx);
2598 panel.update(cx, |panel, cx| panel.open(&Open, cx));
2599 cx.executor().run_until_parked();
2600 assert_eq!(
2601 visible_entries_as_strings(&panel, 0..10, cx),
2602 &[
2603 "v src",
2604 " v test",
2605 " first.rs <== selected",
2606 " second.rs",
2607 " third.rs"
2608 ]
2609 );
2610 ensure_single_file_is_opened(&workspace, "test/first.rs", cx);
2611
2612 submit_deletion(&panel, cx);
2613 assert_eq!(
2614 visible_entries_as_strings(&panel, 0..10, cx),
2615 &[
2616 "v src",
2617 " v test",
2618 " second.rs",
2619 " third.rs"
2620 ],
2621 "Project panel should have no deleted file, no other file is selected in it"
2622 );
2623 ensure_no_open_items_and_panes(&workspace, cx);
2624
2625 select_path(&panel, "src/test/second.rs", cx);
2626 panel.update(cx, |panel, cx| panel.open(&Open, cx));
2627 cx.executor().run_until_parked();
2628 assert_eq!(
2629 visible_entries_as_strings(&panel, 0..10, cx),
2630 &[
2631 "v src",
2632 " v test",
2633 " second.rs <== selected",
2634 " third.rs"
2635 ]
2636 );
2637 ensure_single_file_is_opened(&workspace, "test/second.rs", cx);
2638
2639 workspace
2640 .update(cx, |workspace, cx| {
2641 let active_items = workspace
2642 .panes()
2643 .iter()
2644 .filter_map(|pane| pane.read(cx).active_item())
2645 .collect::<Vec<_>>();
2646 assert_eq!(active_items.len(), 1);
2647 let open_editor = active_items
2648 .into_iter()
2649 .next()
2650 .unwrap()
2651 .downcast::<Editor>()
2652 .expect("Open item should be an editor");
2653 open_editor.update(cx, |editor, cx| editor.set_text("Another text!", cx));
2654 })
2655 .unwrap();
2656 submit_deletion(&panel, cx);
2657 assert_eq!(
2658 visible_entries_as_strings(&panel, 0..10, cx),
2659 &["v src", " v test", " third.rs"],
2660 "Project panel should have no deleted file, with one last file remaining"
2661 );
2662 ensure_no_open_items_and_panes(&workspace, cx);
2663 }
2664
2665 #[gpui::test]
2666 async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
2667 init_test_with_editor(cx);
2668
2669 let fs = FakeFs::new(cx.executor().clone());
2670 fs.insert_tree(
2671 "/src",
2672 json!({
2673 "test": {
2674 "first.rs": "// First Rust file",
2675 "second.rs": "// Second Rust file",
2676 "third.rs": "// Third Rust file",
2677 }
2678 }),
2679 )
2680 .await;
2681
2682 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2683 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2684 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2685 let panel = workspace
2686 .update(cx, |workspace, cx| {
2687 let panel = ProjectPanel::new(workspace, cx);
2688 workspace.add_panel(panel.clone(), cx);
2689 panel
2690 })
2691 .unwrap();
2692
2693 select_path(&panel, "src/", cx);
2694 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2695 cx.executor().run_until_parked();
2696 assert_eq!(
2697 visible_entries_as_strings(&panel, 0..10, cx),
2698 &[
2699 //
2700 "v src <== selected",
2701 " > test"
2702 ]
2703 );
2704 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2705 panel.update(cx, |panel, cx| {
2706 assert!(panel.filename_editor.read(cx).is_focused(cx));
2707 });
2708 assert_eq!(
2709 visible_entries_as_strings(&panel, 0..10, cx),
2710 &[
2711 //
2712 "v src",
2713 " > [EDITOR: ''] <== selected",
2714 " > test"
2715 ]
2716 );
2717 panel.update(cx, |panel, cx| {
2718 panel
2719 .filename_editor
2720 .update(cx, |editor, cx| editor.set_text("test", cx));
2721 assert!(
2722 panel.confirm_edit(cx).is_none(),
2723 "Should not allow to confirm on conflicting new directory name"
2724 )
2725 });
2726 assert_eq!(
2727 visible_entries_as_strings(&panel, 0..10, cx),
2728 &[
2729 //
2730 "v src",
2731 " > test"
2732 ],
2733 "File list should be unchanged after failed folder create confirmation"
2734 );
2735
2736 select_path(&panel, "src/test/", cx);
2737 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2738 cx.executor().run_until_parked();
2739 assert_eq!(
2740 visible_entries_as_strings(&panel, 0..10, cx),
2741 &[
2742 //
2743 "v src",
2744 " > test <== selected"
2745 ]
2746 );
2747 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2748 panel.update(cx, |panel, cx| {
2749 assert!(panel.filename_editor.read(cx).is_focused(cx));
2750 });
2751 assert_eq!(
2752 visible_entries_as_strings(&panel, 0..10, cx),
2753 &[
2754 "v src",
2755 " v test",
2756 " [EDITOR: ''] <== selected",
2757 " first.rs",
2758 " second.rs",
2759 " third.rs"
2760 ]
2761 );
2762 panel.update(cx, |panel, cx| {
2763 panel
2764 .filename_editor
2765 .update(cx, |editor, cx| editor.set_text("first.rs", cx));
2766 assert!(
2767 panel.confirm_edit(cx).is_none(),
2768 "Should not allow to confirm on conflicting new file name"
2769 )
2770 });
2771 assert_eq!(
2772 visible_entries_as_strings(&panel, 0..10, cx),
2773 &[
2774 "v src",
2775 " v test",
2776 " first.rs",
2777 " second.rs",
2778 " third.rs"
2779 ],
2780 "File list should be unchanged after failed file create confirmation"
2781 );
2782
2783 select_path(&panel, "src/test/first.rs", cx);
2784 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2785 cx.executor().run_until_parked();
2786 assert_eq!(
2787 visible_entries_as_strings(&panel, 0..10, cx),
2788 &[
2789 "v src",
2790 " v test",
2791 " first.rs <== selected",
2792 " second.rs",
2793 " third.rs"
2794 ],
2795 );
2796 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2797 panel.update(cx, |panel, cx| {
2798 assert!(panel.filename_editor.read(cx).is_focused(cx));
2799 });
2800 assert_eq!(
2801 visible_entries_as_strings(&panel, 0..10, cx),
2802 &[
2803 "v src",
2804 " v test",
2805 " [EDITOR: 'first.rs'] <== selected",
2806 " second.rs",
2807 " third.rs"
2808 ]
2809 );
2810 panel.update(cx, |panel, cx| {
2811 panel
2812 .filename_editor
2813 .update(cx, |editor, cx| editor.set_text("second.rs", cx));
2814 assert!(
2815 panel.confirm_edit(cx).is_none(),
2816 "Should not allow to confirm on conflicting file rename"
2817 )
2818 });
2819 assert_eq!(
2820 visible_entries_as_strings(&panel, 0..10, cx),
2821 &[
2822 "v src",
2823 " v test",
2824 " first.rs <== selected",
2825 " second.rs",
2826 " third.rs"
2827 ],
2828 "File list should be unchanged after failed rename confirmation"
2829 );
2830 }
2831
2832 #[gpui::test]
2833 async fn test_dir_toggle_collapse(cx: &mut gpui::TestAppContext) {
2834 init_test_with_editor(cx);
2835
2836 let fs = FakeFs::new(cx.executor().clone());
2837 fs.insert_tree(
2838 "/project_root",
2839 json!({
2840 "dir_1": {
2841 "nested_dir": {
2842 "file_a.py": "# File contents",
2843 }
2844 },
2845 "file_1.py": "# File contents",
2846 }),
2847 )
2848 .await;
2849
2850 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
2851 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2852 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2853 let panel = workspace
2854 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2855 .unwrap();
2856
2857 panel.update(cx, |panel, cx| panel.open(&Open, cx));
2858 cx.executor().run_until_parked();
2859 select_path(&panel, "project_root/dir_1", cx);
2860 panel.update(cx, |panel, cx| panel.open(&Open, cx));
2861 select_path(&panel, "project_root/dir_1/nested_dir", cx);
2862 panel.update(cx, |panel, cx| panel.open(&Open, cx));
2863 panel.update(cx, |panel, cx| panel.open(&Open, cx));
2864 cx.executor().run_until_parked();
2865 assert_eq!(
2866 visible_entries_as_strings(&panel, 0..10, cx),
2867 &[
2868 "v project_root",
2869 " v dir_1",
2870 " > nested_dir <== selected",
2871 " file_1.py",
2872 ]
2873 );
2874 }
2875
2876 #[gpui::test]
2877 async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) {
2878 init_test_with_editor(cx);
2879
2880 let fs = FakeFs::new(cx.executor().clone());
2881 fs.insert_tree(
2882 "/project_root",
2883 json!({
2884 "dir_1": {
2885 "nested_dir": {
2886 "file_a.py": "# File contents",
2887 "file_b.py": "# File contents",
2888 "file_c.py": "# File contents",
2889 },
2890 "file_1.py": "# File contents",
2891 "file_2.py": "# File contents",
2892 "file_3.py": "# File contents",
2893 },
2894 "dir_2": {
2895 "file_1.py": "# File contents",
2896 "file_2.py": "# File contents",
2897 "file_3.py": "# File contents",
2898 }
2899 }),
2900 )
2901 .await;
2902
2903 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
2904 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2905 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2906 let panel = workspace
2907 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2908 .unwrap();
2909
2910 panel.update(cx, |panel, cx| {
2911 panel.collapse_all_entries(&CollapseAllEntries, cx)
2912 });
2913 cx.executor().run_until_parked();
2914 assert_eq!(
2915 visible_entries_as_strings(&panel, 0..10, cx),
2916 &["v project_root", " > dir_1", " > dir_2",]
2917 );
2918
2919 // Open dir_1 and make sure nested_dir was collapsed when running collapse_all_entries
2920 toggle_expand_dir(&panel, "project_root/dir_1", cx);
2921 cx.executor().run_until_parked();
2922 assert_eq!(
2923 visible_entries_as_strings(&panel, 0..10, cx),
2924 &[
2925 "v project_root",
2926 " v dir_1 <== selected",
2927 " > nested_dir",
2928 " file_1.py",
2929 " file_2.py",
2930 " file_3.py",
2931 " > dir_2",
2932 ]
2933 );
2934 }
2935
2936 #[gpui::test]
2937 async fn test_new_file_move(cx: &mut gpui::TestAppContext) {
2938 init_test(cx);
2939
2940 let fs = FakeFs::new(cx.executor().clone());
2941 fs.as_fake().insert_tree("/root", json!({})).await;
2942 let project = Project::test(fs, ["/root".as_ref()], cx).await;
2943 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2944 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2945 let panel = workspace
2946 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2947 .unwrap();
2948
2949 // Make a new buffer with no backing file
2950 workspace
2951 .update(cx, |workspace, cx| {
2952 Editor::new_file(workspace, &Default::default(), cx)
2953 })
2954 .unwrap();
2955
2956 // "Save as"" the buffer, creating a new backing file for it
2957 let save_task = workspace
2958 .update(cx, |workspace, cx| {
2959 workspace.save_active_item(workspace::SaveIntent::Save, cx)
2960 })
2961 .unwrap();
2962
2963 cx.executor().run_until_parked();
2964 cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/new")));
2965 save_task.await.unwrap();
2966
2967 // Rename the file
2968 select_path(&panel, "root/new", cx);
2969 assert_eq!(
2970 visible_entries_as_strings(&panel, 0..10, cx),
2971 &["v root", " new <== selected"]
2972 );
2973 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2974 panel.update(cx, |panel, cx| {
2975 panel
2976 .filename_editor
2977 .update(cx, |editor, cx| editor.set_text("newer", cx));
2978 });
2979 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2980
2981 cx.executor().run_until_parked();
2982 assert_eq!(
2983 visible_entries_as_strings(&panel, 0..10, cx),
2984 &["v root", " newer <== selected"]
2985 );
2986
2987 workspace
2988 .update(cx, |workspace, cx| {
2989 workspace.save_active_item(workspace::SaveIntent::Save, cx)
2990 })
2991 .unwrap()
2992 .await
2993 .unwrap();
2994
2995 cx.executor().run_until_parked();
2996 // assert that saving the file doesn't restore "new"
2997 assert_eq!(
2998 visible_entries_as_strings(&panel, 0..10, cx),
2999 &["v root", " newer <== selected"]
3000 );
3001 }
3002
3003 #[gpui::test]
3004 async fn test_autoreveal_and_gitignored_files(cx: &mut gpui::TestAppContext) {
3005 init_test_with_editor(cx);
3006 cx.update(|cx| {
3007 cx.update_global::<SettingsStore, _>(|store, cx| {
3008 store.update_user_settings::<ProjectSettings>(cx, |project_settings| {
3009 project_settings.file_scan_exclusions = Some(Vec::new());
3010 });
3011 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
3012 project_panel_settings.auto_reveal_entries = Some(false)
3013 });
3014 })
3015 });
3016
3017 let fs = FakeFs::new(cx.background_executor.clone());
3018 fs.insert_tree(
3019 "/project_root",
3020 json!({
3021 ".git": {},
3022 ".gitignore": "**/gitignored_dir",
3023 "dir_1": {
3024 "file_1.py": "# File 1_1 contents",
3025 "file_2.py": "# File 1_2 contents",
3026 "file_3.py": "# File 1_3 contents",
3027 "gitignored_dir": {
3028 "file_a.py": "# File contents",
3029 "file_b.py": "# File contents",
3030 "file_c.py": "# File contents",
3031 },
3032 },
3033 "dir_2": {
3034 "file_1.py": "# File 2_1 contents",
3035 "file_2.py": "# File 2_2 contents",
3036 "file_3.py": "# File 2_3 contents",
3037 }
3038 }),
3039 )
3040 .await;
3041
3042 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3043 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3044 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3045 let panel = workspace
3046 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
3047 .unwrap();
3048
3049 assert_eq!(
3050 visible_entries_as_strings(&panel, 0..20, cx),
3051 &[
3052 "v project_root",
3053 " > .git",
3054 " > dir_1",
3055 " > dir_2",
3056 " .gitignore",
3057 ]
3058 );
3059
3060 let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
3061 .expect("dir 1 file is not ignored and should have an entry");
3062 let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
3063 .expect("dir 2 file is not ignored and should have an entry");
3064 let gitignored_dir_file =
3065 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
3066 assert_eq!(
3067 gitignored_dir_file, None,
3068 "File in the gitignored dir should not have an entry before its dir is toggled"
3069 );
3070
3071 toggle_expand_dir(&panel, "project_root/dir_1", cx);
3072 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3073 cx.executor().run_until_parked();
3074 assert_eq!(
3075 visible_entries_as_strings(&panel, 0..20, cx),
3076 &[
3077 "v project_root",
3078 " > .git",
3079 " v dir_1",
3080 " v gitignored_dir <== selected",
3081 " file_a.py",
3082 " file_b.py",
3083 " file_c.py",
3084 " file_1.py",
3085 " file_2.py",
3086 " file_3.py",
3087 " > dir_2",
3088 " .gitignore",
3089 ],
3090 "Should show gitignored dir file list in the project panel"
3091 );
3092 let gitignored_dir_file =
3093 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
3094 .expect("after gitignored dir got opened, a file entry should be present");
3095
3096 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3097 toggle_expand_dir(&panel, "project_root/dir_1", cx);
3098 assert_eq!(
3099 visible_entries_as_strings(&panel, 0..20, cx),
3100 &[
3101 "v project_root",
3102 " > .git",
3103 " > dir_1 <== selected",
3104 " > dir_2",
3105 " .gitignore",
3106 ],
3107 "Should hide all dir contents again and prepare for the auto reveal test"
3108 );
3109
3110 for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
3111 panel.update(cx, |panel, cx| {
3112 panel.project.update(cx, |_, cx| {
3113 cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
3114 })
3115 });
3116 cx.run_until_parked();
3117 assert_eq!(
3118 visible_entries_as_strings(&panel, 0..20, cx),
3119 &[
3120 "v project_root",
3121 " > .git",
3122 " > dir_1 <== selected",
3123 " > dir_2",
3124 " .gitignore",
3125 ],
3126 "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
3127 );
3128 }
3129
3130 cx.update(|cx| {
3131 cx.update_global::<SettingsStore, _>(|store, cx| {
3132 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
3133 project_panel_settings.auto_reveal_entries = Some(true)
3134 });
3135 })
3136 });
3137
3138 panel.update(cx, |panel, cx| {
3139 panel.project.update(cx, |_, cx| {
3140 cx.emit(project::Event::ActiveEntryChanged(Some(dir_1_file)))
3141 })
3142 });
3143 cx.run_until_parked();
3144 assert_eq!(
3145 visible_entries_as_strings(&panel, 0..20, cx),
3146 &[
3147 "v project_root",
3148 " > .git",
3149 " v dir_1",
3150 " > gitignored_dir",
3151 " file_1.py <== selected",
3152 " file_2.py",
3153 " file_3.py",
3154 " > dir_2",
3155 " .gitignore",
3156 ],
3157 "When auto reveal is enabled, not ignored dir_1 entry should be revealed"
3158 );
3159
3160 panel.update(cx, |panel, cx| {
3161 panel.project.update(cx, |_, cx| {
3162 cx.emit(project::Event::ActiveEntryChanged(Some(dir_2_file)))
3163 })
3164 });
3165 cx.run_until_parked();
3166 assert_eq!(
3167 visible_entries_as_strings(&panel, 0..20, cx),
3168 &[
3169 "v project_root",
3170 " > .git",
3171 " v dir_1",
3172 " > gitignored_dir",
3173 " file_1.py",
3174 " file_2.py",
3175 " file_3.py",
3176 " v dir_2",
3177 " file_1.py <== selected",
3178 " file_2.py",
3179 " file_3.py",
3180 " .gitignore",
3181 ],
3182 "When auto reveal is enabled, not ignored dir_2 entry should be revealed"
3183 );
3184
3185 panel.update(cx, |panel, cx| {
3186 panel.project.update(cx, |_, cx| {
3187 cx.emit(project::Event::ActiveEntryChanged(Some(
3188 gitignored_dir_file,
3189 )))
3190 })
3191 });
3192 cx.run_until_parked();
3193 assert_eq!(
3194 visible_entries_as_strings(&panel, 0..20, cx),
3195 &[
3196 "v project_root",
3197 " > .git",
3198 " v dir_1",
3199 " > gitignored_dir",
3200 " file_1.py",
3201 " file_2.py",
3202 " file_3.py",
3203 " v dir_2",
3204 " file_1.py <== selected",
3205 " file_2.py",
3206 " file_3.py",
3207 " .gitignore",
3208 ],
3209 "When auto reveal is enabled, a gitignored selected entry should not be revealed in the project panel"
3210 );
3211
3212 panel.update(cx, |panel, cx| {
3213 panel.project.update(cx, |_, cx| {
3214 cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
3215 })
3216 });
3217 cx.run_until_parked();
3218 assert_eq!(
3219 visible_entries_as_strings(&panel, 0..20, cx),
3220 &[
3221 "v project_root",
3222 " > .git",
3223 " v dir_1",
3224 " v gitignored_dir",
3225 " file_a.py <== selected",
3226 " file_b.py",
3227 " file_c.py",
3228 " file_1.py",
3229 " file_2.py",
3230 " file_3.py",
3231 " v dir_2",
3232 " file_1.py",
3233 " file_2.py",
3234 " file_3.py",
3235 " .gitignore",
3236 ],
3237 "When a gitignored entry is explicitly revealed, it should be shown in the project tree"
3238 );
3239 }
3240
3241 #[gpui::test]
3242 async fn test_explicit_reveal(cx: &mut gpui::TestAppContext) {
3243 init_test_with_editor(cx);
3244 cx.update(|cx| {
3245 cx.update_global::<SettingsStore, _>(|store, cx| {
3246 store.update_user_settings::<ProjectSettings>(cx, |project_settings| {
3247 project_settings.file_scan_exclusions = Some(Vec::new());
3248 });
3249 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
3250 project_panel_settings.auto_reveal_entries = Some(false)
3251 });
3252 })
3253 });
3254
3255 let fs = FakeFs::new(cx.background_executor.clone());
3256 fs.insert_tree(
3257 "/project_root",
3258 json!({
3259 ".git": {},
3260 ".gitignore": "**/gitignored_dir",
3261 "dir_1": {
3262 "file_1.py": "# File 1_1 contents",
3263 "file_2.py": "# File 1_2 contents",
3264 "file_3.py": "# File 1_3 contents",
3265 "gitignored_dir": {
3266 "file_a.py": "# File contents",
3267 "file_b.py": "# File contents",
3268 "file_c.py": "# File contents",
3269 },
3270 },
3271 "dir_2": {
3272 "file_1.py": "# File 2_1 contents",
3273 "file_2.py": "# File 2_2 contents",
3274 "file_3.py": "# File 2_3 contents",
3275 }
3276 }),
3277 )
3278 .await;
3279
3280 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3281 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3282 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3283 let panel = workspace
3284 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
3285 .unwrap();
3286
3287 assert_eq!(
3288 visible_entries_as_strings(&panel, 0..20, cx),
3289 &[
3290 "v project_root",
3291 " > .git",
3292 " > dir_1",
3293 " > dir_2",
3294 " .gitignore",
3295 ]
3296 );
3297
3298 let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
3299 .expect("dir 1 file is not ignored and should have an entry");
3300 let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
3301 .expect("dir 2 file is not ignored and should have an entry");
3302 let gitignored_dir_file =
3303 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
3304 assert_eq!(
3305 gitignored_dir_file, None,
3306 "File in the gitignored dir should not have an entry before its dir is toggled"
3307 );
3308
3309 toggle_expand_dir(&panel, "project_root/dir_1", cx);
3310 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3311 cx.run_until_parked();
3312 assert_eq!(
3313 visible_entries_as_strings(&panel, 0..20, cx),
3314 &[
3315 "v project_root",
3316 " > .git",
3317 " v dir_1",
3318 " v gitignored_dir <== selected",
3319 " file_a.py",
3320 " file_b.py",
3321 " file_c.py",
3322 " file_1.py",
3323 " file_2.py",
3324 " file_3.py",
3325 " > dir_2",
3326 " .gitignore",
3327 ],
3328 "Should show gitignored dir file list in the project panel"
3329 );
3330 let gitignored_dir_file =
3331 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
3332 .expect("after gitignored dir got opened, a file entry should be present");
3333
3334 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3335 toggle_expand_dir(&panel, "project_root/dir_1", cx);
3336 assert_eq!(
3337 visible_entries_as_strings(&panel, 0..20, cx),
3338 &[
3339 "v project_root",
3340 " > .git",
3341 " > dir_1 <== selected",
3342 " > dir_2",
3343 " .gitignore",
3344 ],
3345 "Should hide all dir contents again and prepare for the explicit reveal test"
3346 );
3347
3348 for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
3349 panel.update(cx, |panel, cx| {
3350 panel.project.update(cx, |_, cx| {
3351 cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
3352 })
3353 });
3354 cx.run_until_parked();
3355 assert_eq!(
3356 visible_entries_as_strings(&panel, 0..20, cx),
3357 &[
3358 "v project_root",
3359 " > .git",
3360 " > dir_1 <== selected",
3361 " > dir_2",
3362 " .gitignore",
3363 ],
3364 "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
3365 );
3366 }
3367
3368 panel.update(cx, |panel, cx| {
3369 panel.project.update(cx, |_, cx| {
3370 cx.emit(project::Event::RevealInProjectPanel(dir_1_file))
3371 })
3372 });
3373 cx.run_until_parked();
3374 assert_eq!(
3375 visible_entries_as_strings(&panel, 0..20, cx),
3376 &[
3377 "v project_root",
3378 " > .git",
3379 " v dir_1",
3380 " > gitignored_dir",
3381 " file_1.py <== selected",
3382 " file_2.py",
3383 " file_3.py",
3384 " > dir_2",
3385 " .gitignore",
3386 ],
3387 "With no auto reveal, explicit reveal should show the dir_1 entry in the project panel"
3388 );
3389
3390 panel.update(cx, |panel, cx| {
3391 panel.project.update(cx, |_, cx| {
3392 cx.emit(project::Event::RevealInProjectPanel(dir_2_file))
3393 })
3394 });
3395 cx.run_until_parked();
3396 assert_eq!(
3397 visible_entries_as_strings(&panel, 0..20, cx),
3398 &[
3399 "v project_root",
3400 " > .git",
3401 " v dir_1",
3402 " > gitignored_dir",
3403 " file_1.py",
3404 " file_2.py",
3405 " file_3.py",
3406 " v dir_2",
3407 " file_1.py <== selected",
3408 " file_2.py",
3409 " file_3.py",
3410 " .gitignore",
3411 ],
3412 "With no auto reveal, explicit reveal should show the dir_2 entry in the project panel"
3413 );
3414
3415 panel.update(cx, |panel, cx| {
3416 panel.project.update(cx, |_, cx| {
3417 cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
3418 })
3419 });
3420 cx.run_until_parked();
3421 assert_eq!(
3422 visible_entries_as_strings(&panel, 0..20, cx),
3423 &[
3424 "v project_root",
3425 " > .git",
3426 " v dir_1",
3427 " v gitignored_dir",
3428 " file_a.py <== selected",
3429 " file_b.py",
3430 " file_c.py",
3431 " file_1.py",
3432 " file_2.py",
3433 " file_3.py",
3434 " v dir_2",
3435 " file_1.py",
3436 " file_2.py",
3437 " file_3.py",
3438 " .gitignore",
3439 ],
3440 "With no auto reveal, explicit reveal should show the gitignored entry in the project panel"
3441 );
3442 }
3443
3444 fn toggle_expand_dir(
3445 panel: &View<ProjectPanel>,
3446 path: impl AsRef<Path>,
3447 cx: &mut VisualTestContext,
3448 ) {
3449 let path = path.as_ref();
3450 panel.update(cx, |panel, cx| {
3451 for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
3452 let worktree = worktree.read(cx);
3453 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
3454 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
3455 panel.toggle_expanded(entry_id, cx);
3456 return;
3457 }
3458 }
3459 panic!("no worktree for path {:?}", path);
3460 });
3461 }
3462
3463 fn select_path(panel: &View<ProjectPanel>, path: impl AsRef<Path>, cx: &mut VisualTestContext) {
3464 let path = path.as_ref();
3465 panel.update(cx, |panel, cx| {
3466 for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
3467 let worktree = worktree.read(cx);
3468 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
3469 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
3470 panel.selection = Some(crate::Selection {
3471 worktree_id: worktree.id(),
3472 entry_id,
3473 });
3474 return;
3475 }
3476 }
3477 panic!("no worktree for path {:?}", path);
3478 });
3479 }
3480
3481 fn find_project_entry(
3482 panel: &View<ProjectPanel>,
3483 path: impl AsRef<Path>,
3484 cx: &mut VisualTestContext,
3485 ) -> Option<ProjectEntryId> {
3486 let path = path.as_ref();
3487 panel.update(cx, |panel, cx| {
3488 for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
3489 let worktree = worktree.read(cx);
3490 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
3491 return worktree.entry_for_path(relative_path).map(|entry| entry.id);
3492 }
3493 }
3494 panic!("no worktree for path {path:?}");
3495 })
3496 }
3497
3498 fn visible_entries_as_strings(
3499 panel: &View<ProjectPanel>,
3500 range: Range<usize>,
3501 cx: &mut VisualTestContext,
3502 ) -> Vec<String> {
3503 let mut result = Vec::new();
3504 let mut project_entries = HashSet::default();
3505 let mut has_editor = false;
3506
3507 panel.update(cx, |panel, cx| {
3508 panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
3509 if details.is_editing {
3510 assert!(!has_editor, "duplicate editor entry");
3511 has_editor = true;
3512 } else {
3513 assert!(
3514 project_entries.insert(project_entry),
3515 "duplicate project entry {:?} {:?}",
3516 project_entry,
3517 details
3518 );
3519 }
3520
3521 let indent = " ".repeat(details.depth);
3522 let icon = if details.kind.is_dir() {
3523 if details.is_expanded {
3524 "v "
3525 } else {
3526 "> "
3527 }
3528 } else {
3529 " "
3530 };
3531 let name = if details.is_editing {
3532 format!("[EDITOR: '{}']", details.filename)
3533 } else if details.is_processing {
3534 format!("[PROCESSING: '{}']", details.filename)
3535 } else {
3536 details.filename.clone()
3537 };
3538 let selected = if details.is_selected {
3539 " <== selected"
3540 } else {
3541 ""
3542 };
3543 result.push(format!("{indent}{icon}{name}{selected}"));
3544 });
3545 });
3546
3547 result
3548 }
3549
3550 fn init_test(cx: &mut TestAppContext) {
3551 cx.update(|cx| {
3552 let settings_store = SettingsStore::test(cx);
3553 cx.set_global(settings_store);
3554 init_settings(cx);
3555 theme::init(theme::LoadThemes::JustBase, cx);
3556 language::init(cx);
3557 editor::init_settings(cx);
3558 crate::init((), cx);
3559 workspace::init_settings(cx);
3560 client::init_settings(cx);
3561 Project::init_settings(cx);
3562
3563 cx.update_global::<SettingsStore, _>(|store, cx| {
3564 store.update_user_settings::<ProjectSettings>(cx, |project_settings| {
3565 project_settings.file_scan_exclusions = Some(Vec::new());
3566 });
3567 });
3568 });
3569 }
3570
3571 fn init_test_with_editor(cx: &mut TestAppContext) {
3572 cx.update(|cx| {
3573 let app_state = AppState::test(cx);
3574 theme::init(theme::LoadThemes::JustBase, cx);
3575 init_settings(cx);
3576 language::init(cx);
3577 editor::init(cx);
3578 crate::init((), cx);
3579 workspace::init(app_state.clone(), cx);
3580 Project::init_settings(cx);
3581 });
3582 }
3583
3584 fn ensure_single_file_is_opened(
3585 window: &WindowHandle<Workspace>,
3586 expected_path: &str,
3587 cx: &mut TestAppContext,
3588 ) {
3589 window
3590 .update(cx, |workspace, cx| {
3591 let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
3592 assert_eq!(worktrees.len(), 1);
3593 let worktree_id = worktrees[0].read(cx).id();
3594
3595 let open_project_paths = workspace
3596 .panes()
3597 .iter()
3598 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
3599 .collect::<Vec<_>>();
3600 assert_eq!(
3601 open_project_paths,
3602 vec![ProjectPath {
3603 worktree_id,
3604 path: Arc::from(Path::new(expected_path))
3605 }],
3606 "Should have opened file, selected in project panel"
3607 );
3608 })
3609 .unwrap();
3610 }
3611
3612 fn submit_deletion(panel: &View<ProjectPanel>, cx: &mut VisualTestContext) {
3613 assert!(
3614 !cx.has_pending_prompt(),
3615 "Should have no prompts before the deletion"
3616 );
3617 panel.update(cx, |panel, cx| panel.delete(&Delete, cx));
3618 assert!(
3619 cx.has_pending_prompt(),
3620 "Should have a prompt after the deletion"
3621 );
3622 cx.simulate_prompt_answer(0);
3623 assert!(
3624 !cx.has_pending_prompt(),
3625 "Should have no prompts after prompt was replied to"
3626 );
3627 cx.executor().run_until_parked();
3628 }
3629
3630 fn ensure_no_open_items_and_panes(
3631 workspace: &WindowHandle<Workspace>,
3632 cx: &mut VisualTestContext,
3633 ) {
3634 assert!(
3635 !cx.has_pending_prompt(),
3636 "Should have no prompts after deletion operation closes the file"
3637 );
3638 workspace
3639 .read_with(cx, |workspace, cx| {
3640 let open_project_paths = workspace
3641 .panes()
3642 .iter()
3643 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
3644 .collect::<Vec<_>>();
3645 assert!(
3646 open_project_paths.is_empty(),
3647 "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
3648 );
3649 })
3650 .unwrap();
3651 }
3652}