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) = {
1180                let settings = settings::get::<ProjectPanelSettings>(cx);
1181                (settings.git_status, settings.file_icons)
1182            };
1183            if let Some(worktree) = self.project.read(cx).worktree_for_id(*worktree_id, cx) {
1184                let snapshot = worktree.read(cx).snapshot();
1185                let root_name = OsStr::new(snapshot.root_name());
1186                let expanded_entry_ids = self
1187                    .expanded_dir_ids
1188                    .get(&snapshot.id())
1189                    .map(Vec::as_slice)
1190                    .unwrap_or(&[]);
1191
1192                let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
1193                for entry in visible_worktree_entries[entry_range].iter() {
1194                    let status = git_status_setting.then(|| entry.git_status).flatten();
1195                    let is_expanded = expanded_entry_ids.binary_search(&entry.id).is_ok();
1196                    let icon = show_file_icons.then(|| match entry.kind {
1197                        EntryKind::File(_) => FileAssociations::get_icon(&entry.path, cx),
1198                        _ => FileAssociations::get_folder_icon(is_expanded, cx),
1199                    });
1200
1201                    let mut details = EntryDetails {
1202                        filename: entry
1203                            .path
1204                            .file_name()
1205                            .unwrap_or(root_name)
1206                            .to_string_lossy()
1207                            .to_string(),
1208                        icon,
1209                        path: entry.path.clone(),
1210                        depth: entry.path.components().count(),
1211                        kind: entry.kind,
1212                        is_ignored: entry.is_ignored,
1213                        is_expanded,
1214                        is_selected: self.selection.map_or(false, |e| {
1215                            e.worktree_id == snapshot.id() && e.entry_id == entry.id
1216                        }),
1217                        is_editing: false,
1218                        is_processing: false,
1219                        is_cut: self
1220                            .clipboard_entry
1221                            .map_or(false, |e| e.is_cut() && e.entry_id() == entry.id),
1222                        git_status: status,
1223                    };
1224
1225                    if let Some(edit_state) = &self.edit_state {
1226                        let is_edited_entry = if edit_state.is_new_entry {
1227                            entry.id == NEW_ENTRY_ID
1228                        } else {
1229                            entry.id == edit_state.entry_id
1230                        };
1231
1232                        if is_edited_entry {
1233                            if let Some(processing_filename) = &edit_state.processing_filename {
1234                                details.is_processing = true;
1235                                details.filename.clear();
1236                                details.filename.push_str(processing_filename);
1237                            } else {
1238                                if edit_state.is_new_entry {
1239                                    details.filename.clear();
1240                                }
1241                                details.is_editing = true;
1242                            }
1243                        }
1244                    }
1245
1246                    callback(entry.id, details, cx);
1247                }
1248            }
1249            ix = end_ix;
1250        }
1251    }
1252
1253    fn render_entry_visual_element<V: View>(
1254        details: &EntryDetails,
1255        editor: Option<&ViewHandle<Editor>>,
1256        padding: f32,
1257        row_container_style: ContainerStyle,
1258        style: &ProjectPanelEntry,
1259        cx: &mut ViewContext<V>,
1260    ) -> AnyElement<V> {
1261        let kind = details.kind;
1262        let show_editor = details.is_editing && !details.is_processing;
1263
1264        let mut filename_text_style = style.text.clone();
1265        filename_text_style.color = details
1266            .git_status
1267            .as_ref()
1268            .map(|status| match status {
1269                GitFileStatus::Added => style.status.git.inserted,
1270                GitFileStatus::Modified => style.status.git.modified,
1271                GitFileStatus::Conflict => style.status.git.conflict,
1272            })
1273            .unwrap_or(style.text.color);
1274
1275        Flex::row()
1276            .with_child(if let Some(icon) = &details.icon {
1277                Svg::new(icon.to_string())
1278                    .with_color(style.icon_color)
1279                    .constrained()
1280                    .with_max_width(style.icon_size)
1281                    .with_max_height(style.icon_size)
1282                    .aligned()
1283                    .constrained()
1284                    .with_width(style.icon_size)
1285            } else if kind.is_dir() {
1286                if details.is_expanded {
1287                    Svg::new("icons/chevron_down_8.svg").with_color(style.chevron_color)
1288                } else {
1289                    Svg::new("icons/chevron_right_8.svg").with_color(style.chevron_color)
1290                }
1291                .constrained()
1292                .with_max_width(style.chevron_size)
1293                .with_max_height(style.chevron_size)
1294                .aligned()
1295                .constrained()
1296                .with_width(style.chevron_size)
1297            } else {
1298                Empty::new()
1299                    .constrained()
1300                    .with_max_width(style.chevron_size)
1301                    .with_max_height(style.chevron_size)
1302                    .aligned()
1303                    .constrained()
1304                    .with_width(style.chevron_size)
1305            })
1306            .with_child(if show_editor && editor.is_some() {
1307                ChildView::new(editor.as_ref().unwrap(), cx)
1308                    .contained()
1309                    .with_margin_left(style.icon_spacing)
1310                    .aligned()
1311                    .left()
1312                    .flex(1.0, true)
1313                    .into_any()
1314            } else {
1315                Label::new(details.filename.clone(), filename_text_style)
1316                    .contained()
1317                    .with_margin_left(style.icon_spacing)
1318                    .aligned()
1319                    .left()
1320                    .into_any()
1321            })
1322            .constrained()
1323            .with_height(style.height)
1324            .contained()
1325            .with_style(row_container_style)
1326            .with_padding_left(padding)
1327            .into_any_named("project panel entry visual element")
1328    }
1329
1330    fn render_entry(
1331        entry_id: ProjectEntryId,
1332        details: EntryDetails,
1333        editor: &ViewHandle<Editor>,
1334        dragged_entry_destination: &mut Option<Arc<Path>>,
1335        theme: &theme::ProjectPanel,
1336        cx: &mut ViewContext<Self>,
1337    ) -> AnyElement<Self> {
1338        let kind = details.kind;
1339        let path = details.path.clone();
1340        let padding = theme.container.padding.left + details.depth as f32 * theme.indent_width;
1341
1342        let entry_style = if details.is_cut {
1343            &theme.cut_entry
1344        } else if details.is_ignored {
1345            &theme.ignored_entry
1346        } else {
1347            &theme.entry
1348        };
1349
1350        let show_editor = details.is_editing && !details.is_processing;
1351
1352        MouseEventHandler::<Self, _>::new(entry_id.to_usize(), cx, |state, cx| {
1353            let mut style = entry_style
1354                .in_state(details.is_selected)
1355                .style_for(state)
1356                .clone();
1357
1358            if cx
1359                .global::<DragAndDrop<Workspace>>()
1360                .currently_dragged::<ProjectEntryId>(cx.window_id())
1361                .is_some()
1362                && dragged_entry_destination
1363                    .as_ref()
1364                    .filter(|destination| details.path.starts_with(destination))
1365                    .is_some()
1366            {
1367                style = entry_style.active_state().default.clone();
1368            }
1369
1370            let row_container_style = if show_editor {
1371                theme.filename_editor.container
1372            } else {
1373                style.container
1374            };
1375
1376            Self::render_entry_visual_element(
1377                &details,
1378                Some(editor),
1379                padding,
1380                row_container_style,
1381                &style,
1382                cx,
1383            )
1384        })
1385        .on_click(MouseButton::Left, move |event, this, cx| {
1386            if !show_editor {
1387                if kind.is_dir() {
1388                    this.toggle_expanded(entry_id, cx);
1389                } else {
1390                    if event.cmd {
1391                        this.split_entry(entry_id, cx);
1392                    } else if !event.cmd {
1393                        this.open_entry(entry_id, event.click_count > 1, cx);
1394                    }
1395                }
1396            }
1397        })
1398        .on_down(MouseButton::Right, move |event, this, cx| {
1399            this.deploy_context_menu(event.position, entry_id, cx);
1400        })
1401        .on_up(MouseButton::Left, move |_, this, cx| {
1402            if let Some((_, dragged_entry)) = cx
1403                .global::<DragAndDrop<Workspace>>()
1404                .currently_dragged::<ProjectEntryId>(cx.window_id())
1405            {
1406                this.move_entry(
1407                    *dragged_entry,
1408                    entry_id,
1409                    matches!(details.kind, EntryKind::File(_)),
1410                    cx,
1411                );
1412            }
1413        })
1414        .on_move(move |_, this, cx| {
1415            if cx
1416                .global::<DragAndDrop<Workspace>>()
1417                .currently_dragged::<ProjectEntryId>(cx.window_id())
1418                .is_some()
1419            {
1420                this.dragged_entry_destination = if matches!(kind, EntryKind::File(_)) {
1421                    path.parent().map(|parent| Arc::from(parent))
1422                } else {
1423                    Some(path.clone())
1424                };
1425            }
1426        })
1427        .as_draggable(entry_id, {
1428            let row_container_style = theme.dragged_entry.container;
1429
1430            move |_, cx: &mut ViewContext<Workspace>| {
1431                let theme = theme::current(cx).clone();
1432                Self::render_entry_visual_element(
1433                    &details,
1434                    None,
1435                    padding,
1436                    row_container_style,
1437                    &theme.project_panel.dragged_entry,
1438                    cx,
1439                )
1440            }
1441        })
1442        .with_cursor_style(CursorStyle::PointingHand)
1443        .into_any_named("project panel entry")
1444    }
1445}
1446
1447impl View for ProjectPanel {
1448    fn ui_name() -> &'static str {
1449        "ProjectPanel"
1450    }
1451
1452    fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> gpui::AnyElement<Self> {
1453        enum ProjectPanel {}
1454        let theme = &theme::current(cx).project_panel;
1455        let mut container_style = theme.container;
1456        let padding = std::mem::take(&mut container_style.padding);
1457        let last_worktree_root_id = self.last_worktree_root_id;
1458
1459        let has_worktree = self.visible_entries.len() != 0;
1460
1461        if has_worktree {
1462            Stack::new()
1463                .with_child(
1464                    MouseEventHandler::<ProjectPanel, _>::new(0, cx, |_, cx| {
1465                        UniformList::new(
1466                            self.list.clone(),
1467                            self.visible_entries
1468                                .iter()
1469                                .map(|(_, worktree_entries)| worktree_entries.len())
1470                                .sum(),
1471                            cx,
1472                            move |this, range, items, cx| {
1473                                let theme = theme::current(cx).clone();
1474                                let mut dragged_entry_destination =
1475                                    this.dragged_entry_destination.clone();
1476                                this.for_each_visible_entry(range, cx, |id, details, cx| {
1477                                    items.push(Self::render_entry(
1478                                        id,
1479                                        details,
1480                                        &this.filename_editor,
1481                                        &mut dragged_entry_destination,
1482                                        &theme.project_panel,
1483                                        cx,
1484                                    ));
1485                                });
1486                                this.dragged_entry_destination = dragged_entry_destination;
1487                            },
1488                        )
1489                        .with_padding_top(padding.top)
1490                        .with_padding_bottom(padding.bottom)
1491                        .contained()
1492                        .with_style(container_style)
1493                        .expanded()
1494                    })
1495                    .on_down(MouseButton::Right, move |event, this, cx| {
1496                        // When deploying the context menu anywhere below the last project entry,
1497                        // act as if the user clicked the root of the last worktree.
1498                        if let Some(entry_id) = last_worktree_root_id {
1499                            this.deploy_context_menu(event.position, entry_id, cx);
1500                        }
1501                    }),
1502                )
1503                .with_child(ChildView::new(&self.context_menu, cx))
1504                .into_any_named("project panel")
1505        } else {
1506            Flex::column()
1507                .with_child(
1508                    MouseEventHandler::<Self, _>::new(2, cx, {
1509                        let button_style = theme.open_project_button.clone();
1510                        let context_menu_item_style = theme::current(cx).context_menu.item.clone();
1511                        move |state, cx| {
1512                            let button_style = button_style.style_for(state).clone();
1513                            let context_menu_item = context_menu_item_style
1514                                .active_state()
1515                                .style_for(state)
1516                                .clone();
1517
1518                            theme::ui::keystroke_label(
1519                                "Open a project",
1520                                &button_style,
1521                                &context_menu_item.keystroke,
1522                                Box::new(workspace::Open),
1523                                cx,
1524                            )
1525                        }
1526                    })
1527                    .on_click(MouseButton::Left, move |_, this, cx| {
1528                        if let Some(workspace) = this.workspace.upgrade(cx) {
1529                            workspace.update(cx, |workspace, cx| {
1530                                if let Some(task) = workspace.open(&Default::default(), cx) {
1531                                    task.detach_and_log_err(cx);
1532                                }
1533                            })
1534                        }
1535                    })
1536                    .with_cursor_style(CursorStyle::PointingHand),
1537                )
1538                .contained()
1539                .with_style(container_style)
1540                .into_any_named("empty project panel")
1541        }
1542    }
1543
1544    fn update_keymap_context(&self, keymap: &mut KeymapContext, _: &AppContext) {
1545        Self::reset_to_default_keymap_context(keymap);
1546        keymap.add_identifier("menu");
1547    }
1548
1549    fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
1550        if !self.has_focus {
1551            self.has_focus = true;
1552            cx.emit(Event::Focus);
1553        }
1554    }
1555
1556    fn focus_out(&mut self, _: gpui::AnyViewHandle, _: &mut ViewContext<Self>) {
1557        self.has_focus = false;
1558    }
1559}
1560
1561impl Entity for ProjectPanel {
1562    type Event = Event;
1563}
1564
1565impl workspace::dock::Panel for ProjectPanel {
1566    fn position(&self, cx: &WindowContext) -> DockPosition {
1567        match settings::get::<ProjectPanelSettings>(cx).dock {
1568            ProjectPanelDockPosition::Left => DockPosition::Left,
1569            ProjectPanelDockPosition::Right => DockPosition::Right,
1570        }
1571    }
1572
1573    fn position_is_valid(&self, position: DockPosition) -> bool {
1574        matches!(position, DockPosition::Left | DockPosition::Right)
1575    }
1576
1577    fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
1578        settings::update_settings_file::<ProjectPanelSettings>(
1579            self.fs.clone(),
1580            cx,
1581            move |settings| {
1582                let dock = match position {
1583                    DockPosition::Left | DockPosition::Bottom => ProjectPanelDockPosition::Left,
1584                    DockPosition::Right => ProjectPanelDockPosition::Right,
1585                };
1586                settings.dock = Some(dock);
1587            },
1588        );
1589    }
1590
1591    fn size(&self, cx: &WindowContext) -> f32 {
1592        self.width
1593            .unwrap_or_else(|| settings::get::<ProjectPanelSettings>(cx).default_width)
1594    }
1595
1596    fn set_size(&mut self, size: f32, cx: &mut ViewContext<Self>) {
1597        self.width = Some(size);
1598        self.serialize(cx);
1599        cx.notify();
1600    }
1601
1602    fn should_zoom_in_on_event(_: &Self::Event) -> bool {
1603        false
1604    }
1605
1606    fn should_zoom_out_on_event(_: &Self::Event) -> bool {
1607        false
1608    }
1609
1610    fn is_zoomed(&self, _: &WindowContext) -> bool {
1611        false
1612    }
1613
1614    fn set_zoomed(&mut self, _: bool, _: &mut ViewContext<Self>) {}
1615
1616    fn set_active(&mut self, _: bool, _: &mut ViewContext<Self>) {}
1617
1618    fn icon_path(&self) -> &'static str {
1619        "icons/folder_tree_16.svg"
1620    }
1621
1622    fn icon_tooltip(&self) -> (String, Option<Box<dyn Action>>) {
1623        ("Project Panel".into(), Some(Box::new(ToggleFocus)))
1624    }
1625
1626    fn should_change_position_on_event(event: &Self::Event) -> bool {
1627        matches!(event, Event::DockPositionChanged)
1628    }
1629
1630    fn should_activate_on_event(_: &Self::Event) -> bool {
1631        false
1632    }
1633
1634    fn should_close_on_event(_: &Self::Event) -> bool {
1635        false
1636    }
1637
1638    fn has_focus(&self, _: &WindowContext) -> bool {
1639        self.has_focus
1640    }
1641
1642    fn is_focus_event(event: &Self::Event) -> bool {
1643        matches!(event, Event::Focus)
1644    }
1645}
1646
1647impl ClipboardEntry {
1648    fn is_cut(&self) -> bool {
1649        matches!(self, Self::Cut { .. })
1650    }
1651
1652    fn entry_id(&self) -> ProjectEntryId {
1653        match self {
1654            ClipboardEntry::Copied { entry_id, .. } | ClipboardEntry::Cut { entry_id, .. } => {
1655                *entry_id
1656            }
1657        }
1658    }
1659
1660    fn worktree_id(&self) -> WorktreeId {
1661        match self {
1662            ClipboardEntry::Copied { worktree_id, .. }
1663            | ClipboardEntry::Cut { worktree_id, .. } => *worktree_id,
1664        }
1665    }
1666}
1667
1668#[cfg(test)]
1669mod tests {
1670    use super::*;
1671    use gpui::{TestAppContext, ViewHandle};
1672    use pretty_assertions::assert_eq;
1673    use project::FakeFs;
1674    use serde_json::json;
1675    use settings::SettingsStore;
1676    use std::{collections::HashSet, path::Path};
1677    use workspace::{pane, AppState};
1678
1679    #[gpui::test]
1680    async fn test_visible_list(cx: &mut gpui::TestAppContext) {
1681        init_test(cx);
1682
1683        let fs = FakeFs::new(cx.background());
1684        fs.insert_tree(
1685            "/root1",
1686            json!({
1687                ".dockerignore": "",
1688                ".git": {
1689                    "HEAD": "",
1690                },
1691                "a": {
1692                    "0": { "q": "", "r": "", "s": "" },
1693                    "1": { "t": "", "u": "" },
1694                    "2": { "v": "", "w": "", "x": "", "y": "" },
1695                },
1696                "b": {
1697                    "3": { "Q": "" },
1698                    "4": { "R": "", "S": "", "T": "", "U": "" },
1699                },
1700                "C": {
1701                    "5": {},
1702                    "6": { "V": "", "W": "" },
1703                    "7": { "X": "" },
1704                    "8": { "Y": {}, "Z": "" }
1705                }
1706            }),
1707        )
1708        .await;
1709        fs.insert_tree(
1710            "/root2",
1711            json!({
1712                "d": {
1713                    "9": ""
1714                },
1715                "e": {}
1716            }),
1717        )
1718        .await;
1719
1720        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1721        let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1722        let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
1723        assert_eq!(
1724            visible_entries_as_strings(&panel, 0..50, cx),
1725            &[
1726                "v root1",
1727                "    > .git",
1728                "    > a",
1729                "    > b",
1730                "    > C",
1731                "      .dockerignore",
1732                "v root2",
1733                "    > d",
1734                "    > e",
1735            ]
1736        );
1737
1738        toggle_expand_dir(&panel, "root1/b", cx);
1739        assert_eq!(
1740            visible_entries_as_strings(&panel, 0..50, cx),
1741            &[
1742                "v root1",
1743                "    > .git",
1744                "    > a",
1745                "    v b  <== selected",
1746                "        > 3",
1747                "        > 4",
1748                "    > C",
1749                "      .dockerignore",
1750                "v root2",
1751                "    > d",
1752                "    > e",
1753            ]
1754        );
1755
1756        assert_eq!(
1757            visible_entries_as_strings(&panel, 6..9, cx),
1758            &[
1759                //
1760                "    > C",
1761                "      .dockerignore",
1762                "v root2",
1763            ]
1764        );
1765    }
1766
1767    #[gpui::test(iterations = 30)]
1768    async fn test_editing_files(cx: &mut gpui::TestAppContext) {
1769        init_test(cx);
1770
1771        let fs = FakeFs::new(cx.background());
1772        fs.insert_tree(
1773            "/root1",
1774            json!({
1775                ".dockerignore": "",
1776                ".git": {
1777                    "HEAD": "",
1778                },
1779                "a": {
1780                    "0": { "q": "", "r": "", "s": "" },
1781                    "1": { "t": "", "u": "" },
1782                    "2": { "v": "", "w": "", "x": "", "y": "" },
1783                },
1784                "b": {
1785                    "3": { "Q": "" },
1786                    "4": { "R": "", "S": "", "T": "", "U": "" },
1787                },
1788                "C": {
1789                    "5": {},
1790                    "6": { "V": "", "W": "" },
1791                    "7": { "X": "" },
1792                    "8": { "Y": {}, "Z": "" }
1793                }
1794            }),
1795        )
1796        .await;
1797        fs.insert_tree(
1798            "/root2",
1799            json!({
1800                "d": {
1801                    "9": ""
1802                },
1803                "e": {}
1804            }),
1805        )
1806        .await;
1807
1808        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1809        let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1810        let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
1811
1812        select_path(&panel, "root1", cx);
1813        assert_eq!(
1814            visible_entries_as_strings(&panel, 0..10, cx),
1815            &[
1816                "v root1  <== selected",
1817                "    > .git",
1818                "    > a",
1819                "    > b",
1820                "    > C",
1821                "      .dockerignore",
1822                "v root2",
1823                "    > d",
1824                "    > e",
1825            ]
1826        );
1827
1828        // Add a file with the root folder selected. The filename editor is placed
1829        // before the first file in the root folder.
1830        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
1831        cx.read_window(window_id, |cx| {
1832            let panel = panel.read(cx);
1833            assert!(panel.filename_editor.is_focused(cx));
1834        });
1835        assert_eq!(
1836            visible_entries_as_strings(&panel, 0..10, cx),
1837            &[
1838                "v root1",
1839                "    > .git",
1840                "    > a",
1841                "    > b",
1842                "    > C",
1843                "      [EDITOR: '']  <== selected",
1844                "      .dockerignore",
1845                "v root2",
1846                "    > d",
1847                "    > e",
1848            ]
1849        );
1850
1851        let confirm = panel.update(cx, |panel, cx| {
1852            panel
1853                .filename_editor
1854                .update(cx, |editor, cx| editor.set_text("the-new-filename", cx));
1855            panel.confirm(&Confirm, cx).unwrap()
1856        });
1857        assert_eq!(
1858            visible_entries_as_strings(&panel, 0..10, cx),
1859            &[
1860                "v root1",
1861                "    > .git",
1862                "    > a",
1863                "    > b",
1864                "    > C",
1865                "      [PROCESSING: 'the-new-filename']  <== selected",
1866                "      .dockerignore",
1867                "v root2",
1868                "    > d",
1869                "    > e",
1870            ]
1871        );
1872
1873        confirm.await.unwrap();
1874        assert_eq!(
1875            visible_entries_as_strings(&panel, 0..10, cx),
1876            &[
1877                "v root1",
1878                "    > .git",
1879                "    > a",
1880                "    > b",
1881                "    > C",
1882                "      .dockerignore",
1883                "      the-new-filename  <== selected",
1884                "v root2",
1885                "    > d",
1886                "    > e",
1887            ]
1888        );
1889
1890        select_path(&panel, "root1/b", cx);
1891        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
1892        assert_eq!(
1893            visible_entries_as_strings(&panel, 0..10, cx),
1894            &[
1895                "v root1",
1896                "    > .git",
1897                "    > a",
1898                "    v b",
1899                "        > 3",
1900                "        > 4",
1901                "          [EDITOR: '']  <== selected",
1902                "    > C",
1903                "      .dockerignore",
1904                "      the-new-filename",
1905            ]
1906        );
1907
1908        panel
1909            .update(cx, |panel, cx| {
1910                panel
1911                    .filename_editor
1912                    .update(cx, |editor, cx| editor.set_text("another-filename", cx));
1913                panel.confirm(&Confirm, cx).unwrap()
1914            })
1915            .await
1916            .unwrap();
1917        assert_eq!(
1918            visible_entries_as_strings(&panel, 0..10, cx),
1919            &[
1920                "v root1",
1921                "    > .git",
1922                "    > a",
1923                "    v b",
1924                "        > 3",
1925                "        > 4",
1926                "          another-filename  <== selected",
1927                "    > C",
1928                "      .dockerignore",
1929                "      the-new-filename",
1930            ]
1931        );
1932
1933        select_path(&panel, "root1/b/another-filename", cx);
1934        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
1935        assert_eq!(
1936            visible_entries_as_strings(&panel, 0..10, cx),
1937            &[
1938                "v root1",
1939                "    > .git",
1940                "    > a",
1941                "    v b",
1942                "        > 3",
1943                "        > 4",
1944                "          [EDITOR: 'another-filename']  <== selected",
1945                "    > C",
1946                "      .dockerignore",
1947                "      the-new-filename",
1948            ]
1949        );
1950
1951        let confirm = panel.update(cx, |panel, cx| {
1952            panel
1953                .filename_editor
1954                .update(cx, |editor, cx| editor.set_text("a-different-filename", cx));
1955            panel.confirm(&Confirm, cx).unwrap()
1956        });
1957        assert_eq!(
1958            visible_entries_as_strings(&panel, 0..10, cx),
1959            &[
1960                "v root1",
1961                "    > .git",
1962                "    > a",
1963                "    v b",
1964                "        > 3",
1965                "        > 4",
1966                "          [PROCESSING: 'a-different-filename']  <== selected",
1967                "    > C",
1968                "      .dockerignore",
1969                "      the-new-filename",
1970            ]
1971        );
1972
1973        confirm.await.unwrap();
1974        assert_eq!(
1975            visible_entries_as_strings(&panel, 0..10, cx),
1976            &[
1977                "v root1",
1978                "    > .git",
1979                "    > a",
1980                "    v b",
1981                "        > 3",
1982                "        > 4",
1983                "          a-different-filename  <== selected",
1984                "    > C",
1985                "      .dockerignore",
1986                "      the-new-filename",
1987            ]
1988        );
1989
1990        panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
1991        assert_eq!(
1992            visible_entries_as_strings(&panel, 0..10, cx),
1993            &[
1994                "v root1",
1995                "    > .git",
1996                "    > a",
1997                "    v b",
1998                "        > [EDITOR: '']  <== selected",
1999                "        > 3",
2000                "        > 4",
2001                "          a-different-filename",
2002                "    > C",
2003                "      .dockerignore",
2004            ]
2005        );
2006
2007        let confirm = panel.update(cx, |panel, cx| {
2008            panel
2009                .filename_editor
2010                .update(cx, |editor, cx| editor.set_text("new-dir", cx));
2011            panel.confirm(&Confirm, cx).unwrap()
2012        });
2013        panel.update(cx, |panel, cx| panel.select_next(&Default::default(), cx));
2014        assert_eq!(
2015            visible_entries_as_strings(&panel, 0..10, cx),
2016            &[
2017                "v root1",
2018                "    > .git",
2019                "    > a",
2020                "    v b",
2021                "        > [PROCESSING: 'new-dir']",
2022                "        > 3  <== selected",
2023                "        > 4",
2024                "          a-different-filename",
2025                "    > C",
2026                "      .dockerignore",
2027            ]
2028        );
2029
2030        confirm.await.unwrap();
2031        assert_eq!(
2032            visible_entries_as_strings(&panel, 0..10, cx),
2033            &[
2034                "v root1",
2035                "    > .git",
2036                "    > a",
2037                "    v b",
2038                "        > 3  <== selected",
2039                "        > 4",
2040                "        > new-dir",
2041                "          a-different-filename",
2042                "    > C",
2043                "      .dockerignore",
2044            ]
2045        );
2046
2047        panel.update(cx, |panel, cx| panel.rename(&Default::default(), cx));
2048        assert_eq!(
2049            visible_entries_as_strings(&panel, 0..10, cx),
2050            &[
2051                "v root1",
2052                "    > .git",
2053                "    > a",
2054                "    v b",
2055                "        > [EDITOR: '3']  <== selected",
2056                "        > 4",
2057                "        > new-dir",
2058                "          a-different-filename",
2059                "    > C",
2060                "      .dockerignore",
2061            ]
2062        );
2063
2064        // Dismiss the rename editor when it loses focus.
2065        workspace.update(cx, |_, cx| cx.focus_self());
2066        assert_eq!(
2067            visible_entries_as_strings(&panel, 0..10, cx),
2068            &[
2069                "v root1",
2070                "    > .git",
2071                "    > a",
2072                "    v b",
2073                "        > 3  <== selected",
2074                "        > 4",
2075                "        > new-dir",
2076                "          a-different-filename",
2077                "    > C",
2078                "      .dockerignore",
2079            ]
2080        );
2081    }
2082
2083    #[gpui::test(iterations = 30)]
2084    async fn test_adding_directories_via_file(cx: &mut gpui::TestAppContext) {
2085        init_test(cx);
2086
2087        let fs = FakeFs::new(cx.background());
2088        fs.insert_tree(
2089            "/root1",
2090            json!({
2091                ".dockerignore": "",
2092                ".git": {
2093                    "HEAD": "",
2094                },
2095                "a": {
2096                    "0": { "q": "", "r": "", "s": "" },
2097                    "1": { "t": "", "u": "" },
2098                    "2": { "v": "", "w": "", "x": "", "y": "" },
2099                },
2100                "b": {
2101                    "3": { "Q": "" },
2102                    "4": { "R": "", "S": "", "T": "", "U": "" },
2103                },
2104                "C": {
2105                    "5": {},
2106                    "6": { "V": "", "W": "" },
2107                    "7": { "X": "" },
2108                    "8": { "Y": {}, "Z": "" }
2109                }
2110            }),
2111        )
2112        .await;
2113        fs.insert_tree(
2114            "/root2",
2115            json!({
2116                "d": {
2117                    "9": ""
2118                },
2119                "e": {}
2120            }),
2121        )
2122        .await;
2123
2124        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
2125        let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2126        let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2127
2128        select_path(&panel, "root1", cx);
2129        assert_eq!(
2130            visible_entries_as_strings(&panel, 0..10, cx),
2131            &[
2132                "v root1  <== selected",
2133                "    > .git",
2134                "    > a",
2135                "    > b",
2136                "    > C",
2137                "      .dockerignore",
2138                "v root2",
2139                "    > d",
2140                "    > e",
2141            ]
2142        );
2143
2144        // Add a file with the root folder selected. The filename editor is placed
2145        // before the first file in the root folder.
2146        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2147        cx.read_window(window_id, |cx| {
2148            let panel = panel.read(cx);
2149            assert!(panel.filename_editor.is_focused(cx));
2150        });
2151        assert_eq!(
2152            visible_entries_as_strings(&panel, 0..10, cx),
2153            &[
2154                "v root1",
2155                "    > .git",
2156                "    > a",
2157                "    > b",
2158                "    > C",
2159                "      [EDITOR: '']  <== selected",
2160                "      .dockerignore",
2161                "v root2",
2162                "    > d",
2163                "    > e",
2164            ]
2165        );
2166
2167        let confirm = panel.update(cx, |panel, cx| {
2168            panel.filename_editor.update(cx, |editor, cx| {
2169                editor.set_text("/bdir1/dir2/the-new-filename", cx)
2170            });
2171            panel.confirm(&Confirm, cx).unwrap()
2172        });
2173
2174        assert_eq!(
2175            visible_entries_as_strings(&panel, 0..10, cx),
2176            &[
2177                "v root1",
2178                "    > .git",
2179                "    > a",
2180                "    > b",
2181                "    > C",
2182                "      [PROCESSING: '/bdir1/dir2/the-new-filename']  <== selected",
2183                "      .dockerignore",
2184                "v root2",
2185                "    > d",
2186                "    > e",
2187            ]
2188        );
2189
2190        confirm.await.unwrap();
2191        assert_eq!(
2192            visible_entries_as_strings(&panel, 0..13, cx),
2193            &[
2194                "v root1",
2195                "    > .git",
2196                "    > a",
2197                "    > b",
2198                "    v bdir1",
2199                "        v dir2",
2200                "              the-new-filename  <== selected",
2201                "    > C",
2202                "      .dockerignore",
2203                "v root2",
2204                "    > d",
2205                "    > e",
2206            ]
2207        );
2208    }
2209
2210    #[gpui::test]
2211    async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
2212        init_test(cx);
2213
2214        let fs = FakeFs::new(cx.background());
2215        fs.insert_tree(
2216            "/root1",
2217            json!({
2218                "one.two.txt": "",
2219                "one.txt": ""
2220            }),
2221        )
2222        .await;
2223
2224        let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
2225        let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2226        let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2227
2228        panel.update(cx, |panel, cx| {
2229            panel.select_next(&Default::default(), cx);
2230            panel.select_next(&Default::default(), cx);
2231        });
2232
2233        assert_eq!(
2234            visible_entries_as_strings(&panel, 0..50, cx),
2235            &[
2236                //
2237                "v root1",
2238                "      one.two.txt  <== selected",
2239                "      one.txt",
2240            ]
2241        );
2242
2243        // Regression test - file name is created correctly when
2244        // the copied file's name contains multiple dots.
2245        panel.update(cx, |panel, cx| {
2246            panel.copy(&Default::default(), cx);
2247            panel.paste(&Default::default(), cx);
2248        });
2249        cx.foreground().run_until_parked();
2250
2251        assert_eq!(
2252            visible_entries_as_strings(&panel, 0..50, cx),
2253            &[
2254                //
2255                "v root1",
2256                "      one.two copy.txt",
2257                "      one.two.txt  <== selected",
2258                "      one.txt",
2259            ]
2260        );
2261
2262        panel.update(cx, |panel, cx| {
2263            panel.paste(&Default::default(), cx);
2264        });
2265        cx.foreground().run_until_parked();
2266
2267        assert_eq!(
2268            visible_entries_as_strings(&panel, 0..50, cx),
2269            &[
2270                //
2271                "v root1",
2272                "      one.two copy 1.txt",
2273                "      one.two copy.txt",
2274                "      one.two.txt  <== selected",
2275                "      one.txt",
2276            ]
2277        );
2278    }
2279
2280    #[gpui::test]
2281    async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
2282        init_test_with_editor(cx);
2283
2284        let fs = FakeFs::new(cx.background());
2285        fs.insert_tree(
2286            "/src",
2287            json!({
2288                "test": {
2289                    "first.rs": "// First Rust file",
2290                    "second.rs": "// Second Rust file",
2291                    "third.rs": "// Third Rust file",
2292                }
2293            }),
2294        )
2295        .await;
2296
2297        let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2298        let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2299        let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2300
2301        toggle_expand_dir(&panel, "src/test", cx);
2302        select_path(&panel, "src/test/first.rs", cx);
2303        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2304        cx.foreground().run_until_parked();
2305        assert_eq!(
2306            visible_entries_as_strings(&panel, 0..10, cx),
2307            &[
2308                "v src",
2309                "    v test",
2310                "          first.rs  <== selected",
2311                "          second.rs",
2312                "          third.rs"
2313            ]
2314        );
2315        ensure_single_file_is_opened(window_id, &workspace, "test/first.rs", cx);
2316
2317        submit_deletion(window_id, &panel, cx);
2318        assert_eq!(
2319            visible_entries_as_strings(&panel, 0..10, cx),
2320            &[
2321                "v src",
2322                "    v test",
2323                "          second.rs",
2324                "          third.rs"
2325            ],
2326            "Project panel should have no deleted file, no other file is selected in it"
2327        );
2328        ensure_no_open_items_and_panes(window_id, &workspace, cx);
2329
2330        select_path(&panel, "src/test/second.rs", cx);
2331        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2332        cx.foreground().run_until_parked();
2333        assert_eq!(
2334            visible_entries_as_strings(&panel, 0..10, cx),
2335            &[
2336                "v src",
2337                "    v test",
2338                "          second.rs  <== selected",
2339                "          third.rs"
2340            ]
2341        );
2342        ensure_single_file_is_opened(window_id, &workspace, "test/second.rs", cx);
2343
2344        cx.update_window(window_id, |cx| {
2345            let active_items = workspace
2346                .read(cx)
2347                .panes()
2348                .iter()
2349                .filter_map(|pane| pane.read(cx).active_item())
2350                .collect::<Vec<_>>();
2351            assert_eq!(active_items.len(), 1);
2352            let open_editor = active_items
2353                .into_iter()
2354                .next()
2355                .unwrap()
2356                .downcast::<Editor>()
2357                .expect("Open item should be an editor");
2358            open_editor.update(cx, |editor, cx| editor.set_text("Another text!", cx));
2359        });
2360        submit_deletion(window_id, &panel, cx);
2361        assert_eq!(
2362            visible_entries_as_strings(&panel, 0..10, cx),
2363            &["v src", "    v test", "          third.rs"],
2364            "Project panel should have no deleted file, with one last file remaining"
2365        );
2366        ensure_no_open_items_and_panes(window_id, &workspace, cx);
2367    }
2368
2369    #[gpui::test]
2370    async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
2371        init_test_with_editor(cx);
2372
2373        let fs = FakeFs::new(cx.background());
2374        fs.insert_tree(
2375            "/src",
2376            json!({
2377                "test": {
2378                    "first.rs": "// First Rust file",
2379                    "second.rs": "// Second Rust file",
2380                    "third.rs": "// Third Rust file",
2381                }
2382            }),
2383        )
2384        .await;
2385
2386        let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2387        let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2388        let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2389
2390        select_path(&panel, "src/", cx);
2391        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2392        cx.foreground().run_until_parked();
2393        assert_eq!(
2394            visible_entries_as_strings(&panel, 0..10, cx),
2395            &["v src  <== selected", "    > test"]
2396        );
2397        panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2398        cx.read_window(window_id, |cx| {
2399            let panel = panel.read(cx);
2400            assert!(panel.filename_editor.is_focused(cx));
2401        });
2402        assert_eq!(
2403            visible_entries_as_strings(&panel, 0..10, cx),
2404            &["v src", "    > [EDITOR: '']  <== selected", "    > test"]
2405        );
2406        panel.update(cx, |panel, cx| {
2407            panel
2408                .filename_editor
2409                .update(cx, |editor, cx| editor.set_text("test", cx));
2410            assert!(
2411                panel.confirm(&Confirm, cx).is_none(),
2412                "Should not allow to confirm on conflicting new directory name"
2413            )
2414        });
2415        assert_eq!(
2416            visible_entries_as_strings(&panel, 0..10, cx),
2417            &["v src", "    > test"],
2418            "File list should be unchanged after failed folder create confirmation"
2419        );
2420
2421        select_path(&panel, "src/test/", cx);
2422        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2423        cx.foreground().run_until_parked();
2424        assert_eq!(
2425            visible_entries_as_strings(&panel, 0..10, cx),
2426            &["v src", "    > test  <== selected"]
2427        );
2428        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2429        cx.read_window(window_id, |cx| {
2430            let panel = panel.read(cx);
2431            assert!(panel.filename_editor.is_focused(cx));
2432        });
2433        assert_eq!(
2434            visible_entries_as_strings(&panel, 0..10, cx),
2435            &[
2436                "v src",
2437                "    v test",
2438                "          [EDITOR: '']  <== selected",
2439                "          first.rs",
2440                "          second.rs",
2441                "          third.rs"
2442            ]
2443        );
2444        panel.update(cx, |panel, cx| {
2445            panel
2446                .filename_editor
2447                .update(cx, |editor, cx| editor.set_text("first.rs", cx));
2448            assert!(
2449                panel.confirm(&Confirm, cx).is_none(),
2450                "Should not allow to confirm on conflicting new file name"
2451            )
2452        });
2453        assert_eq!(
2454            visible_entries_as_strings(&panel, 0..10, cx),
2455            &[
2456                "v src",
2457                "    v test",
2458                "          first.rs",
2459                "          second.rs",
2460                "          third.rs"
2461            ],
2462            "File list should be unchanged after failed file create confirmation"
2463        );
2464
2465        select_path(&panel, "src/test/first.rs", cx);
2466        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2467        cx.foreground().run_until_parked();
2468        assert_eq!(
2469            visible_entries_as_strings(&panel, 0..10, cx),
2470            &[
2471                "v src",
2472                "    v test",
2473                "          first.rs  <== selected",
2474                "          second.rs",
2475                "          third.rs"
2476            ],
2477        );
2478        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2479        cx.read_window(window_id, |cx| {
2480            let panel = panel.read(cx);
2481            assert!(panel.filename_editor.is_focused(cx));
2482        });
2483        assert_eq!(
2484            visible_entries_as_strings(&panel, 0..10, cx),
2485            &[
2486                "v src",
2487                "    v test",
2488                "          [EDITOR: 'first.rs']  <== selected",
2489                "          second.rs",
2490                "          third.rs"
2491            ]
2492        );
2493        panel.update(cx, |panel, cx| {
2494            panel
2495                .filename_editor
2496                .update(cx, |editor, cx| editor.set_text("second.rs", cx));
2497            assert!(
2498                panel.confirm(&Confirm, cx).is_none(),
2499                "Should not allow to confirm on conflicting file rename"
2500            )
2501        });
2502        assert_eq!(
2503            visible_entries_as_strings(&panel, 0..10, cx),
2504            &[
2505                "v src",
2506                "    v test",
2507                "          first.rs  <== selected",
2508                "          second.rs",
2509                "          third.rs"
2510            ],
2511            "File list should be unchanged after failed rename confirmation"
2512        );
2513    }
2514
2515    fn toggle_expand_dir(
2516        panel: &ViewHandle<ProjectPanel>,
2517        path: impl AsRef<Path>,
2518        cx: &mut TestAppContext,
2519    ) {
2520        let path = path.as_ref();
2521        panel.update(cx, |panel, cx| {
2522            for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
2523                let worktree = worktree.read(cx);
2524                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
2525                    let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
2526                    panel.toggle_expanded(entry_id, cx);
2527                    return;
2528                }
2529            }
2530            panic!("no worktree for path {:?}", path);
2531        });
2532    }
2533
2534    fn select_path(
2535        panel: &ViewHandle<ProjectPanel>,
2536        path: impl AsRef<Path>,
2537        cx: &mut TestAppContext,
2538    ) {
2539        let path = path.as_ref();
2540        panel.update(cx, |panel, cx| {
2541            for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
2542                let worktree = worktree.read(cx);
2543                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
2544                    let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
2545                    panel.selection = Some(Selection {
2546                        worktree_id: worktree.id(),
2547                        entry_id,
2548                    });
2549                    return;
2550                }
2551            }
2552            panic!("no worktree for path {:?}", path);
2553        });
2554    }
2555
2556    fn visible_entries_as_strings(
2557        panel: &ViewHandle<ProjectPanel>,
2558        range: Range<usize>,
2559        cx: &mut TestAppContext,
2560    ) -> Vec<String> {
2561        let mut result = Vec::new();
2562        let mut project_entries = HashSet::new();
2563        let mut has_editor = false;
2564
2565        panel.update(cx, |panel, cx| {
2566            panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
2567                if details.is_editing {
2568                    assert!(!has_editor, "duplicate editor entry");
2569                    has_editor = true;
2570                } else {
2571                    assert!(
2572                        project_entries.insert(project_entry),
2573                        "duplicate project entry {:?} {:?}",
2574                        project_entry,
2575                        details
2576                    );
2577                }
2578
2579                let indent = "    ".repeat(details.depth);
2580                let icon = if details.kind.is_dir() {
2581                    if details.is_expanded {
2582                        "v "
2583                    } else {
2584                        "> "
2585                    }
2586                } else {
2587                    "  "
2588                };
2589                let name = if details.is_editing {
2590                    format!("[EDITOR: '{}']", details.filename)
2591                } else if details.is_processing {
2592                    format!("[PROCESSING: '{}']", details.filename)
2593                } else {
2594                    details.filename.clone()
2595                };
2596                let selected = if details.is_selected {
2597                    "  <== selected"
2598                } else {
2599                    ""
2600                };
2601                result.push(format!("{indent}{icon}{name}{selected}"));
2602            });
2603        });
2604
2605        result
2606    }
2607
2608    fn init_test(cx: &mut TestAppContext) {
2609        cx.foreground().forbid_parking();
2610        cx.update(|cx| {
2611            cx.set_global(SettingsStore::test(cx));
2612            init_settings(cx);
2613            theme::init((), cx);
2614            language::init(cx);
2615            editor::init_settings(cx);
2616            crate::init((), cx);
2617            workspace::init_settings(cx);
2618            Project::init_settings(cx);
2619        });
2620    }
2621
2622    fn init_test_with_editor(cx: &mut TestAppContext) {
2623        cx.foreground().forbid_parking();
2624        cx.update(|cx| {
2625            let app_state = AppState::test(cx);
2626            theme::init((), cx);
2627            init_settings(cx);
2628            language::init(cx);
2629            editor::init(cx);
2630            pane::init(cx);
2631            crate::init((), cx);
2632            workspace::init(app_state.clone(), cx);
2633            Project::init_settings(cx);
2634        });
2635    }
2636
2637    fn ensure_single_file_is_opened(
2638        window_id: usize,
2639        workspace: &ViewHandle<Workspace>,
2640        expected_path: &str,
2641        cx: &mut TestAppContext,
2642    ) {
2643        cx.read_window(window_id, |cx| {
2644            let workspace = workspace.read(cx);
2645            let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
2646            assert_eq!(worktrees.len(), 1);
2647            let worktree_id = WorktreeId::from_usize(worktrees[0].id());
2648
2649            let open_project_paths = workspace
2650                .panes()
2651                .iter()
2652                .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
2653                .collect::<Vec<_>>();
2654            assert_eq!(
2655                open_project_paths,
2656                vec![ProjectPath {
2657                    worktree_id,
2658                    path: Arc::from(Path::new(expected_path))
2659                }],
2660                "Should have opened file, selected in project panel"
2661            );
2662        });
2663    }
2664
2665    fn submit_deletion(
2666        window_id: usize,
2667        panel: &ViewHandle<ProjectPanel>,
2668        cx: &mut TestAppContext,
2669    ) {
2670        assert!(
2671            !cx.has_pending_prompt(window_id),
2672            "Should have no prompts before the deletion"
2673        );
2674        panel.update(cx, |panel, cx| {
2675            panel
2676                .delete(&Delete, cx)
2677                .expect("Deletion start")
2678                .detach_and_log_err(cx);
2679        });
2680        assert!(
2681            cx.has_pending_prompt(window_id),
2682            "Should have a prompt after the deletion"
2683        );
2684        cx.simulate_prompt_answer(window_id, 0);
2685        assert!(
2686            !cx.has_pending_prompt(window_id),
2687            "Should have no prompts after prompt was replied to"
2688        );
2689        cx.foreground().run_until_parked();
2690    }
2691
2692    fn ensure_no_open_items_and_panes(
2693        window_id: usize,
2694        workspace: &ViewHandle<Workspace>,
2695        cx: &mut TestAppContext,
2696    ) {
2697        assert!(
2698            !cx.has_pending_prompt(window_id),
2699            "Should have no prompts after deletion operation closes the file"
2700        );
2701        cx.read_window(window_id, |cx| {
2702            let open_project_paths = workspace
2703                .read(cx)
2704                .panes()
2705                .iter()
2706                .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
2707                .collect::<Vec<_>>();
2708            assert!(
2709                open_project_paths.is_empty(),
2710                "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
2711            );
2712        });
2713    }
2714}