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