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