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