project_panel.rs

   1use context_menu::{ContextMenu, ContextMenuItem};
   2use drag_and_drop::{DragAndDrop, Draggable};
   3use editor::{Cancel, Editor};
   4use futures::stream::StreamExt;
   5use gpui::{
   6    actions,
   7    anyhow::{anyhow, Result},
   8    elements::{
   9        AnchorCorner, ChildView, ContainerStyle, Empty, Flex, Label, MouseEventHandler,
  10        ParentElement, ScrollTarget, Stack, Svg, UniformList, UniformListState,
  11    },
  12    geometry::vector::Vector2F,
  13    keymap_matcher::KeymapContext,
  14    platform::{CursorStyle, MouseButton, PromptLevel},
  15    AnyElement, AppContext, ClipboardItem, Element, Entity, ModelHandle, Task, View, ViewContext,
  16    ViewHandle, WeakViewHandle,
  17};
  18use menu::{Confirm, SelectNext, SelectPrev};
  19use project::{Entry, EntryKind, Project, ProjectEntryId, ProjectPath, Worktree, WorktreeId};
  20use settings::Settings;
  21use std::{
  22    cmp::Ordering,
  23    collections::{hash_map, HashMap},
  24    ffi::OsStr,
  25    ops::Range,
  26    path::Path,
  27    sync::Arc,
  28};
  29use theme::ProjectPanelEntry;
  30use unicase::UniCase;
  31use workspace::{dock::DockPosition, Workspace};
  32
  33const NEW_ENTRY_ID: ProjectEntryId = ProjectEntryId::MAX;
  34
  35pub struct ProjectPanel {
  36    project: ModelHandle<Project>,
  37    list: UniformListState,
  38    visible_entries: Vec<(WorktreeId, Vec<Entry>)>,
  39    last_worktree_root_id: Option<ProjectEntryId>,
  40    expanded_dir_ids: HashMap<WorktreeId, Vec<ProjectEntryId>>,
  41    selection: Option<Selection>,
  42    edit_state: Option<EditState>,
  43    filename_editor: ViewHandle<Editor>,
  44    clipboard_entry: Option<ClipboardEntry>,
  45    context_menu: ViewHandle<ContextMenu>,
  46    dragged_entry_destination: Option<Arc<Path>>,
  47    workspace: WeakViewHandle<Workspace>,
  48}
  49
  50#[derive(Copy, Clone)]
  51struct Selection {
  52    worktree_id: WorktreeId,
  53    entry_id: ProjectEntryId,
  54}
  55
  56#[derive(Clone, Debug)]
  57struct EditState {
  58    worktree_id: WorktreeId,
  59    entry_id: ProjectEntryId,
  60    is_new_entry: bool,
  61    is_dir: bool,
  62    processing_filename: Option<String>,
  63}
  64
  65#[derive(Copy, Clone)]
  66pub enum ClipboardEntry {
  67    Copied {
  68        worktree_id: WorktreeId,
  69        entry_id: ProjectEntryId,
  70    },
  71    Cut {
  72        worktree_id: WorktreeId,
  73        entry_id: ProjectEntryId,
  74    },
  75}
  76
  77#[derive(Debug, PartialEq, Eq)]
  78pub struct EntryDetails {
  79    filename: String,
  80    path: Arc<Path>,
  81    depth: usize,
  82    kind: EntryKind,
  83    is_ignored: bool,
  84    is_expanded: bool,
  85    is_selected: bool,
  86    is_editing: bool,
  87    is_processing: bool,
  88    is_cut: bool,
  89}
  90
  91actions!(
  92    project_panel,
  93    [
  94        ExpandSelectedEntry,
  95        CollapseSelectedEntry,
  96        NewDirectory,
  97        NewFile,
  98        Copy,
  99        CopyPath,
 100        CopyRelativePath,
 101        RevealInFinder,
 102        Cut,
 103        Paste,
 104        Delete,
 105        Rename,
 106        ToggleFocus
 107    ]
 108);
 109
 110pub fn init(cx: &mut AppContext) {
 111    cx.add_action(ProjectPanel::expand_selected_entry);
 112    cx.add_action(ProjectPanel::collapse_selected_entry);
 113    cx.add_action(ProjectPanel::select_prev);
 114    cx.add_action(ProjectPanel::select_next);
 115    cx.add_action(ProjectPanel::new_file);
 116    cx.add_action(ProjectPanel::new_directory);
 117    cx.add_action(ProjectPanel::rename);
 118    cx.add_async_action(ProjectPanel::delete);
 119    cx.add_async_action(ProjectPanel::confirm);
 120    cx.add_action(ProjectPanel::cancel);
 121    cx.add_action(ProjectPanel::cut);
 122    cx.add_action(ProjectPanel::copy);
 123    cx.add_action(ProjectPanel::copy_path);
 124    cx.add_action(ProjectPanel::copy_relative_path);
 125    cx.add_action(ProjectPanel::reveal_in_finder);
 126    cx.add_action(
 127        |this: &mut ProjectPanel, action: &Paste, cx: &mut ViewContext<ProjectPanel>| {
 128            this.paste(action, cx);
 129        },
 130    );
 131}
 132
 133pub enum Event {
 134    OpenedEntry {
 135        entry_id: ProjectEntryId,
 136        focus_opened_item: bool,
 137    },
 138    DockPositionChanged,
 139}
 140
 141impl ProjectPanel {
 142    pub fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> ViewHandle<Self> {
 143        let project = workspace.project().clone();
 144        let project_panel = cx.add_view(|cx: &mut ViewContext<Self>| {
 145            // Update the dock position when the setting changes.
 146            let mut old_dock_position = cx.global::<Settings>().project_panel_overrides.dock;
 147            cx.observe_global::<Settings, _>(move |_, cx| {
 148                let new_dock_position = cx.global::<Settings>().project_panel_overrides.dock;
 149                if new_dock_position != old_dock_position {
 150                    old_dock_position = new_dock_position;
 151                    cx.emit(Event::DockPositionChanged);
 152                }
 153            })
 154            .detach();
 155
 156            cx.observe(&project, |this, _, cx| {
 157                this.update_visible_entries(None, cx);
 158                cx.notify();
 159            })
 160            .detach();
 161            cx.subscribe(&project, |this, project, event, cx| match event {
 162                project::Event::ActiveEntryChanged(Some(entry_id)) => {
 163                    if let Some(worktree_id) = project.read(cx).worktree_id_for_entry(*entry_id, cx)
 164                    {
 165                        this.expand_entry(worktree_id, *entry_id, cx);
 166                        this.update_visible_entries(Some((worktree_id, *entry_id)), cx);
 167                        this.autoscroll(cx);
 168                        cx.notify();
 169                    }
 170                }
 171                project::Event::WorktreeRemoved(id) => {
 172                    this.expanded_dir_ids.remove(id);
 173                    this.update_visible_entries(None, cx);
 174                    cx.notify();
 175                }
 176                _ => {}
 177            })
 178            .detach();
 179
 180            let filename_editor = cx.add_view(|cx| {
 181                Editor::single_line(
 182                    Some(Arc::new(|theme| {
 183                        let mut style = theme.project_panel.filename_editor.clone();
 184                        style.container.background_color.take();
 185                        style
 186                    })),
 187                    cx,
 188                )
 189            });
 190
 191            cx.subscribe(&filename_editor, |this, _, event, cx| match event {
 192                editor::Event::BufferEdited | editor::Event::SelectionsChanged { .. } => {
 193                    this.autoscroll(cx);
 194                }
 195                _ => {}
 196            })
 197            .detach();
 198            cx.observe_focus(&filename_editor, |this, _, is_focused, cx| {
 199                if !is_focused
 200                    && this
 201                        .edit_state
 202                        .as_ref()
 203                        .map_or(false, |state| state.processing_filename.is_none())
 204                {
 205                    this.edit_state = None;
 206                    this.update_visible_entries(None, cx);
 207                }
 208            })
 209            .detach();
 210
 211            let view_id = cx.view_id();
 212            let mut this = Self {
 213                project: project.clone(),
 214                list: Default::default(),
 215                visible_entries: Default::default(),
 216                last_worktree_root_id: Default::default(),
 217                expanded_dir_ids: Default::default(),
 218                selection: None,
 219                edit_state: None,
 220                filename_editor,
 221                clipboard_entry: None,
 222                context_menu: cx.add_view(|cx| ContextMenu::new(view_id, cx)),
 223                dragged_entry_destination: None,
 224                workspace: workspace.weak_handle(),
 225            };
 226            this.update_visible_entries(None, cx);
 227            this
 228        });
 229
 230        cx.subscribe(&project_panel, {
 231            let project_panel = project_panel.downgrade();
 232            move |workspace, _, event, cx| match event {
 233                &Event::OpenedEntry {
 234                    entry_id,
 235                    focus_opened_item,
 236                } => {
 237                    if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
 238                        if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
 239                            workspace
 240                                .open_path(
 241                                    ProjectPath {
 242                                        worktree_id: worktree.read(cx).id(),
 243                                        path: entry.path.clone(),
 244                                    },
 245                                    None,
 246                                    focus_opened_item,
 247                                    cx,
 248                                )
 249                                .detach_and_log_err(cx);
 250                            if !focus_opened_item {
 251                                if let Some(project_panel) = project_panel.upgrade(cx) {
 252                                    cx.focus(&project_panel);
 253                                }
 254                            }
 255                        }
 256                    }
 257                }
 258                Event::DockPositionChanged => {}
 259            }
 260        })
 261        .detach();
 262
 263        project_panel
 264    }
 265
 266    fn deploy_context_menu(
 267        &mut self,
 268        position: Vector2F,
 269        entry_id: ProjectEntryId,
 270        cx: &mut ViewContext<Self>,
 271    ) {
 272        let project = self.project.read(cx);
 273
 274        let worktree_id = if let Some(id) = project.worktree_id_for_entry(entry_id, cx) {
 275            id
 276        } else {
 277            return;
 278        };
 279
 280        self.selection = Some(Selection {
 281            worktree_id,
 282            entry_id,
 283        });
 284
 285        let mut menu_entries = Vec::new();
 286        if let Some((worktree, entry)) = self.selected_entry(cx) {
 287            let is_root = Some(entry) == worktree.root_entry();
 288            if !project.is_remote() {
 289                menu_entries.push(ContextMenuItem::action(
 290                    "Add Folder to Project",
 291                    workspace::AddFolderToProject,
 292                ));
 293                if is_root {
 294                    let project = self.project.clone();
 295                    menu_entries.push(ContextMenuItem::handler("Remove from Project", move |cx| {
 296                        project.update(cx, |project, cx| project.remove_worktree(worktree_id, cx));
 297                    }));
 298                }
 299            }
 300            menu_entries.push(ContextMenuItem::action("New File", NewFile));
 301            menu_entries.push(ContextMenuItem::action("New Folder", NewDirectory));
 302            menu_entries.push(ContextMenuItem::Separator);
 303            menu_entries.push(ContextMenuItem::action("Cut", Cut));
 304            menu_entries.push(ContextMenuItem::action("Copy", Copy));
 305            menu_entries.push(ContextMenuItem::Separator);
 306            menu_entries.push(ContextMenuItem::action("Copy Path", CopyPath));
 307            menu_entries.push(ContextMenuItem::action(
 308                "Copy Relative Path",
 309                CopyRelativePath,
 310            ));
 311            menu_entries.push(ContextMenuItem::action("Reveal in Finder", RevealInFinder));
 312            if let Some(clipboard_entry) = self.clipboard_entry {
 313                if clipboard_entry.worktree_id() == worktree.id() {
 314                    menu_entries.push(ContextMenuItem::action("Paste", Paste));
 315                }
 316            }
 317            menu_entries.push(ContextMenuItem::Separator);
 318            menu_entries.push(ContextMenuItem::action("Rename", Rename));
 319            if !is_root {
 320                menu_entries.push(ContextMenuItem::action("Delete", Delete));
 321            }
 322        }
 323
 324        self.context_menu.update(cx, |menu, cx| {
 325            menu.show(position, AnchorCorner::TopLeft, menu_entries, cx);
 326        });
 327
 328        cx.notify();
 329    }
 330
 331    fn expand_selected_entry(&mut self, _: &ExpandSelectedEntry, cx: &mut ViewContext<Self>) {
 332        if let Some((worktree, entry)) = self.selected_entry(cx) {
 333            if entry.is_dir() {
 334                let expanded_dir_ids =
 335                    if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree.id()) {
 336                        expanded_dir_ids
 337                    } else {
 338                        return;
 339                    };
 340
 341                match expanded_dir_ids.binary_search(&entry.id) {
 342                    Ok(_) => self.select_next(&SelectNext, cx),
 343                    Err(ix) => {
 344                        expanded_dir_ids.insert(ix, entry.id);
 345                        self.update_visible_entries(None, cx);
 346                        cx.notify();
 347                    }
 348                }
 349            }
 350        }
 351    }
 352
 353    fn collapse_selected_entry(&mut self, _: &CollapseSelectedEntry, cx: &mut ViewContext<Self>) {
 354        if let Some((worktree, mut entry)) = self.selected_entry(cx) {
 355            let expanded_dir_ids =
 356                if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree.id()) {
 357                    expanded_dir_ids
 358                } else {
 359                    return;
 360                };
 361
 362            loop {
 363                match expanded_dir_ids.binary_search(&entry.id) {
 364                    Ok(ix) => {
 365                        expanded_dir_ids.remove(ix);
 366                        self.update_visible_entries(Some((worktree.id(), entry.id)), cx);
 367                        cx.notify();
 368                        break;
 369                    }
 370                    Err(_) => {
 371                        if let Some(parent_entry) =
 372                            entry.path.parent().and_then(|p| worktree.entry_for_path(p))
 373                        {
 374                            entry = parent_entry;
 375                        } else {
 376                            break;
 377                        }
 378                    }
 379                }
 380            }
 381        }
 382    }
 383
 384    fn toggle_expanded(&mut self, entry_id: ProjectEntryId, cx: &mut ViewContext<Self>) {
 385        if let Some(worktree_id) = self.project.read(cx).worktree_id_for_entry(entry_id, cx) {
 386            if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
 387                match expanded_dir_ids.binary_search(&entry_id) {
 388                    Ok(ix) => {
 389                        expanded_dir_ids.remove(ix);
 390                    }
 391                    Err(ix) => {
 392                        expanded_dir_ids.insert(ix, entry_id);
 393                    }
 394                }
 395                self.update_visible_entries(Some((worktree_id, entry_id)), cx);
 396                cx.focus_self();
 397                cx.notify();
 398            }
 399        }
 400    }
 401
 402    fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
 403        if let Some(selection) = self.selection {
 404            let (mut worktree_ix, mut entry_ix, _) =
 405                self.index_for_selection(selection).unwrap_or_default();
 406            if entry_ix > 0 {
 407                entry_ix -= 1;
 408            } else if worktree_ix > 0 {
 409                worktree_ix -= 1;
 410                entry_ix = self.visible_entries[worktree_ix].1.len() - 1;
 411            } else {
 412                return;
 413            }
 414
 415            let (worktree_id, worktree_entries) = &self.visible_entries[worktree_ix];
 416            self.selection = Some(Selection {
 417                worktree_id: *worktree_id,
 418                entry_id: worktree_entries[entry_ix].id,
 419            });
 420            self.autoscroll(cx);
 421            cx.notify();
 422        } else {
 423            self.select_first(cx);
 424        }
 425    }
 426
 427    fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
 428        if let Some(task) = self.confirm_edit(cx) {
 429            Some(task)
 430        } else if let Some((_, entry)) = self.selected_entry(cx) {
 431            if entry.is_file() {
 432                self.open_entry(entry.id, true, cx);
 433            }
 434            None
 435        } else {
 436            None
 437        }
 438    }
 439
 440    fn confirm_edit(&mut self, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
 441        let edit_state = self.edit_state.as_mut()?;
 442        cx.focus_self();
 443
 444        let worktree_id = edit_state.worktree_id;
 445        let is_new_entry = edit_state.is_new_entry;
 446        let is_dir = edit_state.is_dir;
 447        let worktree = self.project.read(cx).worktree_for_id(worktree_id, cx)?;
 448        let entry = worktree.read(cx).entry_for_id(edit_state.entry_id)?.clone();
 449        let filename = self.filename_editor.read(cx).text(cx);
 450
 451        let edit_task;
 452        let edited_entry_id;
 453
 454        if is_new_entry {
 455            self.selection = Some(Selection {
 456                worktree_id,
 457                entry_id: NEW_ENTRY_ID,
 458            });
 459            let new_path = entry.path.join(&filename);
 460            edited_entry_id = NEW_ENTRY_ID;
 461            edit_task = self.project.update(cx, |project, cx| {
 462                project.create_entry((worktree_id, new_path), is_dir, cx)
 463            })?;
 464        } else {
 465            let new_path = if let Some(parent) = entry.path.clone().parent() {
 466                parent.join(&filename)
 467            } else {
 468                filename.clone().into()
 469            };
 470            edited_entry_id = entry.id;
 471            edit_task = self.project.update(cx, |project, cx| {
 472                project.rename_entry(entry.id, new_path, cx)
 473            })?;
 474        };
 475
 476        edit_state.processing_filename = Some(filename);
 477        cx.notify();
 478
 479        Some(cx.spawn(|this, mut cx| async move {
 480            let new_entry = edit_task.await;
 481            this.update(&mut cx, |this, cx| {
 482                this.edit_state.take();
 483                cx.notify();
 484            })?;
 485
 486            let new_entry = new_entry?;
 487            this.update(&mut cx, |this, cx| {
 488                if let Some(selection) = &mut this.selection {
 489                    if selection.entry_id == edited_entry_id {
 490                        selection.worktree_id = worktree_id;
 491                        selection.entry_id = new_entry.id;
 492                    }
 493                }
 494                this.update_visible_entries(None, cx);
 495                if is_new_entry && !is_dir {
 496                    this.open_entry(new_entry.id, true, cx);
 497                }
 498                cx.notify();
 499            })?;
 500            Ok(())
 501        }))
 502    }
 503
 504    fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
 505        self.edit_state = None;
 506        self.update_visible_entries(None, cx);
 507        cx.focus_self();
 508        cx.notify();
 509    }
 510
 511    fn open_entry(
 512        &mut self,
 513        entry_id: ProjectEntryId,
 514        focus_opened_item: bool,
 515        cx: &mut ViewContext<Self>,
 516    ) {
 517        cx.emit(Event::OpenedEntry {
 518            entry_id,
 519            focus_opened_item,
 520        });
 521    }
 522
 523    fn new_file(&mut self, _: &NewFile, cx: &mut ViewContext<Self>) {
 524        self.add_entry(false, cx)
 525    }
 526
 527    fn new_directory(&mut self, _: &NewDirectory, cx: &mut ViewContext<Self>) {
 528        self.add_entry(true, cx)
 529    }
 530
 531    fn add_entry(&mut self, is_dir: bool, cx: &mut ViewContext<Self>) {
 532        if let Some(Selection {
 533            worktree_id,
 534            entry_id,
 535        }) = self.selection
 536        {
 537            let directory_id;
 538            if let Some((worktree, expanded_dir_ids)) = self
 539                .project
 540                .read(cx)
 541                .worktree_for_id(worktree_id, cx)
 542                .zip(self.expanded_dir_ids.get_mut(&worktree_id))
 543            {
 544                let worktree = worktree.read(cx);
 545                if let Some(mut entry) = worktree.entry_for_id(entry_id) {
 546                    loop {
 547                        if entry.is_dir() {
 548                            if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
 549                                expanded_dir_ids.insert(ix, entry.id);
 550                            }
 551                            directory_id = entry.id;
 552                            break;
 553                        } else {
 554                            if let Some(parent_path) = entry.path.parent() {
 555                                if let Some(parent_entry) = worktree.entry_for_path(parent_path) {
 556                                    entry = parent_entry;
 557                                    continue;
 558                                }
 559                            }
 560                            return;
 561                        }
 562                    }
 563                } else {
 564                    return;
 565                };
 566            } else {
 567                return;
 568            };
 569
 570            self.edit_state = Some(EditState {
 571                worktree_id,
 572                entry_id: directory_id,
 573                is_new_entry: true,
 574                is_dir,
 575                processing_filename: None,
 576            });
 577            self.filename_editor
 578                .update(cx, |editor, cx| editor.clear(cx));
 579            cx.focus(&self.filename_editor);
 580            self.update_visible_entries(Some((worktree_id, NEW_ENTRY_ID)), cx);
 581            self.autoscroll(cx);
 582            cx.notify();
 583        }
 584    }
 585
 586    fn rename(&mut self, _: &Rename, cx: &mut ViewContext<Self>) {
 587        if let Some(Selection {
 588            worktree_id,
 589            entry_id,
 590        }) = self.selection
 591        {
 592            if let Some(worktree) = self.project.read(cx).worktree_for_id(worktree_id, cx) {
 593                if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
 594                    self.edit_state = Some(EditState {
 595                        worktree_id,
 596                        entry_id,
 597                        is_new_entry: false,
 598                        is_dir: entry.is_dir(),
 599                        processing_filename: None,
 600                    });
 601                    let filename = entry
 602                        .path
 603                        .file_name()
 604                        .map_or(String::new(), |s| s.to_string_lossy().to_string());
 605                    self.filename_editor.update(cx, |editor, cx| {
 606                        editor.set_text(filename, cx);
 607                        editor.select_all(&Default::default(), cx);
 608                    });
 609                    cx.focus(&self.filename_editor);
 610                    self.update_visible_entries(None, cx);
 611                    self.autoscroll(cx);
 612                    cx.notify();
 613                }
 614            }
 615
 616            cx.update_global(|drag_and_drop: &mut DragAndDrop<Workspace>, cx| {
 617                drag_and_drop.cancel_dragging::<ProjectEntryId>(cx);
 618            })
 619        }
 620    }
 621
 622    fn delete(&mut self, _: &Delete, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
 623        let Selection { entry_id, .. } = self.selection?;
 624        let path = self.project.read(cx).path_for_entry(entry_id, cx)?.path;
 625        let file_name = path.file_name()?;
 626
 627        let mut answer = cx.prompt(
 628            PromptLevel::Info,
 629            &format!("Delete {file_name:?}?"),
 630            &["Delete", "Cancel"],
 631        );
 632        Some(cx.spawn(|this, mut cx| async move {
 633            if answer.next().await != Some(0) {
 634                return Ok(());
 635            }
 636            this.update(&mut cx, |this, cx| {
 637                this.project
 638                    .update(cx, |project, cx| project.delete_entry(entry_id, cx))
 639                    .ok_or_else(|| anyhow!("no such entry"))
 640            })??
 641            .await
 642        }))
 643    }
 644
 645    fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
 646        if let Some(selection) = self.selection {
 647            let (mut worktree_ix, mut entry_ix, _) =
 648                self.index_for_selection(selection).unwrap_or_default();
 649            if let Some((_, worktree_entries)) = self.visible_entries.get(worktree_ix) {
 650                if entry_ix + 1 < worktree_entries.len() {
 651                    entry_ix += 1;
 652                } else {
 653                    worktree_ix += 1;
 654                    entry_ix = 0;
 655                }
 656            }
 657
 658            if let Some((worktree_id, worktree_entries)) = self.visible_entries.get(worktree_ix) {
 659                if let Some(entry) = worktree_entries.get(entry_ix) {
 660                    self.selection = Some(Selection {
 661                        worktree_id: *worktree_id,
 662                        entry_id: entry.id,
 663                    });
 664                    self.autoscroll(cx);
 665                    cx.notify();
 666                }
 667            }
 668        } else {
 669            self.select_first(cx);
 670        }
 671    }
 672
 673    fn select_first(&mut self, cx: &mut ViewContext<Self>) {
 674        let worktree = self
 675            .visible_entries
 676            .first()
 677            .and_then(|(worktree_id, _)| self.project.read(cx).worktree_for_id(*worktree_id, cx));
 678        if let Some(worktree) = worktree {
 679            let worktree = worktree.read(cx);
 680            let worktree_id = worktree.id();
 681            if let Some(root_entry) = worktree.root_entry() {
 682                self.selection = Some(Selection {
 683                    worktree_id,
 684                    entry_id: root_entry.id,
 685                });
 686                self.autoscroll(cx);
 687                cx.notify();
 688            }
 689        }
 690    }
 691
 692    fn autoscroll(&mut self, cx: &mut ViewContext<Self>) {
 693        if let Some((_, _, index)) = self.selection.and_then(|s| self.index_for_selection(s)) {
 694            self.list.scroll_to(ScrollTarget::Show(index));
 695            cx.notify();
 696        }
 697    }
 698
 699    fn cut(&mut self, _: &Cut, cx: &mut ViewContext<Self>) {
 700        if let Some((worktree, entry)) = self.selected_entry(cx) {
 701            self.clipboard_entry = Some(ClipboardEntry::Cut {
 702                worktree_id: worktree.id(),
 703                entry_id: entry.id,
 704            });
 705            cx.notify();
 706        }
 707    }
 708
 709    fn copy(&mut self, _: &Copy, cx: &mut ViewContext<Self>) {
 710        if let Some((worktree, entry)) = self.selected_entry(cx) {
 711            self.clipboard_entry = Some(ClipboardEntry::Copied {
 712                worktree_id: worktree.id(),
 713                entry_id: entry.id,
 714            });
 715            cx.notify();
 716        }
 717    }
 718
 719    fn paste(&mut self, _: &Paste, cx: &mut ViewContext<Self>) -> Option<()> {
 720        if let Some((worktree, entry)) = self.selected_entry(cx) {
 721            let clipboard_entry = self.clipboard_entry?;
 722            if clipboard_entry.worktree_id() != worktree.id() {
 723                return None;
 724            }
 725
 726            let clipboard_entry_file_name = self
 727                .project
 728                .read(cx)
 729                .path_for_entry(clipboard_entry.entry_id(), cx)?
 730                .path
 731                .file_name()?
 732                .to_os_string();
 733
 734            let mut new_path = entry.path.to_path_buf();
 735            if entry.is_file() {
 736                new_path.pop();
 737            }
 738
 739            new_path.push(&clipboard_entry_file_name);
 740            let extension = new_path.extension().map(|e| e.to_os_string());
 741            let file_name_without_extension = Path::new(&clipboard_entry_file_name).file_stem()?;
 742            let mut ix = 0;
 743            while worktree.entry_for_path(&new_path).is_some() {
 744                new_path.pop();
 745
 746                let mut new_file_name = file_name_without_extension.to_os_string();
 747                new_file_name.push(" copy");
 748                if ix > 0 {
 749                    new_file_name.push(format!(" {}", ix));
 750                }
 751                if let Some(extension) = extension.as_ref() {
 752                    new_file_name.push(".");
 753                    new_file_name.push(extension);
 754                }
 755
 756                new_path.push(new_file_name);
 757                ix += 1;
 758            }
 759
 760            if clipboard_entry.is_cut() {
 761                if let Some(task) = self.project.update(cx, |project, cx| {
 762                    project.rename_entry(clipboard_entry.entry_id(), new_path, cx)
 763                }) {
 764                    task.detach_and_log_err(cx)
 765                }
 766            } else if let Some(task) = self.project.update(cx, |project, cx| {
 767                project.copy_entry(clipboard_entry.entry_id(), new_path, cx)
 768            }) {
 769                task.detach_and_log_err(cx)
 770            }
 771        }
 772        None
 773    }
 774
 775    fn copy_path(&mut self, _: &CopyPath, cx: &mut ViewContext<Self>) {
 776        if let Some((worktree, entry)) = self.selected_entry(cx) {
 777            cx.write_to_clipboard(ClipboardItem::new(
 778                worktree
 779                    .abs_path()
 780                    .join(&entry.path)
 781                    .to_string_lossy()
 782                    .to_string(),
 783            ));
 784        }
 785    }
 786
 787    fn copy_relative_path(&mut self, _: &CopyRelativePath, cx: &mut ViewContext<Self>) {
 788        if let Some((_, entry)) = self.selected_entry(cx) {
 789            cx.write_to_clipboard(ClipboardItem::new(entry.path.to_string_lossy().to_string()));
 790        }
 791    }
 792
 793    fn reveal_in_finder(&mut self, _: &RevealInFinder, cx: &mut ViewContext<Self>) {
 794        if let Some((worktree, entry)) = self.selected_entry(cx) {
 795            cx.reveal_path(&worktree.abs_path().join(&entry.path));
 796        }
 797    }
 798
 799    fn move_entry(
 800        &mut self,
 801        entry_to_move: ProjectEntryId,
 802        destination: ProjectEntryId,
 803        destination_is_file: bool,
 804        cx: &mut ViewContext<Self>,
 805    ) {
 806        let destination_worktree = self.project.update(cx, |project, cx| {
 807            let entry_path = project.path_for_entry(entry_to_move, cx)?;
 808            let destination_entry_path = project.path_for_entry(destination, cx)?.path.clone();
 809
 810            let mut destination_path = destination_entry_path.as_ref();
 811            if destination_is_file {
 812                destination_path = destination_path.parent()?;
 813            }
 814
 815            let mut new_path = destination_path.to_path_buf();
 816            new_path.push(entry_path.path.file_name()?);
 817            if new_path != entry_path.path.as_ref() {
 818                let task = project.rename_entry(entry_to_move, new_path, cx)?;
 819                cx.foreground().spawn(task).detach_and_log_err(cx);
 820            }
 821
 822            Some(project.worktree_id_for_entry(destination, cx)?)
 823        });
 824
 825        if let Some(destination_worktree) = destination_worktree {
 826            self.expand_entry(destination_worktree, destination, cx);
 827        }
 828    }
 829
 830    fn index_for_selection(&self, selection: Selection) -> Option<(usize, usize, usize)> {
 831        let mut entry_index = 0;
 832        let mut visible_entries_index = 0;
 833        for (worktree_index, (worktree_id, worktree_entries)) in
 834            self.visible_entries.iter().enumerate()
 835        {
 836            if *worktree_id == selection.worktree_id {
 837                for entry in worktree_entries {
 838                    if entry.id == selection.entry_id {
 839                        return Some((worktree_index, entry_index, visible_entries_index));
 840                    } else {
 841                        visible_entries_index += 1;
 842                        entry_index += 1;
 843                    }
 844                }
 845                break;
 846            } else {
 847                visible_entries_index += worktree_entries.len();
 848            }
 849        }
 850        None
 851    }
 852
 853    fn selected_entry<'a>(&self, cx: &'a AppContext) -> Option<(&'a Worktree, &'a project::Entry)> {
 854        let selection = self.selection?;
 855        let project = self.project.read(cx);
 856        let worktree = project.worktree_for_id(selection.worktree_id, cx)?.read(cx);
 857        Some((worktree, worktree.entry_for_id(selection.entry_id)?))
 858    }
 859
 860    fn update_visible_entries(
 861        &mut self,
 862        new_selected_entry: Option<(WorktreeId, ProjectEntryId)>,
 863        cx: &mut ViewContext<Self>,
 864    ) {
 865        let project = self.project.read(cx);
 866        self.last_worktree_root_id = project
 867            .visible_worktrees(cx)
 868            .rev()
 869            .next()
 870            .and_then(|worktree| worktree.read(cx).root_entry())
 871            .map(|entry| entry.id);
 872
 873        self.visible_entries.clear();
 874        for worktree in project.visible_worktrees(cx) {
 875            let snapshot = worktree.read(cx).snapshot();
 876            let worktree_id = snapshot.id();
 877
 878            let expanded_dir_ids = match self.expanded_dir_ids.entry(worktree_id) {
 879                hash_map::Entry::Occupied(e) => e.into_mut(),
 880                hash_map::Entry::Vacant(e) => {
 881                    // The first time a worktree's root entry becomes available,
 882                    // mark that root entry as expanded.
 883                    if let Some(entry) = snapshot.root_entry() {
 884                        e.insert(vec![entry.id]).as_slice()
 885                    } else {
 886                        &[]
 887                    }
 888                }
 889            };
 890
 891            let mut new_entry_parent_id = None;
 892            let mut new_entry_kind = EntryKind::Dir;
 893            if let Some(edit_state) = &self.edit_state {
 894                if edit_state.worktree_id == worktree_id && edit_state.is_new_entry {
 895                    new_entry_parent_id = Some(edit_state.entry_id);
 896                    new_entry_kind = if edit_state.is_dir {
 897                        EntryKind::Dir
 898                    } else {
 899                        EntryKind::File(Default::default())
 900                    };
 901                }
 902            }
 903
 904            let mut visible_worktree_entries = Vec::new();
 905            let mut entry_iter = snapshot.entries(true);
 906
 907            while let Some(entry) = entry_iter.entry() {
 908                visible_worktree_entries.push(entry.clone());
 909                if Some(entry.id) == new_entry_parent_id {
 910                    visible_worktree_entries.push(Entry {
 911                        id: NEW_ENTRY_ID,
 912                        kind: new_entry_kind,
 913                        path: entry.path.join("\0").into(),
 914                        inode: 0,
 915                        mtime: entry.mtime,
 916                        is_symlink: false,
 917                        is_ignored: false,
 918                    });
 919                }
 920                if expanded_dir_ids.binary_search(&entry.id).is_err()
 921                    && entry_iter.advance_to_sibling()
 922                {
 923                    continue;
 924                }
 925                entry_iter.advance();
 926            }
 927            visible_worktree_entries.sort_by(|entry_a, entry_b| {
 928                let mut components_a = entry_a.path.components().peekable();
 929                let mut components_b = entry_b.path.components().peekable();
 930                loop {
 931                    match (components_a.next(), components_b.next()) {
 932                        (Some(component_a), Some(component_b)) => {
 933                            let a_is_file = components_a.peek().is_none() && entry_a.is_file();
 934                            let b_is_file = components_b.peek().is_none() && entry_b.is_file();
 935                            let ordering = a_is_file.cmp(&b_is_file).then_with(|| {
 936                                let name_a =
 937                                    UniCase::new(component_a.as_os_str().to_string_lossy());
 938                                let name_b =
 939                                    UniCase::new(component_b.as_os_str().to_string_lossy());
 940                                name_a.cmp(&name_b)
 941                            });
 942                            if !ordering.is_eq() {
 943                                return ordering;
 944                            }
 945                        }
 946                        (Some(_), None) => break Ordering::Greater,
 947                        (None, Some(_)) => break Ordering::Less,
 948                        (None, None) => break Ordering::Equal,
 949                    }
 950                }
 951            });
 952            self.visible_entries
 953                .push((worktree_id, visible_worktree_entries));
 954        }
 955
 956        if let Some((worktree_id, entry_id)) = new_selected_entry {
 957            self.selection = Some(Selection {
 958                worktree_id,
 959                entry_id,
 960            });
 961        }
 962    }
 963
 964    fn expand_entry(
 965        &mut self,
 966        worktree_id: WorktreeId,
 967        entry_id: ProjectEntryId,
 968        cx: &mut ViewContext<Self>,
 969    ) {
 970        let project = self.project.read(cx);
 971        if let Some((worktree, expanded_dir_ids)) = project
 972            .worktree_for_id(worktree_id, cx)
 973            .zip(self.expanded_dir_ids.get_mut(&worktree_id))
 974        {
 975            let worktree = worktree.read(cx);
 976
 977            if let Some(mut entry) = worktree.entry_for_id(entry_id) {
 978                loop {
 979                    if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
 980                        expanded_dir_ids.insert(ix, entry.id);
 981                    }
 982
 983                    if let Some(parent_entry) =
 984                        entry.path.parent().and_then(|p| worktree.entry_for_path(p))
 985                    {
 986                        entry = parent_entry;
 987                    } else {
 988                        break;
 989                    }
 990                }
 991            }
 992        }
 993    }
 994
 995    fn for_each_visible_entry(
 996        &self,
 997        range: Range<usize>,
 998        cx: &mut ViewContext<ProjectPanel>,
 999        mut callback: impl FnMut(ProjectEntryId, EntryDetails, &mut ViewContext<ProjectPanel>),
1000    ) {
1001        let mut ix = 0;
1002        for (worktree_id, visible_worktree_entries) in &self.visible_entries {
1003            if ix >= range.end {
1004                return;
1005            }
1006
1007            if ix + visible_worktree_entries.len() <= range.start {
1008                ix += visible_worktree_entries.len();
1009                continue;
1010            }
1011
1012            let end_ix = range.end.min(ix + visible_worktree_entries.len());
1013            if let Some(worktree) = self.project.read(cx).worktree_for_id(*worktree_id, cx) {
1014                let snapshot = worktree.read(cx).snapshot();
1015                let root_name = OsStr::new(snapshot.root_name());
1016                let expanded_entry_ids = self
1017                    .expanded_dir_ids
1018                    .get(&snapshot.id())
1019                    .map(Vec::as_slice)
1020                    .unwrap_or(&[]);
1021
1022                let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
1023                for entry in &visible_worktree_entries[entry_range] {
1024                    let mut details = EntryDetails {
1025                        filename: entry
1026                            .path
1027                            .file_name()
1028                            .unwrap_or(root_name)
1029                            .to_string_lossy()
1030                            .to_string(),
1031                        path: entry.path.clone(),
1032                        depth: entry.path.components().count(),
1033                        kind: entry.kind,
1034                        is_ignored: entry.is_ignored,
1035                        is_expanded: expanded_entry_ids.binary_search(&entry.id).is_ok(),
1036                        is_selected: self.selection.map_or(false, |e| {
1037                            e.worktree_id == snapshot.id() && e.entry_id == entry.id
1038                        }),
1039                        is_editing: false,
1040                        is_processing: false,
1041                        is_cut: self
1042                            .clipboard_entry
1043                            .map_or(false, |e| e.is_cut() && e.entry_id() == entry.id),
1044                    };
1045
1046                    if let Some(edit_state) = &self.edit_state {
1047                        let is_edited_entry = if edit_state.is_new_entry {
1048                            entry.id == NEW_ENTRY_ID
1049                        } else {
1050                            entry.id == edit_state.entry_id
1051                        };
1052
1053                        if is_edited_entry {
1054                            if let Some(processing_filename) = &edit_state.processing_filename {
1055                                details.is_processing = true;
1056                                details.filename.clear();
1057                                details.filename.push_str(processing_filename);
1058                            } else {
1059                                if edit_state.is_new_entry {
1060                                    details.filename.clear();
1061                                }
1062                                details.is_editing = true;
1063                            }
1064                        }
1065                    }
1066
1067                    callback(entry.id, details, cx);
1068                }
1069            }
1070            ix = end_ix;
1071        }
1072    }
1073
1074    fn render_entry_visual_element<V: View>(
1075        details: &EntryDetails,
1076        editor: Option<&ViewHandle<Editor>>,
1077        padding: f32,
1078        row_container_style: ContainerStyle,
1079        style: &ProjectPanelEntry,
1080        cx: &mut ViewContext<V>,
1081    ) -> AnyElement<V> {
1082        let kind = details.kind;
1083        let show_editor = details.is_editing && !details.is_processing;
1084
1085        Flex::row()
1086            .with_child(
1087                if kind == EntryKind::Dir {
1088                    if details.is_expanded {
1089                        Svg::new("icons/chevron_down_8.svg").with_color(style.icon_color)
1090                    } else {
1091                        Svg::new("icons/chevron_right_8.svg").with_color(style.icon_color)
1092                    }
1093                    .constrained()
1094                } else {
1095                    Empty::new().constrained()
1096                }
1097                .with_max_width(style.icon_size)
1098                .with_max_height(style.icon_size)
1099                .aligned()
1100                .constrained()
1101                .with_width(style.icon_size),
1102            )
1103            .with_child(if show_editor && editor.is_some() {
1104                ChildView::new(editor.as_ref().unwrap(), cx)
1105                    .contained()
1106                    .with_margin_left(style.icon_spacing)
1107                    .aligned()
1108                    .left()
1109                    .flex(1.0, true)
1110                    .into_any()
1111            } else {
1112                Label::new(details.filename.clone(), style.text.clone())
1113                    .contained()
1114                    .with_margin_left(style.icon_spacing)
1115                    .aligned()
1116                    .left()
1117                    .into_any()
1118            })
1119            .constrained()
1120            .with_height(style.height)
1121            .contained()
1122            .with_style(row_container_style)
1123            .with_padding_left(padding)
1124            .into_any_named("project panel entry visual element")
1125    }
1126
1127    fn render_entry(
1128        entry_id: ProjectEntryId,
1129        details: EntryDetails,
1130        editor: &ViewHandle<Editor>,
1131        dragged_entry_destination: &mut Option<Arc<Path>>,
1132        theme: &theme::ProjectPanel,
1133        cx: &mut ViewContext<Self>,
1134    ) -> AnyElement<Self> {
1135        let kind = details.kind;
1136        let path = details.path.clone();
1137        let padding = theme.container.padding.left + details.depth as f32 * theme.indent_width;
1138
1139        let entry_style = if details.is_cut {
1140            &theme.cut_entry
1141        } else if details.is_ignored {
1142            &theme.ignored_entry
1143        } else {
1144            &theme.entry
1145        };
1146
1147        let show_editor = details.is_editing && !details.is_processing;
1148
1149        MouseEventHandler::<Self, _>::new(entry_id.to_usize(), cx, |state, cx| {
1150            let mut style = entry_style.style_for(state, details.is_selected).clone();
1151
1152            if cx
1153                .global::<DragAndDrop<Workspace>>()
1154                .currently_dragged::<ProjectEntryId>(cx.window_id())
1155                .is_some()
1156                && dragged_entry_destination
1157                    .as_ref()
1158                    .filter(|destination| details.path.starts_with(destination))
1159                    .is_some()
1160            {
1161                style = entry_style.active.clone().unwrap();
1162            }
1163
1164            let row_container_style = if show_editor {
1165                theme.filename_editor.container
1166            } else {
1167                style.container
1168            };
1169
1170            Self::render_entry_visual_element(
1171                &details,
1172                Some(editor),
1173                padding,
1174                row_container_style,
1175                &style,
1176                cx,
1177            )
1178        })
1179        .on_click(MouseButton::Left, move |event, this, cx| {
1180            if !show_editor {
1181                if kind == EntryKind::Dir {
1182                    this.toggle_expanded(entry_id, cx);
1183                } else {
1184                    this.open_entry(entry_id, event.click_count > 1, cx);
1185                }
1186            }
1187        })
1188        .on_down(MouseButton::Right, move |event, this, cx| {
1189            this.deploy_context_menu(event.position, entry_id, cx);
1190        })
1191        .on_up(MouseButton::Left, move |_, this, cx| {
1192            if let Some((_, dragged_entry)) = cx
1193                .global::<DragAndDrop<Workspace>>()
1194                .currently_dragged::<ProjectEntryId>(cx.window_id())
1195            {
1196                this.move_entry(
1197                    *dragged_entry,
1198                    entry_id,
1199                    matches!(details.kind, EntryKind::File(_)),
1200                    cx,
1201                );
1202            }
1203        })
1204        .on_move(move |_, this, cx| {
1205            if cx
1206                .global::<DragAndDrop<Workspace>>()
1207                .currently_dragged::<ProjectEntryId>(cx.window_id())
1208                .is_some()
1209            {
1210                this.dragged_entry_destination = if matches!(kind, EntryKind::File(_)) {
1211                    path.parent().map(|parent| Arc::from(parent))
1212                } else {
1213                    Some(path.clone())
1214                };
1215            }
1216        })
1217        .as_draggable(entry_id, {
1218            let row_container_style = theme.dragged_entry.container;
1219
1220            move |_, cx: &mut ViewContext<Workspace>| {
1221                let theme = cx.global::<Settings>().theme.clone();
1222                Self::render_entry_visual_element(
1223                    &details,
1224                    None,
1225                    padding,
1226                    row_container_style,
1227                    &theme.project_panel.dragged_entry,
1228                    cx,
1229                )
1230            }
1231        })
1232        .with_cursor_style(CursorStyle::PointingHand)
1233        .into_any_named("project panel entry")
1234    }
1235}
1236
1237impl View for ProjectPanel {
1238    fn ui_name() -> &'static str {
1239        "ProjectPanel"
1240    }
1241
1242    fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> gpui::AnyElement<Self> {
1243        enum ProjectPanel {}
1244        let theme = &cx.global::<Settings>().theme.project_panel;
1245        let mut container_style = theme.container;
1246        let padding = std::mem::take(&mut container_style.padding);
1247        let last_worktree_root_id = self.last_worktree_root_id;
1248
1249        let has_worktree = self.visible_entries.len() != 0;
1250
1251        if has_worktree {
1252            Stack::new()
1253                .with_child(
1254                    MouseEventHandler::<ProjectPanel, _>::new(0, cx, |_, cx| {
1255                        UniformList::new(
1256                            self.list.clone(),
1257                            self.visible_entries
1258                                .iter()
1259                                .map(|(_, worktree_entries)| worktree_entries.len())
1260                                .sum(),
1261                            cx,
1262                            move |this, range, items, cx| {
1263                                let theme = cx.global::<Settings>().theme.clone();
1264                                let mut dragged_entry_destination =
1265                                    this.dragged_entry_destination.clone();
1266                                this.for_each_visible_entry(range, cx, |id, details, cx| {
1267                                    items.push(Self::render_entry(
1268                                        id,
1269                                        details,
1270                                        &this.filename_editor,
1271                                        &mut dragged_entry_destination,
1272                                        &theme.project_panel,
1273                                        cx,
1274                                    ));
1275                                });
1276                                this.dragged_entry_destination = dragged_entry_destination;
1277                            },
1278                        )
1279                        .with_padding_top(padding.top)
1280                        .with_padding_bottom(padding.bottom)
1281                        .contained()
1282                        .with_style(container_style)
1283                        .expanded()
1284                    })
1285                    .on_down(MouseButton::Right, move |event, this, cx| {
1286                        // When deploying the context menu anywhere below the last project entry,
1287                        // act as if the user clicked the root of the last worktree.
1288                        if let Some(entry_id) = last_worktree_root_id {
1289                            this.deploy_context_menu(event.position, entry_id, cx);
1290                        }
1291                    }),
1292                )
1293                .with_child(ChildView::new(&self.context_menu, cx))
1294                .into_any_named("project panel")
1295        } else {
1296            Flex::column()
1297                .with_child(
1298                    MouseEventHandler::<Self, _>::new(2, cx, {
1299                        let button_style = theme.open_project_button.clone();
1300                        let context_menu_item_style =
1301                            cx.global::<Settings>().theme.context_menu.item.clone();
1302                        move |state, cx| {
1303                            let button_style = button_style.style_for(state, false).clone();
1304                            let context_menu_item =
1305                                context_menu_item_style.style_for(state, true).clone();
1306
1307                            theme::ui::keystroke_label(
1308                                "Open a project",
1309                                &button_style,
1310                                &context_menu_item.keystroke,
1311                                Box::new(workspace::Open),
1312                                cx,
1313                            )
1314                        }
1315                    })
1316                    .on_click(MouseButton::Left, move |_, this, cx| {
1317                        if let Some(workspace) = this.workspace.upgrade(cx) {
1318                            workspace.update(cx, |workspace, cx| {
1319                                if let Some(task) = workspace.open(&Default::default(), cx) {
1320                                    task.detach_and_log_err(cx);
1321                                }
1322                            })
1323                        }
1324                    })
1325                    .with_cursor_style(CursorStyle::PointingHand),
1326                )
1327                .contained()
1328                .with_style(container_style)
1329                .into_any_named("empty project panel")
1330        }
1331    }
1332
1333    fn update_keymap_context(&self, keymap: &mut KeymapContext, _: &AppContext) {
1334        Self::reset_to_default_keymap_context(keymap);
1335        keymap.add_identifier("menu");
1336    }
1337}
1338
1339impl Entity for ProjectPanel {
1340    type Event = Event;
1341}
1342
1343impl workspace::dock::Panel for ProjectPanel {
1344    fn position(&self, cx: &gpui::WindowContext) -> DockPosition {
1345        cx.global::<Settings>()
1346            .project_panel_overrides
1347            .dock
1348            .map(Into::into)
1349            .unwrap_or(DockPosition::Left)
1350    }
1351
1352    fn position_is_valid(&self, position: DockPosition) -> bool {
1353        matches!(position, DockPosition::Left | DockPosition::Right)
1354    }
1355
1356    fn icon_path(&self) -> &'static str {
1357        "icons/folder_tree_16.svg"
1358    }
1359
1360    fn icon_tooltip(&self) -> String {
1361        "Project Panel".into()
1362    }
1363
1364    fn should_change_position_on_event(event: &Self::Event) -> bool {
1365        matches!(event, Event::DockPositionChanged)
1366    }
1367
1368    fn should_activate_on_event(&self, _: &Self::Event, _: &AppContext) -> bool {
1369        false
1370    }
1371
1372    fn should_close_on_event(&self, _: &Self::Event, _: &AppContext) -> bool {
1373        false
1374    }
1375}
1376
1377impl ClipboardEntry {
1378    fn is_cut(&self) -> bool {
1379        matches!(self, Self::Cut { .. })
1380    }
1381
1382    fn entry_id(&self) -> ProjectEntryId {
1383        match self {
1384            ClipboardEntry::Copied { entry_id, .. } | ClipboardEntry::Cut { entry_id, .. } => {
1385                *entry_id
1386            }
1387        }
1388    }
1389
1390    fn worktree_id(&self) -> WorktreeId {
1391        match self {
1392            ClipboardEntry::Copied { worktree_id, .. }
1393            | ClipboardEntry::Cut { worktree_id, .. } => *worktree_id,
1394        }
1395    }
1396}
1397
1398#[cfg(test)]
1399mod tests {
1400    use super::*;
1401    use gpui::{TestAppContext, ViewHandle};
1402    use project::FakeFs;
1403    use serde_json::json;
1404    use std::{collections::HashSet, path::Path};
1405
1406    #[gpui::test]
1407    async fn test_visible_list(cx: &mut gpui::TestAppContext) {
1408        cx.foreground().forbid_parking();
1409        cx.update(|cx| {
1410            let settings = Settings::test(cx);
1411            cx.set_global(settings);
1412        });
1413
1414        let fs = FakeFs::new(cx.background());
1415        fs.insert_tree(
1416            "/root1",
1417            json!({
1418                ".dockerignore": "",
1419                ".git": {
1420                    "HEAD": "",
1421                },
1422                "a": {
1423                    "0": { "q": "", "r": "", "s": "" },
1424                    "1": { "t": "", "u": "" },
1425                    "2": { "v": "", "w": "", "x": "", "y": "" },
1426                },
1427                "b": {
1428                    "3": { "Q": "" },
1429                    "4": { "R": "", "S": "", "T": "", "U": "" },
1430                },
1431                "C": {
1432                    "5": {},
1433                    "6": { "V": "", "W": "" },
1434                    "7": { "X": "" },
1435                    "8": { "Y": {}, "Z": "" }
1436                }
1437            }),
1438        )
1439        .await;
1440        fs.insert_tree(
1441            "/root2",
1442            json!({
1443                "d": {
1444                    "9": ""
1445                },
1446                "e": {}
1447            }),
1448        )
1449        .await;
1450
1451        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1452        let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1453        let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
1454        assert_eq!(
1455            visible_entries_as_strings(&panel, 0..50, cx),
1456            &[
1457                "v root1",
1458                "    > .git",
1459                "    > a",
1460                "    > b",
1461                "    > C",
1462                "      .dockerignore",
1463                "v root2",
1464                "    > d",
1465                "    > e",
1466            ]
1467        );
1468
1469        toggle_expand_dir(&panel, "root1/b", cx);
1470        assert_eq!(
1471            visible_entries_as_strings(&panel, 0..50, cx),
1472            &[
1473                "v root1",
1474                "    > .git",
1475                "    > a",
1476                "    v b  <== selected",
1477                "        > 3",
1478                "        > 4",
1479                "    > C",
1480                "      .dockerignore",
1481                "v root2",
1482                "    > d",
1483                "    > e",
1484            ]
1485        );
1486
1487        assert_eq!(
1488            visible_entries_as_strings(&panel, 6..9, cx),
1489            &[
1490                //
1491                "    > C",
1492                "      .dockerignore",
1493                "v root2",
1494            ]
1495        );
1496    }
1497
1498    #[gpui::test(iterations = 30)]
1499    async fn test_editing_files(cx: &mut gpui::TestAppContext) {
1500        cx.foreground().forbid_parking();
1501        cx.update(|cx| {
1502            let settings = Settings::test(cx);
1503            cx.set_global(settings);
1504        });
1505
1506        let fs = FakeFs::new(cx.background());
1507        fs.insert_tree(
1508            "/root1",
1509            json!({
1510                ".dockerignore": "",
1511                ".git": {
1512                    "HEAD": "",
1513                },
1514                "a": {
1515                    "0": { "q": "", "r": "", "s": "" },
1516                    "1": { "t": "", "u": "" },
1517                    "2": { "v": "", "w": "", "x": "", "y": "" },
1518                },
1519                "b": {
1520                    "3": { "Q": "" },
1521                    "4": { "R": "", "S": "", "T": "", "U": "" },
1522                },
1523                "C": {
1524                    "5": {},
1525                    "6": { "V": "", "W": "" },
1526                    "7": { "X": "" },
1527                    "8": { "Y": {}, "Z": "" }
1528                }
1529            }),
1530        )
1531        .await;
1532        fs.insert_tree(
1533            "/root2",
1534            json!({
1535                "d": {
1536                    "9": ""
1537                },
1538                "e": {}
1539            }),
1540        )
1541        .await;
1542
1543        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1544        let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1545        let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
1546
1547        select_path(&panel, "root1", cx);
1548        assert_eq!(
1549            visible_entries_as_strings(&panel, 0..10, cx),
1550            &[
1551                "v root1  <== selected",
1552                "    > .git",
1553                "    > a",
1554                "    > b",
1555                "    > C",
1556                "      .dockerignore",
1557                "v root2",
1558                "    > d",
1559                "    > e",
1560            ]
1561        );
1562
1563        // Add a file with the root folder selected. The filename editor is placed
1564        // before the first file in the root folder.
1565        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
1566        cx.read_window(window_id, |cx| {
1567            let panel = panel.read(cx);
1568            assert!(panel.filename_editor.is_focused(cx));
1569        });
1570        assert_eq!(
1571            visible_entries_as_strings(&panel, 0..10, cx),
1572            &[
1573                "v root1",
1574                "    > .git",
1575                "    > a",
1576                "    > b",
1577                "    > C",
1578                "      [EDITOR: '']  <== selected",
1579                "      .dockerignore",
1580                "v root2",
1581                "    > d",
1582                "    > e",
1583            ]
1584        );
1585
1586        let confirm = panel.update(cx, |panel, cx| {
1587            panel
1588                .filename_editor
1589                .update(cx, |editor, cx| editor.set_text("the-new-filename", cx));
1590            panel.confirm(&Confirm, cx).unwrap()
1591        });
1592        assert_eq!(
1593            visible_entries_as_strings(&panel, 0..10, cx),
1594            &[
1595                "v root1",
1596                "    > .git",
1597                "    > a",
1598                "    > b",
1599                "    > C",
1600                "      [PROCESSING: 'the-new-filename']  <== selected",
1601                "      .dockerignore",
1602                "v root2",
1603                "    > d",
1604                "    > e",
1605            ]
1606        );
1607
1608        confirm.await.unwrap();
1609        assert_eq!(
1610            visible_entries_as_strings(&panel, 0..10, cx),
1611            &[
1612                "v root1",
1613                "    > .git",
1614                "    > a",
1615                "    > b",
1616                "    > C",
1617                "      .dockerignore",
1618                "      the-new-filename  <== selected",
1619                "v root2",
1620                "    > d",
1621                "    > e",
1622            ]
1623        );
1624
1625        select_path(&panel, "root1/b", cx);
1626        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
1627        assert_eq!(
1628            visible_entries_as_strings(&panel, 0..10, cx),
1629            &[
1630                "v root1",
1631                "    > .git",
1632                "    > a",
1633                "    v b",
1634                "        > 3",
1635                "        > 4",
1636                "          [EDITOR: '']  <== selected",
1637                "    > C",
1638                "      .dockerignore",
1639                "      the-new-filename",
1640            ]
1641        );
1642
1643        panel
1644            .update(cx, |panel, cx| {
1645                panel
1646                    .filename_editor
1647                    .update(cx, |editor, cx| editor.set_text("another-filename", cx));
1648                panel.confirm(&Confirm, cx).unwrap()
1649            })
1650            .await
1651            .unwrap();
1652        assert_eq!(
1653            visible_entries_as_strings(&panel, 0..10, cx),
1654            &[
1655                "v root1",
1656                "    > .git",
1657                "    > a",
1658                "    v b",
1659                "        > 3",
1660                "        > 4",
1661                "          another-filename  <== selected",
1662                "    > C",
1663                "      .dockerignore",
1664                "      the-new-filename",
1665            ]
1666        );
1667
1668        select_path(&panel, "root1/b/another-filename", cx);
1669        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
1670        assert_eq!(
1671            visible_entries_as_strings(&panel, 0..10, cx),
1672            &[
1673                "v root1",
1674                "    > .git",
1675                "    > a",
1676                "    v b",
1677                "        > 3",
1678                "        > 4",
1679                "          [EDITOR: 'another-filename']  <== selected",
1680                "    > C",
1681                "      .dockerignore",
1682                "      the-new-filename",
1683            ]
1684        );
1685
1686        let confirm = panel.update(cx, |panel, cx| {
1687            panel
1688                .filename_editor
1689                .update(cx, |editor, cx| editor.set_text("a-different-filename", cx));
1690            panel.confirm(&Confirm, cx).unwrap()
1691        });
1692        assert_eq!(
1693            visible_entries_as_strings(&panel, 0..10, cx),
1694            &[
1695                "v root1",
1696                "    > .git",
1697                "    > a",
1698                "    v b",
1699                "        > 3",
1700                "        > 4",
1701                "          [PROCESSING: 'a-different-filename']  <== selected",
1702                "    > C",
1703                "      .dockerignore",
1704                "      the-new-filename",
1705            ]
1706        );
1707
1708        confirm.await.unwrap();
1709        assert_eq!(
1710            visible_entries_as_strings(&panel, 0..10, cx),
1711            &[
1712                "v root1",
1713                "    > .git",
1714                "    > a",
1715                "    v b",
1716                "        > 3",
1717                "        > 4",
1718                "          a-different-filename  <== selected",
1719                "    > C",
1720                "      .dockerignore",
1721                "      the-new-filename",
1722            ]
1723        );
1724
1725        panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
1726        assert_eq!(
1727            visible_entries_as_strings(&panel, 0..10, cx),
1728            &[
1729                "v root1",
1730                "    > .git",
1731                "    > a",
1732                "    v b",
1733                "        > [EDITOR: '']  <== selected",
1734                "        > 3",
1735                "        > 4",
1736                "          a-different-filename",
1737                "    > C",
1738                "      .dockerignore",
1739            ]
1740        );
1741
1742        let confirm = panel.update(cx, |panel, cx| {
1743            panel
1744                .filename_editor
1745                .update(cx, |editor, cx| editor.set_text("new-dir", cx));
1746            panel.confirm(&Confirm, cx).unwrap()
1747        });
1748        panel.update(cx, |panel, cx| panel.select_next(&Default::default(), cx));
1749        assert_eq!(
1750            visible_entries_as_strings(&panel, 0..10, cx),
1751            &[
1752                "v root1",
1753                "    > .git",
1754                "    > a",
1755                "    v b",
1756                "        > [PROCESSING: 'new-dir']",
1757                "        > 3  <== selected",
1758                "        > 4",
1759                "          a-different-filename",
1760                "    > C",
1761                "      .dockerignore",
1762            ]
1763        );
1764
1765        confirm.await.unwrap();
1766        assert_eq!(
1767            visible_entries_as_strings(&panel, 0..10, cx),
1768            &[
1769                "v root1",
1770                "    > .git",
1771                "    > a",
1772                "    v b",
1773                "        > 3  <== selected",
1774                "        > 4",
1775                "        > new-dir",
1776                "          a-different-filename",
1777                "    > C",
1778                "      .dockerignore",
1779            ]
1780        );
1781
1782        panel.update(cx, |panel, cx| panel.rename(&Default::default(), cx));
1783        assert_eq!(
1784            visible_entries_as_strings(&panel, 0..10, cx),
1785            &[
1786                "v root1",
1787                "    > .git",
1788                "    > a",
1789                "    v b",
1790                "        > [EDITOR: '3']  <== selected",
1791                "        > 4",
1792                "        > new-dir",
1793                "          a-different-filename",
1794                "    > C",
1795                "      .dockerignore",
1796            ]
1797        );
1798
1799        // Dismiss the rename editor when it loses focus.
1800        workspace.update(cx, |_, cx| cx.focus_self());
1801        assert_eq!(
1802            visible_entries_as_strings(&panel, 0..10, cx),
1803            &[
1804                "v root1",
1805                "    > .git",
1806                "    > a",
1807                "    v b",
1808                "        > 3  <== selected",
1809                "        > 4",
1810                "        > new-dir",
1811                "          a-different-filename",
1812                "    > C",
1813                "      .dockerignore",
1814            ]
1815        );
1816    }
1817
1818    #[gpui::test]
1819    async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
1820        cx.foreground().forbid_parking();
1821        cx.update(|cx| {
1822            let settings = Settings::test(cx);
1823            cx.set_global(settings);
1824        });
1825
1826        let fs = FakeFs::new(cx.background());
1827        fs.insert_tree(
1828            "/root1",
1829            json!({
1830                "one.two.txt": "",
1831                "one.txt": ""
1832            }),
1833        )
1834        .await;
1835
1836        let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
1837        let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1838        let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
1839
1840        panel.update(cx, |panel, cx| {
1841            panel.select_next(&Default::default(), cx);
1842            panel.select_next(&Default::default(), cx);
1843        });
1844
1845        assert_eq!(
1846            visible_entries_as_strings(&panel, 0..50, cx),
1847            &[
1848                //
1849                "v root1",
1850                "      one.two.txt  <== selected",
1851                "      one.txt",
1852            ]
1853        );
1854
1855        // Regression test - file name is created correctly when
1856        // the copied file's name contains multiple dots.
1857        panel.update(cx, |panel, cx| {
1858            panel.copy(&Default::default(), cx);
1859            panel.paste(&Default::default(), cx);
1860        });
1861        cx.foreground().run_until_parked();
1862
1863        assert_eq!(
1864            visible_entries_as_strings(&panel, 0..50, cx),
1865            &[
1866                //
1867                "v root1",
1868                "      one.two copy.txt",
1869                "      one.two.txt  <== selected",
1870                "      one.txt",
1871            ]
1872        );
1873
1874        panel.update(cx, |panel, cx| {
1875            panel.paste(&Default::default(), cx);
1876        });
1877        cx.foreground().run_until_parked();
1878
1879        assert_eq!(
1880            visible_entries_as_strings(&panel, 0..50, cx),
1881            &[
1882                //
1883                "v root1",
1884                "      one.two copy 1.txt",
1885                "      one.two copy.txt",
1886                "      one.two.txt  <== selected",
1887                "      one.txt",
1888            ]
1889        );
1890    }
1891
1892    fn toggle_expand_dir(
1893        panel: &ViewHandle<ProjectPanel>,
1894        path: impl AsRef<Path>,
1895        cx: &mut TestAppContext,
1896    ) {
1897        let path = path.as_ref();
1898        panel.update(cx, |panel, cx| {
1899            for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
1900                let worktree = worktree.read(cx);
1901                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
1902                    let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
1903                    panel.toggle_expanded(entry_id, cx);
1904                    return;
1905                }
1906            }
1907            panic!("no worktree for path {:?}", path);
1908        });
1909    }
1910
1911    fn select_path(
1912        panel: &ViewHandle<ProjectPanel>,
1913        path: impl AsRef<Path>,
1914        cx: &mut TestAppContext,
1915    ) {
1916        let path = path.as_ref();
1917        panel.update(cx, |panel, cx| {
1918            for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
1919                let worktree = worktree.read(cx);
1920                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
1921                    let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
1922                    panel.selection = Some(Selection {
1923                        worktree_id: worktree.id(),
1924                        entry_id,
1925                    });
1926                    return;
1927                }
1928            }
1929            panic!("no worktree for path {:?}", path);
1930        });
1931    }
1932
1933    fn visible_entries_as_strings(
1934        panel: &ViewHandle<ProjectPanel>,
1935        range: Range<usize>,
1936        cx: &mut TestAppContext,
1937    ) -> Vec<String> {
1938        let mut result = Vec::new();
1939        let mut project_entries = HashSet::new();
1940        let mut has_editor = false;
1941
1942        panel.update(cx, |panel, cx| {
1943            panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
1944                if details.is_editing {
1945                    assert!(!has_editor, "duplicate editor entry");
1946                    has_editor = true;
1947                } else {
1948                    assert!(
1949                        project_entries.insert(project_entry),
1950                        "duplicate project entry {:?} {:?}",
1951                        project_entry,
1952                        details
1953                    );
1954                }
1955
1956                let indent = "    ".repeat(details.depth);
1957                let icon = if matches!(details.kind, EntryKind::Dir | EntryKind::PendingDir) {
1958                    if details.is_expanded {
1959                        "v "
1960                    } else {
1961                        "> "
1962                    }
1963                } else {
1964                    "  "
1965                };
1966                let name = if details.is_editing {
1967                    format!("[EDITOR: '{}']", details.filename)
1968                } else if details.is_processing {
1969                    format!("[PROCESSING: '{}']", details.filename)
1970                } else {
1971                    details.filename.clone()
1972                };
1973                let selected = if details.is_selected {
1974                    "  <== selected"
1975                } else {
1976                    ""
1977                };
1978                result.push(format!("{indent}{icon}{name}{selected}"));
1979            });
1980        });
1981
1982        result
1983    }
1984}