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