project_panel.rs

   1mod project_panel_settings;
   2use client::{ErrorCode, ErrorExt};
   3use settings::{Settings, SettingsStore};
   4
   5use db::kvp::KEY_VALUE_STORE;
   6use editor::{actions::Cancel, items::entry_git_aware_label_color, scroll::Autoscroll, Editor};
   7use file_icons::FileIcons;
   8
   9use anyhow::{anyhow, Result};
  10use collections::{hash_map, HashMap};
  11use gpui::{
  12    actions, anchored, deferred, div, impl_actions, px, uniform_list, Action, AppContext,
  13    AssetSource, AsyncWindowContext, ClipboardItem, DismissEvent, Div, EventEmitter, FocusHandle,
  14    FocusableView, InteractiveElement, KeyContext, Model, MouseButton, MouseDownEvent,
  15    ParentElement, Pixels, Point, PromptLevel, Render, Stateful, Styled, Subscription, Task,
  16    UniformListScrollHandle, View, ViewContext, VisualContext as _, WeakView, WindowContext,
  17};
  18use menu::{Confirm, SelectNext, SelectPrev};
  19use project::{
  20    repository::GitFileStatus, Entry, EntryKind, Fs, Project, ProjectEntryId, ProjectPath,
  21    Worktree, WorktreeId,
  22};
  23use project_panel_settings::{ProjectPanelDockPosition, ProjectPanelSettings};
  24use serde::{Deserialize, Serialize};
  25use std::{
  26    cmp::Ordering,
  27    collections::HashSet,
  28    ffi::OsStr,
  29    ops::Range,
  30    path::{Path, PathBuf},
  31    sync::Arc,
  32};
  33use theme::ThemeSettings;
  34use ui::{prelude::*, v_flex, ContextMenu, Icon, KeyBinding, Label, ListItem};
  35use unicase::UniCase;
  36use util::{maybe, NumericPrefixWithSuffix, ResultExt, TryFutureExt};
  37use workspace::{
  38    dock::{DockPosition, Panel, PanelEvent},
  39    notifications::DetachAndPromptErr,
  40    Workspace,
  41};
  42
  43const PROJECT_PANEL_KEY: &str = "ProjectPanel";
  44const NEW_ENTRY_ID: ProjectEntryId = ProjectEntryId::MAX;
  45
  46pub struct ProjectPanel {
  47    project: Model<Project>,
  48    fs: Arc<dyn Fs>,
  49    scroll_handle: UniformListScrollHandle,
  50    focus_handle: FocusHandle,
  51    visible_entries: Vec<(WorktreeId, Vec<Entry>)>,
  52    last_worktree_root_id: Option<ProjectEntryId>,
  53    expanded_dir_ids: HashMap<WorktreeId, Vec<ProjectEntryId>>,
  54    unfolded_dir_ids: HashSet<ProjectEntryId>,
  55    selection: Option<Selection>,
  56    context_menu: Option<(View<ContextMenu>, Point<Pixels>, Subscription)>,
  57    edit_state: Option<EditState>,
  58    filename_editor: View<Editor>,
  59    clipboard_entry: Option<ClipboardEntry>,
  60    _dragged_entry_destination: Option<Arc<Path>>,
  61    workspace: WeakView<Workspace>,
  62    width: Option<Pixels>,
  63    pending_serialization: Task<Option<()>>,
  64}
  65
  66#[derive(Copy, Clone, Debug)]
  67struct Selection {
  68    worktree_id: WorktreeId,
  69    entry_id: ProjectEntryId,
  70}
  71
  72#[derive(Clone, Debug)]
  73struct EditState {
  74    worktree_id: WorktreeId,
  75    entry_id: ProjectEntryId,
  76    is_new_entry: bool,
  77    is_dir: bool,
  78    processing_filename: Option<String>,
  79}
  80
  81#[derive(Copy, Clone)]
  82pub enum ClipboardEntry {
  83    Copied {
  84        worktree_id: WorktreeId,
  85        entry_id: ProjectEntryId,
  86    },
  87    Cut {
  88        worktree_id: WorktreeId,
  89        entry_id: ProjectEntryId,
  90    },
  91}
  92
  93#[derive(Debug, PartialEq, Eq, Clone)]
  94pub struct EntryDetails {
  95    filename: String,
  96    icon: Option<Arc<str>>,
  97    path: Arc<Path>,
  98    depth: usize,
  99    kind: EntryKind,
 100    is_ignored: bool,
 101    is_expanded: bool,
 102    is_selected: bool,
 103    is_editing: bool,
 104    is_processing: bool,
 105    is_cut: bool,
 106    git_status: Option<GitFileStatus>,
 107    is_dotenv: bool,
 108}
 109
 110#[derive(PartialEq, Clone, Default, Debug, Deserialize)]
 111pub struct Delete {
 112    #[serde(default)]
 113    pub skip_prompt: bool,
 114}
 115
 116impl_actions!(project_panel, [Delete]);
 117
 118actions!(
 119    project_panel,
 120    [
 121        ExpandSelectedEntry,
 122        CollapseSelectedEntry,
 123        CollapseAllEntries,
 124        NewDirectory,
 125        NewFile,
 126        Copy,
 127        CopyPath,
 128        CopyRelativePath,
 129        RevealInFinder,
 130        OpenInTerminal,
 131        Cut,
 132        Paste,
 133        Rename,
 134        Open,
 135        OpenPermanent,
 136        ToggleFocus,
 137        NewSearchInDirectory,
 138        UnfoldDirectory,
 139        FoldDirectory,
 140    ]
 141);
 142
 143pub fn init_settings(cx: &mut AppContext) {
 144    ProjectPanelSettings::register(cx);
 145}
 146
 147pub fn init(assets: impl AssetSource, cx: &mut AppContext) {
 148    init_settings(cx);
 149    file_icons::init(assets, cx);
 150
 151    cx.observe_new_views(|workspace: &mut Workspace, _| {
 152        workspace.register_action(|workspace, _: &ToggleFocus, cx| {
 153            workspace.toggle_panel_focus::<ProjectPanel>(cx);
 154        });
 155    })
 156    .detach();
 157}
 158
 159#[derive(Debug)]
 160pub enum Event {
 161    OpenedEntry {
 162        entry_id: ProjectEntryId,
 163        focus_opened_item: bool,
 164        allow_preview: bool,
 165    },
 166    SplitEntry {
 167        entry_id: ProjectEntryId,
 168    },
 169    Focus,
 170}
 171
 172#[derive(Serialize, Deserialize)]
 173struct SerializedProjectPanel {
 174    width: Option<Pixels>,
 175}
 176
 177struct DraggedProjectEntryView {
 178    entry_id: ProjectEntryId,
 179    details: EntryDetails,
 180    width: Pixels,
 181}
 182
 183impl ProjectPanel {
 184    fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> View<Self> {
 185        let project = workspace.project().clone();
 186        let project_panel = cx.new_view(|cx: &mut ViewContext<Self>| {
 187            let focus_handle = cx.focus_handle();
 188            cx.on_focus(&focus_handle, Self::focus_in).detach();
 189
 190            cx.subscribe(&project, |this, project, event, cx| match event {
 191                project::Event::ActiveEntryChanged(Some(entry_id)) => {
 192                    if ProjectPanelSettings::get_global(cx).auto_reveal_entries {
 193                        this.reveal_entry(project, *entry_id, true, cx);
 194                    }
 195                }
 196                project::Event::RevealInProjectPanel(entry_id) => {
 197                    this.reveal_entry(project, *entry_id, false, cx);
 198                    cx.emit(PanelEvent::Activate);
 199                }
 200                project::Event::ActivateProjectPanel => {
 201                    cx.emit(PanelEvent::Activate);
 202                }
 203                project::Event::WorktreeRemoved(id) => {
 204                    this.expanded_dir_ids.remove(id);
 205                    this.update_visible_entries(None, cx);
 206                    cx.notify();
 207                }
 208                project::Event::WorktreeUpdatedEntries(_, _) | project::Event::WorktreeAdded => {
 209                    this.update_visible_entries(None, cx);
 210                    cx.notify();
 211                }
 212                _ => {}
 213            })
 214            .detach();
 215
 216            let filename_editor = cx.new_view(|cx| Editor::single_line(cx));
 217
 218            cx.subscribe(&filename_editor, |this, _, event, cx| match event {
 219                editor::EditorEvent::BufferEdited
 220                | editor::EditorEvent::SelectionsChanged { .. } => {
 221                    this.autoscroll(cx);
 222                }
 223                editor::EditorEvent::Blurred => {
 224                    if this
 225                        .edit_state
 226                        .as_ref()
 227                        .map_or(false, |state| state.processing_filename.is_none())
 228                    {
 229                        this.edit_state = None;
 230                        this.update_visible_entries(None, cx);
 231                    }
 232                }
 233                _ => {}
 234            })
 235            .detach();
 236
 237            cx.observe_global::<FileIcons>(|_, cx| {
 238                cx.notify();
 239            })
 240            .detach();
 241
 242            let mut project_panel_settings = *ProjectPanelSettings::get_global(cx);
 243            cx.observe_global::<SettingsStore>(move |_, cx| {
 244                let new_settings = *ProjectPanelSettings::get_global(cx);
 245                if project_panel_settings != new_settings {
 246                    project_panel_settings = new_settings;
 247                    cx.notify();
 248                }
 249            })
 250            .detach();
 251
 252            let mut this = Self {
 253                project: project.clone(),
 254                fs: workspace.app_state().fs.clone(),
 255                scroll_handle: UniformListScrollHandle::new(),
 256                focus_handle,
 257                visible_entries: Default::default(),
 258                last_worktree_root_id: Default::default(),
 259                expanded_dir_ids: Default::default(),
 260                unfolded_dir_ids: Default::default(),
 261                selection: None,
 262                edit_state: None,
 263                context_menu: None,
 264                filename_editor,
 265                clipboard_entry: None,
 266                _dragged_entry_destination: None,
 267                workspace: workspace.weak_handle(),
 268                width: None,
 269                pending_serialization: Task::ready(None),
 270            };
 271            this.update_visible_entries(None, cx);
 272
 273            this
 274        });
 275
 276        cx.subscribe(&project_panel, {
 277            let project_panel = project_panel.downgrade();
 278            move |workspace, _, event, cx| match event {
 279                &Event::OpenedEntry {
 280                    entry_id,
 281                    focus_opened_item,
 282                    allow_preview,
 283                } => {
 284                    if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
 285                        if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
 286                            let file_path = entry.path.clone();
 287                            let worktree_id = worktree.read(cx).id();
 288                            let entry_id = entry.id;
 289
 290                            workspace
 291                                .open_path_preview(
 292                                    ProjectPath {
 293                                        worktree_id,
 294                                        path: file_path.clone(),
 295                                    },
 296                                    None,
 297                                    focus_opened_item,
 298                                    allow_preview,
 299                                    cx,
 300                                )
 301                                .detach_and_prompt_err("Failed to open file", cx, move |e, _| {
 302                                    match e.error_code() {
 303                                        ErrorCode::UnsharedItem => Some(format!(
 304                                            "{} is not shared by the host. This could be because it has been marked as `private`",
 305                                            file_path.display()
 306                                        )),
 307                                        _ => None,
 308                                    }
 309                                });
 310
 311                            if let Some(project_panel) = project_panel.upgrade() {
 312                                // Always select the entry, regardless of whether it is opened or not.
 313                                project_panel.update(cx, |project_panel, _| {
 314                                    project_panel.selection = Some(Selection {
 315                                        worktree_id,
 316                                        entry_id
 317                                    });
 318                                });
 319                                if !focus_opened_item {
 320                                    let focus_handle = project_panel.read(cx).focus_handle.clone();
 321                                    cx.focus(&focus_handle);
 322                                }
 323                            }
 324                        }
 325                    }
 326                }
 327                &Event::SplitEntry { entry_id } => {
 328                    if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
 329                        if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
 330                            workspace
 331                                .split_path(
 332                                    ProjectPath {
 333                                        worktree_id: worktree.read(cx).id(),
 334                                        path: entry.path.clone(),
 335                                    },
 336                                    cx,
 337                                )
 338                                .detach_and_log_err(cx);
 339                        }
 340                    }
 341                }
 342                _ => {}
 343            }
 344        })
 345        .detach();
 346
 347        project_panel
 348    }
 349
 350    pub async fn load(
 351        workspace: WeakView<Workspace>,
 352        mut cx: AsyncWindowContext,
 353    ) -> Result<View<Self>> {
 354        let serialized_panel = cx
 355            .background_executor()
 356            .spawn(async move { KEY_VALUE_STORE.read_kvp(PROJECT_PANEL_KEY) })
 357            .await
 358            .map_err(|e| anyhow!("Failed to load project panel: {}", e))
 359            .log_err()
 360            .flatten()
 361            .map(|panel| serde_json::from_str::<SerializedProjectPanel>(&panel))
 362            .transpose()
 363            .log_err()
 364            .flatten();
 365
 366        workspace.update(&mut cx, |workspace, cx| {
 367            let panel = ProjectPanel::new(workspace, cx);
 368            if let Some(serialized_panel) = serialized_panel {
 369                panel.update(cx, |panel, cx| {
 370                    panel.width = serialized_panel.width.map(|px| px.round());
 371                    cx.notify();
 372                });
 373            }
 374            panel
 375        })
 376    }
 377
 378    fn serialize(&mut self, cx: &mut ViewContext<Self>) {
 379        let width = self.width;
 380        self.pending_serialization = cx.background_executor().spawn(
 381            async move {
 382                KEY_VALUE_STORE
 383                    .write_kvp(
 384                        PROJECT_PANEL_KEY.into(),
 385                        serde_json::to_string(&SerializedProjectPanel { width })?,
 386                    )
 387                    .await?;
 388                anyhow::Ok(())
 389            }
 390            .log_err(),
 391        );
 392    }
 393
 394    fn focus_in(&mut self, cx: &mut ViewContext<Self>) {
 395        if !self.focus_handle.contains_focused(cx) {
 396            cx.emit(Event::Focus);
 397        }
 398    }
 399
 400    fn deploy_context_menu(
 401        &mut self,
 402        position: Point<Pixels>,
 403        entry_id: ProjectEntryId,
 404        cx: &mut ViewContext<Self>,
 405    ) {
 406        let this = cx.view().clone();
 407        let project = self.project.read(cx);
 408
 409        let worktree_id = if let Some(id) = project.worktree_id_for_entry(entry_id, cx) {
 410            id
 411        } else {
 412            return;
 413        };
 414
 415        self.selection = Some(Selection {
 416            worktree_id,
 417            entry_id,
 418        });
 419
 420        if let Some((worktree, entry)) = self.selected_entry(cx) {
 421            let auto_fold_dirs = ProjectPanelSettings::get_global(cx).auto_fold_dirs;
 422            let is_root = Some(entry) == worktree.root_entry();
 423            let is_dir = entry.is_dir();
 424            let is_foldable = auto_fold_dirs && self.is_foldable(entry, worktree);
 425            let is_unfoldable = auto_fold_dirs && self.is_unfoldable(entry, worktree);
 426            let worktree_id = worktree.id();
 427            let is_local = project.is_local();
 428            let is_read_only = project.is_read_only();
 429
 430            let context_menu = ContextMenu::build(cx, |menu, cx| {
 431                menu.context(self.focus_handle.clone()).when_else(
 432                    is_read_only,
 433                    |menu| {
 434                        menu.action("Copy Relative Path", Box::new(CopyRelativePath))
 435                            .when(is_dir, |menu| {
 436                                menu.action("Search Inside", Box::new(NewSearchInDirectory))
 437                            })
 438                    },
 439                    |menu| {
 440                        menu.action("New File", Box::new(NewFile))
 441                            .action("New Folder", Box::new(NewDirectory))
 442                            .separator()
 443                            .action("Reveal in Finder", Box::new(RevealInFinder))
 444                            .when(is_dir, |menu| {
 445                                menu.action("Open in Terminal…", Box::new(OpenInTerminal))
 446                            })
 447                            .when(is_dir, |menu| {
 448                                menu.separator()
 449                                    .action("Find in Folder…", Box::new(NewSearchInDirectory))
 450                            })
 451                            .when(is_unfoldable, |menu| {
 452                                menu.action("Unfold Directory", Box::new(UnfoldDirectory))
 453                            })
 454                            .when(is_foldable, |menu| {
 455                                menu.action("Fold Directory", Box::new(FoldDirectory))
 456                            })
 457                            .separator()
 458                            .action("Cut", Box::new(Cut))
 459                            .action("Copy", Box::new(Copy))
 460                            // TODO: Paste should always be visible, but disabled when clipboard is empty
 461                            .when_some(self.clipboard_entry, |menu, entry| {
 462                                menu.when(entry.worktree_id() == worktree_id, |menu| {
 463                                    menu.action("Paste", Box::new(Paste))
 464                                })
 465                            })
 466                            .separator()
 467                            .action("Copy Path", Box::new(CopyPath))
 468                            .action("Copy Relative Path", Box::new(CopyRelativePath))
 469                            .separator()
 470                            .action("Rename", Box::new(Rename))
 471                            .when(!is_root, |menu| {
 472                                menu.action("Delete", Box::new(Delete { skip_prompt: false }))
 473                            })
 474                            .when(is_local & is_root, |menu| {
 475                                menu.separator()
 476                                    .action(
 477                                        "Add Folder to Project…",
 478                                        Box::new(workspace::AddFolderToProject),
 479                                    )
 480                                    .entry(
 481                                        "Remove from Project",
 482                                        None,
 483                                        cx.handler_for(&this, move |this, cx| {
 484                                            this.project.update(cx, |project, cx| {
 485                                                project.remove_worktree(worktree_id, cx)
 486                                            });
 487                                        }),
 488                                    )
 489                            })
 490                            .when(is_local & is_root, |menu| {
 491                                menu.separator()
 492                                    .action("Collapse All", Box::new(CollapseAllEntries))
 493                            })
 494                    },
 495                )
 496            });
 497
 498            cx.focus_view(&context_menu);
 499            let subscription = cx.subscribe(&context_menu, |this, _, _: &DismissEvent, cx| {
 500                this.context_menu.take();
 501                cx.notify();
 502            });
 503            self.context_menu = Some((context_menu, position, subscription));
 504        }
 505
 506        cx.notify();
 507    }
 508
 509    fn is_unfoldable(&self, entry: &Entry, worktree: &Worktree) -> bool {
 510        if !entry.is_dir() || self.unfolded_dir_ids.contains(&entry.id) {
 511            return false;
 512        }
 513
 514        if let Some(parent_path) = entry.path.parent() {
 515            let snapshot = worktree.snapshot();
 516            let mut child_entries = snapshot.child_entries(&parent_path);
 517            if let Some(child) = child_entries.next() {
 518                if child_entries.next().is_none() {
 519                    return child.kind.is_dir();
 520                }
 521            }
 522        };
 523        false
 524    }
 525
 526    fn is_foldable(&self, entry: &Entry, worktree: &Worktree) -> bool {
 527        if entry.is_dir() {
 528            let snapshot = worktree.snapshot();
 529
 530            let mut child_entries = snapshot.child_entries(&entry.path);
 531            if let Some(child) = child_entries.next() {
 532                if child_entries.next().is_none() {
 533                    return child.kind.is_dir();
 534                }
 535            }
 536        }
 537        false
 538    }
 539
 540    fn expand_selected_entry(&mut self, _: &ExpandSelectedEntry, cx: &mut ViewContext<Self>) {
 541        if let Some((worktree, entry)) = self.selected_entry(cx) {
 542            if entry.is_dir() {
 543                let worktree_id = worktree.id();
 544                let entry_id = entry.id;
 545                let expanded_dir_ids =
 546                    if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
 547                        expanded_dir_ids
 548                    } else {
 549                        return;
 550                    };
 551
 552                match expanded_dir_ids.binary_search(&entry_id) {
 553                    Ok(_) => self.select_next(&SelectNext, cx),
 554                    Err(ix) => {
 555                        self.project.update(cx, |project, cx| {
 556                            project.expand_entry(worktree_id, entry_id, cx);
 557                        });
 558
 559                        expanded_dir_ids.insert(ix, entry_id);
 560                        self.update_visible_entries(None, cx);
 561                        cx.notify();
 562                    }
 563                }
 564            }
 565        }
 566    }
 567
 568    fn collapse_selected_entry(&mut self, _: &CollapseSelectedEntry, cx: &mut ViewContext<Self>) {
 569        if let Some((worktree, mut entry)) = self.selected_entry(cx) {
 570            let worktree_id = worktree.id();
 571            let expanded_dir_ids =
 572                if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
 573                    expanded_dir_ids
 574                } else {
 575                    return;
 576                };
 577
 578            loop {
 579                let entry_id = entry.id;
 580                match expanded_dir_ids.binary_search(&entry_id) {
 581                    Ok(ix) => {
 582                        expanded_dir_ids.remove(ix);
 583                        self.update_visible_entries(Some((worktree_id, entry_id)), cx);
 584                        cx.notify();
 585                        break;
 586                    }
 587                    Err(_) => {
 588                        if let Some(parent_entry) =
 589                            entry.path.parent().and_then(|p| worktree.entry_for_path(p))
 590                        {
 591                            entry = parent_entry;
 592                        } else {
 593                            break;
 594                        }
 595                    }
 596                }
 597            }
 598        }
 599    }
 600
 601    pub fn collapse_all_entries(&mut self, _: &CollapseAllEntries, cx: &mut ViewContext<Self>) {
 602        // By keeping entries for fully collapsed worktrees, we avoid expanding them within update_visible_entries
 603        // (which is it's default behaviour when there's no entry for a worktree in expanded_dir_ids).
 604        self.expanded_dir_ids
 605            .retain(|_, expanded_entries| expanded_entries.is_empty());
 606        self.update_visible_entries(None, cx);
 607        cx.notify();
 608    }
 609
 610    fn toggle_expanded(&mut self, entry_id: ProjectEntryId, cx: &mut ViewContext<Self>) {
 611        if let Some(worktree_id) = self.project.read(cx).worktree_id_for_entry(entry_id, cx) {
 612            if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
 613                self.project.update(cx, |project, cx| {
 614                    match expanded_dir_ids.binary_search(&entry_id) {
 615                        Ok(ix) => {
 616                            expanded_dir_ids.remove(ix);
 617                        }
 618                        Err(ix) => {
 619                            project.expand_entry(worktree_id, entry_id, cx);
 620                            expanded_dir_ids.insert(ix, entry_id);
 621                        }
 622                    }
 623                });
 624                self.update_visible_entries(Some((worktree_id, entry_id)), cx);
 625                cx.focus(&self.focus_handle);
 626                cx.notify();
 627            }
 628        }
 629    }
 630
 631    fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
 632        if let Some(selection) = self.selection {
 633            let (mut worktree_ix, mut entry_ix, _) =
 634                self.index_for_selection(selection).unwrap_or_default();
 635            if entry_ix > 0 {
 636                entry_ix -= 1;
 637            } else if worktree_ix > 0 {
 638                worktree_ix -= 1;
 639                entry_ix = self.visible_entries[worktree_ix].1.len() - 1;
 640            } else {
 641                return;
 642            }
 643
 644            let (worktree_id, worktree_entries) = &self.visible_entries[worktree_ix];
 645            self.selection = Some(Selection {
 646                worktree_id: *worktree_id,
 647                entry_id: worktree_entries[entry_ix].id,
 648            });
 649            self.autoscroll(cx);
 650            cx.notify();
 651        } else {
 652            self.select_first(cx);
 653        }
 654    }
 655
 656    fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
 657        if let Some(task) = self.confirm_edit(cx) {
 658            task.detach_and_log_err(cx);
 659        }
 660    }
 661
 662    fn open(&mut self, _: &Open, cx: &mut ViewContext<Self>) {
 663        self.open_internal(true, false, cx);
 664    }
 665
 666    fn open_permanent(&mut self, _: &OpenPermanent, cx: &mut ViewContext<Self>) {
 667        self.open_internal(false, true, cx);
 668    }
 669
 670    fn open_internal(
 671        &mut self,
 672        allow_preview: bool,
 673        focus_opened_item: bool,
 674        cx: &mut ViewContext<Self>,
 675    ) {
 676        if let Some((_, entry)) = self.selected_entry(cx) {
 677            if entry.is_file() {
 678                self.open_entry(entry.id, focus_opened_item, allow_preview, cx);
 679            } else {
 680                self.toggle_expanded(entry.id, cx);
 681            }
 682        }
 683    }
 684
 685    fn confirm_edit(&mut self, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
 686        let edit_state = self.edit_state.as_mut()?;
 687        cx.focus(&self.focus_handle);
 688
 689        let worktree_id = edit_state.worktree_id;
 690        let is_new_entry = edit_state.is_new_entry;
 691        let is_dir = edit_state.is_dir;
 692        let worktree = self.project.read(cx).worktree_for_id(worktree_id, cx)?;
 693        let entry = worktree.read(cx).entry_for_id(edit_state.entry_id)?.clone();
 694        let filename = self.filename_editor.read(cx).text(cx);
 695
 696        let path_already_exists = |path| worktree.read(cx).entry_for_path(path).is_some();
 697        let edit_task;
 698        let edited_entry_id;
 699        if is_new_entry {
 700            self.selection = Some(Selection {
 701                worktree_id,
 702                entry_id: NEW_ENTRY_ID,
 703            });
 704            let new_path = entry.path.join(&filename.trim_start_matches('/'));
 705            if path_already_exists(new_path.as_path()) {
 706                return None;
 707            }
 708
 709            edited_entry_id = NEW_ENTRY_ID;
 710            edit_task = self.project.update(cx, |project, cx| {
 711                project.create_entry((worktree_id, &new_path), is_dir, cx)
 712            });
 713        } else {
 714            let new_path = if let Some(parent) = entry.path.clone().parent() {
 715                parent.join(&filename)
 716            } else {
 717                filename.clone().into()
 718            };
 719            if path_already_exists(new_path.as_path()) {
 720                return None;
 721            }
 722
 723            edited_entry_id = entry.id;
 724            edit_task = self.project.update(cx, |project, cx| {
 725                project.rename_entry(entry.id, new_path.as_path(), cx)
 726            });
 727        };
 728
 729        edit_state.processing_filename = Some(filename);
 730        cx.notify();
 731
 732        Some(cx.spawn(|this, mut cx| async move {
 733            let new_entry = edit_task.await;
 734            this.update(&mut cx, |this, cx| {
 735                this.edit_state.take();
 736                cx.notify();
 737            })?;
 738
 739            if let Some(new_entry) = new_entry? {
 740                this.update(&mut cx, |this, cx| {
 741                    if let Some(selection) = &mut this.selection {
 742                        if selection.entry_id == edited_entry_id {
 743                            selection.worktree_id = worktree_id;
 744                            selection.entry_id = new_entry.id;
 745                            this.expand_to_selection(cx);
 746                        }
 747                    }
 748                    this.update_visible_entries(None, cx);
 749                    if is_new_entry && !is_dir {
 750                        this.open_entry(new_entry.id, true, false, cx);
 751                    }
 752                    cx.notify();
 753                })?;
 754            }
 755            Ok(())
 756        }))
 757    }
 758
 759    fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
 760        self.edit_state = None;
 761        self.update_visible_entries(None, cx);
 762        cx.focus(&self.focus_handle);
 763        cx.notify();
 764    }
 765
 766    fn open_entry(
 767        &mut self,
 768        entry_id: ProjectEntryId,
 769        focus_opened_item: bool,
 770        allow_preview: bool,
 771        cx: &mut ViewContext<Self>,
 772    ) {
 773        cx.emit(Event::OpenedEntry {
 774            entry_id,
 775            focus_opened_item,
 776            allow_preview,
 777        });
 778    }
 779
 780    fn split_entry(&mut self, entry_id: ProjectEntryId, cx: &mut ViewContext<Self>) {
 781        cx.emit(Event::SplitEntry { entry_id });
 782    }
 783
 784    fn new_file(&mut self, _: &NewFile, cx: &mut ViewContext<Self>) {
 785        self.add_entry(false, cx)
 786    }
 787
 788    fn new_directory(&mut self, _: &NewDirectory, cx: &mut ViewContext<Self>) {
 789        self.add_entry(true, cx)
 790    }
 791
 792    fn add_entry(&mut self, is_dir: bool, cx: &mut ViewContext<Self>) {
 793        if let Some(Selection {
 794            worktree_id,
 795            entry_id,
 796        }) = self.selection
 797        {
 798            let directory_id;
 799            if let Some((worktree, expanded_dir_ids)) = self
 800                .project
 801                .read(cx)
 802                .worktree_for_id(worktree_id, cx)
 803                .zip(self.expanded_dir_ids.get_mut(&worktree_id))
 804            {
 805                let worktree = worktree.read(cx);
 806                if let Some(mut entry) = worktree.entry_for_id(entry_id) {
 807                    loop {
 808                        if entry.is_dir() {
 809                            if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
 810                                expanded_dir_ids.insert(ix, entry.id);
 811                            }
 812                            directory_id = entry.id;
 813                            break;
 814                        } else {
 815                            if let Some(parent_path) = entry.path.parent() {
 816                                if let Some(parent_entry) = worktree.entry_for_path(parent_path) {
 817                                    entry = parent_entry;
 818                                    continue;
 819                                }
 820                            }
 821                            return;
 822                        }
 823                    }
 824                } else {
 825                    return;
 826                };
 827            } else {
 828                return;
 829            };
 830
 831            self.edit_state = Some(EditState {
 832                worktree_id,
 833                entry_id: directory_id,
 834                is_new_entry: true,
 835                is_dir,
 836                processing_filename: None,
 837            });
 838            self.filename_editor.update(cx, |editor, cx| {
 839                editor.clear(cx);
 840                editor.focus(cx);
 841            });
 842            self.update_visible_entries(Some((worktree_id, NEW_ENTRY_ID)), cx);
 843            self.autoscroll(cx);
 844            cx.notify();
 845        }
 846    }
 847
 848    fn rename(&mut self, _: &Rename, cx: &mut ViewContext<Self>) {
 849        if let Some(Selection {
 850            worktree_id,
 851            entry_id,
 852        }) = self.selection
 853        {
 854            if let Some(worktree) = self.project.read(cx).worktree_for_id(worktree_id, cx) {
 855                if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
 856                    self.edit_state = Some(EditState {
 857                        worktree_id,
 858                        entry_id,
 859                        is_new_entry: false,
 860                        is_dir: entry.is_dir(),
 861                        processing_filename: None,
 862                    });
 863                    let file_name = entry
 864                        .path
 865                        .file_name()
 866                        .map(|s| s.to_string_lossy())
 867                        .unwrap_or_default()
 868                        .to_string();
 869                    let file_stem = entry.path.file_stem().map(|s| s.to_string_lossy());
 870                    let selection_end =
 871                        file_stem.map_or(file_name.len(), |file_stem| file_stem.len());
 872                    self.filename_editor.update(cx, |editor, cx| {
 873                        editor.set_text(file_name, cx);
 874                        editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
 875                            s.select_ranges([0..selection_end])
 876                        });
 877                        editor.focus(cx);
 878                    });
 879                    self.update_visible_entries(None, cx);
 880                    self.autoscroll(cx);
 881                    cx.notify();
 882                }
 883            }
 884        }
 885    }
 886
 887    fn delete(&mut self, action: &Delete, cx: &mut ViewContext<Self>) {
 888        maybe!({
 889            let Selection { entry_id, .. } = self.selection?;
 890            let path = self.project.read(cx).path_for_entry(entry_id, cx)?.path;
 891            let file_name = path.file_name()?;
 892
 893            let answer = (!action.skip_prompt).then(|| {
 894                cx.prompt(
 895                    PromptLevel::Info,
 896                    &format!("Delete {file_name:?}?"),
 897                    None,
 898                    &["Delete", "Cancel"],
 899                )
 900            });
 901
 902            cx.spawn(|this, mut cx| async move {
 903                if let Some(answer) = answer {
 904                    if answer.await != Ok(0) {
 905                        return Ok(());
 906                    }
 907                }
 908                this.update(&mut cx, |this, cx| {
 909                    this.project
 910                        .update(cx, |project, cx| project.delete_entry(entry_id, cx))
 911                        .ok_or_else(|| anyhow!("no such entry"))
 912                })??
 913                .await
 914            })
 915            .detach_and_log_err(cx);
 916            Some(())
 917        });
 918    }
 919
 920    fn unfold_directory(&mut self, _: &UnfoldDirectory, cx: &mut ViewContext<Self>) {
 921        if let Some((worktree, entry)) = self.selected_entry(cx) {
 922            self.unfolded_dir_ids.insert(entry.id);
 923
 924            let snapshot = worktree.snapshot();
 925            let mut parent_path = entry.path.parent();
 926            while let Some(path) = parent_path {
 927                if let Some(parent_entry) = worktree.entry_for_path(path) {
 928                    let mut children_iter = snapshot.child_entries(path);
 929
 930                    if children_iter.by_ref().take(2).count() > 1 {
 931                        break;
 932                    }
 933
 934                    self.unfolded_dir_ids.insert(parent_entry.id);
 935                    parent_path = path.parent();
 936                } else {
 937                    break;
 938                }
 939            }
 940
 941            self.update_visible_entries(None, cx);
 942            self.autoscroll(cx);
 943            cx.notify();
 944        }
 945    }
 946
 947    fn fold_directory(&mut self, _: &FoldDirectory, cx: &mut ViewContext<Self>) {
 948        if let Some((worktree, entry)) = self.selected_entry(cx) {
 949            self.unfolded_dir_ids.remove(&entry.id);
 950
 951            let snapshot = worktree.snapshot();
 952            let mut path = &*entry.path;
 953            loop {
 954                let mut child_entries_iter = snapshot.child_entries(path);
 955                if let Some(child) = child_entries_iter.next() {
 956                    if child_entries_iter.next().is_none() && child.is_dir() {
 957                        self.unfolded_dir_ids.remove(&child.id);
 958                        path = &*child.path;
 959                    } else {
 960                        break;
 961                    }
 962                } else {
 963                    break;
 964                }
 965            }
 966
 967            self.update_visible_entries(None, cx);
 968            self.autoscroll(cx);
 969            cx.notify();
 970        }
 971    }
 972
 973    fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
 974        if let Some(selection) = self.selection {
 975            let (mut worktree_ix, mut entry_ix, _) =
 976                self.index_for_selection(selection).unwrap_or_default();
 977            if let Some((_, worktree_entries)) = self.visible_entries.get(worktree_ix) {
 978                if entry_ix + 1 < worktree_entries.len() {
 979                    entry_ix += 1;
 980                } else {
 981                    worktree_ix += 1;
 982                    entry_ix = 0;
 983                }
 984            }
 985
 986            if let Some((worktree_id, worktree_entries)) = self.visible_entries.get(worktree_ix) {
 987                if let Some(entry) = worktree_entries.get(entry_ix) {
 988                    self.selection = Some(Selection {
 989                        worktree_id: *worktree_id,
 990                        entry_id: entry.id,
 991                    });
 992                    self.autoscroll(cx);
 993                    cx.notify();
 994                }
 995            }
 996        } else {
 997            self.select_first(cx);
 998        }
 999    }
1000
1001    fn select_first(&mut self, cx: &mut ViewContext<Self>) {
1002        let worktree = self
1003            .visible_entries
1004            .first()
1005            .and_then(|(worktree_id, _)| self.project.read(cx).worktree_for_id(*worktree_id, cx));
1006        if let Some(worktree) = worktree {
1007            let worktree = worktree.read(cx);
1008            let worktree_id = worktree.id();
1009            if let Some(root_entry) = worktree.root_entry() {
1010                self.selection = Some(Selection {
1011                    worktree_id,
1012                    entry_id: root_entry.id,
1013                });
1014                self.autoscroll(cx);
1015                cx.notify();
1016            }
1017        }
1018    }
1019
1020    fn autoscroll(&mut self, cx: &mut ViewContext<Self>) {
1021        if let Some((_, _, index)) = self.selection.and_then(|s| self.index_for_selection(s)) {
1022            self.scroll_handle.scroll_to_item(index);
1023            cx.notify();
1024        }
1025    }
1026
1027    fn cut(&mut self, _: &Cut, cx: &mut ViewContext<Self>) {
1028        if let Some((worktree, entry)) = self.selected_entry(cx) {
1029            self.clipboard_entry = Some(ClipboardEntry::Cut {
1030                worktree_id: worktree.id(),
1031                entry_id: entry.id,
1032            });
1033            cx.notify();
1034        }
1035    }
1036
1037    fn copy(&mut self, _: &Copy, cx: &mut ViewContext<Self>) {
1038        if let Some((worktree, entry)) = self.selected_entry(cx) {
1039            self.clipboard_entry = Some(ClipboardEntry::Copied {
1040                worktree_id: worktree.id(),
1041                entry_id: entry.id,
1042            });
1043            cx.notify();
1044        }
1045    }
1046
1047    fn paste(&mut self, _: &Paste, cx: &mut ViewContext<Self>) {
1048        maybe!({
1049            let (worktree, entry) = self.selected_entry(cx)?;
1050            let clipboard_entry = self.clipboard_entry?;
1051            if clipboard_entry.worktree_id() != worktree.id() {
1052                return None;
1053            }
1054
1055            let clipboard_entry_file_name = self
1056                .project
1057                .read(cx)
1058                .path_for_entry(clipboard_entry.entry_id(), cx)?
1059                .path
1060                .file_name()?
1061                .to_os_string();
1062
1063            let mut new_path = entry.path.to_path_buf();
1064            // If we're pasting into a file, or a directory into itself, go up one level.
1065            if entry.is_file() || (entry.is_dir() && entry.id == clipboard_entry.entry_id()) {
1066                new_path.pop();
1067            }
1068
1069            new_path.push(&clipboard_entry_file_name);
1070            let extension = new_path.extension().map(|e| e.to_os_string());
1071            let file_name_without_extension = Path::new(&clipboard_entry_file_name).file_stem()?;
1072            let mut ix = 0;
1073            while worktree.entry_for_path(&new_path).is_some() {
1074                new_path.pop();
1075
1076                let mut new_file_name = file_name_without_extension.to_os_string();
1077                new_file_name.push(" copy");
1078                if ix > 0 {
1079                    new_file_name.push(format!(" {}", ix));
1080                }
1081                if let Some(extension) = extension.as_ref() {
1082                    new_file_name.push(".");
1083                    new_file_name.push(extension);
1084                }
1085
1086                new_path.push(new_file_name);
1087                ix += 1;
1088            }
1089
1090            if clipboard_entry.is_cut() {
1091                self.project
1092                    .update(cx, |project, cx| {
1093                        project.rename_entry(clipboard_entry.entry_id(), new_path, cx)
1094                    })
1095                    .detach_and_log_err(cx)
1096            } else {
1097                self.project
1098                    .update(cx, |project, cx| {
1099                        project.copy_entry(clipboard_entry.entry_id(), new_path, cx)
1100                    })
1101                    .detach_and_log_err(cx)
1102            }
1103
1104            Some(())
1105        });
1106    }
1107
1108    fn copy_path(&mut self, _: &CopyPath, cx: &mut ViewContext<Self>) {
1109        if let Some((worktree, entry)) = self.selected_entry(cx) {
1110            cx.write_to_clipboard(ClipboardItem::new(
1111                worktree
1112                    .abs_path()
1113                    .join(&entry.path)
1114                    .to_string_lossy()
1115                    .to_string(),
1116            ));
1117        }
1118    }
1119
1120    fn copy_relative_path(&mut self, _: &CopyRelativePath, cx: &mut ViewContext<Self>) {
1121        if let Some((_, entry)) = self.selected_entry(cx) {
1122            cx.write_to_clipboard(ClipboardItem::new(entry.path.to_string_lossy().to_string()));
1123        }
1124    }
1125
1126    fn reveal_in_finder(&mut self, _: &RevealInFinder, cx: &mut ViewContext<Self>) {
1127        if let Some((worktree, entry)) = self.selected_entry(cx) {
1128            cx.reveal_path(&worktree.abs_path().join(&entry.path));
1129        }
1130    }
1131
1132    fn open_in_terminal(&mut self, _: &OpenInTerminal, cx: &mut ViewContext<Self>) {
1133        if let Some((worktree, entry)) = self.selected_entry(cx) {
1134            let path = worktree.abs_path().join(&entry.path);
1135            cx.dispatch_action(
1136                workspace::OpenTerminal {
1137                    working_directory: path,
1138                }
1139                .boxed_clone(),
1140            )
1141        }
1142    }
1143
1144    pub fn new_search_in_directory(
1145        &mut self,
1146        _: &NewSearchInDirectory,
1147        cx: &mut ViewContext<Self>,
1148    ) {
1149        if let Some((worktree, entry)) = self.selected_entry(cx) {
1150            if entry.is_dir() {
1151                let include_root = self.project.read(cx).visible_worktrees(cx).count() > 1;
1152                let dir_path = if include_root {
1153                    let mut full_path = PathBuf::from(worktree.root_name());
1154                    full_path.push(&entry.path);
1155                    Arc::from(full_path)
1156                } else {
1157                    entry.path.clone()
1158                };
1159
1160                self.workspace
1161                    .update(cx, |workspace, cx| {
1162                        search::ProjectSearchView::new_search_in_directory(
1163                            workspace, &dir_path, cx,
1164                        );
1165                    })
1166                    .ok();
1167            }
1168        }
1169    }
1170
1171    fn move_entry(
1172        &mut self,
1173        entry_to_move: ProjectEntryId,
1174        destination: ProjectEntryId,
1175        destination_is_file: bool,
1176        cx: &mut ViewContext<Self>,
1177    ) {
1178        let destination_worktree = self.project.update(cx, |project, cx| {
1179            let entry_path = project.path_for_entry(entry_to_move, cx)?;
1180            let destination_entry_path = project.path_for_entry(destination, cx)?.path.clone();
1181
1182            let mut destination_path = destination_entry_path.as_ref();
1183            if destination_is_file {
1184                destination_path = destination_path.parent()?;
1185            }
1186
1187            let mut new_path = destination_path.to_path_buf();
1188            new_path.push(entry_path.path.file_name()?);
1189            if new_path != entry_path.path.as_ref() {
1190                let task = project.rename_entry(entry_to_move, new_path, cx);
1191                cx.foreground_executor().spawn(task).detach_and_log_err(cx);
1192            }
1193
1194            project.worktree_id_for_entry(destination, cx)
1195        });
1196
1197        if let Some(destination_worktree) = destination_worktree {
1198            self.expand_entry(destination_worktree, destination, cx);
1199        }
1200    }
1201
1202    fn index_for_selection(&self, selection: Selection) -> Option<(usize, usize, usize)> {
1203        let mut entry_index = 0;
1204        let mut visible_entries_index = 0;
1205        for (worktree_index, (worktree_id, worktree_entries)) in
1206            self.visible_entries.iter().enumerate()
1207        {
1208            if *worktree_id == selection.worktree_id {
1209                for entry in worktree_entries {
1210                    if entry.id == selection.entry_id {
1211                        return Some((worktree_index, entry_index, visible_entries_index));
1212                    } else {
1213                        visible_entries_index += 1;
1214                        entry_index += 1;
1215                    }
1216                }
1217                break;
1218            } else {
1219                visible_entries_index += worktree_entries.len();
1220            }
1221        }
1222        None
1223    }
1224
1225    pub fn selected_entry<'a>(
1226        &self,
1227        cx: &'a AppContext,
1228    ) -> Option<(&'a Worktree, &'a project::Entry)> {
1229        let (worktree, entry) = self.selected_entry_handle(cx)?;
1230        Some((worktree.read(cx), entry))
1231    }
1232
1233    fn selected_entry_handle<'a>(
1234        &self,
1235        cx: &'a AppContext,
1236    ) -> Option<(Model<Worktree>, &'a project::Entry)> {
1237        let selection = self.selection?;
1238        let project = self.project.read(cx);
1239        let worktree = project.worktree_for_id(selection.worktree_id, cx)?;
1240        let entry = worktree.read(cx).entry_for_id(selection.entry_id)?;
1241        Some((worktree, entry))
1242    }
1243
1244    fn expand_to_selection(&mut self, cx: &mut ViewContext<Self>) -> Option<()> {
1245        let (worktree, entry) = self.selected_entry(cx)?;
1246        let expanded_dir_ids = self.expanded_dir_ids.entry(worktree.id()).or_default();
1247
1248        for path in entry.path.ancestors() {
1249            let Some(entry) = worktree.entry_for_path(path) else {
1250                continue;
1251            };
1252            if entry.is_dir() {
1253                if let Err(idx) = expanded_dir_ids.binary_search(&entry.id) {
1254                    expanded_dir_ids.insert(idx, entry.id);
1255                }
1256            }
1257        }
1258
1259        Some(())
1260    }
1261
1262    fn update_visible_entries(
1263        &mut self,
1264        new_selected_entry: Option<(WorktreeId, ProjectEntryId)>,
1265        cx: &mut ViewContext<Self>,
1266    ) {
1267        let auto_collapse_dirs = ProjectPanelSettings::get_global(cx).auto_fold_dirs;
1268        let project = self.project.read(cx);
1269        self.last_worktree_root_id = project
1270            .visible_worktrees(cx)
1271            .rev()
1272            .next()
1273            .and_then(|worktree| worktree.read(cx).root_entry())
1274            .map(|entry| entry.id);
1275
1276        self.visible_entries.clear();
1277        for worktree in project.visible_worktrees(cx) {
1278            let snapshot = worktree.read(cx).snapshot();
1279            let worktree_id = snapshot.id();
1280
1281            let expanded_dir_ids = match self.expanded_dir_ids.entry(worktree_id) {
1282                hash_map::Entry::Occupied(e) => e.into_mut(),
1283                hash_map::Entry::Vacant(e) => {
1284                    // The first time a worktree's root entry becomes available,
1285                    // mark that root entry as expanded.
1286                    if let Some(entry) = snapshot.root_entry() {
1287                        e.insert(vec![entry.id]).as_slice()
1288                    } else {
1289                        &[]
1290                    }
1291                }
1292            };
1293
1294            let mut new_entry_parent_id = None;
1295            let mut new_entry_kind = EntryKind::Dir;
1296            if let Some(edit_state) = &self.edit_state {
1297                if edit_state.worktree_id == worktree_id && edit_state.is_new_entry {
1298                    new_entry_parent_id = Some(edit_state.entry_id);
1299                    new_entry_kind = if edit_state.is_dir {
1300                        EntryKind::Dir
1301                    } else {
1302                        EntryKind::File(Default::default())
1303                    };
1304                }
1305            }
1306
1307            let mut visible_worktree_entries = Vec::new();
1308            let mut entry_iter = snapshot.entries(true);
1309            while let Some(entry) = entry_iter.entry() {
1310                if auto_collapse_dirs
1311                    && entry.kind.is_dir()
1312                    && !self.unfolded_dir_ids.contains(&entry.id)
1313                {
1314                    if let Some(root_path) = snapshot.root_entry() {
1315                        let mut child_entries = snapshot.child_entries(&entry.path);
1316                        if let Some(child) = child_entries.next() {
1317                            if entry.path != root_path.path
1318                                && child_entries.next().is_none()
1319                                && child.kind.is_dir()
1320                            {
1321                                entry_iter.advance();
1322                                continue;
1323                            }
1324                        }
1325                    }
1326                }
1327
1328                visible_worktree_entries.push(entry.clone());
1329                if Some(entry.id) == new_entry_parent_id {
1330                    visible_worktree_entries.push(Entry {
1331                        id: NEW_ENTRY_ID,
1332                        kind: new_entry_kind,
1333                        path: entry.path.join("\0").into(),
1334                        inode: 0,
1335                        mtime: entry.mtime,
1336                        is_symlink: false,
1337                        is_ignored: entry.is_ignored,
1338                        is_external: false,
1339                        is_private: false,
1340                        git_status: entry.git_status,
1341                    });
1342                }
1343                if expanded_dir_ids.binary_search(&entry.id).is_err()
1344                    && entry_iter.advance_to_sibling()
1345                {
1346                    continue;
1347                }
1348                entry_iter.advance();
1349            }
1350
1351            snapshot.propagate_git_statuses(&mut visible_worktree_entries);
1352
1353            visible_worktree_entries.sort_by(|entry_a, entry_b| {
1354                let mut components_a = entry_a.path.components().peekable();
1355                let mut components_b = entry_b.path.components().peekable();
1356                loop {
1357                    match (components_a.next(), components_b.next()) {
1358                        (Some(component_a), Some(component_b)) => {
1359                            let a_is_file = components_a.peek().is_none() && entry_a.is_file();
1360                            let b_is_file = components_b.peek().is_none() && entry_b.is_file();
1361                            let ordering = a_is_file.cmp(&b_is_file).then_with(|| {
1362                                let maybe_numeric_ordering = maybe!({
1363                                    let num_and_remainder_a = Path::new(component_a.as_os_str())
1364                                        .file_stem()
1365                                        .and_then(|s| s.to_str())
1366                                        .and_then(
1367                                            NumericPrefixWithSuffix::from_numeric_prefixed_str,
1368                                        )?;
1369                                    let num_and_remainder_b = Path::new(component_b.as_os_str())
1370                                        .file_stem()
1371                                        .and_then(|s| s.to_str())
1372                                        .and_then(
1373                                            NumericPrefixWithSuffix::from_numeric_prefixed_str,
1374                                        )?;
1375
1376                                    num_and_remainder_a.partial_cmp(&num_and_remainder_b)
1377                                });
1378
1379                                maybe_numeric_ordering.unwrap_or_else(|| {
1380                                    let name_a =
1381                                        UniCase::new(component_a.as_os_str().to_string_lossy());
1382                                    let name_b =
1383                                        UniCase::new(component_b.as_os_str().to_string_lossy());
1384
1385                                    name_a.cmp(&name_b)
1386                                })
1387                            });
1388                            if !ordering.is_eq() {
1389                                return ordering;
1390                            }
1391                        }
1392                        (Some(_), None) => break Ordering::Greater,
1393                        (None, Some(_)) => break Ordering::Less,
1394                        (None, None) => break Ordering::Equal,
1395                    }
1396                }
1397            });
1398            self.visible_entries
1399                .push((worktree_id, visible_worktree_entries));
1400        }
1401
1402        if let Some((worktree_id, entry_id)) = new_selected_entry {
1403            self.selection = Some(Selection {
1404                worktree_id,
1405                entry_id,
1406            });
1407        }
1408    }
1409
1410    fn expand_entry(
1411        &mut self,
1412        worktree_id: WorktreeId,
1413        entry_id: ProjectEntryId,
1414        cx: &mut ViewContext<Self>,
1415    ) {
1416        self.project.update(cx, |project, cx| {
1417            if let Some((worktree, expanded_dir_ids)) = project
1418                .worktree_for_id(worktree_id, cx)
1419                .zip(self.expanded_dir_ids.get_mut(&worktree_id))
1420            {
1421                project.expand_entry(worktree_id, entry_id, cx);
1422                let worktree = worktree.read(cx);
1423
1424                if let Some(mut entry) = worktree.entry_for_id(entry_id) {
1425                    loop {
1426                        if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
1427                            expanded_dir_ids.insert(ix, entry.id);
1428                        }
1429
1430                        if let Some(parent_entry) =
1431                            entry.path.parent().and_then(|p| worktree.entry_for_path(p))
1432                        {
1433                            entry = parent_entry;
1434                        } else {
1435                            break;
1436                        }
1437                    }
1438                }
1439            }
1440        });
1441    }
1442
1443    fn for_each_visible_entry(
1444        &self,
1445        range: Range<usize>,
1446        cx: &mut ViewContext<ProjectPanel>,
1447        mut callback: impl FnMut(ProjectEntryId, EntryDetails, &mut ViewContext<ProjectPanel>),
1448    ) {
1449        let mut ix = 0;
1450        for (worktree_id, visible_worktree_entries) in &self.visible_entries {
1451            if ix >= range.end {
1452                return;
1453            }
1454
1455            if ix + visible_worktree_entries.len() <= range.start {
1456                ix += visible_worktree_entries.len();
1457                continue;
1458            }
1459
1460            let end_ix = range.end.min(ix + visible_worktree_entries.len());
1461            let (git_status_setting, show_file_icons, show_folder_icons) = {
1462                let settings = ProjectPanelSettings::get_global(cx);
1463                (
1464                    settings.git_status,
1465                    settings.file_icons,
1466                    settings.folder_icons,
1467                )
1468            };
1469            if let Some(worktree) = self.project.read(cx).worktree_for_id(*worktree_id, cx) {
1470                let snapshot = worktree.read(cx).snapshot();
1471                let root_name = OsStr::new(snapshot.root_name());
1472                let expanded_entry_ids = self
1473                    .expanded_dir_ids
1474                    .get(&snapshot.id())
1475                    .map(Vec::as_slice)
1476                    .unwrap_or(&[]);
1477
1478                let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
1479                for entry in visible_worktree_entries[entry_range].iter() {
1480                    let status = git_status_setting.then(|| entry.git_status).flatten();
1481                    let is_expanded = expanded_entry_ids.binary_search(&entry.id).is_ok();
1482                    let icon = match entry.kind {
1483                        EntryKind::File(_) => {
1484                            if show_file_icons {
1485                                FileIcons::get_icon(&entry.path, cx)
1486                            } else {
1487                                None
1488                            }
1489                        }
1490                        _ => {
1491                            if show_folder_icons {
1492                                FileIcons::get_folder_icon(is_expanded, cx)
1493                            } else {
1494                                FileIcons::get_chevron_icon(is_expanded, cx)
1495                            }
1496                        }
1497                    };
1498
1499                    let (depth, difference) = ProjectPanel::calculate_depth_and_difference(
1500                        entry,
1501                        visible_worktree_entries,
1502                    );
1503
1504                    let filename = match difference {
1505                        diff if diff > 1 => entry
1506                            .path
1507                            .iter()
1508                            .skip(entry.path.components().count() - diff)
1509                            .collect::<PathBuf>()
1510                            .to_str()
1511                            .unwrap_or_default()
1512                            .to_string(),
1513                        _ => entry
1514                            .path
1515                            .file_name()
1516                            .map(|name| name.to_string_lossy().into_owned())
1517                            .unwrap_or_else(|| root_name.to_string_lossy().to_string()),
1518                    };
1519
1520                    let mut details = EntryDetails {
1521                        filename,
1522                        icon,
1523                        path: entry.path.clone(),
1524                        depth,
1525                        kind: entry.kind,
1526                        is_ignored: entry.is_ignored,
1527                        is_expanded,
1528                        is_selected: self.selection.map_or(false, |e| {
1529                            e.worktree_id == snapshot.id() && e.entry_id == entry.id
1530                        }),
1531                        is_editing: false,
1532                        is_processing: false,
1533                        is_cut: self
1534                            .clipboard_entry
1535                            .map_or(false, |e| e.is_cut() && e.entry_id() == entry.id),
1536                        git_status: status,
1537                        is_dotenv: entry.is_private,
1538                    };
1539
1540                    if let Some(edit_state) = &self.edit_state {
1541                        let is_edited_entry = if edit_state.is_new_entry {
1542                            entry.id == NEW_ENTRY_ID
1543                        } else {
1544                            entry.id == edit_state.entry_id
1545                        };
1546
1547                        if is_edited_entry {
1548                            if let Some(processing_filename) = &edit_state.processing_filename {
1549                                details.is_processing = true;
1550                                details.filename.clear();
1551                                details.filename.push_str(processing_filename);
1552                            } else {
1553                                if edit_state.is_new_entry {
1554                                    details.filename.clear();
1555                                }
1556                                details.is_editing = true;
1557                            }
1558                        }
1559                    }
1560
1561                    callback(entry.id, details, cx);
1562                }
1563            }
1564            ix = end_ix;
1565        }
1566    }
1567
1568    fn calculate_depth_and_difference(
1569        entry: &Entry,
1570        visible_worktree_entries: &Vec<Entry>,
1571    ) -> (usize, usize) {
1572        let visible_worktree_paths: HashSet<Arc<Path>> = visible_worktree_entries
1573            .iter()
1574            .map(|e| e.path.clone())
1575            .collect();
1576
1577        let (depth, difference) = entry
1578            .path
1579            .ancestors()
1580            .skip(1) // Skip the entry itself
1581            .find_map(|ancestor| {
1582                if visible_worktree_paths.contains(ancestor) {
1583                    let parent_entry = visible_worktree_entries
1584                        .iter()
1585                        .find(|&e| &*e.path == ancestor)
1586                        .unwrap();
1587
1588                    let entry_path_components_count = entry.path.components().count();
1589                    let parent_path_components_count = parent_entry.path.components().count();
1590                    let difference = entry_path_components_count - parent_path_components_count;
1591                    let depth = parent_entry
1592                        .path
1593                        .ancestors()
1594                        .skip(1)
1595                        .filter(|ancestor| visible_worktree_paths.contains(*ancestor))
1596                        .count();
1597                    Some((depth + 1, difference))
1598                } else {
1599                    None
1600                }
1601            })
1602            .unwrap_or((0, 0));
1603
1604        (depth, difference)
1605    }
1606
1607    fn render_entry(
1608        &self,
1609        entry_id: ProjectEntryId,
1610        details: EntryDetails,
1611        cx: &mut ViewContext<Self>,
1612    ) -> Stateful<Div> {
1613        let kind = details.kind;
1614        let settings = ProjectPanelSettings::get_global(cx);
1615        let show_editor = details.is_editing && !details.is_processing;
1616        let is_selected = self
1617            .selection
1618            .map_or(false, |selection| selection.entry_id == entry_id);
1619        let width = self.size(cx);
1620        let filename_text_color =
1621            entry_git_aware_label_color(details.git_status, details.is_ignored, is_selected);
1622        let file_name = details.filename.clone();
1623        let icon = details.icon.clone();
1624        let depth = details.depth;
1625        div()
1626            .id(entry_id.to_proto() as usize)
1627            .on_drag(entry_id, move |entry_id, cx| {
1628                cx.new_view(|_| DraggedProjectEntryView {
1629                    details: details.clone(),
1630                    width,
1631                    entry_id: *entry_id,
1632                })
1633            })
1634            .drag_over::<ProjectEntryId>(|style, _, cx| {
1635                style.bg(cx.theme().colors().drop_target_background)
1636            })
1637            .on_drop(cx.listener(move |this, dragged_id: &ProjectEntryId, cx| {
1638                this.move_entry(*dragged_id, entry_id, kind.is_file(), cx);
1639            }))
1640            .child(
1641                ListItem::new(entry_id.to_proto() as usize)
1642                    .indent_level(depth)
1643                    .indent_step_size(px(settings.indent_size))
1644                    .selected(is_selected)
1645                    .child(if let Some(icon) = &icon {
1646                        h_flex().child(Icon::from_path(icon.to_string()).color(filename_text_color))
1647                    } else {
1648                        h_flex()
1649                            .size(IconSize::default().rems())
1650                            .invisible()
1651                            .flex_none()
1652                    })
1653                    .child(
1654                        if let (Some(editor), true) = (Some(&self.filename_editor), show_editor) {
1655                            h_flex().h_6().w_full().child(editor.clone())
1656                        } else {
1657                            h_flex().h_6().child(
1658                                Label::new(file_name)
1659                                    .single_line()
1660                                    .color(filename_text_color),
1661                            )
1662                        }
1663                        .ml_1(),
1664                    )
1665                    .on_click(cx.listener(move |this, event: &gpui::ClickEvent, cx| {
1666                        if event.down.button == MouseButton::Right || event.down.first_mouse {
1667                            return;
1668                        }
1669                        if !show_editor {
1670                            if kind.is_dir() {
1671                                this.toggle_expanded(entry_id, cx);
1672                            } else {
1673                                if event.down.modifiers.secondary() {
1674                                    this.split_entry(entry_id, cx);
1675                                } else {
1676                                    let click_count = event.up.click_count;
1677                                    this.open_entry(
1678                                        entry_id,
1679                                        click_count > 1,
1680                                        click_count == 1,
1681                                        cx,
1682                                    );
1683                                }
1684                            }
1685                        }
1686                    }))
1687                    .on_secondary_mouse_down(cx.listener(
1688                        move |this, event: &MouseDownEvent, cx| {
1689                            // Stop propagation to prevent the catch-all context menu for the project
1690                            // panel from being deployed.
1691                            cx.stop_propagation();
1692                            this.deploy_context_menu(event.position, entry_id, cx);
1693                        },
1694                    )),
1695            )
1696    }
1697
1698    fn dispatch_context(&self, cx: &ViewContext<Self>) -> KeyContext {
1699        let mut dispatch_context = KeyContext::default();
1700        dispatch_context.add("ProjectPanel");
1701        dispatch_context.add("menu");
1702
1703        let identifier = if self.filename_editor.focus_handle(cx).is_focused(cx) {
1704            "editing"
1705        } else {
1706            "not_editing"
1707        };
1708
1709        dispatch_context.add(identifier);
1710        dispatch_context
1711    }
1712
1713    fn reveal_entry(
1714        &mut self,
1715        project: Model<Project>,
1716        entry_id: ProjectEntryId,
1717        skip_ignored: bool,
1718        cx: &mut ViewContext<'_, ProjectPanel>,
1719    ) {
1720        if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
1721            let worktree = worktree.read(cx);
1722            if skip_ignored
1723                && worktree
1724                    .entry_for_id(entry_id)
1725                    .map_or(true, |entry| entry.is_ignored)
1726            {
1727                return;
1728            }
1729
1730            let worktree_id = worktree.id();
1731            self.expand_entry(worktree_id, entry_id, cx);
1732            self.update_visible_entries(Some((worktree_id, entry_id)), cx);
1733            self.autoscroll(cx);
1734            cx.notify();
1735        }
1736    }
1737}
1738
1739impl Render for ProjectPanel {
1740    fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> impl IntoElement {
1741        let has_worktree = self.visible_entries.len() != 0;
1742        let project = self.project.read(cx);
1743
1744        if has_worktree {
1745            div()
1746                .id("project-panel")
1747                .size_full()
1748                .relative()
1749                .key_context(self.dispatch_context(cx))
1750                .on_action(cx.listener(Self::select_next))
1751                .on_action(cx.listener(Self::select_prev))
1752                .on_action(cx.listener(Self::expand_selected_entry))
1753                .on_action(cx.listener(Self::collapse_selected_entry))
1754                .on_action(cx.listener(Self::collapse_all_entries))
1755                .on_action(cx.listener(Self::open))
1756                .on_action(cx.listener(Self::open_permanent))
1757                .on_action(cx.listener(Self::confirm))
1758                .on_action(cx.listener(Self::cancel))
1759                .on_action(cx.listener(Self::copy_path))
1760                .on_action(cx.listener(Self::copy_relative_path))
1761                .on_action(cx.listener(Self::new_search_in_directory))
1762                .on_action(cx.listener(Self::unfold_directory))
1763                .on_action(cx.listener(Self::fold_directory))
1764                .when(!project.is_read_only(), |el| {
1765                    el.on_action(cx.listener(Self::new_file))
1766                        .on_action(cx.listener(Self::new_directory))
1767                        .on_action(cx.listener(Self::rename))
1768                        .on_action(cx.listener(Self::delete))
1769                        .on_action(cx.listener(Self::cut))
1770                        .on_action(cx.listener(Self::copy))
1771                        .on_action(cx.listener(Self::paste))
1772                })
1773                .when(project.is_local(), |el| {
1774                    el.on_action(cx.listener(Self::reveal_in_finder))
1775                        .on_action(cx.listener(Self::open_in_terminal))
1776                })
1777                .on_mouse_down(
1778                    MouseButton::Right,
1779                    cx.listener(move |this, event: &MouseDownEvent, cx| {
1780                        // When deploying the context menu anywhere below the last project entry,
1781                        // act as if the user clicked the root of the last worktree.
1782                        if let Some(entry_id) = this.last_worktree_root_id {
1783                            this.deploy_context_menu(event.position, entry_id, cx);
1784                        }
1785                    }),
1786                )
1787                .track_focus(&self.focus_handle)
1788                .child(
1789                    uniform_list(
1790                        cx.view().clone(),
1791                        "entries",
1792                        self.visible_entries
1793                            .iter()
1794                            .map(|(_, worktree_entries)| worktree_entries.len())
1795                            .sum(),
1796                        {
1797                            |this, range, cx| {
1798                                let mut items = Vec::new();
1799                                this.for_each_visible_entry(range, cx, |id, details, cx| {
1800                                    items.push(this.render_entry(id, details, cx));
1801                                });
1802                                items
1803                            }
1804                        },
1805                    )
1806                    .size_full()
1807                    .track_scroll(self.scroll_handle.clone()),
1808                )
1809                .children(self.context_menu.as_ref().map(|(menu, position, _)| {
1810                    deferred(
1811                        anchored()
1812                            .position(*position)
1813                            .anchor(gpui::AnchorCorner::TopLeft)
1814                            .child(menu.clone()),
1815                    )
1816                    .with_priority(1)
1817                }))
1818        } else {
1819            v_flex()
1820                .id("empty-project_panel")
1821                .size_full()
1822                .p_4()
1823                .track_focus(&self.focus_handle)
1824                .child(
1825                    Button::new("open_project", "Open a project")
1826                        .style(ButtonStyle::Filled)
1827                        .full_width()
1828                        .key_binding(KeyBinding::for_action(&workspace::Open, cx))
1829                        .on_click(cx.listener(|this, _, cx| {
1830                            this.workspace
1831                                .update(cx, |workspace, cx| workspace.open(&workspace::Open, cx))
1832                                .log_err();
1833                        })),
1834                )
1835        }
1836    }
1837}
1838
1839impl Render for DraggedProjectEntryView {
1840    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl Element {
1841        let settings = ProjectPanelSettings::get_global(cx);
1842        let ui_font = ThemeSettings::get_global(cx).ui_font.family.clone();
1843        h_flex()
1844            .font(ui_font)
1845            .bg(cx.theme().colors().background)
1846            .w(self.width)
1847            .child(
1848                ListItem::new(self.entry_id.to_proto() as usize)
1849                    .indent_level(self.details.depth)
1850                    .indent_step_size(px(settings.indent_size))
1851                    .child(if let Some(icon) = &self.details.icon {
1852                        div().child(Icon::from_path(icon.to_string()))
1853                    } else {
1854                        div()
1855                    })
1856                    .child(Label::new(self.details.filename.clone())),
1857            )
1858    }
1859}
1860
1861impl EventEmitter<Event> for ProjectPanel {}
1862
1863impl EventEmitter<PanelEvent> for ProjectPanel {}
1864
1865impl Panel for ProjectPanel {
1866    fn position(&self, cx: &WindowContext) -> DockPosition {
1867        match ProjectPanelSettings::get_global(cx).dock {
1868            ProjectPanelDockPosition::Left => DockPosition::Left,
1869            ProjectPanelDockPosition::Right => DockPosition::Right,
1870        }
1871    }
1872
1873    fn position_is_valid(&self, position: DockPosition) -> bool {
1874        matches!(position, DockPosition::Left | DockPosition::Right)
1875    }
1876
1877    fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
1878        settings::update_settings_file::<ProjectPanelSettings>(
1879            self.fs.clone(),
1880            cx,
1881            move |settings| {
1882                let dock = match position {
1883                    DockPosition::Left | DockPosition::Bottom => ProjectPanelDockPosition::Left,
1884                    DockPosition::Right => ProjectPanelDockPosition::Right,
1885                };
1886                settings.dock = Some(dock);
1887            },
1888        );
1889    }
1890
1891    fn size(&self, cx: &WindowContext) -> Pixels {
1892        self.width
1893            .unwrap_or_else(|| ProjectPanelSettings::get_global(cx).default_width)
1894    }
1895
1896    fn set_size(&mut self, size: Option<Pixels>, cx: &mut ViewContext<Self>) {
1897        self.width = size;
1898        self.serialize(cx);
1899        cx.notify();
1900    }
1901
1902    fn icon(&self, cx: &WindowContext) -> Option<IconName> {
1903        ProjectPanelSettings::get_global(cx)
1904            .button
1905            .then(|| IconName::FileTree)
1906    }
1907
1908    fn icon_tooltip(&self, _cx: &WindowContext) -> Option<&'static str> {
1909        Some("Project Panel")
1910    }
1911
1912    fn toggle_action(&self) -> Box<dyn Action> {
1913        Box::new(ToggleFocus)
1914    }
1915
1916    fn persistent_name() -> &'static str {
1917        "Project Panel"
1918    }
1919
1920    fn starts_open(&self, cx: &WindowContext) -> bool {
1921        self.project.read(cx).visible_worktrees(cx).any(|tree| {
1922            tree.read(cx)
1923                .root_entry()
1924                .map_or(false, |entry| entry.is_dir())
1925        })
1926    }
1927}
1928
1929impl FocusableView for ProjectPanel {
1930    fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
1931        self.focus_handle.clone()
1932    }
1933}
1934
1935impl ClipboardEntry {
1936    fn is_cut(&self) -> bool {
1937        matches!(self, Self::Cut { .. })
1938    }
1939
1940    fn entry_id(&self) -> ProjectEntryId {
1941        match self {
1942            ClipboardEntry::Copied { entry_id, .. } | ClipboardEntry::Cut { entry_id, .. } => {
1943                *entry_id
1944            }
1945        }
1946    }
1947
1948    fn worktree_id(&self) -> WorktreeId {
1949        match self {
1950            ClipboardEntry::Copied { worktree_id, .. }
1951            | ClipboardEntry::Cut { worktree_id, .. } => *worktree_id,
1952        }
1953    }
1954}
1955
1956#[cfg(test)]
1957mod tests {
1958    use super::*;
1959    use collections::HashSet;
1960    use gpui::{TestAppContext, View, VisualTestContext, WindowHandle};
1961    use pretty_assertions::assert_eq;
1962    use project::{FakeFs, WorktreeSettings};
1963    use serde_json::json;
1964    use settings::SettingsStore;
1965    use std::path::{Path, PathBuf};
1966    use workspace::AppState;
1967
1968    #[gpui::test]
1969    async fn test_visible_list(cx: &mut gpui::TestAppContext) {
1970        init_test(cx);
1971
1972        let fs = FakeFs::new(cx.executor().clone());
1973        fs.insert_tree(
1974            "/root1",
1975            json!({
1976                ".dockerignore": "",
1977                ".git": {
1978                    "HEAD": "",
1979                },
1980                "a": {
1981                    "0": { "q": "", "r": "", "s": "" },
1982                    "1": { "t": "", "u": "" },
1983                    "2": { "v": "", "w": "", "x": "", "y": "" },
1984                },
1985                "b": {
1986                    "3": { "Q": "" },
1987                    "4": { "R": "", "S": "", "T": "", "U": "" },
1988                },
1989                "C": {
1990                    "5": {},
1991                    "6": { "V": "", "W": "" },
1992                    "7": { "X": "" },
1993                    "8": { "Y": {}, "Z": "" }
1994                }
1995            }),
1996        )
1997        .await;
1998        fs.insert_tree(
1999            "/root2",
2000            json!({
2001                "d": {
2002                    "9": ""
2003                },
2004                "e": {}
2005            }),
2006        )
2007        .await;
2008
2009        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
2010        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2011        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2012        let panel = workspace
2013            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2014            .unwrap();
2015        assert_eq!(
2016            visible_entries_as_strings(&panel, 0..50, cx),
2017            &[
2018                "v root1",
2019                "    > .git",
2020                "    > a",
2021                "    > b",
2022                "    > C",
2023                "      .dockerignore",
2024                "v root2",
2025                "    > d",
2026                "    > e",
2027            ]
2028        );
2029
2030        toggle_expand_dir(&panel, "root1/b", cx);
2031        assert_eq!(
2032            visible_entries_as_strings(&panel, 0..50, cx),
2033            &[
2034                "v root1",
2035                "    > .git",
2036                "    > a",
2037                "    v b  <== selected",
2038                "        > 3",
2039                "        > 4",
2040                "    > C",
2041                "      .dockerignore",
2042                "v root2",
2043                "    > d",
2044                "    > e",
2045            ]
2046        );
2047
2048        assert_eq!(
2049            visible_entries_as_strings(&panel, 6..9, cx),
2050            &[
2051                //
2052                "    > C",
2053                "      .dockerignore",
2054                "v root2",
2055            ]
2056        );
2057    }
2058
2059    #[gpui::test]
2060    async fn test_exclusions_in_visible_list(cx: &mut gpui::TestAppContext) {
2061        init_test(cx);
2062        cx.update(|cx| {
2063            cx.update_global::<SettingsStore, _>(|store, cx| {
2064                store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
2065                    worktree_settings.file_scan_exclusions =
2066                        Some(vec!["**/.git".to_string(), "**/4/**".to_string()]);
2067                });
2068            });
2069        });
2070
2071        let fs = FakeFs::new(cx.background_executor.clone());
2072        fs.insert_tree(
2073            "/root1",
2074            json!({
2075                ".dockerignore": "",
2076                ".git": {
2077                    "HEAD": "",
2078                },
2079                "a": {
2080                    "0": { "q": "", "r": "", "s": "" },
2081                    "1": { "t": "", "u": "" },
2082                    "2": { "v": "", "w": "", "x": "", "y": "" },
2083                },
2084                "b": {
2085                    "3": { "Q": "" },
2086                    "4": { "R": "", "S": "", "T": "", "U": "" },
2087                },
2088                "C": {
2089                    "5": {},
2090                    "6": { "V": "", "W": "" },
2091                    "7": { "X": "" },
2092                    "8": { "Y": {}, "Z": "" }
2093                }
2094            }),
2095        )
2096        .await;
2097        fs.insert_tree(
2098            "/root2",
2099            json!({
2100                "d": {
2101                    "4": ""
2102                },
2103                "e": {}
2104            }),
2105        )
2106        .await;
2107
2108        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
2109        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2110        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2111        let panel = workspace
2112            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2113            .unwrap();
2114        assert_eq!(
2115            visible_entries_as_strings(&panel, 0..50, cx),
2116            &[
2117                "v root1",
2118                "    > a",
2119                "    > b",
2120                "    > C",
2121                "      .dockerignore",
2122                "v root2",
2123                "    > d",
2124                "    > e",
2125            ]
2126        );
2127
2128        toggle_expand_dir(&panel, "root1/b", cx);
2129        assert_eq!(
2130            visible_entries_as_strings(&panel, 0..50, cx),
2131            &[
2132                "v root1",
2133                "    > a",
2134                "    v b  <== selected",
2135                "        > 3",
2136                "    > C",
2137                "      .dockerignore",
2138                "v root2",
2139                "    > d",
2140                "    > e",
2141            ]
2142        );
2143
2144        toggle_expand_dir(&panel, "root2/d", cx);
2145        assert_eq!(
2146            visible_entries_as_strings(&panel, 0..50, cx),
2147            &[
2148                "v root1",
2149                "    > a",
2150                "    v b",
2151                "        > 3",
2152                "    > C",
2153                "      .dockerignore",
2154                "v root2",
2155                "    v d  <== selected",
2156                "    > e",
2157            ]
2158        );
2159
2160        toggle_expand_dir(&panel, "root2/e", cx);
2161        assert_eq!(
2162            visible_entries_as_strings(&panel, 0..50, cx),
2163            &[
2164                "v root1",
2165                "    > a",
2166                "    v b",
2167                "        > 3",
2168                "    > C",
2169                "      .dockerignore",
2170                "v root2",
2171                "    v d",
2172                "    v e  <== selected",
2173            ]
2174        );
2175    }
2176
2177    #[gpui::test]
2178    async fn test_auto_collapse_dir_paths(cx: &mut gpui::TestAppContext) {
2179        init_test(cx);
2180
2181        let fs = FakeFs::new(cx.executor().clone());
2182        fs.insert_tree(
2183            "/root1",
2184            json!({
2185                "dir_1": {
2186                    "nested_dir_1": {
2187                        "nested_dir_2": {
2188                            "nested_dir_3": {
2189                                "file_a.java": "// File contents",
2190                                "file_b.java": "// File contents",
2191                                "file_c.java": "// File contents",
2192                                "nested_dir_4": {
2193                                    "nested_dir_5": {
2194                                        "file_d.java": "// File contents",
2195                                    }
2196                                }
2197                            }
2198                        }
2199                    }
2200                }
2201            }),
2202        )
2203        .await;
2204        fs.insert_tree(
2205            "/root2",
2206            json!({
2207                "dir_2": {
2208                    "file_1.java": "// File contents",
2209                }
2210            }),
2211        )
2212        .await;
2213
2214        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
2215        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2216        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2217        cx.update(|cx| {
2218            let settings = *ProjectPanelSettings::get_global(cx);
2219            ProjectPanelSettings::override_global(
2220                ProjectPanelSettings {
2221                    auto_fold_dirs: true,
2222                    ..settings
2223                },
2224                cx,
2225            );
2226        });
2227        let panel = workspace
2228            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2229            .unwrap();
2230        assert_eq!(
2231            visible_entries_as_strings(&panel, 0..10, cx),
2232            &[
2233                "v root1",
2234                "    > dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
2235                "v root2",
2236                "    > dir_2",
2237            ]
2238        );
2239
2240        toggle_expand_dir(
2241            &panel,
2242            "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
2243            cx,
2244        );
2245        assert_eq!(
2246            visible_entries_as_strings(&panel, 0..10, cx),
2247            &[
2248                "v root1",
2249                "    v dir_1/nested_dir_1/nested_dir_2/nested_dir_3  <== selected",
2250                "        > nested_dir_4/nested_dir_5",
2251                "          file_a.java",
2252                "          file_b.java",
2253                "          file_c.java",
2254                "v root2",
2255                "    > dir_2",
2256            ]
2257        );
2258
2259        toggle_expand_dir(
2260            &panel,
2261            "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3/nested_dir_4/nested_dir_5",
2262            cx,
2263        );
2264        assert_eq!(
2265            visible_entries_as_strings(&panel, 0..10, cx),
2266            &[
2267                "v root1",
2268                "    v dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
2269                "        v nested_dir_4/nested_dir_5  <== selected",
2270                "              file_d.java",
2271                "          file_a.java",
2272                "          file_b.java",
2273                "          file_c.java",
2274                "v root2",
2275                "    > dir_2",
2276            ]
2277        );
2278        toggle_expand_dir(&panel, "root2/dir_2", cx);
2279        assert_eq!(
2280            visible_entries_as_strings(&panel, 0..10, cx),
2281            &[
2282                "v root1",
2283                "    v dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
2284                "        v nested_dir_4/nested_dir_5",
2285                "              file_d.java",
2286                "          file_a.java",
2287                "          file_b.java",
2288                "          file_c.java",
2289                "v root2",
2290                "    v dir_2  <== selected",
2291                "          file_1.java",
2292            ]
2293        );
2294    }
2295
2296    #[gpui::test(iterations = 30)]
2297    async fn test_editing_files(cx: &mut gpui::TestAppContext) {
2298        init_test(cx);
2299
2300        let fs = FakeFs::new(cx.executor().clone());
2301        fs.insert_tree(
2302            "/root1",
2303            json!({
2304                ".dockerignore": "",
2305                ".git": {
2306                    "HEAD": "",
2307                },
2308                "a": {
2309                    "0": { "q": "", "r": "", "s": "" },
2310                    "1": { "t": "", "u": "" },
2311                    "2": { "v": "", "w": "", "x": "", "y": "" },
2312                },
2313                "b": {
2314                    "3": { "Q": "" },
2315                    "4": { "R": "", "S": "", "T": "", "U": "" },
2316                },
2317                "C": {
2318                    "5": {},
2319                    "6": { "V": "", "W": "" },
2320                    "7": { "X": "" },
2321                    "8": { "Y": {}, "Z": "" }
2322                }
2323            }),
2324        )
2325        .await;
2326        fs.insert_tree(
2327            "/root2",
2328            json!({
2329                "d": {
2330                    "9": ""
2331                },
2332                "e": {}
2333            }),
2334        )
2335        .await;
2336
2337        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
2338        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2339        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2340        let panel = workspace
2341            .update(cx, |workspace, cx| {
2342                let panel = ProjectPanel::new(workspace, cx);
2343                workspace.add_panel(panel.clone(), cx);
2344                panel
2345            })
2346            .unwrap();
2347
2348        select_path(&panel, "root1", cx);
2349        assert_eq!(
2350            visible_entries_as_strings(&panel, 0..10, cx),
2351            &[
2352                "v root1  <== selected",
2353                "    > .git",
2354                "    > a",
2355                "    > b",
2356                "    > C",
2357                "      .dockerignore",
2358                "v root2",
2359                "    > d",
2360                "    > e",
2361            ]
2362        );
2363
2364        // Add a file with the root folder selected. The filename editor is placed
2365        // before the first file in the root folder.
2366        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2367        panel.update(cx, |panel, cx| {
2368            assert!(panel.filename_editor.read(cx).is_focused(cx));
2369        });
2370        assert_eq!(
2371            visible_entries_as_strings(&panel, 0..10, cx),
2372            &[
2373                "v root1",
2374                "    > .git",
2375                "    > a",
2376                "    > b",
2377                "    > C",
2378                "      [EDITOR: '']  <== selected",
2379                "      .dockerignore",
2380                "v root2",
2381                "    > d",
2382                "    > e",
2383            ]
2384        );
2385
2386        let confirm = panel.update(cx, |panel, cx| {
2387            panel
2388                .filename_editor
2389                .update(cx, |editor, cx| editor.set_text("the-new-filename", cx));
2390            panel.confirm_edit(cx).unwrap()
2391        });
2392        assert_eq!(
2393            visible_entries_as_strings(&panel, 0..10, cx),
2394            &[
2395                "v root1",
2396                "    > .git",
2397                "    > a",
2398                "    > b",
2399                "    > C",
2400                "      [PROCESSING: 'the-new-filename']  <== selected",
2401                "      .dockerignore",
2402                "v root2",
2403                "    > d",
2404                "    > e",
2405            ]
2406        );
2407
2408        confirm.await.unwrap();
2409        assert_eq!(
2410            visible_entries_as_strings(&panel, 0..10, cx),
2411            &[
2412                "v root1",
2413                "    > .git",
2414                "    > a",
2415                "    > b",
2416                "    > C",
2417                "      .dockerignore",
2418                "      the-new-filename  <== selected",
2419                "v root2",
2420                "    > d",
2421                "    > e",
2422            ]
2423        );
2424
2425        select_path(&panel, "root1/b", cx);
2426        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2427        assert_eq!(
2428            visible_entries_as_strings(&panel, 0..10, cx),
2429            &[
2430                "v root1",
2431                "    > .git",
2432                "    > a",
2433                "    v b",
2434                "        > 3",
2435                "        > 4",
2436                "          [EDITOR: '']  <== selected",
2437                "    > C",
2438                "      .dockerignore",
2439                "      the-new-filename",
2440            ]
2441        );
2442
2443        panel
2444            .update(cx, |panel, cx| {
2445                panel
2446                    .filename_editor
2447                    .update(cx, |editor, cx| editor.set_text("another-filename.txt", cx));
2448                panel.confirm_edit(cx).unwrap()
2449            })
2450            .await
2451            .unwrap();
2452        assert_eq!(
2453            visible_entries_as_strings(&panel, 0..10, cx),
2454            &[
2455                "v root1",
2456                "    > .git",
2457                "    > a",
2458                "    v b",
2459                "        > 3",
2460                "        > 4",
2461                "          another-filename.txt  <== selected",
2462                "    > C",
2463                "      .dockerignore",
2464                "      the-new-filename",
2465            ]
2466        );
2467
2468        select_path(&panel, "root1/b/another-filename.txt", cx);
2469        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2470        assert_eq!(
2471            visible_entries_as_strings(&panel, 0..10, cx),
2472            &[
2473                "v root1",
2474                "    > .git",
2475                "    > a",
2476                "    v b",
2477                "        > 3",
2478                "        > 4",
2479                "          [EDITOR: 'another-filename.txt']  <== selected",
2480                "    > C",
2481                "      .dockerignore",
2482                "      the-new-filename",
2483            ]
2484        );
2485
2486        let confirm = panel.update(cx, |panel, cx| {
2487            panel.filename_editor.update(cx, |editor, cx| {
2488                let file_name_selections = editor.selections.all::<usize>(cx);
2489                assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
2490                let file_name_selection = &file_name_selections[0];
2491                assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
2492                assert_eq!(file_name_selection.end, "another-filename".len(), "Should not select file extension");
2493
2494                editor.set_text("a-different-filename.tar.gz", cx)
2495            });
2496            panel.confirm_edit(cx).unwrap()
2497        });
2498        assert_eq!(
2499            visible_entries_as_strings(&panel, 0..10, cx),
2500            &[
2501                "v root1",
2502                "    > .git",
2503                "    > a",
2504                "    v b",
2505                "        > 3",
2506                "        > 4",
2507                "          [PROCESSING: 'a-different-filename.tar.gz']  <== selected",
2508                "    > C",
2509                "      .dockerignore",
2510                "      the-new-filename",
2511            ]
2512        );
2513
2514        confirm.await.unwrap();
2515        assert_eq!(
2516            visible_entries_as_strings(&panel, 0..10, cx),
2517            &[
2518                "v root1",
2519                "    > .git",
2520                "    > a",
2521                "    v b",
2522                "        > 3",
2523                "        > 4",
2524                "          a-different-filename.tar.gz  <== selected",
2525                "    > C",
2526                "      .dockerignore",
2527                "      the-new-filename",
2528            ]
2529        );
2530
2531        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2532        assert_eq!(
2533            visible_entries_as_strings(&panel, 0..10, cx),
2534            &[
2535                "v root1",
2536                "    > .git",
2537                "    > a",
2538                "    v b",
2539                "        > 3",
2540                "        > 4",
2541                "          [EDITOR: 'a-different-filename.tar.gz']  <== selected",
2542                "    > C",
2543                "      .dockerignore",
2544                "      the-new-filename",
2545            ]
2546        );
2547
2548        panel.update(cx, |panel, cx| {
2549            panel.filename_editor.update(cx, |editor, cx| {
2550                let file_name_selections = editor.selections.all::<usize>(cx);
2551                assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
2552                let file_name_selection = &file_name_selections[0];
2553                assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
2554                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..");
2555
2556            });
2557            panel.cancel(&Cancel, cx)
2558        });
2559
2560        panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2561        assert_eq!(
2562            visible_entries_as_strings(&panel, 0..10, cx),
2563            &[
2564                "v root1",
2565                "    > .git",
2566                "    > a",
2567                "    v b",
2568                "        > [EDITOR: '']  <== selected",
2569                "        > 3",
2570                "        > 4",
2571                "          a-different-filename.tar.gz",
2572                "    > C",
2573                "      .dockerignore",
2574            ]
2575        );
2576
2577        let confirm = panel.update(cx, |panel, cx| {
2578            panel
2579                .filename_editor
2580                .update(cx, |editor, cx| editor.set_text("new-dir", cx));
2581            panel.confirm_edit(cx).unwrap()
2582        });
2583        panel.update(cx, |panel, cx| panel.select_next(&Default::default(), cx));
2584        assert_eq!(
2585            visible_entries_as_strings(&panel, 0..10, cx),
2586            &[
2587                "v root1",
2588                "    > .git",
2589                "    > a",
2590                "    v b",
2591                "        > [PROCESSING: 'new-dir']",
2592                "        > 3  <== selected",
2593                "        > 4",
2594                "          a-different-filename.tar.gz",
2595                "    > C",
2596                "      .dockerignore",
2597            ]
2598        );
2599
2600        confirm.await.unwrap();
2601        assert_eq!(
2602            visible_entries_as_strings(&panel, 0..10, cx),
2603            &[
2604                "v root1",
2605                "    > .git",
2606                "    > a",
2607                "    v b",
2608                "        > 3  <== selected",
2609                "        > 4",
2610                "        > new-dir",
2611                "          a-different-filename.tar.gz",
2612                "    > C",
2613                "      .dockerignore",
2614            ]
2615        );
2616
2617        panel.update(cx, |panel, cx| panel.rename(&Default::default(), cx));
2618        assert_eq!(
2619            visible_entries_as_strings(&panel, 0..10, cx),
2620            &[
2621                "v root1",
2622                "    > .git",
2623                "    > a",
2624                "    v b",
2625                "        > [EDITOR: '3']  <== selected",
2626                "        > 4",
2627                "        > new-dir",
2628                "          a-different-filename.tar.gz",
2629                "    > C",
2630                "      .dockerignore",
2631            ]
2632        );
2633
2634        // Dismiss the rename editor when it loses focus.
2635        workspace.update(cx, |_, cx| cx.blur()).unwrap();
2636        assert_eq!(
2637            visible_entries_as_strings(&panel, 0..10, cx),
2638            &[
2639                "v root1",
2640                "    > .git",
2641                "    > a",
2642                "    v b",
2643                "        > 3  <== selected",
2644                "        > 4",
2645                "        > new-dir",
2646                "          a-different-filename.tar.gz",
2647                "    > C",
2648                "      .dockerignore",
2649            ]
2650        );
2651    }
2652
2653    #[gpui::test(iterations = 10)]
2654    async fn test_adding_directories_via_file(cx: &mut gpui::TestAppContext) {
2655        init_test(cx);
2656
2657        let fs = FakeFs::new(cx.executor().clone());
2658        fs.insert_tree(
2659            "/root1",
2660            json!({
2661                ".dockerignore": "",
2662                ".git": {
2663                    "HEAD": "",
2664                },
2665                "a": {
2666                    "0": { "q": "", "r": "", "s": "" },
2667                    "1": { "t": "", "u": "" },
2668                    "2": { "v": "", "w": "", "x": "", "y": "" },
2669                },
2670                "b": {
2671                    "3": { "Q": "" },
2672                    "4": { "R": "", "S": "", "T": "", "U": "" },
2673                },
2674                "C": {
2675                    "5": {},
2676                    "6": { "V": "", "W": "" },
2677                    "7": { "X": "" },
2678                    "8": { "Y": {}, "Z": "" }
2679                }
2680            }),
2681        )
2682        .await;
2683        fs.insert_tree(
2684            "/root2",
2685            json!({
2686                "d": {
2687                    "9": ""
2688                },
2689                "e": {}
2690            }),
2691        )
2692        .await;
2693
2694        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
2695        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2696        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2697        let panel = workspace
2698            .update(cx, |workspace, cx| {
2699                let panel = ProjectPanel::new(workspace, cx);
2700                workspace.add_panel(panel.clone(), cx);
2701                panel
2702            })
2703            .unwrap();
2704
2705        select_path(&panel, "root1", cx);
2706        assert_eq!(
2707            visible_entries_as_strings(&panel, 0..10, cx),
2708            &[
2709                "v root1  <== selected",
2710                "    > .git",
2711                "    > a",
2712                "    > b",
2713                "    > C",
2714                "      .dockerignore",
2715                "v root2",
2716                "    > d",
2717                "    > e",
2718            ]
2719        );
2720
2721        // Add a file with the root folder selected. The filename editor is placed
2722        // before the first file in the root folder.
2723        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2724        panel.update(cx, |panel, cx| {
2725            assert!(panel.filename_editor.read(cx).is_focused(cx));
2726        });
2727        assert_eq!(
2728            visible_entries_as_strings(&panel, 0..10, cx),
2729            &[
2730                "v root1",
2731                "    > .git",
2732                "    > a",
2733                "    > b",
2734                "    > C",
2735                "      [EDITOR: '']  <== selected",
2736                "      .dockerignore",
2737                "v root2",
2738                "    > d",
2739                "    > e",
2740            ]
2741        );
2742
2743        let confirm = panel.update(cx, |panel, cx| {
2744            panel.filename_editor.update(cx, |editor, cx| {
2745                editor.set_text("/bdir1/dir2/the-new-filename", cx)
2746            });
2747            panel.confirm_edit(cx).unwrap()
2748        });
2749
2750        assert_eq!(
2751            visible_entries_as_strings(&panel, 0..10, cx),
2752            &[
2753                "v root1",
2754                "    > .git",
2755                "    > a",
2756                "    > b",
2757                "    > C",
2758                "      [PROCESSING: '/bdir1/dir2/the-new-filename']  <== selected",
2759                "      .dockerignore",
2760                "v root2",
2761                "    > d",
2762                "    > e",
2763            ]
2764        );
2765
2766        confirm.await.unwrap();
2767        assert_eq!(
2768            visible_entries_as_strings(&panel, 0..13, cx),
2769            &[
2770                "v root1",
2771                "    > .git",
2772                "    > a",
2773                "    > b",
2774                "    v bdir1",
2775                "        v dir2",
2776                "              the-new-filename  <== selected",
2777                "    > C",
2778                "      .dockerignore",
2779                "v root2",
2780                "    > d",
2781                "    > e",
2782            ]
2783        );
2784    }
2785
2786    #[gpui::test]
2787    async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
2788        init_test(cx);
2789
2790        let fs = FakeFs::new(cx.executor().clone());
2791        fs.insert_tree(
2792            "/root1",
2793            json!({
2794                "one.two.txt": "",
2795                "one.txt": ""
2796            }),
2797        )
2798        .await;
2799
2800        let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
2801        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2802        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2803        let panel = workspace
2804            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2805            .unwrap();
2806
2807        panel.update(cx, |panel, cx| {
2808            panel.select_next(&Default::default(), cx);
2809            panel.select_next(&Default::default(), cx);
2810        });
2811
2812        assert_eq!(
2813            visible_entries_as_strings(&panel, 0..50, cx),
2814            &[
2815                //
2816                "v root1",
2817                "      one.two.txt  <== selected",
2818                "      one.txt",
2819            ]
2820        );
2821
2822        // Regression test - file name is created correctly when
2823        // the copied file's name contains multiple dots.
2824        panel.update(cx, |panel, cx| {
2825            panel.copy(&Default::default(), cx);
2826            panel.paste(&Default::default(), cx);
2827        });
2828        cx.executor().run_until_parked();
2829
2830        assert_eq!(
2831            visible_entries_as_strings(&panel, 0..50, cx),
2832            &[
2833                //
2834                "v root1",
2835                "      one.two copy.txt",
2836                "      one.two.txt  <== selected",
2837                "      one.txt",
2838            ]
2839        );
2840
2841        panel.update(cx, |panel, cx| {
2842            panel.paste(&Default::default(), cx);
2843        });
2844        cx.executor().run_until_parked();
2845
2846        assert_eq!(
2847            visible_entries_as_strings(&panel, 0..50, cx),
2848            &[
2849                //
2850                "v root1",
2851                "      one.two copy 1.txt",
2852                "      one.two copy.txt",
2853                "      one.two.txt  <== selected",
2854                "      one.txt",
2855            ]
2856        );
2857    }
2858
2859    #[gpui::test]
2860    async fn test_copy_paste_directory(cx: &mut gpui::TestAppContext) {
2861        init_test(cx);
2862
2863        let fs = FakeFs::new(cx.executor().clone());
2864        fs.insert_tree(
2865            "/root",
2866            json!({
2867                "a": {
2868                    "one.txt": "",
2869                    "two.txt": "",
2870                    "inner_dir": {
2871                        "three.txt": "",
2872                        "four.txt": "",
2873                    }
2874                },
2875                "b": {}
2876            }),
2877        )
2878        .await;
2879
2880        let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
2881        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2882        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2883        let panel = workspace
2884            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2885            .unwrap();
2886
2887        select_path(&panel, "root/a", cx);
2888        panel.update(cx, |panel, cx| {
2889            panel.copy(&Default::default(), cx);
2890            panel.select_next(&Default::default(), cx);
2891            panel.paste(&Default::default(), cx);
2892        });
2893        cx.executor().run_until_parked();
2894
2895        let pasted_dir = find_project_entry(&panel, "root/b/a", cx);
2896        assert_ne!(pasted_dir, None, "Pasted directory should have an entry");
2897
2898        let pasted_dir_file = find_project_entry(&panel, "root/b/a/one.txt", cx);
2899        assert_ne!(
2900            pasted_dir_file, None,
2901            "Pasted directory file should have an entry"
2902        );
2903
2904        let pasted_dir_inner_dir = find_project_entry(&panel, "root/b/a/inner_dir", cx);
2905        assert_ne!(
2906            pasted_dir_inner_dir, None,
2907            "Directories inside pasted directory should have an entry"
2908        );
2909
2910        toggle_expand_dir(&panel, "root/b", cx);
2911        toggle_expand_dir(&panel, "root/b/a", cx);
2912        toggle_expand_dir(&panel, "root/b/a/inner_dir", cx);
2913
2914        assert_eq!(
2915            visible_entries_as_strings(&panel, 0..50, cx),
2916            &[
2917                //
2918                "v root",
2919                "    > a",
2920                "    v b",
2921                "        v a",
2922                "            v inner_dir  <== selected",
2923                "                  four.txt",
2924                "                  three.txt",
2925                "              one.txt",
2926                "              two.txt",
2927            ]
2928        );
2929
2930        select_path(&panel, "root", cx);
2931        panel.update(cx, |panel, cx| panel.paste(&Default::default(), cx));
2932        cx.executor().run_until_parked();
2933        panel.update(cx, |panel, cx| panel.paste(&Default::default(), cx));
2934        cx.executor().run_until_parked();
2935        assert_eq!(
2936            visible_entries_as_strings(&panel, 0..50, cx),
2937            &[
2938                //
2939                "v root  <== selected",
2940                "    > a",
2941                "    > a copy",
2942                "    > a copy 1",
2943                "    v b",
2944                "        v a",
2945                "            v inner_dir",
2946                "                  four.txt",
2947                "                  three.txt",
2948                "              one.txt",
2949                "              two.txt"
2950            ]
2951        );
2952    }
2953
2954    #[gpui::test]
2955    async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
2956        init_test_with_editor(cx);
2957
2958        let fs = FakeFs::new(cx.executor().clone());
2959        fs.insert_tree(
2960            "/src",
2961            json!({
2962                "test": {
2963                    "first.rs": "// First Rust file",
2964                    "second.rs": "// Second Rust file",
2965                    "third.rs": "// Third Rust file",
2966                }
2967            }),
2968        )
2969        .await;
2970
2971        let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2972        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2973        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2974        let panel = workspace
2975            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2976            .unwrap();
2977
2978        toggle_expand_dir(&panel, "src/test", cx);
2979        select_path(&panel, "src/test/first.rs", cx);
2980        panel.update(cx, |panel, cx| panel.open(&Open, cx));
2981        cx.executor().run_until_parked();
2982        assert_eq!(
2983            visible_entries_as_strings(&panel, 0..10, cx),
2984            &[
2985                "v src",
2986                "    v test",
2987                "          first.rs  <== selected",
2988                "          second.rs",
2989                "          third.rs"
2990            ]
2991        );
2992        ensure_single_file_is_opened(&workspace, "test/first.rs", cx);
2993
2994        submit_deletion(&panel, cx);
2995        assert_eq!(
2996            visible_entries_as_strings(&panel, 0..10, cx),
2997            &[
2998                "v src",
2999                "    v test",
3000                "          second.rs",
3001                "          third.rs"
3002            ],
3003            "Project panel should have no deleted file, no other file is selected in it"
3004        );
3005        ensure_no_open_items_and_panes(&workspace, cx);
3006
3007        select_path(&panel, "src/test/second.rs", cx);
3008        panel.update(cx, |panel, cx| panel.open(&Open, cx));
3009        cx.executor().run_until_parked();
3010        assert_eq!(
3011            visible_entries_as_strings(&panel, 0..10, cx),
3012            &[
3013                "v src",
3014                "    v test",
3015                "          second.rs  <== selected",
3016                "          third.rs"
3017            ]
3018        );
3019        ensure_single_file_is_opened(&workspace, "test/second.rs", cx);
3020
3021        workspace
3022            .update(cx, |workspace, cx| {
3023                let active_items = workspace
3024                    .panes()
3025                    .iter()
3026                    .filter_map(|pane| pane.read(cx).active_item())
3027                    .collect::<Vec<_>>();
3028                assert_eq!(active_items.len(), 1);
3029                let open_editor = active_items
3030                    .into_iter()
3031                    .next()
3032                    .unwrap()
3033                    .downcast::<Editor>()
3034                    .expect("Open item should be an editor");
3035                open_editor.update(cx, |editor, cx| editor.set_text("Another text!", cx));
3036            })
3037            .unwrap();
3038        submit_deletion_skipping_prompt(&panel, cx);
3039        assert_eq!(
3040            visible_entries_as_strings(&panel, 0..10, cx),
3041            &["v src", "    v test", "          third.rs"],
3042            "Project panel should have no deleted file, with one last file remaining"
3043        );
3044        ensure_no_open_items_and_panes(&workspace, cx);
3045    }
3046
3047    #[gpui::test]
3048    async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
3049        init_test_with_editor(cx);
3050
3051        let fs = FakeFs::new(cx.executor().clone());
3052        fs.insert_tree(
3053            "/src",
3054            json!({
3055                "test": {
3056                    "first.rs": "// First Rust file",
3057                    "second.rs": "// Second Rust file",
3058                    "third.rs": "// Third Rust file",
3059                }
3060            }),
3061        )
3062        .await;
3063
3064        let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
3065        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3066        let cx = &mut VisualTestContext::from_window(*workspace, cx);
3067        let panel = workspace
3068            .update(cx, |workspace, cx| {
3069                let panel = ProjectPanel::new(workspace, cx);
3070                workspace.add_panel(panel.clone(), cx);
3071                panel
3072            })
3073            .unwrap();
3074
3075        select_path(&panel, "src/", cx);
3076        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
3077        cx.executor().run_until_parked();
3078        assert_eq!(
3079            visible_entries_as_strings(&panel, 0..10, cx),
3080            &[
3081                //
3082                "v src  <== selected",
3083                "    > test"
3084            ]
3085        );
3086        panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
3087        panel.update(cx, |panel, cx| {
3088            assert!(panel.filename_editor.read(cx).is_focused(cx));
3089        });
3090        assert_eq!(
3091            visible_entries_as_strings(&panel, 0..10, cx),
3092            &[
3093                //
3094                "v src",
3095                "    > [EDITOR: '']  <== selected",
3096                "    > test"
3097            ]
3098        );
3099        panel.update(cx, |panel, cx| {
3100            panel
3101                .filename_editor
3102                .update(cx, |editor, cx| editor.set_text("test", cx));
3103            assert!(
3104                panel.confirm_edit(cx).is_none(),
3105                "Should not allow to confirm on conflicting new directory name"
3106            )
3107        });
3108        assert_eq!(
3109            visible_entries_as_strings(&panel, 0..10, cx),
3110            &[
3111                //
3112                "v src",
3113                "    > test"
3114            ],
3115            "File list should be unchanged after failed folder create confirmation"
3116        );
3117
3118        select_path(&panel, "src/test/", cx);
3119        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
3120        cx.executor().run_until_parked();
3121        assert_eq!(
3122            visible_entries_as_strings(&panel, 0..10, cx),
3123            &[
3124                //
3125                "v src",
3126                "    > test  <== selected"
3127            ]
3128        );
3129        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
3130        panel.update(cx, |panel, cx| {
3131            assert!(panel.filename_editor.read(cx).is_focused(cx));
3132        });
3133        assert_eq!(
3134            visible_entries_as_strings(&panel, 0..10, cx),
3135            &[
3136                "v src",
3137                "    v test",
3138                "          [EDITOR: '']  <== selected",
3139                "          first.rs",
3140                "          second.rs",
3141                "          third.rs"
3142            ]
3143        );
3144        panel.update(cx, |panel, cx| {
3145            panel
3146                .filename_editor
3147                .update(cx, |editor, cx| editor.set_text("first.rs", cx));
3148            assert!(
3149                panel.confirm_edit(cx).is_none(),
3150                "Should not allow to confirm on conflicting new file name"
3151            )
3152        });
3153        assert_eq!(
3154            visible_entries_as_strings(&panel, 0..10, cx),
3155            &[
3156                "v src",
3157                "    v test",
3158                "          first.rs",
3159                "          second.rs",
3160                "          third.rs"
3161            ],
3162            "File list should be unchanged after failed file create confirmation"
3163        );
3164
3165        select_path(&panel, "src/test/first.rs", cx);
3166        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
3167        cx.executor().run_until_parked();
3168        assert_eq!(
3169            visible_entries_as_strings(&panel, 0..10, cx),
3170            &[
3171                "v src",
3172                "    v test",
3173                "          first.rs  <== selected",
3174                "          second.rs",
3175                "          third.rs"
3176            ],
3177        );
3178        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
3179        panel.update(cx, |panel, cx| {
3180            assert!(panel.filename_editor.read(cx).is_focused(cx));
3181        });
3182        assert_eq!(
3183            visible_entries_as_strings(&panel, 0..10, cx),
3184            &[
3185                "v src",
3186                "    v test",
3187                "          [EDITOR: 'first.rs']  <== selected",
3188                "          second.rs",
3189                "          third.rs"
3190            ]
3191        );
3192        panel.update(cx, |panel, cx| {
3193            panel
3194                .filename_editor
3195                .update(cx, |editor, cx| editor.set_text("second.rs", cx));
3196            assert!(
3197                panel.confirm_edit(cx).is_none(),
3198                "Should not allow to confirm on conflicting file rename"
3199            )
3200        });
3201        assert_eq!(
3202            visible_entries_as_strings(&panel, 0..10, cx),
3203            &[
3204                "v src",
3205                "    v test",
3206                "          first.rs  <== selected",
3207                "          second.rs",
3208                "          third.rs"
3209            ],
3210            "File list should be unchanged after failed rename confirmation"
3211        );
3212    }
3213
3214    #[gpui::test]
3215    async fn test_dir_toggle_collapse(cx: &mut gpui::TestAppContext) {
3216        init_test_with_editor(cx);
3217
3218        let fs = FakeFs::new(cx.executor().clone());
3219        fs.insert_tree(
3220            "/project_root",
3221            json!({
3222                "dir_1": {
3223                    "nested_dir": {
3224                        "file_a.py": "# File contents",
3225                    }
3226                },
3227                "file_1.py": "# File contents",
3228            }),
3229        )
3230        .await;
3231
3232        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3233        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3234        let cx = &mut VisualTestContext::from_window(*workspace, cx);
3235        let panel = workspace
3236            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
3237            .unwrap();
3238
3239        panel.update(cx, |panel, cx| panel.open(&Open, cx));
3240        cx.executor().run_until_parked();
3241        select_path(&panel, "project_root/dir_1", cx);
3242        panel.update(cx, |panel, cx| panel.open(&Open, cx));
3243        select_path(&panel, "project_root/dir_1/nested_dir", cx);
3244        panel.update(cx, |panel, cx| panel.open(&Open, cx));
3245        panel.update(cx, |panel, cx| panel.open(&Open, cx));
3246        cx.executor().run_until_parked();
3247        assert_eq!(
3248            visible_entries_as_strings(&panel, 0..10, cx),
3249            &[
3250                "v project_root",
3251                "    v dir_1",
3252                "        > nested_dir  <== selected",
3253                "      file_1.py",
3254            ]
3255        );
3256    }
3257
3258    #[gpui::test]
3259    async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) {
3260        init_test_with_editor(cx);
3261
3262        let fs = FakeFs::new(cx.executor().clone());
3263        fs.insert_tree(
3264            "/project_root",
3265            json!({
3266                "dir_1": {
3267                    "nested_dir": {
3268                        "file_a.py": "# File contents",
3269                        "file_b.py": "# File contents",
3270                        "file_c.py": "# File contents",
3271                    },
3272                    "file_1.py": "# File contents",
3273                    "file_2.py": "# File contents",
3274                    "file_3.py": "# File contents",
3275                },
3276                "dir_2": {
3277                    "file_1.py": "# File contents",
3278                    "file_2.py": "# File contents",
3279                    "file_3.py": "# File contents",
3280                }
3281            }),
3282        )
3283        .await;
3284
3285        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3286        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3287        let cx = &mut VisualTestContext::from_window(*workspace, cx);
3288        let panel = workspace
3289            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
3290            .unwrap();
3291
3292        panel.update(cx, |panel, cx| {
3293            panel.collapse_all_entries(&CollapseAllEntries, cx)
3294        });
3295        cx.executor().run_until_parked();
3296        assert_eq!(
3297            visible_entries_as_strings(&panel, 0..10, cx),
3298            &["v project_root", "    > dir_1", "    > dir_2",]
3299        );
3300
3301        // Open dir_1 and make sure nested_dir was collapsed when running collapse_all_entries
3302        toggle_expand_dir(&panel, "project_root/dir_1", cx);
3303        cx.executor().run_until_parked();
3304        assert_eq!(
3305            visible_entries_as_strings(&panel, 0..10, cx),
3306            &[
3307                "v project_root",
3308                "    v dir_1  <== selected",
3309                "        > nested_dir",
3310                "          file_1.py",
3311                "          file_2.py",
3312                "          file_3.py",
3313                "    > dir_2",
3314            ]
3315        );
3316    }
3317
3318    #[gpui::test]
3319    async fn test_new_file_move(cx: &mut gpui::TestAppContext) {
3320        init_test(cx);
3321
3322        let fs = FakeFs::new(cx.executor().clone());
3323        fs.as_fake().insert_tree("/root", json!({})).await;
3324        let project = Project::test(fs, ["/root".as_ref()], cx).await;
3325        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3326        let cx = &mut VisualTestContext::from_window(*workspace, cx);
3327        let panel = workspace
3328            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
3329            .unwrap();
3330
3331        // Make a new buffer with no backing file
3332        workspace
3333            .update(cx, |workspace, cx| {
3334                Editor::new_file(workspace, &Default::default(), cx)
3335            })
3336            .unwrap();
3337
3338        // "Save as"" the buffer, creating a new backing file for it
3339        let save_task = workspace
3340            .update(cx, |workspace, cx| {
3341                workspace.save_active_item(workspace::SaveIntent::Save, cx)
3342            })
3343            .unwrap();
3344
3345        cx.executor().run_until_parked();
3346        cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/new")));
3347        save_task.await.unwrap();
3348
3349        // Rename the file
3350        select_path(&panel, "root/new", cx);
3351        assert_eq!(
3352            visible_entries_as_strings(&panel, 0..10, cx),
3353            &["v root", "      new  <== selected"]
3354        );
3355        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
3356        panel.update(cx, |panel, cx| {
3357            panel
3358                .filename_editor
3359                .update(cx, |editor, cx| editor.set_text("newer", cx));
3360        });
3361        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
3362
3363        cx.executor().run_until_parked();
3364        assert_eq!(
3365            visible_entries_as_strings(&panel, 0..10, cx),
3366            &["v root", "      newer  <== selected"]
3367        );
3368
3369        workspace
3370            .update(cx, |workspace, cx| {
3371                workspace.save_active_item(workspace::SaveIntent::Save, cx)
3372            })
3373            .unwrap()
3374            .await
3375            .unwrap();
3376
3377        cx.executor().run_until_parked();
3378        // assert that saving the file doesn't restore "new"
3379        assert_eq!(
3380            visible_entries_as_strings(&panel, 0..10, cx),
3381            &["v root", "      newer  <== selected"]
3382        );
3383    }
3384
3385    #[gpui::test]
3386    async fn test_autoreveal_and_gitignored_files(cx: &mut gpui::TestAppContext) {
3387        init_test_with_editor(cx);
3388        cx.update(|cx| {
3389            cx.update_global::<SettingsStore, _>(|store, cx| {
3390                store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
3391                    worktree_settings.file_scan_exclusions = Some(Vec::new());
3392                });
3393                store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
3394                    project_panel_settings.auto_reveal_entries = Some(false)
3395                });
3396            })
3397        });
3398
3399        let fs = FakeFs::new(cx.background_executor.clone());
3400        fs.insert_tree(
3401            "/project_root",
3402            json!({
3403                ".git": {},
3404                ".gitignore": "**/gitignored_dir",
3405                "dir_1": {
3406                    "file_1.py": "# File 1_1 contents",
3407                    "file_2.py": "# File 1_2 contents",
3408                    "file_3.py": "# File 1_3 contents",
3409                    "gitignored_dir": {
3410                        "file_a.py": "# File contents",
3411                        "file_b.py": "# File contents",
3412                        "file_c.py": "# File contents",
3413                    },
3414                },
3415                "dir_2": {
3416                    "file_1.py": "# File 2_1 contents",
3417                    "file_2.py": "# File 2_2 contents",
3418                    "file_3.py": "# File 2_3 contents",
3419                }
3420            }),
3421        )
3422        .await;
3423
3424        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3425        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3426        let cx = &mut VisualTestContext::from_window(*workspace, cx);
3427        let panel = workspace
3428            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
3429            .unwrap();
3430
3431        assert_eq!(
3432            visible_entries_as_strings(&panel, 0..20, cx),
3433            &[
3434                "v project_root",
3435                "    > .git",
3436                "    > dir_1",
3437                "    > dir_2",
3438                "      .gitignore",
3439            ]
3440        );
3441
3442        let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
3443            .expect("dir 1 file is not ignored and should have an entry");
3444        let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
3445            .expect("dir 2 file is not ignored and should have an entry");
3446        let gitignored_dir_file =
3447            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
3448        assert_eq!(
3449            gitignored_dir_file, None,
3450            "File in the gitignored dir should not have an entry before its dir is toggled"
3451        );
3452
3453        toggle_expand_dir(&panel, "project_root/dir_1", cx);
3454        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3455        cx.executor().run_until_parked();
3456        assert_eq!(
3457            visible_entries_as_strings(&panel, 0..20, cx),
3458            &[
3459                "v project_root",
3460                "    > .git",
3461                "    v dir_1",
3462                "        v gitignored_dir  <== selected",
3463                "              file_a.py",
3464                "              file_b.py",
3465                "              file_c.py",
3466                "          file_1.py",
3467                "          file_2.py",
3468                "          file_3.py",
3469                "    > dir_2",
3470                "      .gitignore",
3471            ],
3472            "Should show gitignored dir file list in the project panel"
3473        );
3474        let gitignored_dir_file =
3475            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
3476                .expect("after gitignored dir got opened, a file entry should be present");
3477
3478        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3479        toggle_expand_dir(&panel, "project_root/dir_1", cx);
3480        assert_eq!(
3481            visible_entries_as_strings(&panel, 0..20, cx),
3482            &[
3483                "v project_root",
3484                "    > .git",
3485                "    > dir_1  <== selected",
3486                "    > dir_2",
3487                "      .gitignore",
3488            ],
3489            "Should hide all dir contents again and prepare for the auto reveal test"
3490        );
3491
3492        for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
3493            panel.update(cx, |panel, cx| {
3494                panel.project.update(cx, |_, cx| {
3495                    cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
3496                })
3497            });
3498            cx.run_until_parked();
3499            assert_eq!(
3500                visible_entries_as_strings(&panel, 0..20, cx),
3501                &[
3502                    "v project_root",
3503                    "    > .git",
3504                    "    > dir_1  <== selected",
3505                    "    > dir_2",
3506                    "      .gitignore",
3507                ],
3508                "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
3509            );
3510        }
3511
3512        cx.update(|cx| {
3513            cx.update_global::<SettingsStore, _>(|store, cx| {
3514                store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
3515                    project_panel_settings.auto_reveal_entries = Some(true)
3516                });
3517            })
3518        });
3519
3520        panel.update(cx, |panel, cx| {
3521            panel.project.update(cx, |_, cx| {
3522                cx.emit(project::Event::ActiveEntryChanged(Some(dir_1_file)))
3523            })
3524        });
3525        cx.run_until_parked();
3526        assert_eq!(
3527            visible_entries_as_strings(&panel, 0..20, cx),
3528            &[
3529                "v project_root",
3530                "    > .git",
3531                "    v dir_1",
3532                "        > gitignored_dir",
3533                "          file_1.py  <== selected",
3534                "          file_2.py",
3535                "          file_3.py",
3536                "    > dir_2",
3537                "      .gitignore",
3538            ],
3539            "When auto reveal is enabled, not ignored dir_1 entry should be revealed"
3540        );
3541
3542        panel.update(cx, |panel, cx| {
3543            panel.project.update(cx, |_, cx| {
3544                cx.emit(project::Event::ActiveEntryChanged(Some(dir_2_file)))
3545            })
3546        });
3547        cx.run_until_parked();
3548        assert_eq!(
3549            visible_entries_as_strings(&panel, 0..20, cx),
3550            &[
3551                "v project_root",
3552                "    > .git",
3553                "    v dir_1",
3554                "        > gitignored_dir",
3555                "          file_1.py",
3556                "          file_2.py",
3557                "          file_3.py",
3558                "    v dir_2",
3559                "          file_1.py  <== selected",
3560                "          file_2.py",
3561                "          file_3.py",
3562                "      .gitignore",
3563            ],
3564            "When auto reveal is enabled, not ignored dir_2 entry should be revealed"
3565        );
3566
3567        panel.update(cx, |panel, cx| {
3568            panel.project.update(cx, |_, cx| {
3569                cx.emit(project::Event::ActiveEntryChanged(Some(
3570                    gitignored_dir_file,
3571                )))
3572            })
3573        });
3574        cx.run_until_parked();
3575        assert_eq!(
3576            visible_entries_as_strings(&panel, 0..20, cx),
3577            &[
3578                "v project_root",
3579                "    > .git",
3580                "    v dir_1",
3581                "        > gitignored_dir",
3582                "          file_1.py",
3583                "          file_2.py",
3584                "          file_3.py",
3585                "    v dir_2",
3586                "          file_1.py  <== selected",
3587                "          file_2.py",
3588                "          file_3.py",
3589                "      .gitignore",
3590            ],
3591            "When auto reveal is enabled, a gitignored selected entry should not be revealed in the project panel"
3592        );
3593
3594        panel.update(cx, |panel, cx| {
3595            panel.project.update(cx, |_, cx| {
3596                cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
3597            })
3598        });
3599        cx.run_until_parked();
3600        assert_eq!(
3601            visible_entries_as_strings(&panel, 0..20, cx),
3602            &[
3603                "v project_root",
3604                "    > .git",
3605                "    v dir_1",
3606                "        v gitignored_dir",
3607                "              file_a.py  <== selected",
3608                "              file_b.py",
3609                "              file_c.py",
3610                "          file_1.py",
3611                "          file_2.py",
3612                "          file_3.py",
3613                "    v dir_2",
3614                "          file_1.py",
3615                "          file_2.py",
3616                "          file_3.py",
3617                "      .gitignore",
3618            ],
3619            "When a gitignored entry is explicitly revealed, it should be shown in the project tree"
3620        );
3621    }
3622
3623    #[gpui::test]
3624    async fn test_explicit_reveal(cx: &mut gpui::TestAppContext) {
3625        init_test_with_editor(cx);
3626        cx.update(|cx| {
3627            cx.update_global::<SettingsStore, _>(|store, cx| {
3628                store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
3629                    worktree_settings.file_scan_exclusions = Some(Vec::new());
3630                });
3631                store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
3632                    project_panel_settings.auto_reveal_entries = Some(false)
3633                });
3634            })
3635        });
3636
3637        let fs = FakeFs::new(cx.background_executor.clone());
3638        fs.insert_tree(
3639            "/project_root",
3640            json!({
3641                ".git": {},
3642                ".gitignore": "**/gitignored_dir",
3643                "dir_1": {
3644                    "file_1.py": "# File 1_1 contents",
3645                    "file_2.py": "# File 1_2 contents",
3646                    "file_3.py": "# File 1_3 contents",
3647                    "gitignored_dir": {
3648                        "file_a.py": "# File contents",
3649                        "file_b.py": "# File contents",
3650                        "file_c.py": "# File contents",
3651                    },
3652                },
3653                "dir_2": {
3654                    "file_1.py": "# File 2_1 contents",
3655                    "file_2.py": "# File 2_2 contents",
3656                    "file_3.py": "# File 2_3 contents",
3657                }
3658            }),
3659        )
3660        .await;
3661
3662        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3663        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3664        let cx = &mut VisualTestContext::from_window(*workspace, cx);
3665        let panel = workspace
3666            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
3667            .unwrap();
3668
3669        assert_eq!(
3670            visible_entries_as_strings(&panel, 0..20, cx),
3671            &[
3672                "v project_root",
3673                "    > .git",
3674                "    > dir_1",
3675                "    > dir_2",
3676                "      .gitignore",
3677            ]
3678        );
3679
3680        let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
3681            .expect("dir 1 file is not ignored and should have an entry");
3682        let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
3683            .expect("dir 2 file is not ignored and should have an entry");
3684        let gitignored_dir_file =
3685            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
3686        assert_eq!(
3687            gitignored_dir_file, None,
3688            "File in the gitignored dir should not have an entry before its dir is toggled"
3689        );
3690
3691        toggle_expand_dir(&panel, "project_root/dir_1", cx);
3692        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3693        cx.run_until_parked();
3694        assert_eq!(
3695            visible_entries_as_strings(&panel, 0..20, cx),
3696            &[
3697                "v project_root",
3698                "    > .git",
3699                "    v dir_1",
3700                "        v gitignored_dir  <== selected",
3701                "              file_a.py",
3702                "              file_b.py",
3703                "              file_c.py",
3704                "          file_1.py",
3705                "          file_2.py",
3706                "          file_3.py",
3707                "    > dir_2",
3708                "      .gitignore",
3709            ],
3710            "Should show gitignored dir file list in the project panel"
3711        );
3712        let gitignored_dir_file =
3713            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
3714                .expect("after gitignored dir got opened, a file entry should be present");
3715
3716        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3717        toggle_expand_dir(&panel, "project_root/dir_1", cx);
3718        assert_eq!(
3719            visible_entries_as_strings(&panel, 0..20, cx),
3720            &[
3721                "v project_root",
3722                "    > .git",
3723                "    > dir_1  <== selected",
3724                "    > dir_2",
3725                "      .gitignore",
3726            ],
3727            "Should hide all dir contents again and prepare for the explicit reveal test"
3728        );
3729
3730        for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
3731            panel.update(cx, |panel, cx| {
3732                panel.project.update(cx, |_, cx| {
3733                    cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
3734                })
3735            });
3736            cx.run_until_parked();
3737            assert_eq!(
3738                visible_entries_as_strings(&panel, 0..20, cx),
3739                &[
3740                    "v project_root",
3741                    "    > .git",
3742                    "    > dir_1  <== selected",
3743                    "    > dir_2",
3744                    "      .gitignore",
3745                ],
3746                "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
3747            );
3748        }
3749
3750        panel.update(cx, |panel, cx| {
3751            panel.project.update(cx, |_, cx| {
3752                cx.emit(project::Event::RevealInProjectPanel(dir_1_file))
3753            })
3754        });
3755        cx.run_until_parked();
3756        assert_eq!(
3757            visible_entries_as_strings(&panel, 0..20, cx),
3758            &[
3759                "v project_root",
3760                "    > .git",
3761                "    v dir_1",
3762                "        > gitignored_dir",
3763                "          file_1.py  <== selected",
3764                "          file_2.py",
3765                "          file_3.py",
3766                "    > dir_2",
3767                "      .gitignore",
3768            ],
3769            "With no auto reveal, explicit reveal should show the dir_1 entry in the project panel"
3770        );
3771
3772        panel.update(cx, |panel, cx| {
3773            panel.project.update(cx, |_, cx| {
3774                cx.emit(project::Event::RevealInProjectPanel(dir_2_file))
3775            })
3776        });
3777        cx.run_until_parked();
3778        assert_eq!(
3779            visible_entries_as_strings(&panel, 0..20, cx),
3780            &[
3781                "v project_root",
3782                "    > .git",
3783                "    v dir_1",
3784                "        > gitignored_dir",
3785                "          file_1.py",
3786                "          file_2.py",
3787                "          file_3.py",
3788                "    v dir_2",
3789                "          file_1.py  <== selected",
3790                "          file_2.py",
3791                "          file_3.py",
3792                "      .gitignore",
3793            ],
3794            "With no auto reveal, explicit reveal should show the dir_2 entry in the project panel"
3795        );
3796
3797        panel.update(cx, |panel, cx| {
3798            panel.project.update(cx, |_, cx| {
3799                cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
3800            })
3801        });
3802        cx.run_until_parked();
3803        assert_eq!(
3804            visible_entries_as_strings(&panel, 0..20, cx),
3805            &[
3806                "v project_root",
3807                "    > .git",
3808                "    v dir_1",
3809                "        v gitignored_dir",
3810                "              file_a.py  <== selected",
3811                "              file_b.py",
3812                "              file_c.py",
3813                "          file_1.py",
3814                "          file_2.py",
3815                "          file_3.py",
3816                "    v dir_2",
3817                "          file_1.py",
3818                "          file_2.py",
3819                "          file_3.py",
3820                "      .gitignore",
3821            ],
3822            "With no auto reveal, explicit reveal should show the gitignored entry in the project panel"
3823        );
3824    }
3825
3826    fn toggle_expand_dir(
3827        panel: &View<ProjectPanel>,
3828        path: impl AsRef<Path>,
3829        cx: &mut VisualTestContext,
3830    ) {
3831        let path = path.as_ref();
3832        panel.update(cx, |panel, cx| {
3833            for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
3834                let worktree = worktree.read(cx);
3835                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
3836                    let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
3837                    panel.toggle_expanded(entry_id, cx);
3838                    return;
3839                }
3840            }
3841            panic!("no worktree for path {:?}", path);
3842        });
3843    }
3844
3845    fn select_path(panel: &View<ProjectPanel>, path: impl AsRef<Path>, cx: &mut VisualTestContext) {
3846        let path = path.as_ref();
3847        panel.update(cx, |panel, cx| {
3848            for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
3849                let worktree = worktree.read(cx);
3850                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
3851                    let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
3852                    panel.selection = Some(crate::Selection {
3853                        worktree_id: worktree.id(),
3854                        entry_id,
3855                    });
3856                    return;
3857                }
3858            }
3859            panic!("no worktree for path {:?}", path);
3860        });
3861    }
3862
3863    fn find_project_entry(
3864        panel: &View<ProjectPanel>,
3865        path: impl AsRef<Path>,
3866        cx: &mut VisualTestContext,
3867    ) -> Option<ProjectEntryId> {
3868        let path = path.as_ref();
3869        panel.update(cx, |panel, cx| {
3870            for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
3871                let worktree = worktree.read(cx);
3872                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
3873                    return worktree.entry_for_path(relative_path).map(|entry| entry.id);
3874                }
3875            }
3876            panic!("no worktree for path {path:?}");
3877        })
3878    }
3879
3880    fn visible_entries_as_strings(
3881        panel: &View<ProjectPanel>,
3882        range: Range<usize>,
3883        cx: &mut VisualTestContext,
3884    ) -> Vec<String> {
3885        let mut result = Vec::new();
3886        let mut project_entries = HashSet::default();
3887        let mut has_editor = false;
3888
3889        panel.update(cx, |panel, cx| {
3890            panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
3891                if details.is_editing {
3892                    assert!(!has_editor, "duplicate editor entry");
3893                    has_editor = true;
3894                } else {
3895                    assert!(
3896                        project_entries.insert(project_entry),
3897                        "duplicate project entry {:?} {:?}",
3898                        project_entry,
3899                        details
3900                    );
3901                }
3902
3903                let indent = "    ".repeat(details.depth);
3904                let icon = if details.kind.is_dir() {
3905                    if details.is_expanded {
3906                        "v "
3907                    } else {
3908                        "> "
3909                    }
3910                } else {
3911                    "  "
3912                };
3913                let name = if details.is_editing {
3914                    format!("[EDITOR: '{}']", details.filename)
3915                } else if details.is_processing {
3916                    format!("[PROCESSING: '{}']", details.filename)
3917                } else {
3918                    details.filename.clone()
3919                };
3920                let selected = if details.is_selected {
3921                    "  <== selected"
3922                } else {
3923                    ""
3924                };
3925                result.push(format!("{indent}{icon}{name}{selected}"));
3926            });
3927        });
3928
3929        result
3930    }
3931
3932    fn init_test(cx: &mut TestAppContext) {
3933        cx.update(|cx| {
3934            let settings_store = SettingsStore::test(cx);
3935            cx.set_global(settings_store);
3936            init_settings(cx);
3937            theme::init(theme::LoadThemes::JustBase, cx);
3938            language::init(cx);
3939            editor::init_settings(cx);
3940            crate::init((), cx);
3941            workspace::init_settings(cx);
3942            client::init_settings(cx);
3943            Project::init_settings(cx);
3944
3945            cx.update_global::<SettingsStore, _>(|store, cx| {
3946                store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
3947                    worktree_settings.file_scan_exclusions = Some(Vec::new());
3948                });
3949            });
3950        });
3951    }
3952
3953    fn init_test_with_editor(cx: &mut TestAppContext) {
3954        cx.update(|cx| {
3955            let app_state = AppState::test(cx);
3956            theme::init(theme::LoadThemes::JustBase, cx);
3957            init_settings(cx);
3958            language::init(cx);
3959            editor::init(cx);
3960            crate::init((), cx);
3961            workspace::init(app_state.clone(), cx);
3962            Project::init_settings(cx);
3963        });
3964    }
3965
3966    fn ensure_single_file_is_opened(
3967        window: &WindowHandle<Workspace>,
3968        expected_path: &str,
3969        cx: &mut TestAppContext,
3970    ) {
3971        window
3972            .update(cx, |workspace, cx| {
3973                let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
3974                assert_eq!(worktrees.len(), 1);
3975                let worktree_id = worktrees[0].read(cx).id();
3976
3977                let open_project_paths = workspace
3978                    .panes()
3979                    .iter()
3980                    .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
3981                    .collect::<Vec<_>>();
3982                assert_eq!(
3983                    open_project_paths,
3984                    vec![ProjectPath {
3985                        worktree_id,
3986                        path: Arc::from(Path::new(expected_path))
3987                    }],
3988                    "Should have opened file, selected in project panel"
3989                );
3990            })
3991            .unwrap();
3992    }
3993
3994    fn submit_deletion(panel: &View<ProjectPanel>, cx: &mut VisualTestContext) {
3995        assert!(
3996            !cx.has_pending_prompt(),
3997            "Should have no prompts before the deletion"
3998        );
3999        panel.update(cx, |panel, cx| {
4000            panel.delete(&Delete { skip_prompt: false }, cx)
4001        });
4002        assert!(
4003            cx.has_pending_prompt(),
4004            "Should have a prompt after the deletion"
4005        );
4006        cx.simulate_prompt_answer(0);
4007        assert!(
4008            !cx.has_pending_prompt(),
4009            "Should have no prompts after prompt was replied to"
4010        );
4011        cx.executor().run_until_parked();
4012    }
4013
4014    fn submit_deletion_skipping_prompt(panel: &View<ProjectPanel>, cx: &mut VisualTestContext) {
4015        assert!(
4016            !cx.has_pending_prompt(),
4017            "Should have no prompts before the deletion"
4018        );
4019        panel.update(cx, |panel, cx| {
4020            panel.delete(&Delete { skip_prompt: true }, cx)
4021        });
4022        assert!(!cx.has_pending_prompt(), "Should have received no prompts");
4023        cx.executor().run_until_parked();
4024    }
4025
4026    fn ensure_no_open_items_and_panes(
4027        workspace: &WindowHandle<Workspace>,
4028        cx: &mut VisualTestContext,
4029    ) {
4030        assert!(
4031            !cx.has_pending_prompt(),
4032            "Should have no prompts after deletion operation closes the file"
4033        );
4034        workspace
4035            .read_with(cx, |workspace, cx| {
4036                let open_project_paths = workspace
4037                    .panes()
4038                    .iter()
4039                    .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
4040                    .collect::<Vec<_>>();
4041                assert!(
4042                    open_project_paths.is_empty(),
4043                    "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
4044                );
4045            })
4046            .unwrap();
4047    }
4048}