project_panel.rs

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