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().h_6().child(
1446                                Label::new(file_name)
1447                                    .single_line()
1448                                    .color(filename_text_color),
1449                            )
1450                        }
1451                        .ml_1(),
1452                    )
1453                    .on_click(cx.listener(move |this, event: &gpui::ClickEvent, cx| {
1454                        if event.down.button == MouseButton::Right || event.down.first_mouse {
1455                            return;
1456                        }
1457                        if !show_editor {
1458                            if kind.is_dir() {
1459                                this.toggle_expanded(entry_id, cx);
1460                            } else {
1461                                if event.down.modifiers.secondary() {
1462                                    this.split_entry(entry_id, cx);
1463                                } else {
1464                                    this.open_entry(entry_id, event.up.click_count > 1, cx);
1465                                }
1466                            }
1467                        }
1468                    }))
1469                    .on_secondary_mouse_down(cx.listener(
1470                        move |this, event: &MouseDownEvent, cx| {
1471                            // Stop propagation to prevent the catch-all context menu for the project
1472                            // panel from being deployed.
1473                            cx.stop_propagation();
1474                            this.deploy_context_menu(event.position, entry_id, cx);
1475                        },
1476                    )),
1477            )
1478    }
1479
1480    fn dispatch_context(&self, cx: &ViewContext<Self>) -> KeyContext {
1481        let mut dispatch_context = KeyContext::default();
1482        dispatch_context.add("ProjectPanel");
1483        dispatch_context.add("menu");
1484
1485        let identifier = if self.filename_editor.focus_handle(cx).is_focused(cx) {
1486            "editing"
1487        } else {
1488            "not_editing"
1489        };
1490
1491        dispatch_context.add(identifier);
1492        dispatch_context
1493    }
1494
1495    fn reveal_entry(
1496        &mut self,
1497        project: Model<Project>,
1498        entry_id: ProjectEntryId,
1499        skip_ignored: bool,
1500        cx: &mut ViewContext<'_, ProjectPanel>,
1501    ) {
1502        if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
1503            let worktree = worktree.read(cx);
1504            if skip_ignored
1505                && worktree
1506                    .entry_for_id(entry_id)
1507                    .map_or(true, |entry| entry.is_ignored)
1508            {
1509                return;
1510            }
1511
1512            let worktree_id = worktree.id();
1513            self.expand_entry(worktree_id, entry_id, cx);
1514            self.update_visible_entries(Some((worktree_id, entry_id)), cx);
1515            self.autoscroll(cx);
1516            cx.notify();
1517        }
1518    }
1519}
1520
1521impl Render for ProjectPanel {
1522    fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> impl IntoElement {
1523        let has_worktree = self.visible_entries.len() != 0;
1524        let project = self.project.read(cx);
1525
1526        if has_worktree {
1527            div()
1528                .id("project-panel")
1529                .size_full()
1530                .relative()
1531                .key_context(self.dispatch_context(cx))
1532                .on_action(cx.listener(Self::select_next))
1533                .on_action(cx.listener(Self::select_prev))
1534                .on_action(cx.listener(Self::expand_selected_entry))
1535                .on_action(cx.listener(Self::collapse_selected_entry))
1536                .on_action(cx.listener(Self::collapse_all_entries))
1537                .on_action(cx.listener(Self::open))
1538                .on_action(cx.listener(Self::confirm))
1539                .on_action(cx.listener(Self::cancel))
1540                .on_action(cx.listener(Self::copy_path))
1541                .on_action(cx.listener(Self::copy_relative_path))
1542                .on_action(cx.listener(Self::new_search_in_directory))
1543                .when(!project.is_read_only(), |el| {
1544                    el.on_action(cx.listener(Self::new_file))
1545                        .on_action(cx.listener(Self::new_directory))
1546                        .on_action(cx.listener(Self::rename))
1547                        .on_action(cx.listener(Self::delete))
1548                        .on_action(cx.listener(Self::cut))
1549                        .on_action(cx.listener(Self::copy))
1550                        .on_action(cx.listener(Self::paste))
1551                })
1552                .when(project.is_local(), |el| {
1553                    el.on_action(cx.listener(Self::reveal_in_finder))
1554                        .on_action(cx.listener(Self::open_in_terminal))
1555                })
1556                .on_mouse_down(
1557                    MouseButton::Right,
1558                    cx.listener(move |this, event: &MouseDownEvent, cx| {
1559                        // When deploying the context menu anywhere below the last project entry,
1560                        // act as if the user clicked the root of the last worktree.
1561                        if let Some(entry_id) = this.last_worktree_root_id {
1562                            this.deploy_context_menu(event.position, entry_id, cx);
1563                        }
1564                    }),
1565                )
1566                .track_focus(&self.focus_handle)
1567                .child(
1568                    uniform_list(
1569                        cx.view().clone(),
1570                        "entries",
1571                        self.visible_entries
1572                            .iter()
1573                            .map(|(_, worktree_entries)| worktree_entries.len())
1574                            .sum(),
1575                        {
1576                            |this, range, cx| {
1577                                let mut items = Vec::new();
1578                                this.for_each_visible_entry(range, cx, |id, details, cx| {
1579                                    items.push(this.render_entry(id, details, cx));
1580                                });
1581                                items
1582                            }
1583                        },
1584                    )
1585                    .size_full()
1586                    .track_scroll(self.scroll_handle.clone()),
1587                )
1588                .children(self.context_menu.as_ref().map(|(menu, position, _)| {
1589                    deferred(
1590                        anchored()
1591                            .position(*position)
1592                            .anchor(gpui::AnchorCorner::TopLeft)
1593                            .child(menu.clone()),
1594                    )
1595                    .with_priority(1)
1596                }))
1597        } else {
1598            v_flex()
1599                .id("empty-project_panel")
1600                .size_full()
1601                .p_4()
1602                .track_focus(&self.focus_handle)
1603                .child(
1604                    Button::new("open_project", "Open a project")
1605                        .style(ButtonStyle::Filled)
1606                        .full_width()
1607                        .key_binding(KeyBinding::for_action(&workspace::Open, cx))
1608                        .on_click(cx.listener(|this, _, cx| {
1609                            this.workspace
1610                                .update(cx, |workspace, cx| workspace.open(&workspace::Open, cx))
1611                                .log_err();
1612                        })),
1613                )
1614        }
1615    }
1616}
1617
1618impl Render for DraggedProjectEntryView {
1619    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl Element {
1620        let settings = ProjectPanelSettings::get_global(cx);
1621        let ui_font = ThemeSettings::get_global(cx).ui_font.family.clone();
1622        h_flex()
1623            .font(ui_font)
1624            .bg(cx.theme().colors().background)
1625            .w(self.width)
1626            .child(
1627                ListItem::new(self.entry_id.to_proto() as usize)
1628                    .indent_level(self.details.depth)
1629                    .indent_step_size(px(settings.indent_size))
1630                    .child(if let Some(icon) = &self.details.icon {
1631                        div().child(Icon::from_path(icon.to_string()))
1632                    } else {
1633                        div()
1634                    })
1635                    .child(Label::new(self.details.filename.clone())),
1636            )
1637    }
1638}
1639
1640impl EventEmitter<Event> for ProjectPanel {}
1641
1642impl EventEmitter<PanelEvent> for ProjectPanel {}
1643
1644impl Panel for ProjectPanel {
1645    fn position(&self, cx: &WindowContext) -> DockPosition {
1646        match ProjectPanelSettings::get_global(cx).dock {
1647            ProjectPanelDockPosition::Left => DockPosition::Left,
1648            ProjectPanelDockPosition::Right => DockPosition::Right,
1649        }
1650    }
1651
1652    fn position_is_valid(&self, position: DockPosition) -> bool {
1653        matches!(position, DockPosition::Left | DockPosition::Right)
1654    }
1655
1656    fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
1657        settings::update_settings_file::<ProjectPanelSettings>(
1658            self.fs.clone(),
1659            cx,
1660            move |settings| {
1661                let dock = match position {
1662                    DockPosition::Left | DockPosition::Bottom => ProjectPanelDockPosition::Left,
1663                    DockPosition::Right => ProjectPanelDockPosition::Right,
1664                };
1665                settings.dock = Some(dock);
1666            },
1667        );
1668    }
1669
1670    fn size(&self, cx: &WindowContext) -> Pixels {
1671        self.width
1672            .unwrap_or_else(|| ProjectPanelSettings::get_global(cx).default_width)
1673    }
1674
1675    fn set_size(&mut self, size: Option<Pixels>, cx: &mut ViewContext<Self>) {
1676        self.width = size;
1677        self.serialize(cx);
1678        cx.notify();
1679    }
1680
1681    fn icon(&self, _: &WindowContext) -> Option<ui::IconName> {
1682        Some(ui::IconName::FileTree)
1683    }
1684
1685    fn icon_tooltip(&self, _cx: &WindowContext) -> Option<&'static str> {
1686        Some("Project Panel")
1687    }
1688
1689    fn toggle_action(&self) -> Box<dyn Action> {
1690        Box::new(ToggleFocus)
1691    }
1692
1693    fn persistent_name() -> &'static str {
1694        "Project Panel"
1695    }
1696
1697    fn starts_open(&self, cx: &WindowContext) -> bool {
1698        self.project.read(cx).visible_worktrees(cx).any(|tree| {
1699            tree.read(cx)
1700                .root_entry()
1701                .map_or(false, |entry| entry.is_dir())
1702        })
1703    }
1704}
1705
1706impl FocusableView for ProjectPanel {
1707    fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
1708        self.focus_handle.clone()
1709    }
1710}
1711
1712impl ClipboardEntry {
1713    fn is_cut(&self) -> bool {
1714        matches!(self, Self::Cut { .. })
1715    }
1716
1717    fn entry_id(&self) -> ProjectEntryId {
1718        match self {
1719            ClipboardEntry::Copied { entry_id, .. } | ClipboardEntry::Cut { entry_id, .. } => {
1720                *entry_id
1721            }
1722        }
1723    }
1724
1725    fn worktree_id(&self) -> WorktreeId {
1726        match self {
1727            ClipboardEntry::Copied { worktree_id, .. }
1728            | ClipboardEntry::Cut { worktree_id, .. } => *worktree_id,
1729        }
1730    }
1731}
1732
1733#[cfg(test)]
1734mod tests {
1735    use super::*;
1736    use collections::HashSet;
1737    use gpui::{TestAppContext, View, VisualTestContext, WindowHandle};
1738    use pretty_assertions::assert_eq;
1739    use project::{FakeFs, WorktreeSettings};
1740    use serde_json::json;
1741    use settings::SettingsStore;
1742    use std::path::{Path, PathBuf};
1743    use workspace::AppState;
1744
1745    #[gpui::test]
1746    async fn test_visible_list(cx: &mut gpui::TestAppContext) {
1747        init_test(cx);
1748
1749        let fs = FakeFs::new(cx.executor().clone());
1750        fs.insert_tree(
1751            "/root1",
1752            json!({
1753                ".dockerignore": "",
1754                ".git": {
1755                    "HEAD": "",
1756                },
1757                "a": {
1758                    "0": { "q": "", "r": "", "s": "" },
1759                    "1": { "t": "", "u": "" },
1760                    "2": { "v": "", "w": "", "x": "", "y": "" },
1761                },
1762                "b": {
1763                    "3": { "Q": "" },
1764                    "4": { "R": "", "S": "", "T": "", "U": "" },
1765                },
1766                "C": {
1767                    "5": {},
1768                    "6": { "V": "", "W": "" },
1769                    "7": { "X": "" },
1770                    "8": { "Y": {}, "Z": "" }
1771                }
1772            }),
1773        )
1774        .await;
1775        fs.insert_tree(
1776            "/root2",
1777            json!({
1778                "d": {
1779                    "9": ""
1780                },
1781                "e": {}
1782            }),
1783        )
1784        .await;
1785
1786        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1787        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1788        let cx = &mut VisualTestContext::from_window(*workspace, cx);
1789        let panel = workspace
1790            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
1791            .unwrap();
1792        assert_eq!(
1793            visible_entries_as_strings(&panel, 0..50, cx),
1794            &[
1795                "v root1",
1796                "    > .git",
1797                "    > a",
1798                "    > b",
1799                "    > C",
1800                "      .dockerignore",
1801                "v root2",
1802                "    > d",
1803                "    > e",
1804            ]
1805        );
1806
1807        toggle_expand_dir(&panel, "root1/b", cx);
1808        assert_eq!(
1809            visible_entries_as_strings(&panel, 0..50, cx),
1810            &[
1811                "v root1",
1812                "    > .git",
1813                "    > a",
1814                "    v b  <== selected",
1815                "        > 3",
1816                "        > 4",
1817                "    > C",
1818                "      .dockerignore",
1819                "v root2",
1820                "    > d",
1821                "    > e",
1822            ]
1823        );
1824
1825        assert_eq!(
1826            visible_entries_as_strings(&panel, 6..9, cx),
1827            &[
1828                //
1829                "    > C",
1830                "      .dockerignore",
1831                "v root2",
1832            ]
1833        );
1834    }
1835
1836    #[gpui::test]
1837    async fn test_exclusions_in_visible_list(cx: &mut gpui::TestAppContext) {
1838        init_test(cx);
1839        cx.update(|cx| {
1840            cx.update_global::<SettingsStore, _>(|store, cx| {
1841                store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
1842                    worktree_settings.file_scan_exclusions =
1843                        Some(vec!["**/.git".to_string(), "**/4/**".to_string()]);
1844                });
1845            });
1846        });
1847
1848        let fs = FakeFs::new(cx.background_executor.clone());
1849        fs.insert_tree(
1850            "/root1",
1851            json!({
1852                ".dockerignore": "",
1853                ".git": {
1854                    "HEAD": "",
1855                },
1856                "a": {
1857                    "0": { "q": "", "r": "", "s": "" },
1858                    "1": { "t": "", "u": "" },
1859                    "2": { "v": "", "w": "", "x": "", "y": "" },
1860                },
1861                "b": {
1862                    "3": { "Q": "" },
1863                    "4": { "R": "", "S": "", "T": "", "U": "" },
1864                },
1865                "C": {
1866                    "5": {},
1867                    "6": { "V": "", "W": "" },
1868                    "7": { "X": "" },
1869                    "8": { "Y": {}, "Z": "" }
1870                }
1871            }),
1872        )
1873        .await;
1874        fs.insert_tree(
1875            "/root2",
1876            json!({
1877                "d": {
1878                    "4": ""
1879                },
1880                "e": {}
1881            }),
1882        )
1883        .await;
1884
1885        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1886        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1887        let cx = &mut VisualTestContext::from_window(*workspace, cx);
1888        let panel = workspace
1889            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
1890            .unwrap();
1891        assert_eq!(
1892            visible_entries_as_strings(&panel, 0..50, cx),
1893            &[
1894                "v root1",
1895                "    > a",
1896                "    > b",
1897                "    > C",
1898                "      .dockerignore",
1899                "v root2",
1900                "    > d",
1901                "    > e",
1902            ]
1903        );
1904
1905        toggle_expand_dir(&panel, "root1/b", cx);
1906        assert_eq!(
1907            visible_entries_as_strings(&panel, 0..50, cx),
1908            &[
1909                "v root1",
1910                "    > a",
1911                "    v b  <== selected",
1912                "        > 3",
1913                "    > C",
1914                "      .dockerignore",
1915                "v root2",
1916                "    > d",
1917                "    > e",
1918            ]
1919        );
1920
1921        toggle_expand_dir(&panel, "root2/d", cx);
1922        assert_eq!(
1923            visible_entries_as_strings(&panel, 0..50, cx),
1924            &[
1925                "v root1",
1926                "    > a",
1927                "    v b",
1928                "        > 3",
1929                "    > C",
1930                "      .dockerignore",
1931                "v root2",
1932                "    v d  <== selected",
1933                "    > e",
1934            ]
1935        );
1936
1937        toggle_expand_dir(&panel, "root2/e", cx);
1938        assert_eq!(
1939            visible_entries_as_strings(&panel, 0..50, cx),
1940            &[
1941                "v root1",
1942                "    > a",
1943                "    v b",
1944                "        > 3",
1945                "    > C",
1946                "      .dockerignore",
1947                "v root2",
1948                "    v d",
1949                "    v e  <== selected",
1950            ]
1951        );
1952    }
1953
1954    #[gpui::test(iterations = 30)]
1955    async fn test_editing_files(cx: &mut gpui::TestAppContext) {
1956        init_test(cx);
1957
1958        let fs = FakeFs::new(cx.executor().clone());
1959        fs.insert_tree(
1960            "/root1",
1961            json!({
1962                ".dockerignore": "",
1963                ".git": {
1964                    "HEAD": "",
1965                },
1966                "a": {
1967                    "0": { "q": "", "r": "", "s": "" },
1968                    "1": { "t": "", "u": "" },
1969                    "2": { "v": "", "w": "", "x": "", "y": "" },
1970                },
1971                "b": {
1972                    "3": { "Q": "" },
1973                    "4": { "R": "", "S": "", "T": "", "U": "" },
1974                },
1975                "C": {
1976                    "5": {},
1977                    "6": { "V": "", "W": "" },
1978                    "7": { "X": "" },
1979                    "8": { "Y": {}, "Z": "" }
1980                }
1981            }),
1982        )
1983        .await;
1984        fs.insert_tree(
1985            "/root2",
1986            json!({
1987                "d": {
1988                    "9": ""
1989                },
1990                "e": {}
1991            }),
1992        )
1993        .await;
1994
1995        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1996        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1997        let cx = &mut VisualTestContext::from_window(*workspace, cx);
1998        let panel = workspace
1999            .update(cx, |workspace, cx| {
2000                let panel = ProjectPanel::new(workspace, cx);
2001                workspace.add_panel(panel.clone(), cx);
2002                panel
2003            })
2004            .unwrap();
2005
2006        select_path(&panel, "root1", cx);
2007        assert_eq!(
2008            visible_entries_as_strings(&panel, 0..10, cx),
2009            &[
2010                "v root1  <== selected",
2011                "    > .git",
2012                "    > a",
2013                "    > b",
2014                "    > C",
2015                "      .dockerignore",
2016                "v root2",
2017                "    > d",
2018                "    > e",
2019            ]
2020        );
2021
2022        // Add a file with the root folder selected. The filename editor is placed
2023        // before the first file in the root folder.
2024        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2025        panel.update(cx, |panel, cx| {
2026            assert!(panel.filename_editor.read(cx).is_focused(cx));
2027        });
2028        assert_eq!(
2029            visible_entries_as_strings(&panel, 0..10, cx),
2030            &[
2031                "v root1",
2032                "    > .git",
2033                "    > a",
2034                "    > b",
2035                "    > C",
2036                "      [EDITOR: '']  <== selected",
2037                "      .dockerignore",
2038                "v root2",
2039                "    > d",
2040                "    > e",
2041            ]
2042        );
2043
2044        let confirm = panel.update(cx, |panel, cx| {
2045            panel
2046                .filename_editor
2047                .update(cx, |editor, cx| editor.set_text("the-new-filename", cx));
2048            panel.confirm_edit(cx).unwrap()
2049        });
2050        assert_eq!(
2051            visible_entries_as_strings(&panel, 0..10, cx),
2052            &[
2053                "v root1",
2054                "    > .git",
2055                "    > a",
2056                "    > b",
2057                "    > C",
2058                "      [PROCESSING: 'the-new-filename']  <== selected",
2059                "      .dockerignore",
2060                "v root2",
2061                "    > d",
2062                "    > e",
2063            ]
2064        );
2065
2066        confirm.await.unwrap();
2067        assert_eq!(
2068            visible_entries_as_strings(&panel, 0..10, cx),
2069            &[
2070                "v root1",
2071                "    > .git",
2072                "    > a",
2073                "    > b",
2074                "    > C",
2075                "      .dockerignore",
2076                "      the-new-filename  <== selected",
2077                "v root2",
2078                "    > d",
2079                "    > e",
2080            ]
2081        );
2082
2083        select_path(&panel, "root1/b", cx);
2084        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2085        assert_eq!(
2086            visible_entries_as_strings(&panel, 0..10, cx),
2087            &[
2088                "v root1",
2089                "    > .git",
2090                "    > a",
2091                "    v b",
2092                "        > 3",
2093                "        > 4",
2094                "          [EDITOR: '']  <== selected",
2095                "    > C",
2096                "      .dockerignore",
2097                "      the-new-filename",
2098            ]
2099        );
2100
2101        panel
2102            .update(cx, |panel, cx| {
2103                panel
2104                    .filename_editor
2105                    .update(cx, |editor, cx| editor.set_text("another-filename.txt", cx));
2106                panel.confirm_edit(cx).unwrap()
2107            })
2108            .await
2109            .unwrap();
2110        assert_eq!(
2111            visible_entries_as_strings(&panel, 0..10, cx),
2112            &[
2113                "v root1",
2114                "    > .git",
2115                "    > a",
2116                "    v b",
2117                "        > 3",
2118                "        > 4",
2119                "          another-filename.txt  <== selected",
2120                "    > C",
2121                "      .dockerignore",
2122                "      the-new-filename",
2123            ]
2124        );
2125
2126        select_path(&panel, "root1/b/another-filename.txt", cx);
2127        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2128        assert_eq!(
2129            visible_entries_as_strings(&panel, 0..10, cx),
2130            &[
2131                "v root1",
2132                "    > .git",
2133                "    > a",
2134                "    v b",
2135                "        > 3",
2136                "        > 4",
2137                "          [EDITOR: 'another-filename.txt']  <== selected",
2138                "    > C",
2139                "      .dockerignore",
2140                "      the-new-filename",
2141            ]
2142        );
2143
2144        let confirm = panel.update(cx, |panel, cx| {
2145            panel.filename_editor.update(cx, |editor, cx| {
2146                let file_name_selections = editor.selections.all::<usize>(cx);
2147                assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
2148                let file_name_selection = &file_name_selections[0];
2149                assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
2150                assert_eq!(file_name_selection.end, "another-filename".len(), "Should not select file extension");
2151
2152                editor.set_text("a-different-filename.tar.gz", cx)
2153            });
2154            panel.confirm_edit(cx).unwrap()
2155        });
2156        assert_eq!(
2157            visible_entries_as_strings(&panel, 0..10, cx),
2158            &[
2159                "v root1",
2160                "    > .git",
2161                "    > a",
2162                "    v b",
2163                "        > 3",
2164                "        > 4",
2165                "          [PROCESSING: 'a-different-filename.tar.gz']  <== selected",
2166                "    > C",
2167                "      .dockerignore",
2168                "      the-new-filename",
2169            ]
2170        );
2171
2172        confirm.await.unwrap();
2173        assert_eq!(
2174            visible_entries_as_strings(&panel, 0..10, cx),
2175            &[
2176                "v root1",
2177                "    > .git",
2178                "    > a",
2179                "    v b",
2180                "        > 3",
2181                "        > 4",
2182                "          a-different-filename.tar.gz  <== selected",
2183                "    > C",
2184                "      .dockerignore",
2185                "      the-new-filename",
2186            ]
2187        );
2188
2189        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2190        assert_eq!(
2191            visible_entries_as_strings(&panel, 0..10, cx),
2192            &[
2193                "v root1",
2194                "    > .git",
2195                "    > a",
2196                "    v b",
2197                "        > 3",
2198                "        > 4",
2199                "          [EDITOR: 'a-different-filename.tar.gz']  <== selected",
2200                "    > C",
2201                "      .dockerignore",
2202                "      the-new-filename",
2203            ]
2204        );
2205
2206        panel.update(cx, |panel, cx| {
2207            panel.filename_editor.update(cx, |editor, cx| {
2208                let file_name_selections = editor.selections.all::<usize>(cx);
2209                assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
2210                let file_name_selection = &file_name_selections[0];
2211                assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
2212                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..");
2213
2214            });
2215            panel.cancel(&Cancel, cx)
2216        });
2217
2218        panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2219        assert_eq!(
2220            visible_entries_as_strings(&panel, 0..10, cx),
2221            &[
2222                "v root1",
2223                "    > .git",
2224                "    > a",
2225                "    v b",
2226                "        > [EDITOR: '']  <== selected",
2227                "        > 3",
2228                "        > 4",
2229                "          a-different-filename.tar.gz",
2230                "    > C",
2231                "      .dockerignore",
2232            ]
2233        );
2234
2235        let confirm = panel.update(cx, |panel, cx| {
2236            panel
2237                .filename_editor
2238                .update(cx, |editor, cx| editor.set_text("new-dir", cx));
2239            panel.confirm_edit(cx).unwrap()
2240        });
2241        panel.update(cx, |panel, cx| panel.select_next(&Default::default(), cx));
2242        assert_eq!(
2243            visible_entries_as_strings(&panel, 0..10, cx),
2244            &[
2245                "v root1",
2246                "    > .git",
2247                "    > a",
2248                "    v b",
2249                "        > [PROCESSING: 'new-dir']",
2250                "        > 3  <== selected",
2251                "        > 4",
2252                "          a-different-filename.tar.gz",
2253                "    > C",
2254                "      .dockerignore",
2255            ]
2256        );
2257
2258        confirm.await.unwrap();
2259        assert_eq!(
2260            visible_entries_as_strings(&panel, 0..10, cx),
2261            &[
2262                "v root1",
2263                "    > .git",
2264                "    > a",
2265                "    v b",
2266                "        > 3  <== selected",
2267                "        > 4",
2268                "        > new-dir",
2269                "          a-different-filename.tar.gz",
2270                "    > C",
2271                "      .dockerignore",
2272            ]
2273        );
2274
2275        panel.update(cx, |panel, cx| panel.rename(&Default::default(), cx));
2276        assert_eq!(
2277            visible_entries_as_strings(&panel, 0..10, cx),
2278            &[
2279                "v root1",
2280                "    > .git",
2281                "    > a",
2282                "    v b",
2283                "        > [EDITOR: '3']  <== selected",
2284                "        > 4",
2285                "        > new-dir",
2286                "          a-different-filename.tar.gz",
2287                "    > C",
2288                "      .dockerignore",
2289            ]
2290        );
2291
2292        // Dismiss the rename editor when it loses focus.
2293        workspace.update(cx, |_, cx| cx.blur()).unwrap();
2294        assert_eq!(
2295            visible_entries_as_strings(&panel, 0..10, cx),
2296            &[
2297                "v root1",
2298                "    > .git",
2299                "    > a",
2300                "    v b",
2301                "        > 3  <== selected",
2302                "        > 4",
2303                "        > new-dir",
2304                "          a-different-filename.tar.gz",
2305                "    > C",
2306                "      .dockerignore",
2307            ]
2308        );
2309    }
2310
2311    #[gpui::test(iterations = 10)]
2312    async fn test_adding_directories_via_file(cx: &mut gpui::TestAppContext) {
2313        init_test(cx);
2314
2315        let fs = FakeFs::new(cx.executor().clone());
2316        fs.insert_tree(
2317            "/root1",
2318            json!({
2319                ".dockerignore": "",
2320                ".git": {
2321                    "HEAD": "",
2322                },
2323                "a": {
2324                    "0": { "q": "", "r": "", "s": "" },
2325                    "1": { "t": "", "u": "" },
2326                    "2": { "v": "", "w": "", "x": "", "y": "" },
2327                },
2328                "b": {
2329                    "3": { "Q": "" },
2330                    "4": { "R": "", "S": "", "T": "", "U": "" },
2331                },
2332                "C": {
2333                    "5": {},
2334                    "6": { "V": "", "W": "" },
2335                    "7": { "X": "" },
2336                    "8": { "Y": {}, "Z": "" }
2337                }
2338            }),
2339        )
2340        .await;
2341        fs.insert_tree(
2342            "/root2",
2343            json!({
2344                "d": {
2345                    "9": ""
2346                },
2347                "e": {}
2348            }),
2349        )
2350        .await;
2351
2352        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
2353        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2354        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2355        let panel = workspace
2356            .update(cx, |workspace, cx| {
2357                let panel = ProjectPanel::new(workspace, cx);
2358                workspace.add_panel(panel.clone(), cx);
2359                panel
2360            })
2361            .unwrap();
2362
2363        select_path(&panel, "root1", cx);
2364        assert_eq!(
2365            visible_entries_as_strings(&panel, 0..10, cx),
2366            &[
2367                "v root1  <== selected",
2368                "    > .git",
2369                "    > a",
2370                "    > b",
2371                "    > C",
2372                "      .dockerignore",
2373                "v root2",
2374                "    > d",
2375                "    > e",
2376            ]
2377        );
2378
2379        // Add a file with the root folder selected. The filename editor is placed
2380        // before the first file in the root folder.
2381        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2382        panel.update(cx, |panel, cx| {
2383            assert!(panel.filename_editor.read(cx).is_focused(cx));
2384        });
2385        assert_eq!(
2386            visible_entries_as_strings(&panel, 0..10, cx),
2387            &[
2388                "v root1",
2389                "    > .git",
2390                "    > a",
2391                "    > b",
2392                "    > C",
2393                "      [EDITOR: '']  <== selected",
2394                "      .dockerignore",
2395                "v root2",
2396                "    > d",
2397                "    > e",
2398            ]
2399        );
2400
2401        let confirm = panel.update(cx, |panel, cx| {
2402            panel.filename_editor.update(cx, |editor, cx| {
2403                editor.set_text("/bdir1/dir2/the-new-filename", cx)
2404            });
2405            panel.confirm_edit(cx).unwrap()
2406        });
2407
2408        assert_eq!(
2409            visible_entries_as_strings(&panel, 0..10, cx),
2410            &[
2411                "v root1",
2412                "    > .git",
2413                "    > a",
2414                "    > b",
2415                "    > C",
2416                "      [PROCESSING: '/bdir1/dir2/the-new-filename']  <== selected",
2417                "      .dockerignore",
2418                "v root2",
2419                "    > d",
2420                "    > e",
2421            ]
2422        );
2423
2424        confirm.await.unwrap();
2425        assert_eq!(
2426            visible_entries_as_strings(&panel, 0..13, cx),
2427            &[
2428                "v root1",
2429                "    > .git",
2430                "    > a",
2431                "    > b",
2432                "    v bdir1",
2433                "        v dir2",
2434                "              the-new-filename  <== selected",
2435                "    > C",
2436                "      .dockerignore",
2437                "v root2",
2438                "    > d",
2439                "    > e",
2440            ]
2441        );
2442    }
2443
2444    #[gpui::test]
2445    async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
2446        init_test(cx);
2447
2448        let fs = FakeFs::new(cx.executor().clone());
2449        fs.insert_tree(
2450            "/root1",
2451            json!({
2452                "one.two.txt": "",
2453                "one.txt": ""
2454            }),
2455        )
2456        .await;
2457
2458        let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
2459        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2460        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2461        let panel = workspace
2462            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2463            .unwrap();
2464
2465        panel.update(cx, |panel, cx| {
2466            panel.select_next(&Default::default(), cx);
2467            panel.select_next(&Default::default(), cx);
2468        });
2469
2470        assert_eq!(
2471            visible_entries_as_strings(&panel, 0..50, cx),
2472            &[
2473                //
2474                "v root1",
2475                "      one.two.txt  <== selected",
2476                "      one.txt",
2477            ]
2478        );
2479
2480        // Regression test - file name is created correctly when
2481        // the copied file's name contains multiple dots.
2482        panel.update(cx, |panel, cx| {
2483            panel.copy(&Default::default(), cx);
2484            panel.paste(&Default::default(), cx);
2485        });
2486        cx.executor().run_until_parked();
2487
2488        assert_eq!(
2489            visible_entries_as_strings(&panel, 0..50, cx),
2490            &[
2491                //
2492                "v root1",
2493                "      one.two copy.txt",
2494                "      one.two.txt  <== selected",
2495                "      one.txt",
2496            ]
2497        );
2498
2499        panel.update(cx, |panel, cx| {
2500            panel.paste(&Default::default(), cx);
2501        });
2502        cx.executor().run_until_parked();
2503
2504        assert_eq!(
2505            visible_entries_as_strings(&panel, 0..50, cx),
2506            &[
2507                //
2508                "v root1",
2509                "      one.two copy 1.txt",
2510                "      one.two copy.txt",
2511                "      one.two.txt  <== selected",
2512                "      one.txt",
2513            ]
2514        );
2515    }
2516
2517    #[gpui::test]
2518    async fn test_copy_paste_directory(cx: &mut gpui::TestAppContext) {
2519        init_test(cx);
2520
2521        let fs = FakeFs::new(cx.executor().clone());
2522        fs.insert_tree(
2523            "/root",
2524            json!({
2525                "a": {
2526                    "one.txt": "",
2527                    "two.txt": "",
2528                    "inner_dir": {
2529                        "three.txt": "",
2530                        "four.txt": "",
2531                    }
2532                },
2533                "b": {}
2534            }),
2535        )
2536        .await;
2537
2538        let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
2539        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2540        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2541        let panel = workspace
2542            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2543            .unwrap();
2544
2545        select_path(&panel, "root/a", cx);
2546        panel.update(cx, |panel, cx| {
2547            panel.copy(&Default::default(), cx);
2548            panel.select_next(&Default::default(), cx);
2549            panel.paste(&Default::default(), cx);
2550        });
2551        cx.executor().run_until_parked();
2552
2553        let pasted_dir = find_project_entry(&panel, "root/b/a", cx);
2554        assert_ne!(pasted_dir, None, "Pasted directory should have an entry");
2555
2556        let pasted_dir_file = find_project_entry(&panel, "root/b/a/one.txt", cx);
2557        assert_ne!(
2558            pasted_dir_file, None,
2559            "Pasted directory file should have an entry"
2560        );
2561
2562        let pasted_dir_inner_dir = find_project_entry(&panel, "root/b/a/inner_dir", cx);
2563        assert_ne!(
2564            pasted_dir_inner_dir, None,
2565            "Directories inside pasted directory should have an entry"
2566        );
2567
2568        toggle_expand_dir(&panel, "root/b", cx);
2569        toggle_expand_dir(&panel, "root/b/a", cx);
2570        toggle_expand_dir(&panel, "root/b/a/inner_dir", cx);
2571
2572        assert_eq!(
2573            visible_entries_as_strings(&panel, 0..50, cx),
2574            &[
2575                //
2576                "v root",
2577                "    > a",
2578                "    v b",
2579                "        v a",
2580                "            v inner_dir  <== selected",
2581                "                  four.txt",
2582                "                  three.txt",
2583                "              one.txt",
2584                "              two.txt",
2585            ]
2586        );
2587
2588        select_path(&panel, "root", cx);
2589        panel.update(cx, |panel, cx| panel.paste(&Default::default(), cx));
2590        cx.executor().run_until_parked();
2591        panel.update(cx, |panel, cx| panel.paste(&Default::default(), cx));
2592        cx.executor().run_until_parked();
2593        assert_eq!(
2594            visible_entries_as_strings(&panel, 0..50, cx),
2595            &[
2596                //
2597                "v root  <== selected",
2598                "    > a",
2599                "    > a copy",
2600                "    > a copy 1",
2601                "    v b",
2602                "        v a",
2603                "            v inner_dir",
2604                "                  four.txt",
2605                "                  three.txt",
2606                "              one.txt",
2607                "              two.txt"
2608            ]
2609        );
2610    }
2611
2612    #[gpui::test]
2613    async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
2614        init_test_with_editor(cx);
2615
2616        let fs = FakeFs::new(cx.executor().clone());
2617        fs.insert_tree(
2618            "/src",
2619            json!({
2620                "test": {
2621                    "first.rs": "// First Rust file",
2622                    "second.rs": "// Second Rust file",
2623                    "third.rs": "// Third Rust file",
2624                }
2625            }),
2626        )
2627        .await;
2628
2629        let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2630        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2631        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2632        let panel = workspace
2633            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2634            .unwrap();
2635
2636        toggle_expand_dir(&panel, "src/test", cx);
2637        select_path(&panel, "src/test/first.rs", cx);
2638        panel.update(cx, |panel, cx| panel.open(&Open, cx));
2639        cx.executor().run_until_parked();
2640        assert_eq!(
2641            visible_entries_as_strings(&panel, 0..10, cx),
2642            &[
2643                "v src",
2644                "    v test",
2645                "          first.rs  <== selected",
2646                "          second.rs",
2647                "          third.rs"
2648            ]
2649        );
2650        ensure_single_file_is_opened(&workspace, "test/first.rs", cx);
2651
2652        submit_deletion(&panel, cx);
2653        assert_eq!(
2654            visible_entries_as_strings(&panel, 0..10, cx),
2655            &[
2656                "v src",
2657                "    v test",
2658                "          second.rs",
2659                "          third.rs"
2660            ],
2661            "Project panel should have no deleted file, no other file is selected in it"
2662        );
2663        ensure_no_open_items_and_panes(&workspace, cx);
2664
2665        select_path(&panel, "src/test/second.rs", cx);
2666        panel.update(cx, |panel, cx| panel.open(&Open, cx));
2667        cx.executor().run_until_parked();
2668        assert_eq!(
2669            visible_entries_as_strings(&panel, 0..10, cx),
2670            &[
2671                "v src",
2672                "    v test",
2673                "          second.rs  <== selected",
2674                "          third.rs"
2675            ]
2676        );
2677        ensure_single_file_is_opened(&workspace, "test/second.rs", cx);
2678
2679        workspace
2680            .update(cx, |workspace, cx| {
2681                let active_items = workspace
2682                    .panes()
2683                    .iter()
2684                    .filter_map(|pane| pane.read(cx).active_item())
2685                    .collect::<Vec<_>>();
2686                assert_eq!(active_items.len(), 1);
2687                let open_editor = active_items
2688                    .into_iter()
2689                    .next()
2690                    .unwrap()
2691                    .downcast::<Editor>()
2692                    .expect("Open item should be an editor");
2693                open_editor.update(cx, |editor, cx| editor.set_text("Another text!", cx));
2694            })
2695            .unwrap();
2696        submit_deletion_skipping_prompt(&panel, cx);
2697        assert_eq!(
2698            visible_entries_as_strings(&panel, 0..10, cx),
2699            &["v src", "    v test", "          third.rs"],
2700            "Project panel should have no deleted file, with one last file remaining"
2701        );
2702        ensure_no_open_items_and_panes(&workspace, cx);
2703    }
2704
2705    #[gpui::test]
2706    async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
2707        init_test_with_editor(cx);
2708
2709        let fs = FakeFs::new(cx.executor().clone());
2710        fs.insert_tree(
2711            "/src",
2712            json!({
2713                "test": {
2714                    "first.rs": "// First Rust file",
2715                    "second.rs": "// Second Rust file",
2716                    "third.rs": "// Third Rust file",
2717                }
2718            }),
2719        )
2720        .await;
2721
2722        let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2723        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2724        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2725        let panel = workspace
2726            .update(cx, |workspace, cx| {
2727                let panel = ProjectPanel::new(workspace, cx);
2728                workspace.add_panel(panel.clone(), cx);
2729                panel
2730            })
2731            .unwrap();
2732
2733        select_path(&panel, "src/", cx);
2734        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2735        cx.executor().run_until_parked();
2736        assert_eq!(
2737            visible_entries_as_strings(&panel, 0..10, cx),
2738            &[
2739                //
2740                "v src  <== selected",
2741                "    > test"
2742            ]
2743        );
2744        panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2745        panel.update(cx, |panel, cx| {
2746            assert!(panel.filename_editor.read(cx).is_focused(cx));
2747        });
2748        assert_eq!(
2749            visible_entries_as_strings(&panel, 0..10, cx),
2750            &[
2751                //
2752                "v src",
2753                "    > [EDITOR: '']  <== selected",
2754                "    > test"
2755            ]
2756        );
2757        panel.update(cx, |panel, cx| {
2758            panel
2759                .filename_editor
2760                .update(cx, |editor, cx| editor.set_text("test", cx));
2761            assert!(
2762                panel.confirm_edit(cx).is_none(),
2763                "Should not allow to confirm on conflicting new directory name"
2764            )
2765        });
2766        assert_eq!(
2767            visible_entries_as_strings(&panel, 0..10, cx),
2768            &[
2769                //
2770                "v src",
2771                "    > test"
2772            ],
2773            "File list should be unchanged after failed folder create confirmation"
2774        );
2775
2776        select_path(&panel, "src/test/", cx);
2777        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2778        cx.executor().run_until_parked();
2779        assert_eq!(
2780            visible_entries_as_strings(&panel, 0..10, cx),
2781            &[
2782                //
2783                "v src",
2784                "    > test  <== selected"
2785            ]
2786        );
2787        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2788        panel.update(cx, |panel, cx| {
2789            assert!(panel.filename_editor.read(cx).is_focused(cx));
2790        });
2791        assert_eq!(
2792            visible_entries_as_strings(&panel, 0..10, cx),
2793            &[
2794                "v src",
2795                "    v test",
2796                "          [EDITOR: '']  <== selected",
2797                "          first.rs",
2798                "          second.rs",
2799                "          third.rs"
2800            ]
2801        );
2802        panel.update(cx, |panel, cx| {
2803            panel
2804                .filename_editor
2805                .update(cx, |editor, cx| editor.set_text("first.rs", cx));
2806            assert!(
2807                panel.confirm_edit(cx).is_none(),
2808                "Should not allow to confirm on conflicting new file name"
2809            )
2810        });
2811        assert_eq!(
2812            visible_entries_as_strings(&panel, 0..10, cx),
2813            &[
2814                "v src",
2815                "    v test",
2816                "          first.rs",
2817                "          second.rs",
2818                "          third.rs"
2819            ],
2820            "File list should be unchanged after failed file create confirmation"
2821        );
2822
2823        select_path(&panel, "src/test/first.rs", cx);
2824        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2825        cx.executor().run_until_parked();
2826        assert_eq!(
2827            visible_entries_as_strings(&panel, 0..10, cx),
2828            &[
2829                "v src",
2830                "    v test",
2831                "          first.rs  <== selected",
2832                "          second.rs",
2833                "          third.rs"
2834            ],
2835        );
2836        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2837        panel.update(cx, |panel, cx| {
2838            assert!(panel.filename_editor.read(cx).is_focused(cx));
2839        });
2840        assert_eq!(
2841            visible_entries_as_strings(&panel, 0..10, cx),
2842            &[
2843                "v src",
2844                "    v test",
2845                "          [EDITOR: 'first.rs']  <== selected",
2846                "          second.rs",
2847                "          third.rs"
2848            ]
2849        );
2850        panel.update(cx, |panel, cx| {
2851            panel
2852                .filename_editor
2853                .update(cx, |editor, cx| editor.set_text("second.rs", cx));
2854            assert!(
2855                panel.confirm_edit(cx).is_none(),
2856                "Should not allow to confirm on conflicting file rename"
2857            )
2858        });
2859        assert_eq!(
2860            visible_entries_as_strings(&panel, 0..10, cx),
2861            &[
2862                "v src",
2863                "    v test",
2864                "          first.rs  <== selected",
2865                "          second.rs",
2866                "          third.rs"
2867            ],
2868            "File list should be unchanged after failed rename confirmation"
2869        );
2870    }
2871
2872    #[gpui::test]
2873    async fn test_dir_toggle_collapse(cx: &mut gpui::TestAppContext) {
2874        init_test_with_editor(cx);
2875
2876        let fs = FakeFs::new(cx.executor().clone());
2877        fs.insert_tree(
2878            "/project_root",
2879            json!({
2880                "dir_1": {
2881                    "nested_dir": {
2882                        "file_a.py": "# File contents",
2883                    }
2884                },
2885                "file_1.py": "# File contents",
2886            }),
2887        )
2888        .await;
2889
2890        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
2891        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2892        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2893        let panel = workspace
2894            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2895            .unwrap();
2896
2897        panel.update(cx, |panel, cx| panel.open(&Open, cx));
2898        cx.executor().run_until_parked();
2899        select_path(&panel, "project_root/dir_1", cx);
2900        panel.update(cx, |panel, cx| panel.open(&Open, cx));
2901        select_path(&panel, "project_root/dir_1/nested_dir", cx);
2902        panel.update(cx, |panel, cx| panel.open(&Open, cx));
2903        panel.update(cx, |panel, cx| panel.open(&Open, cx));
2904        cx.executor().run_until_parked();
2905        assert_eq!(
2906            visible_entries_as_strings(&panel, 0..10, cx),
2907            &[
2908                "v project_root",
2909                "    v dir_1",
2910                "        > nested_dir  <== selected",
2911                "      file_1.py",
2912            ]
2913        );
2914    }
2915
2916    #[gpui::test]
2917    async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) {
2918        init_test_with_editor(cx);
2919
2920        let fs = FakeFs::new(cx.executor().clone());
2921        fs.insert_tree(
2922            "/project_root",
2923            json!({
2924                "dir_1": {
2925                    "nested_dir": {
2926                        "file_a.py": "# File contents",
2927                        "file_b.py": "# File contents",
2928                        "file_c.py": "# File contents",
2929                    },
2930                    "file_1.py": "# File contents",
2931                    "file_2.py": "# File contents",
2932                    "file_3.py": "# File contents",
2933                },
2934                "dir_2": {
2935                    "file_1.py": "# File contents",
2936                    "file_2.py": "# File contents",
2937                    "file_3.py": "# File contents",
2938                }
2939            }),
2940        )
2941        .await;
2942
2943        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
2944        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2945        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2946        let panel = workspace
2947            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2948            .unwrap();
2949
2950        panel.update(cx, |panel, cx| {
2951            panel.collapse_all_entries(&CollapseAllEntries, cx)
2952        });
2953        cx.executor().run_until_parked();
2954        assert_eq!(
2955            visible_entries_as_strings(&panel, 0..10, cx),
2956            &["v project_root", "    > dir_1", "    > dir_2",]
2957        );
2958
2959        // Open dir_1 and make sure nested_dir was collapsed when running collapse_all_entries
2960        toggle_expand_dir(&panel, "project_root/dir_1", cx);
2961        cx.executor().run_until_parked();
2962        assert_eq!(
2963            visible_entries_as_strings(&panel, 0..10, cx),
2964            &[
2965                "v project_root",
2966                "    v dir_1  <== selected",
2967                "        > nested_dir",
2968                "          file_1.py",
2969                "          file_2.py",
2970                "          file_3.py",
2971                "    > dir_2",
2972            ]
2973        );
2974    }
2975
2976    #[gpui::test]
2977    async fn test_new_file_move(cx: &mut gpui::TestAppContext) {
2978        init_test(cx);
2979
2980        let fs = FakeFs::new(cx.executor().clone());
2981        fs.as_fake().insert_tree("/root", json!({})).await;
2982        let project = Project::test(fs, ["/root".as_ref()], cx).await;
2983        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2984        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2985        let panel = workspace
2986            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2987            .unwrap();
2988
2989        // Make a new buffer with no backing file
2990        workspace
2991            .update(cx, |workspace, cx| {
2992                Editor::new_file(workspace, &Default::default(), cx)
2993            })
2994            .unwrap();
2995
2996        // "Save as"" the buffer, creating a new backing file for it
2997        let save_task = workspace
2998            .update(cx, |workspace, cx| {
2999                workspace.save_active_item(workspace::SaveIntent::Save, cx)
3000            })
3001            .unwrap();
3002
3003        cx.executor().run_until_parked();
3004        cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/new")));
3005        save_task.await.unwrap();
3006
3007        // Rename the file
3008        select_path(&panel, "root/new", cx);
3009        assert_eq!(
3010            visible_entries_as_strings(&panel, 0..10, cx),
3011            &["v root", "      new  <== selected"]
3012        );
3013        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
3014        panel.update(cx, |panel, cx| {
3015            panel
3016                .filename_editor
3017                .update(cx, |editor, cx| editor.set_text("newer", cx));
3018        });
3019        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
3020
3021        cx.executor().run_until_parked();
3022        assert_eq!(
3023            visible_entries_as_strings(&panel, 0..10, cx),
3024            &["v root", "      newer  <== selected"]
3025        );
3026
3027        workspace
3028            .update(cx, |workspace, cx| {
3029                workspace.save_active_item(workspace::SaveIntent::Save, cx)
3030            })
3031            .unwrap()
3032            .await
3033            .unwrap();
3034
3035        cx.executor().run_until_parked();
3036        // assert that saving the file doesn't restore "new"
3037        assert_eq!(
3038            visible_entries_as_strings(&panel, 0..10, cx),
3039            &["v root", "      newer  <== selected"]
3040        );
3041    }
3042
3043    #[gpui::test]
3044    async fn test_autoreveal_and_gitignored_files(cx: &mut gpui::TestAppContext) {
3045        init_test_with_editor(cx);
3046        cx.update(|cx| {
3047            cx.update_global::<SettingsStore, _>(|store, cx| {
3048                store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
3049                    worktree_settings.file_scan_exclusions = Some(Vec::new());
3050                });
3051                store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
3052                    project_panel_settings.auto_reveal_entries = Some(false)
3053                });
3054            })
3055        });
3056
3057        let fs = FakeFs::new(cx.background_executor.clone());
3058        fs.insert_tree(
3059            "/project_root",
3060            json!({
3061                ".git": {},
3062                ".gitignore": "**/gitignored_dir",
3063                "dir_1": {
3064                    "file_1.py": "# File 1_1 contents",
3065                    "file_2.py": "# File 1_2 contents",
3066                    "file_3.py": "# File 1_3 contents",
3067                    "gitignored_dir": {
3068                        "file_a.py": "# File contents",
3069                        "file_b.py": "# File contents",
3070                        "file_c.py": "# File contents",
3071                    },
3072                },
3073                "dir_2": {
3074                    "file_1.py": "# File 2_1 contents",
3075                    "file_2.py": "# File 2_2 contents",
3076                    "file_3.py": "# File 2_3 contents",
3077                }
3078            }),
3079        )
3080        .await;
3081
3082        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3083        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3084        let cx = &mut VisualTestContext::from_window(*workspace, cx);
3085        let panel = workspace
3086            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
3087            .unwrap();
3088
3089        assert_eq!(
3090            visible_entries_as_strings(&panel, 0..20, cx),
3091            &[
3092                "v project_root",
3093                "    > .git",
3094                "    > dir_1",
3095                "    > dir_2",
3096                "      .gitignore",
3097            ]
3098        );
3099
3100        let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
3101            .expect("dir 1 file is not ignored and should have an entry");
3102        let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
3103            .expect("dir 2 file is not ignored and should have an entry");
3104        let gitignored_dir_file =
3105            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
3106        assert_eq!(
3107            gitignored_dir_file, None,
3108            "File in the gitignored dir should not have an entry before its dir is toggled"
3109        );
3110
3111        toggle_expand_dir(&panel, "project_root/dir_1", cx);
3112        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3113        cx.executor().run_until_parked();
3114        assert_eq!(
3115            visible_entries_as_strings(&panel, 0..20, cx),
3116            &[
3117                "v project_root",
3118                "    > .git",
3119                "    v dir_1",
3120                "        v gitignored_dir  <== selected",
3121                "              file_a.py",
3122                "              file_b.py",
3123                "              file_c.py",
3124                "          file_1.py",
3125                "          file_2.py",
3126                "          file_3.py",
3127                "    > dir_2",
3128                "      .gitignore",
3129            ],
3130            "Should show gitignored dir file list in the project panel"
3131        );
3132        let gitignored_dir_file =
3133            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
3134                .expect("after gitignored dir got opened, a file entry should be present");
3135
3136        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3137        toggle_expand_dir(&panel, "project_root/dir_1", cx);
3138        assert_eq!(
3139            visible_entries_as_strings(&panel, 0..20, cx),
3140            &[
3141                "v project_root",
3142                "    > .git",
3143                "    > dir_1  <== selected",
3144                "    > dir_2",
3145                "      .gitignore",
3146            ],
3147            "Should hide all dir contents again and prepare for the auto reveal test"
3148        );
3149
3150        for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
3151            panel.update(cx, |panel, cx| {
3152                panel.project.update(cx, |_, cx| {
3153                    cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
3154                })
3155            });
3156            cx.run_until_parked();
3157            assert_eq!(
3158                visible_entries_as_strings(&panel, 0..20, cx),
3159                &[
3160                    "v project_root",
3161                    "    > .git",
3162                    "    > dir_1  <== selected",
3163                    "    > dir_2",
3164                    "      .gitignore",
3165                ],
3166                "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
3167            );
3168        }
3169
3170        cx.update(|cx| {
3171            cx.update_global::<SettingsStore, _>(|store, cx| {
3172                store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
3173                    project_panel_settings.auto_reveal_entries = Some(true)
3174                });
3175            })
3176        });
3177
3178        panel.update(cx, |panel, cx| {
3179            panel.project.update(cx, |_, cx| {
3180                cx.emit(project::Event::ActiveEntryChanged(Some(dir_1_file)))
3181            })
3182        });
3183        cx.run_until_parked();
3184        assert_eq!(
3185            visible_entries_as_strings(&panel, 0..20, cx),
3186            &[
3187                "v project_root",
3188                "    > .git",
3189                "    v dir_1",
3190                "        > gitignored_dir",
3191                "          file_1.py  <== selected",
3192                "          file_2.py",
3193                "          file_3.py",
3194                "    > dir_2",
3195                "      .gitignore",
3196            ],
3197            "When auto reveal is enabled, not ignored dir_1 entry should be revealed"
3198        );
3199
3200        panel.update(cx, |panel, cx| {
3201            panel.project.update(cx, |_, cx| {
3202                cx.emit(project::Event::ActiveEntryChanged(Some(dir_2_file)))
3203            })
3204        });
3205        cx.run_until_parked();
3206        assert_eq!(
3207            visible_entries_as_strings(&panel, 0..20, cx),
3208            &[
3209                "v project_root",
3210                "    > .git",
3211                "    v dir_1",
3212                "        > gitignored_dir",
3213                "          file_1.py",
3214                "          file_2.py",
3215                "          file_3.py",
3216                "    v dir_2",
3217                "          file_1.py  <== selected",
3218                "          file_2.py",
3219                "          file_3.py",
3220                "      .gitignore",
3221            ],
3222            "When auto reveal is enabled, not ignored dir_2 entry should be revealed"
3223        );
3224
3225        panel.update(cx, |panel, cx| {
3226            panel.project.update(cx, |_, cx| {
3227                cx.emit(project::Event::ActiveEntryChanged(Some(
3228                    gitignored_dir_file,
3229                )))
3230            })
3231        });
3232        cx.run_until_parked();
3233        assert_eq!(
3234            visible_entries_as_strings(&panel, 0..20, cx),
3235            &[
3236                "v project_root",
3237                "    > .git",
3238                "    v dir_1",
3239                "        > gitignored_dir",
3240                "          file_1.py",
3241                "          file_2.py",
3242                "          file_3.py",
3243                "    v dir_2",
3244                "          file_1.py  <== selected",
3245                "          file_2.py",
3246                "          file_3.py",
3247                "      .gitignore",
3248            ],
3249            "When auto reveal is enabled, a gitignored selected entry should not be revealed in the project panel"
3250        );
3251
3252        panel.update(cx, |panel, cx| {
3253            panel.project.update(cx, |_, cx| {
3254                cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
3255            })
3256        });
3257        cx.run_until_parked();
3258        assert_eq!(
3259            visible_entries_as_strings(&panel, 0..20, cx),
3260            &[
3261                "v project_root",
3262                "    > .git",
3263                "    v dir_1",
3264                "        v gitignored_dir",
3265                "              file_a.py  <== selected",
3266                "              file_b.py",
3267                "              file_c.py",
3268                "          file_1.py",
3269                "          file_2.py",
3270                "          file_3.py",
3271                "    v dir_2",
3272                "          file_1.py",
3273                "          file_2.py",
3274                "          file_3.py",
3275                "      .gitignore",
3276            ],
3277            "When a gitignored entry is explicitly revealed, it should be shown in the project tree"
3278        );
3279    }
3280
3281    #[gpui::test]
3282    async fn test_explicit_reveal(cx: &mut gpui::TestAppContext) {
3283        init_test_with_editor(cx);
3284        cx.update(|cx| {
3285            cx.update_global::<SettingsStore, _>(|store, cx| {
3286                store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
3287                    worktree_settings.file_scan_exclusions = Some(Vec::new());
3288                });
3289                store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
3290                    project_panel_settings.auto_reveal_entries = Some(false)
3291                });
3292            })
3293        });
3294
3295        let fs = FakeFs::new(cx.background_executor.clone());
3296        fs.insert_tree(
3297            "/project_root",
3298            json!({
3299                ".git": {},
3300                ".gitignore": "**/gitignored_dir",
3301                "dir_1": {
3302                    "file_1.py": "# File 1_1 contents",
3303                    "file_2.py": "# File 1_2 contents",
3304                    "file_3.py": "# File 1_3 contents",
3305                    "gitignored_dir": {
3306                        "file_a.py": "# File contents",
3307                        "file_b.py": "# File contents",
3308                        "file_c.py": "# File contents",
3309                    },
3310                },
3311                "dir_2": {
3312                    "file_1.py": "# File 2_1 contents",
3313                    "file_2.py": "# File 2_2 contents",
3314                    "file_3.py": "# File 2_3 contents",
3315                }
3316            }),
3317        )
3318        .await;
3319
3320        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3321        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3322        let cx = &mut VisualTestContext::from_window(*workspace, cx);
3323        let panel = workspace
3324            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
3325            .unwrap();
3326
3327        assert_eq!(
3328            visible_entries_as_strings(&panel, 0..20, cx),
3329            &[
3330                "v project_root",
3331                "    > .git",
3332                "    > dir_1",
3333                "    > dir_2",
3334                "      .gitignore",
3335            ]
3336        );
3337
3338        let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
3339            .expect("dir 1 file is not ignored and should have an entry");
3340        let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
3341            .expect("dir 2 file is not ignored and should have an entry");
3342        let gitignored_dir_file =
3343            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
3344        assert_eq!(
3345            gitignored_dir_file, None,
3346            "File in the gitignored dir should not have an entry before its dir is toggled"
3347        );
3348
3349        toggle_expand_dir(&panel, "project_root/dir_1", cx);
3350        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3351        cx.run_until_parked();
3352        assert_eq!(
3353            visible_entries_as_strings(&panel, 0..20, cx),
3354            &[
3355                "v project_root",
3356                "    > .git",
3357                "    v dir_1",
3358                "        v gitignored_dir  <== selected",
3359                "              file_a.py",
3360                "              file_b.py",
3361                "              file_c.py",
3362                "          file_1.py",
3363                "          file_2.py",
3364                "          file_3.py",
3365                "    > dir_2",
3366                "      .gitignore",
3367            ],
3368            "Should show gitignored dir file list in the project panel"
3369        );
3370        let gitignored_dir_file =
3371            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
3372                .expect("after gitignored dir got opened, a file entry should be present");
3373
3374        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3375        toggle_expand_dir(&panel, "project_root/dir_1", cx);
3376        assert_eq!(
3377            visible_entries_as_strings(&panel, 0..20, cx),
3378            &[
3379                "v project_root",
3380                "    > .git",
3381                "    > dir_1  <== selected",
3382                "    > dir_2",
3383                "      .gitignore",
3384            ],
3385            "Should hide all dir contents again and prepare for the explicit reveal test"
3386        );
3387
3388        for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
3389            panel.update(cx, |panel, cx| {
3390                panel.project.update(cx, |_, cx| {
3391                    cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
3392                })
3393            });
3394            cx.run_until_parked();
3395            assert_eq!(
3396                visible_entries_as_strings(&panel, 0..20, cx),
3397                &[
3398                    "v project_root",
3399                    "    > .git",
3400                    "    > dir_1  <== selected",
3401                    "    > dir_2",
3402                    "      .gitignore",
3403                ],
3404                "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
3405            );
3406        }
3407
3408        panel.update(cx, |panel, cx| {
3409            panel.project.update(cx, |_, cx| {
3410                cx.emit(project::Event::RevealInProjectPanel(dir_1_file))
3411            })
3412        });
3413        cx.run_until_parked();
3414        assert_eq!(
3415            visible_entries_as_strings(&panel, 0..20, cx),
3416            &[
3417                "v project_root",
3418                "    > .git",
3419                "    v dir_1",
3420                "        > gitignored_dir",
3421                "          file_1.py  <== selected",
3422                "          file_2.py",
3423                "          file_3.py",
3424                "    > dir_2",
3425                "      .gitignore",
3426            ],
3427            "With no auto reveal, explicit reveal should show the dir_1 entry in the project panel"
3428        );
3429
3430        panel.update(cx, |panel, cx| {
3431            panel.project.update(cx, |_, cx| {
3432                cx.emit(project::Event::RevealInProjectPanel(dir_2_file))
3433            })
3434        });
3435        cx.run_until_parked();
3436        assert_eq!(
3437            visible_entries_as_strings(&panel, 0..20, cx),
3438            &[
3439                "v project_root",
3440                "    > .git",
3441                "    v dir_1",
3442                "        > gitignored_dir",
3443                "          file_1.py",
3444                "          file_2.py",
3445                "          file_3.py",
3446                "    v dir_2",
3447                "          file_1.py  <== selected",
3448                "          file_2.py",
3449                "          file_3.py",
3450                "      .gitignore",
3451            ],
3452            "With no auto reveal, explicit reveal should show the dir_2 entry in the project panel"
3453        );
3454
3455        panel.update(cx, |panel, cx| {
3456            panel.project.update(cx, |_, cx| {
3457                cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
3458            })
3459        });
3460        cx.run_until_parked();
3461        assert_eq!(
3462            visible_entries_as_strings(&panel, 0..20, cx),
3463            &[
3464                "v project_root",
3465                "    > .git",
3466                "    v dir_1",
3467                "        v gitignored_dir",
3468                "              file_a.py  <== selected",
3469                "              file_b.py",
3470                "              file_c.py",
3471                "          file_1.py",
3472                "          file_2.py",
3473                "          file_3.py",
3474                "    v dir_2",
3475                "          file_1.py",
3476                "          file_2.py",
3477                "          file_3.py",
3478                "      .gitignore",
3479            ],
3480            "With no auto reveal, explicit reveal should show the gitignored entry in the project panel"
3481        );
3482    }
3483
3484    fn toggle_expand_dir(
3485        panel: &View<ProjectPanel>,
3486        path: impl AsRef<Path>,
3487        cx: &mut VisualTestContext,
3488    ) {
3489        let path = path.as_ref();
3490        panel.update(cx, |panel, cx| {
3491            for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
3492                let worktree = worktree.read(cx);
3493                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
3494                    let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
3495                    panel.toggle_expanded(entry_id, cx);
3496                    return;
3497                }
3498            }
3499            panic!("no worktree for path {:?}", path);
3500        });
3501    }
3502
3503    fn select_path(panel: &View<ProjectPanel>, path: impl AsRef<Path>, cx: &mut VisualTestContext) {
3504        let path = path.as_ref();
3505        panel.update(cx, |panel, cx| {
3506            for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
3507                let worktree = worktree.read(cx);
3508                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
3509                    let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
3510                    panel.selection = Some(crate::Selection {
3511                        worktree_id: worktree.id(),
3512                        entry_id,
3513                    });
3514                    return;
3515                }
3516            }
3517            panic!("no worktree for path {:?}", path);
3518        });
3519    }
3520
3521    fn find_project_entry(
3522        panel: &View<ProjectPanel>,
3523        path: impl AsRef<Path>,
3524        cx: &mut VisualTestContext,
3525    ) -> Option<ProjectEntryId> {
3526        let path = path.as_ref();
3527        panel.update(cx, |panel, cx| {
3528            for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
3529                let worktree = worktree.read(cx);
3530                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
3531                    return worktree.entry_for_path(relative_path).map(|entry| entry.id);
3532                }
3533            }
3534            panic!("no worktree for path {path:?}");
3535        })
3536    }
3537
3538    fn visible_entries_as_strings(
3539        panel: &View<ProjectPanel>,
3540        range: Range<usize>,
3541        cx: &mut VisualTestContext,
3542    ) -> Vec<String> {
3543        let mut result = Vec::new();
3544        let mut project_entries = HashSet::default();
3545        let mut has_editor = false;
3546
3547        panel.update(cx, |panel, cx| {
3548            panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
3549                if details.is_editing {
3550                    assert!(!has_editor, "duplicate editor entry");
3551                    has_editor = true;
3552                } else {
3553                    assert!(
3554                        project_entries.insert(project_entry),
3555                        "duplicate project entry {:?} {:?}",
3556                        project_entry,
3557                        details
3558                    );
3559                }
3560
3561                let indent = "    ".repeat(details.depth);
3562                let icon = if details.kind.is_dir() {
3563                    if details.is_expanded {
3564                        "v "
3565                    } else {
3566                        "> "
3567                    }
3568                } else {
3569                    "  "
3570                };
3571                let name = if details.is_editing {
3572                    format!("[EDITOR: '{}']", details.filename)
3573                } else if details.is_processing {
3574                    format!("[PROCESSING: '{}']", details.filename)
3575                } else {
3576                    details.filename.clone()
3577                };
3578                let selected = if details.is_selected {
3579                    "  <== selected"
3580                } else {
3581                    ""
3582                };
3583                result.push(format!("{indent}{icon}{name}{selected}"));
3584            });
3585        });
3586
3587        result
3588    }
3589
3590    fn init_test(cx: &mut TestAppContext) {
3591        cx.update(|cx| {
3592            let settings_store = SettingsStore::test(cx);
3593            cx.set_global(settings_store);
3594            init_settings(cx);
3595            theme::init(theme::LoadThemes::JustBase, cx);
3596            language::init(cx);
3597            editor::init_settings(cx);
3598            crate::init((), cx);
3599            workspace::init_settings(cx);
3600            client::init_settings(cx);
3601            Project::init_settings(cx);
3602
3603            cx.update_global::<SettingsStore, _>(|store, cx| {
3604                store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
3605                    worktree_settings.file_scan_exclusions = Some(Vec::new());
3606                });
3607            });
3608        });
3609    }
3610
3611    fn init_test_with_editor(cx: &mut TestAppContext) {
3612        cx.update(|cx| {
3613            let app_state = AppState::test(cx);
3614            theme::init(theme::LoadThemes::JustBase, cx);
3615            init_settings(cx);
3616            language::init(cx);
3617            editor::init(cx);
3618            crate::init((), cx);
3619            workspace::init(app_state.clone(), cx);
3620            Project::init_settings(cx);
3621        });
3622    }
3623
3624    fn ensure_single_file_is_opened(
3625        window: &WindowHandle<Workspace>,
3626        expected_path: &str,
3627        cx: &mut TestAppContext,
3628    ) {
3629        window
3630            .update(cx, |workspace, cx| {
3631                let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
3632                assert_eq!(worktrees.len(), 1);
3633                let worktree_id = worktrees[0].read(cx).id();
3634
3635                let open_project_paths = workspace
3636                    .panes()
3637                    .iter()
3638                    .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
3639                    .collect::<Vec<_>>();
3640                assert_eq!(
3641                    open_project_paths,
3642                    vec![ProjectPath {
3643                        worktree_id,
3644                        path: Arc::from(Path::new(expected_path))
3645                    }],
3646                    "Should have opened file, selected in project panel"
3647                );
3648            })
3649            .unwrap();
3650    }
3651
3652    fn submit_deletion(panel: &View<ProjectPanel>, cx: &mut VisualTestContext) {
3653        assert!(
3654            !cx.has_pending_prompt(),
3655            "Should have no prompts before the deletion"
3656        );
3657        panel.update(cx, |panel, cx| {
3658            panel.delete(&Delete { skip_prompt: false }, cx)
3659        });
3660        assert!(
3661            cx.has_pending_prompt(),
3662            "Should have a prompt after the deletion"
3663        );
3664        cx.simulate_prompt_answer(0);
3665        assert!(
3666            !cx.has_pending_prompt(),
3667            "Should have no prompts after prompt was replied to"
3668        );
3669        cx.executor().run_until_parked();
3670    }
3671
3672    fn submit_deletion_skipping_prompt(panel: &View<ProjectPanel>, cx: &mut VisualTestContext) {
3673        assert!(
3674            !cx.has_pending_prompt(),
3675            "Should have no prompts before the deletion"
3676        );
3677        panel.update(cx, |panel, cx| {
3678            panel.delete(&Delete { skip_prompt: true }, cx)
3679        });
3680        assert!(!cx.has_pending_prompt(), "Should have received no prompts");
3681        cx.executor().run_until_parked();
3682    }
3683
3684    fn ensure_no_open_items_and_panes(
3685        workspace: &WindowHandle<Workspace>,
3686        cx: &mut VisualTestContext,
3687    ) {
3688        assert!(
3689            !cx.has_pending_prompt(),
3690            "Should have no prompts after deletion operation closes the file"
3691        );
3692        workspace
3693            .read_with(cx, |workspace, cx| {
3694                let open_project_paths = workspace
3695                    .panes()
3696                    .iter()
3697                    .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
3698                    .collect::<Vec<_>>();
3699                assert!(
3700                    open_project_paths.is_empty(),
3701                    "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
3702                );
3703            })
3704            .unwrap();
3705    }
3706}