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