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