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 keymap_context(&self, _: &AppContext) -> KeymapContext {
1356 let mut cx = Self::default_keymap_context();
1357 cx.add_identifier("menu");
1358 cx
1359 }
1360}
1361
1362impl Entity for ProjectPanel {
1363 type Event = Event;
1364}
1365
1366impl workspace::sidebar::SidebarItem for ProjectPanel {
1367 fn should_show_badge(&self, _: &AppContext) -> bool {
1368 false
1369 }
1370}
1371
1372impl ClipboardEntry {
1373 fn is_cut(&self) -> bool {
1374 matches!(self, Self::Cut { .. })
1375 }
1376
1377 fn entry_id(&self) -> ProjectEntryId {
1378 match self {
1379 ClipboardEntry::Copied { entry_id, .. } | ClipboardEntry::Cut { entry_id, .. } => {
1380 *entry_id
1381 }
1382 }
1383 }
1384
1385 fn worktree_id(&self) -> WorktreeId {
1386 match self {
1387 ClipboardEntry::Copied { worktree_id, .. }
1388 | ClipboardEntry::Cut { worktree_id, .. } => *worktree_id,
1389 }
1390 }
1391}
1392
1393#[cfg(test)]
1394mod tests {
1395 use super::*;
1396 use gpui::{TestAppContext, ViewHandle};
1397 use project::FakeFs;
1398 use serde_json::json;
1399 use std::{collections::HashSet, path::Path};
1400
1401 #[gpui::test]
1402 async fn test_visible_list(cx: &mut gpui::TestAppContext) {
1403 cx.foreground().forbid_parking();
1404 cx.update(|cx| {
1405 let settings = Settings::test(cx);
1406 cx.set_global(settings);
1407 });
1408
1409 let fs = FakeFs::new(cx.background());
1410 fs.insert_tree(
1411 "/root1",
1412 json!({
1413 ".dockerignore": "",
1414 ".git": {
1415 "HEAD": "",
1416 },
1417 "a": {
1418 "0": { "q": "", "r": "", "s": "" },
1419 "1": { "t": "", "u": "" },
1420 "2": { "v": "", "w": "", "x": "", "y": "" },
1421 },
1422 "b": {
1423 "3": { "Q": "" },
1424 "4": { "R": "", "S": "", "T": "", "U": "" },
1425 },
1426 "C": {
1427 "5": {},
1428 "6": { "V": "", "W": "" },
1429 "7": { "X": "" },
1430 "8": { "Y": {}, "Z": "" }
1431 }
1432 }),
1433 )
1434 .await;
1435 fs.insert_tree(
1436 "/root2",
1437 json!({
1438 "d": {
1439 "9": ""
1440 },
1441 "e": {}
1442 }),
1443 )
1444 .await;
1445
1446 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1447 let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1448 let panel = workspace.update(cx, |_, cx| ProjectPanel::new(project, cx));
1449 assert_eq!(
1450 visible_entries_as_strings(&panel, 0..50, cx),
1451 &[
1452 "v root1",
1453 " > .git",
1454 " > a",
1455 " > b",
1456 " > C",
1457 " .dockerignore",
1458 "v root2",
1459 " > d",
1460 " > e",
1461 ]
1462 );
1463
1464 toggle_expand_dir(&panel, "root1/b", cx);
1465 assert_eq!(
1466 visible_entries_as_strings(&panel, 0..50, cx),
1467 &[
1468 "v root1",
1469 " > .git",
1470 " > a",
1471 " v b <== selected",
1472 " > 3",
1473 " > 4",
1474 " > C",
1475 " .dockerignore",
1476 "v root2",
1477 " > d",
1478 " > e",
1479 ]
1480 );
1481
1482 assert_eq!(
1483 visible_entries_as_strings(&panel, 6..9, cx),
1484 &[
1485 //
1486 " > C",
1487 " .dockerignore",
1488 "v root2",
1489 ]
1490 );
1491 }
1492
1493 #[gpui::test(iterations = 30)]
1494 async fn test_editing_files(cx: &mut gpui::TestAppContext) {
1495 cx.foreground().forbid_parking();
1496 cx.update(|cx| {
1497 let settings = Settings::test(cx);
1498 cx.set_global(settings);
1499 });
1500
1501 let fs = FakeFs::new(cx.background());
1502 fs.insert_tree(
1503 "/root1",
1504 json!({
1505 ".dockerignore": "",
1506 ".git": {
1507 "HEAD": "",
1508 },
1509 "a": {
1510 "0": { "q": "", "r": "", "s": "" },
1511 "1": { "t": "", "u": "" },
1512 "2": { "v": "", "w": "", "x": "", "y": "" },
1513 },
1514 "b": {
1515 "3": { "Q": "" },
1516 "4": { "R": "", "S": "", "T": "", "U": "" },
1517 },
1518 "C": {
1519 "5": {},
1520 "6": { "V": "", "W": "" },
1521 "7": { "X": "" },
1522 "8": { "Y": {}, "Z": "" }
1523 }
1524 }),
1525 )
1526 .await;
1527 fs.insert_tree(
1528 "/root2",
1529 json!({
1530 "d": {
1531 "9": ""
1532 },
1533 "e": {}
1534 }),
1535 )
1536 .await;
1537
1538 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1539 let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1540 let panel = workspace.update(cx, |_, cx| ProjectPanel::new(project, cx));
1541
1542 select_path(&panel, "root1", cx);
1543 assert_eq!(
1544 visible_entries_as_strings(&panel, 0..10, cx),
1545 &[
1546 "v root1 <== selected",
1547 " > .git",
1548 " > a",
1549 " > b",
1550 " > C",
1551 " .dockerignore",
1552 "v root2",
1553 " > d",
1554 " > e",
1555 ]
1556 );
1557
1558 // Add a file with the root folder selected. The filename editor is placed
1559 // before the first file in the root folder.
1560 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
1561 cx.read_window(window_id, |cx| {
1562 let panel = panel.read(cx);
1563 assert!(panel.filename_editor.is_focused(cx));
1564 });
1565 assert_eq!(
1566 visible_entries_as_strings(&panel, 0..10, cx),
1567 &[
1568 "v root1",
1569 " > .git",
1570 " > a",
1571 " > b",
1572 " > C",
1573 " [EDITOR: ''] <== selected",
1574 " .dockerignore",
1575 "v root2",
1576 " > d",
1577 " > e",
1578 ]
1579 );
1580
1581 let confirm = panel.update(cx, |panel, cx| {
1582 panel
1583 .filename_editor
1584 .update(cx, |editor, cx| editor.set_text("the-new-filename", cx));
1585 panel.confirm(&Confirm, cx).unwrap()
1586 });
1587 assert_eq!(
1588 visible_entries_as_strings(&panel, 0..10, cx),
1589 &[
1590 "v root1",
1591 " > .git",
1592 " > a",
1593 " > b",
1594 " > C",
1595 " [PROCESSING: 'the-new-filename'] <== selected",
1596 " .dockerignore",
1597 "v root2",
1598 " > d",
1599 " > e",
1600 ]
1601 );
1602
1603 confirm.await.unwrap();
1604 assert_eq!(
1605 visible_entries_as_strings(&panel, 0..10, cx),
1606 &[
1607 "v root1",
1608 " > .git",
1609 " > a",
1610 " > b",
1611 " > C",
1612 " .dockerignore",
1613 " the-new-filename <== selected",
1614 "v root2",
1615 " > d",
1616 " > e",
1617 ]
1618 );
1619
1620 select_path(&panel, "root1/b", cx);
1621 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
1622 assert_eq!(
1623 visible_entries_as_strings(&panel, 0..10, cx),
1624 &[
1625 "v root1",
1626 " > .git",
1627 " > a",
1628 " v b",
1629 " > 3",
1630 " > 4",
1631 " [EDITOR: ''] <== selected",
1632 " > C",
1633 " .dockerignore",
1634 " the-new-filename",
1635 ]
1636 );
1637
1638 panel
1639 .update(cx, |panel, cx| {
1640 panel
1641 .filename_editor
1642 .update(cx, |editor, cx| editor.set_text("another-filename", cx));
1643 panel.confirm(&Confirm, cx).unwrap()
1644 })
1645 .await
1646 .unwrap();
1647 assert_eq!(
1648 visible_entries_as_strings(&panel, 0..10, cx),
1649 &[
1650 "v root1",
1651 " > .git",
1652 " > a",
1653 " v b",
1654 " > 3",
1655 " > 4",
1656 " another-filename <== selected",
1657 " > C",
1658 " .dockerignore",
1659 " the-new-filename",
1660 ]
1661 );
1662
1663 select_path(&panel, "root1/b/another-filename", cx);
1664 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
1665 assert_eq!(
1666 visible_entries_as_strings(&panel, 0..10, cx),
1667 &[
1668 "v root1",
1669 " > .git",
1670 " > a",
1671 " v b",
1672 " > 3",
1673 " > 4",
1674 " [EDITOR: 'another-filename'] <== selected",
1675 " > C",
1676 " .dockerignore",
1677 " the-new-filename",
1678 ]
1679 );
1680
1681 let confirm = panel.update(cx, |panel, cx| {
1682 panel
1683 .filename_editor
1684 .update(cx, |editor, cx| editor.set_text("a-different-filename", cx));
1685 panel.confirm(&Confirm, cx).unwrap()
1686 });
1687 assert_eq!(
1688 visible_entries_as_strings(&panel, 0..10, cx),
1689 &[
1690 "v root1",
1691 " > .git",
1692 " > a",
1693 " v b",
1694 " > 3",
1695 " > 4",
1696 " [PROCESSING: 'a-different-filename'] <== selected",
1697 " > C",
1698 " .dockerignore",
1699 " the-new-filename",
1700 ]
1701 );
1702
1703 confirm.await.unwrap();
1704 assert_eq!(
1705 visible_entries_as_strings(&panel, 0..10, cx),
1706 &[
1707 "v root1",
1708 " > .git",
1709 " > a",
1710 " v b",
1711 " > 3",
1712 " > 4",
1713 " a-different-filename <== selected",
1714 " > C",
1715 " .dockerignore",
1716 " the-new-filename",
1717 ]
1718 );
1719
1720 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
1721 assert_eq!(
1722 visible_entries_as_strings(&panel, 0..10, cx),
1723 &[
1724 "v root1",
1725 " > .git",
1726 " > a",
1727 " v b",
1728 " > [EDITOR: ''] <== selected",
1729 " > 3",
1730 " > 4",
1731 " a-different-filename",
1732 " > C",
1733 " .dockerignore",
1734 ]
1735 );
1736
1737 let confirm = panel.update(cx, |panel, cx| {
1738 panel
1739 .filename_editor
1740 .update(cx, |editor, cx| editor.set_text("new-dir", cx));
1741 panel.confirm(&Confirm, cx).unwrap()
1742 });
1743 panel.update(cx, |panel, cx| panel.select_next(&Default::default(), cx));
1744 assert_eq!(
1745 visible_entries_as_strings(&panel, 0..10, cx),
1746 &[
1747 "v root1",
1748 " > .git",
1749 " > a",
1750 " v b",
1751 " > [PROCESSING: 'new-dir']",
1752 " > 3 <== selected",
1753 " > 4",
1754 " a-different-filename",
1755 " > C",
1756 " .dockerignore",
1757 ]
1758 );
1759
1760 confirm.await.unwrap();
1761 assert_eq!(
1762 visible_entries_as_strings(&panel, 0..10, cx),
1763 &[
1764 "v root1",
1765 " > .git",
1766 " > a",
1767 " v b",
1768 " > 3 <== selected",
1769 " > 4",
1770 " > new-dir",
1771 " a-different-filename",
1772 " > C",
1773 " .dockerignore",
1774 ]
1775 );
1776
1777 panel.update(cx, |panel, cx| panel.rename(&Default::default(), cx));
1778 assert_eq!(
1779 visible_entries_as_strings(&panel, 0..10, cx),
1780 &[
1781 "v root1",
1782 " > .git",
1783 " > a",
1784 " v b",
1785 " > [EDITOR: '3'] <== selected",
1786 " > 4",
1787 " > new-dir",
1788 " a-different-filename",
1789 " > C",
1790 " .dockerignore",
1791 ]
1792 );
1793
1794 // Dismiss the rename editor when it loses focus.
1795 workspace.update(cx, |_, cx| cx.focus_self());
1796 assert_eq!(
1797 visible_entries_as_strings(&panel, 0..10, cx),
1798 &[
1799 "v root1",
1800 " > .git",
1801 " > a",
1802 " v b",
1803 " > 3 <== selected",
1804 " > 4",
1805 " > new-dir",
1806 " a-different-filename",
1807 " > C",
1808 " .dockerignore",
1809 ]
1810 );
1811 }
1812
1813 #[gpui::test]
1814 async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
1815 cx.foreground().forbid_parking();
1816 cx.update(|cx| {
1817 let settings = Settings::test(cx);
1818 cx.set_global(settings);
1819 });
1820
1821 let fs = FakeFs::new(cx.background());
1822 fs.insert_tree(
1823 "/root1",
1824 json!({
1825 "one.two.txt": "",
1826 "one.txt": ""
1827 }),
1828 )
1829 .await;
1830
1831 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
1832 let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1833 let panel = workspace.update(cx, |_, cx| ProjectPanel::new(project, cx));
1834
1835 panel.update(cx, |panel, cx| {
1836 panel.select_next(&Default::default(), cx);
1837 panel.select_next(&Default::default(), cx);
1838 });
1839
1840 assert_eq!(
1841 visible_entries_as_strings(&panel, 0..50, cx),
1842 &[
1843 //
1844 "v root1",
1845 " one.two.txt <== selected",
1846 " one.txt",
1847 ]
1848 );
1849
1850 // Regression test - file name is created correctly when
1851 // the copied file's name contains multiple dots.
1852 panel.update(cx, |panel, cx| {
1853 panel.copy(&Default::default(), cx);
1854 panel.paste(&Default::default(), cx);
1855 });
1856 cx.foreground().run_until_parked();
1857
1858 assert_eq!(
1859 visible_entries_as_strings(&panel, 0..50, cx),
1860 &[
1861 //
1862 "v root1",
1863 " one.two copy.txt",
1864 " one.two.txt <== selected",
1865 " one.txt",
1866 ]
1867 );
1868
1869 panel.update(cx, |panel, cx| {
1870 panel.paste(&Default::default(), cx);
1871 });
1872 cx.foreground().run_until_parked();
1873
1874 assert_eq!(
1875 visible_entries_as_strings(&panel, 0..50, cx),
1876 &[
1877 //
1878 "v root1",
1879 " one.two copy 1.txt",
1880 " one.two copy.txt",
1881 " one.two.txt <== selected",
1882 " one.txt",
1883 ]
1884 );
1885 }
1886
1887 fn toggle_expand_dir(
1888 panel: &ViewHandle<ProjectPanel>,
1889 path: impl AsRef<Path>,
1890 cx: &mut TestAppContext,
1891 ) {
1892 let path = path.as_ref();
1893 panel.update(cx, |panel, cx| {
1894 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
1895 let worktree = worktree.read(cx);
1896 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
1897 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
1898 panel.toggle_expanded(&ToggleExpanded(entry_id), cx);
1899 return;
1900 }
1901 }
1902 panic!("no worktree for path {:?}", path);
1903 });
1904 }
1905
1906 fn select_path(
1907 panel: &ViewHandle<ProjectPanel>,
1908 path: impl AsRef<Path>,
1909 cx: &mut TestAppContext,
1910 ) {
1911 let path = path.as_ref();
1912 panel.update(cx, |panel, cx| {
1913 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
1914 let worktree = worktree.read(cx);
1915 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
1916 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
1917 panel.selection = Some(Selection {
1918 worktree_id: worktree.id(),
1919 entry_id,
1920 });
1921 return;
1922 }
1923 }
1924 panic!("no worktree for path {:?}", path);
1925 });
1926 }
1927
1928 fn visible_entries_as_strings(
1929 panel: &ViewHandle<ProjectPanel>,
1930 range: Range<usize>,
1931 cx: &mut TestAppContext,
1932 ) -> Vec<String> {
1933 let mut result = Vec::new();
1934 let mut project_entries = HashSet::new();
1935 let mut has_editor = false;
1936
1937 panel.update(cx, |panel, cx| {
1938 panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
1939 if details.is_editing {
1940 assert!(!has_editor, "duplicate editor entry");
1941 has_editor = true;
1942 } else {
1943 assert!(
1944 project_entries.insert(project_entry),
1945 "duplicate project entry {:?} {:?}",
1946 project_entry,
1947 details
1948 );
1949 }
1950
1951 let indent = " ".repeat(details.depth);
1952 let icon = if matches!(details.kind, EntryKind::Dir | EntryKind::PendingDir) {
1953 if details.is_expanded {
1954 "v "
1955 } else {
1956 "> "
1957 }
1958 } else {
1959 " "
1960 };
1961 let name = if details.is_editing {
1962 format!("[EDITOR: '{}']", details.filename)
1963 } else if details.is_processing {
1964 format!("[PROCESSING: '{}']", details.filename)
1965 } else {
1966 details.filename.clone()
1967 };
1968 let selected = if details.is_selected {
1969 " <== selected"
1970 } else {
1971 ""
1972 };
1973 result.push(format!("{indent}{icon}{name}{selected}"));
1974 });
1975 });
1976
1977 result
1978 }
1979}