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