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