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