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, repo) in
1014 snapshot.entries_with_repositories(visible_worktree_entries[entry_range].iter())
1015 {
1016 let status = (entry.path.parent().is_some() && !entry.is_ignored)
1017 .then(|| repo.and_then(|repo| repo.status_for_path(&snapshot, &entry.path)))
1018 .flatten();
1019
1020 let mut details = EntryDetails {
1021 filename: entry
1022 .path
1023 .file_name()
1024 .unwrap_or(root_name)
1025 .to_string_lossy()
1026 .to_string(),
1027 path: entry.path.clone(),
1028 depth: entry.path.components().count(),
1029 kind: entry.kind,
1030 is_ignored: entry.is_ignored,
1031 is_expanded: expanded_entry_ids.binary_search(&entry.id).is_ok(),
1032 is_selected: self.selection.map_or(false, |e| {
1033 e.worktree_id == snapshot.id() && e.entry_id == entry.id
1034 }),
1035 is_editing: false,
1036 is_processing: false,
1037 is_cut: self
1038 .clipboard_entry
1039 .map_or(false, |e| e.is_cut() && e.entry_id() == entry.id),
1040 git_status: status,
1041 };
1042
1043 if let Some(edit_state) = &self.edit_state {
1044 let is_edited_entry = if edit_state.is_new_entry {
1045 entry.id == NEW_ENTRY_ID
1046 } else {
1047 entry.id == edit_state.entry_id
1048 };
1049
1050 if is_edited_entry {
1051 if let Some(processing_filename) = &edit_state.processing_filename {
1052 details.is_processing = true;
1053 details.filename.clear();
1054 details.filename.push_str(processing_filename);
1055 } else {
1056 if edit_state.is_new_entry {
1057 details.filename.clear();
1058 }
1059 details.is_editing = true;
1060 }
1061 }
1062 }
1063
1064 callback(entry.id, details, cx);
1065 }
1066 }
1067 ix = end_ix;
1068 }
1069 }
1070
1071 fn render_entry_visual_element<V: View>(
1072 details: &EntryDetails,
1073 editor: Option<&ViewHandle<Editor>>,
1074 padding: f32,
1075 row_container_style: ContainerStyle,
1076 style: &ProjectPanelEntry,
1077 cx: &mut ViewContext<V>,
1078 ) -> AnyElement<V> {
1079 let kind = details.kind;
1080 let show_editor = details.is_editing && !details.is_processing;
1081
1082 Flex::row()
1083 .with_child(
1084 if kind == EntryKind::Dir {
1085 if details.is_expanded {
1086 Svg::new("icons/chevron_down_8.svg").with_color(style.icon_color)
1087 } else {
1088 Svg::new("icons/chevron_right_8.svg").with_color(style.icon_color)
1089 }
1090 .constrained()
1091 } else {
1092 Empty::new().constrained()
1093 }
1094 .with_max_width(style.icon_size)
1095 .with_max_height(style.icon_size)
1096 .aligned()
1097 .constrained()
1098 .with_width(style.icon_size),
1099 )
1100 .with_child(if show_editor && editor.is_some() {
1101 ChildView::new(editor.as_ref().unwrap(), cx)
1102 .contained()
1103 .with_margin_left(style.icon_spacing)
1104 .aligned()
1105 .left()
1106 .flex(1.0, true)
1107 .into_any()
1108 } else {
1109 ComponentHost::new(FileName::new(
1110 details.filename.clone(),
1111 details.git_status,
1112 FileName::style(style.text.clone(), &theme::current(cx)),
1113 ))
1114 .contained()
1115 .with_margin_left(style.icon_spacing)
1116 .aligned()
1117 .left()
1118 .into_any()
1119 })
1120 .constrained()
1121 .with_height(style.height)
1122 .contained()
1123 .with_style(row_container_style)
1124 .with_padding_left(padding)
1125 .into_any_named("project panel entry visual element")
1126 }
1127
1128 fn render_entry(
1129 entry_id: ProjectEntryId,
1130 details: EntryDetails,
1131 editor: &ViewHandle<Editor>,
1132 dragged_entry_destination: &mut Option<Arc<Path>>,
1133 theme: &theme::ProjectPanel,
1134 cx: &mut ViewContext<Self>,
1135 ) -> AnyElement<Self> {
1136 let kind = details.kind;
1137 let path = details.path.clone();
1138 let padding = theme.container.padding.left + details.depth as f32 * theme.indent_width;
1139
1140 let entry_style = if details.is_cut {
1141 &theme.cut_entry
1142 } else if details.is_ignored {
1143 &theme.ignored_entry
1144 } else {
1145 &theme.entry
1146 };
1147
1148 let show_editor = details.is_editing && !details.is_processing;
1149
1150 MouseEventHandler::<Self, _>::new(entry_id.to_usize(), cx, |state, cx| {
1151 let mut style = entry_style.style_for(state, details.is_selected).clone();
1152
1153 if cx
1154 .global::<DragAndDrop<Workspace>>()
1155 .currently_dragged::<ProjectEntryId>(cx.window_id())
1156 .is_some()
1157 && dragged_entry_destination
1158 .as_ref()
1159 .filter(|destination| details.path.starts_with(destination))
1160 .is_some()
1161 {
1162 style = entry_style.active.clone().unwrap();
1163 }
1164
1165 let row_container_style = if show_editor {
1166 theme.filename_editor.container
1167 } else {
1168 style.container
1169 };
1170
1171 Self::render_entry_visual_element(
1172 &details,
1173 Some(editor),
1174 padding,
1175 row_container_style,
1176 &style,
1177 cx,
1178 )
1179 })
1180 .on_click(MouseButton::Left, move |event, this, cx| {
1181 if !show_editor {
1182 if kind == EntryKind::Dir {
1183 this.toggle_expanded(entry_id, cx);
1184 } else {
1185 this.open_entry(entry_id, event.click_count > 1, cx);
1186 }
1187 }
1188 })
1189 .on_down(MouseButton::Right, move |event, this, cx| {
1190 this.deploy_context_menu(event.position, entry_id, cx);
1191 })
1192 .on_up(MouseButton::Left, move |_, this, cx| {
1193 if let Some((_, dragged_entry)) = cx
1194 .global::<DragAndDrop<Workspace>>()
1195 .currently_dragged::<ProjectEntryId>(cx.window_id())
1196 {
1197 this.move_entry(
1198 *dragged_entry,
1199 entry_id,
1200 matches!(details.kind, EntryKind::File(_)),
1201 cx,
1202 );
1203 }
1204 })
1205 .on_move(move |_, this, cx| {
1206 if cx
1207 .global::<DragAndDrop<Workspace>>()
1208 .currently_dragged::<ProjectEntryId>(cx.window_id())
1209 .is_some()
1210 {
1211 this.dragged_entry_destination = if matches!(kind, EntryKind::File(_)) {
1212 path.parent().map(|parent| Arc::from(parent))
1213 } else {
1214 Some(path.clone())
1215 };
1216 }
1217 })
1218 .as_draggable(entry_id, {
1219 let row_container_style = theme.dragged_entry.container;
1220
1221 move |_, cx: &mut ViewContext<Workspace>| {
1222 let theme = theme::current(cx).clone();
1223 Self::render_entry_visual_element(
1224 &details,
1225 None,
1226 padding,
1227 row_container_style,
1228 &theme.project_panel.dragged_entry,
1229 cx,
1230 )
1231 }
1232 })
1233 .with_cursor_style(CursorStyle::PointingHand)
1234 .into_any_named("project panel entry")
1235 }
1236}
1237
1238impl View for ProjectPanel {
1239 fn ui_name() -> &'static str {
1240 "ProjectPanel"
1241 }
1242
1243 fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> gpui::AnyElement<Self> {
1244 enum ProjectPanel {}
1245 let theme = &theme::current(cx).project_panel;
1246 let mut container_style = theme.container;
1247 let padding = std::mem::take(&mut container_style.padding);
1248 let last_worktree_root_id = self.last_worktree_root_id;
1249
1250 let has_worktree = self.visible_entries.len() != 0;
1251
1252 if has_worktree {
1253 Stack::new()
1254 .with_child(
1255 MouseEventHandler::<ProjectPanel, _>::new(0, cx, |_, cx| {
1256 UniformList::new(
1257 self.list.clone(),
1258 self.visible_entries
1259 .iter()
1260 .map(|(_, worktree_entries)| worktree_entries.len())
1261 .sum(),
1262 cx,
1263 move |this, range, items, cx| {
1264 let theme = theme::current(cx).clone();
1265 let mut dragged_entry_destination =
1266 this.dragged_entry_destination.clone();
1267 this.for_each_visible_entry(range, cx, |id, details, cx| {
1268 items.push(Self::render_entry(
1269 id,
1270 details,
1271 &this.filename_editor,
1272 &mut dragged_entry_destination,
1273 &theme.project_panel,
1274 cx,
1275 ));
1276 });
1277 this.dragged_entry_destination = dragged_entry_destination;
1278 },
1279 )
1280 .with_padding_top(padding.top)
1281 .with_padding_bottom(padding.bottom)
1282 .contained()
1283 .with_style(container_style)
1284 .expanded()
1285 })
1286 .on_down(MouseButton::Right, move |event, this, cx| {
1287 // When deploying the context menu anywhere below the last project entry,
1288 // act as if the user clicked the root of the last worktree.
1289 if let Some(entry_id) = last_worktree_root_id {
1290 this.deploy_context_menu(event.position, entry_id, cx);
1291 }
1292 }),
1293 )
1294 .with_child(ChildView::new(&self.context_menu, cx))
1295 .into_any_named("project panel")
1296 } else {
1297 Flex::column()
1298 .with_child(
1299 MouseEventHandler::<Self, _>::new(2, cx, {
1300 let button_style = theme.open_project_button.clone();
1301 let context_menu_item_style = theme::current(cx).context_menu.item.clone();
1302 move |state, cx| {
1303 let button_style = button_style.style_for(state, false).clone();
1304 let context_menu_item =
1305 context_menu_item_style.style_for(state, true).clone();
1306
1307 theme::ui::keystroke_label(
1308 "Open a project",
1309 &button_style,
1310 &context_menu_item.keystroke,
1311 Box::new(workspace::Open),
1312 cx,
1313 )
1314 }
1315 })
1316 .on_click(MouseButton::Left, move |_, this, cx| {
1317 if let Some(workspace) = this.workspace.upgrade(cx) {
1318 workspace.update(cx, |workspace, cx| {
1319 if let Some(task) = workspace.open(&Default::default(), cx) {
1320 task.detach_and_log_err(cx);
1321 }
1322 })
1323 }
1324 })
1325 .with_cursor_style(CursorStyle::PointingHand),
1326 )
1327 .contained()
1328 .with_style(container_style)
1329 .into_any_named("empty project panel")
1330 }
1331 }
1332
1333 fn update_keymap_context(&self, keymap: &mut KeymapContext, _: &AppContext) {
1334 Self::reset_to_default_keymap_context(keymap);
1335 keymap.add_identifier("menu");
1336 }
1337}
1338
1339impl Entity for ProjectPanel {
1340 type Event = Event;
1341}
1342
1343impl workspace::sidebar::SidebarItem for ProjectPanel {
1344 fn should_show_badge(&self, _: &AppContext) -> bool {
1345 false
1346 }
1347}
1348
1349impl ClipboardEntry {
1350 fn is_cut(&self) -> bool {
1351 matches!(self, Self::Cut { .. })
1352 }
1353
1354 fn entry_id(&self) -> ProjectEntryId {
1355 match self {
1356 ClipboardEntry::Copied { entry_id, .. } | ClipboardEntry::Cut { entry_id, .. } => {
1357 *entry_id
1358 }
1359 }
1360 }
1361
1362 fn worktree_id(&self) -> WorktreeId {
1363 match self {
1364 ClipboardEntry::Copied { worktree_id, .. }
1365 | ClipboardEntry::Cut { worktree_id, .. } => *worktree_id,
1366 }
1367 }
1368}
1369
1370#[cfg(test)]
1371mod tests {
1372 use super::*;
1373 use gpui::{TestAppContext, ViewHandle};
1374 use project::FakeFs;
1375 use serde_json::json;
1376 use settings::SettingsStore;
1377 use std::{collections::HashSet, path::Path};
1378 use workspace::{pane, AppState};
1379
1380 #[gpui::test]
1381 async fn test_visible_list(cx: &mut gpui::TestAppContext) {
1382 init_test(cx);
1383
1384 let fs = FakeFs::new(cx.background());
1385 fs.insert_tree(
1386 "/root1",
1387 json!({
1388 ".dockerignore": "",
1389 ".git": {
1390 "HEAD": "",
1391 },
1392 "a": {
1393 "0": { "q": "", "r": "", "s": "" },
1394 "1": { "t": "", "u": "" },
1395 "2": { "v": "", "w": "", "x": "", "y": "" },
1396 },
1397 "b": {
1398 "3": { "Q": "" },
1399 "4": { "R": "", "S": "", "T": "", "U": "" },
1400 },
1401 "C": {
1402 "5": {},
1403 "6": { "V": "", "W": "" },
1404 "7": { "X": "" },
1405 "8": { "Y": {}, "Z": "" }
1406 }
1407 }),
1408 )
1409 .await;
1410 fs.insert_tree(
1411 "/root2",
1412 json!({
1413 "d": {
1414 "9": ""
1415 },
1416 "e": {}
1417 }),
1418 )
1419 .await;
1420
1421 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1422 let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1423 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
1424 assert_eq!(
1425 visible_entries_as_strings(&panel, 0..50, cx),
1426 &[
1427 "v root1",
1428 " > .git",
1429 " > a",
1430 " > b",
1431 " > C",
1432 " .dockerignore",
1433 "v root2",
1434 " > d",
1435 " > e",
1436 ]
1437 );
1438
1439 toggle_expand_dir(&panel, "root1/b", cx);
1440 assert_eq!(
1441 visible_entries_as_strings(&panel, 0..50, cx),
1442 &[
1443 "v root1",
1444 " > .git",
1445 " > a",
1446 " v b <== selected",
1447 " > 3",
1448 " > 4",
1449 " > C",
1450 " .dockerignore",
1451 "v root2",
1452 " > d",
1453 " > e",
1454 ]
1455 );
1456
1457 assert_eq!(
1458 visible_entries_as_strings(&panel, 6..9, cx),
1459 &[
1460 //
1461 " > C",
1462 " .dockerignore",
1463 "v root2",
1464 ]
1465 );
1466 }
1467
1468 #[gpui::test(iterations = 30)]
1469 async fn test_editing_files(cx: &mut gpui::TestAppContext) {
1470 init_test(cx);
1471
1472 let fs = FakeFs::new(cx.background());
1473 fs.insert_tree(
1474 "/root1",
1475 json!({
1476 ".dockerignore": "",
1477 ".git": {
1478 "HEAD": "",
1479 },
1480 "a": {
1481 "0": { "q": "", "r": "", "s": "" },
1482 "1": { "t": "", "u": "" },
1483 "2": { "v": "", "w": "", "x": "", "y": "" },
1484 },
1485 "b": {
1486 "3": { "Q": "" },
1487 "4": { "R": "", "S": "", "T": "", "U": "" },
1488 },
1489 "C": {
1490 "5": {},
1491 "6": { "V": "", "W": "" },
1492 "7": { "X": "" },
1493 "8": { "Y": {}, "Z": "" }
1494 }
1495 }),
1496 )
1497 .await;
1498 fs.insert_tree(
1499 "/root2",
1500 json!({
1501 "d": {
1502 "9": ""
1503 },
1504 "e": {}
1505 }),
1506 )
1507 .await;
1508
1509 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1510 let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1511 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
1512
1513 select_path(&panel, "root1", cx);
1514 assert_eq!(
1515 visible_entries_as_strings(&panel, 0..10, cx),
1516 &[
1517 "v root1 <== selected",
1518 " > .git",
1519 " > a",
1520 " > b",
1521 " > C",
1522 " .dockerignore",
1523 "v root2",
1524 " > d",
1525 " > e",
1526 ]
1527 );
1528
1529 // Add a file with the root folder selected. The filename editor is placed
1530 // before the first file in the root folder.
1531 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
1532 cx.read_window(window_id, |cx| {
1533 let panel = panel.read(cx);
1534 assert!(panel.filename_editor.is_focused(cx));
1535 });
1536 assert_eq!(
1537 visible_entries_as_strings(&panel, 0..10, cx),
1538 &[
1539 "v root1",
1540 " > .git",
1541 " > a",
1542 " > b",
1543 " > C",
1544 " [EDITOR: ''] <== selected",
1545 " .dockerignore",
1546 "v root2",
1547 " > d",
1548 " > e",
1549 ]
1550 );
1551
1552 let confirm = panel.update(cx, |panel, cx| {
1553 panel
1554 .filename_editor
1555 .update(cx, |editor, cx| editor.set_text("the-new-filename", cx));
1556 panel.confirm(&Confirm, cx).unwrap()
1557 });
1558 assert_eq!(
1559 visible_entries_as_strings(&panel, 0..10, cx),
1560 &[
1561 "v root1",
1562 " > .git",
1563 " > a",
1564 " > b",
1565 " > C",
1566 " [PROCESSING: 'the-new-filename'] <== selected",
1567 " .dockerignore",
1568 "v root2",
1569 " > d",
1570 " > e",
1571 ]
1572 );
1573
1574 confirm.await.unwrap();
1575 assert_eq!(
1576 visible_entries_as_strings(&panel, 0..10, cx),
1577 &[
1578 "v root1",
1579 " > .git",
1580 " > a",
1581 " > b",
1582 " > C",
1583 " .dockerignore",
1584 " the-new-filename <== selected",
1585 "v root2",
1586 " > d",
1587 " > e",
1588 ]
1589 );
1590
1591 select_path(&panel, "root1/b", cx);
1592 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
1593 assert_eq!(
1594 visible_entries_as_strings(&panel, 0..10, cx),
1595 &[
1596 "v root1",
1597 " > .git",
1598 " > a",
1599 " v b",
1600 " > 3",
1601 " > 4",
1602 " [EDITOR: ''] <== selected",
1603 " > C",
1604 " .dockerignore",
1605 " the-new-filename",
1606 ]
1607 );
1608
1609 panel
1610 .update(cx, |panel, cx| {
1611 panel
1612 .filename_editor
1613 .update(cx, |editor, cx| editor.set_text("another-filename", cx));
1614 panel.confirm(&Confirm, cx).unwrap()
1615 })
1616 .await
1617 .unwrap();
1618 assert_eq!(
1619 visible_entries_as_strings(&panel, 0..10, cx),
1620 &[
1621 "v root1",
1622 " > .git",
1623 " > a",
1624 " v b",
1625 " > 3",
1626 " > 4",
1627 " another-filename <== selected",
1628 " > C",
1629 " .dockerignore",
1630 " the-new-filename",
1631 ]
1632 );
1633
1634 select_path(&panel, "root1/b/another-filename", cx);
1635 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
1636 assert_eq!(
1637 visible_entries_as_strings(&panel, 0..10, cx),
1638 &[
1639 "v root1",
1640 " > .git",
1641 " > a",
1642 " v b",
1643 " > 3",
1644 " > 4",
1645 " [EDITOR: 'another-filename'] <== selected",
1646 " > C",
1647 " .dockerignore",
1648 " the-new-filename",
1649 ]
1650 );
1651
1652 let confirm = panel.update(cx, |panel, cx| {
1653 panel
1654 .filename_editor
1655 .update(cx, |editor, cx| editor.set_text("a-different-filename", cx));
1656 panel.confirm(&Confirm, cx).unwrap()
1657 });
1658 assert_eq!(
1659 visible_entries_as_strings(&panel, 0..10, cx),
1660 &[
1661 "v root1",
1662 " > .git",
1663 " > a",
1664 " v b",
1665 " > 3",
1666 " > 4",
1667 " [PROCESSING: 'a-different-filename'] <== selected",
1668 " > C",
1669 " .dockerignore",
1670 " the-new-filename",
1671 ]
1672 );
1673
1674 confirm.await.unwrap();
1675 assert_eq!(
1676 visible_entries_as_strings(&panel, 0..10, cx),
1677 &[
1678 "v root1",
1679 " > .git",
1680 " > a",
1681 " v b",
1682 " > 3",
1683 " > 4",
1684 " a-different-filename <== selected",
1685 " > C",
1686 " .dockerignore",
1687 " the-new-filename",
1688 ]
1689 );
1690
1691 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
1692 assert_eq!(
1693 visible_entries_as_strings(&panel, 0..10, cx),
1694 &[
1695 "v root1",
1696 " > .git",
1697 " > a",
1698 " v b",
1699 " > [EDITOR: ''] <== selected",
1700 " > 3",
1701 " > 4",
1702 " a-different-filename",
1703 " > C",
1704 " .dockerignore",
1705 ]
1706 );
1707
1708 let confirm = panel.update(cx, |panel, cx| {
1709 panel
1710 .filename_editor
1711 .update(cx, |editor, cx| editor.set_text("new-dir", cx));
1712 panel.confirm(&Confirm, cx).unwrap()
1713 });
1714 panel.update(cx, |panel, cx| panel.select_next(&Default::default(), 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 " > [PROCESSING: 'new-dir']",
1723 " > 3 <== selected",
1724 " > 4",
1725 " a-different-filename",
1726 " > C",
1727 " .dockerignore",
1728 ]
1729 );
1730
1731 confirm.await.unwrap();
1732 assert_eq!(
1733 visible_entries_as_strings(&panel, 0..10, cx),
1734 &[
1735 "v root1",
1736 " > .git",
1737 " > a",
1738 " v b",
1739 " > 3 <== selected",
1740 " > 4",
1741 " > new-dir",
1742 " a-different-filename",
1743 " > C",
1744 " .dockerignore",
1745 ]
1746 );
1747
1748 panel.update(cx, |panel, cx| panel.rename(&Default::default(), cx));
1749 assert_eq!(
1750 visible_entries_as_strings(&panel, 0..10, cx),
1751 &[
1752 "v root1",
1753 " > .git",
1754 " > a",
1755 " v b",
1756 " > [EDITOR: '3'] <== selected",
1757 " > 4",
1758 " > new-dir",
1759 " a-different-filename",
1760 " > C",
1761 " .dockerignore",
1762 ]
1763 );
1764
1765 // Dismiss the rename editor when it loses focus.
1766 workspace.update(cx, |_, cx| cx.focus_self());
1767 assert_eq!(
1768 visible_entries_as_strings(&panel, 0..10, cx),
1769 &[
1770 "v root1",
1771 " > .git",
1772 " > a",
1773 " v b",
1774 " > 3 <== selected",
1775 " > 4",
1776 " > new-dir",
1777 " a-different-filename",
1778 " > C",
1779 " .dockerignore",
1780 ]
1781 );
1782 }
1783
1784 #[gpui::test]
1785 async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
1786 init_test(cx);
1787
1788 let fs = FakeFs::new(cx.background());
1789 fs.insert_tree(
1790 "/root1",
1791 json!({
1792 "one.two.txt": "",
1793 "one.txt": ""
1794 }),
1795 )
1796 .await;
1797
1798 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
1799 let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1800 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
1801
1802 panel.update(cx, |panel, cx| {
1803 panel.select_next(&Default::default(), cx);
1804 panel.select_next(&Default::default(), cx);
1805 });
1806
1807 assert_eq!(
1808 visible_entries_as_strings(&panel, 0..50, cx),
1809 &[
1810 //
1811 "v root1",
1812 " one.two.txt <== selected",
1813 " one.txt",
1814 ]
1815 );
1816
1817 // Regression test - file name is created correctly when
1818 // the copied file's name contains multiple dots.
1819 panel.update(cx, |panel, cx| {
1820 panel.copy(&Default::default(), cx);
1821 panel.paste(&Default::default(), cx);
1822 });
1823 cx.foreground().run_until_parked();
1824
1825 assert_eq!(
1826 visible_entries_as_strings(&panel, 0..50, cx),
1827 &[
1828 //
1829 "v root1",
1830 " one.two copy.txt",
1831 " one.two.txt <== selected",
1832 " one.txt",
1833 ]
1834 );
1835
1836 panel.update(cx, |panel, cx| {
1837 panel.paste(&Default::default(), cx);
1838 });
1839 cx.foreground().run_until_parked();
1840
1841 assert_eq!(
1842 visible_entries_as_strings(&panel, 0..50, cx),
1843 &[
1844 //
1845 "v root1",
1846 " one.two copy 1.txt",
1847 " one.two copy.txt",
1848 " one.two.txt <== selected",
1849 " one.txt",
1850 ]
1851 );
1852 }
1853
1854 #[gpui::test]
1855 async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
1856 init_test_with_editor(cx);
1857
1858 let fs = FakeFs::new(cx.background());
1859 fs.insert_tree(
1860 "/src",
1861 json!({
1862 "test": {
1863 "first.rs": "// First Rust file",
1864 "second.rs": "// Second Rust file",
1865 "third.rs": "// Third Rust file",
1866 }
1867 }),
1868 )
1869 .await;
1870
1871 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
1872 let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1873 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
1874
1875 toggle_expand_dir(&panel, "src/test", cx);
1876 select_path(&panel, "src/test/first.rs", cx);
1877 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
1878 cx.foreground().run_until_parked();
1879 assert_eq!(
1880 visible_entries_as_strings(&panel, 0..10, cx),
1881 &[
1882 "v src",
1883 " v test",
1884 " first.rs <== selected",
1885 " second.rs",
1886 " third.rs"
1887 ]
1888 );
1889 ensure_single_file_is_opened(window_id, &workspace, "test/first.rs", cx);
1890
1891 submit_deletion(window_id, &panel, cx);
1892 assert_eq!(
1893 visible_entries_as_strings(&panel, 0..10, cx),
1894 &[
1895 "v src",
1896 " v test",
1897 " second.rs",
1898 " third.rs"
1899 ],
1900 "Project panel should have no deleted file, no other file is selected in it"
1901 );
1902 ensure_no_open_items_and_panes(window_id, &workspace, cx);
1903
1904 select_path(&panel, "src/test/second.rs", cx);
1905 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
1906 cx.foreground().run_until_parked();
1907 assert_eq!(
1908 visible_entries_as_strings(&panel, 0..10, cx),
1909 &[
1910 "v src",
1911 " v test",
1912 " second.rs <== selected",
1913 " third.rs"
1914 ]
1915 );
1916 ensure_single_file_is_opened(window_id, &workspace, "test/second.rs", cx);
1917
1918 cx.update_window(window_id, |cx| {
1919 let active_items = workspace
1920 .read(cx)
1921 .panes()
1922 .iter()
1923 .filter_map(|pane| pane.read(cx).active_item())
1924 .collect::<Vec<_>>();
1925 assert_eq!(active_items.len(), 1);
1926 let open_editor = active_items
1927 .into_iter()
1928 .next()
1929 .unwrap()
1930 .downcast::<Editor>()
1931 .expect("Open item should be an editor");
1932 open_editor.update(cx, |editor, cx| editor.set_text("Another text!", cx));
1933 });
1934 submit_deletion(window_id, &panel, cx);
1935 assert_eq!(
1936 visible_entries_as_strings(&panel, 0..10, cx),
1937 &["v src", " v test", " third.rs"],
1938 "Project panel should have no deleted file, with one last file remaining"
1939 );
1940 ensure_no_open_items_and_panes(window_id, &workspace, cx);
1941 }
1942
1943 fn toggle_expand_dir(
1944 panel: &ViewHandle<ProjectPanel>,
1945 path: impl AsRef<Path>,
1946 cx: &mut TestAppContext,
1947 ) {
1948 let path = path.as_ref();
1949 panel.update(cx, |panel, cx| {
1950 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
1951 let worktree = worktree.read(cx);
1952 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
1953 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
1954 panel.toggle_expanded(entry_id, cx);
1955 return;
1956 }
1957 }
1958 panic!("no worktree for path {:?}", path);
1959 });
1960 }
1961
1962 fn select_path(
1963 panel: &ViewHandle<ProjectPanel>,
1964 path: impl AsRef<Path>,
1965 cx: &mut TestAppContext,
1966 ) {
1967 let path = path.as_ref();
1968 panel.update(cx, |panel, cx| {
1969 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
1970 let worktree = worktree.read(cx);
1971 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
1972 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
1973 panel.selection = Some(Selection {
1974 worktree_id: worktree.id(),
1975 entry_id,
1976 });
1977 return;
1978 }
1979 }
1980 panic!("no worktree for path {:?}", path);
1981 });
1982 }
1983
1984 fn visible_entries_as_strings(
1985 panel: &ViewHandle<ProjectPanel>,
1986 range: Range<usize>,
1987 cx: &mut TestAppContext,
1988 ) -> Vec<String> {
1989 let mut result = Vec::new();
1990 let mut project_entries = HashSet::new();
1991 let mut has_editor = false;
1992
1993 panel.update(cx, |panel, cx| {
1994 panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
1995 if details.is_editing {
1996 assert!(!has_editor, "duplicate editor entry");
1997 has_editor = true;
1998 } else {
1999 assert!(
2000 project_entries.insert(project_entry),
2001 "duplicate project entry {:?} {:?}",
2002 project_entry,
2003 details
2004 );
2005 }
2006
2007 let indent = " ".repeat(details.depth);
2008 let icon = if matches!(details.kind, EntryKind::Dir | EntryKind::PendingDir) {
2009 if details.is_expanded {
2010 "v "
2011 } else {
2012 "> "
2013 }
2014 } else {
2015 " "
2016 };
2017 let name = if details.is_editing {
2018 format!("[EDITOR: '{}']", details.filename)
2019 } else if details.is_processing {
2020 format!("[PROCESSING: '{}']", details.filename)
2021 } else {
2022 details.filename.clone()
2023 };
2024 let selected = if details.is_selected {
2025 " <== selected"
2026 } else {
2027 ""
2028 };
2029 result.push(format!("{indent}{icon}{name}{selected}"));
2030 });
2031 });
2032
2033 result
2034 }
2035
2036 fn init_test(cx: &mut TestAppContext) {
2037 cx.foreground().forbid_parking();
2038 cx.update(|cx| {
2039 cx.set_global(SettingsStore::test(cx));
2040 theme::init((), cx);
2041 language::init(cx);
2042 editor::init_settings(cx);
2043 workspace::init_settings(cx);
2044 });
2045 }
2046
2047 fn init_test_with_editor(cx: &mut TestAppContext) {
2048 cx.foreground().forbid_parking();
2049 cx.update(|cx| {
2050 let app_state = AppState::test(cx);
2051 theme::init((), cx);
2052 language::init(cx);
2053 editor::init(cx);
2054 pane::init(cx);
2055 workspace::init(app_state.clone(), cx);
2056 });
2057 }
2058
2059 fn ensure_single_file_is_opened(
2060 window_id: usize,
2061 workspace: &ViewHandle<Workspace>,
2062 expected_path: &str,
2063 cx: &mut TestAppContext,
2064 ) {
2065 cx.read_window(window_id, |cx| {
2066 let workspace = workspace.read(cx);
2067 let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
2068 assert_eq!(worktrees.len(), 1);
2069 let worktree_id = WorktreeId::from_usize(worktrees[0].id());
2070
2071 let open_project_paths = workspace
2072 .panes()
2073 .iter()
2074 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
2075 .collect::<Vec<_>>();
2076 assert_eq!(
2077 open_project_paths,
2078 vec![ProjectPath {
2079 worktree_id,
2080 path: Arc::from(Path::new(expected_path))
2081 }],
2082 "Should have opened file, selected in project panel"
2083 );
2084 });
2085 }
2086
2087 fn submit_deletion(
2088 window_id: usize,
2089 panel: &ViewHandle<ProjectPanel>,
2090 cx: &mut TestAppContext,
2091 ) {
2092 assert!(
2093 !cx.has_pending_prompt(window_id),
2094 "Should have no prompts before the deletion"
2095 );
2096 panel.update(cx, |panel, cx| {
2097 panel
2098 .delete(&Delete, cx)
2099 .expect("Deletion start")
2100 .detach_and_log_err(cx);
2101 });
2102 assert!(
2103 cx.has_pending_prompt(window_id),
2104 "Should have a prompt after the deletion"
2105 );
2106 cx.simulate_prompt_answer(window_id, 0);
2107 assert!(
2108 !cx.has_pending_prompt(window_id),
2109 "Should have no prompts after prompt was replied to"
2110 );
2111 cx.foreground().run_until_parked();
2112 }
2113
2114 fn ensure_no_open_items_and_panes(
2115 window_id: usize,
2116 workspace: &ViewHandle<Workspace>,
2117 cx: &mut TestAppContext,
2118 ) {
2119 assert!(
2120 !cx.has_pending_prompt(window_id),
2121 "Should have no prompts after deletion operation closes the file"
2122 );
2123 cx.read_window(window_id, |cx| {
2124 let open_project_paths = workspace
2125 .read(cx)
2126 .panes()
2127 .iter()
2128 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
2129 .collect::<Vec<_>>();
2130 assert!(
2131 open_project_paths.is_empty(),
2132 "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
2133 );
2134 });
2135 }
2136}