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