1pub mod file_associations;
2mod project_panel_settings;
3use client::{ErrorCode, ErrorExt};
4use settings::Settings;
5
6use db::kvp::KEY_VALUE_STORE;
7use editor::{actions::Cancel, scroll::Autoscroll, Editor};
8use file_associations::FileAssociations;
9
10use anyhow::{anyhow, Result};
11use collections::{hash_map, HashMap};
12use gpui::{
13 actions, div, overlay, px, uniform_list, Action, AppContext, AssetSource, AsyncWindowContext,
14 ClipboardItem, DismissEvent, Div, EventEmitter, FocusHandle, FocusableView, InteractiveElement,
15 KeyContext, Model, MouseButton, MouseDownEvent, ParentElement, Pixels, Point, PromptLevel,
16 Render, Stateful, Styled, Subscription, Task, UniformListScrollHandle, View, ViewContext,
17 VisualContext as _, WeakView, WindowContext,
18};
19use menu::{Confirm, SelectNext, SelectPrev};
20use project::{
21 repository::GitFileStatus, Entry, EntryKind, Fs, Project, ProjectEntryId, ProjectPath,
22 Worktree, WorktreeId,
23};
24use project_panel_settings::{ProjectPanelDockPosition, ProjectPanelSettings};
25use serde::{Deserialize, Serialize};
26use std::{cmp::Ordering, ffi::OsStr, ops::Range, path::Path, sync::Arc};
27use theme::ThemeSettings;
28use ui::{prelude::*, v_flex, ContextMenu, Icon, KeyBinding, Label, ListItem};
29use unicase::UniCase;
30use util::{maybe, NumericPrefixWithSuffix, ResultExt, TryFutureExt};
31use workspace::{
32 dock::{DockPosition, Panel, PanelEvent},
33 notifications::DetachAndPromptErr,
34 Workspace,
35};
36
37const PROJECT_PANEL_KEY: &str = "ProjectPanel";
38const NEW_ENTRY_ID: ProjectEntryId = ProjectEntryId::MAX;
39
40pub struct ProjectPanel {
41 project: Model<Project>,
42 fs: Arc<dyn Fs>,
43 scroll_handle: UniformListScrollHandle,
44 focus_handle: FocusHandle,
45 visible_entries: Vec<(WorktreeId, Vec<Entry>)>,
46 last_worktree_root_id: Option<ProjectEntryId>,
47 expanded_dir_ids: HashMap<WorktreeId, Vec<ProjectEntryId>>,
48 selection: Option<Selection>,
49 context_menu: Option<(View<ContextMenu>, Point<Pixels>, Subscription)>,
50 edit_state: Option<EditState>,
51 filename_editor: View<Editor>,
52 clipboard_entry: Option<ClipboardEntry>,
53 _dragged_entry_destination: Option<Arc<Path>>,
54 workspace: WeakView<Workspace>,
55 width: Option<Pixels>,
56 pending_serialization: Task<Option<()>>,
57}
58
59#[derive(Copy, Clone, Debug)]
60struct Selection {
61 worktree_id: WorktreeId,
62 entry_id: ProjectEntryId,
63}
64
65#[derive(Clone, Debug)]
66struct EditState {
67 worktree_id: WorktreeId,
68 entry_id: ProjectEntryId,
69 is_new_entry: bool,
70 is_dir: bool,
71 processing_filename: Option<String>,
72}
73
74#[derive(Copy, Clone)]
75pub enum ClipboardEntry {
76 Copied {
77 worktree_id: WorktreeId,
78 entry_id: ProjectEntryId,
79 },
80 Cut {
81 worktree_id: WorktreeId,
82 entry_id: ProjectEntryId,
83 },
84}
85
86#[derive(Debug, PartialEq, Eq, Clone)]
87pub struct EntryDetails {
88 filename: String,
89 icon: Option<Arc<str>>,
90 path: Arc<Path>,
91 depth: usize,
92 kind: EntryKind,
93 is_ignored: bool,
94 is_expanded: bool,
95 is_selected: bool,
96 is_editing: bool,
97 is_processing: bool,
98 is_cut: bool,
99 git_status: Option<GitFileStatus>,
100 is_dotenv: bool,
101}
102
103actions!(
104 project_panel,
105 [
106 ExpandSelectedEntry,
107 CollapseSelectedEntry,
108 CollapseAllEntries,
109 NewDirectory,
110 NewFile,
111 Copy,
112 CopyPath,
113 CopyRelativePath,
114 RevealInFinder,
115 OpenInTerminal,
116 Cut,
117 Paste,
118 Delete,
119 Rename,
120 Open,
121 ToggleFocus,
122 NewSearchInDirectory,
123 ]
124);
125
126pub fn init_settings(cx: &mut AppContext) {
127 ProjectPanelSettings::register(cx);
128}
129
130pub fn init(assets: impl AssetSource, cx: &mut AppContext) {
131 init_settings(cx);
132 file_associations::init(assets, cx);
133
134 cx.observe_new_views(|workspace: &mut Workspace, _| {
135 workspace.register_action(|workspace, _: &ToggleFocus, cx| {
136 workspace.toggle_panel_focus::<ProjectPanel>(cx);
137 });
138 })
139 .detach();
140}
141
142#[derive(Debug)]
143pub enum Event {
144 OpenedEntry {
145 entry_id: ProjectEntryId,
146 focus_opened_item: bool,
147 },
148 SplitEntry {
149 entry_id: ProjectEntryId,
150 },
151 Focus,
152}
153
154#[derive(Serialize, Deserialize)]
155struct SerializedProjectPanel {
156 width: Option<Pixels>,
157}
158
159struct DraggedProjectEntryView {
160 entry_id: ProjectEntryId,
161 details: EntryDetails,
162 width: Pixels,
163}
164
165impl ProjectPanel {
166 fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> View<Self> {
167 let project = workspace.project().clone();
168 let project_panel = cx.new_view(|cx: &mut ViewContext<Self>| {
169 let focus_handle = cx.focus_handle();
170 cx.on_focus(&focus_handle, Self::focus_in).detach();
171
172 cx.subscribe(&project, |this, project, event, cx| match event {
173 project::Event::ActiveEntryChanged(Some(entry_id)) => {
174 if ProjectPanelSettings::get_global(cx).auto_reveal_entries {
175 this.reveal_entry(project, *entry_id, true, cx);
176 }
177 }
178 project::Event::RevealInProjectPanel(entry_id) => {
179 this.reveal_entry(project, *entry_id, false, cx);
180 cx.emit(PanelEvent::Activate);
181 }
182 project::Event::ActivateProjectPanel => {
183 cx.emit(PanelEvent::Activate);
184 }
185 project::Event::WorktreeRemoved(id) => {
186 this.expanded_dir_ids.remove(id);
187 this.update_visible_entries(None, cx);
188 cx.notify();
189 }
190 project::Event::WorktreeUpdatedEntries(_, _) | project::Event::WorktreeAdded => {
191 this.update_visible_entries(None, cx);
192 cx.notify();
193 }
194 _ => {}
195 })
196 .detach();
197
198 let filename_editor = cx.new_view(|cx| Editor::single_line(cx));
199
200 cx.subscribe(&filename_editor, |this, _, event, cx| match event {
201 editor::EditorEvent::BufferEdited
202 | editor::EditorEvent::SelectionsChanged { .. } => {
203 this.autoscroll(cx);
204 }
205 editor::EditorEvent::Blurred => {
206 if this
207 .edit_state
208 .as_ref()
209 .map_or(false, |state| state.processing_filename.is_none())
210 {
211 this.edit_state = None;
212 this.update_visible_entries(None, cx);
213 }
214 }
215 _ => {}
216 })
217 .detach();
218
219 cx.observe_global::<FileAssociations>(|_, cx| {
220 cx.notify();
221 })
222 .detach();
223
224 let mut this = Self {
225 project: project.clone(),
226 fs: workspace.app_state().fs.clone(),
227 scroll_handle: UniformListScrollHandle::new(),
228 focus_handle,
229 visible_entries: Default::default(),
230 last_worktree_root_id: Default::default(),
231 expanded_dir_ids: Default::default(),
232 selection: None,
233 edit_state: None,
234 context_menu: None,
235 filename_editor,
236 clipboard_entry: None,
237 _dragged_entry_destination: None,
238 workspace: workspace.weak_handle(),
239 width: None,
240 pending_serialization: Task::ready(None),
241 };
242 this.update_visible_entries(None, cx);
243
244 this
245 });
246
247 cx.subscribe(&project_panel, {
248 let project_panel = project_panel.downgrade();
249 move |workspace, _, event, cx| match event {
250 &Event::OpenedEntry {
251 entry_id,
252 focus_opened_item,
253 } => {
254 if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
255 if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
256 let file_path = entry.path.clone();
257 let worktree_id = worktree.read(cx).id();
258 let entry_id = entry.id;
259
260 workspace
261 .open_path(
262 ProjectPath {
263 worktree_id,
264 path: file_path.clone(),
265 },
266 None,
267 focus_opened_item,
268 cx,
269 )
270 .detach_and_prompt_err("Failed to open file", cx, move |e, _| {
271 match e.error_code() {
272 ErrorCode::UnsharedItem => Some(format!(
273 "{} is not shared by the host. This could be because it has been marked as `private`",
274 file_path.display()
275 )),
276 _ => None,
277 }
278 });
279
280 if let Some(project_panel) = project_panel.upgrade() {
281 // Always select the entry, regardless of whether it is opened or not.
282 project_panel.update(cx, |project_panel, _| {
283 project_panel.selection = Some(Selection {
284 worktree_id,
285 entry_id
286 });
287 });
288 if !focus_opened_item {
289 let focus_handle = project_panel.read(cx).focus_handle.clone();
290 cx.focus(&focus_handle);
291 }
292 }
293 }
294 }
295 }
296 &Event::SplitEntry { entry_id } => {
297 if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
298 if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
299 workspace
300 .split_path(
301 ProjectPath {
302 worktree_id: worktree.read(cx).id(),
303 path: entry.path.clone(),
304 },
305 cx,
306 )
307 .detach_and_log_err(cx);
308 }
309 }
310 }
311 _ => {}
312 }
313 })
314 .detach();
315
316 project_panel
317 }
318
319 pub async fn load(
320 workspace: WeakView<Workspace>,
321 mut cx: AsyncWindowContext,
322 ) -> Result<View<Self>> {
323 let serialized_panel = cx
324 .background_executor()
325 .spawn(async move { KEY_VALUE_STORE.read_kvp(PROJECT_PANEL_KEY) })
326 .await
327 .map_err(|e| anyhow!("Failed to load project panel: {}", e))
328 .log_err()
329 .flatten()
330 .map(|panel| serde_json::from_str::<SerializedProjectPanel>(&panel))
331 .transpose()
332 .log_err()
333 .flatten();
334
335 workspace.update(&mut cx, |workspace, cx| {
336 let panel = ProjectPanel::new(workspace, cx);
337 if let Some(serialized_panel) = serialized_panel {
338 panel.update(cx, |panel, cx| {
339 panel.width = serialized_panel.width.map(|px| px.round());
340 cx.notify();
341 });
342 }
343 panel
344 })
345 }
346
347 fn serialize(&mut self, cx: &mut ViewContext<Self>) {
348 let width = self.width;
349 self.pending_serialization = cx.background_executor().spawn(
350 async move {
351 KEY_VALUE_STORE
352 .write_kvp(
353 PROJECT_PANEL_KEY.into(),
354 serde_json::to_string(&SerializedProjectPanel { width })?,
355 )
356 .await?;
357 anyhow::Ok(())
358 }
359 .log_err(),
360 );
361 }
362
363 fn focus_in(&mut self, cx: &mut ViewContext<Self>) {
364 if !self.focus_handle.contains_focused(cx) {
365 cx.emit(Event::Focus);
366 }
367 }
368
369 fn deploy_context_menu(
370 &mut self,
371 position: Point<Pixels>,
372 entry_id: ProjectEntryId,
373 cx: &mut ViewContext<Self>,
374 ) {
375 let this = cx.view().clone();
376 let project = self.project.read(cx);
377
378 let worktree_id = if let Some(id) = project.worktree_id_for_entry(entry_id, cx) {
379 id
380 } else {
381 return;
382 };
383
384 self.selection = Some(Selection {
385 worktree_id,
386 entry_id,
387 });
388
389 if let Some((worktree, entry)) = self.selected_entry(cx) {
390 let is_root = Some(entry) == worktree.root_entry();
391 let is_dir = entry.is_dir();
392 let worktree_id = worktree.id();
393 let is_local = project.is_local();
394 let is_read_only = project.is_read_only();
395
396 let context_menu = ContextMenu::build(cx, |menu, cx| {
397 menu.context(self.focus_handle.clone()).when_else(
398 is_read_only,
399 |menu| {
400 menu.action("Copy Relative Path", Box::new(CopyRelativePath))
401 .when(is_dir, |menu| {
402 menu.action("Search Inside", Box::new(NewSearchInDirectory))
403 })
404 },
405 |menu| {
406 menu.when(is_local, |menu| {
407 menu.action(
408 "Add Folder to Project",
409 Box::new(workspace::AddFolderToProject),
410 )
411 .when(is_root, |menu| {
412 menu.entry(
413 "Remove from Project",
414 None,
415 cx.handler_for(&this, move |this, cx| {
416 this.project.update(cx, |project, cx| {
417 project.remove_worktree(worktree_id, cx)
418 });
419 }),
420 )
421 })
422 })
423 .action("New File", Box::new(NewFile))
424 .action("New Folder", Box::new(NewDirectory))
425 .separator()
426 .action("Cut", Box::new(Cut))
427 .action("Copy", Box::new(Copy))
428 .when_some(self.clipboard_entry, |menu, entry| {
429 menu.when(entry.worktree_id() == worktree_id, |menu| {
430 menu.action("Paste", Box::new(Paste))
431 })
432 })
433 .separator()
434 .action("Copy Path", Box::new(CopyPath))
435 .action("Copy Relative Path", Box::new(CopyRelativePath))
436 .separator()
437 .action("Reveal in Finder", Box::new(RevealInFinder))
438 .when(is_dir, |menu| {
439 menu.action("Open in Terminal", Box::new(OpenInTerminal))
440 .action("Search Inside", Box::new(NewSearchInDirectory))
441 })
442 .separator()
443 .action("Rename", Box::new(Rename))
444 .when(!is_root, |menu| menu.action("Delete", Box::new(Delete)))
445 },
446 )
447 });
448
449 cx.focus_view(&context_menu);
450 let subscription = cx.subscribe(&context_menu, |this, _, _: &DismissEvent, cx| {
451 this.context_menu.take();
452 cx.notify();
453 });
454 self.context_menu = Some((context_menu, position, subscription));
455 }
456
457 cx.notify();
458 }
459
460 fn expand_selected_entry(&mut self, _: &ExpandSelectedEntry, cx: &mut ViewContext<Self>) {
461 if let Some((worktree, entry)) = self.selected_entry(cx) {
462 if entry.is_dir() {
463 let worktree_id = worktree.id();
464 let entry_id = entry.id;
465 let expanded_dir_ids =
466 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
467 expanded_dir_ids
468 } else {
469 return;
470 };
471
472 match expanded_dir_ids.binary_search(&entry_id) {
473 Ok(_) => self.select_next(&SelectNext, cx),
474 Err(ix) => {
475 self.project.update(cx, |project, cx| {
476 project.expand_entry(worktree_id, entry_id, cx);
477 });
478
479 expanded_dir_ids.insert(ix, entry_id);
480 self.update_visible_entries(None, cx);
481 cx.notify();
482 }
483 }
484 }
485 }
486 }
487
488 fn collapse_selected_entry(&mut self, _: &CollapseSelectedEntry, cx: &mut ViewContext<Self>) {
489 if let Some((worktree, mut entry)) = self.selected_entry(cx) {
490 let worktree_id = worktree.id();
491 let expanded_dir_ids =
492 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
493 expanded_dir_ids
494 } else {
495 return;
496 };
497
498 loop {
499 let entry_id = entry.id;
500 match expanded_dir_ids.binary_search(&entry_id) {
501 Ok(ix) => {
502 expanded_dir_ids.remove(ix);
503 self.update_visible_entries(Some((worktree_id, entry_id)), cx);
504 cx.notify();
505 break;
506 }
507 Err(_) => {
508 if let Some(parent_entry) =
509 entry.path.parent().and_then(|p| worktree.entry_for_path(p))
510 {
511 entry = parent_entry;
512 } else {
513 break;
514 }
515 }
516 }
517 }
518 }
519 }
520
521 pub fn collapse_all_entries(&mut self, _: &CollapseAllEntries, cx: &mut ViewContext<Self>) {
522 self.expanded_dir_ids.clear();
523 self.update_visible_entries(None, cx);
524 cx.notify();
525 }
526
527 fn toggle_expanded(&mut self, entry_id: ProjectEntryId, cx: &mut ViewContext<Self>) {
528 if let Some(worktree_id) = self.project.read(cx).worktree_id_for_entry(entry_id, cx) {
529 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
530 self.project.update(cx, |project, cx| {
531 match expanded_dir_ids.binary_search(&entry_id) {
532 Ok(ix) => {
533 expanded_dir_ids.remove(ix);
534 }
535 Err(ix) => {
536 project.expand_entry(worktree_id, entry_id, cx);
537 expanded_dir_ids.insert(ix, entry_id);
538 }
539 }
540 });
541 self.update_visible_entries(Some((worktree_id, entry_id)), cx);
542 cx.focus(&self.focus_handle);
543 cx.notify();
544 }
545 }
546 }
547
548 fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
549 if let Some(selection) = self.selection {
550 let (mut worktree_ix, mut entry_ix, _) =
551 self.index_for_selection(selection).unwrap_or_default();
552 if entry_ix > 0 {
553 entry_ix -= 1;
554 } else if worktree_ix > 0 {
555 worktree_ix -= 1;
556 entry_ix = self.visible_entries[worktree_ix].1.len() - 1;
557 } else {
558 return;
559 }
560
561 let (worktree_id, worktree_entries) = &self.visible_entries[worktree_ix];
562 self.selection = Some(Selection {
563 worktree_id: *worktree_id,
564 entry_id: worktree_entries[entry_ix].id,
565 });
566 self.autoscroll(cx);
567 cx.notify();
568 } else {
569 self.select_first(cx);
570 }
571 }
572
573 fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
574 if let Some(task) = self.confirm_edit(cx) {
575 task.detach_and_log_err(cx);
576 }
577 }
578
579 fn open(&mut self, _: &Open, cx: &mut ViewContext<Self>) {
580 if let Some((_, entry)) = self.selected_entry(cx) {
581 if entry.is_file() {
582 self.open_entry(entry.id, true, cx);
583 } else {
584 self.toggle_expanded(entry.id, cx);
585 }
586 }
587 }
588
589 fn confirm_edit(&mut self, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
590 let edit_state = self.edit_state.as_mut()?;
591 cx.focus(&self.focus_handle);
592
593 let worktree_id = edit_state.worktree_id;
594 let is_new_entry = edit_state.is_new_entry;
595 let is_dir = edit_state.is_dir;
596 let worktree = self.project.read(cx).worktree_for_id(worktree_id, cx)?;
597 let entry = worktree.read(cx).entry_for_id(edit_state.entry_id)?.clone();
598 let filename = self.filename_editor.read(cx).text(cx);
599
600 let path_already_exists = |path| worktree.read(cx).entry_for_path(path).is_some();
601 let edit_task;
602 let edited_entry_id;
603 if is_new_entry {
604 self.selection = Some(Selection {
605 worktree_id,
606 entry_id: NEW_ENTRY_ID,
607 });
608 let new_path = entry.path.join(&filename.trim_start_matches('/'));
609 if path_already_exists(new_path.as_path()) {
610 return None;
611 }
612
613 edited_entry_id = NEW_ENTRY_ID;
614 edit_task = self.project.update(cx, |project, cx| {
615 project.create_entry((worktree_id, &new_path), is_dir, cx)
616 });
617 } else {
618 let new_path = if let Some(parent) = entry.path.clone().parent() {
619 parent.join(&filename)
620 } else {
621 filename.clone().into()
622 };
623 if path_already_exists(new_path.as_path()) {
624 return None;
625 }
626
627 edited_entry_id = entry.id;
628 edit_task = self.project.update(cx, |project, cx| {
629 project.rename_entry(entry.id, new_path.as_path(), cx)
630 });
631 };
632
633 edit_state.processing_filename = Some(filename);
634 cx.notify();
635
636 Some(cx.spawn(|this, mut cx| async move {
637 let new_entry = edit_task.await;
638 this.update(&mut cx, |this, cx| {
639 this.edit_state.take();
640 cx.notify();
641 })?;
642
643 if let Some(new_entry) = new_entry? {
644 this.update(&mut cx, |this, cx| {
645 if let Some(selection) = &mut this.selection {
646 if selection.entry_id == edited_entry_id {
647 selection.worktree_id = worktree_id;
648 selection.entry_id = new_entry.id;
649 this.expand_to_selection(cx);
650 }
651 }
652 this.update_visible_entries(None, cx);
653 if is_new_entry && !is_dir {
654 this.open_entry(new_entry.id, true, cx);
655 }
656 cx.notify();
657 })?;
658 }
659 Ok(())
660 }))
661 }
662
663 fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
664 self.edit_state = None;
665 self.update_visible_entries(None, cx);
666 cx.focus(&self.focus_handle);
667 cx.notify();
668 }
669
670 fn open_entry(
671 &mut self,
672 entry_id: ProjectEntryId,
673 focus_opened_item: bool,
674 cx: &mut ViewContext<Self>,
675 ) {
676 cx.emit(Event::OpenedEntry {
677 entry_id,
678 focus_opened_item,
679 });
680 }
681
682 fn split_entry(&mut self, entry_id: ProjectEntryId, cx: &mut ViewContext<Self>) {
683 cx.emit(Event::SplitEntry { entry_id });
684 }
685
686 fn new_file(&mut self, _: &NewFile, cx: &mut ViewContext<Self>) {
687 self.add_entry(false, cx)
688 }
689
690 fn new_directory(&mut self, _: &NewDirectory, cx: &mut ViewContext<Self>) {
691 self.add_entry(true, cx)
692 }
693
694 fn add_entry(&mut self, is_dir: bool, cx: &mut ViewContext<Self>) {
695 if let Some(Selection {
696 worktree_id,
697 entry_id,
698 }) = self.selection
699 {
700 let directory_id;
701 if let Some((worktree, expanded_dir_ids)) = self
702 .project
703 .read(cx)
704 .worktree_for_id(worktree_id, cx)
705 .zip(self.expanded_dir_ids.get_mut(&worktree_id))
706 {
707 let worktree = worktree.read(cx);
708 if let Some(mut entry) = worktree.entry_for_id(entry_id) {
709 loop {
710 if entry.is_dir() {
711 if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
712 expanded_dir_ids.insert(ix, entry.id);
713 }
714 directory_id = entry.id;
715 break;
716 } else {
717 if let Some(parent_path) = entry.path.parent() {
718 if let Some(parent_entry) = worktree.entry_for_path(parent_path) {
719 entry = parent_entry;
720 continue;
721 }
722 }
723 return;
724 }
725 }
726 } else {
727 return;
728 };
729 } else {
730 return;
731 };
732
733 self.edit_state = Some(EditState {
734 worktree_id,
735 entry_id: directory_id,
736 is_new_entry: true,
737 is_dir,
738 processing_filename: None,
739 });
740 self.filename_editor.update(cx, |editor, cx| {
741 editor.clear(cx);
742 editor.focus(cx);
743 });
744 self.update_visible_entries(Some((worktree_id, NEW_ENTRY_ID)), cx);
745 self.autoscroll(cx);
746 cx.notify();
747 }
748 }
749
750 fn rename(&mut self, _: &Rename, cx: &mut ViewContext<Self>) {
751 if let Some(Selection {
752 worktree_id,
753 entry_id,
754 }) = self.selection
755 {
756 if let Some(worktree) = self.project.read(cx).worktree_for_id(worktree_id, cx) {
757 if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
758 self.edit_state = Some(EditState {
759 worktree_id,
760 entry_id,
761 is_new_entry: false,
762 is_dir: entry.is_dir(),
763 processing_filename: None,
764 });
765 let file_name = entry
766 .path
767 .file_name()
768 .map(|s| s.to_string_lossy())
769 .unwrap_or_default()
770 .to_string();
771 let file_stem = entry.path.file_stem().map(|s| s.to_string_lossy());
772 let selection_end =
773 file_stem.map_or(file_name.len(), |file_stem| file_stem.len());
774 self.filename_editor.update(cx, |editor, cx| {
775 editor.set_text(file_name, cx);
776 editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
777 s.select_ranges([0..selection_end])
778 });
779 editor.focus(cx);
780 });
781 self.update_visible_entries(None, cx);
782 self.autoscroll(cx);
783 cx.notify();
784 }
785 }
786 }
787 }
788
789 fn delete(&mut self, _: &Delete, cx: &mut ViewContext<Self>) {
790 maybe!({
791 let Selection { entry_id, .. } = self.selection?;
792 let path = self.project.read(cx).path_for_entry(entry_id, cx)?.path;
793 let file_name = path.file_name()?;
794
795 let answer = cx.prompt(
796 PromptLevel::Info,
797 &format!("Delete {file_name:?}?"),
798 None,
799 &["Delete", "Cancel"],
800 );
801
802 cx.spawn(|this, mut cx| async move {
803 if answer.await != Ok(0) {
804 return Ok(());
805 }
806 this.update(&mut cx, |this, cx| {
807 this.project
808 .update(cx, |project, cx| project.delete_entry(entry_id, cx))
809 .ok_or_else(|| anyhow!("no such entry"))
810 })??
811 .await
812 })
813 .detach_and_log_err(cx);
814 Some(())
815 });
816 }
817
818 fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
819 if let Some(selection) = self.selection {
820 let (mut worktree_ix, mut entry_ix, _) =
821 self.index_for_selection(selection).unwrap_or_default();
822 if let Some((_, worktree_entries)) = self.visible_entries.get(worktree_ix) {
823 if entry_ix + 1 < worktree_entries.len() {
824 entry_ix += 1;
825 } else {
826 worktree_ix += 1;
827 entry_ix = 0;
828 }
829 }
830
831 if let Some((worktree_id, worktree_entries)) = self.visible_entries.get(worktree_ix) {
832 if let Some(entry) = worktree_entries.get(entry_ix) {
833 self.selection = Some(Selection {
834 worktree_id: *worktree_id,
835 entry_id: entry.id,
836 });
837 self.autoscroll(cx);
838 cx.notify();
839 }
840 }
841 } else {
842 self.select_first(cx);
843 }
844 }
845
846 fn select_first(&mut self, cx: &mut ViewContext<Self>) {
847 let worktree = self
848 .visible_entries
849 .first()
850 .and_then(|(worktree_id, _)| self.project.read(cx).worktree_for_id(*worktree_id, cx));
851 if let Some(worktree) = worktree {
852 let worktree = worktree.read(cx);
853 let worktree_id = worktree.id();
854 if let Some(root_entry) = worktree.root_entry() {
855 self.selection = Some(Selection {
856 worktree_id,
857 entry_id: root_entry.id,
858 });
859 self.autoscroll(cx);
860 cx.notify();
861 }
862 }
863 }
864
865 fn autoscroll(&mut self, cx: &mut ViewContext<Self>) {
866 if let Some((_, _, index)) = self.selection.and_then(|s| self.index_for_selection(s)) {
867 self.scroll_handle.scroll_to_item(index);
868 cx.notify();
869 }
870 }
871
872 fn cut(&mut self, _: &Cut, cx: &mut ViewContext<Self>) {
873 if let Some((worktree, entry)) = self.selected_entry(cx) {
874 self.clipboard_entry = Some(ClipboardEntry::Cut {
875 worktree_id: worktree.id(),
876 entry_id: entry.id,
877 });
878 cx.notify();
879 }
880 }
881
882 fn copy(&mut self, _: &Copy, cx: &mut ViewContext<Self>) {
883 if let Some((worktree, entry)) = self.selected_entry(cx) {
884 self.clipboard_entry = Some(ClipboardEntry::Copied {
885 worktree_id: worktree.id(),
886 entry_id: entry.id,
887 });
888 cx.notify();
889 }
890 }
891
892 fn paste(&mut self, _: &Paste, cx: &mut ViewContext<Self>) {
893 maybe!({
894 let (worktree, entry) = self.selected_entry(cx)?;
895 let clipboard_entry = self.clipboard_entry?;
896 if clipboard_entry.worktree_id() != worktree.id() {
897 return None;
898 }
899
900 let clipboard_entry_file_name = self
901 .project
902 .read(cx)
903 .path_for_entry(clipboard_entry.entry_id(), cx)?
904 .path
905 .file_name()?
906 .to_os_string();
907
908 let mut new_path = entry.path.to_path_buf();
909 // If we're pasting into a file, or a directory into itself, go up one level.
910 if entry.is_file() || (entry.is_dir() && entry.id == clipboard_entry.entry_id()) {
911 new_path.pop();
912 }
913
914 new_path.push(&clipboard_entry_file_name);
915 let extension = new_path.extension().map(|e| e.to_os_string());
916 let file_name_without_extension = Path::new(&clipboard_entry_file_name).file_stem()?;
917 let mut ix = 0;
918 while worktree.entry_for_path(&new_path).is_some() {
919 new_path.pop();
920
921 let mut new_file_name = file_name_without_extension.to_os_string();
922 new_file_name.push(" copy");
923 if ix > 0 {
924 new_file_name.push(format!(" {}", ix));
925 }
926 if let Some(extension) = extension.as_ref() {
927 new_file_name.push(".");
928 new_file_name.push(extension);
929 }
930
931 new_path.push(new_file_name);
932 ix += 1;
933 }
934
935 if clipboard_entry.is_cut() {
936 self.project
937 .update(cx, |project, cx| {
938 project.rename_entry(clipboard_entry.entry_id(), new_path, cx)
939 })
940 .detach_and_log_err(cx)
941 } else {
942 self.project
943 .update(cx, |project, cx| {
944 project.copy_entry(clipboard_entry.entry_id(), new_path, cx)
945 })
946 .detach_and_log_err(cx)
947 }
948
949 Some(())
950 });
951 }
952
953 fn copy_path(&mut self, _: &CopyPath, cx: &mut ViewContext<Self>) {
954 if let Some((worktree, entry)) = self.selected_entry(cx) {
955 cx.write_to_clipboard(ClipboardItem::new(
956 worktree
957 .abs_path()
958 .join(&entry.path)
959 .to_string_lossy()
960 .to_string(),
961 ));
962 }
963 }
964
965 fn copy_relative_path(&mut self, _: &CopyRelativePath, cx: &mut ViewContext<Self>) {
966 if let Some((_, entry)) = self.selected_entry(cx) {
967 cx.write_to_clipboard(ClipboardItem::new(entry.path.to_string_lossy().to_string()));
968 }
969 }
970
971 fn reveal_in_finder(&mut self, _: &RevealInFinder, cx: &mut ViewContext<Self>) {
972 if let Some((worktree, entry)) = self.selected_entry(cx) {
973 cx.reveal_path(&worktree.abs_path().join(&entry.path));
974 }
975 }
976
977 fn open_in_terminal(&mut self, _: &OpenInTerminal, cx: &mut ViewContext<Self>) {
978 if let Some((worktree, entry)) = self.selected_entry(cx) {
979 let path = worktree.abs_path().join(&entry.path);
980 cx.dispatch_action(
981 workspace::OpenTerminal {
982 working_directory: path,
983 }
984 .boxed_clone(),
985 )
986 }
987 }
988
989 pub fn new_search_in_directory(
990 &mut self,
991 _: &NewSearchInDirectory,
992 cx: &mut ViewContext<Self>,
993 ) {
994 if let Some((_, entry)) = self.selected_entry(cx) {
995 if entry.is_dir() {
996 let entry = entry.clone();
997 self.workspace
998 .update(cx, |workspace, cx| {
999 search::ProjectSearchView::new_search_in_directory(workspace, &entry, cx);
1000 })
1001 .ok();
1002 }
1003 }
1004 }
1005
1006 fn move_entry(
1007 &mut self,
1008 entry_to_move: ProjectEntryId,
1009 destination: ProjectEntryId,
1010 destination_is_file: bool,
1011 cx: &mut ViewContext<Self>,
1012 ) {
1013 let destination_worktree = self.project.update(cx, |project, cx| {
1014 let entry_path = project.path_for_entry(entry_to_move, cx)?;
1015 let destination_entry_path = project.path_for_entry(destination, cx)?.path.clone();
1016
1017 let mut destination_path = destination_entry_path.as_ref();
1018 if destination_is_file {
1019 destination_path = destination_path.parent()?;
1020 }
1021
1022 let mut new_path = destination_path.to_path_buf();
1023 new_path.push(entry_path.path.file_name()?);
1024 if new_path != entry_path.path.as_ref() {
1025 let task = project.rename_entry(entry_to_move, new_path, cx);
1026 cx.foreground_executor().spawn(task).detach_and_log_err(cx);
1027 }
1028
1029 project.worktree_id_for_entry(destination, cx)
1030 });
1031
1032 if let Some(destination_worktree) = destination_worktree {
1033 self.expand_entry(destination_worktree, destination, cx);
1034 }
1035 }
1036
1037 fn index_for_selection(&self, selection: Selection) -> Option<(usize, usize, usize)> {
1038 let mut entry_index = 0;
1039 let mut visible_entries_index = 0;
1040 for (worktree_index, (worktree_id, worktree_entries)) in
1041 self.visible_entries.iter().enumerate()
1042 {
1043 if *worktree_id == selection.worktree_id {
1044 for entry in worktree_entries {
1045 if entry.id == selection.entry_id {
1046 return Some((worktree_index, entry_index, visible_entries_index));
1047 } else {
1048 visible_entries_index += 1;
1049 entry_index += 1;
1050 }
1051 }
1052 break;
1053 } else {
1054 visible_entries_index += worktree_entries.len();
1055 }
1056 }
1057 None
1058 }
1059
1060 pub fn selected_entry<'a>(
1061 &self,
1062 cx: &'a AppContext,
1063 ) -> Option<(&'a Worktree, &'a project::Entry)> {
1064 let (worktree, entry) = self.selected_entry_handle(cx)?;
1065 Some((worktree.read(cx), entry))
1066 }
1067
1068 fn selected_entry_handle<'a>(
1069 &self,
1070 cx: &'a AppContext,
1071 ) -> Option<(Model<Worktree>, &'a project::Entry)> {
1072 let selection = self.selection?;
1073 let project = self.project.read(cx);
1074 let worktree = project.worktree_for_id(selection.worktree_id, cx)?;
1075 let entry = worktree.read(cx).entry_for_id(selection.entry_id)?;
1076 Some((worktree, entry))
1077 }
1078
1079 fn expand_to_selection(&mut self, cx: &mut ViewContext<Self>) -> Option<()> {
1080 let (worktree, entry) = self.selected_entry(cx)?;
1081 let expanded_dir_ids = self.expanded_dir_ids.entry(worktree.id()).or_default();
1082
1083 for path in entry.path.ancestors() {
1084 let Some(entry) = worktree.entry_for_path(path) else {
1085 continue;
1086 };
1087 if entry.is_dir() {
1088 if let Err(idx) = expanded_dir_ids.binary_search(&entry.id) {
1089 expanded_dir_ids.insert(idx, entry.id);
1090 }
1091 }
1092 }
1093
1094 Some(())
1095 }
1096
1097 fn update_visible_entries(
1098 &mut self,
1099 new_selected_entry: Option<(WorktreeId, ProjectEntryId)>,
1100 cx: &mut ViewContext<Self>,
1101 ) {
1102 let project = self.project.read(cx);
1103 self.last_worktree_root_id = project
1104 .visible_worktrees(cx)
1105 .rev()
1106 .next()
1107 .and_then(|worktree| worktree.read(cx).root_entry())
1108 .map(|entry| entry.id);
1109
1110 self.visible_entries.clear();
1111 for worktree in project.visible_worktrees(cx) {
1112 let snapshot = worktree.read(cx).snapshot();
1113 let worktree_id = snapshot.id();
1114
1115 let expanded_dir_ids = match self.expanded_dir_ids.entry(worktree_id) {
1116 hash_map::Entry::Occupied(e) => e.into_mut(),
1117 hash_map::Entry::Vacant(e) => {
1118 // The first time a worktree's root entry becomes available,
1119 // mark that root entry as expanded.
1120 if let Some(entry) = snapshot.root_entry() {
1121 e.insert(vec![entry.id]).as_slice()
1122 } else {
1123 &[]
1124 }
1125 }
1126 };
1127
1128 let mut new_entry_parent_id = None;
1129 let mut new_entry_kind = EntryKind::Dir;
1130 if let Some(edit_state) = &self.edit_state {
1131 if edit_state.worktree_id == worktree_id && edit_state.is_new_entry {
1132 new_entry_parent_id = Some(edit_state.entry_id);
1133 new_entry_kind = if edit_state.is_dir {
1134 EntryKind::Dir
1135 } else {
1136 EntryKind::File(Default::default())
1137 };
1138 }
1139 }
1140
1141 let mut visible_worktree_entries = Vec::new();
1142 let mut entry_iter = snapshot.entries(true);
1143
1144 while let Some(entry) = entry_iter.entry() {
1145 visible_worktree_entries.push(entry.clone());
1146 if Some(entry.id) == new_entry_parent_id {
1147 visible_worktree_entries.push(Entry {
1148 id: NEW_ENTRY_ID,
1149 kind: new_entry_kind,
1150 path: entry.path.join("\0").into(),
1151 inode: 0,
1152 mtime: entry.mtime,
1153 is_symlink: false,
1154 is_ignored: false,
1155 is_external: false,
1156 is_private: false,
1157 git_status: entry.git_status,
1158 });
1159 }
1160 if expanded_dir_ids.binary_search(&entry.id).is_err()
1161 && entry_iter.advance_to_sibling()
1162 {
1163 continue;
1164 }
1165 entry_iter.advance();
1166 }
1167
1168 snapshot.propagate_git_statuses(&mut visible_worktree_entries);
1169
1170 visible_worktree_entries.sort_by(|entry_a, entry_b| {
1171 let mut components_a = entry_a.path.components().peekable();
1172 let mut components_b = entry_b.path.components().peekable();
1173 loop {
1174 match (components_a.next(), components_b.next()) {
1175 (Some(component_a), Some(component_b)) => {
1176 let a_is_file = components_a.peek().is_none() && entry_a.is_file();
1177 let b_is_file = components_b.peek().is_none() && entry_b.is_file();
1178 let ordering = a_is_file.cmp(&b_is_file).then_with(|| {
1179 let maybe_numeric_ordering = maybe!({
1180 let num_and_remainder_a = Path::new(component_a.as_os_str())
1181 .file_stem()
1182 .and_then(|s| s.to_str())
1183 .and_then(
1184 NumericPrefixWithSuffix::from_numeric_prefixed_str,
1185 )?;
1186 let num_and_remainder_b = Path::new(component_b.as_os_str())
1187 .file_stem()
1188 .and_then(|s| s.to_str())
1189 .and_then(
1190 NumericPrefixWithSuffix::from_numeric_prefixed_str,
1191 )?;
1192
1193 num_and_remainder_a.partial_cmp(&num_and_remainder_b)
1194 });
1195
1196 maybe_numeric_ordering.unwrap_or_else(|| {
1197 let name_a =
1198 UniCase::new(component_a.as_os_str().to_string_lossy());
1199 let name_b =
1200 UniCase::new(component_b.as_os_str().to_string_lossy());
1201
1202 name_a.cmp(&name_b)
1203 })
1204 });
1205 if !ordering.is_eq() {
1206 return ordering;
1207 }
1208 }
1209 (Some(_), None) => break Ordering::Greater,
1210 (None, Some(_)) => break Ordering::Less,
1211 (None, None) => break Ordering::Equal,
1212 }
1213 }
1214 });
1215 self.visible_entries
1216 .push((worktree_id, visible_worktree_entries));
1217 }
1218
1219 if let Some((worktree_id, entry_id)) = new_selected_entry {
1220 self.selection = Some(Selection {
1221 worktree_id,
1222 entry_id,
1223 });
1224 }
1225 }
1226
1227 fn expand_entry(
1228 &mut self,
1229 worktree_id: WorktreeId,
1230 entry_id: ProjectEntryId,
1231 cx: &mut ViewContext<Self>,
1232 ) {
1233 self.project.update(cx, |project, cx| {
1234 if let Some((worktree, expanded_dir_ids)) = project
1235 .worktree_for_id(worktree_id, cx)
1236 .zip(self.expanded_dir_ids.get_mut(&worktree_id))
1237 {
1238 project.expand_entry(worktree_id, entry_id, cx);
1239 let worktree = worktree.read(cx);
1240
1241 if let Some(mut entry) = worktree.entry_for_id(entry_id) {
1242 loop {
1243 if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
1244 expanded_dir_ids.insert(ix, entry.id);
1245 }
1246
1247 if let Some(parent_entry) =
1248 entry.path.parent().and_then(|p| worktree.entry_for_path(p))
1249 {
1250 entry = parent_entry;
1251 } else {
1252 break;
1253 }
1254 }
1255 }
1256 }
1257 });
1258 }
1259
1260 fn for_each_visible_entry(
1261 &self,
1262 range: Range<usize>,
1263 cx: &mut ViewContext<ProjectPanel>,
1264 mut callback: impl FnMut(ProjectEntryId, EntryDetails, &mut ViewContext<ProjectPanel>),
1265 ) {
1266 let mut ix = 0;
1267 for (worktree_id, visible_worktree_entries) in &self.visible_entries {
1268 if ix >= range.end {
1269 return;
1270 }
1271
1272 if ix + visible_worktree_entries.len() <= range.start {
1273 ix += visible_worktree_entries.len();
1274 continue;
1275 }
1276
1277 let end_ix = range.end.min(ix + visible_worktree_entries.len());
1278 let (git_status_setting, show_file_icons, show_folder_icons) = {
1279 let settings = ProjectPanelSettings::get_global(cx);
1280 (
1281 settings.git_status,
1282 settings.file_icons,
1283 settings.folder_icons,
1284 )
1285 };
1286 if let Some(worktree) = self.project.read(cx).worktree_for_id(*worktree_id, cx) {
1287 let snapshot = worktree.read(cx).snapshot();
1288 let root_name = OsStr::new(snapshot.root_name());
1289 let expanded_entry_ids = self
1290 .expanded_dir_ids
1291 .get(&snapshot.id())
1292 .map(Vec::as_slice)
1293 .unwrap_or(&[]);
1294
1295 let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
1296 for entry in visible_worktree_entries[entry_range].iter() {
1297 let status = git_status_setting.then(|| entry.git_status).flatten();
1298 let is_expanded = expanded_entry_ids.binary_search(&entry.id).is_ok();
1299 let icon = match entry.kind {
1300 EntryKind::File(_) => {
1301 if show_file_icons {
1302 FileAssociations::get_icon(&entry.path, cx)
1303 } else {
1304 None
1305 }
1306 }
1307 _ => {
1308 if show_folder_icons {
1309 FileAssociations::get_folder_icon(is_expanded, cx)
1310 } else {
1311 FileAssociations::get_chevron_icon(is_expanded, cx)
1312 }
1313 }
1314 };
1315
1316 let mut details = EntryDetails {
1317 filename: entry
1318 .path
1319 .file_name()
1320 .unwrap_or(root_name)
1321 .to_string_lossy()
1322 .to_string(),
1323 icon,
1324 path: entry.path.clone(),
1325 depth: entry.path.components().count(),
1326 kind: entry.kind,
1327 is_ignored: entry.is_ignored,
1328 is_expanded,
1329 is_selected: self.selection.map_or(false, |e| {
1330 e.worktree_id == snapshot.id() && e.entry_id == entry.id
1331 }),
1332 is_editing: false,
1333 is_processing: false,
1334 is_cut: self
1335 .clipboard_entry
1336 .map_or(false, |e| e.is_cut() && e.entry_id() == entry.id),
1337 git_status: status,
1338 is_dotenv: entry.is_private,
1339 };
1340
1341 if let Some(edit_state) = &self.edit_state {
1342 let is_edited_entry = if edit_state.is_new_entry {
1343 entry.id == NEW_ENTRY_ID
1344 } else {
1345 entry.id == edit_state.entry_id
1346 };
1347
1348 if is_edited_entry {
1349 if let Some(processing_filename) = &edit_state.processing_filename {
1350 details.is_processing = true;
1351 details.filename.clear();
1352 details.filename.push_str(processing_filename);
1353 } else {
1354 if edit_state.is_new_entry {
1355 details.filename.clear();
1356 }
1357 details.is_editing = true;
1358 }
1359 }
1360 }
1361
1362 callback(entry.id, details, cx);
1363 }
1364 }
1365 ix = end_ix;
1366 }
1367 }
1368
1369 fn render_entry(
1370 &self,
1371 entry_id: ProjectEntryId,
1372 details: EntryDetails,
1373 cx: &mut ViewContext<Self>,
1374 ) -> Stateful<Div> {
1375 let kind = details.kind;
1376 let settings = ProjectPanelSettings::get_global(cx);
1377 let show_editor = details.is_editing && !details.is_processing;
1378 let is_selected = self
1379 .selection
1380 .map_or(false, |selection| selection.entry_id == entry_id);
1381 let width = self.size(cx);
1382
1383 let filename_text_color = details
1384 .git_status
1385 .as_ref()
1386 .map(|status| match status {
1387 GitFileStatus::Added => Color::Created,
1388 GitFileStatus::Modified => Color::Modified,
1389 GitFileStatus::Conflict => Color::Conflict,
1390 })
1391 .unwrap_or(if is_selected {
1392 Color::Default
1393 } else if details.is_ignored {
1394 Color::Disabled
1395 } else {
1396 Color::Muted
1397 });
1398
1399 let file_name = details.filename.clone();
1400 let icon = details.icon.clone();
1401 let depth = details.depth;
1402 div()
1403 .id(entry_id.to_proto() as usize)
1404 .on_drag(entry_id, move |entry_id, cx| {
1405 cx.new_view(|_| DraggedProjectEntryView {
1406 details: details.clone(),
1407 width,
1408 entry_id: *entry_id,
1409 })
1410 })
1411 .drag_over::<ProjectEntryId>(|style, _, cx| {
1412 style.bg(cx.theme().colors().drop_target_background)
1413 })
1414 .on_drop(cx.listener(move |this, dragged_id: &ProjectEntryId, cx| {
1415 this.move_entry(*dragged_id, entry_id, kind.is_file(), cx);
1416 }))
1417 .child(
1418 ListItem::new(entry_id.to_proto() as usize)
1419 .indent_level(depth)
1420 .indent_step_size(px(settings.indent_size))
1421 .selected(is_selected)
1422 .child(if let Some(icon) = &icon {
1423 div().child(Icon::from_path(icon.to_string()).color(filename_text_color))
1424 } else {
1425 div().size(IconSize::default().rems()).invisible()
1426 })
1427 .child(
1428 if let (Some(editor), true) = (Some(&self.filename_editor), show_editor) {
1429 h_flex().h_6().w_full().child(editor.clone())
1430 } else {
1431 div()
1432 .h_6()
1433 .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.scroll_handle.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::{FakeFs, WorktreeSettings};
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::<WorktreeSettings>(cx, |worktree_settings| {
1823 worktree_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::<WorktreeSettings>(cx, |worktree_settings| {
3030 worktree_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::<WorktreeSettings>(cx, |worktree_settings| {
3268 worktree_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::<WorktreeSettings>(cx, |worktree_settings| {
3586 worktree_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}