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