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