project_panel.rs

   1pub mod file_associations;
   2mod project_panel_settings;
   3
   4use context_menu::{ContextMenu, ContextMenuItem};
   5use db::kvp::KEY_VALUE_STORE;
   6use drag_and_drop::{DragAndDrop, Draggable};
   7use editor::{scroll::autoscroll::Autoscroll, Cancel, Editor};
   8use file_associations::FileAssociations;
   9
  10use futures::stream::StreamExt;
  11use gpui::{
  12    actions,
  13    anyhow::{self, anyhow, Result},
  14    elements::{
  15        AnchorCorner, ChildView, ContainerStyle, Empty, Flex, Label, MouseEventHandler,
  16        ParentElement, ScrollTarget, Stack, Svg, UniformList, UniformListState,
  17    },
  18    geometry::vector::Vector2F,
  19    keymap_matcher::KeymapContext,
  20    platform::{CursorStyle, MouseButton, PromptLevel},
  21    Action, AnyElement, AppContext, AssetSource, AsyncAppContext, ClipboardItem, Element, Entity,
  22    ModelHandle, Task, View, ViewContext, ViewHandle, WeakViewHandle, WindowContext,
  23};
  24use menu::{Confirm, SelectNext, SelectPrev};
  25use project::{
  26    repository::GitFileStatus, Entry, EntryKind, Fs, Project, ProjectEntryId, ProjectPath,
  27    Worktree, WorktreeId,
  28};
  29use project_panel_settings::{ProjectPanelDockPosition, ProjectPanelSettings};
  30use serde::{Deserialize, Serialize};
  31use settings::SettingsStore;
  32use std::{
  33    cmp::Ordering,
  34    collections::{hash_map, HashMap},
  35    ffi::OsStr,
  36    ops::Range,
  37    path::Path,
  38    sync::Arc,
  39};
  40use theme::ProjectPanelEntry;
  41use unicase::UniCase;
  42use util::{ResultExt, TryFutureExt};
  43use workspace::{
  44    dock::{DockPosition, Panel},
  45    Workspace,
  46};
  47
  48const PROJECT_PANEL_KEY: &'static str = "ProjectPanel";
  49const NEW_ENTRY_ID: ProjectEntryId = ProjectEntryId::MAX;
  50
  51pub struct ProjectPanel {
  52    project: ModelHandle<Project>,
  53    fs: Arc<dyn Fs>,
  54    list: UniformListState,
  55    visible_entries: Vec<(WorktreeId, Vec<Entry>)>,
  56    last_worktree_root_id: Option<ProjectEntryId>,
  57    expanded_dir_ids: HashMap<WorktreeId, Vec<ProjectEntryId>>,
  58    selection: Option<Selection>,
  59    edit_state: Option<EditState>,
  60    filename_editor: ViewHandle<Editor>,
  61    clipboard_entry: Option<ClipboardEntry>,
  62    context_menu: ViewHandle<ContextMenu>,
  63    dragged_entry_destination: Option<Arc<Path>>,
  64    workspace: WeakViewHandle<Workspace>,
  65    has_focus: bool,
  66    width: Option<f32>,
  67    pending_serialization: Task<Option<()>>,
  68}
  69
  70#[derive(Copy, Clone, Debug)]
  71struct Selection {
  72    worktree_id: WorktreeId,
  73    entry_id: ProjectEntryId,
  74}
  75
  76#[derive(Clone, Debug)]
  77struct EditState {
  78    worktree_id: WorktreeId,
  79    entry_id: ProjectEntryId,
  80    is_new_entry: bool,
  81    is_dir: bool,
  82    processing_filename: Option<String>,
  83}
  84
  85#[derive(Copy, Clone)]
  86pub enum ClipboardEntry {
  87    Copied {
  88        worktree_id: WorktreeId,
  89        entry_id: ProjectEntryId,
  90    },
  91    Cut {
  92        worktree_id: WorktreeId,
  93        entry_id: ProjectEntryId,
  94    },
  95}
  96
  97#[derive(Debug, PartialEq, Eq)]
  98pub struct EntryDetails {
  99    filename: String,
 100    icon: Option<Arc<str>>,
 101    path: Arc<Path>,
 102    depth: usize,
 103    kind: EntryKind,
 104    is_ignored: bool,
 105    is_expanded: bool,
 106    is_selected: bool,
 107    is_editing: bool,
 108    is_processing: bool,
 109    is_cut: bool,
 110    git_status: Option<GitFileStatus>,
 111}
 112
 113actions!(
 114    project_panel,
 115    [
 116        ExpandSelectedEntry,
 117        CollapseSelectedEntry,
 118        CollapseAllEntries,
 119        NewDirectory,
 120        NewFile,
 121        Copy,
 122        CopyPath,
 123        CopyRelativePath,
 124        RevealInFinder,
 125        OpenInTerminal,
 126        Cut,
 127        Paste,
 128        Delete,
 129        Rename,
 130        Open,
 131        ToggleFocus,
 132        NewSearchInDirectory,
 133    ]
 134);
 135
 136pub fn init_settings(cx: &mut AppContext) {
 137    settings::register::<ProjectPanelSettings>(cx);
 138}
 139
 140pub fn init(assets: impl AssetSource, cx: &mut AppContext) {
 141    init_settings(cx);
 142    file_associations::init(assets, cx);
 143    cx.add_action(ProjectPanel::expand_selected_entry);
 144    cx.add_action(ProjectPanel::collapse_selected_entry);
 145    cx.add_action(ProjectPanel::collapse_all_entries);
 146    cx.add_action(ProjectPanel::select_prev);
 147    cx.add_action(ProjectPanel::select_next);
 148    cx.add_action(ProjectPanel::new_file);
 149    cx.add_action(ProjectPanel::new_directory);
 150    cx.add_action(ProjectPanel::rename);
 151    cx.add_async_action(ProjectPanel::delete);
 152    cx.add_async_action(ProjectPanel::confirm);
 153    cx.add_async_action(ProjectPanel::open_file);
 154    cx.add_action(ProjectPanel::cancel);
 155    cx.add_action(ProjectPanel::cut);
 156    cx.add_action(ProjectPanel::copy);
 157    cx.add_action(ProjectPanel::copy_path);
 158    cx.add_action(ProjectPanel::copy_relative_path);
 159    cx.add_action(ProjectPanel::reveal_in_finder);
 160    cx.add_action(ProjectPanel::open_in_terminal);
 161    cx.add_action(ProjectPanel::new_search_in_directory);
 162    cx.add_action(
 163        |this: &mut ProjectPanel, action: &Paste, cx: &mut ViewContext<ProjectPanel>| {
 164            this.paste(action, cx);
 165        },
 166    );
 167}
 168
 169#[derive(Debug)]
 170pub enum Event {
 171    OpenedEntry {
 172        entry_id: ProjectEntryId,
 173        focus_opened_item: bool,
 174    },
 175    SplitEntry {
 176        entry_id: ProjectEntryId,
 177    },
 178    DockPositionChanged,
 179    Focus,
 180    NewSearchInDirectory {
 181        dir_entry: Entry,
 182    },
 183    ActivatePanel,
 184}
 185
 186#[derive(Serialize, Deserialize)]
 187struct SerializedProjectPanel {
 188    width: Option<f32>,
 189}
 190
 191impl ProjectPanel {
 192    fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> ViewHandle<Self> {
 193        let project = workspace.project().clone();
 194        let project_panel = cx.add_view(|cx: &mut ViewContext<Self>| {
 195            cx.observe(&project, |this, _, cx| {
 196                this.update_visible_entries(None, cx);
 197                cx.notify();
 198            })
 199            .detach();
 200            cx.subscribe(&project, |this, project, event, cx| match event {
 201                project::Event::ActiveEntryChanged(Some(entry_id)) => {
 202                    if let Some(worktree_id) = project.read(cx).worktree_id_for_entry(*entry_id, cx)
 203                    {
 204                        this.expand_entry(worktree_id, *entry_id, cx);
 205                        this.update_visible_entries(Some((worktree_id, *entry_id)), cx);
 206                        this.autoscroll(cx);
 207                        cx.notify();
 208                    }
 209                }
 210                project::Event::ActivateProjectPanel => {
 211                    cx.emit(Event::ActivatePanel);
 212                }
 213                project::Event::WorktreeRemoved(id) => {
 214                    this.expanded_dir_ids.remove(id);
 215                    this.update_visible_entries(None, cx);
 216                    cx.notify();
 217                }
 218                _ => {}
 219            })
 220            .detach();
 221
 222            let filename_editor = cx.add_view(|cx| {
 223                Editor::single_line(
 224                    Some(Arc::new(|theme| {
 225                        let mut style = theme.project_panel.filename_editor.clone();
 226                        style.container.background_color.take();
 227                        style
 228                    })),
 229                    cx,
 230                )
 231            });
 232
 233            cx.subscribe(&filename_editor, |this, _, event, cx| match event {
 234                editor::Event::BufferEdited | editor::Event::SelectionsChanged { .. } => {
 235                    this.autoscroll(cx);
 236                }
 237                _ => {}
 238            })
 239            .detach();
 240            cx.observe_focus(&filename_editor, |this, _, is_focused, cx| {
 241                if !is_focused
 242                    && this
 243                        .edit_state
 244                        .as_ref()
 245                        .map_or(false, |state| state.processing_filename.is_none())
 246                {
 247                    this.edit_state = None;
 248                    this.update_visible_entries(None, cx);
 249                }
 250            })
 251            .detach();
 252
 253            cx.observe_global::<FileAssociations, _>(|_, cx| {
 254                cx.notify();
 255            })
 256            .detach();
 257
 258            let view_id = cx.view_id();
 259            let mut this = Self {
 260                project: project.clone(),
 261                fs: workspace.app_state().fs.clone(),
 262                list: Default::default(),
 263                visible_entries: Default::default(),
 264                last_worktree_root_id: Default::default(),
 265                expanded_dir_ids: Default::default(),
 266                selection: None,
 267                edit_state: None,
 268                filename_editor,
 269                clipboard_entry: None,
 270                context_menu: cx.add_view(|cx| ContextMenu::new(view_id, cx)),
 271                dragged_entry_destination: None,
 272                workspace: workspace.weak_handle(),
 273                has_focus: false,
 274                width: None,
 275                pending_serialization: Task::ready(None),
 276            };
 277            this.update_visible_entries(None, cx);
 278
 279            // Update the dock position when the setting changes.
 280            let mut old_dock_position = this.position(cx);
 281            cx.observe_global::<SettingsStore, _>(move |this, cx| {
 282                let new_dock_position = this.position(cx);
 283                if new_dock_position != old_dock_position {
 284                    old_dock_position = new_dock_position;
 285                    cx.emit(Event::DockPositionChanged);
 286                }
 287            })
 288            .detach();
 289
 290            this
 291        });
 292
 293        cx.subscribe(&project_panel, {
 294            let project_panel = project_panel.downgrade();
 295            move |workspace, _, event, cx| match event {
 296                &Event::OpenedEntry {
 297                    entry_id,
 298                    focus_opened_item,
 299                } => {
 300                    if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
 301                        if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
 302                            workspace
 303                                .open_path(
 304                                    ProjectPath {
 305                                        worktree_id: worktree.read(cx).id(),
 306                                        path: entry.path.clone(),
 307                                    },
 308                                    None,
 309                                    focus_opened_item,
 310                                    cx,
 311                                )
 312                                .detach_and_log_err(cx);
 313                            if !focus_opened_item {
 314                                if let Some(project_panel) = project_panel.upgrade(cx) {
 315                                    cx.focus(&project_panel);
 316                                }
 317                            }
 318                        }
 319                    }
 320                }
 321                &Event::SplitEntry { entry_id } => {
 322                    if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
 323                        if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
 324                            workspace
 325                                .split_path(
 326                                    ProjectPath {
 327                                        worktree_id: worktree.read(cx).id(),
 328                                        path: entry.path.clone(),
 329                                    },
 330                                    cx,
 331                                )
 332                                .detach_and_log_err(cx);
 333                        }
 334                    }
 335                }
 336                _ => {}
 337            }
 338        })
 339        .detach();
 340
 341        project_panel
 342    }
 343
 344    pub fn load(
 345        workspace: WeakViewHandle<Workspace>,
 346        cx: AsyncAppContext,
 347    ) -> Task<Result<ViewHandle<Self>>> {
 348        cx.spawn(|mut cx| async move {
 349            let serialized_panel = if let Some(panel) = cx
 350                .background()
 351                .spawn(async move { KEY_VALUE_STORE.read_kvp(PROJECT_PANEL_KEY) })
 352                .await
 353                .log_err()
 354                .flatten()
 355            {
 356                Some(serde_json::from_str::<SerializedProjectPanel>(&panel)?)
 357            } else {
 358                None
 359            };
 360            workspace.update(&mut cx, |workspace, cx| {
 361                let panel = ProjectPanel::new(workspace, cx);
 362                if let Some(serialized_panel) = serialized_panel {
 363                    panel.update(cx, |panel, cx| {
 364                        panel.width = serialized_panel.width;
 365                        cx.notify();
 366                    });
 367                }
 368                panel
 369            })
 370        })
 371    }
 372
 373    fn serialize(&mut self, cx: &mut ViewContext<Self>) {
 374        let width = self.width;
 375        self.pending_serialization = cx.background().spawn(
 376            async move {
 377                KEY_VALUE_STORE
 378                    .write_kvp(
 379                        PROJECT_PANEL_KEY.into(),
 380                        serde_json::to_string(&SerializedProjectPanel { width })?,
 381                    )
 382                    .await?;
 383                anyhow::Ok(())
 384            }
 385            .log_err(),
 386        );
 387    }
 388
 389    fn deploy_context_menu(
 390        &mut self,
 391        position: Vector2F,
 392        entry_id: ProjectEntryId,
 393        cx: &mut ViewContext<Self>,
 394    ) {
 395        let project = self.project.read(cx);
 396
 397        let worktree_id = if let Some(id) = project.worktree_id_for_entry(entry_id, cx) {
 398            id
 399        } else {
 400            return;
 401        };
 402
 403        self.selection = Some(Selection {
 404            worktree_id,
 405            entry_id,
 406        });
 407
 408        let mut menu_entries = Vec::new();
 409        if let Some((worktree, entry)) = self.selected_entry(cx) {
 410            let is_root = Some(entry) == worktree.root_entry();
 411            if !project.is_remote() {
 412                menu_entries.push(ContextMenuItem::action(
 413                    "Add Folder to Project",
 414                    workspace::AddFolderToProject,
 415                ));
 416                if is_root {
 417                    let project = self.project.clone();
 418                    menu_entries.push(ContextMenuItem::handler("Remove from Project", move |cx| {
 419                        project.update(cx, |project, cx| project.remove_worktree(worktree_id, cx));
 420                    }));
 421                }
 422            }
 423            menu_entries.push(ContextMenuItem::action("New File", NewFile));
 424            menu_entries.push(ContextMenuItem::action("New Folder", NewDirectory));
 425            menu_entries.push(ContextMenuItem::Separator);
 426            menu_entries.push(ContextMenuItem::action("Cut", Cut));
 427            menu_entries.push(ContextMenuItem::action("Copy", Copy));
 428            if let Some(clipboard_entry) = self.clipboard_entry {
 429                if clipboard_entry.worktree_id() == worktree.id() {
 430                    menu_entries.push(ContextMenuItem::action("Paste", Paste));
 431                }
 432            }
 433            menu_entries.push(ContextMenuItem::Separator);
 434            menu_entries.push(ContextMenuItem::action("Copy Path", CopyPath));
 435            menu_entries.push(ContextMenuItem::action(
 436                "Copy Relative Path",
 437                CopyRelativePath,
 438            ));
 439
 440            if entry.is_dir() {
 441                menu_entries.push(ContextMenuItem::Separator);
 442            }
 443            menu_entries.push(ContextMenuItem::action("Reveal in Finder", RevealInFinder));
 444            if entry.is_dir() {
 445                menu_entries.push(ContextMenuItem::action("Open in Terminal", OpenInTerminal));
 446                menu_entries.push(ContextMenuItem::action(
 447                    "Search Inside",
 448                    NewSearchInDirectory,
 449                ));
 450            }
 451
 452            menu_entries.push(ContextMenuItem::Separator);
 453            menu_entries.push(ContextMenuItem::action("Rename", Rename));
 454            if !is_root {
 455                menu_entries.push(ContextMenuItem::action("Delete", Delete));
 456            }
 457        }
 458
 459        self.context_menu.update(cx, |menu, cx| {
 460            menu.show(position, AnchorCorner::TopLeft, menu_entries, cx);
 461        });
 462
 463        cx.notify();
 464    }
 465
 466    fn expand_selected_entry(&mut self, _: &ExpandSelectedEntry, cx: &mut ViewContext<Self>) {
 467        if let Some((worktree, entry)) = self.selected_entry(cx) {
 468            if entry.is_dir() {
 469                let worktree_id = worktree.id();
 470                let entry_id = entry.id;
 471                let expanded_dir_ids =
 472                    if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
 473                        expanded_dir_ids
 474                    } else {
 475                        return;
 476                    };
 477
 478                match expanded_dir_ids.binary_search(&entry_id) {
 479                    Ok(_) => self.select_next(&SelectNext, cx),
 480                    Err(ix) => {
 481                        self.project.update(cx, |project, cx| {
 482                            project.expand_entry(worktree_id, entry_id, cx);
 483                        });
 484
 485                        expanded_dir_ids.insert(ix, entry_id);
 486                        self.update_visible_entries(None, cx);
 487                        cx.notify();
 488                    }
 489                }
 490            }
 491        }
 492    }
 493
 494    fn collapse_selected_entry(&mut self, _: &CollapseSelectedEntry, cx: &mut ViewContext<Self>) {
 495        if let Some((worktree, mut entry)) = self.selected_entry(cx) {
 496            let worktree_id = worktree.id();
 497            let expanded_dir_ids =
 498                if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
 499                    expanded_dir_ids
 500                } else {
 501                    return;
 502                };
 503
 504            loop {
 505                let entry_id = entry.id;
 506                match expanded_dir_ids.binary_search(&entry_id) {
 507                    Ok(ix) => {
 508                        expanded_dir_ids.remove(ix);
 509                        self.update_visible_entries(Some((worktree_id, entry_id)), cx);
 510                        cx.notify();
 511                        break;
 512                    }
 513                    Err(_) => {
 514                        if let Some(parent_entry) =
 515                            entry.path.parent().and_then(|p| worktree.entry_for_path(p))
 516                        {
 517                            entry = parent_entry;
 518                        } else {
 519                            break;
 520                        }
 521                    }
 522                }
 523            }
 524        }
 525    }
 526
 527    pub fn collapse_all_entries(&mut self, _: &CollapseAllEntries, cx: &mut ViewContext<Self>) {
 528        self.expanded_dir_ids.clear();
 529        self.update_visible_entries(None, cx);
 530        cx.notify();
 531    }
 532
 533    fn toggle_expanded(&mut self, entry_id: ProjectEntryId, cx: &mut ViewContext<Self>) {
 534        if let Some(worktree_id) = self.project.read(cx).worktree_id_for_entry(entry_id, cx) {
 535            if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
 536                self.project.update(cx, |project, cx| {
 537                    match expanded_dir_ids.binary_search(&entry_id) {
 538                        Ok(ix) => {
 539                            expanded_dir_ids.remove(ix);
 540                        }
 541                        Err(ix) => {
 542                            project.expand_entry(worktree_id, entry_id, cx);
 543                            expanded_dir_ids.insert(ix, entry_id);
 544                        }
 545                    }
 546                });
 547                self.update_visible_entries(Some((worktree_id, entry_id)), cx);
 548                cx.focus_self();
 549                cx.notify();
 550            }
 551        }
 552    }
 553
 554    fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
 555        if let Some(selection) = self.selection {
 556            let (mut worktree_ix, mut entry_ix, _) =
 557                self.index_for_selection(selection).unwrap_or_default();
 558            if entry_ix > 0 {
 559                entry_ix -= 1;
 560            } else if worktree_ix > 0 {
 561                worktree_ix -= 1;
 562                entry_ix = self.visible_entries[worktree_ix].1.len() - 1;
 563            } else {
 564                return;
 565            }
 566
 567            let (worktree_id, worktree_entries) = &self.visible_entries[worktree_ix];
 568            self.selection = Some(Selection {
 569                worktree_id: *worktree_id,
 570                entry_id: worktree_entries[entry_ix].id,
 571            });
 572            self.autoscroll(cx);
 573            cx.notify();
 574        } else {
 575            self.select_first(cx);
 576        }
 577    }
 578
 579    fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
 580        if let Some(task) = self.confirm_edit(cx) {
 581            return Some(task);
 582        }
 583
 584        None
 585    }
 586
 587    fn open_file(&mut self, _: &Open, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
 588        if let Some((_, entry)) = self.selected_entry(cx) {
 589            if entry.is_file() {
 590                self.open_entry(entry.id, true, cx);
 591            }
 592        }
 593
 594        None
 595    }
 596
 597    fn confirm_edit(&mut self, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
 598        let edit_state = self.edit_state.as_mut()?;
 599        cx.focus_self();
 600
 601        let worktree_id = edit_state.worktree_id;
 602        let is_new_entry = edit_state.is_new_entry;
 603        let is_dir = edit_state.is_dir;
 604        let worktree = self.project.read(cx).worktree_for_id(worktree_id, cx)?;
 605        let entry = worktree.read(cx).entry_for_id(edit_state.entry_id)?.clone();
 606        let filename = self.filename_editor.read(cx).text(cx);
 607
 608        let path_already_exists = |path| worktree.read(cx).entry_for_path(path).is_some();
 609        let edit_task;
 610        let edited_entry_id;
 611        if is_new_entry {
 612            self.selection = Some(Selection {
 613                worktree_id,
 614                entry_id: NEW_ENTRY_ID,
 615            });
 616            let new_path = entry.path.join(&filename.trim_start_matches("/"));
 617            if path_already_exists(new_path.as_path()) {
 618                return None;
 619            }
 620
 621            edited_entry_id = NEW_ENTRY_ID;
 622            edit_task = self.project.update(cx, |project, cx| {
 623                project.create_entry((worktree_id, &new_path), is_dir, cx)
 624            })?;
 625        } else {
 626            let new_path = if let Some(parent) = entry.path.clone().parent() {
 627                parent.join(&filename)
 628            } else {
 629                filename.clone().into()
 630            };
 631            if path_already_exists(new_path.as_path()) {
 632                return None;
 633            }
 634
 635            edited_entry_id = entry.id;
 636            edit_task = self.project.update(cx, |project, cx| {
 637                project.rename_entry(entry.id, new_path.as_path(), cx)
 638            })?;
 639        };
 640
 641        edit_state.processing_filename = Some(filename);
 642        cx.notify();
 643
 644        Some(cx.spawn(|this, mut cx| async move {
 645            let new_entry = edit_task.await;
 646            this.update(&mut cx, |this, cx| {
 647                this.edit_state.take();
 648                cx.notify();
 649            })?;
 650
 651            let new_entry = new_entry?;
 652            this.update(&mut cx, |this, cx| {
 653                if let Some(selection) = &mut this.selection {
 654                    if selection.entry_id == edited_entry_id {
 655                        selection.worktree_id = worktree_id;
 656                        selection.entry_id = new_entry.id;
 657                        this.expand_to_selection(cx);
 658                    }
 659                }
 660                this.update_visible_entries(None, cx);
 661                if is_new_entry && !is_dir {
 662                    this.open_entry(new_entry.id, true, cx);
 663                }
 664                cx.notify();
 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();
 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
 748                .update(cx, |editor, cx| editor.clear(cx));
 749            cx.focus(&self.filename_editor);
 750            self.update_visible_entries(Some((worktree_id, NEW_ENTRY_ID)), cx);
 751            self.autoscroll(cx);
 752            cx.notify();
 753        }
 754    }
 755
 756    fn rename(&mut self, _: &Rename, cx: &mut ViewContext<Self>) {
 757        if let Some(Selection {
 758            worktree_id,
 759            entry_id,
 760        }) = self.selection
 761        {
 762            if let Some(worktree) = self.project.read(cx).worktree_for_id(worktree_id, cx) {
 763                if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
 764                    self.edit_state = Some(EditState {
 765                        worktree_id,
 766                        entry_id,
 767                        is_new_entry: false,
 768                        is_dir: entry.is_dir(),
 769                        processing_filename: None,
 770                    });
 771                    let file_name = entry
 772                        .path
 773                        .file_name()
 774                        .map(|s| s.to_string_lossy())
 775                        .unwrap_or_default()
 776                        .to_string();
 777                    let file_stem = entry.path.file_stem().map(|s| s.to_string_lossy());
 778                    let selection_end =
 779                        file_stem.map_or(file_name.len(), |file_stem| file_stem.len());
 780                    self.filename_editor.update(cx, |editor, cx| {
 781                        editor.set_text(file_name, cx);
 782                        editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
 783                            s.select_ranges([0..selection_end])
 784                        })
 785                    });
 786                    cx.focus(&self.filename_editor);
 787                    self.update_visible_entries(None, cx);
 788                    self.autoscroll(cx);
 789                    cx.notify();
 790                }
 791            }
 792
 793            cx.update_global(|drag_and_drop: &mut DragAndDrop<Workspace>, cx| {
 794                drag_and_drop.cancel_dragging::<ProjectEntryId>(cx);
 795            })
 796        }
 797    }
 798
 799    fn delete(&mut self, _: &Delete, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
 800        let Selection { entry_id, .. } = self.selection?;
 801        let path = self.project.read(cx).path_for_entry(entry_id, cx)?.path;
 802        let file_name = path.file_name()?;
 803
 804        let mut answer = cx.prompt(
 805            PromptLevel::Info,
 806            &format!("Delete {file_name:?}?"),
 807            &["Delete", "Cancel"],
 808        );
 809        Some(cx.spawn(|this, mut cx| async move {
 810            if answer.next().await != Some(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    }
 821
 822    fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
 823        if let Some(selection) = self.selection {
 824            let (mut worktree_ix, mut entry_ix, _) =
 825                self.index_for_selection(selection).unwrap_or_default();
 826            if let Some((_, worktree_entries)) = self.visible_entries.get(worktree_ix) {
 827                if entry_ix + 1 < worktree_entries.len() {
 828                    entry_ix += 1;
 829                } else {
 830                    worktree_ix += 1;
 831                    entry_ix = 0;
 832                }
 833            }
 834
 835            if let Some((worktree_id, worktree_entries)) = self.visible_entries.get(worktree_ix) {
 836                if let Some(entry) = worktree_entries.get(entry_ix) {
 837                    self.selection = Some(Selection {
 838                        worktree_id: *worktree_id,
 839                        entry_id: entry.id,
 840                    });
 841                    self.autoscroll(cx);
 842                    cx.notify();
 843                }
 844            }
 845        } else {
 846            self.select_first(cx);
 847        }
 848    }
 849
 850    fn select_first(&mut self, cx: &mut ViewContext<Self>) {
 851        let worktree = self
 852            .visible_entries
 853            .first()
 854            .and_then(|(worktree_id, _)| self.project.read(cx).worktree_for_id(*worktree_id, cx));
 855        if let Some(worktree) = worktree {
 856            let worktree = worktree.read(cx);
 857            let worktree_id = worktree.id();
 858            if let Some(root_entry) = worktree.root_entry() {
 859                self.selection = Some(Selection {
 860                    worktree_id,
 861                    entry_id: root_entry.id,
 862                });
 863                self.autoscroll(cx);
 864                cx.notify();
 865            }
 866        }
 867    }
 868
 869    fn autoscroll(&mut self, cx: &mut ViewContext<Self>) {
 870        if let Some((_, _, index)) = self.selection.and_then(|s| self.index_for_selection(s)) {
 871            self.list.scroll_to(ScrollTarget::Show(index));
 872            cx.notify();
 873        }
 874    }
 875
 876    fn cut(&mut self, _: &Cut, cx: &mut ViewContext<Self>) {
 877        if let Some((worktree, entry)) = self.selected_entry(cx) {
 878            self.clipboard_entry = Some(ClipboardEntry::Cut {
 879                worktree_id: worktree.id(),
 880                entry_id: entry.id,
 881            });
 882            cx.notify();
 883        }
 884    }
 885
 886    fn copy(&mut self, _: &Copy, cx: &mut ViewContext<Self>) {
 887        if let Some((worktree, entry)) = self.selected_entry(cx) {
 888            self.clipboard_entry = Some(ClipboardEntry::Copied {
 889                worktree_id: worktree.id(),
 890                entry_id: entry.id,
 891            });
 892            cx.notify();
 893        }
 894    }
 895
 896    fn paste(&mut self, _: &Paste, cx: &mut ViewContext<Self>) -> Option<()> {
 897        if let Some((worktree, entry)) = self.selected_entry(cx) {
 898            let clipboard_entry = self.clipboard_entry?;
 899            if clipboard_entry.worktree_id() != worktree.id() {
 900                return None;
 901            }
 902
 903            let clipboard_entry_file_name = self
 904                .project
 905                .read(cx)
 906                .path_for_entry(clipboard_entry.entry_id(), cx)?
 907                .path
 908                .file_name()?
 909                .to_os_string();
 910
 911            let mut new_path = entry.path.to_path_buf();
 912            if entry.is_file() {
 913                new_path.pop();
 914            }
 915
 916            new_path.push(&clipboard_entry_file_name);
 917            let extension = new_path.extension().map(|e| e.to_os_string());
 918            let file_name_without_extension = Path::new(&clipboard_entry_file_name).file_stem()?;
 919            let mut ix = 0;
 920            while worktree.entry_for_path(&new_path).is_some() {
 921                new_path.pop();
 922
 923                let mut new_file_name = file_name_without_extension.to_os_string();
 924                new_file_name.push(" copy");
 925                if ix > 0 {
 926                    new_file_name.push(format!(" {}", ix));
 927                }
 928                if let Some(extension) = extension.as_ref() {
 929                    new_file_name.push(".");
 930                    new_file_name.push(extension);
 931                }
 932
 933                new_path.push(new_file_name);
 934                ix += 1;
 935            }
 936
 937            if clipboard_entry.is_cut() {
 938                if let Some(task) = self.project.update(cx, |project, cx| {
 939                    project.rename_entry(clipboard_entry.entry_id(), new_path, cx)
 940                }) {
 941                    task.detach_and_log_err(cx)
 942                }
 943            } else if let Some(task) = self.project.update(cx, |project, cx| {
 944                project.copy_entry(clipboard_entry.entry_id(), new_path, cx)
 945            }) {
 946                task.detach_and_log_err(cx)
 947            }
 948        }
 949        None
 950    }
 951
 952    fn copy_path(&mut self, _: &CopyPath, cx: &mut ViewContext<Self>) {
 953        if let Some((worktree, entry)) = self.selected_entry(cx) {
 954            cx.write_to_clipboard(ClipboardItem::new(
 955                worktree
 956                    .abs_path()
 957                    .join(&entry.path)
 958                    .to_string_lossy()
 959                    .to_string(),
 960            ));
 961        }
 962    }
 963
 964    fn copy_relative_path(&mut self, _: &CopyRelativePath, cx: &mut ViewContext<Self>) {
 965        if let Some((_, entry)) = self.selected_entry(cx) {
 966            cx.write_to_clipboard(ClipboardItem::new(entry.path.to_string_lossy().to_string()));
 967        }
 968    }
 969
 970    fn reveal_in_finder(&mut self, _: &RevealInFinder, cx: &mut ViewContext<Self>) {
 971        if let Some((worktree, entry)) = self.selected_entry(cx) {
 972            cx.reveal_path(&worktree.abs_path().join(&entry.path));
 973        }
 974    }
 975
 976    fn open_in_terminal(&mut self, _: &OpenInTerminal, cx: &mut ViewContext<Self>) {
 977        if let Some((worktree, entry)) = self.selected_entry(cx) {
 978            let window = cx.window();
 979            let view_id = cx.view_id();
 980            let path = worktree.abs_path().join(&entry.path);
 981
 982            cx.app_context()
 983                .spawn(|mut cx| async move {
 984                    window.dispatch_action(
 985                        view_id,
 986                        &workspace::OpenTerminal {
 987                            working_directory: path,
 988                        },
 989                        &mut cx,
 990                    );
 991                })
 992                .detach();
 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((_, entry)) = self.selected_entry(cx) {
1002            if entry.is_dir() {
1003                cx.emit(Event::NewSearchInDirectory {
1004                    dir_entry: entry.clone(),
1005                });
1006            }
1007        }
1008    }
1009
1010    fn move_entry(
1011        &mut self,
1012        entry_to_move: ProjectEntryId,
1013        destination: ProjectEntryId,
1014        destination_is_file: bool,
1015        cx: &mut ViewContext<Self>,
1016    ) {
1017        let destination_worktree = self.project.update(cx, |project, cx| {
1018            let entry_path = project.path_for_entry(entry_to_move, cx)?;
1019            let destination_entry_path = project.path_for_entry(destination, cx)?.path.clone();
1020
1021            let mut destination_path = destination_entry_path.as_ref();
1022            if destination_is_file {
1023                destination_path = destination_path.parent()?;
1024            }
1025
1026            let mut new_path = destination_path.to_path_buf();
1027            new_path.push(entry_path.path.file_name()?);
1028            if new_path != entry_path.path.as_ref() {
1029                let task = project.rename_entry(entry_to_move, new_path, cx)?;
1030                cx.foreground().spawn(task).detach_and_log_err(cx);
1031            }
1032
1033            Some(project.worktree_id_for_entry(destination, cx)?)
1034        });
1035
1036        if let Some(destination_worktree) = destination_worktree {
1037            self.expand_entry(destination_worktree, destination, cx);
1038        }
1039    }
1040
1041    fn index_for_selection(&self, selection: Selection) -> Option<(usize, usize, usize)> {
1042        let mut entry_index = 0;
1043        let mut visible_entries_index = 0;
1044        for (worktree_index, (worktree_id, worktree_entries)) in
1045            self.visible_entries.iter().enumerate()
1046        {
1047            if *worktree_id == selection.worktree_id {
1048                for entry in worktree_entries {
1049                    if entry.id == selection.entry_id {
1050                        return Some((worktree_index, entry_index, visible_entries_index));
1051                    } else {
1052                        visible_entries_index += 1;
1053                        entry_index += 1;
1054                    }
1055                }
1056                break;
1057            } else {
1058                visible_entries_index += worktree_entries.len();
1059            }
1060        }
1061        None
1062    }
1063
1064    pub fn selected_entry<'a>(
1065        &self,
1066        cx: &'a AppContext,
1067    ) -> Option<(&'a Worktree, &'a project::Entry)> {
1068        let (worktree, entry) = self.selected_entry_handle(cx)?;
1069        Some((worktree.read(cx), entry))
1070    }
1071
1072    fn selected_entry_handle<'a>(
1073        &self,
1074        cx: &'a AppContext,
1075    ) -> Option<(ModelHandle<Worktree>, &'a project::Entry)> {
1076        let selection = self.selection?;
1077        let project = self.project.read(cx);
1078        let worktree = project.worktree_for_id(selection.worktree_id, cx)?;
1079        let entry = worktree.read(cx).entry_for_id(selection.entry_id)?;
1080        Some((worktree, entry))
1081    }
1082
1083    fn expand_to_selection(&mut self, cx: &mut ViewContext<Self>) -> Option<()> {
1084        let (worktree, entry) = self.selected_entry(cx)?;
1085        let expanded_dir_ids = self.expanded_dir_ids.entry(worktree.id()).or_default();
1086
1087        for path in entry.path.ancestors() {
1088            let Some(entry) = worktree.entry_for_path(path) else {
1089                continue;
1090            };
1091            if entry.is_dir() {
1092                if let Err(idx) = expanded_dir_ids.binary_search(&entry.id) {
1093                    expanded_dir_ids.insert(idx, entry.id);
1094                }
1095            }
1096        }
1097
1098        Some(())
1099    }
1100
1101    fn update_visible_entries(
1102        &mut self,
1103        new_selected_entry: Option<(WorktreeId, ProjectEntryId)>,
1104        cx: &mut ViewContext<Self>,
1105    ) {
1106        let project = self.project.read(cx);
1107        self.last_worktree_root_id = project
1108            .visible_worktrees(cx)
1109            .rev()
1110            .next()
1111            .and_then(|worktree| worktree.read(cx).root_entry())
1112            .map(|entry| entry.id);
1113
1114        self.visible_entries.clear();
1115        for worktree in project.visible_worktrees(cx) {
1116            let snapshot = worktree.read(cx).snapshot();
1117            let worktree_id = snapshot.id();
1118
1119            let expanded_dir_ids = match self.expanded_dir_ids.entry(worktree_id) {
1120                hash_map::Entry::Occupied(e) => e.into_mut(),
1121                hash_map::Entry::Vacant(e) => {
1122                    // The first time a worktree's root entry becomes available,
1123                    // mark that root entry as expanded.
1124                    if let Some(entry) = snapshot.root_entry() {
1125                        e.insert(vec![entry.id]).as_slice()
1126                    } else {
1127                        &[]
1128                    }
1129                }
1130            };
1131
1132            let mut new_entry_parent_id = None;
1133            let mut new_entry_kind = EntryKind::Dir;
1134            if let Some(edit_state) = &self.edit_state {
1135                if edit_state.worktree_id == worktree_id && edit_state.is_new_entry {
1136                    new_entry_parent_id = Some(edit_state.entry_id);
1137                    new_entry_kind = if edit_state.is_dir {
1138                        EntryKind::Dir
1139                    } else {
1140                        EntryKind::File(Default::default())
1141                    };
1142                }
1143            }
1144
1145            let mut visible_worktree_entries = Vec::new();
1146            let mut entry_iter = snapshot.entries(true);
1147
1148            while let Some(entry) = entry_iter.entry() {
1149                visible_worktree_entries.push(entry.clone());
1150                if Some(entry.id) == new_entry_parent_id {
1151                    visible_worktree_entries.push(Entry {
1152                        id: NEW_ENTRY_ID,
1153                        kind: new_entry_kind,
1154                        path: entry.path.join("\0").into(),
1155                        inode: 0,
1156                        mtime: entry.mtime,
1157                        is_symlink: false,
1158                        is_ignored: false,
1159                        is_external: false,
1160                        git_status: entry.git_status,
1161                    });
1162                }
1163                if expanded_dir_ids.binary_search(&entry.id).is_err()
1164                    && entry_iter.advance_to_sibling()
1165                {
1166                    continue;
1167                }
1168                entry_iter.advance();
1169            }
1170
1171            snapshot.propagate_git_statuses(&mut visible_worktree_entries);
1172
1173            visible_worktree_entries.sort_by(|entry_a, entry_b| {
1174                let mut components_a = entry_a.path.components().peekable();
1175                let mut components_b = entry_b.path.components().peekable();
1176                loop {
1177                    match (components_a.next(), components_b.next()) {
1178                        (Some(component_a), Some(component_b)) => {
1179                            let a_is_file = components_a.peek().is_none() && entry_a.is_file();
1180                            let b_is_file = components_b.peek().is_none() && entry_b.is_file();
1181                            let ordering = a_is_file.cmp(&b_is_file).then_with(|| {
1182                                let name_a =
1183                                    UniCase::new(component_a.as_os_str().to_string_lossy());
1184                                let name_b =
1185                                    UniCase::new(component_b.as_os_str().to_string_lossy());
1186                                name_a.cmp(&name_b)
1187                            });
1188                            if !ordering.is_eq() {
1189                                return ordering;
1190                            }
1191                        }
1192                        (Some(_), None) => break Ordering::Greater,
1193                        (None, Some(_)) => break Ordering::Less,
1194                        (None, None) => break Ordering::Equal,
1195                    }
1196                }
1197            });
1198            self.visible_entries
1199                .push((worktree_id, visible_worktree_entries));
1200        }
1201
1202        if let Some((worktree_id, entry_id)) = new_selected_entry {
1203            self.selection = Some(Selection {
1204                worktree_id,
1205                entry_id,
1206            });
1207        }
1208    }
1209
1210    fn expand_entry(
1211        &mut self,
1212        worktree_id: WorktreeId,
1213        entry_id: ProjectEntryId,
1214        cx: &mut ViewContext<Self>,
1215    ) {
1216        self.project.update(cx, |project, cx| {
1217            if let Some((worktree, expanded_dir_ids)) = project
1218                .worktree_for_id(worktree_id, cx)
1219                .zip(self.expanded_dir_ids.get_mut(&worktree_id))
1220            {
1221                project.expand_entry(worktree_id, entry_id, cx);
1222                let worktree = worktree.read(cx);
1223
1224                if let Some(mut entry) = worktree.entry_for_id(entry_id) {
1225                    loop {
1226                        if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
1227                            expanded_dir_ids.insert(ix, entry.id);
1228                        }
1229
1230                        if let Some(parent_entry) =
1231                            entry.path.parent().and_then(|p| worktree.entry_for_path(p))
1232                        {
1233                            entry = parent_entry;
1234                        } else {
1235                            break;
1236                        }
1237                    }
1238                }
1239            }
1240        });
1241    }
1242
1243    fn for_each_visible_entry(
1244        &self,
1245        range: Range<usize>,
1246        cx: &mut ViewContext<ProjectPanel>,
1247        mut callback: impl FnMut(ProjectEntryId, EntryDetails, &mut ViewContext<ProjectPanel>),
1248    ) {
1249        let mut ix = 0;
1250        for (worktree_id, visible_worktree_entries) in &self.visible_entries {
1251            if ix >= range.end {
1252                return;
1253            }
1254
1255            if ix + visible_worktree_entries.len() <= range.start {
1256                ix += visible_worktree_entries.len();
1257                continue;
1258            }
1259
1260            let end_ix = range.end.min(ix + visible_worktree_entries.len());
1261            let (git_status_setting, show_file_icons, show_folder_icons) = {
1262                let settings = settings::get::<ProjectPanelSettings>(cx);
1263                (
1264                    settings.git_status,
1265                    settings.file_icons,
1266                    settings.folder_icons,
1267                )
1268            };
1269            if let Some(worktree) = self.project.read(cx).worktree_for_id(*worktree_id, cx) {
1270                let snapshot = worktree.read(cx).snapshot();
1271                let root_name = OsStr::new(snapshot.root_name());
1272                let expanded_entry_ids = self
1273                    .expanded_dir_ids
1274                    .get(&snapshot.id())
1275                    .map(Vec::as_slice)
1276                    .unwrap_or(&[]);
1277
1278                let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
1279                for entry in visible_worktree_entries[entry_range].iter() {
1280                    let status = git_status_setting.then(|| entry.git_status).flatten();
1281                    let is_expanded = expanded_entry_ids.binary_search(&entry.id).is_ok();
1282                    let icon = match entry.kind {
1283                        EntryKind::File(_) => {
1284                            if show_file_icons {
1285                                Some(FileAssociations::get_icon(&entry.path, cx))
1286                            } else {
1287                                None
1288                            }
1289                        }
1290                        _ => {
1291                            if show_folder_icons {
1292                                Some(FileAssociations::get_folder_icon(is_expanded, cx))
1293                            } else {
1294                                Some(FileAssociations::get_chevron_icon(is_expanded, cx))
1295                            }
1296                        }
1297                    };
1298
1299                    let mut details = EntryDetails {
1300                        filename: entry
1301                            .path
1302                            .file_name()
1303                            .unwrap_or(root_name)
1304                            .to_string_lossy()
1305                            .to_string(),
1306                        icon,
1307                        path: entry.path.clone(),
1308                        depth: entry.path.components().count(),
1309                        kind: entry.kind,
1310                        is_ignored: entry.is_ignored,
1311                        is_expanded,
1312                        is_selected: self.selection.map_or(false, |e| {
1313                            e.worktree_id == snapshot.id() && e.entry_id == entry.id
1314                        }),
1315                        is_editing: false,
1316                        is_processing: false,
1317                        is_cut: self
1318                            .clipboard_entry
1319                            .map_or(false, |e| e.is_cut() && e.entry_id() == entry.id),
1320                        git_status: status,
1321                    };
1322
1323                    if let Some(edit_state) = &self.edit_state {
1324                        let is_edited_entry = if edit_state.is_new_entry {
1325                            entry.id == NEW_ENTRY_ID
1326                        } else {
1327                            entry.id == edit_state.entry_id
1328                        };
1329
1330                        if is_edited_entry {
1331                            if let Some(processing_filename) = &edit_state.processing_filename {
1332                                details.is_processing = true;
1333                                details.filename.clear();
1334                                details.filename.push_str(processing_filename);
1335                            } else {
1336                                if edit_state.is_new_entry {
1337                                    details.filename.clear();
1338                                }
1339                                details.is_editing = true;
1340                            }
1341                        }
1342                    }
1343
1344                    callback(entry.id, details, cx);
1345                }
1346            }
1347            ix = end_ix;
1348        }
1349    }
1350
1351    fn render_entry_visual_element<V: 'static>(
1352        details: &EntryDetails,
1353        editor: Option<&ViewHandle<Editor>>,
1354        padding: f32,
1355        row_container_style: ContainerStyle,
1356        style: &ProjectPanelEntry,
1357        cx: &mut ViewContext<V>,
1358    ) -> AnyElement<V> {
1359        let show_editor = details.is_editing && !details.is_processing;
1360
1361        let mut filename_text_style = style.text.clone();
1362        filename_text_style.color = details
1363            .git_status
1364            .as_ref()
1365            .map(|status| match status {
1366                GitFileStatus::Added => style.status.git.inserted,
1367                GitFileStatus::Modified => style.status.git.modified,
1368                GitFileStatus::Conflict => style.status.git.conflict,
1369            })
1370            .unwrap_or(style.text.color);
1371
1372        Flex::row()
1373            .with_child(if let Some(icon) = &details.icon {
1374                Svg::new(icon.to_string())
1375                    .with_color(style.icon_color)
1376                    .constrained()
1377                    .with_max_width(style.icon_size)
1378                    .with_max_height(style.icon_size)
1379                    .aligned()
1380                    .constrained()
1381                    .with_width(style.icon_size)
1382            } else {
1383                Empty::new()
1384                    .constrained()
1385                    .with_max_width(style.icon_size)
1386                    .with_max_height(style.icon_size)
1387                    .aligned()
1388                    .constrained()
1389                    .with_width(style.icon_size)
1390            })
1391            .with_child(if show_editor && editor.is_some() {
1392                ChildView::new(editor.as_ref().unwrap(), cx)
1393                    .contained()
1394                    .with_margin_left(style.icon_spacing)
1395                    .aligned()
1396                    .left()
1397                    .flex(1.0, true)
1398                    .into_any()
1399            } else {
1400                Label::new(details.filename.clone(), filename_text_style)
1401                    .contained()
1402                    .with_margin_left(style.icon_spacing)
1403                    .aligned()
1404                    .left()
1405                    .into_any()
1406            })
1407            .constrained()
1408            .with_height(style.height)
1409            .contained()
1410            .with_style(row_container_style)
1411            .with_padding_left(padding)
1412            .into_any_named("project panel entry visual element")
1413    }
1414
1415    fn render_entry(
1416        entry_id: ProjectEntryId,
1417        details: EntryDetails,
1418        editor: &ViewHandle<Editor>,
1419        dragged_entry_destination: &mut Option<Arc<Path>>,
1420        theme: &theme::ProjectPanel,
1421        cx: &mut ViewContext<Self>,
1422    ) -> AnyElement<Self> {
1423        let kind = details.kind;
1424        let path = details.path.clone();
1425        let settings = settings::get::<ProjectPanelSettings>(cx);
1426        let padding = theme.container.padding.left + details.depth as f32 * settings.indent_size;
1427
1428        let entry_style = if details.is_cut {
1429            &theme.cut_entry
1430        } else if details.is_ignored {
1431            &theme.ignored_entry
1432        } else {
1433            &theme.entry
1434        };
1435
1436        let show_editor = details.is_editing && !details.is_processing;
1437
1438        MouseEventHandler::new::<Self, _>(entry_id.to_usize(), cx, |state, cx| {
1439            let mut style = entry_style
1440                .in_state(details.is_selected)
1441                .style_for(state)
1442                .clone();
1443
1444            if cx
1445                .global::<DragAndDrop<Workspace>>()
1446                .currently_dragged::<ProjectEntryId>(cx.window())
1447                .is_some()
1448                && dragged_entry_destination
1449                    .as_ref()
1450                    .filter(|destination| details.path.starts_with(destination))
1451                    .is_some()
1452            {
1453                style = entry_style.active_state().default.clone();
1454            }
1455
1456            let row_container_style = if show_editor {
1457                theme.filename_editor.container
1458            } else {
1459                style.container
1460            };
1461
1462            Self::render_entry_visual_element(
1463                &details,
1464                Some(editor),
1465                padding,
1466                row_container_style,
1467                &style,
1468                cx,
1469            )
1470        })
1471        .on_click(MouseButton::Left, move |event, this, cx| {
1472            if !show_editor {
1473                if kind.is_dir() {
1474                    this.toggle_expanded(entry_id, cx);
1475                } else {
1476                    if event.cmd {
1477                        this.split_entry(entry_id, cx);
1478                    } else if !event.cmd {
1479                        this.open_entry(entry_id, event.click_count > 1, cx);
1480                    }
1481                }
1482            }
1483        })
1484        .on_down(MouseButton::Right, move |event, this, cx| {
1485            this.deploy_context_menu(event.position, entry_id, cx);
1486        })
1487        .on_up(MouseButton::Left, move |_, this, cx| {
1488            if let Some((_, dragged_entry)) = cx
1489                .global::<DragAndDrop<Workspace>>()
1490                .currently_dragged::<ProjectEntryId>(cx.window())
1491            {
1492                this.move_entry(
1493                    *dragged_entry,
1494                    entry_id,
1495                    matches!(details.kind, EntryKind::File(_)),
1496                    cx,
1497                );
1498            }
1499        })
1500        .on_move(move |_, this, cx| {
1501            if cx
1502                .global::<DragAndDrop<Workspace>>()
1503                .currently_dragged::<ProjectEntryId>(cx.window())
1504                .is_some()
1505            {
1506                this.dragged_entry_destination = if matches!(kind, EntryKind::File(_)) {
1507                    path.parent().map(|parent| Arc::from(parent))
1508                } else {
1509                    Some(path.clone())
1510                };
1511            }
1512        })
1513        .as_draggable(entry_id, {
1514            let row_container_style = theme.dragged_entry.container;
1515
1516            move |_, _, cx: &mut ViewContext<Workspace>| {
1517                let theme = theme::current(cx).clone();
1518                Self::render_entry_visual_element(
1519                    &details,
1520                    None,
1521                    padding,
1522                    row_container_style,
1523                    &theme.project_panel.dragged_entry,
1524                    cx,
1525                )
1526            }
1527        })
1528        .with_cursor_style(CursorStyle::PointingHand)
1529        .into_any_named("project panel entry")
1530    }
1531}
1532
1533impl View for ProjectPanel {
1534    fn ui_name() -> &'static str {
1535        "ProjectPanel"
1536    }
1537
1538    fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> gpui::AnyElement<Self> {
1539        enum ProjectPanel {}
1540        let theme = &theme::current(cx).project_panel;
1541        let mut container_style = theme.container;
1542        let padding = std::mem::take(&mut container_style.padding);
1543        let last_worktree_root_id = self.last_worktree_root_id;
1544
1545        let has_worktree = self.visible_entries.len() != 0;
1546
1547        if has_worktree {
1548            Stack::new()
1549                .with_child(
1550                    MouseEventHandler::new::<ProjectPanel, _>(0, cx, |_, cx| {
1551                        UniformList::new(
1552                            self.list.clone(),
1553                            self.visible_entries
1554                                .iter()
1555                                .map(|(_, worktree_entries)| worktree_entries.len())
1556                                .sum(),
1557                            cx,
1558                            move |this, range, items, cx| {
1559                                let theme = theme::current(cx).clone();
1560                                let mut dragged_entry_destination =
1561                                    this.dragged_entry_destination.clone();
1562                                this.for_each_visible_entry(range, cx, |id, details, cx| {
1563                                    items.push(Self::render_entry(
1564                                        id,
1565                                        details,
1566                                        &this.filename_editor,
1567                                        &mut dragged_entry_destination,
1568                                        &theme.project_panel,
1569                                        cx,
1570                                    ));
1571                                });
1572                                this.dragged_entry_destination = dragged_entry_destination;
1573                            },
1574                        )
1575                        .with_padding_top(padding.top)
1576                        .with_padding_bottom(padding.bottom)
1577                        .contained()
1578                        .with_style(container_style)
1579                        .expanded()
1580                    })
1581                    .on_down(MouseButton::Right, move |event, this, cx| {
1582                        // When deploying the context menu anywhere below the last project entry,
1583                        // act as if the user clicked the root of the last worktree.
1584                        if let Some(entry_id) = last_worktree_root_id {
1585                            this.deploy_context_menu(event.position, entry_id, cx);
1586                        }
1587                    }),
1588                )
1589                .with_child(ChildView::new(&self.context_menu, cx))
1590                .into_any_named("project panel")
1591        } else {
1592            Flex::column()
1593                .with_child(
1594                    MouseEventHandler::new::<Self, _>(2, cx, {
1595                        let button_style = theme.open_project_button.clone();
1596                        let context_menu_item_style = theme::current(cx).context_menu.item.clone();
1597                        move |state, cx| {
1598                            let button_style = button_style.style_for(state).clone();
1599                            let context_menu_item = context_menu_item_style
1600                                .active_state()
1601                                .style_for(state)
1602                                .clone();
1603
1604                            theme::ui::keystroke_label(
1605                                "Open a project",
1606                                &button_style,
1607                                &context_menu_item.keystroke,
1608                                Box::new(workspace::Open),
1609                                cx,
1610                            )
1611                        }
1612                    })
1613                    .on_click(MouseButton::Left, move |_, this, cx| {
1614                        if let Some(workspace) = this.workspace.upgrade(cx) {
1615                            workspace.update(cx, |workspace, cx| {
1616                                if let Some(task) = workspace.open(&Default::default(), cx) {
1617                                    task.detach_and_log_err(cx);
1618                                }
1619                            })
1620                        }
1621                    })
1622                    .with_cursor_style(CursorStyle::PointingHand),
1623                )
1624                .contained()
1625                .with_style(container_style)
1626                .into_any_named("empty project panel")
1627        }
1628    }
1629
1630    fn update_keymap_context(&self, keymap: &mut KeymapContext, _: &AppContext) {
1631        Self::reset_to_default_keymap_context(keymap);
1632        keymap.add_identifier("menu");
1633    }
1634
1635    fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
1636        if !self.has_focus {
1637            self.has_focus = true;
1638            cx.emit(Event::Focus);
1639        }
1640    }
1641
1642    fn focus_out(&mut self, _: gpui::AnyViewHandle, _: &mut ViewContext<Self>) {
1643        self.has_focus = false;
1644    }
1645}
1646
1647impl Entity for ProjectPanel {
1648    type Event = Event;
1649}
1650
1651impl workspace::dock::Panel for ProjectPanel {
1652    fn position(&self, cx: &WindowContext) -> DockPosition {
1653        match settings::get::<ProjectPanelSettings>(cx).dock {
1654            ProjectPanelDockPosition::Left => DockPosition::Left,
1655            ProjectPanelDockPosition::Right => DockPosition::Right,
1656        }
1657    }
1658
1659    fn position_is_valid(&self, position: DockPosition) -> bool {
1660        matches!(position, DockPosition::Left | DockPosition::Right)
1661    }
1662
1663    fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
1664        settings::update_settings_file::<ProjectPanelSettings>(
1665            self.fs.clone(),
1666            cx,
1667            move |settings| {
1668                let dock = match position {
1669                    DockPosition::Left | DockPosition::Bottom => ProjectPanelDockPosition::Left,
1670                    DockPosition::Right => ProjectPanelDockPosition::Right,
1671                };
1672                settings.dock = Some(dock);
1673            },
1674        );
1675    }
1676
1677    fn size(&self, cx: &WindowContext) -> f32 {
1678        self.width
1679            .unwrap_or_else(|| settings::get::<ProjectPanelSettings>(cx).default_width)
1680    }
1681
1682    fn set_size(&mut self, size: Option<f32>, cx: &mut ViewContext<Self>) {
1683        self.width = size;
1684        self.serialize(cx);
1685        cx.notify();
1686    }
1687
1688    fn icon_path(&self, _: &WindowContext) -> Option<&'static str> {
1689        Some("icons/project.svg")
1690    }
1691
1692    fn icon_tooltip(&self) -> (String, Option<Box<dyn Action>>) {
1693        ("Project Panel".into(), Some(Box::new(ToggleFocus)))
1694    }
1695
1696    fn should_change_position_on_event(event: &Self::Event) -> bool {
1697        matches!(event, Event::DockPositionChanged)
1698    }
1699
1700    fn has_focus(&self, _: &WindowContext) -> bool {
1701        self.has_focus
1702    }
1703
1704    fn is_focus_event(event: &Self::Event) -> bool {
1705        matches!(event, Event::Focus)
1706    }
1707}
1708
1709impl ClipboardEntry {
1710    fn is_cut(&self) -> bool {
1711        matches!(self, Self::Cut { .. })
1712    }
1713
1714    fn entry_id(&self) -> ProjectEntryId {
1715        match self {
1716            ClipboardEntry::Copied { entry_id, .. } | ClipboardEntry::Cut { entry_id, .. } => {
1717                *entry_id
1718            }
1719        }
1720    }
1721
1722    fn worktree_id(&self) -> WorktreeId {
1723        match self {
1724            ClipboardEntry::Copied { worktree_id, .. }
1725            | ClipboardEntry::Cut { worktree_id, .. } => *worktree_id,
1726        }
1727    }
1728}
1729
1730#[cfg(test)]
1731mod tests {
1732    use super::*;
1733    use gpui::{AnyWindowHandle, TestAppContext, ViewHandle, WindowHandle};
1734    use pretty_assertions::assert_eq;
1735    use project::FakeFs;
1736    use serde_json::json;
1737    use settings::SettingsStore;
1738    use std::{
1739        collections::HashSet,
1740        path::{Path, PathBuf},
1741        sync::atomic::{self, AtomicUsize},
1742    };
1743    use workspace::{pane, AppState};
1744
1745    #[gpui::test]
1746    async fn test_visible_list(cx: &mut gpui::TestAppContext) {
1747        init_test(cx);
1748
1749        let fs = FakeFs::new(cx.background());
1750        fs.insert_tree(
1751            "/root1",
1752            json!({
1753                ".dockerignore": "",
1754                ".git": {
1755                    "HEAD": "",
1756                },
1757                "a": {
1758                    "0": { "q": "", "r": "", "s": "" },
1759                    "1": { "t": "", "u": "" },
1760                    "2": { "v": "", "w": "", "x": "", "y": "" },
1761                },
1762                "b": {
1763                    "3": { "Q": "" },
1764                    "4": { "R": "", "S": "", "T": "", "U": "" },
1765                },
1766                "C": {
1767                    "5": {},
1768                    "6": { "V": "", "W": "" },
1769                    "7": { "X": "" },
1770                    "8": { "Y": {}, "Z": "" }
1771                }
1772            }),
1773        )
1774        .await;
1775        fs.insert_tree(
1776            "/root2",
1777            json!({
1778                "d": {
1779                    "9": ""
1780                },
1781                "e": {}
1782            }),
1783        )
1784        .await;
1785
1786        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1787        let workspace = cx
1788            .add_window(|cx| Workspace::test_new(project.clone(), cx))
1789            .root(cx);
1790        let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
1791        assert_eq!(
1792            visible_entries_as_strings(&panel, 0..50, cx),
1793            &[
1794                "v root1",
1795                "    > .git",
1796                "    > a",
1797                "    > b",
1798                "    > C",
1799                "      .dockerignore",
1800                "v root2",
1801                "    > d",
1802                "    > e",
1803            ]
1804        );
1805
1806        toggle_expand_dir(&panel, "root1/b", cx);
1807        assert_eq!(
1808            visible_entries_as_strings(&panel, 0..50, cx),
1809            &[
1810                "v root1",
1811                "    > .git",
1812                "    > a",
1813                "    v b  <== selected",
1814                "        > 3",
1815                "        > 4",
1816                "    > C",
1817                "      .dockerignore",
1818                "v root2",
1819                "    > d",
1820                "    > e",
1821            ]
1822        );
1823
1824        assert_eq!(
1825            visible_entries_as_strings(&panel, 6..9, cx),
1826            &[
1827                //
1828                "    > C",
1829                "      .dockerignore",
1830                "v root2",
1831            ]
1832        );
1833    }
1834
1835    #[gpui::test(iterations = 30)]
1836    async fn test_editing_files(cx: &mut gpui::TestAppContext) {
1837        init_test(cx);
1838
1839        let fs = FakeFs::new(cx.background());
1840        fs.insert_tree(
1841            "/root1",
1842            json!({
1843                ".dockerignore": "",
1844                ".git": {
1845                    "HEAD": "",
1846                },
1847                "a": {
1848                    "0": { "q": "", "r": "", "s": "" },
1849                    "1": { "t": "", "u": "" },
1850                    "2": { "v": "", "w": "", "x": "", "y": "" },
1851                },
1852                "b": {
1853                    "3": { "Q": "" },
1854                    "4": { "R": "", "S": "", "T": "", "U": "" },
1855                },
1856                "C": {
1857                    "5": {},
1858                    "6": { "V": "", "W": "" },
1859                    "7": { "X": "" },
1860                    "8": { "Y": {}, "Z": "" }
1861                }
1862            }),
1863        )
1864        .await;
1865        fs.insert_tree(
1866            "/root2",
1867            json!({
1868                "d": {
1869                    "9": ""
1870                },
1871                "e": {}
1872            }),
1873        )
1874        .await;
1875
1876        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1877        let window = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1878        let workspace = window.root(cx);
1879        let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
1880
1881        select_path(&panel, "root1", cx);
1882        assert_eq!(
1883            visible_entries_as_strings(&panel, 0..10, cx),
1884            &[
1885                "v root1  <== selected",
1886                "    > .git",
1887                "    > a",
1888                "    > b",
1889                "    > C",
1890                "      .dockerignore",
1891                "v root2",
1892                "    > d",
1893                "    > e",
1894            ]
1895        );
1896
1897        // Add a file with the root folder selected. The filename editor is placed
1898        // before the first file in the root folder.
1899        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
1900        window.read_with(cx, |cx| {
1901            let panel = panel.read(cx);
1902            assert!(panel.filename_editor.is_focused(cx));
1903        });
1904        assert_eq!(
1905            visible_entries_as_strings(&panel, 0..10, cx),
1906            &[
1907                "v root1",
1908                "    > .git",
1909                "    > a",
1910                "    > b",
1911                "    > C",
1912                "      [EDITOR: '']  <== selected",
1913                "      .dockerignore",
1914                "v root2",
1915                "    > d",
1916                "    > e",
1917            ]
1918        );
1919
1920        let confirm = panel.update(cx, |panel, cx| {
1921            panel
1922                .filename_editor
1923                .update(cx, |editor, cx| editor.set_text("the-new-filename", cx));
1924            panel.confirm(&Confirm, cx).unwrap()
1925        });
1926        assert_eq!(
1927            visible_entries_as_strings(&panel, 0..10, cx),
1928            &[
1929                "v root1",
1930                "    > .git",
1931                "    > a",
1932                "    > b",
1933                "    > C",
1934                "      [PROCESSING: 'the-new-filename']  <== selected",
1935                "      .dockerignore",
1936                "v root2",
1937                "    > d",
1938                "    > e",
1939            ]
1940        );
1941
1942        confirm.await.unwrap();
1943        assert_eq!(
1944            visible_entries_as_strings(&panel, 0..10, cx),
1945            &[
1946                "v root1",
1947                "    > .git",
1948                "    > a",
1949                "    > b",
1950                "    > C",
1951                "      .dockerignore",
1952                "      the-new-filename  <== selected",
1953                "v root2",
1954                "    > d",
1955                "    > e",
1956            ]
1957        );
1958
1959        select_path(&panel, "root1/b", cx);
1960        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
1961        assert_eq!(
1962            visible_entries_as_strings(&panel, 0..10, cx),
1963            &[
1964                "v root1",
1965                "    > .git",
1966                "    > a",
1967                "    v b",
1968                "        > 3",
1969                "        > 4",
1970                "          [EDITOR: '']  <== selected",
1971                "    > C",
1972                "      .dockerignore",
1973                "      the-new-filename",
1974            ]
1975        );
1976
1977        panel
1978            .update(cx, |panel, cx| {
1979                panel
1980                    .filename_editor
1981                    .update(cx, |editor, cx| editor.set_text("another-filename.txt", cx));
1982                panel.confirm(&Confirm, cx).unwrap()
1983            })
1984            .await
1985            .unwrap();
1986        assert_eq!(
1987            visible_entries_as_strings(&panel, 0..10, cx),
1988            &[
1989                "v root1",
1990                "    > .git",
1991                "    > a",
1992                "    v b",
1993                "        > 3",
1994                "        > 4",
1995                "          another-filename.txt  <== selected",
1996                "    > C",
1997                "      .dockerignore",
1998                "      the-new-filename",
1999            ]
2000        );
2001
2002        select_path(&panel, "root1/b/another-filename.txt", cx);
2003        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2004        assert_eq!(
2005            visible_entries_as_strings(&panel, 0..10, cx),
2006            &[
2007                "v root1",
2008                "    > .git",
2009                "    > a",
2010                "    v b",
2011                "        > 3",
2012                "        > 4",
2013                "          [EDITOR: 'another-filename.txt']  <== selected",
2014                "    > C",
2015                "      .dockerignore",
2016                "      the-new-filename",
2017            ]
2018        );
2019
2020        let confirm = panel.update(cx, |panel, cx| {
2021            panel.filename_editor.update(cx, |editor, cx| {
2022                let file_name_selections = editor.selections.all::<usize>(cx);
2023                assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
2024                let file_name_selection = &file_name_selections[0];
2025                assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
2026                assert_eq!(file_name_selection.end, "another-filename".len(), "Should not select file extension");
2027
2028                editor.set_text("a-different-filename.tar.gz", cx)
2029            });
2030            panel.confirm(&Confirm, cx).unwrap()
2031        });
2032        assert_eq!(
2033            visible_entries_as_strings(&panel, 0..10, cx),
2034            &[
2035                "v root1",
2036                "    > .git",
2037                "    > a",
2038                "    v b",
2039                "        > 3",
2040                "        > 4",
2041                "          [PROCESSING: 'a-different-filename.tar.gz']  <== selected",
2042                "    > C",
2043                "      .dockerignore",
2044                "      the-new-filename",
2045            ]
2046        );
2047
2048        confirm.await.unwrap();
2049        assert_eq!(
2050            visible_entries_as_strings(&panel, 0..10, cx),
2051            &[
2052                "v root1",
2053                "    > .git",
2054                "    > a",
2055                "    v b",
2056                "        > 3",
2057                "        > 4",
2058                "          a-different-filename.tar.gz  <== selected",
2059                "    > C",
2060                "      .dockerignore",
2061                "      the-new-filename",
2062            ]
2063        );
2064
2065        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2066        assert_eq!(
2067            visible_entries_as_strings(&panel, 0..10, cx),
2068            &[
2069                "v root1",
2070                "    > .git",
2071                "    > a",
2072                "    v b",
2073                "        > 3",
2074                "        > 4",
2075                "          [EDITOR: 'a-different-filename.tar.gz']  <== selected",
2076                "    > C",
2077                "      .dockerignore",
2078                "      the-new-filename",
2079            ]
2080        );
2081
2082        panel.update(cx, |panel, cx| {
2083            panel.filename_editor.update(cx, |editor, cx| {
2084                let file_name_selections = editor.selections.all::<usize>(cx);
2085                assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
2086                let file_name_selection = &file_name_selections[0];
2087                assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
2088                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");
2089
2090            });
2091            panel.cancel(&Cancel, cx)
2092        });
2093
2094        panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2095        assert_eq!(
2096            visible_entries_as_strings(&panel, 0..10, cx),
2097            &[
2098                "v root1",
2099                "    > .git",
2100                "    > a",
2101                "    v b",
2102                "        > [EDITOR: '']  <== selected",
2103                "        > 3",
2104                "        > 4",
2105                "          a-different-filename.tar.gz",
2106                "    > C",
2107                "      .dockerignore",
2108            ]
2109        );
2110
2111        let confirm = panel.update(cx, |panel, cx| {
2112            panel
2113                .filename_editor
2114                .update(cx, |editor, cx| editor.set_text("new-dir", cx));
2115            panel.confirm(&Confirm, cx).unwrap()
2116        });
2117        panel.update(cx, |panel, cx| panel.select_next(&Default::default(), cx));
2118        assert_eq!(
2119            visible_entries_as_strings(&panel, 0..10, cx),
2120            &[
2121                "v root1",
2122                "    > .git",
2123                "    > a",
2124                "    v b",
2125                "        > [PROCESSING: 'new-dir']",
2126                "        > 3  <== selected",
2127                "        > 4",
2128                "          a-different-filename.tar.gz",
2129                "    > C",
2130                "      .dockerignore",
2131            ]
2132        );
2133
2134        confirm.await.unwrap();
2135        assert_eq!(
2136            visible_entries_as_strings(&panel, 0..10, cx),
2137            &[
2138                "v root1",
2139                "    > .git",
2140                "    > a",
2141                "    v b",
2142                "        > 3  <== selected",
2143                "        > 4",
2144                "        > new-dir",
2145                "          a-different-filename.tar.gz",
2146                "    > C",
2147                "      .dockerignore",
2148            ]
2149        );
2150
2151        panel.update(cx, |panel, cx| panel.rename(&Default::default(), cx));
2152        assert_eq!(
2153            visible_entries_as_strings(&panel, 0..10, cx),
2154            &[
2155                "v root1",
2156                "    > .git",
2157                "    > a",
2158                "    v b",
2159                "        > [EDITOR: '3']  <== selected",
2160                "        > 4",
2161                "        > new-dir",
2162                "          a-different-filename.tar.gz",
2163                "    > C",
2164                "      .dockerignore",
2165            ]
2166        );
2167
2168        // Dismiss the rename editor when it loses focus.
2169        workspace.update(cx, |_, cx| cx.focus_self());
2170        assert_eq!(
2171            visible_entries_as_strings(&panel, 0..10, cx),
2172            &[
2173                "v root1",
2174                "    > .git",
2175                "    > a",
2176                "    v b",
2177                "        > 3  <== selected",
2178                "        > 4",
2179                "        > new-dir",
2180                "          a-different-filename.tar.gz",
2181                "    > C",
2182                "      .dockerignore",
2183            ]
2184        );
2185    }
2186
2187    #[gpui::test(iterations = 30)]
2188    async fn test_adding_directories_via_file(cx: &mut gpui::TestAppContext) {
2189        init_test(cx);
2190
2191        let fs = FakeFs::new(cx.background());
2192        fs.insert_tree(
2193            "/root1",
2194            json!({
2195                ".dockerignore": "",
2196                ".git": {
2197                    "HEAD": "",
2198                },
2199                "a": {
2200                    "0": { "q": "", "r": "", "s": "" },
2201                    "1": { "t": "", "u": "" },
2202                    "2": { "v": "", "w": "", "x": "", "y": "" },
2203                },
2204                "b": {
2205                    "3": { "Q": "" },
2206                    "4": { "R": "", "S": "", "T": "", "U": "" },
2207                },
2208                "C": {
2209                    "5": {},
2210                    "6": { "V": "", "W": "" },
2211                    "7": { "X": "" },
2212                    "8": { "Y": {}, "Z": "" }
2213                }
2214            }),
2215        )
2216        .await;
2217        fs.insert_tree(
2218            "/root2",
2219            json!({
2220                "d": {
2221                    "9": ""
2222                },
2223                "e": {}
2224            }),
2225        )
2226        .await;
2227
2228        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
2229        let window = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2230        let workspace = window.root(cx);
2231        let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2232
2233        select_path(&panel, "root1", cx);
2234        assert_eq!(
2235            visible_entries_as_strings(&panel, 0..10, cx),
2236            &[
2237                "v root1  <== selected",
2238                "    > .git",
2239                "    > a",
2240                "    > b",
2241                "    > C",
2242                "      .dockerignore",
2243                "v root2",
2244                "    > d",
2245                "    > e",
2246            ]
2247        );
2248
2249        // Add a file with the root folder selected. The filename editor is placed
2250        // before the first file in the root folder.
2251        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2252        window.read_with(cx, |cx| {
2253            let panel = panel.read(cx);
2254            assert!(panel.filename_editor.is_focused(cx));
2255        });
2256        assert_eq!(
2257            visible_entries_as_strings(&panel, 0..10, cx),
2258            &[
2259                "v root1",
2260                "    > .git",
2261                "    > a",
2262                "    > b",
2263                "    > C",
2264                "      [EDITOR: '']  <== selected",
2265                "      .dockerignore",
2266                "v root2",
2267                "    > d",
2268                "    > e",
2269            ]
2270        );
2271
2272        let confirm = panel.update(cx, |panel, cx| {
2273            panel.filename_editor.update(cx, |editor, cx| {
2274                editor.set_text("/bdir1/dir2/the-new-filename", cx)
2275            });
2276            panel.confirm(&Confirm, cx).unwrap()
2277        });
2278
2279        assert_eq!(
2280            visible_entries_as_strings(&panel, 0..10, cx),
2281            &[
2282                "v root1",
2283                "    > .git",
2284                "    > a",
2285                "    > b",
2286                "    > C",
2287                "      [PROCESSING: '/bdir1/dir2/the-new-filename']  <== selected",
2288                "      .dockerignore",
2289                "v root2",
2290                "    > d",
2291                "    > e",
2292            ]
2293        );
2294
2295        confirm.await.unwrap();
2296        assert_eq!(
2297            visible_entries_as_strings(&panel, 0..13, cx),
2298            &[
2299                "v root1",
2300                "    > .git",
2301                "    > a",
2302                "    > b",
2303                "    v bdir1",
2304                "        v dir2",
2305                "              the-new-filename  <== selected",
2306                "    > C",
2307                "      .dockerignore",
2308                "v root2",
2309                "    > d",
2310                "    > e",
2311            ]
2312        );
2313    }
2314
2315    #[gpui::test]
2316    async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
2317        init_test(cx);
2318
2319        let fs = FakeFs::new(cx.background());
2320        fs.insert_tree(
2321            "/root1",
2322            json!({
2323                "one.two.txt": "",
2324                "one.txt": ""
2325            }),
2326        )
2327        .await;
2328
2329        let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
2330        let workspace = cx
2331            .add_window(|cx| Workspace::test_new(project.clone(), cx))
2332            .root(cx);
2333        let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2334
2335        panel.update(cx, |panel, cx| {
2336            panel.select_next(&Default::default(), cx);
2337            panel.select_next(&Default::default(), cx);
2338        });
2339
2340        assert_eq!(
2341            visible_entries_as_strings(&panel, 0..50, cx),
2342            &[
2343                //
2344                "v root1",
2345                "      one.two.txt  <== selected",
2346                "      one.txt",
2347            ]
2348        );
2349
2350        // Regression test - file name is created correctly when
2351        // the copied file's name contains multiple dots.
2352        panel.update(cx, |panel, cx| {
2353            panel.copy(&Default::default(), cx);
2354            panel.paste(&Default::default(), cx);
2355        });
2356        cx.foreground().run_until_parked();
2357
2358        assert_eq!(
2359            visible_entries_as_strings(&panel, 0..50, cx),
2360            &[
2361                //
2362                "v root1",
2363                "      one.two copy.txt",
2364                "      one.two.txt  <== selected",
2365                "      one.txt",
2366            ]
2367        );
2368
2369        panel.update(cx, |panel, cx| {
2370            panel.paste(&Default::default(), cx);
2371        });
2372        cx.foreground().run_until_parked();
2373
2374        assert_eq!(
2375            visible_entries_as_strings(&panel, 0..50, cx),
2376            &[
2377                //
2378                "v root1",
2379                "      one.two copy 1.txt",
2380                "      one.two copy.txt",
2381                "      one.two.txt  <== selected",
2382                "      one.txt",
2383            ]
2384        );
2385    }
2386
2387    #[gpui::test]
2388    async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
2389        init_test_with_editor(cx);
2390
2391        let fs = FakeFs::new(cx.background());
2392        fs.insert_tree(
2393            "/src",
2394            json!({
2395                "test": {
2396                    "first.rs": "// First Rust file",
2397                    "second.rs": "// Second Rust file",
2398                    "third.rs": "// Third Rust file",
2399                }
2400            }),
2401        )
2402        .await;
2403
2404        let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2405        let window = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2406        let workspace = window.root(cx);
2407        let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2408
2409        toggle_expand_dir(&panel, "src/test", cx);
2410        select_path(&panel, "src/test/first.rs", cx);
2411        panel.update(cx, |panel, cx| panel.open_file(&Open, cx));
2412        cx.foreground().run_until_parked();
2413        assert_eq!(
2414            visible_entries_as_strings(&panel, 0..10, cx),
2415            &[
2416                "v src",
2417                "    v test",
2418                "          first.rs  <== selected",
2419                "          second.rs",
2420                "          third.rs"
2421            ]
2422        );
2423        ensure_single_file_is_opened(window, "test/first.rs", cx);
2424
2425        submit_deletion(window.into(), &panel, cx);
2426        assert_eq!(
2427            visible_entries_as_strings(&panel, 0..10, cx),
2428            &[
2429                "v src",
2430                "    v test",
2431                "          second.rs",
2432                "          third.rs"
2433            ],
2434            "Project panel should have no deleted file, no other file is selected in it"
2435        );
2436        ensure_no_open_items_and_panes(window.into(), &workspace, cx);
2437
2438        select_path(&panel, "src/test/second.rs", cx);
2439        panel.update(cx, |panel, cx| panel.open_file(&Open, cx));
2440        cx.foreground().run_until_parked();
2441        assert_eq!(
2442            visible_entries_as_strings(&panel, 0..10, cx),
2443            &[
2444                "v src",
2445                "    v test",
2446                "          second.rs  <== selected",
2447                "          third.rs"
2448            ]
2449        );
2450        ensure_single_file_is_opened(window, "test/second.rs", cx);
2451
2452        window.update(cx, |cx| {
2453            let active_items = workspace
2454                .read(cx)
2455                .panes()
2456                .iter()
2457                .filter_map(|pane| pane.read(cx).active_item())
2458                .collect::<Vec<_>>();
2459            assert_eq!(active_items.len(), 1);
2460            let open_editor = active_items
2461                .into_iter()
2462                .next()
2463                .unwrap()
2464                .downcast::<Editor>()
2465                .expect("Open item should be an editor");
2466            open_editor.update(cx, |editor, cx| editor.set_text("Another text!", cx));
2467        });
2468        submit_deletion(window.into(), &panel, cx);
2469        assert_eq!(
2470            visible_entries_as_strings(&panel, 0..10, cx),
2471            &["v src", "    v test", "          third.rs"],
2472            "Project panel should have no deleted file, with one last file remaining"
2473        );
2474        ensure_no_open_items_and_panes(window.into(), &workspace, cx);
2475    }
2476
2477    #[gpui::test]
2478    async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
2479        init_test_with_editor(cx);
2480
2481        let fs = FakeFs::new(cx.background());
2482        fs.insert_tree(
2483            "/src",
2484            json!({
2485                "test": {
2486                    "first.rs": "// First Rust file",
2487                    "second.rs": "// Second Rust file",
2488                    "third.rs": "// Third Rust file",
2489                }
2490            }),
2491        )
2492        .await;
2493
2494        let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2495        let window = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2496        let workspace = window.root(cx);
2497        let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2498
2499        select_path(&panel, "src/", cx);
2500        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2501        cx.foreground().run_until_parked();
2502        assert_eq!(
2503            visible_entries_as_strings(&panel, 0..10, cx),
2504            &["v src  <== selected", "    > test"]
2505        );
2506        panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2507        window.read_with(cx, |cx| {
2508            let panel = panel.read(cx);
2509            assert!(panel.filename_editor.is_focused(cx));
2510        });
2511        assert_eq!(
2512            visible_entries_as_strings(&panel, 0..10, cx),
2513            &["v src", "    > [EDITOR: '']  <== selected", "    > test"]
2514        );
2515        panel.update(cx, |panel, cx| {
2516            panel
2517                .filename_editor
2518                .update(cx, |editor, cx| editor.set_text("test", cx));
2519            assert!(
2520                panel.confirm(&Confirm, cx).is_none(),
2521                "Should not allow to confirm on conflicting new directory name"
2522            )
2523        });
2524        assert_eq!(
2525            visible_entries_as_strings(&panel, 0..10, cx),
2526            &["v src", "    > test"],
2527            "File list should be unchanged after failed folder create confirmation"
2528        );
2529
2530        select_path(&panel, "src/test/", cx);
2531        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2532        cx.foreground().run_until_parked();
2533        assert_eq!(
2534            visible_entries_as_strings(&panel, 0..10, cx),
2535            &["v src", "    > test  <== selected"]
2536        );
2537        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2538        window.read_with(cx, |cx| {
2539            let panel = panel.read(cx);
2540            assert!(panel.filename_editor.is_focused(cx));
2541        });
2542        assert_eq!(
2543            visible_entries_as_strings(&panel, 0..10, cx),
2544            &[
2545                "v src",
2546                "    v test",
2547                "          [EDITOR: '']  <== selected",
2548                "          first.rs",
2549                "          second.rs",
2550                "          third.rs"
2551            ]
2552        );
2553        panel.update(cx, |panel, cx| {
2554            panel
2555                .filename_editor
2556                .update(cx, |editor, cx| editor.set_text("first.rs", cx));
2557            assert!(
2558                panel.confirm(&Confirm, cx).is_none(),
2559                "Should not allow to confirm on conflicting new file name"
2560            )
2561        });
2562        assert_eq!(
2563            visible_entries_as_strings(&panel, 0..10, cx),
2564            &[
2565                "v src",
2566                "    v test",
2567                "          first.rs",
2568                "          second.rs",
2569                "          third.rs"
2570            ],
2571            "File list should be unchanged after failed file create confirmation"
2572        );
2573
2574        select_path(&panel, "src/test/first.rs", cx);
2575        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2576        cx.foreground().run_until_parked();
2577        assert_eq!(
2578            visible_entries_as_strings(&panel, 0..10, cx),
2579            &[
2580                "v src",
2581                "    v test",
2582                "          first.rs  <== selected",
2583                "          second.rs",
2584                "          third.rs"
2585            ],
2586        );
2587        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2588        window.read_with(cx, |cx| {
2589            let panel = panel.read(cx);
2590            assert!(panel.filename_editor.is_focused(cx));
2591        });
2592        assert_eq!(
2593            visible_entries_as_strings(&panel, 0..10, cx),
2594            &[
2595                "v src",
2596                "    v test",
2597                "          [EDITOR: 'first.rs']  <== selected",
2598                "          second.rs",
2599                "          third.rs"
2600            ]
2601        );
2602        panel.update(cx, |panel, cx| {
2603            panel
2604                .filename_editor
2605                .update(cx, |editor, cx| editor.set_text("second.rs", cx));
2606            assert!(
2607                panel.confirm(&Confirm, cx).is_none(),
2608                "Should not allow to confirm on conflicting file rename"
2609            )
2610        });
2611        assert_eq!(
2612            visible_entries_as_strings(&panel, 0..10, cx),
2613            &[
2614                "v src",
2615                "    v test",
2616                "          first.rs  <== selected",
2617                "          second.rs",
2618                "          third.rs"
2619            ],
2620            "File list should be unchanged after failed rename confirmation"
2621        );
2622    }
2623
2624    #[gpui::test]
2625    async fn test_new_search_in_directory_trigger(cx: &mut gpui::TestAppContext) {
2626        init_test_with_editor(cx);
2627
2628        let fs = FakeFs::new(cx.background());
2629        fs.insert_tree(
2630            "/src",
2631            json!({
2632                "test": {
2633                    "first.rs": "// First Rust file",
2634                    "second.rs": "// Second Rust file",
2635                    "third.rs": "// Third Rust file",
2636                }
2637            }),
2638        )
2639        .await;
2640
2641        let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2642        let workspace = cx
2643            .add_window(|cx| Workspace::test_new(project.clone(), cx))
2644            .root(cx);
2645        let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2646
2647        let new_search_events_count = Arc::new(AtomicUsize::new(0));
2648        let _subscription = panel.update(cx, |_, cx| {
2649            let subcription_count = Arc::clone(&new_search_events_count);
2650            cx.subscribe(&cx.handle(), move |_, _, event, _| {
2651                if matches!(event, Event::NewSearchInDirectory { .. }) {
2652                    subcription_count.fetch_add(1, atomic::Ordering::SeqCst);
2653                }
2654            })
2655        });
2656
2657        toggle_expand_dir(&panel, "src/test", cx);
2658        select_path(&panel, "src/test/first.rs", cx);
2659        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2660        cx.foreground().run_until_parked();
2661        assert_eq!(
2662            visible_entries_as_strings(&panel, 0..10, cx),
2663            &[
2664                "v src",
2665                "    v test",
2666                "          first.rs  <== selected",
2667                "          second.rs",
2668                "          third.rs"
2669            ]
2670        );
2671        panel.update(cx, |panel, cx| {
2672            panel.new_search_in_directory(&NewSearchInDirectory, cx)
2673        });
2674        assert_eq!(
2675            new_search_events_count.load(atomic::Ordering::SeqCst),
2676            0,
2677            "Should not trigger new search in directory when called on a file"
2678        );
2679
2680        select_path(&panel, "src/test", cx);
2681        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2682        cx.foreground().run_until_parked();
2683        assert_eq!(
2684            visible_entries_as_strings(&panel, 0..10, cx),
2685            &[
2686                "v src",
2687                "    v test  <== selected",
2688                "          first.rs",
2689                "          second.rs",
2690                "          third.rs"
2691            ]
2692        );
2693        panel.update(cx, |panel, cx| {
2694            panel.new_search_in_directory(&NewSearchInDirectory, cx)
2695        });
2696        assert_eq!(
2697            new_search_events_count.load(atomic::Ordering::SeqCst),
2698            1,
2699            "Should trigger new search in directory when called on a directory"
2700        );
2701    }
2702
2703    #[gpui::test]
2704    async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) {
2705        init_test_with_editor(cx);
2706
2707        let fs = FakeFs::new(cx.background());
2708        fs.insert_tree(
2709            "/project_root",
2710            json!({
2711                "dir_1": {
2712                    "nested_dir": {
2713                        "file_a.py": "# File contents",
2714                        "file_b.py": "# File contents",
2715                        "file_c.py": "# File contents",
2716                    },
2717                    "file_1.py": "# File contents",
2718                    "file_2.py": "# File contents",
2719                    "file_3.py": "# File contents",
2720                },
2721                "dir_2": {
2722                    "file_1.py": "# File contents",
2723                    "file_2.py": "# File contents",
2724                    "file_3.py": "# File contents",
2725                }
2726            }),
2727        )
2728        .await;
2729
2730        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
2731        let workspace = cx
2732            .add_window(|cx| Workspace::test_new(project.clone(), cx))
2733            .root(cx);
2734        let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2735
2736        panel.update(cx, |panel, cx| {
2737            panel.collapse_all_entries(&CollapseAllEntries, cx)
2738        });
2739        cx.foreground().run_until_parked();
2740        assert_eq!(
2741            visible_entries_as_strings(&panel, 0..10, cx),
2742            &["v project_root", "    > dir_1", "    > dir_2",]
2743        );
2744
2745        // Open dir_1 and make sure nested_dir was collapsed when running collapse_all_entries
2746        toggle_expand_dir(&panel, "project_root/dir_1", cx);
2747        cx.foreground().run_until_parked();
2748        assert_eq!(
2749            visible_entries_as_strings(&panel, 0..10, cx),
2750            &[
2751                "v project_root",
2752                "    v dir_1  <== selected",
2753                "        > nested_dir",
2754                "          file_1.py",
2755                "          file_2.py",
2756                "          file_3.py",
2757                "    > dir_2",
2758            ]
2759        );
2760    }
2761
2762    #[gpui::test]
2763    async fn test_new_file_move(cx: &mut gpui::TestAppContext) {
2764        init_test(cx);
2765
2766        let fs = FakeFs::new(cx.background());
2767        fs.as_fake().insert_tree("/root", json!({})).await;
2768        let project = Project::test(fs, ["/root".as_ref()], cx).await;
2769        let workspace = cx
2770            .add_window(|cx| Workspace::test_new(project.clone(), cx))
2771            .root(cx);
2772        let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2773
2774        // Make a new buffer with no backing file
2775        workspace.update(cx, |workspace, cx| {
2776            Editor::new_file(workspace, &Default::default(), cx)
2777        });
2778
2779        // "Save as"" the buffer, creating a new backing file for it
2780        let task = workspace.update(cx, |workspace, cx| {
2781            workspace.save_active_item(workspace::SaveIntent::Save, cx)
2782        });
2783
2784        cx.foreground().run_until_parked();
2785        cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/new")));
2786        task.await.unwrap();
2787
2788        // Rename the file
2789        select_path(&panel, "root/new", cx);
2790        assert_eq!(
2791            visible_entries_as_strings(&panel, 0..10, cx),
2792            &["v root", "      new  <== selected"]
2793        );
2794        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2795        panel.update(cx, |panel, cx| {
2796            panel
2797                .filename_editor
2798                .update(cx, |editor, cx| editor.set_text("newer", cx));
2799        });
2800        panel
2801            .update(cx, |panel, cx| panel.confirm(&Confirm, cx))
2802            .unwrap()
2803            .await
2804            .unwrap();
2805
2806        cx.foreground().run_until_parked();
2807        assert_eq!(
2808            visible_entries_as_strings(&panel, 0..10, cx),
2809            &["v root", "      newer  <== selected"]
2810        );
2811
2812        workspace
2813            .update(cx, |workspace, cx| {
2814                workspace.save_active_item(workspace::SaveIntent::Save, cx)
2815            })
2816            .await
2817            .unwrap();
2818
2819        cx.foreground().run_until_parked();
2820        // assert that saving the file doesn't restore "new"
2821        assert_eq!(
2822            visible_entries_as_strings(&panel, 0..10, cx),
2823            &["v root", "      newer  <== selected"]
2824        );
2825    }
2826
2827    fn toggle_expand_dir(
2828        panel: &ViewHandle<ProjectPanel>,
2829        path: impl AsRef<Path>,
2830        cx: &mut TestAppContext,
2831    ) {
2832        let path = path.as_ref();
2833        panel.update(cx, |panel, cx| {
2834            for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
2835                let worktree = worktree.read(cx);
2836                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
2837                    let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
2838                    panel.toggle_expanded(entry_id, cx);
2839                    return;
2840                }
2841            }
2842            panic!("no worktree for path {:?}", path);
2843        });
2844    }
2845
2846    fn select_path(
2847        panel: &ViewHandle<ProjectPanel>,
2848        path: impl AsRef<Path>,
2849        cx: &mut TestAppContext,
2850    ) {
2851        let path = path.as_ref();
2852        panel.update(cx, |panel, cx| {
2853            for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
2854                let worktree = worktree.read(cx);
2855                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
2856                    let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
2857                    panel.selection = Some(Selection {
2858                        worktree_id: worktree.id(),
2859                        entry_id,
2860                    });
2861                    return;
2862                }
2863            }
2864            panic!("no worktree for path {:?}", path);
2865        });
2866    }
2867
2868    fn visible_entries_as_strings(
2869        panel: &ViewHandle<ProjectPanel>,
2870        range: Range<usize>,
2871        cx: &mut TestAppContext,
2872    ) -> Vec<String> {
2873        let mut result = Vec::new();
2874        let mut project_entries = HashSet::new();
2875        let mut has_editor = false;
2876
2877        panel.update(cx, |panel, cx| {
2878            panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
2879                if details.is_editing {
2880                    assert!(!has_editor, "duplicate editor entry");
2881                    has_editor = true;
2882                } else {
2883                    assert!(
2884                        project_entries.insert(project_entry),
2885                        "duplicate project entry {:?} {:?}",
2886                        project_entry,
2887                        details
2888                    );
2889                }
2890
2891                let indent = "    ".repeat(details.depth);
2892                let icon = if details.kind.is_dir() {
2893                    if details.is_expanded {
2894                        "v "
2895                    } else {
2896                        "> "
2897                    }
2898                } else {
2899                    "  "
2900                };
2901                let name = if details.is_editing {
2902                    format!("[EDITOR: '{}']", details.filename)
2903                } else if details.is_processing {
2904                    format!("[PROCESSING: '{}']", details.filename)
2905                } else {
2906                    details.filename.clone()
2907                };
2908                let selected = if details.is_selected {
2909                    "  <== selected"
2910                } else {
2911                    ""
2912                };
2913                result.push(format!("{indent}{icon}{name}{selected}"));
2914            });
2915        });
2916
2917        result
2918    }
2919
2920    fn init_test(cx: &mut TestAppContext) {
2921        cx.foreground().forbid_parking();
2922        cx.update(|cx| {
2923            cx.set_global(SettingsStore::test(cx));
2924            init_settings(cx);
2925            theme::init((), cx);
2926            language::init(cx);
2927            editor::init_settings(cx);
2928            crate::init((), cx);
2929            workspace::init_settings(cx);
2930            client::init_settings(cx);
2931            Project::init_settings(cx);
2932        });
2933    }
2934
2935    fn init_test_with_editor(cx: &mut TestAppContext) {
2936        cx.foreground().forbid_parking();
2937        cx.update(|cx| {
2938            let app_state = AppState::test(cx);
2939            theme::init((), cx);
2940            init_settings(cx);
2941            language::init(cx);
2942            editor::init(cx);
2943            pane::init(cx);
2944            crate::init((), cx);
2945            workspace::init(app_state.clone(), cx);
2946            Project::init_settings(cx);
2947        });
2948    }
2949
2950    fn ensure_single_file_is_opened(
2951        window: WindowHandle<Workspace>,
2952        expected_path: &str,
2953        cx: &mut TestAppContext,
2954    ) {
2955        window.update_root(cx, |workspace, cx| {
2956            let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
2957            assert_eq!(worktrees.len(), 1);
2958            let worktree_id = WorktreeId::from_usize(worktrees[0].id());
2959
2960            let open_project_paths = workspace
2961                .panes()
2962                .iter()
2963                .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
2964                .collect::<Vec<_>>();
2965            assert_eq!(
2966                open_project_paths,
2967                vec![ProjectPath {
2968                    worktree_id,
2969                    path: Arc::from(Path::new(expected_path))
2970                }],
2971                "Should have opened file, selected in project panel"
2972            );
2973        });
2974    }
2975
2976    fn submit_deletion(
2977        window: AnyWindowHandle,
2978        panel: &ViewHandle<ProjectPanel>,
2979        cx: &mut TestAppContext,
2980    ) {
2981        assert!(
2982            !window.has_pending_prompt(cx),
2983            "Should have no prompts before the deletion"
2984        );
2985        panel.update(cx, |panel, cx| {
2986            panel
2987                .delete(&Delete, cx)
2988                .expect("Deletion start")
2989                .detach_and_log_err(cx);
2990        });
2991        assert!(
2992            window.has_pending_prompt(cx),
2993            "Should have a prompt after the deletion"
2994        );
2995        window.simulate_prompt_answer(0, cx);
2996        assert!(
2997            !window.has_pending_prompt(cx),
2998            "Should have no prompts after prompt was replied to"
2999        );
3000        cx.foreground().run_until_parked();
3001    }
3002
3003    fn ensure_no_open_items_and_panes(
3004        window: AnyWindowHandle,
3005        workspace: &ViewHandle<Workspace>,
3006        cx: &mut TestAppContext,
3007    ) {
3008        assert!(
3009            !window.has_pending_prompt(cx),
3010            "Should have no prompts after deletion operation closes the file"
3011        );
3012        window.read_with(cx, |cx| {
3013            let open_project_paths = workspace
3014                .read(cx)
3015                .panes()
3016                .iter()
3017                .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
3018                .collect::<Vec<_>>();
3019            assert!(
3020                open_project_paths.is_empty(),
3021                "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
3022            );
3023        });
3024    }
3025}
3026// TODO - a workspace command?