project_panel.rs

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