project_panel.rs

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