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