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