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