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