project_panel.rs

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