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