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