project_panel.rs

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