project_panel.rs

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