1pub mod file_associations;
2mod project_panel_settings;
3
4use context_menu::{ContextMenu, ContextMenuItem};
5use db::kvp::KEY_VALUE_STORE;
6use drag_and_drop::{DragAndDrop, Draggable};
7use editor::{scroll::autoscroll::Autoscroll, Cancel, Editor};
8use file_associations::FileAssociations;
9
10use futures::stream::StreamExt;
11use gpui::{
12 actions,
13 anyhow::{self, anyhow, Result},
14 elements::{
15 AnchorCorner, ChildView, ContainerStyle, Empty, Flex, Label, MouseEventHandler,
16 ParentElement, ScrollTarget, Stack, Svg, UniformList, UniformListState,
17 },
18 geometry::vector::Vector2F,
19 keymap_matcher::KeymapContext,
20 platform::{CursorStyle, MouseButton, PromptLevel},
21 Action, AnyElement, AppContext, AssetSource, AsyncAppContext, ClipboardItem, Element, Entity,
22 ModelHandle, Task, View, ViewContext, ViewHandle, WeakViewHandle, WindowContext,
23};
24use menu::{Confirm, SelectNext, SelectPrev};
25use project::{
26 repository::GitFileStatus, Entry, EntryKind, Fs, Project, ProjectEntryId, ProjectPath,
27 Worktree, WorktreeId,
28};
29use project_panel_settings::{ProjectPanelDockPosition, ProjectPanelSettings};
30use serde::{Deserialize, Serialize};
31use settings::SettingsStore;
32use std::{
33 cmp::Ordering,
34 collections::{hash_map, HashMap},
35 ffi::OsStr,
36 ops::Range,
37 path::Path,
38 sync::Arc,
39};
40use theme::ProjectPanelEntry;
41use unicase::UniCase;
42use util::{ResultExt, TryFutureExt};
43use workspace::{
44 dock::{DockPosition, Panel},
45 Workspace,
46};
47
48const PROJECT_PANEL_KEY: &'static str = "ProjectPanel";
49const NEW_ENTRY_ID: ProjectEntryId = ProjectEntryId::MAX;
50
51pub struct ProjectPanel {
52 project: ModelHandle<Project>,
53 fs: Arc<dyn Fs>,
54 list: UniformListState,
55 visible_entries: Vec<(WorktreeId, Vec<Entry>)>,
56 last_worktree_root_id: Option<ProjectEntryId>,
57 expanded_dir_ids: HashMap<WorktreeId, Vec<ProjectEntryId>>,
58 selection: Option<Selection>,
59 edit_state: Option<EditState>,
60 filename_editor: ViewHandle<Editor>,
61 clipboard_entry: Option<ClipboardEntry>,
62 context_menu: ViewHandle<ContextMenu>,
63 dragged_entry_destination: Option<Arc<Path>>,
64 workspace: WeakViewHandle<Workspace>,
65 has_focus: bool,
66 width: Option<f32>,
67 pending_serialization: Task<Option<()>>,
68}
69
70#[derive(Copy, Clone, Debug)]
71struct Selection {
72 worktree_id: WorktreeId,
73 entry_id: ProjectEntryId,
74}
75
76#[derive(Clone, Debug)]
77struct EditState {
78 worktree_id: WorktreeId,
79 entry_id: ProjectEntryId,
80 is_new_entry: bool,
81 is_dir: bool,
82 processing_filename: Option<String>,
83}
84
85#[derive(Copy, Clone)]
86pub enum ClipboardEntry {
87 Copied {
88 worktree_id: WorktreeId,
89 entry_id: ProjectEntryId,
90 },
91 Cut {
92 worktree_id: WorktreeId,
93 entry_id: ProjectEntryId,
94 },
95}
96
97#[derive(Debug, PartialEq, Eq)]
98pub struct EntryDetails {
99 filename: String,
100 icon: Option<Arc<str>>,
101 path: Arc<Path>,
102 depth: usize,
103 kind: EntryKind,
104 is_ignored: bool,
105 is_expanded: bool,
106 is_selected: bool,
107 is_editing: bool,
108 is_processing: bool,
109 is_cut: bool,
110 git_status: Option<GitFileStatus>,
111}
112
113actions!(
114 project_panel,
115 [
116 ExpandSelectedEntry,
117 CollapseSelectedEntry,
118 NewDirectory,
119 NewFile,
120 Copy,
121 CopyPath,
122 CopyRelativePath,
123 RevealInFinder,
124 Cut,
125 Paste,
126 Delete,
127 Rename,
128 ToggleFocus,
129 NewSearchInDirectory,
130 ]
131);
132
133pub fn init_settings(cx: &mut AppContext) {
134 settings::register::<ProjectPanelSettings>(cx);
135}
136
137pub fn init(assets: impl AssetSource, cx: &mut AppContext) {
138 init_settings(cx);
139 file_associations::init(assets, cx);
140 cx.add_action(ProjectPanel::expand_selected_entry);
141 cx.add_action(ProjectPanel::collapse_selected_entry);
142 cx.add_action(ProjectPanel::select_prev);
143 cx.add_action(ProjectPanel::select_next);
144 cx.add_action(ProjectPanel::new_file);
145 cx.add_action(ProjectPanel::new_directory);
146 cx.add_action(ProjectPanel::rename);
147 cx.add_async_action(ProjectPanel::delete);
148 cx.add_async_action(ProjectPanel::confirm);
149 cx.add_action(ProjectPanel::cancel);
150 cx.add_action(ProjectPanel::cut);
151 cx.add_action(ProjectPanel::copy);
152 cx.add_action(ProjectPanel::copy_path);
153 cx.add_action(ProjectPanel::copy_relative_path);
154 cx.add_action(ProjectPanel::reveal_in_finder);
155 cx.add_action(ProjectPanel::new_search_in_directory);
156 cx.add_action(
157 |this: &mut ProjectPanel, action: &Paste, cx: &mut ViewContext<ProjectPanel>| {
158 this.paste(action, cx);
159 },
160 );
161}
162
163#[derive(Debug)]
164pub enum Event {
165 OpenedEntry {
166 entry_id: ProjectEntryId,
167 focus_opened_item: bool,
168 },
169 SplitEntry {
170 entry_id: ProjectEntryId,
171 },
172 DockPositionChanged,
173 Focus,
174 NewSearchInDirectory {
175 dir_entry: Entry,
176 },
177 ActivatePanel,
178}
179
180#[derive(Serialize, Deserialize)]
181struct SerializedProjectPanel {
182 width: Option<f32>,
183}
184
185impl ProjectPanel {
186 fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> ViewHandle<Self> {
187 let project = workspace.project().clone();
188 let project_panel = cx.add_view(|cx: &mut ViewContext<Self>| {
189 cx.observe(&project, |this, _, cx| {
190 this.update_visible_entries(None, cx);
191 cx.notify();
192 })
193 .detach();
194 cx.subscribe(&project, |this, project, event, cx| match event {
195 project::Event::ActiveEntryChanged(Some(entry_id)) => {
196 if let Some(worktree_id) = project.read(cx).worktree_id_for_entry(*entry_id, cx)
197 {
198 this.expand_entry(worktree_id, *entry_id, cx);
199 this.update_visible_entries(Some((worktree_id, *entry_id)), cx);
200 this.autoscroll(cx);
201 cx.notify();
202 }
203 }
204 project::Event::ActivateProjectPanel => {
205 cx.emit(Event::ActivatePanel);
206 }
207 project::Event::WorktreeRemoved(id) => {
208 this.expanded_dir_ids.remove(id);
209 this.update_visible_entries(None, cx);
210 cx.notify();
211 }
212 _ => {}
213 })
214 .detach();
215
216 let filename_editor = cx.add_view(|cx| {
217 Editor::single_line(
218 Some(Arc::new(|theme| {
219 let mut style = theme.project_panel.filename_editor.clone();
220 style.container.background_color.take();
221 style
222 })),
223 cx,
224 )
225 });
226
227 cx.subscribe(&filename_editor, |this, _, event, cx| match event {
228 editor::Event::BufferEdited | editor::Event::SelectionsChanged { .. } => {
229 this.autoscroll(cx);
230 }
231 _ => {}
232 })
233 .detach();
234 cx.observe_focus(&filename_editor, |this, _, is_focused, cx| {
235 if !is_focused
236 && this
237 .edit_state
238 .as_ref()
239 .map_or(false, |state| state.processing_filename.is_none())
240 {
241 this.edit_state = None;
242 this.update_visible_entries(None, cx);
243 }
244 })
245 .detach();
246
247 cx.observe_global::<FileAssociations, _>(|_, cx| {
248 cx.notify();
249 })
250 .detach();
251
252 let view_id = cx.view_id();
253 let mut this = Self {
254 project: project.clone(),
255 fs: workspace.app_state().fs.clone(),
256 list: Default::default(),
257 visible_entries: Default::default(),
258 last_worktree_root_id: Default::default(),
259 expanded_dir_ids: Default::default(),
260 selection: None,
261 edit_state: None,
262 filename_editor,
263 clipboard_entry: None,
264 context_menu: cx.add_view(|cx| ContextMenu::new(view_id, cx)),
265 dragged_entry_destination: None,
266 workspace: workspace.weak_handle(),
267 has_focus: false,
268 width: None,
269 pending_serialization: Task::ready(None),
270 };
271 this.update_visible_entries(None, cx);
272
273 // Update the dock position when the setting changes.
274 let mut old_dock_position = this.position(cx);
275 cx.observe_global::<SettingsStore, _>(move |this, cx| {
276 let new_dock_position = this.position(cx);
277 if new_dock_position != old_dock_position {
278 old_dock_position = new_dock_position;
279 cx.emit(Event::DockPositionChanged);
280 }
281 })
282 .detach();
283
284 this
285 });
286
287 cx.subscribe(&project_panel, {
288 let project_panel = project_panel.downgrade();
289 move |workspace, _, event, cx| match event {
290 &Event::OpenedEntry {
291 entry_id,
292 focus_opened_item,
293 } => {
294 if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
295 if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
296 workspace
297 .open_path(
298 ProjectPath {
299 worktree_id: worktree.read(cx).id(),
300 path: entry.path.clone(),
301 },
302 None,
303 focus_opened_item,
304 cx,
305 )
306 .detach_and_log_err(cx);
307 if !focus_opened_item {
308 if let Some(project_panel) = project_panel.upgrade(cx) {
309 cx.focus(&project_panel);
310 }
311 }
312 }
313 }
314 }
315 &Event::SplitEntry { entry_id } => {
316 if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
317 if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
318 workspace
319 .split_path(
320 ProjectPath {
321 worktree_id: worktree.read(cx).id(),
322 path: entry.path.clone(),
323 },
324 cx,
325 )
326 .detach_and_log_err(cx);
327 }
328 }
329 }
330 _ => {}
331 }
332 })
333 .detach();
334
335 project_panel
336 }
337
338 pub fn load(
339 workspace: WeakViewHandle<Workspace>,
340 cx: AsyncAppContext,
341 ) -> Task<Result<ViewHandle<Self>>> {
342 cx.spawn(|mut cx| async move {
343 let serialized_panel = if let Some(panel) = cx
344 .background()
345 .spawn(async move { KEY_VALUE_STORE.read_kvp(PROJECT_PANEL_KEY) })
346 .await
347 .log_err()
348 .flatten()
349 {
350 Some(serde_json::from_str::<SerializedProjectPanel>(&panel)?)
351 } else {
352 None
353 };
354 workspace.update(&mut cx, |workspace, cx| {
355 let panel = ProjectPanel::new(workspace, cx);
356 if let Some(serialized_panel) = serialized_panel {
357 panel.update(cx, |panel, cx| {
358 panel.width = serialized_panel.width;
359 cx.notify();
360 });
361 }
362 panel
363 })
364 })
365 }
366
367 fn serialize(&mut self, cx: &mut ViewContext<Self>) {
368 let width = self.width;
369 self.pending_serialization = cx.background().spawn(
370 async move {
371 KEY_VALUE_STORE
372 .write_kvp(
373 PROJECT_PANEL_KEY.into(),
374 serde_json::to_string(&SerializedProjectPanel { width })?,
375 )
376 .await?;
377 anyhow::Ok(())
378 }
379 .log_err(),
380 );
381 }
382
383 fn deploy_context_menu(
384 &mut self,
385 position: Vector2F,
386 entry_id: ProjectEntryId,
387 cx: &mut ViewContext<Self>,
388 ) {
389 let project = self.project.read(cx);
390
391 let worktree_id = if let Some(id) = project.worktree_id_for_entry(entry_id, cx) {
392 id
393 } else {
394 return;
395 };
396
397 self.selection = Some(Selection {
398 worktree_id,
399 entry_id,
400 });
401
402 let mut menu_entries = Vec::new();
403 if let Some((worktree, entry)) = self.selected_entry(cx) {
404 let is_root = Some(entry) == worktree.root_entry();
405 if !project.is_remote() {
406 menu_entries.push(ContextMenuItem::action(
407 "Add Folder to Project",
408 workspace::AddFolderToProject,
409 ));
410 if is_root {
411 let project = self.project.clone();
412 menu_entries.push(ContextMenuItem::handler("Remove from Project", move |cx| {
413 project.update(cx, |project, cx| project.remove_worktree(worktree_id, cx));
414 }));
415 }
416 }
417 menu_entries.push(ContextMenuItem::action("New File", NewFile));
418 menu_entries.push(ContextMenuItem::action("New Folder", NewDirectory));
419 menu_entries.push(ContextMenuItem::Separator);
420 menu_entries.push(ContextMenuItem::action("Cut", Cut));
421 menu_entries.push(ContextMenuItem::action("Copy", Copy));
422 menu_entries.push(ContextMenuItem::Separator);
423 menu_entries.push(ContextMenuItem::action("Copy Path", CopyPath));
424 menu_entries.push(ContextMenuItem::action(
425 "Copy Relative Path",
426 CopyRelativePath,
427 ));
428 menu_entries.push(ContextMenuItem::action("Reveal in Finder", RevealInFinder));
429 if entry.is_dir() {
430 menu_entries.push(ContextMenuItem::action(
431 "Search inside",
432 NewSearchInDirectory,
433 ));
434 }
435 if let Some(clipboard_entry) = self.clipboard_entry {
436 if clipboard_entry.worktree_id() == worktree.id() {
437 menu_entries.push(ContextMenuItem::action("Paste", Paste));
438 }
439 }
440 menu_entries.push(ContextMenuItem::Separator);
441 menu_entries.push(ContextMenuItem::action("Rename", Rename));
442 if !is_root {
443 menu_entries.push(ContextMenuItem::action("Delete", Delete));
444 }
445 }
446
447 self.context_menu.update(cx, |menu, cx| {
448 menu.show(position, AnchorCorner::TopLeft, menu_entries, cx);
449 });
450
451 cx.notify();
452 }
453
454 fn expand_selected_entry(&mut self, _: &ExpandSelectedEntry, cx: &mut ViewContext<Self>) {
455 if let Some((worktree, entry)) = self.selected_entry(cx) {
456 if entry.is_dir() {
457 let worktree_id = worktree.id();
458 let entry_id = entry.id;
459 let expanded_dir_ids =
460 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
461 expanded_dir_ids
462 } else {
463 return;
464 };
465
466 match expanded_dir_ids.binary_search(&entry_id) {
467 Ok(_) => self.select_next(&SelectNext, cx),
468 Err(ix) => {
469 self.project.update(cx, |project, cx| {
470 project.expand_entry(worktree_id, entry_id, cx);
471 });
472
473 expanded_dir_ids.insert(ix, entry_id);
474 self.update_visible_entries(None, cx);
475 cx.notify();
476 }
477 }
478 }
479 }
480 }
481
482 fn collapse_selected_entry(&mut self, _: &CollapseSelectedEntry, cx: &mut ViewContext<Self>) {
483 if let Some((worktree, mut entry)) = self.selected_entry(cx) {
484 let worktree_id = worktree.id();
485 let expanded_dir_ids =
486 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
487 expanded_dir_ids
488 } else {
489 return;
490 };
491
492 loop {
493 let entry_id = entry.id;
494 match expanded_dir_ids.binary_search(&entry_id) {
495 Ok(ix) => {
496 expanded_dir_ids.remove(ix);
497 self.update_visible_entries(Some((worktree_id, entry_id)), cx);
498 cx.notify();
499 break;
500 }
501 Err(_) => {
502 if let Some(parent_entry) =
503 entry.path.parent().and_then(|p| worktree.entry_for_path(p))
504 {
505 entry = parent_entry;
506 } else {
507 break;
508 }
509 }
510 }
511 }
512 }
513 }
514
515 fn toggle_expanded(&mut self, entry_id: ProjectEntryId, cx: &mut ViewContext<Self>) {
516 if let Some(worktree_id) = self.project.read(cx).worktree_id_for_entry(entry_id, cx) {
517 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
518 self.project.update(cx, |project, cx| {
519 match expanded_dir_ids.binary_search(&entry_id) {
520 Ok(ix) => {
521 expanded_dir_ids.remove(ix);
522 }
523 Err(ix) => {
524 project.expand_entry(worktree_id, entry_id, cx);
525 expanded_dir_ids.insert(ix, entry_id);
526 }
527 }
528 });
529 self.update_visible_entries(Some((worktree_id, entry_id)), cx);
530 cx.focus_self();
531 cx.notify();
532 }
533 }
534 }
535
536 fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
537 if let Some(selection) = self.selection {
538 let (mut worktree_ix, mut entry_ix, _) =
539 self.index_for_selection(selection).unwrap_or_default();
540 if entry_ix > 0 {
541 entry_ix -= 1;
542 } else if worktree_ix > 0 {
543 worktree_ix -= 1;
544 entry_ix = self.visible_entries[worktree_ix].1.len() - 1;
545 } else {
546 return;
547 }
548
549 let (worktree_id, worktree_entries) = &self.visible_entries[worktree_ix];
550 self.selection = Some(Selection {
551 worktree_id: *worktree_id,
552 entry_id: worktree_entries[entry_ix].id,
553 });
554 self.autoscroll(cx);
555 cx.notify();
556 } else {
557 self.select_first(cx);
558 }
559 }
560
561 fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
562 if let Some(task) = self.confirm_edit(cx) {
563 Some(task)
564 } else if let Some((_, entry)) = self.selected_entry(cx) {
565 if entry.is_file() {
566 self.open_entry(entry.id, true, cx);
567 }
568 None
569 } else {
570 None
571 }
572 }
573
574 fn confirm_edit(&mut self, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
575 let edit_state = self.edit_state.as_mut()?;
576 cx.focus_self();
577
578 let worktree_id = edit_state.worktree_id;
579 let is_new_entry = edit_state.is_new_entry;
580 let is_dir = edit_state.is_dir;
581 let worktree = self.project.read(cx).worktree_for_id(worktree_id, cx)?;
582 let entry = worktree.read(cx).entry_for_id(edit_state.entry_id)?.clone();
583 let filename = self.filename_editor.read(cx).text(cx);
584
585 let path_already_exists = |path| worktree.read(cx).entry_for_path(path).is_some();
586 let edit_task;
587 let edited_entry_id;
588 if is_new_entry {
589 self.selection = Some(Selection {
590 worktree_id,
591 entry_id: NEW_ENTRY_ID,
592 });
593 let new_path = entry.path.join(&filename.trim_start_matches("/"));
594 if path_already_exists(new_path.as_path()) {
595 return None;
596 }
597
598 edited_entry_id = NEW_ENTRY_ID;
599 edit_task = self.project.update(cx, |project, cx| {
600 project.create_entry((worktree_id, &new_path), is_dir, cx)
601 })?;
602 } else {
603 let new_path = if let Some(parent) = entry.path.clone().parent() {
604 parent.join(&filename)
605 } else {
606 filename.clone().into()
607 };
608 if path_already_exists(new_path.as_path()) {
609 return None;
610 }
611
612 edited_entry_id = entry.id;
613 edit_task = self.project.update(cx, |project, cx| {
614 project.rename_entry(entry.id, new_path.as_path(), cx)
615 })?;
616 };
617
618 edit_state.processing_filename = Some(filename);
619 cx.notify();
620
621 Some(cx.spawn(|this, mut cx| async move {
622 let new_entry = edit_task.await;
623 this.update(&mut cx, |this, cx| {
624 this.edit_state.take();
625 cx.notify();
626 })?;
627
628 let new_entry = new_entry?;
629 this.update(&mut cx, |this, cx| {
630 if let Some(selection) = &mut this.selection {
631 if selection.entry_id == edited_entry_id {
632 selection.worktree_id = worktree_id;
633 selection.entry_id = new_entry.id;
634 this.expand_to_selection(cx);
635 }
636 }
637 this.update_visible_entries(None, cx);
638 if is_new_entry && !is_dir {
639 this.open_entry(new_entry.id, true, cx);
640 }
641 cx.notify();
642 })?;
643 Ok(())
644 }))
645 }
646
647 fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
648 self.edit_state = None;
649 self.update_visible_entries(None, cx);
650 cx.focus_self();
651 cx.notify();
652 }
653
654 fn open_entry(
655 &mut self,
656 entry_id: ProjectEntryId,
657 focus_opened_item: bool,
658 cx: &mut ViewContext<Self>,
659 ) {
660 cx.emit(Event::OpenedEntry {
661 entry_id,
662 focus_opened_item,
663 });
664 }
665
666 fn split_entry(&mut self, entry_id: ProjectEntryId, cx: &mut ViewContext<Self>) {
667 cx.emit(Event::SplitEntry { entry_id });
668 }
669
670 fn new_file(&mut self, _: &NewFile, cx: &mut ViewContext<Self>) {
671 self.add_entry(false, cx)
672 }
673
674 fn new_directory(&mut self, _: &NewDirectory, cx: &mut ViewContext<Self>) {
675 self.add_entry(true, cx)
676 }
677
678 fn add_entry(&mut self, is_dir: bool, cx: &mut ViewContext<Self>) {
679 if let Some(Selection {
680 worktree_id,
681 entry_id,
682 }) = self.selection
683 {
684 let directory_id;
685 if let Some((worktree, expanded_dir_ids)) = self
686 .project
687 .read(cx)
688 .worktree_for_id(worktree_id, cx)
689 .zip(self.expanded_dir_ids.get_mut(&worktree_id))
690 {
691 let worktree = worktree.read(cx);
692 if let Some(mut entry) = worktree.entry_for_id(entry_id) {
693 loop {
694 if entry.is_dir() {
695 if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
696 expanded_dir_ids.insert(ix, entry.id);
697 }
698 directory_id = entry.id;
699 break;
700 } else {
701 if let Some(parent_path) = entry.path.parent() {
702 if let Some(parent_entry) = worktree.entry_for_path(parent_path) {
703 entry = parent_entry;
704 continue;
705 }
706 }
707 return;
708 }
709 }
710 } else {
711 return;
712 };
713 } else {
714 return;
715 };
716
717 self.edit_state = Some(EditState {
718 worktree_id,
719 entry_id: directory_id,
720 is_new_entry: true,
721 is_dir,
722 processing_filename: None,
723 });
724 self.filename_editor
725 .update(cx, |editor, cx| editor.clear(cx));
726 cx.focus(&self.filename_editor);
727 self.update_visible_entries(Some((worktree_id, NEW_ENTRY_ID)), cx);
728 self.autoscroll(cx);
729 cx.notify();
730 }
731 }
732
733 fn rename(&mut self, _: &Rename, cx: &mut ViewContext<Self>) {
734 if let Some(Selection {
735 worktree_id,
736 entry_id,
737 }) = self.selection
738 {
739 if let Some(worktree) = self.project.read(cx).worktree_for_id(worktree_id, cx) {
740 if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
741 self.edit_state = Some(EditState {
742 worktree_id,
743 entry_id,
744 is_new_entry: false,
745 is_dir: entry.is_dir(),
746 processing_filename: None,
747 });
748 let file_name = entry
749 .path
750 .file_name()
751 .map(|s| s.to_string_lossy())
752 .unwrap_or_default()
753 .to_string();
754 let file_stem = entry.path.file_stem().map(|s| s.to_string_lossy());
755 let selection_end =
756 file_stem.map_or(file_name.len(), |file_stem| file_stem.len());
757 self.filename_editor.update(cx, |editor, cx| {
758 editor.set_text(file_name, cx);
759 editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
760 s.select_ranges([0..selection_end])
761 })
762 });
763 cx.focus(&self.filename_editor);
764 self.update_visible_entries(None, cx);
765 self.autoscroll(cx);
766 cx.notify();
767 }
768 }
769
770 cx.update_global(|drag_and_drop: &mut DragAndDrop<Workspace>, cx| {
771 drag_and_drop.cancel_dragging::<ProjectEntryId>(cx);
772 })
773 }
774 }
775
776 fn delete(&mut self, _: &Delete, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
777 let Selection { entry_id, .. } = self.selection?;
778 let path = self.project.read(cx).path_for_entry(entry_id, cx)?.path;
779 let file_name = path.file_name()?;
780
781 let mut answer = cx.prompt(
782 PromptLevel::Info,
783 &format!("Delete {file_name:?}?"),
784 &["Delete", "Cancel"],
785 );
786 Some(cx.spawn(|this, mut cx| async move {
787 if answer.next().await != Some(0) {
788 return Ok(());
789 }
790 this.update(&mut cx, |this, cx| {
791 this.project
792 .update(cx, |project, cx| project.delete_entry(entry_id, cx))
793 .ok_or_else(|| anyhow!("no such entry"))
794 })??
795 .await
796 }))
797 }
798
799 fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
800 if let Some(selection) = self.selection {
801 let (mut worktree_ix, mut entry_ix, _) =
802 self.index_for_selection(selection).unwrap_or_default();
803 if let Some((_, worktree_entries)) = self.visible_entries.get(worktree_ix) {
804 if entry_ix + 1 < worktree_entries.len() {
805 entry_ix += 1;
806 } else {
807 worktree_ix += 1;
808 entry_ix = 0;
809 }
810 }
811
812 if let Some((worktree_id, worktree_entries)) = self.visible_entries.get(worktree_ix) {
813 if let Some(entry) = worktree_entries.get(entry_ix) {
814 self.selection = Some(Selection {
815 worktree_id: *worktree_id,
816 entry_id: entry.id,
817 });
818 self.autoscroll(cx);
819 cx.notify();
820 }
821 }
822 } else {
823 self.select_first(cx);
824 }
825 }
826
827 fn select_first(&mut self, cx: &mut ViewContext<Self>) {
828 let worktree = self
829 .visible_entries
830 .first()
831 .and_then(|(worktree_id, _)| self.project.read(cx).worktree_for_id(*worktree_id, cx));
832 if let Some(worktree) = worktree {
833 let worktree = worktree.read(cx);
834 let worktree_id = worktree.id();
835 if let Some(root_entry) = worktree.root_entry() {
836 self.selection = Some(Selection {
837 worktree_id,
838 entry_id: root_entry.id,
839 });
840 self.autoscroll(cx);
841 cx.notify();
842 }
843 }
844 }
845
846 fn autoscroll(&mut self, cx: &mut ViewContext<Self>) {
847 if let Some((_, _, index)) = self.selection.and_then(|s| self.index_for_selection(s)) {
848 self.list.scroll_to(ScrollTarget::Show(index));
849 cx.notify();
850 }
851 }
852
853 fn cut(&mut self, _: &Cut, cx: &mut ViewContext<Self>) {
854 if let Some((worktree, entry)) = self.selected_entry(cx) {
855 self.clipboard_entry = Some(ClipboardEntry::Cut {
856 worktree_id: worktree.id(),
857 entry_id: entry.id,
858 });
859 cx.notify();
860 }
861 }
862
863 fn copy(&mut self, _: &Copy, cx: &mut ViewContext<Self>) {
864 if let Some((worktree, entry)) = self.selected_entry(cx) {
865 self.clipboard_entry = Some(ClipboardEntry::Copied {
866 worktree_id: worktree.id(),
867 entry_id: entry.id,
868 });
869 cx.notify();
870 }
871 }
872
873 fn paste(&mut self, _: &Paste, cx: &mut ViewContext<Self>) -> Option<()> {
874 if let Some((worktree, entry)) = self.selected_entry(cx) {
875 let clipboard_entry = self.clipboard_entry?;
876 if clipboard_entry.worktree_id() != worktree.id() {
877 return None;
878 }
879
880 let clipboard_entry_file_name = self
881 .project
882 .read(cx)
883 .path_for_entry(clipboard_entry.entry_id(), cx)?
884 .path
885 .file_name()?
886 .to_os_string();
887
888 let mut new_path = entry.path.to_path_buf();
889 if entry.is_file() {
890 new_path.pop();
891 }
892
893 new_path.push(&clipboard_entry_file_name);
894 let extension = new_path.extension().map(|e| e.to_os_string());
895 let file_name_without_extension = Path::new(&clipboard_entry_file_name).file_stem()?;
896 let mut ix = 0;
897 while worktree.entry_for_path(&new_path).is_some() {
898 new_path.pop();
899
900 let mut new_file_name = file_name_without_extension.to_os_string();
901 new_file_name.push(" copy");
902 if ix > 0 {
903 new_file_name.push(format!(" {}", ix));
904 }
905 if let Some(extension) = extension.as_ref() {
906 new_file_name.push(".");
907 new_file_name.push(extension);
908 }
909
910 new_path.push(new_file_name);
911 ix += 1;
912 }
913
914 if clipboard_entry.is_cut() {
915 if let Some(task) = self.project.update(cx, |project, cx| {
916 project.rename_entry(clipboard_entry.entry_id(), new_path, cx)
917 }) {
918 task.detach_and_log_err(cx)
919 }
920 } else if let Some(task) = self.project.update(cx, |project, cx| {
921 project.copy_entry(clipboard_entry.entry_id(), new_path, cx)
922 }) {
923 task.detach_and_log_err(cx)
924 }
925 }
926 None
927 }
928
929 fn copy_path(&mut self, _: &CopyPath, cx: &mut ViewContext<Self>) {
930 if let Some((worktree, entry)) = self.selected_entry(cx) {
931 cx.write_to_clipboard(ClipboardItem::new(
932 worktree
933 .abs_path()
934 .join(&entry.path)
935 .to_string_lossy()
936 .to_string(),
937 ));
938 }
939 }
940
941 fn copy_relative_path(&mut self, _: &CopyRelativePath, cx: &mut ViewContext<Self>) {
942 if let Some((_, entry)) = self.selected_entry(cx) {
943 cx.write_to_clipboard(ClipboardItem::new(entry.path.to_string_lossy().to_string()));
944 }
945 }
946
947 fn reveal_in_finder(&mut self, _: &RevealInFinder, cx: &mut ViewContext<Self>) {
948 if let Some((worktree, entry)) = self.selected_entry(cx) {
949 cx.reveal_path(&worktree.abs_path().join(&entry.path));
950 }
951 }
952
953 pub fn new_search_in_directory(
954 &mut self,
955 _: &NewSearchInDirectory,
956 cx: &mut ViewContext<Self>,
957 ) {
958 if let Some((_, entry)) = self.selected_entry(cx) {
959 if entry.is_dir() {
960 cx.emit(Event::NewSearchInDirectory {
961 dir_entry: entry.clone(),
962 });
963 }
964 }
965 }
966
967 fn move_entry(
968 &mut self,
969 entry_to_move: ProjectEntryId,
970 destination: ProjectEntryId,
971 destination_is_file: bool,
972 cx: &mut ViewContext<Self>,
973 ) {
974 let destination_worktree = self.project.update(cx, |project, cx| {
975 let entry_path = project.path_for_entry(entry_to_move, cx)?;
976 let destination_entry_path = project.path_for_entry(destination, cx)?.path.clone();
977
978 let mut destination_path = destination_entry_path.as_ref();
979 if destination_is_file {
980 destination_path = destination_path.parent()?;
981 }
982
983 let mut new_path = destination_path.to_path_buf();
984 new_path.push(entry_path.path.file_name()?);
985 if new_path != entry_path.path.as_ref() {
986 let task = project.rename_entry(entry_to_move, new_path, cx)?;
987 cx.foreground().spawn(task).detach_and_log_err(cx);
988 }
989
990 Some(project.worktree_id_for_entry(destination, cx)?)
991 });
992
993 if let Some(destination_worktree) = destination_worktree {
994 self.expand_entry(destination_worktree, destination, cx);
995 }
996 }
997
998 fn index_for_selection(&self, selection: Selection) -> Option<(usize, usize, usize)> {
999 let mut entry_index = 0;
1000 let mut visible_entries_index = 0;
1001 for (worktree_index, (worktree_id, worktree_entries)) in
1002 self.visible_entries.iter().enumerate()
1003 {
1004 if *worktree_id == selection.worktree_id {
1005 for entry in worktree_entries {
1006 if entry.id == selection.entry_id {
1007 return Some((worktree_index, entry_index, visible_entries_index));
1008 } else {
1009 visible_entries_index += 1;
1010 entry_index += 1;
1011 }
1012 }
1013 break;
1014 } else {
1015 visible_entries_index += worktree_entries.len();
1016 }
1017 }
1018 None
1019 }
1020
1021 pub fn selected_entry<'a>(
1022 &self,
1023 cx: &'a AppContext,
1024 ) -> Option<(&'a Worktree, &'a project::Entry)> {
1025 let (worktree, entry) = self.selected_entry_handle(cx)?;
1026 Some((worktree.read(cx), entry))
1027 }
1028
1029 fn selected_entry_handle<'a>(
1030 &self,
1031 cx: &'a AppContext,
1032 ) -> Option<(ModelHandle<Worktree>, &'a project::Entry)> {
1033 let selection = self.selection?;
1034 let project = self.project.read(cx);
1035 let worktree = project.worktree_for_id(selection.worktree_id, cx)?;
1036 let entry = worktree.read(cx).entry_for_id(selection.entry_id)?;
1037 Some((worktree, entry))
1038 }
1039
1040 fn expand_to_selection(&mut self, cx: &mut ViewContext<Self>) -> Option<()> {
1041 let (worktree, entry) = self.selected_entry(cx)?;
1042 let expanded_dir_ids = self.expanded_dir_ids.entry(worktree.id()).or_default();
1043
1044 for path in entry.path.ancestors() {
1045 let Some(entry) = worktree.entry_for_path(path) else {
1046 continue;
1047 };
1048 if entry.is_dir() {
1049 if let Err(idx) = expanded_dir_ids.binary_search(&entry.id) {
1050 expanded_dir_ids.insert(idx, entry.id);
1051 }
1052 }
1053 }
1054
1055 Some(())
1056 }
1057
1058 fn update_visible_entries(
1059 &mut self,
1060 new_selected_entry: Option<(WorktreeId, ProjectEntryId)>,
1061 cx: &mut ViewContext<Self>,
1062 ) {
1063 let project = self.project.read(cx);
1064 self.last_worktree_root_id = project
1065 .visible_worktrees(cx)
1066 .rev()
1067 .next()
1068 .and_then(|worktree| worktree.read(cx).root_entry())
1069 .map(|entry| entry.id);
1070
1071 self.visible_entries.clear();
1072 for worktree in project.visible_worktrees(cx) {
1073 let snapshot = worktree.read(cx).snapshot();
1074 let worktree_id = snapshot.id();
1075
1076 let expanded_dir_ids = match self.expanded_dir_ids.entry(worktree_id) {
1077 hash_map::Entry::Occupied(e) => e.into_mut(),
1078 hash_map::Entry::Vacant(e) => {
1079 // The first time a worktree's root entry becomes available,
1080 // mark that root entry as expanded.
1081 if let Some(entry) = snapshot.root_entry() {
1082 e.insert(vec![entry.id]).as_slice()
1083 } else {
1084 &[]
1085 }
1086 }
1087 };
1088
1089 let mut new_entry_parent_id = None;
1090 let mut new_entry_kind = EntryKind::Dir;
1091 if let Some(edit_state) = &self.edit_state {
1092 if edit_state.worktree_id == worktree_id && edit_state.is_new_entry {
1093 new_entry_parent_id = Some(edit_state.entry_id);
1094 new_entry_kind = if edit_state.is_dir {
1095 EntryKind::Dir
1096 } else {
1097 EntryKind::File(Default::default())
1098 };
1099 }
1100 }
1101
1102 let mut visible_worktree_entries = Vec::new();
1103 let mut entry_iter = snapshot.entries(true);
1104
1105 while let Some(entry) = entry_iter.entry() {
1106 visible_worktree_entries.push(entry.clone());
1107 if Some(entry.id) == new_entry_parent_id {
1108 visible_worktree_entries.push(Entry {
1109 id: NEW_ENTRY_ID,
1110 kind: new_entry_kind,
1111 path: entry.path.join("\0").into(),
1112 inode: 0,
1113 mtime: entry.mtime,
1114 is_symlink: false,
1115 is_ignored: false,
1116 is_external: false,
1117 git_status: entry.git_status,
1118 });
1119 }
1120 if expanded_dir_ids.binary_search(&entry.id).is_err()
1121 && entry_iter.advance_to_sibling()
1122 {
1123 continue;
1124 }
1125 entry_iter.advance();
1126 }
1127
1128 snapshot.propagate_git_statuses(&mut visible_worktree_entries);
1129
1130 visible_worktree_entries.sort_by(|entry_a, entry_b| {
1131 let mut components_a = entry_a.path.components().peekable();
1132 let mut components_b = entry_b.path.components().peekable();
1133 loop {
1134 match (components_a.next(), components_b.next()) {
1135 (Some(component_a), Some(component_b)) => {
1136 let a_is_file = components_a.peek().is_none() && entry_a.is_file();
1137 let b_is_file = components_b.peek().is_none() && entry_b.is_file();
1138 let ordering = a_is_file.cmp(&b_is_file).then_with(|| {
1139 let name_a =
1140 UniCase::new(component_a.as_os_str().to_string_lossy());
1141 let name_b =
1142 UniCase::new(component_b.as_os_str().to_string_lossy());
1143 name_a.cmp(&name_b)
1144 });
1145 if !ordering.is_eq() {
1146 return ordering;
1147 }
1148 }
1149 (Some(_), None) => break Ordering::Greater,
1150 (None, Some(_)) => break Ordering::Less,
1151 (None, None) => break Ordering::Equal,
1152 }
1153 }
1154 });
1155 self.visible_entries
1156 .push((worktree_id, visible_worktree_entries));
1157 }
1158
1159 if let Some((worktree_id, entry_id)) = new_selected_entry {
1160 self.selection = Some(Selection {
1161 worktree_id,
1162 entry_id,
1163 });
1164 }
1165 }
1166
1167 fn expand_entry(
1168 &mut self,
1169 worktree_id: WorktreeId,
1170 entry_id: ProjectEntryId,
1171 cx: &mut ViewContext<Self>,
1172 ) {
1173 self.project.update(cx, |project, cx| {
1174 if let Some((worktree, expanded_dir_ids)) = project
1175 .worktree_for_id(worktree_id, cx)
1176 .zip(self.expanded_dir_ids.get_mut(&worktree_id))
1177 {
1178 project.expand_entry(worktree_id, entry_id, cx);
1179 let worktree = worktree.read(cx);
1180
1181 if let Some(mut entry) = worktree.entry_for_id(entry_id) {
1182 loop {
1183 if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
1184 expanded_dir_ids.insert(ix, entry.id);
1185 }
1186
1187 if let Some(parent_entry) =
1188 entry.path.parent().and_then(|p| worktree.entry_for_path(p))
1189 {
1190 entry = parent_entry;
1191 } else {
1192 break;
1193 }
1194 }
1195 }
1196 }
1197 });
1198 }
1199
1200 fn for_each_visible_entry(
1201 &self,
1202 range: Range<usize>,
1203 cx: &mut ViewContext<ProjectPanel>,
1204 mut callback: impl FnMut(ProjectEntryId, EntryDetails, &mut ViewContext<ProjectPanel>),
1205 ) {
1206 let mut ix = 0;
1207 for (worktree_id, visible_worktree_entries) in &self.visible_entries {
1208 if ix >= range.end {
1209 return;
1210 }
1211
1212 if ix + visible_worktree_entries.len() <= range.start {
1213 ix += visible_worktree_entries.len();
1214 continue;
1215 }
1216
1217 let end_ix = range.end.min(ix + visible_worktree_entries.len());
1218 let (git_status_setting, show_file_icons, show_folder_icons) = {
1219 let settings = settings::get::<ProjectPanelSettings>(cx);
1220 (
1221 settings.git_status,
1222 settings.file_icons,
1223 settings.folder_icons,
1224 )
1225 };
1226 if let Some(worktree) = self.project.read(cx).worktree_for_id(*worktree_id, cx) {
1227 let snapshot = worktree.read(cx).snapshot();
1228 let root_name = OsStr::new(snapshot.root_name());
1229 let expanded_entry_ids = self
1230 .expanded_dir_ids
1231 .get(&snapshot.id())
1232 .map(Vec::as_slice)
1233 .unwrap_or(&[]);
1234
1235 let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
1236 for entry in visible_worktree_entries[entry_range].iter() {
1237 let status = git_status_setting.then(|| entry.git_status).flatten();
1238 let is_expanded = expanded_entry_ids.binary_search(&entry.id).is_ok();
1239 let icon = match entry.kind {
1240 EntryKind::File(_) => {
1241 if show_file_icons {
1242 Some(FileAssociations::get_icon(&entry.path, cx))
1243 } else {
1244 None
1245 }
1246 }
1247 _ => {
1248 if show_folder_icons {
1249 Some(FileAssociations::get_folder_icon(is_expanded, cx))
1250 } else {
1251 Some(FileAssociations::get_chevron_icon(is_expanded, cx))
1252 }
1253 }
1254 };
1255
1256 let mut details = EntryDetails {
1257 filename: entry
1258 .path
1259 .file_name()
1260 .unwrap_or(root_name)
1261 .to_string_lossy()
1262 .to_string(),
1263 icon,
1264 path: entry.path.clone(),
1265 depth: entry.path.components().count(),
1266 kind: entry.kind,
1267 is_ignored: entry.is_ignored,
1268 is_expanded,
1269 is_selected: self.selection.map_or(false, |e| {
1270 e.worktree_id == snapshot.id() && e.entry_id == entry.id
1271 }),
1272 is_editing: false,
1273 is_processing: false,
1274 is_cut: self
1275 .clipboard_entry
1276 .map_or(false, |e| e.is_cut() && e.entry_id() == entry.id),
1277 git_status: status,
1278 };
1279
1280 if let Some(edit_state) = &self.edit_state {
1281 let is_edited_entry = if edit_state.is_new_entry {
1282 entry.id == NEW_ENTRY_ID
1283 } else {
1284 entry.id == edit_state.entry_id
1285 };
1286
1287 if is_edited_entry {
1288 if let Some(processing_filename) = &edit_state.processing_filename {
1289 details.is_processing = true;
1290 details.filename.clear();
1291 details.filename.push_str(processing_filename);
1292 } else {
1293 if edit_state.is_new_entry {
1294 details.filename.clear();
1295 }
1296 details.is_editing = true;
1297 }
1298 }
1299 }
1300
1301 callback(entry.id, details, cx);
1302 }
1303 }
1304 ix = end_ix;
1305 }
1306 }
1307
1308 fn render_entry_visual_element<V: View>(
1309 details: &EntryDetails,
1310 editor: Option<&ViewHandle<Editor>>,
1311 padding: f32,
1312 row_container_style: ContainerStyle,
1313 style: &ProjectPanelEntry,
1314 cx: &mut ViewContext<V>,
1315 ) -> AnyElement<V> {
1316 let show_editor = details.is_editing && !details.is_processing;
1317
1318 let mut filename_text_style = style.text.clone();
1319 filename_text_style.color = details
1320 .git_status
1321 .as_ref()
1322 .map(|status| match status {
1323 GitFileStatus::Added => style.status.git.inserted,
1324 GitFileStatus::Modified => style.status.git.modified,
1325 GitFileStatus::Conflict => style.status.git.conflict,
1326 })
1327 .unwrap_or(style.text.color);
1328
1329 Flex::row()
1330 .with_child(if let Some(icon) = &details.icon {
1331 Svg::new(icon.to_string())
1332 .with_color(style.icon_color)
1333 .constrained()
1334 .with_max_width(style.icon_size)
1335 .with_max_height(style.icon_size)
1336 .aligned()
1337 .constrained()
1338 .with_width(style.icon_size)
1339 } else {
1340 Empty::new()
1341 .constrained()
1342 .with_max_width(style.icon_size)
1343 .with_max_height(style.icon_size)
1344 .aligned()
1345 .constrained()
1346 .with_width(style.icon_size)
1347 })
1348 .with_child(if show_editor && editor.is_some() {
1349 ChildView::new(editor.as_ref().unwrap(), cx)
1350 .contained()
1351 .with_margin_left(style.icon_spacing)
1352 .aligned()
1353 .left()
1354 .flex(1.0, true)
1355 .into_any()
1356 } else {
1357 Label::new(details.filename.clone(), filename_text_style)
1358 .contained()
1359 .with_margin_left(style.icon_spacing)
1360 .aligned()
1361 .left()
1362 .into_any()
1363 })
1364 .constrained()
1365 .with_height(style.height)
1366 .contained()
1367 .with_style(row_container_style)
1368 .with_padding_left(padding)
1369 .into_any_named("project panel entry visual element")
1370 }
1371
1372 fn render_entry(
1373 entry_id: ProjectEntryId,
1374 details: EntryDetails,
1375 editor: &ViewHandle<Editor>,
1376 dragged_entry_destination: &mut Option<Arc<Path>>,
1377 theme: &theme::ProjectPanel,
1378 cx: &mut ViewContext<Self>,
1379 ) -> AnyElement<Self> {
1380 let kind = details.kind;
1381 let path = details.path.clone();
1382 let settings = settings::get::<ProjectPanelSettings>(cx);
1383 let padding = theme.container.padding.left + details.depth as f32 * settings.indent_size;
1384
1385 let entry_style = if details.is_cut {
1386 &theme.cut_entry
1387 } else if details.is_ignored {
1388 &theme.ignored_entry
1389 } else {
1390 &theme.entry
1391 };
1392
1393 let show_editor = details.is_editing && !details.is_processing;
1394
1395 MouseEventHandler::<Self, _>::new(entry_id.to_usize(), cx, |state, cx| {
1396 let mut style = entry_style
1397 .in_state(details.is_selected)
1398 .style_for(state)
1399 .clone();
1400
1401 if cx
1402 .global::<DragAndDrop<Workspace>>()
1403 .currently_dragged::<ProjectEntryId>(cx.window_id())
1404 .is_some()
1405 && dragged_entry_destination
1406 .as_ref()
1407 .filter(|destination| details.path.starts_with(destination))
1408 .is_some()
1409 {
1410 style = entry_style.active_state().default.clone();
1411 }
1412
1413 let row_container_style = if show_editor {
1414 theme.filename_editor.container
1415 } else {
1416 style.container
1417 };
1418
1419 Self::render_entry_visual_element(
1420 &details,
1421 Some(editor),
1422 padding,
1423 row_container_style,
1424 &style,
1425 cx,
1426 )
1427 })
1428 .on_click(MouseButton::Left, move |event, this, cx| {
1429 if !show_editor {
1430 if kind.is_dir() {
1431 this.toggle_expanded(entry_id, cx);
1432 } else {
1433 if event.cmd {
1434 this.split_entry(entry_id, cx);
1435 } else if !event.cmd {
1436 this.open_entry(entry_id, event.click_count > 1, cx);
1437 }
1438 }
1439 }
1440 })
1441 .on_down(MouseButton::Right, move |event, this, cx| {
1442 this.deploy_context_menu(event.position, entry_id, cx);
1443 })
1444 .on_up(MouseButton::Left, move |_, this, cx| {
1445 if let Some((_, dragged_entry)) = cx
1446 .global::<DragAndDrop<Workspace>>()
1447 .currently_dragged::<ProjectEntryId>(cx.window_id())
1448 {
1449 this.move_entry(
1450 *dragged_entry,
1451 entry_id,
1452 matches!(details.kind, EntryKind::File(_)),
1453 cx,
1454 );
1455 }
1456 })
1457 .on_move(move |_, this, cx| {
1458 if cx
1459 .global::<DragAndDrop<Workspace>>()
1460 .currently_dragged::<ProjectEntryId>(cx.window_id())
1461 .is_some()
1462 {
1463 this.dragged_entry_destination = if matches!(kind, EntryKind::File(_)) {
1464 path.parent().map(|parent| Arc::from(parent))
1465 } else {
1466 Some(path.clone())
1467 };
1468 }
1469 })
1470 .as_draggable(entry_id, {
1471 let row_container_style = theme.dragged_entry.container;
1472
1473 move |_, cx: &mut ViewContext<Workspace>| {
1474 let theme = theme::current(cx).clone();
1475 Self::render_entry_visual_element(
1476 &details,
1477 None,
1478 padding,
1479 row_container_style,
1480 &theme.project_panel.dragged_entry,
1481 cx,
1482 )
1483 }
1484 })
1485 .with_cursor_style(CursorStyle::PointingHand)
1486 .into_any_named("project panel entry")
1487 }
1488}
1489
1490impl View for ProjectPanel {
1491 fn ui_name() -> &'static str {
1492 "ProjectPanel"
1493 }
1494
1495 fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> gpui::AnyElement<Self> {
1496 enum ProjectPanel {}
1497 let theme = &theme::current(cx).project_panel;
1498 let mut container_style = theme.container;
1499 let padding = std::mem::take(&mut container_style.padding);
1500 let last_worktree_root_id = self.last_worktree_root_id;
1501
1502 let has_worktree = self.visible_entries.len() != 0;
1503
1504 if has_worktree {
1505 Stack::new()
1506 .with_child(
1507 MouseEventHandler::<ProjectPanel, _>::new(0, cx, |_, cx| {
1508 UniformList::new(
1509 self.list.clone(),
1510 self.visible_entries
1511 .iter()
1512 .map(|(_, worktree_entries)| worktree_entries.len())
1513 .sum(),
1514 cx,
1515 move |this, range, items, cx| {
1516 let theme = theme::current(cx).clone();
1517 let mut dragged_entry_destination =
1518 this.dragged_entry_destination.clone();
1519 this.for_each_visible_entry(range, cx, |id, details, cx| {
1520 items.push(Self::render_entry(
1521 id,
1522 details,
1523 &this.filename_editor,
1524 &mut dragged_entry_destination,
1525 &theme.project_panel,
1526 cx,
1527 ));
1528 });
1529 this.dragged_entry_destination = dragged_entry_destination;
1530 },
1531 )
1532 .with_padding_top(padding.top)
1533 .with_padding_bottom(padding.bottom)
1534 .contained()
1535 .with_style(container_style)
1536 .expanded()
1537 })
1538 .on_down(MouseButton::Right, move |event, this, cx| {
1539 // When deploying the context menu anywhere below the last project entry,
1540 // act as if the user clicked the root of the last worktree.
1541 if let Some(entry_id) = last_worktree_root_id {
1542 this.deploy_context_menu(event.position, entry_id, cx);
1543 }
1544 }),
1545 )
1546 .with_child(ChildView::new(&self.context_menu, cx))
1547 .into_any_named("project panel")
1548 } else {
1549 Flex::column()
1550 .with_child(
1551 MouseEventHandler::<Self, _>::new(2, cx, {
1552 let button_style = theme.open_project_button.clone();
1553 let context_menu_item_style = theme::current(cx).context_menu.item.clone();
1554 move |state, cx| {
1555 let button_style = button_style.style_for(state).clone();
1556 let context_menu_item = context_menu_item_style
1557 .active_state()
1558 .style_for(state)
1559 .clone();
1560
1561 theme::ui::keystroke_label(
1562 "Open a project",
1563 &button_style,
1564 &context_menu_item.keystroke,
1565 Box::new(workspace::Open),
1566 cx,
1567 )
1568 }
1569 })
1570 .on_click(MouseButton::Left, move |_, this, cx| {
1571 if let Some(workspace) = this.workspace.upgrade(cx) {
1572 workspace.update(cx, |workspace, cx| {
1573 if let Some(task) = workspace.open(&Default::default(), cx) {
1574 task.detach_and_log_err(cx);
1575 }
1576 })
1577 }
1578 })
1579 .with_cursor_style(CursorStyle::PointingHand),
1580 )
1581 .contained()
1582 .with_style(container_style)
1583 .into_any_named("empty project panel")
1584 }
1585 }
1586
1587 fn update_keymap_context(&self, keymap: &mut KeymapContext, _: &AppContext) {
1588 Self::reset_to_default_keymap_context(keymap);
1589 keymap.add_identifier("menu");
1590 }
1591
1592 fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
1593 if !self.has_focus {
1594 self.has_focus = true;
1595 cx.emit(Event::Focus);
1596 }
1597 }
1598
1599 fn focus_out(&mut self, _: gpui::AnyViewHandle, _: &mut ViewContext<Self>) {
1600 self.has_focus = false;
1601 }
1602}
1603
1604impl Entity for ProjectPanel {
1605 type Event = Event;
1606}
1607
1608impl workspace::dock::Panel for ProjectPanel {
1609 fn position(&self, cx: &WindowContext) -> DockPosition {
1610 match settings::get::<ProjectPanelSettings>(cx).dock {
1611 ProjectPanelDockPosition::Left => DockPosition::Left,
1612 ProjectPanelDockPosition::Right => DockPosition::Right,
1613 }
1614 }
1615
1616 fn position_is_valid(&self, position: DockPosition) -> bool {
1617 matches!(position, DockPosition::Left | DockPosition::Right)
1618 }
1619
1620 fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
1621 settings::update_settings_file::<ProjectPanelSettings>(
1622 self.fs.clone(),
1623 cx,
1624 move |settings| {
1625 let dock = match position {
1626 DockPosition::Left | DockPosition::Bottom => ProjectPanelDockPosition::Left,
1627 DockPosition::Right => ProjectPanelDockPosition::Right,
1628 };
1629 settings.dock = Some(dock);
1630 },
1631 );
1632 }
1633
1634 fn size(&self, cx: &WindowContext) -> f32 {
1635 self.width
1636 .unwrap_or_else(|| settings::get::<ProjectPanelSettings>(cx).default_width)
1637 }
1638
1639 fn set_size(&mut self, size: f32, cx: &mut ViewContext<Self>) {
1640 self.width = Some(size);
1641 self.serialize(cx);
1642 cx.notify();
1643 }
1644
1645 fn should_zoom_in_on_event(_: &Self::Event) -> bool {
1646 false
1647 }
1648
1649 fn should_zoom_out_on_event(_: &Self::Event) -> bool {
1650 false
1651 }
1652
1653 fn is_zoomed(&self, _: &WindowContext) -> bool {
1654 false
1655 }
1656
1657 fn set_zoomed(&mut self, _: bool, _: &mut ViewContext<Self>) {}
1658
1659 fn set_active(&mut self, _: bool, _: &mut ViewContext<Self>) {}
1660
1661 fn icon_path(&self) -> &'static str {
1662 "icons/folder_tree_16.svg"
1663 }
1664
1665 fn icon_tooltip(&self) -> (String, Option<Box<dyn Action>>) {
1666 ("Project Panel".into(), Some(Box::new(ToggleFocus)))
1667 }
1668
1669 fn should_change_position_on_event(event: &Self::Event) -> bool {
1670 matches!(event, Event::DockPositionChanged)
1671 }
1672
1673 fn should_activate_on_event(_: &Self::Event) -> bool {
1674 false
1675 }
1676
1677 fn should_close_on_event(_: &Self::Event) -> bool {
1678 false
1679 }
1680
1681 fn has_focus(&self, _: &WindowContext) -> bool {
1682 self.has_focus
1683 }
1684
1685 fn is_focus_event(event: &Self::Event) -> bool {
1686 matches!(event, Event::Focus)
1687 }
1688}
1689
1690impl ClipboardEntry {
1691 fn is_cut(&self) -> bool {
1692 matches!(self, Self::Cut { .. })
1693 }
1694
1695 fn entry_id(&self) -> ProjectEntryId {
1696 match self {
1697 ClipboardEntry::Copied { entry_id, .. } | ClipboardEntry::Cut { entry_id, .. } => {
1698 *entry_id
1699 }
1700 }
1701 }
1702
1703 fn worktree_id(&self) -> WorktreeId {
1704 match self {
1705 ClipboardEntry::Copied { worktree_id, .. }
1706 | ClipboardEntry::Cut { worktree_id, .. } => *worktree_id,
1707 }
1708 }
1709}
1710
1711#[cfg(test)]
1712mod tests {
1713 use super::*;
1714 use gpui::{TestAppContext, ViewHandle};
1715 use pretty_assertions::assert_eq;
1716 use project::FakeFs;
1717 use serde_json::json;
1718 use settings::SettingsStore;
1719 use std::{
1720 collections::HashSet,
1721 path::Path,
1722 sync::atomic::{self, AtomicUsize},
1723 };
1724 use workspace::{pane, AppState};
1725
1726 #[gpui::test]
1727 async fn test_visible_list(cx: &mut gpui::TestAppContext) {
1728 init_test(cx);
1729
1730 let fs = FakeFs::new(cx.background());
1731 fs.insert_tree(
1732 "/root1",
1733 json!({
1734 ".dockerignore": "",
1735 ".git": {
1736 "HEAD": "",
1737 },
1738 "a": {
1739 "0": { "q": "", "r": "", "s": "" },
1740 "1": { "t": "", "u": "" },
1741 "2": { "v": "", "w": "", "x": "", "y": "" },
1742 },
1743 "b": {
1744 "3": { "Q": "" },
1745 "4": { "R": "", "S": "", "T": "", "U": "" },
1746 },
1747 "C": {
1748 "5": {},
1749 "6": { "V": "", "W": "" },
1750 "7": { "X": "" },
1751 "8": { "Y": {}, "Z": "" }
1752 }
1753 }),
1754 )
1755 .await;
1756 fs.insert_tree(
1757 "/root2",
1758 json!({
1759 "d": {
1760 "9": ""
1761 },
1762 "e": {}
1763 }),
1764 )
1765 .await;
1766
1767 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1768 let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1769 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
1770 assert_eq!(
1771 visible_entries_as_strings(&panel, 0..50, cx),
1772 &[
1773 "v root1",
1774 " > .git",
1775 " > a",
1776 " > b",
1777 " > C",
1778 " .dockerignore",
1779 "v root2",
1780 " > d",
1781 " > e",
1782 ]
1783 );
1784
1785 toggle_expand_dir(&panel, "root1/b", cx);
1786 assert_eq!(
1787 visible_entries_as_strings(&panel, 0..50, cx),
1788 &[
1789 "v root1",
1790 " > .git",
1791 " > a",
1792 " v b <== selected",
1793 " > 3",
1794 " > 4",
1795 " > C",
1796 " .dockerignore",
1797 "v root2",
1798 " > d",
1799 " > e",
1800 ]
1801 );
1802
1803 assert_eq!(
1804 visible_entries_as_strings(&panel, 6..9, cx),
1805 &[
1806 //
1807 " > C",
1808 " .dockerignore",
1809 "v root2",
1810 ]
1811 );
1812 }
1813
1814 #[gpui::test(iterations = 30)]
1815 async fn test_editing_files(cx: &mut gpui::TestAppContext) {
1816 init_test(cx);
1817
1818 let fs = FakeFs::new(cx.background());
1819 fs.insert_tree(
1820 "/root1",
1821 json!({
1822 ".dockerignore": "",
1823 ".git": {
1824 "HEAD": "",
1825 },
1826 "a": {
1827 "0": { "q": "", "r": "", "s": "" },
1828 "1": { "t": "", "u": "" },
1829 "2": { "v": "", "w": "", "x": "", "y": "" },
1830 },
1831 "b": {
1832 "3": { "Q": "" },
1833 "4": { "R": "", "S": "", "T": "", "U": "" },
1834 },
1835 "C": {
1836 "5": {},
1837 "6": { "V": "", "W": "" },
1838 "7": { "X": "" },
1839 "8": { "Y": {}, "Z": "" }
1840 }
1841 }),
1842 )
1843 .await;
1844 fs.insert_tree(
1845 "/root2",
1846 json!({
1847 "d": {
1848 "9": ""
1849 },
1850 "e": {}
1851 }),
1852 )
1853 .await;
1854
1855 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1856 let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1857 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
1858
1859 select_path(&panel, "root1", cx);
1860 assert_eq!(
1861 visible_entries_as_strings(&panel, 0..10, cx),
1862 &[
1863 "v root1 <== selected",
1864 " > .git",
1865 " > a",
1866 " > b",
1867 " > C",
1868 " .dockerignore",
1869 "v root2",
1870 " > d",
1871 " > e",
1872 ]
1873 );
1874
1875 // Add a file with the root folder selected. The filename editor is placed
1876 // before the first file in the root folder.
1877 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
1878 cx.read_window(window_id, |cx| {
1879 let panel = panel.read(cx);
1880 assert!(panel.filename_editor.is_focused(cx));
1881 });
1882 assert_eq!(
1883 visible_entries_as_strings(&panel, 0..10, cx),
1884 &[
1885 "v root1",
1886 " > .git",
1887 " > a",
1888 " > b",
1889 " > C",
1890 " [EDITOR: ''] <== selected",
1891 " .dockerignore",
1892 "v root2",
1893 " > d",
1894 " > e",
1895 ]
1896 );
1897
1898 let confirm = panel.update(cx, |panel, cx| {
1899 panel
1900 .filename_editor
1901 .update(cx, |editor, cx| editor.set_text("the-new-filename", cx));
1902 panel.confirm(&Confirm, cx).unwrap()
1903 });
1904 assert_eq!(
1905 visible_entries_as_strings(&panel, 0..10, cx),
1906 &[
1907 "v root1",
1908 " > .git",
1909 " > a",
1910 " > b",
1911 " > C",
1912 " [PROCESSING: 'the-new-filename'] <== selected",
1913 " .dockerignore",
1914 "v root2",
1915 " > d",
1916 " > e",
1917 ]
1918 );
1919
1920 confirm.await.unwrap();
1921 assert_eq!(
1922 visible_entries_as_strings(&panel, 0..10, cx),
1923 &[
1924 "v root1",
1925 " > .git",
1926 " > a",
1927 " > b",
1928 " > C",
1929 " .dockerignore",
1930 " the-new-filename <== selected",
1931 "v root2",
1932 " > d",
1933 " > e",
1934 ]
1935 );
1936
1937 select_path(&panel, "root1/b", cx);
1938 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
1939 assert_eq!(
1940 visible_entries_as_strings(&panel, 0..10, cx),
1941 &[
1942 "v root1",
1943 " > .git",
1944 " > a",
1945 " v b",
1946 " > 3",
1947 " > 4",
1948 " [EDITOR: ''] <== selected",
1949 " > C",
1950 " .dockerignore",
1951 " the-new-filename",
1952 ]
1953 );
1954
1955 panel
1956 .update(cx, |panel, cx| {
1957 panel
1958 .filename_editor
1959 .update(cx, |editor, cx| editor.set_text("another-filename.txt", cx));
1960 panel.confirm(&Confirm, cx).unwrap()
1961 })
1962 .await
1963 .unwrap();
1964 assert_eq!(
1965 visible_entries_as_strings(&panel, 0..10, cx),
1966 &[
1967 "v root1",
1968 " > .git",
1969 " > a",
1970 " v b",
1971 " > 3",
1972 " > 4",
1973 " another-filename.txt <== selected",
1974 " > C",
1975 " .dockerignore",
1976 " the-new-filename",
1977 ]
1978 );
1979
1980 select_path(&panel, "root1/b/another-filename.txt", cx);
1981 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
1982 assert_eq!(
1983 visible_entries_as_strings(&panel, 0..10, cx),
1984 &[
1985 "v root1",
1986 " > .git",
1987 " > a",
1988 " v b",
1989 " > 3",
1990 " > 4",
1991 " [EDITOR: 'another-filename.txt'] <== selected",
1992 " > C",
1993 " .dockerignore",
1994 " the-new-filename",
1995 ]
1996 );
1997
1998 let confirm = panel.update(cx, |panel, cx| {
1999 panel.filename_editor.update(cx, |editor, cx| {
2000 let file_name_selections = editor.selections.all::<usize>(cx);
2001 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
2002 let file_name_selection = &file_name_selections[0];
2003 assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
2004 assert_eq!(file_name_selection.end, "another-filename".len(), "Should not select file extension");
2005
2006 editor.set_text("a-different-filename.tar.gz", cx)
2007 });
2008 panel.confirm(&Confirm, cx).unwrap()
2009 });
2010 assert_eq!(
2011 visible_entries_as_strings(&panel, 0..10, cx),
2012 &[
2013 "v root1",
2014 " > .git",
2015 " > a",
2016 " v b",
2017 " > 3",
2018 " > 4",
2019 " [PROCESSING: 'a-different-filename.tar.gz'] <== selected",
2020 " > C",
2021 " .dockerignore",
2022 " the-new-filename",
2023 ]
2024 );
2025
2026 confirm.await.unwrap();
2027 assert_eq!(
2028 visible_entries_as_strings(&panel, 0..10, cx),
2029 &[
2030 "v root1",
2031 " > .git",
2032 " > a",
2033 " v b",
2034 " > 3",
2035 " > 4",
2036 " a-different-filename.tar.gz <== selected",
2037 " > C",
2038 " .dockerignore",
2039 " the-new-filename",
2040 ]
2041 );
2042
2043 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2044 assert_eq!(
2045 visible_entries_as_strings(&panel, 0..10, cx),
2046 &[
2047 "v root1",
2048 " > .git",
2049 " > a",
2050 " v b",
2051 " > 3",
2052 " > 4",
2053 " [EDITOR: 'a-different-filename.tar.gz'] <== selected",
2054 " > C",
2055 " .dockerignore",
2056 " the-new-filename",
2057 ]
2058 );
2059
2060 panel.update(cx, |panel, cx| {
2061 panel.filename_editor.update(cx, |editor, cx| {
2062 let file_name_selections = editor.selections.all::<usize>(cx);
2063 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
2064 let file_name_selection = &file_name_selections[0];
2065 assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
2066 assert_eq!(file_name_selection.end, "a-different-filename.tar".len(), "Should not select file extension, but still may select anything up to the last dot");
2067
2068 });
2069 panel.cancel(&Cancel, cx)
2070 });
2071
2072 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2073 assert_eq!(
2074 visible_entries_as_strings(&panel, 0..10, cx),
2075 &[
2076 "v root1",
2077 " > .git",
2078 " > a",
2079 " v b",
2080 " > [EDITOR: ''] <== selected",
2081 " > 3",
2082 " > 4",
2083 " a-different-filename.tar.gz",
2084 " > C",
2085 " .dockerignore",
2086 ]
2087 );
2088
2089 let confirm = panel.update(cx, |panel, cx| {
2090 panel
2091 .filename_editor
2092 .update(cx, |editor, cx| editor.set_text("new-dir", cx));
2093 panel.confirm(&Confirm, cx).unwrap()
2094 });
2095 panel.update(cx, |panel, cx| panel.select_next(&Default::default(), cx));
2096 assert_eq!(
2097 visible_entries_as_strings(&panel, 0..10, cx),
2098 &[
2099 "v root1",
2100 " > .git",
2101 " > a",
2102 " v b",
2103 " > [PROCESSING: 'new-dir']",
2104 " > 3 <== selected",
2105 " > 4",
2106 " a-different-filename.tar.gz",
2107 " > C",
2108 " .dockerignore",
2109 ]
2110 );
2111
2112 confirm.await.unwrap();
2113 assert_eq!(
2114 visible_entries_as_strings(&panel, 0..10, cx),
2115 &[
2116 "v root1",
2117 " > .git",
2118 " > a",
2119 " v b",
2120 " > 3 <== selected",
2121 " > 4",
2122 " > new-dir",
2123 " a-different-filename.tar.gz",
2124 " > C",
2125 " .dockerignore",
2126 ]
2127 );
2128
2129 panel.update(cx, |panel, cx| panel.rename(&Default::default(), cx));
2130 assert_eq!(
2131 visible_entries_as_strings(&panel, 0..10, cx),
2132 &[
2133 "v root1",
2134 " > .git",
2135 " > a",
2136 " v b",
2137 " > [EDITOR: '3'] <== selected",
2138 " > 4",
2139 " > new-dir",
2140 " a-different-filename.tar.gz",
2141 " > C",
2142 " .dockerignore",
2143 ]
2144 );
2145
2146 // Dismiss the rename editor when it loses focus.
2147 workspace.update(cx, |_, cx| cx.focus_self());
2148 assert_eq!(
2149 visible_entries_as_strings(&panel, 0..10, cx),
2150 &[
2151 "v root1",
2152 " > .git",
2153 " > a",
2154 " v b",
2155 " > 3 <== selected",
2156 " > 4",
2157 " > new-dir",
2158 " a-different-filename.tar.gz",
2159 " > C",
2160 " .dockerignore",
2161 ]
2162 );
2163 }
2164
2165 #[gpui::test(iterations = 30)]
2166 async fn test_adding_directories_via_file(cx: &mut gpui::TestAppContext) {
2167 init_test(cx);
2168
2169 let fs = FakeFs::new(cx.background());
2170 fs.insert_tree(
2171 "/root1",
2172 json!({
2173 ".dockerignore": "",
2174 ".git": {
2175 "HEAD": "",
2176 },
2177 "a": {
2178 "0": { "q": "", "r": "", "s": "" },
2179 "1": { "t": "", "u": "" },
2180 "2": { "v": "", "w": "", "x": "", "y": "" },
2181 },
2182 "b": {
2183 "3": { "Q": "" },
2184 "4": { "R": "", "S": "", "T": "", "U": "" },
2185 },
2186 "C": {
2187 "5": {},
2188 "6": { "V": "", "W": "" },
2189 "7": { "X": "" },
2190 "8": { "Y": {}, "Z": "" }
2191 }
2192 }),
2193 )
2194 .await;
2195 fs.insert_tree(
2196 "/root2",
2197 json!({
2198 "d": {
2199 "9": ""
2200 },
2201 "e": {}
2202 }),
2203 )
2204 .await;
2205
2206 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
2207 let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2208 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2209
2210 select_path(&panel, "root1", cx);
2211 assert_eq!(
2212 visible_entries_as_strings(&panel, 0..10, cx),
2213 &[
2214 "v root1 <== selected",
2215 " > .git",
2216 " > a",
2217 " > b",
2218 " > C",
2219 " .dockerignore",
2220 "v root2",
2221 " > d",
2222 " > e",
2223 ]
2224 );
2225
2226 // Add a file with the root folder selected. The filename editor is placed
2227 // before the first file in the root folder.
2228 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2229 cx.read_window(window_id, |cx| {
2230 let panel = panel.read(cx);
2231 assert!(panel.filename_editor.is_focused(cx));
2232 });
2233 assert_eq!(
2234 visible_entries_as_strings(&panel, 0..10, cx),
2235 &[
2236 "v root1",
2237 " > .git",
2238 " > a",
2239 " > b",
2240 " > C",
2241 " [EDITOR: ''] <== selected",
2242 " .dockerignore",
2243 "v root2",
2244 " > d",
2245 " > e",
2246 ]
2247 );
2248
2249 let confirm = panel.update(cx, |panel, cx| {
2250 panel.filename_editor.update(cx, |editor, cx| {
2251 editor.set_text("/bdir1/dir2/the-new-filename", cx)
2252 });
2253 panel.confirm(&Confirm, cx).unwrap()
2254 });
2255
2256 assert_eq!(
2257 visible_entries_as_strings(&panel, 0..10, cx),
2258 &[
2259 "v root1",
2260 " > .git",
2261 " > a",
2262 " > b",
2263 " > C",
2264 " [PROCESSING: '/bdir1/dir2/the-new-filename'] <== selected",
2265 " .dockerignore",
2266 "v root2",
2267 " > d",
2268 " > e",
2269 ]
2270 );
2271
2272 confirm.await.unwrap();
2273 assert_eq!(
2274 visible_entries_as_strings(&panel, 0..13, cx),
2275 &[
2276 "v root1",
2277 " > .git",
2278 " > a",
2279 " > b",
2280 " v bdir1",
2281 " v dir2",
2282 " the-new-filename <== selected",
2283 " > C",
2284 " .dockerignore",
2285 "v root2",
2286 " > d",
2287 " > e",
2288 ]
2289 );
2290 }
2291
2292 #[gpui::test]
2293 async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
2294 init_test(cx);
2295
2296 let fs = FakeFs::new(cx.background());
2297 fs.insert_tree(
2298 "/root1",
2299 json!({
2300 "one.two.txt": "",
2301 "one.txt": ""
2302 }),
2303 )
2304 .await;
2305
2306 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
2307 let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2308 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2309
2310 panel.update(cx, |panel, cx| {
2311 panel.select_next(&Default::default(), cx);
2312 panel.select_next(&Default::default(), cx);
2313 });
2314
2315 assert_eq!(
2316 visible_entries_as_strings(&panel, 0..50, cx),
2317 &[
2318 //
2319 "v root1",
2320 " one.two.txt <== selected",
2321 " one.txt",
2322 ]
2323 );
2324
2325 // Regression test - file name is created correctly when
2326 // the copied file's name contains multiple dots.
2327 panel.update(cx, |panel, cx| {
2328 panel.copy(&Default::default(), cx);
2329 panel.paste(&Default::default(), cx);
2330 });
2331 cx.foreground().run_until_parked();
2332
2333 assert_eq!(
2334 visible_entries_as_strings(&panel, 0..50, cx),
2335 &[
2336 //
2337 "v root1",
2338 " one.two copy.txt",
2339 " one.two.txt <== selected",
2340 " one.txt",
2341 ]
2342 );
2343
2344 panel.update(cx, |panel, cx| {
2345 panel.paste(&Default::default(), cx);
2346 });
2347 cx.foreground().run_until_parked();
2348
2349 assert_eq!(
2350 visible_entries_as_strings(&panel, 0..50, cx),
2351 &[
2352 //
2353 "v root1",
2354 " one.two copy 1.txt",
2355 " one.two copy.txt",
2356 " one.two.txt <== selected",
2357 " one.txt",
2358 ]
2359 );
2360 }
2361
2362 #[gpui::test]
2363 async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
2364 init_test_with_editor(cx);
2365
2366 let fs = FakeFs::new(cx.background());
2367 fs.insert_tree(
2368 "/src",
2369 json!({
2370 "test": {
2371 "first.rs": "// First Rust file",
2372 "second.rs": "// Second Rust file",
2373 "third.rs": "// Third Rust file",
2374 }
2375 }),
2376 )
2377 .await;
2378
2379 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2380 let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2381 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2382
2383 toggle_expand_dir(&panel, "src/test", cx);
2384 select_path(&panel, "src/test/first.rs", cx);
2385 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2386 cx.foreground().run_until_parked();
2387 assert_eq!(
2388 visible_entries_as_strings(&panel, 0..10, cx),
2389 &[
2390 "v src",
2391 " v test",
2392 " first.rs <== selected",
2393 " second.rs",
2394 " third.rs"
2395 ]
2396 );
2397 ensure_single_file_is_opened(window_id, &workspace, "test/first.rs", cx);
2398
2399 submit_deletion(window_id, &panel, cx);
2400 assert_eq!(
2401 visible_entries_as_strings(&panel, 0..10, cx),
2402 &[
2403 "v src",
2404 " v test",
2405 " second.rs",
2406 " third.rs"
2407 ],
2408 "Project panel should have no deleted file, no other file is selected in it"
2409 );
2410 ensure_no_open_items_and_panes(window_id, &workspace, cx);
2411
2412 select_path(&panel, "src/test/second.rs", cx);
2413 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2414 cx.foreground().run_until_parked();
2415 assert_eq!(
2416 visible_entries_as_strings(&panel, 0..10, cx),
2417 &[
2418 "v src",
2419 " v test",
2420 " second.rs <== selected",
2421 " third.rs"
2422 ]
2423 );
2424 ensure_single_file_is_opened(window_id, &workspace, "test/second.rs", cx);
2425
2426 cx.update_window(window_id, |cx| {
2427 let active_items = workspace
2428 .read(cx)
2429 .panes()
2430 .iter()
2431 .filter_map(|pane| pane.read(cx).active_item())
2432 .collect::<Vec<_>>();
2433 assert_eq!(active_items.len(), 1);
2434 let open_editor = active_items
2435 .into_iter()
2436 .next()
2437 .unwrap()
2438 .downcast::<Editor>()
2439 .expect("Open item should be an editor");
2440 open_editor.update(cx, |editor, cx| editor.set_text("Another text!", cx));
2441 });
2442 submit_deletion(window_id, &panel, cx);
2443 assert_eq!(
2444 visible_entries_as_strings(&panel, 0..10, cx),
2445 &["v src", " v test", " third.rs"],
2446 "Project panel should have no deleted file, with one last file remaining"
2447 );
2448 ensure_no_open_items_and_panes(window_id, &workspace, cx);
2449 }
2450
2451 #[gpui::test]
2452 async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
2453 init_test_with_editor(cx);
2454
2455 let fs = FakeFs::new(cx.background());
2456 fs.insert_tree(
2457 "/src",
2458 json!({
2459 "test": {
2460 "first.rs": "// First Rust file",
2461 "second.rs": "// Second Rust file",
2462 "third.rs": "// Third Rust file",
2463 }
2464 }),
2465 )
2466 .await;
2467
2468 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2469 let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2470 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2471
2472 select_path(&panel, "src/", cx);
2473 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2474 cx.foreground().run_until_parked();
2475 assert_eq!(
2476 visible_entries_as_strings(&panel, 0..10, cx),
2477 &["v src <== selected", " > test"]
2478 );
2479 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2480 cx.read_window(window_id, |cx| {
2481 let panel = panel.read(cx);
2482 assert!(panel.filename_editor.is_focused(cx));
2483 });
2484 assert_eq!(
2485 visible_entries_as_strings(&panel, 0..10, cx),
2486 &["v src", " > [EDITOR: ''] <== selected", " > test"]
2487 );
2488 panel.update(cx, |panel, cx| {
2489 panel
2490 .filename_editor
2491 .update(cx, |editor, cx| editor.set_text("test", cx));
2492 assert!(
2493 panel.confirm(&Confirm, cx).is_none(),
2494 "Should not allow to confirm on conflicting new directory name"
2495 )
2496 });
2497 assert_eq!(
2498 visible_entries_as_strings(&panel, 0..10, cx),
2499 &["v src", " > test"],
2500 "File list should be unchanged after failed folder create confirmation"
2501 );
2502
2503 select_path(&panel, "src/test/", cx);
2504 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2505 cx.foreground().run_until_parked();
2506 assert_eq!(
2507 visible_entries_as_strings(&panel, 0..10, cx),
2508 &["v src", " > test <== selected"]
2509 );
2510 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2511 cx.read_window(window_id, |cx| {
2512 let panel = panel.read(cx);
2513 assert!(panel.filename_editor.is_focused(cx));
2514 });
2515 assert_eq!(
2516 visible_entries_as_strings(&panel, 0..10, cx),
2517 &[
2518 "v src",
2519 " v test",
2520 " [EDITOR: ''] <== selected",
2521 " first.rs",
2522 " second.rs",
2523 " third.rs"
2524 ]
2525 );
2526 panel.update(cx, |panel, cx| {
2527 panel
2528 .filename_editor
2529 .update(cx, |editor, cx| editor.set_text("first.rs", cx));
2530 assert!(
2531 panel.confirm(&Confirm, cx).is_none(),
2532 "Should not allow to confirm on conflicting new file name"
2533 )
2534 });
2535 assert_eq!(
2536 visible_entries_as_strings(&panel, 0..10, cx),
2537 &[
2538 "v src",
2539 " v test",
2540 " first.rs",
2541 " second.rs",
2542 " third.rs"
2543 ],
2544 "File list should be unchanged after failed file create confirmation"
2545 );
2546
2547 select_path(&panel, "src/test/first.rs", cx);
2548 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2549 cx.foreground().run_until_parked();
2550 assert_eq!(
2551 visible_entries_as_strings(&panel, 0..10, cx),
2552 &[
2553 "v src",
2554 " v test",
2555 " first.rs <== selected",
2556 " second.rs",
2557 " third.rs"
2558 ],
2559 );
2560 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2561 cx.read_window(window_id, |cx| {
2562 let panel = panel.read(cx);
2563 assert!(panel.filename_editor.is_focused(cx));
2564 });
2565 assert_eq!(
2566 visible_entries_as_strings(&panel, 0..10, cx),
2567 &[
2568 "v src",
2569 " v test",
2570 " [EDITOR: 'first.rs'] <== selected",
2571 " second.rs",
2572 " third.rs"
2573 ]
2574 );
2575 panel.update(cx, |panel, cx| {
2576 panel
2577 .filename_editor
2578 .update(cx, |editor, cx| editor.set_text("second.rs", cx));
2579 assert!(
2580 panel.confirm(&Confirm, cx).is_none(),
2581 "Should not allow to confirm on conflicting file rename"
2582 )
2583 });
2584 assert_eq!(
2585 visible_entries_as_strings(&panel, 0..10, cx),
2586 &[
2587 "v src",
2588 " v test",
2589 " first.rs <== selected",
2590 " second.rs",
2591 " third.rs"
2592 ],
2593 "File list should be unchanged after failed rename confirmation"
2594 );
2595 }
2596
2597 #[gpui::test]
2598 async fn test_new_search_in_directory_trigger(cx: &mut gpui::TestAppContext) {
2599 init_test_with_editor(cx);
2600
2601 let fs = FakeFs::new(cx.background());
2602 fs.insert_tree(
2603 "/src",
2604 json!({
2605 "test": {
2606 "first.rs": "// First Rust file",
2607 "second.rs": "// Second Rust file",
2608 "third.rs": "// Third Rust file",
2609 }
2610 }),
2611 )
2612 .await;
2613
2614 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2615 let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2616 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2617
2618 let new_search_events_count = Arc::new(AtomicUsize::new(0));
2619 let _subscription = panel.update(cx, |_, cx| {
2620 let subcription_count = Arc::clone(&new_search_events_count);
2621 cx.subscribe(&cx.handle(), move |_, _, event, _| {
2622 if matches!(event, Event::NewSearchInDirectory { .. }) {
2623 subcription_count.fetch_add(1, atomic::Ordering::SeqCst);
2624 }
2625 })
2626 });
2627
2628 toggle_expand_dir(&panel, "src/test", cx);
2629 select_path(&panel, "src/test/first.rs", cx);
2630 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2631 cx.foreground().run_until_parked();
2632 assert_eq!(
2633 visible_entries_as_strings(&panel, 0..10, cx),
2634 &[
2635 "v src",
2636 " v test",
2637 " first.rs <== selected",
2638 " second.rs",
2639 " third.rs"
2640 ]
2641 );
2642 panel.update(cx, |panel, cx| {
2643 panel.new_search_in_directory(&NewSearchInDirectory, cx)
2644 });
2645 assert_eq!(
2646 new_search_events_count.load(atomic::Ordering::SeqCst),
2647 0,
2648 "Should not trigger new search in directory when called on a file"
2649 );
2650
2651 select_path(&panel, "src/test", cx);
2652 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2653 cx.foreground().run_until_parked();
2654 assert_eq!(
2655 visible_entries_as_strings(&panel, 0..10, cx),
2656 &[
2657 "v src",
2658 " v test <== selected",
2659 " first.rs",
2660 " second.rs",
2661 " third.rs"
2662 ]
2663 );
2664 panel.update(cx, |panel, cx| {
2665 panel.new_search_in_directory(&NewSearchInDirectory, cx)
2666 });
2667 assert_eq!(
2668 new_search_events_count.load(atomic::Ordering::SeqCst),
2669 1,
2670 "Should trigger new search in directory when called on a directory"
2671 );
2672 }
2673
2674 fn toggle_expand_dir(
2675 panel: &ViewHandle<ProjectPanel>,
2676 path: impl AsRef<Path>,
2677 cx: &mut TestAppContext,
2678 ) {
2679 let path = path.as_ref();
2680 panel.update(cx, |panel, cx| {
2681 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
2682 let worktree = worktree.read(cx);
2683 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
2684 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
2685 panel.toggle_expanded(entry_id, cx);
2686 return;
2687 }
2688 }
2689 panic!("no worktree for path {:?}", path);
2690 });
2691 }
2692
2693 fn select_path(
2694 panel: &ViewHandle<ProjectPanel>,
2695 path: impl AsRef<Path>,
2696 cx: &mut TestAppContext,
2697 ) {
2698 let path = path.as_ref();
2699 panel.update(cx, |panel, cx| {
2700 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
2701 let worktree = worktree.read(cx);
2702 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
2703 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
2704 panel.selection = Some(Selection {
2705 worktree_id: worktree.id(),
2706 entry_id,
2707 });
2708 return;
2709 }
2710 }
2711 panic!("no worktree for path {:?}", path);
2712 });
2713 }
2714
2715 fn visible_entries_as_strings(
2716 panel: &ViewHandle<ProjectPanel>,
2717 range: Range<usize>,
2718 cx: &mut TestAppContext,
2719 ) -> Vec<String> {
2720 let mut result = Vec::new();
2721 let mut project_entries = HashSet::new();
2722 let mut has_editor = false;
2723
2724 panel.update(cx, |panel, cx| {
2725 panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
2726 if details.is_editing {
2727 assert!(!has_editor, "duplicate editor entry");
2728 has_editor = true;
2729 } else {
2730 assert!(
2731 project_entries.insert(project_entry),
2732 "duplicate project entry {:?} {:?}",
2733 project_entry,
2734 details
2735 );
2736 }
2737
2738 let indent = " ".repeat(details.depth);
2739 let icon = if details.kind.is_dir() {
2740 if details.is_expanded {
2741 "v "
2742 } else {
2743 "> "
2744 }
2745 } else {
2746 " "
2747 };
2748 let name = if details.is_editing {
2749 format!("[EDITOR: '{}']", details.filename)
2750 } else if details.is_processing {
2751 format!("[PROCESSING: '{}']", details.filename)
2752 } else {
2753 details.filename.clone()
2754 };
2755 let selected = if details.is_selected {
2756 " <== selected"
2757 } else {
2758 ""
2759 };
2760 result.push(format!("{indent}{icon}{name}{selected}"));
2761 });
2762 });
2763
2764 result
2765 }
2766
2767 fn init_test(cx: &mut TestAppContext) {
2768 cx.foreground().forbid_parking();
2769 cx.update(|cx| {
2770 cx.set_global(SettingsStore::test(cx));
2771 init_settings(cx);
2772 theme::init((), cx);
2773 language::init(cx);
2774 editor::init_settings(cx);
2775 crate::init((), cx);
2776 workspace::init_settings(cx);
2777 Project::init_settings(cx);
2778 });
2779 }
2780
2781 fn init_test_with_editor(cx: &mut TestAppContext) {
2782 cx.foreground().forbid_parking();
2783 cx.update(|cx| {
2784 let app_state = AppState::test(cx);
2785 theme::init((), cx);
2786 init_settings(cx);
2787 language::init(cx);
2788 editor::init(cx);
2789 pane::init(cx);
2790 crate::init((), cx);
2791 workspace::init(app_state.clone(), cx);
2792 Project::init_settings(cx);
2793 });
2794 }
2795
2796 fn ensure_single_file_is_opened(
2797 window_id: usize,
2798 workspace: &ViewHandle<Workspace>,
2799 expected_path: &str,
2800 cx: &mut TestAppContext,
2801 ) {
2802 cx.read_window(window_id, |cx| {
2803 let workspace = workspace.read(cx);
2804 let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
2805 assert_eq!(worktrees.len(), 1);
2806 let worktree_id = WorktreeId::from_usize(worktrees[0].id());
2807
2808 let open_project_paths = workspace
2809 .panes()
2810 .iter()
2811 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
2812 .collect::<Vec<_>>();
2813 assert_eq!(
2814 open_project_paths,
2815 vec![ProjectPath {
2816 worktree_id,
2817 path: Arc::from(Path::new(expected_path))
2818 }],
2819 "Should have opened file, selected in project panel"
2820 );
2821 });
2822 }
2823
2824 fn submit_deletion(
2825 window_id: usize,
2826 panel: &ViewHandle<ProjectPanel>,
2827 cx: &mut TestAppContext,
2828 ) {
2829 assert!(
2830 !cx.has_pending_prompt(window_id),
2831 "Should have no prompts before the deletion"
2832 );
2833 panel.update(cx, |panel, cx| {
2834 panel
2835 .delete(&Delete, cx)
2836 .expect("Deletion start")
2837 .detach_and_log_err(cx);
2838 });
2839 assert!(
2840 cx.has_pending_prompt(window_id),
2841 "Should have a prompt after the deletion"
2842 );
2843 cx.simulate_prompt_answer(window_id, 0);
2844 assert!(
2845 !cx.has_pending_prompt(window_id),
2846 "Should have no prompts after prompt was replied to"
2847 );
2848 cx.foreground().run_until_parked();
2849 }
2850
2851 fn ensure_no_open_items_and_panes(
2852 window_id: usize,
2853 workspace: &ViewHandle<Workspace>,
2854 cx: &mut TestAppContext,
2855 ) {
2856 assert!(
2857 !cx.has_pending_prompt(window_id),
2858 "Should have no prompts after deletion operation closes the file"
2859 );
2860 cx.read_window(window_id, |cx| {
2861 let open_project_paths = workspace
2862 .read(cx)
2863 .panes()
2864 .iter()
2865 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
2866 .collect::<Vec<_>>();
2867 assert!(
2868 open_project_paths.is_empty(),
2869 "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
2870 );
2871 });
2872 }
2873}