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