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