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
1019 let entry_style = if details.is_cut {
1020 &theme.cut_entry
1021 } else if details.is_ignored {
1022 &theme.ignored_entry
1023 } else {
1024 &theme.entry
1025 };
1026
1027 let style = entry_style.style_for(state, details.is_selected).clone();
1028
1029 let row_container_style = if show_editor {
1030 theme.filename_editor.container
1031 } else {
1032 style.container
1033 };
1034 Flex::row()
1035 .with_child(
1036 ConstrainedBox::new(if kind == EntryKind::Dir {
1037 if details.is_expanded {
1038 Svg::new("icons/chevron_down_8.svg")
1039 .with_color(style.icon_color)
1040 .boxed()
1041 } else {
1042 Svg::new("icons/chevron_right_8.svg")
1043 .with_color(style.icon_color)
1044 .boxed()
1045 }
1046 } else {
1047 Empty::new().boxed()
1048 })
1049 .with_max_width(style.icon_size)
1050 .with_max_height(style.icon_size)
1051 .aligned()
1052 .constrained()
1053 .with_width(style.icon_size)
1054 .boxed(),
1055 )
1056 .with_child(if show_editor {
1057 ChildView::new(editor.clone(), cx)
1058 .contained()
1059 .with_margin_left(theme.entry.default.icon_spacing)
1060 .aligned()
1061 .left()
1062 .flex(1.0, true)
1063 .boxed()
1064 } else {
1065 Label::new(details.filename, style.text.clone())
1066 .contained()
1067 .with_margin_left(style.icon_spacing)
1068 .aligned()
1069 .left()
1070 .boxed()
1071 })
1072 .constrained()
1073 .with_height(theme.entry.default.height)
1074 .contained()
1075 .with_style(row_container_style)
1076 .with_padding_left(padding)
1077 .boxed()
1078 })
1079 .on_click(MouseButton::Left, move |e, cx| {
1080 if kind == EntryKind::Dir {
1081 cx.dispatch_action(ToggleExpanded(entry_id))
1082 } else {
1083 cx.dispatch_action(Open {
1084 entry_id,
1085 change_focus: e.click_count > 1,
1086 })
1087 }
1088 })
1089 .on_down(MouseButton::Right, move |e, cx| {
1090 cx.dispatch_action(DeployContextMenu {
1091 entry_id,
1092 position: e.position,
1093 })
1094 })
1095 .with_cursor_style(CursorStyle::PointingHand)
1096 .boxed()
1097 }
1098}
1099
1100impl View for ProjectPanel {
1101 fn ui_name() -> &'static str {
1102 "ProjectPanel"
1103 }
1104
1105 fn render(&mut self, cx: &mut gpui::RenderContext<'_, Self>) -> gpui::ElementBox {
1106 enum Tag {}
1107 let theme = &cx.global::<Settings>().theme.project_panel;
1108 let mut container_style = theme.container;
1109 let padding = std::mem::take(&mut container_style.padding);
1110 let last_worktree_root_id = self.last_worktree_root_id;
1111 Stack::new()
1112 .with_child(
1113 MouseEventHandler::<Tag>::new(0, cx, |_, cx| {
1114 UniformList::new(
1115 self.list.clone(),
1116 self.visible_entries
1117 .iter()
1118 .map(|(_, worktree_entries)| worktree_entries.len())
1119 .sum(),
1120 cx,
1121 move |this, range, items, cx| {
1122 let theme = cx.global::<Settings>().theme.clone();
1123 this.for_each_visible_entry(range, cx, |id, details, cx| {
1124 items.push(Self::render_entry(
1125 id,
1126 details,
1127 &this.filename_editor,
1128 &theme.project_panel,
1129 cx,
1130 ));
1131 });
1132 },
1133 )
1134 .with_padding_top(padding.top)
1135 .with_padding_bottom(padding.bottom)
1136 .contained()
1137 .with_style(container_style)
1138 .expanded()
1139 .boxed()
1140 })
1141 .on_down(MouseButton::Right, move |e, cx| {
1142 // When deploying the context menu anywhere below the last project entry,
1143 // act as if the user clicked the root of the last worktree.
1144 if let Some(entry_id) = last_worktree_root_id {
1145 cx.dispatch_action(DeployContextMenu {
1146 entry_id,
1147 position: e.position,
1148 })
1149 }
1150 })
1151 .boxed(),
1152 )
1153 .with_child(ChildView::new(&self.context_menu, cx).boxed())
1154 .boxed()
1155 }
1156
1157 fn keymap_context(&self, _: &AppContext) -> keymap::Context {
1158 let mut cx = Self::default_keymap_context();
1159 cx.set.insert("menu".into());
1160 cx
1161 }
1162}
1163
1164impl Entity for ProjectPanel {
1165 type Event = Event;
1166}
1167
1168impl workspace::sidebar::SidebarItem for ProjectPanel {
1169 fn should_show_badge(&self, _: &AppContext) -> bool {
1170 false
1171 }
1172}
1173
1174impl ClipboardEntry {
1175 fn is_cut(&self) -> bool {
1176 matches!(self, Self::Cut { .. })
1177 }
1178
1179 fn entry_id(&self) -> ProjectEntryId {
1180 match self {
1181 ClipboardEntry::Copied { entry_id, .. } | ClipboardEntry::Cut { entry_id, .. } => {
1182 *entry_id
1183 }
1184 }
1185 }
1186
1187 fn worktree_id(&self) -> WorktreeId {
1188 match self {
1189 ClipboardEntry::Copied { worktree_id, .. }
1190 | ClipboardEntry::Cut { worktree_id, .. } => *worktree_id,
1191 }
1192 }
1193}
1194
1195#[cfg(test)]
1196mod tests {
1197 use super::*;
1198 use gpui::{TestAppContext, ViewHandle};
1199 use project::FakeFs;
1200 use serde_json::json;
1201 use std::{collections::HashSet, path::Path};
1202
1203 #[gpui::test]
1204 async fn test_visible_list(cx: &mut gpui::TestAppContext) {
1205 cx.foreground().forbid_parking();
1206 cx.update(|cx| {
1207 let settings = Settings::test(cx);
1208 cx.set_global(settings);
1209 });
1210
1211 let fs = FakeFs::new(cx.background());
1212 fs.insert_tree(
1213 "/root1",
1214 json!({
1215 ".dockerignore": "",
1216 ".git": {
1217 "HEAD": "",
1218 },
1219 "a": {
1220 "0": { "q": "", "r": "", "s": "" },
1221 "1": { "t": "", "u": "" },
1222 "2": { "v": "", "w": "", "x": "", "y": "" },
1223 },
1224 "b": {
1225 "3": { "Q": "" },
1226 "4": { "R": "", "S": "", "T": "", "U": "" },
1227 },
1228 "C": {
1229 "5": {},
1230 "6": { "V": "", "W": "" },
1231 "7": { "X": "" },
1232 "8": { "Y": {}, "Z": "" }
1233 }
1234 }),
1235 )
1236 .await;
1237 fs.insert_tree(
1238 "/root2",
1239 json!({
1240 "d": {
1241 "9": ""
1242 },
1243 "e": {}
1244 }),
1245 )
1246 .await;
1247
1248 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1249 let (_, workspace) =
1250 cx.add_window(|cx| Workspace::new(project.clone(), |_, _| unimplemented!(), cx));
1251 let panel = workspace.update(cx, |_, cx| ProjectPanel::new(project, cx));
1252 assert_eq!(
1253 visible_entries_as_strings(&panel, 0..50, cx),
1254 &[
1255 "v root1",
1256 " > .git",
1257 " > a",
1258 " > b",
1259 " > C",
1260 " .dockerignore",
1261 "v root2",
1262 " > d",
1263 " > e",
1264 ]
1265 );
1266
1267 toggle_expand_dir(&panel, "root1/b", cx);
1268 assert_eq!(
1269 visible_entries_as_strings(&panel, 0..50, cx),
1270 &[
1271 "v root1",
1272 " > .git",
1273 " > a",
1274 " v b <== selected",
1275 " > 3",
1276 " > 4",
1277 " > C",
1278 " .dockerignore",
1279 "v root2",
1280 " > d",
1281 " > e",
1282 ]
1283 );
1284
1285 assert_eq!(
1286 visible_entries_as_strings(&panel, 6..9, cx),
1287 &[
1288 //
1289 " > C",
1290 " .dockerignore",
1291 "v root2",
1292 ]
1293 );
1294 }
1295
1296 #[gpui::test(iterations = 30)]
1297 async fn test_editing_files(cx: &mut gpui::TestAppContext) {
1298 cx.foreground().forbid_parking();
1299 cx.update(|cx| {
1300 let settings = Settings::test(cx);
1301 cx.set_global(settings);
1302 });
1303
1304 let fs = FakeFs::new(cx.background());
1305 fs.insert_tree(
1306 "/root1",
1307 json!({
1308 ".dockerignore": "",
1309 ".git": {
1310 "HEAD": "",
1311 },
1312 "a": {
1313 "0": { "q": "", "r": "", "s": "" },
1314 "1": { "t": "", "u": "" },
1315 "2": { "v": "", "w": "", "x": "", "y": "" },
1316 },
1317 "b": {
1318 "3": { "Q": "" },
1319 "4": { "R": "", "S": "", "T": "", "U": "" },
1320 },
1321 "C": {
1322 "5": {},
1323 "6": { "V": "", "W": "" },
1324 "7": { "X": "" },
1325 "8": { "Y": {}, "Z": "" }
1326 }
1327 }),
1328 )
1329 .await;
1330 fs.insert_tree(
1331 "/root2",
1332 json!({
1333 "d": {
1334 "9": ""
1335 },
1336 "e": {}
1337 }),
1338 )
1339 .await;
1340
1341 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1342 let (_, workspace) =
1343 cx.add_window(|cx| Workspace::new(project.clone(), |_, _| unimplemented!(), cx));
1344 let panel = workspace.update(cx, |_, cx| ProjectPanel::new(project, cx));
1345
1346 select_path(&panel, "root1", cx);
1347 assert_eq!(
1348 visible_entries_as_strings(&panel, 0..10, cx),
1349 &[
1350 "v root1 <== selected",
1351 " > .git",
1352 " > a",
1353 " > b",
1354 " > C",
1355 " .dockerignore",
1356 "v root2",
1357 " > d",
1358 " > e",
1359 ]
1360 );
1361
1362 // Add a file with the root folder selected. The filename editor is placed
1363 // before the first file in the root folder.
1364 panel.update(cx, |panel, cx| panel.add_file(&AddFile, cx));
1365 assert!(panel.read_with(cx, |panel, cx| panel.filename_editor.is_focused(cx)));
1366 assert_eq!(
1367 visible_entries_as_strings(&panel, 0..10, cx),
1368 &[
1369 "v root1",
1370 " > .git",
1371 " > a",
1372 " > b",
1373 " > C",
1374 " [EDITOR: ''] <== selected",
1375 " .dockerignore",
1376 "v root2",
1377 " > d",
1378 " > e",
1379 ]
1380 );
1381
1382 let confirm = panel.update(cx, |panel, cx| {
1383 panel
1384 .filename_editor
1385 .update(cx, |editor, cx| editor.set_text("the-new-filename", cx));
1386 panel.confirm(&Confirm, cx).unwrap()
1387 });
1388 assert_eq!(
1389 visible_entries_as_strings(&panel, 0..10, cx),
1390 &[
1391 "v root1",
1392 " > .git",
1393 " > a",
1394 " > b",
1395 " > C",
1396 " [PROCESSING: 'the-new-filename'] <== selected",
1397 " .dockerignore",
1398 "v root2",
1399 " > d",
1400 " > e",
1401 ]
1402 );
1403
1404 confirm.await.unwrap();
1405 assert_eq!(
1406 visible_entries_as_strings(&panel, 0..10, cx),
1407 &[
1408 "v root1",
1409 " > .git",
1410 " > a",
1411 " > b",
1412 " > C",
1413 " .dockerignore",
1414 " the-new-filename <== selected",
1415 "v root2",
1416 " > d",
1417 " > e",
1418 ]
1419 );
1420
1421 select_path(&panel, "root1/b", cx);
1422 panel.update(cx, |panel, cx| panel.add_file(&AddFile, cx));
1423 assert_eq!(
1424 visible_entries_as_strings(&panel, 0..10, cx),
1425 &[
1426 "v root1",
1427 " > .git",
1428 " > a",
1429 " v b",
1430 " > 3",
1431 " > 4",
1432 " [EDITOR: ''] <== selected",
1433 " > C",
1434 " .dockerignore",
1435 " the-new-filename",
1436 ]
1437 );
1438
1439 panel
1440 .update(cx, |panel, cx| {
1441 panel
1442 .filename_editor
1443 .update(cx, |editor, cx| editor.set_text("another-filename", cx));
1444 panel.confirm(&Confirm, cx).unwrap()
1445 })
1446 .await
1447 .unwrap();
1448 assert_eq!(
1449 visible_entries_as_strings(&panel, 0..10, cx),
1450 &[
1451 "v root1",
1452 " > .git",
1453 " > a",
1454 " v b",
1455 " > 3",
1456 " > 4",
1457 " another-filename <== selected",
1458 " > C",
1459 " .dockerignore",
1460 " the-new-filename",
1461 ]
1462 );
1463
1464 select_path(&panel, "root1/b/another-filename", cx);
1465 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
1466 assert_eq!(
1467 visible_entries_as_strings(&panel, 0..10, cx),
1468 &[
1469 "v root1",
1470 " > .git",
1471 " > a",
1472 " v b",
1473 " > 3",
1474 " > 4",
1475 " [EDITOR: 'another-filename'] <== selected",
1476 " > C",
1477 " .dockerignore",
1478 " the-new-filename",
1479 ]
1480 );
1481
1482 let confirm = panel.update(cx, |panel, cx| {
1483 panel
1484 .filename_editor
1485 .update(cx, |editor, cx| editor.set_text("a-different-filename", cx));
1486 panel.confirm(&Confirm, cx).unwrap()
1487 });
1488 assert_eq!(
1489 visible_entries_as_strings(&panel, 0..10, cx),
1490 &[
1491 "v root1",
1492 " > .git",
1493 " > a",
1494 " v b",
1495 " > 3",
1496 " > 4",
1497 " [PROCESSING: 'a-different-filename'] <== selected",
1498 " > C",
1499 " .dockerignore",
1500 " the-new-filename",
1501 ]
1502 );
1503
1504 confirm.await.unwrap();
1505 assert_eq!(
1506 visible_entries_as_strings(&panel, 0..10, cx),
1507 &[
1508 "v root1",
1509 " > .git",
1510 " > a",
1511 " v b",
1512 " > 3",
1513 " > 4",
1514 " a-different-filename <== selected",
1515 " > C",
1516 " .dockerignore",
1517 " the-new-filename",
1518 ]
1519 );
1520
1521 panel.update(cx, |panel, cx| panel.add_directory(&AddDirectory, cx));
1522 assert_eq!(
1523 visible_entries_as_strings(&panel, 0..10, cx),
1524 &[
1525 "v root1",
1526 " > .git",
1527 " > a",
1528 " v b",
1529 " > [EDITOR: ''] <== selected",
1530 " > 3",
1531 " > 4",
1532 " a-different-filename",
1533 " > C",
1534 " .dockerignore",
1535 ]
1536 );
1537
1538 let confirm = panel.update(cx, |panel, cx| {
1539 panel
1540 .filename_editor
1541 .update(cx, |editor, cx| editor.set_text("new-dir", cx));
1542 panel.confirm(&Confirm, cx).unwrap()
1543 });
1544 panel.update(cx, |panel, cx| panel.select_next(&Default::default(), cx));
1545 assert_eq!(
1546 visible_entries_as_strings(&panel, 0..10, cx),
1547 &[
1548 "v root1",
1549 " > .git",
1550 " > a",
1551 " v b",
1552 " > [PROCESSING: 'new-dir']",
1553 " > 3 <== selected",
1554 " > 4",
1555 " a-different-filename",
1556 " > C",
1557 " .dockerignore",
1558 ]
1559 );
1560
1561 confirm.await.unwrap();
1562 assert_eq!(
1563 visible_entries_as_strings(&panel, 0..10, cx),
1564 &[
1565 "v root1",
1566 " > .git",
1567 " > a",
1568 " v b",
1569 " > 3 <== selected",
1570 " > 4",
1571 " > new-dir",
1572 " a-different-filename",
1573 " > C",
1574 " .dockerignore",
1575 ]
1576 );
1577
1578 panel.update(cx, |panel, cx| panel.rename(&Default::default(), cx));
1579 assert_eq!(
1580 visible_entries_as_strings(&panel, 0..10, cx),
1581 &[
1582 "v root1",
1583 " > .git",
1584 " > a",
1585 " v b",
1586 " > [EDITOR: '3'] <== selected",
1587 " > 4",
1588 " > new-dir",
1589 " a-different-filename",
1590 " > C",
1591 " .dockerignore",
1592 ]
1593 );
1594
1595 // Dismiss the rename editor when it loses focus.
1596 workspace.update(cx, |_, cx| cx.focus_self());
1597 assert_eq!(
1598 visible_entries_as_strings(&panel, 0..10, cx),
1599 &[
1600 "v root1",
1601 " > .git",
1602 " > a",
1603 " v b",
1604 " > 3 <== selected",
1605 " > 4",
1606 " > new-dir",
1607 " a-different-filename",
1608 " > C",
1609 " .dockerignore",
1610 ]
1611 );
1612 }
1613
1614 fn toggle_expand_dir(
1615 panel: &ViewHandle<ProjectPanel>,
1616 path: impl AsRef<Path>,
1617 cx: &mut TestAppContext,
1618 ) {
1619 let path = path.as_ref();
1620 panel.update(cx, |panel, cx| {
1621 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
1622 let worktree = worktree.read(cx);
1623 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
1624 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
1625 panel.toggle_expanded(&ToggleExpanded(entry_id), cx);
1626 return;
1627 }
1628 }
1629 panic!("no worktree for path {:?}", path);
1630 });
1631 }
1632
1633 fn select_path(
1634 panel: &ViewHandle<ProjectPanel>,
1635 path: impl AsRef<Path>,
1636 cx: &mut TestAppContext,
1637 ) {
1638 let path = path.as_ref();
1639 panel.update(cx, |panel, cx| {
1640 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
1641 let worktree = worktree.read(cx);
1642 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
1643 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
1644 panel.selection = Some(Selection {
1645 worktree_id: worktree.id(),
1646 entry_id,
1647 });
1648 return;
1649 }
1650 }
1651 panic!("no worktree for path {:?}", path);
1652 });
1653 }
1654
1655 fn visible_entries_as_strings(
1656 panel: &ViewHandle<ProjectPanel>,
1657 range: Range<usize>,
1658 cx: &mut TestAppContext,
1659 ) -> Vec<String> {
1660 let mut result = Vec::new();
1661 let mut project_entries = HashSet::new();
1662 let mut has_editor = false;
1663 cx.render(panel, |panel, cx| {
1664 panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
1665 if details.is_editing {
1666 assert!(!has_editor, "duplicate editor entry");
1667 has_editor = true;
1668 } else {
1669 assert!(
1670 project_entries.insert(project_entry),
1671 "duplicate project entry {:?} {:?}",
1672 project_entry,
1673 details
1674 );
1675 }
1676
1677 let indent = " ".repeat(details.depth);
1678 let icon = if matches!(details.kind, EntryKind::Dir | EntryKind::PendingDir) {
1679 if details.is_expanded {
1680 "v "
1681 } else {
1682 "> "
1683 }
1684 } else {
1685 " "
1686 };
1687 let name = if details.is_editing {
1688 format!("[EDITOR: '{}']", details.filename)
1689 } else if details.is_processing {
1690 format!("[PROCESSING: '{}']", details.filename)
1691 } else {
1692 details.filename.clone()
1693 };
1694 let selected = if details.is_selected {
1695 " <== selected"
1696 } else {
1697 ""
1698 };
1699 result.push(format!("{indent}{icon}{name}{selected}"));
1700 });
1701 });
1702
1703 result
1704 }
1705}