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