project_panel.rs

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