1pub mod file_associations;
2mod project_panel_settings;
3
4use context_menu::{ContextMenu, ContextMenuItem};
5use db::kvp::KEY_VALUE_STORE;
6use drag_and_drop::{DragAndDrop, Draggable};
7use editor::{scroll::autoscroll::Autoscroll, Cancel, Editor};
8use file_associations::FileAssociations;
9
10use futures::stream::StreamExt;
11use gpui::{
12 actions,
13 anyhow::{self, anyhow, Result},
14 elements::{
15 AnchorCorner, ChildView, ContainerStyle, Empty, Flex, Label, MouseEventHandler,
16 ParentElement, ScrollTarget, Stack, Svg, UniformList, UniformListState,
17 },
18 geometry::vector::Vector2F,
19 keymap_matcher::KeymapContext,
20 platform::{CursorStyle, MouseButton, PromptLevel},
21 Action, AnyElement, AppContext, AssetSource, AsyncAppContext, ClipboardItem, Element, Entity,
22 ModelHandle, Task, View, ViewContext, ViewHandle, WeakViewHandle, WindowContext,
23};
24use menu::{Confirm, SelectNext, SelectPrev};
25use project::{
26 repository::GitFileStatus, Entry, EntryKind, Fs, Project, ProjectEntryId, ProjectPath,
27 Worktree, WorktreeId,
28};
29use project_panel_settings::{ProjectPanelDockPosition, ProjectPanelSettings};
30use serde::{Deserialize, Serialize};
31use settings::SettingsStore;
32use std::{
33 cmp::Ordering,
34 collections::{hash_map, HashMap},
35 ffi::OsStr,
36 ops::Range,
37 path::Path,
38 sync::Arc,
39};
40use theme::ProjectPanelEntry;
41use unicase::UniCase;
42use util::{ResultExt, TryFutureExt};
43use workspace::{
44 dock::{DockPosition, Panel},
45 Workspace,
46};
47
48const PROJECT_PANEL_KEY: &'static str = "ProjectPanel";
49const NEW_ENTRY_ID: ProjectEntryId = ProjectEntryId::MAX;
50
51pub struct ProjectPanel {
52 project: ModelHandle<Project>,
53 fs: Arc<dyn Fs>,
54 list: UniformListState,
55 visible_entries: Vec<(WorktreeId, Vec<Entry>)>,
56 last_worktree_root_id: Option<ProjectEntryId>,
57 expanded_dir_ids: HashMap<WorktreeId, Vec<ProjectEntryId>>,
58 selection: Option<Selection>,
59 edit_state: Option<EditState>,
60 filename_editor: ViewHandle<Editor>,
61 clipboard_entry: Option<ClipboardEntry>,
62 context_menu: ViewHandle<ContextMenu>,
63 dragged_entry_destination: Option<Arc<Path>>,
64 workspace: WeakViewHandle<Workspace>,
65 has_focus: bool,
66 width: Option<f32>,
67 pending_serialization: Task<Option<()>>,
68}
69
70#[derive(Copy, Clone, Debug)]
71struct Selection {
72 worktree_id: WorktreeId,
73 entry_id: ProjectEntryId,
74}
75
76#[derive(Clone, Debug)]
77struct EditState {
78 worktree_id: WorktreeId,
79 entry_id: ProjectEntryId,
80 is_new_entry: bool,
81 is_dir: bool,
82 processing_filename: Option<String>,
83}
84
85#[derive(Copy, Clone)]
86pub enum ClipboardEntry {
87 Copied {
88 worktree_id: WorktreeId,
89 entry_id: ProjectEntryId,
90 },
91 Cut {
92 worktree_id: WorktreeId,
93 entry_id: ProjectEntryId,
94 },
95}
96
97#[derive(Debug, PartialEq, Eq)]
98pub struct EntryDetails {
99 filename: String,
100 icon: Option<Arc<str>>,
101 path: Arc<Path>,
102 depth: usize,
103 kind: EntryKind,
104 is_ignored: bool,
105 is_expanded: bool,
106 is_selected: bool,
107 is_editing: bool,
108 is_processing: bool,
109 is_cut: bool,
110 git_status: Option<GitFileStatus>,
111}
112
113actions!(
114 project_panel,
115 [
116 ExpandSelectedEntry,
117 CollapseSelectedEntry,
118 CollapseAllEntries,
119 NewDirectory,
120 NewFile,
121 Copy,
122 CopyPath,
123 CopyRelativePath,
124 RevealInFinder,
125 Cut,
126 Paste,
127 Delete,
128 Rename,
129 Open,
130 ToggleFocus,
131 NewSearchInDirectory,
132 ]
133);
134
135pub fn init_settings(cx: &mut AppContext) {
136 settings::register::<ProjectPanelSettings>(cx);
137}
138
139pub fn init(assets: impl AssetSource, cx: &mut AppContext) {
140 init_settings(cx);
141 file_associations::init(assets, cx);
142 cx.add_action(ProjectPanel::expand_selected_entry);
143 cx.add_action(ProjectPanel::collapse_selected_entry);
144 cx.add_action(ProjectPanel::collapse_all_entries);
145 cx.add_action(ProjectPanel::select_prev);
146 cx.add_action(ProjectPanel::select_next);
147 cx.add_action(ProjectPanel::new_file);
148 cx.add_action(ProjectPanel::new_directory);
149 cx.add_action(ProjectPanel::rename);
150 cx.add_async_action(ProjectPanel::delete);
151 cx.add_async_action(ProjectPanel::confirm);
152 cx.add_async_action(ProjectPanel::open_file);
153 cx.add_action(ProjectPanel::cancel);
154 cx.add_action(ProjectPanel::cut);
155 cx.add_action(ProjectPanel::copy);
156 cx.add_action(ProjectPanel::copy_path);
157 cx.add_action(ProjectPanel::copy_relative_path);
158 cx.add_action(ProjectPanel::reveal_in_finder);
159 cx.add_action(ProjectPanel::new_search_in_directory);
160 cx.add_action(
161 |this: &mut ProjectPanel, action: &Paste, cx: &mut ViewContext<ProjectPanel>| {
162 this.paste(action, cx);
163 },
164 );
165}
166
167#[derive(Debug)]
168pub enum Event {
169 OpenedEntry {
170 entry_id: ProjectEntryId,
171 focus_opened_item: bool,
172 },
173 SplitEntry {
174 entry_id: ProjectEntryId,
175 },
176 DockPositionChanged,
177 Focus,
178 NewSearchInDirectory {
179 dir_entry: Entry,
180 },
181 ActivatePanel,
182}
183
184#[derive(Serialize, Deserialize)]
185struct SerializedProjectPanel {
186 width: Option<f32>,
187}
188
189impl ProjectPanel {
190 fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> ViewHandle<Self> {
191 let project = workspace.project().clone();
192 let project_panel = cx.add_view(|cx: &mut ViewContext<Self>| {
193 cx.observe(&project, |this, _, cx| {
194 this.update_visible_entries(None, cx);
195 cx.notify();
196 })
197 .detach();
198 cx.subscribe(&project, |this, project, event, cx| match event {
199 project::Event::ActiveEntryChanged(Some(entry_id)) => {
200 if let Some(worktree_id) = project.read(cx).worktree_id_for_entry(*entry_id, cx)
201 {
202 this.expand_entry(worktree_id, *entry_id, cx);
203 this.update_visible_entries(Some((worktree_id, *entry_id)), cx);
204 this.autoscroll(cx);
205 cx.notify();
206 }
207 }
208 project::Event::ActivateProjectPanel => {
209 cx.emit(Event::ActivatePanel);
210 }
211 project::Event::WorktreeRemoved(id) => {
212 this.expanded_dir_ids.remove(id);
213 this.update_visible_entries(None, cx);
214 cx.notify();
215 }
216 _ => {}
217 })
218 .detach();
219
220 let filename_editor = cx.add_view(|cx| {
221 Editor::single_line(
222 Some(Arc::new(|theme| {
223 let mut style = theme.project_panel.filename_editor.clone();
224 style.container.background_color.take();
225 style
226 })),
227 cx,
228 )
229 });
230
231 cx.subscribe(&filename_editor, |this, _, event, cx| match event {
232 editor::Event::BufferEdited | editor::Event::SelectionsChanged { .. } => {
233 this.autoscroll(cx);
234 }
235 _ => {}
236 })
237 .detach();
238 cx.observe_focus(&filename_editor, |this, _, is_focused, cx| {
239 if !is_focused
240 && this
241 .edit_state
242 .as_ref()
243 .map_or(false, |state| state.processing_filename.is_none())
244 {
245 this.edit_state = None;
246 this.update_visible_entries(None, cx);
247 }
248 })
249 .detach();
250
251 cx.observe_global::<FileAssociations, _>(|_, cx| {
252 cx.notify();
253 })
254 .detach();
255
256 let view_id = cx.view_id();
257 let mut this = Self {
258 project: project.clone(),
259 fs: workspace.app_state().fs.clone(),
260 list: Default::default(),
261 visible_entries: Default::default(),
262 last_worktree_root_id: Default::default(),
263 expanded_dir_ids: Default::default(),
264 selection: None,
265 edit_state: None,
266 filename_editor,
267 clipboard_entry: None,
268 context_menu: cx.add_view(|cx| ContextMenu::new(view_id, cx)),
269 dragged_entry_destination: None,
270 workspace: workspace.weak_handle(),
271 has_focus: false,
272 width: None,
273 pending_serialization: Task::ready(None),
274 };
275 this.update_visible_entries(None, cx);
276
277 // Update the dock position when the setting changes.
278 let mut old_dock_position = this.position(cx);
279 cx.observe_global::<SettingsStore, _>(move |this, cx| {
280 let new_dock_position = this.position(cx);
281 if new_dock_position != old_dock_position {
282 old_dock_position = new_dock_position;
283 cx.emit(Event::DockPositionChanged);
284 }
285 })
286 .detach();
287
288 this
289 });
290
291 cx.subscribe(&project_panel, {
292 let project_panel = project_panel.downgrade();
293 move |workspace, _, event, cx| match event {
294 &Event::OpenedEntry {
295 entry_id,
296 focus_opened_item,
297 } => {
298 if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
299 if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
300 workspace
301 .open_path(
302 ProjectPath {
303 worktree_id: worktree.read(cx).id(),
304 path: entry.path.clone(),
305 },
306 None,
307 focus_opened_item,
308 cx,
309 )
310 .detach_and_log_err(cx);
311 if !focus_opened_item {
312 if let Some(project_panel) = project_panel.upgrade(cx) {
313 cx.focus(&project_panel);
314 }
315 }
316 }
317 }
318 }
319 &Event::SplitEntry { entry_id } => {
320 if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
321 if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
322 workspace
323 .split_path(
324 ProjectPath {
325 worktree_id: worktree.read(cx).id(),
326 path: entry.path.clone(),
327 },
328 cx,
329 )
330 .detach_and_log_err(cx);
331 }
332 }
333 }
334 _ => {}
335 }
336 })
337 .detach();
338
339 project_panel
340 }
341
342 pub fn load(
343 workspace: WeakViewHandle<Workspace>,
344 cx: AsyncAppContext,
345 ) -> Task<Result<ViewHandle<Self>>> {
346 cx.spawn(|mut cx| async move {
347 let serialized_panel = if let Some(panel) = cx
348 .background()
349 .spawn(async move { KEY_VALUE_STORE.read_kvp(PROJECT_PANEL_KEY) })
350 .await
351 .log_err()
352 .flatten()
353 {
354 Some(serde_json::from_str::<SerializedProjectPanel>(&panel)?)
355 } else {
356 None
357 };
358 workspace.update(&mut cx, |workspace, cx| {
359 let panel = ProjectPanel::new(workspace, cx);
360 if let Some(serialized_panel) = serialized_panel {
361 panel.update(cx, |panel, cx| {
362 panel.width = serialized_panel.width;
363 cx.notify();
364 });
365 }
366 panel
367 })
368 })
369 }
370
371 fn serialize(&mut self, cx: &mut ViewContext<Self>) {
372 let width = self.width;
373 self.pending_serialization = cx.background().spawn(
374 async move {
375 KEY_VALUE_STORE
376 .write_kvp(
377 PROJECT_PANEL_KEY.into(),
378 serde_json::to_string(&SerializedProjectPanel { width })?,
379 )
380 .await?;
381 anyhow::Ok(())
382 }
383 .log_err(),
384 );
385 }
386
387 fn deploy_context_menu(
388 &mut self,
389 position: Vector2F,
390 entry_id: ProjectEntryId,
391 cx: &mut ViewContext<Self>,
392 ) {
393 let project = self.project.read(cx);
394
395 let worktree_id = if let Some(id) = project.worktree_id_for_entry(entry_id, cx) {
396 id
397 } else {
398 return;
399 };
400
401 self.selection = Some(Selection {
402 worktree_id,
403 entry_id,
404 });
405
406 let mut menu_entries = Vec::new();
407 if let Some((worktree, entry)) = self.selected_entry(cx) {
408 let is_root = Some(entry) == worktree.root_entry();
409 if !project.is_remote() {
410 menu_entries.push(ContextMenuItem::action(
411 "Add Folder to Project",
412 workspace::AddFolderToProject,
413 ));
414 if is_root {
415 let project = self.project.clone();
416 menu_entries.push(ContextMenuItem::handler("Remove from Project", move |cx| {
417 project.update(cx, |project, cx| project.remove_worktree(worktree_id, cx));
418 }));
419 }
420 }
421 menu_entries.push(ContextMenuItem::action("New File", NewFile));
422 menu_entries.push(ContextMenuItem::action("New Folder", NewDirectory));
423 menu_entries.push(ContextMenuItem::Separator);
424 menu_entries.push(ContextMenuItem::action("Cut", Cut));
425 menu_entries.push(ContextMenuItem::action("Copy", Copy));
426 menu_entries.push(ContextMenuItem::Separator);
427 menu_entries.push(ContextMenuItem::action("Copy Path", CopyPath));
428 menu_entries.push(ContextMenuItem::action(
429 "Copy Relative Path",
430 CopyRelativePath,
431 ));
432 menu_entries.push(ContextMenuItem::action("Reveal in Finder", RevealInFinder));
433 if entry.is_dir() {
434 menu_entries.push(ContextMenuItem::action(
435 "Search Inside",
436 NewSearchInDirectory,
437 ));
438 }
439 if let Some(clipboard_entry) = self.clipboard_entry {
440 if clipboard_entry.worktree_id() == worktree.id() {
441 menu_entries.push(ContextMenuItem::action("Paste", Paste));
442 }
443 }
444 menu_entries.push(ContextMenuItem::Separator);
445 menu_entries.push(ContextMenuItem::action("Rename", Rename));
446 if !is_root {
447 menu_entries.push(ContextMenuItem::action("Delete", Delete));
448 }
449 }
450
451 self.context_menu.update(cx, |menu, cx| {
452 menu.show(position, AnchorCorner::TopLeft, menu_entries, cx);
453 });
454
455 cx.notify();
456 }
457
458 fn expand_selected_entry(&mut self, _: &ExpandSelectedEntry, cx: &mut ViewContext<Self>) {
459 if let Some((worktree, entry)) = self.selected_entry(cx) {
460 if entry.is_dir() {
461 let worktree_id = worktree.id();
462 let entry_id = entry.id;
463 let expanded_dir_ids =
464 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
465 expanded_dir_ids
466 } else {
467 return;
468 };
469
470 match expanded_dir_ids.binary_search(&entry_id) {
471 Ok(_) => self.select_next(&SelectNext, cx),
472 Err(ix) => {
473 self.project.update(cx, |project, cx| {
474 project.expand_entry(worktree_id, entry_id, cx);
475 });
476
477 expanded_dir_ids.insert(ix, entry_id);
478 self.update_visible_entries(None, cx);
479 cx.notify();
480 }
481 }
482 }
483 }
484 }
485
486 fn collapse_selected_entry(&mut self, _: &CollapseSelectedEntry, cx: &mut ViewContext<Self>) {
487 if let Some((worktree, mut entry)) = self.selected_entry(cx) {
488 let worktree_id = worktree.id();
489 let expanded_dir_ids =
490 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
491 expanded_dir_ids
492 } else {
493 return;
494 };
495
496 loop {
497 let entry_id = entry.id;
498 match expanded_dir_ids.binary_search(&entry_id) {
499 Ok(ix) => {
500 expanded_dir_ids.remove(ix);
501 self.update_visible_entries(Some((worktree_id, entry_id)), cx);
502 cx.notify();
503 break;
504 }
505 Err(_) => {
506 if let Some(parent_entry) =
507 entry.path.parent().and_then(|p| worktree.entry_for_path(p))
508 {
509 entry = parent_entry;
510 } else {
511 break;
512 }
513 }
514 }
515 }
516 }
517 }
518
519 pub fn collapse_all_entries(&mut self, _: &CollapseAllEntries, cx: &mut ViewContext<Self>) {
520 self.expanded_dir_ids.clear();
521 self.update_visible_entries(None, cx);
522 cx.notify();
523 }
524
525 fn toggle_expanded(&mut self, entry_id: ProjectEntryId, cx: &mut ViewContext<Self>) {
526 if let Some(worktree_id) = self.project.read(cx).worktree_id_for_entry(entry_id, cx) {
527 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
528 self.project.update(cx, |project, cx| {
529 match expanded_dir_ids.binary_search(&entry_id) {
530 Ok(ix) => {
531 expanded_dir_ids.remove(ix);
532 }
533 Err(ix) => {
534 project.expand_entry(worktree_id, entry_id, cx);
535 expanded_dir_ids.insert(ix, entry_id);
536 }
537 }
538 });
539 self.update_visible_entries(Some((worktree_id, entry_id)), cx);
540 cx.focus_self();
541 cx.notify();
542 }
543 }
544 }
545
546 fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
547 if let Some(selection) = self.selection {
548 let (mut worktree_ix, mut entry_ix, _) =
549 self.index_for_selection(selection).unwrap_or_default();
550 if entry_ix > 0 {
551 entry_ix -= 1;
552 } else if worktree_ix > 0 {
553 worktree_ix -= 1;
554 entry_ix = self.visible_entries[worktree_ix].1.len() - 1;
555 } else {
556 return;
557 }
558
559 let (worktree_id, worktree_entries) = &self.visible_entries[worktree_ix];
560 self.selection = Some(Selection {
561 worktree_id: *worktree_id,
562 entry_id: worktree_entries[entry_ix].id,
563 });
564 self.autoscroll(cx);
565 cx.notify();
566 } else {
567 self.select_first(cx);
568 }
569 }
570
571 fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
572 if let Some(task) = self.confirm_edit(cx) {
573 return Some(task);
574 }
575
576 None
577 }
578
579 fn open_file(&mut self, _: &Open, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
580 if let Some((_, entry)) = self.selected_entry(cx) {
581 if entry.is_file() {
582 self.open_entry(entry.id, true, cx);
583 }
584 }
585
586 None
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();
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 let 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 Ok(())
659 }))
660 }
661
662 fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
663 self.edit_state = None;
664 self.update_visible_entries(None, cx);
665 cx.focus_self();
666 cx.notify();
667 }
668
669 fn open_entry(
670 &mut self,
671 entry_id: ProjectEntryId,
672 focus_opened_item: bool,
673 cx: &mut ViewContext<Self>,
674 ) {
675 cx.emit(Event::OpenedEntry {
676 entry_id,
677 focus_opened_item,
678 });
679 }
680
681 fn split_entry(&mut self, entry_id: ProjectEntryId, cx: &mut ViewContext<Self>) {
682 cx.emit(Event::SplitEntry { entry_id });
683 }
684
685 fn new_file(&mut self, _: &NewFile, cx: &mut ViewContext<Self>) {
686 self.add_entry(false, cx)
687 }
688
689 fn new_directory(&mut self, _: &NewDirectory, cx: &mut ViewContext<Self>) {
690 self.add_entry(true, cx)
691 }
692
693 fn add_entry(&mut self, is_dir: bool, cx: &mut ViewContext<Self>) {
694 if let Some(Selection {
695 worktree_id,
696 entry_id,
697 }) = self.selection
698 {
699 let directory_id;
700 if let Some((worktree, expanded_dir_ids)) = self
701 .project
702 .read(cx)
703 .worktree_for_id(worktree_id, cx)
704 .zip(self.expanded_dir_ids.get_mut(&worktree_id))
705 {
706 let worktree = worktree.read(cx);
707 if let Some(mut entry) = worktree.entry_for_id(entry_id) {
708 loop {
709 if entry.is_dir() {
710 if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
711 expanded_dir_ids.insert(ix, entry.id);
712 }
713 directory_id = entry.id;
714 break;
715 } else {
716 if let Some(parent_path) = entry.path.parent() {
717 if let Some(parent_entry) = worktree.entry_for_path(parent_path) {
718 entry = parent_entry;
719 continue;
720 }
721 }
722 return;
723 }
724 }
725 } else {
726 return;
727 };
728 } else {
729 return;
730 };
731
732 self.edit_state = Some(EditState {
733 worktree_id,
734 entry_id: directory_id,
735 is_new_entry: true,
736 is_dir,
737 processing_filename: None,
738 });
739 self.filename_editor
740 .update(cx, |editor, cx| editor.clear(cx));
741 cx.focus(&self.filename_editor);
742 self.update_visible_entries(Some((worktree_id, NEW_ENTRY_ID)), cx);
743 self.autoscroll(cx);
744 cx.notify();
745 }
746 }
747
748 fn rename(&mut self, _: &Rename, cx: &mut ViewContext<Self>) {
749 if let Some(Selection {
750 worktree_id,
751 entry_id,
752 }) = self.selection
753 {
754 if let Some(worktree) = self.project.read(cx).worktree_for_id(worktree_id, cx) {
755 if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
756 self.edit_state = Some(EditState {
757 worktree_id,
758 entry_id,
759 is_new_entry: false,
760 is_dir: entry.is_dir(),
761 processing_filename: None,
762 });
763 let file_name = entry
764 .path
765 .file_name()
766 .map(|s| s.to_string_lossy())
767 .unwrap_or_default()
768 .to_string();
769 let file_stem = entry.path.file_stem().map(|s| s.to_string_lossy());
770 let selection_end =
771 file_stem.map_or(file_name.len(), |file_stem| file_stem.len());
772 self.filename_editor.update(cx, |editor, cx| {
773 editor.set_text(file_name, cx);
774 editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
775 s.select_ranges([0..selection_end])
776 })
777 });
778 cx.focus(&self.filename_editor);
779 self.update_visible_entries(None, cx);
780 self.autoscroll(cx);
781 cx.notify();
782 }
783 }
784
785 cx.update_global(|drag_and_drop: &mut DragAndDrop<Workspace>, cx| {
786 drag_and_drop.cancel_dragging::<ProjectEntryId>(cx);
787 })
788 }
789 }
790
791 fn delete(&mut self, _: &Delete, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
792 let Selection { entry_id, .. } = self.selection?;
793 let path = self.project.read(cx).path_for_entry(entry_id, cx)?.path;
794 let file_name = path.file_name()?;
795
796 let mut answer = cx.prompt(
797 PromptLevel::Info,
798 &format!("Delete {file_name:?}?"),
799 &["Delete", "Cancel"],
800 );
801 Some(cx.spawn(|this, mut cx| async move {
802 if answer.next().await != Some(0) {
803 return Ok(());
804 }
805 this.update(&mut cx, |this, cx| {
806 this.project
807 .update(cx, |project, cx| project.delete_entry(entry_id, cx))
808 .ok_or_else(|| anyhow!("no such entry"))
809 })??
810 .await
811 }))
812 }
813
814 fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
815 if let Some(selection) = self.selection {
816 let (mut worktree_ix, mut entry_ix, _) =
817 self.index_for_selection(selection).unwrap_or_default();
818 if let Some((_, worktree_entries)) = self.visible_entries.get(worktree_ix) {
819 if entry_ix + 1 < worktree_entries.len() {
820 entry_ix += 1;
821 } else {
822 worktree_ix += 1;
823 entry_ix = 0;
824 }
825 }
826
827 if let Some((worktree_id, worktree_entries)) = self.visible_entries.get(worktree_ix) {
828 if let Some(entry) = worktree_entries.get(entry_ix) {
829 self.selection = Some(Selection {
830 worktree_id: *worktree_id,
831 entry_id: entry.id,
832 });
833 self.autoscroll(cx);
834 cx.notify();
835 }
836 }
837 } else {
838 self.select_first(cx);
839 }
840 }
841
842 fn select_first(&mut self, cx: &mut ViewContext<Self>) {
843 let worktree = self
844 .visible_entries
845 .first()
846 .and_then(|(worktree_id, _)| self.project.read(cx).worktree_for_id(*worktree_id, cx));
847 if let Some(worktree) = worktree {
848 let worktree = worktree.read(cx);
849 let worktree_id = worktree.id();
850 if let Some(root_entry) = worktree.root_entry() {
851 self.selection = Some(Selection {
852 worktree_id,
853 entry_id: root_entry.id,
854 });
855 self.autoscroll(cx);
856 cx.notify();
857 }
858 }
859 }
860
861 fn autoscroll(&mut self, cx: &mut ViewContext<Self>) {
862 if let Some((_, _, index)) = self.selection.and_then(|s| self.index_for_selection(s)) {
863 self.list.scroll_to(ScrollTarget::Show(index));
864 cx.notify();
865 }
866 }
867
868 fn cut(&mut self, _: &Cut, cx: &mut ViewContext<Self>) {
869 if let Some((worktree, entry)) = self.selected_entry(cx) {
870 self.clipboard_entry = Some(ClipboardEntry::Cut {
871 worktree_id: worktree.id(),
872 entry_id: entry.id,
873 });
874 cx.notify();
875 }
876 }
877
878 fn copy(&mut self, _: &Copy, cx: &mut ViewContext<Self>) {
879 if let Some((worktree, entry)) = self.selected_entry(cx) {
880 self.clipboard_entry = Some(ClipboardEntry::Copied {
881 worktree_id: worktree.id(),
882 entry_id: entry.id,
883 });
884 cx.notify();
885 }
886 }
887
888 fn paste(&mut self, _: &Paste, cx: &mut ViewContext<Self>) -> Option<()> {
889 if let Some((worktree, entry)) = self.selected_entry(cx) {
890 let clipboard_entry = self.clipboard_entry?;
891 if clipboard_entry.worktree_id() != worktree.id() {
892 return None;
893 }
894
895 let clipboard_entry_file_name = self
896 .project
897 .read(cx)
898 .path_for_entry(clipboard_entry.entry_id(), cx)?
899 .path
900 .file_name()?
901 .to_os_string();
902
903 let mut new_path = entry.path.to_path_buf();
904 if entry.is_file() {
905 new_path.pop();
906 }
907
908 new_path.push(&clipboard_entry_file_name);
909 let extension = new_path.extension().map(|e| e.to_os_string());
910 let file_name_without_extension = Path::new(&clipboard_entry_file_name).file_stem()?;
911 let mut ix = 0;
912 while worktree.entry_for_path(&new_path).is_some() {
913 new_path.pop();
914
915 let mut new_file_name = file_name_without_extension.to_os_string();
916 new_file_name.push(" copy");
917 if ix > 0 {
918 new_file_name.push(format!(" {}", ix));
919 }
920 if let Some(extension) = extension.as_ref() {
921 new_file_name.push(".");
922 new_file_name.push(extension);
923 }
924
925 new_path.push(new_file_name);
926 ix += 1;
927 }
928
929 if clipboard_entry.is_cut() {
930 if let Some(task) = self.project.update(cx, |project, cx| {
931 project.rename_entry(clipboard_entry.entry_id(), new_path, cx)
932 }) {
933 task.detach_and_log_err(cx)
934 }
935 } else if let Some(task) = self.project.update(cx, |project, cx| {
936 project.copy_entry(clipboard_entry.entry_id(), new_path, cx)
937 }) {
938 task.detach_and_log_err(cx)
939 }
940 }
941 None
942 }
943
944 fn copy_path(&mut self, _: &CopyPath, cx: &mut ViewContext<Self>) {
945 if let Some((worktree, entry)) = self.selected_entry(cx) {
946 cx.write_to_clipboard(ClipboardItem::new(
947 worktree
948 .abs_path()
949 .join(&entry.path)
950 .to_string_lossy()
951 .to_string(),
952 ));
953 }
954 }
955
956 fn copy_relative_path(&mut self, _: &CopyRelativePath, cx: &mut ViewContext<Self>) {
957 if let Some((_, entry)) = self.selected_entry(cx) {
958 cx.write_to_clipboard(ClipboardItem::new(entry.path.to_string_lossy().to_string()));
959 }
960 }
961
962 fn reveal_in_finder(&mut self, _: &RevealInFinder, cx: &mut ViewContext<Self>) {
963 if let Some((worktree, entry)) = self.selected_entry(cx) {
964 cx.reveal_path(&worktree.abs_path().join(&entry.path));
965 }
966 }
967
968 pub fn new_search_in_directory(
969 &mut self,
970 _: &NewSearchInDirectory,
971 cx: &mut ViewContext<Self>,
972 ) {
973 if let Some((_, entry)) = self.selected_entry(cx) {
974 if entry.is_dir() {
975 cx.emit(Event::NewSearchInDirectory {
976 dir_entry: entry.clone(),
977 });
978 }
979 }
980 }
981
982 fn move_entry(
983 &mut self,
984 entry_to_move: ProjectEntryId,
985 destination: ProjectEntryId,
986 destination_is_file: bool,
987 cx: &mut ViewContext<Self>,
988 ) {
989 let destination_worktree = self.project.update(cx, |project, cx| {
990 let entry_path = project.path_for_entry(entry_to_move, cx)?;
991 let destination_entry_path = project.path_for_entry(destination, cx)?.path.clone();
992
993 let mut destination_path = destination_entry_path.as_ref();
994 if destination_is_file {
995 destination_path = destination_path.parent()?;
996 }
997
998 let mut new_path = destination_path.to_path_buf();
999 new_path.push(entry_path.path.file_name()?);
1000 if new_path != entry_path.path.as_ref() {
1001 let task = project.rename_entry(entry_to_move, new_path, cx)?;
1002 cx.foreground().spawn(task).detach_and_log_err(cx);
1003 }
1004
1005 Some(project.worktree_id_for_entry(destination, cx)?)
1006 });
1007
1008 if let Some(destination_worktree) = destination_worktree {
1009 self.expand_entry(destination_worktree, destination, cx);
1010 }
1011 }
1012
1013 fn index_for_selection(&self, selection: Selection) -> Option<(usize, usize, usize)> {
1014 let mut entry_index = 0;
1015 let mut visible_entries_index = 0;
1016 for (worktree_index, (worktree_id, worktree_entries)) in
1017 self.visible_entries.iter().enumerate()
1018 {
1019 if *worktree_id == selection.worktree_id {
1020 for entry in worktree_entries {
1021 if entry.id == selection.entry_id {
1022 return Some((worktree_index, entry_index, visible_entries_index));
1023 } else {
1024 visible_entries_index += 1;
1025 entry_index += 1;
1026 }
1027 }
1028 break;
1029 } else {
1030 visible_entries_index += worktree_entries.len();
1031 }
1032 }
1033 None
1034 }
1035
1036 pub fn selected_entry<'a>(
1037 &self,
1038 cx: &'a AppContext,
1039 ) -> Option<(&'a Worktree, &'a project::Entry)> {
1040 let (worktree, entry) = self.selected_entry_handle(cx)?;
1041 Some((worktree.read(cx), entry))
1042 }
1043
1044 fn selected_entry_handle<'a>(
1045 &self,
1046 cx: &'a AppContext,
1047 ) -> Option<(ModelHandle<Worktree>, &'a project::Entry)> {
1048 let selection = self.selection?;
1049 let project = self.project.read(cx);
1050 let worktree = project.worktree_for_id(selection.worktree_id, cx)?;
1051 let entry = worktree.read(cx).entry_for_id(selection.entry_id)?;
1052 Some((worktree, entry))
1053 }
1054
1055 fn expand_to_selection(&mut self, cx: &mut ViewContext<Self>) -> Option<()> {
1056 let (worktree, entry) = self.selected_entry(cx)?;
1057 let expanded_dir_ids = self.expanded_dir_ids.entry(worktree.id()).or_default();
1058
1059 for path in entry.path.ancestors() {
1060 let Some(entry) = worktree.entry_for_path(path) else {
1061 continue;
1062 };
1063 if entry.is_dir() {
1064 if let Err(idx) = expanded_dir_ids.binary_search(&entry.id) {
1065 expanded_dir_ids.insert(idx, entry.id);
1066 }
1067 }
1068 }
1069
1070 Some(())
1071 }
1072
1073 fn update_visible_entries(
1074 &mut self,
1075 new_selected_entry: Option<(WorktreeId, ProjectEntryId)>,
1076 cx: &mut ViewContext<Self>,
1077 ) {
1078 let project = self.project.read(cx);
1079 self.last_worktree_root_id = project
1080 .visible_worktrees(cx)
1081 .rev()
1082 .next()
1083 .and_then(|worktree| worktree.read(cx).root_entry())
1084 .map(|entry| entry.id);
1085
1086 self.visible_entries.clear();
1087 for worktree in project.visible_worktrees(cx) {
1088 let snapshot = worktree.read(cx).snapshot();
1089 let worktree_id = snapshot.id();
1090
1091 let expanded_dir_ids = match self.expanded_dir_ids.entry(worktree_id) {
1092 hash_map::Entry::Occupied(e) => e.into_mut(),
1093 hash_map::Entry::Vacant(e) => {
1094 // The first time a worktree's root entry becomes available,
1095 // mark that root entry as expanded.
1096 if let Some(entry) = snapshot.root_entry() {
1097 e.insert(vec![entry.id]).as_slice()
1098 } else {
1099 &[]
1100 }
1101 }
1102 };
1103
1104 let mut new_entry_parent_id = None;
1105 let mut new_entry_kind = EntryKind::Dir;
1106 if let Some(edit_state) = &self.edit_state {
1107 if edit_state.worktree_id == worktree_id && edit_state.is_new_entry {
1108 new_entry_parent_id = Some(edit_state.entry_id);
1109 new_entry_kind = if edit_state.is_dir {
1110 EntryKind::Dir
1111 } else {
1112 EntryKind::File(Default::default())
1113 };
1114 }
1115 }
1116
1117 let mut visible_worktree_entries = Vec::new();
1118 let mut entry_iter = snapshot.entries(true);
1119
1120 while let Some(entry) = entry_iter.entry() {
1121 visible_worktree_entries.push(entry.clone());
1122 if Some(entry.id) == new_entry_parent_id {
1123 visible_worktree_entries.push(Entry {
1124 id: NEW_ENTRY_ID,
1125 kind: new_entry_kind,
1126 path: entry.path.join("\0").into(),
1127 inode: 0,
1128 mtime: entry.mtime,
1129 is_symlink: false,
1130 is_ignored: false,
1131 is_external: false,
1132 git_status: entry.git_status,
1133 });
1134 }
1135 if expanded_dir_ids.binary_search(&entry.id).is_err()
1136 && entry_iter.advance_to_sibling()
1137 {
1138 continue;
1139 }
1140 entry_iter.advance();
1141 }
1142
1143 snapshot.propagate_git_statuses(&mut visible_worktree_entries);
1144
1145 visible_worktree_entries.sort_by(|entry_a, entry_b| {
1146 let mut components_a = entry_a.path.components().peekable();
1147 let mut components_b = entry_b.path.components().peekable();
1148 loop {
1149 match (components_a.next(), components_b.next()) {
1150 (Some(component_a), Some(component_b)) => {
1151 let a_is_file = components_a.peek().is_none() && entry_a.is_file();
1152 let b_is_file = components_b.peek().is_none() && entry_b.is_file();
1153 let ordering = a_is_file.cmp(&b_is_file).then_with(|| {
1154 let name_a =
1155 UniCase::new(component_a.as_os_str().to_string_lossy());
1156 let name_b =
1157 UniCase::new(component_b.as_os_str().to_string_lossy());
1158 name_a.cmp(&name_b)
1159 });
1160 if !ordering.is_eq() {
1161 return ordering;
1162 }
1163 }
1164 (Some(_), None) => break Ordering::Greater,
1165 (None, Some(_)) => break Ordering::Less,
1166 (None, None) => break Ordering::Equal,
1167 }
1168 }
1169 });
1170 self.visible_entries
1171 .push((worktree_id, visible_worktree_entries));
1172 }
1173
1174 if let Some((worktree_id, entry_id)) = new_selected_entry {
1175 self.selection = Some(Selection {
1176 worktree_id,
1177 entry_id,
1178 });
1179 }
1180 }
1181
1182 fn expand_entry(
1183 &mut self,
1184 worktree_id: WorktreeId,
1185 entry_id: ProjectEntryId,
1186 cx: &mut ViewContext<Self>,
1187 ) {
1188 self.project.update(cx, |project, cx| {
1189 if let Some((worktree, expanded_dir_ids)) = project
1190 .worktree_for_id(worktree_id, cx)
1191 .zip(self.expanded_dir_ids.get_mut(&worktree_id))
1192 {
1193 project.expand_entry(worktree_id, entry_id, cx);
1194 let worktree = worktree.read(cx);
1195
1196 if let Some(mut entry) = worktree.entry_for_id(entry_id) {
1197 loop {
1198 if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
1199 expanded_dir_ids.insert(ix, entry.id);
1200 }
1201
1202 if let Some(parent_entry) =
1203 entry.path.parent().and_then(|p| worktree.entry_for_path(p))
1204 {
1205 entry = parent_entry;
1206 } else {
1207 break;
1208 }
1209 }
1210 }
1211 }
1212 });
1213 }
1214
1215 fn for_each_visible_entry(
1216 &self,
1217 range: Range<usize>,
1218 cx: &mut ViewContext<ProjectPanel>,
1219 mut callback: impl FnMut(ProjectEntryId, EntryDetails, &mut ViewContext<ProjectPanel>),
1220 ) {
1221 let mut ix = 0;
1222 for (worktree_id, visible_worktree_entries) in &self.visible_entries {
1223 if ix >= range.end {
1224 return;
1225 }
1226
1227 if ix + visible_worktree_entries.len() <= range.start {
1228 ix += visible_worktree_entries.len();
1229 continue;
1230 }
1231
1232 let end_ix = range.end.min(ix + visible_worktree_entries.len());
1233 let (git_status_setting, show_file_icons, show_folder_icons) = {
1234 let settings = settings::get::<ProjectPanelSettings>(cx);
1235 (
1236 settings.git_status,
1237 settings.file_icons,
1238 settings.folder_icons,
1239 )
1240 };
1241 if let Some(worktree) = self.project.read(cx).worktree_for_id(*worktree_id, cx) {
1242 let snapshot = worktree.read(cx).snapshot();
1243 let root_name = OsStr::new(snapshot.root_name());
1244 let expanded_entry_ids = self
1245 .expanded_dir_ids
1246 .get(&snapshot.id())
1247 .map(Vec::as_slice)
1248 .unwrap_or(&[]);
1249
1250 let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
1251 for entry in visible_worktree_entries[entry_range].iter() {
1252 let status = git_status_setting.then(|| entry.git_status).flatten();
1253 let is_expanded = expanded_entry_ids.binary_search(&entry.id).is_ok();
1254 let icon = match entry.kind {
1255 EntryKind::File(_) => {
1256 if show_file_icons {
1257 Some(FileAssociations::get_icon(&entry.path, cx))
1258 } else {
1259 None
1260 }
1261 }
1262 _ => {
1263 if show_folder_icons {
1264 Some(FileAssociations::get_folder_icon(is_expanded, cx))
1265 } else {
1266 Some(FileAssociations::get_chevron_icon(is_expanded, cx))
1267 }
1268 }
1269 };
1270
1271 let mut details = EntryDetails {
1272 filename: entry
1273 .path
1274 .file_name()
1275 .unwrap_or(root_name)
1276 .to_string_lossy()
1277 .to_string(),
1278 icon,
1279 path: entry.path.clone(),
1280 depth: entry.path.components().count(),
1281 kind: entry.kind,
1282 is_ignored: entry.is_ignored,
1283 is_expanded,
1284 is_selected: self.selection.map_or(false, |e| {
1285 e.worktree_id == snapshot.id() && e.entry_id == entry.id
1286 }),
1287 is_editing: false,
1288 is_processing: false,
1289 is_cut: self
1290 .clipboard_entry
1291 .map_or(false, |e| e.is_cut() && e.entry_id() == entry.id),
1292 git_status: status,
1293 };
1294
1295 if let Some(edit_state) = &self.edit_state {
1296 let is_edited_entry = if edit_state.is_new_entry {
1297 entry.id == NEW_ENTRY_ID
1298 } else {
1299 entry.id == edit_state.entry_id
1300 };
1301
1302 if is_edited_entry {
1303 if let Some(processing_filename) = &edit_state.processing_filename {
1304 details.is_processing = true;
1305 details.filename.clear();
1306 details.filename.push_str(processing_filename);
1307 } else {
1308 if edit_state.is_new_entry {
1309 details.filename.clear();
1310 }
1311 details.is_editing = true;
1312 }
1313 }
1314 }
1315
1316 callback(entry.id, details, cx);
1317 }
1318 }
1319 ix = end_ix;
1320 }
1321 }
1322
1323 fn render_entry_visual_element<V: View>(
1324 details: &EntryDetails,
1325 editor: Option<&ViewHandle<Editor>>,
1326 padding: f32,
1327 row_container_style: ContainerStyle,
1328 style: &ProjectPanelEntry,
1329 cx: &mut ViewContext<V>,
1330 ) -> AnyElement<V> {
1331 let show_editor = details.is_editing && !details.is_processing;
1332
1333 let mut filename_text_style = style.text.clone();
1334 filename_text_style.color = details
1335 .git_status
1336 .as_ref()
1337 .map(|status| match status {
1338 GitFileStatus::Added => style.status.git.inserted,
1339 GitFileStatus::Modified => style.status.git.modified,
1340 GitFileStatus::Conflict => style.status.git.conflict,
1341 })
1342 .unwrap_or(style.text.color);
1343
1344 Flex::row()
1345 .with_child(if let Some(icon) = &details.icon {
1346 Svg::new(icon.to_string())
1347 .with_color(style.icon_color)
1348 .constrained()
1349 .with_max_width(style.icon_size)
1350 .with_max_height(style.icon_size)
1351 .aligned()
1352 .constrained()
1353 .with_width(style.icon_size)
1354 } else {
1355 Empty::new()
1356 .constrained()
1357 .with_max_width(style.icon_size)
1358 .with_max_height(style.icon_size)
1359 .aligned()
1360 .constrained()
1361 .with_width(style.icon_size)
1362 })
1363 .with_child(if show_editor && editor.is_some() {
1364 ChildView::new(editor.as_ref().unwrap(), cx)
1365 .contained()
1366 .with_margin_left(style.icon_spacing)
1367 .aligned()
1368 .left()
1369 .flex(1.0, true)
1370 .into_any()
1371 } else {
1372 Label::new(details.filename.clone(), filename_text_style)
1373 .contained()
1374 .with_margin_left(style.icon_spacing)
1375 .aligned()
1376 .left()
1377 .into_any()
1378 })
1379 .constrained()
1380 .with_height(style.height)
1381 .contained()
1382 .with_style(row_container_style)
1383 .with_padding_left(padding)
1384 .into_any_named("project panel entry visual element")
1385 }
1386
1387 fn render_entry(
1388 entry_id: ProjectEntryId,
1389 details: EntryDetails,
1390 editor: &ViewHandle<Editor>,
1391 dragged_entry_destination: &mut Option<Arc<Path>>,
1392 theme: &theme::ProjectPanel,
1393 cx: &mut ViewContext<Self>,
1394 ) -> AnyElement<Self> {
1395 let kind = details.kind;
1396 let path = details.path.clone();
1397 let settings = settings::get::<ProjectPanelSettings>(cx);
1398 let padding = theme.container.padding.left + details.depth as f32 * settings.indent_size;
1399
1400 let entry_style = if details.is_cut {
1401 &theme.cut_entry
1402 } else if details.is_ignored {
1403 &theme.ignored_entry
1404 } else {
1405 &theme.entry
1406 };
1407
1408 let show_editor = details.is_editing && !details.is_processing;
1409
1410 MouseEventHandler::<Self, _>::new(entry_id.to_usize(), cx, |state, cx| {
1411 let mut style = entry_style
1412 .in_state(details.is_selected)
1413 .style_for(state)
1414 .clone();
1415
1416 if cx
1417 .global::<DragAndDrop<Workspace>>()
1418 .currently_dragged::<ProjectEntryId>(cx.window_id())
1419 .is_some()
1420 && dragged_entry_destination
1421 .as_ref()
1422 .filter(|destination| details.path.starts_with(destination))
1423 .is_some()
1424 {
1425 style = entry_style.active_state().default.clone();
1426 }
1427
1428 let row_container_style = if show_editor {
1429 theme.filename_editor.container
1430 } else {
1431 style.container
1432 };
1433
1434 Self::render_entry_visual_element(
1435 &details,
1436 Some(editor),
1437 padding,
1438 row_container_style,
1439 &style,
1440 cx,
1441 )
1442 })
1443 .on_click(MouseButton::Left, move |event, this, cx| {
1444 if !show_editor {
1445 if kind.is_dir() {
1446 this.toggle_expanded(entry_id, cx);
1447 } else {
1448 if event.cmd {
1449 this.split_entry(entry_id, cx);
1450 } else if !event.cmd {
1451 this.open_entry(entry_id, event.click_count > 1, cx);
1452 }
1453 }
1454 }
1455 })
1456 .on_down(MouseButton::Right, move |event, this, cx| {
1457 this.deploy_context_menu(event.position, entry_id, cx);
1458 })
1459 .on_up(MouseButton::Left, move |_, this, cx| {
1460 if let Some((_, dragged_entry)) = cx
1461 .global::<DragAndDrop<Workspace>>()
1462 .currently_dragged::<ProjectEntryId>(cx.window_id())
1463 {
1464 this.move_entry(
1465 *dragged_entry,
1466 entry_id,
1467 matches!(details.kind, EntryKind::File(_)),
1468 cx,
1469 );
1470 }
1471 })
1472 .on_move(move |_, this, cx| {
1473 if cx
1474 .global::<DragAndDrop<Workspace>>()
1475 .currently_dragged::<ProjectEntryId>(cx.window_id())
1476 .is_some()
1477 {
1478 this.dragged_entry_destination = if matches!(kind, EntryKind::File(_)) {
1479 path.parent().map(|parent| Arc::from(parent))
1480 } else {
1481 Some(path.clone())
1482 };
1483 }
1484 })
1485 .as_draggable(entry_id, {
1486 let row_container_style = theme.dragged_entry.container;
1487
1488 move |_, cx: &mut ViewContext<Workspace>| {
1489 let theme = theme::current(cx).clone();
1490 Self::render_entry_visual_element(
1491 &details,
1492 None,
1493 padding,
1494 row_container_style,
1495 &theme.project_panel.dragged_entry,
1496 cx,
1497 )
1498 }
1499 })
1500 .with_cursor_style(CursorStyle::PointingHand)
1501 .into_any_named("project panel entry")
1502 }
1503}
1504
1505impl View for ProjectPanel {
1506 fn ui_name() -> &'static str {
1507 "ProjectPanel"
1508 }
1509
1510 fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> gpui::AnyElement<Self> {
1511 enum ProjectPanel {}
1512 let theme = &theme::current(cx).project_panel;
1513 let mut container_style = theme.container;
1514 let padding = std::mem::take(&mut container_style.padding);
1515 let last_worktree_root_id = self.last_worktree_root_id;
1516
1517 let has_worktree = self.visible_entries.len() != 0;
1518
1519 if has_worktree {
1520 Stack::new()
1521 .with_child(
1522 MouseEventHandler::<ProjectPanel, _>::new(0, cx, |_, cx| {
1523 UniformList::new(
1524 self.list.clone(),
1525 self.visible_entries
1526 .iter()
1527 .map(|(_, worktree_entries)| worktree_entries.len())
1528 .sum(),
1529 cx,
1530 move |this, range, items, cx| {
1531 let theme = theme::current(cx).clone();
1532 let mut dragged_entry_destination =
1533 this.dragged_entry_destination.clone();
1534 this.for_each_visible_entry(range, cx, |id, details, cx| {
1535 items.push(Self::render_entry(
1536 id,
1537 details,
1538 &this.filename_editor,
1539 &mut dragged_entry_destination,
1540 &theme.project_panel,
1541 cx,
1542 ));
1543 });
1544 this.dragged_entry_destination = dragged_entry_destination;
1545 },
1546 )
1547 .with_padding_top(padding.top)
1548 .with_padding_bottom(padding.bottom)
1549 .contained()
1550 .with_style(container_style)
1551 .expanded()
1552 })
1553 .on_down(MouseButton::Right, move |event, this, cx| {
1554 // When deploying the context menu anywhere below the last project entry,
1555 // act as if the user clicked the root of the last worktree.
1556 if let Some(entry_id) = last_worktree_root_id {
1557 this.deploy_context_menu(event.position, entry_id, cx);
1558 }
1559 }),
1560 )
1561 .with_child(ChildView::new(&self.context_menu, cx))
1562 .into_any_named("project panel")
1563 } else {
1564 Flex::column()
1565 .with_child(
1566 MouseEventHandler::<Self, _>::new(2, cx, {
1567 let button_style = theme.open_project_button.clone();
1568 let context_menu_item_style = theme::current(cx).context_menu.item.clone();
1569 move |state, cx| {
1570 let button_style = button_style.style_for(state).clone();
1571 let context_menu_item = context_menu_item_style
1572 .active_state()
1573 .style_for(state)
1574 .clone();
1575
1576 theme::ui::keystroke_label(
1577 "Open a project",
1578 &button_style,
1579 &context_menu_item.keystroke,
1580 Box::new(workspace::Open),
1581 cx,
1582 )
1583 }
1584 })
1585 .on_click(MouseButton::Left, move |_, this, cx| {
1586 if let Some(workspace) = this.workspace.upgrade(cx) {
1587 workspace.update(cx, |workspace, cx| {
1588 if let Some(task) = workspace.open(&Default::default(), cx) {
1589 task.detach_and_log_err(cx);
1590 }
1591 })
1592 }
1593 })
1594 .with_cursor_style(CursorStyle::PointingHand),
1595 )
1596 .contained()
1597 .with_style(container_style)
1598 .into_any_named("empty project panel")
1599 }
1600 }
1601
1602 fn update_keymap_context(&self, keymap: &mut KeymapContext, _: &AppContext) {
1603 Self::reset_to_default_keymap_context(keymap);
1604 keymap.add_identifier("menu");
1605 }
1606
1607 fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
1608 if !self.has_focus {
1609 self.has_focus = true;
1610 cx.emit(Event::Focus);
1611 }
1612 }
1613
1614 fn focus_out(&mut self, _: gpui::AnyViewHandle, _: &mut ViewContext<Self>) {
1615 self.has_focus = false;
1616 }
1617}
1618
1619impl Entity for ProjectPanel {
1620 type Event = Event;
1621}
1622
1623impl workspace::dock::Panel for ProjectPanel {
1624 fn position(&self, cx: &WindowContext) -> DockPosition {
1625 match settings::get::<ProjectPanelSettings>(cx).dock {
1626 ProjectPanelDockPosition::Left => DockPosition::Left,
1627 ProjectPanelDockPosition::Right => DockPosition::Right,
1628 }
1629 }
1630
1631 fn position_is_valid(&self, position: DockPosition) -> bool {
1632 matches!(position, DockPosition::Left | DockPosition::Right)
1633 }
1634
1635 fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
1636 settings::update_settings_file::<ProjectPanelSettings>(
1637 self.fs.clone(),
1638 cx,
1639 move |settings| {
1640 let dock = match position {
1641 DockPosition::Left | DockPosition::Bottom => ProjectPanelDockPosition::Left,
1642 DockPosition::Right => ProjectPanelDockPosition::Right,
1643 };
1644 settings.dock = Some(dock);
1645 },
1646 );
1647 }
1648
1649 fn size(&self, cx: &WindowContext) -> f32 {
1650 self.width
1651 .unwrap_or_else(|| settings::get::<ProjectPanelSettings>(cx).default_width)
1652 }
1653
1654 fn set_size(&mut self, size: f32, cx: &mut ViewContext<Self>) {
1655 self.width = Some(size);
1656 self.serialize(cx);
1657 cx.notify();
1658 }
1659
1660 fn should_zoom_in_on_event(_: &Self::Event) -> bool {
1661 false
1662 }
1663
1664 fn should_zoom_out_on_event(_: &Self::Event) -> bool {
1665 false
1666 }
1667
1668 fn is_zoomed(&self, _: &WindowContext) -> bool {
1669 false
1670 }
1671
1672 fn set_zoomed(&mut self, _: bool, _: &mut ViewContext<Self>) {}
1673
1674 fn set_active(&mut self, _: bool, _: &mut ViewContext<Self>) {}
1675
1676 fn icon_path(&self) -> &'static str {
1677 "icons/folder_tree_16.svg"
1678 }
1679
1680 fn icon_tooltip(&self) -> (String, Option<Box<dyn Action>>) {
1681 ("Project Panel".into(), Some(Box::new(ToggleFocus)))
1682 }
1683
1684 fn should_change_position_on_event(event: &Self::Event) -> bool {
1685 matches!(event, Event::DockPositionChanged)
1686 }
1687
1688 fn should_activate_on_event(_: &Self::Event) -> bool {
1689 false
1690 }
1691
1692 fn should_close_on_event(_: &Self::Event) -> bool {
1693 false
1694 }
1695
1696 fn has_focus(&self, _: &WindowContext) -> bool {
1697 self.has_focus
1698 }
1699
1700 fn is_focus_event(event: &Self::Event) -> bool {
1701 matches!(event, Event::Focus)
1702 }
1703}
1704
1705impl ClipboardEntry {
1706 fn is_cut(&self) -> bool {
1707 matches!(self, Self::Cut { .. })
1708 }
1709
1710 fn entry_id(&self) -> ProjectEntryId {
1711 match self {
1712 ClipboardEntry::Copied { entry_id, .. } | ClipboardEntry::Cut { entry_id, .. } => {
1713 *entry_id
1714 }
1715 }
1716 }
1717
1718 fn worktree_id(&self) -> WorktreeId {
1719 match self {
1720 ClipboardEntry::Copied { worktree_id, .. }
1721 | ClipboardEntry::Cut { worktree_id, .. } => *worktree_id,
1722 }
1723 }
1724}
1725
1726#[cfg(test)]
1727mod tests {
1728 use super::*;
1729 use gpui::{TestAppContext, ViewHandle};
1730 use pretty_assertions::assert_eq;
1731 use project::FakeFs;
1732 use serde_json::json;
1733 use settings::SettingsStore;
1734 use std::{
1735 collections::HashSet,
1736 path::Path,
1737 sync::atomic::{self, AtomicUsize},
1738 };
1739 use workspace::{pane, AppState};
1740
1741 #[gpui::test]
1742 async fn test_visible_list(cx: &mut gpui::TestAppContext) {
1743 init_test(cx);
1744
1745 let fs = FakeFs::new(cx.background());
1746 fs.insert_tree(
1747 "/root1",
1748 json!({
1749 ".dockerignore": "",
1750 ".git": {
1751 "HEAD": "",
1752 },
1753 "a": {
1754 "0": { "q": "", "r": "", "s": "" },
1755 "1": { "t": "", "u": "" },
1756 "2": { "v": "", "w": "", "x": "", "y": "" },
1757 },
1758 "b": {
1759 "3": { "Q": "" },
1760 "4": { "R": "", "S": "", "T": "", "U": "" },
1761 },
1762 "C": {
1763 "5": {},
1764 "6": { "V": "", "W": "" },
1765 "7": { "X": "" },
1766 "8": { "Y": {}, "Z": "" }
1767 }
1768 }),
1769 )
1770 .await;
1771 fs.insert_tree(
1772 "/root2",
1773 json!({
1774 "d": {
1775 "9": ""
1776 },
1777 "e": {}
1778 }),
1779 )
1780 .await;
1781
1782 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1783 let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1784 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
1785 assert_eq!(
1786 visible_entries_as_strings(&panel, 0..50, cx),
1787 &[
1788 "v root1",
1789 " > .git",
1790 " > a",
1791 " > b",
1792 " > C",
1793 " .dockerignore",
1794 "v root2",
1795 " > d",
1796 " > e",
1797 ]
1798 );
1799
1800 toggle_expand_dir(&panel, "root1/b", cx);
1801 assert_eq!(
1802 visible_entries_as_strings(&panel, 0..50, cx),
1803 &[
1804 "v root1",
1805 " > .git",
1806 " > a",
1807 " v b <== selected",
1808 " > 3",
1809 " > 4",
1810 " > C",
1811 " .dockerignore",
1812 "v root2",
1813 " > d",
1814 " > e",
1815 ]
1816 );
1817
1818 assert_eq!(
1819 visible_entries_as_strings(&panel, 6..9, cx),
1820 &[
1821 //
1822 " > C",
1823 " .dockerignore",
1824 "v root2",
1825 ]
1826 );
1827 }
1828
1829 #[gpui::test(iterations = 30)]
1830 async fn test_editing_files(cx: &mut gpui::TestAppContext) {
1831 init_test(cx);
1832
1833 let fs = FakeFs::new(cx.background());
1834 fs.insert_tree(
1835 "/root1",
1836 json!({
1837 ".dockerignore": "",
1838 ".git": {
1839 "HEAD": "",
1840 },
1841 "a": {
1842 "0": { "q": "", "r": "", "s": "" },
1843 "1": { "t": "", "u": "" },
1844 "2": { "v": "", "w": "", "x": "", "y": "" },
1845 },
1846 "b": {
1847 "3": { "Q": "" },
1848 "4": { "R": "", "S": "", "T": "", "U": "" },
1849 },
1850 "C": {
1851 "5": {},
1852 "6": { "V": "", "W": "" },
1853 "7": { "X": "" },
1854 "8": { "Y": {}, "Z": "" }
1855 }
1856 }),
1857 )
1858 .await;
1859 fs.insert_tree(
1860 "/root2",
1861 json!({
1862 "d": {
1863 "9": ""
1864 },
1865 "e": {}
1866 }),
1867 )
1868 .await;
1869
1870 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1871 let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1872 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
1873
1874 select_path(&panel, "root1", cx);
1875 assert_eq!(
1876 visible_entries_as_strings(&panel, 0..10, cx),
1877 &[
1878 "v root1 <== selected",
1879 " > .git",
1880 " > a",
1881 " > b",
1882 " > C",
1883 " .dockerignore",
1884 "v root2",
1885 " > d",
1886 " > e",
1887 ]
1888 );
1889
1890 // Add a file with the root folder selected. The filename editor is placed
1891 // before the first file in the root folder.
1892 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
1893 cx.read_window(window_id, |cx| {
1894 let panel = panel.read(cx);
1895 assert!(panel.filename_editor.is_focused(cx));
1896 });
1897 assert_eq!(
1898 visible_entries_as_strings(&panel, 0..10, cx),
1899 &[
1900 "v root1",
1901 " > .git",
1902 " > a",
1903 " > b",
1904 " > C",
1905 " [EDITOR: ''] <== selected",
1906 " .dockerignore",
1907 "v root2",
1908 " > d",
1909 " > e",
1910 ]
1911 );
1912
1913 let confirm = panel.update(cx, |panel, cx| {
1914 panel
1915 .filename_editor
1916 .update(cx, |editor, cx| editor.set_text("the-new-filename", cx));
1917 panel.confirm(&Confirm, cx).unwrap()
1918 });
1919 assert_eq!(
1920 visible_entries_as_strings(&panel, 0..10, cx),
1921 &[
1922 "v root1",
1923 " > .git",
1924 " > a",
1925 " > b",
1926 " > C",
1927 " [PROCESSING: 'the-new-filename'] <== selected",
1928 " .dockerignore",
1929 "v root2",
1930 " > d",
1931 " > e",
1932 ]
1933 );
1934
1935 confirm.await.unwrap();
1936 assert_eq!(
1937 visible_entries_as_strings(&panel, 0..10, cx),
1938 &[
1939 "v root1",
1940 " > .git",
1941 " > a",
1942 " > b",
1943 " > C",
1944 " .dockerignore",
1945 " the-new-filename <== selected",
1946 "v root2",
1947 " > d",
1948 " > e",
1949 ]
1950 );
1951
1952 select_path(&panel, "root1/b", cx);
1953 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
1954 assert_eq!(
1955 visible_entries_as_strings(&panel, 0..10, cx),
1956 &[
1957 "v root1",
1958 " > .git",
1959 " > a",
1960 " v b",
1961 " > 3",
1962 " > 4",
1963 " [EDITOR: ''] <== selected",
1964 " > C",
1965 " .dockerignore",
1966 " the-new-filename",
1967 ]
1968 );
1969
1970 panel
1971 .update(cx, |panel, cx| {
1972 panel
1973 .filename_editor
1974 .update(cx, |editor, cx| editor.set_text("another-filename.txt", cx));
1975 panel.confirm(&Confirm, cx).unwrap()
1976 })
1977 .await
1978 .unwrap();
1979 assert_eq!(
1980 visible_entries_as_strings(&panel, 0..10, cx),
1981 &[
1982 "v root1",
1983 " > .git",
1984 " > a",
1985 " v b",
1986 " > 3",
1987 " > 4",
1988 " another-filename.txt <== selected",
1989 " > C",
1990 " .dockerignore",
1991 " the-new-filename",
1992 ]
1993 );
1994
1995 select_path(&panel, "root1/b/another-filename.txt", cx);
1996 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
1997 assert_eq!(
1998 visible_entries_as_strings(&panel, 0..10, cx),
1999 &[
2000 "v root1",
2001 " > .git",
2002 " > a",
2003 " v b",
2004 " > 3",
2005 " > 4",
2006 " [EDITOR: 'another-filename.txt'] <== selected",
2007 " > C",
2008 " .dockerignore",
2009 " the-new-filename",
2010 ]
2011 );
2012
2013 let confirm = panel.update(cx, |panel, cx| {
2014 panel.filename_editor.update(cx, |editor, cx| {
2015 let file_name_selections = editor.selections.all::<usize>(cx);
2016 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
2017 let file_name_selection = &file_name_selections[0];
2018 assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
2019 assert_eq!(file_name_selection.end, "another-filename".len(), "Should not select file extension");
2020
2021 editor.set_text("a-different-filename.tar.gz", cx)
2022 });
2023 panel.confirm(&Confirm, cx).unwrap()
2024 });
2025 assert_eq!(
2026 visible_entries_as_strings(&panel, 0..10, cx),
2027 &[
2028 "v root1",
2029 " > .git",
2030 " > a",
2031 " v b",
2032 " > 3",
2033 " > 4",
2034 " [PROCESSING: 'a-different-filename.tar.gz'] <== selected",
2035 " > C",
2036 " .dockerignore",
2037 " the-new-filename",
2038 ]
2039 );
2040
2041 confirm.await.unwrap();
2042 assert_eq!(
2043 visible_entries_as_strings(&panel, 0..10, cx),
2044 &[
2045 "v root1",
2046 " > .git",
2047 " > a",
2048 " v b",
2049 " > 3",
2050 " > 4",
2051 " a-different-filename.tar.gz <== selected",
2052 " > C",
2053 " .dockerignore",
2054 " the-new-filename",
2055 ]
2056 );
2057
2058 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2059 assert_eq!(
2060 visible_entries_as_strings(&panel, 0..10, cx),
2061 &[
2062 "v root1",
2063 " > .git",
2064 " > a",
2065 " v b",
2066 " > 3",
2067 " > 4",
2068 " [EDITOR: 'a-different-filename.tar.gz'] <== selected",
2069 " > C",
2070 " .dockerignore",
2071 " the-new-filename",
2072 ]
2073 );
2074
2075 panel.update(cx, |panel, cx| {
2076 panel.filename_editor.update(cx, |editor, cx| {
2077 let file_name_selections = editor.selections.all::<usize>(cx);
2078 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
2079 let file_name_selection = &file_name_selections[0];
2080 assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
2081 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");
2082
2083 });
2084 panel.cancel(&Cancel, cx)
2085 });
2086
2087 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2088 assert_eq!(
2089 visible_entries_as_strings(&panel, 0..10, cx),
2090 &[
2091 "v root1",
2092 " > .git",
2093 " > a",
2094 " v b",
2095 " > [EDITOR: ''] <== selected",
2096 " > 3",
2097 " > 4",
2098 " a-different-filename.tar.gz",
2099 " > C",
2100 " .dockerignore",
2101 ]
2102 );
2103
2104 let confirm = panel.update(cx, |panel, cx| {
2105 panel
2106 .filename_editor
2107 .update(cx, |editor, cx| editor.set_text("new-dir", cx));
2108 panel.confirm(&Confirm, cx).unwrap()
2109 });
2110 panel.update(cx, |panel, cx| panel.select_next(&Default::default(), cx));
2111 assert_eq!(
2112 visible_entries_as_strings(&panel, 0..10, cx),
2113 &[
2114 "v root1",
2115 " > .git",
2116 " > a",
2117 " v b",
2118 " > [PROCESSING: 'new-dir']",
2119 " > 3 <== selected",
2120 " > 4",
2121 " a-different-filename.tar.gz",
2122 " > C",
2123 " .dockerignore",
2124 ]
2125 );
2126
2127 confirm.await.unwrap();
2128 assert_eq!(
2129 visible_entries_as_strings(&panel, 0..10, cx),
2130 &[
2131 "v root1",
2132 " > .git",
2133 " > a",
2134 " v b",
2135 " > 3 <== selected",
2136 " > 4",
2137 " > new-dir",
2138 " a-different-filename.tar.gz",
2139 " > C",
2140 " .dockerignore",
2141 ]
2142 );
2143
2144 panel.update(cx, |panel, cx| panel.rename(&Default::default(), cx));
2145 assert_eq!(
2146 visible_entries_as_strings(&panel, 0..10, cx),
2147 &[
2148 "v root1",
2149 " > .git",
2150 " > a",
2151 " v b",
2152 " > [EDITOR: '3'] <== selected",
2153 " > 4",
2154 " > new-dir",
2155 " a-different-filename.tar.gz",
2156 " > C",
2157 " .dockerignore",
2158 ]
2159 );
2160
2161 // Dismiss the rename editor when it loses focus.
2162 workspace.update(cx, |_, cx| cx.focus_self());
2163 assert_eq!(
2164 visible_entries_as_strings(&panel, 0..10, cx),
2165 &[
2166 "v root1",
2167 " > .git",
2168 " > a",
2169 " v b",
2170 " > 3 <== selected",
2171 " > 4",
2172 " > new-dir",
2173 " a-different-filename.tar.gz",
2174 " > C",
2175 " .dockerignore",
2176 ]
2177 );
2178 }
2179
2180 #[gpui::test(iterations = 30)]
2181 async fn test_adding_directories_via_file(cx: &mut gpui::TestAppContext) {
2182 init_test(cx);
2183
2184 let fs = FakeFs::new(cx.background());
2185 fs.insert_tree(
2186 "/root1",
2187 json!({
2188 ".dockerignore": "",
2189 ".git": {
2190 "HEAD": "",
2191 },
2192 "a": {
2193 "0": { "q": "", "r": "", "s": "" },
2194 "1": { "t": "", "u": "" },
2195 "2": { "v": "", "w": "", "x": "", "y": "" },
2196 },
2197 "b": {
2198 "3": { "Q": "" },
2199 "4": { "R": "", "S": "", "T": "", "U": "" },
2200 },
2201 "C": {
2202 "5": {},
2203 "6": { "V": "", "W": "" },
2204 "7": { "X": "" },
2205 "8": { "Y": {}, "Z": "" }
2206 }
2207 }),
2208 )
2209 .await;
2210 fs.insert_tree(
2211 "/root2",
2212 json!({
2213 "d": {
2214 "9": ""
2215 },
2216 "e": {}
2217 }),
2218 )
2219 .await;
2220
2221 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
2222 let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2223 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2224
2225 select_path(&panel, "root1", cx);
2226 assert_eq!(
2227 visible_entries_as_strings(&panel, 0..10, cx),
2228 &[
2229 "v root1 <== selected",
2230 " > .git",
2231 " > a",
2232 " > b",
2233 " > C",
2234 " .dockerignore",
2235 "v root2",
2236 " > d",
2237 " > e",
2238 ]
2239 );
2240
2241 // Add a file with the root folder selected. The filename editor is placed
2242 // before the first file in the root folder.
2243 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2244 cx.read_window(window_id, |cx| {
2245 let panel = panel.read(cx);
2246 assert!(panel.filename_editor.is_focused(cx));
2247 });
2248 assert_eq!(
2249 visible_entries_as_strings(&panel, 0..10, cx),
2250 &[
2251 "v root1",
2252 " > .git",
2253 " > a",
2254 " > b",
2255 " > C",
2256 " [EDITOR: ''] <== selected",
2257 " .dockerignore",
2258 "v root2",
2259 " > d",
2260 " > e",
2261 ]
2262 );
2263
2264 let confirm = panel.update(cx, |panel, cx| {
2265 panel.filename_editor.update(cx, |editor, cx| {
2266 editor.set_text("/bdir1/dir2/the-new-filename", cx)
2267 });
2268 panel.confirm(&Confirm, cx).unwrap()
2269 });
2270
2271 assert_eq!(
2272 visible_entries_as_strings(&panel, 0..10, cx),
2273 &[
2274 "v root1",
2275 " > .git",
2276 " > a",
2277 " > b",
2278 " > C",
2279 " [PROCESSING: '/bdir1/dir2/the-new-filename'] <== selected",
2280 " .dockerignore",
2281 "v root2",
2282 " > d",
2283 " > e",
2284 ]
2285 );
2286
2287 confirm.await.unwrap();
2288 assert_eq!(
2289 visible_entries_as_strings(&panel, 0..13, cx),
2290 &[
2291 "v root1",
2292 " > .git",
2293 " > a",
2294 " > b",
2295 " v bdir1",
2296 " v dir2",
2297 " the-new-filename <== selected",
2298 " > C",
2299 " .dockerignore",
2300 "v root2",
2301 " > d",
2302 " > e",
2303 ]
2304 );
2305 }
2306
2307 #[gpui::test]
2308 async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
2309 init_test(cx);
2310
2311 let fs = FakeFs::new(cx.background());
2312 fs.insert_tree(
2313 "/root1",
2314 json!({
2315 "one.two.txt": "",
2316 "one.txt": ""
2317 }),
2318 )
2319 .await;
2320
2321 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
2322 let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2323 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2324
2325 panel.update(cx, |panel, cx| {
2326 panel.select_next(&Default::default(), cx);
2327 panel.select_next(&Default::default(), cx);
2328 });
2329
2330 assert_eq!(
2331 visible_entries_as_strings(&panel, 0..50, cx),
2332 &[
2333 //
2334 "v root1",
2335 " one.two.txt <== selected",
2336 " one.txt",
2337 ]
2338 );
2339
2340 // Regression test - file name is created correctly when
2341 // the copied file's name contains multiple dots.
2342 panel.update(cx, |panel, cx| {
2343 panel.copy(&Default::default(), cx);
2344 panel.paste(&Default::default(), cx);
2345 });
2346 cx.foreground().run_until_parked();
2347
2348 assert_eq!(
2349 visible_entries_as_strings(&panel, 0..50, cx),
2350 &[
2351 //
2352 "v root1",
2353 " one.two copy.txt",
2354 " one.two.txt <== selected",
2355 " one.txt",
2356 ]
2357 );
2358
2359 panel.update(cx, |panel, cx| {
2360 panel.paste(&Default::default(), cx);
2361 });
2362 cx.foreground().run_until_parked();
2363
2364 assert_eq!(
2365 visible_entries_as_strings(&panel, 0..50, cx),
2366 &[
2367 //
2368 "v root1",
2369 " one.two copy 1.txt",
2370 " one.two copy.txt",
2371 " one.two.txt <== selected",
2372 " one.txt",
2373 ]
2374 );
2375 }
2376
2377 #[gpui::test]
2378 async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
2379 init_test_with_editor(cx);
2380
2381 let fs = FakeFs::new(cx.background());
2382 fs.insert_tree(
2383 "/src",
2384 json!({
2385 "test": {
2386 "first.rs": "// First Rust file",
2387 "second.rs": "// Second Rust file",
2388 "third.rs": "// Third Rust file",
2389 }
2390 }),
2391 )
2392 .await;
2393
2394 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2395 let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2396 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2397
2398 toggle_expand_dir(&panel, "src/test", cx);
2399 select_path(&panel, "src/test/first.rs", cx);
2400 panel.update(cx, |panel, cx| panel.open_file(&Open, cx));
2401 cx.foreground().run_until_parked();
2402 assert_eq!(
2403 visible_entries_as_strings(&panel, 0..10, cx),
2404 &[
2405 "v src",
2406 " v test",
2407 " first.rs <== selected",
2408 " second.rs",
2409 " third.rs"
2410 ]
2411 );
2412 ensure_single_file_is_opened(window_id, &workspace, "test/first.rs", cx);
2413
2414 submit_deletion(window_id, &panel, cx);
2415 assert_eq!(
2416 visible_entries_as_strings(&panel, 0..10, cx),
2417 &[
2418 "v src",
2419 " v test",
2420 " second.rs",
2421 " third.rs"
2422 ],
2423 "Project panel should have no deleted file, no other file is selected in it"
2424 );
2425 ensure_no_open_items_and_panes(window_id, &workspace, cx);
2426
2427 select_path(&panel, "src/test/second.rs", cx);
2428 panel.update(cx, |panel, cx| panel.open_file(&Open, cx));
2429 cx.foreground().run_until_parked();
2430 assert_eq!(
2431 visible_entries_as_strings(&panel, 0..10, cx),
2432 &[
2433 "v src",
2434 " v test",
2435 " second.rs <== selected",
2436 " third.rs"
2437 ]
2438 );
2439 ensure_single_file_is_opened(window_id, &workspace, "test/second.rs", cx);
2440
2441 cx.update_window(window_id, |cx| {
2442 let active_items = workspace
2443 .read(cx)
2444 .panes()
2445 .iter()
2446 .filter_map(|pane| pane.read(cx).active_item())
2447 .collect::<Vec<_>>();
2448 assert_eq!(active_items.len(), 1);
2449 let open_editor = active_items
2450 .into_iter()
2451 .next()
2452 .unwrap()
2453 .downcast::<Editor>()
2454 .expect("Open item should be an editor");
2455 open_editor.update(cx, |editor, cx| editor.set_text("Another text!", cx));
2456 });
2457 submit_deletion(window_id, &panel, cx);
2458 assert_eq!(
2459 visible_entries_as_strings(&panel, 0..10, cx),
2460 &["v src", " v test", " third.rs"],
2461 "Project panel should have no deleted file, with one last file remaining"
2462 );
2463 ensure_no_open_items_and_panes(window_id, &workspace, cx);
2464 }
2465
2466 #[gpui::test]
2467 async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
2468 init_test_with_editor(cx);
2469
2470 let fs = FakeFs::new(cx.background());
2471 fs.insert_tree(
2472 "/src",
2473 json!({
2474 "test": {
2475 "first.rs": "// First Rust file",
2476 "second.rs": "// Second Rust file",
2477 "third.rs": "// Third Rust file",
2478 }
2479 }),
2480 )
2481 .await;
2482
2483 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2484 let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2485 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2486
2487 select_path(&panel, "src/", cx);
2488 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2489 cx.foreground().run_until_parked();
2490 assert_eq!(
2491 visible_entries_as_strings(&panel, 0..10, cx),
2492 &["v src <== selected", " > test"]
2493 );
2494 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2495 cx.read_window(window_id, |cx| {
2496 let panel = panel.read(cx);
2497 assert!(panel.filename_editor.is_focused(cx));
2498 });
2499 assert_eq!(
2500 visible_entries_as_strings(&panel, 0..10, cx),
2501 &["v src", " > [EDITOR: ''] <== selected", " > test"]
2502 );
2503 panel.update(cx, |panel, cx| {
2504 panel
2505 .filename_editor
2506 .update(cx, |editor, cx| editor.set_text("test", cx));
2507 assert!(
2508 panel.confirm(&Confirm, cx).is_none(),
2509 "Should not allow to confirm on conflicting new directory name"
2510 )
2511 });
2512 assert_eq!(
2513 visible_entries_as_strings(&panel, 0..10, cx),
2514 &["v src", " > test"],
2515 "File list should be unchanged after failed folder create confirmation"
2516 );
2517
2518 select_path(&panel, "src/test/", cx);
2519 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2520 cx.foreground().run_until_parked();
2521 assert_eq!(
2522 visible_entries_as_strings(&panel, 0..10, cx),
2523 &["v src", " > test <== selected"]
2524 );
2525 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2526 cx.read_window(window_id, |cx| {
2527 let panel = panel.read(cx);
2528 assert!(panel.filename_editor.is_focused(cx));
2529 });
2530 assert_eq!(
2531 visible_entries_as_strings(&panel, 0..10, cx),
2532 &[
2533 "v src",
2534 " v test",
2535 " [EDITOR: ''] <== selected",
2536 " first.rs",
2537 " second.rs",
2538 " third.rs"
2539 ]
2540 );
2541 panel.update(cx, |panel, cx| {
2542 panel
2543 .filename_editor
2544 .update(cx, |editor, cx| editor.set_text("first.rs", cx));
2545 assert!(
2546 panel.confirm(&Confirm, cx).is_none(),
2547 "Should not allow to confirm on conflicting new file name"
2548 )
2549 });
2550 assert_eq!(
2551 visible_entries_as_strings(&panel, 0..10, cx),
2552 &[
2553 "v src",
2554 " v test",
2555 " first.rs",
2556 " second.rs",
2557 " third.rs"
2558 ],
2559 "File list should be unchanged after failed file create confirmation"
2560 );
2561
2562 select_path(&panel, "src/test/first.rs", cx);
2563 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2564 cx.foreground().run_until_parked();
2565 assert_eq!(
2566 visible_entries_as_strings(&panel, 0..10, cx),
2567 &[
2568 "v src",
2569 " v test",
2570 " first.rs <== selected",
2571 " second.rs",
2572 " third.rs"
2573 ],
2574 );
2575 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2576 cx.read_window(window_id, |cx| {
2577 let panel = panel.read(cx);
2578 assert!(panel.filename_editor.is_focused(cx));
2579 });
2580 assert_eq!(
2581 visible_entries_as_strings(&panel, 0..10, cx),
2582 &[
2583 "v src",
2584 " v test",
2585 " [EDITOR: 'first.rs'] <== selected",
2586 " second.rs",
2587 " third.rs"
2588 ]
2589 );
2590 panel.update(cx, |panel, cx| {
2591 panel
2592 .filename_editor
2593 .update(cx, |editor, cx| editor.set_text("second.rs", cx));
2594 assert!(
2595 panel.confirm(&Confirm, cx).is_none(),
2596 "Should not allow to confirm on conflicting file rename"
2597 )
2598 });
2599 assert_eq!(
2600 visible_entries_as_strings(&panel, 0..10, cx),
2601 &[
2602 "v src",
2603 " v test",
2604 " first.rs <== selected",
2605 " second.rs",
2606 " third.rs"
2607 ],
2608 "File list should be unchanged after failed rename confirmation"
2609 );
2610 }
2611
2612 #[gpui::test]
2613 async fn test_new_search_in_directory_trigger(cx: &mut gpui::TestAppContext) {
2614 init_test_with_editor(cx);
2615
2616 let fs = FakeFs::new(cx.background());
2617 fs.insert_tree(
2618 "/src",
2619 json!({
2620 "test": {
2621 "first.rs": "// First Rust file",
2622 "second.rs": "// Second Rust file",
2623 "third.rs": "// Third Rust file",
2624 }
2625 }),
2626 )
2627 .await;
2628
2629 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2630 let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2631 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2632
2633 let new_search_events_count = Arc::new(AtomicUsize::new(0));
2634 let _subscription = panel.update(cx, |_, cx| {
2635 let subcription_count = Arc::clone(&new_search_events_count);
2636 cx.subscribe(&cx.handle(), move |_, _, event, _| {
2637 if matches!(event, Event::NewSearchInDirectory { .. }) {
2638 subcription_count.fetch_add(1, atomic::Ordering::SeqCst);
2639 }
2640 })
2641 });
2642
2643 toggle_expand_dir(&panel, "src/test", cx);
2644 select_path(&panel, "src/test/first.rs", cx);
2645 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2646 cx.foreground().run_until_parked();
2647 assert_eq!(
2648 visible_entries_as_strings(&panel, 0..10, cx),
2649 &[
2650 "v src",
2651 " v test",
2652 " first.rs <== selected",
2653 " second.rs",
2654 " third.rs"
2655 ]
2656 );
2657 panel.update(cx, |panel, cx| {
2658 panel.new_search_in_directory(&NewSearchInDirectory, cx)
2659 });
2660 assert_eq!(
2661 new_search_events_count.load(atomic::Ordering::SeqCst),
2662 0,
2663 "Should not trigger new search in directory when called on a file"
2664 );
2665
2666 select_path(&panel, "src/test", cx);
2667 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2668 cx.foreground().run_until_parked();
2669 assert_eq!(
2670 visible_entries_as_strings(&panel, 0..10, cx),
2671 &[
2672 "v src",
2673 " v test <== selected",
2674 " first.rs",
2675 " second.rs",
2676 " third.rs"
2677 ]
2678 );
2679 panel.update(cx, |panel, cx| {
2680 panel.new_search_in_directory(&NewSearchInDirectory, cx)
2681 });
2682 assert_eq!(
2683 new_search_events_count.load(atomic::Ordering::SeqCst),
2684 1,
2685 "Should trigger new search in directory when called on a directory"
2686 );
2687 }
2688
2689 #[gpui::test]
2690 async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) {
2691 init_test_with_editor(cx);
2692
2693 let fs = FakeFs::new(cx.background());
2694 fs.insert_tree(
2695 "/project_root",
2696 json!({
2697 "dir_1": {
2698 "nested_dir": {
2699 "file_a.py": "# File contents",
2700 "file_b.py": "# File contents",
2701 "file_c.py": "# File contents",
2702 },
2703 "file_1.py": "# File contents",
2704 "file_2.py": "# File contents",
2705 "file_3.py": "# File contents",
2706 },
2707 "dir_2": {
2708 "file_1.py": "# File contents",
2709 "file_2.py": "# File contents",
2710 "file_3.py": "# File contents",
2711 }
2712 }),
2713 )
2714 .await;
2715
2716 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
2717 let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2718 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2719
2720 panel.update(cx, |panel, cx| {
2721 panel.collapse_all_entries(&CollapseAllEntries, cx)
2722 });
2723 cx.foreground().run_until_parked();
2724 assert_eq!(
2725 visible_entries_as_strings(&panel, 0..10, cx),
2726 &["v project_root", " > dir_1", " > dir_2",]
2727 );
2728
2729 // Open dir_1 and make sure nested_dir was collapsed when running collapse_all_entries
2730 toggle_expand_dir(&panel, "project_root/dir_1", cx);
2731 cx.foreground().run_until_parked();
2732 assert_eq!(
2733 visible_entries_as_strings(&panel, 0..10, cx),
2734 &[
2735 "v project_root",
2736 " v dir_1 <== selected",
2737 " > nested_dir",
2738 " file_1.py",
2739 " file_2.py",
2740 " file_3.py",
2741 " > dir_2",
2742 ]
2743 );
2744 }
2745
2746 fn toggle_expand_dir(
2747 panel: &ViewHandle<ProjectPanel>,
2748 path: impl AsRef<Path>,
2749 cx: &mut TestAppContext,
2750 ) {
2751 let path = path.as_ref();
2752 panel.update(cx, |panel, cx| {
2753 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
2754 let worktree = worktree.read(cx);
2755 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
2756 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
2757 panel.toggle_expanded(entry_id, cx);
2758 return;
2759 }
2760 }
2761 panic!("no worktree for path {:?}", path);
2762 });
2763 }
2764
2765 fn select_path(
2766 panel: &ViewHandle<ProjectPanel>,
2767 path: impl AsRef<Path>,
2768 cx: &mut TestAppContext,
2769 ) {
2770 let path = path.as_ref();
2771 panel.update(cx, |panel, cx| {
2772 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
2773 let worktree = worktree.read(cx);
2774 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
2775 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
2776 panel.selection = Some(Selection {
2777 worktree_id: worktree.id(),
2778 entry_id,
2779 });
2780 return;
2781 }
2782 }
2783 panic!("no worktree for path {:?}", path);
2784 });
2785 }
2786
2787 fn visible_entries_as_strings(
2788 panel: &ViewHandle<ProjectPanel>,
2789 range: Range<usize>,
2790 cx: &mut TestAppContext,
2791 ) -> Vec<String> {
2792 let mut result = Vec::new();
2793 let mut project_entries = HashSet::new();
2794 let mut has_editor = false;
2795
2796 panel.update(cx, |panel, cx| {
2797 panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
2798 if details.is_editing {
2799 assert!(!has_editor, "duplicate editor entry");
2800 has_editor = true;
2801 } else {
2802 assert!(
2803 project_entries.insert(project_entry),
2804 "duplicate project entry {:?} {:?}",
2805 project_entry,
2806 details
2807 );
2808 }
2809
2810 let indent = " ".repeat(details.depth);
2811 let icon = if details.kind.is_dir() {
2812 if details.is_expanded {
2813 "v "
2814 } else {
2815 "> "
2816 }
2817 } else {
2818 " "
2819 };
2820 let name = if details.is_editing {
2821 format!("[EDITOR: '{}']", details.filename)
2822 } else if details.is_processing {
2823 format!("[PROCESSING: '{}']", details.filename)
2824 } else {
2825 details.filename.clone()
2826 };
2827 let selected = if details.is_selected {
2828 " <== selected"
2829 } else {
2830 ""
2831 };
2832 result.push(format!("{indent}{icon}{name}{selected}"));
2833 });
2834 });
2835
2836 result
2837 }
2838
2839 fn init_test(cx: &mut TestAppContext) {
2840 cx.foreground().forbid_parking();
2841 cx.update(|cx| {
2842 cx.set_global(SettingsStore::test(cx));
2843 init_settings(cx);
2844 theme::init((), cx);
2845 language::init(cx);
2846 editor::init_settings(cx);
2847 crate::init((), cx);
2848 workspace::init_settings(cx);
2849 Project::init_settings(cx);
2850 });
2851 }
2852
2853 fn init_test_with_editor(cx: &mut TestAppContext) {
2854 cx.foreground().forbid_parking();
2855 cx.update(|cx| {
2856 let app_state = AppState::test(cx);
2857 theme::init((), cx);
2858 init_settings(cx);
2859 language::init(cx);
2860 editor::init(cx);
2861 pane::init(cx);
2862 crate::init((), cx);
2863 workspace::init(app_state.clone(), cx);
2864 Project::init_settings(cx);
2865 });
2866 }
2867
2868 fn ensure_single_file_is_opened(
2869 window_id: usize,
2870 workspace: &ViewHandle<Workspace>,
2871 expected_path: &str,
2872 cx: &mut TestAppContext,
2873 ) {
2874 cx.read_window(window_id, |cx| {
2875 let workspace = workspace.read(cx);
2876 let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
2877 assert_eq!(worktrees.len(), 1);
2878 let worktree_id = WorktreeId::from_usize(worktrees[0].id());
2879
2880 let open_project_paths = workspace
2881 .panes()
2882 .iter()
2883 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
2884 .collect::<Vec<_>>();
2885 assert_eq!(
2886 open_project_paths,
2887 vec![ProjectPath {
2888 worktree_id,
2889 path: Arc::from(Path::new(expected_path))
2890 }],
2891 "Should have opened file, selected in project panel"
2892 );
2893 });
2894 }
2895
2896 fn submit_deletion(
2897 window_id: usize,
2898 panel: &ViewHandle<ProjectPanel>,
2899 cx: &mut TestAppContext,
2900 ) {
2901 assert!(
2902 !cx.has_pending_prompt(window_id),
2903 "Should have no prompts before the deletion"
2904 );
2905 panel.update(cx, |panel, cx| {
2906 panel
2907 .delete(&Delete, cx)
2908 .expect("Deletion start")
2909 .detach_and_log_err(cx);
2910 });
2911 assert!(
2912 cx.has_pending_prompt(window_id),
2913 "Should have a prompt after the deletion"
2914 );
2915 cx.simulate_prompt_answer(window_id, 0);
2916 assert!(
2917 !cx.has_pending_prompt(window_id),
2918 "Should have no prompts after prompt was replied to"
2919 );
2920 cx.foreground().run_until_parked();
2921 }
2922
2923 fn ensure_no_open_items_and_panes(
2924 window_id: usize,
2925 workspace: &ViewHandle<Workspace>,
2926 cx: &mut TestAppContext,
2927 ) {
2928 assert!(
2929 !cx.has_pending_prompt(window_id),
2930 "Should have no prompts after deletion operation closes the file"
2931 );
2932 cx.read_window(window_id, |cx| {
2933 let open_project_paths = workspace
2934 .read(cx)
2935 .panes()
2936 .iter()
2937 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
2938 .collect::<Vec<_>>();
2939 assert!(
2940 open_project_paths.is_empty(),
2941 "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
2942 );
2943 });
2944 }
2945}
2946// TODO - a workspace command?