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