project_panel.rs

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