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