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