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, Icon, 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(Icon::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                            // Stop propagation to prevent the catch-all context menu for the project
1437                            // panel from being deployed.
1438                            cx.stop_propagation();
1439                            this.deploy_context_menu(event.position, entry_id, cx);
1440                        },
1441                    )),
1442            )
1443    }
1444
1445    fn dispatch_context(&self, cx: &ViewContext<Self>) -> KeyContext {
1446        let mut dispatch_context = KeyContext::default();
1447        dispatch_context.add("ProjectPanel");
1448        dispatch_context.add("menu");
1449
1450        let identifier = if self.filename_editor.focus_handle(cx).is_focused(cx) {
1451            "editing"
1452        } else {
1453            "not_editing"
1454        };
1455
1456        dispatch_context.add(identifier);
1457        dispatch_context
1458    }
1459
1460    fn reveal_entry(
1461        &mut self,
1462        project: Model<Project>,
1463        entry_id: ProjectEntryId,
1464        skip_ignored: bool,
1465        cx: &mut ViewContext<'_, ProjectPanel>,
1466    ) {
1467        if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
1468            let worktree = worktree.read(cx);
1469            if skip_ignored
1470                && worktree
1471                    .entry_for_id(entry_id)
1472                    .map_or(true, |entry| entry.is_ignored)
1473            {
1474                return;
1475            }
1476
1477            let worktree_id = worktree.id();
1478            self.expand_entry(worktree_id, entry_id, cx);
1479            self.update_visible_entries(Some((worktree_id, entry_id)), cx);
1480            self.autoscroll(cx);
1481            cx.notify();
1482        }
1483    }
1484}
1485
1486impl Render for ProjectPanel {
1487    fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> impl IntoElement {
1488        let has_worktree = self.visible_entries.len() != 0;
1489        let project = self.project.read(cx);
1490
1491        if has_worktree {
1492            div()
1493                .id("project-panel")
1494                .size_full()
1495                .relative()
1496                .key_context(self.dispatch_context(cx))
1497                .on_action(cx.listener(Self::select_next))
1498                .on_action(cx.listener(Self::select_prev))
1499                .on_action(cx.listener(Self::expand_selected_entry))
1500                .on_action(cx.listener(Self::collapse_selected_entry))
1501                .on_action(cx.listener(Self::collapse_all_entries))
1502                .on_action(cx.listener(Self::open_file))
1503                .on_action(cx.listener(Self::confirm))
1504                .on_action(cx.listener(Self::cancel))
1505                .on_action(cx.listener(Self::copy_path))
1506                .on_action(cx.listener(Self::copy_relative_path))
1507                .on_action(cx.listener(Self::new_search_in_directory))
1508                .when(!project.is_read_only(), |el| {
1509                    el.on_action(cx.listener(Self::new_file))
1510                        .on_action(cx.listener(Self::new_directory))
1511                        .on_action(cx.listener(Self::rename))
1512                        .on_action(cx.listener(Self::delete))
1513                        .on_action(cx.listener(Self::cut))
1514                        .on_action(cx.listener(Self::copy))
1515                        .on_action(cx.listener(Self::paste))
1516                })
1517                .when(project.is_local(), |el| {
1518                    el.on_action(cx.listener(Self::reveal_in_finder))
1519                        .on_action(cx.listener(Self::open_in_terminal))
1520                })
1521                .on_mouse_down(
1522                    MouseButton::Right,
1523                    cx.listener(move |this, event: &MouseDownEvent, cx| {
1524                        // When deploying the context menu anywhere below the last project entry,
1525                        // act as if the user clicked the root of the last worktree.
1526                        if let Some(entry_id) = this.last_worktree_root_id {
1527                            this.deploy_context_menu(event.position, entry_id, cx);
1528                        }
1529                    }),
1530                )
1531                .track_focus(&self.focus_handle)
1532                .child(
1533                    uniform_list(
1534                        cx.view().clone(),
1535                        "entries",
1536                        self.visible_entries
1537                            .iter()
1538                            .map(|(_, worktree_entries)| worktree_entries.len())
1539                            .sum(),
1540                        {
1541                            |this, range, cx| {
1542                                let mut items = Vec::new();
1543                                this.for_each_visible_entry(range, cx, |id, details, cx| {
1544                                    items.push(this.render_entry(id, details, cx));
1545                                });
1546                                items
1547                            }
1548                        },
1549                    )
1550                    .size_full()
1551                    .track_scroll(self.list.clone()),
1552                )
1553                .children(self.context_menu.as_ref().map(|(menu, position, _)| {
1554                    overlay()
1555                        .position(*position)
1556                        .anchor(gpui::AnchorCorner::TopLeft)
1557                        .child(menu.clone())
1558                }))
1559        } else {
1560            v_stack()
1561                .id("empty-project_panel")
1562                .size_full()
1563                .p_4()
1564                .track_focus(&self.focus_handle)
1565                .child(
1566                    Button::new("open_project", "Open a project")
1567                        .style(ButtonStyle::Filled)
1568                        .full_width()
1569                        .key_binding(KeyBinding::for_action(&workspace::Open, cx))
1570                        .on_click(cx.listener(|this, _, cx| {
1571                            this.workspace
1572                                .update(cx, |workspace, cx| workspace.open(&workspace::Open, cx))
1573                                .log_err();
1574                        })),
1575                )
1576        }
1577    }
1578}
1579
1580impl Render for DraggedProjectEntryView {
1581    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl Element {
1582        let settings = ProjectPanelSettings::get_global(cx);
1583        let ui_font = ThemeSettings::get_global(cx).ui_font.family.clone();
1584        h_stack()
1585            .font(ui_font)
1586            .bg(cx.theme().colors().background)
1587            .w(self.width)
1588            .child(
1589                ListItem::new(self.entry_id.to_proto() as usize)
1590                    .indent_level(self.details.depth)
1591                    .indent_step_size(px(settings.indent_size))
1592                    .child(if let Some(icon) = &self.details.icon {
1593                        div().child(Icon::from_path(icon.to_string()))
1594                    } else {
1595                        div()
1596                    })
1597                    .child(Label::new(self.details.filename.clone())),
1598            )
1599    }
1600}
1601
1602impl EventEmitter<Event> for ProjectPanel {}
1603
1604impl EventEmitter<PanelEvent> for ProjectPanel {}
1605
1606impl Panel for ProjectPanel {
1607    fn position(&self, cx: &WindowContext) -> DockPosition {
1608        match ProjectPanelSettings::get_global(cx).dock {
1609            ProjectPanelDockPosition::Left => DockPosition::Left,
1610            ProjectPanelDockPosition::Right => DockPosition::Right,
1611        }
1612    }
1613
1614    fn position_is_valid(&self, position: DockPosition) -> bool {
1615        matches!(position, DockPosition::Left | DockPosition::Right)
1616    }
1617
1618    fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
1619        settings::update_settings_file::<ProjectPanelSettings>(
1620            self.fs.clone(),
1621            cx,
1622            move |settings| {
1623                let dock = match position {
1624                    DockPosition::Left | DockPosition::Bottom => ProjectPanelDockPosition::Left,
1625                    DockPosition::Right => ProjectPanelDockPosition::Right,
1626                };
1627                settings.dock = Some(dock);
1628            },
1629        );
1630    }
1631
1632    fn size(&self, cx: &WindowContext) -> Pixels {
1633        self.width
1634            .unwrap_or_else(|| ProjectPanelSettings::get_global(cx).default_width)
1635    }
1636
1637    fn set_size(&mut self, size: Option<Pixels>, cx: &mut ViewContext<Self>) {
1638        self.width = size;
1639        self.serialize(cx);
1640        cx.notify();
1641    }
1642
1643    fn icon(&self, _: &WindowContext) -> Option<ui::IconName> {
1644        Some(ui::IconName::FileTree)
1645    }
1646
1647    fn icon_tooltip(&self, _cx: &WindowContext) -> Option<&'static str> {
1648        Some("Project Panel")
1649    }
1650
1651    fn toggle_action(&self) -> Box<dyn Action> {
1652        Box::new(ToggleFocus)
1653    }
1654
1655    fn persistent_name() -> &'static str {
1656        "Project Panel"
1657    }
1658}
1659
1660impl FocusableView for ProjectPanel {
1661    fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
1662        self.focus_handle.clone()
1663    }
1664}
1665
1666impl ClipboardEntry {
1667    fn is_cut(&self) -> bool {
1668        matches!(self, Self::Cut { .. })
1669    }
1670
1671    fn entry_id(&self) -> ProjectEntryId {
1672        match self {
1673            ClipboardEntry::Copied { entry_id, .. } | ClipboardEntry::Cut { entry_id, .. } => {
1674                *entry_id
1675            }
1676        }
1677    }
1678
1679    fn worktree_id(&self) -> WorktreeId {
1680        match self {
1681            ClipboardEntry::Copied { worktree_id, .. }
1682            | ClipboardEntry::Cut { worktree_id, .. } => *worktree_id,
1683        }
1684    }
1685}
1686
1687#[cfg(test)]
1688mod tests {
1689    use super::*;
1690    use gpui::{TestAppContext, View, VisualTestContext, WindowHandle};
1691    use pretty_assertions::assert_eq;
1692    use project::{project_settings::ProjectSettings, FakeFs};
1693    use serde_json::json;
1694    use settings::SettingsStore;
1695    use std::{
1696        collections::HashSet,
1697        path::{Path, PathBuf},
1698    };
1699    use workspace::AppState;
1700
1701    #[gpui::test]
1702    async fn test_visible_list(cx: &mut gpui::TestAppContext) {
1703        init_test(cx);
1704
1705        let fs = FakeFs::new(cx.executor().clone());
1706        fs.insert_tree(
1707            "/root1",
1708            json!({
1709                ".dockerignore": "",
1710                ".git": {
1711                    "HEAD": "",
1712                },
1713                "a": {
1714                    "0": { "q": "", "r": "", "s": "" },
1715                    "1": { "t": "", "u": "" },
1716                    "2": { "v": "", "w": "", "x": "", "y": "" },
1717                },
1718                "b": {
1719                    "3": { "Q": "" },
1720                    "4": { "R": "", "S": "", "T": "", "U": "" },
1721                },
1722                "C": {
1723                    "5": {},
1724                    "6": { "V": "", "W": "" },
1725                    "7": { "X": "" },
1726                    "8": { "Y": {}, "Z": "" }
1727                }
1728            }),
1729        )
1730        .await;
1731        fs.insert_tree(
1732            "/root2",
1733            json!({
1734                "d": {
1735                    "9": ""
1736                },
1737                "e": {}
1738            }),
1739        )
1740        .await;
1741
1742        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1743        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1744        let cx = &mut VisualTestContext::from_window(*workspace, cx);
1745        let panel = workspace
1746            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
1747            .unwrap();
1748        assert_eq!(
1749            visible_entries_as_strings(&panel, 0..50, cx),
1750            &[
1751                "v root1",
1752                "    > .git",
1753                "    > a",
1754                "    > b",
1755                "    > C",
1756                "      .dockerignore",
1757                "v root2",
1758                "    > d",
1759                "    > e",
1760            ]
1761        );
1762
1763        toggle_expand_dir(&panel, "root1/b", cx);
1764        assert_eq!(
1765            visible_entries_as_strings(&panel, 0..50, cx),
1766            &[
1767                "v root1",
1768                "    > .git",
1769                "    > a",
1770                "    v b  <== selected",
1771                "        > 3",
1772                "        > 4",
1773                "    > C",
1774                "      .dockerignore",
1775                "v root2",
1776                "    > d",
1777                "    > e",
1778            ]
1779        );
1780
1781        assert_eq!(
1782            visible_entries_as_strings(&panel, 6..9, cx),
1783            &[
1784                //
1785                "    > C",
1786                "      .dockerignore",
1787                "v root2",
1788            ]
1789        );
1790    }
1791
1792    #[gpui::test]
1793    async fn test_exclusions_in_visible_list(cx: &mut gpui::TestAppContext) {
1794        init_test(cx);
1795        cx.update(|cx| {
1796            cx.update_global::<SettingsStore, _>(|store, cx| {
1797                store.update_user_settings::<ProjectSettings>(cx, |project_settings| {
1798                    project_settings.file_scan_exclusions =
1799                        Some(vec!["**/.git".to_string(), "**/4/**".to_string()]);
1800                });
1801            });
1802        });
1803
1804        let fs = FakeFs::new(cx.background_executor.clone());
1805        fs.insert_tree(
1806            "/root1",
1807            json!({
1808                ".dockerignore": "",
1809                ".git": {
1810                    "HEAD": "",
1811                },
1812                "a": {
1813                    "0": { "q": "", "r": "", "s": "" },
1814                    "1": { "t": "", "u": "" },
1815                    "2": { "v": "", "w": "", "x": "", "y": "" },
1816                },
1817                "b": {
1818                    "3": { "Q": "" },
1819                    "4": { "R": "", "S": "", "T": "", "U": "" },
1820                },
1821                "C": {
1822                    "5": {},
1823                    "6": { "V": "", "W": "" },
1824                    "7": { "X": "" },
1825                    "8": { "Y": {}, "Z": "" }
1826                }
1827            }),
1828        )
1829        .await;
1830        fs.insert_tree(
1831            "/root2",
1832            json!({
1833                "d": {
1834                    "4": ""
1835                },
1836                "e": {}
1837            }),
1838        )
1839        .await;
1840
1841        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1842        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1843        let cx = &mut VisualTestContext::from_window(*workspace, cx);
1844        let panel = workspace
1845            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
1846            .unwrap();
1847        assert_eq!(
1848            visible_entries_as_strings(&panel, 0..50, cx),
1849            &[
1850                "v root1",
1851                "    > a",
1852                "    > b",
1853                "    > C",
1854                "      .dockerignore",
1855                "v root2",
1856                "    > d",
1857                "    > e",
1858            ]
1859        );
1860
1861        toggle_expand_dir(&panel, "root1/b", cx);
1862        assert_eq!(
1863            visible_entries_as_strings(&panel, 0..50, cx),
1864            &[
1865                "v root1",
1866                "    > a",
1867                "    v b  <== selected",
1868                "        > 3",
1869                "    > C",
1870                "      .dockerignore",
1871                "v root2",
1872                "    > d",
1873                "    > e",
1874            ]
1875        );
1876
1877        toggle_expand_dir(&panel, "root2/d", cx);
1878        assert_eq!(
1879            visible_entries_as_strings(&panel, 0..50, cx),
1880            &[
1881                "v root1",
1882                "    > a",
1883                "    v b",
1884                "        > 3",
1885                "    > C",
1886                "      .dockerignore",
1887                "v root2",
1888                "    v d  <== selected",
1889                "    > e",
1890            ]
1891        );
1892
1893        toggle_expand_dir(&panel, "root2/e", cx);
1894        assert_eq!(
1895            visible_entries_as_strings(&panel, 0..50, cx),
1896            &[
1897                "v root1",
1898                "    > a",
1899                "    v b",
1900                "        > 3",
1901                "    > C",
1902                "      .dockerignore",
1903                "v root2",
1904                "    v d",
1905                "    v e  <== selected",
1906            ]
1907        );
1908    }
1909
1910    #[gpui::test(iterations = 30)]
1911    async fn test_editing_files(cx: &mut gpui::TestAppContext) {
1912        init_test(cx);
1913
1914        let fs = FakeFs::new(cx.executor().clone());
1915        fs.insert_tree(
1916            "/root1",
1917            json!({
1918                ".dockerignore": "",
1919                ".git": {
1920                    "HEAD": "",
1921                },
1922                "a": {
1923                    "0": { "q": "", "r": "", "s": "" },
1924                    "1": { "t": "", "u": "" },
1925                    "2": { "v": "", "w": "", "x": "", "y": "" },
1926                },
1927                "b": {
1928                    "3": { "Q": "" },
1929                    "4": { "R": "", "S": "", "T": "", "U": "" },
1930                },
1931                "C": {
1932                    "5": {},
1933                    "6": { "V": "", "W": "" },
1934                    "7": { "X": "" },
1935                    "8": { "Y": {}, "Z": "" }
1936                }
1937            }),
1938        )
1939        .await;
1940        fs.insert_tree(
1941            "/root2",
1942            json!({
1943                "d": {
1944                    "9": ""
1945                },
1946                "e": {}
1947            }),
1948        )
1949        .await;
1950
1951        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1952        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1953        let cx = &mut VisualTestContext::from_window(*workspace, cx);
1954        let panel = workspace
1955            .update(cx, |workspace, cx| {
1956                let panel = ProjectPanel::new(workspace, cx);
1957                workspace.add_panel(panel.clone(), cx);
1958                workspace.toggle_dock(panel.read(cx).position(cx), cx);
1959                panel
1960            })
1961            .unwrap();
1962
1963        select_path(&panel, "root1", cx);
1964        assert_eq!(
1965            visible_entries_as_strings(&panel, 0..10, cx),
1966            &[
1967                "v root1  <== selected",
1968                "    > .git",
1969                "    > a",
1970                "    > b",
1971                "    > C",
1972                "      .dockerignore",
1973                "v root2",
1974                "    > d",
1975                "    > e",
1976            ]
1977        );
1978
1979        // Add a file with the root folder selected. The filename editor is placed
1980        // before the first file in the root folder.
1981        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
1982        panel.update(cx, |panel, cx| {
1983            assert!(panel.filename_editor.read(cx).is_focused(cx));
1984        });
1985        assert_eq!(
1986            visible_entries_as_strings(&panel, 0..10, cx),
1987            &[
1988                "v root1",
1989                "    > .git",
1990                "    > a",
1991                "    > b",
1992                "    > C",
1993                "      [EDITOR: '']  <== selected",
1994                "      .dockerignore",
1995                "v root2",
1996                "    > d",
1997                "    > e",
1998            ]
1999        );
2000
2001        let confirm = panel.update(cx, |panel, cx| {
2002            panel
2003                .filename_editor
2004                .update(cx, |editor, cx| editor.set_text("the-new-filename", cx));
2005            panel.confirm_edit(cx).unwrap()
2006        });
2007        assert_eq!(
2008            visible_entries_as_strings(&panel, 0..10, cx),
2009            &[
2010                "v root1",
2011                "    > .git",
2012                "    > a",
2013                "    > b",
2014                "    > C",
2015                "      [PROCESSING: 'the-new-filename']  <== selected",
2016                "      .dockerignore",
2017                "v root2",
2018                "    > d",
2019                "    > e",
2020            ]
2021        );
2022
2023        confirm.await.unwrap();
2024        assert_eq!(
2025            visible_entries_as_strings(&panel, 0..10, cx),
2026            &[
2027                "v root1",
2028                "    > .git",
2029                "    > a",
2030                "    > b",
2031                "    > C",
2032                "      .dockerignore",
2033                "      the-new-filename  <== selected",
2034                "v root2",
2035                "    > d",
2036                "    > e",
2037            ]
2038        );
2039
2040        select_path(&panel, "root1/b", cx);
2041        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2042        assert_eq!(
2043            visible_entries_as_strings(&panel, 0..10, cx),
2044            &[
2045                "v root1",
2046                "    > .git",
2047                "    > a",
2048                "    v b",
2049                "        > 3",
2050                "        > 4",
2051                "          [EDITOR: '']  <== selected",
2052                "    > C",
2053                "      .dockerignore",
2054                "      the-new-filename",
2055            ]
2056        );
2057
2058        panel
2059            .update(cx, |panel, cx| {
2060                panel
2061                    .filename_editor
2062                    .update(cx, |editor, cx| editor.set_text("another-filename.txt", cx));
2063                panel.confirm_edit(cx).unwrap()
2064            })
2065            .await
2066            .unwrap();
2067        assert_eq!(
2068            visible_entries_as_strings(&panel, 0..10, cx),
2069            &[
2070                "v root1",
2071                "    > .git",
2072                "    > a",
2073                "    v b",
2074                "        > 3",
2075                "        > 4",
2076                "          another-filename.txt  <== selected",
2077                "    > C",
2078                "      .dockerignore",
2079                "      the-new-filename",
2080            ]
2081        );
2082
2083        select_path(&panel, "root1/b/another-filename.txt", cx);
2084        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2085        assert_eq!(
2086            visible_entries_as_strings(&panel, 0..10, cx),
2087            &[
2088                "v root1",
2089                "    > .git",
2090                "    > a",
2091                "    v b",
2092                "        > 3",
2093                "        > 4",
2094                "          [EDITOR: 'another-filename.txt']  <== selected",
2095                "    > C",
2096                "      .dockerignore",
2097                "      the-new-filename",
2098            ]
2099        );
2100
2101        let confirm = panel.update(cx, |panel, cx| {
2102            panel.filename_editor.update(cx, |editor, cx| {
2103                let file_name_selections = editor.selections.all::<usize>(cx);
2104                assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
2105                let file_name_selection = &file_name_selections[0];
2106                assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
2107                assert_eq!(file_name_selection.end, "another-filename".len(), "Should not select file extension");
2108
2109                editor.set_text("a-different-filename.tar.gz", cx)
2110            });
2111            panel.confirm_edit(cx).unwrap()
2112        });
2113        assert_eq!(
2114            visible_entries_as_strings(&panel, 0..10, cx),
2115            &[
2116                "v root1",
2117                "    > .git",
2118                "    > a",
2119                "    v b",
2120                "        > 3",
2121                "        > 4",
2122                "          [PROCESSING: 'a-different-filename.tar.gz']  <== selected",
2123                "    > C",
2124                "      .dockerignore",
2125                "      the-new-filename",
2126            ]
2127        );
2128
2129        confirm.await.unwrap();
2130        assert_eq!(
2131            visible_entries_as_strings(&panel, 0..10, cx),
2132            &[
2133                "v root1",
2134                "    > .git",
2135                "    > a",
2136                "    v b",
2137                "        > 3",
2138                "        > 4",
2139                "          a-different-filename.tar.gz  <== selected",
2140                "    > C",
2141                "      .dockerignore",
2142                "      the-new-filename",
2143            ]
2144        );
2145
2146        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2147        assert_eq!(
2148            visible_entries_as_strings(&panel, 0..10, cx),
2149            &[
2150                "v root1",
2151                "    > .git",
2152                "    > a",
2153                "    v b",
2154                "        > 3",
2155                "        > 4",
2156                "          [EDITOR: 'a-different-filename.tar.gz']  <== selected",
2157                "    > C",
2158                "      .dockerignore",
2159                "      the-new-filename",
2160            ]
2161        );
2162
2163        panel.update(cx, |panel, cx| {
2164            panel.filename_editor.update(cx, |editor, cx| {
2165                let file_name_selections = editor.selections.all::<usize>(cx);
2166                assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
2167                let file_name_selection = &file_name_selections[0];
2168                assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
2169                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..");
2170
2171            });
2172            panel.cancel(&Cancel, cx)
2173        });
2174
2175        panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2176        assert_eq!(
2177            visible_entries_as_strings(&panel, 0..10, cx),
2178            &[
2179                "v root1",
2180                "    > .git",
2181                "    > a",
2182                "    v b",
2183                "        > [EDITOR: '']  <== selected",
2184                "        > 3",
2185                "        > 4",
2186                "          a-different-filename.tar.gz",
2187                "    > C",
2188                "      .dockerignore",
2189            ]
2190        );
2191
2192        let confirm = panel.update(cx, |panel, cx| {
2193            panel
2194                .filename_editor
2195                .update(cx, |editor, cx| editor.set_text("new-dir", cx));
2196            panel.confirm_edit(cx).unwrap()
2197        });
2198        panel.update(cx, |panel, cx| panel.select_next(&Default::default(), cx));
2199        assert_eq!(
2200            visible_entries_as_strings(&panel, 0..10, cx),
2201            &[
2202                "v root1",
2203                "    > .git",
2204                "    > a",
2205                "    v b",
2206                "        > [PROCESSING: 'new-dir']",
2207                "        > 3  <== selected",
2208                "        > 4",
2209                "          a-different-filename.tar.gz",
2210                "    > C",
2211                "      .dockerignore",
2212            ]
2213        );
2214
2215        confirm.await.unwrap();
2216        assert_eq!(
2217            visible_entries_as_strings(&panel, 0..10, cx),
2218            &[
2219                "v root1",
2220                "    > .git",
2221                "    > a",
2222                "    v b",
2223                "        > 3  <== selected",
2224                "        > 4",
2225                "        > new-dir",
2226                "          a-different-filename.tar.gz",
2227                "    > C",
2228                "      .dockerignore",
2229            ]
2230        );
2231
2232        panel.update(cx, |panel, cx| panel.rename(&Default::default(), cx));
2233        assert_eq!(
2234            visible_entries_as_strings(&panel, 0..10, cx),
2235            &[
2236                "v root1",
2237                "    > .git",
2238                "    > a",
2239                "    v b",
2240                "        > [EDITOR: '3']  <== selected",
2241                "        > 4",
2242                "        > new-dir",
2243                "          a-different-filename.tar.gz",
2244                "    > C",
2245                "      .dockerignore",
2246            ]
2247        );
2248
2249        // Dismiss the rename editor when it loses focus.
2250        workspace.update(cx, |_, cx| cx.blur()).unwrap();
2251        assert_eq!(
2252            visible_entries_as_strings(&panel, 0..10, cx),
2253            &[
2254                "v root1",
2255                "    > .git",
2256                "    > a",
2257                "    v b",
2258                "        > 3  <== selected",
2259                "        > 4",
2260                "        > new-dir",
2261                "          a-different-filename.tar.gz",
2262                "    > C",
2263                "      .dockerignore",
2264            ]
2265        );
2266    }
2267
2268    #[gpui::test(iterations = 10)]
2269    async fn test_adding_directories_via_file(cx: &mut gpui::TestAppContext) {
2270        init_test(cx);
2271
2272        let fs = FakeFs::new(cx.executor().clone());
2273        fs.insert_tree(
2274            "/root1",
2275            json!({
2276                ".dockerignore": "",
2277                ".git": {
2278                    "HEAD": "",
2279                },
2280                "a": {
2281                    "0": { "q": "", "r": "", "s": "" },
2282                    "1": { "t": "", "u": "" },
2283                    "2": { "v": "", "w": "", "x": "", "y": "" },
2284                },
2285                "b": {
2286                    "3": { "Q": "" },
2287                    "4": { "R": "", "S": "", "T": "", "U": "" },
2288                },
2289                "C": {
2290                    "5": {},
2291                    "6": { "V": "", "W": "" },
2292                    "7": { "X": "" },
2293                    "8": { "Y": {}, "Z": "" }
2294                }
2295            }),
2296        )
2297        .await;
2298        fs.insert_tree(
2299            "/root2",
2300            json!({
2301                "d": {
2302                    "9": ""
2303                },
2304                "e": {}
2305            }),
2306        )
2307        .await;
2308
2309        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
2310        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2311        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2312        let panel = workspace
2313            .update(cx, |workspace, cx| {
2314                let panel = ProjectPanel::new(workspace, cx);
2315                workspace.add_panel(panel.clone(), cx);
2316                workspace.toggle_dock(panel.read(cx).position(cx), cx);
2317                panel
2318            })
2319            .unwrap();
2320
2321        select_path(&panel, "root1", cx);
2322        assert_eq!(
2323            visible_entries_as_strings(&panel, 0..10, cx),
2324            &[
2325                "v root1  <== selected",
2326                "    > .git",
2327                "    > a",
2328                "    > b",
2329                "    > C",
2330                "      .dockerignore",
2331                "v root2",
2332                "    > d",
2333                "    > e",
2334            ]
2335        );
2336
2337        // Add a file with the root folder selected. The filename editor is placed
2338        // before the first file in the root folder.
2339        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2340        panel.update(cx, |panel, cx| {
2341            assert!(panel.filename_editor.read(cx).is_focused(cx));
2342        });
2343        assert_eq!(
2344            visible_entries_as_strings(&panel, 0..10, cx),
2345            &[
2346                "v root1",
2347                "    > .git",
2348                "    > a",
2349                "    > b",
2350                "    > C",
2351                "      [EDITOR: '']  <== selected",
2352                "      .dockerignore",
2353                "v root2",
2354                "    > d",
2355                "    > e",
2356            ]
2357        );
2358
2359        let confirm = panel.update(cx, |panel, cx| {
2360            panel.filename_editor.update(cx, |editor, cx| {
2361                editor.set_text("/bdir1/dir2/the-new-filename", cx)
2362            });
2363            panel.confirm_edit(cx).unwrap()
2364        });
2365
2366        assert_eq!(
2367            visible_entries_as_strings(&panel, 0..10, cx),
2368            &[
2369                "v root1",
2370                "    > .git",
2371                "    > a",
2372                "    > b",
2373                "    > C",
2374                "      [PROCESSING: '/bdir1/dir2/the-new-filename']  <== selected",
2375                "      .dockerignore",
2376                "v root2",
2377                "    > d",
2378                "    > e",
2379            ]
2380        );
2381
2382        confirm.await.unwrap();
2383        assert_eq!(
2384            visible_entries_as_strings(&panel, 0..13, cx),
2385            &[
2386                "v root1",
2387                "    > .git",
2388                "    > a",
2389                "    > b",
2390                "    v bdir1",
2391                "        v dir2",
2392                "              the-new-filename  <== selected",
2393                "    > C",
2394                "      .dockerignore",
2395                "v root2",
2396                "    > d",
2397                "    > e",
2398            ]
2399        );
2400    }
2401
2402    #[gpui::test]
2403    async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
2404        init_test(cx);
2405
2406        let fs = FakeFs::new(cx.executor().clone());
2407        fs.insert_tree(
2408            "/root1",
2409            json!({
2410                "one.two.txt": "",
2411                "one.txt": ""
2412            }),
2413        )
2414        .await;
2415
2416        let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
2417        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2418        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2419        let panel = workspace
2420            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2421            .unwrap();
2422
2423        panel.update(cx, |panel, cx| {
2424            panel.select_next(&Default::default(), cx);
2425            panel.select_next(&Default::default(), cx);
2426        });
2427
2428        assert_eq!(
2429            visible_entries_as_strings(&panel, 0..50, cx),
2430            &[
2431                //
2432                "v root1",
2433                "      one.two.txt  <== selected",
2434                "      one.txt",
2435            ]
2436        );
2437
2438        // Regression test - file name is created correctly when
2439        // the copied file's name contains multiple dots.
2440        panel.update(cx, |panel, cx| {
2441            panel.copy(&Default::default(), cx);
2442            panel.paste(&Default::default(), cx);
2443        });
2444        cx.executor().run_until_parked();
2445
2446        assert_eq!(
2447            visible_entries_as_strings(&panel, 0..50, cx),
2448            &[
2449                //
2450                "v root1",
2451                "      one.two copy.txt",
2452                "      one.two.txt  <== selected",
2453                "      one.txt",
2454            ]
2455        );
2456
2457        panel.update(cx, |panel, cx| {
2458            panel.paste(&Default::default(), cx);
2459        });
2460        cx.executor().run_until_parked();
2461
2462        assert_eq!(
2463            visible_entries_as_strings(&panel, 0..50, cx),
2464            &[
2465                //
2466                "v root1",
2467                "      one.two copy 1.txt",
2468                "      one.two copy.txt",
2469                "      one.two.txt  <== selected",
2470                "      one.txt",
2471            ]
2472        );
2473    }
2474
2475    #[gpui::test]
2476    async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
2477        init_test_with_editor(cx);
2478
2479        let fs = FakeFs::new(cx.executor().clone());
2480        fs.insert_tree(
2481            "/src",
2482            json!({
2483                "test": {
2484                    "first.rs": "// First Rust file",
2485                    "second.rs": "// Second Rust file",
2486                    "third.rs": "// Third Rust file",
2487                }
2488            }),
2489        )
2490        .await;
2491
2492        let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2493        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2494        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2495        let panel = workspace
2496            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2497            .unwrap();
2498
2499        toggle_expand_dir(&panel, "src/test", cx);
2500        select_path(&panel, "src/test/first.rs", cx);
2501        panel.update(cx, |panel, cx| panel.open_file(&Open, cx));
2502        cx.executor().run_until_parked();
2503        assert_eq!(
2504            visible_entries_as_strings(&panel, 0..10, cx),
2505            &[
2506                "v src",
2507                "    v test",
2508                "          first.rs  <== selected",
2509                "          second.rs",
2510                "          third.rs"
2511            ]
2512        );
2513        ensure_single_file_is_opened(&workspace, "test/first.rs", cx);
2514
2515        submit_deletion(&panel, cx);
2516        assert_eq!(
2517            visible_entries_as_strings(&panel, 0..10, cx),
2518            &[
2519                "v src",
2520                "    v test",
2521                "          second.rs",
2522                "          third.rs"
2523            ],
2524            "Project panel should have no deleted file, no other file is selected in it"
2525        );
2526        ensure_no_open_items_and_panes(&workspace, cx);
2527
2528        select_path(&panel, "src/test/second.rs", cx);
2529        panel.update(cx, |panel, cx| panel.open_file(&Open, cx));
2530        cx.executor().run_until_parked();
2531        assert_eq!(
2532            visible_entries_as_strings(&panel, 0..10, cx),
2533            &[
2534                "v src",
2535                "    v test",
2536                "          second.rs  <== selected",
2537                "          third.rs"
2538            ]
2539        );
2540        ensure_single_file_is_opened(&workspace, "test/second.rs", cx);
2541
2542        workspace
2543            .update(cx, |workspace, cx| {
2544                let active_items = workspace
2545                    .panes()
2546                    .iter()
2547                    .filter_map(|pane| pane.read(cx).active_item())
2548                    .collect::<Vec<_>>();
2549                assert_eq!(active_items.len(), 1);
2550                let open_editor = active_items
2551                    .into_iter()
2552                    .next()
2553                    .unwrap()
2554                    .downcast::<Editor>()
2555                    .expect("Open item should be an editor");
2556                open_editor.update(cx, |editor, cx| editor.set_text("Another text!", cx));
2557            })
2558            .unwrap();
2559        submit_deletion(&panel, cx);
2560        assert_eq!(
2561            visible_entries_as_strings(&panel, 0..10, cx),
2562            &["v src", "    v test", "          third.rs"],
2563            "Project panel should have no deleted file, with one last file remaining"
2564        );
2565        ensure_no_open_items_and_panes(&workspace, cx);
2566    }
2567
2568    #[gpui::test]
2569    async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
2570        init_test_with_editor(cx);
2571
2572        let fs = FakeFs::new(cx.executor().clone());
2573        fs.insert_tree(
2574            "/src",
2575            json!({
2576                "test": {
2577                    "first.rs": "// First Rust file",
2578                    "second.rs": "// Second Rust file",
2579                    "third.rs": "// Third Rust file",
2580                }
2581            }),
2582        )
2583        .await;
2584
2585        let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2586        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2587        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2588        let panel = workspace
2589            .update(cx, |workspace, cx| {
2590                let panel = ProjectPanel::new(workspace, cx);
2591                workspace.add_panel(panel.clone(), cx);
2592                workspace.toggle_dock(panel.read(cx).position(cx), cx);
2593                panel
2594            })
2595            .unwrap();
2596
2597        select_path(&panel, "src/", cx);
2598        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2599        cx.executor().run_until_parked();
2600        assert_eq!(
2601            visible_entries_as_strings(&panel, 0..10, cx),
2602            &[
2603                //
2604                "v src  <== selected",
2605                "    > test"
2606            ]
2607        );
2608        panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2609        panel.update(cx, |panel, cx| {
2610            assert!(panel.filename_editor.read(cx).is_focused(cx));
2611        });
2612        assert_eq!(
2613            visible_entries_as_strings(&panel, 0..10, cx),
2614            &[
2615                //
2616                "v src",
2617                "    > [EDITOR: '']  <== selected",
2618                "    > test"
2619            ]
2620        );
2621        panel.update(cx, |panel, cx| {
2622            panel
2623                .filename_editor
2624                .update(cx, |editor, cx| editor.set_text("test", cx));
2625            assert!(
2626                panel.confirm_edit(cx).is_none(),
2627                "Should not allow to confirm on conflicting new directory name"
2628            )
2629        });
2630        assert_eq!(
2631            visible_entries_as_strings(&panel, 0..10, cx),
2632            &[
2633                //
2634                "v src",
2635                "    > test"
2636            ],
2637            "File list should be unchanged after failed folder create confirmation"
2638        );
2639
2640        select_path(&panel, "src/test/", cx);
2641        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2642        cx.executor().run_until_parked();
2643        assert_eq!(
2644            visible_entries_as_strings(&panel, 0..10, cx),
2645            &[
2646                //
2647                "v src",
2648                "    > test  <== selected"
2649            ]
2650        );
2651        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2652        panel.update(cx, |panel, cx| {
2653            assert!(panel.filename_editor.read(cx).is_focused(cx));
2654        });
2655        assert_eq!(
2656            visible_entries_as_strings(&panel, 0..10, cx),
2657            &[
2658                "v src",
2659                "    v test",
2660                "          [EDITOR: '']  <== selected",
2661                "          first.rs",
2662                "          second.rs",
2663                "          third.rs"
2664            ]
2665        );
2666        panel.update(cx, |panel, cx| {
2667            panel
2668                .filename_editor
2669                .update(cx, |editor, cx| editor.set_text("first.rs", cx));
2670            assert!(
2671                panel.confirm_edit(cx).is_none(),
2672                "Should not allow to confirm on conflicting new file name"
2673            )
2674        });
2675        assert_eq!(
2676            visible_entries_as_strings(&panel, 0..10, cx),
2677            &[
2678                "v src",
2679                "    v test",
2680                "          first.rs",
2681                "          second.rs",
2682                "          third.rs"
2683            ],
2684            "File list should be unchanged after failed file create confirmation"
2685        );
2686
2687        select_path(&panel, "src/test/first.rs", cx);
2688        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2689        cx.executor().run_until_parked();
2690        assert_eq!(
2691            visible_entries_as_strings(&panel, 0..10, cx),
2692            &[
2693                "v src",
2694                "    v test",
2695                "          first.rs  <== selected",
2696                "          second.rs",
2697                "          third.rs"
2698            ],
2699        );
2700        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2701        panel.update(cx, |panel, cx| {
2702            assert!(panel.filename_editor.read(cx).is_focused(cx));
2703        });
2704        assert_eq!(
2705            visible_entries_as_strings(&panel, 0..10, cx),
2706            &[
2707                "v src",
2708                "    v test",
2709                "          [EDITOR: 'first.rs']  <== selected",
2710                "          second.rs",
2711                "          third.rs"
2712            ]
2713        );
2714        panel.update(cx, |panel, cx| {
2715            panel
2716                .filename_editor
2717                .update(cx, |editor, cx| editor.set_text("second.rs", cx));
2718            assert!(
2719                panel.confirm_edit(cx).is_none(),
2720                "Should not allow to confirm on conflicting file rename"
2721            )
2722        });
2723        assert_eq!(
2724            visible_entries_as_strings(&panel, 0..10, cx),
2725            &[
2726                "v src",
2727                "    v test",
2728                "          first.rs  <== selected",
2729                "          second.rs",
2730                "          third.rs"
2731            ],
2732            "File list should be unchanged after failed rename confirmation"
2733        );
2734    }
2735
2736    #[gpui::test]
2737    async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) {
2738        init_test_with_editor(cx);
2739
2740        let fs = FakeFs::new(cx.executor().clone());
2741        fs.insert_tree(
2742            "/project_root",
2743            json!({
2744                "dir_1": {
2745                    "nested_dir": {
2746                        "file_a.py": "# File contents",
2747                        "file_b.py": "# File contents",
2748                        "file_c.py": "# File contents",
2749                    },
2750                    "file_1.py": "# File contents",
2751                    "file_2.py": "# File contents",
2752                    "file_3.py": "# File contents",
2753                },
2754                "dir_2": {
2755                    "file_1.py": "# File contents",
2756                    "file_2.py": "# File contents",
2757                    "file_3.py": "# File contents",
2758                }
2759            }),
2760        )
2761        .await;
2762
2763        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
2764        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2765        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2766        let panel = workspace
2767            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2768            .unwrap();
2769
2770        panel.update(cx, |panel, cx| {
2771            panel.collapse_all_entries(&CollapseAllEntries, cx)
2772        });
2773        cx.executor().run_until_parked();
2774        assert_eq!(
2775            visible_entries_as_strings(&panel, 0..10, cx),
2776            &["v project_root", "    > dir_1", "    > dir_2",]
2777        );
2778
2779        // Open dir_1 and make sure nested_dir was collapsed when running collapse_all_entries
2780        toggle_expand_dir(&panel, "project_root/dir_1", cx);
2781        cx.executor().run_until_parked();
2782        assert_eq!(
2783            visible_entries_as_strings(&panel, 0..10, cx),
2784            &[
2785                "v project_root",
2786                "    v dir_1  <== selected",
2787                "        > nested_dir",
2788                "          file_1.py",
2789                "          file_2.py",
2790                "          file_3.py",
2791                "    > dir_2",
2792            ]
2793        );
2794    }
2795
2796    #[gpui::test]
2797    async fn test_new_file_move(cx: &mut gpui::TestAppContext) {
2798        init_test(cx);
2799
2800        let fs = FakeFs::new(cx.executor().clone());
2801        fs.as_fake().insert_tree("/root", json!({})).await;
2802        let project = Project::test(fs, ["/root".as_ref()], cx).await;
2803        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2804        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2805        let panel = workspace
2806            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2807            .unwrap();
2808
2809        // Make a new buffer with no backing file
2810        workspace
2811            .update(cx, |workspace, cx| {
2812                Editor::new_file(workspace, &Default::default(), cx)
2813            })
2814            .unwrap();
2815
2816        // "Save as"" the buffer, creating a new backing file for it
2817        let save_task = workspace
2818            .update(cx, |workspace, cx| {
2819                workspace.save_active_item(workspace::SaveIntent::Save, cx)
2820            })
2821            .unwrap();
2822
2823        cx.executor().run_until_parked();
2824        cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/new")));
2825        save_task.await.unwrap();
2826
2827        // Rename the file
2828        select_path(&panel, "root/new", cx);
2829        assert_eq!(
2830            visible_entries_as_strings(&panel, 0..10, cx),
2831            &["v root", "      new  <== selected"]
2832        );
2833        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2834        panel.update(cx, |panel, cx| {
2835            panel
2836                .filename_editor
2837                .update(cx, |editor, cx| editor.set_text("newer", cx));
2838        });
2839        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2840
2841        cx.executor().run_until_parked();
2842        assert_eq!(
2843            visible_entries_as_strings(&panel, 0..10, cx),
2844            &["v root", "      newer  <== selected"]
2845        );
2846
2847        workspace
2848            .update(cx, |workspace, cx| {
2849                workspace.save_active_item(workspace::SaveIntent::Save, cx)
2850            })
2851            .unwrap()
2852            .await
2853            .unwrap();
2854
2855        cx.executor().run_until_parked();
2856        // assert that saving the file doesn't restore "new"
2857        assert_eq!(
2858            visible_entries_as_strings(&panel, 0..10, cx),
2859            &["v root", "      newer  <== selected"]
2860        );
2861    }
2862
2863    #[gpui::test]
2864    async fn test_autoreveal_and_gitignored_files(cx: &mut gpui::TestAppContext) {
2865        init_test_with_editor(cx);
2866        cx.update(|cx| {
2867            cx.update_global::<SettingsStore, _>(|store, cx| {
2868                store.update_user_settings::<ProjectSettings>(cx, |project_settings| {
2869                    project_settings.file_scan_exclusions = Some(Vec::new());
2870                });
2871                store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
2872                    project_panel_settings.auto_reveal_entries = Some(false)
2873                });
2874            })
2875        });
2876
2877        let fs = FakeFs::new(cx.background_executor.clone());
2878        fs.insert_tree(
2879            "/project_root",
2880            json!({
2881                ".git": {},
2882                ".gitignore": "**/gitignored_dir",
2883                "dir_1": {
2884                    "file_1.py": "# File 1_1 contents",
2885                    "file_2.py": "# File 1_2 contents",
2886                    "file_3.py": "# File 1_3 contents",
2887                    "gitignored_dir": {
2888                        "file_a.py": "# File contents",
2889                        "file_b.py": "# File contents",
2890                        "file_c.py": "# File contents",
2891                    },
2892                },
2893                "dir_2": {
2894                    "file_1.py": "# File 2_1 contents",
2895                    "file_2.py": "# File 2_2 contents",
2896                    "file_3.py": "# File 2_3 contents",
2897                }
2898            }),
2899        )
2900        .await;
2901
2902        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
2903        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2904        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2905        let panel = workspace
2906            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2907            .unwrap();
2908
2909        assert_eq!(
2910            visible_entries_as_strings(&panel, 0..20, cx),
2911            &[
2912                "v project_root",
2913                "    > .git",
2914                "    > dir_1",
2915                "    > dir_2",
2916                "      .gitignore",
2917            ]
2918        );
2919
2920        let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
2921            .expect("dir 1 file is not ignored and should have an entry");
2922        let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
2923            .expect("dir 2 file is not ignored and should have an entry");
2924        let gitignored_dir_file =
2925            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
2926        assert_eq!(
2927            gitignored_dir_file, None,
2928            "File in the gitignored dir should not have an entry before its dir is toggled"
2929        );
2930
2931        toggle_expand_dir(&panel, "project_root/dir_1", cx);
2932        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
2933        cx.executor().run_until_parked();
2934        assert_eq!(
2935            visible_entries_as_strings(&panel, 0..20, cx),
2936            &[
2937                "v project_root",
2938                "    > .git",
2939                "    v dir_1",
2940                "        v gitignored_dir  <== selected",
2941                "              file_a.py",
2942                "              file_b.py",
2943                "              file_c.py",
2944                "          file_1.py",
2945                "          file_2.py",
2946                "          file_3.py",
2947                "    > dir_2",
2948                "      .gitignore",
2949            ],
2950            "Should show gitignored dir file list in the project panel"
2951        );
2952        let gitignored_dir_file =
2953            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
2954                .expect("after gitignored dir got opened, a file entry should be present");
2955
2956        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
2957        toggle_expand_dir(&panel, "project_root/dir_1", cx);
2958        assert_eq!(
2959            visible_entries_as_strings(&panel, 0..20, cx),
2960            &[
2961                "v project_root",
2962                "    > .git",
2963                "    > dir_1  <== selected",
2964                "    > dir_2",
2965                "      .gitignore",
2966            ],
2967            "Should hide all dir contents again and prepare for the auto reveal test"
2968        );
2969
2970        for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
2971            panel.update(cx, |panel, cx| {
2972                panel.project.update(cx, |_, cx| {
2973                    cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
2974                })
2975            });
2976            cx.run_until_parked();
2977            assert_eq!(
2978                visible_entries_as_strings(&panel, 0..20, cx),
2979                &[
2980                    "v project_root",
2981                    "    > .git",
2982                    "    > dir_1  <== selected",
2983                    "    > dir_2",
2984                    "      .gitignore",
2985                ],
2986                "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
2987            );
2988        }
2989
2990        cx.update(|cx| {
2991            cx.update_global::<SettingsStore, _>(|store, cx| {
2992                store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
2993                    project_panel_settings.auto_reveal_entries = Some(true)
2994                });
2995            })
2996        });
2997
2998        panel.update(cx, |panel, cx| {
2999            panel.project.update(cx, |_, cx| {
3000                cx.emit(project::Event::ActiveEntryChanged(Some(dir_1_file)))
3001            })
3002        });
3003        cx.run_until_parked();
3004        assert_eq!(
3005            visible_entries_as_strings(&panel, 0..20, cx),
3006            &[
3007                "v project_root",
3008                "    > .git",
3009                "    v dir_1",
3010                "        > gitignored_dir",
3011                "          file_1.py  <== selected",
3012                "          file_2.py",
3013                "          file_3.py",
3014                "    > dir_2",
3015                "      .gitignore",
3016            ],
3017            "When auto reveal is enabled, not ignored dir_1 entry should be revealed"
3018        );
3019
3020        panel.update(cx, |panel, cx| {
3021            panel.project.update(cx, |_, cx| {
3022                cx.emit(project::Event::ActiveEntryChanged(Some(dir_2_file)))
3023            })
3024        });
3025        cx.run_until_parked();
3026        assert_eq!(
3027            visible_entries_as_strings(&panel, 0..20, cx),
3028            &[
3029                "v project_root",
3030                "    > .git",
3031                "    v dir_1",
3032                "        > gitignored_dir",
3033                "          file_1.py",
3034                "          file_2.py",
3035                "          file_3.py",
3036                "    v dir_2",
3037                "          file_1.py  <== selected",
3038                "          file_2.py",
3039                "          file_3.py",
3040                "      .gitignore",
3041            ],
3042            "When auto reveal is enabled, not ignored dir_2 entry should be revealed"
3043        );
3044
3045        panel.update(cx, |panel, cx| {
3046            panel.project.update(cx, |_, cx| {
3047                cx.emit(project::Event::ActiveEntryChanged(Some(
3048                    gitignored_dir_file,
3049                )))
3050            })
3051        });
3052        cx.run_until_parked();
3053        assert_eq!(
3054            visible_entries_as_strings(&panel, 0..20, cx),
3055            &[
3056                "v project_root",
3057                "    > .git",
3058                "    v dir_1",
3059                "        > gitignored_dir",
3060                "          file_1.py",
3061                "          file_2.py",
3062                "          file_3.py",
3063                "    v dir_2",
3064                "          file_1.py  <== selected",
3065                "          file_2.py",
3066                "          file_3.py",
3067                "      .gitignore",
3068            ],
3069            "When auto reveal is enabled, a gitignored selected entry should not be revealed in the project panel"
3070        );
3071
3072        panel.update(cx, |panel, cx| {
3073            panel.project.update(cx, |_, cx| {
3074                cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
3075            })
3076        });
3077        cx.run_until_parked();
3078        assert_eq!(
3079            visible_entries_as_strings(&panel, 0..20, cx),
3080            &[
3081                "v project_root",
3082                "    > .git",
3083                "    v dir_1",
3084                "        v gitignored_dir",
3085                "              file_a.py  <== selected",
3086                "              file_b.py",
3087                "              file_c.py",
3088                "          file_1.py",
3089                "          file_2.py",
3090                "          file_3.py",
3091                "    v dir_2",
3092                "          file_1.py",
3093                "          file_2.py",
3094                "          file_3.py",
3095                "      .gitignore",
3096            ],
3097            "When a gitignored entry is explicitly revealed, it should be shown in the project tree"
3098        );
3099    }
3100
3101    #[gpui::test]
3102    async fn test_explicit_reveal(cx: &mut gpui::TestAppContext) {
3103        init_test_with_editor(cx);
3104        cx.update(|cx| {
3105            cx.update_global::<SettingsStore, _>(|store, cx| {
3106                store.update_user_settings::<ProjectSettings>(cx, |project_settings| {
3107                    project_settings.file_scan_exclusions = Some(Vec::new());
3108                });
3109                store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
3110                    project_panel_settings.auto_reveal_entries = Some(false)
3111                });
3112            })
3113        });
3114
3115        let fs = FakeFs::new(cx.background_executor.clone());
3116        fs.insert_tree(
3117            "/project_root",
3118            json!({
3119                ".git": {},
3120                ".gitignore": "**/gitignored_dir",
3121                "dir_1": {
3122                    "file_1.py": "# File 1_1 contents",
3123                    "file_2.py": "# File 1_2 contents",
3124                    "file_3.py": "# File 1_3 contents",
3125                    "gitignored_dir": {
3126                        "file_a.py": "# File contents",
3127                        "file_b.py": "# File contents",
3128                        "file_c.py": "# File contents",
3129                    },
3130                },
3131                "dir_2": {
3132                    "file_1.py": "# File 2_1 contents",
3133                    "file_2.py": "# File 2_2 contents",
3134                    "file_3.py": "# File 2_3 contents",
3135                }
3136            }),
3137        )
3138        .await;
3139
3140        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3141        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3142        let cx = &mut VisualTestContext::from_window(*workspace, cx);
3143        let panel = workspace
3144            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
3145            .unwrap();
3146
3147        assert_eq!(
3148            visible_entries_as_strings(&panel, 0..20, cx),
3149            &[
3150                "v project_root",
3151                "    > .git",
3152                "    > dir_1",
3153                "    > dir_2",
3154                "      .gitignore",
3155            ]
3156        );
3157
3158        let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
3159            .expect("dir 1 file is not ignored and should have an entry");
3160        let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
3161            .expect("dir 2 file is not ignored and should have an entry");
3162        let gitignored_dir_file =
3163            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
3164        assert_eq!(
3165            gitignored_dir_file, None,
3166            "File in the gitignored dir should not have an entry before its dir is toggled"
3167        );
3168
3169        toggle_expand_dir(&panel, "project_root/dir_1", cx);
3170        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3171        cx.run_until_parked();
3172        assert_eq!(
3173            visible_entries_as_strings(&panel, 0..20, cx),
3174            &[
3175                "v project_root",
3176                "    > .git",
3177                "    v dir_1",
3178                "        v gitignored_dir  <== selected",
3179                "              file_a.py",
3180                "              file_b.py",
3181                "              file_c.py",
3182                "          file_1.py",
3183                "          file_2.py",
3184                "          file_3.py",
3185                "    > dir_2",
3186                "      .gitignore",
3187            ],
3188            "Should show gitignored dir file list in the project panel"
3189        );
3190        let gitignored_dir_file =
3191            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
3192                .expect("after gitignored dir got opened, a file entry should be present");
3193
3194        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3195        toggle_expand_dir(&panel, "project_root/dir_1", cx);
3196        assert_eq!(
3197            visible_entries_as_strings(&panel, 0..20, cx),
3198            &[
3199                "v project_root",
3200                "    > .git",
3201                "    > dir_1  <== selected",
3202                "    > dir_2",
3203                "      .gitignore",
3204            ],
3205            "Should hide all dir contents again and prepare for the explicit reveal test"
3206        );
3207
3208        for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
3209            panel.update(cx, |panel, cx| {
3210                panel.project.update(cx, |_, cx| {
3211                    cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
3212                })
3213            });
3214            cx.run_until_parked();
3215            assert_eq!(
3216                visible_entries_as_strings(&panel, 0..20, cx),
3217                &[
3218                    "v project_root",
3219                    "    > .git",
3220                    "    > dir_1  <== selected",
3221                    "    > dir_2",
3222                    "      .gitignore",
3223                ],
3224                "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
3225            );
3226        }
3227
3228        panel.update(cx, |panel, cx| {
3229            panel.project.update(cx, |_, cx| {
3230                cx.emit(project::Event::RevealInProjectPanel(dir_1_file))
3231            })
3232        });
3233        cx.run_until_parked();
3234        assert_eq!(
3235            visible_entries_as_strings(&panel, 0..20, cx),
3236            &[
3237                "v project_root",
3238                "    > .git",
3239                "    v dir_1",
3240                "        > gitignored_dir",
3241                "          file_1.py  <== selected",
3242                "          file_2.py",
3243                "          file_3.py",
3244                "    > dir_2",
3245                "      .gitignore",
3246            ],
3247            "With no auto reveal, explicit reveal should show the dir_1 entry in the project panel"
3248        );
3249
3250        panel.update(cx, |panel, cx| {
3251            panel.project.update(cx, |_, cx| {
3252                cx.emit(project::Event::RevealInProjectPanel(dir_2_file))
3253            })
3254        });
3255        cx.run_until_parked();
3256        assert_eq!(
3257            visible_entries_as_strings(&panel, 0..20, cx),
3258            &[
3259                "v project_root",
3260                "    > .git",
3261                "    v dir_1",
3262                "        > gitignored_dir",
3263                "          file_1.py",
3264                "          file_2.py",
3265                "          file_3.py",
3266                "    v dir_2",
3267                "          file_1.py  <== selected",
3268                "          file_2.py",
3269                "          file_3.py",
3270                "      .gitignore",
3271            ],
3272            "With no auto reveal, explicit reveal should show the dir_2 entry in the project panel"
3273        );
3274
3275        panel.update(cx, |panel, cx| {
3276            panel.project.update(cx, |_, cx| {
3277                cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
3278            })
3279        });
3280        cx.run_until_parked();
3281        assert_eq!(
3282            visible_entries_as_strings(&panel, 0..20, cx),
3283            &[
3284                "v project_root",
3285                "    > .git",
3286                "    v dir_1",
3287                "        v gitignored_dir",
3288                "              file_a.py  <== selected",
3289                "              file_b.py",
3290                "              file_c.py",
3291                "          file_1.py",
3292                "          file_2.py",
3293                "          file_3.py",
3294                "    v dir_2",
3295                "          file_1.py",
3296                "          file_2.py",
3297                "          file_3.py",
3298                "      .gitignore",
3299            ],
3300            "With no auto reveal, explicit reveal should show the gitignored entry in the project panel"
3301        );
3302    }
3303
3304    fn toggle_expand_dir(
3305        panel: &View<ProjectPanel>,
3306        path: impl AsRef<Path>,
3307        cx: &mut VisualTestContext,
3308    ) {
3309        let path = path.as_ref();
3310        panel.update(cx, |panel, cx| {
3311            for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
3312                let worktree = worktree.read(cx);
3313                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
3314                    let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
3315                    panel.toggle_expanded(entry_id, cx);
3316                    return;
3317                }
3318            }
3319            panic!("no worktree for path {:?}", path);
3320        });
3321    }
3322
3323    fn select_path(panel: &View<ProjectPanel>, path: impl AsRef<Path>, cx: &mut VisualTestContext) {
3324        let path = path.as_ref();
3325        panel.update(cx, |panel, cx| {
3326            for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
3327                let worktree = worktree.read(cx);
3328                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
3329                    let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
3330                    panel.selection = Some(crate::Selection {
3331                        worktree_id: worktree.id(),
3332                        entry_id,
3333                    });
3334                    return;
3335                }
3336            }
3337            panic!("no worktree for path {:?}", path);
3338        });
3339    }
3340
3341    fn find_project_entry(
3342        panel: &View<ProjectPanel>,
3343        path: impl AsRef<Path>,
3344        cx: &mut VisualTestContext,
3345    ) -> Option<ProjectEntryId> {
3346        let path = path.as_ref();
3347        panel.update(cx, |panel, cx| {
3348            for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
3349                let worktree = worktree.read(cx);
3350                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
3351                    return worktree.entry_for_path(relative_path).map(|entry| entry.id);
3352                }
3353            }
3354            panic!("no worktree for path {path:?}");
3355        })
3356    }
3357
3358    fn visible_entries_as_strings(
3359        panel: &View<ProjectPanel>,
3360        range: Range<usize>,
3361        cx: &mut VisualTestContext,
3362    ) -> Vec<String> {
3363        let mut result = Vec::new();
3364        let mut project_entries = HashSet::new();
3365        let mut has_editor = false;
3366
3367        panel.update(cx, |panel, cx| {
3368            panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
3369                if details.is_editing {
3370                    assert!(!has_editor, "duplicate editor entry");
3371                    has_editor = true;
3372                } else {
3373                    assert!(
3374                        project_entries.insert(project_entry),
3375                        "duplicate project entry {:?} {:?}",
3376                        project_entry,
3377                        details
3378                    );
3379                }
3380
3381                let indent = "    ".repeat(details.depth);
3382                let icon = if details.kind.is_dir() {
3383                    if details.is_expanded {
3384                        "v "
3385                    } else {
3386                        "> "
3387                    }
3388                } else {
3389                    "  "
3390                };
3391                let name = if details.is_editing {
3392                    format!("[EDITOR: '{}']", details.filename)
3393                } else if details.is_processing {
3394                    format!("[PROCESSING: '{}']", details.filename)
3395                } else {
3396                    details.filename.clone()
3397                };
3398                let selected = if details.is_selected {
3399                    "  <== selected"
3400                } else {
3401                    ""
3402                };
3403                result.push(format!("{indent}{icon}{name}{selected}"));
3404            });
3405        });
3406
3407        result
3408    }
3409
3410    fn init_test(cx: &mut TestAppContext) {
3411        cx.update(|cx| {
3412            let settings_store = SettingsStore::test(cx);
3413            cx.set_global(settings_store);
3414            init_settings(cx);
3415            theme::init(theme::LoadThemes::JustBase, cx);
3416            language::init(cx);
3417            editor::init_settings(cx);
3418            crate::init((), cx);
3419            workspace::init_settings(cx);
3420            client::init_settings(cx);
3421            Project::init_settings(cx);
3422
3423            cx.update_global::<SettingsStore, _>(|store, cx| {
3424                store.update_user_settings::<ProjectSettings>(cx, |project_settings| {
3425                    project_settings.file_scan_exclusions = Some(Vec::new());
3426                });
3427            });
3428        });
3429    }
3430
3431    fn init_test_with_editor(cx: &mut TestAppContext) {
3432        cx.update(|cx| {
3433            let app_state = AppState::test(cx);
3434            theme::init(theme::LoadThemes::JustBase, cx);
3435            init_settings(cx);
3436            language::init(cx);
3437            editor::init(cx);
3438            crate::init((), cx);
3439            workspace::init(app_state.clone(), cx);
3440            Project::init_settings(cx);
3441        });
3442    }
3443
3444    fn ensure_single_file_is_opened(
3445        window: &WindowHandle<Workspace>,
3446        expected_path: &str,
3447        cx: &mut TestAppContext,
3448    ) {
3449        window
3450            .update(cx, |workspace, cx| {
3451                let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
3452                assert_eq!(worktrees.len(), 1);
3453                let worktree_id = worktrees[0].read(cx).id();
3454
3455                let open_project_paths = workspace
3456                    .panes()
3457                    .iter()
3458                    .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
3459                    .collect::<Vec<_>>();
3460                assert_eq!(
3461                    open_project_paths,
3462                    vec![ProjectPath {
3463                        worktree_id,
3464                        path: Arc::from(Path::new(expected_path))
3465                    }],
3466                    "Should have opened file, selected in project panel"
3467                );
3468            })
3469            .unwrap();
3470    }
3471
3472    fn submit_deletion(panel: &View<ProjectPanel>, cx: &mut VisualTestContext) {
3473        assert!(
3474            !cx.has_pending_prompt(),
3475            "Should have no prompts before the deletion"
3476        );
3477        panel.update(cx, |panel, cx| panel.delete(&Delete, cx));
3478        assert!(
3479            cx.has_pending_prompt(),
3480            "Should have a prompt after the deletion"
3481        );
3482        cx.simulate_prompt_answer(0);
3483        assert!(
3484            !cx.has_pending_prompt(),
3485            "Should have no prompts after prompt was replied to"
3486        );
3487        cx.executor().run_until_parked();
3488    }
3489
3490    fn ensure_no_open_items_and_panes(
3491        workspace: &WindowHandle<Workspace>,
3492        cx: &mut VisualTestContext,
3493    ) {
3494        assert!(
3495            !cx.has_pending_prompt(),
3496            "Should have no prompts after deletion operation closes the file"
3497        );
3498        workspace
3499            .read_with(cx, |workspace, cx| {
3500                let open_project_paths = workspace
3501                    .panes()
3502                    .iter()
3503                    .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
3504                    .collect::<Vec<_>>();
3505                assert!(
3506                    open_project_paths.is_empty(),
3507                    "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
3508                );
3509            })
3510            .unwrap();
3511    }
3512}