project_panel.rs

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