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