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