project_panel.rs

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