project_panel.rs

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