1use context_menu::{ContextMenu, ContextMenuItem};
2use drag_and_drop::{DragAndDrop, Draggable};
3use editor::{Cancel, Editor};
4use futures::stream::StreamExt;
5use gpui::{
6 actions,
7 anyhow::{anyhow, Result},
8 elements::{
9 AnchorCorner, ChildView, ContainerStyle, Empty, Flex, Label, MouseEventHandler,
10 ParentElement, ScrollTarget, Stack, Svg, UniformList, UniformListState,
11 },
12 geometry::vector::Vector2F,
13 impl_internal_actions,
14 keymap_matcher::KeymapContext,
15 platform::{CursorStyle, MouseButton, PromptLevel},
16 AnyElement, AppContext, ClipboardItem, Element, Entity, ModelHandle, Task, View, ViewContext,
17 ViewHandle,
18};
19use menu::{Confirm, SelectNext, SelectPrev};
20use project::{Entry, EntryKind, Project, ProjectEntryId, ProjectPath, Worktree, WorktreeId};
21use settings::Settings;
22use std::{
23 cmp::Ordering,
24 collections::{hash_map, HashMap},
25 ffi::OsStr,
26 ops::Range,
27 path::Path,
28 sync::Arc,
29};
30use theme::ProjectPanelEntry;
31use unicase::UniCase;
32use workspace::Workspace;
33
34const NEW_ENTRY_ID: ProjectEntryId = ProjectEntryId::MAX;
35
36pub struct ProjectPanel {
37 project: ModelHandle<Project>,
38 list: UniformListState,
39 visible_entries: Vec<(WorktreeId, Vec<Entry>)>,
40 last_worktree_root_id: Option<ProjectEntryId>,
41 expanded_dir_ids: HashMap<WorktreeId, Vec<ProjectEntryId>>,
42 selection: Option<Selection>,
43 edit_state: Option<EditState>,
44 filename_editor: ViewHandle<Editor>,
45 clipboard_entry: Option<ClipboardEntry>,
46 context_menu: ViewHandle<ContextMenu>,
47 dragged_entry_destination: Option<Arc<Path>>,
48}
49
50#[derive(Copy, Clone)]
51struct Selection {
52 worktree_id: WorktreeId,
53 entry_id: ProjectEntryId,
54}
55
56#[derive(Clone, Debug)]
57struct EditState {
58 worktree_id: WorktreeId,
59 entry_id: ProjectEntryId,
60 is_new_entry: bool,
61 is_dir: bool,
62 processing_filename: Option<String>,
63}
64
65#[derive(Copy, Clone)]
66pub enum ClipboardEntry {
67 Copied {
68 worktree_id: WorktreeId,
69 entry_id: ProjectEntryId,
70 },
71 Cut {
72 worktree_id: WorktreeId,
73 entry_id: ProjectEntryId,
74 },
75}
76
77#[derive(Debug, PartialEq, Eq)]
78pub struct EntryDetails {
79 filename: String,
80 path: Arc<Path>,
81 depth: usize,
82 kind: EntryKind,
83 is_ignored: bool,
84 is_expanded: bool,
85 is_selected: bool,
86 is_editing: bool,
87 is_processing: bool,
88 is_cut: bool,
89}
90
91#[derive(Clone, PartialEq)]
92pub struct ToggleExpanded(pub ProjectEntryId);
93
94#[derive(Clone, PartialEq)]
95pub struct Open {
96 pub entry_id: ProjectEntryId,
97 pub change_focus: bool,
98}
99
100#[derive(Clone, PartialEq)]
101pub struct MoveProjectEntry {
102 pub entry_to_move: ProjectEntryId,
103 pub destination: ProjectEntryId,
104 pub destination_is_file: bool,
105}
106
107#[derive(Clone, PartialEq)]
108pub struct DeployContextMenu {
109 pub position: Vector2F,
110 pub entry_id: ProjectEntryId,
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);
131impl_internal_actions!(
132 project_panel,
133 [Open, ToggleExpanded, DeployContextMenu, MoveProjectEntry]
134);
135
136pub fn init(cx: &mut AppContext) {
137 cx.add_action(ProjectPanel::deploy_context_menu);
138 cx.add_action(ProjectPanel::expand_selected_entry);
139 cx.add_action(ProjectPanel::collapse_selected_entry);
140 cx.add_action(ProjectPanel::toggle_expanded);
141 cx.add_action(ProjectPanel::select_prev);
142 cx.add_action(ProjectPanel::select_next);
143 cx.add_action(ProjectPanel::open_entry);
144 cx.add_action(ProjectPanel::new_file);
145 cx.add_action(ProjectPanel::new_directory);
146 cx.add_action(ProjectPanel::rename);
147 cx.add_async_action(ProjectPanel::delete);
148 cx.add_async_action(ProjectPanel::confirm);
149 cx.add_action(ProjectPanel::cancel);
150 cx.add_action(ProjectPanel::cut);
151 cx.add_action(ProjectPanel::copy);
152 cx.add_action(ProjectPanel::copy_path);
153 cx.add_action(ProjectPanel::copy_relative_path);
154 cx.add_action(ProjectPanel::reveal_in_finder);
155 cx.add_action(
156 |this: &mut ProjectPanel, action: &Paste, cx: &mut ViewContext<ProjectPanel>| {
157 this.paste(action, cx);
158 },
159 );
160 cx.add_action(ProjectPanel::move_entry);
161}
162
163pub enum Event {
164 OpenedEntry {
165 entry_id: ProjectEntryId,
166 focus_opened_item: bool,
167 },
168}
169
170impl ProjectPanel {
171 pub fn new(project: ModelHandle<Project>, cx: &mut ViewContext<Workspace>) -> ViewHandle<Self> {
172 let project_panel = cx.add_view(|cx: &mut ViewContext<Self>| {
173 cx.observe(&project, |this, _, cx| {
174 this.update_visible_entries(None, cx);
175 cx.notify();
176 })
177 .detach();
178 cx.subscribe(&project, |this, project, event, cx| match event {
179 project::Event::ActiveEntryChanged(Some(entry_id)) => {
180 if let Some(worktree_id) = project.read(cx).worktree_id_for_entry(*entry_id, cx)
181 {
182 this.expand_entry(worktree_id, *entry_id, cx);
183 this.update_visible_entries(Some((worktree_id, *entry_id)), cx);
184 this.autoscroll(cx);
185 cx.notify();
186 }
187 }
188 project::Event::WorktreeRemoved(id) => {
189 this.expanded_dir_ids.remove(id);
190 this.update_visible_entries(None, cx);
191 cx.notify();
192 }
193 _ => {}
194 })
195 .detach();
196
197 let filename_editor = cx.add_view(|cx| {
198 Editor::single_line(
199 Some(Arc::new(|theme| {
200 let mut style = theme.project_panel.filename_editor.clone();
201 style.container.background_color.take();
202 style
203 })),
204 cx,
205 )
206 });
207
208 cx.subscribe(&filename_editor, |this, _, event, cx| match event {
209 editor::Event::BufferEdited | editor::Event::SelectionsChanged { .. } => {
210 this.autoscroll(cx);
211 }
212 _ => {}
213 })
214 .detach();
215 cx.observe_focus(&filename_editor, |this, _, is_focused, cx| {
216 if !is_focused
217 && this
218 .edit_state
219 .as_ref()
220 .map_or(false, |state| state.processing_filename.is_none())
221 {
222 this.edit_state = None;
223 this.update_visible_entries(None, cx);
224 }
225 })
226 .detach();
227
228 let mut this = Self {
229 project: project.clone(),
230 list: Default::default(),
231 visible_entries: Default::default(),
232 last_worktree_root_id: Default::default(),
233 expanded_dir_ids: Default::default(),
234 selection: None,
235 edit_state: None,
236 filename_editor,
237 clipboard_entry: None,
238 context_menu: cx.add_view(ContextMenu::new),
239 dragged_entry_destination: None,
240 };
241 this.update_visible_entries(None, cx);
242 this
243 });
244
245 cx.subscribe(&project_panel, {
246 let project_panel = project_panel.downgrade();
247 move |workspace, _, event, cx| match event {
248 &Event::OpenedEntry {
249 entry_id,
250 focus_opened_item,
251 } => {
252 if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
253 if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
254 workspace
255 .open_path(
256 ProjectPath {
257 worktree_id: worktree.read(cx).id(),
258 path: entry.path.clone(),
259 },
260 None,
261 focus_opened_item,
262 cx,
263 )
264 .detach_and_log_err(cx);
265 if !focus_opened_item {
266 if let Some(project_panel) = project_panel.upgrade(cx) {
267 cx.focus(&project_panel);
268 }
269 }
270 }
271 }
272 }
273 }
274 })
275 .detach();
276
277 project_panel
278 }
279
280 fn deploy_context_menu(&mut self, action: &DeployContextMenu, cx: &mut ViewContext<Self>) {
281 let project = self.project.read(cx);
282
283 let entry_id = action.entry_id;
284 let worktree_id = if let Some(id) = project.worktree_id_for_entry(entry_id, cx) {
285 id
286 } else {
287 return;
288 };
289
290 self.selection = Some(Selection {
291 worktree_id,
292 entry_id,
293 });
294
295 let mut menu_entries = Vec::new();
296 if let Some((worktree, entry)) = self.selected_entry(cx) {
297 let is_root = Some(entry) == worktree.root_entry();
298 if !project.is_remote() {
299 menu_entries.push(ContextMenuItem::item(
300 "Add Folder to Project",
301 workspace::AddFolderToProject,
302 ));
303 if is_root {
304 menu_entries.push(ContextMenuItem::item(
305 "Remove from Project",
306 workspace::RemoveWorktreeFromProject(worktree_id),
307 ));
308 }
309 }
310 menu_entries.push(ContextMenuItem::item("New File", NewFile));
311 menu_entries.push(ContextMenuItem::item("New Folder", NewDirectory));
312 menu_entries.push(ContextMenuItem::Separator);
313 menu_entries.push(ContextMenuItem::item("Cut", Cut));
314 menu_entries.push(ContextMenuItem::item("Copy", Copy));
315 menu_entries.push(ContextMenuItem::Separator);
316 menu_entries.push(ContextMenuItem::item("Copy Path", CopyPath));
317 menu_entries.push(ContextMenuItem::item(
318 "Copy Relative Path",
319 CopyRelativePath,
320 ));
321 menu_entries.push(ContextMenuItem::item("Reveal in Finder", RevealInFinder));
322 if let Some(clipboard_entry) = self.clipboard_entry {
323 if clipboard_entry.worktree_id() == worktree.id() {
324 menu_entries.push(ContextMenuItem::item("Paste", Paste));
325 }
326 }
327 menu_entries.push(ContextMenuItem::Separator);
328 menu_entries.push(ContextMenuItem::item("Rename", Rename));
329 if !is_root {
330 menu_entries.push(ContextMenuItem::item("Delete", Delete));
331 }
332 }
333
334 self.context_menu.update(cx, |menu, cx| {
335 menu.show(action.position, AnchorCorner::TopLeft, menu_entries, cx);
336 });
337
338 cx.notify();
339 }
340
341 fn expand_selected_entry(&mut self, _: &ExpandSelectedEntry, cx: &mut ViewContext<Self>) {
342 if let Some((worktree, entry)) = self.selected_entry(cx) {
343 if entry.is_dir() {
344 let expanded_dir_ids =
345 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree.id()) {
346 expanded_dir_ids
347 } else {
348 return;
349 };
350
351 match expanded_dir_ids.binary_search(&entry.id) {
352 Ok(_) => self.select_next(&SelectNext, cx),
353 Err(ix) => {
354 expanded_dir_ids.insert(ix, entry.id);
355 self.update_visible_entries(None, cx);
356 cx.notify();
357 }
358 }
359 }
360 }
361 }
362
363 fn collapse_selected_entry(&mut self, _: &CollapseSelectedEntry, cx: &mut ViewContext<Self>) {
364 if let Some((worktree, mut entry)) = self.selected_entry(cx) {
365 let expanded_dir_ids =
366 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree.id()) {
367 expanded_dir_ids
368 } else {
369 return;
370 };
371
372 loop {
373 match expanded_dir_ids.binary_search(&entry.id) {
374 Ok(ix) => {
375 expanded_dir_ids.remove(ix);
376 self.update_visible_entries(Some((worktree.id(), entry.id)), cx);
377 cx.notify();
378 break;
379 }
380 Err(_) => {
381 if let Some(parent_entry) =
382 entry.path.parent().and_then(|p| worktree.entry_for_path(p))
383 {
384 entry = parent_entry;
385 } else {
386 break;
387 }
388 }
389 }
390 }
391 }
392 }
393
394 fn toggle_expanded(&mut self, action: &ToggleExpanded, cx: &mut ViewContext<Self>) {
395 let entry_id = action.0;
396 if let Some(worktree_id) = self.project.read(cx).worktree_id_for_entry(entry_id, cx) {
397 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
398 match expanded_dir_ids.binary_search(&entry_id) {
399 Ok(ix) => {
400 expanded_dir_ids.remove(ix);
401 }
402 Err(ix) => {
403 expanded_dir_ids.insert(ix, entry_id);
404 }
405 }
406 self.update_visible_entries(Some((worktree_id, entry_id)), cx);
407 cx.focus_self();
408 cx.notify();
409 }
410 }
411 }
412
413 fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
414 if let Some(selection) = self.selection {
415 let (mut worktree_ix, mut entry_ix, _) =
416 self.index_for_selection(selection).unwrap_or_default();
417 if entry_ix > 0 {
418 entry_ix -= 1;
419 } else if worktree_ix > 0 {
420 worktree_ix -= 1;
421 entry_ix = self.visible_entries[worktree_ix].1.len() - 1;
422 } else {
423 return;
424 }
425
426 let (worktree_id, worktree_entries) = &self.visible_entries[worktree_ix];
427 self.selection = Some(Selection {
428 worktree_id: *worktree_id,
429 entry_id: worktree_entries[entry_ix].id,
430 });
431 self.autoscroll(cx);
432 cx.notify();
433 } else {
434 self.select_first(cx);
435 }
436 }
437
438 fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
439 if let Some(task) = self.confirm_edit(cx) {
440 Some(task)
441 } else if let Some((_, entry)) = self.selected_entry(cx) {
442 if entry.is_file() {
443 self.open_entry(
444 &Open {
445 entry_id: entry.id,
446 change_focus: true,
447 },
448 cx,
449 );
450 }
451 None
452 } else {
453 None
454 }
455 }
456
457 fn confirm_edit(&mut self, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
458 let edit_state = self.edit_state.as_mut()?;
459 cx.focus_self();
460
461 let worktree_id = edit_state.worktree_id;
462 let is_new_entry = edit_state.is_new_entry;
463 let is_dir = edit_state.is_dir;
464 let worktree = self.project.read(cx).worktree_for_id(worktree_id, cx)?;
465 let entry = worktree.read(cx).entry_for_id(edit_state.entry_id)?.clone();
466 let filename = self.filename_editor.read(cx).text(cx);
467
468 let edit_task;
469 let edited_entry_id;
470
471 if is_new_entry {
472 self.selection = Some(Selection {
473 worktree_id,
474 entry_id: NEW_ENTRY_ID,
475 });
476 let new_path = entry.path.join(&filename);
477 edited_entry_id = NEW_ENTRY_ID;
478 edit_task = self.project.update(cx, |project, cx| {
479 project.create_entry((worktree_id, new_path), is_dir, cx)
480 })?;
481 } else {
482 let new_path = if let Some(parent) = entry.path.clone().parent() {
483 parent.join(&filename)
484 } else {
485 filename.clone().into()
486 };
487 edited_entry_id = entry.id;
488 edit_task = self.project.update(cx, |project, cx| {
489 project.rename_entry(entry.id, new_path, cx)
490 })?;
491 };
492
493 edit_state.processing_filename = Some(filename);
494 cx.notify();
495
496 Some(cx.spawn(|this, mut cx| async move {
497 let new_entry = edit_task.await;
498 this.update(&mut cx, |this, cx| {
499 this.edit_state.take();
500 cx.notify();
501 })?;
502
503 let new_entry = new_entry?;
504 this.update(&mut cx, |this, cx| {
505 if let Some(selection) = &mut this.selection {
506 if selection.entry_id == edited_entry_id {
507 selection.worktree_id = worktree_id;
508 selection.entry_id = new_entry.id;
509 }
510 }
511 this.update_visible_entries(None, cx);
512 if is_new_entry && !is_dir {
513 this.open_entry(
514 &Open {
515 entry_id: new_entry.id,
516 change_focus: true,
517 },
518 cx,
519 );
520 }
521 cx.notify();
522 })?;
523 Ok(())
524 }))
525 }
526
527 fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
528 self.edit_state = None;
529 self.update_visible_entries(None, cx);
530 cx.focus_self();
531 cx.notify();
532 }
533
534 fn open_entry(&mut self, action: &Open, cx: &mut ViewContext<Self>) {
535 cx.emit(Event::OpenedEntry {
536 entry_id: action.entry_id,
537 focus_opened_item: action.change_focus,
538 });
539 }
540
541 fn new_file(&mut self, _: &NewFile, cx: &mut ViewContext<Self>) {
542 self.add_entry(false, cx)
543 }
544
545 fn new_directory(&mut self, _: &NewDirectory, cx: &mut ViewContext<Self>) {
546 self.add_entry(true, cx)
547 }
548
549 fn add_entry(&mut self, is_dir: bool, cx: &mut ViewContext<Self>) {
550 if let Some(Selection {
551 worktree_id,
552 entry_id,
553 }) = self.selection
554 {
555 let directory_id;
556 if let Some((worktree, expanded_dir_ids)) = self
557 .project
558 .read(cx)
559 .worktree_for_id(worktree_id, cx)
560 .zip(self.expanded_dir_ids.get_mut(&worktree_id))
561 {
562 let worktree = worktree.read(cx);
563 if let Some(mut entry) = worktree.entry_for_id(entry_id) {
564 loop {
565 if entry.is_dir() {
566 if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
567 expanded_dir_ids.insert(ix, entry.id);
568 }
569 directory_id = entry.id;
570 break;
571 } else {
572 if let Some(parent_path) = entry.path.parent() {
573 if let Some(parent_entry) = worktree.entry_for_path(parent_path) {
574 entry = parent_entry;
575 continue;
576 }
577 }
578 return;
579 }
580 }
581 } else {
582 return;
583 };
584 } else {
585 return;
586 };
587
588 self.edit_state = Some(EditState {
589 worktree_id,
590 entry_id: directory_id,
591 is_new_entry: true,
592 is_dir,
593 processing_filename: None,
594 });
595 self.filename_editor
596 .update(cx, |editor, cx| editor.clear(cx));
597 cx.focus(&self.filename_editor);
598 self.update_visible_entries(Some((worktree_id, NEW_ENTRY_ID)), cx);
599 self.autoscroll(cx);
600 cx.notify();
601 }
602 }
603
604 fn rename(&mut self, _: &Rename, cx: &mut ViewContext<Self>) {
605 if let Some(Selection {
606 worktree_id,
607 entry_id,
608 }) = self.selection
609 {
610 if let Some(worktree) = self.project.read(cx).worktree_for_id(worktree_id, cx) {
611 if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
612 self.edit_state = Some(EditState {
613 worktree_id,
614 entry_id,
615 is_new_entry: false,
616 is_dir: entry.is_dir(),
617 processing_filename: None,
618 });
619 let filename = entry
620 .path
621 .file_name()
622 .map_or(String::new(), |s| s.to_string_lossy().to_string());
623 self.filename_editor.update(cx, |editor, cx| {
624 editor.set_text(filename, cx);
625 editor.select_all(&Default::default(), cx);
626 });
627 cx.focus(&self.filename_editor);
628 self.update_visible_entries(None, cx);
629 self.autoscroll(cx);
630 cx.notify();
631 }
632 }
633
634 cx.update_global(|drag_and_drop: &mut DragAndDrop<Workspace>, cx| {
635 drag_and_drop.cancel_dragging::<ProjectEntryId>(cx);
636 })
637 }
638 }
639
640 fn delete(&mut self, _: &Delete, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
641 let Selection { entry_id, .. } = self.selection?;
642 let path = self.project.read(cx).path_for_entry(entry_id, cx)?.path;
643 let file_name = path.file_name()?;
644
645 let mut answer = cx.prompt(
646 PromptLevel::Info,
647 &format!("Delete {file_name:?}?"),
648 &["Delete", "Cancel"],
649 );
650 Some(cx.spawn(|this, mut cx| async move {
651 if answer.next().await != Some(0) {
652 return Ok(());
653 }
654 this.update(&mut cx, |this, cx| {
655 this.project
656 .update(cx, |project, cx| project.delete_entry(entry_id, cx))
657 .ok_or_else(|| anyhow!("no such entry"))
658 })??
659 .await
660 }))
661 }
662
663 fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
664 if let Some(selection) = self.selection {
665 let (mut worktree_ix, mut entry_ix, _) =
666 self.index_for_selection(selection).unwrap_or_default();
667 if let Some((_, worktree_entries)) = self.visible_entries.get(worktree_ix) {
668 if entry_ix + 1 < worktree_entries.len() {
669 entry_ix += 1;
670 } else {
671 worktree_ix += 1;
672 entry_ix = 0;
673 }
674 }
675
676 if let Some((worktree_id, worktree_entries)) = self.visible_entries.get(worktree_ix) {
677 if let Some(entry) = worktree_entries.get(entry_ix) {
678 self.selection = Some(Selection {
679 worktree_id: *worktree_id,
680 entry_id: entry.id,
681 });
682 self.autoscroll(cx);
683 cx.notify();
684 }
685 }
686 } else {
687 self.select_first(cx);
688 }
689 }
690
691 fn select_first(&mut self, cx: &mut ViewContext<Self>) {
692 let worktree = self
693 .visible_entries
694 .first()
695 .and_then(|(worktree_id, _)| self.project.read(cx).worktree_for_id(*worktree_id, cx));
696 if let Some(worktree) = worktree {
697 let worktree = worktree.read(cx);
698 let worktree_id = worktree.id();
699 if let Some(root_entry) = worktree.root_entry() {
700 self.selection = Some(Selection {
701 worktree_id,
702 entry_id: root_entry.id,
703 });
704 self.autoscroll(cx);
705 cx.notify();
706 }
707 }
708 }
709
710 fn autoscroll(&mut self, cx: &mut ViewContext<Self>) {
711 if let Some((_, _, index)) = self.selection.and_then(|s| self.index_for_selection(s)) {
712 self.list.scroll_to(ScrollTarget::Show(index));
713 cx.notify();
714 }
715 }
716
717 fn cut(&mut self, _: &Cut, cx: &mut ViewContext<Self>) {
718 if let Some((worktree, entry)) = self.selected_entry(cx) {
719 self.clipboard_entry = Some(ClipboardEntry::Cut {
720 worktree_id: worktree.id(),
721 entry_id: entry.id,
722 });
723 cx.notify();
724 }
725 }
726
727 fn copy(&mut self, _: &Copy, cx: &mut ViewContext<Self>) {
728 if let Some((worktree, entry)) = self.selected_entry(cx) {
729 self.clipboard_entry = Some(ClipboardEntry::Copied {
730 worktree_id: worktree.id(),
731 entry_id: entry.id,
732 });
733 cx.notify();
734 }
735 }
736
737 fn paste(&mut self, _: &Paste, cx: &mut ViewContext<Self>) -> Option<()> {
738 if let Some((worktree, entry)) = self.selected_entry(cx) {
739 let clipboard_entry = self.clipboard_entry?;
740 if clipboard_entry.worktree_id() != worktree.id() {
741 return None;
742 }
743
744 let clipboard_entry_file_name = self
745 .project
746 .read(cx)
747 .path_for_entry(clipboard_entry.entry_id(), cx)?
748 .path
749 .file_name()?
750 .to_os_string();
751
752 let mut new_path = entry.path.to_path_buf();
753 if entry.is_file() {
754 new_path.pop();
755 }
756
757 new_path.push(&clipboard_entry_file_name);
758 let extension = new_path.extension().map(|e| e.to_os_string());
759 let file_name_without_extension = Path::new(&clipboard_entry_file_name).file_stem()?;
760 let mut ix = 0;
761 while worktree.entry_for_path(&new_path).is_some() {
762 new_path.pop();
763
764 let mut new_file_name = file_name_without_extension.to_os_string();
765 new_file_name.push(" copy");
766 if ix > 0 {
767 new_file_name.push(format!(" {}", ix));
768 }
769 if let Some(extension) = extension.as_ref() {
770 new_file_name.push(".");
771 new_file_name.push(extension);
772 }
773
774 new_path.push(new_file_name);
775 ix += 1;
776 }
777
778 if clipboard_entry.is_cut() {
779 if let Some(task) = self.project.update(cx, |project, cx| {
780 project.rename_entry(clipboard_entry.entry_id(), new_path, cx)
781 }) {
782 task.detach_and_log_err(cx)
783 }
784 } else if let Some(task) = self.project.update(cx, |project, cx| {
785 project.copy_entry(clipboard_entry.entry_id(), new_path, cx)
786 }) {
787 task.detach_and_log_err(cx)
788 }
789 }
790 None
791 }
792
793 fn copy_path(&mut self, _: &CopyPath, cx: &mut ViewContext<Self>) {
794 if let Some((worktree, entry)) = self.selected_entry(cx) {
795 cx.write_to_clipboard(ClipboardItem::new(
796 worktree
797 .abs_path()
798 .join(&entry.path)
799 .to_string_lossy()
800 .to_string(),
801 ));
802 }
803 }
804
805 fn copy_relative_path(&mut self, _: &CopyRelativePath, cx: &mut ViewContext<Self>) {
806 if let Some((_, entry)) = self.selected_entry(cx) {
807 cx.write_to_clipboard(ClipboardItem::new(entry.path.to_string_lossy().to_string()));
808 }
809 }
810
811 fn reveal_in_finder(&mut self, _: &RevealInFinder, cx: &mut ViewContext<Self>) {
812 if let Some((worktree, entry)) = self.selected_entry(cx) {
813 cx.reveal_path(&worktree.abs_path().join(&entry.path));
814 }
815 }
816
817 fn move_entry(
818 &mut self,
819 &MoveProjectEntry {
820 entry_to_move,
821 destination,
822 destination_is_file,
823 }: &MoveProjectEntry,
824 cx: &mut ViewContext<Self>,
825 ) {
826 let destination_worktree = self.project.update(cx, |project, cx| {
827 let entry_path = project.path_for_entry(entry_to_move, cx)?;
828 let destination_entry_path = project.path_for_entry(destination, cx)?.path.clone();
829
830 let mut destination_path = destination_entry_path.as_ref();
831 if destination_is_file {
832 destination_path = destination_path.parent()?;
833 }
834
835 let mut new_path = destination_path.to_path_buf();
836 new_path.push(entry_path.path.file_name()?);
837 if new_path != entry_path.path.as_ref() {
838 let task = project.rename_entry(entry_to_move, new_path, cx)?;
839 cx.foreground().spawn(task).detach_and_log_err(cx);
840 }
841
842 Some(project.worktree_id_for_entry(destination, cx)?)
843 });
844
845 if let Some(destination_worktree) = destination_worktree {
846 self.expand_entry(destination_worktree, destination, cx);
847 }
848 }
849
850 fn index_for_selection(&self, selection: Selection) -> Option<(usize, usize, usize)> {
851 let mut entry_index = 0;
852 let mut visible_entries_index = 0;
853 for (worktree_index, (worktree_id, worktree_entries)) in
854 self.visible_entries.iter().enumerate()
855 {
856 if *worktree_id == selection.worktree_id {
857 for entry in worktree_entries {
858 if entry.id == selection.entry_id {
859 return Some((worktree_index, entry_index, visible_entries_index));
860 } else {
861 visible_entries_index += 1;
862 entry_index += 1;
863 }
864 }
865 break;
866 } else {
867 visible_entries_index += worktree_entries.len();
868 }
869 }
870 None
871 }
872
873 fn selected_entry<'a>(&self, cx: &'a AppContext) -> Option<(&'a Worktree, &'a project::Entry)> {
874 let selection = self.selection?;
875 let project = self.project.read(cx);
876 let worktree = project.worktree_for_id(selection.worktree_id, cx)?.read(cx);
877 Some((worktree, worktree.entry_for_id(selection.entry_id)?))
878 }
879
880 fn update_visible_entries(
881 &mut self,
882 new_selected_entry: Option<(WorktreeId, ProjectEntryId)>,
883 cx: &mut ViewContext<Self>,
884 ) {
885 let project = self.project.read(cx);
886 self.last_worktree_root_id = project
887 .visible_worktrees(cx)
888 .rev()
889 .next()
890 .and_then(|worktree| worktree.read(cx).root_entry())
891 .map(|entry| entry.id);
892
893 self.visible_entries.clear();
894 for worktree in project.visible_worktrees(cx) {
895 let snapshot = worktree.read(cx).snapshot();
896 let worktree_id = snapshot.id();
897
898 let expanded_dir_ids = match self.expanded_dir_ids.entry(worktree_id) {
899 hash_map::Entry::Occupied(e) => e.into_mut(),
900 hash_map::Entry::Vacant(e) => {
901 // The first time a worktree's root entry becomes available,
902 // mark that root entry as expanded.
903 if let Some(entry) = snapshot.root_entry() {
904 e.insert(vec![entry.id]).as_slice()
905 } else {
906 &[]
907 }
908 }
909 };
910
911 let mut new_entry_parent_id = None;
912 let mut new_entry_kind = EntryKind::Dir;
913 if let Some(edit_state) = &self.edit_state {
914 if edit_state.worktree_id == worktree_id && edit_state.is_new_entry {
915 new_entry_parent_id = Some(edit_state.entry_id);
916 new_entry_kind = if edit_state.is_dir {
917 EntryKind::Dir
918 } else {
919 EntryKind::File(Default::default())
920 };
921 }
922 }
923
924 let mut visible_worktree_entries = Vec::new();
925 let mut entry_iter = snapshot.entries(true);
926
927 while let Some(entry) = entry_iter.entry() {
928 visible_worktree_entries.push(entry.clone());
929 if Some(entry.id) == new_entry_parent_id {
930 visible_worktree_entries.push(Entry {
931 id: NEW_ENTRY_ID,
932 kind: new_entry_kind,
933 path: entry.path.join("\0").into(),
934 inode: 0,
935 mtime: entry.mtime,
936 is_symlink: false,
937 is_ignored: false,
938 });
939 }
940 if expanded_dir_ids.binary_search(&entry.id).is_err()
941 && entry_iter.advance_to_sibling()
942 {
943 continue;
944 }
945 entry_iter.advance();
946 }
947 visible_worktree_entries.sort_by(|entry_a, entry_b| {
948 let mut components_a = entry_a.path.components().peekable();
949 let mut components_b = entry_b.path.components().peekable();
950 loop {
951 match (components_a.next(), components_b.next()) {
952 (Some(component_a), Some(component_b)) => {
953 let a_is_file = components_a.peek().is_none() && entry_a.is_file();
954 let b_is_file = components_b.peek().is_none() && entry_b.is_file();
955 let ordering = a_is_file.cmp(&b_is_file).then_with(|| {
956 let name_a =
957 UniCase::new(component_a.as_os_str().to_string_lossy());
958 let name_b =
959 UniCase::new(component_b.as_os_str().to_string_lossy());
960 name_a.cmp(&name_b)
961 });
962 if !ordering.is_eq() {
963 return ordering;
964 }
965 }
966 (Some(_), None) => break Ordering::Greater,
967 (None, Some(_)) => break Ordering::Less,
968 (None, None) => break Ordering::Equal,
969 }
970 }
971 });
972 self.visible_entries
973 .push((worktree_id, visible_worktree_entries));
974 }
975
976 if let Some((worktree_id, entry_id)) = new_selected_entry {
977 self.selection = Some(Selection {
978 worktree_id,
979 entry_id,
980 });
981 }
982 }
983
984 fn expand_entry(
985 &mut self,
986 worktree_id: WorktreeId,
987 entry_id: ProjectEntryId,
988 cx: &mut ViewContext<Self>,
989 ) {
990 let project = self.project.read(cx);
991 if let Some((worktree, expanded_dir_ids)) = project
992 .worktree_for_id(worktree_id, cx)
993 .zip(self.expanded_dir_ids.get_mut(&worktree_id))
994 {
995 let worktree = worktree.read(cx);
996
997 if let Some(mut entry) = worktree.entry_for_id(entry_id) {
998 loop {
999 if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
1000 expanded_dir_ids.insert(ix, entry.id);
1001 }
1002
1003 if let Some(parent_entry) =
1004 entry.path.parent().and_then(|p| worktree.entry_for_path(p))
1005 {
1006 entry = parent_entry;
1007 } else {
1008 break;
1009 }
1010 }
1011 }
1012 }
1013 }
1014
1015 fn for_each_visible_entry(
1016 &self,
1017 range: Range<usize>,
1018 cx: &mut ViewContext<ProjectPanel>,
1019 mut callback: impl FnMut(ProjectEntryId, EntryDetails, &mut ViewContext<ProjectPanel>),
1020 ) {
1021 let mut ix = 0;
1022 for (worktree_id, visible_worktree_entries) in &self.visible_entries {
1023 if ix >= range.end {
1024 return;
1025 }
1026
1027 if ix + visible_worktree_entries.len() <= range.start {
1028 ix += visible_worktree_entries.len();
1029 continue;
1030 }
1031
1032 let end_ix = range.end.min(ix + visible_worktree_entries.len());
1033 if let Some(worktree) = self.project.read(cx).worktree_for_id(*worktree_id, cx) {
1034 let snapshot = worktree.read(cx).snapshot();
1035 let root_name = OsStr::new(snapshot.root_name());
1036 let expanded_entry_ids = self
1037 .expanded_dir_ids
1038 .get(&snapshot.id())
1039 .map(Vec::as_slice)
1040 .unwrap_or(&[]);
1041
1042 let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
1043 for entry in &visible_worktree_entries[entry_range] {
1044 let mut details = EntryDetails {
1045 filename: entry
1046 .path
1047 .file_name()
1048 .unwrap_or(root_name)
1049 .to_string_lossy()
1050 .to_string(),
1051 path: entry.path.clone(),
1052 depth: entry.path.components().count(),
1053 kind: entry.kind,
1054 is_ignored: entry.is_ignored,
1055 is_expanded: expanded_entry_ids.binary_search(&entry.id).is_ok(),
1056 is_selected: self.selection.map_or(false, |e| {
1057 e.worktree_id == snapshot.id() && e.entry_id == entry.id
1058 }),
1059 is_editing: false,
1060 is_processing: false,
1061 is_cut: self
1062 .clipboard_entry
1063 .map_or(false, |e| e.is_cut() && e.entry_id() == entry.id),
1064 };
1065
1066 if let Some(edit_state) = &self.edit_state {
1067 let is_edited_entry = if edit_state.is_new_entry {
1068 entry.id == NEW_ENTRY_ID
1069 } else {
1070 entry.id == edit_state.entry_id
1071 };
1072
1073 if is_edited_entry {
1074 if let Some(processing_filename) = &edit_state.processing_filename {
1075 details.is_processing = true;
1076 details.filename.clear();
1077 details.filename.push_str(processing_filename);
1078 } else {
1079 if edit_state.is_new_entry {
1080 details.filename.clear();
1081 }
1082 details.is_editing = true;
1083 }
1084 }
1085 }
1086
1087 callback(entry.id, details, cx);
1088 }
1089 }
1090 ix = end_ix;
1091 }
1092 }
1093
1094 fn render_entry_visual_element<V: View>(
1095 details: &EntryDetails,
1096 editor: Option<&ViewHandle<Editor>>,
1097 padding: f32,
1098 row_container_style: ContainerStyle,
1099 style: &ProjectPanelEntry,
1100 cx: &mut ViewContext<V>,
1101 ) -> AnyElement<V> {
1102 let kind = details.kind;
1103 let show_editor = details.is_editing && !details.is_processing;
1104
1105 Flex::row()
1106 .with_child(
1107 if kind == EntryKind::Dir {
1108 if details.is_expanded {
1109 Svg::new("icons/chevron_down_8.svg").with_color(style.icon_color)
1110 } else {
1111 Svg::new("icons/chevron_right_8.svg").with_color(style.icon_color)
1112 }
1113 .constrained()
1114 } else {
1115 Empty::new().constrained()
1116 }
1117 .with_max_width(style.icon_size)
1118 .with_max_height(style.icon_size)
1119 .aligned()
1120 .constrained()
1121 .with_width(style.icon_size),
1122 )
1123 .with_child(if show_editor && editor.is_some() {
1124 ChildView::new(editor.as_ref().unwrap(), cx)
1125 .contained()
1126 .with_margin_left(style.icon_spacing)
1127 .aligned()
1128 .left()
1129 .flex(1.0, true)
1130 .into_any()
1131 } else {
1132 Label::new(details.filename.clone(), style.text.clone())
1133 .contained()
1134 .with_margin_left(style.icon_spacing)
1135 .aligned()
1136 .left()
1137 .into_any()
1138 })
1139 .constrained()
1140 .with_height(style.height)
1141 .contained()
1142 .with_style(row_container_style)
1143 .with_padding_left(padding)
1144 .into_any_named("project panel entry visual element")
1145 }
1146
1147 fn render_entry(
1148 entry_id: ProjectEntryId,
1149 details: EntryDetails,
1150 editor: &ViewHandle<Editor>,
1151 dragged_entry_destination: &mut Option<Arc<Path>>,
1152 theme: &theme::ProjectPanel,
1153 cx: &mut ViewContext<Self>,
1154 ) -> AnyElement<Self> {
1155 let kind = details.kind;
1156 let path = details.path.clone();
1157 let padding = theme.container.padding.left + details.depth as f32 * theme.indent_width;
1158
1159 let entry_style = if details.is_cut {
1160 &theme.cut_entry
1161 } else if details.is_ignored {
1162 &theme.ignored_entry
1163 } else {
1164 &theme.entry
1165 };
1166
1167 let show_editor = details.is_editing && !details.is_processing;
1168
1169 MouseEventHandler::<Self, _>::new(entry_id.to_usize(), cx, |state, cx| {
1170 let mut style = entry_style.style_for(state, details.is_selected).clone();
1171
1172 if cx
1173 .global::<DragAndDrop<Workspace>>()
1174 .currently_dragged::<ProjectEntryId>(cx.window_id())
1175 .is_some()
1176 && dragged_entry_destination
1177 .as_ref()
1178 .filter(|destination| details.path.starts_with(destination))
1179 .is_some()
1180 {
1181 style = entry_style.active.clone().unwrap();
1182 }
1183
1184 let row_container_style = if show_editor {
1185 theme.filename_editor.container
1186 } else {
1187 style.container
1188 };
1189
1190 Self::render_entry_visual_element(
1191 &details,
1192 Some(editor),
1193 padding,
1194 row_container_style,
1195 &style,
1196 cx,
1197 )
1198 })
1199 .on_click(MouseButton::Left, move |e, _, cx| {
1200 if !show_editor {
1201 if kind == EntryKind::Dir {
1202 cx.dispatch_action(ToggleExpanded(entry_id))
1203 } else {
1204 cx.dispatch_action(Open {
1205 entry_id,
1206 change_focus: e.click_count > 1,
1207 })
1208 }
1209 }
1210 })
1211 .on_down(MouseButton::Right, move |e, _, cx| {
1212 cx.dispatch_action(DeployContextMenu {
1213 entry_id,
1214 position: e.position,
1215 })
1216 })
1217 .on_up(MouseButton::Left, move |_, _, cx| {
1218 if let Some((_, dragged_entry)) = cx
1219 .global::<DragAndDrop<Workspace>>()
1220 .currently_dragged::<ProjectEntryId>(cx.window_id())
1221 {
1222 cx.dispatch_action(MoveProjectEntry {
1223 entry_to_move: *dragged_entry,
1224 destination: entry_id,
1225 destination_is_file: matches!(details.kind, EntryKind::File(_)),
1226 });
1227 }
1228 })
1229 .on_move(move |_, this, cx| {
1230 if cx
1231 .global::<DragAndDrop<Workspace>>()
1232 .currently_dragged::<ProjectEntryId>(cx.window_id())
1233 .is_some()
1234 {
1235 this.dragged_entry_destination = if matches!(kind, EntryKind::File(_)) {
1236 path.parent().map(|parent| Arc::from(parent))
1237 } else {
1238 Some(path.clone())
1239 };
1240 }
1241 })
1242 .as_draggable(entry_id, {
1243 let row_container_style = theme.dragged_entry.container;
1244
1245 move |_, cx: &mut ViewContext<Workspace>| {
1246 let theme = cx.global::<Settings>().theme.clone();
1247 Self::render_entry_visual_element(
1248 &details,
1249 None,
1250 padding,
1251 row_container_style,
1252 &theme.project_panel.dragged_entry,
1253 cx,
1254 )
1255 }
1256 })
1257 .with_cursor_style(CursorStyle::PointingHand)
1258 .into_any_named("project panel entry")
1259 }
1260}
1261
1262impl View for ProjectPanel {
1263 fn ui_name() -> &'static str {
1264 "ProjectPanel"
1265 }
1266
1267 fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> gpui::AnyElement<Self> {
1268 enum ProjectPanel {}
1269 let theme = &cx.global::<Settings>().theme.project_panel;
1270 let mut container_style = theme.container;
1271 let padding = std::mem::take(&mut container_style.padding);
1272 let last_worktree_root_id = self.last_worktree_root_id;
1273
1274 let has_worktree = self.visible_entries.len() != 0;
1275
1276 if has_worktree {
1277 Stack::new()
1278 .with_child(
1279 MouseEventHandler::<ProjectPanel, _>::new(0, cx, |_, cx| {
1280 UniformList::new(
1281 self.list.clone(),
1282 self.visible_entries
1283 .iter()
1284 .map(|(_, worktree_entries)| worktree_entries.len())
1285 .sum(),
1286 cx,
1287 move |this, range, items, cx| {
1288 let theme = cx.global::<Settings>().theme.clone();
1289 let mut dragged_entry_destination =
1290 this.dragged_entry_destination.clone();
1291 this.for_each_visible_entry(range, cx, |id, details, cx| {
1292 items.push(Self::render_entry(
1293 id,
1294 details,
1295 &this.filename_editor,
1296 &mut dragged_entry_destination,
1297 &theme.project_panel,
1298 cx,
1299 ));
1300 });
1301 this.dragged_entry_destination = dragged_entry_destination;
1302 },
1303 )
1304 .with_padding_top(padding.top)
1305 .with_padding_bottom(padding.bottom)
1306 .contained()
1307 .with_style(container_style)
1308 .expanded()
1309 })
1310 .on_down(MouseButton::Right, move |e, _, cx| {
1311 // When deploying the context menu anywhere below the last project entry,
1312 // act as if the user clicked the root of the last worktree.
1313 if let Some(entry_id) = last_worktree_root_id {
1314 cx.dispatch_action(DeployContextMenu {
1315 entry_id,
1316 position: e.position,
1317 })
1318 }
1319 }),
1320 )
1321 .with_child(ChildView::new(&self.context_menu, cx))
1322 .into_any_named("project panel")
1323 } else {
1324 Flex::column()
1325 .with_child(
1326 MouseEventHandler::<Self, _>::new(2, cx, {
1327 let button_style = theme.open_project_button.clone();
1328 let context_menu_item_style =
1329 cx.global::<Settings>().theme.context_menu.item.clone();
1330 move |state, cx| {
1331 let button_style = button_style.style_for(state, false).clone();
1332 let context_menu_item =
1333 context_menu_item_style.style_for(state, true).clone();
1334
1335 theme::ui::keystroke_label(
1336 "Open a project",
1337 &button_style,
1338 &context_menu_item.keystroke,
1339 Box::new(workspace::Open),
1340 cx,
1341 )
1342 }
1343 })
1344 .on_click(MouseButton::Left, move |_, _, cx| {
1345 cx.dispatch_action(workspace::Open)
1346 })
1347 .with_cursor_style(CursorStyle::PointingHand),
1348 )
1349 .contained()
1350 .with_style(container_style)
1351 .into_any_named("empty project panel")
1352 }
1353 }
1354
1355 fn update_keymap_context(&self, keymap: &mut KeymapContext, _: &AppContext) {
1356 Self::reset_to_default_keymap_context(keymap);
1357 keymap.add_identifier("menu");
1358 }
1359}
1360
1361impl Entity for ProjectPanel {
1362 type Event = Event;
1363}
1364
1365impl workspace::sidebar::SidebarItem for ProjectPanel {
1366 fn should_show_badge(&self, _: &AppContext) -> bool {
1367 false
1368 }
1369}
1370
1371impl ClipboardEntry {
1372 fn is_cut(&self) -> bool {
1373 matches!(self, Self::Cut { .. })
1374 }
1375
1376 fn entry_id(&self) -> ProjectEntryId {
1377 match self {
1378 ClipboardEntry::Copied { entry_id, .. } | ClipboardEntry::Cut { entry_id, .. } => {
1379 *entry_id
1380 }
1381 }
1382 }
1383
1384 fn worktree_id(&self) -> WorktreeId {
1385 match self {
1386 ClipboardEntry::Copied { worktree_id, .. }
1387 | ClipboardEntry::Cut { worktree_id, .. } => *worktree_id,
1388 }
1389 }
1390}
1391
1392#[cfg(test)]
1393mod tests {
1394 use super::*;
1395 use gpui::{TestAppContext, ViewHandle};
1396 use project::FakeFs;
1397 use serde_json::json;
1398 use std::{collections::HashSet, path::Path};
1399
1400 #[gpui::test]
1401 async fn test_visible_list(cx: &mut gpui::TestAppContext) {
1402 cx.foreground().forbid_parking();
1403 cx.update(|cx| {
1404 let settings = Settings::test(cx);
1405 cx.set_global(settings);
1406 });
1407
1408 let fs = FakeFs::new(cx.background());
1409 fs.insert_tree(
1410 "/root1",
1411 json!({
1412 ".dockerignore": "",
1413 ".git": {
1414 "HEAD": "",
1415 },
1416 "a": {
1417 "0": { "q": "", "r": "", "s": "" },
1418 "1": { "t": "", "u": "" },
1419 "2": { "v": "", "w": "", "x": "", "y": "" },
1420 },
1421 "b": {
1422 "3": { "Q": "" },
1423 "4": { "R": "", "S": "", "T": "", "U": "" },
1424 },
1425 "C": {
1426 "5": {},
1427 "6": { "V": "", "W": "" },
1428 "7": { "X": "" },
1429 "8": { "Y": {}, "Z": "" }
1430 }
1431 }),
1432 )
1433 .await;
1434 fs.insert_tree(
1435 "/root2",
1436 json!({
1437 "d": {
1438 "9": ""
1439 },
1440 "e": {}
1441 }),
1442 )
1443 .await;
1444
1445 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1446 let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1447 let panel = workspace.update(cx, |_, cx| ProjectPanel::new(project, cx));
1448 assert_eq!(
1449 visible_entries_as_strings(&panel, 0..50, cx),
1450 &[
1451 "v root1",
1452 " > .git",
1453 " > a",
1454 " > b",
1455 " > C",
1456 " .dockerignore",
1457 "v root2",
1458 " > d",
1459 " > e",
1460 ]
1461 );
1462
1463 toggle_expand_dir(&panel, "root1/b", cx);
1464 assert_eq!(
1465 visible_entries_as_strings(&panel, 0..50, cx),
1466 &[
1467 "v root1",
1468 " > .git",
1469 " > a",
1470 " v b <== selected",
1471 " > 3",
1472 " > 4",
1473 " > C",
1474 " .dockerignore",
1475 "v root2",
1476 " > d",
1477 " > e",
1478 ]
1479 );
1480
1481 assert_eq!(
1482 visible_entries_as_strings(&panel, 6..9, cx),
1483 &[
1484 //
1485 " > C",
1486 " .dockerignore",
1487 "v root2",
1488 ]
1489 );
1490 }
1491
1492 #[gpui::test(iterations = 30)]
1493 async fn test_editing_files(cx: &mut gpui::TestAppContext) {
1494 cx.foreground().forbid_parking();
1495 cx.update(|cx| {
1496 let settings = Settings::test(cx);
1497 cx.set_global(settings);
1498 });
1499
1500 let fs = FakeFs::new(cx.background());
1501 fs.insert_tree(
1502 "/root1",
1503 json!({
1504 ".dockerignore": "",
1505 ".git": {
1506 "HEAD": "",
1507 },
1508 "a": {
1509 "0": { "q": "", "r": "", "s": "" },
1510 "1": { "t": "", "u": "" },
1511 "2": { "v": "", "w": "", "x": "", "y": "" },
1512 },
1513 "b": {
1514 "3": { "Q": "" },
1515 "4": { "R": "", "S": "", "T": "", "U": "" },
1516 },
1517 "C": {
1518 "5": {},
1519 "6": { "V": "", "W": "" },
1520 "7": { "X": "" },
1521 "8": { "Y": {}, "Z": "" }
1522 }
1523 }),
1524 )
1525 .await;
1526 fs.insert_tree(
1527 "/root2",
1528 json!({
1529 "d": {
1530 "9": ""
1531 },
1532 "e": {}
1533 }),
1534 )
1535 .await;
1536
1537 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1538 let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1539 let panel = workspace.update(cx, |_, cx| ProjectPanel::new(project, cx));
1540
1541 select_path(&panel, "root1", cx);
1542 assert_eq!(
1543 visible_entries_as_strings(&panel, 0..10, cx),
1544 &[
1545 "v root1 <== selected",
1546 " > .git",
1547 " > a",
1548 " > b",
1549 " > C",
1550 " .dockerignore",
1551 "v root2",
1552 " > d",
1553 " > e",
1554 ]
1555 );
1556
1557 // Add a file with the root folder selected. The filename editor is placed
1558 // before the first file in the root folder.
1559 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
1560 cx.read_window(window_id, |cx| {
1561 let panel = panel.read(cx);
1562 assert!(panel.filename_editor.is_focused(cx));
1563 });
1564 assert_eq!(
1565 visible_entries_as_strings(&panel, 0..10, cx),
1566 &[
1567 "v root1",
1568 " > .git",
1569 " > a",
1570 " > b",
1571 " > C",
1572 " [EDITOR: ''] <== selected",
1573 " .dockerignore",
1574 "v root2",
1575 " > d",
1576 " > e",
1577 ]
1578 );
1579
1580 let confirm = panel.update(cx, |panel, cx| {
1581 panel
1582 .filename_editor
1583 .update(cx, |editor, cx| editor.set_text("the-new-filename", cx));
1584 panel.confirm(&Confirm, cx).unwrap()
1585 });
1586 assert_eq!(
1587 visible_entries_as_strings(&panel, 0..10, cx),
1588 &[
1589 "v root1",
1590 " > .git",
1591 " > a",
1592 " > b",
1593 " > C",
1594 " [PROCESSING: 'the-new-filename'] <== selected",
1595 " .dockerignore",
1596 "v root2",
1597 " > d",
1598 " > e",
1599 ]
1600 );
1601
1602 confirm.await.unwrap();
1603 assert_eq!(
1604 visible_entries_as_strings(&panel, 0..10, cx),
1605 &[
1606 "v root1",
1607 " > .git",
1608 " > a",
1609 " > b",
1610 " > C",
1611 " .dockerignore",
1612 " the-new-filename <== selected",
1613 "v root2",
1614 " > d",
1615 " > e",
1616 ]
1617 );
1618
1619 select_path(&panel, "root1/b", cx);
1620 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
1621 assert_eq!(
1622 visible_entries_as_strings(&panel, 0..10, cx),
1623 &[
1624 "v root1",
1625 " > .git",
1626 " > a",
1627 " v b",
1628 " > 3",
1629 " > 4",
1630 " [EDITOR: ''] <== selected",
1631 " > C",
1632 " .dockerignore",
1633 " the-new-filename",
1634 ]
1635 );
1636
1637 panel
1638 .update(cx, |panel, cx| {
1639 panel
1640 .filename_editor
1641 .update(cx, |editor, cx| editor.set_text("another-filename", cx));
1642 panel.confirm(&Confirm, cx).unwrap()
1643 })
1644 .await
1645 .unwrap();
1646 assert_eq!(
1647 visible_entries_as_strings(&panel, 0..10, cx),
1648 &[
1649 "v root1",
1650 " > .git",
1651 " > a",
1652 " v b",
1653 " > 3",
1654 " > 4",
1655 " another-filename <== selected",
1656 " > C",
1657 " .dockerignore",
1658 " the-new-filename",
1659 ]
1660 );
1661
1662 select_path(&panel, "root1/b/another-filename", cx);
1663 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
1664 assert_eq!(
1665 visible_entries_as_strings(&panel, 0..10, cx),
1666 &[
1667 "v root1",
1668 " > .git",
1669 " > a",
1670 " v b",
1671 " > 3",
1672 " > 4",
1673 " [EDITOR: 'another-filename'] <== selected",
1674 " > C",
1675 " .dockerignore",
1676 " the-new-filename",
1677 ]
1678 );
1679
1680 let confirm = panel.update(cx, |panel, cx| {
1681 panel
1682 .filename_editor
1683 .update(cx, |editor, cx| editor.set_text("a-different-filename", cx));
1684 panel.confirm(&Confirm, cx).unwrap()
1685 });
1686 assert_eq!(
1687 visible_entries_as_strings(&panel, 0..10, cx),
1688 &[
1689 "v root1",
1690 " > .git",
1691 " > a",
1692 " v b",
1693 " > 3",
1694 " > 4",
1695 " [PROCESSING: 'a-different-filename'] <== selected",
1696 " > C",
1697 " .dockerignore",
1698 " the-new-filename",
1699 ]
1700 );
1701
1702 confirm.await.unwrap();
1703 assert_eq!(
1704 visible_entries_as_strings(&panel, 0..10, cx),
1705 &[
1706 "v root1",
1707 " > .git",
1708 " > a",
1709 " v b",
1710 " > 3",
1711 " > 4",
1712 " a-different-filename <== selected",
1713 " > C",
1714 " .dockerignore",
1715 " the-new-filename",
1716 ]
1717 );
1718
1719 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
1720 assert_eq!(
1721 visible_entries_as_strings(&panel, 0..10, cx),
1722 &[
1723 "v root1",
1724 " > .git",
1725 " > a",
1726 " v b",
1727 " > [EDITOR: ''] <== selected",
1728 " > 3",
1729 " > 4",
1730 " a-different-filename",
1731 " > C",
1732 " .dockerignore",
1733 ]
1734 );
1735
1736 let confirm = panel.update(cx, |panel, cx| {
1737 panel
1738 .filename_editor
1739 .update(cx, |editor, cx| editor.set_text("new-dir", cx));
1740 panel.confirm(&Confirm, cx).unwrap()
1741 });
1742 panel.update(cx, |panel, cx| panel.select_next(&Default::default(), cx));
1743 assert_eq!(
1744 visible_entries_as_strings(&panel, 0..10, cx),
1745 &[
1746 "v root1",
1747 " > .git",
1748 " > a",
1749 " v b",
1750 " > [PROCESSING: 'new-dir']",
1751 " > 3 <== selected",
1752 " > 4",
1753 " a-different-filename",
1754 " > C",
1755 " .dockerignore",
1756 ]
1757 );
1758
1759 confirm.await.unwrap();
1760 assert_eq!(
1761 visible_entries_as_strings(&panel, 0..10, cx),
1762 &[
1763 "v root1",
1764 " > .git",
1765 " > a",
1766 " v b",
1767 " > 3 <== selected",
1768 " > 4",
1769 " > new-dir",
1770 " a-different-filename",
1771 " > C",
1772 " .dockerignore",
1773 ]
1774 );
1775
1776 panel.update(cx, |panel, cx| panel.rename(&Default::default(), cx));
1777 assert_eq!(
1778 visible_entries_as_strings(&panel, 0..10, cx),
1779 &[
1780 "v root1",
1781 " > .git",
1782 " > a",
1783 " v b",
1784 " > [EDITOR: '3'] <== selected",
1785 " > 4",
1786 " > new-dir",
1787 " a-different-filename",
1788 " > C",
1789 " .dockerignore",
1790 ]
1791 );
1792
1793 // Dismiss the rename editor when it loses focus.
1794 workspace.update(cx, |_, cx| cx.focus_self());
1795 assert_eq!(
1796 visible_entries_as_strings(&panel, 0..10, cx),
1797 &[
1798 "v root1",
1799 " > .git",
1800 " > a",
1801 " v b",
1802 " > 3 <== selected",
1803 " > 4",
1804 " > new-dir",
1805 " a-different-filename",
1806 " > C",
1807 " .dockerignore",
1808 ]
1809 );
1810 }
1811
1812 #[gpui::test]
1813 async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
1814 cx.foreground().forbid_parking();
1815 cx.update(|cx| {
1816 let settings = Settings::test(cx);
1817 cx.set_global(settings);
1818 });
1819
1820 let fs = FakeFs::new(cx.background());
1821 fs.insert_tree(
1822 "/root1",
1823 json!({
1824 "one.two.txt": "",
1825 "one.txt": ""
1826 }),
1827 )
1828 .await;
1829
1830 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
1831 let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1832 let panel = workspace.update(cx, |_, cx| ProjectPanel::new(project, cx));
1833
1834 panel.update(cx, |panel, cx| {
1835 panel.select_next(&Default::default(), cx);
1836 panel.select_next(&Default::default(), cx);
1837 });
1838
1839 assert_eq!(
1840 visible_entries_as_strings(&panel, 0..50, cx),
1841 &[
1842 //
1843 "v root1",
1844 " one.two.txt <== selected",
1845 " one.txt",
1846 ]
1847 );
1848
1849 // Regression test - file name is created correctly when
1850 // the copied file's name contains multiple dots.
1851 panel.update(cx, |panel, cx| {
1852 panel.copy(&Default::default(), cx);
1853 panel.paste(&Default::default(), cx);
1854 });
1855 cx.foreground().run_until_parked();
1856
1857 assert_eq!(
1858 visible_entries_as_strings(&panel, 0..50, cx),
1859 &[
1860 //
1861 "v root1",
1862 " one.two copy.txt",
1863 " one.two.txt <== selected",
1864 " one.txt",
1865 ]
1866 );
1867
1868 panel.update(cx, |panel, cx| {
1869 panel.paste(&Default::default(), cx);
1870 });
1871 cx.foreground().run_until_parked();
1872
1873 assert_eq!(
1874 visible_entries_as_strings(&panel, 0..50, cx),
1875 &[
1876 //
1877 "v root1",
1878 " one.two copy 1.txt",
1879 " one.two copy.txt",
1880 " one.two.txt <== selected",
1881 " one.txt",
1882 ]
1883 );
1884 }
1885
1886 fn toggle_expand_dir(
1887 panel: &ViewHandle<ProjectPanel>,
1888 path: impl AsRef<Path>,
1889 cx: &mut TestAppContext,
1890 ) {
1891 let path = path.as_ref();
1892 panel.update(cx, |panel, cx| {
1893 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
1894 let worktree = worktree.read(cx);
1895 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
1896 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
1897 panel.toggle_expanded(&ToggleExpanded(entry_id), cx);
1898 return;
1899 }
1900 }
1901 panic!("no worktree for path {:?}", path);
1902 });
1903 }
1904
1905 fn select_path(
1906 panel: &ViewHandle<ProjectPanel>,
1907 path: impl AsRef<Path>,
1908 cx: &mut TestAppContext,
1909 ) {
1910 let path = path.as_ref();
1911 panel.update(cx, |panel, cx| {
1912 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
1913 let worktree = worktree.read(cx);
1914 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
1915 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
1916 panel.selection = Some(Selection {
1917 worktree_id: worktree.id(),
1918 entry_id,
1919 });
1920 return;
1921 }
1922 }
1923 panic!("no worktree for path {:?}", path);
1924 });
1925 }
1926
1927 fn visible_entries_as_strings(
1928 panel: &ViewHandle<ProjectPanel>,
1929 range: Range<usize>,
1930 cx: &mut TestAppContext,
1931 ) -> Vec<String> {
1932 let mut result = Vec::new();
1933 let mut project_entries = HashSet::new();
1934 let mut has_editor = false;
1935
1936 panel.update(cx, |panel, cx| {
1937 panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
1938 if details.is_editing {
1939 assert!(!has_editor, "duplicate editor entry");
1940 has_editor = true;
1941 } else {
1942 assert!(
1943 project_entries.insert(project_entry),
1944 "duplicate project entry {:?} {:?}",
1945 project_entry,
1946 details
1947 );
1948 }
1949
1950 let indent = " ".repeat(details.depth);
1951 let icon = if matches!(details.kind, EntryKind::Dir | EntryKind::PendingDir) {
1952 if details.is_expanded {
1953 "v "
1954 } else {
1955 "> "
1956 }
1957 } else {
1958 " "
1959 };
1960 let name = if details.is_editing {
1961 format!("[EDITOR: '{}']", details.filename)
1962 } else if details.is_processing {
1963 format!("[PROCESSING: '{}']", details.filename)
1964 } else {
1965 details.filename.clone()
1966 };
1967 let selected = if details.is_selected {
1968 " <== selected"
1969 } else {
1970 ""
1971 };
1972 result.push(format!("{indent}{icon}{name}{selected}"));
1973 });
1974 });
1975
1976 result
1977 }
1978}