project_panel.rs

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