project_panel.rs

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