project_panel.rs

   1mod project_panel_settings;
   2
   3use client::{ErrorCode, ErrorExt};
   4use settings::{Settings, SettingsStore};
   5use ui::{Scrollbar, ScrollbarState};
   6
   7use db::kvp::KEY_VALUE_STORE;
   8use editor::{
   9    items::entry_git_aware_label_color,
  10    scroll::{Autoscroll, ScrollbarAutoHide},
  11    Editor, EditorEvent, EditorSettings, ShowScrollbar,
  12};
  13use file_icons::FileIcons;
  14
  15use anyhow::{anyhow, Context as _, Result};
  16use collections::{hash_map, BTreeSet, HashMap};
  17use git::repository::GitFileStatus;
  18use gpui::{
  19    actions, anchored, deferred, div, impl_actions, point, px, size, uniform_list, Action,
  20    AnyElement, AppContext, AssetSource, AsyncWindowContext, Bounds, ClipboardItem, DismissEvent,
  21    Div, DragMoveEvent, EventEmitter, ExternalPaths, FocusHandle, FocusableView,
  22    InteractiveElement, KeyContext, ListHorizontalSizingBehavior, ListSizingBehavior, Model,
  23    MouseButton, MouseDownEvent, ParentElement, Pixels, Point, PromptLevel, Render, Stateful,
  24    Styled, Subscription, Task, UniformListScrollHandle, View, ViewContext, VisualContext as _,
  25    WeakView, WindowContext,
  26};
  27use indexmap::IndexMap;
  28use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrev};
  29use project::{
  30    relativize_path, Entry, EntryKind, Fs, Project, ProjectEntryId, ProjectPath, Worktree,
  31    WorktreeId,
  32};
  33use project_panel_settings::{ProjectPanelDockPosition, ProjectPanelSettings, ShowIndentGuides};
  34use serde::{Deserialize, Serialize};
  35use smallvec::SmallVec;
  36use std::{
  37    cell::OnceCell,
  38    collections::HashSet,
  39    ffi::OsStr,
  40    ops::Range,
  41    path::{Path, PathBuf},
  42    sync::Arc,
  43    time::Duration,
  44};
  45use theme::ThemeSettings;
  46use ui::{
  47    prelude::*, v_flex, ContextMenu, Icon, IndentGuideColors, IndentGuideLayout, KeyBinding, Label,
  48    ListItem, Tooltip,
  49};
  50use util::{maybe, ResultExt, TryFutureExt};
  51use workspace::{
  52    dock::{DockPosition, Panel, PanelEvent},
  53    notifications::{DetachAndPromptErr, NotifyTaskExt},
  54    DraggedSelection, OpenInTerminal, SelectedEntry, Workspace,
  55};
  56use worktree::CreatedEntry;
  57
  58const PROJECT_PANEL_KEY: &str = "ProjectPanel";
  59const NEW_ENTRY_ID: ProjectEntryId = ProjectEntryId::MAX;
  60
  61pub struct ProjectPanel {
  62    project: Model<Project>,
  63    fs: Arc<dyn Fs>,
  64    focus_handle: FocusHandle,
  65    scroll_handle: UniformListScrollHandle,
  66    visible_entries: Vec<(WorktreeId, Vec<Entry>, OnceCell<HashSet<Arc<Path>>>)>,
  67    /// Maps from leaf project entry ID to the currently selected ancestor.
  68    /// Relevant only for auto-fold dirs, where a single project panel entry may actually consist of several
  69    /// project entries (and all non-leaf nodes are guaranteed to be directories).
  70    ancestors: HashMap<ProjectEntryId, FoldedAncestors>,
  71    last_worktree_root_id: Option<ProjectEntryId>,
  72    last_external_paths_drag_over_entry: Option<ProjectEntryId>,
  73    expanded_dir_ids: HashMap<WorktreeId, Vec<ProjectEntryId>>,
  74    unfolded_dir_ids: HashSet<ProjectEntryId>,
  75    // Currently selected leaf entry (see auto-folding for a definition of that) in a file tree
  76    selection: Option<SelectedEntry>,
  77    marked_entries: BTreeSet<SelectedEntry>,
  78    context_menu: Option<(View<ContextMenu>, Point<Pixels>, Subscription)>,
  79    edit_state: Option<EditState>,
  80    filename_editor: View<Editor>,
  81    clipboard: Option<ClipboardEntry>,
  82    _dragged_entry_destination: Option<Arc<Path>>,
  83    workspace: WeakView<Workspace>,
  84    width: Option<Pixels>,
  85    pending_serialization: Task<Option<()>>,
  86    show_scrollbar: bool,
  87    vertical_scrollbar_state: ScrollbarState,
  88    horizontal_scrollbar_state: ScrollbarState,
  89    hide_scrollbar_task: Option<Task<()>>,
  90    max_width_item_index: Option<usize>,
  91}
  92
  93#[derive(Clone, Debug)]
  94struct EditState {
  95    worktree_id: WorktreeId,
  96    entry_id: ProjectEntryId,
  97    leaf_entry_id: Option<ProjectEntryId>,
  98    is_dir: bool,
  99    depth: usize,
 100    processing_filename: Option<String>,
 101}
 102
 103impl EditState {
 104    fn is_new_entry(&self) -> bool {
 105        self.leaf_entry_id.is_none()
 106    }
 107}
 108
 109#[derive(Clone, Debug)]
 110enum ClipboardEntry {
 111    Copied(BTreeSet<SelectedEntry>),
 112    Cut(BTreeSet<SelectedEntry>),
 113}
 114
 115#[derive(Debug, PartialEq, Eq, Clone)]
 116struct EntryDetails {
 117    filename: String,
 118    icon: Option<SharedString>,
 119    path: Arc<Path>,
 120    depth: usize,
 121    kind: EntryKind,
 122    is_ignored: bool,
 123    is_expanded: bool,
 124    is_selected: bool,
 125    is_marked: bool,
 126    is_editing: bool,
 127    is_processing: bool,
 128    is_cut: bool,
 129    git_status: Option<GitFileStatus>,
 130    is_private: bool,
 131    worktree_id: WorktreeId,
 132    canonical_path: Option<Box<Path>>,
 133}
 134
 135#[derive(PartialEq, Clone, Default, Debug, Deserialize)]
 136struct Delete {
 137    #[serde(default)]
 138    pub skip_prompt: bool,
 139}
 140
 141#[derive(PartialEq, Clone, Default, Debug, Deserialize)]
 142struct Trash {
 143    #[serde(default)]
 144    pub skip_prompt: bool,
 145}
 146
 147impl_actions!(project_panel, [Delete, Trash]);
 148
 149actions!(
 150    project_panel,
 151    [
 152        ExpandSelectedEntry,
 153        CollapseSelectedEntry,
 154        CollapseAllEntries,
 155        NewDirectory,
 156        NewFile,
 157        Copy,
 158        CopyPath,
 159        CopyRelativePath,
 160        Duplicate,
 161        RevealInFileManager,
 162        OpenWithSystem,
 163        Cut,
 164        Paste,
 165        Rename,
 166        Open,
 167        OpenPermanent,
 168        ToggleFocus,
 169        NewSearchInDirectory,
 170        UnfoldDirectory,
 171        FoldDirectory,
 172        SelectParent,
 173    ]
 174);
 175
 176#[derive(Debug, Default)]
 177struct FoldedAncestors {
 178    current_ancestor_depth: usize,
 179    ancestors: Vec<ProjectEntryId>,
 180}
 181
 182impl FoldedAncestors {
 183    fn max_ancestor_depth(&self) -> usize {
 184        self.ancestors.len()
 185    }
 186}
 187
 188pub fn init_settings(cx: &mut AppContext) {
 189    ProjectPanelSettings::register(cx);
 190}
 191
 192pub fn init(assets: impl AssetSource, cx: &mut AppContext) {
 193    init_settings(cx);
 194    file_icons::init(assets, cx);
 195
 196    cx.observe_new_views(|workspace: &mut Workspace, _| {
 197        workspace.register_action(|workspace, _: &ToggleFocus, cx| {
 198            workspace.toggle_panel_focus::<ProjectPanel>(cx);
 199        });
 200    })
 201    .detach();
 202}
 203
 204#[derive(Debug)]
 205pub enum Event {
 206    OpenedEntry {
 207        entry_id: ProjectEntryId,
 208        focus_opened_item: bool,
 209        allow_preview: bool,
 210        mark_selected: bool,
 211    },
 212    SplitEntry {
 213        entry_id: ProjectEntryId,
 214    },
 215    Focus,
 216}
 217
 218#[derive(Serialize, Deserialize)]
 219struct SerializedProjectPanel {
 220    width: Option<Pixels>,
 221}
 222
 223struct DraggedProjectEntryView {
 224    selection: SelectedEntry,
 225    details: EntryDetails,
 226    width: Pixels,
 227    selections: Arc<BTreeSet<SelectedEntry>>,
 228}
 229
 230impl ProjectPanel {
 231    fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> View<Self> {
 232        let project = workspace.project().clone();
 233        let project_panel = cx.new_view(|cx: &mut ViewContext<Self>| {
 234            let focus_handle = cx.focus_handle();
 235            cx.on_focus(&focus_handle, Self::focus_in).detach();
 236            cx.on_focus_out(&focus_handle, |this, _, cx| {
 237                this.hide_scrollbar(cx);
 238            })
 239            .detach();
 240            cx.subscribe(&project, |this, project, event, cx| match event {
 241                project::Event::ActiveEntryChanged(Some(entry_id)) => {
 242                    if ProjectPanelSettings::get_global(cx).auto_reveal_entries {
 243                        this.reveal_entry(project, *entry_id, true, cx);
 244                    }
 245                }
 246                project::Event::RevealInProjectPanel(entry_id) => {
 247                    this.reveal_entry(project, *entry_id, false, cx);
 248                    cx.emit(PanelEvent::Activate);
 249                }
 250                project::Event::ActivateProjectPanel => {
 251                    cx.emit(PanelEvent::Activate);
 252                }
 253                project::Event::WorktreeRemoved(id) => {
 254                    this.expanded_dir_ids.remove(id);
 255                    this.update_visible_entries(None, cx);
 256                    cx.notify();
 257                }
 258                project::Event::WorktreeUpdatedEntries(_, _)
 259                | project::Event::WorktreeAdded
 260                | project::Event::WorktreeOrderChanged => {
 261                    this.update_visible_entries(None, cx);
 262                    cx.notify();
 263                }
 264                _ => {}
 265            })
 266            .detach();
 267
 268            let filename_editor = cx.new_view(Editor::single_line);
 269
 270            cx.subscribe(
 271                &filename_editor,
 272                |project_panel, _, editor_event, cx| match editor_event {
 273                    EditorEvent::BufferEdited | EditorEvent::SelectionsChanged { .. } => {
 274                        project_panel.autoscroll(cx);
 275                    }
 276                    EditorEvent::Blurred => {
 277                        if project_panel
 278                            .edit_state
 279                            .as_ref()
 280                            .map_or(false, |state| state.processing_filename.is_none())
 281                        {
 282                            project_panel.edit_state = None;
 283                            project_panel.update_visible_entries(None, cx);
 284                            cx.notify();
 285                        }
 286                    }
 287                    _ => {}
 288                },
 289            )
 290            .detach();
 291
 292            cx.observe_global::<FileIcons>(|_, cx| {
 293                cx.notify();
 294            })
 295            .detach();
 296
 297            let mut project_panel_settings = *ProjectPanelSettings::get_global(cx);
 298            cx.observe_global::<SettingsStore>(move |_, cx| {
 299                let new_settings = *ProjectPanelSettings::get_global(cx);
 300                if project_panel_settings != new_settings {
 301                    project_panel_settings = new_settings;
 302                    cx.notify();
 303                }
 304            })
 305            .detach();
 306
 307            let scroll_handle = UniformListScrollHandle::new();
 308            let mut this = Self {
 309                project: project.clone(),
 310                fs: workspace.app_state().fs.clone(),
 311                focus_handle,
 312                visible_entries: Default::default(),
 313                ancestors: Default::default(),
 314                last_worktree_root_id: Default::default(),
 315                last_external_paths_drag_over_entry: None,
 316                expanded_dir_ids: Default::default(),
 317                unfolded_dir_ids: Default::default(),
 318                selection: None,
 319                marked_entries: Default::default(),
 320                edit_state: None,
 321                context_menu: None,
 322                filename_editor,
 323                clipboard: None,
 324                _dragged_entry_destination: None,
 325                workspace: workspace.weak_handle(),
 326                width: None,
 327                pending_serialization: Task::ready(None),
 328                show_scrollbar: !Self::should_autohide_scrollbar(cx),
 329                hide_scrollbar_task: None,
 330                vertical_scrollbar_state: ScrollbarState::new(scroll_handle.clone())
 331                    .parent_view(cx.view()),
 332                horizontal_scrollbar_state: ScrollbarState::new(scroll_handle.clone())
 333                    .parent_view(cx.view()),
 334                max_width_item_index: None,
 335                scroll_handle,
 336            };
 337            this.update_visible_entries(None, cx);
 338
 339            this
 340        });
 341
 342        cx.subscribe(&project_panel, {
 343            let project_panel = project_panel.downgrade();
 344            move |workspace, _, event, cx| match event {
 345                &Event::OpenedEntry {
 346                    entry_id,
 347                    focus_opened_item,
 348                    allow_preview,
 349                    mark_selected
 350                } => {
 351                    if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
 352                        if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
 353                            let file_path = entry.path.clone();
 354                            let worktree_id = worktree.read(cx).id();
 355                            let entry_id = entry.id;
 356
 357                            project_panel.update(cx, |this, _| {
 358                                if !mark_selected {
 359                                    this.marked_entries.clear();
 360                                }
 361                                this.marked_entries.insert(SelectedEntry {
 362                                    worktree_id,
 363                                    entry_id
 364                                });
 365                            }).ok();
 366
 367                            let is_via_ssh = project.read(cx).is_via_ssh();
 368
 369                            workspace
 370                                .open_path_preview(
 371                                    ProjectPath {
 372                                        worktree_id,
 373                                        path: file_path.clone(),
 374                                    },
 375                                    None,
 376                                    focus_opened_item,
 377                                    allow_preview,
 378                                    cx,
 379                                )
 380                                .detach_and_prompt_err("Failed to open file", cx, move |e, _| {
 381                                    match e.error_code() {
 382                                        ErrorCode::Disconnected => if is_via_ssh {
 383                                            Some("Disconnected from SSH host".to_string())
 384                                        } else {
 385                                            Some("Disconnected from remote project".to_string())
 386                                        },
 387                                        ErrorCode::UnsharedItem => Some(format!(
 388                                            "{} is not shared by the host. This could be because it has been marked as `private`",
 389                                            file_path.display()
 390                                        )),
 391                                        _ => None,
 392                                    }
 393                                });
 394
 395                            if let Some(project_panel) = project_panel.upgrade() {
 396                                // Always select the entry, regardless of whether it is opened or not.
 397                                project_panel.update(cx, |project_panel, _| {
 398                                    project_panel.selection = Some(SelectedEntry {
 399                                        worktree_id,
 400                                        entry_id
 401                                    });
 402                                });
 403                                if !focus_opened_item {
 404                                    let focus_handle = project_panel.read(cx).focus_handle.clone();
 405                                    cx.focus(&focus_handle);
 406                                }
 407                            }
 408                        }
 409                    }
 410                }
 411                &Event::SplitEntry { entry_id } => {
 412                    if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
 413                        if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
 414                            workspace
 415                                .split_path(
 416                                    ProjectPath {
 417                                        worktree_id: worktree.read(cx).id(),
 418                                        path: entry.path.clone(),
 419                                    },
 420                                    cx,
 421                                )
 422                                .detach_and_log_err(cx);
 423                        }
 424                    }
 425                }
 426                _ => {}
 427            }
 428        })
 429        .detach();
 430
 431        project_panel
 432    }
 433
 434    pub async fn load(
 435        workspace: WeakView<Workspace>,
 436        mut cx: AsyncWindowContext,
 437    ) -> Result<View<Self>> {
 438        let serialized_panel = cx
 439            .background_executor()
 440            .spawn(async move { KEY_VALUE_STORE.read_kvp(PROJECT_PANEL_KEY) })
 441            .await
 442            .map_err(|e| anyhow!("Failed to load project panel: {}", e))
 443            .log_err()
 444            .flatten()
 445            .map(|panel| serde_json::from_str::<SerializedProjectPanel>(&panel))
 446            .transpose()
 447            .log_err()
 448            .flatten();
 449
 450        workspace.update(&mut cx, |workspace, cx| {
 451            let panel = ProjectPanel::new(workspace, cx);
 452            if let Some(serialized_panel) = serialized_panel {
 453                panel.update(cx, |panel, cx| {
 454                    panel.width = serialized_panel.width.map(|px| px.round());
 455                    cx.notify();
 456                });
 457            }
 458            panel
 459        })
 460    }
 461
 462    fn serialize(&mut self, cx: &mut ViewContext<Self>) {
 463        let width = self.width;
 464        self.pending_serialization = cx.background_executor().spawn(
 465            async move {
 466                KEY_VALUE_STORE
 467                    .write_kvp(
 468                        PROJECT_PANEL_KEY.into(),
 469                        serde_json::to_string(&SerializedProjectPanel { width })?,
 470                    )
 471                    .await?;
 472                anyhow::Ok(())
 473            }
 474            .log_err(),
 475        );
 476    }
 477
 478    fn focus_in(&mut self, cx: &mut ViewContext<Self>) {
 479        if !self.focus_handle.contains_focused(cx) {
 480            cx.emit(Event::Focus);
 481        }
 482    }
 483
 484    fn deploy_context_menu(
 485        &mut self,
 486        position: Point<Pixels>,
 487        entry_id: ProjectEntryId,
 488        cx: &mut ViewContext<Self>,
 489    ) {
 490        let this = cx.view().clone();
 491        let project = self.project.read(cx);
 492
 493        let worktree_id = if let Some(id) = project.worktree_id_for_entry(entry_id, cx) {
 494            id
 495        } else {
 496            return;
 497        };
 498
 499        self.selection = Some(SelectedEntry {
 500            worktree_id,
 501            entry_id,
 502        });
 503
 504        if let Some((worktree, entry)) = self.selected_sub_entry(cx) {
 505            let auto_fold_dirs = ProjectPanelSettings::get_global(cx).auto_fold_dirs;
 506            let worktree = worktree.read(cx);
 507            let is_root = Some(entry) == worktree.root_entry();
 508            let is_dir = entry.is_dir();
 509            let is_foldable = auto_fold_dirs && self.is_foldable(entry, worktree);
 510            let is_unfoldable = auto_fold_dirs && self.is_unfoldable(entry, worktree);
 511            let worktree_id = worktree.id();
 512            let is_read_only = project.is_read_only(cx);
 513            let is_remote = project.is_via_collab();
 514            let is_local = project.is_local();
 515
 516            let context_menu = ContextMenu::build(cx, |menu, cx| {
 517                menu.context(self.focus_handle.clone()).map(|menu| {
 518                    if is_read_only {
 519                        menu.when(is_dir, |menu| {
 520                            menu.action("Search Inside", Box::new(NewSearchInDirectory))
 521                        })
 522                    } else {
 523                        menu.action("New File", Box::new(NewFile))
 524                            .action("New Folder", Box::new(NewDirectory))
 525                            .separator()
 526                            .when(is_local && cfg!(target_os = "macos"), |menu| {
 527                                menu.action("Reveal in Finder", Box::new(RevealInFileManager))
 528                            })
 529                            .when(is_local && cfg!(not(target_os = "macos")), |menu| {
 530                                menu.action("Reveal in File Manager", Box::new(RevealInFileManager))
 531                            })
 532                            .when(is_local, |menu| {
 533                                menu.action("Open in Default App", Box::new(OpenWithSystem))
 534                            })
 535                            .action("Open in Terminal", Box::new(OpenInTerminal))
 536                            .when(is_dir, |menu| {
 537                                menu.separator()
 538                                    .action("Find in Folder…", Box::new(NewSearchInDirectory))
 539                            })
 540                            .when(is_unfoldable, |menu| {
 541                                menu.action("Unfold Directory", Box::new(UnfoldDirectory))
 542                            })
 543                            .when(is_foldable, |menu| {
 544                                menu.action("Fold Directory", Box::new(FoldDirectory))
 545                            })
 546                            .separator()
 547                            .action("Cut", Box::new(Cut))
 548                            .action("Copy", Box::new(Copy))
 549                            .action("Duplicate", Box::new(Duplicate))
 550                            // TODO: Paste should always be visible, cbut disabled when clipboard is empty
 551                            .map(|menu| {
 552                                if self.clipboard.as_ref().is_some() {
 553                                    menu.action("Paste", Box::new(Paste))
 554                                } else {
 555                                    menu.disabled_action("Paste", Box::new(Paste))
 556                                }
 557                            })
 558                            .separator()
 559                            .action("Copy Path", Box::new(CopyPath))
 560                            .action("Copy Relative Path", Box::new(CopyRelativePath))
 561                            .separator()
 562                            .action("Rename", Box::new(Rename))
 563                            .when(!is_root, |menu| {
 564                                menu.action("Trash", Box::new(Trash { skip_prompt: false }))
 565                                    .action("Delete", Box::new(Delete { skip_prompt: false }))
 566                            })
 567                            .when(!is_remote & is_root, |menu| {
 568                                menu.separator()
 569                                    .action(
 570                                        "Add Folder to Project…",
 571                                        Box::new(workspace::AddFolderToProject),
 572                                    )
 573                                    .entry(
 574                                        "Remove from Project",
 575                                        None,
 576                                        cx.handler_for(&this, move |this, cx| {
 577                                            this.project.update(cx, |project, cx| {
 578                                                project.remove_worktree(worktree_id, cx)
 579                                            });
 580                                        }),
 581                                    )
 582                            })
 583                            .when(is_root, |menu| {
 584                                menu.separator()
 585                                    .action("Collapse All", Box::new(CollapseAllEntries))
 586                            })
 587                    }
 588                })
 589            });
 590
 591            cx.focus_view(&context_menu);
 592            let subscription = cx.subscribe(&context_menu, |this, _, _: &DismissEvent, cx| {
 593                this.context_menu.take();
 594                cx.notify();
 595            });
 596            self.context_menu = Some((context_menu, position, subscription));
 597        }
 598
 599        cx.notify();
 600    }
 601
 602    fn is_unfoldable(&self, entry: &Entry, worktree: &Worktree) -> bool {
 603        if !entry.is_dir() || self.unfolded_dir_ids.contains(&entry.id) {
 604            return false;
 605        }
 606
 607        if let Some(parent_path) = entry.path.parent() {
 608            let snapshot = worktree.snapshot();
 609            let mut child_entries = snapshot.child_entries(parent_path);
 610            if let Some(child) = child_entries.next() {
 611                if child_entries.next().is_none() {
 612                    return child.kind.is_dir();
 613                }
 614            }
 615        };
 616        false
 617    }
 618
 619    fn is_foldable(&self, entry: &Entry, worktree: &Worktree) -> bool {
 620        if entry.is_dir() {
 621            let snapshot = worktree.snapshot();
 622
 623            let mut child_entries = snapshot.child_entries(&entry.path);
 624            if let Some(child) = child_entries.next() {
 625                if child_entries.next().is_none() {
 626                    return child.kind.is_dir();
 627                }
 628            }
 629        }
 630        false
 631    }
 632
 633    fn expand_selected_entry(&mut self, _: &ExpandSelectedEntry, cx: &mut ViewContext<Self>) {
 634        if let Some((worktree, entry)) = self.selected_entry(cx) {
 635            if let Some(folded_ancestors) = self.ancestors.get_mut(&entry.id) {
 636                if folded_ancestors.current_ancestor_depth > 0 {
 637                    folded_ancestors.current_ancestor_depth -= 1;
 638                    cx.notify();
 639                    return;
 640                }
 641            }
 642            if entry.is_dir() {
 643                let worktree_id = worktree.id();
 644                let entry_id = entry.id;
 645                let expanded_dir_ids =
 646                    if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
 647                        expanded_dir_ids
 648                    } else {
 649                        return;
 650                    };
 651
 652                match expanded_dir_ids.binary_search(&entry_id) {
 653                    Ok(_) => self.select_next(&SelectNext, cx),
 654                    Err(ix) => {
 655                        self.project.update(cx, |project, cx| {
 656                            project.expand_entry(worktree_id, entry_id, cx);
 657                        });
 658
 659                        expanded_dir_ids.insert(ix, entry_id);
 660                        self.update_visible_entries(None, cx);
 661                        cx.notify();
 662                    }
 663                }
 664            }
 665        }
 666    }
 667
 668    fn collapse_selected_entry(&mut self, _: &CollapseSelectedEntry, cx: &mut ViewContext<Self>) {
 669        let Some((worktree, entry)) = self.selected_entry_handle(cx) else {
 670            return;
 671        };
 672        self.collapse_entry(entry.clone(), worktree, cx)
 673    }
 674
 675    fn collapse_entry(
 676        &mut self,
 677        entry: Entry,
 678        worktree: Model<Worktree>,
 679        cx: &mut ViewContext<Self>,
 680    ) {
 681        let worktree = worktree.read(cx);
 682        if let Some(folded_ancestors) = self.ancestors.get_mut(&entry.id) {
 683            if folded_ancestors.current_ancestor_depth + 1 < folded_ancestors.max_ancestor_depth() {
 684                folded_ancestors.current_ancestor_depth += 1;
 685                cx.notify();
 686                return;
 687            }
 688        }
 689        let worktree_id = worktree.id();
 690        let expanded_dir_ids =
 691            if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
 692                expanded_dir_ids
 693            } else {
 694                return;
 695            };
 696
 697        let mut entry = &entry;
 698        loop {
 699            let entry_id = entry.id;
 700            match expanded_dir_ids.binary_search(&entry_id) {
 701                Ok(ix) => {
 702                    expanded_dir_ids.remove(ix);
 703                    self.update_visible_entries(Some((worktree_id, entry_id)), cx);
 704                    cx.notify();
 705                    break;
 706                }
 707                Err(_) => {
 708                    if let Some(parent_entry) =
 709                        entry.path.parent().and_then(|p| worktree.entry_for_path(p))
 710                    {
 711                        entry = parent_entry;
 712                    } else {
 713                        break;
 714                    }
 715                }
 716            }
 717        }
 718    }
 719
 720    pub fn collapse_all_entries(&mut self, _: &CollapseAllEntries, cx: &mut ViewContext<Self>) {
 721        // By keeping entries for fully collapsed worktrees, we avoid expanding them within update_visible_entries
 722        // (which is it's default behavior when there's no entry for a worktree in expanded_dir_ids).
 723        self.expanded_dir_ids
 724            .retain(|_, expanded_entries| expanded_entries.is_empty());
 725        self.update_visible_entries(None, cx);
 726        cx.notify();
 727    }
 728
 729    fn toggle_expanded(&mut self, entry_id: ProjectEntryId, cx: &mut ViewContext<Self>) {
 730        if let Some(worktree_id) = self.project.read(cx).worktree_id_for_entry(entry_id, cx) {
 731            if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
 732                self.project.update(cx, |project, cx| {
 733                    match expanded_dir_ids.binary_search(&entry_id) {
 734                        Ok(ix) => {
 735                            expanded_dir_ids.remove(ix);
 736                        }
 737                        Err(ix) => {
 738                            project.expand_entry(worktree_id, entry_id, cx);
 739                            expanded_dir_ids.insert(ix, entry_id);
 740                        }
 741                    }
 742                });
 743                self.update_visible_entries(Some((worktree_id, entry_id)), cx);
 744                cx.focus(&self.focus_handle);
 745                cx.notify();
 746            }
 747        }
 748    }
 749
 750    fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
 751        if let Some(edit_state) = &self.edit_state {
 752            if edit_state.processing_filename.is_none() {
 753                self.filename_editor.update(cx, |editor, cx| {
 754                    editor.move_to_beginning_of_line(
 755                        &editor::actions::MoveToBeginningOfLine {
 756                            stop_at_soft_wraps: false,
 757                        },
 758                        cx,
 759                    );
 760                });
 761                return;
 762            }
 763        }
 764        if let Some(selection) = self.selection {
 765            let (mut worktree_ix, mut entry_ix, _) =
 766                self.index_for_selection(selection).unwrap_or_default();
 767            if entry_ix > 0 {
 768                entry_ix -= 1;
 769            } else if worktree_ix > 0 {
 770                worktree_ix -= 1;
 771                entry_ix = self.visible_entries[worktree_ix].1.len() - 1;
 772            } else {
 773                return;
 774            }
 775
 776            let (worktree_id, worktree_entries, _) = &self.visible_entries[worktree_ix];
 777            let selection = SelectedEntry {
 778                worktree_id: *worktree_id,
 779                entry_id: worktree_entries[entry_ix].id,
 780            };
 781            self.selection = Some(selection);
 782            if cx.modifiers().shift {
 783                self.marked_entries.insert(selection);
 784            }
 785            self.autoscroll(cx);
 786            cx.notify();
 787        } else {
 788            self.select_first(&SelectFirst {}, cx);
 789        }
 790    }
 791
 792    fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
 793        if let Some(task) = self.confirm_edit(cx) {
 794            task.detach_and_notify_err(cx);
 795        }
 796    }
 797
 798    fn open(&mut self, _: &Open, cx: &mut ViewContext<Self>) {
 799        self.open_internal(false, true, false, cx);
 800    }
 801
 802    fn open_permanent(&mut self, _: &OpenPermanent, cx: &mut ViewContext<Self>) {
 803        self.open_internal(true, false, true, cx);
 804    }
 805
 806    fn open_internal(
 807        &mut self,
 808        mark_selected: bool,
 809        allow_preview: bool,
 810        focus_opened_item: bool,
 811        cx: &mut ViewContext<Self>,
 812    ) {
 813        if let Some((_, entry)) = self.selected_entry(cx) {
 814            if entry.is_file() {
 815                self.open_entry(
 816                    entry.id,
 817                    mark_selected,
 818                    focus_opened_item,
 819                    allow_preview,
 820                    cx,
 821                );
 822            } else {
 823                self.toggle_expanded(entry.id, cx);
 824            }
 825        }
 826    }
 827
 828    fn confirm_edit(&mut self, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
 829        let edit_state = self.edit_state.as_mut()?;
 830        cx.focus(&self.focus_handle);
 831
 832        let worktree_id = edit_state.worktree_id;
 833        let is_new_entry = edit_state.is_new_entry();
 834        let filename = self.filename_editor.read(cx).text(cx);
 835        edit_state.is_dir = edit_state.is_dir
 836            || (edit_state.is_new_entry() && filename.ends_with(std::path::MAIN_SEPARATOR));
 837        let is_dir = edit_state.is_dir;
 838        let worktree = self.project.read(cx).worktree_for_id(worktree_id, cx)?;
 839        let entry = worktree.read(cx).entry_for_id(edit_state.entry_id)?.clone();
 840
 841        let path_already_exists = |path| worktree.read(cx).entry_for_path(path).is_some();
 842        let edit_task;
 843        let edited_entry_id;
 844        if is_new_entry {
 845            self.selection = Some(SelectedEntry {
 846                worktree_id,
 847                entry_id: NEW_ENTRY_ID,
 848            });
 849            let new_path = entry.path.join(filename.trim_start_matches('/'));
 850            if path_already_exists(new_path.as_path()) {
 851                return None;
 852            }
 853
 854            edited_entry_id = NEW_ENTRY_ID;
 855            edit_task = self.project.update(cx, |project, cx| {
 856                project.create_entry((worktree_id, &new_path), is_dir, cx)
 857            });
 858        } else {
 859            let new_path = if let Some(parent) = entry.path.clone().parent() {
 860                parent.join(&filename)
 861            } else {
 862                filename.clone().into()
 863            };
 864            if path_already_exists(new_path.as_path()) {
 865                return None;
 866            }
 867            edited_entry_id = entry.id;
 868            edit_task = self.project.update(cx, |project, cx| {
 869                project.rename_entry(entry.id, new_path.as_path(), cx)
 870            });
 871        };
 872
 873        edit_state.processing_filename = Some(filename);
 874        cx.notify();
 875
 876        Some(cx.spawn(|project_panel, mut cx| async move {
 877            let new_entry = edit_task.await;
 878            project_panel.update(&mut cx, |project_panel, cx| {
 879                project_panel.edit_state = None;
 880                cx.notify();
 881            })?;
 882
 883            match new_entry {
 884                Err(e) => {
 885                    project_panel.update(&mut cx, |project_panel, cx| {
 886                        project_panel.marked_entries.clear();
 887                        project_panel.update_visible_entries(None, cx);
 888                    }).ok();
 889                    Err(e)?;
 890                }
 891                Ok(CreatedEntry::Included(new_entry)) => {
 892                    project_panel.update(&mut cx, |project_panel, cx| {
 893                        if let Some(selection) = &mut project_panel.selection {
 894                            if selection.entry_id == edited_entry_id {
 895                                selection.worktree_id = worktree_id;
 896                                selection.entry_id = new_entry.id;
 897                                project_panel.marked_entries.clear();
 898                                project_panel.expand_to_selection(cx);
 899                            }
 900                        }
 901                        project_panel.update_visible_entries(None, cx);
 902                        if is_new_entry && !is_dir {
 903                            project_panel.open_entry(new_entry.id, false, true, false, cx);
 904                        }
 905                        cx.notify();
 906                    })?;
 907                }
 908                Ok(CreatedEntry::Excluded { abs_path }) => {
 909                    if let Some(open_task) = project_panel
 910                        .update(&mut cx, |project_panel, cx| {
 911                            project_panel.marked_entries.clear();
 912                            project_panel.update_visible_entries(None, cx);
 913
 914                            if is_dir {
 915                                project_panel.project.update(cx, |_, cx| {
 916                                    cx.emit(project::Event::Toast {
 917                                        notification_id: "excluded-directory".into(),
 918                                        message: format!("Created an excluded directory at {abs_path:?}.\nAlter `file_scan_exclusions` in the settings to show it in the panel")
 919                                    })
 920                                });
 921                                None
 922                            } else {
 923                                project_panel
 924                                    .workspace
 925                                    .update(cx, |workspace, cx| {
 926                                        workspace.open_abs_path(abs_path, true, cx)
 927                                    })
 928                                    .ok()
 929                            }
 930                        })
 931                        .ok()
 932                        .flatten()
 933                    {
 934                        let _ = open_task.await?;
 935                    }
 936                }
 937            }
 938            Ok(())
 939        }))
 940    }
 941
 942    fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
 943        self.edit_state = None;
 944        self.update_visible_entries(None, cx);
 945        self.marked_entries.clear();
 946        cx.focus(&self.focus_handle);
 947        cx.notify();
 948    }
 949
 950    fn open_entry(
 951        &mut self,
 952        entry_id: ProjectEntryId,
 953        mark_selected: bool,
 954        focus_opened_item: bool,
 955        allow_preview: bool,
 956        cx: &mut ViewContext<Self>,
 957    ) {
 958        cx.emit(Event::OpenedEntry {
 959            entry_id,
 960            focus_opened_item,
 961            allow_preview,
 962            mark_selected,
 963        });
 964    }
 965
 966    fn split_entry(&mut self, entry_id: ProjectEntryId, cx: &mut ViewContext<Self>) {
 967        cx.emit(Event::SplitEntry { entry_id });
 968    }
 969
 970    fn new_file(&mut self, _: &NewFile, cx: &mut ViewContext<Self>) {
 971        self.add_entry(false, cx)
 972    }
 973
 974    fn new_directory(&mut self, _: &NewDirectory, cx: &mut ViewContext<Self>) {
 975        self.add_entry(true, cx)
 976    }
 977
 978    fn add_entry(&mut self, is_dir: bool, cx: &mut ViewContext<Self>) {
 979        if let Some(SelectedEntry {
 980            worktree_id,
 981            entry_id,
 982        }) = self.selection
 983        {
 984            let directory_id;
 985            let new_entry_id = self.resolve_entry(entry_id);
 986            if let Some((worktree, expanded_dir_ids)) = self
 987                .project
 988                .read(cx)
 989                .worktree_for_id(worktree_id, cx)
 990                .zip(self.expanded_dir_ids.get_mut(&worktree_id))
 991            {
 992                let worktree = worktree.read(cx);
 993                if let Some(mut entry) = worktree.entry_for_id(new_entry_id) {
 994                    loop {
 995                        if entry.is_dir() {
 996                            if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
 997                                expanded_dir_ids.insert(ix, entry.id);
 998                            }
 999                            directory_id = entry.id;
1000                            break;
1001                        } else {
1002                            if let Some(parent_path) = entry.path.parent() {
1003                                if let Some(parent_entry) = worktree.entry_for_path(parent_path) {
1004                                    entry = parent_entry;
1005                                    continue;
1006                                }
1007                            }
1008                            return;
1009                        }
1010                    }
1011                } else {
1012                    return;
1013                };
1014            } else {
1015                return;
1016            };
1017            self.marked_entries.clear();
1018            self.edit_state = Some(EditState {
1019                worktree_id,
1020                entry_id: directory_id,
1021                leaf_entry_id: None,
1022                is_dir,
1023                processing_filename: None,
1024                depth: 0,
1025            });
1026            self.filename_editor.update(cx, |editor, cx| {
1027                editor.clear(cx);
1028                editor.focus(cx);
1029            });
1030            self.update_visible_entries(Some((worktree_id, NEW_ENTRY_ID)), cx);
1031            self.autoscroll(cx);
1032            cx.notify();
1033        }
1034    }
1035
1036    fn unflatten_entry_id(&self, leaf_entry_id: ProjectEntryId) -> ProjectEntryId {
1037        if let Some(ancestors) = self.ancestors.get(&leaf_entry_id) {
1038            ancestors
1039                .ancestors
1040                .get(ancestors.current_ancestor_depth)
1041                .copied()
1042                .unwrap_or(leaf_entry_id)
1043        } else {
1044            leaf_entry_id
1045        }
1046    }
1047
1048    fn rename(&mut self, _: &Rename, cx: &mut ViewContext<Self>) {
1049        if let Some(SelectedEntry {
1050            worktree_id,
1051            entry_id,
1052        }) = self.selection
1053        {
1054            if let Some(worktree) = self.project.read(cx).worktree_for_id(worktree_id, cx) {
1055                let sub_entry_id = self.unflatten_entry_id(entry_id);
1056                if let Some(entry) = worktree.read(cx).entry_for_id(sub_entry_id) {
1057                    self.edit_state = Some(EditState {
1058                        worktree_id,
1059                        entry_id: sub_entry_id,
1060                        leaf_entry_id: Some(entry_id),
1061                        is_dir: entry.is_dir(),
1062                        processing_filename: None,
1063                        depth: 0,
1064                    });
1065                    let file_name = entry
1066                        .path
1067                        .file_name()
1068                        .map(|s| s.to_string_lossy())
1069                        .unwrap_or_default()
1070                        .to_string();
1071                    let file_stem = entry.path.file_stem().map(|s| s.to_string_lossy());
1072                    let selection_end =
1073                        file_stem.map_or(file_name.len(), |file_stem| file_stem.len());
1074                    self.filename_editor.update(cx, |editor, cx| {
1075                        editor.set_text(file_name, cx);
1076                        editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
1077                            s.select_ranges([0..selection_end])
1078                        });
1079                        editor.focus(cx);
1080                    });
1081                    self.update_visible_entries(None, cx);
1082                    self.autoscroll(cx);
1083                    cx.notify();
1084                }
1085            }
1086        }
1087    }
1088
1089    fn trash(&mut self, action: &Trash, cx: &mut ViewContext<Self>) {
1090        self.remove(true, action.skip_prompt, cx);
1091    }
1092
1093    fn delete(&mut self, action: &Delete, cx: &mut ViewContext<Self>) {
1094        self.remove(false, action.skip_prompt, cx);
1095    }
1096
1097    fn remove(&mut self, trash: bool, skip_prompt: bool, cx: &mut ViewContext<'_, ProjectPanel>) {
1098        maybe!({
1099            if self.marked_entries.is_empty() && self.selection.is_none() {
1100                return None;
1101            }
1102            let project = self.project.read(cx);
1103            let items_to_delete = self.marked_entries();
1104            let file_paths = items_to_delete
1105                .into_iter()
1106                .filter_map(|selection| {
1107                    Some((
1108                        selection.entry_id,
1109                        project
1110                            .path_for_entry(selection.entry_id, cx)?
1111                            .path
1112                            .file_name()?
1113                            .to_string_lossy()
1114                            .into_owned(),
1115                    ))
1116                })
1117                .collect::<Vec<_>>();
1118            if file_paths.is_empty() {
1119                return None;
1120            }
1121            let answer = if !skip_prompt {
1122                let operation = if trash { "Trash" } else { "Delete" };
1123
1124                let prompt =
1125                    if let Some((_, path)) = file_paths.first().filter(|_| file_paths.len() == 1) {
1126                        format!("{operation} {path}?")
1127                    } else {
1128                        const CUTOFF_POINT: usize = 10;
1129                        let names = if file_paths.len() > CUTOFF_POINT {
1130                            let truncated_path_counts = file_paths.len() - CUTOFF_POINT;
1131                            let mut paths = file_paths
1132                                .iter()
1133                                .map(|(_, path)| path.clone())
1134                                .take(CUTOFF_POINT)
1135                                .collect::<Vec<_>>();
1136                            paths.truncate(CUTOFF_POINT);
1137                            if truncated_path_counts == 1 {
1138                                paths.push(".. 1 file not shown".into());
1139                            } else {
1140                                paths.push(format!(".. {} files not shown", truncated_path_counts));
1141                            }
1142                            paths
1143                        } else {
1144                            file_paths.iter().map(|(_, path)| path.clone()).collect()
1145                        };
1146
1147                        format!(
1148                            "Do you want to {} the following {} files?\n{}",
1149                            operation.to_lowercase(),
1150                            file_paths.len(),
1151                            names.join("\n")
1152                        )
1153                    };
1154                Some(cx.prompt(PromptLevel::Info, &prompt, None, &[operation, "Cancel"]))
1155            } else {
1156                None
1157            };
1158
1159            cx.spawn(|this, mut cx| async move {
1160                if let Some(answer) = answer {
1161                    if answer.await != Ok(0) {
1162                        return Result::<(), anyhow::Error>::Ok(());
1163                    }
1164                }
1165                for (entry_id, _) in file_paths {
1166                    this.update(&mut cx, |this, cx| {
1167                        this.project
1168                            .update(cx, |project, cx| project.delete_entry(entry_id, trash, cx))
1169                            .ok_or_else(|| anyhow!("no such entry"))
1170                    })??
1171                    .await?;
1172                }
1173                Result::<(), anyhow::Error>::Ok(())
1174            })
1175            .detach_and_log_err(cx);
1176            Some(())
1177        });
1178    }
1179
1180    fn unfold_directory(&mut self, _: &UnfoldDirectory, cx: &mut ViewContext<Self>) {
1181        if let Some((worktree, entry)) = self.selected_entry(cx) {
1182            self.unfolded_dir_ids.insert(entry.id);
1183
1184            let snapshot = worktree.snapshot();
1185            let mut parent_path = entry.path.parent();
1186            while let Some(path) = parent_path {
1187                if let Some(parent_entry) = worktree.entry_for_path(path) {
1188                    let mut children_iter = snapshot.child_entries(path);
1189
1190                    if children_iter.by_ref().take(2).count() > 1 {
1191                        break;
1192                    }
1193
1194                    self.unfolded_dir_ids.insert(parent_entry.id);
1195                    parent_path = path.parent();
1196                } else {
1197                    break;
1198                }
1199            }
1200
1201            self.update_visible_entries(None, cx);
1202            self.autoscroll(cx);
1203            cx.notify();
1204        }
1205    }
1206
1207    fn fold_directory(&mut self, _: &FoldDirectory, cx: &mut ViewContext<Self>) {
1208        if let Some((worktree, entry)) = self.selected_entry(cx) {
1209            self.unfolded_dir_ids.remove(&entry.id);
1210
1211            let snapshot = worktree.snapshot();
1212            let mut path = &*entry.path;
1213            loop {
1214                let mut child_entries_iter = snapshot.child_entries(path);
1215                if let Some(child) = child_entries_iter.next() {
1216                    if child_entries_iter.next().is_none() && child.is_dir() {
1217                        self.unfolded_dir_ids.remove(&child.id);
1218                        path = &*child.path;
1219                    } else {
1220                        break;
1221                    }
1222                } else {
1223                    break;
1224                }
1225            }
1226
1227            self.update_visible_entries(None, cx);
1228            self.autoscroll(cx);
1229            cx.notify();
1230        }
1231    }
1232
1233    fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
1234        if let Some(edit_state) = &self.edit_state {
1235            if edit_state.processing_filename.is_none() {
1236                self.filename_editor.update(cx, |editor, cx| {
1237                    editor.move_to_end_of_line(
1238                        &editor::actions::MoveToEndOfLine {
1239                            stop_at_soft_wraps: false,
1240                        },
1241                        cx,
1242                    );
1243                });
1244                return;
1245            }
1246        }
1247        if let Some(selection) = self.selection {
1248            let (mut worktree_ix, mut entry_ix, _) =
1249                self.index_for_selection(selection).unwrap_or_default();
1250            if let Some((_, worktree_entries, _)) = self.visible_entries.get(worktree_ix) {
1251                if entry_ix + 1 < worktree_entries.len() {
1252                    entry_ix += 1;
1253                } else {
1254                    worktree_ix += 1;
1255                    entry_ix = 0;
1256                }
1257            }
1258
1259            if let Some((worktree_id, worktree_entries, _)) = self.visible_entries.get(worktree_ix)
1260            {
1261                if let Some(entry) = worktree_entries.get(entry_ix) {
1262                    let selection = SelectedEntry {
1263                        worktree_id: *worktree_id,
1264                        entry_id: entry.id,
1265                    };
1266                    self.selection = Some(selection);
1267                    if cx.modifiers().shift {
1268                        self.marked_entries.insert(selection);
1269                    }
1270
1271                    self.autoscroll(cx);
1272                    cx.notify();
1273                }
1274            }
1275        } else {
1276            self.select_first(&SelectFirst {}, cx);
1277        }
1278    }
1279
1280    fn select_parent(&mut self, _: &SelectParent, cx: &mut ViewContext<Self>) {
1281        if let Some((worktree, entry)) = self.selected_sub_entry(cx) {
1282            if let Some(parent) = entry.path.parent() {
1283                let worktree = worktree.read(cx);
1284                if let Some(parent_entry) = worktree.entry_for_path(parent) {
1285                    self.selection = Some(SelectedEntry {
1286                        worktree_id: worktree.id(),
1287                        entry_id: parent_entry.id,
1288                    });
1289                    self.autoscroll(cx);
1290                    cx.notify();
1291                }
1292            }
1293        } else {
1294            self.select_first(&SelectFirst {}, cx);
1295        }
1296    }
1297
1298    fn select_first(&mut self, _: &SelectFirst, cx: &mut ViewContext<Self>) {
1299        let worktree = self
1300            .visible_entries
1301            .first()
1302            .and_then(|(worktree_id, _, _)| {
1303                self.project.read(cx).worktree_for_id(*worktree_id, cx)
1304            });
1305        if let Some(worktree) = worktree {
1306            let worktree = worktree.read(cx);
1307            let worktree_id = worktree.id();
1308            if let Some(root_entry) = worktree.root_entry() {
1309                let selection = SelectedEntry {
1310                    worktree_id,
1311                    entry_id: root_entry.id,
1312                };
1313                self.selection = Some(selection);
1314                if cx.modifiers().shift {
1315                    self.marked_entries.insert(selection);
1316                }
1317                self.autoscroll(cx);
1318                cx.notify();
1319            }
1320        }
1321    }
1322
1323    fn select_last(&mut self, _: &SelectLast, cx: &mut ViewContext<Self>) {
1324        let worktree = self.visible_entries.last().and_then(|(worktree_id, _, _)| {
1325            self.project.read(cx).worktree_for_id(*worktree_id, cx)
1326        });
1327        if let Some(worktree) = worktree {
1328            let worktree = worktree.read(cx);
1329            let worktree_id = worktree.id();
1330            if let Some(last_entry) = worktree.entries(true, 0).last() {
1331                self.selection = Some(SelectedEntry {
1332                    worktree_id,
1333                    entry_id: last_entry.id,
1334                });
1335                self.autoscroll(cx);
1336                cx.notify();
1337            }
1338        }
1339    }
1340
1341    fn autoscroll(&mut self, cx: &mut ViewContext<Self>) {
1342        if let Some((_, _, index)) = self.selection.and_then(|s| self.index_for_selection(s)) {
1343            self.scroll_handle.scroll_to_item(index);
1344            cx.notify();
1345        }
1346    }
1347
1348    fn cut(&mut self, _: &Cut, cx: &mut ViewContext<Self>) {
1349        let entries = self.marked_entries();
1350        if !entries.is_empty() {
1351            self.clipboard = Some(ClipboardEntry::Cut(entries));
1352            cx.notify();
1353        }
1354    }
1355
1356    fn copy(&mut self, _: &Copy, cx: &mut ViewContext<Self>) {
1357        let entries = self.marked_entries();
1358        if !entries.is_empty() {
1359            self.clipboard = Some(ClipboardEntry::Copied(entries));
1360            cx.notify();
1361        }
1362    }
1363
1364    fn create_paste_path(
1365        &self,
1366        source: &SelectedEntry,
1367        (worktree, target_entry): (Model<Worktree>, &Entry),
1368        cx: &AppContext,
1369    ) -> Option<PathBuf> {
1370        let mut new_path = target_entry.path.to_path_buf();
1371        // If we're pasting into a file, or a directory into itself, go up one level.
1372        if target_entry.is_file() || (target_entry.is_dir() && target_entry.id == source.entry_id) {
1373            new_path.pop();
1374        }
1375        let clipboard_entry_file_name = self
1376            .project
1377            .read(cx)
1378            .path_for_entry(source.entry_id, cx)?
1379            .path
1380            .file_name()?
1381            .to_os_string();
1382        new_path.push(&clipboard_entry_file_name);
1383        let extension = new_path.extension().map(|e| e.to_os_string());
1384        let file_name_without_extension = Path::new(&clipboard_entry_file_name).file_stem()?;
1385        let mut ix = 0;
1386        {
1387            let worktree = worktree.read(cx);
1388            while worktree.entry_for_path(&new_path).is_some() {
1389                new_path.pop();
1390
1391                let mut new_file_name = file_name_without_extension.to_os_string();
1392                new_file_name.push(" copy");
1393                if ix > 0 {
1394                    new_file_name.push(format!(" {}", ix));
1395                }
1396                if let Some(extension) = extension.as_ref() {
1397                    new_file_name.push(".");
1398                    new_file_name.push(extension);
1399                }
1400
1401                new_path.push(new_file_name);
1402                ix += 1;
1403            }
1404        }
1405        Some(new_path)
1406    }
1407
1408    fn paste(&mut self, _: &Paste, cx: &mut ViewContext<Self>) {
1409        maybe!({
1410            let (worktree, entry) = self.selected_entry_handle(cx)?;
1411            let entry = entry.clone();
1412            let worktree_id = worktree.read(cx).id();
1413            let clipboard_entries = self
1414                .clipboard
1415                .as_ref()
1416                .filter(|clipboard| !clipboard.items().is_empty())?;
1417            enum PasteTask {
1418                Rename(Task<Result<CreatedEntry>>),
1419                Copy(Task<Result<Option<Entry>>>),
1420            }
1421            let mut paste_entry_tasks: IndexMap<(ProjectEntryId, bool), PasteTask> =
1422                IndexMap::default();
1423            let clip_is_cut = clipboard_entries.is_cut();
1424            for clipboard_entry in clipboard_entries.items() {
1425                let new_path =
1426                    self.create_paste_path(clipboard_entry, self.selected_sub_entry(cx)?, cx)?;
1427                let clip_entry_id = clipboard_entry.entry_id;
1428                let is_same_worktree = clipboard_entry.worktree_id == worktree_id;
1429                let relative_worktree_source_path = if !is_same_worktree {
1430                    let target_base_path = worktree.read(cx).abs_path();
1431                    let clipboard_project_path =
1432                        self.project.read(cx).path_for_entry(clip_entry_id, cx)?;
1433                    let clipboard_abs_path = self
1434                        .project
1435                        .read(cx)
1436                        .absolute_path(&clipboard_project_path, cx)?;
1437                    Some(relativize_path(
1438                        &target_base_path,
1439                        clipboard_abs_path.as_path(),
1440                    ))
1441                } else {
1442                    None
1443                };
1444                let task = if clip_is_cut && is_same_worktree {
1445                    let task = self.project.update(cx, |project, cx| {
1446                        project.rename_entry(clip_entry_id, new_path, cx)
1447                    });
1448                    PasteTask::Rename(task)
1449                } else {
1450                    let entry_id = if is_same_worktree {
1451                        clip_entry_id
1452                    } else {
1453                        entry.id
1454                    };
1455                    let task = self.project.update(cx, |project, cx| {
1456                        project.copy_entry(entry_id, relative_worktree_source_path, new_path, cx)
1457                    });
1458                    PasteTask::Copy(task)
1459                };
1460                let needs_delete = !is_same_worktree && clip_is_cut;
1461                paste_entry_tasks.insert((clip_entry_id, needs_delete), task);
1462            }
1463
1464            cx.spawn(|project_panel, mut cx| async move {
1465                let mut last_succeed = None;
1466                let mut need_delete_ids = Vec::new();
1467                for ((entry_id, need_delete), task) in paste_entry_tasks.into_iter() {
1468                    match task {
1469                        PasteTask::Rename(task) => {
1470                            if let Some(CreatedEntry::Included(entry)) = task.await.log_err() {
1471                                last_succeed = Some(entry.id);
1472                            }
1473                        }
1474                        PasteTask::Copy(task) => {
1475                            if let Some(Some(entry)) = task.await.log_err() {
1476                                last_succeed = Some(entry.id);
1477                                if need_delete {
1478                                    need_delete_ids.push(entry_id);
1479                                }
1480                            }
1481                        }
1482                    }
1483                }
1484                // update selection
1485                if let Some(entry_id) = last_succeed {
1486                    project_panel
1487                        .update(&mut cx, |project_panel, _cx| {
1488                            project_panel.selection = Some(SelectedEntry {
1489                                worktree_id,
1490                                entry_id,
1491                            });
1492                        })
1493                        .ok();
1494                }
1495                // remove entry for cut in difference worktree
1496                for entry_id in need_delete_ids {
1497                    project_panel
1498                        .update(&mut cx, |project_panel, cx| {
1499                            project_panel
1500                                .project
1501                                .update(cx, |project, cx| project.delete_entry(entry_id, true, cx))
1502                                .ok_or_else(|| anyhow!("no such entry"))
1503                        })??
1504                        .await?;
1505                }
1506
1507                anyhow::Ok(())
1508            })
1509            .detach_and_log_err(cx);
1510
1511            self.expand_entry(worktree_id, entry.id, cx);
1512            Some(())
1513        });
1514    }
1515
1516    fn duplicate(&mut self, _: &Duplicate, cx: &mut ViewContext<Self>) {
1517        self.copy(&Copy {}, cx);
1518        self.paste(&Paste {}, cx);
1519    }
1520
1521    fn copy_path(&mut self, _: &CopyPath, cx: &mut ViewContext<Self>) {
1522        let abs_file_paths = {
1523            let project = self.project.read(cx);
1524            self.marked_entries()
1525                .into_iter()
1526                .filter_map(|entry| {
1527                    let entry_path = project.path_for_entry(entry.entry_id, cx)?.path;
1528                    Some(
1529                        project
1530                            .worktree_for_id(entry.worktree_id, cx)?
1531                            .read(cx)
1532                            .abs_path()
1533                            .join(entry_path)
1534                            .to_string_lossy()
1535                            .to_string(),
1536                    )
1537                })
1538                .collect::<Vec<_>>()
1539        };
1540        if !abs_file_paths.is_empty() {
1541            cx.write_to_clipboard(ClipboardItem::new_string(abs_file_paths.join("\n")));
1542        }
1543    }
1544
1545    fn copy_relative_path(&mut self, _: &CopyRelativePath, cx: &mut ViewContext<Self>) {
1546        let file_paths = {
1547            let project = self.project.read(cx);
1548            self.marked_entries()
1549                .into_iter()
1550                .filter_map(|entry| {
1551                    Some(
1552                        project
1553                            .path_for_entry(entry.entry_id, cx)?
1554                            .path
1555                            .to_string_lossy()
1556                            .to_string(),
1557                    )
1558                })
1559                .collect::<Vec<_>>()
1560        };
1561        if !file_paths.is_empty() {
1562            cx.write_to_clipboard(ClipboardItem::new_string(file_paths.join("\n")));
1563        }
1564    }
1565
1566    fn reveal_in_finder(&mut self, _: &RevealInFileManager, cx: &mut ViewContext<Self>) {
1567        if let Some((worktree, entry)) = self.selected_sub_entry(cx) {
1568            cx.reveal_path(&worktree.read(cx).abs_path().join(&entry.path));
1569        }
1570    }
1571
1572    fn open_system(&mut self, _: &OpenWithSystem, cx: &mut ViewContext<Self>) {
1573        if let Some((worktree, entry)) = self.selected_entry(cx) {
1574            let abs_path = worktree.abs_path().join(&entry.path);
1575            cx.open_with_system(&abs_path);
1576        }
1577    }
1578
1579    fn open_in_terminal(&mut self, _: &OpenInTerminal, cx: &mut ViewContext<Self>) {
1580        if let Some((worktree, entry)) = self.selected_sub_entry(cx) {
1581            let abs_path = match &entry.canonical_path {
1582                Some(canonical_path) => Some(canonical_path.to_path_buf()),
1583                None => worktree.read(cx).absolutize(&entry.path).ok(),
1584            };
1585
1586            let working_directory = if entry.is_dir() {
1587                abs_path
1588            } else {
1589                abs_path.and_then(|path| Some(path.parent()?.to_path_buf()))
1590            };
1591            if let Some(working_directory) = working_directory {
1592                cx.dispatch_action(workspace::OpenTerminal { working_directory }.boxed_clone())
1593            }
1594        }
1595    }
1596
1597    pub fn new_search_in_directory(
1598        &mut self,
1599        _: &NewSearchInDirectory,
1600        cx: &mut ViewContext<Self>,
1601    ) {
1602        if let Some((worktree, entry)) = self.selected_sub_entry(cx) {
1603            if entry.is_dir() {
1604                let include_root = self.project.read(cx).visible_worktrees(cx).count() > 1;
1605                let dir_path = if include_root {
1606                    let mut full_path = PathBuf::from(worktree.read(cx).root_name());
1607                    full_path.push(&entry.path);
1608                    Arc::from(full_path)
1609                } else {
1610                    entry.path.clone()
1611                };
1612
1613                self.workspace
1614                    .update(cx, |workspace, cx| {
1615                        search::ProjectSearchView::new_search_in_directory(
1616                            workspace, &dir_path, cx,
1617                        );
1618                    })
1619                    .ok();
1620            }
1621        }
1622    }
1623
1624    fn move_entry(
1625        &mut self,
1626        entry_to_move: ProjectEntryId,
1627        destination: ProjectEntryId,
1628        destination_is_file: bool,
1629        cx: &mut ViewContext<Self>,
1630    ) {
1631        if self
1632            .project
1633            .read(cx)
1634            .entry_is_worktree_root(entry_to_move, cx)
1635        {
1636            self.move_worktree_root(entry_to_move, destination, cx)
1637        } else {
1638            self.move_worktree_entry(entry_to_move, destination, destination_is_file, cx)
1639        }
1640    }
1641
1642    fn move_worktree_root(
1643        &mut self,
1644        entry_to_move: ProjectEntryId,
1645        destination: ProjectEntryId,
1646        cx: &mut ViewContext<Self>,
1647    ) {
1648        self.project.update(cx, |project, cx| {
1649            let Some(worktree_to_move) = project.worktree_for_entry(entry_to_move, cx) else {
1650                return;
1651            };
1652            let Some(destination_worktree) = project.worktree_for_entry(destination, cx) else {
1653                return;
1654            };
1655
1656            let worktree_id = worktree_to_move.read(cx).id();
1657            let destination_id = destination_worktree.read(cx).id();
1658
1659            project
1660                .move_worktree(worktree_id, destination_id, cx)
1661                .log_err();
1662        });
1663    }
1664
1665    fn move_worktree_entry(
1666        &mut self,
1667        entry_to_move: ProjectEntryId,
1668        destination: ProjectEntryId,
1669        destination_is_file: bool,
1670        cx: &mut ViewContext<Self>,
1671    ) {
1672        let destination_worktree = self.project.update(cx, |project, cx| {
1673            let entry_path = project.path_for_entry(entry_to_move, cx)?;
1674            let destination_entry_path = project.path_for_entry(destination, cx)?.path.clone();
1675
1676            let mut destination_path = destination_entry_path.as_ref();
1677            if destination_is_file {
1678                destination_path = destination_path.parent()?;
1679            }
1680
1681            let mut new_path = destination_path.to_path_buf();
1682            new_path.push(entry_path.path.file_name()?);
1683            if new_path != entry_path.path.as_ref() {
1684                let task = project.rename_entry(entry_to_move, new_path, cx);
1685                cx.foreground_executor().spawn(task).detach_and_log_err(cx);
1686            }
1687
1688            project.worktree_id_for_entry(destination, cx)
1689        });
1690
1691        if let Some(destination_worktree) = destination_worktree {
1692            self.expand_entry(destination_worktree, destination, cx);
1693        }
1694    }
1695
1696    fn index_for_selection(&self, selection: SelectedEntry) -> Option<(usize, usize, usize)> {
1697        let mut entry_index = 0;
1698        let mut visible_entries_index = 0;
1699        for (worktree_index, (worktree_id, worktree_entries, _)) in
1700            self.visible_entries.iter().enumerate()
1701        {
1702            if *worktree_id == selection.worktree_id {
1703                for entry in worktree_entries {
1704                    if entry.id == selection.entry_id {
1705                        return Some((worktree_index, entry_index, visible_entries_index));
1706                    } else {
1707                        visible_entries_index += 1;
1708                        entry_index += 1;
1709                    }
1710                }
1711                break;
1712            } else {
1713                visible_entries_index += worktree_entries.len();
1714            }
1715        }
1716        None
1717    }
1718
1719    // Returns list of entries that should be affected by an operation.
1720    // When currently selected entry is not marked, it's treated as the only marked entry.
1721    fn marked_entries(&self) -> BTreeSet<SelectedEntry> {
1722        let Some(mut selection) = self.selection else {
1723            return Default::default();
1724        };
1725        if self.marked_entries.contains(&selection) {
1726            self.marked_entries
1727                .iter()
1728                .copied()
1729                .map(|mut entry| {
1730                    entry.entry_id = self.resolve_entry(entry.entry_id);
1731                    entry
1732                })
1733                .collect()
1734        } else {
1735            selection.entry_id = self.resolve_entry(selection.entry_id);
1736            BTreeSet::from_iter([selection])
1737        }
1738    }
1739
1740    /// Finds the currently selected subentry for a given leaf entry id. If a given entry
1741    /// has no ancestors, the project entry ID that's passed in is returned as-is.
1742    fn resolve_entry(&self, id: ProjectEntryId) -> ProjectEntryId {
1743        self.ancestors
1744            .get(&id)
1745            .and_then(|ancestors| {
1746                if ancestors.current_ancestor_depth == 0 {
1747                    return None;
1748                }
1749                ancestors.ancestors.get(ancestors.current_ancestor_depth)
1750            })
1751            .copied()
1752            .unwrap_or(id)
1753    }
1754
1755    pub fn selected_entry<'a>(
1756        &self,
1757        cx: &'a AppContext,
1758    ) -> Option<(&'a Worktree, &'a project::Entry)> {
1759        let (worktree, entry) = self.selected_entry_handle(cx)?;
1760        Some((worktree.read(cx), entry))
1761    }
1762
1763    /// Compared to selected_entry, this function resolves to the currently
1764    /// selected subentry if dir auto-folding is enabled.
1765    fn selected_sub_entry<'a>(
1766        &self,
1767        cx: &'a AppContext,
1768    ) -> Option<(Model<Worktree>, &'a project::Entry)> {
1769        let (worktree, mut entry) = self.selected_entry_handle(cx)?;
1770
1771        let resolved_id = self.resolve_entry(entry.id);
1772        if resolved_id != entry.id {
1773            let worktree = worktree.read(cx);
1774            entry = worktree.entry_for_id(resolved_id)?;
1775        }
1776        Some((worktree, entry))
1777    }
1778    fn selected_entry_handle<'a>(
1779        &self,
1780        cx: &'a AppContext,
1781    ) -> Option<(Model<Worktree>, &'a project::Entry)> {
1782        let selection = self.selection?;
1783        let project = self.project.read(cx);
1784        let worktree = project.worktree_for_id(selection.worktree_id, cx)?;
1785        let entry = worktree.read(cx).entry_for_id(selection.entry_id)?;
1786        Some((worktree, entry))
1787    }
1788
1789    fn expand_to_selection(&mut self, cx: &mut ViewContext<Self>) -> Option<()> {
1790        let (worktree, entry) = self.selected_entry(cx)?;
1791        let expanded_dir_ids = self.expanded_dir_ids.entry(worktree.id()).or_default();
1792
1793        for path in entry.path.ancestors() {
1794            let Some(entry) = worktree.entry_for_path(path) else {
1795                continue;
1796            };
1797            if entry.is_dir() {
1798                if let Err(idx) = expanded_dir_ids.binary_search(&entry.id) {
1799                    expanded_dir_ids.insert(idx, entry.id);
1800                }
1801            }
1802        }
1803
1804        Some(())
1805    }
1806
1807    fn update_visible_entries(
1808        &mut self,
1809        new_selected_entry: Option<(WorktreeId, ProjectEntryId)>,
1810        cx: &mut ViewContext<Self>,
1811    ) {
1812        let auto_collapse_dirs = ProjectPanelSettings::get_global(cx).auto_fold_dirs;
1813        let project = self.project.read(cx);
1814        self.last_worktree_root_id = project
1815            .visible_worktrees(cx)
1816            .next_back()
1817            .and_then(|worktree| worktree.read(cx).root_entry())
1818            .map(|entry| entry.id);
1819
1820        let old_ancestors = std::mem::take(&mut self.ancestors);
1821        self.visible_entries.clear();
1822        let mut max_width_item = None;
1823        for worktree in project.visible_worktrees(cx) {
1824            let snapshot = worktree.read(cx).snapshot();
1825            let worktree_id = snapshot.id();
1826
1827            let expanded_dir_ids = match self.expanded_dir_ids.entry(worktree_id) {
1828                hash_map::Entry::Occupied(e) => e.into_mut(),
1829                hash_map::Entry::Vacant(e) => {
1830                    // The first time a worktree's root entry becomes available,
1831                    // mark that root entry as expanded.
1832                    if let Some(entry) = snapshot.root_entry() {
1833                        e.insert(vec![entry.id]).as_slice()
1834                    } else {
1835                        &[]
1836                    }
1837                }
1838            };
1839
1840            let mut new_entry_parent_id = None;
1841            let mut new_entry_kind = EntryKind::Dir;
1842            if let Some(edit_state) = &self.edit_state {
1843                if edit_state.worktree_id == worktree_id && edit_state.is_new_entry() {
1844                    new_entry_parent_id = Some(edit_state.entry_id);
1845                    new_entry_kind = if edit_state.is_dir {
1846                        EntryKind::Dir
1847                    } else {
1848                        EntryKind::File
1849                    };
1850                }
1851            }
1852
1853            let mut visible_worktree_entries = Vec::new();
1854            let mut entry_iter = snapshot.entries(true, 0);
1855            let mut auto_folded_ancestors = vec![];
1856            while let Some(entry) = entry_iter.entry() {
1857                if auto_collapse_dirs && entry.kind.is_dir() {
1858                    auto_folded_ancestors.push(entry.id);
1859                    if !self.unfolded_dir_ids.contains(&entry.id) {
1860                        if let Some(root_path) = snapshot.root_entry() {
1861                            let mut child_entries = snapshot.child_entries(&entry.path);
1862                            if let Some(child) = child_entries.next() {
1863                                if entry.path != root_path.path
1864                                    && child_entries.next().is_none()
1865                                    && child.kind.is_dir()
1866                                {
1867                                    entry_iter.advance();
1868
1869                                    continue;
1870                                }
1871                            }
1872                        }
1873                    }
1874                    let depth = old_ancestors
1875                        .get(&entry.id)
1876                        .map(|ancestor| ancestor.current_ancestor_depth)
1877                        .unwrap_or_default();
1878                    if let Some(edit_state) = &mut self.edit_state {
1879                        if edit_state.entry_id == entry.id {
1880                            edit_state.depth = depth;
1881                        }
1882                    }
1883                    let mut ancestors = std::mem::take(&mut auto_folded_ancestors);
1884                    if ancestors.len() > 1 {
1885                        ancestors.reverse();
1886                        self.ancestors.insert(
1887                            entry.id,
1888                            FoldedAncestors {
1889                                current_ancestor_depth: depth,
1890                                ancestors,
1891                            },
1892                        );
1893                    }
1894                }
1895                auto_folded_ancestors.clear();
1896                visible_worktree_entries.push(entry.clone());
1897                let precedes_new_entry = if let Some(new_entry_id) = new_entry_parent_id {
1898                    entry.id == new_entry_id || {
1899                        self.ancestors.get(&entry.id).map_or(false, |entries| {
1900                            entries
1901                                .ancestors
1902                                .iter()
1903                                .any(|entry_id| *entry_id == new_entry_id)
1904                        })
1905                    }
1906                } else {
1907                    false
1908                };
1909                if precedes_new_entry {
1910                    visible_worktree_entries.push(Entry {
1911                        id: NEW_ENTRY_ID,
1912                        kind: new_entry_kind,
1913                        path: entry.path.join("\0").into(),
1914                        inode: 0,
1915                        mtime: entry.mtime,
1916                        size: entry.size,
1917                        is_ignored: entry.is_ignored,
1918                        is_external: false,
1919                        is_private: false,
1920                        git_status: entry.git_status,
1921                        canonical_path: entry.canonical_path.clone(),
1922                        char_bag: entry.char_bag,
1923                        is_fifo: entry.is_fifo,
1924                    });
1925                }
1926                let worktree_abs_path = worktree.read(cx).abs_path();
1927                let (depth, path) = if Some(entry) == worktree.read(cx).root_entry() {
1928                    let Some(path_name) = worktree_abs_path
1929                        .file_name()
1930                        .with_context(|| {
1931                            format!("Worktree abs path has no file name, root entry: {entry:?}")
1932                        })
1933                        .log_err()
1934                    else {
1935                        continue;
1936                    };
1937                    let path = Arc::from(Path::new(path_name));
1938                    let depth = 0;
1939                    (depth, path)
1940                } else if entry.is_file() {
1941                    let Some(path_name) = entry
1942                        .path
1943                        .file_name()
1944                        .with_context(|| format!("Non-root entry has no file name: {entry:?}"))
1945                        .log_err()
1946                    else {
1947                        continue;
1948                    };
1949                    let path = Arc::from(Path::new(path_name));
1950                    let depth = entry.path.ancestors().count() - 1;
1951                    (depth, path)
1952                } else {
1953                    let path = self
1954                        .ancestors
1955                        .get(&entry.id)
1956                        .and_then(|ancestors| {
1957                            let outermost_ancestor = ancestors.ancestors.last()?;
1958                            let root_folded_entry = worktree
1959                                .read(cx)
1960                                .entry_for_id(*outermost_ancestor)?
1961                                .path
1962                                .as_ref();
1963                            entry
1964                                .path
1965                                .strip_prefix(root_folded_entry)
1966                                .ok()
1967                                .and_then(|suffix| {
1968                                    let full_path = Path::new(root_folded_entry.file_name()?);
1969                                    Some(Arc::<Path>::from(full_path.join(suffix)))
1970                                })
1971                        })
1972                        .or_else(|| entry.path.file_name().map(Path::new).map(Arc::from))
1973                        .unwrap_or_else(|| entry.path.clone());
1974                    let depth = path.components().count();
1975                    (depth, path)
1976                };
1977                let width_estimate = item_width_estimate(
1978                    depth,
1979                    path.to_string_lossy().chars().count(),
1980                    entry.canonical_path.is_some(),
1981                );
1982
1983                match max_width_item.as_mut() {
1984                    Some((id, worktree_id, width)) => {
1985                        if *width < width_estimate {
1986                            *id = entry.id;
1987                            *worktree_id = worktree.read(cx).id();
1988                            *width = width_estimate;
1989                        }
1990                    }
1991                    None => {
1992                        max_width_item = Some((entry.id, worktree.read(cx).id(), width_estimate))
1993                    }
1994                }
1995
1996                if expanded_dir_ids.binary_search(&entry.id).is_err()
1997                    && entry_iter.advance_to_sibling()
1998                {
1999                    continue;
2000                }
2001                entry_iter.advance();
2002            }
2003
2004            snapshot.propagate_git_statuses(&mut visible_worktree_entries);
2005            project::sort_worktree_entries(&mut visible_worktree_entries);
2006            self.visible_entries
2007                .push((worktree_id, visible_worktree_entries, OnceCell::new()));
2008        }
2009
2010        if let Some((project_entry_id, worktree_id, _)) = max_width_item {
2011            let mut visited_worktrees_length = 0;
2012            let index = self.visible_entries.iter().find_map(|(id, entries, _)| {
2013                if worktree_id == *id {
2014                    entries
2015                        .iter()
2016                        .position(|entry| entry.id == project_entry_id)
2017                } else {
2018                    visited_worktrees_length += entries.len();
2019                    None
2020                }
2021            });
2022            if let Some(index) = index {
2023                self.max_width_item_index = Some(visited_worktrees_length + index);
2024            }
2025        }
2026        if let Some((worktree_id, entry_id)) = new_selected_entry {
2027            self.selection = Some(SelectedEntry {
2028                worktree_id,
2029                entry_id,
2030            });
2031            if cx.modifiers().shift {
2032                self.marked_entries.insert(SelectedEntry {
2033                    worktree_id,
2034                    entry_id,
2035                });
2036            }
2037        }
2038    }
2039
2040    fn expand_entry(
2041        &mut self,
2042        worktree_id: WorktreeId,
2043        entry_id: ProjectEntryId,
2044        cx: &mut ViewContext<Self>,
2045    ) {
2046        self.project.update(cx, |project, cx| {
2047            if let Some((worktree, expanded_dir_ids)) = project
2048                .worktree_for_id(worktree_id, cx)
2049                .zip(self.expanded_dir_ids.get_mut(&worktree_id))
2050            {
2051                project.expand_entry(worktree_id, entry_id, cx);
2052                let worktree = worktree.read(cx);
2053
2054                if let Some(mut entry) = worktree.entry_for_id(entry_id) {
2055                    loop {
2056                        if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
2057                            expanded_dir_ids.insert(ix, entry.id);
2058                        }
2059
2060                        if let Some(parent_entry) =
2061                            entry.path.parent().and_then(|p| worktree.entry_for_path(p))
2062                        {
2063                            entry = parent_entry;
2064                        } else {
2065                            break;
2066                        }
2067                    }
2068                }
2069            }
2070        });
2071    }
2072
2073    fn drop_external_files(
2074        &mut self,
2075        paths: &[PathBuf],
2076        entry_id: ProjectEntryId,
2077        cx: &mut ViewContext<Self>,
2078    ) {
2079        let mut paths: Vec<Arc<Path>> = paths.iter().map(|path| Arc::from(path.clone())).collect();
2080
2081        let open_file_after_drop = paths.len() == 1 && paths[0].is_file();
2082
2083        let Some((target_directory, worktree)) = maybe!({
2084            let worktree = self.project.read(cx).worktree_for_entry(entry_id, cx)?;
2085            let entry = worktree.read(cx).entry_for_id(entry_id)?;
2086            let path = worktree.read(cx).absolutize(&entry.path).ok()?;
2087            let target_directory = if path.is_dir() {
2088                path
2089            } else {
2090                path.parent()?.to_path_buf()
2091            };
2092            Some((target_directory, worktree))
2093        }) else {
2094            return;
2095        };
2096
2097        let mut paths_to_replace = Vec::new();
2098        for path in &paths {
2099            if let Some(name) = path.file_name() {
2100                let mut target_path = target_directory.clone();
2101                target_path.push(name);
2102                if target_path.exists() {
2103                    paths_to_replace.push((name.to_string_lossy().to_string(), path.clone()));
2104                }
2105            }
2106        }
2107
2108        cx.spawn(|this, mut cx| {
2109            async move {
2110                for (filename, original_path) in &paths_to_replace {
2111                    let answer = cx
2112                        .prompt(
2113                            PromptLevel::Info,
2114                            format!("A file or folder with name {filename} already exists in the destination folder. Do you want to replace it?").as_str(),
2115                            None,
2116                            &["Replace", "Cancel"],
2117                        )
2118                        .await?;
2119                    if answer == 1 {
2120                        if let Some(item_idx) = paths.iter().position(|p| p == original_path) {
2121                            paths.remove(item_idx);
2122                        }
2123                    }
2124                }
2125
2126                if paths.is_empty() {
2127                    return Ok(());
2128                }
2129
2130                let task = worktree.update(&mut cx, |worktree, cx| {
2131                    worktree.copy_external_entries(target_directory, paths, true, cx)
2132                })?;
2133
2134                let opened_entries = task.await?;
2135                this.update(&mut cx, |this, cx| {
2136                    if open_file_after_drop && !opened_entries.is_empty() {
2137                        this.open_entry(opened_entries[0], true, true, false, cx);
2138                    }
2139                })
2140            }
2141            .log_err()
2142        })
2143        .detach();
2144    }
2145
2146    fn drag_onto(
2147        &mut self,
2148        selections: &DraggedSelection,
2149        target_entry_id: ProjectEntryId,
2150        is_file: bool,
2151        cx: &mut ViewContext<Self>,
2152    ) {
2153        let should_copy = cx.modifiers().alt;
2154        if should_copy {
2155            let _ = maybe!({
2156                let project = self.project.read(cx);
2157                let target_worktree = project.worktree_for_entry(target_entry_id, cx)?;
2158                let target_entry = target_worktree
2159                    .read(cx)
2160                    .entry_for_id(target_entry_id)?
2161                    .clone();
2162                for selection in selections.items() {
2163                    let new_path = self.create_paste_path(
2164                        selection,
2165                        (target_worktree.clone(), &target_entry),
2166                        cx,
2167                    )?;
2168                    self.project
2169                        .update(cx, |project, cx| {
2170                            project.copy_entry(selection.entry_id, None, new_path, cx)
2171                        })
2172                        .detach_and_log_err(cx)
2173                }
2174
2175                Some(())
2176            });
2177        } else {
2178            for selection in selections.items() {
2179                self.move_entry(selection.entry_id, target_entry_id, is_file, cx);
2180            }
2181        }
2182    }
2183
2184    fn index_for_entry(
2185        &self,
2186        entry_id: ProjectEntryId,
2187        worktree_id: WorktreeId,
2188    ) -> Option<(usize, usize, usize)> {
2189        let mut worktree_ix = 0;
2190        let mut total_ix = 0;
2191        for (current_worktree_id, visible_worktree_entries, _) in &self.visible_entries {
2192            if worktree_id != *current_worktree_id {
2193                total_ix += visible_worktree_entries.len();
2194                worktree_ix += 1;
2195                continue;
2196            }
2197
2198            return visible_worktree_entries
2199                .iter()
2200                .enumerate()
2201                .find(|(_, entry)| entry.id == entry_id)
2202                .map(|(ix, _)| (worktree_ix, ix, total_ix + ix));
2203        }
2204        None
2205    }
2206
2207    fn entry_at_index(&self, index: usize) -> Option<(WorktreeId, &Entry)> {
2208        let mut offset = 0;
2209        for (worktree_id, visible_worktree_entries, _) in &self.visible_entries {
2210            if visible_worktree_entries.len() > offset + index {
2211                return visible_worktree_entries
2212                    .get(index)
2213                    .map(|entry| (*worktree_id, entry));
2214            }
2215            offset += visible_worktree_entries.len();
2216        }
2217        None
2218    }
2219
2220    fn iter_visible_entries(
2221        &self,
2222        range: Range<usize>,
2223        cx: &mut ViewContext<ProjectPanel>,
2224        mut callback: impl FnMut(&Entry, &HashSet<Arc<Path>>, &mut ViewContext<ProjectPanel>),
2225    ) {
2226        let mut ix = 0;
2227        for (_, visible_worktree_entries, entries_paths) in &self.visible_entries {
2228            if ix >= range.end {
2229                return;
2230            }
2231
2232            if ix + visible_worktree_entries.len() <= range.start {
2233                ix += visible_worktree_entries.len();
2234                continue;
2235            }
2236
2237            let end_ix = range.end.min(ix + visible_worktree_entries.len());
2238            let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
2239            let entries = entries_paths.get_or_init(|| {
2240                visible_worktree_entries
2241                    .iter()
2242                    .map(|e| (e.path.clone()))
2243                    .collect()
2244            });
2245            for entry in visible_worktree_entries[entry_range].iter() {
2246                callback(entry, entries, cx);
2247            }
2248            ix = end_ix;
2249        }
2250    }
2251
2252    fn for_each_visible_entry(
2253        &self,
2254        range: Range<usize>,
2255        cx: &mut ViewContext<ProjectPanel>,
2256        mut callback: impl FnMut(ProjectEntryId, EntryDetails, &mut ViewContext<ProjectPanel>),
2257    ) {
2258        let mut ix = 0;
2259        for (worktree_id, visible_worktree_entries, entries_paths) in &self.visible_entries {
2260            if ix >= range.end {
2261                return;
2262            }
2263
2264            if ix + visible_worktree_entries.len() <= range.start {
2265                ix += visible_worktree_entries.len();
2266                continue;
2267            }
2268
2269            let end_ix = range.end.min(ix + visible_worktree_entries.len());
2270            let (git_status_setting, show_file_icons, show_folder_icons) = {
2271                let settings = ProjectPanelSettings::get_global(cx);
2272                (
2273                    settings.git_status,
2274                    settings.file_icons,
2275                    settings.folder_icons,
2276                )
2277            };
2278            if let Some(worktree) = self.project.read(cx).worktree_for_id(*worktree_id, cx) {
2279                let snapshot = worktree.read(cx).snapshot();
2280                let root_name = OsStr::new(snapshot.root_name());
2281                let expanded_entry_ids = self
2282                    .expanded_dir_ids
2283                    .get(&snapshot.id())
2284                    .map(Vec::as_slice)
2285                    .unwrap_or(&[]);
2286
2287                let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
2288                let entries = entries_paths.get_or_init(|| {
2289                    visible_worktree_entries
2290                        .iter()
2291                        .map(|e| (e.path.clone()))
2292                        .collect()
2293                });
2294                for entry in visible_worktree_entries[entry_range].iter() {
2295                    let status = git_status_setting.then_some(entry.git_status).flatten();
2296                    let is_expanded = expanded_entry_ids.binary_search(&entry.id).is_ok();
2297                    let icon = match entry.kind {
2298                        EntryKind::File => {
2299                            if show_file_icons {
2300                                FileIcons::get_icon(&entry.path, cx)
2301                            } else {
2302                                None
2303                            }
2304                        }
2305                        _ => {
2306                            if show_folder_icons {
2307                                FileIcons::get_folder_icon(is_expanded, cx)
2308                            } else {
2309                                FileIcons::get_chevron_icon(is_expanded, cx)
2310                            }
2311                        }
2312                    };
2313
2314                    let (depth, difference) =
2315                        ProjectPanel::calculate_depth_and_difference(entry, entries);
2316
2317                    let filename = match difference {
2318                        diff if diff > 1 => entry
2319                            .path
2320                            .iter()
2321                            .skip(entry.path.components().count() - diff)
2322                            .collect::<PathBuf>()
2323                            .to_str()
2324                            .unwrap_or_default()
2325                            .to_string(),
2326                        _ => entry
2327                            .path
2328                            .file_name()
2329                            .map(|name| name.to_string_lossy().into_owned())
2330                            .unwrap_or_else(|| root_name.to_string_lossy().to_string()),
2331                    };
2332                    let selection = SelectedEntry {
2333                        worktree_id: snapshot.id(),
2334                        entry_id: entry.id,
2335                    };
2336                    let mut details = EntryDetails {
2337                        filename,
2338                        icon,
2339                        path: entry.path.clone(),
2340                        depth,
2341                        kind: entry.kind,
2342                        is_ignored: entry.is_ignored,
2343                        is_expanded,
2344                        is_selected: self.selection == Some(selection),
2345                        is_marked: self.marked_entries.contains(&selection),
2346                        is_editing: false,
2347                        is_processing: false,
2348                        is_cut: self
2349                            .clipboard
2350                            .as_ref()
2351                            .map_or(false, |e| e.is_cut() && e.items().contains(&selection)),
2352                        git_status: status,
2353                        is_private: entry.is_private,
2354                        worktree_id: *worktree_id,
2355                        canonical_path: entry.canonical_path.clone(),
2356                    };
2357
2358                    if let Some(edit_state) = &self.edit_state {
2359                        let is_edited_entry = if edit_state.is_new_entry() {
2360                            entry.id == NEW_ENTRY_ID
2361                        } else {
2362                            entry.id == edit_state.entry_id
2363                                || self
2364                                    .ancestors
2365                                    .get(&entry.id)
2366                                    .is_some_and(|auto_folded_dirs| {
2367                                        auto_folded_dirs
2368                                            .ancestors
2369                                            .iter()
2370                                            .any(|entry_id| *entry_id == edit_state.entry_id)
2371                                    })
2372                        };
2373
2374                        if is_edited_entry {
2375                            if let Some(processing_filename) = &edit_state.processing_filename {
2376                                details.is_processing = true;
2377                                if let Some(ancestors) = edit_state
2378                                    .leaf_entry_id
2379                                    .and_then(|entry| self.ancestors.get(&entry))
2380                                {
2381                                    let position = ancestors.ancestors.iter().position(|entry_id| *entry_id == edit_state.entry_id).expect("Edited sub-entry should be an ancestor of selected leaf entry") + 1;
2382                                    let all_components = ancestors.ancestors.len();
2383
2384                                    let prefix_components = all_components - position;
2385                                    let suffix_components = position.checked_sub(1);
2386                                    let mut previous_components =
2387                                        Path::new(&details.filename).components();
2388                                    let mut new_path = previous_components
2389                                        .by_ref()
2390                                        .take(prefix_components)
2391                                        .collect::<PathBuf>();
2392                                    if let Some(last_component) =
2393                                        Path::new(processing_filename).components().last()
2394                                    {
2395                                        new_path.push(last_component);
2396                                        previous_components.next();
2397                                    }
2398
2399                                    if let Some(_) = suffix_components {
2400                                        new_path.push(previous_components);
2401                                    }
2402                                    if let Some(str) = new_path.to_str() {
2403                                        details.filename.clear();
2404                                        details.filename.push_str(str);
2405                                    }
2406                                } else {
2407                                    details.filename.clear();
2408                                    details.filename.push_str(processing_filename);
2409                                }
2410                            } else {
2411                                if edit_state.is_new_entry() {
2412                                    details.filename.clear();
2413                                }
2414                                details.is_editing = true;
2415                            }
2416                        }
2417                    }
2418
2419                    callback(entry.id, details, cx);
2420                }
2421            }
2422            ix = end_ix;
2423        }
2424    }
2425
2426    fn calculate_depth_and_difference(
2427        entry: &Entry,
2428        visible_worktree_entries: &HashSet<Arc<Path>>,
2429    ) -> (usize, usize) {
2430        let (depth, difference) = entry
2431            .path
2432            .ancestors()
2433            .skip(1) // Skip the entry itself
2434            .find_map(|ancestor| {
2435                if let Some(parent_entry) = visible_worktree_entries.get(ancestor) {
2436                    let entry_path_components_count = entry.path.components().count();
2437                    let parent_path_components_count = parent_entry.components().count();
2438                    let difference = entry_path_components_count - parent_path_components_count;
2439                    let depth = parent_entry
2440                        .ancestors()
2441                        .skip(1)
2442                        .filter(|ancestor| visible_worktree_entries.contains(*ancestor))
2443                        .count();
2444                    Some((depth + 1, difference))
2445                } else {
2446                    None
2447                }
2448            })
2449            .unwrap_or((0, 0));
2450
2451        (depth, difference)
2452    }
2453
2454    fn render_entry(
2455        &self,
2456        entry_id: ProjectEntryId,
2457        details: EntryDetails,
2458        cx: &mut ViewContext<Self>,
2459    ) -> Stateful<Div> {
2460        let kind = details.kind;
2461        let settings = ProjectPanelSettings::get_global(cx);
2462        let show_editor = details.is_editing && !details.is_processing;
2463        let selection = SelectedEntry {
2464            worktree_id: details.worktree_id,
2465            entry_id,
2466        };
2467        let is_marked = self.marked_entries.contains(&selection);
2468        let is_active = self
2469            .selection
2470            .map_or(false, |selection| selection.entry_id == entry_id);
2471        let width = self.size(cx);
2472        let filename_text_color =
2473            entry_git_aware_label_color(details.git_status, details.is_ignored, is_marked);
2474        let file_name = details.filename.clone();
2475        let mut icon = details.icon.clone();
2476        if settings.file_icons && show_editor && details.kind.is_file() {
2477            let filename = self.filename_editor.read(cx).text(cx);
2478            if filename.len() > 2 {
2479                icon = FileIcons::get_icon(Path::new(&filename), cx);
2480            }
2481        }
2482
2483        let canonical_path = details
2484            .canonical_path
2485            .as_ref()
2486            .map(|f| f.to_string_lossy().to_string());
2487        let path = details.path.clone();
2488
2489        let depth = details.depth;
2490        let worktree_id = details.worktree_id;
2491        let selections = Arc::new(self.marked_entries.clone());
2492        let is_local = self.project.read(cx).is_local();
2493
2494        let dragged_selection = DraggedSelection {
2495            active_selection: selection,
2496            marked_selections: selections,
2497        };
2498        div()
2499            .id(entry_id.to_proto() as usize)
2500            .when(is_local, |div| {
2501                div.on_drag_move::<ExternalPaths>(cx.listener(
2502                    move |this, event: &DragMoveEvent<ExternalPaths>, cx| {
2503                        if event.bounds.contains(&event.event.position) {
2504                            if this.last_external_paths_drag_over_entry == Some(entry_id) {
2505                                return;
2506                            }
2507                            this.last_external_paths_drag_over_entry = Some(entry_id);
2508                            this.marked_entries.clear();
2509
2510                            let Some((worktree, path, entry)) = maybe!({
2511                                let worktree = this
2512                                    .project
2513                                    .read(cx)
2514                                    .worktree_for_id(selection.worktree_id, cx)?;
2515                                let worktree = worktree.read(cx);
2516                                let abs_path = worktree.absolutize(&path).log_err()?;
2517                                let path = if abs_path.is_dir() {
2518                                    path.as_ref()
2519                                } else {
2520                                    path.parent()?
2521                                };
2522                                let entry = worktree.entry_for_path(path)?;
2523                                Some((worktree, path, entry))
2524                            }) else {
2525                                return;
2526                            };
2527
2528                            this.marked_entries.insert(SelectedEntry {
2529                                entry_id: entry.id,
2530                                worktree_id: worktree.id(),
2531                            });
2532
2533                            for entry in worktree.child_entries(path) {
2534                                this.marked_entries.insert(SelectedEntry {
2535                                    entry_id: entry.id,
2536                                    worktree_id: worktree.id(),
2537                                });
2538                            }
2539
2540                            cx.notify();
2541                        }
2542                    },
2543                ))
2544                .on_drop(cx.listener(
2545                    move |this, external_paths: &ExternalPaths, cx| {
2546                        this.last_external_paths_drag_over_entry = None;
2547                        this.marked_entries.clear();
2548                        this.drop_external_files(external_paths.paths(), entry_id, cx);
2549                        cx.stop_propagation();
2550                    },
2551                ))
2552            })
2553            .on_drag(dragged_selection, move |selection, cx| {
2554                cx.new_view(|_| DraggedProjectEntryView {
2555                    details: details.clone(),
2556                    width,
2557                    selection: selection.active_selection,
2558                    selections: selection.marked_selections.clone(),
2559                })
2560            })
2561            .drag_over::<DraggedSelection>(|style, _, cx| {
2562                style.bg(cx.theme().colors().drop_target_background)
2563            })
2564            .on_drop(cx.listener(move |this, selections: &DraggedSelection, cx| {
2565                this.drag_onto(selections, entry_id, kind.is_file(), cx);
2566            }))
2567            .child(
2568                ListItem::new(entry_id.to_proto() as usize)
2569                    .indent_level(depth)
2570                    .indent_step_size(px(settings.indent_size))
2571                    .selected(is_marked || is_active)
2572                    .when_some(canonical_path, |this, path| {
2573                        this.end_slot::<AnyElement>(
2574                            div()
2575                                .id("symlink_icon")
2576                                .pr_3()
2577                                .tooltip(move |cx| {
2578                                    Tooltip::with_meta(path.to_string(), None, "Symbolic Link", cx)
2579                                })
2580                                .child(
2581                                    Icon::new(IconName::ArrowUpRight)
2582                                        .size(IconSize::Indicator)
2583                                        .color(filename_text_color),
2584                                )
2585                                .into_any_element(),
2586                        )
2587                    })
2588                    .child(if let Some(icon) = &icon {
2589                        h_flex().child(Icon::from_path(icon.to_string()).color(filename_text_color))
2590                    } else {
2591                        h_flex()
2592                            .size(IconSize::default().rems())
2593                            .invisible()
2594                            .flex_none()
2595                    })
2596                    .child(
2597                        if let (Some(editor), true) = (Some(&self.filename_editor), show_editor) {
2598                            h_flex().h_6().w_full().child(editor.clone())
2599                        } else {
2600                            h_flex().h_6().map(|mut this| {
2601                                if let Some(folded_ancestors) = self.ancestors.get(&entry_id) {
2602                                    let components = Path::new(&file_name)
2603                                        .components()
2604                                        .map(|comp| {
2605                                            let comp_str =
2606                                                comp.as_os_str().to_string_lossy().into_owned();
2607                                            comp_str
2608                                        })
2609                                        .collect::<Vec<_>>();
2610
2611                                    let components_len = components.len();
2612                                    let active_index = components_len
2613                                        - 1
2614                                        - folded_ancestors.current_ancestor_depth;
2615                                    const DELIMITER: SharedString =
2616                                        SharedString::new_static(std::path::MAIN_SEPARATOR_STR);
2617                                    for (index, component) in components.into_iter().enumerate() {
2618                                        if index != 0 {
2619                                            this = this.child(
2620                                                Label::new(DELIMITER.clone())
2621                                                    .single_line()
2622                                                    .color(filename_text_color),
2623                                            );
2624                                        }
2625                                        let id = SharedString::from(format!(
2626                                            "project_panel_path_component_{}_{index}",
2627                                            entry_id.to_usize()
2628                                        ));
2629                                        let label = div()
2630                                            .id(id)
2631                                            .on_click(cx.listener(move |this, _, cx| {
2632                                                if index != active_index {
2633                                                    if let Some(folds) =
2634                                                        this.ancestors.get_mut(&entry_id)
2635                                                    {
2636                                                        folds.current_ancestor_depth =
2637                                                            components_len - 1 - index;
2638                                                        cx.notify();
2639                                                    }
2640                                                }
2641                                            }))
2642                                            .child(
2643                                                Label::new(component)
2644                                                    .single_line()
2645                                                    .color(filename_text_color)
2646                                                    .when(
2647                                                        is_active && index == active_index,
2648                                                        |this| this.underline(true),
2649                                                    ),
2650                                            );
2651
2652                                        this = this.child(label);
2653                                    }
2654
2655                                    this
2656                                } else {
2657                                    this.child(
2658                                        Label::new(file_name)
2659                                            .single_line()
2660                                            .color(filename_text_color),
2661                                    )
2662                                }
2663                            })
2664                        }
2665                        .ml_1(),
2666                    )
2667                    .on_click(cx.listener(move |this, event: &gpui::ClickEvent, cx| {
2668                        if event.down.button == MouseButton::Right || event.down.first_mouse {
2669                            return;
2670                        }
2671                        if !show_editor {
2672                            cx.stop_propagation();
2673
2674                            if let Some(selection) =
2675                                this.selection.filter(|_| event.down.modifiers.shift)
2676                            {
2677                                let current_selection = this.index_for_selection(selection);
2678                                let target_selection = this.index_for_selection(SelectedEntry {
2679                                    entry_id,
2680                                    worktree_id,
2681                                });
2682                                if let Some(((_, _, source_index), (_, _, target_index))) =
2683                                    current_selection.zip(target_selection)
2684                                {
2685                                    let range_start = source_index.min(target_index);
2686                                    let range_end = source_index.max(target_index) + 1; // Make the range inclusive.
2687                                    let mut new_selections = BTreeSet::new();
2688                                    this.for_each_visible_entry(
2689                                        range_start..range_end,
2690                                        cx,
2691                                        |entry_id, details, _| {
2692                                            new_selections.insert(SelectedEntry {
2693                                                entry_id,
2694                                                worktree_id: details.worktree_id,
2695                                            });
2696                                        },
2697                                    );
2698
2699                                    this.marked_entries = this
2700                                        .marked_entries
2701                                        .union(&new_selections)
2702                                        .cloned()
2703                                        .collect();
2704
2705                                    this.selection = Some(SelectedEntry {
2706                                        entry_id,
2707                                        worktree_id,
2708                                    });
2709                                    // Ensure that the current entry is selected.
2710                                    this.marked_entries.insert(SelectedEntry {
2711                                        entry_id,
2712                                        worktree_id,
2713                                    });
2714                                }
2715                            } else if event.down.modifiers.secondary() {
2716                                if event.down.click_count > 1 {
2717                                    this.split_entry(entry_id, cx);
2718                                } else if !this.marked_entries.insert(selection) {
2719                                    this.marked_entries.remove(&selection);
2720                                }
2721                            } else if kind.is_dir() {
2722                                this.toggle_expanded(entry_id, cx);
2723                            } else {
2724                                let click_count = event.up.click_count;
2725                                this.open_entry(
2726                                    entry_id,
2727                                    cx.modifiers().secondary(),
2728                                    click_count > 1,
2729                                    click_count == 1,
2730                                    cx,
2731                                );
2732                            }
2733                        }
2734                    }))
2735                    .on_secondary_mouse_down(cx.listener(
2736                        move |this, event: &MouseDownEvent, cx| {
2737                            // Stop propagation to prevent the catch-all context menu for the project
2738                            // panel from being deployed.
2739                            cx.stop_propagation();
2740                            this.deploy_context_menu(event.position, entry_id, cx);
2741                        },
2742                    ))
2743                    .overflow_x(),
2744            )
2745            .border_1()
2746            .border_r_2()
2747            .rounded_none()
2748            .hover(|style| {
2749                if is_active {
2750                    style
2751                } else {
2752                    let hover_color = cx.theme().colors().ghost_element_hover;
2753                    style.bg(hover_color).border_color(hover_color)
2754                }
2755            })
2756            .when(is_marked || is_active, |this| {
2757                let colors = cx.theme().colors();
2758                this.when(is_marked, |this| this.bg(colors.ghost_element_selected))
2759                    .border_color(colors.ghost_element_selected)
2760            })
2761            .when(
2762                is_active && self.focus_handle.contains_focused(cx),
2763                |this| this.border_color(Color::Selected.color(cx)),
2764            )
2765    }
2766
2767    fn render_vertical_scrollbar(&self, cx: &mut ViewContext<Self>) -> Option<Stateful<Div>> {
2768        if !Self::should_show_scrollbar(cx)
2769            || !(self.show_scrollbar || self.vertical_scrollbar_state.is_dragging())
2770        {
2771            return None;
2772        }
2773        Some(
2774            div()
2775                .occlude()
2776                .id("project-panel-vertical-scroll")
2777                .on_mouse_move(cx.listener(|_, _, cx| {
2778                    cx.notify();
2779                    cx.stop_propagation()
2780                }))
2781                .on_hover(|_, cx| {
2782                    cx.stop_propagation();
2783                })
2784                .on_any_mouse_down(|_, cx| {
2785                    cx.stop_propagation();
2786                })
2787                .on_mouse_up(
2788                    MouseButton::Left,
2789                    cx.listener(|this, _, cx| {
2790                        if !this.vertical_scrollbar_state.is_dragging()
2791                            && !this.focus_handle.contains_focused(cx)
2792                        {
2793                            this.hide_scrollbar(cx);
2794                            cx.notify();
2795                        }
2796
2797                        cx.stop_propagation();
2798                    }),
2799                )
2800                .on_scroll_wheel(cx.listener(|_, _, cx| {
2801                    cx.notify();
2802                }))
2803                .h_full()
2804                .absolute()
2805                .right_1()
2806                .top_1()
2807                .bottom_1()
2808                .w(px(12.))
2809                .cursor_default()
2810                .children(Scrollbar::vertical(
2811                    // percentage as f32..end_offset as f32,
2812                    self.vertical_scrollbar_state.clone(),
2813                )),
2814        )
2815    }
2816
2817    fn render_horizontal_scrollbar(&self, cx: &mut ViewContext<Self>) -> Option<Stateful<Div>> {
2818        if !Self::should_show_scrollbar(cx)
2819            || !(self.show_scrollbar || self.horizontal_scrollbar_state.is_dragging())
2820        {
2821            return None;
2822        }
2823
2824        let scroll_handle = self.scroll_handle.0.borrow();
2825        let longest_item_width = scroll_handle
2826            .last_item_size
2827            .filter(|size| size.contents.width > size.item.width)?
2828            .contents
2829            .width
2830            .0 as f64;
2831        if longest_item_width < scroll_handle.base_handle.bounds().size.width.0 as f64 {
2832            return None;
2833        }
2834
2835        Some(
2836            div()
2837                .occlude()
2838                .id("project-panel-horizontal-scroll")
2839                .on_mouse_move(cx.listener(|_, _, cx| {
2840                    cx.notify();
2841                    cx.stop_propagation()
2842                }))
2843                .on_hover(|_, cx| {
2844                    cx.stop_propagation();
2845                })
2846                .on_any_mouse_down(|_, cx| {
2847                    cx.stop_propagation();
2848                })
2849                .on_mouse_up(
2850                    MouseButton::Left,
2851                    cx.listener(|this, _, cx| {
2852                        if !this.horizontal_scrollbar_state.is_dragging()
2853                            && !this.focus_handle.contains_focused(cx)
2854                        {
2855                            this.hide_scrollbar(cx);
2856                            cx.notify();
2857                        }
2858
2859                        cx.stop_propagation();
2860                    }),
2861                )
2862                .on_scroll_wheel(cx.listener(|_, _, cx| {
2863                    cx.notify();
2864                }))
2865                .w_full()
2866                .absolute()
2867                .right_1()
2868                .left_1()
2869                .bottom_1()
2870                .h(px(12.))
2871                .cursor_default()
2872                .when(self.width.is_some(), |this| {
2873                    this.children(Scrollbar::horizontal(
2874                        self.horizontal_scrollbar_state.clone(),
2875                    ))
2876                }),
2877        )
2878    }
2879
2880    fn dispatch_context(&self, cx: &ViewContext<Self>) -> KeyContext {
2881        let mut dispatch_context = KeyContext::new_with_defaults();
2882        dispatch_context.add("ProjectPanel");
2883        dispatch_context.add("menu");
2884
2885        let identifier = if self.filename_editor.focus_handle(cx).is_focused(cx) {
2886            "editing"
2887        } else {
2888            "not_editing"
2889        };
2890
2891        dispatch_context.add(identifier);
2892        dispatch_context
2893    }
2894
2895    fn should_show_scrollbar(cx: &AppContext) -> bool {
2896        let show = ProjectPanelSettings::get_global(cx)
2897            .scrollbar
2898            .show
2899            .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show);
2900        match show {
2901            ShowScrollbar::Auto => true,
2902            ShowScrollbar::System => true,
2903            ShowScrollbar::Always => true,
2904            ShowScrollbar::Never => false,
2905        }
2906    }
2907
2908    fn should_autohide_scrollbar(cx: &AppContext) -> bool {
2909        let show = ProjectPanelSettings::get_global(cx)
2910            .scrollbar
2911            .show
2912            .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show);
2913        match show {
2914            ShowScrollbar::Auto => true,
2915            ShowScrollbar::System => cx
2916                .try_global::<ScrollbarAutoHide>()
2917                .map_or_else(|| cx.should_auto_hide_scrollbars(), |autohide| autohide.0),
2918            ShowScrollbar::Always => false,
2919            ShowScrollbar::Never => true,
2920        }
2921    }
2922
2923    fn hide_scrollbar(&mut self, cx: &mut ViewContext<Self>) {
2924        const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1);
2925        if !Self::should_autohide_scrollbar(cx) {
2926            return;
2927        }
2928        self.hide_scrollbar_task = Some(cx.spawn(|panel, mut cx| async move {
2929            cx.background_executor()
2930                .timer(SCROLLBAR_SHOW_INTERVAL)
2931                .await;
2932            panel
2933                .update(&mut cx, |panel, cx| {
2934                    panel.show_scrollbar = false;
2935                    cx.notify();
2936                })
2937                .log_err();
2938        }))
2939    }
2940
2941    fn reveal_entry(
2942        &mut self,
2943        project: Model<Project>,
2944        entry_id: ProjectEntryId,
2945        skip_ignored: bool,
2946        cx: &mut ViewContext<'_, Self>,
2947    ) {
2948        if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
2949            let worktree = worktree.read(cx);
2950            if skip_ignored
2951                && worktree
2952                    .entry_for_id(entry_id)
2953                    .map_or(true, |entry| entry.is_ignored)
2954            {
2955                return;
2956            }
2957
2958            let worktree_id = worktree.id();
2959            self.marked_entries.clear();
2960            self.expand_entry(worktree_id, entry_id, cx);
2961            self.update_visible_entries(Some((worktree_id, entry_id)), cx);
2962            self.autoscroll(cx);
2963            cx.notify();
2964        }
2965    }
2966
2967    fn find_active_indent_guide(
2968        &self,
2969        indent_guides: &[IndentGuideLayout],
2970        cx: &AppContext,
2971    ) -> Option<usize> {
2972        let (worktree, entry) = self.selected_entry(cx)?;
2973
2974        // Find the parent entry of the indent guide, this will either be the
2975        // expanded folder we have selected, or the parent of the currently
2976        // selected file/collapsed directory
2977        let mut entry = entry;
2978        loop {
2979            let is_expanded_dir = entry.is_dir()
2980                && self
2981                    .expanded_dir_ids
2982                    .get(&worktree.id())
2983                    .map(|ids| ids.binary_search(&entry.id).is_ok())
2984                    .unwrap_or(false);
2985            if is_expanded_dir {
2986                break;
2987            }
2988            entry = worktree.entry_for_path(&entry.path.parent()?)?;
2989        }
2990
2991        let (active_indent_range, depth) = {
2992            let (worktree_ix, child_offset, ix) = self.index_for_entry(entry.id, worktree.id())?;
2993            let child_paths = &self.visible_entries[worktree_ix].1;
2994            let mut child_count = 0;
2995            let depth = entry.path.ancestors().count();
2996            while let Some(entry) = child_paths.get(child_offset + child_count + 1) {
2997                if entry.path.ancestors().count() <= depth {
2998                    break;
2999                }
3000                child_count += 1;
3001            }
3002
3003            let start = ix + 1;
3004            let end = start + child_count;
3005
3006            let (_, entries, paths) = &self.visible_entries[worktree_ix];
3007            let visible_worktree_entries =
3008                paths.get_or_init(|| entries.iter().map(|e| (e.path.clone())).collect());
3009
3010            // Calculate the actual depth of the entry, taking into account that directories can be auto-folded.
3011            let (depth, _) = Self::calculate_depth_and_difference(entry, visible_worktree_entries);
3012            (start..end, depth)
3013        };
3014
3015        let candidates = indent_guides
3016            .iter()
3017            .enumerate()
3018            .filter(|(_, indent_guide)| indent_guide.offset.x == depth);
3019
3020        for (i, indent) in candidates {
3021            // Find matches that are either an exact match, partially on screen, or inside the enclosing indent
3022            if active_indent_range.start <= indent.offset.y + indent.length
3023                && indent.offset.y <= active_indent_range.end
3024            {
3025                return Some(i);
3026            }
3027        }
3028        None
3029    }
3030}
3031
3032fn item_width_estimate(depth: usize, item_text_chars: usize, is_symlink: bool) -> usize {
3033    const ICON_SIZE_FACTOR: usize = 2;
3034    let mut item_width = depth * ICON_SIZE_FACTOR + item_text_chars;
3035    if is_symlink {
3036        item_width += ICON_SIZE_FACTOR;
3037    }
3038    item_width
3039}
3040
3041impl Render for ProjectPanel {
3042    fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> impl IntoElement {
3043        let has_worktree = !self.visible_entries.is_empty();
3044        let project = self.project.read(cx);
3045        let indent_size = ProjectPanelSettings::get_global(cx).indent_size;
3046        let show_indent_guides =
3047            ProjectPanelSettings::get_global(cx).indent_guides.show == ShowIndentGuides::Always;
3048        let is_local = project.is_local();
3049
3050        if has_worktree {
3051            let item_count = self
3052                .visible_entries
3053                .iter()
3054                .map(|(_, worktree_entries, _)| worktree_entries.len())
3055                .sum();
3056
3057            h_flex()
3058                .id("project-panel")
3059                .group("project-panel")
3060                .size_full()
3061                .relative()
3062                .on_hover(cx.listener(|this, hovered, cx| {
3063                    if *hovered {
3064                        this.show_scrollbar = true;
3065                        this.hide_scrollbar_task.take();
3066                        cx.notify();
3067                    } else if !this.focus_handle.contains_focused(cx) {
3068                        this.hide_scrollbar(cx);
3069                    }
3070                }))
3071                .key_context(self.dispatch_context(cx))
3072                .on_action(cx.listener(Self::select_next))
3073                .on_action(cx.listener(Self::select_prev))
3074                .on_action(cx.listener(Self::select_first))
3075                .on_action(cx.listener(Self::select_last))
3076                .on_action(cx.listener(Self::select_parent))
3077                .on_action(cx.listener(Self::expand_selected_entry))
3078                .on_action(cx.listener(Self::collapse_selected_entry))
3079                .on_action(cx.listener(Self::collapse_all_entries))
3080                .on_action(cx.listener(Self::open))
3081                .on_action(cx.listener(Self::open_permanent))
3082                .on_action(cx.listener(Self::confirm))
3083                .on_action(cx.listener(Self::cancel))
3084                .on_action(cx.listener(Self::copy_path))
3085                .on_action(cx.listener(Self::copy_relative_path))
3086                .on_action(cx.listener(Self::new_search_in_directory))
3087                .on_action(cx.listener(Self::unfold_directory))
3088                .on_action(cx.listener(Self::fold_directory))
3089                .when(!project.is_read_only(cx), |el| {
3090                    el.on_action(cx.listener(Self::new_file))
3091                        .on_action(cx.listener(Self::new_directory))
3092                        .on_action(cx.listener(Self::rename))
3093                        .on_action(cx.listener(Self::delete))
3094                        .on_action(cx.listener(Self::trash))
3095                        .on_action(cx.listener(Self::cut))
3096                        .on_action(cx.listener(Self::copy))
3097                        .on_action(cx.listener(Self::paste))
3098                        .on_action(cx.listener(Self::duplicate))
3099                        .on_click(cx.listener(|this, event: &gpui::ClickEvent, cx| {
3100                            if event.up.click_count > 1 {
3101                                if let Some(entry_id) = this.last_worktree_root_id {
3102                                    let project = this.project.read(cx);
3103
3104                                    let worktree_id = if let Some(worktree) =
3105                                        project.worktree_for_entry(entry_id, cx)
3106                                    {
3107                                        worktree.read(cx).id()
3108                                    } else {
3109                                        return;
3110                                    };
3111
3112                                    this.selection = Some(SelectedEntry {
3113                                        worktree_id,
3114                                        entry_id,
3115                                    });
3116
3117                                    this.new_file(&NewFile, cx);
3118                                }
3119                            }
3120                        }))
3121                })
3122                .when(project.is_local(), |el| {
3123                    el.on_action(cx.listener(Self::reveal_in_finder))
3124                        .on_action(cx.listener(Self::open_system))
3125                        .on_action(cx.listener(Self::open_in_terminal))
3126                })
3127                .when(project.is_via_ssh(), |el| {
3128                    el.on_action(cx.listener(Self::open_in_terminal))
3129                })
3130                .on_mouse_down(
3131                    MouseButton::Right,
3132                    cx.listener(move |this, event: &MouseDownEvent, cx| {
3133                        // When deploying the context menu anywhere below the last project entry,
3134                        // act as if the user clicked the root of the last worktree.
3135                        if let Some(entry_id) = this.last_worktree_root_id {
3136                            this.deploy_context_menu(event.position, entry_id, cx);
3137                        }
3138                    }),
3139                )
3140                .track_focus(&self.focus_handle(cx))
3141                .child(
3142                    uniform_list(cx.view().clone(), "entries", item_count, {
3143                        |this, range, cx| {
3144                            let mut items = Vec::with_capacity(range.end - range.start);
3145                            this.for_each_visible_entry(range, cx, |id, details, cx| {
3146                                items.push(this.render_entry(id, details, cx));
3147                            });
3148                            items
3149                        }
3150                    })
3151                    .when(show_indent_guides, |list| {
3152                        list.with_decoration(
3153                            ui::indent_guides(
3154                                cx.view().clone(),
3155                                px(indent_size),
3156                                IndentGuideColors::panel(cx),
3157                                |this, range, cx| {
3158                                    let mut items =
3159                                        SmallVec::with_capacity(range.end - range.start);
3160                                    this.iter_visible_entries(range, cx, |entry, entries, _| {
3161                                        let (depth, _) =
3162                                            Self::calculate_depth_and_difference(entry, entries);
3163                                        items.push(depth);
3164                                    });
3165                                    items
3166                                },
3167                            )
3168                            .on_click(cx.listener(
3169                                |this, active_indent_guide: &IndentGuideLayout, cx| {
3170                                    if cx.modifiers().secondary() {
3171                                        let ix = active_indent_guide.offset.y;
3172                                        let Some((target_entry, worktree)) = maybe!({
3173                                            let (worktree_id, entry) = this.entry_at_index(ix)?;
3174                                            let worktree = this
3175                                                .project
3176                                                .read(cx)
3177                                                .worktree_for_id(worktree_id, cx)?;
3178                                            let target_entry = worktree
3179                                                .read(cx)
3180                                                .entry_for_path(&entry.path.parent()?)?;
3181                                            Some((target_entry, worktree))
3182                                        }) else {
3183                                            return;
3184                                        };
3185
3186                                        this.collapse_entry(target_entry.clone(), worktree, cx);
3187                                    }
3188                                },
3189                            ))
3190                            .with_render_fn(
3191                                cx.view().clone(),
3192                                move |this, params, cx| {
3193                                    const LEFT_OFFSET: f32 = 14.;
3194                                    const PADDING_Y: f32 = 4.;
3195                                    const HITBOX_OVERDRAW: f32 = 3.;
3196
3197                                    let active_indent_guide_index =
3198                                        this.find_active_indent_guide(&params.indent_guides, cx);
3199
3200                                    let indent_size = params.indent_size;
3201                                    let item_height = params.item_height;
3202
3203                                    params
3204                                        .indent_guides
3205                                        .into_iter()
3206                                        .enumerate()
3207                                        .map(|(idx, layout)| {
3208                                            let offset = if layout.continues_offscreen {
3209                                                px(0.)
3210                                            } else {
3211                                                px(PADDING_Y)
3212                                            };
3213                                            let bounds = Bounds::new(
3214                                                point(
3215                                                    px(layout.offset.x as f32) * indent_size
3216                                                        + px(LEFT_OFFSET),
3217                                                    px(layout.offset.y as f32) * item_height
3218                                                        + offset,
3219                                                ),
3220                                                size(
3221                                                    px(1.),
3222                                                    px(layout.length as f32) * item_height
3223                                                        - px(offset.0 * 2.),
3224                                                ),
3225                                            );
3226                                            ui::RenderedIndentGuide {
3227                                                bounds,
3228                                                layout,
3229                                                is_active: Some(idx) == active_indent_guide_index,
3230                                                hitbox: Some(Bounds::new(
3231                                                    point(
3232                                                        bounds.origin.x - px(HITBOX_OVERDRAW),
3233                                                        bounds.origin.y,
3234                                                    ),
3235                                                    size(
3236                                                        bounds.size.width
3237                                                            + px(2. * HITBOX_OVERDRAW),
3238                                                        bounds.size.height,
3239                                                    ),
3240                                                )),
3241                                            }
3242                                        })
3243                                        .collect()
3244                                },
3245                            ),
3246                        )
3247                    })
3248                    .size_full()
3249                    .with_sizing_behavior(ListSizingBehavior::Infer)
3250                    .with_horizontal_sizing_behavior(ListHorizontalSizingBehavior::Unconstrained)
3251                    .with_width_from_item(self.max_width_item_index)
3252                    .track_scroll(self.scroll_handle.clone()),
3253                )
3254                .children(self.render_vertical_scrollbar(cx))
3255                .when_some(self.render_horizontal_scrollbar(cx), |this, scrollbar| {
3256                    this.pb_4().child(scrollbar)
3257                })
3258                .children(self.context_menu.as_ref().map(|(menu, position, _)| {
3259                    deferred(
3260                        anchored()
3261                            .position(*position)
3262                            .anchor(gpui::AnchorCorner::TopLeft)
3263                            .child(menu.clone()),
3264                    )
3265                    .with_priority(1)
3266                }))
3267        } else {
3268            v_flex()
3269                .id("empty-project_panel")
3270                .size_full()
3271                .p_4()
3272                .track_focus(&self.focus_handle(cx))
3273                .child(
3274                    Button::new("open_project", "Open a project")
3275                        .full_width()
3276                        .key_binding(KeyBinding::for_action(&workspace::Open, cx))
3277                        .on_click(cx.listener(|this, _, cx| {
3278                            this.workspace
3279                                .update(cx, |_, cx| cx.dispatch_action(Box::new(workspace::Open)))
3280                                .log_err();
3281                        })),
3282                )
3283                .when(is_local, |div| {
3284                    div.drag_over::<ExternalPaths>(|style, _, cx| {
3285                        style.bg(cx.theme().colors().drop_target_background)
3286                    })
3287                    .on_drop(cx.listener(
3288                        move |this, external_paths: &ExternalPaths, cx| {
3289                            this.last_external_paths_drag_over_entry = None;
3290                            this.marked_entries.clear();
3291                            if let Some(task) = this
3292                                .workspace
3293                                .update(cx, |workspace, cx| {
3294                                    workspace.open_workspace_for_paths(
3295                                        true,
3296                                        external_paths.paths().to_owned(),
3297                                        cx,
3298                                    )
3299                                })
3300                                .log_err()
3301                            {
3302                                task.detach_and_log_err(cx);
3303                            }
3304                            cx.stop_propagation();
3305                        },
3306                    ))
3307                })
3308        }
3309    }
3310}
3311
3312impl Render for DraggedProjectEntryView {
3313    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
3314        let settings = ProjectPanelSettings::get_global(cx);
3315        let ui_font = ThemeSettings::get_global(cx).ui_font.clone();
3316        h_flex().font(ui_font).map(|this| {
3317            if self.selections.contains(&self.selection) {
3318                this.flex_shrink()
3319                    .p_1()
3320                    .items_end()
3321                    .rounded_md()
3322                    .child(self.selections.len().to_string())
3323            } else {
3324                this.bg(cx.theme().colors().background).w(self.width).child(
3325                    ListItem::new(self.selection.entry_id.to_proto() as usize)
3326                        .indent_level(self.details.depth)
3327                        .indent_step_size(px(settings.indent_size))
3328                        .child(if let Some(icon) = &self.details.icon {
3329                            div().child(Icon::from_path(icon.clone()))
3330                        } else {
3331                            div()
3332                        })
3333                        .child(Label::new(self.details.filename.clone())),
3334                )
3335            }
3336        })
3337    }
3338}
3339
3340impl EventEmitter<Event> for ProjectPanel {}
3341
3342impl EventEmitter<PanelEvent> for ProjectPanel {}
3343
3344impl Panel for ProjectPanel {
3345    fn position(&self, cx: &WindowContext) -> DockPosition {
3346        match ProjectPanelSettings::get_global(cx).dock {
3347            ProjectPanelDockPosition::Left => DockPosition::Left,
3348            ProjectPanelDockPosition::Right => DockPosition::Right,
3349        }
3350    }
3351
3352    fn position_is_valid(&self, position: DockPosition) -> bool {
3353        matches!(position, DockPosition::Left | DockPosition::Right)
3354    }
3355
3356    fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
3357        settings::update_settings_file::<ProjectPanelSettings>(
3358            self.fs.clone(),
3359            cx,
3360            move |settings, _| {
3361                let dock = match position {
3362                    DockPosition::Left | DockPosition::Bottom => ProjectPanelDockPosition::Left,
3363                    DockPosition::Right => ProjectPanelDockPosition::Right,
3364                };
3365                settings.dock = Some(dock);
3366            },
3367        );
3368    }
3369
3370    fn size(&self, cx: &WindowContext) -> Pixels {
3371        self.width
3372            .unwrap_or_else(|| ProjectPanelSettings::get_global(cx).default_width)
3373    }
3374
3375    fn set_size(&mut self, size: Option<Pixels>, cx: &mut ViewContext<Self>) {
3376        self.width = size;
3377        self.serialize(cx);
3378        cx.notify();
3379    }
3380
3381    fn icon(&self, cx: &WindowContext) -> Option<IconName> {
3382        ProjectPanelSettings::get_global(cx)
3383            .button
3384            .then_some(IconName::FileTree)
3385    }
3386
3387    fn icon_tooltip(&self, _cx: &WindowContext) -> Option<&'static str> {
3388        Some("Project Panel")
3389    }
3390
3391    fn toggle_action(&self) -> Box<dyn Action> {
3392        Box::new(ToggleFocus)
3393    }
3394
3395    fn persistent_name() -> &'static str {
3396        "Project Panel"
3397    }
3398
3399    fn starts_open(&self, cx: &WindowContext) -> bool {
3400        let project = &self.project.read(cx);
3401        project.visible_worktrees(cx).any(|tree| {
3402            tree.read(cx)
3403                .root_entry()
3404                .map_or(false, |entry| entry.is_dir())
3405        })
3406    }
3407}
3408
3409impl FocusableView for ProjectPanel {
3410    fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
3411        self.focus_handle.clone()
3412    }
3413}
3414
3415impl ClipboardEntry {
3416    fn is_cut(&self) -> bool {
3417        matches!(self, Self::Cut { .. })
3418    }
3419
3420    fn items(&self) -> &BTreeSet<SelectedEntry> {
3421        match self {
3422            ClipboardEntry::Copied(entries) | ClipboardEntry::Cut(entries) => entries,
3423        }
3424    }
3425}
3426
3427#[cfg(test)]
3428mod tests {
3429    use super::*;
3430    use collections::HashSet;
3431    use gpui::{Empty, TestAppContext, View, VisualTestContext, WindowHandle};
3432    use pretty_assertions::assert_eq;
3433    use project::{FakeFs, WorktreeSettings};
3434    use serde_json::json;
3435    use settings::SettingsStore;
3436    use std::path::{Path, PathBuf};
3437    use ui::Context;
3438    use workspace::{
3439        item::{Item, ProjectItem},
3440        register_project_item, AppState,
3441    };
3442
3443    #[gpui::test]
3444    async fn test_visible_list(cx: &mut gpui::TestAppContext) {
3445        init_test(cx);
3446
3447        let fs = FakeFs::new(cx.executor().clone());
3448        fs.insert_tree(
3449            "/root1",
3450            json!({
3451                ".dockerignore": "",
3452                ".git": {
3453                    "HEAD": "",
3454                },
3455                "a": {
3456                    "0": { "q": "", "r": "", "s": "" },
3457                    "1": { "t": "", "u": "" },
3458                    "2": { "v": "", "w": "", "x": "", "y": "" },
3459                },
3460                "b": {
3461                    "3": { "Q": "" },
3462                    "4": { "R": "", "S": "", "T": "", "U": "" },
3463                },
3464                "C": {
3465                    "5": {},
3466                    "6": { "V": "", "W": "" },
3467                    "7": { "X": "" },
3468                    "8": { "Y": {}, "Z": "" }
3469                }
3470            }),
3471        )
3472        .await;
3473        fs.insert_tree(
3474            "/root2",
3475            json!({
3476                "d": {
3477                    "9": ""
3478                },
3479                "e": {}
3480            }),
3481        )
3482        .await;
3483
3484        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
3485        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3486        let cx = &mut VisualTestContext::from_window(*workspace, cx);
3487        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3488        assert_eq!(
3489            visible_entries_as_strings(&panel, 0..50, cx),
3490            &[
3491                "v root1",
3492                "    > .git",
3493                "    > a",
3494                "    > b",
3495                "    > C",
3496                "      .dockerignore",
3497                "v root2",
3498                "    > d",
3499                "    > e",
3500            ]
3501        );
3502
3503        toggle_expand_dir(&panel, "root1/b", cx);
3504        assert_eq!(
3505            visible_entries_as_strings(&panel, 0..50, cx),
3506            &[
3507                "v root1",
3508                "    > .git",
3509                "    > a",
3510                "    v b  <== selected",
3511                "        > 3",
3512                "        > 4",
3513                "    > C",
3514                "      .dockerignore",
3515                "v root2",
3516                "    > d",
3517                "    > e",
3518            ]
3519        );
3520
3521        assert_eq!(
3522            visible_entries_as_strings(&panel, 6..9, cx),
3523            &[
3524                //
3525                "    > C",
3526                "      .dockerignore",
3527                "v root2",
3528            ]
3529        );
3530    }
3531
3532    #[gpui::test]
3533    async fn test_exclusions_in_visible_list(cx: &mut gpui::TestAppContext) {
3534        init_test(cx);
3535        cx.update(|cx| {
3536            cx.update_global::<SettingsStore, _>(|store, cx| {
3537                store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
3538                    worktree_settings.file_scan_exclusions =
3539                        Some(vec!["**/.git".to_string(), "**/4/**".to_string()]);
3540                });
3541            });
3542        });
3543
3544        let fs = FakeFs::new(cx.background_executor.clone());
3545        fs.insert_tree(
3546            "/root1",
3547            json!({
3548                ".dockerignore": "",
3549                ".git": {
3550                    "HEAD": "",
3551                },
3552                "a": {
3553                    "0": { "q": "", "r": "", "s": "" },
3554                    "1": { "t": "", "u": "" },
3555                    "2": { "v": "", "w": "", "x": "", "y": "" },
3556                },
3557                "b": {
3558                    "3": { "Q": "" },
3559                    "4": { "R": "", "S": "", "T": "", "U": "" },
3560                },
3561                "C": {
3562                    "5": {},
3563                    "6": { "V": "", "W": "" },
3564                    "7": { "X": "" },
3565                    "8": { "Y": {}, "Z": "" }
3566                }
3567            }),
3568        )
3569        .await;
3570        fs.insert_tree(
3571            "/root2",
3572            json!({
3573                "d": {
3574                    "4": ""
3575                },
3576                "e": {}
3577            }),
3578        )
3579        .await;
3580
3581        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
3582        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3583        let cx = &mut VisualTestContext::from_window(*workspace, cx);
3584        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3585        assert_eq!(
3586            visible_entries_as_strings(&panel, 0..50, cx),
3587            &[
3588                "v root1",
3589                "    > a",
3590                "    > b",
3591                "    > C",
3592                "      .dockerignore",
3593                "v root2",
3594                "    > d",
3595                "    > e",
3596            ]
3597        );
3598
3599        toggle_expand_dir(&panel, "root1/b", cx);
3600        assert_eq!(
3601            visible_entries_as_strings(&panel, 0..50, cx),
3602            &[
3603                "v root1",
3604                "    > a",
3605                "    v b  <== selected",
3606                "        > 3",
3607                "    > C",
3608                "      .dockerignore",
3609                "v root2",
3610                "    > d",
3611                "    > e",
3612            ]
3613        );
3614
3615        toggle_expand_dir(&panel, "root2/d", cx);
3616        assert_eq!(
3617            visible_entries_as_strings(&panel, 0..50, cx),
3618            &[
3619                "v root1",
3620                "    > a",
3621                "    v b",
3622                "        > 3",
3623                "    > C",
3624                "      .dockerignore",
3625                "v root2",
3626                "    v d  <== selected",
3627                "    > e",
3628            ]
3629        );
3630
3631        toggle_expand_dir(&panel, "root2/e", cx);
3632        assert_eq!(
3633            visible_entries_as_strings(&panel, 0..50, cx),
3634            &[
3635                "v root1",
3636                "    > a",
3637                "    v b",
3638                "        > 3",
3639                "    > C",
3640                "      .dockerignore",
3641                "v root2",
3642                "    v d",
3643                "    v e  <== selected",
3644            ]
3645        );
3646    }
3647
3648    #[gpui::test]
3649    async fn test_auto_collapse_dir_paths(cx: &mut gpui::TestAppContext) {
3650        init_test(cx);
3651
3652        let fs = FakeFs::new(cx.executor().clone());
3653        fs.insert_tree(
3654            "/root1",
3655            json!({
3656                "dir_1": {
3657                    "nested_dir_1": {
3658                        "nested_dir_2": {
3659                            "nested_dir_3": {
3660                                "file_a.java": "// File contents",
3661                                "file_b.java": "// File contents",
3662                                "file_c.java": "// File contents",
3663                                "nested_dir_4": {
3664                                    "nested_dir_5": {
3665                                        "file_d.java": "// File contents",
3666                                    }
3667                                }
3668                            }
3669                        }
3670                    }
3671                }
3672            }),
3673        )
3674        .await;
3675        fs.insert_tree(
3676            "/root2",
3677            json!({
3678                "dir_2": {
3679                    "file_1.java": "// File contents",
3680                }
3681            }),
3682        )
3683        .await;
3684
3685        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
3686        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3687        let cx = &mut VisualTestContext::from_window(*workspace, cx);
3688        cx.update(|cx| {
3689            let settings = *ProjectPanelSettings::get_global(cx);
3690            ProjectPanelSettings::override_global(
3691                ProjectPanelSettings {
3692                    auto_fold_dirs: true,
3693                    ..settings
3694                },
3695                cx,
3696            );
3697        });
3698        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3699        assert_eq!(
3700            visible_entries_as_strings(&panel, 0..10, cx),
3701            &[
3702                "v root1",
3703                "    > dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
3704                "v root2",
3705                "    > dir_2",
3706            ]
3707        );
3708
3709        toggle_expand_dir(
3710            &panel,
3711            "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
3712            cx,
3713        );
3714        assert_eq!(
3715            visible_entries_as_strings(&panel, 0..10, cx),
3716            &[
3717                "v root1",
3718                "    v dir_1/nested_dir_1/nested_dir_2/nested_dir_3  <== selected",
3719                "        > nested_dir_4/nested_dir_5",
3720                "          file_a.java",
3721                "          file_b.java",
3722                "          file_c.java",
3723                "v root2",
3724                "    > dir_2",
3725            ]
3726        );
3727
3728        toggle_expand_dir(
3729            &panel,
3730            "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3/nested_dir_4/nested_dir_5",
3731            cx,
3732        );
3733        assert_eq!(
3734            visible_entries_as_strings(&panel, 0..10, cx),
3735            &[
3736                "v root1",
3737                "    v dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
3738                "        v nested_dir_4/nested_dir_5  <== selected",
3739                "              file_d.java",
3740                "          file_a.java",
3741                "          file_b.java",
3742                "          file_c.java",
3743                "v root2",
3744                "    > dir_2",
3745            ]
3746        );
3747        toggle_expand_dir(&panel, "root2/dir_2", cx);
3748        assert_eq!(
3749            visible_entries_as_strings(&panel, 0..10, cx),
3750            &[
3751                "v root1",
3752                "    v dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
3753                "        v nested_dir_4/nested_dir_5",
3754                "              file_d.java",
3755                "          file_a.java",
3756                "          file_b.java",
3757                "          file_c.java",
3758                "v root2",
3759                "    v dir_2  <== selected",
3760                "          file_1.java",
3761            ]
3762        );
3763    }
3764
3765    #[gpui::test(iterations = 30)]
3766    async fn test_editing_files(cx: &mut gpui::TestAppContext) {
3767        init_test(cx);
3768
3769        let fs = FakeFs::new(cx.executor().clone());
3770        fs.insert_tree(
3771            "/root1",
3772            json!({
3773                ".dockerignore": "",
3774                ".git": {
3775                    "HEAD": "",
3776                },
3777                "a": {
3778                    "0": { "q": "", "r": "", "s": "" },
3779                    "1": { "t": "", "u": "" },
3780                    "2": { "v": "", "w": "", "x": "", "y": "" },
3781                },
3782                "b": {
3783                    "3": { "Q": "" },
3784                    "4": { "R": "", "S": "", "T": "", "U": "" },
3785                },
3786                "C": {
3787                    "5": {},
3788                    "6": { "V": "", "W": "" },
3789                    "7": { "X": "" },
3790                    "8": { "Y": {}, "Z": "" }
3791                }
3792            }),
3793        )
3794        .await;
3795        fs.insert_tree(
3796            "/root2",
3797            json!({
3798                "d": {
3799                    "9": ""
3800                },
3801                "e": {}
3802            }),
3803        )
3804        .await;
3805
3806        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
3807        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3808        let cx = &mut VisualTestContext::from_window(*workspace, cx);
3809        let panel = workspace
3810            .update(cx, |workspace, cx| {
3811                let panel = ProjectPanel::new(workspace, cx);
3812                workspace.add_panel(panel.clone(), cx);
3813                panel
3814            })
3815            .unwrap();
3816
3817        select_path(&panel, "root1", cx);
3818        assert_eq!(
3819            visible_entries_as_strings(&panel, 0..10, cx),
3820            &[
3821                "v root1  <== selected",
3822                "    > .git",
3823                "    > a",
3824                "    > b",
3825                "    > C",
3826                "      .dockerignore",
3827                "v root2",
3828                "    > d",
3829                "    > e",
3830            ]
3831        );
3832
3833        // Add a file with the root folder selected. The filename editor is placed
3834        // before the first file in the root folder.
3835        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
3836        panel.update(cx, |panel, cx| {
3837            assert!(panel.filename_editor.read(cx).is_focused(cx));
3838        });
3839        assert_eq!(
3840            visible_entries_as_strings(&panel, 0..10, cx),
3841            &[
3842                "v root1",
3843                "    > .git",
3844                "    > a",
3845                "    > b",
3846                "    > C",
3847                "      [EDITOR: '']  <== selected",
3848                "      .dockerignore",
3849                "v root2",
3850                "    > d",
3851                "    > e",
3852            ]
3853        );
3854
3855        let confirm = panel.update(cx, |panel, cx| {
3856            panel
3857                .filename_editor
3858                .update(cx, |editor, cx| editor.set_text("the-new-filename", cx));
3859            panel.confirm_edit(cx).unwrap()
3860        });
3861        assert_eq!(
3862            visible_entries_as_strings(&panel, 0..10, cx),
3863            &[
3864                "v root1",
3865                "    > .git",
3866                "    > a",
3867                "    > b",
3868                "    > C",
3869                "      [PROCESSING: 'the-new-filename']  <== selected",
3870                "      .dockerignore",
3871                "v root2",
3872                "    > d",
3873                "    > e",
3874            ]
3875        );
3876
3877        confirm.await.unwrap();
3878        assert_eq!(
3879            visible_entries_as_strings(&panel, 0..10, cx),
3880            &[
3881                "v root1",
3882                "    > .git",
3883                "    > a",
3884                "    > b",
3885                "    > C",
3886                "      .dockerignore",
3887                "      the-new-filename  <== selected  <== marked",
3888                "v root2",
3889                "    > d",
3890                "    > e",
3891            ]
3892        );
3893
3894        select_path(&panel, "root1/b", cx);
3895        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
3896        assert_eq!(
3897            visible_entries_as_strings(&panel, 0..10, cx),
3898            &[
3899                "v root1",
3900                "    > .git",
3901                "    > a",
3902                "    v b",
3903                "        > 3",
3904                "        > 4",
3905                "          [EDITOR: '']  <== selected",
3906                "    > C",
3907                "      .dockerignore",
3908                "      the-new-filename",
3909            ]
3910        );
3911
3912        panel
3913            .update(cx, |panel, cx| {
3914                panel
3915                    .filename_editor
3916                    .update(cx, |editor, cx| editor.set_text("another-filename.txt", cx));
3917                panel.confirm_edit(cx).unwrap()
3918            })
3919            .await
3920            .unwrap();
3921        assert_eq!(
3922            visible_entries_as_strings(&panel, 0..10, cx),
3923            &[
3924                "v root1",
3925                "    > .git",
3926                "    > a",
3927                "    v b",
3928                "        > 3",
3929                "        > 4",
3930                "          another-filename.txt  <== selected  <== marked",
3931                "    > C",
3932                "      .dockerignore",
3933                "      the-new-filename",
3934            ]
3935        );
3936
3937        select_path(&panel, "root1/b/another-filename.txt", cx);
3938        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
3939        assert_eq!(
3940            visible_entries_as_strings(&panel, 0..10, cx),
3941            &[
3942                "v root1",
3943                "    > .git",
3944                "    > a",
3945                "    v b",
3946                "        > 3",
3947                "        > 4",
3948                "          [EDITOR: 'another-filename.txt']  <== selected  <== marked",
3949                "    > C",
3950                "      .dockerignore",
3951                "      the-new-filename",
3952            ]
3953        );
3954
3955        let confirm = panel.update(cx, |panel, cx| {
3956            panel.filename_editor.update(cx, |editor, cx| {
3957                let file_name_selections = editor.selections.all::<usize>(cx);
3958                assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
3959                let file_name_selection = &file_name_selections[0];
3960                assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
3961                assert_eq!(file_name_selection.end, "another-filename".len(), "Should not select file extension");
3962
3963                editor.set_text("a-different-filename.tar.gz", cx)
3964            });
3965            panel.confirm_edit(cx).unwrap()
3966        });
3967        assert_eq!(
3968            visible_entries_as_strings(&panel, 0..10, cx),
3969            &[
3970                "v root1",
3971                "    > .git",
3972                "    > a",
3973                "    v b",
3974                "        > 3",
3975                "        > 4",
3976                "          [PROCESSING: 'a-different-filename.tar.gz']  <== selected  <== marked",
3977                "    > C",
3978                "      .dockerignore",
3979                "      the-new-filename",
3980            ]
3981        );
3982
3983        confirm.await.unwrap();
3984        assert_eq!(
3985            visible_entries_as_strings(&panel, 0..10, cx),
3986            &[
3987                "v root1",
3988                "    > .git",
3989                "    > a",
3990                "    v b",
3991                "        > 3",
3992                "        > 4",
3993                "          a-different-filename.tar.gz  <== selected",
3994                "    > C",
3995                "      .dockerignore",
3996                "      the-new-filename",
3997            ]
3998        );
3999
4000        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
4001        assert_eq!(
4002            visible_entries_as_strings(&panel, 0..10, cx),
4003            &[
4004                "v root1",
4005                "    > .git",
4006                "    > a",
4007                "    v b",
4008                "        > 3",
4009                "        > 4",
4010                "          [EDITOR: 'a-different-filename.tar.gz']  <== selected",
4011                "    > C",
4012                "      .dockerignore",
4013                "      the-new-filename",
4014            ]
4015        );
4016
4017        panel.update(cx, |panel, cx| {
4018            panel.filename_editor.update(cx, |editor, cx| {
4019                let file_name_selections = editor.selections.all::<usize>(cx);
4020                assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
4021                let file_name_selection = &file_name_selections[0];
4022                assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
4023                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..");
4024
4025            });
4026            panel.cancel(&menu::Cancel, cx)
4027        });
4028
4029        panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
4030        assert_eq!(
4031            visible_entries_as_strings(&panel, 0..10, cx),
4032            &[
4033                "v root1",
4034                "    > .git",
4035                "    > a",
4036                "    v b",
4037                "        > 3",
4038                "        > 4",
4039                "        > [EDITOR: '']  <== selected",
4040                "          a-different-filename.tar.gz",
4041                "    > C",
4042                "      .dockerignore",
4043            ]
4044        );
4045
4046        let confirm = panel.update(cx, |panel, cx| {
4047            panel
4048                .filename_editor
4049                .update(cx, |editor, cx| editor.set_text("new-dir", cx));
4050            panel.confirm_edit(cx).unwrap()
4051        });
4052        panel.update(cx, |panel, cx| panel.select_next(&Default::default(), cx));
4053        assert_eq!(
4054            visible_entries_as_strings(&panel, 0..10, cx),
4055            &[
4056                "v root1",
4057                "    > .git",
4058                "    > a",
4059                "    v b",
4060                "        > 3",
4061                "        > 4",
4062                "        > [PROCESSING: 'new-dir']",
4063                "          a-different-filename.tar.gz  <== selected",
4064                "    > C",
4065                "      .dockerignore",
4066            ]
4067        );
4068
4069        confirm.await.unwrap();
4070        assert_eq!(
4071            visible_entries_as_strings(&panel, 0..10, cx),
4072            &[
4073                "v root1",
4074                "    > .git",
4075                "    > a",
4076                "    v b",
4077                "        > 3",
4078                "        > 4",
4079                "        > new-dir",
4080                "          a-different-filename.tar.gz  <== selected",
4081                "    > C",
4082                "      .dockerignore",
4083            ]
4084        );
4085
4086        panel.update(cx, |panel, cx| panel.rename(&Default::default(), cx));
4087        assert_eq!(
4088            visible_entries_as_strings(&panel, 0..10, cx),
4089            &[
4090                "v root1",
4091                "    > .git",
4092                "    > a",
4093                "    v b",
4094                "        > 3",
4095                "        > 4",
4096                "        > new-dir",
4097                "          [EDITOR: 'a-different-filename.tar.gz']  <== selected",
4098                "    > C",
4099                "      .dockerignore",
4100            ]
4101        );
4102
4103        // Dismiss the rename editor when it loses focus.
4104        workspace.update(cx, |_, cx| cx.blur()).unwrap();
4105        assert_eq!(
4106            visible_entries_as_strings(&panel, 0..10, cx),
4107            &[
4108                "v root1",
4109                "    > .git",
4110                "    > a",
4111                "    v b",
4112                "        > 3",
4113                "        > 4",
4114                "        > new-dir",
4115                "          a-different-filename.tar.gz  <== selected",
4116                "    > C",
4117                "      .dockerignore",
4118            ]
4119        );
4120    }
4121
4122    #[gpui::test(iterations = 10)]
4123    async fn test_adding_directories_via_file(cx: &mut gpui::TestAppContext) {
4124        init_test(cx);
4125
4126        let fs = FakeFs::new(cx.executor().clone());
4127        fs.insert_tree(
4128            "/root1",
4129            json!({
4130                ".dockerignore": "",
4131                ".git": {
4132                    "HEAD": "",
4133                },
4134                "a": {
4135                    "0": { "q": "", "r": "", "s": "" },
4136                    "1": { "t": "", "u": "" },
4137                    "2": { "v": "", "w": "", "x": "", "y": "" },
4138                },
4139                "b": {
4140                    "3": { "Q": "" },
4141                    "4": { "R": "", "S": "", "T": "", "U": "" },
4142                },
4143                "C": {
4144                    "5": {},
4145                    "6": { "V": "", "W": "" },
4146                    "7": { "X": "" },
4147                    "8": { "Y": {}, "Z": "" }
4148                }
4149            }),
4150        )
4151        .await;
4152        fs.insert_tree(
4153            "/root2",
4154            json!({
4155                "d": {
4156                    "9": ""
4157                },
4158                "e": {}
4159            }),
4160        )
4161        .await;
4162
4163        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
4164        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4165        let cx = &mut VisualTestContext::from_window(*workspace, cx);
4166        let panel = workspace
4167            .update(cx, |workspace, cx| {
4168                let panel = ProjectPanel::new(workspace, cx);
4169                workspace.add_panel(panel.clone(), cx);
4170                panel
4171            })
4172            .unwrap();
4173
4174        select_path(&panel, "root1", cx);
4175        assert_eq!(
4176            visible_entries_as_strings(&panel, 0..10, cx),
4177            &[
4178                "v root1  <== selected",
4179                "    > .git",
4180                "    > a",
4181                "    > b",
4182                "    > C",
4183                "      .dockerignore",
4184                "v root2",
4185                "    > d",
4186                "    > e",
4187            ]
4188        );
4189
4190        // Add a file with the root folder selected. The filename editor is placed
4191        // before the first file in the root folder.
4192        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
4193        panel.update(cx, |panel, cx| {
4194            assert!(panel.filename_editor.read(cx).is_focused(cx));
4195        });
4196        assert_eq!(
4197            visible_entries_as_strings(&panel, 0..10, cx),
4198            &[
4199                "v root1",
4200                "    > .git",
4201                "    > a",
4202                "    > b",
4203                "    > C",
4204                "      [EDITOR: '']  <== selected",
4205                "      .dockerignore",
4206                "v root2",
4207                "    > d",
4208                "    > e",
4209            ]
4210        );
4211
4212        let confirm = panel.update(cx, |panel, cx| {
4213            panel.filename_editor.update(cx, |editor, cx| {
4214                editor.set_text("/bdir1/dir2/the-new-filename", cx)
4215            });
4216            panel.confirm_edit(cx).unwrap()
4217        });
4218
4219        assert_eq!(
4220            visible_entries_as_strings(&panel, 0..10, cx),
4221            &[
4222                "v root1",
4223                "    > .git",
4224                "    > a",
4225                "    > b",
4226                "    > C",
4227                "      [PROCESSING: '/bdir1/dir2/the-new-filename']  <== selected",
4228                "      .dockerignore",
4229                "v root2",
4230                "    > d",
4231                "    > e",
4232            ]
4233        );
4234
4235        confirm.await.unwrap();
4236        assert_eq!(
4237            visible_entries_as_strings(&panel, 0..13, cx),
4238            &[
4239                "v root1",
4240                "    > .git",
4241                "    > a",
4242                "    > b",
4243                "    v bdir1",
4244                "        v dir2",
4245                "              the-new-filename  <== selected  <== marked",
4246                "    > C",
4247                "      .dockerignore",
4248                "v root2",
4249                "    > d",
4250                "    > e",
4251            ]
4252        );
4253    }
4254
4255    #[gpui::test]
4256    async fn test_adding_directory_via_file(cx: &mut gpui::TestAppContext) {
4257        init_test(cx);
4258
4259        let fs = FakeFs::new(cx.executor().clone());
4260        fs.insert_tree(
4261            "/root1",
4262            json!({
4263                ".dockerignore": "",
4264                ".git": {
4265                    "HEAD": "",
4266                },
4267            }),
4268        )
4269        .await;
4270
4271        let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
4272        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4273        let cx = &mut VisualTestContext::from_window(*workspace, cx);
4274        let panel = workspace
4275            .update(cx, |workspace, cx| {
4276                let panel = ProjectPanel::new(workspace, cx);
4277                workspace.add_panel(panel.clone(), cx);
4278                panel
4279            })
4280            .unwrap();
4281
4282        select_path(&panel, "root1", cx);
4283        assert_eq!(
4284            visible_entries_as_strings(&panel, 0..10, cx),
4285            &["v root1  <== selected", "    > .git", "      .dockerignore",]
4286        );
4287
4288        // Add a file with the root folder selected. The filename editor is placed
4289        // before the first file in the root folder.
4290        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
4291        panel.update(cx, |panel, cx| {
4292            assert!(panel.filename_editor.read(cx).is_focused(cx));
4293        });
4294        assert_eq!(
4295            visible_entries_as_strings(&panel, 0..10, cx),
4296            &[
4297                "v root1",
4298                "    > .git",
4299                "      [EDITOR: '']  <== selected",
4300                "      .dockerignore",
4301            ]
4302        );
4303
4304        let confirm = panel.update(cx, |panel, cx| {
4305            panel
4306                .filename_editor
4307                .update(cx, |editor, cx| editor.set_text("/new_dir/", cx));
4308            panel.confirm_edit(cx).unwrap()
4309        });
4310
4311        assert_eq!(
4312            visible_entries_as_strings(&panel, 0..10, cx),
4313            &[
4314                "v root1",
4315                "    > .git",
4316                "      [PROCESSING: '/new_dir/']  <== selected",
4317                "      .dockerignore",
4318            ]
4319        );
4320
4321        confirm.await.unwrap();
4322        assert_eq!(
4323            visible_entries_as_strings(&panel, 0..13, cx),
4324            &[
4325                "v root1",
4326                "    > .git",
4327                "    v new_dir  <== selected",
4328                "      .dockerignore",
4329            ]
4330        );
4331    }
4332
4333    #[gpui::test]
4334    async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
4335        init_test(cx);
4336
4337        let fs = FakeFs::new(cx.executor().clone());
4338        fs.insert_tree(
4339            "/root1",
4340            json!({
4341                "one.two.txt": "",
4342                "one.txt": ""
4343            }),
4344        )
4345        .await;
4346
4347        let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
4348        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4349        let cx = &mut VisualTestContext::from_window(*workspace, cx);
4350        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4351
4352        panel.update(cx, |panel, cx| {
4353            panel.select_next(&Default::default(), cx);
4354            panel.select_next(&Default::default(), cx);
4355        });
4356
4357        assert_eq!(
4358            visible_entries_as_strings(&panel, 0..50, cx),
4359            &[
4360                //
4361                "v root1",
4362                "      one.txt  <== selected",
4363                "      one.two.txt",
4364            ]
4365        );
4366
4367        // Regression test - file name is created correctly when
4368        // the copied file's name contains multiple dots.
4369        panel.update(cx, |panel, cx| {
4370            panel.copy(&Default::default(), cx);
4371            panel.paste(&Default::default(), cx);
4372        });
4373        cx.executor().run_until_parked();
4374
4375        assert_eq!(
4376            visible_entries_as_strings(&panel, 0..50, cx),
4377            &[
4378                //
4379                "v root1",
4380                "      one.txt",
4381                "      one copy.txt  <== selected",
4382                "      one.two.txt",
4383            ]
4384        );
4385
4386        panel.update(cx, |panel, cx| {
4387            panel.paste(&Default::default(), cx);
4388        });
4389        cx.executor().run_until_parked();
4390
4391        assert_eq!(
4392            visible_entries_as_strings(&panel, 0..50, cx),
4393            &[
4394                //
4395                "v root1",
4396                "      one.txt",
4397                "      one copy.txt",
4398                "      one copy 1.txt  <== selected",
4399                "      one.two.txt",
4400            ]
4401        );
4402    }
4403
4404    #[gpui::test]
4405    async fn test_cut_paste_between_different_worktrees(cx: &mut gpui::TestAppContext) {
4406        init_test(cx);
4407
4408        let fs = FakeFs::new(cx.executor().clone());
4409        fs.insert_tree(
4410            "/root1",
4411            json!({
4412                "one.txt": "",
4413                "two.txt": "",
4414                "three.txt": "",
4415                "a": {
4416                    "0": { "q": "", "r": "", "s": "" },
4417                    "1": { "t": "", "u": "" },
4418                    "2": { "v": "", "w": "", "x": "", "y": "" },
4419                },
4420            }),
4421        )
4422        .await;
4423
4424        fs.insert_tree(
4425            "/root2",
4426            json!({
4427                "one.txt": "",
4428                "two.txt": "",
4429                "four.txt": "",
4430                "b": {
4431                    "3": { "Q": "" },
4432                    "4": { "R": "", "S": "", "T": "", "U": "" },
4433                },
4434            }),
4435        )
4436        .await;
4437
4438        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
4439        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4440        let cx = &mut VisualTestContext::from_window(*workspace, cx);
4441        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4442
4443        select_path(&panel, "root1/three.txt", cx);
4444        panel.update(cx, |panel, cx| {
4445            panel.cut(&Default::default(), cx);
4446        });
4447
4448        select_path(&panel, "root2/one.txt", cx);
4449        panel.update(cx, |panel, cx| {
4450            panel.select_next(&Default::default(), cx);
4451            panel.paste(&Default::default(), cx);
4452        });
4453        cx.executor().run_until_parked();
4454        assert_eq!(
4455            visible_entries_as_strings(&panel, 0..50, cx),
4456            &[
4457                //
4458                "v root1",
4459                "    > a",
4460                "      one.txt",
4461                "      two.txt",
4462                "v root2",
4463                "    > b",
4464                "      four.txt",
4465                "      one.txt",
4466                "      three.txt  <== selected",
4467                "      two.txt",
4468            ]
4469        );
4470
4471        select_path(&panel, "root1/a", cx);
4472        panel.update(cx, |panel, cx| {
4473            panel.cut(&Default::default(), cx);
4474        });
4475        select_path(&panel, "root2/two.txt", cx);
4476        panel.update(cx, |panel, cx| {
4477            panel.select_next(&Default::default(), cx);
4478            panel.paste(&Default::default(), cx);
4479        });
4480
4481        cx.executor().run_until_parked();
4482        assert_eq!(
4483            visible_entries_as_strings(&panel, 0..50, cx),
4484            &[
4485                //
4486                "v root1",
4487                "      one.txt",
4488                "      two.txt",
4489                "v root2",
4490                "    > a  <== selected",
4491                "    > b",
4492                "      four.txt",
4493                "      one.txt",
4494                "      three.txt",
4495                "      two.txt",
4496            ]
4497        );
4498    }
4499
4500    #[gpui::test]
4501    async fn test_copy_paste_between_different_worktrees(cx: &mut gpui::TestAppContext) {
4502        init_test(cx);
4503
4504        let fs = FakeFs::new(cx.executor().clone());
4505        fs.insert_tree(
4506            "/root1",
4507            json!({
4508                "one.txt": "",
4509                "two.txt": "",
4510                "three.txt": "",
4511                "a": {
4512                    "0": { "q": "", "r": "", "s": "" },
4513                    "1": { "t": "", "u": "" },
4514                    "2": { "v": "", "w": "", "x": "", "y": "" },
4515                },
4516            }),
4517        )
4518        .await;
4519
4520        fs.insert_tree(
4521            "/root2",
4522            json!({
4523                "one.txt": "",
4524                "two.txt": "",
4525                "four.txt": "",
4526                "b": {
4527                    "3": { "Q": "" },
4528                    "4": { "R": "", "S": "", "T": "", "U": "" },
4529                },
4530            }),
4531        )
4532        .await;
4533
4534        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
4535        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4536        let cx = &mut VisualTestContext::from_window(*workspace, cx);
4537        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4538
4539        select_path(&panel, "root1/three.txt", cx);
4540        panel.update(cx, |panel, cx| {
4541            panel.copy(&Default::default(), cx);
4542        });
4543
4544        select_path(&panel, "root2/one.txt", cx);
4545        panel.update(cx, |panel, cx| {
4546            panel.select_next(&Default::default(), cx);
4547            panel.paste(&Default::default(), cx);
4548        });
4549        cx.executor().run_until_parked();
4550        assert_eq!(
4551            visible_entries_as_strings(&panel, 0..50, cx),
4552            &[
4553                //
4554                "v root1",
4555                "    > a",
4556                "      one.txt",
4557                "      three.txt",
4558                "      two.txt",
4559                "v root2",
4560                "    > b",
4561                "      four.txt",
4562                "      one.txt",
4563                "      three.txt  <== selected",
4564                "      two.txt",
4565            ]
4566        );
4567
4568        select_path(&panel, "root1/three.txt", cx);
4569        panel.update(cx, |panel, cx| {
4570            panel.copy(&Default::default(), cx);
4571        });
4572        select_path(&panel, "root2/two.txt", cx);
4573        panel.update(cx, |panel, cx| {
4574            panel.select_next(&Default::default(), cx);
4575            panel.paste(&Default::default(), cx);
4576        });
4577
4578        cx.executor().run_until_parked();
4579        assert_eq!(
4580            visible_entries_as_strings(&panel, 0..50, cx),
4581            &[
4582                //
4583                "v root1",
4584                "    > a",
4585                "      one.txt",
4586                "      three.txt",
4587                "      two.txt",
4588                "v root2",
4589                "    > b",
4590                "      four.txt",
4591                "      one.txt",
4592                "      three.txt",
4593                "      three copy.txt  <== selected",
4594                "      two.txt",
4595            ]
4596        );
4597
4598        select_path(&panel, "root1/a", cx);
4599        panel.update(cx, |panel, cx| {
4600            panel.copy(&Default::default(), cx);
4601        });
4602        select_path(&panel, "root2/two.txt", cx);
4603        panel.update(cx, |panel, cx| {
4604            panel.select_next(&Default::default(), cx);
4605            panel.paste(&Default::default(), cx);
4606        });
4607
4608        cx.executor().run_until_parked();
4609        assert_eq!(
4610            visible_entries_as_strings(&panel, 0..50, cx),
4611            &[
4612                //
4613                "v root1",
4614                "    > a",
4615                "      one.txt",
4616                "      three.txt",
4617                "      two.txt",
4618                "v root2",
4619                "    > a  <== selected",
4620                "    > b",
4621                "      four.txt",
4622                "      one.txt",
4623                "      three.txt",
4624                "      three copy.txt",
4625                "      two.txt",
4626            ]
4627        );
4628    }
4629
4630    #[gpui::test]
4631    async fn test_copy_paste_directory(cx: &mut gpui::TestAppContext) {
4632        init_test(cx);
4633
4634        let fs = FakeFs::new(cx.executor().clone());
4635        fs.insert_tree(
4636            "/root",
4637            json!({
4638                "a": {
4639                    "one.txt": "",
4640                    "two.txt": "",
4641                    "inner_dir": {
4642                        "three.txt": "",
4643                        "four.txt": "",
4644                    }
4645                },
4646                "b": {}
4647            }),
4648        )
4649        .await;
4650
4651        let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
4652        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4653        let cx = &mut VisualTestContext::from_window(*workspace, cx);
4654        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4655
4656        select_path(&panel, "root/a", cx);
4657        panel.update(cx, |panel, cx| {
4658            panel.copy(&Default::default(), cx);
4659            panel.select_next(&Default::default(), cx);
4660            panel.paste(&Default::default(), cx);
4661        });
4662        cx.executor().run_until_parked();
4663
4664        let pasted_dir = find_project_entry(&panel, "root/b/a", cx);
4665        assert_ne!(pasted_dir, None, "Pasted directory should have an entry");
4666
4667        let pasted_dir_file = find_project_entry(&panel, "root/b/a/one.txt", cx);
4668        assert_ne!(
4669            pasted_dir_file, None,
4670            "Pasted directory file should have an entry"
4671        );
4672
4673        let pasted_dir_inner_dir = find_project_entry(&panel, "root/b/a/inner_dir", cx);
4674        assert_ne!(
4675            pasted_dir_inner_dir, None,
4676            "Directories inside pasted directory should have an entry"
4677        );
4678
4679        toggle_expand_dir(&panel, "root/b/a", cx);
4680        toggle_expand_dir(&panel, "root/b/a/inner_dir", cx);
4681
4682        assert_eq!(
4683            visible_entries_as_strings(&panel, 0..50, cx),
4684            &[
4685                //
4686                "v root",
4687                "    > a",
4688                "    v b",
4689                "        v a",
4690                "            v inner_dir  <== selected",
4691                "                  four.txt",
4692                "                  three.txt",
4693                "              one.txt",
4694                "              two.txt",
4695            ]
4696        );
4697
4698        select_path(&panel, "root", cx);
4699        panel.update(cx, |panel, cx| panel.paste(&Default::default(), cx));
4700        cx.executor().run_until_parked();
4701        panel.update(cx, |panel, cx| panel.paste(&Default::default(), cx));
4702        cx.executor().run_until_parked();
4703        assert_eq!(
4704            visible_entries_as_strings(&panel, 0..50, cx),
4705            &[
4706                //
4707                "v root",
4708                "    > a",
4709                "    v a copy",
4710                "        > a  <== selected",
4711                "        > inner_dir",
4712                "          one.txt",
4713                "          two.txt",
4714                "    v b",
4715                "        v a",
4716                "            v inner_dir",
4717                "                  four.txt",
4718                "                  three.txt",
4719                "              one.txt",
4720                "              two.txt"
4721            ]
4722        );
4723    }
4724
4725    #[gpui::test]
4726    async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
4727        init_test_with_editor(cx);
4728
4729        let fs = FakeFs::new(cx.executor().clone());
4730        fs.insert_tree(
4731            "/src",
4732            json!({
4733                "test": {
4734                    "first.rs": "// First Rust file",
4735                    "second.rs": "// Second Rust file",
4736                    "third.rs": "// Third Rust file",
4737                }
4738            }),
4739        )
4740        .await;
4741
4742        let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
4743        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4744        let cx = &mut VisualTestContext::from_window(*workspace, cx);
4745        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4746
4747        toggle_expand_dir(&panel, "src/test", cx);
4748        select_path(&panel, "src/test/first.rs", cx);
4749        panel.update(cx, |panel, cx| panel.open(&Open, cx));
4750        cx.executor().run_until_parked();
4751        assert_eq!(
4752            visible_entries_as_strings(&panel, 0..10, cx),
4753            &[
4754                "v src",
4755                "    v test",
4756                "          first.rs  <== selected",
4757                "          second.rs",
4758                "          third.rs"
4759            ]
4760        );
4761        ensure_single_file_is_opened(&workspace, "test/first.rs", cx);
4762
4763        submit_deletion(&panel, cx);
4764        assert_eq!(
4765            visible_entries_as_strings(&panel, 0..10, cx),
4766            &[
4767                "v src",
4768                "    v test",
4769                "          second.rs",
4770                "          third.rs"
4771            ],
4772            "Project panel should have no deleted file, no other file is selected in it"
4773        );
4774        ensure_no_open_items_and_panes(&workspace, cx);
4775
4776        select_path(&panel, "src/test/second.rs", cx);
4777        panel.update(cx, |panel, cx| panel.open(&Open, cx));
4778        cx.executor().run_until_parked();
4779        assert_eq!(
4780            visible_entries_as_strings(&panel, 0..10, cx),
4781            &[
4782                "v src",
4783                "    v test",
4784                "          second.rs  <== selected",
4785                "          third.rs"
4786            ]
4787        );
4788        ensure_single_file_is_opened(&workspace, "test/second.rs", cx);
4789
4790        workspace
4791            .update(cx, |workspace, cx| {
4792                let active_items = workspace
4793                    .panes()
4794                    .iter()
4795                    .filter_map(|pane| pane.read(cx).active_item())
4796                    .collect::<Vec<_>>();
4797                assert_eq!(active_items.len(), 1);
4798                let open_editor = active_items
4799                    .into_iter()
4800                    .next()
4801                    .unwrap()
4802                    .downcast::<Editor>()
4803                    .expect("Open item should be an editor");
4804                open_editor.update(cx, |editor, cx| editor.set_text("Another text!", cx));
4805            })
4806            .unwrap();
4807        submit_deletion_skipping_prompt(&panel, cx);
4808        assert_eq!(
4809            visible_entries_as_strings(&panel, 0..10, cx),
4810            &["v src", "    v test", "          third.rs"],
4811            "Project panel should have no deleted file, with one last file remaining"
4812        );
4813        ensure_no_open_items_and_panes(&workspace, cx);
4814    }
4815
4816    #[gpui::test]
4817    async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
4818        init_test_with_editor(cx);
4819
4820        let fs = FakeFs::new(cx.executor().clone());
4821        fs.insert_tree(
4822            "/src",
4823            json!({
4824                "test": {
4825                    "first.rs": "// First Rust file",
4826                    "second.rs": "// Second Rust file",
4827                    "third.rs": "// Third Rust file",
4828                }
4829            }),
4830        )
4831        .await;
4832
4833        let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
4834        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4835        let cx = &mut VisualTestContext::from_window(*workspace, cx);
4836        let panel = workspace
4837            .update(cx, |workspace, cx| {
4838                let panel = ProjectPanel::new(workspace, cx);
4839                workspace.add_panel(panel.clone(), cx);
4840                panel
4841            })
4842            .unwrap();
4843
4844        select_path(&panel, "src/", cx);
4845        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
4846        cx.executor().run_until_parked();
4847        assert_eq!(
4848            visible_entries_as_strings(&panel, 0..10, cx),
4849            &[
4850                //
4851                "v src  <== selected",
4852                "    > test"
4853            ]
4854        );
4855        panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
4856        panel.update(cx, |panel, cx| {
4857            assert!(panel.filename_editor.read(cx).is_focused(cx));
4858        });
4859        assert_eq!(
4860            visible_entries_as_strings(&panel, 0..10, cx),
4861            &[
4862                //
4863                "v src",
4864                "    > [EDITOR: '']  <== selected",
4865                "    > test"
4866            ]
4867        );
4868        panel.update(cx, |panel, cx| {
4869            panel
4870                .filename_editor
4871                .update(cx, |editor, cx| editor.set_text("test", cx));
4872            assert!(
4873                panel.confirm_edit(cx).is_none(),
4874                "Should not allow to confirm on conflicting new directory name"
4875            )
4876        });
4877        assert_eq!(
4878            visible_entries_as_strings(&panel, 0..10, cx),
4879            &[
4880                //
4881                "v src",
4882                "    > test"
4883            ],
4884            "File list should be unchanged after failed folder create confirmation"
4885        );
4886
4887        select_path(&panel, "src/test/", cx);
4888        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
4889        cx.executor().run_until_parked();
4890        assert_eq!(
4891            visible_entries_as_strings(&panel, 0..10, cx),
4892            &[
4893                //
4894                "v src",
4895                "    > test  <== selected"
4896            ]
4897        );
4898        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
4899        panel.update(cx, |panel, cx| {
4900            assert!(panel.filename_editor.read(cx).is_focused(cx));
4901        });
4902        assert_eq!(
4903            visible_entries_as_strings(&panel, 0..10, cx),
4904            &[
4905                "v src",
4906                "    v test",
4907                "          [EDITOR: '']  <== selected",
4908                "          first.rs",
4909                "          second.rs",
4910                "          third.rs"
4911            ]
4912        );
4913        panel.update(cx, |panel, cx| {
4914            panel
4915                .filename_editor
4916                .update(cx, |editor, cx| editor.set_text("first.rs", cx));
4917            assert!(
4918                panel.confirm_edit(cx).is_none(),
4919                "Should not allow to confirm on conflicting new file name"
4920            )
4921        });
4922        assert_eq!(
4923            visible_entries_as_strings(&panel, 0..10, cx),
4924            &[
4925                "v src",
4926                "    v test",
4927                "          first.rs",
4928                "          second.rs",
4929                "          third.rs"
4930            ],
4931            "File list should be unchanged after failed file create confirmation"
4932        );
4933
4934        select_path(&panel, "src/test/first.rs", cx);
4935        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
4936        cx.executor().run_until_parked();
4937        assert_eq!(
4938            visible_entries_as_strings(&panel, 0..10, cx),
4939            &[
4940                "v src",
4941                "    v test",
4942                "          first.rs  <== selected",
4943                "          second.rs",
4944                "          third.rs"
4945            ],
4946        );
4947        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
4948        panel.update(cx, |panel, cx| {
4949            assert!(panel.filename_editor.read(cx).is_focused(cx));
4950        });
4951        assert_eq!(
4952            visible_entries_as_strings(&panel, 0..10, cx),
4953            &[
4954                "v src",
4955                "    v test",
4956                "          [EDITOR: 'first.rs']  <== selected",
4957                "          second.rs",
4958                "          third.rs"
4959            ]
4960        );
4961        panel.update(cx, |panel, cx| {
4962            panel
4963                .filename_editor
4964                .update(cx, |editor, cx| editor.set_text("second.rs", cx));
4965            assert!(
4966                panel.confirm_edit(cx).is_none(),
4967                "Should not allow to confirm on conflicting file rename"
4968            )
4969        });
4970        assert_eq!(
4971            visible_entries_as_strings(&panel, 0..10, cx),
4972            &[
4973                "v src",
4974                "    v test",
4975                "          first.rs  <== selected",
4976                "          second.rs",
4977                "          third.rs"
4978            ],
4979            "File list should be unchanged after failed rename confirmation"
4980        );
4981    }
4982
4983    #[gpui::test]
4984    async fn test_dir_toggle_collapse(cx: &mut gpui::TestAppContext) {
4985        init_test_with_editor(cx);
4986
4987        let fs = FakeFs::new(cx.executor().clone());
4988        fs.insert_tree(
4989            "/project_root",
4990            json!({
4991                "dir_1": {
4992                    "nested_dir": {
4993                        "file_a.py": "# File contents",
4994                    }
4995                },
4996                "file_1.py": "# File contents",
4997            }),
4998        )
4999        .await;
5000
5001        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
5002        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5003        let cx = &mut VisualTestContext::from_window(*workspace, cx);
5004        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5005
5006        panel.update(cx, |panel, cx| panel.open(&Open, cx));
5007        cx.executor().run_until_parked();
5008        select_path(&panel, "project_root/dir_1", cx);
5009        panel.update(cx, |panel, cx| panel.open(&Open, cx));
5010        select_path(&panel, "project_root/dir_1/nested_dir", cx);
5011        panel.update(cx, |panel, cx| panel.open(&Open, cx));
5012        panel.update(cx, |panel, cx| panel.open(&Open, cx));
5013        cx.executor().run_until_parked();
5014        assert_eq!(
5015            visible_entries_as_strings(&panel, 0..10, cx),
5016            &[
5017                "v project_root",
5018                "    v dir_1",
5019                "        > nested_dir  <== selected",
5020                "      file_1.py",
5021            ]
5022        );
5023    }
5024
5025    #[gpui::test]
5026    async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) {
5027        init_test_with_editor(cx);
5028
5029        let fs = FakeFs::new(cx.executor().clone());
5030        fs.insert_tree(
5031            "/project_root",
5032            json!({
5033                "dir_1": {
5034                    "nested_dir": {
5035                        "file_a.py": "# File contents",
5036                        "file_b.py": "# File contents",
5037                        "file_c.py": "# File contents",
5038                    },
5039                    "file_1.py": "# File contents",
5040                    "file_2.py": "# File contents",
5041                    "file_3.py": "# File contents",
5042                },
5043                "dir_2": {
5044                    "file_1.py": "# File contents",
5045                    "file_2.py": "# File contents",
5046                    "file_3.py": "# File contents",
5047                }
5048            }),
5049        )
5050        .await;
5051
5052        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
5053        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5054        let cx = &mut VisualTestContext::from_window(*workspace, cx);
5055        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5056
5057        panel.update(cx, |panel, cx| {
5058            panel.collapse_all_entries(&CollapseAllEntries, cx)
5059        });
5060        cx.executor().run_until_parked();
5061        assert_eq!(
5062            visible_entries_as_strings(&panel, 0..10, cx),
5063            &["v project_root", "    > dir_1", "    > dir_2",]
5064        );
5065
5066        // Open dir_1 and make sure nested_dir was collapsed when running collapse_all_entries
5067        toggle_expand_dir(&panel, "project_root/dir_1", cx);
5068        cx.executor().run_until_parked();
5069        assert_eq!(
5070            visible_entries_as_strings(&panel, 0..10, cx),
5071            &[
5072                "v project_root",
5073                "    v dir_1  <== selected",
5074                "        > nested_dir",
5075                "          file_1.py",
5076                "          file_2.py",
5077                "          file_3.py",
5078                "    > dir_2",
5079            ]
5080        );
5081    }
5082
5083    #[gpui::test]
5084    async fn test_new_file_move(cx: &mut gpui::TestAppContext) {
5085        init_test(cx);
5086
5087        let fs = FakeFs::new(cx.executor().clone());
5088        fs.as_fake().insert_tree("/root", json!({})).await;
5089        let project = Project::test(fs, ["/root".as_ref()], cx).await;
5090        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5091        let cx = &mut VisualTestContext::from_window(*workspace, cx);
5092        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5093
5094        // Make a new buffer with no backing file
5095        workspace
5096            .update(cx, |workspace, cx| {
5097                Editor::new_file(workspace, &Default::default(), cx)
5098            })
5099            .unwrap();
5100
5101        cx.executor().run_until_parked();
5102
5103        // "Save as" the buffer, creating a new backing file for it
5104        let save_task = workspace
5105            .update(cx, |workspace, cx| {
5106                workspace.save_active_item(workspace::SaveIntent::Save, cx)
5107            })
5108            .unwrap();
5109
5110        cx.executor().run_until_parked();
5111        cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/new")));
5112        save_task.await.unwrap();
5113
5114        // Rename the file
5115        select_path(&panel, "root/new", cx);
5116        assert_eq!(
5117            visible_entries_as_strings(&panel, 0..10, cx),
5118            &["v root", "      new  <== selected"]
5119        );
5120        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
5121        panel.update(cx, |panel, cx| {
5122            panel
5123                .filename_editor
5124                .update(cx, |editor, cx| editor.set_text("newer", cx));
5125        });
5126        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
5127
5128        cx.executor().run_until_parked();
5129        assert_eq!(
5130            visible_entries_as_strings(&panel, 0..10, cx),
5131            &["v root", "      newer  <== selected"]
5132        );
5133
5134        workspace
5135            .update(cx, |workspace, cx| {
5136                workspace.save_active_item(workspace::SaveIntent::Save, cx)
5137            })
5138            .unwrap()
5139            .await
5140            .unwrap();
5141
5142        cx.executor().run_until_parked();
5143        // assert that saving the file doesn't restore "new"
5144        assert_eq!(
5145            visible_entries_as_strings(&panel, 0..10, cx),
5146            &["v root", "      newer  <== selected"]
5147        );
5148    }
5149
5150    #[gpui::test]
5151    async fn test_multiple_marked_entries(cx: &mut gpui::TestAppContext) {
5152        init_test_with_editor(cx);
5153        let fs = FakeFs::new(cx.executor().clone());
5154        fs.insert_tree(
5155            "/project_root",
5156            json!({
5157                "dir_1": {
5158                    "nested_dir": {
5159                        "file_a.py": "# File contents",
5160                    }
5161                },
5162                "file_1.py": "# File contents",
5163            }),
5164        )
5165        .await;
5166
5167        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
5168        let worktree_id =
5169            cx.update(|cx| project.read(cx).worktrees(cx).next().unwrap().read(cx).id());
5170        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5171        let cx = &mut VisualTestContext::from_window(*workspace, cx);
5172        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5173        cx.update(|cx| {
5174            panel.update(cx, |this, cx| {
5175                this.select_next(&Default::default(), cx);
5176                this.expand_selected_entry(&Default::default(), cx);
5177                this.expand_selected_entry(&Default::default(), cx);
5178                this.select_next(&Default::default(), cx);
5179                this.expand_selected_entry(&Default::default(), cx);
5180                this.select_next(&Default::default(), cx);
5181            })
5182        });
5183        assert_eq!(
5184            visible_entries_as_strings(&panel, 0..10, cx),
5185            &[
5186                "v project_root",
5187                "    v dir_1",
5188                "        v nested_dir",
5189                "              file_a.py  <== selected",
5190                "      file_1.py",
5191            ]
5192        );
5193        let modifiers_with_shift = gpui::Modifiers {
5194            shift: true,
5195            ..Default::default()
5196        };
5197        cx.simulate_modifiers_change(modifiers_with_shift);
5198        cx.update(|cx| {
5199            panel.update(cx, |this, cx| {
5200                this.select_next(&Default::default(), cx);
5201            })
5202        });
5203        assert_eq!(
5204            visible_entries_as_strings(&panel, 0..10, cx),
5205            &[
5206                "v project_root",
5207                "    v dir_1",
5208                "        v nested_dir",
5209                "              file_a.py",
5210                "      file_1.py  <== selected  <== marked",
5211            ]
5212        );
5213        cx.update(|cx| {
5214            panel.update(cx, |this, cx| {
5215                this.select_prev(&Default::default(), cx);
5216            })
5217        });
5218        assert_eq!(
5219            visible_entries_as_strings(&panel, 0..10, cx),
5220            &[
5221                "v project_root",
5222                "    v dir_1",
5223                "        v nested_dir",
5224                "              file_a.py  <== selected  <== marked",
5225                "      file_1.py  <== marked",
5226            ]
5227        );
5228        cx.update(|cx| {
5229            panel.update(cx, |this, cx| {
5230                let drag = DraggedSelection {
5231                    active_selection: this.selection.unwrap(),
5232                    marked_selections: Arc::new(this.marked_entries.clone()),
5233                };
5234                let target_entry = this
5235                    .project
5236                    .read(cx)
5237                    .entry_for_path(&(worktree_id, "").into(), cx)
5238                    .unwrap();
5239                this.drag_onto(&drag, target_entry.id, false, cx);
5240            });
5241        });
5242        cx.run_until_parked();
5243        assert_eq!(
5244            visible_entries_as_strings(&panel, 0..10, cx),
5245            &[
5246                "v project_root",
5247                "    v dir_1",
5248                "        v nested_dir",
5249                "      file_1.py  <== marked",
5250                "      file_a.py  <== selected  <== marked",
5251            ]
5252        );
5253        // ESC clears out all marks
5254        cx.update(|cx| {
5255            panel.update(cx, |this, cx| {
5256                this.cancel(&menu::Cancel, cx);
5257            })
5258        });
5259        assert_eq!(
5260            visible_entries_as_strings(&panel, 0..10, cx),
5261            &[
5262                "v project_root",
5263                "    v dir_1",
5264                "        v nested_dir",
5265                "      file_1.py",
5266                "      file_a.py  <== selected",
5267            ]
5268        );
5269        // ESC clears out all marks
5270        cx.update(|cx| {
5271            panel.update(cx, |this, cx| {
5272                this.select_prev(&SelectPrev, cx);
5273                this.select_next(&SelectNext, cx);
5274            })
5275        });
5276        assert_eq!(
5277            visible_entries_as_strings(&panel, 0..10, cx),
5278            &[
5279                "v project_root",
5280                "    v dir_1",
5281                "        v nested_dir",
5282                "      file_1.py  <== marked",
5283                "      file_a.py  <== selected  <== marked",
5284            ]
5285        );
5286        cx.simulate_modifiers_change(Default::default());
5287        cx.update(|cx| {
5288            panel.update(cx, |this, cx| {
5289                this.cut(&Cut, cx);
5290                this.select_prev(&SelectPrev, cx);
5291                this.select_prev(&SelectPrev, cx);
5292
5293                this.paste(&Paste, cx);
5294                // this.expand_selected_entry(&ExpandSelectedEntry, cx);
5295            })
5296        });
5297        cx.run_until_parked();
5298        assert_eq!(
5299            visible_entries_as_strings(&panel, 0..10, cx),
5300            &[
5301                "v project_root",
5302                "    v dir_1",
5303                "        v nested_dir",
5304                "              file_1.py  <== marked",
5305                "              file_a.py  <== selected  <== marked",
5306            ]
5307        );
5308        cx.simulate_modifiers_change(modifiers_with_shift);
5309        cx.update(|cx| {
5310            panel.update(cx, |this, cx| {
5311                this.expand_selected_entry(&Default::default(), cx);
5312                this.select_next(&SelectNext, cx);
5313                this.select_next(&SelectNext, cx);
5314            })
5315        });
5316        submit_deletion(&panel, cx);
5317        assert_eq!(
5318            visible_entries_as_strings(&panel, 0..10, cx),
5319            &["v project_root", "    v dir_1", "        v nested_dir",]
5320        );
5321    }
5322    #[gpui::test]
5323    async fn test_autoreveal_and_gitignored_files(cx: &mut gpui::TestAppContext) {
5324        init_test_with_editor(cx);
5325        cx.update(|cx| {
5326            cx.update_global::<SettingsStore, _>(|store, cx| {
5327                store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
5328                    worktree_settings.file_scan_exclusions = Some(Vec::new());
5329                });
5330                store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
5331                    project_panel_settings.auto_reveal_entries = Some(false)
5332                });
5333            })
5334        });
5335
5336        let fs = FakeFs::new(cx.background_executor.clone());
5337        fs.insert_tree(
5338            "/project_root",
5339            json!({
5340                ".git": {},
5341                ".gitignore": "**/gitignored_dir",
5342                "dir_1": {
5343                    "file_1.py": "# File 1_1 contents",
5344                    "file_2.py": "# File 1_2 contents",
5345                    "file_3.py": "# File 1_3 contents",
5346                    "gitignored_dir": {
5347                        "file_a.py": "# File contents",
5348                        "file_b.py": "# File contents",
5349                        "file_c.py": "# File contents",
5350                    },
5351                },
5352                "dir_2": {
5353                    "file_1.py": "# File 2_1 contents",
5354                    "file_2.py": "# File 2_2 contents",
5355                    "file_3.py": "# File 2_3 contents",
5356                }
5357            }),
5358        )
5359        .await;
5360
5361        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
5362        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5363        let cx = &mut VisualTestContext::from_window(*workspace, cx);
5364        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5365
5366        assert_eq!(
5367            visible_entries_as_strings(&panel, 0..20, cx),
5368            &[
5369                "v project_root",
5370                "    > .git",
5371                "    > dir_1",
5372                "    > dir_2",
5373                "      .gitignore",
5374            ]
5375        );
5376
5377        let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
5378            .expect("dir 1 file is not ignored and should have an entry");
5379        let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
5380            .expect("dir 2 file is not ignored and should have an entry");
5381        let gitignored_dir_file =
5382            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
5383        assert_eq!(
5384            gitignored_dir_file, None,
5385            "File in the gitignored dir should not have an entry before its dir is toggled"
5386        );
5387
5388        toggle_expand_dir(&panel, "project_root/dir_1", cx);
5389        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
5390        cx.executor().run_until_parked();
5391        assert_eq!(
5392            visible_entries_as_strings(&panel, 0..20, cx),
5393            &[
5394                "v project_root",
5395                "    > .git",
5396                "    v dir_1",
5397                "        v gitignored_dir  <== selected",
5398                "              file_a.py",
5399                "              file_b.py",
5400                "              file_c.py",
5401                "          file_1.py",
5402                "          file_2.py",
5403                "          file_3.py",
5404                "    > dir_2",
5405                "      .gitignore",
5406            ],
5407            "Should show gitignored dir file list in the project panel"
5408        );
5409        let gitignored_dir_file =
5410            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
5411                .expect("after gitignored dir got opened, a file entry should be present");
5412
5413        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
5414        toggle_expand_dir(&panel, "project_root/dir_1", cx);
5415        assert_eq!(
5416            visible_entries_as_strings(&panel, 0..20, cx),
5417            &[
5418                "v project_root",
5419                "    > .git",
5420                "    > dir_1  <== selected",
5421                "    > dir_2",
5422                "      .gitignore",
5423            ],
5424            "Should hide all dir contents again and prepare for the auto reveal test"
5425        );
5426
5427        for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
5428            panel.update(cx, |panel, cx| {
5429                panel.project.update(cx, |_, cx| {
5430                    cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
5431                })
5432            });
5433            cx.run_until_parked();
5434            assert_eq!(
5435                visible_entries_as_strings(&panel, 0..20, cx),
5436                &[
5437                    "v project_root",
5438                    "    > .git",
5439                    "    > dir_1  <== selected",
5440                    "    > dir_2",
5441                    "      .gitignore",
5442                ],
5443                "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
5444            );
5445        }
5446
5447        cx.update(|cx| {
5448            cx.update_global::<SettingsStore, _>(|store, cx| {
5449                store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
5450                    project_panel_settings.auto_reveal_entries = Some(true)
5451                });
5452            })
5453        });
5454
5455        panel.update(cx, |panel, cx| {
5456            panel.project.update(cx, |_, cx| {
5457                cx.emit(project::Event::ActiveEntryChanged(Some(dir_1_file)))
5458            })
5459        });
5460        cx.run_until_parked();
5461        assert_eq!(
5462            visible_entries_as_strings(&panel, 0..20, cx),
5463            &[
5464                "v project_root",
5465                "    > .git",
5466                "    v dir_1",
5467                "        > gitignored_dir",
5468                "          file_1.py  <== selected",
5469                "          file_2.py",
5470                "          file_3.py",
5471                "    > dir_2",
5472                "      .gitignore",
5473            ],
5474            "When auto reveal is enabled, not ignored dir_1 entry should be revealed"
5475        );
5476
5477        panel.update(cx, |panel, cx| {
5478            panel.project.update(cx, |_, cx| {
5479                cx.emit(project::Event::ActiveEntryChanged(Some(dir_2_file)))
5480            })
5481        });
5482        cx.run_until_parked();
5483        assert_eq!(
5484            visible_entries_as_strings(&panel, 0..20, cx),
5485            &[
5486                "v project_root",
5487                "    > .git",
5488                "    v dir_1",
5489                "        > gitignored_dir",
5490                "          file_1.py",
5491                "          file_2.py",
5492                "          file_3.py",
5493                "    v dir_2",
5494                "          file_1.py  <== selected",
5495                "          file_2.py",
5496                "          file_3.py",
5497                "      .gitignore",
5498            ],
5499            "When auto reveal is enabled, not ignored dir_2 entry should be revealed"
5500        );
5501
5502        panel.update(cx, |panel, cx| {
5503            panel.project.update(cx, |_, cx| {
5504                cx.emit(project::Event::ActiveEntryChanged(Some(
5505                    gitignored_dir_file,
5506                )))
5507            })
5508        });
5509        cx.run_until_parked();
5510        assert_eq!(
5511            visible_entries_as_strings(&panel, 0..20, cx),
5512            &[
5513                "v project_root",
5514                "    > .git",
5515                "    v dir_1",
5516                "        > gitignored_dir",
5517                "          file_1.py",
5518                "          file_2.py",
5519                "          file_3.py",
5520                "    v dir_2",
5521                "          file_1.py  <== selected",
5522                "          file_2.py",
5523                "          file_3.py",
5524                "      .gitignore",
5525            ],
5526            "When auto reveal is enabled, a gitignored selected entry should not be revealed in the project panel"
5527        );
5528
5529        panel.update(cx, |panel, cx| {
5530            panel.project.update(cx, |_, cx| {
5531                cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
5532            })
5533        });
5534        cx.run_until_parked();
5535        assert_eq!(
5536            visible_entries_as_strings(&panel, 0..20, cx),
5537            &[
5538                "v project_root",
5539                "    > .git",
5540                "    v dir_1",
5541                "        v gitignored_dir",
5542                "              file_a.py  <== selected",
5543                "              file_b.py",
5544                "              file_c.py",
5545                "          file_1.py",
5546                "          file_2.py",
5547                "          file_3.py",
5548                "    v dir_2",
5549                "          file_1.py",
5550                "          file_2.py",
5551                "          file_3.py",
5552                "      .gitignore",
5553            ],
5554            "When a gitignored entry is explicitly revealed, it should be shown in the project tree"
5555        );
5556    }
5557
5558    #[gpui::test]
5559    async fn test_explicit_reveal(cx: &mut gpui::TestAppContext) {
5560        init_test_with_editor(cx);
5561        cx.update(|cx| {
5562            cx.update_global::<SettingsStore, _>(|store, cx| {
5563                store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
5564                    worktree_settings.file_scan_exclusions = Some(Vec::new());
5565                });
5566                store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
5567                    project_panel_settings.auto_reveal_entries = Some(false)
5568                });
5569            })
5570        });
5571
5572        let fs = FakeFs::new(cx.background_executor.clone());
5573        fs.insert_tree(
5574            "/project_root",
5575            json!({
5576                ".git": {},
5577                ".gitignore": "**/gitignored_dir",
5578                "dir_1": {
5579                    "file_1.py": "# File 1_1 contents",
5580                    "file_2.py": "# File 1_2 contents",
5581                    "file_3.py": "# File 1_3 contents",
5582                    "gitignored_dir": {
5583                        "file_a.py": "# File contents",
5584                        "file_b.py": "# File contents",
5585                        "file_c.py": "# File contents",
5586                    },
5587                },
5588                "dir_2": {
5589                    "file_1.py": "# File 2_1 contents",
5590                    "file_2.py": "# File 2_2 contents",
5591                    "file_3.py": "# File 2_3 contents",
5592                }
5593            }),
5594        )
5595        .await;
5596
5597        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
5598        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5599        let cx = &mut VisualTestContext::from_window(*workspace, cx);
5600        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5601
5602        assert_eq!(
5603            visible_entries_as_strings(&panel, 0..20, cx),
5604            &[
5605                "v project_root",
5606                "    > .git",
5607                "    > dir_1",
5608                "    > dir_2",
5609                "      .gitignore",
5610            ]
5611        );
5612
5613        let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
5614            .expect("dir 1 file is not ignored and should have an entry");
5615        let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
5616            .expect("dir 2 file is not ignored and should have an entry");
5617        let gitignored_dir_file =
5618            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
5619        assert_eq!(
5620            gitignored_dir_file, None,
5621            "File in the gitignored dir should not have an entry before its dir is toggled"
5622        );
5623
5624        toggle_expand_dir(&panel, "project_root/dir_1", cx);
5625        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
5626        cx.run_until_parked();
5627        assert_eq!(
5628            visible_entries_as_strings(&panel, 0..20, cx),
5629            &[
5630                "v project_root",
5631                "    > .git",
5632                "    v dir_1",
5633                "        v gitignored_dir  <== selected",
5634                "              file_a.py",
5635                "              file_b.py",
5636                "              file_c.py",
5637                "          file_1.py",
5638                "          file_2.py",
5639                "          file_3.py",
5640                "    > dir_2",
5641                "      .gitignore",
5642            ],
5643            "Should show gitignored dir file list in the project panel"
5644        );
5645        let gitignored_dir_file =
5646            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
5647                .expect("after gitignored dir got opened, a file entry should be present");
5648
5649        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
5650        toggle_expand_dir(&panel, "project_root/dir_1", cx);
5651        assert_eq!(
5652            visible_entries_as_strings(&panel, 0..20, cx),
5653            &[
5654                "v project_root",
5655                "    > .git",
5656                "    > dir_1  <== selected",
5657                "    > dir_2",
5658                "      .gitignore",
5659            ],
5660            "Should hide all dir contents again and prepare for the explicit reveal test"
5661        );
5662
5663        for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
5664            panel.update(cx, |panel, cx| {
5665                panel.project.update(cx, |_, cx| {
5666                    cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
5667                })
5668            });
5669            cx.run_until_parked();
5670            assert_eq!(
5671                visible_entries_as_strings(&panel, 0..20, cx),
5672                &[
5673                    "v project_root",
5674                    "    > .git",
5675                    "    > dir_1  <== selected",
5676                    "    > dir_2",
5677                    "      .gitignore",
5678                ],
5679                "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
5680            );
5681        }
5682
5683        panel.update(cx, |panel, cx| {
5684            panel.project.update(cx, |_, cx| {
5685                cx.emit(project::Event::RevealInProjectPanel(dir_1_file))
5686            })
5687        });
5688        cx.run_until_parked();
5689        assert_eq!(
5690            visible_entries_as_strings(&panel, 0..20, cx),
5691            &[
5692                "v project_root",
5693                "    > .git",
5694                "    v dir_1",
5695                "        > gitignored_dir",
5696                "          file_1.py  <== selected",
5697                "          file_2.py",
5698                "          file_3.py",
5699                "    > dir_2",
5700                "      .gitignore",
5701            ],
5702            "With no auto reveal, explicit reveal should show the dir_1 entry in the project panel"
5703        );
5704
5705        panel.update(cx, |panel, cx| {
5706            panel.project.update(cx, |_, cx| {
5707                cx.emit(project::Event::RevealInProjectPanel(dir_2_file))
5708            })
5709        });
5710        cx.run_until_parked();
5711        assert_eq!(
5712            visible_entries_as_strings(&panel, 0..20, cx),
5713            &[
5714                "v project_root",
5715                "    > .git",
5716                "    v dir_1",
5717                "        > gitignored_dir",
5718                "          file_1.py",
5719                "          file_2.py",
5720                "          file_3.py",
5721                "    v dir_2",
5722                "          file_1.py  <== selected",
5723                "          file_2.py",
5724                "          file_3.py",
5725                "      .gitignore",
5726            ],
5727            "With no auto reveal, explicit reveal should show the dir_2 entry in the project panel"
5728        );
5729
5730        panel.update(cx, |panel, cx| {
5731            panel.project.update(cx, |_, cx| {
5732                cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
5733            })
5734        });
5735        cx.run_until_parked();
5736        assert_eq!(
5737            visible_entries_as_strings(&panel, 0..20, cx),
5738            &[
5739                "v project_root",
5740                "    > .git",
5741                "    v dir_1",
5742                "        v gitignored_dir",
5743                "              file_a.py  <== selected",
5744                "              file_b.py",
5745                "              file_c.py",
5746                "          file_1.py",
5747                "          file_2.py",
5748                "          file_3.py",
5749                "    v dir_2",
5750                "          file_1.py",
5751                "          file_2.py",
5752                "          file_3.py",
5753                "      .gitignore",
5754            ],
5755            "With no auto reveal, explicit reveal should show the gitignored entry in the project panel"
5756        );
5757    }
5758
5759    #[gpui::test]
5760    async fn test_creating_excluded_entries(cx: &mut gpui::TestAppContext) {
5761        init_test(cx);
5762        cx.update(|cx| {
5763            cx.update_global::<SettingsStore, _>(|store, cx| {
5764                store.update_user_settings::<WorktreeSettings>(cx, |project_settings| {
5765                    project_settings.file_scan_exclusions =
5766                        Some(vec!["excluded_dir".to_string(), "**/.git".to_string()]);
5767                });
5768            });
5769        });
5770
5771        cx.update(|cx| {
5772            register_project_item::<TestProjectItemView>(cx);
5773        });
5774
5775        let fs = FakeFs::new(cx.executor().clone());
5776        fs.insert_tree(
5777            "/root1",
5778            json!({
5779                ".dockerignore": "",
5780                ".git": {
5781                    "HEAD": "",
5782                },
5783            }),
5784        )
5785        .await;
5786
5787        let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
5788        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5789        let cx = &mut VisualTestContext::from_window(*workspace, cx);
5790        let panel = workspace
5791            .update(cx, |workspace, cx| {
5792                let panel = ProjectPanel::new(workspace, cx);
5793                workspace.add_panel(panel.clone(), cx);
5794                panel
5795            })
5796            .unwrap();
5797
5798        select_path(&panel, "root1", cx);
5799        assert_eq!(
5800            visible_entries_as_strings(&panel, 0..10, cx),
5801            &["v root1  <== selected", "      .dockerignore",]
5802        );
5803        workspace
5804            .update(cx, |workspace, cx| {
5805                assert!(
5806                    workspace.active_item(cx).is_none(),
5807                    "Should have no active items in the beginning"
5808                );
5809            })
5810            .unwrap();
5811
5812        let excluded_file_path = ".git/COMMIT_EDITMSG";
5813        let excluded_dir_path = "excluded_dir";
5814
5815        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
5816        panel.update(cx, |panel, cx| {
5817            assert!(panel.filename_editor.read(cx).is_focused(cx));
5818        });
5819        panel
5820            .update(cx, |panel, cx| {
5821                panel
5822                    .filename_editor
5823                    .update(cx, |editor, cx| editor.set_text(excluded_file_path, cx));
5824                panel.confirm_edit(cx).unwrap()
5825            })
5826            .await
5827            .unwrap();
5828
5829        assert_eq!(
5830            visible_entries_as_strings(&panel, 0..13, cx),
5831            &["v root1", "      .dockerignore"],
5832            "Excluded dir should not be shown after opening a file in it"
5833        );
5834        panel.update(cx, |panel, cx| {
5835            assert!(
5836                !panel.filename_editor.read(cx).is_focused(cx),
5837                "Should have closed the file name editor"
5838            );
5839        });
5840        workspace
5841            .update(cx, |workspace, cx| {
5842                let active_entry_path = workspace
5843                    .active_item(cx)
5844                    .expect("should have opened and activated the excluded item")
5845                    .act_as::<TestProjectItemView>(cx)
5846                    .expect(
5847                        "should have opened the corresponding project item for the excluded item",
5848                    )
5849                    .read(cx)
5850                    .path
5851                    .clone();
5852                assert_eq!(
5853                    active_entry_path.path.as_ref(),
5854                    Path::new(excluded_file_path),
5855                    "Should open the excluded file"
5856                );
5857
5858                assert!(
5859                    workspace.notification_ids().is_empty(),
5860                    "Should have no notifications after opening an excluded file"
5861                );
5862            })
5863            .unwrap();
5864        assert!(
5865            fs.is_file(Path::new("/root1/.git/COMMIT_EDITMSG")).await,
5866            "Should have created the excluded file"
5867        );
5868
5869        select_path(&panel, "root1", cx);
5870        panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
5871        panel.update(cx, |panel, cx| {
5872            assert!(panel.filename_editor.read(cx).is_focused(cx));
5873        });
5874        panel
5875            .update(cx, |panel, cx| {
5876                panel
5877                    .filename_editor
5878                    .update(cx, |editor, cx| editor.set_text(excluded_file_path, cx));
5879                panel.confirm_edit(cx).unwrap()
5880            })
5881            .await
5882            .unwrap();
5883
5884        assert_eq!(
5885            visible_entries_as_strings(&panel, 0..13, cx),
5886            &["v root1", "      .dockerignore"],
5887            "Should not change the project panel after trying to create an excluded directorya directory with the same name as the excluded file"
5888        );
5889        panel.update(cx, |panel, cx| {
5890            assert!(
5891                !panel.filename_editor.read(cx).is_focused(cx),
5892                "Should have closed the file name editor"
5893            );
5894        });
5895        workspace
5896            .update(cx, |workspace, cx| {
5897                let notifications = workspace.notification_ids();
5898                assert_eq!(
5899                    notifications.len(),
5900                    1,
5901                    "Should receive one notification with the error message"
5902                );
5903                workspace.dismiss_notification(notifications.first().unwrap(), cx);
5904                assert!(workspace.notification_ids().is_empty());
5905            })
5906            .unwrap();
5907
5908        select_path(&panel, "root1", cx);
5909        panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
5910        panel.update(cx, |panel, cx| {
5911            assert!(panel.filename_editor.read(cx).is_focused(cx));
5912        });
5913        panel
5914            .update(cx, |panel, cx| {
5915                panel
5916                    .filename_editor
5917                    .update(cx, |editor, cx| editor.set_text(excluded_dir_path, cx));
5918                panel.confirm_edit(cx).unwrap()
5919            })
5920            .await
5921            .unwrap();
5922
5923        assert_eq!(
5924            visible_entries_as_strings(&panel, 0..13, cx),
5925            &["v root1", "      .dockerignore"],
5926            "Should not change the project panel after trying to create an excluded directory"
5927        );
5928        panel.update(cx, |panel, cx| {
5929            assert!(
5930                !panel.filename_editor.read(cx).is_focused(cx),
5931                "Should have closed the file name editor"
5932            );
5933        });
5934        workspace
5935            .update(cx, |workspace, cx| {
5936                let notifications = workspace.notification_ids();
5937                assert_eq!(
5938                    notifications.len(),
5939                    1,
5940                    "Should receive one notification explaining that no directory is actually shown"
5941                );
5942                workspace.dismiss_notification(notifications.first().unwrap(), cx);
5943                assert!(workspace.notification_ids().is_empty());
5944            })
5945            .unwrap();
5946        assert!(
5947            fs.is_dir(Path::new("/root1/excluded_dir")).await,
5948            "Should have created the excluded directory"
5949        );
5950    }
5951
5952    fn toggle_expand_dir(
5953        panel: &View<ProjectPanel>,
5954        path: impl AsRef<Path>,
5955        cx: &mut VisualTestContext,
5956    ) {
5957        let path = path.as_ref();
5958        panel.update(cx, |panel, cx| {
5959            for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
5960                let worktree = worktree.read(cx);
5961                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
5962                    let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
5963                    panel.toggle_expanded(entry_id, cx);
5964                    return;
5965                }
5966            }
5967            panic!("no worktree for path {:?}", path);
5968        });
5969    }
5970
5971    fn select_path(panel: &View<ProjectPanel>, path: impl AsRef<Path>, cx: &mut VisualTestContext) {
5972        let path = path.as_ref();
5973        panel.update(cx, |panel, cx| {
5974            for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
5975                let worktree = worktree.read(cx);
5976                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
5977                    let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
5978                    panel.selection = Some(crate::SelectedEntry {
5979                        worktree_id: worktree.id(),
5980                        entry_id,
5981                    });
5982                    return;
5983                }
5984            }
5985            panic!("no worktree for path {:?}", path);
5986        });
5987    }
5988
5989    fn find_project_entry(
5990        panel: &View<ProjectPanel>,
5991        path: impl AsRef<Path>,
5992        cx: &mut VisualTestContext,
5993    ) -> Option<ProjectEntryId> {
5994        let path = path.as_ref();
5995        panel.update(cx, |panel, cx| {
5996            for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
5997                let worktree = worktree.read(cx);
5998                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
5999                    return worktree.entry_for_path(relative_path).map(|entry| entry.id);
6000                }
6001            }
6002            panic!("no worktree for path {path:?}");
6003        })
6004    }
6005
6006    fn visible_entries_as_strings(
6007        panel: &View<ProjectPanel>,
6008        range: Range<usize>,
6009        cx: &mut VisualTestContext,
6010    ) -> Vec<String> {
6011        let mut result = Vec::new();
6012        let mut project_entries = HashSet::default();
6013        let mut has_editor = false;
6014
6015        panel.update(cx, |panel, cx| {
6016            panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
6017                if details.is_editing {
6018                    assert!(!has_editor, "duplicate editor entry");
6019                    has_editor = true;
6020                } else {
6021                    assert!(
6022                        project_entries.insert(project_entry),
6023                        "duplicate project entry {:?} {:?}",
6024                        project_entry,
6025                        details
6026                    );
6027                }
6028
6029                let indent = "    ".repeat(details.depth);
6030                let icon = if details.kind.is_dir() {
6031                    if details.is_expanded {
6032                        "v "
6033                    } else {
6034                        "> "
6035                    }
6036                } else {
6037                    "  "
6038                };
6039                let name = if details.is_editing {
6040                    format!("[EDITOR: '{}']", details.filename)
6041                } else if details.is_processing {
6042                    format!("[PROCESSING: '{}']", details.filename)
6043                } else {
6044                    details.filename.clone()
6045                };
6046                let selected = if details.is_selected {
6047                    "  <== selected"
6048                } else {
6049                    ""
6050                };
6051                let marked = if details.is_marked {
6052                    "  <== marked"
6053                } else {
6054                    ""
6055                };
6056
6057                result.push(format!("{indent}{icon}{name}{selected}{marked}"));
6058            });
6059        });
6060
6061        result
6062    }
6063
6064    fn init_test(cx: &mut TestAppContext) {
6065        cx.update(|cx| {
6066            let settings_store = SettingsStore::test(cx);
6067            cx.set_global(settings_store);
6068            init_settings(cx);
6069            theme::init(theme::LoadThemes::JustBase, cx);
6070            language::init(cx);
6071            editor::init_settings(cx);
6072            crate::init((), cx);
6073            workspace::init_settings(cx);
6074            client::init_settings(cx);
6075            Project::init_settings(cx);
6076
6077            cx.update_global::<SettingsStore, _>(|store, cx| {
6078                store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
6079                    project_panel_settings.auto_fold_dirs = Some(false);
6080                });
6081                store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
6082                    worktree_settings.file_scan_exclusions = Some(Vec::new());
6083                });
6084            });
6085        });
6086    }
6087
6088    fn init_test_with_editor(cx: &mut TestAppContext) {
6089        cx.update(|cx| {
6090            let app_state = AppState::test(cx);
6091            theme::init(theme::LoadThemes::JustBase, cx);
6092            init_settings(cx);
6093            language::init(cx);
6094            editor::init(cx);
6095            crate::init((), cx);
6096            workspace::init(app_state.clone(), cx);
6097            Project::init_settings(cx);
6098
6099            cx.update_global::<SettingsStore, _>(|store, cx| {
6100                store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
6101                    project_panel_settings.auto_fold_dirs = Some(false);
6102                });
6103                store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
6104                    worktree_settings.file_scan_exclusions = Some(Vec::new());
6105                });
6106            });
6107        });
6108    }
6109
6110    fn ensure_single_file_is_opened(
6111        window: &WindowHandle<Workspace>,
6112        expected_path: &str,
6113        cx: &mut TestAppContext,
6114    ) {
6115        window
6116            .update(cx, |workspace, cx| {
6117                let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
6118                assert_eq!(worktrees.len(), 1);
6119                let worktree_id = worktrees[0].read(cx).id();
6120
6121                let open_project_paths = workspace
6122                    .panes()
6123                    .iter()
6124                    .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
6125                    .collect::<Vec<_>>();
6126                assert_eq!(
6127                    open_project_paths,
6128                    vec![ProjectPath {
6129                        worktree_id,
6130                        path: Arc::from(Path::new(expected_path))
6131                    }],
6132                    "Should have opened file, selected in project panel"
6133                );
6134            })
6135            .unwrap();
6136    }
6137
6138    fn submit_deletion(panel: &View<ProjectPanel>, cx: &mut VisualTestContext) {
6139        assert!(
6140            !cx.has_pending_prompt(),
6141            "Should have no prompts before the deletion"
6142        );
6143        panel.update(cx, |panel, cx| {
6144            panel.delete(&Delete { skip_prompt: false }, cx)
6145        });
6146        assert!(
6147            cx.has_pending_prompt(),
6148            "Should have a prompt after the deletion"
6149        );
6150        cx.simulate_prompt_answer(0);
6151        assert!(
6152            !cx.has_pending_prompt(),
6153            "Should have no prompts after prompt was replied to"
6154        );
6155        cx.executor().run_until_parked();
6156    }
6157
6158    fn submit_deletion_skipping_prompt(panel: &View<ProjectPanel>, cx: &mut VisualTestContext) {
6159        assert!(
6160            !cx.has_pending_prompt(),
6161            "Should have no prompts before the deletion"
6162        );
6163        panel.update(cx, |panel, cx| {
6164            panel.delete(&Delete { skip_prompt: true }, cx)
6165        });
6166        assert!(!cx.has_pending_prompt(), "Should have received no prompts");
6167        cx.executor().run_until_parked();
6168    }
6169
6170    fn ensure_no_open_items_and_panes(
6171        workspace: &WindowHandle<Workspace>,
6172        cx: &mut VisualTestContext,
6173    ) {
6174        assert!(
6175            !cx.has_pending_prompt(),
6176            "Should have no prompts after deletion operation closes the file"
6177        );
6178        workspace
6179            .read_with(cx, |workspace, cx| {
6180                let open_project_paths = workspace
6181                    .panes()
6182                    .iter()
6183                    .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
6184                    .collect::<Vec<_>>();
6185                assert!(
6186                    open_project_paths.is_empty(),
6187                    "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
6188                );
6189            })
6190            .unwrap();
6191    }
6192
6193    struct TestProjectItemView {
6194        focus_handle: FocusHandle,
6195        path: ProjectPath,
6196    }
6197
6198    struct TestProjectItem {
6199        path: ProjectPath,
6200    }
6201
6202    impl project::Item for TestProjectItem {
6203        fn try_open(
6204            _project: &Model<Project>,
6205            path: &ProjectPath,
6206            cx: &mut AppContext,
6207        ) -> Option<Task<gpui::Result<Model<Self>>>> {
6208            let path = path.clone();
6209            Some(cx.spawn(|mut cx| async move { cx.new_model(|_| Self { path }) }))
6210        }
6211
6212        fn entry_id(&self, _: &AppContext) -> Option<ProjectEntryId> {
6213            None
6214        }
6215
6216        fn project_path(&self, _: &AppContext) -> Option<ProjectPath> {
6217            Some(self.path.clone())
6218        }
6219    }
6220
6221    impl ProjectItem for TestProjectItemView {
6222        type Item = TestProjectItem;
6223
6224        fn for_project_item(
6225            _: Model<Project>,
6226            project_item: Model<Self::Item>,
6227            cx: &mut ViewContext<Self>,
6228        ) -> Self
6229        where
6230            Self: Sized,
6231        {
6232            Self {
6233                path: project_item.update(cx, |project_item, _| project_item.path.clone()),
6234                focus_handle: cx.focus_handle(),
6235            }
6236        }
6237    }
6238
6239    impl Item for TestProjectItemView {
6240        type Event = ();
6241    }
6242
6243    impl EventEmitter<()> for TestProjectItemView {}
6244
6245    impl FocusableView for TestProjectItemView {
6246        fn focus_handle(&self, _: &AppContext) -> FocusHandle {
6247            self.focus_handle.clone()
6248        }
6249    }
6250
6251    impl Render for TestProjectItemView {
6252        fn render(&mut self, _: &mut ViewContext<Self>) -> impl IntoElement {
6253            Empty
6254        }
6255    }
6256}