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