project_panel.rs

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