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