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 icon_path(&self) -> &'static str {
1661 "icons/folder_tree_16.svg"
1662 }
1663
1664 fn icon_tooltip(&self) -> (String, Option<Box<dyn Action>>) {
1665 ("Project Panel".into(), Some(Box::new(ToggleFocus)))
1666 }
1667
1668 fn should_change_position_on_event(event: &Self::Event) -> bool {
1669 matches!(event, Event::DockPositionChanged)
1670 }
1671
1672 fn has_focus(&self, _: &WindowContext) -> bool {
1673 self.has_focus
1674 }
1675
1676 fn is_focus_event(event: &Self::Event) -> bool {
1677 matches!(event, Event::Focus)
1678 }
1679}
1680
1681impl ClipboardEntry {
1682 fn is_cut(&self) -> bool {
1683 matches!(self, Self::Cut { .. })
1684 }
1685
1686 fn entry_id(&self) -> ProjectEntryId {
1687 match self {
1688 ClipboardEntry::Copied { entry_id, .. } | ClipboardEntry::Cut { entry_id, .. } => {
1689 *entry_id
1690 }
1691 }
1692 }
1693
1694 fn worktree_id(&self) -> WorktreeId {
1695 match self {
1696 ClipboardEntry::Copied { worktree_id, .. }
1697 | ClipboardEntry::Cut { worktree_id, .. } => *worktree_id,
1698 }
1699 }
1700}
1701
1702#[cfg(test)]
1703mod tests {
1704 use super::*;
1705 use gpui::{TestAppContext, ViewHandle};
1706 use pretty_assertions::assert_eq;
1707 use project::FakeFs;
1708 use serde_json::json;
1709 use settings::SettingsStore;
1710 use std::{
1711 collections::HashSet,
1712 path::Path,
1713 sync::atomic::{self, AtomicUsize},
1714 };
1715 use workspace::{pane, AppState};
1716
1717 #[gpui::test]
1718 async fn test_visible_list(cx: &mut gpui::TestAppContext) {
1719 init_test(cx);
1720
1721 let fs = FakeFs::new(cx.background());
1722 fs.insert_tree(
1723 "/root1",
1724 json!({
1725 ".dockerignore": "",
1726 ".git": {
1727 "HEAD": "",
1728 },
1729 "a": {
1730 "0": { "q": "", "r": "", "s": "" },
1731 "1": { "t": "", "u": "" },
1732 "2": { "v": "", "w": "", "x": "", "y": "" },
1733 },
1734 "b": {
1735 "3": { "Q": "" },
1736 "4": { "R": "", "S": "", "T": "", "U": "" },
1737 },
1738 "C": {
1739 "5": {},
1740 "6": { "V": "", "W": "" },
1741 "7": { "X": "" },
1742 "8": { "Y": {}, "Z": "" }
1743 }
1744 }),
1745 )
1746 .await;
1747 fs.insert_tree(
1748 "/root2",
1749 json!({
1750 "d": {
1751 "9": ""
1752 },
1753 "e": {}
1754 }),
1755 )
1756 .await;
1757
1758 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1759 let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1760 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
1761 assert_eq!(
1762 visible_entries_as_strings(&panel, 0..50, cx),
1763 &[
1764 "v root1",
1765 " > .git",
1766 " > a",
1767 " > b",
1768 " > C",
1769 " .dockerignore",
1770 "v root2",
1771 " > d",
1772 " > e",
1773 ]
1774 );
1775
1776 toggle_expand_dir(&panel, "root1/b", cx);
1777 assert_eq!(
1778 visible_entries_as_strings(&panel, 0..50, cx),
1779 &[
1780 "v root1",
1781 " > .git",
1782 " > a",
1783 " v b <== selected",
1784 " > 3",
1785 " > 4",
1786 " > C",
1787 " .dockerignore",
1788 "v root2",
1789 " > d",
1790 " > e",
1791 ]
1792 );
1793
1794 assert_eq!(
1795 visible_entries_as_strings(&panel, 6..9, cx),
1796 &[
1797 //
1798 " > C",
1799 " .dockerignore",
1800 "v root2",
1801 ]
1802 );
1803 }
1804
1805 #[gpui::test(iterations = 30)]
1806 async fn test_editing_files(cx: &mut gpui::TestAppContext) {
1807 init_test(cx);
1808
1809 let fs = FakeFs::new(cx.background());
1810 fs.insert_tree(
1811 "/root1",
1812 json!({
1813 ".dockerignore": "",
1814 ".git": {
1815 "HEAD": "",
1816 },
1817 "a": {
1818 "0": { "q": "", "r": "", "s": "" },
1819 "1": { "t": "", "u": "" },
1820 "2": { "v": "", "w": "", "x": "", "y": "" },
1821 },
1822 "b": {
1823 "3": { "Q": "" },
1824 "4": { "R": "", "S": "", "T": "", "U": "" },
1825 },
1826 "C": {
1827 "5": {},
1828 "6": { "V": "", "W": "" },
1829 "7": { "X": "" },
1830 "8": { "Y": {}, "Z": "" }
1831 }
1832 }),
1833 )
1834 .await;
1835 fs.insert_tree(
1836 "/root2",
1837 json!({
1838 "d": {
1839 "9": ""
1840 },
1841 "e": {}
1842 }),
1843 )
1844 .await;
1845
1846 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1847 let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1848 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
1849
1850 select_path(&panel, "root1", cx);
1851 assert_eq!(
1852 visible_entries_as_strings(&panel, 0..10, cx),
1853 &[
1854 "v root1 <== selected",
1855 " > .git",
1856 " > a",
1857 " > b",
1858 " > C",
1859 " .dockerignore",
1860 "v root2",
1861 " > d",
1862 " > e",
1863 ]
1864 );
1865
1866 // Add a file with the root folder selected. The filename editor is placed
1867 // before the first file in the root folder.
1868 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
1869 cx.read_window(window_id, |cx| {
1870 let panel = panel.read(cx);
1871 assert!(panel.filename_editor.is_focused(cx));
1872 });
1873 assert_eq!(
1874 visible_entries_as_strings(&panel, 0..10, cx),
1875 &[
1876 "v root1",
1877 " > .git",
1878 " > a",
1879 " > b",
1880 " > C",
1881 " [EDITOR: ''] <== selected",
1882 " .dockerignore",
1883 "v root2",
1884 " > d",
1885 " > e",
1886 ]
1887 );
1888
1889 let confirm = panel.update(cx, |panel, cx| {
1890 panel
1891 .filename_editor
1892 .update(cx, |editor, cx| editor.set_text("the-new-filename", cx));
1893 panel.confirm(&Confirm, cx).unwrap()
1894 });
1895 assert_eq!(
1896 visible_entries_as_strings(&panel, 0..10, cx),
1897 &[
1898 "v root1",
1899 " > .git",
1900 " > a",
1901 " > b",
1902 " > C",
1903 " [PROCESSING: 'the-new-filename'] <== selected",
1904 " .dockerignore",
1905 "v root2",
1906 " > d",
1907 " > e",
1908 ]
1909 );
1910
1911 confirm.await.unwrap();
1912 assert_eq!(
1913 visible_entries_as_strings(&panel, 0..10, cx),
1914 &[
1915 "v root1",
1916 " > .git",
1917 " > a",
1918 " > b",
1919 " > C",
1920 " .dockerignore",
1921 " the-new-filename <== selected",
1922 "v root2",
1923 " > d",
1924 " > e",
1925 ]
1926 );
1927
1928 select_path(&panel, "root1/b", cx);
1929 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
1930 assert_eq!(
1931 visible_entries_as_strings(&panel, 0..10, cx),
1932 &[
1933 "v root1",
1934 " > .git",
1935 " > a",
1936 " v b",
1937 " > 3",
1938 " > 4",
1939 " [EDITOR: ''] <== selected",
1940 " > C",
1941 " .dockerignore",
1942 " the-new-filename",
1943 ]
1944 );
1945
1946 panel
1947 .update(cx, |panel, cx| {
1948 panel
1949 .filename_editor
1950 .update(cx, |editor, cx| editor.set_text("another-filename.txt", cx));
1951 panel.confirm(&Confirm, cx).unwrap()
1952 })
1953 .await
1954 .unwrap();
1955 assert_eq!(
1956 visible_entries_as_strings(&panel, 0..10, cx),
1957 &[
1958 "v root1",
1959 " > .git",
1960 " > a",
1961 " v b",
1962 " > 3",
1963 " > 4",
1964 " another-filename.txt <== selected",
1965 " > C",
1966 " .dockerignore",
1967 " the-new-filename",
1968 ]
1969 );
1970
1971 select_path(&panel, "root1/b/another-filename.txt", cx);
1972 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
1973 assert_eq!(
1974 visible_entries_as_strings(&panel, 0..10, cx),
1975 &[
1976 "v root1",
1977 " > .git",
1978 " > a",
1979 " v b",
1980 " > 3",
1981 " > 4",
1982 " [EDITOR: 'another-filename.txt'] <== selected",
1983 " > C",
1984 " .dockerignore",
1985 " the-new-filename",
1986 ]
1987 );
1988
1989 let confirm = panel.update(cx, |panel, cx| {
1990 panel.filename_editor.update(cx, |editor, cx| {
1991 let file_name_selections = editor.selections.all::<usize>(cx);
1992 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
1993 let file_name_selection = &file_name_selections[0];
1994 assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
1995 assert_eq!(file_name_selection.end, "another-filename".len(), "Should not select file extension");
1996
1997 editor.set_text("a-different-filename.tar.gz", cx)
1998 });
1999 panel.confirm(&Confirm, cx).unwrap()
2000 });
2001 assert_eq!(
2002 visible_entries_as_strings(&panel, 0..10, cx),
2003 &[
2004 "v root1",
2005 " > .git",
2006 " > a",
2007 " v b",
2008 " > 3",
2009 " > 4",
2010 " [PROCESSING: 'a-different-filename.tar.gz'] <== selected",
2011 " > C",
2012 " .dockerignore",
2013 " the-new-filename",
2014 ]
2015 );
2016
2017 confirm.await.unwrap();
2018 assert_eq!(
2019 visible_entries_as_strings(&panel, 0..10, cx),
2020 &[
2021 "v root1",
2022 " > .git",
2023 " > a",
2024 " v b",
2025 " > 3",
2026 " > 4",
2027 " a-different-filename.tar.gz <== selected",
2028 " > C",
2029 " .dockerignore",
2030 " the-new-filename",
2031 ]
2032 );
2033
2034 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2035 assert_eq!(
2036 visible_entries_as_strings(&panel, 0..10, cx),
2037 &[
2038 "v root1",
2039 " > .git",
2040 " > a",
2041 " v b",
2042 " > 3",
2043 " > 4",
2044 " [EDITOR: 'a-different-filename.tar.gz'] <== selected",
2045 " > C",
2046 " .dockerignore",
2047 " the-new-filename",
2048 ]
2049 );
2050
2051 panel.update(cx, |panel, cx| {
2052 panel.filename_editor.update(cx, |editor, cx| {
2053 let file_name_selections = editor.selections.all::<usize>(cx);
2054 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
2055 let file_name_selection = &file_name_selections[0];
2056 assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
2057 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");
2058
2059 });
2060 panel.cancel(&Cancel, cx)
2061 });
2062
2063 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2064 assert_eq!(
2065 visible_entries_as_strings(&panel, 0..10, cx),
2066 &[
2067 "v root1",
2068 " > .git",
2069 " > a",
2070 " v b",
2071 " > [EDITOR: ''] <== selected",
2072 " > 3",
2073 " > 4",
2074 " a-different-filename.tar.gz",
2075 " > C",
2076 " .dockerignore",
2077 ]
2078 );
2079
2080 let confirm = panel.update(cx, |panel, cx| {
2081 panel
2082 .filename_editor
2083 .update(cx, |editor, cx| editor.set_text("new-dir", cx));
2084 panel.confirm(&Confirm, cx).unwrap()
2085 });
2086 panel.update(cx, |panel, cx| panel.select_next(&Default::default(), cx));
2087 assert_eq!(
2088 visible_entries_as_strings(&panel, 0..10, cx),
2089 &[
2090 "v root1",
2091 " > .git",
2092 " > a",
2093 " v b",
2094 " > [PROCESSING: 'new-dir']",
2095 " > 3 <== selected",
2096 " > 4",
2097 " a-different-filename.tar.gz",
2098 " > C",
2099 " .dockerignore",
2100 ]
2101 );
2102
2103 confirm.await.unwrap();
2104 assert_eq!(
2105 visible_entries_as_strings(&panel, 0..10, cx),
2106 &[
2107 "v root1",
2108 " > .git",
2109 " > a",
2110 " v b",
2111 " > 3 <== selected",
2112 " > 4",
2113 " > new-dir",
2114 " a-different-filename.tar.gz",
2115 " > C",
2116 " .dockerignore",
2117 ]
2118 );
2119
2120 panel.update(cx, |panel, cx| panel.rename(&Default::default(), cx));
2121 assert_eq!(
2122 visible_entries_as_strings(&panel, 0..10, cx),
2123 &[
2124 "v root1",
2125 " > .git",
2126 " > a",
2127 " v b",
2128 " > [EDITOR: '3'] <== selected",
2129 " > 4",
2130 " > new-dir",
2131 " a-different-filename.tar.gz",
2132 " > C",
2133 " .dockerignore",
2134 ]
2135 );
2136
2137 // Dismiss the rename editor when it loses focus.
2138 workspace.update(cx, |_, cx| cx.focus_self());
2139 assert_eq!(
2140 visible_entries_as_strings(&panel, 0..10, cx),
2141 &[
2142 "v root1",
2143 " > .git",
2144 " > a",
2145 " v b",
2146 " > 3 <== selected",
2147 " > 4",
2148 " > new-dir",
2149 " a-different-filename.tar.gz",
2150 " > C",
2151 " .dockerignore",
2152 ]
2153 );
2154 }
2155
2156 #[gpui::test(iterations = 30)]
2157 async fn test_adding_directories_via_file(cx: &mut gpui::TestAppContext) {
2158 init_test(cx);
2159
2160 let fs = FakeFs::new(cx.background());
2161 fs.insert_tree(
2162 "/root1",
2163 json!({
2164 ".dockerignore": "",
2165 ".git": {
2166 "HEAD": "",
2167 },
2168 "a": {
2169 "0": { "q": "", "r": "", "s": "" },
2170 "1": { "t": "", "u": "" },
2171 "2": { "v": "", "w": "", "x": "", "y": "" },
2172 },
2173 "b": {
2174 "3": { "Q": "" },
2175 "4": { "R": "", "S": "", "T": "", "U": "" },
2176 },
2177 "C": {
2178 "5": {},
2179 "6": { "V": "", "W": "" },
2180 "7": { "X": "" },
2181 "8": { "Y": {}, "Z": "" }
2182 }
2183 }),
2184 )
2185 .await;
2186 fs.insert_tree(
2187 "/root2",
2188 json!({
2189 "d": {
2190 "9": ""
2191 },
2192 "e": {}
2193 }),
2194 )
2195 .await;
2196
2197 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
2198 let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2199 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2200
2201 select_path(&panel, "root1", cx);
2202 assert_eq!(
2203 visible_entries_as_strings(&panel, 0..10, cx),
2204 &[
2205 "v root1 <== selected",
2206 " > .git",
2207 " > a",
2208 " > b",
2209 " > C",
2210 " .dockerignore",
2211 "v root2",
2212 " > d",
2213 " > e",
2214 ]
2215 );
2216
2217 // Add a file with the root folder selected. The filename editor is placed
2218 // before the first file in the root folder.
2219 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2220 cx.read_window(window_id, |cx| {
2221 let panel = panel.read(cx);
2222 assert!(panel.filename_editor.is_focused(cx));
2223 });
2224 assert_eq!(
2225 visible_entries_as_strings(&panel, 0..10, cx),
2226 &[
2227 "v root1",
2228 " > .git",
2229 " > a",
2230 " > b",
2231 " > C",
2232 " [EDITOR: ''] <== selected",
2233 " .dockerignore",
2234 "v root2",
2235 " > d",
2236 " > e",
2237 ]
2238 );
2239
2240 let confirm = panel.update(cx, |panel, cx| {
2241 panel.filename_editor.update(cx, |editor, cx| {
2242 editor.set_text("/bdir1/dir2/the-new-filename", cx)
2243 });
2244 panel.confirm(&Confirm, cx).unwrap()
2245 });
2246
2247 assert_eq!(
2248 visible_entries_as_strings(&panel, 0..10, cx),
2249 &[
2250 "v root1",
2251 " > .git",
2252 " > a",
2253 " > b",
2254 " > C",
2255 " [PROCESSING: '/bdir1/dir2/the-new-filename'] <== selected",
2256 " .dockerignore",
2257 "v root2",
2258 " > d",
2259 " > e",
2260 ]
2261 );
2262
2263 confirm.await.unwrap();
2264 assert_eq!(
2265 visible_entries_as_strings(&panel, 0..13, cx),
2266 &[
2267 "v root1",
2268 " > .git",
2269 " > a",
2270 " > b",
2271 " v bdir1",
2272 " v dir2",
2273 " the-new-filename <== selected",
2274 " > C",
2275 " .dockerignore",
2276 "v root2",
2277 " > d",
2278 " > e",
2279 ]
2280 );
2281 }
2282
2283 #[gpui::test]
2284 async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
2285 init_test(cx);
2286
2287 let fs = FakeFs::new(cx.background());
2288 fs.insert_tree(
2289 "/root1",
2290 json!({
2291 "one.two.txt": "",
2292 "one.txt": ""
2293 }),
2294 )
2295 .await;
2296
2297 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
2298 let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2299 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2300
2301 panel.update(cx, |panel, cx| {
2302 panel.select_next(&Default::default(), cx);
2303 panel.select_next(&Default::default(), cx);
2304 });
2305
2306 assert_eq!(
2307 visible_entries_as_strings(&panel, 0..50, cx),
2308 &[
2309 //
2310 "v root1",
2311 " one.two.txt <== selected",
2312 " one.txt",
2313 ]
2314 );
2315
2316 // Regression test - file name is created correctly when
2317 // the copied file's name contains multiple dots.
2318 panel.update(cx, |panel, cx| {
2319 panel.copy(&Default::default(), cx);
2320 panel.paste(&Default::default(), cx);
2321 });
2322 cx.foreground().run_until_parked();
2323
2324 assert_eq!(
2325 visible_entries_as_strings(&panel, 0..50, cx),
2326 &[
2327 //
2328 "v root1",
2329 " one.two copy.txt",
2330 " one.two.txt <== selected",
2331 " one.txt",
2332 ]
2333 );
2334
2335 panel.update(cx, |panel, cx| {
2336 panel.paste(&Default::default(), cx);
2337 });
2338 cx.foreground().run_until_parked();
2339
2340 assert_eq!(
2341 visible_entries_as_strings(&panel, 0..50, cx),
2342 &[
2343 //
2344 "v root1",
2345 " one.two copy 1.txt",
2346 " one.two copy.txt",
2347 " one.two.txt <== selected",
2348 " one.txt",
2349 ]
2350 );
2351 }
2352
2353 #[gpui::test]
2354 async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
2355 init_test_with_editor(cx);
2356
2357 let fs = FakeFs::new(cx.background());
2358 fs.insert_tree(
2359 "/src",
2360 json!({
2361 "test": {
2362 "first.rs": "// First Rust file",
2363 "second.rs": "// Second Rust file",
2364 "third.rs": "// Third Rust file",
2365 }
2366 }),
2367 )
2368 .await;
2369
2370 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2371 let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2372 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2373
2374 toggle_expand_dir(&panel, "src/test", cx);
2375 select_path(&panel, "src/test/first.rs", cx);
2376 panel.update(cx, |panel, cx| panel.open_file(&Open, cx));
2377 cx.foreground().run_until_parked();
2378 assert_eq!(
2379 visible_entries_as_strings(&panel, 0..10, cx),
2380 &[
2381 "v src",
2382 " v test",
2383 " first.rs <== selected",
2384 " second.rs",
2385 " third.rs"
2386 ]
2387 );
2388 ensure_single_file_is_opened(window_id, &workspace, "test/first.rs", cx);
2389
2390 submit_deletion(window_id, &panel, cx);
2391 assert_eq!(
2392 visible_entries_as_strings(&panel, 0..10, cx),
2393 &[
2394 "v src",
2395 " v test",
2396 " second.rs",
2397 " third.rs"
2398 ],
2399 "Project panel should have no deleted file, no other file is selected in it"
2400 );
2401 ensure_no_open_items_and_panes(window_id, &workspace, cx);
2402
2403 select_path(&panel, "src/test/second.rs", cx);
2404 panel.update(cx, |panel, cx| panel.open_file(&Open, cx));
2405 cx.foreground().run_until_parked();
2406 assert_eq!(
2407 visible_entries_as_strings(&panel, 0..10, cx),
2408 &[
2409 "v src",
2410 " v test",
2411 " second.rs <== selected",
2412 " third.rs"
2413 ]
2414 );
2415 ensure_single_file_is_opened(window_id, &workspace, "test/second.rs", cx);
2416
2417 cx.update_window(window_id, |cx| {
2418 let active_items = workspace
2419 .read(cx)
2420 .panes()
2421 .iter()
2422 .filter_map(|pane| pane.read(cx).active_item())
2423 .collect::<Vec<_>>();
2424 assert_eq!(active_items.len(), 1);
2425 let open_editor = active_items
2426 .into_iter()
2427 .next()
2428 .unwrap()
2429 .downcast::<Editor>()
2430 .expect("Open item should be an editor");
2431 open_editor.update(cx, |editor, cx| editor.set_text("Another text!", cx));
2432 });
2433 submit_deletion(window_id, &panel, cx);
2434 assert_eq!(
2435 visible_entries_as_strings(&panel, 0..10, cx),
2436 &["v src", " v test", " third.rs"],
2437 "Project panel should have no deleted file, with one last file remaining"
2438 );
2439 ensure_no_open_items_and_panes(window_id, &workspace, cx);
2440 }
2441
2442 #[gpui::test]
2443 async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
2444 init_test_with_editor(cx);
2445
2446 let fs = FakeFs::new(cx.background());
2447 fs.insert_tree(
2448 "/src",
2449 json!({
2450 "test": {
2451 "first.rs": "// First Rust file",
2452 "second.rs": "// Second Rust file",
2453 "third.rs": "// Third Rust file",
2454 }
2455 }),
2456 )
2457 .await;
2458
2459 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2460 let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2461 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2462
2463 select_path(&panel, "src/", cx);
2464 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2465 cx.foreground().run_until_parked();
2466 assert_eq!(
2467 visible_entries_as_strings(&panel, 0..10, cx),
2468 &["v src <== selected", " > test"]
2469 );
2470 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2471 cx.read_window(window_id, |cx| {
2472 let panel = panel.read(cx);
2473 assert!(panel.filename_editor.is_focused(cx));
2474 });
2475 assert_eq!(
2476 visible_entries_as_strings(&panel, 0..10, cx),
2477 &["v src", " > [EDITOR: ''] <== selected", " > test"]
2478 );
2479 panel.update(cx, |panel, cx| {
2480 panel
2481 .filename_editor
2482 .update(cx, |editor, cx| editor.set_text("test", cx));
2483 assert!(
2484 panel.confirm(&Confirm, cx).is_none(),
2485 "Should not allow to confirm on conflicting new directory name"
2486 )
2487 });
2488 assert_eq!(
2489 visible_entries_as_strings(&panel, 0..10, cx),
2490 &["v src", " > test"],
2491 "File list should be unchanged after failed folder create confirmation"
2492 );
2493
2494 select_path(&panel, "src/test/", cx);
2495 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2496 cx.foreground().run_until_parked();
2497 assert_eq!(
2498 visible_entries_as_strings(&panel, 0..10, cx),
2499 &["v src", " > test <== selected"]
2500 );
2501 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2502 cx.read_window(window_id, |cx| {
2503 let panel = panel.read(cx);
2504 assert!(panel.filename_editor.is_focused(cx));
2505 });
2506 assert_eq!(
2507 visible_entries_as_strings(&panel, 0..10, cx),
2508 &[
2509 "v src",
2510 " v test",
2511 " [EDITOR: ''] <== selected",
2512 " first.rs",
2513 " second.rs",
2514 " third.rs"
2515 ]
2516 );
2517 panel.update(cx, |panel, cx| {
2518 panel
2519 .filename_editor
2520 .update(cx, |editor, cx| editor.set_text("first.rs", cx));
2521 assert!(
2522 panel.confirm(&Confirm, cx).is_none(),
2523 "Should not allow to confirm on conflicting new file name"
2524 )
2525 });
2526 assert_eq!(
2527 visible_entries_as_strings(&panel, 0..10, cx),
2528 &[
2529 "v src",
2530 " v test",
2531 " first.rs",
2532 " second.rs",
2533 " third.rs"
2534 ],
2535 "File list should be unchanged after failed file create confirmation"
2536 );
2537
2538 select_path(&panel, "src/test/first.rs", cx);
2539 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2540 cx.foreground().run_until_parked();
2541 assert_eq!(
2542 visible_entries_as_strings(&panel, 0..10, cx),
2543 &[
2544 "v src",
2545 " v test",
2546 " first.rs <== selected",
2547 " second.rs",
2548 " third.rs"
2549 ],
2550 );
2551 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2552 cx.read_window(window_id, |cx| {
2553 let panel = panel.read(cx);
2554 assert!(panel.filename_editor.is_focused(cx));
2555 });
2556 assert_eq!(
2557 visible_entries_as_strings(&panel, 0..10, cx),
2558 &[
2559 "v src",
2560 " v test",
2561 " [EDITOR: 'first.rs'] <== selected",
2562 " second.rs",
2563 " third.rs"
2564 ]
2565 );
2566 panel.update(cx, |panel, cx| {
2567 panel
2568 .filename_editor
2569 .update(cx, |editor, cx| editor.set_text("second.rs", cx));
2570 assert!(
2571 panel.confirm(&Confirm, cx).is_none(),
2572 "Should not allow to confirm on conflicting file rename"
2573 )
2574 });
2575 assert_eq!(
2576 visible_entries_as_strings(&panel, 0..10, cx),
2577 &[
2578 "v src",
2579 " v test",
2580 " first.rs <== selected",
2581 " second.rs",
2582 " third.rs"
2583 ],
2584 "File list should be unchanged after failed rename confirmation"
2585 );
2586 }
2587
2588 #[gpui::test]
2589 async fn test_new_search_in_directory_trigger(cx: &mut gpui::TestAppContext) {
2590 init_test_with_editor(cx);
2591
2592 let fs = FakeFs::new(cx.background());
2593 fs.insert_tree(
2594 "/src",
2595 json!({
2596 "test": {
2597 "first.rs": "// First Rust file",
2598 "second.rs": "// Second Rust file",
2599 "third.rs": "// Third Rust file",
2600 }
2601 }),
2602 )
2603 .await;
2604
2605 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2606 let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2607 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2608
2609 let new_search_events_count = Arc::new(AtomicUsize::new(0));
2610 let _subscription = panel.update(cx, |_, cx| {
2611 let subcription_count = Arc::clone(&new_search_events_count);
2612 cx.subscribe(&cx.handle(), move |_, _, event, _| {
2613 if matches!(event, Event::NewSearchInDirectory { .. }) {
2614 subcription_count.fetch_add(1, atomic::Ordering::SeqCst);
2615 }
2616 })
2617 });
2618
2619 toggle_expand_dir(&panel, "src/test", cx);
2620 select_path(&panel, "src/test/first.rs", cx);
2621 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2622 cx.foreground().run_until_parked();
2623 assert_eq!(
2624 visible_entries_as_strings(&panel, 0..10, cx),
2625 &[
2626 "v src",
2627 " v test",
2628 " first.rs <== selected",
2629 " second.rs",
2630 " third.rs"
2631 ]
2632 );
2633 panel.update(cx, |panel, cx| {
2634 panel.new_search_in_directory(&NewSearchInDirectory, cx)
2635 });
2636 assert_eq!(
2637 new_search_events_count.load(atomic::Ordering::SeqCst),
2638 0,
2639 "Should not trigger new search in directory when called on a file"
2640 );
2641
2642 select_path(&panel, "src/test", cx);
2643 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2644 cx.foreground().run_until_parked();
2645 assert_eq!(
2646 visible_entries_as_strings(&panel, 0..10, cx),
2647 &[
2648 "v src",
2649 " v test <== selected",
2650 " first.rs",
2651 " second.rs",
2652 " third.rs"
2653 ]
2654 );
2655 panel.update(cx, |panel, cx| {
2656 panel.new_search_in_directory(&NewSearchInDirectory, cx)
2657 });
2658 assert_eq!(
2659 new_search_events_count.load(atomic::Ordering::SeqCst),
2660 1,
2661 "Should trigger new search in directory when called on a directory"
2662 );
2663 }
2664
2665 #[gpui::test]
2666 async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) {
2667 init_test_with_editor(cx);
2668
2669 let fs = FakeFs::new(cx.background());
2670 fs.insert_tree(
2671 "/project_root",
2672 json!({
2673 "dir_1": {
2674 "nested_dir": {
2675 "file_a.py": "# File contents",
2676 "file_b.py": "# File contents",
2677 "file_c.py": "# File contents",
2678 },
2679 "file_1.py": "# File contents",
2680 "file_2.py": "# File contents",
2681 "file_3.py": "# File contents",
2682 },
2683 "dir_2": {
2684 "file_1.py": "# File contents",
2685 "file_2.py": "# File contents",
2686 "file_3.py": "# File contents",
2687 }
2688 }),
2689 )
2690 .await;
2691
2692 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
2693 let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2694 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2695
2696 panel.update(cx, |panel, cx| {
2697 panel.collapse_all_entries(&CollapseAllEntries, cx)
2698 });
2699 cx.foreground().run_until_parked();
2700 assert_eq!(
2701 visible_entries_as_strings(&panel, 0..10, cx),
2702 &["v project_root", " > dir_1", " > dir_2",]
2703 );
2704
2705 // Open dir_1 and make sure nested_dir was collapsed when running collapse_all_entries
2706 toggle_expand_dir(&panel, "project_root/dir_1", cx);
2707 cx.foreground().run_until_parked();
2708 assert_eq!(
2709 visible_entries_as_strings(&panel, 0..10, cx),
2710 &[
2711 "v project_root",
2712 " v dir_1 <== selected",
2713 " > nested_dir",
2714 " file_1.py",
2715 " file_2.py",
2716 " file_3.py",
2717 " > dir_2",
2718 ]
2719 );
2720 }
2721
2722 fn toggle_expand_dir(
2723 panel: &ViewHandle<ProjectPanel>,
2724 path: impl AsRef<Path>,
2725 cx: &mut TestAppContext,
2726 ) {
2727 let path = path.as_ref();
2728 panel.update(cx, |panel, cx| {
2729 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
2730 let worktree = worktree.read(cx);
2731 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
2732 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
2733 panel.toggle_expanded(entry_id, cx);
2734 return;
2735 }
2736 }
2737 panic!("no worktree for path {:?}", path);
2738 });
2739 }
2740
2741 fn select_path(
2742 panel: &ViewHandle<ProjectPanel>,
2743 path: impl AsRef<Path>,
2744 cx: &mut TestAppContext,
2745 ) {
2746 let path = path.as_ref();
2747 panel.update(cx, |panel, cx| {
2748 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
2749 let worktree = worktree.read(cx);
2750 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
2751 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
2752 panel.selection = Some(Selection {
2753 worktree_id: worktree.id(),
2754 entry_id,
2755 });
2756 return;
2757 }
2758 }
2759 panic!("no worktree for path {:?}", path);
2760 });
2761 }
2762
2763 fn visible_entries_as_strings(
2764 panel: &ViewHandle<ProjectPanel>,
2765 range: Range<usize>,
2766 cx: &mut TestAppContext,
2767 ) -> Vec<String> {
2768 let mut result = Vec::new();
2769 let mut project_entries = HashSet::new();
2770 let mut has_editor = false;
2771
2772 panel.update(cx, |panel, cx| {
2773 panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
2774 if details.is_editing {
2775 assert!(!has_editor, "duplicate editor entry");
2776 has_editor = true;
2777 } else {
2778 assert!(
2779 project_entries.insert(project_entry),
2780 "duplicate project entry {:?} {:?}",
2781 project_entry,
2782 details
2783 );
2784 }
2785
2786 let indent = " ".repeat(details.depth);
2787 let icon = if details.kind.is_dir() {
2788 if details.is_expanded {
2789 "v "
2790 } else {
2791 "> "
2792 }
2793 } else {
2794 " "
2795 };
2796 let name = if details.is_editing {
2797 format!("[EDITOR: '{}']", details.filename)
2798 } else if details.is_processing {
2799 format!("[PROCESSING: '{}']", details.filename)
2800 } else {
2801 details.filename.clone()
2802 };
2803 let selected = if details.is_selected {
2804 " <== selected"
2805 } else {
2806 ""
2807 };
2808 result.push(format!("{indent}{icon}{name}{selected}"));
2809 });
2810 });
2811
2812 result
2813 }
2814
2815 fn init_test(cx: &mut TestAppContext) {
2816 cx.foreground().forbid_parking();
2817 cx.update(|cx| {
2818 cx.set_global(SettingsStore::test(cx));
2819 init_settings(cx);
2820 theme::init((), cx);
2821 language::init(cx);
2822 editor::init_settings(cx);
2823 crate::init((), cx);
2824 workspace::init_settings(cx);
2825 Project::init_settings(cx);
2826 });
2827 }
2828
2829 fn init_test_with_editor(cx: &mut TestAppContext) {
2830 cx.foreground().forbid_parking();
2831 cx.update(|cx| {
2832 let app_state = AppState::test(cx);
2833 theme::init((), cx);
2834 init_settings(cx);
2835 language::init(cx);
2836 editor::init(cx);
2837 pane::init(cx);
2838 crate::init((), cx);
2839 workspace::init(app_state.clone(), cx);
2840 Project::init_settings(cx);
2841 });
2842 }
2843
2844 fn ensure_single_file_is_opened(
2845 window_id: usize,
2846 workspace: &ViewHandle<Workspace>,
2847 expected_path: &str,
2848 cx: &mut TestAppContext,
2849 ) {
2850 cx.read_window(window_id, |cx| {
2851 let workspace = workspace.read(cx);
2852 let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
2853 assert_eq!(worktrees.len(), 1);
2854 let worktree_id = WorktreeId::from_usize(worktrees[0].id());
2855
2856 let open_project_paths = workspace
2857 .panes()
2858 .iter()
2859 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
2860 .collect::<Vec<_>>();
2861 assert_eq!(
2862 open_project_paths,
2863 vec![ProjectPath {
2864 worktree_id,
2865 path: Arc::from(Path::new(expected_path))
2866 }],
2867 "Should have opened file, selected in project panel"
2868 );
2869 });
2870 }
2871
2872 fn submit_deletion(
2873 window_id: usize,
2874 panel: &ViewHandle<ProjectPanel>,
2875 cx: &mut TestAppContext,
2876 ) {
2877 assert!(
2878 !cx.has_pending_prompt(window_id),
2879 "Should have no prompts before the deletion"
2880 );
2881 panel.update(cx, |panel, cx| {
2882 panel
2883 .delete(&Delete, cx)
2884 .expect("Deletion start")
2885 .detach_and_log_err(cx);
2886 });
2887 assert!(
2888 cx.has_pending_prompt(window_id),
2889 "Should have a prompt after the deletion"
2890 );
2891 cx.simulate_prompt_answer(window_id, 0);
2892 assert!(
2893 !cx.has_pending_prompt(window_id),
2894 "Should have no prompts after prompt was replied to"
2895 );
2896 cx.foreground().run_until_parked();
2897 }
2898
2899 fn ensure_no_open_items_and_panes(
2900 window_id: usize,
2901 workspace: &ViewHandle<Workspace>,
2902 cx: &mut TestAppContext,
2903 ) {
2904 assert!(
2905 !cx.has_pending_prompt(window_id),
2906 "Should have no prompts after deletion operation closes the file"
2907 );
2908 cx.read_window(window_id, |cx| {
2909 let open_project_paths = workspace
2910 .read(cx)
2911 .panes()
2912 .iter()
2913 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
2914 .collect::<Vec<_>>();
2915 assert!(
2916 open_project_paths.is_empty(),
2917 "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
2918 );
2919 });
2920 }
2921}
2922// TODO - a workspace command?