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