project_panel.rs

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