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