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