project_panel.rs

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