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