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