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        cx.executor().run_until_parked();
3398
3399        // "Save as" the buffer, creating a new backing file for it
3400        let save_task = workspace
3401            .update(cx, |workspace, cx| {
3402                workspace.save_active_item(workspace::SaveIntent::Save, cx)
3403            })
3404            .unwrap();
3405
3406        cx.executor().run_until_parked();
3407        cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/new")));
3408        save_task.await.unwrap();
3409
3410        // Rename the file
3411        select_path(&panel, "root/new", cx);
3412        assert_eq!(
3413            visible_entries_as_strings(&panel, 0..10, cx),
3414            &["v root", "      new  <== selected"]
3415        );
3416        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
3417        panel.update(cx, |panel, cx| {
3418            panel
3419                .filename_editor
3420                .update(cx, |editor, cx| editor.set_text("newer", cx));
3421        });
3422        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
3423
3424        cx.executor().run_until_parked();
3425        assert_eq!(
3426            visible_entries_as_strings(&panel, 0..10, cx),
3427            &["v root", "      newer  <== selected"]
3428        );
3429
3430        workspace
3431            .update(cx, |workspace, cx| {
3432                workspace.save_active_item(workspace::SaveIntent::Save, cx)
3433            })
3434            .unwrap()
3435            .await
3436            .unwrap();
3437
3438        cx.executor().run_until_parked();
3439        // assert that saving the file doesn't restore "new"
3440        assert_eq!(
3441            visible_entries_as_strings(&panel, 0..10, cx),
3442            &["v root", "      newer  <== selected"]
3443        );
3444    }
3445
3446    #[gpui::test]
3447    async fn test_autoreveal_and_gitignored_files(cx: &mut gpui::TestAppContext) {
3448        init_test_with_editor(cx);
3449        cx.update(|cx| {
3450            cx.update_global::<SettingsStore, _>(|store, cx| {
3451                store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
3452                    worktree_settings.file_scan_exclusions = Some(Vec::new());
3453                });
3454                store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
3455                    project_panel_settings.auto_reveal_entries = Some(false)
3456                });
3457            })
3458        });
3459
3460        let fs = FakeFs::new(cx.background_executor.clone());
3461        fs.insert_tree(
3462            "/project_root",
3463            json!({
3464                ".git": {},
3465                ".gitignore": "**/gitignored_dir",
3466                "dir_1": {
3467                    "file_1.py": "# File 1_1 contents",
3468                    "file_2.py": "# File 1_2 contents",
3469                    "file_3.py": "# File 1_3 contents",
3470                    "gitignored_dir": {
3471                        "file_a.py": "# File contents",
3472                        "file_b.py": "# File contents",
3473                        "file_c.py": "# File contents",
3474                    },
3475                },
3476                "dir_2": {
3477                    "file_1.py": "# File 2_1 contents",
3478                    "file_2.py": "# File 2_2 contents",
3479                    "file_3.py": "# File 2_3 contents",
3480                }
3481            }),
3482        )
3483        .await;
3484
3485        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3486        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3487        let cx = &mut VisualTestContext::from_window(*workspace, cx);
3488        let panel = workspace
3489            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
3490            .unwrap();
3491
3492        assert_eq!(
3493            visible_entries_as_strings(&panel, 0..20, cx),
3494            &[
3495                "v project_root",
3496                "    > .git",
3497                "    > dir_1",
3498                "    > dir_2",
3499                "      .gitignore",
3500            ]
3501        );
3502
3503        let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
3504            .expect("dir 1 file is not ignored and should have an entry");
3505        let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
3506            .expect("dir 2 file is not ignored and should have an entry");
3507        let gitignored_dir_file =
3508            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
3509        assert_eq!(
3510            gitignored_dir_file, None,
3511            "File in the gitignored dir should not have an entry before its dir is toggled"
3512        );
3513
3514        toggle_expand_dir(&panel, "project_root/dir_1", cx);
3515        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3516        cx.executor().run_until_parked();
3517        assert_eq!(
3518            visible_entries_as_strings(&panel, 0..20, cx),
3519            &[
3520                "v project_root",
3521                "    > .git",
3522                "    v dir_1",
3523                "        v gitignored_dir  <== selected",
3524                "              file_a.py",
3525                "              file_b.py",
3526                "              file_c.py",
3527                "          file_1.py",
3528                "          file_2.py",
3529                "          file_3.py",
3530                "    > dir_2",
3531                "      .gitignore",
3532            ],
3533            "Should show gitignored dir file list in the project panel"
3534        );
3535        let gitignored_dir_file =
3536            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
3537                .expect("after gitignored dir got opened, a file entry should be present");
3538
3539        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3540        toggle_expand_dir(&panel, "project_root/dir_1", cx);
3541        assert_eq!(
3542            visible_entries_as_strings(&panel, 0..20, cx),
3543            &[
3544                "v project_root",
3545                "    > .git",
3546                "    > dir_1  <== selected",
3547                "    > dir_2",
3548                "      .gitignore",
3549            ],
3550            "Should hide all dir contents again and prepare for the auto reveal test"
3551        );
3552
3553        for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
3554            panel.update(cx, |panel, cx| {
3555                panel.project.update(cx, |_, cx| {
3556                    cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
3557                })
3558            });
3559            cx.run_until_parked();
3560            assert_eq!(
3561                visible_entries_as_strings(&panel, 0..20, cx),
3562                &[
3563                    "v project_root",
3564                    "    > .git",
3565                    "    > dir_1  <== selected",
3566                    "    > dir_2",
3567                    "      .gitignore",
3568                ],
3569                "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
3570            );
3571        }
3572
3573        cx.update(|cx| {
3574            cx.update_global::<SettingsStore, _>(|store, cx| {
3575                store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
3576                    project_panel_settings.auto_reveal_entries = Some(true)
3577                });
3578            })
3579        });
3580
3581        panel.update(cx, |panel, cx| {
3582            panel.project.update(cx, |_, cx| {
3583                cx.emit(project::Event::ActiveEntryChanged(Some(dir_1_file)))
3584            })
3585        });
3586        cx.run_until_parked();
3587        assert_eq!(
3588            visible_entries_as_strings(&panel, 0..20, cx),
3589            &[
3590                "v project_root",
3591                "    > .git",
3592                "    v dir_1",
3593                "        > gitignored_dir",
3594                "          file_1.py  <== selected",
3595                "          file_2.py",
3596                "          file_3.py",
3597                "    > dir_2",
3598                "      .gitignore",
3599            ],
3600            "When auto reveal is enabled, not ignored dir_1 entry should be revealed"
3601        );
3602
3603        panel.update(cx, |panel, cx| {
3604            panel.project.update(cx, |_, cx| {
3605                cx.emit(project::Event::ActiveEntryChanged(Some(dir_2_file)))
3606            })
3607        });
3608        cx.run_until_parked();
3609        assert_eq!(
3610            visible_entries_as_strings(&panel, 0..20, cx),
3611            &[
3612                "v project_root",
3613                "    > .git",
3614                "    v dir_1",
3615                "        > gitignored_dir",
3616                "          file_1.py",
3617                "          file_2.py",
3618                "          file_3.py",
3619                "    v dir_2",
3620                "          file_1.py  <== selected",
3621                "          file_2.py",
3622                "          file_3.py",
3623                "      .gitignore",
3624            ],
3625            "When auto reveal is enabled, not ignored dir_2 entry should be revealed"
3626        );
3627
3628        panel.update(cx, |panel, cx| {
3629            panel.project.update(cx, |_, cx| {
3630                cx.emit(project::Event::ActiveEntryChanged(Some(
3631                    gitignored_dir_file,
3632                )))
3633            })
3634        });
3635        cx.run_until_parked();
3636        assert_eq!(
3637            visible_entries_as_strings(&panel, 0..20, cx),
3638            &[
3639                "v project_root",
3640                "    > .git",
3641                "    v dir_1",
3642                "        > gitignored_dir",
3643                "          file_1.py",
3644                "          file_2.py",
3645                "          file_3.py",
3646                "    v dir_2",
3647                "          file_1.py  <== selected",
3648                "          file_2.py",
3649                "          file_3.py",
3650                "      .gitignore",
3651            ],
3652            "When auto reveal is enabled, a gitignored selected entry should not be revealed in the project panel"
3653        );
3654
3655        panel.update(cx, |panel, cx| {
3656            panel.project.update(cx, |_, cx| {
3657                cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
3658            })
3659        });
3660        cx.run_until_parked();
3661        assert_eq!(
3662            visible_entries_as_strings(&panel, 0..20, cx),
3663            &[
3664                "v project_root",
3665                "    > .git",
3666                "    v dir_1",
3667                "        v gitignored_dir",
3668                "              file_a.py  <== selected",
3669                "              file_b.py",
3670                "              file_c.py",
3671                "          file_1.py",
3672                "          file_2.py",
3673                "          file_3.py",
3674                "    v dir_2",
3675                "          file_1.py",
3676                "          file_2.py",
3677                "          file_3.py",
3678                "      .gitignore",
3679            ],
3680            "When a gitignored entry is explicitly revealed, it should be shown in the project tree"
3681        );
3682    }
3683
3684    #[gpui::test]
3685    async fn test_explicit_reveal(cx: &mut gpui::TestAppContext) {
3686        init_test_with_editor(cx);
3687        cx.update(|cx| {
3688            cx.update_global::<SettingsStore, _>(|store, cx| {
3689                store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
3690                    worktree_settings.file_scan_exclusions = Some(Vec::new());
3691                });
3692                store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
3693                    project_panel_settings.auto_reveal_entries = Some(false)
3694                });
3695            })
3696        });
3697
3698        let fs = FakeFs::new(cx.background_executor.clone());
3699        fs.insert_tree(
3700            "/project_root",
3701            json!({
3702                ".git": {},
3703                ".gitignore": "**/gitignored_dir",
3704                "dir_1": {
3705                    "file_1.py": "# File 1_1 contents",
3706                    "file_2.py": "# File 1_2 contents",
3707                    "file_3.py": "# File 1_3 contents",
3708                    "gitignored_dir": {
3709                        "file_a.py": "# File contents",
3710                        "file_b.py": "# File contents",
3711                        "file_c.py": "# File contents",
3712                    },
3713                },
3714                "dir_2": {
3715                    "file_1.py": "# File 2_1 contents",
3716                    "file_2.py": "# File 2_2 contents",
3717                    "file_3.py": "# File 2_3 contents",
3718                }
3719            }),
3720        )
3721        .await;
3722
3723        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3724        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3725        let cx = &mut VisualTestContext::from_window(*workspace, cx);
3726        let panel = workspace
3727            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
3728            .unwrap();
3729
3730        assert_eq!(
3731            visible_entries_as_strings(&panel, 0..20, cx),
3732            &[
3733                "v project_root",
3734                "    > .git",
3735                "    > dir_1",
3736                "    > dir_2",
3737                "      .gitignore",
3738            ]
3739        );
3740
3741        let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
3742            .expect("dir 1 file is not ignored and should have an entry");
3743        let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
3744            .expect("dir 2 file is not ignored and should have an entry");
3745        let gitignored_dir_file =
3746            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
3747        assert_eq!(
3748            gitignored_dir_file, None,
3749            "File in the gitignored dir should not have an entry before its dir is toggled"
3750        );
3751
3752        toggle_expand_dir(&panel, "project_root/dir_1", cx);
3753        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3754        cx.run_until_parked();
3755        assert_eq!(
3756            visible_entries_as_strings(&panel, 0..20, cx),
3757            &[
3758                "v project_root",
3759                "    > .git",
3760                "    v dir_1",
3761                "        v gitignored_dir  <== selected",
3762                "              file_a.py",
3763                "              file_b.py",
3764                "              file_c.py",
3765                "          file_1.py",
3766                "          file_2.py",
3767                "          file_3.py",
3768                "    > dir_2",
3769                "      .gitignore",
3770            ],
3771            "Should show gitignored dir file list in the project panel"
3772        );
3773        let gitignored_dir_file =
3774            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
3775                .expect("after gitignored dir got opened, a file entry should be present");
3776
3777        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3778        toggle_expand_dir(&panel, "project_root/dir_1", cx);
3779        assert_eq!(
3780            visible_entries_as_strings(&panel, 0..20, cx),
3781            &[
3782                "v project_root",
3783                "    > .git",
3784                "    > dir_1  <== selected",
3785                "    > dir_2",
3786                "      .gitignore",
3787            ],
3788            "Should hide all dir contents again and prepare for the explicit reveal test"
3789        );
3790
3791        for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
3792            panel.update(cx, |panel, cx| {
3793                panel.project.update(cx, |_, cx| {
3794                    cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
3795                })
3796            });
3797            cx.run_until_parked();
3798            assert_eq!(
3799                visible_entries_as_strings(&panel, 0..20, cx),
3800                &[
3801                    "v project_root",
3802                    "    > .git",
3803                    "    > dir_1  <== selected",
3804                    "    > dir_2",
3805                    "      .gitignore",
3806                ],
3807                "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
3808            );
3809        }
3810
3811        panel.update(cx, |panel, cx| {
3812            panel.project.update(cx, |_, cx| {
3813                cx.emit(project::Event::RevealInProjectPanel(dir_1_file))
3814            })
3815        });
3816        cx.run_until_parked();
3817        assert_eq!(
3818            visible_entries_as_strings(&panel, 0..20, cx),
3819            &[
3820                "v project_root",
3821                "    > .git",
3822                "    v dir_1",
3823                "        > gitignored_dir",
3824                "          file_1.py  <== selected",
3825                "          file_2.py",
3826                "          file_3.py",
3827                "    > dir_2",
3828                "      .gitignore",
3829            ],
3830            "With no auto reveal, explicit reveal should show the dir_1 entry in the project panel"
3831        );
3832
3833        panel.update(cx, |panel, cx| {
3834            panel.project.update(cx, |_, cx| {
3835                cx.emit(project::Event::RevealInProjectPanel(dir_2_file))
3836            })
3837        });
3838        cx.run_until_parked();
3839        assert_eq!(
3840            visible_entries_as_strings(&panel, 0..20, cx),
3841            &[
3842                "v project_root",
3843                "    > .git",
3844                "    v dir_1",
3845                "        > gitignored_dir",
3846                "          file_1.py",
3847                "          file_2.py",
3848                "          file_3.py",
3849                "    v dir_2",
3850                "          file_1.py  <== selected",
3851                "          file_2.py",
3852                "          file_3.py",
3853                "      .gitignore",
3854            ],
3855            "With no auto reveal, explicit reveal should show the dir_2 entry in the project panel"
3856        );
3857
3858        panel.update(cx, |panel, cx| {
3859            panel.project.update(cx, |_, cx| {
3860                cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
3861            })
3862        });
3863        cx.run_until_parked();
3864        assert_eq!(
3865            visible_entries_as_strings(&panel, 0..20, cx),
3866            &[
3867                "v project_root",
3868                "    > .git",
3869                "    v dir_1",
3870                "        v gitignored_dir",
3871                "              file_a.py  <== selected",
3872                "              file_b.py",
3873                "              file_c.py",
3874                "          file_1.py",
3875                "          file_2.py",
3876                "          file_3.py",
3877                "    v dir_2",
3878                "          file_1.py",
3879                "          file_2.py",
3880                "          file_3.py",
3881                "      .gitignore",
3882            ],
3883            "With no auto reveal, explicit reveal should show the gitignored entry in the project panel"
3884        );
3885    }
3886
3887    fn toggle_expand_dir(
3888        panel: &View<ProjectPanel>,
3889        path: impl AsRef<Path>,
3890        cx: &mut VisualTestContext,
3891    ) {
3892        let path = path.as_ref();
3893        panel.update(cx, |panel, cx| {
3894            for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
3895                let worktree = worktree.read(cx);
3896                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
3897                    let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
3898                    panel.toggle_expanded(entry_id, cx);
3899                    return;
3900                }
3901            }
3902            panic!("no worktree for path {:?}", path);
3903        });
3904    }
3905
3906    fn select_path(panel: &View<ProjectPanel>, path: impl AsRef<Path>, cx: &mut VisualTestContext) {
3907        let path = path.as_ref();
3908        panel.update(cx, |panel, cx| {
3909            for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
3910                let worktree = worktree.read(cx);
3911                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
3912                    let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
3913                    panel.selection = Some(crate::Selection {
3914                        worktree_id: worktree.id(),
3915                        entry_id,
3916                    });
3917                    return;
3918                }
3919            }
3920            panic!("no worktree for path {:?}", path);
3921        });
3922    }
3923
3924    fn find_project_entry(
3925        panel: &View<ProjectPanel>,
3926        path: impl AsRef<Path>,
3927        cx: &mut VisualTestContext,
3928    ) -> Option<ProjectEntryId> {
3929        let path = path.as_ref();
3930        panel.update(cx, |panel, cx| {
3931            for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
3932                let worktree = worktree.read(cx);
3933                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
3934                    return worktree.entry_for_path(relative_path).map(|entry| entry.id);
3935                }
3936            }
3937            panic!("no worktree for path {path:?}");
3938        })
3939    }
3940
3941    fn visible_entries_as_strings(
3942        panel: &View<ProjectPanel>,
3943        range: Range<usize>,
3944        cx: &mut VisualTestContext,
3945    ) -> Vec<String> {
3946        let mut result = Vec::new();
3947        let mut project_entries = HashSet::default();
3948        let mut has_editor = false;
3949
3950        panel.update(cx, |panel, cx| {
3951            panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
3952                if details.is_editing {
3953                    assert!(!has_editor, "duplicate editor entry");
3954                    has_editor = true;
3955                } else {
3956                    assert!(
3957                        project_entries.insert(project_entry),
3958                        "duplicate project entry {:?} {:?}",
3959                        project_entry,
3960                        details
3961                    );
3962                }
3963
3964                let indent = "    ".repeat(details.depth);
3965                let icon = if details.kind.is_dir() {
3966                    if details.is_expanded {
3967                        "v "
3968                    } else {
3969                        "> "
3970                    }
3971                } else {
3972                    "  "
3973                };
3974                let name = if details.is_editing {
3975                    format!("[EDITOR: '{}']", details.filename)
3976                } else if details.is_processing {
3977                    format!("[PROCESSING: '{}']", details.filename)
3978                } else {
3979                    details.filename.clone()
3980                };
3981                let selected = if details.is_selected {
3982                    "  <== selected"
3983                } else {
3984                    ""
3985                };
3986                result.push(format!("{indent}{icon}{name}{selected}"));
3987            });
3988        });
3989
3990        result
3991    }
3992
3993    fn init_test(cx: &mut TestAppContext) {
3994        cx.update(|cx| {
3995            let settings_store = SettingsStore::test(cx);
3996            cx.set_global(settings_store);
3997            init_settings(cx);
3998            theme::init(theme::LoadThemes::JustBase, cx);
3999            language::init(cx);
4000            editor::init_settings(cx);
4001            crate::init((), cx);
4002            workspace::init_settings(cx);
4003            client::init_settings(cx);
4004            Project::init_settings(cx);
4005
4006            cx.update_global::<SettingsStore, _>(|store, cx| {
4007                store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
4008                    worktree_settings.file_scan_exclusions = Some(Vec::new());
4009                });
4010            });
4011        });
4012    }
4013
4014    fn init_test_with_editor(cx: &mut TestAppContext) {
4015        cx.update(|cx| {
4016            let app_state = AppState::test(cx);
4017            theme::init(theme::LoadThemes::JustBase, cx);
4018            init_settings(cx);
4019            language::init(cx);
4020            editor::init(cx);
4021            crate::init((), cx);
4022            workspace::init(app_state.clone(), cx);
4023            Project::init_settings(cx);
4024        });
4025    }
4026
4027    fn ensure_single_file_is_opened(
4028        window: &WindowHandle<Workspace>,
4029        expected_path: &str,
4030        cx: &mut TestAppContext,
4031    ) {
4032        window
4033            .update(cx, |workspace, cx| {
4034                let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
4035                assert_eq!(worktrees.len(), 1);
4036                let worktree_id = worktrees[0].read(cx).id();
4037
4038                let open_project_paths = workspace
4039                    .panes()
4040                    .iter()
4041                    .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
4042                    .collect::<Vec<_>>();
4043                assert_eq!(
4044                    open_project_paths,
4045                    vec![ProjectPath {
4046                        worktree_id,
4047                        path: Arc::from(Path::new(expected_path))
4048                    }],
4049                    "Should have opened file, selected in project panel"
4050                );
4051            })
4052            .unwrap();
4053    }
4054
4055    fn submit_deletion(panel: &View<ProjectPanel>, cx: &mut VisualTestContext) {
4056        assert!(
4057            !cx.has_pending_prompt(),
4058            "Should have no prompts before the deletion"
4059        );
4060        panel.update(cx, |panel, cx| {
4061            panel.delete(&Delete { skip_prompt: false }, cx)
4062        });
4063        assert!(
4064            cx.has_pending_prompt(),
4065            "Should have a prompt after the deletion"
4066        );
4067        cx.simulate_prompt_answer(0);
4068        assert!(
4069            !cx.has_pending_prompt(),
4070            "Should have no prompts after prompt was replied to"
4071        );
4072        cx.executor().run_until_parked();
4073    }
4074
4075    fn submit_deletion_skipping_prompt(panel: &View<ProjectPanel>, cx: &mut VisualTestContext) {
4076        assert!(
4077            !cx.has_pending_prompt(),
4078            "Should have no prompts before the deletion"
4079        );
4080        panel.update(cx, |panel, cx| {
4081            panel.delete(&Delete { skip_prompt: true }, cx)
4082        });
4083        assert!(!cx.has_pending_prompt(), "Should have received no prompts");
4084        cx.executor().run_until_parked();
4085    }
4086
4087    fn ensure_no_open_items_and_panes(
4088        workspace: &WindowHandle<Workspace>,
4089        cx: &mut VisualTestContext,
4090    ) {
4091        assert!(
4092            !cx.has_pending_prompt(),
4093            "Should have no prompts after deletion operation closes the file"
4094        );
4095        workspace
4096            .read_with(cx, |workspace, cx| {
4097                let open_project_paths = workspace
4098                    .panes()
4099                    .iter()
4100                    .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
4101                    .collect::<Vec<_>>();
4102                assert!(
4103                    open_project_paths.is_empty(),
4104                    "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
4105                );
4106            })
4107            .unwrap();
4108    }
4109}