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