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