project_panel.rs

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