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