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