project_panel.rs

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