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