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