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