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