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.platform()
796 .reveal_path(&worktree.abs_path().join(&entry.path));
797 }
798 }
799
800 fn move_entry(
801 &mut self,
802 &MoveProjectEntry {
803 entry_to_move,
804 destination,
805 destination_is_file,
806 }: &MoveProjectEntry,
807 cx: &mut ViewContext<Self>,
808 ) {
809 let destination_worktree = self.project.update(cx, |project, cx| {
810 let entry_path = project.path_for_entry(entry_to_move, cx)?;
811 let destination_entry_path = project.path_for_entry(destination, cx)?.path.clone();
812
813 let mut destination_path = destination_entry_path.as_ref();
814 if destination_is_file {
815 destination_path = destination_path.parent()?;
816 }
817
818 let mut new_path = destination_path.to_path_buf();
819 new_path.push(entry_path.path.file_name()?);
820 if new_path != entry_path.path.as_ref() {
821 let task = project.rename_entry(entry_to_move, new_path, cx)?;
822 cx.foreground().spawn(task).detach_and_log_err(cx);
823 }
824
825 Some(project.worktree_id_for_entry(destination, cx)?)
826 });
827
828 if let Some(destination_worktree) = destination_worktree {
829 self.expand_entry(destination_worktree, destination, cx);
830 }
831 }
832
833 fn index_for_selection(&self, selection: Selection) -> Option<(usize, usize, usize)> {
834 let mut entry_index = 0;
835 let mut visible_entries_index = 0;
836 for (worktree_index, (worktree_id, worktree_entries)) in
837 self.visible_entries.iter().enumerate()
838 {
839 if *worktree_id == selection.worktree_id {
840 for entry in worktree_entries {
841 if entry.id == selection.entry_id {
842 return Some((worktree_index, entry_index, visible_entries_index));
843 } else {
844 visible_entries_index += 1;
845 entry_index += 1;
846 }
847 }
848 break;
849 } else {
850 visible_entries_index += worktree_entries.len();
851 }
852 }
853 None
854 }
855
856 fn selected_entry<'a>(&self, cx: &'a AppContext) -> Option<(&'a Worktree, &'a project::Entry)> {
857 let selection = self.selection?;
858 let project = self.project.read(cx);
859 let worktree = project.worktree_for_id(selection.worktree_id, cx)?.read(cx);
860 Some((worktree, worktree.entry_for_id(selection.entry_id)?))
861 }
862
863 fn update_visible_entries(
864 &mut self,
865 new_selected_entry: Option<(WorktreeId, ProjectEntryId)>,
866 cx: &mut ViewContext<Self>,
867 ) {
868 let project = self.project.read(cx);
869 self.last_worktree_root_id = project
870 .visible_worktrees(cx)
871 .rev()
872 .next()
873 .and_then(|worktree| worktree.read(cx).root_entry())
874 .map(|entry| entry.id);
875
876 self.visible_entries.clear();
877 for worktree in project.visible_worktrees(cx) {
878 let snapshot = worktree.read(cx).snapshot();
879 let worktree_id = snapshot.id();
880
881 let expanded_dir_ids = match self.expanded_dir_ids.entry(worktree_id) {
882 hash_map::Entry::Occupied(e) => e.into_mut(),
883 hash_map::Entry::Vacant(e) => {
884 // The first time a worktree's root entry becomes available,
885 // mark that root entry as expanded.
886 if let Some(entry) = snapshot.root_entry() {
887 e.insert(vec![entry.id]).as_slice()
888 } else {
889 &[]
890 }
891 }
892 };
893
894 let mut new_entry_parent_id = None;
895 let mut new_entry_kind = EntryKind::Dir;
896 if let Some(edit_state) = &self.edit_state {
897 if edit_state.worktree_id == worktree_id && edit_state.is_new_entry {
898 new_entry_parent_id = Some(edit_state.entry_id);
899 new_entry_kind = if edit_state.is_dir {
900 EntryKind::Dir
901 } else {
902 EntryKind::File(Default::default())
903 };
904 }
905 }
906
907 let mut visible_worktree_entries = Vec::new();
908 let mut entry_iter = snapshot.entries(true);
909
910 while let Some(entry) = entry_iter.entry() {
911 visible_worktree_entries.push(entry.clone());
912 if Some(entry.id) == new_entry_parent_id {
913 visible_worktree_entries.push(Entry {
914 id: NEW_ENTRY_ID,
915 kind: new_entry_kind,
916 path: entry.path.join("\0").into(),
917 inode: 0,
918 mtime: entry.mtime,
919 is_symlink: false,
920 is_ignored: false,
921 });
922 }
923 if expanded_dir_ids.binary_search(&entry.id).is_err()
924 && entry_iter.advance_to_sibling()
925 {
926 continue;
927 }
928 entry_iter.advance();
929 }
930 visible_worktree_entries.sort_by(|entry_a, entry_b| {
931 let mut components_a = entry_a.path.components().peekable();
932 let mut components_b = entry_b.path.components().peekable();
933 loop {
934 match (components_a.next(), components_b.next()) {
935 (Some(component_a), Some(component_b)) => {
936 let a_is_file = components_a.peek().is_none() && entry_a.is_file();
937 let b_is_file = components_b.peek().is_none() && entry_b.is_file();
938 let ordering = a_is_file.cmp(&b_is_file).then_with(|| {
939 let name_a =
940 UniCase::new(component_a.as_os_str().to_string_lossy());
941 let name_b =
942 UniCase::new(component_b.as_os_str().to_string_lossy());
943 name_a.cmp(&name_b)
944 });
945 if !ordering.is_eq() {
946 return ordering;
947 }
948 }
949 (Some(_), None) => break Ordering::Greater,
950 (None, Some(_)) => break Ordering::Less,
951 (None, None) => break Ordering::Equal,
952 }
953 }
954 });
955 self.visible_entries
956 .push((worktree_id, visible_worktree_entries));
957 }
958
959 if let Some((worktree_id, entry_id)) = new_selected_entry {
960 self.selection = Some(Selection {
961 worktree_id,
962 entry_id,
963 });
964 }
965 }
966
967 fn expand_entry(
968 &mut self,
969 worktree_id: WorktreeId,
970 entry_id: ProjectEntryId,
971 cx: &mut ViewContext<Self>,
972 ) {
973 let project = self.project.read(cx);
974 if let Some((worktree, expanded_dir_ids)) = project
975 .worktree_for_id(worktree_id, cx)
976 .zip(self.expanded_dir_ids.get_mut(&worktree_id))
977 {
978 let worktree = worktree.read(cx);
979
980 if let Some(mut entry) = worktree.entry_for_id(entry_id) {
981 loop {
982 if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
983 expanded_dir_ids.insert(ix, entry.id);
984 }
985
986 if let Some(parent_entry) =
987 entry.path.parent().and_then(|p| worktree.entry_for_path(p))
988 {
989 entry = parent_entry;
990 } else {
991 break;
992 }
993 }
994 }
995 }
996 }
997
998 fn for_each_visible_entry(
999 &self,
1000 range: Range<usize>,
1001 cx: &mut RenderContext<ProjectPanel>,
1002 mut callback: impl FnMut(ProjectEntryId, EntryDetails, &mut RenderContext<ProjectPanel>),
1003 ) {
1004 let mut ix = 0;
1005 for (worktree_id, visible_worktree_entries) in &self.visible_entries {
1006 if ix >= range.end {
1007 return;
1008 }
1009
1010 if ix + visible_worktree_entries.len() <= range.start {
1011 ix += visible_worktree_entries.len();
1012 continue;
1013 }
1014
1015 let end_ix = range.end.min(ix + visible_worktree_entries.len());
1016 if let Some(worktree) = self.project.read(cx).worktree_for_id(*worktree_id, cx) {
1017 let snapshot = worktree.read(cx).snapshot();
1018 let root_name = OsStr::new(snapshot.root_name());
1019 let expanded_entry_ids = self
1020 .expanded_dir_ids
1021 .get(&snapshot.id())
1022 .map(Vec::as_slice)
1023 .unwrap_or(&[]);
1024
1025 let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
1026 for entry in &visible_worktree_entries[entry_range] {
1027 let mut details = EntryDetails {
1028 filename: entry
1029 .path
1030 .file_name()
1031 .unwrap_or(root_name)
1032 .to_string_lossy()
1033 .to_string(),
1034 path: entry.path.clone(),
1035 depth: entry.path.components().count(),
1036 kind: entry.kind,
1037 is_ignored: entry.is_ignored,
1038 is_expanded: expanded_entry_ids.binary_search(&entry.id).is_ok(),
1039 is_selected: self.selection.map_or(false, |e| {
1040 e.worktree_id == snapshot.id() && e.entry_id == entry.id
1041 }),
1042 is_editing: false,
1043 is_processing: false,
1044 is_cut: self
1045 .clipboard_entry
1046 .map_or(false, |e| e.is_cut() && e.entry_id() == entry.id),
1047 };
1048
1049 if let Some(edit_state) = &self.edit_state {
1050 let is_edited_entry = if edit_state.is_new_entry {
1051 entry.id == NEW_ENTRY_ID
1052 } else {
1053 entry.id == edit_state.entry_id
1054 };
1055
1056 if is_edited_entry {
1057 if let Some(processing_filename) = &edit_state.processing_filename {
1058 details.is_processing = true;
1059 details.filename.clear();
1060 details.filename.push_str(processing_filename);
1061 } else {
1062 if edit_state.is_new_entry {
1063 details.filename.clear();
1064 }
1065 details.is_editing = true;
1066 }
1067 }
1068 }
1069
1070 callback(entry.id, details, cx);
1071 }
1072 }
1073 ix = end_ix;
1074 }
1075 }
1076
1077 fn render_entry_visual_element<V: View>(
1078 details: &EntryDetails,
1079 editor: Option<&ViewHandle<Editor>>,
1080 padding: f32,
1081 row_container_style: ContainerStyle,
1082 style: &ProjectPanelEntry,
1083 cx: &mut RenderContext<V>,
1084 ) -> ElementBox {
1085 let kind = details.kind;
1086 let show_editor = details.is_editing && !details.is_processing;
1087
1088 Flex::row()
1089 .with_child(
1090 ConstrainedBox::new(if kind == EntryKind::Dir {
1091 if details.is_expanded {
1092 Svg::new("icons/chevron_down_8.svg")
1093 .with_color(style.icon_color)
1094 .boxed()
1095 } else {
1096 Svg::new("icons/chevron_right_8.svg")
1097 .with_color(style.icon_color)
1098 .boxed()
1099 }
1100 } else {
1101 Empty::new().boxed()
1102 })
1103 .with_max_width(style.icon_size)
1104 .with_max_height(style.icon_size)
1105 .aligned()
1106 .constrained()
1107 .with_width(style.icon_size)
1108 .boxed(),
1109 )
1110 .with_child(if show_editor && editor.is_some() {
1111 ChildView::new(editor.unwrap().clone(), cx)
1112 .contained()
1113 .with_margin_left(style.icon_spacing)
1114 .aligned()
1115 .left()
1116 .flex(1.0, true)
1117 .boxed()
1118 } else {
1119 Label::new(details.filename.clone(), style.text.clone())
1120 .contained()
1121 .with_margin_left(style.icon_spacing)
1122 .aligned()
1123 .left()
1124 .boxed()
1125 })
1126 .constrained()
1127 .with_height(style.height)
1128 .contained()
1129 .with_style(row_container_style)
1130 .with_padding_left(padding)
1131 .boxed()
1132 }
1133
1134 fn render_entry(
1135 entry_id: ProjectEntryId,
1136 details: EntryDetails,
1137 editor: &ViewHandle<Editor>,
1138 dragged_entry_destination: &mut Option<Arc<Path>>,
1139 theme: &theme::ProjectPanel,
1140 cx: &mut RenderContext<Self>,
1141 ) -> ElementBox {
1142 let this = cx.handle();
1143 let kind = details.kind;
1144 let path = details.path.clone();
1145 let padding = theme.container.padding.left + details.depth as f32 * theme.indent_width;
1146
1147 let entry_style = if details.is_cut {
1148 &theme.cut_entry
1149 } else if details.is_ignored {
1150 &theme.ignored_entry
1151 } else {
1152 &theme.entry
1153 };
1154
1155 let show_editor = details.is_editing && !details.is_processing;
1156
1157 MouseEventHandler::<Self>::new(entry_id.to_usize(), cx, |state, cx| {
1158 let mut style = entry_style.style_for(state, details.is_selected).clone();
1159
1160 if cx
1161 .global::<DragAndDrop<Workspace>>()
1162 .currently_dragged::<ProjectEntryId>(cx.window_id())
1163 .is_some()
1164 && dragged_entry_destination
1165 .as_ref()
1166 .filter(|destination| details.path.starts_with(destination))
1167 .is_some()
1168 {
1169 style = entry_style.active.clone().unwrap();
1170 }
1171
1172 let row_container_style = if show_editor {
1173 theme.filename_editor.container
1174 } else {
1175 style.container
1176 };
1177
1178 Self::render_entry_visual_element(
1179 &details,
1180 Some(editor),
1181 padding,
1182 row_container_style,
1183 &style,
1184 cx,
1185 )
1186 })
1187 .on_click(MouseButton::Left, move |e, cx| {
1188 if !show_editor {
1189 if kind == EntryKind::Dir {
1190 cx.dispatch_action(ToggleExpanded(entry_id))
1191 } else {
1192 cx.dispatch_action(Open {
1193 entry_id,
1194 change_focus: e.click_count > 1,
1195 })
1196 }
1197 }
1198 })
1199 .on_down(MouseButton::Right, move |e, cx| {
1200 cx.dispatch_action(DeployContextMenu {
1201 entry_id,
1202 position: e.position,
1203 })
1204 })
1205 .on_up(MouseButton::Left, move |_, cx| {
1206 if let Some((_, dragged_entry)) = cx
1207 .global::<DragAndDrop<Workspace>>()
1208 .currently_dragged::<ProjectEntryId>(cx.window_id())
1209 {
1210 cx.dispatch_action(MoveProjectEntry {
1211 entry_to_move: *dragged_entry,
1212 destination: entry_id,
1213 destination_is_file: matches!(details.kind, EntryKind::File(_)),
1214 });
1215 }
1216 })
1217 .on_move(move |_, cx| {
1218 if cx
1219 .global::<DragAndDrop<Workspace>>()
1220 .currently_dragged::<ProjectEntryId>(cx.window_id())
1221 .is_some()
1222 {
1223 if let Some(this) = this.upgrade(cx.app) {
1224 this.update(cx.app, |this, _| {
1225 this.dragged_entry_destination = if matches!(kind, EntryKind::File(_)) {
1226 path.parent().map(|parent| Arc::from(parent))
1227 } else {
1228 Some(path.clone())
1229 };
1230 })
1231 }
1232 }
1233 })
1234 .as_draggable(entry_id, {
1235 let row_container_style = theme.dragged_entry.container;
1236
1237 move |_, cx: &mut RenderContext<Workspace>| {
1238 let theme = cx.global::<Settings>().theme.clone();
1239 Self::render_entry_visual_element(
1240 &details,
1241 None,
1242 padding,
1243 row_container_style,
1244 &theme.project_panel.dragged_entry,
1245 cx,
1246 )
1247 }
1248 })
1249 .with_cursor_style(CursorStyle::PointingHand)
1250 .boxed()
1251 }
1252}
1253
1254impl View for ProjectPanel {
1255 fn ui_name() -> &'static str {
1256 "ProjectPanel"
1257 }
1258
1259 fn render(&mut self, cx: &mut gpui::RenderContext<'_, Self>) -> gpui::ElementBox {
1260 enum ProjectPanel {}
1261 let theme = &cx.global::<Settings>().theme.project_panel;
1262 let mut container_style = theme.container;
1263 let padding = std::mem::take(&mut container_style.padding);
1264 let last_worktree_root_id = self.last_worktree_root_id;
1265
1266 Stack::new()
1267 .with_child(
1268 MouseEventHandler::<ProjectPanel>::new(0, cx, |_, cx| {
1269 UniformList::new(
1270 self.list.clone(),
1271 self.visible_entries
1272 .iter()
1273 .map(|(_, worktree_entries)| worktree_entries.len())
1274 .sum(),
1275 cx,
1276 move |this, range, items, cx| {
1277 let theme = cx.global::<Settings>().theme.clone();
1278 let mut dragged_entry_destination =
1279 this.dragged_entry_destination.clone();
1280 this.for_each_visible_entry(range, cx, |id, details, cx| {
1281 items.push(Self::render_entry(
1282 id,
1283 details,
1284 &this.filename_editor,
1285 &mut dragged_entry_destination,
1286 &theme.project_panel,
1287 cx,
1288 ));
1289 });
1290 this.dragged_entry_destination = dragged_entry_destination;
1291 },
1292 )
1293 .with_padding_top(padding.top)
1294 .with_padding_bottom(padding.bottom)
1295 .contained()
1296 .with_style(container_style)
1297 .expanded()
1298 .boxed()
1299 })
1300 .on_down(MouseButton::Right, move |e, cx| {
1301 // When deploying the context menu anywhere below the last project entry,
1302 // act as if the user clicked the root of the last worktree.
1303 if let Some(entry_id) = last_worktree_root_id {
1304 cx.dispatch_action(DeployContextMenu {
1305 entry_id,
1306 position: e.position,
1307 })
1308 }
1309 })
1310 .boxed(),
1311 )
1312 .with_child(ChildView::new(&self.context_menu, cx).boxed())
1313 .boxed()
1314 }
1315
1316 fn keymap_context(&self, _: &AppContext) -> KeymapContext {
1317 let mut cx = Self::default_keymap_context();
1318 cx.set.insert("menu".into());
1319 cx
1320 }
1321}
1322
1323impl Entity for ProjectPanel {
1324 type Event = Event;
1325}
1326
1327impl workspace::sidebar::SidebarItem for ProjectPanel {
1328 fn should_show_badge(&self, _: &AppContext) -> bool {
1329 false
1330 }
1331}
1332
1333impl ClipboardEntry {
1334 fn is_cut(&self) -> bool {
1335 matches!(self, Self::Cut { .. })
1336 }
1337
1338 fn entry_id(&self) -> ProjectEntryId {
1339 match self {
1340 ClipboardEntry::Copied { entry_id, .. } | ClipboardEntry::Cut { entry_id, .. } => {
1341 *entry_id
1342 }
1343 }
1344 }
1345
1346 fn worktree_id(&self) -> WorktreeId {
1347 match self {
1348 ClipboardEntry::Copied { worktree_id, .. }
1349 | ClipboardEntry::Cut { worktree_id, .. } => *worktree_id,
1350 }
1351 }
1352}
1353
1354#[cfg(test)]
1355mod tests {
1356 use super::*;
1357 use gpui::{TestAppContext, ViewHandle};
1358 use project::FakeFs;
1359 use serde_json::json;
1360 use std::{collections::HashSet, path::Path};
1361
1362 #[gpui::test]
1363 async fn test_visible_list(cx: &mut gpui::TestAppContext) {
1364 cx.foreground().forbid_parking();
1365 cx.update(|cx| {
1366 let settings = Settings::test(cx);
1367 cx.set_global(settings);
1368 });
1369
1370 let fs = FakeFs::new(cx.background());
1371 fs.insert_tree(
1372 "/root1",
1373 json!({
1374 ".dockerignore": "",
1375 ".git": {
1376 "HEAD": "",
1377 },
1378 "a": {
1379 "0": { "q": "", "r": "", "s": "" },
1380 "1": { "t": "", "u": "" },
1381 "2": { "v": "", "w": "", "x": "", "y": "" },
1382 },
1383 "b": {
1384 "3": { "Q": "" },
1385 "4": { "R": "", "S": "", "T": "", "U": "" },
1386 },
1387 "C": {
1388 "5": {},
1389 "6": { "V": "", "W": "" },
1390 "7": { "X": "" },
1391 "8": { "Y": {}, "Z": "" }
1392 }
1393 }),
1394 )
1395 .await;
1396 fs.insert_tree(
1397 "/root2",
1398 json!({
1399 "d": {
1400 "9": ""
1401 },
1402 "e": {}
1403 }),
1404 )
1405 .await;
1406
1407 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1408 let (_, workspace) = cx.add_window(|cx| {
1409 Workspace::new(
1410 Default::default(),
1411 0,
1412 project.clone(),
1413 |_, _| unimplemented!(),
1414 cx,
1415 )
1416 });
1417 let panel = workspace.update(cx, |_, cx| ProjectPanel::new(project, cx));
1418 assert_eq!(
1419 visible_entries_as_strings(&panel, 0..50, cx),
1420 &[
1421 "v root1",
1422 " > .git",
1423 " > a",
1424 " > b",
1425 " > C",
1426 " .dockerignore",
1427 "v root2",
1428 " > d",
1429 " > e",
1430 ]
1431 );
1432
1433 toggle_expand_dir(&panel, "root1/b", cx);
1434 assert_eq!(
1435 visible_entries_as_strings(&panel, 0..50, cx),
1436 &[
1437 "v root1",
1438 " > .git",
1439 " > a",
1440 " v b <== selected",
1441 " > 3",
1442 " > 4",
1443 " > C",
1444 " .dockerignore",
1445 "v root2",
1446 " > d",
1447 " > e",
1448 ]
1449 );
1450
1451 assert_eq!(
1452 visible_entries_as_strings(&panel, 6..9, cx),
1453 &[
1454 //
1455 " > C",
1456 " .dockerignore",
1457 "v root2",
1458 ]
1459 );
1460 }
1461
1462 #[gpui::test(iterations = 30)]
1463 async fn test_editing_files(cx: &mut gpui::TestAppContext) {
1464 cx.foreground().forbid_parking();
1465 cx.update(|cx| {
1466 let settings = Settings::test(cx);
1467 cx.set_global(settings);
1468 });
1469
1470 let fs = FakeFs::new(cx.background());
1471 fs.insert_tree(
1472 "/root1",
1473 json!({
1474 ".dockerignore": "",
1475 ".git": {
1476 "HEAD": "",
1477 },
1478 "a": {
1479 "0": { "q": "", "r": "", "s": "" },
1480 "1": { "t": "", "u": "" },
1481 "2": { "v": "", "w": "", "x": "", "y": "" },
1482 },
1483 "b": {
1484 "3": { "Q": "" },
1485 "4": { "R": "", "S": "", "T": "", "U": "" },
1486 },
1487 "C": {
1488 "5": {},
1489 "6": { "V": "", "W": "" },
1490 "7": { "X": "" },
1491 "8": { "Y": {}, "Z": "" }
1492 }
1493 }),
1494 )
1495 .await;
1496 fs.insert_tree(
1497 "/root2",
1498 json!({
1499 "d": {
1500 "9": ""
1501 },
1502 "e": {}
1503 }),
1504 )
1505 .await;
1506
1507 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1508 let (_, workspace) = cx.add_window(|cx| {
1509 Workspace::new(
1510 Default::default(),
1511 0,
1512 project.clone(),
1513 |_, _| unimplemented!(),
1514 cx,
1515 )
1516 });
1517 let panel = workspace.update(cx, |_, cx| ProjectPanel::new(project, cx));
1518
1519 select_path(&panel, "root1", cx);
1520 assert_eq!(
1521 visible_entries_as_strings(&panel, 0..10, cx),
1522 &[
1523 "v root1 <== selected",
1524 " > .git",
1525 " > a",
1526 " > b",
1527 " > C",
1528 " .dockerignore",
1529 "v root2",
1530 " > d",
1531 " > e",
1532 ]
1533 );
1534
1535 // Add a file with the root folder selected. The filename editor is placed
1536 // before the first file in the root folder.
1537 panel.update(cx, |panel, cx| panel.add_file(&AddFile, cx));
1538 assert!(panel.read_with(cx, |panel, cx| panel.filename_editor.is_focused(cx)));
1539 assert_eq!(
1540 visible_entries_as_strings(&panel, 0..10, cx),
1541 &[
1542 "v root1",
1543 " > .git",
1544 " > a",
1545 " > b",
1546 " > C",
1547 " [EDITOR: ''] <== selected",
1548 " .dockerignore",
1549 "v root2",
1550 " > d",
1551 " > e",
1552 ]
1553 );
1554
1555 let confirm = panel.update(cx, |panel, cx| {
1556 panel
1557 .filename_editor
1558 .update(cx, |editor, cx| editor.set_text("the-new-filename", cx));
1559 panel.confirm(&Confirm, cx).unwrap()
1560 });
1561 assert_eq!(
1562 visible_entries_as_strings(&panel, 0..10, cx),
1563 &[
1564 "v root1",
1565 " > .git",
1566 " > a",
1567 " > b",
1568 " > C",
1569 " [PROCESSING: 'the-new-filename'] <== selected",
1570 " .dockerignore",
1571 "v root2",
1572 " > d",
1573 " > e",
1574 ]
1575 );
1576
1577 confirm.await.unwrap();
1578 assert_eq!(
1579 visible_entries_as_strings(&panel, 0..10, cx),
1580 &[
1581 "v root1",
1582 " > .git",
1583 " > a",
1584 " > b",
1585 " > C",
1586 " .dockerignore",
1587 " the-new-filename <== selected",
1588 "v root2",
1589 " > d",
1590 " > e",
1591 ]
1592 );
1593
1594 select_path(&panel, "root1/b", cx);
1595 panel.update(cx, |panel, cx| panel.add_file(&AddFile, cx));
1596 assert_eq!(
1597 visible_entries_as_strings(&panel, 0..10, cx),
1598 &[
1599 "v root1",
1600 " > .git",
1601 " > a",
1602 " v b",
1603 " > 3",
1604 " > 4",
1605 " [EDITOR: ''] <== selected",
1606 " > C",
1607 " .dockerignore",
1608 " the-new-filename",
1609 ]
1610 );
1611
1612 panel
1613 .update(cx, |panel, cx| {
1614 panel
1615 .filename_editor
1616 .update(cx, |editor, cx| editor.set_text("another-filename", cx));
1617 panel.confirm(&Confirm, cx).unwrap()
1618 })
1619 .await
1620 .unwrap();
1621 assert_eq!(
1622 visible_entries_as_strings(&panel, 0..10, cx),
1623 &[
1624 "v root1",
1625 " > .git",
1626 " > a",
1627 " v b",
1628 " > 3",
1629 " > 4",
1630 " another-filename <== selected",
1631 " > C",
1632 " .dockerignore",
1633 " the-new-filename",
1634 ]
1635 );
1636
1637 select_path(&panel, "root1/b/another-filename", cx);
1638 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
1639 assert_eq!(
1640 visible_entries_as_strings(&panel, 0..10, cx),
1641 &[
1642 "v root1",
1643 " > .git",
1644 " > a",
1645 " v b",
1646 " > 3",
1647 " > 4",
1648 " [EDITOR: 'another-filename'] <== selected",
1649 " > C",
1650 " .dockerignore",
1651 " the-new-filename",
1652 ]
1653 );
1654
1655 let confirm = panel.update(cx, |panel, cx| {
1656 panel
1657 .filename_editor
1658 .update(cx, |editor, cx| editor.set_text("a-different-filename", cx));
1659 panel.confirm(&Confirm, cx).unwrap()
1660 });
1661 assert_eq!(
1662 visible_entries_as_strings(&panel, 0..10, cx),
1663 &[
1664 "v root1",
1665 " > .git",
1666 " > a",
1667 " v b",
1668 " > 3",
1669 " > 4",
1670 " [PROCESSING: 'a-different-filename'] <== selected",
1671 " > C",
1672 " .dockerignore",
1673 " the-new-filename",
1674 ]
1675 );
1676
1677 confirm.await.unwrap();
1678 assert_eq!(
1679 visible_entries_as_strings(&panel, 0..10, cx),
1680 &[
1681 "v root1",
1682 " > .git",
1683 " > a",
1684 " v b",
1685 " > 3",
1686 " > 4",
1687 " a-different-filename <== selected",
1688 " > C",
1689 " .dockerignore",
1690 " the-new-filename",
1691 ]
1692 );
1693
1694 panel.update(cx, |panel, cx| panel.add_directory(&AddDirectory, cx));
1695 assert_eq!(
1696 visible_entries_as_strings(&panel, 0..10, cx),
1697 &[
1698 "v root1",
1699 " > .git",
1700 " > a",
1701 " v b",
1702 " > [EDITOR: ''] <== selected",
1703 " > 3",
1704 " > 4",
1705 " a-different-filename",
1706 " > C",
1707 " .dockerignore",
1708 ]
1709 );
1710
1711 let confirm = panel.update(cx, |panel, cx| {
1712 panel
1713 .filename_editor
1714 .update(cx, |editor, cx| editor.set_text("new-dir", cx));
1715 panel.confirm(&Confirm, cx).unwrap()
1716 });
1717 panel.update(cx, |panel, cx| panel.select_next(&Default::default(), cx));
1718 assert_eq!(
1719 visible_entries_as_strings(&panel, 0..10, cx),
1720 &[
1721 "v root1",
1722 " > .git",
1723 " > a",
1724 " v b",
1725 " > [PROCESSING: 'new-dir']",
1726 " > 3 <== selected",
1727 " > 4",
1728 " a-different-filename",
1729 " > C",
1730 " .dockerignore",
1731 ]
1732 );
1733
1734 confirm.await.unwrap();
1735 assert_eq!(
1736 visible_entries_as_strings(&panel, 0..10, cx),
1737 &[
1738 "v root1",
1739 " > .git",
1740 " > a",
1741 " v b",
1742 " > 3 <== selected",
1743 " > 4",
1744 " > new-dir",
1745 " a-different-filename",
1746 " > C",
1747 " .dockerignore",
1748 ]
1749 );
1750
1751 panel.update(cx, |panel, cx| panel.rename(&Default::default(), cx));
1752 assert_eq!(
1753 visible_entries_as_strings(&panel, 0..10, cx),
1754 &[
1755 "v root1",
1756 " > .git",
1757 " > a",
1758 " v b",
1759 " > [EDITOR: '3'] <== selected",
1760 " > 4",
1761 " > new-dir",
1762 " a-different-filename",
1763 " > C",
1764 " .dockerignore",
1765 ]
1766 );
1767
1768 // Dismiss the rename editor when it loses focus.
1769 workspace.update(cx, |_, cx| cx.focus_self());
1770 assert_eq!(
1771 visible_entries_as_strings(&panel, 0..10, cx),
1772 &[
1773 "v root1",
1774 " > .git",
1775 " > a",
1776 " v b",
1777 " > 3 <== selected",
1778 " > 4",
1779 " > new-dir",
1780 " a-different-filename",
1781 " > C",
1782 " .dockerignore",
1783 ]
1784 );
1785 }
1786
1787 fn toggle_expand_dir(
1788 panel: &ViewHandle<ProjectPanel>,
1789 path: impl AsRef<Path>,
1790 cx: &mut TestAppContext,
1791 ) {
1792 let path = path.as_ref();
1793 panel.update(cx, |panel, cx| {
1794 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
1795 let worktree = worktree.read(cx);
1796 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
1797 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
1798 panel.toggle_expanded(&ToggleExpanded(entry_id), cx);
1799 return;
1800 }
1801 }
1802 panic!("no worktree for path {:?}", path);
1803 });
1804 }
1805
1806 fn select_path(
1807 panel: &ViewHandle<ProjectPanel>,
1808 path: impl AsRef<Path>,
1809 cx: &mut TestAppContext,
1810 ) {
1811 let path = path.as_ref();
1812 panel.update(cx, |panel, cx| {
1813 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
1814 let worktree = worktree.read(cx);
1815 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
1816 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
1817 panel.selection = Some(Selection {
1818 worktree_id: worktree.id(),
1819 entry_id,
1820 });
1821 return;
1822 }
1823 }
1824 panic!("no worktree for path {:?}", path);
1825 });
1826 }
1827
1828 fn visible_entries_as_strings(
1829 panel: &ViewHandle<ProjectPanel>,
1830 range: Range<usize>,
1831 cx: &mut TestAppContext,
1832 ) -> Vec<String> {
1833 let mut result = Vec::new();
1834 let mut project_entries = HashSet::new();
1835 let mut has_editor = false;
1836 cx.render(panel, |panel, cx| {
1837 panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
1838 if details.is_editing {
1839 assert!(!has_editor, "duplicate editor entry");
1840 has_editor = true;
1841 } else {
1842 assert!(
1843 project_entries.insert(project_entry),
1844 "duplicate project entry {:?} {:?}",
1845 project_entry,
1846 details
1847 );
1848 }
1849
1850 let indent = " ".repeat(details.depth);
1851 let icon = if matches!(details.kind, EntryKind::Dir | EntryKind::PendingDir) {
1852 if details.is_expanded {
1853 "v "
1854 } else {
1855 "> "
1856 }
1857 } else {
1858 " "
1859 };
1860 let name = if details.is_editing {
1861 format!("[EDITOR: '{}']", details.filename)
1862 } else if details.is_processing {
1863 format!("[PROCESSING: '{}']", details.filename)
1864 } else {
1865 details.filename.clone()
1866 };
1867 let selected = if details.is_selected {
1868 " <== selected"
1869 } else {
1870 ""
1871 };
1872 result.push(format!("{indent}{icon}{name}{selected}"));
1873 });
1874 });
1875
1876 result
1877 }
1878}