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, Component, Div, EventEmitter, FocusHandle, Focusable, FocusableView,
  13    InteractiveComponent, Model, MouseButton, ParentComponent, Pixels, Point, PromptLevel, Render,
  14    Stateful, StatefulInteractiveComponent, 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                dbg!("OLA!");
 251                let new_dock_position = this.position(cx);
 252                if new_dock_position != old_dock_position {
 253                    old_dock_position = new_dock_position;
 254                    cx.emit(PanelEvent::ChangePosition);
 255                }
 256            })
 257            .detach();
 258
 259            this
 260        });
 261
 262        cx.subscribe(&project_panel, {
 263            let project_panel = project_panel.downgrade();
 264            move |workspace, _, event, cx| match event {
 265                &Event::OpenedEntry {
 266                    entry_id,
 267                    focus_opened_item,
 268                } => {
 269                    if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
 270                        if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
 271                            workspace
 272                                .open_path(
 273                                    ProjectPath {
 274                                        worktree_id: worktree.read(cx).id(),
 275                                        path: entry.path.clone(),
 276                                    },
 277                                    None,
 278                                    focus_opened_item,
 279                                    cx,
 280                                )
 281                                .detach_and_log_err(cx);
 282                            if !focus_opened_item {
 283                                if let Some(project_panel) = project_panel.upgrade() {
 284                                    let focus_handle = project_panel.read(cx).focus_handle.clone();
 285                                    cx.focus(&focus_handle);
 286                                }
 287                            }
 288                        }
 289                    }
 290                }
 291                &Event::SplitEntry { entry_id } => {
 292                    if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
 293                        if let Some(_entry) = worktree.read(cx).entry_for_id(entry_id) {
 294                            // workspace
 295                            //     .split_path(
 296                            //         ProjectPath {
 297                            //             worktree_id: worktree.read(cx).id(),
 298                            //             path: entry.path.clone(),
 299                            //         },
 300                            //         cx,
 301                            //     )
 302                            //     .detach_and_log_err(cx);
 303                        }
 304                    }
 305                }
 306                _ => {}
 307            }
 308        })
 309        .detach();
 310
 311        project_panel
 312    }
 313
 314    pub async fn load(
 315        workspace: WeakView<Workspace>,
 316        mut cx: AsyncWindowContext,
 317    ) -> Result<View<Self>> {
 318        let serialized_panel = cx
 319            .background_executor()
 320            .spawn(async move { KEY_VALUE_STORE.read_kvp(PROJECT_PANEL_KEY) })
 321            .await
 322            .map_err(|e| anyhow!("Failed to load project panel: {}", e))
 323            .log_err()
 324            .flatten()
 325            .map(|panel| serde_json::from_str::<SerializedProjectPanel>(&panel))
 326            .transpose()
 327            .log_err()
 328            .flatten();
 329
 330        workspace.update(&mut cx, |workspace, cx| {
 331            let panel = ProjectPanel::new(workspace, cx);
 332            if let Some(serialized_panel) = serialized_panel {
 333                panel.update(cx, |panel, cx| {
 334                    panel.width = serialized_panel.width;
 335                    cx.notify();
 336                });
 337            }
 338            panel
 339        })
 340    }
 341
 342    fn serialize(&mut self, cx: &mut ViewContext<Self>) {
 343        let width = self.width;
 344        self.pending_serialization = cx.background_executor().spawn(
 345            async move {
 346                KEY_VALUE_STORE
 347                    .write_kvp(
 348                        PROJECT_PANEL_KEY.into(),
 349                        serde_json::to_string(&SerializedProjectPanel { width })?,
 350                    )
 351                    .await?;
 352                anyhow::Ok(())
 353            }
 354            .log_err(),
 355        );
 356    }
 357
 358    fn focus_in(&mut self, cx: &mut ViewContext<Self>) {
 359        if !self.has_focus {
 360            self.has_focus = true;
 361            cx.emit(Event::Focus);
 362        }
 363    }
 364
 365    fn focus_out(&mut self, _: &mut ViewContext<Self>) {
 366        self.has_focus = false;
 367    }
 368
 369    fn deploy_context_menu(
 370        &mut self,
 371        _position: Point<Pixels>,
 372        _entry_id: ProjectEntryId,
 373        _cx: &mut ViewContext<Self>,
 374    ) {
 375        todo!()
 376        //     let project = self.project.read(cx);
 377
 378        //     let worktree_id = if let Some(id) = project.worktree_id_for_entry(entry_id, cx) {
 379        //         id
 380        //     } else {
 381        //         return;
 382        //     };
 383
 384        //     self.selection = Some(Selection {
 385        //         worktree_id,
 386        //         entry_id,
 387        //     });
 388
 389        //     let mut menu_entries = Vec::new();
 390        //     if let Some((worktree, entry)) = self.selected_entry(cx) {
 391        //         let is_root = Some(entry) == worktree.root_entry();
 392        //         if !project.is_remote() {
 393        //             menu_entries.push(ContextMenuItem::action(
 394        //                 "Add Folder to Project",
 395        //                 workspace::AddFolderToProject,
 396        //             ));
 397        //             if is_root {
 398        //                 let project = self.project.clone();
 399        //                 menu_entries.push(ContextMenuItem::handler("Remove from Project", move |cx| {
 400        //                     project.update(cx, |project, cx| project.remove_worktree(worktree_id, cx));
 401        //                 }));
 402        //             }
 403        //         }
 404        //         menu_entries.push(ContextMenuItem::action("New File", NewFile));
 405        //         menu_entries.push(ContextMenuItem::action("New Folder", NewDirectory));
 406        //         menu_entries.push(ContextMenuItem::Separator);
 407        //         menu_entries.push(ContextMenuItem::action("Cut", Cut));
 408        //         menu_entries.push(ContextMenuItem::action("Copy", Copy));
 409        //         if let Some(clipboard_entry) = self.clipboard_entry {
 410        //             if clipboard_entry.worktree_id() == worktree.id() {
 411        //                 menu_entries.push(ContextMenuItem::action("Paste", Paste));
 412        //             }
 413        //         }
 414        //         menu_entries.push(ContextMenuItem::Separator);
 415        //         menu_entries.push(ContextMenuItem::action("Copy Path", CopyPath));
 416        //         menu_entries.push(ContextMenuItem::action(
 417        //             "Copy Relative Path",
 418        //             CopyRelativePath,
 419        //         ));
 420
 421        //         if entry.is_dir() {
 422        //             menu_entries.push(ContextMenuItem::Separator);
 423        //         }
 424        //         menu_entries.push(ContextMenuItem::action("Reveal in Finder", RevealInFinder));
 425        //         if entry.is_dir() {
 426        //             menu_entries.push(ContextMenuItem::action("Open in Terminal", OpenInTerminal));
 427        //             menu_entries.push(ContextMenuItem::action(
 428        //                 "Search Inside",
 429        //                 NewSearchInDirectory,
 430        //             ));
 431        //         }
 432
 433        //         menu_entries.push(ContextMenuItem::Separator);
 434        //         menu_entries.push(ContextMenuItem::action("Rename", Rename));
 435        //         if !is_root {
 436        //             menu_entries.push(ContextMenuItem::action("Delete", Delete));
 437        //         }
 438        //     }
 439
 440        //     // self.context_menu.update(cx, |menu, cx| {
 441        //     //     menu.show(position, AnchorCorner::TopLeft, menu_entries, cx);
 442        //     // });
 443
 444        //     cx.notify();
 445    }
 446
 447    fn expand_selected_entry(&mut self, _: &ExpandSelectedEntry, cx: &mut ViewContext<Self>) {
 448        if let Some((worktree, entry)) = self.selected_entry(cx) {
 449            if entry.is_dir() {
 450                let worktree_id = worktree.id();
 451                let entry_id = entry.id;
 452                let expanded_dir_ids =
 453                    if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
 454                        expanded_dir_ids
 455                    } else {
 456                        return;
 457                    };
 458
 459                match expanded_dir_ids.binary_search(&entry_id) {
 460                    Ok(_) => self.select_next(&SelectNext, cx),
 461                    Err(ix) => {
 462                        self.project.update(cx, |project, cx| {
 463                            project.expand_entry(worktree_id, entry_id, cx);
 464                        });
 465
 466                        expanded_dir_ids.insert(ix, entry_id);
 467                        self.update_visible_entries(None, cx);
 468                        cx.notify();
 469                    }
 470                }
 471            }
 472        }
 473    }
 474
 475    fn collapse_selected_entry(&mut self, _: &CollapseSelectedEntry, cx: &mut ViewContext<Self>) {
 476        if let Some((worktree, mut entry)) = self.selected_entry(cx) {
 477            let worktree_id = worktree.id();
 478            let expanded_dir_ids =
 479                if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
 480                    expanded_dir_ids
 481                } else {
 482                    return;
 483                };
 484
 485            loop {
 486                let entry_id = entry.id;
 487                match expanded_dir_ids.binary_search(&entry_id) {
 488                    Ok(ix) => {
 489                        expanded_dir_ids.remove(ix);
 490                        self.update_visible_entries(Some((worktree_id, entry_id)), cx);
 491                        cx.notify();
 492                        break;
 493                    }
 494                    Err(_) => {
 495                        if let Some(parent_entry) =
 496                            entry.path.parent().and_then(|p| worktree.entry_for_path(p))
 497                        {
 498                            entry = parent_entry;
 499                        } else {
 500                            break;
 501                        }
 502                    }
 503                }
 504            }
 505        }
 506    }
 507
 508    pub fn collapse_all_entries(&mut self, _: &CollapseAllEntries, cx: &mut ViewContext<Self>) {
 509        self.expanded_dir_ids.clear();
 510        self.update_visible_entries(None, cx);
 511        cx.notify();
 512    }
 513
 514    fn toggle_expanded(&mut self, entry_id: ProjectEntryId, cx: &mut ViewContext<Self>) {
 515        if let Some(worktree_id) = self.project.read(cx).worktree_id_for_entry(entry_id, cx) {
 516            if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
 517                self.project.update(cx, |project, cx| {
 518                    match expanded_dir_ids.binary_search(&entry_id) {
 519                        Ok(ix) => {
 520                            expanded_dir_ids.remove(ix);
 521                        }
 522                        Err(ix) => {
 523                            project.expand_entry(worktree_id, entry_id, cx);
 524                            expanded_dir_ids.insert(ix, entry_id);
 525                        }
 526                    }
 527                });
 528                self.update_visible_entries(Some((worktree_id, entry_id)), cx);
 529                cx.focus(&self.focus_handle);
 530                cx.notify();
 531            }
 532        }
 533    }
 534
 535    fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
 536        if let Some(selection) = self.selection {
 537            let (mut worktree_ix, mut entry_ix, _) =
 538                self.index_for_selection(selection).unwrap_or_default();
 539            if entry_ix > 0 {
 540                entry_ix -= 1;
 541            } else if worktree_ix > 0 {
 542                worktree_ix -= 1;
 543                entry_ix = self.visible_entries[worktree_ix].1.len() - 1;
 544            } else {
 545                return;
 546            }
 547
 548            let (worktree_id, worktree_entries) = &self.visible_entries[worktree_ix];
 549            self.selection = Some(Selection {
 550                worktree_id: *worktree_id,
 551                entry_id: worktree_entries[entry_ix].id,
 552            });
 553            self.autoscroll(cx);
 554            cx.notify();
 555        } else {
 556            self.select_first(cx);
 557        }
 558    }
 559
 560    fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
 561        if let Some(task) = self.confirm_edit(cx) {
 562            task.detach_and_log_err(cx);
 563        }
 564    }
 565
 566    fn open_file(&mut self, _: &Open, cx: &mut ViewContext<Self>) {
 567        if let Some((_, entry)) = self.selected_entry(cx) {
 568            if entry.is_file() {
 569                self.open_entry(entry.id, true, cx);
 570            }
 571        }
 572    }
 573
 574    fn confirm_edit(&mut self, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
 575        let edit_state = self.edit_state.as_mut()?;
 576        cx.focus(&self.focus_handle);
 577
 578        let worktree_id = edit_state.worktree_id;
 579        let is_new_entry = edit_state.is_new_entry;
 580        let is_dir = edit_state.is_dir;
 581        let worktree = self.project.read(cx).worktree_for_id(worktree_id, cx)?;
 582        let entry = worktree.read(cx).entry_for_id(edit_state.entry_id)?.clone();
 583        let filename = self.filename_editor.read(cx).text(cx);
 584
 585        let path_already_exists = |path| worktree.read(cx).entry_for_path(path).is_some();
 586        let edit_task;
 587        let edited_entry_id;
 588        if is_new_entry {
 589            self.selection = Some(Selection {
 590                worktree_id,
 591                entry_id: NEW_ENTRY_ID,
 592            });
 593            let new_path = entry.path.join(&filename.trim_start_matches("/"));
 594            if path_already_exists(new_path.as_path()) {
 595                return None;
 596            }
 597
 598            edited_entry_id = NEW_ENTRY_ID;
 599            edit_task = self.project.update(cx, |project, cx| {
 600                project.create_entry((worktree_id, &new_path), is_dir, cx)
 601            })?;
 602        } else {
 603            let new_path = if let Some(parent) = entry.path.clone().parent() {
 604                parent.join(&filename)
 605            } else {
 606                filename.clone().into()
 607            };
 608            if path_already_exists(new_path.as_path()) {
 609                return None;
 610            }
 611
 612            edited_entry_id = entry.id;
 613            edit_task = self.project.update(cx, |project, cx| {
 614                project.rename_entry(entry.id, new_path.as_path(), cx)
 615            })?;
 616        };
 617
 618        edit_state.processing_filename = Some(filename);
 619        cx.notify();
 620
 621        Some(cx.spawn(|this, mut cx| async move {
 622            let new_entry = edit_task.await;
 623            this.update(&mut cx, |this, cx| {
 624                this.edit_state.take();
 625                cx.notify();
 626            })?;
 627
 628            let new_entry = new_entry?;
 629            this.update(&mut cx, |this, cx| {
 630                if let Some(selection) = &mut this.selection {
 631                    if selection.entry_id == edited_entry_id {
 632                        selection.worktree_id = worktree_id;
 633                        selection.entry_id = new_entry.id;
 634                        this.expand_to_selection(cx);
 635                    }
 636                }
 637                this.update_visible_entries(None, cx);
 638                if is_new_entry && !is_dir {
 639                    this.open_entry(new_entry.id, true, cx);
 640                }
 641                cx.notify();
 642            })?;
 643            Ok(())
 644        }))
 645    }
 646
 647    fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
 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<Self> {
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<Self, Div<Self>> {
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(move |this, event, 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(MouseButton::Right, move |this, event, cx| {
1414                this.deploy_context_menu(event.position, entry_id, cx);
1415            })
1416        // .on_drop::<ProjectEntryId>(|this, event, cx| {
1417        //     this.move_entry(
1418        //         *dragged_entry,
1419        //         entry_id,
1420        //         matches!(details.kind, EntryKind::File(_)),
1421        //         cx,
1422        //     );
1423        // })
1424    }
1425}
1426
1427impl Render for ProjectPanel {
1428    type Element = Focusable<Self, Stateful<Self, Div<Self>>>;
1429
1430    fn render(&mut self, _cx: &mut gpui::ViewContext<Self>) -> Self::Element {
1431        let has_worktree = self.visible_entries.len() != 0;
1432
1433        if has_worktree {
1434            div()
1435                .id("project-panel")
1436                .size_full()
1437                .key_context("ProjectPanel")
1438                .on_action(Self::select_next)
1439                .on_action(Self::select_prev)
1440                .on_action(Self::expand_selected_entry)
1441                .on_action(Self::collapse_selected_entry)
1442                .on_action(Self::collapse_all_entries)
1443                .on_action(Self::new_file)
1444                .on_action(Self::new_directory)
1445                .on_action(Self::rename)
1446                .on_action(Self::delete)
1447                .on_action(Self::confirm)
1448                .on_action(Self::open_file)
1449                .on_action(Self::cancel)
1450                .on_action(Self::cut)
1451                .on_action(Self::copy)
1452                .on_action(Self::copy_path)
1453                .on_action(Self::copy_relative_path)
1454                .on_action(Self::paste)
1455                .on_action(Self::reveal_in_finder)
1456                .on_action(Self::open_in_terminal)
1457                .on_action(Self::new_search_in_directory)
1458                .track_focus(&self.focus_handle)
1459                .child(
1460                    uniform_list(
1461                        "entries",
1462                        self.visible_entries
1463                            .iter()
1464                            .map(|(_, worktree_entries)| worktree_entries.len())
1465                            .sum(),
1466                        |this: &mut Self, range, cx| {
1467                            let mut items = Vec::new();
1468                            this.for_each_visible_entry(range, cx, |id, details, cx| {
1469                                items.push(this.render_entry(id, details, cx));
1470                            });
1471                            items
1472                        },
1473                    )
1474                    .size_full()
1475                    .track_scroll(self.list.clone()),
1476                )
1477        } else {
1478            v_stack()
1479                .id("empty-project_panel")
1480                .track_focus(&self.focus_handle)
1481        }
1482    }
1483}
1484
1485impl EventEmitter<Event> for ProjectPanel {}
1486
1487impl EventEmitter<PanelEvent> for ProjectPanel {}
1488
1489impl Panel for ProjectPanel {
1490    fn position(&self, cx: &WindowContext) -> DockPosition {
1491        match ProjectPanelSettings::get_global(cx).dock {
1492            ProjectPanelDockPosition::Left => DockPosition::Left,
1493            ProjectPanelDockPosition::Right => DockPosition::Right,
1494        }
1495    }
1496
1497    fn position_is_valid(&self, position: DockPosition) -> bool {
1498        matches!(position, DockPosition::Left | DockPosition::Right)
1499    }
1500
1501    fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
1502        settings::update_settings_file::<ProjectPanelSettings>(
1503            self.fs.clone(),
1504            cx,
1505            move |settings| {
1506                let dock = match position {
1507                    DockPosition::Left | DockPosition::Bottom => ProjectPanelDockPosition::Left,
1508                    DockPosition::Right => ProjectPanelDockPosition::Right,
1509                };
1510                settings.dock = Some(dock);
1511            },
1512        );
1513    }
1514
1515    fn size(&self, cx: &WindowContext) -> f32 {
1516        self.width
1517            .unwrap_or_else(|| ProjectPanelSettings::get_global(cx).default_width)
1518    }
1519
1520    fn set_size(&mut self, size: Option<f32>, cx: &mut ViewContext<Self>) {
1521        self.width = size;
1522        self.serialize(cx);
1523        cx.notify();
1524    }
1525
1526    fn icon(&self, _: &WindowContext) -> Option<ui::Icon> {
1527        Some(ui::Icon::FileTree)
1528    }
1529
1530    fn toggle_action(&self) -> Box<dyn Action> {
1531        Box::new(ToggleFocus)
1532    }
1533
1534    fn has_focus(&self, _: &WindowContext) -> bool {
1535        self.has_focus
1536    }
1537
1538    fn persistent_name() -> &'static str {
1539        "Project Panel"
1540    }
1541}
1542
1543impl FocusableView for ProjectPanel {
1544    fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
1545        self.focus_handle.clone()
1546    }
1547}
1548
1549impl ClipboardEntry {
1550    fn is_cut(&self) -> bool {
1551        matches!(self, Self::Cut { .. })
1552    }
1553
1554    fn entry_id(&self) -> ProjectEntryId {
1555        match self {
1556            ClipboardEntry::Copied { entry_id, .. } | ClipboardEntry::Cut { entry_id, .. } => {
1557                *entry_id
1558            }
1559        }
1560    }
1561
1562    fn worktree_id(&self) -> WorktreeId {
1563        match self {
1564            ClipboardEntry::Copied { worktree_id, .. }
1565            | ClipboardEntry::Cut { worktree_id, .. } => *worktree_id,
1566        }
1567    }
1568}
1569
1570#[cfg(test)]
1571mod tests {
1572    use super::*;
1573    use gpui::{TestAppContext, View, VisualTestContext, WindowHandle};
1574    use pretty_assertions::assert_eq;
1575    use project::{project_settings::ProjectSettings, FakeFs};
1576    use serde_json::json;
1577    use settings::SettingsStore;
1578    use std::{
1579        collections::HashSet,
1580        path::{Path, PathBuf},
1581        sync::atomic::{self, AtomicUsize},
1582    };
1583    use workspace::AppState;
1584
1585    #[gpui::test]
1586    async fn test_visible_list(cx: &mut gpui::TestAppContext) {
1587        init_test(cx);
1588
1589        let fs = FakeFs::new(cx.executor().clone());
1590        fs.insert_tree(
1591            "/root1",
1592            json!({
1593                ".dockerignore": "",
1594                ".git": {
1595                    "HEAD": "",
1596                },
1597                "a": {
1598                    "0": { "q": "", "r": "", "s": "" },
1599                    "1": { "t": "", "u": "" },
1600                    "2": { "v": "", "w": "", "x": "", "y": "" },
1601                },
1602                "b": {
1603                    "3": { "Q": "" },
1604                    "4": { "R": "", "S": "", "T": "", "U": "" },
1605                },
1606                "C": {
1607                    "5": {},
1608                    "6": { "V": "", "W": "" },
1609                    "7": { "X": "" },
1610                    "8": { "Y": {}, "Z": "" }
1611                }
1612            }),
1613        )
1614        .await;
1615        fs.insert_tree(
1616            "/root2",
1617            json!({
1618                "d": {
1619                    "9": ""
1620                },
1621                "e": {}
1622            }),
1623        )
1624        .await;
1625
1626        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1627        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1628        let cx = &mut VisualTestContext::from_window(*workspace, cx);
1629        let panel = workspace
1630            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
1631            .unwrap();
1632        assert_eq!(
1633            visible_entries_as_strings(&panel, 0..50, cx),
1634            &[
1635                "v root1",
1636                "    > .git",
1637                "    > a",
1638                "    > b",
1639                "    > C",
1640                "      .dockerignore",
1641                "v root2",
1642                "    > d",
1643                "    > e",
1644            ]
1645        );
1646
1647        toggle_expand_dir(&panel, "root1/b", cx);
1648        assert_eq!(
1649            visible_entries_as_strings(&panel, 0..50, cx),
1650            &[
1651                "v root1",
1652                "    > .git",
1653                "    > a",
1654                "    v b  <== selected",
1655                "        > 3",
1656                "        > 4",
1657                "    > C",
1658                "      .dockerignore",
1659                "v root2",
1660                "    > d",
1661                "    > e",
1662            ]
1663        );
1664
1665        assert_eq!(
1666            visible_entries_as_strings(&panel, 6..9, cx),
1667            &[
1668                //
1669                "    > C",
1670                "      .dockerignore",
1671                "v root2",
1672            ]
1673        );
1674    }
1675
1676    #[gpui::test]
1677    async fn test_exclusions_in_visible_list(cx: &mut gpui::TestAppContext) {
1678        init_test(cx);
1679        cx.update(|cx| {
1680            cx.update_global::<SettingsStore, _>(|store, cx| {
1681                store.update_user_settings::<ProjectSettings>(cx, |project_settings| {
1682                    project_settings.file_scan_exclusions =
1683                        Some(vec!["**/.git".to_string(), "**/4/**".to_string()]);
1684                });
1685            });
1686        });
1687
1688        let fs = FakeFs::new(cx.background_executor.clone());
1689        fs.insert_tree(
1690            "/root1",
1691            json!({
1692                ".dockerignore": "",
1693                ".git": {
1694                    "HEAD": "",
1695                },
1696                "a": {
1697                    "0": { "q": "", "r": "", "s": "" },
1698                    "1": { "t": "", "u": "" },
1699                    "2": { "v": "", "w": "", "x": "", "y": "" },
1700                },
1701                "b": {
1702                    "3": { "Q": "" },
1703                    "4": { "R": "", "S": "", "T": "", "U": "" },
1704                },
1705                "C": {
1706                    "5": {},
1707                    "6": { "V": "", "W": "" },
1708                    "7": { "X": "" },
1709                    "8": { "Y": {}, "Z": "" }
1710                }
1711            }),
1712        )
1713        .await;
1714        fs.insert_tree(
1715            "/root2",
1716            json!({
1717                "d": {
1718                    "4": ""
1719                },
1720                "e": {}
1721            }),
1722        )
1723        .await;
1724
1725        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1726        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1727        let cx = &mut VisualTestContext::from_window(*workspace, cx);
1728        let panel = workspace
1729            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
1730            .unwrap();
1731        assert_eq!(
1732            visible_entries_as_strings(&panel, 0..50, cx),
1733            &[
1734                "v root1",
1735                "    > a",
1736                "    > b",
1737                "    > C",
1738                "      .dockerignore",
1739                "v root2",
1740                "    > d",
1741                "    > e",
1742            ]
1743        );
1744
1745        toggle_expand_dir(&panel, "root1/b", cx);
1746        assert_eq!(
1747            visible_entries_as_strings(&panel, 0..50, cx),
1748            &[
1749                "v root1",
1750                "    > a",
1751                "    v b  <== selected",
1752                "        > 3",
1753                "    > C",
1754                "      .dockerignore",
1755                "v root2",
1756                "    > d",
1757                "    > e",
1758            ]
1759        );
1760
1761        toggle_expand_dir(&panel, "root2/d", cx);
1762        assert_eq!(
1763            visible_entries_as_strings(&panel, 0..50, cx),
1764            &[
1765                "v root1",
1766                "    > a",
1767                "    v b",
1768                "        > 3",
1769                "    > C",
1770                "      .dockerignore",
1771                "v root2",
1772                "    v d  <== selected",
1773                "    > e",
1774            ]
1775        );
1776
1777        toggle_expand_dir(&panel, "root2/e", cx);
1778        assert_eq!(
1779            visible_entries_as_strings(&panel, 0..50, cx),
1780            &[
1781                "v root1",
1782                "    > a",
1783                "    v b",
1784                "        > 3",
1785                "    > C",
1786                "      .dockerignore",
1787                "v root2",
1788                "    v d",
1789                "    v e  <== selected",
1790            ]
1791        );
1792    }
1793
1794    #[gpui::test(iterations = 30)]
1795    async fn test_editing_files(cx: &mut gpui::TestAppContext) {
1796        init_test(cx);
1797
1798        let fs = FakeFs::new(cx.executor().clone());
1799        fs.insert_tree(
1800            "/root1",
1801            json!({
1802                ".dockerignore": "",
1803                ".git": {
1804                    "HEAD": "",
1805                },
1806                "a": {
1807                    "0": { "q": "", "r": "", "s": "" },
1808                    "1": { "t": "", "u": "" },
1809                    "2": { "v": "", "w": "", "x": "", "y": "" },
1810                },
1811                "b": {
1812                    "3": { "Q": "" },
1813                    "4": { "R": "", "S": "", "T": "", "U": "" },
1814                },
1815                "C": {
1816                    "5": {},
1817                    "6": { "V": "", "W": "" },
1818                    "7": { "X": "" },
1819                    "8": { "Y": {}, "Z": "" }
1820                }
1821            }),
1822        )
1823        .await;
1824        fs.insert_tree(
1825            "/root2",
1826            json!({
1827                "d": {
1828                    "9": ""
1829                },
1830                "e": {}
1831            }),
1832        )
1833        .await;
1834
1835        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1836        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1837        let cx = &mut VisualTestContext::from_window(*workspace, cx);
1838        let panel = workspace
1839            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
1840            .unwrap();
1841
1842        select_path(&panel, "root1", cx);
1843        assert_eq!(
1844            visible_entries_as_strings(&panel, 0..10, cx),
1845            &[
1846                "v root1  <== selected",
1847                "    > .git",
1848                "    > a",
1849                "    > b",
1850                "    > C",
1851                "      .dockerignore",
1852                "v root2",
1853                "    > d",
1854                "    > e",
1855            ]
1856        );
1857
1858        // Add a file with the root folder selected. The filename editor is placed
1859        // before the first file in the root folder.
1860        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
1861        panel.update(cx, |panel, cx| {
1862            assert!(panel.filename_editor.read(cx).is_focused(cx));
1863        });
1864        assert_eq!(
1865            visible_entries_as_strings(&panel, 0..10, cx),
1866            &[
1867                "v root1",
1868                "    > .git",
1869                "    > a",
1870                "    > b",
1871                "    > C",
1872                "      [EDITOR: '']  <== selected",
1873                "      .dockerignore",
1874                "v root2",
1875                "    > d",
1876                "    > e",
1877            ]
1878        );
1879
1880        let confirm = panel.update(cx, |panel, cx| {
1881            panel
1882                .filename_editor
1883                .update(cx, |editor, cx| editor.set_text("the-new-filename", cx));
1884            panel.confirm_edit(cx).unwrap()
1885        });
1886        assert_eq!(
1887            visible_entries_as_strings(&panel, 0..10, cx),
1888            &[
1889                "v root1",
1890                "    > .git",
1891                "    > a",
1892                "    > b",
1893                "    > C",
1894                "      [PROCESSING: 'the-new-filename']  <== selected",
1895                "      .dockerignore",
1896                "v root2",
1897                "    > d",
1898                "    > e",
1899            ]
1900        );
1901
1902        confirm.await.unwrap();
1903        assert_eq!(
1904            visible_entries_as_strings(&panel, 0..10, cx),
1905            &[
1906                "v root1",
1907                "    > .git",
1908                "    > a",
1909                "    > b",
1910                "    > C",
1911                "      .dockerignore",
1912                "      the-new-filename  <== selected",
1913                "v root2",
1914                "    > d",
1915                "    > e",
1916            ]
1917        );
1918
1919        select_path(&panel, "root1/b", cx);
1920        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
1921        assert_eq!(
1922            visible_entries_as_strings(&panel, 0..10, cx),
1923            &[
1924                "v root1",
1925                "    > .git",
1926                "    > a",
1927                "    v b",
1928                "        > 3",
1929                "        > 4",
1930                "          [EDITOR: '']  <== selected",
1931                "    > C",
1932                "      .dockerignore",
1933                "      the-new-filename",
1934            ]
1935        );
1936
1937        panel
1938            .update(cx, |panel, cx| {
1939                panel
1940                    .filename_editor
1941                    .update(cx, |editor, cx| editor.set_text("another-filename.txt", cx));
1942                panel.confirm_edit(cx).unwrap()
1943            })
1944            .await
1945            .unwrap();
1946        assert_eq!(
1947            visible_entries_as_strings(&panel, 0..10, cx),
1948            &[
1949                "v root1",
1950                "    > .git",
1951                "    > a",
1952                "    v b",
1953                "        > 3",
1954                "        > 4",
1955                "          another-filename.txt  <== selected",
1956                "    > C",
1957                "      .dockerignore",
1958                "      the-new-filename",
1959            ]
1960        );
1961
1962        select_path(&panel, "root1/b/another-filename.txt", cx);
1963        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
1964        assert_eq!(
1965            visible_entries_as_strings(&panel, 0..10, cx),
1966            &[
1967                "v root1",
1968                "    > .git",
1969                "    > a",
1970                "    v b",
1971                "        > 3",
1972                "        > 4",
1973                "          [EDITOR: 'another-filename.txt']  <== selected",
1974                "    > C",
1975                "      .dockerignore",
1976                "      the-new-filename",
1977            ]
1978        );
1979
1980        let confirm = panel.update(cx, |panel, cx| {
1981            panel.filename_editor.update(cx, |editor, cx| {
1982                let file_name_selections = editor.selections.all::<usize>(cx);
1983                assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
1984                let file_name_selection = &file_name_selections[0];
1985                assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
1986                assert_eq!(file_name_selection.end, "another-filename".len(), "Should not select file extension");
1987
1988                editor.set_text("a-different-filename.tar.gz", cx)
1989            });
1990            panel.confirm_edit(cx).unwrap()
1991        });
1992        assert_eq!(
1993            visible_entries_as_strings(&panel, 0..10, cx),
1994            &[
1995                "v root1",
1996                "    > .git",
1997                "    > a",
1998                "    v b",
1999                "        > 3",
2000                "        > 4",
2001                "          [PROCESSING: 'a-different-filename.tar.gz']  <== selected",
2002                "    > C",
2003                "      .dockerignore",
2004                "      the-new-filename",
2005            ]
2006        );
2007
2008        confirm.await.unwrap();
2009        assert_eq!(
2010            visible_entries_as_strings(&panel, 0..10, cx),
2011            &[
2012                "v root1",
2013                "    > .git",
2014                "    > a",
2015                "    v b",
2016                "        > 3",
2017                "        > 4",
2018                "          a-different-filename.tar.gz  <== selected",
2019                "    > C",
2020                "      .dockerignore",
2021                "      the-new-filename",
2022            ]
2023        );
2024
2025        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2026        assert_eq!(
2027            visible_entries_as_strings(&panel, 0..10, cx),
2028            &[
2029                "v root1",
2030                "    > .git",
2031                "    > a",
2032                "    v b",
2033                "        > 3",
2034                "        > 4",
2035                "          [EDITOR: 'a-different-filename.tar.gz']  <== selected",
2036                "    > C",
2037                "      .dockerignore",
2038                "      the-new-filename",
2039            ]
2040        );
2041
2042        panel.update(cx, |panel, cx| {
2043            panel.filename_editor.update(cx, |editor, cx| {
2044                let file_name_selections = editor.selections.all::<usize>(cx);
2045                assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
2046                let file_name_selection = &file_name_selections[0];
2047                assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
2048                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..");
2049
2050            });
2051            panel.cancel(&Cancel, cx)
2052        });
2053
2054        panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2055        assert_eq!(
2056            visible_entries_as_strings(&panel, 0..10, cx),
2057            &[
2058                "v root1",
2059                "    > .git",
2060                "    > a",
2061                "    v b",
2062                "        > [EDITOR: '']  <== selected",
2063                "        > 3",
2064                "        > 4",
2065                "          a-different-filename.tar.gz",
2066                "    > C",
2067                "      .dockerignore",
2068            ]
2069        );
2070
2071        let confirm = panel.update(cx, |panel, cx| {
2072            panel
2073                .filename_editor
2074                .update(cx, |editor, cx| editor.set_text("new-dir", cx));
2075            panel.confirm_edit(cx).unwrap()
2076        });
2077        panel.update(cx, |panel, cx| panel.select_next(&Default::default(), cx));
2078        assert_eq!(
2079            visible_entries_as_strings(&panel, 0..10, cx),
2080            &[
2081                "v root1",
2082                "    > .git",
2083                "    > a",
2084                "    v b",
2085                "        > [PROCESSING: 'new-dir']",
2086                "        > 3  <== selected",
2087                "        > 4",
2088                "          a-different-filename.tar.gz",
2089                "    > C",
2090                "      .dockerignore",
2091            ]
2092        );
2093
2094        confirm.await.unwrap();
2095        assert_eq!(
2096            visible_entries_as_strings(&panel, 0..10, cx),
2097            &[
2098                "v root1",
2099                "    > .git",
2100                "    > a",
2101                "    v b",
2102                "        > 3  <== selected",
2103                "        > 4",
2104                "        > new-dir",
2105                "          a-different-filename.tar.gz",
2106                "    > C",
2107                "      .dockerignore",
2108            ]
2109        );
2110
2111        panel.update(cx, |panel, cx| panel.rename(&Default::default(), cx));
2112        assert_eq!(
2113            visible_entries_as_strings(&panel, 0..10, cx),
2114            &[
2115                "v root1",
2116                "    > .git",
2117                "    > a",
2118                "    v b",
2119                "        > [EDITOR: '3']  <== selected",
2120                "        > 4",
2121                "        > new-dir",
2122                "          a-different-filename.tar.gz",
2123                "    > C",
2124                "      .dockerignore",
2125            ]
2126        );
2127
2128        // Dismiss the rename editor when it loses focus.
2129        workspace.update(cx, |_, cx| cx.blur()).unwrap();
2130        assert_eq!(
2131            visible_entries_as_strings(&panel, 0..10, cx),
2132            &[
2133                "v root1",
2134                "    > .git",
2135                "    > a",
2136                "    v b",
2137                "        > 3  <== selected",
2138                "        > 4",
2139                "        > new-dir",
2140                "          a-different-filename.tar.gz",
2141                "    > C",
2142                "      .dockerignore",
2143            ]
2144        );
2145    }
2146
2147    #[gpui::test(iterations = 10)]
2148    async fn test_adding_directories_via_file(cx: &mut gpui::TestAppContext) {
2149        init_test(cx);
2150
2151        let fs = FakeFs::new(cx.executor().clone());
2152        fs.insert_tree(
2153            "/root1",
2154            json!({
2155                ".dockerignore": "",
2156                ".git": {
2157                    "HEAD": "",
2158                },
2159                "a": {
2160                    "0": { "q": "", "r": "", "s": "" },
2161                    "1": { "t": "", "u": "" },
2162                    "2": { "v": "", "w": "", "x": "", "y": "" },
2163                },
2164                "b": {
2165                    "3": { "Q": "" },
2166                    "4": { "R": "", "S": "", "T": "", "U": "" },
2167                },
2168                "C": {
2169                    "5": {},
2170                    "6": { "V": "", "W": "" },
2171                    "7": { "X": "" },
2172                    "8": { "Y": {}, "Z": "" }
2173                }
2174            }),
2175        )
2176        .await;
2177        fs.insert_tree(
2178            "/root2",
2179            json!({
2180                "d": {
2181                    "9": ""
2182                },
2183                "e": {}
2184            }),
2185        )
2186        .await;
2187
2188        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
2189        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2190        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2191        let panel = workspace
2192            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2193            .unwrap();
2194
2195        select_path(&panel, "root1", cx);
2196        assert_eq!(
2197            visible_entries_as_strings(&panel, 0..10, cx),
2198            &[
2199                "v root1  <== selected",
2200                "    > .git",
2201                "    > a",
2202                "    > b",
2203                "    > C",
2204                "      .dockerignore",
2205                "v root2",
2206                "    > d",
2207                "    > e",
2208            ]
2209        );
2210
2211        // Add a file with the root folder selected. The filename editor is placed
2212        // before the first file in the root folder.
2213        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2214        panel.update(cx, |panel, cx| {
2215            assert!(panel.filename_editor.read(cx).is_focused(cx));
2216        });
2217        assert_eq!(
2218            visible_entries_as_strings(&panel, 0..10, cx),
2219            &[
2220                "v root1",
2221                "    > .git",
2222                "    > a",
2223                "    > b",
2224                "    > C",
2225                "      [EDITOR: '']  <== selected",
2226                "      .dockerignore",
2227                "v root2",
2228                "    > d",
2229                "    > e",
2230            ]
2231        );
2232
2233        let confirm = panel.update(cx, |panel, cx| {
2234            panel.filename_editor.update(cx, |editor, cx| {
2235                editor.set_text("/bdir1/dir2/the-new-filename", cx)
2236            });
2237            panel.confirm_edit(cx).unwrap()
2238        });
2239
2240        assert_eq!(
2241            visible_entries_as_strings(&panel, 0..10, cx),
2242            &[
2243                "v root1",
2244                "    > .git",
2245                "    > a",
2246                "    > b",
2247                "    > C",
2248                "      [PROCESSING: '/bdir1/dir2/the-new-filename']  <== selected",
2249                "      .dockerignore",
2250                "v root2",
2251                "    > d",
2252                "    > e",
2253            ]
2254        );
2255
2256        confirm.await.unwrap();
2257        assert_eq!(
2258            visible_entries_as_strings(&panel, 0..13, cx),
2259            &[
2260                "v root1",
2261                "    > .git",
2262                "    > a",
2263                "    > b",
2264                "    v bdir1",
2265                "        v dir2",
2266                "              the-new-filename  <== selected",
2267                "    > C",
2268                "      .dockerignore",
2269                "v root2",
2270                "    > d",
2271                "    > e",
2272            ]
2273        );
2274    }
2275
2276    #[gpui::test]
2277    async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
2278        init_test(cx);
2279
2280        let fs = FakeFs::new(cx.executor().clone());
2281        fs.insert_tree(
2282            "/root1",
2283            json!({
2284                "one.two.txt": "",
2285                "one.txt": ""
2286            }),
2287        )
2288        .await;
2289
2290        let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
2291        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2292        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2293        let panel = workspace
2294            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2295            .unwrap();
2296
2297        panel.update(cx, |panel, cx| {
2298            panel.select_next(&Default::default(), cx);
2299            panel.select_next(&Default::default(), cx);
2300        });
2301
2302        assert_eq!(
2303            visible_entries_as_strings(&panel, 0..50, cx),
2304            &[
2305                //
2306                "v root1",
2307                "      one.two.txt  <== selected",
2308                "      one.txt",
2309            ]
2310        );
2311
2312        // Regression test - file name is created correctly when
2313        // the copied file's name contains multiple dots.
2314        panel.update(cx, |panel, cx| {
2315            panel.copy(&Default::default(), cx);
2316            panel.paste(&Default::default(), cx);
2317        });
2318        cx.executor().run_until_parked();
2319
2320        assert_eq!(
2321            visible_entries_as_strings(&panel, 0..50, cx),
2322            &[
2323                //
2324                "v root1",
2325                "      one.two copy.txt",
2326                "      one.two.txt  <== selected",
2327                "      one.txt",
2328            ]
2329        );
2330
2331        panel.update(cx, |panel, cx| {
2332            panel.paste(&Default::default(), cx);
2333        });
2334        cx.executor().run_until_parked();
2335
2336        assert_eq!(
2337            visible_entries_as_strings(&panel, 0..50, cx),
2338            &[
2339                //
2340                "v root1",
2341                "      one.two copy 1.txt",
2342                "      one.two copy.txt",
2343                "      one.two.txt  <== selected",
2344                "      one.txt",
2345            ]
2346        );
2347    }
2348
2349    #[gpui::test]
2350    async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
2351        init_test_with_editor(cx);
2352
2353        let fs = FakeFs::new(cx.executor().clone());
2354        fs.insert_tree(
2355            "/src",
2356            json!({
2357                "test": {
2358                    "first.rs": "// First Rust file",
2359                    "second.rs": "// Second Rust file",
2360                    "third.rs": "// Third Rust file",
2361                }
2362            }),
2363        )
2364        .await;
2365
2366        let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2367        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2368        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2369        let panel = workspace
2370            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2371            .unwrap();
2372
2373        toggle_expand_dir(&panel, "src/test", cx);
2374        select_path(&panel, "src/test/first.rs", cx);
2375        panel.update(cx, |panel, cx| panel.open_file(&Open, cx));
2376        cx.executor().run_until_parked();
2377        assert_eq!(
2378            visible_entries_as_strings(&panel, 0..10, cx),
2379            &[
2380                "v src",
2381                "    v test",
2382                "          first.rs  <== selected",
2383                "          second.rs",
2384                "          third.rs"
2385            ]
2386        );
2387        ensure_single_file_is_opened(&workspace, "test/first.rs", cx);
2388
2389        submit_deletion(&panel, cx);
2390        assert_eq!(
2391            visible_entries_as_strings(&panel, 0..10, cx),
2392            &[
2393                "v src",
2394                "    v test",
2395                "          second.rs",
2396                "          third.rs"
2397            ],
2398            "Project panel should have no deleted file, no other file is selected in it"
2399        );
2400        ensure_no_open_items_and_panes(&workspace, cx);
2401
2402        select_path(&panel, "src/test/second.rs", cx);
2403        panel.update(cx, |panel, cx| panel.open_file(&Open, cx));
2404        cx.executor().run_until_parked();
2405        assert_eq!(
2406            visible_entries_as_strings(&panel, 0..10, cx),
2407            &[
2408                "v src",
2409                "    v test",
2410                "          second.rs  <== selected",
2411                "          third.rs"
2412            ]
2413        );
2414        ensure_single_file_is_opened(&workspace, "test/second.rs", cx);
2415
2416        workspace
2417            .update(cx, |workspace, cx| {
2418                let active_items = workspace
2419                    .panes()
2420                    .iter()
2421                    .filter_map(|pane| pane.read(cx).active_item())
2422                    .collect::<Vec<_>>();
2423                assert_eq!(active_items.len(), 1);
2424                let open_editor = active_items
2425                    .into_iter()
2426                    .next()
2427                    .unwrap()
2428                    .downcast::<Editor>()
2429                    .expect("Open item should be an editor");
2430                open_editor.update(cx, |editor, cx| editor.set_text("Another text!", cx));
2431            })
2432            .unwrap();
2433        submit_deletion(&panel, cx);
2434        assert_eq!(
2435            visible_entries_as_strings(&panel, 0..10, cx),
2436            &["v src", "    v test", "          third.rs"],
2437            "Project panel should have no deleted file, with one last file remaining"
2438        );
2439        ensure_no_open_items_and_panes(&workspace, cx);
2440    }
2441
2442    #[gpui::test]
2443    async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
2444        init_test_with_editor(cx);
2445
2446        let fs = FakeFs::new(cx.executor().clone());
2447        fs.insert_tree(
2448            "/src",
2449            json!({
2450                "test": {
2451                    "first.rs": "// First Rust file",
2452                    "second.rs": "// Second Rust file",
2453                    "third.rs": "// Third Rust file",
2454                }
2455            }),
2456        )
2457        .await;
2458
2459        let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2460        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2461        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2462        let panel = workspace
2463            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2464            .unwrap();
2465
2466        select_path(&panel, "src/", cx);
2467        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2468        cx.executor().run_until_parked();
2469        assert_eq!(
2470            visible_entries_as_strings(&panel, 0..10, cx),
2471            &[
2472                //
2473                "v src  <== selected",
2474                "    > test"
2475            ]
2476        );
2477        panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2478        panel.update(cx, |panel, cx| {
2479            assert!(panel.filename_editor.read(cx).is_focused(cx));
2480        });
2481        assert_eq!(
2482            visible_entries_as_strings(&panel, 0..10, cx),
2483            &[
2484                //
2485                "v src",
2486                "    > [EDITOR: '']  <== selected",
2487                "    > test"
2488            ]
2489        );
2490        panel.update(cx, |panel, cx| {
2491            panel
2492                .filename_editor
2493                .update(cx, |editor, cx| editor.set_text("test", cx));
2494            assert!(
2495                panel.confirm_edit(cx).is_none(),
2496                "Should not allow to confirm on conflicting new directory name"
2497            )
2498        });
2499        assert_eq!(
2500            visible_entries_as_strings(&panel, 0..10, cx),
2501            &[
2502                //
2503                "v src",
2504                "    > test"
2505            ],
2506            "File list should be unchanged after failed folder create confirmation"
2507        );
2508
2509        select_path(&panel, "src/test/", cx);
2510        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2511        cx.executor().run_until_parked();
2512        assert_eq!(
2513            visible_entries_as_strings(&panel, 0..10, cx),
2514            &[
2515                //
2516                "v src",
2517                "    > test  <== selected"
2518            ]
2519        );
2520        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2521        panel.update(cx, |panel, cx| {
2522            assert!(panel.filename_editor.read(cx).is_focused(cx));
2523        });
2524        assert_eq!(
2525            visible_entries_as_strings(&panel, 0..10, cx),
2526            &[
2527                "v src",
2528                "    v test",
2529                "          [EDITOR: '']  <== selected",
2530                "          first.rs",
2531                "          second.rs",
2532                "          third.rs"
2533            ]
2534        );
2535        panel.update(cx, |panel, cx| {
2536            panel
2537                .filename_editor
2538                .update(cx, |editor, cx| editor.set_text("first.rs", cx));
2539            assert!(
2540                panel.confirm_edit(cx).is_none(),
2541                "Should not allow to confirm on conflicting new file name"
2542            )
2543        });
2544        assert_eq!(
2545            visible_entries_as_strings(&panel, 0..10, cx),
2546            &[
2547                "v src",
2548                "    v test",
2549                "          first.rs",
2550                "          second.rs",
2551                "          third.rs"
2552            ],
2553            "File list should be unchanged after failed file create confirmation"
2554        );
2555
2556        select_path(&panel, "src/test/first.rs", cx);
2557        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2558        cx.executor().run_until_parked();
2559        assert_eq!(
2560            visible_entries_as_strings(&panel, 0..10, cx),
2561            &[
2562                "v src",
2563                "    v test",
2564                "          first.rs  <== selected",
2565                "          second.rs",
2566                "          third.rs"
2567            ],
2568        );
2569        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2570        panel.update(cx, |panel, cx| {
2571            assert!(panel.filename_editor.read(cx).is_focused(cx));
2572        });
2573        assert_eq!(
2574            visible_entries_as_strings(&panel, 0..10, cx),
2575            &[
2576                "v src",
2577                "    v test",
2578                "          [EDITOR: 'first.rs']  <== selected",
2579                "          second.rs",
2580                "          third.rs"
2581            ]
2582        );
2583        panel.update(cx, |panel, cx| {
2584            panel
2585                .filename_editor
2586                .update(cx, |editor, cx| editor.set_text("second.rs", cx));
2587            assert!(
2588                panel.confirm_edit(cx).is_none(),
2589                "Should not allow to confirm on conflicting file rename"
2590            )
2591        });
2592        assert_eq!(
2593            visible_entries_as_strings(&panel, 0..10, cx),
2594            &[
2595                "v src",
2596                "    v test",
2597                "          first.rs  <== selected",
2598                "          second.rs",
2599                "          third.rs"
2600            ],
2601            "File list should be unchanged after failed rename confirmation"
2602        );
2603    }
2604
2605    #[gpui::test]
2606    async fn test_new_search_in_directory_trigger(cx: &mut gpui::TestAppContext) {
2607        init_test_with_editor(cx);
2608
2609        let fs = FakeFs::new(cx.executor().clone());
2610        fs.insert_tree(
2611            "/src",
2612            json!({
2613                "test": {
2614                    "first.rs": "// First Rust file",
2615                    "second.rs": "// Second Rust file",
2616                    "third.rs": "// Third Rust file",
2617                }
2618            }),
2619        )
2620        .await;
2621
2622        let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2623        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2624        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2625        let panel = workspace
2626            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2627            .unwrap();
2628
2629        let new_search_events_count = Arc::new(AtomicUsize::new(0));
2630        let _subscription = panel.update(cx, |_, cx| {
2631            let subcription_count = Arc::clone(&new_search_events_count);
2632            let view = cx.view().clone();
2633            cx.subscribe(&view, move |_, _, event, _| {
2634                if matches!(event, Event::NewSearchInDirectory { .. }) {
2635                    subcription_count.fetch_add(1, atomic::Ordering::SeqCst);
2636                }
2637            })
2638        });
2639
2640        toggle_expand_dir(&panel, "src/test", cx);
2641        select_path(&panel, "src/test/first.rs", cx);
2642        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2643        cx.executor().run_until_parked();
2644        assert_eq!(
2645            visible_entries_as_strings(&panel, 0..10, cx),
2646            &[
2647                "v src",
2648                "    v test",
2649                "          first.rs  <== selected",
2650                "          second.rs",
2651                "          third.rs"
2652            ]
2653        );
2654        panel.update(cx, |panel, cx| {
2655            panel.new_search_in_directory(&NewSearchInDirectory, cx)
2656        });
2657        assert_eq!(
2658            new_search_events_count.load(atomic::Ordering::SeqCst),
2659            0,
2660            "Should not trigger new search in directory when called on a file"
2661        );
2662
2663        select_path(&panel, "src/test", cx);
2664        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2665        cx.executor().run_until_parked();
2666        assert_eq!(
2667            visible_entries_as_strings(&panel, 0..10, cx),
2668            &[
2669                "v src",
2670                "    v test  <== selected",
2671                "          first.rs",
2672                "          second.rs",
2673                "          third.rs"
2674            ]
2675        );
2676        panel.update(cx, |panel, cx| {
2677            panel.new_search_in_directory(&NewSearchInDirectory, cx)
2678        });
2679        assert_eq!(
2680            new_search_events_count.load(atomic::Ordering::SeqCst),
2681            1,
2682            "Should trigger new search in directory when called on a directory"
2683        );
2684    }
2685
2686    #[gpui::test]
2687    async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) {
2688        init_test_with_editor(cx);
2689
2690        let fs = FakeFs::new(cx.executor().clone());
2691        fs.insert_tree(
2692            "/project_root",
2693            json!({
2694                "dir_1": {
2695                    "nested_dir": {
2696                        "file_a.py": "# File contents",
2697                        "file_b.py": "# File contents",
2698                        "file_c.py": "# File contents",
2699                    },
2700                    "file_1.py": "# File contents",
2701                    "file_2.py": "# File contents",
2702                    "file_3.py": "# File contents",
2703                },
2704                "dir_2": {
2705                    "file_1.py": "# File contents",
2706                    "file_2.py": "# File contents",
2707                    "file_3.py": "# File contents",
2708                }
2709            }),
2710        )
2711        .await;
2712
2713        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
2714        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2715        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2716        let panel = workspace
2717            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2718            .unwrap();
2719
2720        panel.update(cx, |panel, cx| {
2721            panel.collapse_all_entries(&CollapseAllEntries, cx)
2722        });
2723        cx.executor().run_until_parked();
2724        assert_eq!(
2725            visible_entries_as_strings(&panel, 0..10, cx),
2726            &["v project_root", "    > dir_1", "    > dir_2",]
2727        );
2728
2729        // Open dir_1 and make sure nested_dir was collapsed when running collapse_all_entries
2730        toggle_expand_dir(&panel, "project_root/dir_1", cx);
2731        cx.executor().run_until_parked();
2732        assert_eq!(
2733            visible_entries_as_strings(&panel, 0..10, cx),
2734            &[
2735                "v project_root",
2736                "    v dir_1  <== selected",
2737                "        > nested_dir",
2738                "          file_1.py",
2739                "          file_2.py",
2740                "          file_3.py",
2741                "    > dir_2",
2742            ]
2743        );
2744    }
2745
2746    #[gpui::test]
2747    async fn test_new_file_move(cx: &mut gpui::TestAppContext) {
2748        init_test(cx);
2749
2750        let fs = FakeFs::new(cx.executor().clone());
2751        fs.as_fake().insert_tree("/root", json!({})).await;
2752        let project = Project::test(fs, ["/root".as_ref()], cx).await;
2753        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2754        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2755        let panel = workspace
2756            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2757            .unwrap();
2758
2759        // Make a new buffer with no backing file
2760        workspace
2761            .update(cx, |workspace, cx| {
2762                Editor::new_file(workspace, &Default::default(), cx)
2763            })
2764            .unwrap();
2765
2766        // "Save as"" the buffer, creating a new backing file for it
2767        let save_task = workspace
2768            .update(cx, |workspace, cx| {
2769                workspace.save_active_item(workspace::SaveIntent::Save, cx)
2770            })
2771            .unwrap();
2772
2773        cx.executor().run_until_parked();
2774        cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/new")));
2775        save_task.await.unwrap();
2776
2777        // Rename the file
2778        select_path(&panel, "root/new", cx);
2779        assert_eq!(
2780            visible_entries_as_strings(&panel, 0..10, cx),
2781            &["v root", "      new  <== selected"]
2782        );
2783        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2784        panel.update(cx, |panel, cx| {
2785            panel
2786                .filename_editor
2787                .update(cx, |editor, cx| editor.set_text("newer", cx));
2788        });
2789        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2790
2791        cx.executor().run_until_parked();
2792        assert_eq!(
2793            visible_entries_as_strings(&panel, 0..10, cx),
2794            &["v root", "      newer  <== selected"]
2795        );
2796
2797        workspace
2798            .update(cx, |workspace, cx| {
2799                workspace.save_active_item(workspace::SaveIntent::Save, cx)
2800            })
2801            .unwrap()
2802            .await
2803            .unwrap();
2804
2805        cx.executor().run_until_parked();
2806        // assert that saving the file doesn't restore "new"
2807        assert_eq!(
2808            visible_entries_as_strings(&panel, 0..10, cx),
2809            &["v root", "      newer  <== selected"]
2810        );
2811    }
2812
2813    fn toggle_expand_dir(
2814        panel: &View<ProjectPanel>,
2815        path: impl AsRef<Path>,
2816        cx: &mut VisualTestContext,
2817    ) {
2818        let path = path.as_ref();
2819        panel.update(cx, |panel, cx| {
2820            for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
2821                let worktree = worktree.read(cx);
2822                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
2823                    let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
2824                    panel.toggle_expanded(entry_id, cx);
2825                    return;
2826                }
2827            }
2828            panic!("no worktree for path {:?}", path);
2829        });
2830    }
2831
2832    fn select_path(panel: &View<ProjectPanel>, path: impl AsRef<Path>, cx: &mut VisualTestContext) {
2833        let path = path.as_ref();
2834        panel.update(cx, |panel, cx| {
2835            for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
2836                let worktree = worktree.read(cx);
2837                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
2838                    let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
2839                    panel.selection = Some(Selection {
2840                        worktree_id: worktree.id(),
2841                        entry_id,
2842                    });
2843                    return;
2844                }
2845            }
2846            panic!("no worktree for path {:?}", path);
2847        });
2848    }
2849
2850    fn visible_entries_as_strings(
2851        panel: &View<ProjectPanel>,
2852        range: Range<usize>,
2853        cx: &mut VisualTestContext,
2854    ) -> Vec<String> {
2855        let mut result = Vec::new();
2856        let mut project_entries = HashSet::new();
2857        let mut has_editor = false;
2858
2859        panel.update(cx, |panel, cx| {
2860            panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
2861                if details.is_editing {
2862                    assert!(!has_editor, "duplicate editor entry");
2863                    has_editor = true;
2864                } else {
2865                    assert!(
2866                        project_entries.insert(project_entry),
2867                        "duplicate project entry {:?} {:?}",
2868                        project_entry,
2869                        details
2870                    );
2871                }
2872
2873                let indent = "    ".repeat(details.depth);
2874                let icon = if details.kind.is_dir() {
2875                    if details.is_expanded {
2876                        "v "
2877                    } else {
2878                        "> "
2879                    }
2880                } else {
2881                    "  "
2882                };
2883                let name = if details.is_editing {
2884                    format!("[EDITOR: '{}']", details.filename)
2885                } else if details.is_processing {
2886                    format!("[PROCESSING: '{}']", details.filename)
2887                } else {
2888                    details.filename.clone()
2889                };
2890                let selected = if details.is_selected {
2891                    "  <== selected"
2892                } else {
2893                    ""
2894                };
2895                result.push(format!("{indent}{icon}{name}{selected}"));
2896            });
2897        });
2898
2899        result
2900    }
2901
2902    fn init_test(cx: &mut TestAppContext) {
2903        cx.update(|cx| {
2904            let settings_store = SettingsStore::test(cx);
2905            cx.set_global(settings_store);
2906            init_settings(cx);
2907            theme::init(theme::LoadThemes::JustBase, cx);
2908            language::init(cx);
2909            editor::init_settings(cx);
2910            crate::init((), cx);
2911            workspace::init_settings(cx);
2912            client::init_settings(cx);
2913            Project::init_settings(cx);
2914
2915            cx.update_global::<SettingsStore, _>(|store, cx| {
2916                store.update_user_settings::<ProjectSettings>(cx, |project_settings| {
2917                    project_settings.file_scan_exclusions = Some(Vec::new());
2918                });
2919            });
2920        });
2921    }
2922
2923    fn init_test_with_editor(cx: &mut TestAppContext) {
2924        cx.update(|cx| {
2925            let app_state = AppState::test(cx);
2926            theme::init(theme::LoadThemes::JustBase, cx);
2927            init_settings(cx);
2928            language::init(cx);
2929            editor::init(cx);
2930            crate::init((), cx);
2931            workspace::init(app_state.clone(), cx);
2932            Project::init_settings(cx);
2933        });
2934    }
2935
2936    fn ensure_single_file_is_opened(
2937        window: &WindowHandle<Workspace>,
2938        expected_path: &str,
2939        cx: &mut TestAppContext,
2940    ) {
2941        window
2942            .update(cx, |workspace, cx| {
2943                let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
2944                assert_eq!(worktrees.len(), 1);
2945                let worktree_id = worktrees[0].read(cx).id();
2946
2947                let open_project_paths = workspace
2948                    .panes()
2949                    .iter()
2950                    .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
2951                    .collect::<Vec<_>>();
2952                assert_eq!(
2953                    open_project_paths,
2954                    vec![ProjectPath {
2955                        worktree_id,
2956                        path: Arc::from(Path::new(expected_path))
2957                    }],
2958                    "Should have opened file, selected in project panel"
2959                );
2960            })
2961            .unwrap();
2962    }
2963
2964    fn submit_deletion(panel: &View<ProjectPanel>, cx: &mut VisualTestContext) {
2965        assert!(
2966            !cx.has_pending_prompt(),
2967            "Should have no prompts before the deletion"
2968        );
2969        panel.update(cx, |panel, cx| panel.delete(&Delete, cx));
2970        assert!(
2971            cx.has_pending_prompt(),
2972            "Should have a prompt after the deletion"
2973        );
2974        cx.simulate_prompt_answer(0);
2975        assert!(
2976            !cx.has_pending_prompt(),
2977            "Should have no prompts after prompt was replied to"
2978        );
2979        cx.executor().run_until_parked();
2980    }
2981
2982    fn ensure_no_open_items_and_panes(
2983        workspace: &WindowHandle<Workspace>,
2984        cx: &mut VisualTestContext,
2985    ) {
2986        assert!(
2987            !cx.has_pending_prompt(),
2988            "Should have no prompts after deletion operation closes the file"
2989        );
2990        workspace
2991            .read_with(cx, |workspace, cx| {
2992                let open_project_paths = workspace
2993                    .panes()
2994                    .iter()
2995                    .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
2996                    .collect::<Vec<_>>();
2997                assert!(
2998                    open_project_paths.is_empty(),
2999                    "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
3000                );
3001            })
3002            .unwrap();
3003    }
3004}