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