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