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