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