project_panel.rs

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